Canonicalize ago duration formatting

This commit is contained in:
rcourtman 2026-03-19 03:07:56 +00:00
parent 409c2605c2
commit 59e9edc052
11 changed files with 114 additions and 37 deletions

View file

@ -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

View file

@ -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

View file

@ -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")
}

View file

@ -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)",
} {

View file

@ -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"

View file

@ -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

View file

@ -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)
}
})
}
}

View file

@ -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)
}
}
}

View file

@ -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")

View file

@ -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

View file

@ -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)
}
})
}
}