relay-flow 0.3.12-alpha → 0.3.14-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +39 -5
  2. package/cmd/relay-flow/commands_test.go +48 -0
  3. package/cmd/relay-flow/main.go +17 -8
  4. package/cmd/relay-flow/repo_registration.go +68 -0
  5. package/cmd/relay-flow/repo_selection_keyboard_test.go +178 -0
  6. package/cmd/relay-flow/scenario_test.go +25 -0
  7. package/cmd/relay-flow/serve.go +33 -2
  8. package/cmd/relay-flow/startup_alternate_plugins_test.go +138 -0
  9. package/cmd/relay-flow/startup_errors.go +85 -0
  10. package/cmd/relay-flow/startup_errors_test.go +88 -0
  11. package/cmd/relay-flow/startup_failure_categories_test.go +115 -0
  12. package/cmd/relay-flow/startup_ordering_test.go +75 -0
  13. package/cmd/relay-flow/startup_prerequisites_test.go +324 -0
  14. package/cmd/relay-flow/startup_readiness_test.go +116 -0
  15. package/cmd/relay-flow/startup_submission_test.go +123 -0
  16. package/internal/execution/goworkflows/engine_test.go +1 -1
  17. package/internal/execution/temporal/operations_test.go +1 -1
  18. package/internal/harness/opencode/opencode.go +14 -0
  19. package/internal/harness/opencode/opencode_test.go +1 -1
  20. package/internal/harness/opencode/repo_setup.go +1 -1
  21. package/internal/harness/pi/pi.go +14 -0
  22. package/internal/harness/startup.go +25 -0
  23. package/internal/harness/startup_test.go +85 -0
  24. package/internal/runner/herdr/herdr.go +9 -0
  25. package/internal/runner/herdr/herdrcli/exec.go +14 -0
  26. package/internal/runner/herdr/herdrcli/operations.go +8 -2
  27. package/internal/runner/herdr/herdrcli/response.go +6 -0
  28. package/internal/runner/herdr/herdrcli/startup_test.go +101 -0
  29. package/internal/runner/herdr/startup_test.go +58 -0
  30. package/internal/runner/orca/orca.go +9 -0
  31. package/internal/runner/orca/orcacli/orcacli.go +99 -34
  32. package/internal/runner/orca/orcacli/orcacli_test.go +4 -1
  33. package/internal/runner/orca/orcacli/startup_test.go +158 -0
  34. package/internal/runner/orca/startup_test.go +57 -0
  35. package/internal/runner/startup.go +27 -0
  36. package/internal/runner/startup_test.go +59 -0
  37. package/internal/task/beads/bdcli/bdcli.go +12 -0
  38. package/internal/task/beads/bdcli/startup.go +20 -0
  39. package/internal/task/beads/bdcli/startup_test.go +27 -0
  40. package/internal/task/beads/beads.go +1 -0
  41. package/internal/task/beads/startup.go +75 -0
  42. package/internal/task/beads/startup_test.go +155 -0
  43. package/internal/task/factory.go +6 -2
  44. package/internal/task/jira/jira.go +4 -3
  45. package/internal/task/jira/rest/client.go +40 -6
  46. package/internal/task/jira/rest/startup_redirect_test.go +23 -0
  47. package/internal/task/jira/rest/startup_test.go +133 -0
  48. package/internal/task/jira/startup.go +22 -0
  49. package/internal/task/jira/startup_test.go +78 -0
  50. package/internal/task/startup.go +25 -0
  51. package/internal/task/startup_test.go +51 -0
  52. package/internal/workflow/report.go +4 -4
  53. package/internal/workflow/report_test.go +4 -4
  54. 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.12-alpha
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.12-alpha
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.12-alpha"]
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.12-alpha"]
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.12-alpha
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() {
@@ -308,6 +310,45 @@ func TestReportValidationErrorIsStructuredOnStderr(t *testing.T) {
308
310
  }
309
311
  }
310
312
 
313
+ func TestReportOtherAPIErrorsPreserveCodeForRetryClassification(t *testing.T) {
314
+ const report = `{"runId":"run-1","node":"coding","reportId":"s:m","report":{"status":"success","nextStep":"end"}}`
315
+ for _, tc := range []struct {
316
+ name, code string
317
+ err error
318
+ }{
319
+ {"invalid request", "invalid", fmt.Errorf("%w: malformed report", server.ErrInvalid)},
320
+ {"server failure", "internalError", errors.New("temporary backend failure")},
321
+ } {
322
+ t.Run(tc.name, func(t *testing.T) {
323
+ home := t.TempDir()
324
+ serveAck(t, home, runsvc.ReportAck{}, tc.err)
325
+ reader, writer, err := os.Pipe()
326
+ if err != nil {
327
+ t.Fatal(err)
328
+ }
329
+ original := os.Stderr
330
+ os.Stderr = writer
331
+ code := cli(t, home, report, "report")
332
+ os.Stderr = original
333
+ _ = writer.Close()
334
+ defer reader.Close()
335
+ output, err := io.ReadAll(reader)
336
+ if err != nil {
337
+ t.Fatal(err)
338
+ }
339
+ var response struct {
340
+ Error struct {
341
+ Code string `json:"code"`
342
+ Message string `json:"message"`
343
+ } `json:"error"`
344
+ }
345
+ if err := json.Unmarshal(output, &response); err != nil || code != exitFail || response.Error.Code != tc.code || response.Error.Message != tc.err.Error() {
346
+ t.Fatalf("CLI response: exit=%d stderr=%q parsed=%+v err=%v", code, output, response, err)
347
+ }
348
+ })
349
+ }
350
+ }
351
+
311
352
  func TestReportUnreachableServerExits1(t *testing.T) {
312
353
  valid := `{"runId":"payments/basicFlow/PAY-101","node":"coding","reportId":"s:m","report":{"status":"success","nextStep":"end","summary":{"completed":"x","commits":"abc123","notCompleted":"None","issuesDiscovered":"None","verification":"x","notes":"None"},"feedback":{"reasonForNextStep":"None","requiredActions":"None","relevantContext":"None","expectedResult":"None"}}}`
313
354
  if code := cli(t, t.TempDir(), valid, "report"); code != 1 {
@@ -1094,6 +1135,7 @@ func TestDefaultServeRelativeHomeUsesSharedState(t *testing.T) {
1094
1135
  if out, err := init.CombinedOutput(); err != nil {
1095
1136
  t.Fatalf("relative-home init: %v\n%s", err, out)
1096
1137
  }
1138
+ installHealthyServePrerequisites(t, expectedRoot)
1097
1139
  for _, name := range []string{"config.yaml", "state.db"} {
1098
1140
  if _, err := os.Stat(filepath.Join(expectedRoot, name)); err != nil {
1099
1141
  t.Fatalf("relative-home init missing %s: %v", name, err)
@@ -1125,6 +1167,7 @@ func TestDefaultServeRelativeHomeUsesSharedState(t *testing.T) {
1125
1167
  func TestInProcessDefaultServeReadinessAndStop(t *testing.T) {
1126
1168
  home := t.TempDir()
1127
1169
  initHome(t, home)
1170
+ installHealthyServePrerequisites(t, filepath.Join(home, ".relay-flow"))
1128
1171
  if code := cli(t, home, "", "serve"); code != exitOK {
1129
1172
  t.Fatalf("in-process detached serve exit = %d", code)
1130
1173
  }
@@ -1145,6 +1188,7 @@ func TestDetachedServeUsesStableWorkingDirectory(t *testing.T) {
1145
1188
  if code := run(initArgs(), strings.NewReader("")); code != 0 {
1146
1189
  t.Fatalf("init exit = %d", code)
1147
1190
  }
1191
+ installHealthyServePrerequisites(t, root)
1148
1192
  binary := buildCLIBinary(t)
1149
1193
  t.Cleanup(func() {
1150
1194
  stop := exec.Command(binary, "stop")
@@ -1191,6 +1235,7 @@ func TestForegroundServeUsesStableWorkingDirectory(t *testing.T) {
1191
1235
  if code := run(initArgs(), strings.NewReader("")); code != 0 {
1192
1236
  t.Fatalf("init exit = %d", code)
1193
1237
  }
1238
+ installHealthyServePrerequisites(t, root)
1194
1239
  binary := buildCLIBinary(t)
1195
1240
  caller := t.TempDir()
1196
1241
  serve := exec.Command(binary, "serve", "--foreground")
@@ -1249,6 +1294,7 @@ func TestForegroundServeRelativeHomeUsesSharedState(t *testing.T) {
1249
1294
  if out, err := init.CombinedOutput(); err != nil {
1250
1295
  t.Fatalf("relative-home init: %v\n%s", err, out)
1251
1296
  }
1297
+ installHealthyServePrerequisites(t, expectedRoot)
1252
1298
  serve := exec.Command(binary, "serve", "--foreground")
1253
1299
  serve.Dir = caller
1254
1300
  serve.Env = os.Environ()
@@ -1310,6 +1356,7 @@ func testDetachedServe(t *testing.T, mode []string) {
1310
1356
  if code := run(initArgs(), strings.NewReader("")); code != 0 {
1311
1357
  t.Fatalf("init exit = %d", code)
1312
1358
  }
1359
+ installHealthyServePrerequisites(t, root)
1313
1360
  binary := buildCLIBinary(t)
1314
1361
  args := append([]string{"serve"}, mode...)
1315
1362
  start := exec.Command(binary, args...)
@@ -1416,6 +1463,7 @@ func TestForegroundServeRemainsBlocking(t *testing.T) {
1416
1463
  if code := run(initArgs(), strings.NewReader("")); code != 0 {
1417
1464
  t.Fatalf("init exit = %d", code)
1418
1465
  }
1466
+ installHealthyServePrerequisites(t, root)
1419
1467
  binary := buildCLIBinary(t)
1420
1468
  serve := exec.Command(binary, "serve", "--foreground")
1421
1469
  serve.Env = os.Environ()
@@ -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
- slog.Error("server startup failed", "error", err)
900
- fmt.Fprintln(os.Stderr, err)
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
- wait <- serveRoot(serveCtx, p, recover)
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
  }
@@ -1148,7 +1157,7 @@ func cmdReport(c *server.Client, stdin io.Reader) int {
1148
1157
  defer cancel()
1149
1158
  if _, err := c.SubmitReport(ctx, req); err != nil {
1150
1159
  var apiErr *server.APIError
1151
- if errors.As(err, &apiErr) && apiErr.Code == "invalidReport" {
1160
+ if errors.As(err, &apiErr) {
1152
1161
  _ = json.NewEncoder(os.Stderr).Encode(map[string]any{
1153
1162
  "error": map[string]string{"code": apiErr.Code, "message": apiErr.Message},
1154
1163
  })
@@ -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) *huh.MultiSelect[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(&copy)
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
@@ -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 (docs/structs-methods-interfaces.md lines 1081-1097, verbatim):
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
- // load workflow files with integrity/local checks → bind trusted
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