Pulse/internal/ai/memory/paths.go
rcourtman 8439ce6e6b Dedupe AI runtime guest-family, tool-pipeline, and provider clones
Clears the ten dupl pairs across internal/ai:

- patrol_intelligence.go, tools_query.go, tools_storage.go: VM and LXC
  system-container paths collapse into generics over read-state view
  method subsets (gatherGuestIntelligenceFromViews, canonicalGuestGetResult
  + guestViewGetResult, addCanonicalGuestSearchMatches +
  addGuestViewSearchMatches, appendGuestDiskSummaries).
- tools_file.go: append/write share executeFileMutation driven by
  fileMutationSpec (approval-command text, shell redirect, verification
  strategy stay per-action and verbatim).
- tools_kubernetes.go: deployment restart / pod delete share
  executeKubernetesResourceAction driven by kubernetesResourceAction.
- providers/anthropic.go + anthropic_oauth.go: message conversion shared
  via convertMessagesToAnthropic (the OAuth copy was annotated 'same as
  regular client').
- memory/changes.go + memory/remediation.go: history loading shared via
  the generic loadMemoryHistory in memory/paths.go (10 MiB cap, sort,
  missing-file semantics preserved via a found flag).
- findings.go and unified/alerts.go: Finding/findingJSON and
  UnifiedFinding/unifiedFindingJSON are deliberate marshal-mirror twins
  (AlertIdentifier json:"-" vs alert_identifier round-trip); merging
  would break every public literal. Suppressed with nolint:dupl and
  enforced instead by new reflect-based mirror-sync tests.

Contract Extension Points name the shared helpers and the mirror
invariant. Full ./internal/ai/... test tree passes.
2026-06-10 09:38:52 +01:00

77 lines
1.9 KiB
Go

package memory
import (
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
)
const (
changeHistoryFileName = "ai_changes.json"
incidentHistoryFileName = "ai_incidents.json"
remediationHistoryFileName = "ai_remediations.json"
incidentFileName = incidentHistoryFileName
)
func normalizeOptionalMemoryDataDir(dir string) string {
trimmed := strings.TrimSpace(dir)
if trimmed == "" {
return ""
}
normalized, err := securityutil.NormalizeStorageDir(trimmed)
if err != nil {
return ""
}
return normalized
}
func memoryPersistencePath(dataDir string, leaf string) (string, error) {
normalizedDir := normalizeOptionalMemoryDataDir(dataDir)
if normalizedDir == "" {
return "", fmt.Errorf("memory data directory is required")
}
return securityutil.JoinStorageLeaf(normalizedDir, leaf)
}
// loadMemoryHistory loads a JSON history slice from dataDir/fileName sorted
// by less, enforcing the shared 10 MiB on-disk safety cap. The boolean result
// is false when persistence is disabled (empty dataDir) or the file does not
// exist; label feeds the too-large error message.
func loadMemoryHistory[T any](dataDir, fileName, label string, less func(a, b T) bool) ([]T, bool, error) {
if dataDir == "" {
return nil, false, nil
}
path, err := memoryPersistencePath(dataDir, fileName)
if err != nil {
return nil, false, err
}
if st, err := os.Stat(path); err == nil {
const maxOnDiskBytes = 10 << 20 // 10 MiB safety cap
if st.Size() > maxOnDiskBytes {
return nil, false, fmt.Errorf("%s file too large (%d bytes)", label, st.Size())
}
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, false, nil
}
return nil, false, err
}
var items []T
if err := json.Unmarshal(data, &items); err != nil {
return nil, false, err
}
sort.Slice(items, func(i, j int) bool {
return less(items[i], items[j])
})
return items, true, nil
}