relay-flow 0.2.3-alpha → 0.2.5-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 (85) hide show
  1. package/README.md +121 -10
  2. package/cmd/relay-flow/backend_selection_test.go +149 -0
  3. package/cmd/relay-flow/main.go +96 -13
  4. package/cmd/relay-flow/pi_wiring_test.go +64 -0
  5. package/cmd/relay-flow/scenario_test.go +19 -2
  6. package/cmd/relay-flow/serve.go +100 -19
  7. package/cmd/relay-flow/serve_recovery_test.go +100 -0
  8. package/cmd/relay-flow/temporal_init.go +170 -0
  9. package/cmd/relay-flow/temporal_init_test.go +217 -0
  10. package/cmd/relay-flow/temporal_report_test.go +733 -0
  11. package/examples/beads-workflow.yaml +3 -3
  12. package/examples/config-reference.yaml +144 -0
  13. package/examples/minimal-beads-task-workflow.yaml +35 -0
  14. package/examples/minimal-jira-task-workflow.yaml +68 -0
  15. package/examples/workflow-reference.yaml +112 -0
  16. package/go.mod +37 -16
  17. package/go.sum +129 -61
  18. package/internal/config/machine.go +33 -1
  19. package/internal/config/machine_test.go +76 -0
  20. package/internal/execution/goworkflows/engine.go +13 -38
  21. package/internal/execution/goworkflows/projection.go +47 -464
  22. package/internal/execution/projection/projection.go +867 -0
  23. package/internal/execution/projection/projection_test.go +347 -0
  24. package/internal/execution/temporal/activities.go +567 -0
  25. package/internal/execution/temporal/engine.go +384 -0
  26. package/internal/execution/temporal/engine_test.go +277 -0
  27. package/internal/execution/temporal/interpreter.go +736 -0
  28. package/internal/execution/temporal/operations.go +455 -0
  29. package/internal/execution/temporal/operations_test.go +101 -0
  30. package/internal/execution/temporal/recovery.go +194 -0
  31. package/internal/execution/temporal/recovery_runtime.go +41 -0
  32. package/internal/execution/temporal/recovery_test.go +102 -0
  33. package/internal/execution/temporal/snapshot_restart_test.go +72 -0
  34. package/internal/execution/temporal/spike_test.go +934 -0
  35. package/internal/execution/temporal/visibility_lag_test.go +415 -0
  36. package/internal/harness/opencode/opencode.go +3 -1
  37. package/internal/harness/opencode/opencode_test.go +1 -1
  38. package/internal/harness/opencode/repo_setup.go +1 -1
  39. package/internal/harness/pi/config_test.go +46 -0
  40. package/internal/harness/pi/lifecycle_test.go +69 -0
  41. package/internal/harness/pi/pi.go +264 -0
  42. package/internal/harness/pi/pi_test.go +338 -0
  43. package/internal/harness/pi/prompt_test.go +150 -0
  44. package/internal/harness/pi/testdata/pi-0.84.1/capture.json +126 -0
  45. package/internal/harness/pi/testdata/pi-0.84.1/noninteractive-output.txt +11 -0
  46. package/internal/harness/pi/testdata/pi-0.84.1/tui-output-sanitized.txt +17 -0
  47. package/internal/harness/pi/validation_test.go +144 -0
  48. package/internal/runner/herdr/baseref.go +60 -0
  49. package/internal/runner/herdr/herdr.go +592 -0
  50. package/internal/runner/herdr/herdr_test.go +708 -0
  51. package/internal/runner/herdr/herdrcli/contract.go +128 -0
  52. package/internal/runner/herdr/herdrcli/exec.go +68 -0
  53. package/internal/runner/herdr/herdrcli/herdrcli_test.go +274 -0
  54. package/internal/runner/herdr/herdrcli/live_test.go +132 -0
  55. package/internal/runner/herdr/herdrcli/operations.go +281 -0
  56. package/internal/runner/herdr/herdrcli/response.go +116 -0
  57. package/internal/runner/herdr/herdrcli/testdata/empty-panes.json +1 -0
  58. package/internal/runner/herdr/herdrcli/testdata/empty-tabs.json +1 -0
  59. package/internal/runner/herdr/herdrcli/testdata/error-not-git-worktree.json +1 -0
  60. package/internal/runner/herdr/herdrcli/testdata/error-pane-not-found.json +1 -0
  61. package/internal/runner/herdr/herdrcli/testdata/error-workspace-not-found.json +1 -0
  62. package/internal/runner/herdr/herdrcli/testdata/error-worktree-not-found.json +1 -0
  63. package/internal/runner/herdr/herdrcli/testdata/malformed.json +1 -0
  64. package/internal/runner/herdr/herdrcli/testdata/pane-close.json +6 -0
  65. package/internal/runner/herdr/herdrcli/testdata/pane-get.json +25 -0
  66. package/internal/runner/herdr/herdrcli/testdata/pane-list.json +45 -0
  67. package/internal/runner/herdr/herdrcli/testdata/pane-process-info-shell.json +22 -0
  68. package/internal/runner/herdr/herdrcli/testdata/pane-process-info.json +23 -0
  69. package/internal/runner/herdr/herdrcli/testdata/pane-rename.json +23 -0
  70. package/internal/runner/herdr/herdrcli/testdata/snapshot.json +213 -0
  71. package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +175 -0
  72. package/internal/runner/herdr/herdrcli/testdata/tab-create.json +31 -0
  73. package/internal/runner/herdr/herdrcli/testdata/tab-list.json +26 -0
  74. package/internal/runner/herdr/herdrcli/testdata/workspace-close.json +6 -0
  75. package/internal/runner/herdr/herdrcli/testdata/worktree-create.json +58 -0
  76. package/internal/runner/herdr/herdrcli/testdata/worktree-list.json +35 -0
  77. package/internal/runner/herdr/herdrcli/testdata/worktree-open.json +59 -0
  78. package/internal/runner/orca/orca.go +33 -0
  79. package/internal/runner/orca/orca_test.go +33 -4
  80. package/internal/runner/runner.go +8 -0
  81. package/internal/task/beads/beads.go +0 -16
  82. package/internal/task/beads/config_compatibility_test.go +7 -8
  83. package/internal/task/jira/filters_test.go +32 -6
  84. package/internal/task/jira/jira.go +27 -17
  85. package/package.json +1 -1
@@ -0,0 +1,592 @@
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
+ // DiscoverTerminal finds an existing live pane by its stable title during
210
+ // explicit projection recovery. It never creates a workspace or pane.
211
+ func (a *adapter) DiscoverTerminal(ctx context.Context, spec runner.RunSpec, title string) (runner.Terminal, bool, error) {
212
+ workspaceID, found, err := a.ticketWorkspace(ctx, spec)
213
+ if err != nil || !found {
214
+ return runner.Terminal{}, false, err
215
+ }
216
+ handle, _, err := a.recoverTerminal(ctx, workspaceID, title)
217
+ if err != nil || handle == "" {
218
+ return runner.Terminal{}, false, err
219
+ }
220
+ return a.FindTerminal(ctx, runner.Terminal{ID: handle, Title: title})
221
+ }
222
+
223
+ // FindTerminal addresses a persisted pane handle directly and returns it only
224
+ // when Herdr still has a live agent process in that pane. A pane restored as a
225
+ // bare shell after a Herdr restart is not a usable agent terminal.
226
+ func (a *adapter) FindTerminal(ctx context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
227
+ attrs := []any{"title", terminal.Title, "handle", terminal.ID}
228
+ logCall("find-terminal", attrs...)
229
+ if terminal.ID == "" {
230
+ logOutcome("find-terminal", "absent", attrs...)
231
+ return runner.Terminal{}, false, nil
232
+ }
233
+ pane, err := a.cli.GetPane(ctx, terminal.ID)
234
+ if errors.Is(err, herdrcli.ErrPaneNotFound) {
235
+ logOutcome("find-terminal", "absent", attrs...)
236
+ return runner.Terminal{}, false, nil
237
+ }
238
+ if err != nil {
239
+ logOutcome("find-terminal", "error", attrs...)
240
+ return runner.Terminal{}, false, err
241
+ }
242
+ if pane.ID != terminal.ID || pane.TerminalID == "" {
243
+ logOutcome("find-terminal", "absent", attrs...)
244
+ return runner.Terminal{}, false, nil
245
+ }
246
+ info, err := a.cli.ProcessInfo(ctx, pane.ID)
247
+ if errors.Is(err, herdrcli.ErrPaneNotFound) {
248
+ logOutcome("find-terminal", "absent", attrs...)
249
+ return runner.Terminal{}, false, nil
250
+ }
251
+ if err != nil {
252
+ logOutcome("find-terminal", "error", attrs...)
253
+ return runner.Terminal{}, false, err
254
+ }
255
+ if !hasLiveForegroundProcess(info) {
256
+ logOutcome("find-terminal", "absent", attrs...)
257
+ return runner.Terminal{}, false, nil
258
+ }
259
+ logOutcome("find-terminal", "found", attrs...)
260
+ return runner.Terminal{ID: pane.ID, Title: terminal.Title}, true, nil
261
+ }
262
+
263
+ // hasLiveForegroundProcess reports whether the pane runs something other than
264
+ // its own shell. Herdr reports the shell itself in the foreground when a pane
265
+ // is idle or was restored after a restart.
266
+ func hasLiveForegroundProcess(info herdrcli.ProcessInfo) bool {
267
+ if info.PaneID == "" || info.ShellPID == nil {
268
+ return false
269
+ }
270
+ for _, process := range info.ForegroundProcesses {
271
+ if process.PID != *info.ShellPID {
272
+ return true
273
+ }
274
+ }
275
+ return false
276
+ }
277
+
278
+ // CreateTerminal always creates a node pane; it performs no label discovery.
279
+ func (a *adapter) CreateTerminal(ctx context.Context, env runner.Environment, title string, command runner.Command) (runner.Terminal, error) {
280
+ attrs := []any{"envID", env.ID, "title", title}
281
+ logCall("create-terminal", attrs...)
282
+ _, pane, err := a.cli.CreateTab(ctx, env.ID, env.Path, title)
283
+ if err != nil {
284
+ logOutcome("create-terminal", "error", attrs...)
285
+ return runner.Terminal{}, err
286
+ }
287
+ if pane.ID == "" {
288
+ logOutcome("create-terminal", "error", attrs...)
289
+ return runner.Terminal{}, fmt.Errorf("herdr: tab create returned an empty root pane ID")
290
+ }
291
+ if err := a.startNode(ctx, pane.ID, title, command, false); err != nil {
292
+ logOutcome("create-terminal", "error", attrs...)
293
+ return runner.Terminal{}, err
294
+ }
295
+ logOutcome("create-terminal", "ok", attrs...)
296
+ return runner.Terminal{ID: pane.ID, Title: title}, nil
297
+ }
298
+
299
+ // startNode applies the stable pane label and submits the harness command.
300
+ // A pane that cannot be prepared is closed so no half-built node pane is left
301
+ // behind for label recovery to adopt.
302
+ func (a *adapter) startNode(ctx context.Context, paneID, title string, command runner.Command, labelled bool) error {
303
+ if !labelled {
304
+ if err := a.cli.RenamePane(ctx, paneID, title); err != nil {
305
+ _ = a.cli.ClosePane(ctx, paneID)
306
+ return err
307
+ }
308
+ }
309
+ if err := a.cli.RunPane(ctx, paneID, shellCommand(command)); err != nil {
310
+ _ = a.cli.ClosePane(ctx, paneID)
311
+ return err
312
+ }
313
+ return nil
314
+ }
315
+
316
+ // EnsureTerminal reuses a live pane, then recovers a pane whose creation was
317
+ // acknowledged but not persisted, and only then creates a replacement.
318
+ func (a *adapter) EnsureTerminal(ctx context.Context, env runner.Environment, stored runner.Terminal, title string, command runner.Command) (runner.Terminal, error) {
319
+ attrs := []any{"envID", env.ID, "title", title}
320
+ logCall("ensure-terminal", attrs...)
321
+ if terminal, ok, err := a.FindTerminal(ctx, stored); err != nil {
322
+ logOutcome("ensure-terminal", "error", attrs...)
323
+ return runner.Terminal{}, err
324
+ } else if ok {
325
+ logOutcome("ensure-terminal", "exists", attrs...)
326
+ return terminal, nil
327
+ }
328
+ if stored.ID != "" {
329
+ if err := a.CloseTerminal(ctx, runner.Terminal{ID: stored.ID, Title: title}); err != nil {
330
+ logOutcome("ensure-terminal", "error", attrs...)
331
+ return runner.Terminal{}, err
332
+ }
333
+ }
334
+
335
+ // A create acknowledgement can be lost before the pane handle is
336
+ // persisted. The tab label is the recovery marker until pane rename
337
+ // completes; the pane label is used afterward.
338
+ recovered, labelled, err := a.recoverTerminal(ctx, env.ID, title)
339
+ if err != nil {
340
+ logOutcome("ensure-terminal", "error", attrs...)
341
+ return runner.Terminal{}, err
342
+ }
343
+ if recovered != "" && recovered != stored.ID {
344
+ if terminal, ok, err := a.FindTerminal(ctx, runner.Terminal{ID: recovered, Title: title}); err != nil {
345
+ logOutcome("ensure-terminal", "error", attrs...)
346
+ return runner.Terminal{}, err
347
+ } else if ok {
348
+ logOutcome("ensure-terminal", "exists", attrs...)
349
+ return terminal, nil
350
+ }
351
+ // The recovered pane is the root shell left between tab creation and
352
+ // command submission: finish that launch instead of duplicating it.
353
+ if err := a.startNode(ctx, recovered, title, command, labelled); err != nil {
354
+ logOutcome("ensure-terminal", "error", attrs...)
355
+ return runner.Terminal{}, err
356
+ }
357
+ logOutcome("ensure-terminal", "exists", attrs...)
358
+ return runner.Terminal{ID: recovered, Title: title}, nil
359
+ }
360
+
361
+ terminal, err := a.CreateTerminal(ctx, env, title, command)
362
+ if err != nil {
363
+ logOutcome("ensure-terminal", "error", attrs...)
364
+ return runner.Terminal{}, err
365
+ }
366
+ logOutcome("ensure-terminal", "created", attrs...)
367
+ return terminal, nil
368
+ }
369
+
370
+ // recoverTerminal finds a pane belonging to the node's stable label, either
371
+ // directly or through its tab. labelled reports whether the pane already
372
+ // carries the node label.
373
+ func (a *adapter) recoverTerminal(ctx context.Context, workspaceID, title string) (string, bool, error) {
374
+ if workspaceID == "" || title == "" {
375
+ return "", false, nil
376
+ }
377
+ tabs, err := a.cli.ListTabs(ctx, workspaceID)
378
+ if err != nil {
379
+ if errors.Is(err, herdrcli.ErrWorkspaceNotFound) {
380
+ return "", false, nil
381
+ }
382
+ return "", false, err
383
+ }
384
+ panes, err := a.cli.ListPanes(ctx, workspaceID)
385
+ if err != nil {
386
+ if errors.Is(err, herdrcli.ErrWorkspaceNotFound) {
387
+ return "", false, nil
388
+ }
389
+ return "", false, err
390
+ }
391
+ ownedTabs := make(map[string]bool)
392
+ for _, tab := range tabs {
393
+ if tab.WorkspaceID == workspaceID && tab.Label == title {
394
+ ownedTabs[tab.ID] = true
395
+ }
396
+ }
397
+ candidates := make([]herdrcli.Pane, 0, len(panes))
398
+ for _, pane := range panes {
399
+ if pane.WorkspaceID != workspaceID || pane.ID == "" {
400
+ continue
401
+ }
402
+ if pane.Label == title || ownedTabs[pane.TabID] {
403
+ candidates = append(candidates, pane)
404
+ }
405
+ }
406
+ if len(candidates) == 0 {
407
+ return "", false, nil
408
+ }
409
+ sort.Slice(candidates, func(i, j int) bool { return candidates[i].ID < candidates[j].ID })
410
+ return candidates[0].ID, candidates[0].Label == title, nil
411
+ }
412
+
413
+ func (a *adapter) SendTerminal(ctx context.Context, terminal runner.Terminal, text string) error {
414
+ attrs := []any{"title", terminal.Title, "handle", terminal.ID}
415
+ logCall("send-terminal", attrs...)
416
+ if err := a.cli.RunPane(ctx, terminal.ID, text); err != nil {
417
+ logOutcome("send-terminal", "error", attrs...)
418
+ return err
419
+ }
420
+ logOutcome("send-terminal", "ok", attrs...)
421
+ return nil
422
+ }
423
+
424
+ // CloseTerminal closes one pane by handle and tolerates an already-closed
425
+ // pane.
426
+ func (a *adapter) CloseTerminal(ctx context.Context, terminal runner.Terminal) error {
427
+ attrs := []any{"title", terminal.Title, "handle", terminal.ID}
428
+ logCall("close-terminal", attrs...)
429
+ if terminal.ID == "" {
430
+ logOutcome("close-terminal", "absent", attrs...)
431
+ return nil
432
+ }
433
+ if err := a.cli.ClosePane(ctx, terminal.ID); err != nil {
434
+ if errors.Is(err, herdrcli.ErrPaneNotFound) {
435
+ logOutcome("close-terminal", "absent", attrs...)
436
+ return nil
437
+ }
438
+ logOutcome("close-terminal", "error", attrs...)
439
+ return err
440
+ }
441
+ logOutcome("close-terminal", "ok", attrs...)
442
+ return nil
443
+ }
444
+
445
+ // --- Cleanup ---
446
+
447
+ // CloseTerminals closes the run's node panes inside the ticket worktree
448
+ // workspace while preserving the workspace, the worktree, and any pane the
449
+ // user opened there. Run-owned panes carry the stable "<ticket>:" prefix on
450
+ // the pane label or its tab label.
451
+ func (a *adapter) CloseTerminals(ctx context.Context, spec runner.RunSpec) error {
452
+ attrs := []any{"ticket", spec.TicketKey, "runID", string(spec.RunID)}
453
+ logCall("close-terminals", attrs...)
454
+ workspaceID, found, err := a.ticketWorkspace(ctx, spec)
455
+ if err != nil {
456
+ logOutcome("close-terminals", "error", attrs...)
457
+ return err
458
+ }
459
+ if !found {
460
+ logOutcome("close-terminals", "no-environment", attrs...)
461
+ return nil
462
+ }
463
+ tabs, err := a.cli.ListTabs(ctx, workspaceID)
464
+ if err != nil {
465
+ if errors.Is(err, herdrcli.ErrWorkspaceNotFound) {
466
+ logOutcome("close-terminals", "no-environment", attrs...)
467
+ return nil
468
+ }
469
+ logOutcome("close-terminals", "error", attrs...)
470
+ return err
471
+ }
472
+ panes, err := a.cli.ListPanes(ctx, workspaceID)
473
+ if err != nil {
474
+ if errors.Is(err, herdrcli.ErrWorkspaceNotFound) {
475
+ logOutcome("close-terminals", "no-environment", attrs...)
476
+ return nil
477
+ }
478
+ logOutcome("close-terminals", "error", attrs...)
479
+ return err
480
+ }
481
+
482
+ prefix := spec.TicketKey + ":"
483
+ ownedTabs := make(map[string]bool)
484
+ for _, tab := range tabs {
485
+ if tab.WorkspaceID == workspaceID && strings.HasPrefix(tab.Label, prefix) {
486
+ ownedTabs[tab.ID] = true
487
+ }
488
+ }
489
+ closed := 0
490
+ for _, pane := range panes {
491
+ if pane.WorkspaceID != workspaceID || pane.ID == "" {
492
+ continue
493
+ }
494
+ if !strings.HasPrefix(pane.Label, prefix) && !ownedTabs[pane.TabID] {
495
+ continue
496
+ }
497
+ if err := a.CloseTerminal(ctx, runner.Terminal{ID: pane.ID, Title: pane.Label}); err != nil {
498
+ logOutcome("close-terminals", "error", attrs...)
499
+ return fmt.Errorf("herdr: close ticket pane %q: %w", pane.ID, err)
500
+ }
501
+ closed++
502
+ }
503
+ args := append([]any(nil), attrs...)
504
+ args = append(args, "closed", closed)
505
+ logOutcome("close-terminals", "ok", args...)
506
+ return nil
507
+ }
508
+
509
+ // CleanupRun releases the runner-owned resources for the run: node panes and
510
+ // the ticket workspace. The Git worktree, its branch, and its files are
511
+ // deliberately preserved; a later run reopens the same checkout.
512
+ func (a *adapter) CleanupRun(ctx context.Context, spec runner.RunSpec) error {
513
+ attrs := []any{"ticket", spec.TicketKey, "runID", string(spec.RunID)}
514
+ logCall("cleanup-run", attrs...)
515
+ if err := a.CloseTerminals(ctx, spec); err != nil {
516
+ logOutcome("cleanup-run", "error", attrs...)
517
+ return err
518
+ }
519
+ workspaceID, found, err := a.ticketWorkspace(ctx, spec)
520
+ if err != nil {
521
+ logOutcome("cleanup-run", "error", attrs...)
522
+ return err
523
+ }
524
+ if !found {
525
+ logOutcome("cleanup-run", "no-environment", attrs...)
526
+ return nil
527
+ }
528
+ if err := a.cli.CloseWorkspace(ctx, workspaceID); err != nil {
529
+ if errors.Is(err, herdrcli.ErrWorkspaceNotFound) {
530
+ logOutcome("cleanup-run", "no-environment", attrs...)
531
+ return nil
532
+ }
533
+ logOutcome("cleanup-run", "error", attrs...)
534
+ return err
535
+ }
536
+ logOutcome("cleanup-run", "ok", attrs...)
537
+ return nil
538
+ }
539
+
540
+ // --- Command rendering ---
541
+
542
+ // shellCommand renders the structured command as one shell line with env
543
+ // assignments. Binding the environment to the launch rather than to the pane
544
+ // keeps a reused pane from inheriting a previous run's values.
545
+ func shellCommand(command runner.Command) string {
546
+ var b strings.Builder
547
+ for _, key := range sortedKeys(command.Env) {
548
+ b.WriteString(key)
549
+ b.WriteString("=")
550
+ b.WriteString(shellQuote(command.Env[key]))
551
+ b.WriteString(" ")
552
+ }
553
+ b.WriteString(shellQuote(command.Executable))
554
+ for _, arg := range command.Args {
555
+ b.WriteString(" ")
556
+ b.WriteString(shellQuote(arg))
557
+ }
558
+ return b.String()
559
+ }
560
+
561
+ func shellQuote(value string) string {
562
+ return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
563
+ }
564
+
565
+ func sortedKeys(values map[string]string) []string {
566
+ keys := make([]string, 0, len(values))
567
+ for key := range values {
568
+ keys = append(keys, key)
569
+ }
570
+ sort.Strings(keys)
571
+ return keys
572
+ }
573
+
574
+ func normalizePath(path string) string {
575
+ if path == "" {
576
+ return ""
577
+ }
578
+ absolute, err := filepath.Abs(path)
579
+ if err != nil {
580
+ return filepath.Clean(path)
581
+ }
582
+ resolved, err := filepath.EvalSymlinks(absolute)
583
+ if err != nil {
584
+ if !errors.Is(err, os.ErrNotExist) {
585
+ return filepath.Clean(absolute)
586
+ }
587
+ return filepath.Clean(absolute)
588
+ }
589
+ return filepath.Clean(resolved)
590
+ }
591
+
592
+ var _ runner.Runner = (*adapter)(nil)