mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
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.
77 lines
1.9 KiB
Go
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
|
|
}
|