packages feed

keiro-ops-0.12.0.0: src/Keiro/Ops/Workflow.hs

module Keiro.Ops.Workflow
  ( AwakeableCommand (..),
    Command (..),
    GcOptions (..),
    InspectOptions (..),
    ListOptions (..),
    PayloadArg (..),
    ResumeHook,
    ResumeOptions (..),
    WorkflowRef (..),
    commandParser,
    commandParserWithResume,
    isMutation,
    runCommand,
    runCommandWithResume,
  )
where

import Data.Aeson (Value, object, (.=))
import Data.Aeson qualified as Aeson
import Data.Aeson.Types qualified as AesonTypes
import Data.Int (Int64)
import Data.List.NonEmpty qualified as NonEmpty
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (catMaybes)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text.Encoding
import Data.Time (NominalDiffTime, UTCTime, getCurrentTime)
import Data.UUID (UUID)
import Data.UUID qualified as UUID
import Data.Vector qualified as Vector
import Effectful (Eff, IOE, (:>))
import Effectful.Error.Static (Error)
import Keiro.Codec (decodeRecorded)
import Keiro.Ops.Env (OpsEnv (..), OutputMode (..))
import Keiro.Ops.Parse (durationReader, nonNegativeReader, positiveIntReader)
import Keiro.Ops.Render
import Keiro.Workflow.Awakeable (AwakeableId (..), cancelAwakeable, signalAwakeable)
import Keiro.Workflow.Awakeable.Schema qualified as Awakeable
import Keiro.Workflow.Child.Schema qualified as Child
import Keiro.Workflow.Gc qualified as Gc
import Keiro.Workflow.Instance qualified as Instance
import Keiro.Workflow.Resume
  ( ResumeSummary (..),
    WorkflowRegistry,
    WorkflowResumeOptions,
    resumeWorkflowsOnceUpTo,
  )
import Keiro.Workflow.Schema qualified as WorkflowSchema
import Keiro.Workflow.Types
  ( WorkflowId (..),
    WorkflowJournalEvent (..),
    WorkflowName (..),
    awakeableAllocStepPrefix,
    cancelledStepName,
    completedStepName,
    continuedAsNewStepName,
    failedStepName,
    workflowGenerationStreamName,
    workflowJournalCodec,
  )
import Kiroku.Store.Effect (Store, runStoreIO)
import Kiroku.Store.Error (StoreError)
import Kiroku.Store.Read qualified as StoreRead
import Kiroku.Store.Types
import Options.Applicative hiding (action, value)
import Options.Applicative qualified as Optparse

data WorkflowRef = WorkflowRef
  { workflowName :: !Text,
    workflowId :: !Text
  }
  deriving stock (Eq, Show)

data ListOptions = ListOptions
  { statusFilters :: ![Instance.WorkflowStatus],
    workflowNameFilter :: !(Maybe Text),
    afterKey :: !(Maybe (Text, Text)),
    limit :: !Int
  }
  deriving stock (Eq, Show)

data InspectOptions = InspectOptions
  { target :: !WorkflowRef,
    generation :: !(Maybe Int)
  }
  deriving stock (Eq, Show)

data PayloadArg = PayloadArg
  { rawPayload :: !Text,
    payload :: !Value
  }
  deriving stock (Eq, Show)

data GcOptions = GcOptions
  { retention :: !NominalDiffTime,
    batchSize :: !Int
  }
  deriving stock (Eq, Show)

data ResumeOptions = ResumeOptions
  { limit :: !Int
  }
  deriving stock (Eq, Show)

type ResumeHook =
  ( WorkflowRegistry '[Store, Error StoreError, IOE],
    WorkflowResumeOptions
  )

data AwakeableCommand
  = AwakeableShow !UUID
  | AwakeableSignal !UUID !PayloadArg
  | AwakeableCancel !UUID
  deriving stock (Eq, Show)

data Command
  = List !ListOptions
  | Show !WorkflowRef
  | Steps !InspectOptions
  | Journal !InspectOptions
  | Awakeable !AwakeableCommand
  | Cancel !WorkflowRef
  | Resurrect !WorkflowRef
  | ReleaseLease !WorkflowRef
  | GcRunOnce !GcOptions
  | ResumeOnce !ResumeOptions
  deriving stock (Eq, Show)

commandParser :: Parser Command
commandParser = commandParserWithResume False

commandParserWithResume :: Bool -> Parser Command
commandParserWithResume includeResume =
  hsubparser
    ( command "list" (info (List <$> listOptionsParser) (progDesc "List workflow instances with stable keyset paging"))
        <> command "show" (info (Show <$> workflowRefParser) (progDesc "Show an instance, its children, and its awakeables"))
        <> command "steps" (info (Steps <$> inspectOptionsParser) (progDesc "Show the derived step index for one generation"))
        <> command "journal" (info (Journal <$> inspectOptionsParser) (progDesc "Decode one workflow journal generation in order"))
        <> command "awakeable" (info (Awakeable <$> awakeableCommandParser) (progDesc "Inspect, signal, or cancel an awakeable"))
        <> command "cancel" (info (Cancel <$> workflowRefParser) (progDesc "Preview or cancel a workflow at its next durable boundary"))
        <> command "resurrect" (info (Resurrect <$> workflowRefParser) (progDesc "Preview or resurrect a terminally failed workflow"))
        <> command "lease" (info leaseCommandParser (progDesc "Operate workflow instance leases"))
        <> command "gc" (info gcCommandParser (progDesc "Preview or run one workflow garbage-collection pass"))
        <> resumeCommand
    )
  where
    resumeCommand
      | includeResume =
          command
            "resume-once"
            ( info
                (ResumeOnce . ResumeOptions <$> option positiveIntReader (long "limit" <> metavar "N" <> Optparse.value 100 <> showDefault <> help "Maximum workflow instances to advance"))
                (progDesc "Preview or run one bounded application-registry resume pass")
            )
      | otherwise = mempty

listOptionsParser :: Parser ListOptions
listOptionsParser =
  ListOptions
    <$> many
      ( option
          workflowStatusReader
          (long "status" <> metavar "STATUS" <> help "Exact status; repeat to match more than one")
      )
    <*> optional (Text.pack <$> strOption (long "name" <> metavar "NAME" <> help "Exact workflow definition name"))
    <*> optional
      ( (,)
          <$> (Text.pack <$> strOption (long "after" <> metavar "NAME" <> help "Keyset cursor workflow name; followed by ID"))
          <*> (Text.pack <$> argument str (metavar "ID"))
      )
    <*> option positiveIntReader (long "limit" <> metavar "N" <> Optparse.value 100 <> showDefault <> help "Maximum rows to return")

inspectOptionsParser :: Parser InspectOptions
inspectOptionsParser =
  InspectOptions
    <$> workflowRefParser
    <*> optional (option (nonNegativeReader "expected a non-negative generation") (long "generation" <> metavar "N" <> help "Journal generation; defaults to current"))

workflowRefParser :: Parser WorkflowRef
workflowRefParser =
  WorkflowRef
    <$> (Text.pack <$> argument str (metavar "NAME"))
    <*> (Text.pack <$> argument str (metavar "ID"))

awakeableCommandParser :: Parser AwakeableCommand
awakeableCommandParser =
  hsubparser
    ( command "show" (info (AwakeableShow <$> uuidArgument) (progDesc "Show one awakeable"))
        <> command
          "signal"
          ( info
              (AwakeableSignal <$> uuidArgument <*> option payloadReader (long "payload" <> metavar "JSON" <> help "JSON completion payload"))
              (progDesc "Preview or signal a pending awakeable")
          )
        <> command "cancel" (info (AwakeableCancel <$> uuidArgument) (progDesc "Preview or cancel a pending awakeable"))
    )

leaseCommandParser :: Parser Command
leaseCommandParser =
  hsubparser
    (command "release" (info (ReleaseLease <$> workflowRefParser) (progDesc "Preview or forcibly release an instance lease")))

gcCommandParser :: Parser Command
gcCommandParser =
  hsubparser
    ( command
        "run-once"
        ( info
            ( GcRunOnce
                <$> ( GcOptions
                        <$> option durationReader (long "retention" <> metavar "DURATION" <> help "Minimum terminal age, such as 30d or 12h")
                        <*> option positiveIntReader (long "batch" <> metavar "N" <> Optparse.value 100 <> showDefault <> help "Maximum workflows to collect")
                    )
            )
            (progDesc "Preview or run one bounded garbage-collection pass")
        )
    )

uuidArgument :: Parser UUID
uuidArgument = argument uuidReader (metavar "UUID")

uuidReader :: ReadM UUID
uuidReader = eitherReader $ \raw ->
  maybe (Left "expected a UUID") Right (UUID.fromString raw)

payloadReader :: ReadM PayloadArg
payloadReader = eitherReader $ \raw ->
  let rawText = Text.pack raw
   in case Aeson.eitherDecodeStrict' (Text.Encoding.encodeUtf8 rawText) of
        Left err -> Left ("invalid JSON payload: " <> err)
        Right value -> Right (PayloadArg rawText value)

workflowStatusReader :: ReadM Instance.WorkflowStatus
workflowStatusReader = eitherReader $ \case
  "running" -> Right Instance.WfRunning
  "suspended" -> Right Instance.WfSuspended
  "completed" -> Right Instance.WfCompleted
  "cancelled" -> Right Instance.WfCancelled
  "failed" -> Right Instance.WfFailed
  _ -> Left "expected one of: running, suspended, completed, cancelled, failed"

isMutation :: Command -> Bool
isMutation = \case
  List {} -> False
  Show {} -> False
  Steps {} -> False
  Journal {} -> False
  Awakeable (AwakeableShow {}) -> False
  Awakeable (AwakeableSignal {}) -> True
  Awakeable (AwakeableCancel {}) -> True
  Cancel {} -> True
  Resurrect {} -> True
  ReleaseLease {} -> True
  GcRunOnce {} -> True
  ResumeOnce {} -> True

runCommand :: OpsEnv -> Command -> IO OpsOutcome
runCommand = runCommandWithResume Nothing

runCommandWithResume :: Maybe ResumeHook -> OpsEnv -> Command -> IO OpsOutcome
runCommandWithResume resumeHook env = \case
  List options -> runList env options
  Show ref -> runShow env ref
  Steps options -> runSteps env options
  Journal options -> runJournal env options
  Awakeable awakeableCommand -> runAwakeable env awakeableCommand
  Cancel ref -> runCancel env ref
  Resurrect ref -> runResurrect env ref
  ReleaseLease ref -> runReleaseLease env ref
  GcRunOnce options -> runGc env options
  ResumeOnce options -> runResumeOnce resumeHook env options

runResumeOnce :: Maybe ResumeHook -> OpsEnv -> ResumeOptions -> IO OpsOutcome
runResumeOnce Nothing _ _ =
  pure (Failed "workflow resume hook is not mounted")
runResumeOnce (Just (registry, resumeOptions)) env options
  | not env.force = do
      now <- getCurrentTime
      runAction env (take options.limit <$> WorkflowSchema.findUnfinishedWorkflowIds now) $ \candidates ->
        PreviewRequired
          (resumePreviewResult options.limit candidates)
          (forceInvocation env ["wf", "resume-once", "--limit", Text.pack (show options.limit)])
  | otherwise =
      runAction
        env
        (resumeWorkflowsOnceUpTo options.limit resumeOptions registry)
        (Succeeded . resumeSummaryResult)

resumePreviewResult :: Int -> [(Text, Text)] -> OpsResult
resumePreviewResult limit candidates =
  OpsResult
    { headers = ["name", "id"],
      rows = [[name, workflowId] | (workflowId, name) <- candidates],
      jsonValue =
        object
          [ "preview" .= True,
            "limit" .= limit,
            "candidates"
              .= [ object ["workflow_name" .= name, "workflow_id" .= workflowId]
                 | (workflowId, name) <- candidates
                 ]
          ]
    }

resumeSummaryResult :: ResumeSummary -> OpsResult
resumeSummaryResult summary =
  OpsResult
    { headers = ["discovered", "resumed", "completed", "suspended", "unknown", "failed", "errors", "lease_skipped", "advanced", "paced", "sleep_due", "unregistered"],
      rows =
        [ map (Text.pack . show) counts
            <> [renderUnregistered summary.unregisteredNames]
        ],
      jsonValue =
        object
          [ "discovered" .= summary.discovered,
            "resumed" .= summary.resumed,
            "completed" .= summary.completed,
            "still_suspended" .= summary.stillSuspended,
            "unknown_name" .= summary.unknownName,
            "failed" .= summary.failed,
            "transient_errors" .= summary.transientErrors,
            "lease_skipped" .= summary.leaseSkipped,
            "advanced" .= summary.advanced,
            "paced" .= summary.paced,
            "sleep_due" .= summary.sleepDue,
            "unregistered_names" .= Set.toAscList summary.unregisteredNames
          ]
    }
  where
    counts =
      [ summary.discovered,
        summary.resumed,
        summary.completed,
        summary.stillSuspended,
        summary.unknownName,
        summary.failed,
        summary.transientErrors,
        summary.leaseSkipped,
        summary.advanced,
        summary.paced,
        summary.sleepDue
      ]
    renderUnregistered names
      | Set.null names = "-"
      | otherwise = Text.intercalate "," (Set.toAscList names)

runList :: OpsEnv -> ListOptions -> IO OpsOutcome
runList env options =
  runAction env (Instance.listWorkflowInstances filters) (Succeeded . workflowListResult)
  where
    filters =
      Instance.WorkflowInstanceFilter
        (NonEmpty.nonEmpty options.statusFilters)
        options.workflowNameFilter
        options.afterKey
        options.limit

runShow :: OpsEnv -> WorkflowRef -> IO OpsOutcome
runShow env ref =
  runAction env action $ \case
    Nothing -> Failed (workflowLabel ref <> " was not found")
    Just details -> Succeeded (workflowDetailsResult details)
  where
    action = do
      instanceRow <- Instance.lookupInstance (refName ref) (refId ref)
      case instanceRow of
        Nothing -> pure Nothing
        Just row -> do
          children <- Child.lookupChildrenOfParent ref.workflowId ref.workflowName
          awakeables <- lookupWorkflowAwakeables ref
          pure (Just (row, children, awakeables))

runSteps :: OpsEnv -> InspectOptions -> IO OpsOutcome
runSteps env options =
  runAction env action (Succeeded . uncurry stepsResult)
  where
    action = do
      selectedGeneration <- resolveGeneration options
      steps <- WorkflowSchema.loadStepIndex (refName options.target) (refId options.target) selectedGeneration
      pure (selectedGeneration, steps)

runJournal :: OpsEnv -> InspectOptions -> IO OpsOutcome
runJournal env options =
  runAction env action $ \(selectedGeneration, recorded) ->
    case traverse decodeJournalView recorded of
      Left err -> Failed err
      Right views -> Succeeded (journalResult selectedGeneration views)
  where
    action = do
      selectedGeneration <- resolveGeneration options
      recorded <- readJournalEvents (workflowGenerationStreamName (refName options.target) (refId options.target) selectedGeneration)
      pure (selectedGeneration, recorded)

runAwakeable :: OpsEnv -> AwakeableCommand -> IO OpsOutcome
runAwakeable env = \case
  AwakeableShow awakeableId ->
    runAction env (Awakeable.lookupAwakeable awakeableId) $ \case
      Nothing -> Failed ("awakeable " <> UUID.toText awakeableId <> " was not found")
      Just row -> Succeeded (awakeableResult row)
  AwakeableSignal awakeableId payloadArg
    | not env.force ->
        runAction env (Awakeable.lookupAwakeable awakeableId) $ \row ->
          PreviewRequired
            (awakeablePreviewResult "signal" awakeableId row)
            (forceInvocation env ["wf", "awakeable", "signal", UUID.toText awakeableId, "--payload", payloadArg.rawPayload])
    | otherwise ->
        runAction env action $ \(transitioned, row) ->
          Succeeded (awakeableMutationResult "signal" awakeableId transitioned row)
    where
      action = do
        transitioned <- signalAwakeable (AwakeableId awakeableId) payloadArg.payload
        row <- Awakeable.lookupAwakeable awakeableId
        pure (transitioned, row)
  AwakeableCancel awakeableId
    | not env.force ->
        runAction env (Awakeable.lookupAwakeable awakeableId) $ \row ->
          PreviewRequired
            (awakeablePreviewResult "cancel" awakeableId row)
            (forceInvocation env ["wf", "awakeable", "cancel", UUID.toText awakeableId])
    | otherwise ->
        runAction env action $ \(transitioned, row) ->
          Succeeded (awakeableMutationResult "cancel" awakeableId transitioned row)
    where
      action = do
        transitioned <- cancelAwakeable (AwakeableId awakeableId)
        row <- Awakeable.lookupAwakeable awakeableId
        pure (transitioned, row)

runCancel :: OpsEnv -> WorkflowRef -> IO OpsOutcome
runCancel env ref
  | not env.force =
      runAction env preview $ \(row, journalExists) ->
        PreviewRequired
          (instancePreviewResult "cancel" ref (cancelPreviewDisposition row journalExists) row)
          (forceInvocation env ["wf", "cancel", ref.workflowName, ref.workflowId])
  | otherwise =
      runAction env (Instance.cancelWorkflow (refName ref) (refId ref)) $ \outcome ->
        Succeeded (workflowMutationResult "cancel" ref (cancelOutcomeText outcome))
  where
    preview = do
      row <- Instance.lookupInstance (refName ref) (refId ref)
      journalExists <- case row of
        Just _ -> pure True
        Nothing -> do
          generation <- WorkflowSchema.currentGeneration (refName ref) (refId ref)
          not . Map.null <$> WorkflowSchema.loadStepIndex (refName ref) (refId ref) generation
      pure (row, journalExists)

runResurrect :: OpsEnv -> WorkflowRef -> IO OpsOutcome
runResurrect env ref
  | not env.force =
      runAction env (Instance.lookupInstance (refName ref) (refId ref)) $ \row ->
        PreviewRequired
          (instancePreviewResult "resurrect" ref (resurrectPreviewDisposition row) row)
          (forceInvocation env ["wf", "resurrect", ref.workflowName, ref.workflowId])
  | otherwise =
      runAction env (Instance.resurrectFailedWorkflow (refName ref) (refId ref)) $ \outcome ->
        Succeeded (workflowMutationResult "resurrect" ref (resurrectOutcomeText outcome))

runReleaseLease :: OpsEnv -> WorkflowRef -> IO OpsOutcome
runReleaseLease env ref
  | not env.force =
      runAction env (Instance.lookupInstance (refName ref) (refId ref)) $ \row ->
        PreviewRequired
          (instancePreviewResult "lease release" ref (leasePreviewDisposition row) row)
          (forceInvocation env ["wf", "lease", "release", ref.workflowName, ref.workflowId])
  | otherwise =
      runAction env (Instance.forceReleaseInstanceLease (refName ref) (refId ref)) $ \released ->
        Succeeded (workflowMutationResult "lease release" ref (if released then "released" else "no_lease_released"))

runGc :: OpsEnv -> GcOptions -> IO OpsOutcome
runGc env options
  | not env.force = do
      now <- getCurrentTime
      runAction env (Gc.listWorkflowGcCandidates now policy) $ \candidates ->
        PreviewRequired
          (gcCandidatesResult candidates)
          ( forceInvocation
              env
              [ "wf",
                "gc",
                "run-once",
                "--retention",
                Text.pack (show (realToFrac options.retention :: Double)) <> "s",
                "--batch",
                Text.pack (show options.batchSize)
              ]
          )
  | otherwise = do
      now <- getCurrentTime
      runAction env (Gc.gcWorkflowsOnce now policy) (Succeeded . gcSummaryResult)
  where
    policy = Gc.WorkflowGcPolicy options.retention options.batchSize

runAction ::
  OpsEnv ->
  Eff '[Store, Error StoreError, IOE] a ->
  (a -> OpsOutcome) ->
  IO OpsOutcome
runAction env action onSuccess = do
  result <- runStoreIO env.store action
  pure $ case result of
    Left storeError -> Failed (Text.pack (show storeError))
    Right value -> onSuccess value

resolveGeneration :: (Store :> es) => InspectOptions -> Eff es Int
resolveGeneration options =
  maybe
    (WorkflowSchema.currentGeneration (refName options.target) (refId options.target))
    pure
    options.generation

lookupWorkflowAwakeables :: (Store :> es) => WorkflowRef -> Eff es [Awakeable.AwakeableRow]
lookupWorkflowAwakeables ref = do
  current <- WorkflowSchema.currentGeneration (refName ref) (refId ref)
  stepIndexes <- traverse (WorkflowSchema.loadStepIndex (refName ref) (refId ref)) [0 .. current]
  catMaybes <$> traverse Awakeable.lookupAwakeable (awakeableIds stepIndexes)

awakeableIds :: [Map Text Value] -> [UUID]
awakeableIds indexes =
  Set.toAscList . Set.fromList $ do
    index <- indexes
    (stepName, value) <- Map.toList index
    if awakeableAllocStepPrefix `Text.isPrefixOf` stepName
      then case Aeson.fromJSON value of
        AesonTypes.Success (AwakeableId awakeableId) -> [awakeableId]
        AesonTypes.Error _ -> []
      else []

readJournalEvents :: (Store :> es) => StreamName -> Eff es [RecordedEvent]
readJournalEvents streamName = go (StreamVersion 0) []
  where
    pageSize = 256
    go cursor pages = do
      page <- StoreRead.readStreamForward streamName cursor pageSize
      if Vector.null page
        then pure (concat (reverse pages))
        else
          let nextCursor = (Vector.last page).streamVersion
           in go nextCursor (Vector.toList page : pages)

data JournalView = JournalView
  { eventId :: !Text,
    eventType :: !Text,
    streamVersion :: !Int64,
    globalPosition :: !Int64,
    stepName :: !Text,
    recordedAt :: !UTCTime,
    payload :: !Value
  }

decodeJournalView :: RecordedEvent -> Either Text JournalView
decodeJournalView recorded = do
  event <- firstShow (decodeRecorded workflowJournalCodec recorded)
  let (stepName, recordedAt, payload) = case event of
        StepRecorded name value timestamp -> (name, timestamp, value)
        WorkflowCompleted timestamp -> (completedStepName, timestamp, Aeson.Null)
        WorkflowCancelled timestamp -> (cancelledStepName, timestamp, Aeson.Null)
        WorkflowFailed reason timestamp -> (failedStepName, timestamp, Aeson.toJSON reason)
        WorkflowContinuedAsNew generation timestamp -> (continuedAsNewStepName, timestamp, Aeson.toJSON generation)
  pure
    JournalView
      { eventId = case recorded.eventId of EventId value -> UUID.toText value,
        eventType = case recorded.eventType of EventType value -> value,
        streamVersion = case recorded.streamVersion of StreamVersion value -> value,
        globalPosition = case recorded.globalPosition of GlobalPosition value -> value,
        stepName,
        recordedAt,
        payload
      }

firstShow :: (Show err) => Either err value -> Either Text value
firstShow = \case
  Left err -> Left ("workflow journal decode failed: " <> Text.pack (show err))
  Right value -> Right value

workflowListResult :: [Instance.WorkflowInstanceRow] -> OpsResult
workflowListResult instances =
  OpsResult
    { headers = ["name", "id", "generation", "status", "attempts", "lease", "wake_after", "updated_at"],
      rows = map workflowListRow instances,
      jsonValue = Aeson.toJSON (map workflowInstanceJson instances)
    }

workflowListRow :: Instance.WorkflowInstanceRow -> [Text]
workflowListRow row =
  [ row.workflowName,
    row.workflowId,
    Text.pack (show row.generation),
    Instance.statusToText row.status,
    Text.pack (show row.attempts),
    leaseText row,
    maybeTime row.wakeAfter,
    timeText row.updatedAt
  ]

workflowInstanceJson :: Instance.WorkflowInstanceRow -> Value
workflowInstanceJson row =
  object
    [ "workflow_id" .= row.workflowId,
      "workflow_name" .= row.workflowName,
      "generation" .= row.generation,
      "status" .= Instance.statusToText row.status,
      "attempts" .= row.attempts,
      "last_error" .= row.lastError,
      "next_attempt_at" .= row.nextAttemptAt,
      "wake_after" .= row.wakeAfter,
      "leased_by" .= row.leasedBy,
      "lease_expires_at" .= row.leaseExpiresAt,
      "created_at" .= row.createdAt,
      "updated_at" .= row.updatedAt,
      "completed_at" .= row.completedAt
    ]

workflowDetailsResult :: (Instance.WorkflowInstanceRow, [Child.ChildRow], [Awakeable.AwakeableRow]) -> OpsResult
workflowDetailsResult (row, children, awakeables) =
  OpsResult
    { headers = ["name", "id", "generation", "status", "attempts", "lease", "wake_after", "children", "awakeables"],
      rows =
        [ [ row.workflowName,
            row.workflowId,
            Text.pack (show row.generation),
            Instance.statusToText row.status,
            Text.pack (show row.attempts),
            leaseText row,
            maybeTime row.wakeAfter,
            Text.pack (show (length children)),
            Text.pack (show (length awakeables))
          ]
        ],
      jsonValue =
        object
          [ "instance" .= workflowInstanceJson row,
            "children" .= map childJson children,
            "awakeables" .= map awakeableJson awakeables
          ]
    }

childJson :: Child.ChildRow -> Value
childJson row =
  object
    [ "child_id" .= row.childId,
      "child_name" .= row.childName,
      "parent_id" .= row.parentId,
      "parent_name" .= row.parentName,
      "await_step" .= row.awaitStep,
      "status" .= Child.statusToText row.status,
      "result" .= row.result,
      "failure_reason" .= row.failureReason,
      "created_at" .= row.createdAt,
      "updated_at" .= row.updatedAt,
      "completed_at" .= row.completedAt
    ]

stepsResult :: Int -> Map Text Value -> OpsResult
stepsResult generation steps =
  OpsResult
    { headers = ["step", "result"],
      rows = [[name, truncateCell 120 (jsonText value)] | (name, value) <- Map.toAscList steps],
      jsonValue =
        object
          [ "generation" .= generation,
            "steps" .= [object ["step" .= name, "result" .= value] | (name, value) <- Map.toAscList steps]
          ]
    }

journalResult :: Int -> [JournalView] -> OpsResult
journalResult generation views =
  OpsResult
    { headers = ["version", "event_type", "step", "recorded_at", "payload"],
      rows =
        [ [ Text.pack (show view.streamVersion),
            view.eventType,
            view.stepName,
            timeText view.recordedAt,
            truncateCell 120 (jsonText view.payload)
          ]
        | view <- views
        ],
      jsonValue = object ["generation" .= generation, "events" .= map journalViewJson views]
    }

journalViewJson :: JournalView -> Value
journalViewJson view =
  object
    [ "event_id" .= view.eventId,
      "event_type" .= view.eventType,
      "stream_version" .= view.streamVersion,
      "global_position" .= view.globalPosition,
      "step_name" .= view.stepName,
      "recorded_at" .= view.recordedAt,
      "payload" .= view.payload
    ]

awakeableResult :: Awakeable.AwakeableRow -> OpsResult
awakeableResult row =
  OpsResult
    { headers = ["id", "owner_name", "owner_id", "status", "payload", "updated_at"],
      rows =
        [ [ UUID.toText row.awakeableId,
            row.ownerWorkflowName,
            row.ownerWorkflowId,
            Awakeable.statusToText row.status,
            maybe "-" (truncateCell 120 . jsonText) row.payload,
            timeText row.updatedAt
          ]
        ],
      jsonValue = awakeableJson row
    }

awakeableJson :: Awakeable.AwakeableRow -> Value
awakeableJson row =
  object
    [ "awakeable_id" .= UUID.toText row.awakeableId,
      "owner_workflow_name" .= row.ownerWorkflowName,
      "owner_workflow_id" .= row.ownerWorkflowId,
      "status" .= Awakeable.statusToText row.status,
      "payload" .= row.payload,
      "created_at" .= row.createdAt,
      "updated_at" .= row.updatedAt,
      "completed_at" .= row.completedAt
    ]

awakeablePreviewResult :: Text -> UUID -> Maybe Awakeable.AwakeableRow -> OpsResult
awakeablePreviewResult operation awakeableId row =
  OpsResult
    { headers = ["operation", "id", "disposition", "status"],
      rows = [[operation, UUID.toText awakeableId, disposition, maybe "not_found" (Awakeable.statusToText . (.status)) row]],
      jsonValue =
        object
          [ "preview" .= True,
            "operation" .= operation,
            "disposition" .= disposition,
            "awakeable" .= fmap awakeableJson row
          ]
    }
  where
    disposition = case row of
      Nothing -> "not_found"
      Just found -> case found.status of
        Awakeable.Pending -> "would_mutate"
        Awakeable.Completed | operation == "signal" -> "would_repair_if_needed"
        _ -> "no_op"

awakeableMutationResult :: Text -> UUID -> Bool -> Maybe Awakeable.AwakeableRow -> OpsResult
awakeableMutationResult operation awakeableId transitioned row =
  OpsResult
    { headers = ["operation", "id", "outcome", "status"],
      rows = [[operation, UUID.toText awakeableId, outcome, maybe "not_found" (Awakeable.statusToText . (.status)) row]],
      jsonValue =
        object
          [ "operation" .= operation,
            "outcome" .= outcome,
            "transitioned" .= transitioned,
            "awakeable" .= fmap awakeableJson row
          ]
    }
  where
    outcome
      | transitioned = "transitioned"
      | otherwise = "not_transitioned"

instancePreviewResult :: Text -> WorkflowRef -> Text -> Maybe Instance.WorkflowInstanceRow -> OpsResult
instancePreviewResult operation ref disposition row =
  OpsResult
    { headers = ["operation", "name", "id", "disposition", "status"],
      rows = [[operation, ref.workflowName, ref.workflowId, disposition, maybe "not_found" (Instance.statusToText . (.status)) row]],
      jsonValue =
        object
          [ "preview" .= True,
            "operation" .= operation,
            "disposition" .= disposition,
            "target" .= object ["workflow_name" .= ref.workflowName, "workflow_id" .= ref.workflowId],
            "instance" .= fmap workflowInstanceJson row
          ]
    }

workflowMutationResult :: Text -> WorkflowRef -> Text -> OpsResult
workflowMutationResult operation ref outcome =
  OpsResult
    { headers = ["operation", "name", "id", "outcome"],
      rows = [[operation, ref.workflowName, ref.workflowId, outcome]],
      jsonValue =
        object
          [ "operation" .= operation,
            "workflow_name" .= ref.workflowName,
            "workflow_id" .= ref.workflowId,
            "outcome" .= outcome
          ]
    }

gcCandidatesResult :: [Gc.WorkflowGcCandidate] -> OpsResult
gcCandidatesResult candidates =
  OpsResult
    { headers = ["name", "id", "disposition"],
      rows = [[candidate.workflowName, candidate.workflowId, "would_collect"] | candidate <- candidates],
      jsonValue =
        object
          [ "preview" .= True,
            "candidates"
              .= [ object ["workflow_name" .= candidate.workflowName, "workflow_id" .= candidate.workflowId]
                 | candidate <- candidates
                 ]
          ]
    }

gcSummaryResult :: Gc.WorkflowGcSummary -> OpsResult
gcSummaryResult summary =
  OpsResult
    { headers = ["scanned", "deleted"],
      rows = [[Text.pack (show summary.scanned), Text.pack (show summary.deleted)]],
      jsonValue = object ["scanned" .= summary.scanned, "deleted" .= summary.deleted]
    }

cancelPreviewDisposition :: Maybe Instance.WorkflowInstanceRow -> Bool -> Text
cancelPreviewDisposition row journalExists = case row of
  Just found -> case found.status of
    Instance.WfRunning -> "would_cancel"
    Instance.WfSuspended -> "would_cancel"
    _ -> "already_terminal"
  Nothing
    | journalExists -> "would_cancel_journal_only_instance"
    | otherwise -> "not_found"

resurrectPreviewDisposition :: Maybe Instance.WorkflowInstanceRow -> Text
resurrectPreviewDisposition = \case
  Just row | row.status == Instance.WfFailed -> "would_resurrect"
  Just _ -> "not_failed"
  Nothing -> "not_found"

leasePreviewDisposition :: Maybe Instance.WorkflowInstanceRow -> Text
leasePreviewDisposition = \case
  Just row | Just _ <- row.leasedBy -> "would_release"
  Just _ -> "no_lease"
  Nothing -> "not_found"

cancelOutcomeText :: Instance.CancelWorkflowOutcome -> Text
cancelOutcomeText = \case
  Instance.WorkflowCancelRecorded -> "cancel_recorded"
  Instance.WorkflowAlreadyTerminal status -> "already_" <> Instance.statusToText status
  Instance.WorkflowCancelUnknown -> "not_found"

resurrectOutcomeText :: Instance.ResurrectOutcome -> Text
resurrectOutcomeText = \case
  Instance.WorkflowResurrected -> "resurrected"
  Instance.WorkflowNotFailed -> "not_failed"
  Instance.WorkflowNotFound -> "not_found"

refName :: WorkflowRef -> WorkflowName
refName = WorkflowName . (.workflowName)

refId :: WorkflowRef -> WorkflowId
refId = WorkflowId . (.workflowId)

workflowLabel :: WorkflowRef -> Text
workflowLabel ref = ref.workflowName <> "/" <> ref.workflowId

leaseText :: Instance.WorkflowInstanceRow -> Text
leaseText row = case row.leasedBy of
  Nothing -> "-"
  Just owner -> owner <> maybe "" ((" until " <>) . timeText) row.leaseExpiresAt

maybeTime :: Maybe UTCTime -> Text
maybeTime = maybe "-" timeText

timeText :: UTCTime -> Text
timeText = Text.pack . show

forceInvocation :: OpsEnv -> [Text] -> Text
forceInvocation env arguments =
  Text.unwords (map shellQuote ("keiro-ops" : arguments <> globalFlags <> ["--force"]))
  where
    globalFlags =
      ["--json" | env.outputMode == Json]
        <> ["--allow-schema-drift" | env.allowSchemaDrift]

shellQuote :: Text -> Text
shellQuote value = "'" <> Text.replace "'" "'\"'\"'" value <> "'"