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.
- package/README.md +76 -35
- package/cmd/relay-flow/commands_test.go +117 -8
- package/cmd/relay-flow/main.go +391 -83
- package/cmd/relay-flow/observability_test.go +204 -0
- package/cmd/relay-flow/onboarding.go +344 -0
- package/cmd/relay-flow/onboarding_test.go +515 -0
- package/cmd/relay-flow/render.go +629 -0
- package/cmd/relay-flow/repo_registration.go +219 -0
- package/cmd/relay-flow/serve.go +35 -1
- package/go.mod +12 -8
- package/go.sum +24 -17
- package/internal/execution/goworkflows/activities.go +9 -0
- package/internal/execution/goworkflows/engine.go +15 -0
- package/internal/execution/goworkflows/engine_test.go +3 -3
- package/internal/execution/goworkflows/interpreter.go +86 -3
- package/internal/execution/goworkflows/projection.go +16 -0
- package/internal/execution/projection/detail_test.go +259 -0
- package/internal/execution/projection/projection.go +290 -6
- package/internal/execution/projection/projection_test.go +3 -2
- package/internal/execution/temporal/activities.go +9 -0
- package/internal/execution/temporal/engine.go +13 -0
- package/internal/execution/temporal/interpreter.go +131 -3
- package/internal/execution/temporal/operations.go +174 -0
- package/internal/execution/temporal/operations_test.go +36 -0
- package/internal/harness/opencode/opencode_test.go +4 -4
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/pi/prompt_test.go +3 -3
- package/internal/repo/service.go +34 -3
- package/internal/repo/service_test.go +22 -0
- package/internal/run/detail.go +277 -0
- package/internal/run/detail_test.go +71 -0
- package/internal/run/run.go +4 -0
- package/internal/runner/herdr/herdr.go +103 -4
- package/internal/runner/herdr/herdr_test.go +72 -2
- package/internal/runner/herdr/herdrcli/contract.go +12 -3
- package/internal/runner/herdr/herdrcli/herdrcli_test.go +17 -2
- package/internal/runner/herdr/herdrcli/operations.go +16 -0
- package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +8 -0
- package/internal/runner/herdr/herdrcli/testdata/workspace-create.json +1 -0
- package/internal/runner/orca/orca.go +68 -9
- package/internal/runner/orca/orca_test.go +134 -2
- package/internal/runner/orca/orcacli/orcacli.go +21 -1
- package/internal/runner/orca/orcacli/orcacli_test.go +22 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +5 -0
- package/internal/runner/runner.go +9 -0
- package/internal/server/api_test.go +19 -0
- package/internal/server/client.go +49 -0
- package/internal/server/fixture_test.go +7 -0
- package/internal/server/observability.go +109 -0
- package/internal/server/observability_test.go +60 -0
- package/internal/server/server.go +77 -0
- package/internal/task/jira/testdata/jira_search_issues.json +4 -4
- package/internal/workflow/workflow.go +4 -1
- package/internal/workflow/workflow_test.go +4 -4
- 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
|
+
}
|
package/internal/run/run.go
CHANGED
|
@@ -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
|
-
|
|
95
|
-
if
|
|
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[
|
|
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
|
|
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
|
|
33
|
-
createErr
|
|
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
|
|
55
|
-
//
|
|
56
|
-
//
|
|
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", "
|
|
250
|
+
{"workspace", "get", "w2"},
|
|
236
251
|
{"worktree", "remove", "--workspace", "w2"},
|
|
237
252
|
{"terminal", "create", "--pane", "w2:p2"},
|
|
238
253
|
{"pane", "get", "--pane", "w2:p2"},
|