26f897c1ec
release / release-please (push) Failing after 1m49s
docs / build (push) Failing after 6m34s
release / build-and-upload (arm64, linux) (push) Has been cancelled
release / build-and-upload (arm64, windows) (push) Has been cancelled
release / build-darwin (amd64, darwin) (push) Has been cancelled
release / checksums (push) Has been cancelled
release / finalize (push) Has been cancelled
release / build-darwin (arm64, darwin) (push) Has been cancelled
release / build-and-upload (amd64, linux) (push) Has been cancelled
release / build-and-upload (amd64, windows) (push) Has been cancelled
docs / deploy (push) Has been cancelled
361 lines
10 KiB
Go
361 lines
10 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
toon "github.com/toon-format/toon-go"
|
|
|
|
"github.com/kunchenguid/no-mistakes/internal/db"
|
|
"github.com/kunchenguid/no-mistakes/internal/git"
|
|
"github.com/kunchenguid/no-mistakes/internal/ipc"
|
|
"github.com/kunchenguid/no-mistakes/internal/telemetry"
|
|
"github.com/kunchenguid/no-mistakes/internal/types"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// logTailLines is how many trailing log lines `axi logs` shows without --full.
|
|
const logTailLines = 40
|
|
|
|
func newAxiStatusCmd() *cobra.Command {
|
|
var runID string
|
|
cmd := &cobra.Command{
|
|
Use: "status",
|
|
Short: "Show the active (or most recent) run in detail",
|
|
Args: cobra.NoArgs,
|
|
SilenceErrors: true,
|
|
SilenceUsage: true,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return trackReadSurface("axi-status", telemetry.Fields{
|
|
"explicit_run_id": strings.TrimSpace(runID) != "",
|
|
}, func() (string, string, error) {
|
|
fingerprint, err := runAxiStatus(cmd, runID)
|
|
return fingerprint, "", err
|
|
})
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&runID, "run", "", "inspect a specific run ID (default: active or most recent)")
|
|
return cmd
|
|
}
|
|
|
|
// runAxiStatus renders the run status and returns a low-cardinality state
|
|
// fingerprint (run id, run status, per-step statuses) used to dedupe the
|
|
// command's telemetry across repeated polls.
|
|
func runAxiStatus(cmd *cobra.Command, runID string) (string, error) {
|
|
env, err := openAxiEnv(false)
|
|
if err != nil {
|
|
return "", emitError(cmd, 1, err.Error(), repoInitHelp(err)...)
|
|
}
|
|
defer env.close()
|
|
|
|
run, err := resolveRun(env, runID, currentBranchForRunResolve(cmd.Context()))
|
|
if err != nil {
|
|
return "", emitError(cmd, 1, err.Error())
|
|
}
|
|
|
|
if run == nil {
|
|
if runID != "" {
|
|
return "", emitError(cmd, 1, fmt.Sprintf("run %q not found", runID))
|
|
}
|
|
emitDoc(cmd,
|
|
toon.Field{Key: "runs", Value: "0 runs yet in this repository"},
|
|
toon.Field{Key: "help", Value: []string{startRunHelp()}},
|
|
)
|
|
return env.repo.ID + "|no-runs", nil
|
|
}
|
|
|
|
steps, err := env.d.GetStepsByRun(run.ID)
|
|
if err != nil {
|
|
return "", emitError(cmd, 1, fmt.Sprintf("load steps: %v", err))
|
|
}
|
|
rv := runViewFromDB(run, steps)
|
|
annotateRunView(env, &rv)
|
|
fields := []toon.Field{runObjectField(rv)}
|
|
if gate, ok := rv.awaitingStep(); ok {
|
|
fields = append(fields, gateFields(gate)...)
|
|
} else if terminalStatus(rv.Status) {
|
|
fields = append(fields, toon.Field{Key: "outcome", Value: outcomeFor(rv.Status)})
|
|
if run.Error != nil && *run.Error != "" {
|
|
fields = append(fields, toon.Field{Key: "error", Value: *run.Error})
|
|
}
|
|
}
|
|
emitDoc(cmd, fields...)
|
|
return runStateFingerprint(rv), nil
|
|
}
|
|
|
|
// runStateFingerprint summarizes a run's observable state for telemetry
|
|
// dedupe: any run/step status transition changes the fingerprint.
|
|
func runStateFingerprint(rv runView) string {
|
|
var b strings.Builder
|
|
b.WriteString(rv.ID)
|
|
b.WriteByte('|')
|
|
b.WriteString(rv.Branch)
|
|
b.WriteByte('|')
|
|
b.WriteString(rv.Status)
|
|
b.WriteByte('|')
|
|
b.WriteString(rv.HeadSHA)
|
|
b.WriteByte('|')
|
|
b.WriteString(rv.PRURL)
|
|
for _, step := range rv.Steps {
|
|
b.WriteByte('|')
|
|
b.WriteString(step.Name)
|
|
b.WriteByte(':')
|
|
b.WriteString(step.Status)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func annotateRunView(env *axiEnv, rv *runView) {
|
|
if env == nil || rv == nil {
|
|
return
|
|
}
|
|
quietWarning := configQuietWarning(env)
|
|
for i := range rv.Steps {
|
|
step := &rv.Steps[i]
|
|
step.QuietWarning = quietWarning
|
|
if step.ID != "" {
|
|
if stats, err := env.d.StepRoundStats(step.ID); err == nil {
|
|
step.RoundCount = stats.TotalRounds
|
|
step.FixRoundCount = stats.FixRounds
|
|
step.PendingFixSource = stats.PendingFixSource
|
|
}
|
|
}
|
|
if step.LastActivityAt == nil {
|
|
logPath := filepath.Join(env.p.RunLogDir(rv.ID), step.Name+".log")
|
|
if info, err := os.Stat(logPath); err == nil {
|
|
ts := info.ModTime().Unix()
|
|
step.LastActivityAt = &ts
|
|
step.LastActivity = "step log updated"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func configQuietWarning(env *axiEnv) time.Duration {
|
|
if env == nil || env.cfg == nil || env.cfg.StepQuietWarning <= 0 {
|
|
return 0
|
|
}
|
|
return env.cfg.StepQuietWarning
|
|
}
|
|
|
|
func startRunHelp() string {
|
|
return `Run no-mistakes axi run --intent "the user's goal" --yes to validate the current branch`
|
|
}
|
|
|
|
func noRunLogsHelp() string {
|
|
return startRunHelp()
|
|
}
|
|
|
|
func newAxiLogsCmd() *cobra.Command {
|
|
var step, runID string
|
|
var full bool
|
|
cmd := &cobra.Command{
|
|
Use: "logs",
|
|
Short: "Show the log output of one pipeline step",
|
|
Args: cobra.NoArgs,
|
|
SilenceErrors: true,
|
|
SilenceUsage: true,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return trackReadSurface("axi-logs", telemetry.Fields{
|
|
"step": sanitizeAxiTelemetryStep(step),
|
|
"full": full,
|
|
"explicit_run_id": strings.TrimSpace(runID) != "",
|
|
}, func() (string, string, error) {
|
|
fingerprint, err := runAxiLogs(cmd, step, runID, full)
|
|
return fingerprint, "", err
|
|
})
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&step, "step", "", "step name: intent, rebase, review, test, document, lint, push, pr, ci (required)")
|
|
cmd.Flags().StringVar(&runID, "run", "", "run ID (default: active or most recent)")
|
|
cmd.Flags().BoolVar(&full, "full", false, "show the entire log instead of the tail")
|
|
return cmd
|
|
}
|
|
|
|
// runAxiLogs renders a step log and returns a run+step telemetry fingerprint:
|
|
// repeated reads of the same step's log carry no distinct analytics signal,
|
|
// so only switching run or step (or the heartbeat) re-emits.
|
|
func runAxiLogs(cmd *cobra.Command, step, runID string, full bool) (string, error) {
|
|
step = strings.TrimSpace(step)
|
|
if step == "" {
|
|
return "", emitError(cmd, 2, "--step is required",
|
|
"Valid steps: intent, rebase, review, test, document, lint, push, pr, ci")
|
|
}
|
|
if !validStep(types.StepName(step)) {
|
|
return "", emitError(cmd, 2, fmt.Sprintf("unknown step %q", step),
|
|
"Valid steps: intent, rebase, review, test, document, lint, push, pr, ci")
|
|
}
|
|
|
|
env, err := openAxiEnv(false)
|
|
if err != nil {
|
|
return "", emitError(cmd, 1, err.Error(), repoInitHelp(err)...)
|
|
}
|
|
defer env.close()
|
|
|
|
run, err := resolveRun(env, runID, currentBranchForRunResolve(cmd.Context()))
|
|
if err != nil {
|
|
return "", emitError(cmd, 1, err.Error())
|
|
}
|
|
if run == nil {
|
|
return "", emitError(cmd, 1, "no run found to read logs from",
|
|
noRunLogsHelp())
|
|
}
|
|
steps, err := env.d.GetStepsByRun(run.ID)
|
|
if err != nil {
|
|
return "", emitError(cmd, 1, fmt.Sprintf("load steps: %v", err))
|
|
}
|
|
fingerprint := runStateFingerprint(runViewFromDB(run, steps)) + "|log:" + step
|
|
|
|
path := filepath.Join(env.p.RunLogDir(run.ID), step+".log")
|
|
data, err := os.ReadFile(path)
|
|
fields := []toon.Field{
|
|
{Key: "step", Value: step},
|
|
{Key: "run", Value: run.ID},
|
|
}
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
fields = append(fields, toon.Field{Key: "log", Value: fmt.Sprintf("no log recorded for step %q in this run", step)})
|
|
emitDoc(cmd, fields...)
|
|
return fingerprint, nil
|
|
}
|
|
return "", emitError(cmd, 1, fmt.Sprintf("read log: %v", err))
|
|
}
|
|
|
|
lines := splitLogLines(string(data))
|
|
shown := lines
|
|
if !full && len(lines) > logTailLines {
|
|
shown = lines[len(lines)-logTailLines:]
|
|
fields = append(fields,
|
|
toon.Field{Key: "lines", Value: fmt.Sprintf("%d of %d total (tail)", len(shown), len(lines))},
|
|
toon.Field{Key: "log", Value: logRows(shown)},
|
|
toon.Field{Key: "help", Value: []string{fmt.Sprintf("Run `no-mistakes axi logs --step %s --full` to see the entire log", step)}},
|
|
)
|
|
emitDoc(cmd, fields...)
|
|
return fingerprint, nil
|
|
}
|
|
fields = append(fields,
|
|
toon.Field{Key: "lines", Value: fmt.Sprintf("%d total", len(lines))},
|
|
toon.Field{Key: "log", Value: logRows(shown)},
|
|
)
|
|
emitDoc(cmd, fields...)
|
|
return fingerprint, nil
|
|
}
|
|
|
|
// logRows wraps log lines as single-column rows so the encoder renders them as
|
|
// a block array (one line per row) rather than a single inline row.
|
|
func logRows(lines []string) []logRow {
|
|
rows := make([]logRow, len(lines))
|
|
for i, l := range lines {
|
|
rows[i] = logRow{Line: l}
|
|
}
|
|
return rows
|
|
}
|
|
|
|
// resolveRun picks the run to inspect: an explicit ID, else the active run,
|
|
// else the most recent run for the repo. Returns (nil, nil) when none exist.
|
|
func resolveRun(env *axiEnv, runID, branch string) (*db.Run, error) {
|
|
if runID != "" {
|
|
run, err := env.d.GetRun(runID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get run: %w", err)
|
|
}
|
|
return run, nil
|
|
}
|
|
if branch != "" {
|
|
active, err := env.d.GetActiveRun(env.repo.ID, branch)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get active run: %w", err)
|
|
}
|
|
if active != nil {
|
|
return active, nil
|
|
}
|
|
runs, err := env.d.GetRunsByRepo(env.repo.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list runs: %w", err)
|
|
}
|
|
for _, run := range runs {
|
|
if run.Branch == branch {
|
|
return run, nil
|
|
}
|
|
}
|
|
}
|
|
active, err := env.d.GetActiveRun(env.repo.ID, "")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get active run: %w", err)
|
|
}
|
|
if active != nil {
|
|
return active, nil
|
|
}
|
|
runs, err := env.d.GetRunsByRepo(env.repo.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list runs: %w", err)
|
|
}
|
|
if len(runs) == 0 {
|
|
return nil, nil
|
|
}
|
|
return runs[0], nil
|
|
}
|
|
|
|
func currentBranchForRunResolve(ctx context.Context) string {
|
|
branch, err := git.CurrentBranch(ctx, ".")
|
|
if err != nil || branch == "HEAD" {
|
|
return ""
|
|
}
|
|
return branch
|
|
}
|
|
|
|
func splitLogLines(s string) []string {
|
|
s = strings.TrimRight(s, "\n")
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return strings.Split(s, "\n")
|
|
}
|
|
|
|
// parseAddFinding decodes a user-authored finding from a JSON object string.
|
|
func parseAddFinding(raw string) (types.Finding, error) {
|
|
var f types.Finding
|
|
if err := json.Unmarshal([]byte(raw), &f); err != nil {
|
|
return types.Finding{}, err
|
|
}
|
|
if strings.TrimSpace(f.Description) == "" {
|
|
return types.Finding{}, fmt.Errorf("description is required")
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
// progressPrinter emits step and run status transitions to stderr so a human
|
|
// or agent watching the command sees liveness without parsing stdout.
|
|
type progressPrinter struct {
|
|
w io.Writer
|
|
seen map[string]string
|
|
runStatus string
|
|
}
|
|
|
|
func (p *progressPrinter) update(run *ipc.RunInfo) {
|
|
if p.w == nil {
|
|
return
|
|
}
|
|
if string(run.Status) != p.runStatus {
|
|
p.runStatus = string(run.Status)
|
|
fmt.Fprintf(p.w, "run: %s\n", p.runStatus)
|
|
}
|
|
for _, s := range run.Steps {
|
|
name := string(s.StepName)
|
|
status := string(s.Status)
|
|
if status == string(types.StepStatusPending) {
|
|
continue
|
|
}
|
|
if p.seen[name] != status {
|
|
p.seen[name] = status
|
|
fmt.Fprintf(p.w, " %s: %s\n", name, status)
|
|
}
|
|
}
|
|
}
|