relay-flow 0.3.13-alpha → 0.3.14-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -5
- package/cmd/relay-flow/commands_test.go +9 -0
- package/cmd/relay-flow/main.go +16 -7
- package/cmd/relay-flow/repo_registration.go +68 -0
- package/cmd/relay-flow/repo_selection_keyboard_test.go +178 -0
- package/cmd/relay-flow/scenario_test.go +25 -0
- package/cmd/relay-flow/serve.go +33 -2
- package/cmd/relay-flow/startup_alternate_plugins_test.go +138 -0
- package/cmd/relay-flow/startup_errors.go +85 -0
- package/cmd/relay-flow/startup_errors_test.go +88 -0
- package/cmd/relay-flow/startup_failure_categories_test.go +115 -0
- package/cmd/relay-flow/startup_ordering_test.go +75 -0
- package/cmd/relay-flow/startup_prerequisites_test.go +324 -0
- package/cmd/relay-flow/startup_readiness_test.go +116 -0
- package/cmd/relay-flow/startup_submission_test.go +123 -0
- package/internal/harness/opencode/opencode.go +14 -0
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/pi/pi.go +14 -0
- package/internal/harness/startup.go +25 -0
- package/internal/harness/startup_test.go +85 -0
- package/internal/runner/herdr/herdr.go +9 -0
- package/internal/runner/herdr/herdrcli/exec.go +14 -0
- package/internal/runner/herdr/herdrcli/operations.go +8 -2
- package/internal/runner/herdr/herdrcli/response.go +6 -0
- package/internal/runner/herdr/herdrcli/startup_test.go +101 -0
- package/internal/runner/herdr/startup_test.go +58 -0
- package/internal/runner/orca/orca.go +9 -0
- package/internal/runner/orca/orcacli/orcacli.go +99 -34
- package/internal/runner/orca/orcacli/orcacli_test.go +4 -1
- package/internal/runner/orca/orcacli/startup_test.go +158 -0
- package/internal/runner/orca/startup_test.go +57 -0
- package/internal/runner/startup.go +27 -0
- package/internal/runner/startup_test.go +59 -0
- package/internal/task/beads/bdcli/bdcli.go +12 -0
- package/internal/task/beads/bdcli/startup.go +20 -0
- package/internal/task/beads/bdcli/startup_test.go +27 -0
- package/internal/task/beads/beads.go +1 -0
- package/internal/task/beads/startup.go +75 -0
- package/internal/task/beads/startup_test.go +155 -0
- package/internal/task/factory.go +6 -2
- package/internal/task/jira/jira.go +4 -3
- package/internal/task/jira/rest/client.go +40 -6
- package/internal/task/jira/rest/startup_redirect_test.go +23 -0
- package/internal/task/jira/rest/startup_test.go +133 -0
- package/internal/task/jira/startup.go +22 -0
- package/internal/task/jira/startup_test.go +78 -0
- package/internal/task/startup.go +25 -0
- package/internal/task/startup_test.go +51 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,13 +28,13 @@ same `relay-flow-plugin` package with host-specific entrypoints.
|
|
|
28
28
|
**OpenCode**
|
|
29
29
|
|
|
30
30
|
```sh
|
|
31
|
-
opencode plugin relay-flow-plugin@0.3.
|
|
31
|
+
opencode plugin relay-flow-plugin@0.3.14-alpha
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
**Pi**
|
|
35
35
|
|
|
36
36
|
```sh
|
|
37
|
-
pi install npm:relay-flow-plugin@0.3.
|
|
37
|
+
pi install npm:relay-flow-plugin@0.3.14-alpha
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
Pi loads the package's `pi.ts` extension from its manifest. Do not add
|
|
@@ -155,6 +155,15 @@ and app; Herdr requires its CLI/server. OpenCode and Pi each require their
|
|
|
155
155
|
corresponding agent runtime. These integrations remain behind their small
|
|
156
156
|
contracts, so task-system fields do not leak into runners or harnesses.
|
|
157
157
|
|
|
158
|
+
The Orca adapter selects its launcher once in the **server's** environment:
|
|
159
|
+
`ORCA_CLI_COMMAND` when set, otherwise `orca-dev` when
|
|
160
|
+
`ORCA_DEV_REPO_ROOT` is set, otherwise `orca-ide` on Linux and `orca` on
|
|
161
|
+
macOS. The resolved executable is reused for every adapter operation; no
|
|
162
|
+
alternate binary, shell alias, or legacy shim is tried on failure. On Linux,
|
|
163
|
+
substitute `orca-ide` for `orca` in the standalone setup examples below (or
|
|
164
|
+
use the explicit session selector). Changing the client's PATH does not
|
|
165
|
+
change an already-running server's launcher.
|
|
166
|
+
|
|
158
167
|
## Detailed setup
|
|
159
168
|
|
|
160
169
|
### Harness configuration
|
|
@@ -164,7 +173,7 @@ OpenCode plugin configuration uses both entrypoints. The server entrypoint is li
|
|
|
164
173
|
```json
|
|
165
174
|
{
|
|
166
175
|
"$schema": "https://opencode.ai/config.json",
|
|
167
|
-
"plugin": ["relay-flow-plugin@0.3.
|
|
176
|
+
"plugin": ["relay-flow-plugin@0.3.14-alpha"]
|
|
168
177
|
}
|
|
169
178
|
```
|
|
170
179
|
|
|
@@ -173,7 +182,7 @@ The native HITL approval entrypoint is listed in `.opencode/tui.json`:
|
|
|
173
182
|
```json
|
|
174
183
|
{
|
|
175
184
|
"$schema": "https://opencode.ai/tui.json",
|
|
176
|
-
"plugin": ["relay-flow-plugin@0.3.
|
|
185
|
+
"plugin": ["relay-flow-plugin@0.3.14-alpha"]
|
|
177
186
|
}
|
|
178
187
|
```
|
|
179
188
|
|
|
@@ -189,7 +198,7 @@ Pi plugin: install the same published package manually in Pi's global package
|
|
|
189
198
|
settings before starting a Pi harness session:
|
|
190
199
|
|
|
191
200
|
```sh
|
|
192
|
-
pi install npm:relay-flow-plugin@0.3.
|
|
201
|
+
pi install npm:relay-flow-plugin@0.3.14-alpha
|
|
193
202
|
```
|
|
194
203
|
|
|
195
204
|
Relay-flow does not install or configure the package automatically. Pi resolves
|
|
@@ -418,6 +427,31 @@ codes, and server lifecycle. Detached startup logs diagnostics to
|
|
|
418
427
|
working directory. Use `--foreground` only when the process supervisor owns
|
|
419
428
|
the server lifetime; `--background` remains accepted for existing scripts.
|
|
420
429
|
|
|
430
|
+
Before workers, pollers, recovery effects, or socket readiness, `serve` checks
|
|
431
|
+
only basic prerequisites for the selected task system, runner runtime, and
|
|
432
|
+
local harness executable. This phase has a ten-second aggregate deadline and
|
|
433
|
+
at most five seconds per external probe, with no startup retries. These budgets
|
|
434
|
+
do not cover database opening or durable replay. Zero registered repositories
|
|
435
|
+
is valid: empty Orca/Herdr runtimes are healthy; Beads checks local `bd` and
|
|
436
|
+
reports connection verification deferred to registration only when no workspace
|
|
437
|
+
is explicitly configured. Shared configured Beads workspaces are probed once.
|
|
438
|
+
Full repository, workflow, task-setting, and agent validation remains at
|
|
439
|
+
registration/submission, not a repeated startup preflight. Changed, malformed,
|
|
440
|
+
or missing-hash stored workflows remain isolated from routing while the
|
|
441
|
+
management API and unrelated accepted workflows stay available when basic
|
|
442
|
+
prerequisites pass.
|
|
443
|
+
|
|
444
|
+
Prerequisite errors distinguish missing executable, executable launch failure,
|
|
445
|
+
authentication/permission rejection, connection failure, malformed response,
|
|
446
|
+
and timeout. Foreground and detached failures exit nonzero without `server
|
|
447
|
+
started`, identify the selected dependency and safe operation, and point to
|
|
448
|
+
`server.log`. Detached diagnostics read only the current child's bounded
|
|
449
|
+
startup-failure record; old or unrelated log entries are not replayed. Startup
|
|
450
|
+
never installs/starts dependencies, enumerates agents, scans ticket history,
|
|
451
|
+
or changes the child environment to hide a failure. Guided registration
|
|
452
|
+
propagates startup errors and reuses an existing ready server without assuming
|
|
453
|
+
that executable resolution in the client changes the server's environment.
|
|
454
|
+
|
|
421
455
|
`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.
|
|
422
456
|
|
|
423
457
|
---
|
|
@@ -177,6 +177,7 @@ func TestTaskAuthDispatchesSelectedPlugin(t *testing.T) {
|
|
|
177
177
|
func TestServerLockIsOwnerOnly(t *testing.T) {
|
|
178
178
|
home := t.TempDir()
|
|
179
179
|
initHome(t, home)
|
|
180
|
+
installHealthyServePrerequisites(t, filepath.Join(home, ".relay-flow"))
|
|
180
181
|
// Start foreground serve in the background of the test via the parser
|
|
181
182
|
// entry; it creates the flock file then blocks. We assert the lock file's
|
|
182
183
|
// mode, then stop the fixture so the test does not leak a server.
|
|
@@ -216,6 +217,7 @@ func TestServerLockIsOwnerOnly(t *testing.T) {
|
|
|
216
217
|
func TestServerSocketIsOwnerOnly(t *testing.T) {
|
|
217
218
|
home := t.TempDir()
|
|
218
219
|
initHome(t, home)
|
|
220
|
+
installHealthyServePrerequisites(t, filepath.Join(home, ".relay-flow"))
|
|
219
221
|
done := make(chan int, 1)
|
|
220
222
|
go func() { done <- cli(t, home, "", "serve", "--foreground") }()
|
|
221
223
|
t.Cleanup(func() {
|
|
@@ -1133,6 +1135,7 @@ func TestDefaultServeRelativeHomeUsesSharedState(t *testing.T) {
|
|
|
1133
1135
|
if out, err := init.CombinedOutput(); err != nil {
|
|
1134
1136
|
t.Fatalf("relative-home init: %v\n%s", err, out)
|
|
1135
1137
|
}
|
|
1138
|
+
installHealthyServePrerequisites(t, expectedRoot)
|
|
1136
1139
|
for _, name := range []string{"config.yaml", "state.db"} {
|
|
1137
1140
|
if _, err := os.Stat(filepath.Join(expectedRoot, name)); err != nil {
|
|
1138
1141
|
t.Fatalf("relative-home init missing %s: %v", name, err)
|
|
@@ -1164,6 +1167,7 @@ func TestDefaultServeRelativeHomeUsesSharedState(t *testing.T) {
|
|
|
1164
1167
|
func TestInProcessDefaultServeReadinessAndStop(t *testing.T) {
|
|
1165
1168
|
home := t.TempDir()
|
|
1166
1169
|
initHome(t, home)
|
|
1170
|
+
installHealthyServePrerequisites(t, filepath.Join(home, ".relay-flow"))
|
|
1167
1171
|
if code := cli(t, home, "", "serve"); code != exitOK {
|
|
1168
1172
|
t.Fatalf("in-process detached serve exit = %d", code)
|
|
1169
1173
|
}
|
|
@@ -1184,6 +1188,7 @@ func TestDetachedServeUsesStableWorkingDirectory(t *testing.T) {
|
|
|
1184
1188
|
if code := run(initArgs(), strings.NewReader("")); code != 0 {
|
|
1185
1189
|
t.Fatalf("init exit = %d", code)
|
|
1186
1190
|
}
|
|
1191
|
+
installHealthyServePrerequisites(t, root)
|
|
1187
1192
|
binary := buildCLIBinary(t)
|
|
1188
1193
|
t.Cleanup(func() {
|
|
1189
1194
|
stop := exec.Command(binary, "stop")
|
|
@@ -1230,6 +1235,7 @@ func TestForegroundServeUsesStableWorkingDirectory(t *testing.T) {
|
|
|
1230
1235
|
if code := run(initArgs(), strings.NewReader("")); code != 0 {
|
|
1231
1236
|
t.Fatalf("init exit = %d", code)
|
|
1232
1237
|
}
|
|
1238
|
+
installHealthyServePrerequisites(t, root)
|
|
1233
1239
|
binary := buildCLIBinary(t)
|
|
1234
1240
|
caller := t.TempDir()
|
|
1235
1241
|
serve := exec.Command(binary, "serve", "--foreground")
|
|
@@ -1288,6 +1294,7 @@ func TestForegroundServeRelativeHomeUsesSharedState(t *testing.T) {
|
|
|
1288
1294
|
if out, err := init.CombinedOutput(); err != nil {
|
|
1289
1295
|
t.Fatalf("relative-home init: %v\n%s", err, out)
|
|
1290
1296
|
}
|
|
1297
|
+
installHealthyServePrerequisites(t, expectedRoot)
|
|
1291
1298
|
serve := exec.Command(binary, "serve", "--foreground")
|
|
1292
1299
|
serve.Dir = caller
|
|
1293
1300
|
serve.Env = os.Environ()
|
|
@@ -1349,6 +1356,7 @@ func testDetachedServe(t *testing.T, mode []string) {
|
|
|
1349
1356
|
if code := run(initArgs(), strings.NewReader("")); code != 0 {
|
|
1350
1357
|
t.Fatalf("init exit = %d", code)
|
|
1351
1358
|
}
|
|
1359
|
+
installHealthyServePrerequisites(t, root)
|
|
1352
1360
|
binary := buildCLIBinary(t)
|
|
1353
1361
|
args := append([]string{"serve"}, mode...)
|
|
1354
1362
|
start := exec.Command(binary, args...)
|
|
@@ -1455,6 +1463,7 @@ func TestForegroundServeRemainsBlocking(t *testing.T) {
|
|
|
1455
1463
|
if code := run(initArgs(), strings.NewReader("")); code != 0 {
|
|
1456
1464
|
t.Fatalf("init exit = %d", code)
|
|
1457
1465
|
}
|
|
1466
|
+
installHealthyServePrerequisites(t, root)
|
|
1458
1467
|
binary := buildCLIBinary(t)
|
|
1459
1468
|
serve := exec.Command(binary, "serve", "--foreground")
|
|
1460
1469
|
serve.Env = os.Environ()
|
package/cmd/relay-flow/main.go
CHANGED
|
@@ -13,7 +13,6 @@ import (
|
|
|
13
13
|
"flag"
|
|
14
14
|
"fmt"
|
|
15
15
|
"io"
|
|
16
|
-
"log/slog"
|
|
17
16
|
"os"
|
|
18
17
|
"os/exec"
|
|
19
18
|
"os/signal"
|
|
@@ -896,8 +895,8 @@ func cmdServe(p paths.Paths, args []string) int {
|
|
|
896
895
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
897
896
|
defer stop()
|
|
898
897
|
if err := serveRoot(ctx, p, *recover); err != nil {
|
|
899
|
-
|
|
900
|
-
fmt.
|
|
898
|
+
logStartupFailure(err)
|
|
899
|
+
fmt.Fprintf(os.Stderr, "%s; see %s\n", startupDiagnostic(err.Error()), p.ServerLog)
|
|
901
900
|
return exitFail
|
|
902
901
|
}
|
|
903
902
|
return exitOK
|
|
@@ -928,6 +927,8 @@ func startBackgroundServe(p paths.Paths, recover, debug bool) error {
|
|
|
928
927
|
testServeCancel chan struct{}
|
|
929
928
|
observedOwner = lockHeld
|
|
930
929
|
devNull *os.File
|
|
930
|
+
logOffset = startupLogOffset(p.ServerLog)
|
|
931
|
+
childPID = os.Getpid()
|
|
931
932
|
)
|
|
932
933
|
if !lockHeld {
|
|
933
934
|
wait = make(chan error, 1)
|
|
@@ -949,7 +950,11 @@ func startBackgroundServe(p paths.Paths, recover, debug bool) error {
|
|
|
949
950
|
return
|
|
950
951
|
}
|
|
951
952
|
defer logCloser.Close()
|
|
952
|
-
|
|
953
|
+
serveErr := serveRoot(serveCtx, p, recover)
|
|
954
|
+
if serveErr != nil {
|
|
955
|
+
logStartupFailure(serveErr)
|
|
956
|
+
}
|
|
957
|
+
wait <- serveErr
|
|
953
958
|
}()
|
|
954
959
|
} else {
|
|
955
960
|
executable, resolveErr := os.Executable()
|
|
@@ -972,6 +977,7 @@ func startBackgroundServe(p paths.Paths, recover, debug bool) error {
|
|
|
972
977
|
if err := cmd.Start(); err != nil {
|
|
973
978
|
return fmt.Errorf("serve --background: start: %w; see %s", err, p.ServerLog)
|
|
974
979
|
}
|
|
980
|
+
childPID = cmd.Process.Pid
|
|
975
981
|
go func() { wait <- cmd.Wait() }()
|
|
976
982
|
}
|
|
977
983
|
}
|
|
@@ -1017,6 +1023,9 @@ func startBackgroundServe(p paths.Paths, recover, debug bool) error {
|
|
|
1017
1023
|
cmd = nil
|
|
1018
1024
|
continue
|
|
1019
1025
|
}
|
|
1026
|
+
if diagnostic := readStartupFailure(p.ServerLog, logOffset, childPID); diagnostic != "" {
|
|
1027
|
+
return fmt.Errorf("serve --background: %s; server exited before readiness: %v; see %s", diagnostic, childErr, p.ServerLog)
|
|
1028
|
+
}
|
|
1020
1029
|
return fmt.Errorf("serve --background: server exited before readiness: %v; see %s", childErr, p.ServerLog)
|
|
1021
1030
|
case <-ticker.C:
|
|
1022
1031
|
}
|
|
@@ -1433,8 +1442,8 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
|
|
|
1433
1442
|
return exitOK
|
|
1434
1443
|
}
|
|
1435
1444
|
|
|
1436
|
-
func repoMultiSelect(options []huh.Option[int], selected *[]int) *
|
|
1437
|
-
return huh.NewMultiSelect[int]().
|
|
1445
|
+
func repoMultiSelect(options []huh.Option[int], selected *[]int) *repoMultiSelectField {
|
|
1446
|
+
return &repoMultiSelectField{huh.NewMultiSelect[int]().
|
|
1438
1447
|
Title("Select repositories").
|
|
1439
1448
|
Description("Press / to filter repositories; select [+] Add repository to add a runner resource.").
|
|
1440
1449
|
Filterable(true).
|
|
@@ -1445,7 +1454,7 @@ func repoMultiSelect(options []huh.Option[int], selected *[]int) *huh.MultiSelec
|
|
|
1445
1454
|
return fmt.Errorf("select at least one repository")
|
|
1446
1455
|
}
|
|
1447
1456
|
return nil
|
|
1448
|
-
})
|
|
1457
|
+
})}
|
|
1449
1458
|
}
|
|
1450
1459
|
|
|
1451
1460
|
// kvFlags collects repeated key=value flags (registration task config).
|
|
@@ -4,8 +4,10 @@ import (
|
|
|
4
4
|
"context"
|
|
5
5
|
"fmt"
|
|
6
6
|
"path/filepath"
|
|
7
|
+
"slices"
|
|
7
8
|
"strings"
|
|
8
9
|
|
|
10
|
+
tea "github.com/charmbracelet/bubbletea"
|
|
9
11
|
"github.com/charmbracelet/huh"
|
|
10
12
|
"github.com/rajpopat27/relay-flow/internal/config"
|
|
11
13
|
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
@@ -16,6 +18,72 @@ import (
|
|
|
16
18
|
|
|
17
19
|
const addRepositorySelection = -1
|
|
18
20
|
|
|
21
|
+
// repoMultiSelectField keeps Huh's picker behavior, except that bulk selection
|
|
22
|
+
// toggles only visible repositories, never the explicit Add action.
|
|
23
|
+
type repoMultiSelectField struct {
|
|
24
|
+
*huh.MultiSelect[int]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func (m *repoMultiSelectField) WithKeyMap(keymap *huh.KeyMap) huh.Field {
|
|
28
|
+
copy := *keymap
|
|
29
|
+
copy.MultiSelect.SelectAll.SetHelp("ctrl+a", "toggle repositories")
|
|
30
|
+
copy.MultiSelect.SelectNone.SetHelp("ctrl+a", "toggle repositories")
|
|
31
|
+
m.MultiSelect.WithKeyMap(©)
|
|
32
|
+
return m
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func (m *repoMultiSelectField) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
36
|
+
if key, ok := msg.(tea.KeyMsg); ok && key.Type == tea.KeyCtrlA {
|
|
37
|
+
m.toggleRepositories()
|
|
38
|
+
return m, nil
|
|
39
|
+
}
|
|
40
|
+
_, cmd := m.MultiSelect.Update(msg)
|
|
41
|
+
return m, cmd
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func (m *repoMultiSelectField) toggleRepositories() {
|
|
45
|
+
original, ok := m.Hovered()
|
|
46
|
+
if !ok {
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
// Use Huh's own filtered view and Space handling rather than maintaining
|
|
50
|
+
// a second filter or selection state. Temporarily stop editing the filter
|
|
51
|
+
// so navigation/toggle keys do not modify its text.
|
|
52
|
+
filtering := m.GetFiltering()
|
|
53
|
+
m.Filtering(false)
|
|
54
|
+
defer m.Filtering(filtering)
|
|
55
|
+
m.MultiSelect.Update(tea.KeyMsg{Type: tea.KeyHome})
|
|
56
|
+
selected := m.GetValue().([]int)
|
|
57
|
+
visible := []int{}
|
|
58
|
+
allSelected := true
|
|
59
|
+
for {
|
|
60
|
+
value, ok := m.Hovered()
|
|
61
|
+
if !ok || (len(visible) > 0 && visible[len(visible)-1] == value) {
|
|
62
|
+
break
|
|
63
|
+
}
|
|
64
|
+
visible = append(visible, value)
|
|
65
|
+
if value != addRepositorySelection && !slices.Contains(selected, value) {
|
|
66
|
+
allSelected = false
|
|
67
|
+
}
|
|
68
|
+
m.MultiSelect.Update(tea.KeyMsg{Type: tea.KeyDown})
|
|
69
|
+
}
|
|
70
|
+
m.MultiSelect.Update(tea.KeyMsg{Type: tea.KeyHome})
|
|
71
|
+
for _, value := range visible {
|
|
72
|
+
if value != addRepositorySelection && slices.Contains(selected, value) == allSelected {
|
|
73
|
+
m.MultiSelect.Update(tea.KeyMsg{Type: tea.KeySpace})
|
|
74
|
+
}
|
|
75
|
+
m.MultiSelect.Update(tea.KeyMsg{Type: tea.KeyDown})
|
|
76
|
+
}
|
|
77
|
+
// Restore the hovered entry so Space still acts on the same option.
|
|
78
|
+
m.MultiSelect.Update(tea.KeyMsg{Type: tea.KeyHome})
|
|
79
|
+
for _, value := range visible {
|
|
80
|
+
if value == original {
|
|
81
|
+
break
|
|
82
|
+
}
|
|
83
|
+
m.MultiSelect.Update(tea.KeyMsg{Type: tea.KeyDown})
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
19
87
|
func repoSelectionOptions(candidates []runner.RepoCandidate) []huh.Option[int] {
|
|
20
88
|
options := make([]huh.Option[int], 0, len(candidates)+1)
|
|
21
89
|
for i, candidate := range candidates {
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"io"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
"time"
|
|
11
|
+
|
|
12
|
+
tea "github.com/charmbracelet/bubbletea"
|
|
13
|
+
"github.com/charmbracelet/huh"
|
|
14
|
+
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
15
|
+
"github.com/rajpopat27/relay-flow/internal/server"
|
|
16
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
func repoKeyboardField(candidates []runner.RepoCandidate, selected *[]int) huh.Field {
|
|
20
|
+
field := repoMultiSelect(repoSelectionOptions(candidates), selected)
|
|
21
|
+
// Apply the same setup that Huh's form/group applies to a field.
|
|
22
|
+
huh.NewForm(huh.NewGroup(field))
|
|
23
|
+
field.Focus()
|
|
24
|
+
return field
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func sendRepoKeys(t *testing.T, field huh.Field, keys ...tea.KeyMsg) {
|
|
28
|
+
t.Helper()
|
|
29
|
+
for _, key := range keys {
|
|
30
|
+
model, _ := field.Update(key)
|
|
31
|
+
if model != field {
|
|
32
|
+
t.Fatal("keyboard update replaced the production selection field")
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func TestRepoSelectionFormCtrlAEnter(t *testing.T) {
|
|
38
|
+
var selected []int
|
|
39
|
+
field := repoMultiSelect(repoSelectionOptions([]runner.RepoCandidate{
|
|
40
|
+
{Name: "payments", Path: "/work/payments"},
|
|
41
|
+
{Name: "checkout", Path: "/work/checkout"},
|
|
42
|
+
}), &selected)
|
|
43
|
+
form := huh.NewForm(huh.NewGroup(field)).
|
|
44
|
+
WithProgramOptions(tea.WithoutRenderer(), tea.WithoutSignalHandler()).
|
|
45
|
+
WithInput(strings.NewReader("\x01\r")).
|
|
46
|
+
WithOutput(io.Discard).
|
|
47
|
+
WithTimeout(3 * time.Second)
|
|
48
|
+
if err := form.Run(); err != nil {
|
|
49
|
+
t.Fatal(err)
|
|
50
|
+
}
|
|
51
|
+
if form.State != huh.StateCompleted || fmt.Sprint(selected) != "[0 1]" {
|
|
52
|
+
t.Fatalf("Ctrl+A/Enter: form state=%v selected=%v", form.State, selected)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func TestRepoSelectionKeyboardBulkToggle(t *testing.T) {
|
|
57
|
+
candidates := []runner.RepoCandidate{
|
|
58
|
+
{Name: "payments", Path: "/work/payments"},
|
|
59
|
+
{Name: "checkout", Path: "/work/checkout"},
|
|
60
|
+
}
|
|
61
|
+
for _, count := range []int{0, 1, 2} {
|
|
62
|
+
t.Run(fmt.Sprintf("%d repositories", count), func(t *testing.T) {
|
|
63
|
+
var selected []int
|
|
64
|
+
field := repoKeyboardField(candidates[:count], &selected)
|
|
65
|
+
all := make([]int, count)
|
|
66
|
+
for i := range all {
|
|
67
|
+
all[i] = i
|
|
68
|
+
}
|
|
69
|
+
for _, want := range []string{fmt.Sprint(all), "[]"} {
|
|
70
|
+
sendRepoKeys(t, field, tea.KeyMsg{Type: tea.KeyCtrlA})
|
|
71
|
+
if fmt.Sprint(selected) != want {
|
|
72
|
+
t.Fatalf("Ctrl+A selected %v, want %s (Add must never be bulk-selected)", selected, want)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
sendRepoKeys(t, field, tea.KeyMsg{Type: tea.KeyEnter})
|
|
76
|
+
if field.Error() == nil {
|
|
77
|
+
t.Fatal("Enter accepted an empty repository selection")
|
|
78
|
+
}
|
|
79
|
+
// Explicit Space on Add remains available even without candidates.
|
|
80
|
+
sendRepoKeys(t, field, tea.KeyMsg{Type: tea.KeyEnd}, tea.KeyMsg{Type: tea.KeySpace}, tea.KeyMsg{Type: tea.KeyEnter})
|
|
81
|
+
if field.Error() != nil || fmt.Sprint(selected) != "[-1]" {
|
|
82
|
+
t.Fatalf("explicit Add: selected=%v error=%v", selected, field.Error())
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func TestRepoSelectionKeyboardFilteredBulkToggle(t *testing.T) {
|
|
89
|
+
candidates := []runner.RepoCandidate{
|
|
90
|
+
{Name: "payments", Path: "/work/payments"},
|
|
91
|
+
{Name: "checkout", Path: "/work/checkout"},
|
|
92
|
+
{Name: "payments-api", Path: "/work/payments-api"},
|
|
93
|
+
}
|
|
94
|
+
for _, filter := range []string{"", "payments", "repository", "does-not-match"} {
|
|
95
|
+
for _, editing := range []bool{false, true} {
|
|
96
|
+
t.Run(fmt.Sprintf("filter=%s/editing=%t", filter, editing), func(t *testing.T) {
|
|
97
|
+
selected := []int{1} // A selection outside the payments filter must survive.
|
|
98
|
+
field := repoKeyboardField(candidates, &selected)
|
|
99
|
+
sendRepoKeys(t, field, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(filter)})
|
|
100
|
+
if !editing {
|
|
101
|
+
sendRepoKeys(t, field, tea.KeyMsg{Type: tea.KeyEnter})
|
|
102
|
+
}
|
|
103
|
+
wants := []string{"[1]", "[1]"}
|
|
104
|
+
switch filter {
|
|
105
|
+
case "":
|
|
106
|
+
wants = []string{"[0 1 2]", "[]"}
|
|
107
|
+
case "payments":
|
|
108
|
+
wants = []string{"[0 1 2]", "[1]"}
|
|
109
|
+
case "does-not-match":
|
|
110
|
+
if !editing {
|
|
111
|
+
// Huh clears a no-match filter when Enter commits it.
|
|
112
|
+
wants = []string{"[0 1 2]", "[]"}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
for _, want := range wants {
|
|
116
|
+
sendRepoKeys(t, field, tea.KeyMsg{Type: tea.KeyCtrlA})
|
|
117
|
+
if fmt.Sprint(selected) != want {
|
|
118
|
+
t.Fatalf("Ctrl+A selected %v, want %s", selected, want)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
func TestRepoSelectionKeyboardBulkProceedsWithoutAdding(t *testing.T) {
|
|
127
|
+
for _, count := range []int{1, 2} {
|
|
128
|
+
t.Run(fmt.Sprintf("%d repositories", count), func(t *testing.T) {
|
|
129
|
+
home := t.TempDir()
|
|
130
|
+
deps := serveRegister(t, home, []string{"project", "component"})
|
|
131
|
+
deps.candidates = []runner.RepoCandidate{
|
|
132
|
+
{Name: "payments", Path: "/work/payments"},
|
|
133
|
+
{Name: "checkout", Path: "/work/checkout"},
|
|
134
|
+
}[:count]
|
|
135
|
+
client := server.NewClient(filepath.Join(home, ".relay-flow", "server.sock"))
|
|
136
|
+
candidates, selected, err := selectReposInteractiveWithPrompts(context.Background(), client, deps.candidates, nil,
|
|
137
|
+
func(candidates []runner.RepoCandidate, selected []int) ([]int, error) {
|
|
138
|
+
field := repoKeyboardField(candidates, &selected)
|
|
139
|
+
sendRepoKeys(t, field, tea.KeyMsg{Type: tea.KeyCtrlA}, tea.KeyMsg{Type: tea.KeyEnter})
|
|
140
|
+
return selected, field.Error()
|
|
141
|
+
},
|
|
142
|
+
func() (runner.RepoCandidate, error) {
|
|
143
|
+
t.Fatal("Ctrl+A/Enter opened the path/name prompt")
|
|
144
|
+
return runner.RepoCandidate{}, nil
|
|
145
|
+
})
|
|
146
|
+
if err != nil {
|
|
147
|
+
t.Fatal(err)
|
|
148
|
+
}
|
|
149
|
+
registration := task.Registration{Fields: []task.RegistrationField{
|
|
150
|
+
{Key: "project", Title: "Project"},
|
|
151
|
+
{Key: "component", Title: "Component", Derived: true},
|
|
152
|
+
}}
|
|
153
|
+
if err := registerSelectedReposDynamic(context.Background(), client, candidates, selected, registration, kvFlags{"project": "PAY"}); err != nil {
|
|
154
|
+
t.Fatal(err)
|
|
155
|
+
}
|
|
156
|
+
if len(deps.ensureCalls) != 0 || len(deps.successful) != count {
|
|
157
|
+
t.Fatalf("ensure calls=%v registrations=%v", deps.ensureCalls, deps.successful)
|
|
158
|
+
}
|
|
159
|
+
for _, input := range deps.successful {
|
|
160
|
+
if input.TaskConfig["project"] != "PAY" || input.TaskConfig["component"] != input.Name {
|
|
161
|
+
t.Fatalf("incorrect shared mapping: %+v", input)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
func TestRepoSelectionKeyboardSpacePreservesExplicitAdd(t *testing.T) {
|
|
169
|
+
selected := []int{0}
|
|
170
|
+
field := repoKeyboardField([]runner.RepoCandidate{{Name: "payments", Path: "/work/payments"}}, &selected)
|
|
171
|
+
sendRepoKeys(t, field, tea.KeyMsg{Type: tea.KeyEnd}, tea.KeyMsg{Type: tea.KeySpace}, tea.KeyMsg{Type: tea.KeyCtrlA}, tea.KeyMsg{Type: tea.KeyEnter})
|
|
172
|
+
if fmt.Sprint(selected) != "[-1]" || field.Error() != nil {
|
|
173
|
+
t.Fatalf("explicit Add was lost: selected=%v error=%v", selected, field.Error())
|
|
174
|
+
}
|
|
175
|
+
if !strings.Contains(field.View(), "Add repository") {
|
|
176
|
+
t.Fatal("Add action is no longer visible")
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -60,6 +60,16 @@ func init() {
|
|
|
60
60
|
},
|
|
61
61
|
New: newScenarioTask,
|
|
62
62
|
NewLocal: newScenarioTask,
|
|
63
|
+
ProbeStartup: func(ctx context.Context, _ config.RawValues, _ map[string]config.Repo) error {
|
|
64
|
+
scenarioFactoryMu.Lock()
|
|
65
|
+
system := scenarioFactorySystem
|
|
66
|
+
scenarioFactoryMu.Unlock()
|
|
67
|
+
probe, ok := system.(interface{ ProbeStartup(context.Context) error })
|
|
68
|
+
if !ok {
|
|
69
|
+
return errors.New("scenario task system has no startup probe")
|
|
70
|
+
}
|
|
71
|
+
return probe.ProbeStartup(ctx)
|
|
72
|
+
},
|
|
63
73
|
})
|
|
64
74
|
runner.Register(scenarioRunnerPlugin, func(config.RawValues) (runner.Runner, error) {
|
|
65
75
|
scenarioFactoryMu.Lock()
|
|
@@ -817,6 +827,11 @@ func newScenarioTaskSystem(log *scenarioLog) *scenarioTaskSystem {
|
|
|
817
827
|
}
|
|
818
828
|
}
|
|
819
829
|
|
|
830
|
+
func (s *scenarioTaskSystem) ProbeStartup(ctx context.Context) error {
|
|
831
|
+
s.log.add("startup:task")
|
|
832
|
+
return ctx.Err()
|
|
833
|
+
}
|
|
834
|
+
|
|
820
835
|
func (s *scenarioTaskSystem) Poll(context.Context) ([]task.Ticket, error) {
|
|
821
836
|
s.log.add("poll")
|
|
822
837
|
s.mu.Lock()
|
|
@@ -1060,6 +1075,11 @@ func newScenarioRunner(log *scenarioLog) *scenarioRunner {
|
|
|
1060
1075
|
}
|
|
1061
1076
|
}
|
|
1062
1077
|
|
|
1078
|
+
func (r *scenarioRunner) ProbeStartup(ctx context.Context) error {
|
|
1079
|
+
r.log.add("startup:runner")
|
|
1080
|
+
return ctx.Err()
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1063
1083
|
func (r *scenarioRunner) DiscoverRepos(context.Context) ([]runner.RepoCandidate, error) {
|
|
1064
1084
|
return []runner.RepoCandidate{{Name: scenarioRepo, Path: scenarioRepoPath}}, nil
|
|
1065
1085
|
}
|
|
@@ -1183,6 +1203,11 @@ func newScenarioHarness(log *scenarioLog) *scenarioHarness {
|
|
|
1183
1203
|
return &scenarioHarness{log: log, sessions: map[string]harness.Session{}, launches: map[string][]harness.LaunchSpec{}}
|
|
1184
1204
|
}
|
|
1185
1205
|
|
|
1206
|
+
func (h *scenarioHarness) ProbeStartup(ctx context.Context) error {
|
|
1207
|
+
h.log.add("startup:harness")
|
|
1208
|
+
return ctx.Err()
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1186
1211
|
func (h *scenarioHarness) ValidateAgent(_ context.Context, _, agent string) error {
|
|
1187
1212
|
h.log.add("harness-agent-validated:" + agent)
|
|
1188
1213
|
return nil
|
package/cmd/relay-flow/serve.go
CHANGED
|
@@ -46,11 +46,12 @@ import (
|
|
|
46
46
|
// serveRoot is the composition root. It runs the documented startup order
|
|
47
47
|
// and blocks until ctx is canceled, then shuts down in reverse.
|
|
48
48
|
//
|
|
49
|
-
// Order (
|
|
49
|
+
// Order (local wiring, machine prerequisites, then durable execution):
|
|
50
50
|
//
|
|
51
51
|
// flock → load machine config → select task/runner/harness factories →
|
|
52
52
|
// construct shared runner/harness → load repos + one task.System per repo →
|
|
53
|
-
//
|
|
53
|
+
// check basic machine prerequisites → load workflow files with
|
|
54
|
+
// integrity/local checks → bind trusted
|
|
54
55
|
// workflows+matchers to repos → open go-workflows
|
|
55
56
|
// SQLite engine and start its workers → construct the Run Manager →
|
|
56
57
|
// start the Repo Poller group → start the Unix-socket server.
|
|
@@ -131,6 +132,14 @@ func serveRoot(ctx context.Context, p paths.Paths, recover bool) error {
|
|
|
131
132
|
})
|
|
132
133
|
}
|
|
133
134
|
|
|
135
|
+
// Gate all execution, recovery and readiness on machine prerequisites.
|
|
136
|
+
// This does not run repository/workflow/agent preflight; those checks
|
|
137
|
+
// remain submission-only. In particular, recovery cannot move aside the
|
|
138
|
+
// database or touch surviving resources before this phase succeeds.
|
|
139
|
+
if err := checkStartupPrerequisites(ctx, cfg, rnr, hrn); err != nil {
|
|
140
|
+
return err
|
|
141
|
+
}
|
|
142
|
+
|
|
134
143
|
// Load workflow files independently. Startup verifies the accepted
|
|
135
144
|
// integrity hash and performs only cheap local parsing/structure checks;
|
|
136
145
|
// malformed, legacy, or changed definitions are retained for inspection
|
|
@@ -459,6 +468,28 @@ func serveRoot(ctx context.Context, p paths.Paths, recover bool) error {
|
|
|
459
468
|
return serveResult
|
|
460
469
|
}
|
|
461
470
|
|
|
471
|
+
// checkStartupPrerequisites has one aggregate budget independent of workflow
|
|
472
|
+
// and node counts. Each selected adapter owns the narrow probe wire contract;
|
|
473
|
+
// task adapters also own shared-connection deduplication and per-probe limits.
|
|
474
|
+
// These deadlines cover prerequisites, not database opening or engine replay.
|
|
475
|
+
func checkStartupPrerequisites(ctx context.Context, cfg *config.Machine, rnr runner.Runner, hrn harness.Harness) error {
|
|
476
|
+
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
477
|
+
defer cancel()
|
|
478
|
+
if err := task.ProbeStartup(ctx, cfg.TaskPlugin, cfg.TaskConfig, cfg.Repos); err != nil {
|
|
479
|
+
return fmt.Errorf("startup prerequisite task plugin %q: %w", cfg.TaskPlugin, err)
|
|
480
|
+
}
|
|
481
|
+
if err := runner.ProbeStartup(ctx, rnr); err != nil {
|
|
482
|
+
return fmt.Errorf("startup prerequisite runner plugin %q: %w", cfg.RunnerPlugin, err)
|
|
483
|
+
}
|
|
484
|
+
if err := harness.ProbeStartup(ctx, hrn); err != nil {
|
|
485
|
+
return fmt.Errorf("startup prerequisite harness plugin %q: %w", cfg.HarnessPlugin, err)
|
|
486
|
+
}
|
|
487
|
+
if err := ctx.Err(); err != nil {
|
|
488
|
+
return fmt.Errorf("startup prerequisites: %w", err)
|
|
489
|
+
}
|
|
490
|
+
return nil
|
|
491
|
+
}
|
|
492
|
+
|
|
462
493
|
func recoveryBackupStem(database, stamp string, suffixes []string) (string, error) {
|
|
463
494
|
for attempt := 0; attempt < 1000; attempt++ {
|
|
464
495
|
candidateStamp := stamp
|