// 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() }