From 59e9edc052a0d95dc1cdc12cdd1a4209ba669056 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 19 Mar 2026 03:07:56 +0000 Subject: [PATCH] Canonicalize ago duration formatting --- .../v6/internal/subsystems/ai-runtime.md | 7 +- .../internal/subsystems/unified-resources.md | 6 +- internal/ai/memory/presentation.go | 2 +- internal/ai/memory/presentation_test.go | 9 +++ internal/ai/memory/remediation.go | 2 +- .../unifiedresources/change_presentation.go | 4 +- .../change_presentation_test.go | 79 +++++++++++++------ .../unifiedresources/code_standards_test.go | 4 +- .../relationship_presentation.go | 4 +- internal/utils/duration.go | 9 +++ internal/utils/duration_test.go | 25 ++++++ 11 files changed, 114 insertions(+), 37 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 434dd831a..02e1a8485 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -149,9 +149,10 @@ detectors only serving as fallback coverage when the canonical store is not available. When that patrol-local fallback is used, it must render through the shared memory change presentation helper so the same heading, scope prefix, and change-type labels are reused instead of being rebuilt ad hoc in AI-local code. -That shared presentation layer also owns the elapsed-time wording utility, so -the same "time ago" phrasing stays consistent across resource, incident, and -fallback memory summaries instead of being reformatted independently. +That shared presentation layer also owns the elapsed-time and "ago" wording +utilities, so the same "time ago" phrasing stays consistent across resource, +incident, and fallback memory summaries instead of being reformatted +independently. Resource-only incident context should follow the same rule: if an alert timeline is absent, the incident prompt path should fall back to the canonical unified-resource timeline rather than depending only on patrol-local change diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 40f661ad9..27bc1dc8e 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -128,9 +128,9 @@ The same surfaces now also render recent changes through the shared card, so canonical timeline wording and ordering stay governed by one frontend feed instead of separate page-local loops. The canonical unified-resource change and relationship presenters now also -share the same elapsed-time wording utility, so `observed`, `last seen`, and -`ago` fragments stay consistent without each formatter maintaining its own -"time ago" implementation. +share the same elapsed-time and "ago" wording utilities, so `observed`, +`last seen`, and `ago` fragments stay consistent without each formatter +maintaining its own "time ago" implementation. The change emitter now also classifies canonical restart changes for Docker and Kubernetes resources when restart counters increase or uptime resets, so the timeline can distinguish restarts from generic state transitions instead diff --git a/internal/ai/memory/presentation.go b/internal/ai/memory/presentation.go index 23a662767..a6246f422 100644 --- a/internal/ai/memory/presentation.go +++ b/internal/ai/memory/presentation.go @@ -69,7 +69,7 @@ func FormatRecentChangesContext(changes []Change, includeResourcePrefix bool, he entry = fmt.Sprintf("%s: %s", scope, entry) } ago := time.Since(change.DetectedAt).Truncate(time.Minute) - lines = append(lines, fmt.Sprintf("- %s (%s ago)", entry, utils.FormatDuration(ago))) + lines = append(lines, fmt.Sprintf("- %s (%s)", entry, utils.FormatDurationAgo(ago))) } return strings.Join(lines, "\n") } diff --git a/internal/ai/memory/presentation_test.go b/internal/ai/memory/presentation_test.go index d6b7240b5..71604b5c6 100644 --- a/internal/ai/memory/presentation_test.go +++ b/internal/ai/memory/presentation_test.go @@ -36,6 +36,14 @@ func TestFormatRecentChangesContext(t *testing.T) { now := time.Now() changes := []Change{ + { + ResourceID: "res-0", + ResourceType: "node", + ResourceName: "lb-01", + ChangeType: ChangeCreated, + Description: "came online", + DetectedAt: now.Add(-30 * time.Second), + }, { ResourceID: "res-1", ResourceType: "vm", @@ -56,6 +64,7 @@ func TestFormatRecentChangesContext(t *testing.T) { got := FormatRecentChangesContext(changes, true, "###") for _, want := range []string{ "### Recent Changes Across Infrastructure", + "res-0 (node): **Created** came online (just now)", "res-1 (vm): **Restart** restarted after maintenance (1 hour ago)", "cache-1 (container): **Config update** adjusted memory limit (30 minutes ago)", } { diff --git a/internal/ai/memory/remediation.go b/internal/ai/memory/remediation.go index a8f118f58..731ae7f6d 100644 --- a/internal/ai/memory/remediation.go +++ b/internal/ai/memory/remediation.go @@ -290,7 +290,7 @@ func (r *RemediationLog) FormatForContext(resourceID string, limit int) string { for _, rec := range records { ago := time.Since(rec.Timestamp) outcomeStr := string(rec.Outcome) - result += "- " + utils.FormatDuration(ago) + " ago: " + rec.Problem + "\n" + result += "- " + utils.FormatDurationAgo(ago) + ": " + rec.Problem + "\n" result += " Action: " + rec.Action + " (" + outcomeStr + ")\n" if rec.Note != "" { result += " Note: " + rec.Note + "\n" diff --git a/internal/unifiedresources/change_presentation.go b/internal/unifiedresources/change_presentation.go index 41fa25e1b..7ab4155fe 100644 --- a/internal/unifiedresources/change_presentation.go +++ b/internal/unifiedresources/change_presentation.go @@ -112,9 +112,9 @@ func FormatResourceChangeSummary(change ResourceChange) string { ago := "recently" if !change.ObservedAt.IsZero() { - ago = utils.FormatDuration(time.Since(change.ObservedAt).Truncate(time.Minute)) + ago = utils.FormatDurationAgo(time.Since(change.ObservedAt).Truncate(time.Minute)) } - return summary + fmt.Sprintf(" (%s ago)", ago) + return summary + fmt.Sprintf(" (%s)", ago) } // FormatResourceRecentChangesContext returns the canonical markdown section diff --git a/internal/unifiedresources/change_presentation_test.go b/internal/unifiedresources/change_presentation_test.go index ca5a24782..bf96ce1cd 100644 --- a/internal/unifiedresources/change_presentation_test.go +++ b/internal/unifiedresources/change_presentation_test.go @@ -65,31 +65,64 @@ func TestDescribeChange(t *testing.T) { } func TestFormatResourceChangeSummary(t *testing.T) { - change := ResourceChange{ - Kind: ChangeRestart, - From: "running", - To: "restarting", - SourceType: SourcePlatformEvent, - SourceAdapter: AdapterProxmox, - Actor: "agent:oncall-helper", - Reason: "Routine restart requested", - RelatedResources: []string{"node-1"}, - ObservedAt: time.Now().Add(-2 * time.Hour), + cases := []struct { + name string + change ResourceChange + wants []string + }{ + { + name: "recent", + change: ResourceChange{ + Kind: ChangeRestart, + From: "running", + To: "restarting", + SourceType: SourcePlatformEvent, + SourceAdapter: AdapterProxmox, + Actor: "agent:oncall-helper", + Reason: "Routine restart requested", + RelatedResources: []string{"node-1"}, + ObservedAt: time.Now().Add(-2 * time.Hour), + }, + wants: []string{ + "**Restart**", + "running → restarting", + "platform_event/proxmox_adapter", + "actor agent:oncall-helper", + "Routine restart requested", + "related: node-1", + "2 hours ago", + }, + }, + { + name: "just now", + change: ResourceChange{ + Kind: ChangeConfigUpdate, + From: "old", + To: "new", + SourceType: SourcePulseDiff, + SourceAdapter: AdapterOpsAgent, + ObservedAt: time.Now().Add(-30 * time.Second), + }, + wants: []string{ + "just now", + }, + }, } - summary := FormatResourceChangeSummary(change) - for _, want := range []string{ - "**Restart**", - "running → restarting", - "platform_event/proxmox_adapter", - "actor agent:oncall-helper", - "Routine restart requested", - "related: node-1", - "2 hours ago", - } { - if !strings.Contains(summary, want) { - t.Fatalf("expected summary %q to contain %q", summary, want) - } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + summary := FormatResourceChangeSummary(tc.change) + for _, want := range tc.wants { + if !strings.Contains(summary, want) { + t.Fatalf("expected summary %q to contain %q", summary, want) + } + } + if strings.Contains(summary, "just now ago") { + t.Fatalf("summary should not contain duplicated ago wording: %q", summary) + } + }) } } diff --git a/internal/unifiedresources/code_standards_test.go b/internal/unifiedresources/code_standards_test.go index 8e89800fc..8a72dbd8c 100644 --- a/internal/unifiedresources/code_standards_test.go +++ b/internal/unifiedresources/code_standards_test.go @@ -429,8 +429,8 @@ func TestResourcePresentationsUseSharedDurationHelper(t *testing.T) { if err != nil { t.Fatalf("failed to read %s: %v", name, err) } - if !strings.Contains(string(data), "utils.FormatDuration(") { - t.Fatalf("%s must use the shared utils.FormatDuration helper", name) + if !strings.Contains(string(data), "utils.FormatDurationAgo(") { + t.Fatalf("%s must use the shared utils.FormatDurationAgo helper", name) } } } diff --git a/internal/unifiedresources/relationship_presentation.go b/internal/unifiedresources/relationship_presentation.go index a9865d401..20e267330 100644 --- a/internal/unifiedresources/relationship_presentation.go +++ b/internal/unifiedresources/relationship_presentation.go @@ -98,10 +98,10 @@ func FormatResourceGraphContext(resource *Resource, limit int) string { parts = append(parts, fmt.Sprintf("confidence %s", presentation.Confidence)) } if !rel.ObservedAt.IsZero() { - parts = append(parts, fmt.Sprintf("observed %s ago", utils.FormatDuration(time.Since(rel.ObservedAt).Truncate(time.Minute)))) + parts = append(parts, fmt.Sprintf("observed %s", utils.FormatDurationAgo(time.Since(rel.ObservedAt).Truncate(time.Minute)))) } if !rel.LastSeenAt.IsZero() { - parts = append(parts, fmt.Sprintf("last seen %s ago", utils.FormatDuration(time.Since(rel.LastSeenAt).Truncate(time.Minute)))) + parts = append(parts, fmt.Sprintf("last seen %s", utils.FormatDurationAgo(time.Since(rel.LastSeenAt).Truncate(time.Minute)))) } if presentation.HasMetadata { parts = append(parts, "metadata present") diff --git a/internal/utils/duration.go b/internal/utils/duration.go index ae71540fb..fbb058c16 100644 --- a/internal/utils/duration.go +++ b/internal/utils/duration.go @@ -19,6 +19,15 @@ func FormatDuration(d time.Duration) string { return formatUnit(int(d.Hours()/24), "day") } +// FormatDurationAgo returns a short human-readable elapsed-time string with an "ago" suffix. +func FormatDurationAgo(d time.Duration) string { + formatted := FormatDuration(d) + if formatted == "just now" { + return formatted + } + return formatted + " ago" +} + func formatUnit(n int, unit string) string { if n == 1 { return "1 " + unit diff --git a/internal/utils/duration_test.go b/internal/utils/duration_test.go index 9f5551bd9..0afa35d9a 100644 --- a/internal/utils/duration_test.go +++ b/internal/utils/duration_test.go @@ -29,3 +29,28 @@ func TestFormatDuration(t *testing.T) { }) } } + +func TestFormatDurationAgo(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + d time.Duration + want string + }{ + {name: "just now", d: 30 * time.Second, want: "just now"}, + {name: "minutes", d: 2 * time.Minute, want: "2 minutes ago"}, + {name: "hours", d: 3 * time.Hour, want: "3 hours ago"}, + {name: "days", d: 48 * time.Hour, want: "2 days ago"}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := FormatDurationAgo(tc.d); got != tc.want { + t.Fatalf("FormatDurationAgo(%v) = %q, want %q", tc.d, got, tc.want) + } + }) + } +}