relay-flow 0.2.8-alpha → 0.2.9-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 (36) hide show
  1. package/README.md +62 -35
  2. package/cmd/relay-flow/commands_test.go +117 -8
  3. package/cmd/relay-flow/main.go +167 -60
  4. package/cmd/relay-flow/onboarding.go +344 -0
  5. package/cmd/relay-flow/onboarding_test.go +515 -0
  6. package/cmd/relay-flow/repo_registration.go +219 -0
  7. package/cmd/relay-flow/serve.go +3 -0
  8. package/go.mod +12 -8
  9. package/go.sum +24 -17
  10. package/internal/execution/goworkflows/engine_test.go +3 -3
  11. package/internal/harness/opencode/opencode_test.go +4 -4
  12. package/internal/harness/opencode/repo_setup.go +1 -1
  13. package/internal/harness/pi/prompt_test.go +3 -3
  14. package/internal/repo/service.go +34 -3
  15. package/internal/repo/service_test.go +22 -0
  16. package/internal/runner/herdr/herdr.go +103 -4
  17. package/internal/runner/herdr/herdr_test.go +72 -2
  18. package/internal/runner/herdr/herdrcli/contract.go +12 -3
  19. package/internal/runner/herdr/herdrcli/herdrcli_test.go +17 -2
  20. package/internal/runner/herdr/herdrcli/operations.go +16 -0
  21. package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +8 -0
  22. package/internal/runner/herdr/herdrcli/testdata/workspace-create.json +1 -0
  23. package/internal/runner/orca/orca.go +68 -9
  24. package/internal/runner/orca/orca_test.go +134 -2
  25. package/internal/runner/orca/orcacli/orcacli.go +21 -1
  26. package/internal/runner/orca/orcacli/orcacli_test.go +22 -0
  27. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +5 -0
  28. package/internal/runner/runner.go +9 -0
  29. package/internal/server/api_test.go +19 -0
  30. package/internal/server/client.go +10 -0
  31. package/internal/server/fixture_test.go +7 -0
  32. package/internal/server/server.go +41 -0
  33. package/internal/task/jira/testdata/jira_search_issues.json +4 -4
  34. package/internal/workflow/workflow.go +4 -1
  35. package/internal/workflow/workflow_test.go +4 -4
  36. package/package.json +1 -1
package/README.md CHANGED
@@ -24,13 +24,13 @@ same `relay-flow-plugin` package with host-specific entrypoints.
24
24
  **OpenCode**
25
25
 
26
26
  ```sh
27
- opencode plugin relay-flow-plugin@0.2.8-alpha
27
+ opencode plugin relay-flow-plugin@0.2.9-alpha
28
28
  ```
29
29
 
30
30
  **Pi**
31
31
 
32
32
  ```sh
33
- pi install npm:relay-flow-plugin@0.2.8-alpha
33
+ pi install npm:relay-flow-plugin@0.2.9-alpha
34
34
  ```
35
35
 
36
36
  Pi loads the package's `pi.ts` extension from its manifest. Do not add
@@ -38,39 +38,40 @@ Pi loads the package's `pi.ts` extension from its manifest. Do not add
38
38
 
39
39
  ### 3. Initialize relay-flow
40
40
 
41
- Choose one task system, runner, harness, and durable executor. This example
42
- uses Jira, Orca, OpenCode, and the default embedded executor:
41
+ For a first-time interactive setup, run:
43
42
 
44
43
  ```sh
45
- relay-flow init \
46
- --task-plugin jira \
47
- --runner-plugin orca \
48
- --harness-plugin opencode
44
+ relay-flow init
49
45
  ```
50
46
 
51
- The default executor is `goworkflows` with SQLite. To use Temporal instead,
52
- add `--executor-plugin temporal`, `--temporal-address <host:port>`, and
53
- `--temporal-namespace <name>` to the command.
54
-
55
- ### 4. Authenticate the task system
47
+ Relay-flow selects the task system, runner, harness, and durable executor,
48
+ runs the selected task plugin's authentication flow inline, and then asks
49
+ whether repositories should be registered now. Choosing no completes setup
50
+ without starting a server. Choosing yes starts (or reuses) the background
51
+ server, waits for its Unix socket, and opens the existing repository
52
+ registration form. After registration, the server remains running; stop it
53
+ with `relay-flow stop`.
56
54
 
57
- For Jira, authenticate the selected task plugin:
55
+ The equivalent non-interactive setup keeps the independent commands available
56
+ for scripts and existing installations. This example uses Jira, Orca,
57
+ OpenCode, and the default embedded executor:
58
58
 
59
59
  ```sh
60
+ relay-flow init \
61
+ --task-plugin jira \
62
+ --runner-plugin orca \
63
+ --harness-plugin opencode
60
64
  relay-flow task auth
65
+ relay-flow serve --background
61
66
  ```
62
67
 
63
- For Beads, skip this command and initialize/authenticate the Beads workspace
68
+ For Beads, skip `task auth` and initialize/authenticate the Beads workspace
64
69
  with `bd` and, when needed, Dolt. See [Beads task system](#beads-task-system)
65
- below.
70
+ below. The default executor is `goworkflows` with SQLite. To use Temporal
71
+ instead, add `--executor-plugin temporal`, `--temporal-address <host:port>`,
72
+ and `--temporal-namespace <name>` to the command.
66
73
 
67
- ### 5. Start the server
68
-
69
- ```sh
70
- relay-flow serve --background
71
- ```
72
-
73
- ### 6. Register a repository
74
+ ### 4. Register a repository later
74
75
 
75
76
  The repository must already exist in the selected runner. For Orca:
76
77
 
@@ -81,9 +82,11 @@ relay-flow repo register
81
82
 
82
83
  For Herdr, register the repository path directly; relay-flow creates ticket
83
84
  worktrees lazily. The interactive registration asks for the task-system values
84
- required by the selected task plugin.
85
+ required by the selected task plugin. `repo register` remains the standalone
86
+ escape hatch for adding repositories after initialization and requires a
87
+ running `relay-flow serve --background`.
85
88
 
86
- ### 7. Submit a workflow
89
+ ### 5. Submit a workflow
87
90
 
88
91
  ```sh
89
92
  relay-flow workflow submit --file examples/minimal-jira-task-workflow.yaml
@@ -154,7 +157,7 @@ OpenCode plugin configuration uses both entrypoints. The server entrypoint is li
154
157
  ```json
155
158
  {
156
159
  "$schema": "https://opencode.ai/config.json",
157
- "plugin": ["relay-flow-plugin@0.2.8-alpha"]
160
+ "plugin": ["relay-flow-plugin@0.2.9-alpha"]
158
161
  }
159
162
  ```
160
163
 
@@ -163,7 +166,7 @@ The native HITL approval entrypoint is listed in `.opencode/tui.json`:
163
166
  ```json
164
167
  {
165
168
  "$schema": "https://opencode.ai/tui.json",
166
- "plugin": ["relay-flow-plugin@0.2.8-alpha"]
169
+ "plugin": ["relay-flow-plugin@0.2.9-alpha"]
167
170
  }
168
171
  ```
169
172
 
@@ -179,7 +182,7 @@ Pi plugin: install the same published package manually in Pi's global package
179
182
  settings before starting a Pi harness session:
180
183
 
181
184
  ```sh
182
- pi install npm:relay-flow-plugin@0.2.8-alpha
185
+ pi install npm:relay-flow-plugin@0.2.9-alpha
183
186
  ```
184
187
 
185
188
  Relay-flow does not install or configure the package automatically. Pi resolves
@@ -199,10 +202,30 @@ internal and is never part of either plugin payload.
199
202
 
200
203
  ```sh
201
204
  relay-flow init
202
- relay-flow task auth
203
205
  ```
204
206
 
205
- `init` selects the task system, runner, harness, and durable executor (singleton options are automatic), writes machine config, and initializes the selected execution backend. `task auth` delegates authentication to that selected task plug-in. Jira prompts for its site, email, and masked API token, validates `/myself`, and owns the system-wide `credentials.yaml`; for scripts, pass `task auth --site`, `--email`, and `--token`. A normal init rerun refuses existing state. `relay-flow init --force` updates safe stopped instances while preserving durable and repo state.
207
+ On a new interactive home, `init` selects the task system, runner, harness,
208
+ and durable executor, runs the selected task plug-in's authentication flow,
209
+ and asks whether to register repositories. It writes machine config and the
210
+ selected execution backend only after authentication succeeds. A skipped
211
+ repository setup does not start the server; selecting it starts or reuses the
212
+ background server, waits for socket readiness, and invokes the same
213
+ `repo register` form described below. The server remains running after a
214
+ successful guided registration.
215
+
216
+ For scripts and existing installations, keep the independent sequence:
217
+
218
+ ```sh
219
+ relay-flow init --task-plugin jira --runner-plugin orca --harness-plugin opencode
220
+ relay-flow task auth --site https://company.atlassian.net --email you@example.com --token "$JIRA_API_TOKEN"
221
+ relay-flow serve --background
222
+ ```
223
+
224
+ `task auth` delegates to the selected task plug-in. Jira prompts for its site,
225
+ email, and masked API token when flags are omitted, validates `/myself`, and
226
+ owns the system-wide `credentials.yaml`. A normal init rerun refuses existing
227
+ state. `relay-flow init --force` updates safe stopped instances while
228
+ preserving durable and repo state; it does not launch repository onboarding.
206
229
 
207
230
  For Beads, select the plugin explicitly when scripting setup:
208
231
 
@@ -234,9 +257,11 @@ workflows/<name>.yaml 0644 submitted workflow definitions
234
257
 
235
258
  ### Register a repo
236
259
 
237
- `repo register` and `workflow submit` are server-backed commands: start the
238
- server first, otherwise they fail with `dial unix ~/.relay-flow/server.sock:
239
- connect: no such file or directory`.
260
+ The guided first-run flow can start the server and open repository registration
261
+ for you. `repo register` remains a server-backed standalone command for later
262
+ additions: if no server is running, start it with
263
+ `relay-flow serve --background` (or rerun interactive `relay-flow init` for a
264
+ new home).
240
265
 
241
266
  ```sh
242
267
  relay-flow serve --background
@@ -369,8 +394,9 @@ Task, runner, harness, and durable executor plugins are selected machine-wide. A
369
394
 
370
395
  ### Run
371
396
 
372
- The server must already be running for `repo register` and `workflow submit`;
373
- the same process polls repos and drives runs.
397
+ The guided first-run flow starts or reuses the server before `repo register`.
398
+ Outside that flow, `repo register` and `workflow submit` require the server to
399
+ already be running; the same process polls repos and drives runs.
374
400
 
375
401
  ```sh
376
402
  relay-flow serve # normal start; requires an initialized database
@@ -455,6 +481,7 @@ Rules enforced at submit:
455
481
  - The graph must be fully reachable from `start`; unknown fields are rejected; `runnerPlugin`/`harnessPlugin`/`closeOn`/legacy `tasks`/`runner` keys are rejected.
456
482
  - Only `agent` and `hitl` nodes receive mailbox subtasks; `start` and `end` never do.
457
483
  - `cleanupRunnerOnEnd` is the only workflow cleanup knob and takes priority over terminal retention after `end`; the word `terminal` refers to runner terminals only.
484
+ - `nudgePrompt` supports `{{taskSystem}}`, `{{ticket}}`, `{{workflow}}`, `{{repo}}`, `{{node}}`, `{{mailbox}}`, and `{{nextSteps}}`; unsupported variables are rejected at submission.
458
485
 
459
486
  ### Task-config merge
460
487
 
@@ -631,13 +631,16 @@ func insertRun(t *testing.T, path, id, state string) {
631
631
  // 8.4: fully-flagged repo register never prompts and produces the same
632
632
  // repo entry the interactive run would post.
633
633
  type registerServer struct {
634
- ackServer // embed for the unreachable stubs
635
- fields []string
636
- gotInput *repo.RegisterInput
637
- gotInputs []repo.RegisterInput
638
- successful []repo.RegisterInput
639
- failName string
640
- calls int
634
+ ackServer // embed for the unreachable stubs
635
+ fields []string
636
+ gotInput *repo.RegisterInput
637
+ gotInputs []repo.RegisterInput
638
+ successful []repo.RegisterInput
639
+ failName string
640
+ calls int
641
+ candidates []runner.RepoCandidate
642
+ ensureCalls []runner.RepoCandidate
643
+ registered []repo.Info
641
644
  }
642
645
 
643
646
  func (s *registerServer) TaskRegistrationFields(context.Context, config.RawValues) ([]task.RegistrationField, error) {
@@ -648,6 +651,25 @@ func (s *registerServer) TaskRegistrationFields(context.Context, config.RawValue
648
651
  }
649
652
  return out, nil
650
653
  }
654
+ func (s *registerServer) DiscoverRepos(context.Context) ([]runner.RepoCandidate, error) {
655
+ return append([]runner.RepoCandidate(nil), s.candidates...), nil
656
+ }
657
+
658
+ func (s *registerServer) EnsureRepo(_ context.Context, candidate runner.RepoCandidate) error {
659
+ s.ensureCalls = append(s.ensureCalls, candidate)
660
+ for _, existing := range s.candidates {
661
+ if existing.Path == candidate.Path {
662
+ return nil
663
+ }
664
+ }
665
+ s.candidates = append(s.candidates, candidate)
666
+ return nil
667
+ }
668
+
669
+ func (s *registerServer) ListRepos(context.Context) ([]repo.Info, error) {
670
+ return append([]repo.Info(nil), s.registered...), nil
671
+ }
672
+
651
673
  func (s *registerServer) RegisterRepo(_ context.Context, in repo.RegisterInput) (repo.Info, error) {
652
674
  s.calls++
653
675
  cp := in
@@ -657,7 +679,9 @@ func (s *registerServer) RegisterRepo(_ context.Context, in repo.RegisterInput)
657
679
  return repo.Info{}, errors.New("registration failed")
658
680
  }
659
681
  s.successful = append(s.successful, cp)
660
- return repo.Info{Name: in.Name, Path: in.Path, TaskConfig: in.TaskConfig}, nil
682
+ info := repo.Info{Name: in.Name, Path: in.Path, TaskConfig: in.TaskConfig}
683
+ s.registered = append(s.registered, info)
684
+ return info, nil
661
685
  }
662
686
 
663
687
  func serveRegister(t *testing.T, home string, fields []string) *registerServer {
@@ -831,6 +855,91 @@ func TestRegistrationTaskConfigBuildsNestedStatusDefaults(t *testing.T) {
831
855
  }
832
856
  }
833
857
 
858
+ func TestRepoAddValidationRejectsDuplicateNormalizedPathAndName(t *testing.T) {
859
+ candidates := []runner.RepoCandidate{{Name: "payments", Path: "/work/payments"}}
860
+ if err := validateAddedRepo(runner.RepoCandidate{Name: "new", Path: "/work/payments/."}, candidates, nil); err == nil || !strings.Contains(err.Error(), "already discovered") {
861
+ t.Fatalf("duplicate discovered path error = %v", err)
862
+ }
863
+ registered := []repo.Info{{Name: "checkout", Path: "/work/checkout"}}
864
+ if err := validateAddedRepo(runner.RepoCandidate{Name: " checkout ", Path: "/work/new"}, candidates, registered); err == nil || !strings.Contains(err.Error(), "already registered") {
865
+ t.Fatalf("duplicate registered name error = %v", err)
866
+ }
867
+ for _, candidate := range []runner.RepoCandidate{{Name: "", Path: "/work/new"}, {Name: "new", Path: ""}} {
868
+ if err := validateAddedRepo(candidate, nil, nil); err == nil {
869
+ t.Fatalf("validateAddedRepo(%+v) accepted missing input", candidate)
870
+ }
871
+ }
872
+ }
873
+
874
+ func TestInteractiveRepoSelectionPreservesSelectionsAndAddsMultiple(t *testing.T) {
875
+ home := t.TempDir()
876
+ deps := serveRegister(t, home, nil)
877
+ deps.candidates = []runner.RepoCandidate{{Name: "existing", Path: "/work/existing"}}
878
+ client := server.NewClient(filepath.Join(home, ".relay-flow", "server.sock"))
879
+
880
+ selectionSteps := [][]int{{0, addRepositorySelection}, {0, 1, addRepositorySelection}, {0, 1, 2}}
881
+ addSteps := []runner.RepoCandidate{
882
+ {Name: "new-one", Path: filepath.Join(t.TempDir(), "new-one")},
883
+ {Name: "new-two", Path: filepath.Join(t.TempDir(), "new-two")},
884
+ }
885
+ selectionCalls := 0
886
+ addCalls := 0
887
+ candidates, selected, err := selectReposInteractiveWithPrompts(
888
+ context.Background(), client, deps.candidates, nil,
889
+ func(got []runner.RepoCandidate, selected []int) ([]int, error) {
890
+ wantSelected := [][]int{nil, {0, 1}, {0, 1, 2}}[selectionCalls]
891
+ if fmt.Sprint(selected) != fmt.Sprint(wantSelected) {
892
+ t.Fatalf("selection %d started with %v, want %v", selectionCalls, selected, wantSelected)
893
+ }
894
+ if selectionCalls >= len(selectionSteps) {
895
+ t.Fatalf("unexpected selection call %d with candidates %+v", selectionCalls, got)
896
+ }
897
+ step := append([]int(nil), selectionSteps[selectionCalls]...)
898
+ selectionCalls++
899
+ return step, nil
900
+ },
901
+ func() (runner.RepoCandidate, error) {
902
+ if addCalls >= len(addSteps) {
903
+ return runner.RepoCandidate{}, errors.New("unexpected add prompt")
904
+ }
905
+ step := addSteps[addCalls]
906
+ addCalls++
907
+ return step, nil
908
+ },
909
+ )
910
+ if err != nil {
911
+ t.Fatal(err)
912
+ }
913
+ if selectionCalls != 3 || addCalls != 2 || len(deps.ensureCalls) != 2 {
914
+ t.Fatalf("selection calls=%d add calls=%d ensure calls=%v", selectionCalls, addCalls, deps.ensureCalls)
915
+ }
916
+ if len(candidates) != 3 || len(selected) != 3 || fmt.Sprint(selected) != "[0 1 2]" {
917
+ t.Fatalf("final candidates=%+v selected=%v", candidates, selected)
918
+ }
919
+ if candidates[1].Name != "new-one" || candidates[2].Name != "new-two" {
920
+ t.Fatalf("final candidate names=%+v", candidates)
921
+ }
922
+ }
923
+
924
+ func TestRepoSelectionOptionsIncludeVisibleAddAction(t *testing.T) {
925
+ options := repoSelectionOptions([]runner.RepoCandidate{{Name: "payments", Path: "/work/payments"}})
926
+ if len(options) != 2 {
927
+ t.Fatalf("options = %d, want candidate plus Add action", len(options))
928
+ }
929
+ var selected []int
930
+ field := repoMultiSelect(options, &selected)
931
+ var out bytes.Buffer
932
+ if err := field.RunAccessible(&out, strings.NewReader("2\n")); err != nil {
933
+ t.Fatal(err)
934
+ }
935
+ if len(selected) != 1 || selected[0] != addRepositorySelection {
936
+ t.Fatalf("selected = %v, want Add action sentinel", selected)
937
+ }
938
+ if !strings.Contains(out.String(), "Add repository") {
939
+ t.Fatalf("output %q missing Add repository", out.String())
940
+ }
941
+ }
942
+
834
943
  func TestRepoMultiSelectAndPluginMapping(t *testing.T) {
835
944
  selected := []int{0, 1}
836
945
  field := repoMultiSelect([]huh.Option[int]{