keiro-ops-http (empty) → 0.22.0.0
raw patch · 26 files changed
+4152/−0 lines, 26 filesdep +aesondep +asyncdep +base
Dependencies added: aeson, async, base, bytestring, containers, directory, effectful, filepath, hasql, hasql-pool, hspec, http-client, http-types, jitsurei, keiro, keiro-migrations, keiro-ops, keiro-ops-http, keiro-pgmq, keiro-test-support, kiroku-metrics, kiroku-store, network, network-uri, optparse-applicative, pgmq-migration, process, stm, text, time, uuid, vector, wai, wai-extra, wai-websockets, warp, websockets
Files
- CHANGELOG.md +63/−0
- LICENSE +28/−0
- app/Main.hs +50/−0
- bench/InspectionClients.hs +141/−0
- bench/InspectionInterference.hs +343/−0
- example/Compose.hs +56/−0
- keiro-ops-http.cabal +228/−0
- src/Keiro/Ops/Http.hs +23/−0
- src/Keiro/Ops/Http/Application.hs +264/−0
- src/Keiro/Ops/Http/Budget.hs +96/−0
- src/Keiro/Ops/Http/Compose.hs +110/−0
- src/Keiro/Ops/Http/Config.hs +56/−0
- src/Keiro/Ops/Http/Cors.hs +67/−0
- src/Keiro/Ops/Http/Feed.hs +235/−0
- src/Keiro/Ops/Http/Feed/Driver.hs +258/−0
- src/Keiro/Ops/Http/Feed/Queue.hs +158/−0
- src/Keiro/Ops/Http/Pool.hs +40/−0
- src/Keiro/Ops/Http/Protocol.hs +106/−0
- src/Keiro/Ops/Http/Route.hs +94/−0
- src/Keiro/Ops/Http/Server.hs +71/−0
- src/Keiro/Ops/Http/Views.hs +147/−0
- src/Keiro/Ops/Http/WebSocket.hs +184/−0
- test/Main.hs +802/−0
- test/Test/ComposeSpec.hs +137/−0
- test/Test/FeedSpec.hs +228/−0
- test/Test/WebSocketSpec.hs +167/−0
+ CHANGELOG.md view
@@ -0,0 +1,63 @@+# Changelog++## [Unreleased]++## 0.22.0.0 — 2026-10-27++### Breaking Changes++- Require the Kiroku Store 0.12 cohort from+ mori://shinzui/kiroku/packages/kiroku-store; apply migration 0016 from+ mori://shinzui/kiroku/packages/kiroku-store-migrations with affected workers+ stopped, then restart new binaries before using namespace targets. Update+ exhaustive subscription target/event matches and low-level record constructors.++- Direct `InspectionLimits` records must supply `deliveryDeadlineSeconds`; the default is 30 seconds. Nonempty confirmation bodies must be application/json, with media-type parameters permitted.++### New Features++- Expose mounted process-manager discovery as `GET /pm/types` through the shared+ command tree, preserving schema verification and transport resource limits.+ Encode discovery on demand with the same byte/deadline bounds and no response cache.++- Compose allocated inspection applications under stable `/keiro`, `/kiroku`,+ `/shibuya` and `/pgmq` prefixes with preserved raw paths, shared CORS policy+ and a flag-guarded example using released Kiroku metrics.++- Add shared `/ws` first-page feeds for workflow, timer and projection status,+ backed by the same bounded reads and encoders as polls. Drivers share the+ HTTP budget; idle handles own no wake cursor. Count/byte-bounded queues,+ in-flight accounting, overflow recovery, upgrade policy checks and scoped+ cleanup protect runtime resources. The standalone HTTP server mounts feeds.++- Add bounded projection-status pages and `projection status`, preserving the+ frozen v1 relation and complete-list compatibility API. Page keys precede+ correlated status reads; no writer index is added. HTTP feed protocol and+ canonical view definitions are available as foundations for live feeds.+++- Bounded workflow routes and typed cursor/size/generation failures; pure validation precedes schema scans.+++- Bounded HTTP transport for the supported operational command tree.++### Bug Fixes++- Fail oversized workflow continuations explicitly instead of returning a cursor+ that the next request cannot decode.+++- Refuse malformed, null, multiple and foreign mutation origins before body, schema or command effects with `origin_not_allowed` (403). HTTP, CORS and WebSocket comparisons normalize scheme/host/effective port. No-Origin clients and empty previews remain supported; TLS proxies explicitly allow-list HTTPS.+- Nonempty unsupported confirmation media types, including chunked bodies, return `unsupported_media_type` (415).+- Bound delivery independently of computation; expire stalled response callbacks, return permits and terminate incomplete connections without a second response. Shared health admission may refuse during saturation and recovers after cancellation.+- Retain per-refresh schema verification and fresh snapshot presentation without clock-only deltas. Return `view_retiring` while zero-reference cleanup joins. Composition preserves the complete child origin authorization policy and one CORS header.+- Follow primary-key workflow pages with `wf-key2`, preserving continuation byte caps and honest filtered-query deadlines.++### Other Changes++- Reuse at most 32 header presentations / 1 MiB charged data after exact current-row checks, retaining live schema verification and host response caps.++- Reuse bounded pure read parsing and the RTS deadline manager. Retain a fresh+ live schema check for every admitted operation and hold admission through+ response delivery.+
+ LICENSE view
@@ -0,0 +1,28 @@+BSD 3-Clause License++Copyright (c) 2026, Nadeem Bitar++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++1. Redistributions of source code must retain the above copyright notice, this+ list of conditions and the following disclaimer.++2. Redistributions in binary form must reproduce the above copyright notice,+ this list of conditions and the following disclaimer in the documentation+ and/or other materials provided with the distribution.++3. Neither the name of the copyright holder nor the names of its contributors+ may be used to endorse or promote products derived from this software without+ specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES+INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS+OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT INCLUDING+NEGLIGENCE OR OTHERWISE ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN+IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ app/Main.hs view
@@ -0,0 +1,50 @@+module Main (main) where++import Control.Concurrent.Async (wait)+import Data.Foldable (traverse_)+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.IO qualified as Text+import Hasql.Connection.Settings qualified as Settings+import Keiro.Migrations.SchemaCheck+import Keiro.Ops (emptyAppHooks)+import Keiro.Ops.Env (resolveConnectionString)+import Keiro.Ops.Http+import Keiro.Ops.Http.Config qualified as Config+import Kiroku.Store.Connection (ConnectionSettingsM (..), KirokuStore (..), defaultConnectionSettings, withStore)+import Options.Applicative+import System.IO (stderr)++data Options = Options !(Maybe Text) !OpsHttpConfig !OpsHttpServerConfig++options :: Parser Options+options =+ Options+ <$> optional (textOption (long "database-url" <> metavar "URL"))+ <*> ( (\enabled drift origins -> defaultOpsHttpConfig {mutations = if enabled then MutationsEnabled else MutationsDisabled, schemaDrift = if drift then AllowDrift else RefuseOnDrift, Config.allowedOrigins = origins})+ <$> switch (long "enable-mutations")+ <*> switch (long "allow-schema-drift")+ <*> many (textOption (long "allow-origin" <> metavar "ORIGIN"))+ )+ <*> (OpsHttpServerConfig <$> textOption (long "bind" <> value "127.0.0.1") <*> option auto (long "port" <> value 9092) <*> option auto (long "request-timeout" <> value 300))+ where+ textOption modifiers = Text.pack <$> strOption modifiers++main :: IO ()+main = do+ Options database config serverConfig <- execParser (info (options <**> helper) (fullDesc <> progDesc "Serve Keiro inspection on a trusted network or behind an authenticating proxy"))+ connectionString <- resolveConnectionString database+ verified <- verifyExpectedSchema (Settings.connectionString connectionString)+ case verified of+ Left err -> ioError (userError (show err))+ Right drift -> traverse_ (Text.hPutStrLn stderr . ("warning: " <>) . renderSchemaDrift) drift+ -- This store belongs only to inspection. All its pooled connections inherit+ -- the SQL deadline; command execution never borrows the runtime's pool.+ let settings = (defaultConnectionSettings connectionString) {poolSize = config.inspectionLimits.maxConcurrentReads, statementTimeout = Just 2}+ withStore settings $ \baseStore -> withOpsInspectionPool config.inspectionLimits settings $ \pool -> do+ let store = baseStore {pool}+ withFeedHandle config defaultFeedConfig store $ \feeds -> do+ app <- opsApplicationWithFeeds config feeds emptyAppHooks store+ withOpsHttpServer serverConfig app $ \server -> do+ Text.hPutStrLn stderr ("keiro-ops-http: listening on " <> serverConfig.bindHost <> ":" <> Text.pack (show server.serverPort) <> " (mutations " <> (if config.mutations == MutationsEnabled then "enabled" else "disabled") <> ")")+ wait server.serverThread
+ bench/InspectionClients.hs view
@@ -0,0 +1,141 @@+-- External load process: client allocation never enters runtime RTS counters.+module InspectionClients (runClients) where++import Control.Concurrent (threadDelay)+import Control.Concurrent.Async+import Control.Concurrent.MVar+import Control.Exception (bracket, onException, throwIO, try)+import Control.Monad (forM, forM_, forever, unless, void)+import Data.Aeson (Value (..), encode, object, (.=))+import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KeyMap+import Data.ByteString.Lazy qualified as Lazy+import Data.ByteString.Lazy.Char8 qualified as Char8+import Data.IORef+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Text qualified as Text+import Network.HTTP.Client qualified as HTTP+import Network.HTTP.Types (statusCode)+import Network.WebSockets qualified as WS+import System.IO (hFlush, hPutStrLn, stderr, stdout)+import System.Timeout (timeout)++runClients :: [String] -> IO ()+runClients [portArg, clientsArg, _, secondsArg, mode]+ | mode `elem` ["pm-types", "pm-types-many"] =+ runManagerClients (read portArg) (read clientsArg) (read secondsArg) (mode == "pm-types-many")+runClients [portArg, clientsArg, viewsArg, secondsArg, mode] = do+ let port = read portArg+ clients = read clientsArg+ views = read viewsArg+ seconds = read secondsArg+ go <- newEmptyMVar+ counters <- newIORef Map.empty+ let count code = atomicModifyIORef' counters (\counts -> (Map.insertWith (+) code (1 :: Int) counts, ()))+ subscribe index+ | views == 32 && index `mod` views == 31 = encode (object ["type" .= ("subscribe" :: Text), "feed" .= ("timers" :: Text)])+ | views == 32 && index `mod` views == 30 = encode (object ["type" .= ("subscribe" :: Text), "feed" .= ("projection_groups" :: Text)])+ | otherwise = encode (object ["type" .= ("subscribe" :: Text), "feed" .= ("workflows" :: Text), "workflow_name" .= (if index `mod` views == 0 then "index-evidence" else "seed-" <> show (index `mod` views - 1)), "limit" .= (100 :: Int)])+ frame connection = do+ raw <- WS.receiveData connection :: IO Lazy.ByteString+ case Aeson.eitherDecode raw of+ Right (Object fields) -> case KeyMap.lookup "type" fields of+ Just (String "snapshot") -> count "snapshot" >> pure "snapshot"+ Just (String "update") -> count "update" >> pure "update"+ Just (String "error") -> count ("feed_" <> show (KeyMap.lookup "code" fields)) >> pure "error"+ _ -> pure "control"+ _ -> fail "invalid feed JSON"+ worker index ready = WS.runClient "127.0.0.1" port "/keiro/ws" $ \connection -> do+ WS.sendTextData connection (subscribe index)+ initial <- frame connection+ unless (initial == ("snapshot" :: Text)) (fail "client did not receive a real initial snapshot")+ putMVar ready (Right ())+ readMVar go+ let consume = if mode == "slow-reader" then threadDelay (seconds * 1000000) else forever (frame connection)+ churn = forever $ do+ threadDelay 1000000+ WS.sendTextData connection (encode (object ["type" .= ("unsubscribe" :: Text), "feed" .= ("workflows" :: Text)]))+ WS.sendTextData connection (subscribe index)+ if mode == "churn" then race_ consume churn else consume+ start index = do+ ready <- newEmptyMVar+ task <- async (worker index ready `onException` void (tryPutMVar ready (Left (userError "feed client failed before readiness"))))+ status <- timeout 10000000 (takeMVar ready)+ case status of+ Just (Right ()) -> pure task+ Just (Left err) -> cancel task >> throwIO err+ Nothing -> cancel task >> fail "feed client readiness timeout"+ -- Sequential handshake avoids benchmarking a startup admission race. All+ -- sockets remain live together during the measured window.+ bracket (forM [0 .. clients - 1] start) (mapM_ cancel) $ \tasks -> do+ manager <- HTTP.newManager HTTP.defaultManagerSettings {HTTP.managerConnCount = clients}+ -- Match the established header-cache experiment: warm the exact 32 poll+ -- keys outside the timed writer window, without counting these requests.+ forM_ [1 .. 32 :: Int] $ \index -> do+ request <- HTTP.parseRequest ("http://127.0.0.1:" <> show port <> "/keiro/wf/inspect/header/index-evidence/warm-" <> show index)+ response <- HTTP.httpLbs request manager+ unless (statusCode (HTTP.responseStatus response) == 200) (fail "HTTP view warm-up refused")+ let poll index = do+ request <- HTTP.parseRequest ("http://127.0.0.1:" <> show port <> "/keiro/wf/inspect/header/index-evidence/warm-" <> show (1 + index `mod` 32))+ readMVar go+ threadDelay (index * 1000000 `div` clients)+ forever $ do+ result <- try (HTTP.httpLbs request manager) :: IO (Either HTTP.HttpException (HTTP.Response Lazy.ByteString))+ either (const (count "http_exception")) (count . ("http_" <>) . show . statusCode . HTTP.responseStatus) result+ threadDelay 1000000+ bracket (forM [0 .. clients - 1] (async . poll)) (mapM_ cancel) $ \pollers -> do+ putStrLn "ready"+ hFlush stdout+ instruction <- getLine+ if instruction /= "go" then fail "missing measurement control" else pure ()+ putMVar go ()+ -- Surface unexpected client failures instead of measuring a lost load.+ race_ (threadDelay (seconds * 1000000)) (mapConcurrently_ wait (tasks <> pollers))+ HTTP.closeManager manager+ counts <- readIORef counters+ hPutStrLn stderr ("inspection clients: " <> show counts)+ let good = sum [n | (key, n) <- Map.toList counts, key `elem` ["http_200", "snapshot", "update"]]+ bad = sum [n | (key, n) <- Map.toList counts, key `notElem` ["http_200", "snapshot", "update"]]+ Char8.putStrLn (encode (good, bad))+runClients _ = fail "expected PORT CLIENTS VIEWS SECONDS MODE"++-- HTTP-only discovery load: no WebSocket subscriptions or workflow header warmup.+-- Finite staggered schedules retain the intended four requests per second even+-- when request time varies; a failed request or late schedule fails the trial.+runManagerClients :: Int -> Int -> Int -> Bool -> IO ()+runManagerClients port clients seconds many = do+ unless (clients == 4 && seconds > 0) (fail "expected four manager discovery clients")+ let expectedNames = if many then ["alias-" <> Text.justifyRight 4 '0' (Text.pack (show n)) | n <- [1 .. 1000 :: Int]] else ["jitsurei-fulfillment"]+ expected = object ["items" .= [object ["name" .= name, "category" .= ("fulfillment" :: Text), "snapshots_configured" .= False, "snapshot_policy" .= ("never" :: Text), "timers_configured" .= Null] | name <- expectedNames]]+ bracket (HTTP.newManager HTTP.defaultManagerSettings {HTTP.managerConnCount = clients}) HTTP.closeManager $ \manager -> do+ request <- HTTP.parseRequest ("http://127.0.0.1:" <> show port <> "/keiro/pm/types")+ let fetch = do+ response <- HTTP.httpLbs request manager+ unless+ (statusCode (HTTP.responseStatus response) == 200 && Aeson.eitherDecode (HTTP.responseBody response) == Right expected)+ (fail "manager discovery poll was refused or returned incorrect metadata")+ fetch+ go <- newEmptyMVar+ successes <- newIORef (0 :: Int)+ let poll index = do+ readMVar go+ threadDelay (index * 1000000 `div` clients)+ forM_ [1 .. seconds] $ \second -> do+ -- Sleep until the next tick independently of request duration.+ withAsync (threadDelay 1000000) $ \tick -> do+ fetch+ atomicModifyIORef' successes (\n -> (n + 1, ()))+ unless (second == seconds) (wait tick)+ bracket (forM [0 .. clients - 1] (async . poll)) (mapM_ cancel) $ \tasks -> do+ putStrLn "ready"+ hFlush stdout+ instruction <- getLine+ unless (instruction == "go") (fail "missing measurement control")+ putMVar go ()+ finished <- timeout ((seconds + 5) * 1000000) (mapConcurrently_ wait tasks)+ unless (finished == Just ()) (fail "manager polling did not sustain the intended load")+ good <- readIORef successes+ unless (good == clients * seconds) (fail "manager polling load disappeared")+ hPutStrLn stderr ("manager discovery clients: successes=" <> show good <> " failures=0")+ Char8.putStrLn (encode (good, 0 :: Int))
+ bench/InspectionInterference.hs view
@@ -0,0 +1,343 @@+{-# LANGUAGE CPP #-}+{-# LANGUAGE DuplicateRecordFields #-}+{-# LANGUAGE OverloadedRecordDot #-}+{-# LANGUAGE OverloadedStrings #-}++-- Durable writer profile. KEIRO_INSPECTION_BASELINE compiles the same writer+-- against pre-inspection packages without requiring the new HTTP package.+-- Supply OUTPUT_DIRECTORY [saturation|OPS_PER_SECOND] [TRIALS] [MODE].+-- Run with +RTS -N2 -T -RTS; this is not the full domain workload matrix.+import Control.Concurrent (threadDelay)+import Control.Exception (bracket)+import Control.Monad (forM, forM_, unless, void, when)+import Data.Aeson (Value, object, (.=))+import Data.Aeson qualified as Aeson+import Data.ByteString.Lazy qualified as Lazy+import Data.ByteString.Lazy.Char8 qualified as LazyChar8Portable+#ifndef KEIRO_INSPECTION_BASELINE+import InspectionClients (runClients)+import Data.Map.Strict qualified as Map+import Jitsurei.Inspection (jitsureiProcessManagerInspectors)+#endif+import Data.ByteString qualified as Bytes+import Data.List (sort)+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text+import Data.Time (getCurrentTime)+import Data.UUID qualified as UUID+import Data.UUID.V5 qualified as UUIDv5+import Effectful (Eff, IOE)+import Effectful.Error.Static (Error)+import GHC.Clock (getMonotonicTimeNSec)+import GHC.Conc (ThreadStatus (..), getAllocationCounter, listThreads, threadStatus)+import GHC.Stats (GCDetails (..), RTSStats (..), getRTSStats, getRTSStatsEnabled)+import Hasql.Connection qualified as Connection+import Hasql.Connection.Settings qualified as Settings+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Pool qualified as Pool+import Hasql.Session qualified as Session+import Hasql.Statement qualified as Statement+#ifndef KEIRO_INSPECTION_BASELINE+import Keiro.Ops qualified as Ops+import Keiro.Ops.Http+#endif+import Keiro.ReadModel (registerReadModel)+import Keiro.Test.Postgres+import Keiro.Timer qualified as Timer+import Keiro.Workflow+import Keiro.Workflow.Instance qualified as Instance+import Kiroku.Store.Connection+import Kiroku.Store.Effect (Store, runStoreIO)+import Kiroku.Store.Error (StoreError)+import Kiroku.Store.Transaction (runTransaction)+import Kiroku.Store.Types (EventId (..))+import System.Directory (createDirectoryIfMissing)+import System.Environment (getArgs, getExecutablePath, lookupEnv, withArgs)+import System.IO (IOMode (WriteMode), hFlush, withFile)+#ifndef KEIRO_INSPECTION_BASELINE+import System.Exit (ExitCode (..))+import System.IO (hClose, hGetLine, hPutStrLn)+#endif+import System.FilePath ((</>))+import System.Mem (performMajorGC)+#ifndef KEIRO_INSPECTION_BASELINE+import System.Process (CreateProcess (..), StdStream (..), createProcess, proc, readProcessWithExitCode, terminateProcess, waitForProcess)+#endif+import Text.Read (readMaybe)++main :: IO ()+main = do+ args <- getArgs+#ifndef KEIRO_INSPECTION_BASELINE+ case args of+ "--clients" : rest -> runClients rest+ _ -> withArgs (translate args) benchmark+#else+ withArgs (translate args) benchmark+#endif++-- Named options for cabal bench, retaining positional arguments for matched runs.+translate :: [String] -> [String]+translate args+ | "--mode" `elem` args = [option "--output" "inspection-results", option "--rate" "100", option "--trials" "1", option "--mode" "disabled"]+ | otherwise = args+ where+ option key fallback = case dropWhile (/= key) args of _ : value : _ -> value; _ -> fallback++benchmark :: IO ()+benchmark = do+ (output, loadArgument, trialsArgument, modeArgument) <-+ getArgs >>= \case+ [path] -> pure (path, "50", "5", "all")+ [path, load] -> pure (path, load, "5", "all")+ [path, load, trials] -> pure (path, load, trials, "all")+ [path, load, trials, mode] -> pure (path, load, trials, mode)+ _ -> fail "expected OUTPUT_DIRECTORY [saturation|OPS_PER_SECOND] [TRIALS] [MODE]"+ offeredRate <- case loadArgument of+ "saturation" -> pure Nothing+ raw | Just rate <- readMaybe raw, rate > 0, rate <= (1000 :: Int) -> pure (Just rate)+ _ -> fail "offered rate must be 1..1000 or saturation"+ trialCount <- case readMaybe trialsArgument of+ Just count | count > 0, count <= (100 :: Int) -> pure count+ _ -> fail "trials must be 1..100"+ selectedModes <- case modeArgument of+ "all" -> pure inspectionModes+ mode | Text.pack mode `elem` inspectionModes -> pure [Text.pack mode]+ _ -> fail "mode is unavailable in this binary"+ retentionSeconds <-+ lookupEnv "KEIRO_INSPECTION_RETENTION_SECONDS" >>= \case+ Nothing -> pure Nothing+ Just raw | Just seconds <- readMaybe raw, seconds >= 60, seconds <= (3600 :: Int), mod seconds 5 == 0 -> pure (Just seconds)+ _ -> fail "KEIRO_INSPECTION_RETENTION_SECONDS must be 60..3600 and divisible by 5"+ unless (retentionSeconds == Nothing || offeredRate /= Nothing) (fail "retention mode requires a paced offered rate")+ configuredSeconds <- lookupEnv "KEIRO_INSPECTION_SECONDS"+ let duration = maybe (maybe 20 id (configuredSeconds >>= readMaybe)) id retentionSeconds+ sampleCount = maybe 1000 (* duration) offeredRate+ modes = maybe ["disabled"] (const selectedModes) offeredRate+ enabled <- getRTSStatsEnabled+ unless enabled (fail "run with +RTS -T")+ createDirectoryIfMissing True output+ withMigratedBenchmarkSuite $ \fixture ->+ forM_ [1 .. trialCount] $ \trial ->+ forM_ modes $ \mode -> withFreshDatabase fixture $ \url -> do+ execute url "INSERT INTO keiro.keiro_workflows(workflow_id,workflow_name,status) SELECT 'seed-'||n,'seed-'||(n%31),'running' FROM generate_series(1,100000) n; ANALYZE keiro.keiro_workflows"+ execute url "UPDATE keiro.keiro_workflows SET last_error=repeat('x',65536) WHERE workflow_id=(SELECT workflow_id FROM keiro.keiro_workflows WHERE workflow_name='seed-0' ORDER BY workflow_id LIMIT 1)"+ execute url "INSERT INTO keiro.keiro_timers(timer_id,process_manager_name,correlation_id,fire_at,payload,status) SELECT ('00000000-0000-0000-0000-'||lpad(n::text,12,'0'))::uuid,'seed-timer','corr-'||(n%100),'2100-01-01'::timestamptz+n*interval '1 second',CASE WHEN n=2 THEN to_jsonb(repeat('x',65536)) ELSE jsonb_build_object('n',n) END,CASE WHEN n%4=0 THEN 'fired' WHEN n%8=1 THEN 'firing' ELSE 'scheduled' END FROM generate_series(1,100000) n; ANALYZE keiro.keiro_timers"+ withStore (defaultConnectionSettings url) {poolSize = 1} $ \store -> withMount store url mode $ \app -> do+ _ <- runStoreIO store (registerReadModel "inspection-benchmark" 1 "shape") >>= right+ settings <- use store (Session.statement () (Statement.preparable "SELECT jsonb_build_object('fsync',current_setting('fsync'),'synchronous_commit',current_setting('synchronous_commit'),'full_page_writes',current_setting('full_page_writes'),'autovacuum',current_setting('autovacuum'),'server_version',current_setting('server_version'))" E.noParams (D.singleRow (D.column (D.nonNullable D.jsonb)))))+ durable <- use store (Session.statement () (Statement.preparable "SELECT current_setting('fsync')='on' AND current_setting('synchronous_commit')='on' AND current_setting('full_page_writes')='on' AND current_setting('autovacuum')='on'" E.noParams (D.singleRow (D.column (D.nonNullable D.bool)))))+ unless durable (fail "index experiment requires normal durable settings")+ -- Finish the 100k-row setup's dirty-page writes before warm-up.+ -- Both package sets execute this same explicit checkpoint; normal+ -- durable settings and autovacuum stay enabled during measurement.+ use store (Session.script "CHECKPOINT")+ forM_ [1 .. 50] $ \n -> operation store ("warm-" <> number n) >> pure ()+ lifecycleBefore <- if retentionSeconds /= Nothing then Just <$> heapSample "mounted-before-pollers" 0 else pure Nothing+ ((samples, statsBefore, started, before, writerFinished), pollSuccess, pollFailure) <- withPollers app mode duration $ \startPolling -> do+ -- Let warm writer notifications settle in every mode, including+ -- the baseline, before capturing counters and the measurement clock.+ threadDelay 1000000+ flush store+ before <- counters store+ performMajorGC+ statsBefore <- getRTSStats+ started <- getMonotonicTimeNSec+ startPolling+ let measured n = do+ now <- getMonotonicTimeNSec+ let due = maybe now (\rate -> started + fromIntegral (n - 1) * div 1000000000 (fromIntegral rate)) offeredRate+ when (due > now) (threadDelay (fromIntegral (div (due - now) 1000)))+ admitted <- getMonotonicTimeNSec+ (schedule, claim, resume, timer, allocation) <- operation store ("measured-" <> Text.justifyRight 9 '0' (number (sampleCount - n)))+ let waiting = if offeredRate /= Nothing && admitted > due then fromIntegral (admitted - due) / 1000 else 0+ pure (waiting, schedule, claim, resume, timer, allocation)+ samples <- case retentionSeconds of+ Nothing -> forM [1 .. sampleCount] measured+ Just _ -> withFile (output </> (show trial <> "-" <> Text.unpack mode <> "-heap.jsonl")) WriteMode $ \handle -> do+ let emit phase n = do+ value <- heapSample phase n+ LazyChar8Portable.hPutStrLn handle (Aeson.encode value)+ hFlush handle+ emit "before-running" 0+ -- Discard every per-operation timing. Retaining a latency list+ -- would itself create a false positive live-heap trend.+ -- A range shared across trials/modes floats outside their+ -- callbacks at O1 and retains 40 bytes per completed item.+ -- Keep this counter strict and allocate no enumeration list.+ let loop n+ | n > sampleCount = pure ()+ | otherwise = do+ void (measured n)+ when (mod n (maybe 1 (* 5) offeredRate) == 0) (emit "running" n)+ loop (n + 1)+ loop 1+ pure []+ writerFinished <- getMonotonicTimeNSec+ pure (samples, statsBefore, started, before, writerFinished)+ finished <- getMonotonicTimeNSec+ performMajorGC+ statsAfter <- getRTSStats+ flush store+ after <- counters store+ resourcesAfter <- traverse (\(_, sample) -> sample True) app+ lifecycleAfter <- case retentionSeconds of+ Nothing -> pure Nothing+ Just _ -> do+ threadDelay 12000000+ Just <$> heapSample "mounted-after-poller-cleanup-and-drain" sampleCount+ let elapsed = fromIntegral (writerFinished - started) / 1e9 :: Double+ cleanupSeconds = fromIntegral (finished - writerFinished) / 1e9 :: Double+ durations = map (\(q, a, b, c, d, _) -> q + a + b + c + d) samples+ percentile p = if null durations then Nothing else Just (sort durations !! min (sampleCount - 1) (floor (p * fromIntegral sampleCount)))+ (views, clients) = pollingShape mode+ artifact = object ["resources_after_clients" .= resourcesAfter, "trial" .= trial, "retention_seconds" .= retentionSeconds, "lifecycle_before" .= lifecycleBefore, "lifecycle_after" .= lifecycleAfter, "mode" .= mode, "views" .= views, "clients" .= clients, "poll_success" .= pollSuccess, "poll_failure" .= pollFailure, "writer_thread_allocated_bytes_per_operation" .= (if null samples then Nothing else Just (sum [allocated | (_, _, _, _, _, allocated) <- samples] / fromIntegral sampleCount)), "allocated_bytes" .= (statsAfter.allocated_bytes - statsBefore.allocated_bytes), "cpu_ns" .= (statsAfter.cpu_ns - statsBefore.cpu_ns), "settings" .= (settings :: Value), "offered_ops_per_second" .= offeredRate, "elapsed_seconds" .= elapsed, "client_cleanup_seconds" .= cleanupSeconds, "minor_and_major_gcs" .= (statsAfter.gcs - statsBefore.gcs), "major_gcs" .= (statsAfter.major_gcs - statsBefore.major_gcs), "gc_cpu_ns" .= (statsAfter.gc_cpu_ns - statsBefore.gc_cpu_ns), "gc_elapsed_ns" .= (statsAfter.gc_elapsed_ns - statsBefore.gc_elapsed_ns), "throughput" .= (fromIntegral sampleCount / elapsed), "p50_us" .= percentile 0.5, "p95_us" .= percentile 0.95, "p99_us" .= percentile 0.99, "before" .= before, "after" .= after, "samples_wait_schedule_claim_resume_timer_us_writer_bytes" .= samples]+ path = output </> (show trial <> "-" <> Text.unpack mode <> ".json")+ Lazy.writeFile path (Aeson.encode artifact)+ putStrLn path++-- Equal operation-block samples after major GC, including active threads and+-- large objects. The JSONL output is streamed and never retained in a list.+heapSample :: Text -> Int -> IO Value+heapSample phase completed = do+ performMajorGC+ stats <- getRTSStats+ statuses <- listThreads >>= traverse threadStatus+ let active ThreadFinished = False+ active ThreadDied = False+ active _ = True+ pure (object ["phase" .= phase, "operations" .= completed, "live_bytes" .= stats.gc.gcdetails_live_bytes, "large_object_bytes" .= stats.gc.gcdetails_large_objects_bytes, "active_threads" .= length (filter active statuses)])++-- Returns schedule/claim/resume phase durations. The resume performs a real+-- named step and completion, then releases its advance lease.+operation :: KirokuStore -> Text -> IO (Double, Double, Double, Double, Double)+operation store wid = do+ let name = WorkflowName "index-evidence"+ identity = WorkflowId wid+ checked :: Eff '[Store, Error StoreError, IOE] a -> IO a+ checked action = runStoreIO store action >>= right+ allocationBefore <- getAllocationCounter+ a <- getMonotonicTimeNSec+ checked (runTransaction (Instance.upsertInstanceTx wid "index-evidence" 0 Instance.WfRunning Nothing))+ b <- getMonotonicTimeNSec+ claim <- checked (Instance.claimInstance "index-evidence" 30 name identity)+ unless (claim == Instance.ClaimAcquired) (fail (show claim))+ c <- getMonotonicTimeNSec+ outcome <- checked (runWorkflow name identity (step (StepName "work") (pure (1 :: Int))))+ case outcome of Completed 1 -> pure (); _ -> fail (show outcome)+ checked (Instance.releaseInstance "index-evidence" True name identity)+ d <- getMonotonicTimeNSec+ now <- getCurrentTime+ let timerId = Timer.TimerId (UUIDv5.generateNamed UUIDv5.namespaceURL (Bytes.unpack (Text.encodeUtf8 wid)))+ checked (runTransaction (Timer.scheduleTimerTx (Timer.TimerRequest timerId "index-evidence" wid now Aeson.Null)))+ claimed <- checked (Timer.claimDueTimer now)+ unless (maybe False (\row -> row.timerId == timerId) claimed) (fail "timer was not claimed")+ fired <- checked (Timer.markTimerFired timerId (EventId UUID.nil))+ unless fired (fail "timer was not fired")+ e <- getMonotonicTimeNSec+ allocationAfter <- getAllocationCounter+ pure (micros (b - a), micros (c - b), micros (d - c), micros (e - d), fromIntegral (allocationBefore - allocationAfter))+ where+ micros ns = fromIntegral ns / 1000++flush :: KirokuStore -> IO ()+flush store = use store (Session.script "SELECT pg_stat_force_next_flush(); SELECT pg_stat_clear_snapshot()")++counters :: KirokuStore -> IO (Text, Int, Int)+counters store = use store (Session.statement () (Statement.preparable "SELECT pg_current_wal_insert_lsn()::text,n_tup_upd,n_tup_hot_upd FROM pg_stat_user_tables WHERE schemaname='keiro' AND relname='keiro_workflows'" E.noParams (D.singleRow ((,,) <$> D.column (D.nonNullable D.text) <*> (fromIntegral <$> D.column (D.nonNullable D.int8)) <*> (fromIntegral <$> D.column (D.nonNullable D.int8))))))++number :: Int -> Text+number = Text.pack . show++use :: KirokuStore -> Session.Session a -> IO a+use store session = Pool.use store.pool session >>= right++right :: (Show e) => Either e a -> IO a+right = either (fail . show) pure++execute :: Text -> Text -> IO ()+execute url sql = bracket (Connection.acquire (Settings.connectionString url) >>= right) Connection.release $ \connection ->+ Connection.use connection (Session.script sql) >>= right++withMount :: KirokuStore -> Text -> Text -> (Maybe (Int, Bool -> IO Value) -> IO a) -> IO a+#ifdef KEIRO_INSPECTION_BASELINE+withMount _ _ _ action = action Nothing++inspectionModes :: [Text]+inspectionModes = ["disabled"]++withPollers :: Maybe (Int, Bool -> IO Value) -> Text -> Int -> (IO () -> IO a) -> IO (a, Int, Int)+withPollers _ _ _ action = do+ result <- action (pure ())+ pure (result, 0, 0)+#else+withMount store url mode action+ | mode == "disabled" = action Nothing+ | otherwise = withOpsInspectionPool defaultInspectionLimits (defaultConnectionSettings url) $ \pool -> do+ let inspected = store {pool = pool}+ withFeedHandle defaultOpsHttpConfig defaultFeedConfig inspected $ \feeds -> do+ let catalog+ | mode `elem` ["pm-types-many", "pm-types-many-idle"] = Map.fromList [("alias-" <> Text.justifyRight 4 '0' (number n), snd (Map.findMin jitsureiProcessManagerInspectors)) | n <- [1 .. 1000]]+ | otherwise = jitsureiProcessManagerInspectors+ hooks+ | "pm-types" `Text.isPrefixOf` mode = Ops.emptyAppHooks {Ops.processManagers = Just catalog}+ | otherwise = Ops.emptyAppHooks+ child <- opsApplicationWithFeeds defaultOpsHttpConfig feeds hooks inspected+ app <- either (fail . show) pure (composeSurfaces defaultComposeConfig [MountedSurface keiroPrefix child])+ withOpsHttpServer defaultOpsHttpServerConfig {port = 0} app $ \server -> action (Just (server.serverPort, resourceSample feeds))++resourceSample :: FeedHandle -> Bool -> IO Value+resourceSample handle awaitCleanup = loop (if awaitCleanup then 200 else 0 :: Int)+ where+ loop attempts = do+ stats <- feedStats handle+ if awaitCleanup && (stats.activeViews /= 0 || stats.activeConnections /= 0 || stats.wakePumpRunning) && attempts > 0+ then threadDelay 10000 >> loop (attempts - 1)+ else do+ when (awaitCleanup && (stats.activeViews /= 0 || stats.activeConnections /= 0 || stats.wakePumpRunning)) (fail "feed resources did not return to idle")+ pure (object ["views" .= stats.activeViews, "connections" .= stats.activeConnections, "wake_pump" .= stats.wakePumpRunning])++withPollers :: Maybe (Int, Bool -> IO Value) -> Text -> Int -> (IO () -> IO a) -> IO (a, Int, Int)+withPollers mount mode seconds action = case mount of+ Just (serverPort, _) | clients > 0 -> do+ executable <- getExecutablePath+ let child = (proc executable ["--clients", show serverPort, show clients, show views, show seconds, Text.unpack mode]) {std_in = CreatePipe, std_out = CreatePipe}+ cleanup (input, output, errors, process) = do+ terminateProcess process+ _ <- waitForProcess process+ mapM_ (maybe (pure ()) hClose) [input, output, errors]+ bracket (createProcess child) cleanup $ \case+ (Just input, Just output, _, process) -> do+ ready <- hGetLine output+ unless (ready == "ready") (fail "poll client did not finish warming its views")+ result <- action (hPutStrLn input "go" >> hFlush input)+ counts <- hGetLine output+ exit <- waitForProcess process+ unless (exit == ExitSuccess) (fail "poll client failed")+ (good, bad) <- right (Aeson.eitherDecode (LazyChar8Portable.pack counts))+ pure (result, good, bad)+ _ -> fail "missing poll client control pipes"+ _ -> do+ result <- action (pure ())+ pure (result, 0, 0)+ where+ (views, clients) = pollingShape mode++inspectionModes :: [Text]+inspectionModes = ["disabled", "idle", "active-shared", "active-distinct", "slow-reader", "churn", "pm-types-idle", "pm-types", "pm-types-many-idle", "pm-types-many"]++#endif++-- Distinct views and connection counts are independent: a single client can+-- poll all views, while many clients can share one view's authoritative route.+pollingShape :: Text -> (Int, Int)+pollingShape = \case+ "pm-types" -> (0, 4)+ "pm-types-many" -> (0, 4)+ "active-shared" -> (1, 1)+ "active-distinct" -> (32, 256)+ "slow-reader" -> (32, 256)+ "churn" -> (32, 32)+ _ -> (0, 0)
+ example/Compose.hs view
@@ -0,0 +1,56 @@+module Main (main) where++import Control.Concurrent.Async (wait)+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.IO qualified as Text+import Keiro.Ops (emptyAppHooks)+import Keiro.Ops.Env (resolveConnectionString)+import Keiro.Ops.Http+import Keiro.Ops.Http.Application (opsApplicationWithBudget)+import Keiro.Ops.Http.Config qualified as Config+import Keiro.Ops.Http.WebSocket (feedsMiddleware)+import Kiroku.Metrics qualified as Metrics+import Kiroku.Metrics.Config qualified as MetricsConfig+import Kiroku.Store.Connection (ConnectionSettingsM (..), KirokuStore (..), defaultConnectionSettings, withStore)+import Options.Applicative+import System.IO (stderr)++data Options = Options !(Maybe Text) !Text !Int ![Text]++options :: Parser Options+options =+ Options+ <$> optional (textOption (long "database-url" <> metavar "URL"))+ <*> textOption (long "bind" <> value "127.0.0.1")+ <*> option auto (long "port" <> value 9092)+ <*> many (textOption (long "allow-origin" <> metavar "ORIGIN"))+ where+ textOption modifiers = Text.pack <$> strOption modifiers++main :: IO ()+main = do+ Options database host port origins <- execParser (info (options <**> helper) (fullDesc <> progDesc "Compose inspection surfaces on a trusted network or behind an authenticating proxy"))+ connectionString <- resolveConnectionString database+ parsedOrigins <- either (fail . show) pure (traverse Metrics.allowedOrigin origins)+ let cors = Metrics.corsAllowOrigins parsedOrigins+ let config = defaultOpsHttpConfig {Config.allowedOrigins = origins}+ serverConfig = defaultOpsHttpServerConfig {bindHost = host, port}+ settings = (defaultConnectionSettings connectionString) {poolSize = config.inspectionLimits.maxConcurrentReads, statementTimeout = Just 2}+ -- An inspection-owned store and pool; the application never borrows the+ -- runtime writer's reserved connections. Each constructor runs once.+ withStore settings $ \baseStore -> withOpsInspectionPool config.inspectionLimits settings $ \pool -> do+ let store = baseStore {pool}+ metricsConfig = Metrics.defaultConfig {MetricsConfig.cors = cors}+ metrics <- Metrics.newKirokuMetrics store+ providers <- Metrics.storeServerProviders metricsConfig metrics store+ let kiroku = Metrics.combinedAppWithProviders metricsConfig metrics [] providers+ withFeedHandle config defaultFeedConfig store $ \feeds -> do+ http <- opsApplicationWithBudget config (handleBudget feeds) emptyAppHooks store+ let keiro = feedsMiddleware config feeds http+ app <-+ either (fail . Text.unpack . renderComposeError) pure $+ composeSurfaces (ComposeConfig origins) [MountedSurface keiroPrefix keiro, MountedSurface kirokuPrefix kiroku]+ withOpsHttpServer serverConfig app $ \server -> do+ Text.hPutStrLn stderr ("keiro-ops-http-compose-example: listening on " <> host <> ":" <> Text.pack (show server.serverPort) <> "; surfaces /keiro /kiroku")+ wait server.serverThread
+ keiro-ops-http.cabal view
@@ -0,0 +1,228 @@+cabal-version: 3.0+name: keiro-ops-http+version: 0.22.0.0+synopsis:+ Bounded HTTP transport for the Keiro operational command tree++description:+ An embeddable WAI application, Warp runner, and database-only executable.+ Exposes the supported command tree with bounded reads and confirmed mutations.++license: BSD-3-Clause+license-file: LICENSE+author: Nadeem Bitar+maintainer: nadeem@gmail.com+copyright: 2026 Nadeem Bitar+category: Operations+homepage: https://github.com/shinzui/keiro#readme+bug-reports: https://github.com/shinzui/keiro/issues+build-type: Simple+tested-with: ghc >=9.12 && <9.13+extra-doc-files: CHANGELOG.md++source-repository head+ type: git+ location: https://github.com/shinzui/keiro.git++common warnings+ ghc-options:+ -Wall+ -Wcompat+ -Widentities+ -Wincomplete-record-updates+ -Wincomplete-uni-patterns+ -Wpartial-fields+ -Wredundant-constraints++common shared+ default-language: GHC2024+ default-extensions:+ BlockArguments+ DeriveAnyClass+ DuplicateRecordFields+ ImportQualifiedPost+ LambdaCase+ OverloadedLabels+ OverloadedRecordDot+ OverloadedStrings++library+ import: warnings, shared+ hs-source-dirs: src+ exposed-modules:+ Keiro.Ops.Http+ Keiro.Ops.Http.Application+ Keiro.Ops.Http.Budget+ Keiro.Ops.Http.Compose+ Keiro.Ops.Http.Config+ Keiro.Ops.Http.Cors+ Keiro.Ops.Http.Feed+ Keiro.Ops.Http.Pool+ Keiro.Ops.Http.Protocol+ Keiro.Ops.Http.Route+ Keiro.Ops.Http.Server+ Keiro.Ops.Http.Views+ Keiro.Ops.Http.WebSocket++ other-modules:+ Keiro.Ops.Http.Feed.Driver+ Keiro.Ops.Http.Feed.Queue++ build-depends:+ aeson >=2.2.2 && <2.3,+ async >=2.2 && <2.3,+ base >=4.21 && <5,+ bytestring >=0.12 && <0.13,+ containers >=0.6 && <0.8,+ effectful >=2.6 && <2.8,+ hasql >=1.10 && <1.11,+ hasql-pool >=1.2 && <1.5,+ http-types >=0.12 && <0.13,+ keiro ^>=0.22.0.0,+ keiro-migrations ^>=0.22.0.0,+ keiro-ops ^>=0.22.0.0,+ kiroku-store ^>=0.12.0.0,+ network >=3.1 && <3.3,+ network-uri >=2.6.4.2 && <2.7,+ optparse-applicative >=0.19 && <0.20,+ stm >=2.5 && <2.6,+ text >=2.1 && <2.2,+ time >=1.12 && <1.15,+ uuid >=1.3 && <1.4,+ wai >=3.2 && <3.3,+ wai-websockets >=3.0 && <3.1,+ warp >=3.4 && <3.5,+ websockets >=0.13 && <0.14,++executable keiro-ops-http+ import: warnings, shared+ hs-source-dirs: app+ main-is: Main.hs+ ghc-options:+ -threaded+ -rtsopts+ -with-rtsopts=-N++ build-depends:+ async >=2.2 && <2.3,+ base >=4.21 && <5,+ hasql >=1.10 && <1.11,+ keiro-migrations ^>=0.22.0.0,+ keiro-ops ^>=0.22.0.0,+ keiro-ops-http,+ kiroku-store ^>=0.12.0.0,+ optparse-applicative >=0.19 && <0.20,+ text >=2.1 && <2.2,++flag compose-example+ description: Build the composed inspection example with the sibling metrics surface.+ default: False+ manual: True++executable keiro-ops-http-compose-example+ import: warnings, shared+ hs-source-dirs: example+ main-is: Compose.hs+ ghc-options:+ -threaded+ -rtsopts+ -with-rtsopts=-N++ if !flag(compose-example)+ buildable: False+ build-depends:+ async >=2.2 && <2.3,+ base >=4.21 && <5,+ keiro-ops ^>=0.22.0.0,+ keiro-ops-http,+ kiroku-metrics ^>=0.4.0.0,+ kiroku-store ^>=0.12.0.0,+ optparse-applicative >=0.19 && <0.20,+ text >=2.1 && <2.2,++benchmark inspection-interference+ import: warnings, shared+ type: exitcode-stdio-1.0+ hs-source-dirs: bench+ main-is: InspectionInterference.hs+ other-modules: InspectionClients+ ghc-options:+ -threaded+ -rtsopts+ -with-rtsopts=-N8++ build-depends:+ aeson >=2.2 && <2.3,+ async >=2.2 && <2.3,+ base >=4.21 && <5,+ bytestring >=0.12 && <0.13,+ containers >=0.6 && <0.8,+ directory >=1.3 && <1.4,+ effectful >=2.6 && <2.8,+ filepath >=1.4 && <1.6,+ hasql >=1.10 && <1.11,+ hasql-pool >=1.2 && <1.5,+ http-client >=0.7 && <0.8,+ http-types >=0.12 && <0.13,+ jitsurei,+ keiro ^>=0.22.0.0,+ keiro-ops ^>=0.22.0.0,+ keiro-ops-http,+ keiro-test-support ^>=0.22.0.0,+ kiroku-store ^>=0.12.0.0,+ process >=1.6 && <1.7,+ text >=2.1 && <2.2,+ time >=1.12 && <1.15,+ uuid >=1.3 && <1.4,+ websockets >=0.13 && <0.14,++test-suite keiro-ops-http-test+ import: warnings, shared+ type: exitcode-stdio-1.0+ hs-source-dirs: test+ main-is: Main.hs+ ghc-options:+ -threaded+ -rtsopts+ -with-rtsopts=-N++ other-modules:+ Test.ComposeSpec+ Test.FeedSpec+ Test.WebSocketSpec++ build-tool-depends: keiro-ops:keiro-ops+ build-depends:+ aeson >=2.2.2 && <2.3,+ async >=2.2 && <2.3,+ base >=4.21 && <5,+ bytestring >=0.12 && <0.13,+ containers >=0.6 && <0.8,+ effectful >=2.6 && <2.8,+ hasql >=1.10 && <1.11,+ hasql-pool >=1.2 && <1.5,+ hspec >=2.11,+ http-client >=0.7 && <0.8,+ http-types >=0.12 && <0.13,+ jitsurei,+ keiro ^>=0.22.0.0,+ keiro-migrations ^>=0.22.0.0,+ keiro-ops ^>=0.22.0.0,+ keiro-ops-http,+ keiro-pgmq ^>=0.22.0.0,+ keiro-test-support ^>=0.22.0.0,+ kiroku-metrics ^>=0.4.0.0,+ kiroku-store ^>=0.12.0.0,+ network >=3.1 && <3.3,+ optparse-applicative >=0.19 && <0.20,+ pgmq-migration >=0.7.0.0 && <0.8,+ process >=1.6 && <1.7,+ stm >=2.5 && <2.6,+ text >=2.1 && <2.2,+ time >=1.12 && <1.15,+ uuid >=1.3 && <1.4,+ vector >=0.13 && <0.14,+ wai >=3.2 && <3.3,+ wai-extra >=3.1 && <3.2,+ warp >=3.4 && <3.5,+ websockets >=0.13 && <0.14,
+ src/Keiro/Ops/Http.hs view
@@ -0,0 +1,23 @@+module Keiro.Ops.Http+ ( opsApplication,+ opsApplicationWithFeeds,+ module Keiro.Ops.Http.Feed,+ module Keiro.Ops.Http.Compose,+ OpsHttpError (..),+ errorResponse,+ module Keiro.Ops.Http.Config,+ module Keiro.Ops.Http.Server,+ withOpsInspectionPool,+ corsMiddleware,+ )+where++import Keiro.Ops.Http.Application+import Keiro.Ops.Http.Compose+import Keiro.Ops.Http.Config+import Keiro.Ops.Http.Cors+import Keiro.Ops.Http.Pool+import Keiro.Ops.Http.Server++import Keiro.Ops.Http.Feed+import Keiro.Ops.Http.WebSocket (opsApplicationWithFeeds)
+ src/Keiro/Ops/Http/Application.hs view
@@ -0,0 +1,264 @@+module Keiro.Ops.Http.Application+ ( opsApplication,+ opsApplicationWithBudget,+ OpsHttpError (..),+ errorResponse,+ OpsHttpOperations (..),+ opsApplicationWithOperations,+ )+where++import Control.Exception (SomeAsyncException, SomeException, displayException, evaluate, fromException, throwIO, try)+import Data.Aeson (Value (..), object, (.=))+import Data.Aeson qualified as Aeson+import Data.ByteString qualified as ByteString+import Data.ByteString.Char8 qualified as Char8+import Data.ByteString.Lazy qualified as LazyByteString+import Data.Char (toLower)+import Data.IORef+import Data.Map.Strict qualified as Map+import Data.Sequence qualified as Seq+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text+import Hasql.Pool qualified as Pool+import Keiro.Migrations.SchemaCheck (renderSchemaDrift, verifyExpectedSchemaSession)+import Keiro.Ops (AppHooks, Command, commandParser, isMutation, runCommandWithReadEncoding, validateCommand)+import Keiro.Ops.Env (OpsEnv (..), OutputMode (Json))+import Keiro.Ops.Http.Budget+import Keiro.Ops.Http.Config+import Keiro.Ops.Http.Cors+import Keiro.Ops.Http.Route+import Keiro.Ops.Render+import Keiro.Ops.WorkflowHeaderCache (newWorkflowHeaderCache)+import Kiroku.Store.Connection (KirokuStore (..))+import Network.HTTP.Types+import Network.Wai+import System.Exit (ExitCode (..))++data ResponsePlan = Immediate Response | Inspected Bool (IO Response)++data OpsHttpError = OpsHttpError+ { status :: !Status,+ code :: !Text,+ message :: !Text,+ details :: !(Maybe Value)+ }++jsonHeaders :: ResponseHeaders+jsonHeaders = [(hContentType, "application/json; charset=utf-8")]++errorResponse :: OpsHttpError -> Response+errorResponse err = responseLBS err.status jsonHeaders (Aeson.encode (errorValue err))++errorValue :: OpsHttpError -> Value+errorValue err = object ["error" .= object (["code" .= err.code, "message" .= err.message] <> maybe [] (\value -> ["details" .= value]) err.details)]++failure :: Status -> Text -> Text -> Response+failure status code message = errorResponse (OpsHttpError status code message Nothing)++-- | A host/test seam for supported verification and execution, never a route+-- registry. Production mounting uses 'opsApplication'.+data OpsHttpOperations = OpsHttpOperations+ { verifySchema :: IO (Either Text [Text]),+ executeCommand :: OpsEnv -> Command -> IO OpsOutcome+ }++-- | Allocate once per mount. The supplied store must have statement_timeout+-- configured on EVERY connection, at or below inspectionLimits. Use a dedicated+-- pool; changing a borrowed connection here cannot constrain another handler's+-- borrow and would change writer policy on a shared pool.+opsApplication :: OpsHttpConfig -> AppHooks -> KirokuStore -> IO Application+opsApplication config hooks store = do+ budget <- newInspectionBudget config.inspectionLimits+ opsApplicationWithBudget config budget hooks store++-- | Internal feed integration: HTTP and refresh work consume the same budget.+opsApplicationWithBudget :: OpsHttpConfig -> InspectionBudget -> AppHooks -> KirokuStore -> IO Application+opsApplicationWithBudget config budget hooks store = do+ cache <- newWorkflowHeaderCache config.inspectionLimits.maxResponseBytes $ \value ->+ fmap LazyByteString.toStrict <$> encodeBounded config.inspectionLimits.maxResponseBytes value+ let verify = do+ result <- Pool.use store.pool verifyExpectedSchemaSession+ pure $ case result of+ Left err -> Left (Text.pack (show err))+ Right (Left err) -> Left (Text.pack (show err))+ Right (Right drifts) -> Right (map renderSchemaDrift drifts)+ let encodeDirect encoding = fmap LazyByteString.toStrict <$> encodeBoundedEncoding config.inspectionLimits.maxResponseBytes encoding+ opsApplicationPrepared config budget hooks store verify (runCommandWithReadEncoding encodeDirect cache hooks)++opsApplicationWithOperations :: OpsHttpConfig -> InspectionBudget -> AppHooks -> KirokuStore -> OpsHttpOperations -> IO Application+opsApplicationWithOperations config budget hooks store operations =+ opsApplicationPrepared config budget hooks store operations.verifySchema $ \env command -> do+ outcome <- operations.executeCommand env command+ pure (outcome, Nothing)++-- The prepared bytes are paired with the exact successful read outcome by the+-- owning runner. They never change the public OpsResult or mutation protocol.+opsApplicationPrepared :: OpsHttpConfig -> InspectionBudget -> AppHooks -> KirokuStore -> IO (Either Text [Text]) -> (OpsEnv -> Command -> IO (OpsOutcome, Maybe ByteString.ByteString)) -> IO Application+opsApplicationPrepared config budget hooks store verify execute = do+ parsedReads <- newIORef (Map.empty, Seq.empty)+ let parser = commandParser hooks+ flags = flagNames parser+ parse arguments = do+ (known, _) <- readIORef parsedReads+ case Map.lookup arguments known of+ Just command -> pure (Parsed command)+ Nothing -> case parseArgumentsWithParser parser arguments of+ Parsed command+ | not (isMutation command),+ Right () <- validateCommand command -> do+ -- Cache only pure parsing, never observations or authorization.+ -- FIFO capacity and the request-byte cap bound retained inputs.+ atomicModifyIORef' parsedReads $ \(commands, order) ->+ if Map.member arguments commands+ then ((commands, order), ())+ else+ let (kept, queue) = case Seq.viewl order of+ oldest Seq.:< rest | Map.size commands >= 64 -> (Map.delete oldest commands, rest)+ _ -> (commands, order)+ in ((Map.insert arguments command kept, queue Seq.|> arguments), ())+ pure (Parsed command)+ outcome -> pure outcome+ -- Initialize static command metadata during mounting, before requests arrive.+ _ <- evaluate (flags [])+ pure $ corsMiddleware config.allowedOrigins $ \request respond -> do+ result <- trySync (dispatch parse flags request)+ case result of+ Left _ -> respond internalError+ Right (Immediate response) -> respond response+ Right (Inspected mutation action) -> do+ let deadline = if mutation then limits.mutationDeadlineSeconds else limits.readDeadlineSeconds+ delivered <- withInspectionAdmission budget $ do+ computed <- withInspectionDeadline deadline (trySync action)+ -- Network exceptions must propagate; never attempt a second response.+ withInspectionDelivery limits.deliveryDeadlineSeconds $ respond $ case computed of+ Left problem -> budgetFailure mutation problem+ Right (Left _) -> internalError+ Right (Right response) -> response+ case delivered of+ Left problem -> respond (budgetFailure mutation problem)+ Right sent -> pure sent+ where+ limits = config.inspectionLimits+ dispatch parse flags request+ | inputBytes request > limits.maxRequestBytes = immediate (failure status413 "inspection_request_too_large" "request path and query exceed the host limit")+ | pathInfo request == [] = immediate (failure status404 "not_found" "no inspection command at this path")+ | pathInfo request == ["health"] =+ if requestMethod request == methodGet+ then pure (Inspected False health)+ else immediate (methodFailure methodGet)+ | otherwise = case translate flags request of+ Left message -> immediate (failure status400 "invalid_request" message)+ Right arguments -> do+ parsed <- parse arguments+ case parsed of+ Rejected message -> immediate (errorResponse (OpsHttpError status400 "invalid_request" message (Just (object ["arguments" .= arguments]))))+ Parsed command | Left (code, message) <- validateCommand command -> immediate (failure status400 code message)+ Parsed command -> do+ let mutation = isMutation command+ method = if mutation then methodPost else methodGet+ if requestMethod request /= method+ then immediate (methodFailure method)+ else+ if mutation && (any (\(key, value) -> key == "Origin" && ByteString.length value > limits.maxRequestBytes) (requestHeaders request) || not (originAllowed config.allowedOrigins (isSecure request) (requestHeaders request)))+ then immediate (failure status403 "origin_not_allowed" "mutation origin is not allowed")+ else+ if mutation && config.mutations == MutationsDisabled+ then immediate (failure status403 "mutations_disabled" "this surface was mounted without mutations")+ else budgetedCommand request command mutation+ translate flags request = do+ query <- either (Left . Text.pack . displayException) Right (traverse decodeQuery (queryString request))+ either (Left . renderRouteError) Right (translateRequest (flags (pathInfo request)) (pathInfo request) query)+ decodeQuery (key, value) = (,) <$> Text.decodeUtf8' key <*> traverse Text.decodeUtf8' value+ inputBytes request = ByteString.length (rawPathInfo request) + ByteString.length (rawQueryString request)+ methodFailure method = mapResponseHeaders ((hAllow, method) :) (failure status405 "method_not_allowed" "method does not match the command classification")+ budgetedCommand request command mutation =+ pure $ Inspected mutation $ do+ confirmation <- if mutation then readConfirmation request else pure (Right False)+ case confirmation of+ Left err -> pure err+ Right confirmed -> do+ verified <- verify+ case verified of+ Left message -> pure (failure status500 "schema_verification_failed" message)+ Right drift+ | not (null drift) && config.schemaDrift == RefuseOnDrift ->+ boundedJson status409 [] (errorValue (OpsHttpError status409 "schema_drift" "the live schema differs from this binary; refusing to serve until they agree" (Just (object ["drift" .= drift]))))+ | otherwise -> do+ let env = OpsEnv store Json (mutation && confirmed) drift (config.schemaDrift == AllowDrift)+ (outcome, prepared) <- execute env command+ renderOutcome mutation drift prepared outcome+ immediate = pure . Immediate+ internalError = failure status500 "internal_error" "inspection failed"+ budgetFailure mutation = \case+ InspectionBusy -> failure status503 "inspection_busy" "all inspection slots are in use; retry later"+ InspectionTimedOut+ | mutation -> failure status504 "mutation_outcome_unknown" "deadline exceeded; the mutation may have committed; inspect state before deciding whether to retry"+ | otherwise -> failure status504 "inspection_timeout" "inspection exceeded the host deadline"+ health = do+ result <- verify+ case result of+ Left message -> boundedJson status503 [] (object ["status" .= ("unavailable" :: Text), "message" .= message])+ Right drift ->+ boundedJson+ (if null drift || config.schemaDrift == AllowDrift then status200 else status503)+ (driftHeaders drift)+ (object ["status" .= (if null drift then "ok" else "schema_drift" :: Text), "schema_drift" .= drift, "mutations_enabled" .= (config.mutations == MutationsEnabled)])+ driftHeaders drift = [("Keiro-Ops-Schema-Drift", Char8.pack (show (length drift))) | not (null drift) && config.schemaDrift == AllowDrift]+ boundedJson status headers value = do+ encoded <- encodeBounded limits.maxResponseBytes value+ pure $ case encoded of+ Nothing -> failure status413 "inspection_result_too_large" "encoded result exceeds the host limit; narrow the read"+ Just body -> responseLBS status (jsonHeaders <> headers) body+ renderOutcome mutation drift prepared = \case+ InspectionFailed code message ->+ let status+ | code == "cursor_generation_mismatch" = status409+ | code == "inspection_result_too_large" = status413+ | otherwise = status400+ in boundedJson status [] (errorValue (OpsHttpError status code message Nothing))+ Failed message -> boundedJson status422 [] (errorValue (OpsHttpError status422 "command_failed" message Nothing))+ PreviewRequired result invocation -> boundedJson status200 (driftHeaders drift) (object ["outcome" .= ("preview" :: Text), "result" .= result.jsonValue, "cli_reinvocation" .= invocation])+ Succeeded result -> success result ExitSuccess+ SucceededWithExit result exitCode -> success result exitCode+ where+ success result exitCode =+ let exitFields = case exitCode of ExitSuccess -> []; ExitFailure n -> ["exit_code" .= n]+ exitHeaders = case exitCode of ExitSuccess -> []; ExitFailure n -> [("Keiro-Ops-Exit-Code", Char8.pack (show n))]+ value = if mutation then object (["outcome" .= ("executed" :: Text), "result" .= result.jsonValue] <> exitFields) else result.jsonValue+ in case (mutation, prepared) of+ (False, Just body)+ | ByteString.length body <= limits.maxResponseBytes ->+ pure (responseLBS status200 (jsonHeaders <> driftHeaders drift <> exitHeaders) (LazyByteString.fromStrict body))+ | otherwise ->+ pure (failure status413 "inspection_result_too_large" "encoded result exceeds the host limit; narrow the read")+ _ -> boundedJson status200 (driftHeaders drift <> if mutation then [] else exitHeaders) value++readConfirmation :: Request -> IO (Either Response Bool)+readConfirmation request = go 0 []+ where+ go size chunks = do+ chunk <- getRequestBodyChunk request+ let nextSize = size + ByteString.length chunk+ if nextSize > 1024+ then pure (Left (failure status413 "inspection_request_too_large" "confirmation body exceeds 1 KiB"))+ else+ if ByteString.null chunk+ then pure (parse (LazyByteString.fromChunks (reverse chunks)))+ else go nextSize (chunk : chunks)+ jsonContentType = case [value | (key, value) <- requestHeaders request, key == hContentType] of+ [value] -> Char8.map toLower (Char8.strip (Char8.takeWhile (/= ';') value)) == "application/json"+ _ -> False+ parse body+ | LazyByteString.null body = Right False+ | not jsonContentType = Left (failure status415 "unsupported_media_type" "a nonempty confirmation body requires application/json")+ | Aeson.decode body == Just (object ["confirm" .= ("execute" :: Text)]) = Right True+ | otherwise = Left (failure status400 "invalid_confirmation" "expected an empty body or exactly {\"confirm\":\"execute\"}")++trySync :: IO a -> IO (Either SomeException a)+trySync action = do+ result <- try action+ case result of+ Left exception | Just (_ :: SomeAsyncException) <- fromException exception -> throwIO exception+ _ -> pure result
+ src/Keiro/Ops/Http/Budget.hs view
@@ -0,0 +1,96 @@+module Keiro.Ops.Http.Budget+ ( InspectionBudget,+ BudgetFailure (..),+ newInspectionBudget,+ withInspectionAdmission,+ withInspectionDeadline,+ withInspectionDelivery,+ withInspectionBudget,+ encodeBounded,+ encodeBoundedEncoding,+ )+where++import Control.Concurrent.STM+import Control.Exception (Exception, bracket, evaluate, mask, throwIO)+import Data.Aeson (Value, encode)+import Data.Aeson.Encoding (Encoding, encodingToLazyByteString)+import Data.ByteString qualified as ByteString+import Data.ByteString.Lazy qualified as LazyByteString+import Keiro.Ops.Http.Config+import System.Timeout (timeout)++-- | One mount's admission counter; feed refreshes share this exact handle.+data InspectionBudget = InspectionBudget !Int !(TVar Int)++data BudgetFailure = InspectionBusy | InspectionTimedOut+ deriving stock (Eq, Show)++newInspectionBudget :: InspectionLimits -> IO InspectionBudget+newInspectionBudget limits+ | any (<= 0) values || any (> maxBound `div` 1000000) deadlines =+ ioError (userError "inspection limits must be positive and deadlines representable in microseconds")+ | otherwise = InspectionBudget limits.maxConcurrentReads <$> newTVarIO 0+ where+ deadlines = [limits.readDeadlineSeconds, limits.mutationDeadlineSeconds, limits.deliveryDeadlineSeconds]+ values = deadlines <> [limits.maxConcurrentReads, limits.statementTimeoutMillis, limits.maxResponseBytes, limits.maxRequestBytes]++-- | No waiting queue. Timed-out or cancelled work unwinds before releasing the+-- permit. Exceptions from work are preserved.+withInspectionBudget :: InspectionBudget -> Int -> IO a -> IO (Either BudgetFailure a)+withInspectionBudget budget seconds action = do+ result <- withInspectionAdmission budget (withInspectionDeadline seconds action)+ pure (either Left id result)++-- | Keep the permit through response delivery as well as computation, so slow+-- clients cannot retain an unbounded number of encoded response bodies.+withInspectionAdmission :: InspectionBudget -> IO a -> IO (Either BudgetFailure a)+withInspectionAdmission (InspectionBudget capacity active) action = mask $ \restore -> do+ admitted <- atomically $ do+ count <- readTVar active+ if count >= capacity then pure False else writeTVar active (count + 1) >> pure True+ if not admitted+ then pure (Left InspectionBusy)+ else bracket (pure ()) (const (atomically (modifyTVar' active (subtract 1)))) $ \_ -> do+ Right <$> restore action++-- | Bound computation separately from delivery: once headers are sent, a+-- computation timeout response can no longer be substituted safely.+withInspectionDeadline :: Int -> IO a -> IO (Either BudgetFailure a)+withInspectionDeadline seconds action = do+ -- The threaded RTS timer manager delivers an async exception on expiry;+ -- successful reads reuse the request thread rather than allocating two Async+ -- threads and fresh stacks per request. timeout owns timer cleanup as well as+ -- unwinding the action before returning Nothing.+ outcome <- timeout (seconds * 1000000) action+ pure (maybe (Left InspectionTimedOut) Right outcome)++-- Private termination signal: after delivery starts no second response is safe.+data DeliveryExpired = DeliveryExpired deriving stock (Show)++instance Exception DeliveryExpired++withInspectionDelivery :: Int -> IO a -> IO a+withInspectionDelivery seconds action = do+ result <- timeout (seconds * 1000000) action+ maybe (throwIO DeliveryExpired) pure result++-- | Demand at most the cap plus one byte of Aeson's lazy output. Force the+-- bounded body before leaving the deadline; no partial result is served.+encodeBounded :: Int -> Value -> IO (Maybe LazyByteString.ByteString)+encodeBounded limit = boundBytes limit . encode++-- | The same byte boundary for owner-provided direct JSON encodings.+encodeBoundedEncoding :: Int -> Encoding -> IO (Maybe LazyByteString.ByteString)+encodeBoundedEncoding limit = boundBytes limit . encodingToLazyByteString++boundBytes :: Int -> LazyByteString.ByteString -> IO (Maybe LazyByteString.ByteString)+boundBytes limit encoded = do+ let prefix = LazyByteString.take (fromIntegral limit + 1) encoded+ size <- evaluate (LazyByteString.length prefix)+ if size > fromIntegral limit+ then pure Nothing+ else do+ body <- evaluate (LazyByteString.toStrict prefix)+ _ <- evaluate (ByteString.length body)+ pure (Just (LazyByteString.fromStrict body))
+ src/Keiro/Ops/Http/Compose.hs view
@@ -0,0 +1,110 @@+-- | Mount already allocated applications. This router owns no runtime resources.+module Keiro.Ops.Http.Compose+ ( SurfacePrefix,+ mkSurfacePrefix,+ surfacePrefixText,+ keiroPrefix,+ kirokuPrefix,+ shibuyaPrefix,+ pgmqPrefix,+ MountedSurface (..),+ ComposeConfig (..),+ defaultComposeConfig,+ ComposeError (..),+ renderComposeError,+ composeSurfaces,+ stripSurfacePrefix,+ composedCorsMiddleware,+ )+where++import Data.Aeson (encode, object, (.=))+import Data.ByteString qualified as Bytes+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Text qualified as Text+import Keiro.Ops.Http.Application (OpsHttpError (..), errorResponse)+import Keiro.Ops.Http.Cors (corsMiddleware)+import Network.HTTP.Types+import Network.Wai++newtype SurfacePrefix = SurfacePrefix Text+ deriving stock (Eq, Ord, Show)++mkSurfacePrefix :: Text -> Either ComposeError SurfacePrefix+mkSurfacePrefix value+ | not (Text.null value), not (Text.isPrefixOf "-" value), Text.all valid value = Right (SurfacePrefix value)+ | otherwise = Left (InvalidPrefix value)+ where+ valid c = c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-'++surfacePrefixText :: SurfacePrefix -> Text+surfacePrefixText (SurfacePrefix value) = value++keiroPrefix, kirokuPrefix, shibuyaPrefix, pgmqPrefix :: SurfacePrefix+keiroPrefix = SurfacePrefix "keiro"+kirokuPrefix = SurfacePrefix "kiroku"+shibuyaPrefix = SurfacePrefix "shibuya"+pgmqPrefix = SurfacePrefix "pgmq"++data MountedSurface = MountedSurface+ { prefix :: !SurfacePrefix,+ application :: !Application+ }++newtype ComposeConfig = ComposeConfig {allowedOrigins :: [Text]}+ deriving stock (Eq, Show)++defaultComposeConfig :: ComposeConfig+defaultComposeConfig = ComposeConfig []++data ComposeError = InvalidPrefix !Text | DuplicatePrefix !Text | NoSurfaces+ deriving stock (Eq, Show)++renderComposeError :: ComposeError -> Text+renderComposeError = \case+ InvalidPrefix value -> "invalid surface prefix: " <> value+ DuplicatePrefix value -> "duplicate surface prefix: " <> value+ NoSurfaces -> "at least one inspection surface must be mounted"++composeSurfaces :: ComposeConfig -> [MountedSurface] -> Either ComposeError Application+composeSurfaces _ [] = Left NoSurfaces+composeSurfaces config surfaces = do+ mounts <- foldl add (Right Map.empty) surfaces+ let names = map (surfacePrefixText . (.prefix)) surfaces+ index =+ responseLBS status200 [(hContentType, "application/json; charset=utf-8")] $+ encode (object ["surfaces" .= map (\name -> object ["prefix" .= name, "path" .= ("/" <> name)]) names])+ router req respond = case pathInfo req of+ [] | requestMethod req == methodGet -> respond index+ [] -> respond $ mapResponseHeaders ((hAllow, "GET") :) $ errorResponse (OpsHttpError status405 "method_not_allowed" "the composed root answers GET only" Nothing)+ name : _ -> case Map.lookup name mounts of+ Just surface -> case stripSurfacePrefix surface.prefix req of+ Just relative -> surface.application relative respond+ Nothing -> respond (errorResponse (OpsHttpError status400 "invalid_path" "inconsistent encoded request path" Nothing))+ Nothing -> respond (errorResponse (OpsHttpError status404 "surface_not_mounted" ("no surface is mounted at /" <> name) (Just (object ["mounted" .= names]))))+ pure (composedCorsMiddleware config.allowedOrigins router)+ where+ add result surface = do+ mounts <- result+ let name = surfacePrefixText surface.prefix+ if Map.member name mounts then Left (DuplicatePrefix name) else Right (Map.insert name surface mounts)++-- | Strip exactly one raw segment, preserving every remaining byte and query.+-- Only the mount root's trailing slash is normalized. Inconsistent synthetic+-- WAI requests are rejected rather than routed to a different target.+stripSurfacePrefix :: SurfacePrefix -> Request -> Maybe Request+stripSurfacePrefix prefix req = case pathInfo req of+ name : rest+ | name == surfacePrefixText prefix,+ Bytes.isPrefixOf "/" (rawPathInfo req),+ decodePathSegments (rawPathInfo req) == pathInfo req ->+ let remainder = Bytes.dropWhile (/= 47) (Bytes.drop 1 (rawPathInfo req))+ raw = if Bytes.null remainder then "/" else remainder+ segments = if rest == [""] then [] else rest+ in Just req {pathInfo = segments, rawPathInfo = raw}+ _ -> Nothing++-- | Use the same exact-origin, duplicate-safe policy as standalone HTTP.+composedCorsMiddleware :: [Text] -> Middleware+composedCorsMiddleware = corsMiddleware
+ src/Keiro/Ops/Http/Config.hs view
@@ -0,0 +1,56 @@+module Keiro.Ops.Http.Config+ ( MutationPolicy (..),+ SchemaDriftPolicy (..),+ InspectionLimits (..),+ defaultInspectionLimits,+ OpsHttpConfig (..),+ defaultOpsHttpConfig,+ OpsHttpServerConfig (..),+ defaultOpsHttpServerConfig,+ )+where++import Data.Text (Text)++data MutationPolicy = MutationsDisabled | MutationsEnabled+ deriving stock (Eq, Show)++data SchemaDriftPolicy = RefuseOnDrift | AllowDrift+ deriving stock (Eq, Show)++-- | All limits must be positive. Deadlines cover verification, execution, and+-- encoding. A mutation timeout has an unknown commit outcome and is not retried.+data InspectionLimits = InspectionLimits+ { maxConcurrentReads :: !Int,+ readDeadlineSeconds :: !Int,+ mutationDeadlineSeconds :: !Int,+ deliveryDeadlineSeconds :: !Int,+ statementTimeoutMillis :: !Int,+ maxResponseBytes :: !Int,+ maxRequestBytes :: !Int+ }+ deriving stock (Eq, Show)++defaultInspectionLimits :: InspectionLimits+defaultInspectionLimits = InspectionLimits 4 5 30 30 2000 1048576 16384++data OpsHttpConfig = OpsHttpConfig+ { mutations :: !MutationPolicy,+ schemaDrift :: !SchemaDriftPolicy,+ allowedOrigins :: ![Text],+ inspectionLimits :: !InspectionLimits+ }+ deriving stock (Eq, Show)++defaultOpsHttpConfig :: OpsHttpConfig+defaultOpsHttpConfig = OpsHttpConfig MutationsDisabled RefuseOnDrift [] defaultInspectionLimits++data OpsHttpServerConfig = OpsHttpServerConfig+ { bindHost :: !Text,+ port :: !Int,+ requestTimeoutSeconds :: !Int+ }+ deriving stock (Eq, Show)++defaultOpsHttpServerConfig :: OpsHttpServerConfig+defaultOpsHttpServerConfig = OpsHttpServerConfig "127.0.0.1" 9092 300
+ src/Keiro/Ops/Http/Cors.hs view
@@ -0,0 +1,67 @@+module Keiro.Ops.Http.Cors (corsMiddleware, originAllowed) where++import Control.Monad (guard)+import Data.ByteString (ByteString)+import Data.ByteString.Char8 qualified as Bytes+import Data.Char (isAlphaNum, isAscii, toLower)+import Data.Text (Text)+import Data.Text.Encoding (encodeUtf8)+import Network.HTTP.Types+import Network.URI (URI (..), URIAuth (..), parseURI)+import Network.Wai+import Text.Read (readMaybe)++data Origin = Origin String String Int deriving stock (Eq)++-- A serialized HTTP origin contains only scheme, host and optional port.+-- No user information, path, query, fragment, opaque/null or origin list.+parseOrigin :: ByteString -> Maybe Origin+parseOrigin bytes = do+ uri <- parseURI (Bytes.unpack bytes)+ let scheme = map toLower uri.uriScheme+ defaultPort <- case scheme of "http:" -> Just 80; "https:" -> Just 443; _ -> Nothing+ authority <- uri.uriAuthority+ guard (null uri.uriPath && null uri.uriQuery && null uri.uriFragment && null authority.uriUserInfo)+ let host = map toLower authority.uriRegName+ guard (not (null host) && all (\c -> isAscii c && (isAlphaNum c || c `elem` (".-:[]" :: String))) host)+ port <- case authority.uriPort of+ "" -> Just defaultPort+ ':' : digits | not (null digits) && all (\c -> c >= '0' && c <= '9') digits -> readMaybe digits+ _ -> Nothing+ guard (port >= 0 && port <= 65535)+ pure (Origin scheme host port)++allowedCorsOrigin :: [Text] -> RequestHeaders -> Maybe ByteString+allowedCorsOrigin origins headers = do+ [raw] <- pure [value | (key, value) <- headers, key == "Origin"]+ origin <- parseOrigin raw+ guard (any ((== Just origin) . parseOrigin . encodeUtf8) origins)+ pure raw++-- HTTP mutations and WebSocket upgrades use exactly the same origin policy.+-- Forwarded headers are deliberately excluded: TLS proxies allow-list HTTPS.+originAllowed :: [Text] -> Bool -> RequestHeaders -> Bool+originAllowed origins secure headers = case [value | (key, value) <- headers, key == "Origin"] of+ [] -> True+ [raw] -> case parseOrigin raw of+ Nothing -> False+ Just origin ->+ any ((== Just origin) . parseOrigin . encodeUtf8) origins+ || case [host | (key, host) <- headers, key == hHost] of+ [host] -> parseOrigin ((if secure then "https://" else "http://") <> host) == Just origin+ _ -> False+ _ -> False++-- No credentials. Composition preserves already supplied surface CORS headers.+corsMiddleware :: [Text] -> Middleware+corsMiddleware [] = id+corsMiddleware origins = \app request respond ->+ case allowedCorsOrigin origins (requestHeaders request) of+ Just origin ->+ let add headers+ | any ((== "Access-Control-Allow-Origin") . fst) headers = headers+ | otherwise = ("Access-Control-Allow-Origin", origin) : ("Vary", "Origin") : headers+ in if requestMethod request == methodOptions && any ((== "Access-Control-Request-Method") . fst) (requestHeaders request)+ then respond (responseLBS status204 (add [("Access-Control-Allow-Methods", "GET, POST, OPTIONS"), ("Access-Control-Allow-Headers", maybe "Content-Type" id (lookup "Access-Control-Request-Headers" (requestHeaders request))), ("Access-Control-Max-Age", "600")]) "")+ else app request (respond . mapResponseHeaders add)+ Nothing -> app request respond
+ src/Keiro/Ops/Http/Feed.hs view
@@ -0,0 +1,235 @@+-- | Shared, bounded inspection drivers. A mounted idle handle owns no wake+-- cursor and performs no recurring reads. Close the handle before its store.+module Keiro.Ops.Http.Feed+ ( FeedConfig (..),+ defaultFeedConfig,+ Driver,+ DriverFailure (..),+ Subscriber,+ newDriver,+ stopDriver,+ subscribe,+ unsubscribe,+ nextFrame,+ driverSnapshot,+ FeedAcquireError (..),+ FeedHandle,+ FeedStats (..),+ feedStats,+ newFeedHandle,+ closeFeedHandle,+ withFeedHandle,+ acquireDriver,+ releaseDriver,+ handleHttpConfig,+ handleFeedConfig,+ handleBudget,+ handleClosed,+ withFeedConnection,+ FeedSchemaCheck (..),+ checkFeedSchema,+ )+where++import Control.Concurrent.Async (Async, asyncWithUnmask, uninterruptibleCancel, wait)+import Control.Concurrent.MVar+import Control.Concurrent.STM+import Control.Exception (bracket, finally, mask, onException, uninterruptibleMask_)+import Control.Monad (forever, when)+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Word (Word64)+import Hasql.Pool qualified as Pool+import Keiro.Migrations.SchemaCheck (verifyExpectedSchemaSession)+import Keiro.Ops.Http.Budget+import Keiro.Ops.Http.Config+import Keiro.Ops.Http.Feed.Driver+import Keiro.Ops.Http.Protocol qualified as Protocol+import Keiro.Ops.Http.Views+import Keiro.Wake+import Kiroku.Store.Connection (KirokuStore (..))+import Kiroku.Store.Effect (runStoreIO)++data FeedAcquireError = TooManyViews | InitialReadFailed !Text | FeedClosed | FeedBusy | FeedRetiring | InitialResultTooLarge+ deriving stock (Eq, Show)++data Entry = Entry {token :: !Int, driver :: !Driver, references :: !Int}++data Registry = Registry+ {entries :: !(Map.Map Text Entry), nextToken :: !Int, pump :: !(Maybe (Async ())), closing :: !(Maybe (Async ()))}++data FeedHandle = FeedHandle+ { handleHttpConfig :: !OpsHttpConfig,+ handleFeedConfig :: !FeedConfig,+ store :: !KirokuStore,+ handleBudget :: !InspectionBudget,+ registry :: !(MVar Registry),+ epoch :: !(TVar Word64),+ closed :: !(TVar Bool),+ connections :: !(TVar Int)+ }++data FeedStats = FeedStats {activeViews :: !Int, activeConnections :: !Int, wakePumpRunning :: !Bool}+ deriving stock (Eq, Show)++newFeedHandle :: OpsHttpConfig -> FeedConfig -> KirokuStore -> IO FeedHandle+newFeedHandle http config store = do+ let effective =+ config+ { maxItems = min 500 config.maxItems,+ maxFrameBytes = min config.maxFrameBytes http.inspectionLimits.maxResponseBytes,+ readTimeout = min config.readTimeout (fromIntegral http.inspectionLimits.readDeadlineSeconds)+ }+ validateFeedConfig effective+ budget <- newInspectionBudget http.inspectionLimits+ FeedHandle http effective store budget+ <$> newMVar (Registry Map.empty 0 Nothing Nothing)+ <*> newTVarIO 0+ <*> newTVarIO False+ <*> newTVarIO 0++withFeedHandle :: OpsHttpConfig -> FeedConfig -> KirokuStore -> (FeedHandle -> IO a) -> IO a+withFeedHandle http config store = bracket (newFeedHandle http config store) closeFeedHandle++handleClosed :: FeedHandle -> STM Bool+handleClosed handle = readTVar handle.closed++-- | Reserve upgrading sockets too, before any schema IO. All exits, including+-- failed accept/send and cancellation, return this connection slot.+withFeedConnection :: FeedHandle -> IO a -> IO (Maybe a)+withFeedConnection handle action = mask $ \restore -> do+ admitted <- atomically $ do+ closed <- handleClosed handle+ active <- readTVar handle.connections+ if closed || active >= handle.handleFeedConfig.maxConnections+ then pure False+ else writeTVar handle.connections (active + 1) >> pure True+ if not admitted+ then pure Nothing+ else+ Just <$> restore action `finally` atomically (modifyTVar' handle.connections (subtract 1))++data FeedSchemaCheck = FeedSchemaMatches | FeedSchemaDrift | FeedSchemaReadFailed+ deriving stock (Eq, Show)++-- Call inside the shared admission/deadline scope, both on upgrade and refresh.+checkFeedSchema :: FeedHandle -> IO FeedSchemaCheck+checkFeedSchema handle = do+ result <- Pool.use handle.store.pool verifyExpectedSchemaSession+ pure $ case result of+ Right (Right []) -> FeedSchemaMatches+ Right (Right _) -> FeedSchemaDrift+ _ -> FeedSchemaReadFailed++readView :: FeedHandle -> View -> IO (Either Text ReadResult)+readView handle view = do+ schema <- checkFeedSchema handle+ if schema == FeedSchemaReadFailed || (schema == FeedSchemaDrift && handle.handleHttpConfig.schemaDrift == RefuseOnDrift)+ then pure (Left "schema verification failed")+ else do+ result <- runStoreIO handle.store view.readItems+ pure (either (const (Left "inspection read failed")) Right result)++-- | One initialization task/result per canonical view. Entries retiring at zero+-- references keep their slot until cancellation has joined the driver.+acquireDriver :: FeedHandle -> View -> IO (Either FeedAcquireError Driver)+acquireDriver handle view = mask $ \restore -> do+ reserved <- modifyMVar handle.registry $ \state -> do+ closed <- readTVarIO handle.closed+ if closed+ then pure (state, Left FeedClosed)+ else case Map.lookup view.viewId state.entries of+ Just entry | entry.references == 0 -> pure (state, Left FeedRetiring)+ Just entry -> pure (state {entries = Map.insert view.viewId entry {references = entry.references + 1} state.entries}, Right entry.driver)+ Nothing | Map.size state.entries >= handle.handleFeedConfig.maxViews -> pure (state, Left TooManyViews)+ Nothing -> do+ pump <- case state.pump of+ Just pump -> pure pump+ Nothing -> startPump handle+ seen <- readTVarIO handle.epoch >>= newTVarIO+ driver <-+ startDriver+ view.feedName+ handle.handleFeedConfig+ handle.handleBudget+ handle.handleHttpConfig.inspectionLimits.readDeadlineSeconds+ (awaitEpoch handle seen)+ (readView handle view)+ `onException` (case state.pump of Nothing -> uninterruptibleCancel pump; Just _ -> pure ())+ let entry = Entry state.nextToken driver 1+ pure (state {entries = Map.insert view.viewId entry state.entries, nextToken = state.nextToken + 1, pump = Just pump}, Right driver)+ case reserved of+ Left problem -> pure (Left problem)+ Right driver -> do+ result <- restore (awaitDriver driver) `onException` releaseDriver handle view+ case result of+ Right () -> do+ closed <- atomically ((||) <$> handleClosed handle <*> driverClosed driver)+ if closed then releaseDriver handle view >> pure (Left FeedClosed) else pure (Right driver)+ Left (DriverFailure code message) -> do+ releaseDriver handle view+ pure+ ( Left+ ( case code of+ Protocol.InspectionBusy -> FeedBusy+ Protocol.InspectionResultTooLarge -> InitialResultTooLarge+ _ -> InitialReadFailed message+ )+ )++releaseDriver :: FeedHandle -> View -> IO ()+releaseDriver handle view = uninterruptibleMask_ $ do+ retiring <- modifyMVar handle.registry $ \state -> case Map.lookup view.viewId state.entries of+ Nothing -> pure (state, Nothing)+ Just entry | entry.references <= 0 -> pure (state, Nothing)+ Just entry | entry.references > 1 -> pure (state {entries = Map.insert view.viewId entry {references = entry.references - 1} state.entries}, Nothing)+ Just entry -> pure (state {entries = Map.insert view.viewId entry {references = 0} state.entries}, Just entry)+ case retiring of+ Nothing -> pure ()+ Just entry -> do+ stopDriver entry.driver+ modifyMVar_ handle.registry $ \state -> case Map.lookup view.viewId state.entries of+ Just current | current.token == entry.token -> do+ let remaining = Map.delete view.viewId state.entries+ if Map.null remaining+ then do+ -- Join the old cursor owner before admitting a replacement pump.+ mapM_ uninterruptibleCancel state.pump+ pure state {entries = remaining, pump = Nothing}+ else pure state {entries = remaining}+ _ -> pure state++startPump :: FeedHandle -> IO (Async ())+startPump handle = do+ signal <- wakeSignalFromStore handle.store+ asyncWithUnmask $ \unmask -> unmask $ forever $ do+ reason <- waitForWake signal 1000000+ when (reason == WokenByNotify) (atomically (modifyTVar' handle.epoch (+ 1)))++awaitEpoch :: FeedHandle -> TVar Word64 -> IO WakeReason+awaitEpoch handle seen = do+ timer <- registerDelay (ceiling (handle.handleFeedConfig.fallbackInterval * 1000000))+ atomically $+ (do current <- readTVar handle.epoch; previous <- readTVar seen; check (current /= previous); writeTVar seen current; pure WokenByNotify)+ `orElse` (readTVar timer >>= check >> pure WokenByTimeout)++-- An owned closing job finishes even if a caller waiting on close is cancelled.+-- Later close calls join the same job; it is never restarted or abandoned.+closeFeedHandle :: FeedHandle -> IO ()+closeFeedHandle handle = mask $ \restore -> do+ closing <- uninterruptibleMask_ $ modifyMVar handle.registry $ \state -> case state.closing of+ Just task -> pure (state, task)+ Nothing -> do+ atomically (writeTVar handle.closed True)+ task <- asyncWithUnmask $ \unmask -> unmask $ do+ mapM_ (stopDriver . (.driver)) (Map.elems state.entries)+ mapM_ uninterruptibleCancel state.pump+ atomically (readTVar handle.connections >>= check . (== 0))+ pure (state {entries = Map.empty, pump = Nothing, closing = Just task}, task)+ restore (wait closing)++feedStats :: FeedHandle -> IO FeedStats+feedStats handle = do+ state <- readMVar handle.registry+ active <- readTVarIO handle.connections+ pure (FeedStats (Map.size state.entries) active (maybe False (const True) state.pump))
+ src/Keiro/Ops/Http/Feed/Driver.hs view
@@ -0,0 +1,258 @@+-- | One read loop per canonical view. Only the registry creates production+-- drivers; the injected constructor keeps fallback/diff checks small.+module Keiro.Ops.Http.Feed.Driver+ ( FeedConfig (..),+ defaultFeedConfig,+ validateFeedConfig,+ Driver,+ Subscriber,+ DriverFailure (..),+ ReadResult,+ newDriver,+ startDriver,+ awaitDriver,+ stopDriver,+ subscribe,+ unsubscribe,+ nextFrame,+ subscribeOnQueue,+ unsubscribeSTM,+ driverClosed,+ driverSnapshot,+ enqueueSnapshot,+ )+where++import Control.Concurrent (threadDelay)+import Control.Concurrent.Async (Async, asyncWithUnmask, uninterruptibleCancel)+import Control.Concurrent.STM+import Control.Exception (Exception, SomeAsyncException, SomeException, finally, fromException, mask, onException, throwIO, try)+import Control.Monad (forM_, unless, void)+import Data.Aeson (Value)+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Time (NominalDiffTime, UTCTime)+import Data.Word (Word64)+import GHC.Clock (getMonotonicTimeNSec)+import GHC.Generics (Generic)+import Keiro.Ops.Http.Budget qualified as Budget+import Keiro.Ops.Http.Config (InspectionLimits (..), defaultInspectionLimits)+import Keiro.Ops.Http.Feed.Queue qualified as Queue+import Keiro.Ops.Http.Protocol qualified as P+import Keiro.Ops.Http.Views+import Keiro.Wake (WakeReason (..))+import System.Timeout (timeout)++data FeedConfig = FeedConfig+ { fallbackInterval :: !NominalDiffTime,+ minRefreshInterval :: !NominalDiffTime,+ maxViews :: !Int,+ maxFrameBytes :: !Int,+ maxQueuedBytes :: !Int,+ readTimeout :: !NominalDiffTime,+ queueCapacity :: !Int,+ idlePingSeconds :: !Int,+ maxConnections :: !Int,+ maxItems :: !Int+ }+ deriving stock (Eq, Show, Generic)++defaultFeedConfig :: FeedConfig+defaultFeedConfig = FeedConfig 1 1 32 1048576 2097152 5 256 30 256 500++validateFeedConfig :: FeedConfig -> IO ()+validateFeedConfig config = unless valid (ioError (userError "feed limits must be positive, representable, and hold control frames (256 bytes)"))+ where+ periods = [config.fallbackInterval, config.minRefreshInterval, config.readTimeout, fromIntegral config.idlePingSeconds]+ valid =+ all (> 0) [config.maxViews, config.queueCapacity, config.maxConnections, config.maxItems]+ && config.maxFrameBytes >= 256+ && config.maxQueuedBytes >= 256+ && all (\period -> period > 0 && ceiling (period * 1000000) <= toInteger (maxBound :: Int)) periods++type ReadResult = (UTCTime, [Item], Maybe Value)++data DriverFailure = DriverFailure !P.SocketErrorCode !Text+ deriving stock (Eq, Show)++instance Exception DriverFailure++data DriverState = DriverState+ { currentSequence :: !Word64,+ rows :: ![Item],+ extra :: !(Maybe Value),+ snapshot :: !(Maybe Queue.Outgoing),+ subscribers :: !(Map.Map Int Queue.OutboundQueue),+ nextId :: !Int,+ closed :: !Bool,+ lastError :: !(Maybe P.SocketErrorCode)+ }++data Driver = Driver+ { name :: !P.FeedName,+ config :: !FeedConfig,+ state :: !(TVar DriverState),+ ready :: !(TMVar (Either DriverFailure ())),+ worker :: !(Async ())+ }++data Subscriber = Subscriber !Int !Queue.OutboundQueue++-- | Production supplies the shared mount budget. The worker, including its+-- initial read, is owned before any caller waits; cancelled waiters cannot+-- strand an initialization task or lose another waiter's shared result.+startDriver :: P.FeedName -> FeedConfig -> Budget.InspectionBudget -> Int -> IO WakeReason -> IO (Either Text ReadResult) -> IO Driver+startDriver name config budget hostDeadline awaitChange readItems = mask $ \_ -> do+ validateFeedConfig config+ state <- newTVarIO (DriverState 0 [] Nothing Nothing Map.empty 0 False Nothing)+ ready <- newEmptyTMVarIO+ worker <- asyncWithUnmask $ \unmask ->+ unmask (run state ready)+ `finally` atomically+ ( do+ modifyTVar' state (\value -> value {closed = True, rows = [], extra = Nothing, snapshot = Nothing, subscribers = Map.empty})+ void (tryPutTMVar ready (Left (DriverFailure P.ReadFailed "feed driver closed")))+ )+ pure (Driver name config state ready worker)+ where+ maximumBytes = min config.maxFrameBytes config.maxQueuedBytes+ deadline = ceiling (min config.readTimeout (fromIntegral hostDeadline) * 1000000)+ cadence = if name == P.Timers then max 5 config.minRefreshInterval else config.minRefreshInterval+ run state ready = do+ firstStart <- getMonotonicTimeNSec+ initial <- bounded (refresh state P.Subscribed)+ atomically (void (tryPutTMVar ready (fmap (const ()) initial)))+ case initial of Left _ -> pure (); Right _ -> loop state firstStart+ loop state previousStart = do+ active <- atomically $ do+ value <- readTVar state+ check (value.closed || not (Map.null value.subscribers))+ pure (not value.closed)+ if not active+ then pure ()+ else do+ reason <- awaitChange+ now <- getMonotonicTimeNSec+ let elapsed = toInteger (now - previousStart) `div` 1000+ remaining = ceiling (cadence * 1000000) - elapsed+ if remaining > 0 then threadDelay (fromInteger remaining) else pure ()+ value <- readTVarIO state+ if value.closed+ then pure ()+ else+ if Map.null value.subscribers+ then loop state previousStart+ else do+ started <- getMonotonicTimeNSec+ result <- bounded (refresh state (case reason of WokenByNotify -> P.Notify; WokenByTimeout -> P.Timeout))+ case result of Left problem -> publishError state problem; Right _ -> pure ()+ loop state started+ bounded action = do+ admitted <- Budget.withInspectionAdmission budget (trySync (timeout deadline action))+ pure $ case admitted of+ Left _ -> Left (DriverFailure P.InspectionBusy "inspection read capacity is busy")+ Right (Left problem) -> Left problem+ Right (Right Nothing) -> Left (DriverFailure P.ReadFailed "inspection read deadline exceeded")+ Right (Right (Just value)) -> Right value+ refresh state trigger = do+ result <- readItems+ (stamp, items, extra) <- either (const (throwIO (DriverFailure P.ReadFailed "inspection read failed"))) pure result+ previous <- readTVarIO state+ let old = Map.fromList [(item.key, item) | item <- previous.rows]+ current = Map.fromList [(item.key, item) | item <- items]+ changed = [item.body | item <- items, maybe True ((/= stableItemBody name item) . stableItemBody name) (Map.lookup item.key old)]+ removed = [item.keyJson | item <- previous.rows, Map.notMember item.key current]+ different = not (null changed && null removed) || extra /= previous.extra+ sequence = previous.currentSequence + if trigger /= P.Subscribed && different then 1 else 0+ snapshot <- prepare (P.Snapshot name sequence P.Subscribed stamp (map (.body) items) extra)+ update <-+ if trigger /= P.Subscribed && different+ then Just <$> prepare (P.Update name sequence trigger stamp changed removed extra)+ else pure Nothing+ atomically $ do+ value <- readTVar state+ unless value.closed $ do+ writeTVar state value {currentSequence = sequence, rows = items, extra = extra, snapshot = Just snapshot, lastError = Nothing}+ forM_ update $ \frame -> forM_ (Map.elems value.subscribers) (\queue -> void (Queue.enqueueOutgoing queue frame))+ prepare frame =+ Queue.prepareOutgoing maximumBytes frame+ >>= maybe+ (throwIO (DriverFailure P.InspectionResultTooLarge "inspection frame exceeds the host byte limit"))+ pure+ publishError state (DriverFailure code message) = do+ frame <- Queue.prepareOutgoing maximumBytes (P.ErrorFrame code message (Just name))+ atomically $ do+ value <- readTVar state+ unless (value.closed || value.lastError == Just code) $ do+ writeTVar state value {lastError = Just code}+ forM_ frame $ \outgoing -> forM_ (Map.elems value.subscribers) (\queue -> void (Queue.enqueueOutgoing queue outgoing))++trySync :: IO a -> IO (Either DriverFailure a)+trySync action =+ try action >>= \case+ Right value -> pure (Right value)+ Left (err :: SomeException) -> case fromException err :: Maybe SomeAsyncException of+ Just _ -> throwIO err+ Nothing -> pure (Left (maybe (DriverFailure P.ReadFailed "inspection read failed") id (fromException err)))++newDriver :: P.FeedName -> FeedConfig -> IO WakeReason -> IO (Either Text ReadResult) -> IO Driver+newDriver name config awaitChange readItems = mask $ \restore -> do+ budget <- Budget.newInspectionBudget defaultInspectionLimits {maxConcurrentReads = 1}+ driver <- startDriver name config budget (max 1 (ceiling config.readTimeout)) awaitChange readItems+ outcome <- restore (awaitDriver driver) `onException` stopDriver driver+ case outcome of Left problem -> stopDriver driver >> throwIO problem; Right () -> pure driver++awaitDriver :: Driver -> IO (Either DriverFailure ())+awaitDriver driver = atomically (readTMVar driver.ready)++driverClosed :: Driver -> STM Bool+driverClosed driver = (.closed) <$> readTVar driver.state++stopDriver :: Driver -> IO ()+stopDriver driver = mask $ \_ -> do+ atomically $ modifyTVar' driver.state (\value -> value {closed = True, rows = [], extra = Nothing, snapshot = Nothing, subscribers = Map.empty})+ uninterruptibleCancel driver.worker++subscribe :: Driver -> IO Subscriber+subscribe driver = do+ queue <- Queue.newOutboundQueue driver.config.queueCapacity driver.config.maxQueuedBytes driver.config.maxFrameBytes+ atomically (subscribeOnQueue driver queue)++subscribeOnQueue :: Driver -> Queue.OutboundQueue -> STM Subscriber+subscribeOnQueue driver queue = do+ value <- readTVar driver.state+ if value.closed || Map.size value.subscribers >= driver.config.maxConnections+ then throwSTM (DriverFailure P.ReadFailed "driver is closed or subscriber capacity is full")+ else case value.snapshot of+ Nothing -> throwSTM (DriverFailure P.ReadFailed "driver initialization is incomplete")+ Just snapshot -> do+ let identifier = value.nextId+ writeTVar driver.state value {subscribers = Map.insert identifier queue value.subscribers, nextId = identifier + 1}+ void (Queue.enqueueOutgoing queue snapshot)+ pure (Subscriber identifier queue)++unsubscribeSTM :: Driver -> Subscriber -> STM ()+unsubscribeSTM driver (Subscriber identifier queue) = do+ modifyTVar' driver.state (\value -> value {subscribers = Map.delete identifier value.subscribers})+ Queue.purgeFeed queue driver.name++unsubscribe :: Driver -> Subscriber -> IO ()+unsubscribe driver subscriber = atomically (unsubscribeSTM driver subscriber)++nextFrame :: Subscriber -> STM P.ServerFrame+nextFrame (Subscriber _ queue) = Queue.nextFrame queue++-- | The last successful observation, used for resubscription without a read.+driverSnapshot :: Driver -> STM P.ServerFrame+driverSnapshot driver = do+ value <- readTVar driver.state+ case value.snapshot of+ Just outgoing | not value.closed -> pure outgoing.frame+ _ -> throwSTM (DriverFailure P.ReadFailed "driver has no current snapshot")++enqueueSnapshot :: Driver -> Queue.OutboundQueue -> STM ()+enqueueSnapshot driver queue = do+ value <- readTVar driver.state+ case value.snapshot of+ Just outgoing | not value.closed -> void (Queue.enqueueOutgoing queue outgoing)+ _ -> throwSTM (DriverFailure P.ReadFailed "driver has no current snapshot")
+ src/Keiro/Ops/Http/Feed/Queue.hs view
@@ -0,0 +1,158 @@+-- | One bounded outbound queue per socket, including the writer's in-flight+-- frame. Broadcast bytes are shared; overflow is signalled before later deltas.+module Keiro.Ops.Http.Feed.Queue+ ( OutboundQueue,+ Outgoing (..),+ newOutboundQueue,+ prepareOutgoing,+ enqueueOutgoing,+ nextOutgoing,+ finishOutgoing,+ nextFrame,+ purgeFeed,+ closeQueue,+ )+where++import Control.Concurrent.STM+import Data.Aeson (toJSON)+import Data.ByteString qualified as Bytes+import Data.ByteString.Lazy qualified as Lazy+import Data.Map.Strict qualified as Map+import Data.Sequence (Seq, ViewL (..), (|>))+import Data.Sequence qualified as Seq+import Data.Set qualified as Set+import Keiro.Ops.Http.Budget (encodeBounded)+import Keiro.Ops.Http.Protocol++-- | Keep the frame for typed consumers and its single shared wire encoding.+data Outgoing = Outgoing {frame :: !ServerFrame, bytes :: !Bytes.ByteString, owner :: !(Maybe FeedName)}++data QueueState = QueueState+ { pending :: !(Seq Outgoing),+ usedBytes :: !Int,+ usedFrames :: !Int,+ overflow :: !(Set.Set (Maybe FeedName)),+ closed :: !Bool+ }++data OutboundQueue = OutboundQueue !Int !Int !(Map.Map (Maybe FeedName) Outgoing) !(TVar QueueState)++newOutboundQueue :: Int -> Int -> Int -> IO OutboundQueue+newOutboundQueue capacity byteLimit frameLimit+ | capacity <= 0 || byteLimit < 256 || frameLimit < 256 = ioError (userError "feed queues must hold a control frame (256 bytes) and at least one frame")+ | otherwise = do+ errors <- mapM makeError [Nothing, Just Workflows, Just Timers, Just ProjectionGroups]+ OutboundQueue capacity byteLimit (Map.fromList errors) <$> newTVarIO (QueueState Seq.empty 0 0 Set.empty False)+ where+ makeError feed = do+ let value = ErrorFrame QueueOverflow "frames were dropped for this feed; re-subscribe or re-read" feed+ prepared <- prepareOutgoing (min frameLimit byteLimit) value+ maybe (ioError (userError "feed byte limits cannot hold an overflow frame")) (pure . (feed,)) prepared++prepareOutgoing :: Int -> ServerFrame -> IO (Maybe Outgoing)+prepareOutgoing limit value = do+ encoded <- encodeBounded limit (toJSON value)+ pure (fmap (\bytes -> Outgoing value (Lazy.toStrict bytes) (frameOwner value)) encoded)++frameOwner :: ServerFrame -> Maybe FeedName+frameOwner = \case+ Snapshot feed _ _ _ _ _ -> Just feed+ Update feed _ _ _ _ _ _ -> Just feed+ Unsubscribed feed -> Just feed+ ErrorFrame _ _ feed -> feed+ _ -> Nothing++size :: Outgoing -> Int+size value = Bytes.length value.bytes++-- Drop oldest queued frames until both limits fit. An in-flight frame cannot+-- be retracted; if it occupies the budget, discard the incoming frame instead.+roomFor :: Int -> Int -> Outgoing -> QueueState -> QueueState+roomFor capacity byteLimit incoming state+ | state.usedFrames < capacity && toInteger state.usedBytes + toInteger (size incoming) <= toInteger byteLimit = state+ | otherwise = case Seq.viewl state.pending of+ EmptyL -> state+ dropped :< rest ->+ roomFor+ capacity+ byteLimit+ incoming+ state+ { pending = rest,+ usedFrames = state.usedFrames - 1,+ usedBytes = state.usedBytes - size dropped,+ overflow = Set.insert dropped.owner state.overflow+ }++enqueueOutgoing :: OutboundQueue -> Outgoing -> STM Bool+enqueueOutgoing (OutboundQueue capacity byteLimit _ variable) value = do+ initial <- readTVar variable+ if initial.closed+ then pure False+ else do+ let state = roomFor capacity byteLimit value initial+ fits = state.usedFrames < capacity && toInteger state.usedBytes + toInteger (size value) <= toInteger byteLimit+ result =+ if fits+ then state {pending = state.pending |> value, usedFrames = state.usedFrames + 1, usedBytes = state.usedBytes + size value}+ else state {overflow = Set.insert value.owner state.overflow}+ writeTVar variable result+ pure fits++-- | The caller must finish this frame in a masked/bracketed writer before+-- dequeuing again. Its bytes/count stay charged while network delivery blocks.+nextOutgoing :: OutboundQueue -> STM (Maybe Outgoing)+nextOutgoing (OutboundQueue capacity byteLimit errors variable) = do+ state <- readTVar variable+ if state.closed+ then pure Nothing+ else case Set.lookupMin state.overflow of+ Just feed -> do+ let value = errors Map.! feed+ withRoom = roomFor capacity byteLimit value state+ check (withRoom.usedFrames < capacity && toInteger withRoom.usedBytes + toInteger (size value) <= toInteger byteLimit)+ writeTVar+ variable+ withRoom+ { overflow = Set.delete feed withRoom.overflow,+ usedFrames = withRoom.usedFrames + 1,+ usedBytes = withRoom.usedBytes + size value+ }+ pure (Just value)+ Nothing -> case Seq.viewl state.pending of+ EmptyL -> retry+ value :< rest -> do+ writeTVar variable state {pending = rest}+ pure (Just value)++finishOutgoing :: OutboundQueue -> Outgoing -> STM ()+finishOutgoing (OutboundQueue _ _ _ variable) value = modifyTVar' variable $ \state ->+ state {usedFrames = state.usedFrames - 1, usedBytes = state.usedBytes - size value}++-- | Typed in-process consumption releases the queue's reference immediately.+nextFrame :: OutboundQueue -> STM ServerFrame+nextFrame queue =+ nextOutgoing queue >>= \case+ Nothing -> pure Goodbye+ Just value -> finishOutgoing queue value >> pure value.frame++purgeFeed :: OutboundQueue -> FeedName -> STM ()+purgeFeed (OutboundQueue _ _ _ variable) feed = modifyTVar' variable $ \state ->+ let removed = Seq.filter ((== Just feed) . (.owner)) state.pending+ in state+ { pending = Seq.filter ((/= Just feed) . (.owner)) state.pending,+ usedFrames = state.usedFrames - Seq.length removed,+ usedBytes = state.usedBytes - sum (fmap size removed),+ overflow = Set.delete (Just feed) state.overflow+ }++closeQueue :: OutboundQueue -> STM ()+closeQueue (OutboundQueue _ _ _ variable) = modifyTVar' variable $ \state ->+ state+ { pending = Seq.empty,+ usedFrames = state.usedFrames - Seq.length state.pending,+ usedBytes = state.usedBytes - sum (fmap size state.pending),+ overflow = Set.empty,+ closed = True+ }
+ src/Keiro/Ops/Http/Pool.hs view
@@ -0,0 +1,40 @@+module Keiro.Ops.Http.Pool (withOpsInspectionPool) where++import Control.Exception (bracket)+import Data.Text qualified as Text+import Hasql.Connection.Settings qualified as Settings+import Hasql.Pool (Pool)+import Hasql.Pool qualified as Pool+import Hasql.Pool.Config qualified as PoolConfig+import Hasql.Session qualified as Session+import Keiro.Ops.Http.Budget (newInspectionBudget)+import Keiro.Ops.Http.Config+import Kiroku.Store.Connection (ConnectionSettings)+import Kiroku.Store.Connection qualified as Store++-- | Create an inspection-owned pool, preserving the host's connection string+-- and search path. Substitute this pool on the store passed to the HTTP/feed+-- surface. Every connection gets the same statement timeout, including after+-- cancellation/reconnection. No writer connection is modified.+withOpsInspectionPool :: InspectionLimits -> ConnectionSettings -> (Pool -> IO a) -> IO a+withOpsInspectionPool limits settings action = do+ _ <- newInspectionBudget limits+ bracket (Pool.acquire config) Pool.release action+ where+ quote name = "\"" <> Text.replace "\"" "\"\"" name <> "\""+ initScript =+ "SET search_path TO "+ <> Text.intercalate ", " (map quote (settings.schema : settings.extraSearchPath))+ <> ", pg_catalog; SET statement_timeout = '"+ <> Text.pack (show limits.statementTimeoutMillis)+ <> "ms'; SET idle_in_transaction_session_timeout = '"+ <> Text.pack (show settings.idleInTransactionTimeout)+ <> "s'"+ config =+ PoolConfig.settings+ ( [ PoolConfig.staticConnectionSettings (Settings.connectionString settings.connString),+ PoolConfig.size limits.maxConcurrentReads,+ PoolConfig.initSession (Session.script initScript)+ ]+ <> maybe [] (\observe -> [PoolConfig.observationHandler observe]) settings.observationHandler+ )
+ src/Keiro/Ops/Http/Protocol.hs view
@@ -0,0 +1,106 @@+-- | The inspection socket dialect. Frames describe observations; clients recover+-- missed deltas by re-subscribing or reading the paired HTTP page.+module Keiro.Ops.Http.Protocol+ ( FeedName (..),+ feedNameText,+ parseFeedName,+ ClientFrame (..),+ ServerFrame (..),+ Trigger (..),+ SocketErrorCode (..),+ socketErrorText,+ )+where++import Data.Aeson (FromJSON (..), ToJSON (..), Value (..), object, withObject, withText, (.:), (.=))+import Data.Aeson.KeyMap qualified as KeyMap+import Data.Text (Text)+import Data.Time (UTCTime)+import Data.Word (Word64)+import GHC.Generics (Generic)++-- | Exactly the three supported first-page views.+data FeedName = Workflows | Timers | ProjectionGroups deriving stock (Eq, Ord, Show, Generic)++feedNameText :: FeedName -> Text+feedNameText = \case Workflows -> "workflows"; Timers -> "timers"; ProjectionGroups -> "projection_groups"++parseFeedName :: Text -> Maybe FeedName+parseFeedName = \case "workflows" -> Just Workflows; "timers" -> Just Timers; "projection_groups" -> Just ProjectionGroups; _ -> Nothing++instance ToJSON FeedName where toJSON = String . feedNameText++instance FromJSON FeedName where parseJSON = withText "feed" (maybe (fail "unknown_feed") pure . parseFeedName)++-- Unknown names remain a typed subscription refusal, independently of malformed+-- JSON or an unknown frame type. The filter object is validated by the view.+data ClientFrame = Subscribe !FeedName !Value | UnknownSubscription !Text | Unsubscribe !FeedName | Ping+ deriving stock (Eq, Show, Generic)++instance FromJSON ClientFrame where+ parseJSON = withObject "client frame" $ \fields -> do+ kind <- fields .: "type"+ case (kind :: Text) of+ "subscribe" -> do+ name <- fields .: "feed"+ pure (maybe (UnknownSubscription name) (\feed -> Subscribe feed (Object (KeyMap.delete "type" (KeyMap.delete "feed" fields)))) (parseFeedName name))+ "unsubscribe" -> do+ name <- fields .: "feed"+ pure (maybe (UnknownSubscription name) Unsubscribe (parseFeedName name))+ "ping" -> pure Ping+ _ -> fail "unknown frame type"++instance ToJSON ClientFrame where+ toJSON = \case+ Subscribe feed filters -> object (["type" .= ("subscribe" :: Text), "feed" .= feed] <> filterFields filters)+ UnknownSubscription feed -> object ["type" .= ("subscribe" :: Text), "feed" .= feed]+ Unsubscribe feed -> object ["type" .= ("unsubscribe" :: Text), "feed" .= feed]+ Ping -> object ["type" .= ("ping" :: Text)]+ where+ filterFields (Object fields) = [(key, value) | (key, value) <- KeyMap.toList fields, key /= "type", key /= "feed"]; filterFields _ = []++data Trigger = Subscribed | Notify | Timeout deriving stock (Eq, Show, Generic)++instance ToJSON Trigger where toJSON = String . \case Subscribed -> "subscribe"; Notify -> "notify"; Timeout -> "timeout"++data SocketErrorCode = InvalidFrame | UnknownFeed | InvalidSubscription | QueueOverflow | ReadFailed | TooManyViews | InspectionBusy | ViewRetiring | InspectionResultTooLarge+ deriving stock (Eq, Show, Generic)++socketErrorText :: SocketErrorCode -> Text+socketErrorText = \case+ InvalidFrame -> "invalid_frame"+ UnknownFeed -> "unknown_feed"+ InvalidSubscription -> "invalid_subscription"+ QueueOverflow -> "queue_overflow"+ ReadFailed -> "read_failed"+ TooManyViews -> "too_many_views"+ InspectionBusy -> "inspection_busy"+ ViewRetiring -> "view_retiring"+ InspectionResultTooLarge -> "inspection_result_too_large"++instance ToJSON SocketErrorCode where toJSON = String . socketErrorText++data ServerFrame+ = Snapshot {feed :: !FeedName, sequence :: !Word64, trigger :: !Trigger, readAt :: !UTCTime, items :: ![Value], extra :: !(Maybe Value)}+ | Update {feed :: !FeedName, sequence :: !Word64, trigger :: !Trigger, readAt :: !UTCTime, changed :: ![Value], removed :: ![Value], extra :: !(Maybe Value)}+ | Unsubscribed !FeedName+ | Pong+ | ErrorFrame {code :: !SocketErrorCode, message :: !Text, errorFeed :: !(Maybe FeedName)}+ | Goodbye+ deriving stock (Eq, Show, Generic)++instance ToJSON ServerFrame where+ toJSON = \case+ Snapshot feed sequence trigger stamp items extra ->+ object+ (["type" .= ("snapshot" :: Text), "feed" .= feed, "sequence" .= sequence, "trigger" .= trigger, "observed_at" .= stamp, "items" .= items] <> extraFields extra)+ Update feed sequence trigger stamp changed removed extra ->+ object+ (["type" .= ("update" :: Text), "feed" .= feed, "sequence" .= sequence, "trigger" .= trigger, "observed_at" .= stamp, "changed" .= changed, "removed" .= removed] <> extraFields extra)+ Unsubscribed feed -> object ["type" .= ("unsubscribed" :: Text), "feed" .= feed]+ Pong -> object ["type" .= ("pong" :: Text)]+ ErrorFrame code message feed -> object (["type" .= ("error" :: Text), "code" .= code, "message" .= message] <> maybe [] (\value -> ["feed" .= value]) feed)+ Goodbye -> object ["type" .= ("goodbye" :: Text)]+ where+ extraFields (Just (Object fields)) = [(key, value) | (key, value) <- KeyMap.toList fields, key `notElem` ["type", "feed", "sequence", "trigger", "observed_at", "items", "changed", "removed"]]+ extraFields _ = []
+ src/Keiro/Ops/Http/Route.hs view
@@ -0,0 +1,94 @@+module Keiro.Ops.Http.Route+ ( RouteError (..),+ renderRouteError,+ flagNames,+ translateRequest,+ ParseOutcome (..),+ parseArguments,+ parseArgumentsWithParser,+ )+where++import Data.Char (isAsciiLower, isDigit)+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Set (Set)+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as Text+import Keiro.Ops (AppHooks, Command, commandParser)+import Options.Applicative qualified as Options+import Options.Applicative.Common (mapParser)+import Options.Applicative.Types qualified as Types++data RouteError+ = EmptyPathSegment+ | OptionLikePathSegment !Text+ | InvalidQueryKey !Text+ | InvalidFlagValue !Text !Text+ | ReservedQueryKey !Text+ deriving stock (Eq, Show)++renderRouteError :: RouteError -> Text+renderRouteError = Text.pack . show++data Grammar = Grammar !(Set Text) !(Map Text Grammar)++-- | Partially apply at startup: metadata is shared across requests. Sibling+-- commands may use the same long option with different arities.+flagNames :: Options.Parser a -> [Text] -> Set Text+flagNames parser = select Set.empty (grammar parser)+ where+ select inherited (Grammar flags commands) path =+ let current = inherited <> flags+ in case path of+ word : rest | Just child <- Map.lookup word commands -> select current child rest+ _ -> current++grammar :: Options.Parser a -> Grammar+grammar parser = foldr combine (Grammar Set.empty Map.empty) (mapParser optionGrammar parser)+ where+ combine (Grammar a b) (Grammar c d) = Grammar (a <> c) (b <> d)+ optionGrammar :: Types.ArgumentReachability -> Types.Option x -> Grammar+ optionGrammar _ option = case Types.optMain option of+ Types.FlagReader names _ -> Grammar (Set.fromList [Text.pack name | Types.OptLong name <- names]) Map.empty+ Types.CmdReader _ commands ->+ Grammar Set.empty (Map.fromList [(Text.pack name, grammar (Types.infoParser child)) | (name, child) <- commands])+ _ -> Grammar Set.empty Map.empty++translateRequest :: Set Text -> [Text] -> [(Text, Maybe Text)] -> Either RouteError [Text]+translateRequest flags path query = do+ traverse_ checkSegment path+ options <- concat <$> traverse translate query+ pure (path <> options)+ where+ traverse_ f = foldr ((>>) . f) (pure ())+ checkSegment segment+ | Text.null segment = Left EmptyPathSegment+ | "-" `Text.isPrefixOf` segment = Left (OptionLikePathSegment segment)+ | otherwise = Right ()+ translate (key, maybeValue)+ | key `elem` ["json", "force", "database_url", "allow_schema_drift", "help"] = Left (ReservedQueryKey key)+ | Text.null key || not (Text.all (\c -> isAsciiLower c || isDigit c || c == '_') key) = Left (InvalidQueryKey key)+ | Set.member name flags = case value of+ "true" -> Right ["--" <> name]+ "false" -> Right []+ _ -> Left (InvalidFlagValue key value)+ | otherwise = Right ["--" <> name <> "=" <> value]+ where+ name = Text.replace "_" "-" key+ value = maybe "" id maybeValue++data ParseOutcome = Parsed !Command | Rejected !Text++parseArguments :: AppHooks -> [Text] -> ParseOutcome+parseArguments hooks = parseArgumentsWithParser (commandParser hooks)++-- | Share the immutable command grammar across requests at one mount. This+-- does not cache command results or bypass any owning-library validation.+parseArgumentsWithParser :: Options.Parser Command -> [Text] -> ParseOutcome+parseArgumentsWithParser parser arguments =+ case Options.execParserPure Options.defaultPrefs (Options.info parser Options.fullDesc) (map Text.unpack arguments) of+ Options.Success command -> Parsed command+ Options.Failure failure -> Rejected (Text.pack (fst (Options.renderFailure failure "keiro-ops")))+ Options.CompletionInvoked _ -> Rejected "shell completion is not available over HTTP"
+ src/Keiro/Ops/Http/Server.hs view
@@ -0,0 +1,71 @@+module Keiro.Ops.Http.Server+ ( OpsHttpServer (..),+ startOpsHttpServer,+ stopOpsHttpServer,+ withOpsHttpServer,+ )+where++import Control.Concurrent.Async (Async, async, asyncThreadId, asyncWithUnmask, cancel, link)+import Control.Concurrent.MVar+import Control.Exception (bracket, bracketOnError, finally, mask, onException)+import Data.Map.Strict qualified as Map+import Data.Text qualified as Text+import Keiro.Ops.Http.Config+import Network.Socket qualified as Socket+import Network.Wai (Application)+import Network.Wai.Handler.Warp qualified as Warp++data OpsHttpServer = OpsHttpServer+ { serverThread :: !(Async ()),+ serverPort :: !Int+ }++startOpsHttpServer :: OpsHttpServerConfig -> Application -> IO OpsHttpServer+startOpsHttpServer config app+ | config.port < 0 || config.port > 65535 || config.requestTimeoutSeconds <= 0 = ioError (userError "invalid inspection server port or timeout")+ | otherwise = mask $ \restore -> do+ -- The runner owns the listener and every connection worker. Closing only+ -- the accept loop leaves keep-alive clients and their handlers alive.+ socket <- openSocket config+ actualPort <- fromIntegral <$> Socket.socketPort socket+ ready <- newEmptyMVar+ clients <- newMVar Map.empty+ let spawn :: ((forall a. IO a -> IO a) -> IO ()) -> IO ()+ spawn action = mask $ \_ -> do+ registered <- newEmptyMVar+ client <- asyncWithUnmask $ \unmask ->+ -- Warp must install its accepted-socket cleanup before waiting+ -- for registration. Its first unmask occurs inside that bracket.+ action (\io -> readMVar registered >> unmask io) `finally` do+ tid <- readMVar registered+ modifyMVar_ clients (\connections -> pure $! Map.delete tid connections)+ let tid = asyncThreadId client+ (modifyMVar_ clients (\connections -> pure $! Map.insert tid client connections) >> putMVar registered tid)+ `onException` (tryPutMVar registered tid >> cancel client)+ stopClients = do+ live <- modifyMVar clients (\connections -> pure (Map.empty, Map.elems connections))+ mapM_ cancel live+ settings = Warp.setFork spawn (Warp.setBeforeMainLoop (putMVar ready ()) (Warp.setTimeout config.requestTimeoutSeconds Warp.defaultSettings))+ worker <- async (restore (Warp.runSettingsSocket settings socket app) `finally` (Socket.close socket >> stopClients)) `onException` Socket.close socket+ (link worker >> restore (takeMVar ready)) `onException` (cancel worker >> Socket.close socket)+ pure (OpsHttpServer worker actualPort)++stopOpsHttpServer :: OpsHttpServer -> IO ()+stopOpsHttpServer server = cancel server.serverThread++withOpsHttpServer :: OpsHttpServerConfig -> Application -> (OpsHttpServer -> IO a) -> IO a+withOpsHttpServer config app = bracket (startOpsHttpServer config app) stopOpsHttpServer++openSocket :: OpsHttpServerConfig -> IO Socket.Socket+openSocket config = do+ let hints = Socket.defaultHints {Socket.addrSocketType = Socket.Stream, Socket.addrFlags = [Socket.AI_PASSIVE]}+ host = if config.bindHost == "*" then Nothing else Just (Text.unpack config.bindHost)+ addresses <- Socket.getAddrInfo (Just hints) host (Just (show config.port))+ case addresses of+ [] -> ioError (userError "inspection bind host has no addresses")+ address : _ -> bracketOnError (Socket.socket (Socket.addrFamily address) Socket.Stream Socket.defaultProtocol) Socket.close $ \socket -> do+ Socket.setSocketOption socket Socket.ReuseAddr 1+ Socket.bind socket (Socket.addrAddress address)+ Socket.listen socket Socket.maxListenQueue+ pure socket
+ src/Keiro/Ops/Http/Views.hs view
@@ -0,0 +1,147 @@+-- | Canonical first-page views over the same reads and encoders as the CLI.+module Keiro.Ops.Http.Views+ ( Item (..),+ View (..),+ workflowsView,+ timersView,+ projectionGroupsView,+ stableItemBody,+ )+where++import Control.Monad (unless)+import Data.Aeson (Value (..), encode, object, withObject, (.:?), (.=))+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KeyMap+import Data.Aeson.Types (Parser, parseEither)+import Data.ByteString.Lazy qualified as Lazy+import Data.List.NonEmpty qualified as NonEmpty+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.Time (UTCTime)+import Data.UUID qualified as UUID+import Effectful (Eff, IOE)+import Effectful.Error.Static (Error)+import Effectful.Exception (throwIO)+import Keiro.Ops.Http.Protocol (FeedName (..), feedNameText)+import Keiro.Ops.Projection (projectionGroupStatusJson)+import Keiro.Projection.Catalog (rebuildGroupIdText)+import Keiro.ReadModel (storeHeadPosition)+import Keiro.ReadModel.Rebuild+import Keiro.Timer+import Keiro.Workflow.Inspection qualified as Workflow+import Keiro.Workflow.Instance (WorkflowInstanceRow (..))+import Kiroku.Store.Effect (Store)+import Kiroku.Store.Error (StoreError)+import Kiroku.Store.Types (GlobalPosition (..))++-- | keyJson is the removal identity; body is the shared poll representation.+data Item = Item {key :: !Text, keyJson :: !Value, body :: !Value}+ deriving stock (Eq, Show)++data View = View+ { viewId :: !Text,+ feedName :: !FeedName,+ readItems :: Eff '[Store, Error StoreError, IOE] (UTCTime, [Item], Maybe Value)+ }++canonical :: FeedName -> Value -> Text+canonical feed filters = feedNameText feed <> ":" <> Text.decodeUtf8 (Lazy.toStrict (encode filters))++parseFilters :: [Text] -> (KeyMap.KeyMap Value -> Parser a) -> Value -> Either Text a+parseFilters known parser =+ either (Left . Text.pack) Right+ . parseEither+ ( withObject+ "subscription"+ ( \fields -> do+ case filter (`notElem` known) (map Key.toText (KeyMap.keys fields)) of+ field : _ -> fail ("unknown subscription field: " <> Text.unpack field)+ [] -> pure ()+ parser fields+ )+ )++pageLimit :: Int -> KeyMap.KeyMap Value -> Parser Int+pageLimit bound fields = do+ supplied <- fields .:? "limit"+ let maximumItems = min 500 bound+ count = maybe (min 100 maximumItems) id supplied+ unless (maximumItems > 0 && count > 0 && count <= maximumItems) (fail "limit must be between 1 and the host maximum (at most 500)")+ pure count++workflowsView :: Int -> Value -> Either Text View+workflowsView bound filters = do+ (statuses, name, limit) <- parseFilters ["status_class", "workflow_name", "limit"] parse filters+ let query = Workflow.defaultWorkflowListQuery {Workflow.classes = NonEmpty.nonEmpty statuses, Workflow.workflowName = name, Workflow.pageSize = limit}+ identity = object ["status_class" .= map Workflow.statusClassToText statuses, "workflow_name" .= name, "limit" .= limit]+ pure+ ( View (canonical Workflows identity) Workflows $ do+ page <- Workflow.listWorkflowInstancesPage query >>= require+ pure (page.observedAt, map workflowItem page.items, Nothing)+ )+ where+ parse fields = do+ raw <- fields .:? "status_class"+ statuses <- case raw of+ Nothing -> pure []+ Just names -> do+ unless (not (null (names :: [Text]))) (fail "status_class must not be empty")+ parsed <- traverse (maybe (fail "unknown status_class") pure . Workflow.statusClassFromText) names+ let selected = Set.fromList parsed+ pure (if selected == Set.fromList [minBound .. maxBound] then [] else Set.toAscList selected)+ (statuses,,) <$> fields .:? "workflow_name" <*> pageLimit bound fields++workflowItem :: WorkflowInstanceRow -> Item+workflowItem row =+ Item+ (row.workflowName <> "\NUL" <> row.workflowId)+ (object ["workflow_name" .= row.workflowName, "workflow_id" .= row.workflowId])+ (Workflow.instanceToJson row)++timersView :: Int -> Value -> Either Text View+timersView bound filters = do+ (manager, correlation, limit) <-+ parseFilters+ ["manager", "correlation_id", "limit"]+ (\fields -> (,,) <$> fields .:? "manager" <*> fields .:? "correlation_id" <*> pageLimit bound fields)+ filters+ let identity = object ["manager" .= manager, "correlation_id" .= correlation, "limit" .= limit]+ pure+ ( View (canonical Timers identity) Timers $ do+ page <- findPendingTimers (PendingTimerFilter manager correlation) (PendingTimerPageRequest limit Nothing) >>= require+ pure+ ( page.observedAt,+ [ let TimerId uuid = inspection.timer.timerId+ timerId = UUID.toText uuid+ in Item timerId (object ["timer_id" .= timerId]) (pendingTimerItemJson page.observedAt inspection)+ | inspection <- page.timers+ ],+ Nothing+ )+ )++projectionGroupsView :: Int -> Value -> Either Text View+projectionGroupsView bound filters = do+ limit <- parseFilters ["limit"] (pageLimit bound) filters+ pure+ ( View (canonical ProjectionGroups (object ["limit" .= limit])) ProjectionGroups $ do+ page <- listProjectionGroupStatusesPage (ProjectionStatusPageRequest limit Nothing) >>= require+ GlobalPosition visibleHead <- storeHeadPosition+ pure+ ( page.observedAt,+ [let groupId = rebuildGroupIdText row.groupId in Item groupId (object ["group_id" .= groupId]) (projectionGroupStatusJson row) | row <- page.items],+ Just (object ["visible_store_head" .= visibleHead])+ )+ )++require :: (Show err) => Either err value -> Eff '[Store, Error StoreError, IOE] value+require = either (throwIO . userError . show) pure++-- Database time changes the presentation countdown, not the timer's state.+-- Drivers retain each fresh body/read time even when this comparison is equal.+stableItemBody :: FeedName -> Item -> Value+stableItemBody Timers item = case item.body of Object fields -> Object (KeyMap.delete "due_in_seconds" fields); value -> value+stableItemBody _ item = item.body
+ src/Keiro/Ops/Http/WebSocket.hs view
@@ -0,0 +1,184 @@+-- | One socket writer and a multiplexed, bounded connection queue. Notifications+-- remain hints; all frames are produced by supported inspection reads.+module Keiro.Ops.Http.WebSocket (feedServerApp, feedsMiddleware, opsApplicationWithFeeds) where++import Control.Concurrent.Async (race_)+import Control.Concurrent.STM+import Control.Exception (bracket, catch, finally, mask, onException, throwIO)+import Control.Monad (forM_, void, when)+import Data.Aeson (object, (.=))+import Data.Aeson qualified as Aeson+import Data.ByteString qualified as Bytes+import Data.ByteString.Char8 qualified as Char8+import Data.ByteString.Lazy qualified as Lazy+import Data.IORef+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Text.Encoding qualified as Text+import Keiro.Ops (AppHooks)+import Keiro.Ops.Http.Application (opsApplicationWithBudget)+import Keiro.Ops.Http.Budget qualified as Budget+import Keiro.Ops.Http.Config+import Keiro.Ops.Http.Cors qualified as Cors+import Keiro.Ops.Http.Feed qualified as Feed+import Keiro.Ops.Http.Feed.Driver qualified as Driver+import Keiro.Ops.Http.Feed.Queue qualified as Queue+import Keiro.Ops.Http.Protocol qualified as P+import Keiro.Ops.Http.Views+import Kiroku.Store.Connection (KirokuStore)+import Network.Wai (Application, Middleware, pathInfo)+import Network.Wai.Handler.WebSockets qualified as WaiWS+import Network.WebSockets qualified as WS+import System.Timeout (timeout)++opsApplicationWithFeeds :: OpsHttpConfig -> Feed.FeedHandle -> AppHooks -> KirokuStore -> IO Application+opsApplicationWithFeeds config handle hooks store+ | config /= Feed.handleHttpConfig handle = ioError (userError "HTTP and feeds must share the same inspection policy and budget")+ | otherwise = feedsMiddleware config handle <$> opsApplicationWithBudget config (Feed.handleBudget handle) hooks store++feedsMiddleware :: OpsHttpConfig -> Feed.FeedHandle -> Middleware+feedsMiddleware config handle application request respond+ | pathInfo request == ["ws"] = WaiWS.websocketsOr options (feedServerApp config handle) application request respond+ | otherwise = application request respond+ where+ maximum = fromIntegral config.inspectionLimits.maxRequestBytes+ options = WS.defaultConnectionOptions {WS.connectionFramePayloadSizeLimit = WS.SizeLimit maximum, WS.connectionMessageDataSizeLimit = WS.SizeLimit maximum}++feedServerApp :: OpsHttpConfig -> Feed.FeedHandle -> WS.ServerApp+feedServerApp config handle pending+ | config /= Feed.handleHttpConfig handle = reject 500 "invalid_feed_policy" "HTTP and feeds have different host policies"+ | Char8.takeWhile (/= '?') request.requestPath /= "/ws" = reject 404 "not_found" "no inspection socket at this path"+ | Bytes.length request.requestPath > config.inspectionLimits.maxRequestBytes = reject 413 "inspection_request_too_large" "socket path exceeds the host limit"+ | not (originAllowed config request) = reject 403 "origin_not_allowed" "socket origin is not allowed"+ | otherwise = do+ admitted <- Feed.withFeedConnection handle $ do+ checked <- Budget.withInspectionBudget (Feed.handleBudget handle) config.inspectionLimits.readDeadlineSeconds (Feed.checkFeedSchema handle)+ case checked of+ Left Budget.InspectionBusy -> reject 503 "inspection_busy" "inspection read capacity is busy"+ Left Budget.InspectionTimedOut -> reject 504 "inspection_timeout" "schema verification exceeded the read deadline"+ Right Feed.FeedSchemaReadFailed -> reject 500 "schema_check_failed" "schema verification failed"+ Right Feed.FeedSchemaDrift | config.schemaDrift == RefuseOnDrift -> reject 409 "schema_drift" "database schema does not match this binary"+ Right _ -> do+ closed <- atomically (Feed.handleClosed handle)+ if closed+ then reject 503 "feed_closed" "inspection feeds are closed"+ else WS.acceptRequest pending >>= runConnection handle+ case admitted of+ Just () -> pure ()+ Nothing -> reject 503 "too_many_connections" "socket connection capacity is full"+ where+ request = WS.pendingRequest pending+ reject code reason message =+ WS.rejectRequestWith+ pending+ WS.defaultRejectRequest+ { WS.rejectCode = code,+ WS.rejectMessage = "Inspection request refused",+ WS.rejectHeaders = [("Content-Type", "application/json")],+ WS.rejectBody = Lazy.toStrict (Aeson.encode (object ["error" .= object ["code" .= (reason :: Text), "message" .= (message :: Text)]]))+ }++originAllowed :: OpsHttpConfig -> WS.RequestHead -> Bool+originAllowed config request =+ all (\(key, value) -> key /= "Origin" || Bytes.length value <= config.inspectionLimits.maxRequestBytes) request.requestHeaders+ && Cors.originAllowed config.allowedOrigins request.requestSecure request.requestHeaders++type Subscription = (View, Feed.Driver, Feed.Subscriber)++runConnection :: Feed.FeedHandle -> WS.Connection -> IO ()+runConnection handle connection = do+ let config = Feed.handleFeedConfig handle+ queue <- Queue.newOutboundQueue config.queueCapacity config.maxQueuedBytes config.maxFrameBytes+ subscriptions <- newIORef Map.empty+ let cleanup = do+ atomically (Queue.closeQueue queue)+ current <- atomicModifyIORef' subscriptions (\values -> (Map.empty, values))+ forM_ (Map.elems current) $ \(view, driver, subscriber) -> do+ Feed.unsubscribe driver subscriber+ Feed.releaseDriver handle view+ sendControl frame = do+ prepared <- Queue.prepareOutgoing (min config.maxFrameBytes config.maxQueuedBytes) frame+ case prepared of+ Nothing -> throwIO (userError "socket control frame exceeds host limits")+ Just outgoing -> void (atomically (Queue.enqueueOutgoing queue outgoing))+ failure code message feed = sendControl (P.ErrorFrame code message feed)+ receive = do+ bytes <- WS.receiveData connection :: IO Lazy.ByteString+ case Aeson.eitherDecode bytes of+ Left _ -> failure P.InvalidFrame "invalid socket frame" Nothing+ Right P.Ping -> sendControl P.Pong+ Right (P.UnknownSubscription _) -> failure P.UnknownFeed "unknown feed" Nothing+ Right (P.Unsubscribe name) -> mask $ \_ -> do+ current <- readIORef subscriptions+ forM_ (Map.lookup name current) $ \(view, driver, subscriber) -> do+ atomically (Driver.unsubscribeSTM driver subscriber)+ writeIORef subscriptions (Map.delete name current)+ Feed.releaseDriver handle view+ sendControl (P.Unsubscribed name)+ Right (P.Subscribe name filters) -> case makeView name filters of+ Left message -> failure P.InvalidSubscription message (Just name)+ Right view -> replaceSubscription handle queue subscriptions name view failure+ receive+ send = do+ again <-+ bracket+ ( timeout+ (config.idlePingSeconds * 1000000)+ ( atomically $ do+ closed <- Feed.handleClosed handle+ if closed then Queue.closeQueue queue >> pure Nothing else Queue.nextOutgoing queue+ )+ )+ (\case Just (Just outgoing) -> atomically (Queue.finishOutgoing queue outgoing); _ -> pure ())+ ( \case+ Nothing -> boundedSend (WS.sendPing connection ("" :: Bytes.ByteString)) >> pure True+ Just Nothing -> do+ boundedSend (WS.sendTextData connection (Aeson.encode P.Goodbye))+ boundedSend (WS.sendClose connection ("inspection feeds closed" :: Text))+ pure False+ Just (Just outgoing) -> boundedSend (WS.sendTextData connection outgoing.bytes) >> pure True+ )+ when again send+ boundedSend action =+ timeout (ceiling (config.readTimeout * 1000000)) action+ >>= maybe+ (throwIO (userError "socket send deadline exceeded"))+ pure+ makeView = \case P.Workflows -> workflowsView config.maxItems; P.Timers -> timersView config.maxItems; P.ProjectionGroups -> projectionGroupsView config.maxItems+ (race_ receive send `catch` (\(_ :: WS.ConnectionException) -> pure ())) `finally` cleanup++-- Acquiring a replacement does not disturb the old subscription on refusal.+-- The queue purge, detach and attach commit atomically so old-view deltas cannot+-- arrive after the new snapshot. Balanced registry releases cannot be cancelled.+replaceSubscription :: Feed.FeedHandle -> Queue.OutboundQueue -> IORef (Map.Map P.FeedName Subscription) -> P.FeedName -> View -> (P.SocketErrorCode -> Text -> Maybe P.FeedName -> IO ()) -> IO ()+replaceSubscription handle queue subscriptions name view failure = mask $ \restore -> do+ current <- readIORef subscriptions+ case Map.lookup name current of+ Just (old, driver, _) | old.viewId == view.viewId -> atomically $ do+ Queue.purgeFeed queue name+ Driver.enqueueSnapshot driver queue+ previous -> do+ acquired <- Feed.acquireDriver handle view+ case acquired of+ Left problem ->+ restore+ ( failure+ ( case problem of+ Feed.TooManyViews -> P.TooManyViews+ Feed.FeedBusy -> P.InspectionBusy+ Feed.FeedRetiring -> P.ViewRetiring+ Feed.InitialResultTooLarge -> P.InspectionResultTooLarge+ _ -> P.ReadFailed+ )+ "subscription could not be initialized"+ (Just name)+ )+ Right driver -> do+ subscriber <-+ ( atomically $ do+ forM_ previous (\(_, old, subscriber) -> Driver.unsubscribeSTM old subscriber)+ Driver.subscribeOnQueue driver queue+ )+ `onException` Feed.releaseDriver handle view+ writeIORef subscriptions (Map.insert name (view, driver, subscriber) current)+ forM_ previous (\(old, _, _) -> Feed.releaseDriver handle old)
+ test/Main.hs view
@@ -0,0 +1,802 @@+module Main (main) where++import Control.Concurrent (threadDelay)+import Control.Concurrent.Async+import Control.Concurrent.Chan (newChan, readChan, writeChan)+import Control.Concurrent.MVar+import Control.Exception (IOException, SomeException, bracket, finally, try)+import Control.Monad (forM_, replicateM)+import Data.Aeson (Value (..), object, (.=))+import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KeyMap+import Data.ByteString.Char8 qualified as Char8+import Data.ByteString.Lazy qualified as LazyByteString+import Data.IORef+import Data.Map.Strict qualified as Map+import Data.Maybe (isJust)+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.Time (addUTCTime, getCurrentTime)+import Data.UUID qualified as UUID+import Hasql.Connection qualified as Connection+import Hasql.Connection.Settings qualified as Settings+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Pool qualified as Pool+import Hasql.Pool.Observation qualified as Observation+import Hasql.Session qualified as Session+import Hasql.Statement qualified as Statement+import Jitsurei.Inspection (jitsureiProcessManagerInspectors)+import Keiro.Ops qualified as Ops+import Keiro.Ops.Env+import Keiro.Ops.Http+import Keiro.Ops.Http.Application+import Keiro.Ops.Http.Budget+import Keiro.Ops.Http.Config qualified+import Keiro.Ops.Http.Config qualified as Config+import Keiro.Ops.Http.Cors qualified as Cors+import Keiro.Ops.Http.Protocol qualified as Protocol+import Keiro.Ops.Http.Route+import Keiro.Ops.Http.Views qualified as Views+import Keiro.Ops.Render+import Keiro.Ops.ReplayAudit qualified as Audit+import Keiro.Ops.Workflow qualified as Workflow+import Keiro.Ops.WorkflowHeaderCache+import Keiro.PGMQ+import Keiro.Projection.Catalog qualified as Catalog+import Keiro.Projection.Catalog.Operations qualified as CatalogOperations+import Keiro.Test.Postgres+import Keiro.Timer qualified as Timer+import Keiro.Workflow (WorkflowId (..), WorkflowJournalEvent (..), WorkflowName (..), appendJournalEntry, continueAsNew, runWorkflow)+import Keiro.Workflow.Resume (defaultWorkflowResumeOptions)+import Kiroku.Store.Append (appendToStream)+import Kiroku.Store.Connection+import Kiroku.Store.Effect (runStoreIO)+import Kiroku.Store.Read (getStream)+import Kiroku.Store.Transaction (runTransaction)+import Kiroku.Store.Types hiding (id)+import Network.HTTP.Client qualified as Client+import Network.HTTP.Types+import Network.Socket qualified as Socket+import Network.Socket.ByteString qualified as Socket+import Network.Wai+import Network.Wai.Test hiding (request)+import Options.Applicative qualified as Options+import Options.Applicative.Common (mapParser)+import Options.Applicative.Types qualified as Types+import Pgmq.Migration qualified as Pgmq+import System.Exit (ExitCode (..))+import System.Process (readProcessWithExitCode)+import System.Timeout (timeout)+import Test.ComposeSpec qualified as ComposeSpec+import Test.FeedSpec qualified as FeedSpec+import Test.Hspec+import Test.WebSocketSpec qualified as WebSocketSpec++main :: IO ()+main = do+ pgmq <- either (fail . show) pure Pgmq.pgmqMigrations+ withMigratedSuiteWith [pgmq] $ \fixture -> hspec (spec fixture)++spec :: Fixture -> Spec+spec fixture = do+ ComposeSpec.spec fixture+ FeedSpec.spec fixture+ WebSocketSpec.spec fixture+ describe "live view parameters" $ do+ it "canonicalizes equivalent status sets and bounds subscription inputs" $ do+ Right one <- pure (Views.workflowsView 500 (object ["status_class" .= (["running", "sleeping", "running"] :: [Text])]))+ Right two <- pure (Views.workflowsView 500 (object ["status_class" .= (["sleeping", "running"] :: [Text])]))+ one.viewId `shouldBe` two.viewId+ either (const True) (const False) (Views.timersView 20 (object ["limit" .= (21 :: Int)])) `shouldBe` True+ case Views.timersView 20 (object ["unexpected_filter" .= True]) of+ Left message -> message `shouldSatisfy` Text.isInfixOf "unexpected_filter"+ Right _ -> expectationFailure "unknown filter was accepted"+ it "compares timer state independently of presentation countdowns" $ do+ let one = Views.Item "timer" (object ["timer_id" .= ("timer" :: Text)]) (object ["status" .= ("scheduled" :: Text), "due_in_seconds" .= (10 :: Int)])+ two = one {Views.body = object ["status" .= ("scheduled" :: Text), "due_in_seconds" .= (9 :: Int)]}+ Views.stableItemBody Protocol.Timers one `shouldBe` Views.stableItemBody Protocol.Timers two+ one.body `shouldNotBe` two.body++ describe "request translation" $ do+ it "preserves path and repeated option order, converting snake_case" $ do+ translateRequest Set.empty ["wf", "show", "approval", "wf-1"] [] `shouldBe` Right ["wf", "show", "approval", "wf-1"]+ translateRequest Set.empty ["wf", "list"] [("status", Just "running"), ("status", Just "failed"), ("min_age", Just "5m")]+ `shouldBe` Right ["wf", "list", "--status=running", "--status=failed", "--min-age=5m"]+ it "translates flags using only the selected command grammar" $ do+ let parser =+ Options.hsubparser+ ( Options.command "flag" (Options.info (Options.switch (Options.long "shared")) mempty)+ <> Options.command "option" (Options.info ((== ("yes" :: String)) <$> Options.strOption (Options.long "shared")) mempty)+ )+ flags = flagNames parser+ flags ["flag"] `shouldBe` Set.singleton "shared"+ flags ["option"] `shouldBe` Set.empty+ translateRequest (flags ["flag"]) ["flag"] [("shared", Just "true")] `shouldBe` Right ["flag", "--shared"]+ translateRequest (flags ["flag"]) ["flag"] [("shared", Just "false")] `shouldBe` Right ["flag"]+ translateRequest (flags ["option"]) ["option"] [("shared", Just "yes")] `shouldBe` Right ["option", "--shared=yes"]+ translateRequest (flags ["flag"]) ["flag"] [("shared", Just "yes")] `shouldBe` Left (InvalidFlagValue "shared" "yes")+ it "rejects global options, malformed keys, empty and option-like segments" $ do+ forM_ ["json", "force", "database_url", "allow_schema_drift", "help"] $ \key ->+ translateRequest Set.empty ["wf"] [(key, Nothing)] `shouldBe` Left (ReservedQueryKey key)+ forM_ ["Bad", "with-hyphen", "", "x y"] $ \key ->+ translateRequest Set.empty ["wf"] [(key, Nothing)] `shouldBe` Left (InvalidQueryKey key)+ translateRequest Set.empty [""] [] `shouldBe` Left EmptyPathSegment+ translateRequest Set.empty ["--force"] [] `shouldBe` Left (OptionLikePathSegment "--force")+ it "uses the real parser including the legacy two-value after option" $ do+ let parsed path query = either (const False) parses (translateRequest (flagNames (Ops.commandParser Ops.emptyAppHooks) path) path query)+ parses args = case parseArguments Ops.emptyAppHooks args of Parsed _ -> True; Rejected _ -> False+ parsed ["wf", "list", "instance-1"] [("after", Just "approval")] `shouldBe` True+ parsed ["wf", "list"] [("limit", Just "abc")] `shouldBe` False+ case parseArguments Ops.emptyAppHooks ["wf", "list"] of+ Parsed command -> Ops.isMutation command `shouldBe` False+ _ -> expectationFailure "read did not parse"+ case parseArguments Ops.emptyAppHooks ["outbox", "maintenance-pass"] of+ Parsed command -> Ops.isMutation command `shouldBe` True+ _ -> expectationFailure "mutation did not parse"+ describe "budget" $ do+ it "rejects excess work immediately and releases a cancelled permit after cleanup" $ do+ budget <- newInspectionBudget defaultInspectionLimits+ entered <- newChanCounter+ gate <- newEmptyMVar+ let blocked = withInspectionBudget budget 10 (signalCounter entered >> takeMVar gate)+ bracket (replicateM 4 (async blocked)) (mapM_ cancel) $ \jobs -> do+ awaitCounter entered 4+ withInspectionBudget budget 1 (pure ()) `shouldReturn` Left InspectionBusy+ case jobs of first : _ -> cancel first; [] -> expectationFailure "missing jobs"+ withInspectionBudget budget 1 (pure ()) `shouldReturn` Right ()+ it "joins timed-out work and keeps the permit reusable" $ do+ budget <- newInspectionBudget defaultInspectionLimits+ cleaned <- newEmptyMVar+ withInspectionBudget budget 1 (threadDelay 3000000 `finally` putMVar cleaned ()) `shouldReturn` Left InspectionTimedOut+ tryTakeMVar cleaned `shouldReturn` Just ()+ withInspectionBudget budget 1 (pure ()) `shouldReturn` Right ()+ it "bounds encoding and rejects invalid host limits" $ do+ encodeBounded 10 (String (Text.replicate 10000 "x")) `shouldReturn` Nothing+ encodeBounded 4 (String "hi") `shouldReturn` Just "\"hi\""+ encodeBoundedEncoding 4 (Aeson.toEncoding ("hi" :: Text)) `shouldReturn` Just "\"hi\""+ encodeBoundedEncoding 10 (Aeson.toEncoding (replicate 100000 ("x" :: Text))) `shouldReturn` Nothing+ newInspectionBudget defaultInspectionLimits {maxConcurrentReads = 0} `shouldThrow` anyIOException+ forM_ [0, -1, maxBound] $ \seconds ->+ newInspectionBudget defaultInspectionLimits {deliveryDeadlineSeconds = seconds} `shouldThrow` anyIOException+ around (withFreshStore fixture) $ do+ describe "mounted process-manager discovery" $ do+ it "serves Jitsurei metadata without journal instances and agrees with the parsed CLI" $ \store -> do+ let mounted = Ops.emptyAppHooks {Ops.processManagers = Just jitsureiProcessManagerInspectors}+ expected = object ["items" .= [object ["name" .= ("jitsurei-fulfillment" :: Text), "category" .= ("fulfillment" :: Text), "snapshots_configured" .= False, "snapshot_policy" .= ("never" :: Text), "timers_configured" .= Null]]]+ app <- opsApplication defaultOpsHttpConfig mounted store+ response <- call app methodGet "/pm/types" ""+ simpleStatus response `shouldBe` status200+ body response `shouldBe` expected+ case parseArguments mounted ["pm", "types"] of+ Parsed command -> do+ Succeeded result <- Ops.runCommand mounted (OpsEnv store Json False [] False) command+ result.jsonValue `shouldBe` body response+ _ -> expectationFailure "CLI pm types did not parse"+ refused <- call app methodPost "/pm/types" ""+ assertError refused status405 "method_not_allowed"+ named <- call app methodGet "/pm/types?name=jitsurei-fulfillment" ""+ assertError named status400 "invalid_request"+ it "distinguishes absent and empty hooks and bounds large catalogs" $ \store -> do+ absent <- opsApplication defaultOpsHttpConfig Ops.emptyAppHooks store+ forM_ ["/pm/types", "/pm/list?name=missing"] $ \path -> do+ response <- call absent methodGet path ""+ assertError response status400 "invalid_request"+ empty <- opsApplication defaultOpsHttpConfig Ops.emptyAppHooks {Ops.processManagers = Just Map.empty} store+ response <- call empty methodGet "/pm/types" ""+ simpleStatus response `shouldBe` status200+ body response `shouldBe` object ["items" .= ([] :: [Value])]+ let descriptor = snd (Map.findMin jitsureiProcessManagerInspectors)+ catalog = Map.fromList [("alias-" <> Text.pack (show n), descriptor) | n <- [1 .. 1000 :: Int]]+ mounted = Ops.emptyAppHooks {Ops.processManagers = Just catalog}+ config = defaultOpsHttpConfig {inspectionLimits = defaultInspectionLimits {maxResponseBytes = 1024}}+ bounded <- opsApplication config mounted store+ oversized <- call bounded methodGet "/pm/types" ""+ assertError oversized status413 "inspection_result_too_large"+ it "keeps fresh schema refusal for metadata reads" $ \store -> do+ app <- opsApplication defaultOpsHttpConfig Ops.emptyAppHooks {Ops.processManagers = Just jitsureiProcessManagerInspectors} store+ Right () <- Pool.use store.pool (Session.script "ALTER TABLE keiro.keiro_timers ADD COLUMN pm_types_drift text")+ response <- call app methodGet "/pm/types" ""+ assertError response status409 "schema_drift"+ describe "workflow page routes" $ do+ it "rejects an unrepresentable outgoing cursor instead of returning an unusable token" $ \store -> do+ result <- Pool.use store.pool (Session.script "INSERT INTO keiro.keiro_workflows(workflow_id,workflow_name,status) VALUES ('a',repeat('界',2000),'running'),('b',repeat('界',2000),'running')")+ either (fail . show) pure result+ app <- opsApplication defaultOpsHttpConfig hooks store+ response <- call app methodGet "/wf/inspect/list?limit=1" ""+ assertError response status413 "inspection_result_too_large"+ it "returns wide workflow keys within the continuation and response caps" $ \store -> do+ result <- Pool.use store.pool (Session.script "INSERT INTO keiro.keiro_workflows(workflow_id,workflow_name,status) SELECT 'wide-'||n,repeat('x',2700),'running' FROM generate_series(1,65) n")+ either (fail . show) pure result+ app <- opsApplication defaultOpsHttpConfig hooks store+ response <- call app methodGet "/wf/inspect/list?limit=1" ""+ simpleStatus response `shouldBe` status200+ field "next_cursor" (body response) `shouldSatisfy` isJust+ it "pages supported reads through the same parser and maps typed failures" $ \store -> do+ seedInspection store+ now <- getCurrentTime+ forM_ ["wf-2", "wf-3"] $ \wid ->+ runStoreIO store (appendJournalEntry (WorkflowName "approval") (WorkflowId wid) (StepRecorded "received" Null now)) >>= either (fail . show) pure+ app <- opsApplication defaultOpsHttpConfig hooks store+ let pages cursor = do+ response <- call app methodGet ("/wf/inspect/list?limit=1" <> maybe "" ("&after=" <>) cursor) ""+ simpleStatus response `shouldBe` status200+ field "observed_at" (body response) `shouldSatisfy` maybe False (\case String _ -> True; _ -> False)+ let items = case field "items" (body response) of Just (Array rows) -> foldr (:) [] rows; _ -> []+ length items `shouldBe` 1+ rest <- case field "next_cursor" (body response) of Just (String token) -> pages (Just token); _ -> pure []+ pure (items <> rest)+ rows <- pages Nothing+ map (field "workflow_id") rows `shouldBe` map (Just . String) ["wf-1", "wf-2", "wf-3"]+ header <- call app methodGet "/wf/inspect/header/approval/wf-1" ""+ legacy <- call app methodGet "/wf/show/approval/wf-1" ""+ field "instance" (body legacy) `shouldBe` Just (body header)+ forM_ ["header", "journal", "steps", "children", "awakeables"] $ \command -> do+ response <- call app methodGet ("/wf/inspect/" <> command <> "/approval/wf-1") ""+ simpleStatus response `shouldBe` status200+ malformed <- call app methodGet "/wf/inspect/list?after=not-a-cursor" ""+ assertError malformed status400 "invalid_cursor"+ oversized <- call app methodGet "/wf/inspect/list?limit=501" ""+ assertError oversized status400 "invalid_page_size"+ runStoreIO store (appendJournalEntry (WorkflowName "approval") (WorkflowId "wf-1") (StepRecorded "second" Null now)) >>= either (fail . show) pure+ first <- call app methodGet "/wf/inspect/steps/approval/wf-1?limit=1" ""+ token <- case field "next_cursor" (body first) of Just (String value) -> pure value; _ -> fail "missing step cursor"+ _ <- runStoreIO store (runWorkflow (WorkflowName "approval") (WorkflowId "wf-1") (continueAsNew () >> pure ())) >>= either (fail . show) pure+ rotated <- call app methodGet ("/wf/inspect/steps/approval/wf-1?after=" <> token) ""+ assertError rotated status409 "cursor_generation_mismatch"+ freshHeader <- call app methodGet "/wf/inspect/header/approval/wf-1" ""+ field "generation" (body freshHeader) `shouldBe` Just (Number 1)+ describe "fresh header presentation reuse" $ do+ it "preserves canonical bytes and observes edits, deletion and recreation" $ \store -> do+ seedInspection store+ app <- opsApplication defaultOpsHttpConfig hooks store+ let readHeader = call app methodGet "/wf/inspect/header/approval/wf-1" ""+ sql statement = Pool.use store.pool (Session.script statement) >>= either (fail . show) pure+ first <- readHeader+ second <- readHeader+ shouldBe (simpleBody second) (simpleBody first)+ sql "UPDATE keiro.keiro_workflows SET attempts=7,last_error='changed',leased_by='new-owner' WHERE workflow_name='approval' AND workflow_id='wf-1'"+ changed <- readHeader+ shouldBe (field "attempts" (body changed)) (Just (Number 7))+ shouldBe (field "last_error" (body changed)) (Just (String "changed"))+ shouldBe (field "leased_by" (body changed)) (Just (String "new-owner"))+ legacy <- call app methodGet "/wf/show/approval/wf-1" ""+ shouldBe (field "instance" (body legacy)) (Just (body changed))+ sql "DELETE FROM keiro.keiro_workflows WHERE workflow_name='approval' AND workflow_id='wf-1'"+ missing <- readHeader+ assertError missing status422 "command_failed"+ sql "INSERT INTO keiro.keiro_workflows(workflow_name,workflow_id,status) VALUES ('approval','wf-1','failed')"+ recreated <- readHeader+ shouldBe (field "status" (body recreated)) (Just (String "failed"))+ it "enforces byte and entry limits through churn, oversized changes and misses" $ \store -> do+ let sql statement = Pool.use store.pool (Session.script statement) >>= either (fail . show) pure+ encode value = fmap LazyByteString.toStrict <$> encodeBounded (1024 * 1024) value+ sql "INSERT INTO keiro.keiro_workflows(workflow_name,workflow_id,status) SELECT 'cache','id-'||n,'running' FROM generate_series(1,96) n"+ cache <- newWorkflowHeaderCache (1024 * 1024) encode+ let readHeader wid = Ops.runCommandWithHeaderCache cache hooks (OpsEnv store Json False [] False) (Ops.Workflow (Workflow.Inspect (Workflow.InspectHeader (Workflow.WorkflowRef "cache" wid))))+ bounded = do+ (count, bytes) <- workflowHeaderCacheUsage cache+ shouldSatisfy count (<= 32)+ shouldSatisfy bytes (<= 1024 * 1024)+ forM_ [1 .. (96 :: Int)] $ \n -> do+ (outcome, encoded) <- readHeader ("id-" <> Text.pack (show n))+ shouldSatisfy outcome (\case Succeeded _ -> True; _ -> False)+ shouldSatisfy encoded isJust+ bounded+ shouldReturn (fmap fst (workflowHeaderCacheUsage cache)) 32+ sql "UPDATE keiro.keiro_workflows SET last_error=repeat('x',20000)"+ forM_ [1 .. (96 :: Int)] $ \n -> readHeader ("id-" <> Text.pack (show n)) >> bounded+ (largeCount, _) <- workflowHeaderCacheUsage cache+ shouldSatisfy largeCount (< 32)+ sql "DELETE FROM keiro.keiro_workflows"+ forM_ [1 .. (96 :: Int)] $ \n -> readHeader ("id-" <> Text.pack (show n)) >> bounded+ shouldReturn (workflowHeaderCacheUsage cache) (0, 0)+ it "fails oversized changes explicitly and reapplies schema policy to warm headers" $ \store -> do+ seedInspection store+ let sql statement = Pool.use store.pool (Session.script statement) >>= either (fail . show) pure+ config = defaultOpsHttpConfig {inspectionLimits = defaultInspectionLimits {maxResponseBytes = 1024}}+ app <- opsApplication config hooks store+ let readHeader = call app methodGet "/wf/inspect/header/approval/wf-1" ""+ warm <- readHeader+ shouldBe (simpleStatus warm) status200+ sql "UPDATE keiro.keiro_workflows SET last_error=repeat('x',100000) WHERE workflow_id='wf-1'"+ tooLarge <- readHeader+ assertError tooLarge status413 "inspection_result_too_large"+ sql "UPDATE keiro.keiro_workflows SET last_error=NULL WHERE workflow_id='wf-1'"+ restored <- readHeader+ shouldBe (body restored) (body warm)+ sql "ALTER TABLE keiro.keiro_timers ADD COLUMN header_test_drift text"+ refused <- readHeader+ assertError refused status409 "schema_drift"+ allowed <- opsApplication config {Keiro.Ops.Http.Config.schemaDrift = AllowDrift} hooks store+ first <- call allowed methodGet "/wf/inspect/header/approval/wf-1" ""+ second <- call allowed methodGet "/wf/inspect/header/approval/wf-1" ""+ shouldBe (simpleBody second) (simpleBody first)+ shouldBe (lookup "Keiro-Ops-Schema-Drift" (simpleHeaders second)) (Just "1")+ describe "HTTP boundary" $ do+ it "shares the supported read/write classification across every mounted domain" $ \store -> do+ seedInspection store+ app <- opsApplication defaultOpsHttpConfig hooks store+ let domainWords = Set.fromList (concat (mapParser (\_ option -> case Types.optMain option of Types.CmdReader _ commands -> map (Text.pack . fst) commands; _ -> []) (Ops.commandParser hooks)))+ domainWords `shouldBe` Set.fromList ("pgmq" : "replay-audit" : map (Text.takeWhile (/= '/') . Text.drop 1 . fst) commandPairs)+ forM_ commandPairs $ \(readPath, mutationPath) -> do+ response <- call app methodGet readPath ""+ (readPath, simpleStatus response, body response) `shouldSatisfy` (\(_, status, _) -> status == status200)+ readPost <- call app methodPost readPath ""+ simpleStatus readPost `shouldBe` status405+ lookup hAllow (simpleHeaders readPost) `shouldBe` Just methodGet+ mutationGet <- call app methodGet mutationPath ""+ simpleStatus mutationGet `shouldBe` status405+ lookup hAllow (simpleHeaders mutationGet) `shouldBe` Just methodPost+ disabled <- call app methodPost mutationPath "{\"confirm\":\"execute\"}"+ assertError disabled status403 "mutations_disabled"+ it "renders a real preview and applies the confirmed operation only" $ \store -> do+ seedInspection store+ app <- opsApplication defaultOpsHttpConfig {mutations = MutationsEnabled} hooks store+ preview <- call app methodPost "/stream/soft-delete/order-1" ""+ simpleStatus preview `shouldBe` status200+ field "outcome" (body preview) `shouldBe` Just (String "preview")+ beforeStream <- runStoreIO store (getStream (StreamName "order-1")) >>= either (fail . show) pure+ fmap (isJust . (.deletedAt)) beforeStream `shouldBe` Just False+ direct <- case parseArguments hooks ["stream", "soft-delete", "order-1"] of+ Parsed command -> Ops.runCommand hooks (OpsEnv store Json False [] False) command+ Rejected err -> fail (Text.unpack err)+ case direct of+ PreviewRequired result invocation -> do+ field "result" (body preview) `shouldBe` Just result.jsonValue+ field "cli_reinvocation" (body preview) `shouldBe` Just (String invocation)+ _ -> expectationFailure "expected supported preview"+ confirmed <- call app methodPost "/stream/soft-delete/order-1" "{\"confirm\":\"execute\"}"+ simpleStatus confirmed `shouldBe` status200+ field "outcome" (body confirmed) `shouldBe` Just (String "executed")+ afterStream <- runStoreIO store (getStream (StreamName "order-1")) >>= either (fail . show) pure+ fmap (isJust . (.deletedAt)) afterStream `shouldBe` Just True+ forM_ ["{\"confirm\":true}", "{\"confirm\":\"yes\"}", "{\"confirm\":\"execute\",\"extra\":1}", "[]"] $ \bad -> do+ response <- call app methodPost "/stream/soft-delete/order-1" bad+ assertError response status400 "invalid_confirmation"+ it "requires preview and confirmation for DLQ reads that reserve messages" $ \store -> do+ seedInspection store+ readOnly <- opsApplication defaultOpsHttpConfig hooks store+ denied <- call readOnly methodPost "/pgmq/dlq/read?queue=http_test" ""+ assertError denied status403 "mutations_disabled"+ getResponse <- call readOnly methodGet "/pgmq/dlq/read?queue=http_test" ""+ assertError getResponse status405 "method_not_allowed"+ app <- opsApplication defaultOpsHttpConfig {mutations = MutationsEnabled} hooks store+ preview <- call app methodPost "/pgmq/dlq/read?queue=http_test" ""+ field "outcome" (body preview) `shouldBe` Just (String "preview")+ response <- call app methodPost "/pgmq/dlq/read?queue=http_test" "{\"confirm\":\"execute\"}"+ field "outcome" (body response) `shouldBe` Just (String "executed")+ field "result" (body response) `shouldSatisfy` maybe False (\case Array items -> not (null items); _ -> False)+ next <- call app methodPost "/pgmq/dlq/read?queue=http_test" "{\"confirm\":\"execute\"}"+ field "result" (body next) `shouldBe` Just (Array mempty)+ it "rejects invalid requests and has structured errors" $ \store -> do+ app <- opsApplication defaultOpsHttpConfig Ops.emptyAppHooks store+ forM_ ["/wf/list?limit=abc", "/wf", "/nope", "/wf/list?force=true", "/wf/list?%FF=x", "/wf/list?name=%FF", "/wf/--force"] $ \path -> do+ response <- call app methodGet path ""+ assertError response status400 "invalid_request"+ response <- call app methodGet "/" ""+ assertError response status404 "not_found"+ it "normalizes parsed origins and rejects malformed or repeated origins" $ \_ -> do+ let allowed headers = Cors.originAllowed ["https://console.example"] False headers+ forM_ [[], [("Origin", "http://EXAMPLE.test:80"), (hHost, "example.test")], [("Origin", "HTTPS://CONSOLE.EXAMPLE:443")]] $ \headers ->+ allowed headers `shouldBe` True+ forM_ [[("Origin", "null")], [("Origin", "https://console.example/path")], [("Origin", "https://console.example#fragment")], [("Origin", "https://user@console.example")], [("Origin", "https://console.example https://other.example")], [("Origin", "https://console.example"), ("Origin", "https://console.example")], [("Origin", "http://console.example")], [("Origin", "https://console.example:444")], [("Origin", "https://evil.example"), ("X-Forwarded-Host", "evil.example")]] $ \headers ->+ allowed headers `shouldBe` False+ it "rejects foreign mutations before reading their body or verifying schema" $ \store -> do+ count <- newIORef (0 :: Int)+ budget <- newInspectionBudget defaultInspectionLimits+ app <-+ opsApplicationWithOperations+ defaultOpsHttpConfig {mutations = MutationsEnabled}+ budget+ hooks+ store+ (OpsHttpOperations (modifyIORef' count (+ 1) >> pure (Right [])) (\_ _ -> modifyIORef' count (+ 1) >> pure (Succeeded emptyResult)))+ let foreignApp request respond = app (request {requestBody = fail "foreign request body was read"}) respond+ req = (setPath defaultRequest "/stream/soft-delete/order-1") {requestMethod = methodPost, requestHeaders = [("Origin", "https://evil.example"), (hContentType, "application/json")]}+ refused <- runSession (srequest (SRequest req "{\"confirm\":\"execute\"}")) foreignApp+ assertError refused status403 "origin_not_allowed"+ readIORef count `shouldReturn` 0+ it "requires JSON for nonempty bodies including chunked confirmation, and permits empty previews" $ \store -> do+ app <- opsApplication defaultOpsHttpConfig {mutations = MutationsEnabled} hooks store+ let post headers payload chunked =+ let req = (setPath defaultRequest "/stream/soft-delete/order-1") {requestMethod = methodPost, requestHeaders = headers}+ wrapped request respond = app (if chunked then request {requestBodyLength = ChunkedBody} else request) respond+ in runSession (srequest (SRequest req payload)) wrapped+ forM_ [False, True] $ \chunked -> do+ forM_ [[], [(hContentType, "text/plain")]] $ \headers ->+ post headers "{\"confirm\":\"execute\"}" chunked >>= \response -> assertError response status415 "unsupported_media_type"+ preview <- post [] "" chunked+ simpleStatus preview `shouldBe` status200+ forM_ [[("Origin", "http://example.test:80"), (hHost, "EXAMPLE.test")], [("Origin", "https://console.example")], []] $ \origins -> do+ allowed <- opsApplication defaultOpsHttpConfig {mutations = MutationsEnabled, Config.allowedOrigins = ["https://console.example"]} hooks store+ response <- runSession (srequest (SRequest ((setPath defaultRequest "/outbox/maintenance-pass") {requestMethod = methodPost, requestHeaders = (hContentType, "Application/JSON; charset=utf-8") : origins}) "{\"confirm\":\"execute\"}")) allowed+ simpleStatus response `shouldBe` status200+ it "applies exact-origin CORS without credentials or duplicate headers" $ \store -> do+ app <- opsApplication defaultOpsHttpConfig {Config.allowedOrigins = ["http://ui.example"]} Ops.emptyAppHooks store+ let request origin method = (setPath defaultRequest "/wf/list") {requestMethod = method, requestHeaders = [("Origin", origin), ("Access-Control-Request-Method", "GET")]}+ allowed <- runSession (srequest (SRequest (request "http://ui.example" methodGet) "")) app+ lookup "Access-Control-Allow-Origin" (simpleHeaders allowed) `shouldBe` Just "http://ui.example"+ lookup "Access-Control-Allow-Credentials" (simpleHeaders allowed) `shouldBe` Nothing+ preflight <- runSession (srequest (SRequest (request "http://ui.example" methodOptions) "")) app+ simpleStatus preflight `shouldBe` status204+ denied <- runSession (srequest (SRequest (request "http://other.example" methodGet) "")) app+ lookup "Access-Control-Allow-Origin" (simpleHeaders denied) `shouldBe` Nothing+ disabledApp <- opsApplication defaultOpsHttpConfig Ops.emptyAppHooks store+ disabled <- runSession (srequest (SRequest (request "http://ui.example" methodGet) "")) disabledApp+ lookup "Access-Control-Allow-Origin" (simpleHeaders disabled) `shouldBe` Nothing+ it "bounds requests and results before responding" $ \store -> do+ let config = defaultOpsHttpConfig {inspectionLimits = defaultInspectionLimits {maxRequestBytes = 30, maxResponseBytes = 2}}+ app <- opsApplication config Ops.emptyAppHooks store+ tooLarge <- call app methodGet ("/wf/list?name=" <> Text.replicate 100 "x") ""+ assertError tooLarge status413 "inspection_request_too_large"+ health <- call app methodGet "/health" ""+ assertError health status413 "inspection_result_too_large"+ enabled <- opsApplication defaultOpsHttpConfig {mutations = MutationsEnabled} hooks store+ oversized <- call enabled methodPost "/outbox/maintenance-pass" (LazyByteString.replicate 1025 32)+ assertError oversized status413 "inspection_request_too_large"+ it "keeps hook-dependent commands absent unless mounted and preserves supported failures" $ \store -> do+ standalone <- opsApplication defaultOpsHttpConfig Ops.emptyAppHooks store+ absent <- call standalone methodGet "/rebuild/list" ""+ assertError absent status400 "invalid_request"+ app <- opsApplication defaultOpsHttpConfig hooks store+ audited <- call app methodGet "/replay-audit?full=true" ""+ assertError audited status422 "command_failed"+ listed <- call app methodGet "/rebuild/list" ""+ direct <- case parseArguments hooks ["rebuild", "list"] of+ Parsed command -> Ops.runCommand hooks (OpsEnv store Json False [] False) command+ Rejected err -> fail (Text.unpack err)+ case direct of+ Succeeded result -> body listed `shouldBe` result.jsonValue+ _ -> expectationFailure "expected supported catalog read"+ it "refuses excess requests before verification and cancels schema work on deadline" $ \store -> do+ count <- newIORef (0 :: Int)+ entered <- newEmptyMVar+ let config = defaultOpsHttpConfig {inspectionLimits = defaultInspectionLimits {maxConcurrentReads = 1, readDeadlineSeconds = 1}}+ operations = OpsHttpOperations (modifyIORef' count (+ 1) >> putMVar entered () >> threadDelay 3000000 >> pure (Right [])) (\_ _ -> pure (Succeeded emptyResult))+ budget <- newInspectionBudget config.inspectionLimits+ app <- opsApplicationWithOperations config budget Ops.emptyAppHooks store operations+ withAsync (call app methodGet "/wf/list" "") $ \job -> do+ takeMVar entered+ refused <- call app methodGet "/wf/list" ""+ assertError refused status503 "inspection_busy"+ readIORef count `shouldReturn` 1+ response <- wait job+ assertError response status504 "inspection_timeout"+ it "holds admission during response delivery and releases it when the client cancels" $ \store -> do+ entered <- newEmptyMVar+ gate <- newEmptyMVar+ count <- newIORef (0 :: Int)+ let config = defaultOpsHttpConfig {inspectionLimits = defaultInspectionLimits {maxConcurrentReads = 1}}+ operations = OpsHttpOperations (modifyIORef' count (+ 1) >> pure (Right [])) (\_ _ -> pure (Succeeded emptyResult))+ budget <- newInspectionBudget config.inspectionLimits+ app <- opsApplicationWithOperations config budget Ops.emptyAppHooks store operations+ let slow request respond = app request (\response -> putMVar entered () >> takeMVar gate >> respond response)+ withAsync (call slow methodGet "/health" "") $ \job -> do+ takeMVar entered+ refused <- call app methodGet "/health" ""+ assertError refused status503 "inspection_busy"+ readIORef count `shouldReturn` 1+ cancel job+ recovered <- call app methodGet "/health" ""+ simpleStatus recovered `shouldBe` status200+ it "expires four blocked deliveries once, returns all permits, and recovers health" $ \store -> do+ entered <- newChan+ blocked <- newEmptyMVar+ responses <- newIORef (0 :: Int)+ cancelled <- newIORef (0 :: Int)+ let config = defaultOpsHttpConfig {inspectionLimits = defaultInspectionLimits {maxConcurrentReads = 4, deliveryDeadlineSeconds = 1}}+ budget <- newInspectionBudget config.inspectionLimits+ app <- opsApplicationWithOperations config budget Ops.emptyAppHooks store (OpsHttpOperations (pure (Right [])) (\_ _ -> pure (Succeeded emptyResult)))+ let slow request respond =+ app+ request+ ( \response -> do+ atomicModifyIORef' responses (\n -> (n + 1, ()))+ writeChan entered ()+ (takeMVar blocked >> respond response) `finally` atomicModifyIORef' cancelled (\n -> (n + 1, ()))+ )+ jobs <- replicateM 4 (async (try (call slow methodGet "/health" "") :: IO (Either SomeException SResponse)))+ timeout 1000000 (replicateM 4 (readChan entered)) `shouldReturn` Just [(), (), (), ()]+ refused <- call app methodGet "/health" ""+ assertError refused status503 "inspection_busy"+ finished <- timeout 3000000 (mapM wait jobs)+ case finished of+ Just outcomes -> forM_ outcomes $ \outcome -> outcome `shouldSatisfy` either (const True) (const False)+ Nothing -> mapM_ cancel jobs >> expectationFailure "delivery did not terminate"+ readIORef responses `shouldReturn` 4+ readIORef cancelled `shouldReturn` 4+ call app methodGet "/health" "" >>= \response -> simpleStatus response `shouldBe` status200+ it "mounts idly without recurring verification and skips SQL for disabled/invalid requests" $ \store -> do+ count <- newIORef (0 :: Int)+ budget <- newInspectionBudget defaultInspectionLimits+ app <-+ opsApplicationWithOperations+ defaultOpsHttpConfig+ budget+ Ops.emptyAppHooks+ store+ (OpsHttpOperations (modifyIORef' count (+ 1) >> pure (Right [])) (\_ _ -> pure (Succeeded emptyResult)))+ threadDelay 100000+ _ <- call app methodGet "/wf/list?force=true" ""+ _ <- call app methodGet "/wf/inspect/list?after=not-a-cursor" ""+ _ <- call app methodGet "/wf/inspect/list?limit=501" ""+ _ <- call app methodPost "/outbox/maintenance-pass" ""+ readIORef count `shouldReturn` 0+ it "serves through a loopback socket and releases its port" $ \store -> do+ app <- opsApplication defaultOpsHttpConfig Ops.emptyAppHooks store+ manager <- Client.newManager Client.defaultManagerSettings+ port <- withOpsHttpServer defaultOpsHttpServerConfig {port = 0} app $ \server -> do+ request <- Client.parseRequest ("http://127.0.0.1:" <> show server.serverPort <> "/health")+ response <- Client.httpLbs request manager+ Client.responseStatus response `shouldBe` status200+ pure server.serverPort+ request <- Client.parseRequest ("http://127.0.0.1:" <> show port <> "/health")+ freshManager <- Client.newManager Client.defaultManagerSettings+ Client.httpLbs request manager `shouldThrow` (\(_ :: Client.HttpException) -> True)+ Client.httpLbs request freshManager `shouldThrow` (\(_ :: Client.HttpException) -> True)+ it "refuses foreign origins and non-JSON confirmation over a real HTTP socket" $ \store -> do+ app <- opsApplication defaultOpsHttpConfig {mutations = MutationsEnabled} hooks store+ manager <- Client.newManager Client.defaultManagerSettings+ withOpsHttpServer defaultOpsHttpServerConfig {port = 0} app $ \server -> do+ base <- Client.parseRequest ("http://127.0.0.1:" <> show server.serverPort <> "/outbox/maintenance-pass")+ forM_ [([("Origin", "https://evil.example"), (hContentType, "application/json")], status403), ([(hContentType, "text/plain")], status415)] $ \(headers, status) -> do+ let request = base {Client.method = methodPost, Client.requestHeaders = headers, Client.requestBody = Client.RequestBodyLBS "{\"confirm\":\"execute\"}"}+ response <- Client.httpLbs request manager+ Client.responseStatus response `shouldBe` status+ it "terminates a backpressured Warp delivery and recovers admission" $ \store -> do+ entered <- newEmptyMVar+ delivered <- newEmptyMVar+ let bodyBytes = 32 * 1024 * 1024+ config = defaultOpsHttpConfig {inspectionLimits = defaultInspectionLimits {maxConcurrentReads = 1, deliveryDeadlineSeconds = 1, maxResponseBytes = 64 * 1024 * 1024}}+ large = emptyResult {jsonValue = String (Text.replicate bodyBytes "x")}+ budget <- newInspectionBudget config.inspectionLimits+ app <- opsApplicationWithOperations config budget Ops.emptyAppHooks store (OpsHttpOperations (pure (Right [])) (\_ _ -> pure (Succeeded large)))+ let watched request respond =+ app+ request+ ( \response -> do+ putMVar entered ()+ respond response `finally` putMVar delivered ()+ )+ withOpsHttpServer defaultOpsHttpServerConfig {port = 0} watched $ \server ->+ bracket (Socket.socket Socket.AF_INET Socket.Stream Socket.defaultProtocol) Socket.close $ \client -> do+ Socket.setSocketOption client Socket.RecvBuffer 4096+ Socket.connect client (Socket.SockAddrInet (fromIntegral server.serverPort) (Socket.tupleToHostAddress (127, 0, 0, 1)))+ Socket.sendAll client "GET /wf/list HTTP/1.1\r\nHost: localhost\r\nConnection: keep-alive\r\n\r\n"+ timeout 5000000 (takeMVar entered) `shouldReturn` Just ()+ tryTakeMVar delivered `shouldReturn` Nothing+ -- The 32-MiB response exceeds socket buffers; no response bytes+ -- have been read. The callback is still inside actual Warp delivery.+ refused <- call app methodGet "/health" ""+ assertError refused status503 "inspection_busy"+ timeout 3000000 (takeMVar delivered) `shouldReturn` Just ()+ recovered <- call app methodGet "/health" ""+ simpleStatus recovered `shouldBe` status200+ Socket.setSocketOption client Socket.RecvBuffer (1024 * 1024)+ let drain total = do+ chunk <- Socket.recv client 65536+ if Char8.null chunk then pure total else drain (total + Char8.length chunk)+ terminated <- timeout 5000000 (try (drain 0) :: IO (Either IOException Int))+ case terminated of+ Just (Right count) -> count `shouldSatisfy` (< bodyBytes)+ Just (Left _) -> pure ()+ Nothing -> expectationFailure "expired Warp connection remained open"+ it "joins idle accepted connections across repeated start/stop churn" $ \store -> do+ app <- opsApplication defaultOpsHttpConfig Ops.emptyAppHooks store+ forM_ [1 .. (20 :: Int)] $ \_ ->+ withOpsHttpServer defaultOpsHttpServerConfig {port = 0} app $ \server ->+ bracket (Socket.socket Socket.AF_INET Socket.Stream Socket.defaultProtocol) Socket.close $ \client -> do+ Socket.connect client (Socket.SockAddrInet (fromIntegral server.serverPort) (Socket.tupleToHostAddress (127, 0, 0, 1)))+ stopOpsHttpServer server+ result <- timeout 1000000 (try (Socket.recv client 1))+ case result of+ Just (Left (_ :: IOException)) -> pure ()+ Just (Right bytes) | Char8.null bytes -> pure ()+ _ -> expectationFailure "stopped runner retained an idle connection"+ around (withFreshDatabase fixture) $ do+ describe "process-manager discovery database boundary" $ do+ it "performs only aggregate-types verifier work and rejects parsing before borrowing" $ \url ->+ withStore (defaultConnectionSettings url) $ \store -> do+ calls <- newIORef (0 :: Int)+ let observe = \case Observation.ConnectionObservation _ Observation.InUseConnectionStatus -> atomicModifyIORef' calls (\n -> (n + 1, ())); _ -> pure ()+ settings = (defaultConnectionSettings url) {observationHandler = Just observe}+ mounted = Ops.emptyAppHooks {Ops.processManagers = Just jitsureiProcessManagerInspectors, Ops.aggregates = Just Map.empty}+ withOpsInspectionPool defaultInspectionLimits settings $ \pool -> do+ app <- opsApplication defaultOpsHttpConfig mounted store {pool}+ before <- readIORef calls+ invalid <- call app methodGet "/pm/types?name=invalid" ""+ assertError invalid status400 "invalid_request"+ readIORef calls `shouldReturn` before+ aggregate <- call app methodGet "/aggregate/types" ""+ simpleStatus aggregate `shouldBe` status200+ afterAggregate <- readIORef calls+ manager <- call app methodGet "/pm/types" ""+ simpleStatus manager `shouldBe` status200+ afterManager <- readIORef calls+ afterAggregate `shouldSatisfy` (> before)+ afterManager - afterAggregate `shouldBe` afterAggregate - before+ repeated <- call app methodGet "/pm/types" ""+ body repeated `shouldBe` body manager+ afterRepeated <- readIORef calls+ afterRepeated - afterManager `shouldBe` afterAggregate - before+ describe "bounded workflow header" $ do+ it "uses one supported instance read even with 100000 children" $ \url ->+ withStore (defaultConnectionSettings url) $ \store -> do+ seedInspection store+ executeSql url "INSERT INTO keiro.keiro_workflow_children(child_id,child_name,parent_id,parent_name,await_step) SELECT 'child-'||n,'child','wf-1','approval','await-'||n FROM generate_series(1,100000) n"+ calls <- newIORef (0 :: Int)+ let observe = \case Observation.ConnectionObservation _ Observation.InUseConnectionStatus -> atomicModifyIORef' calls (\n -> (n + 1, ())); _ -> pure ()+ settings = (defaultConnectionSettings url) {observationHandler = Just observe}+ withOpsInspectionPool defaultInspectionLimits settings $ \pool -> do+ app <- opsApplication defaultOpsHttpConfig hooks store {pool}+ response <- call app methodGet "/wf/inspect/header/approval/wf-1" ""+ simpleStatus response `shouldBe` status200+ field "workflow_id" (body response) `shouldBe` Just (String "wf-1")+ field "children" (body response) `shouldBe` Nothing+ -- One schema-verification session and one instance-read session.+ readIORef calls `shouldReturn` 2+ describe "schema handshake" $ do+ it "bounds SQL on every inspection connection and releases pool capacity on cancellation" $ \url -> do+ let limits = defaultInspectionLimits {statementTimeoutMillis = 100, maxConcurrentReads = 1}+ withOpsInspectionPool limits (defaultConnectionSettings url) $ \pool -> do+ slow <- Pool.use pool (Session.script "SELECT pg_sleep(1)")+ slow `shouldSatisfy` either (const True) (const False)+ result <- timeout 1000000 (Pool.use pool (Session.script "SELECT 1"))+ result `shouldBe` Just (Right ())+ withOpsInspectionPool defaultInspectionLimits {maxConcurrentReads = 1} (defaultConnectionSettings url) $ \pool ->+ bracket (Connection.acquire (Settings.connectionString url) >>= either (fail . show) pure) Connection.release $ \control ->+ withAsync (Pool.use pool (Session.script "SELECT pg_sleep(10)")) $ \job -> do+ let active = do+ result <- Connection.use control (Session.statement () (Statement.preparable "SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE datname=current_database() AND query='SELECT pg_sleep(10)' AND state='active')" E.noParams (D.singleRow (D.column (D.nonNullable D.bool)))))+ case result of Right True -> pure (); Right False -> threadDelay 1000 >> active; Left err -> fail (show err)+ timeout 1000000 active `shouldReturn` Just ()+ cancel job+ timeout 1000000 (Pool.use pool (Session.script "SELECT 1")) `shouldReturn` Just (Right ())+ -- The host's ordinary connection was never configured by the pool.+ executeSql url "DO $$ BEGIN IF current_setting('statement_timeout') <> '0' THEN RAISE EXCEPTION 'writer timeout changed'; END IF; END $$"+ it "detects drift after construction, refuses previews/confirmation, and supports explicit policy" $ \url ->+ withStore (defaultConnectionSettings url) $ \store -> do+ app <- opsApplication defaultOpsHttpConfig {mutations = MutationsEnabled} Ops.emptyAppHooks store+ healthy <- call app methodGet "/health" ""+ simpleStatus healthy `shouldBe` status200+ executeSql url "ALTER TABLE keiro.keiro_timers ADD COLUMN http_test_drift text"+ forM_ [(methodGet, "/wf/list", ""), (methodPost, "/outbox/maintenance-pass", ""), (methodPost, "/outbox/maintenance-pass", "{\"confirm\":\"execute\"}")] $ \(method, path, payload) -> do+ response <- call app method path payload+ assertError response status409 "schema_drift"+ health <- call app methodGet "/health" ""+ simpleStatus health `shouldBe` status503+ allowed <- opsApplication defaultOpsHttpConfig {Keiro.Ops.Http.Config.schemaDrift = AllowDrift} Ops.emptyAppHooks store+ response <- call allowed methodGet "/wf/list" ""+ simpleStatus response `shouldBe` status200+ lookup "Keiro-Ops-Schema-Drift" (simpleHeaders response) `shouldBe` Just "1"+ describe "CLI agreement" $ do+ it "agrees with executable JSON across the database-only command domains" $ \url ->+ withStore (defaultConnectionSettings url) $ \store -> do+ seedInspection store+ executable <- opsExecutable+ app <- opsApplication defaultOpsHttpConfig Ops.emptyAppHooks store+ forM_ cliReads $ \(path, args) -> do+ (exit, out, err) <- readProcessWithExitCode executable (["--database-url", Text.unpack url] <> args <> ["--json"]) ""+ (path, exit, out, err) `shouldSatisfy` (\(_, code, _, _) -> code == ExitSuccess)+ response <- call app methodGet path ""+ simpleStatus response `shouldBe` status200+ -- These are two observations at different times. Compare stable+ -- fields while retaining the timestamp/age shape checks in ops.+ fmap stableObservation (Aeson.eitherDecode (simpleBody response)) `shouldBe` (fmap stableObservation (Aeson.eitherDecode (LazyByteString.fromStrict (Char8.pack out))) :: Either String Value)+ err `shouldBe` ""++stableObservation :: Value -> Value+stableObservation (Object fields) = Object (KeyMap.map stableObservation (foldr KeyMap.delete fields ["observed_at", "lease_age_seconds", "due_in_seconds"]))+stableObservation (Array items) = Array (fmap stableObservation items)+stableObservation value = value++hooks :: Ops.AppHooks+hooks =+ Ops.emptyAppHooks+ { Ops.workflowResume = Just (Map.empty, defaultWorkflowResumeOptions),+ Ops.timerFire = Just (\_ -> pure Nothing),+ Ops.replayAudit = Just (Audit.OpsAuditConfig []),+ Ops.projectionCatalog =+ Just+ ( case Catalog.validateProjectionCatalog Catalog.emptyProjectionCatalog of+ Catalog.Success catalog -> CatalogOperations.projectionCatalogOperations catalog+ Catalog.Failure errors -> error (show errors)+ )+ }++commandPairs :: [(Text, Text)]+commandPairs =+ [ ("/wf/list", "/wf/cancel/missing/missing"),+ ("/timer/stuck/list", "/timer/cancel/00000000-0000-0000-0000-000000000001"),+ ("/outbox/backlog", "/outbox/maintenance-pass"),+ ("/inbox/backlog", "/inbox/gc"),+ ("/projection/position?subscription=http_test", "/projection/prune-dedup?projection=http_test&before=2026-01-01T00%3A00%3A00Z"),+ ("/shard/status?subscription=http_test", "/shard/relinquish?subscription=http_test&worker=00000000-0000-0000-0000-000000000001"),+ ("/snapshot/show?stream=missing", "/snapshot/delete?stream=missing"),+ ("/stream/subscriptions", "/stream/soft-delete/missing"),+ ("/rebuild/list", "/rebuild/start/missing?run_id=test&requested_by=test&reason=test")+ ]++cliReads :: [(Text, [String])]+cliReads =+ [ ("/wf/list", ["wf", "list"]),+ ("/timer/pending/list?limit=1", ["timer", "pending", "list", "--limit", "1"]),+ ("/shard/list", ["shard", "list"]),+ ("/timer/stuck/list?min_age=0s", ["timer", "stuck", "list", "--min-age", "0s"]),+ ("/outbox/backlog", ["outbox", "backlog"]),+ ("/inbox/backlog", ["inbox", "backlog"]),+ ("/projection/position?subscription=http_test", ["projection", "position", "--subscription", "http_test"]),+ ("/projection/status?limit=1", ["projection", "status", "--limit", "1"]),+ ("/shard/status?subscription=http_test", ["shard", "status", "--subscription", "http_test"]),+ ("/snapshot/show?stream=missing", ["snapshot", "show", "--stream", "missing"]),+ ("/stream/show/order-1", ["stream", "show", "order-1"]),+ ("/stream/subscriptions", ["stream", "subscriptions"])+ ]++call :: Application -> Method -> Text -> LazyByteString.ByteString -> IO SResponse+call app method path payload = runSession (srequest (SRequest ((setPath defaultRequest (Text.encodeUtf8 path)) {requestMethod = method, requestHeaders = [(hContentType, "application/json")]}) payload)) app++body :: SResponse -> Value+body response = either error id (Aeson.eitherDecode (simpleBody response))++field :: Aeson.Key -> Value -> Maybe Value+field key (Object value) = KeyMap.lookup key value+field _ _ = Nothing++assertError :: SResponse -> Status -> Text -> Expectation+assertError response expected code = do+ simpleStatus response `shouldBe` expected+ (field "error" (body response) >>= field "code") `shouldBe` Just (String code)++executeSql :: Text -> Text -> IO ()+executeSql url sql = bracket acquire Connection.release $ \connection -> do+ result <- Connection.use connection (Session.script sql)+ either (fail . show) pure result+ where+ acquire = Connection.acquire (Settings.connectionString url) >>= either (fail . show) pure++opsExecutable :: IO FilePath+opsExecutable = do+ (exit, out, err) <- readProcessWithExitCode "cabal" ["list-bin", "exe:keiro-ops"] ""+ if exit == ExitSuccess then pure (Text.unpack (Text.strip (Text.pack out))) else fail err++newChanCounter :: IO (MVar Int)+newChanCounter = newMVar 0++signalCounter :: MVar Int -> IO ()+signalCounter counter = modifyMVar_ counter (pure . (+ 1))++awaitCounter :: MVar Int -> Int -> IO ()+awaitCounter counter expected = do+ result <- timeout 2000000 loop+ result `shouldBe` Just ()+ where+ loop = do count <- readMVar counter; if count == expected then pure () else threadDelay 1000 >> loop++seedInspection :: KirokuStore -> IO ()+seedInspection store = do+ now <- getCurrentTime+ _ <-+ runStoreIO+ store+ ( appendToStream+ (StreamName "order-1")+ AnyVersion+ [ EventData Nothing (EventType "order.created") (object ["order" .= (1 :: Int)]) Nothing Nothing Nothing,+ EventData Nothing (EventType "order.confirmed") (object ["order" .= (1 :: Int)]) Nothing Nothing Nothing+ ]+ )+ >>= either (fail . show) pure+ runStoreIO store (appendJournalEntry (WorkflowName "approval") (WorkflowId "wf-1") (StepRecorded "received" Null now)) >>= either (fail . show) pure+ let timer = Timer.TimerRequest (Timer.TimerId UUID.nil) "billing" "invoice-1" (addUTCTime (-60) now) Null+ runStoreIO store (runTransaction (Timer.scheduleTimerTx timer)) >>= either (fail . show) pure+ let job = Job {jobName = "http_test", jobQueue = queueRef "http_test", jobCodec = aesonJobCodec, jobOrdering = Unordered, jobPolicy = defaultRetryPolicy} :: Job Value+ result <- runJobEff (JobRuntime store.pool Nothing) $ do+ ensureJobQueue job+ _ <- enqueue job (object ["poison" .= True])+ _ <- runJobOnce 1 job (\_ -> pure (Dead "test poison"))+ pure ()+ either (fail . show) pure result
+ test/Test/ComposeSpec.hs view
@@ -0,0 +1,137 @@+module Test.ComposeSpec (spec) where++import Control.Monad (forM_, void)+import Data.Aeson (Value (..), encode, object, (.=))+import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KeyMap+import Data.ByteString.Builder qualified as Builder+import Data.ByteString.Lazy qualified as Lazy+import Data.Text (Text)+import Data.Time (getCurrentTime)+import Effectful (Eff, IOE)+import Effectful.Error.Static (Error)+import Keiro.Ops (emptyAppHooks)+import Keiro.Ops.Http+import Keiro.Ops.Http.Application (opsApplicationWithBudget)+import Keiro.Ops.Http.Config qualified as Config+import Keiro.Ops.Http.WebSocket (feedsMiddleware)+import Keiro.Test.Postgres (Fixture, withFreshStore)+import Keiro.Workflow (WorkflowId (..), WorkflowJournalEvent (..), WorkflowName (..), appendJournalEntry)+import Kiroku.Metrics qualified as Metrics+import Kiroku.Store.Connection (KirokuStore)+import Kiroku.Store.Effect (Store, runStoreIO)+import Kiroku.Store.Error (StoreError)+import Network.HTTP.Types+import Network.Wai+import Network.Wai.Test qualified as Wai+import Network.WebSockets qualified as WS+import System.Timeout (timeout)+import Test.Hspec++spec :: Fixture -> Spec+spec fixture = describe "Compose" $ do+ it "strips exactly one raw segment and preserves route spelling, queries and streaming bodies" $ do+ let app = composed defaultComposeConfig [MountedSurface keiroPrefix echo]+ forM_+ [ ("/keiro", "/"),+ ("/keiro/", "/"),+ ("/k%65iro/ws?from=3", "/ws"),+ ("/keiro/a%2Fb/%25/%E6%97%A5/", "/a%2Fb/%25/%E6%97%A5/"),+ ("/keiro/x//y/", "/x//y/")+ ]+ $ \(path, relative) -> do+ let req = Wai.setPath defaultRequest path+ Just stripped <- pure (stripSurfacePrefix keiroPrefix req)+ rawPathInfo stripped `shouldBe` relative+ rawQueryString stripped `shouldBe` rawQueryString req+ queryString stripped `shouldBe` queryString req+ response <- get app req+ Wai.simpleStatus response `shouldBe` status200+ Wai.simpleBody response `shouldBe` Lazy.fromStrict relative+ stripSurfacePrefix keiroPrefix ((Wai.setPath defaultRequest "/keiro/one") {rawPathInfo = "/keiro/two"}) `shouldSatisfy` maybe True (const False)+ it "validates mounts, lists them in order, refuses absent surfaces and applies CORS once" $ do+ forM_ ["", "-x", "UPPER", "x/y"] $ \prefix -> mkSurfacePrefix prefix `shouldBe` Left (InvalidPrefix prefix)+ case composeSurfaces defaultComposeConfig [] of Left NoSurfaces -> pure (); _ -> expectationFailure "empty mount accepted"+ case composeSurfaces defaultComposeConfig [MountedSurface keiroPrefix echo, MountedSurface keiroPrefix echo] of+ Left (DuplicatePrefix "keiro") -> pure ()+ _ -> expectationFailure "duplicate mount accepted"+ let app = composed (ComposeConfig ["http://ui.test"]) [MountedSurface keiroPrefix echo, MountedSurface kirokuPrefix existingCors]+ index <- get app (Wai.setPath defaultRequest "/")+ decode (Wai.simpleBody index) `shouldBe` object ["surfaces" .= [object ["prefix" .= ("keiro" :: Text), "path" .= ("/keiro" :: Text)], object ["prefix" .= ("kiroku" :: Text), "path" .= ("/kiroku" :: Text)]]]+ refused <- get app (Wai.setPath defaultRequest "/pgmq/queues")+ Wai.simpleStatus refused `shouldBe` status404+ method <- get app ((Wai.setPath defaultRequest "/") {requestMethod = methodPost})+ lookup hAllow (Wai.simpleHeaders method) `shouldBe` Just "GET"+ forM_ [("/keiro/x", "http://ui.test"), ("/kiroku/x", "http://already.test")] $ \(path, origin) -> do+ response <- get app ((Wai.setPath defaultRequest path) {requestHeaders = [(hOrigin, "http://ui.test")]})+ filter ((== "Access-Control-Allow-Origin") . fst) (Wai.simpleHeaders response) `shouldBe` [("Access-Control-Allow-Origin", origin)]+ lookup "Access-Control-Allow-Credentials" (Wai.simpleHeaders response) `shouldBe` Nothing+ let preflight = (Wai.setPath defaultRequest "/keiro/x") {requestMethod = methodOptions, requestHeaders = [(hOrigin, "http://ui.test"), ("Access-Control-Request-Method", "GET"), ("Access-Control-Request-Headers", "X-Request-Id")]}+ response <- get app preflight+ Wai.simpleStatus response `shouldBe` status204+ lookup "Access-Control-Allow-Headers" (Wai.simpleHeaders response) `shouldBe` Just "X-Request-Id"+ forM_ [composed defaultComposeConfig [MountedSurface keiroPrefix echo], app] $ \candidate -> do+ denied <- get candidate ((Wai.setPath defaultRequest "/keiro/x") {requestHeaders = [(hOrigin, "http://other.test")]})+ lookup "Access-Control-Allow-Origin" (Wai.simpleHeaders denied) `shouldBe` Nothing+ around (withFreshStore fixture) $ it "mounts real Keiro and Kiroku applications and both socket protocols on one port" $ \store -> do+ now <- getCurrentTime+ void (stored store (appendJournalEntry (WorkflowName "compose") (WorkflowId "one") (StepRecorded "start" Null now)))+ let config = defaultOpsHttpConfig {Config.allowedOrigins = ["http://ui.test"], mutations = MutationsEnabled}+ metricsConfig = Metrics.defaultConfig {Metrics.wsPushIntervalUs = 30000000}+ metrics <- Metrics.newKirokuMetrics store+ providers <- Metrics.storeServerProviders metricsConfig metrics store+ let kiroku = Metrics.combinedAppWithProviders metricsConfig metrics [] providers+ withFeedHandle config defaultFeedConfig store $ \handle -> do+ http <- opsApplicationWithBudget config (handleBudget handle) emptyAppHooks store+ let keiro = feedsMiddleware config handle http+ app = composed (ComposeConfig config.allowedOrigins) [MountedSurface keiroPrefix keiro, MountedSurface kirokuPrefix kiroku]+ forM_ [(keiro, "/health", "/keiro/health"), (kiroku, "/capabilities", "/kiroku/capabilities")] $ \(standalone, relative, mounted) -> do+ plain <- get standalone (Wai.setPath defaultRequest relative)+ through <- get app ((Wai.setPath defaultRequest mounted) {requestHeaders = [(hOrigin, "http://ui.test")]})+ Wai.simpleStatus through `shouldBe` Wai.simpleStatus plain+ Wai.simpleBody through `shouldBe` Wai.simpleBody plain+ filter ((== "Access-Control-Allow-Origin") . fst) (Wai.simpleHeaders through) `shouldBe` [("Access-Control-Allow-Origin", "http://ui.test")]+ forM_ [("http://ui.test", status200), ("http://evil.test", status403)] $ \(origin, status) -> do+ let request = (Wai.setPath defaultRequest "/keiro/outbox/maintenance-pass") {requestMethod = methodPost, requestHeaders = [(hOrigin, origin)]}+ response <- Wai.runSession (Wai.srequest (Wai.SRequest request "")) app+ Wai.simpleStatus response `shouldBe` status+ withOpsHttpServer defaultOpsHttpServerConfig {port = 0} app $ \server -> within $ do+ WS.runClient "127.0.0.1" server.serverPort "/kiroku/ws/metrics" $ \connection -> do+ receive connection >>= hasType "snapshot"+ WS.sendTextData connection (encode (object ["type" .= ("ping" :: Text)]))+ receive connection >>= hasType "pong"+ WS.runClient "127.0.0.1" server.serverPort "/keiro/ws?from=3" $ \connection -> do+ WS.sendTextData connection (encode (object ["type" .= ("subscribe" :: Text), "feed" .= ("workflows" :: Text)]))+ receive connection >>= hasType "snapshot"+ void (stored store (appendJournalEntry (WorkflowName "compose") (WorkflowId "one") (WorkflowCompleted now)))+ receive connection >>= hasType "update"+ closeFeedHandle handle+ receive connection >>= hasType "goodbye"+ feedStats handle `shouldReturn` FeedStats 0 0 False++composed :: ComposeConfig -> [MountedSurface] -> Application+composed config = either (error . show) id . composeSurfaces config++echo :: Application+echo req respond = respond (responseStream status200 [] (\write flush -> write (Builder.byteString (rawPathInfo req)) >> flush))++existingCors :: Application+existingCors req respond = echo req (respond . mapResponseHeaders (("Access-Control-Allow-Origin", "http://already.test") :))++get :: Application -> Request -> IO Wai.SResponse+get app req = Wai.runSession (Wai.srequest (Wai.SRequest req "")) app++decode :: Lazy.ByteString -> Value+decode = either error id . Aeson.eitherDecode++receive :: WS.Connection -> IO Value+receive connection = decode <$> (WS.receiveData connection :: IO Lazy.ByteString)++hasType :: Text -> Value -> Expectation+hasType kind = \case Object fields -> KeyMap.lookup "type" fields `shouldBe` Just (String kind); value -> expectationFailure (show value)++within :: IO a -> IO a+within action = timeout 15000000 action >>= maybe (fail "composed socket deadline") pure++stored :: KirokuStore -> Eff '[Store, Error StoreError, IOE] a -> IO a+stored store action = runStoreIO store action >>= either (fail . show) pure
+ test/Test/FeedSpec.hs view
@@ -0,0 +1,228 @@+module Test.FeedSpec (spec) where++import Control.Concurrent.Async (async, cancel, wait)+import Control.Concurrent.MVar+import Control.Concurrent.STM+import Control.Exception (bracket, finally)+import Control.Monad (forM_)+import Data.Aeson (Value, object, (.=))+import Data.IORef+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Time (addUTCTime, getCurrentTime)+import Data.Word (Word64)+import Effectful (liftIO)+import Hasql.Pool qualified as Pool+import Hasql.Session qualified as Session+import Keiro.Ops.Http.Config+import Keiro.Ops.Http.Feed qualified as Feed+import Keiro.Ops.Http.Protocol qualified as P+import Keiro.Ops.Http.Views+import Keiro.Test.Postgres (Fixture, withFreshStore)+import Keiro.Wake (WakeReason (..), neverWake, waitForWake)+import Kiroku.Store.Connection (KirokuStore (..))+import System.Timeout (timeout)+import Test.Hspec++spec :: Fixture -> Spec+spec fixture = do+ describe "shared feed driver" $ do+ it "diffs fallback truth, extra-only changes, and recovery without duplicate reads per subscriber" $ do+ let removedItem = Item "two" (object ["id" .= ("two" :: Text)]) (body (item 2))+ source <- newIORef (Right ([item 1, removedItem], Nothing))+ let readItems = do+ value <- readIORef source+ stamp <- getCurrentTime+ pure (fmap (\(rows, extra) -> (stamp, rows, extra)) value)+ bracket (Feed.newDriver P.Workflows quick (waitForWake neverWake 20000) readItems) Feed.stopDriver $ \driver -> do+ one <- Feed.subscribe driver+ two <- Feed.subscribe driver+ first <- receive one+ receive two `shouldReturn` first+ snapshotSequence first `shouldBe` Just 0+ writeIORef source (Right ([item 3], Nothing))+ changed <- receive one+ receive two `shouldReturn` changed+ case changed of+ P.Update P.Workflows 1 P.Timeout _ rows removed _ -> do+ rows `shouldBe` [body (item 3)]+ removed `shouldBe` [keyJson removedItem]+ _ -> expectationFailure (show changed)+ timeout 100000 (atomically (Feed.nextFrame one)) `shouldReturn` Nothing+ writeIORef source (Left "read unavailable")+ forM_ [one, two] $ \subscriber ->+ receive subscriber >>= \case+ P.ErrorFrame P.ReadFailed _ _ -> pure ()+ frame -> expectationFailure (show frame)+ writeIORef source (Right ([item 3], Just (object ["visible_store_head" .= (9 :: Int)])))+ recovered <- receive one+ receive two `shouldReturn` recovered+ case recovered of+ P.Update P.Workflows 2 _ _ rows removed _ -> (rows, removed) `shouldBe` ([], [])+ _ -> expectationFailure (show recovered)+ it "signals overflow before retained deltas and resubscribes from the current sequence" $ do+ source <- newIORef [item 1]+ calls <- newIORef (0 :: Int)+ wake <- newEmptyMVar+ let readItems = do+ modifyIORef' calls (+ 1)+ rows <- readIORef source+ stamp <- getCurrentTime+ pure (Right (stamp, rows, Nothing))+ bracket (Feed.newDriver P.Workflows quick {Feed.queueCapacity = 2} (takeMVar wake >> pure WokenByNotify) readItems) Feed.stopDriver $ \driver -> do+ subscriber <- Feed.subscribe driver+ _ <- receive subscriber+ forM_ [1 .. 5] $ \sequence -> do+ writeIORef source [item (fromIntegral sequence + 1)]+ putMVar wake ()+ reached <- timeout 1000000 $ atomically $ do+ frame <- Feed.driverSnapshot driver+ check (snapshotSequence frame == Just sequence)+ reached `shouldBe` Just ()+ receive subscriber >>= \case+ P.ErrorFrame P.QueueOverflow _ _ -> pure ()+ frame -> expectationFailure (show frame)+ -- The overflow control frame also consumes the strict queue budget;+ -- retaining it can evict one more old delta. Recovery uses a snapshot.+ receive subscriber >>= \case+ P.Update P.Workflows 5 _ _ rows _ _ -> rows `shouldBe` [body (item 6)]+ frame -> expectationFailure (show frame)+ recovered <- Feed.subscribe driver >>= receive+ snapshotSequence recovered `shouldBe` Just 5+ readIORef calls `shouldReturn` 6+ it "retains fresh timer presentation without broadcasting a clock-only delta" $ do+ firstStamp <- getCurrentTime+ let secondStamp = addUTCTime 1 firstStamp+ timer countdown = Item "timer" (object ["timer_id" .= ("timer" :: Text)]) (object ["status" .= ("scheduled" :: Text), "due_in_seconds" .= (countdown :: Int)])+ source <- newIORef (firstStamp, [timer 10])+ wake <- newEmptyMVar+ let readItems = do+ (stamp, rows) <- readIORef source+ pure (Right (stamp, rows, Nothing))+ bracket (Feed.newDriver P.Timers quick (takeMVar wake >> pure WokenByNotify) readItems) Feed.stopDriver $ \driver -> do+ one <- Feed.subscribe driver+ _ <- receive one+ writeIORef source (secondStamp, [timer 9])+ putMVar wake ()+ refreshed <- timeout 7000000 $ atomically $ do+ frame <- Feed.driverSnapshot driver+ case frame of+ P.Snapshot _ 0 _ stamp rows _ -> check (stamp == secondStamp && rows == [body (timer 9)])+ _ -> retry+ refreshed `shouldBe` Just ()+ timeout 100000 (atomically (Feed.nextFrame one)) `shouldReturn` Nothing+ fresh <- Feed.subscribe driver >>= receive+ case fresh of+ P.Snapshot P.Timers 0 _ stamp rows _ -> (stamp, rows) `shouldBe` (secondStamp, [body (timer 9)])+ _ -> expectationFailure (show fresh)+ describe "feed registry" $ around (withFreshStore fixture) $ do+ it "reserves initialization, joins cancellation, shares a ready view, and drops the idle wake cursor" $ \store ->+ Feed.withFeedHandle defaultOpsHttpConfig quick {Feed.maxViews = 1, Feed.maxFrameBytes = 512} store $ \handle -> do+ Feed.feedStats handle `shouldReturn` Feed.FeedStats 0 0 False+ entered <- newEmptyMVar+ gate <- newEmptyMVar+ cleaned <- newEmptyMVar+ let blocked = View "blocked" P.Workflows (liftIO ((putMVar entered () >> takeMVar gate) `finally` putMVar cleaned ()))+ other = View "other" P.Workflows (liftIO (getCurrentTime >>= \stamp -> pure (stamp, [], Nothing)))+ request <- async (Feed.acquireDriver handle blocked)+ timeout 1000000 (takeMVar entered) `shouldReturn` Just ()+ refused <- Feed.acquireDriver handle other+ either (== Feed.TooManyViews) (const False) refused `shouldBe` True+ cancel request+ tryTakeMVar cleaned `shouldReturn` Just ()+ Feed.feedStats handle `shouldReturn` Feed.FeedStats 0 0 False+ let large = View "large" P.Workflows $ liftIO $ do+ stamp <- getCurrentTime+ pure (stamp, [Item "large" (object []) (object ["text" .= Text.replicate 1000 "x"])], Nothing)+ oversized <- Feed.acquireDriver handle large+ either (== Feed.InitialResultTooLarge) (const False) oversized `shouldBe` True+ Feed.feedStats handle `shouldReturn` Feed.FeedStats 0 0 False+ calls <- newIORef (0 :: Int)+ let shared = View "shared" P.Workflows $ liftIO $ do+ modifyIORef' calls (+ 1)+ stamp <- getCurrentTime+ pure (stamp, [item 1], Nothing)+ Right first <- Feed.acquireDriver handle shared+ Right second <- Feed.acquireDriver handle shared+ readIORef calls `shouldReturn` 1+ atomically (Feed.driverSnapshot first) >>= \frame -> atomically (Feed.driverSnapshot second) `shouldReturn` frame+ Feed.releaseDriver handle shared+ Feed.feedStats handle `shouldReturn` Feed.FeedStats 1 0 True+ Feed.releaseDriver handle shared+ Feed.feedStats handle `shouldReturn` Feed.FeedStats 0 0 False+ Feed.closeFeedHandle handle+ Feed.closeFeedHandle handle+ it "refuses a retiring view distinctly until blocked cleanup has joined" $ \store ->+ Feed.withFeedHandle defaultOpsHttpConfig quick {Feed.maxViews = 1} store $ \handle -> do+ block <- newIORef False+ entered <- newEmptyMVar+ cleanupEntered <- newEmptyMVar+ cleanupGate <- newEmptyMVar+ never <- newEmptyMVar+ let view = View "retiring" P.Workflows $ liftIO $ do+ shouldBlock <- readIORef block+ if shouldBlock+ then (putMVar entered () >> takeMVar never) `finally` (putMVar cleanupEntered () >> takeMVar cleanupGate)+ else pure ()+ stamp <- getCurrentTime+ pure (stamp, [item 1], Nothing)+ Right driver <- Feed.acquireDriver handle view+ _ <- Feed.subscribe driver >>= receive+ writeIORef block True+ timeout 2000000 (takeMVar entered) `shouldReturn` Just ()+ release <- async (Feed.releaseDriver handle view)+ ( do+ timeout 1000000 (takeMVar cleanupEntered) `shouldReturn` Just ()+ refused <- Feed.acquireDriver handle view+ either (== Feed.FeedRetiring) (const False) refused `shouldBe` True+ Feed.feedStats handle `shouldReturn` Feed.FeedStats 1 0 True+ writeIORef block False+ )+ `finally` (tryPutMVar cleanupGate () >> pure ())+ timeout 1000000 (wait release) `shouldReturn` Just ()+ Feed.feedStats handle `shouldReturn` Feed.FeedStats 0 0 False+ Right _ <- Feed.acquireDriver handle view+ Feed.releaseDriver handle view+ it "rechecks schema for each active view and recovers only after drift is removed" $ \store ->+ Feed.withFeedHandle defaultOpsHttpConfig quick store $ \handle -> do+ firstCalls <- newTVarIO (0 :: Int)+ secondCalls <- newTVarIO (0 :: Int)+ let view name calls = View name P.Workflows $ liftIO $ do+ atomically (modifyTVar' calls (+ 1))+ stamp <- getCurrentTime+ pure (stamp, [item 1], Nothing)+ firstView = view "schema-first" firstCalls+ secondView = view "schema-second" secondCalls+ sql text = Pool.use store.pool (Session.script text) >>= either (fail . show) pure+ Right first <- Feed.acquireDriver handle firstView+ one <- Feed.subscribe first+ _ <- receive one+ Right second <- Feed.acquireDriver handle secondView+ two <- Feed.subscribe second+ _ <- receive two+ sql "ALTER TABLE keiro.keiro_workflows ADD COLUMN feed_drift bigint"+ forM_ [one, two] $ \subscriber ->+ receive subscriber >>= \case+ P.ErrorFrame P.ReadFailed _ _ -> pure ()+ frame -> expectationFailure (show frame)+ before <- atomically ((,) <$> readTVar firstCalls <*> readTVar secondCalls)+ sql "ALTER TABLE keiro.keiro_workflows DROP COLUMN feed_drift"+ recovered <- timeout 2000000 $ atomically $ do+ after <- (,) <$> readTVar firstCalls <*> readTVar secondCalls+ check (fst after > fst before && snd after > snd before)+ recovered `shouldBe` Just ()+ forM_ [first, second] $ \driver -> Feed.subscribe driver >>= receive >>= \frame -> snapshotSequence frame `shouldBe` Just 0+ Feed.releaseDriver handle firstView+ Feed.releaseDriver handle secondView++quick :: Feed.FeedConfig+quick = Feed.defaultFeedConfig {Feed.fallbackInterval = 0.02, Feed.minRefreshInterval = 0.001, Feed.readTimeout = 2}++item :: Int -> Item+item value = Item "one" (object ["id" .= ("one" :: Text)]) (object ["value" .= value])++snapshotSequence :: P.ServerFrame -> Maybe Word64+snapshotSequence = \case P.Snapshot _ sequence _ _ _ _ -> Just sequence; _ -> Nothing++receive :: Feed.Subscriber -> IO P.ServerFrame+receive subscriber = timeout 2000000 (atomically (Feed.nextFrame subscriber)) >>= maybe (fail "feed frame deadline exceeded") pure
+ test/Test/WebSocketSpec.hs view
@@ -0,0 +1,167 @@+module Test.WebSocketSpec (spec) where++import Control.Monad (forM_, void)+import Data.Aeson (Value (..), object, (.=))+import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KeyMap+import Data.ByteString qualified as Bytes+import Data.ByteString.Char8 qualified as Char8+import Data.ByteString.Lazy qualified as Lazy+import Data.Text (Text)+import Data.Time (UTCTime, addUTCTime, getCurrentTime)+import Data.UUID qualified as UUID+import Effectful (Eff, IOE)+import Effectful.Error.Static (Error)+import Hasql.Pool qualified as Pool+import Hasql.Session qualified as Session+import Keiro.Ops (emptyAppHooks)+import Keiro.Ops.Http hiding (subscribe)+import Keiro.Ops.Projection (projectionGroupStatusJson)+import Keiro.ReadModel (registerReadModel)+import Keiro.ReadModel.Rebuild+import Keiro.Test.Postgres (Fixture, withFreshStore)+import Keiro.Timer qualified as Timer+import Keiro.Workflow (WorkflowId (..), WorkflowJournalEvent (..), WorkflowName (..), appendJournalEntry)+import Kiroku.Store.Connection (KirokuStore (..))+import Kiroku.Store.Effect (Store, runStoreIO)+import Kiroku.Store.Error (StoreError)+import Kiroku.Store.Transaction (runTransaction)+import Kiroku.Store.Types (EventId (..))+import Network.HTTP.Client qualified as HTTP+import Network.HTTP.Types+import Network.Wai (Application, defaultRequest)+import Network.Wai.Test qualified as Wai+import Network.WebSockets qualified as WS+import System.Timeout (timeout)+import Test.Hspec++spec :: Fixture -> Spec+spec fixture = describe "inspection sockets" $ around (withFreshStore fixture) $ do+ it "shares workflow truth, retains a subscription on refusal, and releases each sent frame" $ \store -> do+ now <- getCurrentTime+ void (stored store (appendJournalEntry (WorkflowName "approval") (WorkflowId "one") (StepRecorded "start" Null now)))+ serve store defaultOpsHttpConfig socketConfig {maxViews = 1} $ \handle app port ->+ within $ WS.runClient "127.0.0.1" port "/ws?client=one" $ \one ->+ WS.runClient "127.0.0.1" port "/ws" $ \two -> do+ forM_ [one, two] $ \connection -> send connection (subscribe "workflows") >> receive connection >>= assertType "snapshot"+ feedStats handle `shouldReturn` FeedStats 1 2 True+ -- With two queue slots, five sequential sends detect unreleased+ -- in-flight frames or a writer recursing inside its bracket.+ forM_ [1 .. 5 :: Int] $ \_ -> send one (object ["type" .= ("ping" :: Text)]) >> receive one >>= assertType "pong"+ send one (object ["type" .= ("subscribe" :: Text), "feed" .= ("workflows" :: Text), "workflow_name" .= ("other" :: Text)])+ refused <- receive one+ field "code" refused `shouldBe` Just (String "too_many_views")+ forM_+ [ (object ["type" .= ("subscribe" :: Text), "feed" .= ("unknown" :: Text)], "unknown_feed"),+ (object ["type" .= ("subscribe" :: Text), "feed" .= ("timers" :: Text), "bad_filter" .= True], "invalid_subscription")+ ]+ $ \(frame, code) -> do+ send one frame+ response <- receive one+ field "code" response `shouldBe` Just (String code)+ WS.sendTextData one ("{broken" :: Text)+ malformed <- receive one+ field "code" malformed `shouldBe` Just (String "invalid_frame")++ void (stored store (appendJournalEntry (WorkflowName "approval") (WorkflowId "one") (WorkflowCompleted now)))+ update <- receive one+ receive two `shouldReturn` update+ assertType "update" update+ field "trigger" update `shouldBe` Just (String "notify")+ field "sequence" update `shouldBe` Just (Aeson.toJSON (1 :: Int))+ response <- Wai.runSession (Wai.srequest (Wai.SRequest (Wai.setPath defaultRequest "/wf/inspect/list") "")) app+ field "items" (decode (Wai.simpleBody response)) `shouldBe` field "changed" update+ forM_ [one, two] $ \connection -> do+ send connection (object ["type" .= ("unsubscribe" :: Text), "feed" .= ("workflows" :: Text)])+ receive connection >>= assertType "unsubscribed"+ feedStats handle `shouldReturn` FeedStats 0 2 False+ it "streams timer fallback truth and projection extra-only changes using the poll encoders" $ \store ->+ serve store defaultOpsHttpConfig socketConfig $ \_ _ port -> within $+ WS.runClient "127.0.0.1" port "/ws" $ \connection -> do+ forM_ ["timers", "projection_groups"] $ \feed -> send connection (subscribe feed) >> receive connection >>= assertType "snapshot"+ now <- getCurrentTime+ let timer = Timer.TimerRequest (Timer.TimerId UUID.nil) "manager" "one" (addUTCTime (-60) now) Null+ stored store (runTransaction (Timer.scheduleTimerTx timer))+ added <- receive connection+ field "feed" added `shouldBe` Just (String "timers")+ field "trigger" added `shouldBe` Just (String "timeout")+ Just observed <- pure (field "observed_at" added)+ Aeson.Success stamp <- pure (Aeson.fromJSON observed :: Aeson.Result UTCTime)+ Just inspection <- stored store (Timer.lookupTimerInspection timer.timerId)+ field "changed" added `shouldBe` Just (Aeson.toJSON [Timer.pendingTimerItemJson stamp inspection])+ Just _ <- stored store (Timer.claimDueTimer now)+ stored store (Timer.markTimerFired timer.timerId (EventId UUID.nil)) `shouldReturn` True+ removed <- receive connection+ field "removed" removed `shouldBe` Just (Aeson.toJSON [object ["timer_id" .= UUID.toText UUID.nil]])+ _ <- stored store (registerReadModel "socket-model" 1 "shape")+ projection <- receive connection+ field "feed" projection `shouldBe` Just (String "projection_groups")+ Right page <- stored store (listProjectionGroupStatusesPage (ProjectionStatusPageRequest 100 Nothing))+ field "changed" projection `shouldBe` Just (Aeson.toJSON (map projectionGroupStatusJson page.items))+ void (stored store (appendJournalEntry (WorkflowName "head") (WorkflowId "one") (StepRecorded "head" Null now)))+ headOnly <- receive connection+ field "feed" headOnly `shouldBe` Just (String "projection_groups")+ field "changed" headOnly `shouldBe` Just (Aeson.toJSON ([] :: [Value]))+ field "removed" headOnly `shouldBe` Just (Aeson.toJSON ([] :: [Value]))+ field "visible_store_head" headOnly `shouldSatisfy` (\case Just (Number position) -> position > 0; _ -> False)+ it "checks upgrade origin, capacity and schema policy and closes active sockets" $ \store -> do+ manager <- HTTP.newManager HTTP.defaultManagerSettings+ serve store defaultOpsHttpConfig socketConfig {maxConnections = 1} $ \handle _ port -> do+ forbidden <- upgrade manager port [(hOrigin, "http://evil.test")]+ HTTP.responseStatus forbidden `shouldBe` status403+ let origin = "http://127.0.0.1:" <> show port+ within $ WS.runClientWith "127.0.0.1" port "/ws" WS.defaultConnectionOptions [("Origin", fromStringBytes origin)] $ \connection -> do+ full <- upgrade manager port []+ HTTP.responseStatus full `shouldBe` status503+ closeFeedHandle handle+ receive connection >>= assertType "goodbye"+ feedStats handle `shouldReturn` FeedStats 0 0 False+ result <- Pool.use store.pool (Session.script "ALTER TABLE keiro.keiro_timers ADD COLUMN socket_test_drift text")+ either (fail . show) pure result+ serve store defaultOpsHttpConfig socketConfig $ \_ _ port -> do+ refused <- upgrade manager port []+ HTTP.responseStatus refused `shouldBe` status409+ let allow = defaultOpsHttpConfig {schemaDrift = AllowDrift}+ serve store allow socketConfig $ \_ _ port -> within $ WS.runClient "127.0.0.1" port "/ws" $ \connection -> do+ send connection (subscribe "timers")+ receive connection >>= assertType "snapshot"++socketConfig :: FeedConfig+socketConfig = defaultFeedConfig {fallbackInterval = 5, minRefreshInterval = 0.01, readTimeout = 2, queueCapacity = 2}++serve :: KirokuStore -> OpsHttpConfig -> FeedConfig -> (FeedHandle -> Application -> Int -> IO a) -> IO a+serve store config feeds action = withFeedHandle config feeds store $ \handle -> do+ app <- opsApplicationWithFeeds config handle emptyAppHooks store+ withOpsHttpServer defaultOpsHttpServerConfig {port = 0} app (\server -> action handle app server.serverPort)++subscribe :: Text -> Value+subscribe feed = object ["type" .= ("subscribe" :: Text), "feed" .= feed]++send :: WS.Connection -> Value -> IO ()+send connection = WS.sendTextData connection . Aeson.encode++receive :: WS.Connection -> IO Value+receive connection = timeout 7000000 (WS.receiveData connection :: IO Lazy.ByteString) >>= maybe (fail "socket frame deadline exceeded") (pure . decode)++decode :: Lazy.ByteString -> Value+decode = either error id . Aeson.eitherDecode++field :: Aeson.Key -> Value -> Maybe Value+field key = \case Object fields -> KeyMap.lookup key fields; _ -> Nothing++assertType :: Text -> Value -> IO ()+assertType expected value = field "type" value `shouldBe` Just (String expected)++stored :: KirokuStore -> Eff '[Store, Error StoreError, IOE] a -> IO a+stored store action = runStoreIO store action >>= either (fail . show) pure++within :: IO a -> IO a+within action = timeout 25000000 action >>= maybe (fail "socket client deadline exceeded") pure++upgrade :: HTTP.Manager -> Int -> RequestHeaders -> IO (HTTP.Response Lazy.ByteString)+upgrade manager port headers = do+ request <- HTTP.parseRequest ("http://127.0.0.1:" <> show port <> "/ws")+ HTTP.httpLbs request {HTTP.requestHeaders = headers <> [(hConnection, "Upgrade"), (hUpgrade, "websocket"), ("Sec-WebSocket-Version", "13"), ("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")]} manager++fromStringBytes :: String -> Bytes.ByteString+fromStringBytes = Char8.pack