packages feed

seihou-cli-0.7.0.0: src-exe/Seihou/CLI/AgentMigrate.hs

{-# LANGUAGE TemplateHaskell #-}

module Seihou.CLI.AgentMigrate
  ( handleAgentMigrate,
  )
where

import Baikai.Trace.Sink (TraceSink)
import Control.Applicative ((<|>))
import Control.Monad (unless, when)
import Data.FileEmbed (embedFile)
import Data.Generics.Labels ()
import Data.List (nub)
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe, listToMaybe, maybeToList)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Text.IO qualified as TIO
import Data.Time.Clock (getCurrentTime)
import Seihou.CLI.AgentCompletion
  ( AgentModelConfig (..),
    AgentProvider (..),
    buildAgentCompletionRequestWith,
    runAgentCompletion,
  )
import Seihou.CLI.AgentConfig
  ( PendingAgentConfig,
    agentLaunchDeclaration,
    resolveDeclaredAgentConfig,
  )
import Seihou.CLI.AgentGuard (enforceAgentArtifactGuard)
import Seihou.CLI.AgentLaunch (gatherAgentContext)
import Seihou.CLI.AgentLaunchExec (launchConfiguredAgentAddingDirs)
import Seihou.CLI.AgentTrace (traceSinkForConfig)
import Seihou.CLI.AppliedBlueprintMigration (recordAppliedBlueprintMigration)
import Seihou.CLI.BlueprintExecution
  ( BlueprintExecutionRequest (..),
    PreparedBlueprintExecution (..),
    prepareBlueprintExecution,
  )
import Seihou.CLI.BlueprintMigration
  ( BlueprintMigrationLaunchFailure (..),
    BlueprintMigrationLaunchResult (..),
    BlueprintMigrationRunResult (..),
    ResolvedWindow (..),
    VersionProbeResult (..),
    formatBlueprintMigrationDebugOutput,
    formatMigrationStepLabel,
    formatProbeFailure,
    formatResolvedWindow,
    formatWindowResolutionError,
    highestMigratedVersion,
    parseNotApplicableSignal,
    pendingBlueprintMigrations,
    renderBlueprintMigrationSystemPrompt,
    resolveMigrationWindow,
    runBlueprintMigrationsWith,
    runVersionProbe,
    unstatedNotApplicableReason,
  )
import Seihou.CLI.Commands (BlueprintMigrationOpts (..))
import Seihou.CLI.MigrationCohort
  ( CohortBlueprint (..),
    CohortResolutionError (..),
    resolveCohortBlueprint,
    resolveMigrationCohort,
  )
import Seihou.CLI.Shared (formatVarError, logIO)
import Seihou.Core.Migration
  ( BlueprintMigration (..),
    BlueprintMigrationPlan (..),
    BlueprintMigrationStep (..),
    EntailedEdge (..),
    EntailmentError (..),
    EntailmentSite (..),
    MigrationPlanError (..),
    expandEntailedEdges,
    planBlueprintMigrationChain,
  )
import Seihou.Core.Module (defaultSearchPaths, discoverRunnable)
import Seihou.Core.Types
import Seihou.Core.Version (Version, parseVersion, renderVersion)
import Seihou.Effect.FilesystemInterp (runFilesystem)
import Seihou.Effect.Logger (logError, logWarn)
import Seihou.Effect.ManifestStore (readManifest)
import Seihou.Effect.ManifestStoreInterp (runManifestStore)
import Seihou.Effect.ProcessInterp (runProcessIO)
import Seihou.Prelude
import System.Directory
  ( createDirectoryIfMissing,
    doesFileExist,
    getCurrentDirectory,
    removeFile,
  )
import System.Exit (ExitCode (..), exitFailure, exitWith)
import System.FilePath (takeDirectory)
import System.Timeout (timeout)

migrationPromptTemplate :: Text
migrationPromptTemplate = TE.decodeUtf8 $(embedFile "data/blueprint-migration-prompt.md")

handleAgentMigrate :: Bool -> PendingAgentConfig -> BlueprintMigrationOpts -> IO ()
handleAgentMigrate debug pendingConfig opts = do
  let level = if opts ^. #verbose then LogVerbose else LogNormal
      manifestPath = ".seihou" </> "manifest.json"

  -- The blueprint's discovery directory is classified into a portable origin
  -- as it is loaded. Receipts are keyed by that origin, not by the name the
  -- user typed, so a blueprint of the same name from another repository has
  -- its own receipts.
  projectRoot <- getCurrentDirectory
  searchPaths <- defaultSearchPaths
  invoked <- discoverMigrationBlueprint level projectRoot searchPaths (opts ^. #name)
  let blueprint = invoked ^. #blueprint

  -- Pre-flight downgrade and origin guard, before a single edge is planned.
  -- Placing it before planning is the point rather than an implementation
  -- detail: a substituted blueprint's edges do not match this project's
  -- receipts, so without the check the command would report that every edge in
  -- the window already has a receipt and exit successfully, having silently
  -- skipped work that never ran. The refusal happens before any receipt is
  -- written, so the manifest is left byte-identical.
  --
  -- No baseline modules are in scope: migration mode applies no baseModules
  -- (see docs/user/blueprint-migrations.md), and refusing for artifacts this
  -- command will not touch would violate the scoping rule in
  -- docs/adr/0003-a-stale-or-substituted-artifact-is-a-hard-error.md.
  --
  -- --debug performs no check at all: it contacts no provider and writes
  -- nothing, so a prompt can still be inspected on any machine.
  --
  -- Only the invoked blueprint is in scope here. Blueprints reached by
  -- entailment are not known until the window has been planned, and they are
  -- checked separately once they are; the guard is not moved later to
  -- accommodate them, because the invoked blueprint being substituted is
  -- exactly the case that would make the planned window meaningless.
  unless debug $
    enforceAgentArtifactGuard
      (opts ^. #allowDowngrade)
      manifestPath
      [blueprint ^. #name]
      mempty

  -- Finish provider/model/effort resolution now that the blueprint is loaded:
  -- `agent migrate` reads the same Blueprint record as `agent run`, so it
  -- honors the same launch declaration.
  modelConfig <-
    resolveDeclaredAgentConfig
      level
      ("blueprint '" <> blueprint ^. #name . #unModuleName <> "'")
      pendingConfig
      (agentLaunchDeclaration (blueprint ^. #launch))

  -- The receipts are read before the window is planned, not after, because
  -- the window itself now depends on them: an omitted --from is the highest
  -- version this project has already migrated this blueprint to. The same
  -- list is reused for per-step filtering further down, so the manifest is
  -- read once.
  receipts <- readMigrationReceipts level manifestPath

  window <- resolveWindow level projectRoot invoked receipts opts
  let current = window ^. #fromVersion
      target = window ^. #toVersion
      windowReport = formatResolvedWindow (opts ^. #verbose) window
  unless (null windowReport) $ mapM_ TIO.putStrLn (windowReport <> [""])

  planned <-
    case planBlueprintMigrationChain (blueprint ^. #name . #unModuleName) (blueprint ^. #migrations) current target of
      Left err -> exitErr level (renderPlanError err)
      Right Nothing -> do
        TIO.putStrLn "No blueprint migration needed: --from and --to resolve to the same version."
        pure Nothing
      Right (Just migrationPlan) -> pure (Just migrationPlan)

  case planned of
    Nothing -> pure ()
    Just migrationPlan -> do
      -- Load every blueprint this window reaches by entailment, then flatten
      -- the window into an ordered list of steps, each labelled with the
      -- blueprint that declares it. Both happen before receipts are consulted,
      -- because an entailed step is filtered against its own owner's receipts
      -- rather than the invoked blueprint's.
      let invokedName = blueprint ^. #name . #unModuleName
      cohortResult <-
        resolveMigrationCohort
          projectRoot
          searchPaths
          (Map.singleton invokedName invoked)
          (migrationPlan ^. #steps)
      cohort <- case cohortResult of
        Left err -> exitErr level (renderCohortError err)
        Right resolved -> pure resolved

      -- Every entailed blueprint is an artifact this command is about to
      -- generate from, so ADR 0003's scoping rule reaches it too. The invoked
      -- blueprint was already checked before planning; this covers the rest,
      -- and still lands before any launch.
      let entailedNames =
            [ModuleName name | name <- Map.keys cohort, name /= invokedName]
      unless (debug || null entailedNames) $
        enforceAgentArtifactGuard
          (opts ^. #allowDowngrade)
          manifestPath
          entailedNames
          mempty

      expandedPlan <- case expandEntailedEdges (lookupCohortMigrations cohort) (migrationPlan ^. #steps) of
        Left err -> exitErr level (renderEntailmentError cohort err)
        Right expandedSteps -> pure (migrationPlan & #steps .~ expandedSteps)

      let pending =
            pendingBlueprintMigrations
              (opts ^. #rerun)
              (lookupCohortIdentity cohort)
              receipts
              expandedPlan
      if null pending
        then reportNoPending expandedPlan
        else do
          -- One execution context per blueprint that owns a pending step, so
          -- each step gets its own reference files, allowed tools, and
          -- variables. All of them are resolved now rather than lazily per
          -- step: a user should answer every prompt up front rather than
          -- being interrupted between agent sessions.
          preparedByOwner <- prepareCohort level modelConfig opts cohort pending
          traceSink <- traceSinkForConfig level modelConfig
          context <- gatherAgentContext
          let signalPath = notApplicableSignalPath projectRoot
              renderStep position total step =
                case Map.lookup (step ^. #owner) preparedByOwner of
                  Nothing -> missingOwnerMessage step
                  Just prepared ->
                    renderBlueprintMigrationSystemPrompt
                      migrationPromptTemplate
                      signalPath
                      context
                      prepared
                      position
                      total
                      step
              renderDebugStep position total step =
                renderStep position total step
                  <> maybe
                    ""
                    ("\n\n===== Initial user instruction =====\n" <>)
                    (opts ^. #prompt)

          if debug
            then
              TIO.putStrLn $
                "Blueprint migrations for "
                  <> invokedName
                  <> ": "
                  <> renderVersion (expandedPlan ^. #from)
                  <> " -> "
                  <> renderVersion (expandedPlan ^. #to)
                  <> "\n"
                  <> formatBlueprintMigrationDebugOutput renderDebugStep pending
            else do
              -- The agent needs somewhere to put the signal file, and the
              -- directory is created by the first receipt anyway.
              createDirectoryIfMissing True (takeDirectory signalPath)
              result <-
                runBlueprintMigrationsWith
                  (launchMigration traceSink modelConfig opts preparedByOwner signalPath renderStep)
                  (recordMigration manifestPath cohort)
                  pending
              handleRunResult level (blueprint ^. #name) result

-- | What a step's owning blueprint declares, for the pure expander. A name
-- absent from the cohort was not installed, which the expander reports against
-- the edge that named it.
lookupCohortMigrations :: Map Text CohortBlueprint -> Text -> Maybe [BlueprintMigration]
lookupCohortMigrations cohort name =
  (^. #blueprint . #migrations) <$> Map.lookup name cohort

-- | A step's owning blueprint's recorded identity, for receipt matching. This
-- is the whole cross-entry-point property in one function: a step owned by
-- @kiroku-upgrade@ is filtered against kiroku's receipts no matter which
-- blueprint the user named.
lookupCohortIdentity :: Map Text CohortBlueprint -> Text -> Maybe (ModuleName, ArtifactOrigin)
lookupCohortIdentity cohort name = do
  resolved <- Map.lookup name cohort
  pure (resolved ^. #blueprint . #name, resolved ^. #origin)

-- | Prepare one execution context per blueprint that owns a pending step.
--
-- Blueprints in the cohort that own no pending step are deliberately skipped:
-- preparing one resolves its variables, which can prompt, and asking a user to
-- answer questions for a blueprint whose every edge already has a receipt is
-- pure friction.
prepareCohort ::
  LogLevel ->
  AgentModelConfig ->
  BlueprintMigrationOpts ->
  Map Text CohortBlueprint ->
  [BlueprintMigrationStep] ->
  IO (Map Text PreparedBlueprintExecution)
prepareCohort level modelConfig opts cohort pending =
  Map.fromList <$> traverse prepareOne owners
  where
    owners = nub [step ^. #owner | step <- pending]

    prepareOne name = case Map.lookup name cohort of
      -- Unreachable: every owner came out of a plan the cohort resolved.
      Nothing -> exitErr level ("Internal error: no blueprint loaded for migration step owner '" <> name <> "'.")
      Just resolved -> do
        prepared <-
          prepare level modelConfig opts (resolved ^. #blueprint) (resolved ^. #blueprintDir)
        pure (name, prepared)

-- | Unreachable in production — 'prepareCohort' covers every pending step's
-- owner — but a rendered message beats a partial-function crash if the two
-- ever drift apart.
missingOwnerMessage :: BlueprintMigrationStep -> Text
missingOwnerMessage step =
  "Internal error: no execution context prepared for '" <> step ^. #owner <> "'."

discoverMigrationBlueprint :: LogLevel -> FilePath -> [FilePath] -> ModuleName -> IO CohortBlueprint
discoverMigrationBlueprint level projectRoot searchPaths requestedName = do
  result <- resolveCohortBlueprint projectRoot searchPaths requestedName
  case result of
    Right resolved -> pure resolved
    Left err -> exitErr level (renderCohortError err)

renderCohortError :: CohortResolutionError -> Text
renderCohortError = \case
  CohortArtifactWrongKind name kind ->
    "'" <> name ^. #unModuleName <> "' is a " <> kind <> ", not a blueprint."
  CohortArtifactMissing name searched ->
    renderModuleLoadError (ModuleNotFound name searched)
  CohortArtifactUnusable err -> renderModuleLoadError err

-- | Turn an expansion failure into the message a blueprint author has to act
-- on. These are the only feedback an author gets about an @entails@ list, so
-- each says which blueprint is at fault and what to do next.
renderEntailmentError :: Map Text CohortBlueprint -> EntailmentError -> Text
renderEntailmentError cohort = \case
  EntailedBlueprintNotFound site name ->
    renderSite site
      <> " entails blueprint '"
      <> name
      <> "', which is not installed on this machine.\n\n"
      <> "  Install it, then re-run:\n"
      <> "    seihou install <url> --module "
      <> name
  EntailedEdgeNotDeclared site name fromVersion toVersion ->
    renderSite site
      <> " entails edge "
      <> fromVersion
      <> " -> "
      <> toVersion
      <> " of '"
      <> name
      <> "', which declares no such edge.\n\n"
      <> "  This is an authoring error in '"
      <> site ^. #blueprint
      <> "'. Report it upstream.\n"
      <> "  Declared edges of '"
      <> name
      <> "': "
      <> declaredEdges name
  EntailmentCycle chain ->
    "blueprint migration entailment forms a cycle:\n"
      <> T.intercalate "\n" ["    " <> link | link <- chain]
      <> "\n\n  Each of these edges declares that the next must run first, so"
      <> " there is no order that satisfies them all.\n"
      <> "  This is an authoring error in the blueprints listed. Report it upstream."
  where
    renderSite site =
      "'" <> site ^. #blueprint <> "' edge " <> site ^. #from <> " -> " <> site ^. #to

    -- The likeliest cause of a missing edge is an off-by-one in a version
    -- string, so showing the real list usually makes the mistake obvious.
    declaredEdges name = case Map.lookup name cohort of
      Nothing -> "(none: the blueprint could not be read)"
      Just resolved ->
        case [edge ^. #from <> " -> " <> edge ^. #to | edge <- resolved ^. #blueprint . #migrations] of
          [] -> "(it declares no migrations at all)"
          rendered -> T.intercalate ", " rendered

-- | Decide both ends of the version window, running the blueprint's declared
-- probe only if it is needed.
--
-- The probe is skipped entirely when @--to@ was supplied: an explicit
-- invocation must never execute a subprocess whose answer it would discard.
-- It /is/ run under @--debug@, though nothing else there is: it is a
-- read-only command the blueprint supplies, and refusing to run it would make
-- debug output diverge from a real run in exactly the way that matters — the
-- window, and therefore which edges are shown.
--
-- Receipts are matched against the invoked blueprint's own identity. That is
-- the same "by owner" rule the per-step filtering uses: the window is
-- expressed in the invoked library's version space, so the receipts that
-- bound it are the ones the invoked blueprint owns.
resolveWindow ::
  LogLevel ->
  FilePath ->
  CohortBlueprint ->
  [AppliedBlueprintMigration] ->
  BlueprintMigrationOpts ->
  IO ResolvedWindow
resolveWindow level projectRoot invoked receipts opts = do
  fromFlag <- traverse (parseRequestedVersion level "--from") (opts ^. #from)
  toFlag <- traverse (parseRequestedVersion level "--to") (opts ^. #to)
  probed <- case (toFlag, invoked ^. #blueprint . #versionProbe) of
    (Just _, _) -> pure Nothing
    (Nothing, Nothing) -> pure Nothing
    (Nothing, Just command) -> do
      result <- executeVersionProbe level projectRoot command
      pure $ case result of
        ProbeVersion version -> Just (version, command)
        _ -> Nothing
  let recorded =
        highestMigratedVersion
          (invoked ^. #origin)
          (invoked ^. #blueprint . #name)
          receipts
  case resolveMigrationWindow fromFlag toFlag probed recorded of
    Right window -> pure window
    Left err ->
      exitErr level (formatWindowResolutionError (invoked ^. #blueprint . #name) err)

-- | Run one version probe under a wall-clock bound, reporting anything that
-- is not a version and returning it for the caller to discard.
--
-- Every failure here is a warning rather than an error. The user did not
-- write the probe, and still has @--to@; turning an author's broken command
-- into a hard failure would take a working escape hatch away from the person
-- who cannot fix it.
executeVersionProbe :: LogLevel -> FilePath -> Text -> IO VersionProbeResult
executeVersionProbe level projectRoot command = do
  bounded <- timeout probeTimeoutMicroseconds run
  let result = fromMaybe (ProbeExitedNonZero 124 timedOut) bounded
  mapM_ (logIO level . logWarn) (formatProbeFailure command result)
  pure result
  where
    run = runEff $ runProcessIO $ runVersionProbe command projectRoot

    -- 124 is what `timeout(1)` reports, which is the closest thing to a
    -- convention for "the command did not finish".
    timedOut =
      "timed out after "
        <> T.pack (show (probeTimeoutMicroseconds `div` 1_000_000))
        <> " seconds"

-- | How long a version probe may take before the command stops waiting for
-- it. Generous enough for a cold @nix eval@, short enough that a probe that
-- hangs forever does not hang @seihou agent migrate@ forever with it.
probeTimeoutMicroseconds :: Int
probeTimeoutMicroseconds = 60 * 1_000_000

parseRequestedVersion :: LogLevel -> Text -> Text -> IO Version
parseRequestedVersion level flag raw =
  case parseVersion raw of
    Just version -> pure version
    Nothing -> exitErr level (flag <> " value '" <> raw <> "' is not a valid dotted numeric version.")

readMigrationReceipts :: LogLevel -> FilePath -> IO [AppliedBlueprintMigration]
readMigrationReceipts level manifestPath = do
  result <- runEff $ runFilesystem $ runManifestStore manifestPath readManifest
  case result of
    Left err -> exitErr level ("Error reading migration receipts: " <> err)
    Right Nothing -> pure []
    Right (Just manifest) -> pure (manifest ^. #blueprintMigrations)

prepare ::
  LogLevel ->
  AgentModelConfig ->
  BlueprintMigrationOpts ->
  Blueprint ->
  FilePath ->
  IO PreparedBlueprintExecution
prepare level modelConfig opts blueprint blueprintDir = do
  let providerCanMountFiles =
        modelConfig ^. #provider == AgentProviderClaudeCli
          || modelConfig ^. #provider == AgentProviderCodexCli
  result <-
    prepareBlueprintExecution
      BlueprintExecutionRequest
        { blueprint = blueprint,
          blueprintDir = blueprintDir,
          variableOverrides = opts ^. #vars,
          namespaceOverride = opts ^. #namespace,
          contextOverride = opts ^. #context,
          canMountFiles = providerCanMountFiles,
          logLevel = level
        }
  case result of
    Left errs -> do
      logIO level $ logError "Error resolving blueprint migration variables:"
      mapM_ (logIO level . logError . ("  " <>) . formatVarError) errs
      exitFailure
    Right prepared -> pure prepared

-- | Where an edge reports that it does not apply.
--
-- It lives under @.seihou\/@ rather than in the working tree so a signal a
-- crashed run left behind never shows up in @git status@, and the leading dot
-- keeps it out of the way of @.seihou@'s own contents.
notApplicableSignalPath :: FilePath -> FilePath
notApplicableSignalPath projectRoot = projectRoot </> ".seihou" </> ".migrate-signal"

-- | Delete a signal left behind by an earlier edge or a crashed run, so it
-- cannot be misread as this edge's answer.
clearNotApplicableSignal :: FilePath -> IO ()
clearNotApplicableSignal signalPath = do
  exists <- doesFileExist signalPath
  when exists (removeFile signalPath)

-- | Read and consume the signal an edge may have written.
--
-- The file's existence is the signal: an agent creates it deliberately, with
-- its own tools, at a path only this command names. An empty one therefore
-- still means "not applicable", it just fails to say why.
readNotApplicableSignal :: FilePath -> IO (Maybe Text)
readNotApplicableSignal signalPath = do
  exists <- doesFileExist signalPath
  if not exists
    then pure Nothing
    else do
      contents <- TIO.readFile signalPath
      removeFile signalPath
      let firstLine = listToMaybe (filter (not . T.null) (map T.strip (T.lines contents)))
      pure (Just (fromMaybe unstatedNotApplicableReason firstLine))

launchMigration ::
  -- | built once per command, so every migration edge appends to one destination
  TraceSink ->
  AgentModelConfig ->
  BlueprintMigrationOpts ->
  -- | one execution context per owning blueprint, keyed by name
  Map Text PreparedBlueprintExecution ->
  -- | where this edge reports that it does not apply
  FilePath ->
  (Int -> Int -> BlueprintMigrationStep -> Text) ->
  Int ->
  Int ->
  BlueprintMigrationStep ->
  IO (Either BlueprintMigrationLaunchFailure BlueprintMigrationLaunchResult)
launchMigration traceSink modelConfig opts preparedByOwner signalPath renderStep position total step = do
  TIO.putStrLn $
    "Running blueprint migration "
      <> stepLabel
      <> ": "
      <> formatMigrationStepLabel step
  clearNotApplicableSignal signalPath
  let systemPrompt = renderStep position total step
  case Map.lookup (step ^. #owner) preparedByOwner of
    Nothing -> pure (Left (BlueprintMigrationProviderFailure (missingOwnerMessage step)))
    Just prepared -> case modelConfig ^. #provider of
      AgentProviderClaudeCli -> launchInteractive prepared systemPrompt
      AgentProviderCodexCli -> launchInteractive prepared systemPrompt
      AgentProviderAnthropic -> launchCompletion systemPrompt
      AgentProviderOpenAI -> launchCompletion systemPrompt
  where
    stepLabel = T.pack (show position) <> "/" <> T.pack (show total)

    -- An interactive session communicates only through its exit code, so the
    -- signal file is the one channel an agent has to report inapplicability.
    -- Only the owning blueprint's files/ directory is mounted: handing this
    -- step another cohort member's reference material invites the agent to
    -- pre-apply work the framing prompt tells it to leave for a later step.
    launchInteractive prepared systemPrompt = do
      exitCode <-
        launchConfiguredAgentAddingDirs
          (maybeToList (prepared ^. #mountedFilesDir))
          modelConfig
          (prepared ^. #allowedTools)
          False
          systemPrompt
          (opts ^. #prompt)
      case exitCode of
        ExitSuccess -> Right <$> sessionResultFromSignal Nothing
        failure -> do
          -- A failed session's signal is not this edge's answer.
          clearNotApplicableSignal signalPath
          pure (Left (BlueprintMigrationProcessFailure failure))

    -- An API provider hands us its reply directly, so the marker line works.
    -- The signal file is still checked, because a provider given tool access
    -- may take the prompt's first instruction rather than its fallback.
    launchCompletion systemPrompt = do
      result <-
        runAgentCompletion
          (buildAgentCompletionRequestWith traceSink modelConfig systemPrompt (opts ^. #prompt))
      case result of
        Left err -> do
          clearNotApplicableSignal signalPath
          pure (Left (BlueprintMigrationProviderFailure err))
        Right assistantText -> do
          TIO.putStrLn assistantText
          Right <$> sessionResultFromSignal (parseNotApplicableSignal assistantText)

    sessionResultFromSignal parsedReason = do
      fileReason <- readNotApplicableSignal signalPath
      case fileReason <|> parsedReason of
        Nothing -> pure BlueprintMigrationSessionReturned
        Just reason -> do
          TIO.putStrLn $
            "Blueprint migration "
              <> stepLabel
              <> ": "
              <> formatMigrationStepLabel step
              <> " — not applicable: "
              <> reason
          pure (BlueprintMigrationSessionNotApplicable reason)

-- | Write one edge's receipt under the identity of the blueprint that /owns/
-- the edge, not the one the user named on the command line.
--
-- This looks like a mistake to a reader who does not know the design, and it
-- is the single line that makes fan-out correct. A project that crossed
-- kiroku's edge by running @keiro-upgrade@ has a receipt saying so under
-- @kiroku-upgrade@'s name and origin, so running @kiroku-upgrade@ directly
-- afterwards finds it and crosses nothing twice. Recording under the invoking
-- blueprint would make the same work look like two different edges.
recordMigration ::
  FilePath ->
  -- | every blueprint this run loaded, keyed by name
  Map Text CohortBlueprint ->
  BlueprintMigrationStep ->
  MigrationOutcome ->
  IO (Either Text ())
recordMigration manifestPath cohort step migrationOutcome =
  case Map.lookup (step ^. #owner) cohort of
    -- Unreachable: the step came out of a plan this cohort resolved.
    Nothing -> pure (Left (missingOwnerMessage step))
    Just owner -> do
      now <- getCurrentTime
      recordAppliedBlueprintMigration
        manifestPath
        AppliedBlueprintMigration
          { name = owner ^. #blueprint . #name,
            origin = owner ^. #origin,
            blueprintVersion = owner ^. #blueprint . #version,
            fromVersion = step ^. #edge . #from,
            toVersion = step ^. #edge . #to,
            outcome = migrationOutcome,
            appliedAt = now,
            agentSessionId = Nothing
          }

handleRunResult :: LogLevel -> ModuleName -> BlueprintMigrationRunResult -> IO ()
handleRunResult level blueprintName = \case
  BlueprintMigrationNoWork ->
    TIO.putStrLn "No pending blueprint migrations."
  BlueprintMigrationComplete completed -> do
    let notApplicable = length [() | (_, MigrationNotApplicable _) <- completed]
    TIO.putStrLn $
      "Completed "
        <> T.pack (show (length completed))
        <> " blueprint migration(s) for '"
        <> blueprintName ^. #unModuleName
        <> "'"
        <> ( if notApplicable > 0
               then " (" <> T.pack (show notApplicable) <> " not applicable)"
               else ""
           )
        <> "."
  BlueprintMigrationLaunchFailed step failure -> do
    let prefix =
          "Blueprint migration "
            <> formatMigrationStepLabel step
            <> " failed; completed earlier edges remain recorded. "
        retry = "Fix the provider error, then rerun the same command to resume."
    case failure of
      BlueprintMigrationProcessFailure exitCode -> do
        logIO level $ logError $ prefix <> "Provider exited with " <> T.pack (show exitCode) <> ". " <> retry
        exitWith exitCode
      BlueprintMigrationProviderFailure err -> do
        logIO level $ logError $ prefix <> err <> " " <> retry
        exitFailure
  BlueprintMigrationRecordFailed step err -> do
    logIO level $
      logError $
        "Agent completed blueprint migration "
          <> formatMigrationStepLabel step
          <> ", but its receipt could not be recorded: "
          <> err
          <> ". The next edge was not started; repair manifest access, then rerun the same command."
    exitFailure

reportNoPending :: BlueprintMigrationPlan -> IO ()
reportNoPending migrationPlan
  | null (migrationPlan ^. #steps) =
      TIO.putStrLn "No blueprint migrations are declared inside the requested version window."
  | otherwise =
      TIO.putStrLn "All blueprint migrations in the requested version window already have receipts."

renderPlanError :: MigrationPlanError -> Text
renderPlanError (MigrationVersionUnparseable raw) =
  "the blueprint declares an unparseable migration version: '" <> raw <> "'."
renderPlanError (MigrationDowngradeNotSupported current target) =
  "blueprint migration downgrades are not supported: --from "
    <> renderVersion current
    <> ", --to "
    <> renderVersion target
    <> "."
renderPlanError (MigrationDuplicateEdge fromVersion _) =
  "the blueprint declares more than one migration starting at "
    <> renderVersion fromVersion
    <> "; the author must merge or remove the duplicate."

renderModuleLoadError :: ModuleLoadError -> Text
renderModuleLoadError = \case
  ModuleNotFound name searched ->
    "Blueprint '"
      <> name ^. #unModuleName
      <> "' not found. Searched in:\n"
      <> T.intercalate "\n" (map (("  " <>) . T.pack) searched)
  DhallEvalError name msg ->
    "Failed to evaluate '" <> name ^. #unModuleName <> "': " <> msg
  DhallDecodeError name msg ->
    "Failed to decode '" <> name ^. #unModuleName <> "': " <> msg
  ValidationError name msgs ->
    "Validation failed for '"
      <> name ^. #unModuleName
      <> "':\n"
      <> T.intercalate "\n" (map ("  " <>) msgs)
  CircularDependency names ->
    "Circular dependency detected: " <> T.intercalate " -> " (map (^. #unModuleName) names)
  MissingSourceFile name path ->
    "Missing source file in '" <> name ^. #unModuleName <> "': " <> T.pack path
  RegistryEvalError path msg ->
    "Failed to evaluate registry at '" <> path <> "': " <> msg

exitErr :: LogLevel -> Text -> IO a
exitErr level msg = do
  logIO level (logError msg)
  exitFailure