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.
Files changed (57) hide show
  1. package/README.md +30 -34
  2. package/cmd/relay-flow/commands_test.go +449 -23
  3. package/cmd/relay-flow/main.go +207 -37
  4. package/cmd/relay-flow/observability_test.go +11 -0
  5. package/cmd/relay-flow/onboarding.go +1 -1
  6. package/cmd/relay-flow/onboarding_test.go +1 -1
  7. package/cmd/relay-flow/scenario_test.go +26 -10
  8. package/cmd/relay-flow/serve.go +27 -1
  9. package/examples/config-reference.yaml +23 -19
  10. package/examples/workflow-reference.yaml +3 -0
  11. package/internal/execution/goworkflows/activities.go +13 -22
  12. package/internal/execution/goworkflows/engine_test.go +17 -3
  13. package/internal/execution/goworkflows/fakes_test.go +20 -0
  14. package/internal/execution/goworkflows/interpreter.go +25 -11
  15. package/internal/execution/goworkflows/mailbox_test.go +28 -1
  16. package/internal/execution/goworkflows/ownership_test.go +259 -0
  17. package/internal/execution/goworkflows/recovery_test.go +81 -1
  18. package/internal/execution/temporal/activities.go +13 -22
  19. package/internal/execution/temporal/compact_mailbox_test.go +56 -0
  20. package/internal/execution/temporal/interpreter.go +16 -3
  21. package/internal/execution/temporal/recovery.go +68 -36
  22. package/internal/harness/harness.go +23 -10
  23. package/internal/harness/opencode/opencode.go +22 -4
  24. package/internal/harness/opencode/opencode_test.go +41 -1
  25. package/internal/harness/opencode/repo_setup.go +1 -1
  26. package/internal/harness/opencode/task_env_test.go +2 -2
  27. package/internal/harness/pi/pi.go +21 -2
  28. package/internal/harness/pi/pi_test.go +7 -4
  29. package/internal/harness/pi/prompt_test.go +13 -5
  30. package/internal/harness/pi/task_env_test.go +18 -2
  31. package/internal/recover/recover.go +30 -1
  32. package/internal/repo/binding_test.go +63 -0
  33. package/internal/repo/repo.go +47 -6
  34. package/internal/router/router.go +39 -5
  35. package/internal/router/router_test.go +67 -1
  36. package/internal/run/manager.go +90 -3
  37. package/internal/run/run_manager_test.go +74 -0
  38. package/internal/server/api_test.go +13 -0
  39. package/internal/server/client.go +7 -0
  40. package/internal/server/fixture_test.go +5 -0
  41. package/internal/server/server.go +32 -3
  42. package/internal/task/beads/beads.go +201 -3
  43. package/internal/task/beads/beads_test.go +172 -0
  44. package/internal/task/jira/effects_test.go +86 -0
  45. package/internal/task/jira/filters_test.go +103 -0
  46. package/internal/task/jira/helpers_test.go +11 -2
  47. package/internal/task/jira/jira.go +244 -15
  48. package/internal/task/jira/normalize.go +30 -15
  49. package/internal/task/jira/rest/client.go +17 -2
  50. package/internal/task/jira/templates_test.go +38 -2
  51. package/internal/task/jira/transition_defaults_test.go +3 -1
  52. package/internal/task/task.go +67 -0
  53. package/internal/workflow/report.go +10 -0
  54. package/internal/workflow/report_test.go +16 -0
  55. package/internal/workflow/workflow.go +24 -0
  56. package/internal/workflow/workflow_test.go +58 -1
  57. 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"}, {"serve", "--recover"}, {"stop"}, {"report"},
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"}, // missing --file
73
- {"workflow", "remove"}, // missing --name
74
- {"workflow", "get"}, // missing --name
75
- {"repo", "remove"}, // missing --name
76
- {"repo", "get"}, // missing --name
77
- {"run", "get"}, // missing --ticket
78
- {"run", "restart"}, // missing --ticket
79
- {"run", "cancel"}, // missing --ticket
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 entry; it
175
- // creates the flock file then blocks. We assert the lock file's mode.
176
- go cli(t, home, "", "serve")
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
- go cli(t, home, "", "serve")
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
- start := exec.Command(binary, "serve", "--background")
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("background serve: %v\n%s", err, out)
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("background output = %q", out)
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
- deadline := time.Now().Add(5 * time.Second)
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
- t.Fatal("server did not become ready")
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
  }