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.
Files changed (54) hide show
  1. package/README.md +39 -5
  2. package/cmd/relay-flow/commands_test.go +48 -0
  3. package/cmd/relay-flow/main.go +17 -8
  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/execution/goworkflows/engine_test.go +1 -1
  17. package/internal/execution/temporal/operations_test.go +1 -1
  18. package/internal/harness/opencode/opencode.go +14 -0
  19. package/internal/harness/opencode/opencode_test.go +1 -1
  20. package/internal/harness/opencode/repo_setup.go +1 -1
  21. package/internal/harness/pi/pi.go +14 -0
  22. package/internal/harness/startup.go +25 -0
  23. package/internal/harness/startup_test.go +85 -0
  24. package/internal/runner/herdr/herdr.go +9 -0
  25. package/internal/runner/herdr/herdrcli/exec.go +14 -0
  26. package/internal/runner/herdr/herdrcli/operations.go +8 -2
  27. package/internal/runner/herdr/herdrcli/response.go +6 -0
  28. package/internal/runner/herdr/herdrcli/startup_test.go +101 -0
  29. package/internal/runner/herdr/startup_test.go +58 -0
  30. package/internal/runner/orca/orca.go +9 -0
  31. package/internal/runner/orca/orcacli/orcacli.go +99 -34
  32. package/internal/runner/orca/orcacli/orcacli_test.go +4 -1
  33. package/internal/runner/orca/orcacli/startup_test.go +158 -0
  34. package/internal/runner/orca/startup_test.go +57 -0
  35. package/internal/runner/startup.go +27 -0
  36. package/internal/runner/startup_test.go +59 -0
  37. package/internal/task/beads/bdcli/bdcli.go +12 -0
  38. package/internal/task/beads/bdcli/startup.go +20 -0
  39. package/internal/task/beads/bdcli/startup_test.go +27 -0
  40. package/internal/task/beads/beads.go +1 -0
  41. package/internal/task/beads/startup.go +75 -0
  42. package/internal/task/beads/startup_test.go +155 -0
  43. package/internal/task/factory.go +6 -2
  44. package/internal/task/jira/jira.go +4 -3
  45. package/internal/task/jira/rest/client.go +40 -6
  46. package/internal/task/jira/rest/startup_redirect_test.go +23 -0
  47. package/internal/task/jira/rest/startup_test.go +133 -0
  48. package/internal/task/jira/startup.go +22 -0
  49. package/internal/task/jira/startup_test.go +78 -0
  50. package/internal/task/startup.go +25 -0
  51. package/internal/task/startup_test.go +51 -0
  52. package/internal/workflow/report.go +4 -4
  53. package/internal/workflow/report_test.go +4 -4
  54. 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
+ }
@@ -108,6 +108,7 @@ func init() {
108
108
  ValidateTextConfig: validateTextConfig,
109
109
  New: newSystem,
110
110
  NewLocal: newSystemLocal,
111
+ ProbeStartup: probeStartup,
111
112
  })
112
113
  }
113
114
 
@@ -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
+ }
@@ -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; submission-time validation owns those
52
- // checks. Factories without a local constructor retain the old behavior.
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
- Auth: auth,
339
- New: newSystemFromCredentials,
340
- NewLocal: newSystemLocal,
338
+ ProbeStartup: probeStartup,
339
+ Auth: auth,
340
+ New: newSystemFromCredentials,
341
+ NewLocal: newSystemLocal,
341
342
  })
342
343
  }
343
344