relay-flow 0.2.3-alpha → 0.2.4-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 (55) hide show
  1. package/README.md +116 -10
  2. package/cmd/relay-flow/main.go +2 -0
  3. package/cmd/relay-flow/pi_wiring_test.go +64 -0
  4. package/cmd/relay-flow/serve.go +2 -0
  5. package/examples/beads-workflow.yaml +3 -3
  6. package/examples/config-reference.yaml +144 -0
  7. package/examples/minimal-beads-task-workflow.yaml +34 -0
  8. package/examples/minimal-jira-task-workflow.yaml +68 -0
  9. package/examples/workflow-reference.yaml +111 -0
  10. package/internal/harness/opencode/opencode_test.go +1 -1
  11. package/internal/harness/opencode/repo_setup.go +1 -1
  12. package/internal/harness/pi/config_test.go +46 -0
  13. package/internal/harness/pi/lifecycle_test.go +69 -0
  14. package/internal/harness/pi/pi.go +261 -0
  15. package/internal/harness/pi/pi_test.go +322 -0
  16. package/internal/harness/pi/prompt_test.go +121 -0
  17. package/internal/harness/pi/testdata/pi-0.84.1/capture.json +126 -0
  18. package/internal/harness/pi/testdata/pi-0.84.1/noninteractive-output.txt +11 -0
  19. package/internal/harness/pi/testdata/pi-0.84.1/tui-output-sanitized.txt +17 -0
  20. package/internal/harness/pi/validation_test.go +168 -0
  21. package/internal/runner/herdr/baseref.go +60 -0
  22. package/internal/runner/herdr/herdr.go +578 -0
  23. package/internal/runner/herdr/herdr_test.go +688 -0
  24. package/internal/runner/herdr/herdrcli/contract.go +128 -0
  25. package/internal/runner/herdr/herdrcli/exec.go +68 -0
  26. package/internal/runner/herdr/herdrcli/herdrcli_test.go +274 -0
  27. package/internal/runner/herdr/herdrcli/live_test.go +132 -0
  28. package/internal/runner/herdr/herdrcli/operations.go +281 -0
  29. package/internal/runner/herdr/herdrcli/response.go +116 -0
  30. package/internal/runner/herdr/herdrcli/testdata/empty-panes.json +1 -0
  31. package/internal/runner/herdr/herdrcli/testdata/empty-tabs.json +1 -0
  32. package/internal/runner/herdr/herdrcli/testdata/error-not-git-worktree.json +1 -0
  33. package/internal/runner/herdr/herdrcli/testdata/error-pane-not-found.json +1 -0
  34. package/internal/runner/herdr/herdrcli/testdata/error-workspace-not-found.json +1 -0
  35. package/internal/runner/herdr/herdrcli/testdata/error-worktree-not-found.json +1 -0
  36. package/internal/runner/herdr/herdrcli/testdata/malformed.json +1 -0
  37. package/internal/runner/herdr/herdrcli/testdata/pane-close.json +6 -0
  38. package/internal/runner/herdr/herdrcli/testdata/pane-get.json +25 -0
  39. package/internal/runner/herdr/herdrcli/testdata/pane-list.json +45 -0
  40. package/internal/runner/herdr/herdrcli/testdata/pane-process-info-shell.json +22 -0
  41. package/internal/runner/herdr/herdrcli/testdata/pane-process-info.json +23 -0
  42. package/internal/runner/herdr/herdrcli/testdata/pane-rename.json +23 -0
  43. package/internal/runner/herdr/herdrcli/testdata/snapshot.json +213 -0
  44. package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +175 -0
  45. package/internal/runner/herdr/herdrcli/testdata/tab-create.json +31 -0
  46. package/internal/runner/herdr/herdrcli/testdata/tab-list.json +26 -0
  47. package/internal/runner/herdr/herdrcli/testdata/workspace-close.json +6 -0
  48. package/internal/runner/herdr/herdrcli/testdata/worktree-create.json +58 -0
  49. package/internal/runner/herdr/herdrcli/testdata/worktree-list.json +35 -0
  50. package/internal/runner/herdr/herdrcli/testdata/worktree-open.json +59 -0
  51. package/internal/task/beads/beads.go +0 -16
  52. package/internal/task/beads/config_compatibility_test.go +7 -8
  53. package/internal/task/jira/filters_test.go +32 -6
  54. package/internal/task/jira/jira.go +27 -17
  55. package/package.json +1 -1
@@ -0,0 +1,128 @@
1
+ // Package herdrcli defines the narrow internal boundary between the Herdr
2
+ // runner adapter and the public Herdr CLI.
3
+ package herdrcli
4
+
5
+ import (
6
+ "context"
7
+ "errors"
8
+ )
9
+
10
+ // Herdr reports failures as JSON error envelopes on stderr with a stable
11
+ // code. These sentinels expose the codes the adapter must react to; every
12
+ // other failure is returned unchanged so it can be retried.
13
+ var (
14
+ ErrPaneNotFound = errors.New("pane not found")
15
+ ErrWorkspaceNotFound = errors.New("workspace not found")
16
+ ErrWorktreeNotFound = errors.New("worktree not found")
17
+ ErrNotGitWorktree = errors.New("path is not inside a Git work tree")
18
+ )
19
+
20
+ // Client is the adapter-facing Herdr CLI contract. The production adapter
21
+ // receives a *CLI; tests may provide a client implementing this interface.
22
+ type Client interface {
23
+ Snapshot(ctx context.Context) (Snapshot, error)
24
+ WorktreeList(ctx context.Context, repoPath string) (WorktreeListing, error)
25
+ WorktreeCreate(ctx context.Context, repoPath, branch, base, label string) (Workspace, error)
26
+ WorktreeOpen(ctx context.Context, repoPath, branch, label string) (Workspace, error)
27
+ CreateTab(ctx context.Context, workspaceID, cwd, label string) (Tab, Pane, error)
28
+ ListTabs(ctx context.Context, workspaceID string) ([]Tab, error)
29
+ ListPanes(ctx context.Context, workspaceID string) ([]Pane, error)
30
+ GetPane(ctx context.Context, paneID string) (Pane, error)
31
+ ProcessInfo(ctx context.Context, paneID string) (ProcessInfo, error)
32
+ RenamePane(ctx context.Context, paneID, label string) error
33
+ RunPane(ctx context.Context, paneID, command string) error
34
+ ClosePane(ctx context.Context, paneID string) error
35
+ CloseWorkspace(ctx context.Context, workspaceID string) error
36
+ }
37
+
38
+ // Options selects the Herdr session or explicit socket used by the CLI.
39
+ type Options struct {
40
+ Session string
41
+ SocketPath string
42
+ }
43
+
44
+ // CLI is the production client backed by the herdr executable.
45
+ type CLI struct {
46
+ options Options
47
+ }
48
+
49
+ // New constructs a production Herdr CLI client.
50
+ func New(options Options) *CLI {
51
+ return &CLI{options: options}
52
+ }
53
+
54
+ // WorkspaceWorktree is the Git identity Herdr reports for a workspace. The
55
+ // source checkout of a repository has IsLinked false; every ticket worktree
56
+ // workspace has IsLinked true and the same RepoRoot.
57
+ type WorkspaceWorktree struct {
58
+ CheckoutPath string
59
+ RepoName string
60
+ RepoRoot string
61
+ IsLinked bool
62
+ }
63
+
64
+ // Workspace is the subset of Herdr workspace output used by relay-flow.
65
+ type Workspace struct {
66
+ ID string
67
+ Label string
68
+ Worktree WorkspaceWorktree
69
+ }
70
+
71
+ // Worktree is one checkout reported by worktree list. OpenWorkspaceID is
72
+ // empty when the checkout exists but is not open as a workspace.
73
+ type Worktree struct {
74
+ Path string
75
+ Branch string
76
+ IsLinked bool
77
+ OpenWorkspaceID string
78
+ }
79
+
80
+ // WorktreeSource identifies the repository a worktree listing belongs to.
81
+ type WorktreeSource struct {
82
+ RepoName string
83
+ RepoRoot string
84
+ SourceCheckoutPath string
85
+ }
86
+
87
+ // WorktreeListing is the response of worktree list for one repository.
88
+ type WorktreeListing struct {
89
+ Source WorktreeSource
90
+ Worktrees []Worktree
91
+ }
92
+
93
+ // Tab is the subset of Herdr tab output used by relay-flow.
94
+ type Tab struct {
95
+ ID string
96
+ WorkspaceID string
97
+ Label string
98
+ }
99
+
100
+ // Pane is the subset of Herdr pane output used by relay-flow.
101
+ type Pane struct {
102
+ ID string
103
+ TerminalID string
104
+ WorkspaceID string
105
+ TabID string
106
+ Label string
107
+ CWD string
108
+ }
109
+
110
+ // Snapshot contains the workspace, tab, and pane records returned by Herdr.
111
+ type Snapshot struct {
112
+ Workspaces []Workspace
113
+ Tabs []Tab
114
+ Panes []Pane
115
+ }
116
+
117
+ // ProcessInfo contains the process records returned for a pane.
118
+ type ProcessInfo struct {
119
+ PaneID string
120
+ ShellPID *uint32
121
+ ForegroundProcesses []ForegroundProcess
122
+ }
123
+
124
+ // ForegroundProcess is the observed process shape returned by Herdr.
125
+ type ForegroundProcess struct {
126
+ PID uint32
127
+ Name string
128
+ }
@@ -0,0 +1,68 @@
1
+ package herdrcli
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "fmt"
7
+ "os"
8
+ "os/exec"
9
+ "path/filepath"
10
+ "strings"
11
+ )
12
+
13
+ // execute invokes the installed Herdr executable and keeps its two output
14
+ // streams separate. Command-specific response parsing and error handling are
15
+ // deliberately left to the callers that implement the public operations.
16
+ func (c *CLI) execute(ctx context.Context, args ...string) (stdout, stderr []byte, err error) {
17
+ if err := validateAbsoluteCWD(args); err != nil {
18
+ return nil, nil, err
19
+ }
20
+
21
+ cmd := exec.CommandContext(ctx, "herdr", args...)
22
+ cmd.Env = c.environment(os.Environ())
23
+
24
+ var out bytes.Buffer
25
+ var errOut bytes.Buffer
26
+ cmd.Stdout = &out
27
+ cmd.Stderr = &errOut
28
+ err = cmd.Run()
29
+ return out.Bytes(), errOut.Bytes(), err
30
+ }
31
+
32
+ func (c *CLI) environment(base []string) []string {
33
+ env := append([]string(nil), base...)
34
+ if c.options.Session != "" {
35
+ env = setEnvironmentValue(env, "HERDR_SESSION", c.options.Session)
36
+ }
37
+ if c.options.SocketPath != "" {
38
+ env = setEnvironmentValue(env, "HERDR_SOCKET_PATH", c.options.SocketPath)
39
+ }
40
+ return env
41
+ }
42
+
43
+ func setEnvironmentValue(env []string, key, value string) []string {
44
+ prefix := key + "="
45
+ for i, entry := range env {
46
+ if strings.HasPrefix(entry, prefix) {
47
+ env[i] = prefix + value
48
+ return env
49
+ }
50
+ }
51
+ return append(env, prefix+value)
52
+ }
53
+
54
+ func validateAbsoluteCWD(args []string) error {
55
+ for i, arg := range args {
56
+ if arg != "--cwd" {
57
+ continue
58
+ }
59
+ if i+1 >= len(args) {
60
+ return fmt.Errorf("herdr --cwd requires a path")
61
+ }
62
+ cwd := args[i+1]
63
+ if !filepath.IsAbs(cwd) {
64
+ return fmt.Errorf("herdr --cwd must be absolute: %q", cwd)
65
+ }
66
+ }
67
+ return nil
68
+ }
@@ -0,0 +1,274 @@
1
+ package herdrcli
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "os"
7
+ "os/exec"
8
+ "path/filepath"
9
+ "strings"
10
+ "testing"
11
+ )
12
+
13
+ func TestCLIUsesExactProductionCommandShapes(t *testing.T) {
14
+ installStrictFakeHerdr(t)
15
+ cli := newTestCLI()
16
+ ctx := context.Background()
17
+
18
+ if _, err := cli.Snapshot(ctx); err != nil {
19
+ t.Fatalf("Snapshot: %v", err)
20
+ }
21
+ if _, err := cli.WorktreeList(ctx, "/work/payments"); err != nil {
22
+ t.Fatalf("WorktreeList: %v", err)
23
+ }
24
+ if _, err := cli.WorktreeOpen(ctx, "/work/payments", "PAY-101", "PAY-101"); err != nil {
25
+ t.Fatalf("WorktreeOpen: %v", err)
26
+ }
27
+ if _, err := cli.WorktreeCreate(ctx, "/work/payments", "PAY-101", "origin/main", "PAY-101"); err != nil {
28
+ t.Fatalf("WorktreeCreate: %v", err)
29
+ }
30
+ if _, _, err := cli.CreateTab(ctx, "w2", "/work/worktrees/payments/pay-101", "PAY-101:coding"); err != nil {
31
+ t.Fatalf("CreateTab: %v", err)
32
+ }
33
+ if _, err := cli.ListTabs(ctx, "w2"); err != nil {
34
+ t.Fatalf("ListTabs: %v", err)
35
+ }
36
+ if _, err := cli.ListPanes(ctx, "w2"); err != nil {
37
+ t.Fatalf("ListPanes: %v", err)
38
+ }
39
+ if _, err := cli.GetPane(ctx, "w2:p2"); err != nil {
40
+ t.Fatalf("GetPane: %v", err)
41
+ }
42
+ if _, err := cli.ProcessInfo(ctx, "w2:p2"); err != nil {
43
+ t.Fatalf("ProcessInfo: %v", err)
44
+ }
45
+ if err := cli.RenamePane(ctx, "w2:p2", "PAY-101:coding"); err != nil {
46
+ t.Fatalf("RenamePane: %v", err)
47
+ }
48
+ if err := cli.RunPane(ctx, "w2:p2", "RELAY_FLOW_NODE='coding' 'opencode' '--prompt' 'line one\nline two'"); err != nil {
49
+ t.Fatalf("RunPane: %v", err)
50
+ }
51
+ if err := cli.ClosePane(ctx, "w2:p2"); err != nil {
52
+ t.Fatalf("ClosePane: %v", err)
53
+ }
54
+ if err := cli.CloseWorkspace(ctx, "w2"); err != nil {
55
+ t.Fatalf("CloseWorkspace: %v", err)
56
+ }
57
+ }
58
+
59
+ func TestCLIDecodesCapturedResponseLocations(t *testing.T) {
60
+ installStrictFakeHerdr(t)
61
+ cli := newTestCLI()
62
+ ctx := context.Background()
63
+
64
+ snapshot, err := cli.Snapshot(ctx)
65
+ if err != nil {
66
+ t.Fatalf("Snapshot: %v", err)
67
+ }
68
+ if len(snapshot.Workspaces) != 2 {
69
+ t.Fatalf("Snapshot workspaces = %+v", snapshot.Workspaces)
70
+ }
71
+ source := snapshot.Workspaces[0]
72
+ if source.ID != "w1" || source.Worktree.RepoRoot != "/work/payments" || source.Worktree.IsLinked {
73
+ t.Fatalf("source workspace = %+v", source)
74
+ }
75
+ ticket := snapshot.Workspaces[1]
76
+ if ticket.ID != "w2" || ticket.Label != "PAY-101" || !ticket.Worktree.IsLinked ||
77
+ ticket.Worktree.CheckoutPath != "/work/worktrees/payments/pay-101" ||
78
+ ticket.Worktree.RepoRoot != "/work/payments" {
79
+ t.Fatalf("ticket workspace = %+v", ticket)
80
+ }
81
+ if len(snapshot.Tabs) != 3 || snapshot.Tabs[2] != (Tab{ID: "w2:t2", WorkspaceID: "w2", Label: "PAY-101:coding"}) {
82
+ t.Fatalf("Snapshot tabs = %+v", snapshot.Tabs)
83
+ }
84
+ if len(snapshot.Panes) != 3 || snapshot.Panes[2].ID != "w2:p2" || snapshot.Panes[2].TerminalID == "" {
85
+ t.Fatalf("Snapshot panes = %+v", snapshot.Panes)
86
+ }
87
+
88
+ listing, err := cli.WorktreeList(ctx, "/work/payments")
89
+ if err != nil {
90
+ t.Fatalf("WorktreeList: %v", err)
91
+ }
92
+ if listing.Source.RepoRoot != "/work/payments" || listing.Source.RepoName != "repo" {
93
+ t.Fatalf("WorktreeList source = %+v", listing.Source)
94
+ }
95
+ if len(listing.Worktrees) != 2 {
96
+ t.Fatalf("WorktreeList worktrees = %+v", listing.Worktrees)
97
+ }
98
+ if listing.Worktrees[1] != (Worktree{
99
+ Path: "/work/worktrees/payments/pay-101",
100
+ Branch: "PAY-101",
101
+ IsLinked: true,
102
+ OpenWorkspaceID: "w2",
103
+ }) {
104
+ t.Fatalf("ticket worktree = %+v", listing.Worktrees[1])
105
+ }
106
+
107
+ created, err := cli.WorktreeCreate(ctx, "/work/payments", "PAY-101", "origin/main", "PAY-101")
108
+ if err != nil {
109
+ t.Fatalf("WorktreeCreate: %v", err)
110
+ }
111
+ if created.ID != "w2" || created.Worktree.CheckoutPath != "/work/worktrees/payments/pay-101" {
112
+ t.Fatalf("WorktreeCreate workspace = %+v", created)
113
+ }
114
+ opened, err := cli.WorktreeOpen(ctx, "/work/payments", "PAY-101", "PAY-101")
115
+ if err != nil {
116
+ t.Fatalf("WorktreeOpen: %v", err)
117
+ }
118
+ if opened.ID != "w2" || opened.Worktree.CheckoutPath != "/work/worktrees/payments/pay-101" {
119
+ t.Fatalf("WorktreeOpen workspace = %+v", opened)
120
+ }
121
+
122
+ tab, rootPane, err := cli.CreateTab(ctx, "w2", "/work/worktrees/payments/pay-101", "PAY-101:coding")
123
+ if err != nil {
124
+ t.Fatalf("CreateTab: %v", err)
125
+ }
126
+ if tab != (Tab{ID: "w2:t2", WorkspaceID: "w2", Label: "PAY-101:coding"}) {
127
+ t.Fatalf("CreateTab tab = %+v", tab)
128
+ }
129
+ if rootPane.ID != "w2:p2" || rootPane.TabID != "w2:t2" || rootPane.CWD != "/work/worktrees/payments/pay-101" {
130
+ t.Fatalf("CreateTab root pane = %+v", rootPane)
131
+ }
132
+
133
+ tabs, err := cli.ListTabs(ctx, "w2")
134
+ if err != nil || len(tabs) != 2 || tabs[1].Label != "PAY-101:coding" {
135
+ t.Fatalf("ListTabs = %+v, %v", tabs, err)
136
+ }
137
+ panes, err := cli.ListPanes(ctx, "w2")
138
+ if err != nil || len(panes) != 2 || panes[1].Label != "PAY-101:coding" {
139
+ t.Fatalf("ListPanes = %+v, %v", panes, err)
140
+ }
141
+ pane, err := cli.GetPane(ctx, "w2:p2")
142
+ if err != nil || pane.ID != "w2:p2" || pane.Label != "PAY-101:coding" {
143
+ t.Fatalf("GetPane = %+v, %v", pane, err)
144
+ }
145
+
146
+ info, err := cli.ProcessInfo(ctx, "w2:p2")
147
+ if err != nil || info.PaneID != "w2:p2" || info.ShellPID == nil || len(info.ForegroundProcesses) != 1 {
148
+ t.Fatalf("ProcessInfo = %+v, %v", info, err)
149
+ }
150
+ if info.ForegroundProcesses[0].PID == *info.ShellPID {
151
+ t.Fatalf("running command fixture must not report the shell as foreground: %+v", info)
152
+ }
153
+ shellInfo, err := cli.ProcessInfo(ctx, "shell")
154
+ if err != nil || shellInfo.ShellPID == nil || len(shellInfo.ForegroundProcesses) != 1 {
155
+ t.Fatalf("ProcessInfo(shell) = %+v, %v", shellInfo, err)
156
+ }
157
+ if shellInfo.ForegroundProcesses[0].PID != *shellInfo.ShellPID {
158
+ t.Fatalf("restored shell fixture must report the shell as foreground: %+v", shellInfo)
159
+ }
160
+ }
161
+
162
+ func TestCLIHandlesEmptyResults(t *testing.T) {
163
+ installStrictFakeHerdr(t)
164
+ cli := newTestCLI()
165
+ ctx := context.Background()
166
+
167
+ tabs, err := cli.ListTabs(ctx, "empty")
168
+ if err != nil || tabs == nil || len(tabs) != 0 {
169
+ t.Fatalf("ListTabs(empty) = %+v, %v", tabs, err)
170
+ }
171
+ panes, err := cli.ListPanes(ctx, "empty")
172
+ if err != nil || panes == nil || len(panes) != 0 {
173
+ t.Fatalf("ListPanes(empty) = %+v, %v", panes, err)
174
+ }
175
+ }
176
+
177
+ func TestCLIMapsHerdrErrorCodesToSentinels(t *testing.T) {
178
+ installStrictFakeHerdr(t)
179
+ cli := newTestCLI()
180
+ ctx := context.Background()
181
+
182
+ if _, err := cli.GetPane(ctx, "missing"); !errors.Is(err, ErrPaneNotFound) {
183
+ t.Fatalf("GetPane(missing) = %v, want ErrPaneNotFound", err)
184
+ }
185
+ if _, err := cli.ProcessInfo(ctx, "missing"); !errors.Is(err, ErrPaneNotFound) {
186
+ t.Fatalf("ProcessInfo(missing) = %v, want ErrPaneNotFound", err)
187
+ }
188
+ if err := cli.ClosePane(ctx, "missing"); !errors.Is(err, ErrPaneNotFound) {
189
+ t.Fatalf("ClosePane(missing) = %v, want ErrPaneNotFound", err)
190
+ }
191
+ if _, err := cli.ListTabs(ctx, "missing"); !errors.Is(err, ErrWorkspaceNotFound) {
192
+ t.Fatalf("ListTabs(missing) = %v, want ErrWorkspaceNotFound", err)
193
+ }
194
+ if _, err := cli.ListPanes(ctx, "missing"); !errors.Is(err, ErrWorkspaceNotFound) {
195
+ t.Fatalf("ListPanes(missing) = %v, want ErrWorkspaceNotFound", err)
196
+ }
197
+ if err := cli.CloseWorkspace(ctx, "missing"); !errors.Is(err, ErrWorkspaceNotFound) {
198
+ t.Fatalf("CloseWorkspace(missing) = %v, want ErrWorkspaceNotFound", err)
199
+ }
200
+ if _, err := cli.WorktreeOpen(ctx, "/work/payments", "MISSING-1", "MISSING-1"); !errors.Is(err, ErrWorktreeNotFound) {
201
+ t.Fatalf("WorktreeOpen(missing) = %v, want ErrWorktreeNotFound", err)
202
+ }
203
+ if _, err := cli.WorktreeList(ctx, "/work/not-a-repo"); !errors.Is(err, ErrNotGitWorktree) {
204
+ t.Fatalf("WorktreeList(non-repo) = %v, want ErrNotGitWorktree", err)
205
+ }
206
+ if _, err := cli.GetPane(ctx, "missing"); !strings.Contains(err.Error(), "pane w9:p9 not found") {
207
+ t.Fatalf("error message = %v, want Herdr's own message", err)
208
+ }
209
+ }
210
+
211
+ func TestCLIReportsMalformedOutputAndToleratesStderrWarnings(t *testing.T) {
212
+ installStrictFakeHerdr(t)
213
+ cli := newTestCLI()
214
+ ctx := context.Background()
215
+
216
+ if _, err := cli.GetPane(ctx, "malformed"); err == nil {
217
+ t.Fatal("GetPane accepted malformed JSON")
218
+ }
219
+ if pane, err := cli.GetPane(ctx, "warning"); err != nil || pane.ID != "w2:p2" {
220
+ t.Fatalf("GetPane(warning) = %+v, %v", pane, err)
221
+ }
222
+ }
223
+
224
+ func TestCLIRejectsRelativeCWD(t *testing.T) {
225
+ installStrictFakeHerdr(t)
226
+ cli := newTestCLI()
227
+ if _, err := cli.WorktreeList(context.Background(), "relative/path"); err == nil {
228
+ t.Fatal("WorktreeList accepted a relative --cwd")
229
+ }
230
+ }
231
+
232
+ func TestStrictFakeHerdrRejectsUnsupportedProductionShapes(t *testing.T) {
233
+ fake := installStrictFakeHerdr(t)
234
+ unsupported := [][]string{
235
+ {"workspace", "create", "--cwd", "/work/payments", "--label", "payments", "--no-focus"},
236
+ {"worktree", "remove", "--workspace", "w2"},
237
+ {"terminal", "create", "--pane", "w2:p2"},
238
+ {"pane", "get", "--pane", "w2:p2"},
239
+ {"pane", "process-info", "w2:p2"},
240
+ {"tab", "list", "w2"},
241
+ {"tab", "create", "--workspace", "w2", "--cwd", "/work/payments", "--label", "x", "--no-focus", "--env", "K=V"},
242
+ }
243
+ for _, args := range unsupported {
244
+ if err := exec.Command(fake, args...).Run(); err == nil {
245
+ t.Errorf("strict fake accepted unsupported invocation %v", args)
246
+ }
247
+ }
248
+ }
249
+
250
+ func newTestCLI() *CLI {
251
+ return New(Options{Session: "relay-flow", SocketPath: "/tmp/relay-flow-herdr.sock"})
252
+ }
253
+
254
+ func installStrictFakeHerdr(t *testing.T) string {
255
+ t.Helper()
256
+ binDir := t.TempDir()
257
+ fake := filepath.Join(binDir, "herdr")
258
+ script, err := os.ReadFile(filepath.Join("testdata", "strict-herdr.sh"))
259
+ if err != nil {
260
+ t.Fatal(err)
261
+ }
262
+ if err := os.WriteFile(fake, script, 0o755); err != nil {
263
+ t.Fatal(err)
264
+ }
265
+ fixtureDir, err := filepath.Abs("testdata")
266
+ if err != nil {
267
+ t.Fatal(err)
268
+ }
269
+ t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
270
+ t.Setenv("HERDR_FIXTURE_DIR", fixtureDir)
271
+ t.Setenv("HERDR_EXPECT_SESSION", "relay-flow")
272
+ t.Setenv("HERDR_EXPECT_SOCKET_PATH", "/tmp/relay-flow-herdr.sock")
273
+ return fake
274
+ }
@@ -0,0 +1,132 @@
1
+ package herdrcli
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "os"
7
+ "os/exec"
8
+ "path/filepath"
9
+ "testing"
10
+ )
11
+
12
+ // TestLiveInstalledHerdr drives the production wrapper against the installed
13
+ // Herdr binary. It is skipped unless RELAY_FLOW_HERDR_LIVE=1 so default CI,
14
+ // which has no Herdr, stays green; the fixtures in this package are captured
15
+ // from exactly this flow. It never touches the default Herdr session.
16
+ //
17
+ // RELAY_FLOW_HERDR_LIVE=1 go test ./internal/runner/herdr/herdrcli/ -run Live -v
18
+ func TestLiveInstalledHerdr(t *testing.T) {
19
+ if os.Getenv("RELAY_FLOW_HERDR_LIVE") != "1" {
20
+ t.Skip("set RELAY_FLOW_HERDR_LIVE=1 to run against the installed Herdr binary")
21
+ }
22
+ if _, err := exec.LookPath("herdr"); err != nil {
23
+ t.Fatalf("herdr executable not found: %v", err)
24
+ }
25
+
26
+ ctx := context.Background()
27
+ session := "relay-flow-live-" + filepath.Base(t.TempDir())
28
+ repo := t.TempDir()
29
+ runGit(t, repo, "init", "-q", "-b", "main", ".")
30
+ runGit(t, repo, "config", "user.email", "live@relay-flow.test")
31
+ runGit(t, repo, "config", "user.name", "live")
32
+ if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("live\n"), 0o644); err != nil {
33
+ t.Fatal(err)
34
+ }
35
+ runGit(t, repo, "add", "-A")
36
+ runGit(t, repo, "commit", "-qm", "init")
37
+
38
+ server := exec.Command("herdr", "--session", session, "server")
39
+ if err := server.Start(); err != nil {
40
+ t.Fatalf("start herdr server: %v", err)
41
+ }
42
+ cli := New(Options{Session: session})
43
+ t.Cleanup(func() {
44
+ _ = exec.Command("herdr", "--session", session, "server", "stop").Run()
45
+ _ = server.Wait()
46
+ _ = exec.Command("herdr", "session", "delete", session, "--json").Run()
47
+ })
48
+
49
+ var ready bool
50
+ for i := 0; i < 200; i++ {
51
+ if _, err := cli.Snapshot(ctx); err == nil {
52
+ ready = true
53
+ break
54
+ }
55
+ }
56
+ if !ready {
57
+ t.Fatal("herdr session never became ready through the production wrapper")
58
+ }
59
+
60
+ // Missing branch must be reported as ErrWorktreeNotFound so the adapter
61
+ // can create instead of failing the run.
62
+ if _, err := cli.WorktreeOpen(ctx, repo, "LIVE-1", "LIVE-1"); !errors.Is(err, ErrWorktreeNotFound) {
63
+ t.Fatalf("WorktreeOpen(missing) = %v, want ErrWorktreeNotFound", err)
64
+ }
65
+ workspace, err := cli.WorktreeCreate(ctx, repo, "LIVE-1", "main", "LIVE-1")
66
+ if err != nil {
67
+ t.Fatalf("WorktreeCreate: %v", err)
68
+ }
69
+ t.Cleanup(func() {
70
+ _ = os.RemoveAll(workspace.Worktree.CheckoutPath)
71
+ // Herdr groups checkouts under a per-repository directory; remove it
72
+ // too when this test's checkout was the only one in it.
73
+ _ = os.Remove(filepath.Dir(workspace.Worktree.CheckoutPath))
74
+ })
75
+ if workspace.ID == "" || workspace.Worktree.CheckoutPath == "" || workspace.Worktree.RepoRoot == "" {
76
+ t.Fatalf("WorktreeCreate workspace = %+v", workspace)
77
+ }
78
+ if reopened, err := cli.WorktreeOpen(ctx, repo, "LIVE-1", "LIVE-1"); err != nil || reopened.ID != workspace.ID {
79
+ t.Fatalf("WorktreeOpen = %+v, %v", reopened, err)
80
+ }
81
+
82
+ listing, err := cli.WorktreeList(ctx, repo)
83
+ if err != nil {
84
+ t.Fatalf("WorktreeList: %v", err)
85
+ }
86
+ if listing.Source.RepoRoot == "" || len(listing.Worktrees) < 2 {
87
+ t.Fatalf("WorktreeList = %+v", listing)
88
+ }
89
+
90
+ _, pane, err := cli.CreateTab(ctx, workspace.ID, workspace.Worktree.CheckoutPath, "LIVE-1:coding")
91
+ if err != nil {
92
+ t.Fatalf("CreateTab: %v", err)
93
+ }
94
+ if err := cli.RenamePane(ctx, pane.ID, "LIVE-1:coding"); err != nil {
95
+ t.Fatalf("RenamePane: %v", err)
96
+ }
97
+ // A real multiline, env-carrying command line, exactly as the adapter
98
+ // renders it.
99
+ if err := cli.RunPane(ctx, pane.ID, "RELAY_FLOW_NODE='coding' sleep 30"); err != nil {
100
+ t.Fatalf("RunPane: %v", err)
101
+ }
102
+ if got, err := cli.GetPane(ctx, pane.ID); err != nil || got.Label != "LIVE-1:coding" {
103
+ t.Fatalf("GetPane = %+v, %v", got, err)
104
+ }
105
+ if _, err := cli.ProcessInfo(ctx, pane.ID); err != nil {
106
+ t.Fatalf("ProcessInfo: %v", err)
107
+ }
108
+ if _, err := cli.GetPane(ctx, "w99:p99"); !errors.Is(err, ErrPaneNotFound) {
109
+ t.Fatalf("GetPane(missing) = %v, want ErrPaneNotFound", err)
110
+ }
111
+ if _, err := cli.ListTabs(ctx, "w99"); !errors.Is(err, ErrWorkspaceNotFound) {
112
+ t.Fatalf("ListTabs(missing) = %v, want ErrWorkspaceNotFound", err)
113
+ }
114
+ if _, err := cli.WorktreeList(ctx, t.TempDir()); !errors.Is(err, ErrNotGitWorktree) {
115
+ t.Fatalf("WorktreeList(non-repo) = %v, want ErrNotGitWorktree", err)
116
+ }
117
+ if err := cli.ClosePane(ctx, pane.ID); err != nil {
118
+ t.Fatalf("ClosePane: %v", err)
119
+ }
120
+ if err := cli.CloseWorkspace(ctx, workspace.ID); err != nil {
121
+ t.Fatalf("CloseWorkspace: %v", err)
122
+ }
123
+ }
124
+
125
+ func runGit(t *testing.T, dir string, args ...string) {
126
+ t.Helper()
127
+ cmd := exec.Command("git", args...)
128
+ cmd.Dir = dir
129
+ if out, err := cmd.CombinedOutput(); err != nil {
130
+ t.Fatalf("git %v: %v: %s", args, err, out)
131
+ }
132
+ }