f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
628 lines
20 KiB
Go
628 lines
20 KiB
Go
// ABOUTME: `agentsview parse-diff` — report-only re-parse of session
|
|
// ABOUTME: source files diffed against the stored SQLite archive.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"text/tabwriter"
|
|
|
|
"github.com/spf13/cobra"
|
|
"go.kenn.io/agentsview/internal/config"
|
|
"go.kenn.io/agentsview/internal/parser"
|
|
"go.kenn.io/agentsview/internal/sync"
|
|
)
|
|
|
|
// parseDiffChangedCap caps the non-verbose changed-sessions
|
|
// drill-down so a badly drifted archive doesn't flood the terminal.
|
|
const parseDiffChangedCap = 20
|
|
|
|
// ParseDiffConfig holds parsed CLI options for the parse-diff command.
|
|
type ParseDiffConfig struct {
|
|
Agents []string
|
|
Limit int
|
|
FailOnChange bool
|
|
JSON bool
|
|
Verbose bool
|
|
|
|
// Stdout and Stderr default to the process streams. The command
|
|
// wires them to cobra's writers so tests can capture output.
|
|
Stdout io.Writer
|
|
Stderr io.Writer
|
|
}
|
|
|
|
func (c ParseDiffConfig) stdout() io.Writer {
|
|
if c.Stdout != nil {
|
|
return c.Stdout
|
|
}
|
|
return os.Stdout
|
|
}
|
|
|
|
func (c ParseDiffConfig) stderr() io.Writer {
|
|
if c.Stderr != nil {
|
|
return c.Stderr
|
|
}
|
|
return os.Stderr
|
|
}
|
|
|
|
func newParseDiffCommand() *cobra.Command {
|
|
var cfg ParseDiffConfig
|
|
cmd := &cobra.Command{
|
|
Use: "parse-diff",
|
|
Short: "Re-parse session files and diff against the archive",
|
|
Long: "Re-parses session source files with the current binary, runs\n" +
|
|
"the result through the same normalization sync applies, and\n" +
|
|
"compares it against the stored rows. It does not write\n" +
|
|
"session, skip-cache, or sync-state data; opening the archive\n" +
|
|
"still creates the database and runs schema migrations if\n" +
|
|
"needed.\n\n" +
|
|
"Use it to vet parser changes against the real archive before\n" +
|
|
"bumping the data version, or to detect upstream format drift.\n" +
|
|
"Run it against a quiescent archive: sessions still being\n" +
|
|
"written can show benign drift against a full re-parse and are\n" +
|
|
"classified as raced. Sessions last written through the\n" +
|
|
"incremental-append path are detected and classified as\n" +
|
|
"incremental_skew (both excluded from --fail-on-change); a\n" +
|
|
"freshly resynced archive still gives the cleanest baseline.",
|
|
GroupID: groupData,
|
|
SilenceUsage: true,
|
|
Args: cobra.NoArgs,
|
|
PreRunE: func(cmd *cobra.Command, _ []string) error {
|
|
if cfg.Limit < 0 {
|
|
return fmt.Errorf("--limit must be >= 0")
|
|
}
|
|
_, err := parseDiffAgentTypes(cfg.Agents)
|
|
return err
|
|
},
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
cfg.JSON = outputFormat(cmd) == "json"
|
|
cfg.Stdout = cmd.OutOrStdout()
|
|
cfg.Stderr = cmd.ErrOrStderr()
|
|
runParseDiff(cfg)
|
|
},
|
|
}
|
|
cmd.Flags().StringArrayVar(&cfg.Agents, "agent", nil,
|
|
"Restrict to these agents (repeatable; default: all re-parseable agents)")
|
|
cmd.Flags().IntVar(&cfg.Limit, "limit", 0,
|
|
"Re-parse only the N most recently modified source files (0 = all)")
|
|
cmd.Flags().BoolVar(&cfg.FailOnChange, "fail-on-change", false,
|
|
"Exit 1 when sessions changed or files failed to parse")
|
|
registerFormatFlags(cmd.Flags())
|
|
cmd.Flags().BoolVarP(&cfg.Verbose, "verbose", "v", false,
|
|
"Show every field diff for every changed session")
|
|
return cmd
|
|
}
|
|
|
|
func runParseDiff(cfg ParseDiffConfig) {
|
|
if doParseDiff(cfg) {
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// doParseDiff runs the report-only comparison and reports whether
|
|
// --fail-on-change should turn the run into a non-zero exit. It owns
|
|
// the deferred db close so runParseDiff can translate the result into
|
|
// an exit code without skipping cleanup. It deliberately skips
|
|
// setupLogFile: stdout owns the report and engine warnings belong on
|
|
// stderr, matching the health command's diagnostic style.
|
|
func doParseDiff(cfg ParseDiffConfig) (failed bool) {
|
|
agents, err := parseDiffAgentTypes(cfg.Agents)
|
|
if err != nil {
|
|
fatal("%v", err)
|
|
}
|
|
|
|
appCfg, err := config.LoadMinimal()
|
|
if err != nil {
|
|
fatal("loading config: %v", err)
|
|
}
|
|
if err := os.MkdirAll(appCfg.DataDir, 0o755); err != nil {
|
|
fatal("creating data dir: %v", err)
|
|
}
|
|
|
|
database, writeLock := mustOpenWriteDB(context.Background(), appCfg)
|
|
defer closeWriteDB(database, writeLock)
|
|
|
|
engine := sync.NewDiffEngine(database, sync.EngineConfig{
|
|
AgentDirs: appCfg.AgentDirs,
|
|
IncludeCwdPrefixes: appCfg.SyncIncludeCwdPrefixes,
|
|
Machine: appCfg.LocalMachineName,
|
|
BlockedResultCategories: appCfg.ResultContentBlockedCategories,
|
|
})
|
|
|
|
opts := sync.ParseDiffOptions{Agents: agents, Limit: cfg.Limit}
|
|
if !cfg.JSON && isTerminalWriter(cfg.stderr()) {
|
|
opts.Progress = parseDiffProgress(cfg.stderr())
|
|
}
|
|
|
|
report, err := engine.ParseDiff(context.Background(), opts)
|
|
if err != nil {
|
|
fatal("parse-diff: %v", err)
|
|
}
|
|
// Stamp the archive identity so an attached JSON report is
|
|
// self-describing.
|
|
report.DBPath = appCfg.DBPath
|
|
|
|
if cfg.JSON {
|
|
writeJSON(cfg.stdout(), report)
|
|
} else {
|
|
// The header says "all agents" only when the user did not
|
|
// restrict the run; report.Agents always carries the full
|
|
// resolved list.
|
|
agentsLabel := "all agents"
|
|
if len(agents) > 0 {
|
|
agentsLabel = strings.Join(report.Agents, ", ")
|
|
}
|
|
renderParseDiffReport(
|
|
cfg.stdout(), report, appCfg.DBPath, agentsLabel, cfg.Verbose,
|
|
)
|
|
}
|
|
return parseDiffExitFailure(report, cfg.FailOnChange, cfg.stderr())
|
|
}
|
|
|
|
// parseDiffExitFailure decides whether --fail-on-change should turn the
|
|
// run into a non-zero exit and writes the vacuous-run explanation to
|
|
// stderr when that is the reason. It is split from doParseDiff's I/O so
|
|
// the exit contract is unit-testable, mirroring ParseDiffReport's own
|
|
// HasFailures/VacuousResync helpers.
|
|
//
|
|
// A vacuous run -- this binary's data version is ahead of every
|
|
// examined session, so all of them are pending_resync -- detects no
|
|
// drift by construction (a resync rewrites every row), so a clean
|
|
// result is not evidence the parser is unchanged. Treat it as a gate
|
|
// failure rather than a green light, and explain the non-zero exit on
|
|
// stderr: the stdout warning is absent under --json and easy to miss in
|
|
// CI logs.
|
|
func parseDiffExitFailure(
|
|
report *sync.ParseDiffReport, failOnChange bool, stderr io.Writer,
|
|
) bool {
|
|
if !failOnChange {
|
|
return false
|
|
}
|
|
vacuous := report.VacuousResync()
|
|
if vacuous {
|
|
fmt.Fprintln(stderr,
|
|
"parse-diff: --fail-on-change failed: the run was vacuous "+
|
|
"(every examined session is pending resync because this "+
|
|
"binary's data version is ahead of the whole archive), so "+
|
|
"no parser drift could be detected. Re-run against a freshly "+
|
|
"resynced archive, or with a binary built before the "+
|
|
"data-version bump, to vet the change.")
|
|
}
|
|
return report.HasFailures() || vacuous
|
|
}
|
|
|
|
// parseDiffProgress returns a simple stderr counter for
|
|
// ParseDiffOptions.Progress. It rewrites one line in place and
|
|
// terminates it once the last file completes.
|
|
func parseDiffProgress(w io.Writer) func(done, total int) {
|
|
return func(done, total int) {
|
|
fmt.Fprintf(w, "\rRe-parsing files: %d/%d", done, total)
|
|
if done >= total {
|
|
fmt.Fprintln(w)
|
|
}
|
|
}
|
|
}
|
|
|
|
// isTerminalWriter reports whether w is an interactive terminal, so
|
|
// the carriage-return progress ticker never spams piped output or CI
|
|
// logs.
|
|
func isTerminalWriter(w io.Writer) bool {
|
|
f, ok := w.(*os.File)
|
|
if !ok {
|
|
return false
|
|
}
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return info.Mode()&os.ModeCharDevice != 0
|
|
}
|
|
|
|
// parseDiffAgentTypes validates --agent values against the parser
|
|
// registry and returns the corresponding agent types, de-duplicated
|
|
// in flag order. An empty input means "every supported agent" and
|
|
// returns nil.
|
|
func parseDiffAgentTypes(names []string) ([]parser.AgentType, error) {
|
|
if len(names) == 0 {
|
|
return nil, nil
|
|
}
|
|
out := make([]parser.AgentType, 0, len(names))
|
|
seen := make(map[parser.AgentType]bool, len(names))
|
|
for _, raw := range names {
|
|
name := strings.ToLower(strings.TrimSpace(raw))
|
|
def, ok := parser.AgentByType(parser.AgentType(name))
|
|
if !ok {
|
|
return nil, fmt.Errorf(
|
|
"unknown agent %q (supported: %s)",
|
|
raw,
|
|
strings.Join(parseDiffSupportedAgents(), ", "),
|
|
)
|
|
}
|
|
if !parseDiffAgentSupported(def) {
|
|
return nil, fmt.Errorf(
|
|
"agent %q is not supported by parse-diff",
|
|
raw,
|
|
)
|
|
}
|
|
if !seen[def.Type] {
|
|
seen[def.Type] = true
|
|
out = append(out, def.Type)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// parseDiffSupportedAgents lists the agent types parse-diff can re-parse.
|
|
func parseDiffSupportedAgents() []string {
|
|
var names []string
|
|
for _, def := range parser.Registry {
|
|
if parseDiffAgentSupported(def) {
|
|
names = append(names, string(def.Type))
|
|
}
|
|
}
|
|
return names
|
|
}
|
|
|
|
func parseDiffAgentSupported(def parser.AgentDef) bool {
|
|
// A provider-authoritative agent with a registered factory has a
|
|
// Discover()/Parse() parse-diff can re-parse, whether it reads literal
|
|
// per-session files or a shared SQLite store it fans out per session
|
|
// (Kiro, OpenCode, Forge, Devin, Piebald, Warp). FileBased is not
|
|
// consulted: import-only agents are already excluded because they are
|
|
// not provider-authoritative.
|
|
switch parser.ProviderMigrationModes()[def.Type] {
|
|
case parser.ProviderMigrationProviderAuthoritative:
|
|
_, ok := parser.ProviderFactoryByType(def.Type)
|
|
return ok
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// renderParseDiffReport writes the human-readable report. An empty
|
|
// archive renders a zero-count summary with no tables. Every value
|
|
// that originates in session files or archive rows (IDs, paths,
|
|
// agents, field values, error reasons) passes through
|
|
// sanitizeTerminal: session content can carry ESC/OSC sequences that
|
|
// would otherwise reach the terminal. JSON output is left raw.
|
|
func renderParseDiffReport(
|
|
w io.Writer, r *sync.ParseDiffReport, dbPath, agentsLabel string,
|
|
verbose bool,
|
|
) {
|
|
fmt.Fprintf(w,
|
|
"Parse diff: %d files re-parsed (%s) against %s (data version %d)\n",
|
|
r.FilesExamined, sanitizeTerminal(agentsLabel),
|
|
sanitizeTerminal(dbPath), r.DataVersion)
|
|
if r.FilesLimited {
|
|
fmt.Fprintln(w,
|
|
"Note: --limit truncated discovery; totals cover a sample.")
|
|
}
|
|
fmt.Fprintln(w)
|
|
|
|
if r.VacuousResync() {
|
|
fmt.Fprintln(w,
|
|
"Warning: this binary's data version is ahead of every "+
|
|
"examined session, so all of them are pending resync "+
|
|
"and no drift can be detected. Run parse-diff with a "+
|
|
"binary built before the data-version bump, or against "+
|
|
"a freshly resynced archive, to vet a parser change.")
|
|
fmt.Fprintln(w)
|
|
}
|
|
|
|
renderParseDiffSummary(w, r.Totals)
|
|
renderParseDiffFieldCounts(w, r.FieldCounts)
|
|
renderParseDiffParseErrors(w, r.Sessions)
|
|
renderParseDiffChanged(w, r.Sessions, verbose)
|
|
renderParseDiffRaced(w, r.Sessions, verbose)
|
|
renderParseDiffIncrementalSkew(w, r.Sessions, verbose)
|
|
if verbose {
|
|
renderParseDiffPendingResync(w, r.Sessions)
|
|
}
|
|
|
|
// Incremental-skew sessions are suppressed from --fail-on-change but
|
|
// still signal that the comparison basis is compromised for those
|
|
// rows. A full resync rewrites them through normalization, clears the
|
|
// marker, and restores full diff scrutiny -- so recommend it, mirroring
|
|
// the vacuous-resync warning above. Totals.IncrementalSkew is already
|
|
// in the JSON, so no new bool method is needed to gate the note.
|
|
if r.Totals.IncrementalSkew > 0 {
|
|
fmt.Fprintf(w,
|
|
"Note: %d session(s) were last written through the "+
|
|
"incremental-append path, so a full re-parse legitimately "+
|
|
"differs from the stored rows. They are classified "+
|
|
"incremental_skew and excluded from --fail-on-change. Run a "+
|
|
"full resync for a clean parse-diff baseline that restores "+
|
|
"drift detection on these sessions.\n\n",
|
|
r.Totals.IncrementalSkew)
|
|
}
|
|
|
|
fmt.Fprintf(w, "%d sessions changed, %d identical.\n",
|
|
r.Totals.Changed, r.Totals.Identical)
|
|
}
|
|
|
|
// renderParseDiffPendingResync lists pending-resync sessions that
|
|
// carry attached field diffs (verbose only). These are not counted as
|
|
// drift — a resync rewrites them — but showing the diffs lets an
|
|
// operator see what would change, which is the only signal available
|
|
// when the whole run is vacuous (data version ahead of the archive).
|
|
func renderParseDiffPendingResync(w io.Writer, sessions []sync.SessionDiff) {
|
|
var pending []sync.SessionDiff
|
|
for _, s := range sessions {
|
|
if s.Class == sync.DiffPendingResync && len(s.Fields) > 0 {
|
|
pending = append(pending, s)
|
|
}
|
|
}
|
|
if len(pending) == 0 {
|
|
return
|
|
}
|
|
fmt.Fprintln(w,
|
|
"Pending-resync sessions (not counted; resync rewrites these)")
|
|
for _, s := range pending {
|
|
renderParseDiffSessionVerbose(w, s)
|
|
}
|
|
fmt.Fprintln(w)
|
|
}
|
|
|
|
// renderParseDiffParseErrors lists files the current binary could not
|
|
// parse, with the path and error. Parse errors trip --fail-on-change,
|
|
// so the human report must explain why a run failed; the summary count
|
|
// alone is not actionable.
|
|
func renderParseDiffParseErrors(w io.Writer, sessions []sync.SessionDiff) {
|
|
var errs []sync.SessionDiff
|
|
for _, s := range sessions {
|
|
if s.Class == sync.DiffParseError {
|
|
errs = append(errs, s)
|
|
}
|
|
}
|
|
if len(errs) == 0 {
|
|
return
|
|
}
|
|
fmt.Fprintln(w, "Parse errors")
|
|
for _, s := range errs {
|
|
path := s.FilePath
|
|
if path == "" {
|
|
path = "(unknown file)"
|
|
}
|
|
fmt.Fprintf(w, " %s %s\n %s\n",
|
|
sanitizeTerminal(s.Agent), sanitizeTerminal(path),
|
|
sanitizeTerminal(s.Reason))
|
|
}
|
|
fmt.Fprintln(w)
|
|
}
|
|
|
|
// renderParseDiffSummary prints one line per non-zero total, with
|
|
// short explanations for the non-obvious buckets. Examined always
|
|
// prints so an empty archive still renders a summary.
|
|
func renderParseDiffSummary(w io.Writer, t sync.ParseDiffTotals) {
|
|
fmt.Fprintln(w, "Summary")
|
|
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
|
line := func(name string, n int, note string) {
|
|
if n == 0 && name != "Examined" {
|
|
return
|
|
}
|
|
fmt.Fprintf(tw, " %s\t%d\t%s\n", name, n, note)
|
|
}
|
|
line("Examined", t.Examined,
|
|
"(stored sessions compared against a fresh parse)")
|
|
line("Identical", t.Identical, "")
|
|
line("Changed", t.Changed, "")
|
|
line("Pending resync", t.PendingResync,
|
|
"(stored data version behind; next resync rewrites these)")
|
|
line("New on disk", t.NewOnDisk,
|
|
"(no stored row; sync would add them)")
|
|
line("Skipped", t.Skipped,
|
|
"(source not re-parsed: missing, remote, trashed, or not sampled)")
|
|
line("Raced", t.Raced,
|
|
"(source changed mid-run; inconclusive, not counted as drift)")
|
|
line("Incremental skew", t.IncrementalSkew,
|
|
"(last written incrementally; full re-parse differs, not counted as drift)")
|
|
line("Excluded by parser", t.ExcludedByParser,
|
|
"(parser intentionally drops these; sync would delete them)")
|
|
line("Parse errors", t.ParseErrors,
|
|
"(current binary failed to parse the source file)")
|
|
line("Needs retry", t.NeedsRetry,
|
|
"(transient low-fidelity parse; differences expected)")
|
|
line("Informational only", t.InformationalOnly,
|
|
"(identical except informational diffs)")
|
|
tw.Flush()
|
|
fmt.Fprintln(w)
|
|
}
|
|
|
|
// renderParseDiffFieldCounts prints the changed-field histogram,
|
|
// sorted by count descending with alphabetical tie-breaks.
|
|
func renderParseDiffFieldCounts(w io.Writer, counts map[string]int) {
|
|
if len(counts) == 0 {
|
|
return
|
|
}
|
|
fmt.Fprintln(w, "Changed fields (sessions affected)")
|
|
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
|
for _, e := range sortedIntMap(counts) {
|
|
fmt.Fprintf(tw, " %s\t%d\n", sanitizeTerminal(e.key), e.val)
|
|
}
|
|
tw.Flush()
|
|
fmt.Fprintln(w)
|
|
}
|
|
|
|
// renderParseDiffChanged prints the changed-sessions drill-down:
|
|
// one compact line per session, capped unless verbose; verbose
|
|
// prints a block per session with every field diff.
|
|
func renderParseDiffChanged(
|
|
w io.Writer, sessions []sync.SessionDiff, verbose bool,
|
|
) {
|
|
var changed []sync.SessionDiff
|
|
for _, s := range sessions {
|
|
if s.Class == sync.DiffChanged {
|
|
changed = append(changed, s)
|
|
}
|
|
}
|
|
if len(changed) == 0 {
|
|
return
|
|
}
|
|
fmt.Fprintln(w, "Changed sessions")
|
|
if verbose {
|
|
for _, s := range changed {
|
|
renderParseDiffSessionVerbose(w, s)
|
|
}
|
|
fmt.Fprintln(w)
|
|
return
|
|
}
|
|
shown := changed
|
|
if len(shown) > parseDiffChangedCap {
|
|
shown = shown[:parseDiffChangedCap]
|
|
}
|
|
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
|
for _, s := range shown {
|
|
fmt.Fprintf(tw, " %s\t%s\t%s\n",
|
|
sanitizeTerminal(s.Agent),
|
|
sanitizeTerminal(shortID(s.SessionID)),
|
|
sanitizeTerminal(parseDiffFieldSummary(s.Fields)))
|
|
}
|
|
tw.Flush()
|
|
if extra := len(changed) - len(shown); extra > 0 {
|
|
fmt.Fprintf(w, " ... (%d more; use --verbose or --json)\n",
|
|
extra)
|
|
}
|
|
fmt.Fprintln(w)
|
|
}
|
|
|
|
// renderParseDiffRaced lists sessions whose would-be change was
|
|
// reclassified as a live-write skew: the on-disk source advanced past
|
|
// the snapshot mtime, so the change is a torn comparison rather than
|
|
// parser drift. These never trip --fail-on-change, but surfacing them
|
|
// tells the operator a comparison was inconclusive and the run should
|
|
// be repeated against a quiescent archive. Compact by default; verbose
|
|
// shows the masked field diffs.
|
|
func renderParseDiffRaced(
|
|
w io.Writer, sessions []sync.SessionDiff, verbose bool,
|
|
) {
|
|
var raced []sync.SessionDiff
|
|
for _, s := range sessions {
|
|
if s.Class == sync.DiffRaced {
|
|
raced = append(raced, s)
|
|
}
|
|
}
|
|
if len(raced) == 0 {
|
|
return
|
|
}
|
|
fmt.Fprintln(w,
|
|
"Raced sessions (source changed mid-run; not counted as drift)")
|
|
if verbose {
|
|
for _, s := range raced {
|
|
renderParseDiffSessionVerbose(w, s)
|
|
}
|
|
fmt.Fprintln(w)
|
|
return
|
|
}
|
|
shown := raced
|
|
if len(shown) > parseDiffChangedCap {
|
|
shown = shown[:parseDiffChangedCap]
|
|
}
|
|
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
|
for _, s := range shown {
|
|
fmt.Fprintf(tw, " %s\t%s\t%s\n",
|
|
sanitizeTerminal(s.Agent),
|
|
sanitizeTerminal(shortID(s.SessionID)),
|
|
sanitizeTerminal(parseDiffFieldSummary(s.Fields)))
|
|
}
|
|
tw.Flush()
|
|
if extra := len(raced) - len(shown); extra > 0 {
|
|
fmt.Fprintf(w, " ... (%d more; use --verbose or --json)\n",
|
|
extra)
|
|
}
|
|
fmt.Fprintln(w)
|
|
}
|
|
|
|
// renderParseDiffIncrementalSkew lists sessions whose would-be change
|
|
// was reclassified as incremental-append skew: the stored row was last
|
|
// written through the incremental-append path, so a full re-parse
|
|
// legitimately differs. These never trip --fail-on-change, but surfacing
|
|
// them tells the operator the comparison basis is compromised for those
|
|
// rows and a full resync gives a clean baseline. Compact by default;
|
|
// verbose shows the masked field diffs.
|
|
func renderParseDiffIncrementalSkew(
|
|
w io.Writer, sessions []sync.SessionDiff, verbose bool,
|
|
) {
|
|
var skew []sync.SessionDiff
|
|
for _, s := range sessions {
|
|
if s.Class == sync.DiffIncrementalSkew {
|
|
skew = append(skew, s)
|
|
}
|
|
}
|
|
if len(skew) == 0 {
|
|
return
|
|
}
|
|
fmt.Fprintln(w,
|
|
"Incremental-skew sessions "+
|
|
"(last written incrementally; not counted as drift)")
|
|
if verbose {
|
|
for _, s := range skew {
|
|
renderParseDiffSessionVerbose(w, s)
|
|
}
|
|
fmt.Fprintln(w)
|
|
return
|
|
}
|
|
shown := skew
|
|
if len(shown) > parseDiffChangedCap {
|
|
shown = shown[:parseDiffChangedCap]
|
|
}
|
|
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
|
for _, s := range shown {
|
|
fmt.Fprintf(tw, " %s\t%s\t%s\n",
|
|
sanitizeTerminal(s.Agent),
|
|
sanitizeTerminal(shortID(s.SessionID)),
|
|
sanitizeTerminal(parseDiffFieldSummary(s.Fields)))
|
|
}
|
|
tw.Flush()
|
|
if extra := len(skew) - len(shown); extra > 0 {
|
|
fmt.Fprintf(w, " ... (%d more; use --verbose or --json)\n",
|
|
extra)
|
|
}
|
|
fmt.Fprintln(w)
|
|
}
|
|
|
|
// renderParseDiffSessionVerbose prints one changed session with
|
|
// every field diff: Field, Stored -> Parsed, Detail, and an
|
|
// [informational] tag where applicable.
|
|
func renderParseDiffSessionVerbose(w io.Writer, s sync.SessionDiff) {
|
|
fmt.Fprintf(w, " %s %s",
|
|
sanitizeTerminal(s.Agent), sanitizeTerminal(s.SessionID))
|
|
if s.FilePath != "" {
|
|
fmt.Fprintf(w, " %s", sanitizeTerminal(s.FilePath))
|
|
}
|
|
fmt.Fprintln(w)
|
|
for _, f := range s.Fields {
|
|
fmt.Fprintf(w, " %s: %s -> %s",
|
|
sanitizeTerminal(f.Field),
|
|
sanitizeTerminal(f.Stored), sanitizeTerminal(f.Parsed))
|
|
if f.Detail != "" {
|
|
fmt.Fprintf(w, " (%s)", sanitizeTerminal(f.Detail))
|
|
}
|
|
if f.Informational {
|
|
fmt.Fprint(w, " [informational]")
|
|
}
|
|
fmt.Fprintln(w)
|
|
}
|
|
}
|
|
|
|
// parseDiffFieldSummary renders the compact field list for one
|
|
// changed-session line: the non-informational field names in diff
|
|
// order. If every diff is informational (defensive; such sessions
|
|
// are classified identical), the names are tagged instead.
|
|
func parseDiffFieldSummary(fields []sync.FieldDiff) string {
|
|
names := make([]string, 0, len(fields))
|
|
for _, f := range fields {
|
|
if !f.Informational {
|
|
names = append(names, f.Field)
|
|
}
|
|
}
|
|
if len(names) == 0 {
|
|
for _, f := range fields {
|
|
names = append(names, f.Field+" [informational]")
|
|
}
|
|
}
|
|
return strings.Join(names, ", ")
|
|
}
|