Add session controller with snapshot persistence
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user