never executed always true always false
1 module PureClaw.Handles.Transcript
2 ( -- * Handle type
3 TranscriptHandle (..)
4 -- * Implementations
5 , mkFileTranscriptHandle
6 , mkNoOpTranscriptHandle
7 ) where
8
9 import Control.Exception
10 import Control.Monad
11 import Data.Foldable qualified
12 import Data.Aeson qualified as Aeson
13 import Data.ByteString qualified as BS
14 import Data.ByteString.Lazy qualified as LBS
15 import Data.ByteString.Lazy.Char8 qualified as LBC
16 import Data.IORef
17 import Data.Text qualified as T
18 import System.Directory
19 import System.FilePath
20 import System.IO.Error
21 import System.Posix.Files
22 import System.Posix.IO qualified as Posix
23 import System.Posix.IO.ByteString qualified as PosixBS
24 import System.Posix.Types
25
26 import Foreign.C.Error
27 import Foreign.C.Types
28
29 import PureClaw.Handles.Log
30 import PureClaw.Transcript.Types
31
32 -- | POSIX fsync(2) — flush kernel buffers to disk.
33 foreign import ccall "fsync" c_fsync :: CInt -> IO CInt
34
35 fdSync :: Fd -> IO ()
36 fdSync (Fd fd) = throwErrnoIfMinus1_ "fdSync" (c_fsync fd)
37
38 -- | Transcript I/O capability. Append-only JSONL log with query support.
39 data TranscriptHandle = TranscriptHandle
40 { _th_record :: TranscriptEntry -> IO ()
41 , _th_query :: TranscriptFilter -> IO [TranscriptEntry]
42 , _th_getPath :: IO FilePath
43 , _th_flush :: IO ()
44 , _th_close :: IO ()
45 }
46
47 -- | File-backed transcript handle using JSONL format.
48 mkFileTranscriptHandle :: LogHandle -> FilePath -> IO TranscriptHandle
49 mkFileTranscriptHandle logger path = do
50 -- Create parent directory with 0700 permissions if it doesn't exist
51 let dir = takeDirectory path
52 dirExists <- doesDirectoryExist dir
53 unless dirExists $ do
54 createDirectoryIfMissing True dir
55 setFileMode dir 0o700
56
57 -- Open file with 0600 permissions using raw POSIX fd (no GHC file locking)
58 fd <- Posix.openFd path
59 Posix.ReadWrite
60 (Posix.defaultFileFlags { Posix.append = True, Posix.creat = Just 0o600 })
61
62 closedRef <- newIORef False
63 fdRef <- newIORef (Just fd)
64
65 pure TranscriptHandle
66 { _th_record = \entry -> do
67 closed <- readIORef closedRef
68 unless closed $ do
69 mfd <- readIORef fdRef
70 case mfd of
71 Nothing -> pure ()
72 Just wfd -> do
73 let line = LBS.toStrict (Aeson.encode entry) <> "\n"
74 void (PosixBS.fdWrite wfd line)
75
76 , _th_query = \tf -> do
77 closed <- readIORef closedRef
78 if closed
79 then pure []
80 else do
81 -- Read via a separate fd to avoid any conflicts
82 contents <- readFileRaw path
83 let rawLines = LBC.lines (LBS.fromStrict contents)
84 decoded <- decodeLines logger rawLines
85 pure (applyFilter tf decoded)
86
87 , _th_getPath = pure path
88
89 , _th_flush = do
90 closed <- readIORef closedRef
91 unless closed $ do
92 mfd <- readIORef fdRef
93 Data.Foldable.for_ mfd fdSync
94
95 , _th_close = do
96 closed <- readIORef closedRef
97 unless closed $ do
98 writeIORef closedRef True
99 mfd <- readIORef fdRef
100 writeIORef fdRef Nothing
101 case mfd of
102 Nothing -> pure ()
103 Just wfd -> do
104 fdSync wfd
105 Posix.closeFd wfd
106 }
107
108 -- | Read a file strictly using raw POSIX fd operations, bypassing
109 -- GHC RTS file locking to avoid conflicts with an already-open fd.
110 readFileRaw :: FilePath -> IO BS.ByteString
111 readFileRaw fp = do
112 rfd <- Posix.openFd fp Posix.ReadOnly Posix.defaultFileFlags
113 let chunkSize :: ByteCount
114 chunkSize = 65536
115 go acc = do
116 result <- try (PosixBS.fdRead rfd chunkSize)
117 case result of
118 Left e
119 | isEOFError e -> pure (BS.concat (reverse acc))
120 | otherwise -> throwIO e
121 Right chunk
122 | BS.null chunk -> pure (BS.concat (reverse acc))
123 | otherwise -> go (chunk : acc)
124 contents <- go []
125 Posix.closeFd rfd
126 pure contents
127
128 -- | Decode JSONL lines, skipping malformed ones and logging warnings.
129 decodeLines :: LogHandle -> [LBS.ByteString] -> IO [TranscriptEntry]
130 decodeLines logger = go
131 where
132 go [] = pure []
133 go (line:rest)
134 | LBS.null line = go rest
135 | otherwise = case Aeson.eitherDecode line of
136 Right entry -> (entry :) <$> go rest
137 Left err -> do
138 _lh_logWarn logger ("Skipping malformed JSONL line: " <> T.pack err)
139 go rest
140
141 -- | No-op transcript handle for testing.
142 mkNoOpTranscriptHandle :: TranscriptHandle
143 mkNoOpTranscriptHandle = TranscriptHandle
144 { _th_record = \_ -> pure ()
145 , _th_query = \_ -> pure []
146 , _th_getPath = pure ""
147 , _th_flush = pure ()
148 , _th_close = pure ()
149 }