diff --git a/pkg/proboctl/commonthirdparty/list.go b/pkg/proboctl/commonthirdparty/list.go index ded2f73fc..f7da34818 100644 --- a/pkg/proboctl/commonthirdparty/list.go +++ b/pkg/proboctl/commonthirdparty/list.go @@ -149,8 +149,13 @@ func newCmdList(f *cmdutil.Factory) *cobra.Command { return nil } - table := clicmdutil.NewTable("ID", "NAME", "SLUG", "CATEGORY", "STATE", "STATUS", "UPDATED") + table := clicmdutil.NewTable("ID", "NAME", "SLUG", "CATEGORY", "STATE", "STATUS", "LAST ATTEMPT", "UPDATED") for _, p := range parties { + lastAttempt := "" + if p.LastEnrichmentAttemptAt != nil { + lastAttempt = p.LastEnrichmentAttemptAt.Format("2006-01-02 15:04:05") + } + table.Row( p.ID.String(), p.Name, @@ -158,6 +163,7 @@ func newCmdList(f *cmdutil.Factory) *cobra.Command { string(p.Category), enrichmentState(p), enrichmentStatus(p), + lastAttempt, p.UpdatedAt.Format("2006-01-02 15:04:05"), ) } diff --git a/pkg/proboctl/commontrackerpattern/enrichment_state_test.go b/pkg/proboctl/commontrackerpattern/enrichment_state_test.go new file mode 100644 index 000000000..6b2203420 --- /dev/null +++ b/pkg/proboctl/commontrackerpattern/enrichment_state_test.go @@ -0,0 +1,83 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package commontrackerpattern + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.probo.inc/probo/pkg/coredata" +) + +func TestEnrichmentState(t *testing.T) { + t.Parallel() + + requestedAt := time.Now() + + tests := []struct { + name string + pattern coredata.CommonTrackerPattern + expected string + }{ + { + name: "no payload and not queued is unenriched", + pattern: coredata.CommonTrackerPattern{}, + expected: "unenriched", + }, + { + name: "queued takes precedence", + pattern: coredata.CommonTrackerPattern{EnrichmentRequestedAt: &requestedAt}, + expected: "queued", + }, + { + name: "payload without fields reads enriched", + pattern: coredata.CommonTrackerPattern{Enrichment: json.RawMessage(`{"status":"migrated"}`)}, + expected: "enriched", + }, + { + name: "all fields resolved reads enriched", + pattern: coredata.CommonTrackerPattern{ + Enrichment: json.RawMessage(`{"status":"done","fields":{"description":{"status":"found"},"third_party":{"status":"exists_external"}}}`), + }, + expected: "enriched", + }, + { + name: "some fields resolved reads partial", + pattern: coredata.CommonTrackerPattern{ + Enrichment: json.RawMessage(`{"status":"partial","fields":{"description":{"status":"found"},"third_party":{"status":"not_found"}}}`), + }, + expected: "partial (1/2)", + }, + { + name: "no fields resolved reads partial zero", + pattern: coredata.CommonTrackerPattern{ + Enrichment: json.RawMessage(`{"status":"no_result","fields":{"description":{"status":"not_found"},"third_party":{"status":"not_found"}}}`), + }, + expected: "partial (0/2)", + }, + } + + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expected, enrichmentState(&tt.pattern)) + }, + ) + } +} diff --git a/pkg/proboctl/commontrackerpattern/list.go b/pkg/proboctl/commontrackerpattern/list.go index 42a961ff6..116fcdff9 100644 --- a/pkg/proboctl/commontrackerpattern/list.go +++ b/pkg/proboctl/commontrackerpattern/list.go @@ -229,7 +229,7 @@ func renderPatternTable(cmd *cobra.Command, f *cmdutil.Factory, patterns coredat return err } - table := clicmdutil.NewTable("ID", "TYPE", "MATCH", "PATTERN", "CONF", "STATE", "THIRD PARTY", "CREATED", "UPDATED") + table := clicmdutil.NewTable("ID", "TYPE", "MATCH", "PATTERN", "CONF", "STATE", "THIRD PARTY", "LAST ATTEMPT", "CREATED", "UPDATED") for _, p := range patterns { thirdParty := "" @@ -237,6 +237,11 @@ func renderPatternTable(cmd *cobra.Command, f *cmdutil.Factory, patterns coredat thirdParty = names[*p.CommonThirdPartyID] } + lastAttempt := "" + if p.LastEnrichmentAttemptAt != nil { + lastAttempt = p.LastEnrichmentAttemptAt.Format("2006-01-02 15:04:05") + } + table.Row( p.ID.String(), string(p.TrackerType), @@ -245,6 +250,7 @@ func renderPatternTable(cmd *cobra.Command, f *cmdutil.Factory, patterns coredat fmt.Sprintf("%.2f", p.Confidence), enrichmentState(p), thirdParty, + lastAttempt, p.CreatedAt.Format("2006-01-02 15:04:05"), p.UpdatedAt.Format("2006-01-02 15:04:05"), ) diff --git a/pkg/proboctl/commontrackerpattern/show.go b/pkg/proboctl/commontrackerpattern/show.go index 78ba80122..d2d189019 100644 --- a/pkg/proboctl/commontrackerpattern/show.go +++ b/pkg/proboctl/commontrackerpattern/show.go @@ -16,8 +16,12 @@ package commontrackerpattern import ( "context" + "encoding/json" "errors" "fmt" + "io" + "sort" + "time" "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" @@ -28,6 +32,31 @@ import ( "go.probo.inc/probo/pkg/proboctl/cmdutil" ) +// patternEnrichmentMetadataView mirrors the subset of the common tracker +// pattern enrichment payload (written by the enrichment worker) that show +// renders. It is decoded locally to avoid a dependency on the cookiebanner +// package. +type patternEnrichmentMetadataView struct { + Model string `json:"model"` + AttemptedAt time.Time `json:"attempted_at"` + Status string `json:"status"` + Error string `json:"error"` + Fields map[string]patternEnrichmentFieldView `json:"fields"` + Attribution *patternEnrichmentAttributionView `json:"attribution"` +} + +type patternEnrichmentFieldView struct { + Status string `json:"status"` + UpdatedAt time.Time `json:"updated_at"` +} + +type patternEnrichmentAttributionView struct { + ThirdPartyName string `json:"third_party_name"` + Category string `json:"category"` + Confidence float64 `json:"confidence"` + Linked bool `json:"linked"` +} + func newCmdShow(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "show ", @@ -140,5 +169,81 @@ func renderPatternDetail(f *cmdutil.Factory, p coredata.CommonTrackerPattern, th row("Created:", p.CreatedAt.Format("2006-01-02 15:04:05")) row("Updated:", p.UpdatedAt.Format("2006-01-02 15:04:05")) + printPatternEnrichmentDetails(out, label, p) + return nil } + +// printPatternEnrichmentDetails renders the run-level status (done, +// partial, no_result), the agent attribution, and the per-field +// provenance recorded in the enrichment payload, when present. +func printPatternEnrichmentDetails(out io.Writer, label lipgloss.Style, p coredata.CommonTrackerPattern) { + if len(p.Enrichment) == 0 { + return + } + + var meta patternEnrichmentMetadataView + if err := json.Unmarshal(p.Enrichment, &meta); err != nil { + return + } + + row := func(name, value string) { + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render(name), value) + } + + if meta.Status != "" { + row("Last run status:", meta.Status) + } + + if !meta.AttemptedAt.IsZero() { + row("Last run recorded:", meta.AttemptedAt.Format("2006-01-02 15:04:05")) + } + + if meta.Model != "" { + row("Enrichment model:", meta.Model) + } + + if meta.Error != "" { + row("Last error:", meta.Error) + } + + if meta.Attribution != nil { + name := meta.Attribution.ThirdPartyName + if name == "" { + name = "(none)" + } + + linked := "no" + if meta.Attribution.Linked { + linked = "yes" + } + + row("Agent attribution:", fmt.Sprintf("%s [%s] conf %.2f linked=%s", name, meta.Attribution.Category, meta.Attribution.Confidence, linked)) + } + + if len(meta.Fields) > 0 { + names := make([]string, 0, len(meta.Fields)) + for name := range meta.Fields { + names = append(names, name) + } + + sort.Strings(names) + + _, _ = fmt.Fprintln(out) + + table := clicmdutil.NewTable("FIELD", "STATUS", "UPDATED") + + for _, name := range names { + fm := meta.Fields[name] + + updated := "" + if !fm.UpdatedAt.IsZero() { + updated = fm.UpdatedAt.Format("2006-01-02 15:04:05") + } + + table.Row(name, fm.Status, updated) + } + + _, _ = fmt.Fprintln(out, table.Render()) + } +}