heni-0.1.0.0: src/Test/Heni/Runner.hs
-- | One baseline/candidate lifecycle shared by library users and the CLI.
module Test.Heni.Runner
( Campaign,
mkCampaign,
campaignCases,
campaignCommands,
RunOptions (..),
ReusePolicy (..),
defaultRunOptions,
RunEvent (..),
ConfigurationFailure (..),
runCampaign,
)
where
import Control.Concurrent.MVar
import Control.Exception hiding (handle)
import Control.Monad (forM_, unless, when)
import Data.Aeson
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString qualified as BS
import Data.Foldable (toList)
import Data.IORef
import Data.List (sort)
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text
import Data.Version (showVersion)
import System.Directory qualified as Dir
import System.Environment (getEnvironment)
import System.FilePath (takeDirectory)
import System.Info qualified as Platform
import Test.Heni.Adapter qualified as Adapter
import Test.Heni.BuildReuse qualified as Build
import Test.Heni.Case
import Test.Heni.Command
import Test.Heni.Crash qualified as Crash
import Test.Heni.Edit
import Test.Heni.Expectation
import Test.Heni.Internal.Baseline qualified as Baseline
import Test.Heni.Internal.Codec
import Test.Heni.Internal.Files (digest, withFinalizer)
import Test.Heni.Process
import Test.Heni.Replay qualified as Replay
import Test.Heni.Report
import Test.Heni.Resource qualified as Resource
import Test.Heni.Result
import Test.Heni.Scheduler qualified as Scheduler
import Test.Heni.Workspace
import Prelude hiding (sequence)
-- | Validated references and portable definitions, with optional trusted IO hooks.
data Campaign = Campaign !(Map.Map CommandId Command) ![(CaseDefinition, CaseHooks)]
-- | Construction rejects all case/command definition errors before execution.
mkCampaign :: Map.Map CommandId Command -> [(CaseDefinition, CaseHooks)] -> Either ConfigError Campaign
mkCampaign commands definitions = do
_ <- mkSelectedCases [i | (CaseDefinition {caseId = i}, _) <- definitions]
mapM_ validateCommand (Map.elems commands)
forM_ definitions $ \(definition@CaseDefinition {caseId, build, test, edits}, CaseHooks {customEdits}) -> do
validateCase definition
unless
(all (`Map.member` commands) (build : maybe [] pure test))
(Left (ConfigError (caseIdText caseId <> ".commands") "unknown command reference"))
unless
(length customEdits == Set.size (Set.fromList (map fst customEdits)))
(Left (ConfigError (caseIdText caseId <> ".hooks") "duplicate custom edit hook"))
forM_ edits $ \case
CustomEdit key | key `notElem` map fst customEdits -> Left (ConfigError (caseIdText caseId <> ".edits") "missing custom edit hook")
_ -> Right ()
pure (Campaign commands definitions)
-- | Portable definitions in author order.
campaignCases :: Campaign -> [CaseDefinition]
campaignCases (Campaign _ definitions) = map fst definitions
-- | Named reusable commands.
campaignCommands :: Campaign -> Map.Map CommandId Command
campaignCommands (Campaign commands _) = commands
-- | Observer notifications do not own verdicts.
data RunEvent = CaseStarted !CaseId | StageFinished !CaseId !Text !StageEvidence | CaseFinished !CaseResult
deriving stock (Eq, Show)
-- | Fresh is the reference mode. Opt-in sharing requires a nonsecret toolchain
-- contract; structured suites also require their exact producer identity. The
-- consumer asserts that external inputs and test behavior do not depend on
-- per-attempt identity fields. Unknown external state disables sharing.
data ReusePolicy = Fresh | ReuseBaselines !Text !(Maybe Text)
deriving stock (Eq, Show)
data BaselineKey = BaselineKey !Text !Command !(Maybe Command) !(Maybe Text) !Expectation !EvidenceMode ![(String, String)] ![(Text, Resource.ResourceBinding)] !ReusePolicy
deriving stock (Eq)
data BaselineEntry = BaselineEntry !CaseId !(Maybe Text)
-- | Explicit source/output roots and optional selected ledger. No mandatory output.
data RunOptions = RunOptions
{ sourceRoot :: !FilePath,
outputDirectory :: !FilePath,
sourcePolicy :: !SnapshotPolicy,
selectedCases :: !(Maybe SelectedCases),
progress :: !(RunEvent -> IO ()),
workspaceCleanup :: !(Workspace -> IO ()),
workers :: !Int,
resources :: ![Resource.ResourceProvider],
resourceClaims :: !(Map.Map CaseId [Text]),
reusePolicy :: !ReusePolicy,
buildReuse :: !(Maybe Build.BuildReuse)
}
-- | Fresh serial workspaces, whole working-tree inputs, all cases, silent observer.
defaultRunOptions :: FilePath -> FilePath -> RunOptions
defaultRunOptions source output = RunOptions source output defaultSnapshotPolicy Nothing (const (pure ())) (const (pure ())) 1 [] Map.empty Fresh Nothing
-- | Invalid configurations are separate from executed mutation results.
newtype ConfigurationFailure = ConfigurationFailure ConfigError
deriving stock (Show)
deriving anyclass (Exception)
-- | Execute selected cases. Runtime failures return structured results. On
-- asynchronous cancellation, finalize the partial report and rethrow the cause.
runCampaign :: RunOptions -> Campaign -> IO RunResult
runCampaign RunOptions {sourceRoot, outputDirectory, sourcePolicy, selectedCases, progress, workspaceCleanup, workers, resources, resourceClaims, reusePolicy, buildReuse} campaign@(Campaign commands definitions) = mask $ \restore -> do
either (throwIO . ConfigurationFailure) pure (Resource.validateResources resources)
unless (workers > 0 && workers <= 256) (throwIO (ConfigurationFailure (ConfigError "workers" "must be between 1 and 256")))
forM_ (Map.toList resourceClaims) $ \(cid, claims) -> do
unless
(cid `elem` [caseId | (CaseDefinition {caseId}, _) <- definitions] && all (`elem` map Resource.resourceName resources) claims && length claims == Set.size (Set.fromList claims))
(throwIO (ConfigurationFailure (ConfigError "resources.claims" "unknown case/resource or duplicate claim")))
forM_ buildReuse (either (throwIO . ConfigurationFailure) pure . Build.validateBuildReuse)
case reusePolicy of
ReuseBaselines contract adapter ->
unless
(not (Text.null (Text.strip contract)) && maybe True (not . Text.null . Text.strip) adapter)
(throwIO (ConfigurationFailure (ConfigError "reuse" "toolchain/known adapter contracts must be nonempty")))
Fresh -> pure ()
selected <- either (throwIO . ConfigurationFailure) pure $ maybe (mkSelectedCases [i | (CaseDefinition {caseId = i}, _) <- definitions]) Right selectedCases
let ids = selectedCaseIds selected
unless
(all (`elem` [i | (CaseDefinition {caseId = i}, _) <- definitions]) ids)
(throwIO (ConfigurationFailure (ConfigError "selection" "unknown case ID")))
validateOutputLocation sourceRoot sourcePolicy outputDirectory `catch` \(e :: WorkspaceError) ->
throwIO (ConfigurationFailure (ConfigError "output" (Text.pack (show e))))
existing <- Dir.doesPathExist outputDirectory
when existing (throwIO (ConfigurationFailure (ConfigError "output" "run directory already exists")))
-- Executable availability is validated for selected commands only.
let chosen = [(d, h) | i <- ids, (d@CaseDefinition {caseId = j}, h) <- definitions, i == j]
commandIds = Set.fromList [c | (CaseDefinition {build, test}, _) <- chosen, c <- build : maybe [] pure test]
let activeNames = concat [Map.findWithDefault [] cid resourceClaims | cid <- ids]
activeResources = filter ((`elem` activeNames) . Resource.resourceName) resources
effectiveWorkers = minimum (workers : [Resource.resourceCapacity p | p <- activeResources, Resource.resourceScope p == Resource.WorkerScope])
forM_ (Set.toList commandIds) $ \key -> validateExecutable (commands Map.! key) >>= either (throwIO . ConfigurationFailure) pure
forM_ (concatMap Resource.resourceCommands activeResources) $ \command -> validateExecutable command >>= either (throwIO . ConfigurationFailure) pure
resultsRef <- newIORef Map.empty
failuresRef <- newIORef []
contextRef <-
newIORef
( case initialMetadata campaign chosen of
Object o ->
Object
( KM.insert
"execution"
( object
[ "workers" .= effectiveWorkers,
"requestedWorkers" .= workers,
"baselineReuse" .= reusePolicyJSON reusePolicy,
"buildReuseContract" .= fmap (\Build.BuildReuse {contractIdentity} -> contractIdentity) buildReuse,
"resourceDefinitions" .= [object ["name" .= Resource.resourceName p, "scope" .= resourceScopeText (Resource.resourceScope p), "capacity" .= Resource.resourceCapacity p, "commands" .= map redactedCommand (Resource.resourceCommands p)] | p <- resources],
"resourceClaims" .= object [Key.fromText (caseIdText cid) .= claims | (cid, claims) <- Map.toAscList resourceClaims]
]
)
o
)
other -> other
)
sequenceRef <- newIORef 0
runIdentity <- newIORef ""
journalLock <- newMVar ()
observerLock <- newMVar ()
workerBindings <- newMVar Map.empty
baselineCache <- Baseline.newBaselineCache
let summary = do
results <- Map.elems <$> readIORef resultsRef
failures <- readIORef failuresRef
either (throwIO . ConfigurationFailure) pure (summarizeRun selected results failures)
notifyProgress event = withMVar observerLock (\_ -> progress event)
stored = StoredReport <$> readIORef contextRef <*> summary
journal event = withMVar journalLock $ \_ -> do
value <- stored
sequence <- readIORef sequenceRef
appendJournal outputDirectory sequence event value
writeIORef sequenceRef (sequence + 1)
addCaseDetails caseId key value = atomicModify_ contextRef $ \context -> case context of
Object metadata -> Object (mapToKeyMap metadata caseId key value)
_ -> context
runOne snapshot leases retainedWorker (definition@CaseDefinition {caseId, build, test, expectation, evidenceMode}, hooks) = do
baselineRef <- newIORef BaselineUnavailable
mutationRef <- newIORef Unchanged
stagesRef <- newIORef []
errorsRef <- newIORef []
cleanupRef <- newIORef []
producerIdentity <- newIORef Nothing
environmentRef <- newIORef []
actualBaselineRef <- newIORef Nothing
let currentEvidence stopped = CaseEvidence <$> readIORef baselineRef <*> readIORef mutationRef <*> readIORef stagesRef <*> readIORef errorsRef <*> readIORef cleanupRef <*> pure stopped
cleanup workspace =
workspaceCleanup workspace `catch` \(e :: SomeException) -> do
modifyIORef' cleanupRef (++ [Text.pack (displayException e)])
throwIO e
updatePartial = do
observations <- currentEvidence (Just "case execution is incomplete")
atomicModify_ resultsRef (Map.insert caseId (classifyCase definition observations))
runStage role workspace stage command = do
identity <- readIORef runIdentity
attempt <-
if stage `elem` [Test, Replay] && (evidenceMode == StructuredTestsV1 || isCrash expectation)
then Just <$> Adapter.newEvidenceAttempt outputDirectory identity caseId stage role
else pure Nothing
additions <- readIORef environmentRef
let prefix = Text.unpack (caseIdText caseId <> "-" <> role <> "-" <> stageName stage)
wanted = witnessesFor expectation stage
effectiveCommand = withEnvironment (additions ++ maybe [] Adapter.attemptEnvironment attempt) (withSelector definition command)
options = case defaultProcessOptions outputDirectory prefix of
ProcessOptions d p g _ _ observer -> ProcessOptions d p g wanted reservedEnvironment observer
record processReport@ProcessReport {evidence, errors, logs, preservedWitnesses} = do
normalized <- normalizeAttempt producerIdentity definition attempt evidence
enhanced <- if evidenceMode == StructuredTestsV1 then observeStructured hooks normalized else enhance hooks normalized
when (role == "candidate") $ do
modifyIORef' stagesRef (++ [enhanced])
let invalidEvidence = either pure (const []) (validateStageEvidence enhanced)
modifyIORef' errorsRef (++ (errors ++ invalidEvidence))
addCaseDetails
caseId
(role <> "-" <> stageName stage)
( object
[ "stage" .= stageJSON enhanced,
"logs" .= map logJSON logs,
"preservedWitnesses" .= preservedWitnesses,
"errors" .= errors,
"command" .= redactedCommand effectiveCommand,
"attempt" .= maybe Null Adapter.attemptIdentityJSON attempt
]
)
updatePartial
journal "stage"
notifyProgress (StageFinished caseId role enhanced)
pure (processReport, enhanced)
process <- try (runCommand options workspace stage effectiveCommand)
case process of
Left ProcessAborted {report, cause} -> record report >> throwIO cause
Right report -> record report
updatePartial
attempted <- try $ restore $ do
notifyProgress (CaseStarted caseId)
prepared <- mapM (\(name, lease) -> (name,) <$> Resource.prepareResource lease) leases
additions <- either (throwIO . ConfigurationFailure) pure (Resource.bindingEnvironment prepared)
writeIORef environmentRef additions
addCaseDetails caseId "baseline-resources" (toJSON [resourceBindingJSON name binding | (name, binding) <- prepared])
let executeBaseline =
withWorkspace snapshot $ \workspace ->
( do
(buildReport, buildEvidence) <- runStage "baseline" workspace Build (commands Map.! build)
if not (stagePassed buildReport buildEvidence)
then pure False
else case test of
Nothing -> pure True
Just testId | needsTest expectation -> do
(testReport, testEvidence) <- runStage "baseline" workspace Test (commands Map.! testId)
pure (stagePassed testReport testEvidence && baselineSelection definition testEvidence)
_ -> pure True
)
`withFinalizer` cleanup workspace
inherited <- sort <$> getEnvironment
let CaseDefinition {selector} = definition
baselineKey = BaselineKey (snapshotIdentity snapshot) (commands Map.! build) (if needsTest expectation then (commands Map.!) <$> test else Nothing) selector expectation evidenceMode inherited prepared reusePolicy
hooksAllow = case hooks of CaseHooks {customEdits, customEvidence} -> null customEdits && case customEvidence of Nothing -> True; Just _ -> False
epochsKnown = all (\(_, Resource.ResourceBinding {epoch}) -> epoch /= Nothing) prepared
adapterKnown = case (evidenceMode, reusePolicy) of (StructuredTestsV1, ReuseBaselines _ (Just _)) -> True; (StructuredTestsV1, _) -> False; _ -> True
eligible = reusePolicy /= Fresh && hooksAllow && epochsKnown && adapterKnown
compute = do
passed <- executeBaseline
writeIORef actualBaselineRef (Just passed)
producer <- readIORef producerIdentity
let producerAgrees = case (evidenceMode, reusePolicy) of (StructuredTestsV1, ReuseBaselines _ expected) -> expected == producer; _ -> True
pure (if passed && producerAgrees then Just (BaselineEntry caseId producer) else Nothing)
(reused, entry) <- if eligible then Baseline.shareBaseline baselineCache baselineKey compute else (False,) <$> compute
actual <- readIORef actualBaselineRef
let baselinePassed = case entry of Just _ -> True; Nothing -> actual == Just True
reason
| reusePolicy == Fresh = "fresh reference mode"
| not hooksAllow = "custom hooks have no portable baseline identity"
| not epochsKnown = "resource epoch is unknown"
| not adapterKnown = "structured producer identity is undeclared"
| reused = "matching campaign-local baseline contract"
| otherwise = "executed fresh baseline; only passing matching producer facts are shareable"
forM_ entry $ \(BaselineEntry _ producer) -> writeIORef producerIdentity producer
addCaseDetails caseId "baseline-reuse" (object ["reused" .= reused, "ownerCase" .= fmap (\(BaselineEntry owner _) -> caseIdText owner) entry, "reason" .= String reason, "policy" .= reusePolicyJSON reusePolicy, "environmentComparedInMemory" .= True])
writeIORef baselineRef (if baselinePassed then BaselinePassed else BaselineFailed "unchanged build/test or required selection failed")
when baselinePassed $ do
let withCandidate staged action = case retainedWorker of
Nothing -> action staged Nothing
Just worker -> do
inheritedNow <- sort <$> getEnvironment
Build.withBuildCandidate worker (commands Map.! build, additions ++ inheritedNow) staged (\workspace inputId -> action workspace (Just inputId))
applied <- withEditedWorkspace (patchRunner caseId) hooks snapshot definition $ \staged editEvidence ->
withCandidate staged $ \workspace inputId ->
( do
candidateBindings <- mapM (\(name, lease) -> (name,) <$> Resource.prepareResource lease) leases
unless (candidateBindings == prepared) (ioError (userError "resource identity/epoch/environment changed between baseline and candidate"))
candidateEnvironment <- either (throwIO . ConfigurationFailure) pure (Resource.bindingEnvironment candidateBindings)
let buildEnvironment = maybe [] (\key -> [("HENI_BUILD_INPUT_ID", Text.unpack key)]) inputId
writeIORef environmentRef (buildEnvironment ++ candidateEnvironment)
addCaseDetails caseId "build-reuse" (object ["enabled" .= (inputId /= Nothing), "inputIdentity" .= inputId, "contract" .= fmap (\Build.BuildReuse {contractIdentity} -> contractIdentity) buildReuse])
addCaseDetails caseId "candidate-resources" (toJSON [resourceBindingJSON name binding | (name, binding) <- candidateBindings])
writeIORef mutationRef (if expectation == Control then Unchanged else Changed)
addCaseDetails caseId "changes" (changesJSON editEvidence)
(buildReport, buildEvidence) <- runStage "candidate" workspace Build (commands Map.! build)
when (case buildEvidence of StageEvidence {outcome = Exited _} -> True; _ -> False) $ forM_ ((,) <$> retainedWorker <*> inputId) (\(worker, key) -> Build.confirmBuild worker key)
when (stagePassed buildReport buildEvidence && needsTest expectation) $ forM_ test $ \testId -> do
(_, testEvidence) <- runStage "candidate" workspace Test (commands Map.! testId)
case (expectation, testEvidence, hooks) of
(NamedTests expected RequireReplay, StageEvidence {outcome = Exited 1, replay = Just token, selection = SelectionEvidence {failedIds}}, CaseHooks {replayBuilder})
| expected == failedIds && validateStageEvidence testEvidence == Right () -> do
built <- maybe (pure (Right (Replay.replayCommand token (commands Map.! testId)))) (\builder -> builder token (commands Map.! testId)) replayBuilder
case built of
Left why -> addCaseDetails caseId "replayRejected" (String why)
Right replayCommand -> do
_ <- runStage "candidate" workspace Replay replayCommand
pure ()
_ -> pure ()
)
`withFinalizer` cleanup workspace
case applied of
Left (InvalidEdit why) -> writeIORef mutationRef (EditRejected why)
Left (EditInfrastructure why) -> modifyIORef' errorsRef (++ [why])
Right () -> pure ()
case attempted of
Left (e :: SomeException) -> do
modifyIORef' cleanupRef (++ finalizationErrors e)
let original = originalException e
case fromException original :: Maybe SomeAsyncException of
Just _ -> do
observations <- currentEvidence (Just "execution interrupted")
atomicModify_ resultsRef (Map.insert caseId (classifyCase definition observations))
throwIO original
Nothing -> modifyIORef' errorsRef (++ [Text.pack (displayException e)])
Right () -> pure ()
observations <- currentEvidence Nothing
let result@CaseResult {verdict} = classifyCase definition observations
when (verdict == InfrastructureError) $ do
Baseline.clearBaselineCache baselineCache
forM_ retainedWorker Build.retireBuildWorker
atomicModify_ resultsRef (Map.insert caseId result)
patchRunner caseId workspace args = do
-- Patch check/apply share the exact supervisor used by build/test.
let prefix = Text.unpack (caseIdText caseId <> if "--check" `elem` args then "-patch-check" else "-patch-apply")
budget = either (error . show) id (mkStageBudget 5000000)
command = Command "git" args Set.empty Nothing [] budget (OutputPolicy 65536)
ProcessReport {evidence = patchEvidence@StageEvidence {outcome}, errors, logs} <- runCommand (defaultProcessOptions outputDirectory prefix) workspace Setup command `catch` \ProcessAborted {cause} -> throwIO cause
addCaseDetails
caseId
(if "--check" `elem` args then "patch-check" else "patch-apply")
(object ["stage" .= stageJSON patchEvidence, "logs" .= map logJSON logs, "errors" .= errors])
journal "stage"
unless (null errors) (throwIO (EditInfrastructure (Text.intercalate "; " errors)))
case outcome of
Exited 0 -> pure (Right ())
Exited _ -> pure (Left "Git rejected textual patch; see patch logs")
_ -> throwIO (EditInfrastructure ("Git patch supervisor failed: " <> Text.pack (show outcome)))
attempted <- try $ restore $ do
Dir.createDirectoryIfMissing True (takeDirectory outputDirectory)
Dir.createDirectory outputDirectory
identity <- Adapter.newRunIdentity outputDirectory
writeIORef runIdentity identity
atomicModify_ contextRef (\case Object o -> Object (KM.insert "runId" (String identity) o); other -> other)
journal "selected"
withSnapshot sourceRoot sourcePolicy $ \snapshot -> do
atomicModify_ contextRef (addSnapshot snapshot)
journal "snapshot"
let observeResource event = do
atomicModify_ contextRef (appendResourceEvent event)
journal "resource"
context ident = Resource.ResourceContext ident snapshot outputDirectory observeResource
scope target = filter ((== target) . Resource.resourceScope) activeResources
withWorker number action = Resource.withResources (scope Resource.WorkerScope) (context ("worker-" <> Text.pack (show number))) $ \leases ->
let run retained =
(modifyMVar_ workerBindings (pure . Map.insert number (leases, retained)) >> action)
`withFinalizer` modifyMVar_ workerBindings (pure . Map.delete number)
in maybe (run Nothing) (\provider -> Build.withBuildWorker snapshot provider (run . Just)) buildReuse
Resource.withResources (scope Resource.CampaignScope) (context "campaign") $ \campaignLeases ->
Scheduler.runScheduled
effectiveWorkers
(Map.fromList [(Resource.resourceName p, Resource.resourceCapacity p) | p <- activeResources])
[(item, Map.findWithDefault [] cid resourceClaims) | item@(CaseDefinition {caseId = cid}, _) <- chosen]
withWorker
$ \number item@(definition@CaseDefinition {caseId}, _) -> do
(workerLeases, retainedWorker) <- Map.findWithDefault ([], Nothing) number <$> readMVar workerBindings
let claims = Map.findWithDefault [] caseId resourceClaims
persistent = filter ((`elem` claims) . fst) (campaignLeases ++ workerLeases)
localProviders = filter ((`elem` claims) . Resource.resourceName) (scope Resource.CaseScope)
attempt <- try (Resource.withResources localProviders (context ("case-" <> caseIdText caseId)) (\leases -> runOne snapshot (persistent ++ leases) retainedWorker item))
case attempt of
Right () -> pure ()
Left (e :: SomeException) -> case fromException (originalException e) :: Maybe SomeAsyncException of
Just _ -> throwIO (originalException e)
Nothing -> do
Baseline.clearBaselineCache baselineCache
forM_ retainedWorker Build.retireBuildWorker
prior <- Map.lookup caseId <$> readIORef resultsRef
let old = case prior of Just CaseResult {evidence} -> evidence; Nothing -> CaseEvidence BaselineUnavailable Unchanged [] [] [] Nothing
CaseEvidence b m stages errors clean stop = old
failed = classifyCase definition (CaseEvidence b m stages errors (clean ++ [Text.pack (displayException e)]) stop)
atomicModify_ resultsRef (Map.insert caseId failed)
journal "resource-failure"
completed <- Map.lookup caseId <$> readIORef resultsRef
journal "case"
forM_ completed (notifyProgress . CaseFinished)
case attempted of
Left (e :: SomeException) -> do
atomicModify_ failuresRef (++ map RunInfrastructure (finalizationErrors e))
case fromException (originalException e) :: Maybe SomeAsyncException of
Just _ -> atomicModify_ failuresRef (++ [Interrupted (Text.pack (displayException (originalException e)))])
Nothing -> atomicModify_ failuresRef (++ [RunInfrastructure (Text.pack (displayException e))])
Right () -> pure ()
finalWrite <- try (journal "finalized" >> stored >>= writeReport outputDirectory) :: IO (Either SomeException ())
case finalWrite of
Left e -> atomicModify_ failuresRef (++ [RunInfrastructure ("report finalization failed: " <> Text.pack (displayException e))])
Right () -> pure ()
final <- summary
case attempted of
Left e | Just (_ :: SomeAsyncException) <- fromException (originalException e) -> throwIO (originalException e)
_ -> case finalWrite of
Left e | Just (_ :: SomeAsyncException) <- fromException e -> throwIO e
_ -> pure final
stageName :: Stage -> Text
stageName s = case enumValue stageTable s of String t -> t; _ -> error "stage table"
needsTest :: Expectation -> Bool
needsTest BuildRejection {} = False
needsTest (Diagnostic Build _ _) = False
needsTest _ = True
witnessesFor :: Expectation -> Stage -> [Text]
witnessesFor (Diagnostic s _ witness) stage | s == stage = [witness]
witnessesFor (BuildRejection _ witness) Build = [witness]
witnessesFor _ _ = []
stagePassed :: ProcessReport -> StageEvidence -> Bool
stagePassed ProcessReport {errors} evidence@StageEvidence {outcome} = outcome == Exited 0 && null errors && validateStageEvidence evidence == Right ()
baselineSelection :: CaseDefinition -> StageEvidence -> Bool
baselineSelection CaseDefinition {expectation = NamedTests expected _} StageEvidence {selection = SelectionEvidence {selected, executed, executedIds, failedIds, skippedIds}} =
maybe False (> 0) selected && maybe False (> 0) executed && expected `Set.isSubsetOf` executedIds && Set.null failedIds && Set.null (expected `Set.intersection` skippedIds)
baselineSelection CaseDefinition {evidenceMode = StructuredTestsV1} StageEvidence {selection = SelectionEvidence {selected, executed, failedIds}} = maybe False (> 0) selected && maybe False (> 0) executed && Set.null failedIds
baselineSelection _ _ = True
enhance :: CaseHooks -> StageEvidence -> IO StageEvidence
enhance CaseHooks {customEvidence = Nothing} evidence = pure evidence
enhance _ evidence@StageEvidence {outcome} | outcome `elem` [Timeout, Cancelled] = pure evidence
enhance CaseHooks {customEvidence = Just hook} evidence@(StageEvidence s o text unavailable _ _ duration logs) = do
supplied <- hook evidence
case supplied of
Left why -> pure (StageEvidence s o text True (SelectionEvidence Nothing Nothing Nothing Set.empty Set.empty Set.empty [why]) Nothing duration logs)
Right StageEvidence {selection, replay} -> pure (StageEvidence s o text unavailable selection replay duration logs)
isCrash :: Expectation -> Bool
isCrash CrashProbe {} = True
isCrash _ = False
reservedEnvironment :: [String]
reservedEnvironment = ["HENI_EVIDENCE_FILE", "HENI_RUN_ID", "HENI_CASE_ID", "HENI_STAGE", "HENI_ROLE", "HENI_ATTEMPT", "HENI_SELECTOR", "HENI_REPLAY_TOKEN", "HENI_REPLAY_GENERATOR", "HENI_BUILD_INPUT_ID"]
withEnvironment :: [(String, String)] -> Command -> Command
withEnvironment additions (Command exe args sensitive working env budget output) =
Command exe args sensitive working (additions ++ filter (\(key, _) -> key `notElem` map fst additions) env) budget output
-- Protocol evidence is authoritative. A custom observer can reject it, but cannot
-- replace a framework's actual execution or replay membership.
observeStructured :: CaseHooks -> StageEvidence -> IO StageEvidence
observeStructured CaseHooks {customEvidence = Nothing} evidence = pure evidence
observeStructured _ evidence@StageEvidence {outcome} | outcome `elem` [Timeout, Cancelled] = pure evidence
observeStructured CaseHooks {customEvidence = Just hook} evidence@(StageEvidence s o text _ selection replay duration logs) =
hook evidence >>= \case
Right _ -> pure evidence
Left why -> pure (StageEvidence s o text True (addSelectionError why selection) replay duration logs)
addSelectionError :: Text -> SelectionEvidence -> SelectionEvidence
addSelectionError why (SelectionEvidence d s e ids failed skipped errors) = SelectionEvidence d s e ids failed skipped (errors ++ [why])
normalizeAttempt :: IORef (Maybe Text) -> CaseDefinition -> Maybe Adapter.EvidenceAttempt -> StageEvidence -> IO StageEvidence
normalizeAttempt _ _ Nothing evidence = pure evidence
normalizeAttempt producerIdentity CaseDefinition {expectation, evidenceMode} (Just attempt@Adapter.EvidenceAttempt {outputRoot, role}) evidence@(StageEvidence s o text unavailable selection replay duration logs) = do
let addArtifact relative = do
exists <- Dir.doesFileExist (outputRoot ++ "/" ++ relative)
pure [relative | exists]
protocolArtifacts <- if evidenceMode == StructuredTestsV1 then addArtifact (Adapter.evidenceRelativePath attempt) else pure []
probeArtifacts <- if isCrash expectation then addArtifact (Crash.probeRelativePath attempt) else pure []
let artifacts = logs ++ protocolArtifacts ++ probeArtifacts
if o `elem` [Timeout, Cancelled]
then pure (StageEvidence s o text unavailable selection replay duration artifacts)
else case expectation of
CrashProbe _ probe -> do
valid <- Crash.readCrashProbe attempt probe
pure (StageEvidence s o (either (const "") (const probe) valid) unavailable selection replay duration artifacts)
_
| evidenceMode == StructuredTestsV1 ->
Adapter.readEvidence attempt >>= \case
Left why -> pure (StageEvidence s o text True (addSelectionError why unknownSelection) Nothing duration artifacts)
Right (selected, replayed, producer) -> do
expected <- readIORef producerIdentity
if role == "baseline" then writeIORef producerIdentity (Just producer) else pure ()
let normalized = if role /= "baseline" && expected /= Just producer then addSelectionError "reporter version changed after baseline" selected else selected
pure (StageEvidence s o text unavailable normalized replayed duration artifacts)
_ -> pure evidence
initialMetadata :: Campaign -> [(CaseDefinition, CaseHooks)] -> Value
initialMetadata (Campaign commands _) selected =
object
[ "platform" .= object ["os" .= Platform.os, "arch" .= Platform.arch, "compiler" .= Platform.compilerName, "compilerVersion" .= showVersion Platform.compilerVersion],
"sourcePolicy" .= String "selected working-tree bytes including dirty/untracked regular files",
"commands" .= object [Key.fromText (commandIdText key) .= redactedCommand command | (key, command) <- Map.toAscList commands],
"definitions" .= map (caseJSON . fst) selected,
"caseDetails" .= object [],
"resources" .= ([] :: [Value])
]
redactedCommand :: Command -> Value
redactedCommand Command {executable, arguments, sensitiveArguments, workingDirectory, environment, budget, output} =
object
[ "executable" .= executable,
"arguments" .= [if Set.member i sensitiveArguments then "<redacted>" else arg | (i, arg) <- zip [0 ..] arguments],
"sensitiveArguments" .= Set.toAscList sensitiveArguments,
"workingDirectory" .= fmap targetPath workingDirectory,
"environmentOverrides" .= object [Key.fromString key .= String "<redacted>" | (key, _) <- environment],
"environmentRedacted" .= True,
"timeoutMicroseconds" .= budgetMicroseconds budget,
"outputLimitBytes" .= case output of OutputPolicy {limitBytes} -> limitBytes
]
addSnapshot :: SourceSnapshot -> Value -> Value
addSnapshot snapshot (Object o) = Object (KM.insert "snapshot" (object ["identity" .= snapshotIdentity snapshot, "policy" .= policyJSON (snapshotPolicy snapshot)]) o)
addSnapshot _ other = other
policyJSON :: SnapshotPolicy -> Value
policyJSON SnapshotPolicy {includes, excludes} = object ["includes" .= map targetPath includes, "excludes" .= map targetPath excludes, "defaultExcludedNames" .= defaultExcludedNames, "defaultExcludedPrefixes" .= defaultExcludedPrefixes]
mapToKeyMap :: Object -> CaseId -> Text -> Value -> Object
mapToKeyMap metadata caseId key value = KM.insert "caseDetails" (Object updated) metadata
where
existing = case KM.lookup "caseDetails" metadata of Just (Object o) -> o; _ -> KM.empty
caseKey = Key.fromText (caseIdText caseId)
details = case KM.lookup caseKey existing of Just (Object o) -> o; _ -> KM.empty
updated = KM.insert caseKey (Object (KM.insert (Key.fromText key) value details)) existing
logJSON :: LogArtifact -> Value
logJSON LogArtifact {stream, path, bytesSeen, bytesRetained, truncated} =
object
["stream" .= (if stream == Stdout then String "stdout" else String "stderr"), "path" .= path, "bytesSeen" .= bytesSeen, "bytesRetained" .= bytesRetained, "truncated" .= truncated]
changesJSON :: EditEvidence -> Value
changesJSON (EditEvidence changes) = toJSON [object ["path" .= targetPath path, "before" .= nullable fileJSON before, "after" .= nullable fileJSON after] | FileChange {path, before, after} <- changes]
where
fileJSON FileEntry {bytes, executableBits} = object ["sha256" .= digest bytes, "executableBits" .= executableBits, "text" .= either (const Nothing) Just (Text.decodeUtf8' bytes), "byteLength" .= BS.length bytes]
withSelector :: CaseDefinition -> Command -> Command
withSelector CaseDefinition {selector} (Command executable arguments sensitive working environment budget output) =
Command executable arguments sensitive working (maybe environment (\s -> ("HENI_SELECTOR", Text.unpack s) : filter ((/= "HENI_SELECTOR") . fst) environment) selector) budget output
originalException :: SomeException -> SomeException
originalException e = case fromException e of
Just (WorkspaceFinalizationError (Just primary) _) -> originalException primary
Just (WorkspaceFinalizationError Nothing secondary) -> originalException secondary
_ -> case fromException e of
Just (Resource.ResourceError _ _ cause) -> originalException cause
_ -> e
finalizationErrors :: SomeException -> [Text]
finalizationErrors e = case fromException e of
Just (WorkspaceFinalizationError primary secondary) -> Text.pack (displayException secondary) : maybe [] finalizationErrors primary
_ -> case fromException e of
Just (Resource.ResourceError _ _ cause) -> finalizationErrors cause
_ -> []
-- Atomic shared state; case-local evidence is private to one scheduled owner.
atomicModify_ :: IORef a -> (a -> a) -> IO ()
atomicModify_ ref update = atomicModifyIORef' ref (\value -> (update value, ()))
resourceBindingJSON :: Text -> Resource.ResourceBinding -> Value
resourceBindingJSON name Resource.ResourceBinding {identity, version, epoch, environment} =
object
["name" .= name, "identity" .= identity, "version" .= version, "epoch" .= epoch, "environmentKeys" .= map fst environment, "environmentRedacted" .= True]
appendResourceEvent :: Resource.ResourceEvent -> Value -> Value
appendResourceEvent event (Object o) =
let old = case KM.lookup "resources" o of Just (Array a) -> toList a; _ -> []
value = case event of
Resource.ResourceEvent name instanceId phase binding -> object ["instance" .= instanceId, "phase" .= resourcePhaseText phase, "binding" .= resourceBindingJSON name binding]
Resource.ResourceCommandFinished name instanceId phase ProcessReport {evidence, logs, errors} -> object ["resource" .= name, "instance" .= instanceId, "phase" .= resourcePhaseText phase, "stage" .= stageJSON evidence, "logs" .= map logJSON logs, "errors" .= errors]
in Object (KM.insert "resources" (toJSON (old ++ [value])) o)
appendResourceEvent _ other = other
reusePolicyJSON :: ReusePolicy -> Value
reusePolicyJSON Fresh = object ["mode" .= String "fresh"]
reusePolicyJSON (ReuseBaselines toolchain adapter) = object ["mode" .= String "baselines", "toolchain" .= toolchain, "adapter" .= adapter]
resourceScopeText :: Resource.ResourceScope -> Text
resourceScopeText = \case Resource.CampaignScope -> "campaign"; Resource.WorkerScope -> "worker"; Resource.CaseScope -> "case"
resourcePhaseText :: Resource.ResourcePhase -> Text
resourcePhaseText = \case Resource.AcquireResource -> "acquire"; Resource.ResetResource -> "reset"; Resource.ReleaseResource -> "release"