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.
- 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
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.
|
|
27
|
+
opencode plugin relay-flow-plugin@0.2.10-alpha
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
**Pi**
|
|
31
31
|
|
|
32
32
|
```sh
|
|
33
|
-
pi install npm:relay-flow-plugin@0.2.
|
|
33
|
+
pi install npm:relay-flow-plugin@0.2.10-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.
|
|
163
|
+
"plugin": ["relay-flow-plugin@0.2.10-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.
|
|
172
|
+
"plugin": ["relay-flow-plugin@0.2.10-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.
|
|
188
|
+
pi install npm:relay-flow-plugin@0.2.10-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
|
package/cmd/relay-flow/main.go
CHANGED
|
@@ -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
|
-
|
|
156
|
-
relay-flow run
|
|
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
|
-
|
|
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
|
-
|
|
813
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
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
|
-
|
|
1050
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
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
|
+
}
|