relay-flow 0.2.11-alpha → 0.3.1-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.
@@ -162,7 +162,8 @@ func (a *Activities) LoadNodeRuntime(ctx context.Context, id run.ID, node string
162
162
 
163
163
  // EnsureNodeRuntime uses only persisted terminal/session IDs on the normal
164
164
  // path. A live terminal is rebound to the new visit; otherwise EnsureTerminal
165
- // creates a replacement and its direct ID is persisted immediately.
165
+ // creates a replacement and its direct ID is persisted immediately. A stored
166
+ // session receives feedback whether its terminal is reused or replaced.
166
167
  func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, repoPath string, spec harness.LaunchSpec, rt NodeRuntime) (NodeRuntime, error) {
167
168
  a.runtimeMu.Lock()
168
169
  defer a.runtimeMu.Unlock()
@@ -204,6 +205,10 @@ func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, rep
204
205
  rt.TerminalID = currentRuntime.TerminalID
205
206
  rt.SessionID = currentRuntime.SessionID
206
207
  spec.ResumeID = rt.SessionID
208
+ promptKind := harness.PromptInitial
209
+ if rt.SessionID != "" {
210
+ promptKind = harness.PromptFeedback
211
+ }
207
212
  stored := runner.Terminal{ID: rt.TerminalID, Title: spec.Title}
208
213
  terminal, live, err := a.Runner.FindTerminal(ctx, stored)
209
214
  if err != nil {
@@ -218,7 +223,7 @@ func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, rep
218
223
  // Same-visit retry/restart is silent: do not render, build, or send.
219
224
  return rt, nil
220
225
  }
221
- prompt, err := a.Harness.RenderPrompt(harness.PromptFeedback, spec.PromptData, spec.NudgePrompt)
226
+ prompt, err := a.Harness.RenderPrompt(promptKind, spec.PromptData, spec.NudgePrompt)
222
227
  if err != nil {
223
228
  return NodeRuntime{}, err
224
229
  }
@@ -232,14 +237,14 @@ func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, rep
232
237
  }
233
238
  }
234
239
 
235
- // An initial or replacement terminal resumes the stored session and
236
- // receives the rendered initial prompt. Same-visit replacements omit the
237
- // node nudge; a new visit includes it.
240
+ // A fresh session receives the initial prompt; a stored session receives
241
+ // feedback even when its terminal must be replaced. Same-visit
242
+ // replacements omit the node nudge; a new visit includes it.
238
243
  nudge := ""
239
244
  if rt.NodeVisitID == "" || revisit {
240
245
  nudge = spec.NudgePrompt
241
246
  }
242
- spec.Prompt, err = a.Harness.RenderPrompt(harness.PromptInitial, spec.PromptData, nudge)
247
+ spec.Prompt, err = a.Harness.RenderPrompt(promptKind, spec.PromptData, nudge)
243
248
  if err != nil {
244
249
  return NodeRuntime{}, err
245
250
  }
@@ -0,0 +1,147 @@
1
+ package temporal
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "testing"
7
+ "time"
8
+
9
+ "github.com/rajpopat27/relay-flow/internal/run"
10
+ "github.com/rajpopat27/relay-flow/internal/runner"
11
+ "github.com/rajpopat27/relay-flow/internal/task"
12
+ "github.com/rajpopat27/relay-flow/internal/workflow"
13
+ "go.temporal.io/sdk/testsuite"
14
+ temporalworkflow "go.temporal.io/sdk/workflow"
15
+ )
16
+
17
+ // The workflow uses a start-to-end graph so the test isolates end cleanup;
18
+ // terminalLive models the persisted node terminal that finalization would close.
19
+ type endCleanupActivities struct {
20
+ events []string
21
+ cleanupDirty bool
22
+ terminalLive bool
23
+ completionObserved bool
24
+ completionObservedOnFailure bool
25
+ }
26
+
27
+ func (a *endCleanupActivities) EnsureMailboxes(context.Context, run.Work, []task.MailboxSpec) (map[string]task.Mailbox, error) {
28
+ return map[string]task.Mailbox{}, nil
29
+ }
30
+
31
+ func (a *endCleanupActivities) ValidateAgents(context.Context, string, []string) error {
32
+ return nil
33
+ }
34
+
35
+ func (a *endCleanupActivities) ProjectionUpsertStep(context.Context, run.StepEntry) error {
36
+ return nil
37
+ }
38
+
39
+ func (a *endCleanupActivities) ApplyTaskConfig(context.Context, run.Work, string, *task.Mailbox, map[string]any) error {
40
+ return nil
41
+ }
42
+
43
+ func (a *endCleanupActivities) EnsureEnvironment(context.Context, run.Work, string) (runner.Environment, error) {
44
+ return runner.Environment{}, nil
45
+ }
46
+
47
+ func (a *endCleanupActivities) SetEnvironmentStatus(context.Context, run.Work, string, string) error {
48
+ return nil
49
+ }
50
+
51
+ func (a *endCleanupActivities) CleanupRun(context.Context, run.Work, string) error {
52
+ if a.cleanupDirty {
53
+ a.cleanupDirty = false
54
+ a.completionObservedOnFailure = a.completionObserved
55
+ if a.terminalLive {
56
+ a.events = append(a.events, "cleanup-failed-terminal-live")
57
+ } else {
58
+ a.events = append(a.events, "cleanup-failed-terminal-closed")
59
+ }
60
+ return errors.New("ticket checkout is dirty; commit required before runner cleanup")
61
+ }
62
+ a.events = append(a.events, "cleanup")
63
+ return nil
64
+ }
65
+
66
+ func (a *endCleanupActivities) FinalizeNodeRuntimes(context.Context, run.Work, string, run.RuntimePolicy) error {
67
+ if a.terminalLive {
68
+ a.terminalLive = false
69
+ a.events = append(a.events, "finalize-terminal-closed")
70
+ } else {
71
+ a.events = append(a.events, "finalize-terminal-already-closed")
72
+ }
73
+ return nil
74
+ }
75
+
76
+ func (a *endCleanupActivities) ProjectionUpdateRetry(context.Context, run.ID, *run.RetryStatus) error {
77
+ return nil
78
+ }
79
+
80
+ func (a *endCleanupActivities) ProjectionUpdateState(_ context.Context, _ run.ID, state run.State, _ string, _ *time.Time) error {
81
+ if state == run.StateCompleted {
82
+ a.completionObserved = true
83
+ if a.terminalLive {
84
+ a.events = append(a.events, "completed-terminal-live")
85
+ } else {
86
+ a.events = append(a.events, "completed")
87
+ }
88
+ }
89
+ return nil
90
+ }
91
+
92
+ func endCleanupOrderingWorkflow(ctx temporalworkflow.Context, start run.Start) error {
93
+ state := newWorkflowState(ctx, start)
94
+ reason := "canceled"
95
+ return runGraph(ctx, start, state, temporalworkflow.GetSignalChannel(ctx, cancelReasonSignalName), &reason)
96
+ }
97
+
98
+ func TestTemporalEndCleanupRunsBeforeRuntimeFinalization(t *testing.T) {
99
+ for _, cleanup := range []bool{true, false} {
100
+ t.Run(map[bool]string{true: "enabled", false: "disabled"}[cleanup], func(t *testing.T) {
101
+ activities := &endCleanupActivities{cleanupDirty: cleanup, terminalLive: true}
102
+ var suite testsuite.WorkflowTestSuite
103
+ env := suite.NewTestWorkflowEnvironment()
104
+ env.RegisterWorkflow(endCleanupOrderingWorkflow)
105
+ env.RegisterActivity(activities)
106
+ start := run.Start{
107
+ ID: "repo/cleanup/T-1", Repo: "repo", RepoPath: t.TempDir(),
108
+ Workflow: workflow.Workflow{
109
+ Name: "cleanup", CleanupRunnerOnEnd: cleanup,
110
+ Nodes: map[string]workflow.Node{
111
+ "start": {OnSuccess: []workflow.Route{{Target: "end"}}},
112
+ "end": {},
113
+ },
114
+ },
115
+ Ticket: task.TicketRef{ID: "T-1", Key: "T-1"},
116
+ }
117
+ env.ExecuteWorkflow(endCleanupOrderingWorkflow, start)
118
+ if err := env.GetWorkflowError(); err != nil {
119
+ t.Fatalf("workflow error: %v", err)
120
+ }
121
+ if cleanup {
122
+ if activities.completionObservedOnFailure {
123
+ t.Fatal("run was marked completed while dirty cleanup was retrying")
124
+ }
125
+ want := []string{"cleanup-failed-terminal-live", "cleanup", "finalize-terminal-closed", "completed"}
126
+ if len(activities.events) != len(want) {
127
+ t.Fatalf("activity order = %v, want %v", activities.events, want)
128
+ }
129
+ for i := range want {
130
+ if activities.events[i] != want[i] {
131
+ t.Fatalf("activity order = %v, want %v", activities.events, want)
132
+ }
133
+ }
134
+ } else {
135
+ want := []string{"finalize-terminal-closed", "completed"}
136
+ if len(activities.events) != len(want) {
137
+ t.Fatalf("cleanup-disabled activity order = %v, want %v", activities.events, want)
138
+ }
139
+ for i := range want {
140
+ if activities.events[i] != want[i] {
141
+ t.Fatalf("cleanup-disabled activity order = %v, want %v", activities.events, want)
142
+ }
143
+ }
144
+ }
145
+ })
146
+ }
147
+ }
@@ -597,6 +597,17 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
597
597
  if wf.CleanupRunnerOnEnd {
598
598
  finalPolicy.KeepTerminalsAlive = false
599
599
  }
600
+ // Cleanup must run before finalizing node runtimes when enabled. The
601
+ // runner performs its Git cleanliness check before closing terminals, so a
602
+ // dirty checkout leaves the agent terminal available for a commit while
603
+ // retryActivity waits.
604
+ if wf.CleanupRunnerOnEnd {
605
+ if _, err := retryActivity(ctx, state, work, "", func() (struct{}, error) {
606
+ return executeActivity[struct{}](ctx, activityCleanupRun, work, start.RepoPath)
607
+ }); err != nil {
608
+ return err
609
+ }
610
+ }
600
611
  if _, err := retryActivity(ctx, state, work, "", func() (struct{}, error) {
601
612
  return executeActivity[struct{}](ctx, activityFinalizeNodeRuntimes, work, start.RepoPath, finalPolicy)
602
613
  }); err != nil {
@@ -605,13 +616,6 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
605
616
  for node := range state.bindings {
606
617
  applyRuntimePolicy(state, node, finalPolicy)
607
618
  }
608
- if wf.CleanupRunnerOnEnd {
609
- if _, err := retryActivity(ctx, state, work, "", func() (struct{}, error) {
610
- return executeActivity[struct{}](ctx, activityCleanupRun, work, start.RepoPath)
611
- }); err != nil {
612
- return err
613
- }
614
- }
615
619
  now := temporalworkflow.Now(ctx).UTC()
616
620
  endStep.Status, endStep.FinishedAt = run.StepSucceeded, &now
617
621
  if _, err := retryActivity(ctx, state, work, "end", func() (struct{}, error) {
@@ -88,7 +88,7 @@ func init() {
88
88
  type Harness struct {
89
89
  templates Config
90
90
  // listAgents is the test seam; nil → real `opencode agent list`.
91
- listAgents func(ctx context.Context) ([]string, error)
91
+ listAgents func(ctx context.Context, repoPath string) ([]string, error)
92
92
  }
93
93
 
94
94
  // New returns the production Harness.
@@ -110,7 +110,7 @@ func (h *Harness) SetupRepo(_ context.Context, repoPath string) error {
110
110
  // ValidateAgent reports whether name is a known OpenCode agent for the
111
111
  // repo, per `opencode agent list` (agent names are the unindented first
112
112
  // tokens).
113
- func (h *Harness) ValidateAgent(ctx context.Context, _ string, agent string) error {
113
+ func (h *Harness) ValidateAgent(ctx context.Context, repoPath, agent string) error {
114
114
  if agent == "" {
115
115
  return fmt.Errorf("opencode: agent name is empty")
116
116
  }
@@ -118,7 +118,7 @@ func (h *Harness) ValidateAgent(ctx context.Context, _ string, agent string) err
118
118
  if list == nil {
119
119
  list = listAgents
120
120
  }
121
- names, err := list(ctx)
121
+ names, err := list(ctx, repoPath)
122
122
  if err != nil {
123
123
  return err
124
124
  }
@@ -236,8 +236,10 @@ func relayFlowHome() (string, error) {
236
236
 
237
237
  // listAgents runs `opencode agent list` and returns the agent names
238
238
  // (the unindented lines' first tokens).
239
- func listAgents(ctx context.Context) ([]string, error) {
240
- out, err := exec.CommandContext(ctx, "opencode", "agent", "list").Output()
239
+ func listAgents(ctx context.Context, repoPath string) ([]string, error) {
240
+ cmd := exec.CommandContext(ctx, "opencode", "agent", "list")
241
+ cmd.Dir = repoPath
242
+ out, err := cmd.Output()
241
243
  if err != nil {
242
244
  return nil, fmt.Errorf("opencode agent list: %w", err)
243
245
  }
@@ -14,7 +14,7 @@ import (
14
14
  "github.com/rajpopat27/relay-flow/internal/workflow"
15
15
  )
16
16
 
17
- const configuredPlugin = "relay-flow-plugin@0.2.11-alpha"
17
+ const configuredPlugin = "relay-flow-plugin@0.3.1-alpha"
18
18
 
19
19
  func TestBuildCommandArgv(t *testing.T) {
20
20
  t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
@@ -10,7 +10,7 @@ import (
10
10
  "github.com/rajpopat27/relay-flow/internal/config"
11
11
  )
12
12
 
13
- const relayFlowPlugin = "relay-flow-plugin@0.2.11-alpha"
13
+ const relayFlowPlugin = "relay-flow-plugin@0.3.1-alpha"
14
14
 
15
15
  type jsoncToken struct {
16
16
  kind byte
@@ -0,0 +1,106 @@
1
+ package opencode
2
+
3
+ import (
4
+ "context"
5
+ "os"
6
+ "path/filepath"
7
+ "runtime"
8
+ "strings"
9
+ "testing"
10
+ )
11
+
12
+ func TestValidateAgentPassesRepositoryToListAgentsSeam(t *testing.T) {
13
+ repoPath := t.TempDir()
14
+ var gotRepoPath string
15
+ h := New()
16
+ h.listAgents = func(_ context.Context, path string) ([]string, error) {
17
+ gotRepoPath = path
18
+ return []string{"build"}, nil
19
+ }
20
+
21
+ if err := h.ValidateAgent(context.Background(), repoPath, "build"); err != nil {
22
+ t.Fatalf("ValidateAgent: %v", err)
23
+ }
24
+ if gotRepoPath != repoPath {
25
+ t.Fatalf("listAgents repoPath = %q, want %q", gotRepoPath, repoPath)
26
+ }
27
+ }
28
+
29
+ func TestValidateAgentUsesRepositoryWorkingDirectory(t *testing.T) {
30
+ if runtime.GOOS == "windows" {
31
+ t.Skip("fake opencode uses a POSIX shell")
32
+ }
33
+
34
+ repoA := t.TempDir()
35
+ repoB := t.TempDir()
36
+ fakeDir := t.TempDir()
37
+ fake := filepath.Join(fakeDir, "opencode")
38
+ script := `#!/bin/sh
39
+ set -eu
40
+ [ "$#" -eq 2 ] && [ "$1" = "agent" ] && [ "$2" = "list" ] || exit 2
41
+ actual=$(pwd)
42
+ case "$actual" in
43
+ "$OPENCODE_REPO_A")
44
+ printf 'repo-a-agent details\n'
45
+ ;;
46
+ "$OPENCODE_REPO_B")
47
+ printf 'repo-b-agent details\n'
48
+ ;;
49
+ *)
50
+ printf 'unexpected working directory: %s\n' "$actual" >&2
51
+ exit 3
52
+ ;;
53
+ esac
54
+ printf ' ignored-indented\n'
55
+ printf '[ignored-json]\n'
56
+ printf '{"ignored":true}\n'
57
+ `
58
+ if err := os.WriteFile(fake, []byte(script), 0o700); err != nil {
59
+ t.Fatal(err)
60
+ }
61
+ t.Setenv("PATH", fakeDir)
62
+ t.Setenv("OPENCODE_REPO_A", repoA)
63
+ t.Setenv("OPENCODE_REPO_B", repoB)
64
+
65
+ h := New()
66
+ if err := h.ValidateAgent(context.Background(), repoA, "repo-a-agent"); err != nil {
67
+ t.Fatalf("agent from repository A rejected: %v", err)
68
+ }
69
+ if err := h.ValidateAgent(context.Background(), repoB, "repo-b-agent"); err != nil {
70
+ t.Fatalf("agent from repository B rejected: %v", err)
71
+ }
72
+ if err := h.ValidateAgent(context.Background(), repoA, "repo-b-agent"); err == nil {
73
+ t.Fatal("repository B agent accepted while validating repository A")
74
+ }
75
+ if err := h.ValidateAgent(context.Background(), repoB, "repo-a-agent"); err == nil {
76
+ t.Fatal("repository A agent accepted while validating repository B")
77
+ }
78
+ }
79
+
80
+ func TestValidateAgentPropagatesListCommandFailure(t *testing.T) {
81
+ if runtime.GOOS == "windows" {
82
+ t.Skip("fake opencode uses a POSIX shell")
83
+ }
84
+
85
+ fakeDir := t.TempDir()
86
+ fake := filepath.Join(fakeDir, "opencode")
87
+ script := `#!/bin/sh
88
+ set -eu
89
+ [ "$#" -eq 2 ] && [ "$1" = "agent" ] && [ "$2" = "list" ] || exit 2
90
+ printf 'agent list failed\n' >&2
91
+ exit 17
92
+ `
93
+ if err := os.WriteFile(fake, []byte(script), 0o700); err != nil {
94
+ t.Fatal(err)
95
+ }
96
+ t.Setenv("PATH", fakeDir)
97
+
98
+ h := New()
99
+ err := h.ValidateAgent(context.Background(), t.TempDir(), "build")
100
+ if err == nil {
101
+ t.Fatal("ValidateAgent succeeded after opencode agent list failed")
102
+ }
103
+ if !strings.Contains(err.Error(), "opencode agent list") {
104
+ t.Fatalf("error = %q, want opencode agent list context", err)
105
+ }
106
+ }
@@ -148,10 +148,11 @@ func (*Harness) FindSession(context.Context, string, string) (harness.Session, b
148
148
  }
149
149
 
150
150
  // RenderPrompt renders the selected initial or feedback template and the
151
- // node's nudge template. Named Pi agents are native prompt-template commands,
152
- // so the complete rendered text is supplied as the command arguments. HITL
153
- // approval is not encoded in the prompt; the Pi extension asks for approval
154
- // through ctx.ui.select.
151
+ // node's nudge template. Initial prompts use Pi's native prompt-template
152
+ // command syntax; feedback is sent to an existing session and must remain
153
+ // raw so Pi does not expand the full prompt template again. HITL approval is
154
+ // not encoded in the prompt; the Pi extension asks for approval through
155
+ // ctx.ui.select.
155
156
  func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData, nudgeTemplate string) (string, error) {
156
157
  var tmpl string
157
158
  switch kind {
@@ -168,15 +169,20 @@ func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData,
168
169
  }
169
170
  }
170
171
  prompt := appendPrompt(renderTemplate(tmpl, data), renderTemplate(nudgeTemplate, data))
171
- return applyPromptTemplate(data.Agent, prompt), nil
172
+ if kind == harness.PromptInitial {
173
+ return applyPromptTemplate(data.Agent, prompt), nil
174
+ }
175
+ return prompt, nil
172
176
  }
173
177
 
174
178
  // BuildCommand returns the interactive Pi invocation. The runner supplies a
175
179
  // PTY for Pi's stdin/stdout; the rendered prompt is the final positional argv
176
180
  // value. A named workflow agent adds Pi's --prompt-template option for the
177
181
  // project-owned .pi/prompts/<agent>.md file and invokes it with its native
178
- // slash-command syntax. Pi 0.84.1 rejects a bare -- terminator, so none is
179
- // included. A non-empty ResumeID selects Pi's exact session-id resume option.
182
+ // slash-command syntax only for a fresh launch. Resumed sessions receive the
183
+ // raw feedback prompt so Pi does not expand the template a second time. Pi
184
+ // 0.84.1 rejects a bare -- terminator, so none is included. A non-empty
185
+ // ResumeID selects Pi's exact session-id resume option.
180
186
  func (*Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
181
187
  if err := validateAgentName(spec.Agent); err != nil {
182
188
  return runner.Command{}, err
@@ -215,7 +221,11 @@ func (*Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
215
221
  if spec.ResumeID != "" {
216
222
  args = append(args, "--session-id", spec.ResumeID)
217
223
  }
218
- args = append(args, applyPromptTemplate(spec.Agent, spec.Prompt))
224
+ prompt := spec.Prompt
225
+ if spec.ResumeID == "" {
226
+ prompt = applyPromptTemplate(spec.Agent, prompt)
227
+ }
228
+ args = append(args, prompt)
219
229
  return runner.Command{
220
230
  Executable: "pi",
221
231
  Args: args,
@@ -148,7 +148,7 @@ func TestBuildCommandUsesNativePromptTemplate(t *testing.T) {
148
148
  if err != nil {
149
149
  t.Fatalf("BuildCommand(resume): %v", err)
150
150
  }
151
- want = []string{"--name", spec.Title, "--prompt-template", ".pi/prompts/coder.md", "--session-id", spec.ResumeID, "/coder " + spec.Prompt}
151
+ want = []string{"--name", spec.Title, "--prompt-template", ".pi/prompts/coder.md", "--session-id", spec.ResumeID, spec.Prompt}
152
152
  if !reflect.DeepEqual(cmd.Args, want) {
153
153
  t.Fatalf("resume Args = %#v, want %#v", cmd.Args, want)
154
154
  }
@@ -97,7 +97,7 @@ func TestPiPromptRetainsDefaultAgentLabel(t *testing.T) {
97
97
  }
98
98
  }
99
99
 
100
- func TestPiRenderPromptUsesNativeTemplateCommandForNamedAgent(t *testing.T) {
100
+ func TestPiRenderPromptUsesNativeTemplateCommandForInitialNamedAgent(t *testing.T) {
101
101
  h := newPiHarness(t)
102
102
  data := harness.PromptData{
103
103
  TaskSystem: "jira",
@@ -120,7 +120,7 @@ func TestPiRenderPromptUsesNativeTemplateCommandForNamedAgent(t *testing.T) {
120
120
  if err != nil {
121
121
  t.Fatalf("RenderPrompt(feedback): %v", err)
122
122
  }
123
- wantFeedback := "/coder New feedback was added to the comments section of your mailbox subtask PAY-234. Read it.\n\nfeedback nudge"
123
+ wantFeedback := "New feedback was added to the comments section of your mailbox subtask PAY-234. Read it.\n\nfeedback nudge"
124
124
  if feedback != wantFeedback {
125
125
  t.Fatalf("feedback prompt = %q, want %q", feedback, wantFeedback)
126
126
  }
@@ -0,0 +1,38 @@
1
+ package runner
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "os"
8
+ "os/exec"
9
+ "strings"
10
+ )
11
+
12
+ // CheckCleanCheckout verifies the ticket-scoped checkout before runner cleanup.
13
+ // A checkout that has already disappeared is an idempotent cleanup success;
14
+ // callers preserve their existing roll-forward behavior in that case.
15
+ func CheckCleanCheckout(ctx context.Context, ticket, checkout string) error {
16
+ if strings.TrimSpace(checkout) == "" {
17
+ return fmt.Errorf("cannot check Git cleanliness for ticket %q: checkout path is empty", ticket)
18
+ }
19
+ if _, err := os.Stat(checkout); err != nil {
20
+ if errors.Is(err, os.ErrNotExist) {
21
+ return nil
22
+ }
23
+ return fmt.Errorf("cannot check Git cleanliness for ticket %q at %q: %w", ticket, checkout, err)
24
+ }
25
+
26
+ out, err := exec.CommandContext(ctx, "git", "-C", checkout, "status", "--porcelain=v1", "--untracked-files=all").CombinedOutput()
27
+ if err != nil {
28
+ detail := strings.TrimSpace(string(out))
29
+ if detail != "" {
30
+ return fmt.Errorf("cannot check Git cleanliness for ticket %q at %q: %w: %s", ticket, checkout, err, detail)
31
+ }
32
+ return fmt.Errorf("cannot check Git cleanliness for ticket %q at %q: %w", ticket, checkout, err)
33
+ }
34
+ if len(out) != 0 {
35
+ return fmt.Errorf("ticket %q checkout is dirty; commit required before runner cleanup", ticket)
36
+ }
37
+ return nil
38
+ }
@@ -0,0 +1,84 @@
1
+ package runner
2
+
3
+ import (
4
+ "context"
5
+ "os"
6
+ "os/exec"
7
+ "path/filepath"
8
+ "strings"
9
+ "testing"
10
+ )
11
+
12
+ func TestCheckCleanCheckoutRejectsGitChanges(t *testing.T) {
13
+ cases := map[string]func(t *testing.T, repo string){
14
+ "untracked": func(t *testing.T, repo string) {
15
+ writeFile(t, filepath.Join(repo, "untracked.txt"), "new\n")
16
+ },
17
+ "unstaged": func(t *testing.T, repo string) {
18
+ writeFile(t, filepath.Join(repo, "tracked.txt"), "changed\n")
19
+ },
20
+ "staged": func(t *testing.T, repo string) {
21
+ writeFile(t, filepath.Join(repo, "tracked.txt"), "changed\n")
22
+ runGit(t, repo, "add", "tracked.txt")
23
+ },
24
+ }
25
+ for name, makeDirty := range cases {
26
+ t.Run(name, func(t *testing.T) {
27
+ repo := newCleanCheckout(t)
28
+ makeDirty(t, repo)
29
+ err := CheckCleanCheckout(context.Background(), "PAY-101", repo)
30
+ if err == nil || !strings.Contains(err.Error(), "PAY-101") || !strings.Contains(err.Error(), "commit required") {
31
+ t.Fatalf("CheckCleanCheckout error = %v, want ticket and commit-required message", err)
32
+ }
33
+ })
34
+ }
35
+ }
36
+
37
+ func TestCheckCleanCheckoutAcceptsCleanAndMissingCheckouts(t *testing.T) {
38
+ repo := newCleanCheckout(t)
39
+ if err := CheckCleanCheckout(context.Background(), "PAY-101", repo); err != nil {
40
+ t.Fatalf("clean checkout returned %v", err)
41
+ }
42
+ if err := CheckCleanCheckout(context.Background(), "PAY-101", filepath.Join(t.TempDir(), "removed")); err != nil {
43
+ t.Fatalf("missing checkout returned %v, want idempotent success", err)
44
+ }
45
+ }
46
+
47
+ func TestCheckCleanCheckoutPropagatesGitStatusFailure(t *testing.T) {
48
+ dir := t.TempDir()
49
+ err := CheckCleanCheckout(context.Background(), "PAY-101", dir)
50
+ if err == nil {
51
+ t.Fatal("non-Git checkout was treated as clean")
52
+ }
53
+ if strings.Contains(err.Error(), "commit required") {
54
+ t.Fatalf("Git status failure was reported as dirty state: %v", err)
55
+ }
56
+ }
57
+
58
+ func newCleanCheckout(t *testing.T) string {
59
+ t.Helper()
60
+ repo := t.TempDir()
61
+ runGit(t, repo, "init", "-q", "-b", "main")
62
+ runGit(t, repo, "config", "user.email", "relay-flow@example.invalid")
63
+ runGit(t, repo, "config", "user.name", "Relay Flow")
64
+ writeFile(t, filepath.Join(repo, "tracked.txt"), "clean\n")
65
+ runGit(t, repo, "add", "tracked.txt")
66
+ runGit(t, repo, "commit", "-qm", "initial")
67
+ return repo
68
+ }
69
+
70
+ func writeFile(t *testing.T, path, contents string) {
71
+ t.Helper()
72
+ if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
73
+ t.Fatal(err)
74
+ }
75
+ }
76
+
77
+ func runGit(t *testing.T, repo string, args ...string) {
78
+ t.Helper()
79
+ cmd := exec.Command("git", args...)
80
+ cmd.Dir = repo
81
+ if output, err := cmd.CombinedOutput(); err != nil {
82
+ t.Fatalf("git %v: %v: %s", args, err, output)
83
+ }
84
+ }