relay-flow 0.3.9-alpha → 0.3.11-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 +30 -34
- package/cmd/relay-flow/commands_test.go +449 -23
- package/cmd/relay-flow/main.go +207 -37
- package/cmd/relay-flow/observability_test.go +11 -0
- package/cmd/relay-flow/onboarding.go +1 -1
- package/cmd/relay-flow/onboarding_test.go +1 -1
- package/cmd/relay-flow/scenario_test.go +26 -10
- package/cmd/relay-flow/serve.go +27 -1
- package/examples/config-reference.yaml +23 -19
- package/examples/workflow-reference.yaml +3 -0
- package/internal/execution/goworkflows/activities.go +13 -22
- package/internal/execution/goworkflows/engine_test.go +17 -3
- package/internal/execution/goworkflows/fakes_test.go +20 -0
- package/internal/execution/goworkflows/interpreter.go +25 -11
- package/internal/execution/goworkflows/mailbox_test.go +28 -1
- package/internal/execution/goworkflows/ownership_test.go +259 -0
- package/internal/execution/goworkflows/recovery_test.go +81 -1
- package/internal/execution/temporal/activities.go +13 -22
- package/internal/execution/temporal/compact_mailbox_test.go +56 -0
- package/internal/execution/temporal/interpreter.go +16 -3
- package/internal/execution/temporal/recovery.go +68 -36
- package/internal/harness/harness.go +23 -10
- package/internal/harness/opencode/opencode.go +22 -4
- package/internal/harness/opencode/opencode_test.go +41 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/opencode/task_env_test.go +2 -2
- package/internal/harness/pi/pi.go +21 -2
- package/internal/harness/pi/pi_test.go +7 -4
- package/internal/harness/pi/prompt_test.go +13 -5
- package/internal/harness/pi/task_env_test.go +18 -2
- package/internal/recover/recover.go +30 -1
- package/internal/repo/binding_test.go +63 -0
- package/internal/repo/repo.go +47 -6
- package/internal/router/router.go +39 -5
- package/internal/router/router_test.go +67 -1
- package/internal/run/manager.go +90 -3
- package/internal/run/run_manager_test.go +74 -0
- package/internal/server/api_test.go +13 -0
- package/internal/server/client.go +7 -0
- package/internal/server/fixture_test.go +5 -0
- package/internal/server/server.go +32 -3
- package/internal/task/beads/beads.go +201 -3
- package/internal/task/beads/beads_test.go +172 -0
- package/internal/task/jira/effects_test.go +86 -0
- package/internal/task/jira/filters_test.go +103 -0
- package/internal/task/jira/helpers_test.go +11 -2
- package/internal/task/jira/jira.go +244 -15
- package/internal/task/jira/normalize.go +30 -15
- package/internal/task/jira/rest/client.go +17 -2
- package/internal/task/jira/templates_test.go +38 -2
- package/internal/task/jira/transition_defaults_test.go +3 -1
- package/internal/task/task.go +67 -0
- package/internal/workflow/report.go +10 -0
- package/internal/workflow/report_test.go +16 -0
- package/internal/workflow/workflow.go +24 -0
- package/internal/workflow/workflow_test.go +58 -1
- package/package.json +1 -1
|
@@ -12,6 +12,9 @@ import (
|
|
|
12
12
|
"os"
|
|
13
13
|
"os/exec"
|
|
14
14
|
"path/filepath"
|
|
15
|
+
"reflect"
|
|
16
|
+
"runtime"
|
|
17
|
+
"strconv"
|
|
15
18
|
"strings"
|
|
16
19
|
"sync"
|
|
17
20
|
"syscall"
|
|
@@ -20,6 +23,7 @@ import (
|
|
|
20
23
|
|
|
21
24
|
"github.com/charmbracelet/huh"
|
|
22
25
|
"github.com/rajpopat27/relay-flow/internal/config"
|
|
26
|
+
"github.com/rajpopat27/relay-flow/internal/harness"
|
|
23
27
|
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
24
28
|
runsvc "github.com/rajpopat27/relay-flow/internal/run"
|
|
25
29
|
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
@@ -43,11 +47,11 @@ func cli(t *testing.T, home string, stdin string, args ...string) (code int) {
|
|
|
43
47
|
func TestCommandSurfaceExists(t *testing.T) {
|
|
44
48
|
home := t.TempDir()
|
|
45
49
|
commands := [][]string{
|
|
46
|
-
{"init"}, {"task", "auth"}, {"
|
|
50
|
+
{"init"}, {"task", "auth"}, {"stop"}, {"report"},
|
|
47
51
|
{"workflow", "submit", "--file", "x.yaml"}, {"workflow", "remove", "--name", "x"},
|
|
48
52
|
{"workflow", "list"}, {"workflow", "get", "--name", "x"},
|
|
49
53
|
{"repo", "register"}, {"repo", "remove", "--name", "x"}, {"repo", "list"}, {"repo", "get", "--name", "x"},
|
|
50
|
-
{"run", "list"}, {"run", "get", "--ticket", "PAY-101"}, {"run", "restart", "--ticket", "PAY-101"}, {"run", "cancel", "--ticket", "PAY-101"},
|
|
54
|
+
{"run", "list"}, {"run", "get", "--ticket", "PAY-101"}, {"run", "restart", "--ticket", "PAY-101"}, {"run", "cancel", "--ticket", "PAY-101"}, {"run", "backfill-owner", "--repo", "payments", "--ticket", "PAY-101", "--workflow", "basicFlow"},
|
|
51
55
|
}
|
|
52
56
|
for _, argv := range commands {
|
|
53
57
|
// Recognized commands do not exit 2 ("usage/unknown"); they may exit
|
|
@@ -69,14 +73,15 @@ func TestUnknownFlagExits2(t *testing.T) {
|
|
|
69
73
|
func TestRequiredFlagMissingExits2(t *testing.T) {
|
|
70
74
|
home := t.TempDir()
|
|
71
75
|
for _, argv := range [][]string{
|
|
72
|
-
{"workflow", "submit"},
|
|
73
|
-
{"workflow", "remove"},
|
|
74
|
-
{"workflow", "get"},
|
|
75
|
-
{"repo", "remove"},
|
|
76
|
-
{"repo", "get"},
|
|
77
|
-
{"run", "get"},
|
|
78
|
-
{"run", "restart"},
|
|
79
|
-
{"run", "cancel"},
|
|
76
|
+
{"workflow", "submit"}, // missing --file
|
|
77
|
+
{"workflow", "remove"}, // missing --name
|
|
78
|
+
{"workflow", "get"}, // missing --name
|
|
79
|
+
{"repo", "remove"}, // missing --name
|
|
80
|
+
{"repo", "get"}, // missing --name
|
|
81
|
+
{"run", "get"}, // missing --ticket
|
|
82
|
+
{"run", "restart"}, // missing --ticket
|
|
83
|
+
{"run", "cancel"}, // missing --ticket
|
|
84
|
+
{"run", "backfill-owner"}, // missing --repo/--ticket/--workflow
|
|
80
85
|
} {
|
|
81
86
|
if code := cli(t, home, "", argv...); code != 2 {
|
|
82
87
|
t.Fatalf("%v with missing required flag exit = %d, want 2", argv, code)
|
|
@@ -171,9 +176,19 @@ func TestTaskAuthDispatchesSelectedPlugin(t *testing.T) {
|
|
|
171
176
|
func TestServerLockIsOwnerOnly(t *testing.T) {
|
|
172
177
|
home := t.TempDir()
|
|
173
178
|
initHome(t, home)
|
|
174
|
-
// Start serve in the background of the test via the parser
|
|
175
|
-
// creates the flock file then blocks. We assert the lock file's
|
|
176
|
-
|
|
179
|
+
// Start foreground serve in the background of the test via the parser
|
|
180
|
+
// entry; it creates the flock file then blocks. We assert the lock file's
|
|
181
|
+
// mode, then stop the fixture so the test does not leak a server.
|
|
182
|
+
done := make(chan int, 1)
|
|
183
|
+
go func() { done <- cli(t, home, "", "serve", "--foreground") }()
|
|
184
|
+
t.Cleanup(func() {
|
|
185
|
+
_ = cli(t, home, "", "stop")
|
|
186
|
+
select {
|
|
187
|
+
case <-done:
|
|
188
|
+
case <-time.After(5 * time.Second):
|
|
189
|
+
t.Errorf("foreground serve did not stop")
|
|
190
|
+
}
|
|
191
|
+
})
|
|
177
192
|
lock := filepath.Join(home, ".relay-flow", "server.lock")
|
|
178
193
|
var fi os.FileInfo
|
|
179
194
|
var err error
|
|
@@ -191,6 +206,7 @@ func TestServerLockIsOwnerOnly(t *testing.T) {
|
|
|
191
206
|
if fi.Mode().Perm() != 0600 {
|
|
192
207
|
t.Fatalf("server.lock mode = %o, want 0600", fi.Mode().Perm())
|
|
193
208
|
}
|
|
209
|
+
waitForServer(t, server.NewClient(filepath.Join(home, ".relay-flow", "server.sock")))
|
|
194
210
|
}
|
|
195
211
|
|
|
196
212
|
// 3.29 (socket): the serve startup path owns server.sock creation and chmods
|
|
@@ -199,7 +215,16 @@ func TestServerLockIsOwnerOnly(t *testing.T) {
|
|
|
199
215
|
func TestServerSocketIsOwnerOnly(t *testing.T) {
|
|
200
216
|
home := t.TempDir()
|
|
201
217
|
initHome(t, home)
|
|
202
|
-
|
|
218
|
+
done := make(chan int, 1)
|
|
219
|
+
go func() { done <- cli(t, home, "", "serve", "--foreground") }()
|
|
220
|
+
t.Cleanup(func() {
|
|
221
|
+
_ = cli(t, home, "", "stop")
|
|
222
|
+
select {
|
|
223
|
+
case <-done:
|
|
224
|
+
case <-time.After(5 * time.Second):
|
|
225
|
+
t.Errorf("foreground serve did not stop")
|
|
226
|
+
}
|
|
227
|
+
})
|
|
203
228
|
sock := filepath.Join(home, ".relay-flow", "server.sock")
|
|
204
229
|
var fi os.FileInfo
|
|
205
230
|
var err error
|
|
@@ -362,6 +387,9 @@ func TestInitWritesHarnessPromptDefaults(t *testing.T) {
|
|
|
362
387
|
t.Fatalf("harnessConfig.%s = %#v", key, cfg.HarnessConfig[key])
|
|
363
388
|
}
|
|
364
389
|
}
|
|
390
|
+
if cfg.HarnessConfig["initial"] != harness.JiraInitialPrompt || cfg.HarnessConfig["feedback"] != harness.JiraFeedbackPrompt {
|
|
391
|
+
t.Fatalf("Jira prompt defaults = %#v", cfg.HarnessConfig)
|
|
392
|
+
}
|
|
365
393
|
}
|
|
366
394
|
|
|
367
395
|
func TestInitWritesTaskTextDefaults(t *testing.T) {
|
|
@@ -382,6 +410,9 @@ func TestInitWritesTaskTextDefaults(t *testing.T) {
|
|
|
382
410
|
t.Fatalf("taskConfig.templates.%s = %#v", key, templates[key])
|
|
383
411
|
}
|
|
384
412
|
}
|
|
413
|
+
if !strings.Contains(templates["mailboxDescription"].(string), "{{report}}") {
|
|
414
|
+
t.Fatal("Jira mailbox description must expose the canonical report")
|
|
415
|
+
}
|
|
385
416
|
}
|
|
386
417
|
|
|
387
418
|
func TestInitDoesNotCollectOrWriteTaskCredentials(t *testing.T) {
|
|
@@ -456,7 +487,7 @@ func TestInitForcePreservesDurableAndUserState(t *testing.T) {
|
|
|
456
487
|
if !ok {
|
|
457
488
|
t.Fatalf("taskConfig.templates = %#v", cfg.TaskConfig["templates"])
|
|
458
489
|
}
|
|
459
|
-
taskTemplates["mailboxDescription"] = "custom {{nodeDescription}}"
|
|
490
|
+
taskTemplates["mailboxDescription"] = "custom {{ticket}} / {{node}} — {{nodeType}} — {{agent}}\n{{nodeDescription}}\n{{successRoutes}}\n{{failureRoutes}}\n{{report}}"
|
|
460
491
|
if err := config.SaveMachine(filepath.Join(root, "config.yaml"), cfg); err != nil {
|
|
461
492
|
t.Fatal(err)
|
|
462
493
|
}
|
|
@@ -504,7 +535,7 @@ func TestInitForcePreservesDurableAndUserState(t *testing.T) {
|
|
|
504
535
|
if !ok {
|
|
505
536
|
t.Fatalf("taskConfig.templates = %#v", cfg.TaskConfig["templates"])
|
|
506
537
|
}
|
|
507
|
-
if got := gotTaskTemplates["mailboxDescription"]; got != "custom {{nodeDescription}}" {
|
|
538
|
+
if got := gotTaskTemplates["mailboxDescription"]; got != "custom {{ticket}} / {{node}} — {{nodeType}} — {{agent}}\n{{nodeDescription}}\n{{successRoutes}}\n{{failureRoutes}}\n{{report}}" {
|
|
508
539
|
t.Fatalf("forced init changed task text override: %#v", got)
|
|
509
540
|
}
|
|
510
541
|
for _, key := range []string{"summaryComment", "feedbackComment"} {
|
|
@@ -1009,7 +1040,239 @@ func TestRepoRegistrationPartialFailureKeepsPriorSuccess(t *testing.T) {
|
|
|
1009
1040
|
}
|
|
1010
1041
|
}
|
|
1011
1042
|
|
|
1043
|
+
func TestDefaultServeReadinessAndStop(t *testing.T) {
|
|
1044
|
+
testDetachedServe(t, nil)
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
func TestDefaultServeRelativeHomeUsesSharedState(t *testing.T) {
|
|
1048
|
+
caller := t.TempDir()
|
|
1049
|
+
relativeHome := ".rf-rel"
|
|
1050
|
+
expectedRoot := filepath.Join(caller, relativeHome)
|
|
1051
|
+
t.Setenv("RELAY_FLOW_HOME", relativeHome)
|
|
1052
|
+
binary := buildCLIBinary(t)
|
|
1053
|
+
t.Cleanup(func() {
|
|
1054
|
+
stop := exec.Command(binary, "stop")
|
|
1055
|
+
stop.Dir = caller
|
|
1056
|
+
stop.Env = os.Environ()
|
|
1057
|
+
_, _ = stop.CombinedOutput()
|
|
1058
|
+
})
|
|
1059
|
+
|
|
1060
|
+
init := exec.Command(binary, initArgs()...)
|
|
1061
|
+
init.Dir = caller
|
|
1062
|
+
init.Env = os.Environ()
|
|
1063
|
+
if out, err := init.CombinedOutput(); err != nil {
|
|
1064
|
+
t.Fatalf("relative-home init: %v\n%s", err, out)
|
|
1065
|
+
}
|
|
1066
|
+
for _, name := range []string{"config.yaml", "state.db"} {
|
|
1067
|
+
if _, err := os.Stat(filepath.Join(expectedRoot, name)); err != nil {
|
|
1068
|
+
t.Fatalf("relative-home init missing %s: %v", name, err)
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
1073
|
+
defer cancel()
|
|
1074
|
+
serve := exec.CommandContext(ctx, binary, "serve")
|
|
1075
|
+
serve.Dir = caller
|
|
1076
|
+
serve.Env = os.Environ()
|
|
1077
|
+
out, err := serve.CombinedOutput()
|
|
1078
|
+
if err != nil {
|
|
1079
|
+
t.Fatalf("relative-home detached serve: %v\n%s", err, out)
|
|
1080
|
+
}
|
|
1081
|
+
client := server.NewClient(filepath.Join(expectedRoot, "server.sock"))
|
|
1082
|
+
if _, err := client.ListRepos(context.Background()); err != nil {
|
|
1083
|
+
t.Fatalf("relative-home server did not respond: %v", err)
|
|
1084
|
+
}
|
|
1085
|
+
stop := exec.Command(binary, "stop")
|
|
1086
|
+
stop.Dir = caller
|
|
1087
|
+
stop.Env = os.Environ()
|
|
1088
|
+
if out, err := stop.CombinedOutput(); err != nil {
|
|
1089
|
+
t.Fatalf("relative-home stop: %v\n%s", err, out)
|
|
1090
|
+
}
|
|
1091
|
+
waitForServerStop(t, client)
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
func TestInProcessDefaultServeReadinessAndStop(t *testing.T) {
|
|
1095
|
+
home := t.TempDir()
|
|
1096
|
+
initHome(t, home)
|
|
1097
|
+
if code := cli(t, home, "", "serve"); code != exitOK {
|
|
1098
|
+
t.Fatalf("in-process detached serve exit = %d", code)
|
|
1099
|
+
}
|
|
1100
|
+
client := server.NewClient(filepath.Join(home, ".relay-flow", "server.sock"))
|
|
1101
|
+
if _, err := client.ListRepos(context.Background()); err != nil {
|
|
1102
|
+
t.Fatalf("in-process detached server did not respond: %v", err)
|
|
1103
|
+
}
|
|
1104
|
+
if code := cli(t, home, "", "stop"); code != exitOK {
|
|
1105
|
+
t.Fatalf("in-process detached stop exit = %d", code)
|
|
1106
|
+
}
|
|
1107
|
+
waitForServerStop(t, client)
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
func TestDetachedServeUsesStableWorkingDirectory(t *testing.T) {
|
|
1111
|
+
home := t.TempDir()
|
|
1112
|
+
root := filepath.Join(home, ".relay-flow")
|
|
1113
|
+
t.Setenv("RELAY_FLOW_HOME", root)
|
|
1114
|
+
if code := run(initArgs(), strings.NewReader("")); code != 0 {
|
|
1115
|
+
t.Fatalf("init exit = %d", code)
|
|
1116
|
+
}
|
|
1117
|
+
binary := buildCLIBinary(t)
|
|
1118
|
+
t.Cleanup(func() {
|
|
1119
|
+
stop := exec.Command(binary, "stop")
|
|
1120
|
+
stop.Env = os.Environ()
|
|
1121
|
+
_, _ = stop.CombinedOutput()
|
|
1122
|
+
})
|
|
1123
|
+
caller := t.TempDir()
|
|
1124
|
+
serve := exec.Command(binary, "serve")
|
|
1125
|
+
serve.Dir = caller
|
|
1126
|
+
serve.Env = os.Environ()
|
|
1127
|
+
out, err := serve.CombinedOutput()
|
|
1128
|
+
if err != nil {
|
|
1129
|
+
t.Fatalf("detached serve from temporary directory: %v\n%s", err, out)
|
|
1130
|
+
}
|
|
1131
|
+
client := server.NewClient(filepath.Join(root, "server.sock"))
|
|
1132
|
+
if _, err := client.ListRepos(context.Background()); err != nil {
|
|
1133
|
+
t.Fatalf("ready server did not respond: %v", err)
|
|
1134
|
+
}
|
|
1135
|
+
if err := os.RemoveAll(caller); err != nil {
|
|
1136
|
+
t.Fatal(err)
|
|
1137
|
+
}
|
|
1138
|
+
cwd, ok, inspectErr := waitForServeProcessCWD(binary)
|
|
1139
|
+
if inspectErr != nil {
|
|
1140
|
+
t.Skipf("process cwd inspection unavailable: %v", inspectErr)
|
|
1141
|
+
}
|
|
1142
|
+
if !ok {
|
|
1143
|
+
t.Fatalf("could not find detached serve process")
|
|
1144
|
+
}
|
|
1145
|
+
if cwd != root {
|
|
1146
|
+
t.Fatalf("detached server cwd = %q, want stable root %q", cwd, root)
|
|
1147
|
+
}
|
|
1148
|
+
stop := exec.Command(binary, "stop")
|
|
1149
|
+
stop.Env = os.Environ()
|
|
1150
|
+
if out, err := stop.CombinedOutput(); err != nil {
|
|
1151
|
+
t.Fatalf("stop: %v\n%s", err, out)
|
|
1152
|
+
}
|
|
1153
|
+
waitForServerStop(t, client)
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
func TestForegroundServeUsesStableWorkingDirectory(t *testing.T) {
|
|
1157
|
+
home := t.TempDir()
|
|
1158
|
+
root := filepath.Join(home, ".relay-flow")
|
|
1159
|
+
t.Setenv("RELAY_FLOW_HOME", root)
|
|
1160
|
+
if code := run(initArgs(), strings.NewReader("")); code != 0 {
|
|
1161
|
+
t.Fatalf("init exit = %d", code)
|
|
1162
|
+
}
|
|
1163
|
+
binary := buildCLIBinary(t)
|
|
1164
|
+
caller := t.TempDir()
|
|
1165
|
+
serve := exec.Command(binary, "serve", "--foreground")
|
|
1166
|
+
serve.Dir = caller
|
|
1167
|
+
serve.Env = os.Environ()
|
|
1168
|
+
if err := serve.Start(); err != nil {
|
|
1169
|
+
t.Fatal(err)
|
|
1170
|
+
}
|
|
1171
|
+
wait := make(chan error, 1)
|
|
1172
|
+
go func() { wait <- serve.Wait() }()
|
|
1173
|
+
t.Cleanup(func() {
|
|
1174
|
+
if serve.Process != nil {
|
|
1175
|
+
_ = serve.Process.Kill()
|
|
1176
|
+
}
|
|
1177
|
+
})
|
|
1178
|
+
client := server.NewClient(filepath.Join(root, "server.sock"))
|
|
1179
|
+
waitForServer(t, client)
|
|
1180
|
+
if err := os.RemoveAll(caller); err != nil {
|
|
1181
|
+
t.Fatal(err)
|
|
1182
|
+
}
|
|
1183
|
+
cwd, ok, inspectErr := waitForServeProcessCWD(binary)
|
|
1184
|
+
if inspectErr != nil {
|
|
1185
|
+
t.Skipf("process cwd inspection unavailable: %v", inspectErr)
|
|
1186
|
+
}
|
|
1187
|
+
if !ok {
|
|
1188
|
+
t.Fatalf("could not find foreground serve process")
|
|
1189
|
+
}
|
|
1190
|
+
if cwd != root {
|
|
1191
|
+
t.Fatalf("foreground server cwd = %q, want stable root %q", cwd, root)
|
|
1192
|
+
}
|
|
1193
|
+
stop := exec.Command(binary, "stop")
|
|
1194
|
+
stop.Env = os.Environ()
|
|
1195
|
+
if out, err := stop.CombinedOutput(); err != nil {
|
|
1196
|
+
t.Fatalf("stop: %v\n%s", err, out)
|
|
1197
|
+
}
|
|
1198
|
+
select {
|
|
1199
|
+
case err := <-wait:
|
|
1200
|
+
if err != nil {
|
|
1201
|
+
t.Fatalf("foreground serve exit: %v", err)
|
|
1202
|
+
}
|
|
1203
|
+
case <-time.After(5 * time.Second):
|
|
1204
|
+
t.Fatal("foreground serve did not exit after stop")
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
func TestForegroundServeRelativeHomeUsesSharedState(t *testing.T) {
|
|
1209
|
+
caller := t.TempDir()
|
|
1210
|
+
relativeHome := ".rf-fg"
|
|
1211
|
+
expectedRoot := filepath.Join(caller, relativeHome)
|
|
1212
|
+
t.Setenv("RELAY_FLOW_HOME", relativeHome)
|
|
1213
|
+
binary := buildCLIBinary(t)
|
|
1214
|
+
|
|
1215
|
+
init := exec.Command(binary, initArgs()...)
|
|
1216
|
+
init.Dir = caller
|
|
1217
|
+
init.Env = os.Environ()
|
|
1218
|
+
if out, err := init.CombinedOutput(); err != nil {
|
|
1219
|
+
t.Fatalf("relative-home init: %v\n%s", err, out)
|
|
1220
|
+
}
|
|
1221
|
+
serve := exec.Command(binary, "serve", "--foreground")
|
|
1222
|
+
serve.Dir = caller
|
|
1223
|
+
serve.Env = os.Environ()
|
|
1224
|
+
var serveStdout, serveStderr bytes.Buffer
|
|
1225
|
+
serve.Stdout = &serveStdout
|
|
1226
|
+
serve.Stderr = &serveStderr
|
|
1227
|
+
if err := serve.Start(); err != nil {
|
|
1228
|
+
t.Fatal(err)
|
|
1229
|
+
}
|
|
1230
|
+
wait := make(chan error, 1)
|
|
1231
|
+
go func() { wait <- serve.Wait() }()
|
|
1232
|
+
t.Cleanup(func() {
|
|
1233
|
+
if serve.Process != nil {
|
|
1234
|
+
_ = serve.Process.Kill()
|
|
1235
|
+
}
|
|
1236
|
+
})
|
|
1237
|
+
client := server.NewClient(filepath.Join(expectedRoot, "server.sock"))
|
|
1238
|
+
if err := waitForServerResult(client, 15*time.Second); err != nil {
|
|
1239
|
+
logData, _ := os.ReadFile(filepath.Join(expectedRoot, "server.log"))
|
|
1240
|
+
t.Fatalf("relative-home foreground readiness: %v; stdout=%q stderr=%q server.log=%q", err, serveStdout.String(), serveStderr.String(), string(logData))
|
|
1241
|
+
}
|
|
1242
|
+
for _, name := range []string{"config.yaml", "state.db", "server.sock", "server.lock"} {
|
|
1243
|
+
if _, err := os.Stat(filepath.Join(expectedRoot, name)); err != nil {
|
|
1244
|
+
t.Fatalf("relative-home foreground missing %s: %v", name, err)
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
cwd, ok, inspectErr := waitForServeProcessCWD(binary)
|
|
1248
|
+
if inspectErr != nil {
|
|
1249
|
+
t.Skipf("process cwd inspection unavailable: %v", inspectErr)
|
|
1250
|
+
}
|
|
1251
|
+
if !ok || cwd != expectedRoot {
|
|
1252
|
+
t.Fatalf("relative-home foreground cwd = %q (found=%v), want %q", cwd, ok, expectedRoot)
|
|
1253
|
+
}
|
|
1254
|
+
stop := exec.Command(binary, "stop")
|
|
1255
|
+
stop.Dir = caller
|
|
1256
|
+
stop.Env = os.Environ()
|
|
1257
|
+
if out, err := stop.CombinedOutput(); err != nil {
|
|
1258
|
+
t.Fatalf("relative-home foreground stop: %v\n%s", err, out)
|
|
1259
|
+
}
|
|
1260
|
+
select {
|
|
1261
|
+
case err := <-wait:
|
|
1262
|
+
if err != nil {
|
|
1263
|
+
t.Fatalf("relative-home foreground serve exit: %v", err)
|
|
1264
|
+
}
|
|
1265
|
+
case <-time.After(5 * time.Second):
|
|
1266
|
+
t.Fatal("relative-home foreground serve did not exit after stop")
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1012
1270
|
func TestBackgroundServeReadinessAndStop(t *testing.T) {
|
|
1271
|
+
testDetachedServe(t, []string{"--background"})
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
func testDetachedServe(t *testing.T, mode []string) {
|
|
1275
|
+
t.Helper()
|
|
1013
1276
|
home := t.TempDir()
|
|
1014
1277
|
root := filepath.Join(home, ".relay-flow")
|
|
1015
1278
|
t.Setenv("RELAY_FLOW_HOME", root)
|
|
@@ -1017,14 +1280,15 @@ func TestBackgroundServeReadinessAndStop(t *testing.T) {
|
|
|
1017
1280
|
t.Fatalf("init exit = %d", code)
|
|
1018
1281
|
}
|
|
1019
1282
|
binary := buildCLIBinary(t)
|
|
1020
|
-
|
|
1283
|
+
args := append([]string{"serve"}, mode...)
|
|
1284
|
+
start := exec.Command(binary, args...)
|
|
1021
1285
|
start.Env = os.Environ()
|
|
1022
1286
|
out, err := start.CombinedOutput()
|
|
1023
1287
|
if err != nil {
|
|
1024
|
-
t.Fatalf("
|
|
1288
|
+
t.Fatalf("detached serve: %v\n%s", err, out)
|
|
1025
1289
|
}
|
|
1026
1290
|
if !strings.Contains(string(out), "Relay-flow server started") {
|
|
1027
|
-
t.Fatalf("
|
|
1291
|
+
t.Fatalf("detached output = %q", out)
|
|
1028
1292
|
}
|
|
1029
1293
|
client := server.NewClient(filepath.Join(root, "server.sock"))
|
|
1030
1294
|
if _, err := client.ListRepos(context.Background()); err != nil {
|
|
@@ -1038,6 +1302,29 @@ func TestBackgroundServeReadinessAndStop(t *testing.T) {
|
|
|
1038
1302
|
waitForServerStop(t, client)
|
|
1039
1303
|
}
|
|
1040
1304
|
|
|
1305
|
+
func TestServeRejectsConflictingModes(t *testing.T) {
|
|
1306
|
+
if code := cli(t, t.TempDir(), "", "serve", "--foreground", "--background"); code != exitUsage {
|
|
1307
|
+
t.Fatalf("conflicting serve modes exit = %d, want %d", code, exitUsage)
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
func TestServeChildArgsPropagateOnlyRecoveryAndDebug(t *testing.T) {
|
|
1312
|
+
for _, tc := range []struct {
|
|
1313
|
+
recover bool
|
|
1314
|
+
debug bool
|
|
1315
|
+
want []string
|
|
1316
|
+
}{
|
|
1317
|
+
{want: []string{"serve"}},
|
|
1318
|
+
{recover: true, want: []string{"serve", "--recover"}},
|
|
1319
|
+
{debug: true, want: []string{"serve", "--debug"}},
|
|
1320
|
+
{recover: true, debug: true, want: []string{"serve", "--recover", "--debug"}},
|
|
1321
|
+
} {
|
|
1322
|
+
if got := serveChildArgs(tc.recover, tc.debug); !reflect.DeepEqual(got, tc.want) {
|
|
1323
|
+
t.Fatalf("serveChildArgs(%v, %v) = %v, want %v", tc.recover, tc.debug, got, tc.want)
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1041
1328
|
func TestBackgroundServeFailurePointsToLog(t *testing.T) {
|
|
1042
1329
|
home := t.TempDir()
|
|
1043
1330
|
root := filepath.Join(home, ".relay-flow")
|
|
@@ -1064,6 +1351,33 @@ func TestBackgroundServeFailurePointsToLog(t *testing.T) {
|
|
|
1064
1351
|
}
|
|
1065
1352
|
}
|
|
1066
1353
|
|
|
1354
|
+
func TestAnchorForegroundServeNormalizesHomeEnvironment(t *testing.T) {
|
|
1355
|
+
originalWD, err := os.Getwd()
|
|
1356
|
+
if err != nil {
|
|
1357
|
+
t.Fatal(err)
|
|
1358
|
+
}
|
|
1359
|
+
originalPWD, hadPWD := os.LookupEnv("PWD")
|
|
1360
|
+
defer func() {
|
|
1361
|
+
_ = os.Chdir(originalWD)
|
|
1362
|
+
if hadPWD {
|
|
1363
|
+
_ = os.Setenv("PWD", originalPWD)
|
|
1364
|
+
} else {
|
|
1365
|
+
_ = os.Unsetenv("PWD")
|
|
1366
|
+
}
|
|
1367
|
+
}()
|
|
1368
|
+
root := filepath.Join(t.TempDir(), "stable-home")
|
|
1369
|
+
t.Setenv("RELAY_FLOW_HOME", "relative-home")
|
|
1370
|
+
if err := anchorForegroundServe(root); err != nil {
|
|
1371
|
+
t.Fatal(err)
|
|
1372
|
+
}
|
|
1373
|
+
if got := os.Getenv("RELAY_FLOW_HOME"); got != root {
|
|
1374
|
+
t.Fatalf("RELAY_FLOW_HOME = %q, want %q", got, root)
|
|
1375
|
+
}
|
|
1376
|
+
if got, err := os.Getwd(); err != nil || got != root {
|
|
1377
|
+
t.Fatalf("working directory = %q, %v; want %q", got, err, root)
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1067
1381
|
func TestForegroundServeRemainsBlocking(t *testing.T) {
|
|
1068
1382
|
home := t.TempDir()
|
|
1069
1383
|
root := filepath.Join(home, ".relay-flow")
|
|
@@ -1072,7 +1386,7 @@ func TestForegroundServeRemainsBlocking(t *testing.T) {
|
|
|
1072
1386
|
t.Fatalf("init exit = %d", code)
|
|
1073
1387
|
}
|
|
1074
1388
|
binary := buildCLIBinary(t)
|
|
1075
|
-
serve := exec.Command(binary, "serve")
|
|
1389
|
+
serve := exec.Command(binary, "serve", "--foreground")
|
|
1076
1390
|
serve.Env = os.Environ()
|
|
1077
1391
|
if err := serve.Start(); err != nil {
|
|
1078
1392
|
t.Fatal(err)
|
|
@@ -1106,6 +1420,104 @@ func TestForegroundServeRemainsBlocking(t *testing.T) {
|
|
|
1106
1420
|
}
|
|
1107
1421
|
}
|
|
1108
1422
|
|
|
1423
|
+
func waitForServeProcessCWD(executable string) (string, bool, error) {
|
|
1424
|
+
deadline := time.Now().Add(5 * time.Second)
|
|
1425
|
+
for time.Now().Before(deadline) {
|
|
1426
|
+
pids, err := serveProcessIDs(executable)
|
|
1427
|
+
if err != nil {
|
|
1428
|
+
return "", false, err
|
|
1429
|
+
}
|
|
1430
|
+
for _, pid := range pids {
|
|
1431
|
+
cwd, readErr := processCWD(pid)
|
|
1432
|
+
if readErr == nil {
|
|
1433
|
+
return cwd, true, nil
|
|
1434
|
+
}
|
|
1435
|
+
if runtime.GOOS == "darwin" {
|
|
1436
|
+
return "", false, readErr
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
time.Sleep(20 * time.Millisecond)
|
|
1440
|
+
}
|
|
1441
|
+
return "", false, nil
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
func serveProcessIDs(executable string) ([]int, error) {
|
|
1445
|
+
switch runtime.GOOS {
|
|
1446
|
+
case "linux":
|
|
1447
|
+
entries, err := os.ReadDir("/proc")
|
|
1448
|
+
if err != nil {
|
|
1449
|
+
return nil, fmt.Errorf("read /proc: %w", err)
|
|
1450
|
+
}
|
|
1451
|
+
var pids []int
|
|
1452
|
+
for _, entry := range entries {
|
|
1453
|
+
if !entry.IsDir() || entry.Name() == "self" {
|
|
1454
|
+
continue
|
|
1455
|
+
}
|
|
1456
|
+
cmdline, readErr := os.ReadFile(filepath.Join("/proc", entry.Name(), "cmdline"))
|
|
1457
|
+
if readErr != nil {
|
|
1458
|
+
continue
|
|
1459
|
+
}
|
|
1460
|
+
args := strings.Split(strings.TrimRight(string(cmdline), "\x00"), "\x00")
|
|
1461
|
+
if len(args) < 2 || args[0] != executable || args[1] != "serve" {
|
|
1462
|
+
continue
|
|
1463
|
+
}
|
|
1464
|
+
pid, parseErr := strconv.Atoi(entry.Name())
|
|
1465
|
+
if parseErr == nil {
|
|
1466
|
+
pids = append(pids, pid)
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
return pids, nil
|
|
1470
|
+
case "darwin":
|
|
1471
|
+
output, err := exec.Command("ps", "-axo", "pid=,command=").Output()
|
|
1472
|
+
if err != nil {
|
|
1473
|
+
return nil, fmt.Errorf("list processes with ps: %w", err)
|
|
1474
|
+
}
|
|
1475
|
+
var pids []int
|
|
1476
|
+
for _, line := range strings.Split(string(output), "\n") {
|
|
1477
|
+
line = strings.TrimSpace(line)
|
|
1478
|
+
fields := strings.Fields(line)
|
|
1479
|
+
if len(fields) < 2 {
|
|
1480
|
+
continue
|
|
1481
|
+
}
|
|
1482
|
+
pid, parseErr := strconv.Atoi(fields[0])
|
|
1483
|
+
if parseErr != nil {
|
|
1484
|
+
continue
|
|
1485
|
+
}
|
|
1486
|
+
command := strings.TrimSpace(strings.TrimPrefix(line, fields[0]))
|
|
1487
|
+
if strings.HasPrefix(command, executable+" serve") {
|
|
1488
|
+
pids = append(pids, pid)
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
return pids, nil
|
|
1492
|
+
default:
|
|
1493
|
+
return nil, fmt.Errorf("process cwd inspection is unsupported on %s", runtime.GOOS)
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
func processCWD(pid int) (string, error) {
|
|
1498
|
+
switch runtime.GOOS {
|
|
1499
|
+
case "linux":
|
|
1500
|
+
return os.Readlink(filepath.Join("/proc", strconv.Itoa(pid), "cwd"))
|
|
1501
|
+
case "darwin":
|
|
1502
|
+
lsof, err := exec.LookPath("lsof")
|
|
1503
|
+
if err != nil {
|
|
1504
|
+
return "", fmt.Errorf("find lsof for Darwin cwd inspection: %w", err)
|
|
1505
|
+
}
|
|
1506
|
+
output, err := exec.Command(lsof, "-a", "-p", strconv.Itoa(pid), "-d", "cwd", "-Fn").Output()
|
|
1507
|
+
if err != nil {
|
|
1508
|
+
return "", fmt.Errorf("inspect cwd with lsof: %w", err)
|
|
1509
|
+
}
|
|
1510
|
+
for _, line := range strings.Split(string(output), "\n") {
|
|
1511
|
+
if strings.HasPrefix(line, "n") {
|
|
1512
|
+
return strings.TrimPrefix(line, "n"), nil
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
return "", fmt.Errorf("lsof returned no cwd for pid %d", pid)
|
|
1516
|
+
default:
|
|
1517
|
+
return "", fmt.Errorf("process cwd inspection is unsupported on %s", runtime.GOOS)
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1109
1521
|
func buildCLIBinary(t *testing.T) string {
|
|
1110
1522
|
t.Helper()
|
|
1111
1523
|
binary := filepath.Join(t.TempDir(), "relay-flow")
|
|
@@ -1119,17 +1531,28 @@ func buildCLIBinary(t *testing.T) string {
|
|
|
1119
1531
|
|
|
1120
1532
|
func waitForServer(t *testing.T, client *server.Client) {
|
|
1121
1533
|
t.Helper()
|
|
1122
|
-
|
|
1534
|
+
waitForServerTimeout(t, client, 5*time.Second)
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
func waitForServerTimeout(t *testing.T, client *server.Client, timeout time.Duration) {
|
|
1538
|
+
t.Helper()
|
|
1539
|
+
if err := waitForServerResult(client, timeout); err != nil {
|
|
1540
|
+
t.Fatal(err)
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
func waitForServerResult(client *server.Client, timeout time.Duration) error {
|
|
1545
|
+
deadline := time.Now().Add(timeout)
|
|
1123
1546
|
for time.Now().Before(deadline) {
|
|
1124
1547
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
1125
1548
|
_, err := client.ListRepos(ctx)
|
|
1126
1549
|
cancel()
|
|
1127
1550
|
if err == nil {
|
|
1128
|
-
return
|
|
1551
|
+
return nil
|
|
1129
1552
|
}
|
|
1130
1553
|
time.Sleep(20 * time.Millisecond)
|
|
1131
1554
|
}
|
|
1132
|
-
|
|
1555
|
+
return fmt.Errorf("server did not become ready")
|
|
1133
1556
|
}
|
|
1134
1557
|
|
|
1135
1558
|
func waitForServerStop(t *testing.T, client *server.Client) {
|
|
@@ -1187,6 +1610,9 @@ func (s *ackServer) RestartRun(context.Context, string) (runsvc.Run, error) {
|
|
|
1187
1610
|
panic("unreachable")
|
|
1188
1611
|
}
|
|
1189
1612
|
func (s *ackServer) CancelRun(context.Context, string, string) error { panic("unreachable") }
|
|
1613
|
+
func (s *ackServer) BackfillClaimOwner(context.Context, string, string, string) error {
|
|
1614
|
+
panic("unreachable")
|
|
1615
|
+
}
|
|
1190
1616
|
func (s *ackServer) DiscoverRepos(context.Context) ([]runner.RepoCandidate, error) {
|
|
1191
1617
|
panic("unreachable")
|
|
1192
1618
|
}
|