Commit 55df8b9

Eric Bower  ·  2026-05-06 23:44:47 -0400 EDT
parent 642d214
feat(runner): --wait command for debugging
2 files changed,  +219, -19
+1, -0
......@@ -1 +1,2 @@
11 ci
2+pici
+218, -19
......@@ -12,8 +12,10 @@ import (
1212 "log/slog"
1313 "os"
1414 "os/exec"
15+ "os/signal"
1516 "path/filepath"
1617 "strings"
18+ "syscall"
1719 "time"
1820
1921 "github.com/golang-cz/devslog"
......@@ -43,6 +45,7 @@ type Cfg struct {
4345 StatusOutput io.Writer // where status JSONL is written (default: os.Stdout)
4446 StatusFilter string // "terminal" (default) or "all"
4547 HumanOutput bool // human-readable output instead of JSONL / slog
48+ Wait bool // block until job completes, print history and summary
4649 }
4750
4851 type Event struct {
......@@ -53,7 +56,7 @@ type Event struct {
5356 ArtifactURL string `json:"artifact_url"` // public URL for artifacts (e.g. https://{user}-artifacts.pgs.sh/{repo})
5457 }
5558
56-func NewCfg() *Cfg {
59+func NewCfg() (*Cfg, string, bool) {
5760 var keyLoc, certLoc, artifactDir, event string
5861 var monitorInterval time.Duration
5962 var logLevel string
......@@ -67,9 +70,15 @@ func NewCfg() *Cfg {
6770 flag.BoolVar(&structured, "structured", false, "use structured key=value log output")
6871 var statusFilter string
6972 var human bool
73+ var wait bool
7074 flag.StringVar(&statusFilter, "status-filter", "terminal", "status output filter: terminal (default) or all")
7175 flag.BoolVar(&human, "human", false, "human-readable output (default: JSONL / slog)")
72- flag.Parse()
76+ flag.BoolVar(&wait, "wait", false, "block until job completes, printing session history and summary")
77+
78+ // Split args so the subcommand (first non-flag arg) doesn't block
79+ // flags that appear after it: "pici runner --wait" works.
80+ flags, cmd, wantHelp := splitCommand(os.Args[1:])
81+ flag.CommandLine.Parse(flags)
7382
7483 logger := newLogger("ci", logLevel, structured)
7584 ctx, cancel := context.WithCancel(context.Background())
......@@ -85,7 +94,27 @@ func NewCfg() *Cfg {
8594 MonitorInterval: monitorInterval,
8695 StatusFilter: statusFilter,
8796 HumanOutput: human,
97+ Wait: wait,
98+ }, cmd, wantHelp
99+}
100+
101+// splitCommand separates the first non-flag argument (the subcommand) from
102+// the rest of the flags, so "runner --wait" becomes flags=["--wait"], cmd="runner".
103+// It strips --help/help so we can print custom help per subcommand.
104+func splitCommand(args []string) (flags []string, cmd string, wantHelp bool) {
105+ flags = make([]string, 0, len(args))
106+ for _, arg := range args {
107+ if arg == "--help" || arg == "help" {
108+ wantHelp = true
109+ continue
110+ }
111+ if cmd == "" && arg != "" && !strings.HasPrefix(arg, "-") {
112+ cmd = arg
113+ } else {
114+ flags = append(flags, arg)
115+ }
88116 }
117+ return flags, cmd, wantHelp
89118 }
90119
91120 func parseLogLevel(s string) slog.Level {
......@@ -116,18 +145,22 @@ func newLogger(space string, levelStr string, structured bool) *slog.Logger {
116145 }
117146
118147 func main() {
119- cfg := NewCfg()
120- cmd := flag.Arg(0)
148+ cfg, cmd, wantHelp := NewCfg()
121149
122150 cfg.Logger.Debug("setting up ci", "cfg", cfg)
123151 cfg.Logger.Debug("running cmd", "cmd", cmd)
124152
125- switch cmd {
126- case "runner":
127- if flag.Arg(1) == "--help" || flag.Arg(1) == "help" {
153+ if wantHelp && (cmd == "runner" || cmd == "monitor") {
154+ if cmd == "runner" {
128155 printRunnerHelp()
129- return
156+ } else {
157+ printMonitorHelp()
130158 }
159+ return
160+ }
161+
162+ switch cmd {
163+ case "runner":
131164 cfg.Logger.Debug("starting runner")
132165 if err := RunRunner(cfg); err != nil {
133166 cfg.Logger.Error("runner failed", "err", err)
......@@ -146,10 +179,6 @@ func main() {
146179 os.Exit(1)
147180 }
148181 case "monitor":
149- if flag.Arg(1) == "--help" || flag.Arg(1) == "help" {
150- printMonitorHelp()
151- return
152- }
153182 cfg.Logger.Debug("starting monitor")
154183 if err := runMonitor(cfg); err != nil {
155184 cfg.Logger.Error("monitor failed", "err", err)
......@@ -198,6 +227,7 @@ func printRunnerHelp() {
198227 USAGE
199228 echo '<event-json>' | pici runner [flags]
200229 pici runner --event '<event-json>' [flags]
230+ echo '<event-json>' | pici runner --wait [flags] # block until done
201231
202232 EVENT JSON FIELDS
203233 type (required) Event type, e.g. "push", "merge_request"
......@@ -215,7 +245,8 @@ FLAGS
215245 -event <json> Event JSON string (alternative to reading from stdin)
216246 -artifact-dir <path> Local directory to stage artifacts (default: /tmp/pici-artifacts)
217247 -log-level <level> Log level: debug, info, warn, error (default: info)
218- -human Human-readable output (default: enabled for runner)`)
248+ -human Human-readable output (default: enabled for runner)
249+ -wait Block until job completes, printing session history and summary`)
219250
220251 }
221252
......@@ -354,21 +385,22 @@ func (eng *JobEngine) Run() error {
354385 log.Debug("starting runner session", "session", prefix+"runner")
355386
356387 evStr := fmt.Sprintf("PICO_CI_EVENT=%s", eng.Ev.Type)
357- jobStr := fmt.Sprintf("ZMX_SESSION_PREFIX=%s", childPrefix)
358388
359389 runnerName := prefix + "runner"
360- // Name the runner explicitly and don't set ZMX_SESSION_PREFIX for this
361- // outer zmx run call. The prefix is only for child sessions spawned
362- // inside pico.sh (via the exported env var).
390+ // Name the runner explicitly. Do NOT set ZMX_SESSION_PREFIX for this
391+ // outer zmx run call — it would be prepended to the runner's own name.
392+ // The prefix is only for child sessions spawned inside pico.sh (via the
393+ // exported env var).
363394 cmdEnv := make([]string, 0, len(os.Environ())+2)
364395 for _, e := range os.Environ() {
365396 if !strings.HasPrefix(e, "ZMX_SESSION_PREFIX=") {
366397 cmdEnv = append(cmdEnv, e)
367398 }
368399 }
369- cmdEnv = append(cmdEnv, evStr, jobStr)
400+ cmdEnv = append(cmdEnv, evStr)
370401
371- cmd := exec.Command("zmx", "run", runnerName, "-d", "bash", manifest)
402+ zmxPrefixStr := fmt.Sprintf("ZMX_SESSION_PREFIX=%s", childPrefix)
403+ cmd := exec.Command("zmx", "run", runnerName, "-d", zmxPrefixStr, "bash", manifest)
372404 cmd.Env = cmdEnv
373405 cmd.Dir = eng.Wk.GetDir()
374406
......@@ -441,10 +473,177 @@ func eventHandler(cfg *Cfg, eventData *Event) error {
441473 }
442474
443475 fmt.Fprintln(os.Stdout, "āœ… job launched")
476+
477+ if cfg.Wait {
478+ if err := waitAndReport(cfg, log, eventData.Name, jobID); err != nil {
479+ return fmt.Errorf("wait: %w", err)
480+ }
481+ return nil
482+ }
483+
444484 fmt.Fprintf(os.Stdout, " follow: zmx tail ci.%s.%s.runner\n", eventData.Name, jobID)
445485 return nil
446486 }
447487
488+// waitAndReport polls the job's sessions until all complete, prints live
489+// progress to stdout, then dumps session history and a final summary.
490+func waitAndReport(cfg *Cfg, log *slog.Logger, name, jobID string) error {
491+ prefix := "ci." + name + "." + jobID + "."
492+ ticker := time.NewTicker(cfg.MonitorInterval)
493+ defer ticker.Stop()
494+
495+ // Handle ^C gracefully
496+ sigCh := make(chan os.Signal, 1)
497+ signal.Notify(sigCh, syscall.SIGINT)
498+ defer signal.Stop(sigCh)
499+
500+ fmt.Fprintln(os.Stdout)
501+ fmt.Fprint(os.Stdout, "ā³ waiting for completion...\n")
502+
503+ // Track what we've already printed to avoid duplicates
504+ type sessionState struct {
505+ status string // "running", "success", "failed"
506+ exitCode string
507+ duration string
508+ printed bool
509+ }
510+ known := make(map[string]*sessionState)
511+ var jobSessions []SessionInfo
512+ var sessionOrder []string // track insertion order for deterministic output
513+
514+ for {
515+ select {
516+ case <-cfg.Ctx.Done():
517+ return cfg.Ctx.Err()
518+ case <-sigCh:
519+ fmt.Fprintln(os.Stdout, "\nā¹ cancelled")
520+ return nil
521+ case <-ticker.C:
522+ }
523+
524+ // Fetch current session list
525+ listOutput, err := exec.Command("zmx", "list").CombinedOutput()
526+ if err != nil {
527+ log.Error("zmx list", "err", err)
528+ continue
529+ }
530+
531+ sessions := parseZMXList(string(listOutput))
532+ jobSessions = nil
533+ for _, s := range sessions {
534+ if strings.HasPrefix(s.Name, prefix) {
535+ s.Short = cleanSessionShort(s.Name, prefix, name, jobID)
536+ jobSessions = append(jobSessions, s)
537+ }
538+ }
539+
540+ if len(jobSessions) == 0 {
541+ continue // runner session may not have appeared yet
542+ }
543+
544+ // Update state for each session
545+ for _, s := range jobSessions {
546+ state, ok := known[s.Short]
547+ if !ok {
548+ state = &sessionState{}
549+ known[s.Short] = state
550+ sessionOrder = append(sessionOrder, s.Short)
551+ }
552+
553+ if s.Ended == "" {
554+ state.status = "running"
555+ } else {
556+ if s.ExitCode == "0" {
557+ state.status = "success"
558+ } else {
559+ state.status = "failed"
560+ state.exitCode = s.ExitCode
561+ }
562+ state.duration = fmtDuration(s.Created, s.Ended)
563+ }
564+
565+ // Print when state changes
566+ if !state.printed {
567+ icon := map[string]string{"running": "šŸš€", "success": "āœ…", "failed": "āŒ"}[state.status]
568+ detail := ""
569+ if state.status == "failed" && state.exitCode != "" {
570+ detail = fmt.Sprintf(", exit %s", state.exitCode)
571+ }
572+ if state.duration != "" && state.duration != "—" {
573+ detail += fmt.Sprintf(" (%s)", state.duration)
574+ }
575+ fmt.Fprintf(os.Stdout, " %-12s %s %s%s\n", s.Short, icon, state.status, detail)
576+ state.printed = true
577+ }
578+ }
579+
580+ // Check if all sessions are done
581+ if allCompleted(jobSessions) {
582+ break
583+ }
584+ }
585+
586+ // Print last 25 lines of history for failed sessions only
587+ fmt.Fprintln(os.Stdout)
588+ for _, short := range sessionOrder {
589+ state := known[short]
590+ if state.status != "failed" {
591+ continue
592+ }
593+
594+ full := prefix + short
595+
596+ separator := strings.Repeat("\u2500", 50)
597+ fmt.Fprintln(os.Stdout, separator)
598+ fmt.Fprintf(os.Stdout, "Session: %s (exit %s)\n", short, state.exitCode)
599+ fmt.Fprintln(os.Stdout, separator)
600+ fmt.Fprintln(os.Stdout)
601+
602+ history, err := fetchHistoryPlain(full)
603+ if err != nil {
604+ fmt.Fprintf(os.Stdout, " (history unavailable: %v)\n", err)
605+ } else {
606+ lines := strings.Split(history, "\n")
607+ // Show last 25 lines
608+ if len(lines) > 25 {
609+ fmt.Fprintf(os.Stdout, " ... (%d lines omitted)\n", len(lines)-25)
610+ lines = lines[len(lines)-25:]
611+ }
612+ for _, line := range lines {
613+ if line != "" {
614+ fmt.Fprintf(os.Stdout, " %s\n", line)
615+ }
616+ }
617+ }
618+ fmt.Fprintln(os.Stdout)
619+ }
620+
621+ // Final summary
622+ exitCode, status := resolveJobExitCode(jobSessions)
623+ _, _, duration := computeJobTiming(jobSessions)
624+ icon := map[string]string{"success": "āœ…", "failed": "āŒ"}[status]
625+ if exitCode == 0 {
626+ fmt.Fprintf(os.Stdout, "%s job finished: %s (%s)\n", icon, status, duration)
627+ } else {
628+ fmt.Fprintf(os.Stdout, "%s job failed: exit %d (%s)\n", icon, exitCode, duration)
629+ }
630+
631+ return nil
632+}
633+
634+// cleanSessionShort produces a readable short name from a full session name.
635+// ci.name.jobID.step.lint → lint
636+// ci.name.jobID.step.ci.name.jobID.runner → runner
637+func cleanSessionShort(name, prefix, repoName, jobID string) string {
638+ short := strings.TrimPrefix(name, prefix)
639+ // Strip "step." prefix added by child sessions
640+ short = strings.TrimPrefix(short, "step.")
641+ // Strip nested full prefix (e.g. runner named ci.name.jobID.runner)
642+ nested := "ci." + repoName + "." + jobID + "."
643+ short = strings.TrimPrefix(short, nested)
644+ return short
645+}
646+
448647 // runMonitor is a long-lived daemon that polls all ci.* zmx sessions,
449648 // writes status as JSONL to stdout, stages artifacts, and syncs to destination.
450649 // Logs go to stderr. Compose with shell tools to route status: