Commit 85f918b
Eric Bower
·
2026-05-07 11:26:22 -0400 EDT
parent 9b7abfc
chore: cleanup
M
go.mod
+0,
-2
| ... | ... | @@ -1,5 +1,3 @@ | |
| 1 | 1 | module github.com/picosh/pici | |
| 2 | 2 | ||
| 3 | 3 | go 1.26.2 | |
| 4 | - | ||
| 5 | - | require github.com/golang-cz/devslog v0.0.15 |
M
go.sum
+0,
-2
| ... | ... | @@ -1,2 +0,0 @@ | |
| 1 | - | github.com/golang-cz/devslog v0.0.15 h1:ejoBLTCwJHWGbAmDf2fyTJJQO3AkzcPjw8SC9LaOQMI= | |
| 2 | - | github.com/golang-cz/devslog v0.0.15/go.mod h1:bSe5bm0A7Nyfqtijf1OMNgVJHlWEuVSXnkuASiE1vV8= |
M
main.go
+134,
-29
| ... | ... | @@ -19,7 +19,7 @@ import ( | |
| 19 | 19 | "syscall" | |
| 20 | 20 | "time" | |
| 21 | 21 | ||
| 22 | - | "github.com/golang-cz/devslog" | |
| 22 | + | ||
| 23 | 23 | ) | |
| 24 | 24 | ||
| 25 | 25 | type WorkspaceFactory func(cfg *Cfg, logger *slog.Logger, source string) Workspace |
| ... | ... | @@ -61,14 +61,12 @@ func NewCfg() (*Cfg, string, bool) { | |
| 61 | 61 | var keyLoc, certLoc, artifactDir, event string | |
| 62 | 62 | var monitorInterval time.Duration | |
| 63 | 63 | var logLevel string | |
| 64 | - | var structured bool | |
| 65 | 64 | flag.StringVar(&keyLoc, "pk", "", "ssh private key used to authenticate with pico services") | |
| 66 | 65 | flag.StringVar(&certLoc, "ck", "", "ssh certificate public key used to authenticate with pico services (only required if using ssh certificates)") | |
| 67 | 66 | flag.StringVar(&artifactDir, "artifact-dir", "/tmp/pici-artifacts", "local directory to stage artifacts") | |
| 68 | 67 | flag.StringVar(&event, "event", "", "event JSON to run (alternative to reading from stdin)") | |
| 69 | 68 | flag.DurationVar(&monitorInterval, "monitor-interval", 5*time.Second, "interval for monitoring zmx sessions") | |
| 70 | 69 | flag.StringVar(&logLevel, "log-level", "info", "log level: debug, info, warn, error") | |
| 71 | - | flag.BoolVar(&structured, "structured", false, "use structured key=value log output") | |
| 72 | 70 | var statusFilter string | |
| 73 | 71 | var human bool | |
| 74 | 72 | var wait bool |
| ... | ... | @@ -81,7 +79,7 @@ func NewCfg() (*Cfg, string, bool) { | |
| 81 | 79 | flags, cmd, wantHelp := splitCommand(os.Args[1:]) | |
| 82 | 80 | flag.CommandLine.Parse(flags) | |
| 83 | 81 | ||
| 84 | - | logger := newLogger("ci", logLevel, structured) | |
| 82 | + | logger := newLogger("ci", logLevel) | |
| 85 | 83 | ctx, cancel := context.WithCancel(context.Background()) | |
| 86 | 84 | return &Cfg{ | |
| 87 | 85 | NewWorkspace: defaultWorkspaceFactory, |
| ... | ... | @@ -131,17 +129,10 @@ func parseLogLevel(s string) slog.Level { | |
| 131 | 129 | } | |
| 132 | 130 | } | |
| 133 | 131 | ||
| 134 | - | func newLogger(space string, levelStr string, structured bool) *slog.Logger { | |
| 132 | + | func newLogger(space string, levelStr string) *slog.Logger { | |
| 135 | 133 | lvl := parseLogLevel(levelStr) | |
| 136 | - | if structured { | |
| 137 | - | return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ | |
| 138 | - | Level: lvl, | |
| 139 | - | })).With("service", space) | |
| 140 | - | } | |
| 141 | - | return slog.New(devslog.NewHandler(os.Stderr, &devslog.Options{ | |
| 142 | - | HandlerOptions: &slog.HandlerOptions{ | |
| 143 | - | Level: lvl, | |
| 144 | - | }, | |
| 134 | + | return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ | |
| 135 | + | Level: lvl, | |
| 145 | 136 | })).With("service", space) | |
| 146 | 137 | } | |
| 147 | 138 |
| ... | ... | @@ -528,12 +519,6 @@ func waitAndReport(cfg *Cfg, log *slog.Logger, name, jobID string) error { | |
| 528 | 519 | fmt.Fprint(os.Stdout, "⏳ waiting for completion...\n") | |
| 529 | 520 | ||
| 530 | 521 | // Track state for each session | |
| 531 | - | type sessionState struct { | |
| 532 | - | status string // "running", "success", "failed" | |
| 533 | - | exitCode string | |
| 534 | - | duration string | |
| 535 | - | printed bool // true once final state (success/failed) is printed | |
| 536 | - | } | |
| 537 | 522 | known := make(map[string]*sessionState) | |
| 538 | 523 | var jobSessions []SessionInfo | |
| 539 | 524 | var sessionOrder []string // track insertion order for deterministic output |
| ... | ... | @@ -702,6 +687,23 @@ func cleanSessionShort(name, prefix, repoName, jobID string) string { | |
| 702 | 687 | // pici monitor | ssh pipe.pico.sh "pub build.status -b=false" | |
| 703 | 688 | // pici monitor | while read -r line; do curl -sd"$line" $WEBHOOK; done | |
| 704 | 689 | // pici monitor > status.jsonl | |
| 690 | + | // | |
| 691 | + | // sessionState tracks the display state of a single session. | |
| 692 | + | type sessionState struct { | |
| 693 | + | status string // "running", "success", "failed" | |
| 694 | + | exitCode string | |
| 695 | + | duration string | |
| 696 | + | printed bool // true once final state (success/failed) is printed | |
| 697 | + | } | |
| 698 | + | ||
| 699 | + | // monitorJobState tracks display state for a single job across ticks. | |
| 700 | + | type monitorJobState struct { | |
| 701 | + | sessionOrder []string // insertion order for deterministic output | |
| 702 | + | sessions map[string]*sessionState | |
| 703 | + | liveLines []string // last set of status lines printed (for overwrite) | |
| 704 | + | published bool // final status already printed | |
| 705 | + | } | |
| 706 | + | ||
| 705 | 707 | func runMonitor(cfg *Cfg) error { | |
| 706 | 708 | log := cfg.Logger.With("cmd", "monitor") | |
| 707 | 709 |
| ... | ... | @@ -713,6 +715,9 @@ func runMonitor(cfg *Cfg) error { | |
| 713 | 715 | ticker := time.NewTicker(cfg.MonitorInterval) | |
| 714 | 716 | defer ticker.Stop() | |
| 715 | 717 | ||
| 718 | + | // Track per-job display state across ticks (for human output) | |
| 719 | + | jobStates := make(map[string]*monitorJobState) // key: "name/jobID" | |
| 720 | + | ||
| 716 | 721 | log.Debug("monitor started", "interval", cfg.MonitorInterval, "artifact_dir", cfg.ArtifactDir) | |
| 717 | 722 | log.Debug("monitoring ci.* sessions for job status, writing status to stdout") | |
| 718 | 723 |
| ... | ... | @@ -724,14 +729,114 @@ func runMonitor(cfg *Cfg) error { | |
| 724 | 729 | case <-ticker.C: | |
| 725 | 730 | } | |
| 726 | 731 | ||
| 727 | - | if err := monitorTick(cfg, log, output); err != nil { | |
| 732 | + | if err := monitorTick(cfg, log, output, jobStates); err != nil { | |
| 728 | 733 | log.Error("monitor tick", "err", err) | |
| 729 | 734 | } | |
| 730 | 735 | } | |
| 731 | 736 | } | |
| 732 | 737 | ||
| 733 | 738 | // monitorTick performs a single monitoring pass over all ci.* sessions. | |
| 734 | - | func monitorTick(cfg *Cfg, log *slog.Logger, output io.Writer) error { | |
| 739 | + | // renderJobRunning prints per-session status for a running job, using ANSI | |
| 740 | + | // cursor control to overwrite previous lines. | |
| 741 | + | func renderJobRunning(output io.Writer, name, jobID string, group []SessionInfo, duration string, jobStates map[string]*monitorJobState) { | |
| 742 | + | key := name + "/" + jobID | |
| 743 | + | state, ok := jobStates[key] | |
| 744 | + | if !ok { | |
| 745 | + | state = &monitorJobState{sessions: make(map[string]*sessionState)} | |
| 746 | + | jobStates[key] = state | |
| 747 | + | } | |
| 748 | + | ||
| 749 | + | // Track new sessions | |
| 750 | + | for _, s := range group { | |
| 751 | + | if _, ok := state.sessions[s.Short]; !ok { | |
| 752 | + | state.sessions[s.Short] = &sessionState{} | |
| 753 | + | state.sessionOrder = append(state.sessionOrder, s.Short) | |
| 754 | + | } | |
| 755 | + | } | |
| 756 | + | ||
| 757 | + | // Update session states | |
| 758 | + | for _, s := range group { | |
| 759 | + | ss := state.sessions[s.Short] | |
| 760 | + | if s.Ended == "" { | |
| 761 | + | ss.status = "running" | |
| 762 | + | created, _ := strconv.ParseInt(s.Created, 10, 64) | |
| 763 | + | ss.duration = fmtDurationTs(created, time.Now().Unix()) | |
| 764 | + | } else { | |
| 765 | + | if s.ExitCode == "0" { | |
| 766 | + | ss.status = "success" | |
| 767 | + | } else { | |
| 768 | + | ss.status = "failed" | |
| 769 | + | ss.exitCode = s.ExitCode | |
| 770 | + | } | |
| 771 | + | ss.duration = fmtDuration(s.Created, s.Ended) | |
| 772 | + | } | |
| 773 | + | } | |
| 774 | + | ||
| 775 | + | // Build status lines | |
| 776 | + | lines := make([]string, 0, len(state.sessionOrder)) | |
| 777 | + | for _, short := range state.sessionOrder { | |
| 778 | + | ss := state.sessions[short] | |
| 779 | + | icon := map[string]string{"running": "🚀", "success": "✅", "failed": "❌"}[ss.status] | |
| 780 | + | detail := "" | |
| 781 | + | if ss.status == "failed" && ss.exitCode != "" { | |
| 782 | + | detail = fmt.Sprintf(", exit %s", ss.exitCode) | |
| 783 | + | } | |
| 784 | + | if ss.duration != "" && ss.duration != "—" { | |
| 785 | + | detail += fmt.Sprintf(" (%s)", ss.duration) | |
| 786 | + | } | |
| 787 | + | lines = append(lines, fmt.Sprintf(" %-12s %s %s%s", short, icon, ss.status, detail)) | |
| 788 | + | } | |
| 789 | + | ||
| 790 | + | // Overwrite previous lines or print fresh | |
| 791 | + | if len(state.liveLines) > 0 { | |
| 792 | + | for range len(state.liveLines) { | |
| 793 | + | fmt.Fprint(output, "\033[A") | |
| 794 | + | } | |
| 795 | + | for i, line := range lines { | |
| 796 | + | if i > 0 { | |
| 797 | + | fmt.Fprint(output, "\n") | |
| 798 | + | } | |
| 799 | + | fmt.Fprint(output, line+"\033[K") | |
| 800 | + | } | |
| 801 | + | fmt.Fprint(output, "\n") | |
| 802 | + | } else { | |
| 803 | + | fmt.Fprintf(output, " %-12s %s %s\n", name, "🚀", "running") | |
| 804 | + | for _, line := range lines { | |
| 805 | + | fmt.Fprintln(output, line) | |
| 806 | + | } | |
| 807 | + | } | |
| 808 | + | state.liveLines = lines | |
| 809 | + | } | |
| 810 | + | ||
| 811 | + | // renderJobFinal prints the final status for a completed job. | |
| 812 | + | func renderJobFinal(output io.Writer, name, jobID string, group []SessionInfo, duration, status string, success bool, workspace, artifactDir, artifactURL string) { | |
| 813 | + | icon := map[string]string{"success": "✅", "failed": "❌"}[status] | |
| 814 | + | fmt.Fprintf(output, " %-12s %s %s (%s)\n", name, icon, status, duration) | |
| 815 | + | ||
| 816 | + | // Per-session summary | |
| 817 | + | for _, s := range group { | |
| 818 | + | icon := "✅" | |
| 819 | + | if s.ExitCode != "0" { | |
| 820 | + | icon = "❌" | |
| 821 | + | } | |
| 822 | + | dur := fmtDuration(s.Created, s.Ended) | |
| 823 | + | fmt.Fprintf(output, " %-12s %s done (%s)\n", s.Short, icon, dur) | |
| 824 | + | } | |
| 825 | + | ||
| 826 | + | // Context info | |
| 827 | + | fmt.Fprint(output, "\n") | |
| 828 | + | if workspace != "" { | |
| 829 | + | fmt.Fprintf(output, " workspace: %s\n", workspace) | |
| 830 | + | } | |
| 831 | + | artifactPath := filepath.Join(artifactDir, name, jobID) | |
| 832 | + | fmt.Fprintf(output, " artifacts: %s\n", artifactPath) | |
| 833 | + | if artifactURL != "" { | |
| 834 | + | fmt.Fprintf(output, " url: %s\n", artifactURL) | |
| 835 | + | } | |
| 836 | + | fmt.Fprint(output, "\n") | |
| 837 | + | } | |
| 838 | + | ||
| 839 | + | func monitorTick(cfg *Cfg, log *slog.Logger, output io.Writer, jobStates map[string]*monitorJobState) error { | |
| 735 | 840 | // a. zmx list → filter ci.* sessions | |
| 736 | 841 | listOutput, err := exec.Command("zmx", "list").CombinedOutput() | |
| 737 | 842 | if err != nil { |
| ... | ... | @@ -816,9 +921,7 @@ func monitorTick(cfg *Cfg, log *slog.Logger, output io.Writer) error { | |
| 816 | 921 | log.Info("job finished", "status", status, "exit_code", exitCode) | |
| 817 | 922 | ||
| 818 | 923 | if cfg.HumanOutput { | |
| 819 | - | icon := map[string]string{"success": "✅", "failed": "❌"}[status] | |
| 820 | - | fmt.Fprintf(output, "[%d/%d] %s %s: %s (%s)\n", | |
| 821 | - | countCompleted(group), len(group), icon, status, name, duration) | |
| 924 | + | renderJobFinal(output, name, jobID, group, duration, status, exitCode == 0, eventData.Workspace, cfg.ArtifactDir, artifactURL) | |
| 822 | 925 | } else { | |
| 823 | 926 | payload := StatusPayload{ | |
| 824 | 927 | Name: name, |
| ... | ... | @@ -845,9 +948,7 @@ func monitorTick(cfg *Cfg, log *slog.Logger, output io.Writer) error { | |
| 845 | 948 | // Publish running status only when filter is "all" | |
| 846 | 949 | if cfg.StatusFilter != "terminal" { | |
| 847 | 950 | if cfg.HumanOutput { | |
| 848 | - | completed := countCompleted(group) | |
| 849 | - | fmt.Fprintf(output, "[%d/%d] 🚀 running: %s (%s)\n", | |
| 850 | - | completed, len(group), name, duration) | |
| 951 | + | renderJobRunning(output, name, jobID, group, duration, jobStates) | |
| 851 | 952 | } else { | |
| 852 | 953 | payload := StatusPayload{ | |
| 853 | 954 | Name: name, |
| ... | ... | @@ -869,6 +970,9 @@ func monitorTick(cfg *Cfg, log *slog.Logger, output io.Writer) error { | |
| 869 | 970 | } | |
| 870 | 971 | ||
| 871 | 972 | // d. Sync artifacts once per tick | |
| 973 | + | if cfg.HumanOutput { | |
| 974 | + | fmt.Fprint(output, " 📦 syncing artifacts...\n") | |
| 975 | + | } | |
| 872 | 976 | if err := syncArtifacts(cfg, log); err != nil { | |
| 873 | 977 | log.Error("sync artifacts", "err", err) | |
| 874 | 978 | } |
| ... | ... | @@ -966,7 +1070,8 @@ func groupSessionsByJob(sessions []SessionInfo) map[string][]SessionInfo { | |
| 966 | 1070 | continue | |
| 967 | 1071 | } | |
| 968 | 1072 | // Set the Short name | |
| 969 | - | s.Short = strings.TrimPrefix(s.Name, prefix) | |
| 1073 | + | name, jobID := parseJobPrefix(prefix) | |
| 1074 | + | s.Short = cleanSessionShort(s.Name, prefix, name, jobID) | |
| 970 | 1075 | groups[prefix] = append(groups[prefix], s) | |
| 971 | 1076 | } | |
| 972 | 1077 | return groups |