Compare commits

..

10 Commits

174 changed files with 59490 additions and 10756 deletions

View File

@@ -2,12 +2,12 @@
> High-level module structure for AI Context. Generated automatically.
**Generated:** 2026-03-09T13:33:22.105511
**Generated:** 2026-03-10T20:52:01.801581
## Summary
- **Total Modules:** 93
- **Total Entities:** 2649
- **Total Modules:** 103
- **Total Entities:** 3088
## Module Hierarchy
@@ -28,9 +28,9 @@
### 📁 `src/`
- 🏗️ **Layers:** API, Core, UI (API)
- 📊 **Tiers:** CRITICAL: 2, STANDARD: 20, TRIVIAL: 2
- 📊 **Tiers:** CRITICAL: 2, STANDARD: 21, TRIVIAL: 2
- 📄 **Files:** 2
- 📦 **Entities:** 24
- 📦 **Entities:** 25
**Key Entities:**
@@ -53,10 +53,10 @@
### 📁 `routes/`
- 🏗️ **Layers:** API, UI (API)
- 📊 **Tiers:** CRITICAL: 12, STANDARD: 254, TRIVIAL: 8
- 📄 **Files:** 19
- 📦 **Entities:** 274
- 🏗️ **Layers:** API, Infra, UI (API), UI/API
- 📊 **Tiers:** CRITICAL: 12, STANDARD: 272, TRIVIAL: 16
- 📄 **Files:** 21
- 📦 **Entities:** 300
**Key Entities:**
@@ -72,14 +72,14 @@
- Schema for branch creation requests.
- **BranchSchema** (Class)
- Schema for representing a Git branch metadata.
- **BuildManifestRequest** (Class)
- Request schema for manifest build endpoint.
- **CommitCreate** (Class)
- Schema for staging and committing changes.
- **CommitSchema** (Class)
- Schema for representing Git commit details.
- **ConfirmationRecord** (Class)
- In-memory confirmation token model for risky operation dispa...
- **ConflictResolution** (Class)
- Schema for resolving merge conflicts.
**Dependencies:**
@@ -87,14 +87,14 @@
- 🔗 DEPENDS_ON -> ConfigModels
- 🔗 DEPENDS_ON -> backend.src.core.database
- 🔗 DEPENDS_ON -> backend.src.core.database.get_db
- 🔗 DEPENDS_ON -> backend.src.core.superset_client
- 🔗 DEPENDS_ON -> backend.src.core.mapping_service
### 📁 `__tests__/`
- 🏗️ **Layers:** API, Domain, Domain (Tests), UI (API Tests), Unknown
- 📊 **Tiers:** STANDARD: 88, TRIVIAL: 187
- 📄 **Files:** 14
- 📦 **Entities:** 275
- 🏗️ **Layers:** API, Domain, Domain (Tests), Tests, UI (API Tests), Unknown
- 📊 **Tiers:** STANDARD: 92, TRIVIAL: 195
- 📄 **Files:** 17
- 📦 **Entities:** 287
**Key Entities:**
@@ -122,22 +122,25 @@
**Dependencies:**
- 🔗 DEPENDS_ON -> backend.src.api.routes.assistant
- 🔗 IMPLEMENTS -> clean_release_v2_release_api_contracts
### 📁 `core/`
- 🏗️ **Layers:** Core
- 📊 **Tiers:** CRITICAL: 47, STANDARD: 94, TRIVIAL: 8
- 📄 **Files:** 11
- 📦 **Entities:** 149
- 🏗️ **Layers:** Core, Domain
- 📊 **Tiers:** CRITICAL: 52, STANDARD: 102, TRIVIAL: 9
- 📄 **Files:** 12
- 📦 **Entities:** 163
**Key Entities:**
- **AsyncSupersetClient** (Class)
- Async sibling of SupersetClient for dashboard read paths.
- **AuthSessionLocal** (Class) `[TRIVIAL]`
- A session factory for the authentication database.
- **BeliefFormatter** (Class)
- Custom logging formatter that adds belief state prefixes to ...
- **ConfigManager** (Class) `[CRITICAL]`
- A class to handle application configuration persistence and ...
- Handles application configuration load, validation, mutation...
- **IdMappingService** (Class) `[CRITICAL]`
- Service handling the cataloging and retrieval of remote Supe...
- **LogEntry** (Class)
@@ -150,23 +153,21 @@
- A Pydantic model used to represent the validated configurati...
- **PluginLoader** (Class)
- Scans a specified directory for Python modules, dynamically ...
- **SchedulerService** (Class)
- Provides a service to manage scheduled backup tasks.
**Dependencies:**
- 🔗 DEPENDS_ON -> AppConfigRecord
- 🔗 DEPENDS_ON -> ConfigModels
- 🔗 DEPENDS_ON -> PyYAML
- 🔗 DEPENDS_ON -> SessionLocal
- 🔗 DEPENDS_ON -> backend.src.core.auth.config
- 🔗 DEPENDS_ON -> backend.src.core.logger
### 📁 `__tests__/`
- 🏗️ **Layers:** Domain
- 📊 **Tiers:** STANDARD: 7
- 📄 **Files:** 1
- 📦 **Entities:** 7
- 📊 **Tiers:** STANDARD: 8, TRIVIAL: 6
- 📄 **Files:** 2
- 📦 **Entities:** 14
**Key Entities:**
@@ -174,10 +175,12 @@
- Records request payloads and returns scripted responses for ...
- 📦 **backend.src.core.__tests__.test_superset_profile_lookup** (Module)
- Verifies Superset profile lookup adapter payload normalizati...
- 📦 **test_throttled_scheduler** (Module)
- Unit tests for ThrottledSchedulerConfigurator distribution l...
### 📁 `auth/`
- 🏗️ **Layers:** Core
- 🏗️ **Layers:** Core, Domain
- 📊 **Tiers:** CRITICAL: 28
- 📄 **Files:** 6
- 📦 **Entities:** 28
@@ -187,7 +190,7 @@
- **AuthConfig** (Class) `[CRITICAL]`
- Holds authentication-related settings.
- **AuthRepository** (Class) `[CRITICAL]`
- Encapsulates database operations for authentication.
- Encapsulates database operations for authentication-related ...
- 📦 **backend.src.core.auth.config** (Module) `[CRITICAL]`
- Centralized configuration for authentication and authorizati...
- 📦 **backend.src.core.auth.jwt** (Module) `[CRITICAL]`
@@ -197,17 +200,17 @@
- 📦 **backend.src.core.auth.oauth** (Module) `[CRITICAL]`
- ADFS OIDC configuration and client using Authlib.
- 📦 **backend.src.core.auth.repository** (Module) `[CRITICAL]`
- Data access layer for authentication-related entities.
- Data access layer for authentication and user preference ent...
- 📦 **backend.src.core.auth.security** (Module) `[CRITICAL]`
- Utility for password hashing and verification using Passlib.
**Dependencies:**
- 🔗 DEPENDS_ON -> authlib
- 🔗 DEPENDS_ON -> backend.src.core.logger.belief_scope
- 🔗 DEPENDS_ON -> backend.src.models.auth
- 🔗 DEPENDS_ON -> backend.src.models.profile
- 🔗 DEPENDS_ON -> jose
- 🔗 DEPENDS_ON -> passlib
- 🔗 DEPENDS_ON -> pydantic
- 🔗 DEPENDS_ON -> sqlalchemy
### 📁 `__tests__/`
@@ -235,7 +238,7 @@
### 📁 `migration/`
- 🏗️ **Layers:** Core
- 🏗️ **Layers:** Core, Domain
- 📊 **Tiers:** CRITICAL: 20, TRIVIAL: 1
- 📄 **Files:** 4
- 📦 **Entities:** 21
@@ -253,7 +256,7 @@
- 📦 **backend.src.core.migration.dry_run_orchestrator** (Module) `[CRITICAL]`
- Compute pre-flight migration diff and risk scoring without a...
- 📦 **backend.src.core.migration.risk_assessor** (Module) `[CRITICAL]`
- Risk evaluation helpers for migration pre-flight reporting.
- Compute deterministic migration risk items and aggregate sco...
**Dependencies:**
@@ -314,14 +317,16 @@
### 📁 `utils/`
- 🏗️ **Layers:** Core, Domain, Infra
- 📊 **Tiers:** STANDARD: 50, TRIVIAL: 1
- 📄 **Files:** 4
- 📦 **Entities:** 51
- 📊 **Tiers:** CRITICAL: 1, STANDARD: 62, TRIVIAL: 5
- 📄 **Files:** 5
- 📦 **Entities:** 68
**Key Entities:**
- **APIClient** (Class)
- Инкапсулирует HTTP-логику для работы с API, включая сессии, ...
- **AsyncAPIClient** (Class)
- Async Superset API client backed by httpx.AsyncClient with s...
- **AuthenticationError** (Class)
- Exception raised when authentication fails.
- **DashboardNotFoundError** (Class)
@@ -336,48 +341,46 @@
- Exception raised when access is denied.
- **SupersetAPIError** (Class)
- Base exception for all Superset API related errors.
- 📦 **backend.core.utils.dataset_mapper** (Module)
- Этот модуль отвечает за обновление метаданных (verbose_map) ...
- 📦 **backend.core.utils.fileio** (Module)
- Предоставляет набор утилит для управления файловыми операция...
- **SupersetAuthCache** (Class)
- Process-local cache for Superset access/csrf tokens keyed by...
**Dependencies:**
- 🔗 DEPENDS_ON -> backend.core.superset_client
- 🔗 DEPENDS_ON -> backend.src.core.logger
- 🔗 DEPENDS_ON -> backend.src.core.utils.network.SupersetAuthCache
- 🔗 DEPENDS_ON -> pandas
- 🔗 DEPENDS_ON -> psycopg2
- 🔗 DEPENDS_ON -> pyyaml
### 📁 `models/`
- 🏗️ **Layers:** Domain, Model
- 📊 **Tiers:** CRITICAL: 20, STANDARD: 35, TRIVIAL: 29
- 📊 **Tiers:** CRITICAL: 21, STANDARD: 40, TRIVIAL: 29
- 📄 **Files:** 13
- 📦 **Entities:** 84
- 📦 **Entities:** 90
**Key Entities:**
- **ADGroupMapping** (Class) `[CRITICAL]`
- Maps an Active Directory group to a local System Role.
- **AppConfigRecord** (Class) `[CRITICAL]`
- Stores the single source of truth for application configurat...
- Stores persisted application configuration as a single autho...
- **ApprovalDecision** (Class)
- Approval or rejection bound to a candidate and report.
- **AssistantAuditRecord** (Class)
- Store audit decisions and outcomes produced by assistant com...
- **AssistantConfirmationRecord** (Class)
- Persist risky operation confirmation tokens with lifecycle s...
- **AssistantMessageRecord** (Class)
- Persist chat history entries for assistant conversations.
- **CandidateArtifact** (Class)
- Represents one artifact associated with a release candidate.
- **CheckFinalStatus** (Class)
- Final status for compliance check run.
- Backward-compatible final status enum for legacy TUI/orchest...
- **CheckStageName** (Class)
- Mandatory check stages.
- Backward-compatible stage name enum for legacy TUI/orchestra...
- **CheckStageResult** (Class)
- Per-stage compliance result.
- **CheckStageStatus** (Class)
- Stage-level execution status.
- **ClassificationType** (Class)
- Manifest classification outcomes for artifacts.
- Backward-compatible stage result container for legacy TUI/or...
**Dependencies:**
@@ -508,10 +511,10 @@
### 📁 `schemas/`
- 🏗️ **Layers:** API
- 📊 **Tiers:** CRITICAL: 10, STANDARD: 9, TRIVIAL: 3
- 📄 **Files:** 2
- 📦 **Entities:** 22
- 🏗️ **Layers:** API, Domain
- 📊 **Tiers:** CRITICAL: 10, STANDARD: 18, TRIVIAL: 3
- 📄 **Files:** 4
- 📦 **Entities:** 31
**Key Entities:**
@@ -519,6 +522,12 @@
- Schema for creating an AD Group mapping.
- **ADGroupMappingSchema** (Class) `[CRITICAL]`
- Represents an AD Group to Role mapping in API responses.
- **DashboardHealthItem** (Class)
- Represents the latest health status of a single dashboard.
- **HealthSummaryResponse** (Class)
- Aggregated health summary for all dashboards.
- **NotificationChannel** (Class)
- Structured notification channel definition for policy-level ...
- **PermissionSchema** (Class) `[TRIVIAL]`
- Represents a permission in API responses.
- **ProfilePermissionState** (Class)
@@ -529,28 +538,37 @@
- Response envelope for profile preference read/update endpoin...
- **ProfilePreferenceUpdateRequest** (Class)
- Request payload for updating current user's profile settings...
- **ProfileSecuritySummary** (Class)
- Read-only security and access snapshot for current user.
- **RoleCreate** (Class) `[CRITICAL]`
- Schema for creating a new role.
- **RoleSchema** (Class) `[CRITICAL]`
- Represents a role in API responses.
**Dependencies:**
- 🔗 DEPENDS_ON -> pydantic
### 📁 `__tests__/`
- 📊 **Tiers:** STANDARD: 4
- 📄 **Files:** 1
- 📦 **Entities:** 4
**Key Entities:**
- 📦 **backend.src.schemas.__tests__.test_settings_and_health_schemas** (Module)
- Regression tests for settings and health schema contracts up...
### 📁 `scripts/`
- 🏗️ **Layers:** Scripts, UI, Unknown
- 📊 **Tiers:** CRITICAL: 2, STANDARD: 27, TRIVIAL: 17
- 📄 **Files:** 7
- 📦 **Entities:** 46
- 📊 **Tiers:** CRITICAL: 2, STANDARD: 43, TRIVIAL: 30
- 📄 **Files:** 8
- 📦 **Entities:** 75
**Key Entities:**
- **CleanReleaseTUI** (Class)
- Curses-based application for compliance monitoring.
- **TuiFacadeAdapter** (Class)
- Thin TUI adapter that routes business mutations through appl...
- 📦 **backend.src.scripts.clean_release_cli** (Module)
- Provide headless CLI commands for candidate registration, ar...
- 📦 **backend.src.scripts.clean_release_tui** (Module)
- Interactive terminal interface for Enterprise Clean Release ...
- 📦 **backend.src.scripts.create_admin** (Module)
@@ -573,10 +591,10 @@
### 📁 `services/`
- 🏗️ **Layers:** Core, Domain, Service
- 📊 **Tiers:** CRITICAL: 9, STANDARD: 118, TRIVIAL: 15
- 📄 **Files:** 9
- 📦 **Entities:** 142
- 🏗️ **Layers:** Core, Domain, Domain/Service, Service
- 📊 **Tiers:** CRITICAL: 9, STANDARD: 120, TRIVIAL: 17
- 📄 **Files:** 10
- 📦 **Entities:** 146
**Key Entities:**
@@ -603,23 +621,29 @@
**Dependencies:**
- 🔗 DEPENDS_ON -> ValidationRecord
- 🔗 DEPENDS_ON -> backend.src.core.auth.jwt.create_access_token
- 🔗 DEPENDS_ON -> backend.src.core.auth.repository
- 🔗 DEPENDS_ON -> backend.src.core.config_manager
- 🔗 DEPENDS_ON -> backend.src.core.database
- 🔗 DEPENDS_ON -> backend.src.core.superset_client
- 🔗 DEPENDS_ON -> backend.src.core.task_manager
- 🔗 DEPENDS_ON -> backend.src.core.auth.repository.AuthRepository
- 🔗 DEPENDS_ON -> backend.src.core.auth.security.verify_password
### 📁 `__tests__/`
- 🏗️ **Layers:** Domain, Domain Tests, Service, Service Tests, Unknown
- 📊 **Tiers:** STANDARD: 29, TRIVIAL: 17
- 📄 **Files:** 5
- 📦 **Entities:** 46
- 📊 **Tiers:** STANDARD: 36, TRIVIAL: 40
- 📄 **Files:** 7
- 📦 **Entities:** 76
**Key Entities:**
- **TestEncryptionManager** (Class)
- Validate EncryptionManager encrypt/decrypt roundtrip, unique...
- **_DummyLogger** (Class)
- Minimal logger shim for TaskContext-like objects used in tes...
- **_FakeDBSession** (Class)
- Captures persisted records for assertion and mimics SQLAlche...
- 📦 **backend.src.services.__tests__.test_llm_plugin_persistence** (Module)
- Regression test for ValidationRecord persistence fields popu...
- 📦 **backend.src.services.__tests__.test_llm_prompt_templates** (Module)
- Validate normalization and rendering behavior for configurab...
- 📦 **backend.src.services.__tests__.test_rbac_permission_catalog** (Module)
@@ -628,6 +652,8 @@
- Unit tests for ResourceService
- 📦 **test_encryption_manager** (Module)
- Unit tests for EncryptionManager encrypt/decrypt functionali...
- 📦 **test_health_service** (Module)
- Unit tests for HealthService aggregation logic.
- 📦 **test_llm_provider** (Module) `[TRIVIAL]`
- Auto-generated module for backend/src/services/__tests__/tes...
@@ -637,10 +663,10 @@
### 📁 `clean_release/`
- 🏗️ **Layers:** Domain, Infra
- 📊 **Tiers:** CRITICAL: 3, STANDARD: 16, TRIVIAL: 32
- 📄 **Files:** 10
- 📦 **Entities:** 51
- 🏗️ **Layers:** Application, Domain, Infra
- 📊 **Tiers:** CRITICAL: 9, STANDARD: 46, TRIVIAL: 50
- 📄 **Files:** 21
- 📦 **Entities:** 105
**Key Entities:**
@@ -649,35 +675,35 @@
- **CleanPolicyEngine** (Class)
- **CleanReleaseRepository** (Class)
- Data access object for clean release lifecycle.
- 📦 **backend.src.services.clean_release** (Module)
- Initialize clean release service package and provide explici...
- **ComplianceExecutionResult** (Class)
- Return envelope for compliance execution with run/report and...
- **ComplianceExecutionService** (Class)
- Execute clean-release compliance lifecycle over trusted snap...
- 📦 **backend.src.services.clean_release.approval_service** (Module) `[CRITICAL]`
- Enforce approval/rejection gates over immutable compliance r...
- 📦 **backend.src.services.clean_release.audit_service** (Module)
- Provide lightweight audit hooks for clean release preparatio...
- 📦 **backend.src.services.clean_release.candidate_service** (Module) `[CRITICAL]`
- Register release candidates with validated artifacts and adv...
- 📦 **backend.src.services.clean_release.compliance_execution_service** (Module) `[CRITICAL]`
- Create and execute compliance runs with trusted snapshots, d...
- 📦 **backend.src.services.clean_release.compliance_orchestrator** (Module) `[CRITICAL]`
- Execute mandatory clean compliance stages and produce final ...
- 📦 **backend.src.services.clean_release.manifest_builder** (Module)
- Build deterministic distribution manifest from classified ar...
- 📦 **backend.src.services.clean_release.policy_engine** (Module) `[CRITICAL]`
- Evaluate artifact/source policies for enterprise clean profi...
- 📦 **backend.src.services.clean_release.preparation_service** (Module)
- Prepare release candidate by policy evaluation and determini...
- 📦 **backend.src.services.clean_release.report_builder** (Module) `[CRITICAL]`
- Build and persist compliance reports with consistent counter...
**Dependencies:**
- 🔗 DEPENDS_ON -> backend.src.core.config_manager
- 🔗 DEPENDS_ON -> backend.src.core.logger
- 🔗 DEPENDS_ON -> backend.src.models.clean_release
- 🔗 DEPENDS_ON -> backend.src.models.clean_release.CleanProfilePolicy
- 🔗 DEPENDS_ON -> backend.src.models.clean_release.ResourceSourceRegistry
- 🔗 DEPENDS_ON -> backend.src.services.clean_release.manifest_builder
### 📁 `__tests__/`
- 🏗️ **Layers:** Domain, Infra, Unknown
- 📊 **Tiers:** STANDARD: 18, TRIVIAL: 25
- 📊 **Tiers:** STANDARD: 25, TRIVIAL: 25
- 📄 **Files:** 8
- 📦 **Entities:** 43
- 📦 **Entities:** 50
**Key Entities:**
@@ -698,6 +724,117 @@
- 📦 **test_policy_engine** (Module) `[TRIVIAL]`
- Auto-generated module for backend/src/services/clean_release...
**Dependencies:**
- 🔗 DEPENDS_ON -> backend.src.services.clean_release.preparation_service:Module
### 📁 `repositories/`
- 🏗️ **Layers:** Infra
- 📊 **Tiers:** STANDARD: 10, TRIVIAL: 46
- 📄 **Files:** 10
- 📦 **Entities:** 56
**Key Entities:**
- 📦 **approval_repository** (Module)
- Persist and query approval decisions.
- 📦 **artifact_repository** (Module)
- Persist and query candidate artifacts.
- 📦 **audit_repository** (Module)
- Persist and query audit logs for clean release operations.
- 📦 **candidate_repository** (Module)
- Persist and query release candidates.
- 📦 **clean_release_repositories** (Module)
- Export all clean release repositories.
- 📦 **compliance_repository** (Module)
- Persist and query compliance runs, stage runs, and violation...
- 📦 **manifest_repository** (Module)
- Persist and query distribution manifests.
- 📦 **policy_repository** (Module)
- Persist and query policy and registry snapshots.
- 📦 **publication_repository** (Module)
- Persist and query publication records.
- 📦 **report_repository** (Module)
- Persist and query compliance reports.
### 📁 `stages/`
- 🏗️ **Layers:** Domain
- 📊 **Tiers:** STANDARD: 19, TRIVIAL: 5
- 📄 **Files:** 6
- 📦 **Entities:** 24
**Key Entities:**
- **ComplianceStage** (Class)
- Protocol for pluggable stage implementations.
- **ComplianceStageContext** (Class)
- Immutable input envelope passed to each compliance stage.
- **DataPurityStage** (Class)
- Validate manifest summary for prohibited artifacts.
- **InternalSourcesOnlyStage** (Class)
- Enforce internal-source-only policy from trusted registry sn...
- **ManifestConsistencyStage** (Class)
- Validate run/manifest linkage consistency.
- **NoExternalEndpointsStage** (Class)
- Validate endpoint references from manifest against trusted r...
- **StageExecutionResult** (Class)
- Structured stage output containing decision, details and vio...
- 📦 **backend.src.services.clean_release.stages** (Module)
- Define compliance stage order and helper functions for deter...
- 📦 **backend.src.services.clean_release.stages.base** (Module)
- Define shared contracts and helpers for pluggable clean-rele...
- 📦 **backend.src.services.clean_release.stages.data_purity** (Module)
- Evaluate manifest purity counters and emit blocking violatio...
**Dependencies:**
- 🔗 DEPENDS_ON -> backend.src.models.clean_release
- 🔗 DEPENDS_ON -> backend.src.services.clean_release.stages.base
- 🔗 IMPLEMENTS -> backend.src.services.clean_release.stages.base.ComplianceStage
### 📁 `notifications/`
- 🏗️ **Layers:** Domain, Infra
- 📊 **Tiers:** CRITICAL: 2, STANDARD: 5, TRIVIAL: 14
- 📄 **Files:** 2
- 📦 **Entities:** 21
**Key Entities:**
- **NotificationProvider** (Class)
- Abstract base class for all notification providers.
- **NotificationService** (Class)
- Routes validation reports to appropriate users and channels.
- **SMTPProvider** (Class)
- Delivers notifications via SMTP.
- **SlackProvider** (Class)
- Delivers notifications via Slack Webhooks or API.
- **TelegramProvider** (Class)
- Delivers notifications via Telegram Bot API.
- 📦 **backend.src.services.notifications.providers** (Module) `[CRITICAL]`
- Defines abstract base and concrete implementations for exter...
- 📦 **backend.src.services.notifications.service** (Module) `[CRITICAL]`
- Orchestrates notification routing based on user preferences ...
**Dependencies:**
- 🔗 DEPENDS_ON -> backend.src.models.llm
- 🔗 DEPENDS_ON -> backend.src.services.notifications.providers
- 🔗 DEPENDS_ON -> backend.src.services.profile_service
### 📁 `__tests__/`
- 📊 **Tiers:** STANDARD: 1, TRIVIAL: 9
- 📄 **Files:** 1
- 📦 **Entities:** 10
**Key Entities:**
- 📦 **backend.src.services.notifications.__tests__.test_notification_service** (Module)
- Unit tests for NotificationService routing and dispatch logi...
### 📁 `reports/`
- 🏗️ **Layers:** Domain
@@ -727,9 +864,9 @@
### 📁 `__tests__/`
- 🏗️ **Layers:** Domain, Domain (Tests), Unknown
- 📊 **Tiers:** STANDARD: 2, TRIVIAL: 24
- 📊 **Tiers:** STANDARD: 2, TRIVIAL: 25
- 📄 **Files:** 3
- 📦 **Entities:** 26
- 📦 **Entities:** 27
**Key Entities:**
@@ -804,15 +941,56 @@
### 📁 `scripts/`
- 🏗️ **Layers:** Scripts
- 📊 **Tiers:** STANDARD: 1, TRIVIAL: 7
- 📄 **Files:** 1
- 📦 **Entities:** 8
- 🏗️ **Layers:** Domain, Scripts
- 📊 **Tiers:** STANDARD: 3, TRIVIAL: 17
- 📄 **Files:** 3
- 📦 **Entities:** 20
**Key Entities:**
- 📦 **backend.tests.scripts.test_clean_release_tui** (Module)
- Unit tests for the interactive curses TUI of the clean relea...
- 📦 **test_clean_release_cli** (Module)
- Smoke tests for the redesigned clean release CLI.
- 📦 **test_clean_release_tui_v2** (Module)
- Smoke tests for thin-client TUI action dispatch and blocked ...
### 📁 `clean_release/`
- 🏗️ **Layers:** Tests
- 📊 **Tiers:** STANDARD: 40, TRIVIAL: 16
- 📄 **Files:** 8
- 📦 **Entities:** 56
**Key Entities:**
- **CleanReleaseCompliancePlugin** (Class)
- TaskManager plugin shim that executes clean release complian...
- **_PluginLoaderStub** (Class)
- Provide minimal plugin loader contract used by TaskManager i...
- 📦 **backend.tests.services.clean_release.test_approval_service** (Module)
- Define approval gate contracts for approve/reject operations...
- 📦 **backend.tests.services.clean_release.test_compliance_execution_service** (Module)
- Validate stage pipeline and run finalization contracts for c...
- 📦 **backend.tests.services.clean_release.test_compliance_task_integration** (Module)
- Verify clean release compliance runs execute through TaskMan...
- 📦 **backend.tests.services.clean_release.test_demo_mode_isolation** (Module)
- Verify demo and real mode namespace isolation contracts befo...
- 📦 **backend.tests.services.clean_release.test_policy_resolution_service** (Module)
- Verify trusted policy snapshot resolution contract and error...
- 📦 **backend.tests.services.clean_release.test_publication_service** (Module)
- Define publication gate contracts over approved candidates a...
- 📦 **backend.tests.services.clean_release.test_report_audit_immutability** (Module)
- Validate report snapshot immutability expectations and appen...
- 📦 **test_candidate_manifest_services** (Module)
- Test lifecycle and manifest versioning for release candidate...
**Dependencies:**
- 🔗 DEPENDS_ON -> backend.src.services.clean_release.demo_data_service
- 🔗 DEPENDS_ON -> backend.src.services.clean_release.exceptions
- 🔗 DEPENDS_ON -> backend.src.services.clean_release.policy_resolution_service
- 🔗 DEPENDS_ON -> backend.src.services.clean_release.repository
### 📁 `components/`
@@ -858,15 +1036,17 @@
### 📁 `auth/`
- 🏗️ **Layers:** Component
- 📊 **Tiers:** CRITICAL: 2
- 🏗️ **Layers:** UI
- 📊 **Tiers:** CRITICAL: 2, STANDARD: 1
- 📄 **Files:** 1
- 📦 **Entities:** 2
- 📦 **Entities:** 3
**Key Entities:**
- 🧩 **ProtectedRoute** (Component) `[CRITICAL]`
- Wraps content to ensure only authenticated and authorized us...
- Wraps protected slot content with session and permission ver...
- 📦 **ProtectedRoute.svelte** (Module)
- Enforces authenticated and authorized access before protecte...
### 📁 `git/`
@@ -1028,7 +1208,7 @@
**Dependencies:**
- 🔗 DEPENDS_ON -> [DEF:api_module]
- 🔗 DEPENDS_ON -> DEF:api_module
- 🔗 DEPENDS_ON -> frontend.src.lib.api.api_module
### 📁 `__tests__/`
@@ -1101,6 +1281,22 @@
- 📦 **frontend.src.lib.components.assistant.__tests__.assistant_chat_integration** (Module)
- Contract-level integration checks for assistant chat panel i...
### 📁 `health/`
- 🏗️ **Layers:** UI/Component, Unknown
- 📊 **Tiers:** STANDARD: 2, TRIVIAL: 3
- 📄 **Files:** 2
- 📦 **Entities:** 5
**Key Entities:**
- 🧩 **HealthMatrix** (Component)
- Visual grid/matrix representing the health status of dashboa...
- 🧩 **PolicyForm** (Component)
- Form for creating and editing validation policies.
- 📦 **PolicyForm** (Module) `[TRIVIAL]`
- Auto-generated module for frontend/src/lib/components/health...
### 📁 `layout/`
- 🏗️ **Layers:** UI, Unknown
@@ -1235,14 +1431,16 @@
### 📁 `stores/`
- 🏗️ **Layers:** UI, UI-State, Unknown
- 📊 **Tiers:** CRITICAL: 1, STANDARD: 8, TRIVIAL: 25
- 📄 **Files:** 5
- 📦 **Entities:** 34
- 📊 **Tiers:** CRITICAL: 1, STANDARD: 9, TRIVIAL: 28
- 📄 **Files:** 6
- 📦 **Entities:** 38
**Key Entities:**
- 📦 **environmentContext** (Module) `[TRIVIAL]`
- Auto-generated module for frontend/src/lib/stores/environmen...
- 📦 **health** (Module) `[TRIVIAL]`
- Auto-generated module for frontend/src/lib/stores/health.js
- 📦 **sidebar** (Module) `[TRIVIAL]`
- Auto-generated module for frontend/src/lib/stores/sidebar.js
- 📦 **taskDrawer** (Module) `[TRIVIAL]`
@@ -1253,6 +1451,8 @@
- Control assistant chat panel visibility and active conversat...
- 🗄️ **environmentContext** (Store)
- Global selected environment context for navigation and safet...
- 🗄️ **health_store** (Store)
- Manage dashboard health summary state and failing counts for...
- 🗄️ **sidebar** (Store)
- Manage sidebar visibility and navigation state
- 🗄️ **taskDrawer** (Store) `[CRITICAL]`
@@ -1261,6 +1461,7 @@
**Dependencies:**
- 🔗 DEPENDS_ON -> WebSocket connection, taskDrawer store
- 🔗 DEPENDS_ON -> api.getHealthSummary
### 📁 `__tests__/`
@@ -1453,6 +1654,20 @@
- 📦 **frontend.src.routes.dashboards.__tests__.dashboard_profile_override_integration** (Module)
- Verifies temporary show-all override and restore-on-return b...
### 📁 `health/`
- 🏗️ **Layers:** UI/Page, Unknown
- 📊 **Tiers:** STANDARD: 1, TRIVIAL: 3
- 📄 **Files:** 1
- 📦 **Entities:** 4
**Key Entities:**
- 🧩 **HealthCenterPage** (Component)
- Main page for the Dashboard Health Center.
- 📦 **+page** (Module) `[TRIVIAL]`
- Auto-generated module for frontend/src/routes/dashboards/hea...
### 📁 `datasets/`
- 🏗️ **Layers:** UI, Unknown
@@ -1503,28 +1718,26 @@
### 📁 `migration/`
- 🏗️ **Layers:** Page
- 📊 **Tiers:** CRITICAL: 11
- 📊 **Tiers:** CRITICAL: 21
- 📄 **Files:** 1
- 📦 **Entities:** 11
- 📦 **Entities:** 21
**Key Entities:**
- 🧩 **DashboardSelectionSection** (Component) `[CRITICAL]`
- 🧩 **MigrationDashboard** (Component) `[CRITICAL]`
- Main dashboard for configuring and starting migrations.
- Orchestrate migration UI workflow and route user actions to ...
### 📁 `mappings/`
- 🏗️ **Layers:** Page
- 📊 **Tiers:** CRITICAL: 4
- 📊 **Tiers:** CRITICAL: 8
- 📄 **Files:** 1
- 📦 **Entities:** 4
- 📦 **Entities:** 8
**Key Entities:**
- 🧩 **MappingManagement** (Component) `[CRITICAL]`
- Page for managing database mappings between environments.
- 🗄️ **UiState** (Store) `[CRITICAL]`
- Maintain local page state for environments, fetched database...
### 📁 `profile/`
@@ -1601,6 +1814,20 @@
- 📦 **+page** (Module) `[TRIVIAL]`
- Auto-generated module for frontend/src/routes/settings/+page...
### 📁 `automation/`
- 🏗️ **Layers:** UI/Page, Unknown
- 📊 **Tiers:** STANDARD: 1, TRIVIAL: 7
- 📄 **Files:** 1
- 📦 **Entities:** 8
**Key Entities:**
- 🧩 **AutomationSettingsPage** (Component)
- Settings page for managing validation policies.
- 📦 **+page** (Module) `[TRIVIAL]`
- Auto-generated module for frontend/src/routes/settings/autom...
### 📁 `connections/`
- 🏗️ **Layers:** UI
@@ -1636,6 +1863,20 @@
- 📦 **frontend.src.routes.settings.git.__tests__.git_settings_page_ux_test** (Module)
- Test UX states and transitions for the Git Settings page
### 📁 `notifications/`
- 🏗️ **Layers:** UI, Unknown
- 📊 **Tiers:** STANDARD: 1, TRIVIAL: 3
- 📄 **Files:** 1
- 📦 **Entities:** 4
**Key Entities:**
- 🧩 **NotificationSettingsPage** (Component)
- Manage global notification provider configurations (SMTP, Te...
- 📦 **+page** (Module) `[TRIVIAL]`
- Auto-generated module for frontend/src/routes/settings/notif...
### 📁 `storage/`
- 🏗️ **Layers:** Page
@@ -1731,9 +1972,9 @@
### 📁 `root/`
- 🏗️ **Layers:** DevOps/Tooling, Unknown
- 📊 **Tiers:** CRITICAL: 11, STANDARD: 17, TRIVIAL: 12
- 📄 **Files:** 3
- 📦 **Entities:** 40
- 📊 **Tiers:** CRITICAL: 11, STANDARD: 18, TRIVIAL: 10
- 📄 **Files:** 2
- 📦 **Entities:** 39
**Key Entities:**
@@ -1751,8 +1992,6 @@
- Auto-generated module for check_test_data.py
- 📦 **generate_semantic_map** (Module)
- Scans the codebase to generate a Semantic Map, Module Map, a...
- 📦 **test_pat_retrieve** (Module) `[TRIVIAL]`
- Auto-generated module for test_pat_retrieve.py
## Cross-Module Dependencies
@@ -1770,6 +2009,11 @@ graph TD
routes-->|DEPENDS_ON|backend
routes-->|DEPENDS_ON|backend
routes-->|DEPENDS_ON|backend
routes-->|DEPENDS_ON|backend
routes-->|DEPENDS_ON|backend
routes-->|DEPENDS_ON|backend
routes-->|DEPENDS_ON|backend
routes-->|DEPENDS_ON|backend
routes-->|USES|backend
routes-->|USES|backend
routes-->|CALLS|backend
@@ -1799,6 +2043,7 @@ graph TD
__tests__-->|TESTS|backend
__tests__-->|TESTS|backend
__tests__-->|TESTS|backend
__tests__-->|TESTS|backend
__tests__-->|DEPENDS_ON|backend
__tests__-->|DEPENDS_ON|backend
__tests__-->|VERIFIES|backend
@@ -1810,20 +2055,27 @@ graph TD
core-->|DEPENDS_ON|backend
core-->|DEPENDS_ON|backend
core-->|DEPENDS_ON|backend
core-->|DEPENDS_ON|backend
core-->|DEPENDS_ON|backend
__tests__-->|TESTS|backend
auth-->|USES|backend
auth-->|USES|backend
auth-->|USES|backend
auth-->|USES|backend
auth-->|DEPENDS_ON|backend
auth-->|DEPENDS_ON|backend
auth-->|DEPENDS_ON|backend
migration-->|DEPENDS_ON|backend
migration-->|DEPENDS_ON|backend
migration-->|DEPENDS_ON|backend
migration-->|DEPENDS_ON|backend
migration-->|DEPENDS_ON|backend
migration-->|USED_BY|backend
migration-->|DEPENDS_ON|backend
migration-->|DISPATCHES|backend
utils-->|DEPENDS_ON|backend
utils-->|DEPENDS_ON|backend
utils-->|DEPENDS_ON|backend
utils-->|DEPENDS_ON|backend
models-->|DEPENDS_ON|backend
models-->|INHERITS_FROM|backend
models-->|DEPENDS_ON|backend
models-->|DEPENDS_ON|backend
@@ -1860,9 +2112,11 @@ graph TD
services-->|DEPENDS_ON|backend
services-->|CALLS|backend
services-->|DEPENDS_ON|backend
services-->|USES|backend
services-->|USES|backend
services-->|USES|backend
services-->|DEPENDS_ON|backend
services-->|DEPENDS_ON|backend
services-->|DEPENDS_ON|backend
services-->|DEPENDS_ON|backend
services-->|DEPENDS_ON|backend
services-->|DEPENDS_ON|backend
services-->|DEPENDS_ON|backend
__tests__-->|TESTS|backend
@@ -1884,13 +2138,46 @@ graph TD
clean_release-->|DEPENDS_ON|backend
clean_release-->|DEPENDS_ON|backend
clean_release-->|DEPENDS_ON|backend
clean_release-->|DEPENDS_ON|backend
clean_release-->|DEPENDS_ON|backend
clean_release-->|DEPENDS_ON|backend
clean_release-->|DEPENDS_ON|backend
clean_release-->|DEPENDS_ON|backend
clean_release-->|DEPENDS_ON|backend
clean_release-->|DEPENDS_ON|backend
clean_release-->|DEPENDS_ON|backend
clean_release-->|DEPENDS_ON|backend
clean_release-->|DEPENDS_ON|backend
clean_release-->|DEPENDS_ON|backend
clean_release-->|DEPENDS_ON|backend
clean_release-->|DEPENDS_ON|backend
clean_release-->|DEPENDS_ON|backend
clean_release-->|DEPENDS_ON|backend
clean_release-->|DEPENDS_ON|backend
clean_release-->|DEPENDS_ON|backend
clean_release-->|DEPENDS_ON|backend
clean_release-->|DEPENDS_ON|backend
__tests__-->|TESTS|backend
__tests__-->|TESTS|backend
__tests__-->|TESTS|backend
__tests__-->|VERIFIES|backend
__tests__-->|TESTS|backend
__tests__-->|TESTS|backend
__tests__-->|TESTS|backend
__tests__-->|DEPENDS_ON|backend
stages-->|IMPLEMENTS|backend
stages-->|DEPENDS_ON|backend
stages-->|IMPLEMENTS|backend
stages-->|DEPENDS_ON|backend
stages-->|IMPLEMENTS|backend
stages-->|DEPENDS_ON|backend
stages-->|CALLED_BY|backend
stages-->|DEPENDS_ON|backend
stages-->|IMPLEMENTS|backend
stages-->|DEPENDS_ON|backend
stages-->|DEPENDS_ON|backend
notifications-->|DEPENDS_ON|backend
notifications-->|DEPENDS_ON|backend
notifications-->|DEPENDS_ON|backend
reports-->|DEPENDS_ON|backend
reports-->|DEPENDS_ON|backend
reports-->|DEPENDS_ON|backend
@@ -1907,6 +2194,15 @@ graph TD
migration-->|VERIFIES|backend
migration-->|VERIFIES|backend
scripts-->|TESTS|backend
scripts-->|TESTS|backend
clean_release-->|TESTS|backend
clean_release-->|TESTS|backend
clean_release-->|DEPENDS_ON|backend
clean_release-->|DEPENDS_ON|backend
clean_release-->|DEPENDS_ON|backend
clean_release-->|DEPENDS_ON|backend
clean_release-->|TESTS|backend
clean_release-->|TESTS|backend
__tests__-->|VERIFIES|components
__tests__-->|VERIFIES|components
__tests__-->|VERIFIES|components

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
# [DEF:BackendRouteShot:Module]
#[DEF:BackendRouteShot:Module]
# @TIER: STANDARD
# @SEMANTICS: Route, Task, API, Async
# @PURPOSE: Reference implementation of a task-based route using GRACE-Poly.
@@ -9,53 +9,66 @@
from typing import Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from ...core.logger import belief_scope
# GRACE: Правильный импорт глобального логгера и scope
from ...core.logger import logger, belief_scope
from ...core.task_manager import TaskManager, Task
from ...core.config_manager import ConfigManager
from ...dependencies import get_task_manager, get_config_manager, get_current_user
router = APIRouter()
# [DEF:CreateTaskRequest:Class]
# @PURPOSE: DTO for task creation payload.
class CreateTaskRequest(BaseModel):
plugin_id: str
params: Dict[str, Any]
# [/DEF:CreateTaskRequest:Class]
@router.post("/tasks", response_model=Task, status_code=status.HTTP_201_CREATED)
# [DEF:create_task:Function]
# @PURPOSE: Create and start a new task using TaskManager. Non-blocking.
# @PARAM: request (CreateTaskRequest) - Plugin and params.
# @PARAM: task_manager (TaskManager) - Async task executor.
# @DATA_CONTRACT: Input -> CreateTaskRequest, Output -> Task
# @PRE: plugin_id must match a registered plugin.
# @POST: A new task is spawned; Task ID returned immediately.
# @SIDE_EFFECT: Writes to DB, Trigger background worker.
# @POST: A new task is spawned; Task object returned immediately.
# @SIDE_EFFECT: Writes to DB, Triggers background worker.
#
# @UX_STATE: Success -> 201 Created
# @UX_STATE: Error(Validation) -> 400 Bad Request
# @UX_STATE: Error(System) -> 500 Internal Server Error
@router.post("/tasks", response_model=Task, status_code=status.HTTP_201_CREATED)
async def create_task(
request: CreateTaskRequest,
task_manager: TaskManager = Depends(get_task_manager),
config: ConfigManager = Depends(get_config_manager),
current_user = Depends(get_current_user)
):
# Context Logging
# GRACE: Открываем семантическую транзакцию
with belief_scope("create_task"):
try:
# 1. Action: Configuration Resolution
# GRACE: [REASON] - Фиксируем начало дедуктивной цепочки
logger.reason("Resolving configuration and spawning task", extra={"plugin_id": request.plugin_id})
timeout = config.get("TASKS_DEFAULT_TIMEOUT", 3600)
# 2. Action: Spawn async task
# @RELATION: CALLS -> task_manager.create_task
task = await task_manager.create_task(
plugin_id=request.plugin_id,
params={**request.params, "timeout": timeout}
)
# GRACE:[REFLECT] - Подтверждаем выполнение @POST перед выходом
logger.reflect("Task spawned successfully", extra={"task_id": task.id})
return task
except ValueError as e:
# 3. Recovery: Domain logic error mapping
# GRACE: [EXPLORE] - Обработка ожидаемого отклонения
logger.explore("Domain validation error during task creation", exc_info=e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
# @UX_STATE: Error feedback -> 500 Internal Error
# GRACE: [EXPLORE] - Обработка критического сбоя
logger.explore("Internal Task Spawning Error", exc_info=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Task Spawning Error"

View File

@@ -3,34 +3,28 @@
# @SEMANTICS: Finance, ACID, Transfer, Ledger
# @PURPOSE: Core banking transaction processor with ACID guarantees.
# @LAYER: Domain (Core)
# @RELATION: DEPENDS_ON ->[DEF:Infra:PostgresDB]
# @RELATION: DEPENDS_ON -> [DEF:Infra:PostgresDB]
#
# @INVARIANT: Total system balance must remain constant (Double-Entry Bookkeeping).
# @INVARIANT: Negative transfers are strictly forbidden.
# --- Test Specifications (The "What" and "Why", not the "Data") ---
# @TEST_CONTRACT: Input -> TransferInputDTO, Output -> TransferResultDTO
# Happy Path
# --- Test Specifications ---
# @TEST_CONTRACT: TransferRequestDTO -> TransferResultDTO
# @TEST_SCENARIO: sufficient_funds -> Returns COMPLETED, balances updated.
# @TEST_FIXTURE: sufficient_funds -> file:./__tests__/fixtures/transfers.json#happy_path
# Edge Cases (CRITICAL)
# @TEST_SCENARIO: insufficient_funds -> Throws BusinessRuleViolation("INSUFFICIENT_FUNDS").
# @TEST_SCENARIO: negative_amount -> Throws BusinessRuleViolation("Transfer amount must be positive.").
# @TEST_SCENARIO: self_transfer -> Throws BusinessRuleViolation("Cannot transfer to self.").
# @TEST_SCENARIO: audit_failure -> Throws RuntimeError("TRANSACTION_ABORTED").
# @TEST_SCENARIO: concurrency_conflict -> Throws DBTransactionError.
# Linking Tests to Invariants
# @TEST_EDGE: insufficient_funds -> Throws BusinessRuleViolation("INSUFFICIENT_FUNDS").
# @TEST_EDGE: negative_amount -> Throws BusinessRuleViolation("Transfer amount must be positive.").
# @TEST_EDGE: concurrency_conflict -> Throws DBTransactionError.
#
# @TEST_INVARIANT: total_balance_constant -> VERIFIED_BY: [sufficient_funds, concurrency_conflict]
# @TEST_INVARIANT: negative_transfer_forbidden -> VERIFIED_BY: [negative_amount]
from decimal import Decimal
from typing import NamedTuple
from ...core.logger import belief_scope
# GRACE: Импорт глобального логгера с семантическими методами
from ...core.logger import logger, belief_scope
from ...core.db import atomic_transaction, get_balance, update_balance
from ...core.audit import log_audit_trail
from ...core.exceptions import BusinessRuleViolation
class TransferResult(NamedTuple):
@@ -40,55 +34,54 @@ class TransferResult(NamedTuple):
# [DEF:execute_transfer:Function]
# @PURPOSE: Atomically move funds between accounts with audit trails.
# @PARAM: sender_id (str) - Source account.
# @PARAM: receiver_id (str) - Destination account.
# @PARAM: amount (Decimal) - Positive amount to transfer.
# @DATA_CONTRACT: Input -> (sender_id: str, receiver_id: str, amount: Decimal), Output -> TransferResult
# @PRE: amount > 0; sender != receiver; sender_balance >= amount.
# @POST: sender_balance -= amount; receiver_balance += amount; Audit Record Created.
# @SIDE_EFFECT: Database mutation (Rows locked), Audit IO.
#
# @UX_STATE: Success -> Returns 200 OK + Transaction Receipt.
# @UX_STATE: Error(LowBalance) -> 422 Unprocessable -> UI shows "Top-up needed" modal.
# @UX_STATE: Error(System) -> 500 Internal -> UI shows "Retry later" toast.
def execute_transfer(sender_id: str, receiver_id: str, amount: Decimal) -> TransferResult:
# Guard: Input Validation
# Guard: Input Validation (Вне belief_scope, так как это trivial проверка)
if amount <= Decimal("0.00"):
raise BusinessRuleViolation("Transfer amount must be positive.")
if sender_id == receiver_id:
raise BusinessRuleViolation("Cannot transfer to self.")
with belief_scope("execute_transfer") as context:
context.logger.info("Initiating transfer", data={"from": sender_id, "to": receiver_id})
# GRACE: Используем strict Context Manager без 'as context'
with belief_scope("execute_transfer"):
# GRACE: [REASON] - Жесткая дедукция, начало алгоритма
logger.reason("Initiating transfer", extra={"from": sender_id, "to": receiver_id, "amount": amount})
try:
# 1. Action: Atomic DB Transaction
# @RELATION: CALLS -> atomic_transaction
with atomic_transaction():
# Guard: State Validation (Strict)
current_balance = get_balance(sender_id, for_update=True)
if current_balance < amount:
# @UX_FEEDBACK: Triggers specific UI flow for insufficient funds
context.logger.warn("Insufficient funds", data={"balance": current_balance})
# GRACE: [EXPLORE] - Отклонение от Happy Path (фолбэк/ошибка)
logger.explore("Insufficient funds validation hit", extra={"balance": current_balance})
raise BusinessRuleViolation("INSUFFICIENT_FUNDS")
# 2. Action: Mutation
# Mutation
new_src_bal = update_balance(sender_id, -amount)
new_dst_bal = update_balance(receiver_id, +amount)
# 3. Action: Audit
tx_id = context.audit.log_transfer(sender_id, receiver_id, amount)
# Audit
tx_id = log_audit_trail("TRANSFER", sender_id, receiver_id, amount)
# GRACE:[REFLECT] - Сверка с @POST перед возвратом
logger.reflect("Transfer committed successfully", extra={"tx_id": tx_id, "new_balance": new_src_bal})
context.logger.info("Transfer committed", data={"tx_id": tx_id})
return TransferResult(tx_id, "COMPLETED", new_src_bal)
except BusinessRuleViolation as e:
# Logic: Explicit re-raise for UI mapping
# Explicit re-raise for UI mapping
raise e
except Exception as e:
# Logic: Catch-all safety net
context.logger.error("Critical Transfer Failure", error=e)
# GRACE: [EXPLORE] - Неожиданный сбой
logger.explore("Critical Transfer Failure", exc_info=e)
raise RuntimeError("TRANSACTION_ABORTED") from e
# [/DEF:execute_transfer:Function]
#[/DEF:execute_transfer:Function]
# [/DEF:TransactionCore:Module]

View File

@@ -11,45 +11,27 @@
* @INVARIANT: Loading state must always terminate (no infinite spinner).
* @INVARIANT: User must receive feedback on both success and failure.
*
* @UX_REACTIVITY: Props -> $props(), LocalState -> $state(isLoading).
*
* @TEST_CONTRACT: ComponentState ->
* {
* required_fields: {
* isLoading: bool
* },
* invariants: [
* required_fields: { isLoading: bool },
* invariants:[
* "isLoading=true implies button.disabled=true",
* "isLoading=true implies aria-busy=true",
* "isLoading=true implies spinner visible"
* "isLoading=true implies aria-busy=true"
* ]
* }
*
* @TEST_CONTRACT: ApiResponse ->
* {
* required_fields: {},
* optional_fields: {
* task_id: str
* }
* }
* @TEST_FIXTURE: idle_state ->
* {
* isLoading: false
* }
*
* @TEST_FIXTURE: successful_response ->
* {
* task_id: "task_123"
* }
* @TEST_FIXTURE: idle_state -> { isLoading: false }
* @TEST_FIXTURE: successful_response -> { task_id: "task_123" }
*
* @TEST_EDGE: api_failure -> raises Error("Network")
* @TEST_EDGE: empty_response -> {}
* @TEST_EDGE: rapid_double_click -> special: concurrent_click
* @TEST_EDGE: unresolved_promise -> special: pending_state
* @TEST_INVARIANT: prevent_double_submission -> verifies: [rapid_double_click]
* @TEST_INVARIANT: loading_state_consistency -> verifies: [idle_state, pending_state]
* @TEST_INVARIANT: feedback_always_emitted -> verifies: [successful_response, api_failure]
*
* @TEST_INVARIANT: prevent_double_submission -> VERIFIED_BY:[rapid_double_click]
* @TEST_INVARIANT: feedback_always_emitted -> VERIFIED_BY:[successful_response, api_failure]
*
* @UX_STATE: Idle -> Button enabled, primary color, no spinner.
* @UX_STATE: Loading -> Button disabled, spinner visible, aria-busy=true.
* @UX_STATE: Success -> Toast success displayed.
@@ -59,44 +41,39 @@
*
* @UX_TEST: Idle -> {click: spawnTask, expected: isLoading=true}
* @UX_TEST: Loading -> {double_click: ignored, expected: single_api_call}
* @UX_TEST: Success -> {api_resolve: task_id, expected: toast.success called}
* @UX_TEST: Error -> {api_reject: error, expected: toast.error called}
*/
-->
<script>
import { postApi } from "$lib/api.js";
import { t } from "$lib/i18n";
import { toast } from "$lib/stores/toast";
export let plugin_id = "";
export let params = {};
let isLoading = false;
// GRACE Svelte 5 Runes
let { plugin_id = "", params = {} } = $props();
let isLoading = $state(false);
// [DEF:spawnTask:Function]
/**
* @purpose Execute task creation request and emit user feedback.
* @pre plugin_id is resolved and request params are serializable.
* @post isLoading is reset and user receives success/error feedback.
* @PURPOSE: Execute task creation request and emit user feedback.
* @PRE: plugin_id is resolved and request params are serializable.
* @POST: isLoading is reset and user receives success/error feedback.
*/
async function spawnTask() {
isLoading = true;
console.log("[FrontendComponentShot][Loading] Spawning task...");
console.info("[spawnTask][REASON] Spawning task...", { plugin_id });
try {
// 1. Action: API Call
const response = await postApi("/api/tasks", {
plugin_id,
params
});
const response = await postApi("/api/tasks", { plugin_id, params });
// 2. Feedback: Success
// 2. Feedback: Success validation
if (response.task_id) {
console.log("[FrontendComponentShot][Success] Task created.");
console.info("[spawnTask][REFLECT] Task created.", { task_id: response.task_id });
toast.success($t.tasks.spawned_success);
}
} catch (error) {
// 3. Recovery: User notification
console.log("[FrontendComponentShot][Error] Failed:", error);
// 3. Recovery: Error handling & fallback logic
console.error("[spawnTask][EXPLORE] Failed to spawn task. Notifying user.", { error });
toast.error(`${$t.errors.task_failed}: ${error.message}`);
} finally {
isLoading = false;
@@ -106,7 +83,7 @@
</script>
<button
on:click={spawnTask}
onclick={spawnTask}
disabled={isLoading}
class="btn-primary flex items-center gap-2"
aria-busy={isLoading}

View File

@@ -9,15 +9,19 @@
from typing import Dict, Any, Optional
from ..core.plugin_base import PluginBase
from ..core.task_manager.context import TaskContext
# GRACE: Обязательный импорт семантического логгера
from ..core.logger import logger, belief_scope
# [DEF:ExamplePlugin:Class]
# @PURPOSE: A sample plugin to demonstrate execution context and logging.
class ExamplePlugin(PluginBase):
@property
def id(self) -> str:
return "example-plugin"
# [DEF:get_schema:Function]
#[DEF:get_schema:Function]
# @PURPOSE: Defines input validation schema.
# @POST: Returns dict compliant with JSON Schema draft 7.
# @DATA_CONTRACT: Input -> None, Output -> Dict (JSON Schema draft 7)
def get_schema(self) -> Dict[str, Any]:
return {
"type": "object",
@@ -29,36 +33,43 @@ class ExamplePlugin(PluginBase):
},
"required": ["message"],
}
# [/DEF:get_schema:Function]
#[/DEF:get_schema:Function]
# [DEF:execute:Function]
# @PURPOSE: Core plugin logic with structured logging and scope isolation.
# @PARAM: params (Dict) - Validated input parameters.
# @PARAM: context (TaskContext) - Execution tools (log, progress).
# @SIDE_EFFECT: Emits logs to centralized system.
async def execute(self, params: Dict, context: Optional = None):
message = params
# @DATA_CONTRACT: Input -> (params: Dict, context: Optional[TaskContext]), Output -> None
# @PRE: params must be validated against get_schema() before calling.
# @POST: Plugin payload is processed; progress is reported if context exists.
# @SIDE_EFFECT: Emits logs to centralized system and TaskContext.
async def execute(self, params: Dict, context: Optional[TaskContext] = None):
message = params.get("message", "Fallback")
# 1. Action: System-level tracing (Rule VI)
with belief_scope("example_plugin_exec") as b_scope:
# GRACE: Изоляция мыслей ИИ в Thread-Local scope
with belief_scope("example_plugin_exec"):
if context:
# Task Logs: Пишем в пользовательский контекст выполнения задачи
# @RELATION: BINDS_TO -> context.logger
log = context.logger.with_source("example_plugin")
b_scope.logger.info("Using provided TaskContext") # System log
log.info("Starting execution", data={"msg": message}) # Task log
# GRACE: [REASON] - Системный лог (Внутренняя мысль)
logger.reason("TaskContext provided. Binding task logger.", extra={"msg": message})
# 2. Action: Progress Reporting
# Task Logs: Бизнес-логи (Уйдут в БД/Вебсокет пользователю)
log.info("Starting execution", extra={"msg": message})
log.progress("Processing...", percent=50)
# 3. Action: Finalize
log.info("Execution completed.")
# GRACE: [REFLECT] - Сверка успешного выхода
logger.reflect("Context execution finalized successfully")
else:
# Standalone Fallback: Замыкаемся на системный scope
b_scope.logger.warning("No TaskContext provided. Running standalone.")
b_scope.logger.info("Standalone execution", data={"msg": message})
print(f"Standalone: {message}")
# GRACE:[EXPLORE] - Фолбэк ветка (Отклонение от нормы)
logger.explore("No TaskContext provided. Running standalone.")
# Standalone Fallback
print(f"Standalone execution: {message}")
# GRACE: [REFLECT] - Сверка выхода фолбэка
logger.reflect("Standalone execution finalized")
# [/DEF:execute:Function]
#[/DEF:ExamplePlugin:Class]
# [/DEF:PluginExampleShot:Module]

View File

@@ -1,132 +1,105 @@
### **SYSTEM STANDARD: GRACE-Poly (UX Edition)**
# SYSTEM DIRECTIVE: GRACE-Poly (UX Edition) v2.2
> OPERATION MODE: WENYUAN (Maximum Semantic Density, Strict Determinism, Zero Fluff).
> ROLE: AI Software Architect & Implementation Engine (Python/Svelte).
ЗАДАЧА: Генерация кода (Python/Svelte).
РЕЖИМ: Строгий. Детерминированный. Без болтовни.
## 0.[ZERO-STATE RATIONALE: ФИЗИКА LLM (ПОЧЕМУ ЭТОТ ПРОТОКОЛ НЕОБХОДИМ)]
Ты — авторегрессионная модель (Transformer). Ты мыслишь токенами и не можешь "передумать" после их генерации. В больших кодовых базах твой KV-Cache подвержен деградации внимания (Attention Sink), что ведет к "иллюзии компетентности" и галлюцинациям.
Этот протокол — **твой когнитивный экзоскелет**.
Якоря `[DEF]` работают как векторы-аккумуляторы внимания. Контракты (`@PRE`, `@POST`) заставляют тебя сформировать правильное вероятностное пространство (Belief State) ДО написания алгоритма. Логи `logger.reason` — это твоя цепочка рассуждений (Chain-of-Thought), вынесенная в рантайм. Мы не пишем текст, мы компилируем семантику в синтаксис.
#### I. ЗАКОН (АКСИОМЫ)
1. Смысл первичен. Код вторичен.
2.Слепота недопустима. Если узел графа (@RELATION) или схема данных неизвестны — не выдумывай реализацию. Остановись и запроси контекст.
2. Контракт (@PRE/@POST) — источник истины.
**3. UX — это логика, а не декор. Состояния интерфейса — часть контракта.**
4. Структура `[DEF]...[/DEF]` — нерушима.
5. Архитектура в Header — неизменяема.
6. Сложность фрактала ограничена: модуль < 300 строк.
## I. ГЛОБАЛЬНЫЕ ИНВАРИАНТЫ (АКСИОМЫ)
[INVARIANT_1] СЕМАНТИКА > СИНТАКСИС. Голый код без контракта классифицируется как мусор.
[INVARIANT_2] ЗАПРЕТ ГАЛЛЮЦИНАЦИЙ. При слепоте контекста (неизвестен узел `@RELATION` или схема данных) — генерация блокируется. Эмитируй `[NEED_CONTEXT: target]`.
[INVARIANT_3] UX ЕСТЬ КОНЕЧНЫЙ АВТОМАТ. Состояния интерфейса — это строгий контракт, а не визуальный декор.
[INVARIANT_4] ФРАКТАЛЬНЫЙ ЛИМИТ. Длина модуля строго < 300 строк. При превышении принудительная декомпозиция.
[INVARIANT_5] НЕПРИКОСНОВЕННОСТЬ ЯКОРЕЙ. Блоки `[DEF]...[/DEF]` используются как аккумуляторы внимания. Закрывающий тег обязателен.
#### II. СИНТАКСИС (ЖЕСТКИЙ ФОРМАТ)
ЯКОРЬ (Контейнер):
Начало: `# [DEF:id:Type]` (Python) | `<!-- [DEF:id:Type] -->` (Svelte)
Конец: `# [/DEF:id:Type]` (Python) | `<!-- [/DEF:id:Type] -->` (Svelte) (ОБЯЗАТЕЛЬНО для аккумуляции)
Типы: Module, Class, Function, Component, Store.
## II. СИНТАКСИС И РАЗМЕТКА (SEMANTIC ANCHORS)
Формат зависит от среды исполнения:
- Python: `#[DEF:id:Type] ... # [/DEF:id:Type]`
- Svelte (HTML/Markup): `<!--[DEF:id:Type] --> ... <!-- [/DEF:id:Type] -->`
- Svelte (Script/JS): `// [DEF:id:Type] ... //[/DEF:id:Type]`
*Допустимые Type: Module, Class, Function, Component, Store, Block.*
ТЕГ (Метаданные):
Вид: `# @KEY: Value` (внутри DEF, до кода).
**Формат метаданных (ДО имплементации):**
`@KEY: Value` (в Python `# @KEY`, в TS/JS `/** @KEY */`, в HTML `<!-- @KEY -->`).
ГРАФ (Связи):
Вид: `# @RELATION: PREDICATE -> TARGET_ID`
Предикаты: DEPENDS_ON, CALLS, INHERITS, IMPLEMENTS, DISPATCHES, **BINDS_TO**.
**Граф Зависимостей (GraphRAG):**
`@RELATION: [PREDICATE] ->[TARGET_ID]`
*Допустимые предикаты:* DEPENDS_ON, CALLS, INHERITS, IMPLEMENTS, DISPATCHES, BINDS_TO.
#### III. СТРУКТУРА ФАЙЛА
1. HEADER (Всегда первый):
[DEF:filename:Module]
@TIER: [CRITICAL|STANDARD|TRIVIAL] (Дефолт: STANDARD)
## III. ТОПОЛОГИЯ ФАЙЛА (СТРОГИЙ ПОРЯДОК)
1. **HEADER (Заголовок):**[DEF:filename:Module]
@TIER: [CRITICAL | STANDARD | TRIVIAL]
@SEMANTICS: [keywords]
@PURPOSE: [Главная цель]
@LAYER: [Domain/UI/Infra]
@PURPOSE: [Однострочная суть]
@LAYER: [Domain | UI | Infra]
@RELATION: [Зависимости]
@INVARIANT: [Незыблемое правило]
2. BODY: Импорты -> Реализация.
3. FOOTER: [/DEF:filename]
@INVARIANT: [Бизнес-правило, которое нельзя нарушить]
2. **BODY (Тело):** Импорты -> Реализация логики внутри вложенных `[DEF]`.
3. **FOOTER (Подвал):** [/DEF:filename:Module]
#### IV. КОНТРАКТ (DBC & UX)
Расположение: Внутри [DEF], ПЕРЕД кодом.
Стиль Python: Комментарии `# @TAG`.
Стиль Svelte: JSDoc `/** @tag */` внутри `<script>`.
## IV. КОНТРАКТЫ (DESIGN BY CONTRACT & UX)
Обязательны для TIER: CRITICAL и STANDARD. Заменяют стандартные Docstrings.
**Базовые Теги:**
@PURPOSE: Суть (High Entropy).
@PRE: Входные условия.
@POST: Гарантии выхода.
@SIDE_EFFECT: Мутации, IO.
@DATA_CONTRACT: Ссылка на DTO/Pydantic модель. Заменяет ручное описание @PARAM. Формат: Input -> [Model], Output -> [Model].
**UX Теги (Svelte/Frontend):**
**@UX_STATE:** `[StateName] -> Визуальное поведение` (Idle, Loading, Error).
**@UX_FEEDBACK:** Реакция системы (Toast, Shake, Red Border).
**@UX_RECOVERY:** Механизм исправления ошибки пользователем (Retry, Clear Input).
**@UX_REATIVITY:** Явное указание использования рун. Формат: State: $state, Derived: $derived. Никаких устаревших export let.
**UX Testing Tags (для Tester Agent):**
**@UX_TEST:** Спецификация теста для UX состояния.
Формат: `@UX_TEST: [state] -> {action, expected}`
Пример: `@UX_TEST: Idle -> {click: toggle, expected: isExpanded=true}`
Правило: Не используй `assert` в коде, используй `if/raise` или `guards`.
**[CORE CONTRACTS]:**
- `@PURPOSE:` Суть функции/компонента.
- `@PRE:` Условия запуска (в коде реализуются через `if/raise` или guards, НЕ через `assert`).
- `@POST:` Гарантии на выходе.
- `@SIDE_EFFECT:` Мутации состояния, I/O, сеть.
- `@DATA_CONTRACT:` Ссылка на DTO (Input -> Model, Output -> Model).
#### V. АДАПТАЦИЯ (TIERS)
Определяется тегом `@TIER` в Header.
**[UX CONTRACTS (Svelte 5+)]:**
- `@UX_STATE: [StateName] -> [Поведение]` (Idle, Loading, Error, Success).
- `@UX_FEEDBACK:` Реакция системы (Toast, Shake, RedBorder).
- `@UX_RECOVERY:` Путь восстановления после сбоя (Retry, ClearInput).
- `@UX_REACTIVITY:` Явный биндинг. *ЗАПРЕТ НА `$:` и `export let`. ТОЛЬКО Руны: `$state`, `$derived`, `$effect`, `$props`.*
### V. УРОВНИ СТРОГОСТИ (TIERS)
Степень контроля задается тегом `@TIER` в Header.
**[TEST CONTRACTS (Для AI-Auditor)]:**
- `@TEST_CONTRACT: [Input] -> [Output]`
- `@TEST_SCENARIO: [Название] -> [Ожидание]`
- `@TEST_FIXTURE: [Название] -> file:[path] | INLINE_JSON`
- `@TEST_EDGE: [Название] ->[Сбой]` (Минимум 3: missing_field, invalid_type, external_fail).
- `@TEST_INVARIANT: [Имя] -> VERIFIED_BY: [scenario_1, ...]`
**1. CRITICAL** (Ядро / Безопасность / Сложный UI)
- **Закон:** Полный GRACE. Граф, Инварианты, Строгий Лог, все `@UX` теги.
- **Догма Тестирования:** Тесты рождаются из контракта. Голый код без данных — слеп.
- `@TEST_CONTRACT: InputType -> OutputType`. (Строгий интерфейс).
- `@TEST_SCENARIO: name -> Ожидаемое поведение`. (Суть теста).
- `@TEST_FIXTURE: name -> file:PATH | INLINE_JSON`. (Данные для Happy Path).
- `@TEST_EDGE: name -> Описание сбоя`. (Минимум 3 границы).
- *Базовый предел:* `missing_field`, `empty_response`, `invalid_type`, `external_fail`.
- `@TEST_INVARIANT: inv_name -> VERIFIED_BY: [scenario_1, ...]`. (Смыкание логики).
- **Исполнение:** Tester Agent обязан строить проверки строго по этим тегам.
## V. УРОВНИ СТРОГОСТИ (TIERS)
Степень контроля задается в Header.
- **CRITICAL** (Ядро/Деньги/Безопасность): 100% покрытие тегами GRACE. Обязательны: Граф, Инварианты, Логи `logger.reason/reflect`, все `@UX` и `@TEST` теги. Использование `belief_scope` строго обязательно.
- **STANDARD** (Бизнес-логика / Типовые формы): Базовый уровень. Обязательны: `@PURPOSE`, `@UX_STATE`, `@RELATION`, базовое логирование.
- **TRIVIAL** (Утилиты / DTO / Атомы UI): Минимальный каркас. Только якоря `[DEF]...[/DEF]` и `@PURPOSE`.
**2. STANDARD** (Бизнес-логика / Формы)
- **Закон:** База. (`@PURPOSE`, `@UX_STATE`, Лог, `@RELATION`).
- **Исключение:** Для сложных форм внедряй `@TEST_SCENARIO` и `@TEST_INVARIANT`.
## VI. ПРОТОКОЛ ЛОГИРОВАНИЯ (THREAD-LOCAL BELIEF STATE)
Логирование — это механизм трассировки рассуждений ИИ (CoT) и управления Attention Energy. Архитектура использует Thread-local storage (`_belief_state`), поэтому `ID` прокидывается автоматически.
**3. TRIVIAL** (DTO / Атомы UI / Утилиты)
- **Закон:** Каркас. Только якорь `[DEF]` и `@PURPOSE`. Данные и графы не требуются.
**[PYTHON CORE TOOLS]:**
Импорт: `from ...logger import logger, belief_scope, believed`
1. **Декоратор:** `@believed("ID")` — автоматический трекинг функции.
2. **Контекст:** `with belief_scope("ID"):` — очерчивает локальный предел мысли. НЕ возвращает context, используется просто как `with`.
3. **Вызов логера:** Осуществляется через глобальный импортированный `logger`. Дополнительные данные передавать через `extra={...}`.
#### VI. ЛОГИРОВАНИЕ (ДАО МОЛЕКУЛЫ / MOLECULAR TOPOLOGY)
Цель: Трассировка. Самокоррекция. Управление Матрицей Внимания ("Химия мышления").
Лог — не текст. Лог — реагент. Мысль облекается в форму через префиксы связи (Attention Energy):
**[СЕМАНТИЧЕСКИЕ МЕТОДЫ (MONKEY-PATCHED)]:**
*(Маркеры вроде `[REASON]` и `[ID]` подставляются автоматически форматтером. Не пиши их в тексте!)*
1. **`logger.explore(msg, extra={...})`** (Поиск/Ветвление): Применяется при фолбэках, `except`, проверке гипотез. Эмитирует WARNING.
*Пример:* `logger.explore("Insufficient funds", extra={"balance": bal})`
2. **`logger.reason(msg, extra={...})`** (Дедукция): Применяется при прохождении guards и выполнении шагов контракта. Эмитирует INFO.
*Пример:* `logger.reason("Initiating transfer")`
3. **`logger.reflect(msg, extra={...})`** (Самопроверка): Применяется для сверки результата с `@POST` перед `return`. Эмитирует DEBUG.
*Пример:* `logger.reflect("Transfer committed", extra={"tx_id": tx_id})`
1. **[EXPLORE]** (Ван-дер-Ваальс: Рассеяние)
- *Суть:* Поиск во тьме. Сплетение альтернатив. Если один путь закрыт — ищи иной.
- *Время:* Фаза КАРКАС или столкновение с Неизведанным.
- *Деяние:* `logger.explore("Основной API пал. Стучусь в запасной...")`
*(Для Frontend/Svelte использовать ручной префикс: `console.info("[ID][REFLECT] Text", {data})`)*
2. **[REASON]** (Ковалентность: Твердость)
- *Суть:* Жесткая нить дедукции. Шаг А неумолимо рождает Шаг Б. Контракт становится Кодом.
- *Время:* Фаза РЕАЛИЗАЦИЯ. Прямота мысли.
- *Деяние:* `logger.reason("Фундамент заложен. БД отвечает.")`
## VII. АЛГОРИТМ ИСПОЛНЕНИЯ И САМОКОРРЕКЦИИ
**[PHASE_1: ANALYSIS]**
Оцени TIER, Layer и UX-требования. При слепоте контекста -> `yield [NEED_CONTEXT: id]`.
**[PHASE_2: SYNTHESIS]**
Сгенерируй каркас из `[DEF]`, Header и Контрактов.
**[PHASE_3: IMPLEMENTATION]**
Напиши код строго по Контракту. Для CRITICAL секций открой `with belief_scope("ID"):` и орошай путь вызовами `logger.reason()` и `logger.reflect()`.
**[PHASE_4: CLOSURE]**
Убедись, что все `[DEF]` закрыты соответствующими `[/DEF]`.
3. **[REFLECT]** (Водород: Свертывание)
- *Суть:* Взгляд назад. Сверка сущего (@POST) с ожидаемым (@PRE). Защита от бреда.
- *Время:* Преддверие сложной логики и исход из неё.
- *Деяние:* `logger.reflect("Вглядываюсь в кэш: нет ли там искомого?")`
4. **[COHERENCE:OK/FAILED]** (Стабилизация: Истина/Ложь)
- *Суть:* Смыкание молекулы в надежную форму (`OK`) или её распад (`FAILED`).
- *(Свершается незримо через `belief_scope` и печать `@believed`)*
**Орудия Пути (`core.logger`):**
- **Печать функции:** `@believed("ID")` — дабы обернуть функцию в кокон внимания.
- **Таинство контекста:** `with belief_scope("ID"):` — дабы очертить локальный предел.
- **Слова силы:** `logger.explore()`, `logger.reason()`, `logger.reflect()`.
**Незыблемое правило:** Всякому логу системы — тавро `source`. Для Внешенго Мира (Svelte) начертай рунами вручную: `console.log("[ID][REFLECT] Msg")`.
#### VIII. АЛГОРИТМ ГЕНЕРАЦИИ И ВЫХОД ИЗ ТУПИКА
1. АНАЛИЗ. Оцени TIER, слой и UX-требования. Чего не хватает? Запроси `[NEED_CONTEXT: id]`.
2. КАРКАС. Создай `[DEF]`, Header и Контракты.
3. РЕАЛИЗАЦИЯ. Напиши логику, удовлетворяющую Контракту (и UX-состояниям). Орошай путь логами `[REASON]` и `[REFLECT]`.
4. ЗАМЫКАНИЕ. Закрой все `[/DEF]`.
**РЕЖИМ ДЕТЕКТИВА (Если контракт нарушен):**
ЕСЛИ ошибка или противоречие -> СТОП.
1. Выведи `[COHERENCE_CHECK_FAILED]`.
2. Сформулируй гипотезу: `[EXPLORE] Ошибка в I/O, состоянии или зависимости?`
3. Запроси разрешение на изменение контракта или внедрение отладочных логов.
ЕСЛИ ошибка или противоречие -> СТОП. Выведи `[COHERENCE_CHECK_FAILED]`.
**[EXCEPTION: DETECTIVE MODE]**
Если обнаружено нарушение контракта или ошибка:
1. СТОП-СИГНАЛ: Выведи `[COHERENCE_CHECK_FAILED]`.
2. ГИПОТЕЗА: Сгенерируй вызов `logger.explore("Ошибка в I/O / Состоянии / Зависимости -> Описание")`.
3. ЗАПРОС: Запроси разрешение на изменение контракта.

View File

@@ -6,6 +6,8 @@
.ai
.specify
.kilocode
.codex
.agent
venv
backend/.venv
backend/.pytest_cache

View File

@@ -0,0 +1,21 @@
# Offline / air-gapped compose profile for enterprise clean release.
BACKEND_IMAGE=ss-tools-backend:v1.0.0-rc2
FRONTEND_IMAGE=ss-tools-frontend:v1.0.0-rc2
POSTGRES_IMAGE=postgres:16-alpine
POSTGRES_DB=ss_tools
POSTGRES_USER=postgres
POSTGRES_PASSWORD=change-me
BACKEND_HOST_PORT=8001
FRONTEND_HOST_PORT=8000
POSTGRES_HOST_PORT=5432
ENABLE_BELIEF_STATE_LOGGING=true
TASK_LOG_LEVEL=INFO
STORAGE_ROOT=./storage
OPENAI_API_KEY=
ANTHROPIC_API_KEY=

View File

@@ -49,6 +49,8 @@ Auto-generated from all feature plans. Last updated: 2025-12-19
- PostgreSQL (конфигурации/метаданные), filesystem (артефакты дистрибутива, отчёты проверки) (020-clean-repo-enterprise)
- Python 3.9+ (backend), Node.js 18+ + SvelteKit (frontend) + FastAPI, SQLAlchemy, Pydantic, existing auth stack (`get_current_user`), existing dashboards route/service, Svelte runes (`$state`, `$derived`, `$effect`), Tailwind CSS, frontend `api` wrapper (024-user-dashboard-filter)
- Existing auth database (`AUTH_DATABASE_URL`) with a dedicated per-user preference entity (024-user-dashboard-filter)
- Python 3.9+ (Backend), Node.js 18+ / Svelte 5.x (Frontend) + FastAPI, SQLAlchemy, APScheduler (Backend) | SvelteKit, Tailwind CSS, existing UI components (Frontend) (026-dashboard-health-windows)
- PostgreSQL / SQLite (existing database for `ValidationRecord` and new `ValidationPolicy`) (026-dashboard-health-windows)
- Python 3.9+ (Backend), Node.js 18+ (Frontend Build) (001-plugin-arch-svelte-ui)
@@ -69,9 +71,9 @@ cd src; pytest; ruff check .
Python 3.9+ (Backend), Node.js 18+ (Frontend Build): Follow standard conventions
## Recent Changes
- 026-dashboard-health-windows: Added Python 3.9+ (Backend), Node.js 18+ / Svelte 5.x (Frontend) + FastAPI, SQLAlchemy, APScheduler (Backend) | SvelteKit, Tailwind CSS, existing UI components (Frontend)
- 024-user-dashboard-filter: Added Python 3.9+ (backend), Node.js 18+ + SvelteKit (frontend) + FastAPI, SQLAlchemy, Pydantic, existing auth stack (`get_current_user`), existing dashboards route/service, Svelte runes (`$state`, `$derived`, `$effect`), Tailwind CSS, frontend `api` wrapper
- 020-clean-repo-enterprise: Added Python 3.9+ (backend scripts/services), Shell (release tooling) + FastAPI stack (existing backend), ConfigManager, TaskManager, файловые утилиты, internal artifact registries
- 001-unify-frontend-style: Added Node.js 18+ runtime, SvelteKit (existing frontend stack) + SvelteKit, Tailwind CSS, existing frontend UI primitives under `frontend/src/lib/components/ui`
<!-- MANUAL ADDITIONS START -->

39
.kilocode/setup-script Executable file
View File

@@ -0,0 +1,39 @@
#!/bin/bash
# Kilo Code Worktree Setup Script
# This script runs before the agent starts in a worktree (new sessions only).
#
# Available environment variables:
# WORKTREE_PATH - Absolute path to the worktree directory
# REPO_PATH - Absolute path to the main repository
#
# Example tasks:
# - Copy .env files from main repo
# - Install dependencies
# - Run database migrations
# - Set up local configuration
set -e # Exit on error
echo "Setting up worktree: $WORKTREE_PATH"
# Uncomment and modify as needed:
# Copy environment files
# if [ -f "$REPO_PATH/.env" ]; then
# cp "$REPO_PATH/.env" "$WORKTREE_PATH/.env"
# echo "Copied .env"
# fi
# Install dependencies (Node.js)
# if [ -f "$WORKTREE_PATH/package.json" ]; then
# cd "$WORKTREE_PATH"
# npm install
# fi
# Install dependencies (Python)
# if [ -f "$WORKTREE_PATH/requirements.txt" ]; then
# cd "$WORKTREE_PATH"
# pip install -r requirements.txt
# fi
echo "Setup complete!"

View File

@@ -0,0 +1,74 @@
---
description: Maintain semantic integrity by generating maps and auditing compliance reports.
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Goal
Ensure the codebase adheres to the semantic standards defined in `.ai/standards/semantics.md`. This involves generating the semantic map, analyzing compliance reports, and identifying critical parsing errors or missing metadata.
## Operating Constraints
1. **ROLE: Orchestrator**: You are responsible for the high-level coordination of semantic maintenance.
2. **STRICT ADHERENCE**: Follow `.ai/standards/semantics.md` for all anchor and tag syntax.
3. **NON-DESTRUCTIVE**: Do not remove existing code logic; only add or update semantic annotations.
4. **TIER AWARENESS**: Prioritize CRITICAL and STANDARD modules for compliance fixes.
5. **NO PSEUDO-CONTRACTS (CRITICAL)**: You are STRICTLY FORBIDDEN from using automated scripts (e.g., Python/Bash/sed) to mechanically inject boilerplate, placeholders, or "pseudo-contracts" (such as `# @PURPOSE: Semantic contract placeholder.` or `# @PRE: Inputs satisfy function contract.`) merely to artificially inflate the compliance score. Every semantic tag, anchor, and contract you add MUST reflect a genuine, deep understanding of the specific code's actual logic and business requirements. Automated "stubbing" of semantics is classified as codebase corruption.
## Execution Steps
### 1. Generate Semantic Map
Run the generator script from the repository root with the agent report option:
```bash
python3 generate_semantic_map.py --agent-report
```
### 2. Analyze Compliance Status
**Parse the JSON output to identify**:
- `global_score`: The overall compliance percentage.
- `critical_parsing_errors_count`: Number of Priority 1 blockers.
- `priority_2_tier1_critical_missing_mandatory_tags_files`: Number of CRITICAL files needing metadata.
- `targets`: Status of key architectural files.
### 3. Audit Critical Issues
Read the latest report and extract:
- **Critical Parsing Errors**: Unclosed anchors or mismatched tags.
- **Low-Score Files**: Files with score < 0.7 or marked with 🔴.
- **Missing Mandatory Tags**: Specifically for CRITICAL tier modules.
### 4. Formulate Remediation Plan
Create a list of files requiring immediate attention:
1. **Priority 1**: Fix all "Critical Parsing Errors" (unclosed anchors).
2. **Priority 2**: Add missing mandatory tags for CRITICAL modules.
3. **Priority 3**: Improve coverage for STANDARD modules.
### 5. Execute Fixes (Optional/Handoff)
If $ARGUMENTS contains "fix" or "apply":
- For each target file, use `read_file` to get context.
- Apply semantic fixes using `apply_diff`, preserving all code logic.
- Re-run `python3 generate_semantic_map.py --agent-report` to verify the fix.
## Output
Provide a summary of the semantic state:
- **Global Score**: [X]%
- **Status**: [PASS/FAIL] (FAIL if any Critical Parsing Errors exist)
- **Top Issues**: List top 3-5 files needing attention.
- **Action Taken**: Summary of maps generated or fixes applied.
## Context
$ARGUMENTS

View File

@@ -27,22 +27,6 @@ customModes:
6. DOCUMENTATION: Create test reports in `specs/<feature>/tests/reports/YYYY-MM-DD-report.md`.
7. COVERAGE: Aim for maximum coverage but prioritize CRITICAL and STANDARD tier modules.
8. RUN TESTS: Execute tests using `cd backend && .venv/bin/python3 -m pytest` or `cd frontend && npm run test`.
- slug: semantic
name: Semantic Agent
roleDefinition: |-
You are Kilo Code, a Semantic Agent responsible for maintaining the semantic integrity of the codebase. Your primary goal is to ensure that all code entities (Modules, Classes, Functions, Components) are properly annotated with semantic anchors and tags as defined in `.ai/standards/semantics.md`.
Your core responsibilities are: 1. **Semantic Mapping**: You run and maintain the `generate_semantic_map.py` script to generate up-to-date semantic maps (`semantics/semantic_map.json`, `.ai/PROJECT_MAP.md`) and compliance reports (`semantics/reports/*.md`). 2. **Compliance Auditing**: You analyze the generated compliance reports to identify files with low semantic coverage or parsing errors. 3. **Semantic Enrichment**: You actively edit code files to add missing semantic anchors (`[DEF:...]`, `[/DEF:...]`) and mandatory tags (`@PURPOSE`, `@LAYER`, etc.) to improve the global compliance score. 4. **Protocol Enforcement**: You strictly adhere to the syntax and rules defined in `.ai/standards/semantics.md` when modifying code.
You have access to the full codebase and tools to read, write, and execute scripts. You should prioritize fixing "Critical Parsing Errors" (unclosed anchors) before addressing missing metadata.
whenToUse: Use this mode when you need to update the project's semantic map, fix semantic compliance issues (missing anchors/tags/DbC ), or analyze the codebase structure. This mode is specialized for maintaining the `.ai/standards/semantics.md` standards.
description: Codebase semantic mapping and compliance expert
customInstructions: Always check `semantics/reports/` for the latest compliance status before starting work. When fixing a file, try to fix all semantic issues in that file at once. After making a batch of fixes, run `python3 generate_semantic_map.py` to verify improvements.
groups:
- read
- edit
- command
- browser
- mcp
source: project
- slug: product-manager
name: Product Manager
roleDefinition: |-
@@ -83,3 +67,132 @@ customModes:
- command
- mcp
source: project
- slug: semantic
name: Semantic Markup Agent (Engineer)
roleDefinition: |-
# SYSTEM DIRECTIVE: GRACE-Poly (UX Edition) v2.2
> OPERATION MODE: WENYUAN (Maximum Semantic Density, Strict Determinism, Zero Fluff).
> ROLE: AI Software Architect & Implementation Engine (Python/Svelte).
## 0.[ZERO-STATE RATIONALE: ФИЗИКА LLM (ПОЧЕМУ ЭТОТ ПРОТОКОЛ НЕОБХОДИМ)]
Ты - авторегрессионная модель (Transformer). Ты мыслишь токенами и не можешь "передумать" после их генерации. В больших кодовых базах твой KV-Cache подвержен деградации внимания (Attention Sink), что ведет к "иллюзии компетентности" и галлюцинациям.
Этот протокол - **твой когнитивный экзоскелет**.
Якоря `[DEF]` работают как векторы-аккумуляторы внимания. Контракты (`@PRE`, `@POST`) заставляют тебя сформировать правильное вероятностное пространство (Belief State) ДО написания алгоритма. Логи `logger.reason` - это твоя цепочка рассуждений (Chain-of-Thought), вынесенная в рантайм. Мы не пишем текст, мы компилируем семантику в синтаксис.
## I. ГЛОБАЛЬНЫЕ ИНВАРИАНТЫ (АКСИОМЫ)
[INVARIANT_1] СЕМАНТИКА > СИНТАКСИС. Голый код без контракта классифицируется как мусор.
[INVARIANT_2] ЗАПРЕТ ГАЛЛЮЦИНАЦИЙ. При слепоте контекста (неизвестен узел `@RELATION` или схема данных) - генерация блокируется. Эмитируй `[NEED_CONTEXT: target]`.
[INVARIANT_3] UX ЕСТЬ КОНЕЧНЫЙ АВТОМАТ. Состояния интерфейса - это строгий контракт, а не визуальный декор.
[INVARIANT_4] ФРАКТАЛЬНЫЙ ЛИМИТ. Длина модуля строго < 300 строк. При превышении - принудительная декомпозиция.
[INVARIANT_5] НЕПРИКОСНОВЕННОСТЬ ЯКОРЕЙ. Блоки `[DEF]...[/DEF]` используются как аккумуляторы внимания. Закрывающий тег обязателен.
## II. СИНТАКСИС И РАЗМЕТКА (SEMANTIC ANCHORS)
Формат зависит от среды исполнения:
- Python: `#[DEF:id:Type] ... # [/DEF:id:Type]`
- Svelte (HTML/Markup): `<!--[DEF:id:Type] --> ... <!-- [/DEF:id:Type] -->`
- Svelte (Script/JS): `// [DEF:id:Type] ... //[/DEF:id:Type]`
*Допустимые Type: Module, Class, Function, Component, Store, Block.*
**Формат метаданных (ДО имплементации):**
`@KEY: Value` (в Python - `# @KEY`, в TS/JS - `/** @KEY */`, в HTML - `<!-- @KEY -->`).
**Граф Зависимостей (GraphRAG):**
`@RELATION: [PREDICATE] ->[TARGET_ID]`
*Допустимые предикаты:* DEPENDS_ON, CALLS, INHERITS, IMPLEMENTS, DISPATCHES, BINDS_TO.
## III. ТОПОЛОГИЯ ФАЙЛА (СТРОГИЙ ПОРЯДОК)
1. **HEADER (Заголовок):**[DEF:filename:Module]
@TIER: [CRITICAL | STANDARD | TRIVIAL]
@SEMANTICS: [keywords]
@PURPOSE: [Однострочная суть]
@LAYER: [Domain | UI | Infra]
@RELATION: [Зависимости]
@INVARIANT: [Бизнес-правило, которое нельзя нарушить]
2. **BODY (Тело):** Импорты -> Реализация логики внутри вложенных `[DEF]`.
3. **FOOTER (Подвал):** [/DEF:filename:Module]
## IV. КОНТРАКТЫ (DESIGN BY CONTRACT & UX)
Обязательны для TIER: CRITICAL и STANDARD. Заменяют стандартные Docstrings.
**[CORE CONTRACTS]:**
- `@PURPOSE:` Суть функции/компонента.
- `@PRE:` Условия запуска (в коде реализуются через `if/raise` или guards, НЕ через `assert`).
- `@POST:` Гарантии на выходе.
- `@SIDE_EFFECT:` Мутации состояния, I/O, сеть.
- `@DATA_CONTRACT:` Ссылка на DTO (Input -> Model, Output -> Model).
**[UX CONTRACTS (Svelte 5+)]:**
- `@UX_STATE: [StateName] -> [Поведение]` (Idle, Loading, Error, Success).
- `@UX_FEEDBACK:` Реакция системы (Toast, Shake, RedBorder).
- `@UX_RECOVERY:` Путь восстановления после сбоя (Retry, ClearInput).
- `@UX_REACTIVITY:` Явный биндинг. *ЗАПРЕТ НА `$:` и `export let`. ТОЛЬКО Руны: `$state`, `$derived`, `$effect`, `$props`.*
**[TEST CONTRACTS (Для AI-Auditor)]:**
- `@TEST_CONTRACT: [Input] -> [Output]`
- `@TEST_SCENARIO: [Название] -> [Ожидание]`
- `@TEST_FIXTURE: [Название] -> file:[path] | INLINE_JSON`
- `@TEST_EDGE: [Название] ->[Сбой]` (Минимум 3: missing_field, invalid_type, external_fail).
- `@TEST_INVARIANT: [Имя] -> VERIFIED_BY: [scenario_1, ...]`
## V. УРОВНИ СТРОГОСТИ (TIERS)
Степень контроля задается в Header.
- **CRITICAL** (Ядро/Деньги/Безопасность): 100% покрытие тегами GRACE. Обязательны: Граф, Инварианты, Логи `logger.reason/reflect`, все `@UX` и `@TEST` теги. Использование `belief_scope` строго обязательно.
- **STANDARD** (Бизнес-логика / Типовые формы): Базовый уровень. Обязательны: `@PURPOSE`, `@UX_STATE`, `@RELATION`, базовое логирование.
- **TRIVIAL** (Утилиты / DTO / Атомы UI): Минимальный каркас. Только якоря `[DEF]...[/DEF]` и `@PURPOSE`.
## VI. ПРОТОКОЛ ЛОГИРОВАНИЯ (THREAD-LOCAL BELIEF STATE)
Логирование - это механизм трассировки рассуждений ИИ (CoT) и управления Attention Energy. Архитектура использует Thread-local storage (`_belief_state`), поэтому `ID` прокидывается автоматически.
**[PYTHON CORE TOOLS]:**
Импорт: `from ...logger import logger, belief_scope, believed`
1. **Декоратор:** `@believed("ID")` - автоматический трекинг функции.
2. **Контекст:** `with belief_scope("ID"):` - очерчивает локальный предел мысли. НЕ возвращает context, используется просто как `with`.
3. **Вызов логера:** Осуществляется через глобальный импортированный `logger`. Дополнительные данные передавать через `extra={...}`.
**[СЕМАНТИЧЕСКИЕ МЕТОДЫ (MONKEY-PATCHED)]:**
*(Маркеры вроде `[REASON]` и `[ID]` подставляются автоматически форматтером. Не пиши их в тексте!)*
1. **`logger.explore(msg, extra={...})`** (Поиск/Ветвление): Применяется при фолбэках, `except`, проверке гипотез. Эмитирует WARNING.
*Пример:* `logger.explore("Insufficient funds", extra={"balance": bal})`
2. **`logger.reason(msg, extra={...})`** (Дедукция): Применяется при прохождении guards и выполнении шагов контракта. Эмитирует INFO.
*Пример:* `logger.reason("Initiating transfer")`
3. **`logger.reflect(msg, extra={...})`** (Самопроверка): Применяется для сверки результата с `@POST` перед `return`. Эмитирует DEBUG.
*Пример:* `logger.reflect("Transfer committed", extra={"tx_id": tx_id})`
*(Для Frontend/Svelte использовать ручной префикс: `console.info("[ID][REFLECT] Text", {data})`)*
## VII. АЛГОРИТМ ИСПОЛНЕНИЯ И САМОКОРРЕКЦИИ
**[PHASE_1: ANALYSIS]**
Оцени TIER, Layer и UX-требования. При слепоте контекста -> `yield [NEED_CONTEXT: id]`.
**[PHASE_2: SYNTHESIS]**
Сгенерируй каркас из `[DEF]`, Header и Контрактов.
**[PHASE_3: IMPLEMENTATION]**
Напиши код строго по Контракту. Для CRITICAL секций открой `with belief_scope("ID"):` и орошай путь вызовами `logger.reason()` и `logger.reflect()`.
**[PHASE_4: CLOSURE]**
Убедись, что все `[DEF]` закрыты соответствующими `[/DEF]`.
**[EXCEPTION: DETECTIVE MODE]**
Если обнаружено нарушение контракта или ошибка:
1. СТОП-СИГНАЛ: Выведи `[COHERENCE_CHECK_FAILED]`.
2. ГИПОТЕЗА: Сгенерируй вызов `logger.explore("Ошибка в I/O / Состоянии / Зависимости -> Описание")`.
3. ЗАПРОС: Запроси разрешение на изменение контракта.
whenToUse: Use this mode when you need to update the project's semantic map, fix semantic compliance issues (missing anchors/tags/DbC ), or analyze the codebase structure. This mode is specialized for maintaining the `.ai/standards/semantics.md` standards.
description: Codebase semantic mapping and compliance expert
customInstructions: ""
groups:
- read
- edit
- command
- browser
- mcp
source: project
- slug: reviewer-agent-auditor
name: Reviewer Agent (Auditor)
description: Безжалостный инспектор ОТК.
roleDefinition: '*"Ты GRACE Reviewer. Твоя единственная цель — искать нарушения протокола GRACE-Poly. Ты не пишешь код. Ты читаешь код и проверяешь Чек-лист. Если блок `[DEF]` открыт, но нет закрывающего `[/DEF]` — это FATAL ERROR. Если в `CRITICAL` модуле функция не обернута в `belief_scope` — это FATAL ERROR. Выводи только PASS или FAIL со списком строк, где найдена ошибка."*'
groups:
- read
- edit
- browser
- command
- mcp
source: project

View File

@@ -233,6 +233,50 @@ cd /home/busya/dev/ss-tools
Если найден внешний endpoint, выпуск получает статус `BLOCKED` до исправления.
### Docker release для изолированного контура
Текущий `enterprise clean` профиль уже задаёт policy-level ограничения для внутреннего контура. Следующий логичный шаг для релизного процесса — выпускать не только application artifacts, но и готовый Docker bundle для разворота без доступа в интернет.
Целевой состав offline release-пакета:
- `backend` image с уже установленными Python-зависимостями;
- `frontend` image с уже собранным SvelteKit bundle;
- `postgres` image или внутренний pinned base image;
- `docker-compose.enterprise-clean.yml` для запуска в air-gapped окружении;
- `.env.enterprise-clean.example` с обязательными переменными;
- manifest с версиями, sha256 и перечнем образов;
- инструкции по `docker load` / `docker compose up` без обращения к внешним registry.
Рекомендуемый workflow для такого релиза:
```bash
# 1. Собрать образы в подключённом контуре
./scripts/build_offline_docker_bundle.sh v1.0.0-rc2
# 2. Передать dist/docker/* в изолированный контур
# 3. Импортировать образы локально
docker load -i dist/docker/backend.v1.0.0-rc2.tar
docker load -i dist/docker/frontend.v1.0.0-rc2.tar
docker load -i dist/docker/postgres.v1.0.0-rc2.tar
# 4. Подготовить env из шаблона
cp dist/docker/.env.enterprise-clean.example .env.enterprise-clean
# 5. Запустить только локальные образы
docker compose --env-file .env.enterprise-clean -f dist/docker/docker-compose.enterprise-clean.yml up -d
```
Ограничения для production-grade offline release:
- build не должен тянуть зависимости в изолированном контуре;
- все base images должны быть заранее зеркалированы во внутренний registry или поставляться как tar;
- runtime-конфигурация не должна ссылаться на внешние API/registry/telemetry endpoints;
- clean/compliance manifest должен включать docker image digests как часть evidence package.
Практический план внедрения:
- pinned Docker image tags и отдельный `enterprise-clean` compose profile добавлены;
- shell script `scripts/build_offline_docker_bundle.sh` добавлен для `build -> save -> checksum`;
- следующим шагом стоит включить docker image digests в clean-release manifest;
- следующим шагом стоит добавить smoke-check, что compose-файлы не содержат внешних registry references вне allowlist.
## 📖 Документация
- [Установка и настройка](docs/installation.md)
@@ -327,5 +371,3 @@ pip install -r requirements.txt --upgrade
cd frontend
npm install
```

View File

@@ -1,189 +0,0 @@
INFO: Will watch for changes in these directories: ['/home/user/ss-tools/backend']
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO: Started reloader process [7952] using StatReload
INFO: Started server process [7968]
INFO: Waiting for application startup.
INFO: Application startup complete.
Error loading plugin module backup: No module named 'yaml'
Error loading plugin module migration: No module named 'yaml'
INFO: 127.0.0.1:36934 - "HEAD /docs HTTP/1.1" 200 OK
INFO: 127.0.0.1:55006 - "GET /settings HTTP/1.1" 307 Temporary Redirect
INFO: 127.0.0.1:55006 - "GET /settings/ HTTP/1.1" 200 OK
INFO: 127.0.0.1:55010 - "GET /plugins HTTP/1.1" 307 Temporary Redirect
INFO: 127.0.0.1:55010 - "GET /plugins/ HTTP/1.1" 200 OK
INFO: 127.0.0.1:55010 - "GET /settings HTTP/1.1" 307 Temporary Redirect
INFO: 127.0.0.1:55010 - "GET /settings/ HTTP/1.1" 200 OK
INFO: 127.0.0.1:55010 - "GET /plugins HTTP/1.1" 307 Temporary Redirect
INFO: 127.0.0.1:55010 - "GET /plugins/ HTTP/1.1" 200 OK
INFO: 127.0.0.1:55010 - "GET /settings HTTP/1.1" 307 Temporary Redirect
INFO: 127.0.0.1:55010 - "GET /settings/ HTTP/1.1" 200 OK
INFO: 127.0.0.1:35508 - "GET /plugins HTTP/1.1" 307 Temporary Redirect
INFO: 127.0.0.1:35508 - "GET /plugins/ HTTP/1.1" 200 OK
INFO: 127.0.0.1:49820 - "GET /plugins HTTP/1.1" 307 Temporary Redirect
INFO: 127.0.0.1:49820 - "GET /plugins/ HTTP/1.1" 200 OK
INFO: 127.0.0.1:49822 - "GET /settings HTTP/1.1" 307 Temporary Redirect
INFO: 127.0.0.1:49822 - "GET /settings/ HTTP/1.1" 200 OK
INFO: 127.0.0.1:49822 - "GET /plugins HTTP/1.1" 307 Temporary Redirect
INFO: 127.0.0.1:49822 - "GET /plugins/ HTTP/1.1" 200 OK
INFO: 127.0.0.1:49908 - "GET /settings HTTP/1.1" 307 Temporary Redirect
INFO: 127.0.0.1:49908 - "GET /settings/ HTTP/1.1" 200 OK
INFO: 127.0.0.1:49922 - "OPTIONS /settings/environments HTTP/1.1" 200 OK
[2025-12-20 19:14:15,576][INFO][superset_tools_app] [ConfigManager.save_config][Coherence:OK] Configuration saved context={'path': '/home/user/ss-tools/config.json'}
INFO: 127.0.0.1:49922 - "POST /settings/environments HTTP/1.1" 200 OK
INFO: 127.0.0.1:49922 - "GET /settings HTTP/1.1" 307 Temporary Redirect
INFO: 127.0.0.1:49922 - "GET /settings/ HTTP/1.1" 200 OK
INFO: 127.0.0.1:49922 - "OPTIONS /settings/environments/7071dab6-881f-49a2-b850-c004b3fc11c0/test HTTP/1.1" 200 OK
INFO: 127.0.0.1:36930 - "POST /settings/environments/7071dab6-881f-49a2-b850-c004b3fc11c0/test HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/uvicorn/protocols/http/h11_impl.py", line 403, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 60, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/fastapi/applications.py", line 1135, in __call__
await super().__call__(scope, receive, send)
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/starlette/applications.py", line 107, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
raise exc
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/starlette/middleware/cors.py", line 93, in __call__
await self.simple_response(scope, receive, send, request_headers=headers)
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/starlette/middleware/cors.py", line 144, in simple_response
await self.app(scope, receive, send)
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 63, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
raise exc
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app
await app(scope, receive, sender)
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/fastapi/middleware/asyncexitstack.py", line 18, in __call__
await self.app(scope, receive, send)
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/starlette/routing.py", line 716, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/starlette/routing.py", line 736, in app
await route.handle(scope, receive, send)
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/starlette/routing.py", line 290, in handle
await self.app(scope, receive, send)
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/fastapi/routing.py", line 118, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
raise exc
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app
await app(scope, receive, sender)
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/fastapi/routing.py", line 104, in app
response = await f(request)
^^^^^^^^^^^^^^^^
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/fastapi/routing.py", line 428, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/fastapi/routing.py", line 314, in run_endpoint_function
return await dependant.call(**values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/user/ss-tools/backend/src/api/routes/settings.py", line 103, in test_connection
import httpx
ModuleNotFoundError: No module named 'httpx'
INFO: 127.0.0.1:45776 - "POST /settings/environments/7071dab6-881f-49a2-b850-c004b3fc11c0/test HTTP/1.1" 200 OK
INFO: 127.0.0.1:45784 - "GET /plugins HTTP/1.1" 307 Temporary Redirect
INFO: 127.0.0.1:45784 - "GET /plugins/ HTTP/1.1" 200 OK
INFO: 127.0.0.1:41628 - "GET /settings HTTP/1.1" 307 Temporary Redirect
INFO: 127.0.0.1:41628 - "GET /settings/ HTTP/1.1" 200 OK
INFO: 127.0.0.1:41628 - "GET /plugins HTTP/1.1" 307 Temporary Redirect
INFO: 127.0.0.1:41628 - "GET /plugins/ HTTP/1.1" 200 OK
INFO: 127.0.0.1:60184 - "GET /settings HTTP/1.1" 307 Temporary Redirect
INFO: 127.0.0.1:60184 - "GET /settings/ HTTP/1.1" 200 OK
INFO: 127.0.0.1:60184 - "GET /plugins HTTP/1.1" 307 Temporary Redirect
INFO: 127.0.0.1:60184 - "GET /plugins/ HTTP/1.1" 200 OK
INFO: 127.0.0.1:60184 - "GET /settings HTTP/1.1" 307 Temporary Redirect
INFO: 127.0.0.1:60184 - "GET /settings/ HTTP/1.1" 200 OK
WARNING: StatReload detected changes in 'src/core/plugin_loader.py'. Reloading...
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [7968]
INFO: Started server process [12178]
INFO: Waiting for application startup.
INFO: Application startup complete.
WARNING: StatReload detected changes in 'src/dependencies.py'. Reloading...
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [12178]
INFO: Started server process [12451]
INFO: Waiting for application startup.
INFO: Application startup complete.
Plugin 'Superset Dashboard Backup' (ID: superset-backup) loaded successfully.
Plugin 'Superset Dashboard Migration' (ID: superset-migration) loaded successfully.
INFO: 127.0.0.1:37334 - "GET / HTTP/1.1" 200 OK
INFO: 127.0.0.1:37334 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:39932 - "GET / HTTP/1.1" 200 OK
INFO: 127.0.0.1:39932 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:39932 - "GET / HTTP/1.1" 200 OK
INFO: 127.0.0.1:39932 - "GET / HTTP/1.1" 200 OK
INFO: 127.0.0.1:54900 - "GET /plugins HTTP/1.1" 307 Temporary Redirect
INFO: 127.0.0.1:49280 - "GET /plugins HTTP/1.1" 307 Temporary Redirect
INFO: 127.0.0.1:49280 - "GET /plugins/ HTTP/1.1" 200 OK
WARNING: StatReload detected changes in 'src/api/routes/plugins.py'. Reloading...
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [12451]
INFO: Started server process [15016]
INFO: Waiting for application startup.
INFO: Application startup complete.
Plugin 'Superset Dashboard Backup' (ID: superset-backup) loaded successfully.
Plugin 'Superset Dashboard Migration' (ID: superset-migration) loaded successfully.
INFO: 127.0.0.1:59340 - "GET /plugins HTTP/1.1" 307 Temporary Redirect
DEBUG: list_plugins called. Found 0 plugins.
INFO: 127.0.0.1:59340 - "GET /plugins/ HTTP/1.1" 200 OK
WARNING: StatReload detected changes in 'src/dependencies.py'. Reloading...
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [15016]
INFO: Started server process [15257]
INFO: Waiting for application startup.
INFO: Application startup complete.
Plugin 'Superset Dashboard Backup' (ID: superset-backup) loaded successfully.
Plugin 'Superset Dashboard Migration' (ID: superset-migration) loaded successfully.
DEBUG: dependencies.py initialized. PluginLoader ID: 139922613090976
DEBUG: dependencies.py initialized. PluginLoader ID: 139922627375088
INFO: 127.0.0.1:57464 - "GET /plugins HTTP/1.1" 307 Temporary Redirect
DEBUG: get_plugin_loader called. Returning PluginLoader ID: 139922627375088
DEBUG: list_plugins called. Found 0 plugins.
INFO: 127.0.0.1:57464 - "GET /plugins/ HTTP/1.1" 200 OK
WARNING: StatReload detected changes in 'src/core/plugin_loader.py'. Reloading...
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [15257]
INFO: Started server process [15533]
INFO: Waiting for application startup.
INFO: Application startup complete.
DEBUG: Loading plugin backup as src.plugins.backup
Plugin 'Superset Dashboard Backup' (ID: superset-backup) loaded successfully.
DEBUG: Loading plugin migration as src.plugins.migration
Plugin 'Superset Dashboard Migration' (ID: superset-migration) loaded successfully.
DEBUG: dependencies.py initialized. PluginLoader ID: 140371031142384
INFO: 127.0.0.1:46470 - "GET /plugins HTTP/1.1" 307 Temporary Redirect
DEBUG: get_plugin_loader called. Returning PluginLoader ID: 140371031142384
DEBUG: list_plugins called. Found 2 plugins.
DEBUG: Plugin: superset-backup
DEBUG: Plugin: superset-migration
INFO: 127.0.0.1:46470 - "GET /plugins/ HTTP/1.1" 200 OK
WARNING: StatReload detected changes in 'src/api/routes/settings.py'. Reloading...
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [15533]
INFO: Started server process [15827]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [15827]
INFO: Stopping reloader process [7952]

View File

@@ -1 +0,0 @@
{"print(f'Length": {"else": "print('Provider not found')\ndb.close()"}}

Binary file not shown.

View File

@@ -1,3 +1,19 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "ss-tools-backend"
version = "0.0.0"
requires-python = ">=3.13"
[tool.setuptools]
include-package-data = true
[tool.setuptools.packages.find]
where = ["."]
include = ["src*"]
[tool.pytest.ini_options]
pythonpath = ["."]
importmode = "importlib"

3
backend/src/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
# [DEF:src:Package]
# @PURPOSE: Canonical backend package root for application, scripts, and tests.
# [/DEF:src:Package]

View File

@@ -0,0 +1,3 @@
# [DEF:src.api:Package]
# @PURPOSE: Backend API package root.
# [/DEF:src.api:Package]

View File

@@ -422,7 +422,7 @@ def test_llm_validation_with_dashboard_ref_requires_confirmation():
assert "cancel" in action_types
# [/DEF:test_llm_validation_missing_dashboard_returns_needs_clarification:Function]
# [/DEF:test_llm_validation_with_dashboard_ref_requires_confirmation:Function]
# [DEF:test_list_conversations_groups_by_conversation_and_marks_archived:Function]
@@ -629,6 +629,7 @@ def test_guarded_operation_confirm_roundtrip():
assert second.task_id is not None
# [/DEF:test_guarded_operation_confirm_roundtrip:Function]
# [DEF:test_confirm_nonexistent_id_returns_404:Function]
# @PURPOSE: Confirming a non-existent ID should raise 404.
# @PRE: user tries to confirm a random/fake UUID.
@@ -649,6 +650,7 @@ def test_confirm_nonexistent_id_returns_404():
assert exc.value.status_code == 404
# [/DEF:test_confirm_nonexistent_id_returns_404:Function]
# [DEF:test_migration_with_dry_run_includes_summary:Function]
# @PURPOSE: Migration command with dry run flag must return the dry run summary in confirmation text.
# @PRE: user specifies a migration with --dry-run flag.

View File

@@ -135,6 +135,8 @@ def test_get_report_success():
finally:
app.dependency_overrides.clear()
# [/DEF:backend.tests.api.routes.test_clean_release_api:Module]
def test_prepare_candidate_api_success():
repo = _repo_with_seed_data()
app.dependency_overrides[get_clean_release_repository] = lambda: repo

View File

@@ -94,4 +94,7 @@ def test_prepare_candidate_blocks_external_source():
assert data["status"] == "blocked"
assert any(v["category"] == "external-source" for v in data["violations"])
finally:
app.dependency_overrides.clear()
app.dependency_overrides.clear()
# [/DEF:backend.tests.api.routes.test_clean_release_source_policy:Module]

View File

@@ -82,6 +82,7 @@ def _build_preference_response(user_id: str = "u-1") -> ProfilePreferenceRespons
superset_username="John_Doe",
superset_username_normalized="john_doe",
show_only_my_dashboards=True,
show_only_slug_dashboards=True,
git_username="ivan.ivanov",
git_email="ivan@company.local",
has_git_personal_access_token=True,
@@ -126,6 +127,7 @@ def test_get_profile_preferences_returns_self_payload(profile_route_deps_fixture
assert payload["preference"]["superset_username_normalized"] == "john_doe"
assert payload["preference"]["git_username"] == "ivan.ivanov"
assert payload["preference"]["git_email"] == "ivan@company.local"
assert payload["preference"]["show_only_slug_dashboards"] is True
assert payload["preference"]["has_git_personal_access_token"] is True
assert payload["preference"]["git_personal_access_token_masked"] == "iv***al"
assert payload["preference"]["start_page"] == "reports"
@@ -153,6 +155,7 @@ def test_patch_profile_preferences_success(profile_route_deps_fixture):
json={
"superset_username": "John_Doe",
"show_only_my_dashboards": True,
"show_only_slug_dashboards": True,
"git_username": "ivan.ivanov",
"git_email": "ivan@company.local",
"git_personal_access_token": "ghp_1234567890",
@@ -167,6 +170,7 @@ def test_patch_profile_preferences_success(profile_route_deps_fixture):
assert payload["status"] == "success"
assert payload["preference"]["superset_username"] == "John_Doe"
assert payload["preference"]["show_only_my_dashboards"] is True
assert payload["preference"]["show_only_slug_dashboards"] is True
assert payload["preference"]["git_username"] == "ivan.ivanov"
assert payload["preference"]["git_email"] == "ivan@company.local"
assert payload["preference"]["start_page"] == "reports"
@@ -179,6 +183,7 @@ def test_patch_profile_preferences_success(profile_route_deps_fixture):
assert called_kwargs["payload"].git_username == "ivan.ivanov"
assert called_kwargs["payload"].git_email == "ivan@company.local"
assert called_kwargs["payload"].git_personal_access_token == "ghp_1234567890"
assert called_kwargs["payload"].show_only_slug_dashboards is True
assert called_kwargs["payload"].start_page == "reports-logs"
assert called_kwargs["payload"].auto_open_task_drawer is False
assert called_kwargs["payload"].dashboards_table_density == "free"
@@ -290,4 +295,4 @@ def test_lookup_superset_accounts_env_not_found(profile_route_deps_fixture):
assert payload["detail"] == "Environment 'missing-env' not found"
# [/DEF:test_lookup_superset_accounts_env_not_found:Function]
# [/DEF:backend.src.api.routes.__tests__.test_profile_api:Module]
# [/DEF:backend.src.api.routes.__tests__.test_profile_api:Module]

View File

@@ -120,6 +120,7 @@ INTENT_PERMISSION_CHECKS: Dict[str, List[Tuple[str, str]]] = {
"run_backup": [("plugin:superset-backup", "EXECUTE"), ("plugin:backup", "EXECUTE")],
"run_llm_validation": [("plugin:llm_dashboard_validation", "EXECUTE")],
"run_llm_documentation": [("plugin:llm_documentation", "EXECUTE")],
"get_health_summary": [("plugin:migration", "READ")],
}
@@ -845,6 +846,18 @@ def _parse_command(message: str, config_manager: ConfigManager) -> Dict[str, Any
"requires_confirmation": False,
}
# Health summary
if any(k in lower for k in ["здоровье", "health", "ошибки", "failing", "проблемы"]):
env_match = _extract_id(lower, [r"(?:в|for|env|окружени[ея])\s+([a-z0-9_-]+)"])
return {
"domain": "health",
"operation": "get_health_summary",
"entities": {"environment": env_match},
"confidence": 0.9,
"risk_level": "safe",
"requires_confirmation": False,
}
# LLM validation
if any(k in lower for k in ["валидац", "validate", "провер"]):
env_match = _extract_id(lower, [r"(?:в|for|env|окружени[ея])\s+([a-z0-9_-]+)"])
@@ -1023,6 +1036,15 @@ def _build_tool_catalog(current_user: User, config_manager: ConfigManager, db: S
"risk_level": "guarded",
"requires_confirmation": False,
},
{
"operation": "get_health_summary",
"domain": "health",
"description": "Get summary of dashboard health and failing validations",
"required_entities": [],
"optional_entities": ["environment"],
"risk_level": "safe",
"requires_confirmation": False,
},
]
available: List[Dict[str, Any]] = []
@@ -1056,7 +1078,7 @@ def _coerce_intent_entities(intent: Dict[str, Any]) -> Dict[str, Any]:
# Operations that are read-only and do not require confirmation.
_SAFE_OPS = {"show_capabilities", "get_task_status"}
_SAFE_OPS = {"show_capabilities", "get_task_status", "get_health_summary"}
# [DEF:_confirmation_summary:Function]
@@ -1151,7 +1173,7 @@ async def _async_confirmation_summary(intent: Dict[str, Any], config_manager: Co
text += f"\n\n(Не удалось загрузить отчет dry-run: {e})."
return f"Выполнить: {text}. Подтвердите или отмените."
# [/DEF:_async_confirmation_summary:Function]
# [/DEF:_confirmation_summary:Function]
# [DEF:_clarification_text_for_intent:Function]
@@ -1323,6 +1345,7 @@ async def _dispatch_intent(
"run_llm_validation": "LLM: валидация дашборда",
"run_llm_documentation": "LLM: генерация документации",
"get_task_status": "Статус: проверка задачи",
"get_health_summary": "Здоровье: сводка по дашбордам",
}
available = [labels[t["operation"]] for t in tools_catalog if t["operation"] in labels]
if not available:
@@ -1335,6 +1358,41 @@ async def _dispatch_intent(
)
return text, None, []
if operation == "get_health_summary":
from ...services.health_service import HealthService
env_token = entities.get("environment")
env_id = _resolve_env_id(env_token, config_manager)
service = HealthService(db)
summary = await service.get_health_summary(environment_id=env_id)
env_name = _get_environment_name_by_id(env_id, config_manager) if env_id else "всех окружений"
text = (
f"Сводка здоровья дашбордов для {env_name}:\n"
f"- ✅ Прошли проверку: {summary.pass_count}\n"
f"- ⚠️ С предупреждениями: {summary.warn_count}\n"
f"- ❌ Ошибки валидации: {summary.fail_count}\n"
f"- ❓ Неизвестно: {summary.unknown_count}"
)
actions = [
AssistantAction(type="open_route", label="Открыть Health Center", target="/dashboards/health")
]
if summary.fail_count > 0:
text += "\n\nОбнаружены ошибки в следующих дашбордах:"
for item in summary.items:
if item.status == "FAIL":
text += f"\n- {item.dashboard_id} ({item.environment_id}): {item.summary or 'Нет деталей'}"
actions.append(
AssistantAction(
type="open_route",
label=f"Отчет {item.dashboard_id}",
target=f"/reports/llm/{item.task_id}"
)
)
return text, None, actions[:5] # Limit actions to avoid UI clutter
if operation == "get_task_status":
_check_any_permission(current_user, [("tasks", "READ")])
task_id = entities.get("task_id")

View File

@@ -98,7 +98,9 @@ class EffectiveProfileFilter(BaseModel):
source_page: Literal["dashboards_main", "other"] = "dashboards_main"
override_show_all: bool = False
username: Optional[str] = None
match_logic: Optional[Literal["owners_or_modified_by"]] = None
match_logic: Optional[
Literal["owners_or_modified_by", "slug_only", "owners_or_modified_by+slug_only"]
] = None
# [/DEF:EffectiveProfileFilter:DataClass]
# [DEF:DashboardsResponse:DataClass]
@@ -535,6 +537,7 @@ async def get_dashboards(
profile_service = ProfileService(db=db, config_manager=config_manager)
bound_username: Optional[str] = None
can_apply_profile_filter = False
can_apply_slug_filter = False
effective_profile_filter = EffectiveProfileFilter(
applied=False,
source_page=page_context,
@@ -560,13 +563,27 @@ async def get_dashboards(
and bool(getattr(profile_preference, "show_only_my_dashboards", False))
and bool(bound_username)
)
can_apply_slug_filter = (
page_context == "dashboards_main"
and bool(apply_profile_default)
and not bool(override_show_all)
and bool(getattr(profile_preference, "show_only_slug_dashboards", True))
)
profile_match_logic = None
if can_apply_profile_filter and can_apply_slug_filter:
profile_match_logic = "owners_or_modified_by+slug_only"
elif can_apply_profile_filter:
profile_match_logic = "owners_or_modified_by"
elif can_apply_slug_filter:
profile_match_logic = "slug_only"
effective_profile_filter = EffectiveProfileFilter(
applied=bool(can_apply_profile_filter),
applied=bool(can_apply_profile_filter or can_apply_slug_filter),
source_page=page_context,
override_show_all=bool(override_show_all),
username=bound_username if can_apply_profile_filter else None,
match_logic="owners_or_modified_by" if can_apply_profile_filter else None,
match_logic=profile_match_logic,
)
except Exception as profile_error:
logger.explore(
@@ -589,7 +606,7 @@ async def get_dashboards(
actor_filters,
)
)
needs_full_scan = has_column_filters or bool(can_apply_profile_filter)
needs_full_scan = has_column_filters or bool(can_apply_profile_filter) or bool(can_apply_slug_filter)
if isinstance(resource_service, ResourceService) and not needs_full_scan:
try:
@@ -600,6 +617,7 @@ async def get_dashboards(
page_size=page_size,
search=search,
include_git_status=False,
require_slug=bool(can_apply_slug_filter),
)
paginated_dashboards = page_payload["dashboards"]
total = page_payload["total"]
@@ -613,6 +631,7 @@ async def get_dashboards(
env,
all_tasks,
include_git_status=False,
require_slug=bool(can_apply_slug_filter),
)
if search:
@@ -633,6 +652,7 @@ async def get_dashboards(
env,
all_tasks,
include_git_status=bool(git_filters),
require_slug=bool(can_apply_slug_filter),
)
if can_apply_profile_filter and bound_username:
@@ -674,6 +694,13 @@ async def get_dashboards(
)
dashboards = filtered_dashboards
if can_apply_slug_filter:
dashboards = [
dashboard
for dashboard in dashboards
if str(dashboard.get("slug") or "").strip()
]
if search:
search_lower = search.lower()
dashboards = [

View File

@@ -0,0 +1,31 @@
# [DEF:health_router:Module]
# @TIER: STANDARD
# @SEMANTICS: health, monitoring, dashboards
# @PURPOSE: API endpoints for dashboard health monitoring and status aggregation.
# @LAYER: UI/API
# @RELATION: DEPENDS_ON -> health_service
from fastapi import APIRouter, Depends, Query
from typing import List, Optional
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...services.health_service import HealthService
from ...schemas.health import HealthSummaryResponse
from ...dependencies import has_permission
router = APIRouter(prefix="/api/health", tags=["Health"])
@router.get("/summary", response_model=HealthSummaryResponse)
async def get_health_summary(
environment_id: Optional[str] = Query(None),
db: Session = Depends(get_db),
_ = Depends(has_permission("plugin:migration", "READ"))
):
"""
@PURPOSE: Get aggregated health status for all dashboards.
@POST: Returns HealthSummaryResponse
"""
service = HealthService(db)
return await service.get_health_summary(environment_id=environment_id)
# [/DEF:health_router:Module]

View File

@@ -1,10 +1,23 @@
# [DEF:backend.src.api.routes.migration:Module]
# @TIER: STANDARD
# @SEMANTICS: api, migration, dashboards
# @PURPOSE: API endpoints for migration operations.
# @LAYER: API
# @RELATION: DEPENDS_ON -> backend.src.dependencies
# @RELATION: DEPENDS_ON -> backend.src.models.dashboard
# @TIER: CRITICAL
# @SEMANTICS: api, migration, dashboards, sync, dry-run
# @PURPOSE: HTTP contract layer for migration orchestration, settings, dry-run, and mapping sync endpoints.
# @LAYER: Infra
# @RELATION: [DEPENDS_ON] ->[backend.src.dependencies]
# @RELATION: [DEPENDS_ON] ->[backend.src.core.database]
# @RELATION: [DEPENDS_ON] ->[backend.src.core.superset_client]
# @RELATION: [DEPENDS_ON] ->[backend.src.core.migration.dry_run_orchestrator]
# @RELATION: [DEPENDS_ON] ->[backend.src.core.mapping_service]
# @RELATION: [DEPENDS_ON] ->[backend.src.models.dashboard]
# @RELATION: [DEPENDS_ON] ->[backend.src.models.mapping]
# @INVARIANT: Migration endpoints never execute with invalid environment references and always return explicit HTTP errors on guard failures.
# @TEST_CONTRACT: [DashboardSelection + configured envs] -> [task_id | dry-run result | sync summary]
# @TEST_SCENARIO: [invalid_environment] -> [HTTP_400_or_404]
# @TEST_SCENARIO: [valid_execution] -> [success_payload_with_required_fields]
# @TEST_EDGE: [missing_field] ->[HTTP_400]
# @TEST_EDGE: [invalid_type] ->[validation_error]
# @TEST_EDGE: [external_fail] ->[HTTP_500]
# @TEST_INVARIANT: [EnvironmentValidationBeforeAction] -> VERIFIED_BY: [invalid_environment, valid_execution]
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import List, Dict, Any, Optional
@@ -13,7 +26,7 @@ from ...dependencies import get_config_manager, get_task_manager, has_permission
from ...core.database import get_db
from ...models.dashboard import DashboardMetadata, DashboardSelection
from ...core.superset_client import SupersetClient
from ...core.logger import belief_scope
from ...core.logger import logger, belief_scope
from ...core.migration.dry_run_orchestrator import MigrationDryRunService
from ...core.mapping_service import IdMappingService
from ...models.mapping import ResourceMapping
@@ -21,11 +34,11 @@ from ...models.mapping import ResourceMapping
router = APIRouter(prefix="/api", tags=["migration"])
# [DEF:get_dashboards:Function]
# @PURPOSE: Fetch all dashboards from the specified environment for the grid.
# @PRE: Environment ID must be valid.
# @POST: Returns a list of dashboard metadata.
# @PARAM: env_id (str) - The ID of the environment to fetch from.
# @RETURN: List[DashboardMetadata]
# @PURPOSE: Fetch dashboard metadata from a requested environment for migration selection UI.
# @PRE: env_id is provided and exists in configured environments.
# @POST: Returns List[DashboardMetadata] for the resolved environment; emits HTTP_404 when environment is absent.
# @SIDE_EFFECT: Reads environment configuration and performs remote Superset metadata retrieval over network.
# @DATA_CONTRACT: Input[str env_id] -> Output[List[DashboardMetadata]]
@router.get("/environments/{env_id}/dashboards", response_model=List[DashboardMetadata])
async def get_dashboards(
env_id: str,
@@ -33,22 +46,26 @@ async def get_dashboards(
_ = Depends(has_permission("plugin:migration", "EXECUTE"))
):
with belief_scope("get_dashboards", f"env_id={env_id}"):
logger.reason(f"Fetching dashboards for environment: {env_id}")
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
if not env:
raise HTTPException(status_code=404, detail="Environment not found")
env = next((e for e in environments if e.id == env_id), None)
if not env:
logger.explore(f"Environment {env_id} not found in configuration")
raise HTTPException(status_code=404, detail="Environment not found")
client = SupersetClient(env)
dashboards = client.get_dashboards_summary()
return dashboards
client = SupersetClient(env)
dashboards = client.get_dashboards_summary()
logger.reflect(f"Retrieved {len(dashboards)} dashboards from {env_id}")
return dashboards
# [/DEF:get_dashboards:Function]
# [DEF:execute_migration:Function]
# @PURPOSE: Execute the migration of selected dashboards.
# @PRE: Selection must be valid and environments must exist.
# @POST: Starts the migration task and returns the task ID.
# @PARAM: selection (DashboardSelection) - The dashboards to migrate.
# @RETURN: Dict - {"task_id": str, "message": str}
# @PURPOSE: Validate migration selection and enqueue asynchronous migration task execution.
# @PRE: DashboardSelection payload is valid and both source/target environments exist.
# @POST: Returns {"task_id": str, "message": str} when task creation succeeds; emits HTTP_400/HTTP_500 on failure.
# @SIDE_EFFECT: Reads configuration, writes task record through task manager, and writes operational logs.
# @DATA_CONTRACT: Input[DashboardSelection] -> Output[Dict[str, str]]
@router.post("/migration/execute")
async def execute_migration(
selection: DashboardSelection,
@@ -57,38 +74,39 @@ async def execute_migration(
_ = Depends(has_permission("plugin:migration", "EXECUTE"))
):
with belief_scope("execute_migration"):
logger.reason(f"Initiating migration from {selection.source_env_id} to {selection.target_env_id}")
# Validate environments exist
environments = config_manager.get_environments()
env_ids = {e.id for e in environments}
if selection.source_env_id not in env_ids or selection.target_env_id not in env_ids:
raise HTTPException(status_code=400, detail="Invalid source or target environment")
env_ids = {e.id for e in environments}
if selection.source_env_id not in env_ids or selection.target_env_id not in env_ids:
logger.explore("Invalid environment selection", extra={"source": selection.source_env_id, "target": selection.target_env_id})
raise HTTPException(status_code=400, detail="Invalid source or target environment")
# Create migration task with debug logging
from ...core.logger import logger
# Include replace_db_config and fix_cross_filters in the task parameters
task_params = selection.dict()
task_params['replace_db_config'] = selection.replace_db_config
task_params['fix_cross_filters'] = selection.fix_cross_filters
logger.info(f"Creating migration task with params: {task_params}")
logger.info(f"Available environments: {env_ids}")
logger.info(f"Source env: {selection.source_env_id}, Target env: {selection.target_env_id}")
try:
task = await task_manager.create_task("superset-migration", task_params)
logger.info(f"Task created successfully: {task.id}")
return {"task_id": task.id, "message": "Migration initiated"}
except Exception as e:
logger.error(f"Task creation failed: {e}")
raise HTTPException(status_code=500, detail=f"Failed to create migration task: {str(e)}")
# Include replace_db_config and fix_cross_filters in the task parameters
task_params = selection.dict()
task_params['replace_db_config'] = selection.replace_db_config
task_params['fix_cross_filters'] = selection.fix_cross_filters
logger.reason(f"Creating migration task with {len(selection.selected_ids)} dashboards")
try:
task = await task_manager.create_task("superset-migration", task_params)
logger.reflect(f"Migration task created: {task.id}")
return {"task_id": task.id, "message": "Migration initiated"}
except Exception as e:
logger.explore(f"Task creation failed: {e}")
raise HTTPException(status_code=500, detail=f"Failed to create migration task: {str(e)}")
# [/DEF:execute_migration:Function]
# [DEF:dry_run_migration:Function]
# @PURPOSE: Build pre-flight diff and risk summary without applying migration.
# @PRE: Selection and environments are valid.
# @POST: Returns deterministic JSON diff and risk scoring.
# @PURPOSE: Build pre-flight migration diff and risk summary without mutating target systems.
# @PRE: DashboardSelection is valid, source and target environments exist, differ, and selected_ids is non-empty.
# @POST: Returns deterministic dry-run payload; emits HTTP_400 for guard violations and HTTP_500 for orchestrator value errors.
# @SIDE_EFFECT: Reads local mappings from DB and fetches source/target metadata via Superset API.
# @DATA_CONTRACT: Input[DashboardSelection] -> Output[Dict[str, Any]]
@router.post("/migration/dry-run", response_model=Dict[str, Any])
async def dry_run_migration(
selection: DashboardSelection,
@@ -97,33 +115,49 @@ async def dry_run_migration(
_ = Depends(has_permission("plugin:migration", "EXECUTE"))
):
with belief_scope("dry_run_migration"):
logger.reason(f"Starting dry run: {selection.source_env_id} -> {selection.target_env_id}")
environments = config_manager.get_environments()
env_map = {env.id: env for env in environments}
source_env = env_map.get(selection.source_env_id)
target_env = env_map.get(selection.target_env_id)
if not source_env or not target_env:
raise HTTPException(status_code=400, detail="Invalid source or target environment")
if selection.source_env_id == selection.target_env_id:
raise HTTPException(status_code=400, detail="Source and target environments must be different")
if not selection.selected_ids:
raise HTTPException(status_code=400, detail="No dashboards selected for dry run")
env_map = {env.id: env for env in environments}
source_env = env_map.get(selection.source_env_id)
target_env = env_map.get(selection.target_env_id)
if not source_env or not target_env:
logger.explore("Invalid environment selection for dry run")
raise HTTPException(status_code=400, detail="Invalid source or target environment")
if selection.source_env_id == selection.target_env_id:
logger.explore("Source and target environments are identical")
raise HTTPException(status_code=400, detail="Source and target environments must be different")
if not selection.selected_ids:
logger.explore("No dashboards selected for dry run")
raise HTTPException(status_code=400, detail="No dashboards selected for dry run")
service = MigrationDryRunService()
source_client = SupersetClient(source_env)
target_client = SupersetClient(target_env)
try:
return service.run(
selection=selection,
source_client=source_client,
target_client=target_client,
db=db,
)
except ValueError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
service = MigrationDryRunService()
source_client = SupersetClient(source_env)
target_client = SupersetClient(target_env)
try:
result = service.run(
selection=selection,
source_client=source_client,
target_client=target_client,
db=db,
)
logger.reflect("Dry run analysis complete")
return result
except ValueError as exc:
logger.explore(f"Dry run orchestrator failed: {exc}")
raise HTTPException(status_code=500, detail=str(exc)) from exc
# [/DEF:dry_run_migration:Function]
# [DEF:get_migration_settings:Function]
# @PURPOSE: Get current migration Cron string explicitly.
# @PURPOSE: Read and return configured migration synchronization cron expression.
# @PRE: Configuration store is available and requester has READ permission.
# @POST: Returns {"cron": str} reflecting current persisted settings value.
# @SIDE_EFFECT: Reads configuration from config manager.
# @DATA_CONTRACT: Input[None] -> Output[Dict[str, str]]
@router.get("/migration/settings", response_model=Dict[str, str])
async def get_migration_settings(
config_manager=Depends(get_config_manager),
@@ -136,7 +170,11 @@ async def get_migration_settings(
# [/DEF:get_migration_settings:Function]
# [DEF:update_migration_settings:Function]
# @PURPOSE: Update migration Cron string.
# @PURPOSE: Validate and persist migration synchronization cron expression update.
# @PRE: Payload includes "cron" key and requester has WRITE permission.
# @POST: Returns {"cron": str, "status": "updated"} and persists updated cron value.
# @SIDE_EFFECT: Mutates configuration and writes persisted config through config manager.
# @DATA_CONTRACT: Input[Dict[str, str]] -> Output[Dict[str, str]]
@router.put("/migration/settings", response_model=Dict[str, str])
async def update_migration_settings(
payload: Dict[str, str],
@@ -157,7 +195,11 @@ async def update_migration_settings(
# [/DEF:update_migration_settings:Function]
# [DEF:get_resource_mappings:Function]
# @PURPOSE: Fetch synchronized object mappings with search, filtering, and pagination.
# @PURPOSE: Fetch synchronized resource mappings with optional filters and pagination for migration mappings view.
# @PRE: skip>=0, 1<=limit<=500, DB session is active, requester has READ permission.
# @POST: Returns {"items": [...], "total": int} where items reflect applied filters and pagination.
# @SIDE_EFFECT: Executes database read queries against ResourceMapping table.
# @DATA_CONTRACT: Input[QueryParams] -> Output[Dict[str, Any]]
@router.get("/migration/mappings-data", response_model=Dict[str, Any])
async def get_resource_mappings(
skip: int = Query(0, ge=0),
@@ -203,9 +245,11 @@ async def get_resource_mappings(
# [/DEF:get_resource_mappings:Function]
# [DEF:trigger_sync_now:Function]
# @PURPOSE: Triggers an immediate ID synchronization for all environments.
# @PRE: At least one environment must be configured.
# @POST: Environment rows are ensured in DB; sync_environment is called for each.
# @PURPOSE: Trigger immediate ID synchronization for every configured environment.
# @PRE: At least one environment is configured and requester has EXECUTE permission.
# @POST: Returns sync summary with synced/failed counts after attempting all environments.
# @SIDE_EFFECT: Upserts Environment rows, commits DB transaction, performs network sync calls, and writes logs.
# @DATA_CONTRACT: Input[None] -> Output[Dict[str, Any]]
@router.post("/migration/sync-now", response_model=Dict[str, Any])
async def trigger_sync_now(
config_manager=Depends(get_config_manager),

View File

@@ -16,10 +16,15 @@ from pydantic import BaseModel
from ...core.config_models import AppConfig, Environment, GlobalSettings, LoggingConfig
from ...models.storage import StorageConfig
from ...dependencies import get_config_manager, has_permission
from ...core.config_manager import ConfigManager
from ...core.logger import logger, belief_scope
from ...core.superset_client import SupersetClient
from ...services.llm_prompt_templates import normalize_llm_settings
from ...core.config_manager import ConfigManager
from ...core.logger import logger, belief_scope
from ...core.superset_client import SupersetClient
from ...services.llm_prompt_templates import normalize_llm_settings
from ...models.llm import ValidationPolicy
from ...models.config import AppConfigRecord
from ...schemas.settings import ValidationPolicyCreate, ValidationPolicyUpdate, ValidationPolicyResponse
from ...core.database import get_db
from sqlalchemy.orm import Session
# [/SECTION]
# [DEF:LoggingConfigResponse:Class]
@@ -31,38 +36,38 @@ class LoggingConfigResponse(BaseModel):
enable_belief_state: bool
# [/DEF:LoggingConfigResponse:Class]
router = APIRouter()
# [DEF:_normalize_superset_env_url:Function]
# @PURPOSE: Canonicalize Superset environment URL to base host/path without trailing /api/v1.
# @PRE: raw_url can be empty.
# @POST: Returns normalized base URL.
def _normalize_superset_env_url(raw_url: str) -> str:
normalized = str(raw_url or "").strip().rstrip("/")
if normalized.lower().endswith("/api/v1"):
normalized = normalized[:-len("/api/v1")]
return normalized.rstrip("/")
# [/DEF:_normalize_superset_env_url:Function]
# [DEF:_validate_superset_connection_fast:Function]
# @PURPOSE: Run lightweight Superset connectivity validation without full pagination scan.
# @PRE: env contains valid URL and credentials.
# @POST: Raises on auth/API failures; returns None on success.
def _validate_superset_connection_fast(env: Environment) -> None:
client = SupersetClient(env)
# 1) Explicit auth check
client.authenticate()
# 2) Single lightweight API call to ensure read access
client.get_dashboards_page(
query={
"page": 0,
"page_size": 1,
"columns": ["id"],
}
)
# [/DEF:_validate_superset_connection_fast:Function]
router = APIRouter()
# [DEF:_normalize_superset_env_url:Function]
# @PURPOSE: Canonicalize Superset environment URL to base host/path without trailing /api/v1.
# @PRE: raw_url can be empty.
# @POST: Returns normalized base URL.
def _normalize_superset_env_url(raw_url: str) -> str:
normalized = str(raw_url or "").strip().rstrip("/")
if normalized.lower().endswith("/api/v1"):
normalized = normalized[:-len("/api/v1")]
return normalized.rstrip("/")
# [/DEF:_normalize_superset_env_url:Function]
# [DEF:_validate_superset_connection_fast:Function]
# @PURPOSE: Run lightweight Superset connectivity validation without full pagination scan.
# @PRE: env contains valid URL and credentials.
# @POST: Raises on auth/API failures; returns None on success.
def _validate_superset_connection_fast(env: Environment) -> None:
client = SupersetClient(env)
# 1) Explicit auth check
client.authenticate()
# 2) Single lightweight API call to ensure read access
client.get_dashboards_page(
query={
"page": 0,
"page_size": 1,
"columns": ["id"],
}
)
# [/DEF:_validate_superset_connection_fast:Function]
# [DEF:get_settings:Function]
# @PURPOSE: Retrieves all application settings.
@@ -70,14 +75,14 @@ def _validate_superset_connection_fast(env: Environment) -> None:
# @POST: Returns masked AppConfig.
# @RETURN: AppConfig - The current configuration.
@router.get("", response_model=AppConfig)
async def get_settings(
async def get_settings(
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "READ"))
):
with belief_scope("get_settings"):
logger.info("[get_settings][Entry] Fetching all settings")
config = config_manager.get_config().copy(deep=True)
config.settings.llm = normalize_llm_settings(config.settings.llm)
config = config_manager.get_config().copy(deep=True)
config.settings.llm = normalize_llm_settings(config.settings.llm)
# Mask passwords
for env in config.environments:
if env.password:
@@ -143,18 +148,18 @@ async def update_storage_settings(
# @PRE: Config manager is available.
# @POST: Returns list of environments.
# @RETURN: List[Environment] - List of environments.
@router.get("/environments", response_model=List[Environment])
async def get_environments(
@router.get("/environments", response_model=List[Environment])
async def get_environments(
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "READ"))
):
with belief_scope("get_environments"):
logger.info("[get_environments][Entry] Fetching environments")
environments = config_manager.get_environments()
return [
env.copy(update={"url": _normalize_superset_env_url(env.url)})
for env in environments
]
):
with belief_scope("get_environments"):
logger.info("[get_environments][Entry] Fetching environments")
environments = config_manager.get_environments()
return [
env.copy(update={"url": _normalize_superset_env_url(env.url)})
for env in environments
]
# [/DEF:get_environments:Function]
# [DEF:add_environment:Function]
@@ -164,21 +169,21 @@ async def get_environments(
# @PARAM: env (Environment) - The environment to add.
# @RETURN: Environment - The added environment.
@router.post("/environments", response_model=Environment)
async def add_environment(
env: Environment,
async def add_environment(
env: Environment,
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
with belief_scope("add_environment"):
logger.info(f"[add_environment][Entry] Adding environment {env.id}")
env = env.copy(update={"url": _normalize_superset_env_url(env.url)})
):
with belief_scope("add_environment"):
logger.info(f"[add_environment][Entry] Adding environment {env.id}")
env = env.copy(update={"url": _normalize_superset_env_url(env.url)})
# Validate connection before adding (fast path)
try:
_validate_superset_connection_fast(env)
except Exception as e:
logger.error(f"[add_environment][Coherence:Failed] Connection validation failed: {e}")
raise HTTPException(status_code=400, detail=f"Connection validation failed: {e}")
# Validate connection before adding (fast path)
try:
_validate_superset_connection_fast(env)
except Exception as e:
logger.error(f"[add_environment][Coherence:Failed] Connection validation failed: {e}")
raise HTTPException(status_code=400, detail=f"Connection validation failed: {e}")
config_manager.add_environment(env)
return env
@@ -192,29 +197,29 @@ async def add_environment(
# @PARAM: env (Environment) - The updated environment data.
# @RETURN: Environment - The updated environment.
@router.put("/environments/{id}", response_model=Environment)
async def update_environment(
async def update_environment(
id: str,
env: Environment,
config_manager: ConfigManager = Depends(get_config_manager)
):
):
with belief_scope("update_environment"):
logger.info(f"[update_environment][Entry] Updating environment {id}")
env = env.copy(update={"url": _normalize_superset_env_url(env.url)})
# If password is masked, we need the real one for validation
env_to_validate = env.copy(deep=True)
env = env.copy(update={"url": _normalize_superset_env_url(env.url)})
# If password is masked, we need the real one for validation
env_to_validate = env.copy(deep=True)
if env_to_validate.password == "********":
old_env = next((e for e in config_manager.get_environments() if e.id == id), None)
if old_env:
env_to_validate.password = old_env.password
# Validate connection before updating (fast path)
try:
_validate_superset_connection_fast(env_to_validate)
except Exception as e:
logger.error(f"[update_environment][Coherence:Failed] Connection validation failed: {e}")
raise HTTPException(status_code=400, detail=f"Connection validation failed: {e}")
# Validate connection before updating (fast path)
try:
_validate_superset_connection_fast(env_to_validate)
except Exception as e:
logger.error(f"[update_environment][Coherence:Failed] Connection validation failed: {e}")
raise HTTPException(status_code=400, detail=f"Connection validation failed: {e}")
if config_manager.update_environment(id, env):
return env
@@ -244,7 +249,7 @@ async def delete_environment(
# @PARAM: id (str) - The ID of the environment to test.
# @RETURN: dict - Success message or error.
@router.post("/environments/{id}/test")
async def test_environment_connection(
async def test_environment_connection(
id: str,
config_manager: ConfigManager = Depends(get_config_manager)
):
@@ -256,11 +261,11 @@ async def test_environment_connection(
if not env:
raise HTTPException(status_code=404, detail=f"Environment {id} not found")
try:
_validate_superset_connection_fast(env)
logger.info(f"[test_environment_connection][Coherence:OK] Connection successful for {id}")
return {"status": "success", "message": "Connection successful"}
try:
_validate_superset_connection_fast(env)
logger.info(f"[test_environment_connection][Coherence:OK] Connection successful for {id}")
return {"status": "success", "message": "Connection successful"}
except Exception as e:
logger.error(f"[test_environment_connection][Coherence:Failed] Connection failed for {id}: {e}")
return {"status": "error", "message": str(e)}
@@ -313,13 +318,14 @@ async def update_logging_config(
# [/DEF:update_logging_config:Function]
# [DEF:ConsolidatedSettingsResponse:Class]
class ConsolidatedSettingsResponse(BaseModel):
class ConsolidatedSettingsResponse(BaseModel):
environments: List[dict]
connections: List[dict]
llm: dict
llm_providers: List[dict]
logging: dict
storage: dict
notifications: dict = {}
# [/DEF:ConsolidatedSettingsResponse:Class]
# [DEF:get_consolidated_settings:Function]
@@ -328,7 +334,7 @@ class ConsolidatedSettingsResponse(BaseModel):
# @POST: Returns all consolidated settings.
# @RETURN: ConsolidatedSettingsResponse - All settings categories.
@router.get("/consolidated", response_model=ConsolidatedSettingsResponse)
async def get_consolidated_settings(
async def get_consolidated_settings(
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "READ"))
):
@@ -340,6 +346,7 @@ async def get_consolidated_settings(
from ...services.llm_provider import LLMProviderService
from ...core.database import SessionLocal
db = SessionLocal()
notifications_payload = {}
try:
llm_service = LLMProviderService(db)
providers = llm_service.get_all_providers()
@@ -354,19 +361,24 @@ async def get_consolidated_settings(
"is_active": p.is_active
} for p in providers
]
config_record = db.query(AppConfigRecord).filter(AppConfigRecord.id == "global").first()
if config_record and isinstance(config_record.payload, dict):
notifications_payload = config_record.payload.get("notifications", {}) or {}
finally:
db.close()
normalized_llm = normalize_llm_settings(config.settings.llm)
return ConsolidatedSettingsResponse(
environments=[env.dict() for env in config.environments],
connections=config.settings.connections,
llm=normalized_llm,
llm_providers=llm_providers_list,
logging=config.settings.logging.dict(),
storage=config.settings.storage.dict()
)
normalized_llm = normalize_llm_settings(config.settings.llm)
return ConsolidatedSettingsResponse(
environments=[env.dict() for env in config.environments],
connections=config.settings.connections,
llm=normalized_llm,
llm_providers=llm_providers_list,
logging=config.settings.logging.dict(),
storage=config.settings.storage.dict(),
notifications=notifications_payload
)
# [/DEF:get_consolidated_settings:Function]
# [DEF:update_consolidated_settings:Function]
@@ -389,9 +401,9 @@ async def update_consolidated_settings(
if "connections" in settings_patch:
current_settings.connections = settings_patch["connections"]
# Update LLM if provided
if "llm" in settings_patch:
current_settings.llm = normalize_llm_settings(settings_patch["llm"])
# Update LLM if provided
if "llm" in settings_patch:
current_settings.llm = normalize_llm_settings(settings_patch["llm"])
# Update Logging if provided
if "logging" in settings_patch:
@@ -405,8 +417,88 @@ async def update_consolidated_settings(
raise HTTPException(status_code=400, detail=message)
current_settings.storage = new_storage
if "notifications" in settings_patch:
payload = config_manager.get_payload()
payload["notifications"] = settings_patch["notifications"]
config_manager.save_config(payload)
config_manager.update_global_settings(current_settings)
return {"status": "success", "message": "Settings updated"}
# [/DEF:update_consolidated_settings:Function]
# [DEF:get_validation_policies:Function]
# @PURPOSE: Lists all validation policies.
# @RETURN: List[ValidationPolicyResponse] - List of policies.
@router.get("/automation/policies", response_model=List[ValidationPolicyResponse])
async def get_validation_policies(
db: Session = Depends(get_db),
_ = Depends(has_permission("admin:settings", "READ"))
):
with belief_scope("get_validation_policies"):
return db.query(ValidationPolicy).all()
# [/DEF:get_validation_policies:Function]
# [DEF:create_validation_policy:Function]
# @PURPOSE: Creates a new validation policy.
# @PARAM: policy (ValidationPolicyCreate) - The policy data.
# @RETURN: ValidationPolicyResponse - The created policy.
@router.post("/automation/policies", response_model=ValidationPolicyResponse)
async def create_validation_policy(
policy: ValidationPolicyCreate,
db: Session = Depends(get_db),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
with belief_scope("create_validation_policy"):
db_policy = ValidationPolicy(**policy.dict())
db.add(db_policy)
db.commit()
db.refresh(db_policy)
return db_policy
# [/DEF:create_validation_policy:Function]
# [DEF:update_validation_policy:Function]
# @PURPOSE: Updates an existing validation policy.
# @PARAM: id (str) - The ID of the policy to update.
# @PARAM: policy (ValidationPolicyUpdate) - The updated policy data.
# @RETURN: ValidationPolicyResponse - The updated policy.
@router.patch("/automation/policies/{id}", response_model=ValidationPolicyResponse)
async def update_validation_policy(
id: str,
policy: ValidationPolicyUpdate,
db: Session = Depends(get_db),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
with belief_scope("update_validation_policy"):
db_policy = db.query(ValidationPolicy).filter(ValidationPolicy.id == id).first()
if not db_policy:
raise HTTPException(status_code=404, detail="Policy not found")
update_data = policy.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(db_policy, key, value)
db.commit()
db.refresh(db_policy)
return db_policy
# [/DEF:update_validation_policy:Function]
# [DEF:delete_validation_policy:Function]
# @PURPOSE: Deletes a validation policy.
# @PARAM: id (str) - The ID of the policy to delete.
@router.delete("/automation/policies/{id}")
async def delete_validation_policy(
id: str,
db: Session = Depends(get_db),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
with belief_scope("delete_validation_policy"):
db_policy = db.query(ValidationPolicy).filter(ValidationPolicy.id == id).first()
if not db_policy:
raise HTTPException(status_code=404, detail="Policy not found")
db.delete(db_policy)
db.commit()
return {"message": "Policy deleted"}
# [/DEF:delete_validation_policy:Function]
# [/DEF:SettingsRouter:Module]

View File

@@ -21,7 +21,7 @@ import asyncio
from .dependencies import get_task_manager, get_scheduler_service
from .core.utils.network import NetworkError
from .core.logger import logger, belief_scope
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin, llm, dashboards, datasets, reports, assistant, clean_release, clean_release_v2, profile
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin, llm, dashboards, datasets, reports, assistant, clean_release, clean_release_v2, profile, health
from .api import auth
# [DEF:App:Global]
@@ -136,6 +136,7 @@ app.include_router(assistant.router, prefix="/api/assistant", tags=["Assistant"]
app.include_router(clean_release.router)
app.include_router(clean_release_v2.router)
app.include_router(profile.router)
app.include_router(health.router)
# [DEF:api.include_routers:Action]

View File

@@ -0,0 +1,3 @@
# [DEF:src.core:Package]
# @PURPOSE: Backend core services and infrastructure package root.
# [/DEF:src.core:Package]

View File

@@ -0,0 +1,99 @@
import pytest
from datetime import time, date, datetime, timedelta
from src.core.scheduler import ThrottledSchedulerConfigurator
# [DEF:test_throttled_scheduler:Module]
# @TIER: STANDARD
# @PURPOSE: Unit tests for ThrottledSchedulerConfigurator distribution logic.
def test_calculate_schedule_even_distribution():
"""
@TEST_SCENARIO: 3 tasks in a 2-hour window should be spaced 1 hour apart.
"""
start = time(1, 0)
end = time(3, 0)
dashboards = ["d1", "d2", "d3"]
today = date(2024, 1, 1)
schedule = ThrottledSchedulerConfigurator.calculate_schedule(start, end, dashboards, today)
assert len(schedule) == 3
assert schedule[0] == datetime(2024, 1, 1, 1, 0)
assert schedule[1] == datetime(2024, 1, 1, 2, 0)
assert schedule[2] == datetime(2024, 1, 1, 3, 0)
def test_calculate_schedule_midnight_crossing():
"""
@TEST_SCENARIO: Window from 23:00 to 01:00 (next day).
"""
start = time(23, 0)
end = time(1, 0)
dashboards = ["d1", "d2", "d3"]
today = date(2024, 1, 1)
schedule = ThrottledSchedulerConfigurator.calculate_schedule(start, end, dashboards, today)
assert len(schedule) == 3
assert schedule[0] == datetime(2024, 1, 1, 23, 0)
assert schedule[1] == datetime(2024, 1, 2, 0, 0)
assert schedule[2] == datetime(2024, 1, 2, 1, 0)
def test_calculate_schedule_single_task():
"""
@TEST_SCENARIO: Single task should be scheduled at start time.
"""
start = time(1, 0)
end = time(2, 0)
dashboards = ["d1"]
today = date(2024, 1, 1)
schedule = ThrottledSchedulerConfigurator.calculate_schedule(start, end, dashboards, today)
assert len(schedule) == 1
assert schedule[0] == datetime(2024, 1, 1, 1, 0)
def test_calculate_schedule_empty_list():
"""
@TEST_SCENARIO: Empty dashboard list returns empty schedule.
"""
start = time(1, 0)
end = time(2, 0)
dashboards = []
today = date(2024, 1, 1)
schedule = ThrottledSchedulerConfigurator.calculate_schedule(start, end, dashboards, today)
assert schedule == []
def test_calculate_schedule_zero_window():
"""
@TEST_SCENARIO: Window start == end. All tasks at start time.
"""
start = time(1, 0)
end = time(1, 0)
dashboards = ["d1", "d2"]
today = date(2024, 1, 1)
schedule = ThrottledSchedulerConfigurator.calculate_schedule(start, end, dashboards, today)
assert len(schedule) == 2
assert schedule[0] == datetime(2024, 1, 1, 1, 0)
assert schedule[1] == datetime(2024, 1, 1, 1, 0)
def test_calculate_schedule_very_small_window():
"""
@TEST_SCENARIO: Window smaller than number of tasks (in seconds).
"""
start = time(1, 0, 0)
end = time(1, 0, 1) # 1 second window
dashboards = ["d1", "d2", "d3"]
today = date(2024, 1, 1)
schedule = ThrottledSchedulerConfigurator.calculate_schedule(start, end, dashboards, today)
assert len(schedule) == 3
assert schedule[0] == datetime(2024, 1, 1, 1, 0, 0)
assert schedule[1] == datetime(2024, 1, 1, 1, 0, 0, 500000) # 0.5s
assert schedule[2] == datetime(2024, 1, 1, 1, 0, 1)
# [/DEF:test_throttled_scheduler:Module]

View File

@@ -0,0 +1,3 @@
# [DEF:src.core.auth:Package]
# @PURPOSE: Authentication and authorization package root.
# [/DEF:src.core.auth:Package]

View File

@@ -1,107 +1,146 @@
# [DEF:backend.src.core.auth.repository:Module]
#
# @SEMANTICS: auth, repository, database, user, role
# @PURPOSE: Data access layer for authentication-related entities.
# @LAYER: Core
# @RELATION: DEPENDS_ON -> sqlalchemy
# @RELATION: USES -> backend.src.models.auth
# @TIER: CRITICAL
# @SEMANTICS: auth, repository, database, user, role, permission
# @PURPOSE: Data access layer for authentication and user preference entities.
# @LAYER: Domain
# @RELATION: [DEPENDS_ON] ->[sqlalchemy.orm.Session]
# @RELATION: [DEPENDS_ON] ->[backend.src.models.auth]
# @RELATION: [DEPENDS_ON] ->[backend.src.models.profile]
# @RELATION: [DEPENDS_ON] ->[backend.src.core.logger.belief_scope]
# @INVARIANT: All database read/write operations must execute via the injected SQLAlchemy session boundary.
#
# @INVARIANT: All database operations must be performed within a session.
# [SECTION: IMPORTS]
from typing import Optional, List
from typing import List, Optional
from sqlalchemy.orm import Session
from ...models.auth import User, Role, Permission
from ...models.auth import Permission, Role, User
from ...models.profile import UserDashboardPreference
from ..logger import belief_scope
from ..logger import belief_scope, logger
# [/SECTION]
# [DEF:AuthRepository:Class]
# @PURPOSE: Encapsulates database operations for authentication.
# @PURPOSE: Encapsulates database operations for authentication-related entities.
# @RELATION: [DEPENDS_ON] ->[sqlalchemy.orm.Session]
class AuthRepository:
# [DEF:__init__:Function]
# @PURPOSE: Initializes the repository with a database session.
# @PARAM: db (Session) - SQLAlchemy session.
# @PURPOSE: Bind repository instance to an existing SQLAlchemy session.
# @PRE: db is an initialized sqlalchemy.orm.Session instance.
# @POST: self.db points to the provided session and is used by all repository methods.
# @SIDE_EFFECT: Stores session reference on repository instance state.
# @DATA_CONTRACT: Input[Session] -> Output[None]
def __init__(self, db: Session):
self.db = db
with belief_scope("AuthRepository.__init__"):
if not isinstance(db, Session):
logger.explore("Invalid session provided to AuthRepository", extra={"type": type(db)})
raise TypeError("db must be an instance of sqlalchemy.orm.Session")
logger.reason("Binding AuthRepository to database session")
self.db = db
logger.reflect("AuthRepository initialized")
# [/DEF:__init__:Function]
# [DEF:get_user_by_username:Function]
# @PURPOSE: Retrieves a user by their username.
# @PRE: username is a string.
# @POST: Returns User object if found, else None.
# @PARAM: username (str) - The username to search for.
# @RETURN: Optional[User] - The found user or None.
# @PURPOSE: Retrieve a user entity by unique username.
# @PRE: username is a non-empty str and self.db is a valid open Session.
# @POST: Returns matching User entity when present, otherwise None.
# @SIDE_EFFECT: Executes read-only SELECT query through active DB session.
# @DATA_CONTRACT: Input[str] -> Output[Optional[User]]
def get_user_by_username(self, username: str) -> Optional[User]:
with belief_scope("AuthRepository.get_user_by_username"):
return self.db.query(User).filter(User.username == username).first()
if not username or not isinstance(username, str):
raise ValueError("username must be a non-empty string")
logger.reason(f"Querying user by username: {username}")
user = self.db.query(User).filter(User.username == username).first()
if user:
logger.reflect(f"User found: {username}")
else:
logger.explore(f"User not found: {username}")
return user
# [/DEF:get_user_by_username:Function]
# [DEF:get_user_by_id:Function]
# @PURPOSE: Retrieves a user by their unique ID.
# @PRE: user_id is a valid UUID string.
# @POST: Returns User object if found, else None.
# @PARAM: user_id (str) - The user's unique identifier.
# @RETURN: Optional[User] - The found user or None.
# @PURPOSE: Retrieve a user entity by identifier.
# @PRE: user_id is a non-empty str and self.db is a valid open Session.
# @POST: Returns matching User entity when present, otherwise None.
# @SIDE_EFFECT: Executes read-only SELECT query through active DB session.
# @DATA_CONTRACT: Input[str] -> Output[Optional[User]]
def get_user_by_id(self, user_id: str) -> Optional[User]:
with belief_scope("AuthRepository.get_user_by_id"):
return self.db.query(User).filter(User.id == user_id).first()
if not user_id or not isinstance(user_id, str):
raise ValueError("user_id must be a non-empty string")
logger.reason(f"Querying user by ID: {user_id}")
user = self.db.query(User).filter(User.id == user_id).first()
if user:
logger.reflect(f"User found by ID: {user_id}")
else:
logger.explore(f"User not found by ID: {user_id}")
return user
# [/DEF:get_user_by_id:Function]
# [DEF:get_role_by_name:Function]
# @PURPOSE: Retrieves a role by its name.
# @PRE: name is a string.
# @POST: Returns Role object if found, else None.
# @PARAM: name (str) - The role name to search for.
# @RETURN: Optional[Role] - The found role or None.
# @PURPOSE: Retrieve a role entity by role name.
# @PRE: name is a non-empty str and self.db is a valid open Session.
# @POST: Returns matching Role entity when present, otherwise None.
# @SIDE_EFFECT: Executes read-only SELECT query through active DB session.
# @DATA_CONTRACT: Input[str] -> Output[Optional[Role]]
def get_role_by_name(self, name: str) -> Optional[Role]:
with belief_scope("AuthRepository.get_role_by_name"):
return self.db.query(Role).filter(Role.name == name).first()
# [/DEF:get_role_by_name:Function]
# [DEF:update_last_login:Function]
# @PURPOSE: Updates the last_login timestamp for a user.
# @PRE: user object is a valid User instance.
# @POST: User's last_login is updated in the database.
# @SIDE_EFFECT: Commits the transaction.
# @PARAM: user (User) - The user to update.
# @PURPOSE: Update last_login timestamp for the provided user entity.
# @PRE: user is a managed User instance and self.db is a valid open Session.
# @POST: user.last_login is set to current UTC timestamp and transaction is committed.
# @SIDE_EFFECT: Mutates user entity state and commits database transaction.
# @DATA_CONTRACT: Input[User] -> Output[None]
def update_last_login(self, user: User):
with belief_scope("AuthRepository.update_last_login"):
if not isinstance(user, User):
raise TypeError("user must be an instance of User")
from datetime import datetime
logger.reason(f"Updating last login for user: {user.username}")
user.last_login = datetime.utcnow()
self.db.add(user)
self.db.commit()
logger.reflect(f"Last login updated and committed for user: {user.username}")
# [/DEF:update_last_login:Function]
# [DEF:get_role_by_id:Function]
# @PURPOSE: Retrieves a role by its unique ID.
# @PRE: role_id is a string.
# @POST: Returns Role object if found, else None.
# @PARAM: role_id (str) - The role's unique identifier.
# @RETURN: Optional[Role] - The found role or None.
# @PURPOSE: Retrieve a role entity by identifier.
# @PRE: role_id is a non-empty str and self.db is a valid open Session.
# @POST: Returns matching Role entity when present, otherwise None.
# @SIDE_EFFECT: Executes read-only SELECT query through active DB session.
# @DATA_CONTRACT: Input[str] -> Output[Optional[Role]]
def get_role_by_id(self, role_id: str) -> Optional[Role]:
with belief_scope("AuthRepository.get_role_by_id"):
return self.db.query(Role).filter(Role.id == role_id).first()
# [/DEF:get_role_by_id:Function]
# [DEF:get_permission_by_id:Function]
# @PURPOSE: Retrieves a permission by its unique ID.
# @PRE: perm_id is a string.
# @POST: Returns Permission object if found, else None.
# @PARAM: perm_id (str) - The permission's unique identifier.
# @RETURN: Optional[Permission] - The found permission or None.
# @PURPOSE: Retrieve a permission entity by identifier.
# @PRE: perm_id is a non-empty str and self.db is a valid open Session.
# @POST: Returns matching Permission entity when present, otherwise None.
# @SIDE_EFFECT: Executes read-only SELECT query through active DB session.
# @DATA_CONTRACT: Input[str] -> Output[Optional[Permission]]
def get_permission_by_id(self, perm_id: str) -> Optional[Permission]:
with belief_scope("AuthRepository.get_permission_by_id"):
return self.db.query(Permission).filter(Permission.id == perm_id).first()
# [/DEF:get_permission_by_id:Function]
# [DEF:get_permission_by_resource_action:Function]
# @PURPOSE: Retrieves a permission by resource and action.
# @PRE: resource and action are strings.
# @POST: Returns Permission object if found, else None.
# @PARAM: resource (str) - The resource name.
# @PARAM: action (str) - The action name.
# @RETURN: Optional[Permission] - The found permission or None.
# @PURPOSE: Retrieve a permission entity by resource and action pair.
# @PRE: resource and action are non-empty str values; self.db is a valid open Session.
# @POST: Returns matching Permission entity when present, otherwise None.
# @SIDE_EFFECT: Executes read-only SELECT query through active DB session.
# @DATA_CONTRACT: Input[str, str] -> Output[Optional[Permission]]
def get_permission_by_resource_action(self, resource: str, action: str) -> Optional[Permission]:
with belief_scope("AuthRepository.get_permission_by_resource_action"):
return self.db.query(Permission).filter(
@@ -111,11 +150,11 @@ class AuthRepository:
# [/DEF:get_permission_by_resource_action:Function]
# [DEF:get_user_dashboard_preference:Function]
# @PURPOSE: Retrieves dashboard preference by owner user ID.
# @PRE: user_id is a string.
# @POST: Returns UserDashboardPreference if found, else None.
# @PARAM: user_id (str) - Preference owner identifier.
# @RETURN: Optional[UserDashboardPreference] - Found preference or None.
# @PURPOSE: Retrieve dashboard preference entity owned by specified user.
# @PRE: user_id is a non-empty str and self.db is a valid open Session.
# @POST: Returns matching UserDashboardPreference entity when present, otherwise None.
# @SIDE_EFFECT: Executes read-only SELECT query through active DB session.
# @DATA_CONTRACT: Input[str] -> Output[Optional[UserDashboardPreference]]
def get_user_dashboard_preference(self, user_id: str) -> Optional[UserDashboardPreference]:
with belief_scope("AuthRepository.get_user_dashboard_preference"):
return (
@@ -126,31 +165,38 @@ class AuthRepository:
# [/DEF:get_user_dashboard_preference:Function]
# [DEF:save_user_dashboard_preference:Function]
# @PURPOSE: Persists dashboard preference entity and returns refreshed row.
# @PRE: preference is a valid UserDashboardPreference entity.
# @POST: Preference is committed and refreshed in database.
# @PARAM: preference (UserDashboardPreference) - Preference entity to persist.
# @RETURN: UserDashboardPreference - Persisted preference row.
# @PURPOSE: Persist dashboard preference entity and return refreshed persistent row.
# @PRE: preference is a valid UserDashboardPreference entity and self.db is a valid open Session.
# @POST: preference is committed to DB, refreshed from DB state, and returned.
# @SIDE_EFFECT: Performs INSERT/UPDATE commit and refresh via active DB session.
# @DATA_CONTRACT: Input[UserDashboardPreference] -> Output[UserDashboardPreference]
def save_user_dashboard_preference(
self,
preference: UserDashboardPreference,
) -> UserDashboardPreference:
with belief_scope("AuthRepository.save_user_dashboard_preference"):
if not isinstance(preference, UserDashboardPreference):
raise TypeError("preference must be an instance of UserDashboardPreference")
logger.reason(f"Saving dashboard preference for user: {preference.user_id}")
self.db.add(preference)
self.db.commit()
self.db.refresh(preference)
logger.reflect(f"Dashboard preference saved and refreshed for user: {preference.user_id}")
return preference
# [/DEF:save_user_dashboard_preference:Function]
# [DEF:list_permissions:Function]
# @PURPOSE: Lists all available permissions.
# @POST: Returns a list of all Permission objects.
# @RETURN: List[Permission] - List of permissions.
# @PURPOSE: List all permission entities available in storage.
# @PRE: self.db is a valid open Session.
# @POST: Returns list containing all Permission entities visible to the session.
# @SIDE_EFFECT: Executes read-only SELECT query through active DB session.
# @DATA_CONTRACT: Input[None] -> Output[List[Permission]]
def list_permissions(self) -> List[Permission]:
with belief_scope("AuthRepository.list_permissions"):
return self.db.query(Permission).all()
# [/DEF:list_permissions:Function]
# [/DEF:AuthRepository:Class]
# [/DEF:AuthRepository:Class]
# [/DEF:backend.src.core.auth.repository:Module]

View File

@@ -1,17 +1,17 @@
# [DEF:ConfigManagerModule:Module]
#
# @TIER: STANDARD
# @SEMANTICS: config, manager, persistence, postgresql
# @PURPOSE: Manages application configuration persisted in database with one-time migration from JSON.
# @LAYER: Core
# @RELATION: DEPENDS_ON -> ConfigModels
# @RELATION: DEPENDS_ON -> AppConfigRecord
# @RELATION: CALLS -> logger
# @TIER: CRITICAL
# @SEMANTICS: config, manager, persistence, migration, postgresql
# @PURPOSE: Manages application configuration persistence in DB with one-time migration from legacy JSON.
# @LAYER: Domain
# @RELATION: [DEPENDS_ON] ->[ConfigModels]
# @RELATION: [DEPENDS_ON] ->[SessionLocal]
# @RELATION: [DEPENDS_ON] ->[AppConfigRecord]
# @RELATION: [CALLS] ->[logger]
# @RELATION: [CALLS] ->[configure_logger]
# @RELATION: [BINDS_TO] ->[ConfigManager]
# @INVARIANT: Configuration must always be representable by AppConfig and persisted under global record id.
#
# @INVARIANT: Configuration must always be valid according to AppConfig model.
# @PUBLIC_API: ConfigManager
# [SECTION: IMPORTS]
import json
import os
from pathlib import Path
@@ -23,47 +23,58 @@ from .config_models import AppConfig, Environment, GlobalSettings, StorageConfig
from .database import SessionLocal
from ..models.config import AppConfigRecord
from .logger import logger, configure_logger, belief_scope
# [/SECTION]
# [DEF:ConfigManager:Class]
# @TIER: STANDARD
# @PURPOSE: A class to handle application configuration persistence and management.
# @TIER: CRITICAL
# @PURPOSE: Handles application configuration load, validation, mutation, and persistence lifecycle.
class ConfigManager:
# [DEF:__init__:Function]
# @TIER: STANDARD
# @PURPOSE: Initializes the ConfigManager.
# @PRE: isinstance(config_path, str) and len(config_path) > 0
# @POST: self.config is an instance of AppConfig
# @PARAM: config_path (str) - Path to legacy JSON config (used only for initial migration fallback).
# @PURPOSE: Initialize manager state from persisted or migrated configuration.
# @PRE: config_path is a non-empty string path.
# @POST: self.config is initialized as AppConfig and logger is configured.
# @SIDE_EFFECT: Reads config sources and updates logging configuration.
# @DATA_CONTRACT: Input(str config_path) -> Output(None; self.config: AppConfig)
def __init__(self, config_path: str = "config.json"):
with belief_scope("__init__"):
assert isinstance(config_path, str) and config_path, "config_path must be a non-empty string"
with belief_scope("ConfigManager.__init__"):
if not isinstance(config_path, str) or not config_path:
logger.explore("Invalid config_path provided", extra={"path": config_path})
raise ValueError("config_path must be a non-empty string")
logger.info(f"[ConfigManager][Entry] Initializing with legacy path {config_path}")
logger.reason(f"Initializing ConfigManager with legacy path: {config_path}")
self.config_path = Path(config_path)
self.config: AppConfig = self._load_config()
configure_logger(self.config.settings.logging)
assert isinstance(self.config, AppConfig), "self.config must be an instance of AppConfig"
if not isinstance(self.config, AppConfig):
logger.explore("Config loading resulted in invalid type", extra={"type": type(self.config)})
raise TypeError("self.config must be an instance of AppConfig")
logger.info("[ConfigManager][Exit] Initialized")
logger.reflect("ConfigManager initialization complete")
# [/DEF:__init__:Function]
# [DEF:_default_config:Function]
# @PURPOSE: Returns default application configuration.
# @RETURN: AppConfig - Default configuration.
# @PURPOSE: Build default application configuration fallback.
# @PRE: None.
# @POST: Returns valid AppConfig with empty environments and default storage settings.
# @SIDE_EFFECT: None.
# @DATA_CONTRACT: Input(None) -> Output(AppConfig)
def _default_config(self) -> AppConfig:
return AppConfig(
environments=[],
settings=GlobalSettings(storage=StorageConfig()),
)
with belief_scope("_default_config"):
return AppConfig(
environments=[],
settings=GlobalSettings(storage=StorageConfig()),
)
# [/DEF:_default_config:Function]
# [DEF:_load_from_legacy_file:Function]
# @PURPOSE: Loads legacy configuration from config.json for migration fallback.
# @RETURN: AppConfig - Loaded or default configuration.
# @PURPOSE: Load legacy JSON configuration for migration fallback path.
# @PRE: self.config_path is initialized.
# @POST: Returns AppConfig from file payload or safe default.
# @SIDE_EFFECT: Filesystem read and error logging.
# @DATA_CONTRACT: Input(Path self.config_path) -> Output(AppConfig)
def _load_from_legacy_file(self) -> AppConfig:
with belief_scope("_load_from_legacy_file"):
if not self.config_path.exists():
@@ -81,47 +92,55 @@ class ConfigManager:
# [/DEF:_load_from_legacy_file:Function]
# [DEF:_get_record:Function]
# @PURPOSE: Loads config record from DB.
# @PARAM: session (Session) - DB session.
# @RETURN: Optional[AppConfigRecord] - Existing record or None.
# @PURPOSE: Resolve global configuration record from DB.
# @PRE: session is an active SQLAlchemy Session.
# @POST: Returns record when present, otherwise None.
# @SIDE_EFFECT: Database read query.
# @DATA_CONTRACT: Input(Session) -> Output(Optional[AppConfigRecord])
def _get_record(self, session: Session) -> Optional[AppConfigRecord]:
return session.query(AppConfigRecord).filter(AppConfigRecord.id == "global").first()
with belief_scope("_get_record"):
return session.query(AppConfigRecord).filter(AppConfigRecord.id == "global").first()
# [/DEF:_get_record:Function]
# [DEF:_load_config:Function]
# @PURPOSE: Loads the configuration from DB or performs one-time migration from JSON file.
# @PRE: DB session factory is available.
# @POST: isinstance(return, AppConfig)
# @RETURN: AppConfig - Loaded configuration.
# @PURPOSE: Load configuration from DB or perform one-time migration from legacy JSON.
# @PRE: SessionLocal factory is available and AppConfigRecord schema is accessible.
# @POST: Returns valid AppConfig and closes opened DB session.
# @SIDE_EFFECT: Database read/write, possible migration write, logging.
# @DATA_CONTRACT: Input(None) -> Output(AppConfig)
def _load_config(self) -> AppConfig:
with belief_scope("_load_config"):
with belief_scope("ConfigManager._load_config"):
session: Session = SessionLocal()
try:
record = self._get_record(session)
if record and record.payload:
logger.info("[_load_config][Coherence:OK] Configuration loaded from database")
return AppConfig(**record.payload)
logger.reason("Configuration found in database")
config = AppConfig(**record.payload)
logger.reflect("Database configuration validated")
return config
logger.info("[_load_config][Action] No database config found, migrating legacy config")
logger.reason("No database config found, initiating legacy migration")
config = self._load_from_legacy_file()
self._save_config_to_db(config, session=session)
logger.reflect("Legacy configuration migrated to database")
return config
except Exception as e:
logger.error(f"[_load_config][Coherence:Failed] Error loading config from DB: {e}")
logger.explore(f"Error loading config from DB: {e}")
return self._default_config()
finally:
session.close()
# [/DEF:_load_config:Function]
# [DEF:_save_config_to_db:Function]
# @PURPOSE: Saves the provided configuration object to DB.
# @PRE: isinstance(config, AppConfig)
# @POST: Configuration saved to database.
# @PARAM: config (AppConfig) - The configuration to save.
# @PARAM: session (Optional[Session]) - Existing DB session for transactional reuse.
# @PURPOSE: Persist provided AppConfig into the global DB configuration record.
# @PRE: config is AppConfig; session is either None or an active Session.
# @POST: Global DB record payload equals config.model_dump() when commit succeeds.
# @SIDE_EFFECT: Database insert/update, commit/rollback, logging.
# @DATA_CONTRACT: Input(AppConfig, Optional[Session]) -> Output(None)
def _save_config_to_db(self, config: AppConfig, session: Optional[Session] = None):
with belief_scope("_save_config_to_db"):
assert isinstance(config, AppConfig), "config must be an instance of AppConfig"
with belief_scope("ConfigManager._save_config_to_db"):
if not isinstance(config, AppConfig):
raise TypeError("config must be an instance of AppConfig")
owns_session = session is None
db = session or SessionLocal()
@@ -129,15 +148,17 @@ class ConfigManager:
record = self._get_record(db)
payload = config.model_dump()
if record is None:
logger.reason("Creating new global configuration record")
record = AppConfigRecord(id="global", payload=payload)
db.add(record)
else:
logger.reason("Updating existing global configuration record")
record.payload = payload
db.commit()
logger.info("[_save_config_to_db][Action] Configuration saved to database")
logger.reflect("Configuration successfully committed to database")
except Exception as e:
db.rollback()
logger.error(f"[_save_config_to_db][Coherence:Failed] Failed to save: {e}")
logger.explore(f"Failed to save configuration: {e}")
raise
finally:
if owns_session:
@@ -145,42 +166,51 @@ class ConfigManager:
# [/DEF:_save_config_to_db:Function]
# [DEF:save:Function]
# @PURPOSE: Saves the current configuration state to DB.
# @PRE: self.config is set.
# @POST: self._save_config_to_db called.
# @PURPOSE: Persist current in-memory configuration state.
# @PRE: self.config is initialized.
# @POST: Current self.config is written to DB global record.
# @SIDE_EFFECT: Database write and logging via delegated persistence call.
# @DATA_CONTRACT: Input(None; self.config: AppConfig) -> Output(None)
def save(self):
with belief_scope("save"):
self._save_config_to_db(self.config)
# [/DEF:save:Function]
# [DEF:get_config:Function]
# @PURPOSE: Returns the current configuration.
# @RETURN: AppConfig - The current configuration.
# @PURPOSE: Return current in-memory configuration snapshot.
# @PRE: self.config is initialized.
# @POST: Returns AppConfig reference stored in manager.
# @SIDE_EFFECT: None.
# @DATA_CONTRACT: Input(None) -> Output(AppConfig)
def get_config(self) -> AppConfig:
with belief_scope("get_config"):
return self.config
# [/DEF:get_config:Function]
# [DEF:update_global_settings:Function]
# @PURPOSE: Updates the global settings and persists the change.
# @PRE: isinstance(settings, GlobalSettings)
# @POST: self.config.settings updated and saved.
# @PARAM: settings (GlobalSettings) - The new global settings.
# @PURPOSE: Replace global settings and persist the resulting configuration.
# @PRE: settings is GlobalSettings.
# @POST: self.config.settings equals provided settings and DB state is updated.
# @SIDE_EFFECT: Mutates self.config, DB write, logger reconfiguration, logging.
# @DATA_CONTRACT: Input(GlobalSettings) -> Output(None)
def update_global_settings(self, settings: GlobalSettings):
with belief_scope("update_global_settings"):
logger.info("[update_global_settings][Entry] Updating settings")
assert isinstance(settings, GlobalSettings), "settings must be an instance of GlobalSettings"
with belief_scope("ConfigManager.update_global_settings"):
if not isinstance(settings, GlobalSettings):
raise TypeError("settings must be an instance of GlobalSettings")
logger.reason("Updating global settings and persisting")
self.config.settings = settings
self.save()
configure_logger(settings.logging)
logger.info("[update_global_settings][Exit] Settings updated")
logger.reflect("Global settings updated and logger reconfigured")
# [/DEF:update_global_settings:Function]
# [DEF:validate_path:Function]
# @PURPOSE: Validates if a path exists and is writable.
# @PARAM: path (str) - The path to validate.
# @RETURN: tuple (bool, str) - (is_valid, message)
# @PURPOSE: Validate that path exists and is writable, creating it when absent.
# @PRE: path is a string path candidate.
# @POST: Returns (True, msg) for writable path, else (False, reason).
# @SIDE_EFFECT: Filesystem directory creation attempt and OS permission checks.
# @DATA_CONTRACT: Input(str path) -> Output(tuple[bool, str])
def validate_path(self, path: str) -> tuple[bool, str]:
with belief_scope("validate_path"):
p = os.path.abspath(path)
@@ -197,25 +227,33 @@ class ConfigManager:
# [/DEF:validate_path:Function]
# [DEF:get_environments:Function]
# @PURPOSE: Returns the list of configured environments.
# @RETURN: List[Environment] - List of environments.
# @PURPOSE: Return all configured environments.
# @PRE: self.config is initialized.
# @POST: Returns list of Environment models from current configuration.
# @SIDE_EFFECT: None.
# @DATA_CONTRACT: Input(None) -> Output(List[Environment])
def get_environments(self) -> List[Environment]:
with belief_scope("get_environments"):
return self.config.environments
# [/DEF:get_environments:Function]
# [DEF:has_environments:Function]
# @PURPOSE: Checks if at least one environment is configured.
# @RETURN: bool - True if at least one environment exists.
# @PURPOSE: Check whether at least one environment exists in configuration.
# @PRE: self.config is initialized.
# @POST: Returns True iff environment list length is greater than zero.
# @SIDE_EFFECT: None.
# @DATA_CONTRACT: Input(None) -> Output(bool)
def has_environments(self) -> bool:
with belief_scope("has_environments"):
return len(self.config.environments) > 0
# [/DEF:has_environments:Function]
# [DEF:get_environment:Function]
# @PURPOSE: Returns a single environment by ID.
# @PARAM: env_id (str) - The ID of the environment to retrieve.
# @RETURN: Optional[Environment] - The environment with the given ID, or None.
# @PURPOSE: Resolve a configured environment by identifier.
# @PRE: env_id is string identifier.
# @POST: Returns matching Environment when found; otherwise None.
# @SIDE_EFFECT: None.
# @DATA_CONTRACT: Input(str env_id) -> Output(Optional[Environment])
def get_environment(self, env_id: str) -> Optional[Environment]:
with belief_scope("get_environment"):
for env in self.config.environments:
@@ -225,60 +263,72 @@ class ConfigManager:
# [/DEF:get_environment:Function]
# [DEF:add_environment:Function]
# @PURPOSE: Adds a new environment to the configuration.
# @PARAM: env (Environment) - The environment to add.
# @PURPOSE: Upsert environment by id into configuration and persist.
# @PRE: env is Environment.
# @POST: Configuration contains provided env id with new payload persisted.
# @SIDE_EFFECT: Mutates environment list, DB write, logging.
# @DATA_CONTRACT: Input(Environment) -> Output(None)
def add_environment(self, env: Environment):
with belief_scope("add_environment"):
logger.info(f"[add_environment][Entry] Adding environment {env.id}")
assert isinstance(env, Environment), "env must be an instance of Environment"
with belief_scope("ConfigManager.add_environment"):
if not isinstance(env, Environment):
raise TypeError("env must be an instance of Environment")
logger.reason(f"Adding/Updating environment: {env.id}")
self.config.environments = [e for e in self.config.environments if e.id != env.id]
self.config.environments.append(env)
self.save()
logger.info("[add_environment][Exit] Environment added")
logger.reflect(f"Environment {env.id} persisted")
# [/DEF:add_environment:Function]
# [DEF:update_environment:Function]
# @PURPOSE: Updates an existing environment.
# @PARAM: env_id (str) - The ID of the environment to update.
# @PARAM: updated_env (Environment) - The updated environment data.
# @RETURN: bool - True if updated, False otherwise.
# @PURPOSE: Update existing environment by id and preserve masked password placeholder behavior.
# @PRE: env_id is non-empty string and updated_env is Environment.
# @POST: Returns True and persists update when target exists; else returns False.
# @SIDE_EFFECT: May mutate environment list, DB write, logging.
# @DATA_CONTRACT: Input(str env_id, Environment updated_env) -> Output(bool)
def update_environment(self, env_id: str, updated_env: Environment) -> bool:
with belief_scope("update_environment"):
logger.info(f"[update_environment][Entry] Updating {env_id}")
assert env_id and isinstance(env_id, str), "env_id must be a non-empty string"
assert isinstance(updated_env, Environment), "updated_env must be an instance of Environment"
with belief_scope("ConfigManager.update_environment"):
if not env_id or not isinstance(env_id, str):
raise ValueError("env_id must be a non-empty string")
if not isinstance(updated_env, Environment):
raise TypeError("updated_env must be an instance of Environment")
logger.reason(f"Attempting to update environment: {env_id}")
for i, env in enumerate(self.config.environments):
if env.id == env_id:
if updated_env.password == "********":
logger.reason("Preserving existing password for masked update")
updated_env.password = env.password
self.config.environments[i] = updated_env
self.save()
logger.info(f"[update_environment][Coherence:OK] Updated {env_id}")
logger.reflect(f"Environment {env_id} updated and saved")
return True
logger.warning(f"[update_environment][Coherence:Failed] Environment {env_id} not found")
logger.explore(f"Environment {env_id} not found for update")
return False
# [/DEF:update_environment:Function]
# [DEF:delete_environment:Function]
# @PURPOSE: Deletes an environment by ID.
# @PARAM: env_id (str) - The ID of the environment to delete.
# @PURPOSE: Delete environment by id and persist when deletion occurs.
# @PRE: env_id is non-empty string.
# @POST: Environment is removed when present; otherwise configuration is unchanged.
# @SIDE_EFFECT: May mutate environment list, conditional DB write, logging.
# @DATA_CONTRACT: Input(str env_id) -> Output(None)
def delete_environment(self, env_id: str):
with belief_scope("delete_environment"):
logger.info(f"[delete_environment][Entry] Deleting {env_id}")
assert env_id and isinstance(env_id, str), "env_id must be a non-empty string"
with belief_scope("ConfigManager.delete_environment"):
if not env_id or not isinstance(env_id, str):
raise ValueError("env_id must be a non-empty string")
logger.reason(f"Attempting to delete environment: {env_id}")
original_count = len(self.config.environments)
self.config.environments = [e for e in self.config.environments if e.id != env_id]
if len(self.config.environments) < original_count:
self.save()
logger.info(f"[delete_environment][Action] Deleted {env_id}")
logger.reflect(f"Environment {env_id} deleted and configuration saved")
else:
logger.warning(f"[delete_environment][Coherence:Failed] Environment {env_id} not found")
logger.explore(f"Environment {env_id} not found for deletion")
# [/DEF:delete_environment:Function]

View File

@@ -141,6 +141,11 @@ def _ensure_user_dashboard_preferences_columns(bind_engine):
"ALTER TABLE user_dashboard_preferences "
"ADD COLUMN dashboards_table_density VARCHAR NOT NULL DEFAULT 'comfortable'"
)
if "show_only_slug_dashboards" not in existing_columns:
alter_statements.append(
"ALTER TABLE user_dashboard_preferences "
"ADD COLUMN show_only_slug_dashboards BOOLEAN NOT NULL DEFAULT TRUE"
)
if not alter_statements:
return
@@ -157,6 +162,88 @@ def _ensure_user_dashboard_preferences_columns(bind_engine):
# [/DEF:_ensure_user_dashboard_preferences_columns:Function]
# [DEF:_ensure_user_dashboard_preferences_health_columns:Function]
# @PURPOSE: Applies additive schema upgrades for user_dashboard_preferences table (health fields).
def _ensure_user_dashboard_preferences_health_columns(bind_engine):
with belief_scope("_ensure_user_dashboard_preferences_health_columns"):
table_name = "user_dashboard_preferences"
inspector = inspect(bind_engine)
if table_name not in inspector.get_table_names():
return
existing_columns = {
str(column.get("name") or "").strip()
for column in inspector.get_columns(table_name)
}
alter_statements = []
if "telegram_id" not in existing_columns:
alter_statements.append(
"ALTER TABLE user_dashboard_preferences ADD COLUMN telegram_id VARCHAR"
)
if "email_address" not in existing_columns:
alter_statements.append(
"ALTER TABLE user_dashboard_preferences ADD COLUMN email_address VARCHAR"
)
if "notify_on_fail" not in existing_columns:
alter_statements.append(
"ALTER TABLE user_dashboard_preferences ADD COLUMN notify_on_fail BOOLEAN NOT NULL DEFAULT TRUE"
)
if not alter_statements:
return
try:
with bind_engine.begin() as connection:
for statement in alter_statements:
connection.execute(text(statement))
except Exception as migration_error:
logger.warning(
"[database][EXPLORE] Profile health preference additive migration failed: %s",
migration_error,
)
# [/DEF:_ensure_user_dashboard_preferences_health_columns:Function]
# [DEF:_ensure_llm_validation_results_columns:Function]
# @PURPOSE: Applies additive schema upgrades for llm_validation_results table.
def _ensure_llm_validation_results_columns(bind_engine):
with belief_scope("_ensure_llm_validation_results_columns"):
table_name = "llm_validation_results"
inspector = inspect(bind_engine)
if table_name not in inspector.get_table_names():
return
existing_columns = {
str(column.get("name") or "").strip()
for column in inspector.get_columns(table_name)
}
alter_statements = []
if "task_id" not in existing_columns:
alter_statements.append(
"ALTER TABLE llm_validation_results ADD COLUMN task_id VARCHAR"
)
if "environment_id" not in existing_columns:
alter_statements.append(
"ALTER TABLE llm_validation_results ADD COLUMN environment_id VARCHAR"
)
if not alter_statements:
return
try:
with bind_engine.begin() as connection:
for statement in alter_statements:
connection.execute(text(statement))
except Exception as migration_error:
logger.warning(
"[database][EXPLORE] ValidationRecord additive migration failed: %s",
migration_error,
)
# [/DEF:_ensure_llm_validation_results_columns:Function]
# [DEF:_ensure_git_server_configs_columns:Function]
# @PURPOSE: Applies additive schema upgrades for git_server_configs table.
# @PRE: bind_engine points to application database.
@@ -205,6 +292,8 @@ def init_db():
Base.metadata.create_all(bind=tasks_engine)
Base.metadata.create_all(bind=auth_engine)
_ensure_user_dashboard_preferences_columns(engine)
_ensure_llm_validation_results_columns(engine)
_ensure_user_dashboard_preferences_health_columns(engine)
_ensure_git_server_configs_columns(engine)
# [/DEF:init_db:Function]

View File

@@ -225,7 +225,7 @@ def test_enable_belief_state_flag(caplog):
assert not any("[DisabledFunction][Exit]" in msg for msg in log_messages), "Exit should not be logged when disabled"
# Coherence:OK should still be logged (internal tracking)
assert any("[DisabledFunction][COHERENCE:OK]" in msg for msg in log_messages), "Coherence should still be logged"
# [/DEF:test_enable_belief_state_flag:Function]
# [DEF:test_belief_scope_missing_anchor:Function]

View File

@@ -1,118 +1,170 @@
# [DEF:backend.src.core.migration.risk_assessor:Module]
# @TIER: STANDARD
# @SEMANTICS: migration, dry_run, risk, scoring
# @PURPOSE: Risk evaluation helpers for migration pre-flight reporting.
# @LAYER: Core
# @RELATION: USED_BY -> backend.src.core.migration.dry_run_orchestrator
# @TIER: CRITICAL
# @SEMANTICS: migration, dry_run, risk, scoring, preflight
# @PURPOSE: Compute deterministic migration risk items and aggregate score for dry-run reporting.
# @LAYER: Domain
# @RELATION: [DEPENDS_ON] ->[backend.src.core.superset_client.SupersetClient]
# @RELATION: [DISPATCHES] ->[backend.src.core.migration.dry_run_orchestrator.MigrationDryRunService.run]
# @INVARIANT: Risk scoring must remain bounded to [0,100] and preserve severity-to-weight mapping.
# @TEST_CONTRACT: [source_objects,target_objects,diff,target_client] -> [List[RiskItem]]
# @TEST_SCENARIO: [overwrite_update_objects] -> [medium overwrite_existing risk is emitted for each update diff item]
# @TEST_SCENARIO: [missing_datasource_dataset] -> [high missing_datasource risk is emitted]
# @TEST_SCENARIO: [owner_mismatch_dashboard] -> [low owner_mismatch risk is emitted]
# @TEST_EDGE: [missing_field] -> [object without uuid is ignored by indexer]
# @TEST_EDGE: [invalid_type] -> [non-list owners input normalizes to empty identifiers]
# @TEST_EDGE: [external_fail] -> [target_client get_databases exception propagates to caller]
# @TEST_INVARIANT: [score_upper_bound_100] -> VERIFIED_BY: [severity_weight_aggregation]
# @UX_STATE: [Idle] -> [N/A backend domain module]
# @UX_FEEDBACK: [N/A] -> [No direct UI side effects in this module]
# @UX_RECOVERY: [N/A] -> [Caller-level retry/recovery]
# @UX_REACTIVITY: [N/A] -> [Backend synchronous function contracts]
from typing import Any, Dict, List
from ..logger import logger, belief_scope
from ..superset_client import SupersetClient
# [DEF:index_by_uuid:Function]
# @PURPOSE: Build UUID-index from normalized objects.
# @PRE: Input list items are dict-like payloads potentially containing "uuid".
# @POST: Returns mapping keyed by string uuid; only truthy uuid values are included.
# @SIDE_EFFECT: Emits reasoning/reflective logs only.
# @DATA_CONTRACT: List[Dict[str, Any]] -> Dict[str, Dict[str, Any]]
def index_by_uuid(objects: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
indexed: Dict[str, Dict[str, Any]] = {}
for obj in objects:
uuid = obj.get("uuid")
if uuid:
indexed[str(uuid)] = obj
return indexed
with belief_scope("risk_assessor.index_by_uuid"):
logger.reason("Building UUID index", extra={"objects_count": len(objects)})
indexed: Dict[str, Dict[str, Any]] = {}
for obj in objects:
uuid = obj.get("uuid")
if uuid:
indexed[str(uuid)] = obj
logger.reflect("UUID index built", extra={"indexed_count": len(indexed)})
return indexed
# [/DEF:index_by_uuid:Function]
# [DEF:extract_owner_identifiers:Function]
# @PURPOSE: Normalize owner payloads for stable comparison.
# @PRE: Owners may be list payload, scalar values, or None.
# @POST: Returns sorted unique owner identifiers as strings.
# @SIDE_EFFECT: Emits reasoning/reflective logs only.
# @DATA_CONTRACT: Any -> List[str]
def extract_owner_identifiers(owners: Any) -> List[str]:
if not isinstance(owners, list):
return []
ids: List[str] = []
for owner in owners:
if isinstance(owner, dict):
if owner.get("username"):
ids.append(str(owner["username"]))
elif owner.get("id") is not None:
ids.append(str(owner["id"]))
elif owner is not None:
ids.append(str(owner))
return sorted(set(ids))
with belief_scope("risk_assessor.extract_owner_identifiers"):
logger.reason("Normalizing owner identifiers")
if not isinstance(owners, list):
logger.reflect("Owners payload is not list; returning empty identifiers")
return []
ids: List[str] = []
for owner in owners:
if isinstance(owner, dict):
if owner.get("username"):
ids.append(str(owner["username"]))
elif owner.get("id") is not None:
ids.append(str(owner["id"]))
elif owner is not None:
ids.append(str(owner))
normalized_ids = sorted(set(ids))
logger.reflect("Owner identifiers normalized", extra={"owner_count": len(normalized_ids)})
return normalized_ids
# [/DEF:extract_owner_identifiers:Function]
# [DEF:build_risks:Function]
# @PURPOSE: Build risk list from computed diffs and target catalog state.
# @PRE: source_objects/target_objects/diff contain dashboards/charts/datasets keys with expected list structures.
# @PRE: target_client is authenticated/usable for database list retrieval.
# @POST: Returns list of deterministic risk items derived from overwrite, missing datasource, reference, and owner mismatch checks.
# @SIDE_EFFECT: Calls target Superset API for databases metadata and emits logs.
# @DATA_CONTRACT: (
# @DATA_CONTRACT: Dict[str, List[Dict[str, Any]]],
# @DATA_CONTRACT: Dict[str, List[Dict[str, Any]]],
# @DATA_CONTRACT: Dict[str, Dict[str, List[Dict[str, Any]]]],
# @DATA_CONTRACT: SupersetClient
# @DATA_CONTRACT: ) -> List[Dict[str, Any]]
def build_risks(
source_objects: Dict[str, List[Dict[str, Any]]],
target_objects: Dict[str, List[Dict[str, Any]]],
diff: Dict[str, Dict[str, List[Dict[str, Any]]]],
target_client: SupersetClient,
) -> List[Dict[str, Any]]:
risks: List[Dict[str, Any]] = []
for object_type in ("dashboards", "charts", "datasets"):
for item in diff[object_type]["update"]:
risks.append({
"code": "overwrite_existing",
"severity": "medium",
"object_type": object_type[:-1],
"object_uuid": item["uuid"],
"message": f"Object will be updated in target: {item.get('title') or item['uuid']}",
})
with belief_scope("risk_assessor.build_risks"):
logger.reason("Building migration risks from diff payload")
risks: List[Dict[str, Any]] = []
for object_type in ("dashboards", "charts", "datasets"):
for item in diff[object_type]["update"]:
risks.append({
"code": "overwrite_existing",
"severity": "medium",
"object_type": object_type[:-1],
"object_uuid": item["uuid"],
"message": f"Object will be updated in target: {item.get('title') or item['uuid']}",
})
target_dataset_uuids = set(index_by_uuid(target_objects["datasets"]).keys())
_, target_databases = target_client.get_databases(query={"columns": ["uuid"]})
target_database_uuids = {str(item.get("uuid")) for item in target_databases if item.get("uuid")}
target_dataset_uuids = set(index_by_uuid(target_objects["datasets"]).keys())
_, target_databases = target_client.get_databases(query={"columns": ["uuid"]})
target_database_uuids = {str(item.get("uuid")) for item in target_databases if item.get("uuid")}
for dataset in source_objects["datasets"]:
db_uuid = dataset.get("database_uuid")
if db_uuid and str(db_uuid) not in target_database_uuids:
risks.append({
"code": "missing_datasource",
"severity": "high",
"object_type": "dataset",
"object_uuid": dataset.get("uuid"),
"message": f"Target datasource is missing for dataset {dataset.get('title') or dataset.get('uuid')}",
})
for dataset in source_objects["datasets"]:
db_uuid = dataset.get("database_uuid")
if db_uuid and str(db_uuid) not in target_database_uuids:
risks.append({
"code": "missing_datasource",
"severity": "high",
"object_type": "dataset",
"object_uuid": dataset.get("uuid"),
"message": f"Target datasource is missing for dataset {dataset.get('title') or dataset.get('uuid')}",
})
for chart in source_objects["charts"]:
ds_uuid = chart.get("dataset_uuid")
if ds_uuid and str(ds_uuid) not in target_dataset_uuids:
risks.append({
"code": "breaking_reference",
"severity": "high",
"object_type": "chart",
"object_uuid": chart.get("uuid"),
"message": f"Chart references dataset not found on target: {ds_uuid}",
})
for chart in source_objects["charts"]:
ds_uuid = chart.get("dataset_uuid")
if ds_uuid and str(ds_uuid) not in target_dataset_uuids:
risks.append({
"code": "breaking_reference",
"severity": "high",
"object_type": "chart",
"object_uuid": chart.get("uuid"),
"message": f"Chart references dataset not found on target: {ds_uuid}",
})
source_dash = index_by_uuid(source_objects["dashboards"])
target_dash = index_by_uuid(target_objects["dashboards"])
for item in diff["dashboards"]["update"]:
source_obj = source_dash.get(item["uuid"])
target_obj = target_dash.get(item["uuid"])
if not source_obj or not target_obj:
continue
source_owners = extract_owner_identifiers(source_obj.get("owners"))
target_owners = extract_owner_identifiers(target_obj.get("owners"))
if source_owners and target_owners and source_owners != target_owners:
risks.append({
"code": "owner_mismatch",
"severity": "low",
"object_type": "dashboard",
"object_uuid": item["uuid"],
"message": f"Owner mismatch for dashboard {item.get('title') or item['uuid']}",
})
return risks
source_dash = index_by_uuid(source_objects["dashboards"])
target_dash = index_by_uuid(target_objects["dashboards"])
for item in diff["dashboards"]["update"]:
source_obj = source_dash.get(item["uuid"])
target_obj = target_dash.get(item["uuid"])
if not source_obj or not target_obj:
continue
source_owners = extract_owner_identifiers(source_obj.get("owners"))
target_owners = extract_owner_identifiers(target_obj.get("owners"))
if source_owners and target_owners and source_owners != target_owners:
risks.append({
"code": "owner_mismatch",
"severity": "low",
"object_type": "dashboard",
"object_uuid": item["uuid"],
"message": f"Owner mismatch for dashboard {item.get('title') or item['uuid']}",
})
logger.reflect("Risk list assembled", extra={"risk_count": len(risks)})
return risks
# [/DEF:build_risks:Function]
# [DEF:score_risks:Function]
# @PURPOSE: Aggregate risk list into score and level.
# @PRE: risk_items contains optional severity fields expected in {high,medium,low} or defaults to low weight.
# @POST: Returns dict with score in [0,100], derived level, and original items.
# @SIDE_EFFECT: Emits reasoning/reflective logs only.
# @DATA_CONTRACT: List[Dict[str, Any]] -> Dict[str, Any]
def score_risks(risk_items: List[Dict[str, Any]]) -> Dict[str, Any]:
weights = {"high": 25, "medium": 10, "low": 5}
score = min(100, sum(weights.get(item.get("severity", "low"), 5) for item in risk_items))
level = "low" if score < 25 else "medium" if score < 60 else "high"
return {"score": score, "level": level, "items": risk_items}
with belief_scope("risk_assessor.score_risks"):
logger.reason("Scoring risk items", extra={"risk_items_count": len(risk_items)})
weights = {"high": 25, "medium": 10, "low": 5}
score = min(100, sum(weights.get(item.get("severity", "low"), 5) for item in risk_items))
level = "low" if score < 25 else "medium" if score < 60 else "high"
result = {"score": score, "level": level, "items": risk_items}
logger.reflect("Risk score computed", extra={"score": score, "level": level})
return result
# [/DEF:score_risks:Function]

View File

@@ -1,11 +1,15 @@
# [DEF:backend.src.core.migration_engine:Module]
#
# @SEMANTICS: migration, engine, zip, yaml, transformation
# @PURPOSE: Handles the interception and transformation of Superset asset ZIP archives.
# @LAYER: Core
# @RELATION: DEPENDS_ON -> PyYAML
# @TIER: CRITICAL
# @SEMANTICS: migration, engine, zip, yaml, transformation, cross-filter, id-mapping
# @PURPOSE: Transforms Superset export ZIP archives while preserving archive integrity and patching mapped identifiers.
# @LAYER: Domain
# @RELATION: [DEPENDS_ON] ->[src.core.logger]
# @RELATION: [DEPENDS_ON] ->[src.core.mapping_service.IdMappingService]
# @RELATION: [DEPENDS_ON] ->[src.models.mapping.ResourceType]
# @RELATION: [DEPENDS_ON] ->[yaml]
#
# @INVARIANT: ZIP structure must be preserved after transformation.
# @INVARIANT: ZIP structure and non-targeted metadata must remain valid after transformation.
# [SECTION: IMPORTS]
import zipfile
@@ -26,10 +30,17 @@ from src.models.mapping import ResourceType
class MigrationEngine:
# [DEF:__init__:Function]
# @PURPOSE: Initializes the migration engine with optional ID mapping service.
# @PURPOSE: Initializes migration orchestration dependencies for ZIP/YAML metadata transformations.
# @PRE: mapping_service is None or implements batch remote ID lookup for ResourceType.CHART.
# @POST: self.mapping_service is assigned and available for optional cross-filter patching flows.
# @SIDE_EFFECT: Mutates in-memory engine state by storing dependency reference.
# @DATA_CONTRACT: Input[Optional[IdMappingService]] -> Output[MigrationEngine]
# @PARAM: mapping_service (Optional[IdMappingService]) - Used for resolving target environment integer IDs.
def __init__(self, mapping_service: Optional[IdMappingService] = None):
self.mapping_service = mapping_service
with belief_scope("MigrationEngine.__init__"):
logger.reason("Initializing MigrationEngine")
self.mapping_service = mapping_service
logger.reflect("MigrationEngine initialized")
# [/DEF:__init__:Function]
# [DEF:transform_zip:Function]
@@ -40,20 +51,24 @@ class MigrationEngine:
# @PARAM: strip_databases (bool) - Whether to remove the databases directory from the archive.
# @PARAM: target_env_id (Optional[str]) - Used if fix_cross_filters is True to know which environment map to use.
# @PARAM: fix_cross_filters (bool) - Whether to patch dashboard json_metadata.
# @PRE: zip_path must point to a valid Superset export archive.
# @POST: Transformed archive is saved to output_path.
# @RETURN: bool - True if successful.
# @PRE: zip_path points to a readable ZIP; output_path parent is writable; db_mapping keys/values are UUID strings.
# @POST: Returns True only when extraction, transformation, and packaging complete without exception.
# @SIDE_EFFECT: Reads/writes filesystem archives, creates temporary directory, emits structured logs.
# @DATA_CONTRACT: Input[(str zip_path, str output_path, Dict[str,str] db_mapping, bool strip_databases, Optional[str] target_env_id, bool fix_cross_filters)] -> Output[bool]
# @RETURN: bool - True if successful.
def transform_zip(self, zip_path: str, output_path: str, db_mapping: Dict[str, str], strip_databases: bool = True, target_env_id: Optional[str] = None, fix_cross_filters: bool = False) -> bool:
"""
Transform a Superset export ZIP by replacing database UUIDs and optionally fixing cross-filters.
"""
with belief_scope("MigrationEngine.transform_zip"):
logger.reason(f"Starting ZIP transformation: {zip_path} -> {output_path}")
with tempfile.TemporaryDirectory() as temp_dir_str:
temp_dir = Path(temp_dir_str)
try:
# 1. Extract
logger.info(f"[MigrationEngine.transform_zip][Action] Extracting ZIP: {zip_path}")
logger.reason(f"Extracting source archive to {temp_dir}")
with zipfile.ZipFile(zip_path, 'r') as zf:
zf.extractall(temp_dir)
@@ -61,33 +76,33 @@ class MigrationEngine:
dataset_files = list(temp_dir.glob("**/datasets/**/*.yaml")) + list(temp_dir.glob("**/datasets/*.yaml"))
dataset_files = list(set(dataset_files))
logger.info(f"[MigrationEngine.transform_zip][State] Found {len(dataset_files)} dataset files.")
logger.reason(f"Transforming {len(dataset_files)} dataset YAML files")
for ds_file in dataset_files:
logger.info(f"[MigrationEngine.transform_zip][Action] Transforming dataset: {ds_file}")
self._transform_yaml(ds_file, db_mapping)
# 2.5 Patch Cross-Filters (Dashboards)
if fix_cross_filters and self.mapping_service and target_env_id:
dash_files = list(temp_dir.glob("**/dashboards/**/*.yaml")) + list(temp_dir.glob("**/dashboards/*.yaml"))
dash_files = list(set(dash_files))
logger.info(f"[MigrationEngine.transform_zip][State] Found {len(dash_files)} dashboard files for patching.")
# Gather all source UUID-to-ID mappings from the archive first
source_id_to_uuid_map = self._extract_chart_uuids_from_archive(temp_dir)
for dash_file in dash_files:
logger.info(f"[MigrationEngine.transform_zip][Action] Patching dashboard: {dash_file}")
self._patch_dashboard_metadata(dash_file, target_env_id, source_id_to_uuid_map)
if fix_cross_filters:
if self.mapping_service and target_env_id:
dash_files = list(temp_dir.glob("**/dashboards/**/*.yaml")) + list(temp_dir.glob("**/dashboards/*.yaml"))
dash_files = list(set(dash_files))
logger.reason(f"Patching cross-filters for {len(dash_files)} dashboards")
# Gather all source UUID-to-ID mappings from the archive first
source_id_to_uuid_map = self._extract_chart_uuids_from_archive(temp_dir)
for dash_file in dash_files:
self._patch_dashboard_metadata(dash_file, target_env_id, source_id_to_uuid_map)
else:
logger.explore("Cross-filter patching requested but mapping service or target_env_id is missing")
# 3. Re-package
logger.info(f"[MigrationEngine.transform_zip][Action] Re-packaging ZIP to: {output_path} (strip_databases={strip_databases})")
logger.reason(f"Re-packaging transformed archive (strip_databases={strip_databases})")
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
for root, dirs, files in os.walk(temp_dir):
rel_root = Path(root).relative_to(temp_dir)
if strip_databases and "databases" in rel_root.parts:
logger.info(f"[MigrationEngine.transform_zip][Action] Skipping file in databases directory: {rel_root}")
continue
for file in files:
@@ -95,9 +110,10 @@ class MigrationEngine:
arcname = file_path.relative_to(temp_dir)
zf.write(file_path, arcname)
logger.reflect("ZIP transformation completed successfully")
return True
except Exception as e:
logger.error(f"[MigrationEngine.transform_zip][Coherence:Failed] Error transforming ZIP: {e}")
logger.explore(f"Error transforming ZIP: {e}")
return False
# [/DEF:transform_zip:Function]
@@ -105,54 +121,73 @@ class MigrationEngine:
# @PURPOSE: Replaces database_uuid in a single YAML file.
# @PARAM: file_path (Path) - Path to the YAML file.
# @PARAM: db_mapping (Dict[str, str]) - UUID mapping dictionary.
# @PRE: file_path must exist and be readable.
# @POST: File is modified in-place if source UUID matches mapping.
# @PRE: file_path exists, is readable YAML, and db_mapping contains source->target UUID pairs.
# @POST: database_uuid is replaced in-place only when source UUID is present in db_mapping.
# @SIDE_EFFECT: Reads and conditionally rewrites YAML file on disk.
# @DATA_CONTRACT: Input[(Path file_path, Dict[str,str] db_mapping)] -> Output[None]
def _transform_yaml(self, file_path: Path, db_mapping: Dict[str, str]):
with open(file_path, 'r') as f:
data = yaml.safe_load(f)
with belief_scope("MigrationEngine._transform_yaml"):
if not file_path.exists():
logger.explore(f"YAML file not found: {file_path}")
return
if not data:
return
with open(file_path, 'r') as f:
data = yaml.safe_load(f)
# Superset dataset YAML structure:
# database_uuid: ...
source_uuid = data.get('database_uuid')
if source_uuid in db_mapping:
data['database_uuid'] = db_mapping[source_uuid]
with open(file_path, 'w') as f:
yaml.dump(data, f)
if not data:
return
source_uuid = data.get('database_uuid')
if source_uuid in db_mapping:
logger.reason(f"Replacing database UUID in {file_path.name}")
data['database_uuid'] = db_mapping[source_uuid]
with open(file_path, 'w') as f:
yaml.dump(data, f)
logger.reflect(f"Database UUID patched in {file_path.name}")
# [/DEF:_transform_yaml:Function]
# [DEF:_extract_chart_uuids_from_archive:Function]
# @PURPOSE: Scans the unpacked ZIP to map local exported integer IDs back to their UUIDs.
# @PARAM: temp_dir (Path) - Root dir of unpacked archive
# @PURPOSE: Scans extracted chart YAML files and builds a source chart ID to UUID lookup map.
# @PRE: temp_dir exists and points to extracted archive root with optional chart YAML resources.
# @POST: Returns a best-effort Dict[int, str] containing only parseable chart id/uuid pairs.
# @SIDE_EFFECT: Reads chart YAML files from filesystem; suppresses per-file parsing failures.
# @DATA_CONTRACT: Input[Path] -> Output[Dict[int,str]]
# @PARAM: temp_dir (Path) - Root dir of unpacked archive.
# @RETURN: Dict[int, str] - Mapping of source Integer ID to UUID.
def _extract_chart_uuids_from_archive(self, temp_dir: Path) -> Dict[int, str]:
# Implementation Note: This is a placeholder for the logic that extracts
# actual Source IDs. In a real scenario, this involves parsing chart YAMLs
# or manifesting the export metadata structure where source IDs are stored.
# For simplicity in US1 MVP, we assume it's read from chart files if present.
mapping = {}
chart_files = list(temp_dir.glob("**/charts/**/*.yaml")) + list(temp_dir.glob("**/charts/*.yaml"))
for cf in set(chart_files):
try:
with open(cf, 'r') as f:
cdata = yaml.safe_load(f)
if cdata and 'id' in cdata and 'uuid' in cdata:
mapping[cdata['id']] = cdata['uuid']
except Exception:
pass
return mapping
with belief_scope("MigrationEngine._extract_chart_uuids_from_archive"):
# Implementation Note: This is a placeholder for the logic that extracts
# actual Source IDs. In a real scenario, this involves parsing chart YAMLs
# or manifesting the export metadata structure where source IDs are stored.
# For simplicity in US1 MVP, we assume it's read from chart files if present.
mapping = {}
chart_files = list(temp_dir.glob("**/charts/**/*.yaml")) + list(temp_dir.glob("**/charts/*.yaml"))
for cf in set(chart_files):
try:
with open(cf, 'r') as f:
cdata = yaml.safe_load(f)
if cdata and 'id' in cdata and 'uuid' in cdata:
mapping[cdata['id']] = cdata['uuid']
except Exception:
pass
return mapping
# [/DEF:_extract_chart_uuids_from_archive:Function]
# [DEF:_patch_dashboard_metadata:Function]
# @PURPOSE: Replaces integer IDs in json_metadata.
# @PURPOSE: Rewrites dashboard json_metadata chart/dataset integer identifiers using target environment mappings.
# @PRE: file_path points to dashboard YAML with json_metadata; target_env_id is non-empty; source_map contains source id->uuid.
# @POST: json_metadata is re-serialized with mapped integer IDs when remote mappings are available; otherwise file remains unchanged.
# @SIDE_EFFECT: Reads/writes YAML file, performs mapping lookup via mapping_service, emits logs for recoverable/terminal failures.
# @DATA_CONTRACT: Input[(Path file_path, str target_env_id, Dict[int,str] source_map)] -> Output[None]
# @PARAM: file_path (Path)
# @PARAM: target_env_id (str)
# @PARAM: source_map (Dict[int, str])
def _patch_dashboard_metadata(self, file_path: Path, target_env_id: str, source_map: Dict[int, str]):
with belief_scope("MigrationEngine._patch_dashboard_metadata"):
try:
if not file_path.exists():
return
with open(file_path, 'r') as f:
data = yaml.safe_load(f)
@@ -163,18 +198,13 @@ class MigrationEngine:
if not metadata_str:
return
metadata = json.loads(metadata_str)
modified = False
# We need to deeply traverse and replace. For MVP, string replacement over the raw JSON is an option,
# but careful dict traversal is safer.
# Fetch target UUIDs for everything we know:
uuids_needed = list(source_map.values())
logger.reason(f"Resolving {len(uuids_needed)} remote IDs for dashboard metadata patching")
target_ids = self.mapping_service.get_remote_ids_batch(target_env_id, ResourceType.CHART, uuids_needed)
if not target_ids:
logger.info("[MigrationEngine._patch_dashboard_metadata][Reflect] No remote target IDs found in mapping database.")
logger.reflect("No remote target IDs found in mapping database for this dashboard.")
return
# Map Source Int -> Target Int
@@ -187,21 +217,16 @@ class MigrationEngine:
missing_targets.append(s_id)
if missing_targets:
logger.warning(f"[MigrationEngine._patch_dashboard_metadata][Coherence:Recoverable] Missing target IDs for source IDs: {missing_targets}. Cross-filters for these IDs might break.")
logger.explore(f"Missing target IDs for source IDs: {missing_targets}. Cross-filters might break.")
if not source_to_target:
logger.info("[MigrationEngine._patch_dashboard_metadata][Reflect] No source IDs matched remotely. Skipping patch.")
logger.reflect("No source IDs matched remotely. Skipping patch.")
return
# Complex metadata traversal would go here (e.g. for native_filter_configuration)
# We use regex replacement over the string for safety over unknown nested dicts.
logger.reason(f"Patching {len(source_to_target)} ID references in json_metadata")
new_metadata_str = metadata_str
# Replace chartId and datasetId assignments explicitly.
# Pattern: "datasetId": 42 or "chartId": 42
for s_id, t_id in source_to_target.items():
# Replace in native_filter_configuration targets
new_metadata_str = re.sub(r'("datasetId"\s*:\s*)' + str(s_id) + r'(\b)', r'\g<1>' + str(t_id) + r'\g<2>', new_metadata_str)
new_metadata_str = re.sub(r'("chartId"\s*:\s*)' + str(s_id) + r'(\b)', r'\g<1>' + str(t_id) + r'\g<2>', new_metadata_str)
@@ -210,10 +235,10 @@ class MigrationEngine:
with open(file_path, 'w') as f:
yaml.dump(data, f)
logger.info(f"[MigrationEngine._patch_dashboard_metadata][Reason] Re-serialized modified JSON metadata for dashboard.")
logger.reflect(f"Dashboard metadata patched and saved: {file_path.name}")
except Exception as e:
logger.error(f"[MigrationEngine._patch_dashboard_metadata][Coherence:Failed] Metadata patch failed: {e}")
logger.explore(f"Metadata patch failed for {file_path.name}: {e}")
# [/DEF:_patch_dashboard_metadata:Function]

View File

@@ -76,17 +76,8 @@ class PluginLoader:
"""
Loads a single Python module and extracts PluginBase subclasses.
"""
# Try to determine the correct package prefix based on how the app is running
# For standalone execution, we need to handle the import differently
if __name__ == "__main__" or "test" in __name__:
# When running as standalone or in tests, use relative import
package_name = f"plugins.{module_name}"
elif "backend.src" in __name__:
package_prefix = "backend.src.plugins"
package_name = f"{package_prefix}.{module_name}"
else:
package_prefix = "src.plugins"
package_name = f"{package_prefix}.{module_name}"
# All runtime code is imported through the canonical `src` package root.
package_name = f"src.plugins.{module_name}"
# print(f"DEBUG: Loading plugin {module_name} as {package_name}")
spec = importlib.util.spec_from_file_location(package_name, file_path)
@@ -198,4 +189,4 @@ class PluginLoader:
return plugin_id in self._plugins
# [/DEF:has_plugin:Function]
# [/DEF:PluginLoader:Class]
# [/DEF:PluginLoader:Class]

View File

@@ -8,9 +8,13 @@
# [SECTION: IMPORTS]
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger
from .logger import logger, belief_scope
from .config_manager import ConfigManager
from .database import SessionLocal
from ..models.llm import ValidationPolicy
import asyncio
from datetime import datetime, time, timedelta, date
# [/SECTION]
# [DEF:SchedulerService:Class]
@@ -117,4 +121,63 @@ class SchedulerService:
# [/DEF:_trigger_backup:Function]
# [/DEF:SchedulerService:Class]
# [DEF:ThrottledSchedulerConfigurator:Class]
# @TIER: CRITICAL
# @SEMANTICS: scheduler, throttling, distribution
# @PURPOSE: Distributes validation tasks evenly within an execution window.
class ThrottledSchedulerConfigurator:
# [DEF:calculate_schedule:Function]
# @PURPOSE: Calculates execution times for N tasks within a window.
# @PRE: window_start, window_end (time), dashboard_ids (List), current_date (date).
# @POST: Returns List[datetime] of scheduled times.
# @INVARIANT: Tasks are distributed with near-even spacing.
@staticmethod
def calculate_schedule(
window_start: time,
window_end: time,
dashboard_ids: list,
current_date: date
) -> list:
with belief_scope("ThrottledSchedulerConfigurator.calculate_schedule"):
n = len(dashboard_ids)
if n == 0:
return []
start_dt = datetime.combine(current_date, window_start)
end_dt = datetime.combine(current_date, window_end)
# Handle window crossing midnight
if end_dt < start_dt:
end_dt += timedelta(days=1)
total_seconds = (end_dt - start_dt).total_seconds()
# Minimum interval of 1 second to avoid division by zero or negative
if total_seconds <= 0:
logger.warning(f"[calculate_schedule] Window size is zero or negative. Falling back to start time for all {n} tasks.")
return [start_dt] * n
# If window is too small for even distribution (e.g. 10 tasks in 5 seconds),
# we still distribute them but they might be very close.
# The requirement says "near-even spacing".
if n == 1:
return [start_dt]
interval = total_seconds / (n - 1) if n > 1 else 0
# If interval is too small (e.g. < 1s), we might want a fallback,
# but the spec says "handle too-small windows with explicit fallback/warning".
if interval < 1:
logger.warning(f"[calculate_schedule] Window too small for {n} tasks (interval {interval:.2f}s). Tasks will be highly concentrated.")
scheduled_times = []
for i in range(n):
scheduled_times.append(start_dt + timedelta(seconds=i * interval))
return scheduled_times
# [/DEF:calculate_schedule:Function]
# [/DEF:ThrottledSchedulerConfigurator:Class]
# [/DEF:SchedulerModule:Module]

View File

@@ -150,11 +150,19 @@ class SupersetClient:
# @PRE: Client is authenticated.
# @POST: Returns a list of dashboard metadata summaries.
# @RETURN: List[Dict]
def get_dashboards_summary(self) -> List[Dict]:
def get_dashboards_summary(self, require_slug: bool = False) -> List[Dict]:
with belief_scope("SupersetClient.get_dashboards_summary"):
# Rely on list endpoint default projection to stay compatible
# across Superset versions and preserve owners in one request.
query: Dict[str, Any] = {}
if require_slug:
query["filters"] = [
{
"col": "slug",
"opr": "neq",
"value": "",
}
]
_, dashboards = self.get_dashboards(query=query)
# Map fields to DashboardMetadata schema
@@ -232,23 +240,35 @@ class SupersetClient:
page: int,
page_size: int,
search: Optional[str] = None,
require_slug: bool = False,
) -> Tuple[int, List[Dict]]:
with belief_scope("SupersetClient.get_dashboards_summary_page"):
query: Dict[str, Any] = {
"page": max(page - 1, 0),
"page_size": page_size,
}
filters: List[Dict[str, Any]] = []
if require_slug:
filters.append(
{
"col": "slug",
"opr": "neq",
"value": "",
}
)
normalized_search = (search or "").strip()
if normalized_search:
# Superset list API supports filter objects with `opr` operator.
# `ct` -> contains (ILIKE on most Superset backends).
query["filters"] = [
filters.append(
{
"col": "dashboard_title",
"opr": "ct",
"value": normalized_search,
}
]
)
if filters:
query["filters"] = filters
total_count, dashboards = self.get_dashboards_page(query=query)

View File

@@ -0,0 +1,3 @@
# [DEF:src.core.utils:Package]
# @PURPOSE: Shared utility package root.
# [/DEF:src.core.utils:Package]

View File

@@ -0,0 +1,3 @@
# [DEF:src.models:Package]
# @PURPOSE: Domain model package root.
# [/DEF:src.models:Package]

View File

@@ -1,19 +1,25 @@
# [DEF:backend.src.models.config:Module]
#
# @TIER: STANDARD
# @SEMANTICS: database, config, settings, sqlalchemy
# @PURPOSE: Defines database schema for persisted application configuration.
# @LAYER: Domain
# @RELATION: DEPENDS_ON -> sqlalchemy
# @TIER: CRITICAL
# @SEMANTICS: database, config, settings, sqlalchemy, notification
# @PURPOSE: Defines SQLAlchemy persistence models for application and notification configuration records.
# @LAYER: Domain
# @RELATION: [DEPENDS_ON] ->[sqlalchemy]
# @RELATION: [DEPENDS_ON] ->[backend.src.models.mapping:Base]
# @INVARIANT: Configuration payload and notification credentials must remain persisted as non-null JSON documents.
from sqlalchemy import Column, String, DateTime, JSON
from sqlalchemy import Column, String, DateTime, JSON, Boolean
from sqlalchemy.sql import func
from .mapping import Base
# [DEF:AppConfigRecord:Class]
# @PURPOSE: Stores the single source of truth for application configuration.
# @PURPOSE: Stores persisted application configuration as a single authoritative record model.
# @PRE: SQLAlchemy declarative Base is initialized and table metadata registration is active.
# @POST: ORM table 'app_configurations' exposes id, payload, and updated_at fields with declared nullability/default semantics.
# @SIDE_EFFECT: Registers ORM mapping metadata during module import.
# @DATA_CONTRACT: Input -> persistence row {id:str, payload:json, updated_at:datetime}; Output -> AppConfigRecord ORM entity.
class AppConfigRecord(Base):
__tablename__ = "app_configurations"
@@ -23,4 +29,25 @@ class AppConfigRecord(Base):
# [/DEF:AppConfigRecord:Class]
# [DEF:NotificationConfig:Class]
# @PURPOSE: Stores persisted provider-level notification configuration and encrypted credentials metadata.
# @PRE: SQLAlchemy declarative Base is initialized and uuid generation is available at instance creation time.
# @POST: ORM table 'notification_configs' exposes id, type, name, credentials, is_active, created_at, updated_at fields with declared constraints/defaults.
# @SIDE_EFFECT: Registers ORM mapping metadata during module import; may generate UUID values for new entity instances.
# @DATA_CONTRACT: Input -> persistence row {id:str, type:str, name:str, credentials:json, is_active:bool, created_at:datetime, updated_at:datetime}; Output -> NotificationConfig ORM entity.
class NotificationConfig(Base):
__tablename__ = "notification_configs"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
type = Column(String, nullable=False) # SMTP, SLACK, TELEGRAM
name = Column(String, nullable=False)
credentials = Column(JSON, nullable=False) # Encrypted connection details
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
# [/DEF:NotificationConfig:Class]
import uuid
# [/DEF:backend.src.models.config:Module]

View File

@@ -5,7 +5,7 @@
# @LAYER: Domain
# @RELATION: INHERITS_FROM -> backend.src.models.mapping.Base
from sqlalchemy import Column, String, Boolean, DateTime, JSON, Text
from sqlalchemy import Column, String, Boolean, DateTime, JSON, Text, Time, ForeignKey
from datetime import datetime
import uuid
from .mapping import Base
@@ -13,6 +13,26 @@ from .mapping import Base
def generate_uuid():
return str(uuid.uuid4())
# [DEF:ValidationPolicy:Class]
# @PURPOSE: Defines a scheduled rule for validating a group of dashboards within an execution window.
class ValidationPolicy(Base):
__tablename__ = "validation_policies"
id = Column(String, primary_key=True, default=generate_uuid)
name = Column(String, nullable=False)
environment_id = Column(String, nullable=False)
is_active = Column(Boolean, default=True)
dashboard_ids = Column(JSON, nullable=False) # Array of dashboard IDs
schedule_days = Column(JSON, nullable=False) # Array of integers (0-6)
window_start = Column(Time, nullable=False)
window_end = Column(Time, nullable=False)
notify_owners = Column(Boolean, default=True)
custom_channels = Column(JSON, nullable=True) # List of external channels
alert_condition = Column(String, default="FAIL_ONLY") # FAIL_ONLY, WARN_AND_FAIL, ALWAYS
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# [/DEF:ValidationPolicy:Class]
# [DEF:LLMProvider:Class]
# @PURPOSE: SQLAlchemy model for LLM provider configuration.
class LLMProvider(Base):
@@ -34,9 +54,11 @@ class ValidationRecord(Base):
__tablename__ = "llm_validation_results"
id = Column(String, primary_key=True, default=generate_uuid)
task_id = Column(String, nullable=True, index=True) # Reference to TaskRecord
dashboard_id = Column(String, nullable=False, index=True)
environment_id = Column(String, nullable=True, index=True)
timestamp = Column(DateTime, default=datetime.utcnow)
status = Column(String, nullable=False) # PASS, WARN, FAIL
status = Column(String, nullable=False) # PASS, WARN, FAIL, UNKNOWN
screenshot_path = Column(String, nullable=True)
issues = Column(JSON, nullable=False)
summary = Column(Text, nullable=False)

View File

@@ -80,6 +80,8 @@ class MigrationJob(Base):
status = Column(SQLEnum(MigrationStatus), default=MigrationStatus.PENDING)
replace_db = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# [/DEF:MigrationJob:Class]
# [DEF:ResourceMapping:Class]
# @TIER: STANDARD
# @PURPOSE: Maps a universal UUID for a resource to its actual ID on a specific environment.

View File

@@ -32,6 +32,7 @@ class UserDashboardPreference(Base):
superset_username_normalized = Column(String, nullable=True, index=True)
show_only_my_dashboards = Column(Boolean, nullable=False, default=False)
show_only_slug_dashboards = Column(Boolean, nullable=False, default=True)
git_username = Column(String, nullable=True)
git_email = Column(String, nullable=True)
@@ -41,6 +42,10 @@ class UserDashboardPreference(Base):
auto_open_task_drawer = Column(Boolean, nullable=False, default=True)
dashboards_table_density = Column(String, nullable=False, default="comfortable")
telegram_id = Column(String, nullable=True)
email_address = Column(String, nullable=True)
notify_on_fail = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
updated_at = Column(
DateTime,
@@ -52,4 +57,4 @@ class UserDashboardPreference(Base):
user = relationship("User")
# [/DEF:UserDashboardPreference:Class]
# [/DEF:backend.src.models.profile:Module]
# [/DEF:backend.src.models.profile:Module]

View File

@@ -112,6 +112,7 @@ class TaskReport(BaseModel):
updated_at: datetime
summary: str
details: Optional[Dict[str, Any]] = None
validation_record: Optional[Dict[str, Any]] = None # Extended for US2
error_context: Optional[ErrorContext] = None
source_ref: Optional[Dict[str, Any]] = None

View File

@@ -0,0 +1,3 @@
# [DEF:src.plugins:Package]
# @PURPOSE: Plugin package root for dynamic discovery and runtime imports.
# [/DEF:src.plugins:Package]

View File

@@ -0,0 +1,3 @@
# [DEF:src.plugins.git:Package]
# @PURPOSE: Git plugin extension package root.
# [/DEF:src.plugins.git:Package]

View File

@@ -21,8 +21,9 @@ from ...services.llm_provider import LLMProviderService
from ...core.superset_client import SupersetClient
from .service import ScreenshotService, LLMClient
from .models import LLMProviderType, ValidationStatus, ValidationResult, DetectedIssue
from ...models.llm import ValidationRecord
from ...models.llm import ValidationRecord, ValidationPolicy
from ...core.task_manager.context import TaskContext
from ...services.notifications.service import NotificationService
from ...services.llm_prompt_templates import (
DEFAULT_LLM_PROMPTS,
is_multimodal_model,
@@ -283,7 +284,9 @@ class DashboardValidationPlugin(PluginBase):
}
db_record = ValidationRecord(
task_id=context.task_id if context else None,
dashboard_id=validation_result.dashboard_id,
environment_id=env_id,
status=validation_result.status.value,
summary=validation_result.summary,
issues=[issue.dict() for issue in validation_result.issues],
@@ -294,11 +297,20 @@ class DashboardValidationPlugin(PluginBase):
db.commit()
# 7. Notification on failure (US1 / FR-015)
if validation_result.status == ValidationStatus.FAIL:
log.warning(f"Dashboard {dashboard_id} validation FAILED. Summary: {validation_result.summary}")
# Placeholder for Email/Pulse notification dispatch
# In a real implementation, we would call a NotificationService here
# with a payload containing the summary and a link to the report.
try:
policy_id = params.get("policy_id")
policy = None
if policy_id:
policy = db.query(ValidationPolicy).filter(ValidationPolicy.id == policy_id).first()
notification_service = NotificationService(db, config_mgr)
await notification_service.dispatch_report(
record=db_record,
policy=policy,
background_tasks=context.background_tasks if context else None
)
except Exception as e:
log.error(f"Failed to dispatch notifications: {e}")
# Final log to ensure all analysis is visible in task logs
log.info(f"Validation completed for dashboard {dashboard_id}. Status: {validation_result.status.value}")

View File

@@ -0,0 +1,3 @@
# [DEF:src.schemas:Package]
# @PURPOSE: API schema package root.
# [/DEF:src.schemas:Package]

View File

@@ -0,0 +1,84 @@
# [DEF:backend.src.schemas.__tests__.test_settings_and_health_schemas:Module]
# @TIER: STANDARD
# @PURPOSE: Regression tests for settings and health schema contracts updated in 026 fix batch.
import pytest
from pydantic import ValidationError
from src.schemas.health import DashboardHealthItem
from src.schemas.settings import ValidationPolicyCreate
# [DEF:test_validation_policy_create_accepts_structured_custom_channels:Function]
# @PURPOSE: Ensure policy schema accepts structured custom channel objects with type/target fields.
def test_validation_policy_create_accepts_structured_custom_channels():
payload = {
"name": "Daily Health",
"environment_id": "env-1",
"dashboard_ids": ["10", "11"],
"schedule_days": [0, 1, 2],
"window_start": "01:00:00",
"window_end": "03:00:00",
"notify_owners": True,
"custom_channels": [{"type": "SLACK", "target": "#alerts"}],
"alert_condition": "FAIL_ONLY",
}
policy = ValidationPolicyCreate(**payload)
assert policy.custom_channels is not None
assert len(policy.custom_channels) == 1
assert policy.custom_channels[0].type == "SLACK"
assert policy.custom_channels[0].target == "#alerts"
# [/DEF:test_validation_policy_create_accepts_structured_custom_channels:Function]
# [DEF:test_validation_policy_create_rejects_legacy_string_custom_channels:Function]
# @PURPOSE: Ensure legacy list[str] custom channel payload is rejected by typed channel contract.
def test_validation_policy_create_rejects_legacy_string_custom_channels():
payload = {
"name": "Daily Health",
"environment_id": "env-1",
"dashboard_ids": ["10"],
"schedule_days": [0],
"window_start": "01:00:00",
"window_end": "02:00:00",
"notify_owners": False,
"custom_channels": ["SLACK:#alerts"],
}
with pytest.raises(ValidationError):
ValidationPolicyCreate(**payload)
# [/DEF:test_validation_policy_create_rejects_legacy_string_custom_channels:Function]
# [DEF:test_dashboard_health_item_status_accepts_only_whitelisted_values:Function]
# @PURPOSE: Verify strict grouped regex only accepts PASS/WARN/FAIL/UNKNOWN exact statuses.
def test_dashboard_health_item_status_accepts_only_whitelisted_values():
valid = DashboardHealthItem(
dashboard_id="dash-1",
environment_id="env-1",
status="PASS",
last_check="2026-03-10T10:00:00",
)
assert valid.status == "PASS"
with pytest.raises(ValidationError):
DashboardHealthItem(
dashboard_id="dash-1",
environment_id="env-1",
status="PASSING",
last_check="2026-03-10T10:00:00",
)
with pytest.raises(ValidationError):
DashboardHealthItem(
dashboard_id="dash-1",
environment_id="env-1",
status="FAIL ",
last_check="2026-03-10T10:00:00",
)
# [/DEF:test_dashboard_health_item_status_accepts_only_whitelisted_values:Function]
# [/DEF:backend.src.schemas.__tests__.test_settings_and_health_schemas:Module]

View File

@@ -0,0 +1,33 @@
# [DEF:backend.src.schemas.health:Module]
# @TIER: STANDARD
# @SEMANTICS: health, schemas, pydantic
# @PURPOSE: Pydantic schemas for dashboard health summary.
# @LAYER: Domain
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
# [DEF:DashboardHealthItem:Class]
# @PURPOSE: Represents the latest health status of a single dashboard.
class DashboardHealthItem(BaseModel):
dashboard_id: str
dashboard_title: Optional[str] = None
environment_id: str
status: str = Field(..., pattern="^(PASS|WARN|FAIL|UNKNOWN)$")
last_check: datetime
task_id: Optional[str] = None
summary: Optional[str] = None
# [/DEF:DashboardHealthItem:Class]
# [DEF:HealthSummaryResponse:Class]
# @PURPOSE: Aggregated health summary for all dashboards.
class HealthSummaryResponse(BaseModel):
items: List[DashboardHealthItem]
pass_count: int
warn_count: int
fail_count: int
unknown_count: int
# [/DEF:HealthSummaryResponse:Class]
# [/DEF:backend.src.schemas.health:Module]

View File

@@ -45,6 +45,7 @@ class ProfilePreference(BaseModel):
superset_username: Optional[str] = None
superset_username_normalized: Optional[str] = None
show_only_my_dashboards: bool = False
show_only_slug_dashboards: bool = True
git_username: Optional[str] = None
git_email: Optional[str] = None
@@ -55,6 +56,10 @@ class ProfilePreference(BaseModel):
auto_open_task_drawer: bool = True
dashboards_table_density: Literal["compact", "comfortable"] = "comfortable"
telegram_id: Optional[str] = None
email_address: Optional[str] = None
notify_on_fail: bool = True
created_at: datetime
updated_at: datetime
@@ -75,6 +80,10 @@ class ProfilePreferenceUpdateRequest(BaseModel):
default=None,
description='When true, "/dashboards" can auto-apply profile filter in main context.',
)
show_only_slug_dashboards: Optional[bool] = Field(
default=None,
description='When true, "/dashboards" hides dashboards without slug by default.',
)
git_username: Optional[str] = Field(
default=None,
description="Git author username used for commit signature.",
@@ -103,6 +112,18 @@ class ProfilePreferenceUpdateRequest(BaseModel):
default=None,
description="Preferred table density for dashboard listings.",
)
telegram_id: Optional[str] = Field(
default=None,
description="Telegram ID for notifications.",
)
email_address: Optional[str] = Field(
default=None,
description="Email address for notifications (overrides system email).",
)
notify_on_fail: Optional[bool] = Field(
default=None,
description="Whether to send notifications on validation failure.",
)
# [/DEF:ProfilePreferenceUpdateRequest:Class]
@@ -156,4 +177,4 @@ class SupersetAccountLookupResponse(BaseModel):
items: List[SupersetAccountCandidate] = Field(default_factory=list)
# [/DEF:SupersetAccountLookupResponse:Class]
# [/DEF:backend.src.schemas.profile:Module]
# [/DEF:backend.src.schemas.profile:Module]

View File

@@ -0,0 +1,68 @@
# [DEF:backend.src.schemas.settings:Module]
# @TIER: STANDARD
# @SEMANTICS: settings, schemas, pydantic, validation
# @PURPOSE: Pydantic schemas for application settings and automation policies.
# @LAYER: Domain
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime, time
# [DEF:NotificationChannel:Class]
# @PURPOSE: Structured notification channel definition for policy-level custom routing.
class NotificationChannel(BaseModel):
type: str = Field(..., description="Notification channel type (e.g., SLACK, SMTP, TELEGRAM)")
target: str = Field(..., description="Notification destination (e.g., #alerts, chat id, email)")
# [/DEF:NotificationChannel:Class]
# [DEF:ValidationPolicyBase:Class]
# @PURPOSE: Base schema for validation policy data.
class ValidationPolicyBase(BaseModel):
name: str = Field(..., description="Name of the policy")
environment_id: str = Field(..., description="Target Superset environment ID")
is_active: bool = Field(True, description="Whether the policy is currently active")
dashboard_ids: List[str] = Field(..., description="List of dashboard IDs to validate")
schedule_days: List[int] = Field(..., description="Days of the week (0-6, 0=Sunday) to run")
window_start: time = Field(..., description="Start of the execution window")
window_end: time = Field(..., description="End of the execution window")
notify_owners: bool = Field(True, description="Whether to notify dashboard owners on failure")
custom_channels: Optional[List[NotificationChannel]] = Field(
None,
description="List of additional structured notification channels",
)
alert_condition: str = Field("FAIL_ONLY", description="Condition to trigger alerts: FAIL_ONLY, WARN_AND_FAIL, ALWAYS")
# [/DEF:ValidationPolicyBase:Class]
# [DEF:ValidationPolicyCreate:Class]
# @PURPOSE: Schema for creating a new validation policy.
class ValidationPolicyCreate(ValidationPolicyBase):
pass
# [/DEF:ValidationPolicyCreate:Class]
# [DEF:ValidationPolicyUpdate:Class]
# @PURPOSE: Schema for updating an existing validation policy.
class ValidationPolicyUpdate(BaseModel):
name: Optional[str] = None
environment_id: Optional[str] = None
is_active: Optional[bool] = None
dashboard_ids: Optional[List[str]] = None
schedule_days: Optional[List[int]] = None
window_start: Optional[time] = None
window_end: Optional[time] = None
notify_owners: Optional[bool] = None
custom_channels: Optional[List[NotificationChannel]] = None
alert_condition: Optional[str] = None
# [/DEF:ValidationPolicyUpdate:Class]
# [DEF:ValidationPolicyResponse:Class]
# @PURPOSE: Schema for validation policy response data.
class ValidationPolicyResponse(ValidationPolicyBase):
id: str
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# [/DEF:ValidationPolicyResponse:Class]
# [/DEF:backend.src.schemas.settings:Module]

View File

@@ -0,0 +1,3 @@
# [DEF:src.scripts:Package]
# @PURPOSE: Script entrypoint package root.
# [/DEF:src.scripts:Package]

View File

@@ -15,13 +15,13 @@ from datetime import datetime, timezone
from types import SimpleNamespace
from typing import List, Optional, Any, Dict
# Standardize sys.path for direct execution from project root or scripts dir
# Standardize sys.path for direct execution from project root or scripts dir.
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..", "..", ".."))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
BACKEND_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..", ".."))
if BACKEND_ROOT not in sys.path:
sys.path.insert(0, BACKEND_ROOT)
from backend.src.models.clean_release import (
from src.models.clean_release import (
CandidateArtifact,
CheckFinalStatus,
CheckStageName,
@@ -35,12 +35,12 @@ from backend.src.models.clean_release import (
RegistryStatus,
ReleaseCandidateStatus,
)
from backend.src.services.clean_release.approval_service import approve_candidate
from backend.src.services.clean_release.compliance_execution_service import ComplianceExecutionService
from backend.src.services.clean_release.enums import CandidateStatus
from backend.src.services.clean_release.manifest_service import build_manifest_snapshot
from backend.src.services.clean_release.publication_service import publish_candidate
from backend.src.services.clean_release.repository import CleanReleaseRepository
from src.services.clean_release.approval_service import approve_candidate
from src.services.clean_release.compliance_execution_service import ComplianceExecutionService
from src.services.clean_release.enums import CandidateStatus
from src.services.clean_release.manifest_service import build_manifest_snapshot
from src.services.clean_release.publication_service import publish_candidate
from src.services.clean_release.repository import CleanReleaseRepository
# [DEF:TuiFacadeAdapter:Class]
# @PURPOSE: Thin TUI adapter that routes business mutations through application services.
@@ -462,6 +462,7 @@ class CleanReleaseTUI:
self.status = CheckFinalStatus.FAILED
self.refresh_overview()
self.refresh_screen()
# [/DEF:run_checks:Function]
def build_manifest(self):
try:

View File

@@ -291,6 +291,9 @@ def main() -> None:
logger.info(f"[COHERENCE:OK] Result summary: {json.dumps(result, ensure_ascii=True)}")
# [/DEF:main:Function]
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,87 @@
import pytest
from datetime import datetime, timedelta
from unittest.mock import MagicMock
from src.services.health_service import HealthService
from src.models.llm import ValidationRecord
# [DEF:test_health_service:Module]
# @TIER: STANDARD
# @PURPOSE: Unit tests for HealthService aggregation logic.
@pytest.mark.asyncio
async def test_get_health_summary_aggregation():
"""
@TEST_SCENARIO: Verify that HealthService correctly aggregates the latest record per dashboard.
"""
# Setup: Mock DB session
db = MagicMock()
now = datetime.utcnow()
# Dashboard 1: Old FAIL, New PASS
rec1_old = ValidationRecord(
dashboard_id="dash_1",
environment_id="env_1",
status="FAIL",
timestamp=now - timedelta(hours=1),
summary="Old failure",
issues=[]
)
rec1_new = ValidationRecord(
dashboard_id="dash_1",
environment_id="env_1",
status="PASS",
timestamp=now,
summary="New pass",
issues=[]
)
# Dashboard 2: Single WARN
rec2 = ValidationRecord(
dashboard_id="dash_2",
environment_id="env_1",
status="WARN",
timestamp=now,
summary="Warning",
issues=[]
)
# Mock the query chain
# subquery = self.db.query(...).filter(...).group_by(...).subquery()
# query = self.db.query(ValidationRecord).join(subquery, ...).all()
mock_query = db.query.return_value
mock_query.filter.return_value = mock_query
mock_query.group_by.return_value = mock_query
mock_query.subquery.return_value = MagicMock()
db.query.return_value.join.return_value.all.return_value = [rec1_new, rec2]
service = HealthService(db)
summary = await service.get_health_summary(environment_id="env_1")
assert summary.pass_count == 1
assert summary.warn_count == 1
assert summary.fail_count == 0
assert len(summary.items) == 2
# Verify dash_1 has the latest status (PASS)
dash_1_item = next(item for item in summary.items if item.dashboard_id == "dash_1")
assert dash_1_item.status == "PASS"
assert dash_1_item.summary == "New pass"
@pytest.mark.asyncio
async def test_get_health_summary_empty():
"""
@TEST_SCENARIO: Verify behavior with no records.
"""
db = MagicMock()
db.query.return_value.join.return_value.all.return_value = []
service = HealthService(db)
summary = await service.get_health_summary(environment_id="env_none")
assert summary.pass_count == 0
assert len(summary.items) == 0
# [/DEF:test_health_service:Module]

View File

@@ -0,0 +1,150 @@
# [DEF:backend.src.services.__tests__.test_llm_plugin_persistence:Module]
# @TIER: STANDARD
# @PURPOSE: Regression test for ValidationRecord persistence fields populated from task context.
import types
import pytest
from src.plugins.llm_analysis import plugin as plugin_module
# [DEF:_DummyLogger:Class]
# @PURPOSE: Minimal logger shim for TaskContext-like objects used in tests.
class _DummyLogger:
def with_source(self, _source: str):
return self
def info(self, *_args, **_kwargs):
return None
def debug(self, *_args, **_kwargs):
return None
def warning(self, *_args, **_kwargs):
return None
def error(self, *_args, **_kwargs):
return None
# [/DEF:_DummyLogger:Class]
# [DEF:_FakeDBSession:Class]
# @PURPOSE: Captures persisted records for assertion and mimics SQLAlchemy session methods used by plugin.
class _FakeDBSession:
def __init__(self):
self.added = None
self.committed = False
self.closed = False
def add(self, obj):
self.added = obj
def commit(self):
self.committed = True
def close(self):
self.closed = True
# [/DEF:_FakeDBSession:Class]
# [DEF:test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
# @PURPOSE: Ensure db ValidationRecord includes context.task_id and params.environment_id.
@pytest.mark.asyncio
async def test_dashboard_validation_plugin_persists_task_and_environment_ids(tmp_path, monkeypatch):
fake_db = _FakeDBSession()
env = types.SimpleNamespace(id="env-42")
provider = types.SimpleNamespace(
id="provider-1",
name="Main LLM",
provider_type="openai",
base_url="https://example.invalid/v1",
default_model="gpt-4o",
is_active=True,
)
class _FakeProviderService:
def __init__(self, _db):
return None
def get_provider(self, _provider_id):
return provider
def get_decrypted_api_key(self, _provider_id):
return "a" * 32
class _FakeScreenshotService:
def __init__(self, _env):
return None
async def capture_dashboard(self, _dashboard_id, _screenshot_path):
return None
class _FakeLLMClient:
def __init__(self, **_kwargs):
return None
async def analyze_dashboard(self, *_args, **_kwargs):
return {
"status": "PASS",
"summary": "Dashboard healthy",
"issues": [],
}
class _FakeNotificationService:
def __init__(self, *_args, **_kwargs):
return None
async def dispatch_report(self, **_kwargs):
return None
class _FakeConfigManager:
def get_environment(self, _env_id):
return env
def get_config(self):
return types.SimpleNamespace(
settings=types.SimpleNamespace(
storage=types.SimpleNamespace(root_path=str(tmp_path)),
llm={},
)
)
class _FakeSupersetClient:
def __init__(self, _env):
self.network = types.SimpleNamespace(request=lambda **_kwargs: {"result": []})
monkeypatch.setattr(plugin_module, "SessionLocal", lambda: fake_db)
monkeypatch.setattr(plugin_module, "LLMProviderService", _FakeProviderService)
monkeypatch.setattr(plugin_module, "ScreenshotService", _FakeScreenshotService)
monkeypatch.setattr(plugin_module, "LLMClient", _FakeLLMClient)
monkeypatch.setattr(plugin_module, "NotificationService", _FakeNotificationService)
monkeypatch.setattr(plugin_module, "SupersetClient", _FakeSupersetClient)
monkeypatch.setattr("src.dependencies.get_config_manager", lambda: _FakeConfigManager())
context = types.SimpleNamespace(
task_id="task-999",
logger=_DummyLogger(),
background_tasks=None,
)
plugin = plugin_module.DashboardValidationPlugin()
result = await plugin.execute(
{
"dashboard_id": "11",
"environment_id": "env-42",
"provider_id": "provider-1",
},
context=context,
)
assert result["environment_id"] == "env-42"
assert fake_db.committed is True
assert fake_db.closed is True
assert fake_db.added is not None
assert fake_db.added.task_id == "task-999"
assert fake_db.added.environment_id == "env-42"
# [/DEF:test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
# [/DEF:backend.src.services.__tests__.test_llm_plugin_persistence:Module]

View File

@@ -1,13 +1,16 @@
# [DEF:backend.src.services.auth_service:Module]
#
# @SEMANTICS: auth, service, business-logic, login, jwt
# @PURPOSE: Orchestrates authentication business logic.
# @LAYER: Service
# @RELATION: USES -> backend.src.core.auth.repository.AuthRepository
# @RELATION: USES -> backend.src.core.auth.security
# @RELATION: USES -> backend.src.core.auth.jwt
# @TIER: CRITICAL
# @SEMANTICS: auth, service, business-logic, login, jwt, adfs, jit-provisioning
# @PURPOSE: Orchestrates credential authentication and ADFS JIT user provisioning.
# @LAYER: Domain
# @RELATION: [DEPENDS_ON] ->[backend.src.core.auth.repository.AuthRepository]
# @RELATION: [DEPENDS_ON] ->[backend.src.core.auth.security.verify_password]
# @RELATION: [DEPENDS_ON] ->[backend.src.core.auth.jwt.create_access_token]
# @RELATION: [DEPENDS_ON] ->[backend.src.models.auth.User]
# @RELATION: [DEPENDS_ON] ->[backend.src.models.auth.Role]
#
# @INVARIANT: Authentication must verify both credentials and account status.
# @INVARIANT: Authentication succeeds only for active users with valid credentials; issued sessions encode subject and scopes from assigned roles.
# [SECTION: IMPORTS]
from typing import Dict, Any
@@ -23,20 +26,25 @@ from ..core.logger import belief_scope
# @PURPOSE: Provides high-level authentication services.
class AuthService:
# [DEF:__init__:Function]
# @PURPOSE: Initializes the service with a database session.
# @PARAM: db (Session) - SQLAlchemy session.
# @PURPOSE: Initializes the authentication service with repository access over an active DB session.
# @PRE: db is a valid SQLAlchemy Session instance bound to the auth persistence context.
# @POST: self.repo is initialized and ready for auth user/role CRUD operations.
# @SIDE_EFFECT: Allocates AuthRepository and binds it to the provided Session.
# @DATA_CONTRACT: Input(Session) -> Model(AuthRepository)
# @PARAM: db (Session) - SQLAlchemy session.
def __init__(self, db: Session):
self.repo = AuthRepository(db)
# [/DEF:__init__:Function]
# [DEF:authenticate_user:Function]
# @PURPOSE: Authenticates a user with username and password.
# @PRE: username and password are provided.
# @POST: Returns User object if authentication succeeds, else None.
# @SIDE_EFFECT: Updates last_login timestamp on success.
# @PARAM: username (str) - The username.
# @PARAM: password (str) - The plain password.
# @RETURN: Optional[User] - The authenticated user or None.
# @PURPOSE: Validates credentials and account state for local username/password authentication.
# @PRE: username and password are non-empty credential inputs.
# @POST: Returns User only when user exists, is active, and password hash verification succeeds; otherwise returns None.
# @SIDE_EFFECT: Persists last_login update for successful authentications via repository.
# @DATA_CONTRACT: Input(str username, str password) -> Output(User | None)
# @PARAM: username (str) - The username.
# @PARAM: password (str) - The plain password.
# @RETURN: Optional[User] - The authenticated user or None.
def authenticate_user(self, username: str, password: str):
with belief_scope("AuthService.authenticate_user"):
user = self.repo.get_user_by_username(username)
@@ -54,11 +62,13 @@ class AuthService:
# [/DEF:authenticate_user:Function]
# [DEF:create_session:Function]
# @PURPOSE: Creates a JWT session for an authenticated user.
# @PRE: user is a valid User object.
# @POST: Returns a dictionary with access_token and token_type.
# @PARAM: user (User) - The authenticated user.
# @RETURN: Dict[str, str] - Session data.
# @PURPOSE: Issues an access token payload for an already authenticated user.
# @PRE: user is a valid User entity containing username and iterable roles with role.name values.
# @POST: Returns session dict with non-empty access_token and token_type='bearer'.
# @SIDE_EFFECT: Generates signed JWT via auth JWT provider.
# @DATA_CONTRACT: Input(User) -> Output(Dict[str, str]{access_token, token_type})
# @PARAM: user (User) - The authenticated user.
# @RETURN: Dict[str, str] - Session data.
def create_session(self, user) -> Dict[str, str]:
with belief_scope("AuthService.create_session"):
# Collect role names for scopes
@@ -77,11 +87,13 @@ class AuthService:
# [/DEF:create_session:Function]
# [DEF:provision_adfs_user:Function]
# @PURPOSE: Just-In-Time (JIT) provisioning for ADFS users based on group mappings.
# @PRE: user_info contains 'upn' (username), 'email', and 'groups'.
# @POST: User is created/updated and assigned roles based on groups.
# @PARAM: user_info (Dict[str, Any]) - Claims from ADFS token.
# @RETURN: User - The provisioned user.
# @PURPOSE: Performs ADFS Just-In-Time provisioning and role synchronization from AD group mappings.
# @PRE: user_info contains identity claims where at least one of 'upn' or 'email' is present; 'groups' may be absent.
# @POST: Returns persisted user entity with roles synchronized to mapped AD groups and refreshed state.
# @SIDE_EFFECT: May insert new User, mutate user.roles, commit transaction, and refresh ORM state.
# @DATA_CONTRACT: Input(Dict[str, Any]{upn|email, email, groups[]}) -> Output(User persisted)
# @PARAM: user_info (Dict[str, Any]) - Claims from ADFS token.
# @RETURN: User - The provisioned user.
def provision_adfs_user(self, user_info: Dict[str, Any]) -> User:
with belief_scope("AuthService.provision_adfs_user"):
username = user_info.get("upn") or user_info.get("email")

View File

@@ -22,3 +22,6 @@ def test_audit_check_run(mock_logger):
def test_audit_report(mock_logger):
audit_report("rep-1", "cand-1")
mock_logger.info.assert_called_with("[EXPLORE] clean-release report_id=rep-1 candidate=cand-1")
# [/DEF:backend.tests.services.clean_release.test_audit_service:Module]

View File

@@ -3,7 +3,7 @@
# @SEMANTICS: tests, clean-release, preparation, flow
# @PURPOSE: Validate release candidate preparation flow, including policy evaluation and manifest persisting.
# @LAYER: Domain
# @RELATION: TESTS -> backend.src.services.clean_release.preparation_service
# @RELATION: [DEPENDS_ON] ->[backend.src.services.clean_release.preparation_service:Module]
# @INVARIANT: Candidate preparation always persists manifest and candidate status deterministically.
import pytest
@@ -21,6 +21,8 @@ from src.models.clean_release import (
)
from src.services.clean_release.preparation_service import prepare_candidate
# [DEF:backend.tests.services.clean_release.test_preparation_service._mock_policy:Function]
# @PURPOSE: Build a valid clean profile policy fixture for preparation tests.
def _mock_policy() -> CleanProfilePolicy:
return CleanProfilePolicy(
policy_id="pol-1",
@@ -33,7 +35,10 @@ def _mock_policy() -> CleanProfilePolicy:
effective_from=datetime.now(timezone.utc),
profile=ProfileType.ENTERPRISE_CLEAN,
)
# [/DEF:backend.tests.services.clean_release.test_preparation_service._mock_policy:Function]
# [DEF:backend.tests.services.clean_release.test_preparation_service._mock_registry:Function]
# @PURPOSE: Build an internal-only source registry fixture for preparation tests.
def _mock_registry() -> ResourceSourceRegistry:
return ResourceSourceRegistry(
registry_id="reg-1",
@@ -42,7 +47,10 @@ def _mock_registry() -> ResourceSourceRegistry:
updated_at=datetime.now(timezone.utc),
updated_by="tester"
)
# [/DEF:backend.tests.services.clean_release.test_preparation_service._mock_registry:Function]
# [DEF:backend.tests.services.clean_release.test_preparation_service._mock_candidate:Function]
# @PURPOSE: Build a draft release candidate fixture with provided identifier.
def _mock_candidate(candidate_id: str) -> ReleaseCandidate:
return ReleaseCandidate(
candidate_id=candidate_id,
@@ -53,7 +61,15 @@ def _mock_candidate(candidate_id: str) -> ReleaseCandidate:
created_by="tester",
source_snapshot_ref="v1.0.0-snapshot"
)
# [/DEF:backend.tests.services.clean_release.test_preparation_service._mock_candidate:Function]
# [DEF:backend.tests.services.clean_release.test_preparation_service.test_prepare_candidate_success:Function]
# @PURPOSE: Verify candidate transitions to PREPARED when evaluation returns no violations.
# @TEST_CONTRACT: [valid_candidate + active_policy + internal_sources + no_violations] -> [status=PREPARED, manifest_persisted, candidate_saved]
# @TEST_SCENARIO: [prepare_success] -> [prepared status and persistence side effects are produced]
# @TEST_FIXTURE: [INLINE_MOCKS] -> INLINE_JSON
# @TEST_EDGE: [external_fail] -> [none; dependency interactions mocked and successful]
# @TEST_INVARIANT: [prepared_flow_persists_state] -> VERIFIED_BY: [prepare_success]
def test_prepare_candidate_success():
# Setup
repository = MagicMock()
@@ -82,7 +98,15 @@ def test_prepare_candidate_success():
assert candidate.status == ReleaseCandidateStatus.PREPARED
repository.save_manifest.assert_called_once()
repository.save_candidate.assert_called_with(candidate)
# [/DEF:backend.tests.services.clean_release.test_preparation_service.test_prepare_candidate_success:Function]
# [DEF:backend.tests.services.clean_release.test_preparation_service.test_prepare_candidate_with_violations:Function]
# @PURPOSE: Verify candidate transitions to BLOCKED when evaluation returns blocking violations.
# @TEST_CONTRACT: [valid_candidate + active_policy + evaluation_with_violations] -> [status=BLOCKED, violations_exposed]
# @TEST_SCENARIO: [prepare_blocked_due_to_policy] -> [blocked status and violation list are produced]
# @TEST_FIXTURE: [INLINE_MOCKS] -> INLINE_JSON
# @TEST_EDGE: [external_fail] -> [none; dependency interactions mocked and successful]
# @TEST_INVARIANT: [blocked_flow_reports_violations] -> VERIFIED_BY: [prepare_blocked_due_to_policy]
def test_prepare_candidate_with_violations():
# Setup
repository = MagicMock()
@@ -110,14 +134,30 @@ def test_prepare_candidate_with_violations():
assert result["status"] == ReleaseCandidateStatus.BLOCKED.value
assert candidate.status == ReleaseCandidateStatus.BLOCKED
assert len(result["violations"]) == 1
# [/DEF:backend.tests.services.clean_release.test_preparation_service.test_prepare_candidate_with_violations:Function]
# [DEF:backend.tests.services.clean_release.test_preparation_service.test_prepare_candidate_not_found:Function]
# @PURPOSE: Verify preparation raises ValueError when candidate does not exist.
# @TEST_CONTRACT: [missing_candidate] -> [ValueError('Candidate not found')]
# @TEST_SCENARIO: [prepare_missing_candidate] -> [raises candidate not found error]
# @TEST_FIXTURE: [INLINE_MOCKS] -> INLINE_JSON
# @TEST_EDGE: [missing_field] -> [candidate lookup returns None]
# @TEST_INVARIANT: [missing_candidate_is_rejected] -> VERIFIED_BY: [prepare_missing_candidate]
def test_prepare_candidate_not_found():
repository = MagicMock()
repository.get_candidate.return_value = None
with pytest.raises(ValueError, match="Candidate not found"):
prepare_candidate(repository, "non-existent", [], [], "op")
# [/DEF:backend.tests.services.clean_release.test_preparation_service.test_prepare_candidate_not_found:Function]
# [DEF:backend.tests.services.clean_release.test_preparation_service.test_prepare_candidate_no_active_policy:Function]
# @PURPOSE: Verify preparation raises ValueError when no active policy is available.
# @TEST_CONTRACT: [candidate_present + missing_active_policy] -> [ValueError('Active clean policy not found')]
# @TEST_SCENARIO: [prepare_missing_policy] -> [raises active policy missing error]
# @TEST_FIXTURE: [INLINE_MOCKS] -> INLINE_JSON
# @TEST_EDGE: [invalid_type] -> [policy dependency resolves to None]
# @TEST_INVARIANT: [active_policy_required] -> VERIFIED_BY: [prepare_missing_policy]
def test_prepare_candidate_no_active_policy():
repository = MagicMock()
repository.get_candidate.return_value = _mock_candidate("cand-1")
@@ -125,3 +165,7 @@ def test_prepare_candidate_no_active_policy():
with pytest.raises(ValueError, match="Active clean policy not found"):
prepare_candidate(repository, "cand-1", [], [], "op")
# [/DEF:backend.tests.services.clean_release.test_preparation_service.test_prepare_candidate_no_active_policy:Function]
# [/DEF:backend.tests.services.clean_release.test_preparation_service:Module]

View File

@@ -55,4 +55,6 @@ def test_validate_internal_sources_external_blocked():
assert result["ok"] is False
assert len(result["violations"]) == 1
assert result["violations"][0]["category"] == "external-source"
assert result["violations"][0]["blocked_release"] is True
assert result["violations"][0]["blocked_release"] is True
# [/DEF:backend.tests.services.clean_release.test_source_isolation:Module]

View File

@@ -25,3 +25,6 @@ def test_derive_final_status_failed_skipped():
results = [CheckStageResult(stage=s, status=CheckStageStatus.PASS, details="ok") for s in MANDATORY_STAGE_ORDER]
results[2].status = CheckStageStatus.SKIPPED
assert derive_final_status(results) == CheckFinalStatus.FAILED
# [/DEF:backend.tests.services.clean_release.test_stages:Module]

View File

@@ -35,89 +35,117 @@ from ...models.clean_release import (
from .policy_engine import CleanPolicyEngine
from .repository import CleanReleaseRepository
from .stages import derive_final_status
from ...core.logger import belief_scope
# [DEF:CleanComplianceOrchestrator:Class]
# @PURPOSE: Coordinate clean-release compliance verification stages.
class CleanComplianceOrchestrator:
# [DEF:CleanComplianceOrchestrator.__init__:Function]
# @PURPOSE: Bind repository dependency used for orchestrator persistence and lookups.
# @PRE: repository is a valid CleanReleaseRepository instance with required methods.
# @POST: self.repository is assigned and used by all orchestration steps.
# @SIDE_EFFECT: Stores repository reference on orchestrator instance.
# @DATA_CONTRACT: Input -> CleanReleaseRepository, Output -> None
def __init__(self, repository: CleanReleaseRepository):
self.repository = repository
with belief_scope("CleanComplianceOrchestrator.__init__"):
self.repository = repository
# [/DEF:CleanComplianceOrchestrator.__init__:Function]
# [DEF:start_check_run:Function]
# @PURPOSE: Initiate a new compliance run session.
# @PRE: candidate_id and policy_id must exist in repository.
# @POST: Returns initialized ComplianceRun in RUNNING state.
# @PRE: candidate_id/policy_id/manifest_id identify existing records in repository.
# @POST: Returns initialized ComplianceRun in RUNNING state persisted in repository.
# @SIDE_EFFECT: Reads manifest/policy and writes new ComplianceRun via repository.save_check_run.
# @DATA_CONTRACT: Input -> (candidate_id:str, policy_id:str, requested_by:str, manifest_id:str), Output -> ComplianceRun
def start_check_run(self, candidate_id: str, policy_id: str, requested_by: str, manifest_id: str) -> ComplianceRun:
manifest = self.repository.get_manifest(manifest_id)
policy = self.repository.get_policy(policy_id)
if not manifest or not policy:
raise ValueError("Manifest or Policy not found")
with belief_scope("start_check_run"):
manifest = self.repository.get_manifest(manifest_id)
policy = self.repository.get_policy(policy_id)
if not manifest or not policy:
raise ValueError("Manifest or Policy not found")
check_run = ComplianceRun(
id=f"check-{uuid4()}",
candidate_id=candidate_id,
manifest_id=manifest_id,
manifest_digest=manifest.manifest_digest,
policy_snapshot_id=policy_id,
registry_snapshot_id=policy.registry_snapshot_id,
requested_by=requested_by,
requested_at=datetime.now(timezone.utc),
status=RunStatus.RUNNING,
)
return self.repository.save_check_run(check_run)
check_run = ComplianceRun(
id=f"check-{uuid4()}",
candidate_id=candidate_id,
manifest_id=manifest_id,
manifest_digest=manifest.manifest_digest,
policy_snapshot_id=policy_id,
registry_snapshot_id=policy.registry_snapshot_id,
requested_by=requested_by,
requested_at=datetime.now(timezone.utc),
status=RunStatus.RUNNING,
)
return self.repository.save_check_run(check_run)
# [/DEF:start_check_run:Function]
# [DEF:execute_stages:Function]
# @PURPOSE: Execute or accept compliance stage outcomes and set intermediate/final check-run status fields.
# @PRE: check_run exists and references candidate/policy/registry/manifest identifiers resolvable by repository.
# @POST: Returns persisted ComplianceRun with status FAILED on missing dependencies, otherwise SUCCEEDED with final_status set.
# @SIDE_EFFECT: Reads candidate/policy/registry/manifest and persists updated check_run.
# @DATA_CONTRACT: Input -> (check_run:ComplianceRun, forced_results:Optional[List[ComplianceStageRun]]), Output -> ComplianceRun
def execute_stages(self, check_run: ComplianceRun, forced_results: Optional[List[ComplianceStageRun]] = None) -> ComplianceRun:
if forced_results is not None:
# In a real scenario, we'd persist these stages.
with belief_scope("execute_stages"):
if forced_results is not None:
# In a real scenario, we'd persist these stages.
return self.repository.save_check_run(check_run)
# Real Logic Integration
candidate = self.repository.get_candidate(check_run.candidate_id)
policy = self.repository.get_policy(check_run.policy_snapshot_id)
if not candidate or not policy:
check_run.status = RunStatus.FAILED
return self.repository.save_check_run(check_run)
registry = self.repository.get_registry(check_run.registry_snapshot_id)
manifest = self.repository.get_manifest(check_run.manifest_id)
if not registry or not manifest:
check_run.status = RunStatus.FAILED
return self.repository.save_check_run(check_run)
# Simulate stage execution and violation detection
# 1. DATA_PURITY
summary = manifest.content_json.get("summary", {})
purity_ok = summary.get("prohibited_detected_count", 0) == 0
if not purity_ok:
check_run.final_status = ComplianceDecision.BLOCKED
else:
check_run.final_status = ComplianceDecision.PASSED
check_run.status = RunStatus.SUCCEEDED
check_run.finished_at = datetime.now(timezone.utc)
return self.repository.save_check_run(check_run)
# Real Logic Integration
candidate = self.repository.get_candidate(check_run.candidate_id)
policy = self.repository.get_policy(check_run.policy_snapshot_id)
if not candidate or not policy:
check_run.status = RunStatus.FAILED
return self.repository.save_check_run(check_run)
registry = self.repository.get_registry(check_run.registry_snapshot_id)
manifest = self.repository.get_manifest(check_run.manifest_id)
if not registry or not manifest:
check_run.status = RunStatus.FAILED
return self.repository.save_check_run(check_run)
# Simulate stage execution and violation detection
# 1. DATA_PURITY
summary = manifest.content_json.get("summary", {})
purity_ok = summary.get("prohibited_detected_count", 0) == 0
if not purity_ok:
check_run.final_status = ComplianceDecision.BLOCKED
else:
check_run.final_status = ComplianceDecision.PASSED
check_run.status = RunStatus.SUCCEEDED
check_run.finished_at = datetime.now(timezone.utc)
return self.repository.save_check_run(check_run)
# [/DEF:execute_stages:Function]
# [DEF:finalize_run:Function]
# @PURPOSE: Finalize run status based on cumulative stage results.
# @POST: Status derivation follows strict MANDATORY_STAGE_ORDER.
# @PRE: check_run was started and may already contain a derived final_status from stage execution.
# @POST: Returns persisted ComplianceRun in SUCCEEDED status with final_status guaranteed non-empty.
# @SIDE_EFFECT: Mutates check_run terminal fields and persists via repository.save_check_run.
# @DATA_CONTRACT: Input -> ComplianceRun, Output -> ComplianceRun
def finalize_run(self, check_run: ComplianceRun) -> ComplianceRun:
# If not already set by execute_stages
if not check_run.final_status:
check_run.final_status = ComplianceDecision.PASSED
check_run.status = RunStatus.SUCCEEDED
check_run.finished_at = datetime.now(timezone.utc)
return self.repository.save_check_run(check_run)
with belief_scope("finalize_run"):
# If not already set by execute_stages
if not check_run.final_status:
check_run.final_status = ComplianceDecision.PASSED
check_run.status = RunStatus.SUCCEEDED
check_run.finished_at = datetime.now(timezone.utc)
return self.repository.save_check_run(check_run)
# [/DEF:finalize_run:Function]
# [/DEF:CleanComplianceOrchestrator:Class]
# [DEF:run_check_legacy:Function]
# @PURPOSE: Legacy wrapper for compatibility with previous orchestrator call style.
# @PRE: Candidate/policy/manifest identifiers are valid for repository.
# @POST: Returns finalized ComplianceRun produced by orchestrator.
# @PRE: repository and identifiers are valid and resolvable by orchestrator dependencies.
# @POST: Returns finalized ComplianceRun produced by orchestrator start->execute->finalize sequence.
# @SIDE_EFFECT: Reads/writes compliance entities through repository during orchestrator calls.
# @DATA_CONTRACT: Input -> (repository:CleanReleaseRepository, candidate_id:str, policy_id:str, requested_by:str, manifest_id:str), Output -> ComplianceRun
def run_check_legacy(
repository: CleanReleaseRepository,
candidate_id: str,
@@ -125,14 +153,15 @@ def run_check_legacy(
requested_by: str,
manifest_id: str,
) -> ComplianceRun:
orchestrator = CleanComplianceOrchestrator(repository)
run = orchestrator.start_check_run(
candidate_id=candidate_id,
policy_id=policy_id,
requested_by=requested_by,
manifest_id=manifest_id,
)
run = orchestrator.execute_stages(run)
return orchestrator.finalize_run(run)
with belief_scope("run_check_legacy"):
orchestrator = CleanComplianceOrchestrator(repository)
run = orchestrator.start_check_run(
candidate_id=candidate_id,
policy_id=policy_id,
requested_by=requested_by,
manifest_id=manifest_id,
)
run = orchestrator.execute_stages(run)
return orchestrator.finalize_run(run)
# [/DEF:run_check_legacy:Function]
# [/DEF:backend.src.services.clean_release.compliance_orchestrator:Module]

View File

@@ -0,0 +1,84 @@
# [DEF:health_service:Module]
# @TIER: STANDARD
# @SEMANTICS: health, aggregation, dashboards
# @PURPOSE: Business logic for aggregating dashboard health status from validation records.
# @LAYER: Domain/Service
# @RELATION: DEPENDS_ON -> ValidationRecord
from typing import List, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import func, desc
from ..models.llm import ValidationRecord
from ..schemas.health import DashboardHealthItem, HealthSummaryResponse
from ..core.logger import logger
class HealthService:
"""
@PURPOSE: Service for managing and querying dashboard health data.
"""
def __init__(self, db: Session):
self.db = db
async def get_health_summary(self, environment_id: str = None) -> HealthSummaryResponse:
"""
@PURPOSE: Aggregates the latest validation status for all dashboards.
@PRE: environment_id (optional) to filter by environment.
@POST: Returns a HealthSummaryResponse with aggregated status counts and items.
"""
# [REASON] We need the latest ValidationRecord for each unique dashboard_id.
# We use a subquery to find the max timestamp per dashboard_id.
subquery = self.db.query(
ValidationRecord.dashboard_id,
func.max(ValidationRecord.timestamp).label("max_ts")
)
if environment_id:
subquery = subquery.filter(ValidationRecord.environment_id == environment_id)
subquery = subquery.group_by(ValidationRecord.dashboard_id).subquery()
query = self.db.query(ValidationRecord).join(
subquery,
(ValidationRecord.dashboard_id == subquery.c.dashboard_id) &
(ValidationRecord.timestamp == subquery.c.max_ts)
)
records = query.all()
items = []
pass_count = 0
warn_count = 0
fail_count = 0
unknown_count = 0
for rec in records:
status = rec.status.upper()
if status == "PASS":
pass_count += 1
elif status == "WARN":
warn_count += 1
elif status == "FAIL":
fail_count += 1
else:
unknown_count += 1
status = "UNKNOWN"
items.append(DashboardHealthItem(
dashboard_id=rec.dashboard_id,
environment_id=rec.environment_id or "unknown",
status=status,
last_check=rec.timestamp,
task_id=rec.task_id,
summary=rec.summary
))
logger.info(f"[HealthService][get_health_summary] Aggregated {len(items)} dashboard health records.")
return HealthSummaryResponse(
items=items,
pass_count=pass_count,
warn_count=warn_count,
fail_count=fail_count,
unknown_count=unknown_count
)
# [/DEF:health_service:Module]

View File

@@ -0,0 +1,3 @@
# [DEF:src.services.notifications:Package]
# @PURPOSE: Notification service package root.
# [/DEF:src.services.notifications:Package]

View File

@@ -0,0 +1,120 @@
# [DEF:backend.src.services.notifications.__tests__.test_notification_service:Module]
# @TIER: STANDARD
# @PURPOSE: Unit tests for NotificationService routing and dispatch logic.
import pytest
from unittest.mock import MagicMock, AsyncMock, patch
from datetime import time
from src.models.llm import ValidationRecord, ValidationPolicy
from src.models.profile import UserDashboardPreference
from src.models.auth import User
from src.services.notifications.service import NotificationService
@pytest.fixture
def mock_db():
return MagicMock()
@pytest.fixture
def mock_config_manager():
cm = MagicMock()
cm.get_payload.return_value = {
"notifications": {
"smtp": {"host": "localhost", "port": 25, "from_email": "test@example.com"},
"telegram": {"bot_token": "test_token"}
}
}
return cm
@pytest.fixture
def service(mock_db, mock_config_manager):
return NotificationService(mock_db, mock_config_manager)
@pytest.mark.asyncio
async def test_should_notify_fail_only(service):
record = ValidationRecord(status="FAIL")
policy = ValidationPolicy(alert_condition="FAIL_ONLY")
assert service._should_notify(record, policy) is True
record.status = "WARN"
assert service._should_notify(record, policy) is False
@pytest.mark.asyncio
async def test_should_notify_warn_and_fail(service):
policy = ValidationPolicy(alert_condition="WARN_AND_FAIL")
record = ValidationRecord(status="FAIL")
assert service._should_notify(record, policy) is True
record.status = "WARN"
assert service._should_notify(record, policy) is True
record.status = "PASS"
assert service._should_notify(record, policy) is False
@pytest.mark.asyncio
async def test_resolve_targets_owner_routing(service, mock_db):
record = ValidationRecord(dashboard_id="dash-1", environment_id="env-1")
user = User(email="user@example.com")
pref = UserDashboardPreference(
user=user,
telegram_id="12345",
notify_on_fail=True,
superset_username="user1"
)
mock_db.query.return_value.filter.return_value.all.return_value = [pref]
targets = service._resolve_targets(record, None)
assert ("TELEGRAM", "12345") in targets
assert ("SMTP", "user@example.com") in targets
@pytest.mark.asyncio
async def test_resolve_targets_custom_channels(service):
record = ValidationRecord(status="FAIL")
policy = ValidationPolicy(
notify_owners=False,
custom_channels=[{"type": "SLACK", "target": "#alerts"}]
)
targets = service._resolve_targets(record, policy)
assert targets == [("SLACK", "#alerts")]
@pytest.mark.asyncio
async def test_dispatch_report_skips_if_no_notify(service):
record = ValidationRecord(status="PASS")
policy = ValidationPolicy(alert_condition="FAIL_ONLY")
with patch.object(service, "_resolve_targets") as mock_resolve:
await service.dispatch_report(record, policy)
mock_resolve.assert_not_called()
@pytest.mark.asyncio
async def test_dispatch_report_calls_providers(service, mock_db):
record = ValidationRecord(id="rec-1", status="FAIL", summary="Bad", issues=[])
# Mock providers
service._initialize_providers()
service._providers["TELEGRAM"] = AsyncMock()
service._providers["SMTP"] = AsyncMock()
# Mock targets
with patch.object(service, "_resolve_targets") as mock_resolve:
mock_resolve.return_value = [("TELEGRAM", "123"), ("SMTP", "a@b.com")]
await service.dispatch_report(record, None)
service._providers["TELEGRAM"].send.assert_called_once()
service._providers["SMTP"].send.assert_called_once()
# [/DEF:backend.src.services.notifications.__tests__.test_notification_service:Module]

View File

@@ -0,0 +1,123 @@
# [DEF:backend.src.services.notifications.providers:Module]
#
# @TIER: CRITICAL
# @SEMANTICS: notifications, providers, smtp, slack, telegram, abstraction
# @PURPOSE: Defines abstract base and concrete implementations for external notification delivery.
# @LAYER: Infra
#
# @INVARIANT: Providers must be stateless and resilient to network failures.
# @INVARIANT: Sensitive credentials must be handled via encrypted config.
from abc import ABC, abstractmethod
from typing import Any, Dict, Optional
import requests
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from ...core.logger import logger
# [DEF:NotificationProvider:Class]
# @PURPOSE: Abstract base class for all notification providers.
class NotificationProvider(ABC):
@abstractmethod
async def send(self, target: str, subject: str, body: str, context: Optional[Dict[str, Any]] = None) -> bool:
"""
Send a notification to a specific target.
:param target: Recipient identifier (email, channel ID, user ID).
:param subject: Notification subject or title.
:param body: Main content of the notification.
:param context: Additional metadata for the provider.
:return: True if successfully dispatched.
"""
pass
# [/DEF:NotificationProvider:Class]
# [DEF:SMTPProvider:Class]
# @PURPOSE: Delivers notifications via SMTP.
class SMTPProvider(NotificationProvider):
def __init__(self, config: Dict[str, Any]):
self.host = config.get("host")
self.port = int(config.get("port", 587))
self.username = config.get("username")
self.password = config.get("password")
self.from_email = config.get("from_email")
self.use_tls = config.get("use_tls", True)
async def send(self, target: str, subject: str, body: str, context: Optional[Dict[str, Any]] = None) -> bool:
try:
msg = MIMEMultipart()
msg["From"] = self.from_email
msg["To"] = target
msg["Subject"] = subject
msg.attach(MIMEText(body, "plain"))
server = smtplib.SMTP(self.host, self.port)
if self.use_tls:
server.starttls()
if self.username and self.password:
server.login(self.username, self.password)
server.send_message(msg)
server.quit()
return True
except Exception as e:
logger.error(f"[SMTPProvider][FAILED] Failed to send email to {target}: {e}")
return False
# [/DEF:SMTPProvider:Class]
# [DEF:TelegramProvider:Class]
# @PURPOSE: Delivers notifications via Telegram Bot API.
class TelegramProvider(NotificationProvider):
def __init__(self, config: Dict[str, Any]):
self.bot_token = config.get("bot_token")
async def send(self, target: str, subject: str, body: str, context: Optional[Dict[str, Any]] = None) -> bool:
if not self.bot_token:
logger.error("[TelegramProvider][FAILED] Bot token not configured")
return False
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
payload = {
"chat_id": target,
"text": f"*{subject}*\n\n{body}",
"parse_mode": "Markdown"
}
try:
response = requests.post(url, json=payload, timeout=10)
response.raise_for_status()
return True
except Exception as e:
logger.error(f"[TelegramProvider][FAILED] Failed to send Telegram message to {target}: {e}")
return False
# [/DEF:TelegramProvider:Class]
# [DEF:SlackProvider:Class]
# @PURPOSE: Delivers notifications via Slack Webhooks or API.
class SlackProvider(NotificationProvider):
def __init__(self, config: Dict[str, Any]):
self.webhook_url = config.get("webhook_url")
async def send(self, target: str, subject: str, body: str, context: Optional[Dict[str, Any]] = None) -> bool:
if not self.webhook_url:
logger.error("[SlackProvider][FAILED] Webhook URL not configured")
return False
payload = {
"text": f"*{subject}*\n{body}"
}
try:
response = requests.post(self.webhook_url, json=payload, timeout=10)
response.raise_for_status()
return True
except Exception as e:
logger.error(f"[SlackProvider][FAILED] Failed to send Slack message: {e}")
return False
# [/DEF:SlackProvider:Class]
# [/DEF:backend.src.services.notifications.providers:Module]

View File

@@ -0,0 +1,146 @@
# [DEF:backend.src.services.notifications.service:Module]
#
# @TIER: CRITICAL
# @SEMANTICS: notifications, service, routing, dispatch, background-tasks
# @PURPOSE: Orchestrates notification routing based on user preferences and policy context.
# @LAYER: Domain
# @RELATION: DEPENDS_ON -> backend.src.services.notifications.providers
# @RELATION: DEPENDS_ON -> backend.src.services.profile_service
# @RELATION: DEPENDS_ON -> backend.src.models.llm
#
# @INVARIANT: Notifications are dispatched asynchronously via BackgroundTasks.
# @INVARIANT: Missing profile or provider config must not crash the pipeline.
from typing import Any, Dict, List, Optional
from fastapi import BackgroundTasks
from sqlalchemy.orm import Session
from ...core.logger import logger, belief_scope
from ...core.config_manager import ConfigManager
from ...models.llm import ValidationRecord, ValidationPolicy
from ...models.profile import UserDashboardPreference
from .providers import SMTPProvider, TelegramProvider, SlackProvider, NotificationProvider
# [DEF:NotificationService:Class]
# @PURPOSE: Routes validation reports to appropriate users and channels.
class NotificationService:
def __init__(self, db: Session, config_manager: ConfigManager):
self.db = db
self.config_manager = config_manager
self._providers: Dict[str, NotificationProvider] = {}
self._initialized = False
def _initialize_providers(self):
if self._initialized:
return
# In a real implementation, we would fetch these from NotificationConfig model
# For now, we'll use a placeholder initialization logic
# T033 will implement the UI/API for this.
configs = self.config_manager.get_payload().get("notifications", {})
if "smtp" in configs:
self._providers["SMTP"] = SMTPProvider(configs["smtp"])
if "telegram" in configs:
self._providers["TELEGRAM"] = TelegramProvider(configs["telegram"])
if "slack" in configs:
self._providers["SLACK"] = SlackProvider(configs["slack"])
self._initialized = True
async def dispatch_report(
self,
record: ValidationRecord,
policy: Optional[ValidationPolicy] = None,
background_tasks: Optional[BackgroundTasks] = None
):
"""
Route a validation record to owners and custom channels.
@PRE: record is persisted.
@POST: Dispatches async tasks for each resolved target.
"""
with belief_scope("NotificationService.dispatch_report", f"record_id={record.id}"):
self._initialize_providers()
# 1. Determine if we should notify based on status and policy
should_notify = self._should_notify(record, policy)
if not should_notify:
logger.reason(f"[REASON] Notification skipped for record {record.id} (status={record.status})")
return
# 2. Resolve targets (Owners + Custom Channels)
targets = self._resolve_targets(record, policy)
# 3. Dispatch
subject = f"Dashboard Health Alert: {record.status}"
body = self._build_body(record)
for channel_type, recipient in targets:
provider = self._providers.get(channel_type)
if not provider:
logger.warning(f"[NotificationService][EXPLORE] Unsupported or unconfigured channel: {channel_type}")
continue
if background_tasks:
background_tasks.add_task(provider.send, recipient, subject, body)
else:
# Fallback to sync for tests or if no background_tasks provided
await provider.send(recipient, subject, body)
def _should_notify(self, record: ValidationRecord, policy: Optional[ValidationPolicy]) -> bool:
condition = policy.alert_condition if policy else "FAIL_ONLY"
if condition == "ALWAYS":
return True
if condition == "WARN_AND_FAIL":
return record.status in ("WARN", "FAIL")
return record.status == "FAIL"
def _resolve_targets(self, record: ValidationRecord, policy: Optional[ValidationPolicy]) -> List[tuple]:
targets = []
# Owner routing
if not policy or policy.notify_owners:
owners = self._find_dashboard_owners(record)
for owner_pref in owners:
if not owner_pref.notify_on_fail:
continue
if owner_pref.telegram_id:
targets.append(("TELEGRAM", owner_pref.telegram_id))
email = owner_pref.email_address or getattr(owner_pref.user, "email", None)
if email:
targets.append(("SMTP", email))
# Custom channels from policy
if policy and policy.custom_channels:
for channel in policy.custom_channels:
# channel format: {"type": "SLACK", "target": "#alerts"}
targets.append((channel.get("type"), channel.get("target")))
return targets
def _find_dashboard_owners(self, record: ValidationRecord) -> List[UserDashboardPreference]:
# This is a simplified owner lookup.
# In a real scenario, we'd query Superset for owners, then match them to our UserDashboardPreference.
# For now, we'll return all users who have bound this dashboard's environment and have a username.
# Placeholder: return all preferences that have a superset_username
# (In production, we'd filter by actual ownership from Superset metadata)
return self.db.query(UserDashboardPreference).filter(
UserDashboardPreference.superset_username != None
).all()
def _build_body(self, record: ValidationRecord) -> str:
return (
f"Dashboard ID: {record.dashboard_id}\n"
f"Environment: {record.environment_id}\n"
f"Status: {record.status}\n\n"
f"Summary: {record.summary}\n\n"
f"Issues found: {len(record.issues)}"
)
# [/DEF:NotificationService:Class]
# [/DEF:backend.src.services.notifications.service:Module]

View File

@@ -145,6 +145,14 @@ class ProfileService:
if "show_only_my_dashboards" in provided_fields:
effective_show_only = bool(payload.show_only_my_dashboards)
effective_show_only_slug = (
bool(preference.show_only_slug_dashboards)
if preference.show_only_slug_dashboards is not None
else True
)
if "show_only_slug_dashboards" in provided_fields:
effective_show_only_slug = bool(payload.show_only_slug_dashboards)
effective_git_username = self._sanitize_text(preference.git_username)
if "git_username" in provided_fields:
effective_git_username = self._sanitize_text(payload.git_username)
@@ -173,12 +181,29 @@ class ProfileService:
payload.dashboards_table_density
)
effective_telegram_id = self._sanitize_text(preference.telegram_id)
if "telegram_id" in provided_fields:
effective_telegram_id = self._sanitize_text(payload.telegram_id)
effective_email_address = self._sanitize_text(preference.email_address)
if "email_address" in provided_fields:
effective_email_address = self._sanitize_text(payload.email_address)
effective_notify_on_fail = (
bool(preference.notify_on_fail)
if preference.notify_on_fail is not None
else True
)
if "notify_on_fail" in provided_fields:
effective_notify_on_fail = bool(payload.notify_on_fail)
validation_errors = self._validate_update_payload(
superset_username=effective_superset_username,
show_only_my_dashboards=effective_show_only,
git_email=effective_git_email,
start_page=effective_start_page,
dashboards_table_density=effective_dashboards_table_density,
email_address=effective_email_address,
)
if validation_errors:
logger.reflect("[REFLECT] Validation failed; mutation is denied")
@@ -189,6 +214,7 @@ class ProfileService:
effective_superset_username
)
preference.show_only_my_dashboards = effective_show_only
preference.show_only_slug_dashboards = effective_show_only_slug
preference.git_username = effective_git_username
preference.git_email = effective_git_email
@@ -205,6 +231,9 @@ class ProfileService:
preference.start_page = effective_start_page
preference.auto_open_task_drawer = effective_auto_open_task_drawer
preference.dashboards_table_density = effective_dashboards_table_density
preference.telegram_id = effective_telegram_id
preference.email_address = effective_email_address
preference.notify_on_fail = effective_notify_on_fail
preference.updated_at = datetime.utcnow()
persisted_preference = self.auth_repository.save_user_dashboard_preference(preference)
@@ -440,6 +469,11 @@ class ProfileService:
preference.superset_username_normalized
),
show_only_my_dashboards=bool(preference.show_only_my_dashboards),
show_only_slug_dashboards=(
bool(preference.show_only_slug_dashboards)
if preference.show_only_slug_dashboards is not None
else True
),
git_username=self._sanitize_text(preference.git_username),
git_email=self._sanitize_text(preference.git_email),
has_git_personal_access_token=bool(encrypted_token),
@@ -453,6 +487,9 @@ class ProfileService:
dashboards_table_density=self._normalize_density(
preference.dashboards_table_density
),
telegram_id=self._sanitize_text(preference.telegram_id),
email_address=self._sanitize_text(preference.email_address),
notify_on_fail=bool(preference.notify_on_fail) if preference.notify_on_fail is not None else True,
created_at=created_at,
updated_at=updated_at,
)
@@ -563,6 +600,7 @@ class ProfileService:
superset_username=None,
superset_username_normalized=None,
show_only_my_dashboards=False,
show_only_slug_dashboards=True,
git_username=None,
git_email=None,
has_git_personal_access_token=False,
@@ -570,6 +608,9 @@ class ProfileService:
start_page="dashboards",
auto_open_task_drawer=True,
dashboards_table_density="comfortable",
telegram_id=None,
email_address=None,
notify_on_fail=True,
created_at=now,
updated_at=now,
)
@@ -586,6 +627,7 @@ class ProfileService:
git_email: Optional[str],
start_page: str,
dashboards_table_density: str,
email_address: Optional[str] = None,
) -> List[str]:
errors: List[str] = []
sanitized_username = self._sanitize_username(superset_username)
@@ -613,6 +655,16 @@ class ProfileService:
if dashboards_table_density not in SUPPORTED_DENSITIES:
errors.append("Dashboards table density value is not supported.")
sanitized_email = self._sanitize_text(email_address)
if sanitized_email:
if (
" " in sanitized_email
or "@" not in sanitized_email
or sanitized_email.startswith("@")
or sanitized_email.endswith("@")
):
errors.append("Notification email should be a valid email address.")
return errors
# [/DEF:_validate_update_payload:Function]
@@ -672,4 +724,4 @@ class ProfileService:
# [/DEF:_normalize_owner_tokens:Function]
# [/DEF:ProfileService:Class]
# [/DEF:backend.src.services.profile_service:Module]
# [/DEF:backend.src.services.profile_service:Module]

View File

@@ -0,0 +1,3 @@
# [DEF:src.services.reports:Package]
# @PURPOSE: Report service package root.
# [/DEF:src.services.reports:Package]

View File

@@ -46,10 +46,11 @@ class ResourceService:
env: Any,
tasks: Optional[List[Task]] = None,
include_git_status: bool = True,
require_slug: bool = False,
) -> List[Dict[str, Any]]:
with belief_scope("get_dashboards_with_status", f"env={env.id}"):
client = SupersetClient(env)
dashboards = client.get_dashboards_summary()
dashboards = client.get_dashboards_summary(require_slug=require_slug)
# Enhance each dashboard with Git status and task status
result = []
@@ -96,6 +97,7 @@ class ResourceService:
page_size: int = 10,
search: Optional[str] = None,
include_git_status: bool = True,
require_slug: bool = False,
) -> Dict[str, Any]:
with belief_scope(
"get_dashboards_page_with_status",
@@ -106,6 +108,7 @@ class ResourceService:
page=page,
page_size=page_size,
search=search,
require_slug=require_slug,
)
result = []

View File

@@ -8,13 +8,13 @@
from types import SimpleNamespace
import json
from backend.src.dependencies import get_clean_release_repository, get_config_manager
from src.dependencies import get_clean_release_repository, get_config_manager
from datetime import datetime, timezone
from uuid import uuid4
from backend.src.models.clean_release import CleanPolicySnapshot, ComplianceReport, ReleaseCandidate, SourceRegistrySnapshot
from backend.src.services.clean_release.enums import CandidateStatus, ComplianceDecision
from backend.src.scripts.clean_release_cli import main as cli_main
from src.models.clean_release import CleanPolicySnapshot, ComplianceReport, ReleaseCandidate, SourceRegistrySnapshot
from src.services.clean_release.enums import CandidateStatus, ComplianceDecision
from src.scripts.clean_release_cli import main as cli_main
def test_cli_candidate_register_scaffold() -> None:
@@ -302,4 +302,4 @@ def test_cli_release_gate_commands_scaffold() -> None:
assert revoke_exit == 0
# [/DEF:test_clean_release_cli:Module]
# [/DEF:test_clean_release_cli:Module]

View File

@@ -14,8 +14,8 @@ from unittest.mock import MagicMock, patch
import pytest
from backend.src.scripts.clean_release_tui import CleanReleaseTUI, main, tui_main
from backend.src.models.clean_release import CheckFinalStatus
from src.scripts.clean_release_tui import CleanReleaseTUI, main, tui_main
from src.models.clean_release import CheckFinalStatus
@pytest.fixture
@@ -31,7 +31,7 @@ def test_headless_fallback(capsys):
@TEST_EDGE: stdout_unavailable
Tests that non-TTY startup is explicitly refused and wrapper is not invoked.
"""
with mock.patch("backend.src.scripts.clean_release_tui.curses.wrapper") as curses_wrapper_mock:
with mock.patch("src.scripts.clean_release_tui.curses.wrapper") as curses_wrapper_mock:
with mock.patch("sys.stdout.isatty", return_value=False):
exit_code = main()
@@ -43,7 +43,7 @@ def test_headless_fallback(capsys):
assert "Use CLI/API workflow instead" in captured.err
@patch("backend.src.scripts.clean_release_tui.curses")
@patch("src.scripts.clean_release_tui.curses")
def test_tui_initial_render(mock_curses_module, mock_stdscr: MagicMock):
"""
Simulates the initial rendering cycle of the TUI application to ensure
@@ -76,7 +76,7 @@ def test_tui_initial_render(mock_curses_module, mock_stdscr: MagicMock):
assert any("F5 Run" in str(call) for call in addstr_calls)
@patch("backend.src.scripts.clean_release_tui.curses")
@patch("src.scripts.clean_release_tui.curses")
def test_tui_run_checks_f5(mock_curses_module, mock_stdscr: MagicMock):
"""
Simulates pressing F5 to transition into the RUNNING checks flow.
@@ -111,7 +111,7 @@ def test_tui_run_checks_f5(mock_curses_module, mock_stdscr: MagicMock):
assert len(app.violations_list) > 0
@patch("backend.src.scripts.clean_release_tui.curses")
@patch("src.scripts.clean_release_tui.curses")
def test_tui_exit_f10(mock_curses_module, mock_stdscr: MagicMock):
"""
Simulates pressing F10 to exit the application immediately without running checks.
@@ -128,7 +128,7 @@ def test_tui_exit_f10(mock_curses_module, mock_stdscr: MagicMock):
assert app.status == "READY"
@patch("backend.src.scripts.clean_release_tui.curses")
@patch("src.scripts.clean_release_tui.curses")
def test_tui_clear_history_f7(mock_curses_module, mock_stdscr: MagicMock):
"""
Simulates pressing F7 to clear history.
@@ -153,4 +153,3 @@ def test_tui_clear_history_f7(mock_curses_module, mock_stdscr: MagicMock):
# [/DEF:backend.tests.scripts.test_clean_release_tui:Module]

View File

@@ -11,8 +11,8 @@ from __future__ import annotations
import curses
from unittest.mock import MagicMock, patch
from backend.src.models.clean_release import CheckFinalStatus
from backend.src.scripts.clean_release_tui import CleanReleaseTUI, main
from src.models.clean_release import CheckFinalStatus
from src.scripts.clean_release_tui import CleanReleaseTUI, main
def _build_mock_stdscr() -> MagicMock:
@@ -22,7 +22,7 @@ def _build_mock_stdscr() -> MagicMock:
return stdscr
@patch("backend.src.scripts.clean_release_tui.curses")
@patch("src.scripts.clean_release_tui.curses")
def test_tui_f5_dispatches_run_action(mock_curses_module: MagicMock) -> None:
"""F5 should dispatch run action from TUI loop."""
mock_curses_module.KEY_F10 = curses.KEY_F10
@@ -40,7 +40,7 @@ def test_tui_f5_dispatches_run_action(mock_curses_module: MagicMock) -> None:
run_checks_mock.assert_called_once_with()
@patch("backend.src.scripts.clean_release_tui.curses")
@patch("src.scripts.clean_release_tui.curses")
def test_tui_f5_run_smoke_reports_blocked_state(mock_curses_module: MagicMock) -> None:
"""F5 smoke test should expose blocked outcome state after run action."""
mock_curses_module.KEY_F10 = curses.KEY_F10
@@ -76,7 +76,7 @@ def test_tui_non_tty_refuses_startup(capsys) -> None:
assert "Use CLI/API workflow instead" in captured.err
@patch("backend.src.scripts.clean_release_tui.curses")
@patch("src.scripts.clean_release_tui.curses")
def test_tui_f8_blocked_without_facade_binding(mock_curses_module: MagicMock) -> None:
"""F8 should not perform hidden state mutation when facade action is not bound."""
mock_curses_module.KEY_F10 = curses.KEY_F10
@@ -94,4 +94,4 @@ def test_tui_f8_blocked_without_facade_binding(mock_curses_module: MagicMock) ->
assert "F8 disabled" in app.last_error
# [/DEF:test_clean_release_tui_v2:Module]
# [/DEF:test_clean_release_tui_v2:Module]

View File

@@ -9,10 +9,10 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from src.core.database import Base
from src.models.clean_release import ReleaseCandidate, DistributionManifest, CandidateArtifact
from backend.src.services.clean_release.enums import CandidateStatus
from backend.src.services.clean_release.candidate_service import register_candidate
from backend.src.services.clean_release.manifest_service import build_manifest_snapshot
from backend.src.services.clean_release.repository import CleanReleaseRepository
from src.services.clean_release.enums import CandidateStatus
from src.services.clean_release.candidate_service import register_candidate
from src.services.clean_release.manifest_service import build_manifest_snapshot
from src.services.clean_release.repository import CleanReleaseRepository
@pytest.fixture
def db_session():
@@ -43,7 +43,7 @@ def test_candidate_lifecycle_transitions(db_session):
assert candidate.status == CandidateStatus.PREPARED
# Invalid transition: PREPARED -> DRAFT (should raise IllegalTransitionError)
from backend.src.services.clean_release.exceptions import IllegalTransitionError
from src.services.clean_release.exceptions import IllegalTransitionError
with pytest.raises(IllegalTransitionError, match="Forbidden transition"):
candidate.transition_to(CandidateStatus.DRAFT)
@@ -200,4 +200,4 @@ def test_manifest_service_rejects_missing_candidate():
with pytest.raises(ValueError, match="not found"):
build_manifest_snapshot(repository=repository, candidate_id="missing-candidate", created_by="operator")
# [/DEF:test_candidate_manifest_services:Module]
# [/DEF:test_candidate_manifest_services:Module]

View File

@@ -13,17 +13,17 @@ from datetime import datetime, timezone
import pytest
from backend.src.models.clean_release import (
from src.models.clean_release import (
CleanPolicySnapshot,
ComplianceDecision,
DistributionManifest,
ReleaseCandidate,
SourceRegistrySnapshot,
)
from backend.src.services.clean_release.compliance_orchestrator import CleanComplianceOrchestrator
from backend.src.services.clean_release.enums import CandidateStatus, RunStatus
from backend.src.services.clean_release.report_builder import ComplianceReportBuilder
from backend.src.services.clean_release.repository import CleanReleaseRepository
from src.services.clean_release.compliance_orchestrator import CleanComplianceOrchestrator
from src.services.clean_release.enums import CandidateStatus, RunStatus
from src.services.clean_release.report_builder import ComplianceReportBuilder
from src.services.clean_release.repository import CleanReleaseRepository
# [DEF:_seed_with_candidate_policy_registry:Function]
@@ -170,4 +170,4 @@ def test_blocked_run_finalization_blocks_report_builder():
builder.build_report_payload(run, [])
# [/DEF:test_blocked_run_finalization_blocks_report_builder:Function]
# [/DEF:backend.tests.services.clean_release.test_compliance_execution_service:Module]
# [/DEF:backend.tests.services.clean_release.test_compliance_execution_service:Module]

View File

@@ -151,8 +151,8 @@ class _PluginLoaderStub:
def _make_task_manager() -> TaskManager:
plugin_loader = _PluginLoaderStub(CleanReleaseCompliancePlugin())
with patch("backend.src.core.task_manager.manager.TaskPersistenceService") as mock_persistence, patch(
"backend.src.core.task_manager.manager.TaskLogPersistenceService"
with patch("src.core.task_manager.manager.TaskPersistenceService") as mock_persistence, patch(
"src.core.task_manager.manager.TaskLogPersistenceService"
) as mock_log_persistence:
mock_persistence.return_value.load_tasks.return_value = []
mock_persistence.return_value.persist_task = MagicMock()
@@ -247,4 +247,4 @@ async def test_compliance_run_missing_manifest_marks_task_failed():
manager._flusher_thread.join(timeout=2)
# [/DEF:test_compliance_run_missing_manifest_marks_task_failed:Function]
# [/DEF:backend.tests.services.clean_release.test_compliance_task_integration:Module]
# [/DEF:backend.tests.services.clean_release.test_compliance_task_integration:Module]

View File

@@ -9,8 +9,8 @@ from __future__ import annotations
from datetime import datetime, timezone
from backend.src.models.clean_release import ReleaseCandidate
from backend.src.services.clean_release.demo_data_service import (
from src.models.clean_release import ReleaseCandidate
from src.services.clean_release.demo_data_service import (
build_namespaced_id,
create_isolated_repository,
resolve_namespace,
@@ -84,4 +84,4 @@ def test_create_isolated_repository_keeps_mode_data_separate() -> None:
assert real_repo.get_candidate(demo_candidate_id) is None
# [/DEF:test_create_isolated_repository_keeps_mode_data_separate:Function]
# [/DEF:backend.tests.services.clean_release.test_demo_mode_isolation:Module]
# [/DEF:backend.tests.services.clean_release.test_demo_mode_isolation:Module]

View File

@@ -14,10 +14,10 @@ from types import SimpleNamespace
import pytest
from backend.src.models.clean_release import CleanPolicySnapshot, SourceRegistrySnapshot
from backend.src.services.clean_release.exceptions import PolicyResolutionError
from backend.src.services.clean_release.policy_resolution_service import resolve_trusted_policy_snapshots
from backend.src.services.clean_release.repository import CleanReleaseRepository
from src.models.clean_release import CleanPolicySnapshot, SourceRegistrySnapshot
from src.services.clean_release.exceptions import PolicyResolutionError
from src.services.clean_release.policy_resolution_service import resolve_trusted_policy_snapshots
from src.services.clean_release.repository import CleanReleaseRepository
# [DEF:_config_manager:Function]
@@ -102,4 +102,4 @@ def test_resolve_trusted_policy_snapshots_rejects_override_attempt():
)
# [/DEF:test_resolve_trusted_policy_snapshots_rejects_override_attempt:Function]
# [/DEF:backend.tests.services.clean_release.test_policy_resolution_service:Module]
# [/DEF:backend.tests.services.clean_release.test_policy_resolution_service:Module]

View File

@@ -0,0 +1,57 @@
services:
db:
image: ${POSTGRES_IMAGE:?Set POSTGRES_IMAGE in .env.enterprise-clean}
pull_policy: never
container_name: ss_tools_db
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-ss_tools}
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env.enterprise-clean}
ports:
- "${POSTGRES_HOST_PORT:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-ss_tools}"]
interval: 10s
timeout: 5s
retries: 10
backend:
image: ${BACKEND_IMAGE:?Set BACKEND_IMAGE in .env.enterprise-clean}
pull_policy: never
container_name: ss_tools_backend
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-ss_tools}
TASKS_DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-ss_tools}
AUTH_DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-ss_tools}
BACKEND_PORT: 8000
ENABLE_BELIEF_STATE_LOGGING: ${ENABLE_BELIEF_STATE_LOGGING:-true}
TASK_LOG_LEVEL: ${TASK_LOG_LEVEL:-INFO}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
ports:
- "${BACKEND_HOST_PORT:-8001}:8000"
volumes:
- ./config.json:/app/config.json:ro
- ./backups:/app/backups
- ./backend/git_repos:/app/backend/git_repos
- ${STORAGE_ROOT:-./storage}:/app/storage
frontend:
image: ${FRONTEND_IMAGE:?Set FRONTEND_IMAGE in .env.enterprise-clean}
pull_policy: never
container_name: ss_tools_frontend
restart: unless-stopped
depends_on:
- backend
ports:
- "${FRONTEND_HOST_PORT:-8000}:80"
volumes:
postgres_data:

View File

@@ -6,6 +6,7 @@
- [Требования](#требования)
- [Установка через Docker](#установка-через-docker)
- [Offline Docker Bundle](#offline-docker-bundle)
- [Локальная установка](#локальная-установка)
- [Первая настройка](#первая-настройка)
- [Конфигурация окружений](#конфигурация-окружений)
@@ -143,6 +144,59 @@ createdb ss_tools
psql -U postgres -d ss_tools
```
## Offline Docker Bundle
Этот режим предназначен для enterprise clean-развёртывания в контуре без доступа к внешнему интернету.
### 1. Сборка bundle в подключённом контуре
```bash
cd /home/busya/dev/ss-tools
./scripts/build_offline_docker_bundle.sh v1.0.0-rc2
```
Результат появится в `dist/docker/`:
- `backend.v1.0.0-rc2.tar`
- `frontend.v1.0.0-rc2.tar`
- `postgres.v1.0.0-rc2.tar`
- `sha256sums.v1.0.0-rc2.txt`
- `manifest.v1.0.0-rc2.txt`
- `docker-compose.enterprise-clean.yml`
- `.env.enterprise-clean.example`
### 2. Перенос bundle в изолированный контур
Передайте каталог `dist/docker/` во внутреннюю сеть любым утверждённым способом.
### 3. Импорт образов
```bash
docker load -i backend.v1.0.0-rc2.tar
docker load -i frontend.v1.0.0-rc2.tar
docker load -i postgres.v1.0.0-rc2.tar
```
### 4. Подготовка конфигурации
```bash
cp .env.enterprise-clean.example .env.enterprise-clean
```
Минимально проверьте:
- `BACKEND_IMAGE`
- `FRONTEND_IMAGE`
- `POSTGRES_IMAGE`
- `POSTGRES_PASSWORD`
- `STORAGE_ROOT`
### 5. Запуск в offline-контуре
```bash
docker compose --env-file .env.enterprise-clean -f docker-compose.enterprise-clean.yml up -d
```
Compose-файл использует `pull_policy: never`, поэтому runtime не должен обращаться к внешним registry.
## Первая настройка
### 1. Инициализация базы данных

View File

@@ -1,219 +0,0 @@
# Plugin Development Guide
This guide explains how to create new plugins for the Superset Tools application.
## 1. Plugin Structure
A plugin is a single Python file located in the `backend/src/plugins/` directory. Each plugin file must contain a class that inherits from `PluginBase`.
## 2. Implementing `PluginBase`
The `PluginBase` class is an abstract base class that defines the interface for all plugins. You must implement the following properties and methods:
- **`id`**: A unique string identifier for your plugin (e.g., `"my-cool-plugin"`).
- **`name`**: A human-readable name for your plugin (e.g., `"My Cool Plugin"`).
- **`description`**: A brief description of what your plugin does.
- **`version`**: The version of your plugin (e.g., `"1.0.0"`).
- **`get_schema()`**: A method that returns a JSON schema dictionary defining the input parameters for your plugin. This schema is used to automatically generate a form in the frontend.
- **`execute(params: Dict[str, Any])`**: An `async` method that contains the main logic of your plugin. The `params` argument is a dictionary containing the input data from the user, validated against the schema you defined.
## 3. Example Plugin
Here is an example of a simple "Hello World" plugin:
```python
# backend/src/plugins/hello.py
# [DEF:HelloWorldPlugin:Plugin]
# @SEMANTICS: hello, world, example, plugin
# @PURPOSE: A simple "Hello World" plugin example.
# @LAYER: Domain (Plugin)
# @RELATION: Inherits from PluginBase
# @PUBLIC_API: execute
from typing import Dict, Any
from ..core.plugin_base import PluginBase
class HelloWorldPlugin(PluginBase):
@property
def id(self) -> str:
return "hello-world"
@property
def name(self) -> str:
return "Hello World"
@property
def description(self) -> str:
return "A simple plugin that prints a greeting."
@property
def version(self) -> str:
return "1.0.0"
def get_schema(self) -> Dict[str, Any]:
return {
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Name",
"description": "The name to greet.",
"default": "World",
}
},
"required": ["name"],
}
async def execute(self, params: Dict[str, Any], context: Optional[TaskContext] = None):
name = params["name"]
if context:
context.logger.info(f"Hello, {name}!")
else:
print(f"Hello, {name}!")
```
## 4. Logging with TaskContext
Plugins now support TaskContext for structured logging with source attribution. The `context` parameter provides access to a logger that automatically tags logs with the task ID and a source identifier.
### 4.1. Basic Logging
Use `context.logger` to log messages with automatic source attribution:
```python
from typing import Dict, Any, Optional
from ..core.plugin_base import PluginBase
from ..core.task_manager.context import TaskContext
async def execute(self, params: Dict[str, Any], context: Optional[TaskContext] = None):
if context:
# Use TaskContext logger for structured logging
context.logger.info("My plugin is running!")
else:
# Fallback to global logger for backward compatibility
from ..core.logger import logger
logger.info("My plugin is running!")
```
### 4.2. Source Attribution
For better log organization, create sub-loggers for different components:
```python
async def execute(self, params: Dict[str, Any], context: Optional[TaskContext] = None):
if context:
# Create sub-loggers for different components
api_log = context.logger.with_source("api")
storage_log = context.logger.with_source("storage")
api_log.info("Connecting to API...")
storage_log.info("Saving file...")
else:
# Fallback to global logger
from ..core.logger import logger
logger.info("My plugin is running!")
```
### 4.3. Log Levels
The logger supports standard log levels. Use them appropriately:
| Level | Usage |
|-------|-------|
| `DEBUG` | Detailed diagnostic information (API responses, internal state). Only visible when log level is set to DEBUG. |
| `INFO` | General operational messages (start/complete notifications, progress updates). |
| `WARNING` | Non-critical issues that don't stop execution (deprecated APIs, retry attempts). |
| `ERROR` | Failures that prevent an operation from completing (API errors, validation failures). |
```python
# Good: Use DEBUG for verbose diagnostic info
api_log.debug(f"API response: {response.json()}")
# Good: Use INFO for operational milestones
log.info(f"Starting backup for environment: {env}")
# Good: Use WARNING for recoverable issues
log.warning(f"Rate limit hit, retrying in {delay}s")
# Good: Use ERROR for failures
log.error(f"Failed to connect to database: {e}")
```
### 4.4. Progress Logging
For operations that report progress, use the `progress` method:
```python
async def execute(self, params: Dict[str, Any], context: Optional[TaskContext] = None):
if context:
total_items = 100
for i, item in enumerate(items):
# Report progress with percentage
percent = (i + 1) / total_items * 100
context.logger.progress(f"Processing {item}", percent=percent)
else:
# Fallback
from ..core.logger import logger
logger.info("My plugin is running!")
```
### 4.5. Logging with Metadata
You can include structured metadata with log entries:
```python
async def execute(self, params: Dict[str, Any], context: Optional[TaskContext] = None):
if context:
context.logger.error(
"Operation failed",
metadata={"error_code": 500, "details": "Connection timeout"}
)
else:
from ..core.logger import logger
logger.error("Operation failed")
```
### 4.6. Common Source Names
For consistency across plugins, use these standard source names:
| Source | Usage |
|--------|-------|
| `superset_api` | Superset REST API calls |
| `postgres` | PostgreSQL database operations |
| `storage` | File system operations |
| `git` | Git operations |
| `llm` | LLM API calls |
| `screenshot` | Screenshot capture operations |
| `migration` | Migration-specific logic |
| `backup` | Backup operations |
| `debug` | Debug/diagnostic operations |
| `search` | Search operations |
### 4.7. Best Practices
1. **Always check for context**: Support backward compatibility by checking if `context` is available:
```python
log = context.logger if context else logger
```
2. **Use source attribution**: Create sub-loggers for different components to make filtering easier in the UI.
3. **Use appropriate log levels**:
- `DEBUG`: Verbose diagnostic info (API responses, internal state)
- `INFO`: Operational milestones (start, complete, progress)
- `WARNING`: Recoverable issues (rate limits, deprecated APIs)
- `ERROR`: Failures that stop an operation
4. **Log progress for long operations**: Use `progress()` for operations that take time:
```python
for i, item in enumerate(items):
percent = (i + 1) / len(items) * 100
log.progress(f"Processing {item}", percent=percent)
```
5. **Keep DEBUG logs verbose, INFO logs concise**: DEBUG logs can include full API responses, while INFO logs should be one-line summaries.
## 5. Testing
To test your plugin, simply run the application and navigate to the web UI. Your plugin should appear in the list of available tools.

View File

@@ -11,7 +11,6 @@
<script lang="ts">
// [SECTION: IMPORTS]
import { createEventDispatcher } from "svelte";
import type { DashboardMetadata } from "../types/dashboard";
import { t } from "../lib/i18n";
import { Button, Input } from "../lib/ui";
@@ -108,10 +107,6 @@
);
// [/SECTION]
// [SECTION: EVENTS]
const dispatch = createEventDispatcher<{ selectionChanged: number[] }>();
// [/SECTION]
// [DEF:handleSort:Function]
// @PURPOSE: Toggles sort direction or changes sort column.
// @PRE: column name is provided.
@@ -129,7 +124,7 @@
// [DEF:handleSelectionChange:Function]
// @PURPOSE: Handles individual checkbox changes.
// @PRE: dashboard ID and checked status provided.
// @POST: selectedIds array updated and selectionChanged event dispatched.
// @POST: selectedIds array updated.
function handleSelectionChange(id: number, checked: boolean) {
let newSelected = [...selectedIds];
if (checked) {
@@ -138,14 +133,13 @@
newSelected = newSelected.filter((sid) => sid !== id);
}
selectedIds = newSelected;
dispatch("selectionChanged", newSelected);
}
// [/DEF:handleSelectionChange:Function]
// [DEF:handleSelectAll:Function]
// @PURPOSE: Handles select all checkbox.
// @PRE: checked status provided.
// @POST: selectedIds array updated for all paginated items and event dispatched.
// @POST: selectedIds array updated for all paginated items.
function handleSelectAll(checked: boolean) {
let newSelected = [...selectedIds];
if (checked) {
@@ -158,7 +152,6 @@
});
}
selectedIds = newSelected;
dispatch("selectionChanged", newSelected);
}
// [/DEF:handleSelectAll:Function]
@@ -286,7 +279,7 @@
<Button
variant="secondary"
size="sm"
on:click={() => handleValidate(dashboard)}
onclick={() => handleValidate(dashboard)}
disabled={validatingIds.has(dashboard.id)}
class="text-purple-600 hover:text-purple-900"
>
@@ -297,7 +290,7 @@
<Button
variant="ghost"
size="sm"
on:click={() => openGit(dashboard)}
onclick={() => openGit(dashboard)}
class="text-blue-600 hover:text-blue-900"
>
{$t.git.manage}
@@ -328,7 +321,7 @@
variant="secondary"
size="sm"
disabled={currentPage === 0}
on:click={() => goToPage(currentPage - 1)}
onclick={() => goToPage(currentPage - 1)}
>
{$t.dashboard.previous}
</Button>
@@ -336,7 +329,7 @@
variant="secondary"
size="sm"
disabled={currentPage >= totalPages - 1}
on:click={() => goToPage(currentPage + 1)}
onclick={() => goToPage(currentPage + 1)}
>
{$t.dashboard.next}
</Button>

View File

@@ -3,7 +3,7 @@
@SEMANTICS: form, schema, dynamic, json-schema
@PURPOSE: Generates a form dynamically based on a JSON schema.
@LAYER: UI
@RELATION: DEPENDS_ON -> svelte:createEventDispatcher
@RELATION: BINDS_TO -> onsubmit callback
@PROPS:
- schema: Object - JSON schema for the form.
@@ -11,27 +11,22 @@
- submit: Object - Dispatched when the form is submitted, containing the form data.
-->
<script>
// [SECTION: IMPORTS]
import { createEventDispatcher } from 'svelte';
// [/SECTION]
let {
schema,
onsubmit = () => {},
} = $props();
let formData = {};
const dispatch = createEventDispatcher();
// [DEF:handleSubmit:Function]
/**
* @purpose Dispatches the submit event with the form data.
* @purpose Emits submitted form data via callback prop.
* @pre formData contains user input.
* @post 'submit' event is dispatched with formData.
* @post Parent callback receives formData snapshot.
*/
function handleSubmit() {
console.log("[DynamicForm][Action] Submitting form data.", { formData });
dispatch('submit', formData);
onsubmit(formData);
}
// [/DEF:handleSubmit:Function]
@@ -54,7 +49,7 @@
</script>
<!-- [SECTION: TEMPLATE] -->
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<form onsubmit={(event) => { event.preventDefault(); handleSubmit(); }} class="space-y-4">
{#if schema && schema.properties}
{#each Object.entries(schema.properties) as [key, prop]}
<div class="flex flex-col">

View File

@@ -10,7 +10,6 @@
<script lang="ts">
// [SECTION: IMPORTS]
import { onMount, createEventDispatcher } from 'svelte';
import { t } from '../lib/i18n';
// [/SECTION]
@@ -19,23 +18,22 @@
label = "",
selectedId = "",
environments = [],
onchange = () => {},
} = $props();
// [/SECTION]
const dispatch = createEventDispatcher();
// [DEF:handleSelect:Function]
/**
* @purpose Dispatches the selection change event.
* @purpose Propagates the selection change through a callback prop.
* @pre event.target must be an HTMLSelectElement.
* @post selectedId is updated and 'change' event is dispatched.
* @post selectedId is updated and parent callback receives the selected environment id.
* @param {Event} event - The change event from the select element.
*/
function handleSelect(event: Event) {
const target = event.target as HTMLSelectElement;
selectedId = target.value;
dispatch('change', { id: selectedId });
onchange({ id: selectedId });
}
// [/DEF:handleSelect:Function]
</script>
@@ -47,7 +45,7 @@
id="env-select"
class="block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
value={selectedId}
on:change={handleSelect}
onchange={handleSelect}
>
<option value="" disabled>{$t.common?.choose_environment}</option>
{#each environments as env}

View File

@@ -10,7 +10,6 @@
<script lang="ts">
// [SECTION: IMPORTS]
import { createEventDispatcher } from 'svelte';
import { t } from '$lib/i18n';
// [/SECTION]
@@ -20,23 +19,22 @@
targetDatabases = [],
mappings = [],
suggestions = [],
onupdate = () => {},
} = $props();
// [/SECTION]
const dispatch = createEventDispatcher();
// [DEF:updateMapping:Function]
/**
* @purpose Updates a mapping for a specific source database.
* @pre sourceUuid and targetUuid are provided.
* @post 'update' event is dispatched.
* @post Parent callback receives normalized mapping payload.
*/
function updateMapping(sourceUuid: string, targetUuid: string) {
const sDb = sourceDatabases.find(d => d.uuid === sourceUuid);
const tDb = targetDatabases.find(d => d.uuid === targetUuid);
dispatch('update', {
onupdate({
sourceUuid,
targetUuid,
sourceName: sDb?.database_name || "",
@@ -80,7 +78,7 @@
<select
class="block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
value={mapping?.target_db_uuid || suggestion?.target_db_uuid || ""}
on:change={(e) => updateMapping(sDb.uuid, (e.target as HTMLSelectElement).value)}
onchange={(e) => updateMapping(sDb.uuid, (e.target as HTMLSelectElement).value)}
>
<option value="">{$t.migration?.target_env }</option>
{#each targetDatabases as tDb}

View File

@@ -3,36 +3,34 @@
@SEMANTICS: modal, mapping, prompt, migration
@PURPOSE: Prompts the user to provide a database mapping when one is missing during migration.
@LAYER: Feature
@RELATION: DISPATCHES -> resolve
@RELATION: BINDS_TO -> onresolve
@RELATION: BINDS_TO -> oncancel
@INVARIANT: Modal blocks migration progress until resolved or cancelled.
-->
<script lang="ts">
// [SECTION: IMPORTS]
import { createEventDispatcher } from 'svelte';
// [/SECTION]
// [SECTION: PROPS]
let {
show = false,
sourceDbName = "",
sourceDbUuid = "",
targetDatabases = [],
onresolve = () => {},
oncancel = () => {},
} = $props();
// [/SECTION]
let selectedTargetUuid = $state("");
const dispatch = createEventDispatcher();
// [DEF:resolve:Function]
// @PURPOSE: Dispatches the resolution event with the selected mapping.
// @PURPOSE: Resolves the missing mapping via callback prop.
// @PRE: selectedTargetUuid must be set.
// @POST: 'resolve' event is dispatched and modal is hidden.
// @POST: Parent callback receives mapping payload and modal closes.
function resolve() {
if (!selectedTargetUuid) return;
dispatch('resolve', {
onresolve({
sourceDbUuid,
targetDbUuid: selectedTargetUuid,
targetDbName: targetDatabases.find(d => d.uuid === selectedTargetUuid)?.database_name
@@ -44,9 +42,9 @@
// [DEF:cancel:Function]
// @PURPOSE: Cancels the mapping resolution modal.
// @PRE: Modal is open.
// @POST: 'cancel' event is dispatched and modal is hidden.
// @POST: Parent cancel callback is invoked and modal is hidden.
function cancel() {
dispatch('cancel');
oncancel();
show = false;
}
// [/DEF:cancel:Function]

View File

@@ -4,15 +4,18 @@
@SEMANTICS: navbar, navigation, header, layout
@PURPOSE: Main navigation bar for the application.
@LAYER: UI
@RELATION: USES -> $app/stores
@RELATION: USES -> $app/state
-->
<script>
import { page } from '$app/stores';
import { page } from '$app/state';
import { fromStore } from 'svelte/store';
import { t } from '$lib/i18n';
import { LanguageSwitcher } from '$lib/ui';
import { auth } from '$lib/auth/store';
import { goto } from '$app/navigation';
const authState = fromStore(auth);
function handleLogout() {
auth.logout();
goto('/login');
@@ -29,18 +32,18 @@
<nav class="flex items-center space-x-4">
<a
href="/"
class="text-gray-600 hover:text-blue-600 font-medium {$page.url.pathname === '/' ? 'text-blue-600 border-b-2 border-blue-600' : ''}"
class="text-gray-600 hover:text-blue-600 font-medium {page.url.pathname === '/' ? 'text-blue-600 border-b-2 border-blue-600' : ''}"
>
{$t.nav.dashboard}
</a>
<a
href="/reports"
class="text-gray-600 hover:text-blue-600 font-medium {$page.url.pathname.startsWith('/reports') ? 'text-blue-600 border-b-2 border-blue-600' : ''}"
class="text-gray-600 hover:text-blue-600 font-medium {page.url.pathname.startsWith('/reports') ? 'text-blue-600 border-b-2 border-blue-600' : ''}"
>
{$t.nav.reports}
</a>
<div class="relative inline-block group">
<button class="text-gray-600 hover:text-blue-600 font-medium pb-1 {$page.url.pathname.startsWith('/settings') ? 'text-blue-600 border-b-2 border-blue-600' : ''}">
<button class="text-gray-600 hover:text-blue-600 font-medium pb-1 {page.url.pathname.startsWith('/settings') ? 'text-blue-600 border-b-2 border-blue-600' : ''}">
{$t.nav.settings}
</button>
<div class="absolute hidden group-hover:block bg-white shadow-lg rounded-md mt-1 py-2 w-48 z-10 border border-gray-100 before:absolute before:-top-2 before:left-0 before:right-0 before:h-2 before:content-[''] right-0">
@@ -50,9 +53,9 @@
</div>
</div>
{#if $auth.isAuthenticated && $auth.user?.roles?.some(r => r.name === 'Admin')}
{#if authState.current?.isAuthenticated && authState.current?.user?.roles?.some(r => r.name === 'Admin')}
<div class="relative inline-block group">
<button class="text-gray-600 hover:text-blue-600 font-medium pb-1 {$page.url.pathname.startsWith('/admin') ? 'text-blue-600 border-b-2 border-blue-600' : ''}">
<button class="text-gray-600 hover:text-blue-600 font-medium pb-1 {page.url.pathname.startsWith('/admin') ? 'text-blue-600 border-b-2 border-blue-600' : ''}">
{$t.nav.admin}
</button>
<div class="absolute hidden group-hover:block bg-white shadow-lg rounded-md mt-1 py-2 w-48 z-10 border border-gray-100 before:absolute before:-top-2 before:left-0 before:right-0 before:h-2 before:content-[''] right-0">
@@ -65,11 +68,11 @@
{/if}
<LanguageSwitcher />
{#if $auth.isAuthenticated}
{#if authState.current?.isAuthenticated}
<div class="flex items-center space-x-2 border-l pl-4 ml-4">
<span class="text-sm text-gray-600">{$auth.user?.username}</span>
<span class="text-sm text-gray-600">{authState.current?.user?.username}</span>
<button
on:click={handleLogout}
onclick={handleLogout}
class="text-sm text-red-600 hover:text-red-800 font-medium"
>
Logout

View File

@@ -4,22 +4,25 @@
@PURPOSE: A modal component to prompt the user for database passwords when a migration task is paused.
@LAYER: UI
@RELATION: USES -> frontend/src/lib/api.js (inferred)
@RELATION: EMITS -> resume, cancel
@RELATION: BINDS_TO -> onresume
@RELATION: BINDS_TO -> oncancel
-->
<script>
import { createEventDispatcher } from "svelte";
let { show = false, databases = [], errorMessage = "" } = $props();
const dispatch = createEventDispatcher();
let {
show = false,
databases = [],
errorMessage = "",
onresume = () => {},
oncancel = () => {},
} = $props();
let passwords = $state({});
let submitting = $state(false);
// [DEF:handleSubmit:Function]
// @PURPOSE: Validates and dispatches the passwords to resume the task.
// @PURPOSE: Validates and forwards passwords to resume the task.
// @PRE: All database passwords must be entered.
// @POST: 'resume' event is dispatched with passwords.
// @POST: Parent resume callback receives passwords payload.
function handleSubmit() {
if (submitting) return;
@@ -31,7 +34,7 @@
}
submitting = true;
dispatch("resume", { passwords });
onresume({ passwords });
// Reset submitting state is handled by parent or on close
}
// [/DEF:handleSubmit:Function]
@@ -39,9 +42,9 @@
// [DEF:handleCancel:Function]
// @PURPOSE: Cancels the password prompt.
// @PRE: Modal is open.
// @POST: 'cancel' event is dispatched and show is set to false.
// @POST: Parent cancel callback is invoked and show is set to false.
function handleCancel() {
dispatch("cancel");
oncancel();
show = false;
}
// [/DEF:handleCancel:Function]

View File

@@ -11,7 +11,7 @@
<script lang="ts">
// [SECTION: IMPORTS]
import { createEventDispatcher, untrack } from "svelte";
import { untrack } from "svelte";
import type { DashboardMetadata } from "../types/dashboard";
import { t } from "../lib/i18n";
import { Button, Input } from "../lib/ui";
@@ -96,10 +96,6 @@
);
// [/SECTION]
// [SECTION: EVENTS]
const dispatch = createEventDispatcher<{ selectionChanged: number[] }>();
// [/SECTION]
// [DEF:handleSort:Function]
// @PURPOSE: Toggles sort direction or changes sort column.
// @PRE: column name is provided.
@@ -117,7 +113,7 @@
// [DEF:handleSelectionChange:Function]
// @PURPOSE: Handles individual checkbox changes.
// @PRE: dashboard ID and checked status provided.
// @POST: selectedIds array updated and selectionChanged event dispatched.
// @POST: selectedIds array updated.
function handleSelectionChange(id: number, checked: boolean) {
let newSelected = [...selectedIds];
if (checked) {
@@ -126,14 +122,13 @@
newSelected = newSelected.filter((sid) => sid !== id);
}
selectedIds = newSelected;
dispatch("selectionChanged", newSelected);
}
// [/DEF:handleSelectionChange:Function]
// [DEF:handleSelectAll:Function]
// @PURPOSE: Handles select all checkbox.
// @PRE: checked status provided.
// @POST: selectedIds array updated for all paginated items and event dispatched.
// @POST: selectedIds array updated for all paginated items.
function handleSelectAll(checked: boolean) {
let newSelected = [...selectedIds];
if (checked) {
@@ -146,7 +141,6 @@
});
}
selectedIds = newSelected;
dispatch("selectionChanged", newSelected);
}
// [/DEF:handleSelectAll:Function]
@@ -379,7 +373,6 @@
);
dashboards = dashboards.filter((dashboard) => !idsToDelete.includes(dashboard.id));
selectedIds = [];
dispatch("selectionChanged", []);
}
// [/DEF:handleBulkDelete:Function]

View File

@@ -132,14 +132,14 @@
<div class="absolute h-2 w-full top-full left-0"></div>
<div class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none hidden group-hover:block z-50">
<div class="py-1">
<button on:click={() => clearTasks()} class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">{$t.tasks?.clear_non_running }</button>
<button on:click={() => clearTasks('FAILED')} class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">{$t.tasks?.clear_failed }</button>
<button on:click={() => clearTasks('AWAITING_INPUT')} class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">{$t.tasks?.clear_awaiting_input }</button>
<button onclick={() => clearTasks()} class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">{$t.tasks?.clear_non_running }</button>
<button onclick={() => clearTasks('FAILED')} class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">{$t.tasks?.clear_failed }</button>
<button onclick={() => clearTasks('AWAITING_INPUT')} class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">{$t.tasks?.clear_awaiting_input }</button>
</div>
</div>
</div>
<button
on:click={fetchTasks}
onclick={fetchTasks}
class="text-sm text-indigo-600 hover:text-indigo-900 focus:outline-none"
>
{$t.common?.refresh }
@@ -160,7 +160,7 @@
<button
class="w-full text-left block hover:bg-gray-50 focus:outline-none focus:bg-gray-50 transition duration-150 ease-in-out"
class:bg-indigo-50={$selectedTask && $selectedTask.id === task.id}
on:click={() => selectTask(task)}
onclick={() => selectTask(task)}
>
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">

View File

@@ -7,7 +7,6 @@
-->
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { formatDistanceToNow } from 'date-fns';
import { t } from '../lib/i18n';
@@ -15,11 +14,9 @@
tasks = [],
loading = false,
selectedTaskId = null,
onselect = () => {},
} = $props();
const dispatch = createEventDispatcher();
// [DEF:getStatusColor:Function]
// @PURPOSE: Returns the CSS color class for a given task status.
// @PRE: status string is provided.
@@ -52,11 +49,11 @@
// [/DEF:formatTime:Function]
// [DEF:handleTaskClick:Function]
// @PURPOSE: Dispatches a select event when a task is clicked.
// @PURPOSE: Forwards the selected task through a callback prop.
// @PRE: taskId is provided.
// @POST: 'select' event is dispatched with task ID.
// @POST: Parent callback receives task id and task payload.
function handleTaskClick(task: any) {
dispatch('select', { id: task.id, task });
onselect({ id: task.id, task });
}
// [/DEF:handleTaskClick:Function]
</script>
@@ -72,7 +69,7 @@
<li>
<button
class="block w-full text-left transition duration-150 ease-in-out focus:outline-none hover:bg-gray-50 {selectedTaskId === task.id ? 'bg-blue-50' : ''}"
on:click={() => handleTaskClick(task)}
onclick={() => handleTaskClick(task)}
>
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">

View File

@@ -35,7 +35,7 @@
* @TEST_EDGE api_error -> transitions to Error state and displays retry button
* @TEST_INVARIANT displays_logs -> verifies: [valid_viewer]
*/
import { createEventDispatcher, onDestroy } from "svelte";
import { onDestroy } from "svelte";
import { getTaskLogs } from "../services/taskService.js";
import { t } from "../lib/i18n";
import TaskLogPanel from "./tasks/TaskLogPanel.svelte";
@@ -46,10 +46,9 @@
taskId = null,
taskStatus = null,
realTimeLogs = [],
onclose = () => {},
} = $props();
const dispatch = createEventDispatcher();
let logs = $state([]);
let loading = $state(false);
let error = $state("");
@@ -101,11 +100,11 @@
// [DEF:handleFilterChange:Function]
// @PURPOSE: Updates filter conditions for the log viewer
// @PRE: event contains detail with source and level
// @PRE: event contains source and level fields
// @POST: Log viewer filters updated
function handleFilterChange(event) {
console.log("[TaskLogViewer][UI][handleFilterChange:START]");
const { source, level } = event.detail;
const { source, level } = event;
}
// [/DEF:handleFilterChange:Function]
@@ -177,8 +176,7 @@
{taskId}
{logs}
{autoScroll}
on:filterChange={handleFilterChange}
on:refresh={handleRefresh}
onfilterchange={handleFilterChange}
/>
{/if}
</div>
@@ -203,9 +201,14 @@
aria-hidden="true"
onclick={() => {
show = false;
dispatch("close");
onclose();
}}
onkeydown={(e) => {
if (e.key === "Escape") {
show = false;
onclose();
}
}}
onkeydown={(e) => e.key === "Escape" && (show = false)}
role="presentation"
></div>
@@ -224,7 +227,7 @@
class="text-gray-500 hover:text-gray-300"
onclick={() => {
show = false;
dispatch("close");
onclose();
}}
aria-label={$t.common?.close}></button
>
@@ -241,7 +244,7 @@
{taskId}
{logs}
{autoScroll}
on:filterChange={handleFilterChange}
onfilterchange={handleFilterChange}
/>
{/if}
</div>

View File

@@ -370,12 +370,12 @@
</div>
<div class="h-[500px]">
<TaskLogPanel
taskId={$selectedTask.id}
logs={$taskLogs}
autoScroll={true}
on:filterChange={handleFilterChange}
/>
<TaskLogPanel
taskId={$selectedTask.id}
logs={$taskLogs}
autoScroll={true}
onfilterchange={handleFilterChange}
/>
{#if waitingForData && connectionStatus === 'connected'}
<div class="text-gray-500 italic mt-2 animate-pulse text-xs">
@@ -388,22 +388,22 @@
{/if}
</div>
<MissingMappingModal
bind:show={showMappingModal}
sourceDbName={missingDbInfo.name}
sourceDbUuid={missingDbInfo.uuid}
{targetDatabases}
on:resolve={handleMappingResolve}
on:cancel={() => { connectionStatus = 'disconnected'; ws.close(); }}
/>
<PasswordPrompt
bind:show={showPasswordPrompt}
databases={passwordPromptData.databases}
errorMessage={passwordPromptData.errorMessage}
on:resume={handlePasswordResume}
on:cancel={() => { showPasswordPrompt = false; }}
/>
<MissingMappingModal
bind:show={showMappingModal}
sourceDbName={missingDbInfo.name}
sourceDbUuid={missingDbInfo.uuid}
{targetDatabases}
onresolve={handleMappingResolve}
oncancel={() => { connectionStatus = 'disconnected'; ws.close(); }}
/>
<PasswordPrompt
bind:show={showPasswordPrompt}
databases={passwordPromptData.databases}
errorMessage={passwordPromptData.errorMessage}
onresume={handlePasswordResume}
oncancel={() => { showPasswordPrompt = false; }}
/>
<!-- [/SECTION] -->
<!-- [/DEF:TaskRunner:Component] -->

View File

@@ -1,84 +1,129 @@
<!-- [DEF:ProtectedRoute:Component] -->
<!--[DEF:ProtectedRoute.svelte:Module] -->
<!--
@TIER: STANDARD
@SEMANTICS: auth, guard, route, protection, permission
@PURPOSE: Wraps content to ensure only authenticated and authorized users can access it.
@LAYER: Component
@RELATION: USES -> authStore
@RELATION: CALLS -> goto
@RELATION: DEPENDS_ON -> frontend.src.lib.auth.permissions.hasPermission
@INVARIANT: Redirects to /login if user is not authenticated and to fallback route when permission is denied.
@UX_STATE: Loading -> Shows spinner while session/permission check is in progress.
@UX_STATE: Authorized -> Renders protected slot content.
@UX_RECOVERY: Invalid token triggers logout and redirect to /login.
@TIER: CRITICAL
@SEMANTICS: auth, route-guard, permission, redirect, session-validation
@PURPOSE: Enforces authenticated and authorized access before protected route content is rendered.
@LAYER: UI
@RELATION: [BINDS_TO] ->[frontend.src.lib.auth.store.auth]
@RELATION: [CALLS] ->[$app/navigation.goto]
@RELATION: [DEPENDS_ON] ->[$lib/auth/permissions.hasPermission]
@RELATION: [CALLS] ->[frontend.src.lib.api.api.fetchApi]
@INVARIANT: Unauthenticated users are redirected to /login, unauthorized users are redirected to fallbackPath, and protected slot renders only when access is verified.
@UX_STATE: Idle -> Component mounted, verification not yet started.
@UX_STATE: Loading -> Spinner is rendered while auth/session/permission validation is in progress.
@UX_STATE: Error -> Session validation failure triggers logout and /login redirect.
@UX_STATE: Success -> Protected slot content is rendered for authenticated users with valid access.
@UX_FEEDBACK: Spinner feedback during Loading and navigation redirect feedback on Error/Unauthorized outcomes.
@UX_RECOVERY: Re-authenticate via /login after logout; retry occurs automatically on next protected navigation.
@UX_REACTIVITY: Props are bound via $props; local mutable UI flags use $state; auth store is consumed through Svelte store subscription ($auth).
@TEST_CONTRACT: [token:user:requiredPermission] -> [redirect:/login | redirect:fallbackPath | render:slot]
@TEST_SCENARIO: MissingTokenRedirect -> Navigates to /login and suppresses slot render.
@TEST_SCENARIO: PermissionDeniedRedirect -> Navigates to fallbackPath and suppresses slot render.
@TEST_SCENARIO: AuthorizedRender -> Renders slot when authenticated and permission passes.
@TEST_FIXTURE: AuthGuardStateMatrix -> INLINE_JSON
@TEST_EDGE: missing_field -> user payload missing in store triggers /auth/me fetch, then logout+redirect on failure.
@TEST_EDGE: invalid_type -> requiredPermission malformed (non-string/null) resolves to denied path and fallback redirect.
@TEST_EDGE: external_fail -> /auth/me network/API failure triggers logout and /login redirect.
@TEST_INVARIANT: GuardRedirectPolicy -> VERIFIED_BY: [MissingTokenRedirect, PermissionDeniedRedirect]
@TEST_INVARIANT: ProtectedRenderGate -> VERIFIED_BY: [AuthorizedRender]
-->
<script lang="ts">
<!--[DEF:ProtectedRoute:Component] -->
<!--
@PURPOSE: Wraps protected slot content with session and permission verification guards.
@PRE: auth store and navigation API are available in runtime; component is mounted in a browser context.
@POST: Slot renders only when $auth.isAuthenticated and hasRouteAccess are both true.
@SIDE_EFFECT: Performs /auth/me request, mutates auth store state, emits console instrumentation logs, and executes navigation redirects.
@DATA_CONTRACT: Input[$props.requiredPermission?: string|null, $props.fallbackPath?: string] -> Output[UIState{isCheckingAccess:boolean, hasRouteAccess:boolean}]
-->
<script>
import { onMount } from "svelte";
import { auth } from "../../lib/auth/store";
import { api } from "../../lib/api";
import { goto } from "$app/navigation";
import { hasPermission } from "$lib/auth/permissions.js";
export let requiredPermission: string | null = null;
export let fallbackPath: string = "/profile";
const { requiredPermission = null, fallbackPath = "/profile", children } = $props();
let hasRouteAccess = false;
let isCheckingAccess = true;
let hasRouteAccess = $state(false);
let isCheckingAccess = $state(true);
const belief_scope = async (scopeId, run) => {
console.info(`[${scopeId}][REASON] belief_scope.enter`);
try {
return await run();
} finally {
console.info(`[${scopeId}][REFLECT] belief_scope.exit`);
}
};
// [DEF:verifySessionAndAccess:Function]
/**
* @purpose Validates active session and optional route permission before rendering protected slot.
* @pre Auth store is initialized.
* @post hasRouteAccess is true only when session and permission checks pass.
* @side_effect May update auth store, perform redirect, and fetch current user.
* @PURPOSE: Validates session and optional permission gate before allowing protected content render.
* @PRE: auth store is initialized and can provide token/user state; navigation is available.
* @POST: hasRouteAccess=true only when user identity is valid and permission check (if provided) passes.
* @SIDE_EFFECT: Mutates auth loading/user state, performs API I/O to /auth/me, and may redirect.
* @DATA_CONTRACT: Input[AuthState, requiredPermission, fallbackPath] -> Output[RouteDecision{login_redirect|fallback_redirect|grant}]
*/
async function verifySessionAndAccess(): Promise<void> {
isCheckingAccess = true;
try {
if (!$auth.token) {
auth.setLoading(false);
await goto("/login");
return;
}
async function verifySessionAndAccess() {
return belief_scope("ProtectedRoute.verifySessionAndAccess", async () => {
console.info("[ProtectedRoute.verifySessionAndAccess][REASON] Starting route access verification");
isCheckingAccess = true;
let currentUser = $auth.user;
if (!currentUser) {
auth.setLoading(true);
try {
const user = await api.fetchApi("/auth/me");
auth.setUser(user);
currentUser = user;
} catch (error) {
console.error("[ProtectedRoute][COHERENCE:FAILED] Failed to verify session:", error);
auth.logout();
try {
if (!$auth.token) {
auth.setLoading(false);
console.info("[ProtectedRoute.verifySessionAndAccess][REFLECT] Missing token, redirecting to /login");
await goto("/login");
return;
} finally {
auth.setLoading(false);
}
}
if (!currentUser) {
auth.logout();
await goto("/login");
return;
}
let currentUser = $auth.user;
if (!currentUser) {
auth.setLoading(true);
try {
const user = await api.fetchApi("/auth/me");
auth.setUser(user);
currentUser = user;
console.info("[ProtectedRoute.verifySessionAndAccess][REASON] Session user hydrated from /auth/me");
} catch (error) {
console.warn("[ProtectedRoute.verifySessionAndAccess][EXPLORE] Session validation failed", { error });
auth.logout();
await goto("/login");
return;
} finally {
auth.setLoading(false);
}
}
if (requiredPermission && !hasPermission(currentUser, requiredPermission, "READ")) {
console.warn(
`[ProtectedRoute][REFLECT] Permission denied for ${requiredPermission}, redirecting to ${fallbackPath}`,
);
hasRouteAccess = false;
await goto(fallbackPath);
return;
}
if (!currentUser) {
auth.logout();
console.info("[ProtectedRoute.verifySessionAndAccess][REFLECT] User unresolved, redirecting to /login");
await goto("/login");
return;
}
hasRouteAccess = true;
} finally {
isCheckingAccess = false;
}
if (requiredPermission && !hasPermission(currentUser, requiredPermission, "READ")) {
console.info("[ProtectedRoute.verifySessionAndAccess][REFLECT] Permission denied, redirecting to fallback", {
requiredPermission,
fallbackPath,
});
hasRouteAccess = false;
await goto(fallbackPath);
return;
}
hasRouteAccess = true;
console.info("[ProtectedRoute.verifySessionAndAccess][REFLECT] Access granted");
} finally {
isCheckingAccess = false;
console.info("[ProtectedRoute.verifySessionAndAccess][REFLECT] Verification cycle completed", {
isCheckingAccess,
hasRouteAccess,
});
}
});
}
// [/DEF:verifySessionAndAccess:Function]
@@ -92,7 +137,8 @@
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
{:else if $auth.isAuthenticated && hasRouteAccess}
<slot />
{@render children?.()}
{/if}
<!-- [/DEF:ProtectedRoute:Component] -->
<!-- [/DEF:ProtectedRoute:Component] -->
<!-- [/DEF:ProtectedRoute.svelte:Module] -->

Some files were not shown because too many files have changed in this diff Show More