packages feed

kioku-core-0.1.0.0: src/Kioku/Session/Domain.hs

{-# LANGUAGE TemplateHaskell #-}

module Kioku.Session.Domain
  ( SessionVertex (..),
    SessionRegs,
    StartSessionData (..),
    CompleteSessionData (..),
    FailSessionData (..),
    Continuation (..),
    AwaitInputData (..),
    ResumeSessionData (..),
    RecordInteractiveSessionData (..),
    RecordTurnData (..),
    SessionCommand (..),
    commandSessionId,
    SessionStartedData (..),
    SessionCompletedData (..),
    SessionFailedData (..),
    SessionAwaitingData (..),
    SessionResumedData (..),
    InteractiveSessionRecordedData (..),
    TurnRecordedData (..),
    SessionEvent (..),
    eventSessionId,
    sessionTransducer,
  )
where

import Data.Aeson.Types (withObject, (.!=), (.:), (.:?))
import Keiki.Builder ((=:))
import Keiki.Builder qualified as B
import Keiki.Core (HsPred, SymTransducer, lit, (.==), (.||))
import Keiki.Generics (emptyRegFile)
import Keiki.Generics.TH (deriveAggregate)
import Kioku.Api.Scope (MemoryScope)
import Kioku.Id (SessionId)
import Kioku.Prelude

data SessionVertex = NotCreated | Running | Completed | Failed | Interactive | Awaiting
  deriving stock (Eq, Ord, Show, Enum, Bounded)

-- | Replayed aggregate state carried alongside the vertex.
--
-- @awaitedCorrelationKey@ is the key the session is currently parked on (set by
-- @SessionAwaiting@, cleared by @SessionResumed@). It is what makes resume-correlation
-- matching an aggregate invariant rather than a racy read-model precheck.
--
-- @lastTurnIndex@ is the highest turn index committed so far (-1 before any turn), which
-- makes @RecordTurn@'s strictly-increasing index contract enforceable in the state machine
-- rather than only at the command layer.
type SessionRegs =
  '[ '("awaitedCorrelationKey", Maybe Text),
     '("lastTurnIndex", Int)
   ]

data StartSessionData = StartSessionData
  { sessionId :: !SessionId,
    agentId :: !Text,
    focus :: !Text,
    scope :: !MemoryScope,
    subjectRef :: !(Maybe Text),
    previousSessionId :: !(Maybe SessionId),
    parentSessionId :: !(Maybe SessionId),
    delegationDepth :: !Int,
    startedAt :: !UTCTime
  }
  deriving stock (Generic, Eq, Show)

data CompleteSessionData = CompleteSessionData
  { sessionId :: !SessionId,
    completedAt :: !UTCTime,
    modelUsed :: !(Maybe Text),
    summary :: !(Maybe Text)
  }
  deriving stock (Generic, Eq, Show)

data FailSessionData = FailSessionData
  { sessionId :: !SessionId,
    failedAt :: !UTCTime,
    errorMessage :: !Text
  }
  deriving stock (Generic, Eq, Show)

-- | What a parked session is waiting for.
--
-- @deadline@ is advisory only: it is stored for hosts and kioku does not enforce it. No timer
-- fires and nothing expires when it passes (MasterPlan 2 decision, 2026-07-07).
data Continuation = Continuation
  { reason :: !Text,
    correlationKey :: !(Maybe Text),
    deadline :: !(Maybe UTCTime)
  }
  deriving stock (Generic, Eq, Show)
  deriving anyclass (FromJSON, ToJSON)

-- | Park a session until a host supplies input.
--
-- @deadline@ is advisory only: it is stored for hosts and kioku does not enforce it. No timer
-- fires and nothing expires when it passes (MasterPlan 2 decision, 2026-07-07).
data AwaitInputData = AwaitInputData
  { sessionId :: !SessionId,
    reason :: !Text,
    correlationKey :: !(Maybe Text),
    deadline :: !(Maybe UTCTime),
    awaitedAt :: !UTCTime
  }
  deriving stock (Generic, Eq, Show)

-- | Resume a parked session.
--
-- @correlationKey@ must equal the key the session parked on, or the aggregate rejects the
-- command. @force@ waives that check; it is set only by 'Kioku.Session.forceResume' and is
-- inherently last-writer-wins.
data ResumeSessionData = ResumeSessionData
  { sessionId :: !SessionId,
    correlationKey :: !(Maybe Text),
    force :: !Bool,
    input :: !Text,
    resumedAt :: !UTCTime
  }
  deriving stock (Generic, Eq, Show)

data RecordInteractiveSessionData = RecordInteractiveSessionData
  { sessionId :: !SessionId,
    agentId :: !Text,
    focus :: !Text,
    scope :: !MemoryScope,
    subjectRef :: !(Maybe Text),
    startedAt :: !UTCTime
  }
  deriving stock (Generic, Eq, Show)

data RecordTurnData = RecordTurnData
  { sessionId :: !SessionId,
    turnId :: !Text,
    turnIndex :: !Int,
    role :: !Text,
    content :: !Text,
    toolSummary :: !(Maybe Text),
    promptTokens :: !(Maybe Int),
    outputTokens :: !(Maybe Int),
    recordedAt :: !UTCTime
  }
  deriving stock (Generic, Eq, Show)

data SessionCommand
  = StartSession !StartSessionData
  | CompleteSession !CompleteSessionData
  | FailSession !FailSessionData
  | AwaitInput !AwaitInputData
  | ResumeSession !ResumeSessionData
  | RecordInteractiveSession !RecordInteractiveSessionData
  | RecordTurn !RecordTurnData
  deriving stock (Generic, Eq, Show)

commandSessionId :: SessionCommand -> SessionId
commandSessionId = \case
  StartSession d -> d.sessionId
  CompleteSession d -> d.sessionId
  FailSession d -> d.sessionId
  AwaitInput d -> d.sessionId
  ResumeSession d -> d.sessionId
  RecordInteractiveSession d -> d.sessionId
  RecordTurn d -> d.sessionId

data SessionStartedData = SessionStartedData
  { sessionId :: !SessionId,
    agentId :: !Text,
    focus :: !Text,
    scope :: !MemoryScope,
    subjectRef :: !(Maybe Text),
    previousSessionId :: !(Maybe SessionId),
    parentSessionId :: !(Maybe SessionId),
    delegationDepth :: !Int,
    startedAt :: !UTCTime
  }
  deriving stock (Generic, Eq, Show)
  deriving anyclass (ToJSON)

instance FromJSON SessionStartedData where
  parseJSON =
    withObject "SessionStartedData" \o ->
      SessionStartedData
        <$> o .: "sessionId"
        <*> o .: "agentId"
        <*> o .: "focus"
        <*> o .: "scope"
        <*> o .:? "subjectRef"
        <*> o .:? "previousSessionId"
        <*> o .:? "parentSessionId"
        <*> o .:? "delegationDepth" .!= 0
        <*> o .: "startedAt"

data SessionCompletedData = SessionCompletedData
  { sessionId :: !SessionId,
    completedAt :: !UTCTime,
    modelUsed :: !(Maybe Text),
    summary :: !(Maybe Text)
  }
  deriving stock (Generic, Eq, Show)
  deriving anyclass (FromJSON, ToJSON)

data SessionFailedData = SessionFailedData
  { sessionId :: !SessionId,
    failedAt :: !UTCTime,
    errorMessage :: !Text
  }
  deriving stock (Generic, Eq, Show)
  deriving anyclass (FromJSON, ToJSON)

data SessionAwaitingData = SessionAwaitingData
  { sessionId :: !SessionId,
    reason :: !Text,
    correlationKey :: !(Maybe Text),
    deadline :: !(Maybe UTCTime),
    awaitedAt :: !UTCTime
  }
  deriving stock (Generic, Eq, Show)
  deriving anyclass (FromJSON, ToJSON)

data SessionResumedData = SessionResumedData
  { sessionId :: !SessionId,
    correlationKey :: !(Maybe Text),
    force :: !Bool,
    input :: !Text,
    resumedAt :: !UTCTime
  }
  deriving stock (Generic, Eq, Show)
  deriving anyclass (ToJSON)

-- | Events written before the resume-correlation guard landed carry no @force@ key. Under
-- the old code an omitted correlation key bypassed matching entirely, so a keyless legacy
-- resume decodes as a force-resume and a keyed one as a plain resume. That is what keeps
-- every historical stream replayable through the guard the transducer now applies.
instance FromJSON SessionResumedData where
  parseJSON =
    withObject "SessionResumedData" \o -> do
      sessionId <- o .: "sessionId"
      correlationKey <- o .:? "correlationKey"
      force <- o .:? "force" .!= isNothing correlationKey
      input <- o .: "input"
      resumedAt <- o .: "resumedAt"
      pure SessionResumedData {sessionId, correlationKey, force, input, resumedAt}

data InteractiveSessionRecordedData = InteractiveSessionRecordedData
  { sessionId :: !SessionId,
    agentId :: !Text,
    focus :: !Text,
    scope :: !MemoryScope,
    subjectRef :: !(Maybe Text),
    startedAt :: !UTCTime
  }
  deriving stock (Generic, Eq, Show)
  deriving anyclass (FromJSON, ToJSON)

data TurnRecordedData = TurnRecordedData
  { sessionId :: !SessionId,
    turnId :: !Text,
    turnIndex :: !Int,
    role :: !Text,
    content :: !Text,
    toolSummary :: !(Maybe Text),
    promptTokens :: !(Maybe Int),
    outputTokens :: !(Maybe Int),
    recordedAt :: !UTCTime
  }
  deriving stock (Generic, Eq, Show)
  deriving anyclass (FromJSON, ToJSON)

data SessionEvent
  = SessionStarted !SessionStartedData
  | SessionCompleted !SessionCompletedData
  | SessionFailed !SessionFailedData
  | SessionAwaiting !SessionAwaitingData
  | SessionResumed !SessionResumedData
  | InteractiveSessionRecorded !InteractiveSessionRecordedData
  | TurnRecorded !TurnRecordedData
  deriving stock (Generic, Eq, Show)

instance FromJSON SessionEvent where
  parseJSON = genericParseJSON eventAesonOptions

instance ToJSON SessionEvent where
  toJSON = genericToJSON eventAesonOptions

eventSessionId :: SessionEvent -> SessionId
eventSessionId = \case
  SessionStarted d -> d.sessionId
  SessionCompleted d -> d.sessionId
  SessionFailed d -> d.sessionId
  SessionAwaiting d -> d.sessionId
  SessionResumed d -> d.sessionId
  InteractiveSessionRecorded d -> d.sessionId
  TurnRecorded d -> d.sessionId

$(deriveAggregate ''SessionCommand ''SessionRegs ''SessionEvent)

sessionTransducer ::
  SymTransducer
    (HsPred SessionRegs SessionCommand)
    SessionRegs
    SessionVertex
    SessionCommand
    SessionEvent
sessionTransducer =
  B.buildTransducer NotCreated emptyRegFile isTerminal do
    B.from NotCreated do
      B.onCmd inCtorStartSession $ \d -> B.do
        -- 'emptyRegFile' binds every slot to a deferred error, so this edge — the only way
        -- into Running, and thus into Awaiting and RecordTurn — must initialize both.
        B.slot @"awaitedCorrelationKey" =: lit Nothing
        B.slot @"lastTurnIndex" =: lit (-1)
        B.emit
          wireSessionStarted
          SessionStartedTermFields
            { sessionId = d.sessionId,
              agentId = d.agentId,
              focus = d.focus,
              scope = d.scope,
              subjectRef = d.subjectRef,
              previousSessionId = d.previousSessionId,
              parentSessionId = d.parentSessionId,
              delegationDepth = d.delegationDepth,
              startedAt = d.startedAt
            }
        B.goto Running

      B.onCmd inCtorRecordInteractiveSession $ \d -> B.do
        B.emit
          wireInteractiveSessionRecorded
          InteractiveSessionRecordedTermFields
            { sessionId = d.sessionId,
              agentId = d.agentId,
              focus = d.focus,
              scope = d.scope,
              subjectRef = d.subjectRef,
              startedAt = d.startedAt
            }
        B.goto Interactive

    B.from Running do
      B.onCmd inCtorCompleteSession $ \d -> B.do
        B.emit
          wireSessionCompleted
          SessionCompletedTermFields
            { sessionId = d.sessionId,
              completedAt = d.completedAt,
              modelUsed = d.modelUsed,
              summary = d.summary
            }
        B.goto Completed

      B.onCmd inCtorFailSession $ \d -> B.do
        B.emit
          wireSessionFailed
          SessionFailedTermFields
            { sessionId = d.sessionId,
              failedAt = d.failedAt,
              errorMessage = d.errorMessage
            }
        B.goto Failed

      B.onCmd inCtorRecordTurn $ \d -> B.do
        -- Turn identity: (sessionId, turnIndex). Indexes must strictly increase, so a
        -- re-delivered or out-of-order turn cannot silently overwrite a committed one.
        -- 'turnIndex' is already in the event payload, so replay recovers it and existing
        -- strictly-increasing streams rehydrate unchanged (verified by Audit B).
        B.requireGt d.turnIndex (B.reg @"lastTurnIndex")
        B.slot @"lastTurnIndex" =: d.turnIndex
        B.emit
          wireTurnRecorded
          TurnRecordedTermFields
            { sessionId = d.sessionId,
              turnId = d.turnId,
              turnIndex = d.turnIndex,
              role = d.role,
              content = d.content,
              toolSummary = d.toolSummary,
              promptTokens = d.promptTokens,
              outputTokens = d.outputTokens,
              recordedAt = d.recordedAt
            }
        B.goto Running

      B.onCmd inCtorAwaitInput $ \d -> B.do
        B.slot @"awaitedCorrelationKey" =: d.correlationKey
        B.emit
          wireSessionAwaiting
          SessionAwaitingTermFields
            { sessionId = d.sessionId,
              reason = d.reason,
              correlationKey = d.correlationKey,
              deadline = d.deadline,
              awaitedAt = d.awaitedAt
            }
        B.goto Awaiting

    B.from Awaiting do
      B.onCmd inCtorResumeSession $ \d -> B.do
        -- The resume must name the key this session actually parked on, unless it is an
        -- explicit force. Enforcing it here rather than in a read-model precheck is what
        -- closes the race: keiro re-runs this edge against the post-conflict state.
        B.requireGuard
          ((d.force .== lit True) .|| (d.correlationKey .== B.reg @"awaitedCorrelationKey"))
        B.slot @"awaitedCorrelationKey" =: lit Nothing
        B.emit
          wireSessionResumed
          SessionResumedTermFields
            { sessionId = d.sessionId,
              correlationKey = d.correlationKey,
              -- Mandatory for replay: the guard reads 'force', and hydration can only
              -- recover command fields that the event payload carries.
              force = d.force,
              input = d.input,
              resumedAt = d.resumedAt
            }
        B.goto Running

      B.onCmd inCtorCompleteSession $ \d -> B.do
        B.emit
          wireSessionCompleted
          SessionCompletedTermFields
            { sessionId = d.sessionId,
              completedAt = d.completedAt,
              modelUsed = d.modelUsed,
              summary = d.summary
            }
        B.goto Completed

      B.onCmd inCtorFailSession $ \d -> B.do
        B.emit
          wireSessionFailed
          SessionFailedTermFields
            { sessionId = d.sessionId,
              failedAt = d.failedAt,
              errorMessage = d.errorMessage
            }
        B.goto Failed
  where
    isTerminal = \case
      Completed -> True
      Failed -> True
      Interactive -> True
      _ -> False