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,259 @@
|
|
|
1
|
+
package projection_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"testing"
|
|
6
|
+
"time"
|
|
7
|
+
|
|
8
|
+
"github.com/rajpopat27/relay-flow/internal/run"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func TestLegacyNullCreationAndDurationRemainUnavailable(t *testing.T) {
|
|
12
|
+
ctx := context.Background()
|
|
13
|
+
p, db := openProjection(t)
|
|
14
|
+
now := time.Now().UTC()
|
|
15
|
+
if _, err := db.Exec(`INSERT INTO relay_runs (id, repo, workflow, ticket_id, ticket_key, state, started_at, updated_at) VALUES (?, 'repo', 'workflow', 'T-LEGACY', 'T-LEGACY', 'waiting', ?, ?)`, "legacy-null", now, now); err != nil {
|
|
16
|
+
t.Fatal(err)
|
|
17
|
+
}
|
|
18
|
+
detail, err := p.GetDetail(ctx, "legacy-null")
|
|
19
|
+
if err != nil {
|
|
20
|
+
t.Fatal(err)
|
|
21
|
+
}
|
|
22
|
+
if detail.CreatedAt != nil {
|
|
23
|
+
t.Fatalf("legacy CreatedAt = %v, want nil", detail.CreatedAt)
|
|
24
|
+
}
|
|
25
|
+
if err := p.UpsertStep(ctx, run.StepEntry{RunID: "legacy-null", Sequence: 1, Node: "coding", Status: run.StepRunning}); err != nil {
|
|
26
|
+
t.Fatal(err)
|
|
27
|
+
}
|
|
28
|
+
steps, err := p.ListSteps(ctx, "legacy-null")
|
|
29
|
+
if err != nil {
|
|
30
|
+
t.Fatal(err)
|
|
31
|
+
}
|
|
32
|
+
if len(steps) != 1 || steps[0].DurationKnown {
|
|
33
|
+
t.Fatalf("legacy unknown duration = %#v", steps)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func TestStepProjectionIsIdempotentAndDerivesDetail(t *testing.T) {
|
|
38
|
+
ctx := context.Background()
|
|
39
|
+
p, _ := openProjection(t)
|
|
40
|
+
start := projectionStart("detail-run", "PAY-DETAIL")
|
|
41
|
+
started := time.Now().UTC().Add(-2 * time.Minute)
|
|
42
|
+
if err := p.InsertStart(ctx, start, started); err != nil {
|
|
43
|
+
t.Fatal(err)
|
|
44
|
+
}
|
|
45
|
+
finished := started.Add(time.Minute)
|
|
46
|
+
step := run.StepEntry{
|
|
47
|
+
RunID: start.ID, Sequence: 1, Node: "coding", NodeVisitID: "visit-1", NodeType: "agent",
|
|
48
|
+
Status: run.StepSucceeded, StartedAt: &started, FinishedAt: &finished,
|
|
49
|
+
Message: "report accepted", Route: "end", Runtime: "coder", Resource: "harness",
|
|
50
|
+
}
|
|
51
|
+
if err := p.UpsertStep(ctx, step); err != nil {
|
|
52
|
+
t.Fatal(err)
|
|
53
|
+
}
|
|
54
|
+
if err := p.UpsertStep(ctx, step); err != nil {
|
|
55
|
+
t.Fatal(err)
|
|
56
|
+
}
|
|
57
|
+
steps, err := p.ListSteps(ctx, start.ID)
|
|
58
|
+
if err != nil {
|
|
59
|
+
t.Fatal(err)
|
|
60
|
+
}
|
|
61
|
+
if len(steps) != 1 || steps[0].NodeVisitID != step.NodeVisitID || steps[0].Route != "end" || steps[0].Duration <= 0 {
|
|
62
|
+
t.Fatalf("steps = %#v", steps)
|
|
63
|
+
}
|
|
64
|
+
detail, err := p.GetDetail(ctx, start.ID)
|
|
65
|
+
if err != nil {
|
|
66
|
+
t.Fatal(err)
|
|
67
|
+
}
|
|
68
|
+
if detail.Progress.Completed != 1 || detail.Progress.Total != 1 || detail.ResourcesDuration.Harness <= 0 {
|
|
69
|
+
t.Fatalf("detail = %#v", detail)
|
|
70
|
+
}
|
|
71
|
+
if detail.Conditions.Completed {
|
|
72
|
+
t.Fatal("starting run was reported completed")
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func TestStepUpsertDoesNotRegressTerminalVisit(t *testing.T) {
|
|
77
|
+
ctx := context.Background()
|
|
78
|
+
p, _ := openProjection(t)
|
|
79
|
+
start := projectionStart("monotonic-run", "PAY-MONOTONIC")
|
|
80
|
+
if err := p.InsertStart(ctx, start, time.Now().UTC()); err != nil {
|
|
81
|
+
t.Fatal(err)
|
|
82
|
+
}
|
|
83
|
+
finished := time.Now().UTC()
|
|
84
|
+
terminal := run.StepEntry{RunID: start.ID, Sequence: 1, Node: "coding", NodeVisitID: "visit-1", Status: run.StepSucceeded, FinishedAt: &finished, Message: "accepted", Route: "end"}
|
|
85
|
+
if err := p.UpsertStep(ctx, terminal); err != nil {
|
|
86
|
+
t.Fatal(err)
|
|
87
|
+
}
|
|
88
|
+
stale := terminal
|
|
89
|
+
stale.Status, stale.Message, stale.Route = run.StepRunning, "stale retry", "coding"
|
|
90
|
+
if err := p.UpsertStep(ctx, stale); err != nil {
|
|
91
|
+
t.Fatal(err)
|
|
92
|
+
}
|
|
93
|
+
steps, err := p.ListSteps(ctx, start.ID)
|
|
94
|
+
if err != nil {
|
|
95
|
+
t.Fatal(err)
|
|
96
|
+
}
|
|
97
|
+
if len(steps) != 1 || steps[0].Status != run.StepSucceeded || steps[0].Message != "accepted" || steps[0].Route != "end" {
|
|
98
|
+
t.Fatalf("stale write regressed terminal step: %#v", steps)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
func TestBranchLoopVisitsRemainDistinctAndNested(t *testing.T) {
|
|
103
|
+
ctx := context.Background()
|
|
104
|
+
p, _ := openProjection(t)
|
|
105
|
+
start := projectionStart("branch-loop", "PAY-BRANCH")
|
|
106
|
+
if err := p.InsertStart(ctx, start, time.Now().UTC()); err != nil {
|
|
107
|
+
t.Fatal(err)
|
|
108
|
+
}
|
|
109
|
+
steps := []run.StepEntry{
|
|
110
|
+
{RunID: start.ID, Sequence: 1, Node: "coding", NodeVisitID: "visit-coding-1", Status: run.StepSucceeded, Depth: 1},
|
|
111
|
+
{RunID: start.ID, Sequence: 2, Node: "review", NodeVisitID: "visit-review-1", Status: run.StepSucceeded, Depth: 1},
|
|
112
|
+
{RunID: start.ID, Sequence: 3, Node: "coding", NodeVisitID: "visit-coding-2", ParentSequence: 1, Status: run.StepWaiting, Depth: 2},
|
|
113
|
+
}
|
|
114
|
+
for _, step := range steps {
|
|
115
|
+
if err := p.UpsertStep(ctx, step); err != nil {
|
|
116
|
+
t.Fatal(err)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
got, err := p.ListSteps(ctx, start.ID)
|
|
120
|
+
if err != nil {
|
|
121
|
+
t.Fatal(err)
|
|
122
|
+
}
|
|
123
|
+
if len(got) != len(steps) || got[2].NodeVisitID != "visit-coding-2" || got[2].ParentSequence != 1 || got[2].Depth != 2 {
|
|
124
|
+
t.Fatalf("branch/loop steps = %#v", got)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
func TestStepProjectionFailureDoesNotBlockAuthoritativeRunState(t *testing.T) {
|
|
129
|
+
ctx := context.Background()
|
|
130
|
+
p, db := openProjection(t)
|
|
131
|
+
start := projectionStart("projection-failure", "PAY-PROJECTION")
|
|
132
|
+
if err := p.InsertStart(ctx, start, time.Now().UTC()); err != nil {
|
|
133
|
+
t.Fatal(err)
|
|
134
|
+
}
|
|
135
|
+
if _, err := db.Exec(`DROP TABLE relay_run_steps`); err != nil {
|
|
136
|
+
t.Fatal(err)
|
|
137
|
+
}
|
|
138
|
+
if err := p.UpdateState(ctx, start.ID, run.StateWaiting, "", nil); err != nil {
|
|
139
|
+
t.Fatalf("authoritative state update failed with missing display table: %v", err)
|
|
140
|
+
}
|
|
141
|
+
got, err := p.Get(ctx, start.ID)
|
|
142
|
+
if err != nil {
|
|
143
|
+
t.Fatal(err)
|
|
144
|
+
}
|
|
145
|
+
if got.State != run.StateWaiting {
|
|
146
|
+
t.Fatalf("state = %s, want waiting", got.State)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
func TestTerminalStepTimingIsStableAcrossLaterRunFinalization(t *testing.T) {
|
|
151
|
+
ctx := context.Background()
|
|
152
|
+
p, _ := openProjection(t)
|
|
153
|
+
start := projectionStart("stable-terminal", "PAY-STABLE")
|
|
154
|
+
t0 := time.Now().UTC().Add(-2 * time.Minute)
|
|
155
|
+
t1 := t0.Add(20 * time.Second)
|
|
156
|
+
if err := p.InsertStart(ctx, start, t0); err != nil {
|
|
157
|
+
t.Fatal(err)
|
|
158
|
+
}
|
|
159
|
+
if err := p.UpsertStep(ctx, run.StepEntry{RunID: start.ID, Sequence: 1, Node: "coding", Status: run.StepSucceeded, StartedAt: &t0, FinishedAt: &t1}); err != nil {
|
|
160
|
+
t.Fatal(err)
|
|
161
|
+
}
|
|
162
|
+
before, err := p.ListSteps(ctx, start.ID)
|
|
163
|
+
if err != nil {
|
|
164
|
+
t.Fatal(err)
|
|
165
|
+
}
|
|
166
|
+
if len(before) != 1 || before[0].FinishedAt == nil || before[0].Duration <= 0 {
|
|
167
|
+
t.Fatalf("initial terminal step = %#v", before)
|
|
168
|
+
}
|
|
169
|
+
finished := t1.Add(time.Minute)
|
|
170
|
+
if err := p.UpdateState(ctx, start.ID, run.StateCanceled, "canceled", &finished); err != nil {
|
|
171
|
+
t.Fatal(err)
|
|
172
|
+
}
|
|
173
|
+
if err := p.UpdateState(ctx, start.ID, run.StateCanceled, "canceled", &finished); err != nil {
|
|
174
|
+
t.Fatal(err)
|
|
175
|
+
}
|
|
176
|
+
after, err := p.ListSteps(ctx, start.ID)
|
|
177
|
+
if err != nil {
|
|
178
|
+
t.Fatal(err)
|
|
179
|
+
}
|
|
180
|
+
if len(after) != 1 || !after[0].FinishedAt.Equal(*before[0].FinishedAt) || after[0].Duration != before[0].Duration {
|
|
181
|
+
t.Fatalf("terminal timing changed after run finalization: before=%#v after=%#v", before, after)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
func TestCompletedRunFinalizesActiveStepWhenFinalUpsertIsMissing(t *testing.T) {
|
|
186
|
+
ctx := context.Background()
|
|
187
|
+
p, _ := openProjection(t)
|
|
188
|
+
start := projectionStart("complete-detail", "PAY-COMPLETE-DETAIL")
|
|
189
|
+
started := time.Now().UTC().Add(-time.Minute)
|
|
190
|
+
if err := p.InsertStart(ctx, start, started); err != nil {
|
|
191
|
+
t.Fatal(err)
|
|
192
|
+
}
|
|
193
|
+
if err := p.UpsertStep(ctx, run.StepEntry{RunID: start.ID, Sequence: 1, Node: "end", Status: run.StepRunning, StartedAt: &started}); err != nil {
|
|
194
|
+
t.Fatal(err)
|
|
195
|
+
}
|
|
196
|
+
finished := time.Now().UTC()
|
|
197
|
+
if err := p.UpdateState(ctx, start.ID, run.StateCompleted, "", &finished); err != nil {
|
|
198
|
+
t.Fatal(err)
|
|
199
|
+
}
|
|
200
|
+
steps, err := p.ListSteps(ctx, start.ID)
|
|
201
|
+
if err != nil {
|
|
202
|
+
t.Fatal(err)
|
|
203
|
+
}
|
|
204
|
+
if len(steps) != 1 || steps[0].Status != run.StepSucceeded || steps[0].FinishedAt == nil || steps[0].Duration <= 0 {
|
|
205
|
+
t.Fatalf("completed step = %#v", steps)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
func TestCanceledRunFinalizesCurrentStepTiming(t *testing.T) {
|
|
210
|
+
ctx := context.Background()
|
|
211
|
+
p, _ := openProjection(t)
|
|
212
|
+
start := projectionStart("cancel-detail", "PAY-CANCEL-DETAIL")
|
|
213
|
+
started := time.Now().UTC().Add(-time.Minute)
|
|
214
|
+
if err := p.InsertStart(ctx, start, started); err != nil {
|
|
215
|
+
t.Fatal(err)
|
|
216
|
+
}
|
|
217
|
+
if err := p.UpsertStep(ctx, run.StepEntry{RunID: start.ID, Sequence: 1, Node: "coding", Status: run.StepRunning, StartedAt: &started}); err != nil {
|
|
218
|
+
t.Fatal(err)
|
|
219
|
+
}
|
|
220
|
+
if err := p.UpdateState(ctx, start.ID, run.StateCanceling, "operator canceled", nil); err != nil {
|
|
221
|
+
t.Fatal(err)
|
|
222
|
+
}
|
|
223
|
+
finished := time.Now().UTC()
|
|
224
|
+
if err := p.UpdateState(ctx, start.ID, run.StateCanceled, "operator canceled", &finished); err != nil {
|
|
225
|
+
t.Fatal(err)
|
|
226
|
+
}
|
|
227
|
+
steps, err := p.ListSteps(ctx, start.ID)
|
|
228
|
+
if err != nil {
|
|
229
|
+
t.Fatal(err)
|
|
230
|
+
}
|
|
231
|
+
if len(steps) != 1 || steps[0].Status != run.StepCanceled || steps[0].FinishedAt == nil || steps[0].Duration <= 0 {
|
|
232
|
+
t.Fatalf("canceled step = %#v", steps)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
func TestSetupConflictMarksCurrentStepBlocked(t *testing.T) {
|
|
237
|
+
ctx := context.Background()
|
|
238
|
+
p, _ := openProjection(t)
|
|
239
|
+
start := projectionStart("setup-conflict", "PAY-SETUP")
|
|
240
|
+
if err := p.InsertStart(ctx, start, time.Now().UTC()); err != nil {
|
|
241
|
+
t.Fatal(err)
|
|
242
|
+
}
|
|
243
|
+
if err := p.UpsertStep(ctx, run.StepEntry{RunID: start.ID, Sequence: 1, Node: "coding", Status: run.StepRunning}); err != nil {
|
|
244
|
+
t.Fatal(err)
|
|
245
|
+
}
|
|
246
|
+
if err := p.UpdateState(ctx, start.ID, run.StateBlocked, "runner setup conflict", nil); err != nil {
|
|
247
|
+
t.Fatal(err)
|
|
248
|
+
}
|
|
249
|
+
steps, err := p.ListSteps(ctx, start.ID)
|
|
250
|
+
if err != nil {
|
|
251
|
+
t.Fatal(err)
|
|
252
|
+
}
|
|
253
|
+
if len(steps) != 1 || steps[0].Status != run.StepBlocked || steps[0].Message != "runner setup conflict" {
|
|
254
|
+
t.Fatalf("blocked setup step = %#v", steps)
|
|
255
|
+
}
|
|
256
|
+
if steps[0].DurationKnown {
|
|
257
|
+
t.Fatal("unknown setup duration was fabricated as known")
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -5,6 +5,7 @@ import (
|
|
|
5
5
|
"database/sql"
|
|
6
6
|
"errors"
|
|
7
7
|
"fmt"
|
|
8
|
+
"log/slog"
|
|
8
9
|
"os"
|
|
9
10
|
"strings"
|
|
10
11
|
"sync"
|
|
@@ -117,6 +118,7 @@ CREATE TABLE IF NOT EXISTS relay_runs (
|
|
|
117
118
|
workflow TEXT NOT NULL,
|
|
118
119
|
ticket_id TEXT NOT NULL,
|
|
119
120
|
ticket_key TEXT NOT NULL,
|
|
121
|
+
created_at DATETIME,
|
|
120
122
|
state TEXT NOT NULL,
|
|
121
123
|
current_node TEXT,
|
|
122
124
|
current_node_visit_id TEXT,
|
|
@@ -158,6 +160,28 @@ CREATE TABLE IF NOT EXISTS relay_node_sessions (
|
|
|
158
160
|
PRIMARY KEY (run_id, node, session_id),
|
|
159
161
|
FOREIGN KEY (run_id) REFERENCES relay_runs(id) ON DELETE CASCADE
|
|
160
162
|
);
|
|
163
|
+
CREATE TABLE IF NOT EXISTS relay_run_steps (
|
|
164
|
+
run_id TEXT NOT NULL,
|
|
165
|
+
sequence INTEGER NOT NULL,
|
|
166
|
+
node TEXT NOT NULL,
|
|
167
|
+
node_visit_id TEXT,
|
|
168
|
+
node_type TEXT,
|
|
169
|
+
parent_sequence INTEGER,
|
|
170
|
+
depth INTEGER,
|
|
171
|
+
status TEXT NOT NULL,
|
|
172
|
+
started_at DATETIME,
|
|
173
|
+
finished_at DATETIME,
|
|
174
|
+
duration_ns INTEGER,
|
|
175
|
+
message TEXT,
|
|
176
|
+
selected_route TEXT,
|
|
177
|
+
runtime TEXT,
|
|
178
|
+
resource TEXT,
|
|
179
|
+
PRIMARY KEY (run_id, sequence),
|
|
180
|
+
FOREIGN KEY (run_id) REFERENCES relay_runs(id) ON DELETE CASCADE
|
|
181
|
+
);
|
|
182
|
+
CREATE INDEX IF NOT EXISTS relay_run_steps_run_order ON relay_run_steps (run_id, sequence);
|
|
183
|
+
CREATE UNIQUE INDEX IF NOT EXISTS relay_run_steps_run_visit ON relay_run_steps (run_id, node_visit_id)
|
|
184
|
+
WHERE node_visit_id IS NOT NULL AND node_visit_id <> '';
|
|
161
185
|
`
|
|
162
186
|
|
|
163
187
|
func (p *RunProjection) migrate() error {
|
|
@@ -170,6 +194,7 @@ func (p *RunProjection) migrate() error {
|
|
|
170
194
|
"retry_error": "TEXT",
|
|
171
195
|
"retry_attempt": "INTEGER",
|
|
172
196
|
"next_retry_at": "DATETIME",
|
|
197
|
+
"created_at": "DATETIME",
|
|
173
198
|
} {
|
|
174
199
|
var count int
|
|
175
200
|
if err := p.DB.QueryRow(`SELECT COUNT(1) FROM pragma_table_info('relay_runs') WHERE name = ?`, name).Scan(&count); err != nil {
|
|
@@ -190,10 +215,24 @@ func (p *RunProjection) migrate() error {
|
|
|
190
215
|
if _, err := p.DB.Exec(`UPDATE relay_runs SET attempt_id = 1 WHERE attempt_id IS NULL OR attempt_id = 0`); err != nil {
|
|
191
216
|
return err
|
|
192
217
|
}
|
|
218
|
+
for name, definition := range map[string]string{
|
|
219
|
+
"parent_sequence": "INTEGER",
|
|
220
|
+
"depth": "INTEGER",
|
|
221
|
+
} {
|
|
222
|
+
var count int
|
|
223
|
+
if err := p.DB.QueryRow(`SELECT COUNT(1) FROM pragma_table_info('relay_run_steps') WHERE name = ?`, name).Scan(&count); err != nil {
|
|
224
|
+
return err
|
|
225
|
+
}
|
|
226
|
+
if count == 0 {
|
|
227
|
+
if _, err := p.DB.Exec(`ALTER TABLE relay_run_steps ADD COLUMN ` + name + ` ` + definition); err != nil {
|
|
228
|
+
return err
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
193
232
|
return nil
|
|
194
233
|
}
|
|
195
234
|
|
|
196
|
-
var errRunNotFound =
|
|
235
|
+
var errRunNotFound = run.ErrNotFound
|
|
197
236
|
var errNodeRuntimeNotFound = errors.New("node runtime not found")
|
|
198
237
|
|
|
199
238
|
// IsNotFound reports a missing projection row.
|
|
@@ -209,24 +248,86 @@ func (p *RunProjection) insertStart(ctx context.Context, s run.Start, now time.T
|
|
|
209
248
|
attemptID = 1
|
|
210
249
|
}
|
|
211
250
|
_, err := p.DB.ExecContext(ctx, `
|
|
212
|
-
INSERT INTO relay_runs (id, logical_run_id, attempt_id, repo, workflow, ticket_id, ticket_key, state, started_at, updated_at)
|
|
213
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
251
|
+
INSERT INTO relay_runs (id, logical_run_id, attempt_id, repo, workflow, ticket_id, ticket_key, created_at, state, started_at, updated_at)
|
|
252
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
214
253
|
ON CONFLICT(id) DO NOTHING`,
|
|
215
254
|
string(s.ID), string(logicalID), int64(attemptID), s.Repo, s.Workflow.Name, s.Ticket.ID, s.Ticket.Key,
|
|
216
|
-
string(run.StateStarting), now, now)
|
|
255
|
+
now, string(run.StateStarting), now, now)
|
|
217
256
|
return err
|
|
218
257
|
}
|
|
219
258
|
|
|
220
259
|
func (p *RunProjection) updateState(ctx context.Context, id run.ID, state run.State, lastErr string, finished *time.Time) error {
|
|
221
260
|
terminal := state == run.StateCompleted || state == run.StateCanceled
|
|
261
|
+
now := time.Now().UTC()
|
|
222
262
|
_, err := p.DB.ExecContext(ctx, `
|
|
223
263
|
UPDATE relay_runs SET state = ?, last_error = ?, updated_at = ?, finished_at = COALESCE(?, finished_at),
|
|
224
264
|
retry_error = CASE WHEN ? THEN NULL ELSE retry_error END,
|
|
225
265
|
retry_attempt = CASE WHEN ? THEN NULL ELSE retry_attempt END,
|
|
226
266
|
next_retry_at = CASE WHEN ? THEN NULL ELSE next_retry_at END
|
|
227
267
|
WHERE id = ?`,
|
|
228
|
-
string(state), lastErr,
|
|
229
|
-
|
|
268
|
+
string(state), lastErr, now, finished, terminal, terminal, terminal, string(id))
|
|
269
|
+
if err != nil {
|
|
270
|
+
return err
|
|
271
|
+
}
|
|
272
|
+
// Keep the derived timeline useful during retries and cancellation without
|
|
273
|
+
// making it an execution authority. Identify one active row first; a
|
|
274
|
+
// repeated terminal update with no active row is a no-op for the timeline.
|
|
275
|
+
stepStatus := stepStatusForRunState(state)
|
|
276
|
+
if stepStatus == "" {
|
|
277
|
+
return nil
|
|
278
|
+
}
|
|
279
|
+
var sequence int64
|
|
280
|
+
var startedAt sql.NullTime
|
|
281
|
+
lookupErr := p.DB.QueryRowContext(ctx, `
|
|
282
|
+
SELECT sequence, started_at FROM relay_run_steps
|
|
283
|
+
WHERE run_id = ? AND status IN ('running', 'waiting', 'blocked')
|
|
284
|
+
ORDER BY sequence DESC LIMIT 1`, string(id)).Scan(&sequence, &startedAt)
|
|
285
|
+
if errors.Is(lookupErr, sql.ErrNoRows) {
|
|
286
|
+
return nil
|
|
287
|
+
}
|
|
288
|
+
if lookupErr != nil {
|
|
289
|
+
// relay_run_steps is display/cache data. The authoritative relay_runs
|
|
290
|
+
// state update above has already succeeded and must not be retried just
|
|
291
|
+
// because this optional projection is unavailable.
|
|
292
|
+
slog.Warn("step projection state lookup unavailable", "runID", string(id), "state", state, "error", lookupErr)
|
|
293
|
+
return nil
|
|
294
|
+
}
|
|
295
|
+
var stepFinished any
|
|
296
|
+
if terminal {
|
|
297
|
+
if finished != nil {
|
|
298
|
+
stepFinished = *finished
|
|
299
|
+
} else {
|
|
300
|
+
stepFinished = now
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
result, err := p.DB.ExecContext(ctx, `
|
|
304
|
+
UPDATE relay_run_steps SET status = ?, message = CASE WHEN ? <> '' THEN ? ELSE message END,
|
|
305
|
+
finished_at = CASE WHEN ? THEN COALESCE(finished_at, ?) ELSE finished_at END
|
|
306
|
+
WHERE run_id = ? AND sequence = ? AND status IN ('running', 'waiting', 'blocked')`,
|
|
307
|
+
stepStatus, lastErr, lastErr, terminal, stepFinished,
|
|
308
|
+
string(id), sequence)
|
|
309
|
+
if err != nil {
|
|
310
|
+
slog.Warn("step projection state update unavailable", "runID", string(id), "state", state, "error", err)
|
|
311
|
+
return nil
|
|
312
|
+
}
|
|
313
|
+
updated, err := result.RowsAffected()
|
|
314
|
+
if err != nil || updated != 1 {
|
|
315
|
+
return nil
|
|
316
|
+
}
|
|
317
|
+
if terminal && startedAt.Valid {
|
|
318
|
+
endAt := now
|
|
319
|
+
if finished != nil {
|
|
320
|
+
endAt = *finished
|
|
321
|
+
}
|
|
322
|
+
duration := endAt.Sub(startedAt.Time)
|
|
323
|
+
if duration < 0 {
|
|
324
|
+
duration = 0
|
|
325
|
+
}
|
|
326
|
+
if _, durationErr := p.DB.ExecContext(ctx, `UPDATE relay_run_steps SET duration_ns = ? WHERE run_id = ? AND sequence = ?`, int64(duration), string(id), sequence); durationErr != nil {
|
|
327
|
+
slog.Warn("step projection duration update unavailable", "runID", string(id), "state", state, "error", durationErr)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return nil
|
|
230
331
|
}
|
|
231
332
|
|
|
232
333
|
func (p *RunProjection) updateRetry(ctx context.Context, id run.ID, status *run.RetryStatus) error {
|
|
@@ -271,6 +372,169 @@ func (p *RunProjection) updateNode(ctx context.Context, id run.ID, state run.Sta
|
|
|
271
372
|
return tx.Commit()
|
|
272
373
|
}
|
|
273
374
|
|
|
375
|
+
func stepStatusForRunState(state run.State) string {
|
|
376
|
+
switch state {
|
|
377
|
+
case run.StateRunning:
|
|
378
|
+
return string(run.StepRunning)
|
|
379
|
+
case run.StateWaiting:
|
|
380
|
+
return string(run.StepWaiting)
|
|
381
|
+
case run.StateBlocked:
|
|
382
|
+
return string(run.StepBlocked)
|
|
383
|
+
case run.StateCompleted:
|
|
384
|
+
return string(run.StepSucceeded)
|
|
385
|
+
case run.StateCanceled:
|
|
386
|
+
return string(run.StepCanceled)
|
|
387
|
+
default:
|
|
388
|
+
return ""
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// upsertStep records one display row idempotently. The durable executor calls
|
|
393
|
+
// this at existing lifecycle boundaries; no route or report decision reads
|
|
394
|
+
// this table.
|
|
395
|
+
func (p *RunProjection) upsertStep(ctx context.Context, step run.StepEntry) error {
|
|
396
|
+
if step.RunID == "" {
|
|
397
|
+
return fmt.Errorf("step projection requires a run id")
|
|
398
|
+
}
|
|
399
|
+
if step.Node == "" {
|
|
400
|
+
return fmt.Errorf("step projection requires a node")
|
|
401
|
+
}
|
|
402
|
+
if step.Status == "" {
|
|
403
|
+
return fmt.Errorf("step projection requires a status")
|
|
404
|
+
}
|
|
405
|
+
var startedAt, finishedAt any
|
|
406
|
+
if step.StartedAt != nil && !step.StartedAt.IsZero() {
|
|
407
|
+
startedAt = step.StartedAt.UTC()
|
|
408
|
+
}
|
|
409
|
+
if step.FinishedAt != nil && !step.FinishedAt.IsZero() {
|
|
410
|
+
finishedAt = step.FinishedAt.UTC()
|
|
411
|
+
}
|
|
412
|
+
_, err := p.DB.ExecContext(ctx, `
|
|
413
|
+
INSERT INTO relay_run_steps
|
|
414
|
+
(run_id, sequence, node, node_visit_id, node_type, parent_sequence, depth, status, started_at, finished_at,
|
|
415
|
+
duration_ns, message, selected_route, runtime, resource)
|
|
416
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
417
|
+
ON CONFLICT(run_id, sequence) DO UPDATE SET
|
|
418
|
+
node = excluded.node,
|
|
419
|
+
node_visit_id = COALESCE(NULLIF(excluded.node_visit_id, ''), relay_run_steps.node_visit_id),
|
|
420
|
+
node_type = COALESCE(NULLIF(excluded.node_type, ''), relay_run_steps.node_type),
|
|
421
|
+
parent_sequence = CASE WHEN excluded.parent_sequence <> 0 THEN excluded.parent_sequence ELSE relay_run_steps.parent_sequence END,
|
|
422
|
+
depth = CASE WHEN excluded.depth <> 0 THEN excluded.depth ELSE relay_run_steps.depth END,
|
|
423
|
+
status = CASE
|
|
424
|
+
WHEN relay_run_steps.status IN ('succeeded', 'failed', 'canceled') THEN relay_run_steps.status
|
|
425
|
+
WHEN relay_run_steps.status = 'blocked' AND excluded.status IN ('pending', 'running', 'waiting') THEN relay_run_steps.status
|
|
426
|
+
WHEN relay_run_steps.status = 'waiting' AND excluded.status IN ('pending', 'running') THEN relay_run_steps.status
|
|
427
|
+
WHEN relay_run_steps.status = 'running' AND excluded.status = 'pending' THEN relay_run_steps.status
|
|
428
|
+
ELSE excluded.status
|
|
429
|
+
END,
|
|
430
|
+
started_at = COALESCE(relay_run_steps.started_at, excluded.started_at),
|
|
431
|
+
finished_at = CASE
|
|
432
|
+
WHEN relay_run_steps.status IN ('succeeded', 'failed', 'canceled') THEN relay_run_steps.finished_at
|
|
433
|
+
ELSE COALESCE(excluded.finished_at, relay_run_steps.finished_at)
|
|
434
|
+
END,
|
|
435
|
+
duration_ns = CASE
|
|
436
|
+
WHEN relay_run_steps.status IN ('succeeded', 'failed', 'canceled') THEN relay_run_steps.duration_ns
|
|
437
|
+
WHEN excluded.duration_ns <> 0 THEN excluded.duration_ns
|
|
438
|
+
WHEN excluded.finished_at IS NOT NULL AND COALESCE(relay_run_steps.started_at, excluded.started_at) IS NOT NULL
|
|
439
|
+
THEN CAST((julianday(excluded.finished_at) - julianday(COALESCE(relay_run_steps.started_at, excluded.started_at))) * 86400000000000 AS INTEGER)
|
|
440
|
+
ELSE relay_run_steps.duration_ns
|
|
441
|
+
END,
|
|
442
|
+
message = CASE WHEN relay_run_steps.status IN ('succeeded', 'failed', 'canceled') THEN relay_run_steps.message ELSE COALESCE(NULLIF(excluded.message, ''), relay_run_steps.message) END,
|
|
443
|
+
selected_route = CASE WHEN relay_run_steps.status IN ('succeeded', 'failed', 'canceled') THEN relay_run_steps.selected_route ELSE COALESCE(NULLIF(excluded.selected_route, ''), relay_run_steps.selected_route) END,
|
|
444
|
+
runtime = COALESCE(NULLIF(excluded.runtime, ''), relay_run_steps.runtime),
|
|
445
|
+
resource = COALESCE(NULLIF(excluded.resource, ''), relay_run_steps.resource)`,
|
|
446
|
+
string(step.RunID), step.Sequence, step.Node, nullableString(string(step.NodeVisitID)),
|
|
447
|
+
nullableString(step.NodeType), step.ParentSequence, step.Depth, string(step.Status), startedAt, finishedAt,
|
|
448
|
+
durationNanos(step), nullableString(step.Message), nullableString(step.Route),
|
|
449
|
+
nullableString(step.Runtime), nullableString(step.Resource))
|
|
450
|
+
return err
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
func durationNanos(step run.StepEntry) any {
|
|
454
|
+
if step.Duration != 0 {
|
|
455
|
+
return int64(step.Duration)
|
|
456
|
+
}
|
|
457
|
+
if step.StartedAt != nil && step.FinishedAt != nil {
|
|
458
|
+
duration := step.FinishedAt.Sub(*step.StartedAt)
|
|
459
|
+
if duration < 0 {
|
|
460
|
+
duration = 0
|
|
461
|
+
}
|
|
462
|
+
return int64(duration)
|
|
463
|
+
}
|
|
464
|
+
if step.DurationKnown {
|
|
465
|
+
return int64(0)
|
|
466
|
+
}
|
|
467
|
+
return nil
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
func (p *RunProjection) listSteps(ctx context.Context, id run.ID) ([]run.StepEntry, error) {
|
|
471
|
+
rows, err := p.DB.QueryContext(ctx, `
|
|
472
|
+
SELECT run_id, sequence, node, node_visit_id, node_type, parent_sequence, depth, status,
|
|
473
|
+
started_at, finished_at, duration_ns, message, selected_route, runtime, resource
|
|
474
|
+
FROM relay_run_steps WHERE run_id = ? ORDER BY sequence`, string(id))
|
|
475
|
+
if err != nil {
|
|
476
|
+
return nil, err
|
|
477
|
+
}
|
|
478
|
+
defer rows.Close()
|
|
479
|
+
steps := make([]run.StepEntry, 0)
|
|
480
|
+
for rows.Next() {
|
|
481
|
+
var step run.StepEntry
|
|
482
|
+
var visit, nodeType, message, route, runtime, resource sql.NullString
|
|
483
|
+
var started, finished sql.NullTime
|
|
484
|
+
var duration, parentSequence, depth sql.NullInt64
|
|
485
|
+
if err := rows.Scan(&step.RunID, &step.Sequence, &step.Node, &visit, &nodeType,
|
|
486
|
+
&parentSequence, &depth, &step.Status, &started, &finished, &duration,
|
|
487
|
+
&message, &route, &runtime, &resource); err != nil {
|
|
488
|
+
return nil, err
|
|
489
|
+
}
|
|
490
|
+
step.NodeVisitID = run.NodeVisitID(visit.String)
|
|
491
|
+
if parentSequence.Valid {
|
|
492
|
+
step.ParentSequence = parentSequence.Int64
|
|
493
|
+
}
|
|
494
|
+
if depth.Valid {
|
|
495
|
+
step.Depth = int(depth.Int64)
|
|
496
|
+
}
|
|
497
|
+
step.NodeType, step.Message, step.Route = nodeType.String, message.String, route.String
|
|
498
|
+
step.Runtime, step.Resource = runtime.String, resource.String
|
|
499
|
+
if started.Valid {
|
|
500
|
+
t := started.Time.UTC()
|
|
501
|
+
step.StartedAt = &t
|
|
502
|
+
}
|
|
503
|
+
if finished.Valid {
|
|
504
|
+
t := finished.Time.UTC()
|
|
505
|
+
step.FinishedAt = &t
|
|
506
|
+
}
|
|
507
|
+
if duration.Valid {
|
|
508
|
+
step.Duration = time.Duration(duration.Int64)
|
|
509
|
+
step.DurationKnown = true
|
|
510
|
+
}
|
|
511
|
+
steps = append(steps, step)
|
|
512
|
+
}
|
|
513
|
+
return steps, rows.Err()
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
func (p *RunProjection) getDetail(ctx context.Context, id run.ID) (run.RunDetail, error) {
|
|
517
|
+
base, err := p.get(ctx, id)
|
|
518
|
+
if err != nil {
|
|
519
|
+
return run.RunDetail{}, err
|
|
520
|
+
}
|
|
521
|
+
detail := run.NewRunDetail(base)
|
|
522
|
+
var created sql.NullTime
|
|
523
|
+
if err := p.DB.QueryRowContext(ctx, `SELECT created_at FROM relay_runs WHERE id = ?`, string(id)).Scan(&created); err != nil {
|
|
524
|
+
return run.RunDetail{}, err
|
|
525
|
+
}
|
|
526
|
+
if created.Valid {
|
|
527
|
+
createdAt := created.Time.UTC()
|
|
528
|
+
detail.CreatedAt = &createdAt
|
|
529
|
+
}
|
|
530
|
+
detail.Steps, err = p.listSteps(ctx, id)
|
|
531
|
+
if err != nil {
|
|
532
|
+
return run.RunDetail{}, err
|
|
533
|
+
}
|
|
534
|
+
detail.DeriveInspectionFields(time.Now().UTC())
|
|
535
|
+
return detail, nil
|
|
536
|
+
}
|
|
537
|
+
|
|
274
538
|
func (p *RunProjection) getNodeRuntime(ctx context.Context, id run.ID, node string) (NodeRuntime, error) {
|
|
275
539
|
var rt NodeRuntime
|
|
276
540
|
var terminalID, sessionID sql.NullString
|
|
@@ -610,6 +874,10 @@ func (p *RunProjection) sweepRetention(ctx context.Context, olderThan time.Time)
|
|
|
610
874
|
tx.Rollback()
|
|
611
875
|
return ids, err
|
|
612
876
|
}
|
|
877
|
+
if _, err := tx.ExecContext(ctx, `DELETE FROM relay_run_steps WHERE run_id = ?`, id); err != nil {
|
|
878
|
+
tx.Rollback()
|
|
879
|
+
return ids, err
|
|
880
|
+
}
|
|
613
881
|
if _, err := tx.ExecContext(ctx, `DELETE FROM relay_runs WHERE id = ?`, id); err != nil {
|
|
614
882
|
tx.Rollback()
|
|
615
883
|
return ids, err
|
|
@@ -649,6 +917,22 @@ func (p *RunProjection) UpdateNode(ctx context.Context, id run.ID, state run.Sta
|
|
|
649
917
|
return p.updateNode(ctx, id, state, node, visit)
|
|
650
918
|
}
|
|
651
919
|
|
|
920
|
+
// UpsertStep records or updates one display-only execution step.
|
|
921
|
+
func (p *RunProjection) UpsertStep(ctx context.Context, step run.StepEntry) error {
|
|
922
|
+
return p.upsertStep(ctx, step)
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// ListSteps returns the ordered display-only execution timeline for a run.
|
|
926
|
+
func (p *RunProjection) ListSteps(ctx context.Context, id run.ID) ([]run.StepEntry, error) {
|
|
927
|
+
return p.listSteps(ctx, id)
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// GetDetail returns a run and its derived inspection data in one query
|
|
931
|
+
// boundary. The result never drives durable execution.
|
|
932
|
+
func (p *RunProjection) GetDetail(ctx context.Context, id run.ID) (run.RunDetail, error) {
|
|
933
|
+
return p.getDetail(ctx, id)
|
|
934
|
+
}
|
|
935
|
+
|
|
652
936
|
// GetNodeRuntime returns one node's persisted runtime binding.
|
|
653
937
|
func (p *RunProjection) GetNodeRuntime(ctx context.Context, id run.ID, node string) (NodeRuntime, error) {
|
|
654
938
|
return p.getNodeRuntime(ctx, id, node)
|
|
@@ -80,6 +80,7 @@ func TestSharedProjectionSchemaAndQueries(t *testing.T) {
|
|
|
80
80
|
"relay_node_runtime",
|
|
81
81
|
"relay_node_sessions",
|
|
82
82
|
"relay_processed_reports",
|
|
83
|
+
"relay_run_steps",
|
|
83
84
|
"relay_runs",
|
|
84
85
|
}
|
|
85
86
|
if len(tables) != len(wantTables) {
|
|
@@ -209,8 +210,8 @@ func TestProjectionImplementationIsSharedByExecutorModes(t *testing.T) {
|
|
|
209
210
|
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name LIKE 'relay_%'`).Scan(&count); err != nil {
|
|
210
211
|
t.Fatal(err)
|
|
211
212
|
}
|
|
212
|
-
if count !=
|
|
213
|
-
t.Fatalf("relay schema table count = %d, want
|
|
213
|
+
if count != 6 {
|
|
214
|
+
t.Fatalf("relay schema table count = %d, want six shared tables", count)
|
|
214
215
|
}
|
|
215
216
|
if err := p.InsertStart(context.Background(), projectionStart(run.ID("run-"+mode), "PAY-"+mode), time.Now().UTC()); err != nil {
|
|
216
217
|
t.Fatalf("insert %s run through shared projection: %v", mode, err)
|
|
@@ -411,6 +411,15 @@ func (a *Activities) CompleteMailbox(ctx context.Context, w run.Work, mailbox ta
|
|
|
411
411
|
|
|
412
412
|
// Projection activities: idempotent read-model updates.
|
|
413
413
|
|
|
414
|
+
func (a *Activities) ProjectionUpsertStep(ctx context.Context, step run.StepEntry) error {
|
|
415
|
+
if err := a.Runs.UpsertStep(ctx, step); err != nil {
|
|
416
|
+
// Step detail is a cache. Never let a display-projection failure block
|
|
417
|
+
// route selection, report acceptance, or durable graph progression.
|
|
418
|
+
slog.Warn("step projection unavailable", "runID", string(step.RunID), "node", step.Node, "sequence", step.Sequence, "error", err)
|
|
419
|
+
}
|
|
420
|
+
return nil
|
|
421
|
+
}
|
|
422
|
+
|
|
414
423
|
func (a *Activities) ProjectionUpdateNode(ctx context.Context, id run.ID, state run.State, node string, visit run.NodeVisitID) error {
|
|
415
424
|
return a.Runs.UpdateNode(ctx, id, state, node, visit)
|
|
416
425
|
}
|
|
@@ -362,6 +362,19 @@ func (e *Engine) GetRun(ctx context.Context, id run.ID) (run.Run, error) {
|
|
|
362
362
|
func (e *Engine) FindRunByTicket(ctx context.Context, ticket string) (run.Run, error) {
|
|
363
363
|
return e.runs.FindByTicket(ctx, ticket)
|
|
364
364
|
}
|
|
365
|
+
func (e *Engine) GetRunDetail(ctx context.Context, id run.ID) (run.RunDetail, error) {
|
|
366
|
+
detail, err := e.runs.GetDetail(ctx, id)
|
|
367
|
+
if err != nil {
|
|
368
|
+
return run.RunDetail{}, err
|
|
369
|
+
}
|
|
370
|
+
// Static definitions only contribute explicit pending display rows. The
|
|
371
|
+
// derived projection is never consulted by Temporal workflow code.
|
|
372
|
+
if wf, wfErr := e.workflowFromHistory(ctx, id, ""); wfErr == nil {
|
|
373
|
+
detail.AddPendingNodes(*wf)
|
|
374
|
+
detail.DeriveInspectionFields(time.Now().UTC())
|
|
375
|
+
}
|
|
376
|
+
return detail, nil
|
|
377
|
+
}
|
|
365
378
|
func (e *Engine) ListRuns(ctx context.Context, filter run.Filter) ([]run.Run, error) {
|
|
366
379
|
return e.runs.List(ctx, filter)
|
|
367
380
|
}
|