mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 18:45:53 +00:00
365 lines
14 KiB
Go
365 lines
14 KiB
Go
package unifiedresources
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// LoopReportType is the discriminator that names the loop a report came
|
|
// from. Only one loop is implemented for the MVP — maintenance window
|
|
// verification — but the field stays in the contract so future loops can
|
|
// reuse the same persistence and review surfaces without a schema split.
|
|
type LoopReportType string
|
|
|
|
const (
|
|
// LoopReportTypeMaintenanceVerification is the durable summary the
|
|
// sentinel writes after a maintenance window ends for a resource. The
|
|
// product-facing name is "Maintenance Verification Report"; this is
|
|
// the internal type token used for storage and API filtering.
|
|
LoopReportTypeMaintenanceVerification LoopReportType = "maintenance_verification"
|
|
)
|
|
|
|
const (
|
|
MaintenanceVerificationActivityReported = "maintenance_verification_reported"
|
|
|
|
maintenanceVerificationSourceAdapter ChangeSourceAdapter = "maintenance_sentinel"
|
|
)
|
|
|
|
// LoopReportStatus reports the verification outcome.
|
|
//
|
|
// - healthy — all deterministic checks passed.
|
|
// - needs_review — evidence is ambiguous; an operator should
|
|
// look. Used when there is no metric history to compare, when the
|
|
// resource is operator-marked intentionally offline, or when a
|
|
// warning-level alert/finding is active.
|
|
// - failed_verification — at least one strong negative signal
|
|
// (critical alert, critical finding, recent failed action) is
|
|
// present. The report does not auto-remediate; the operator
|
|
// decides next steps.
|
|
//
|
|
// The set is intentionally small. Adding more states is a contract
|
|
// change because the UI and review action depend on the enum.
|
|
type LoopReportStatus string
|
|
|
|
const (
|
|
LoopReportStatusPending LoopReportStatus = "pending"
|
|
LoopReportStatusHealthy LoopReportStatus = "healthy"
|
|
LoopReportStatusNeedsReview LoopReportStatus = "needs_review"
|
|
LoopReportStatusFailedVerification LoopReportStatus = "failed_verification"
|
|
)
|
|
|
|
// LoopReportUserOutcome records the operator's review verdict after
|
|
// reading a report. "" means the operator has not yet reviewed.
|
|
type LoopReportUserOutcome string
|
|
|
|
const (
|
|
LoopReportUserOutcomeReviewed LoopReportUserOutcome = "reviewed"
|
|
)
|
|
|
|
// LoopReportEvidence captures the deterministic checks the sentinel
|
|
// ran. Each field is read-only operational evidence — counts and
|
|
// summaries — not executable commands or stale plans. The shape is
|
|
// stable so future review tooling can render every report uniformly.
|
|
type LoopReportEvidence struct {
|
|
// OperatorStateSummary is a short human-readable summary of the
|
|
// operator state at evaluation time (e.g.
|
|
// "maintenance_window_ended", "intentionally_offline", "no
|
|
// operator state").
|
|
OperatorStateSummary string `json:"operatorStateSummary,omitempty"`
|
|
|
|
// ActiveCriticalAlerts and ActiveWarningAlerts count alerts that
|
|
// were active for this resource at evaluation time.
|
|
ActiveCriticalAlerts int `json:"activeCriticalAlerts"`
|
|
ActiveWarningAlerts int `json:"activeWarningAlerts"`
|
|
|
|
// ActiveCriticalFindings and ActiveWarningFindings count Patrol
|
|
// findings active for the resource at evaluation time.
|
|
ActiveCriticalFindings int `json:"activeCriticalFindings"`
|
|
ActiveWarningFindings int `json:"activeWarningFindings"`
|
|
|
|
// FailedActionsSinceWindowStart is the number of action audits
|
|
// targeting the resource whose state ended in "failed" with an
|
|
// updated_at after the maintenance window started.
|
|
FailedActionsSinceWindowStart int `json:"failedActionsSinceWindowStart"`
|
|
|
|
// MetricRecovery describes the basic recent-metric check. Absent
|
|
// when no metric source was available for the resource at
|
|
// evaluation time.
|
|
MetricRecovery *MetricRecoveryEvidence `json:"metricRecovery,omitempty"`
|
|
|
|
// PatrolRunTODO is set when the deterministic evidence was
|
|
// ambiguous and a scoped Patrol run would have helped, but
|
|
// triggering one was not safe in this build. The value is the
|
|
// reason string so the UI can surface what was missing.
|
|
//
|
|
// MVP: deterministic checks only; the Patrol run trigger is
|
|
// deferred until the Patrol API surfaces a scoped per-resource
|
|
// run entrypoint that does not race the global scheduler.
|
|
PatrolRunTODO string `json:"patrolRunTodo,omitempty"`
|
|
}
|
|
|
|
// MetricRecoveryEvidence is a small summary of the metric values
|
|
// observed inside the maintenance window vs. immediately after it.
|
|
// "Recent metric recovery" is intentionally shallow for the MVP — a
|
|
// per-metric trend label rather than a model.
|
|
type MetricRecoveryEvidence struct {
|
|
// MetricsObserved lists the metric short names the sentinel
|
|
// inspected (e.g. "cpu", "memory").
|
|
MetricsObserved []string `json:"metricsObserved,omitempty"`
|
|
|
|
// SamplesAfterEnd is the number of metric samples observed after
|
|
// the window ended. Zero means the resource has not reported any
|
|
// metrics since the window closed — operator should look.
|
|
SamplesAfterEnd int `json:"samplesAfterEnd"`
|
|
|
|
// Trend describes the post-window metric trend in plain English.
|
|
// Allowed values: "improving", "stable", "degrading", "unknown".
|
|
Trend string `json:"trend,omitempty"`
|
|
|
|
// Note is an optional explanation surfaced to the operator
|
|
// alongside the trend.
|
|
Note string `json:"note,omitempty"`
|
|
}
|
|
|
|
// LoopReport is the durable record the sentinel writes when a loop run
|
|
// produces an outcome an operator may want to review. Fields are
|
|
// minimal on purpose — this is shared substrate, not a generic agent
|
|
// framework.
|
|
//
|
|
// For the maintenance-verification loop:
|
|
// - Scope is the canonical resource ID.
|
|
// - Trigger is always "maintenance_window_end".
|
|
// - WindowStartedAt and WindowEndedAt mirror the operator-set window
|
|
// so future runs can de-duplicate against the (resource, window)
|
|
// pair.
|
|
type LoopReport struct {
|
|
ID string `json:"id"`
|
|
Type LoopReportType `json:"type"`
|
|
Scope string `json:"scope"`
|
|
Trigger string `json:"trigger"`
|
|
Goal string `json:"goal,omitempty"`
|
|
Status LoopReportStatus `json:"status"`
|
|
StartedAt time.Time `json:"startedAt"`
|
|
CompletedAt time.Time `json:"completedAt"`
|
|
WindowStartedAt *time.Time `json:"windowStartedAt,omitempty"`
|
|
WindowEndedAt *time.Time `json:"windowEndedAt,omitempty"`
|
|
Evidence LoopReportEvidence `json:"evidence"`
|
|
LinkedFindingIDs []string `json:"linkedFindingIds,omitempty"`
|
|
LinkedAlertIDs []string `json:"linkedAlertIds,omitempty"`
|
|
LinkedActionIDs []string `json:"linkedActionIds,omitempty"`
|
|
LinkedPatrolRunID string `json:"linkedPatrolRunId,omitempty"`
|
|
Recommendation string `json:"recommendation,omitempty"`
|
|
UserOutcome LoopReportUserOutcome `json:"userOutcome,omitempty"`
|
|
ReviewedAt *time.Time `json:"reviewedAt,omitempty"`
|
|
ReviewedBy string `json:"reviewedBy,omitempty"`
|
|
ReviewNote string `json:"reviewNote,omitempty"`
|
|
}
|
|
|
|
// ErrLoopReportInvalid is returned when a record fails contract checks
|
|
// at the store boundary.
|
|
var ErrLoopReportInvalid = errors.New("loop_report_invalid")
|
|
|
|
// IsValidLoopReportType reports whether the value names a known loop
|
|
// report type. Unknown types are rejected at the store boundary so
|
|
// freeform discriminators cannot accumulate.
|
|
func IsValidLoopReportType(t LoopReportType) bool {
|
|
switch t {
|
|
case LoopReportTypeMaintenanceVerification:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsValidLoopReportStatus reports whether the value names a known
|
|
// terminal or in-flight status.
|
|
func IsValidLoopReportStatus(s LoopReportStatus) bool {
|
|
switch s {
|
|
case LoopReportStatusPending,
|
|
LoopReportStatusHealthy,
|
|
LoopReportStatusNeedsReview,
|
|
LoopReportStatusFailedVerification:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsValidLoopReportUserOutcome reports whether the value names a known
|
|
// review verdict. Empty is valid — the operator has not reviewed yet.
|
|
func IsValidLoopReportUserOutcome(o LoopReportUserOutcome) bool {
|
|
switch o {
|
|
case "", LoopReportUserOutcomeReviewed:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// NormalizeLoopReport trims string fields, canonicalizes the scope,
|
|
// and applies UTC to all timestamps. It does not validate — callers
|
|
// follow normalize with ValidateLoopReport.
|
|
func NormalizeLoopReport(r LoopReport) LoopReport {
|
|
r.ID = strings.TrimSpace(r.ID)
|
|
r.Scope = CanonicalResourceID(r.Scope)
|
|
r.Trigger = strings.TrimSpace(r.Trigger)
|
|
r.Goal = strings.TrimSpace(r.Goal)
|
|
r.Recommendation = strings.TrimSpace(r.Recommendation)
|
|
r.ReviewedBy = strings.TrimSpace(r.ReviewedBy)
|
|
r.ReviewNote = strings.TrimSpace(r.ReviewNote)
|
|
r.LinkedPatrolRunID = strings.TrimSpace(r.LinkedPatrolRunID)
|
|
r.Evidence.OperatorStateSummary = strings.TrimSpace(r.Evidence.OperatorStateSummary)
|
|
r.Evidence.PatrolRunTODO = strings.TrimSpace(r.Evidence.PatrolRunTODO)
|
|
if r.Evidence.MetricRecovery != nil {
|
|
r.Evidence.MetricRecovery.Trend = strings.TrimSpace(r.Evidence.MetricRecovery.Trend)
|
|
r.Evidence.MetricRecovery.Note = strings.TrimSpace(r.Evidence.MetricRecovery.Note)
|
|
}
|
|
if !r.StartedAt.IsZero() {
|
|
r.StartedAt = r.StartedAt.UTC()
|
|
}
|
|
if !r.CompletedAt.IsZero() {
|
|
r.CompletedAt = r.CompletedAt.UTC()
|
|
}
|
|
if r.WindowStartedAt != nil {
|
|
t := r.WindowStartedAt.UTC()
|
|
r.WindowStartedAt = &t
|
|
}
|
|
if r.WindowEndedAt != nil {
|
|
t := r.WindowEndedAt.UTC()
|
|
r.WindowEndedAt = &t
|
|
}
|
|
if r.ReviewedAt != nil {
|
|
t := r.ReviewedAt.UTC()
|
|
r.ReviewedAt = &t
|
|
}
|
|
r.LinkedFindingIDs = trimAndDedupeStrings(r.LinkedFindingIDs)
|
|
r.LinkedAlertIDs = trimAndDedupeStrings(r.LinkedAlertIDs)
|
|
r.LinkedActionIDs = trimAndDedupeStrings(r.LinkedActionIDs)
|
|
return r
|
|
}
|
|
|
|
// ValidateLoopReport applies the contract checks the persistence layer
|
|
// must enforce before writing a record. Returns an
|
|
// ErrLoopReportInvalid-wrapped error on violation.
|
|
func ValidateLoopReport(r LoopReport) error {
|
|
if r.ID == "" {
|
|
return fmt.Errorf("%w: id is required", ErrLoopReportInvalid)
|
|
}
|
|
if !IsValidLoopReportType(r.Type) {
|
|
return fmt.Errorf("%w: unknown report type %q", ErrLoopReportInvalid, r.Type)
|
|
}
|
|
if r.Scope == "" {
|
|
return fmt.Errorf("%w: scope (canonical resource id) is required", ErrLoopReportInvalid)
|
|
}
|
|
if r.Trigger == "" {
|
|
return fmt.Errorf("%w: trigger is required", ErrLoopReportInvalid)
|
|
}
|
|
if !IsValidLoopReportStatus(r.Status) {
|
|
return fmt.Errorf("%w: unknown status %q", ErrLoopReportInvalid, r.Status)
|
|
}
|
|
if r.StartedAt.IsZero() {
|
|
return fmt.Errorf("%w: startedAt is required", ErrLoopReportInvalid)
|
|
}
|
|
if r.CompletedAt.IsZero() {
|
|
return fmt.Errorf("%w: completedAt is required", ErrLoopReportInvalid)
|
|
}
|
|
if r.CompletedAt.Before(r.StartedAt) {
|
|
return fmt.Errorf("%w: completedAt must not be before startedAt", ErrLoopReportInvalid)
|
|
}
|
|
if !IsValidLoopReportUserOutcome(r.UserOutcome) {
|
|
return fmt.Errorf("%w: unknown user outcome %q", ErrLoopReportInvalid, r.UserOutcome)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func trimAndDedupeStrings(in []string) []string {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
seen := make(map[string]struct{}, len(in))
|
|
out := make([]string, 0, len(in))
|
|
for _, v := range in {
|
|
v = strings.TrimSpace(v)
|
|
if v == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[v]; ok {
|
|
continue
|
|
}
|
|
seen[v] = struct{}{}
|
|
out = append(out, v)
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// BuildLoopReportResourceChange projects an immutable loop report into the
|
|
// canonical resource timeline. The report remains the source-of-truth record;
|
|
// this change is the discoverable timeline evidence that lets existing
|
|
// change consumers explain why the resource's post-maintenance state changed.
|
|
func BuildLoopReportResourceChange(report LoopReport) (ResourceChange, bool) {
|
|
report = NormalizeLoopReport(report)
|
|
if report.ID == "" || report.Scope == "" || report.Type != LoopReportTypeMaintenanceVerification {
|
|
return ResourceChange{}, false
|
|
}
|
|
observedAt := report.CompletedAt
|
|
if observedAt.IsZero() {
|
|
observedAt = report.StartedAt
|
|
}
|
|
if observedAt.IsZero() {
|
|
observedAt = time.Now().UTC()
|
|
}
|
|
observedAt = observedAt.UTC()
|
|
|
|
var occurredAt *time.Time
|
|
if report.WindowEndedAt != nil {
|
|
t := report.WindowEndedAt.UTC()
|
|
occurredAt = &t
|
|
}
|
|
|
|
metadata := map[string]any{
|
|
"activityType": MaintenanceVerificationActivityReported,
|
|
"reportId": report.ID,
|
|
"reportType": string(report.Type),
|
|
"status": string(report.Status),
|
|
"trigger": report.Trigger,
|
|
}
|
|
if report.WindowStartedAt != nil {
|
|
metadata["windowStartedAt"] = report.WindowStartedAt.UTC().Format(time.RFC3339)
|
|
}
|
|
if report.WindowEndedAt != nil {
|
|
metadata["windowEndedAt"] = report.WindowEndedAt.UTC().Format(time.RFC3339)
|
|
}
|
|
if report.Recommendation != "" {
|
|
metadata["recommendation"] = report.Recommendation
|
|
}
|
|
if len(report.LinkedAlertIDs) > 0 {
|
|
metadata["linkedAlertCount"] = len(report.LinkedAlertIDs)
|
|
}
|
|
if len(report.LinkedFindingIDs) > 0 {
|
|
metadata["linkedFindingCount"] = len(report.LinkedFindingIDs)
|
|
}
|
|
if len(report.LinkedActionIDs) > 0 {
|
|
metadata["linkedActionCount"] = len(report.LinkedActionIDs)
|
|
}
|
|
if report.Evidence.OperatorStateSummary != "" {
|
|
metadata["operatorStateSummary"] = report.Evidence.OperatorStateSummary
|
|
}
|
|
|
|
return ResourceChange{
|
|
ID: resourceChangeID("loop-report", report.Scope, report.ID, observedAt),
|
|
ObservedAt: observedAt,
|
|
OccurredAt: occurredAt,
|
|
ResourceID: report.Scope,
|
|
Kind: ChangeActivity,
|
|
From: report.Trigger,
|
|
To: string(report.Status),
|
|
SourceType: SourceHeuristic,
|
|
SourceAdapter: maintenanceVerificationSourceAdapter,
|
|
Confidence: ConfidenceHigh,
|
|
Reason: "Maintenance verification reported " + string(report.Status),
|
|
Metadata: metadata,
|
|
}, true
|
|
}
|