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
@@ -57,8 +57,22 @@ type Client interface {
57
57
  // CLI is the production Client backed by the orca binary.
58
58
  type CLI struct{}
59
59
 
60
+ // RepoRegistrar is the optional repository-provisioning seam used by the
61
+ // runner adapter. Keeping it separate from Client lets existing read-only
62
+ // test clients remain focused on the runtime operations they exercise.
63
+ type RepoRegistrar interface {
64
+ AddRepo(ctx context.Context, path string) error
65
+ }
66
+
60
67
  func New() *CLI { return &CLI{} }
61
68
 
69
+ // AddRepo registers an existing local repository with Orca. Orca derives its
70
+ // display name from the path; relay-flow keeps the user-facing registration
71
+ // name separately.
72
+ func (CLI) AddRepo(ctx context.Context, path string) error {
73
+ return run(ctx, "repo", "add", "--path", path, "--json")
74
+ }
75
+
62
76
  func (CLI) ListRepos(ctx context.Context) ([]Repo, error) {
63
77
  var res struct {
64
78
  Result struct {
@@ -194,7 +208,13 @@ func (CLI) CreateTerminal(ctx context.Context, ticketKey, title, command string)
194
208
  }
195
209
 
196
210
  func (CLI) CloseTerminal(ctx context.Context, handle string) error {
197
- return run(ctx, "terminal", "close", "--terminal", handle, "--json")
211
+ if err := run(ctx, "terminal", "close", "--terminal", handle, "--json"); err != nil {
212
+ if strings.Contains(err.Error(), "terminal_handle_stale") {
213
+ return ErrTerminalUnavailable
214
+ }
215
+ return err
216
+ }
217
+ return nil
198
218
  }
199
219
 
200
220
  func run(ctx context.Context, args ...string) error {
@@ -2,6 +2,7 @@ package orcacli
2
2
 
3
3
  import (
4
4
  "context"
5
+ "errors"
5
6
  "os"
6
7
  "os/exec"
7
8
  "path/filepath"
@@ -30,6 +31,9 @@ func TestCLIContractsAgainstCapturedRealOutput(t *testing.T) {
30
31
  t.Fatalf("ListWorktrees = %+v", worktrees)
31
32
  }
32
33
 
34
+ if err := cli.AddRepo(ctx, "/work/new"); err != nil {
35
+ t.Fatal(err)
36
+ }
33
37
  if err := cli.CreateWorktree(ctx, "PAY-101", "repo-1", "wt-main", "origin/alice/PAY-101"); err != nil {
34
38
  t.Fatal(err)
35
39
  }
@@ -80,6 +84,24 @@ func TestStrictFakeOrcaRejectsMissingFlags(t *testing.T) {
80
84
  }
81
85
  }
82
86
 
87
+ func TestCloseTerminalMapsStaleHandleToUnavailable(t *testing.T) {
88
+ installStrictFakeOrca(t)
89
+ cli := New()
90
+
91
+ err := cli.CloseTerminal(context.Background(), "term-stale")
92
+ if !errors.Is(err, ErrTerminalUnavailable) {
93
+ t.Fatalf("CloseTerminal(stale) = %v, want ErrTerminalUnavailable", err)
94
+ }
95
+
96
+ err = cli.CloseTerminal(context.Background(), "term-unknown")
97
+ if err == nil {
98
+ t.Fatal("CloseTerminal(unknown) = nil, want the CLI failure")
99
+ }
100
+ if errors.Is(err, ErrTerminalUnavailable) {
101
+ t.Fatalf("CloseTerminal(unknown) = %v, incorrectly classified as stale", err)
102
+ }
103
+ }
104
+
83
105
  func TestFindExistingBranchLocalOnly(t *testing.T) {
84
106
  repo := newGitRepo(t)
85
107
  runGit(t, repo, "branch", "alice/PAY-101")
@@ -4,6 +4,8 @@ set -eu
4
4
  fixture=
5
5
  if [ "$#" -eq 3 ] && [ "$1" = repo ] && [ "$2" = list ] && [ "$3" = --json ]; then
6
6
  fixture=repo-list.json
7
+ elif [ "$#" -eq 5 ] && [ "$1" = repo ] && [ "$2" = add ] && [ "$3" = --path ] && [ "$4" = /work/new ] && [ "$5" = --json ]; then
8
+ fixture=repo-list.json
7
9
  elif [ "$#" -eq 3 ] && [ "$1" = worktree ] && [ "$2" = list ] && [ "$3" = --json ]; then
8
10
  fixture=worktree-list.json
9
11
  elif [ "$#" -eq 11 ] && [ "$1" = worktree ] && [ "$2" = create ] && [ "$3" = --name ] && [ "$4" = PAY-101 ] && [ "$5" = --repo ] && [ "$6" = id:repo-1 ] && [ "$7" = --parent-worktree ] && [ "$8" = worktree:wt-main ] && [ "$9" = --base-branch ] && [ "${10}" = origin/alice/PAY-101 ] && [ "${11}" = --json ]; then
@@ -20,6 +22,9 @@ elif [ "$#" -eq 8 ] && [ "$1" = terminal ] && [ "$2" = send ] && [ "$3" = --term
20
22
  fixture=terminal-send.json
21
23
  elif [ "$#" -eq 9 ] && [ "$1" = terminal ] && [ "$2" = create ] && [ "$3" = --worktree ] && [ "$4" = name:PAY-101 ] && [ "$5" = --title ] && [ "$6" = PAY-101:implement ] && [ "$7" = --command ] && [ "$8" = 'echo hello' ] && [ "$9" = --json ]; then
22
24
  fixture=terminal-create.json
25
+ elif [ "$#" -eq 5 ] && [ "$1" = terminal ] && [ "$2" = close ] && [ "$3" = --terminal ] && [ "$4" = term-stale ] && [ "$5" = --json ]; then
26
+ printf '%s\n' '{"ok":false,"error":{"code":"terminal_handle_stale","message":"terminal_handle_stale"}}'
27
+ exit 1
23
28
  elif [ "$#" -eq 5 ] && [ "$1" = terminal ] && [ "$2" = close ] && [ "$3" = --terminal ] && [ "$4" = term-1 ] && [ "$5" = --json ]; then
24
29
  fixture=terminal-close.json
25
30
  else
@@ -24,6 +24,15 @@ type RepoCandidate struct {
24
24
  Path string `json:"path"`
25
25
  }
26
26
 
27
+ // RepoRegistrar is the optional runner capability used by repository
28
+ // registration. Runners that manage an external repository/workspace can
29
+ // make the resource available before relay-flow persists the repo entry.
30
+ // Runners without a provisioning primitive still use ValidateRepo through
31
+ // the repo service.
32
+ type RepoRegistrar interface {
33
+ EnsureRepo(context.Context, string, string) error
34
+ }
35
+
27
36
  type Environment struct {
28
37
  ID string `json:"id"`
29
38
  Path string `json:"path"`
@@ -14,6 +14,7 @@ import (
14
14
 
15
15
  "github.com/rajpopat27/relay-flow/internal/config"
16
16
  "github.com/rajpopat27/relay-flow/internal/run"
17
+ "github.com/rajpopat27/relay-flow/internal/runner"
17
18
  "github.com/rajpopat27/relay-flow/internal/server"
18
19
  "github.com/rajpopat27/relay-flow/internal/task"
19
20
  "github.com/rajpopat27/relay-flow/internal/workflow"
@@ -333,6 +334,24 @@ func TestClientRepoRegistrationFieldsUsesGetThenPost(t *testing.T) {
333
334
  }
334
335
  }
335
336
 
337
+ func TestRepoEnsureEndpoint(t *testing.T) {
338
+ fake := &fakeServices{}
339
+ c, cleanup := startHandler(t, fake)
340
+ defer cleanup()
341
+ code, env := do(t, c, http.MethodPost, "http://relay/repos/ensure", []byte(`{"name":"payments","path":"/srv/payments"}`))
342
+ if code != http.StatusOK || !env.OK {
343
+ t.Fatalf("POST /repos/ensure: code=%d env=%+v, want 200 ok", code, env)
344
+ }
345
+ if len(fake.ensureRepoCalls) != 1 || fake.ensureRepoCalls[0] != (runner.RepoCandidate{Name: "payments", Path: "/srv/payments"}) {
346
+ t.Fatalf("EnsureRepo calls = %+v", fake.ensureRepoCalls)
347
+ }
348
+
349
+ code, env = do(t, c, http.MethodPost, "http://relay/repos/ensure", []byte(`{"name":"payments"}`))
350
+ if code != http.StatusBadRequest || env.OK {
351
+ t.Fatalf("invalid POST /repos/ensure: code=%d env=%+v, want 400", code, env)
352
+ }
353
+ }
354
+
336
355
  func TestRepoOperations(t *testing.T) {
337
356
  c, cleanup := startHandler(t, &fakeServices{})
338
357
  defer cleanup()
@@ -136,6 +136,16 @@ func (c *Client) DiscoverRepos(ctx context.Context) ([]runner.RepoCandidate, err
136
136
  return out, nil
137
137
  }
138
138
 
139
+ // EnsureRepo asks the configured runner to make a repository resource
140
+ // available without persisting a relay-flow repo entry.
141
+ func (c *Client) EnsureRepo(ctx context.Context, candidate runner.RepoCandidate) error {
142
+ payload, err := json.Marshal(candidate)
143
+ if err != nil {
144
+ return err
145
+ }
146
+ return c.call(ctx, http.MethodPost, "/repos/ensure", payload, nil)
147
+ }
148
+
139
149
  // RepoTaskFields returns the documented initial required-key metadata.
140
150
  func (c *Client) RepoTaskFields(ctx context.Context) ([]string, error) {
141
151
  var out struct {
@@ -40,6 +40,8 @@ type fakeServices struct {
40
40
  restartErr error
41
41
  restarts []string
42
42
  registrationValues []config.RawValues
43
+ ensureRepoCalls []runner.RepoCandidate
44
+ ensureRepoErr error
43
45
  }
44
46
 
45
47
  func (f *fakeServices) SubmitWorkflow(_ context.Context, yaml []byte) (*workflow.Workflow, error) {
@@ -155,6 +157,11 @@ func (f *fakeServices) DiscoverRepos(context.Context) ([]runner.RepoCandidate, e
155
157
  return []runner.RepoCandidate{{Name: "payments", Path: "/srv/payments"}}, nil
156
158
  }
157
159
 
160
+ func (f *fakeServices) EnsureRepo(_ context.Context, candidate runner.RepoCandidate) error {
161
+ f.ensureRepoCalls = append(f.ensureRepoCalls, candidate)
162
+ return f.ensureRepoErr
163
+ }
164
+
158
165
  func (f *fakeServices) TaskRegistrationFields(_ context.Context, values config.RawValues) ([]task.RegistrationField, error) {
159
166
  f.registrationValues = append(f.registrationValues, values)
160
167
  return []task.RegistrationField{{Key: "project", Title: "Project"}, {Key: "component", Title: "Component", Derived: true}}, nil
@@ -85,6 +85,7 @@ func New(deps Deps) http.Handler {
85
85
  mux.HandleFunc("/workflows", s.handleWorkflows)
86
86
  mux.HandleFunc("/workflows/", s.handleWorkflowByName)
87
87
  mux.HandleFunc("/repos/discover", s.handleReposDiscover)
88
+ mux.HandleFunc("/repos/ensure", s.handleRepoEnsure)
88
89
  mux.HandleFunc("/repos/task-fields", s.handleRepoTaskFields)
89
90
  mux.HandleFunc("/repos", s.handleRepos)
90
91
  mux.HandleFunc("/repos/", s.handleRepoByName)
@@ -99,6 +100,14 @@ type server struct {
99
100
  deps Deps
100
101
  }
101
102
 
103
+ // repoEnsurer is implemented by the composition root when the selected
104
+ // runner can provision a repository resource. It is intentionally separate
105
+ // from Deps so existing task/run service seams do not need a runner-specific
106
+ // method.
107
+ type repoEnsurer interface {
108
+ EnsureRepo(context.Context, runner.RepoCandidate) error
109
+ }
110
+
102
111
  // --- envelope helpers ---
103
112
 
104
113
  func writeOK(w http.ResponseWriter, status int, data any) {
@@ -247,6 +256,38 @@ func (s *server) handleReposDiscover(w http.ResponseWriter, r *http.Request) {
247
256
  writeOK(w, http.StatusOK, candidates)
248
257
  }
249
258
 
259
+ func (s *server) handleRepoEnsure(w http.ResponseWriter, r *http.Request) {
260
+ if !methodOnly(w, r, http.MethodPost) {
261
+ return
262
+ }
263
+ ensurer, ok := s.deps.(repoEnsurer)
264
+ if !ok {
265
+ mapErr(w, fmt.Errorf("%w: runner repository provisioning is unavailable", ErrInvalid))
266
+ return
267
+ }
268
+ body, err := readBody(r)
269
+ if err != nil {
270
+ writeErr(w, http.StatusBadRequest, "invalid", err.Error())
271
+ return
272
+ }
273
+ var candidate runner.RepoCandidate
274
+ if err := decodeStrict(body, &candidate); err != nil {
275
+ writeErr(w, http.StatusBadRequest, "invalid", err.Error())
276
+ return
277
+ }
278
+ candidate.Name = strings.TrimSpace(candidate.Name)
279
+ candidate.Path = strings.TrimSpace(candidate.Path)
280
+ if candidate.Name == "" || candidate.Path == "" {
281
+ writeErr(w, http.StatusBadRequest, "invalid", "repository name and path are required")
282
+ return
283
+ }
284
+ if err := ensurer.EnsureRepo(r.Context(), candidate); err != nil {
285
+ mapErr(w, err)
286
+ return
287
+ }
288
+ writeOK(w, http.StatusOK, candidate)
289
+ }
290
+
250
291
  func (s *server) handleRepoTaskFields(w http.ResponseWriter, r *http.Request) {
251
292
  if r.Method == http.MethodGet {
252
293
  fields, err := s.deps.TaskRegistrationFields(r.Context(), nil)
@@ -15,7 +15,7 @@
15
15
  "48x48": "https://secure.gravatar.com/avatar/09110b9326675e772c94aa61fc89195b?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FRP-6.png"
16
16
  },
17
17
  "displayName": "Raj Popat",
18
- "emailAddress": "raj.popat@wolterskluwer.com",
18
+ "emailAddress": "test.user@example.com",
19
19
  "self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/user?accountId=712020%3Adad5df85-a7bd-4337-b858-0faf9012a009",
20
20
  "timeZone": "America/New_York"
21
21
  },
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "fieldsToInclude": null,
50
50
  "id": "157320",
51
- "key": "GHCOS-41242",
51
+ "key": "DEMO-41242",
52
52
  "names": null,
53
53
  "operations": null,
54
54
  "properties": null,
@@ -74,7 +74,7 @@
74
74
  "48x48": "https://secure.gravatar.com/avatar/09110b9326675e772c94aa61fc89195b?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FRP-6.png"
75
75
  },
76
76
  "displayName": "Raj Popat",
77
- "emailAddress": "raj.popat@wolterskluwer.com",
77
+ "emailAddress": "test.user@example.com",
78
78
  "self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/user?accountId=712020%3Adad5df85-a7bd-4337-b858-0faf9012a009",
79
79
  "timeZone": "America/New_York"
80
80
  },
@@ -107,7 +107,7 @@
107
107
  },
108
108
  "fieldsToInclude": null,
109
109
  "id": "155110",
110
- "key": "GHCOS-41175",
110
+ "key": "DEMO-41175",
111
111
  "names": null,
112
112
  "operations": null,
113
113
  "properties": null,
@@ -62,6 +62,7 @@ type NudgeTemplateData struct {
62
62
  Workflow string
63
63
  Repo string
64
64
  Node string
65
+ Mailbox string
65
66
  NextSteps string
66
67
  }
67
68
 
@@ -70,7 +71,7 @@ var namePattern = regexp.MustCompile(`^[a-z][a-zA-Z0-9]*$`)
70
71
  var nudgeVarPattern = regexp.MustCompile(`\{\{([^{}]*)\}\}`)
71
72
 
72
73
  var knownNudgeVars = map[string]bool{
73
- "taskSystem": true, "ticket": true, "workflow": true, "repo": true, "node": true, "nextSteps": true,
74
+ "taskSystem": true, "ticket": true, "workflow": true, "repo": true, "node": true, "mailbox": true, "nextSteps": true,
74
75
  }
75
76
 
76
77
  // Parse strictly decodes a workflow YAML document. Unknown fields and
@@ -326,6 +327,8 @@ func (w *Workflow) RenderNudge(node string, data NudgeTemplateData) (string, err
326
327
  return data.Repo
327
328
  case "node":
328
329
  return data.Node
330
+ case "mailbox":
331
+ return data.Mailbox
329
332
  case "nextSteps":
330
333
  return data.NextSteps
331
334
  }
@@ -350,7 +350,7 @@ func TestValidateGraphReachability(t *testing.T) {
350
350
 
351
351
  func TestValidateNudgeTemplate(t *testing.T) {
352
352
  t.Run("supported variables", func(t *testing.T) {
353
- yaml := strings.Replace(minimalValid, " description: Do the coding work.", " description: Do the coding work.\n nudgePrompt: \"{{taskSystem}} {{ticket}} {{workflow}} {{repo}} {{node}} {{nextSteps}}\"", 1)
353
+ yaml := strings.Replace(minimalValid, " description: Do the coding work.", " description: Do the coding work.\n nudgePrompt: \"{{taskSystem}} {{ticket}} {{workflow}} {{repo}} {{node}} {{mailbox}} {{nextSteps}}\"", 1)
354
354
  wf := parse(t, "basicFlow", yaml)
355
355
  if err := wf.Validate(); err != nil {
356
356
  t.Fatalf("supported nudge variables rejected: %v", err)
@@ -389,16 +389,16 @@ func TestCleanupRunnerOnEndDefaultsFalse(t *testing.T) {
389
389
  }
390
390
 
391
391
  func TestRenderNudge(t *testing.T) {
392
- yaml := strings.Replace(minimalValid, " description: Do the coding work.", " description: Do the coding work.\n nudgePrompt: \"task={{taskSystem}} ticket={{ticket}} wf={{workflow}} repo={{repo}} node={{node}} steps={{nextSteps}}\"", 1)
392
+ yaml := strings.Replace(minimalValid, " description: Do the coding work.", " description: Do the coding work.\n nudgePrompt: \"task={{taskSystem}} ticket={{ticket}} wf={{workflow}} repo={{repo}} node={{node}} mailbox={{mailbox}} steps={{nextSteps}}\"", 1)
393
393
  wf := parse(t, "basicFlow", yaml)
394
394
  for _, taskSystem := range []string{"jira", "linear"} {
395
395
  out, err := wf.RenderNudge("coding", workflow.NudgeTemplateData{
396
- TaskSystem: taskSystem, Ticket: "PAY-101", Workflow: "basicFlow", Repo: "payments", Node: "coding", NextSteps: "end",
396
+ TaskSystem: taskSystem, Ticket: "PAY-101", Workflow: "basicFlow", Repo: "payments", Node: "coding", Mailbox: "PAY-101.1", NextSteps: "end",
397
397
  })
398
398
  if err != nil {
399
399
  t.Fatalf("RenderNudge(%s) failed: %v", taskSystem, err)
400
400
  }
401
- want := "task=" + taskSystem + " ticket=PAY-101 wf=basicFlow repo=payments node=coding steps=end"
401
+ want := "task=" + taskSystem + " ticket=PAY-101 wf=basicFlow repo=payments node=coding mailbox=PAY-101.1 steps=end"
402
402
  if out != want {
403
403
  t.Fatalf("RenderNudge(%s) = %q, want %q", taskSystem, out, want)
404
404
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-flow",
3
- "version": "0.2.8-alpha",
3
+ "version": "0.2.9-alpha",
4
4
  "description": "Graph-based agent workflow engine — tracker-agnostic, pluggable runners",
5
5
  "bin": {
6
6
  "relay-flow": "./bin/relay-flow.js",