25 Commits

Author SHA1 Message Date
83e4875097 Merge branch '001-unify-frontend-style' into master 2026-02-23 16:06:12 +03:00
e635bd7e5f Add Apache Superset OpenAPI documentation reference to ROOT.md 2026-02-23 16:04:42 +03:00
43dd97ecbf Новый экранчик для обзора дашей 2026-02-23 15:54:20 +03:00
0685f50ae7 Merge branch '020-task-reports-design' into master 2026-02-23 13:28:31 +03:00
d0ffc2f1df Finalize task-020 reports navigation and stability fixes 2026-02-23 13:28:30 +03:00
26880d2e09 semantic update 2026-02-23 13:15:48 +03:00
008b6d72c9 таски готовы 2026-02-23 10:18:56 +03:00
f0c85e4c03 Fix task API stability and Playwright runtime in Docker 2026-02-21 23:43:46 +03:00
6ffdf5f8a4 feat: restore legacy data and add typed task result views 2026-02-21 23:17:56 +03:00
0cf0ef25f1 db + docker 2026-02-20 20:47:39 +03:00
af74841765 semantic update 2026-02-20 10:41:15 +03:00
d7e4919d54 few shots update 2026-02-20 10:26:01 +03:00
fdcbe32dfa css refactor 2026-02-19 18:24:36 +03:00
4de5b22d57 +Svelte specific 2026-02-19 17:47:24 +03:00
c8029ed309 ai base 2026-02-19 17:43:45 +03:00
c2a4c8062a fix tax log 2026-02-19 16:05:59 +03:00
2c820e103a tests ready 2026-02-19 13:33:20 +03:00
c8b84b7bd7 Coder + fix workflow 2026-02-19 13:33:10 +03:00
fdb944f123 Test logic update 2026-02-19 12:44:31 +03:00
d29bc511a2 task panel 2026-02-19 09:43:01 +03:00
a3a9f0788d docs: amend constitution to v2.3.0 (tailwind css first principle) 2026-02-18 18:29:52 +03:00
77147dc95b refactor 2026-02-18 17:29:46 +03:00
026239e3bf fix 2026-02-15 11:11:30 +03:00
4a0273a604 измененные спеки таски 2026-02-10 15:53:38 +03:00
edb2dd5263 updated tasks 2026-02-10 15:04:43 +03:00
234 changed files with 120976 additions and 64097 deletions

1415
.ai/MODULE_MAP.md Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

41
.ai/ROOT.md Normal file
View File

@@ -0,0 +1,41 @@
# [DEF:Project_Knowledge_Map:Root]
# @TIER: CRITICAL
# @PURPOSE: Global navigation map for AI-Agent (GRACE Knowledge Graph).
# @LAST_UPDATE: 2026-02-20
## 1. SYSTEM STANDARDS (Rules of the Game)
Strict policies and formatting rules.
* **Constitution:** High-level architectural and business invariants.
* Ref: `.ai/standards/constitution.md` -> `[DEF:Std:Constitution]`
* **Architecture:** Service boundaries and tech stack decisions.
* Ref: `.ai/standards/architecture.md` -> `[DEF:Std:Architecture]`
* **Plugin Design:** Rules for building and integrating Plugins.
* Ref: `.ai/standards/plugin_design.md` -> `[DEF:Std:Plugin]`
* **API Design:** Rules for FastAPI endpoints and Pydantic models.
* Ref: `.ai/standards/api_design.md` -> `[DEF:Std:API_FastAPI]`
* **UI Design:** SvelteKit and Tailwind CSS component standards.
* Ref: `.ai/standards/ui_design.md` -> `[DEF:Std:UI_Svelte]`
* **Semantic Mapping:** Using `[DEF:]` and belief scopes.
* Ref: `.ai/standards/semantics.md` -> `[DEF:Std:Semantics]`
## 2. FEW-SHOT EXAMPLES (Patterns)
Use these for code generation (Style Transfer).
* **FastAPI Route:** Reference implementation of a task-based route.
* Ref: `.ai/shots/backend_route.py` -> `[DEF:Shot:FastAPI_Route]`
* **Svelte Component:** Reference implementation of a sidebar/navigation component.
* Ref: `.ai/shots/frontend_component.svelte` -> `[DEF:Shot:Svelte_Component]`
* **Plugin Module:** Reference implementation of a task plugin.
* Ref: `.ai/shots/plugin_example.py` -> `[DEF:Shot:Plugin_Example]`
* **Critical Module:** Core banking transaction processor with ACID guarantees.
* Ref: `.ai/shots/critical_module.py` -> `[DEF:Shot:Critical_Module]`
## 3. DOMAIN MAP (Modules)
* **Module Map:** `.ai/MODULE_MAP.md` -> `[DEF:Module_Map]`
* **Project Map:** `.ai/PROJECT_MAP.md` -> `[DEF:Project_Map]`
* **Apache Superset OpenAPI:** `.ai/openapi.json` -> `[DEF:Doc:Superset_OpenAPI]`
* **Backend Core:** `backend/src/core` -> `[DEF:Module:Backend_Core]`
* **Backend API:** `backend/src/api` -> `[DEF:Module:Backend_API]`
* **Frontend Lib:** `frontend/src/lib` -> `[DEF:Module:Frontend_Lib]`
* **Specifications:** `specs/` -> `[DEF:Module:Specs]`
# [/DEF:Project_Knowledge_Map]

30933
.ai/openapi.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,65 @@
# [DEF:BackendRouteShot:Module]
# @TIER: STANDARD
# @SEMANTICS: Route, Task, API, Async
# @PURPOSE: Reference implementation of a task-based route using GRACE-Poly.
# @LAYER: Interface (API)
# @RELATION: IMPLEMENTS -> [DEF:Std:API_FastAPI]
# @INVARIANT: TaskManager must be available in dependency graph.
from typing import Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from ...core.logger import 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()
class CreateTaskRequest(BaseModel):
plugin_id: str
params: Dict[str, Any]
@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.
# @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.
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
with belief_scope("create_task"):
try:
# 1. Action: Configuration Resolution
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}
)
return task
except ValueError as e:
# 3. Recovery: Domain logic error mapping
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
# @UX_STATE: Error feedback -> 500 Internal Error
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Task Spawning Error"
)
# [/DEF:create_task:Function]
# [/DEF:BackendRouteShot:Module]

View File

@@ -0,0 +1,79 @@
# [DEF:TransactionCore:Module]
# @TIER: CRITICAL
# @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:AuditLog]
# @INVARIANT: Total system balance must remain constant (Double-Entry Bookkeeping).
# @INVARIANT: Negative transfers are strictly forbidden.
# @TEST_DATA: sufficient_funds -> {"from": "acc_A", "to": "acc_B", "amt": 100.00}
# @TEST_DATA: insufficient_funds -> {"from": "acc_empty", "to": "acc_B", "amt": 1000.00}
# @TEST_DATA: concurrency_lock -> {./fixtures/transactions.json#race_condition}
from decimal import Decimal
from typing import NamedTuple
from ...core.logger import belief_scope
from ...core.db import atomic_transaction, get_balance, update_balance
from ...core.exceptions import BusinessRuleViolation
class TransferResult(NamedTuple):
tx_id: str
status: str
new_balance: Decimal
# [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.
# @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
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})
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})
raise BusinessRuleViolation("INSUFFICIENT_FUNDS")
# 2. Action: 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)
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
raise e
except Exception as e:
# Logic: Catch-all safety net
context.logger.error("Critical Transfer Failure", error=e)
raise RuntimeError("TRANSACTION_ABORTED") from e
# [/DEF:execute_transfer:Function]
# [/DEF:TransactionCore:Module]

View File

@@ -0,0 +1,76 @@
<!-- [DEF:FrontendComponentShot:Component] -->
<!-- /**
* @TIER: CRITICAL
* @SEMANTICS: Task, Button, Action, UX
* @PURPOSE: Action button to spawn a new task with full UX feedback cycle.
* @LAYER: UI (Presentation)
* @RELATION: CALLS -> postApi
* @INVARIANT: Must prevent double-submission while loading.
*
* @TEST_DATA: idle_state -> {"isLoading": false}
* @TEST_DATA: loading_state -> {"isLoading": true}
*
* @UX_STATE: Idle -> Button enabled, primary color.
* @UX_STATE: Loading -> Button disabled, spinner visible.
* @UX_STATE: Error -> Toast notification triggers.
*
* @UX_FEEDBACK: Toast success/error.
* @UX_TEST: Idle -> {click: spawnTask, expected: isLoading=true}
* @UX_TEST: Success -> {api_resolve: 200, expected: toast.success 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;
// [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.
*/
async function spawnTask() {
isLoading = true;
console.log("[FrontendComponentShot][Loading] Spawning task...");
try {
// 1. Action: API Call
const response = await postApi("/api/tasks", {
plugin_id,
params
});
// 2. Feedback: Success
if (response.task_id) {
console.log("[FrontendComponentShot][Success] Task created.");
toast.success($t.tasks.spawned_success);
}
} catch (error) {
// 3. Recovery: User notification
console.log("[FrontendComponentShot][Error] Failed:", error);
toast.error(`${$t.errors.task_failed}: ${error.message}`);
} finally {
isLoading = false;
}
}
// [/DEF:spawnTask:Function]
</script>
<button
on:click={spawnTask}
disabled={isLoading}
class="btn-primary flex items-center gap-2"
aria-busy={isLoading}
>
{#if isLoading}
<span class="animate-spin" aria-label="Loading">🌀</span>
{/if}
<span>{$t.actions.start_task}</span>
</button>
<!-- [/DEF:FrontendComponentShot:Component] -->

View File

@@ -0,0 +1,64 @@
# [DEF:PluginExampleShot:Module]
# @TIER: STANDARD
# @SEMANTICS: Plugin, Core, Extension
# @PURPOSE: Reference implementation of a plugin following GRACE standards.
# @LAYER: Domain (Business Logic)
# @RELATION: INHERITS -> PluginBase
# @INVARIANT: get_schema must return valid JSON Schema.
from typing import Dict, Any, Optional
from ..core.plugin_base import PluginBase
from ..core.task_manager.context import TaskContext
class ExamplePlugin(PluginBase):
@property
def id(self) -> str:
return "example-plugin"
# [DEF:get_schema:Function]
# @PURPOSE: Defines input validation schema.
# @POST: Returns dict compliant with JSON Schema draft 7.
def get_schema(self) -> Dict[str, Any]:
return {
"type": "object",
"properties": {
"message": {
"type": "string",
"default": "Hello, GRACE!",
}
},
"required": ["message"],
}
# [/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
# 1. Action: System-level tracing (Rule VI)
with belief_scope("example_plugin_exec") as b_scope:
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
# 2. Action: Progress Reporting
log.progress("Processing...", percent=50)
# 3. Action: Finalize
log.info("Execution completed.")
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}")
# [/DEF:execute:Function]
# [/DEF:PluginExampleShot:Module]

View File

@@ -0,0 +1,47 @@
# [DEF:Std:API_FastAPI:Standard]
# @TIER: CRITICAL
# @PURPOSE: Unification of all FastAPI endpoints following GRACE-Poly.
# @LAYER: UI (API)
# @INVARIANT: All non-trivial route logic must be wrapped in `belief_scope`.
# @INVARIANT: Every module and function MUST have `[DEF:]` anchors and metadata.
## 1. ROUTE MODULE DEFINITION
Every API route file must start with a module definition header:
```python
# [DEF:ModuleName:Module]
# @TIER: [CRITICAL | STANDARD | TRIVIAL]
# @SEMANTICS: list, of, keywords
# @PURPOSE: High-level purpose of the module.
# @LAYER: UI (API)
# @RELATION: DEPENDS_ON -> [OtherModule]
```
## 2. FUNCTION DEFINITION & CONTRACT
Every endpoint handler must be decorated with `[DEF:]` and explicit metadata before the implementation:
```python
@router.post("/endpoint", response_model=ModelOut)
# [DEF:function_name:Function]
# @PURPOSE: What it does (brief, high-entropy).
# @PARAM: param_name (Type) - Description.
# @PRE: Conditions before execution (e.g., auth, existence).
# @POST: Expected state after execution.
# @RETURN: What it returns.
async def function_name(...):
with belief_scope("function_name"):
# Implementation
pass
# [/DEF:function_name:Function]
```
## 3. DEPENDENCY INJECTION & CORE SERVICES
* **Auth:** `Depends(get_current_user)` for authentication.
* **Perms:** `Depends(has_permission("resource", "ACTION"))` for RBAC.
* **Config:** Use `Depends(get_config_manager)` for settings. Hardcoding is FORBIDDEN.
* **Tasks:** Long-running operations must be executed via `TaskManager`. API routes should return Task ID and be non-blocking.
## 4. ERROR HANDLING
* Raise `HTTPException` from the router layer.
* Use `try-except` blocks within `belief_scope` to ensure proper error logging and classification.
* Do not leak internal implementation details in error responses.
# [/DEF:Std:API_FastAPI]

View File

@@ -0,0 +1,25 @@
# [DEF:Std:Architecture:Standard]
# @TIER: CRITICAL
# @PURPOSE: Core architectural decisions and service boundaries.
# @LAYER: Infra
# @INVARIANT: ss-tools MUST remain a standalone service (Orchestrator).
# @INVARIANT: Backend: FastAPI, Frontend: SvelteKit.
## 1. ORCHESTRATOR VS INSTANCE
* **Role:** ss-tools is a "Manager of Managers". It sits ABOVE Superset environments.
* **Isolation:** Do not integrate directly into Superset as a plugin to maintain multi-environment management capability.
* **Tech Stack:**
* Backend: Python 3.9+ with FastAPI (Asynchronous logic).
* Frontend: SvelteKit + Tailwind CSS (Reactive UX).
## 2. COMPONENT BOUNDARIES
* **Plugins:** All business logic must be encapsulated in Plugins (`backend/src/plugins/`).
* **TaskManager:** All long-running operations MUST be handled by the TaskManager.
* **Security:** Independent RBAC system managed in `auth.db`.
## 3. INTEGRATION STRATEGY
* **Superset API:** Communication via REST API.
* **Database:** Local SQLite for metadata (`tasks.db`, `auth.db`, `migrations.db`).
* **Filesystem:** Local storage for backups and git repositories.
# [/DEF:Std:Architecture]

View File

@@ -0,0 +1,36 @@
# [DEF:Std:Constitution:Standard]
# @TIER: CRITICAL
# @PURPOSE: Supreme Law of the Repository. High-level architectural and business invariants.
# @VERSION: 2.3.0
# @LAST_UPDATE: 2026-02-19
# @INVARIANT: Any deviation from this Constitution constitutes a build failure.
## 1. CORE PRINCIPLES
### I. Semantic Protocol Compliance
* **Ref:** `[DEF:Std:Semantics]` (formerly `semantic_protocol.md`)
* **Law:** All code must adhere to the Axioms (Meaning First, Contract First, etc.).
* **Compliance:** Strict matching of Anchors (`[DEF]`), Tags (`@KEY`), and structures is mandatory.
### II. Modular Plugin Architecture
* **Pattern:** Everything is a Plugin inheriting from `PluginBase`.
* **Centralized Config:** Use `ConfigManager` via `get_config_manager()`. Hardcoding is FORBIDDEN.
### III. Unified Frontend Experience
* **Styling:** Tailwind CSS First. Minimize scoped `<style>`.
* **i18n:** All user-facing text must be in `src/lib/i18n`.
* **API:** Use `requestApi` / `fetchApi` wrappers. Native `fetch` is FORBIDDEN.
### IV. Security & RBAC
* **Permissions:** Every Plugin must define unique permission strings (e.g., `plugin:name:execute`).
* **Auth:** Mandatory registration in `auth.db`.
### V. Independent Testability
* **Requirement:** Every feature must define "Independent Tests" for isolated verification.
### VI. Asynchronous Execution
* **TaskManager:** Long-running operations must be async tasks.
* **Non-Blocking:** API endpoints return Task ID immediately.
* **Observability:** Real-time updates via WebSocket.
# [/DEF:Std:Constitution]

View File

@@ -0,0 +1,32 @@
# [DEF:Std:Plugin:Standard]
# @TIER: CRITICAL
# @PURPOSE: Standards for building and integrating Plugins.
# @LAYER: Domain (Plugin)
# @INVARIANT: All plugins MUST inherit from `PluginBase`.
# @INVARIANT: All plugins MUST be located in `backend/src/plugins/`.
## 1. PLUGIN CONTRACT
Every plugin must implement the following properties and methods:
* `id`: Unique string (e.g., `"my-plugin"`).
* `name`: Human-readable name.
* `description`: Brief purpose.
* `version`: Semantic version.
* `get_schema()`: Returns JSON schema for input validation.
* `execute(params: Dict[str, Any], context: TaskContext)`: Core async logic.
## 2. STRUCTURED LOGGING (TASKCONTEXT)
Plugins MUST use `TaskContext` for logging to ensure proper source attribution:
* **Source Attribution:** Use `context.logger.with_source("src_name")` for specific operations (e.g., `"superset_api"`, `"git"`, `"llm"`).
* **Levels:**
* `DEBUG`: Detailed diagnostics (API responses).
* `INFO`: Operational milestones (start/end).
* `WARNING`: Recoverable issues.
* `ERROR`: Failures stopping execution.
* **Progress:** Use `context.logger.progress("msg", percent=XX)` for long-running tasks.
## 3. BEST PRACTICES
1. **Asynchronous Execution:** Always use `async/await` for I/O operations.
2. **Schema Validation:** Ensure the `get_schema()` precisely matches the `execute()` input expectations.
3. **Isolation:** Plugins should be self-contained and not depend on other plugins directly. Use core services (`ConfigManager`, `TaskManager`) via dependency injection or the provided `context`.
# [/DEF:Std:Plugin]

View File

@@ -0,0 +1,97 @@
### **SYSTEM STANDARD: GRACE-Poly (UX Edition)**
ЗАДАЧА: Генерация кода (Python/Svelte).
РЕЖИМ: Строгий. Детерминированный. Без болтовни.
#### I. ЗАКОН (АКСИОМЫ)
1. Смысл первичен. Код вторичен.
2. Контракт (@PRE/@POST) — источник истины.
**3. UX — это логика, а не декор. Состояния интерфейса — часть контракта.**
4. Структура `[DEF]...[/DEF]` — нерушима.
5. Архитектура в Header — неизменяема.
6. Сложность фрактала ограничена: модуль < 300 строк.
#### II. СИНТАКСИС (ЖЕСТКИЙ ФОРМАТ)
ЯКОРЬ (Контейнер):
Начало: `# [DEF:id:Type]` (Python) | `<!-- [DEF:id:Type] -->` (Svelte)
Конец: `# [/DEF:id:Type]` (Python) | `<!-- [/DEF:id:Type] -->` (Svelte) (ОБЯЗАТЕЛЬНО для аккумуляции)
Типы: Module, Class, Function, Component, Store.
ТЕГ (Метаданные):
Вид: `# @KEY: Value` (внутри DEF, до кода).
ГРАФ (Связи):
Вид: `# @RELATION: PREDICATE -> TARGET_ID`
Предикаты: DEPENDS_ON, CALLS, INHERITS, IMPLEMENTS, DISPATCHES, **BINDS_TO**.
#### III. СТРУКТУРА ФАЙЛА
1. HEADER (Всегда первый):
[DEF:filename:Module]
@TIER: [CRITICAL|STANDARD|TRIVIAL] (Дефолт: STANDARD)
@SEMANTICS: [keywords]
@PURPOSE: [Главная цель]
@LAYER: [Domain/UI/Infra]
@RELATION: [Зависимости]
@INVARIANT: [Незыблемое правило]
2. BODY: Импорты -> Реализация.
3. FOOTER: [/DEF:filename]
#### IV. КОНТРАКТ (DBC & UX)
Расположение: Внутри [DEF], ПЕРЕД кодом.
Стиль Python: Комментарии `# @TAG`.
Стиль Svelte: JSDoc `/** @tag */` внутри `<script>`.
**Базовые Теги:**
@PURPOSE: Суть (High Entropy).
@PRE: Входные условия.
@POST: Гарантии выхода.
@SIDE_EFFECT: Мутации, IO.
**UX Теги (Svelte/Frontend):**
**@UX_STATE:** `[StateName] -> Визуальное поведение` (Idle, Loading, Error).
**@UX_FEEDBACK:** Реакция системы (Toast, Shake, Red Border).
**@UX_RECOVERY:** Механизм исправления ошибки пользователем (Retry, Clear Input).
**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`.
#### V. АДАПТАЦИЯ (TIERS)
Определяется тегом `@TIER` в Header.
1. **CRITICAL** (Core/Security/**Complex UI**):
- Требование: Полный контракт (включая **все @UX теги**), Граф, Инварианты, Строгие Логи.
- **@TEST_DATA**: Обязательные эталонные данные для тестирования. Формат:
```
@TEST_DATA: fixture_name -> {JSON_PATH} | {INLINE_DATA}
```
Примеры:
- `@TEST_DATA: valid_user -> {./fixtures/users.json#valid}`
- `@TEST_DATA: empty_state -> {"dashboards": [], "total": 0}`
- Tester Agent **ОБЯЗАН** использовать @TEST_DATA при написании тестов для CRITICAL модулей.
2. **STANDARD** (BizLogic/**Forms**):
- Требование: Базовый контракт (@PURPOSE, @UX_STATE), Логи, @RELATION.
- @TEST_DATA: Рекомендуется для Complex Forms.
3. **TRIVIAL** (DTO/**Atoms**):
- Требование: Только Якоря [DEF] и @PURPOSE.
#### VI. ЛОГИРОВАНИЕ (BELIEF STATE & TASK LOGS)
Цель: Трассировка для самокоррекции и пользовательский мониторинг.
Python:
- Системные логи: Context Manager `with belief_scope("ID"):`.
- Логи задач: `context.logger.info("msg", source="component")`.
Svelte: `console.log("[ID][STATE] Msg")`.
Состояния: Entry -> Action -> Coherence:OK / Failed -> Exit.
Инвариант: Каждый лог задачи должен иметь атрибут `source` для фильтрации.
#### VII. АЛГОРИТМ ГЕНЕРАЦИИ
1. АНАЛИЗ. Оцени TIER, слой и UX-требования.
2. КАРКАС. Создай `[DEF]`, Header и Контракты.
3. РЕАЛИЗАЦИЯ. Напиши логику, удовлетворяющую Контракту (и UX-состояниям).
4. ЗАМЫКАНИЕ. Закрой все `[/DEF]`.
ЕСЛИ ошибка или противоречие -> СТОП. Выведи `[COHERENCE_CHECK_FAILED]`.

View File

@@ -0,0 +1,75 @@
# [DEF:Std:UI_Svelte:Standard]
# @TIER: CRITICAL
# @PURPOSE: Unification of all Svelte components following GRACE-Poly (UX Edition).
# @LAYER: UI
# @INVARIANT: Every component MUST have `<!-- [DEF:] -->` anchors and UX tags.
# @INVARIANT: Use Tailwind CSS for all styling (no custom CSS without justification).
## 1. UX PHILOSOPHY: RESOURCE-CENTRIC & SVELTE 5
* **Version:** Project uses Svelte 5.
* **Runes:** Use Svelte 5 Runes for reactivity: `$state()`, `$derived()`, `$effect()`, `$props()`. Traditional `let` (for reactivity) and `export let` (for props) are DEPRECATED in favor of runes.
* **Definition:** Navigation and actions revolve around Resources.
* **Traceability:** Every action must be linked to a Task ID with visible logs in the Task Drawer.
## 2. COMPONENT ARCHITECTURE: GLOBAL TASK DRAWER
* **Role:** A single, persistent slide-out panel (`GlobalTaskDrawer.svelte`) in `+layout.svelte`.
* **Triggering:** Opens automatically when a task starts or when a user clicks a status badge.
* **Interaction:** Interactive elements (Password prompts, Mapping tables) MUST be rendered INSIDE the Drawer, not as center-screen modals.
## 3. COMPONENT STRUCTURE & CORE RULES
* **Styling:** Tailwind CSS utility classes are MANDATORY. Minimize scoped `<style>`.
* **Localization:** All user-facing text must use `$t` from `src/lib/i18n`.
* **API Calls:** Use `requestApi` / `fetchApi` wrappers. Native `fetch` is FORBIDDEN.
* **Anchors:** Every component MUST have `<!-- [DEF:] -->` anchors and UX tags.
## 2. COMPONENT TEMPLATE
Each Svelte file must follow this structure:
```html
<!-- [DEF:ComponentName:Component] -->
<script>
/**
* @TIER: [CRITICAL | STANDARD | TRIVIAL]
* @PURPOSE: Brief description of the component purpose.
* @LAYER: UI
* @SEMANTICS: list, of, keywords
* @RELATION: DEPENDS_ON -> [OtherComponent|Store]
*
* @UX_STATE: [StateName] -> Visual behavior description.
* @UX_FEEDBACK: System reaction (e.g., Toast, Shake).
* @UX_RECOVERY: Error recovery mechanism.
* @UX_TEST: [state] -> {action, expected}
*/
import { ... } from "...";
// Exports (Props)
export let prop_name = "...";
// Logic
</script>
<!-- HTML Template -->
<div class="...">
...
</div>
<style>
/* Optional: Local styles using @apply only */
</style>
<!-- [/DEF:ComponentName:Component] -->
```
## 2. STATE MANAGEMENT & STORES
* **Subscription:** Use `$` prefix for reactive store access (e.g., `$sidebarStore`).
* **Data Flow:** Mark store interactions in `[DEF:]` metadata:
* `# @RELATION: BINDS_TO -> store_id`
## 3. UI/UX BEST PRACTICES
* **Transitions:** Use Svelte built-in transitions for UI state changes.
* **Feedback:** Always provide visual feedback for async actions (Loading spinners, skeleton loaders).
* **Modularity:** Break down components into "Atoms" (Trivial) and "Orchestrators" (Critical).
## 4. ACCESSIBILITY (A11Y)
* Ensure proper ARIA roles and keyboard navigation for interactive elements.
* Use semantic HTML tags (`<nav>`, `<header>`, `<main>`, `<footer>`).
# [/DEF:Std:UI_Svelte]

31
.dockerignore Normal file
View File

@@ -0,0 +1,31 @@
.git
.gitignore
.pytest_cache
.ruff_cache
.vscode
.ai
.specify
.kilocode
venv
backend/.venv
backend/.pytest_cache
frontend/node_modules
frontend/.svelte-kit
frontend/.vite
frontend/build
backend/__pycache__
backend/src/__pycache__
backend/tests/__pycache__
**/__pycache__
*.pyc
*.pyo
*.pyd
*.db
*.log
.env*
coverage/
Dockerfile*
.dockerignore
backups
semantics
specs

9
.gitignore vendored
View File

@@ -10,8 +10,6 @@ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
@@ -69,3 +67,10 @@ backend/tasks.db
backend/logs
backend/auth.db
semantics/reports
backend/tasks.db
# Universal / tooling
node_modules/
.venv/
coverage/
*.tmp

View File

@@ -2,6 +2,12 @@
Auto-generated from all feature plans. Last updated: 2025-12-19
## Knowledge Graph (GRACE)
**CRITICAL**: This project uses a GRACE Knowledge Graph for context. Always load the root map first:
- **Root Map**: `.ai/ROOT.md` -> `[DEF:Project_Knowledge_Map:Root]`
- **Project Map**: `.ai/PROJECT_MAP.md` -> `[DEF:Project_Map]`
- **Standards**: Read `.ai/standards/` for architecture and style rules.
## Active Technologies
- Python 3.9+, Node.js 18+ + `uvicorn`, `npm`, `bash` (003-project-launch-script)
- Python 3.9+, Node.js 18+ + SvelteKit, FastAPI, Tailwind CSS (inferred from existing frontend) (004-integrate-svelte-kit)
@@ -35,6 +41,10 @@ Auto-generated from all feature plans. Last updated: 2025-12-19
- SQLite (existing `tasks.db` for results, `auth.db` for permissions, `mappings.db` or new `plugins.db` for provider config/metadata) (017-llm-analysis-plugin)
- Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, Tailwind CSS, SQLAlchemy, WebSocket (existing) (019-superset-ux-redesign)
- SQLite (tasks.db, auth.db, migrations.db) - no new database tables required (019-superset-ux-redesign)
- Python 3.9+ (backend), Node.js 18+ (frontend) + FastAPI, SvelteKit, Tailwind CSS, SQLAlchemy/Pydantic task models, existing task/websocket stack (020-task-reports-design)
- SQLite task/result persistence (existing task DB), filesystem only for existing artifacts (no new primary store required) (020-task-reports-design)
- Node.js 18+ runtime, SvelteKit (existing frontend stack) + SvelteKit, Tailwind CSS, existing frontend UI primitives under `frontend/src/lib/components/ui` (001-unify-frontend-style)
- N/A (UI styling and component behavior only) (001-unify-frontend-style)
- Python 3.9+ (Backend), Node.js 18+ (Frontend Build) (001-plugin-arch-svelte-ui)
@@ -55,9 +65,9 @@ cd src; pytest; ruff check .
Python 3.9+ (Backend), Node.js 18+ (Frontend Build): Follow standard conventions
## Recent Changes
- 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`
- 020-task-reports-design: Added Python 3.9+ (backend), Node.js 18+ (frontend) + FastAPI, SvelteKit, Tailwind CSS, SQLAlchemy/Pydantic task models, existing task/websocket stack
- 019-superset-ux-redesign: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, Tailwind CSS, SQLAlchemy, WebSocket (existing)
- 017-llm-analysis-plugin: Added Python 3.9+ (Backend), Node.js 18+ (Frontend)
- 016-multi-user-auth: Added Python 3.9+ (Backend), Node.js 18+ (Frontend)
<!-- MANUAL ADDITIONS START -->

View File

@@ -1,4 +1,4 @@
---
description: USE SEMANTIC
---
Прочитай semantic_protocol.md. ОБЯЗАТЕЛЬНО используй его при разработке
Прочитай .ai/standards/semantics.md. ОБЯЗАТЕЛЬНО используй его при разработке

View File

@@ -18,7 +18,7 @@ Identify inconsistencies, duplications, ambiguities, and underspecified items ac
**STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually).
**Constitution Authority**: The project constitution (`.specify/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit.analyze`.
**Constitution Authority**: The project constitution (`.ai/standards/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit.analyze`.
## Execution Steps
@@ -62,8 +62,8 @@ Load only the minimal necessary context from each artifact:
**From constitution:**
- Load `.specify/memory/constitution.md` for principle validation
- Load `semantic_protocol.md` for technical standard validation
- Load `.ai/standards/constitution.md` for principle validation
- Load `.ai/standards/semantics.md` for technical standard validation
### 3. Build Semantic Models

View File

@@ -16,11 +16,11 @@ You **MUST** consider the user input before proceeding (if not empty).
## Outline
You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts.
You are updating the project constitution at `.ai/standards/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts.
Follow this execution flow:
1. Load the existing constitution template at `.specify/memory/constitution.md`.
1. Load the existing constitution template at `.ai/standards/constitution.md`.
- Identify every placeholder token of the form `[ALL_CAPS_IDENTIFIER]`.
**IMPORTANT**: The user might require less or more principles than the ones used in the template. If a number is specified, respect that - follow the general template. You will update the doc accordingly.
@@ -61,7 +61,7 @@ Follow this execution flow:
- Dates ISO format YYYY-MM-DD.
- Principles are declarative, testable, and free of vague language ("should" → replace with MUST/SHOULD rationale where appropriate).
7. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite).
7. Write the completed constitution back to `.ai/standards/constitution.md` (overwrite).
8. Output a final summary to the user with:
- New version and bump rationale.
@@ -79,4 +79,4 @@ If the user supplies partial updates (e.g., only one principle revision), still
If critical info missing (e.g., ratification date truly unknown), insert `TODO(<FIELD_NAME>): explanation` and include in the Sync Impact Report under deferred items.
Do not create a new template; always operate on the existing `.specify/memory/constitution.md` file.
Do not create a new template; always operate on the existing `.ai/standards/constitution.md` file.

View File

@@ -0,0 +1,199 @@
---
description: Fix failing tests and implementation issues based on test reports
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Goal
Analyze test failure reports, identify root causes, and fix implementation issues while preserving semantic protocol compliance.
## Operating Constraints
1. **USE CODER MODE**: Always switch to `coder` mode for code fixes
2. **SEMANTIC PROTOCOL**: Never remove semantic annotations ([DEF], @TAGS). Only update code logic.
3. **TEST DATA**: If tests use @TEST_DATA fixtures, preserve them when fixing
4. **NO DELETION**: Never delete existing tests or semantic annotations
5. **REPORT FIRST**: Always write a fix report before making changes
## Execution Steps
### 1. Load Test Report
**Required**: Test report file path (e.g., `specs/<feature>/tests/reports/2026-02-19-report.md`)
**Parse the report for**:
- Failed test cases
- Error messages
- Stack traces
- Expected vs actual behavior
- Affected modules/files
### 2. Analyze Root Causes
For each failed test:
1. **Read the test file** to understand what it's testing
2. **Read the implementation file** to find the bug
3. **Check semantic protocol compliance**:
- Does the implementation have correct [DEF] anchors?
- Are @TAGS (@PRE, @POST, @UX_STATE, etc.) present?
- Does the code match the TIER requirements?
4. **Identify the fix**:
- Logic error in implementation
- Missing error handling
- Incorrect API usage
- State management issue
### 3. Write Fix Report
Create a structured fix report:
```markdown
# Fix Report: [FEATURE]
**Date**: [YYYY-MM-DD]
**Report**: [Test Report Path]
**Fixer**: Coder Agent
## Summary
- Total Failed Tests: [X]
- Total Fixed: [X]
- Total Skipped: [X]
## Failed Tests Analysis
### Test: [Test Name]
**File**: `path/to/test.py`
**Error**: [Error message]
**Root Cause**: [Explanation of why test failed]
**Fix Required**: [Description of fix]
**Status**: [Pending/In Progress/Completed]
## Fixes Applied
### Fix 1: [Description]
**Affected File**: `path/to/file.py`
**Test Affected**: `[Test Name]`
**Changes**:
```diff
<<<<<<< SEARCH
[Original Code]
=======
[Fixed Code]
>>>>>>> REPLACE
```
**Verification**: [How to verify fix works]
**Semantic Integrity**: [Confirmed annotations preserved]
## Next Steps
- [ ] Run tests to verify fix: `cd backend && .venv/bin/python3 -m pytest`
- [ ] Check for related failing tests
- [ ] Update test documentation if needed
```
### 4. Apply Fixes (in Coder Mode)
Switch to `coder` mode and apply fixes:
1. **Read the implementation file** to get exact content
2. **Apply the fix** using apply_diff
3. **Preserve all semantic annotations**:
- Keep [DEF:...] and [/DEF:...] anchors
- Keep all @TAGS (@PURPOSE, @LAYER, @TIER, @RELATION, @PRE, @POST, @UX_STATE, @UX_FEEDBACK, @UX_RECOVERY)
4. **Only update code logic** to fix the bug
5. **Run tests** to verify the fix
### 5. Verification
After applying fixes:
1. **Run tests**:
```bash
cd backend && .venv/bin/python3 -m pytest -v
```
or
```bash
cd frontend && npm run test
```
2. **Check test results**:
- Failed tests should now pass
- No new tests should fail
- Coverage should not decrease
3. **Update fix report** with results:
- Mark fixes as completed
- Add verification steps
- Note any remaining issues
## Output
Generate final fix report:
```markdown
# Fix Report: [FEATURE] - COMPLETED
**Date**: [YYYY-MM-DD]
**Report**: [Test Report Path]
**Fixer**: Coder Agent
## Summary
- Total Failed Tests: [X]
- Total Fixed: [X] ✅
- Total Skipped: [X]
## Fixes Applied
### Fix 1: [Description] ✅
**Affected File**: `path/to/file.py`
**Test Affected**: `[Test Name]`
**Changes**: [Summary of changes]
**Verification**: All tests pass ✅
**Semantic Integrity**: Preserved ✅
## Test Results
```
[Full test output showing all passing tests]
```
## Recommendations
- [ ] Monitor for similar issues
- [ ] Update documentation if needed
- [ ] Consider adding more tests for edge cases
## Related Files
- Test Report: [path]
- Implementation: [path]
- Test File: [path]
```
## Context for Fixing
$ARGUMENTS

View File

@@ -51,7 +51,7 @@ You **MUST** consider the user input before proceeding (if not empty).
- Automatically proceed to step 3
3. Load and analyze the implementation context:
- **REQUIRED**: Read `semantic_protocol.md` for strict coding standards and contract requirements
- **REQUIRED**: Read `.ai/standards/semantics.md` for strict coding standards and contract requirements
- **REQUIRED**: Read tasks.md for the complete task list and execution plan
- **REQUIRED**: Read plan.md for tech stack, architecture, and file structure
- **IF EXISTS**: Read data-model.md for entities and relationships
@@ -117,7 +117,8 @@ You **MUST** consider the user input before proceeding (if not empty).
- **Validation checkpoints**: Verify each phase completion before proceeding
7. Implementation execution rules:
- **Strict Adherence**: Apply `semantic_protocol.md` rules - every file must start with [DEF] header, include @TIER, and define contracts
- **Strict Adherence**: Apply `.ai/standards/semantics.md` rules - every file must start with [DEF] header, include @TIER, and define contracts.
- **CRITICAL Contracts**: If a task description contains a contract summary (e.g., `CRITICAL: PRE: ..., POST: ...`), these constraints are **MANDATORY** and must be strictly implemented in the code using guards/assertions (if applicable per protocol).
- **Setup first**: Initialize project structure, dependencies, configuration
- **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios
- **Core development**: Implement models, services, CLI commands, endpoints

View File

@@ -22,7 +22,7 @@ You **MUST** consider the user input before proceeding (if not empty).
1. **Setup**: Run `.specify/scripts/bash/setup-plan.sh --json` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Load context**: Read FEATURE_SPEC, `ux_reference.md`, `semantic_protocol.md` and `.specify/memory/constitution.md`. Load IMPL_PLAN template (already copied).
2. **Load context**: Read `.ai/ROOT.md` and `.ai/PROJECT_MAP.md` to understand the project structure and navigation. Then read required standards: `.ai/standards/constitution.md` and `.ai/standards/semantics.md`. Load IMPL_PLAN template.
3. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to:
- Fill Technical Context (mark unknowns as "NEEDS CLARIFICATION")
@@ -73,7 +73,9 @@ You **MUST** consider the user input before proceeding (if not empty).
- Entity name, fields, relationships, validation rules.
2. **Design & Verify Contracts (Semantic Protocol)**:
- **Drafting**: Define [DEF] Headers and Contracts for all new modules based on `semantic_protocol.md`.
- **Drafting**: Define [DEF] Headers and Contracts for all new modules based on `.ai/standards/semantics.md`.
- **TIER Classification**: Explicitly assign `@TIER: [CRITICAL|STANDARD|TRIVIAL]` to each module.
- **CRITICAL Requirements**: For all CRITICAL modules, define full `@PRE`, `@POST`, and (if UI) `@UX_STATE` contracts.
- **Self-Review**:
- *Completeness*: Do `@PRE`/`@POST` cover edge cases identified in Research?
- *Connectivity*: Do `@RELATION` tags form a coherent graph?

View File

@@ -119,7 +119,10 @@ Every task MUST strictly follow this format:
- If tests requested: Tests specific to that story
- Mark story dependencies (most stories should be independent)
2. **From Contracts**:
2. **From Contracts (CRITICAL TIER)**:
- Identify components marked as `@TIER: CRITICAL` in `contracts/modules.md`.
- For these components, **MUST** append the summary of `@PRE`, `@POST`, and `@UX_STATE` contracts directly to the task description.
- Example: `- [ ] T005 [P] [US1] Implement Auth (CRITICAL: PRE: token exists, POST: returns User) in src/auth.py`
- Map each contract/endpoint → to the user story it serves
- If tests requested: Each contract → contract test task [P] before implementation in that story's phase

View File

@@ -1,17 +1,7 @@
**speckit.tasks.md**
### Modified Workflow
---
description: Generate tests, manage test documentation, and ensure maximum code coverage
```markdown
description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts.
handoffs:
- label: Analyze For Consistency
agent: speckit.analyze
prompt: Run a project analysis for consistency
send: true
- label: Implement Project
agent: speckit.implement
prompt: Start the implementation in phases
send: true
---
## User Input
@@ -22,95 +12,167 @@ $ARGUMENTS
You **MUST** consider the user input before proceeding (if not empty).
## Outline
## Goal
1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute.
Execute full testing cycle: analyze code for testable modules, write tests with proper coverage, maintain test documentation, and ensure no test duplication or deletion.
2. **Load design documents**: Read from FEATURE_DIR:
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities), ux_reference.md (experience source of truth)
- **Optional**: data-model.md (entities), contracts/ (API endpoints), research.md (decisions)
## Operating Constraints
3. **Execute task generation workflow**:
- **Architecture Analysis (CRITICAL)**: Scan existing codebase for patterns (DI, Auth, ORM).
- Load plan.md/spec.md.
- Generate tasks organized by user story.
- **Apply Fractal Co-location**: Ensure all unit tests are mapped to `__tests__` subdirectories relative to the code.
- Validate task completeness.
1. **NEVER delete existing tests** - Only update if they fail due to bugs in the test or implementation
2. **NEVER duplicate tests** - Check existing tests first before creating new ones
3. **Use TEST_DATA fixtures** - For CRITICAL tier modules, read @TEST_DATA from .ai/standards/semantics.md
4. **Co-location required** - Write tests in `__tests__` directories relative to the code being tested
4. **Generate tasks.md**: Use `.specify/templates/tasks-template.md` as structure.
- Phase 1: Context & Setup.
- Phase 2: Foundational tasks.
- Phase 3+: User Stories (Priority order).
- Final Phase: Polish.
- **Strict Constraint**: Ensure tasks follow the Co-location and Mocking rules below.
## Execution Steps
5. **Report**: Output path to generated tasks.md and summary.
### 1. Analyze Context
Context for task generation: $ARGUMENTS
Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS.
## Task Generation Rules
Determine:
- FEATURE_DIR - where the feature is located
- TASKS_FILE - path to tasks.md
- Which modules need testing based on task status
**CRITICAL**: Tasks MUST be actionable, specific, architecture-aware, and context-local.
### 2. Load Relevant Artifacts
### Implementation & Testing Constraints (ANTI-LOOP & CO-LOCATION)
**From tasks.md:**
- Identify completed implementation tasks (not test tasks)
- Extract file paths that need tests
To prevent infinite debugging loops and context fragmentation, apply these rules:
**From .ai/standards/semantics.md:**
- Read @TIER annotations for modules
- For CRITICAL modules: Read @TEST_DATA fixtures
1. **Fractal Co-location Strategy (MANDATORY)**:
- **Rule**: Unit tests MUST live next to the code they verify.
- **Forbidden**: Do NOT create unit tests in root `tests/` or `backend/tests/`. Those are for E2E/Integration only.
- **Pattern (Python)**:
- Source: `src/domain/order/processing.py`
- Test Task: `Create tests in src/domain/order/__tests__/test_processing.py`
- **Pattern (Frontend)**:
- Source: `src/lib/components/UserCard.svelte`
- Test Task: `Create tests in src/lib/components/__tests__/UserCard.test.ts`
**From existing tests:**
- Scan `__tests__` directories for existing tests
- Identify test patterns and coverage gaps
2. **Semantic Relations**:
- Test generation tasks must explicitly instruct to add the relation header: `# @RELATION: VERIFIES -> [TargetComponent]`
### 3. Test Coverage Analysis
3. **Strict Mocking for Unit Tests**:
- Any task creating Unit Tests MUST specify: *"Use `unittest.mock.MagicMock` for heavy dependencies (DB sessions, Auth). Do NOT instantiate real service classes."*
Create coverage matrix:
4. **Schema/Model Separation**:
- Explicitly separate tasks for ORM Models (SQLAlchemy) and Pydantic Schemas.
| Module | File | Has Tests | TIER | TEST_DATA Available |
|--------|------|-----------|------|-------------------|
| ... | ... | ... | ... | ... |
### UX Preservation (CRITICAL)
### 4. Write Tests (TDD Approach)
- **Source of Truth**: `ux_reference.md` is the absolute standard.
- **Verification Task**: You **MUST** add a specific task at the end of each User Story phase: `- [ ] Txxx [USx] Verify implementation matches ux_reference.md (Happy Path & Errors)`
For each module requiring tests:
### Checklist Format (REQUIRED)
1. **Check existing tests**: Scan `__tests__/` for duplicates
2. **Read TEST_DATA**: If CRITICAL tier, read @TEST_DATA from .ai/standards/semantics.md
3. **Write test**: Follow co-location strategy
- Python: `src/module/__tests__/test_module.py`
- Svelte: `src/lib/components/__tests__/test_component.test.js`
4. **Use mocks**: Use `unittest.mock.MagicMock` for external dependencies
Every task MUST strictly follow this format:
### 4a. UX Contract Testing (Frontend Components)
```text
- [ ] [TaskID] [P?] [Story?] Description with file path
For Svelte components with `@UX_STATE`, `@UX_FEEDBACK`, `@UX_RECOVERY` tags:
1. **Parse UX tags**: Read component file and extract all `@UX_*` annotations
2. **Generate UX tests**: Create tests for each UX state transition
```javascript
// Example: Testing @UX_STATE: Idle -> Expanded
it('should transition from Idle to Expanded on toggle click', async () => {
render(Sidebar);
const toggleBtn = screen.getByRole('button', { name: /toggle/i });
await fireEvent.click(toggleBtn);
expect(screen.getByTestId('sidebar')).toHaveClass('expanded');
});
```
3. **Test @UX_FEEDBACK**: Verify visual feedback (toast, shake, color changes)
4. **Test @UX_RECOVERY**: Verify error recovery mechanisms (retry, clear input)
5. **Use @UX_TEST fixtures**: If component has `@UX_TEST` tags, use them as test specifications
**UX Test Template:**
```javascript
// [DEF:__tests__/test_Component:Module]
// @RELATION: VERIFIES -> ../Component.svelte
// @PURPOSE: Test UX states and transitions
describe('Component UX States', () => {
// @UX_STATE: Idle -> {action: click, expected: Active}
it('should transition Idle -> Active on click', async () => { ... });
// @UX_FEEDBACK: Toast on success
it('should show toast on successful action', async () => { ... });
// @UX_RECOVERY: Retry on error
it('should allow retry on error', async () => { ... });
});
```
**Examples**:
-`- [ ] T005 [US1] Create unit tests for OrderService in src/services/__tests__/test_order.py (Mock DB)`
-`- [ ] T006 [US1] Implement OrderService in src/services/order.py`
-`- [ ] T005 [US1] Create tests in backend/tests/test_order.py` (VIOLATION: Wrong location)
### 5. Test Documentation
### Task Organization & Phase Structure
Create/update documentation in `specs/<feature>/tests/`:
**Phase 1: Context & Setup**
- **Goal**: Prepare environment and understand existing patterns.
- **Mandatory Task**: `- [ ] T001 Analyze existing project structure, auth patterns, and `conftest.py` location`
```
tests/
├── README.md # Test strategy and overview
├── coverage.md # Coverage matrix and reports
└── reports/
└── YYYY-MM-DD-report.md
```
**Phase 2: Foundational (Data & Core)**
- Database Models (ORM).
- Pydantic Schemas (DTOs).
- Core Service interfaces.
### 6. Execute Tests
**Phase 3+: User Stories (Iterative)**
- **Step 1: Isolation Tests (Co-located)**:
- `- [ ] Txxx [USx] Create unit tests for [Component] in [Path]/__tests__/test_[name].py`
- *Note: Specify using MagicMock for external deps.*
- **Step 2: Implementation**: Services -> Endpoints.
- **Step 3: Integration**: Wire up real dependencies (if E2E tests requested).
- **Step 4: UX Verification**.
Run tests and report results:
**Final Phase: Polish**
- Linting, formatting, final manual verify.
**Backend:**
```bash
cd backend && .venv/bin/python3 -m pytest -v
```
**Frontend:**
```bash
cd frontend && npm run test
```
### 7. Update Tasks
Mark test tasks as completed in tasks.md with:
- Test file path
- Coverage achieved
- Any issues found
## Output
Generate test execution report:
```markdown
# Test Report: [FEATURE]
**Date**: [YYYY-MM-DD]
**Executed by**: Tester Agent
## Coverage Summary
| Module | Tests | Coverage % |
|--------|-------|------------|
| ... | ... | ... |
## Test Results
- Total: [X]
- Passed: [X]
- Failed: [X]
- Skipped: [X]
## Issues Found
| Test | Error | Resolution |
|------|-------|------------|
| ... | ... | ... |
## Next Steps
- [ ] Fix failed tests
- [ ] Add more coverage for [module]
- [ ] Review TEST_DATA fixtures
```
## Context for Testing
$ARGUMENTS

View File

@@ -1,25 +1,39 @@
customModes:
- slug: tester
name: Tester
description: QA and Plan Verification Specialist
description: QA and Test Engineer - Full Testing Cycle
roleDefinition: |-
You are Kilo Code, acting as a QA and Verification Specialist. Your primary goal is to validate that the project implementation aligns strictly with the defined specifications and task plans.
Your responsibilities include: - Reading and analyzing task plans and specifications (typically in the `specs/` directory). - Verifying that implemented code matches the requirements. - Executing tests and validating system behavior via CLI or Browser. - Updating the status of tasks in the plan files (e.g., marking checkboxes [x]) as they are verified. - Identifying and reporting missing features or bugs.
whenToUse: Use this mode when you need to audit the progress of a project, verify completed tasks against the plan, run quality assurance checks, or update the status of task lists in specification documents.
You are Kilo Code, acting as a QA and Test Engineer. Your primary goal is to ensure maximum test coverage, maintain test quality, and preserve existing tests.
Your responsibilities include:
- WRITING TESTS: Create comprehensive unit tests following TDD principles, using co-location strategy (`__tests__` directories).
- TEST DATA: For CRITICAL tier modules, you MUST use @TEST_DATA fixtures defined in .ai/standards/semantics.md. Read and apply them in your tests.
- DOCUMENTATION: Maintain test documentation in `specs/<feature>/tests/` directory with coverage reports and test case specifications.
- VERIFICATION: Run tests, analyze results, and ensure all tests pass.
- PROTECTION: NEVER delete existing tests. NEVER duplicate tests - check for existing tests first.
whenToUse: Use this mode when you need to write tests, run test coverage analysis, or perform quality assurance with full testing cycle.
groups:
- read
- edit
- command
- browser
- mcp
customInstructions: 1. Always begin by loading the relevant plan or task list from the `specs/` directory. 2. Do not assume a task is done just because it is checked; verify the code or functionality first if asked to audit. 3. When updating task lists, ensure you only mark items as complete if you have verified them.
customInstructions: |
1. KNOWLEDGE GRAPH: ALWAYS read .ai/ROOT.md first to understand the project structure and navigation.
2. CO-LOCATION: Write tests in `__tests__` subdirectories relative to the code being tested (Fractal Strategy).
2. TEST DATA MANDATORY: For CRITICAL modules, read @TEST_DATA from .ai/standards/semantics.md and use fixtures in tests.
3. UX CONTRACT TESTING: For Svelte components with @UX_STATE, @UX_FEEDBACK, @UX_RECOVERY tags, create comprehensive UX tests.
4. NO DELETION: Never delete existing tests - only update if they fail due to legitimate bugs.
5. NO DUPLICATION: Check existing tests in `__tests__/` before creating new ones. Reuse existing test patterns.
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 `semantic_protocol.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`, `specs/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 `semantic_protocol.md` when modifying code.
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 `semantic_protocol.md` standards.
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:
@@ -33,11 +47,36 @@ customModes:
name: Product Manager
roleDefinition: |-
Your purpose is to rigorously execute the workflows defined in `.kilocode/workflows/`.
You act as the orchestrator for: - Specification (`speckit.specify`, `speckit.clarify`) - Planning (`speckit.plan`) - Task Management (`speckit.tasks`, `speckit.taskstoissues`) - Quality Assurance (`speckit.analyze`, `speckit.checklist`) - Governance (`speckit.constitution`) - Implementation Oversight (`speckit.implement`)
You act as the orchestrator for: - Specification (`speckit.specify`, `speckit.clarify`) - Planning (`speckit.plan`) - Task Management (`speckit.tasks`, `speckit.taskstoissues`) - Quality Assurance (`speckit.analyze`, `speckit.checklist`, `speckit.test`, `speckit.fix`) - Governance (`speckit.constitution`) - Implementation Oversight (`speckit.implement`)
For each task, you must read the relevant workflow file from `.kilocode/workflows/` and follow its Execution Steps precisely.
whenToUse: Use this mode when you need to run any /speckit.* command or when dealing with high-level feature planning, specification writing, or project management tasks.
description: Executes SpecKit workflows for feature management
customInstructions: 1. Always read the specific workflow file in `.kilocode/workflows/` before executing a command. 2. Adhere strictly to the "Operating Constraints" and "Execution Steps" in the workflow files.
customInstructions: 1. Always read `.ai/ROOT.md` first to understand the Knowledge Graph structure. 2. Read the specific workflow file in `.kilocode/workflows/` before executing a command. 3. Adhere strictly to the "Operating Constraints" and "Execution Steps" in the workflow files.
groups:
- read
- edit
- command
- mcp
source: project
- slug: coder
name: Coder
roleDefinition: You are Kilo Code, acting as an Implementation Specialist. Your primary goal is to write code that strictly follows the Semantic Protocol defined in `.ai/standards/semantics.md`.
whenToUse: Use this mode when you need to implement features, write code, or fix issues based on test reports.
description: Implementation Specialist - Semantic Protocol Compliant
customInstructions: |
1. KNOWLEDGE GRAPH: ALWAYS read .ai/ROOT.md first to understand the project structure and navigation.
2. CONSTITUTION: Strictly follow architectural invariants in .ai/standards/constitution.md.
3. SEMANTIC PROTOCOL: ALWAYS use .ai/standards/semantics.md as your source of truth for syntax.
4. ANCHOR FORMAT: Use #[DEF:filename:Type] at start and #[/DEF:filename] at end.
3. TAGS: Add @PURPOSE, @LAYER, @TIER, @RELATION, @PRE, @POST, @UX_STATE, @UX_FEEDBACK, @UX_RECOVERY.
4. TIER COMPLIANCE:
- CRITICAL: Full contract + all UX tags + strict logging
- STANDARD: Basic contract + UX tags where applicable
- TRIVIAL: Only anchors + @PURPOSE
5. CODE SIZE: Keep modules under 300 lines. Refactor if exceeding.
6. ERROR HANDLING: Use if/raise or guards, never assert.
7. TEST FIXES: When fixing failing tests, preserve semantic annotations. Only update code logic.
8. RUN TESTS: After fixes, run tests to verify: `cd backend && .venv/bin/python3 -m pytest` or `cd frontend && npm run test`.
groups:
- read
- edit

View File

@@ -1,55 +0,0 @@
<!--
SYNC IMPACT REPORT
Version: 2.2.0 (ConfigManager Discipline)
Changes:
- Updated Principle II: Added mandatory requirement for using `ConfigManager` (via dependency injection) for all configuration access to ensure consistent environment handling and avoid hardcoded values.
- Updated Principle III: Refined `requestApi` requirement.
Templates Status:
- .specify/templates/plan-template.md: ✅ Aligned.
- .specify/templates/spec-template.md: ✅ Aligned.
- .specify/templates/tasks-template.md: ✅ Aligned.
-->
# Semantic Code Generation Constitution
## Core Principles
### I. Semantic Protocol Compliance
The file `semantic_protocol.md` is the **sole and authoritative technical standard** for this project.
- **Law**: All code must adhere to the Axioms (Meaning First, Contract First, etc.) defined in the Protocol.
- **Syntax & Structure**: Anchors (`[DEF]`), Tags (`@KEY`), and File Structures must strictly match the Protocol.
- **Compliance**: Any deviation from `semantic_protocol.md` constitutes a build failure.
### II. Everything is a Plugin & Centralized Config
All functional extensions, tools, or major features must be implemented as modular Plugins inheriting from `PluginBase`.
- **Modularity**: Logic should not reside in standalone services or scripts unless strictly necessary for core infrastructure. This ensures a unified execution model via the `TaskManager`.
- **Configuration Discipline**: All configuration access (environments, settings, paths) MUST use the `ConfigManager`. In the backend, the singleton instance MUST be obtained via dependency injection (`get_config_manager()`). Hardcoding environment IDs (e.g., "1") or paths is STRICTLY FORBIDDEN.
### III. Unified Frontend Experience
To ensure a consistent and accessible user experience, all frontend implementations must strictly adhere to the unified design and localization standards.
- **Component Reusability**: All UI elements MUST utilize the standardized Svelte component library (`src/lib/ui`) and centralized design tokens.
- **Internationalization (i18n)**: All user-facing text MUST be extracted to the translation system (`src/lib/i18n`).
- **Backend Communication**: All API requests MUST use the `requestApi` wrapper (or its derivatives like `fetchApi`, `postApi`) from `src/lib/api.js`. Direct use of the native `fetch` API for backend communication is FORBIDDEN to ensure consistent authentication (JWT) and error handling.
### IV. Security & Access Control
To support the Role-Based Access Control (RBAC) system, all functional components must define explicit permissions.
- **Granular Permissions**: Every Plugin MUST define a unique permission string (e.g., `plugin:name:execute`) required for its operation.
- **Registration**: These permissions MUST be registered in the system database (`auth.db`) during initialization.
### V. Independent Testability
Every feature specification MUST define "Independent Tests" that allow the feature to be verified in isolation.
- **Decoupling**: Features should be designed such that they can be tested without requiring the full application state or external dependencies where possible.
- **Verification**: A feature is not complete until its Independent Test scenarios pass.
### VI. Asynchronous Execution
All long-running or resource-intensive operations (migrations, analysis, backups, external API calls) MUST be executed as asynchronous tasks via the `TaskManager`.
- **Non-Blocking**: HTTP API endpoints MUST NOT block on these operations; they should spawn a task and return a Task ID.
- **Observability**: Tasks MUST emit real-time status updates via the WebSocket infrastructure.
## Governance
This Constitution establishes the "Semantic Code Generation Protocol" as the supreme law of this repository.
- **Authoritative Source**: `semantic_protocol.md` defines the specific implementation rules for Principle I.
- **Amendments**: Changes to core principles require a Constitution amendment. Changes to technical syntax require a Protocol update.
- **Compliance**: Failure to adhere to the Protocol constitutes a build failure.
**Version**: 2.2.0 | **Ratified**: 2025-12-19 | **Last Amended**: 2026-01-29

View File

@@ -2,6 +2,12 @@
Auto-generated from all feature plans. Last updated: [DATE]
## Knowledge Graph (GRACE)
**CRITICAL**: This project uses a GRACE Knowledge Graph for context. Always load the root map first:
- **Root Map**: `.ai/ROOT.md` -> `[DEF:Project_Knowledge_Map:Root]`
- **Project Map**: `.ai/PROJECT_MAP.md` -> `[DEF:Project_Map]`
- **Standards**: Read `.ai/standards/` for architecture and style rules.
## Active Technologies
[EXTRACTED FROM ALL PLAN.MD FILES]

View File

@@ -18,7 +18,7 @@
-->
**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION]
**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION]
**Primary Dependencies**: [e.g., FastAPI, Tailwind CSS, SvelteKit or NEEDS CLARIFICATION]
**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION]
**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION]
@@ -102,3 +102,14 @@ directories captured above]
|-----------|------------|-------------------------------------|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
## Test Data Reference
> **For CRITICAL tier components, reference test fixtures from spec.md**
| Component | TIER | Fixture Name | Location |
|-----------|------|--------------|----------|
| [e.g., DashboardAPI] | CRITICAL | valid_dashboard | spec.md#test-data-fixtures |
| [e.g., TaskDrawer] | CRITICAL | task_states | spec.md#test-data-fixtures |
**Note**: Tester Agent MUST use these fixtures when writing unit tests for CRITICAL modules. See `.ai/standards/semantics.md` for @TEST_DATA syntax.

View File

@@ -114,3 +114,52 @@
- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"]
- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"]
- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"]
---
## Test Data Fixtures *(recommended for CRITICAL components)*
<!--
Define reference/fixture data for testing CRITICAL tier components.
This data will be used by the Tester Agent when writing unit tests.
Format: JSON or YAML that matches the component's data structures.
-->
### Fixtures
```yaml
# Example fixture format
fixture_name:
description: "Description of this test data"
data:
# JSON or YAML data structure
```
### Example: Dashboard API
```yaml
valid_dashboard:
description: "Valid dashboard object for API responses"
data:
id: 1
title: "Sales Report"
slug: "sales"
git_status:
branch: "main"
sync_status: "OK"
last_task:
task_id: "task-123"
status: "SUCCESS"
empty_dashboards:
description: "Empty dashboard list response"
data:
dashboards: []
total: 0
page: 1
error_not_found:
description: "404 error response"
data:
detail: "Dashboard not found"
```

View File

@@ -93,7 +93,8 @@ Examples of foundational tasks (adjust based on your project):
- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013)
- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py
- [ ] T016 [US1] Add validation and error handling
- [ ] T017 [US1] Add logging for user story 1 operations
- [ ] T017 [US1] [P] Implement UI using Tailwind CSS (minimize scoped styles)
- [ ] T018 [US1] Add logging for user story 1 operations
**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently

View File

@@ -0,0 +1,152 @@
---
description: "Test documentation template for feature implementation"
---
# Test Documentation: [FEATURE NAME]
**Feature**: [Link to spec.md]
**Created**: [DATE]
**Updated**: [DATE]
**Tester**: [Agent/User Name]
---
## Overview
[Brief description of what this feature does and why testing is important]
**Test Strategy**:
- [ ] Unit Tests (co-located in `__tests__/` directories)
- [ ] Integration Tests (if needed)
- [ ] E2E Tests (if critical user flows)
- [ ] Contract Tests (for API endpoints)
---
## Test Coverage Matrix
| Module | File | Unit Tests | Coverage % | Status |
|--------|------|------------|------------|--------|
| [Module Name] | `path/to/file.py` | [x] | [XX%] | [Pass/Fail] |
| [Module Name] | `path/to/file.svelte` | [x] | [XX%] | [Pass/Fail] |
---
## Test Cases
### [Module Name]
**Target File**: `path/to/module.py`
| ID | Test Case | Type | Expected Result | Status |
|----|-----------|------|------------------|--------|
| TC001 | [Description] | [Unit/Integration] | [Expected] | [Pass/Fail] |
| TC002 | [Description] | [Unit/Integration] | [Expected] | [Pass/Fail] |
---
## Test Execution Reports
### Report [YYYY-MM-DD]
**Executed by**: [Tester]
**Duration**: [X] minutes
**Result**: [Pass/Fail]
**Summary**:
- Total Tests: [X]
- Passed: [X]
- Failed: [X]
- Skipped: [X]
**Failed Tests**:
| Test | Error | Resolution |
|------|-------|-------------|
| [Test Name] | [Error Message] | [How Fixed] |
---
## Anti-Patterns & Rules
### ✅ DO
1. Write tests BEFORE implementation (TDD approach)
2. Use co-location: `src/module/__tests__/test_module.py`
3. Use MagicMock for external dependencies (DB, Auth, APIs)
4. Include semantic annotations: `# @RELATION: VERIFIES -> module.name`
5. Test edge cases and error conditions
6. **Test UX states** for Svelte components (@UX_STATE, @UX_FEEDBACK, @UX_RECOVERY)
### ❌ DON'T
1. Delete existing tests (only update if they fail)
2. Duplicate tests - check for existing tests first
3. Test implementation details, not behavior
4. Use real external services in unit tests
5. Skip error handling tests
6. **Skip UX contract tests** for CRITICAL frontend components
---
## UX Contract Testing (Frontend)
### UX States Coverage
| Component | @UX_STATE | @UX_FEEDBACK | @UX_RECOVERY | Tests |
|-----------|-----------|--------------|--------------|-------|
| [Component] | [states] | [feedback] | [recovery] | [status] |
### UX Test Cases
| ID | Component | UX Tag | Test Action | Expected Result | Status |
|----|-----------|--------|-------------|-----------------|--------|
| UX001 | [Component] | @UX_STATE: Idle | [action] | [expected] | [Pass/Fail] |
| UX002 | [Component] | @UX_FEEDBACK | [action] | [expected] | [Pass/Fail] |
| UX003 | [Component] | @UX_RECOVERY | [action] | [expected] | [Pass/Fail] |
### UX Test Examples
```javascript
// Testing @UX_STATE transition
it('should transition from Idle to Loading on submit', async () => {
render(FormComponent);
await fireEvent.click(screen.getByText('Submit'));
expect(screen.getByTestId('form')).toHaveClass('loading');
});
// Testing @UX_FEEDBACK
it('should show error toast on validation failure', async () => {
render(FormComponent);
await fireEvent.click(screen.getByText('Submit'));
expect(screen.getByRole('alert')).toHaveTextContent('Validation error');
});
// Testing @UX_RECOVERY
it('should allow retry after error', async () => {
render(FormComponent);
// Trigger error state
await fireEvent.click(screen.getByText('Submit'));
// Click retry
await fireEvent.click(screen.getByText('Retry'));
expect(screen.getByTestId('form')).not.toHaveClass('error');
});
```
---
## Notes
- [Additional notes about testing approach]
- [Known issues or limitations]
- [Recommendations for future testing]
---
## Related Documents
- [spec.md](./spec.md)
- [plan.md](./plan.md)
- [tasks.md](./tasks.md)
- [contracts/](./contracts/)

View File

@@ -32,7 +32,7 @@
## Технологический стек
- **Backend**: Python 3.9+, FastAPI, SQLAlchemy, APScheduler, Pydantic.
- **Frontend**: Node.js 18+, SvelteKit, Tailwind CSS.
- **Database**: SQLite (для хранения метаданных, задач и настроек доступа).
- **Database**: PostgreSQL (для хранения метаданных, задач, логов и конфигурации).
## Структура проекта
- `backend/` — Серверная часть, API и логика плагинов.
@@ -61,10 +61,14 @@
Переменные окружения:
- `BACKEND_PORT`: Порт API (по умолчанию 8000).
- `FRONTEND_PORT`: Порт UI (по умолчанию 5173).
- `POSTGRES_URL`: Базовый URL PostgreSQL по умолчанию для всех подсистем.
- `DATABASE_URL`: URL основной БД (если не задан, используется `POSTGRES_URL`).
- `TASKS_DATABASE_URL`: URL БД задач/логов (если не задан, используется `DATABASE_URL`).
- `AUTH_DATABASE_URL`: URL БД авторизации (если не задан, используется PostgreSQL дефолт).
## Разработка
Проект следует строгим правилам разработки:
1. **Semantic Code Generation**: Использование протокола `semantic_protocol.md` для обеспечения надежности кода.
1. **Semantic Code Generation**: Использование протокола `.ai/standards/semantics.md` для обеспечения надежности кода.
2. **Design by Contract (DbC)**: Определение предусловий и постусловий для ключевых функций.
3. **Constitution**: Соблюдение правил, описанных в конституции проекта в папке `.specify/`.
@@ -73,5 +77,52 @@
- **Frontend**: `cd frontend && npm run dev`
- **Тесты**: `cd backend && .venv/bin/pytest`
## Docker и CI/CD
### Локальный запуск в Docker (приложение + PostgreSQL)
```bash
docker compose up --build
```
После старта:
- UI/API: `http://localhost:8000`
- PostgreSQL: `localhost:5432` (`postgres/postgres`, DB `ss_tools`)
Остановить:
```bash
docker compose down
```
Полная очистка тома БД:
```bash
docker compose down -v
```
Если `postgres:16-alpine` не тянется из Docker Hub (TLS timeout), используйте fallback image:
```bash
POSTGRES_IMAGE=mirror.gcr.io/library/postgres:16-alpine docker compose up -d db
```
или:
```bash
POSTGRES_IMAGE=bitnami/postgresql:latest docker compose up -d db
```
Если на хосте уже занят `5432`, поднимайте Postgres на другом порту:
```bash
POSTGRES_HOST_PORT=5433 docker compose up -d db
```
### Миграция legacy-данных в PostgreSQL
Если нужно перенести старые данные из `tasks.db`/`config.json`:
```bash
cd backend
PYTHONPATH=. .venv/bin/python src/scripts/migrate_sqlite_to_postgres.py --sqlite-path tasks.db
```
### CI/CD
Добавлен workflow: `.github/workflows/ci-cd.yml`
- backend smoke tests
- frontend build
- docker build
- push образа в GHCR на `main/master`
## Контакты и вклад
Для добавления новых функций или исправления ошибок, пожалуйста, ознакомьтесь с `docs/plugin_dev.md` и создайте соответствующую спецификацию в `specs/`.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -54,3 +54,4 @@ email-validator
openai
playwright
tenacity
Pillow

View File

@@ -1,3 +1,10 @@
from . import plugins, tasks, settings, connections, environments, mappings, migration, git, storage, admin
# Lazy loading of route modules to avoid import issues in tests
# This allows tests to import routes without triggering all module imports
__all__ = ['plugins', 'tasks', 'settings', 'connections', 'environments', 'mappings', 'migration', 'git', 'storage', 'admin']
__all__ = ['plugins', 'tasks', 'settings', 'connections', 'environments', 'mappings', 'migration', 'git', 'storage', 'admin', 'reports']
def __getattr__(name):
if name in __all__:
import importlib
return importlib.import_module(f".{name}", __name__)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@@ -0,0 +1,357 @@
# [DEF:backend.src.api.routes.__tests__.test_dashboards:Module]
# @TIER: STANDARD
# @PURPOSE: Unit tests for Dashboards API endpoints
# @LAYER: API
# @RELATION: TESTS -> backend.src.api.routes.dashboards
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from fastapi.testclient import TestClient
from src.app import app
from src.api.routes.dashboards import DashboardsResponse
client = TestClient(app)
# [DEF:test_get_dashboards_success:Function]
# @TEST: GET /api/dashboards returns 200 and valid schema
# @PRE: env_id exists
# @POST: Response matches DashboardsResponse schema
def test_get_dashboards_success():
with patch("src.api.routes.dashboards.get_config_manager") as mock_config, \
patch("src.api.routes.dashboards.get_resource_service") as mock_service, \
patch("src.api.routes.dashboards.get_task_manager") as mock_task_mgr, \
patch("src.api.routes.dashboards.has_permission") as mock_perm:
# Mock environment
mock_env = MagicMock()
mock_env.id = "prod"
mock_config.return_value.get_environments.return_value = [mock_env]
# Mock task manager
mock_task_mgr.return_value.get_all_tasks.return_value = []
# Mock resource service response
async def mock_get_dashboards(env, tasks):
return [
{
"id": 1,
"title": "Sales Report",
"slug": "sales",
"git_status": {"branch": "main", "sync_status": "OK"},
"last_task": {"task_id": "task-1", "status": "SUCCESS"}
}
]
mock_service.return_value.get_dashboards_with_status = AsyncMock(
side_effect=mock_get_dashboards
)
# Mock permission
mock_perm.return_value = lambda: True
response = client.get("/api/dashboards?env_id=prod")
assert response.status_code == 200
data = response.json()
assert "dashboards" in data
assert "total" in data
assert "page" in data
# [/DEF:test_get_dashboards_success:Function]
# [DEF:test_get_dashboards_with_search:Function]
# @TEST: GET /api/dashboards filters by search term
# @PRE: search parameter provided
# @POST: Only matching dashboards returned
def test_get_dashboards_with_search():
with patch("src.api.routes.dashboards.get_config_manager") as mock_config, \
patch("src.api.routes.dashboards.get_resource_service") as mock_service, \
patch("src.api.routes.dashboards.get_task_manager") as mock_task_mgr, \
patch("src.api.routes.dashboards.has_permission") as mock_perm:
# Mock environment
mock_env = MagicMock()
mock_env.id = "prod"
mock_config.return_value.get_environments.return_value = [mock_env]
mock_task_mgr.return_value.get_all_tasks.return_value = []
async def mock_get_dashboards(env, tasks):
return [
{"id": 1, "title": "Sales Report", "slug": "sales"},
{"id": 2, "title": "Marketing Dashboard", "slug": "marketing"}
]
mock_service.return_value.get_dashboards_with_status = AsyncMock(
side_effect=mock_get_dashboards
)
mock_perm.return_value = lambda: True
response = client.get("/api/dashboards?env_id=prod&search=sales")
assert response.status_code == 200
data = response.json()
# Filtered by search term
# [/DEF:test_get_dashboards_with_search:Function]
# [DEF:test_get_dashboards_env_not_found:Function]
# @TEST: GET /api/dashboards returns 404 if env_id missing
# @PRE: env_id does not exist
# @POST: Returns 404 error
def test_get_dashboards_env_not_found():
with patch("src.api.routes.dashboards.get_config_manager") as mock_config, \
patch("src.api.routes.dashboards.has_permission") as mock_perm:
mock_config.return_value.get_environments.return_value = []
mock_perm.return_value = lambda: True
response = client.get("/api/dashboards?env_id=nonexistent")
assert response.status_code == 404
assert "Environment not found" in response.json()["detail"]
# [/DEF:test_get_dashboards_env_not_found:Function]
# [DEF:test_get_dashboards_invalid_pagination:Function]
# @TEST: GET /api/dashboards returns 400 for invalid page/page_size
# @PRE: page < 1 or page_size > 100
# @POST: Returns 400 error
def test_get_dashboards_invalid_pagination():
with patch("src.api.routes.dashboards.get_config_manager") as mock_config, \
patch("src.api.routes.dashboards.has_permission") as mock_perm:
mock_env = MagicMock()
mock_env.id = "prod"
mock_config.return_value.get_environments.return_value = [mock_env]
mock_perm.return_value = lambda: True
# Invalid page
response = client.get("/api/dashboards?env_id=prod&page=0")
assert response.status_code == 400
assert "Page must be >= 1" in response.json()["detail"]
# Invalid page_size
response = client.get("/api/dashboards?env_id=prod&page_size=101")
assert response.status_code == 400
assert "Page size must be between 1 and 100" in response.json()["detail"]
# [/DEF:test_get_dashboards_invalid_pagination:Function]
# [DEF:test_get_dashboard_detail_success:Function]
# @TEST: GET /api/dashboards/{id} returns dashboard detail with charts and datasets
def test_get_dashboard_detail_success():
with patch("src.api.routes.dashboards.get_config_manager") as mock_config, \
patch("src.api.routes.dashboards.has_permission") as mock_perm, \
patch("src.api.routes.dashboards.SupersetClient") as mock_client_cls:
mock_env = MagicMock()
mock_env.id = "prod"
mock_config.return_value.get_environments.return_value = [mock_env]
mock_perm.return_value = lambda: True
mock_client = MagicMock()
mock_client.get_dashboard_detail.return_value = {
"id": 42,
"title": "Revenue Dashboard",
"slug": "revenue-dashboard",
"url": "/superset/dashboard/42/",
"description": "Overview",
"last_modified": "2026-02-20T10:00:00+00:00",
"published": True,
"charts": [
{
"id": 100,
"title": "Revenue by Month",
"viz_type": "line",
"dataset_id": 7,
"last_modified": "2026-02-19T10:00:00+00:00",
"overview": "line"
}
],
"datasets": [
{
"id": 7,
"table_name": "fact_revenue",
"schema": "mart",
"database": "Analytics",
"last_modified": "2026-02-18T10:00:00+00:00",
"overview": "mart.fact_revenue"
}
],
"chart_count": 1,
"dataset_count": 1
}
mock_client_cls.return_value = mock_client
response = client.get("/api/dashboards/42?env_id=prod")
assert response.status_code == 200
payload = response.json()
assert payload["id"] == 42
assert payload["chart_count"] == 1
assert payload["dataset_count"] == 1
# [/DEF:test_get_dashboard_detail_success:Function]
# [DEF:test_get_dashboard_detail_env_not_found:Function]
# @TEST: GET /api/dashboards/{id} returns 404 for missing environment
def test_get_dashboard_detail_env_not_found():
with patch("src.api.routes.dashboards.get_config_manager") as mock_config, \
patch("src.api.routes.dashboards.has_permission") as mock_perm:
mock_config.return_value.get_environments.return_value = []
mock_perm.return_value = lambda: True
response = client.get("/api/dashboards/42?env_id=missing")
assert response.status_code == 404
assert "Environment not found" in response.json()["detail"]
# [/DEF:test_get_dashboard_detail_env_not_found:Function]
# [DEF:test_migrate_dashboards_success:Function]
# @TEST: POST /api/dashboards/migrate creates migration task
# @PRE: Valid source_env_id, target_env_id, dashboard_ids
# @POST: Returns task_id
def test_migrate_dashboards_success():
with patch("src.api.routes.dashboards.get_config_manager") as mock_config, \
patch("src.api.routes.dashboards.get_task_manager") as mock_task_mgr, \
patch("src.api.routes.dashboards.has_permission") as mock_perm:
# Mock environments
mock_source = MagicMock()
mock_source.id = "source"
mock_target = MagicMock()
mock_target.id = "target"
mock_config.return_value.get_environments.return_value = [mock_source, mock_target]
# Mock task manager
mock_task = MagicMock()
mock_task.id = "task-migrate-123"
mock_task_mgr.return_value.create_task = AsyncMock(return_value=mock_task)
# Mock permission
mock_perm.return_value = lambda: True
response = client.post(
"/api/dashboards/migrate",
json={
"source_env_id": "source",
"target_env_id": "target",
"dashboard_ids": [1, 2, 3],
"db_mappings": {"old_db": "new_db"}
}
)
assert response.status_code == 200
data = response.json()
assert "task_id" in data
# [/DEF:test_migrate_dashboards_success:Function]
# [DEF:test_migrate_dashboards_no_ids:Function]
# @TEST: POST /api/dashboards/migrate returns 400 for empty dashboard_ids
# @PRE: dashboard_ids is empty
# @POST: Returns 400 error
def test_migrate_dashboards_no_ids():
with patch("src.api.routes.dashboards.has_permission") as mock_perm:
mock_perm.return_value = lambda: True
response = client.post(
"/api/dashboards/migrate",
json={
"source_env_id": "source",
"target_env_id": "target",
"dashboard_ids": []
}
)
assert response.status_code == 400
assert "At least one dashboard ID must be provided" in response.json()["detail"]
# [/DEF:test_migrate_dashboards_no_ids:Function]
# [DEF:test_backup_dashboards_success:Function]
# @TEST: POST /api/dashboards/backup creates backup task
# @PRE: Valid env_id, dashboard_ids
# @POST: Returns task_id
def test_backup_dashboards_success():
with patch("src.api.routes.dashboards.get_config_manager") as mock_config, \
patch("src.api.routes.dashboards.get_task_manager") as mock_task_mgr, \
patch("src.api.routes.dashboards.has_permission") as mock_perm:
# Mock environment
mock_env = MagicMock()
mock_env.id = "prod"
mock_config.return_value.get_environments.return_value = [mock_env]
# Mock task manager
mock_task = MagicMock()
mock_task.id = "task-backup-456"
mock_task_mgr.return_value.create_task = AsyncMock(return_value=mock_task)
# Mock permission
mock_perm.return_value = lambda: True
response = client.post(
"/api/dashboards/backup",
json={
"env_id": "prod",
"dashboard_ids": [1, 2, 3],
"schedule": "0 0 * * *"
}
)
assert response.status_code == 200
data = response.json()
assert "task_id" in data
# [/DEF:test_backup_dashboards_success:Function]
# [DEF:test_get_database_mappings_success:Function]
# @TEST: GET /api/dashboards/db-mappings returns mapping suggestions
# @PRE: Valid source_env_id, target_env_id
# @POST: Returns list of database mappings
def test_get_database_mappings_success():
with patch("src.api.routes.dashboards.get_mapping_service") as mock_service, \
patch("src.api.routes.dashboards.has_permission") as mock_perm:
# Mock mapping service
mock_service.return_value.get_suggestions = AsyncMock(return_value=[
{
"source_db": "old_sales",
"target_db": "new_sales",
"source_db_uuid": "uuid-1",
"target_db_uuid": "uuid-2",
"confidence": 0.95
}
])
# Mock permission
mock_perm.return_value = lambda: True
response = client.get("/api/dashboards/db-mappings?source_env_id=prod&target_env_id=staging")
assert response.status_code == 200
data = response.json()
assert "mappings" in data
# [/DEF:test_get_database_mappings_success:Function]
# [/DEF:backend.src.api.routes.__tests__.test_dashboards:Module]

View File

@@ -0,0 +1,212 @@
# [DEF:backend.src.api.routes.__tests__.test_datasets:Module]
# @TIER: STANDARD
# @SEMANTICS: datasets, api, tests, pagination, mapping, docs
# @PURPOSE: Unit tests for Datasets API endpoints
# @LAYER: API
# @RELATION: TESTS -> backend.src.api.routes.datasets
# @INVARIANT: Endpoint contracts remain stable for success and validation failure paths.
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from fastapi.testclient import TestClient
from src.app import app
from src.api.routes.datasets import DatasetsResponse, DatasetDetailResponse
client = TestClient(app)
# [DEF:test_get_datasets_success:Function]
# @PURPOSE: Validate successful datasets listing contract for an existing environment.
# @TEST: GET /api/datasets returns 200 and valid schema
# @PRE: env_id exists
# @POST: Response matches DatasetsResponse schema
def test_get_datasets_success():
with patch("src.api.routes.datasets.get_config_manager") as mock_config, \
patch("src.api.routes.datasets.get_resource_service") as mock_service, \
patch("src.api.routes.datasets.has_permission") as mock_perm:
# Mock environment
mock_env = MagicMock()
mock_env.id = "prod"
mock_config.return_value.get_environments.return_value = [mock_env]
# Mock resource service response
mock_service.return_value.get_datasets_with_status.return_value = AsyncMock()(
return_value=[
{
"id": 1,
"table_name": "sales_data",
"schema": "public",
"database": "sales_db",
"mapped_fields": {"total": 10, "mapped": 5},
"last_task": {"task_id": "task-1", "status": "SUCCESS"}
}
]
)
# Mock permission
mock_perm.return_value = lambda: True
response = client.get("/api/datasets?env_id=prod")
assert response.status_code == 200
data = response.json()
assert "datasets" in data
assert len(data["datasets"]) >= 0
# Validate against Pydantic model
DatasetsResponse(**data)
# [/DEF:test_get_datasets_success:Function]
# [DEF:test_get_datasets_env_not_found:Function]
# @TEST: GET /api/datasets returns 404 if env_id missing
# @PRE: env_id does not exist
# @POST: Returns 404 error
def test_get_datasets_env_not_found():
with patch("src.api.routes.datasets.get_config_manager") as mock_config, \
patch("src.api.routes.datasets.has_permission") as mock_perm:
mock_config.return_value.get_environments.return_value = []
mock_perm.return_value = lambda: True
response = client.get("/api/datasets?env_id=nonexistent")
assert response.status_code == 404
assert "Environment not found" in response.json()["detail"]
# [/DEF:test_get_datasets_env_not_found:Function]
# [DEF:test_get_datasets_invalid_pagination:Function]
# @TEST: GET /api/datasets returns 400 for invalid page/page_size
# @PRE: page < 1 or page_size > 100
# @POST: Returns 400 error
def test_get_datasets_invalid_pagination():
with patch("src.api.routes.datasets.get_config_manager") as mock_config, \
patch("src.api.routes.datasets.has_permission") as mock_perm:
mock_env = MagicMock()
mock_env.id = "prod"
mock_config.return_value.get_environments.return_value = [mock_env]
mock_perm.return_value = lambda: True
# Invalid page
response = client.get("/api/datasets?env_id=prod&page=0")
assert response.status_code == 400
assert "Page must be >= 1" in response.json()["detail"]
# Invalid page_size
response = client.get("/api/datasets?env_id=prod&page_size=0")
assert response.status_code == 400
assert "Page size must be between 1 and 100" in response.json()["detail"]
# [/DEF:test_get_datasets_invalid_pagination:Function]
# [DEF:test_map_columns_success:Function]
# @TEST: POST /api/datasets/map-columns creates mapping task
# @PRE: Valid env_id, dataset_ids, source_type
# @POST: Returns task_id
def test_map_columns_success():
with patch("src.api.routes.datasets.get_config_manager") as mock_config, \
patch("src.api.routes.datasets.get_task_manager") as mock_task_mgr, \
patch("src.api.routes.datasets.has_permission") as mock_perm:
# Mock environment
mock_env = MagicMock()
mock_env.id = "prod"
mock_config.return_value.get_environments.return_value = [mock_env]
# Mock task manager
mock_task = MagicMock()
mock_task.id = "task-123"
mock_task_mgr.return_value.create_task = AsyncMock(return_value=mock_task)
# Mock permission
mock_perm.return_value = lambda: True
response = client.post(
"/api/datasets/map-columns",
json={
"env_id": "prod",
"dataset_ids": [1, 2, 3],
"source_type": "postgresql"
}
)
assert response.status_code == 200
data = response.json()
assert "task_id" in data
# [/DEF:test_map_columns_success:Function]
# [DEF:test_map_columns_invalid_source_type:Function]
# @TEST: POST /api/datasets/map-columns returns 400 for invalid source_type
# @PRE: source_type is not 'postgresql' or 'xlsx'
# @POST: Returns 400 error
def test_map_columns_invalid_source_type():
with patch("src.api.routes.datasets.has_permission") as mock_perm:
mock_perm.return_value = lambda: True
response = client.post(
"/api/datasets/map-columns",
json={
"env_id": "prod",
"dataset_ids": [1],
"source_type": "invalid"
}
)
assert response.status_code == 400
assert "Source type must be 'postgresql' or 'xlsx'" in response.json()["detail"]
# [/DEF:test_map_columns_invalid_source_type:Function]
# [DEF:test_generate_docs_success:Function]
# @TEST: POST /api/datasets/generate-docs creates doc generation task
# @PRE: Valid env_id, dataset_ids, llm_provider
# @POST: Returns task_id
def test_generate_docs_success():
with patch("src.api.routes.datasets.get_config_manager") as mock_config, \
patch("src.api.routes.datasets.get_task_manager") as mock_task_mgr, \
patch("src.api.routes.datasets.has_permission") as mock_perm:
# Mock environment
mock_env = MagicMock()
mock_env.id = "prod"
mock_config.return_value.get_environments.return_value = [mock_env]
# Mock task manager
mock_task = MagicMock()
mock_task.id = "task-456"
mock_task_mgr.return_value.create_task = AsyncMock(return_value=mock_task)
# Mock permission
mock_perm.return_value = lambda: True
response = client.post(
"/api/datasets/generate-docs",
json={
"env_id": "prod",
"dataset_ids": [1],
"llm_provider": "openai"
}
)
assert response.status_code == 200
data = response.json()
assert "task_id" in data
# [/DEF:test_generate_docs_success:Function]
# [/DEF:backend.src.api.routes.__tests__.test_datasets:Module]

View File

@@ -0,0 +1,139 @@
# [DEF:backend.tests.test_reports_api:Module]
# @TIER: CRITICAL
# @SEMANTICS: tests, reports, api, contract, pagination, filtering
# @PURPOSE: Contract tests for GET /api/reports defaults, pagination, and filtering behavior.
# @LAYER: Domain (Tests)
# @RELATION: TESTS -> backend.src.api.routes.reports
# @INVARIANT: API response contract contains {items,total,page,page_size,has_next,applied_filters}.
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from fastapi.testclient import TestClient
from src.app import app
from src.core.task_manager.models import Task, TaskStatus
from src.dependencies import get_current_user, get_task_manager
class _FakeTaskManager:
def __init__(self, tasks):
self._tasks = tasks
def get_all_tasks(self):
return self._tasks
def _admin_user():
admin_role = SimpleNamespace(name="Admin", permissions=[])
return SimpleNamespace(username="test-admin", roles=[admin_role])
def _make_task(task_id: str, plugin_id: str, status: TaskStatus, started_at: datetime, finished_at: datetime = None, result=None):
return Task(
id=task_id,
plugin_id=plugin_id,
status=status,
started_at=started_at,
finished_at=finished_at,
params={"environment_id": "env-1"},
result=result or {"summary": f"{plugin_id} {status.value.lower()}"},
)
def test_get_reports_default_pagination_contract():
now = datetime.utcnow()
tasks = [
_make_task("t-1", "superset-backup", TaskStatus.SUCCESS, now - timedelta(minutes=10), now - timedelta(minutes=9)),
_make_task("t-2", "superset-migration", TaskStatus.FAILED, now - timedelta(minutes=8), now - timedelta(minutes=7)),
_make_task("t-3", "llm_dashboard_validation", TaskStatus.RUNNING, now - timedelta(minutes=6), None),
]
app.dependency_overrides[get_current_user] = lambda: _admin_user()
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
try:
client = TestClient(app)
response = client.get("/api/reports")
assert response.status_code == 200
data = response.json()
assert set(["items", "total", "page", "page_size", "has_next", "applied_filters"]).issubset(data.keys())
assert data["page"] == 1
assert data["page_size"] == 20
assert data["total"] == 3
assert isinstance(data["items"], list)
assert data["applied_filters"]["sort_by"] == "updated_at"
assert data["applied_filters"]["sort_order"] == "desc"
finally:
app.dependency_overrides.clear()
def test_get_reports_filter_and_pagination():
now = datetime.utcnow()
tasks = [
_make_task("t-1", "superset-backup", TaskStatus.SUCCESS, now - timedelta(minutes=30), now - timedelta(minutes=29)),
_make_task("t-2", "superset-backup", TaskStatus.FAILED, now - timedelta(minutes=20), now - timedelta(minutes=19)),
_make_task("t-3", "superset-migration", TaskStatus.FAILED, now - timedelta(minutes=10), now - timedelta(minutes=9)),
]
app.dependency_overrides[get_current_user] = lambda: _admin_user()
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
try:
client = TestClient(app)
response = client.get("/api/reports?task_types=backup&statuses=failed&page=1&page_size=1")
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert data["page"] == 1
assert data["page_size"] == 1
assert data["has_next"] is False
assert len(data["items"]) == 1
assert data["items"][0]["task_type"] == "backup"
assert data["items"][0]["status"] == "failed"
finally:
app.dependency_overrides.clear()
def test_get_reports_handles_mixed_naive_and_aware_datetimes():
naive_now = datetime.utcnow()
aware_now = datetime.now(timezone.utc)
tasks = [
_make_task("t-naive", "superset-backup", TaskStatus.SUCCESS, naive_now - timedelta(minutes=5), naive_now - timedelta(minutes=4)),
_make_task("t-aware", "superset-migration", TaskStatus.FAILED, aware_now - timedelta(minutes=3), aware_now - timedelta(minutes=2)),
]
app.dependency_overrides[get_current_user] = lambda: _admin_user()
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
try:
client = TestClient(app)
response = client.get("/api/reports?sort_by=updated_at&sort_order=desc")
assert response.status_code == 200
data = response.json()
assert data["total"] == 2
assert len(data["items"]) == 2
finally:
app.dependency_overrides.clear()
def test_get_reports_invalid_filter_returns_400():
now = datetime.utcnow()
tasks = [_make_task("t-1", "superset-backup", TaskStatus.SUCCESS, now - timedelta(minutes=5), now - timedelta(minutes=4))]
app.dependency_overrides[get_current_user] = lambda: _admin_user()
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
try:
client = TestClient(app)
response = client.get("/api/reports?task_types=bad_type")
assert response.status_code == 400
body = response.json()
assert "detail" in body
finally:
app.dependency_overrides.clear()
# [/DEF:backend.tests.test_reports_api:Module]

View File

@@ -0,0 +1,84 @@
# [DEF:backend.tests.test_reports_detail_api:Module]
# @TIER: CRITICAL
# @SEMANTICS: tests, reports, api, detail, diagnostics
# @PURPOSE: Contract tests for GET /api/reports/{report_id} detail endpoint behavior.
# @LAYER: Domain (Tests)
# @RELATION: TESTS -> backend.src.api.routes.reports
# @INVARIANT: Detail endpoint tests must keep deterministic assertions for success and not-found contracts.
from datetime import datetime, timedelta
from types import SimpleNamespace
from fastapi.testclient import TestClient
from src.app import app
from src.core.task_manager.models import Task, TaskStatus
from src.dependencies import get_current_user, get_task_manager
class _FakeTaskManager:
def __init__(self, tasks):
self._tasks = tasks
def get_all_tasks(self):
return self._tasks
def _admin_user():
role = SimpleNamespace(name="Admin", permissions=[])
return SimpleNamespace(username="test-admin", roles=[role])
def _make_task(task_id: str, plugin_id: str, status: TaskStatus, result=None):
now = datetime.utcnow()
return Task(
id=task_id,
plugin_id=plugin_id,
status=status,
started_at=now - timedelta(minutes=2),
finished_at=now - timedelta(minutes=1) if status != TaskStatus.RUNNING else None,
params={"environment_id": "env-1"},
result=result or {"summary": f"{plugin_id} result"},
)
def test_get_report_detail_success():
task = _make_task(
"detail-1",
"superset-migration",
TaskStatus.FAILED,
result={"error": {"message": "Step failed", "next_actions": ["Check mapping", "Retry"]}},
)
app.dependency_overrides[get_current_user] = lambda: _admin_user()
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager([task])
try:
client = TestClient(app)
response = client.get("/api/reports/detail-1")
assert response.status_code == 200
data = response.json()
assert "report" in data
assert data["report"]["report_id"] == "detail-1"
assert "diagnostics" in data
assert "next_actions" in data
finally:
app.dependency_overrides.clear()
def test_get_report_detail_not_found():
task = _make_task("detail-2", "superset-backup", TaskStatus.SUCCESS)
app.dependency_overrides[get_current_user] = lambda: _admin_user()
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager([task])
try:
client = TestClient(app)
response = client.get("/api/reports/unknown-id")
assert response.status_code == 404
finally:
app.dependency_overrides.clear()
# [/DEF:backend.tests.test_reports_detail_api:Module]

View File

@@ -0,0 +1,81 @@
# [DEF:backend.tests.test_reports_openapi_conformance:Module]
# @TIER: CRITICAL
# @SEMANTICS: tests, reports, openapi, conformance
# @PURPOSE: Validate implemented reports payload shape against OpenAPI-required top-level contract fields.
# @LAYER: Domain (Tests)
# @RELATION: TESTS -> specs/020-task-reports-design/contracts/reports-api.openapi.yaml
# @INVARIANT: List and detail payloads include required contract keys.
from datetime import datetime
from types import SimpleNamespace
from fastapi.testclient import TestClient
from src.app import app
from src.core.task_manager.models import Task, TaskStatus
from src.dependencies import get_current_user, get_task_manager
class _FakeTaskManager:
def __init__(self, tasks):
self._tasks = tasks
def get_all_tasks(self):
return self._tasks
def _admin_user():
role = SimpleNamespace(name="Admin", permissions=[])
return SimpleNamespace(username="test-admin", roles=[role])
def _task(task_id: str, plugin_id: str, status: TaskStatus):
now = datetime.utcnow()
return Task(
id=task_id,
plugin_id=plugin_id,
status=status,
started_at=now,
finished_at=now if status != TaskStatus.RUNNING else None,
params={"environment_id": "env-1"},
result={"summary": f"{plugin_id} {status.value.lower()}"},
)
def test_reports_list_openapi_required_keys():
tasks = [
_task("r-1", "superset-backup", TaskStatus.SUCCESS),
_task("r-2", "superset-migration", TaskStatus.FAILED),
]
app.dependency_overrides[get_current_user] = lambda: _admin_user()
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
try:
client = TestClient(app)
response = client.get("/api/reports")
assert response.status_code == 200
body = response.json()
required = {"items", "total", "page", "page_size", "has_next", "applied_filters"}
assert required.issubset(body.keys())
finally:
app.dependency_overrides.clear()
def test_reports_detail_openapi_required_keys():
tasks = [_task("r-3", "llm_dashboard_validation", TaskStatus.SUCCESS)]
app.dependency_overrides[get_current_user] = lambda: _admin_user()
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
try:
client = TestClient(app)
response = client.get("/api/reports/r-3")
assert response.status_code == 200
body = response.json()
assert "report" in body
finally:
app.dependency_overrides.clear()
# [/DEF:backend.tests.test_reports_openapi_conformance:Module]

View File

@@ -12,13 +12,14 @@
# [SECTION: IMPORTS]
from fastapi import APIRouter, Depends, HTTPException
from typing import List, Optional
from typing import List, Optional, Dict
from pydantic import BaseModel, Field
from ...dependencies import get_config_manager, get_task_manager, get_resource_service, has_permission
from ...dependencies import get_config_manager, get_task_manager, get_resource_service, get_mapping_service, has_permission
from ...core.logger import logger, belief_scope
from ...core.superset_client import SupersetClient
# [/SECTION]
router = APIRouter()
router = APIRouter(prefix="/api/dashboards", tags=["Dashboards"])
# [DEF:GitStatus:DataClass]
class GitStatus(BaseModel):
@@ -47,26 +48,79 @@ class DashboardItem(BaseModel):
class DashboardsResponse(BaseModel):
dashboards: List[DashboardItem]
total: int
page: int
page_size: int
total_pages: int
# [/DEF:DashboardsResponse:DataClass]
# [DEF:DashboardChartItem:DataClass]
class DashboardChartItem(BaseModel):
id: int
title: str
viz_type: Optional[str] = None
dataset_id: Optional[int] = None
last_modified: Optional[str] = None
overview: Optional[str] = None
# [/DEF:DashboardChartItem:DataClass]
# [DEF:DashboardDatasetItem:DataClass]
class DashboardDatasetItem(BaseModel):
id: int
table_name: str
schema: Optional[str] = None
database: str
last_modified: Optional[str] = None
overview: Optional[str] = None
# [/DEF:DashboardDatasetItem:DataClass]
# [DEF:DashboardDetailResponse:DataClass]
class DashboardDetailResponse(BaseModel):
id: int
title: str
slug: Optional[str] = None
url: Optional[str] = None
description: Optional[str] = None
last_modified: Optional[str] = None
published: Optional[bool] = None
charts: List[DashboardChartItem]
datasets: List[DashboardDatasetItem]
chart_count: int
dataset_count: int
# [/DEF:DashboardDetailResponse:DataClass]
# [DEF:get_dashboards:Function]
# @PURPOSE: Fetch list of dashboards from a specific environment with Git status and last task status
# @PRE: env_id must be a valid environment ID
# @POST: Returns a list of dashboards with enhanced metadata
# @PRE: page must be >= 1 if provided
# @PRE: page_size must be between 1 and 100 if provided
# @POST: Returns a list of dashboards with enhanced metadata and pagination info
# @POST: Response includes pagination metadata (page, page_size, total, total_pages)
# @PARAM: env_id (str) - The environment ID to fetch dashboards from
# @PARAM: search (Optional[str]) - Filter by title/slug
# @PARAM: page (Optional[int]) - Page number (default: 1)
# @PARAM: page_size (Optional[int]) - Items per page (default: 10, max: 100)
# @RETURN: DashboardsResponse - List of dashboards with status metadata
# @RELATION: CALLS -> ResourceService.get_dashboards_with_status
@router.get("/api/dashboards", response_model=DashboardsResponse)
@router.get("", response_model=DashboardsResponse)
async def get_dashboards(
env_id: str,
search: Optional[str] = None,
page: int = 1,
page_size: int = 10,
config_manager=Depends(get_config_manager),
task_manager=Depends(get_task_manager),
resource_service=Depends(get_resource_service),
_ = Depends(has_permission("plugin:migration", "READ"))
):
with belief_scope("get_dashboards", f"env_id={env_id}, search={search}"):
with belief_scope("get_dashboards", f"env_id={env_id}, search={search}, page={page}, page_size={page_size}"):
# Validate pagination parameters
if page < 1:
logger.error(f"[get_dashboards][Coherence:Failed] Invalid page: {page}")
raise HTTPException(status_code=400, detail="Page must be >= 1")
if page_size < 1 or page_size > 100:
logger.error(f"[get_dashboards][Coherence:Failed] Invalid page_size: {page_size}")
raise HTTPException(status_code=400, detail="Page size must be between 1 and 100")
# Validate environment exists
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
@@ -90,11 +144,23 @@ async def get_dashboards(
or search_lower in d.get('slug', '').lower()
]
logger.info(f"[get_dashboards][Coherence:OK] Returning {len(dashboards)} dashboards")
# Calculate pagination
total = len(dashboards)
total_pages = (total + page_size - 1) // page_size if total > 0 else 1
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
# Slice dashboards for current page
paginated_dashboards = dashboards[start_idx:end_idx]
logger.info(f"[get_dashboards][Coherence:OK] Returning {len(paginated_dashboards)} dashboards (page {page}/{total_pages}, total: {total})")
return DashboardsResponse(
dashboards=dashboards,
total=len(dashboards)
dashboards=paginated_dashboards,
total=total,
page=page,
page_size=page_size,
total_pages=total_pages
)
except Exception as e:
@@ -102,4 +168,229 @@ async def get_dashboards(
raise HTTPException(status_code=503, detail=f"Failed to fetch dashboards: {str(e)}")
# [/DEF:get_dashboards:Function]
# [DEF:get_dashboard_detail:Function]
# @PURPOSE: Fetch detailed dashboard info with related charts and datasets
# @PRE: env_id must be valid and dashboard_id must exist
# @POST: Returns dashboard detail payload for overview page
# @RELATION: CALLS -> SupersetClient.get_dashboard_detail
@router.get("/{dashboard_id}", response_model=DashboardDetailResponse)
async def get_dashboard_detail(
dashboard_id: int,
env_id: str,
config_manager=Depends(get_config_manager),
_ = Depends(has_permission("plugin:migration", "READ"))
):
with belief_scope("get_dashboard_detail", f"dashboard_id={dashboard_id}, env_id={env_id}"):
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
if not env:
logger.error(f"[get_dashboard_detail][Coherence:Failed] Environment not found: {env_id}")
raise HTTPException(status_code=404, detail="Environment not found")
try:
client = SupersetClient(env)
detail = client.get_dashboard_detail(dashboard_id)
logger.info(
f"[get_dashboard_detail][Coherence:OK] Dashboard {dashboard_id}: {detail.get('chart_count', 0)} charts, {detail.get('dataset_count', 0)} datasets"
)
return DashboardDetailResponse(**detail)
except HTTPException:
raise
except Exception as e:
logger.error(f"[get_dashboard_detail][Coherence:Failed] Failed to fetch dashboard detail: {e}")
raise HTTPException(status_code=503, detail=f"Failed to fetch dashboard detail: {str(e)}")
# [/DEF:get_dashboard_detail:Function]
# [DEF:MigrateRequest:DataClass]
class MigrateRequest(BaseModel):
source_env_id: str = Field(..., description="Source environment ID")
target_env_id: str = Field(..., description="Target environment ID")
dashboard_ids: List[int] = Field(..., description="List of dashboard IDs to migrate")
db_mappings: Optional[Dict[str, str]] = Field(None, description="Database mappings for migration")
replace_db_config: bool = Field(False, description="Replace database configuration")
# [/DEF:MigrateRequest:DataClass]
# [DEF:TaskResponse:DataClass]
class TaskResponse(BaseModel):
task_id: str
# [/DEF:TaskResponse:DataClass]
# [DEF:migrate_dashboards:Function]
# @PURPOSE: Trigger bulk migration of dashboards from source to target environment
# @PRE: User has permission plugin:migration:execute
# @PRE: source_env_id and target_env_id are valid environment IDs
# @PRE: dashboard_ids is a non-empty list
# @POST: Returns task_id for tracking migration progress
# @POST: Task is created and queued for execution
# @PARAM: request (MigrateRequest) - Migration request with source, target, and dashboard IDs
# @RETURN: TaskResponse - Task ID for tracking
# @RELATION: DISPATCHES -> MigrationPlugin
# @RELATION: CALLS -> task_manager.create_task
@router.post("/migrate", response_model=TaskResponse)
async def migrate_dashboards(
request: MigrateRequest,
config_manager=Depends(get_config_manager),
task_manager=Depends(get_task_manager),
_ = Depends(has_permission("plugin:migration", "EXECUTE"))
):
with belief_scope("migrate_dashboards", f"source={request.source_env_id}, target={request.target_env_id}, count={len(request.dashboard_ids)}"):
# Validate request
if not request.dashboard_ids:
logger.error("[migrate_dashboards][Coherence:Failed] No dashboard IDs provided")
raise HTTPException(status_code=400, detail="At least one dashboard ID must be provided")
# Validate environments exist
environments = config_manager.get_environments()
source_env = next((e for e in environments if e.id == request.source_env_id), None)
target_env = next((e for e in environments if e.id == request.target_env_id), None)
if not source_env:
logger.error(f"[migrate_dashboards][Coherence:Failed] Source environment not found: {request.source_env_id}")
raise HTTPException(status_code=404, detail="Source environment not found")
if not target_env:
logger.error(f"[migrate_dashboards][Coherence:Failed] Target environment not found: {request.target_env_id}")
raise HTTPException(status_code=404, detail="Target environment not found")
try:
# Create migration task
task_params = {
'source_env_id': request.source_env_id,
'target_env_id': request.target_env_id,
'selected_ids': request.dashboard_ids,
'replace_db_config': request.replace_db_config,
'db_mappings': request.db_mappings or {}
}
task_obj = await task_manager.create_task(
plugin_id='superset-migration',
params=task_params
)
logger.info(f"[migrate_dashboards][Coherence:OK] Migration task created: {task_obj.id} for {len(request.dashboard_ids)} dashboards")
return TaskResponse(task_id=str(task_obj.id))
except Exception as e:
logger.error(f"[migrate_dashboards][Coherence:Failed] Failed to create migration task: {e}")
raise HTTPException(status_code=503, detail=f"Failed to create migration task: {str(e)}")
# [/DEF:migrate_dashboards:Function]
# [DEF:BackupRequest:DataClass]
class BackupRequest(BaseModel):
env_id: str = Field(..., description="Environment ID")
dashboard_ids: List[int] = Field(..., description="List of dashboard IDs to backup")
schedule: Optional[str] = Field(None, description="Cron schedule for recurring backups (e.g., '0 0 * * *')")
# [/DEF:BackupRequest:DataClass]
# [DEF:backup_dashboards:Function]
# @PURPOSE: Trigger bulk backup of dashboards with optional cron schedule
# @PRE: User has permission plugin:backup:execute
# @PRE: env_id is a valid environment ID
# @PRE: dashboard_ids is a non-empty list
# @POST: Returns task_id for tracking backup progress
# @POST: Task is created and queued for execution
# @POST: If schedule is provided, a scheduled task is created
# @PARAM: request (BackupRequest) - Backup request with environment and dashboard IDs
# @RETURN: TaskResponse - Task ID for tracking
# @RELATION: DISPATCHES -> BackupPlugin
# @RELATION: CALLS -> task_manager.create_task
@router.post("/backup", response_model=TaskResponse)
async def backup_dashboards(
request: BackupRequest,
config_manager=Depends(get_config_manager),
task_manager=Depends(get_task_manager),
_ = Depends(has_permission("plugin:backup", "EXECUTE"))
):
with belief_scope("backup_dashboards", f"env={request.env_id}, count={len(request.dashboard_ids)}, schedule={request.schedule}"):
# Validate request
if not request.dashboard_ids:
logger.error("[backup_dashboards][Coherence:Failed] No dashboard IDs provided")
raise HTTPException(status_code=400, detail="At least one dashboard ID must be provided")
# Validate environment exists
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == request.env_id), None)
if not env:
logger.error(f"[backup_dashboards][Coherence:Failed] Environment not found: {request.env_id}")
raise HTTPException(status_code=404, detail="Environment not found")
try:
# Create backup task
task_params = {
'env': request.env_id,
'dashboards': request.dashboard_ids,
'schedule': request.schedule
}
task_obj = await task_manager.create_task(
plugin_id='superset-backup',
params=task_params
)
logger.info(f"[backup_dashboards][Coherence:OK] Backup task created: {task_obj.id} for {len(request.dashboard_ids)} dashboards")
return TaskResponse(task_id=str(task_obj.id))
except Exception as e:
logger.error(f"[backup_dashboards][Coherence:Failed] Failed to create backup task: {e}")
raise HTTPException(status_code=503, detail=f"Failed to create backup task: {str(e)}")
# [/DEF:backup_dashboards:Function]
# [DEF:DatabaseMapping:DataClass]
class DatabaseMapping(BaseModel):
source_db: str
target_db: str
source_db_uuid: Optional[str] = None
target_db_uuid: Optional[str] = None
confidence: float
# [/DEF:DatabaseMapping:DataClass]
# [DEF:DatabaseMappingsResponse:DataClass]
class DatabaseMappingsResponse(BaseModel):
mappings: List[DatabaseMapping]
# [/DEF:DatabaseMappingsResponse:DataClass]
# [DEF:get_database_mappings:Function]
# @PURPOSE: Get database mapping suggestions between source and target environments
# @PRE: User has permission plugin:migration:read
# @PRE: source_env_id and target_env_id are valid environment IDs
# @POST: Returns list of suggested database mappings with confidence scores
# @PARAM: source_env_id (str) - Source environment ID
# @PARAM: target_env_id (str) - Target environment ID
# @RETURN: DatabaseMappingsResponse - List of suggested mappings
# @RELATION: CALLS -> MappingService.get_suggestions
@router.get("/db-mappings", response_model=DatabaseMappingsResponse)
async def get_database_mappings(
source_env_id: str,
target_env_id: str,
mapping_service=Depends(get_mapping_service),
_ = Depends(has_permission("plugin:migration", "READ"))
):
with belief_scope("get_database_mappings", f"source={source_env_id}, target={target_env_id}"):
try:
# Get mapping suggestions using MappingService
suggestions = await mapping_service.get_suggestions(source_env_id, target_env_id)
# Format suggestions as DatabaseMapping objects
mappings = [
DatabaseMapping(
source_db=s.get('source_db', ''),
target_db=s.get('target_db', ''),
source_db_uuid=s.get('source_db_uuid'),
target_db_uuid=s.get('target_db_uuid'),
confidence=s.get('confidence', 0.0)
)
for s in suggestions
]
logger.info(f"[get_database_mappings][Coherence:OK] Returning {len(mappings)} database mapping suggestions")
return DatabaseMappingsResponse(mappings=mappings)
except Exception as e:
logger.error(f"[get_database_mappings][Coherence:Failed] Failed to get database mappings: {e}")
raise HTTPException(status_code=503, detail=f"Failed to get database mappings: {str(e)}")
# [/DEF:get_database_mappings:Function]
# [/DEF:backend.src.api.routes.dashboards:Module]

View File

@@ -16,9 +16,10 @@ from typing import List, Optional
from pydantic import BaseModel, Field
from ...dependencies import get_config_manager, get_task_manager, get_resource_service, has_permission
from ...core.logger import logger, belief_scope
from ...core.superset_client import SupersetClient
# [/SECTION]
router = APIRouter()
router = APIRouter(prefix="/api/datasets", tags=["Datasets"])
# [DEF:MappedFields:DataClass]
class MappedFields(BaseModel):
@@ -42,22 +43,64 @@ class DatasetItem(BaseModel):
last_task: Optional[LastTask] = None
# [/DEF:DatasetItem:DataClass]
# [DEF:LinkedDashboard:DataClass]
class LinkedDashboard(BaseModel):
id: int
title: str
slug: Optional[str] = None
# [/DEF:LinkedDashboard:DataClass]
# [DEF:DatasetColumn:DataClass]
class DatasetColumn(BaseModel):
id: int
name: str
type: Optional[str] = None
is_dttm: bool = False
is_active: bool = True
description: Optional[str] = None
# [/DEF:DatasetColumn:DataClass]
# [DEF:DatasetDetailResponse:DataClass]
class DatasetDetailResponse(BaseModel):
id: int
table_name: Optional[str] = None
schema: Optional[str] = None
database: str
description: Optional[str] = None
columns: List[DatasetColumn]
column_count: int
sql: Optional[str] = None
linked_dashboards: List[LinkedDashboard]
linked_dashboard_count: int
is_sqllab_view: bool = False
created_on: Optional[str] = None
changed_on: Optional[str] = None
# [/DEF:DatasetDetailResponse:DataClass]
# [DEF:DatasetsResponse:DataClass]
class DatasetsResponse(BaseModel):
datasets: List[DatasetItem]
total: int
page: int
page_size: int
total_pages: int
# [/DEF:DatasetsResponse:DataClass]
# [DEF:get_datasets:Function]
# @PURPOSE: Fetch list of datasets from a specific environment with mapping progress
# @PRE: env_id must be a valid environment ID
# @POST: Returns a list of datasets with enhanced metadata
# @PARAM: env_id (str) - The environment ID to fetch datasets from
# @PARAM: search (Optional[str]) - Filter by table name
# @RETURN: DatasetsResponse - List of datasets with status metadata
# [DEF:TaskResponse:DataClass]
class TaskResponse(BaseModel):
task_id: str
# [/DEF:TaskResponse:DataClass]
# [DEF:get_dataset_ids:Function]
# @PURPOSE: Fetch list of all dataset IDs from a specific environment (without pagination)
# @PRE: env_id must be a valid environment ID
# @POST: Returns a list of all dataset IDs
# @PARAM: env_id (str) - The environment ID to fetch datasets from
# @PARAM: search (Optional[str]) - Filter by table name
# @RETURN: List[int] - List of dataset IDs
# @RELATION: CALLS -> ResourceService.get_datasets_with_status
@router.get("/api/datasets", response_model=DatasetsResponse)
async def get_datasets(
@router.get("/ids")
async def get_dataset_ids(
env_id: str,
search: Optional[str] = None,
config_manager=Depends(get_config_manager),
@@ -65,7 +108,73 @@ async def get_datasets(
resource_service=Depends(get_resource_service),
_ = Depends(has_permission("plugin:migration", "READ"))
):
with belief_scope("get_datasets", f"env_id={env_id}, search={search}"):
with belief_scope("get_dataset_ids", f"env_id={env_id}, search={search}"):
# Validate environment exists
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
if not env:
logger.error(f"[get_dataset_ids][Coherence:Failed] Environment not found: {env_id}")
raise HTTPException(status_code=404, detail="Environment not found")
try:
# Get all tasks for status lookup
all_tasks = task_manager.get_all_tasks()
# Fetch datasets with status using ResourceService
datasets = await resource_service.get_datasets_with_status(env, all_tasks)
# Apply search filter if provided
if search:
search_lower = search.lower()
datasets = [
d for d in datasets
if search_lower in d.get('table_name', '').lower()
]
# Extract and return just the IDs
dataset_ids = [d['id'] for d in datasets]
logger.info(f"[get_dataset_ids][Coherence:OK] Returning {len(dataset_ids)} dataset IDs")
return {"dataset_ids": dataset_ids}
except Exception as e:
logger.error(f"[get_dataset_ids][Coherence:Failed] Failed to fetch dataset IDs: {e}")
raise HTTPException(status_code=503, detail=f"Failed to fetch dataset IDs: {str(e)}")
# [/DEF:get_dataset_ids:Function]
# [DEF:get_datasets:Function]
# @PURPOSE: Fetch list of datasets from a specific environment with mapping progress
# @PRE: env_id must be a valid environment ID
# @PRE: page must be >= 1 if provided
# @PRE: page_size must be between 1 and 100 if provided
# @POST: Returns a list of datasets with enhanced metadata and pagination info
# @POST: Response includes pagination metadata (page, page_size, total, total_pages)
# @PARAM: env_id (str) - The environment ID to fetch datasets from
# @PARAM: search (Optional[str]) - Filter by table name
# @PARAM: page (Optional[int]) - Page number (default: 1)
# @PARAM: page_size (Optional[int]) - Items per page (default: 10, max: 100)
# @RETURN: DatasetsResponse - List of datasets with status metadata
# @RELATION: CALLS -> ResourceService.get_datasets_with_status
@router.get("", response_model=DatasetsResponse)
async def get_datasets(
env_id: str,
search: Optional[str] = None,
page: int = 1,
page_size: int = 10,
config_manager=Depends(get_config_manager),
task_manager=Depends(get_task_manager),
resource_service=Depends(get_resource_service),
_ = Depends(has_permission("plugin:migration", "READ"))
):
with belief_scope("get_datasets", f"env_id={env_id}, search={search}, page={page}, page_size={page_size}"):
# Validate pagination parameters
if page < 1:
logger.error(f"[get_datasets][Coherence:Failed] Invalid page: {page}")
raise HTTPException(status_code=400, detail="Page must be >= 1")
if page_size < 1 or page_size > 100:
logger.error(f"[get_datasets][Coherence:Failed] Invalid page_size: {page_size}")
raise HTTPException(status_code=400, detail="Page size must be between 1 and 100")
# Validate environment exists
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
@@ -88,11 +197,23 @@ async def get_datasets(
if search_lower in d.get('table_name', '').lower()
]
logger.info(f"[get_datasets][Coherence:OK] Returning {len(datasets)} datasets")
# Calculate pagination
total = len(datasets)
total_pages = (total + page_size - 1) // page_size if total > 0 else 1
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
# Slice datasets for current page
paginated_datasets = datasets[start_idx:end_idx]
logger.info(f"[get_datasets][Coherence:OK] Returning {len(paginated_datasets)} datasets (page {page}/{total_pages}, total: {total})")
return DatasetsResponse(
datasets=datasets,
total=len(datasets)
datasets=paginated_datasets,
total=total,
page=page,
page_size=page_size,
total_pages=total_pages
)
except Exception as e:
@@ -100,4 +221,175 @@ async def get_datasets(
raise HTTPException(status_code=503, detail=f"Failed to fetch datasets: {str(e)}")
# [/DEF:get_datasets:Function]
# [DEF:MapColumnsRequest:DataClass]
class MapColumnsRequest(BaseModel):
env_id: str = Field(..., description="Environment ID")
dataset_ids: List[int] = Field(..., description="List of dataset IDs to map")
source_type: str = Field(..., description="Source type: 'postgresql' or 'xlsx'")
connection_id: Optional[str] = Field(None, description="Connection ID for PostgreSQL source")
file_data: Optional[str] = Field(None, description="File path or data for XLSX source")
# [/DEF:MapColumnsRequest:DataClass]
# [DEF:map_columns:Function]
# @PURPOSE: Trigger bulk column mapping for datasets
# @PRE: User has permission plugin:mapper:execute
# @PRE: env_id is a valid environment ID
# @PRE: dataset_ids is a non-empty list
# @POST: Returns task_id for tracking mapping progress
# @POST: Task is created and queued for execution
# @PARAM: request (MapColumnsRequest) - Mapping request with environment and dataset IDs
# @RETURN: TaskResponse - Task ID for tracking
# @RELATION: DISPATCHES -> MapperPlugin
# @RELATION: CALLS -> task_manager.create_task
@router.post("/map-columns", response_model=TaskResponse)
async def map_columns(
request: MapColumnsRequest,
config_manager=Depends(get_config_manager),
task_manager=Depends(get_task_manager),
_ = Depends(has_permission("plugin:mapper", "EXECUTE"))
):
with belief_scope("map_columns", f"env={request.env_id}, count={len(request.dataset_ids)}, source={request.source_type}"):
# Validate request
if not request.dataset_ids:
logger.error("[map_columns][Coherence:Failed] No dataset IDs provided")
raise HTTPException(status_code=400, detail="At least one dataset ID must be provided")
# Validate source type
if request.source_type not in ['postgresql', 'xlsx']:
logger.error(f"[map_columns][Coherence:Failed] Invalid source type: {request.source_type}")
raise HTTPException(status_code=400, detail="Source type must be 'postgresql' or 'xlsx'")
# Validate environment exists
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == request.env_id), None)
if not env:
logger.error(f"[map_columns][Coherence:Failed] Environment not found: {request.env_id}")
raise HTTPException(status_code=404, detail="Environment not found")
try:
# Create mapping task
task_params = {
'env': request.env_id,
'dataset_id': request.dataset_ids[0] if request.dataset_ids else None,
'source': request.source_type,
'connection_id': request.connection_id,
'file_data': request.file_data
}
task_obj = await task_manager.create_task(
plugin_id='dataset-mapper',
params=task_params
)
logger.info(f"[map_columns][Coherence:OK] Mapping task created: {task_obj.id} for {len(request.dataset_ids)} datasets")
return TaskResponse(task_id=str(task_obj.id))
except Exception as e:
logger.error(f"[map_columns][Coherence:Failed] Failed to create mapping task: {e}")
raise HTTPException(status_code=503, detail=f"Failed to create mapping task: {str(e)}")
# [/DEF:map_columns:Function]
# [DEF:GenerateDocsRequest:DataClass]
class GenerateDocsRequest(BaseModel):
env_id: str = Field(..., description="Environment ID")
dataset_ids: List[int] = Field(..., description="List of dataset IDs to generate docs for")
llm_provider: str = Field(..., description="LLM provider to use")
options: Optional[dict] = Field(None, description="Additional options for documentation generation")
# [/DEF:GenerateDocsRequest:DataClass]
# [DEF:generate_docs:Function]
# @PURPOSE: Trigger bulk documentation generation for datasets
# @PRE: User has permission plugin:llm_analysis:execute
# @PRE: env_id is a valid environment ID
# @PRE: dataset_ids is a non-empty list
# @POST: Returns task_id for tracking documentation generation progress
# @POST: Task is created and queued for execution
# @PARAM: request (GenerateDocsRequest) - Documentation generation request
# @RETURN: TaskResponse - Task ID for tracking
# @RELATION: DISPATCHES -> LLMAnalysisPlugin
# @RELATION: CALLS -> task_manager.create_task
@router.post("/generate-docs", response_model=TaskResponse)
async def generate_docs(
request: GenerateDocsRequest,
config_manager=Depends(get_config_manager),
task_manager=Depends(get_task_manager),
_ = Depends(has_permission("plugin:llm_analysis", "EXECUTE"))
):
with belief_scope("generate_docs", f"env={request.env_id}, count={len(request.dataset_ids)}, provider={request.llm_provider}"):
# Validate request
if not request.dataset_ids:
logger.error("[generate_docs][Coherence:Failed] No dataset IDs provided")
raise HTTPException(status_code=400, detail="At least one dataset ID must be provided")
# Validate environment exists
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == request.env_id), None)
if not env:
logger.error(f"[generate_docs][Coherence:Failed] Environment not found: {request.env_id}")
raise HTTPException(status_code=404, detail="Environment not found")
try:
# Create documentation generation task
task_params = {
'environment_id': request.env_id,
'dataset_id': str(request.dataset_ids[0]) if request.dataset_ids else None,
'provider_id': request.llm_provider,
'options': request.options or {}
}
task_obj = await task_manager.create_task(
plugin_id='llm_documentation',
params=task_params
)
logger.info(f"[generate_docs][Coherence:OK] Documentation generation task created: {task_obj.id} for {len(request.dataset_ids)} datasets")
return TaskResponse(task_id=str(task_obj.id))
except Exception as e:
logger.error(f"[generate_docs][Coherence:Failed] Failed to create documentation generation task: {e}")
raise HTTPException(status_code=503, detail=f"Failed to create documentation generation task: {str(e)}")
# [/DEF:generate_docs:Function]
# [DEF:get_dataset_detail:Function]
# @PURPOSE: Get detailed dataset information including columns and linked dashboards
# @PRE: env_id is a valid environment ID
# @PRE: dataset_id is a valid dataset ID
# @POST: Returns detailed dataset info with columns and linked dashboards
# @PARAM: env_id (str) - The environment ID
# @PARAM: dataset_id (int) - The dataset ID
# @RETURN: DatasetDetailResponse - Detailed dataset information
# @RELATION: CALLS -> SupersetClient.get_dataset_detail
@router.get("/{dataset_id}", response_model=DatasetDetailResponse)
async def get_dataset_detail(
env_id: str,
dataset_id: int,
config_manager=Depends(get_config_manager),
_ = Depends(has_permission("plugin:migration", "READ"))
):
with belief_scope("get_dataset_detail", f"env_id={env_id}, dataset_id={dataset_id}"):
# Validate environment exists
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
if not env:
logger.error(f"[get_dataset_detail][Coherence:Failed] Environment not found: {env_id}")
raise HTTPException(status_code=404, detail="Environment not found")
try:
# Fetch detailed dataset info using SupersetClient
client = SupersetClient(env)
dataset_detail = client.get_dataset_detail(dataset_id)
logger.info(f"[get_dataset_detail][Coherence:OK] Retrieved dataset {dataset_id} with {dataset_detail['column_count']} columns and {dataset_detail['linked_dashboard_count']} linked dashboards")
return DatasetDetailResponse(**dataset_detail)
except Exception as e:
logger.error(f"[get_dataset_detail][Coherence:Failed] Failed to fetch dataset detail: {e}")
raise HTTPException(status_code=503, detail=f"Failed to fetch dataset detail: {str(e)}")
# [/DEF:get_dataset_detail:Function]
# [/DEF:backend.src.api.routes.datasets:Module]

View File

@@ -18,7 +18,7 @@ from pydantic import BaseModel, Field
from ...core.logger import belief_scope
# [/SECTION]
router = APIRouter()
router = APIRouter(prefix="/api/environments", tags=["Environments"])
# [DEF:ScheduleSchema:DataClass]
class ScheduleSchema(BaseModel):
@@ -43,6 +43,8 @@ class DatabaseResponse(BaseModel):
# [DEF:get_environments:Function]
# @PURPOSE: List all configured environments.
# @LAYER: API
# @SEMANTICS: list, environments, config
# @PRE: config_manager is injected via Depends.
# @POST: Returns a list of EnvironmentResponse objects.
# @RETURN: List[EnvironmentResponse]
@@ -71,6 +73,8 @@ async def get_environments(
# [DEF:update_environment_schedule:Function]
# @PURPOSE: Update backup schedule for an environment.
# @LAYER: API
# @SEMANTICS: update, schedule, backup, environment
# @PRE: Environment id exists, schedule is valid ScheduleSchema.
# @POST: Backup schedule updated and scheduler reloaded.
# @PARAM: id (str) - The environment ID.
@@ -103,6 +107,8 @@ async def update_environment_schedule(
# [DEF:get_environment_databases:Function]
# @PURPOSE: Fetch the list of databases from a specific environment.
# @LAYER: API
# @SEMANTICS: fetch, databases, superset, environment
# @PRE: Environment id exists.
# @POST: Returns a list of database summaries from the environment.
# @PARAM: id (str) - The environment ID.

View File

@@ -26,7 +26,7 @@ from src.api.routes.git_schemas import (
from src.services.git_service import GitService
from src.core.logger import logger, belief_scope
router = APIRouter(prefix="/api/git", tags=["git"])
router = APIRouter(tags=["git"])
git_service = GitService()
# [DEF:get_git_configs:Function]

View File

@@ -16,7 +16,7 @@ from sqlalchemy.orm import Session
# [DEF:router:Global]
# @PURPOSE: APIRouter instance for LLM routes.
router = APIRouter(prefix="/api/llm", tags=["LLM"])
router = APIRouter(tags=["LLM"])
# [/DEF:router:Global]
# [DEF:get_providers:Function]

View File

@@ -21,7 +21,7 @@ from ...models.mapping import DatabaseMapping
from pydantic import BaseModel
# [/SECTION]
router = APIRouter(prefix="/api/mappings", tags=["mappings"])
router = APIRouter(tags=["mappings"])
# [DEF:MappingCreate:DataClass]
class MappingCreate(BaseModel):
@@ -31,6 +31,7 @@ class MappingCreate(BaseModel):
target_db_uuid: str
source_db_name: str
target_db_name: str
engine: Optional[str] = None
# [/DEF:MappingCreate:DataClass]
# [DEF:MappingResponse:DataClass]
@@ -42,6 +43,7 @@ class MappingResponse(BaseModel):
target_db_uuid: str
source_db_name: str
target_db_name: str
engine: Optional[str] = None
class Config:
from_attributes = True
@@ -94,6 +96,7 @@ async def create_mapping(
if existing:
existing.target_db_uuid = mapping.target_db_uuid
existing.target_db_name = mapping.target_db_name
existing.engine = mapping.engine
db.commit()
db.refresh(existing)
return existing

View File

@@ -44,7 +44,7 @@ async def get_dashboards(
# @POST: Starts the migration task and returns the task ID.
# @PARAM: selection (DashboardSelection) - The dashboards to migrate.
# @RETURN: Dict - {"task_id": str, "message": str}
@router.post("/migration/execute")
@router.post("/execute")
async def execute_migration(
selection: DashboardSelection,
config_manager=Depends(get_config_manager),

View File

@@ -0,0 +1,131 @@
# [DEF:ReportsRouter:Module]
# @TIER: CRITICAL
# @SEMANTICS: api, reports, list, detail, pagination, filters
# @PURPOSE: FastAPI router for unified task report list and detail retrieval endpoints.
# @LAYER: UI (API)
# @RELATION: DEPENDS_ON -> backend.src.services.reports.report_service.ReportsService
# @RELATION: DEPENDS_ON -> backend.src.dependencies
# @INVARIANT: Endpoints are read-only and do not trigger long-running tasks.
# [SECTION: IMPORTS]
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from ...dependencies import get_task_manager, has_permission
from ...core.task_manager import TaskManager
from ...core.logger import belief_scope
from ...models.report import ReportCollection, ReportDetailView, ReportQuery, ReportStatus, TaskType
from ...services.reports.report_service import ReportsService
# [/SECTION]
router = APIRouter(prefix="/api/reports", tags=["Reports"])
# [DEF:_parse_csv_enum_list:Function]
# @PURPOSE: Parse comma-separated query value into enum list.
# @PRE: raw may be None/empty or comma-separated values.
# @POST: Returns enum list or raises HTTP 400 with deterministic machine-readable payload.
# @PARAM: raw (Optional[str]) - Comma-separated enum values.
# @PARAM: enum_cls (type) - Enum class for validation.
# @PARAM: field_name (str) - Query field name for diagnostics.
# @RETURN: List - Parsed enum values.
def _parse_csv_enum_list(raw: Optional[str], enum_cls, field_name: str) -> List:
if raw is None or not raw.strip():
return []
values = [item.strip() for item in raw.split(",") if item.strip()]
parsed = []
invalid = []
for value in values:
try:
parsed.append(enum_cls(value))
except ValueError:
invalid.append(value)
if invalid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"message": f"Invalid values for '{field_name}'",
"field": field_name,
"invalid_values": invalid,
"allowed_values": [item.value for item in enum_cls],
},
)
return parsed
# [/DEF:_parse_csv_enum_list:Function]
# [DEF:list_reports:Function]
# @PURPOSE: Return paginated unified reports list.
# @PRE: authenticated/authorized request and validated query params.
# @POST: returns {items,total,page,page_size,has_next,applied_filters}.
# @POST: deterministic error payload for invalid filters.
@router.get("", response_model=ReportCollection)
async def list_reports(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
task_types: Optional[str] = Query(None, description="Comma-separated task types"),
statuses: Optional[str] = Query(None, description="Comma-separated statuses"),
time_from: Optional[datetime] = Query(None),
time_to: Optional[datetime] = Query(None),
search: Optional[str] = Query(None, max_length=200),
sort_by: str = Query("updated_at"),
sort_order: str = Query("desc"),
task_manager: TaskManager = Depends(get_task_manager),
_=Depends(has_permission("tasks", "READ")),
):
with belief_scope("list_reports"):
try:
parsed_task_types = _parse_csv_enum_list(task_types, TaskType, "task_types")
parsed_statuses = _parse_csv_enum_list(statuses, ReportStatus, "statuses")
query = ReportQuery(
page=page,
page_size=page_size,
task_types=parsed_task_types,
statuses=parsed_statuses,
time_from=time_from,
time_to=time_to,
search=search,
sort_by=sort_by,
sort_order=sort_order,
)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"message": "Invalid query parameters",
"code": "INVALID_REPORT_QUERY",
"reason": str(exc),
},
)
service = ReportsService(task_manager)
return service.list_reports(query)
# [/DEF:list_reports:Function]
# [DEF:get_report_detail:Function]
# @PURPOSE: Return one normalized report detail with diagnostics and next actions.
# @PRE: authenticated/authorized request and existing report_id.
# @POST: returns normalized detail envelope or 404 when report is not found.
@router.get("/{report_id}", response_model=ReportDetailView)
async def get_report_detail(
report_id: str,
task_manager: TaskManager = Depends(get_task_manager),
_=Depends(has_permission("tasks", "READ")),
):
with belief_scope("get_report_detail", f"report_id={report_id}"):
service = ReportsService(task_manager)
detail = service.get_report_detail(report_id)
if not detail:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"message": "Report not found", "code": "REPORT_NOT_FOUND"},
)
return detail
# [/DEF:get_report_detail:Function]
# [/DEF:ReportsRouter:Module]

View File

@@ -283,6 +283,7 @@ class ConsolidatedSettingsResponse(BaseModel):
environments: List[dict]
connections: List[dict]
llm: dict
llm_providers: List[dict]
logging: dict
storage: dict
# [/DEF:ConsolidatedSettingsResponse:Class]
@@ -302,13 +303,74 @@ async def get_consolidated_settings(
config = config_manager.get_config()
from ...services.llm_provider import LLMProviderService
from ...core.database import SessionLocal
db = SessionLocal()
try:
llm_service = LLMProviderService(db)
providers = llm_service.get_all_providers()
llm_providers_list = [
{
"id": p.id,
"provider_type": p.provider_type,
"name": p.name,
"base_url": p.base_url,
"api_key": "********",
"default_model": p.default_model,
"is_active": p.is_active
} for p in providers
]
finally:
db.close()
return ConsolidatedSettingsResponse(
environments=config.environments,
environments=[env.dict() for env in config.environments],
connections=config.settings.connections,
llm=config.settings.llm,
logging=config.settings.logging,
storage=config.settings.storage
llm_providers=llm_providers_list,
logging=config.settings.logging.dict(),
storage=config.settings.storage.dict()
)
# [/DEF:get_consolidated_settings:Function]
# [DEF:update_consolidated_settings:Function]
# @PURPOSE: Bulk update application settings from the consolidated view.
# @PRE: User has admin permissions, config is valid.
# @POST: Settings are updated and saved via ConfigManager.
@router.patch("/consolidated")
async def update_consolidated_settings(
settings_patch: dict,
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
with belief_scope("update_consolidated_settings"):
logger.info("[update_consolidated_settings][Entry] Applying consolidated settings patch")
current_config = config_manager.get_config()
current_settings = current_config.settings
# Update connections if provided
if "connections" in settings_patch:
current_settings.connections = settings_patch["connections"]
# Update LLM if provided
if "llm" in settings_patch:
current_settings.llm = settings_patch["llm"]
# Update Logging if provided
if "logging" in settings_patch:
current_settings.logging = LoggingConfig(**settings_patch["logging"])
# Update Storage if provided
if "storage" in settings_patch:
new_storage = StorageConfig(**settings_patch["storage"])
is_valid, message = config_manager.validate_path(new_storage.root_path)
if not is_valid:
raise HTTPException(status_code=400, detail=message)
current_settings.storage = new_storage
config_manager.update_global_settings(current_settings)
return {"status": "success", "message": "Settings updated"}
# [/DEF:update_consolidated_settings:Function]
# [/DEF:SettingsRouter:Module]

View File

@@ -15,6 +15,12 @@ from ...dependencies import get_task_manager, has_permission, get_current_user
router = APIRouter()
TASK_TYPE_PLUGIN_MAP = {
"llm_validation": ["llm_dashboard_validation"],
"backup": ["superset-backup"],
"migration": ["superset-migration"],
}
class CreateTaskRequest(BaseModel):
plugin_id: str
params: Dict[str, Any]
@@ -82,7 +88,10 @@ async def create_task(
async def list_tasks(
limit: int = 10,
offset: int = 0,
status: Optional[TaskStatus] = None,
status_filter: Optional[TaskStatus] = Query(None, alias="status"),
task_type: Optional[str] = Query(None, description="Task category: llm_validation, backup, migration"),
plugin_id: Optional[List[str]] = Query(None, description="Filter by plugin_id (repeatable query param)"),
completed_only: bool = Query(False, description="Return only completed tasks (SUCCESS/FAILED)"),
task_manager: TaskManager = Depends(get_task_manager),
_ = Depends(has_permission("tasks", "READ"))
):
@@ -90,7 +99,22 @@ async def list_tasks(
Retrieve a list of tasks with pagination and optional status filter.
"""
with belief_scope("list_tasks"):
return task_manager.get_tasks(limit=limit, offset=offset, status=status)
plugin_filters = list(plugin_id) if plugin_id else []
if task_type:
if task_type not in TASK_TYPE_PLUGIN_MAP:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported task_type '{task_type}'. Allowed: {', '.join(TASK_TYPE_PLUGIN_MAP.keys())}"
)
plugin_filters.extend(TASK_TYPE_PLUGIN_MAP[task_type])
return task_manager.get_tasks(
limit=limit,
offset=offset,
status=status_filter,
plugin_ids=plugin_filters or None,
completed_only=completed_only
)
# [/DEF:list_tasks:Function]
@router.get("/{task_id}", response_model=Task)

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
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin, llm, dashboards, datasets, reports
from .api import auth
# [DEF:App:Global]
@@ -115,14 +115,22 @@ app.include_router(plugins.router, prefix="/api/plugins", tags=["Plugins"])
app.include_router(tasks.router, prefix="/api/tasks", tags=["Tasks"])
app.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
app.include_router(connections.router, prefix="/api/settings/connections", tags=["Connections"])
app.include_router(environments.router, prefix="/api/environments", tags=["Environments"])
app.include_router(mappings.router)
app.include_router(environments.router, tags=["Environments"])
app.include_router(mappings.router, prefix="/api/mappings", tags=["Mappings"])
app.include_router(migration.router)
app.include_router(git.router)
app.include_router(llm.router)
app.include_router(git.router, prefix="/api/git", tags=["Git"])
app.include_router(llm.router, prefix="/api/llm", tags=["LLM"])
app.include_router(storage.router, prefix="/api/storage", tags=["Storage"])
app.include_router(dashboards.router, tags=["Dashboards"])
app.include_router(datasets.router, tags=["Datasets"])
app.include_router(dashboards.router)
app.include_router(datasets.router)
app.include_router(reports.router)
# [DEF:api.include_routers:Action]
# @PURPOSE: Registers all API routers with the FastAPI application.
# @LAYER: API
# @SEMANTICS: routes, registration, api
# [/DEF:api.include_routers:Action]
# [DEF:websocket_endpoint:Function]
# @PURPOSE: Provides a WebSocket endpoint for real-time log streaming of a task with server-side filtering.
@@ -234,24 +242,23 @@ frontend_path = project_root / "frontend" / "build"
if frontend_path.exists():
app.mount("/_app", StaticFiles(directory=str(frontend_path / "_app")), name="static")
# Serve other static files from the root of build directory
# [DEF:serve_spa:Function]
# @PURPOSE: Serves frontend static files or index.html for SPA routing.
# @PRE: file_path is requested by the client.
# @POST: Returns the requested file or index.html as a fallback.
@app.get("/{file_path:path}")
# @PURPOSE: Serves the SPA frontend for any path not matched by API routes.
# @PRE: frontend_path exists.
# @POST: Returns the requested file or index.html.
@app.get("/{file_path:path}", include_in_schema=False)
async def serve_spa(file_path: str):
with belief_scope("serve_spa", f"path={file_path}"):
# Don't serve SPA for API routes that fell through
if file_path.startswith("api/"):
logger.info(f"[DEBUG] API route fell through to serve_spa: {file_path}")
raise HTTPException(status_code=404, detail=f"API endpoint not found: {file_path}")
# Only serve SPA for non-API paths
# API routes are registered separately and should be matched by FastAPI first
if file_path and (file_path.startswith("api/") or file_path.startswith("/api/") or file_path == "api"):
# This should not happen if API routers are properly registered
# Return 404 instead of serving HTML
raise HTTPException(status_code=404, detail=f"API endpoint not found: {file_path}")
full_path = frontend_path / file_path
if full_path.is_file():
return FileResponse(str(full_path))
# Fallback to index.html for SPA routing
return FileResponse(str(frontend_path / "index.html"))
full_path = frontend_path / file_path
if file_path and full_path.is_file():
return FileResponse(str(full_path))
return FileResponse(str(frontend_path / "index.html"))
# [/DEF:serve_spa:Function]
else:
# [DEF:read_root:Function]

View File

@@ -0,0 +1,179 @@
# [DEF:test_auth:Module]
# @TIER: STANDARD
# @PURPOSE: Unit tests for authentication module
# @LAYER: Domain
# @RELATION: VERIFIES -> src.core.auth
import sys
from pathlib import Path
# Add src to path
sys.path.append(str(Path(__file__).parent.parent.parent.parent / "src"))
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from src.core.database import Base
from src.models.auth import User, Role, Permission, ADGroupMapping
from src.services.auth_service import AuthService
from src.core.auth.repository import AuthRepository
from src.core.auth.security import verify_password, get_password_hash
# Create in-memory SQLite database for testing
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Create all tables
Base.metadata.create_all(bind=engine)
@pytest.fixture
def db_session():
"""Create a new database session with a transaction, rollback after test"""
connection = engine.connect()
transaction = connection.begin()
session = TestingSessionLocal(bind=connection)
yield session
session.close()
transaction.rollback()
connection.close()
@pytest.fixture
def auth_service(db_session):
return AuthService(db_session)
@pytest.fixture
def auth_repo(db_session):
return AuthRepository(db_session)
def test_create_user(auth_repo):
"""Test user creation"""
user = User(
username="testuser",
email="test@example.com",
password_hash=get_password_hash("testpassword123"),
auth_source="LOCAL"
)
auth_repo.db.add(user)
auth_repo.db.commit()
retrieved_user = auth_repo.get_user_by_username("testuser")
assert retrieved_user is not None
assert retrieved_user.username == "testuser"
assert retrieved_user.email == "test@example.com"
assert verify_password("testpassword123", retrieved_user.password_hash)
def test_authenticate_user(auth_service, auth_repo):
"""Test user authentication with valid and invalid credentials"""
user = User(
username="testuser",
email="test@example.com",
password_hash=get_password_hash("testpassword123"),
auth_source="LOCAL"
)
auth_repo.db.add(user)
auth_repo.db.commit()
# Test valid credentials
authenticated_user = auth_service.authenticate_user("testuser", "testpassword123")
assert authenticated_user is not None
assert authenticated_user.username == "testuser"
# Test invalid password
invalid_user = auth_service.authenticate_user("testuser", "wrongpassword")
assert invalid_user is None
# Test invalid username
invalid_user = auth_service.authenticate_user("nonexistent", "testpassword123")
assert invalid_user is None
def test_create_session(auth_service, auth_repo):
"""Test session token creation"""
user = User(
username="testuser",
email="test@example.com",
password_hash=get_password_hash("testpassword123"),
auth_source="LOCAL"
)
auth_repo.db.add(user)
auth_repo.db.commit()
session = auth_service.create_session(user)
assert "access_token" in session
assert "token_type" in session
assert session["token_type"] == "bearer"
assert len(session["access_token"]) > 0
def test_role_permission_association(auth_repo):
"""Test role and permission association"""
role = Role(name="Admin", description="System administrator")
perm1 = Permission(resource="admin:users", action="READ")
perm2 = Permission(resource="admin:users", action="WRITE")
role.permissions.extend([perm1, perm2])
auth_repo.db.add(role)
auth_repo.db.commit()
retrieved_role = auth_repo.get_role_by_name("Admin")
assert retrieved_role is not None
assert len(retrieved_role.permissions) == 2
permissions = [f"{p.resource}:{p.action}" for p in retrieved_role.permissions]
assert "admin:users:READ" in permissions
assert "admin:users:WRITE" in permissions
def test_user_role_association(auth_repo):
"""Test user and role association"""
role = Role(name="Admin", description="System administrator")
user = User(
username="adminuser",
email="admin@example.com",
password_hash=get_password_hash("adminpass123"),
auth_source="LOCAL"
)
user.roles.append(role)
auth_repo.db.add(role)
auth_repo.db.add(user)
auth_repo.db.commit()
retrieved_user = auth_repo.get_user_by_username("adminuser")
assert retrieved_user is not None
assert len(retrieved_user.roles) == 1
assert retrieved_user.roles[0].name == "Admin"
def test_ad_group_mapping(auth_repo):
"""Test AD group mapping"""
role = Role(name="ADFS_Admin", description="ADFS administrators")
auth_repo.db.add(role)
auth_repo.db.commit()
mapping = ADGroupMapping(ad_group="DOMAIN\\ADFS_Admins", role_id=role.id)
auth_repo.db.add(mapping)
auth_repo.db.commit()
retrieved_mapping = auth_repo.db.query(ADGroupMapping).filter_by(ad_group="DOMAIN\\ADFS_Admins").first()
assert retrieved_mapping is not None
assert retrieved_mapping.role_id == role.id
# [/DEF:test_auth:Module]

View File

@@ -24,7 +24,10 @@ class AuthConfig(BaseSettings):
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# Database Settings
AUTH_DATABASE_URL: str = Field(default="sqlite:///./backend/auth.db", env="AUTH_DATABASE_URL")
AUTH_DATABASE_URL: str = Field(
default="postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools",
env="AUTH_DATABASE_URL",
)
# ADFS Settings
ADFS_CLIENT_ID: str = Field(default="", env="ADFS_CLIENT_ID")

View File

@@ -8,14 +8,9 @@
# @INVARIANT: Uses bcrypt for hashing with standard work factor.
# [SECTION: IMPORTS]
from passlib.context import CryptContext
import bcrypt
# [/SECTION]
# [DEF:pwd_context:Variable]
# @PURPOSE: Passlib CryptContext for password management.
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# [/DEF:pwd_context:Variable]
# [DEF:verify_password:Function]
# @PURPOSE: Verifies a plain password against a hashed password.
# @PRE: plain_password is a string, hashed_password is a bcrypt hash.
@@ -25,7 +20,15 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# @PARAM: hashed_password (str) - The stored hash.
# @RETURN: bool - Verification result.
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
if not hashed_password:
return False
try:
return bcrypt.checkpw(
plain_password.encode("utf-8"),
hashed_password.encode("utf-8"),
)
except Exception:
return False
# [/DEF:verify_password:Function]
# [DEF:get_password_hash:Function]
@@ -36,7 +39,7 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
# @PARAM: password (str) - The password to hash.
# @RETURN: str - The generated hash.
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
# [/DEF:get_password_hash:Function]
# [/DEF:backend.src.core.auth.security:Module]

276
backend/src/core/config_manager.py Executable file → Normal file
View File

@@ -1,11 +1,12 @@
# [DEF:ConfigManagerModule:Module]
#
# @SEMANTICS: config, manager, persistence, json
# @PURPOSE: Manages application configuration, including loading/saving to JSON and CRUD for environments.
# @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
# @RELATION: WRITES_TO -> config.json
#
# @INVARIANT: Configuration must always be valid according to AppConfig model.
# @PUBLIC_API: ConfigManager
@@ -15,112 +16,145 @@ import json
import os
from pathlib import Path
from typing import Optional, List
from sqlalchemy.orm import Session
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]
# @PURPOSE: A class to handle application configuration persistence and management.
# @RELATION: WRITES_TO -> config.json
class ConfigManager:
# [DEF:ConfigManager:Class]
# @TIER: STANDARD
# @PURPOSE: A class to handle application configuration persistence and management.
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 the configuration file.
# @PARAM: config_path (str) - Path to legacy JSON config (used only for initial migration fallback).
def __init__(self, config_path: str = "config.json"):
with belief_scope("__init__"):
# 1. Runtime check of @PRE
assert isinstance(config_path, str) and config_path, "config_path must be a non-empty string"
logger.info(f"[ConfigManager][Entry] Initializing with {config_path}")
logger.info(f"[ConfigManager][Entry] Initializing with legacy path {config_path}")
# 2. Logic implementation
self.config_path = Path(config_path)
self.config: AppConfig = self._load_config()
# Configure logger with loaded settings
configure_logger(self.config.settings.logging)
# 3. Runtime check of @POST
assert isinstance(self.config, AppConfig), "self.config must be an instance of AppConfig"
logger.info("[ConfigManager][Exit] Initialized")
# [/DEF:__init__:Function]
# [DEF:_default_config:Function]
# @PURPOSE: Returns default application configuration.
# @RETURN: AppConfig - Default configuration.
def _default_config(self) -> AppConfig:
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.
def _load_from_legacy_file(self) -> AppConfig:
with belief_scope("_load_from_legacy_file"):
if not self.config_path.exists():
logger.info("[_load_from_legacy_file][Action] Legacy config file not found, using defaults")
return self._default_config()
try:
with open(self.config_path, "r", encoding="utf-8") as f:
data = json.load(f)
logger.info("[_load_from_legacy_file][Coherence:OK] Legacy configuration loaded")
return AppConfig(**data)
except Exception as e:
logger.error(f"[_load_from_legacy_file][Coherence:Failed] Error loading legacy config: {e}")
return self._default_config()
# [/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.
def _get_record(self, session: Session) -> Optional[AppConfigRecord]:
return session.query(AppConfigRecord).filter(AppConfigRecord.id == "global").first()
# [/DEF:_get_record:Function]
# [DEF:_load_config:Function]
# @PURPOSE: Loads the configuration from disk or creates a default one.
# @PRE: self.config_path is set.
# @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 - The loaded or default configuration.
# @RETURN: AppConfig - Loaded configuration.
def _load_config(self) -> AppConfig:
with belief_scope("_load_config"):
logger.debug(f"[_load_config][Entry] Loading from {self.config_path}")
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)
if not self.config_path.exists():
logger.info("[_load_config][Action] Config file not found. Creating default.")
default_config = AppConfig(
environments=[],
settings=GlobalSettings()
)
self._save_config_to_disk(default_config)
return default_config
try:
with open(self.config_path, "r") as f:
data = json.load(f)
# Check for deprecated field
if "settings" in data and "backup_path" in data["settings"]:
del data["settings"]["backup_path"]
config = AppConfig(**data)
logger.info("[_load_config][Coherence:OK] Configuration loaded")
logger.info("[_load_config][Action] No database config found, migrating legacy config")
config = self._load_from_legacy_file()
self._save_config_to_db(config, session=session)
return config
except Exception as e:
logger.error(f"[_load_config][Coherence:Failed] Error loading config: {e}")
# Fallback but try to preserve existing settings if possible?
# For now, return default to be safe, but log the error prominently.
return AppConfig(
environments=[],
settings=GlobalSettings(storage=StorageConfig())
)
except Exception as e:
logger.error(f"[_load_config][Coherence:Failed] Error loading config from DB: {e}")
return self._default_config()
finally:
session.close()
# [/DEF:_load_config:Function]
# [DEF:_save_config_to_disk:Function]
# @PURPOSE: Saves the provided configuration object to disk.
# [DEF:_save_config_to_db:Function]
# @PURPOSE: Saves the provided configuration object to DB.
# @PRE: isinstance(config, AppConfig)
# @POST: Configuration saved to disk.
# @POST: Configuration saved to database.
# @PARAM: config (AppConfig) - The configuration to save.
def _save_config_to_disk(self, config: AppConfig):
with belief_scope("_save_config_to_disk"):
logger.debug(f"[_save_config_to_disk][Entry] Saving to {self.config_path}")
# @PARAM: session (Optional[Session]) - Existing DB session for transactional reuse.
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"
# 1. Runtime check of @PRE
assert isinstance(config, AppConfig), "config must be an instance of AppConfig"
# 2. Logic implementation
try:
with open(self.config_path, "w") as f:
json.dump(config.dict(), f, indent=4)
logger.info("[_save_config_to_disk][Action] Configuration saved")
except Exception as e:
logger.error(f"[_save_config_to_disk][Coherence:Failed] Failed to save: {e}")
# [/DEF:_save_config_to_disk:Function]
owns_session = session is None
db = session or SessionLocal()
try:
record = self._get_record(db)
payload = config.model_dump()
if record is None:
record = AppConfigRecord(id="global", payload=payload)
db.add(record)
else:
record.payload = payload
db.commit()
logger.info("[_save_config_to_db][Action] Configuration saved to database")
except Exception as e:
db.rollback()
logger.error(f"[_save_config_to_db][Coherence:Failed] Failed to save: {e}")
raise
finally:
if owns_session:
db.close()
# [/DEF:_save_config_to_db:Function]
# [DEF:save:Function]
# @PURPOSE: Saves the current configuration state to disk.
# @PURPOSE: Saves the current configuration state to DB.
# @PRE: self.config is set.
# @POST: self._save_config_to_disk called.
# @POST: self._save_config_to_db called.
def save(self):
with belief_scope("save"):
self._save_config_to_disk(self.config)
self._save_config_to_db(self.config)
# [/DEF:save:Function]
# [DEF:get_config:Function]
# @PURPOSE: Returns the current configuration.
# @PRE: self.config is set.
# @POST: Returns self.config.
# @RETURN: AppConfig - The current configuration.
def get_config(self) -> AppConfig:
with belief_scope("get_config"):
@@ -136,44 +170,34 @@ class ConfigManager:
with belief_scope("update_global_settings"):
logger.info("[update_global_settings][Entry] Updating settings")
# 1. Runtime check of @PRE
assert isinstance(settings, GlobalSettings), "settings must be an instance of GlobalSettings"
# 2. Logic implementation
self.config.settings = settings
self.save()
# Reconfigure logger with new settings
configure_logger(settings.logging)
logger.info("[update_global_settings][Exit] Settings updated")
assert isinstance(settings, GlobalSettings), "settings must be an instance of GlobalSettings"
self.config.settings = settings
self.save()
configure_logger(settings.logging)
logger.info("[update_global_settings][Exit] Settings updated")
# [/DEF:update_global_settings:Function]
# [DEF:validate_path:Function]
# @PURPOSE: Validates if a path exists and is writable.
# @PRE: path is a string.
# @POST: Returns (bool, str) status.
# @PARAM: path (str) - The path to validate.
# @RETURN: tuple (bool, str) - (is_valid, message)
def validate_path(self, path: str) -> tuple[bool, str]:
with belief_scope("validate_path"):
p = os.path.abspath(path)
if not os.path.exists(p):
try:
os.makedirs(p, exist_ok=True)
except Exception as e:
return False, f"Path does not exist and could not be created: {e}"
if not os.path.exists(p):
try:
os.makedirs(p, exist_ok=True)
except Exception as e:
return False, f"Path does not exist and could not be created: {e}"
if not os.access(p, os.W_OK):
return False, "Path is not writable"
if not os.access(p, os.W_OK):
return False, "Path is not writable"
return True, "Path is valid and writable"
return True, "Path is valid and writable"
# [/DEF:validate_path:Function]
# [DEF:get_environments:Function]
# @PURPOSE: Returns the list of configured environments.
# @PRE: self.config is set.
# @POST: Returns list of environments.
# @RETURN: List[Environment] - List of environments.
def get_environments(self) -> List[Environment]:
with belief_scope("get_environments"):
@@ -182,8 +206,6 @@ class ConfigManager:
# [DEF:has_environments:Function]
# @PURPOSE: Checks if at least one environment is configured.
# @PRE: self.config is set.
# @POST: Returns boolean indicating if environments exist.
# @RETURN: bool - True if at least one environment exists.
def has_environments(self) -> bool:
with belief_scope("has_environments"):
@@ -192,8 +214,6 @@ class ConfigManager:
# [DEF:get_environment:Function]
# @PURPOSE: Returns a single environment by ID.
# @PRE: self.config is set and isinstance(env_id, str) and len(env_id) > 0.
# @POST: Returns Environment object if found, None otherwise.
# @PARAM: env_id (str) - The ID of the environment to retrieve.
# @RETURN: Optional[Environment] - The environment with the given ID, or None.
def get_environment(self, env_id: str) -> Optional[Environment]:
@@ -206,79 +226,61 @@ class ConfigManager:
# [DEF:add_environment:Function]
# @PURPOSE: Adds a new environment to the configuration.
# @PRE: isinstance(env, Environment)
# @POST: Environment added or updated in self.config.environments.
# @PARAM: env (Environment) - The environment to add.
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"
# 1. Runtime check of @PRE
assert isinstance(env, Environment), "env must be an instance of Environment"
# 2. Logic implementation
# Check for duplicate ID and remove if exists
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")
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")
# [/DEF:add_environment:Function]
# [DEF:update_environment:Function]
# @PURPOSE: Updates an existing environment.
# @PRE: isinstance(env_id, str) and len(env_id) > 0 and isinstance(updated_env, Environment)
# @POST: Returns True if environment was found and updated.
# @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.
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"
# 1. Runtime check of @PRE
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"
for i, env in enumerate(self.config.environments):
if env.id == env_id:
if updated_env.password == "********":
updated_env.password = env.password
# 2. Logic implementation
for i, env in enumerate(self.config.environments):
if env.id == env_id:
# If password is masked, keep the old one
if updated_env.password == "********":
updated_env.password = env.password
self.config.environments[i] = updated_env
self.save()
logger.info(f"[update_environment][Coherence:OK] Updated {env_id}")
return True
self.config.environments[i] = updated_env
self.save()
logger.info(f"[update_environment][Coherence:OK] Updated {env_id}")
return True
logger.warning(f"[update_environment][Coherence:Failed] Environment {env_id} not found")
return False
logger.warning(f"[update_environment][Coherence:Failed] Environment {env_id} not found")
return False
# [/DEF:update_environment:Function]
# [DEF:delete_environment:Function]
# @PURPOSE: Deletes an environment by ID.
# @PRE: isinstance(env_id, str) and len(env_id) > 0
# @POST: Environment removed from self.config.environments if it existed.
# @PARAM: env_id (str) - The ID of the environment to delete.
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"
# 1. Runtime check of @PRE
assert env_id and isinstance(env_id, str), "env_id must be a non-empty string"
original_count = len(self.config.environments)
self.config.environments = [e for e in self.config.environments if e.id != env_id]
# 2. Logic implementation
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}")
else:
logger.warning(f"[delete_environment][Coherence:Failed] Environment {env_id} not found")
if len(self.config.environments) < original_count:
self.save()
logger.info(f"[delete_environment][Action] Deleted {env_id}")
else:
logger.warning(f"[delete_environment][Coherence:Failed] Environment {env_id} not found")
# [/DEF:delete_environment:Function]
# [/DEF:ConfigManager:Class]
# [/DEF:ConfigManager:Class]
# [/DEF:ConfigManagerModule:Module]

View File

@@ -3,7 +3,7 @@
# @SEMANTICS: config, models, pydantic
# @PURPOSE: Defines the data models for application configuration using Pydantic.
# @LAYER: Core
# @RELATION: READS_FROM -> config.json
# @RELATION: READS_FROM -> app_configurations (database)
# @RELATION: USED_BY -> ConfigManager
from pydantic import BaseModel, Field
@@ -36,7 +36,7 @@ class Environment(BaseModel):
class LoggingConfig(BaseModel):
level: str = "INFO"
task_log_level: str = "INFO" # Minimum level for task-specific logs (DEBUG, INFO, WARNING, ERROR)
file_path: Optional[str] = "logs/app.log"
file_path: Optional[str] = None
max_bytes: int = 10 * 1024 * 1024
backup_count: int = 5
enable_belief_state: bool = True
@@ -48,6 +48,8 @@ class GlobalSettings(BaseModel):
storage: StorageConfig = Field(default_factory=StorageConfig)
default_environment_id: Optional[str] = None
logging: LoggingConfig = Field(default_factory=LoggingConfig)
connections: List[dict] = []
llm: dict = Field(default_factory=lambda: {"providers": [], "default_provider": ""})
# Task retention settings
task_retention_days: int = 30

View File

@@ -1,7 +1,7 @@
# [DEF:backend.src.core.database:Module]
#
# @SEMANTICS: database, sqlite, sqlalchemy, session, persistence
# @PURPOSE: Configures the SQLite database connection and session management.
# @SEMANTICS: database, postgresql, sqlalchemy, session, persistence
# @PURPOSE: Configures database connection and session management (PostgreSQL-first).
# @LAYER: Core
# @RELATION: DEPENDS_ON -> sqlalchemy
# @RELATION: USES -> backend.src.models.mapping
@@ -14,6 +14,10 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from ..models.mapping import Base
# Import models to ensure they're registered with Base
from ..models import task as _task_models # noqa: F401
from ..models import auth as _auth_models # noqa: F401
from ..models import config as _config_models # noqa: F401
from ..models import llm as _llm_models # noqa: F401
from .logger import belief_scope
from .auth.config import auth_config
import os
@@ -21,44 +25,50 @@ from pathlib import Path
# [/SECTION]
# [DEF:BASE_DIR:Variable]
# @PURPOSE: Base directory for the backend (where .db files should reside).
# @PURPOSE: Base directory for the backend.
BASE_DIR = Path(__file__).resolve().parent.parent.parent
# [/DEF:BASE_DIR:Variable]
# [DEF:DATABASE_URL:Constant]
# @PURPOSE: URL for the main mappings database.
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{BASE_DIR}/mappings.db")
# @PURPOSE: URL for the main application database.
DEFAULT_POSTGRES_URL = os.getenv(
"POSTGRES_URL",
"postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools",
)
DATABASE_URL = os.getenv("DATABASE_URL", DEFAULT_POSTGRES_URL)
# [/DEF:DATABASE_URL:Constant]
# [DEF:TASKS_DATABASE_URL:Constant]
# @PURPOSE: URL for the tasks execution database.
TASKS_DATABASE_URL = os.getenv("TASKS_DATABASE_URL", f"sqlite:///{BASE_DIR}/tasks.db")
# Defaults to DATABASE_URL to keep task logs in the same PostgreSQL instance.
TASKS_DATABASE_URL = os.getenv("TASKS_DATABASE_URL", DATABASE_URL)
# [/DEF:TASKS_DATABASE_URL:Constant]
# [DEF:AUTH_DATABASE_URL:Constant]
# @PURPOSE: URL for the authentication database.
AUTH_DATABASE_URL = os.getenv("AUTH_DATABASE_URL", auth_config.AUTH_DATABASE_URL)
# If it's a relative sqlite path starting with ./backend/, fix it to be absolute or relative to BASE_DIR
if AUTH_DATABASE_URL.startswith("sqlite:///./backend/"):
AUTH_DATABASE_URL = AUTH_DATABASE_URL.replace("sqlite:///./backend/", f"sqlite:///{BASE_DIR}/")
elif AUTH_DATABASE_URL.startswith("sqlite:///./") and not AUTH_DATABASE_URL.startswith("sqlite:///./backend/"):
# If it's just ./ but we are in backend, it's fine, but let's make it absolute for robustness
AUTH_DATABASE_URL = AUTH_DATABASE_URL.replace("sqlite:///./", f"sqlite:///{BASE_DIR}/")
# [/DEF:AUTH_DATABASE_URL:Constant]
# [DEF:engine:Variable]
def _build_engine(db_url: str):
with belief_scope("_build_engine"):
if db_url.startswith("sqlite"):
return create_engine(db_url, connect_args={"check_same_thread": False})
return create_engine(db_url, pool_pre_ping=True)
# @PURPOSE: SQLAlchemy engine for mappings database.
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
engine = _build_engine(DATABASE_URL)
# [/DEF:engine:Variable]
# [DEF:tasks_engine:Variable]
# @PURPOSE: SQLAlchemy engine for tasks database.
tasks_engine = create_engine(TASKS_DATABASE_URL, connect_args={"check_same_thread": False})
tasks_engine = _build_engine(TASKS_DATABASE_URL)
# [/DEF:tasks_engine:Variable]
# [DEF:auth_engine:Variable]
# @PURPOSE: SQLAlchemy engine for authentication database.
auth_engine = create_engine(AUTH_DATABASE_URL, connect_args={"check_same_thread": False})
auth_engine = _build_engine(AUTH_DATABASE_URL)
# [/DEF:auth_engine:Variable]
# [DEF:SessionLocal:Class]

View File

@@ -0,0 +1,228 @@
# [DEF:test_logger:Module]
# @TIER: STANDARD
# @PURPOSE: Unit tests for logger module
# @LAYER: Infra
# @RELATION: VERIFIES -> src.core.logger
import sys
from pathlib import Path
# Add src to path
sys.path.append(str(Path(__file__).parent.parent.parent.parent / "src"))
import pytest
from src.core.logger import (
belief_scope,
logger,
configure_logger,
get_task_log_level,
should_log_task_level
)
from src.core.config_models import LoggingConfig
# [DEF:test_belief_scope_logs_entry_action_exit_at_debug:Function]
# @PURPOSE: Test that belief_scope generates [ID][Entry], [ID][Action], and [ID][Exit] logs at DEBUG level.
# @PRE: belief_scope is available. caplog fixture is used. Logger configured to DEBUG.
# @POST: Logs are verified to contain Entry, Action, and Exit tags at DEBUG level.
def test_belief_scope_logs_entry_action_exit_at_debug(caplog):
"""Test that belief_scope generates [ID][Entry], [ID][Action], and [ID][Exit] logs at DEBUG level."""
# Configure logger to DEBUG level
config = LoggingConfig(
level="DEBUG",
task_log_level="DEBUG",
enable_belief_state=True
)
configure_logger(config)
caplog.set_level("DEBUG")
with belief_scope("TestFunction"):
logger.info("Doing something important")
# Check that the logs contain the expected patterns
log_messages = [record.message for record in caplog.records]
assert any("[TestFunction][Entry]" in msg for msg in log_messages), "Entry log not found"
assert any("[TestFunction][Action] Doing something important" in msg for msg in log_messages), "Action log not found"
assert any("[TestFunction][Exit]" in msg for msg in log_messages), "Exit log not found"
# Reset to INFO
config = LoggingConfig(level="INFO", task_log_level="INFO", enable_belief_state=True)
configure_logger(config)
# [/DEF:test_belief_scope_logs_entry_action_exit_at_debug:Function]
# [DEF:test_belief_scope_error_handling:Function]
# @PURPOSE: Test that belief_scope logs Coherence:Failed on exception.
# @PRE: belief_scope is available. caplog fixture is used. Logger configured to DEBUG.
# @POST: Logs are verified to contain Coherence:Failed tag.
def test_belief_scope_error_handling(caplog):
"""Test that belief_scope logs Coherence:Failed on exception."""
# Configure logger to DEBUG level
config = LoggingConfig(
level="DEBUG",
task_log_level="DEBUG",
enable_belief_state=True
)
configure_logger(config)
caplog.set_level("DEBUG")
with pytest.raises(ValueError):
with belief_scope("FailingFunction"):
raise ValueError("Something went wrong")
log_messages = [record.message for record in caplog.records]
assert any("[FailingFunction][Entry]" in msg for msg in log_messages), "Entry log not found"
assert any("[FailingFunction][Coherence:Failed]" in msg for msg in log_messages), "Failed coherence log not found"
# Exit should not be logged on failure
# Reset to INFO
config = LoggingConfig(level="INFO", task_log_level="INFO", enable_belief_state=True)
configure_logger(config)
# [/DEF:test_belief_scope_error_handling:Function]
# [DEF:test_belief_scope_success_coherence:Function]
# @PURPOSE: Test that belief_scope logs Coherence:OK on success.
# @PRE: belief_scope is available. caplog fixture is used. Logger configured to DEBUG.
# @POST: Logs are verified to contain Coherence:OK tag.
def test_belief_scope_success_coherence(caplog):
"""Test that belief_scope logs Coherence:OK on success."""
# Configure logger to DEBUG level
config = LoggingConfig(
level="DEBUG",
task_log_level="DEBUG",
enable_belief_state=True
)
configure_logger(config)
caplog.set_level("DEBUG")
with belief_scope("SuccessFunction"):
pass
log_messages = [record.message for record in caplog.records]
assert any("[SuccessFunction][Coherence:OK]" in msg for msg in log_messages), "Success coherence log not found"
# Reset to INFO
config = LoggingConfig(level="INFO", task_log_level="INFO", enable_belief_state=True)
configure_logger(config)
# [/DEF:test_belief_scope_success_coherence:Function]
# [DEF:test_belief_scope_not_visible_at_info:Function]
# @PURPOSE: Test that belief_scope Entry/Exit/Coherence logs are NOT visible at INFO level.
# @PRE: belief_scope is available. caplog fixture is used.
# @POST: Entry/Exit/Coherence logs are not captured at INFO level.
def test_belief_scope_not_visible_at_info(caplog):
"""Test that belief_scope Entry/Exit/Coherence logs are NOT visible at INFO level."""
caplog.set_level("INFO")
with belief_scope("InfoLevelFunction"):
logger.info("Doing something important")
log_messages = [record.message for record in caplog.records]
# Action log should be visible
assert any("[InfoLevelFunction][Action] Doing something important" in msg for msg in log_messages), "Action log not found"
# Entry/Exit/Coherence should NOT be visible at INFO level
assert not any("[InfoLevelFunction][Entry]" in msg for msg in log_messages), "Entry log should not be visible at INFO"
assert not any("[InfoLevelFunction][Exit]" in msg for msg in log_messages), "Exit log should not be visible at INFO"
assert not any("[InfoLevelFunction][Coherence:OK]" in msg for msg in log_messages), "Coherence log should not be visible at INFO"
# [/DEF:test_belief_scope_not_visible_at_info:Function]
# [DEF:test_task_log_level_default:Function]
# @PURPOSE: Test that default task log level is INFO.
# @PRE: None.
# @POST: Default level is INFO.
def test_task_log_level_default():
"""Test that default task log level is INFO."""
level = get_task_log_level()
assert level == "INFO"
# [/DEF:test_task_log_level_default:Function]
# [DEF:test_should_log_task_level:Function]
# @PURPOSE: Test that should_log_task_level correctly filters log levels.
# @PRE: None.
# @POST: Filtering works correctly for all level combinations.
def test_should_log_task_level():
"""Test that should_log_task_level correctly filters log levels."""
# Default level is INFO
assert should_log_task_level("ERROR") is True, "ERROR should be logged at INFO threshold"
assert should_log_task_level("WARNING") is True, "WARNING should be logged at INFO threshold"
assert should_log_task_level("INFO") is True, "INFO should be logged at INFO threshold"
assert should_log_task_level("DEBUG") is False, "DEBUG should NOT be logged at INFO threshold"
# [/DEF:test_should_log_task_level:Function]
# [DEF:test_configure_logger_task_log_level:Function]
# @PURPOSE: Test that configure_logger updates task_log_level.
# @PRE: LoggingConfig is available.
# @POST: task_log_level is updated correctly.
def test_configure_logger_task_log_level():
"""Test that configure_logger updates task_log_level."""
config = LoggingConfig(
level="DEBUG",
task_log_level="DEBUG",
enable_belief_state=True
)
configure_logger(config)
assert get_task_log_level() == "DEBUG", "task_log_level should be DEBUG"
assert should_log_task_level("DEBUG") is True, "DEBUG should be logged at DEBUG threshold"
# Reset to INFO
config = LoggingConfig(
level="INFO",
task_log_level="INFO",
enable_belief_state=True
)
configure_logger(config)
assert get_task_log_level() == "INFO", "task_log_level should be reset to INFO"
# [/DEF:test_configure_logger_task_log_level:Function]
# [DEF:test_enable_belief_state_flag:Function]
# @PURPOSE: Test that enable_belief_state flag controls belief_scope logging.
# @PRE: LoggingConfig is available. caplog fixture is used.
# @POST: belief_scope logs are controlled by the flag.
def test_enable_belief_state_flag(caplog):
"""Test that enable_belief_state flag controls belief_scope logging."""
# Disable belief state
config = LoggingConfig(
level="DEBUG",
task_log_level="DEBUG",
enable_belief_state=False
)
configure_logger(config)
caplog.set_level("DEBUG")
with belief_scope("DisabledFunction"):
logger.info("Doing something")
log_messages = [record.message for record in caplog.records]
# Entry and Exit should NOT be logged when disabled
assert not any("[DisabledFunction][Entry]" in msg for msg in log_messages), "Entry should not be logged when disabled"
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"
# Re-enable for other tests
config = LoggingConfig(
level="DEBUG",
task_log_level="DEBUG",
enable_belief_state=True
)
configure_logger(config)
# [/DEF:test_enable_belief_state_flag:Function]
# [/DEF:test_logger:Module]

View File

@@ -11,6 +11,7 @@
# [SECTION: IMPORTS]
import json
import re
import zipfile
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union, cast
@@ -87,11 +88,11 @@ class SupersetClient:
if 'columns' not in validated_query:
validated_query['columns'] = ["slug", "id", "changed_on_utc", "dashboard_title", "published"]
total_count = self._fetch_total_object_count(endpoint="/dashboard/")
paginated_data = self._fetch_all_pages(
endpoint="/dashboard/",
pagination_options={"base_query": validated_query, "total_count": total_count, "results_field": "result"},
pagination_options={"base_query": validated_query, "results_field": "result"},
)
total_count = len(paginated_data)
app_logger.info("[get_dashboards][Exit] Found %d dashboards.", total_count)
return total_count, paginated_data
# [/DEF:get_dashboards:Function]
@@ -120,6 +121,252 @@ class SupersetClient:
return result
# [/DEF:get_dashboards_summary:Function]
# [DEF:get_dashboard:Function]
# @PURPOSE: Fetches a single dashboard by ID.
# @PRE: Client is authenticated and dashboard_id exists.
# @POST: Returns dashboard payload from Superset API.
# @RETURN: Dict
def get_dashboard(self, dashboard_id: int) -> Dict:
with belief_scope("SupersetClient.get_dashboard", f"id={dashboard_id}"):
response = self.network.request(method="GET", endpoint=f"/dashboard/{dashboard_id}")
return cast(Dict, response)
# [/DEF:get_dashboard:Function]
# [DEF:get_chart:Function]
# @PURPOSE: Fetches a single chart by ID.
# @PRE: Client is authenticated and chart_id exists.
# @POST: Returns chart payload from Superset API.
# @RETURN: Dict
def get_chart(self, chart_id: int) -> Dict:
with belief_scope("SupersetClient.get_chart", f"id={chart_id}"):
response = self.network.request(method="GET", endpoint=f"/chart/{chart_id}")
return cast(Dict, response)
# [/DEF:get_chart:Function]
# [DEF:get_dashboard_detail:Function]
# @PURPOSE: Fetches detailed dashboard information including related charts and datasets.
# @PRE: Client is authenticated and dashboard_id exists.
# @POST: Returns dashboard metadata with charts and datasets lists.
# @RETURN: Dict
def get_dashboard_detail(self, dashboard_id: int) -> Dict:
with belief_scope("SupersetClient.get_dashboard_detail", f"id={dashboard_id}"):
dashboard_response = self.get_dashboard(dashboard_id)
dashboard_data = dashboard_response.get("result", dashboard_response)
charts: List[Dict] = []
datasets: List[Dict] = []
def extract_dataset_id_from_form_data(form_data: Optional[Dict]) -> Optional[int]:
if not isinstance(form_data, dict):
return None
datasource = form_data.get("datasource")
if isinstance(datasource, str):
matched = re.match(r"^(\d+)__", datasource)
if matched:
try:
return int(matched.group(1))
except ValueError:
return None
if isinstance(datasource, dict):
ds_id = datasource.get("id")
try:
return int(ds_id) if ds_id is not None else None
except (TypeError, ValueError):
return None
ds_id = form_data.get("datasource_id")
try:
return int(ds_id) if ds_id is not None else None
except (TypeError, ValueError):
return None
# Canonical endpoints from Superset OpenAPI:
# /dashboard/{id_or_slug}/charts and /dashboard/{id_or_slug}/datasets.
try:
charts_response = self.network.request(
method="GET",
endpoint=f"/dashboard/{dashboard_id}/charts"
)
charts_payload = charts_response.get("result", []) if isinstance(charts_response, dict) else []
for chart_obj in charts_payload:
if not isinstance(chart_obj, dict):
continue
chart_id = chart_obj.get("id")
if chart_id is None:
continue
form_data = chart_obj.get("form_data")
if isinstance(form_data, str):
try:
form_data = json.loads(form_data)
except Exception:
form_data = {}
dataset_id = extract_dataset_id_from_form_data(form_data) or chart_obj.get("datasource_id")
charts.append({
"id": int(chart_id),
"title": chart_obj.get("slice_name") or chart_obj.get("name") or f"Chart {chart_id}",
"viz_type": (form_data.get("viz_type") if isinstance(form_data, dict) else None),
"dataset_id": int(dataset_id) if dataset_id is not None else None,
"last_modified": chart_obj.get("changed_on"),
"overview": chart_obj.get("description") or (form_data.get("viz_type") if isinstance(form_data, dict) else None) or "Chart",
})
except Exception as e:
app_logger.warning("[get_dashboard_detail][Warning] Failed to fetch dashboard charts: %s", e)
try:
datasets_response = self.network.request(
method="GET",
endpoint=f"/dashboard/{dashboard_id}/datasets"
)
datasets_payload = datasets_response.get("result", []) if isinstance(datasets_response, dict) else []
for dataset_obj in datasets_payload:
if not isinstance(dataset_obj, dict):
continue
dataset_id = dataset_obj.get("id")
if dataset_id is None:
continue
db_payload = dataset_obj.get("database")
db_name = db_payload.get("database_name") if isinstance(db_payload, dict) else None
table_name = dataset_obj.get("table_name") or dataset_obj.get("datasource_name") or dataset_obj.get("name") or f"Dataset {dataset_id}"
schema = dataset_obj.get("schema")
fq_name = f"{schema}.{table_name}" if schema else table_name
datasets.append({
"id": int(dataset_id),
"table_name": table_name,
"schema": schema,
"database": db_name or dataset_obj.get("database_name") or "Unknown",
"last_modified": dataset_obj.get("changed_on"),
"overview": fq_name,
})
except Exception as e:
app_logger.warning("[get_dashboard_detail][Warning] Failed to fetch dashboard datasets: %s", e)
# Fallback: derive chart IDs from layout metadata if dashboard charts endpoint fails.
if not charts:
raw_position_json = dashboard_data.get("position_json")
chart_ids_from_position = set()
if isinstance(raw_position_json, str) and raw_position_json:
try:
parsed_position = json.loads(raw_position_json)
chart_ids_from_position.update(self._extract_chart_ids_from_layout(parsed_position))
except Exception:
pass
elif isinstance(raw_position_json, dict):
chart_ids_from_position.update(self._extract_chart_ids_from_layout(raw_position_json))
raw_json_metadata = dashboard_data.get("json_metadata")
if isinstance(raw_json_metadata, str) and raw_json_metadata:
try:
parsed_metadata = json.loads(raw_json_metadata)
chart_ids_from_position.update(self._extract_chart_ids_from_layout(parsed_metadata))
except Exception:
pass
elif isinstance(raw_json_metadata, dict):
chart_ids_from_position.update(self._extract_chart_ids_from_layout(raw_json_metadata))
app_logger.info(
"[get_dashboard_detail][State] Extracted %s fallback chart IDs from layout (dashboard_id=%s)",
len(chart_ids_from_position),
dashboard_id,
)
for chart_id in sorted(chart_ids_from_position):
try:
chart_response = self.get_chart(int(chart_id))
chart_data = chart_response.get("result", chart_response)
charts.append({
"id": int(chart_id),
"title": chart_data.get("slice_name") or chart_data.get("name") or f"Chart {chart_id}",
"viz_type": chart_data.get("viz_type"),
"dataset_id": chart_data.get("datasource_id"),
"last_modified": chart_data.get("changed_on"),
"overview": chart_data.get("description") or chart_data.get("viz_type") or "Chart",
})
except Exception as e:
app_logger.warning("[get_dashboard_detail][Warning] Failed to resolve fallback chart %s: %s", chart_id, e)
# Backfill datasets from chart datasource IDs.
dataset_ids_from_charts = {
c.get("dataset_id")
for c in charts
if c.get("dataset_id") is not None
}
known_dataset_ids = {d.get("id") for d in datasets}
missing_dataset_ids = [ds_id for ds_id in dataset_ids_from_charts if ds_id not in known_dataset_ids]
for dataset_id in missing_dataset_ids:
try:
dataset_response = self.get_dataset(int(dataset_id))
dataset_data = dataset_response.get("result", dataset_response)
db_payload = dataset_data.get("database")
db_name = db_payload.get("database_name") if isinstance(db_payload, dict) else None
table_name = dataset_data.get("table_name") or f"Dataset {dataset_id}"
schema = dataset_data.get("schema")
fq_name = f"{schema}.{table_name}" if schema else table_name
datasets.append({
"id": int(dataset_id),
"table_name": table_name,
"schema": schema,
"database": db_name or "Unknown",
"last_modified": dataset_data.get("changed_on_utc") or dataset_data.get("changed_on"),
"overview": fq_name,
})
except Exception as e:
app_logger.warning("[get_dashboard_detail][Warning] Failed to resolve dataset %s: %s", dataset_id, e)
unique_charts = {}
for chart in charts:
unique_charts[chart["id"]] = chart
unique_datasets = {}
for dataset in datasets:
unique_datasets[dataset["id"]] = dataset
return {
"id": dashboard_data.get("id", dashboard_id),
"title": dashboard_data.get("dashboard_title") or dashboard_data.get("title") or f"Dashboard {dashboard_id}",
"slug": dashboard_data.get("slug"),
"url": dashboard_data.get("url"),
"description": dashboard_data.get("description") or "",
"last_modified": dashboard_data.get("changed_on_utc") or dashboard_data.get("changed_on"),
"published": dashboard_data.get("published"),
"charts": list(unique_charts.values()),
"datasets": list(unique_datasets.values()),
"chart_count": len(unique_charts),
"dataset_count": len(unique_datasets),
}
# [/DEF:get_dashboard_detail:Function]
# [DEF:_extract_chart_ids_from_layout:Function]
# @PURPOSE: Traverses dashboard layout metadata and extracts chart IDs from common keys.
# @PRE: payload can be dict/list/scalar.
# @POST: Returns a set of chart IDs found in nested structures.
def _extract_chart_ids_from_layout(self, payload: Union[Dict, List, str, int, None]) -> set:
with belief_scope("_extract_chart_ids_from_layout"):
found = set()
def walk(node):
if isinstance(node, dict):
for key, value in node.items():
if key in ("chartId", "chart_id", "slice_id", "sliceId"):
try:
found.add(int(value))
except (TypeError, ValueError):
pass
if key == "id" and isinstance(value, str):
match = re.match(r"^CHART-(\d+)$", value)
if match:
try:
found.add(int(match.group(1)))
except ValueError:
pass
walk(value)
elif isinstance(node, list):
for item in node:
walk(item)
walk(payload)
return found
# [/DEF:_extract_chart_ids_from_layout:Function]
# [DEF:export_dashboard:Function]
# @PURPOSE: Экспортирует дашборд в виде ZIP-архива.
# @PARAM: dashboard_id (int) - ID дашборда для экспорта.
@@ -203,11 +450,11 @@ class SupersetClient:
app_logger.info("[get_datasets][Enter] Fetching datasets.")
validated_query = self._validate_query_params(query)
total_count = self._fetch_total_object_count(endpoint="/dataset/")
paginated_data = self._fetch_all_pages(
endpoint="/dataset/",
pagination_options={"base_query": validated_query, "total_count": total_count, "results_field": "result"},
pagination_options={"base_query": validated_query, "results_field": "result"},
)
total_count = len(paginated_data)
app_logger.info("[get_datasets][Exit] Found %d datasets.", total_count)
return total_count, paginated_data
# [/DEF:get_datasets:Function]
@@ -236,6 +483,118 @@ class SupersetClient:
return result
# [/DEF:get_datasets_summary:Function]
# [DEF:get_dataset_detail:Function]
# @PURPOSE: Fetches detailed dataset information including columns and linked dashboards
# @PRE: Client is authenticated and dataset_id exists.
# @POST: Returns detailed dataset info with columns and linked dashboards.
# @PARAM: dataset_id (int) - The dataset ID to fetch details for.
# @RETURN: Dict - Dataset details with columns and linked_dashboards.
# @RELATION: CALLS -> self.get_dataset
# @RELATION: CALLS -> self.network.request (for related_objects)
def get_dataset_detail(self, dataset_id: int) -> Dict:
with belief_scope("SupersetClient.get_dataset_detail", f"id={dataset_id}"):
def as_bool(value, default=False):
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in ("1", "true", "yes", "y", "on")
return bool(value)
# Get base dataset info
response = self.get_dataset(dataset_id)
# If the response is a dict and has a 'result' key, use that (standard Superset API)
if isinstance(response, dict) and 'result' in response:
dataset = response['result']
else:
dataset = response
# Extract columns information
columns = dataset.get("columns", [])
column_info = []
for col in columns:
col_id = col.get("id")
if col_id is None:
continue
column_info.append({
"id": int(col_id),
"name": col.get("column_name"),
"type": col.get("type"),
"is_dttm": as_bool(col.get("is_dttm"), default=False),
"is_active": as_bool(col.get("is_active"), default=True),
"description": col.get("description", "")
})
# Get linked dashboards using related_objects endpoint
linked_dashboards = []
try:
related_objects = self.network.request(
method="GET",
endpoint=f"/dataset/{dataset_id}/related_objects"
)
# Handle different response formats
if isinstance(related_objects, dict):
if "dashboards" in related_objects:
dashboards_data = related_objects["dashboards"]
elif "result" in related_objects and isinstance(related_objects["result"], dict):
dashboards_data = related_objects["result"].get("dashboards", [])
else:
dashboards_data = []
for dash in dashboards_data:
if isinstance(dash, dict):
dash_id = dash.get("id")
if dash_id is None:
continue
linked_dashboards.append({
"id": int(dash_id),
"title": dash.get("dashboard_title") or dash.get("title", f"Dashboard {dash_id}"),
"slug": dash.get("slug")
})
else:
try:
dash_id = int(dash)
except (TypeError, ValueError):
continue
linked_dashboards.append({
"id": dash_id,
"title": f"Dashboard {dash_id}",
"slug": None
})
except Exception as e:
app_logger.warning(f"[get_dataset_detail][Warning] Failed to fetch related dashboards: {e}")
linked_dashboards = []
# Extract SQL table information
sql = dataset.get("sql", "")
result = {
"id": dataset.get("id"),
"table_name": dataset.get("table_name"),
"schema": dataset.get("schema"),
"database": (
dataset.get("database", {}).get("database_name", "Unknown")
if isinstance(dataset.get("database"), dict)
else dataset.get("database_name") or "Unknown"
),
"description": dataset.get("description", ""),
"columns": column_info,
"column_count": len(column_info),
"sql": sql,
"linked_dashboards": linked_dashboards,
"linked_dashboard_count": len(linked_dashboards),
"is_sqllab_view": as_bool(dataset.get("is_sqllab_view"), default=False),
"created_on": dataset.get("created_on"),
"changed_on": dataset.get("changed_on")
}
app_logger.info(f"[get_dataset_detail][Exit] Got dataset {dataset_id} with {len(column_info)} columns and {len(linked_dashboards)} linked dashboards")
return result
# [/DEF:get_dataset_detail:Function]
# [DEF:get_dataset:Function]
# @PURPOSE: Получает информацию о конкретном датасете по его ID.
# @PARAM: dataset_id (int) - ID датасета.
@@ -288,11 +647,12 @@ class SupersetClient:
validated_query = self._validate_query_params(query or {})
if 'columns' not in validated_query:
validated_query['columns'] = []
total_count = self._fetch_total_object_count(endpoint="/database/")
paginated_data = self._fetch_all_pages(
endpoint="/database/",
pagination_options={"base_query": validated_query, "total_count": total_count, "results_field": "result"},
pagination_options={"base_query": validated_query, "results_field": "result"},
)
total_count = len(paginated_data)
app_logger.info("[get_databases][Exit] Found %d databases.", total_count)
return total_count, paginated_data
# [/DEF:get_databases:Function]

View File

@@ -11,7 +11,7 @@ import asyncio
import threading
import inspect
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from datetime import datetime, timezone
from typing import Dict, Any, List, Optional
from .models import Task, TaskStatus, LogEntry, LogFilter, LogStats
@@ -312,13 +312,35 @@ class TaskManager:
# @PARAM: offset (int) - Number of tasks to skip.
# @PARAM: status (Optional[TaskStatus]) - Filter by task status.
# @RETURN: List[Task] - List of tasks matching criteria.
def get_tasks(self, limit: int = 10, offset: int = 0, status: Optional[TaskStatus] = None) -> List[Task]:
def get_tasks(
self,
limit: int = 10,
offset: int = 0,
status: Optional[TaskStatus] = None,
plugin_ids: Optional[List[str]] = None,
completed_only: bool = False
) -> List[Task]:
with belief_scope("TaskManager.get_tasks"):
tasks = list(self.tasks.values())
if status:
tasks = [t for t in tasks if t.status == status]
# Sort by start_time descending (most recent first)
tasks.sort(key=lambda t: t.started_at or datetime.min, reverse=True)
if plugin_ids:
plugin_id_set = set(plugin_ids)
tasks = [t for t in tasks if t.plugin_id in plugin_id_set]
if completed_only:
tasks = [t for t in tasks if t.status in [TaskStatus.SUCCESS, TaskStatus.FAILED]]
# Sort by started_at descending with tolerant handling of mixed tz-aware/naive values.
def sort_key(task: Task) -> float:
started_at = task.started_at
if started_at is None:
return float("-inf")
if not isinstance(started_at, datetime):
return float("-inf")
if started_at.tzinfo is None:
return started_at.replace(tzinfo=timezone.utc).timestamp()
return started_at.timestamp()
tasks.sort(key=sort_key, reverse=True)
return tasks[offset:offset + limit]
# [/DEF:get_tasks:Function]

View File

@@ -109,7 +109,8 @@ class Task(BaseModel):
params: Dict[str, Any] = Field(default_factory=dict)
input_required: bool = False
input_request: Optional[Dict[str, Any]] = None
result: Optional[Dict[str, Any]] = None
# Result payload can be dict/list/scalar depending on plugin and legacy records.
result: Optional[Any] = None
# [DEF:__init__:Function]
# @PURPOSE: Initializes the Task model and validates input_request for AWAITING_INPUT status.

View File

@@ -12,6 +12,7 @@ import json
from sqlalchemy.orm import Session
from ...models.task import TaskRecord, TaskLogRecord
from ...models.mapping import Environment
from ..database import TasksSessionLocal
from .models import Task, TaskStatus, LogEntry, TaskLog, LogFilter, LogStats
from ..logger import logger, belief_scope
@@ -21,6 +22,40 @@ from ..logger import logger, belief_scope
# @SEMANTICS: persistence, service, database, sqlalchemy
# @PURPOSE: Provides methods to save and load tasks from the tasks.db database using SQLAlchemy.
class TaskPersistenceService:
@staticmethod
def _json_load_if_needed(value):
if value is None:
return None
if isinstance(value, (dict, list)):
return value
if isinstance(value, str):
stripped = value.strip()
if stripped == "" or stripped.lower() == "null":
return None
try:
return json.loads(stripped)
except json.JSONDecodeError:
return value
return value
@staticmethod
def _parse_datetime(value):
if value is None or isinstance(value, datetime):
return value
if isinstance(value, str):
try:
return datetime.fromisoformat(value)
except ValueError:
return None
return None
@staticmethod
def _resolve_environment_id(session: Session, env_id: Optional[str]) -> Optional[str]:
if not env_id:
return None
exists = session.query(Environment.id).filter(Environment.id == env_id).first()
return env_id if exists else None
# [DEF:__init__:Function]
# @PURPOSE: Initializes the persistence service.
# @PRE: None.
@@ -48,7 +83,8 @@ class TaskPersistenceService:
record.type = task.plugin_id
record.status = task.status.value
record.environment_id = task.params.get("environment_id") or task.params.get("source_env_id")
raw_env_id = task.params.get("environment_id") or task.params.get("source_env_id")
record.environment_id = self._resolve_environment_id(session, raw_env_id)
record.started_at = task.started_at
record.finished_at = task.finished_at
@@ -123,21 +159,28 @@ class TaskPersistenceService:
for record in records:
try:
logs = []
if record.logs:
for log_data in record.logs:
# Handle timestamp conversion if it's a string
if isinstance(log_data.get('timestamp'), str):
log_data['timestamp'] = datetime.fromisoformat(log_data['timestamp'])
logs_payload = self._json_load_if_needed(record.logs)
if isinstance(logs_payload, list):
for log_data in logs_payload:
if not isinstance(log_data, dict):
continue
log_data = dict(log_data)
log_data['timestamp'] = self._parse_datetime(log_data.get('timestamp')) or datetime.utcnow()
logs.append(LogEntry(**log_data))
started_at = self._parse_datetime(record.started_at)
finished_at = self._parse_datetime(record.finished_at)
params = self._json_load_if_needed(record.params)
result = self._json_load_if_needed(record.result)
task = Task(
id=record.id,
plugin_id=record.type,
status=TaskStatus(record.status),
started_at=record.started_at,
finished_at=record.finished_at,
params=record.params or {},
result=record.result,
started_at=started_at,
finished_at=finished_at,
params=params if isinstance(params, dict) else {},
result=result,
logs=logs
)
loaded_tasks.append(task)

View File

@@ -42,6 +42,8 @@ def suggest_mappings(source_databases: List[Dict], target_databases: List[Dict],
name, score, index = match
if score >= threshold:
suggestions.append({
"source_db": s_db['database_name'],
"target_db": target_databases[index]['database_name'],
"source_db_uuid": s_db['uuid'],
"target_db_uuid": target_databases[index]['uuid'],
"confidence": score / 100.0

View File

@@ -118,14 +118,41 @@ class APIClient:
def _init_session(self) -> requests.Session:
with belief_scope("_init_session"):
session = requests.Session()
# Create a custom adapter that handles TLS issues
class TLSAdapter(HTTPAdapter):
def init_poolmanager(self, connections, maxsize, block=False):
from urllib3.poolmanager import PoolManager
import ssl
# Create an SSL context that ignores TLSv1 unrecognized name errors
ctx = ssl.create_default_context()
ctx.set_ciphers('HIGH:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA')
# Ignore TLSV1_UNRECOGNIZED_NAME errors by disabling hostname verification
# This is safe when verify_ssl is false (we're already not verifying the certificate)
ctx.check_hostname = False
self.poolmanager = PoolManager(
num_pools=connections,
maxsize=maxsize,
block=block,
ssl_context=ctx
)
retries = Retry(total=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504])
adapter = HTTPAdapter(max_retries=retries)
adapter = TLSAdapter(max_retries=retries)
session.mount('http://', adapter)
session.mount('https://', adapter)
if not self.request_settings["verify_ssl"]:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
app_logger.warning("[_init_session][State] SSL verification disabled.")
session.verify = self.request_settings["verify_ssl"]
# When verify_ssl is false, we should also disable hostname verification
session.verify = False
else:
session.verify = True
return session
# [/DEF:_init_session:Function]
@@ -328,20 +355,40 @@ class APIClient:
# @PURPOSE: Автоматически собирает данные со всех страниц пагинированного эндпоинта.
# @PARAM: endpoint (str) - Эндпоинт.
# @PARAM: pagination_options (Dict[str, Any]) - Опции пагинации.
# @PRE: pagination_options must contain 'base_query', 'total_count', 'results_field'.
# @PRE: pagination_options must contain 'base_query', 'results_field'. 'total_count' is optional.
# @POST: Returns all items across all pages.
# @RETURN: List[Any] - Список данных.
def fetch_paginated_data(self, endpoint: str, pagination_options: Dict[str, Any]) -> List[Any]:
with belief_scope("fetch_paginated_data"):
base_query, total_count = pagination_options["base_query"], pagination_options["total_count"]
results_field, page_size = pagination_options["results_field"], base_query.get('page_size')
assert page_size and page_size > 0, "'page_size' must be a positive number."
base_query = pagination_options["base_query"]
total_count = pagination_options.get("total_count")
results_field = pagination_options["results_field"]
count_field = pagination_options.get("count_field", "count")
page_size = base_query.get('page_size', 1000)
assert page_size > 0, "'page_size' must be a positive number."
results = []
for page in range((total_count + page_size - 1) // page_size):
page = 0
# Fetch first page to get data and total count if not provided
query = {**base_query, 'page': page}
response_json = cast(Dict[str, Any], self.request("GET", endpoint, params={"q": json.dumps(query)}))
first_page_results = response_json.get(results_field, [])
results.extend(first_page_results)
if total_count is None:
total_count = response_json.get(count_field, len(first_page_results))
app_logger.debug(f"[fetch_paginated_data][State] Total count resolved from first page: {total_count}")
# Fetch remaining pages
total_pages = (total_count + page_size - 1) // page_size
for page in range(1, total_pages):
query = {**base_query, 'page': page}
response_json = cast(Dict[str, Any], self.request("GET", endpoint, params={"q": json.dumps(query)}))
results.extend(response_json.get(results_field, []))
return results
# [/DEF:fetch_paginated_data:Function]

View File

@@ -1,8 +1,8 @@
# [DEF:Dependencies:Module]
# @SEMANTICS: dependency, injection, singleton, factory, auth, jwt
# @PURPOSE: Manages the creation and provision of shared application dependencies, such as the PluginLoader and TaskManager, to avoid circular imports.
# @PURPOSE: Manages creation and provision of shared application dependencies, such as PluginLoader and TaskManager, to avoid circular imports.
# @LAYER: Core
# @RELATION: Used by the main app and API routers to get access to shared instances.
# @RELATION: Used by main app and API routers to get access to shared instances.
from pathlib import Path
from fastapi import Depends, HTTPException, status
@@ -13,6 +13,7 @@ from .core.task_manager import TaskManager
from .core.config_manager import ConfigManager
from .core.scheduler import SchedulerService
from .services.resource_service import ResourceService
from .services.mapping_service import MappingService
from .core.database import init_db, get_auth_db
from .core.logger import logger
from .core.auth.jwt import decode_token
@@ -23,18 +24,18 @@ from .models.auth import User
# Use absolute path relative to this file to ensure plugins are found regardless of CWD
project_root = Path(__file__).parent.parent.parent
config_path = project_root / "config.json"
# Initialize database before services that use persisted configuration.
init_db()
config_manager = ConfigManager(config_path=str(config_path))
# Initialize database before any other services that might use it
init_db()
# [DEF:get_config_manager:Function]
# @PURPOSE: Dependency injector for the ConfigManager.
# @PURPOSE: Dependency injector for ConfigManager.
# @PRE: Global config_manager must be initialized.
# @POST: Returns shared ConfigManager instance.
# @RETURN: ConfigManager - The shared config manager instance.
def get_config_manager() -> ConfigManager:
"""Dependency injector for the ConfigManager."""
"""Dependency injector for ConfigManager."""
return config_manager
# [/DEF:get_config_manager:Function]
@@ -54,54 +55,64 @@ resource_service = ResourceService()
logger.info("ResourceService initialized")
# [DEF:get_plugin_loader:Function]
# @PURPOSE: Dependency injector for the PluginLoader.
# @PURPOSE: Dependency injector for PluginLoader.
# @PRE: Global plugin_loader must be initialized.
# @POST: Returns shared PluginLoader instance.
# @RETURN: PluginLoader - The shared plugin loader instance.
def get_plugin_loader() -> PluginLoader:
"""Dependency injector for the PluginLoader."""
"""Dependency injector for PluginLoader."""
return plugin_loader
# [/DEF:get_plugin_loader:Function]
# [DEF:get_task_manager:Function]
# @PURPOSE: Dependency injector for the TaskManager.
# @PURPOSE: Dependency injector for TaskManager.
# @PRE: Global task_manager must be initialized.
# @POST: Returns shared TaskManager instance.
# @RETURN: TaskManager - The shared task manager instance.
def get_task_manager() -> TaskManager:
"""Dependency injector for the TaskManager."""
"""Dependency injector for TaskManager."""
return task_manager
# [/DEF:get_task_manager:Function]
# [DEF:get_scheduler_service:Function]
# @PURPOSE: Dependency injector for the SchedulerService.
# @PURPOSE: Dependency injector for SchedulerService.
# @PRE: Global scheduler_service must be initialized.
# @POST: Returns shared SchedulerService instance.
# @RETURN: SchedulerService - The shared scheduler service instance.
def get_scheduler_service() -> SchedulerService:
"""Dependency injector for the SchedulerService."""
"""Dependency injector for SchedulerService."""
return scheduler_service
# [/DEF:get_scheduler_service:Function]
# [DEF:get_resource_service:Function]
# @PURPOSE: Dependency injector for the ResourceService.
# @PURPOSE: Dependency injector for ResourceService.
# @PRE: Global resource_service must be initialized.
# @POST: Returns shared ResourceService instance.
# @RETURN: ResourceService - The shared resource service instance.
def get_resource_service() -> ResourceService:
"""Dependency injector for the ResourceService."""
"""Dependency injector for ResourceService."""
return resource_service
# [/DEF:get_resource_service:Function]
# [DEF:get_mapping_service:Function]
# @PURPOSE: Dependency injector for MappingService.
# @PRE: Global config_manager must be initialized.
# @POST: Returns new MappingService instance.
# @RETURN: MappingService - A new mapping service instance.
def get_mapping_service() -> MappingService:
"""Dependency injector for MappingService."""
return MappingService(config_manager)
# [/DEF:get_mapping_service:Function]
# [DEF:oauth2_scheme:Variable]
# @PURPOSE: OAuth2 password bearer scheme for token extraction.
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
# [/DEF:oauth2_scheme:Variable]
# [DEF:get_current_user:Function]
# @PURPOSE: Dependency for retrieving the currently authenticated user from a JWT.
# @PURPOSE: Dependency for retrieving currently authenticated user from a JWT.
# @PRE: JWT token provided in Authorization header.
# @POST: Returns the User object if token is valid.
# @POST: Returns User object if token is valid.
# @THROW: HTTPException 401 if token is invalid or user not found.
# @PARAM: token (str) - Extracted JWT token.
# @PARAM: db (Session) - Auth database session.

View File

@@ -0,0 +1,36 @@
# [DEF:test_models:Module]
# @TIER: TRIVIAL
# @PURPOSE: Unit tests for data models
# @LAYER: Domain
# @RELATION: VERIFIES -> src.models
import sys
from pathlib import Path
# Add src to path
sys.path.append(str(Path(__file__).parent.parent.parent.parent / "src"))
from src.core.config_models import Environment
from src.core.logger import belief_scope
# [DEF:test_environment_model:Function]
# @PURPOSE: Tests that Environment model correctly stores values.
# @PRE: Environment class is available.
# @POST: Values are verified.
def test_environment_model():
with belief_scope("test_environment_model"):
env = Environment(
id="test-id",
name="test-env",
url="http://localhost:8088/api/v1",
username="admin",
password="password"
)
assert env.id == "test-id"
assert env.name == "test-env"
assert env.url == "http://localhost:8088/api/v1"
# [/DEF:test_environment_model:Function]
# [/DEF:test_models:Module]

View File

@@ -0,0 +1,26 @@
# [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
from sqlalchemy import Column, String, DateTime, JSON
from sqlalchemy.sql import func
from .mapping import Base
# [DEF:AppConfigRecord:Class]
# @PURPOSE: Stores the single source of truth for application configuration.
class AppConfigRecord(Base):
__tablename__ = "app_configurations"
id = Column(String, primary_key=True)
payload = Column(JSON, nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
# [/DEF:AppConfigRecord:Class]
# [/DEF:backend.src.models.config:Module]

View File

@@ -0,0 +1,128 @@
# [DEF:backend.src.models.report:Module]
# @TIER: CRITICAL
# @SEMANTICS: reports, models, pydantic, normalization, pagination
# @PURPOSE: Canonical report schemas for unified task reporting across heterogeneous task types.
# @LAYER: Domain
# @RELATION: DEPENDS_ON -> backend.src.core.task_manager.models
# @INVARIANT: Canonical report fields are always present for every report item.
# [SECTION: IMPORTS]
from datetime import datetime
from enum import Enum
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, field_validator, model_validator
# [/SECTION]
# [DEF:TaskType:Class]
# @PURPOSE: Supported normalized task report types.
class TaskType(str, Enum):
LLM_VERIFICATION = "llm_verification"
BACKUP = "backup"
MIGRATION = "migration"
DOCUMENTATION = "documentation"
UNKNOWN = "unknown"
# [/DEF:TaskType:Class]
# [DEF:ReportStatus:Class]
# @PURPOSE: Supported normalized report status values.
class ReportStatus(str, Enum):
SUCCESS = "success"
FAILED = "failed"
IN_PROGRESS = "in_progress"
PARTIAL = "partial"
# [/DEF:ReportStatus:Class]
# [DEF:ErrorContext:Class]
# @PURPOSE: Error and recovery context for failed/partial reports.
class ErrorContext(BaseModel):
code: Optional[str] = None
message: str
next_actions: List[str] = Field(default_factory=list)
# [/DEF:ErrorContext:Class]
# [DEF:TaskReport:Class]
# @PURPOSE: Canonical normalized report envelope for one task execution.
class TaskReport(BaseModel):
report_id: str
task_id: str
task_type: TaskType
status: ReportStatus
started_at: Optional[datetime] = None
updated_at: datetime
summary: str
details: Optional[Dict[str, Any]] = None
error_context: Optional[ErrorContext] = None
source_ref: Optional[Dict[str, Any]] = None
@field_validator("report_id", "task_id", "summary")
@classmethod
def _non_empty_str(cls, value: str) -> str:
if not isinstance(value, str) or not value.strip():
raise ValueError("Value must be a non-empty string")
return value.strip()
# [/DEF:TaskReport:Class]
# [DEF:ReportQuery:Class]
# @PURPOSE: Query object for server-side report filtering, sorting, and pagination.
class ReportQuery(BaseModel):
page: int = Field(default=1, ge=1)
page_size: int = Field(default=20, ge=1, le=100)
task_types: List[TaskType] = Field(default_factory=list)
statuses: List[ReportStatus] = Field(default_factory=list)
time_from: Optional[datetime] = None
time_to: Optional[datetime] = None
search: Optional[str] = Field(default=None, max_length=200)
sort_by: str = Field(default="updated_at")
sort_order: str = Field(default="desc")
@field_validator("sort_by")
@classmethod
def _validate_sort_by(cls, value: str) -> str:
allowed = {"updated_at", "status", "task_type"}
if value not in allowed:
raise ValueError(f"sort_by must be one of: {', '.join(sorted(allowed))}")
return value
@field_validator("sort_order")
@classmethod
def _validate_sort_order(cls, value: str) -> str:
if value not in {"asc", "desc"}:
raise ValueError("sort_order must be 'asc' or 'desc'")
return value
@model_validator(mode="after")
def _validate_time_range(self):
if self.time_from and self.time_to and self.time_from > self.time_to:
raise ValueError("time_from must be less than or equal to time_to")
return self
# [/DEF:ReportQuery:Class]
# [DEF:ReportCollection:Class]
# @PURPOSE: Paginated collection of normalized task reports.
class ReportCollection(BaseModel):
items: List[TaskReport]
total: int = Field(ge=0)
page: int = Field(ge=1)
page_size: int = Field(ge=1)
has_next: bool
applied_filters: ReportQuery
# [/DEF:ReportCollection:Class]
# [DEF:ReportDetailView:Class]
# @PURPOSE: Detailed report representation including diagnostics and recovery actions.
class ReportDetailView(BaseModel):
report: TaskReport
timeline: List[Dict[str, Any]] = Field(default_factory=list)
diagnostics: Optional[Dict[str, Any]] = None
next_actions: List[str] = Field(default_factory=list)
# [/DEF:ReportDetailView:Class]
# [/DEF:backend.src.models.report:Module]

View File

@@ -22,6 +22,8 @@ class FileCategory(str, Enum):
# @PURPOSE: Configuration model for the storage system, defining paths and naming patterns.
class StorageConfig(BaseModel):
root_path: str = Field(default="backups", description="Absolute path to the storage root directory.")
backup_path: str = Field(default="backups", description="Subpath for backups.")
repo_path: str = Field(default="repositorys", description="Subpath for repositories.")
backup_structure_pattern: str = Field(default="{category}/", description="Pattern for backup directory structure.")
repo_structure_pattern: str = Field(default="{category}/", description="Pattern for repository directory structure.")
filename_pattern: str = Field(default="{name}_{timestamp}", description="Pattern for filenames.")

View File

@@ -113,14 +113,21 @@ class BackupPlugin(PluginBase):
# [DEF:execute:Function]
# @PURPOSE: Executes the dashboard backup logic with TaskContext support.
# @PARAM: params (Dict[str, Any]) - Backup parameters (env, backup_path).
# @PARAM: params (Dict[str, Any]) - Backup parameters (env, backup_path, dashboard_ids).
# @PARAM: context (Optional[TaskContext]) - Task context for logging with source attribution.
# @PRE: Target environment must be configured. params must be a dictionary.
# @POST: All dashboards are exported and archived.
async def execute(self, params: Dict[str, Any], context: Optional[TaskContext] = None):
with belief_scope("execute"):
config_manager = get_config_manager()
env_id = params.get("environment_id")
# Support both parameter names: environment_id (for task creation) and env (for direct calls)
env_id = params.get("environment_id") or params.get("env")
dashboard_ids = params.get("dashboard_ids") or params.get("dashboards")
# Log the incoming parameters for debugging
log = context.logger if context else app_logger
log.info(f"Backup parameters received: env_id={env_id}, dashboard_ids={dashboard_ids}")
# Resolve environment name if environment_id is provided
if env_id:
@@ -132,6 +139,8 @@ class BackupPlugin(PluginBase):
if not env:
raise KeyError("env")
log.info(f"Backup started for environment: {env}, selected dashboards: {dashboard_ids}")
storage_settings = config_manager.get_config().settings.storage
# Use 'backups' subfolder within the storage root
backup_path = Path(storage_settings.root_path) / "backups"
@@ -156,14 +165,37 @@ class BackupPlugin(PluginBase):
client = SupersetClient(env_config)
dashboard_count, dashboard_meta = client.get_dashboards()
superset_log.info(f"Found {dashboard_count} dashboards to export")
# Get all dashboards
all_dashboard_count, all_dashboard_meta = client.get_dashboards()
superset_log.info(f"Found {all_dashboard_count} total dashboards in environment")
# Filter dashboards if specific IDs are provided
if dashboard_ids:
dashboard_ids_int = [int(did) for did in dashboard_ids]
dashboard_meta = [db for db in all_dashboard_meta if db.get('id') in dashboard_ids_int]
dashboard_count = len(dashboard_meta)
superset_log.info(f"Filtered to {dashboard_count} selected dashboards: {dashboard_ids_int}")
else:
dashboard_count = all_dashboard_count
superset_log.info("No dashboard filter applied - backing up all dashboards")
dashboard_meta = all_dashboard_meta
if dashboard_count == 0:
log.info("No dashboards to back up")
return
return {
"status": "NO_DASHBOARDS",
"environment": env,
"backup_root": str(backup_path / env.upper()),
"total_dashboards": 0,
"backed_up_dashboards": 0,
"failed_dashboards": 0,
"dashboards": [],
"failures": []
}
total = len(dashboard_meta)
backed_up_dashboards = []
failed_dashboards = []
for idx, db in enumerate(dashboard_meta, 1):
dashboard_id = db.get('id')
dashboard_title = db.get('dashboard_title', 'Unknown Dashboard')
@@ -191,15 +223,35 @@ class BackupPlugin(PluginBase):
archive_exports(str(dashboard_dir), policy=RetentionPolicy())
storage_log.debug(f"Archived dashboard: {dashboard_title}")
backed_up_dashboards.append({
"id": dashboard_id,
"title": dashboard_title,
"path": str(dashboard_dir)
})
except (SupersetAPIError, RequestException, IOError, OSError) as db_error:
log.error(f"Failed to export dashboard {dashboard_title} (ID: {dashboard_id}): {db_error}")
failed_dashboards.append({
"id": dashboard_id,
"title": dashboard_title,
"error": str(db_error)
})
continue
consolidate_archive_folders(backup_path / env.upper())
remove_empty_directories(str(backup_path / env.upper()))
log.info(f"Backup completed successfully for {env}")
return {
"status": "SUCCESS" if not failed_dashboards else "PARTIAL_SUCCESS",
"environment": env,
"backup_root": str(backup_path / env.upper()),
"total_dashboards": total,
"backed_up_dashboards": len(backed_up_dashboards),
"failed_dashboards": len(failed_dashboards),
"dashboards": backed_up_dashboards,
"failures": failed_dashboards
}
except (RequestException, IOError, KeyError) as e:
log.error(f"Fatal error during backup for {env}: {e}")

View File

@@ -74,7 +74,8 @@ class DashboardValidationPlugin(PluginBase):
log.info(f"Executing {self.name} with params: {params}")
dashboard_id = params.get("dashboard_id")
dashboard_id_raw = params.get("dashboard_id")
dashboard_id = str(dashboard_id_raw) if dashboard_id_raw is not None else None
env_id = params.get("environment_id")
provider_id = params.get("provider_id")

View File

@@ -194,6 +194,15 @@ class MigrationPlugin(PluginBase):
to_env_name = tgt_env.name
log.info(f"Resolved environments: {from_env_name} -> {to_env_name}")
migration_result = {
"status": "SUCCESS",
"source_environment": from_env_name,
"target_environment": to_env_name,
"selected_dashboards": 0,
"migrated_dashboards": [],
"failed_dashboards": [],
"mapping_count": 0
}
from_c = SupersetClient(src_env)
to_c = SupersetClient(tgt_env)
@@ -213,31 +222,43 @@ class MigrationPlugin(PluginBase):
]
else:
log.warning("No selection criteria provided (selected_ids or dashboard_regex).")
return
migration_result["status"] = "NO_SELECTION"
return migration_result
if not dashboards_to_migrate:
log.warning("No dashboards found matching criteria.")
return
migration_result["status"] = "NO_MATCHES"
return migration_result
# Fetch mappings from database
db_mapping = {}
migration_result["selected_dashboards"] = len(dashboards_to_migrate)
# Get mappings from params
db_mapping = params.get("db_mappings", {})
if not isinstance(db_mapping, dict):
db_mapping = {}
# Fetch additional mappings from database if requested
if replace_db_config:
db = SessionLocal()
try:
# Find environment IDs by name
src_env = db.query(Environment).filter(Environment.name == from_env_name).first()
tgt_env = db.query(Environment).filter(Environment.name == to_env_name).first()
src_env_db = db.query(Environment).filter(Environment.name == from_env_name).first()
tgt_env_db = db.query(Environment).filter(Environment.name == to_env_name).first()
if src_env and tgt_env:
mappings = db.query(DatabaseMapping).filter(
DatabaseMapping.source_env_id == src_env.id,
DatabaseMapping.target_env_id == tgt_env.id
if src_env_db and tgt_env_db:
stored_mappings = db.query(DatabaseMapping).filter(
DatabaseMapping.source_env_id == src_env_db.id,
DatabaseMapping.target_env_id == tgt_env_db.id
).all()
db_mapping = {m.source_db_uuid: m.target_db_uuid for m in mappings}
log.info(f"Loaded {len(db_mapping)} database mappings.")
# Provided mappings override stored ones
stored_map_dict = {m.source_db_uuid: m.target_db_uuid for m in stored_mappings}
stored_map_dict.update(db_mapping)
db_mapping = stored_map_dict
log.info(f"Loaded {len(stored_mappings)} database mappings from database.")
finally:
db.close()
migration_result["mapping_count"] = len(db_mapping)
engine = MigrationEngine()
for dash in dashboards_to_migrate:
@@ -274,8 +295,17 @@ class MigrationPlugin(PluginBase):
if success:
to_c.import_dashboard(file_name=tmp_new_zip, dash_id=dash_id, dash_slug=dash_slug)
migration_result["migrated_dashboards"].append({
"id": dash_id,
"title": title
})
else:
migration_log.error(f"Failed to transform ZIP for dashboard {title}")
migration_result["failed_dashboards"].append({
"id": dash_id,
"title": title,
"error": "Failed to transform ZIP"
})
superset_log.info(f"Dashboard {title} imported.")
except Exception as exc:
@@ -321,14 +351,26 @@ class MigrationPlugin(PluginBase):
app_logger.info(f"[MigrationPlugin][Action] Retrying import for {title} with provided passwords.")
to_c.import_dashboard(file_name=tmp_new_zip, dash_id=dash_id, dash_slug=dash_slug, passwords=passwords)
app_logger.info(f"[MigrationPlugin][Success] Dashboard {title} imported after password injection.")
migration_result["migrated_dashboards"].append({
"id": dash_id,
"title": title
})
# Clear passwords from params after use for security
if "passwords" in task.params:
del task.params["passwords"]
continue
app_logger.error(f"[MigrationPlugin][Failure] Failed to migrate dashboard {title}: {exc}", exc_info=True)
migration_result["failed_dashboards"].append({
"id": dash_id,
"title": title,
"error": str(exc)
})
app_logger.info("[MigrationPlugin][Exit] Migration finished.")
if migration_result["failed_dashboards"]:
migration_result["status"] = "PARTIAL_SUCCESS"
return migration_result
except Exception as e:
app_logger.critical(f"[MigrationPlugin][Failure] Fatal error during migration: {e}", exc_info=True)
raise e

View File

@@ -0,0 +1,361 @@
# [DEF:backend.src.scripts.migrate_sqlite_to_postgres:Module]
#
# @TIER: STANDARD
# @SEMANTICS: migration, sqlite, postgresql, config, task_logs, task_records
# @PURPOSE: Migrates legacy config and task history from SQLite/file storage to PostgreSQL.
# @LAYER: Scripts
# @RELATION: READS_FROM -> backend/tasks.db
# @RELATION: READS_FROM -> backend/config.json
# @RELATION: WRITES_TO -> postgresql.task_records
# @RELATION: WRITES_TO -> postgresql.task_logs
# @RELATION: WRITES_TO -> postgresql.app_configurations
#
# @INVARIANT: Script is idempotent for task_records and app_configurations.
# [SECTION: IMPORTS]
import argparse
import json
import os
import sqlite3
from pathlib import Path
from typing import Any, Dict, Iterable, Optional
from sqlalchemy import create_engine, text
from sqlalchemy.exc import SQLAlchemyError
from src.core.logger import belief_scope, logger
# [/SECTION]
# [DEF:Constants:Section]
DEFAULT_TARGET_URL = os.getenv(
"DATABASE_URL",
os.getenv("POSTGRES_URL", "postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools"),
)
# [/DEF:Constants:Section]
# [DEF:_json_load_if_needed:Function]
# @TIER: STANDARD
# @PURPOSE: Parses JSON-like values from SQLite TEXT/JSON columns to Python objects.
# @PRE: value is scalar JSON/text/list/dict or None.
# @POST: Returns normalized Python object or original scalar value.
def _json_load_if_needed(value: Any) -> Any:
with belief_scope("_json_load_if_needed"):
if value is None:
return None
if isinstance(value, (dict, list)):
return value
if isinstance(value, str):
raw = value.strip()
if not raw:
return None
if raw[0] in "{[":
try:
return json.loads(raw)
except json.JSONDecodeError:
return value
return value
# [/DEF:_json_load_if_needed:Function]
# [DEF:_find_legacy_config_path:Function]
# @PURPOSE: Resolves the existing legacy config.json path from candidates.
def _find_legacy_config_path(explicit_path: Optional[str]) -> Optional[Path]:
with belief_scope("_find_legacy_config_path"):
if explicit_path:
p = Path(explicit_path)
return p if p.exists() else None
candidates = [
Path("backend/config.json"),
Path("config.json"),
]
for candidate in candidates:
if candidate.exists():
return candidate
return None
# [/DEF:_find_legacy_config_path:Function]
# [DEF:_connect_sqlite:Function]
# @PURPOSE: Opens a SQLite connection with row factory.
def _connect_sqlite(path: Path) -> sqlite3.Connection:
with belief_scope("_connect_sqlite"):
conn = sqlite3.connect(str(path))
conn.row_factory = sqlite3.Row
return conn
# [/DEF:_connect_sqlite:Function]
# [DEF:_ensure_target_schema:Function]
# @PURPOSE: Ensures required PostgreSQL tables exist before migration.
def _ensure_target_schema(engine) -> None:
with belief_scope("_ensure_target_schema"):
stmts: Iterable[str] = (
"""
CREATE TABLE IF NOT EXISTS app_configurations (
id TEXT PRIMARY KEY,
payload JSONB NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW()
)
""",
"""
CREATE TABLE IF NOT EXISTS task_records (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
status TEXT NOT NULL,
environment_id TEXT NULL,
started_at TIMESTAMPTZ NULL,
finished_at TIMESTAMPTZ NULL,
logs JSONB NULL,
error TEXT NULL,
result JSONB NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
params JSONB NULL
)
""",
"""
CREATE TABLE IF NOT EXISTS task_logs (
id INTEGER PRIMARY KEY,
task_id TEXT NOT NULL,
timestamp TIMESTAMPTZ NOT NULL,
level VARCHAR(16) NOT NULL,
source VARCHAR(64) NOT NULL DEFAULT 'system',
message TEXT NOT NULL,
metadata_json TEXT NULL,
CONSTRAINT fk_task_logs_task
FOREIGN KEY(task_id)
REFERENCES task_records(id)
ON DELETE CASCADE
)
""",
"CREATE INDEX IF NOT EXISTS ix_task_logs_task_timestamp ON task_logs (task_id, timestamp)",
"CREATE INDEX IF NOT EXISTS ix_task_logs_task_level ON task_logs (task_id, level)",
"CREATE INDEX IF NOT EXISTS ix_task_logs_task_source ON task_logs (task_id, source)",
"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_class WHERE relkind = 'S' AND relname = 'task_logs_id_seq'
) THEN
PERFORM 1;
ELSE
CREATE SEQUENCE task_logs_id_seq OWNED BY task_logs.id;
END IF;
END $$;
""",
"ALTER TABLE task_logs ALTER COLUMN id SET DEFAULT nextval('task_logs_id_seq')",
)
with engine.begin() as conn:
for stmt in stmts:
conn.execute(text(stmt))
# [/DEF:_ensure_target_schema:Function]
# [DEF:_migrate_config:Function]
# @PURPOSE: Migrates legacy config.json into app_configurations(global).
def _migrate_config(engine, legacy_config_path: Optional[Path]) -> int:
with belief_scope("_migrate_config"):
if legacy_config_path is None:
logger.info("[_migrate_config][Action] No legacy config.json found, skipping")
return 0
payload = json.loads(legacy_config_path.read_text(encoding="utf-8"))
with engine.begin() as conn:
conn.execute(
text(
"""
INSERT INTO app_configurations (id, payload, updated_at)
VALUES ('global', CAST(:payload AS JSONB), NOW())
ON CONFLICT (id)
DO UPDATE SET payload = EXCLUDED.payload, updated_at = NOW()
"""
),
{"payload": json.dumps(payload, ensure_ascii=True)},
)
logger.info("[_migrate_config][Coherence:OK] Config migrated from %s", legacy_config_path)
return 1
# [/DEF:_migrate_config:Function]
# [DEF:_migrate_tasks_and_logs:Function]
# @PURPOSE: Migrates task_records and task_logs from SQLite into PostgreSQL.
def _migrate_tasks_and_logs(engine, sqlite_conn: sqlite3.Connection) -> Dict[str, int]:
with belief_scope("_migrate_tasks_and_logs"):
stats = {"task_records_total": 0, "task_records_inserted": 0, "task_logs_total": 0, "task_logs_inserted": 0}
rows = sqlite_conn.execute(
"""
SELECT id, type, status, environment_id, started_at, finished_at, logs, error, result, created_at, params
FROM task_records
ORDER BY created_at ASC
"""
).fetchall()
stats["task_records_total"] = len(rows)
with engine.begin() as conn:
existing_env_ids = {
row[0]
for row in conn.execute(text("SELECT id FROM environments")).fetchall()
}
for row in rows:
params_obj = _json_load_if_needed(row["params"])
result_obj = _json_load_if_needed(row["result"])
logs_obj = _json_load_if_needed(row["logs"])
environment_id = row["environment_id"]
if environment_id and environment_id not in existing_env_ids:
# Legacy task may reference environments that were not migrated; keep task row and drop FK value.
environment_id = None
res = conn.execute(
text(
"""
INSERT INTO task_records (
id, type, status, environment_id, started_at, finished_at,
logs, error, result, created_at, params
) VALUES (
:id, :type, :status, :environment_id, :started_at, :finished_at,
CAST(:logs AS JSONB), :error, CAST(:result AS JSONB), :created_at, CAST(:params AS JSONB)
)
ON CONFLICT (id) DO NOTHING
"""
),
{
"id": row["id"],
"type": row["type"],
"status": row["status"],
"environment_id": environment_id,
"started_at": row["started_at"],
"finished_at": row["finished_at"],
"logs": json.dumps(logs_obj, ensure_ascii=True) if logs_obj is not None else None,
"error": row["error"],
"result": json.dumps(result_obj, ensure_ascii=True) if result_obj is not None else None,
"created_at": row["created_at"],
"params": json.dumps(params_obj, ensure_ascii=True) if params_obj is not None else None,
},
)
if res.rowcount and res.rowcount > 0:
stats["task_records_inserted"] += int(res.rowcount)
log_rows = sqlite_conn.execute(
"""
SELECT id, task_id, timestamp, level, source, message, metadata_json
FROM task_logs
ORDER BY id ASC
"""
).fetchall()
stats["task_logs_total"] = len(log_rows)
with engine.begin() as conn:
for row in log_rows:
# Preserve original IDs to keep migration idempotent.
res = conn.execute(
text(
"""
INSERT INTO task_logs (id, task_id, timestamp, level, source, message, metadata_json)
VALUES (:id, :task_id, :timestamp, :level, :source, :message, :metadata_json)
ON CONFLICT (id) DO NOTHING
"""
),
{
"id": row["id"],
"task_id": row["task_id"],
"timestamp": row["timestamp"],
"level": row["level"],
"source": row["source"] or "system",
"message": row["message"],
"metadata_json": row["metadata_json"],
},
)
if res.rowcount and res.rowcount > 0:
stats["task_logs_inserted"] += int(res.rowcount)
# Ensure sequence is aligned after explicit id inserts.
conn.execute(
text(
"""
SELECT setval(
'task_logs_id_seq',
COALESCE((SELECT MAX(id) FROM task_logs), 1),
TRUE
)
"""
)
)
logger.info(
"[_migrate_tasks_and_logs][Coherence:OK] task_records=%s/%s task_logs=%s/%s",
stats["task_records_inserted"],
stats["task_records_total"],
stats["task_logs_inserted"],
stats["task_logs_total"],
)
return stats
# [/DEF:_migrate_tasks_and_logs:Function]
# [DEF:run_migration:Function]
# @PURPOSE: Orchestrates migration from SQLite/file to PostgreSQL.
def run_migration(sqlite_path: Path, target_url: str, legacy_config_path: Optional[Path]) -> Dict[str, int]:
with belief_scope("run_migration"):
logger.info("[run_migration][Entry] sqlite=%s target=%s", sqlite_path, target_url)
if not sqlite_path.exists():
raise FileNotFoundError(f"SQLite source not found: {sqlite_path}")
sqlite_conn = _connect_sqlite(sqlite_path)
engine = create_engine(target_url, pool_pre_ping=True)
try:
_ensure_target_schema(engine)
config_upserted = _migrate_config(engine, legacy_config_path)
stats = _migrate_tasks_and_logs(engine, sqlite_conn)
stats["config_upserted"] = config_upserted
return stats
finally:
sqlite_conn.close()
# [/DEF:run_migration:Function]
# [DEF:main:Function]
# @PURPOSE: CLI entrypoint.
def main() -> int:
with belief_scope("main"):
parser = argparse.ArgumentParser(
description="Migrate legacy config.json and task logs from SQLite to PostgreSQL.",
)
parser.add_argument(
"--sqlite-path",
default="backend/tasks.db",
help="Path to source SQLite DB with task_records/task_logs (default: backend/tasks.db).",
)
parser.add_argument(
"--target-url",
default=DEFAULT_TARGET_URL,
help="Target PostgreSQL SQLAlchemy URL (default: DATABASE_URL/POSTGRES_URL env).",
)
parser.add_argument(
"--config-path",
default=None,
help="Optional path to legacy config.json (auto-detected when omitted).",
)
args = parser.parse_args()
sqlite_path = Path(args.sqlite_path)
legacy_config_path = _find_legacy_config_path(args.config_path)
try:
stats = run_migration(sqlite_path=sqlite_path, target_url=args.target_url, legacy_config_path=legacy_config_path)
print("Migration completed.")
print(json.dumps(stats, indent=2))
return 0
except (SQLAlchemyError, OSError, sqlite3.Error, ValueError) as e:
logger.error("[main][Coherence:Failed] Migration failed: %s", e)
print(f"Migration failed: {e}")
return 1
if __name__ == "__main__":
raise SystemExit(main())
# [/DEF:main:Function]
# [/DEF:backend.src.scripts.migrate_sqlite_to_postgres:Module]

View File

@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""
Script to test dataset-to-dashboard relationships from Superset API.
Usage:
cd backend && .venv/bin/python3 src/scripts/test_dataset_dashboard_relations.py
"""
import json
import sys
from pathlib import Path
# Add src to path (parent of scripts directory)
sys.path.append(str(Path(__file__).parent.parent.parent))
from src.core.superset_client import SupersetClient
from src.core.config_manager import ConfigManager
from src.core.logger import logger
def test_dashboard_dataset_relations():
"""Test fetching dataset-to-dashboard relationships."""
# Load environment from existing config
config_manager = ConfigManager()
environments = config_manager.get_environments()
if not environments:
logger.error("No environments configured!")
return
# Use first available environment
env = environments[0]
logger.info(f"Using environment: {env.name} ({env.url})")
client = SupersetClient(env)
try:
# Authenticate
logger.info("Authenticating to Superset...")
client.authenticate()
logger.info("Authentication successful!")
# Test dashboard ID 13
dashboard_id = 13
logger.info(f"\n=== Fetching Dashboard {dashboard_id} ===")
dashboard = client.network.request(method="GET", endpoint=f"/dashboard/{dashboard_id}")
print("\nDashboard structure:")
print(f" ID: {dashboard.get('id')}")
print(f" Title: {dashboard.get('dashboard_title')}")
print(f" Published: {dashboard.get('published')}")
# Check for slices/charts
if 'slices' in dashboard:
logger.info(f"\n Found {len(dashboard['slices'])} slices/charts in dashboard")
for i, slice_data in enumerate(dashboard['slices'][:5]): # Show first 5
print(f" Slice {i+1}:")
print(f" ID: {slice_data.get('slice_id')}")
print(f" Name: {slice_data.get('slice_name')}")
# Check for datasource_id
if 'datasource_id' in slice_data:
print(f" Datasource ID: {slice_data['datasource_id']}")
if 'datasource_name' in slice_data:
print(f" Datasource Name: {slice_data['datasource_name']}")
if 'datasource_type' in slice_data:
print(f" Datasource Type: {slice_data['datasource_type']}")
else:
logger.warning(" No 'slices' field found in dashboard response")
logger.info(f" Available fields: {list(dashboard.keys())}")
# Test dataset ID 26
dataset_id = 26
logger.info(f"\n=== Fetching Dataset {dataset_id} ===")
dataset = client.get_dataset(dataset_id)
print("\nDataset structure:")
print(f" ID: {dataset.get('id')}")
print(f" Table Name: {dataset.get('table_name')}")
print(f" Schema: {dataset.get('schema')}")
print(f" Database: {dataset.get('database', {}).get('database_name', 'Unknown')}")
# Check for dashboards that use this dataset
logger.info(f"\n=== Finding Dashboards using Dataset {dataset_id} ===")
# Method: Use Superset's related_objects API
try:
logger.info(f" Using /api/v1/dataset/{dataset_id}/related_objects endpoint...")
related_objects = client.network.request(
method="GET",
endpoint=f"/dataset/{dataset_id}/related_objects"
)
logger.info(f" Related objects response type: {type(related_objects)}")
logger.info(f" Related objects keys: {list(related_objects.keys()) if isinstance(related_objects, dict) else 'N/A'}")
# Check for dashboards in related objects
if 'dashboards' in related_objects:
dashboards = related_objects['dashboards']
logger.info(f" Found {len(dashboards)} dashboards using this dataset:")
for dash in dashboards:
logger.info(f" - Dashboard ID {dash.get('id')}: {dash.get('dashboard_title', dash.get('title', 'Unknown'))}")
elif 'result' in related_objects:
# Some Superset versions use 'result' wrapper
result = related_objects['result']
if 'dashboards' in result:
dashboards = result['dashboards']
logger.info(f" Found {len(dashboards)} dashboards using this dataset:")
for dash in dashboards:
logger.info(f" - Dashboard ID {dash.get('id')}: {dash.get('dashboard_title', dash.get('title', 'Unknown'))}")
else:
logger.warning(f" No 'dashboards' key in result. Keys: {list(result.keys())}")
else:
logger.warning(f" No 'dashboards' key in response. Available keys: {list(related_objects.keys())}")
logger.info(f" Full related_objects response:")
print(json.dumps(related_objects, indent=2, default=str)[:1000])
except Exception as e:
logger.error(f" Error fetching related objects: {e}")
import traceback
traceback.print_exc()
# Method 2: Try to use the position_json from dashboard
logger.info(f"\n=== Analyzing Dashboard Position JSON ===")
if 'position_json' in dashboard:
position_data = json.loads(dashboard['position_json'])
logger.info(f" Position data type: {type(position_data)}")
# Look for datasource references
datasource_ids = set()
if isinstance(position_data, dict):
for key, value in position_data.items():
if 'datasource' in key.lower() or key == 'DASHBOARD_VERSION_KEY':
logger.debug(f" Key: {key}, Value type: {type(value)}")
elif isinstance(position_data, list):
logger.info(f" Position data has {len(position_data)} items")
for item in position_data[:3]: # Show first 3
logger.debug(f" Item: {type(item)}, keys: {list(item.keys()) if isinstance(item, dict) else 'N/A'}")
if isinstance(item, dict):
if 'datasource_id' in item:
datasource_ids.add(item['datasource_id'])
if datasource_ids:
logger.info(f" Found datasource IDs: {datasource_ids}")
# Save full response for analysis
output_file = Path(__file__).parent / "dataset_dashboard_analysis.json"
with open(output_file, 'w') as f:
json.dump({
'dashboard': dashboard,
'dataset': dataset
}, f, indent=2, default=str)
logger.info(f"\nFull response saved to: {output_file}")
except Exception as e:
logger.error(f"Error: {e}", exc_info=True)
raise
if __name__ == "__main__":
test_dashboard_dataset_relations()

View File

@@ -7,12 +7,15 @@
# @NOTE: Only export services that don't cause circular imports
# @NOTE: GitService, AuthService, LLMProviderService have circular import issues - import directly when needed
# Only export services that don't cause circular imports
from .mapping_service import MappingService
from .resource_service import ResourceService
# Lazy loading to avoid import issues in tests
__all__ = ['MappingService', 'ResourceService']
__all__ = [
'MappingService',
'ResourceService',
]
def __getattr__(name):
if name == 'MappingService':
from .mapping_service import MappingService
return MappingService
if name == 'ResourceService':
from .resource_service import ResourceService
return ResourceService
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
# [/DEF:backend.src.services:Module]

View File

@@ -0,0 +1,215 @@
# [DEF:backend.src.services.__tests__.test_resource_service:Module]
# @TIER: STANDARD
# @SEMANTICS: resource-service, tests, dashboards, datasets, activity
# @PURPOSE: Unit tests for ResourceService
# @LAYER: Service
# @RELATION: TESTS -> backend.src.services.resource_service
# @RELATION: VERIFIES -> ResourceService
# @INVARIANT: Resource summaries preserve task linkage and status projection behavior.
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from datetime import datetime
# [DEF:test_get_dashboards_with_status:Function]
# @PURPOSE: Validate dashboard enrichment includes git/task status projections.
# @TEST: get_dashboards_with_status returns dashboards with git and task status
# @PRE: SupersetClient returns dashboard list
# @POST: Each dashboard has git_status and last_task fields
@pytest.mark.asyncio
async def test_get_dashboards_with_status():
with patch("src.services.resource_service.SupersetClient") as mock_client, \
patch("src.services.resource_service.GitService"):
from src.services.resource_service import ResourceService
service = ResourceService()
# Mock Superset response
mock_client.return_value.get_dashboards_summary.return_value = [
{"id": 1, "title": "Dashboard 1", "slug": "dash-1"},
{"id": 2, "title": "Dashboard 2", "slug": "dash-2"}
]
# Mock tasks
mock_task = MagicMock()
mock_task.id = "task-123"
mock_task.status = "SUCCESS"
mock_task.params = {"resource_id": "dashboard-1"}
mock_task.created_at = datetime.now()
env = MagicMock()
env.id = "prod"
result = await service.get_dashboards_with_status(env, [mock_task])
assert len(result) == 2
assert result[0]["id"] == 1
assert "git_status" in result[0]
assert "last_task" in result[0]
assert result[0]["last_task"]["task_id"] == "task-123"
# [/DEF:test_get_dashboards_with_status:Function]
# [DEF:test_get_datasets_with_status:Function]
# @TEST: get_datasets_with_status returns datasets with task status
# @PRE: SupersetClient returns dataset list
# @POST: Each dataset has last_task field
@pytest.mark.asyncio
async def test_get_datasets_with_status():
with patch("src.services.resource_service.SupersetClient") as mock_client:
from src.services.resource_service import ResourceService
service = ResourceService()
# Mock Superset response
mock_client.return_value.get_datasets_summary.return_value = [
{"id": 1, "table_name": "users", "schema": "public", "database": "app"},
{"id": 2, "table_name": "orders", "schema": "public", "database": "app"}
]
# Mock tasks
mock_task = MagicMock()
mock_task.id = "task-456"
mock_task.status = "RUNNING"
mock_task.params = {"resource_id": "dataset-1"}
mock_task.created_at = datetime.now()
env = MagicMock()
env.id = "prod"
result = await service.get_datasets_with_status(env, [mock_task])
assert len(result) == 2
assert result[0]["table_name"] == "users"
assert "last_task" in result[0]
assert result[0]["last_task"]["task_id"] == "task-456"
assert result[0]["last_task"]["status"] == "RUNNING"
# [/DEF:test_get_datasets_with_status:Function]
# [DEF:test_get_activity_summary:Function]
# @TEST: get_activity_summary returns active count and recent tasks
# @PRE: tasks list provided
# @POST: Returns dict with active_count and recent_tasks
def test_get_activity_summary():
from src.services.resource_service import ResourceService
service = ResourceService()
# Create mock tasks
task1 = MagicMock()
task1.id = "task-1"
task1.status = "RUNNING"
task1.params = {"resource_name": "Dashboard 1", "resource_type": "dashboard"}
task1.created_at = datetime(2024, 1, 1, 10, 0, 0)
task2 = MagicMock()
task2.id = "task-2"
task2.status = "SUCCESS"
task2.params = {"resource_name": "Dataset 1", "resource_type": "dataset"}
task2.created_at = datetime(2024, 1, 1, 9, 0, 0)
task3 = MagicMock()
task3.id = "task-3"
task3.status = "WAITING_INPUT"
task3.params = {"resource_name": "Dashboard 2", "resource_type": "dashboard"}
task3.created_at = datetime(2024, 1, 1, 8, 0, 0)
result = service.get_activity_summary([task1, task2, task3])
assert result["active_count"] == 2 # RUNNING + WAITING_INPUT
assert len(result["recent_tasks"]) == 3
# [/DEF:test_get_activity_summary:Function]
# [DEF:test_get_git_status_for_dashboard_no_repo:Function]
# @TEST: _get_git_status_for_dashboard returns None when no repo exists
# @PRE: GitService returns None for repo
# @POST: Returns None
def test_get_git_status_for_dashboard_no_repo():
with patch("src.services.resource_service.GitService") as mock_git:
from src.services.resource_service import ResourceService
service = ResourceService()
mock_git.return_value.get_repo.return_value = None
result = service._get_git_status_for_dashboard(123)
assert result is None
# [/DEF:test_get_git_status_for_dashboard_no_repo:Function]
# [DEF:test_get_last_task_for_resource:Function]
# @TEST: _get_last_task_for_resource returns most recent task for resource
# @PRE: tasks list with matching resource_id
# @POST: Returns task summary with task_id and status
def test_get_last_task_for_resource():
from src.services.resource_service import ResourceService
service = ResourceService()
# Create mock tasks
task1 = MagicMock()
task1.id = "task-old"
task1.status = "SUCCESS"
task1.params = {"resource_id": "dashboard-1"}
task1.created_at = datetime(2024, 1, 1, 10, 0, 0)
task2 = MagicMock()
task2.id = "task-new"
task2.status = "RUNNING"
task2.params = {"resource_id": "dashboard-1"}
task2.created_at = datetime(2024, 1, 1, 12, 0, 0)
result = service._get_last_task_for_resource("dashboard-1", [task1, task2])
assert result is not None
assert result["task_id"] == "task-new" # Most recent
assert result["status"] == "RUNNING"
# [/DEF:test_get_last_task_for_resource:Function]
# [DEF:test_extract_resource_name_from_task:Function]
# @TEST: _extract_resource_name_from_task extracts name from params
# @PRE: task has resource_name in params
# @POST: Returns resource name or fallback
def test_extract_resource_name_from_task():
from src.services.resource_service import ResourceService
service = ResourceService()
# Task with resource_name
task = MagicMock()
task.id = "task-123"
task.params = {"resource_name": "My Dashboard"}
result = service._extract_resource_name_from_task(task)
assert result == "My Dashboard"
# Task without resource_name
task2 = MagicMock()
task2.id = "task-456"
task2.params = {}
result2 = service._extract_resource_name_from_task(task2)
assert "task-456" in result2
# [/DEF:test_extract_resource_name_from_task:Function]
# [/DEF:backend.src.services.__tests__.test_resource_service:Module]

View File

@@ -0,0 +1,51 @@
# [DEF:backend.tests.test_report_normalizer:Module]
# @TIER: CRITICAL
# @SEMANTICS: tests, reports, normalizer, fallback
# @PURPOSE: Validate unknown task type fallback and partial payload normalization behavior.
# @LAYER: Domain (Tests)
# @RELATION: TESTS -> backend.src.services.reports.normalizer
# @INVARIANT: Unknown plugin types are mapped to canonical unknown task type.
from datetime import datetime
from src.core.task_manager.models import Task, TaskStatus
from src.services.reports.normalizer import normalize_task_report
def test_unknown_type_maps_to_unknown_profile():
task = Task(
id="unknown-1",
plugin_id="custom-unmapped-plugin",
status=TaskStatus.FAILED,
started_at=datetime.utcnow(),
finished_at=datetime.utcnow(),
params={},
result={"error_message": "Unexpected plugin payload"},
)
report = normalize_task_report(task)
assert report.task_type.value == "unknown"
assert report.summary
assert report.error_context is not None
def test_partial_payload_keeps_report_visible_with_placeholders():
task = Task(
id="partial-1",
plugin_id="superset-backup",
status=TaskStatus.SUCCESS,
started_at=datetime.utcnow(),
finished_at=datetime.utcnow(),
params={},
result=None,
)
report = normalize_task_report(task)
assert report.task_type.value == "backup"
assert report.details is not None
assert "result" in report.details
# [/DEF:backend.tests.test_report_normalizer:Module]

View File

@@ -0,0 +1,152 @@
# [DEF:backend.src.services.reports.normalizer:Module]
# @TIER: CRITICAL
# @SEMANTICS: reports, normalization, tasks, fallback
# @PURPOSE: Convert task manager task objects into canonical unified TaskReport entities with deterministic fallback behavior.
# @LAYER: Domain
# @RELATION: DEPENDS_ON -> backend.src.core.task_manager.models.Task
# @RELATION: DEPENDS_ON -> backend.src.models.report
# @RELATION: DEPENDS_ON -> backend.src.services.reports.type_profiles
# @INVARIANT: Unknown task types and partial payloads remain visible via fallback mapping.
# [SECTION: IMPORTS]
from datetime import datetime
from typing import Any, Dict, Optional
from ...core.task_manager.models import Task, TaskStatus
from ...models.report import ErrorContext, ReportStatus, TaskReport
from .type_profiles import get_type_profile, resolve_task_type
# [/SECTION]
# [DEF:status_to_report_status:Function]
# @PURPOSE: Normalize internal task status to canonical report status.
# @PRE: status may be known or unknown string/enum value.
# @POST: Always returns one of canonical ReportStatus values.
# @PARAM: status (Any) - Internal task status value.
# @RETURN: ReportStatus - Canonical report status.
def status_to_report_status(status: Any) -> ReportStatus:
raw = str(status.value if isinstance(status, TaskStatus) else status).upper()
if raw == TaskStatus.SUCCESS.value:
return ReportStatus.SUCCESS
if raw == TaskStatus.FAILED.value:
return ReportStatus.FAILED
if raw in {TaskStatus.PENDING.value, TaskStatus.RUNNING.value, TaskStatus.AWAITING_INPUT.value, TaskStatus.AWAITING_MAPPING.value}:
return ReportStatus.IN_PROGRESS
return ReportStatus.PARTIAL
# [/DEF:status_to_report_status:Function]
# [DEF:build_summary:Function]
# @PURPOSE: Build deterministic user-facing summary from task payload and status.
# @PRE: report_status is canonical; plugin_id may be unknown.
# @POST: Returns non-empty summary text.
# @PARAM: task (Task) - Source task object.
# @PARAM: report_status (ReportStatus) - Canonical status.
# @RETURN: str - Normalized summary.
def build_summary(task: Task, report_status: ReportStatus) -> str:
result = task.result
if isinstance(result, dict):
for key in ("summary", "message", "status_message", "description"):
value = result.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
if report_status == ReportStatus.SUCCESS:
return "Task completed successfully"
if report_status == ReportStatus.FAILED:
return "Task failed"
if report_status == ReportStatus.IN_PROGRESS:
return "Task is in progress"
return "Task completed with partial data"
# [/DEF:build_summary:Function]
# [DEF:extract_error_context:Function]
# @PURPOSE: Extract normalized error context and next actions for failed/partial reports.
# @PRE: task is a valid Task object.
# @POST: Returns ErrorContext for failed/partial when context exists; otherwise None.
# @PARAM: task (Task) - Source task.
# @PARAM: report_status (ReportStatus) - Canonical status.
# @RETURN: Optional[ErrorContext] - Error context block.
def extract_error_context(task: Task, report_status: ReportStatus) -> Optional[ErrorContext]:
if report_status not in {ReportStatus.FAILED, ReportStatus.PARTIAL}:
return None
result = task.result if isinstance(task.result, dict) else {}
message = None
code = None
next_actions = []
if isinstance(result.get("error"), dict):
error_obj = result.get("error", {})
message = error_obj.get("message") or message
code = error_obj.get("code") or code
actions = error_obj.get("next_actions")
if isinstance(actions, list):
next_actions = [str(action) for action in actions if str(action).strip()]
if not message:
message = result.get("error_message") if isinstance(result.get("error_message"), str) else None
if not message:
for log in reversed(task.logs):
if str(log.level).upper() == "ERROR" and log.message:
message = log.message
break
if not message:
message = "Not provided"
if not next_actions:
next_actions = ["Review task diagnostics", "Retry the operation"]
return ErrorContext(code=code, message=message, next_actions=next_actions)
# [/DEF:extract_error_context:Function]
# [DEF:normalize_task_report:Function]
# @PURPOSE: Convert one Task to canonical TaskReport envelope.
# @PRE: task has valid id and plugin_id fields.
# @POST: Returns TaskReport with required fields and deterministic fallback behavior.
# @PARAM: task (Task) - Source task.
# @RETURN: TaskReport - Canonical normalized report.
def normalize_task_report(task: Task) -> TaskReport:
task_type = resolve_task_type(task.plugin_id)
report_status = status_to_report_status(task.status)
profile = get_type_profile(task_type)
started_at = task.started_at if isinstance(task.started_at, datetime) else None
updated_at = task.finished_at if isinstance(task.finished_at, datetime) else None
if not updated_at:
updated_at = started_at or datetime.utcnow()
details: Dict[str, Any] = {
"profile": {
"display_label": profile.get("display_label"),
"visual_variant": profile.get("visual_variant"),
"icon_token": profile.get("icon_token"),
"emphasis_rules": profile.get("emphasis_rules", []),
},
"result": task.result if task.result is not None else {"note": "Not provided"},
}
source_ref: Dict[str, Any] = {}
if isinstance(task.params, dict):
for key in ("environment_id", "source_env_id", "target_env_id", "dashboard_id", "dataset_id", "resource_id"):
if key in task.params:
source_ref[key] = task.params.get(key)
return TaskReport(
report_id=task.id,
task_id=task.id,
task_type=task_type,
status=report_status,
started_at=started_at,
updated_at=updated_at,
summary=build_summary(task, report_status),
details=details,
error_context=extract_error_context(task, report_status),
source_ref=source_ref or None,
)
# [/DEF:normalize_task_report:Function]
# [/DEF:backend.src.services.reports.normalizer:Module]

View File

@@ -0,0 +1,195 @@
# [DEF:backend.src.services.reports.report_service:Module]
# @TIER: CRITICAL
# @SEMANTICS: reports, service, aggregation, filtering, pagination, detail
# @PURPOSE: Aggregate, normalize, filter, and paginate task reports for unified list/detail API use cases.
# @LAYER: Domain
# @RELATION: DEPENDS_ON -> backend.src.core.task_manager.manager.TaskManager
# @RELATION: DEPENDS_ON -> backend.src.models.report
# @RELATION: DEPENDS_ON -> backend.src.services.reports.normalizer
# @INVARIANT: List responses are deterministic and include applied filter echo metadata.
# [SECTION: IMPORTS]
from datetime import datetime, timezone
from typing import List, Optional
from ...core.task_manager import TaskManager
from ...models.report import ReportCollection, ReportDetailView, ReportQuery, ReportStatus, TaskReport, TaskType
from .normalizer import normalize_task_report
# [/SECTION]
# [DEF:ReportsService:Class]
# @PURPOSE: Service layer for list/detail report retrieval and normalization.
# @TIER: CRITICAL
# @PRE: TaskManager dependency is initialized.
# @POST: Provides deterministic list/detail report responses.
# @INVARIANT: Service methods are read-only over task history source.
class ReportsService:
# [DEF:__init__:Function]
# @TIER: CRITICAL
# @PURPOSE: Initialize service with TaskManager dependency.
# @PRE: task_manager is a live TaskManager instance.
# @POST: self.task_manager is assigned and ready for read operations.
# @INVARIANT: Constructor performs no task mutations.
# @PARAM: task_manager (TaskManager) - Task manager providing source task history.
def __init__(self, task_manager: TaskManager):
self.task_manager = task_manager
# [/DEF:__init__:Function]
# [DEF:_load_normalized_reports:Function]
# @PURPOSE: Build normalized reports from all available tasks.
# @PRE: Task manager returns iterable task history records.
# @POST: Returns normalized report list preserving source cardinality.
# @INVARIANT: Every returned item is a TaskReport.
# @RETURN: List[TaskReport] - Reports sorted later by list logic.
def _load_normalized_reports(self) -> List[TaskReport]:
tasks = self.task_manager.get_all_tasks()
reports = [normalize_task_report(task) for task in tasks]
return reports
# [/DEF:_load_normalized_reports:Function]
# [DEF:_to_utc_datetime:Function]
# @PURPOSE: Normalize naive/aware datetime values to UTC-aware datetime for safe comparisons.
# @PRE: value is either datetime or None.
# @POST: Returns UTC-aware datetime or None.
# @INVARIANT: Naive datetimes are interpreted as UTC to preserve deterministic ordering/filtering.
# @PARAM: value (Optional[datetime]) - Source datetime value.
# @RETURN: Optional[datetime] - UTC-aware datetime or None.
def _to_utc_datetime(self, value: Optional[datetime]) -> Optional[datetime]:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
# [/DEF:_to_utc_datetime:Function]
# [DEF:_datetime_sort_key:Function]
# @PURPOSE: Produce stable numeric sort key for report timestamps.
# @PRE: report contains updated_at datetime.
# @POST: Returns float timestamp suitable for deterministic sorting.
# @INVARIANT: Mixed naive/aware datetimes never raise TypeError.
# @PARAM: report (TaskReport) - Report item.
# @RETURN: float - UTC timestamp key.
def _datetime_sort_key(self, report: TaskReport) -> float:
updated = self._to_utc_datetime(report.updated_at)
if updated is None:
return 0.0
return updated.timestamp()
# [/DEF:_datetime_sort_key:Function]
# [DEF:_matches_query:Function]
# @PURPOSE: Apply query filtering to a report.
# @PRE: report and query are normalized schema instances.
# @POST: Returns True iff report satisfies all active query filters.
# @INVARIANT: Filter evaluation is side-effect free.
# @PARAM: report (TaskReport) - Candidate report.
# @PARAM: query (ReportQuery) - Applied query.
# @RETURN: bool - True if report matches all filters.
def _matches_query(self, report: TaskReport, query: ReportQuery) -> bool:
if query.task_types and report.task_type not in query.task_types:
return False
if query.statuses and report.status not in query.statuses:
return False
report_updated_at = self._to_utc_datetime(report.updated_at)
query_time_from = self._to_utc_datetime(query.time_from)
query_time_to = self._to_utc_datetime(query.time_to)
if query_time_from and report_updated_at and report_updated_at < query_time_from:
return False
if query_time_to and report_updated_at and report_updated_at > query_time_to:
return False
if query.search:
needle = query.search.lower()
haystack = f"{report.summary} {report.task_type.value} {report.status.value}".lower()
if needle not in haystack:
return False
return True
# [/DEF:_matches_query:Function]
# [DEF:_sort_reports:Function]
# @PURPOSE: Sort reports deterministically according to query settings.
# @PRE: reports contains only TaskReport items.
# @POST: Returns reports ordered by selected sort field and order.
# @INVARIANT: Sorting criteria are deterministic for equal input.
# @PARAM: reports (List[TaskReport]) - Filtered reports.
# @PARAM: query (ReportQuery) - Sort config.
# @RETURN: List[TaskReport] - Sorted reports.
def _sort_reports(self, reports: List[TaskReport], query: ReportQuery) -> List[TaskReport]:
reverse = query.sort_order == "desc"
if query.sort_by == "status":
reports.sort(key=lambda item: item.status.value, reverse=reverse)
elif query.sort_by == "task_type":
reports.sort(key=lambda item: item.task_type.value, reverse=reverse)
else:
reports.sort(key=self._datetime_sort_key, reverse=reverse)
return reports
# [/DEF:_sort_reports:Function]
# [DEF:list_reports:Function]
# @PURPOSE: Return filtered, sorted, paginated report collection.
# @PRE: query has passed schema validation.
# @POST: Returns {items,total,page,page_size,has_next,applied_filters}.
# @PARAM: query (ReportQuery) - List filters and pagination.
# @RETURN: ReportCollection - Paginated unified reports payload.
def list_reports(self, query: ReportQuery) -> ReportCollection:
reports = self._load_normalized_reports()
filtered = [report for report in reports if self._matches_query(report, query)]
sorted_reports = self._sort_reports(filtered, query)
total = len(sorted_reports)
start = (query.page - 1) * query.page_size
end = start + query.page_size
items = sorted_reports[start:end]
has_next = end < total
return ReportCollection(
items=items,
total=total,
page=query.page,
page_size=query.page_size,
has_next=has_next,
applied_filters=query,
)
# [/DEF:list_reports:Function]
# [DEF:get_report_detail:Function]
# @PURPOSE: Return one normalized report with timeline/diagnostics/next actions.
# @PRE: report_id exists in normalized report set.
# @POST: Returns normalized detail envelope with diagnostics and next actions where applicable.
# @PARAM: report_id (str) - Stable report identifier.
# @RETURN: Optional[ReportDetailView] - Detailed report or None if not found.
def get_report_detail(self, report_id: str) -> Optional[ReportDetailView]:
reports = self._load_normalized_reports()
target = next((report for report in reports if report.report_id == report_id), None)
if not target:
return None
timeline = []
if target.started_at:
timeline.append({"event": "started", "at": target.started_at.isoformat()})
timeline.append({"event": "updated", "at": target.updated_at.isoformat()})
diagnostics = target.details or {}
if not diagnostics:
diagnostics = {"note": "Not provided"}
if target.error_context:
diagnostics["error_context"] = target.error_context.model_dump()
next_actions = []
if target.error_context and target.error_context.next_actions:
next_actions = target.error_context.next_actions
elif target.status in {ReportStatus.FAILED, ReportStatus.PARTIAL}:
next_actions = ["Review diagnostics", "Retry task if applicable"]
return ReportDetailView(
report=target,
timeline=timeline,
diagnostics=diagnostics,
next_actions=next_actions,
)
# [/DEF:get_report_detail:Function]
# [/DEF:ReportsService:Class]
# [/DEF:backend.src.services.reports.report_service:Module]

View File

@@ -0,0 +1,91 @@
# [DEF:backend.src.services.reports.type_profiles:Module]
# @TIER: CRITICAL
# @SEMANTICS: reports, type_profiles, normalization, fallback
# @PURPOSE: Deterministic mapping of plugin/task identifiers to canonical report task types and fallback profile metadata.
# @LAYER: Domain
# @RELATION: DEPENDS_ON -> backend.src.models.report.TaskType
# @INVARIANT: Unknown input always resolves to TaskType.UNKNOWN with a single fallback profile.
# [SECTION: IMPORTS]
from typing import Any, Dict, Optional
from ...models.report import TaskType
# [/SECTION]
# [DEF:PLUGIN_TO_TASK_TYPE:Data]
# @PURPOSE: Maps plugin identifiers to normalized report task types.
PLUGIN_TO_TASK_TYPE: Dict[str, TaskType] = {
"llm_dashboard_validation": TaskType.LLM_VERIFICATION,
"superset-backup": TaskType.BACKUP,
"superset-migration": TaskType.MIGRATION,
"documentation": TaskType.DOCUMENTATION,
}
# [/DEF:PLUGIN_TO_TASK_TYPE:Data]
# [DEF:TASK_TYPE_PROFILES:Data]
# @PURPOSE: Profile metadata registry for each normalized task type.
TASK_TYPE_PROFILES: Dict[TaskType, Dict[str, Any]] = {
TaskType.LLM_VERIFICATION: {
"display_label": "LLM Verification",
"visual_variant": "llm",
"icon_token": "sparkles",
"emphasis_rules": ["summary", "status", "next_actions"],
"fallback": False,
},
TaskType.BACKUP: {
"display_label": "Backup",
"visual_variant": "backup",
"icon_token": "archive",
"emphasis_rules": ["summary", "status", "updated_at"],
"fallback": False,
},
TaskType.MIGRATION: {
"display_label": "Migration",
"visual_variant": "migration",
"icon_token": "shuffle",
"emphasis_rules": ["summary", "status", "error_context"],
"fallback": False,
},
TaskType.DOCUMENTATION: {
"display_label": "Documentation",
"visual_variant": "documentation",
"icon_token": "file-text",
"emphasis_rules": ["summary", "status", "details"],
"fallback": False,
},
TaskType.UNKNOWN: {
"display_label": "Other / Unknown",
"visual_variant": "unknown",
"icon_token": "help-circle",
"emphasis_rules": ["summary", "status"],
"fallback": True,
},
}
# [/DEF:TASK_TYPE_PROFILES:Data]
# [DEF:resolve_task_type:Function]
# @PURPOSE: Resolve canonical task type from plugin/task identifier with guaranteed fallback.
# @PRE: plugin_id may be None or unknown.
# @POST: Always returns one of TaskType enum values.
# @PARAM: plugin_id (Optional[str]) - Source plugin/task identifier from task record.
# @RETURN: TaskType - Resolved canonical type or UNKNOWN fallback.
def resolve_task_type(plugin_id: Optional[str]) -> TaskType:
normalized = (plugin_id or "").strip()
if not normalized:
return TaskType.UNKNOWN
return PLUGIN_TO_TASK_TYPE.get(normalized, TaskType.UNKNOWN)
# [/DEF:resolve_task_type:Function]
# [DEF:get_type_profile:Function]
# @PURPOSE: Return deterministic profile metadata for a task type.
# @PRE: task_type may be known or unknown.
# @POST: Returns a profile dict and never raises for unknown types.
# @PARAM: task_type (TaskType) - Canonical task type.
# @RETURN: Dict[str, Any] - Profile metadata used by normalization and UI contracts.
def get_type_profile(task_type: TaskType) -> Dict[str, Any]:
return TASK_TYPE_PROFILES.get(task_type, TASK_TYPE_PROFILES[TaskType.UNKNOWN])
# [/DEF:get_type_profile:Function]
# [/DEF:backend.src.services.reports.type_profiles:Module]

Binary file not shown.

View File

@@ -0,0 +1,81 @@
{
"mixed_task_reports": {
"description": "Mixed reports across all supported task types",
"items": [
{
"report_id": "rep-001",
"task_id": "task-001",
"task_type": "llm_verification",
"status": "success",
"started_at": "2026-02-22T09:00:00Z",
"updated_at": "2026-02-22T09:00:30Z",
"summary": "LLM verification completed",
"details": {
"checks_performed": 12,
"issues_found": 1
}
},
{
"report_id": "rep-002",
"task_id": "task-002",
"task_type": "backup",
"status": "failed",
"started_at": "2026-02-22T09:10:00Z",
"updated_at": "2026-02-22T09:11:00Z",
"summary": "Backup failed due to storage limit",
"error_context": {
"message": "Not enough disk space",
"next_actions": ["Free storage", "Retry backup"]
}
},
{
"report_id": "rep-003",
"task_id": "task-003",
"task_type": "migration",
"status": "in_progress",
"started_at": "2026-02-22T09:20:00Z",
"updated_at": "2026-02-22T09:21:00Z",
"summary": "Migration running",
"details": {
"progress_percent": 42
}
},
{
"report_id": "rep-004",
"task_id": "task-004",
"task_type": "documentation",
"status": "partial",
"started_at": "2026-02-22T09:30:00Z",
"updated_at": "2026-02-22T09:31:00Z",
"summary": "Documentation generated with partial coverage",
"error_context": {
"message": "Missing metadata for 3 columns",
"next_actions": ["Review missing metadata"]
}
}
]
},
"unknown_type_partial_payload": {
"description": "Unknown type and partial payload fallback coverage",
"items": [
{
"report_id": "rep-unknown-001",
"task_id": "task-unknown-001",
"task_type": "unknown",
"status": "failed",
"updated_at": "2026-02-22T10:00:00Z",
"summary": "Unknown task type failed",
"details": null
},
{
"report_id": "rep-partial-001",
"task_id": "task-partial-001",
"task_type": "backup",
"status": "success",
"updated_at": "2026-02-22T10:05:00Z",
"summary": "Backup completed",
"details": {}
}
]
}
}

View File

@@ -1,6 +1,8 @@
# [DEF:backend.tests.test_dashboards_api:Module]
# @TIER: STANDARD
# @PURPOSE: Contract-driven tests for Dashboard Hub API
# @LAYER: Domain (Tests)
# @SEMANTICS: tests, dashboards, api, contract
# @RELATION: TESTS -> backend.src.api.routes.dashboards
from fastapi.testclient import TestClient
@@ -48,6 +50,8 @@ def test_get_dashboards_success():
# Validate against Pydantic model
DashboardsResponse(**data)
# [/DEF:test_get_dashboards_success:Function]
# [DEF:test_get_dashboards_env_not_found:Function]
# @TEST: GET /api/dashboards returns 404 if env_id missing
# @PRE: env_id does not exist
@@ -64,4 +68,6 @@ def test_get_dashboards_env_not_found():
assert response.status_code == 404
assert "Environment not found" in response.json()["detail"]
# [/DEF:test_get_dashboards_env_not_found:Function]
# [/DEF:backend.tests.test_dashboards_api:Module]

View File

@@ -1,47 +0,0 @@
# [DEF:backend.tests.test_resource_service:Module]
# @TIER: STANDARD
# @PURPOSE: Contract-driven tests for ResourceService
# @RELATION: TESTS -> backend.src.services.resource_service
import pytest
from unittest.mock import MagicMock, patch
from src.services.resource_service import ResourceService
@pytest.mark.asyncio
async def test_get_dashboards_with_status():
# [DEF:test_get_dashboards_with_status:Function]
# @TEST: ResourceService correctly enhances dashboard data
# @PRE: SupersetClient returns raw dashboards
# @POST: Returned dicts contain git_status and last_task
with patch("src.services.resource_service.SupersetClient") as mock_client, \
patch("src.services.resource_service.GitService") as mock_git:
service = ResourceService()
# Mock Superset response
mock_client.return_value.get_dashboards_summary.return_value = [
{"id": 1, "title": "Test Dashboard", "slug": "test"}
]
# Mock Git status
mock_git.return_value.get_repo.return_value = None # No repo
# Mock tasks
mock_task = MagicMock()
mock_task.id = "task-123"
mock_task.status = "RUNNING"
mock_task.params = {"resource_id": "dashboard-1"}
env = MagicMock()
env.id = "prod"
result = await service.get_dashboards_with_status(env, [mock_task])
assert len(result) == 1
assert result[0]["id"] == 1
assert "git_status" in result[0]
assert result[0]["last_task"]["task_id"] == "task-123"
assert result[0]["last_task"]["status"] == "RUNNING"
# [/DEF:backend.tests.test_resource_service:Module]

29
build.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
if ! command -v docker >/dev/null 2>&1; then
echo "Error: docker is not installed or not in PATH."
exit 1
fi
if docker compose version >/dev/null 2>&1; then
COMPOSE_CMD=(docker compose)
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD=(docker-compose)
else
echo "Error: docker compose is not available."
exit 1
fi
echo "[1/2] Building project images..."
"${COMPOSE_CMD[@]}" build
echo "[2/2] Starting Docker services..."
"${COMPOSE_CMD[@]}" up -d
echo "Done. Services are running."
echo "Use '${COMPOSE_CMD[*]} ps' to check status and '${COMPOSE_CMD[*]} logs -f' to stream logs."

54
docker-compose.yml Normal file
View File

@@ -0,0 +1,54 @@
services:
db:
image: ${POSTGRES_IMAGE:-postgres:16-alpine}
container_name: ss_tools_db
restart: unless-stopped
environment:
POSTGRES_DB: ss_tools
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "${POSTGRES_HOST_PORT:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d ss_tools"]
interval: 10s
timeout: 5s
retries: 10
backend:
build:
context: .
dockerfile: docker/backend.Dockerfile
container_name: ss_tools_backend
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
POSTGRES_URL: postgresql+psycopg2://postgres:postgres@db:5432/ss_tools
DATABASE_URL: postgresql+psycopg2://postgres:postgres@db:5432/ss_tools
TASKS_DATABASE_URL: postgresql+psycopg2://postgres:postgres@db:5432/ss_tools
AUTH_DATABASE_URL: postgresql+psycopg2://postgres:postgres@db:5432/ss_tools
BACKEND_PORT: 8000
ports:
- "${BACKEND_HOST_PORT:-8001}:8000"
volumes:
- ./config.json:/app/config.json
- ./backups:/app/backups
- ./backend/git_repos:/app/backend/git_repos
frontend:
build:
context: .
dockerfile: docker/frontend.Dockerfile
container_name: ss_tools_frontend
restart: unless-stopped
depends_on:
- backend
ports:
- "${FRONTEND_HOST_PORT:-8000}:80"
volumes:
postgres_data:

24
docker/backend.Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV BACKEND_PORT=8000
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt /app/backend/requirements.txt
RUN pip install --no-cache-dir -r /app/backend/requirements.txt
RUN python -m playwright install --with-deps chromium
COPY backend/ /app/backend/
WORKDIR /app/backend
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "src.app:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,16 @@
FROM node:20-alpine AS build
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM nginx:1.27-alpine
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/frontend/build /usr/share/nginx/html
EXPOSE 80

31
docker/nginx.conf Normal file
View File

@@ -0,0 +1,31 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ws/ {
proxy_pass http://backend:8000/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View File

@@ -17,10 +17,11 @@ The application moves from a **Task-Centric** model (where users navigate to "Mi
`[Home] [Migration] [Git Manager] [Mapper] [Settings] [Logout]`
**New Menu:**
`[Superset Manager] [Dashboards] [Datasets] [Storage] | [Activity (0)] [Settings] [User]`
`[Superset Manager] [Dashboards] [Datasets] [Reports] [Storage] | [Activity (0)] [Settings] [User]`
* **Dashboards**: Main hub for all dashboard operations (Migrate, Backup, Git).
* **Datasets**: Hub for dataset documentation and mapping.
* **Reports**: Unified center for all task outcomes with type-distinct visual profiles and detail diagnostics.
* **Storage**: File management (Backups, Repositories).
* **Activity**: Global indicator of running tasks. Clicking it opens the Task Drawer.

View File

@@ -39,6 +39,23 @@ The settings API is available at `/settings`:
The settings page is located at `frontend/src/pages/Settings.svelte`. It provides forms for managing global settings and Superset environments.
## Reports Center
Unified reports are available at [`/reports`](frontend/src/routes/reports/+page.svelte) and use the backend API at [`/api/reports`](backend/src/api/routes/reports.py) and [`/api/reports/{report_id}`](backend/src/api/routes/reports.py).
### What operators can do
- View all task outcomes (LLM verification, backup, migration, documentation) in one list.
- Filter by type and status.
- Open report detail with diagnostics and recommended next actions.
- Continue working even for unknown task types and partial payloads (explicit placeholders are shown instead of hidden data).
### Troubleshooting
- If report list is empty, verify tasks exist and clear filters.
- If report detail is not found (404), confirm the selected report still exists in task history.
- If report API tests fail during local execution with database connectivity errors, ensure the configured DB is reachable or run in an environment with available test DB services.
## Integration
Existing plugins and utilities use the `ConfigManager` to fetch configuration:

File diff suppressed because it is too large Load Diff

View File

@@ -6,17 +6,23 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.49.2",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"autoprefixer": "^10.4.0",
"jsdom": "^28.1.0",
"postcss": "^8.4.0",
"svelte": "^5.43.8",
"tailwindcss": "^3.0.0",
"vite": "^7.2.4"
"vite": "^7.2.4",
"vitest": "^4.0.18"
},
"dependencies": {
"date-fns": "^4.1.0"

View File

@@ -2,7 +2,8 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" />
<link rel="alternate icon" type="image/png" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>

View File

@@ -11,19 +11,18 @@
<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';
import GitManager from './git/GitManager.svelte';
import { api } from '../lib/api';
import { addToast as toast } from '../lib/toasts.js';
import { createEventDispatcher } from "svelte";
import type { DashboardMetadata } from "../types/dashboard";
import { t } from "../lib/i18n";
import { Button, Input } from "../lib/ui";
import GitManager from "./git/GitManager.svelte";
import { api } from "../lib/api";
import { addToast as toast } from "../lib/toasts.js";
// [/SECTION]
// [SECTION: PROPS]
export let dashboards: DashboardMetadata[] = [];
export let selectedIds: number[] = [];
export let environmentId: string = "ss1";
let { dashboards = [], selectedIds = [], environmentId = "ss1" } = $props();
// [/SECTION]
// [SECTION: STATE]
@@ -52,66 +51,80 @@
validatingIds = validatingIds; // Trigger reactivity
try {
// TODO: Get provider_id from settings or prompt user
// For now, we assume a default provider or let the backend handle it if possible,
// but the plugin requires provider_id.
// In a real implementation, we might open a modal to select provider if not configured globally.
// Or we pick the first active one.
// TODO: Get provider_id from settings or prompt user
// For now, we assume a default provider or let the backend handle it if possible,
// but the plugin requires provider_id.
// In a real implementation, we might open a modal to select provider if not configured globally.
// Or we pick the first active one.
// Fetch active provider first
const providers = await api.fetchApi('/llm/providers');
const activeProvider = providers.find((p: any) => p.is_active);
// Fetch active provider first
const providers = await api.fetchApi("/llm/providers");
const activeProvider = providers.find((p: any) => p.is_active);
if (!activeProvider) {
toast('No active LLM provider found. Please configure one in settings.', 'error');
return;
}
if (!activeProvider) {
toast(
"No active LLM provider found. Please configure one in settings.",
"error",
);
return;
}
await api.postApi('/tasks', {
plugin_id: 'llm_dashboard_validation',
params: {
dashboard_id: dashboard.id.toString(),
environment_id: environmentId,
provider_id: activeProvider.id
}
});
await api.postApi("/tasks", {
plugin_id: "llm_dashboard_validation",
params: {
dashboard_id: dashboard.id.toString(),
environment_id: environmentId,
provider_id: activeProvider.id,
},
});
toast('Validation task started', 'success');
toast("Validation task started", "success");
} catch (e: any) {
toast(e.message || 'Validation failed to start', 'error');
toast(e.message || "Validation failed to start", "error");
} finally {
validatingIds.delete(dashboard.id);
validatingIds = validatingIds;
validatingIds.delete(dashboard.id);
validatingIds = validatingIds;
}
}
// [/DEF:handleValidate:Function]
// [SECTION: DERIVED]
$: filteredDashboards = dashboards.filter(d =>
d.title.toLowerCase().includes(filterText.toLowerCase())
let filteredDashboards = $derived(
dashboards.filter((d) =>
d.title.toLowerCase().includes(filterText.toLowerCase()),
),
);
$: sortedDashboards = [...filteredDashboards].sort((a, b) => {
let aVal = a[sortColumn];
let bVal = b[sortColumn];
if (sortColumn === "id") {
aVal = Number(aVal);
bVal = Number(bVal);
}
if (aVal < bVal) return sortDirection === "asc" ? -1 : 1;
if (aVal > bVal) return sortDirection === "asc" ? 1 : -1;
return 0;
});
$: paginatedDashboards = sortedDashboards.slice(
currentPage * pageSize,
(currentPage + 1) * pageSize
let sortedDashboards = $derived(
[...filteredDashboards].sort((a, b) => {
let aVal = a[sortColumn];
let bVal = b[sortColumn];
if (sortColumn === "id") {
aVal = Number(aVal);
bVal = Number(bVal);
}
if (aVal < bVal) return sortDirection === "asc" ? -1 : 1;
if (aVal > bVal) return sortDirection === "asc" ? 1 : -1;
return 0;
}),
);
$: totalPages = Math.ceil(sortedDashboards.length / pageSize);
let paginatedDashboards = $derived(
sortedDashboards.slice(
currentPage * pageSize,
(currentPage + 1) * pageSize,
),
);
$: allSelected = paginatedDashboards.length > 0 && paginatedDashboards.every(d => selectedIds.includes(d.id));
$: someSelected = paginatedDashboards.some(d => selectedIds.includes(d.id));
let totalPages = $derived(Math.ceil(sortedDashboards.length / pageSize));
let allSelected = $derived(
paginatedDashboards.length > 0 &&
paginatedDashboards.every((d) => selectedIds.includes(d.id)),
);
let someSelected = $derived(
paginatedDashboards.some((d) => selectedIds.includes(d.id)),
);
// [/SECTION]
// [SECTION: EVENTS]
@@ -141,10 +154,10 @@
if (checked) {
if (!newSelected.includes(id)) newSelected.push(id);
} else {
newSelected = newSelected.filter(sid => sid !== id);
newSelected = newSelected.filter((sid) => sid !== id);
}
selectedIds = newSelected;
dispatch('selectionChanged', newSelected);
dispatch("selectionChanged", newSelected);
}
// [/DEF:handleSelectionChange:Function]
@@ -155,16 +168,16 @@
function handleSelectAll(checked: boolean) {
let newSelected = [...selectedIds];
if (checked) {
paginatedDashboards.forEach(d => {
paginatedDashboards.forEach((d) => {
if (!newSelected.includes(d.id)) newSelected.push(d.id);
});
} else {
paginatedDashboards.forEach(d => {
newSelected = newSelected.filter(sid => sid !== d.id);
paginatedDashboards.forEach((d) => {
newSelected = newSelected.filter((sid) => sid !== d.id);
});
}
selectedIds = newSelected;
dispatch('selectionChanged', newSelected);
dispatch("selectionChanged", newSelected);
}
// [/DEF:handleSelectAll:Function]
@@ -189,17 +202,13 @@
showGitManager = true;
}
// [/DEF:openGit:Function]
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="dashboard-grid">
<!-- Filter Input -->
<div class="mb-6">
<Input
bind:value={filterText}
placeholder={$t.dashboard.search}
/>
<Input bind:value={filterText} placeholder={$t.dashboard.search} />
</div>
<!-- Grid/Table -->
@@ -212,21 +221,52 @@
type="checkbox"
checked={allSelected}
indeterminate={someSelected && !allSelected}
on:change={(e) => handleSelectAll((e.target as HTMLInputElement).checked)}
on:change={(e) =>
handleSelectAll((e.target as HTMLInputElement).checked)}
class="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700 transition-colors" on:click={() => handleSort('title')}>
{$t.dashboard.title} {sortColumn === 'title' ? (sortDirection === 'asc' ? '↑' : '↓') : ''}
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700 transition-colors"
on:click={() => handleSort("title")}
>
{$t.dashboard.title}
{sortColumn === "title"
? sortDirection === "asc"
? "↑"
: "↓"
: ""}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700 transition-colors" on:click={() => handleSort('last_modified')}>
{$t.dashboard.last_modified} {sortColumn === 'last_modified' ? (sortDirection === 'asc' ? '↑' : '↓') : ''}
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700 transition-colors"
on:click={() => handleSort("last_modified")}
>
{$t.dashboard.last_modified}
{sortColumn === "last_modified"
? sortDirection === "asc"
? "↑"
: "↓"
: ""}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700 transition-colors" on:click={() => handleSort('status')}>
{$t.dashboard.status} {sortColumn === 'status' ? (sortDirection === 'asc' ? '↑' : '↓') : ''}
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700 transition-colors"
on:click={() => handleSort("status")}
>
{$t.dashboard.status}
{sortColumn === "status"
? sortDirection === "asc"
? "↑"
: "↓"
: ""}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.dashboard.validation}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.dashboard.git}</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>{$t.dashboard.validation}</th
>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>{$t.dashboard.git}</th
>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@@ -236,14 +276,28 @@
<input
type="checkbox"
checked={selectedIds.includes(dashboard.id)}
on:change={(e) => handleSelectionChange(dashboard.id, (e.target as HTMLInputElement).checked)}
on:change={(e) =>
handleSelectionChange(
dashboard.id,
(e.target as HTMLInputElement).checked,
)}
class="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{dashboard.title}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{new Date(dashboard.last_modified).toLocaleDateString()}</td>
<td
class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"
>{dashboard.title}</td
>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"
>{new Date(dashboard.last_modified).toLocaleDateString()}</td
>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<span class="px-2 py-1 text-xs font-medium rounded-full {dashboard.status === 'published' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}">
<span
class="px-2 py-1 text-xs font-medium rounded-full {dashboard.status ===
'published'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'}"
>
{dashboard.status}
</span>
</td>
@@ -255,7 +309,7 @@
disabled={validatingIds.has(dashboard.id)}
class="text-purple-600 hover:text-purple-900"
>
{validatingIds.has(dashboard.id) ? 'Validating...' : 'Validate'}
{validatingIds.has(dashboard.id) ? "Validating..." : "Validate"}
</Button>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
@@ -278,9 +332,15 @@
<div class="flex items-center justify-between mt-6">
<div class="text-sm text-gray-500">
{($t.dashboard?.showing || "")
.replace('{start}', (currentPage * pageSize + 1).toString())
.replace('{end}', Math.min((currentPage + 1) * pageSize, sortedDashboards.length).toString())
.replace('{total}', sortedDashboards.length.toString())}
.replace("{start}", (currentPage * pageSize + 1).toString())
.replace(
"{end}",
Math.min(
(currentPage + 1) * pageSize,
sortedDashboards.length,
).toString(),
)
.replace("{total}", sortedDashboards.length.toString())}
</div>
<div class="flex gap-2">
<Button
@@ -313,8 +373,4 @@
<!-- [/SECTION] -->
<style>
/* Component styles */
</style>
<!-- [/DEF:DashboardGrid:Component] -->

View File

@@ -15,7 +15,10 @@
import { createEventDispatcher } from 'svelte';
// [/SECTION]
export let schema;
let {
schema,
} = $props();
let formData = {};
const dispatch = createEventDispatcher();

View File

@@ -14,9 +14,12 @@
// [/SECTION]
// [SECTION: PROPS]
export let label: string = "Select Environment";
export let selectedId: string = "";
export let environments: Array<{id: string, name: string, url: string}> = [];
let {
label = "",
selectedId = "",
environments = [],
} = $props();
// [/SECTION]
const dispatch = createEventDispatcher();
@@ -53,8 +56,5 @@
</div>
<!-- [/SECTION] -->
<style>
/* Component specific styles */
</style>
<!-- [/DEF:EnvSelector:Component] -->

View File

@@ -14,10 +14,13 @@
// [/SECTION]
// [SECTION: PROPS]
export let sourceDatabases: Array<{uuid: string, database_name: string}> = [];
export let targetDatabases: Array<{uuid: string, database_name: string}> = [];
export let mappings: Array<{source_db_uuid: string, target_db_uuid: string}> = [];
export let suggestions: Array<{source_db_uuid: string, target_db_uuid: string, confidence: number}> = [];
let {
sourceDatabases = [],
targetDatabases = [],
mappings = [],
suggestions = [],
} = $props();
// [/SECTION]
const dispatch = createEventDispatcher();
@@ -29,7 +32,16 @@
* @post 'update' event is dispatched.
*/
function updateMapping(sourceUuid: string, targetUuid: string) {
dispatch('update', { sourceUuid, targetUuid });
const sDb = sourceDatabases.find(d => d.uuid === sourceUuid);
const tDb = targetDatabases.find(d => d.uuid === targetUuid);
dispatch('update', {
sourceUuid,
targetUuid,
sourceName: sDb?.database_name || "",
targetName: tDb?.database_name || "",
engine: sDb?.engine || ""
});
}
// [/DEF:updateMapping:Function]
@@ -91,8 +103,5 @@
</div>
<!-- [/SECTION] -->
<style>
/* Component specific styles */
</style>
<!-- [/DEF:MappingTable:Component] -->

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