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.
- package/README.md +116 -10
- package/cmd/relay-flow/main.go +2 -0
- package/cmd/relay-flow/pi_wiring_test.go +64 -0
- package/cmd/relay-flow/serve.go +2 -0
- package/examples/beads-workflow.yaml +3 -3
- package/examples/config-reference.yaml +144 -0
- package/examples/minimal-beads-task-workflow.yaml +34 -0
- package/examples/minimal-jira-task-workflow.yaml +68 -0
- package/examples/workflow-reference.yaml +111 -0
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/pi/config_test.go +46 -0
- package/internal/harness/pi/lifecycle_test.go +69 -0
- package/internal/harness/pi/pi.go +261 -0
- package/internal/harness/pi/pi_test.go +322 -0
- package/internal/harness/pi/prompt_test.go +121 -0
- package/internal/harness/pi/testdata/pi-0.84.1/capture.json +126 -0
- package/internal/harness/pi/testdata/pi-0.84.1/noninteractive-output.txt +11 -0
- package/internal/harness/pi/testdata/pi-0.84.1/tui-output-sanitized.txt +17 -0
- package/internal/harness/pi/validation_test.go +168 -0
- package/internal/runner/herdr/baseref.go +60 -0
- package/internal/runner/herdr/herdr.go +578 -0
- package/internal/runner/herdr/herdr_test.go +688 -0
- package/internal/runner/herdr/herdrcli/contract.go +128 -0
- package/internal/runner/herdr/herdrcli/exec.go +68 -0
- package/internal/runner/herdr/herdrcli/herdrcli_test.go +274 -0
- package/internal/runner/herdr/herdrcli/live_test.go +132 -0
- package/internal/runner/herdr/herdrcli/operations.go +281 -0
- package/internal/runner/herdr/herdrcli/response.go +116 -0
- package/internal/runner/herdr/herdrcli/testdata/empty-panes.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/empty-tabs.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/error-not-git-worktree.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/error-pane-not-found.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/error-workspace-not-found.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/error-worktree-not-found.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/malformed.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-close.json +6 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-get.json +25 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-list.json +45 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-process-info-shell.json +22 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-process-info.json +23 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-rename.json +23 -0
- package/internal/runner/herdr/herdrcli/testdata/snapshot.json +213 -0
- package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +175 -0
- package/internal/runner/herdr/herdrcli/testdata/tab-create.json +31 -0
- package/internal/runner/herdr/herdrcli/testdata/tab-list.json +26 -0
- package/internal/runner/herdr/herdrcli/testdata/workspace-close.json +6 -0
- package/internal/runner/herdr/herdrcli/testdata/worktree-create.json +58 -0
- package/internal/runner/herdr/herdrcli/testdata/worktree-list.json +35 -0
- package/internal/runner/herdr/herdrcli/testdata/worktree-open.json +59 -0
- package/internal/task/beads/beads.go +0 -16
- package/internal/task/beads/config_compatibility_test.go +7 -8
- package/internal/task/jira/filters_test.go +32 -6
- package/internal/task/jira/jira.go +27 -17
- 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
|
+
}
|