relay-flow 0.2.2-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 +154 -13
- package/cmd/relay-flow/commands_test.go +5 -1
- package/cmd/relay-flow/main.go +27 -1
- package/cmd/relay-flow/pi_wiring_test.go +64 -0
- package/cmd/relay-flow/serve.go +14 -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/execution/goworkflows/activities.go +19 -0
- package/internal/execution/goworkflows/engine.go +20 -0
- package/internal/execution/goworkflows/engine_test.go +1 -1
- package/internal/execution/goworkflows/fakes_test.go +34 -6
- package/internal/execution/goworkflows/interpreter.go +48 -1
- package/internal/execution/goworkflows/projection.go +52 -11
- package/internal/execution/goworkflows/recovery_test.go +129 -0
- package/internal/harness/opencode/opencode.go +4 -4
- package/internal/harness/opencode/opencode_test.go +59 -4
- package/internal/harness/opencode/repo_setup.go +42 -2
- 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/identity/identity.go +28 -1
- package/internal/identity/identity_test.go +40 -0
- package/internal/run/manager.go +193 -25
- package/internal/run/run.go +24 -6
- package/internal/run/run_manager_test.go +100 -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/server/api_test.go +54 -0
- package/internal/server/client.go +11 -0
- package/internal/server/fixture_test.go +18 -0
- package/internal/server/server.go +16 -0
- package/internal/task/beads/beads.go +16 -16
- package/internal/task/beads/config_compatibility_test.go +7 -8
- package/internal/task/beads/status_compatibility_test.go +43 -0
- package/internal/task/jira/filters_test.go +32 -6
- package/internal/task/jira/helpers_test.go +3 -0
- package/internal/task/jira/jira.go +43 -17
- package/internal/task/jira/transition_defaults_test.go +43 -0
- package/internal/task/task.go +8 -0
- 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)
|