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,688 @@
1
+ package herdr
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "os"
7
+ "os/exec"
8
+ "path/filepath"
9
+ "strings"
10
+ "sync"
11
+ "testing"
12
+
13
+ "github.com/rajpopat27/relay-flow/internal/config"
14
+ "github.com/rajpopat27/relay-flow/internal/runner"
15
+ "github.com/rajpopat27/relay-flow/internal/runner/herdr/herdrcli"
16
+ )
17
+
18
+ // fakeClient is the typed CLI seam used for adapter decision tests. Command
19
+ // shapes themselves are covered by the strict executable tests in herdrcli.
20
+ type fakeClient struct {
21
+ mu sync.Mutex
22
+
23
+ snapshot herdrcli.Snapshot
24
+ snapshotErr error
25
+
26
+ listing herdrcli.WorktreeListing
27
+ listingErr error
28
+
29
+ openWorkspace herdrcli.Workspace
30
+ openErr error
31
+
32
+ createWorkspace herdrcli.Workspace
33
+ createErr error
34
+
35
+ tabs []herdrcli.Tab
36
+ tabsErr error
37
+
38
+ panes []herdrcli.Pane
39
+ panesErr error
40
+
41
+ pane herdrcli.Pane
42
+ paneErr error
43
+
44
+ info herdrcli.ProcessInfo
45
+ infoErr error
46
+
47
+ createdTab herdrcli.Tab
48
+ createdPane herdrcli.Pane
49
+ createTabErr error
50
+
51
+ renameErr error
52
+ runErr error
53
+ closePaneErr error
54
+ closeWorkspaceErr error
55
+
56
+ openCalls []worktreeCall
57
+ createCalls []worktreeCall
58
+ tabCreateCalls []tabCreateCall
59
+ renameCalls []renameCall
60
+ runCalls []runCall
61
+ closedPanes []string
62
+ closedWorkspaces []string
63
+ }
64
+
65
+ type worktreeCall struct{ RepoPath, Branch, Base, Label string }
66
+ type tabCreateCall struct{ WorkspaceID, CWD, Label string }
67
+ type renameCall struct{ PaneID, Label string }
68
+ type runCall struct{ PaneID, Command string }
69
+
70
+ var _ herdrcli.Client = (*fakeClient)(nil)
71
+
72
+ func (f *fakeClient) Snapshot(context.Context) (herdrcli.Snapshot, error) {
73
+ f.mu.Lock()
74
+ defer f.mu.Unlock()
75
+ return f.snapshot, f.snapshotErr
76
+ }
77
+
78
+ func (f *fakeClient) WorktreeList(_ context.Context, _ string) (herdrcli.WorktreeListing, error) {
79
+ f.mu.Lock()
80
+ defer f.mu.Unlock()
81
+ return f.listing, f.listingErr
82
+ }
83
+
84
+ func (f *fakeClient) WorktreeCreate(_ context.Context, repoPath, branch, base, label string) (herdrcli.Workspace, error) {
85
+ f.mu.Lock()
86
+ defer f.mu.Unlock()
87
+ f.createCalls = append(f.createCalls, worktreeCall{RepoPath: repoPath, Branch: branch, Base: base, Label: label})
88
+ return f.createWorkspace, f.createErr
89
+ }
90
+
91
+ func (f *fakeClient) WorktreeOpen(_ context.Context, repoPath, branch, label string) (herdrcli.Workspace, error) {
92
+ f.mu.Lock()
93
+ defer f.mu.Unlock()
94
+ f.openCalls = append(f.openCalls, worktreeCall{RepoPath: repoPath, Branch: branch, Label: label})
95
+ return f.openWorkspace, f.openErr
96
+ }
97
+
98
+ func (f *fakeClient) CreateTab(_ context.Context, workspaceID, cwd, label string) (herdrcli.Tab, herdrcli.Pane, error) {
99
+ f.mu.Lock()
100
+ defer f.mu.Unlock()
101
+ f.tabCreateCalls = append(f.tabCreateCalls, tabCreateCall{WorkspaceID: workspaceID, CWD: cwd, Label: label})
102
+ return f.createdTab, f.createdPane, f.createTabErr
103
+ }
104
+
105
+ func (f *fakeClient) ListTabs(context.Context, string) ([]herdrcli.Tab, error) {
106
+ f.mu.Lock()
107
+ defer f.mu.Unlock()
108
+ return f.tabs, f.tabsErr
109
+ }
110
+
111
+ func (f *fakeClient) ListPanes(context.Context, string) ([]herdrcli.Pane, error) {
112
+ f.mu.Lock()
113
+ defer f.mu.Unlock()
114
+ return f.panes, f.panesErr
115
+ }
116
+
117
+ func (f *fakeClient) GetPane(context.Context, string) (herdrcli.Pane, error) {
118
+ f.mu.Lock()
119
+ defer f.mu.Unlock()
120
+ return f.pane, f.paneErr
121
+ }
122
+
123
+ func (f *fakeClient) ProcessInfo(context.Context, string) (herdrcli.ProcessInfo, error) {
124
+ f.mu.Lock()
125
+ defer f.mu.Unlock()
126
+ return f.info, f.infoErr
127
+ }
128
+
129
+ func (f *fakeClient) RenamePane(_ context.Context, paneID, label string) error {
130
+ f.mu.Lock()
131
+ defer f.mu.Unlock()
132
+ f.renameCalls = append(f.renameCalls, renameCall{PaneID: paneID, Label: label})
133
+ return f.renameErr
134
+ }
135
+
136
+ func (f *fakeClient) RunPane(_ context.Context, paneID, command string) error {
137
+ f.mu.Lock()
138
+ defer f.mu.Unlock()
139
+ f.runCalls = append(f.runCalls, runCall{PaneID: paneID, Command: command})
140
+ return f.runErr
141
+ }
142
+
143
+ func (f *fakeClient) ClosePane(_ context.Context, paneID string) error {
144
+ f.mu.Lock()
145
+ defer f.mu.Unlock()
146
+ f.closedPanes = append(f.closedPanes, paneID)
147
+ return f.closePaneErr
148
+ }
149
+
150
+ func (f *fakeClient) CloseWorkspace(_ context.Context, workspaceID string) error {
151
+ f.mu.Lock()
152
+ defer f.mu.Unlock()
153
+ f.closedWorkspaces = append(f.closedWorkspaces, workspaceID)
154
+ return f.closeWorkspaceErr
155
+ }
156
+
157
+ func uint32Pointer(v uint32) *uint32 { return &v }
158
+
159
+ const (
160
+ repoPath = "/work/payments"
161
+ checkoutPath = "/work/worktrees/payments/pay-101"
162
+ )
163
+
164
+ func ticketWorkspaceValue() herdrcli.Workspace {
165
+ return herdrcli.Workspace{
166
+ ID: "w2",
167
+ Label: "PAY-101",
168
+ Worktree: herdrcli.WorkspaceWorktree{
169
+ CheckoutPath: checkoutPath,
170
+ RepoName: "payments",
171
+ RepoRoot: repoPath,
172
+ IsLinked: true,
173
+ },
174
+ }
175
+ }
176
+
177
+ func runSpec() runner.RunSpec {
178
+ return runner.RunSpec{RunID: "run-1", RepoName: "payments", RepoPath: repoPath, TicketKey: "PAY-101"}
179
+ }
180
+
181
+ func liveProcessInfo(paneID string) herdrcli.ProcessInfo {
182
+ return herdrcli.ProcessInfo{
183
+ PaneID: paneID,
184
+ ShellPID: uint32Pointer(100),
185
+ ForegroundProcesses: []herdrcli.ForegroundProcess{{PID: 200, Name: "opencode"}},
186
+ }
187
+ }
188
+
189
+ func shellProcessInfo(paneID string) herdrcli.ProcessInfo {
190
+ return herdrcli.ProcessInfo{
191
+ PaneID: paneID,
192
+ ShellPID: uint32Pointer(100),
193
+ ForegroundProcesses: []herdrcli.ForegroundProcess{{PID: 100, Name: "zsh"}},
194
+ }
195
+ }
196
+
197
+ func nodeCommand(runID string) runner.Command {
198
+ return runner.Command{
199
+ Executable: "opencode",
200
+ Args: []string{"--agent", "build", "--prompt", "line one\nline two"},
201
+ Env: map[string]string{"RELAY_FLOW_RUN_ID": runID, "RELAY_FLOW_NODE": "coding"},
202
+ }
203
+ }
204
+
205
+ // --- Construction ---
206
+
207
+ func TestNewRejectsUnknownRunnerConfigKeys(t *testing.T) {
208
+ if _, err := New(config.RawValues{"session": "relay-flow"}); err != nil {
209
+ t.Fatalf("New(valid) = %v", err)
210
+ }
211
+ if _, err := New(config.RawValues{"workspace": "w1"}); err == nil {
212
+ t.Fatal("New accepted an unknown runnerConfig key")
213
+ }
214
+ }
215
+
216
+ // --- Repos ---
217
+
218
+ func TestDiscoverReposDeduplicatesRepositoryRoots(t *testing.T) {
219
+ cli := &fakeClient{snapshot: herdrcli.Snapshot{Workspaces: []herdrcli.Workspace{
220
+ {ID: "w1", Label: "payments", Worktree: herdrcli.WorkspaceWorktree{CheckoutPath: repoPath, RepoName: "payments", RepoRoot: repoPath}},
221
+ ticketWorkspaceValue(),
222
+ {ID: "w3", Label: "billing", Worktree: herdrcli.WorkspaceWorktree{CheckoutPath: "/work/billing", RepoName: "billing", RepoRoot: "/work/billing"}},
223
+ {ID: "w4", Label: "scratch"},
224
+ }}}
225
+ got, err := newAdapter(cli).DiscoverRepos(context.Background())
226
+ if err != nil {
227
+ t.Fatal(err)
228
+ }
229
+ want := []runner.RepoCandidate{
230
+ {Name: "billing", Path: "/work/billing"},
231
+ {Name: "payments", Path: repoPath},
232
+ }
233
+ if len(got) != len(want) {
234
+ t.Fatalf("DiscoverRepos = %+v, want %+v", got, want)
235
+ }
236
+ for i := range want {
237
+ if got[i] != want[i] {
238
+ t.Fatalf("DiscoverRepos[%d] = %+v, want %+v", i, got[i], want[i])
239
+ }
240
+ }
241
+ }
242
+
243
+ func TestValidateRepoAcceptsRepositoryRootAndRejectsInnerPaths(t *testing.T) {
244
+ cli := &fakeClient{listing: herdrcli.WorktreeListing{
245
+ Source: herdrcli.WorktreeSource{RepoName: "payments", RepoRoot: repoPath, SourceCheckoutPath: repoPath},
246
+ }}
247
+ a := newAdapter(cli)
248
+ if err := a.ValidateRepo(context.Background(), "payments", repoPath); err != nil {
249
+ t.Fatalf("ValidateRepo(root) = %v", err)
250
+ }
251
+
252
+ err := a.ValidateRepo(context.Background(), "payments", repoPath+"/internal/api")
253
+ if err == nil {
254
+ t.Fatal("ValidateRepo accepted a path inside the repository")
255
+ }
256
+ if !strings.Contains(err.Error(), repoPath) {
257
+ t.Fatalf("ValidateRepo error = %v, want the repository root in the message", err)
258
+ }
259
+ }
260
+
261
+ func TestValidateRepoRejectsNonGitPath(t *testing.T) {
262
+ cli := &fakeClient{listingErr: herdrcli.ErrNotGitWorktree}
263
+ err := newAdapter(cli).ValidateRepo(context.Background(), "payments", "/work/not-a-repo")
264
+ if !errors.Is(err, herdrcli.ErrNotGitWorktree) {
265
+ t.Fatalf("ValidateRepo(non-git) = %v, want ErrNotGitWorktree", err)
266
+ }
267
+ }
268
+
269
+ func TestValidateRepoCreatesNothing(t *testing.T) {
270
+ cli := &fakeClient{listing: herdrcli.WorktreeListing{
271
+ Source: herdrcli.WorktreeSource{RepoRoot: repoPath, SourceCheckoutPath: repoPath},
272
+ }}
273
+ if err := newAdapter(cli).ValidateRepo(context.Background(), "payments", repoPath); err != nil {
274
+ t.Fatal(err)
275
+ }
276
+ if len(cli.createCalls) != 0 || len(cli.openCalls) != 0 || len(cli.tabCreateCalls) != 0 {
277
+ t.Fatalf("ValidateRepo created Herdr resources: %+v %+v %+v", cli.createCalls, cli.openCalls, cli.tabCreateCalls)
278
+ }
279
+ }
280
+
281
+ // --- Environment ---
282
+
283
+ func TestEnsureEnvironmentReusesExistingTicketWorktree(t *testing.T) {
284
+ cli := &fakeClient{openWorkspace: ticketWorkspaceValue()}
285
+ env, err := newAdapter(cli).EnsureEnvironment(context.Background(), runSpec())
286
+ if err != nil {
287
+ t.Fatal(err)
288
+ }
289
+ if env != (runner.Environment{ID: "w2", Path: checkoutPath}) {
290
+ t.Fatalf("EnsureEnvironment = %+v", env)
291
+ }
292
+ if len(cli.createCalls) != 0 {
293
+ t.Fatalf("worktree create calls = %+v, want none for an existing checkout", cli.createCalls)
294
+ }
295
+ if len(cli.openCalls) != 1 || cli.openCalls[0].Branch != "PAY-101" || cli.openCalls[0].Label != "PAY-101" {
296
+ t.Fatalf("worktree open calls = %+v", cli.openCalls)
297
+ }
298
+ }
299
+
300
+ func TestEnsureEnvironmentCreatesTicketWorktreeFromOriginBase(t *testing.T) {
301
+ repo := newGitRepo(t, "main")
302
+ git(t, repo, "remote", "add", "origin", repo)
303
+ git(t, repo, "update-ref", "refs/remotes/origin/main", "HEAD")
304
+ git(t, repo, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main")
305
+
306
+ cli := &fakeClient{
307
+ openErr: herdrcli.ErrWorktreeNotFound,
308
+ createWorkspace: ticketWorkspaceValue(),
309
+ }
310
+ spec := runSpec()
311
+ spec.RepoPath = repo
312
+ env, err := newAdapter(cli).EnsureEnvironment(context.Background(), spec)
313
+ if err != nil {
314
+ t.Fatal(err)
315
+ }
316
+ if env.ID != "w2" || env.Path != checkoutPath {
317
+ t.Fatalf("EnsureEnvironment = %+v", env)
318
+ }
319
+ if len(cli.createCalls) != 1 {
320
+ t.Fatalf("worktree create calls = %+v", cli.createCalls)
321
+ }
322
+ call := cli.createCalls[0]
323
+ if call.Branch != "PAY-101" || call.Label != "PAY-101" || call.Base != "origin/main" {
324
+ t.Fatalf("worktree create call = %+v, want ticket branch based on origin/main", call)
325
+ }
326
+ }
327
+
328
+ func TestEnsureEnvironmentPropagatesOpenFailures(t *testing.T) {
329
+ cli := &fakeClient{openErr: errors.New("herdr server unavailable")}
330
+ if _, err := newAdapter(cli).EnsureEnvironment(context.Background(), runSpec()); err == nil {
331
+ t.Fatal("EnsureEnvironment hid a transport failure")
332
+ }
333
+ if len(cli.createCalls) != 0 {
334
+ t.Fatalf("worktree create calls = %+v, want none after a transport failure", cli.createCalls)
335
+ }
336
+ }
337
+
338
+ func TestSetEnvironmentStatusIsSuccessfulNoOp(t *testing.T) {
339
+ cli := &fakeClient{}
340
+ err := newAdapter(cli).SetEnvironmentStatus(context.Background(), runner.Environment{ID: "w2"}, runner.WorkspaceStatusInProgress)
341
+ if err != nil {
342
+ t.Fatalf("SetEnvironmentStatus = %v", err)
343
+ }
344
+ if len(cli.closedWorkspaces) != 0 || len(cli.createCalls) != 0 {
345
+ t.Fatal("SetEnvironmentStatus touched Herdr state")
346
+ }
347
+ }
348
+
349
+ // --- Base ref ladder ---
350
+
351
+ func TestOriginBaseRefLadder(t *testing.T) {
352
+ t.Run("origin/HEAD", func(t *testing.T) {
353
+ repo := newGitRepo(t, "trunk")
354
+ git(t, repo, "remote", "add", "origin", repo)
355
+ git(t, repo, "update-ref", "refs/remotes/origin/trunk", "HEAD")
356
+ git(t, repo, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/trunk")
357
+ assertBaseRef(t, repo, "origin/trunk")
358
+ })
359
+ t.Run("origin/main", func(t *testing.T) {
360
+ repo := newGitRepo(t, "work")
361
+ git(t, repo, "remote", "add", "origin", repo)
362
+ git(t, repo, "update-ref", "refs/remotes/origin/main", "HEAD")
363
+ assertBaseRef(t, repo, "origin/main")
364
+ })
365
+ t.Run("origin/master", func(t *testing.T) {
366
+ repo := newGitRepo(t, "work")
367
+ git(t, repo, "remote", "add", "origin", repo)
368
+ git(t, repo, "update-ref", "refs/remotes/origin/master", "HEAD")
369
+ assertBaseRef(t, repo, "origin/master")
370
+ })
371
+ t.Run("local main without origin", func(t *testing.T) {
372
+ assertBaseRef(t, newGitRepo(t, "main"), "main")
373
+ })
374
+ t.Run("local master without origin", func(t *testing.T) {
375
+ assertBaseRef(t, newGitRepo(t, "master"), "master")
376
+ })
377
+ t.Run("no usable base", func(t *testing.T) {
378
+ repo := newGitRepo(t, "wip")
379
+ if _, err := originBaseRef(repo); err == nil {
380
+ t.Fatal("originBaseRef accepted a repository with no origin or main/master")
381
+ } else if !strings.Contains(err.Error(), "remote set-head") {
382
+ t.Fatalf("originBaseRef error = %v, want actionable guidance", err)
383
+ }
384
+ })
385
+ }
386
+
387
+ func assertBaseRef(t *testing.T, repo, want string) {
388
+ t.Helper()
389
+ got, err := originBaseRef(repo)
390
+ if err != nil {
391
+ t.Fatal(err)
392
+ }
393
+ if got != want {
394
+ t.Fatalf("originBaseRef = %q, want %q", got, want)
395
+ }
396
+ }
397
+
398
+ func newGitRepo(t *testing.T, branch string) string {
399
+ t.Helper()
400
+ dir := t.TempDir()
401
+ git(t, dir, "init", "-q", "-b", branch, ".")
402
+ git(t, dir, "config", "user.email", "test@relay-flow.test")
403
+ git(t, dir, "config", "user.name", "test")
404
+ if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("x\n"), 0o644); err != nil {
405
+ t.Fatal(err)
406
+ }
407
+ git(t, dir, "add", "-A")
408
+ git(t, dir, "commit", "-qm", "init")
409
+ return dir
410
+ }
411
+
412
+ func git(t *testing.T, dir string, args ...string) {
413
+ t.Helper()
414
+ cmd := exec.Command("git", args...)
415
+ cmd.Dir = dir
416
+ if out, err := cmd.CombinedOutput(); err != nil {
417
+ t.Fatalf("git %v: %v: %s", args, err, out)
418
+ }
419
+ }
420
+
421
+ // --- Terminals ---
422
+
423
+ func TestCreateTerminalUsesPublicPaneIDAndStableLabel(t *testing.T) {
424
+ cli := &fakeClient{
425
+ createdTab: herdrcli.Tab{ID: "w2:t2", WorkspaceID: "w2", Label: "PAY-101:coding"},
426
+ createdPane: herdrcli.Pane{ID: "w2:p2", TerminalID: "term_ephemeral", WorkspaceID: "w2", TabID: "w2:t2"},
427
+ }
428
+ env := runner.Environment{ID: "w2", Path: checkoutPath}
429
+ terminal, err := newAdapter(cli).CreateTerminal(context.Background(), env, "PAY-101:coding", nodeCommand("run-1"))
430
+ if err != nil {
431
+ t.Fatal(err)
432
+ }
433
+ if terminal != (runner.Terminal{ID: "w2:p2", Title: "PAY-101:coding"}) {
434
+ t.Fatalf("CreateTerminal = %+v, want the public pane ID", terminal)
435
+ }
436
+ if len(cli.tabCreateCalls) != 1 || cli.tabCreateCalls[0] != (tabCreateCall{WorkspaceID: "w2", CWD: checkoutPath, Label: "PAY-101:coding"}) {
437
+ t.Fatalf("tab create calls = %+v, want the worktree checkout as cwd", cli.tabCreateCalls)
438
+ }
439
+ if len(cli.renameCalls) != 1 || cli.renameCalls[0].Label != "PAY-101:coding" {
440
+ t.Fatalf("rename calls = %+v", cli.renameCalls)
441
+ }
442
+ if len(cli.runCalls) != 1 {
443
+ t.Fatalf("run calls = %+v", cli.runCalls)
444
+ }
445
+ command := cli.runCalls[0].Command
446
+ for _, want := range []string{"RELAY_FLOW_RUN_ID='run-1'", "RELAY_FLOW_NODE='coding'", "'opencode'", "'line one\nline two'"} {
447
+ if !strings.Contains(command, want) {
448
+ t.Fatalf("command %q missing %q", command, want)
449
+ }
450
+ }
451
+ for _, forbidden := range []string{"nodeVisit", "run-1:coding", "--env"} {
452
+ if strings.Contains(cli.tabCreateCalls[0].Label, forbidden) {
453
+ t.Fatalf("label %q contains %q", cli.tabCreateCalls[0].Label, forbidden)
454
+ }
455
+ }
456
+ }
457
+
458
+ func TestFindTerminalAcceptsLiveAgentAndRejectsRestoredShell(t *testing.T) {
459
+ live := &fakeClient{
460
+ pane: herdrcli.Pane{ID: "w2:p2", TerminalID: "term_1", WorkspaceID: "w2", Label: "PAY-101:coding"},
461
+ info: liveProcessInfo("w2:p2"),
462
+ }
463
+ terminal, ok, err := newAdapter(live).FindTerminal(context.Background(), runner.Terminal{ID: "w2:p2", Title: "PAY-101:coding"})
464
+ if err != nil || !ok || terminal.ID != "w2:p2" {
465
+ t.Fatalf("FindTerminal(live) = %+v, %v, %v", terminal, ok, err)
466
+ }
467
+
468
+ restored := &fakeClient{
469
+ pane: herdrcli.Pane{ID: "w2:p2", TerminalID: "term_after_restart", WorkspaceID: "w2", Label: "PAY-101:coding"},
470
+ info: shellProcessInfo("w2:p2"),
471
+ }
472
+ if _, ok, err := newAdapter(restored).FindTerminal(context.Background(), runner.Terminal{ID: "w2:p2"}); err != nil || ok {
473
+ t.Fatalf("FindTerminal(restored shell) = %v, %v, want absent", ok, err)
474
+ }
475
+ }
476
+
477
+ func TestFindTerminalDistinguishesMissingPaneFromTransportFailure(t *testing.T) {
478
+ missing := &fakeClient{paneErr: herdrcli.ErrPaneNotFound}
479
+ if _, ok, err := newAdapter(missing).FindTerminal(context.Background(), runner.Terminal{ID: "w2:p9"}); err != nil || ok {
480
+ t.Fatalf("FindTerminal(missing) = %v, %v, want absent without error", ok, err)
481
+ }
482
+
483
+ broken := &fakeClient{paneErr: errors.New("herdr server unavailable")}
484
+ if _, ok, err := newAdapter(broken).FindTerminal(context.Background(), runner.Terminal{ID: "w2:p2"}); err == nil || ok {
485
+ t.Fatalf("FindTerminal(transport failure) = %v, %v, want the error to propagate", ok, err)
486
+ }
487
+ }
488
+
489
+ func TestEnsureTerminalReusesLivePaneWithoutRelaunching(t *testing.T) {
490
+ cli := &fakeClient{
491
+ pane: herdrcli.Pane{ID: "w2:p2", TerminalID: "term_1", WorkspaceID: "w2", Label: "PAY-101:coding"},
492
+ info: liveProcessInfo("w2:p2"),
493
+ }
494
+ env := runner.Environment{ID: "w2", Path: checkoutPath}
495
+ stored := runner.Terminal{ID: "w2:p2", Title: "PAY-101:coding"}
496
+ got, err := newAdapter(cli).EnsureTerminal(context.Background(), env, stored, "PAY-101:coding", nodeCommand("run-1"))
497
+ if err != nil || got.ID != "w2:p2" {
498
+ t.Fatalf("EnsureTerminal = %+v, %v", got, err)
499
+ }
500
+ if len(cli.tabCreateCalls) != 0 || len(cli.runCalls) != 0 || len(cli.closedPanes) != 0 {
501
+ t.Fatal("EnsureTerminal disturbed a live pane")
502
+ }
503
+ }
504
+
505
+ func TestEnsureTerminalReplacesMissingPane(t *testing.T) {
506
+ cli := &fakeClient{
507
+ paneErr: herdrcli.ErrPaneNotFound,
508
+ createdTab: herdrcli.Tab{ID: "w2:t3", WorkspaceID: "w2", Label: "PAY-101:coding"},
509
+ createdPane: herdrcli.Pane{ID: "w2:p3", TerminalID: "term_new", WorkspaceID: "w2", TabID: "w2:t3"},
510
+ }
511
+ env := runner.Environment{ID: "w2", Path: checkoutPath}
512
+ stored := runner.Terminal{ID: "w2:p2", Title: "PAY-101:coding"}
513
+ got, err := newAdapter(cli).EnsureTerminal(context.Background(), env, stored, "PAY-101:coding", nodeCommand("run-1"))
514
+ if err != nil || got.ID != "w2:p3" {
515
+ t.Fatalf("EnsureTerminal = %+v, %v", got, err)
516
+ }
517
+ if len(cli.tabCreateCalls) != 1 {
518
+ t.Fatalf("tab create calls = %+v, want exactly one replacement", cli.tabCreateCalls)
519
+ }
520
+ }
521
+
522
+ func TestEnsureTerminalRecoversLostCreateThroughTabLabel(t *testing.T) {
523
+ cli := &fakeClient{
524
+ paneErr: herdrcli.ErrPaneNotFound,
525
+ tabs: []herdrcli.Tab{{ID: "w2:t2", WorkspaceID: "w2", Label: "PAY-101:coding"}},
526
+ panes: []herdrcli.Pane{{ID: "w2:p2", WorkspaceID: "w2", TabID: "w2:t2"}},
527
+ }
528
+ env := runner.Environment{ID: "w2", Path: checkoutPath}
529
+ got, err := newAdapter(cli).EnsureTerminal(context.Background(), env, runner.Terminal{}, "PAY-101:coding", nodeCommand("run-2"))
530
+ if err != nil || got.ID != "w2:p2" {
531
+ t.Fatalf("EnsureTerminal = %+v, %v", got, err)
532
+ }
533
+ if len(cli.tabCreateCalls) != 0 {
534
+ t.Fatalf("tab create calls = %+v, want no duplicate pane", cli.tabCreateCalls)
535
+ }
536
+ if len(cli.renameCalls) != 1 || cli.renameCalls[0].PaneID != "w2:p2" {
537
+ t.Fatalf("rename calls = %+v, want the recovered pane labelled", cli.renameCalls)
538
+ }
539
+ if len(cli.runCalls) != 1 || !strings.Contains(cli.runCalls[0].Command, "RELAY_FLOW_RUN_ID='run-2'") {
540
+ t.Fatalf("run calls = %+v, want the current run's environment", cli.runCalls)
541
+ }
542
+ }
543
+
544
+ func TestEnsureTerminalRecoversLabelledPaneWithoutRenaming(t *testing.T) {
545
+ cli := &fakeClient{
546
+ paneErr: herdrcli.ErrPaneNotFound,
547
+ tabs: []herdrcli.Tab{{ID: "w2:t2", WorkspaceID: "w2", Label: "PAY-101:coding"}},
548
+ panes: []herdrcli.Pane{{ID: "w2:p2", WorkspaceID: "w2", TabID: "w2:t2", Label: "PAY-101:coding"}},
549
+ }
550
+ env := runner.Environment{ID: "w2", Path: checkoutPath}
551
+ got, err := newAdapter(cli).EnsureTerminal(context.Background(), env, runner.Terminal{}, "PAY-101:coding", nodeCommand("run-2"))
552
+ if err != nil || got.ID != "w2:p2" {
553
+ t.Fatalf("EnsureTerminal = %+v, %v", got, err)
554
+ }
555
+ if len(cli.renameCalls) != 0 {
556
+ t.Fatalf("rename calls = %+v, want none for an already labelled pane", cli.renameCalls)
557
+ }
558
+ if len(cli.runCalls) != 1 {
559
+ t.Fatalf("run calls = %+v", cli.runCalls)
560
+ }
561
+ }
562
+
563
+ // A pane left by an earlier run must be relaunched with the current run's
564
+ // environment: Herdr binds environment to the launched command, not the tab.
565
+ func TestAdoptedPaneNeverInheritsPreviousRunEnvironment(t *testing.T) {
566
+ cli := &fakeClient{
567
+ paneErr: herdrcli.ErrPaneNotFound,
568
+ tabs: []herdrcli.Tab{{ID: "w2:t2", WorkspaceID: "w2", Label: "PAY-101:coding"}},
569
+ panes: []herdrcli.Pane{{ID: "w2:p2", WorkspaceID: "w2", TabID: "w2:t2", Label: "PAY-101:coding"}},
570
+ }
571
+ env := runner.Environment{ID: "w2", Path: checkoutPath}
572
+ if _, err := newAdapter(cli).EnsureTerminal(context.Background(), env, runner.Terminal{}, "PAY-101:coding", nodeCommand("run-9")); err != nil {
573
+ t.Fatal(err)
574
+ }
575
+ command := cli.runCalls[0].Command
576
+ if !strings.Contains(command, "RELAY_FLOW_RUN_ID='run-9'") || strings.Contains(command, "run-1") {
577
+ t.Fatalf("command %q must carry only the current run identity", command)
578
+ }
579
+ }
580
+
581
+ func TestSendTerminalForwardsMultilineTextUnchanged(t *testing.T) {
582
+ cli := &fakeClient{}
583
+ text := "feedback line one\nfeedback line two\n"
584
+ if err := newAdapter(cli).SendTerminal(context.Background(), runner.Terminal{ID: "w2:p2"}, text); err != nil {
585
+ t.Fatal(err)
586
+ }
587
+ if len(cli.runCalls) != 1 || cli.runCalls[0].Command != text {
588
+ t.Fatalf("run calls = %+v, want the exact text", cli.runCalls)
589
+ }
590
+ }
591
+
592
+ func TestCloseTerminalIsIdempotentForMissingPane(t *testing.T) {
593
+ cli := &fakeClient{closePaneErr: herdrcli.ErrPaneNotFound}
594
+ if err := newAdapter(cli).CloseTerminal(context.Background(), runner.Terminal{ID: "w2:p9"}); err != nil {
595
+ t.Fatalf("CloseTerminal(missing) = %v, want nil", err)
596
+ }
597
+ cli.closePaneErr = errors.New("herdr server unavailable")
598
+ if err := newAdapter(cli).CloseTerminal(context.Background(), runner.Terminal{ID: "w2:p2"}); err == nil {
599
+ t.Fatal("CloseTerminal hid a transport failure")
600
+ }
601
+ }
602
+
603
+ // --- Cleanup ---
604
+
605
+ func openTicketWorktreeClient() *fakeClient {
606
+ return &fakeClient{
607
+ listing: herdrcli.WorktreeListing{
608
+ Source: herdrcli.WorktreeSource{RepoName: "payments", RepoRoot: repoPath, SourceCheckoutPath: repoPath},
609
+ Worktrees: []herdrcli.Worktree{
610
+ {Path: repoPath, Branch: "main", OpenWorkspaceID: "w1"},
611
+ {Path: checkoutPath, Branch: "PAY-101", IsLinked: true, OpenWorkspaceID: "w2"},
612
+ },
613
+ },
614
+ tabs: []herdrcli.Tab{
615
+ {ID: "w2:t1", WorkspaceID: "w2", Label: "1"},
616
+ {ID: "w2:t2", WorkspaceID: "w2", Label: "PAY-101:coding"},
617
+ },
618
+ panes: []herdrcli.Pane{
619
+ {ID: "w2:p1", WorkspaceID: "w2", TabID: "w2:t1"},
620
+ {ID: "w2:p2", WorkspaceID: "w2", TabID: "w2:t2", Label: "PAY-101:coding"},
621
+ {ID: "w2:p3", WorkspaceID: "w2", TabID: "w2:t3", Label: "PAY-202:review"},
622
+ },
623
+ }
624
+ }
625
+
626
+ func TestCloseTerminalsClosesOnlyTicketPanesAndKeepsWorkspace(t *testing.T) {
627
+ cli := openTicketWorktreeClient()
628
+ if err := newAdapter(cli).CloseTerminals(context.Background(), runSpec()); err != nil {
629
+ t.Fatal(err)
630
+ }
631
+ if len(cli.closedPanes) != 1 || cli.closedPanes[0] != "w2:p2" {
632
+ t.Fatalf("closed panes = %v, want only the ticket node pane", cli.closedPanes)
633
+ }
634
+ if len(cli.closedWorkspaces) != 0 {
635
+ t.Fatalf("closed workspaces = %v, want the workspace preserved", cli.closedWorkspaces)
636
+ }
637
+ }
638
+
639
+ func TestCleanupRunClosesTicketWorkspaceAndKeepsWorktree(t *testing.T) {
640
+ cli := openTicketWorktreeClient()
641
+ if err := newAdapter(cli).CleanupRun(context.Background(), runSpec()); err != nil {
642
+ t.Fatal(err)
643
+ }
644
+ if len(cli.closedPanes) != 1 || cli.closedPanes[0] != "w2:p2" {
645
+ t.Fatalf("closed panes = %v", cli.closedPanes)
646
+ }
647
+ if len(cli.closedWorkspaces) != 1 || cli.closedWorkspaces[0] != "w2" {
648
+ t.Fatalf("closed workspaces = %v, want the ticket workspace", cli.closedWorkspaces)
649
+ }
650
+ }
651
+
652
+ func TestCleanupRollsForwardWhenTicketWorktreeIsGone(t *testing.T) {
653
+ cases := map[string]*fakeClient{
654
+ "repository is not a git work tree": {listingErr: herdrcli.ErrNotGitWorktree},
655
+ "ticket checkout removed": {listing: herdrcli.WorktreeListing{
656
+ Source: herdrcli.WorktreeSource{RepoRoot: repoPath},
657
+ Worktrees: []herdrcli.Worktree{{Path: repoPath, Branch: "main", OpenWorkspaceID: "w1"}},
658
+ }},
659
+ "ticket workspace closed": {listing: herdrcli.WorktreeListing{
660
+ Source: herdrcli.WorktreeSource{RepoRoot: repoPath},
661
+ Worktrees: []herdrcli.Worktree{{Path: checkoutPath, Branch: "PAY-101", IsLinked: true}},
662
+ }},
663
+ }
664
+ for name, cli := range cases {
665
+ t.Run(name, func(t *testing.T) {
666
+ a := newAdapter(cli)
667
+ if err := a.CloseTerminals(context.Background(), runSpec()); err != nil {
668
+ t.Fatalf("CloseTerminals = %v, want roll-forward success", err)
669
+ }
670
+ if err := a.CleanupRun(context.Background(), runSpec()); err != nil {
671
+ t.Fatalf("CleanupRun = %v, want roll-forward success", err)
672
+ }
673
+ if len(cli.closedPanes) != 0 || len(cli.closedWorkspaces) != 0 {
674
+ t.Fatal("cleanup touched Herdr state for an absent environment")
675
+ }
676
+ })
677
+ }
678
+ }
679
+
680
+ func TestCleanupPropagatesTransportFailures(t *testing.T) {
681
+ cli := &fakeClient{listingErr: errors.New("herdr server unavailable")}
682
+ if err := newAdapter(cli).CloseTerminals(context.Background(), runSpec()); err == nil {
683
+ t.Fatal("CloseTerminals hid a transport failure")
684
+ }
685
+ if err := newAdapter(cli).CleanupRun(context.Background(), runSpec()); err == nil {
686
+ t.Fatal("CleanupRun hid a transport failure")
687
+ }
688
+ }