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,578 @@
1
+ // Package herdr is the Herdr runner adapter. It maps a registered repository
2
+ // to its Herdr-managed Git worktrees: each ticket gets its own worktree
3
+ // workspace, and each node gets one labelled pane inside it. It does not know
4
+ // task-system fields, workflow routes, report contents, or agent command
5
+ // syntax.
6
+ //
7
+ // External-call logging mirrors the Orca adapter: one debug line before the
8
+ // call and one info line after with the outcome only, never payloads.
9
+ package herdr
10
+
11
+ import (
12
+ "context"
13
+ "errors"
14
+ "fmt"
15
+ "log/slog"
16
+ "os"
17
+ "path/filepath"
18
+ "sort"
19
+ "strings"
20
+
21
+ "github.com/rajpopat27/relay-flow/internal/config"
22
+ "github.com/rajpopat27/relay-flow/internal/runner"
23
+ "github.com/rajpopat27/relay-flow/internal/runner/herdr/herdrcli"
24
+ )
25
+
26
+ // Config is the adapter-owned machine-level Herdr runner configuration.
27
+ type Config struct {
28
+ Session string `yaml:"session,omitempty"`
29
+ SocketPath string `yaml:"socketPath,omitempty"`
30
+ }
31
+
32
+ func init() {
33
+ runner.Register("herdr", New)
34
+ }
35
+
36
+ // adapter implements runner.Runner around the public Herdr CLI. The
37
+ // herdrcli.Client field is the package-local seam used by adapter tests;
38
+ // New always supplies the concrete production CLI client.
39
+ type adapter struct {
40
+ cli herdrcli.Client
41
+ }
42
+
43
+ // New constructs the production Herdr runner from machine-level raw config.
44
+ // Configuration is decoded before constructing the CLI client so invalid
45
+ // values cannot trigger an external Herdr invocation.
46
+ func New(raw config.RawValues) (runner.Runner, error) {
47
+ var cfg Config
48
+ if err := config.DecodeStrict(raw, &cfg); err != nil {
49
+ return nil, fmt.Errorf("herdr runnerConfig: %w", err)
50
+ }
51
+ cli := herdrcli.New(herdrcli.Options{
52
+ Session: cfg.Session,
53
+ SocketPath: cfg.SocketPath,
54
+ })
55
+ return &adapter{cli: cli}, nil
56
+ }
57
+
58
+ // newAdapter wires a typed CLI test seam. It is intentionally unexported;
59
+ // production construction goes through New.
60
+ func newAdapter(cli herdrcli.Client) *adapter {
61
+ return &adapter{cli: cli}
62
+ }
63
+
64
+ func logCall(operation string, attrs ...any) {
65
+ args := []any{"op", operation}
66
+ args = append(args, attrs...)
67
+ slog.Debug("herdr call", args...)
68
+ }
69
+
70
+ // logOutcome records only safe operation identity and the result. Do not add
71
+ // command, prompt, environment, selector, or external error payloads here.
72
+ func logOutcome(operation, result string, attrs ...any) {
73
+ args := []any{"op", operation}
74
+ args = append(args, attrs...)
75
+ args = append(args, "result", result)
76
+ slog.Info("herdr outcome", args...)
77
+ }
78
+
79
+ // --- Repos ---
80
+
81
+ // DiscoverRepos returns the repositories Herdr currently has open, derived
82
+ // from the Git identity Herdr reports for every workspace. A ticket worktree
83
+ // workspace reports the same repository root as its source checkout, so the
84
+ // candidates are deduplicated by repository root.
85
+ func (a *adapter) DiscoverRepos(ctx context.Context) ([]runner.RepoCandidate, error) {
86
+ logCall("discover-repos")
87
+ snapshot, err := a.cli.Snapshot(ctx)
88
+ if err != nil {
89
+ logOutcome("discover-repos", "error")
90
+ return nil, err
91
+ }
92
+ candidates := make(map[string]runner.RepoCandidate)
93
+ for _, workspace := range snapshot.Workspaces {
94
+ root := normalizePath(workspace.Worktree.RepoRoot)
95
+ if root == "" {
96
+ continue
97
+ }
98
+ candidates[root] = runner.RepoCandidate{Name: workspace.Worktree.RepoName, Path: root}
99
+ }
100
+ out := make([]runner.RepoCandidate, 0, len(candidates))
101
+ for _, candidate := range candidates {
102
+ out = append(out, candidate)
103
+ }
104
+ sort.Slice(out, func(i, j int) bool {
105
+ if out[i].Name != out[j].Name {
106
+ return out[i].Name < out[j].Name
107
+ }
108
+ return out[i].Path < out[j].Path
109
+ })
110
+ logOutcome("discover-repos", "ok", "count", len(out))
111
+ return out, nil
112
+ }
113
+
114
+ // ValidateRepo verifies the registered path is the root of a Git repository
115
+ // Herdr can manage. Registration needs no Herdr workspace and creates none.
116
+ func (a *adapter) ValidateRepo(ctx context.Context, name, path string) error {
117
+ logCall("validate-repo", "repo", name)
118
+ registered := normalizePath(path)
119
+ if registered == "" {
120
+ logOutcome("validate-repo", "error", "repo", name)
121
+ return fmt.Errorf("herdr: repository %q has an empty path", name)
122
+ }
123
+ listing, err := a.cli.WorktreeList(ctx, registered)
124
+ if err != nil {
125
+ logOutcome("validate-repo", "error", "repo", name)
126
+ return fmt.Errorf("herdr: repository %q at %q: %w", name, path, err)
127
+ }
128
+ if root := normalizePath(listing.Source.RepoRoot); root != registered {
129
+ logOutcome("validate-repo", "error", "repo", name)
130
+ return fmt.Errorf("herdr: repository %q path %q is inside repository %q; register %q instead", name, path, root, root)
131
+ }
132
+ logOutcome("validate-repo", "ok", "repo", name)
133
+ return nil
134
+ }
135
+
136
+ // --- Environment ---
137
+
138
+ // EnsureEnvironment returns the ticket's Herdr worktree workspace, opening the
139
+ // existing ticket branch checkout or creating it from the repository's origin
140
+ // branch. Herdr reuses an existing branch, so previous agent commits are never
141
+ // discarded. The workspace ID is Herdr's current handle for the open
142
+ // workspace; the durable identity is the ticket branch and its checkout.
143
+ func (a *adapter) EnsureEnvironment(ctx context.Context, spec runner.RunSpec) (runner.Environment, error) {
144
+ attrs := []any{"ticket", spec.TicketKey, "runID", string(spec.RunID), "repo", spec.RepoName}
145
+ logCall("ensure-environment", attrs...)
146
+ repoPath := normalizePath(spec.RepoPath)
147
+ if repoPath == "" {
148
+ logOutcome("ensure-environment", "error", attrs...)
149
+ return runner.Environment{}, fmt.Errorf("herdr: repository %q has an empty path", spec.RepoName)
150
+ }
151
+
152
+ workspace, err := a.cli.WorktreeOpen(ctx, repoPath, spec.TicketKey, spec.TicketKey)
153
+ outcome := "exists"
154
+ if errors.Is(err, herdrcli.ErrWorktreeNotFound) {
155
+ base, baseErr := originBaseRef(repoPath)
156
+ if baseErr != nil {
157
+ logOutcome("ensure-environment", "error", attrs...)
158
+ return runner.Environment{}, baseErr
159
+ }
160
+ workspace, err = a.cli.WorktreeCreate(ctx, repoPath, spec.TicketKey, base, spec.TicketKey)
161
+ outcome = "created"
162
+ }
163
+ if err != nil {
164
+ logOutcome("ensure-environment", "error", attrs...)
165
+ return runner.Environment{}, err
166
+ }
167
+ checkout := normalizePath(workspace.Worktree.CheckoutPath)
168
+ if workspace.ID == "" || checkout == "" {
169
+ logOutcome("ensure-environment", "error", attrs...)
170
+ return runner.Environment{}, fmt.Errorf("herdr: ticket %q worktree workspace is incomplete", spec.TicketKey)
171
+ }
172
+ logOutcome("ensure-environment", outcome, attrs...)
173
+ return runner.Environment{ID: workspace.ID, Path: checkout}, nil
174
+ }
175
+
176
+ // SetEnvironmentStatus is a successful no-op: Herdr has no workspace status
177
+ // primitive, and relay-flow's own run state remains the source of truth.
178
+ func (*adapter) SetEnvironmentStatus(context.Context, runner.Environment, string) error {
179
+ logCall("set-environment-status")
180
+ logOutcome("set-environment-status", "ok")
181
+ return nil
182
+ }
183
+
184
+ // ticketWorkspace resolves the ticket's currently open worktree workspace
185
+ // without creating anything. found is false when the repository, the ticket
186
+ // checkout, or its workspace is absent, which lets cleanup roll forward.
187
+ func (a *adapter) ticketWorkspace(ctx context.Context, spec runner.RunSpec) (string, bool, error) {
188
+ repoPath := normalizePath(spec.RepoPath)
189
+ if repoPath == "" {
190
+ return "", false, nil
191
+ }
192
+ listing, err := a.cli.WorktreeList(ctx, repoPath)
193
+ if err != nil {
194
+ if errors.Is(err, herdrcli.ErrNotGitWorktree) || errors.Is(err, herdrcli.ErrWorktreeNotFound) {
195
+ return "", false, nil
196
+ }
197
+ return "", false, err
198
+ }
199
+ for _, worktree := range listing.Worktrees {
200
+ if worktree.Branch == spec.TicketKey && worktree.OpenWorkspaceID != "" {
201
+ return worktree.OpenWorkspaceID, true, nil
202
+ }
203
+ }
204
+ return "", false, nil
205
+ }
206
+
207
+ // --- Terminals ---
208
+
209
+ // FindTerminal addresses a persisted pane handle directly and returns it only
210
+ // when Herdr still has a live agent process in that pane. A pane restored as a
211
+ // bare shell after a Herdr restart is not a usable agent terminal.
212
+ func (a *adapter) FindTerminal(ctx context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
213
+ attrs := []any{"title", terminal.Title, "handle", terminal.ID}
214
+ logCall("find-terminal", attrs...)
215
+ if terminal.ID == "" {
216
+ logOutcome("find-terminal", "absent", attrs...)
217
+ return runner.Terminal{}, false, nil
218
+ }
219
+ pane, err := a.cli.GetPane(ctx, terminal.ID)
220
+ if errors.Is(err, herdrcli.ErrPaneNotFound) {
221
+ logOutcome("find-terminal", "absent", attrs...)
222
+ return runner.Terminal{}, false, nil
223
+ }
224
+ if err != nil {
225
+ logOutcome("find-terminal", "error", attrs...)
226
+ return runner.Terminal{}, false, err
227
+ }
228
+ if pane.ID != terminal.ID || pane.TerminalID == "" {
229
+ logOutcome("find-terminal", "absent", attrs...)
230
+ return runner.Terminal{}, false, nil
231
+ }
232
+ info, err := a.cli.ProcessInfo(ctx, pane.ID)
233
+ if errors.Is(err, herdrcli.ErrPaneNotFound) {
234
+ logOutcome("find-terminal", "absent", attrs...)
235
+ return runner.Terminal{}, false, nil
236
+ }
237
+ if err != nil {
238
+ logOutcome("find-terminal", "error", attrs...)
239
+ return runner.Terminal{}, false, err
240
+ }
241
+ if !hasLiveForegroundProcess(info) {
242
+ logOutcome("find-terminal", "absent", attrs...)
243
+ return runner.Terminal{}, false, nil
244
+ }
245
+ logOutcome("find-terminal", "found", attrs...)
246
+ return runner.Terminal{ID: pane.ID, Title: terminal.Title}, true, nil
247
+ }
248
+
249
+ // hasLiveForegroundProcess reports whether the pane runs something other than
250
+ // its own shell. Herdr reports the shell itself in the foreground when a pane
251
+ // is idle or was restored after a restart.
252
+ func hasLiveForegroundProcess(info herdrcli.ProcessInfo) bool {
253
+ if info.PaneID == "" || info.ShellPID == nil {
254
+ return false
255
+ }
256
+ for _, process := range info.ForegroundProcesses {
257
+ if process.PID != *info.ShellPID {
258
+ return true
259
+ }
260
+ }
261
+ return false
262
+ }
263
+
264
+ // CreateTerminal always creates a node pane; it performs no label discovery.
265
+ func (a *adapter) CreateTerminal(ctx context.Context, env runner.Environment, title string, command runner.Command) (runner.Terminal, error) {
266
+ attrs := []any{"envID", env.ID, "title", title}
267
+ logCall("create-terminal", attrs...)
268
+ _, pane, err := a.cli.CreateTab(ctx, env.ID, env.Path, title)
269
+ if err != nil {
270
+ logOutcome("create-terminal", "error", attrs...)
271
+ return runner.Terminal{}, err
272
+ }
273
+ if pane.ID == "" {
274
+ logOutcome("create-terminal", "error", attrs...)
275
+ return runner.Terminal{}, fmt.Errorf("herdr: tab create returned an empty root pane ID")
276
+ }
277
+ if err := a.startNode(ctx, pane.ID, title, command, false); err != nil {
278
+ logOutcome("create-terminal", "error", attrs...)
279
+ return runner.Terminal{}, err
280
+ }
281
+ logOutcome("create-terminal", "ok", attrs...)
282
+ return runner.Terminal{ID: pane.ID, Title: title}, nil
283
+ }
284
+
285
+ // startNode applies the stable pane label and submits the harness command.
286
+ // A pane that cannot be prepared is closed so no half-built node pane is left
287
+ // behind for label recovery to adopt.
288
+ func (a *adapter) startNode(ctx context.Context, paneID, title string, command runner.Command, labelled bool) error {
289
+ if !labelled {
290
+ if err := a.cli.RenamePane(ctx, paneID, title); err != nil {
291
+ _ = a.cli.ClosePane(ctx, paneID)
292
+ return err
293
+ }
294
+ }
295
+ if err := a.cli.RunPane(ctx, paneID, shellCommand(command)); err != nil {
296
+ _ = a.cli.ClosePane(ctx, paneID)
297
+ return err
298
+ }
299
+ return nil
300
+ }
301
+
302
+ // EnsureTerminal reuses a live pane, then recovers a pane whose creation was
303
+ // acknowledged but not persisted, and only then creates a replacement.
304
+ func (a *adapter) EnsureTerminal(ctx context.Context, env runner.Environment, stored runner.Terminal, title string, command runner.Command) (runner.Terminal, error) {
305
+ attrs := []any{"envID", env.ID, "title", title}
306
+ logCall("ensure-terminal", attrs...)
307
+ if terminal, ok, err := a.FindTerminal(ctx, stored); err != nil {
308
+ logOutcome("ensure-terminal", "error", attrs...)
309
+ return runner.Terminal{}, err
310
+ } else if ok {
311
+ logOutcome("ensure-terminal", "exists", attrs...)
312
+ return terminal, nil
313
+ }
314
+ if stored.ID != "" {
315
+ if err := a.CloseTerminal(ctx, runner.Terminal{ID: stored.ID, Title: title}); err != nil {
316
+ logOutcome("ensure-terminal", "error", attrs...)
317
+ return runner.Terminal{}, err
318
+ }
319
+ }
320
+
321
+ // A create acknowledgement can be lost before the pane handle is
322
+ // persisted. The tab label is the recovery marker until pane rename
323
+ // completes; the pane label is used afterward.
324
+ recovered, labelled, err := a.recoverTerminal(ctx, env.ID, title)
325
+ if err != nil {
326
+ logOutcome("ensure-terminal", "error", attrs...)
327
+ return runner.Terminal{}, err
328
+ }
329
+ if recovered != "" && recovered != stored.ID {
330
+ if terminal, ok, err := a.FindTerminal(ctx, runner.Terminal{ID: recovered, Title: title}); err != nil {
331
+ logOutcome("ensure-terminal", "error", attrs...)
332
+ return runner.Terminal{}, err
333
+ } else if ok {
334
+ logOutcome("ensure-terminal", "exists", attrs...)
335
+ return terminal, nil
336
+ }
337
+ // The recovered pane is the root shell left between tab creation and
338
+ // command submission: finish that launch instead of duplicating it.
339
+ if err := a.startNode(ctx, recovered, title, command, labelled); err != nil {
340
+ logOutcome("ensure-terminal", "error", attrs...)
341
+ return runner.Terminal{}, err
342
+ }
343
+ logOutcome("ensure-terminal", "exists", attrs...)
344
+ return runner.Terminal{ID: recovered, Title: title}, nil
345
+ }
346
+
347
+ terminal, err := a.CreateTerminal(ctx, env, title, command)
348
+ if err != nil {
349
+ logOutcome("ensure-terminal", "error", attrs...)
350
+ return runner.Terminal{}, err
351
+ }
352
+ logOutcome("ensure-terminal", "created", attrs...)
353
+ return terminal, nil
354
+ }
355
+
356
+ // recoverTerminal finds a pane belonging to the node's stable label, either
357
+ // directly or through its tab. labelled reports whether the pane already
358
+ // carries the node label.
359
+ func (a *adapter) recoverTerminal(ctx context.Context, workspaceID, title string) (string, bool, error) {
360
+ if workspaceID == "" || title == "" {
361
+ return "", false, nil
362
+ }
363
+ tabs, err := a.cli.ListTabs(ctx, workspaceID)
364
+ if err != nil {
365
+ if errors.Is(err, herdrcli.ErrWorkspaceNotFound) {
366
+ return "", false, nil
367
+ }
368
+ return "", false, err
369
+ }
370
+ panes, err := a.cli.ListPanes(ctx, workspaceID)
371
+ if err != nil {
372
+ if errors.Is(err, herdrcli.ErrWorkspaceNotFound) {
373
+ return "", false, nil
374
+ }
375
+ return "", false, err
376
+ }
377
+ ownedTabs := make(map[string]bool)
378
+ for _, tab := range tabs {
379
+ if tab.WorkspaceID == workspaceID && tab.Label == title {
380
+ ownedTabs[tab.ID] = true
381
+ }
382
+ }
383
+ candidates := make([]herdrcli.Pane, 0, len(panes))
384
+ for _, pane := range panes {
385
+ if pane.WorkspaceID != workspaceID || pane.ID == "" {
386
+ continue
387
+ }
388
+ if pane.Label == title || ownedTabs[pane.TabID] {
389
+ candidates = append(candidates, pane)
390
+ }
391
+ }
392
+ if len(candidates) == 0 {
393
+ return "", false, nil
394
+ }
395
+ sort.Slice(candidates, func(i, j int) bool { return candidates[i].ID < candidates[j].ID })
396
+ return candidates[0].ID, candidates[0].Label == title, nil
397
+ }
398
+
399
+ func (a *adapter) SendTerminal(ctx context.Context, terminal runner.Terminal, text string) error {
400
+ attrs := []any{"title", terminal.Title, "handle", terminal.ID}
401
+ logCall("send-terminal", attrs...)
402
+ if err := a.cli.RunPane(ctx, terminal.ID, text); err != nil {
403
+ logOutcome("send-terminal", "error", attrs...)
404
+ return err
405
+ }
406
+ logOutcome("send-terminal", "ok", attrs...)
407
+ return nil
408
+ }
409
+
410
+ // CloseTerminal closes one pane by handle and tolerates an already-closed
411
+ // pane.
412
+ func (a *adapter) CloseTerminal(ctx context.Context, terminal runner.Terminal) error {
413
+ attrs := []any{"title", terminal.Title, "handle", terminal.ID}
414
+ logCall("close-terminal", attrs...)
415
+ if terminal.ID == "" {
416
+ logOutcome("close-terminal", "absent", attrs...)
417
+ return nil
418
+ }
419
+ if err := a.cli.ClosePane(ctx, terminal.ID); err != nil {
420
+ if errors.Is(err, herdrcli.ErrPaneNotFound) {
421
+ logOutcome("close-terminal", "absent", attrs...)
422
+ return nil
423
+ }
424
+ logOutcome("close-terminal", "error", attrs...)
425
+ return err
426
+ }
427
+ logOutcome("close-terminal", "ok", attrs...)
428
+ return nil
429
+ }
430
+
431
+ // --- Cleanup ---
432
+
433
+ // CloseTerminals closes the run's node panes inside the ticket worktree
434
+ // workspace while preserving the workspace, the worktree, and any pane the
435
+ // user opened there. Run-owned panes carry the stable "<ticket>:" prefix on
436
+ // the pane label or its tab label.
437
+ func (a *adapter) CloseTerminals(ctx context.Context, spec runner.RunSpec) error {
438
+ attrs := []any{"ticket", spec.TicketKey, "runID", string(spec.RunID)}
439
+ logCall("close-terminals", attrs...)
440
+ workspaceID, found, err := a.ticketWorkspace(ctx, spec)
441
+ if err != nil {
442
+ logOutcome("close-terminals", "error", attrs...)
443
+ return err
444
+ }
445
+ if !found {
446
+ logOutcome("close-terminals", "no-environment", attrs...)
447
+ return nil
448
+ }
449
+ tabs, err := a.cli.ListTabs(ctx, workspaceID)
450
+ if err != nil {
451
+ if errors.Is(err, herdrcli.ErrWorkspaceNotFound) {
452
+ logOutcome("close-terminals", "no-environment", attrs...)
453
+ return nil
454
+ }
455
+ logOutcome("close-terminals", "error", attrs...)
456
+ return err
457
+ }
458
+ panes, err := a.cli.ListPanes(ctx, workspaceID)
459
+ if err != nil {
460
+ if errors.Is(err, herdrcli.ErrWorkspaceNotFound) {
461
+ logOutcome("close-terminals", "no-environment", attrs...)
462
+ return nil
463
+ }
464
+ logOutcome("close-terminals", "error", attrs...)
465
+ return err
466
+ }
467
+
468
+ prefix := spec.TicketKey + ":"
469
+ ownedTabs := make(map[string]bool)
470
+ for _, tab := range tabs {
471
+ if tab.WorkspaceID == workspaceID && strings.HasPrefix(tab.Label, prefix) {
472
+ ownedTabs[tab.ID] = true
473
+ }
474
+ }
475
+ closed := 0
476
+ for _, pane := range panes {
477
+ if pane.WorkspaceID != workspaceID || pane.ID == "" {
478
+ continue
479
+ }
480
+ if !strings.HasPrefix(pane.Label, prefix) && !ownedTabs[pane.TabID] {
481
+ continue
482
+ }
483
+ if err := a.CloseTerminal(ctx, runner.Terminal{ID: pane.ID, Title: pane.Label}); err != nil {
484
+ logOutcome("close-terminals", "error", attrs...)
485
+ return fmt.Errorf("herdr: close ticket pane %q: %w", pane.ID, err)
486
+ }
487
+ closed++
488
+ }
489
+ args := append([]any(nil), attrs...)
490
+ args = append(args, "closed", closed)
491
+ logOutcome("close-terminals", "ok", args...)
492
+ return nil
493
+ }
494
+
495
+ // CleanupRun releases the runner-owned resources for the run: node panes and
496
+ // the ticket workspace. The Git worktree, its branch, and its files are
497
+ // deliberately preserved; a later run reopens the same checkout.
498
+ func (a *adapter) CleanupRun(ctx context.Context, spec runner.RunSpec) error {
499
+ attrs := []any{"ticket", spec.TicketKey, "runID", string(spec.RunID)}
500
+ logCall("cleanup-run", attrs...)
501
+ if err := a.CloseTerminals(ctx, spec); err != nil {
502
+ logOutcome("cleanup-run", "error", attrs...)
503
+ return err
504
+ }
505
+ workspaceID, found, err := a.ticketWorkspace(ctx, spec)
506
+ if err != nil {
507
+ logOutcome("cleanup-run", "error", attrs...)
508
+ return err
509
+ }
510
+ if !found {
511
+ logOutcome("cleanup-run", "no-environment", attrs...)
512
+ return nil
513
+ }
514
+ if err := a.cli.CloseWorkspace(ctx, workspaceID); err != nil {
515
+ if errors.Is(err, herdrcli.ErrWorkspaceNotFound) {
516
+ logOutcome("cleanup-run", "no-environment", attrs...)
517
+ return nil
518
+ }
519
+ logOutcome("cleanup-run", "error", attrs...)
520
+ return err
521
+ }
522
+ logOutcome("cleanup-run", "ok", attrs...)
523
+ return nil
524
+ }
525
+
526
+ // --- Command rendering ---
527
+
528
+ // shellCommand renders the structured command as one shell line with env
529
+ // assignments. Binding the environment to the launch rather than to the pane
530
+ // keeps a reused pane from inheriting a previous run's values.
531
+ func shellCommand(command runner.Command) string {
532
+ var b strings.Builder
533
+ for _, key := range sortedKeys(command.Env) {
534
+ b.WriteString(key)
535
+ b.WriteString("=")
536
+ b.WriteString(shellQuote(command.Env[key]))
537
+ b.WriteString(" ")
538
+ }
539
+ b.WriteString(shellQuote(command.Executable))
540
+ for _, arg := range command.Args {
541
+ b.WriteString(" ")
542
+ b.WriteString(shellQuote(arg))
543
+ }
544
+ return b.String()
545
+ }
546
+
547
+ func shellQuote(value string) string {
548
+ return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
549
+ }
550
+
551
+ func sortedKeys(values map[string]string) []string {
552
+ keys := make([]string, 0, len(values))
553
+ for key := range values {
554
+ keys = append(keys, key)
555
+ }
556
+ sort.Strings(keys)
557
+ return keys
558
+ }
559
+
560
+ func normalizePath(path string) string {
561
+ if path == "" {
562
+ return ""
563
+ }
564
+ absolute, err := filepath.Abs(path)
565
+ if err != nil {
566
+ return filepath.Clean(path)
567
+ }
568
+ resolved, err := filepath.EvalSymlinks(absolute)
569
+ if err != nil {
570
+ if !errors.Is(err, os.ErrNotExist) {
571
+ return filepath.Clean(absolute)
572
+ }
573
+ return filepath.Clean(absolute)
574
+ }
575
+ return filepath.Clean(resolved)
576
+ }
577
+
578
+ var _ runner.Runner = (*adapter)(nil)