relay-flow 0.3.13-alpha → 0.3.14-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +39 -5
  2. package/cmd/relay-flow/commands_test.go +9 -0
  3. package/cmd/relay-flow/main.go +16 -7
  4. package/cmd/relay-flow/repo_registration.go +68 -0
  5. package/cmd/relay-flow/repo_selection_keyboard_test.go +178 -0
  6. package/cmd/relay-flow/scenario_test.go +25 -0
  7. package/cmd/relay-flow/serve.go +33 -2
  8. package/cmd/relay-flow/startup_alternate_plugins_test.go +138 -0
  9. package/cmd/relay-flow/startup_errors.go +85 -0
  10. package/cmd/relay-flow/startup_errors_test.go +88 -0
  11. package/cmd/relay-flow/startup_failure_categories_test.go +115 -0
  12. package/cmd/relay-flow/startup_ordering_test.go +75 -0
  13. package/cmd/relay-flow/startup_prerequisites_test.go +324 -0
  14. package/cmd/relay-flow/startup_readiness_test.go +116 -0
  15. package/cmd/relay-flow/startup_submission_test.go +123 -0
  16. package/internal/harness/opencode/opencode.go +14 -0
  17. package/internal/harness/opencode/opencode_test.go +1 -1
  18. package/internal/harness/opencode/repo_setup.go +1 -1
  19. package/internal/harness/pi/pi.go +14 -0
  20. package/internal/harness/startup.go +25 -0
  21. package/internal/harness/startup_test.go +85 -0
  22. package/internal/runner/herdr/herdr.go +9 -0
  23. package/internal/runner/herdr/herdrcli/exec.go +14 -0
  24. package/internal/runner/herdr/herdrcli/operations.go +8 -2
  25. package/internal/runner/herdr/herdrcli/response.go +6 -0
  26. package/internal/runner/herdr/herdrcli/startup_test.go +101 -0
  27. package/internal/runner/herdr/startup_test.go +58 -0
  28. package/internal/runner/orca/orca.go +9 -0
  29. package/internal/runner/orca/orcacli/orcacli.go +99 -34
  30. package/internal/runner/orca/orcacli/orcacli_test.go +4 -1
  31. package/internal/runner/orca/orcacli/startup_test.go +158 -0
  32. package/internal/runner/orca/startup_test.go +57 -0
  33. package/internal/runner/startup.go +27 -0
  34. package/internal/runner/startup_test.go +59 -0
  35. package/internal/task/beads/bdcli/bdcli.go +12 -0
  36. package/internal/task/beads/bdcli/startup.go +20 -0
  37. package/internal/task/beads/bdcli/startup_test.go +27 -0
  38. package/internal/task/beads/beads.go +1 -0
  39. package/internal/task/beads/startup.go +75 -0
  40. package/internal/task/beads/startup_test.go +155 -0
  41. package/internal/task/factory.go +6 -2
  42. package/internal/task/jira/jira.go +4 -3
  43. package/internal/task/jira/rest/client.go +40 -6
  44. package/internal/task/jira/rest/startup_redirect_test.go +23 -0
  45. package/internal/task/jira/rest/startup_test.go +133 -0
  46. package/internal/task/jira/startup.go +22 -0
  47. package/internal/task/jira/startup_test.go +78 -0
  48. package/internal/task/startup.go +25 -0
  49. package/internal/task/startup_test.go +51 -0
  50. package/package.json +1 -1
@@ -0,0 +1,324 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "os"
10
+ "path/filepath"
11
+ "reflect"
12
+ "runtime"
13
+ "strings"
14
+ "sync/atomic"
15
+ "testing"
16
+ "time"
17
+
18
+ "github.com/rajpopat27/relay-flow/internal/config"
19
+ "github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
20
+ "github.com/rajpopat27/relay-flow/internal/harness"
21
+ "github.com/rajpopat27/relay-flow/internal/paths"
22
+ "github.com/rajpopat27/relay-flow/internal/runner"
23
+ "github.com/rajpopat27/relay-flow/internal/server"
24
+ "github.com/rajpopat27/relay-flow/internal/task"
25
+ "github.com/rajpopat27/relay-flow/internal/workflow"
26
+ )
27
+
28
+ // Existing CLI lifecycle tests use production plugin selection with isolated
29
+ // prerequisite dependencies. These fake executables accept only the startup
30
+ // read; neither a desktop runner nor an agent process may be invoked.
31
+ func installHealthyServePrerequisites(t *testing.T, root string) {
32
+ t.Helper()
33
+ s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
34
+ user, token, ok := r.BasicAuth()
35
+ if !ok || user != "startup@example.invalid" || token != "fixture-token" || r.Method != "GET" || r.URL.Path != "/rest/api/3/myself" {
36
+ t.Errorf("unexpected startup request: %s %s", r.Method, r.URL.Path)
37
+ http.Error(w, "unexpected request", http.StatusBadRequest)
38
+ return
39
+ }
40
+ _, _ = w.Write([]byte(`{"accountId":"startup-account"}`))
41
+ }))
42
+ t.Cleanup(s.Close)
43
+ credentials := fmt.Sprintf("site: %q\nemail: startup@example.invalid\ntoken: fixture-token\n", s.URL)
44
+ if err := os.WriteFile(filepath.Join(root, "credentials.yaml"), []byte(credentials), 0o600); err != nil {
45
+ t.Fatal(err)
46
+ }
47
+ bin := t.TempDir()
48
+ launcher := "orca"
49
+ if runtime.GOOS == "linux" {
50
+ launcher = "orca-ide"
51
+ }
52
+ for name, script := range map[string]string{
53
+ launcher: "#!/bin/sh\n[ \"$*\" = 'repo list --json' ] || exit 99\nprintf '%s' '{\"ok\":true,\"result\":{\"repos\":[]}}'\n",
54
+ "opencode": "#!/bin/sh\necho 'startup must not execute harness commands' >&2\nexit 99\n",
55
+ } {
56
+ if err := os.WriteFile(filepath.Join(bin, name), []byte(script), 0o755); err != nil {
57
+ t.Fatal(err)
58
+ }
59
+ }
60
+ t.Setenv("ORCA_CLI_COMMAND", "")
61
+ t.Setenv("ORCA_DEV_REPO_ROOT", "")
62
+ t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
63
+ }
64
+
65
+ type prerequisiteTask struct{ *scenarioTaskSystem }
66
+
67
+ func (s *prerequisiteTask) Poll(context.Context) ([]task.Ticket, error) {
68
+ s.log.add("poll")
69
+ return nil, nil
70
+ }
71
+
72
+ type prerequisiteRunner struct {
73
+ *scenarioRunner
74
+ probe func(context.Context) error
75
+ }
76
+
77
+ func (r *prerequisiteRunner) ProbeStartup(ctx context.Context) error {
78
+ r.log.add("startup:runner")
79
+ if r.probe != nil {
80
+ return r.probe(ctx)
81
+ }
82
+ return ctx.Err()
83
+ }
84
+
85
+ type prerequisiteHarness struct {
86
+ *scenarioHarness
87
+ probe func(context.Context) error
88
+ }
89
+
90
+ func (h *prerequisiteHarness) ProbeStartup(ctx context.Context) error {
91
+ h.log.add("startup:harness")
92
+ if h.probe != nil {
93
+ return h.probe(ctx)
94
+ }
95
+ return ctx.Err()
96
+ }
97
+
98
+ var prerequisitePluginSequence atomic.Int64
99
+
100
+ type prerequisiteFixture struct {
101
+ paths paths.Paths
102
+ cfg *config.Machine
103
+ log *scenarioLog
104
+ rnr *prerequisiteRunner
105
+ hrn *prerequisiteHarness
106
+ }
107
+
108
+ func newPrerequisiteFixture(t *testing.T, taskProbe func(context.Context) error) *prerequisiteFixture {
109
+ t.Helper()
110
+ f := &prerequisiteFixture{paths: pathsForRoot(filepath.Join(t.TempDir(), "relay-flow")), log: newScenarioLog()}
111
+ t.Setenv("RELAY_FLOW_HOME", f.paths.Root)
112
+ if err := paths.Ensure(f.paths); err != nil {
113
+ t.Fatal(err)
114
+ }
115
+ name := fmt.Sprintf("prerequisite-test-%d", prerequisitePluginSequence.Add(1))
116
+ f.cfg = &config.Machine{TaskPlugin: name + "-task", RunnerPlugin: name + "-runner", HarnessPlugin: name + "-harness", PollIntervalSeconds: 1}
117
+ sys := &prerequisiteTask{newScenarioTaskSystem(f.log)}
118
+ f.rnr = &prerequisiteRunner{scenarioRunner: newScenarioRunner(f.log)}
119
+ f.hrn = &prerequisiteHarness{scenarioHarness: newScenarioHarness(f.log)}
120
+ constructTask := func(context.Context, task.RepoSpec) (task.System, error) {
121
+ f.log.add("construct:task")
122
+ return sys, nil
123
+ }
124
+ task.Register(f.cfg.TaskPlugin, task.Factory{
125
+ RequiredRepoKeys: func() []string { return nil },
126
+ TaskScopeKey: func(config.RawValues, config.RawValues) (string, error) { return name, nil },
127
+ New: constructTask, NewLocal: constructTask,
128
+ ProbeStartup: func(ctx context.Context, _ config.RawValues, _ map[string]config.Repo) error {
129
+ f.log.add("startup:task")
130
+ if taskProbe != nil {
131
+ return taskProbe(ctx)
132
+ }
133
+ return ctx.Err()
134
+ },
135
+ })
136
+ runner.Register(f.cfg.RunnerPlugin, func(config.RawValues) (runner.Runner, error) { return f.rnr, nil })
137
+ harness.Register(f.cfg.HarnessPlugin, harness.Factory{New: func(config.RawValues) (harness.Harness, error) { return f.hrn, nil }})
138
+ if err := goworkflows.InitDatabase(f.paths.Database); err != nil {
139
+ t.Fatal(err)
140
+ }
141
+ f.save(t)
142
+ return f
143
+ }
144
+
145
+ func (f *prerequisiteFixture) save(t *testing.T) {
146
+ t.Helper()
147
+ if err := config.SaveMachine(f.paths.Config, f.cfg); err != nil {
148
+ t.Fatal(err)
149
+ }
150
+ }
151
+
152
+ func (f *prerequisiteFixture) start(t *testing.T) *server.Client {
153
+ t.Helper()
154
+ ctx, cancel := context.WithCancel(context.Background())
155
+ done := make(chan error, 1)
156
+ go func() { done <- serveRoot(ctx, f.paths, false) }()
157
+ t.Cleanup(func() {
158
+ cancel()
159
+ select {
160
+ case err := <-done:
161
+ if err != nil {
162
+ t.Errorf("serveRoot shutdown: %v", err)
163
+ }
164
+ case <-time.After(10 * time.Second):
165
+ t.Error("serveRoot did not stop")
166
+ }
167
+ })
168
+ client := server.NewClient(f.paths.Socket)
169
+ waitForServerOrError(t, client, done)
170
+ return client
171
+ }
172
+
173
+ func TestServePrerequisitesFailBeforeWorkersPollingRecoveryAndReadiness(t *testing.T) {
174
+ for _, stage := range []string{"task", "runner", "harness"} {
175
+ for _, recover := range []bool{false, true} {
176
+ t.Run(fmt.Sprintf("%s/recover=%v", stage, recover), func(t *testing.T) {
177
+ failure := errors.New("selected dependency unavailable")
178
+ var taskProbe func(context.Context) error
179
+ if stage == "task" {
180
+ taskProbe = func(context.Context) error { return failure }
181
+ }
182
+ f := newPrerequisiteFixture(t, taskProbe)
183
+ if stage == "runner" {
184
+ f.rnr.probe = func(context.Context) error { return failure }
185
+ }
186
+ if stage == "harness" {
187
+ f.hrn.probe = func(context.Context) error { return failure }
188
+ }
189
+ f.cfg.Repos = map[string]config.Repo{"app": {Path: "/work/app"}}
190
+ f.save(t)
191
+ before, err := os.ReadFile(f.paths.Database)
192
+ if err != nil {
193
+ t.Fatal(err)
194
+ }
195
+ err = serveRoot(context.Background(), f.paths, recover)
196
+ if !errors.Is(err, failure) || !strings.Contains(err.Error(), stage+" plugin") {
197
+ t.Fatalf("prerequisite failure = %v", err)
198
+ }
199
+ want := []string{"construct:task", "startup:task"}
200
+ if stage != "task" {
201
+ want = append(want, "startup:runner")
202
+ }
203
+ if stage == "harness" {
204
+ want = append(want, "startup:harness")
205
+ }
206
+ if got := f.log.all(); !reflect.DeepEqual(got, want) {
207
+ t.Fatalf("unexpected work before prerequisites: %v, want %v", got, want)
208
+ }
209
+ after, readErr := os.ReadFile(f.paths.Database)
210
+ backups, _ := filepath.Glob(f.paths.Database + ".recover-*")
211
+ if readErr != nil || string(before) != string(after) || len(backups) != 0 {
212
+ t.Fatalf("prerequisite failure changed execution/recovery state: %v, backups=%v", readErr, backups)
213
+ }
214
+ if serverResponding(server.NewClient(f.paths.Socket), 50*time.Millisecond) {
215
+ t.Fatal("failed prerequisites published readiness")
216
+ }
217
+ if _, err := os.Stat(f.paths.Socket); !os.IsNotExist(err) {
218
+ t.Fatalf("socket created before prerequisites: %v", err)
219
+ }
220
+ })
221
+ }
222
+ }
223
+ }
224
+
225
+ func TestServePrerequisitesAcceptZeroReposAndProbeOnlySelectedPlugins(t *testing.T) {
226
+ f := newPrerequisiteFixture(t, nil)
227
+ client := f.start(t)
228
+ repos, err := client.ListRepos(context.Background())
229
+ if err != nil || len(repos) != 0 {
230
+ t.Fatalf("zero-repo readiness = %v, %v", repos, err)
231
+ }
232
+ if got := f.log.all(); !reflect.DeepEqual(got, []string{"startup:task", "startup:runner", "startup:harness"}) {
233
+ t.Fatalf("zero-repo startup queried workflows, repos or unselected plugins: %v", got)
234
+ }
235
+ }
236
+
237
+ func TestServePrerequisitesDoNotRepeatAcceptedWorkflowPreflightAndIsolateMalformedWorkflow(t *testing.T) {
238
+ f := newPrerequisiteFixture(t, nil)
239
+ f.cfg.Repos = map[string]config.Repo{scenarioRepo: {Path: scenarioRepoPath}}
240
+ f.save(t)
241
+ store := &workflow.Store{Dir: f.paths.Workflows}
242
+ if err := store.Put(scenarioWorkflowName, scenarioWorkflowYAML); err != nil {
243
+ t.Fatal(err)
244
+ }
245
+ if err := os.WriteFile(filepath.Join(f.paths.Workflows, "broken.yaml"), []byte("name: [broken\n"), 0o644); err != nil {
246
+ t.Fatal(err)
247
+ }
248
+ client := f.start(t)
249
+ definitions, err := client.ListWorkflows(context.Background())
250
+ if err != nil || len(definitions) != 2 {
251
+ t.Fatalf("stored definitions = %v, %v", definitions, err)
252
+ }
253
+ for _, definition := range definitions {
254
+ if definition.Name == scenarioWorkflowName && !definition.IsRoutable() {
255
+ t.Fatalf("accepted workflow lost routing: %+v", definition)
256
+ }
257
+ if definition.Name == "broken" && definition.IsRoutable() {
258
+ t.Fatal("malformed workflow published routes")
259
+ }
260
+ }
261
+ waitScenario(t, time.Second, func() bool { return f.log.countPrefix("poll") > 0 })
262
+ for _, stage := range []string{"task", "runner", "harness"} {
263
+ if f.log.countPrefix("startup:"+stage) != 1 {
264
+ t.Fatalf("prerequisite repeated per workflow: %v", f.log.all())
265
+ }
266
+ assertBefore(t, f.log.all(), "startup:"+stage, "poll")
267
+ }
268
+ for _, fullCheck := range []string{"task-config-validated", "runner-repo-validated", "harness-agent-validated", "environment:", "terminal-created:"} {
269
+ if f.log.countPrefix(fullCheck) != 0 {
270
+ t.Fatalf("startup repeated full preflight or launched work: %v", f.log.all())
271
+ }
272
+ }
273
+ }
274
+
275
+ func TestPrerequisiteGateUsesTenSecondAggregateAndEarlierCallerBudget(t *testing.T) {
276
+ start := time.Now()
277
+ f := newPrerequisiteFixture(t, func(ctx context.Context) error {
278
+ deadline, ok := ctx.Deadline()
279
+ if !ok || deadline.After(time.Now().Add(10*time.Second)) {
280
+ t.Fatalf("missing aggregate ceiling: %v", deadline)
281
+ }
282
+ return nil
283
+ })
284
+ f.rnr.probe = func(ctx context.Context) error {
285
+ deadline, ok := ctx.Deadline()
286
+ if !ok || deadline.After(time.Now().Add(5*time.Second)) {
287
+ t.Fatalf("missing runner ceiling: %v", deadline)
288
+ }
289
+ return nil
290
+ }
291
+ if err := checkStartupPrerequisites(context.Background(), f.cfg, f.rnr, f.hrn); err != nil {
292
+ t.Fatal(err)
293
+ }
294
+ ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond)
295
+ defer cancel()
296
+ f.rnr.probe = func(ctx context.Context) error { <-ctx.Done(); return ctx.Err() }
297
+ start = time.Now()
298
+ err := checkStartupPrerequisites(ctx, f.cfg, f.rnr, f.hrn)
299
+ if !errors.Is(err, context.DeadlineExceeded) || time.Since(start) > time.Second || f.log.countPrefix("startup:harness") != 1 {
300
+ t.Fatalf("gate extended deadline or continued after failure: %v, %v", err, f.log.all())
301
+ }
302
+ }
303
+
304
+ func TestPrerequisiteGateAggregateIncludesMultipleTaskConnectionsAndRunner(t *testing.T) {
305
+ f := newPrerequisiteFixture(t, func(ctx context.Context) error {
306
+ // Model two successful task connections, each strictly below five
307
+ // seconds. The runner must receive the aggregate's remaining budget,
308
+ // not a fresh five seconds beyond the ten-second prerequisite phase.
309
+ for i := 0; i < 2; i++ {
310
+ select {
311
+ case <-time.After(4 * time.Second):
312
+ case <-ctx.Done():
313
+ return ctx.Err()
314
+ }
315
+ }
316
+ return nil
317
+ })
318
+ f.rnr.probe = func(ctx context.Context) error { <-ctx.Done(); return ctx.Err() }
319
+ start := time.Now()
320
+ err := checkStartupPrerequisites(context.Background(), f.cfg, f.rnr, f.hrn)
321
+ if !errors.Is(err, context.DeadlineExceeded) || time.Since(start) > 11*time.Second || f.log.countPrefix("startup:harness") != 0 {
322
+ t.Fatalf("aggregate prerequisite budget not enforced: %v (%v), events=%v", err, time.Since(start), f.log.all())
323
+ }
324
+ }
@@ -0,0 +1,116 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "os"
7
+ "os/exec"
8
+ "path/filepath"
9
+ "runtime"
10
+ "strings"
11
+ "testing"
12
+ "time"
13
+
14
+ "github.com/rajpopat27/relay-flow/internal/config"
15
+ "github.com/rajpopat27/relay-flow/internal/harness"
16
+ "github.com/rajpopat27/relay-flow/internal/runner"
17
+ "github.com/rajpopat27/relay-flow/internal/server"
18
+ "github.com/rajpopat27/relay-flow/internal/task"
19
+ )
20
+
21
+ func TestServePrerequisitesRequireSelectedPluginProbeSupport(t *testing.T) {
22
+ for _, stage := range []string{"task", "runner", "harness"} {
23
+ t.Run(stage, func(t *testing.T) {
24
+ f := newPrerequisiteFixture(t, nil)
25
+ if stage == "task" {
26
+ name := fmt.Sprintf("missing-startup-task-%d", prerequisitePluginSequence.Add(1))
27
+ task.Register(name, task.Factory{NewLocal: func(context.Context, task.RepoSpec) (task.System, error) {
28
+ return newScenarioTaskSystem(f.log), nil
29
+ }})
30
+ f.cfg.TaskPlugin = name
31
+ } else if stage == "runner" {
32
+ name := fmt.Sprintf("missing-startup-runner-%d", prerequisitePluginSequence.Add(1))
33
+ runner.Register(name, func(config.RawValues) (runner.Runner, error) {
34
+ // Embedding the runtime interface deliberately does not expose
35
+ // the separate startup boundary of the concrete fake.
36
+ return struct{ runner.Runner }{f.rnr}, nil
37
+ })
38
+ f.cfg.RunnerPlugin = name
39
+ } else {
40
+ name := fmt.Sprintf("missing-startup-harness-%d", prerequisitePluginSequence.Add(1))
41
+ harness.Register(name, harness.Factory{New: func(config.RawValues) (harness.Harness, error) {
42
+ return struct{ harness.Harness }{f.hrn}, nil
43
+ }})
44
+ f.cfg.HarnessPlugin = name
45
+ }
46
+ f.save(t)
47
+ err := serveRoot(context.Background(), f.paths, false)
48
+ if err == nil || !strings.Contains(err.Error(), stage+" plugin") || !strings.Contains(err.Error(), "startup prob") {
49
+ t.Fatalf("selected dependency was silently skipped: %v", err)
50
+ }
51
+ if serverResponding(server.NewClient(f.paths.Socket), 50*time.Millisecond) {
52
+ t.Fatal("unsupported probe published readiness")
53
+ }
54
+ })
55
+ }
56
+ }
57
+
58
+ func TestForegroundAndDetachedServeNeverReportReadinessOnDependencyFailure(t *testing.T) {
59
+ binary := buildCLIBinary(t)
60
+ for _, stage := range []string{"task", "runner", "harness"} {
61
+ for _, foreground := range []bool{false, true} {
62
+ t.Run(fmt.Sprintf("%s/foreground=%v", stage, foreground), func(t *testing.T) {
63
+ root := filepath.Join(t.TempDir(), "relay-flow")
64
+ t.Setenv("RELAY_FLOW_HOME", root)
65
+ init := exec.Command(binary, initArgs()...)
66
+ init.Env = os.Environ()
67
+ if out, err := init.CombinedOutput(); err != nil {
68
+ t.Fatalf("init: %v\n%s", err, out)
69
+ }
70
+ installHealthyServePrerequisites(t, root)
71
+ bin := strings.Split(os.Getenv("PATH"), string(os.PathListSeparator))[0]
72
+ // The child's own environment has only the isolated executables.
73
+ // Removing one must not resolve a real installed dependency.
74
+ t.Setenv("PATH", bin)
75
+ missing := filepath.Join(root, "credentials.yaml")
76
+ if stage == "runner" {
77
+ launcher := "orca"
78
+ if runtime.GOOS == "linux" {
79
+ launcher = "orca-ide"
80
+ }
81
+ missing = filepath.Join(bin, launcher)
82
+ } else if stage == "harness" {
83
+ missing = filepath.Join(bin, "opencode")
84
+ }
85
+ if err := os.Remove(missing); err != nil {
86
+ t.Fatal(err)
87
+ }
88
+ args := []string{"serve"}
89
+ if foreground {
90
+ args = append(args, "--foreground")
91
+ }
92
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
93
+ defer cancel()
94
+ serve := exec.CommandContext(ctx, binary, args...)
95
+ serve.Env = os.Environ()
96
+ out, err := serve.CombinedOutput()
97
+ if err == nil || strings.Contains(string(out), "server started") {
98
+ t.Fatalf("failed dependency reported readiness: err=%v, output=%s", err, out)
99
+ }
100
+ log := readFile(t, filepath.Join(root, "server.log"))
101
+ if !strings.Contains(log, "startup prerequisite "+stage+" plugin") {
102
+ t.Fatalf("server did not report the selected dependency: %s", log)
103
+ }
104
+ if !strings.Contains(string(out), "startup prerequisite "+stage+" plugin") {
105
+ t.Fatalf("startup failure lost dependency identity: %s", out)
106
+ }
107
+ if !strings.Contains(string(out), filepath.Join(root, "server.log")) {
108
+ t.Fatalf("startup failure lost log location: %s", out)
109
+ }
110
+ if serverResponding(server.NewClient(filepath.Join(root, "server.sock")), 50*time.Millisecond) {
111
+ t.Fatal("failed dependency left a ready server")
112
+ }
113
+ })
114
+ }
115
+ }
116
+ }
@@ -0,0 +1,123 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ "testing"
10
+
11
+ "github.com/rajpopat27/relay-flow/internal/config"
12
+ "github.com/rajpopat27/relay-flow/internal/repo"
13
+ "github.com/rajpopat27/relay-flow/internal/task"
14
+ "github.com/rajpopat27/relay-flow/internal/workflow"
15
+ )
16
+
17
+ type rejectingSubmissionTask struct {
18
+ *scenarioTaskSystem
19
+ failure error
20
+ }
21
+
22
+ func (s *rejectingSubmissionTask) ValidateConfig(ctx context.Context, root config.RawValues, nodes map[string]config.RawValues) error {
23
+ if s.failure != nil {
24
+ return s.failure
25
+ }
26
+ return s.scenarioTaskSystem.ValidateConfig(ctx, root, nodes)
27
+ }
28
+
29
+ type rejectingSubmissionRunner struct {
30
+ *scenarioRunner
31
+ failure error
32
+ }
33
+
34
+ func (r *rejectingSubmissionRunner) ValidateRepo(ctx context.Context, name, path string) error {
35
+ if r.failure != nil {
36
+ return r.failure
37
+ }
38
+ return r.scenarioRunner.ValidateRepo(ctx, name, path)
39
+ }
40
+
41
+ type rejectingSubmissionHarness struct {
42
+ *scenarioHarness
43
+ failure error
44
+ }
45
+
46
+ func (h *rejectingSubmissionHarness) ValidateAgent(ctx context.Context, path, agent string) error {
47
+ if h.failure != nil {
48
+ return h.failure
49
+ }
50
+ return h.scenarioHarness.ValidateAgent(ctx, path, agent)
51
+ }
52
+
53
+ type submissionActiveRuns struct{}
54
+
55
+ func (submissionActiveRuns) HasActiveWorkflow(context.Context, string) (bool, error) {
56
+ return false, nil
57
+ }
58
+
59
+ func TestStartupPrerequisitesDoNotBypassSubmissionValidationOrReplaceAcceptedState(t *testing.T) {
60
+ for _, stage := range []string{"task-config", "runner-repo", "harness-agent"} {
61
+ t.Run(stage, func(t *testing.T) {
62
+ f := newPrerequisiteFixture(t, nil)
63
+ sys := &rejectingSubmissionTask{scenarioTaskSystem: newScenarioTaskSystem(f.log)}
64
+ rnr := &rejectingSubmissionRunner{scenarioRunner: newScenarioRunner(f.log)}
65
+ hrn := &rejectingSubmissionHarness{scenarioHarness: newScenarioHarness(f.log)}
66
+ registry := repo.NewRegistry()
67
+ registered := &repo.Repo{Name: scenarioRepo, Path: scenarioRepoPath, TaskSystem: sys}
68
+ registry.Replace(registered)
69
+ store := &workflow.Store{Dir: f.paths.Workflows}
70
+ service := workflow.NewService(store, submissionActiveRuns{}, repoExists{registry})
71
+ service.ValidateTaskConfig = workflowConfigValidator(registry)
72
+ service.ValidateSubmission = workflowSubmissionValidator(registry, rnr, hrn)
73
+ service.Rebind = func() error { return registry.BindWorkflows(service.Registry().List()) }
74
+ accepted, err := service.Submit(context.Background(), scenarioWorkflowYAML)
75
+ if err != nil {
76
+ t.Fatal(err)
77
+ }
78
+ bindings := registered.Bindings()
79
+ yamlPath := filepath.Join(store.Dir, scenarioWorkflowName+".yaml")
80
+ hashPath := filepath.Join(store.Dir, scenarioWorkflowName+".sha256")
81
+ beforeYAML := readFile(t, yamlPath)
82
+ beforeHash := readFile(t, hashPath)
83
+ failure := errors.New("candidate-specific validation rejected")
84
+ switch stage {
85
+ case "task-config":
86
+ sys.failure = failure
87
+ case "runner-repo":
88
+ rnr.failure = failure
89
+ case "harness-agent":
90
+ hrn.failure = failure
91
+ }
92
+ // Basic machine health still succeeds; it does not authorize a
93
+ // candidate that fails the full repo/task/agent validation path.
94
+ if err := checkStartupPrerequisites(context.Background(), f.cfg, rnr, hrn); err != nil {
95
+ t.Fatal(err)
96
+ }
97
+ candidate := []byte(strings.Replace(string(scenarioWorkflowYAML), "Implement the requested change.", "Rejected replacement description.", 1))
98
+ if _, err := service.Submit(context.Background(), candidate); !errors.Is(err, failure) {
99
+ t.Fatalf("submission bypassed %s validation: %v", stage, err)
100
+ }
101
+ if readFile(t, yamlPath) != beforeYAML || readFile(t, hashPath) != beforeHash {
102
+ t.Fatal("failed submission replaced accepted YAML/hash")
103
+ }
104
+ current, err := service.Get(scenarioWorkflowName)
105
+ if err != nil || current != accepted || !current.IsRoutable() {
106
+ t.Fatalf("failed submission replaced accepted in-memory definition: %v", err)
107
+ }
108
+ after := registered.Bindings()
109
+ if len(bindings) != 1 || len(after) != 1 || after[0].Workflow != bindings[0].Workflow || after[0].Workflow != accepted {
110
+ t.Fatal("failed submission changed routing bindings")
111
+ }
112
+ if matches := after[0].Match(task.Ticket{Key: scenarioTicket}); !matches {
113
+ t.Fatal("accepted routing matcher no longer works")
114
+ }
115
+ if transactions, err := filepath.Glob(filepath.Join(store.Dir, "*.txn")); err != nil || len(transactions) != 0 {
116
+ t.Fatalf("failed validation attempted publication: %v, %v", transactions, err)
117
+ }
118
+ if _, err := os.Stat(yamlPath); err != nil {
119
+ t.Fatal(err)
120
+ }
121
+ })
122
+ }
123
+ }
@@ -102,6 +102,20 @@ func New(cfg ...Config) *Harness {
102
102
  }}
103
103
  }
104
104
 
105
+ var _ harness.StartupProber = (*Harness)(nil)
106
+
107
+ // ProbeStartup resolves only the OpenCode executable, without agent listing,
108
+ // repository/template inspection or session launches.
109
+ func (*Harness) ProbeStartup(ctx context.Context) error {
110
+ if err := ctx.Err(); err != nil {
111
+ return err
112
+ }
113
+ if _, err := exec.LookPath("opencode"); err != nil {
114
+ return fmt.Errorf("opencode startup: required executable \"opencode\" unavailable: %w", err)
115
+ }
116
+ return nil
117
+ }
118
+
105
119
  // SetupRepo ensures the relay-flow runtime plugin is configured for OpenCode
106
120
  // in the registered repository.
107
121
  func (h *Harness) SetupRepo(_ context.Context, repoPath string) error {
@@ -14,7 +14,7 @@ import (
14
14
  "github.com/rajpopat27/relay-flow/internal/workflow"
15
15
  )
16
16
 
17
- const configuredPlugin = "relay-flow-plugin@0.3.13-alpha"
17
+ const configuredPlugin = "relay-flow-plugin@0.3.14-alpha"
18
18
 
19
19
  func TestBuildCommandArgv(t *testing.T) {
20
20
  t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
@@ -10,7 +10,7 @@ import (
10
10
  "github.com/rajpopat27/relay-flow/internal/config"
11
11
  )
12
12
 
13
- const relayFlowPlugin = "relay-flow-plugin@0.3.13-alpha"
13
+ const relayFlowPlugin = "relay-flow-plugin@0.3.14-alpha"
14
14
 
15
15
  type jsoncToken struct {
16
16
  kind byte
@@ -96,6 +96,20 @@ func New(cfg ...Config) *Harness {
96
96
  }}
97
97
  }
98
98
 
99
+ var _ harness.StartupProber = (*Harness)(nil)
100
+
101
+ // ProbeStartup resolves only the Pi executable; agent/template validation is
102
+ // deliberately separate and no session is launched.
103
+ func (*Harness) ProbeStartup(ctx context.Context) error {
104
+ if err := ctx.Err(); err != nil {
105
+ return err
106
+ }
107
+ if _, err := exec.LookPath("pi"); err != nil {
108
+ return fmt.Errorf("pi startup: required executable \"pi\" unavailable: %w", err)
109
+ }
110
+ return nil
111
+ }
112
+
99
113
  // SetupRepo is intentionally a no-op. The relay-flow Pi runtime extension is
100
114
  // installed manually in Pi's global package settings rather than configured
101
115
  // in each repository.
@@ -0,0 +1,25 @@
1
+ package harness
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "time"
7
+ )
8
+
9
+ // StartupProber is the local selected-executable prerequisite boundary. It
10
+ // must not enumerate agents, inspect repositories or launch a session.
11
+ type StartupProber interface {
12
+ ProbeStartup(context.Context) error
13
+ }
14
+
15
+ // ProbeStartup checks the constructed selected harness. Missing support is
16
+ // an error, not permission to skip its executable prerequisite.
17
+ func ProbeStartup(ctx context.Context, selected Harness) error {
18
+ probe, ok := selected.(StartupProber)
19
+ if !ok {
20
+ return fmt.Errorf("selected harness does not implement startup probing")
21
+ }
22
+ ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
23
+ defer cancel()
24
+ return probe.ProbeStartup(ctx)
25
+ }