relay-flow 0.2.9-alpha → 0.2.11-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 +19 -5
- package/cmd/relay-flow/main.go +224 -23
- package/cmd/relay-flow/observability_test.go +204 -0
- package/cmd/relay-flow/render.go +629 -0
- package/cmd/relay-flow/serve.go +32 -1
- package/internal/execution/goworkflows/activities.go +9 -0
- package/internal/execution/goworkflows/engine.go +15 -0
- 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 +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/run/detail.go +277 -0
- package/internal/run/detail_test.go +71 -0
- package/internal/run/run.go +4 -0
- package/internal/server/client.go +39 -0
- package/internal/server/observability.go +109 -0
- package/internal/server/observability_test.go +60 -0
- package/internal/server/server.go +36 -0
- package/package.json +1 -1
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"fmt"
|
|
6
|
+
"io"
|
|
7
|
+
"os"
|
|
8
|
+
"sort"
|
|
9
|
+
"strconv"
|
|
10
|
+
"strings"
|
|
11
|
+
"text/tabwriter"
|
|
12
|
+
"time"
|
|
13
|
+
|
|
14
|
+
"github.com/mattn/go-isatty"
|
|
15
|
+
runsvc "github.com/rajpopat27/relay-flow/internal/run"
|
|
16
|
+
"github.com/rajpopat27/relay-flow/internal/server"
|
|
17
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
// renderOptions controls terminal-sensitive decoration. The renderer itself
|
|
21
|
+
// never writes ANSI escapes; marks remain meaningful when color is disabled or
|
|
22
|
+
// output is piped.
|
|
23
|
+
type renderOptions struct {
|
|
24
|
+
ASCII bool
|
|
25
|
+
Width int
|
|
26
|
+
Now time.Time
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func cliRenderOptions() renderOptions {
|
|
30
|
+
ascii := true
|
|
31
|
+
if isatty.IsTerminal(os.Stdout.Fd()) && os.Getenv("TERM") != "dumb" {
|
|
32
|
+
ascii = false
|
|
33
|
+
}
|
|
34
|
+
width := 120
|
|
35
|
+
if value, err := strconv.Atoi(os.Getenv("COLUMNS")); err == nil && value > 0 {
|
|
36
|
+
width = value
|
|
37
|
+
}
|
|
38
|
+
return renderOptions{ASCII: ascii, Width: width, Now: time.Now().UTC()}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func encodeJSON(value any) int {
|
|
42
|
+
enc := json.NewEncoder(os.Stdout)
|
|
43
|
+
enc.SetIndent("", " ")
|
|
44
|
+
if err := enc.Encode(value); err != nil {
|
|
45
|
+
fmt.Fprintln(os.Stderr, err)
|
|
46
|
+
return exitFail
|
|
47
|
+
}
|
|
48
|
+
return exitOK
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func beginLoading(label string, jsonOutput bool) func(error) {
|
|
52
|
+
if jsonOutput || !isatty.IsTerminal(os.Stdout.Fd()) {
|
|
53
|
+
return func(error) {}
|
|
54
|
+
}
|
|
55
|
+
fmt.Fprintf(os.Stderr, "⠋ %s...\n", label)
|
|
56
|
+
return func(err error) {
|
|
57
|
+
if err != nil {
|
|
58
|
+
fmt.Fprintf(os.Stderr, "✗ %s failed\n", label)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
fmt.Fprintf(os.Stderr, "✓ %s loaded\n", label)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func (o renderOptions) now() time.Time {
|
|
66
|
+
if o.Now.IsZero() {
|
|
67
|
+
return time.Now().UTC()
|
|
68
|
+
}
|
|
69
|
+
return o.Now
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
func markForRun(r runsvc.Run, ascii bool) string {
|
|
73
|
+
switch r.State {
|
|
74
|
+
case runsvc.StateCompleted:
|
|
75
|
+
return statusMark("completed", ascii)
|
|
76
|
+
case runsvc.StateCanceled, runsvc.StateCanceling:
|
|
77
|
+
return statusMark("canceled", ascii)
|
|
78
|
+
case runsvc.StateBlocked:
|
|
79
|
+
return statusMark("failed", ascii)
|
|
80
|
+
case runsvc.StateStarting, runsvc.StateRunning, runsvc.StateWaiting:
|
|
81
|
+
return statusMark("running", ascii)
|
|
82
|
+
default:
|
|
83
|
+
return statusMark("pending", ascii)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
func markForStep(status runsvc.StepStatus, ascii bool) string {
|
|
88
|
+
switch status {
|
|
89
|
+
case runsvc.StepSucceeded:
|
|
90
|
+
return statusMark("completed", ascii)
|
|
91
|
+
case runsvc.StepFailed, runsvc.StepBlocked:
|
|
92
|
+
return statusMark("failed", ascii)
|
|
93
|
+
case runsvc.StepCanceled:
|
|
94
|
+
return statusMark("canceled", ascii)
|
|
95
|
+
case runsvc.StepRunning, runsvc.StepWaiting:
|
|
96
|
+
return statusMark("running", ascii)
|
|
97
|
+
default:
|
|
98
|
+
return statusMark("pending", ascii)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
func statusMark(status string, ascii bool) string {
|
|
103
|
+
if ascii {
|
|
104
|
+
switch status {
|
|
105
|
+
case "completed", "valid":
|
|
106
|
+
return "[x]"
|
|
107
|
+
case "running", "waiting", "active":
|
|
108
|
+
return "[>]"
|
|
109
|
+
case "failed", "blocked":
|
|
110
|
+
return "[!]"
|
|
111
|
+
case "canceled":
|
|
112
|
+
return "[-]"
|
|
113
|
+
default:
|
|
114
|
+
return "[ ]"
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
switch status {
|
|
118
|
+
case "completed", "valid":
|
|
119
|
+
return "✓"
|
|
120
|
+
case "running", "waiting", "active":
|
|
121
|
+
return "⟳"
|
|
122
|
+
case "failed", "blocked":
|
|
123
|
+
return "✗"
|
|
124
|
+
case "canceled":
|
|
125
|
+
return "−"
|
|
126
|
+
default:
|
|
127
|
+
return "○"
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
func formatTimestamp(t time.Time) string {
|
|
132
|
+
if t.IsZero() {
|
|
133
|
+
return "-"
|
|
134
|
+
}
|
|
135
|
+
return t.UTC().Format("2006-01-02 15:04:05 UTC")
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func formatDuration(duration time.Duration) string {
|
|
139
|
+
if duration <= 0 {
|
|
140
|
+
return "-"
|
|
141
|
+
}
|
|
142
|
+
seconds := int64(duration / time.Second)
|
|
143
|
+
if seconds == 0 {
|
|
144
|
+
return "<1s"
|
|
145
|
+
}
|
|
146
|
+
days := seconds / 86400
|
|
147
|
+
seconds %= 86400
|
|
148
|
+
hours := seconds / 3600
|
|
149
|
+
seconds %= 3600
|
|
150
|
+
minutes := seconds / 60
|
|
151
|
+
seconds %= 60
|
|
152
|
+
var parts []string
|
|
153
|
+
if days > 0 {
|
|
154
|
+
parts = append(parts, fmt.Sprintf("%dd", days))
|
|
155
|
+
}
|
|
156
|
+
if hours > 0 || days > 0 {
|
|
157
|
+
parts = append(parts, fmt.Sprintf("%dh", hours))
|
|
158
|
+
}
|
|
159
|
+
if minutes > 0 || hours > 0 || days > 0 {
|
|
160
|
+
parts = append(parts, fmt.Sprintf("%dm", minutes))
|
|
161
|
+
}
|
|
162
|
+
if seconds > 0 || len(parts) == 0 {
|
|
163
|
+
parts = append(parts, fmt.Sprintf("%ds", seconds))
|
|
164
|
+
}
|
|
165
|
+
return strings.Join(parts, " ")
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
func runDuration(r runsvc.Run, now time.Time) time.Duration {
|
|
169
|
+
if r.StartedAt.IsZero() {
|
|
170
|
+
return 0
|
|
171
|
+
}
|
|
172
|
+
end := now
|
|
173
|
+
if r.FinishedAt != nil {
|
|
174
|
+
end = *r.FinishedAt
|
|
175
|
+
}
|
|
176
|
+
if end.Before(r.StartedAt) {
|
|
177
|
+
return 0
|
|
178
|
+
}
|
|
179
|
+
return end.Sub(r.StartedAt)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
func stepDuration(step runsvc.StepEntry, now time.Time) time.Duration {
|
|
183
|
+
if step.Duration > 0 {
|
|
184
|
+
return step.Duration
|
|
185
|
+
}
|
|
186
|
+
if step.StartedAt == nil || (step.FinishedAt == nil && step.Status.IsTerminal() && !step.DurationKnown) {
|
|
187
|
+
return 0
|
|
188
|
+
}
|
|
189
|
+
end := now
|
|
190
|
+
if step.FinishedAt != nil {
|
|
191
|
+
end = *step.FinishedAt
|
|
192
|
+
}
|
|
193
|
+
if end.Before(*step.StartedAt) {
|
|
194
|
+
return 0
|
|
195
|
+
}
|
|
196
|
+
return end.Sub(*step.StartedAt)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
func formatKnownDuration(duration time.Duration, known bool) string {
|
|
200
|
+
if !known {
|
|
201
|
+
return "-"
|
|
202
|
+
}
|
|
203
|
+
if duration == 0 {
|
|
204
|
+
return "0s"
|
|
205
|
+
}
|
|
206
|
+
return formatDuration(duration)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
func stepDurationKnown(step runsvc.StepEntry) bool {
|
|
210
|
+
if step.DurationKnown || step.Duration != 0 || step.FinishedAt != nil {
|
|
211
|
+
return true
|
|
212
|
+
}
|
|
213
|
+
return !step.Status.IsTerminal() && step.StartedAt != nil
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
func renderRunDetail(w io.Writer, detail runsvc.RunDetail, options renderOptions) {
|
|
217
|
+
var body strings.Builder
|
|
218
|
+
renderRunDetailBody(&body, detail, options)
|
|
219
|
+
text := body.String()
|
|
220
|
+
if options.Width <= 0 {
|
|
221
|
+
_, _ = io.WriteString(w, text)
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
for _, line := range strings.Split(strings.TrimSuffix(text, "\n"), "\n") {
|
|
225
|
+
fmt.Fprintln(w, truncate(line, options.Width))
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
func renderRunDetailBody(w io.Writer, detail runsvc.RunDetail, options renderOptions) {
|
|
230
|
+
now := options.now()
|
|
231
|
+
detail.DeriveInspectionFields(now)
|
|
232
|
+
fmt.Fprintf(w, "Name: %s\n", valueOrDash(string(detail.ID)))
|
|
233
|
+
fmt.Fprintf(w, "Workflow: %s\n", valueOrDash(detail.Workflow))
|
|
234
|
+
fmt.Fprintf(w, "Repository: %s\n", valueOrDash(detail.Repo))
|
|
235
|
+
fmt.Fprintf(w, "Ticket: %s\n", valueOrDash(detail.Ticket.Key))
|
|
236
|
+
fmt.Fprintf(w, "Status: %s\n\n", detail.DisplayStatus())
|
|
237
|
+
|
|
238
|
+
fmt.Fprintln(w, "Conditions:")
|
|
239
|
+
fmt.Fprintf(w, " NodeRunning: %s\n", boolText(detail.Conditions.NodeRunning))
|
|
240
|
+
fmt.Fprintf(w, " Waiting: %s\n", boolText(detail.Conditions.Waiting))
|
|
241
|
+
fmt.Fprintf(w, " RetryScheduled: %s\n", boolText(detail.Conditions.RetryScheduled))
|
|
242
|
+
fmt.Fprintf(w, " Completed: %s\n", boolText(detail.Conditions.Completed))
|
|
243
|
+
if detail.Conditions.Failed {
|
|
244
|
+
fmt.Fprintf(w, " Failed: True\n")
|
|
245
|
+
}
|
|
246
|
+
if detail.Conditions.Canceled {
|
|
247
|
+
fmt.Fprintf(w, " Canceled: True\n")
|
|
248
|
+
}
|
|
249
|
+
if detail.Retry != nil && detail.Retry.LastError != "" {
|
|
250
|
+
fmt.Fprintf(w, " RetryError: %s\n", detail.Retry.LastError)
|
|
251
|
+
}
|
|
252
|
+
fmt.Fprintln(w)
|
|
253
|
+
|
|
254
|
+
var createdAt time.Time
|
|
255
|
+
if detail.CreatedAt != nil {
|
|
256
|
+
createdAt = *detail.CreatedAt
|
|
257
|
+
}
|
|
258
|
+
fmt.Fprintf(w, "Created: %s\n", formatTimestamp(createdAt))
|
|
259
|
+
fmt.Fprintf(w, "Started: %s\n", formatTimestamp(detail.StartedAt))
|
|
260
|
+
if detail.FinishedAt == nil {
|
|
261
|
+
fmt.Fprintln(w, "Finished: -")
|
|
262
|
+
} else {
|
|
263
|
+
fmt.Fprintf(w, "Finished: %s\n", formatTimestamp(*detail.FinishedAt))
|
|
264
|
+
}
|
|
265
|
+
fmt.Fprintf(w, "Duration: %s\n", formatKnownDuration(runDuration(detail.Run, now), !detail.StartedAt.IsZero()))
|
|
266
|
+
fmt.Fprintf(w, "Progress: %d/%d\n", detail.Progress.Completed, detail.Progress.Total)
|
|
267
|
+
fmt.Fprintf(w, "ResourcesDuration: runner %s, task-system %s, harness %s\n\n",
|
|
268
|
+
formatKnownDuration(detail.ResourcesDuration.Runner, detail.ResourcesDuration.Known),
|
|
269
|
+
formatKnownDuration(detail.ResourcesDuration.TaskSystem, detail.ResourcesDuration.Known),
|
|
270
|
+
formatKnownDuration(detail.ResourcesDuration.Harness, detail.ResourcesDuration.Known))
|
|
271
|
+
|
|
272
|
+
nameWidth, messageWidth := 40, 24
|
|
273
|
+
header := "STEP DURATION MESSAGE RUNTIME"
|
|
274
|
+
if options.Width > 0 && options.Width < 100 {
|
|
275
|
+
nameWidth = options.Width / 2
|
|
276
|
+
if nameWidth < 16 {
|
|
277
|
+
nameWidth = 16
|
|
278
|
+
}
|
|
279
|
+
messageWidth = options.Width - nameWidth - 28
|
|
280
|
+
if messageWidth < 8 {
|
|
281
|
+
messageWidth = 8
|
|
282
|
+
}
|
|
283
|
+
header = "STEP DURATION MESSAGE RUNTIME"
|
|
284
|
+
}
|
|
285
|
+
fmt.Fprintln(w, truncate(header, options.Width))
|
|
286
|
+
rootMessage := strings.ToLower(detail.DisplayStatus())
|
|
287
|
+
if detail.LastError != "" {
|
|
288
|
+
rootMessage = detail.LastError
|
|
289
|
+
} else if detail.Retry != nil && detail.Retry.LastError != "" {
|
|
290
|
+
rootMessage = detail.Retry.LastError
|
|
291
|
+
}
|
|
292
|
+
rootLine := fmt.Sprintf("%s %-*s %-10s %-*s %s", markForRun(detail.Run, options.ASCII),
|
|
293
|
+
nameWidth, truncate(string(detail.ID), nameWidth), formatKnownDuration(runDuration(detail.Run, now), !detail.StartedAt.IsZero()), messageWidth, truncate(rootMessage, messageWidth), "-")
|
|
294
|
+
fmt.Fprintln(w, truncate(rootLine, options.Width))
|
|
295
|
+
for i, step := range detail.Steps {
|
|
296
|
+
message := step.Message
|
|
297
|
+
if message == "" {
|
|
298
|
+
message = strings.ToLower(step.Status.DisplayStatus())
|
|
299
|
+
}
|
|
300
|
+
prefix := stepTreePrefix(detail.Steps, i, options.ASCII)
|
|
301
|
+
line := fmt.Sprintf("%s %s %-*s %-10s %-*s %s", prefix,
|
|
302
|
+
markForStep(step.Status, options.ASCII), nameWidth-4, truncate(step.Node, nameWidth-4),
|
|
303
|
+
formatKnownDuration(stepDuration(step, now), stepDurationKnown(step)), messageWidth, truncate(message, messageWidth), valueOrDash(step.Runtime))
|
|
304
|
+
fmt.Fprintln(w, truncate(line, options.Width))
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
func stepIndexBySequence(steps []runsvc.StepEntry, sequence int64) (int, bool) {
|
|
309
|
+
for i := range steps {
|
|
310
|
+
if steps[i].Sequence == sequence {
|
|
311
|
+
return i, true
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return 0, false
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
func hasLaterSibling(steps []runsvc.StepEntry, index int, parent int64) bool {
|
|
318
|
+
for i := index + 1; i < len(steps); i++ {
|
|
319
|
+
if steps[i].ParentSequence == parent {
|
|
320
|
+
return true
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return false
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
func stepTreePrefix(steps []runsvc.StepEntry, index int, ascii bool) string {
|
|
327
|
+
// Walk persisted parent links rather than assuming that sequence order is
|
|
328
|
+
// a flat list. This keeps revisit rows under their actual parent visit.
|
|
329
|
+
ancestors := make([]int, 0, steps[index].Depth)
|
|
330
|
+
parent := steps[index].ParentSequence
|
|
331
|
+
seen := map[int64]bool{}
|
|
332
|
+
for parent != 0 && !seen[parent] {
|
|
333
|
+
seen[parent] = true
|
|
334
|
+
parentIndex, ok := stepIndexBySequence(steps, parent)
|
|
335
|
+
if !ok {
|
|
336
|
+
break
|
|
337
|
+
}
|
|
338
|
+
ancestors = append(ancestors, parentIndex)
|
|
339
|
+
parent = steps[parentIndex].ParentSequence
|
|
340
|
+
}
|
|
341
|
+
var b strings.Builder
|
|
342
|
+
for i := len(ancestors) - 1; i >= 0; i-- {
|
|
343
|
+
ancestor := ancestors[i]
|
|
344
|
+
if hasLaterSibling(steps, index, steps[ancestor].Sequence) {
|
|
345
|
+
if ascii {
|
|
346
|
+
b.WriteString("| ")
|
|
347
|
+
} else {
|
|
348
|
+
b.WriteString("│ ")
|
|
349
|
+
}
|
|
350
|
+
} else {
|
|
351
|
+
b.WriteString(" ")
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if hasLaterSibling(steps, index, steps[index].ParentSequence) {
|
|
355
|
+
if ascii {
|
|
356
|
+
b.WriteString("|--")
|
|
357
|
+
} else {
|
|
358
|
+
b.WriteString("├──")
|
|
359
|
+
}
|
|
360
|
+
} else if ascii {
|
|
361
|
+
b.WriteString("`--")
|
|
362
|
+
} else {
|
|
363
|
+
b.WriteString("└──")
|
|
364
|
+
}
|
|
365
|
+
return b.String()
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
func renderWorkflowSummaries(w io.Writer, summaries []server.WorkflowSummary, options renderOptions) {
|
|
369
|
+
var body strings.Builder
|
|
370
|
+
renderWorkflowSummariesBody(&body, summaries, options)
|
|
371
|
+
writeTerminalWidth(w, body.String(), options.Width)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
func renderWorkflowSummariesBody(w io.Writer, summaries []server.WorkflowSummary, options renderOptions) {
|
|
375
|
+
if len(summaries) == 0 {
|
|
376
|
+
fmt.Fprintf(w, "%s No workflows configured. Submit one with: relay-flow workflow submit --file <path>\n", statusMark("pending", options.ASCII))
|
|
377
|
+
fmt.Fprintln(w, "0 workflows | 0 active run(s)")
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
fmt.Fprintln(w, "WORKFLOWS")
|
|
381
|
+
fmt.Fprintln(w, strings.Repeat("=", 80))
|
|
382
|
+
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
|
383
|
+
fmt.Fprintln(tw, "STATE\tNAME\tREPOS\tNODES\tACTIVE\tLAST RUN")
|
|
384
|
+
fmt.Fprintln(tw, "------\t----\t-----\t-----\t------\t--------")
|
|
385
|
+
active := 0
|
|
386
|
+
for _, summary := range summaries {
|
|
387
|
+
name := "-"
|
|
388
|
+
repos := strings.Join(summary.Repositories, ",")
|
|
389
|
+
if summary.Workflow != nil {
|
|
390
|
+
name = summary.Name
|
|
391
|
+
if repos == "" {
|
|
392
|
+
repos = strings.Join(summary.Repos, ",")
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
last := "no runs"
|
|
396
|
+
state := statusMark("pending", options.ASCII)
|
|
397
|
+
if summary.LatestRun != nil {
|
|
398
|
+
last = strings.ToLower(summary.LatestRun.DisplayStatus())
|
|
399
|
+
if summary.LatestRun.CurrentNode != "" {
|
|
400
|
+
last += " / " + summary.LatestRun.CurrentNode
|
|
401
|
+
}
|
|
402
|
+
state = markForRun(*summary.LatestRun, options.ASCII)
|
|
403
|
+
}
|
|
404
|
+
active += summary.ActiveRuns
|
|
405
|
+
fmt.Fprintf(tw, "%s\t%s\t%s\t%d\t%d\t%s\n", state, name, valueOrDash(repos), summary.NodeCount, summary.ActiveRuns, last)
|
|
406
|
+
}
|
|
407
|
+
_ = tw.Flush()
|
|
408
|
+
fmt.Fprintln(w, strings.Repeat("=", 80))
|
|
409
|
+
fmt.Fprintf(w, "%d workflows | %d active run(s)\n\n", len(summaries), active)
|
|
410
|
+
fmt.Fprintf(w, "Legend: %s healthy/complete %s active %s failed %s no runs\n",
|
|
411
|
+
statusMark("completed", options.ASCII), statusMark("active", options.ASCII),
|
|
412
|
+
statusMark("failed", options.ASCII), statusMark("pending", options.ASCII))
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
func renderRuns(w io.Writer, runs []runsvc.Run, options renderOptions) {
|
|
416
|
+
renderRunsFiltered(w, runs, options, runsvc.Filter{})
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
func renderRunsFiltered(w io.Writer, runs []runsvc.Run, options renderOptions, filter runsvc.Filter) {
|
|
420
|
+
var body strings.Builder
|
|
421
|
+
renderRunsFilteredBody(&body, runs, options, filter)
|
|
422
|
+
writeTerminalWidth(w, body.String(), options.Width)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
func renderRunsFilteredBody(w io.Writer, runs []runsvc.Run, options renderOptions, filter runsvc.Filter) {
|
|
426
|
+
if len(runs) == 0 {
|
|
427
|
+
fmt.Fprintf(w, "%s No runs match filters (%s). Try: relay-flow run list --active\n", statusMark("pending", options.ASCII), formatRunFilter(filter))
|
|
428
|
+
fmt.Fprintln(w, "0 runs | 0 active | 0 failed | 0 completed | 0 canceled")
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
fmt.Fprintln(w, "RUNS")
|
|
432
|
+
fmt.Fprintln(w, strings.Repeat("=", 80))
|
|
433
|
+
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
|
434
|
+
fmt.Fprintln(tw, "STATE\tTICKET\tREPOSITORY\tWORKFLOW\tNODE\tAGE\tRETRY/ERROR")
|
|
435
|
+
fmt.Fprintln(tw, "-----\t------\t----------\t--------\t----\t---\t-----------")
|
|
436
|
+
now := options.now()
|
|
437
|
+
active, failed, completed, canceled := 0, 0, 0, 0
|
|
438
|
+
for _, current := range runs {
|
|
439
|
+
state := markForRun(current, options.ASCII)
|
|
440
|
+
switch current.State {
|
|
441
|
+
case runsvc.StateCompleted:
|
|
442
|
+
completed++
|
|
443
|
+
case runsvc.StateCanceled:
|
|
444
|
+
canceled++
|
|
445
|
+
case runsvc.StateBlocked:
|
|
446
|
+
failed++
|
|
447
|
+
active++
|
|
448
|
+
default:
|
|
449
|
+
active++
|
|
450
|
+
}
|
|
451
|
+
extra := "-"
|
|
452
|
+
if current.Retry != nil {
|
|
453
|
+
extra = fmt.Sprintf("retry %d: %s", current.Retry.Attempt, current.Retry.LastError)
|
|
454
|
+
} else if current.LastError != "" {
|
|
455
|
+
extra = current.LastError
|
|
456
|
+
}
|
|
457
|
+
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", state,
|
|
458
|
+
valueOrDash(current.Ticket.Key), valueOrDash(current.Repo), valueOrDash(current.Workflow),
|
|
459
|
+
valueOrDash(current.CurrentNode), formatAge(current.UpdatedAt, now), truncate(extra, 40))
|
|
460
|
+
}
|
|
461
|
+
_ = tw.Flush()
|
|
462
|
+
fmt.Fprintln(w, strings.Repeat("=", 80))
|
|
463
|
+
fmt.Fprintf(w, "%d runs | %d active | %d failed | %d completed | %d canceled\n", len(runs), active, failed, completed, canceled)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
func renderWorkflowDetail(w io.Writer, detail server.WorkflowDetail, options renderOptions) {
|
|
467
|
+
var body strings.Builder
|
|
468
|
+
renderWorkflowDetailBody(&body, detail, options)
|
|
469
|
+
writeTerminalWidth(w, body.String(), options.Width)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
func renderWorkflowDetailBody(w io.Writer, detail server.WorkflowDetail, options renderOptions) {
|
|
473
|
+
wf := detail.Workflow
|
|
474
|
+
if wf == nil {
|
|
475
|
+
fmt.Fprintf(w, "%s Workflow unavailable\n", statusMark("failed", options.ASCII))
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
fmt.Fprintf(w, "WORKFLOW %s\n", wf.Name)
|
|
479
|
+
fmt.Fprintln(w, strings.Repeat("=", 80))
|
|
480
|
+
validMark := statusMark("valid", options.ASCII)
|
|
481
|
+
fmt.Fprintf(w, "STATUS %s VALID\n", validMark)
|
|
482
|
+
fmt.Fprintf(w, "REPOSITORIES %s\n", valueOrDash(strings.Join(wf.Repos, ", ")))
|
|
483
|
+
fmt.Fprintf(w, "NODES %d\n", len(wf.Nodes))
|
|
484
|
+
cleanup := "disabled"
|
|
485
|
+
if wf.CleanupRunnerOnEnd {
|
|
486
|
+
cleanup = "enabled"
|
|
487
|
+
}
|
|
488
|
+
fmt.Fprintf(w, "CLEANUP %s\n", cleanup)
|
|
489
|
+
fmt.Fprintf(w, "ACTIVE RUNS %d\n", detail.ActiveRuns)
|
|
490
|
+
fmt.Fprintln(w, strings.Repeat("=", 80))
|
|
491
|
+
fmt.Fprintln(w, "\nGRAPH")
|
|
492
|
+
renderGraph(w, wf, workflow.StartNode, " ", map[string]bool{}, options.ASCII)
|
|
493
|
+
fmt.Fprintln(w, "\nRECENT RUNS")
|
|
494
|
+
if len(detail.RecentRuns) == 0 {
|
|
495
|
+
fmt.Fprintf(w, " %s no runs\n", statusMark("pending", options.ASCII))
|
|
496
|
+
return
|
|
497
|
+
}
|
|
498
|
+
for _, current := range detail.RecentRuns {
|
|
499
|
+
fmt.Fprintf(w, " %s %-24s %-10s %s\n", markForRun(current, options.ASCII), current.ID,
|
|
500
|
+
strings.ToLower(current.DisplayStatus()), valueOrDash(current.CurrentNode))
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
func renderGraph(w io.Writer, wf *workflow.Workflow, node, indent string, visiting map[string]bool, ascii bool) {
|
|
505
|
+
if visiting[node] {
|
|
506
|
+
fmt.Fprintf(w, "%s%s %s (cycle)\n", indent, statusMark("valid", ascii), node)
|
|
507
|
+
return
|
|
508
|
+
}
|
|
509
|
+
n, ok := wf.Nodes[node]
|
|
510
|
+
if !ok {
|
|
511
|
+
fmt.Fprintf(w, "%s%s %s (missing)\n", indent, statusMark("failed", ascii), node)
|
|
512
|
+
return
|
|
513
|
+
}
|
|
514
|
+
fmt.Fprintf(w, "%s%s %s\n", indent, statusMark("valid", ascii), node)
|
|
515
|
+
visiting[node] = true
|
|
516
|
+
type graphRoute struct {
|
|
517
|
+
route workflow.Route
|
|
518
|
+
outcome string
|
|
519
|
+
}
|
|
520
|
+
routes := make([]graphRoute, 0, len(n.OnSuccess)+len(n.OnFailure))
|
|
521
|
+
for _, route := range n.OnSuccess {
|
|
522
|
+
routes = append(routes, graphRoute{route: route, outcome: "success"})
|
|
523
|
+
}
|
|
524
|
+
for _, route := range n.OnFailure {
|
|
525
|
+
routes = append(routes, graphRoute{route: route, outcome: "failure"})
|
|
526
|
+
}
|
|
527
|
+
for i, edge := range routes {
|
|
528
|
+
route := edge.route
|
|
529
|
+
connector := "+-->"
|
|
530
|
+
if i == len(routes)-1 {
|
|
531
|
+
connector = "`-->"
|
|
532
|
+
}
|
|
533
|
+
if ascii {
|
|
534
|
+
connector = "+-->"
|
|
535
|
+
if i == len(routes)-1 {
|
|
536
|
+
connector = "`-->"
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
when := route.When
|
|
540
|
+
if when != "" {
|
|
541
|
+
when = " — " + when
|
|
542
|
+
}
|
|
543
|
+
fmt.Fprintf(w, "%s%s %s %s -> %s%s\n", indent, connector, statusMark("valid", ascii), edge.outcome, route.Target, when)
|
|
544
|
+
if !visiting[route.Target] {
|
|
545
|
+
renderGraph(w, wf, route.Target, indent+" ", visiting, ascii)
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
delete(visiting, node)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
func writeTerminalWidth(w io.Writer, text string, width int) {
|
|
552
|
+
if width <= 0 {
|
|
553
|
+
_, _ = io.WriteString(w, text)
|
|
554
|
+
return
|
|
555
|
+
}
|
|
556
|
+
for _, line := range strings.Split(strings.TrimSuffix(text, "\n"), "\n") {
|
|
557
|
+
fmt.Fprintln(w, truncate(line, width))
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
func formatRunFilter(filter runsvc.Filter) string {
|
|
562
|
+
parts := make([]string, 0, 4)
|
|
563
|
+
if filter.Repo != "" {
|
|
564
|
+
parts = append(parts, "repo="+filter.Repo)
|
|
565
|
+
}
|
|
566
|
+
if filter.Workflow != "" {
|
|
567
|
+
parts = append(parts, "workflow="+filter.Workflow)
|
|
568
|
+
}
|
|
569
|
+
if filter.Ticket != "" {
|
|
570
|
+
parts = append(parts, "ticket="+filter.Ticket)
|
|
571
|
+
}
|
|
572
|
+
if filter.Active != nil {
|
|
573
|
+
parts = append(parts, fmt.Sprintf("active=%t", *filter.Active))
|
|
574
|
+
}
|
|
575
|
+
if len(parts) == 0 {
|
|
576
|
+
return "none"
|
|
577
|
+
}
|
|
578
|
+
return strings.Join(parts, ", ")
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
func formatAge(updated, now time.Time) string {
|
|
582
|
+
if updated.IsZero() {
|
|
583
|
+
return "-"
|
|
584
|
+
}
|
|
585
|
+
age := now.Sub(updated)
|
|
586
|
+
if age < 0 {
|
|
587
|
+
age = 0
|
|
588
|
+
}
|
|
589
|
+
return formatDuration(age)
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
func boolText(value bool) string {
|
|
593
|
+
if value {
|
|
594
|
+
return "True"
|
|
595
|
+
}
|
|
596
|
+
return "False"
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
func valueOrDash(value string) string {
|
|
600
|
+
if strings.TrimSpace(value) == "" {
|
|
601
|
+
return "-"
|
|
602
|
+
}
|
|
603
|
+
return value
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
func truncate(value string, width int) string {
|
|
607
|
+
value = strings.ReplaceAll(strings.ReplaceAll(value, "\n", " "), "\r", " ")
|
|
608
|
+
if width <= 0 || len([]rune(value)) <= width {
|
|
609
|
+
return value
|
|
610
|
+
}
|
|
611
|
+
runes := []rune(value)
|
|
612
|
+
if width <= 3 {
|
|
613
|
+
return string(runes[:width])
|
|
614
|
+
}
|
|
615
|
+
return string(runes[:width-3]) + "..."
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// sortedRuns is useful for deterministic callers that assemble detail data
|
|
619
|
+
// outside the server.
|
|
620
|
+
func sortedRuns(runs []runsvc.Run) []runsvc.Run {
|
|
621
|
+
out := append([]runsvc.Run(nil), runs...)
|
|
622
|
+
sort.SliceStable(out, func(i, j int) bool {
|
|
623
|
+
if out[i].UpdatedAt.Equal(out[j].UpdatedAt) {
|
|
624
|
+
return out[i].ID < out[j].ID
|
|
625
|
+
}
|
|
626
|
+
return out[i].UpdatedAt.After(out[j].UpdatedAt)
|
|
627
|
+
})
|
|
628
|
+
return out
|
|
629
|
+
}
|
package/cmd/relay-flow/serve.go
CHANGED
|
@@ -504,6 +504,7 @@ func (r repoExists) Exists(name string) bool {
|
|
|
504
504
|
type durableEngine interface {
|
|
505
505
|
runsvc.Executor
|
|
506
506
|
runsvc.RunQueries
|
|
507
|
+
GetRunDetail(context.Context, runsvc.ID) (runsvc.RunDetail, error)
|
|
507
508
|
Start(context.Context) error
|
|
508
509
|
Shutdown(context.Context) error
|
|
509
510
|
HasProcessedReport(context.Context, runsvc.ID, string) (bool, error)
|
|
@@ -523,11 +524,34 @@ func (d *serveDeps) SubmitWorkflow(ctx context.Context, yaml []byte) (*workflow.
|
|
|
523
524
|
return d.wf.Submit(ctx, yaml)
|
|
524
525
|
}
|
|
525
526
|
func (d *serveDeps) GetWorkflow(_ context.Context, name string) (*workflow.Workflow, error) {
|
|
526
|
-
|
|
527
|
+
wf, err := d.wf.Get(name)
|
|
528
|
+
if err != nil {
|
|
529
|
+
return nil, fmt.Errorf("%w: %v", server.ErrNotFound, err)
|
|
530
|
+
}
|
|
531
|
+
return wf, nil
|
|
527
532
|
}
|
|
528
533
|
func (d *serveDeps) ListWorkflows(context.Context) ([]*workflow.Workflow, error) {
|
|
529
534
|
return d.wf.List(), nil
|
|
530
535
|
}
|
|
536
|
+
func (d *serveDeps) ListWorkflowSummaries(ctx context.Context) ([]server.WorkflowSummary, error) {
|
|
537
|
+
workflows := d.wf.List()
|
|
538
|
+
runs, err := d.engine.ListRuns(ctx, runsvc.Filter{})
|
|
539
|
+
if err != nil {
|
|
540
|
+
return nil, err
|
|
541
|
+
}
|
|
542
|
+
return server.BuildWorkflowSummaries(workflows, runs), nil
|
|
543
|
+
}
|
|
544
|
+
func (d *serveDeps) GetWorkflowDetail(ctx context.Context, name string) (server.WorkflowDetail, error) {
|
|
545
|
+
wf, err := d.wf.Get(name)
|
|
546
|
+
if err != nil {
|
|
547
|
+
return server.WorkflowDetail{}, fmt.Errorf("%w: %v", server.ErrNotFound, err)
|
|
548
|
+
}
|
|
549
|
+
runs, err := d.engine.ListRuns(ctx, runsvc.Filter{Workflow: name})
|
|
550
|
+
if err != nil {
|
|
551
|
+
return server.WorkflowDetail{}, err
|
|
552
|
+
}
|
|
553
|
+
return server.BuildWorkflowDetail(wf, runs), nil
|
|
554
|
+
}
|
|
531
555
|
func (d *serveDeps) RemoveWorkflow(ctx context.Context, name string) error {
|
|
532
556
|
return d.wf.Remove(ctx, name)
|
|
533
557
|
}
|
|
@@ -538,6 +562,13 @@ func (d *serveDeps) ListRuns(ctx context.Context, filter runsvc.Filter) ([]runsv
|
|
|
538
562
|
func (d *serveDeps) GetRunByTicket(ctx context.Context, ticket string) (runsvc.Run, error) {
|
|
539
563
|
return d.engine.FindRunByTicket(ctx, ticket)
|
|
540
564
|
}
|
|
565
|
+
func (d *serveDeps) GetRunDetail(ctx context.Context, ticket string) (runsvc.RunDetail, error) {
|
|
566
|
+
base, err := d.engine.FindRunByTicket(ctx, ticket)
|
|
567
|
+
if err != nil {
|
|
568
|
+
return runsvc.RunDetail{}, err
|
|
569
|
+
}
|
|
570
|
+
return d.engine.GetRunDetail(ctx, base.ID)
|
|
571
|
+
}
|
|
541
572
|
func (d *serveDeps) RestartRun(ctx context.Context, ticket string) (runsvc.Run, error) {
|
|
542
573
|
rn, err := d.runManager.RestartByTicket(ctx, ticket)
|
|
543
574
|
if err != nil {
|
|
@@ -402,6 +402,15 @@ func (a *Activities) CompleteMailbox(ctx context.Context, w run.Work, mailbox ta
|
|
|
402
402
|
|
|
403
403
|
// Projection activities: idempotent read-model updates.
|
|
404
404
|
|
|
405
|
+
func (a *Activities) ProjectionUpsertStep(ctx context.Context, step run.StepEntry) error {
|
|
406
|
+
if err := a.Runs.upsertStep(ctx, step); err != nil {
|
|
407
|
+
// Step detail is a cache. Never let a display-projection failure block
|
|
408
|
+
// route selection, report acceptance, or durable graph progression.
|
|
409
|
+
slog.Warn("step projection unavailable", "runID", string(step.RunID), "node", step.Node, "sequence", step.Sequence, "error", err)
|
|
410
|
+
}
|
|
411
|
+
return nil
|
|
412
|
+
}
|
|
413
|
+
|
|
405
414
|
func (a *Activities) ProjectionUpdateNode(ctx context.Context, id run.ID, state run.State, node string, visit run.NodeVisitID) error {
|
|
406
415
|
return a.Runs.updateNode(ctx, id, state, node, visit)
|
|
407
416
|
}
|