packages feed

shikumi-trace-0.1.0.0: src/Shikumi/Trace/Store.hs

-- | Persisting a 'TraceTree' to a stable on-disk JSON format, and deriving the
-- replay index from it (EP-7, M2).
--
-- A trace file is a single JSON document — a 'TraceFile' carrying a
-- @formatVersion@ integer and the whole 'TraceTree'. Persisting the tree as a
-- tree (rather than a flat event log) keeps replay a simple key lookup and keeps
-- the file inspectable with @jq@ (e.g. @jq '.tree.spans | length'@).
--
-- 'writeTraceFile' is atomic (write to a sibling @.tmp@ then rename), so a crash
-- mid-write never leaves a half-written file. 'readTraceFile' returns 'Left' on a
-- parse error or a format-version mismatch — replay refuses to start on a file it
-- does not understand rather than producing wrong outputs.
--
-- 'replayIndex' projects the tree into the @Map CacheKey Value@ the replay
-- interpreter ('Shikumi.Trace.Replay') consults: each LM-call span's EP-6
-- 'Shikumi.Cache.Key.CacheKey' mapped to its recorded response JSON.
module Shikumi.Trace.Store
  ( TraceFile (..),
    currentFormatVersion,
    writeTraceFile,
    readTraceFile,
    replayIndex,
  )
where

import Control.Lens ((^.))
import Data.Aeson (FromJSON, ToJSON, Value, eitherDecode, encode)
import Data.ByteString.Lazy qualified as BL
import Data.Generics.Labels ()
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as T
import GHC.Generics (Generic)
import Shikumi.Cache.Key (CacheKey (..))
import Shikumi.Trace (SpanKind (LlmCallSpan), TraceTree (..))
import System.Directory (renameFile)

-- | The on-disk wrapper: a format version plus the tree. Bump
-- 'currentFormatVersion' on any breaking schema change so old files are rejected
-- loudly rather than mis-decoded.
data TraceFile = TraceFile
  { formatVersion :: !Int,
    tree :: !TraceTree
  }
  deriving stock (Eq, Show, Generic)
  deriving anyclass (ToJSON, FromJSON)

-- | The trace-file schema version this build reads and writes. Bumped 1→2 in
-- EP-16 for the additive optional @SpanAttrs.nodePath@ field.
currentFormatVersion :: Int
currentFormatVersion = 2

-- | Write a tree to @path@ atomically: encode to a sibling @.tmp@ file, then
-- 'renameFile' it into place (an atomic rename on the same filesystem).
writeTraceFile :: FilePath -> TraceTree -> IO ()
writeTraceFile path t = do
  let tmp = path <> ".tmp"
  BL.writeFile tmp (encode (TraceFile currentFormatVersion t))
  renameFile tmp path

-- | Read a tree from @path@. 'Left' on a JSON parse error or a @formatVersion@
-- this build does not understand.
readTraceFile :: FilePath -> IO (Either Text TraceTree)
readTraceFile path = do
  bs <- BL.readFile path
  pure $ case eitherDecode bs of
    Left e -> Left ("trace parse error: " <> T.pack e)
    Right tf
      | formatVersion tf /= currentFormatVersion ->
          Left
            ( "unsupported trace formatVersion "
                <> T.pack (show (formatVersion tf))
                <> " (expected "
                <> T.pack (show currentFormatVersion)
                <> ")"
            )
      | otherwise -> Right (tree tf)

-- | The replay index: every LM-call span's content-addressed key mapped to its
-- recorded response JSON. A span missing either its @cacheKey@ or its @response@
-- contributes nothing.
replayIndex :: TraceTree -> Map CacheKey Value
replayIndex t =
  Map.fromList
    [ (CacheKey ck, v)
    | s <- Map.elems (spans t),
      (s ^. #kind) == LlmCallSpan,
      Just ck <- [s ^. #attrs . #cacheKey],
      Just v <- [s ^. #attrs . #response]
    ]