A reading now keeps the path it was measured at, and the peak is read only from readings at the item's path now. Before, an agent's home that moved to its own account was compared with the operator's home it left, and data-shrank was raised for data that was never lost. Existing readings take their item's path now, so a shrink that is real stays raised.
175 lines
7.9 KiB
Go
175 lines
7.9 KiB
Go
package inventory
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func size(n int64) *int64 { return &n }
|
|
|
|
func at(t time.Time) *time.Time { return &t }
|
|
|
|
// What a machine declares is kept with what its holder measured; an irreplaceable item it no longer
|
|
// declares — its module unassigned — is RETIRED and kept, never forgotten, and comes back as it was when
|
|
// declared again; anything else no longer declared is forgotten (novox/hq ADR 0233).
|
|
func TestAnUndeclaredIrreplaceableItemIsRetiredNotForgotten(t *testing.T) {
|
|
inv := fresh(t)
|
|
ctx := t.Context()
|
|
now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
|
|
declared := []DeclaredData{
|
|
{Module: "home-assistant", Item: "config", Class: "irreplaceable", Owned: true},
|
|
{Module: "searxng", Item: "valkey", Class: "cache"},
|
|
{Module: "ollama", Item: "models", Class: "rebuildable"},
|
|
}
|
|
measured := map[string]map[string]Measurement{"home-assistant": {"config": {Path: "/var/lib/home-assistant/config",
|
|
Size: size(900 << 20), LastWrite: at(now.Add(-time.Minute)), MeasuredAt: at(now), LastBackup: at(now.Add(-6 * time.Hour))}}}
|
|
if _, err := inv.RecordData(ctx, "home", declared, measured, "", now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
all, err := inv.Data(ctx)
|
|
if err != nil || len(all) != 3 {
|
|
t.Fatalf("%+v, %v", all, err)
|
|
}
|
|
|
|
// Unassigned: nothing of it is declared any more.
|
|
later := now.Add(time.Hour)
|
|
change, err := inv.RecordData(ctx, "home", nil, nil, "home-assistant unassigned", later)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(change.Retired) != 1 || change.Retired[0].Item != "config" {
|
|
t.Fatalf("retired %+v", change.Retired)
|
|
}
|
|
all, err = inv.Data(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(all) != 1 || !all[0].Retired() || all[0].Path != "/var/lib/home-assistant/config" ||
|
|
*all[0].Size != 900<<20 || all[0].RetiredWhy != "home-assistant unassigned" {
|
|
t.Fatalf("the irreplaceable item is not kept retired with where it is: %+v", all)
|
|
}
|
|
|
|
// A second run that still does not declare it changes nothing: retired once, not again.
|
|
change, err = inv.RecordData(ctx, "home", nil, nil, "again", later.Add(time.Hour))
|
|
if err != nil || len(change.Retired) != 0 {
|
|
t.Fatalf("retired twice: %+v, %v", change, err)
|
|
}
|
|
|
|
// Deleting needs it retired; assigned again it is not retired any more, and its history stays.
|
|
if err := inv.MarkDataDeleted(ctx, "home", "searxng", "valkey", "p", "w", later); err == nil {
|
|
t.Fatal("deleting something the mesh does not hold retired was recorded")
|
|
}
|
|
change, err = inv.RecordData(ctx, "home", declared[:1], nil, "", later.Add(2*time.Hour))
|
|
if err != nil || len(change.Reenabled) != 1 {
|
|
t.Fatalf("declared again: %+v, %v", change, err)
|
|
}
|
|
r, err := inv.DataOf(ctx, "home", "home-assistant", "config")
|
|
if err != nil || r.Retired() || !r.FirstSeen.Equal(now) || r.Size == nil {
|
|
t.Fatalf("declared again lost what was known: %+v, %v", r, err)
|
|
}
|
|
|
|
// Retired and then deleted by a person: recorded, never by dropping the row.
|
|
if _, err := inv.RecordData(ctx, "home", nil, nil, "unassigned again", later.Add(3*time.Hour)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := inv.MarkDataDeleted(ctx, "home", "home-assistant", "config", "jochen", "moved to the anchor", later.Add(4*time.Hour)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r, err = inv.DataOf(ctx, "home", "home-assistant", "config")
|
|
if err != nil || r.DeletedAt == nil || r.DeletedBy != "jochen" || r.Retired() {
|
|
t.Fatalf("a deletion is not on record: %+v, %v", r, err)
|
|
}
|
|
}
|
|
|
|
// A reading is kept at most once an hour, and the peak is read over the window asked.
|
|
func TestReadingsAreHourlyAndThePeakIsTheLargest(t *testing.T) {
|
|
inv := fresh(t)
|
|
ctx := t.Context()
|
|
now := time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)
|
|
declared := []DeclaredData{{Module: "grafana", Item: "data", Class: "valuable", Owned: true}}
|
|
for i, s := range []int64{100, 120, 999, 130, 40} {
|
|
when := now.Add(time.Duration(i) * 20 * time.Minute)
|
|
if _, err := inv.RecordData(ctx, "ace", declared, map[string]map[string]Measurement{"grafana": {"data": {
|
|
Path: "/var/lib/grafana/data", Size: size(s), MeasuredAt: at(when)}}}, "", when); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
var n int
|
|
if err := inv.store.Pool().QueryRow(ctx, `select count(*) from data_reading`).Scan(&n); err != nil || n != 2 {
|
|
t.Fatalf("%d readings kept for five measurements over 80 minutes, want 2 (one an hour): %v", n, err)
|
|
}
|
|
peaks, err := inv.DataPeaks(ctx, now.Add(-time.Hour))
|
|
if err != nil || peaks["ace/grafana/data"] != 130 {
|
|
t.Fatalf("peak %v, %v", peaks, err)
|
|
}
|
|
r, _ := inv.DataOf(ctx, "ace", "grafana", "data")
|
|
if r.Size == nil || *r.Size != 40 {
|
|
t.Fatalf("the newest measurement is not the one on record: %+v", r)
|
|
}
|
|
}
|
|
|
|
// A partial walk's lower bound is kept as the newest measurement, said as partial, and never kept as a
|
|
// reading a shrink would be read against.
|
|
func TestAPartialMeasurementIsNeverAReading(t *testing.T) {
|
|
inv := fresh(t)
|
|
ctx := t.Context()
|
|
now := time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)
|
|
declared := []DeclaredData{{Module: "big", Item: "data", Class: "valuable", Owned: true}}
|
|
if _, err := inv.RecordData(ctx, "home", declared, map[string]map[string]Measurement{"big": {"data": {
|
|
Path: "/srv/big", Size: size(5), MeasuredAt: at(now), Precision: "partial: stopped"}}}, "", now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var n int
|
|
if err := inv.store.Pool().QueryRow(ctx, `select count(*) from data_reading`).Scan(&n); err != nil || n != 0 {
|
|
t.Fatalf("%d readings from a partial measurement: %v", n, err)
|
|
}
|
|
r, err := inv.DataOf(ctx, "home", "big", "data")
|
|
if err != nil || r.Precision != "partial: stopped" || Comparable(r.Precision) {
|
|
t.Fatalf("%+v, %v", r, err)
|
|
}
|
|
}
|
|
|
|
// A shrink is read against what the same directory held (novox/hq issue 368): an item whose path moved
|
|
// — an agent's home moved to its own account — starts its size history again at the new path, and a
|
|
// genuine shrink at the new path is still read against what that path held.
|
|
func TestThePeakIsReadAtTheItemsPathOnly(t *testing.T) {
|
|
inv := fresh(t)
|
|
ctx := t.Context()
|
|
now := time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC)
|
|
declared := []DeclaredData{{Module: "claude-code", Item: "agent-home", Class: "valuable", Owned: true}}
|
|
measure := func(when time.Time, path string, s int64) {
|
|
t.Helper()
|
|
if _, err := inv.RecordData(ctx, "novox", declared, map[string]map[string]Measurement{"claude-code": {
|
|
"agent-home": {Path: path, Size: size(s), MeasuredAt: at(when)}}}, "", when); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
measure(now, "/home/operator/.claude", 94_700_000)
|
|
// The path moves ten minutes later: the new directory is measured at once, not an hour on.
|
|
measure(now.Add(10*time.Minute), "/home/agent/.claude", 490)
|
|
peaks, err := inv.DataPeaks(ctx, now.Add(-time.Hour))
|
|
if err != nil || peaks["novox/claude-code/agent-home"] != 490 {
|
|
t.Fatalf("after the path moved the peak is %v (%v), want 490: the old directory's size is no shrink "+
|
|
"of the new one", peaks, err)
|
|
}
|
|
// The new directory grows, then genuinely loses most of it: that is read against the new path's peak.
|
|
measure(now.Add(2*time.Hour), "/home/agent/.claude", 80_000_000)
|
|
measure(now.Add(4*time.Hour), "/home/agent/.claude", 1_000)
|
|
peaks, err = inv.DataPeaks(ctx, now.Add(-time.Hour))
|
|
if err != nil || peaks["novox/claude-code/agent-home"] != 80_000_000 {
|
|
t.Fatalf("a shrink at the new path is read against %v (%v), want 80000000", peaks, err)
|
|
}
|
|
// A measurement that names no path is the item's last known path's, not a new history.
|
|
measure(now.Add(6*time.Hour), "", 2_000)
|
|
peaks, err = inv.DataPeaks(ctx, now.Add(-time.Hour))
|
|
if err != nil || peaks["novox/claude-code/agent-home"] != 80_000_000 {
|
|
t.Fatalf("a measurement with no path started a new history: peak %v (%v)", peaks, err)
|
|
}
|
|
// Moving back to a directory measured before reads it against what it held then.
|
|
measure(now.Add(8*time.Hour), "/home/operator/.claude", 94_000_000)
|
|
peaks, err = inv.DataPeaks(ctx, now.Add(-time.Hour))
|
|
if err != nil || peaks["novox/claude-code/agent-home"] != 94_700_000 {
|
|
t.Fatalf("back at the first path the peak is %v (%v), want 94700000", peaks, err)
|
|
}
|
|
}
|