main
reporter_plain.go
Eric Bower
·
2026-08-16
1package main
2
3import (
4 "fmt"
5 "log/slog"
6 "os"
7 "os/exec"
8 "os/signal"
9 "path/filepath"
10 "strconv"
11 "strings"
12 "syscall"
13 "time"
14)
15
16// waitAndReportPlain provides clean, linear append-only output for background services
17// like pici-runner.service, bypassing all TTY cursor overwrites and spinner loops.
18func waitAndReportPlain(cfg *Cfg, log *slog.Logger, name, jobID, eventType string) error {
19 domain := getDomain(eventType)
20 prefix := fmt.Sprintf("%s.%s.%s.", domain, name, jobID)
21 interval := cfg.MonitorInterval
22 if interval <= 0 {
23 interval = 1 * time.Second
24 }
25 ticker := time.NewTicker(interval)
26 defer ticker.Stop()
27
28 sigCh := make(chan os.Signal, 1)
29 signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
30 defer signal.Stop(sigCh)
31
32 startedSessions := make(map[string]bool)
33 completedSessions := make(map[string]bool)
34 var finalSessions []SessionInfo
35
36 var done <-chan struct{}
37 if cfg.Ctx != nil {
38 done = cfg.Ctx.Done()
39 }
40
41 for {
42 select {
43 case <-done:
44 cancelJobSessions(prefix)
45 fmt.Fprintf(os.Stdout, "[%s] ⏹ runner cancelled\n", time.Now().Format("15:04:05")) //nolint:errcheck
46 return cfg.Ctx.Err()
47 case <-sigCh:
48 cancelJobSessions(prefix)
49 fmt.Fprintf(os.Stdout, "\n[%s] ⏹ runner terminated by signal\n", time.Now().Format("15:04:05")) //nolint:errcheck
50 return fmt.Errorf("job cancelled by signal")
51 case <-ticker.C:
52 }
53
54 listOutput, err := exec.Command("zmx", "list").CombinedOutput()
55 if err != nil {
56 if log != nil {
57 log.Error("zmx list", "err", err)
58 }
59 continue
60 }
61
62 sessions := parseZMXList(string(listOutput))
63 var jobSessions []SessionInfo
64 for _, s := range sessions {
65 if strings.HasPrefix(s.Name, prefix) {
66 s.Short = cleanSessionShort(s.Name, prefix, name, jobID)
67 jobSessions = append(jobSessions, s)
68 }
69 }
70
71 finalSessions = jobSessions
72
73 for _, s := range jobSessions {
74 if s.Short == "runner" || s.Short == "" {
75 continue
76 }
77
78 if !startedSessions[s.Short] {
79 startedSessions[s.Short] = true
80 fmt.Fprintf(os.Stdout, "[%s] ▶ [%s] started\n", time.Now().Format("15:04:05"), s.Short) //nolint:errcheck
81 }
82
83 if s.Ended != "" && !completedSessions[s.Short] {
84 completedSessions[s.Short] = true
85 created, _ := strconv.ParseInt(s.Created, 10, 64)
86 ended, _ := strconv.ParseInt(s.Ended, 10, 64)
87 dur := time.Duration(ended-created) * time.Second
88 if s.ExitCode == "0" {
89 fmt.Fprintf(os.Stdout, "[%s] ✔ [%s] succeeded (%s)\n", time.Now().Format("15:04:05"), s.Short, formatShortDuration(dur)) //nolint:errcheck
90 } else {
91 fmt.Fprintf(os.Stdout, "[%s] ✖ [%s] failed with exit %s (%s)\n", time.Now().Format("15:04:05"), s.Short, s.ExitCode, formatShortDuration(dur)) //nolint:errcheck
92 hist, err := fetchHistoryPlain(s.Name)
93 if err == nil && hist != "" {
94 errPreview := extractErrorPreview(hist, 5)
95 for _, line := range errPreview {
96 fmt.Fprintf(os.Stdout, " │ %s\n", line) //nolint:errcheck
97 }
98 }
99 }
100 }
101 }
102
103 // Stage local artifacts
104 _ = stageLocalArtifacts(cfg, log, name, jobID, eventType)
105
106 if isJobComplete(jobSessions) {
107 break
108 }
109 }
110
111 exitCode, status := resolveJobExitCode(finalSessions)
112 _, _, duration := computeJobTiming(finalSessions)
113
114 reportPath := filepath.Join(cfg.ArtifactDir, name, jobID, "index.html")
115 if exitCode == 0 {
116 fmt.Fprintf(os.Stdout, "[%s] ✔ job finished: %s (%s)\n", time.Now().Format("15:04:05"), status, duration) //nolint:errcheck
117 } else {
118 fmt.Fprintf(os.Stdout, "[%s] ❌ job failed: exit %d (%s)\n", time.Now().Format("15:04:05"), exitCode, duration) //nolint:errcheck
119 }
120 fmt.Fprintf(os.Stdout, "Artifacts: %s\n", reportPath) //nolint:errcheck
121
122 if cfg.SummaryFile != "" {
123 knownStates := make(map[string]*sessionState)
124 for _, s := range finalSessions {
125 st := &sessionState{
126 status: "failed",
127 exitCode: s.ExitCode,
128 }
129 if s.ExitCode == "0" {
130 st.status = "success"
131 }
132 knownStates[s.Short] = st
133 }
134 topic := fmt.Sprintf("%s.%s.%s", domain, name, jobID)
135 if err := writeSummaryMarkdown(cfg.SummaryFile, name, jobID, finalSessions, knownStates, cfg.DebugOnFail && exitCode != 0, topic); err != nil && log != nil {
136 log.Error("write summary markdown", "err", err, "file", cfg.SummaryFile)
137 }
138 }
139
140 if exitCode != 0 {
141 return &JobFailedError{ExitCode: exitCode}
142 }
143
144 return nil
145}