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 CHANGED
@@ -24,13 +24,13 @@ same `relay-flow-plugin` package with host-specific entrypoints.
24
24
  **OpenCode**
25
25
 
26
26
  ```sh
27
- opencode plugin relay-flow-plugin@0.2.9-alpha
27
+ opencode plugin relay-flow-plugin@0.2.11-alpha
28
28
  ```
29
29
 
30
30
  **Pi**
31
31
 
32
32
  ```sh
33
- pi install npm:relay-flow-plugin@0.2.9-alpha
33
+ pi install npm:relay-flow-plugin@0.2.11-alpha
34
34
  ```
35
35
 
36
36
  Pi loads the package's `pi.ts` extension from its manifest. Do not add
@@ -91,6 +91,9 @@ running `relay-flow serve --background`.
91
91
  ```sh
92
92
  relay-flow workflow submit --file examples/minimal-jira-task-workflow.yaml
93
93
  relay-flow workflow list
94
+ relay-flow workflow list --json # stable automation output
95
+ relay-flow run get --ticket PAY-101 # Argo-shaped human inspection view
96
+ relay-flow run get --ticket PAY-101 --json # stable automation output
94
97
  ```
95
98
 
96
99
  Replace the example workflow with
@@ -157,7 +160,7 @@ OpenCode plugin configuration uses both entrypoints. The server entrypoint is li
157
160
  ```json
158
161
  {
159
162
  "$schema": "https://opencode.ai/config.json",
160
- "plugin": ["relay-flow-plugin@0.2.9-alpha"]
163
+ "plugin": ["relay-flow-plugin@0.2.11-alpha"]
161
164
  }
162
165
  ```
163
166
 
@@ -166,7 +169,7 @@ The native HITL approval entrypoint is listed in `.opencode/tui.json`:
166
169
  ```json
167
170
  {
168
171
  "$schema": "https://opencode.ai/tui.json",
169
- "plugin": ["relay-flow-plugin@0.2.9-alpha"]
172
+ "plugin": ["relay-flow-plugin@0.2.11-alpha"]
170
173
  }
171
174
  ```
172
175
 
@@ -182,7 +185,7 @@ Pi plugin: install the same published package manually in Pi's global package
182
185
  settings before starting a Pi harness session:
183
186
 
184
187
  ```sh
185
- pi install npm:relay-flow-plugin@0.2.9-alpha
188
+ pi install npm:relay-flow-plugin@0.2.11-alpha
186
189
  ```
187
190
 
188
191
  Relay-flow does not install or configure the package automatically. Pi resolves
@@ -678,6 +681,17 @@ One Repo Poller per registered repo (not per workflow) fetches active parent tic
678
681
 
679
682
  ---
680
683
 
684
+ ## CLI inspection and automation
685
+
686
+ Human-readable workflow and run views are intended for interactive operator
687
+ use. Scripts should always pass `--json`; the JSON output is the stable
688
+ machine-readable interface. `run get --ticket <key>` shows metadata,
689
+ conditions, timing, progress, resource-duration placeholders, and the actual
690
+ node-visit tree, including retries and revisits. Scoped `--help` is available
691
+ at the root, group, and leaf command levels. Build information is available
692
+ without a server through `relay-flow version`, `relay-flow --version`, and
693
+ `relay-flow -v`.
694
+
681
695
  ## Development
682
696
 
683
697
  ```sh
@@ -17,6 +17,7 @@ import (
17
17
  "os/exec"
18
18
  "os/signal"
19
19
  "path/filepath"
20
+ "runtime"
20
21
  "strings"
21
22
  "syscall"
22
23
  "time"
@@ -52,6 +53,14 @@ const (
52
53
  exitUsage = 2
53
54
  )
54
55
 
56
+ // Build metadata has safe local-build defaults and can be replaced by release
57
+ // builds with -ldflags -X main.version=... and friends.
58
+ var (
59
+ version = "dev"
60
+ commit = "unknown"
61
+ buildDate = "unknown"
62
+ )
63
+
55
64
  func main() { os.Exit(run(os.Args[1:], os.Stdin)) }
56
65
 
57
66
  // home returns the relay-flow root, honoring RELAY_FLOW_HOME (tests and
@@ -81,6 +90,13 @@ func run(args []string, stdin io.Reader) int {
81
90
  usage(os.Stderr)
82
91
  return exitUsage
83
92
  }
93
+ if isVersionArgs(args) {
94
+ printVersion(os.Stdout, len(args) == 1 && args[0] == "version")
95
+ return exitOK
96
+ }
97
+ if hasHelpArg(args) {
98
+ return printScopedHelp(args, os.Stdout)
99
+ }
84
100
  p, err := home()
85
101
  if err != nil {
86
102
  fmt.Fprintln(os.Stderr, err)
@@ -133,6 +149,9 @@ func usage(w io.Writer) {
133
149
  fmt.Fprintln(w, `relay-flow — durable ticket runner
134
150
 
135
151
  Usage:
152
+ relay-flow [command] [flags]
153
+
154
+ Setup and server:
136
155
  relay-flow init [--force] [--task-plugin <name> --runner-plugin <name> --harness-plugin <name>]
137
156
  [--executor-plugin <goworkflows|temporal>]
138
157
  [--temporal-address <host:port>] [--temporal-namespace <name>]
@@ -141,21 +160,174 @@ Usage:
141
160
  relay-flow serve [--recover] [--debug] [--background]
142
161
  relay-flow stop
143
162
  relay-flow report
163
+ relay-flow version | --version | -v
144
164
 
165
+ Workflow:
145
166
  relay-flow workflow submit --file <path>
146
167
  relay-flow workflow remove --name <name>
147
- relay-flow workflow list
148
- relay-flow workflow get --name <name>
168
+ relay-flow workflow list [--json]
169
+ relay-flow workflow get --name <name> [--json]
149
170
 
171
+ Repository:
150
172
  relay-flow repo register [--name <name> --path <path> --set key=value ...]
151
173
  relay-flow repo remove --name <name>
152
174
  relay-flow repo list
153
175
  relay-flow repo get --name <name>
154
176
 
155
- relay-flow run list
156
- relay-flow run get --ticket <key>
177
+ Run:
178
+ relay-flow run list [--repo <name>] [--workflow <name>] [--ticket <key>] [--active] [--json]
179
+ relay-flow run get --ticket <key> [--json]
157
180
  relay-flow run restart --ticket <key>
158
- relay-flow run cancel --ticket <key>`)
181
+ relay-flow run cancel --ticket <key>
182
+
183
+ Examples:
184
+ relay-flow workflow list --json
185
+ relay-flow run get --ticket PAY-101
186
+ Example: relay-flow run get --ticket PAY-101`)
187
+ }
188
+
189
+ func isVersionArgs(args []string) bool {
190
+ return len(args) == 1 && (args[0] == "version" || args[0] == "--version" || args[0] == "-v")
191
+ }
192
+
193
+ func hasHelpArg(args []string) bool {
194
+ for _, arg := range args {
195
+ if arg == "--help" || arg == "-h" {
196
+ return true
197
+ }
198
+ }
199
+ return false
200
+ }
201
+
202
+ func printVersion(w io.Writer, long bool) {
203
+ if !long {
204
+ fmt.Fprintf(w, "relay-flow %s\n", version)
205
+ return
206
+ }
207
+ fmt.Fprintf(w, "relay-flow %s\n", version)
208
+ fmt.Fprintf(w, "Commit: %s\n", commit)
209
+ fmt.Fprintf(w, "Build date: %s\n", buildDate)
210
+ fmt.Fprintf(w, "Go: %s\n", runtime.Version())
211
+ fmt.Fprintf(w, "Platform: %s/%s\n", runtime.GOOS, runtime.GOARCH)
212
+ }
213
+
214
+ func printScopedHelp(args []string, w io.Writer) int {
215
+ // Help is handled before home/config/client creation. Keep each block
216
+ // scoped to the requested command instead of dumping the entire surface.
217
+ path := make([]string, 0, len(args))
218
+ for _, arg := range args {
219
+ if arg == "--help" || arg == "-h" {
220
+ continue
221
+ }
222
+ // Command names are positional; once a flag appears, the remaining
223
+ // tokens are command arguments and must not change the help scope.
224
+ if strings.HasPrefix(arg, "-") {
225
+ break
226
+ }
227
+ path = append(path, arg)
228
+ }
229
+ if len(path) == 0 {
230
+ usage(w)
231
+ return exitOK
232
+ }
233
+ name := strings.Join(path, " ")
234
+ switch name {
235
+ case "init":
236
+ fmt.Fprintln(w, "Usage: relay-flow init [flags]")
237
+ fmt.Fprintln(w, "\nInitialize relay-flow and its durable state.")
238
+ fmt.Fprintln(w, "Flags: --force, --task-plugin, --runner-plugin, --harness-plugin, --executor-plugin")
239
+ fmt.Fprintln(w, "Example: relay-flow init --task-plugin jira --runner-plugin orca --harness-plugin opencode")
240
+ case "serve":
241
+ fmt.Fprintln(w, "Usage: relay-flow serve [--recover] [--debug] [--background]")
242
+ fmt.Fprintln(w, "\nStart the relay-flow server.")
243
+ fmt.Fprintln(w, "Example: relay-flow serve --background")
244
+ case "stop":
245
+ fmt.Fprintln(w, "Usage: relay-flow stop")
246
+ fmt.Fprintln(w, "\nStop the running relay-flow server.")
247
+ fmt.Fprintln(w, "Example: relay-flow stop")
248
+ case "report":
249
+ fmt.Fprintln(w, "Usage: relay-flow report < report.json")
250
+ fmt.Fprintln(w, "\nSubmit one complete structured node report.")
251
+ fmt.Fprintln(w, "Example: relay-flow report < report.json")
252
+ case "task":
253
+ fmt.Fprintln(w, "Usage: relay-flow task <command>")
254
+ fmt.Fprintln(w, "\nCommands: auth")
255
+ fmt.Fprintln(w, "Example: relay-flow task auth")
256
+ case "task auth":
257
+ fmt.Fprintln(w, "Usage: relay-flow task auth [task-plugin options]")
258
+ fmt.Fprintln(w, "\nDelegate authentication to the configured task-system plugin.")
259
+ fmt.Fprintln(w, "Example: relay-flow task auth")
260
+ case "runtime-register":
261
+ fmt.Fprintln(w, "Usage: relay-flow runtime-register < session.json")
262
+ fmt.Fprintln(w, "\nRegister a harness runtime session with the server.")
263
+ fmt.Fprintln(w, "Example: relay-flow runtime-register < session.json")
264
+ case "version":
265
+ fmt.Fprintln(w, "Usage: relay-flow version")
266
+ fmt.Fprintln(w, "\nPrint release and build metadata without contacting the server.")
267
+ fmt.Fprintln(w, "Example: relay-flow version")
268
+ case "workflow":
269
+ fmt.Fprintln(w, "Usage: relay-flow workflow <command>")
270
+ fmt.Fprintln(w, "\nCommands: submit, remove, list, get")
271
+ fmt.Fprintln(w, "Use relay-flow workflow <command> --help for command-specific flags.")
272
+ fmt.Fprintln(w, "Example: relay-flow workflow list --json")
273
+ case "workflow submit":
274
+ fmt.Fprintln(w, "Usage: relay-flow workflow submit --file <path>")
275
+ fmt.Fprintln(w, "\nCreate or replace a validated workflow definition.")
276
+ fmt.Fprintln(w, "Example: relay-flow workflow submit --file workflow.yaml")
277
+ case "workflow remove":
278
+ fmt.Fprintln(w, "Usage: relay-flow workflow remove --name <name>")
279
+ fmt.Fprintln(w, "\nRemove a workflow with no active runs.")
280
+ fmt.Fprintln(w, "Example: relay-flow workflow remove --name basicFlow")
281
+ case "workflow list":
282
+ fmt.Fprintln(w, "Usage: relay-flow workflow list [--json] [--no-color]")
283
+ fmt.Fprintln(w, "\nList configured workflows and their latest execution summaries.")
284
+ fmt.Fprintln(w, "Example: relay-flow workflow list --json")
285
+ case "workflow get":
286
+ fmt.Fprintln(w, "Usage: relay-flow workflow get --name <name> [--json] [--no-color]")
287
+ fmt.Fprintln(w, "\nShow validated static graph and recent runs. Use --json for automation.")
288
+ fmt.Fprintln(w, "Example: relay-flow workflow get --name basicFlow")
289
+ case "repo":
290
+ fmt.Fprintln(w, "Usage: relay-flow repo <command>")
291
+ fmt.Fprintln(w, "\nCommands: register, remove, list, get")
292
+ fmt.Fprintln(w, "Use relay-flow repo <command> --help for command-specific flags.")
293
+ fmt.Fprintln(w, "Example: relay-flow repo list")
294
+ case "repo register":
295
+ fmt.Fprintln(w, "Usage: relay-flow repo register [--name <name> --path <path> --set key=value]")
296
+ fmt.Fprintln(w, "\nRegister a runner repository and task-system configuration.")
297
+ fmt.Fprintln(w, "Example: relay-flow repo register --name payments --path /work/payments")
298
+ case "repo remove", "repo get":
299
+ fmt.Fprintf(w, "Usage: relay-flow %s --name <name>\n", name)
300
+ fmt.Fprintf(w, "Example: relay-flow %s --name payments\n", name)
301
+ case "repo list":
302
+ fmt.Fprintln(w, "Usage: relay-flow repo list")
303
+ fmt.Fprintln(w, "\nList registered repositories.")
304
+ fmt.Fprintln(w, "Example: relay-flow repo list")
305
+ case "run":
306
+ fmt.Fprintln(w, "Usage: relay-flow run <command>")
307
+ fmt.Fprintln(w, "\nCommands: list, get, restart, cancel")
308
+ fmt.Fprintln(w, "Use relay-flow run <command> --help for command-specific flags.")
309
+ fmt.Fprintln(w, "Example: relay-flow run get --ticket PAY-101")
310
+ case "run list":
311
+ fmt.Fprintln(w, "Usage: relay-flow run list [--repo <name>] [--workflow <name>] [--ticket <key>] [--active] [--json] [--no-color]")
312
+ fmt.Fprintln(w, "\nList durable runs. Use --json for automation.")
313
+ fmt.Fprintln(w, "Example: relay-flow run list --active --json")
314
+ case "run get":
315
+ fmt.Fprintln(w, "Usage: relay-flow run get --ticket <key> [--json] [--no-color]")
316
+ fmt.Fprintln(w, "\nShow an Argo-shaped execution detail view and actual step visits.")
317
+ fmt.Fprintln(w, "Example: relay-flow run get --ticket PAY-101")
318
+ case "run restart":
319
+ fmt.Fprintln(w, "Usage: relay-flow run restart --ticket <key>")
320
+ fmt.Fprintln(w, "\nStart a fresh attempt for a canceled run.")
321
+ fmt.Fprintln(w, "Example: relay-flow run restart --ticket PAY-101")
322
+ case "run cancel":
323
+ fmt.Fprintln(w, "Usage: relay-flow run cancel --ticket <key> [--reason <text>]")
324
+ fmt.Fprintln(w, "\nPermanently cancel the current execution attempt.")
325
+ fmt.Fprintln(w, "Example: relay-flow run cancel --ticket PAY-101 --reason "+`"operator request"`)
326
+ default:
327
+ fmt.Fprintf(os.Stderr, "unknown help target: %s\n", name)
328
+ return exitUsage
329
+ }
330
+ return exitOK
159
331
  }
160
332
 
161
333
  // --- init / serve delegate to the section-5 composition root ---
@@ -801,21 +973,28 @@ func cmdWorkflow(c *server.Client, args []string) int {
801
973
  return exitOK
802
974
  case "list":
803
975
  fs := flag.NewFlagSet("workflow list", flag.ContinueOnError)
976
+ jsonOutput := fs.Bool("json", false, "print stable JSON")
977
+ fs.Bool("no-color", false, "disable terminal colors")
804
978
  if err := fs.Parse(args[1:]); err != nil {
805
979
  return exitUsage
806
980
  }
807
- wfs, err := c.ListWorkflows(context.Background())
981
+ finishLoading := beginLoading("Loading workflows", *jsonOutput)
982
+ summaries, err := c.ListWorkflowSummaries(context.Background())
983
+ finishLoading(err)
808
984
  if err != nil {
809
985
  fmt.Fprintln(os.Stderr, err)
810
986
  return exitFail
811
987
  }
812
- for _, wf := range wfs {
813
- fmt.Println(wf.Name)
988
+ if *jsonOutput {
989
+ return encodeJSON(summaries)
814
990
  }
991
+ renderWorkflowSummaries(os.Stdout, summaries, cliRenderOptions())
815
992
  return exitOK
816
993
  case "get":
817
994
  fs := flag.NewFlagSet("workflow get", flag.ContinueOnError)
818
995
  name := fs.String("name", "", "workflow name")
996
+ jsonOutput := fs.Bool("json", false, "print stable JSON")
997
+ fs.Bool("no-color", false, "disable terminal colors")
819
998
  if err := fs.Parse(args[1:]); err != nil {
820
999
  return exitUsage
821
1000
  }
@@ -823,14 +1002,23 @@ func cmdWorkflow(c *server.Client, args []string) int {
823
1002
  fmt.Fprintln(os.Stderr, "workflow get: --name is required")
824
1003
  return exitUsage
825
1004
  }
826
- wf, err := c.GetWorkflow(context.Background(), *name)
1005
+ finishLoading := beginLoading("Loading workflow", *jsonOutput)
1006
+ if *jsonOutput {
1007
+ wf, err := c.GetWorkflow(context.Background(), *name)
1008
+ finishLoading(err)
1009
+ if err != nil {
1010
+ fmt.Fprintln(os.Stderr, err)
1011
+ return exitFail
1012
+ }
1013
+ return encodeJSON(wf)
1014
+ }
1015
+ detail, err := c.GetWorkflowDetail(context.Background(), *name)
1016
+ finishLoading(err)
827
1017
  if err != nil {
828
1018
  fmt.Fprintln(os.Stderr, err)
829
1019
  return exitFail
830
1020
  }
831
- enc := json.NewEncoder(os.Stdout)
832
- enc.SetIndent("", " ")
833
- _ = enc.Encode(wf)
1021
+ renderWorkflowDetail(os.Stdout, detail, cliRenderOptions())
834
1022
  return exitOK
835
1023
  }
836
1024
  usage(os.Stderr)
@@ -1034,25 +1222,35 @@ func cmdRun(c *server.Client, args []string) int {
1034
1222
  repoName := fs.String("repo", "", "filter by repo")
1035
1223
  workflowName := fs.String("workflow", "", "filter by workflow")
1036
1224
  ticket := fs.String("ticket", "", "filter by ticket")
1225
+ activeOnly := fs.Bool("active", false, "show only active runs")
1226
+ jsonOutput := fs.Bool("json", false, "print stable JSON")
1227
+ fs.Bool("no-color", false, "disable terminal colors")
1037
1228
  if err := fs.Parse(args[1:]); err != nil {
1038
1229
  return exitUsage
1039
1230
  }
1040
- runs, err := c.ListRuns(context.Background(), runsvc.Filter{
1041
- Repo: *repoName,
1042
- Workflow: *workflowName,
1043
- Ticket: *ticket,
1044
- })
1231
+ var active *bool
1232
+ if *activeOnly {
1233
+ value := true
1234
+ active = &value
1235
+ }
1236
+ filter := runsvc.Filter{Repo: *repoName, Workflow: *workflowName, Ticket: *ticket, Active: active}
1237
+ finishLoading := beginLoading("Loading runs", *jsonOutput)
1238
+ runs, err := c.ListRuns(context.Background(), filter)
1239
+ finishLoading(err)
1045
1240
  if err != nil {
1046
1241
  fmt.Fprintln(os.Stderr, err)
1047
1242
  return exitFail
1048
1243
  }
1049
- for _, r := range runs {
1050
- fmt.Println(formatRunListRow(r))
1244
+ if *jsonOutput {
1245
+ return encodeJSON(runs)
1051
1246
  }
1247
+ renderRunsFiltered(os.Stdout, runs, cliRenderOptions(), filter)
1052
1248
  return exitOK
1053
1249
  case "get":
1054
1250
  fs := flag.NewFlagSet("run get", flag.ContinueOnError)
1055
1251
  ticket := fs.String("ticket", "", "ticket key")
1252
+ jsonOutput := fs.Bool("json", false, "print stable JSON")
1253
+ fs.Bool("no-color", false, "disable terminal colors")
1056
1254
  if err := fs.Parse(args[1:]); err != nil {
1057
1255
  return exitUsage
1058
1256
  }
@@ -1060,14 +1258,17 @@ func cmdRun(c *server.Client, args []string) int {
1060
1258
  fmt.Fprintln(os.Stderr, "run get: --ticket is required")
1061
1259
  return exitUsage
1062
1260
  }
1063
- rn, err := c.GetRunByTicket(context.Background(), *ticket)
1261
+ finishLoading := beginLoading("Loading run", *jsonOutput)
1262
+ detail, err := c.GetRunDetailByTicket(context.Background(), *ticket)
1263
+ finishLoading(err)
1064
1264
  if err != nil {
1065
1265
  fmt.Fprintln(os.Stderr, err)
1066
1266
  return exitFail
1067
1267
  }
1068
- enc := json.NewEncoder(os.Stdout)
1069
- enc.SetIndent("", " ")
1070
- _ = enc.Encode(rn)
1268
+ if *jsonOutput {
1269
+ return encodeJSON(detail)
1270
+ }
1271
+ renderRunDetail(os.Stdout, detail, cliRenderOptions())
1071
1272
  return exitOK
1072
1273
  case "restart":
1073
1274
  fs := flag.NewFlagSet("run restart", flag.ContinueOnError)
@@ -0,0 +1,204 @@
1
+ package main
2
+
3
+ import (
4
+ "bytes"
5
+ "io"
6
+ "os"
7
+ "strings"
8
+ "testing"
9
+ "time"
10
+
11
+ runsvc "github.com/rajpopat27/relay-flow/internal/run"
12
+ "github.com/rajpopat27/relay-flow/internal/server"
13
+ "github.com/rajpopat27/relay-flow/internal/task"
14
+ "github.com/rajpopat27/relay-flow/internal/workflow"
15
+ )
16
+
17
+ func TestRunDetailRendererKeepsPendingAndNestedVisitOrder(t *testing.T) {
18
+ now := time.Date(2026, 9, 10, 12, 0, 0, 0, time.UTC)
19
+ started := now.Add(-2 * time.Minute)
20
+ finished := now.Add(-time.Minute)
21
+ detail := runsvc.RunDetail{Run: runsvc.Run{
22
+ ID: "repo/workflow/T-1", Repo: "repo", Workflow: "workflow", State: runsvc.StateWaiting,
23
+ Ticket: runsvcTicket("T-1"), StartedAt: started,
24
+ }, Steps: []runsvc.StepEntry{
25
+ {Sequence: 0, Node: "start", Status: runsvc.StepSucceeded, Depth: 1, StartedAt: &started, FinishedAt: &finished},
26
+ {Sequence: 1, Node: "coding", Status: runsvc.StepWaiting, Depth: 1, StartedAt: &started},
27
+ {Sequence: 2, Node: "coding", Status: runsvc.StepFailed, Depth: 2, StartedAt: &started, FinishedAt: &finished, Message: "setup failed", ParentSequence: 1},
28
+ {Sequence: 3, Node: "end", Status: runsvc.StepPending, Depth: 1},
29
+ }}
30
+ var out bytes.Buffer
31
+ renderRunDetail(&out, detail, renderOptions{ASCII: true, Now: now})
32
+ text := out.String()
33
+ for _, want := range []string{"Status: Waiting", "Conditions:", "Progress: 3/4", "[ ] end", "setup failed", " `--"} {
34
+ if !strings.Contains(text, want) {
35
+ t.Fatalf("rendered detail missing %q:\n%s", want, text)
36
+ }
37
+ }
38
+ if strings.Index(text, "start") > strings.Index(text, "coding") || strings.Index(text, "coding") > strings.Index(text, "end") {
39
+ t.Fatalf("step order was not preserved:\n%s", text)
40
+ }
41
+ }
42
+
43
+ func TestScopedHelpCoversAllCommandLevels(t *testing.T) {
44
+ for _, args := range [][]string{
45
+ {"--help"}, {"workflow", "--help"}, {"workflow", "list", "--help"},
46
+ {"repo", "--help"}, {"repo", "register", "--help"}, {"run", "--help"},
47
+ {"run", "get", "--help"}, {"task", "--help"}, {"task", "auth", "--help"},
48
+ {"serve", "--help"}, {"runtime-register", "--help"}, {"version", "--help"},
49
+ } {
50
+ var out bytes.Buffer
51
+ if code := printScopedHelp(args, &out); code != exitOK {
52
+ t.Fatalf("help %v exit = %d", args, code)
53
+ }
54
+ if out.Len() == 0 {
55
+ t.Fatalf("help %v was empty", args)
56
+ }
57
+ if !strings.Contains(out.String(), "Example:") {
58
+ t.Fatalf("help %v missing example: %q", args, out.String())
59
+ }
60
+ }
61
+ var example bytes.Buffer
62
+ if code := printScopedHelp([]string{"run", "get", "--help"}, &example); code != exitOK || !strings.Contains(example.String(), "Example:") {
63
+ t.Fatalf("run get help missing example: %q", example.String())
64
+ }
65
+ }
66
+
67
+ func TestFilteredEmptyRunListShowsEffectiveFilters(t *testing.T) {
68
+ var out bytes.Buffer
69
+ active := true
70
+ renderRunsFiltered(&out, nil, renderOptions{ASCII: true}, runsvc.Filter{Repo: "payments", Workflow: "basicFlow", Active: &active})
71
+ text := out.String()
72
+ if !strings.Contains(text, "repo=payments") || !strings.Contains(text, "workflow=basicFlow") || !strings.Contains(text, "active=true") || !strings.Contains(text, "0 runs | 0 active | 0 failed") {
73
+ t.Fatalf("empty filtered output = %q", text)
74
+ }
75
+ var workflows bytes.Buffer
76
+ renderWorkflowSummaries(&workflows, nil, renderOptions{ASCII: true, Width: 120})
77
+ if !strings.Contains(workflows.String(), "0 workflows | 0 active") {
78
+ t.Fatalf("empty workflow output = %q", workflows.String())
79
+ }
80
+ }
81
+
82
+ func TestRunDetailRendererShowsRetryErrorWhenRunErrorIsEmpty(t *testing.T) {
83
+ detail := runsvc.RunDetail{Run: runsvc.Run{ID: "repo/workflow/T-RETRY", State: runsvc.StateWaiting, StartedAt: time.Now().UTC()}}
84
+ detail.Retry = &runsvc.RetryStatus{Attempt: 2, LastError: "runner temporarily unavailable", NextRetryAt: time.Now().UTC().Add(time.Minute)}
85
+ var out bytes.Buffer
86
+ renderRunDetail(&out, detail, renderOptions{ASCII: true, Width: 120, Now: time.Now().UTC()})
87
+ if !strings.Contains(out.String(), "runner temporarily unavailable") || !strings.Contains(out.String(), "RetryScheduled: True") {
88
+ t.Fatalf("retry error output = %q", out.String())
89
+ }
90
+ }
91
+
92
+ func TestTerminalStepWithUnknownDurationRendersPlaceholder(t *testing.T) {
93
+ started := time.Now().UTC().Add(-time.Minute)
94
+ detail := runsvc.RunDetail{Run: runsvc.Run{ID: "repo/workflow/T-UNKNOWN", State: runsvc.StateCompleted, StartedAt: started}, Steps: []runsvc.StepEntry{{Node: "coding", Status: runsvc.StepSucceeded, Depth: 1, StartedAt: &started}}}
95
+ var out bytes.Buffer
96
+ renderRunDetail(&out, detail, renderOptions{ASCII: true, Width: 120, Now: time.Now().UTC()})
97
+ found := false
98
+ for _, line := range strings.Split(out.String(), "\n") {
99
+ if strings.Contains(line, "coding") && strings.Contains(line, " - ") {
100
+ found = true
101
+ }
102
+ }
103
+ if !found {
104
+ t.Fatalf("unknown terminal duration output = %q", out.String())
105
+ }
106
+ }
107
+
108
+ func TestRunDetailRendererPreservesKnownZeroTimingAndResourceValues(t *testing.T) {
109
+ created := time.Date(2026, 9, 10, 11, 0, 0, 0, time.UTC)
110
+ started := created.Add(time.Minute)
111
+ detail := runsvc.RunDetail{Run: runsvc.Run{ID: "repo/workflow/T-2", State: runsvc.StateCompleted, StartedAt: started, FinishedAt: &started}, CreatedAt: &created, Steps: []runsvc.StepEntry{{Node: "end", Status: runsvc.StepSucceeded, Depth: 1, StartedAt: &started, FinishedAt: &started, Resource: "harness"}}}
112
+ var out bytes.Buffer
113
+ renderRunDetail(&out, detail, renderOptions{ASCII: true, Now: started})
114
+ text := out.String()
115
+ for _, want := range []string{"Created: 2026-09-10 11:00:00 UTC", "Started: 2026-09-10 11:01:00 UTC", "Duration: 0s", "ResourcesDuration: runner 0s, task-system 0s, harness 0s"} {
116
+ if !strings.Contains(text, want) {
117
+ t.Fatalf("timing/resource output missing %q:\n%s", want, text)
118
+ }
119
+ }
120
+ }
121
+
122
+ func TestLoadingIndicatorIsSilentForPipedOutput(t *testing.T) {
123
+ oldStdout, oldStderr := os.Stdout, os.Stderr
124
+ stdoutR, stdoutW, err := os.Pipe()
125
+ if err != nil {
126
+ t.Fatal(err)
127
+ }
128
+ stderrR, stderrW, err := os.Pipe()
129
+ if err != nil {
130
+ t.Fatal(err)
131
+ }
132
+ os.Stdout, os.Stderr = stdoutW, stderrW
133
+ finish := beginLoading("Loading runs", false)
134
+ finish(nil)
135
+ _ = stdoutW.Close()
136
+ _ = stderrW.Close()
137
+ os.Stdout, os.Stderr = oldStdout, oldStderr
138
+ stdout, _ := io.ReadAll(stdoutR)
139
+ stderr, _ := io.ReadAll(stderrR)
140
+ _ = stdoutR.Close()
141
+ _ = stderrR.Close()
142
+ if len(stdout) != 0 || len(stderr) != 0 {
143
+ t.Fatalf("piped loading output stdout=%q stderr=%q", stdout, stderr)
144
+ }
145
+ }
146
+
147
+ func TestNarrowRunDetailOutputDoesNotExceedTerminalWidth(t *testing.T) {
148
+ detail := runsvc.RunDetail{Run: runsvc.Run{ID: "repo/workflow/T-1", State: runsvc.StateWaiting, StartedAt: time.Now().UTC()}, Steps: []runsvc.StepEntry{{Node: "a-very-long-node-name", Status: runsvc.StepWaiting, Depth: 1, Message: "a very long message"}}}
149
+ var out bytes.Buffer
150
+ renderRunDetail(&out, detail, renderOptions{ASCII: true, Width: 40, Now: time.Now().UTC()})
151
+ for _, line := range strings.Split(strings.TrimSuffix(out.String(), "\n"), "\n") {
152
+ if len([]rune(line)) > 40 {
153
+ t.Fatalf("narrow line length=%d: %q", len([]rune(line)), line)
154
+ }
155
+ }
156
+ }
157
+
158
+ func TestBlockedActiveRunCountsAsActiveAndFailed(t *testing.T) {
159
+ var out bytes.Buffer
160
+ active := true
161
+ renderRunsFiltered(&out, []runsvc.Run{{State: runsvc.StateBlocked, Ticket: runsvcTicket("T-BLOCKED"), Workflow: "workflow"}}, renderOptions{ASCII: true, Width: 120}, runsvc.Filter{Active: &active})
162
+ if !strings.Contains(out.String(), "1 active") || !strings.Contains(out.String(), "1 failed") {
163
+ t.Fatalf("blocked aggregate = %q", out.String())
164
+ }
165
+ }
166
+
167
+ func TestNarrowWorkflowDetailStaysWithinTerminalWidth(t *testing.T) {
168
+ wf := &workflow.Workflow{Name: "a-very-long-workflow-name", Repos: []string{"a-very-long-repository-name"}, Nodes: map[string]workflow.Node{workflow.StartNode: {}, workflow.EndNode: {}}}
169
+ var out bytes.Buffer
170
+ renderWorkflowDetail(&out, server.WorkflowDetail{Workflow: wf}, renderOptions{ASCII: true, Width: 40})
171
+ for _, line := range strings.Split(strings.TrimSuffix(out.String(), "\n"), "\n") {
172
+ if len([]rune(line)) > 40 {
173
+ t.Fatalf("narrow workflow detail line length=%d: %q", len([]rune(line)), line)
174
+ }
175
+ }
176
+ }
177
+
178
+ func TestNarrowWorkflowAndRunListsStayWithinTerminalWidth(t *testing.T) {
179
+ options := renderOptions{ASCII: true, Width: 40, Now: time.Now().UTC()}
180
+ var workflows bytes.Buffer
181
+ renderWorkflowSummaries(&workflows, []server.WorkflowSummary{{Workflow: &workflow.Workflow{Name: "a-very-long-workflow-name", Repos: []string{"a-very-long-repository-name"}}, NodeCount: 7, ActiveRuns: 1}}, options)
182
+ var runs bytes.Buffer
183
+ renderRunsFiltered(&runs, []runsvc.Run{{ID: "repo/workflow/T-3", Repo: "a-very-long-repository-name", Workflow: "a-very-long-workflow-name", Ticket: runsvcTicket("T-3"), CurrentNode: "a-very-long-node-name", State: runsvc.StateBlocked, UpdatedAt: options.Now}}, options, runsvc.Filter{Repo: "repo"})
184
+ for _, text := range []string{workflows.String(), runs.String()} {
185
+ for _, line := range strings.Split(strings.TrimSuffix(text, "\n"), "\n") {
186
+ if len([]rune(line)) > options.Width {
187
+ t.Fatalf("narrow list line length=%d: %q", len([]rune(line)), line)
188
+ }
189
+ }
190
+ }
191
+ }
192
+
193
+ func TestVersionOutputIsServerIndependent(t *testing.T) {
194
+ var short, long bytes.Buffer
195
+ printVersion(&short, false)
196
+ printVersion(&long, true)
197
+ if !strings.HasPrefix(short.String(), "relay-flow ") || !strings.Contains(long.String(), "Go:") || !strings.Contains(long.String(), "Platform:") {
198
+ t.Fatalf("version output short=%q long=%q", short.String(), long.String())
199
+ }
200
+ }
201
+
202
+ func runsvcTicket(key string) task.TicketRef {
203
+ return task.TicketRef{Key: key}
204
+ }