packages feed

kiroku-cli 0.1.0.0 → 0.2.0.0

raw patch · 8 files changed

+329/−171 lines, 8 filesdep +http-clientdep +http-client-tlsdep +http-typesdep ~kiroku-storePVP ok

version bump matches the API change (PVP)

Dependencies added: http-client, http-client-tls, http-types

Dependency ranges changed: kiroku-store

API changes (from Hackage documentation)

- Kiroku.Cli.Command: newtype StatusOptions
- Kiroku.Cli.Standalone: [databaseUrl] :: StandaloneOptions -> !Maybe Text
- Kiroku.Cli.Standalone: [poolSize] :: StandaloneOptions -> !Int
- Kiroku.Cli.Standalone: [runtimeCommand] :: StandaloneRuntime -> !KirokuCommand
- Kiroku.Cli.Standalone: [schema] :: StandaloneOptions -> !Text
- Kiroku.Cli.Standalone: [settings] :: StandaloneRuntime -> !ConnectionSettings
- Kiroku.Cli.Standalone: [standaloneCommand] :: StandaloneOptions -> !KirokuCommand
- Kiroku.Cli.Standalone: data StandaloneOptions
- Kiroku.Cli.Standalone: data StandaloneRuntime
- Kiroku.Cli.Subscription.Status: [rowGlobalPosition] :: SubscriptionStatusRow -> !Int64
- Kiroku.Cli.Subscription.Status: [rowMember] :: SubscriptionStatusRow -> !Int32
- Kiroku.Cli.Subscription.Status: [rowPhase] :: SubscriptionStatusRow -> !Text
- Kiroku.Cli.Subscription.Status: [rowSubscription] :: SubscriptionStatusRow -> !Text
+ Kiroku.Cli.Command: RemoteEndpoint :: Text -> RemoteEndpoint
+ Kiroku.Cli.Command: [endpoint] :: StatusOptions -> !Maybe RemoteEndpoint
+ Kiroku.Cli.Command: data StatusOptions
+ Kiroku.Cli.Command: instance GHC.Classes.Eq Kiroku.Cli.Command.RemoteEndpoint
+ Kiroku.Cli.Command: instance GHC.Internal.Show.Show Kiroku.Cli.Command.RemoteEndpoint
+ Kiroku.Cli.Command: newtype RemoteEndpoint
+ Kiroku.Cli.Standalone: [command] :: StandaloneRuntime -> KirokuCommand
+ Kiroku.Cli.Standalone: instance GHC.Internal.Generics.Generic Kiroku.Cli.Standalone.StandaloneOptions
+ Kiroku.Cli.Standalone: instance GHC.Internal.Generics.Generic Kiroku.Cli.Standalone.StandaloneRuntime
+ Kiroku.Cli.Standalone: newtype StandaloneOptions
+ Kiroku.Cli.Standalone: newtype StandaloneRuntime
+ Kiroku.Cli.Subscription.Status: [globalPosition] :: SubscriptionStatusRow -> !Int64
+ Kiroku.Cli.Subscription.Status: [member] :: SubscriptionStatusRow -> !Int32
+ Kiroku.Cli.Subscription.Status: [phase] :: SubscriptionStatusRow -> !Text
+ Kiroku.Cli.Subscription.Status: [subscription] :: SubscriptionStatusRow -> !Text
+ Kiroku.Cli.Subscription.Status: fetchRemoteSubscriptionStatusRows :: RemoteEndpoint -> IO (Either Text [SubscriptionStatusRow])
+ Kiroku.Cli.Subscription.Status: instance Data.Aeson.Types.FromJSON.FromJSON Kiroku.Cli.Subscription.Status.SubscriptionStatusRow
+ Kiroku.Cli.Subscription.Status: instance Data.Aeson.Types.ToJSON.ToJSON Kiroku.Cli.Subscription.Status.SubscriptionStatusRow
+ Kiroku.Cli.Subscription.Status: instance GHC.Internal.Generics.Generic Kiroku.Cli.Subscription.Status.SubscriptionStatusRow
+ Kiroku.Cli.Subscription.Status: renderRemoteSubscriptionStatus :: RemoteEndpoint -> OutputFormat -> IO Text
- Kiroku.Cli.Command: StatusOptions :: OutputFormat -> StatusOptions
+ Kiroku.Cli.Command: StatusOptions :: !OutputFormat -> !Maybe RemoteEndpoint -> StatusOptions
- Kiroku.Cli.Command: [outputFormat] :: StatusOptions -> OutputFormat
+ Kiroku.Cli.Command: [outputFormat] :: StatusOptions -> !OutputFormat
- Kiroku.Cli.Standalone: StandaloneOptions :: !Maybe Text -> !Text -> !Int -> !KirokuCommand -> StandaloneOptions
+ Kiroku.Cli.Standalone: StandaloneOptions :: KirokuCommand -> StandaloneOptions
- Kiroku.Cli.Standalone: StandaloneRuntime :: !ConnectionSettings -> !KirokuCommand -> StandaloneRuntime
+ Kiroku.Cli.Standalone: StandaloneRuntime :: KirokuCommand -> StandaloneRuntime

Files

CHANGELOG.md view
@@ -2,6 +2,43 @@  ## Unreleased +## 0.2.0.0 — 2026-07-11++### Breaking Changes++* The standalone `kiroku` binary is now a pure remote client. It no longer opens+  a store, and the `--database-url`, `--schema`, and `--pool-size` options are+  gone. Subscription status is resolved from `--remote-url` or the+  `KIROKU_REMOTE_URL` environment variable and served by a running worker's+  `kiroku-metrics` `/subscriptions` endpoint; with neither set, the binary exits+  with guidance instead of connecting to a database. The embeddable library path+  (`runKirokuCommandWithStore` / `renderKirokuCommandWithStore`) still reads the+  in-process registry, so host applications are unaffected.+* `StandaloneOptions` and `StandaloneRuntime` are now newtypes carrying only+  `command :: KirokuCommand`. `StandaloneOptions` lost `databaseUrl`, `schema`,+  and `poolSize`; `StandaloneRuntime` lost `settings :: ConnectionSettings`.+  `resolveStandaloneOptions` no longer builds connection settings.+* Record field prefixes were dropped throughout the public API, so field+  selectors are renamed. `StandaloneOptions.standaloneCommand` and+  `StandaloneRuntime.runtimeCommand` both become `command`, and+  `SubscriptionStatusRow`'s `rowSubscription`, `rowMember`, `rowPhase`, and+  `rowGlobalPosition` become `subscription`, `member`, `phase`, and+  `globalPosition`. The affected records now derive `Generic`, so they are+  addressable with `generic-lens` labels (`row ^. #subscription`).+* Requires `kiroku-store ^>=0.3`.++### New Features++* `Kiroku.Cli.Subscription.Status` gains a remote client:+  `fetchRemoteSubscriptionStatusRows` and `renderRemoteSubscriptionStatus` query+  a worker's `/subscriptions` endpoint over `http-client` /`http-client-tls`.+* `SubscriptionStatusRow` gains `ToJSON` / `FromJSON` instances, giving the HTTP+  endpoint and the CLI a single wire contract; `renderJson` now encodes through+  that codec.+* `StatusOptions` gains `endpoint :: Maybe RemoteEndpoint` (`Nothing` reads the+  in-process registry; `Just` queries a remote worker), populated by the new+  `--remote-url` option.+ ## 0.1.0.0 — 2026-05-31  ### New Features
kiroku-cli.cabal view
@@ -1,6 +1,6 @@ cabal-version:   3.0 name:            kiroku-cli-version:         0.1.0.0+version:         0.2.0.0 synopsis:        Embeddable operator CLI for Kiroku description:   Parser, runner, and executable entry points for Kiroku operator commands.@@ -46,7 +46,10 @@     , bytestring            >=0.11 && <0.13     , containers            >=0.6  && <0.8     , generic-lens          >=2.2  && <2.4-    , kiroku-store          ^>=0.2+    , http-client           >=0.7  && <0.8+    , http-client-tls       >=0.3  && <0.4+    , http-types            >=0.12 && <0.13+    , kiroku-store          ^>=0.3     , lens                  >=5.2  && <5.4     , optparse-applicative  >=0.19 && <0.20     , text                  >=2.0  && <2.2@@ -76,6 +79,8 @@     , containers     , generic-lens          >=2.2  && <2.4     , hspec                 >=2.10 && <2.12+    , http-client           >=0.7  && <0.8+    , http-client-tls       >=0.3  && <0.4     , kiroku-cli     , kiroku-store     , kiroku-test-support
src/Kiroku/Cli/Command.hs view
@@ -1,10 +1,13 @@ module Kiroku.Cli.Command (     KirokuCommand (..),     OutputFormat (..),+    RemoteEndpoint (..),     StatusOptions (..),     SubscriptionCommand (..), ) where +import Data.Text (Text)+ data KirokuCommand     = KirokuNoCommand     | KirokuSubscriptions SubscriptionCommand@@ -14,8 +17,18 @@     = SubscriptionStatus StatusOptions     deriving stock (Eq, Show) -newtype StatusOptions = StatusOptions-    { outputFormat :: OutputFormat+{- | The base URL of a running worker's @kiroku-metrics@ server (e.g.+@http://worker:9091@), used to query its @\/subscriptions@ endpoint.+-}+newtype RemoteEndpoint = RemoteEndpoint Text+    deriving stock (Eq, Show)++data StatusOptions = StatusOptions+    { outputFormat :: !OutputFormat+    , endpoint :: !(Maybe RemoteEndpoint)+    {- ^ 'Nothing' = read the in-process registry (embeddable library); 'Just' =+    query a remote worker over HTTP.+    -}     }     deriving stock (Eq, Show) 
src/Kiroku/Cli/Parser.hs view
@@ -5,6 +5,7 @@ ) where  import Control.Applicative (optional)+import Data.Text qualified as T import Kiroku.Cli.Command (KirokuCommand (..)) import Kiroku.Cli.Command qualified as Command import Options.Applicative (@@ -23,6 +24,7 @@     metavar,     option,     progDesc,+    strOption,     subparser,     value,     (<**>),@@ -62,6 +64,16 @@                 <> metavar "table|json"                 <> value Command.OutputTable                 <> help "Render as a human table or script-friendly JSON."+            )+        <*> optional+            ( Command.RemoteEndpoint . T.pack+                <$> strOption+                    ( long "remote-url"+                        <> metavar "URL"+                        <> help+                            "Query a running worker's kiroku-metrics /subscriptions endpoint \+                            \(e.g. http://worker:9091) instead of this process's local registry."+                    )             )  parseOutputFormat :: String -> Either String Command.OutputFormat
src/Kiroku/Cli/Run.hs view
@@ -7,7 +7,11 @@ import Data.Text (Text) import Data.Text.IO qualified as TIO import Kiroku.Cli.Command (KirokuCommand (..), StatusOptions (..), SubscriptionCommand (..))-import Kiroku.Cli.Subscription.Status (renderSubscriptionStatusRows, subscriptionStatusRows)+import Kiroku.Cli.Subscription.Status (+    renderRemoteSubscriptionStatus,+    renderSubscriptionStatusRows,+    subscriptionStatusRows,+ ) import Kiroku.Store (KirokuStore) import Kiroku.Store.Subscription (subscriptionStates) @@ -24,6 +28,11 @@ renderKirokuCommandWithStore :: KirokuStore -> KirokuCommand -> IO Text renderKirokuCommandWithStore _ KirokuNoCommand =     pure "No Kiroku operator command was selected."-renderKirokuCommandWithStore store (KirokuSubscriptions (SubscriptionStatus (StatusOptions format))) = do-    snapshot <- subscriptionStates store-    pure (renderSubscriptionStatusRows format (subscriptionStatusRows snapshot))+renderKirokuCommandWithStore store (KirokuSubscriptions (SubscriptionStatus (StatusOptions format endpoint))) =+    case endpoint of+        -- An optional remote override: a host can point the in-process command at+        -- a sibling worker over HTTP instead of reading its own registry.+        Just ep -> renderRemoteSubscriptionStatus ep format+        Nothing -> do+            snapshot <- subscriptionStates store+            pure (renderSubscriptionStatusRows format (subscriptionStatusRows snapshot))
src/Kiroku/Cli/Standalone.hs view
@@ -7,112 +7,97 @@     runStandaloneCommand, ) where -import Control.Applicative (optional, (<|>))-import Control.Lens ((&), (.~))+import Control.Applicative ((<|>)) import Data.Generics.Labels () import Data.Text (Text) import Data.Text qualified as T-import Kiroku.Cli.Command (KirokuCommand (..), OutputFormat (..), StatusOptions (..), SubscriptionCommand (..))+import GHC.Generics (Generic)+import Kiroku.Cli.Command (+    KirokuCommand (..),+    RemoteEndpoint (..),+    StatusOptions (..),+    SubscriptionCommand (..),+ ) import Kiroku.Cli.Parser (kirokuCommandParser)-import Kiroku.Cli.Subscription.Status (renderSubscriptionStatusRows, subscriptionStatusRows)-import Kiroku.Store (ConnectionSettings, KirokuStore, defaultConnectionSettings, withStore)-import Kiroku.Store.Subscription (subscriptionStates)+import Kiroku.Cli.Subscription.Status (renderRemoteSubscriptionStatus) import Options.Applicative (     Parser,     ParserInfo,-    auto,     fullDesc,     header,-    help,     helper,     info,-    long,-    metavar,-    option,     progDesc,-    strOption,-    value,     (<**>),  ) -data StandaloneOptions = StandaloneOptions-    { databaseUrl :: !(Maybe Text)-    , schema :: !Text-    , poolSize :: !Int-    , standaloneCommand :: !KirokuCommand+{- | Options for the standalone @kiroku@ binary. The binary is a pure remote+client: it carries only the parsed command (whose @--remote-url@ lives inside+'StatusOptions'); it never opens a store.+-}+newtype StandaloneOptions = StandaloneOptions+    { command :: KirokuCommand     }-    deriving stock (Eq, Show)+    deriving stock (Generic, Eq, Show) -data StandaloneRuntime = StandaloneRuntime-    { settings :: !ConnectionSettings-    , runtimeCommand :: !KirokuCommand+{- | The resolved command, with any subscription-status endpoint filled in from+@--remote-url@ or @KIROKU_REMOTE_URL@.+-}+newtype StandaloneRuntime = StandaloneRuntime+    { command :: KirokuCommand     }+    deriving stock (Generic)  standaloneParserInfo :: ParserInfo StandaloneOptions standaloneParserInfo =     info         (standaloneOptionsParser <**> helper)         ( fullDesc-            <> progDesc "Run Kiroku operator commands against a store opened by this process. Subscription status reads this process-local live registry."-            <> header "kiroku - operator commands for Kiroku event stores"+            <> progDesc+                "Inspect a running Kiroku worker over HTTP. Subscription status queries the worker's \+                \kiroku-metrics /subscriptions endpoint (--remote-url or KIROKU_REMOTE_URL); the binary \+                \opens no store of its own."+            <> header "kiroku - remote operator client for Kiroku event stores"         )  standaloneOptionsParser :: Parser StandaloneOptions standaloneOptionsParser =-    StandaloneOptions-        <$> optional-            ( strOption-                ( long "database-url"-                    <> metavar "URL"-                    <> help "PostgreSQL connection string. Defaults to KIROKU_DATABASE_URL."-                )-            )-        <*> strOption-            ( long "schema"-                <> metavar "SCHEMA"-                <> value "kiroku"-                <> help "PostgreSQL schema that owns Kiroku objects."-            )-        <*> option-            auto-            ( long "pool-size"-                <> metavar "INT"-                <> value 2-                <> help "Connection pool size for this operator command."-            )-        <*> kirokuCommandParser+    StandaloneOptions <$> kirokuCommandParser +{- | Resolve the parsed options into a runnable command. For a subscription-status+command, the endpoint is taken from @--remote-url@ or, failing that, the+@KIROKU_REMOTE_URL@ environment variable; if neither is present, fail with+guidance. Commands that need no endpoint pass through unchanged.+-} resolveStandaloneOptions :: [(String, String)] -> StandaloneOptions -> Either Text StandaloneRuntime-resolveStandaloneOptions env opts-    | poolSize opts <= 0 = Left "kiroku: --pool-size must be greater than zero"-    | otherwise = do-        conn <- case databaseUrl opts <|> (T.pack <$> lookup "KIROKU_DATABASE_URL" env) of-            Just connString | not (T.null connString) -> Right connString-            _ -> Left "kiroku: missing database connection string; pass --database-url or set KIROKU_DATABASE_URL"-        pure-            StandaloneRuntime-                { settings =-                    defaultConnectionSettings conn-                        & #schema .~ schema opts-                        & #poolSize .~ poolSize opts-                , runtimeCommand = standaloneCommand opts-                }+resolveStandaloneOptions env (StandaloneOptions cmd) =+    case cmd of+        KirokuNoCommand -> Right (StandaloneRuntime KirokuNoCommand)+        KirokuSubscriptions (SubscriptionStatus (StatusOptions format mEndpoint)) ->+            case mEndpoint <|> envEndpoint of+                Just ep ->+                    Right+                        ( StandaloneRuntime+                            (KirokuSubscriptions (SubscriptionStatus (StatusOptions format (Just ep))))+                        )+                Nothing -> Left noEndpointMessage+  where+    envEndpoint =+        case T.pack <$> lookup "KIROKU_REMOTE_URL" env of+            Just url | not (T.null url) -> Just (RemoteEndpoint url)+            _ -> Nothing+    noEndpointMessage =+        "kiroku: no worker endpoint; pass --remote-url or set KIROKU_REMOTE_URL. \+        \The standalone binary inspects a running worker over HTTP; it cannot see \+        \in-process subscriptions because it runs none."  runStandaloneCommand :: StandaloneRuntime -> IO Text-runStandaloneCommand runtime =-    withStore (settings runtime) $ \store ->-        renderStandaloneCommand store (runtimeCommand runtime)--renderStandaloneCommand :: KirokuStore -> KirokuCommand -> IO Text-renderStandaloneCommand _ KirokuNoCommand =-    pure "No Kiroku operator command was selected."-renderStandaloneCommand store (KirokuSubscriptions (SubscriptionStatus (StatusOptions format))) = do-    snapshot <- subscriptionStates store-    let rows = subscriptionStatusRows snapshot-        rendered = renderSubscriptionStatusRows format rows-    pure $-        case (format, rows) of-            (OutputTable, []) ->-                rendered-                    <> "No live subscriptions in this process-local registry. Standalone status cannot inspect subscriptions running in another service process."-            _ -> rendered+runStandaloneCommand (StandaloneRuntime cmd) =+    case cmd of+        KirokuNoCommand -> pure "No Kiroku operator command was selected."+        KirokuSubscriptions (SubscriptionStatus (StatusOptions format (Just ep))) ->+            renderRemoteSubscriptionStatus ep format+        KirokuSubscriptions (SubscriptionStatus (StatusOptions _ Nothing)) ->+            -- Unreachable: resolveStandaloneOptions fills the endpoint or fails.+            pure+                "kiroku: no worker endpoint; pass --remote-url or set KIROKU_REMOTE_URL."
src/Kiroku/Cli/Subscription/Status.hs view
@@ -2,10 +2,15 @@     SubscriptionStatusRow (..),     renderSubscriptionStatusRows,     subscriptionStatusRows,+    fetchRemoteSubscriptionStatusRows,+    renderRemoteSubscriptionStatus, ) where +import Control.Exception (SomeException, try)+import Control.Lens ((^.)) import Data.Aeson qualified as Aeson import Data.ByteString.Lazy qualified as LBS+import Data.Generics.Labels () import Data.Int (Int32, Int64) import Data.List (sortOn) import Data.Map.Strict (Map)@@ -13,22 +18,56 @@ import Data.Text (Text) import Data.Text qualified as T import Data.Text.Encoding qualified as TE-import Kiroku.Cli.Command (OutputFormat (..))+import GHC.Generics (Generic)+import Kiroku.Cli.Command (OutputFormat (..), RemoteEndpoint (..)) import Kiroku.Store.Subscription (SubscriptionStateView (..)) import Kiroku.Store.Subscription.Types (SubscriptionName (..)) import Kiroku.Store.Types (GlobalPosition (..))+import Network.HTTP.Client (+    Request,+    Response,+    httpLbs,+    newManager,+    parseRequest,+    responseBody,+    responseStatus,+ )+import Network.HTTP.Client.TLS (tlsManagerSettings)+import Network.HTTP.Types.Status (statusCode)  data SubscriptionStatusRow = SubscriptionStatusRow-    { rowSubscription :: !Text-    , rowMember :: !Int32-    , rowPhase :: !Text-    , rowGlobalPosition :: !Int64+    { subscription :: !Text+    , member :: !Int32+    , phase :: !Text+    , globalPosition :: !Int64     }-    deriving stock (Eq, Show)+    deriving stock (Generic, Eq, Show) +{- | The IP-5 wire contract: a JSON object with keys @subscription@, @member@,+@phase@, @global_position@. This is the single source of truth shared by the+@kiroku-metrics@ @\/subscriptions@ endpoint (which encodes) and the CLI remote+client (which decodes).+-}+instance Aeson.ToJSON SubscriptionStatusRow where+    toJSON row =+        Aeson.object+            [ "subscription" Aeson..= (row ^. #subscription)+            , "member" Aeson..= (row ^. #member)+            , "phase" Aeson..= (row ^. #phase)+            , "global_position" Aeson..= (row ^. #globalPosition)+            ]++instance Aeson.FromJSON SubscriptionStatusRow where+    parseJSON = Aeson.withObject "SubscriptionStatusRow" $ \o ->+        SubscriptionStatusRow+            <$> o Aeson..: "subscription"+            <*> o Aeson..: "member"+            <*> o Aeson..: "phase"+            <*> o Aeson..: "global_position"+ subscriptionStatusRows :: Map (SubscriptionName, Int32) SubscriptionStateView -> [SubscriptionStatusRow] subscriptionStatusRows =-    sortOn (\row -> (rowSubscription row, rowMember row))+    sortOn (\row -> (row ^. #subscription, row ^. #member))         . fmap toRow         . Map.elems   where@@ -36,10 +75,10 @@         let SubscriptionName name = subscriptionName view             GlobalPosition position = cursor view          in SubscriptionStatusRow-                { rowSubscription = name-                , rowMember = member view-                , rowPhase = statePhase view-                , rowGlobalPosition = position+                { subscription = name+                , member = view ^. #member+                , phase = view ^. #statePhase+                , globalPosition = position                 }  renderSubscriptionStatusRows :: OutputFormat -> [SubscriptionStatusRow] -> Text@@ -57,17 +96,17 @@             <> "  "             <> pad phaseWidth "PHASE"             <> "  GLOBAL_POSITION"-    subWidth = columnWidth "SUBSCRIPTION" (fmap rowSubscription rows)-    memberWidth = columnWidth "MEMBER" (fmap (T.pack . show . rowMember) rows)-    phaseWidth = columnWidth "PHASE" (fmap rowPhase rows)+    subWidth = columnWidth "SUBSCRIPTION" (fmap (^. #subscription) rows)+    memberWidth = columnWidth "MEMBER" (fmap (T.pack . show . (^. #member)) rows)+    phaseWidth = columnWidth "PHASE" (fmap (^. #phase) rows)     renderRow row =-        pad subWidth (rowSubscription row)+        pad subWidth (row ^. #subscription)             <> "  "-            <> pad memberWidth (T.pack (show (rowMember row)))+            <> pad memberWidth (T.pack (show (row ^. #member)))             <> "  "-            <> pad phaseWidth (rowPhase row)+            <> pad phaseWidth (row ^. #phase)             <> "  "-            <> T.pack (show (rowGlobalPosition row))+            <> T.pack (show (row ^. #globalPosition))  columnWidth :: Text -> [Text] -> Int columnWidth label values =@@ -82,12 +121,43 @@     TE.decodeUtf8         . LBS.toStrict         . Aeson.encode-        . fmap rowJson++{- | Fetch a running worker's @\/subscriptions@ endpoint and decode the rows.+GETs @\<base\>/subscriptions@ (trimming a trailing slash on the base), and on a+2xx response decodes @[SubscriptionStatusRow]@ via the shared 'Aeson.FromJSON'.+Non-2xx responses, connection failures, and decode errors are returned as a+readable 'Left' message rather than thrown.+-}+fetchRemoteSubscriptionStatusRows :: RemoteEndpoint -> IO (Either Text [SubscriptionStatusRow])+fetchRemoteSubscriptionStatusRows (RemoteEndpoint base) = do+    let url = T.unpack (T.dropWhileEnd (== '/') base) <> "/subscriptions"+    reqResult <- try (parseRequest url) :: IO (Either SomeException Request)+    case reqResult of+        Left err -> pure (Left ("kiroku: invalid --remote-url " <> T.pack url <> ": " <> T.pack (show err)))+        Right req -> do+            manager <- newManager tlsManagerSettings+            respResult <- try (httpLbs req manager) :: IO (Either SomeException (Response LBS.ByteString))+            pure $ case respResult of+                Left err -> Left ("kiroku: could not reach " <> T.pack url <> ": " <> T.pack (show err))+                Right resp -> decodeResponse url resp   where-    rowJson row =-        Aeson.object-            [ "subscription" Aeson..= rowSubscription row-            , "member" Aeson..= rowMember row-            , "phase" Aeson..= rowPhase row-            , "global_position" Aeson..= rowGlobalPosition row-            ]+    decodeResponse :: String -> Response LBS.ByteString -> Either Text [SubscriptionStatusRow]+    decodeResponse url resp =+        let code = statusCode (responseStatus resp)+         in if code >= 200 && code < 300+                then case Aeson.eitherDecode (responseBody resp) of+                    Right rows -> Right rows+                    Left decodeErr ->+                        Left ("kiroku: could not decode response from " <> T.pack url <> ": " <> T.pack decodeErr)+                else+                    Left ("kiroku: " <> T.pack url <> " returned HTTP " <> T.pack (show code))++{- | Fetch and render a remote worker's subscription status, reusing the same+table/JSON renderer the in-process command uses. On error, returns the error text.+-}+renderRemoteSubscriptionStatus :: RemoteEndpoint -> OutputFormat -> IO Text+renderRemoteSubscriptionStatus ep format = do+    result <- fetchRemoteSubscriptionStatusRows ep+    pure $ case result of+        Left err -> err+        Right rows -> renderSubscriptionStatusRows format rows
test/Main.hs view
@@ -1,16 +1,14 @@ module Main where  import Control.Concurrent (threadDelay)-import Control.Lens ((^.)) import Data.Aeson qualified as Aeson-import Data.Generics.Labels () import Data.Int (Int32, Int64) import Data.List (isInfixOf) import Data.Map.Strict qualified as Map import Data.Text (Text) import Data.Text qualified as T import Kiroku.Cli (KirokuCommand (..), kirokuParserInfo, kirokuSubparser, renderKirokuCommandWithStore)-import Kiroku.Cli.Command (OutputFormat (..), StatusOptions (..), SubscriptionCommand (..))+import Kiroku.Cli.Command (OutputFormat (..), RemoteEndpoint (..), StatusOptions (..), SubscriptionCommand (..)) import Kiroku.Cli.Standalone (StandaloneOptions (..), StandaloneRuntime (..), resolveStandaloneOptions, runStandaloneCommand, standaloneParserInfo) import Kiroku.Cli.Subscription.Status (SubscriptionStatusRow (..), renderSubscriptionStatusRows, subscriptionStatusRows) import Kiroku.Store@@ -20,7 +18,6 @@     Parser,     ParserInfo,     ParserResult (..),-    command,     defaultPrefs,     execParserPure,     fullDesc,@@ -31,6 +28,7 @@     subparser,     (<**>),  )+import Options.Applicative qualified as Options import Test.Hspec  data HostCommand@@ -49,15 +47,23 @@             it "parses subscriptions status with the default table format" $ do                 case execParserPure defaultPrefs kirokuParserInfo ["subscriptions", "status"] of                     Success parsed ->-                        parsed `shouldBe` KirokuSubscriptions (SubscriptionStatus (StatusOptions OutputTable))+                        parsed `shouldBe` KirokuSubscriptions (SubscriptionStatus (StatusOptions OutputTable Nothing))                     other -> expectationFailure ("expected parser success, got " <> renderedHelp other)              it "parses subscriptions status with JSON output" $ do                 case execParserPure defaultPrefs kirokuParserInfo ["subscriptions", "status", "--format", "json"] of                     Success parsed ->-                        parsed `shouldBe` KirokuSubscriptions (SubscriptionStatus (StatusOptions OutputJson))+                        parsed `shouldBe` KirokuSubscriptions (SubscriptionStatus (StatusOptions OutputJson Nothing))                     other -> expectationFailure ("expected parser success, got " <> renderedHelp other) +            it "parses subscriptions status with a remote URL" $ do+                case execParserPure defaultPrefs kirokuParserInfo ["subscriptions", "status", "--remote-url", "http://worker:9091"] of+                    Success parsed ->+                        parsed+                            `shouldBe` KirokuSubscriptions+                                (SubscriptionStatus (StatusOptions OutputTable (Just (RemoteEndpoint "http://worker:9091"))))+                    other -> expectationFailure ("expected parser success, got " <> renderedHelp other)+             it "renders status help" $ do                 let result = execParserPure defaultPrefs kirokuParserInfo ["subscriptions", "status", "--help"]                 renderedHelp result `shouldSatisfy` isInfixOf "List live subscription phases"@@ -71,62 +77,95 @@             it "parses a nested subscription status command" $ do                 case execParserPure defaultPrefs hostParserInfo ["kiroku", "subscriptions", "status", "--format", "json"] of                     Success parsed ->-                        parsed `shouldBe` HostKiroku (KirokuSubscriptions (SubscriptionStatus (StatusOptions OutputJson)))+                        parsed `shouldBe` HostKiroku (KirokuSubscriptions (SubscriptionStatus (StatusOptions OutputJson Nothing)))                     other -> expectationFailure ("expected parser success, got " <> renderedHelp other)              it "renders nested help under a host command parser" $ do                 let result = execParserPure defaultPrefs hostParserInfo ["kiroku", "--help"]                 renderedHelp result `shouldSatisfy` isInfixOf "Run Kiroku operator commands." -        describe "standaloneParserInfo" $ do-            it "parses process options separately from the Kiroku command" $ do-                case execParserPure defaultPrefs standaloneParserInfo ["--database-url", "postgres://flag", "--schema", "ops", "--pool-size", "3", "subscriptions", "status", "--format", "json"] of+        describe "standaloneParserInfo (remote client)" $ do+            it "parses a status command with a remote URL and format" $ do+                case execParserPure defaultPrefs standaloneParserInfo ["subscriptions", "status", "--remote-url", "http://worker:9091", "--format", "json"] of                     Success parsed ->                         parsed                             `shouldBe` StandaloneOptions-                                { databaseUrl = Just "postgres://flag"-                                , schema = "ops"-                                , poolSize = 3-                                , standaloneCommand = KirokuSubscriptions (SubscriptionStatus (StatusOptions OutputJson))+                                { command =+                                    KirokuSubscriptions+                                        (SubscriptionStatus (StatusOptions OutputJson (Just (RemoteEndpoint "http://worker:9091"))))                                 }                     other -> expectationFailure ("expected parser success, got " <> renderedHelp other) -            it "resolves database URL from the environment when the flag is absent" $ do-                case execParserPure defaultPrefs standaloneParserInfo ["subscriptions", "status"] of-                    Success parsed ->-                        case resolveStandaloneOptions [("KIROKU_DATABASE_URL", "postgres://env")] parsed of-                            Right StandaloneRuntime{settings = settings, runtimeCommand = parsedCommand} -> do-                                settings ^. #connString `shouldBe` "postgres://env"-                                settings ^. #schema `shouldBe` "kiroku"-                                settings ^. #poolSize `shouldBe` 2-                                parsedCommand `shouldBe` KirokuSubscriptions (SubscriptionStatus (StatusOptions OutputTable))-                            Left err -> expectationFailure ("expected resolved runtime, got " <> show err)-                    other -> expectationFailure ("expected parser success, got " <> renderedHelp other)+            it "no longer accepts --database-url" $ do+                case execParserPure defaultPrefs standaloneParserInfo ["--database-url", "postgres://x", "subscriptions", "status"] of+                    Success _ -> expectationFailure "expected --database-url to be rejected"+                    _ -> pure () -            it "lets --database-url override the environment and rejects invalid pool sizes" $ do+            it "resolves the endpoint from --remote-url" $ do                 let opts =                         StandaloneOptions-                            { databaseUrl = Just "postgres://flag"-                            , schema = "ops"-                            , poolSize = 0-                            , standaloneCommand = KirokuNoCommand+                            { command =+                                KirokuSubscriptions+                                    (SubscriptionStatus (StatusOptions OutputTable (Just (RemoteEndpoint "http://flag:9091"))))                             }-                case resolveStandaloneOptions [("KIROKU_DATABASE_URL", "postgres://env")] opts of-                    Left err -> err `shouldBe` "kiroku: --pool-size must be greater than zero"-                    Right _ -> expectationFailure "expected invalid pool size to fail"+                case resolveStandaloneOptions [("KIROKU_REMOTE_URL", "http://env:9091")] opts of+                    Right (StandaloneRuntime{command = KirokuSubscriptions (SubscriptionStatus (StatusOptions _ endpoint))}) ->+                        endpoint `shouldBe` Just (RemoteEndpoint "http://flag:9091")+                    other -> expectationFailure ("expected resolved remote runtime, got " <> show' other) -            it "requires a database URL from either flag or environment" $ do+            it "falls back to KIROKU_REMOTE_URL when no flag is given" $ do                 let opts =                         StandaloneOptions-                            { databaseUrl = Nothing-                            , schema = "kiroku"-                            , poolSize = 2-                            , standaloneCommand = KirokuNoCommand+                            { command = KirokuSubscriptions (SubscriptionStatus (StatusOptions OutputTable Nothing))                             }+                case resolveStandaloneOptions [("KIROKU_REMOTE_URL", "http://env:9091")] opts of+                    Right (StandaloneRuntime{command = KirokuSubscriptions (SubscriptionStatus (StatusOptions _ endpoint))}) ->+                        endpoint `shouldBe` Just (RemoteEndpoint "http://env:9091")+                    other -> expectationFailure ("expected resolved remote runtime, got " <> show' other)++            it "errors with guidance when no endpoint is given" $ do+                let opts =+                        StandaloneOptions+                            { command = KirokuSubscriptions (SubscriptionStatus (StatusOptions OutputTable Nothing))+                            }                 case resolveStandaloneOptions [] opts of-                    Left err -> err `shouldBe` "kiroku: missing database connection string; pass --database-url or set KIROKU_DATABASE_URL"-                    Right _ -> expectationFailure "expected missing database URL to fail"+                    Left err -> T.unpack err `shouldSatisfy` isInfixOf "no worker endpoint"+                    Right _ -> expectationFailure "expected missing endpoint to fail" +            it "reports an unreachable endpoint as a readable error, not an exception" $ do+                let opts =+                        StandaloneOptions+                            { command =+                                -- 127.0.0.1:1 is reserved and refuses connections.+                                KirokuSubscriptions+                                    (SubscriptionStatus (StatusOptions OutputTable (Just (RemoteEndpoint "http://127.0.0.1:1"))))+                            }+                case resolveStandaloneOptions [] opts of+                    Right runtime -> do+                        output <- runStandaloneCommand runtime+                        T.unpack output `shouldSatisfy` isInfixOf "could not reach"+                    Left err -> expectationFailure ("expected resolved runtime, got " <> T.unpack err)++        describe "SubscriptionStatusRow codec (IP-5 wire contract)" $ do+            it "round-trips through encode/decode" $ do+                let rows =+                        [ SubscriptionStatusRow "alpha" 0 "catching_up" 7+                        , SubscriptionStatusRow "beta" 1 "live" 9223372036854775807+                        , SubscriptionStatusRow "gamma" 2 "reconnecting" 0+                        ]+                Aeson.decode (Aeson.encode rows) `shouldBe` Just rows++            it "uses the exact wire keys" $ do+                Aeson.decode (Aeson.encode (SubscriptionStatusRow "alpha" 0 "live" 12))+                    `shouldBe` Just+                        ( Aeson.object+                            [ "subscription" Aeson..= ("alpha" :: Text)+                            , "member" Aeson..= (0 :: Int)+                            , "phase" Aeson..= ("live" :: Text)+                            , "global_position" Aeson..= (12 :: Int)+                            ]+                        )+         describe "subscriptionStatusRows" $ do             it "sorts rows and extracts public scalar fields" $ do                 subscriptionStatusRows@@ -170,7 +209,7 @@                     output <-                         renderKirokuCommandWithStore                             store-                            (KirokuSubscriptions (SubscriptionStatus (StatusOptions OutputTable)))+                            (KirokuSubscriptions (SubscriptionStatus (StatusOptions OutputTable Nothing)))                     output `shouldSatisfy` T.isInfixOf "cli-registry"                     output `shouldSatisfy` T.isInfixOf "live"                     output `shouldSatisfy` T.isInfixOf "0"@@ -178,26 +217,9 @@              it "runs nested Kiroku commands through a host command wrapper" $                 withTestStore $ \store -> do-                    output <- runHostCommand store (HostKiroku (KirokuSubscriptions (SubscriptionStatus (StatusOptions OutputJson))))+                    output <- runHostCommand store (HostKiroku (KirokuSubscriptions (SubscriptionStatus (StatusOptions OutputJson Nothing))))                     output `shouldBe` "[]" -        describe "runStandaloneCommand" $ do-            it "opens a migrated store and reports an empty process-local registry successfully" $-                withMigratedTestDatabase $ \connStr -> do-                    let opts =-                            StandaloneOptions-                                { databaseUrl = Just connStr-                                , schema = "kiroku"-                                , poolSize = 2-                                , standaloneCommand = KirokuSubscriptions (SubscriptionStatus (StatusOptions OutputTable))-                                }-                    case resolveStandaloneOptions [] opts of-                        Left err -> expectationFailure ("expected resolved runtime, got " <> show err)-                        Right runtime -> do-                            output <- runStandaloneCommand runtime-                            output `shouldSatisfy` T.isInfixOf "SUBSCRIPTION"-                            output `shouldSatisfy` T.isInfixOf "No live subscriptions in this process-local registry"- hostParserInfo :: ParserInfo HostCommand hostParserInfo =     info@@ -207,7 +229,7 @@ hostCommandParser :: Parser HostCommand hostCommandParser =     subparser-        ( command+        ( Options.command             "host"             (info (pure HostOnly) (progDesc "Run a host-only command."))             <> kirokuSubparser HostKiroku@@ -226,6 +248,11 @@     "" renderedHelp (CompletionInvoked _) =     ""++-- | 'StandaloneRuntime' has no 'Show'; describe a resolved/failed result for test messages.+show' :: Either Text StandaloneRuntime -> String+show' (Left err) = "Left " <> T.unpack err+show' (Right _) = "Right <runtime with unexpected command>"  view :: Text -> Int32 -> Text -> Int64 -> SubscriptionStateView view name member phase position =