relay-flow 0.2.8-alpha → 0.2.10-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +76 -35
  2. package/cmd/relay-flow/commands_test.go +117 -8
  3. package/cmd/relay-flow/main.go +391 -83
  4. package/cmd/relay-flow/observability_test.go +204 -0
  5. package/cmd/relay-flow/onboarding.go +344 -0
  6. package/cmd/relay-flow/onboarding_test.go +515 -0
  7. package/cmd/relay-flow/render.go +629 -0
  8. package/cmd/relay-flow/repo_registration.go +219 -0
  9. package/cmd/relay-flow/serve.go +35 -1
  10. package/go.mod +12 -8
  11. package/go.sum +24 -17
  12. package/internal/execution/goworkflows/activities.go +9 -0
  13. package/internal/execution/goworkflows/engine.go +15 -0
  14. package/internal/execution/goworkflows/engine_test.go +3 -3
  15. package/internal/execution/goworkflows/interpreter.go +86 -3
  16. package/internal/execution/goworkflows/projection.go +16 -0
  17. package/internal/execution/projection/detail_test.go +259 -0
  18. package/internal/execution/projection/projection.go +290 -6
  19. package/internal/execution/projection/projection_test.go +3 -2
  20. package/internal/execution/temporal/activities.go +9 -0
  21. package/internal/execution/temporal/engine.go +13 -0
  22. package/internal/execution/temporal/interpreter.go +131 -3
  23. package/internal/execution/temporal/operations.go +174 -0
  24. package/internal/execution/temporal/operations_test.go +36 -0
  25. package/internal/harness/opencode/opencode_test.go +4 -4
  26. package/internal/harness/opencode/repo_setup.go +1 -1
  27. package/internal/harness/pi/prompt_test.go +3 -3
  28. package/internal/repo/service.go +34 -3
  29. package/internal/repo/service_test.go +22 -0
  30. package/internal/run/detail.go +277 -0
  31. package/internal/run/detail_test.go +71 -0
  32. package/internal/run/run.go +4 -0
  33. package/internal/runner/herdr/herdr.go +103 -4
  34. package/internal/runner/herdr/herdr_test.go +72 -2
  35. package/internal/runner/herdr/herdrcli/contract.go +12 -3
  36. package/internal/runner/herdr/herdrcli/herdrcli_test.go +17 -2
  37. package/internal/runner/herdr/herdrcli/operations.go +16 -0
  38. package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +8 -0
  39. package/internal/runner/herdr/herdrcli/testdata/workspace-create.json +1 -0
  40. package/internal/runner/orca/orca.go +68 -9
  41. package/internal/runner/orca/orca_test.go +134 -2
  42. package/internal/runner/orca/orcacli/orcacli.go +21 -1
  43. package/internal/runner/orca/orcacli/orcacli_test.go +22 -0
  44. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +5 -0
  45. package/internal/runner/runner.go +9 -0
  46. package/internal/server/api_test.go +19 -0
  47. package/internal/server/client.go +49 -0
  48. package/internal/server/fixture_test.go +7 -0
  49. package/internal/server/observability.go +109 -0
  50. package/internal/server/observability_test.go +60 -0
  51. package/internal/server/server.go +77 -0
  52. package/internal/task/jira/testdata/jira_search_issues.json +4 -4
  53. package/internal/workflow/workflow.go +4 -1
  54. package/internal/workflow/workflow_test.go +4 -4
  55. package/package.json +1 -1
@@ -0,0 +1,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
+ }