From 18108c3da36127ed66e0de006ea0d8643bab6f6c Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Sun, 31 May 2026 11:45:46 -0400 Subject: [PATCH] Add session controller with snapshot persistence Co-Authored-By: Claude Opus 4.8 --- internal/session/session.go | 156 +++++++++++++++++++++++++++++++ internal/session/session_test.go | 87 +++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 internal/session/session.go create mode 100644 internal/session/session_test.go diff --git a/internal/session/session.go b/internal/session/session.go new file mode 100644 index 0000000..e9ecddc --- /dev/null +++ b/internal/session/session.go @@ -0,0 +1,156 @@ +// Package session owns the daemon's in-memory state of truth and persists a +// snapshot on every change. Transitions go through the pure statemachine. +package session + +import ( + "sync" + "time" + + "antidrift/internal/domain" + "antidrift/internal/statemachine" + "antidrift/internal/store" +) + +// Controller holds runtime state and the active commitment behind a mutex. +type Controller struct { + mu sync.Mutex + runtimeState domain.RuntimeState + commitment *domain.Commitment + deadline time.Time + snapshotPath string +} + +// CommitmentView is the UI projection of the active commitment. +type CommitmentView struct { + NextAction string `json:"next_action"` + SuccessCondition string `json:"success_condition"` + TimeboxSecs int64 `json:"timebox_secs"` + DeadlineUnixSecs int64 `json:"deadline_unix_secs"` +} + +// State is the broadcastable view of the controller. +type State struct { + RuntimeState domain.RuntimeState `json:"runtime_state"` + Commitment *CommitmentView `json:"commitment"` +} + +// New loads any persisted snapshot, or starts Locked. +func New(snapshotPath string) (*Controller, error) { + s, err := store.Load(snapshotPath) + if err != nil { + return nil, err + } + c := &Controller{ + runtimeState: s.RuntimeState, + commitment: s.Commitment, + snapshotPath: snapshotPath, + } + if s.DeadlineUnixSecs > 0 { + c.deadline = time.Unix(s.DeadlineUnixSecs, 0) + } + if c.runtimeState == "" { + c.runtimeState = domain.RuntimeLocked + } + return c, nil +} + +// State returns the current broadcastable state. Safe for concurrent use. +func (c *Controller) State() State { + c.mu.Lock() + defer c.mu.Unlock() + return c.stateLocked() +} + +// Deadline returns the active commitment deadline, or the zero time. +func (c *Controller) Deadline() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.deadline +} + +func (c *Controller) stateLocked() State { + st := State{RuntimeState: c.runtimeState} + if c.commitment != nil { + st.Commitment = &CommitmentView{ + NextAction: c.commitment.NextAction, + SuccessCondition: c.commitment.SuccessCondition, + TimeboxSecs: c.commitment.TimeboxSecs, + DeadlineUnixSecs: c.deadline.Unix(), + } + } + return st +} + +func (c *Controller) persistLocked() error { + snap := store.Snapshot{RuntimeState: c.runtimeState, Commitment: c.commitment} + if !c.deadline.IsZero() { + snap.DeadlineUnixSecs = c.deadline.Unix() + } + return store.Save(c.snapshotPath, snap) +} + +// EnterPlanning moves Locked -> Planning. +func (c *Controller) EnterPlanning() error { + c.mu.Lock() + defer c.mu.Unlock() + next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EnterPlanning) + if err != nil { + return err + } + c.runtimeState = next + return c.persistLocked() +} + +// StartManualCommitment validates input, activates a new commitment, and moves +// Planning -> Active. +func (c *Controller) StartManualCommitment(nextAction, successCondition string, timebox time.Duration) error { + c.mu.Lock() + defer c.mu.Unlock() + commitment, err := domain.NewManual(nextAction, successCondition, timebox) + if err != nil { + return err + } + commitment.State, err = statemachine.TransitionCommitment(commitment.State, statemachine.CommitmentActivate) + if err != nil { + return err + } + next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.ActivateAccepted) + if err != nil { + return err + } + c.runtimeState = next + c.commitment = &commitment + c.deadline = time.Now().Add(timebox) + return c.persistLocked() +} + +// Complete moves Active -> Review and marks the commitment completed. +func (c *Controller) Complete() error { + c.mu.Lock() + defer c.mu.Unlock() + next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.CompleteForReview) + if err != nil { + return err + } + c.runtimeState = next + if c.commitment != nil { + if s, e := statemachine.TransitionCommitment(c.commitment.State, statemachine.Complete); e == nil { + c.commitment.State = s + } + } + return c.persistLocked() +} + +// End moves Review -> Locked and clears the commitment. +func (c *Controller) End() error { + c.mu.Lock() + defer c.mu.Unlock() + next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EndWorkPeriod) + if err != nil { + return err + } + c.runtimeState = next + c.commitment = nil + c.deadline = time.Time{} + return c.persistLocked() +} diff --git a/internal/session/session_test.go b/internal/session/session_test.go new file mode 100644 index 0000000..fe09cc0 --- /dev/null +++ b/internal/session/session_test.go @@ -0,0 +1,87 @@ +package session + +import ( + "path/filepath" + "testing" + "time" + + "antidrift/internal/domain" +) + +func newTestController(t *testing.T) (*Controller, string) { + t.Helper() + path := filepath.Join(t.TempDir(), "state.json") + c, err := New(path) + if err != nil { + t.Fatalf("new controller: %v", err) + } + return c, path +} + +func TestHappyPathDrivesStates(t *testing.T) { + c, _ := newTestController(t) + if c.State().RuntimeState != domain.RuntimeLocked { + t.Fatalf("should start Locked") + } + if err := c.EnterPlanning(); err != nil { + t.Fatalf("enter planning: %v", err) + } + if err := c.StartManualCommitment("Port session", "session tests pass", 25*time.Minute); err != nil { + t.Fatalf("start commitment: %v", err) + } + st := c.State() + if st.RuntimeState != domain.RuntimeActive { + t.Fatalf("should be Active, got %s", st.RuntimeState) + } + if st.Commitment == nil || st.Commitment.NextAction != "Port session" { + t.Fatalf("active commitment missing: %+v", st.Commitment) + } + if st.Commitment.DeadlineUnixSecs <= time.Now().Unix() { + t.Fatalf("deadline should be in the future") + } + if err := c.Complete(); err != nil { + t.Fatalf("complete: %v", err) + } + if c.State().RuntimeState != domain.RuntimeReview { + t.Fatalf("should be Review after complete") + } + if err := c.End(); err != nil { + t.Fatalf("end: %v", err) + } + if c.State().RuntimeState != domain.RuntimeLocked { + t.Fatalf("should be Locked after end") + } +} + +func TestStartCommitmentRejectsInvalidInput(t *testing.T) { + c, _ := newTestController(t) + _ = c.EnterPlanning() + if err := c.StartManualCommitment("", "x", 25*time.Minute); err == nil { + t.Fatalf("empty next action should error") + } + if c.State().RuntimeState != domain.RuntimePlanning { + t.Fatalf("failed start must not change state, got %s", c.State().RuntimeState) + } +} + +func TestStateRestoresFromSnapshot(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + first, err := New(path) + if err != nil { + t.Fatalf("new: %v", err) + } + _ = first.EnterPlanning() + _ = first.StartManualCommitment("Persisted action", "done", 25*time.Minute) + + second, err := New(path) + if err != nil { + t.Fatalf("reopen: %v", err) + } + st := second.State() + if st.RuntimeState != domain.RuntimeActive { + t.Fatalf("restored state should be Active, got %s", st.RuntimeState) + } + if st.Commitment == nil || st.Commitment.NextAction != "Persisted action" { + t.Fatalf("restored commitment missing: %+v", st.Commitment) + } +}