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,158 @@
|
|
|
1
|
+
package orcacli
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"runtime"
|
|
9
|
+
"strings"
|
|
10
|
+
"testing"
|
|
11
|
+
"time"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
func TestLauncherSelection(t *testing.T) {
|
|
15
|
+
for _, tc := range []struct {
|
|
16
|
+
os, selector, devRoot, want string
|
|
17
|
+
}{
|
|
18
|
+
{"linux", "", "", "orca-ide"},
|
|
19
|
+
{"darwin", "", "", "orca"},
|
|
20
|
+
{"windows", "", "", "orca"},
|
|
21
|
+
{"linux", "", "/dev/orca", "orca-dev"},
|
|
22
|
+
{"darwin", "", "/dev/orca", "orca-dev"},
|
|
23
|
+
{"linux", "/session/orca.exe", "/dev/orca", "/session/orca.exe"},
|
|
24
|
+
{"darwin", "session-orca", "", "session-orca"},
|
|
25
|
+
} {
|
|
26
|
+
if got := selectCommand(tc.os, tc.selector, tc.devRoot); got != tc.want {
|
|
27
|
+
t.Errorf("selectCommand(%q, %q, %q) = %q, want %q", tc.os, tc.selector, tc.devRoot, got, tc.want)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func installStartupLauncher(t *testing.T, name, script string) string {
|
|
33
|
+
t.Helper()
|
|
34
|
+
bin := t.TempDir()
|
|
35
|
+
path := filepath.Join(bin, name)
|
|
36
|
+
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+script), 0o755); err != nil {
|
|
37
|
+
t.Fatal(err)
|
|
38
|
+
}
|
|
39
|
+
t.Setenv("PATH", bin)
|
|
40
|
+
t.Setenv("ORCA_CLI_COMMAND", name)
|
|
41
|
+
t.Setenv("ORCA_DEV_REPO_ROOT", "")
|
|
42
|
+
return path
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
func TestSelectedLauncherIsResolvedOnceForAllCalls(t *testing.T) {
|
|
46
|
+
fake := installStrictFakeOrca(t)
|
|
47
|
+
cli := New()
|
|
48
|
+
if cli.executable != fake {
|
|
49
|
+
t.Fatalf("resolved launcher = %q, want %q", cli.executable, fake)
|
|
50
|
+
}
|
|
51
|
+
// Neither a client-side selector nor PATH change replaces the server's CLI.
|
|
52
|
+
t.Setenv("ORCA_CLI_COMMAND", "nonexistent-other-orca")
|
|
53
|
+
t.Setenv("PATH", t.TempDir()+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
54
|
+
if _, err := cli.ListRepos(context.Background()); err != nil {
|
|
55
|
+
t.Fatal(err)
|
|
56
|
+
}
|
|
57
|
+
if _, err := cli.ListWorktrees(context.Background()); err != nil {
|
|
58
|
+
t.Fatal(err)
|
|
59
|
+
}
|
|
60
|
+
if err := cli.SendTerminal(context.Background(), "term-1", "hello"); err != nil {
|
|
61
|
+
t.Fatal(err)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func TestMissingSelectedLauncherNeverFallsBack(t *testing.T) {
|
|
66
|
+
installStartupLauncher(t, "orca", "printf '%s' '{\"result\":{\"repos\":[]}}'\n")
|
|
67
|
+
t.Setenv("ORCA_CLI_COMMAND", "missing-session-launcher")
|
|
68
|
+
_, err := New().ListRepos(context.Background())
|
|
69
|
+
if err == nil || !strings.Contains(err.Error(), "missing executable") || !strings.Contains(err.Error(), "missing-session-launcher") {
|
|
70
|
+
t.Fatalf("missing selector = %v", err)
|
|
71
|
+
}
|
|
72
|
+
if runtime.GOOS == "linux" {
|
|
73
|
+
t.Setenv("ORCA_CLI_COMMAND", "")
|
|
74
|
+
_, err = New().ListRepos(context.Background())
|
|
75
|
+
if err == nil || !strings.Contains(err.Error(), "orca-ide") {
|
|
76
|
+
t.Fatalf("Linux must not invoke bare orca: %v", err)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
func TestRepoListAcceptsEmptyArrayAndRejectsMalformedResponses(t *testing.T) {
|
|
82
|
+
for _, tc := range []struct {
|
|
83
|
+
response string
|
|
84
|
+
valid bool
|
|
85
|
+
}{
|
|
86
|
+
{`{"result":{"repos":[]}}`, true},
|
|
87
|
+
{`{}`, false},
|
|
88
|
+
{`null`, false},
|
|
89
|
+
{`{"result":null}`, false},
|
|
90
|
+
{`{"result":{}}`, false},
|
|
91
|
+
{`{"result":{"repos":null}}`, false},
|
|
92
|
+
{`{"result":{"repos":{}}}`, false},
|
|
93
|
+
{`not-json`, false},
|
|
94
|
+
} {
|
|
95
|
+
t.Run(tc.response, func(t *testing.T) {
|
|
96
|
+
installStartupLauncher(t, "selected-orca", "printf '%s' '"+tc.response+"'\n")
|
|
97
|
+
repos, err := New().ListRepos(context.Background())
|
|
98
|
+
if tc.valid {
|
|
99
|
+
if err != nil || repos == nil || len(repos) != 0 {
|
|
100
|
+
t.Fatalf("empty runtime = %v, %v", repos, err)
|
|
101
|
+
}
|
|
102
|
+
} else if err == nil || !strings.Contains(err.Error(), "malformed") {
|
|
103
|
+
t.Fatalf("malformed response = %v", err)
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func TestRepoListReportsLaunchAndRuntimeFailures(t *testing.T) {
|
|
110
|
+
t.Run("launch", func(t *testing.T) {
|
|
111
|
+
path := installStartupLauncher(t, "broken-orca", "")
|
|
112
|
+
if err := os.WriteFile(path, []byte("#!/nonexistent/interpreter\n"), 0o755); err != nil {
|
|
113
|
+
t.Fatal(err)
|
|
114
|
+
}
|
|
115
|
+
_, err := New().ListRepos(context.Background())
|
|
116
|
+
// The selected executable exists, but its interpreter cannot launch.
|
|
117
|
+
if err == nil || !strings.Contains(err.Error(), "launch failure") || !strings.Contains(err.Error(), path) {
|
|
118
|
+
t.Fatalf("launch error = %v", err)
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
t.Run("connection", func(t *testing.T) {
|
|
122
|
+
installStartupLauncher(t, "selected-orca", "printf '%s' '{\"error\":{\"code\":\"connection_refused\",\"message\":\"runtime unavailable\"}}'\nexit 1\n")
|
|
123
|
+
_, err := New().ListRepos(context.Background())
|
|
124
|
+
if err == nil || !strings.Contains(err.Error(), "connection_refused") || !strings.Contains(err.Error(), "repo list") {
|
|
125
|
+
t.Fatalf("connection error = %v", err)
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
t.Run("stderr", func(t *testing.T) {
|
|
129
|
+
installStartupLauncher(t, "selected-orca", "printf '%s' 'authentication required' >&2\nexit 1\n")
|
|
130
|
+
_, err := New().ListRepos(context.Background())
|
|
131
|
+
if err == nil || !strings.Contains(err.Error(), "authentication required") {
|
|
132
|
+
t.Fatalf("stderr error = %v", err)
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
func TestRepoListCancellationBoundsInheritedOutputPipes(t *testing.T) {
|
|
138
|
+
// The background descendant retains both pipes after the launcher is killed.
|
|
139
|
+
installStartupLauncher(t, "selected-orca", "/bin/sleep 2 &\nwait\n")
|
|
140
|
+
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
|
|
141
|
+
defer cancel()
|
|
142
|
+
start := time.Now()
|
|
143
|
+
_, err := New().ListRepos(ctx)
|
|
144
|
+
if !errors.Is(err, context.DeadlineExceeded) || !strings.Contains(err.Error(), "timeout") {
|
|
145
|
+
t.Fatalf("timeout = %v", err)
|
|
146
|
+
}
|
|
147
|
+
if elapsed := time.Since(start); elapsed > time.Second {
|
|
148
|
+
t.Fatalf("inherited pipes defeated deadline: %v", elapsed)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
func TestRuntimeFailureDoesNotExposeCommandArguments(t *testing.T) {
|
|
153
|
+
installStartupLauncher(t, "selected-orca", "exit 1\n")
|
|
154
|
+
err := New().SendTerminal(context.Background(), "term-1", "SECRET PROMPT")
|
|
155
|
+
if err == nil || strings.Contains(err.Error(), "SECRET PROMPT") || !strings.Contains(err.Error(), "terminal send") {
|
|
156
|
+
t.Fatalf("unsafe operation error = %v", err)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
package orca
|
|
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/orca/orcacli"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
type startupCLI struct {
|
|
13
|
+
*fakeCLI
|
|
14
|
+
calls int
|
|
15
|
+
err error
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func (c *startupCLI) ListRepos(ctx context.Context) ([]orcacli.Repo, error) {
|
|
19
|
+
c.calls++
|
|
20
|
+
if ctx.Err() != nil {
|
|
21
|
+
return nil, ctx.Err()
|
|
22
|
+
}
|
|
23
|
+
return c.repos, c.err
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func TestStartupProbeReadsRepoListOnceWithoutWrites(t *testing.T) {
|
|
27
|
+
for _, repos := range [][]orcacli.Repo{nil, {{ID: "r1", Path: "/work/app", DisplayName: "app"}}} {
|
|
28
|
+
c := &startupCLI{fakeCLI: &fakeCLI{repos: repos}}
|
|
29
|
+
a, err := New(c, nil)
|
|
30
|
+
if err != nil {
|
|
31
|
+
t.Fatal(err)
|
|
32
|
+
}
|
|
33
|
+
if err := runner.ProbeStartup(context.Background(), a); err != nil {
|
|
34
|
+
t.Fatal(err)
|
|
35
|
+
}
|
|
36
|
+
if c.calls != 1 || c.createN != 0 || len(c.closedHandles) != 0 || len(c.deletedWorktrees) != 0 || len(c.worktrees) != 0 || c.status != "" {
|
|
37
|
+
t.Fatalf("unexpected startup effects: %+v", c)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func TestStartupProbePropagatesFailureAndCancellation(t *testing.T) {
|
|
43
|
+
failure := errors.New("orca connection unavailable")
|
|
44
|
+
c := &startupCLI{fakeCLI: &fakeCLI{}, err: failure}
|
|
45
|
+
a, err := New(c, nil)
|
|
46
|
+
if err != nil {
|
|
47
|
+
t.Fatal(err)
|
|
48
|
+
}
|
|
49
|
+
if err := runner.ProbeStartup(context.Background(), a); !errors.Is(err, failure) || c.calls != 1 {
|
|
50
|
+
t.Fatalf("failure = %v, calls=%d", err, c.calls)
|
|
51
|
+
}
|
|
52
|
+
ctx, cancel := context.WithCancel(context.Background())
|
|
53
|
+
cancel()
|
|
54
|
+
if err := runner.ProbeStartup(ctx, a); !errors.Is(err, context.Canceled) || c.calls != 2 {
|
|
55
|
+
t.Fatalf("cancellation = %v, calls=%d", err, c.calls)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
package runner
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"time"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// StartupProber is the machine-level readiness boundary. Selected runners
|
|
10
|
+
// must implement it; repo validation and discovery remain separate operations.
|
|
11
|
+
// A probe performs one read-only runtime request, with no retries or writes.
|
|
12
|
+
type StartupProber interface {
|
|
13
|
+
ProbeStartup(context.Context) error
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ProbeStartup checks the selected runner once, within the caller's aggregate
|
|
17
|
+
// startup budget and a five-second per-probe ceiling. Missing probe support is
|
|
18
|
+
// an error, never permission to skip a selected dependency.
|
|
19
|
+
func ProbeStartup(ctx context.Context, selected Runner) error {
|
|
20
|
+
probe, ok := selected.(StartupProber)
|
|
21
|
+
if !ok {
|
|
22
|
+
return fmt.Errorf("selected runner does not implement startup probing")
|
|
23
|
+
}
|
|
24
|
+
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
25
|
+
defer cancel()
|
|
26
|
+
return probe.ProbeStartup(ctx)
|
|
27
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
package runner_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
type startupRunner struct {
|
|
14
|
+
*fakeRunner
|
|
15
|
+
calls int
|
|
16
|
+
probe func(context.Context) error
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func (r *startupRunner) ProbeStartup(ctx context.Context) error {
|
|
20
|
+
r.calls++
|
|
21
|
+
return r.probe(ctx)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func TestStartupProbeRequiresSelectedRunnerSupport(t *testing.T) {
|
|
25
|
+
if err := runner.ProbeStartup(context.Background(), newFakeRunner()); err == nil || !strings.Contains(err.Error(), "startup probing") {
|
|
26
|
+
t.Fatalf("unsupported selected runner = %v", err)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func TestStartupProbeIsSingleAttemptWithBoundedDeadline(t *testing.T) {
|
|
31
|
+
failure := errors.New("runtime unavailable")
|
|
32
|
+
r := &startupRunner{fakeRunner: newFakeRunner(), probe: func(ctx context.Context) error {
|
|
33
|
+
deadline, ok := ctx.Deadline()
|
|
34
|
+
if !ok || time.Until(deadline) > 5*time.Second {
|
|
35
|
+
t.Fatalf("probe deadline = %v, %v", deadline, ok)
|
|
36
|
+
}
|
|
37
|
+
return failure
|
|
38
|
+
}}
|
|
39
|
+
if err := runner.ProbeStartup(context.Background(), r); !errors.Is(err, failure) || r.calls != 1 {
|
|
40
|
+
t.Fatalf("ProbeStartup = %v, calls=%d", err, r.calls)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func TestStartupProbeHonorsEarlierAggregateDeadline(t *testing.T) {
|
|
45
|
+
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
|
|
46
|
+
defer cancel()
|
|
47
|
+
deadline, _ := ctx.Deadline()
|
|
48
|
+
r := &startupRunner{fakeRunner: newFakeRunner(), probe: func(probeCtx context.Context) error {
|
|
49
|
+
got, _ := probeCtx.Deadline()
|
|
50
|
+
if !got.Equal(deadline) {
|
|
51
|
+
t.Fatalf("probe extended aggregate deadline: %v > %v", got, deadline)
|
|
52
|
+
}
|
|
53
|
+
<-probeCtx.Done()
|
|
54
|
+
return probeCtx.Err()
|
|
55
|
+
}}
|
|
56
|
+
if err := runner.ProbeStartup(ctx, r); !errors.Is(err, context.DeadlineExceeded) || r.calls != 1 {
|
|
57
|
+
t.Fatalf("ProbeStartup = %v, calls=%d", err, r.calls)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -16,6 +16,7 @@ import (
|
|
|
16
16
|
"os/exec"
|
|
17
17
|
"strings"
|
|
18
18
|
"sync"
|
|
19
|
+
"time"
|
|
19
20
|
)
|
|
20
21
|
|
|
21
22
|
// CLI executes bd in one repository and one configured Beads workspace.
|
|
@@ -104,6 +105,12 @@ func (e *CommandError) Error() string {
|
|
|
104
105
|
return "bd command failed"
|
|
105
106
|
}
|
|
106
107
|
message := fmt.Sprintf("bd %v: exit status %d", e.Args, e.ExitCode)
|
|
108
|
+
if errors.Is(e.cause, context.DeadlineExceeded) || errors.Is(e.cause, context.Canceled) {
|
|
109
|
+
return fmt.Sprintf("bd %v: timeout/cancellation: %v", e.Args, e.cause)
|
|
110
|
+
}
|
|
111
|
+
if e.ExitCode == -1 && e.cause != nil {
|
|
112
|
+
message += ": launch failure: " + e.cause.Error()
|
|
113
|
+
}
|
|
107
114
|
if stderr := strings.TrimSpace(e.Stderr); stderr != "" {
|
|
108
115
|
message += ": " + stderr
|
|
109
116
|
}
|
|
@@ -241,6 +248,8 @@ func (c *CLI) run(ctx context.Context, args []string, stdin io.Reader) ([]byte,
|
|
|
241
248
|
defer c.mu.Unlock()
|
|
242
249
|
|
|
243
250
|
cmd := exec.CommandContext(ctx, "bd", args...)
|
|
251
|
+
// A launcher descendant retaining output pipes must not defeat deadlines.
|
|
252
|
+
cmd.WaitDelay = 100 * time.Millisecond
|
|
244
253
|
cmd.Dir = c.repoPath
|
|
245
254
|
cmd.Env = commandEnvironment(c.repoPath, c.beadsDir)
|
|
246
255
|
cmd.Stdin = stdin
|
|
@@ -249,6 +258,9 @@ func (c *CLI) run(ctx context.Context, args []string, stdin io.Reader) ([]byte,
|
|
|
249
258
|
cmd.Stdout = &stdout
|
|
250
259
|
cmd.Stderr = &stderr
|
|
251
260
|
if err := cmd.Run(); err != nil {
|
|
261
|
+
if ctx.Err() != nil {
|
|
262
|
+
err = ctx.Err()
|
|
263
|
+
}
|
|
252
264
|
exitCode := -1
|
|
253
265
|
var exitErr *exec.ExitError
|
|
254
266
|
if errors.As(err, &exitErr) && exitErr.ProcessState != nil {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
package bdcli
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os/exec"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// CheckExecutable is a local prerequisite check, valid without a configured
|
|
10
|
+
// workspace. It never invokes bd or lets an ambient workspace act as a probe.
|
|
11
|
+
func CheckExecutable() error {
|
|
12
|
+
_, err := exec.LookPath("bd")
|
|
13
|
+
if errors.Is(err, exec.ErrNotFound) {
|
|
14
|
+
return fmt.Errorf("bd startup: missing executable \"bd\": %w", err)
|
|
15
|
+
}
|
|
16
|
+
if err != nil {
|
|
17
|
+
return fmt.Errorf("bd startup: resolve executable \"bd\": %w", err)
|
|
18
|
+
}
|
|
19
|
+
return nil
|
|
20
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
package bdcli
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestProbeDistinguishesSelectedBDLaunchFailure(t *testing.T) {
|
|
13
|
+
bin := t.TempDir()
|
|
14
|
+
path := filepath.Join(bin, "bd")
|
|
15
|
+
if err := os.WriteFile(path, []byte("#!/nonexistent/interpreter\n"), 0o755); err != nil {
|
|
16
|
+
t.Fatal(err)
|
|
17
|
+
}
|
|
18
|
+
t.Setenv("PATH", bin)
|
|
19
|
+
if err := CheckExecutable(); err != nil {
|
|
20
|
+
t.Fatalf("executable resolution unexpectedly launched bd: %v", err)
|
|
21
|
+
}
|
|
22
|
+
err := New(t.TempDir(), t.TempDir()).Probe(context.Background())
|
|
23
|
+
var commandErr *CommandError
|
|
24
|
+
if !errors.As(err, &commandErr) || !strings.Contains(err.Error(), "launch failure") || !strings.Contains(err.Error(), path) {
|
|
25
|
+
t.Fatalf("launch failure = %v", err)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
package beads
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"log/slog"
|
|
7
|
+
"sort"
|
|
8
|
+
"strings"
|
|
9
|
+
"time"
|
|
10
|
+
|
|
11
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/task/beads/bdcli"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// probeStartup checks only explicitly configured connections, not repository
|
|
16
|
+
// ownership, filters, tickets or history. A shared workspace is probed once.
|
|
17
|
+
func probeStartup(ctx context.Context, root config.RawValues, repos map[string]config.Repo) error {
|
|
18
|
+
if err := bdcli.CheckExecutable(); err != nil {
|
|
19
|
+
return err
|
|
20
|
+
}
|
|
21
|
+
rootCfg, err := decodeConfig(root)
|
|
22
|
+
if err != nil {
|
|
23
|
+
return fmt.Errorf("beads startup root config: %w", err)
|
|
24
|
+
}
|
|
25
|
+
workspaces := map[string]string{} // canonical workspace -> code cwd
|
|
26
|
+
names := make([]string, 0, len(repos))
|
|
27
|
+
for name := range repos {
|
|
28
|
+
names = append(names, name)
|
|
29
|
+
}
|
|
30
|
+
sort.Strings(names)
|
|
31
|
+
for _, name := range names {
|
|
32
|
+
registered := repos[name]
|
|
33
|
+
dir, err := configuredBeadsDir(root, registered.TaskConfig)
|
|
34
|
+
if err != nil {
|
|
35
|
+
return fmt.Errorf("beads startup repo %q: %w", name, err)
|
|
36
|
+
}
|
|
37
|
+
dir, err = canonicalBeadsDir(dir)
|
|
38
|
+
if err != nil {
|
|
39
|
+
return fmt.Errorf("beads startup repo %q workspace: %w", name, err)
|
|
40
|
+
}
|
|
41
|
+
if _, exists := workspaces[dir]; !exists {
|
|
42
|
+
workspaces[dir] = registered.Path
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// A root connection, when explicitly supplied, must not be silently skipped
|
|
46
|
+
// even without repos. Prefer an existing code cwd for a shared connection;
|
|
47
|
+
// otherwise use the explicitly configured workspace, never an ambient cwd.
|
|
48
|
+
if strings.TrimSpace(rootCfg.BeadsDir) != "" {
|
|
49
|
+
dir, err := canonicalBeadsDir(rootCfg.BeadsDir)
|
|
50
|
+
if err != nil {
|
|
51
|
+
return fmt.Errorf("beads startup root workspace: %w", err)
|
|
52
|
+
}
|
|
53
|
+
if _, exists := workspaces[dir]; !exists {
|
|
54
|
+
workspaces[dir] = dir
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if len(workspaces) == 0 {
|
|
58
|
+
slog.Info("beads startup connection verification deferred to registration", "reason", "no configured workspace")
|
|
59
|
+
return nil
|
|
60
|
+
}
|
|
61
|
+
dirs := make([]string, 0, len(workspaces))
|
|
62
|
+
for dir := range workspaces {
|
|
63
|
+
dirs = append(dirs, dir)
|
|
64
|
+
}
|
|
65
|
+
sort.Strings(dirs)
|
|
66
|
+
for _, dir := range dirs {
|
|
67
|
+
probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
68
|
+
err := bdcli.New(workspaces[dir], dir).Probe(probeCtx)
|
|
69
|
+
cancel()
|
|
70
|
+
if err != nil {
|
|
71
|
+
return fmt.Errorf("beads startup workspace probe: %w", err)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return nil
|
|
75
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
package beads
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"context"
|
|
6
|
+
"errors"
|
|
7
|
+
"log/slog"
|
|
8
|
+
"os"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"strings"
|
|
11
|
+
"testing"
|
|
12
|
+
"time"
|
|
13
|
+
|
|
14
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
15
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
func installStartupBD(t *testing.T, script string) string {
|
|
19
|
+
t.Helper()
|
|
20
|
+
bin := t.TempDir()
|
|
21
|
+
log := filepath.Join(t.TempDir(), "calls")
|
|
22
|
+
t.Setenv("BD_STARTUP_LOG", log)
|
|
23
|
+
t.Setenv("PATH", bin)
|
|
24
|
+
t.Setenv("BEADS_DIR", "/ambient/workspace")
|
|
25
|
+
t.Setenv("BEADS_DB", "/ambient/database")
|
|
26
|
+
t.Setenv("BD_DB", "/ambient/legacy")
|
|
27
|
+
if err := os.WriteFile(filepath.Join(bin, "bd"), []byte("#!/bin/sh\n"+script), 0o755); err != nil {
|
|
28
|
+
t.Fatal(err)
|
|
29
|
+
}
|
|
30
|
+
return log
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const startupReadScript = `
|
|
34
|
+
[ "$*" = 'list --ready --limit 1 --no-parent --json' ] || exit 2
|
|
35
|
+
[ -z "${BEADS_DB+x}" ] || exit 3
|
|
36
|
+
[ -z "${BD_DB+x}" ] || exit 4
|
|
37
|
+
printf '%s|%s|%s\n' "$PWD" "$BEADS_DIR" "$*" >> "$BD_STARTUP_LOG"
|
|
38
|
+
printf '%s' '[]'
|
|
39
|
+
`
|
|
40
|
+
|
|
41
|
+
func TestBeadsStartupWithoutWorkspaceChecksOnlyExecutableAndReportsDeferred(t *testing.T) {
|
|
42
|
+
log := installStartupBD(t, "printf invoked >> \"$BD_STARTUP_LOG\"\nexit 99\n")
|
|
43
|
+
var output bytes.Buffer
|
|
44
|
+
original := slog.Default()
|
|
45
|
+
slog.SetDefault(slog.New(slog.NewTextHandler(&output, nil)))
|
|
46
|
+
t.Cleanup(func() { slog.SetDefault(original) })
|
|
47
|
+
if err := task.ProbeStartup(context.Background(), "beads", nil, nil); err != nil {
|
|
48
|
+
t.Fatal(err)
|
|
49
|
+
}
|
|
50
|
+
if _, err := os.Stat(log); !os.IsNotExist(err) {
|
|
51
|
+
t.Fatalf("startup probed ambient workspace: %v", err)
|
|
52
|
+
}
|
|
53
|
+
if !strings.Contains(output.String(), "deferred to registration") {
|
|
54
|
+
t.Fatalf("deferred verification not reported: %s", output.String())
|
|
55
|
+
}
|
|
56
|
+
t.Setenv("PATH", t.TempDir())
|
|
57
|
+
if err := task.ProbeStartup(context.Background(), "beads", nil, nil); err == nil || !strings.Contains(err.Error(), "missing executable") {
|
|
58
|
+
t.Fatalf("missing bd was silently skipped: %v", err)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func TestBeadsStartupDeduplicatesExplicitWorkspaceAliasesAndPreservesCWD(t *testing.T) {
|
|
63
|
+
log := installStartupBD(t, startupReadScript)
|
|
64
|
+
shared, other := t.TempDir(), t.TempDir()
|
|
65
|
+
alias := filepath.Join(t.TempDir(), "workspace-alias")
|
|
66
|
+
if err := os.Symlink(shared, alias); err != nil {
|
|
67
|
+
t.Fatal(err)
|
|
68
|
+
}
|
|
69
|
+
codeOne, codeTwo, codeThree := t.TempDir(), t.TempDir(), t.TempDir()
|
|
70
|
+
repos := map[string]config.Repo{
|
|
71
|
+
"one": {Path: codeOne, TaskConfig: config.RawValues{"beadsDir": shared}},
|
|
72
|
+
"two": {Path: codeTwo, TaskConfig: config.RawValues{"beadsDir": alias}},
|
|
73
|
+
"three": {Path: codeThree, TaskConfig: config.RawValues{"beadsDir": other}},
|
|
74
|
+
}
|
|
75
|
+
if err := task.ProbeStartup(context.Background(), "beads", config.RawValues{"beadsDir": shared}, repos); err != nil {
|
|
76
|
+
t.Fatal(err)
|
|
77
|
+
}
|
|
78
|
+
calls, err := os.ReadFile(log)
|
|
79
|
+
if err != nil {
|
|
80
|
+
t.Fatal(err)
|
|
81
|
+
}
|
|
82
|
+
lines := strings.Split(strings.TrimSpace(string(calls)), "\n")
|
|
83
|
+
if len(lines) != 2 || !strings.Contains(string(calls), codeOne+"|"+shared+"|") || !strings.Contains(string(calls), codeThree+"|"+other+"|") || strings.Contains(string(calls), codeTwo+"|") {
|
|
84
|
+
t.Fatalf("unexpected deduplicated probes: %s", calls)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func TestBeadsStartupDoesNotSkipConfiguredRootWorkspaceWithoutRepos(t *testing.T) {
|
|
89
|
+
log := installStartupBD(t, startupReadScript)
|
|
90
|
+
workspace := t.TempDir()
|
|
91
|
+
if err := task.ProbeStartup(context.Background(), "beads", config.RawValues{"beadsDir": workspace}, nil); err != nil {
|
|
92
|
+
t.Fatal(err)
|
|
93
|
+
}
|
|
94
|
+
calls, err := os.ReadFile(log)
|
|
95
|
+
if err != nil || !strings.HasPrefix(string(calls), workspace+"|"+workspace+"|") || strings.Count(string(calls), "\n") != 1 {
|
|
96
|
+
t.Fatalf("configured root connection not probed explicitly: %q, %v", calls, err)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
func TestBeadsStartupRejectsInvalidWorkspaceAndNeverFallsBackToRoot(t *testing.T) {
|
|
101
|
+
log := installStartupBD(t, startupReadScript)
|
|
102
|
+
for _, tc := range []struct {
|
|
103
|
+
name string
|
|
104
|
+
repo config.RawValues
|
|
105
|
+
}{
|
|
106
|
+
{"missing-repo-key", nil},
|
|
107
|
+
{"missing-directory", config.RawValues{"beadsDir": filepath.Join(t.TempDir(), "missing")}},
|
|
108
|
+
{"wrong-type", config.RawValues{"beadsDir": 123}},
|
|
109
|
+
} {
|
|
110
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
111
|
+
repos := map[string]config.Repo{"app": {Path: t.TempDir(), TaskConfig: tc.repo}}
|
|
112
|
+
if err := task.ProbeStartup(context.Background(), "beads", config.RawValues{"beadsDir": t.TempDir()}, repos); err == nil {
|
|
113
|
+
t.Fatal("invalid configured workspace accepted")
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
if _, err := os.Stat(log); !os.IsNotExist(err) {
|
|
118
|
+
t.Fatalf("invalid config caused an ambient/root fallback read: %v", err)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
func TestBeadsStartupReportsConnectionAndResponseFailuresWithoutRetry(t *testing.T) {
|
|
123
|
+
for _, tc := range []struct {
|
|
124
|
+
name, script, want string
|
|
125
|
+
}{
|
|
126
|
+
{"connection", "printf '%s' 'Dolt connection refused' >&2\nexit 1\n", "connection refused"},
|
|
127
|
+
{"malformed", "printf '%s' 'not-json'\n", "JSON"},
|
|
128
|
+
{"null", "printf '%s' 'null'\n", "JSON"},
|
|
129
|
+
} {
|
|
130
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
131
|
+
log := installStartupBD(t, "printf '%s\\n' called >> \"$BD_STARTUP_LOG\"\n"+tc.script)
|
|
132
|
+
repos := map[string]config.Repo{"app": {Path: t.TempDir(), TaskConfig: config.RawValues{"beadsDir": t.TempDir()}}}
|
|
133
|
+
err := task.ProbeStartup(context.Background(), "beads", nil, repos)
|
|
134
|
+
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
135
|
+
t.Fatalf("%s = %v", tc.name, err)
|
|
136
|
+
}
|
|
137
|
+
calls, err := os.ReadFile(log)
|
|
138
|
+
if err != nil || string(calls) != "called\n" {
|
|
139
|
+
t.Fatalf("workspace probe retried: %q, %v", calls, err)
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
func TestBeadsStartupHonorsEarlierAggregateDeadline(t *testing.T) {
|
|
146
|
+
installStartupBD(t, "/bin/sleep 2 &\nwait\n")
|
|
147
|
+
repos := map[string]config.Repo{"app": {Path: t.TempDir(), TaskConfig: config.RawValues{"beadsDir": t.TempDir()}}}
|
|
148
|
+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
|
149
|
+
defer cancel()
|
|
150
|
+
start := time.Now()
|
|
151
|
+
err := task.ProbeStartup(ctx, "beads", nil, repos)
|
|
152
|
+
if !errors.Is(err, context.DeadlineExceeded) || !strings.Contains(err.Error(), "timeout") || time.Since(start) > time.Second {
|
|
153
|
+
t.Fatalf("startup exceeded caller budget: %v (%v)", err, time.Since(start))
|
|
154
|
+
}
|
|
155
|
+
}
|
package/internal/task/factory.go
CHANGED
|
@@ -44,12 +44,16 @@ type Factory struct {
|
|
|
44
44
|
Auth func(context.Context, []string, io.Reader) error
|
|
45
45
|
DefaultConfig func() config.RawValues
|
|
46
46
|
ValidateTextConfig func(config.RawValues) error
|
|
47
|
+
// ProbeStartup checks machine credentials/connections without constructing
|
|
48
|
+
// repo systems or validating workflows. Adapters deduplicate connections
|
|
49
|
+
// and cap each external probe at five seconds within the caller's budget.
|
|
50
|
+
ProbeStartup func(context.Context, config.RawValues, map[string]config.Repo) error
|
|
47
51
|
// New constructs a fully validated repo-bound system. It is used by repo
|
|
48
52
|
// registration, where connectivity must be confirmed immediately.
|
|
49
53
|
New func(context.Context, RepoSpec) (System, error)
|
|
50
54
|
// NewLocal constructs only the local adapter state needed for startup. It
|
|
51
|
-
// must not probe remote services
|
|
52
|
-
//
|
|
55
|
+
// must not probe remote services. ProbeStartup owns basic machine checks;
|
|
56
|
+
// registration/submission owns full repo-specific validation.
|
|
53
57
|
NewLocal func(context.Context, RepoSpec) (System, error)
|
|
54
58
|
}
|
|
55
59
|
|
|
@@ -335,9 +335,10 @@ func init() {
|
|
|
335
335
|
}
|
|
336
336
|
return strings.Join([]string{creds.Site, proj, comp}, "/"), nil
|
|
337
337
|
},
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
338
|
+
ProbeStartup: probeStartup,
|
|
339
|
+
Auth: auth,
|
|
340
|
+
New: newSystemFromCredentials,
|
|
341
|
+
NewLocal: newSystemLocal,
|
|
341
342
|
})
|
|
342
343
|
}
|
|
343
344
|
|