packages feed

keiro-0.2.0.0: src/Keiro/Workflow/Types.hs

{-# OPTIONS_GHC -Wno-partial-fields #-}

{- | Core types and the journal codec for the durable workflow runtime.

A /durable workflow/ is an ordinary @effectful@ computation whose side
effects are recorded ("journaled") at named checkpoints so the computation
can be paused and resumed across crashes without re-running work that
already happened. The journal is a kiroku stream named
@wf:\<workflow-name\>-\<workflow-id\>@ ('workflowStreamName') holding one
'StepRecorded' event per executed step and a terminal 'WorkflowCompleted'
event.

This module owns the contracts every sibling plan of the v2 MasterPlan
builds on: the identity newtypes ('WorkflowName', 'WorkflowId',
'StepName'), the journal event sum ('WorkflowJournalEvent') and its
'Codec' ('workflowJournalCodec'), the accumulated-state alias
('WorkflowState'), the run outcome ('WorkflowOutcome'), and the
reserved step-name conventions ('completedStepName' plus the
@sleep:@\/@awk:@\/@child:@ prefixes).
-}
module Keiro.Workflow.Types (
    -- * Identity
    WorkflowName (..),
    WorkflowId (..),
    WorkflowIdentityError (..),
    mkWorkflowName,
    mkWorkflowId,
    StepName (..),
    PatchId (..),

    -- * Stream naming
    workflowStreamName,
    workflowGenerationStreamName,

    -- * Journal events and codec
    WorkflowJournalEvent (..),
    workflowJournalCodec,

    -- * Accumulated state and run outcome
    WorkflowState,
    WorkflowOutcome (..),

    -- * Reserved step names
    completedStepName,
    cancelledStepName,
    failedStepName,
    continuedAsNewStepName,
    continueSeedStepName,
    sleepStepPrefix,
    awakeableStepPrefix,
    awakeableAllocStepPrefix,
    childStepPrefix,
    patchStepPrefix,
    patchSetStepName,
    patchStepName,
)
where

import Data.Aeson qualified as Aeson
import Data.Aeson.Types (parseEither)
import Data.Bifunctor (first)
import Data.Map.Strict (Map)
import Data.Text qualified as Text
import Keiro.Codec (Codec (..))
import Keiro.Prelude
import Kiroku.Store.Types (EventType (..), StreamName (..))

{- | The stable name of a workflow /definition/ (for example
@"orderFulfillment"@). Part of the journal stream name and of every
deterministic journal-event id, so it must not change for a given definition
across deploys. Prefer 'mkWorkflowName' for new code; the raw constructor is
kept for compatibility and can create ambiguous journal stream names if the
text contains structural separators such as @-@, @:@, or @#@.
-}
newtype WorkflowName = WorkflowName {unWorkflowName :: Text}
    deriving stock (Eq, Ord, Show, Generic)

{- | The id of a single workflow /instance/ (a UUID-as-text or any stable
caller-supplied string). Combined with the 'WorkflowName' it identifies the
instance's journal stream. Prefer 'mkWorkflowId' for new code; the raw
constructor is kept for compatibility and can create ambiguous journal stream
names if the text contains structural separators such as @:@ or @#@.
-}
newtype WorkflowId = WorkflowId {unWorkflowId :: Text}
    deriving stock (Eq, Ord, Show, Generic)

data WorkflowIdentityError
    = WorkflowNameEmpty
    | WorkflowNameInvalidChar !Char !Text
    | WorkflowIdEmpty
    | WorkflowIdInvalidChar !Char !Text
    deriving stock (Eq, Show, Generic)

{- | Validated constructor for workflow names.

Rejects empty names and the structural separators @:@ (stream prefix), @-@
(name/id boundary), and @#@ (generation suffix). Prefer camelCase compound
names such as @"orderFulfillment"@; the raw 'WorkflowName' constructor remains
available for compatibility.
-}
mkWorkflowName :: Text -> Either WorkflowIdentityError WorkflowName
mkWorkflowName raw
    | Text.null raw = Left WorkflowNameEmpty
    | Just c <- Text.find (`elem` ([':', '-', '#'] :: [Char])) raw =
        Left (WorkflowNameInvalidChar c raw)
    | otherwise = Right (WorkflowName raw)

{- | Validated constructor for workflow ids.

Rejects empty ids and the structural separators @:@ and @#@. The @-@ character
is permitted so UUID-style ids remain valid; with a validated, hyphen-free
workflow name, the @wf:\<name\>-\<id\>@ boundary is the first @-@.
-}
mkWorkflowId :: Text -> Either WorkflowIdentityError WorkflowId
mkWorkflowId raw
    | Text.null raw = Left WorkflowIdEmpty
    | Just c <- Text.find (`elem` ([':', '#'] :: [Char])) raw =
        Left (WorkflowIdInvalidChar c raw)
    | otherwise = Right (WorkflowId raw)

{- | The label identifying a step within a workflow. Replay matches on this
label, not on source position, so reordering code between deploys does not
corrupt an in-flight workflow.
-}
newtype StepName = StepName {unStepName :: Text}
    deriving stock (Eq, Ord, Show, Generic)

{- | The stable identifier of a /patch/ (EP-49) — a guarded, cross-cutting change
to a running workflow's logic. The author chooses an opaque, never-reused string
(for example @"fraud-check-v2"@). A patch decision is journaled under the key
@patch:\<patchId\>@, so the id must not contain the structural @:@ in a way that
makes the prefix boundary ambiguous, mirroring the 'sleepStepPrefix' caveat.
-}
newtype PatchId = PatchId {unPatchId :: Text}
    deriving stock (Eq, Ord, Show, Generic)

{- | The journal stream name for a workflow instance:
@wf:\<name\>-\<id\>@.

The @:@ and @-@ characters are structural, and generation streams reserve @#@.
Use 'mkWorkflowName' and 'mkWorkflowId' for separator-safe identities. Raw
unvalidated names containing @-@ can make two distinct @(name, id)@ pairs share
one journal stream (the v1 process-manager @pm:\<name\>-\<correlationId\>@
convention carries the same caveat).
-}
workflowStreamName :: WorkflowName -> WorkflowId -> StreamName
workflowStreamName (WorkflowName name) (WorkflowId wid) =
    StreamName ("wf:" <> name <> "-" <> wid)

{- | The PHYSICAL journal stream for a given /generation/ of a logical
workflow (EP-48 continue-as-new).

Generation 0 keeps the legacy name @wf:\<name\>-\<id\>@ — so already-running,
never-rotated workflows are byte-for-byte unchanged and need zero data
migration — while generation @g > 0@ appends a @#\<g\>@ suffix. The @#@ is a
new structural separator, distinct from the @:@ and @-@ that
'workflowStreamName' already reserves, so it cannot collide with an existing
boundary. The /logical/ identity @('WorkflowName', 'WorkflowId')@ the author
and the resume registry see is stable across rotations; only the physical
stream the journal lives on rotates underneath it.
-}
workflowGenerationStreamName :: WorkflowName -> WorkflowId -> Int -> StreamName
workflowGenerationStreamName name wid gen
    | gen <= 0 = workflowStreamName name wid
    | otherwise =
        let StreamName base = workflowStreamName name wid
         in StreamName (base <> "#" <> Text.pack (show gen))

{- | The events written to a workflow journal.

* 'StepRecorded' — a step (identified by 'stepName') ran and produced
  'result' (the step's value encoded as JSON) at 'recordedAt'. The
  suspension primitives journal their completions as ordinary
  'StepRecorded' events whose 'stepName' carries a reserved prefix
  ('sleepStepPrefix', 'awakeableStepPrefix', 'childStepPrefix'); the
  replay loop stays uniform because there is no separate event type.
* 'WorkflowCompleted' — the terminal marker appended once the whole
  computation has returned.
* 'WorkflowCancelled' — a terminal marker (EP-43) written to a /child/
  workflow's journal by @cancelChild@; a run whose journal carries it
  short-circuits without executing further steps and reports
  'Keiro.Workflow.Types.Cancelled'.
* 'WorkflowFailed' — a terminal failure marker (carries a 'reason'),
  available for a worker to record a permanently-failed run.

These last two are purely additive within @schemaVersion = 1@ (a new wire
tag added to 'workflowJournalCodec''s 'eventTypes'; old journals never carry
it, so no upcaster is needed). The codec is deliberately the single place to
extend.
-}
data WorkflowJournalEvent
    = StepRecorded {stepName :: !Text, result :: !Aeson.Value, recordedAt :: !UTCTime}
    | WorkflowCompleted {recordedAt :: !UTCTime}
    | WorkflowCancelled {recordedAt :: !UTCTime}
    | WorkflowFailed {reason :: !Text, recordedAt :: !UTCTime}
    | {- | Terminal-for-this-generation rotation marker (EP-48). 'generation'
      is the NEXT generation this rotation opens. Additive within
      @schemaVersion = 1@: old journals never carry the
      @"WorkflowContinuedAsNew"@ tag, so no upcaster is needed.
      -}
      WorkflowContinuedAsNew {generation :: !Int, recordedAt :: !UTCTime}
    deriving stock (Eq, Show, Generic)

{- | The 'Codec' that serializes 'WorkflowJournalEvent' to and from the
JSON payloads stored on the journal stream. Schema version 1; no
upcasters. Each payload is self-describing (it carries a @"kind"@
discriminator) so 'decode' can reconstruct the constructor from the
payload alone.
-}
workflowJournalCodec :: Codec WorkflowJournalEvent
workflowJournalCodec =
    Codec
        { eventTypes = EventType "StepRecorded" :| [EventType "WorkflowCompleted", EventType "WorkflowCancelled", EventType "WorkflowFailed", EventType "WorkflowContinuedAsNew"]
        , eventType = \case
            StepRecorded{} -> EventType "StepRecorded"
            WorkflowCompleted{} -> EventType "WorkflowCompleted"
            WorkflowCancelled{} -> EventType "WorkflowCancelled"
            WorkflowFailed{} -> EventType "WorkflowFailed"
            WorkflowContinuedAsNew{} -> EventType "WorkflowContinuedAsNew"
        , schemaVersion = 1
        , encode = encodeJournalEvent
        , decode = decodeJournalEvent
        , upcasters = []
        }

encodeJournalEvent :: WorkflowJournalEvent -> Aeson.Value
encodeJournalEvent = \case
    StepRecorded name r t ->
        Aeson.object
            [ "kind" Aeson..= ("StepRecorded" :: Text)
            , "stepName" Aeson..= name
            , "result" Aeson..= r
            , "recordedAt" Aeson..= t
            ]
    WorkflowCompleted t ->
        Aeson.object
            [ "kind" Aeson..= ("WorkflowCompleted" :: Text)
            , "recordedAt" Aeson..= t
            ]
    WorkflowCancelled t ->
        Aeson.object
            [ "kind" Aeson..= ("WorkflowCancelled" :: Text)
            , "recordedAt" Aeson..= t
            ]
    WorkflowFailed r t ->
        Aeson.object
            [ "kind" Aeson..= ("WorkflowFailed" :: Text)
            , "reason" Aeson..= r
            , "recordedAt" Aeson..= t
            ]
    WorkflowContinuedAsNew g t ->
        Aeson.object
            [ "kind" Aeson..= ("WorkflowContinuedAsNew" :: Text)
            , "generation" Aeson..= g
            , "recordedAt" Aeson..= t
            ]

decodeJournalEvent :: EventType -> Aeson.Value -> Either Text WorkflowJournalEvent
decodeJournalEvent (EventType tag) value = first Text.pack (parseEither parser value)
  where
    parser = Aeson.withObject "WorkflowJournalEvent" $ \o -> do
        case tag of
            "StepRecorded" ->
                StepRecorded <$> o Aeson..: "stepName" <*> o Aeson..: "result" <*> o Aeson..: "recordedAt"
            "WorkflowCompleted" ->
                WorkflowCompleted <$> o Aeson..: "recordedAt"
            "WorkflowCancelled" ->
                WorkflowCancelled <$> o Aeson..: "recordedAt"
            "WorkflowFailed" ->
                WorkflowFailed <$> o Aeson..: "reason" <*> o Aeson..: "recordedAt"
            "WorkflowContinuedAsNew" ->
                WorkflowContinuedAsNew <$> o Aeson..: "generation" <*> o Aeson..: "recordedAt"
            other ->
                fail ("unknown workflow journal event type: " <> Text.unpack other)

{- | The accumulated step state a running workflow holds in memory: a map
from step name to that step's recorded JSON result. This is exactly the
value the journal carries, folded into a map.

This alias is the integration contract EP-41 (snapshots) consumes; it must
remain a @Map Text Value@ whose keys are dynamic step-name strings.
-}
type WorkflowState = Map Text Aeson.Value

{- | The result of running a workflow.

* 'Completed' — the computation ran to its end and a 'WorkflowCompleted'
  event was journaled.
* 'Suspended' — the computation paused at an unresolved @awaitStep@; a
  wake source was armed and the run will be resumed later (by EP-42's
  resume worker) once the awaited result is journaled.

* 'Cancelled' — the run's journal carried a 'WorkflowCancelled' marker (a
  child cancelled by its parent, EP-43), so the handler short-circuited and
  executed nothing further. Distinct from 'Suspended' (which will resume) and
  'Completed' (which finished its work).
* 'Failed' — the run's journal carried a 'WorkflowFailed' marker, so the
  handler short-circuited and executed nothing further.
-}
data WorkflowOutcome a
    = Completed a
    | Suspended
    | Cancelled
    | Failed
    | {- | EP-48: the run rotated onto a fresh journal generation via
      @continueAsNew@; a subsequent run/resume of the same logical id
      continues from the carried seed. Distinct from 'Suspended' (a wake
      source is pending) and 'Completed' (the workflow is done): a rotated
      workflow is still unfinished, so the resume worker re-invokes it and it
      proceeds on the new generation.
      -}
      ContinuedAsNew
    deriving stock (Eq, Show, Functor)

{- | The reserved step name written (as a 'WorkflowCompleted' journal event
and an index row) when a workflow finishes. The discovery query in
"Keiro.Workflow.Schema" ('Keiro.Workflow.Schema.findUnfinishedWorkflowIds')
treats a workflow lacking a row with this step name as unfinished, so the
literal here must match the literal in that SQL.
-}
completedStepName :: Text
completedStepName = "__workflow_completed__"

{- | The reserved step name written (as a 'WorkflowCancelled' journal event and
an index row) when a workflow is cancelled. The replay handler short-circuits a
run that has a row with this step name, and
'Keiro.Workflow.Schema.findUnfinishedWorkflowIds' treats it (like
'completedStepName') as a terminal marker, so a cancelled workflow drops out of
resume discovery. The literal must match the literal in that SQL.
-}
cancelledStepName :: Text
cancelledStepName = "__workflow_cancelled__"

{- | The reserved step name written (as a 'WorkflowFailed' journal event and an
index row) when a workflow is recorded as permanently failed.
-}
failedStepName :: Text
failedStepName = "__workflow_failed__"

{- | The reserved step name written (as a 'WorkflowContinuedAsNew' journal event
and an index row) when a workflow's generation rotates via @continueAsNew@
(EP-48). Its presence on a generation's index row makes that generation terminal
/for itself/ (a rotated-away generation drops out of any per-generation
"unfinished" check); the /current/ generation is the one with no terminal marker
row. Distinct from 'completedStepName'/'cancelledStepName': a rotated workflow is
NOT finished — its work continues on the next generation —
so 'Keiro.Workflow.Schema.findUnfinishedWorkflowIds' deliberately scopes its
terminal-marker check to the current (MAX) generation and does not treat
'continuedAsNewStepName' as terminal for the logical workflow.
-}
continuedAsNewStepName :: Text
continuedAsNewStepName = "__workflow_continued_as_new__"

{- | The reserved step name under which @continueAsNew@ (EP-48) carries the
author's seed value into the next generation. On rotation the runtime appends a
single @StepRecorded continueSeedStepName seedJson@ to the next generation's
journal (and snapshots it); the next run's body reads it back via @restoreSeed@
— an ordinary journaled @step@ — so the carried state is restored without
re-running anything. The leading/trailing @__@ marks it reserved, like the
other @__workflow_*__@ names.
-}
continueSeedStepName :: Text
continueSeedStepName = "__workflow_seed__"

{- | Reserved step-name prefix EP-39 uses to journal a durable @sleep@'s
completion. Integration contract: EP-39 must use exactly this string.
-}
sleepStepPrefix :: Text
sleepStepPrefix = "sleep:"

{- | Reserved step-name prefix EP-40 uses to journal an awakeable's
completion. Integration contract: EP-40 must use exactly this string.
-}
awakeableStepPrefix :: Text
awakeableStepPrefix = "awk:"

{- | Reserved step-name prefix used to journal the random id allocated for an
awakeable. The id is random for new allocations but replay-stable because it is
recorded under @awkid:\<label\>@ before the workflow awaits @awk:\<uuid\>@.
-}
awakeableAllocStepPrefix :: Text
awakeableAllocStepPrefix = "awkid:"

{- | Reserved step-name prefix EP-43 uses to journal a child workflow's
completion. Integration contract: EP-43 must use exactly this string.
-}
childStepPrefix :: Text
childStepPrefix = "child:"

{- | Reserved step-name prefix EP-49 uses to journal a 'patch' decision. A patch
decision is journaled as an ordinary 'StepRecorded' event whose 'stepName' is
@'patchStepPrefix' <> 'unPatchId' pid@ and whose 'result' is the JSON 'Bool'
branch decision, so the replay loop and the step index stay uniform — no new
journal-event constructor is added.
-}
patchStepPrefix :: Text
patchStepPrefix = "patch:"

{- | Reserved step name under which a workflow generation records the patch ids
that were active when that generation first started.
-}
patchSetStepName :: Text
patchSetStepName = "__workflow_patches__"

-- | The journal key a patch decision is recorded under: @patch:\<patchId\>@.
patchStepName :: PatchId -> Text
patchStepName (PatchId pid) = patchStepPrefix <> pid