Files
inter-hub/Application/Helper/BottleneckDetector.hs
Bernd Worsch 74bab5f6f2 fix(WP-0014/A2): continued type-correctness fixes and Tailwind CSS output
- Schema.sql: add FK constraints for phases 6–12 so IHP generates Id X
  instead of UUID for FK columns (widget_adapter_specs, friction_scores,
  hub_routing_rules, agent_proposals, hub_capability_manifests, etc.)
- HubHealth, ModelRouter, ApiInteractionEvents: remove toUUID() wrappers
  now that FK columns carry proper Id types
- FederatedGovernance/Dashboard, HubRoutingRules/Index: same Id comparison fix
- AgentProposals/Index, DecisionRecords/Index, ApiConsumers/Edit: Id type fixes
- BottleneckDetector: add Data.Coerce import; CrossHubPropagation: add guard
- ApiKeys: qualify cryptohash-sha256 import to resolve package ambiguity
- WebhookDeliveryJob: use LBS.fromStrict; remove duplicate diffUTCTime
- Sessions/New: use renderFlashMessages (IHP built-in)
- ArchiveRecords/LineageInspector: simplify renderChainStep signature
- static/app.css: Tailwind CSS output (2011 lines) — A3 confirmed
- workplans/IHUB-WP-0015-local-deployment-intro-ui.md: add workplan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 01:49:41 +00:00

105 lines
4.3 KiB
Haskell

module Application.Helper.BottleneckDetector where
import IHP.Prelude
import IHP.ModelSupport
import IHP.QueryBuilder
import IHP.Fetch
import Generated.Types
import Web.Routes ()
import Data.Time.Clock (addUTCTime, getCurrentTime, NominalDiffTime)
import Database.PostgreSQL.Simple (Only(..))
import Data.Coerce (coerce)
-- | Severity based on how much older than the threshold the record is.
staleSeverity :: NominalDiffTime -> NominalDiffTime -> Text
staleSeverity age threshold
| age > threshold * 2 = "critical"
| age > threshold * 1.5 = "high"
| otherwise = "medium"
-- | Detect pipeline bottlenecks for a hub and upsert BottleneckRecord rows.
-- Idempotent: skips subjects that already have an unresolved record.
detectBottlenecks
:: (?modelContext :: ModelContext)
=> Id Hub
-> [Widget]
-> [RequirementCandidate]
-> [Requirement]
-> [DecisionRecord]
-> [DeploymentRecord]
-> IO [BottleneckRecord]
detectBottlenecks hubId hubWidgets candidates requirements decisions deployments = do
now <- getCurrentTime
existing <- query @BottleneckRecord
|> filterWhere (#hubId, hubId)
|> filterWhereSql (#resolvedAt, "IS NULL")
|> fetch
let existingSubjects = map (.subjectId) existing
let candidateThreshold = 30 * 86400 :: NominalDiffTime
requirementThreshold = 60 * 86400 :: NominalDiffTime
decisionThreshold = 30 * 86400 :: NominalDiffTime
observationThreshold = 14 * 86400 :: NominalDiffTime
-- Stage 1: open candidates older than 30 days
let staleCandidates =
[ (c, addUTCTime (negate candidateThreshold) now)
| c <- candidates
, c.status == "open"
, c.createdAt < addUTCTime (negate candidateThreshold) now
, c.id `notElem` map coerce existingSubjects
]
-- Stage 2: requirements with no decision older than 60 days
let linkedReqIds = mapMaybe (.requirementId) decisions
stalRequirements =
[ (r, addUTCTime (negate requirementThreshold) now)
| r <- requirements
, r.createdAt < addUTCTime (negate requirementThreshold) now
, r.id `notElem` linkedReqIds
, r.id `notElem` map coerce existingSubjects
]
-- Stage 3: decisions with no deployment older than 30 days
let linkedDecisionIds = map (.decisionId) deployments
staleDecisions =
[ (d, addUTCTime (negate decisionThreshold) now)
| d <- decisions
, d.decidedAt < addUTCTime (negate decisionThreshold) now
, d.id `notElem` linkedDecisionIds
, d.id `notElem` map coerce existingSubjects
]
-- Stage 4: deployments with no outcome signal older than 14 days
signalWidgetIds <- sqlQuery
"SELECT DISTINCT widget_id FROM outcome_signals" ()
let signalWids = map (\(Only wid) -> wid) (signalWidgetIds :: [Only (Id Widget)])
let widgetIdSet = map (.id) hubWidgets
let staleDeployments =
[ (dep, addUTCTime (negate observationThreshold) now)
| dep <- deployments
, dep.deployedAt < addUTCTime (negate observationThreshold) now
, not (any (\wid -> wid `elem` signalWids) widgetIdSet)
, dep.id `notElem` map coerce existingSubjects
]
let mkBottleneck stage subjType subjId stalledSince threshold = do
let age = now `diffUTCTime` stalledSince
severity = staleSeverity age threshold
newRecord @BottleneckRecord
|> set #hubId hubId
|> set #stage stage
|> set #subjectType subjType
|> set #subjectId (coerce subjId)
|> set #stalledSince stalledSince
|> set #severity severity
|> createRecord
r1 <- mapM (\(c, t) -> mkBottleneck "candidate" "RequirementCandidate" c.id t candidateThreshold) staleCandidates
r2 <- mapM (\(r, t) -> mkBottleneck "requirement" "Requirement" r.id t requirementThreshold) stalRequirements
r3 <- mapM (\(d, t) -> mkBottleneck "decision" "DecisionRecord" d.id t decisionThreshold) staleDecisions
r4 <- mapM (\(d, t) -> mkBottleneck "observation" "DeploymentRecord" d.id t observationThreshold) staleDeployments
pure (r1 <> r2 <> r3 <> r4)