relay-flow 0.2.8-alpha → 0.2.10-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 +76 -35
  2. package/cmd/relay-flow/commands_test.go +117 -8
  3. package/cmd/relay-flow/main.go +391 -83
  4. package/cmd/relay-flow/observability_test.go +204 -0
  5. package/cmd/relay-flow/onboarding.go +344 -0
  6. package/cmd/relay-flow/onboarding_test.go +515 -0
  7. package/cmd/relay-flow/render.go +629 -0
  8. package/cmd/relay-flow/repo_registration.go +219 -0
  9. package/cmd/relay-flow/serve.go +35 -1
  10. package/go.mod +12 -8
  11. package/go.sum +24 -17
  12. package/internal/execution/goworkflows/activities.go +9 -0
  13. package/internal/execution/goworkflows/engine.go +15 -0
  14. package/internal/execution/goworkflows/engine_test.go +3 -3
  15. package/internal/execution/goworkflows/interpreter.go +86 -3
  16. package/internal/execution/goworkflows/projection.go +16 -0
  17. package/internal/execution/projection/detail_test.go +259 -0
  18. package/internal/execution/projection/projection.go +290 -6
  19. package/internal/execution/projection/projection_test.go +3 -2
  20. package/internal/execution/temporal/activities.go +9 -0
  21. package/internal/execution/temporal/engine.go +13 -0
  22. package/internal/execution/temporal/interpreter.go +131 -3
  23. package/internal/execution/temporal/operations.go +174 -0
  24. package/internal/execution/temporal/operations_test.go +36 -0
  25. package/internal/harness/opencode/opencode_test.go +4 -4
  26. package/internal/harness/opencode/repo_setup.go +1 -1
  27. package/internal/harness/pi/prompt_test.go +3 -3
  28. package/internal/repo/service.go +34 -3
  29. package/internal/repo/service_test.go +22 -0
  30. package/internal/run/detail.go +277 -0
  31. package/internal/run/detail_test.go +71 -0
  32. package/internal/run/run.go +4 -0
  33. package/internal/runner/herdr/herdr.go +103 -4
  34. package/internal/runner/herdr/herdr_test.go +72 -2
  35. package/internal/runner/herdr/herdrcli/contract.go +12 -3
  36. package/internal/runner/herdr/herdrcli/herdrcli_test.go +17 -2
  37. package/internal/runner/herdr/herdrcli/operations.go +16 -0
  38. package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +8 -0
  39. package/internal/runner/herdr/herdrcli/testdata/workspace-create.json +1 -0
  40. package/internal/runner/orca/orca.go +68 -9
  41. package/internal/runner/orca/orca_test.go +134 -2
  42. package/internal/runner/orca/orcacli/orcacli.go +21 -1
  43. package/internal/runner/orca/orcacli/orcacli_test.go +22 -0
  44. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +5 -0
  45. package/internal/runner/runner.go +9 -0
  46. package/internal/server/api_test.go +19 -0
  47. package/internal/server/client.go +49 -0
  48. package/internal/server/fixture_test.go +7 -0
  49. package/internal/server/observability.go +109 -0
  50. package/internal/server/observability_test.go +60 -0
  51. package/internal/server/server.go +77 -0
  52. package/internal/task/jira/testdata/jira_search_issues.json +4 -4
  53. package/internal/workflow/workflow.go +4 -1
  54. package/internal/workflow/workflow_test.go +4 -4
  55. package/package.json +1 -1
@@ -0,0 +1,277 @@
1
+ package run
2
+
3
+ import (
4
+ "time"
5
+
6
+ "github.com/rajpopat27/relay-flow/internal/workflow"
7
+ )
8
+
9
+ // StepStatus is the display-only lifecycle of one recorded node visit. It is
10
+ // deliberately separate from State: State is the execution authority's
11
+ // current run state, while StepStatus describes one historical row in the
12
+ // relay-owned inspection projection.
13
+ type StepStatus string
14
+
15
+ const (
16
+ StepPending StepStatus = "pending"
17
+ StepRunning StepStatus = "running"
18
+ StepWaiting StepStatus = "waiting"
19
+ StepSucceeded StepStatus = "succeeded"
20
+ StepFailed StepStatus = "failed"
21
+ StepBlocked StepStatus = "blocked"
22
+ StepCanceled StepStatus = "canceled"
23
+ )
24
+
25
+ // StepEntry is the small, engine-neutral read model used by run inspection.
26
+ // Sequence is the stable execution order within one attempt. A node visit is
27
+ // identified by NodeVisitID when the node is a work node; lifecycle rows such
28
+ // as start/end may not have one.
29
+ type StepEntry struct {
30
+ RunID ID `json:"runId,omitempty"`
31
+ Sequence int64 `json:"sequence"`
32
+ Node string `json:"node"`
33
+ NodeVisitID NodeVisitID `json:"nodeVisitId,omitempty"`
34
+ NodeType string `json:"nodeType,omitempty"`
35
+ ParentSequence int64 `json:"parentSequence,omitempty"`
36
+ Depth int `json:"depth,omitempty"`
37
+ Status StepStatus `json:"status"`
38
+ StartedAt *time.Time `json:"startedAt,omitempty"`
39
+ FinishedAt *time.Time `json:"finishedAt,omitempty"`
40
+ Duration time.Duration `json:"duration,omitempty"`
41
+ DurationKnown bool `json:"durationKnown,omitempty"`
42
+ Message string `json:"message,omitempty"`
43
+ Route string `json:"route,omitempty"`
44
+ Runtime string `json:"runtime,omitempty"`
45
+ Resource string `json:"resource,omitempty"`
46
+ }
47
+
48
+ // RunConditions are derived from the current run state and retry metadata.
49
+ // They are display data only and do not participate in execution decisions.
50
+ type RunConditions struct {
51
+ NodeRunning bool `json:"nodeRunning"`
52
+ Waiting bool `json:"waiting"`
53
+ RetryScheduled bool `json:"retryScheduled"`
54
+ Completed bool `json:"completed"`
55
+ Failed bool `json:"failed"`
56
+ Canceled bool `json:"canceled"`
57
+ }
58
+
59
+ // RunProgress counts reached visits in the derived timeline. Pending rows are
60
+ // the denominator remainder for an active/waiting run.
61
+ type RunProgress struct {
62
+ Completed int `json:"completed"`
63
+ Total int `json:"total"`
64
+ Pending int `json:"pending"`
65
+ }
66
+
67
+ // ResourceDuration is an optional display aggregation. Known is false when
68
+ // executors did not retain resource attribution; zero values with Known=true
69
+ // represent a real zero-duration resource interval.
70
+ type ResourceDuration struct {
71
+ Known bool `json:"known"`
72
+ Runner time.Duration `json:"runner,omitempty"`
73
+ TaskSystem time.Duration `json:"taskSystem,omitempty"`
74
+ Harness time.Duration `json:"harness,omitempty"`
75
+ Other time.Duration `json:"other,omitempty"`
76
+ }
77
+
78
+ // RunDetail embeds the original Run value so existing JSON clients that
79
+ // decode a run.Run continue to work when the server adds inspection fields.
80
+ // The remaining fields are read-only relay projection data.
81
+ type RunDetail struct {
82
+ Run
83
+ CreatedAt *time.Time `json:"createdAt,omitempty"`
84
+ Conditions RunConditions `json:"conditions"`
85
+ Progress RunProgress `json:"progress"`
86
+ ResourcesDuration ResourceDuration `json:"resourcesDuration"`
87
+ Steps []StepEntry `json:"steps"`
88
+ }
89
+
90
+ // NewRunDetail creates an empty detail response for a base run. Projection
91
+ // implementations fill Steps and derive the remaining fields.
92
+ func NewRunDetail(r Run) RunDetail {
93
+ return RunDetail{Run: r, Steps: []StepEntry{}}
94
+ }
95
+
96
+ // AddPendingNodes adds only pending downstream rows that can be justified by
97
+ // the current run state and selected route. Completed runs intentionally show
98
+ // only actual visits; the static graph is not treated as execution history.
99
+ func (d *RunDetail) AddPendingNodes(wf workflow.Workflow) {
100
+ if d.State == StateCompleted {
101
+ return
102
+ }
103
+ actual := make(map[string]bool, len(d.Steps))
104
+ maxSequence := int64(-1)
105
+ current := d.CurrentNode
106
+ var currentStep *StepEntry
107
+ for i := range d.Steps {
108
+ step := &d.Steps[i]
109
+ if step.Status == StepPending {
110
+ continue
111
+ }
112
+ actual[step.Node] = true
113
+ if step.Sequence > maxSequence {
114
+ maxSequence = step.Sequence
115
+ }
116
+ if currentStep == nil || step.Sequence > currentStep.Sequence {
117
+ currentStep = step
118
+ }
119
+ }
120
+ if current == "" && currentStep != nil {
121
+ current = currentStep.Node
122
+ }
123
+ if current == "" {
124
+ return
125
+ }
126
+
127
+ candidates := make([]string, 0, 2)
128
+ addCandidate := func(target string) {
129
+ if target == "" || actual[target] {
130
+ return
131
+ }
132
+ for _, existing := range candidates {
133
+ if existing == target {
134
+ return
135
+ }
136
+ }
137
+ candidates = append(candidates, target)
138
+ }
139
+ if currentStep != nil && currentStep.Node == current && currentStep.Route != "" {
140
+ addCandidate(currentStep.Route)
141
+ } else if node, ok := wf.Nodes[current]; ok {
142
+ for _, route := range append(append([]workflow.Route{}, node.OnSuccess...), node.OnFailure...) {
143
+ addCandidate(route.Target)
144
+ }
145
+ }
146
+
147
+ added := map[string]bool{}
148
+ var appendPending func(string, bool)
149
+ appendPending = func(name string, followSingle bool) {
150
+ if name == "" || actual[name] || added[name] {
151
+ return
152
+ }
153
+ node, ok := wf.Nodes[name]
154
+ if !ok {
155
+ return
156
+ }
157
+ added[name] = true
158
+ nodeType := string(node.Type)
159
+ if name == workflow.StartNode || name == workflow.EndNode {
160
+ nodeType = "lifecycle"
161
+ }
162
+ maxSequence++
163
+ d.Steps = append(d.Steps, StepEntry{RunID: d.ID, Sequence: maxSequence,
164
+ Node: name, NodeType: nodeType, Status: StepPending, Depth: 1})
165
+ if !followSingle || name == workflow.EndNode {
166
+ return
167
+ }
168
+ routes := append(append([]workflow.Route{}, node.OnSuccess...), node.OnFailure...)
169
+ if len(routes) == 1 {
170
+ appendPending(routes[0].Target, true)
171
+ }
172
+ }
173
+ for _, candidate := range candidates {
174
+ appendPending(candidate, currentStep != nil && currentStep.Node == current && currentStep.Route != "")
175
+ }
176
+ }
177
+
178
+ // DeriveInspectionFields fills conditions, progress, and resource durations
179
+ // from the supplied run and step timeline. This helper intentionally reads no
180
+ // executor or task-system state.
181
+ func (d *RunDetail) DeriveInspectionFields(now time.Time) {
182
+ if now.IsZero() {
183
+ now = time.Now().UTC()
184
+ }
185
+ d.Conditions = RunConditions{
186
+ NodeRunning: d.State == StateRunning,
187
+ Waiting: d.State == StateWaiting,
188
+ RetryScheduled: d.Retry != nil,
189
+ Completed: d.State == StateCompleted,
190
+ Failed: d.State == StateBlocked,
191
+ Canceled: d.State == StateCanceled || d.State == StateCanceling,
192
+ }
193
+ d.Progress = RunProgress{Total: len(d.Steps)}
194
+ d.ResourcesDuration = ResourceDuration{}
195
+ for i, step := range d.Steps {
196
+ switch step.Status {
197
+ case StepPending:
198
+ d.Progress.Pending++
199
+ default:
200
+ // Progress counts reached visits, including the current waiting or
201
+ // blocked visit; pending rows are the denominator remainder.
202
+ d.Progress.Completed++
203
+ }
204
+ duration := step.Duration
205
+ if duration == 0 && step.StartedAt != nil && step.FinishedAt != nil {
206
+ duration = step.FinishedAt.Sub(*step.StartedAt)
207
+ d.Steps[i].DurationKnown = true
208
+ }
209
+ if duration == 0 && step.StartedAt != nil && step.FinishedAt == nil &&
210
+ (step.Status == StepRunning || step.Status == StepWaiting || step.Status == StepBlocked) {
211
+ duration = now.Sub(*step.StartedAt)
212
+ }
213
+ if duration < 0 {
214
+ duration = 0
215
+ }
216
+ if step.Resource != "" {
217
+ d.ResourcesDuration.Known = true
218
+ }
219
+ switch step.Resource {
220
+ case "runner":
221
+ d.ResourcesDuration.Runner += duration
222
+ case "task-system", "taskSystem":
223
+ d.ResourcesDuration.TaskSystem += duration
224
+ case "harness":
225
+ d.ResourcesDuration.Harness += duration
226
+ case "":
227
+ default:
228
+ d.ResourcesDuration.Other += duration
229
+ }
230
+ }
231
+ }
232
+
233
+ // DisplayStatus returns the stable human status vocabulary used by the CLI.
234
+ func (r Run) DisplayStatus() string {
235
+ switch r.State {
236
+ case StateCompleted:
237
+ return "Succeeded"
238
+ case StateRunning:
239
+ return "Running"
240
+ case StateWaiting:
241
+ return "Waiting"
242
+ case StateBlocked:
243
+ return "Failed"
244
+ case StateCanceled, StateCanceling:
245
+ return "Canceled"
246
+ case StateStarting:
247
+ return "Running"
248
+ default:
249
+ return string(r.State)
250
+ }
251
+ }
252
+
253
+ // DisplayStatus returns the stable human status vocabulary for a step.
254
+ func (s StepStatus) DisplayStatus() string {
255
+ switch s {
256
+ case StepSucceeded:
257
+ return "Succeeded"
258
+ case StepRunning:
259
+ return "Running"
260
+ case StepWaiting:
261
+ return "Waiting"
262
+ case StepFailed, StepBlocked:
263
+ return "Failed"
264
+ case StepCanceled:
265
+ return "Canceled"
266
+ case StepPending:
267
+ return "Pending"
268
+ default:
269
+ return string(s)
270
+ }
271
+ }
272
+
273
+ // IsTerminal reports whether a step has finished and should contribute to
274
+ // progress. It is intentionally local to the display model.
275
+ func (s StepStatus) IsTerminal() bool {
276
+ return s == StepSucceeded || s == StepFailed || s == StepCanceled
277
+ }
@@ -0,0 +1,71 @@
1
+ package run
2
+
3
+ import (
4
+ "encoding/json"
5
+ "strings"
6
+ "testing"
7
+ "time"
8
+
9
+ "github.com/rajpopat27/relay-flow/internal/workflow"
10
+ )
11
+
12
+ func TestRunDetailDerivationIsRepeatable(t *testing.T) {
13
+ started := time.Now().UTC().Add(-time.Minute)
14
+ finished := started.Add(30 * time.Second)
15
+ detail := NewRunDetail(Run{State: StateWaiting})
16
+ detail.Steps = []StepEntry{{Status: StepSucceeded, StartedAt: &started, FinishedAt: &finished, Resource: "harness"}}
17
+ detail.DeriveInspectionFields(time.Now().UTC())
18
+ first := detail.ResourcesDuration.Harness
19
+ detail.DeriveInspectionFields(time.Now().UTC())
20
+ if first <= 0 || detail.ResourcesDuration.Harness != first {
21
+ t.Fatalf("resource duration changed on repeat: first=%s second=%s", first, detail.ResourcesDuration.Harness)
22
+ }
23
+ }
24
+
25
+ func TestCompletedRunDoesNotInventUnvisitedBranchRows(t *testing.T) {
26
+ detail := NewRunDetail(Run{State: StateCompleted, CurrentNode: workflow.EndNode})
27
+ detail.Steps = []StepEntry{
28
+ {Sequence: 0, Node: workflow.StartNode, Status: StepSucceeded},
29
+ {Sequence: 1, Node: "coding", Status: StepSucceeded},
30
+ {Sequence: 2, Node: workflow.EndNode, Status: StepSucceeded},
31
+ }
32
+ detail.AddPendingNodes(workflow.Workflow{Nodes: map[string]workflow.Node{
33
+ workflow.StartNode: {}, "coding": {}, "review": {}, workflow.EndNode: {},
34
+ }})
35
+ if len(detail.Steps) != 3 {
36
+ t.Fatalf("completed branch added pending rows: %#v", detail.Steps)
37
+ }
38
+ }
39
+
40
+ func TestRunDetailAddsPendingStaticNodesWithoutChangingActualVisits(t *testing.T) {
41
+ detail := NewRunDetail(Run{State: StateWaiting, CurrentNode: "coding"})
42
+ detail.Steps = []StepEntry{{Sequence: 0, Node: workflow.StartNode, Status: StepSucceeded, Depth: 1}, {Sequence: 1, Node: "coding", Status: StepWaiting, Depth: 1}}
43
+ detail.AddPendingNodes(workflow.Workflow{Nodes: map[string]workflow.Node{
44
+ workflow.StartNode: {}, "coding": {OnSuccess: []workflow.Route{{Target: workflow.EndNode}}}, workflow.EndNode: {}, "review": {},
45
+ }})
46
+ detail.DeriveInspectionFields(time.Now().UTC())
47
+ if len(detail.Steps) != 3 || detail.Steps[2].Status != StepPending || detail.Progress.Pending != 1 || detail.Progress.Total != 3 {
48
+ t.Fatalf("pending detail = %#v", detail)
49
+ }
50
+ if detail.Steps[0].Status != StepSucceeded || detail.Steps[1].Status != StepWaiting {
51
+ t.Fatalf("actual visits changed: %#v", detail.Steps)
52
+ }
53
+ }
54
+
55
+ func TestRunDetailJSONKeepsBaseRunFieldsAndStepIdentity(t *testing.T) {
56
+ detail := NewRunDetail(Run{ID: "repo/workflow/T-1", State: StateWaiting})
57
+ detail.Steps = []StepEntry{{RunID: detail.ID, Sequence: 1, Node: "coding", Status: StepWaiting}}
58
+ raw, err := json.Marshal(detail)
59
+ if err != nil {
60
+ t.Fatal(err)
61
+ }
62
+ text := string(raw)
63
+ for _, want := range []string{`"id":"repo/workflow/T-1"`, `"steps"`, `"node":"coding"`} {
64
+ if !strings.Contains(text, want) {
65
+ t.Fatalf("JSON %q missing %s", text, want)
66
+ }
67
+ }
68
+ if !strings.Contains(text, `"runId":"repo/workflow/T-1"`) {
69
+ t.Fatalf("step identity missing from JSON: %s", text)
70
+ }
71
+ }
@@ -124,6 +124,10 @@ type ReportAck struct {
124
124
  // its current durable state. Server handlers map it to HTTP 409.
125
125
  var ErrRestartConflict = errors.New("restart conflict")
126
126
 
127
+ // ErrNotFound identifies a missing durable run in the engine-neutral query
128
+ // boundary. HTTP adapters may map it to their own 404 error type.
129
+ var ErrNotFound = errors.New("run not found")
130
+
127
131
  // NodeRuntimeRegistration binds the OpenCode session emitted for one run/node.
128
132
  type NodeRuntimeRegistration struct {
129
133
  RunID ID `json:"runId"`
@@ -91,11 +91,15 @@ func (a *adapter) DiscoverRepos(ctx context.Context) ([]runner.RepoCandidate, er
91
91
  }
92
92
  candidates := make(map[string]runner.RepoCandidate)
93
93
  for _, workspace := range snapshot.Workspaces {
94
- root := normalizePath(workspace.Worktree.RepoRoot)
95
- if root == "" {
94
+ candidate, ok, err := a.workspaceRepository(ctx, workspace, snapshot.Panes)
95
+ if err != nil {
96
+ logOutcome("discover-repos", "error")
97
+ return nil, err
98
+ }
99
+ if !ok {
96
100
  continue
97
101
  }
98
- candidates[root] = runner.RepoCandidate{Name: workspace.Worktree.RepoName, Path: root}
102
+ candidates[canonicalCandidatePath(candidate.Path)] = candidate
99
103
  }
100
104
  out := make([]runner.RepoCandidate, 0, len(candidates))
101
105
  for _, candidate := range candidates {
@@ -111,8 +115,102 @@ func (a *adapter) DiscoverRepos(ctx context.Context) ([]runner.RepoCandidate, er
111
115
  return out, nil
112
116
  }
113
117
 
118
+ // workspaceRepository resolves the Git identity of a workspace. Herdr's
119
+ // snapshot includes worktree metadata for linked worktrees, but plain source
120
+ // workspaces may omit the worktree object; their pane CWD is the supported
121
+ // lookup handle for worktree list.
122
+ func (a *adapter) workspaceRepository(ctx context.Context, workspace herdrcli.Workspace, panes []herdrcli.Pane) (runner.RepoCandidate, bool, error) {
123
+ if root := normalizePath(workspace.Worktree.RepoRoot); root != "" {
124
+ name := workspace.Worktree.RepoName
125
+ if name == "" {
126
+ name = workspace.Label
127
+ }
128
+ return runner.RepoCandidate{Name: name, Path: root}, true, nil
129
+ }
130
+ for _, pane := range panes {
131
+ if pane.WorkspaceID != workspace.ID || !filepath.IsAbs(strings.TrimSpace(pane.CWD)) {
132
+ continue
133
+ }
134
+ listing, err := a.cli.WorktreeList(ctx, pane.CWD)
135
+ if errors.Is(err, herdrcli.ErrNotGitWorktree) {
136
+ continue
137
+ }
138
+ if err != nil {
139
+ return runner.RepoCandidate{}, false, err
140
+ }
141
+ root := normalizePath(listing.Source.RepoRoot)
142
+ if root == "" {
143
+ continue
144
+ }
145
+ name := listing.Source.RepoName
146
+ if name == "" {
147
+ name = workspace.Label
148
+ }
149
+ return runner.RepoCandidate{Name: name, Path: root}, true, nil
150
+ }
151
+ return runner.RepoCandidate{}, false, nil
152
+ }
153
+
154
+ func canonicalCandidatePath(path string) string {
155
+ if normalized := normalizePath(path); normalized != "" {
156
+ return normalized
157
+ }
158
+ return path
159
+ }
160
+
161
+ // EnsureRepo opens a source repository as a Herdr workspace when it is not
162
+ // already open. WorktreeList is the source of truth for both the repository
163
+ // root and the source workspace handle; it is checked before workspace create
164
+ // so repeated registration remains idempotent even when snapshots omit
165
+ // worktree metadata for plain source workspaces.
166
+ func (a *adapter) EnsureRepo(ctx context.Context, name, path string) error {
167
+ attrs := []any{"repo", name}
168
+ logCall("ensure-repo", attrs...)
169
+ root := normalizePath(path)
170
+ if root == "" {
171
+ logOutcome("ensure-repo", "error", attrs...)
172
+ return fmt.Errorf("herdr: repository %q has an empty path", name)
173
+ }
174
+ listing, err := a.cli.WorktreeList(ctx, root)
175
+ if err != nil {
176
+ logOutcome("ensure-repo", "error", attrs...)
177
+ return err
178
+ }
179
+ if reported := normalizePath(listing.Source.RepoRoot); reported != root {
180
+ logOutcome("ensure-repo", "error", attrs...)
181
+ return fmt.Errorf("herdr: repository %q path %q is not its repository root %q", name, path, reported)
182
+ }
183
+ if hasOpenSourceWorkspace(listing) {
184
+ logOutcome("ensure-repo", "exists", attrs...)
185
+ return nil
186
+ }
187
+ registrar, ok := a.cli.(herdrcli.RepositoryRegistrar)
188
+ if !ok {
189
+ logOutcome("ensure-repo", "error", attrs...)
190
+ return fmt.Errorf("herdr: repository workspace provisioning is unavailable")
191
+ }
192
+ if _, err := registrar.CreateWorkspace(ctx, root, name); err != nil {
193
+ logOutcome("ensure-repo", "error", attrs...)
194
+ return err
195
+ }
196
+ logOutcome("ensure-repo", "created", attrs...)
197
+ return nil
198
+ }
199
+
200
+ func hasOpenSourceWorkspace(listing herdrcli.WorktreeListing) bool {
201
+ if listing.Source.SourceWorkspaceID != "" {
202
+ return true
203
+ }
204
+ for _, worktree := range listing.Worktrees {
205
+ if !worktree.IsLinked && worktree.OpenWorkspaceID != "" {
206
+ return true
207
+ }
208
+ }
209
+ return false
210
+ }
211
+
114
212
  // ValidateRepo verifies the registered path is the root of a Git repository
115
- // Herdr can manage. Registration needs no Herdr workspace and creates none.
213
+ // Herdr can manage without creating a workspace.
116
214
  func (a *adapter) ValidateRepo(ctx context.Context, name, path string) error {
117
215
  logCall("validate-repo", "repo", name)
118
216
  registered := normalizePath(path)
@@ -590,3 +688,4 @@ func normalizePath(path string) string {
590
688
  }
591
689
 
592
690
  var _ runner.Runner = (*adapter)(nil)
691
+ var _ runner.RepoRegistrar = (*adapter)(nil)
@@ -29,8 +29,11 @@ type fakeClient struct {
29
29
  openWorkspace herdrcli.Workspace
30
30
  openErr error
31
31
 
32
- createWorkspace herdrcli.Workspace
33
- createErr error
32
+ createWorkspace herdrcli.Workspace
33
+ createErr error
34
+ createdSourceWorkspace herdrcli.Workspace
35
+ createdSourceErr error
36
+ workspaceCreateCalls []worktreeCall
34
37
 
35
38
  tabs []herdrcli.Tab
36
39
  tabsErr error
@@ -95,6 +98,13 @@ func (f *fakeClient) WorktreeOpen(_ context.Context, repoPath, branch, label str
95
98
  return f.openWorkspace, f.openErr
96
99
  }
97
100
 
101
+ func (f *fakeClient) CreateWorkspace(_ context.Context, repoPath, label string) (herdrcli.Workspace, error) {
102
+ f.mu.Lock()
103
+ defer f.mu.Unlock()
104
+ f.workspaceCreateCalls = append(f.workspaceCreateCalls, worktreeCall{RepoPath: repoPath, Label: label})
105
+ return f.createdSourceWorkspace, f.createdSourceErr
106
+ }
107
+
98
108
  func (f *fakeClient) CreateTab(_ context.Context, workspaceID, cwd, label string) (herdrcli.Tab, herdrcli.Pane, error) {
99
109
  f.mu.Lock()
100
110
  defer f.mu.Unlock()
@@ -240,6 +250,26 @@ func TestDiscoverReposDeduplicatesRepositoryRoots(t *testing.T) {
240
250
  }
241
251
  }
242
252
 
253
+ func TestDiscoverReposResolvesPlainSourceWorkspaceFromPaneCWD(t *testing.T) {
254
+ cli := &fakeClient{
255
+ snapshot: herdrcli.Snapshot{
256
+ Workspaces: []herdrcli.Workspace{{ID: "w-source", Label: "payments"}},
257
+ Panes: []herdrcli.Pane{{ID: "w-source:p1", WorkspaceID: "w-source", CWD: repoPath}},
258
+ },
259
+ listing: herdrcli.WorktreeListing{
260
+ Source: herdrcli.WorktreeSource{RepoName: "payments", RepoRoot: repoPath, SourceCheckoutPath: repoPath},
261
+ },
262
+ }
263
+ got, err := newAdapter(cli).DiscoverRepos(context.Background())
264
+ if err != nil {
265
+ t.Fatal(err)
266
+ }
267
+ want := []runner.RepoCandidate{{Name: "payments", Path: repoPath}}
268
+ if len(got) != 1 || got[0] != want[0] {
269
+ t.Fatalf("DiscoverRepos = %+v, want %+v", got, want)
270
+ }
271
+ }
272
+
243
273
  func TestValidateRepoAcceptsRepositoryRootAndRejectsInnerPaths(t *testing.T) {
244
274
  cli := &fakeClient{listing: herdrcli.WorktreeListing{
245
275
  Source: herdrcli.WorktreeSource{RepoName: "payments", RepoRoot: repoPath, SourceCheckoutPath: repoPath},
@@ -278,6 +308,46 @@ func TestValidateRepoCreatesNothing(t *testing.T) {
278
308
  }
279
309
  }
280
310
 
311
+ func TestEnsureRepoReusesAnOpenSourceWorkspace(t *testing.T) {
312
+ cli := &fakeClient{
313
+ listing: herdrcli.WorktreeListing{Source: herdrcli.WorktreeSource{RepoRoot: repoPath, SourceWorkspaceID: "w-source"}},
314
+ snapshot: herdrcli.Snapshot{Workspaces: []herdrcli.Workspace{{ID: "w-source"}}},
315
+ }
316
+ a := newAdapter(cli)
317
+ if err := a.EnsureRepo(context.Background(), "payments", repoPath); err != nil {
318
+ t.Fatal(err)
319
+ }
320
+ if err := a.EnsureRepo(context.Background(), "payments", repoPath); err != nil {
321
+ t.Fatal(err)
322
+ }
323
+ if len(cli.workspaceCreateCalls) != 0 {
324
+ t.Fatalf("CreateWorkspace calls = %+v, want none", cli.workspaceCreateCalls)
325
+ }
326
+ }
327
+
328
+ func TestEnsureRepoOpensMissingSourceWorkspace(t *testing.T) {
329
+ cli := &fakeClient{
330
+ listing: herdrcli.WorktreeListing{Source: herdrcli.WorktreeSource{RepoRoot: repoPath}},
331
+ createdSourceWorkspace: herdrcli.Workspace{ID: "source-workspace"},
332
+ }
333
+ if err := newAdapter(cli).EnsureRepo(context.Background(), "payments", repoPath); err != nil {
334
+ t.Fatal(err)
335
+ }
336
+ if len(cli.workspaceCreateCalls) != 1 || cli.workspaceCreateCalls[0] != (worktreeCall{RepoPath: repoPath, Label: "payments"}) {
337
+ t.Fatalf("CreateWorkspace calls = %+v", cli.workspaceCreateCalls)
338
+ }
339
+ }
340
+
341
+ func TestEnsureRepoPropagatesSourceWorkspaceFailure(t *testing.T) {
342
+ cli := &fakeClient{
343
+ listing: herdrcli.WorktreeListing{Source: herdrcli.WorktreeSource{RepoRoot: repoPath}},
344
+ createdSourceErr: errors.New("herdr unavailable"),
345
+ }
346
+ if err := newAdapter(cli).EnsureRepo(context.Background(), "payments", repoPath); err == nil || !strings.Contains(err.Error(), "herdr unavailable") {
347
+ t.Fatalf("EnsureRepo error = %v, want workspace creation failure", err)
348
+ }
349
+ }
350
+
281
351
  // --- Environment ---
282
352
 
283
353
  func TestEnsureEnvironmentReusesExistingTicketWorktree(t *testing.T) {
@@ -35,6 +35,13 @@ type Client interface {
35
35
  CloseWorkspace(ctx context.Context, workspaceID string) error
36
36
  }
37
37
 
38
+ // RepositoryRegistrar is the optional repository-workspace provisioning seam
39
+ // used by the Herdr runner adapter. It remains separate from Client so
40
+ // existing runtime-only test clients do not need to implement registration.
41
+ type RepositoryRegistrar interface {
42
+ CreateWorkspace(ctx context.Context, cwd, label string) (Workspace, error)
43
+ }
44
+
38
45
  // Options selects the Herdr session or explicit socket used by the CLI.
39
46
  type Options struct {
40
47
  Session string
@@ -51,9 +58,10 @@ func New(options Options) *CLI {
51
58
  return &CLI{options: options}
52
59
  }
53
60
 
54
- // WorkspaceWorktree is the Git identity Herdr reports for a workspace. The
55
- // source checkout of a repository has IsLinked false; every ticket worktree
56
- // workspace has IsLinked true and the same RepoRoot.
61
+ // WorkspaceWorktree is the Git identity Herdr may report for a workspace.
62
+ // Linked ticket workspaces have IsLinked true and the source RepoRoot. Plain
63
+ // source workspaces can omit this object; callers resolve those through their
64
+ // pane CWD and worktree list.
57
65
  type WorkspaceWorktree struct {
58
66
  CheckoutPath string
59
67
  RepoName string
@@ -82,6 +90,7 @@ type WorktreeSource struct {
82
90
  RepoName string
83
91
  RepoRoot string
84
92
  SourceCheckoutPath string
93
+ SourceWorkspaceID string
85
94
  }
86
95
 
87
96
  // WorktreeListing is the response of worktree list for one repository.
@@ -24,6 +24,9 @@ func TestCLIUsesExactProductionCommandShapes(t *testing.T) {
24
24
  if _, err := cli.WorktreeOpen(ctx, "/work/payments", "PAY-101", "PAY-101"); err != nil {
25
25
  t.Fatalf("WorktreeOpen: %v", err)
26
26
  }
27
+ if _, err := cli.CreateWorkspace(ctx, "/work/payments", "payments"); err != nil {
28
+ t.Fatalf("CreateWorkspace: %v", err)
29
+ }
27
30
  if _, err := cli.WorktreeCreate(ctx, "/work/payments", "PAY-101", "origin/main", "PAY-101"); err != nil {
28
31
  t.Fatalf("WorktreeCreate: %v", err)
29
32
  }
@@ -85,11 +88,19 @@ func TestCLIDecodesCapturedResponseLocations(t *testing.T) {
85
88
  t.Fatalf("Snapshot panes = %+v", snapshot.Panes)
86
89
  }
87
90
 
91
+ sourceWorkspace, err := cli.CreateWorkspace(ctx, "/work/payments", "payments")
92
+ if err != nil {
93
+ t.Fatalf("CreateWorkspace: %v", err)
94
+ }
95
+ if sourceWorkspace.ID != "w1" || sourceWorkspace.Label != "payments" || sourceWorkspace.Worktree.RepoRoot != "" {
96
+ t.Fatalf("CreateWorkspace = %+v, want the captured plain source workspace shape", sourceWorkspace)
97
+ }
98
+
88
99
  listing, err := cli.WorktreeList(ctx, "/work/payments")
89
100
  if err != nil {
90
101
  t.Fatalf("WorktreeList: %v", err)
91
102
  }
92
- if listing.Source.RepoRoot != "/work/payments" || listing.Source.RepoName != "repo" {
103
+ if listing.Source.RepoRoot != "/work/payments" || listing.Source.RepoName != "repo" || listing.Source.SourceWorkspaceID != "w1" {
93
104
  t.Fatalf("WorktreeList source = %+v", listing.Source)
94
105
  }
95
106
  if len(listing.Worktrees) != 2 {
@@ -231,8 +242,12 @@ func TestCLIRejectsRelativeCWD(t *testing.T) {
231
242
 
232
243
  func TestStrictFakeHerdrRejectsUnsupportedProductionShapes(t *testing.T) {
233
244
  fake := installStrictFakeHerdr(t)
245
+ // This test invokes the fake executable directly rather than through CLI,
246
+ // so provide the same selector environment the production wrapper sets.
247
+ t.Setenv("HERDR_SESSION", "relay-flow")
248
+ t.Setenv("HERDR_SOCKET_PATH", "/tmp/relay-flow-herdr.sock")
234
249
  unsupported := [][]string{
235
- {"workspace", "create", "--cwd", "/work/payments", "--label", "payments", "--no-focus"},
250
+ {"workspace", "get", "w2"},
236
251
  {"worktree", "remove", "--workspace", "w2"},
237
252
  {"terminal", "create", "--pane", "w2:p2"},
238
253
  {"pane", "get", "--pane", "w2:p2"},