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,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
|
+
}
|
|
@@ -316,7 +316,7 @@ func TestInvalidReportIsPermanentAndDoesNotSignal(t *testing.T) {
|
|
|
316
316
|
if !errors.Is(err, run.ErrInvalidReport) || ack.Accepted {
|
|
317
317
|
t.Fatalf("invalid report: ack=%+v err=%v", ack, err)
|
|
318
318
|
}
|
|
319
|
-
if want := `
|
|
319
|
+
if want := `NEXT STEP is end, so FEEDBACK must be exactly None.`; err.Error() != want {
|
|
320
320
|
t.Fatalf("validation message = %q, want %q", err, want)
|
|
321
321
|
}
|
|
322
322
|
after, err := engine.GetRun(context.Background(), rid)
|
|
@@ -85,7 +85,7 @@ func TestTemporalSubmitReportRejectsInvalidReportBeforeSignal(t *testing.T) {
|
|
|
85
85
|
if !errors.Is(err, run.ErrInvalidReport) || ack.Accepted || client.signaled {
|
|
86
86
|
t.Fatalf("invalid Temporal report: ack=%+v err=%v signaled=%v", ack, err, client.signaled)
|
|
87
87
|
}
|
|
88
|
-
if want := `
|
|
88
|
+
if want := `NEXT STEP is end, so FEEDBACK must be exactly None.`; err.Error() != want {
|
|
89
89
|
t.Fatalf("validation message = %q, want %q", err, want)
|
|
90
90
|
}
|
|
91
91
|
}
|
|
@@ -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.
|
|
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")
|
|
@@ -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
|
+
}
|