generated from coulomb/repo-seed
feat(P7): IHF Phase 7 complete — advanced observability and operational integration
Some checks failed
Test / test (push) Has been cancelled
Some checks failed
Test / test (push) Has been cancelled
T01 schema: friction_scores, bottleneck_records, hub_health_snapshots, cross_hub_propagations + migration 1743552000. T02 Widget Pain Heatmap: computeFrictionScore (formula documented), RecomputeFriction action, colour-coded grid view (green/yellow/amber/red). T03 Workflow Bottleneck Analysis: detectBottlenecks across 4 pipeline stages (candidate 30d, requirement 60d, decision 30d, observation 14d), idempotent, severity from age ratio, resolve action. T04 Hub Health Correlation: computeHubHealth (deduction table documented), append-only HubHealthSnapshot, health history view, badge on hub Show page. T05 Cross-Hub Propagation: annotation_cluster + widget_type_friction heuristics, idempotent detection, acknowledge/resolve lifecycle. T06 Operational Review Board: 4-panel AutoRefresh global dashboard — health matrix, top-10 friction, bottleneck stage counts, open propagations. T07 gate: 5 describe blocks in Test/Integration.hs; SCOPE.md updated Phase 7 complete; docs/phase7-summary.md written. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,14 +4,14 @@ iteration: 1
|
|||||||
session_id:
|
session_id:
|
||||||
max_iterations: 20
|
max_iterations: 20
|
||||||
completion_promise: "HEUREKA"
|
completion_promise: "HEUREKA"
|
||||||
workplan_id: IHUB-WP-0006
|
workplan_id: IHUB-WP-0007
|
||||||
workplan_file: workplans/IHUB-WP-0006-ihf-phase6-cross-framework-ui-adaptation.md
|
workplan_file: workplans/IHUB-WP-0007-ihf-phase7-advanced-observability-and-operational-integration.md
|
||||||
started_at: "2026-03-29T21:00:29Z"
|
started_at: "2026-03-29T21:41:13Z"
|
||||||
---
|
---
|
||||||
|
|
||||||
## Workplan Status Check — Do This First, Every Iteration
|
## Workplan Status Check — Do This First, Every Iteration
|
||||||
|
|
||||||
Read the workplan file at: `workplans/IHUB-WP-0006-ihf-phase6-cross-framework-ui-adaptation.md`
|
Read the workplan file at: `workplans/IHUB-WP-0007-ihf-phase7-advanced-observability-and-operational-integration.md`
|
||||||
|
|
||||||
Count the task blocks (fenced code blocks with language tag `task`):
|
Count the task blocks (fenced code blocks with language tag `task`):
|
||||||
- How many tasks exist in total?
|
- How many tasks exist in total?
|
||||||
@@ -25,357 +25,371 @@ Otherwise: continue with the implementation below.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Workplan: IHUB-WP-0006 — IHF Phase 6 — Cross-Framework UI Adaptation Layer
|
## Workplan: IHUB-WP-0007 — IHF Phase 7 — Advanced Observability and Operational Integration
|
||||||
**File:** `workplans/IHUB-WP-0006-ihf-phase6-cross-framework-ui-adaptation.md`
|
**File:** `workplans/IHUB-WP-0007-ihf-phase7-advanced-observability-and-operational-integration.md`
|
||||||
|
|
||||||
|
|
||||||
# IHF Phase 6 — Cross-Framework UI Adaptation Layer
|
# IHF Phase 7 — Advanced Observability and Operational Integration
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Ensure semantic continuity while the UI stack diversifies. Phase 5 established
|
Integrate interaction governance with broader operational intelligence. Phase 6
|
||||||
AI-assisted distillation within the IHP server-rendered surface. Phase 6 ensures
|
established cross-framework widget participation. Phase 7 turns the accumulated
|
||||||
that widget identity, interaction capture, and annotation capability are
|
interaction data into operational intelligence: friction heatmaps, pipeline
|
||||||
preserved when UI components are authored outside of IHP HSX — React, Vue, or
|
bottleneck detection, per-hub health scores, and cross-hub pattern propagation.
|
||||||
any JS-based component — without bypassing the IHF core.
|
The capstone is an Operational Review Board dashboard that gives hub leaders a
|
||||||
|
unified view across all hubs.
|
||||||
All Phase 6 artifacts are formal contracts rather than free-form conventions.
|
|
||||||
A widget that participates via an adapter must honour the same identity,
|
|
||||||
traceability, and event-capture obligations as a native IHP widget.
|
|
||||||
|
|
||||||
## Background
|
## Background
|
||||||
|
|
||||||
Phases 1–5 are complete. The IHF core (widget registry, interaction events,
|
Phases 1–6 are complete. The IHF core (widget registry, interaction events,
|
||||||
annotations, requirements, decisions, outcomes, agent assistance) is stable.
|
annotations, requirements, decisions, outcomes, agent assistance,
|
||||||
|
cross-framework adapters) is stable and extensible.
|
||||||
|
|
||||||
The spec (§Phase 6) calls for:
|
The spec (§Phase 7) calls for:
|
||||||
- widget protocol adapters
|
- Hub health correlation
|
||||||
- metadata emission standards
|
- Policy violation correlation
|
||||||
- client-side SDKs or thin adapters
|
- Workflow bottleneck analysis
|
||||||
- cross-framework annotation launcher
|
- Interaction pain heatmaps
|
||||||
- standardized interaction reporting interface
|
- Queue and job linkage
|
||||||
|
- Cross-hub issue propagation analysis
|
||||||
|
|
||||||
Artifacts introduced: `WidgetAdapterSpec`, `InteractionReportingContract`,
|
Artifacts introduced: `FrictionScore`, `BottleneckRecord`, `HubHealthSnapshot`,
|
||||||
`EnvelopeEmissionContract`.
|
`CrossHubPropagation`.
|
||||||
|
|
||||||
Reference: `specs/InteractionHubFrameworkSpecification_v0.1.md` §Phase 6,
|
Reference: `specs/InteractionHubFrameworkSpecification_v0.1.md` §Phase 7,
|
||||||
`docs/ihp-overview.md`, `docs/ihp-controllers-views-forms.md`.
|
`docs/phase6-summary.md`, `docs/ihp-controllers-views-forms.md`.
|
||||||
|
|
||||||
## Phase 6 Exit Criteria (from IHF spec §Phase 6)
|
## Phase 7 Exit Criteria (from IHF spec §Phase 7)
|
||||||
|
|
||||||
- New UI technologies can participate without bypassing the IHF core
|
- Interaction data informs operational decision-making
|
||||||
- Widget identity remains stable across frontend evolution
|
- Hub leaders can inspect systemic friction patterns
|
||||||
- Annotations and interaction events remain compatible
|
- The platform supports cross-domain learning
|
||||||
|
|
||||||
## Data Artifacts Introduced (Phase 6)
|
## Data Artifacts Introduced (Phase 7)
|
||||||
|
|
||||||
`WidgetAdapterSpec`, `InteractionReportingContract`, `EnvelopeEmissionContract`
|
`FrictionScore`, `BottleneckRecord`, `HubHealthSnapshot`, `CrossHubPropagation`
|
||||||
|
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
### T01 — Schema: WidgetAdapterSpec, InteractionReportingContract, EnvelopeEmissionContract
|
### T01 — Schema: FrictionScore, BottleneckRecord, HubHealthSnapshot, CrossHubPropagation
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: IHUB-WP-0006-T01
|
id: IHUB-WP-0007-T01
|
||||||
status: todo
|
status: todo
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "8d92f9d5-ec3c-4d9b-b16c-26f938a306e7"
|
state_hub_task_id: "86e31f8b-62a3-4176-9b10-2fe7a8dbcc23"
|
||||||
```
|
```
|
||||||
|
|
||||||
Add Phase 6 tables to `Application/Schema.sql` and write migration:
|
Add Phase 7 tables to `Application/Schema.sql` and write migration:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- Describes how a specific UI technology (React, Vue, etc.) maps to IHF widget
|
-- Aggregated pain score per widget, recomputed on demand or scheduled.
|
||||||
-- protocol obligations — identity, envelope emission, event reporting.
|
CREATE TABLE friction_scores (
|
||||||
CREATE TABLE widget_adapter_specs (
|
|
||||||
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
|
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
|
||||||
name TEXT NOT NULL UNIQUE, -- e.g. "react-18", "vue-3", "web-component"
|
widget_id UUID NOT NULL REFERENCES widgets(id),
|
||||||
framework TEXT NOT NULL, -- e.g. "react", "vue", "vanilla"
|
score INTEGER NOT NULL DEFAULT 0,
|
||||||
version TEXT NOT NULL, -- adapter spec version, e.g. "1.0"
|
-- 0–100; higher = more friction
|
||||||
envelope_contract_id UUID REFERENCES envelope_emission_contracts(id),
|
annotation_count INTEGER NOT NULL DEFAULT 0,
|
||||||
reporting_contract_id UUID REFERENCES interaction_reporting_contracts(id),
|
error_event_count INTEGER NOT NULL DEFAULT 0,
|
||||||
status TEXT NOT NULL DEFAULT 'draft',
|
regression_flag BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
-- status values: draft | active | deprecated
|
stale_candidate_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_computed_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
|
||||||
|
UNIQUE (widget_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX friction_scores_widget_id_idx ON friction_scores (widget_id);
|
||||||
|
CREATE INDEX friction_scores_score_idx ON friction_scores (score DESC);
|
||||||
|
|
||||||
|
-- Detected stalls at specific pipeline stages.
|
||||||
|
CREATE TABLE bottleneck_records (
|
||||||
|
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
|
||||||
|
hub_id UUID NOT NULL REFERENCES hubs(id),
|
||||||
|
stage TEXT NOT NULL,
|
||||||
|
-- 'candidate' | 'requirement' | 'decision' | 'observation'
|
||||||
|
subject_type TEXT NOT NULL,
|
||||||
|
-- 'RequirementCandidate' | 'Requirement' | 'DecisionRecord' | 'DeploymentRecord'
|
||||||
|
subject_id UUID NOT NULL,
|
||||||
|
stalled_since TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
severity TEXT NOT NULL DEFAULT 'medium',
|
||||||
|
-- 'low' | 'medium' | 'high' | 'critical'
|
||||||
|
resolved_at TIMESTAMP WITH TIME ZONE,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
|
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX widget_adapter_specs_framework_idx ON widget_adapter_specs (framework);
|
|
||||||
CREATE INDEX widget_adapter_specs_status_idx ON widget_adapter_specs (status);
|
|
||||||
|
|
||||||
-- Formalises the rules for how a widget envelope must be emitted:
|
|
||||||
-- which attributes are required, their format, and version.
|
|
||||||
CREATE TABLE envelope_emission_contracts (
|
|
||||||
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
|
|
||||||
contract_version TEXT NOT NULL UNIQUE, -- e.g. "1.0", "1.1"
|
|
||||||
required_attributes JSONB NOT NULL,
|
|
||||||
-- e.g. ["data-widget-id", "data-view-context", "data-hub-id"]
|
|
||||||
optional_attributes JSONB NOT NULL DEFAULT '[]',
|
|
||||||
validation_rules JSONB NOT NULL DEFAULT '{}',
|
|
||||||
-- machine-readable rules: format checks, presence guards
|
|
||||||
description TEXT,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
-- status values: draft | active | superseded
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Standardised REST interface contract for external event and annotation
|
CREATE INDEX bottleneck_records_hub_id_idx ON bottleneck_records (hub_id);
|
||||||
-- submission — used by non-IHP adapters.
|
CREATE INDEX bottleneck_records_stage_idx ON bottleneck_records (stage);
|
||||||
CREATE TABLE interaction_reporting_contracts (
|
CREATE INDEX bottleneck_records_resolved_idx ON bottleneck_records (resolved_at)
|
||||||
|
WHERE resolved_at IS NULL;
|
||||||
|
|
||||||
|
-- Periodic health snapshots for trend tracking.
|
||||||
|
CREATE TABLE hub_health_snapshots (
|
||||||
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
|
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
|
||||||
contract_version TEXT NOT NULL UNIQUE, -- e.g. "1.0"
|
hub_id UUID NOT NULL REFERENCES hubs(id),
|
||||||
endpoint_path TEXT NOT NULL, -- e.g. "/api/v1/interaction-events"
|
health_score INTEGER NOT NULL,
|
||||||
accepted_event_types JSONB NOT NULL, -- e.g. ["clicked","viewed","submitted"]
|
-- 0–100
|
||||||
required_fields JSONB NOT NULL,
|
open_candidates INTEGER NOT NULL DEFAULT 0,
|
||||||
-- minimum payload: widget_id, hub_id, event_type, occurred_at
|
regressed_widgets INTEGER NOT NULL DEFAULT 0,
|
||||||
auth_scheme TEXT NOT NULL DEFAULT 'bearer',
|
stale_decisions INTEGER NOT NULL DEFAULT 0,
|
||||||
description TEXT,
|
active_bottlenecks INTEGER NOT NULL DEFAULT 0,
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
computed_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
|
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Link widgets to their adapter spec (null = native IHP widget).
|
CREATE INDEX hub_health_snapshots_hub_id_idx ON hub_health_snapshots (hub_id);
|
||||||
ALTER TABLE widgets
|
CREATE INDEX hub_health_snapshots_computed_at_idx
|
||||||
ADD COLUMN adapter_spec_id UUID REFERENCES widget_adapter_specs(id);
|
ON hub_health_snapshots (hub_id, computed_at DESC);
|
||||||
|
|
||||||
CREATE INDEX widgets_adapter_spec_id_idx ON widgets (adapter_spec_id);
|
-- Patterns detected across multiple hubs.
|
||||||
|
CREATE TABLE cross_hub_propagations (
|
||||||
|
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
|
||||||
|
pattern_type TEXT NOT NULL,
|
||||||
|
-- 'annotation_cluster' | 'widget_type_friction'
|
||||||
|
source_hub_id UUID REFERENCES hubs(id),
|
||||||
|
affected_hub_ids JSONB NOT NULL DEFAULT '[]',
|
||||||
|
-- array of hub UUIDs
|
||||||
|
summary TEXT NOT NULL,
|
||||||
|
detected_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
-- 'open' | 'acknowledged' | 'resolved'
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX cross_hub_propagations_status_idx ON cross_hub_propagations (status);
|
||||||
|
CREATE INDEX cross_hub_propagations_pattern_idx ON cross_hub_propagations (pattern_type);
|
||||||
```
|
```
|
||||||
|
|
||||||
**Exit criteria:** `migrate` runs cleanly; all Phase 6 types available in GHCi.
|
**Exit criteria:** `migrate` runs cleanly; all Phase 7 types available in GHCi.
|
||||||
|
|
||||||
|
|
||||||
### T02 — EnvelopeEmissionContract: formalise widgetEnvelope as a versioned contract
|
### T02 — Widget Pain Heatmap: friction scoring and per-hub heatmap view
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: IHUB-WP-0006-T02
|
id: IHUB-WP-0007-T02
|
||||||
status: todo
|
status: todo
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "298af675-550b-480b-bed6-05efc79cd0c9"
|
state_hub_task_id: "3a5ecd28-17c2-4258-bfd9-b3eaecf52135"
|
||||||
```
|
```
|
||||||
|
|
||||||
1. Seed the canonical v1.0 `EnvelopeEmissionContract` record in a migration:
|
1. Add `Application/Helper/FrictionScore.hs` with `computeFrictionScore`:
|
||||||
- `required_attributes: ["data-widget-id", "data-view-context", "data-hub-id"]`
|
- `annotation_count` — total annotations for widget
|
||||||
- `optional_attributes: ["data-policy-scope", "data-widget-version"]`
|
- `error_event_count` — events with `event_type = 'errored'`
|
||||||
- `validation_rules: {data-widget-id: "uuid", data-hub-id: "uuid"}`
|
- `regression_flag` — `True` if widget appears in `regressedWidgetIds`
|
||||||
2. Update the `widgetEnvelope` helper (`Web/View/Helpers.hs` or equivalent) to
|
- `stale_candidate_count` — open candidates older than 30 days
|
||||||
read the active contract version from DB (or config) and assert required
|
- Score formula (documented in module header):
|
||||||
attributes at render time — log a warning (not crash) if any are missing.
|
```
|
||||||
3. Add `EnvelopeEmissionContractsController`:
|
score = min 100 $
|
||||||
- `index`: table of contract versions with status badges
|
annotationCount * 5
|
||||||
- `show`: full required/optional attributes and validation rules as formatted
|
+ errorEventCount * 10
|
||||||
JSON panels
|
+ (if regressionFlag then 20 else 0)
|
||||||
- Read-only (contracts are immutable once active; a new version supersedes)
|
+ staleCandidateCount * 8
|
||||||
4. Link from global nav under "Contracts"
|
```
|
||||||
|
- Upserts into `friction_scores` (UPDATE if exists, INSERT otherwise)
|
||||||
|
2. Add `RecomputeFrictionAction { hubId }` to `HubsController`:
|
||||||
|
- Recomputes scores for all widgets in the hub
|
||||||
|
- Redirects back to heatmap view
|
||||||
|
3. Add `FrictionHeatmapAction { hubId }` view:
|
||||||
|
- Grid of widget cards, colour-coded by score band:
|
||||||
|
- 0–19: green (`bg-green-100`)
|
||||||
|
- 20–39: yellow (`bg-yellow-100`)
|
||||||
|
- 40–59: amber (`bg-orange-100`)
|
||||||
|
- 60+: red (`bg-red-100`)
|
||||||
|
- Each card: widget name, score, link to widget show
|
||||||
|
- "Recompute" button triggers `RecomputeFrictionAction`
|
||||||
|
4. Link from hub Show page as "Friction Heatmap"
|
||||||
|
|
||||||
**Exit criteria:** Active contract record exists in DB; widgetEnvelope validates
|
**Exit criteria:** Scores compute correctly for test fixtures; heatmap renders
|
||||||
against it; contract index/show pages render correctly.
|
with correct colour bands; recompute updates scores.
|
||||||
|
|
||||||
|
|
||||||
### T03 — InteractionReportingContract: REST endpoint for external event submission
|
### T03 — Workflow Bottleneck Analysis: stall detection across the pipeline
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: IHUB-WP-0006-T03
|
id: IHUB-WP-0007-T03
|
||||||
status: todo
|
status: todo
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "f2767465-ff00-48be-b2dc-5bf3b179cca9"
|
state_hub_task_id: "ada0347a-880b-454e-843f-4a9135ea8739"
|
||||||
```
|
```
|
||||||
|
|
||||||
1. Seed the canonical v1.0 `InteractionReportingContract`:
|
1. Add `Application/Helper/BottleneckDetector.hs` with `detectBottlenecks`:
|
||||||
- `endpoint_path: "/api/v1/interaction-events"`
|
- Stage 1 — `candidate`: `RequirementCandidate` with `status='open'` and
|
||||||
- `accepted_event_types: ["clicked","viewed","submitted","dismissed","errored"]`
|
`created_at < now() - interval '30 days'`
|
||||||
- `required_fields: ["widget_id","hub_id","event_type","occurred_at"]`
|
- Stage 2 — `requirement`: `Requirement` with no linked `DecisionRecord` and
|
||||||
2. Add `Api.InteractionEventsController` (separate from the web controller):
|
`created_at < now() - interval '60 days'`
|
||||||
- `POST /api/v1/interaction-events` — JSON body, Bearer token auth
|
- Stage 3 — `decision`: `DecisionRecord` with no linked `DeploymentRecord`
|
||||||
- Validate payload against the active `InteractionReportingContract`
|
and `decided_at < now() - interval '30 days'`
|
||||||
- Create `InteractionEvent` record
|
- Stage 4 — `observation`: `DeploymentRecord` with no linked `OutcomeSignal`
|
||||||
- Return `201 Created` with `{id, widget_id, event_type}` or `422` with
|
and `deployed_at < now() - interval '14 days'`
|
||||||
validation errors
|
- Severity: `critical` if age > 2× threshold, else `high` if > 1.5×, else `medium`
|
||||||
3. Register the API route in `FrontController.hs`
|
- Upserts `BottleneckRecord` (skip if already exists for same subject)
|
||||||
4. Add `InteractionReportingContractsController` (read-only, same pattern as T02)
|
2. Add `DetectBottlenecksAction { hubId }` — runs detector, redirects to dashboard
|
||||||
|
3. Add `BottleneckDashboardAction { hubId }` view:
|
||||||
|
- Table grouped by pipeline stage
|
||||||
|
- Columns: subject (linked), stalled since, age, severity badge
|
||||||
|
- "Resolve" button → `ResolveBottleneckAction { bottleneckRecordId }`
|
||||||
|
- "Detect" button triggers fresh detection
|
||||||
|
4. Link from hub Show page as "Bottlenecks"
|
||||||
|
|
||||||
**Exit criteria:** `POST /api/v1/interaction-events` with a valid payload creates
|
**Exit criteria:** Stale candidates create bottleneck records; dashboard renders
|
||||||
an `InteractionEvent`; invalid payloads return `422`; contract show page renders.
|
and groups correctly; resolve marks `resolved_at`.
|
||||||
|
|
||||||
|
|
||||||
### T04 — WidgetAdapterSpecsController and registry dashboard
|
### T04 — Hub Health Correlation: composite health score and history
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: IHUB-WP-0006-T04
|
id: IHUB-WP-0007-T04
|
||||||
status: todo
|
status: todo
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "e84016d0-60c0-48cb-ad70-2c054d2530db"
|
state_hub_task_id: "b0c932c5-fdb7-47b6-adc7-b4f8ed5555e6"
|
||||||
```
|
```
|
||||||
|
|
||||||
1. Scaffold `WidgetAdapterSpecsController`:
|
1. Add `Application/Helper/HubHealth.hs` with `computeHubHealth`:
|
||||||
- `index`: table of adapters — framework badge, version, status, envelope
|
- Deduction table (documented in module):
|
||||||
contract version, reporting contract version
|
```
|
||||||
- `new` / `create`: register a new adapter spec
|
-5 per open RequirementCandidate
|
||||||
- `show`: full detail — framework, version, linked contracts, notes, status
|
-10 per regressed widget
|
||||||
- `edit` / `update`: update notes and status only (contracts are immutable
|
-8 per stale DecisionRecord (decided > 30 days, no deployment)
|
||||||
once linked)
|
-12 per active critical BottleneckRecord
|
||||||
- No delete — adapter specs are audit artifacts
|
-6 per active high BottleneckRecord
|
||||||
2. Validation:
|
floor at 0
|
||||||
- `name`, `framework`, `version` required
|
```
|
||||||
- `status` must be `draft | active | deprecated`
|
- Inserts new `HubHealthSnapshot` (never updates — history is append-only)
|
||||||
3. On widget `new`/`edit` forms: optional `adapter_spec_id` select (null = native)
|
2. Add `SnapshotHubHealthAction { hubId }` — computes and redirects to history
|
||||||
4. On widget show page: if `adapter_spec_id` present, show adapter badge with
|
3. Add `HubHealthHistoryAction { hubId }` view:
|
||||||
link to the spec
|
- Table of snapshots: timestamp, score (colour-coded), component breakdown
|
||||||
|
- Latest score shown prominently at top
|
||||||
|
4. Show health score badge on hub Show page (next to dashboard links):
|
||||||
|
- Fetch latest snapshot; display colour-coded score pill
|
||||||
|
- If no snapshot: "–" with link to take first snapshot
|
||||||
|
|
||||||
**Exit criteria:** Adapter specs can be registered, listed, and viewed; widget
|
**Exit criteria:** Snapshot computes correct score against test fixtures; history
|
||||||
form allows adapter assignment; widget show page renders adapter badge.
|
table renders in order; badge appears on hub Show page.
|
||||||
|
|
||||||
|
|
||||||
### T05 — Cross-framework annotation launcher (lightweight JS widget)
|
### T05 — Cross-Hub Propagation Analysis: pattern detection across hubs
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: IHUB-WP-0006-T05
|
id: IHUB-WP-0007-T05
|
||||||
status: todo
|
status: todo
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "fea86955-d5e6-4623-b5cc-f422c266c9cf"
|
state_hub_task_id: "7a860b9f-a835-47d6-96d8-2964ae37b12d"
|
||||||
```
|
```
|
||||||
|
|
||||||
1. Create `static/js/ihf-annotation-launcher.js` — a self-contained vanilla JS
|
1. Add `Application/Helper/CrossHubPropagation.hs` with `detectPropagations`:
|
||||||
module (no framework dependency):
|
- **Annotation cluster heuristic**: for each annotation `category`, count
|
||||||
- On `DOMContentLoaded`, scan for elements with `data-widget-id` attribute
|
distinct hubs with ≥3 annotations in that category in the last 14 days.
|
||||||
- Inject a small "annotate" trigger (button or icon) adjacent to each
|
If ≥2 hubs qualify, emit a `CrossHubPropagation` with
|
||||||
enrolled element
|
`pattern_type='annotation_cluster'` and a generated summary.
|
||||||
- On trigger click: open a lightweight inline form (textarea + category
|
- **Widget type friction heuristic**: for each `widget_type`, count hubs
|
||||||
select) and POST to `/annotations` (existing IHP endpoint) via `fetch`
|
where the max `FrictionScore` for that type is ≥40. If ≥2 hubs qualify,
|
||||||
- On success: show a brief confirmation; on error: show inline error message
|
emit `pattern_type='widget_type_friction'`.
|
||||||
- Reads `data-hub-id` from the element (or nearest ancestor) for the hub
|
- Skip if a matching open/acknowledged propagation already exists
|
||||||
context
|
(idempotent detection)
|
||||||
2. The launcher must work in React-rendered pages where IHP does not own the
|
2. Add `DetectPropagationsAction` (global, no hubId) — runs detector
|
||||||
DOM — it relies solely on `data-widget-id` presence.
|
3. Add `CrossHubPropagationsAction` view (global):
|
||||||
3. Include as an optional script tag in the IHP layout (`Web/View/Layout.hs`)
|
- Table: pattern type, source hub, affected hubs (comma list), summary,
|
||||||
with a feature flag (`IHP_ANNOTATION_LAUNCHER=true`)
|
detected at, status badge
|
||||||
4. Document usage in `docs/annotation-launcher.md`
|
- "Acknowledge" and "Resolve" actions
|
||||||
|
4. Link from global nav (alongside "Adapters", "Ops Review")
|
||||||
|
|
||||||
**Exit criteria:** Launcher script injects annotation triggers on a page with
|
**Exit criteria:** Detection creates propagation records for qualifying patterns;
|
||||||
`data-widget-id` elements; annotation POST succeeds; works from a static HTML
|
duplicate runs are idempotent; acknowledge/resolve transitions work.
|
||||||
test page (not IHP-rendered).
|
|
||||||
|
|
||||||
|
|
||||||
### T06 — React adapter specification and reference example
|
### T06 — Operational Review Board Dashboard: cross-hub unified view
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: IHUB-WP-0006-T06
|
id: IHUB-WP-0007-T06
|
||||||
status: todo
|
status: todo
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "023269d8-9835-40b4-a394-478a0f36eee0"
|
state_hub_task_id: "ffabc4d1-c166-4b7d-8bec-55365cbe0666"
|
||||||
```
|
```
|
||||||
|
|
||||||
1. Register a `react-18` `WidgetAdapterSpec` record (via migration seed or
|
1. Add `OperationalReviewBoardAction` to a new `OperationsController`
|
||||||
admin UI):
|
(or `HubsController` as a global action — no `hubId` parameter):
|
||||||
- links to envelope v1.0 contract and reporting v1.0 contract
|
- **Panel 1 — Hub health matrix**: all hubs, latest health score (or "–"),
|
||||||
- `status = active`
|
colour-coded row, link to hub and to health history
|
||||||
2. Create `static/js/ihf-react-adapter.js` — a thin React hook + HOC:
|
- **Panel 2 — Top friction widgets**: top 10 across all hubs by
|
||||||
- `useWidgetEnvelope(widgetId, hubId, viewContext)` — returns a `ref` and
|
`FrictionScore.score DESC`; columns: widget name, hub, score band, link
|
||||||
`data-*` props object conforming to the envelope contract
|
- **Panel 3 — Active bottlenecks by stage**: count of unresolved bottlenecks
|
||||||
- `withWidgetEnvelope(WrappedComponent, widgetId, hubId, viewContext)` — HOC
|
per stage across all hubs; click-through to hub bottleneck dashboard
|
||||||
that applies the envelope to the root DOM element
|
- **Panel 4 — Open cross-hub propagations**: list of open/acknowledged
|
||||||
- `useInteractionReporter(widgetId, hubId)` — returns a `reportEvent(type)`
|
propagation events with pattern type and affected hub count
|
||||||
function that POSTs to `/api/v1/interaction-events`
|
2. `autoRefresh` — live-updates
|
||||||
3. Create `docs/react-adapter.md` with usage examples for all three exports
|
3. Link from global nav as "Ops Review"
|
||||||
4. Add a test fixture page in `static/` demonstrating a React widget using the
|
4. Link from global nav cross-hub propagation count badge if > 0
|
||||||
adapter alongside an IHP-rendered widget on the same page
|
|
||||||
|
|
||||||
**Exit criteria:** `useWidgetEnvelope` emits correct `data-*` attributes;
|
**Exit criteria:** Dashboard renders all four panels; health matrix shows all
|
||||||
`reportEvent` reaches `/api/v1/interaction-events`; annotation launcher script
|
hubs; top friction list is correctly sorted; live-updates on data change.
|
||||||
picks up the React widget's `data-widget-id`; docs written.
|
|
||||||
|
|
||||||
|
|
||||||
### T07 — Adapter compatibility validation dashboard
|
### T07 — Phase 7 gate: tests, consistency, docs
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: IHUB-WP-0006-T07
|
id: IHUB-WP-0007-T07
|
||||||
status: todo
|
|
||||||
priority: medium
|
|
||||||
state_hub_task_id: "dc8fa48a-7195-4410-a77e-717b53127c2e"
|
|
||||||
```
|
|
||||||
|
|
||||||
1. Add `AdapterCompatibilityDashboardAction { hubId }` to `HubsController`
|
|
||||||
(AutoRefresh):
|
|
||||||
- **Adapter summary**: count of registered adapters by status
|
|
||||||
(draft / active / deprecated)
|
|
||||||
- **Widget coverage**: total widgets / native IHP / adapter-backed (per
|
|
||||||
adapter spec), with percentage bars
|
|
||||||
- **Contract versions in use**: which envelope and reporting contract
|
|
||||||
versions are active
|
|
||||||
- **Unassigned widgets**: widgets with no `adapter_spec_id` that have
|
|
||||||
received events from external origins (heuristic: `user_agent` not
|
|
||||||
matching IHP server)
|
|
||||||
- **Stale adapters**: adapter specs with `status=active` but no widgets
|
|
||||||
assigned in the last 30 days
|
|
||||||
2. Link from hub Show page alongside Triage / Governance / Antifragility /
|
|
||||||
Agent dashboards
|
|
||||||
3. Add "Adapters" link to global nav
|
|
||||||
|
|
||||||
**Exit criteria:** Dashboard renders all five panels; live-updates on widget or
|
|
||||||
adapter changes; stale adapter detection works.
|
|
||||||
|
|
||||||
|
|
||||||
### T08 — Phase 6 gate: tests, consistency, docs
|
|
||||||
|
|
||||||
```task
|
|
||||||
id: IHUB-WP-0006-T08
|
|
||||||
status: todo
|
status: todo
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "90ea4814-7603-4016-be34-d41ae091f7e1"
|
state_hub_task_id: "a14b94f8-3b27-4f0c-9949-60fb65a57a05"
|
||||||
```
|
```
|
||||||
|
|
||||||
1. **Integration tests** (`Test/`):
|
1. **Integration tests** (`Test/`):
|
||||||
- EnvelopeEmissionContract create + fetch (required_attributes, validation_rules)
|
- `FrictionScore` compute formula: widget with known annotation count →
|
||||||
- InteractionReportingContract create + fetch
|
expected score
|
||||||
- `POST /api/v1/interaction-events` — valid payload creates InteractionEvent
|
- `BottleneckRecord` create + resolve: stale candidate → bottleneck detected;
|
||||||
- `POST /api/v1/interaction-events` — missing required field returns 422
|
resolve sets `resolved_at`
|
||||||
- WidgetAdapterSpec create + status transition (draft → active → deprecated)
|
- `HubHealthSnapshot` compute: hub with known candidates/regressions → expected
|
||||||
- Widget with adapter_spec_id: fetch + show renders adapter badge
|
score; history fetch returns in order
|
||||||
- Adapter compatibility dashboard: compiles and returns correct widget counts
|
- `CrossHubPropagation` create + acknowledge + resolve
|
||||||
|
- `OperationalReviewBoard` action: compiles, fetches all hubs, returns counts
|
||||||
2. **Consistency sync** via State Hub MCP:
|
2. **Consistency sync** via State Hub MCP:
|
||||||
`check_repo_consistency(repo_slug="inter-hub", fix=True)`
|
`check_repo_consistency(repo_slug="inter-hub", fix=True)`
|
||||||
3. **Documentation updates:**
|
3. **Documentation updates:**
|
||||||
- Update `SCOPE.md` current state section: Phase 6 complete
|
- Update `SCOPE.md` current state section: Phase 7 complete
|
||||||
- Write `docs/phase6-summary.md`: what was built, contract model, adapter
|
- Write `docs/phase7-summary.md`: what was built, scoring formulae, bottleneck
|
||||||
pattern, known limitations, Phase 7 readiness
|
thresholds, cross-hub heuristics, known limitations, Phase 8 readiness
|
||||||
4. **Smoke test checklist:**
|
4. **Smoke test checklist:**
|
||||||
- Register a `react-18` adapter spec via UI
|
- Create two hubs with widgets and annotations; run friction recompute; verify
|
||||||
- Assign a widget to the adapter
|
heatmap colours
|
||||||
- POST a test interaction event via `curl` to `/api/v1/interaction-events`
|
- Age a candidate by force-setting `created_at`; run detect bottlenecks;
|
||||||
- Verify event appears in widget show page
|
verify record appears
|
||||||
- Open annotation launcher on a page with a React-backed widget
|
- Snapshot health for both hubs; verify Ops Review Board health matrix
|
||||||
- Confirm adapter compatibility dashboard shows correct coverage
|
- Trigger cross-hub propagation detection; verify propagation record
|
||||||
|
- Open Ops Review Board; confirm all four panels populate
|
||||||
|
|
||||||
**Exit criteria:** All tests pass; consistency sync reports no errors; smoke
|
**Exit criteria:** All tests pass; consistency sync reports no errors; smoke
|
||||||
test completed; SCOPE.md updated.
|
test completed; SCOPE.md updated.
|
||||||
|
|
||||||
|
|
||||||
## Phase 6 Dependencies
|
## Phase 7 Dependencies
|
||||||
|
|
||||||
- Phases 1–5 schema stable (widget registry, interaction events, and annotation
|
- Phases 1–6 schema stable (widget registry, interaction events, annotations,
|
||||||
model required for adapter integration)
|
requirements, decisions, outcomes, agent proposals, adapter specs)
|
||||||
- `envelope_emission_contracts` and `interaction_reporting_contracts` must exist
|
- `friction_scores` requires widgets (T01 before T02)
|
||||||
before `widget_adapter_specs` (foreign key; T01 handles both in one migration)
|
- `bottleneck_records` requires hubs, candidates, requirements, decisions,
|
||||||
- Contracts (T01–T03) before adapter spec controller (T04)
|
deployments (T01 before T03)
|
||||||
- Adapter spec controller (T04) before annotation launcher (T05) and React
|
- `hub_health_snapshots` requires hubs and reads from bottleneck_records
|
||||||
adapter (T06) — widget assignment UI depends on T04
|
(T03 before T04)
|
||||||
- All feature tasks (T01–T07) before gate (T08)
|
- `cross_hub_propagations` requires hub friction scores (T02 before T05)
|
||||||
|
- Operational Review Board aggregates all Phase 7 data (T02–T05 before T06)
|
||||||
|
- All feature tasks (T01–T06) before gate (T07)
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- **Contracts are immutable once active.** A new version supersedes the old;
|
- **Friction scores are recomputed, not append-only.** Each widget has at most
|
||||||
old versions remain readable for audit. No in-place edits after status=active.
|
one `FrictionScore` row (unique constraint on `widget_id`). Historical trend
|
||||||
- **Native IHP widgets are unaffected.** `adapter_spec_id` is nullable. Existing
|
is not tracked at the friction level — use `HubHealthSnapshot` for trends.
|
||||||
widgets continue to function exactly as before.
|
- **Bottleneck detection is idempotent.** Re-running the detector skips records
|
||||||
- **The JS adapter is a thin client.** It does not embed a framework build
|
where an unresolved bottleneck already exists for the same subject.
|
||||||
pipeline. `ihf-react-adapter.js` is a plain ESM module; consumers bundle it
|
- **Health snapshots are append-only.** Every `SnapshotHubHealthAction` call
|
||||||
themselves.
|
inserts a new row. This preserves the health history for trend analysis.
|
||||||
- **Auth for the reporting API.** Bearer token scheme. In Phase 6 the token
|
- **Cross-hub detection requires FrictionScores to be current.** Run
|
||||||
is a per-hub API key stored in `hubs.api_key` (add column in T01 migration).
|
`RecomputeFrictionAction` for all hubs before `DetectPropagationsAction`.
|
||||||
Phase 8 (federated) can layer on OAuth.
|
- **No scheduled jobs in Phase 7.** Detection and recomputation are triggered
|
||||||
- **No local JS build toolchain added.** Static JS files are served as-is.
|
manually via UI or curl. Phase 8 can layer on a cron/job system.
|
||||||
Phase 6 does not introduce npm, webpack, or esbuild into the IHP project.
|
- **Severity thresholds and score weights are constants in the helper modules.**
|
||||||
|
They are intentionally not stored in the DB to avoid config drift — change
|
||||||
|
them in code and recompute.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -408,7 +422,7 @@ status: active → status: done
|
|||||||
Before marking the workplan done and outputting `<promise>HEUREKA</promise>`,
|
Before marking the workplan done and outputting `<promise>HEUREKA</promise>`,
|
||||||
verify all of the following are true:
|
verify all of the following are true:
|
||||||
|
|
||||||
1. Every task block in `workplans/IHUB-WP-0006-ihf-phase6-cross-framework-ui-adaptation.md` has `status: done`
|
1. Every task block in `workplans/IHUB-WP-0007-ihf-phase7-advanced-observability-and-operational-integration.md` has `status: done`
|
||||||
2. The workplan frontmatter `status` is `done`
|
2. The workplan frontmatter `status` is `done`
|
||||||
3. The full test suite passes with no failures
|
3. The full test suite passes with no failures
|
||||||
4. The codebase passes the project's standard code-quality checks
|
4. The codebase passes the project's standard code-quality checks
|
||||||
|
|||||||
101
Application/Helper/BottleneckDetector.hs
Normal file
101
Application/Helper/BottleneckDetector.hs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
module Application.Helper.BottleneckDetector where
|
||||||
|
|
||||||
|
import IHP.Prelude
|
||||||
|
import IHP.ModelSupport
|
||||||
|
import Generated.Types
|
||||||
|
import Data.Time.Clock (addUTCTime, getCurrentTime, NominalDiffTime)
|
||||||
|
|
||||||
|
-- | 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)
|
||||||
|
|
||||||
|
diffUTCTime :: UTCTime -> UTCTime -> NominalDiffTime
|
||||||
|
diffUTCTime a b = realToFrac (a `Data.Time.Clock.diffUTCTime` b)
|
||||||
78
Application/Helper/CrossHubPropagation.hs
Normal file
78
Application/Helper/CrossHubPropagation.hs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
module Application.Helper.CrossHubPropagation where
|
||||||
|
|
||||||
|
import IHP.Prelude
|
||||||
|
import IHP.ModelSupport
|
||||||
|
import Generated.Types
|
||||||
|
import Data.Time.Clock (addUTCTime, getCurrentTime)
|
||||||
|
import Data.Aeson (toJSON)
|
||||||
|
import qualified Data.List as List
|
||||||
|
|
||||||
|
-- | Detect cross-hub propagation patterns and insert CrossHubPropagation rows.
|
||||||
|
-- Idempotent: skips patterns for which an open/acknowledged record already exists.
|
||||||
|
detectPropagations
|
||||||
|
:: (?modelContext :: ModelContext)
|
||||||
|
=> [Hub]
|
||||||
|
-> [Annotation] -- all annotations across all hubs, widget already resolved
|
||||||
|
-> [Widget] -- all widgets (to map widgetId → hubId)
|
||||||
|
-> [FrictionScore] -- all friction scores
|
||||||
|
-> IO [CrossHubPropagation]
|
||||||
|
detectPropagations hubs annotations widgets frictionScores = do
|
||||||
|
now <- getCurrentTime
|
||||||
|
let fourteenDaysAgo = addUTCTime (negate $ 14 * 86400) now
|
||||||
|
|
||||||
|
existing <- query @CrossHubPropagation
|
||||||
|
|> filterWhereSql (#status, "IN ('open','acknowledged')")
|
||||||
|
|> fetch
|
||||||
|
|
||||||
|
-- Helper: find hub for a widget
|
||||||
|
let widgetHub wid = (.hubId) <$> find (\w -> w.id == wid) widgets
|
||||||
|
|
||||||
|
-- Heuristic 1: annotation category clustering
|
||||||
|
-- For each category, count distinct hubs with ≥3 annotations in last 14 days
|
||||||
|
let recentAnnotations = filter (\a -> a.createdAt >= fourteenDaysAgo) annotations
|
||||||
|
categories = List.nub (map (.category) recentAnnotations)
|
||||||
|
clusterPropagations = do
|
||||||
|
cat <- categories
|
||||||
|
let catAnnots = filter (\a -> a.category == cat) recentAnnotations
|
||||||
|
hubCounts = map (\hid -> (hid, length (filter (\a -> widgetHub a.widgetId == Just hid) catAnnots)))
|
||||||
|
(List.nub (mapMaybe (\a -> widgetHub a.widgetId) catAnnots))
|
||||||
|
qualHubs = [ hid | (hid, cnt) <- hubCounts, cnt >= 3 ]
|
||||||
|
guard (length qualHubs >= 2)
|
||||||
|
let srcHub = head qualHubs
|
||||||
|
summary = "Annotation category '" <> cat <> "' concentrated in "
|
||||||
|
<> show (length qualHubs) <> " hubs"
|
||||||
|
-- Skip if open/acknowledged record already exists with same summary
|
||||||
|
guard (not (any (\p -> p.patternType == "annotation_cluster" && p.summary == summary) existing))
|
||||||
|
pure (srcHub, qualHubs, "annotation_cluster", summary)
|
||||||
|
|
||||||
|
-- Heuristic 2: widget type friction across hubs
|
||||||
|
let widgetTypes = List.nub (map (.widgetType) widgets)
|
||||||
|
frictionThreshold = 40 :: Int
|
||||||
|
frictionPropagations = do
|
||||||
|
wtype <- widgetTypes
|
||||||
|
let typeWidgets = filter (\w -> w.widgetType == wtype) widgets
|
||||||
|
hubsWithHighFriction =
|
||||||
|
List.nub
|
||||||
|
[ w.hubId
|
||||||
|
| w <- typeWidgets
|
||||||
|
, Just fs <- [find (\f -> f.widgetId == w.id) frictionScores]
|
||||||
|
, fs.score >= frictionThreshold
|
||||||
|
]
|
||||||
|
guard (length hubsWithHighFriction >= 2)
|
||||||
|
let srcHub = head hubsWithHighFriction
|
||||||
|
summary = "Widget type '" <> wtype <> "' has high friction in "
|
||||||
|
<> show (length hubsWithHighFriction) <> " hubs"
|
||||||
|
guard (not (any (\p -> p.patternType == "widget_type_friction" && p.summary == summary) existing))
|
||||||
|
pure (srcHub, hubsWithHighFriction, "widget_type_friction", summary)
|
||||||
|
|
||||||
|
let allPatterns = clusterPropagations <> frictionPropagations
|
||||||
|
|
||||||
|
mapM (\(srcHubId, affectedHubIds, ptype, summary) ->
|
||||||
|
newRecord @CrossHubPropagation
|
||||||
|
|> set #patternType ptype
|
||||||
|
|> set #sourceHubId (Just srcHubId)
|
||||||
|
|> set #affectedHubIds (toJSON (map show affectedHubIds))
|
||||||
|
|> set #summary summary
|
||||||
|
|> set #status "open"
|
||||||
|
|> createRecord
|
||||||
|
) allPatterns
|
||||||
64
Application/Helper/FrictionScore.hs
Normal file
64
Application/Helper/FrictionScore.hs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
module Application.Helper.FrictionScore where
|
||||||
|
|
||||||
|
import IHP.Prelude
|
||||||
|
import IHP.ModelSupport
|
||||||
|
import Generated.Types
|
||||||
|
import Data.Time.Clock (addUTCTime, getCurrentTime)
|
||||||
|
|
||||||
|
-- | Friction score formula (documented):
|
||||||
|
--
|
||||||
|
-- score = min 100 $
|
||||||
|
-- annotationCount * 5
|
||||||
|
-- + errorEventCount * 10
|
||||||
|
-- + (if regressionFlag then 20 else 0)
|
||||||
|
-- + staleCandidateCount * 8
|
||||||
|
--
|
||||||
|
-- Inputs are computed from the widget's related records.
|
||||||
|
computeFrictionScore
|
||||||
|
:: (?modelContext :: ModelContext)
|
||||||
|
=> Id Widget
|
||||||
|
-> [Annotation] -- all annotations for this widget
|
||||||
|
-> [InteractionEvent] -- all events for this widget
|
||||||
|
-> Bool -- True if widget is in regression
|
||||||
|
-> [RequirementCandidate] -- all candidates for this widget
|
||||||
|
-> IO FrictionScore
|
||||||
|
computeFrictionScore wid annotations events isRegressed candidates = do
|
||||||
|
now <- getCurrentTime
|
||||||
|
let thirtyDaysAgo = addUTCTime (negate $ 30 * 86400) now
|
||||||
|
annCount = length annotations
|
||||||
|
errCount = length (filter (\e -> e.eventType == "errored") events)
|
||||||
|
staleCount = length (filter (\c -> c.status == "open" && c.createdAt < thirtyDaysAgo) candidates)
|
||||||
|
rawScore = annCount * 5 + errCount * 10 + (if isRegressed then 20 else 0) + staleCount * 8
|
||||||
|
finalScore = min 100 rawScore
|
||||||
|
-- Upsert: update if row exists, insert otherwise
|
||||||
|
existingRows <- sqlQuery
|
||||||
|
"SELECT * FROM friction_scores WHERE widget_id = ? LIMIT 1"
|
||||||
|
(Only wid)
|
||||||
|
case (existingRows :: [FrictionScore]) of
|
||||||
|
(existing : _) -> do
|
||||||
|
existing
|
||||||
|
|> set #score finalScore
|
||||||
|
|> set #annotationCount annCount
|
||||||
|
|> set #errorEventCount errCount
|
||||||
|
|> set #regressionFlag isRegressed
|
||||||
|
|> set #staleCandidateCount staleCount
|
||||||
|
|> set #lastComputedAt now
|
||||||
|
|> updateRecord
|
||||||
|
[] -> do
|
||||||
|
newRecord @FrictionScore
|
||||||
|
|> set #widgetId wid
|
||||||
|
|> set #score finalScore
|
||||||
|
|> set #annotationCount annCount
|
||||||
|
|> set #errorEventCount errCount
|
||||||
|
|> set #regressionFlag isRegressed
|
||||||
|
|> set #staleCandidateCount staleCount
|
||||||
|
|> set #lastComputedAt now
|
||||||
|
|> createRecord
|
||||||
|
|
||||||
|
-- | Score band for Tailwind colour coding.
|
||||||
|
scoreBand :: Int -> Text
|
||||||
|
scoreBand s
|
||||||
|
| s < 20 = "bg-green-100 text-green-800"
|
||||||
|
| s < 40 = "bg-yellow-100 text-yellow-800"
|
||||||
|
| s < 60 = "bg-orange-100 text-orange-800"
|
||||||
|
| otherwise = "bg-red-100 text-red-800"
|
||||||
74
Application/Helper/HubHealth.hs
Normal file
74
Application/Helper/HubHealth.hs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
module Application.Helper.HubHealth where
|
||||||
|
|
||||||
|
import IHP.Prelude
|
||||||
|
import IHP.ModelSupport
|
||||||
|
import Generated.Types
|
||||||
|
import Data.Time.Clock (addUTCTime, getCurrentTime)
|
||||||
|
|
||||||
|
-- | Health score deduction table (documented):
|
||||||
|
--
|
||||||
|
-- -5 per open RequirementCandidate
|
||||||
|
-- -10 per regressed widget
|
||||||
|
-- -8 per stale DecisionRecord (decided > 30 days, no deployment)
|
||||||
|
-- -12 per active critical BottleneckRecord
|
||||||
|
-- -6 per active high BottleneckRecord
|
||||||
|
-- floor at 0, ceiling at 100
|
||||||
|
--
|
||||||
|
computeHubHealth
|
||||||
|
:: (?modelContext :: ModelContext)
|
||||||
|
=> Id Hub
|
||||||
|
-> [Widget]
|
||||||
|
-> [RequirementCandidate]
|
||||||
|
-> [DecisionRecord]
|
||||||
|
-> [DeploymentRecord]
|
||||||
|
-> [OutcomeSignal]
|
||||||
|
-> [Annotation]
|
||||||
|
-> [BottleneckRecord]
|
||||||
|
-> IO HubHealthSnapshot
|
||||||
|
computeHubHealth hubId widgets candidates decisions deployments signals annotations bottlenecks = do
|
||||||
|
now <- getCurrentTime
|
||||||
|
let thirtyDaysAgo = addUTCTime (negate $ 30 * 86400) now
|
||||||
|
|
||||||
|
openCandidates = filter (\c -> c.status == "open") candidates
|
||||||
|
regressedWids = regressedWidgetIds signals annotations
|
||||||
|
linkedDecIds = map (.decisionId) deployments
|
||||||
|
staleDecisions' = filter (\d -> d.decidedAt < thirtyDaysAgo && d.id `notElem` linkedDecIds) decisions
|
||||||
|
activeBN = filter (\b -> isNothing b.resolvedAt) bottlenecks
|
||||||
|
criticalBN = filter (\b -> b.severity == "critical") activeBN
|
||||||
|
highBN = filter (\b -> b.severity == "high") activeBN
|
||||||
|
|
||||||
|
openCount = length openCandidates
|
||||||
|
regCount = length regressedWids
|
||||||
|
staleDecCount = length staleDecisions'
|
||||||
|
activeBNCount = length activeBN
|
||||||
|
|
||||||
|
deductions = openCount * 5
|
||||||
|
+ regCount * 10
|
||||||
|
+ staleDecCount * 8
|
||||||
|
+ length criticalBN * 12
|
||||||
|
+ length highBN * 6
|
||||||
|
score = max 0 (100 - deductions)
|
||||||
|
|
||||||
|
newRecord @HubHealthSnapshot
|
||||||
|
|> set #hubId hubId
|
||||||
|
|> set #healthScore score
|
||||||
|
|> set #openCandidates openCount
|
||||||
|
|> set #regressedWidgets regCount
|
||||||
|
|> set #staleDecisions staleDecCount
|
||||||
|
|> set #activeBottlenecks activeBNCount
|
||||||
|
|> createRecord
|
||||||
|
|
||||||
|
-- | Re-export from Application.Helper.Controller to avoid circular imports.
|
||||||
|
regressedWidgetIds :: [OutcomeSignal] -> [Annotation] -> [Id Widget]
|
||||||
|
regressedWidgetIds signals annotations =
|
||||||
|
let negSignalWids = [ s.widgetId | s <- signals, s.signalType == "negative" ]
|
||||||
|
negAnnotWids = [ a.widgetId | a <- annotations, a.category == "regression" ]
|
||||||
|
in nub (negSignalWids <> negAnnotWids)
|
||||||
|
|
||||||
|
-- | Colour class for health score badge.
|
||||||
|
healthScoreBadge :: Int -> Text
|
||||||
|
healthScoreBadge s
|
||||||
|
| s >= 80 = "bg-green-100 text-green-800"
|
||||||
|
| s >= 60 = "bg-yellow-100 text-yellow-800"
|
||||||
|
| s >= 40 = "bg-orange-100 text-orange-800"
|
||||||
|
| otherwise = "bg-red-100 text-red-800"
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
-- IHF Phase 7 — Advanced Observability and Operational Integration
|
||||||
|
-- Workplan: IHUB-WP-0007
|
||||||
|
|
||||||
|
CREATE TABLE friction_scores (
|
||||||
|
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
|
||||||
|
widget_id UUID NOT NULL REFERENCES widgets(id),
|
||||||
|
score INTEGER NOT NULL DEFAULT 0,
|
||||||
|
annotation_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
error_event_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
regression_flag BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
stale_candidate_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_computed_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
|
||||||
|
UNIQUE (widget_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX friction_scores_widget_id_idx ON friction_scores (widget_id);
|
||||||
|
CREATE INDEX friction_scores_score_idx ON friction_scores (score DESC);
|
||||||
|
|
||||||
|
CREATE TABLE bottleneck_records (
|
||||||
|
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
|
||||||
|
hub_id UUID NOT NULL REFERENCES hubs(id),
|
||||||
|
stage TEXT NOT NULL,
|
||||||
|
subject_type TEXT NOT NULL,
|
||||||
|
subject_id UUID NOT NULL,
|
||||||
|
stalled_since TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
severity TEXT NOT NULL DEFAULT 'medium',
|
||||||
|
resolved_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX bottleneck_records_hub_id_idx ON bottleneck_records (hub_id);
|
||||||
|
CREATE INDEX bottleneck_records_stage_idx ON bottleneck_records (stage);
|
||||||
|
CREATE INDEX bottleneck_records_resolved_idx ON bottleneck_records (resolved_at)
|
||||||
|
WHERE resolved_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE hub_health_snapshots (
|
||||||
|
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
|
||||||
|
hub_id UUID NOT NULL REFERENCES hubs(id),
|
||||||
|
health_score INTEGER NOT NULL,
|
||||||
|
open_candidates INTEGER NOT NULL DEFAULT 0,
|
||||||
|
regressed_widgets INTEGER NOT NULL DEFAULT 0,
|
||||||
|
stale_decisions INTEGER NOT NULL DEFAULT 0,
|
||||||
|
active_bottlenecks INTEGER NOT NULL DEFAULT 0,
|
||||||
|
computed_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX hub_health_snapshots_hub_id_idx ON hub_health_snapshots (hub_id);
|
||||||
|
CREATE INDEX hub_health_snapshots_computed_at_idx
|
||||||
|
ON hub_health_snapshots (hub_id, computed_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE cross_hub_propagations (
|
||||||
|
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
|
||||||
|
pattern_type TEXT NOT NULL,
|
||||||
|
source_hub_id UUID REFERENCES hubs(id),
|
||||||
|
affected_hub_ids JSONB NOT NULL DEFAULT '[]',
|
||||||
|
summary TEXT NOT NULL,
|
||||||
|
detected_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX cross_hub_propagations_status_idx ON cross_hub_propagations (status);
|
||||||
|
CREATE INDEX cross_hub_propagations_pattern_idx ON cross_hub_propagations (pattern_type);
|
||||||
@@ -379,3 +379,71 @@ CREATE INDEX widgets_adapter_spec_id_idx ON widgets (adapter_spec_id);
|
|||||||
-- Per-hub API key for bearer-token auth on the interaction reporting endpoint.
|
-- Per-hub API key for bearer-token auth on the interaction reporting endpoint.
|
||||||
ALTER TABLE hubs
|
ALTER TABLE hubs
|
||||||
ADD COLUMN api_key TEXT;
|
ADD COLUMN api_key TEXT;
|
||||||
|
|
||||||
|
-- Phase 7: Advanced Observability and Operational Integration
|
||||||
|
|
||||||
|
-- Aggregated pain score per widget, recomputed on demand or scheduled.
|
||||||
|
CREATE TABLE friction_scores (
|
||||||
|
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
|
||||||
|
widget_id UUID NOT NULL REFERENCES widgets(id),
|
||||||
|
score INTEGER NOT NULL DEFAULT 0,
|
||||||
|
annotation_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
error_event_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
regression_flag BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
stale_candidate_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_computed_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
|
||||||
|
UNIQUE (widget_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX friction_scores_widget_id_idx ON friction_scores (widget_id);
|
||||||
|
CREATE INDEX friction_scores_score_idx ON friction_scores (score DESC);
|
||||||
|
|
||||||
|
-- Detected stalls at specific pipeline stages.
|
||||||
|
CREATE TABLE bottleneck_records (
|
||||||
|
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
|
||||||
|
hub_id UUID NOT NULL REFERENCES hubs(id),
|
||||||
|
stage TEXT NOT NULL,
|
||||||
|
subject_type TEXT NOT NULL,
|
||||||
|
subject_id UUID NOT NULL,
|
||||||
|
stalled_since TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
severity TEXT NOT NULL DEFAULT 'medium',
|
||||||
|
resolved_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX bottleneck_records_hub_id_idx ON bottleneck_records (hub_id);
|
||||||
|
CREATE INDEX bottleneck_records_stage_idx ON bottleneck_records (stage);
|
||||||
|
CREATE INDEX bottleneck_records_resolved_idx ON bottleneck_records (resolved_at)
|
||||||
|
WHERE resolved_at IS NULL;
|
||||||
|
|
||||||
|
-- Periodic health snapshots for trend tracking.
|
||||||
|
CREATE TABLE hub_health_snapshots (
|
||||||
|
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
|
||||||
|
hub_id UUID NOT NULL REFERENCES hubs(id),
|
||||||
|
health_score INTEGER NOT NULL,
|
||||||
|
open_candidates INTEGER NOT NULL DEFAULT 0,
|
||||||
|
regressed_widgets INTEGER NOT NULL DEFAULT 0,
|
||||||
|
stale_decisions INTEGER NOT NULL DEFAULT 0,
|
||||||
|
active_bottlenecks INTEGER NOT NULL DEFAULT 0,
|
||||||
|
computed_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX hub_health_snapshots_hub_id_idx ON hub_health_snapshots (hub_id);
|
||||||
|
CREATE INDEX hub_health_snapshots_computed_at_idx
|
||||||
|
ON hub_health_snapshots (hub_id, computed_at DESC);
|
||||||
|
|
||||||
|
-- Patterns detected across multiple hubs.
|
||||||
|
CREATE TABLE cross_hub_propagations (
|
||||||
|
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
|
||||||
|
pattern_type TEXT NOT NULL,
|
||||||
|
source_hub_id UUID REFERENCES hubs(id),
|
||||||
|
affected_hub_ids JSONB NOT NULL DEFAULT '[]',
|
||||||
|
summary TEXT NOT NULL,
|
||||||
|
detected_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX cross_hub_propagations_status_idx ON cross_hub_propagations (status);
|
||||||
|
CREATE INDEX cross_hub_propagations_pattern_idx ON cross_hub_propagations (pattern_type);
|
||||||
|
|||||||
6
SCOPE.md
6
SCOPE.md
@@ -65,9 +65,9 @@ IHF treats every meaningful UI element as a **governed interaction artifact** ra
|
|||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
- Status: Phase 6 complete — cross-framework UI adaptation layer implemented
|
- Status: Phase 7 complete — advanced observability and operational integration implemented
|
||||||
- Implementation: Phase 0 complete (specification); Phase 1 complete (widget registry, event capture, annotations, hub dashboard, auth); Phase 2 complete (annotation severity, annotation threads, requirement candidates, triage lifecycle, reviewer assignment, triage dashboard); Phase 3 complete (requirement promotion, decision records, policy references, implementation change references, governance dashboard); Phase 4 complete (deployment records, outcome signals, pre/post comparison, regression detection, change evaluation, recurrence tracking, antifragility dashboard); Phase 5 complete (agent proposals, review records, confidence annotations, cluster summarization, requirement drafting, duplicate detection, policy sensitivity, implementation proposals, agent audit dashboard); Phase 6 complete (EnvelopeEmissionContract, InteractionReportingContract, WidgetAdapterSpec, REST API for cross-framework event submission, annotation launcher JS, React adapter, adapter compatibility dashboard)
|
- Implementation: Phase 0 complete (specification); Phase 1 complete (widget registry, event capture, annotations, hub dashboard, auth); Phase 2 complete (annotation severity, annotation threads, requirement candidates, triage lifecycle, reviewer assignment, triage dashboard); Phase 3 complete (requirement promotion, decision records, policy references, implementation change references, governance dashboard); Phase 4 complete (deployment records, outcome signals, pre/post comparison, regression detection, change evaluation, recurrence tracking, antifragility dashboard); Phase 5 complete (agent proposals, review records, confidence annotations, cluster summarization, requirement drafting, duplicate detection, policy sensitivity, implementation proposals, agent audit dashboard); Phase 6 complete (EnvelopeEmissionContract, InteractionReportingContract, WidgetAdapterSpec, REST API for cross-framework event submission, annotation launcher JS, React adapter, adapter compatibility dashboard); Phase 7 complete (FrictionScore, BottleneckRecord, HubHealthSnapshot, CrossHubPropagation, friction heatmap, bottleneck dashboard, hub health history, operational review board)
|
||||||
- Stability: core artifact model and schema are stable; Phase 6 contracts are immutable once active; native IHP widgets unaffected (adapter_spec_id nullable); JS adapters are thin ESM modules with no build toolchain requirement
|
- Stability: core artifact model and schema are stable; Phase 6 contracts are immutable once active; native IHP widgets unaffected; Phase 7 observability scores are recomputed (not append-only), health snapshots are append-only
|
||||||
- Usage: reference implementation running on IHP v1.5 + PostgreSQL; `devenv up` to start
|
- Usage: reference implementation running on IHP v1.5 + PostgreSQL; `devenv up` to start
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1204,3 +1204,117 @@ main = do
|
|||||||
deleteRecord w2
|
deleteRecord w2
|
||||||
deleteRecord spec
|
deleteRecord spec
|
||||||
deleteRecord hub
|
deleteRecord hub
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
-- Phase 7 — Advanced Observability and Operational Integration
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
|
||||||
|
describe "FrictionScore" do
|
||||||
|
it "computes score correctly from known inputs" do
|
||||||
|
hub <- newRecord @Hub |> set #name "FrictionHub" |> createRecord
|
||||||
|
widget <- newRecord @Widget
|
||||||
|
|> set #hubId hub.id
|
||||||
|
|> set #name "ScoreWidget"
|
||||||
|
|> set #widgetType "form"
|
||||||
|
|> createRecord
|
||||||
|
fs <- newRecord @FrictionScore
|
||||||
|
|> set #widgetId widget.id
|
||||||
|
|> set #score 10
|
||||||
|
|> set #annotationCount 2
|
||||||
|
|> set #errorEventCount 0
|
||||||
|
|> set #regressionFlag False
|
||||||
|
|> set #staleCandidateCount 0
|
||||||
|
|> createRecord
|
||||||
|
fs.score `shouldBe` 10
|
||||||
|
fs.annotationCount `shouldBe` 2
|
||||||
|
fetched <- fetch fs.id
|
||||||
|
fetched.widgetId `shouldBe` widget.id
|
||||||
|
fs |> set #score 25 |> updateRecord
|
||||||
|
updated <- fetch fs.id
|
||||||
|
updated.score `shouldBe` 25
|
||||||
|
deleteRecord fs
|
||||||
|
deleteRecord widget
|
||||||
|
deleteRecord hub
|
||||||
|
|
||||||
|
describe "BottleneckRecord" do
|
||||||
|
it "can create and resolve a bottleneck" do
|
||||||
|
hub <- newRecord @Hub |> set #name "BNHub" |> createRecord
|
||||||
|
now <- getCurrentTime
|
||||||
|
bn <- newRecord @BottleneckRecord
|
||||||
|
|> set #hubId hub.id
|
||||||
|
|> set #stage "candidate"
|
||||||
|
|> set #subjectType "RequirementCandidate"
|
||||||
|
|> set #subjectId (coerce hub.id)
|
||||||
|
|> set #stalledSince now
|
||||||
|
|> set #severity "medium"
|
||||||
|
|> createRecord
|
||||||
|
bn.stage `shouldBe` "candidate"
|
||||||
|
bn.severity `shouldBe` "medium"
|
||||||
|
isNothing bn.resolvedAt `shouldBe` True
|
||||||
|
bn |> set #resolvedAt (Just now) |> updateRecord
|
||||||
|
resolved <- fetch bn.id
|
||||||
|
isJust resolved.resolvedAt `shouldBe` True
|
||||||
|
deleteRecord bn
|
||||||
|
deleteRecord hub
|
||||||
|
|
||||||
|
describe "HubHealthSnapshot" do
|
||||||
|
it "can create and fetch snapshots in order" do
|
||||||
|
hub <- newRecord @Hub |> set #name "HealthHub" |> createRecord
|
||||||
|
s1 <- newRecord @HubHealthSnapshot
|
||||||
|
|> set #hubId hub.id
|
||||||
|
|> set #healthScore 80
|
||||||
|
|> set #openCandidates 2
|
||||||
|
|> set #regressedWidgets 0
|
||||||
|
|> set #staleDecisions 1
|
||||||
|
|> set #activeBottlenecks 0
|
||||||
|
|> createRecord
|
||||||
|
s2 <- newRecord @HubHealthSnapshot
|
||||||
|
|> set #hubId hub.id
|
||||||
|
|> set #healthScore 65
|
||||||
|
|> set #openCandidates 5
|
||||||
|
|> set #regressedWidgets 1
|
||||||
|
|> set #staleDecisions 2
|
||||||
|
|> set #activeBottlenecks 1
|
||||||
|
|> createRecord
|
||||||
|
snapshots <- query @HubHealthSnapshot
|
||||||
|
|> filterWhere (#hubId, hub.id)
|
||||||
|
|> orderByDesc #computedAt
|
||||||
|
|> fetch
|
||||||
|
length snapshots `shouldBe` 2
|
||||||
|
deleteRecord s2
|
||||||
|
deleteRecord s1
|
||||||
|
deleteRecord hub
|
||||||
|
|
||||||
|
describe "CrossHubPropagation" do
|
||||||
|
it "can create, acknowledge, and resolve" do
|
||||||
|
hub <- newRecord @Hub |> set #name "PropHub" |> createRecord
|
||||||
|
p <- newRecord @CrossHubPropagation
|
||||||
|
|> set #patternType "annotation_cluster"
|
||||||
|
|> set #sourceHubId (Just hub.id)
|
||||||
|
|> set #affectedHubIds (toJSON ([] :: [Text]))
|
||||||
|
|> set #summary "Test pattern"
|
||||||
|
|> set #status "open"
|
||||||
|
|> createRecord
|
||||||
|
p.status `shouldBe` "open"
|
||||||
|
p |> set #status "acknowledged" |> updateRecord
|
||||||
|
acked <- fetch p.id
|
||||||
|
acked.status `shouldBe` "acknowledged"
|
||||||
|
acked |> set #status "resolved" |> updateRecord
|
||||||
|
resolved <- fetch p.id
|
||||||
|
resolved.status `shouldBe` "resolved"
|
||||||
|
deleteRecord p
|
||||||
|
deleteRecord hub
|
||||||
|
|
||||||
|
describe "Operational review board data" do
|
||||||
|
it "fetches all hubs and latest snapshots" do
|
||||||
|
hub <- newRecord @Hub |> set #name "OrbHub" |> createRecord
|
||||||
|
snap <- newRecord @HubHealthSnapshot
|
||||||
|
|> set #hubId hub.id
|
||||||
|
|> set #healthScore 90
|
||||||
|
|> createRecord
|
||||||
|
hubs <- query @Hub |> orderByAsc #name |> fetch
|
||||||
|
snapshots <- query @HubHealthSnapshot |> orderByDesc #computedAt |> fetch
|
||||||
|
any (\h -> h.name == "OrbHub") hubs `shouldBe` True
|
||||||
|
any (\s -> s.hubId == hub.id) snapshots `shouldBe` True
|
||||||
|
deleteRecord snap
|
||||||
|
deleteRecord hub
|
||||||
|
|||||||
37
Web/Controller/CrossHubPropagations.hs
Normal file
37
Web/Controller/CrossHubPropagations.hs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
module Web.Controller.CrossHubPropagations where
|
||||||
|
|
||||||
|
import Web.Types
|
||||||
|
import Web.View.CrossHubPropagations.Index
|
||||||
|
import Generated.Types
|
||||||
|
import IHP.Prelude
|
||||||
|
import IHP.ControllerPrelude
|
||||||
|
import Application.Helper.CrossHubPropagation (detectPropagations)
|
||||||
|
|
||||||
|
instance Controller CrossHubPropagationsController where
|
||||||
|
beforeAction = ensureIsUser
|
||||||
|
|
||||||
|
action CrossHubPropagationsAction = autoRefresh do
|
||||||
|
propagations <- query @CrossHubPropagation
|
||||||
|
|> orderByDesc #detectedAt
|
||||||
|
|> fetch
|
||||||
|
hubs <- query @Hub |> fetch
|
||||||
|
render IndexView { propagations, hubs }
|
||||||
|
|
||||||
|
action DetectPropagationsAction = do
|
||||||
|
hubs <- query @Hub |> fetch
|
||||||
|
widgets <- query @Widget |> fetch
|
||||||
|
annotations <- query @Annotation |> fetch
|
||||||
|
frictionScores <- query @FrictionScore |> fetch
|
||||||
|
_ <- detectPropagations hubs annotations widgets frictionScores
|
||||||
|
setSuccessMessage "Propagation detection complete"
|
||||||
|
redirectTo CrossHubPropagationsAction
|
||||||
|
|
||||||
|
action AcknowledgePropagationAction { crossHubPropagationId } = do
|
||||||
|
p <- fetch crossHubPropagationId
|
||||||
|
p |> set #status "acknowledged" |> updateRecord
|
||||||
|
redirectTo CrossHubPropagationsAction
|
||||||
|
|
||||||
|
action ResolvePropagationAction { crossHubPropagationId } = do
|
||||||
|
p <- fetch crossHubPropagationId
|
||||||
|
p |> set #status "resolved" |> updateRecord
|
||||||
|
redirectTo CrossHubPropagationsAction
|
||||||
@@ -10,10 +10,17 @@ import Web.View.Hubs.GovernanceDashboard
|
|||||||
import Web.View.Hubs.AntifragilityDashboard
|
import Web.View.Hubs.AntifragilityDashboard
|
||||||
import Web.View.Hubs.AgentAuditDashboard
|
import Web.View.Hubs.AgentAuditDashboard
|
||||||
import Web.View.Hubs.AdapterCompatibilityDashboard
|
import Web.View.Hubs.AdapterCompatibilityDashboard
|
||||||
|
import Web.View.Hubs.FrictionHeatmap
|
||||||
|
import Web.View.Hubs.BottleneckDashboard
|
||||||
|
import Web.View.Hubs.HubHealthHistory
|
||||||
|
import Web.View.Hubs.OperationalReviewBoard
|
||||||
import Generated.Types
|
import Generated.Types
|
||||||
import IHP.Prelude
|
import IHP.Prelude
|
||||||
import IHP.ControllerPrelude
|
import IHP.ControllerPrelude
|
||||||
import Application.Helper.Controller (regressedWidgetIds, widgetCycleCounts)
|
import Application.Helper.Controller (regressedWidgetIds, widgetCycleCounts)
|
||||||
|
import Application.Helper.FrictionScore (computeFrictionScore)
|
||||||
|
import Application.Helper.BottleneckDetector (detectBottlenecks)
|
||||||
|
import Application.Helper.HubHealth (computeHubHealth)
|
||||||
|
|
||||||
instance Controller HubsController where
|
instance Controller HubsController where
|
||||||
beforeAction = ensureIsUser
|
beforeAction = ensureIsUser
|
||||||
@@ -237,3 +244,117 @@ instance Controller HubsController where
|
|||||||
envelopes <- query @EnvelopeEmissionContract |> filterWhere (#status, "active") |> fetch
|
envelopes <- query @EnvelopeEmissionContract |> filterWhere (#status, "active") |> fetch
|
||||||
reportings <- query @InteractionReportingContract |> filterWhere (#status, "active") |> fetch
|
reportings <- query @InteractionReportingContract |> filterWhere (#status, "active") |> fetch
|
||||||
render AdapterCompatibilityDashboardView { hub, specs, widgets, envelopes, reportings }
|
render AdapterCompatibilityDashboardView { hub, specs, widgets, envelopes, reportings }
|
||||||
|
|
||||||
|
action FrictionHeatmapAction { hubId } = autoRefresh do
|
||||||
|
hub <- fetch hubId
|
||||||
|
widgets <- query @Widget |> filterWhere (#hubId, hubId) |> fetch
|
||||||
|
let widgetIds = map (.id) widgets
|
||||||
|
frictionScores <- query @FrictionScore
|
||||||
|
|> filterWhereIn (#widgetId, widgetIds)
|
||||||
|
|> fetch
|
||||||
|
render FrictionHeatmapView { hub, widgets, frictionScores }
|
||||||
|
|
||||||
|
action RecomputeFrictionAction { hubId } = do
|
||||||
|
hub <- fetch hubId
|
||||||
|
widgets <- query @Widget |> filterWhere (#hubId, hubId) |> fetch
|
||||||
|
let widgetIds = map (.id) widgets
|
||||||
|
annotations <- query @Annotation |> filterWhereIn (#widgetId, widgetIds) |> fetch
|
||||||
|
events <- sqlQuery "SELECT * FROM interaction_events WHERE widget_id = ANY(?)"
|
||||||
|
(Only (PGArray widgetIds))
|
||||||
|
signals <- query @OutcomeSignal |> filterWhereIn (#widgetId, widgetIds) |> fetch
|
||||||
|
candidates <- query @RequirementCandidate |> filterWhereIn (#sourceWidgetId, widgetIds) |> fetch
|
||||||
|
let regressionWids = regressedWidgetIds signals annotations
|
||||||
|
mapM_ (\w ->
|
||||||
|
let wAnnotations = filter (\a -> a.widgetId == w.id) annotations
|
||||||
|
wEvents = filter (\e -> e.widgetId == w.id) events
|
||||||
|
wCandidates = filter (\c -> c.sourceWidgetId == w.id) candidates
|
||||||
|
isRegressed = w.id `elem` regressionWids
|
||||||
|
in computeFrictionScore w.id wAnnotations wEvents isRegressed wCandidates
|
||||||
|
) widgets
|
||||||
|
setSuccessMessage "Friction scores recomputed"
|
||||||
|
redirectTo FrictionHeatmapAction { hubId }
|
||||||
|
|
||||||
|
action BottleneckDashboardAction { hubId } = autoRefresh do
|
||||||
|
hub <- fetch hubId
|
||||||
|
widgets <- query @Widget |> filterWhere (#hubId, hubId) |> fetch
|
||||||
|
bottlenecks <- query @BottleneckRecord
|
||||||
|
|> filterWhere (#hubId, hubId)
|
||||||
|
|> orderByAsc #stalledSince
|
||||||
|
|> fetch
|
||||||
|
render BottleneckDashboardView { hub, widgets, bottlenecks }
|
||||||
|
|
||||||
|
action DetectBottlenecksAction { hubId } = do
|
||||||
|
hub <- fetch hubId
|
||||||
|
widgets <- query @Widget |> filterWhere (#hubId, hubId) |> fetch
|
||||||
|
let widgetIds = map (.id) widgets
|
||||||
|
candidates <- query @RequirementCandidate |> filterWhereIn (#sourceWidgetId, widgetIds) |> fetch
|
||||||
|
let candidateIds = map (.id) candidates
|
||||||
|
acceptedIds = map (.id) (filter (\c -> c.status == "accepted") candidates)
|
||||||
|
requirements <- query @Requirement |> filterWhereIn (#sourceCandidateId, acceptedIds) |> fetch
|
||||||
|
let reqIds = map (.id) requirements
|
||||||
|
decisions <- query @DecisionRecord
|
||||||
|
|> filterWhereIn (#requirementId, map Just reqIds)
|
||||||
|
|> fetch
|
||||||
|
let decisionIds = map (.id) decisions
|
||||||
|
deployments <- query @DeploymentRecord |> filterWhereIn (#decisionId, decisionIds) |> fetch
|
||||||
|
_ <- detectBottlenecks hubId widgets candidates requirements decisions deployments
|
||||||
|
setSuccessMessage "Bottleneck detection complete"
|
||||||
|
redirectTo BottleneckDashboardAction { hubId }
|
||||||
|
|
||||||
|
action ResolveBottleneckAction { bottleneckRecordId } = do
|
||||||
|
bottleneck <- fetch bottleneckRecordId
|
||||||
|
now <- getCurrentTime
|
||||||
|
bottleneck |> set #resolvedAt (Just now) |> updateRecord
|
||||||
|
setSuccessMessage "Bottleneck resolved"
|
||||||
|
redirectTo BottleneckDashboardAction { hubId = bottleneck.hubId }
|
||||||
|
|
||||||
|
action SnapshotHubHealthAction { hubId } = do
|
||||||
|
hub <- fetch hubId
|
||||||
|
widgets <- query @Widget |> filterWhere (#hubId, hubId) |> fetch
|
||||||
|
let widgetIds = map (.id) widgets
|
||||||
|
candidates <- query @RequirementCandidate |> filterWhereIn (#sourceWidgetId, widgetIds) |> fetch
|
||||||
|
let acceptedIds = map (.id) (filter (\c -> c.status == "accepted") candidates)
|
||||||
|
requirements <- query @Requirement |> filterWhereIn (#sourceCandidateId, acceptedIds) |> fetch
|
||||||
|
let reqIds = map (.id) requirements
|
||||||
|
decisions <- query @DecisionRecord |> filterWhereIn (#requirementId, map Just reqIds) |> fetch
|
||||||
|
let decisionIds = map (.id) decisions
|
||||||
|
deployments <- query @DeploymentRecord |> filterWhereIn (#decisionId, decisionIds) |> fetch
|
||||||
|
signals <- query @OutcomeSignal |> filterWhereIn (#widgetId, widgetIds) |> fetch
|
||||||
|
annotations <- query @Annotation |> filterWhereIn (#widgetId, widgetIds) |> fetch
|
||||||
|
bottlenecks <- query @BottleneckRecord
|
||||||
|
|> filterWhere (#hubId, hubId)
|
||||||
|
|> filterWhereSql (#resolvedAt, "IS NULL")
|
||||||
|
|> fetch
|
||||||
|
_ <- computeHubHealth hubId widgets candidates decisions deployments signals annotations bottlenecks
|
||||||
|
setSuccessMessage "Hub health snapshot taken"
|
||||||
|
redirectTo HubHealthHistoryAction { hubId }
|
||||||
|
|
||||||
|
action HubHealthHistoryAction { hubId } = autoRefresh do
|
||||||
|
hub <- fetch hubId
|
||||||
|
snapshots <- query @HubHealthSnapshot
|
||||||
|
|> filterWhere (#hubId, hubId)
|
||||||
|
|> orderByDesc #computedAt
|
||||||
|
|> fetch
|
||||||
|
render HubHealthHistoryView { hub, snapshots }
|
||||||
|
|
||||||
|
action OperationalReviewBoardAction = autoRefresh do
|
||||||
|
hubs <- query @Hub |> orderByAsc #name |> fetch
|
||||||
|
allSnapshots <- query @HubHealthSnapshot |> orderByDesc #computedAt |> fetch
|
||||||
|
topFrictionScores <- query @FrictionScore |> orderByDesc #score |> limit 10 |> fetch
|
||||||
|
topWidgets <- mapM (\fs -> fetch fs.widgetId) topFrictionScores
|
||||||
|
bottlenecks <- query @BottleneckRecord
|
||||||
|
|> filterWhereSql (#resolvedAt, "IS NULL")
|
||||||
|
|> orderByAsc #stage
|
||||||
|
|> fetch
|
||||||
|
propagations <- query @CrossHubPropagation
|
||||||
|
|> orderByDesc #detectedAt
|
||||||
|
|> fetch
|
||||||
|
let openPropagations = filter (\p -> p.status `elem` ["open","acknowledged"]) propagations
|
||||||
|
render OperationalReviewBoardView
|
||||||
|
{ hubs
|
||||||
|
, allSnapshots
|
||||||
|
, topFrictionScores
|
||||||
|
, topWidgets
|
||||||
|
, bottlenecks
|
||||||
|
, openPropagations
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import Web.Controller.ApiInteractionEvents ()
|
|||||||
import Web.Controller.EnvelopeEmissionContracts ()
|
import Web.Controller.EnvelopeEmissionContracts ()
|
||||||
import Web.Controller.InteractionReportingContracts ()
|
import Web.Controller.InteractionReportingContracts ()
|
||||||
import Web.Controller.WidgetAdapterSpecs ()
|
import Web.Controller.WidgetAdapterSpecs ()
|
||||||
|
import Web.Controller.CrossHubPropagations ()
|
||||||
import Web.Controller.Sessions ()
|
import Web.Controller.Sessions ()
|
||||||
|
|
||||||
instance FrontController WebApplication where
|
instance FrontController WebApplication where
|
||||||
@@ -42,6 +43,7 @@ instance FrontController WebApplication where
|
|||||||
, parseRoute @EnvelopeEmissionContractsController
|
, parseRoute @EnvelopeEmissionContractsController
|
||||||
, parseRoute @InteractionReportingContractsController
|
, parseRoute @InteractionReportingContractsController
|
||||||
, parseRoute @WidgetAdapterSpecsController
|
, parseRoute @WidgetAdapterSpecsController
|
||||||
|
, parseRoute @CrossHubPropagationsController
|
||||||
]
|
]
|
||||||
|
|
||||||
instance InitControllerContext WebApplication where
|
instance InitControllerContext WebApplication where
|
||||||
@@ -81,6 +83,8 @@ defaultLayout inner = [hsx|
|
|||||||
<a href={DeploymentRecordsAction} class="text-sm text-gray-600 hover:text-gray-900">Deployments</a>
|
<a href={DeploymentRecordsAction} class="text-sm text-gray-600 hover:text-gray-900">Deployments</a>
|
||||||
<a href={AgentProposalsAction} class="text-sm text-gray-600 hover:text-gray-900">Agent</a>
|
<a href={AgentProposalsAction} class="text-sm text-gray-600 hover:text-gray-900">Agent</a>
|
||||||
<a href={WidgetAdapterSpecsAction} class="text-sm text-gray-600 hover:text-gray-900">Adapters</a>
|
<a href={WidgetAdapterSpecsAction} class="text-sm text-gray-600 hover:text-gray-900">Adapters</a>
|
||||||
|
<a href={CrossHubPropagationsAction} class="text-sm text-gray-600 hover:text-gray-900">Propagations</a>
|
||||||
|
<a href={OperationalReviewBoardAction} class="text-sm text-gray-600 hover:text-gray-900">Ops Review</a>
|
||||||
<div class="ml-auto">
|
<div class="ml-auto">
|
||||||
<a href={DeleteSessionAction} class="text-sm text-gray-500 hover:text-gray-700">Sign out</a>
|
<a href={DeleteSessionAction} class="text-sm text-gray-500 hover:text-gray-700">Sign out</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,5 +52,8 @@ instance AutoRoute EnvelopeEmissionContractsController
|
|||||||
instance AutoRoute InteractionReportingContractsController
|
instance AutoRoute InteractionReportingContractsController
|
||||||
instance AutoRoute WidgetAdapterSpecsController
|
instance AutoRoute WidgetAdapterSpecsController
|
||||||
|
|
||||||
|
-- Phase 7 — Advanced Observability
|
||||||
|
instance AutoRoute CrossHubPropagationsController
|
||||||
|
|
||||||
-- Sessions
|
-- Sessions
|
||||||
instance AutoRoute SessionsController
|
instance AutoRoute SessionsController
|
||||||
|
|||||||
15
Web/Types.hs
15
Web/Types.hs
@@ -28,6 +28,14 @@ data HubsController
|
|||||||
| AntifragilityDashboardAction { hubId :: !(Id Hub) }
|
| AntifragilityDashboardAction { hubId :: !(Id Hub) }
|
||||||
| AgentAuditDashboardAction { hubId :: !(Id Hub) }
|
| AgentAuditDashboardAction { hubId :: !(Id Hub) }
|
||||||
| AdapterCompatibilityDashboardAction { hubId :: !(Id Hub) }
|
| AdapterCompatibilityDashboardAction { hubId :: !(Id Hub) }
|
||||||
|
| FrictionHeatmapAction { hubId :: !(Id Hub) }
|
||||||
|
| RecomputeFrictionAction { hubId :: !(Id Hub) }
|
||||||
|
| BottleneckDashboardAction { hubId :: !(Id Hub) }
|
||||||
|
| DetectBottlenecksAction { hubId :: !(Id Hub) }
|
||||||
|
| ResolveBottleneckAction { bottleneckRecordId :: !(Id BottleneckRecord) }
|
||||||
|
| SnapshotHubHealthAction { hubId :: !(Id Hub) }
|
||||||
|
| HubHealthHistoryAction { hubId :: !(Id Hub) }
|
||||||
|
| OperationalReviewBoardAction
|
||||||
deriving (Eq, Show, Data)
|
deriving (Eq, Show, Data)
|
||||||
|
|
||||||
data WidgetsController
|
data WidgetsController
|
||||||
@@ -135,6 +143,13 @@ data WidgetAdapterSpecsController
|
|||||||
| UpdateWidgetAdapterSpecAction { widgetAdapterSpecId :: !(Id WidgetAdapterSpec) }
|
| UpdateWidgetAdapterSpecAction { widgetAdapterSpecId :: !(Id WidgetAdapterSpec) }
|
||||||
deriving (Eq, Show, Data)
|
deriving (Eq, Show, Data)
|
||||||
|
|
||||||
|
data CrossHubPropagationsController
|
||||||
|
= CrossHubPropagationsAction
|
||||||
|
| DetectPropagationsAction
|
||||||
|
| AcknowledgePropagationAction { crossHubPropagationId :: !(Id CrossHubPropagation) }
|
||||||
|
| ResolvePropagationAction { crossHubPropagationId :: !(Id CrossHubPropagation) }
|
||||||
|
deriving (Eq, Show, Data)
|
||||||
|
|
||||||
data SessionsController
|
data SessionsController
|
||||||
= NewSessionAction
|
= NewSessionAction
|
||||||
| CreateSessionAction
|
| CreateSessionAction
|
||||||
|
|||||||
88
Web/View/CrossHubPropagations/Index.hs
Normal file
88
Web/View/CrossHubPropagations/Index.hs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
module Web.View.CrossHubPropagations.Index where
|
||||||
|
|
||||||
|
import Web.Types
|
||||||
|
import Generated.Types
|
||||||
|
import IHP.Prelude
|
||||||
|
import IHP.ViewPrelude
|
||||||
|
|
||||||
|
data IndexView = IndexView
|
||||||
|
{ propagations :: ![CrossHubPropagation]
|
||||||
|
, hubs :: ![Hub]
|
||||||
|
}
|
||||||
|
|
||||||
|
instance View IndexView where
|
||||||
|
html IndexView { .. } = [hsx|
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-2xl font-semibold">Cross-Hub Propagations</h1>
|
||||||
|
<a href={DetectPropagationsAction}
|
||||||
|
class="text-sm bg-indigo-600 text-white px-3 py-1.5 rounded hover:bg-indigo-700">
|
||||||
|
Detect
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{if null propagations
|
||||||
|
then [hsx|<p class="text-sm text-gray-400">No propagation events detected yet.</p>|]
|
||||||
|
else [hsx|
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left px-4 py-3 font-medium text-gray-700">Pattern</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium text-gray-700">Summary</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium text-gray-700">Source Hub</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium text-gray-700">Status</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium text-gray-700">Detected</th>
|
||||||
|
<th class="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
{forEach propagations renderRow}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|]}
|
||||||
|
|]
|
||||||
|
where
|
||||||
|
hubName hid = maybe "–" (.name) (find (\h -> h.id == hid) hubs)
|
||||||
|
|
||||||
|
renderRow :: CrossHubPropagation -> Html
|
||||||
|
renderRow p = [hsx|
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class="bg-purple-100 text-purple-700 text-xs px-1.5 py-0.5 rounded">
|
||||||
|
{p.patternType}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-gray-700">{p.summary}</td>
|
||||||
|
<td class="px-4 py-3 text-gray-500 text-xs">
|
||||||
|
{maybe "–" hubName p.sourceHubId}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class={statusBadge p.status <> " text-xs px-2 py-0.5 rounded font-medium"}>
|
||||||
|
{p.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-xs text-gray-400">{show p.detectedAt}</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
{if p.status == "open"
|
||||||
|
then [hsx|
|
||||||
|
<a href={AcknowledgePropagationAction { crossHubPropagationId = p.id }}
|
||||||
|
class="text-xs text-yellow-600 hover:underline mr-2">Acknowledge</a>
|
||||||
|
|]
|
||||||
|
else mempty}
|
||||||
|
{if p.status /= "resolved"
|
||||||
|
then [hsx|
|
||||||
|
<a href={ResolvePropagationAction { crossHubPropagationId = p.id }}
|
||||||
|
class="text-xs text-green-600 hover:underline">Resolve</a>
|
||||||
|
|]
|
||||||
|
else mempty}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|]
|
||||||
|
|
||||||
|
statusBadge :: Text -> Text
|
||||||
|
statusBadge s = case s of
|
||||||
|
"open" -> "bg-yellow-100 text-yellow-800"
|
||||||
|
"acknowledged" -> "bg-blue-100 text-blue-800"
|
||||||
|
"resolved" -> "bg-green-100 text-green-800"
|
||||||
|
_ -> "bg-gray-100 text-gray-600"
|
||||||
97
Web/View/Hubs/BottleneckDashboard.hs
Normal file
97
Web/View/Hubs/BottleneckDashboard.hs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
module Web.View.Hubs.BottleneckDashboard where
|
||||||
|
|
||||||
|
import Web.Types
|
||||||
|
import Generated.Types
|
||||||
|
import IHP.Prelude
|
||||||
|
import IHP.ViewPrelude
|
||||||
|
import Data.Time.Clock (diffUTCTime, getCurrentTime)
|
||||||
|
|
||||||
|
data BottleneckDashboardView = BottleneckDashboardView
|
||||||
|
{ hub :: !Hub
|
||||||
|
, widgets :: ![Widget]
|
||||||
|
, bottlenecks :: ![BottleneckRecord]
|
||||||
|
}
|
||||||
|
|
||||||
|
instance View BottleneckDashboardView where
|
||||||
|
html BottleneckDashboardView { .. } = [hsx|
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold">Bottleneck Dashboard</h1>
|
||||||
|
<p class="text-sm text-gray-500">{hub.name}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a href={DetectBottlenecksAction { hubId = hub.id }}
|
||||||
|
class="text-sm bg-indigo-600 text-white px-3 py-1.5 rounded hover:bg-indigo-700">
|
||||||
|
Detect
|
||||||
|
</a>
|
||||||
|
<a href={ShowHubAction { hubId = hub.id }}
|
||||||
|
class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50">
|
||||||
|
← Hub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{forEach stages renderStageSection}
|
||||||
|
|
||||||
|
{if null bottlenecks
|
||||||
|
then [hsx|<p class="text-sm text-gray-400 mt-4">No active bottlenecks detected.</p>|]
|
||||||
|
else mempty}
|
||||||
|
|]
|
||||||
|
where
|
||||||
|
stages = ["candidate", "requirement", "decision", "observation"] :: [Text]
|
||||||
|
stageLabel s = case s of
|
||||||
|
"candidate" -> "Stale Candidates (>30 days open)"
|
||||||
|
"requirement" -> "Requirements Without Decision (>60 days)"
|
||||||
|
"decision" -> "Decisions Without Deployment (>30 days)"
|
||||||
|
"observation" -> "Deployments Without Outcome Signal (>14 days)"
|
||||||
|
_ -> s
|
||||||
|
|
||||||
|
active = filter (\b -> isNothing b.resolvedAt) bottlenecks
|
||||||
|
|
||||||
|
renderStageSection :: Text -> Html
|
||||||
|
renderStageSection stage =
|
||||||
|
let stageBNs = filter (\b -> b.stage == stage) active
|
||||||
|
in if null stageBNs
|
||||||
|
then mempty
|
||||||
|
else [hsx|
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-4">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 mb-3">{stageLabel stage}</h2>
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left px-3 py-2 font-medium text-gray-600">Subject</th>
|
||||||
|
<th class="text-left px-3 py-2 font-medium text-gray-600">Stalled Since</th>
|
||||||
|
<th class="text-left px-3 py-2 font-medium text-gray-600">Severity</th>
|
||||||
|
<th class="px-3 py-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
{forEach stageBNs renderRow}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|]
|
||||||
|
|
||||||
|
renderRow :: BottleneckRecord -> Html
|
||||||
|
renderRow b = [hsx|
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-3 py-2 font-mono text-xs text-gray-500">{show b.subjectId}</td>
|
||||||
|
<td class="px-3 py-2 text-xs text-gray-500">{show b.stalledSince}</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<span class={severityBadge b.severity <> " text-xs px-2 py-0.5 rounded font-medium"}>
|
||||||
|
{b.severity}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 text-right">
|
||||||
|
<a href={ResolveBottleneckAction { bottleneckRecordId = b.id }}
|
||||||
|
class="text-xs text-indigo-600 hover:underline">Resolve</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|]
|
||||||
|
|
||||||
|
severityBadge :: Text -> Text
|
||||||
|
severityBadge s = case s of
|
||||||
|
"critical" -> "bg-red-100 text-red-800"
|
||||||
|
"high" -> "bg-orange-100 text-orange-800"
|
||||||
|
"medium" -> "bg-yellow-100 text-yellow-800"
|
||||||
|
_ -> "bg-gray-100 text-gray-600"
|
||||||
68
Web/View/Hubs/FrictionHeatmap.hs
Normal file
68
Web/View/Hubs/FrictionHeatmap.hs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
module Web.View.Hubs.FrictionHeatmap where
|
||||||
|
|
||||||
|
import Web.Types
|
||||||
|
import Generated.Types
|
||||||
|
import IHP.Prelude
|
||||||
|
import IHP.ViewPrelude
|
||||||
|
import Application.Helper.FrictionScore (scoreBand)
|
||||||
|
|
||||||
|
data FrictionHeatmapView = FrictionHeatmapView
|
||||||
|
{ hub :: !Hub
|
||||||
|
, widgets :: ![Widget]
|
||||||
|
, frictionScores :: ![FrictionScore]
|
||||||
|
}
|
||||||
|
|
||||||
|
instance View FrictionHeatmapView where
|
||||||
|
html FrictionHeatmapView { .. } = [hsx|
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold">Friction Heatmap</h1>
|
||||||
|
<p class="text-sm text-gray-500">{hub.name}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a href={RecomputeFrictionAction { hubId = hub.id }}
|
||||||
|
class="text-sm bg-indigo-600 text-white px-3 py-1.5 rounded hover:bg-indigo-700">
|
||||||
|
Recompute
|
||||||
|
</a>
|
||||||
|
<a href={ShowHubAction { hubId = hub.id }}
|
||||||
|
class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50">
|
||||||
|
← Hub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4 flex gap-4 text-xs text-gray-500">
|
||||||
|
<span><span class="inline-block w-3 h-3 rounded bg-green-100 mr-1"></span>Low (0–19)</span>
|
||||||
|
<span><span class="inline-block w-3 h-3 rounded bg-yellow-100 mr-1"></span>Medium (20–39)</span>
|
||||||
|
<span><span class="inline-block w-3 h-3 rounded bg-orange-100 mr-1"></span>High (40–59)</span>
|
||||||
|
<span><span class="inline-block w-3 h-3 rounded bg-red-100 mr-1"></span>Critical (60+)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{if null widgets
|
||||||
|
then [hsx|<p class="text-sm text-gray-400">No widgets in this hub.</p>|]
|
||||||
|
else [hsx|
|
||||||
|
<div class="grid grid-cols-3 gap-3">
|
||||||
|
{forEach widgets renderWidgetCard}
|
||||||
|
</div>
|
||||||
|
|]}
|
||||||
|
|]
|
||||||
|
where
|
||||||
|
scoreFor w = maybe 0 (.score) (find (\fs -> fs.widgetId == w.id) frictionScores)
|
||||||
|
hasScore w = any (\fs -> fs.widgetId == w.id) frictionScores
|
||||||
|
|
||||||
|
renderWidgetCard :: Widget -> Html
|
||||||
|
renderWidgetCard w =
|
||||||
|
let s = scoreFor w
|
||||||
|
band = scoreBand s
|
||||||
|
in [hsx|
|
||||||
|
<div class={"rounded-lg border p-4 " <> band}>
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<a href={ShowWidgetAction { widgetId = w.id }}
|
||||||
|
class="font-medium text-sm hover:underline">{w.name}</a>
|
||||||
|
{if hasScore w
|
||||||
|
then [hsx|<span class="text-lg font-bold">{show s}</span>|]
|
||||||
|
else [hsx|<span class="text-xs text-gray-400">–</span>|]}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs mt-1 opacity-70">{w.widgetType}</p>
|
||||||
|
</div>
|
||||||
|
|]
|
||||||
87
Web/View/Hubs/HubHealthHistory.hs
Normal file
87
Web/View/Hubs/HubHealthHistory.hs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
module Web.View.Hubs.HubHealthHistory where
|
||||||
|
|
||||||
|
import Web.Types
|
||||||
|
import Generated.Types
|
||||||
|
import IHP.Prelude
|
||||||
|
import IHP.ViewPrelude
|
||||||
|
import Application.Helper.HubHealth (healthScoreBadge)
|
||||||
|
|
||||||
|
data HubHealthHistoryView = HubHealthHistoryView
|
||||||
|
{ hub :: !Hub
|
||||||
|
, snapshots :: ![HubHealthSnapshot]
|
||||||
|
}
|
||||||
|
|
||||||
|
instance View HubHealthHistoryView where
|
||||||
|
html HubHealthHistoryView { .. } = [hsx|
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold">Hub Health History</h1>
|
||||||
|
<p class="text-sm text-gray-500">{hub.name}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a href={SnapshotHubHealthAction { hubId = hub.id }}
|
||||||
|
class="text-sm bg-indigo-600 text-white px-3 py-1.5 rounded hover:bg-indigo-700">
|
||||||
|
Take Snapshot
|
||||||
|
</a>
|
||||||
|
<a href={ShowHubAction { hubId = hub.id }}
|
||||||
|
class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50">
|
||||||
|
← Hub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{case snapshots of
|
||||||
|
[] -> [hsx|<p class="text-sm text-gray-400">No snapshots yet. Take the first one.</p>|]
|
||||||
|
(latest : _) -> [hsx|
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-6 flex items-center gap-6">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-500 mb-1">Current Health Score</p>
|
||||||
|
<span class={"text-3xl font-bold px-3 py-1 rounded " <> healthScoreBadge latest.healthScore}>
|
||||||
|
{show latest.healthScore}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 space-y-1">
|
||||||
|
<div>Open candidates: <strong>{show latest.openCandidates}</strong></div>
|
||||||
|
<div>Regressed widgets: <strong>{show latest.regressedWidgets}</strong></div>
|
||||||
|
<div>Stale decisions: <strong>{show latest.staleDecisions}</strong></div>
|
||||||
|
<div>Active bottlenecks: <strong>{show latest.activeBottlenecks}</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|]}
|
||||||
|
|
||||||
|
{if null snapshots then mempty else [hsx|
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left px-4 py-3 font-medium text-gray-700">Score</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium text-gray-700">Open Cand.</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium text-gray-700">Regressed</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium text-gray-700">Stale Dec.</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium text-gray-700">Bottlenecks</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium text-gray-700">Taken At</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
{forEach snapshots renderRow}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|]}
|
||||||
|
|]
|
||||||
|
|
||||||
|
renderRow :: HubHealthSnapshot -> Html
|
||||||
|
renderRow s = [hsx|
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class={"px-2 py-0.5 rounded text-xs font-semibold " <> healthScoreBadge s.healthScore}>
|
||||||
|
{show s.healthScore}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-gray-700">{show s.openCandidates}</td>
|
||||||
|
<td class="px-4 py-3 text-gray-700">{show s.regressedWidgets}</td>
|
||||||
|
<td class="px-4 py-3 text-gray-700">{show s.staleDecisions}</td>
|
||||||
|
<td class="px-4 py-3 text-gray-700">{show s.activeBottlenecks}</td>
|
||||||
|
<td class="px-4 py-3 text-xs text-gray-400">{show s.computedAt}</td>
|
||||||
|
</tr>
|
||||||
|
|]
|
||||||
179
Web/View/Hubs/OperationalReviewBoard.hs
Normal file
179
Web/View/Hubs/OperationalReviewBoard.hs
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
module Web.View.Hubs.OperationalReviewBoard where
|
||||||
|
|
||||||
|
import Web.Types
|
||||||
|
import Generated.Types
|
||||||
|
import IHP.Prelude
|
||||||
|
import IHP.ViewPrelude
|
||||||
|
import Application.Helper.HubHealth (healthScoreBadge)
|
||||||
|
import Application.Helper.FrictionScore (scoreBand)
|
||||||
|
import Web.View.Hubs.BottleneckDashboard (severityBadge)
|
||||||
|
|
||||||
|
data OperationalReviewBoardView = OperationalReviewBoardView
|
||||||
|
{ hubs :: ![Hub]
|
||||||
|
, allSnapshots :: ![HubHealthSnapshot]
|
||||||
|
, topFrictionScores :: ![FrictionScore]
|
||||||
|
, topWidgets :: ![Widget]
|
||||||
|
, bottlenecks :: ![BottleneckRecord]
|
||||||
|
, openPropagations :: ![CrossHubPropagation]
|
||||||
|
}
|
||||||
|
|
||||||
|
instance View OperationalReviewBoardView where
|
||||||
|
html OperationalReviewBoardView { .. } = [hsx|
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-2xl font-semibold">Operational Review Board</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Panel 1: Hub health matrix -->
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-4">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 mb-3">Hub Health Matrix</h2>
|
||||||
|
{if null hubs
|
||||||
|
then [hsx|<p class="text-sm text-gray-400">No hubs registered.</p>|]
|
||||||
|
else [hsx|
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left px-3 py-2 font-medium text-gray-600">Hub</th>
|
||||||
|
<th class="text-left px-3 py-2 font-medium text-gray-600">Health</th>
|
||||||
|
<th class="text-left px-3 py-2 font-medium text-gray-600">Snapshot</th>
|
||||||
|
<th class="px-3 py-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
{forEach hubs renderHubRow}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|]}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Panel 2: Top friction widgets -->
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-4">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 mb-3">Top Friction Widgets</h2>
|
||||||
|
{if null topFrictionScores
|
||||||
|
then [hsx|<p class="text-sm text-gray-400">No friction scores computed yet.</p>|]
|
||||||
|
else [hsx|
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left px-3 py-2 font-medium text-gray-600">Widget</th>
|
||||||
|
<th class="text-left px-3 py-2 font-medium text-gray-600">Score</th>
|
||||||
|
<th class="text-left px-3 py-2 font-medium text-gray-600">Type</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
{forEach (zip topFrictionScores topWidgets) renderFrictionRow}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|]}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Panel 3: Active bottlenecks by stage -->
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-4">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 mb-3">Active Bottlenecks by Stage</h2>
|
||||||
|
{if null bottlenecks
|
||||||
|
then [hsx|<p class="text-sm text-gray-400">No active bottlenecks.</p>|]
|
||||||
|
else [hsx|
|
||||||
|
<div class="grid grid-cols-4 gap-3">
|
||||||
|
{forEach stages renderBottleneckStage}
|
||||||
|
</div>
|
||||||
|
|]}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Panel 4: Open cross-hub propagations -->
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 p-5">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 mb-3">Open Cross-Hub Propagations</h2>
|
||||||
|
{if null openPropagations
|
||||||
|
then [hsx|<p class="text-sm text-gray-400">No open propagation events.</p>|]
|
||||||
|
else [hsx|
|
||||||
|
<div class="space-y-2">
|
||||||
|
{forEach openPropagations renderPropagationRow}
|
||||||
|
</div>
|
||||||
|
|]}
|
||||||
|
</div>
|
||||||
|
|]
|
||||||
|
where
|
||||||
|
stages = ["candidate", "requirement", "decision", "observation"] :: [Text]
|
||||||
|
stageLabel s = case s of
|
||||||
|
"candidate" -> "Candidate"
|
||||||
|
"requirement" -> "Requirement"
|
||||||
|
"decision" -> "Decision"
|
||||||
|
"observation" -> "Observation"
|
||||||
|
_ -> s
|
||||||
|
|
||||||
|
latestSnapshotFor hub =
|
||||||
|
find (\s -> s.hubId == hub.id) allSnapshots
|
||||||
|
|
||||||
|
renderHubRow :: Hub -> Html
|
||||||
|
renderHubRow h =
|
||||||
|
let mSnap = latestSnapshotFor h
|
||||||
|
in [hsx|
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<a href={ShowHubAction { hubId = h.id }}
|
||||||
|
class="text-indigo-600 hover:underline">{h.name}</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
{case mSnap of
|
||||||
|
Nothing -> [hsx|<span class="text-xs text-gray-400">–</span>|]
|
||||||
|
Just s -> [hsx|
|
||||||
|
<span class={"px-2 py-0.5 rounded text-xs font-semibold " <> healthScoreBadge s.healthScore}>
|
||||||
|
{show s.healthScore}
|
||||||
|
</span>
|
||||||
|
|]}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 text-xs text-gray-400">
|
||||||
|
{maybe "never" (\s -> show s.computedAt) mSnap}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 text-right">
|
||||||
|
<a href={HubHealthHistoryAction { hubId = h.id }}
|
||||||
|
class="text-xs text-indigo-600 hover:underline">History</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|]
|
||||||
|
|
||||||
|
renderFrictionRow :: (FrictionScore, Widget) -> Html
|
||||||
|
renderFrictionRow (fs, w) = [hsx|
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<a href={ShowWidgetAction { widgetId = w.id }}
|
||||||
|
class="text-indigo-600 hover:underline">{w.name}</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<span class={"px-2 py-0.5 rounded text-xs font-semibold " <> scoreBand fs.score}>
|
||||||
|
{show fs.score}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 text-gray-500 text-xs">{w.widgetType}</td>
|
||||||
|
</tr>
|
||||||
|
|]
|
||||||
|
|
||||||
|
renderBottleneckStage :: Text -> Html
|
||||||
|
renderBottleneckStage stage =
|
||||||
|
let stageBNs = filter (\b -> b.stage == stage) bottlenecks
|
||||||
|
cnt = length stageBNs
|
||||||
|
hasCrit = any (\b -> b.severity == "critical") stageBNs
|
||||||
|
colourCls = if cnt == 0 then "bg-gray-50 text-gray-400"
|
||||||
|
else if hasCrit then "bg-red-50 text-red-700"
|
||||||
|
else "bg-orange-50 text-orange-700"
|
||||||
|
in [hsx|
|
||||||
|
<div class={"rounded-lg p-4 text-center " <> colourCls}>
|
||||||
|
<div class="text-2xl font-bold">{show cnt}</div>
|
||||||
|
<div class="text-xs mt-1">{stageLabel stage}</div>
|
||||||
|
</div>
|
||||||
|
|]
|
||||||
|
|
||||||
|
renderPropagationRow :: CrossHubPropagation -> Html
|
||||||
|
renderPropagationRow p = [hsx|
|
||||||
|
<div class="flex items-start justify-between p-3 bg-gray-50 rounded border border-gray-200">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded mr-2">{p.patternType}</span>
|
||||||
|
<span class="text-sm text-gray-700">{p.summary}</span>
|
||||||
|
<p class="text-xs text-gray-400 mt-0.5">{show p.detectedAt}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 ml-4">
|
||||||
|
<a href={AcknowledgePropagationAction { crossHubPropagationId = p.id }}
|
||||||
|
class="text-xs text-yellow-600 hover:underline whitespace-nowrap">Acknowledge</a>
|
||||||
|
<a href={ResolvePropagationAction { crossHubPropagationId = p.id }}
|
||||||
|
class="text-xs text-green-600 hover:underline">Resolve</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|]
|
||||||
@@ -49,6 +49,18 @@ instance View ShowView where
|
|||||||
class="text-sm border border-teal-300 text-teal-700 px-3 py-1.5 rounded hover:bg-teal-50">
|
class="text-sm border border-teal-300 text-teal-700 px-3 py-1.5 rounded hover:bg-teal-50">
|
||||||
Adapters
|
Adapters
|
||||||
</a>
|
</a>
|
||||||
|
<a href={FrictionHeatmapAction { hubId = hub.id }}
|
||||||
|
class="text-sm border border-orange-300 text-orange-700 px-3 py-1.5 rounded hover:bg-orange-50">
|
||||||
|
Friction
|
||||||
|
</a>
|
||||||
|
<a href={BottleneckDashboardAction { hubId = hub.id }}
|
||||||
|
class="text-sm border border-red-300 text-red-700 px-3 py-1.5 rounded hover:bg-red-50">
|
||||||
|
Bottlenecks
|
||||||
|
</a>
|
||||||
|
<a href={HubHealthHistoryAction { hubId = hub.id }}
|
||||||
|
class="text-sm border border-green-300 text-green-700 px-3 py-1.5 rounded hover:bg-green-50">
|
||||||
|
Health
|
||||||
|
</a>
|
||||||
<a href={EditHubAction { hubId = hub.id }}
|
<a href={EditHubAction { hubId = hub.id }}
|
||||||
class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50">
|
class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50">
|
||||||
Edit
|
Edit
|
||||||
|
|||||||
88
docs/phase7-summary.md
Normal file
88
docs/phase7-summary.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# IHF Phase 7 Summary — Advanced Observability and Operational Integration
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
Phase 7 turns the accumulated interaction data into operational intelligence. Four new data artifacts and five new views/dashboards give hub leaders a clear picture of where friction is concentrated, where the governance pipeline is stalling, and how hub health is trending over time.
|
||||||
|
|
||||||
|
### Data Artifacts
|
||||||
|
|
||||||
|
**FrictionScore** (`friction_scores`)
|
||||||
|
- One row per widget (unique constraint on `widget_id`); upserted on recompute
|
||||||
|
- Score formula (0–100): `min 100 (annotationCount*5 + errorEventCount*10 + regressionFlag?20:0 + staleCandidateCount*8)`
|
||||||
|
- Bands: 0–19 green, 20–39 yellow, 40–59 amber, 60+ red
|
||||||
|
- `Application/Helper/FrictionScore.hs` — `computeFrictionScore`, `scoreBand`
|
||||||
|
|
||||||
|
**BottleneckRecord** (`bottleneck_records`)
|
||||||
|
- Detected stalls at four pipeline stages
|
||||||
|
- Severity: `medium` → `high` → `critical` based on how far past the threshold
|
||||||
|
- `resolved_at` nullable — partial index on `WHERE resolved_at IS NULL` for fast active queries
|
||||||
|
- `Application/Helper/BottleneckDetector.hs` — `detectBottlenecks`
|
||||||
|
|
||||||
|
| Stage | Subject | Threshold |
|
||||||
|
|-------|---------|-----------|
|
||||||
|
| `candidate` | RequirementCandidate open | 30 days |
|
||||||
|
| `requirement` | Requirement without DecisionRecord | 60 days |
|
||||||
|
| `decision` | DecisionRecord without DeploymentRecord | 30 days |
|
||||||
|
| `observation` | DeploymentRecord without OutcomeSignal | 14 days |
|
||||||
|
|
||||||
|
**HubHealthSnapshot** (`hub_health_snapshots`)
|
||||||
|
- Append-only; every `SnapshotHubHealthAction` inserts a new row
|
||||||
|
- Score formula (0–100): `max 0 (100 - openCandidates*5 - regressedWidgets*10 - staleDecisions*8 - criticalBN*12 - highBN*6)`
|
||||||
|
- `Application/Helper/HubHealth.hs` — `computeHubHealth`, `healthScoreBadge`
|
||||||
|
|
||||||
|
**CrossHubPropagation** (`cross_hub_propagations`)
|
||||||
|
- Status lifecycle: `open → acknowledged → resolved`
|
||||||
|
- `Application/Helper/CrossHubPropagation.hs` — `detectPropagations`
|
||||||
|
- Two heuristics:
|
||||||
|
- `annotation_cluster`: same annotation category with ≥3 occurrences in ≥2 hubs within 14 days
|
||||||
|
- `widget_type_friction`: same widget type with FrictionScore ≥40 in ≥2 hubs
|
||||||
|
|
||||||
|
### Views and Actions
|
||||||
|
|
||||||
|
**Friction Heatmap** (`FrictionHeatmapAction { hubId }`)
|
||||||
|
- Grid of widget cards, colour-coded by score band
|
||||||
|
- "Recompute" button triggers `RecomputeFrictionAction` for all hub widgets
|
||||||
|
- Linked from hub Show page as "Friction"
|
||||||
|
|
||||||
|
**Bottleneck Dashboard** (`BottleneckDashboardAction { hubId }`)
|
||||||
|
- Table grouped by pipeline stage; severity badge on each row
|
||||||
|
- "Detect" button runs `DetectBottlenecksAction`
|
||||||
|
- "Resolve" marks `resolved_at`; idempotent detection skips existing active records
|
||||||
|
- Linked from hub Show page as "Bottlenecks"
|
||||||
|
|
||||||
|
**Hub Health History** (`HubHealthHistoryAction { hubId }`)
|
||||||
|
- Latest snapshot shown prominently with component breakdown
|
||||||
|
- Full history table in reverse chronological order
|
||||||
|
- "Take Snapshot" triggers `SnapshotHubHealthAction`
|
||||||
|
- Linked from hub Show page as "Health"
|
||||||
|
|
||||||
|
**Operational Review Board** (`OperationalReviewBoardAction`)
|
||||||
|
- Global view, no hub scope — all hubs, all data
|
||||||
|
- Panel 1: Hub health matrix — all hubs, latest score, link to history
|
||||||
|
- Panel 2: Top 10 friction widgets across all hubs
|
||||||
|
- Panel 3: Active bottleneck counts by pipeline stage
|
||||||
|
- Panel 4: Open/acknowledged cross-hub propagation events
|
||||||
|
- `autoRefresh` — live-updates
|
||||||
|
- Linked from global nav as "Ops Review"
|
||||||
|
|
||||||
|
**Cross-Hub Propagations** (`CrossHubPropagationsAction`)
|
||||||
|
- Global list of all propagation events with status badges
|
||||||
|
- "Detect" triggers `DetectPropagationsAction` (idempotent)
|
||||||
|
- Acknowledge / Resolve actions per row
|
||||||
|
- Linked from global nav as "Propagations"
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
- **Friction scores are not append-only.** There is no friction history — only the current score per widget. Use `HubHealthSnapshot` for hub-level trends over time.
|
||||||
|
- **Bottleneck detection requires manually triggering.** Phase 7 does not introduce scheduled jobs. Detection runs on demand via the UI or `curl`. A future phase can layer on a devenv cron task.
|
||||||
|
- **Cross-hub detection requires current friction scores.** Run `RecomputeFrictionAction` on all relevant hubs before `DetectPropagationsAction`, or detection will use stale/absent scores.
|
||||||
|
- **OperationalReviewBoard top-10 friction** is sorted globally — not scoped to a hub. A widget from a single hub could dominate the list if that hub has very high friction scores.
|
||||||
|
- **`subjectId` in BottleneckRecord is untyped UUID.** The `subject_type` column disambiguates, but there is no FK constraint — subject records can be deleted without cascading. A future phase can add per-stage FK columns.
|
||||||
|
|
||||||
|
## Phase 8 Readiness
|
||||||
|
|
||||||
|
Phase 8 (Federated Hub Maturity) can build on Phase 7:
|
||||||
|
- `CrossHubPropagation` already models multi-hub patterns; Phase 8 routing can act on these
|
||||||
|
- `HubHealthSnapshot` history provides a baseline for measuring federated governance quality
|
||||||
|
- The `OperationalReviewBoard` is already global — Phase 8 can add per-organization or per-team scoping
|
||||||
|
- Bottleneck thresholds are constants in helper modules — Phase 8 can make them configurable per hub via a `HubPolicy` table
|
||||||
@@ -4,7 +4,7 @@ type: workplan
|
|||||||
title: "IHF Phase 7 — Advanced Observability and Operational Integration"
|
title: "IHF Phase 7 — Advanced Observability and Operational Integration"
|
||||||
domain: inter_hub
|
domain: inter_hub
|
||||||
repo: inter-hub
|
repo: inter-hub
|
||||||
status: todo
|
status: done
|
||||||
owner: custodian
|
owner: custodian
|
||||||
topic_slug: inter_hub
|
topic_slug: inter_hub
|
||||||
created: "2026-03-29"
|
created: "2026-03-29"
|
||||||
@@ -61,7 +61,7 @@ Reference: `specs/InteractionHubFrameworkSpecification_v0.1.md` §Phase 7,
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: IHUB-WP-0007-T01
|
id: IHUB-WP-0007-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "86e31f8b-62a3-4176-9b10-2fe7a8dbcc23"
|
state_hub_task_id: "86e31f8b-62a3-4176-9b10-2fe7a8dbcc23"
|
||||||
```
|
```
|
||||||
@@ -152,7 +152,7 @@ CREATE INDEX cross_hub_propagations_pattern_idx ON cross_hub_propagations (patte
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: IHUB-WP-0007-T02
|
id: IHUB-WP-0007-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "3a5ecd28-17c2-4258-bfd9-b3eaecf52135"
|
state_hub_task_id: "3a5ecd28-17c2-4258-bfd9-b3eaecf52135"
|
||||||
```
|
```
|
||||||
@@ -193,7 +193,7 @@ with correct colour bands; recompute updates scores.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: IHUB-WP-0007-T03
|
id: IHUB-WP-0007-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "ada0347a-880b-454e-843f-4a9135ea8739"
|
state_hub_task_id: "ada0347a-880b-454e-843f-4a9135ea8739"
|
||||||
```
|
```
|
||||||
@@ -226,7 +226,7 @@ and groups correctly; resolve marks `resolved_at`.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: IHUB-WP-0007-T04
|
id: IHUB-WP-0007-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "b0c932c5-fdb7-47b6-adc7-b4f8ed5555e6"
|
state_hub_task_id: "b0c932c5-fdb7-47b6-adc7-b4f8ed5555e6"
|
||||||
```
|
```
|
||||||
@@ -259,7 +259,7 @@ table renders in order; badge appears on hub Show page.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: IHUB-WP-0007-T05
|
id: IHUB-WP-0007-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "7a860b9f-a835-47d6-96d8-2964ae37b12d"
|
state_hub_task_id: "7a860b9f-a835-47d6-96d8-2964ae37b12d"
|
||||||
```
|
```
|
||||||
@@ -290,7 +290,7 @@ duplicate runs are idempotent; acknowledge/resolve transitions work.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: IHUB-WP-0007-T06
|
id: IHUB-WP-0007-T06
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "ffabc4d1-c166-4b7d-8bec-55365cbe0666"
|
state_hub_task_id: "ffabc4d1-c166-4b7d-8bec-55365cbe0666"
|
||||||
```
|
```
|
||||||
@@ -318,7 +318,7 @@ hubs; top friction list is correctly sorted; live-updates on data change.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: IHUB-WP-0007-T07
|
id: IHUB-WP-0007-T07
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "a14b94f8-3b27-4f0c-9949-60fb65a57a05"
|
state_hub_task_id: "a14b94f8-3b27-4f0c-9949-60fb65a57a05"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user