relay-flow 0.3.8-alpha → 0.3.10-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 +27 -19
- package/cmd/relay-flow/commands_test.go +440 -21
- package/cmd/relay-flow/main.go +203 -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/internal/execution/goworkflows/activities.go +11 -0
- package/internal/execution/goworkflows/cancellation_test.go +50 -0
- package/internal/execution/goworkflows/engine.go +278 -9
- package/internal/execution/goworkflows/fakes_test.go +30 -1
- package/internal/execution/goworkflows/interpreter.go +18 -1
- package/internal/execution/goworkflows/ownership_test.go +259 -0
- package/internal/execution/goworkflows/projection.go +8 -0
- package/internal/execution/goworkflows/recovery_test.go +563 -1
- package/internal/execution/projection/detail_test.go +38 -0
- package/internal/execution/projection/projection.go +80 -15
- package/internal/execution/temporal/interpreter.go +2 -1
- package/internal/execution/temporal/recovery.go +68 -36
- package/internal/harness/harness.go +1 -0
- package/internal/harness/opencode/opencode.go +5 -0
- package/internal/harness/opencode/opencode_test.go +17 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/pi/pi.go +6 -0
- package/internal/harness/pi/task_env_test.go +16 -0
- 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 +228 -4
- package/internal/task/jira/normalize.go +30 -15
- package/internal/task/jira/rest/client.go +17 -2
- package/internal/task/jira/transition_defaults_test.go +3 -1
- package/internal/task/task.go +66 -0
- package/internal/workflow/report.go +3 -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
package/README.md
CHANGED
|
@@ -16,6 +16,10 @@ Install the latest released CLI with Homebrew:
|
|
|
16
16
|
brew install rajpopat27/tap/relay-flow
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
+
The package installs both equivalent commands, `relay-flow` and `rf`. The
|
|
20
|
+
short `rf` name is also included in release archives and can be installed by
|
|
21
|
+
`install.sh` or with `go install ./cmd/relay-flow ./cmd/rf` from a checkout.
|
|
22
|
+
|
|
19
23
|
### 2. Install the harness extension
|
|
20
24
|
|
|
21
25
|
Choose the harness that will run your agent sessions. Both commands install the
|
|
@@ -24,13 +28,13 @@ same `relay-flow-plugin` package with host-specific entrypoints.
|
|
|
24
28
|
**OpenCode**
|
|
25
29
|
|
|
26
30
|
```sh
|
|
27
|
-
opencode plugin relay-flow-plugin@0.3.
|
|
31
|
+
opencode plugin relay-flow-plugin@0.3.10-alpha
|
|
28
32
|
```
|
|
29
33
|
|
|
30
34
|
**Pi**
|
|
31
35
|
|
|
32
36
|
```sh
|
|
33
|
-
pi install npm:relay-flow-plugin@0.3.
|
|
37
|
+
pi install npm:relay-flow-plugin@0.3.10-alpha
|
|
34
38
|
```
|
|
35
39
|
|
|
36
40
|
Pi loads the package's `pi.ts` extension from its manifest. Do not add
|
|
@@ -62,7 +66,7 @@ relay-flow init \
|
|
|
62
66
|
--runner-plugin orca \
|
|
63
67
|
--harness-plugin opencode
|
|
64
68
|
relay-flow task auth
|
|
65
|
-
|
|
69
|
+
rf serve
|
|
66
70
|
```
|
|
67
71
|
|
|
68
72
|
For Beads, skip `task auth` and initialize/authenticate the Beads workspace
|
|
@@ -84,7 +88,7 @@ For Herdr, register the repository path directly; relay-flow creates ticket
|
|
|
84
88
|
worktrees lazily. The interactive registration asks for the task-system values
|
|
85
89
|
required by the selected task plugin. `repo register` remains the standalone
|
|
86
90
|
escape hatch for adding repositories after initialization and requires a
|
|
87
|
-
running `relay-flow serve
|
|
91
|
+
running `rf serve` (or `relay-flow serve`).
|
|
88
92
|
|
|
89
93
|
### 5. Submit a workflow
|
|
90
94
|
|
|
@@ -160,7 +164,7 @@ OpenCode plugin configuration uses both entrypoints. The server entrypoint is li
|
|
|
160
164
|
```json
|
|
161
165
|
{
|
|
162
166
|
"$schema": "https://opencode.ai/config.json",
|
|
163
|
-
"plugin": ["relay-flow-plugin@0.3.
|
|
167
|
+
"plugin": ["relay-flow-plugin@0.3.10-alpha"]
|
|
164
168
|
}
|
|
165
169
|
```
|
|
166
170
|
|
|
@@ -169,7 +173,7 @@ The native HITL approval entrypoint is listed in `.opencode/tui.json`:
|
|
|
169
173
|
```json
|
|
170
174
|
{
|
|
171
175
|
"$schema": "https://opencode.ai/tui.json",
|
|
172
|
-
"plugin": ["relay-flow-plugin@0.3.
|
|
176
|
+
"plugin": ["relay-flow-plugin@0.3.10-alpha"]
|
|
173
177
|
}
|
|
174
178
|
```
|
|
175
179
|
|
|
@@ -185,7 +189,7 @@ Pi plugin: install the same published package manually in Pi's global package
|
|
|
185
189
|
settings before starting a Pi harness session:
|
|
186
190
|
|
|
187
191
|
```sh
|
|
188
|
-
pi install npm:relay-flow-plugin@0.3.
|
|
192
|
+
pi install npm:relay-flow-plugin@0.3.10-alpha
|
|
189
193
|
```
|
|
190
194
|
|
|
191
195
|
Relay-flow does not install or configure the package automatically. Pi resolves
|
|
@@ -221,7 +225,7 @@ For scripts and existing installations, keep the independent sequence:
|
|
|
221
225
|
```sh
|
|
222
226
|
relay-flow init --task-plugin jira --runner-plugin orca --harness-plugin opencode
|
|
223
227
|
relay-flow task auth --site https://company.atlassian.net --email you@example.com --token "$JIRA_API_TOKEN"
|
|
224
|
-
|
|
228
|
+
rf serve
|
|
225
229
|
```
|
|
226
230
|
|
|
227
231
|
`task auth` delegates to the selected task plug-in. Jira prompts for its site,
|
|
@@ -262,12 +266,11 @@ workflows/<name>.yaml 0644 submitted workflow definitions
|
|
|
262
266
|
|
|
263
267
|
The guided first-run flow can start the server and open repository registration
|
|
264
268
|
for you. `repo register` remains a server-backed standalone command for later
|
|
265
|
-
additions: if no server is running, start it with
|
|
266
|
-
|
|
267
|
-
new home).
|
|
269
|
+
additions: if no server is running, start it with `rf serve` (or rerun
|
|
270
|
+
interactive `relay-flow init` for a new home).
|
|
268
271
|
|
|
269
272
|
```sh
|
|
270
|
-
|
|
273
|
+
rf serve
|
|
271
274
|
```
|
|
272
275
|
|
|
273
276
|
The repo must already exist in the runner. For the Orca runner, add it first
|
|
@@ -402,13 +405,18 @@ Outside that flow, `repo register` and `workflow submit` require the server to
|
|
|
402
405
|
already be running; the same process polls repos and drives runs.
|
|
403
406
|
|
|
404
407
|
```sh
|
|
405
|
-
|
|
406
|
-
relay-flow serve --
|
|
407
|
-
relay-flow serve --
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
408
|
+
rf serve # detached by default; waits for readiness
|
|
409
|
+
relay-flow serve --foreground # blocking mode for supervisors and development
|
|
410
|
+
relay-flow serve --background # compatibility spelling for detached startup
|
|
411
|
+
rf serve --recover # explicit destructive rebuild from the task system
|
|
412
|
+
rf stop
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
Both executable names share the same home, socket, lock, database, logs, exit
|
|
416
|
+
codes, and server lifecycle. Detached startup logs diagnostics to
|
|
417
|
+
`~/.relay-flow/server.log` and does not inherit a temporary or deleted caller
|
|
418
|
+
working directory. Use `--foreground` only when the process supervisor owns
|
|
419
|
+
the server lifetime; `--background` remains accepted for existing scripts.
|
|
412
420
|
|
|
413
421
|
`serve --recover` treats ALL SQLite execution state as gone, closes surviving run-owned terminals (preserving worktrees and code), resets Jira parent+mailbox state, and starts every labeled parent in a fresh deterministic run from `start` with fresh `nodeVisitID`s. Recovery never runs automatically; database loss is never inferred.
|
|
414
422
|
|
|
@@ -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"
|
|
@@ -43,11 +46,11 @@ func cli(t *testing.T, home string, stdin string, args ...string) (code int) {
|
|
|
43
46
|
func TestCommandSurfaceExists(t *testing.T) {
|
|
44
47
|
home := t.TempDir()
|
|
45
48
|
commands := [][]string{
|
|
46
|
-
{"init"}, {"task", "auth"}, {"
|
|
49
|
+
{"init"}, {"task", "auth"}, {"stop"}, {"report"},
|
|
47
50
|
{"workflow", "submit", "--file", "x.yaml"}, {"workflow", "remove", "--name", "x"},
|
|
48
51
|
{"workflow", "list"}, {"workflow", "get", "--name", "x"},
|
|
49
52
|
{"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"},
|
|
53
|
+
{"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
54
|
}
|
|
52
55
|
for _, argv := range commands {
|
|
53
56
|
// Recognized commands do not exit 2 ("usage/unknown"); they may exit
|
|
@@ -69,14 +72,15 @@ func TestUnknownFlagExits2(t *testing.T) {
|
|
|
69
72
|
func TestRequiredFlagMissingExits2(t *testing.T) {
|
|
70
73
|
home := t.TempDir()
|
|
71
74
|
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"},
|
|
75
|
+
{"workflow", "submit"}, // missing --file
|
|
76
|
+
{"workflow", "remove"}, // missing --name
|
|
77
|
+
{"workflow", "get"}, // missing --name
|
|
78
|
+
{"repo", "remove"}, // missing --name
|
|
79
|
+
{"repo", "get"}, // missing --name
|
|
80
|
+
{"run", "get"}, // missing --ticket
|
|
81
|
+
{"run", "restart"}, // missing --ticket
|
|
82
|
+
{"run", "cancel"}, // missing --ticket
|
|
83
|
+
{"run", "backfill-owner"}, // missing --repo/--ticket/--workflow
|
|
80
84
|
} {
|
|
81
85
|
if code := cli(t, home, "", argv...); code != 2 {
|
|
82
86
|
t.Fatalf("%v with missing required flag exit = %d, want 2", argv, code)
|
|
@@ -171,9 +175,19 @@ func TestTaskAuthDispatchesSelectedPlugin(t *testing.T) {
|
|
|
171
175
|
func TestServerLockIsOwnerOnly(t *testing.T) {
|
|
172
176
|
home := t.TempDir()
|
|
173
177
|
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
|
-
|
|
178
|
+
// Start foreground serve in the background of the test via the parser
|
|
179
|
+
// entry; it creates the flock file then blocks. We assert the lock file's
|
|
180
|
+
// mode, then stop the fixture so the test does not leak a server.
|
|
181
|
+
done := make(chan int, 1)
|
|
182
|
+
go func() { done <- cli(t, home, "", "serve", "--foreground") }()
|
|
183
|
+
t.Cleanup(func() {
|
|
184
|
+
_ = cli(t, home, "", "stop")
|
|
185
|
+
select {
|
|
186
|
+
case <-done:
|
|
187
|
+
case <-time.After(5 * time.Second):
|
|
188
|
+
t.Errorf("foreground serve did not stop")
|
|
189
|
+
}
|
|
190
|
+
})
|
|
177
191
|
lock := filepath.Join(home, ".relay-flow", "server.lock")
|
|
178
192
|
var fi os.FileInfo
|
|
179
193
|
var err error
|
|
@@ -191,6 +205,7 @@ func TestServerLockIsOwnerOnly(t *testing.T) {
|
|
|
191
205
|
if fi.Mode().Perm() != 0600 {
|
|
192
206
|
t.Fatalf("server.lock mode = %o, want 0600", fi.Mode().Perm())
|
|
193
207
|
}
|
|
208
|
+
waitForServer(t, server.NewClient(filepath.Join(home, ".relay-flow", "server.sock")))
|
|
194
209
|
}
|
|
195
210
|
|
|
196
211
|
// 3.29 (socket): the serve startup path owns server.sock creation and chmods
|
|
@@ -199,7 +214,16 @@ func TestServerLockIsOwnerOnly(t *testing.T) {
|
|
|
199
214
|
func TestServerSocketIsOwnerOnly(t *testing.T) {
|
|
200
215
|
home := t.TempDir()
|
|
201
216
|
initHome(t, home)
|
|
202
|
-
|
|
217
|
+
done := make(chan int, 1)
|
|
218
|
+
go func() { done <- cli(t, home, "", "serve", "--foreground") }()
|
|
219
|
+
t.Cleanup(func() {
|
|
220
|
+
_ = cli(t, home, "", "stop")
|
|
221
|
+
select {
|
|
222
|
+
case <-done:
|
|
223
|
+
case <-time.After(5 * time.Second):
|
|
224
|
+
t.Errorf("foreground serve did not stop")
|
|
225
|
+
}
|
|
226
|
+
})
|
|
203
227
|
sock := filepath.Join(home, ".relay-flow", "server.sock")
|
|
204
228
|
var fi os.FileInfo
|
|
205
229
|
var err error
|
|
@@ -1009,7 +1033,239 @@ func TestRepoRegistrationPartialFailureKeepsPriorSuccess(t *testing.T) {
|
|
|
1009
1033
|
}
|
|
1010
1034
|
}
|
|
1011
1035
|
|
|
1036
|
+
func TestDefaultServeReadinessAndStop(t *testing.T) {
|
|
1037
|
+
testDetachedServe(t, nil)
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
func TestDefaultServeRelativeHomeUsesSharedState(t *testing.T) {
|
|
1041
|
+
caller := t.TempDir()
|
|
1042
|
+
relativeHome := ".rf-rel"
|
|
1043
|
+
expectedRoot := filepath.Join(caller, relativeHome)
|
|
1044
|
+
t.Setenv("RELAY_FLOW_HOME", relativeHome)
|
|
1045
|
+
binary := buildCLIBinary(t)
|
|
1046
|
+
t.Cleanup(func() {
|
|
1047
|
+
stop := exec.Command(binary, "stop")
|
|
1048
|
+
stop.Dir = caller
|
|
1049
|
+
stop.Env = os.Environ()
|
|
1050
|
+
_, _ = stop.CombinedOutput()
|
|
1051
|
+
})
|
|
1052
|
+
|
|
1053
|
+
init := exec.Command(binary, initArgs()...)
|
|
1054
|
+
init.Dir = caller
|
|
1055
|
+
init.Env = os.Environ()
|
|
1056
|
+
if out, err := init.CombinedOutput(); err != nil {
|
|
1057
|
+
t.Fatalf("relative-home init: %v\n%s", err, out)
|
|
1058
|
+
}
|
|
1059
|
+
for _, name := range []string{"config.yaml", "state.db"} {
|
|
1060
|
+
if _, err := os.Stat(filepath.Join(expectedRoot, name)); err != nil {
|
|
1061
|
+
t.Fatalf("relative-home init missing %s: %v", name, err)
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
1066
|
+
defer cancel()
|
|
1067
|
+
serve := exec.CommandContext(ctx, binary, "serve")
|
|
1068
|
+
serve.Dir = caller
|
|
1069
|
+
serve.Env = os.Environ()
|
|
1070
|
+
out, err := serve.CombinedOutput()
|
|
1071
|
+
if err != nil {
|
|
1072
|
+
t.Fatalf("relative-home detached serve: %v\n%s", err, out)
|
|
1073
|
+
}
|
|
1074
|
+
client := server.NewClient(filepath.Join(expectedRoot, "server.sock"))
|
|
1075
|
+
if _, err := client.ListRepos(context.Background()); err != nil {
|
|
1076
|
+
t.Fatalf("relative-home server did not respond: %v", err)
|
|
1077
|
+
}
|
|
1078
|
+
stop := exec.Command(binary, "stop")
|
|
1079
|
+
stop.Dir = caller
|
|
1080
|
+
stop.Env = os.Environ()
|
|
1081
|
+
if out, err := stop.CombinedOutput(); err != nil {
|
|
1082
|
+
t.Fatalf("relative-home stop: %v\n%s", err, out)
|
|
1083
|
+
}
|
|
1084
|
+
waitForServerStop(t, client)
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
func TestInProcessDefaultServeReadinessAndStop(t *testing.T) {
|
|
1088
|
+
home := t.TempDir()
|
|
1089
|
+
initHome(t, home)
|
|
1090
|
+
if code := cli(t, home, "", "serve"); code != exitOK {
|
|
1091
|
+
t.Fatalf("in-process detached serve exit = %d", code)
|
|
1092
|
+
}
|
|
1093
|
+
client := server.NewClient(filepath.Join(home, ".relay-flow", "server.sock"))
|
|
1094
|
+
if _, err := client.ListRepos(context.Background()); err != nil {
|
|
1095
|
+
t.Fatalf("in-process detached server did not respond: %v", err)
|
|
1096
|
+
}
|
|
1097
|
+
if code := cli(t, home, "", "stop"); code != exitOK {
|
|
1098
|
+
t.Fatalf("in-process detached stop exit = %d", code)
|
|
1099
|
+
}
|
|
1100
|
+
waitForServerStop(t, client)
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
func TestDetachedServeUsesStableWorkingDirectory(t *testing.T) {
|
|
1104
|
+
home := t.TempDir()
|
|
1105
|
+
root := filepath.Join(home, ".relay-flow")
|
|
1106
|
+
t.Setenv("RELAY_FLOW_HOME", root)
|
|
1107
|
+
if code := run(initArgs(), strings.NewReader("")); code != 0 {
|
|
1108
|
+
t.Fatalf("init exit = %d", code)
|
|
1109
|
+
}
|
|
1110
|
+
binary := buildCLIBinary(t)
|
|
1111
|
+
t.Cleanup(func() {
|
|
1112
|
+
stop := exec.Command(binary, "stop")
|
|
1113
|
+
stop.Env = os.Environ()
|
|
1114
|
+
_, _ = stop.CombinedOutput()
|
|
1115
|
+
})
|
|
1116
|
+
caller := t.TempDir()
|
|
1117
|
+
serve := exec.Command(binary, "serve")
|
|
1118
|
+
serve.Dir = caller
|
|
1119
|
+
serve.Env = os.Environ()
|
|
1120
|
+
out, err := serve.CombinedOutput()
|
|
1121
|
+
if err != nil {
|
|
1122
|
+
t.Fatalf("detached serve from temporary directory: %v\n%s", err, out)
|
|
1123
|
+
}
|
|
1124
|
+
client := server.NewClient(filepath.Join(root, "server.sock"))
|
|
1125
|
+
if _, err := client.ListRepos(context.Background()); err != nil {
|
|
1126
|
+
t.Fatalf("ready server did not respond: %v", err)
|
|
1127
|
+
}
|
|
1128
|
+
if err := os.RemoveAll(caller); err != nil {
|
|
1129
|
+
t.Fatal(err)
|
|
1130
|
+
}
|
|
1131
|
+
cwd, ok, inspectErr := waitForServeProcessCWD(binary)
|
|
1132
|
+
if inspectErr != nil {
|
|
1133
|
+
t.Skipf("process cwd inspection unavailable: %v", inspectErr)
|
|
1134
|
+
}
|
|
1135
|
+
if !ok {
|
|
1136
|
+
t.Fatalf("could not find detached serve process")
|
|
1137
|
+
}
|
|
1138
|
+
if cwd != root {
|
|
1139
|
+
t.Fatalf("detached server cwd = %q, want stable root %q", cwd, root)
|
|
1140
|
+
}
|
|
1141
|
+
stop := exec.Command(binary, "stop")
|
|
1142
|
+
stop.Env = os.Environ()
|
|
1143
|
+
if out, err := stop.CombinedOutput(); err != nil {
|
|
1144
|
+
t.Fatalf("stop: %v\n%s", err, out)
|
|
1145
|
+
}
|
|
1146
|
+
waitForServerStop(t, client)
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
func TestForegroundServeUsesStableWorkingDirectory(t *testing.T) {
|
|
1150
|
+
home := t.TempDir()
|
|
1151
|
+
root := filepath.Join(home, ".relay-flow")
|
|
1152
|
+
t.Setenv("RELAY_FLOW_HOME", root)
|
|
1153
|
+
if code := run(initArgs(), strings.NewReader("")); code != 0 {
|
|
1154
|
+
t.Fatalf("init exit = %d", code)
|
|
1155
|
+
}
|
|
1156
|
+
binary := buildCLIBinary(t)
|
|
1157
|
+
caller := t.TempDir()
|
|
1158
|
+
serve := exec.Command(binary, "serve", "--foreground")
|
|
1159
|
+
serve.Dir = caller
|
|
1160
|
+
serve.Env = os.Environ()
|
|
1161
|
+
if err := serve.Start(); err != nil {
|
|
1162
|
+
t.Fatal(err)
|
|
1163
|
+
}
|
|
1164
|
+
wait := make(chan error, 1)
|
|
1165
|
+
go func() { wait <- serve.Wait() }()
|
|
1166
|
+
t.Cleanup(func() {
|
|
1167
|
+
if serve.Process != nil {
|
|
1168
|
+
_ = serve.Process.Kill()
|
|
1169
|
+
}
|
|
1170
|
+
})
|
|
1171
|
+
client := server.NewClient(filepath.Join(root, "server.sock"))
|
|
1172
|
+
waitForServer(t, client)
|
|
1173
|
+
if err := os.RemoveAll(caller); err != nil {
|
|
1174
|
+
t.Fatal(err)
|
|
1175
|
+
}
|
|
1176
|
+
cwd, ok, inspectErr := waitForServeProcessCWD(binary)
|
|
1177
|
+
if inspectErr != nil {
|
|
1178
|
+
t.Skipf("process cwd inspection unavailable: %v", inspectErr)
|
|
1179
|
+
}
|
|
1180
|
+
if !ok {
|
|
1181
|
+
t.Fatalf("could not find foreground serve process")
|
|
1182
|
+
}
|
|
1183
|
+
if cwd != root {
|
|
1184
|
+
t.Fatalf("foreground server cwd = %q, want stable root %q", cwd, root)
|
|
1185
|
+
}
|
|
1186
|
+
stop := exec.Command(binary, "stop")
|
|
1187
|
+
stop.Env = os.Environ()
|
|
1188
|
+
if out, err := stop.CombinedOutput(); err != nil {
|
|
1189
|
+
t.Fatalf("stop: %v\n%s", err, out)
|
|
1190
|
+
}
|
|
1191
|
+
select {
|
|
1192
|
+
case err := <-wait:
|
|
1193
|
+
if err != nil {
|
|
1194
|
+
t.Fatalf("foreground serve exit: %v", err)
|
|
1195
|
+
}
|
|
1196
|
+
case <-time.After(5 * time.Second):
|
|
1197
|
+
t.Fatal("foreground serve did not exit after stop")
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
func TestForegroundServeRelativeHomeUsesSharedState(t *testing.T) {
|
|
1202
|
+
caller := t.TempDir()
|
|
1203
|
+
relativeHome := ".rf-fg"
|
|
1204
|
+
expectedRoot := filepath.Join(caller, relativeHome)
|
|
1205
|
+
t.Setenv("RELAY_FLOW_HOME", relativeHome)
|
|
1206
|
+
binary := buildCLIBinary(t)
|
|
1207
|
+
|
|
1208
|
+
init := exec.Command(binary, initArgs()...)
|
|
1209
|
+
init.Dir = caller
|
|
1210
|
+
init.Env = os.Environ()
|
|
1211
|
+
if out, err := init.CombinedOutput(); err != nil {
|
|
1212
|
+
t.Fatalf("relative-home init: %v\n%s", err, out)
|
|
1213
|
+
}
|
|
1214
|
+
serve := exec.Command(binary, "serve", "--foreground")
|
|
1215
|
+
serve.Dir = caller
|
|
1216
|
+
serve.Env = os.Environ()
|
|
1217
|
+
var serveStdout, serveStderr bytes.Buffer
|
|
1218
|
+
serve.Stdout = &serveStdout
|
|
1219
|
+
serve.Stderr = &serveStderr
|
|
1220
|
+
if err := serve.Start(); err != nil {
|
|
1221
|
+
t.Fatal(err)
|
|
1222
|
+
}
|
|
1223
|
+
wait := make(chan error, 1)
|
|
1224
|
+
go func() { wait <- serve.Wait() }()
|
|
1225
|
+
t.Cleanup(func() {
|
|
1226
|
+
if serve.Process != nil {
|
|
1227
|
+
_ = serve.Process.Kill()
|
|
1228
|
+
}
|
|
1229
|
+
})
|
|
1230
|
+
client := server.NewClient(filepath.Join(expectedRoot, "server.sock"))
|
|
1231
|
+
if err := waitForServerResult(client, 15*time.Second); err != nil {
|
|
1232
|
+
logData, _ := os.ReadFile(filepath.Join(expectedRoot, "server.log"))
|
|
1233
|
+
t.Fatalf("relative-home foreground readiness: %v; stdout=%q stderr=%q server.log=%q", err, serveStdout.String(), serveStderr.String(), string(logData))
|
|
1234
|
+
}
|
|
1235
|
+
for _, name := range []string{"config.yaml", "state.db", "server.sock", "server.lock"} {
|
|
1236
|
+
if _, err := os.Stat(filepath.Join(expectedRoot, name)); err != nil {
|
|
1237
|
+
t.Fatalf("relative-home foreground missing %s: %v", name, err)
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
cwd, ok, inspectErr := waitForServeProcessCWD(binary)
|
|
1241
|
+
if inspectErr != nil {
|
|
1242
|
+
t.Skipf("process cwd inspection unavailable: %v", inspectErr)
|
|
1243
|
+
}
|
|
1244
|
+
if !ok || cwd != expectedRoot {
|
|
1245
|
+
t.Fatalf("relative-home foreground cwd = %q (found=%v), want %q", cwd, ok, expectedRoot)
|
|
1246
|
+
}
|
|
1247
|
+
stop := exec.Command(binary, "stop")
|
|
1248
|
+
stop.Dir = caller
|
|
1249
|
+
stop.Env = os.Environ()
|
|
1250
|
+
if out, err := stop.CombinedOutput(); err != nil {
|
|
1251
|
+
t.Fatalf("relative-home foreground stop: %v\n%s", err, out)
|
|
1252
|
+
}
|
|
1253
|
+
select {
|
|
1254
|
+
case err := <-wait:
|
|
1255
|
+
if err != nil {
|
|
1256
|
+
t.Fatalf("relative-home foreground serve exit: %v", err)
|
|
1257
|
+
}
|
|
1258
|
+
case <-time.After(5 * time.Second):
|
|
1259
|
+
t.Fatal("relative-home foreground serve did not exit after stop")
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1012
1263
|
func TestBackgroundServeReadinessAndStop(t *testing.T) {
|
|
1264
|
+
testDetachedServe(t, []string{"--background"})
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
func testDetachedServe(t *testing.T, mode []string) {
|
|
1268
|
+
t.Helper()
|
|
1013
1269
|
home := t.TempDir()
|
|
1014
1270
|
root := filepath.Join(home, ".relay-flow")
|
|
1015
1271
|
t.Setenv("RELAY_FLOW_HOME", root)
|
|
@@ -1017,14 +1273,15 @@ func TestBackgroundServeReadinessAndStop(t *testing.T) {
|
|
|
1017
1273
|
t.Fatalf("init exit = %d", code)
|
|
1018
1274
|
}
|
|
1019
1275
|
binary := buildCLIBinary(t)
|
|
1020
|
-
|
|
1276
|
+
args := append([]string{"serve"}, mode...)
|
|
1277
|
+
start := exec.Command(binary, args...)
|
|
1021
1278
|
start.Env = os.Environ()
|
|
1022
1279
|
out, err := start.CombinedOutput()
|
|
1023
1280
|
if err != nil {
|
|
1024
|
-
t.Fatalf("
|
|
1281
|
+
t.Fatalf("detached serve: %v\n%s", err, out)
|
|
1025
1282
|
}
|
|
1026
1283
|
if !strings.Contains(string(out), "Relay-flow server started") {
|
|
1027
|
-
t.Fatalf("
|
|
1284
|
+
t.Fatalf("detached output = %q", out)
|
|
1028
1285
|
}
|
|
1029
1286
|
client := server.NewClient(filepath.Join(root, "server.sock"))
|
|
1030
1287
|
if _, err := client.ListRepos(context.Background()); err != nil {
|
|
@@ -1038,6 +1295,29 @@ func TestBackgroundServeReadinessAndStop(t *testing.T) {
|
|
|
1038
1295
|
waitForServerStop(t, client)
|
|
1039
1296
|
}
|
|
1040
1297
|
|
|
1298
|
+
func TestServeRejectsConflictingModes(t *testing.T) {
|
|
1299
|
+
if code := cli(t, t.TempDir(), "", "serve", "--foreground", "--background"); code != exitUsage {
|
|
1300
|
+
t.Fatalf("conflicting serve modes exit = %d, want %d", code, exitUsage)
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
func TestServeChildArgsPropagateOnlyRecoveryAndDebug(t *testing.T) {
|
|
1305
|
+
for _, tc := range []struct {
|
|
1306
|
+
recover bool
|
|
1307
|
+
debug bool
|
|
1308
|
+
want []string
|
|
1309
|
+
}{
|
|
1310
|
+
{want: []string{"serve"}},
|
|
1311
|
+
{recover: true, want: []string{"serve", "--recover"}},
|
|
1312
|
+
{debug: true, want: []string{"serve", "--debug"}},
|
|
1313
|
+
{recover: true, debug: true, want: []string{"serve", "--recover", "--debug"}},
|
|
1314
|
+
} {
|
|
1315
|
+
if got := serveChildArgs(tc.recover, tc.debug); !reflect.DeepEqual(got, tc.want) {
|
|
1316
|
+
t.Fatalf("serveChildArgs(%v, %v) = %v, want %v", tc.recover, tc.debug, got, tc.want)
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1041
1321
|
func TestBackgroundServeFailurePointsToLog(t *testing.T) {
|
|
1042
1322
|
home := t.TempDir()
|
|
1043
1323
|
root := filepath.Join(home, ".relay-flow")
|
|
@@ -1064,6 +1344,33 @@ func TestBackgroundServeFailurePointsToLog(t *testing.T) {
|
|
|
1064
1344
|
}
|
|
1065
1345
|
}
|
|
1066
1346
|
|
|
1347
|
+
func TestAnchorForegroundServeNormalizesHomeEnvironment(t *testing.T) {
|
|
1348
|
+
originalWD, err := os.Getwd()
|
|
1349
|
+
if err != nil {
|
|
1350
|
+
t.Fatal(err)
|
|
1351
|
+
}
|
|
1352
|
+
originalPWD, hadPWD := os.LookupEnv("PWD")
|
|
1353
|
+
defer func() {
|
|
1354
|
+
_ = os.Chdir(originalWD)
|
|
1355
|
+
if hadPWD {
|
|
1356
|
+
_ = os.Setenv("PWD", originalPWD)
|
|
1357
|
+
} else {
|
|
1358
|
+
_ = os.Unsetenv("PWD")
|
|
1359
|
+
}
|
|
1360
|
+
}()
|
|
1361
|
+
root := filepath.Join(t.TempDir(), "stable-home")
|
|
1362
|
+
t.Setenv("RELAY_FLOW_HOME", "relative-home")
|
|
1363
|
+
if err := anchorForegroundServe(root); err != nil {
|
|
1364
|
+
t.Fatal(err)
|
|
1365
|
+
}
|
|
1366
|
+
if got := os.Getenv("RELAY_FLOW_HOME"); got != root {
|
|
1367
|
+
t.Fatalf("RELAY_FLOW_HOME = %q, want %q", got, root)
|
|
1368
|
+
}
|
|
1369
|
+
if got, err := os.Getwd(); err != nil || got != root {
|
|
1370
|
+
t.Fatalf("working directory = %q, %v; want %q", got, err, root)
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1067
1374
|
func TestForegroundServeRemainsBlocking(t *testing.T) {
|
|
1068
1375
|
home := t.TempDir()
|
|
1069
1376
|
root := filepath.Join(home, ".relay-flow")
|
|
@@ -1072,7 +1379,7 @@ func TestForegroundServeRemainsBlocking(t *testing.T) {
|
|
|
1072
1379
|
t.Fatalf("init exit = %d", code)
|
|
1073
1380
|
}
|
|
1074
1381
|
binary := buildCLIBinary(t)
|
|
1075
|
-
serve := exec.Command(binary, "serve")
|
|
1382
|
+
serve := exec.Command(binary, "serve", "--foreground")
|
|
1076
1383
|
serve.Env = os.Environ()
|
|
1077
1384
|
if err := serve.Start(); err != nil {
|
|
1078
1385
|
t.Fatal(err)
|
|
@@ -1106,6 +1413,104 @@ func TestForegroundServeRemainsBlocking(t *testing.T) {
|
|
|
1106
1413
|
}
|
|
1107
1414
|
}
|
|
1108
1415
|
|
|
1416
|
+
func waitForServeProcessCWD(executable string) (string, bool, error) {
|
|
1417
|
+
deadline := time.Now().Add(5 * time.Second)
|
|
1418
|
+
for time.Now().Before(deadline) {
|
|
1419
|
+
pids, err := serveProcessIDs(executable)
|
|
1420
|
+
if err != nil {
|
|
1421
|
+
return "", false, err
|
|
1422
|
+
}
|
|
1423
|
+
for _, pid := range pids {
|
|
1424
|
+
cwd, readErr := processCWD(pid)
|
|
1425
|
+
if readErr == nil {
|
|
1426
|
+
return cwd, true, nil
|
|
1427
|
+
}
|
|
1428
|
+
if runtime.GOOS == "darwin" {
|
|
1429
|
+
return "", false, readErr
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
time.Sleep(20 * time.Millisecond)
|
|
1433
|
+
}
|
|
1434
|
+
return "", false, nil
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
func serveProcessIDs(executable string) ([]int, error) {
|
|
1438
|
+
switch runtime.GOOS {
|
|
1439
|
+
case "linux":
|
|
1440
|
+
entries, err := os.ReadDir("/proc")
|
|
1441
|
+
if err != nil {
|
|
1442
|
+
return nil, fmt.Errorf("read /proc: %w", err)
|
|
1443
|
+
}
|
|
1444
|
+
var pids []int
|
|
1445
|
+
for _, entry := range entries {
|
|
1446
|
+
if !entry.IsDir() || entry.Name() == "self" {
|
|
1447
|
+
continue
|
|
1448
|
+
}
|
|
1449
|
+
cmdline, readErr := os.ReadFile(filepath.Join("/proc", entry.Name(), "cmdline"))
|
|
1450
|
+
if readErr != nil {
|
|
1451
|
+
continue
|
|
1452
|
+
}
|
|
1453
|
+
args := strings.Split(strings.TrimRight(string(cmdline), "\x00"), "\x00")
|
|
1454
|
+
if len(args) < 2 || args[0] != executable || args[1] != "serve" {
|
|
1455
|
+
continue
|
|
1456
|
+
}
|
|
1457
|
+
pid, parseErr := strconv.Atoi(entry.Name())
|
|
1458
|
+
if parseErr == nil {
|
|
1459
|
+
pids = append(pids, pid)
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
return pids, nil
|
|
1463
|
+
case "darwin":
|
|
1464
|
+
output, err := exec.Command("ps", "-axo", "pid=,command=").Output()
|
|
1465
|
+
if err != nil {
|
|
1466
|
+
return nil, fmt.Errorf("list processes with ps: %w", err)
|
|
1467
|
+
}
|
|
1468
|
+
var pids []int
|
|
1469
|
+
for _, line := range strings.Split(string(output), "\n") {
|
|
1470
|
+
line = strings.TrimSpace(line)
|
|
1471
|
+
fields := strings.Fields(line)
|
|
1472
|
+
if len(fields) < 2 {
|
|
1473
|
+
continue
|
|
1474
|
+
}
|
|
1475
|
+
pid, parseErr := strconv.Atoi(fields[0])
|
|
1476
|
+
if parseErr != nil {
|
|
1477
|
+
continue
|
|
1478
|
+
}
|
|
1479
|
+
command := strings.TrimSpace(strings.TrimPrefix(line, fields[0]))
|
|
1480
|
+
if strings.HasPrefix(command, executable+" serve") {
|
|
1481
|
+
pids = append(pids, pid)
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
return pids, nil
|
|
1485
|
+
default:
|
|
1486
|
+
return nil, fmt.Errorf("process cwd inspection is unsupported on %s", runtime.GOOS)
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
func processCWD(pid int) (string, error) {
|
|
1491
|
+
switch runtime.GOOS {
|
|
1492
|
+
case "linux":
|
|
1493
|
+
return os.Readlink(filepath.Join("/proc", strconv.Itoa(pid), "cwd"))
|
|
1494
|
+
case "darwin":
|
|
1495
|
+
lsof, err := exec.LookPath("lsof")
|
|
1496
|
+
if err != nil {
|
|
1497
|
+
return "", fmt.Errorf("find lsof for Darwin cwd inspection: %w", err)
|
|
1498
|
+
}
|
|
1499
|
+
output, err := exec.Command(lsof, "-a", "-p", strconv.Itoa(pid), "-d", "cwd", "-Fn").Output()
|
|
1500
|
+
if err != nil {
|
|
1501
|
+
return "", fmt.Errorf("inspect cwd with lsof: %w", err)
|
|
1502
|
+
}
|
|
1503
|
+
for _, line := range strings.Split(string(output), "\n") {
|
|
1504
|
+
if strings.HasPrefix(line, "n") {
|
|
1505
|
+
return strings.TrimPrefix(line, "n"), nil
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
return "", fmt.Errorf("lsof returned no cwd for pid %d", pid)
|
|
1509
|
+
default:
|
|
1510
|
+
return "", fmt.Errorf("process cwd inspection is unsupported on %s", runtime.GOOS)
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1109
1514
|
func buildCLIBinary(t *testing.T) string {
|
|
1110
1515
|
t.Helper()
|
|
1111
1516
|
binary := filepath.Join(t.TempDir(), "relay-flow")
|
|
@@ -1119,17 +1524,28 @@ func buildCLIBinary(t *testing.T) string {
|
|
|
1119
1524
|
|
|
1120
1525
|
func waitForServer(t *testing.T, client *server.Client) {
|
|
1121
1526
|
t.Helper()
|
|
1122
|
-
|
|
1527
|
+
waitForServerTimeout(t, client, 5*time.Second)
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
func waitForServerTimeout(t *testing.T, client *server.Client, timeout time.Duration) {
|
|
1531
|
+
t.Helper()
|
|
1532
|
+
if err := waitForServerResult(client, timeout); err != nil {
|
|
1533
|
+
t.Fatal(err)
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
func waitForServerResult(client *server.Client, timeout time.Duration) error {
|
|
1538
|
+
deadline := time.Now().Add(timeout)
|
|
1123
1539
|
for time.Now().Before(deadline) {
|
|
1124
1540
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
1125
1541
|
_, err := client.ListRepos(ctx)
|
|
1126
1542
|
cancel()
|
|
1127
1543
|
if err == nil {
|
|
1128
|
-
return
|
|
1544
|
+
return nil
|
|
1129
1545
|
}
|
|
1130
1546
|
time.Sleep(20 * time.Millisecond)
|
|
1131
1547
|
}
|
|
1132
|
-
|
|
1548
|
+
return fmt.Errorf("server did not become ready")
|
|
1133
1549
|
}
|
|
1134
1550
|
|
|
1135
1551
|
func waitForServerStop(t *testing.T, client *server.Client) {
|
|
@@ -1187,6 +1603,9 @@ func (s *ackServer) RestartRun(context.Context, string) (runsvc.Run, error) {
|
|
|
1187
1603
|
panic("unreachable")
|
|
1188
1604
|
}
|
|
1189
1605
|
func (s *ackServer) CancelRun(context.Context, string, string) error { panic("unreachable") }
|
|
1606
|
+
func (s *ackServer) BackfillClaimOwner(context.Context, string, string, string) error {
|
|
1607
|
+
panic("unreachable")
|
|
1608
|
+
}
|
|
1190
1609
|
func (s *ackServer) DiscoverRepos(context.Context) ([]runner.RepoCandidate, error) {
|
|
1191
1610
|
panic("unreachable")
|
|
1192
1611
|
}
|