packages feed

keiro-0.22.0.0: src/Keiro/ProcessManager/Inspect.hs

{-# LANGUAGE GADTs #-}
{-# LANGUAGE OverloadedRecordDot #-}

-- | Application-owned descriptors and observations of persisted manager state.
-- Inspection uses the runtime hydration algorithm; raw history remains in Kiroku.
module Keiro.ProcessManager.Inspect
  ( ProcessManagerInspector (..),
    SomeProcessManagerInspector (..),
    ProcessManagerInspectors,
    ProcessManagerTypeSummary (..),
    listProcessManagerTypes,
    processManagerTypeJson,
    processManagerInspector,
    domainProcessManagerInspector,
    reactiveProcessManagerInspector,
    ProcessManagerProvenanceMode (..),
    EnabledInput (..),
    ReactionProvenance (..),
    encodeReactionProvenance,
    decodeReactionProvenance,
    DispatchWitness (..),
    DispatchedCommandStatus (..),
    LastReaction (..),
    InstanceViewRequest (..),
    ProcessManagerInspectionError (..),
    ProcessManagerInstanceView (..),
    inspectProcessManagerInstance,
    processManagerViewJson,
    InstanceListRequest (..),
    InstanceListError (..),
    InstanceSummary (..),
    InstancePage (..),
    instanceListCursorKind,
    listProcessManagerInstances,
    instancePageJson,
  )
where

import Data.Aeson (object, (.=))
import Data.Aeson qualified as Aeson
import Data.Aeson.Encoding qualified as Encoding
import Data.Aeson.KeyMap qualified as KeyMap
import Data.List.NonEmpty qualified as NonEmpty
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (mapMaybe)
import Data.Text qualified as Text
import Data.UUID qualified as UUID
import Data.Vector qualified as Vector
import Effectful (Eff, IOE, (:>))
import Keiki.Core (BoolAlg, HsPred, RegFile)
import Keiki.Core qualified as Keiki
import Keiki.Render.Mermaid (edgeInputName)
import Keiro.Command
import Keiro.EventStream (EventStream (..), SnapshotPolicy (..))
import Keiro.EventStream.Validate (ValidatedEventStream, unvalidated)
import Keiro.Inspection.Aggregate (hydrationSourceJson)
import Keiro.Inspection.Cursor
import Keiro.Prelude hiding ((.=))
import Keiro.ProcessManager (DomainProcessManager, ProcessManager, deterministicCommandIdProbes, firstExistingEventId)
import Keiro.ProcessManager.Provenance
import Keiro.ProcessManager.Reaction (ReactiveProcessManager)
import Keiro.Stream (Stream, StreamCategory, categoryName, categoryText)
import Keiro.Timer
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Read (getStream, listStreams, lookupStreamName, readStreamForward)
import Kiroku.Store.Types hiding (categoryName)

-- | Select the persisted evidence family explicitly; state inspection alone
-- does not authorize interpreting legacy metadata as modern reaction evidence.
data ProcessManagerProvenanceMode = ReadLegacyProvenance | NoReactionProvenance
  deriving stock (Generic, Eq, Show)

-- | Encoders and membership mappings belong to the application. A custom
-- mapping must round-trip through streamFor and resolveStreamName.
data ProcessManagerInspector phi rs s ci co = ProcessManagerInspector
  { name :: !Text,
    eventStream :: !(ValidatedEventStream phi rs s ci co),
    streamFor :: !(Text -> Stream (EventStream phi rs s ci co)),
    category :: !(StreamCategory (EventStream phi rs s ci co)),
    correlationOf :: !(StreamName -> Maybe Text),
    displayState :: !(s -> RegFile rs -> Value),
    stateLabel :: !(s -> Text),
    enabledInputs :: !(s -> [EnabledInput]),
    provenanceMode :: !ProcessManagerProvenanceMode
  }
  deriving stock (Generic)

data EnabledInput = EnabledInput {input :: !(Maybe Text), targetState :: !Text}
  deriving stock (Generic, Eq, Show)

mkInspector :: (Show s) => ProcessManagerProvenanceMode -> Text -> ValidatedEventStream (HsPred rs ci) rs s ci co -> (Text -> Stream (EventStream (HsPred rs ci) rs s ci co)) -> StreamCategory (EventStream (HsPred rs ci) rs s ci co) -> (s -> RegFile rs -> Value) -> ProcessManagerInspector (HsPred rs ci) rs s ci co
mkInspector provenanceMode name definition streamFor category displayState =
  ProcessManagerInspector
    name
    definition
    streamFor
    category
    (\(StreamName raw) -> Text.stripPrefix (categoryText category <> "-") raw)
    displayState
    (Text.pack . show)
    ( \state ->
        [ EnabledInput (edgeInputName edge) (Text.pack (show (Keiki.target edge)))
        | edge <- Keiki.edgesOut (unvalidated definition ^. #transducer) state,
          Keiki.mode edge == Keiki.Live
        ]
    )
    provenanceMode

processManagerInspector :: (Show s) => ProcessManager input (HsPred rs ci) rs s ci co tPhi tRs tS tCi tCo -> StreamCategory (EventStream (HsPred rs ci) rs s ci co) -> (s -> RegFile rs -> Value) -> ProcessManagerInspector (HsPred rs ci) rs s ci co
processManagerInspector manager = mkInspector ReadLegacyProvenance (manager ^. #name) (manager ^. #eventStream) (manager ^. #streamFor)

domainProcessManagerInspector :: (Show s) => DomainProcessManager input (HsPred rs ci) rs s ci co tPhi tRs tS tCi tCo rej noOp -> StreamCategory (EventStream (HsPred rs ci) rs s ci co) -> (s -> RegFile rs -> Value) -> ProcessManagerInspector (HsPred rs ci) rs s ci co
domainProcessManagerInspector manager = mkInspector ReadLegacyProvenance (manager ^. #name) (manager ^. #eventStream) (manager ^. #streamFor)

-- | State inspection for the surviving reaction API. Persisted events do not
-- retain enough input/source information to infer modern dispatch provenance.
reactiveProcessManagerInspector :: (Show s) => ReactiveProcessManager input (HsPred rs ci) rs s ci co tPhi tRs tS tCi tCo rej noOp -> StreamCategory (EventStream (HsPred rs ci) rs s ci co) -> (s -> RegFile rs -> Value) -> ProcessManagerInspector (HsPred rs ci) rs s ci co
reactiveProcessManagerInspector manager = mkInspector NoReactionProvenance (manager ^. #name) (manager ^. #sagaHandler . #eventStream) (manager ^. #streamFor)

data SomeProcessManagerInspector where
  SomeProcessManagerInspector :: (BoolAlg phi (RegFile rs, ci), Eq co) => ProcessManagerInspector phi rs s ci co -> SomeProcessManagerInspector

type ProcessManagerInspectors = Map Text SomeProcessManagerInspector

-- | Mounted metadata, independent of persisted instances. Names are catalog
-- lookup keys, including aliases. Timer capability is not currently declared.
data ProcessManagerTypeSummary = ProcessManagerTypeSummary
  { name :: !Text,
    category :: !Text,
    snapshotsConfigured :: !Bool,
    snapshotPolicy :: !Text,
    timersConfigured :: !(Maybe Bool)
  }
  deriving stock (Eq, Show, Generic)

-- | Enumerate only descriptor fields; never evaluate a policy predicate.
-- Lookup names retain the map's ascending order.
listProcessManagerTypes :: ProcessManagerInspectors -> [ProcessManagerTypeSummary]
listProcessManagerTypes = map describe . Map.toAscList
  where
    describe (lookupName, SomeProcessManagerInspector inspector) =
      case unvalidated inspector.eventStream of
        EventStream {stateCodec = codec, snapshotPolicy = policy} ->
          ProcessManagerTypeSummary lookupName (categoryText inspector.category) (isJust codec) (policyLabel policy) Nothing

-- Keep the constructor match separate from the existential map traversal.
-- GHC 9.12.4 otherwise mislabels Custom as Never in the optimized callback
-- sentinel test; OPAQUE also prevents worker/wrapper from removing this boundary.
{-# OPAQUE policyLabel #-}
policyLabel :: SnapshotPolicy s -> Text
policyLabel = \case
  Never -> "never"
  Every n -> "every:" <> Text.pack (show n)
  OnTerminal -> "on_terminal"
  Custom _ -> "custom"

processManagerTypeJson :: ProcessManagerTypeSummary -> Value
processManagerTypeJson summary =
  object
    [ "name" .= summary.name,
      "category" .= summary.category,
      "snapshots_configured" .= summary.snapshotsConfigured,
      "snapshot_policy" .= summary.snapshotPolicy,
      "timers_configured" .= summary.timersConfigured
    ]

-- Direct encoding avoids building an intermediate JSON tree on polling reads.
-- Keep its meaning equal to processManagerTypeJson (covered by parity tests).
instance Aeson.ToJSON ProcessManagerTypeSummary where
  toJSON = processManagerTypeJson
  toEncoding summary =
    Encoding.pairs
      ( "category" .= summary.category
          <> "name" .= summary.name
          <> "snapshot_policy" .= summary.snapshotPolicy
          <> "snapshots_configured" .= summary.snapshotsConfigured
          <> "timers_configured" .= summary.timersConfigured
      )

data DispatchWitness = EventPresent | NotObserved deriving stock (Generic, Eq, Show)

data DispatchedCommandStatus = DispatchedCommandStatus
  {emitIndex :: !Int, targetStreamName :: !StreamName, eventId :: !EventId, witness :: !DispatchWitness}
  deriving stock (Generic, Eq, Show)

data LastReaction = LastReaction
  { eventId :: !EventId,
    eventType :: !EventType,
    streamVersion :: !StreamVersion,
    globalPosition :: !GlobalPosition,
    recordedAt :: !UTCTime,
    provenance :: !(Maybe ReactionProvenance),
    sourceStreamName :: !(Maybe StreamName),
    dispatchedCommands :: ![DispatchedCommandStatus]
  }
  deriving stock (Generic, Eq, Show)

data InstanceViewRequest = InstanceViewRequest {timerPageSize :: !Int}
  deriving stock (Generic, Eq, Show)

data ProcessManagerInspectionError
  = ProcessManagerHydrationFailed !CommandError
  | InvalidInstanceViewRequest !Text
  | ProcessManagerTimerReadFailed !PendingTimerReadError
  deriving stock (Generic, Eq, Show)

data ProcessManagerInstanceView = ProcessManagerInstanceView
  { name :: !Text,
    correlationId :: !Text,
    journalStreamName :: !StreamName,
    streamVersion :: !StreamVersion,
    observedHeadVersion :: !StreamVersion,
    createdAt :: !UTCTime,
    deletedAt :: !(Maybe UTCTime),
    truncateBefore :: !StreamVersion,
    hydration :: !(Maybe HydrationSource),
    stateLabel :: !(Maybe Text),
    state :: !(Maybe Value),
    lastReaction :: !(Maybe LastReaction),
    waitingFor :: ![EnabledInput],
    pendingTimers :: !PendingTimerPage,
    observedAt :: !UTCTime
  }
  deriving stock (Generic, Eq, Show)

physicalName :: ProcessManagerInspector phi rs s ci co -> Text -> StreamName
physicalName inspector correlation = (unvalidated inspector.eventStream ^. #resolveStreamName) (inspector.streamFor correlation)

inspectProcessManagerInstance :: (IOE :> es, Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) => ProcessManagerInspector phi rs s ci co -> InstanceViewRequest -> Text -> Eff es (Either ProcessManagerInspectionError (Maybe ProcessManagerInstanceView))
inspectProcessManagerInstance inspector request correlation
  | Text.null (Text.strip correlation) = pure (Left (InvalidInstanceViewRequest "correlation id must not be blank"))
  | Left err <- validatePendingTimerRequest timerFilter timerRequest = pure (Left (ProcessManagerTimerReadFailed err))
  | otherwise = do
      metadata <- getStream streamName
      case metadata of
        Nothing -> pure (Right Nothing)
        Just info -> do
          result <-
            if isJust info.deletedAt
              then pure (Right Nothing)
              else fmap (fmap Just) (hydrateWithSource inspectionRunCommandOptions (unvalidated inspector.eventStream) (inspector.streamFor correlation))
          case result of
            Left err -> pure (Left (ProcessManagerHydrationFailed err))
            Right hydrated -> do
              let version = maybe info.version (\(value, _) -> value ^. #streamVersion) hydrated
              lastReaction <- case hydrated of
                Just _ | version > StreamVersion 0 -> do
                  let StreamVersion n = version
                  events <- readStreamForward streamName (StreamVersion (n - 1)) 1
                  case Vector.toList events of
                    event : _ | event.streamVersion == version -> Just <$> observeReaction event
                    _ -> pure Nothing
                _ -> pure Nothing
              timers <- findPendingTimers timerFilter timerRequest
              pure $ case timers of
                Left err -> Left (ProcessManagerTimerReadFailed err)
                Right page ->
                  Right
                    ( Just
                        ( ProcessManagerInstanceView
                            inspector.name
                            correlation
                            streamName
                            version
                            info.version
                            info.createdAt
                            info.deletedAt
                            info.truncateBefore
                            (snd <$> hydrated)
                            (fmap (\(value, _) -> inspector.stateLabel (value ^. #state)) hydrated)
                            (fmap (\(value, _) -> inspector.displayState (value ^. #state) (value ^. #registers)) hydrated)
                            lastReaction
                            (maybe [] (\(value, _) -> inspector.enabledInputs (value ^. #state)) hydrated)
                            page
                            page.observedAt
                        )
                    )
  where
    streamName = physicalName inspector correlation
    timerFilter = PendingTimerFilter (Just inspector.name) (Just correlation)
    timerRequest = PendingTimerPageRequest request.timerPageSize Nothing
    observeReaction event = case inspector.provenanceMode of
      NoReactionProvenance -> pure (LastReaction event.eventId event.eventType event.streamVersion event.globalPosition event.createdAt Nothing Nothing [])
      ReadLegacyProvenance -> observeLegacyReaction event
    observeLegacyReaction event = do
      let provenance = event.metadata >>= (\case Aeson.Object fields -> KeyMap.lookup "keiro.pm" fields; _ -> Nothing) >>= decodeReactionProvenance
      sourceName <- maybe (pure Nothing) (lookupStreamName . (.sourceStreamId)) provenance
      statuses <- maybe (pure []) (\record -> traverse (probe record) record.dispatched) provenance
      pure (LastReaction event.eventId event.eventType event.streamVersion event.globalPosition event.createdAt provenance sourceName statuses)
    probe record (index, target) = do
      let probes = deterministicCommandIdProbes inspector.name correlation record.sourceEventId index
      existing <- firstExistingEventId inspectionRunCommandOptions target probes
      pure (DispatchedCommandStatus index target (fromMaybe (NonEmpty.head probes) existing) (maybe NotObserved (const EventPresent) existing))

data InstanceListRequest = InstanceListRequest {pageSize :: !Int, after :: !(Maybe InspectionCursor)}
  deriving stock (Generic, Eq, Show)

data InstanceListError = InvalidInstancePageSize !Int | InvalidInstanceCursor !CursorError | InstanceResultTooLarge
  deriving stock (Generic, Eq, Show)

data InstanceSummary = InstanceSummary
  { correlationId :: !Text,
    streamName :: !StreamName,
    streamVersion :: !StreamVersion,
    createdAt :: !UTCTime,
    deletedAt :: !(Maybe UTCTime),
    truncateBefore :: !StreamVersion
  }
  deriving stock (Generic, Eq, Show)

data InstancePage = InstancePage {items :: ![InstanceSummary], skipped :: !Int, next :: !(Maybe InspectionCursor)}
  deriving stock (Generic, Eq, Show)

instanceListCursorKind :: Text
instanceListCursorKind = "pm-list"

-- | Exactly one raw store page. Rejected membership rows count as examined;
-- even an empty displayed page advances from the last examined raw row.
listProcessManagerInstances :: (Store :> es) => ProcessManagerInspector phi rs s ci co -> InstanceListRequest -> Eff es (Either InstanceListError InstancePage)
listProcessManagerInstances inspector request = case parameters of
  Left err -> pure (Left err)
  Right (after, limit) -> do
    raw <- listStreams (Just (categoryName inspector.category)) (Just (categoryText inspector.category <> "-")) after limit
    let examined = Vector.toList (Vector.take request.pageSize raw)
        items = mapMaybe summary examined
        continuation =
          if Vector.length raw > request.pageSize
            then case reverse examined of
              row : _ -> maybe (Left InstanceResultTooLarge) (Right . Just) (encodeCursorBounded instanceListCursorKind (inspector.name, categoryText inspector.category, nameText row.name))
              [] -> Right Nothing
            else Right Nothing
    pure (InstancePage items (length examined - length items) <$> continuation)
  where
    parameters
      | request.pageSize < 1 || request.pageSize > 500 = Left (InvalidInstancePageSize request.pageSize)
      | otherwise = do
          limit <- either (const (Left (InvalidInstancePageSize request.pageSize))) Right (mkBrowsePageSize (request.pageSize + 1))
          after <- traverse decodeAfter request.after
          pure (after, limit)
    decodeAfter cursor = do
      (name, category, lastName) <- either (Left . InvalidInstanceCursor) Right (decodeCursor instanceListCursorKind cursor)
      if name == inspector.name && category == categoryText inspector.category && Text.isPrefixOf (category <> "-") lastName
        then Right (StreamName lastName)
        else Left (InvalidInstanceCursor (MalformedCursor "cursor belongs to another process-manager inspector"))
    summary info = do
      correlation <- inspector.correlationOf info.name
      if Text.null (Text.strip correlation) || physicalName inspector correlation /= info.name
        then Nothing
        else Just (InstanceSummary correlation info.name info.version info.createdAt info.deletedAt info.truncateBefore)

-- | Library-owned representation shared by transport adapters.
processManagerViewJson :: ProcessManagerInstanceView -> Value
processManagerViewJson view =
  object
    [ "name" .= view.name,
      "correlation_id" .= view.correlationId,
      "journal_stream_name" .= nameText view.journalStreamName,
      "stream_version" .= versionNumber view.streamVersion,
      "state_version" .= fmap (const (versionNumber view.streamVersion)) view.state,
      "observed_head_version" .= versionNumber view.observedHeadVersion,
      "created_at" .= view.createdAt,
      "deleted_at" .= view.deletedAt,
      "truncate_before" .= versionNumber view.truncateBefore,
      "observed_at" .= view.observedAt,
      "hydration" .= fmap hydrationSourceJson view.hydration,
      "state_label" .= view.stateLabel,
      "state" .= view.state,
      "last_reaction" .= fmap reactionJson view.lastReaction,
      "pending_timers" .= pendingTimerPageJson view.pendingTimers,
      "waiting_for" .= [object ["input" .= candidate.input, "target_state" .= candidate.targetState] | candidate <- view.waitingFor]
    ]

reactionJson :: LastReaction -> Value
reactionJson reaction =
  object
    [ "event_id" .= eventText reaction.eventId,
      "event_type" .= eventTypeText reaction.eventType,
      "stream_version" .= versionNumber reaction.streamVersion,
      "global_position" .= positionNumber reaction.globalPosition,
      "recorded_at" .= reaction.recordedAt,
      "provenance_status" .= (case reaction.provenance of Nothing -> "unavailable"; Just p | p.truncated -> "truncated"; _ -> "available" :: Text),
      "provenance" .= fmap provenanceJson reaction.provenance
    ]
  where
    provenanceJson record =
      object
        [ "family" .= record.family,
          "truncated" .= record.truncated,
          "source_event_id" .= eventText record.sourceEventId,
          "source_global_position" .= positionNumber record.sourceGlobalPosition,
          "source_stream_id" .= streamNumber record.sourceStreamId,
          "source_stream_name" .= fmap nameText reaction.sourceStreamName,
          "dispatched_commands"
            .= [ object
                   [ "emit_index" .= status.emitIndex,
                     "target_stream_name" .= nameText status.targetStreamName,
                     "event_id" .= eventText status.eventId,
                     "witness" .= (case status.witness of EventPresent -> "event_present"; NotObserved -> "not_observed" :: Text)
                   ]
               | status <- reaction.dispatchedCommands
               ],
          "timer_ids" .= [UUID.toText uuid | TimerId uuid <- record.timerIds]
        ]

instancePageJson :: InstancePage -> Value
instancePageJson page =
  object
    ( [ "items"
          .= [ object
                 [ "correlation_id" .= item.correlationId,
                   "stream_name" .= nameText item.streamName,
                   "stream_version" .= versionNumber item.streamVersion,
                   "created_at" .= item.createdAt,
                   "deleted_at" .= item.deletedAt,
                   "truncate_before" .= versionNumber item.truncateBefore
                 ]
             | item <- page.items
             ],
        "skipped" .= page.skipped
      ]
        <> maybe [] (\cursor -> ["next_cursor" .= cursor.cursorText]) page.next
    )

nameText :: StreamName -> Text
nameText (StreamName value) = value

versionNumber :: StreamVersion -> Int64
versionNumber (StreamVersion value) = value

positionNumber :: GlobalPosition -> Int64
positionNumber (GlobalPosition value) = value

streamNumber :: StreamId -> Int64
streamNumber (StreamId value) = value

eventText :: EventId -> Text
eventText (EventId value) = UUID.toText value

eventTypeText :: EventType -> Text
eventTypeText (EventType value) = value