relay-flow 0.2.9-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.
@@ -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
+ }
@@ -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
- return d.wf.Get(name)
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
  }