From 63247e402a5d64cdd6e14a75f5f24076cdc1f8b3 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Sun, 31 May 2026 11:43:31 -0400 Subject: [PATCH] Add snapshot store Co-Authored-By: Claude Opus 4.8 --- internal/store/store.go | 62 ++++++++++++++++++++++++++++++++++++ internal/store/store_test.go | 50 +++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..d8f8d77 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,62 @@ +// Package store persists and restores the daemon's current state as a single +// JSON snapshot. It is the M0 source of durability; the hash-chained audit log +// arrives in a later milestone. +package store + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + + "antidrift/internal/domain" +) + +// Snapshot is the persisted current state of the daemon. +type Snapshot struct { + RuntimeState domain.RuntimeState `json:"runtime_state"` + Commitment *domain.Commitment `json:"commitment,omitempty"` + DeadlineUnixSecs int64 `json:"deadline_unix_secs,omitempty"` +} + +// DefaultPath returns ~/.antidrift/state.json. +func DefaultPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".antidrift", "state.json"), nil +} + +// Load reads a snapshot. A missing file yields a default Locked snapshot. +func Load(path string) (Snapshot, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return Snapshot{RuntimeState: domain.RuntimeLocked}, nil + } + if err != nil { + return Snapshot{}, err + } + var s Snapshot + if err := json.Unmarshal(data, &s); err != nil { + return Snapshot{}, err + } + return s, nil +} + +// Save writes a snapshot atomically (temp file + rename), creating the +// directory if needed. +func Save(path string, s Snapshot) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..53fe597 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,50 @@ +package store + +import ( + "path/filepath" + "testing" + "time" + + "antidrift/internal/domain" +) + +func TestLoadMissingFileReturnsLocked(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + s, err := Load(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if s.RuntimeState != domain.RuntimeLocked { + t.Errorf("missing file should be Locked, got %s", s.RuntimeState) + } + if s.Commitment != nil { + t.Errorf("missing file should have nil commitment") + } +} + +func TestSaveThenLoadRoundTrips(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "state.json") + c, _ := domain.NewManual("Port store", "store tests pass", 25*time.Minute) + c.State = domain.CommitmentActive + want := Snapshot{ + RuntimeState: domain.RuntimeActive, + Commitment: &c, + DeadlineUnixSecs: 1748725200, + } + if err := Save(path, want); err != nil { + t.Fatalf("save failed: %v", err) + } + got, err := Load(path) + if err != nil { + t.Fatalf("load failed: %v", err) + } + if got.RuntimeState != want.RuntimeState { + t.Errorf("runtime state: got %s want %s", got.RuntimeState, want.RuntimeState) + } + if got.Commitment == nil || got.Commitment.NextAction != "Port store" { + t.Errorf("commitment did not round-trip: %+v", got.Commitment) + } + if got.DeadlineUnixSecs != want.DeadlineUnixSecs { + t.Errorf("deadline: got %d want %d", got.DeadlineUnixSecs, want.DeadlineUnixSecs) + } +}