# katip logging Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace all `print`/`putStrLn` logging under `src/` with katip structured logging, threading a `LogEnv` through the existing `Bus`-based runtime. **Architecture:** Approach A — `Bus` gains a `busLogEnv :: LogEnv` field; `defaultMain` builds the `LogEnv` (one stdout scribe, bracketed `closeScribes`) and passes it to `newBus`. The `HASSEff` interpreters (`dryRunHassEval`, `channelHassEval`) gain the controller name as a 2nd arg and log via `runKatipT`. `supervised` gains a leading `LogEnv`. The AFRP layer and controller definitions are untouched. **Tech Stack:** Haskell (GHC2024), cabal, nix devShell, katip 0.8.8.4 (in nixpkgs), hspec/hedgehog. ## Global Constraints - Build always via the nix devShell: `nix develop -c cabal build`. - Tests always via the nix devShell: `nix develop -c cabal test`. - After any edit to `home-assistant-controller.cabal`, regenerate `default.nix` with `nix run nixpkgs#cabal2nix -- ./. > default.nix` — never hand-edit `default.nix` (AGENTS.md). - `secrets.yaml` is sops-encrypted — never read, print, or commit its contents. - Code style: vertical modules, minimal comments describing contracts not implementation (AGENTS.md). No comments unless asked. - `app/Main.hs` is outside `src/` and is NOT modified. - AFRP layer (`AFRP.hs`) and controller definitions (`Controller.hs`, `Controller/Bedroom.hs`) remain logging-free. - `-Wall` is on (cabal `common warnings`); all code must compile without warnings (no unused bindings). --- ## File Structure | File | Responsibility | Change | |------|---------------|--------| | `home-assistant-controller.cabal` | build config | add `katip` to library + test `build-depends`; add `Support` to test `other-modules` | | `default.nix` | generated cabal derivation | regenerated by cabal2nix (twice) | | `src/HomeAssistant/Runtime/Bus.hs` | `Bus`, `newBus`, `channelHassEval` | add `busLogEnv`; `newBus :: LogEnv -> Int -> IO Bus`; `channelHassEval :: Bus -> Text -> ...` with katip | | `src/HomeAssistant/Runtime.hs` | `defaultMain`, `runController`, `dryRunHassEval` | build `LogEnv` + bracket; thread controller `name`; `dryRunHassEval :: Bus -> Text -> ...` with katip | | `src/HomeAssistant/Runtime/Supervisor.hs` | `supervised` | `supervised :: LogEnv -> Text -> Backoff -> IO Void -> IO Void` with katip | | `src/HomeAssistant/Runtime/Connection.hs` | reader/writer workers | two `putStrLn` → `logMsg` | | `test/Support.hs` | test helpers (new) | `silentLogEnv :: IO LogEnv` | | `test/BusSpec.hs` | bus tests | pass `silentLogEnv`, add name arg | | `test/RuntimeSpec.hs` | runtime tests | pass `silentLogEnv` | | `test/SupervisorSpec.hs` | supervisor tests | pass `silentLogEnv`, add `LogEnv` arg (3 sites) | No new modules under `src/`. No changes to `AFRP.hs`, `Controller.hs`, `Controller/Bedroom.hs`, `ConnectionSpec.hs`, `BackoffProp.hs`, `app/Main.hs`. --- ### Task 1: Add katip dependency **Files:** - Modify: `home-assistant-controller.cabal` (library `build-depends` ~line 77-89, test-suite `build-depends` ~line 149-160) - Regenerate: `default.nix` **Interfaces:** - Consumes: nothing - Produces: `katip` available to library and test-suite; `default.nix` lists `katip` in `libraryHaskellDepends` and `testHaskellDepends` - [ ] **Step 1: Add katip to library build-depends** In `home-assistant-controller.cabal`, in the `library` stanza's `build-depends` (after `uuid`), add `, katip`. The library block becomes: ``` build-depends: base ^>=4.20.2.0 , websockets , aeson , lens-aeson , lens , text , network , bytestring , time , stm , async , annotated-exception , uuid , katip ``` - [ ] **Step 2: Add katip to test-suite build-depends** In the `test-suite home-assistant-controller-test` stanza's `build-depends` (after `time`), add `, katip`: ``` build-depends: base ^>=4.20.2.0, home-assistant-controller, hspec, stm, aeson, text, async, hedgehog, hspec-hedgehog, annotated-exception, time, katip ``` - [ ] **Step 3: Regenerate default.nix** Run: ```bash nix run nixpkgs#cabal2nix -- ./. > default.nix ``` Expected: `default.nix` now lists `katip` in `libraryHaskellDepends` and `testHaskellDepends`. Verify with: ```bash grep katip default.nix ``` Expected output: two lines mentioning `katip`. - [ ] **Step 4: Build to verify the dependency resolves** Run: ```bash nix develop -c cabal build ``` Expected: builds successfully (no code uses katip yet, so no compile errors). If nix needs to build katip first this may take a while on the first run. - [ ] **Step 5: Commit** ```bash git add home-assistant-controller.cabal default.nix git commit -m "Add katip dependency" ``` --- ### Task 2: Add busLogEnv to Bus; newBus signature; silentLogEnv helper; update callers This task adds the `LogEnv` field to `Bus`, changes `newBus` to take `LogEnv`, adds a silent test helper, and updates every `newBus` caller so the build compiles. No logging behavior changes yet (the interpreters still `print`; they switch in Task 3). **Files:** - Modify: `src/HomeAssistant/Runtime/Bus.hs` (full file) - Modify: `src/HomeAssistant/Runtime.hs` (`defaultMain` ~line 62-72) - Modify: `home-assistant-controller.cabal` (test `other-modules` ~line 130) - Regenerate: `default.nix` - Create: `test/Support.hs` - Modify: `test/BusSpec.hs` (~line 19) - Modify: `test/RuntimeSpec.hs` (~line 17) **Interfaces:** - Consumes: `katip` (`LogEnv`, `initLogEnv`) from Task 1 - Produces: - `Bus` has field `busLogEnv :: LogEnv` - `newBus :: LogEnv -> Int -> IO Bus` - `Support.silentLogEnv :: IO LogEnv` - [ ] **Step 1: Add busLogEnv field and update newBus in Bus.hs** In `src/HomeAssistant/Runtime/Bus.hs`, add imports and the field. The full updated file: ```haskell {-# LANGUAGE LambdaCase #-} module HomeAssistant.Runtime.Bus ( Bus(..) , CallIdGen(..) , mkCallIdGen , newBus , channelHassEval ) where import Control.Concurrent.STM ( TChan , TVar , atomically , newBroadcastTChanIO , newTChanIO , newTVarIO , writeTChan ) import Data.Aeson (Value) import Data.IORef (atomicModifyIORef', newIORef) import HomeAssistant.Controller (HASSEff (..), Service) import Katip (LogEnv) import Network.WebSockets (Connection) -- | Shared runtime state: inbound is a broadcast channel (controllers -- read from 'dupTChan' copies), outbound queues service calls for the -- writer, conn holds the current websocket (Nothing before first connect). data Bus = Bus { busInbound :: TChan Value , busOutbound :: TChan Service , busConn :: TVar (Maybe Connection) , busGen :: CallIdGen , busLogEnv :: LogEnv } newBus :: LogEnv -> Int -> IO Bus newBus le start = Bus <$> newBroadcastTChanIO <*> newTChanIO <*> newTVarIO Nothing <*> mkCallIdGen start <*> pure le channelHassEval :: Bus -> HASSEff a -> IO a channelHassEval bus = \case CallService svc -> atomically $ writeTChan (busOutbound bus) svc Debug x -> print x Trace req x -> print (req, x) newtype CallIdGen = CallIdGen { generateCallId :: IO Int } mkCallIdGen :: Int -> IO CallIdGen mkCallIdGen start = do gen <- newIORef start pure $ CallIdGen $ atomicModifyIORef' gen (\old -> let new = old + 1 in new `seq` (new, new)) ``` Note: `channelHassEval` still uses `print` here — it is converted in Task 3. Keeping it temporarily so this task compiles. `Data.Text (Text)` is NOT imported yet (it would be unused here, triggering `-Wall`); Task 3 adds it when the signature gains the `Text` name parameter. - [ ] **Step 2: Update defaultMain to build a LogEnv and pass to newBus** In `src/HomeAssistant/Runtime.hs`, update imports and `defaultMain`. Add these imports near the top (with the other imports): ```haskell import Control.Exception (bracket) import Katip ( ColorStrategy (ColorIfTerminal) , Severity (DebugS) , Verbosity (V2) , closeScribes , defaultScribeSettings , initLogEnv , mkHandleScribe , permitItem , registerScribe ) import System.IO (stdout) ``` Then replace `defaultMain` with: ```haskell defaultMain :: IO () defaultMain = withSocketsDo $ do scribe <- mkHandleScribe ColorIfTerminal stdout (permitItem DebugS) V2 le <- registerScribe "stdout" scribe defaultScribeSettings =<< initLogEnv "home-assistant-controller" "production" bracket (pure le) closeScribes $ \le' -> do token <- getEnv "HA_TOKEN" bus <- newBus le' 0 let workers = [ ("reader", readerAction "last-resort-redux" 8123 token bus) , ("writer", writerAction bus) ] ++ [ (name, runController bus c) | c@(Controller name _) <- controllers ] as <- mapM (\(name, act) -> async (supervised name defaultBackoff act)) workers (_, v) <- waitAny as absurd v ``` Note: `supervised` still has its 3-arg signature here — it is updated in Task 4. The `Controller name _` pattern (was `_name`) is required now so the workers list can use `name`; this is safe since `Controller`'s first field is already `T.Text`. `mkHandleScribe`, `ColorIfTerminal`, `permitItem`, and `V2` are all re-exported by the main `Katip` module, so no separate `Katip.Scribes.Handle` import is needed. - [ ] **Step 3: Create test/Support.hs with silentLogEnv** Create `test/Support.hs`: ```haskell module Support ( silentLogEnv ) where import Katip (LogEnv, initLogEnv) -- | A LogEnv with no scribes: all log calls are no-ops. For tests that -- must construct a Bus or call supervised without spewing output. silentLogEnv :: IO LogEnv silentLogEnv = initLogEnv "home-assistant-controller" "test" ``` - [ ] **Step 4: Add Support to test-suite other-modules in cabal** In `home-assistant-controller.cabal`, in the test-suite's `other-modules` (currently lists `BusSpec`, `ConnectionSpec`, `RuntimeSpec`, `SupervisorSpec`, `BackoffProp`), add `, Support`: ``` other-modules: BusSpec , ConnectionSpec , RuntimeSpec , SupervisorSpec , BackoffProp , Support ``` - [ ] **Step 5: Regenerate default.nix** Run: ```bash nix run nixpkgs#cabal2nix -- ./. > default.nix ``` Expected: `default.nix` regenerated; `katip` still present, `Support` is not listed (test modules aren't in the derivation) — this is fine. - [ ] **Step 6: Update BusSpec to use silentLogEnv** In `test/BusSpec.hs`, add the import and update the two `newBus 0` calls. Add to imports: ```haskell import Support (silentLogEnv) ``` Replace each `bus <- newBus 0` with: ```haskell le <- silentLogEnv bus <- newBus le 0 ``` There are two occurrences (lines ~19 and ~30). The `channelHassEval bus (CallService svc)` call on ~line 32 is unchanged in this task (Task 3 adds the name arg). - [ ] **Step 7: Update RuntimeSpec to use silentLogEnv** In `test/RuntimeSpec.hs`, add the import: ```haskell import Support (silentLogEnv) ``` Replace `bus <- newBus 0` (line ~17) with: ```haskell le <- silentLogEnv bus <- newBus le 0 ``` - [ ] **Step 8: Build and run tests to verify green** Run: ```bash nix develop -c cabal build && nix develop -c cabal test ``` Expected: builds with no warnings (the `print` in `channelHassEval` is still used, so no unused-binding warning); all tests pass. - [ ] **Step 9: Commit** ```bash git add src/HomeAssistant/Runtime/Bus.hs src/HomeAssistant/Runtime.hs test/Support.hs test/BusSpec.hs test/RuntimeSpec.hs home-assistant-controller.cabal default.nix git commit -m "Add busLogEnv to Bus, silentLogEnv test helper" ``` --- ### Task 3: Convert HASSEff interpreters to katip; thread controller name Both interpreters gain the controller name (`Text`) as the 2nd arg and log via `runKatipT` + `logMsg`/`logF`. `runController` threads the `Controller` name into the active interpreter call. `Debug`/`Trace` use the controller name as the namespace; `Trace` logs `requestTraceId` as a structured field. **Files:** - Modify: `src/HomeAssistant/Runtime/Bus.hs` (`channelHassEval`) - Modify: `src/HomeAssistant/Runtime.hs` (`dryRunHassEval`, `runController`, imports) - Modify: `test/BusSpec.hs` (`channelHassEval` call) **Interfaces:** - Consumes: `busLogEnv :: LogEnv` from Task 2 - Produces: - `channelHassEval :: Bus -> Text -> HASSEff a -> IO a` - `dryRunHassEval :: Bus -> Text -> HASSEff a -> IO a` - `runController` passes the controller name to the interpreter - [ ] **Step 1: Convert channelHassEval to katip in Bus.hs** In `src/HomeAssistant/Runtime/Bus.hs`, update imports and `channelHassEval`. First add `OverloadedStrings` to the pragmas at the top of the file (needed so the `"traceId"` literal in `sl` is `Text`): ```haskell {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} ``` Replace the `import Katip (LogEnv)` line with: ```haskell import AFRP (Request (requestTraceId)) import Data.Text (Text) import Data.UUID (toText) import Katip ( LogEnv , Namespace (Namespace) , Severity (DebugS) , logF , logMsg , runKatipT , showLS , sl ) ``` Replace the `channelHassEval` definition with: ```haskell channelHassEval :: Bus -> Text -> HASSEff a -> IO a channelHassEval bus name = \case CallService svc -> atomically $ writeTChan (busOutbound bus) svc Debug x -> runKatipT (busLogEnv bus) $ logMsg (Namespace [name]) DebugS (showLS x) Trace req x -> runKatipT (busLogEnv bus) $ logF (sl "traceId" (toText (requestTraceId req))) (Namespace [name]) DebugS (showLS x) ``` `Trace req x` binds `req :: Request` (from `HASSEff`'s `Trace` constructor, imported via `HASSEff (..)`); `requestTraceId` is the record field brought into scope by `import AFRP (Request (requestTraceId))`. `toText :: UUID -> Text` (aeson's `ToJSON Text` makes `sl` happy). `UUID`'s `ToJSON` would also work directly, but `toText` is used consistently with `Runtime.hs` per the spec. - [ ] **Step 2: Convert dryRunHassEval to katip in Runtime.hs** In `src/HomeAssistant/Runtime.hs`, update imports. Extend the `Data.UUID` import to include `toText`: ```haskell import Data.UUID (UUID, toText) ``` Add to the `Katip` import list (from Task 2) the logging functions. The full `Katip` import for `Runtime.hs` becomes: ```haskell import Katip ( ColorStrategy (ColorIfTerminal) , Namespace (Namespace) , Severity (DebugS) , Verbosity (V2) , closeScribes , defaultScribeSettings , initLogEnv , logF , logMsg , mkHandleScribe , permitItem , registerScribe , runKatipT , showLS , sl ) ``` Replace `dryRunHassEval` with: ```haskell dryRunHassEval :: Bus -> T.Text -> HASSEff a -> IO a dryRunHassEval bus name = \case CallService svc -> do callId <- generateCallId (busGen bus) runKatipT (busLogEnv bus) $ logF (sl "callId" callId) (Namespace [name]) DebugS (showLS svc) Debug x -> runKatipT (busLogEnv bus) $ logMsg (Namespace [name]) DebugS (showLS x) Trace req x -> runKatipT (busLogEnv bus) $ logF (sl "traceId" (toText (requestTraceId req))) (Namespace [name]) DebugS (showLS x) ``` Note: the `callId` is logged as a structured field (keeping `generateCallId` meaningful under `-Wall` — a small refinement over the spec's `logMsg ... showLS svc`, which would have left `callId` unused). `requestTraceId` is accessed via the `Request` record; `Runtime.hs` already imports `AFRP (Event (..), Mealy (..), Request (..))` — confirm `Request (..)` brings `requestTraceId` into scope. The current import is `import AFRP (Event (..), Mealy (..), Request (..))`, so `requestTraceId` is in scope. Good. - [ ] **Step 3: Thread controller name in runController** In `src/HomeAssistant/Runtime.hs`, update `runController` to use the name and pass it to the interpreter. Replace `runController` with: ```haskell runController :: Bus -> Controller -> IO Void runController bus (Controller name machine) = do inbound <- atomically (dupTChan (busInbound bus)) go inbound machine where go inbound f = do msg <- atomically (readTChan inbound) uuid <- UUID.V4.nextRandom -- (_, f') <- step (channelHassEval bus name) uuid f (Event msg) (_, f') <- step (dryRunHassEval bus name) uuid f (Event msg) go inbound f' ``` The commented `channelHassEval` line is updated to the new signature too, so enabling it later requires no further edits. - [ ] **Step 4: Update BusSpec channelHassEval call** In `test/BusSpec.hs`, the test "channelHassEval writes CallService to the outbound channel" calls `channelHassEval bus (CallService svc)`. Update it to pass a name: ```haskell channelHassEval bus "test" (CallService svc) ``` - [ ] **Step 5: Build and run tests to verify green** Run: ```bash nix develop -c cabal build && nix develop -c cabal test ``` Expected: builds with no warnings; all tests pass. The `print` calls in `channelHassEval`/`dryRunHassEval` are now gone — confirm with: ```bash rg -n "print" src/HomeAssistant/Runtime/Bus.hs src/HomeAssistant/Runtime.hs ``` Expected: no output (no `print` remains in those two files). - [ ] **Step 6: Commit** ```bash git add src/HomeAssistant/Runtime/Bus.hs src/HomeAssistant/Runtime.hs test/BusSpec.hs git commit -m "Convert HASSEff interpreters to katip, thread controller name" ``` --- ### Task 4: Convert supervised to take LogEnv and use katip `supervised` gains a leading `LogEnv` argument and replaces its two `putStrLn` calls with `logMsg`. The crash message is `WarningS`, the restart message is `InfoS`, both under namespace `runtime.supervisor.`. `defaultMain` and `SupervisorSpec` are updated. **Files:** - Modify: `src/HomeAssistant/Runtime/Supervisor.hs` (imports, `supervised`) - Modify: `src/HomeAssistant/Runtime.hs` (`defaultMain` supervised call) - Modify: `test/SupervisorSpec.hs` (3 `supervised` call sites + import) **Interfaces:** - Consumes: `LogEnv` from katip (Task 1), `busLogEnv` (Task 2) for the `defaultMain` call - Produces: `supervised :: LogEnv -> Text -> Backoff -> IO Void -> IO Void` - [ ] **Step 1: Update supervised in Supervisor.hs** In `src/HomeAssistant/Runtime/Supervisor.hs`, add `OverloadedStrings` to the pragmas at the top (needed so the message string literals are `LogStr`): ```haskell {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} ``` Add imports. Add after the existing imports: ```haskell import Katip ( LogEnv , Namespace (Namespace) , Severity (InfoS, WarningS) , logMsg , ls , runKatipT , showLS ) ``` Also **remove** the now-unused `import qualified Data.Text as T` line (the only `T.` usages were `T.unpack`/`T.pack` in the removed `putStrLn` calls; with `-Wall` the unused qualified import would warn). Keep `import Data.Text (Text)` — it is still used by `Fatal :: Text` and the `supervised` signature. Update the export list (the type signature changes but `supervised` stays exported). Update `supervised`'s type and body. Replace the `supervised` definition with: ```haskell supervised :: LogEnv -> Text -> Backoff -> IO Void -> IO Void supervised le name backoff action = go 1 where go attempt = do start <- getCurrentTime outcome <- (action >> pure (Nothing :: Maybe (Either Fatal SomeException))) `catches` [ Handler $ \(f :: Fatal) -> pure (Just (Left f)) , Handler $ \(e :: SomeException) -> pure (Just (Right e)) ] end <- getCurrentTime case outcome of Nothing -> error "unreachable: supervised action returned" Just (Left f) -> throw f Just (Right e) -> do runKatipT le $ logMsg (Namespace ["runtime", "supervisor", name]) WarningS ("attempt " <> showLS attempt <> " crashed: " <> ls (displayException e)) let delay = backoffDelay backoff attempt runKatipT le $ logMsg (Namespace ["runtime", "supervisor", name]) InfoS ("restarting in " <> showLS delay) threadDelay (round (realToFrac delay * 1000000 :: Double)) go (nextAttempt backoff (diffUTCTime end start) attempt) ``` Note: `logMsg`'s message is a `LogStr`; `(<>)` concatenates `LogStr` (it has a `Semigroup` instance). `showLS` uses `show`, so it is right for `attempt` (`Int`) and `delay` (`NominalDiffTime`) but **wrong for `displayException e`** (a `String`) — `showLS` would double-quote/escape it. Use `ls` (which converts via `StringConv` to `Text`) for raw `String` values. `displayException` is already imported from `Control.Exception.Annotated`. `Data.Text (Text)` stays for `Fatal`/the signature; the qualified `T` import is dropped (see above). - [ ] **Step 2: Update defaultMain supervised call in Runtime.hs** In `src/HomeAssistant/Runtime.hs`, in `defaultMain`, the supervised call is currently `async (supervised name defaultBackoff act)`. Update it to pass the `LogEnv`: ```haskell as <- mapM (\(name, act) -> async (supervised le' name defaultBackoff act)) workers ``` (`le'` is the bracketed `LogEnv` from Task 2.) - [ ] **Step 3: Update SupervisorSpec to pass silentLogEnv** In `test/SupervisorSpec.hs`, add imports: ```haskell import Support (silentLogEnv) ``` There are three `async (supervised "test" tinyBackoff action)` calls (lines ~28, ~41, ~56). Each must become: ```haskell le <- silentLogEnv sup <- async (supervised le "test" tinyBackoff action) ``` Insert the `le <- silentLogEnv` line before each `async` call. The three sites are in the `it` blocks: "restarts a crashing action until it stays up", "rethrows Fatal instead of restarting", and "does not restart on async exceptions". For "rethrows Fatal instead of restarting" the surrounding block becomes: ```haskell it "rethrows Fatal instead of restarting" $ do counter <- newIORef (0 :: Int) let action = do _ <- atomicModifyIORef' counter (\c -> (c + 1, c + 1)) throw (Fatal "auth_invalid") le <- silentLogEnv sup <- async (supervised le "test" tinyBackoff action) res <- waitCatch sup ``` Apply the same `le <- silentLogEnv` insertion to the other two sites. - [ ] **Step 4: Build and run tests to verify green** Run: ```bash nix develop -c cabal build && nix develop -c cabal test ``` Expected: builds with no warnings; all tests pass. Confirm no `putStrLn` remains in Supervisor.hs: ```bash rg -n "putStrLn" src/HomeAssistant/Runtime/Supervisor.hs ``` Expected: no output. - [ ] **Step 5: Commit** ```bash git add src/HomeAssistant/Runtime/Supervisor.hs src/HomeAssistant/Runtime.hs test/SupervisorSpec.hs git commit -m "Convert supervised to katip logging" ``` --- ### Task 5: Convert Connection.hs reader/writer putStrLn to katip The two `putStrLn` sites in `Connection.hs` (reader "connected" and "skipping undecodable message") become `logMsg` calls under namespace `runtime.reader`. "connected" is `InfoS`; "skipping undecodable" is `WarningS`. **Files:** - Modify: `src/HomeAssistant/Runtime/Connection.hs` (imports, two log sites) **Interfaces:** - Consumes: `busLogEnv :: LogEnv` from Task 2 - Produces: no signature changes (reader/writer already take `Bus`) - [ ] **Step 1: Add katip imports to Connection.hs** In `src/HomeAssistant/Runtime/Connection.hs`, add after the existing imports: ```haskell import Katip ( Namespace (Namespace) , Severity (InfoS, WarningS) , logMsg , ls , runKatipT ) ``` (`showLS` is not needed here — both messages use string literals / `ls` for the raw `String` `err`.) - [ ] **Step 2: Replace the "connected" putStrLn** In `readerAction`, replace: ```haskell putStrLn "[reader] connected" ``` with: ```haskell runKatipT (busLogEnv bus) $ logMsg (Namespace ["runtime", "reader"]) InfoS "connected" ``` `"connected"` is a `LogStr` via `IsString`. The `onException` line that follows is unchanged. - [ ] **Step 3: Replace the "skipping undecodable" putStrLn** In `receiveLoop`, replace: ```haskell Left err -> putStrLn $ "[reader] skipping undecodable message: " <> err ``` with: ```haskell Left err -> runKatipT (busLogEnv bus) $ logMsg (Namespace ["runtime", "reader"]) WarningS ("skipping undecodable message: " <> ls err) ``` `err` is a `String` from `eitherDecode`; `ls err` converts it to a `LogStr` without `show`'s quoting (use `ls`, not `showLS`, for raw `String`s). (`<>` on `LogStr`.) - [ ] **Step 4: Build and run tests to verify green** Run: ```bash nix develop -c cabal build && nix develop -c cabal test ``` Expected: builds with no warnings; all tests pass. Confirm no `putStrLn` remains in Connection.hs: ```bash rg -n "putStrLn" src/HomeAssistant/Runtime/Connection.hs ``` Expected: no output. - [ ] **Step 5: Commit** ```bash git add src/HomeAssistant/Runtime/Connection.hs git commit -m "Convert reader/writer logging to katip" ``` --- ### Task 6: Final verification — no manual printing remains under src/ Confirm the whole logging swap is complete: the build and tests are green, and no `print`/`putStrLn`/`putStr` remains under `src/`. **Files:** - None modified (verification only) - [ ] **Step 1: Confirm no manual printing remains under src/** Run: ```bash rg -n "print|putStrLn|putStr|hPutStr" src/ ``` Expected: no output. If any match remains, it is a missed site — fix it before committing (revisit the relevant task). - [ ] **Step 2: Full clean build and test** Run: ```bash nix develop -c cabal build && nix develop -c cabal test ``` Expected: builds with no warnings (`-Wall`); all tests pass (BusSpec, ConnectionSpec, RuntimeSpec, SupervisorSpec, BackoffProp). - [ ] **Step 3: Confirm default.nix is in sync with the cabal file** Run: ```bash nix run nixpkgs#cabal2nix -- ./. > default.nix git diff --exit-code default.nix ``` Expected: `git diff --exit-code` exits 0 (no changes — `default.nix` was already regenerated in Tasks 1 and 2). If it does change, the cabal file was edited without regenerating; stage and amend the relevant commit. - [ ] **Step 4: Smoke-check the executable wiring (optional, manual)** If a Home Assistant instance and `HA_TOKEN` are available, run the executable and confirm katip-formatted lines appear on stdout (e.g. `[home-assistant-controller.runtime.reader][Info][...] connected`). This is a manual check, not automated. - [ ] **Step 5: Final commit if any fixes were needed** If Steps 1-3 required fixes, commit them: ```bash git add -A git commit -m "Finish katip logging swap" ``` Otherwise no commit is needed — the work is already committed across Tasks 1-5.