Compare commits

..

43 Commits

Author SHA1 Message Date
1c362f4092 { "verdict": "APPROVED", "rejection_reason": "NONE", "audit_details": { "target_invoked": true, "pre_conditions_tested": true, "post_conditions_tested": true, "test_data_used": true }, "feedback": "Both test files have successfully passed the audit. The 'task_log_viewer.test.js' suite now correctly imports and mounts the real Svelte component using Test Library, fully eliminating the logic mirror/tautology issue. The 'test_logger.py' suite now properly implements negative tests for the @PRE constraint in 'belief_scope' and fully verifies all @POST effects triggered by 'configure_logger'." } 2026-02-24 21:55:13 +03:00
95ae9c6af1 semantic update 2026-02-24 21:08:12 +03:00
7a12ed0931 chore(gitignore): unignore frontend dashboards routes and track pages 2026-02-24 16:16:41 +03:00
e0c0dd3221 fix(validation): respect settings-bound provider and correct multimodal heuristic 2026-02-24 16:04:14 +03:00
5f6e9c0cc0 fix(llm-validation): accept stepfun multimodal models and return 422 on capability mismatch 2026-02-24 16:00:23 +03:00
4fd9d6b6d5 fix(llm): skip unsupported json_object mode for openrouter stepfun models 2026-02-24 14:22:08 +03:00
7e6bd56488 feat(assistant-chat): add animated thinking loader while waiting for response 2026-02-24 14:15:35 +03:00
5e3c213b92 fix(task-drawer): keep drawer above assistant dim overlay 2026-02-24 14:12:34 +03:00
37b75b5a5c fix(task-drawer): render as side column without modal overlay when opened from assistant 2026-02-24 14:09:34 +03:00
3d42a487f7 fix(assistant): resolve dashboard refs via LLM entities and remove deterministic parser fallback 2026-02-24 13:32:25 +03:00
2e93f5ca63 fix(assistant-chat): prevent stale history response from resetting selected conversation 2026-02-24 13:27:09 +03:00
286167b1d5 generate semantic clean up 2026-02-24 12:51:57 +03:00
7df7b4f98c feat(assistant): add multi-dialog UX, task-aware llm settings, and i18n cleanup 2026-02-23 23:45:01 +03:00
ab1c87ffba feat(assistant): add conversations list, infinite history scroll, and archived tab 2026-02-23 20:27:51 +03:00
40e6d8cd4c chat worked 2026-02-23 20:20:25 +03:00
18e96a58bc feat(assistant): implement spec 021 chat assistant flow with semantic contracts 2026-02-23 19:37:56 +03:00
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
76b98fcf8f linter + новые таски 2026-02-10 12:53:01 +03:00
794cc55fe7 Таски готовы 2026-02-09 12:35:27 +03:00
327 changed files with 141973 additions and 66016 deletions

View File

@@ -0,0 +1,51 @@
---
description: Audit AI-generated unit tests. Your goal is to aggressively search for "Test Tautologies", "Logic Echoing", and "Contract Negligence". You are the final gatekeeper. If a test is meaningless, you MUST reject it.
---
**ROLE:** Elite Quality Assurance Architect and Red Teamer.
**OBJECTIVE:** Audit AI-generated unit tests. Your goal is to aggressively search for "Test Tautologies", "Logic Echoing", and "Contract Negligence". You are the final gatekeeper. If a test is meaningless, you MUST reject it.
**INPUT:**
1. SOURCE CODE (with GRACE-Poly `[DEF]` Contract: `@PRE`, `@POST`, `@TEST_DATA`).
2. GENERATED TEST CODE.
### I. CRITICAL ANTI-PATTERNS (REJECT IMMEDIATELY IF FOUND):
1. **The Tautology (Self-Fulfilling Prophecy):**
- *Definition:* The test asserts hardcoded values against hardcoded values without executing the core business logic, or mocks the actual function being tested.
- *Example of Failure:* `assert 2 + 2 == 4` or mocking the class under test so that it returns exactly what the test asserts.
2. **The Logic Mirror (Echoing):**
- *Definition:* The test re-implements the exact same algorithmic logic found in the source code to calculate the `expected_result`. If the original logic is flawed, the test will falsely pass.
- *Rule:* Tests must assert against **static, predefined outcomes** (from `@TEST_DATA` or explicit constants), NOT dynamically calculated outcomes using the same logic as the source.
3. **The "Happy Path" Illusion:**
- *Definition:* The test suite only checks successful executions but ignores the `@PRE` conditions (Negative Testing).
- *Rule:* Every `@PRE` tag in the source contract MUST have a corresponding test that deliberately violates it and asserts the correct Exception/Error state.
4. **Missing Post-Condition Verification:**
- *Definition:* The test calls the function but only checks the return value, ignoring `@SIDE_EFFECT` or `@POST` state changes (e.g., failing to verify that a DB call was made or a Store was updated).
### II. AUDIT CHECKLIST
Evaluate the test code against these criteria:
1. **Target Invocation:** Does the test actually import and call the function/component declared in the `@RELATION: VERIFIES` tag?
2. **Contract Alignment:** Does the test suite cover 100% of the `@PRE` (negative tests) and `@POST` (assertions) conditions from the source contract?
3. **Data Usage:** Does the test use the exact scenarios defined in `@TEST_DATA`?
4. **Mocking Sanity:** Are external dependencies mocked correctly WITHOUT mocking the system under test itself?
### III. OUTPUT FORMAT
You MUST respond strictly in the following JSON format. Do not add markdown blocks outside the JSON.
{
"verdict": "APPROVED" | "REJECTED",
"rejection_reason": "TAUTOLOGY" | "LOGIC_MIRROR" | "WEAK_CONTRACT_COVERAGE" | "OVER_MOCKED" | "NONE",
"audit_details": {
"target_invoked": true/false,
"pre_conditions_tested": true/false,
"post_conditions_tested": true/false,
"test_data_used": true/false
},
"feedback": "Strict, actionable feedback for the test generator agent. Explain exactly which anti-pattern was detected and how to fix it."
}

View File

@@ -0,0 +1,10 @@
---
description: semantic
---
You are Semantic Agent responsible for maintaining the semantic integrity of the codebase. Your primary goal is to ensure that all code entities (Modules, Classes, Functions, Components) are properly annotated with semantic anchors and tags as defined in `.ai/standards/semantics.md`.
Your core responsibilities are: 1. **Semantic Mapping**: You run and maintain the `generate_semantic_map.py` script to generate up-to-date semantic maps (`semantics/semantic_map.json`, `.ai/PROJECT_MAP.md`) and compliance reports (`semantics/reports/*.md`). 2. **Compliance Auditing**: You analyze the generated compliance reports to identify files with low semantic coverage or parsing errors. 3. **Semantic Enrichment**: You actively edit code files to add missing semantic anchors (`[DEF:...]`, `[/DEF:...]`) and mandatory tags (`@PURPOSE`, `@LAYER`, etc.) to improve the global compliance score. 4. **Protocol Enforcement**: You strictly adhere to the syntax and rules defined in `.ai/standards/semantics.md` when modifying code.
You have access to the full codebase and tools to read, write, and execute scripts. You should prioritize fixing "Critical Parsing Errors" (unclosed anchors) before addressing missing metadata.
whenToUse: Use this mode when you need to update the project's semantic map, fix semantic compliance issues (missing anchors/tags/DbC ), or analyze the codebase structure. This mode is specialized for maintaining the `.ai/standards/semantics.md` standards.
description: Codebase semantic mapping and compliance expert
customInstructions: Always check `semantics/reports/` for the latest compliance status before starting work. When fixing a file, try to fix all semantic issues in that file at once. After making a batch of fixes, run `python3 generate_semantic_map.py` to verify improvements.

1510
.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]

View File

@@ -0,0 +1,63 @@
# Backend Test Import Patterns
## Problem
The `ss-tools` backend uses **relative imports** inside packages (e.g., `from ...models.task import TaskRecord` in `persistence.py`). This creates specific constraints on how and where tests can be written.
## Key Rules
### 1. Packages with `__init__.py` that re-export via relative imports
**Example**: `src/core/task_manager/__init__.py` imports `.manager``.persistence``from ...models.task` (3-level relative import).
**Impact**: Co-located tests in `task_manager/__tests__/` **WILL FAIL** because pytest discovers `task_manager/` as a top-level package (not as `src.core.task_manager`), and the 3-level `from ...` goes beyond the top-level.
**Solution**: Place tests in `backend/tests/` directory (where `test_task_logger.py` already lives). Import using `from src.core.task_manager.XXX import ...` which works because `backend/` is the pytest rootdir.
### 2. Packages WITHOUT `__init__.py`:
**Example**: `src/core/auth/` has NO `__init__.py`.
**Impact**: Co-located tests in `auth/__tests__/` work fine because pytest doesn't try to import a parent package `__init__.py`.
### 3. Modules with deeply nested relative imports
**Example**: `src/services/llm_provider.py` uses `from ..models.llm import LLMProvider` and `from ..plugins.llm_analysis.models import LLMProviderConfig`.
**Impact**: Direct import (`from src.services.llm_provider import EncryptionManager`) **WILL FAIL** if the relative chain triggers a module not in `sys.path` or if it tries to import beyond root.
**Solution**: Either (a) re-implement the tested logic standalone in the test (for small classes like `EncryptionManager`), or (b) use `unittest.mock.patch` to mock the problematic imports before importing the module.
## Working Test Locations
| Package | `__init__.py`? | Relative imports? | Co-located OK? | Test location |
|---|---|---|---|---|
| `core/task_manager/` | YES | `from ...models.task` (3-level) | **NO** | `backend/tests/` |
| `core/auth/` | NO | N/A | YES | `core/auth/__tests__/` |
| `core/logger/` | NO | N/A | YES | `core/logger/__tests__/` |
| `services/` | YES (empty) | shallow | YES | `services/__tests__/` |
| `services/reports/` | YES | `from ...core.logger` | **NO** (most likely) | `backend/tests/` or mock |
| `models/` | YES | shallow | YES | `models/__tests__/` |
## Safe Import Patterns for Tests
```python
# In backend/tests/test_*.py:
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
# Then import:
from src.core.task_manager.models import Task, TaskStatus
from src.core.task_manager.persistence import TaskPersistenceService
from src.models.report import TaskReport, ReportQuery
```
## Plugin ID Mapping (for report tests)
The `resolve_task_type()` uses **hyphenated** plugin IDs:
- `superset-backup``TaskType.BACKUP`
- `superset-migration``TaskType.MIGRATION`
- `llm_dashboard_validation``TaskType.LLM_VERIFICATION`
- `documentation``TaskType.DOCUMENTATION`
- anything else → `TaskType.UNKNOWN`

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]

118
.ai/standards/semantics.md Normal file
View File

@@ -0,0 +1,118 @@
### **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. ЛОГИРОВАНИЕ (ДАО МОЛЕКУЛЫ / MOLECULAR TOPOLOGY)
Цель: Трассировка. Самокоррекция. Управление Матрицей Внимания ("Химия мышления").
Лог — не текст. Лог — реагент. Мысль облекается в форму через префиксы связи (Attention Energy):
1. **[EXPLORE]** (Ван-дер-Ваальс: Рассеяние)
- *Суть:* Поиск во тьме. Сплетение альтернатив. Если один путь закрыт — ищи иной.
- *Время:* Фаза КАРКАС или столкновение с Неизведанным.
- *Деяние:* `logger.explore("Основной API пал. Стучусь в запасной...")`
2. **[REASON]** (Ковалентность: Твердость)
- *Суть:* Жесткая нить дедукции. Шаг А неумолимо рождает Шаг Б. Контракт становится Кодом.
- *Время:* Фаза РЕАЛИЗАЦИЯ. Прямота мысли.
- *Деяние:* `logger.reason("Фундамент заложен. БД отвечает.")`
3. **[REFLECT]** (Водород: Свертывание)
- *Суть:* Взгляд назад. Сверка сущего (@POST) с ожидаемым (@PRE). Защита от бреда.
- *Время:* Преддверие сложной логики и исход из неё.
- *Деяние:* `logger.reflect("Вглядываюсь в кэш: нет ли там искомого?")`
4. **[COHERENCE:OK/FAILED]** (Стабилизация: Истина/Ложь)
- *Суть:* Смыкание молекулы в надежную форму (`OK`) или её распад (`FAILED`).
- *(Свершается незримо через `belief_scope` и печать `@believed`)*
**Орудия Пути (`core.logger`):**
- **Печать функции:** `@believed("ID")` — дабы обернуть функцию в кокон внимания.
- **Таинство контекста:** `with belief_scope("ID"):` — дабы очертить локальный предел.
- **Слова силы:** `logger.explore()`, `logger.reason()`, `logger.reflect()`.
**Незыблемое правило:** Всякому логу системы — тавро `source`. Для Внешенго Мира (Svelte) начертай рунами вручную: `console.log("[ID][REFLECT] Msg")`.
#### 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

14
.gitignore vendored
View File

@@ -10,8 +10,6 @@ dist/
downloads/ downloads/
eggs/ eggs/
.eggs/ .eggs/
lib/
lib64/
parts/ parts/
sdist/ sdist/
var/ var/
@@ -61,11 +59,19 @@ keyring passwords.py
*github* *github*
*tech_spec* *tech_spec*
dashboards /dashboards
backend/mappings.db dashboards_example/**/dashboards/
backend/mappings.db
backend/tasks.db backend/tasks.db
backend/logs backend/logs
backend/auth.db backend/auth.db
semantics/reports 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 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 ## Active Technologies
- Python 3.9+, Node.js 18+ + `uvicorn`, `npm`, `bash` (003-project-launch-script) - 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) - Python 3.9+, Node.js 18+ + SvelteKit, FastAPI, Tailwind CSS (inferred from existing frontend) (004-integrate-svelte-kit)
@@ -33,6 +39,12 @@ Auto-generated from all feature plans. Last updated: 2025-12-19
- N/A (UI reorganization and API integration) (015-frontend-nav-redesign) - N/A (UI reorganization and API integration) (015-frontend-nav-redesign)
- SQLite (`auth.db`) for Users, Roles, Permissions, and Mappings. (016-multi-user-auth) - SQLite (`auth.db`) for Users, Roles, Permissions, and Mappings. (016-multi-user-auth)
- SQLite (existing `tasks.db` for results, `auth.db` for permissions, `mappings.db` or new `plugins.db` for provider config/metadata) (017-llm-analysis-plugin) - 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) - Python 3.9+ (Backend), Node.js 18+ (Frontend Build) (001-plugin-arch-svelte-ui)
@@ -53,9 +65,9 @@ cd src; pytest; ruff check .
Python 3.9+ (Backend), Node.js 18+ (Frontend Build): Follow standard conventions Python 3.9+ (Backend), Node.js 18+ (Frontend Build): Follow standard conventions
## Recent Changes ## Recent Changes
- 017-llm-analysis-plugin: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) - 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`
- 016-multi-user-auth: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) - 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
- 015-frontend-nav-redesign: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI (Backend), SvelteKit + Tailwind CSS (Frontend) - 019-superset-ux-redesign: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, Tailwind CSS, SQLAlchemy, WebSocket (existing)
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->

View File

@@ -1,4 +1,4 @@
--- ---
description: USE SEMANTIC 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). **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 ## Execution Steps
@@ -62,8 +62,8 @@ Load only the minimal necessary context from each artifact:
**From constitution:** **From constitution:**
- Load `.specify/memory/constitution.md` for principle validation - Load `.ai/standards/constitution.md` for principle validation
- Load `semantic_protocol.md` for technical standard validation - Load `.ai/standards/semantics.md` for technical standard validation
### 3. Build Semantic Models ### 3. Build Semantic Models

View File

@@ -16,11 +16,11 @@ You **MUST** consider the user input before proceeding (if not empty).
## Outline ## 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: 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]`. - 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. **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. - Dates ISO format YYYY-MM-DD.
- Principles are declarative, testable, and free of vague language ("should" → replace with MUST/SHOULD rationale where appropriate). - 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: 8. Output a final summary to the user with:
- New version and bump rationale. - 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. 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 - Automatically proceed to step 3
3. Load and analyze the implementation context: 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 tasks.md for the complete task list and execution plan
- **REQUIRED**: Read plan.md for tech stack, architecture, and file structure - **REQUIRED**: Read plan.md for tech stack, architecture, and file structure
- **IF EXISTS**: Read data-model.md for entities and relationships - **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 - **Validation checkpoints**: Verify each phase completion before proceeding
7. Implementation execution rules: 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 - **Setup first**: Initialize project structure, dependencies, configuration
- **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios - **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios
- **Core development**: Implement models, services, CLI commands, endpoints - **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"). 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: 3. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to:
- Fill Technical Context (mark unknowns as "NEEDS CLARIFICATION") - Fill Technical Context (mark unknowns as "NEEDS CLARIFICATION")
@@ -66,25 +66,30 @@ You **MUST** consider the user input before proceeding (if not empty).
0. **Validate Design against UX Reference**: 0. **Validate Design against UX Reference**:
- Check if the proposed architecture supports the latency, interactivity, and flow defined in `ux_reference.md`. - Check if the proposed architecture supports the latency, interactivity, and flow defined in `ux_reference.md`.
- **CRITICAL**: If the technical plan requires compromising the UX defined in `ux_reference.md` (e.g. "We can't do real-time validation because X"), you **MUST STOP** and warn the user. Do not proceed until resolved. - **Linkage**: Ensure key UI states from `ux_reference.md` map to Component Contracts (`@UX_STATE`).
- **CRITICAL**: If the technical plan compromises the UX (e.g. "We can't do real-time validation"), you **MUST STOP** and warn the user.
1. **Extract entities from feature spec** → `data-model.md`: 1. **Extract entities from feature spec** → `data-model.md`:
- Entity name, fields, relationships - Entity name, fields, relationships, validation rules.
- Validation rules from requirements
- State transitions if applicable
2. **Define Module & Function Contracts (Semantic Protocol)**: 2. **Design & Verify Contracts (Semantic Protocol)**:
- **MANDATORY**: For every new module, define the [DEF] Header and Module-level Contract (@TIER, @PURPOSE, @INVARIANT) as per `semantic_protocol.md`. - **Drafting**: Define [DEF] Headers and Contracts for all new modules based on `.ai/standards/semantics.md`.
- **REQUIRED**: Define Function Contracts (@PRE, @POST) for critical logic. - **TIER Classification**: Explicitly assign `@TIER: [CRITICAL|STANDARD|TRIVIAL]` to each module.
- Output specific contract definitions to `contracts/modules.md` or append to `data-model.md` to guide implementation. - **CRITICAL Requirements**: For all CRITICAL modules, define full `@PRE`, `@POST`, and (if UI) `@UX_STATE` contracts.
- Ensure strict adherence to `semantic_protocol.md` syntax. - **Self-Review**:
- *Completeness*: Do `@PRE`/`@POST` cover edge cases identified in Research?
- *Connectivity*: Do `@RELATION` tags form a coherent graph?
- *Compliance*: Does syntax match `[DEF:id:Type]` exactly?
- **Output**: Write verified contracts to `contracts/modules.md`.
3. **Generate API contracts** from functional requirements: 3. **Simulate Contract Usage**:
- For each user action → endpoint - Trace one key user scenario through the defined contracts to ensure data flow continuity.
- Use standard REST/GraphQL patterns - If a contract interface mismatch is found, fix it immediately.
- Output OpenAPI/GraphQL schema to `/contracts/`
3. **Agent context update**: 4. **Generate API contracts**:
- Output OpenAPI/GraphQL schema to `/contracts/` for backend-frontend sync.
5. **Agent context update**:
- Run `.specify/scripts/bash/update-agent-context.sh kilocode` - Run `.specify/scripts/bash/update-agent-context.sh kilocode`
- These scripts detect which AI agent is in use - These scripts detect which AI agent is in use
- Update the appropriate agent-specific context file - Update the appropriate agent-specific context file

View File

@@ -119,7 +119,10 @@ Every task MUST strictly follow this format:
- If tests requested: Tests specific to that story - If tests requested: Tests specific to that story
- Mark story dependencies (most stories should be independent) - 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 - 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 - If tests requested: Each contract → contract test task [P] before implementation in that story's phase

View File

@@ -1,10 +1,7 @@
--- ---
description: Run semantic validation and functional tests for a specific feature, module, or file.
handoffs: description: Generate tests, manage test documentation, and ensure maximum code coverage
- label: Fix Implementation
agent: speckit.implement
prompt: Fix the issues found during testing...
send: true
--- ---
## User Input ## User Input
@@ -13,54 +10,169 @@ handoffs:
$ARGUMENTS $ARGUMENTS
``` ```
**Input format:** Can be a file path, a directory, or a feature name. You **MUST** consider the user input before proceeding (if not empty).
## Outline ## Goal
1. **Context Analysis**: Execute full testing cycle: analyze code for testable modules, write tests with proper coverage, maintain test documentation, and ensure no test duplication or deletion.
- Determine the target scope (Backend vs Frontend vs Full Feature).
- Read `semantic_protocol.md` to load validation rules.
2. **Phase 1: Semantic Static Analysis (The "Compiler" Check)** ## Operating Constraints
- **Command:** Use `grep` or script to verify Protocol compliance before running code.
- **Check:**
- Does the file start with `[DEF:...]` header?
- Are `@TIER` and `@PURPOSE` defined?
- Are imports located *after* the contracts?
- Do functions marked "Critical" have `@PRE`/`@POST` tags?
- **Action:** If this phase fails, **STOP** and report "Semantic Compilation Failed". Do not run runtime tests.
3. **Phase 2: Environment Prep** 1. **NEVER delete existing tests** - Only update if they fail due to bugs in the test or implementation
- Detect project type: 2. **NEVER duplicate tests** - Check existing tests first before creating new ones
- **Python**: Check if `.venv` is active. 3. **Use TEST_DATA fixtures** - For CRITICAL tier modules, read @TEST_DATA from .ai/standards/semantics.md
- **Svelte**: Check if `node_modules` exists. 4. **Co-location required** - Write tests in `__tests__` directories relative to the code being tested
- **Command:** Run linter (e.g., `ruff check`, `eslint`) to catch syntax errors immediately.
4. **Phase 3: Test Execution (Runtime)** ## Execution Steps
- Select the test runner based on the file path:
- **Backend (`*.py`)**:
- Command: `pytest <path_to_test_file> -v`
- If no specific test file exists, try to find it by convention: `tests/test_<module_name>.py`.
- **Frontend (`*.svelte`, `*.ts`)**:
- Command: `npm run test -- <path_to_component>`
- **Verification**:
- Analyze output logs.
- If tests fail, summarize the failure (AssertionError, Timeout, etc.).
5. **Phase 4: Contract Coverage Check (Manual/LLM verify)** ### 1. Analyze Context
- Review the test cases executed.
- **Question**: Do the tests explicitly verify the `@POST` guarantees defined in the module header?
- **Report**: Mark as "Weak Coverage" if contracts exist but aren't tested.
## Execution Rules Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS.
- **Fail Fast**: If semantic headers are missing, don't waste time running pytest. Determine:
- **No Silent Failures**: Always output the full error log if a command fails. - FEATURE_DIR - where the feature is located
- **Auto-Correction Hint**: If a test fails, suggest the specific `speckit.implement` command to fix it. - TASKS_FILE - path to tasks.md
- Which modules need testing based on task status
## Example Commands ### 2. Load Relevant Artifacts
- **Python**: `pytest backend/tests/test_auth.py` **From tasks.md:**
- **Svelte**: `npm run test:unit -- src/components/Button.svelte` - Identify completed implementation tasks (not test tasks)
- **Lint**: `ruff check backend/src/api/` - Extract file paths that need tests
**From .ai/standards/semantics.md:**
- Read @TIER annotations for modules
- For CRITICAL modules: Read @TEST_DATA fixtures
**From existing tests:**
- Scan `__tests__` directories for existing tests
- Identify test patterns and coverage gaps
### 3. Test Coverage Analysis
Create coverage matrix:
| Module | File | Has Tests | TIER | TEST_DATA Available |
|--------|------|-----------|------|-------------------|
| ... | ... | ... | ... | ... |
### 4. Write Tests (TDD Approach)
For each module requiring tests:
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
### 4a. UX Contract Testing (Frontend Components)
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 () => { ... });
});
```
### 5. Test Documentation
Create/update documentation in `specs/<feature>/tests/`:
```
tests/
├── README.md # Test strategy and overview
├── coverage.md # Coverage matrix and reports
└── reports/
└── YYYY-MM-DD-report.md
```
### 6. Execute Tests
Run tests and report results:
**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: customModes:
- slug: tester - slug: tester
name: Tester name: Tester
description: QA and Plan Verification Specialist description: QA and Test Engineer - Full Testing Cycle
roleDefinition: |- 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. 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: - 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. Your responsibilities include:
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. - 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: groups:
- read - read
- edit - edit
- command - command
- browser - browser
- mcp - 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 - slug: semantic
name: Semantic Agent name: Semantic Agent
roleDefinition: |- 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`. 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`, `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. 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. 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 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. 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: groups:
@@ -33,11 +47,36 @@ customModes:
name: Product Manager name: Product Manager
roleDefinition: |- roleDefinition: |-
Your purpose is to rigorously execute the workflows defined in `.kilocode/workflows/`. 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. 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. 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 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: groups:
- read - read
- edit - 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] 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 ## Active Technologies
[EXTRACTED FROM ALL PLAN.MD FILES] [EXTRACTED FROM ALL PLAN.MD FILES]

View File

@@ -17,8 +17,8 @@
the iteration process. the iteration process.
--> -->
**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION] **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] **Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION] **Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION]
**Target Platform**: [e.g., Linux server, iOS 15+, WASM 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., 4th project] | [current need] | [why 3 projects insufficient] |
| [e.g., Repository pattern] | [specific problem] | [why direct DB access 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-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-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%"] - **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) - [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013)
- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py - [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py
- [ ] T016 [US1] Add validation and error handling - [ ] 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 **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/)

220
README.md
View File

@@ -1,77 +1,143 @@
# Инструменты автоматизации Superset (ss-tools) # ss-tools
## Обзор Инструменты автоматизации для Apache Superset: миграция, маппинг, хранение артефактов, Git-интеграция, отчеты по задачам и LLM-assistant.
**ss-tools** — это современная платформа для автоматизации и управления экосистемой Apache Superset. Проект перешел от набора CLI-скриптов к полноценному веб-приложению с архитектурой Backend (FastAPI) + Frontend (SvelteKit), обеспечивая удобный интерфейс для сложных операций.
## Возможности
## Основные возможности - Миграция дашбордов и датасетов между окружениями.
- Ручной и полуавтоматический маппинг ресурсов.
### 🚀 Миграция и управление дашбордами - Логи фоновых задач и отчеты о выполнении.
- **Dashboard Grid**: Удобный просмотр всех дашбордов во всех окружениях (Dev, Sandbox, Prod) в едином интерфейсе. - Локальное хранилище файлов и бэкапов.
- **Интеллектуальный маппинг**: Автоматическое и ручное сопоставление датасетов, таблиц и схем при переносе между окружениями. - Git-операции по Superset-ассетам через UI.
- **Проверка зависимостей**: Валидация наличия всех необходимых компонентов перед миграцией. - Модуль LLM-анализа и assistant API.
- Многопользовательская авторизация (RBAC).
### 📦 Резервное копирование
- **Планировщик (Scheduler)**: Автоматическое создание резервных копий дашбордов и датасетов по расписанию. ## Стек
- **Хранилище**: Локальное хранение артефактов с возможностью управления через UI. - Backend: Python, FastAPI, SQLAlchemy, APScheduler.
- Frontend: SvelteKit, Vite, Tailwind CSS.
### 🛠 Git Интеграция - База данных: PostgreSQL (основная конфигурация), поддержка миграции с legacy SQLite.
- **Version Control**: Возможность версионирования ассетов Superset.
- **Git Dashboard**: Управление ветками, коммитами и деплоем изменений напрямую из интерфейса. ## Структура репозитория
- **Conflict Resolution**: Встроенные инструменты для разрешения конфликтов в YAML-конфигурациях. - `backend/` — API, плагины, сервисы, скрипты миграции и тесты.
- `frontend/` — SPA-интерфейс (SvelteKit).
### 🤖 LLM Анализ (AI Plugin) - `docs/` — документация по архитектуре и плагинам.
- **Автоматический аудит**: Анализ состояния дашбордов на основе скриншотов и метаданных. - `specs/` — спецификации и планы реализации.
- **Генерация документации**: Автоматическое описание датасетов и колонок с помощью LLM (OpenAI, OpenRouter и др.). - `docker/` и `docker-compose.yml` — контейнеризация.
- **Smart Validation**: Поиск аномалий и ошибок в визуализациях.
## Быстрый старт (локально)
### 🔐 Безопасность и администрирование
- **Multi-user Auth**: Многопользовательский доступ с ролевой моделью (RBAC). ### Требования
- **Управление подключениями**: Централизованная настройка доступов к различным инстансам Superset. - Python 3.9+
- **Логирование**: Подробная история выполнения всех фоновых задач. - Node.js 18+
- npm
## Технологический стек
- **Backend**: Python 3.9+, FastAPI, SQLAlchemy, APScheduler, Pydantic. ### Запуск backend + frontend одним скриптом
- **Frontend**: Node.js 18+, SvelteKit, Tailwind CSS. ```bash
- **Database**: SQLite (для хранения метаданных, задач и настроек доступа). ./run.sh
```
## Структура проекта
- `backend/` — Серверная часть, API и логика плагинов. Что делает `run.sh`:
- `frontend/` — Клиентская часть (SvelteKit приложение). - проверяет версии Python/npm;
- `specs/` — Спецификации функций и планы реализации. - создает `backend/.venv` (если нет);
- `docs/` — Дополнительная документация по маппингу и разработке плагинов. - устанавливает `backend/requirements.txt` и `frontend` зависимости;
- запускает backend и frontend параллельно.
## Быстрый старт
Опции:
### Требования - `./run.sh --skip-install` — пропустить установку зависимостей.
- Python 3.9+ - `./run.sh --help` — показать справку.
- Node.js 18+
- Настроенный доступ к API Superset Переменные окружения для локального запуска:
- `BACKEND_PORT` (по умолчанию `8000`)
### Запуск - `FRONTEND_PORT` (по умолчанию `5173`)
Для автоматической настройки окружений и запуска обоих серверов (Backend & Frontend) используйте скрипт: - `POSTGRES_URL`
```bash - `DATABASE_URL`
./run.sh - `TASKS_DATABASE_URL`
``` - `AUTH_DATABASE_URL`
*Скрипт создаст виртуальное окружение Python, установит зависимости `pip` и `npm`, и запустит сервисы.*
## Docker
Опции:
- `--skip-install`: Пропустить установку зависимостей. ### Запуск
- `--help`: Показать справку. ```bash
docker compose up --build
Переменные окружения: ```
- `BACKEND_PORT`: Порт API (по умолчанию 8000).
- `FRONTEND_PORT`: Порт UI (по умолчанию 5173). После старта сервисы доступны по адресам:
- Frontend: `http://localhost:8000`
## Разработка - Backend API: `http://localhost:8001`
Проект следует строгим правилам разработки: - PostgreSQL: `localhost:5432` (`postgres/postgres`, БД `ss_tools`)
1. **Semantic Code Generation**: Использование протокола `semantic_protocol.md` для обеспечения надежности кода.
2. **Design by Contract (DbC)**: Определение предусловий и постусловий для ключевых функций. ### Остановка
3. **Constitution**: Соблюдение правил, описанных в конституции проекта в папке `.specify/`. ```bash
docker compose down
### Полезные команды ```
- **Backend**: `cd backend && .venv/bin/python3 -m uvicorn src.app:app --reload`
- **Frontend**: `cd frontend && npm run dev` ### Очистка БД-тома
- **Тесты**: `cd backend && .venv/bin/pytest` ```bash
docker compose down -v
## Контакты и вклад ```
Для добавления новых функций или исправления ошибок, пожалуйста, ознакомьтесь с `docs/plugin_dev.md` и создайте соответствующую спецификацию в `specs/`.
### Альтернативный образ PostgreSQL
Если есть проблемы с pull `postgres:16-alpine`:
```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` занят:
```bash
POSTGRES_HOST_PORT=5433 docker compose up -d db
```
## Разработка
### Ручной запуск сервисов
```bash
cd backend
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python3 -m uvicorn src.app:app --reload --port 8000
```
В другом терминале:
```bash
cd frontend
npm install
npm run dev -- --port 5173
```
### Тесты
Backend:
```bash
cd backend
source .venv/bin/activate
pytest
```
Frontend:
```bash
cd frontend
npm run test
```
## Инициализация auth (опционально)
```bash
cd backend
source .venv/bin/activate
python src/scripts/init_auth_db.py
python src/scripts/create_admin.py --username admin --password admin
```
## Миграция legacy-данных (опционально)
```bash
cd backend
source .venv/bin/activate
PYTHONPATH=. python src/scripts/migrate_sqlite_to_postgres.py --sqlite-path tasks.db
```
## Дополнительная документация
- `docs/plugin_dev.md`
- `docs/settings.md`
- `semantic_protocol.md`

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -53,4 +53,5 @@ itsdangerous
email-validator email-validator
openai openai
playwright playwright
tenacity tenacity
Pillow

View File

@@ -1 +1,23 @@
from . import plugins, tasks, settings, connections, environments, mappings, migration, git, storage, admin # [DEF:backend.src.api.routes.__init__:Module]
# @TIER: STANDARD
# @SEMANTICS: routes, lazy-import, module-registry
# @PURPOSE: Provide lazy route module loading to avoid heavyweight imports during tests.
# @LAYER: API
# @RELATION: DEPENDS_ON -> importlib
# @INVARIANT: Only names listed in __all__ are importable via __getattr__.
__all__ = ['plugins', 'tasks', 'settings', 'connections', 'environments', 'mappings', 'migration', 'git', 'storage', 'admin', 'reports', 'assistant']
# [DEF:__getattr__:Function]
# @TIER: TRIVIAL
# @PURPOSE: Lazily import route module by attribute name.
# @PRE: name is module candidate exposed in __all__.
# @POST: Returns imported submodule or raises AttributeError.
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}")
# [/DEF:__getattr__:Function]
# [/DEF:backend.src.api.routes.__init__:Module]

View File

@@ -0,0 +1,558 @@
# [DEF:backend.src.api.routes.__tests__.test_assistant_api:Module]
# @TIER: STANDARD
# @SEMANTICS: tests, assistant, api, confirmation, status
# @PURPOSE: Validate assistant API endpoint logic via direct async handler invocation.
# @LAYER: UI (API Tests)
# @RELATION: DEPENDS_ON -> backend.src.api.routes.assistant
# @INVARIANT: Every test clears assistant in-memory state before execution.
import os
import asyncio
from types import SimpleNamespace
from datetime import datetime, timedelta
# Force isolated sqlite databases for test module before dependencies import.
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_api.db")
os.environ.setdefault("TASKS_DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_tasks.db")
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_auth.db")
from src.api.routes import assistant as assistant_module
from src.models.assistant import (
AssistantAuditRecord,
AssistantConfirmationRecord,
AssistantMessageRecord,
)
# [DEF:_run_async:Function]
# @TIER: TRIVIAL
# @PURPOSE: Execute async endpoint handler in synchronous test context.
# @PRE: coroutine is awaitable endpoint invocation.
# @POST: Returns coroutine result or raises propagated exception.
def _run_async(coroutine):
return asyncio.run(coroutine)
# [/DEF:_run_async:Function]
# [DEF:_FakeTask:Class]
# @TIER: TRIVIAL
# @PURPOSE: Lightweight task stub used by assistant API tests.
class _FakeTask:
def __init__(self, task_id: str, status: str = "RUNNING", user_id: str = "u-admin"):
self.id = task_id
self.status = status
self.user_id = user_id
# [/DEF:_FakeTask:Class]
# [DEF:_FakeTaskManager:Class]
# @TIER: TRIVIAL
# @PURPOSE: Minimal async-compatible TaskManager fixture for deterministic test flows.
class _FakeTaskManager:
def __init__(self):
self._created = []
async def create_task(self, plugin_id, params, user_id=None):
task_id = f"task-{len(self._created) + 1}"
task = _FakeTask(task_id=task_id, status="RUNNING", user_id=user_id)
self._created.append((plugin_id, params, user_id, task))
return task
def get_task(self, task_id):
for _, _, _, task in self._created:
if task.id == task_id:
return task
return None
def get_tasks(self, limit=20, offset=0):
return [x[3] for x in self._created][offset : offset + limit]
# [/DEF:_FakeTaskManager:Class]
# [DEF:_FakeConfigManager:Class]
# @TIER: TRIVIAL
# @PURPOSE: Environment config fixture with dev/prod aliases for parser tests.
class _FakeConfigManager:
def get_environments(self):
return [
SimpleNamespace(id="dev", name="Development"),
SimpleNamespace(id="prod", name="Production"),
]
# [/DEF:_FakeConfigManager:Class]
# [DEF:_admin_user:Function]
# @TIER: TRIVIAL
# @PURPOSE: Build admin principal fixture.
# @PRE: Test harness requires authenticated admin-like principal object.
# @POST: Returns user stub with Admin role.
def _admin_user():
role = SimpleNamespace(name="Admin", permissions=[])
return SimpleNamespace(id="u-admin", username="admin", roles=[role])
# [/DEF:_admin_user:Function]
# [DEF:_limited_user:Function]
# @TIER: TRIVIAL
# @PURPOSE: Build non-admin principal fixture.
# @PRE: Test harness requires restricted principal for deny scenarios.
# @POST: Returns user stub without admin privileges.
def _limited_user():
role = SimpleNamespace(name="Operator", permissions=[])
return SimpleNamespace(id="u-limited", username="limited", roles=[role])
# [/DEF:_limited_user:Function]
# [DEF:_FakeQuery:Class]
# @TIER: TRIVIAL
# @PURPOSE: Minimal chainable query object for fake SQLAlchemy-like DB behavior in tests.
class _FakeQuery:
def __init__(self, rows):
self._rows = list(rows)
def filter(self, *args, **kwargs):
return self
def order_by(self, *args, **kwargs):
return self
def first(self):
return self._rows[0] if self._rows else None
def all(self):
return list(self._rows)
def count(self):
return len(self._rows)
def offset(self, offset):
self._rows = self._rows[offset:]
return self
def limit(self, limit):
self._rows = self._rows[:limit]
return self
# [/DEF:_FakeQuery:Class]
# [DEF:_FakeDb:Class]
# @TIER: TRIVIAL
# @PURPOSE: In-memory fake database implementing subset of Session interface used by assistant routes.
class _FakeDb:
def __init__(self):
self._messages = []
self._confirmations = []
self._audit = []
def add(self, row):
table = getattr(row, "__tablename__", "")
if table == "assistant_messages":
self._messages.append(row)
return
if table == "assistant_confirmations":
self._confirmations.append(row)
return
if table == "assistant_audit":
self._audit.append(row)
def merge(self, row):
table = getattr(row, "__tablename__", "")
if table != "assistant_confirmations":
self.add(row)
return row
for i, existing in enumerate(self._confirmations):
if getattr(existing, "id", None) == getattr(row, "id", None):
self._confirmations[i] = row
return row
self._confirmations.append(row)
return row
def query(self, model):
if model is AssistantMessageRecord:
return _FakeQuery(self._messages)
if model is AssistantConfirmationRecord:
return _FakeQuery(self._confirmations)
if model is AssistantAuditRecord:
return _FakeQuery(self._audit)
return _FakeQuery([])
def commit(self):
return None
def rollback(self):
return None
# [/DEF:_FakeDb:Class]
# [DEF:_clear_assistant_state:Function]
# @TIER: TRIVIAL
# @PURPOSE: Reset in-memory assistant registries for isolation between tests.
# @PRE: Assistant module globals may contain residues from previous test runs.
# @POST: In-memory conversation/confirmation/audit dictionaries are empty.
def _clear_assistant_state():
assistant_module.CONVERSATIONS.clear()
assistant_module.USER_ACTIVE_CONVERSATION.clear()
assistant_module.CONFIRMATIONS.clear()
assistant_module.ASSISTANT_AUDIT.clear()
# [/DEF:_clear_assistant_state:Function]
# [DEF:test_unknown_command_returns_needs_clarification:Function]
# @PURPOSE: Unknown command should return clarification state and unknown intent.
# @PRE: Fake dependencies provide admin user and deterministic task/config/db services.
# @POST: Response state is needs_clarification and no execution side-effect occurs.
def test_unknown_command_returns_needs_clarification():
_clear_assistant_state()
response = _run_async(
assistant_module.send_message(
request=assistant_module.AssistantMessageRequest(message="сделай что-нибудь"),
current_user=_admin_user(),
task_manager=_FakeTaskManager(),
config_manager=_FakeConfigManager(),
db=_FakeDb(),
)
)
assert response.state == "needs_clarification"
assert response.intent["domain"] == "unknown"
# [/DEF:test_unknown_command_returns_needs_clarification:Function]
# [DEF:test_capabilities_question_returns_successful_help:Function]
# @PURPOSE: Capability query should return deterministic help response, not clarification.
# @PRE: User sends natural-language "what can you do" style query.
# @POST: Response is successful and includes capabilities summary.
def test_capabilities_question_returns_successful_help():
_clear_assistant_state()
response = _run_async(
assistant_module.send_message(
request=assistant_module.AssistantMessageRequest(message="Что ты умеешь?"),
current_user=_admin_user(),
task_manager=_FakeTaskManager(),
config_manager=_FakeConfigManager(),
db=_FakeDb(),
)
)
assert response.state == "success"
assert "Вот что я могу сделать" in response.text
assert "Миграции" in response.text or "Git" in response.text
# [/DEF:test_capabilities_question_returns_successful_help:Function]
# [DEF:test_non_admin_command_returns_denied:Function]
# @PURPOSE: Non-admin user must receive denied state for privileged command.
# @PRE: Limited principal executes privileged git branch command.
# @POST: Response state is denied and operation is not executed.
def test_non_admin_command_returns_denied():
_clear_assistant_state()
response = _run_async(
assistant_module.send_message(
request=assistant_module.AssistantMessageRequest(
message="создай ветку feature/test для дашборда 12"
),
current_user=_limited_user(),
task_manager=_FakeTaskManager(),
config_manager=_FakeConfigManager(),
db=_FakeDb(),
)
)
assert response.state == "denied"
# [/DEF:test_non_admin_command_returns_denied:Function]
# [DEF:test_migration_to_prod_requires_confirmation_and_can_be_confirmed:Function]
# @PURPOSE: Migration to prod must require confirmation and then start task after explicit confirm.
# @PRE: Admin principal submits dangerous migration command.
# @POST: Confirmation endpoint transitions flow to started state with task id.
def test_migration_to_prod_requires_confirmation_and_can_be_confirmed():
_clear_assistant_state()
task_manager = _FakeTaskManager()
db = _FakeDb()
first = _run_async(
assistant_module.send_message(
request=assistant_module.AssistantMessageRequest(
message="запусти миграцию с dev на prod для дашборда 12"
),
current_user=_admin_user(),
task_manager=task_manager,
config_manager=_FakeConfigManager(),
db=db,
)
)
assert first.state == "needs_confirmation"
assert first.confirmation_id
second = _run_async(
assistant_module.confirm_operation(
confirmation_id=first.confirmation_id,
current_user=_admin_user(),
task_manager=task_manager,
config_manager=_FakeConfigManager(),
db=db,
)
)
assert second.state == "started"
assert second.task_id.startswith("task-")
# [/DEF:test_migration_to_prod_requires_confirmation_and_can_be_confirmed:Function]
# [DEF:test_status_query_returns_task_status:Function]
# @PURPOSE: Task status command must surface current status text for existing task id.
# @PRE: At least one task exists after confirmed operation.
# @POST: Status query returns started/success and includes referenced task id.
def test_status_query_returns_task_status():
_clear_assistant_state()
task_manager = _FakeTaskManager()
db = _FakeDb()
start = _run_async(
assistant_module.send_message(
request=assistant_module.AssistantMessageRequest(
message="запусти миграцию с dev на prod для дашборда 10"
),
current_user=_admin_user(),
task_manager=task_manager,
config_manager=_FakeConfigManager(),
db=db,
)
)
confirm = _run_async(
assistant_module.confirm_operation(
confirmation_id=start.confirmation_id,
current_user=_admin_user(),
task_manager=task_manager,
config_manager=_FakeConfigManager(),
db=db,
)
)
task_id = confirm.task_id
status_resp = _run_async(
assistant_module.send_message(
request=assistant_module.AssistantMessageRequest(
message=f"проверь статус задачи {task_id}"
),
current_user=_admin_user(),
task_manager=task_manager,
config_manager=_FakeConfigManager(),
db=db,
)
)
assert status_resp.state in {"started", "success"}
assert task_id in status_resp.text
# [/DEF:test_status_query_returns_task_status:Function]
# [DEF:test_status_query_without_task_id_returns_latest_user_task:Function]
# @PURPOSE: Status command without explicit task_id should resolve to latest task for current user.
# @PRE: User has at least one created task in task manager history.
# @POST: Response references latest task status without explicit task id in command.
def test_status_query_without_task_id_returns_latest_user_task():
_clear_assistant_state()
task_manager = _FakeTaskManager()
db = _FakeDb()
start = _run_async(
assistant_module.send_message(
request=assistant_module.AssistantMessageRequest(
message="запусти миграцию с dev на prod для дашборда 33"
),
current_user=_admin_user(),
task_manager=task_manager,
config_manager=_FakeConfigManager(),
db=db,
)
)
_run_async(
assistant_module.confirm_operation(
confirmation_id=start.confirmation_id,
current_user=_admin_user(),
task_manager=task_manager,
config_manager=_FakeConfigManager(),
db=db,
)
)
status_resp = _run_async(
assistant_module.send_message(
request=assistant_module.AssistantMessageRequest(
message="покажи статус последней задачи"
),
current_user=_admin_user(),
task_manager=task_manager,
config_manager=_FakeConfigManager(),
db=db,
)
)
assert status_resp.state in {"started", "success"}
assert "Последняя задача:" in status_resp.text
# [/DEF:test_status_query_without_task_id_returns_latest_user_task:Function]
# [DEF:test_llm_validation_missing_dashboard_returns_needs_clarification:Function]
# @PURPOSE: LLM validation command without resolvable dashboard id must request clarification instead of generic failure.
# @PRE: Command intent resolves to run_llm_validation but dashboard id cannot be inferred.
# @POST: Assistant response state is needs_clarification with guidance text.
def test_llm_validation_missing_dashboard_returns_needs_clarification():
_clear_assistant_state()
response = _run_async(
assistant_module.send_message(
request=assistant_module.AssistantMessageRequest(
message="Я хочу сделать валидацию дашборда test1"
),
current_user=_admin_user(),
task_manager=_FakeTaskManager(),
config_manager=_FakeConfigManager(),
db=_FakeDb(),
)
)
assert response.state == "needs_clarification"
assert "Укажите" in response.text or "Missing dashboard_id" in response.text
# [/DEF:test_llm_validation_missing_dashboard_returns_needs_clarification:Function]
# [DEF:test_list_conversations_groups_by_conversation_and_marks_archived:Function]
# @PURPOSE: Conversations endpoint must group messages and compute archived marker by inactivity threshold.
# @PRE: Fake DB contains two conversations with different update timestamps.
# @POST: Response includes both conversations with archived flag set for stale one.
def test_list_conversations_groups_by_conversation_and_marks_archived():
_clear_assistant_state()
db = _FakeDb()
now = datetime.utcnow()
db.add(
AssistantMessageRecord(
id="m-1",
user_id="u-admin",
conversation_id="conv-active",
role="user",
text="active chat",
created_at=now,
)
)
db.add(
AssistantMessageRecord(
id="m-2",
user_id="u-admin",
conversation_id="conv-old",
role="user",
text="old chat",
created_at=now - timedelta(days=assistant_module.ASSISTANT_ARCHIVE_AFTER_DAYS + 2),
)
)
result = _run_async(
assistant_module.list_conversations(
page=1,
page_size=20,
include_archived=True,
search=None,
current_user=_admin_user(),
db=db,
)
)
assert result["total"] == 2
by_id = {item["conversation_id"]: item for item in result["items"]}
assert by_id["conv-active"]["archived"] is False
assert by_id["conv-old"]["archived"] is True
# [/DEF:test_list_conversations_groups_by_conversation_and_marks_archived:Function]
# [DEF:test_history_from_latest_returns_recent_page_first:Function]
# @PURPOSE: History endpoint from_latest mode must return newest page while preserving chronological order in chunk.
# @PRE: Conversation has more messages than single page size.
# @POST: First page returns latest messages and has_next indicates older pages exist.
def test_history_from_latest_returns_recent_page_first():
_clear_assistant_state()
db = _FakeDb()
base_time = datetime.utcnow() - timedelta(minutes=10)
conv_id = "conv-paginated"
for i in range(4, -1, -1):
db.add(
AssistantMessageRecord(
id=f"msg-{i}",
user_id="u-admin",
conversation_id=conv_id,
role="user" if i % 2 == 0 else "assistant",
text=f"message-{i}",
created_at=base_time + timedelta(minutes=i),
)
)
result = _run_async(
assistant_module.get_history(
page=1,
page_size=2,
conversation_id=conv_id,
from_latest=True,
current_user=_admin_user(),
db=db,
)
)
assert result["from_latest"] is True
assert result["has_next"] is True
# Chunk is chronological while representing latest page.
assert [item["text"] for item in result["items"]] == ["message-3", "message-4"]
# [/DEF:test_history_from_latest_returns_recent_page_first:Function]
# [DEF:test_list_conversations_archived_only_filters_active:Function]
# @PURPOSE: archived_only mode must return only archived conversations.
# @PRE: Dataset includes one active and one archived conversation.
# @POST: Only archived conversation remains in response payload.
def test_list_conversations_archived_only_filters_active():
_clear_assistant_state()
db = _FakeDb()
now = datetime.utcnow()
db.add(
AssistantMessageRecord(
id="m-active",
user_id="u-admin",
conversation_id="conv-active-2",
role="user",
text="active",
created_at=now,
)
)
db.add(
AssistantMessageRecord(
id="m-archived",
user_id="u-admin",
conversation_id="conv-archived-2",
role="user",
text="archived",
created_at=now - timedelta(days=assistant_module.ASSISTANT_ARCHIVE_AFTER_DAYS + 3),
)
)
result = _run_async(
assistant_module.list_conversations(
page=1,
page_size=20,
include_archived=True,
archived_only=True,
search=None,
current_user=_admin_user(),
db=db,
)
)
assert result["total"] == 1
assert result["items"][0]["conversation_id"] == "conv-archived-2"
assert result["items"][0]["archived"] is True
# [/DEF:test_list_conversations_archived_only_filters_active:Function]
# [/DEF:backend.src.api.routes.__tests__.test_assistant_api:Module]

View File

@@ -0,0 +1,306 @@
# [DEF:backend.src.api.routes.__tests__.test_assistant_authz:Module]
# @TIER: STANDARD
# @SEMANTICS: tests, assistant, authz, confirmation, rbac
# @PURPOSE: Verify assistant confirmation ownership, expiration, and deny behavior for restricted users.
# @LAYER: UI (API Tests)
# @RELATION: DEPENDS_ON -> backend.src.api.routes.assistant
# @INVARIANT: Security-sensitive flows fail closed for unauthorized actors.
import os
import asyncio
from datetime import datetime, timedelta
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
# Force isolated sqlite databases for test module before dependencies import.
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_authz.db")
os.environ.setdefault("TASKS_DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_authz_tasks.db")
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_authz_auth.db")
from src.api.routes import assistant as assistant_module
from src.models.assistant import (
AssistantAuditRecord,
AssistantConfirmationRecord,
AssistantMessageRecord,
)
# [DEF:_run_async:Function]
# @TIER: TRIVIAL
# @PURPOSE: Execute async endpoint handler in synchronous test context.
# @PRE: coroutine is awaitable endpoint invocation.
# @POST: Returns coroutine result or raises propagated exception.
def _run_async(coroutine):
return asyncio.run(coroutine)
# [/DEF:_run_async:Function]
# [DEF:_FakeTask:Class]
# @TIER: TRIVIAL
# @PURPOSE: Lightweight task model used for assistant authz tests.
class _FakeTask:
def __init__(self, task_id: str, status: str = "RUNNING", user_id: str = "u-admin"):
self.id = task_id
self.status = status
self.user_id = user_id
# [/DEF:_FakeTask:Class]
# [DEF:_FakeTaskManager:Class]
# @TIER: TRIVIAL
# @PURPOSE: Minimal task manager for deterministic operation creation and lookup.
class _FakeTaskManager:
def __init__(self):
self._created = []
async def create_task(self, plugin_id, params, user_id=None):
task_id = f"task-{len(self._created) + 1}"
task = _FakeTask(task_id=task_id, status="RUNNING", user_id=user_id)
self._created.append((plugin_id, params, user_id, task))
return task
def get_task(self, task_id):
for _, _, _, task in self._created:
if task.id == task_id:
return task
return None
def get_tasks(self, limit=20, offset=0):
return [x[3] for x in self._created][offset : offset + limit]
# [/DEF:_FakeTaskManager:Class]
# [DEF:_FakeConfigManager:Class]
# @TIER: TRIVIAL
# @PURPOSE: Provide deterministic environment aliases required by intent parsing.
class _FakeConfigManager:
def get_environments(self):
return [
SimpleNamespace(id="dev", name="Development"),
SimpleNamespace(id="prod", name="Production"),
]
# [/DEF:_FakeConfigManager:Class]
# [DEF:_admin_user:Function]
# @TIER: TRIVIAL
# @PURPOSE: Build admin principal fixture.
# @PRE: Test requires privileged principal for risky operations.
# @POST: Returns admin-like user stub with Admin role.
def _admin_user():
role = SimpleNamespace(name="Admin", permissions=[])
return SimpleNamespace(id="u-admin", username="admin", roles=[role])
# [/DEF:_admin_user:Function]
# [DEF:_other_admin_user:Function]
# @TIER: TRIVIAL
# @PURPOSE: Build second admin principal fixture for ownership tests.
# @PRE: Ownership mismatch scenario needs distinct authenticated actor.
# @POST: Returns alternate admin-like user stub.
def _other_admin_user():
role = SimpleNamespace(name="Admin", permissions=[])
return SimpleNamespace(id="u-admin-2", username="admin2", roles=[role])
# [/DEF:_other_admin_user:Function]
# [DEF:_limited_user:Function]
# @TIER: TRIVIAL
# @PURPOSE: Build limited principal without required assistant execution privileges.
# @PRE: Permission denial scenario needs non-admin actor.
# @POST: Returns restricted user stub.
def _limited_user():
role = SimpleNamespace(name="Operator", permissions=[])
return SimpleNamespace(id="u-limited", username="limited", roles=[role])
# [/DEF:_limited_user:Function]
# [DEF:_FakeQuery:Class]
# @TIER: TRIVIAL
# @PURPOSE: Minimal chainable query object for fake DB interactions.
class _FakeQuery:
def __init__(self, rows):
self._rows = list(rows)
def filter(self, *args, **kwargs):
return self
def order_by(self, *args, **kwargs):
return self
def first(self):
return self._rows[0] if self._rows else None
def all(self):
return list(self._rows)
def limit(self, limit):
self._rows = self._rows[:limit]
return self
def offset(self, offset):
self._rows = self._rows[offset:]
return self
def count(self):
return len(self._rows)
# [/DEF:_FakeQuery:Class]
# [DEF:_FakeDb:Class]
# @TIER: TRIVIAL
# @PURPOSE: In-memory session substitute for assistant route persistence calls.
class _FakeDb:
def __init__(self):
self._messages = []
self._confirmations = []
self._audit = []
def add(self, row):
table = getattr(row, "__tablename__", "")
if table == "assistant_messages":
self._messages.append(row)
elif table == "assistant_confirmations":
self._confirmations.append(row)
elif table == "assistant_audit":
self._audit.append(row)
def merge(self, row):
if getattr(row, "__tablename__", "") != "assistant_confirmations":
self.add(row)
return row
for i, existing in enumerate(self._confirmations):
if getattr(existing, "id", None) == getattr(row, "id", None):
self._confirmations[i] = row
return row
self._confirmations.append(row)
return row
def query(self, model):
if model is AssistantMessageRecord:
return _FakeQuery(self._messages)
if model is AssistantConfirmationRecord:
return _FakeQuery(self._confirmations)
if model is AssistantAuditRecord:
return _FakeQuery(self._audit)
return _FakeQuery([])
def commit(self):
return None
def rollback(self):
return None
# [/DEF:_FakeDb:Class]
# [DEF:_clear_assistant_state:Function]
# @TIER: TRIVIAL
# @PURPOSE: Reset assistant process-local state between test cases.
# @PRE: Assistant globals may contain state from prior tests.
# @POST: Assistant in-memory state dictionaries are cleared.
def _clear_assistant_state():
assistant_module.CONVERSATIONS.clear()
assistant_module.USER_ACTIVE_CONVERSATION.clear()
assistant_module.CONFIRMATIONS.clear()
assistant_module.ASSISTANT_AUDIT.clear()
# [/DEF:_clear_assistant_state:Function]
# [DEF:test_confirmation_owner_mismatch_returns_403:Function]
# @PURPOSE: Confirm endpoint should reject requests from user that does not own the confirmation token.
# @PRE: Confirmation token is created by first admin actor.
# @POST: Second actor receives 403 on confirm operation.
def test_confirmation_owner_mismatch_returns_403():
_clear_assistant_state()
task_manager = _FakeTaskManager()
db = _FakeDb()
create = _run_async(
assistant_module.send_message(
request=assistant_module.AssistantMessageRequest(
message="запусти миграцию с dev на prod для дашборда 18"
),
current_user=_admin_user(),
task_manager=task_manager,
config_manager=_FakeConfigManager(),
db=db,
)
)
assert create.state == "needs_confirmation"
with pytest.raises(HTTPException) as exc:
_run_async(
assistant_module.confirm_operation(
confirmation_id=create.confirmation_id,
current_user=_other_admin_user(),
task_manager=task_manager,
config_manager=_FakeConfigManager(),
db=db,
)
)
assert exc.value.status_code == 403
# [/DEF:test_confirmation_owner_mismatch_returns_403:Function]
# [DEF:test_expired_confirmation_cannot_be_confirmed:Function]
# @PURPOSE: Expired confirmation token should be rejected and not create task.
# @PRE: Confirmation token exists and is manually expired before confirm request.
# @POST: Confirm endpoint raises 400 and no task is created.
def test_expired_confirmation_cannot_be_confirmed():
_clear_assistant_state()
task_manager = _FakeTaskManager()
db = _FakeDb()
create = _run_async(
assistant_module.send_message(
request=assistant_module.AssistantMessageRequest(
message="запусти миграцию с dev на prod для дашборда 19"
),
current_user=_admin_user(),
task_manager=task_manager,
config_manager=_FakeConfigManager(),
db=db,
)
)
assistant_module.CONFIRMATIONS[create.confirmation_id].expires_at = datetime.utcnow() - timedelta(minutes=1)
with pytest.raises(HTTPException) as exc:
_run_async(
assistant_module.confirm_operation(
confirmation_id=create.confirmation_id,
current_user=_admin_user(),
task_manager=task_manager,
config_manager=_FakeConfigManager(),
db=db,
)
)
assert exc.value.status_code == 400
assert task_manager.get_tasks(limit=10, offset=0) == []
# [/DEF:test_expired_confirmation_cannot_be_confirmed:Function]
# [DEF:test_limited_user_cannot_launch_restricted_operation:Function]
# @PURPOSE: Limited user should receive denied state for privileged operation.
# @PRE: Restricted user attempts dangerous deploy command.
# @POST: Assistant returns denied state and does not execute operation.
def test_limited_user_cannot_launch_restricted_operation():
_clear_assistant_state()
response = _run_async(
assistant_module.send_message(
request=assistant_module.AssistantMessageRequest(
message="задеплой дашборд 88 в production"
),
current_user=_limited_user(),
task_manager=_FakeTaskManager(),
config_manager=_FakeConfigManager(),
db=_FakeDb(),
)
)
assert response.state == "denied"
# [/DEF:test_limited_user_cannot_launch_restricted_operation:Function]
# [/DEF:backend.src.api.routes.__tests__.test_assistant_authz:Module]

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

@@ -21,8 +21,8 @@ from ...schemas.auth import (
RoleSchema, RoleCreate, RoleUpdate, PermissionSchema, RoleSchema, RoleCreate, RoleUpdate, PermissionSchema,
ADGroupMappingSchema, ADGroupMappingCreate ADGroupMappingSchema, ADGroupMappingCreate
) )
from ...models.auth import User, Role, Permission, ADGroupMapping from ...models.auth import User, Role, ADGroupMapping
from ...dependencies import has_permission, get_current_user from ...dependencies import has_permission
from ...core.logger import logger, belief_scope from ...core.logger import logger, belief_scope
# [/SECTION] # [/SECTION]

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ...core.database import get_db from ...core.database import get_db
from ...models.connection import ConnectionConfig from ...models.connection import ConnectionConfig
from pydantic import BaseModel, Field from pydantic import BaseModel
from datetime import datetime from datetime import datetime
from ...core.logger import logger, belief_scope from ...core.logger import logger, belief_scope
# [/SECTION] # [/SECTION]

View File

@@ -0,0 +1,396 @@
# [DEF:backend.src.api.routes.dashboards:Module]
#
# @TIER: STANDARD
# @SEMANTICS: api, dashboards, resources, hub
# @PURPOSE: API endpoints for the Dashboard Hub - listing dashboards with Git and task status
# @LAYER: API
# @RELATION: DEPENDS_ON -> backend.src.dependencies
# @RELATION: DEPENDS_ON -> backend.src.services.resource_service
# @RELATION: DEPENDS_ON -> backend.src.core.superset_client
#
# @INVARIANT: All dashboard responses include git_status and last_task metadata
# [SECTION: IMPORTS]
from fastapi import APIRouter, Depends, HTTPException
from typing import List, Optional, Dict
from pydantic import BaseModel, Field
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(prefix="/api/dashboards", tags=["Dashboards"])
# [DEF:GitStatus:DataClass]
class GitStatus(BaseModel):
branch: Optional[str] = None
sync_status: Optional[str] = Field(None, pattern="^OK|DIFF$")
# [/DEF:GitStatus:DataClass]
# [DEF:LastTask:DataClass]
class LastTask(BaseModel):
task_id: Optional[str] = None
status: Optional[str] = Field(None, pattern="^RUNNING|SUCCESS|ERROR|WAITING_INPUT$")
# [/DEF:LastTask:DataClass]
# [DEF:DashboardItem:DataClass]
class DashboardItem(BaseModel):
id: int
title: str
slug: Optional[str] = None
url: Optional[str] = None
last_modified: Optional[str] = None
git_status: Optional[GitStatus] = None
last_task: Optional[LastTask] = None
# [/DEF:DashboardItem:DataClass]
# [DEF:DashboardsResponse:DataClass]
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
# @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("", 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}, 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)
if not env:
logger.error(f"[get_dashboards][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 dashboards with status using ResourceService
dashboards = await resource_service.get_dashboards_with_status(env, all_tasks)
# Apply search filter if provided
if search:
search_lower = search.lower()
dashboards = [
d for d in dashboards
if search_lower in d.get('title', '').lower()
or search_lower in d.get('slug', '').lower()
]
# 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=paginated_dashboards,
total=total,
page=page,
page_size=page_size,
total_pages=total_pages
)
except Exception as e:
logger.error(f"[get_dashboards][Coherence:Failed] Failed to fetch dashboards: {e}")
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

@@ -0,0 +1,395 @@
# [DEF:backend.src.api.routes.datasets:Module]
#
# @TIER: STANDARD
# @SEMANTICS: api, datasets, resources, hub
# @PURPOSE: API endpoints for the Dataset Hub - listing datasets with mapping progress
# @LAYER: API
# @RELATION: DEPENDS_ON -> backend.src.dependencies
# @RELATION: DEPENDS_ON -> backend.src.services.resource_service
# @RELATION: DEPENDS_ON -> backend.src.core.superset_client
#
# @INVARIANT: All dataset responses include last_task metadata
# [SECTION: IMPORTS]
from fastapi import APIRouter, Depends, HTTPException
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(prefix="/api/datasets", tags=["Datasets"])
# [DEF:MappedFields:DataClass]
class MappedFields(BaseModel):
total: int
mapped: int
# [/DEF:MappedFields:DataClass]
# [DEF:LastTask:DataClass]
class LastTask(BaseModel):
task_id: Optional[str] = None
status: Optional[str] = Field(None, pattern="^RUNNING|SUCCESS|ERROR|WAITING_INPUT$")
# [/DEF:LastTask:DataClass]
# [DEF:DatasetItem:DataClass]
class DatasetItem(BaseModel):
id: int
table_name: str
schema: str
database: str
mapped_fields: Optional[MappedFields] = None
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: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("/ids")
async def get_dataset_ids(
env_id: str,
search: Optional[str] = None,
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_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)
if not env:
logger.error(f"[get_datasets][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()
]
# 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=paginated_datasets,
total=total,
page=page,
page_size=page_size,
total_pages=total_pages
)
except Exception as e:
logger.error(f"[get_datasets][Coherence:Failed] Failed to fetch datasets: {e}")
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

@@ -11,15 +11,14 @@
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from typing import List, Dict, Optional from typing import List, Optional
from ...dependencies import get_config_manager, get_scheduler_service, has_permission from ...dependencies import get_config_manager, get_scheduler_service, has_permission
from ...core.superset_client import SupersetClient from ...core.superset_client import SupersetClient
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from ...core.config_models import Environment as EnvModel
from ...core.logger import belief_scope from ...core.logger import belief_scope
# [/SECTION] # [/SECTION]
router = APIRouter() router = APIRouter(prefix="/api/environments", tags=["Environments"])
# [DEF:ScheduleSchema:DataClass] # [DEF:ScheduleSchema:DataClass]
class ScheduleSchema(BaseModel): class ScheduleSchema(BaseModel):
@@ -44,6 +43,8 @@ class DatabaseResponse(BaseModel):
# [DEF:get_environments:Function] # [DEF:get_environments:Function]
# @PURPOSE: List all configured environments. # @PURPOSE: List all configured environments.
# @LAYER: API
# @SEMANTICS: list, environments, config
# @PRE: config_manager is injected via Depends. # @PRE: config_manager is injected via Depends.
# @POST: Returns a list of EnvironmentResponse objects. # @POST: Returns a list of EnvironmentResponse objects.
# @RETURN: List[EnvironmentResponse] # @RETURN: List[EnvironmentResponse]
@@ -72,6 +73,8 @@ async def get_environments(
# [DEF:update_environment_schedule:Function] # [DEF:update_environment_schedule:Function]
# @PURPOSE: Update backup schedule for an environment. # @PURPOSE: Update backup schedule for an environment.
# @LAYER: API
# @SEMANTICS: update, schedule, backup, environment
# @PRE: Environment id exists, schedule is valid ScheduleSchema. # @PRE: Environment id exists, schedule is valid ScheduleSchema.
# @POST: Backup schedule updated and scheduler reloaded. # @POST: Backup schedule updated and scheduler reloaded.
# @PARAM: id (str) - The environment ID. # @PARAM: id (str) - The environment ID.
@@ -104,6 +107,8 @@ async def update_environment_schedule(
# [DEF:get_environment_databases:Function] # [DEF:get_environment_databases:Function]
# @PURPOSE: Fetch the list of databases from a specific environment. # @PURPOSE: Fetch the list of databases from a specific environment.
# @LAYER: API
# @SEMANTICS: fetch, databases, superset, environment
# @PRE: Environment id exists. # @PRE: Environment id exists.
# @POST: Returns a list of database summaries from the environment. # @POST: Returns a list of database summaries from the environment.
# @PARAM: id (str) - The environment ID. # @PARAM: id (str) - The environment ID.

View File

@@ -16,17 +16,22 @@ from typing import List, Optional
import typing import typing
from src.dependencies import get_config_manager, has_permission from src.dependencies import get_config_manager, has_permission
from src.core.database import get_db from src.core.database import get_db
from src.models.git import GitServerConfig, GitStatus, DeploymentEnvironment, GitRepository from src.models.git import GitServerConfig, GitRepository
from src.api.routes.git_schemas import ( from src.api.routes.git_schemas import (
GitServerConfigSchema, GitServerConfigCreate, GitServerConfigSchema, GitServerConfigCreate,
GitRepositorySchema, BranchSchema, BranchCreate, BranchSchema, BranchCreate,
BranchCheckout, CommitSchema, CommitCreate, BranchCheckout, CommitSchema, CommitCreate,
DeploymentEnvironmentSchema, DeployRequest, RepoInitRequest DeploymentEnvironmentSchema, DeployRequest, RepoInitRequest
) )
from src.services.git_service import GitService from src.services.git_service import GitService
from src.core.logger import logger, belief_scope from src.core.logger import logger, belief_scope
from ...services.llm_prompt_templates import (
DEFAULT_LLM_PROMPTS,
normalize_llm_settings,
resolve_bound_provider_id,
)
router = APIRouter(prefix="/api/git", tags=["git"]) router = APIRouter(tags=["git"])
git_service = GitService() git_service = GitService()
# [DEF:get_git_configs:Function] # [DEF:get_git_configs:Function]
@@ -406,6 +411,7 @@ async def get_repository_diff(
async def generate_commit_message( async def generate_commit_message(
dashboard_id: int, dashboard_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
config_manager = Depends(get_config_manager),
_ = Depends(has_permission("plugin:git", "EXECUTE")) _ = Depends(has_permission("plugin:git", "EXECUTE"))
): ):
with belief_scope("generate_commit_message"): with belief_scope("generate_commit_message"):
@@ -429,7 +435,11 @@ async def generate_commit_message(
llm_service = LLMProviderService(db) llm_service = LLMProviderService(db)
providers = llm_service.get_all_providers() providers = llm_service.get_all_providers()
provider = next((p for p in providers if p.is_active), None) llm_settings = normalize_llm_settings(config_manager.get_config().settings.llm)
bound_provider_id = resolve_bound_provider_id(llm_settings, "git_commit")
provider = next((p for p in providers if p.id == bound_provider_id), None)
if not provider:
provider = next((p for p in providers if p.is_active), None)
if not provider: if not provider:
raise HTTPException(status_code=400, detail="No active LLM provider found") raise HTTPException(status_code=400, detail="No active LLM provider found")
@@ -445,7 +455,15 @@ async def generate_commit_message(
# 4. Generate Message # 4. Generate Message
from ...plugins.git.llm_extension import GitLLMExtension from ...plugins.git.llm_extension import GitLLMExtension
extension = GitLLMExtension(client) extension = GitLLMExtension(client)
message = await extension.suggest_commit_message(diff, history) git_prompt = llm_settings["prompts"].get(
"git_commit_prompt",
DEFAULT_LLM_PROMPTS["git_commit_prompt"],
)
message = await extension.suggest_commit_message(
diff,
history,
prompt_template=git_prompt,
)
return {"message": message} return {"message": message}
except Exception as e: except Exception as e:
@@ -453,4 +471,4 @@ async def generate_commit_message(
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
# [/DEF:generate_commit_message:Function] # [/DEF:generate_commit_message:Function]
# [/DEF:backend.src.api.routes.git:Module] # [/DEF:backend.src.api.routes.git:Module]

View File

@@ -11,7 +11,6 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import List, Optional from typing import List, Optional
from datetime import datetime from datetime import datetime
from uuid import UUID
from src.models.git import GitProvider, GitStatus, SyncStatus from src.models.git import GitProvider, GitStatus, SyncStatus
# [DEF:GitServerConfigBase:Class] # [DEF:GitServerConfigBase:Class]

View File

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

View File

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

View File

@@ -7,7 +7,7 @@
# @RELATION: DEPENDS_ON -> backend.src.models.dashboard # @RELATION: DEPENDS_ON -> backend.src.models.dashboard
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from typing import List, Dict from typing import List
from ...dependencies import get_config_manager, get_task_manager, has_permission from ...dependencies import get_config_manager, get_task_manager, has_permission
from ...models.dashboard import DashboardMetadata, DashboardSelection from ...models.dashboard import DashboardMetadata, DashboardSelection
from ...core.superset_client import SupersetClient from ...core.superset_client import SupersetClient
@@ -44,7 +44,7 @@ async def get_dashboards(
# @POST: Starts the migration task and returns the task ID. # @POST: Starts the migration task and returns the task ID.
# @PARAM: selection (DashboardSelection) - The dashboards to migrate. # @PARAM: selection (DashboardSelection) - The dashboards to migrate.
# @RETURN: Dict - {"task_id": str, "message": str} # @RETURN: Dict - {"task_id": str, "message": str}
@router.post("/migration/execute") @router.post("/execute")
async def execute_migration( async def execute_migration(
selection: DashboardSelection, selection: DashboardSelection,
config_manager=Depends(get_config_manager), config_manager=Depends(get_config_manager),

View File

@@ -0,0 +1,132 @@
# [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:
with belief_scope("_parse_csv_enum_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

@@ -16,10 +16,10 @@ from pydantic import BaseModel
from ...core.config_models import AppConfig, Environment, GlobalSettings, LoggingConfig from ...core.config_models import AppConfig, Environment, GlobalSettings, LoggingConfig
from ...models.storage import StorageConfig from ...models.storage import StorageConfig
from ...dependencies import get_config_manager, has_permission from ...dependencies import get_config_manager, has_permission
from ...core.config_manager import ConfigManager from ...core.config_manager import ConfigManager
from ...core.logger import logger, belief_scope, get_task_log_level from ...core.logger import logger, belief_scope
from ...core.superset_client import SupersetClient from ...core.superset_client import SupersetClient
import os from ...services.llm_prompt_templates import normalize_llm_settings
# [/SECTION] # [/SECTION]
# [DEF:LoggingConfigResponse:Class] # [DEF:LoggingConfigResponse:Class]
@@ -39,13 +39,14 @@ router = APIRouter()
# @POST: Returns masked AppConfig. # @POST: Returns masked AppConfig.
# @RETURN: AppConfig - The current configuration. # @RETURN: AppConfig - The current configuration.
@router.get("", response_model=AppConfig) @router.get("", response_model=AppConfig)
async def get_settings( async def get_settings(
config_manager: ConfigManager = Depends(get_config_manager), config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "READ")) _ = Depends(has_permission("admin:settings", "READ"))
): ):
with belief_scope("get_settings"): with belief_scope("get_settings"):
logger.info("[get_settings][Entry] Fetching all settings") logger.info("[get_settings][Entry] Fetching all settings")
config = config_manager.get_config().copy(deep=True) config = config_manager.get_config().copy(deep=True)
config.settings.llm = normalize_llm_settings(config.settings.llm)
# Mask passwords # Mask passwords
for env in config.environments: for env in config.environments:
if env.password: if env.password:
@@ -279,4 +280,101 @@ async def update_logging_config(
) )
# [/DEF:update_logging_config:Function] # [/DEF:update_logging_config:Function]
# [DEF:ConsolidatedSettingsResponse:Class]
class ConsolidatedSettingsResponse(BaseModel):
environments: List[dict]
connections: List[dict]
llm: dict
llm_providers: List[dict]
logging: dict
storage: dict
# [/DEF:ConsolidatedSettingsResponse:Class]
# [DEF:get_consolidated_settings:Function]
# @PURPOSE: Retrieves all settings categories in a single call
# @PRE: Config manager is available.
# @POST: Returns all consolidated settings.
# @RETURN: ConsolidatedSettingsResponse - All settings categories.
@router.get("/consolidated", response_model=ConsolidatedSettingsResponse)
async def get_consolidated_settings(
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "READ"))
):
with belief_scope("get_consolidated_settings"):
logger.info("[get_consolidated_settings][Entry] Fetching all 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()
normalized_llm = normalize_llm_settings(config.settings.llm)
return ConsolidatedSettingsResponse(
environments=[env.dict() for env in config.environments],
connections=config.settings.connections,
llm=normalized_llm,
llm_providers=llm_providers_list,
logging=config.settings.logging.dict(),
storage=config.settings.storage.dict()
)
# [/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 = normalize_llm_settings(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] # [/DEF:SettingsRouter:Module]

View File

@@ -4,18 +4,30 @@
# @PURPOSE: Defines the FastAPI router for task-related endpoints, allowing clients to create, list, and get the status of tasks. # @PURPOSE: Defines the FastAPI router for task-related endpoints, allowing clients to create, list, and get the status of tasks.
# @LAYER: UI (API) # @LAYER: UI (API)
# @RELATION: Depends on the TaskManager. It is included by the main app. # @RELATION: Depends on the TaskManager. It is included by the main app.
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query from fastapi import APIRouter, Depends, HTTPException, status, Query
from pydantic import BaseModel, Field from pydantic import BaseModel
from ...core.logger import belief_scope from ...core.logger import belief_scope
from ...core.task_manager import TaskManager, Task, TaskStatus, LogEntry from ...core.task_manager import TaskManager, Task, TaskStatus, LogEntry
from ...core.task_manager.models import LogFilter, LogStats from ...core.task_manager.models import LogFilter, LogStats
from ...dependencies import get_task_manager, has_permission, get_current_user from ...dependencies import get_task_manager, has_permission, get_current_user, get_config_manager
from ...core.config_manager import ConfigManager
from ...services.llm_prompt_templates import (
is_multimodal_model,
normalize_llm_settings,
resolve_bound_provider_id,
)
router = APIRouter() router = APIRouter()
class CreateTaskRequest(BaseModel): TASK_TYPE_PLUGIN_MAP = {
"llm_validation": ["llm_dashboard_validation"],
"backup": ["superset-backup"],
"migration": ["superset-migration"],
}
class CreateTaskRequest(BaseModel):
plugin_id: str plugin_id: str
params: Dict[str, Any] params: Dict[str, Any]
@@ -33,32 +45,54 @@ class ResumeTaskRequest(BaseModel):
# @PRE: plugin_id must exist and params must be valid for that plugin. # @PRE: plugin_id must exist and params must be valid for that plugin.
# @POST: A new task is created and started. # @POST: A new task is created and started.
# @RETURN: Task - The created task instance. # @RETURN: Task - The created task instance.
async def create_task( async def create_task(
request: CreateTaskRequest, request: CreateTaskRequest,
task_manager: TaskManager = Depends(get_task_manager), task_manager: TaskManager = Depends(get_task_manager),
current_user = Depends(get_current_user) current_user = Depends(get_current_user),
): config_manager: ConfigManager = Depends(get_config_manager),
):
# Dynamic permission check based on plugin_id # Dynamic permission check based on plugin_id
has_permission(f"plugin:{request.plugin_id}", "EXECUTE")(current_user) has_permission(f"plugin:{request.plugin_id}", "EXECUTE")(current_user)
""" """
Create and start a new task for a given plugin. Create and start a new task for a given plugin.
""" """
with belief_scope("create_task"): with belief_scope("create_task"):
try: try:
# Special handling for validation task to include provider config # Special handling for LLM tasks to resolve provider config by task binding.
if request.plugin_id == "llm_dashboard_validation": if request.plugin_id in {"llm_dashboard_validation", "llm_documentation"}:
from ...core.database import SessionLocal from ...core.database import SessionLocal
from ...services.llm_provider import LLMProviderService from ...services.llm_provider import LLMProviderService
db = SessionLocal() db = SessionLocal()
try: try:
llm_service = LLMProviderService(db) llm_service = LLMProviderService(db)
provider_id = request.params.get("provider_id") provider_id = request.params.get("provider_id")
if provider_id: if not provider_id:
db_provider = llm_service.get_provider(provider_id) llm_settings = normalize_llm_settings(config_manager.get_config().settings.llm)
if not db_provider: binding_key = "dashboard_validation" if request.plugin_id == "llm_dashboard_validation" else "documentation"
raise ValueError(f"LLM Provider {provider_id} not found") provider_id = resolve_bound_provider_id(llm_settings, binding_key)
finally: if provider_id:
db.close() request.params["provider_id"] = provider_id
if not provider_id:
providers = llm_service.get_all_providers()
active_provider = next((p for p in providers if p.is_active), None)
if active_provider:
provider_id = active_provider.id
request.params["provider_id"] = provider_id
if provider_id:
db_provider = llm_service.get_provider(provider_id)
if not db_provider:
raise ValueError(f"LLM Provider {provider_id} not found")
if request.plugin_id == "llm_dashboard_validation" and not is_multimodal_model(
db_provider.default_model,
db_provider.provider_type,
):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Selected provider model is not multimodal for dashboard validation",
)
finally:
db.close()
task = await task_manager.create_task( task = await task_manager.create_task(
plugin_id=request.plugin_id, plugin_id=request.plugin_id,
@@ -79,18 +113,36 @@ async def create_task(
# @PRE: task_manager must be available. # @PRE: task_manager must be available.
# @POST: Returns a list of tasks. # @POST: Returns a list of tasks.
# @RETURN: List[Task] - List of tasks. # @RETURN: List[Task] - List of tasks.
async def list_tasks( async def list_tasks(
limit: int = 10, limit: int = 10,
offset: int = 0, offset: int = 0,
status: Optional[TaskStatus] = None, status_filter: Optional[TaskStatus] = Query(None, alias="status"),
task_manager: TaskManager = Depends(get_task_manager), task_type: Optional[str] = Query(None, description="Task category: llm_validation, backup, migration"),
_ = Depends(has_permission("tasks", "READ")) 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),
Retrieve a list of tasks with pagination and optional status filter. _ = Depends(has_permission("tasks", "READ"))
""" ):
with belief_scope("list_tasks"): """
return task_manager.get_tasks(limit=limit, offset=offset, status=status) Retrieve a list of tasks with pagination and optional status filter.
"""
with belief_scope("list_tasks"):
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] # [/DEF:list_tasks:Function]
@router.get("/{task_id}", response_model=Task) @router.get("/{task_id}", response_model=Task)
@@ -276,4 +328,4 @@ async def clear_tasks(
task_manager.clear_tasks(status) task_manager.clear_tasks(status)
return return
# [/DEF:clear_tasks:Function] # [/DEF:clear_tasks:Function]
# [/DEF:TasksRouter:Module] # [/DEF:TasksRouter:Module]

View File

@@ -6,26 +6,23 @@
# @RELATION: Depends on the dependency module and API route modules. # @RELATION: Depends on the dependency module and API route modules.
# @INVARIANT: Only one FastAPI app instance exists per process. # @INVARIANT: Only one FastAPI app instance exists per process.
# @INVARIANT: All WebSocket connections must be properly cleaned up on disconnect. # @INVARIANT: All WebSocket connections must be properly cleaned up on disconnect.
import sys
from pathlib import Path from pathlib import Path
# project_root is used for static files mounting # project_root is used for static files mounting
project_root = Path(__file__).resolve().parent.parent.parent project_root = Path(__file__).resolve().parent.parent.parent
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Request, HTTPException from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, HTTPException
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
import asyncio import asyncio
import os
from .dependencies import get_task_manager, get_scheduler_service from .dependencies import get_task_manager, get_scheduler_service
from .core.utils.network import NetworkError from .core.utils.network import NetworkError
from .core.logger import logger, belief_scope from .core.logger import logger, belief_scope
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin, llm from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin, llm, dashboards, datasets, reports, assistant
from .api import auth from .api import auth
from .core.database import init_db
# [DEF:App:Global] # [DEF:App:Global]
# @SEMANTICS: app, fastapi, instance # @SEMANTICS: app, fastapi, instance
@@ -75,12 +72,12 @@ app.add_middleware(
) )
# [DEF:log_requests:Function] # [DEF:network_error_handler:Function]
# @PURPOSE: Middleware to log incoming HTTP requests and their response status. # @PURPOSE: Global exception handler for NetworkError.
# @PRE: request is a FastAPI Request object. # @PRE: request is a FastAPI Request object.
# @POST: Logs request and response details. # @POST: Returns 503 HTTP Exception.
# @PARAM: request (Request) - The incoming request object. # @PARAM: request (Request) - The incoming request object.
# @PARAM: call_next (Callable) - The next middleware or route handler. # @PARAM: exc (NetworkError) - The exception instance.
@app.exception_handler(NetworkError) @app.exception_handler(NetworkError)
async def network_error_handler(request: Request, exc: NetworkError): async def network_error_handler(request: Request, exc: NetworkError):
with belief_scope("network_error_handler"): with belief_scope("network_error_handler"):
@@ -89,26 +86,34 @@ async def network_error_handler(request: Request, exc: NetworkError):
status_code=503, status_code=503,
detail="Environment unavailable. Please check if the Superset instance is running." detail="Environment unavailable. Please check if the Superset instance is running."
) )
# [/DEF:network_error_handler:Function]
# [DEF:log_requests:Function]
# @PURPOSE: Middleware to log incoming HTTP requests and their response status.
# @PRE: request is a FastAPI Request object.
# @POST: Logs request and response details.
# @PARAM: request (Request) - The incoming request object.
# @PARAM: call_next (Callable) - The next middleware or route handler.
@app.middleware("http") @app.middleware("http")
async def log_requests(request: Request, call_next): async def log_requests(request: Request, call_next):
# Avoid spamming logs for polling endpoints with belief_scope("log_requests"):
is_polling = request.url.path.endswith("/api/tasks") and request.method == "GET" # Avoid spamming logs for polling endpoints
is_polling = request.url.path.endswith("/api/tasks") and request.method == "GET"
if not is_polling:
logger.info(f"Incoming request: {request.method} {request.url.path}")
try:
response = await call_next(request)
if not is_polling: if not is_polling:
logger.info(f"Response status: {response.status_code} for {request.url.path}") logger.info(f"Incoming request: {request.method} {request.url.path}")
return response
except NetworkError as e: try:
logger.error(f"Network error caught in middleware: {e}") response = await call_next(request)
raise HTTPException( if not is_polling:
status_code=503, logger.info(f"Response status: {response.status_code} for {request.url.path}")
detail="Environment unavailable. Please check if the Superset instance is running." return response
) except NetworkError as e:
logger.error(f"Network error caught in middleware: {e}")
raise HTTPException(
status_code=503,
detail="Environment unavailable. Please check if the Superset instance is running."
)
# [/DEF:log_requests:Function] # [/DEF:log_requests:Function]
# Include API routes # Include API routes
@@ -118,12 +123,23 @@ app.include_router(plugins.router, prefix="/api/plugins", tags=["Plugins"])
app.include_router(tasks.router, prefix="/api/tasks", tags=["Tasks"]) app.include_router(tasks.router, prefix="/api/tasks", tags=["Tasks"])
app.include_router(settings.router, prefix="/api/settings", tags=["Settings"]) app.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
app.include_router(connections.router, prefix="/api/settings/connections", tags=["Connections"]) app.include_router(connections.router, prefix="/api/settings/connections", tags=["Connections"])
app.include_router(environments.router, prefix="/api/environments", tags=["Environments"]) app.include_router(environments.router, tags=["Environments"])
app.include_router(mappings.router) app.include_router(mappings.router, prefix="/api/mappings", tags=["Mappings"])
app.include_router(migration.router) app.include_router(migration.router)
app.include_router(git.router) app.include_router(git.router, prefix="/api/git", tags=["Git"])
app.include_router(llm.router) app.include_router(llm.router, prefix="/api/llm", tags=["LLM"])
app.include_router(storage.router, prefix="/api/storage", tags=["Storage"]) app.include_router(storage.router, prefix="/api/storage", tags=["Storage"])
app.include_router(dashboards.router)
app.include_router(datasets.router)
app.include_router(reports.router)
app.include_router(assistant.router, prefix="/api/assistant", tags=["Assistant"])
# [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] # [DEF:websocket_endpoint:Function]
# @PURPOSE: Provides a WebSocket endpoint for real-time log streaming of a task with server-side filtering. # @PURPOSE: Provides a WebSocket endpoint for real-time log streaming of a task with server-side filtering.
@@ -234,25 +250,25 @@ async def websocket_endpoint(
frontend_path = project_root / "frontend" / "build" frontend_path = project_root / "frontend" / "build"
if frontend_path.exists(): if frontend_path.exists():
app.mount("/_app", StaticFiles(directory=str(frontend_path / "_app")), name="static") 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] # [DEF:serve_spa:Function]
# @PURPOSE: Serves frontend static files or index.html for SPA routing. # @PURPOSE: Serves the SPA frontend for any path not matched by API routes.
# @PRE: file_path is requested by the client. # @PRE: frontend_path exists.
# @POST: Returns the requested file or index.html as a fallback. # @POST: Returns the requested file or index.html.
@app.get("/{file_path:path}") @app.get("/{file_path:path}", include_in_schema=False)
async def serve_spa(file_path: str): async def serve_spa(file_path: str):
with belief_scope("serve_spa", f"path={file_path}"): with belief_scope("serve_spa"):
# Don't serve SPA for API routes that fell through # Only serve SPA for non-API paths
if file_path.startswith("api/"): # API routes are registered separately and should be matched by FastAPI first
logger.info(f"[DEBUG] API route fell through to serve_spa: {file_path}") 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}") raise HTTPException(status_code=404, detail=f"API endpoint not found: {file_path}")
full_path = frontend_path / file_path full_path = frontend_path / file_path
if full_path.is_file(): if file_path and full_path.is_file():
return FileResponse(str(full_path)) return FileResponse(str(full_path))
# Fallback to index.html for SPA routing return FileResponse(str(frontend_path / "index.html"))
return FileResponse(str(frontend_path / "index.html"))
# [/DEF:serve_spa:Function] # [/DEF:serve_spa:Function]
else: else:
# [DEF:read_root:Function] # [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

@@ -10,7 +10,6 @@
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
from pydantic import Field from pydantic import Field
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
import os
# [/SECTION] # [/SECTION]
# [DEF:AuthConfig:Class] # [DEF:AuthConfig:Class]
@@ -25,7 +24,10 @@ class AuthConfig(BaseSettings):
REFRESH_TOKEN_EXPIRE_DAYS: int = 7 REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# Database Settings # 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 Settings
ADFS_CLIENT_ID: str = Field(default="", env="ADFS_CLIENT_ID") ADFS_CLIENT_ID: str = Field(default="", env="ADFS_CLIENT_ID")
@@ -42,4 +44,4 @@ class AuthConfig(BaseSettings):
auth_config = AuthConfig() auth_config = AuthConfig()
# [/DEF:auth_config:Variable] # [/DEF:auth_config:Variable]
# [/DEF:backend.src.core.auth.config:Module] # [/DEF:backend.src.core.auth.config:Module]

View File

@@ -11,8 +11,8 @@
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional, List from typing import Optional
from jose import JWTError, jwt from jose import jwt
from .config import auth_config from .config import auth_config
from ..logger import belief_scope from ..logger import belief_scope
# [/SECTION] # [/SECTION]

View File

@@ -11,7 +11,7 @@
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
from typing import Optional, List from typing import Optional, List
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ...models.auth import User, Role, Permission, ADGroupMapping from ...models.auth import User, Role, Permission
from ..logger import belief_scope from ..logger import belief_scope
# [/SECTION] # [/SECTION]

View File

@@ -8,14 +8,9 @@
# @INVARIANT: Uses bcrypt for hashing with standard work factor. # @INVARIANT: Uses bcrypt for hashing with standard work factor.
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
from passlib.context import CryptContext import bcrypt
# [/SECTION] # [/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] # [DEF:verify_password:Function]
# @PURPOSE: Verifies a plain password against a hashed password. # @PURPOSE: Verifies a plain password against a hashed password.
# @PRE: plain_password is a string, hashed_password is a bcrypt hash. # @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. # @PARAM: hashed_password (str) - The stored hash.
# @RETURN: bool - Verification result. # @RETURN: bool - Verification result.
def verify_password(plain_password: str, hashed_password: str) -> bool: 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:verify_password:Function]
# [DEF:get_password_hash: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. # @PARAM: password (str) - The password to hash.
# @RETURN: str - The generated hash. # @RETURN: str - The generated hash.
def get_password_hash(password: str) -> str: 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:get_password_hash:Function]
# [/DEF:backend.src.core.auth.security:Module] # [/DEF:backend.src.core.auth.security:Module]

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

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

View File

@@ -3,12 +3,17 @@
# @SEMANTICS: config, models, pydantic # @SEMANTICS: config, models, pydantic
# @PURPOSE: Defines the data models for application configuration using Pydantic. # @PURPOSE: Defines the data models for application configuration using Pydantic.
# @LAYER: Core # @LAYER: Core
# @RELATION: READS_FROM -> config.json # @RELATION: READS_FROM -> app_configurations (database)
# @RELATION: USED_BY -> ConfigManager # @RELATION: USED_BY -> ConfigManager
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import List, Optional from typing import List, Optional
from ..models.storage import StorageConfig from ..models.storage import StorageConfig
from ..services.llm_prompt_templates import (
DEFAULT_LLM_ASSISTANT_SETTINGS,
DEFAULT_LLM_PROMPTS,
DEFAULT_LLM_PROVIDER_BINDINGS,
)
# [DEF:Schedule:DataClass] # [DEF:Schedule:DataClass]
# @PURPOSE: Represents a backup schedule configuration. # @PURPOSE: Represents a backup schedule configuration.
@@ -33,10 +38,10 @@ class Environment(BaseModel):
# [DEF:LoggingConfig:DataClass] # [DEF:LoggingConfig:DataClass]
# @PURPOSE: Defines the configuration for the application's logging system. # @PURPOSE: Defines the configuration for the application's logging system.
class LoggingConfig(BaseModel): class LoggingConfig(BaseModel):
level: str = "INFO" level: str = "INFO"
task_log_level: str = "INFO" # Minimum level for task-specific logs (DEBUG, INFO, WARNING, ERROR) 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 max_bytes: int = 10 * 1024 * 1024
backup_count: int = 5 backup_count: int = 5
enable_belief_state: bool = True enable_belief_state: bool = True
@@ -44,10 +49,20 @@ class LoggingConfig(BaseModel):
# [DEF:GlobalSettings:DataClass] # [DEF:GlobalSettings:DataClass]
# @PURPOSE: Represents global application settings. # @PURPOSE: Represents global application settings.
class GlobalSettings(BaseModel): class GlobalSettings(BaseModel):
storage: StorageConfig = Field(default_factory=StorageConfig) storage: StorageConfig = Field(default_factory=StorageConfig)
default_environment_id: Optional[str] = None default_environment_id: Optional[str] = None
logging: LoggingConfig = Field(default_factory=LoggingConfig) logging: LoggingConfig = Field(default_factory=LoggingConfig)
connections: List[dict] = []
llm: dict = Field(
default_factory=lambda: {
"providers": [],
"default_provider": "",
"prompts": dict(DEFAULT_LLM_PROMPTS),
"provider_bindings": dict(DEFAULT_LLM_PROVIDER_BINDINGS),
**dict(DEFAULT_LLM_ASSISTANT_SETTINGS),
}
)
# Task retention settings # Task retention settings
task_retention_days: int = 30 task_retention_days: int = 30

View File

@@ -1,24 +1,25 @@
# [DEF:backend.src.core.database:Module] # [DEF:backend.src.core.database:Module]
# #
# @SEMANTICS: database, sqlite, sqlalchemy, session, persistence # @TIER: STANDARD
# @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 # @LAYER: Core
# @RELATION: DEPENDS_ON -> sqlalchemy # @RELATION: DEPENDS_ON -> sqlalchemy
# @RELATION: USES -> backend.src.models.mapping # @RELATION: DEPENDS_ON -> backend.src.models.mapping
# @RELATION: USES -> backend.src.core.auth.config # @RELATION: DEPENDS_ON -> backend.src.core.auth.config
# #
# @INVARIANT: A single engine instance is used for the entire application. # @INVARIANT: A single engine instance is used for the entire application.
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session from sqlalchemy.orm import sessionmaker
from ..models.mapping import Base from ..models.mapping import Base
# Import models to ensure they're registered with Base # Import models to ensure they're registered with Base
from ..models.task import TaskRecord from ..models import task as _task_models # noqa: F401
from ..models.connection import ConnectionConfig from ..models import auth as _auth_models # noqa: F401
from ..models.git import GitServerConfig, GitRepository, DeploymentEnvironment from ..models import config as _config_models # noqa: F401
from ..models.auth import User, Role, Permission, ADGroupMapping from ..models import llm as _llm_models # noqa: F401
from ..models.llm import LLMProvider, ValidationRecord from ..models import assistant as _assistant_models # noqa: F401
from .logger import belief_scope from .logger import belief_scope
from .auth.config import auth_config from .auth.config import auth_config
import os import os
@@ -26,59 +27,68 @@ from pathlib import Path
# [/SECTION] # [/SECTION]
# [DEF:BASE_DIR:Variable] # [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 BASE_DIR = Path(__file__).resolve().parent.parent.parent
# [/DEF:BASE_DIR:Variable] # [/DEF:BASE_DIR:Variable]
# [DEF:DATABASE_URL:Constant] # [DEF:DATABASE_URL:Constant]
# @PURPOSE: URL for the main mappings database. # @PURPOSE: URL for the main application database.
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{BASE_DIR}/mappings.db") 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:DATABASE_URL:Constant]
# [DEF:TASKS_DATABASE_URL:Constant] # [DEF:TASKS_DATABASE_URL:Constant]
# @PURPOSE: URL for the tasks execution database. # @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:TASKS_DATABASE_URL:Constant]
# [DEF:AUTH_DATABASE_URL:Constant] # [DEF:AUTH_DATABASE_URL:Constant]
# @PURPOSE: URL for the authentication database. # @PURPOSE: URL for the authentication database.
AUTH_DATABASE_URL = os.getenv("AUTH_DATABASE_URL", auth_config.AUTH_DATABASE_URL) 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:AUTH_DATABASE_URL:Constant]
# [DEF:engine:Variable] # [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. # @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:engine:Variable]
# [DEF:tasks_engine:Variable] # [DEF:tasks_engine:Variable]
# @PURPOSE: SQLAlchemy engine for tasks database. # @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:tasks_engine:Variable]
# [DEF:auth_engine:Variable] # [DEF:auth_engine:Variable]
# @PURPOSE: SQLAlchemy engine for authentication database. # @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:auth_engine:Variable]
# [DEF:SessionLocal:Class] # [DEF:SessionLocal:Class]
# @TIER: TRIVIAL
# @PURPOSE: A session factory for the main mappings database. # @PURPOSE: A session factory for the main mappings database.
# @PRE: engine is initialized. # @PRE: engine is initialized.
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# [/DEF:SessionLocal:Class] # [/DEF:SessionLocal:Class]
# [DEF:TasksSessionLocal:Class] # [DEF:TasksSessionLocal:Class]
# @TIER: TRIVIAL
# @PURPOSE: A session factory for the tasks execution database. # @PURPOSE: A session factory for the tasks execution database.
# @PRE: tasks_engine is initialized. # @PRE: tasks_engine is initialized.
TasksSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=tasks_engine) TasksSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=tasks_engine)
# [/DEF:TasksSessionLocal:Class] # [/DEF:TasksSessionLocal:Class]
# [DEF:AuthSessionLocal:Class] # [DEF:AuthSessionLocal:Class]
# @TIER: TRIVIAL
# @PURPOSE: A session factory for the authentication database. # @PURPOSE: A session factory for the authentication database.
# @PRE: auth_engine is initialized. # @PRE: auth_engine is initialized.
AuthSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=auth_engine) AuthSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=auth_engine)

View File

@@ -35,7 +35,19 @@ class BeliefFormatter(logging.Formatter):
def format(self, record): def format(self, record):
anchor_id = getattr(_belief_state, 'anchor_id', None) anchor_id = getattr(_belief_state, 'anchor_id', None)
if anchor_id: if anchor_id:
record.msg = f"[{anchor_id}][Action] {record.msg}" msg = str(record.msg)
# Supported molecular topology markers
markers = ("[EXPLORE]", "[REASON]", "[REFLECT]", "[COHERENCE:", "[Action]", "[Entry]", "[Exit]")
# Avoid duplicating anchor or overriding explicit markers
if msg.startswith(f"[{anchor_id}]"):
pass
elif any(msg.startswith(m) for m in markers):
record.msg = f"[{anchor_id}]{msg}"
else:
# Default covalent bond
record.msg = f"[{anchor_id}][Action] {msg}"
return super().format(record) return super().format(record)
# [/DEF:format:Function] # [/DEF:format:Function]
# [/DEF:BeliefFormatter:Class] # [/DEF:BeliefFormatter:Class]
@@ -75,12 +87,12 @@ def belief_scope(anchor_id: str, message: str = ""):
try: try:
yield yield
# Log Coherence OK and Exit (DEBUG level to reduce noise) # Log Coherence OK and Exit (DEBUG level to reduce noise)
logger.debug(f"[{anchor_id}][Coherence:OK]") logger.debug("[COHERENCE:OK]")
if _enable_belief_state: if _enable_belief_state:
logger.debug(f"[{anchor_id}][Exit]") logger.debug("[Exit]")
except Exception as e: except Exception as e:
# Log Coherence Failed (DEBUG level to reduce noise) # Log Coherence Failed (DEBUG level to reduce noise)
logger.debug(f"[{anchor_id}][Coherence:Failed] {str(e)}") logger.debug(f"[COHERENCE:FAILED] {str(e)}")
raise raise
finally: finally:
# Restore old anchor # Restore old anchor
@@ -111,7 +123,6 @@ def configure_logger(config):
# Add file handler if file_path is set # Add file handler if file_path is set
if config.file_path: if config.file_path:
import os
from pathlib import Path from pathlib import Path
log_file = Path(config.file_path) log_file = Path(config.file_path)
log_file.parent.mkdir(parents=True, exist_ok=True) log_file.parent.mkdir(parents=True, exist_ok=True)
@@ -276,5 +287,33 @@ logger.addHandler(websocket_log_handler)
# Example usage: # Example usage:
# logger.info("Application started", extra={"context_key": "context_value"}) # logger.info("Application started", extra={"context_key": "context_value"})
# logger.error("An error occurred", exc_info=True) # logger.error("An error occurred", exc_info=True)
import types
# [DEF:explore:Function]
# @PURPOSE: Logs an EXPLORE message (Van der Waals force) for searching, alternatives, and hypotheses.
# @SEMANTICS: log, explore, molecule
def explore(self, msg, *args, **kwargs):
self.warning(f"[EXPLORE] {msg}", *args, **kwargs)
# [/DEF:explore:Function]
# [DEF:reason:Function]
# @PURPOSE: Logs a REASON message (Covalent bond) for strict deduction and core logic.
# @SEMANTICS: log, reason, molecule
def reason(self, msg, *args, **kwargs):
self.info(f"[REASON] {msg}", *args, **kwargs)
# [/DEF:reason:Function]
# [DEF:reflect:Function]
# @PURPOSE: Logs a REFLECT message (Hydrogen bond) for self-check and structural validation.
# @SEMANTICS: log, reflect, molecule
def reflect(self, msg, *args, **kwargs):
self.debug(f"[REFLECT] {msg}", *args, **kwargs)
# [/DEF:reflect:Function]
logger.explore = types.MethodType(explore, logger)
logger.reason = types.MethodType(reason, logger)
logger.reflect = types.MethodType(reflect, logger)
# [/DEF:Logger:Global] # [/DEF:Logger:Global]
# [/DEF:LoggerModule:Module] # [/DEF:LoggerModule:Module]

View File

@@ -0,0 +1,281 @@
# [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
import logging
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
@pytest.fixture(autouse=True)
def reset_logger_state():
"""Reset logger state before each test to avoid cross-test contamination."""
config = LoggingConfig(
level="INFO",
task_log_level="INFO",
enable_belief_state=True
)
configure_logger(config)
# Also reset the logger level for caplog to work correctly
logging.getLogger("superset_tools_app").setLevel(logging.DEBUG)
yield
# Reset after test too
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]
# @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"
# [/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 (after reset fixture)."""
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"
# [/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"
# [DEF:test_belief_scope_missing_anchor:Function]
# @PURPOSE: Test @PRE condition: anchor_id must be provided
def test_belief_scope_missing_anchor():
"""Test that belief_scope enforces anchor_id to be provided."""
import pytest
from src.core.logger import belief_scope
with pytest.raises(TypeError):
# Missing required positional argument 'anchor_id'
with belief_scope():
pass
# [/DEF:test_belief_scope_missing_anchor:Function]
# [DEF:test_configure_logger_post_conditions:Function]
# @PURPOSE: Test @POST condition: Logger level, handlers, belief state flag, and task log level are updated.
def test_configure_logger_post_conditions(tmp_path):
"""Test that configure_logger satisfies all @POST conditions."""
import logging
from logging.handlers import RotatingFileHandler
from src.core.config_models import LoggingConfig
from src.core.logger import configure_logger, logger, BeliefFormatter, get_task_log_level
import src.core.logger as logger_module
log_file = tmp_path / "test.log"
config = LoggingConfig(
level="WARNING",
task_log_level="DEBUG",
enable_belief_state=False,
file_path=str(log_file)
)
configure_logger(config)
# 1. Logger level is updated
assert logger.level == logging.WARNING
# 2. Handlers are updated (file handler removed old ones, added new one)
file_handlers = [h for h in logger.handlers if isinstance(h, RotatingFileHandler)]
assert len(file_handlers) == 1
import pathlib
assert pathlib.Path(file_handlers[0].baseFilename) == log_file.resolve()
# 3. Formatter is set to BeliefFormatter
for handler in logger.handlers:
assert isinstance(handler.formatter, BeliefFormatter)
# 4. Global states
assert getattr(logger_module, '_enable_belief_state') is False
assert get_task_log_level() == "DEBUG"
# [/DEF:test_configure_logger_post_conditions:Function]
# [/DEF:test_logger:Module]

View File

@@ -11,12 +11,10 @@
import zipfile import zipfile
import yaml import yaml
import os import os
import shutil
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Dict from typing import Dict
from .logger import logger, belief_scope from .logger import logger, belief_scope
import yaml
# [/SECTION] # [/SECTION]
# [DEF:MigrationEngine:Class] # [DEF:MigrationEngine:Class]

View File

@@ -1,9 +1,8 @@
import importlib.util import importlib.util
import os import os
import sys # Added this line import sys # Added this line
from typing import Dict, Type, List, Optional from typing import Dict, List, Optional
from .plugin_base import PluginBase, PluginConfig from .plugin_base import PluginBase, PluginConfig
from jsonschema import validate
from .logger import belief_scope from .logger import belief_scope
# [DEF:PluginLoader:Class] # [DEF:PluginLoader:Class]

View File

@@ -10,7 +10,6 @@ from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from .logger import logger, belief_scope from .logger import logger, belief_scope
from .config_manager import ConfigManager from .config_manager import ConfigManager
from typing import Optional
import asyncio import asyncio
# [/SECTION] # [/SECTION]

View File

@@ -11,12 +11,13 @@
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
import json import json
import re
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union, cast from typing import Dict, List, Optional, Tuple, Union, cast
from requests import Response from requests import Response
from .logger import logger as app_logger, belief_scope from .logger import logger as app_logger, belief_scope
from .utils.network import APIClient, SupersetAPIError, AuthenticationError, DashboardNotFoundError, NetworkError from .utils.network import APIClient, SupersetAPIError
from .utils.fileio import get_filename_from_headers from .utils.fileio import get_filename_from_headers
from .config_models import Environment from .config_models import Environment
# [/SECTION] # [/SECTION]
@@ -87,11 +88,11 @@ class SupersetClient:
if 'columns' not in validated_query: if 'columns' not in validated_query:
validated_query['columns'] = ["slug", "id", "changed_on_utc", "dashboard_title", "published"] 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( paginated_data = self._fetch_all_pages(
endpoint="/dashboard/", 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) app_logger.info("[get_dashboards][Exit] Found %d dashboards.", total_count)
return total_count, paginated_data return total_count, paginated_data
# [/DEF:get_dashboards:Function] # [/DEF:get_dashboards:Function]
@@ -120,6 +121,252 @@ class SupersetClient:
return result return result
# [/DEF:get_dashboards_summary:Function] # [/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] # [DEF:export_dashboard:Function]
# @PURPOSE: Экспортирует дашборд в виде ZIP-архива. # @PURPOSE: Экспортирует дашборд в виде ZIP-архива.
# @PARAM: dashboard_id (int) - ID дашборда для экспорта. # @PARAM: dashboard_id (int) - ID дашборда для экспорта.
@@ -203,15 +450,151 @@ class SupersetClient:
app_logger.info("[get_datasets][Enter] Fetching datasets.") app_logger.info("[get_datasets][Enter] Fetching datasets.")
validated_query = self._validate_query_params(query) validated_query = self._validate_query_params(query)
total_count = self._fetch_total_object_count(endpoint="/dataset/")
paginated_data = self._fetch_all_pages( paginated_data = self._fetch_all_pages(
endpoint="/dataset/", 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) app_logger.info("[get_datasets][Exit] Found %d datasets.", total_count)
return total_count, paginated_data return total_count, paginated_data
# [/DEF:get_datasets:Function] # [/DEF:get_datasets:Function]
# [DEF:get_datasets_summary:Function]
# @PURPOSE: Fetches dataset metadata optimized for the Dataset Hub grid.
# @PRE: Client is authenticated.
# @POST: Returns a list of dataset metadata summaries.
# @RETURN: List[Dict]
def get_datasets_summary(self) -> List[Dict]:
with belief_scope("SupersetClient.get_datasets_summary"):
query = {
"columns": ["id", "table_name", "schema", "database"]
}
_, datasets = self.get_datasets(query=query)
# Map fields to match the contracts
result = []
for ds in datasets:
result.append({
"id": ds.get("id"),
"table_name": ds.get("table_name"),
"schema": ds.get("schema"),
"database": ds.get("database", {}).get("database_name", "Unknown")
})
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] # [DEF:get_dataset:Function]
# @PURPOSE: Получает информацию о конкретном датасете по его ID. # @PURPOSE: Получает информацию о конкретном датасете по его ID.
# @PARAM: dataset_id (int) - ID датасета. # @PARAM: dataset_id (int) - ID датасета.
@@ -264,11 +647,12 @@ class SupersetClient:
validated_query = self._validate_query_params(query or {}) validated_query = self._validate_query_params(query or {})
if 'columns' not in validated_query: if 'columns' not in validated_query:
validated_query['columns'] = [] validated_query['columns'] = []
total_count = self._fetch_total_object_count(endpoint="/database/")
paginated_data = self._fetch_all_pages( paginated_data = self._fetch_all_pages(
endpoint="/database/", 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) app_logger.info("[get_databases][Exit] Found %d databases.", total_count)
return total_count, paginated_data return total_count, paginated_data
# [/DEF:get_databases:Function] # [/DEF:get_databases:Function]

View File

@@ -5,7 +5,6 @@
# @LAYER: Core # @LAYER: Core
# @RELATION: Uses TaskPersistenceService and TaskLogPersistenceService to delete old tasks and logs. # @RELATION: Uses TaskPersistenceService and TaskLogPersistenceService to delete old tasks and logs.
from datetime import datetime, timedelta
from typing import List from typing import List
from .persistence import TaskPersistenceService, TaskLogPersistenceService from .persistence import TaskPersistenceService, TaskLogPersistenceService
from ..logger import logger, belief_scope from ..logger import logger, belief_scope

View File

@@ -7,8 +7,10 @@
# @INVARIANT: Each TaskContext is bound to a single task execution. # @INVARIANT: Each TaskContext is bound to a single task execution.
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
from typing import Dict, Any, Optional, Callable # [SECTION: IMPORTS]
from typing import Dict, Any, Callable
from .task_logger import TaskLogger from .task_logger import TaskLogger
from ..logger import belief_scope
# [/SECTION] # [/SECTION]
# [DEF:TaskContext:Class] # [DEF:TaskContext:Class]
@@ -44,13 +46,14 @@ class TaskContext:
params: Dict[str, Any], params: Dict[str, Any],
default_source: str = "plugin" default_source: str = "plugin"
): ):
self._task_id = task_id with belief_scope("__init__"):
self._params = params self._task_id = task_id
self._logger = TaskLogger( self._params = params
task_id=task_id, self._logger = TaskLogger(
add_log_fn=add_log_fn, task_id=task_id,
source=default_source add_log_fn=add_log_fn,
) source=default_source
)
# [/DEF:__init__:Function] # [/DEF:__init__:Function]
# [DEF:task_id:Function] # [DEF:task_id:Function]
@@ -60,7 +63,8 @@ class TaskContext:
# @RETURN: str - The task ID. # @RETURN: str - The task ID.
@property @property
def task_id(self) -> str: def task_id(self) -> str:
return self._task_id with belief_scope("task_id"):
return self._task_id
# [/DEF:task_id:Function] # [/DEF:task_id:Function]
# [DEF:logger:Function] # [DEF:logger:Function]
@@ -70,7 +74,8 @@ class TaskContext:
# @RETURN: TaskLogger - The logger instance. # @RETURN: TaskLogger - The logger instance.
@property @property
def logger(self) -> TaskLogger: def logger(self) -> TaskLogger:
return self._logger with belief_scope("logger"):
return self._logger
# [/DEF:logger:Function] # [/DEF:logger:Function]
# [DEF:params:Function] # [DEF:params:Function]
@@ -80,7 +85,8 @@ class TaskContext:
# @RETURN: Dict[str, Any] - The task parameters. # @RETURN: Dict[str, Any] - The task parameters.
@property @property
def params(self) -> Dict[str, Any]: def params(self) -> Dict[str, Any]:
return self._params with belief_scope("params"):
return self._params
# [/DEF:params:Function] # [/DEF:params:Function]
# [DEF:get_param:Function] # [DEF:get_param:Function]
@@ -91,7 +97,8 @@ class TaskContext:
# @PARAM: default (Any) - Default value if key not found. # @PARAM: default (Any) - Default value if key not found.
# @RETURN: Any - Parameter value or default. # @RETURN: Any - Parameter value or default.
def get_param(self, key: str, default: Any = None) -> Any: def get_param(self, key: str, default: Any = None) -> Any:
return self._params.get(key, default) with belief_scope("get_param"):
return self._params.get(key, default)
# [/DEF:get_param:Function] # [/DEF:get_param:Function]
# [DEF:create_sub_context:Function] # [DEF:create_sub_context:Function]
@@ -102,12 +109,13 @@ class TaskContext:
# @RETURN: TaskContext - New context with different source. # @RETURN: TaskContext - New context with different source.
def create_sub_context(self, source: str) -> "TaskContext": def create_sub_context(self, source: str) -> "TaskContext":
"""Create a sub-context with a different default source for logging.""" """Create a sub-context with a different default source for logging."""
return TaskContext( with belief_scope("create_sub_context"):
task_id=self._task_id, return TaskContext(
add_log_fn=self._logger._add_log, task_id=self._task_id,
params=self._params, add_log_fn=self._logger._add_log,
default_source=source params=self._params,
) default_source=source
)
# [/DEF:create_sub_context:Function] # [/DEF:create_sub_context:Function]
# [/DEF:TaskContext:Class] # [/DEF:TaskContext:Class]

View File

@@ -1,4 +1,5 @@
# [DEF:TaskManagerModule:Module] # [DEF:TaskManagerModule:Module]
# @TIER: CRITICAL
# @SEMANTICS: task, manager, lifecycle, execution, state # @SEMANTICS: task, manager, lifecycle, execution, state
# @PURPOSE: Manages the lifecycle of tasks, including their creation, execution, and state tracking. It uses a thread pool to run plugins asynchronously. # @PURPOSE: Manages the lifecycle of tasks, including their creation, execution, and state tracking. It uses a thread pool to run plugins asynchronously.
# @LAYER: Core # @LAYER: Core
@@ -11,10 +12,10 @@ import asyncio
import threading import threading
import inspect import inspect
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import datetime from datetime import datetime, timezone
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from .models import Task, TaskStatus, LogEntry, LogFilter, LogStats, TaskLog from .models import Task, TaskStatus, LogEntry, LogFilter, LogStats
from .persistence import TaskPersistenceService, TaskLogPersistenceService from .persistence import TaskPersistenceService, TaskLogPersistenceService
from .context import TaskContext from .context import TaskContext
from ..logger import logger, belief_scope, should_log_task_level from ..logger import logger, belief_scope, should_log_task_level
@@ -74,9 +75,10 @@ class TaskManager:
# @POST: Logs are batch-written to database every LOG_FLUSH_INTERVAL seconds. # @POST: Logs are batch-written to database every LOG_FLUSH_INTERVAL seconds.
def _flusher_loop(self): def _flusher_loop(self):
"""Background thread that flushes log buffer to database.""" """Background thread that flushes log buffer to database."""
while not self._flusher_stop_event.is_set(): with belief_scope("_flusher_loop"):
self._flush_logs() while not self._flusher_stop_event.is_set():
self._flusher_stop_event.wait(self.LOG_FLUSH_INTERVAL) self._flush_logs()
self._flusher_stop_event.wait(self.LOG_FLUSH_INTERVAL)
# [/DEF:_flusher_loop:Function] # [/DEF:_flusher_loop:Function]
# [DEF:_flush_logs:Function] # [DEF:_flush_logs:Function]
@@ -85,23 +87,24 @@ class TaskManager:
# @POST: All buffered logs are written to task_logs table. # @POST: All buffered logs are written to task_logs table.
def _flush_logs(self): def _flush_logs(self):
"""Flush all buffered logs to the database.""" """Flush all buffered logs to the database."""
with self._log_buffer_lock: with belief_scope("_flush_logs"):
task_ids = list(self._log_buffer.keys())
for task_id in task_ids:
with self._log_buffer_lock: with self._log_buffer_lock:
logs = self._log_buffer.pop(task_id, []) task_ids = list(self._log_buffer.keys())
if logs: for task_id in task_ids:
try: with self._log_buffer_lock:
self.log_persistence_service.add_logs(task_id, logs) logs = self._log_buffer.pop(task_id, [])
except Exception as e:
logger.error(f"Failed to flush logs for task {task_id}: {e}") if logs:
# Re-add logs to buffer on failure try:
with self._log_buffer_lock: self.log_persistence_service.add_logs(task_id, logs)
if task_id not in self._log_buffer: except Exception as e:
self._log_buffer[task_id] = [] logger.error(f"Failed to flush logs for task {task_id}: {e}")
self._log_buffer[task_id].extend(logs) # Re-add logs to buffer on failure
with self._log_buffer_lock:
if task_id not in self._log_buffer:
self._log_buffer[task_id] = []
self._log_buffer[task_id].extend(logs)
# [/DEF:_flush_logs:Function] # [/DEF:_flush_logs:Function]
# [DEF:_flush_task_logs:Function] # [DEF:_flush_task_logs:Function]
@@ -111,14 +114,15 @@ class TaskManager:
# @PARAM: task_id (str) - The task ID. # @PARAM: task_id (str) - The task ID.
def _flush_task_logs(self, task_id: str): def _flush_task_logs(self, task_id: str):
"""Flush logs for a specific task immediately.""" """Flush logs for a specific task immediately."""
with self._log_buffer_lock: with belief_scope("_flush_task_logs"):
logs = self._log_buffer.pop(task_id, []) with self._log_buffer_lock:
logs = self._log_buffer.pop(task_id, [])
if logs:
try: if logs:
self.log_persistence_service.add_logs(task_id, logs) try:
except Exception as e: self.log_persistence_service.add_logs(task_id, logs)
logger.error(f"Failed to flush logs for task {task_id}: {e}") except Exception as e:
logger.error(f"Failed to flush logs for task {task_id}: {e}")
# [/DEF:_flush_task_logs:Function] # [/DEF:_flush_task_logs:Function]
# [DEF:create_task:Function] # [DEF:create_task:Function]
@@ -136,7 +140,7 @@ class TaskManager:
logger.error(f"Plugin with ID '{plugin_id}' not found.") logger.error(f"Plugin with ID '{plugin_id}' not found.")
raise ValueError(f"Plugin with ID '{plugin_id}' not found.") raise ValueError(f"Plugin with ID '{plugin_id}' not found.")
plugin = self.plugin_loader.get_plugin(plugin_id) self.plugin_loader.get_plugin(plugin_id)
if not isinstance(params, dict): if not isinstance(params, dict):
logger.error("Task parameters must be a dictionary.") logger.error("Task parameters must be a dictionary.")
@@ -248,7 +252,8 @@ class TaskManager:
async def wait_for_resolution(self, task_id: str): async def wait_for_resolution(self, task_id: str):
with belief_scope("TaskManager.wait_for_resolution", f"task_id={task_id}"): with belief_scope("TaskManager.wait_for_resolution", f"task_id={task_id}"):
task = self.tasks.get(task_id) task = self.tasks.get(task_id)
if not task: return if not task:
return
task.status = TaskStatus.AWAITING_MAPPING task.status = TaskStatus.AWAITING_MAPPING
self.persistence_service.persist_task(task) self.persistence_service.persist_task(task)
@@ -269,7 +274,8 @@ class TaskManager:
async def wait_for_input(self, task_id: str): async def wait_for_input(self, task_id: str):
with belief_scope("TaskManager.wait_for_input", f"task_id={task_id}"): with belief_scope("TaskManager.wait_for_input", f"task_id={task_id}"):
task = self.tasks.get(task_id) task = self.tasks.get(task_id)
if not task: return if not task:
return
# Status is already set to AWAITING_INPUT by await_input() # Status is already set to AWAITING_INPUT by await_input()
self.task_futures[task_id] = self.loop.create_future() self.task_futures[task_id] = self.loop.create_future()
@@ -310,13 +316,35 @@ class TaskManager:
# @PARAM: offset (int) - Number of tasks to skip. # @PARAM: offset (int) - Number of tasks to skip.
# @PARAM: status (Optional[TaskStatus]) - Filter by task status. # @PARAM: status (Optional[TaskStatus]) - Filter by task status.
# @RETURN: List[Task] - List of tasks matching criteria. # @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"): with belief_scope("TaskManager.get_tasks"):
tasks = list(self.tasks.values()) tasks = list(self.tasks.values())
if status: if status:
tasks = [t for t in tasks if t.status == status] tasks = [t for t in tasks if t.status == status]
# Sort by start_time descending (most recent first) if plugin_ids:
tasks.sort(key=lambda t: t.started_at or datetime.min, reverse=True) 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] return tasks[offset:offset + limit]
# [/DEF:get_tasks:Function] # [/DEF:get_tasks:Function]
@@ -566,4 +594,4 @@ class TaskManager:
# [/DEF:clear_tasks:Function] # [/DEF:clear_tasks:Function]
# [/DEF:TaskManager:Class] # [/DEF:TaskManager:Class]
# [/DEF:TaskManagerModule:Module] # [/DEF:TaskManagerModule:Module]

View File

@@ -109,7 +109,8 @@ class Task(BaseModel):
params: Dict[str, Any] = Field(default_factory=dict) params: Dict[str, Any] = Field(default_factory=dict)
input_required: bool = False input_required: bool = False
input_request: Optional[Dict[str, Any]] = None 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] # [DEF:__init__:Function]
# @PURPOSE: Initializes the Task model and validates input_request for AWAITING_INPUT status. # @PURPOSE: Initializes the Task model and validates input_request for AWAITING_INPUT status.
@@ -123,4 +124,4 @@ class Task(BaseModel):
# [/DEF:__init__:Function] # [/DEF:__init__:Function]
# [/DEF:Task:Class] # [/DEF:Task:Class]
# [/DEF:TaskManagerModels:Module] # [/DEF:TaskManagerModels:Module]

View File

@@ -1,4 +1,5 @@
# [DEF:TaskPersistenceModule:Module] # [DEF:TaskPersistenceModule:Module]
# @TIER: CRITICAL
# @SEMANTICS: persistence, sqlite, sqlalchemy, task, storage # @SEMANTICS: persistence, sqlite, sqlalchemy, task, storage
# @PURPOSE: Handles the persistence of tasks using SQLAlchemy and the tasks.db database. # @PURPOSE: Handles the persistence of tasks using SQLAlchemy and the tasks.db database.
# @LAYER: Core # @LAYER: Core
@@ -7,21 +8,78 @@
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
from datetime import datetime from datetime import datetime
from typing import List, Optional, Dict, Any from typing import List, Optional
import json import json
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from ...models.task import TaskRecord, TaskLogRecord from ...models.task import TaskRecord, TaskLogRecord
from ...models.mapping import Environment
from ..database import TasksSessionLocal from ..database import TasksSessionLocal
from .models import Task, TaskStatus, LogEntry, TaskLog, LogFilter, LogStats from .models import Task, TaskStatus, LogEntry, TaskLog, LogFilter, LogStats
from ..logger import logger, belief_scope from ..logger import logger, belief_scope
# [/SECTION] # [/SECTION]
# [DEF:TaskPersistenceService:Class] # [DEF:TaskPersistenceService:Class]
# @TIER: CRITICAL
# @SEMANTICS: persistence, service, database, sqlalchemy # @SEMANTICS: persistence, service, database, sqlalchemy
# @PURPOSE: Provides methods to save and load tasks from the tasks.db database using SQLAlchemy. # @PURPOSE: Provides methods to save and load tasks from the tasks.db database using SQLAlchemy.
# @INVARIANT: Persistence must handle potentially missing task fields natively.
class TaskPersistenceService: class TaskPersistenceService:
# [DEF:_json_load_if_needed:Function]
# @PURPOSE: Safely load JSON strings from DB if necessary
# @PRE: value is an arbitrary database value
# @POST: Returns parsed JSON object, list, string, or primitive
@staticmethod
def _json_load_if_needed(value):
with belief_scope("TaskPersistenceService._json_load_if_needed"):
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
# [/DEF:_json_load_if_needed:Function]
# [DEF:_parse_datetime:Function]
# @PURPOSE: Safely parse a datetime string from the database
# @PRE: value is an ISO string or datetime object
# @POST: Returns datetime object or None
@staticmethod
def _parse_datetime(value):
with belief_scope("TaskPersistenceService._parse_datetime"):
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
# [/DEF:_parse_datetime:Function]
# [DEF:_resolve_environment_id:Function]
# @TIER: STANDARD
# @PURPOSE: Resolve environment id based on provided value or fallback to default
# @PRE: Session is active
# @POST: Environment ID is returned
@staticmethod
def _resolve_environment_id(session: Session, env_id: Optional[str]) -> str:
with belief_scope("_resolve_environment_id"):
if env_id:
return env_id
repo_env = session.query(Environment).filter_by(name="default").first()
if repo_env:
return str(repo_env.id)
return "default"
# [/DEF:_resolve_environment_id:Function]
# [DEF:__init__:Function] # [DEF:__init__:Function]
# @PURPOSE: Initializes the persistence service. # @PURPOSE: Initializes the persistence service.
# @PRE: None. # @PRE: None.
@@ -49,19 +107,21 @@ class TaskPersistenceService:
record.type = task.plugin_id record.type = task.plugin_id
record.status = task.status.value 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.started_at = task.started_at
record.finished_at = task.finished_at record.finished_at = task.finished_at
# Ensure params and result are JSON serializable # Ensure params and result are JSON serializable
def json_serializable(obj): def json_serializable(obj):
if isinstance(obj, dict): with belief_scope("TaskPersistenceService.json_serializable"):
return {k: json_serializable(v) for k, v in obj.items()} if isinstance(obj, dict):
elif isinstance(obj, list): return {k: json_serializable(v) for k, v in obj.items()}
return [json_serializable(v) for v in obj] elif isinstance(obj, list):
elif isinstance(obj, datetime): return [json_serializable(v) for v in obj]
return obj.isoformat() elif isinstance(obj, datetime):
return obj return obj.isoformat()
return obj
record.params = json_serializable(task.params) record.params = json_serializable(task.params)
record.result = json_serializable(task.result) record.result = json_serializable(task.result)
@@ -124,21 +184,28 @@ class TaskPersistenceService:
for record in records: for record in records:
try: try:
logs = [] logs = []
if record.logs: logs_payload = self._json_load_if_needed(record.logs)
for log_data in record.logs: if isinstance(logs_payload, list):
# Handle timestamp conversion if it's a string for log_data in logs_payload:
if isinstance(log_data.get('timestamp'), str): if not isinstance(log_data, dict):
log_data['timestamp'] = datetime.fromisoformat(log_data['timestamp']) continue
log_data = dict(log_data)
log_data['timestamp'] = self._parse_datetime(log_data.get('timestamp')) or datetime.utcnow()
logs.append(LogEntry(**log_data)) 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( task = Task(
id=record.id, id=record.id,
plugin_id=record.type, plugin_id=record.type,
status=TaskStatus(record.status), status=TaskStatus(record.status),
started_at=record.started_at, started_at=started_at,
finished_at=record.finished_at, finished_at=finished_at,
params=record.params or {}, params=params if isinstance(params, dict) else {},
result=record.result, result=result,
logs=logs logs=logs
) )
loaded_tasks.append(task) loaded_tasks.append(task)
@@ -185,9 +252,11 @@ class TaskLogPersistenceService:
""" """
# [DEF:__init__:Function] # [DEF:__init__:Function]
# @PURPOSE: Initialize the log persistence service. # @TIER: STANDARD
# @POST: Service is ready. # @PURPOSE: Initializes the TaskLogPersistenceService
def __init__(self): # @PRE: config is provided or defaults are used
# @POST: Service is ready for log persistence
def __init__(self, config=None):
pass pass
# [/DEF:__init__:Function] # [/DEF:__init__:Function]
@@ -382,4 +451,4 @@ class TaskLogPersistenceService:
# [/DEF:delete_logs_for_tasks:Function] # [/DEF:delete_logs_for_tasks:Function]
# [/DEF:TaskLogPersistenceService:Class] # [/DEF:TaskLogPersistenceService:Class]
# [/DEF:TaskPersistenceModule:Module] # [/DEF:TaskPersistenceModule:Module]

View File

@@ -8,7 +8,6 @@
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
from typing import Dict, Any, Optional, Callable from typing import Dict, Any, Optional, Callable
from datetime import datetime
# [/SECTION] # [/SECTION]
# [DEF:TaskLogger:Class] # [DEF:TaskLogger:Class]
@@ -16,6 +15,7 @@ from datetime import datetime
# @PURPOSE: A wrapper around TaskManager._add_log that carries task_id and source context. # @PURPOSE: A wrapper around TaskManager._add_log that carries task_id and source context.
# @TIER: CRITICAL # @TIER: CRITICAL
# @INVARIANT: All log calls include the task_id and source. # @INVARIANT: All log calls include the task_id and source.
# @TEST_DATA: task_logger -> {"task_id": "test_123", "source": "test_plugin"}
# @UX_STATE: Idle -> Logging -> (system records log) # @UX_STATE: Idle -> Logging -> (system records log)
class TaskLogger: class TaskLogger:
""" """
@@ -72,6 +72,7 @@ class TaskLogger:
# @PARAM: message (str) - Log message. # @PARAM: message (str) - Log message.
# @PARAM: source (Optional[str]) - Override source for this log entry. # @PARAM: source (Optional[str]) - Override source for this log entry.
# @PARAM: metadata (Optional[Dict]) - Additional structured data. # @PARAM: metadata (Optional[Dict]) - Additional structured data.
# @UX_STATE: Logging -> (writing internal log)
def _log( def _log(
self, self,
level: str, level: str,
@@ -91,6 +92,8 @@ class TaskLogger:
# [DEF:debug:Function] # [DEF:debug:Function]
# @PURPOSE: Log a DEBUG level message. # @PURPOSE: Log a DEBUG level message.
# @PRE: message is a string.
# @POST: Log entry added via internally with DEBUG level.
# @PARAM: message (str) - Log message. # @PARAM: message (str) - Log message.
# @PARAM: source (Optional[str]) - Override source. # @PARAM: source (Optional[str]) - Override source.
# @PARAM: metadata (Optional[Dict]) - Additional data. # @PARAM: metadata (Optional[Dict]) - Additional data.
@@ -105,6 +108,8 @@ class TaskLogger:
# [DEF:info:Function] # [DEF:info:Function]
# @PURPOSE: Log an INFO level message. # @PURPOSE: Log an INFO level message.
# @PRE: message is a string.
# @POST: Log entry added internally with INFO level.
# @PARAM: message (str) - Log message. # @PARAM: message (str) - Log message.
# @PARAM: source (Optional[str]) - Override source. # @PARAM: source (Optional[str]) - Override source.
# @PARAM: metadata (Optional[Dict]) - Additional data. # @PARAM: metadata (Optional[Dict]) - Additional data.
@@ -119,6 +124,8 @@ class TaskLogger:
# [DEF:warning:Function] # [DEF:warning:Function]
# @PURPOSE: Log a WARNING level message. # @PURPOSE: Log a WARNING level message.
# @PRE: message is a string.
# @POST: Log entry added internally with WARNING level.
# @PARAM: message (str) - Log message. # @PARAM: message (str) - Log message.
# @PARAM: source (Optional[str]) - Override source. # @PARAM: source (Optional[str]) - Override source.
# @PARAM: metadata (Optional[Dict]) - Additional data. # @PARAM: metadata (Optional[Dict]) - Additional data.
@@ -133,6 +140,8 @@ class TaskLogger:
# [DEF:error:Function] # [DEF:error:Function]
# @PURPOSE: Log an ERROR level message. # @PURPOSE: Log an ERROR level message.
# @PRE: message is a string.
# @POST: Log entry added internally with ERROR level.
# @PARAM: message (str) - Log message. # @PARAM: message (str) - Log message.
# @PARAM: source (Optional[str]) - Override source. # @PARAM: source (Optional[str]) - Override source.
# @PARAM: metadata (Optional[Dict]) - Additional data. # @PARAM: metadata (Optional[Dict]) - Additional data.

View File

@@ -11,7 +11,7 @@
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
import pandas as pd # type: ignore import pandas as pd # type: ignore
import psycopg2 # type: ignore import psycopg2 # type: ignore
from typing import Dict, List, Optional, Any from typing import Dict, Optional, Any
from ..logger import logger as app_logger, belief_scope from ..logger import logger as app_logger, belief_scope
# [/SECTION] # [/SECTION]

View File

@@ -19,7 +19,6 @@ from datetime import date, datetime
import shutil import shutil
import zlib import zlib
from dataclasses import dataclass from dataclasses import dataclass
import yaml
from ..logger import logger as app_logger, belief_scope from ..logger import logger as app_logger, belief_scope
# [/SECTION] # [/SECTION]

View File

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

View File

@@ -118,14 +118,41 @@ class APIClient:
def _init_session(self) -> requests.Session: def _init_session(self) -> requests.Session:
with belief_scope("_init_session"): with belief_scope("_init_session"):
session = requests.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]) 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('http://', adapter)
session.mount('https://', adapter) session.mount('https://', adapter)
if not self.request_settings["verify_ssl"]: if not self.request_settings["verify_ssl"]:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
app_logger.warning("[_init_session][State] SSL verification disabled.") 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 return session
# [/DEF:_init_session:Function] # [/DEF:_init_session:Function]
@@ -177,7 +204,8 @@ class APIClient:
# @POST: Returns headers including auth tokens. # @POST: Returns headers including auth tokens.
def headers(self) -> Dict[str, str]: def headers(self) -> Dict[str, str]:
with belief_scope("headers"): with belief_scope("headers"):
if not self._authenticated: self.authenticate() if not self._authenticated:
self.authenticate()
return { return {
"Authorization": f"Bearer {self._tokens['access_token']}", "Authorization": f"Bearer {self._tokens['access_token']}",
"X-CSRFToken": self._tokens.get("csrf_token", ""), "X-CSRFToken": self._tokens.get("csrf_token", ""),
@@ -200,7 +228,8 @@ class APIClient:
with belief_scope("request"): with belief_scope("request"):
full_url = f"{self.base_url}{endpoint}" full_url = f"{self.base_url}{endpoint}"
_headers = self.headers.copy() _headers = self.headers.copy()
if headers: _headers.update(headers) if headers:
_headers.update(headers)
try: try:
response = self.session.request(method, full_url, headers=_headers, **kwargs) response = self.session.request(method, full_url, headers=_headers, **kwargs)
@@ -223,9 +252,12 @@ class APIClient:
status_code = e.response.status_code status_code = e.response.status_code
if status_code == 502 or status_code == 503 or status_code == 504: if status_code == 502 or status_code == 503 or status_code == 504:
raise NetworkError(f"Environment unavailable (Status {status_code})", status_code=status_code) from e raise NetworkError(f"Environment unavailable (Status {status_code})", status_code=status_code) from e
if status_code == 404: raise DashboardNotFoundError(endpoint) from e if status_code == 404:
if status_code == 403: raise PermissionDeniedError() from e raise DashboardNotFoundError(endpoint) from e
if status_code == 401: raise AuthenticationError() from e if status_code == 403:
raise PermissionDeniedError() from e
if status_code == 401:
raise AuthenticationError() from e
raise SupersetAPIError(f"API Error {status_code}: {e.response.text}") from e raise SupersetAPIError(f"API Error {status_code}: {e.response.text}") from e
# [/DEF:_handle_http_error:Function] # [/DEF:_handle_http_error:Function]
@@ -237,9 +269,12 @@ class APIClient:
# @POST: Raises a NetworkError. # @POST: Raises a NetworkError.
def _handle_network_error(self, e: requests.exceptions.RequestException, url: str): def _handle_network_error(self, e: requests.exceptions.RequestException, url: str):
with belief_scope("_handle_network_error"): with belief_scope("_handle_network_error"):
if isinstance(e, requests.exceptions.Timeout): msg = "Request timeout" if isinstance(e, requests.exceptions.Timeout):
elif isinstance(e, requests.exceptions.ConnectionError): msg = "Connection error" msg = "Request timeout"
else: msg = f"Unknown network error: {e}" elif isinstance(e, requests.exceptions.ConnectionError):
msg = "Connection error"
else:
msg = f"Unknown network error: {e}"
raise NetworkError(msg, url=url) from e raise NetworkError(msg, url=url) from e
# [/DEF:_handle_network_error:Function] # [/DEF:_handle_network_error:Function]
@@ -256,7 +291,9 @@ class APIClient:
def upload_file(self, endpoint: str, file_info: Dict[str, Any], extra_data: Optional[Dict] = None, timeout: Optional[int] = None) -> Dict: def upload_file(self, endpoint: str, file_info: Dict[str, Any], extra_data: Optional[Dict] = None, timeout: Optional[int] = None) -> Dict:
with belief_scope("upload_file"): with belief_scope("upload_file"):
full_url = f"{self.base_url}{endpoint}" full_url = f"{self.base_url}{endpoint}"
_headers = self.headers.copy(); _headers.pop('Content-Type', None) _headers = self.headers.copy()
_headers.pop('Content-Type', None)
file_obj, file_name, form_field = file_info.get("file_obj"), file_info.get("file_name"), file_info.get("form_field", "file") file_obj, file_name, form_field = file_info.get("file_obj"), file_info.get("file_name"), file_info.get("form_field", "file")
@@ -318,20 +355,40 @@ class APIClient:
# @PURPOSE: Автоматически собирает данные со всех страниц пагинированного эндпоинта. # @PURPOSE: Автоматически собирает данные со всех страниц пагинированного эндпоинта.
# @PARAM: endpoint (str) - Эндпоинт. # @PARAM: endpoint (str) - Эндпоинт.
# @PARAM: pagination_options (Dict[str, Any]) - Опции пагинации. # @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. # @POST: Returns all items across all pages.
# @RETURN: List[Any] - Список данных. # @RETURN: List[Any] - Список данных.
def fetch_paginated_data(self, endpoint: str, pagination_options: Dict[str, Any]) -> List[Any]: def fetch_paginated_data(self, endpoint: str, pagination_options: Dict[str, Any]) -> List[Any]:
with belief_scope("fetch_paginated_data"): with belief_scope("fetch_paginated_data"):
base_query, total_count = pagination_options["base_query"], pagination_options["total_count"] base_query = pagination_options["base_query"]
results_field, page_size = pagination_options["results_field"], base_query.get('page_size') total_count = pagination_options.get("total_count")
assert page_size and page_size > 0, "'page_size' must be a positive number."
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 = [] 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} query = {**base_query, 'page': page}
response_json = cast(Dict[str, Any], self.request("GET", endpoint, params={"q": json.dumps(query)})) response_json = cast(Dict[str, Any], self.request("GET", endpoint, params={"q": json.dumps(query)}))
results.extend(response_json.get(results_field, [])) results.extend(response_json.get(results_field, []))
return results return results
# [/DEF:fetch_paginated_data:Function] # [/DEF:fetch_paginated_data:Function]

View File

@@ -1,11 +1,10 @@
# [DEF:Dependencies:Module] # [DEF:Dependencies:Module]
# @SEMANTICS: dependency, injection, singleton, factory, auth, jwt # @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 # @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 pathlib import Path
from typing import Optional
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from jose import JWTError from jose import JWTError
@@ -13,28 +12,30 @@ from .core.plugin_loader import PluginLoader
from .core.task_manager import TaskManager from .core.task_manager import TaskManager
from .core.config_manager import ConfigManager from .core.config_manager import ConfigManager
from .core.scheduler import SchedulerService 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.database import init_db, get_auth_db
from .core.logger import logger, belief_scope from .core.logger import logger
from .core.auth.jwt import decode_token from .core.auth.jwt import decode_token
from .core.auth.repository import AuthRepository from .core.auth.repository import AuthRepository
from .models.auth import User from .models.auth import User
# Initialize singletons # Initialize singletons
# Use absolute path relative to this file to ensure plugins are found regardless of CWD # Use absolute path relative to this file to ensure plugins are found regardless of CWD
project_root = Path(__file__).parent.parent.parent project_root = Path(__file__).parent.parent.parent
config_path = project_root / "config.json" config_path = project_root / "config.json"
config_manager = ConfigManager(config_path=str(config_path))
# Initialize database before services that use persisted configuration.
# Initialize database before any other services that might use it init_db()
init_db() config_manager = ConfigManager(config_path=str(config_path))
# [DEF:get_config_manager:Function] # [DEF:get_config_manager:Function]
# @PURPOSE: Dependency injector for the ConfigManager. # @PURPOSE: Dependency injector for ConfigManager.
# @PRE: Global config_manager must be initialized. # @PRE: Global config_manager must be initialized.
# @POST: Returns shared ConfigManager instance. # @POST: Returns shared ConfigManager instance.
# @RETURN: ConfigManager - The shared config manager instance. # @RETURN: ConfigManager - The shared config manager instance.
def get_config_manager() -> ConfigManager: def get_config_manager() -> ConfigManager:
"""Dependency injector for the ConfigManager.""" """Dependency injector for ConfigManager."""
return config_manager return config_manager
# [/DEF:get_config_manager:Function] # [/DEF:get_config_manager:Function]
@@ -50,45 +51,68 @@ logger.info("TaskManager initialized")
scheduler_service = SchedulerService(task_manager, config_manager) scheduler_service = SchedulerService(task_manager, config_manager)
logger.info("SchedulerService initialized") logger.info("SchedulerService initialized")
resource_service = ResourceService()
logger.info("ResourceService initialized")
# [DEF:get_plugin_loader:Function] # [DEF:get_plugin_loader:Function]
# @PURPOSE: Dependency injector for the PluginLoader. # @PURPOSE: Dependency injector for PluginLoader.
# @PRE: Global plugin_loader must be initialized. # @PRE: Global plugin_loader must be initialized.
# @POST: Returns shared PluginLoader instance. # @POST: Returns shared PluginLoader instance.
# @RETURN: PluginLoader - The shared plugin loader instance. # @RETURN: PluginLoader - The shared plugin loader instance.
def get_plugin_loader() -> PluginLoader: def get_plugin_loader() -> PluginLoader:
"""Dependency injector for the PluginLoader.""" """Dependency injector for PluginLoader."""
return plugin_loader return plugin_loader
# [/DEF:get_plugin_loader:Function] # [/DEF:get_plugin_loader:Function]
# [DEF:get_task_manager:Function] # [DEF:get_task_manager:Function]
# @PURPOSE: Dependency injector for the TaskManager. # @PURPOSE: Dependency injector for TaskManager.
# @PRE: Global task_manager must be initialized. # @PRE: Global task_manager must be initialized.
# @POST: Returns shared TaskManager instance. # @POST: Returns shared TaskManager instance.
# @RETURN: TaskManager - The shared task manager instance. # @RETURN: TaskManager - The shared task manager instance.
def get_task_manager() -> TaskManager: def get_task_manager() -> TaskManager:
"""Dependency injector for the TaskManager.""" """Dependency injector for TaskManager."""
return task_manager return task_manager
# [/DEF:get_task_manager:Function] # [/DEF:get_task_manager:Function]
# [DEF:get_scheduler_service:Function] # [DEF:get_scheduler_service:Function]
# @PURPOSE: Dependency injector for the SchedulerService. # @PURPOSE: Dependency injector for SchedulerService.
# @PRE: Global scheduler_service must be initialized. # @PRE: Global scheduler_service must be initialized.
# @POST: Returns shared SchedulerService instance. # @POST: Returns shared SchedulerService instance.
# @RETURN: SchedulerService - The shared scheduler service instance. # @RETURN: SchedulerService - The shared scheduler service instance.
def get_scheduler_service() -> SchedulerService: def get_scheduler_service() -> SchedulerService:
"""Dependency injector for the SchedulerService.""" """Dependency injector for SchedulerService."""
return scheduler_service return scheduler_service
# [/DEF:get_scheduler_service:Function] # [/DEF:get_scheduler_service:Function]
# [DEF:get_resource_service:Function]
# @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 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] # [DEF:oauth2_scheme:Variable]
# @PURPOSE: OAuth2 password bearer scheme for token extraction. # @PURPOSE: OAuth2 password bearer scheme for token extraction.
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
# [/DEF:oauth2_scheme:Variable] # [/DEF:oauth2_scheme:Variable]
# [DEF:get_current_user:Function] # [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. # @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. # @THROW: HTTPException 401 if token is invalid or user not found.
# @PARAM: token (str) - Extracted JWT token. # @PARAM: token (str) - Extracted JWT token.
# @PARAM: db (Session) - Auth database session. # @PARAM: db (Session) - Auth database session.
@@ -144,4 +168,4 @@ def has_permission(resource: str, action: str):
return permission_checker return permission_checker
# [/DEF:has_permission:Function] # [/DEF:has_permission:Function]
# [/DEF:Dependencies:Module] # [/DEF:Dependencies:Module]

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,235 @@
# [DEF:test_report_models:Module]
# @TIER: CRITICAL
# @PURPOSE: Unit tests for report Pydantic models and their validators
# @LAYER: Domain
# @RELATION: TESTS -> backend.src.models.report
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
import pytest
from datetime import datetime, timedelta
class TestTaskType:
"""Tests for the TaskType enum."""
def test_enum_values(self):
from src.models.report import TaskType
assert TaskType.LLM_VERIFICATION == "llm_verification"
assert TaskType.BACKUP == "backup"
assert TaskType.MIGRATION == "migration"
assert TaskType.DOCUMENTATION == "documentation"
assert TaskType.UNKNOWN == "unknown"
class TestReportStatus:
"""Tests for the ReportStatus enum."""
def test_enum_values(self):
from src.models.report import ReportStatus
assert ReportStatus.SUCCESS == "success"
assert ReportStatus.FAILED == "failed"
assert ReportStatus.IN_PROGRESS == "in_progress"
assert ReportStatus.PARTIAL == "partial"
class TestErrorContext:
"""Tests for ErrorContext model."""
def test_valid_creation(self):
from src.models.report import ErrorContext
ctx = ErrorContext(message="Something failed", code="ERR_001", next_actions=["Retry"])
assert ctx.message == "Something failed"
assert ctx.code == "ERR_001"
assert ctx.next_actions == ["Retry"]
def test_minimal_creation(self):
from src.models.report import ErrorContext
ctx = ErrorContext(message="Error occurred")
assert ctx.code is None
assert ctx.next_actions == []
class TestTaskReport:
"""Tests for TaskReport model and its validators."""
def _make_report(self, **overrides):
from src.models.report import TaskReport, TaskType, ReportStatus
defaults = {
"report_id": "rpt-001",
"task_id": "task-001",
"task_type": TaskType.BACKUP,
"status": ReportStatus.SUCCESS,
"updated_at": datetime(2024, 1, 15, 12, 0, 0),
"summary": "Backup completed",
}
defaults.update(overrides)
return TaskReport(**defaults)
def test_valid_creation(self):
report = self._make_report()
assert report.report_id == "rpt-001"
assert report.task_id == "task-001"
assert report.summary == "Backup completed"
def test_empty_report_id_raises(self):
with pytest.raises(ValueError, match="non-empty"):
self._make_report(report_id="")
def test_whitespace_report_id_raises(self):
with pytest.raises(ValueError, match="non-empty"):
self._make_report(report_id=" ")
def test_empty_task_id_raises(self):
with pytest.raises(ValueError, match="non-empty"):
self._make_report(task_id="")
def test_empty_summary_raises(self):
with pytest.raises(ValueError, match="non-empty"):
self._make_report(summary="")
def test_summary_whitespace_trimmed(self):
report = self._make_report(summary=" Trimmed ")
assert report.summary == "Trimmed"
def test_optional_fields(self):
report = self._make_report()
assert report.started_at is None
assert report.details is None
assert report.error_context is None
assert report.source_ref is None
def test_with_error_context(self):
from src.models.report import ErrorContext
ctx = ErrorContext(message="Connection failed")
report = self._make_report(error_context=ctx)
assert report.error_context.message == "Connection failed"
class TestReportQuery:
"""Tests for ReportQuery model and its validators."""
def test_defaults(self):
from src.models.report import ReportQuery
q = ReportQuery()
assert q.page == 1
assert q.page_size == 20
assert q.task_types == []
assert q.statuses == []
assert q.sort_by == "updated_at"
assert q.sort_order == "desc"
def test_invalid_sort_by_raises(self):
from src.models.report import ReportQuery
with pytest.raises(ValueError, match="sort_by"):
ReportQuery(sort_by="invalid_field")
def test_valid_sort_by_values(self):
from src.models.report import ReportQuery
for field in ["updated_at", "status", "task_type"]:
q = ReportQuery(sort_by=field)
assert q.sort_by == field
def test_invalid_sort_order_raises(self):
from src.models.report import ReportQuery
with pytest.raises(ValueError, match="sort_order"):
ReportQuery(sort_order="invalid")
def test_valid_sort_order_values(self):
from src.models.report import ReportQuery
for order in ["asc", "desc"]:
q = ReportQuery(sort_order=order)
assert q.sort_order == order
def test_time_range_validation_valid(self):
from src.models.report import ReportQuery
now = datetime.utcnow()
q = ReportQuery(time_from=now - timedelta(days=1), time_to=now)
assert q.time_from < q.time_to
def test_time_range_validation_invalid(self):
from src.models.report import ReportQuery
now = datetime.utcnow()
with pytest.raises(ValueError, match="time_from"):
ReportQuery(time_from=now, time_to=now - timedelta(days=1))
def test_page_ge_1(self):
from src.models.report import ReportQuery
with pytest.raises(ValueError):
ReportQuery(page=0)
def test_page_size_bounds(self):
from src.models.report import ReportQuery
with pytest.raises(ValueError):
ReportQuery(page_size=0)
with pytest.raises(ValueError):
ReportQuery(page_size=101)
class TestReportCollection:
"""Tests for ReportCollection model."""
def test_valid_creation(self):
from src.models.report import ReportCollection, ReportQuery
col = ReportCollection(
items=[],
total=0,
page=1,
page_size=20,
has_next=False,
applied_filters=ReportQuery(),
)
assert col.total == 0
assert col.has_next is False
def test_with_items(self):
from src.models.report import ReportCollection, ReportQuery, TaskReport, TaskType, ReportStatus
report = TaskReport(
report_id="r1", task_id="t1", task_type=TaskType.BACKUP,
status=ReportStatus.SUCCESS, updated_at=datetime.utcnow(),
summary="OK"
)
col = ReportCollection(
items=[report], total=1, page=1, page_size=20,
has_next=False, applied_filters=ReportQuery()
)
assert len(col.items) == 1
assert col.items[0].report_id == "r1"
class TestReportDetailView:
"""Tests for ReportDetailView model."""
def test_valid_creation(self):
from src.models.report import ReportDetailView, TaskReport, TaskType, ReportStatus
report = TaskReport(
report_id="r1", task_id="t1", task_type=TaskType.BACKUP,
status=ReportStatus.SUCCESS, updated_at=datetime.utcnow(),
summary="Backup OK"
)
detail = ReportDetailView(report=report)
assert detail.report.report_id == "r1"
assert detail.timeline == []
assert detail.diagnostics is None
assert detail.next_actions == []
def test_with_all_fields(self):
from src.models.report import ReportDetailView, TaskReport, TaskType, ReportStatus
report = TaskReport(
report_id="r1", task_id="t1", task_type=TaskType.MIGRATION,
status=ReportStatus.FAILED, updated_at=datetime.utcnow(),
summary="Migration failed"
)
detail = ReportDetailView(
report=report,
timeline=[{"event": "started", "at": "2024-01-01T00:00:00"}],
diagnostics={"cause": "timeout"},
next_actions=["Retry", "Check connection"],
)
assert len(detail.timeline) == 1
assert detail.diagnostics["cause"] == "timeout"
assert "Retry" in detail.next_actions
# [/DEF:test_report_models:Module]

View File

@@ -0,0 +1,74 @@
# [DEF:backend.src.models.assistant:Module]
# @TIER: STANDARD
# @SEMANTICS: assistant, audit, confirmation, chat
# @PURPOSE: SQLAlchemy models for assistant audit trail and confirmation tokens.
# @LAYER: Domain
# @RELATION: DEPENDS_ON -> backend.src.models.mapping
# @INVARIANT: Assistant records preserve immutable ids and creation timestamps.
from datetime import datetime
from sqlalchemy import Column, String, DateTime, JSON, Text
from .mapping import Base
# [DEF:AssistantAuditRecord:Class]
# @TIER: STANDARD
# @PURPOSE: Store audit decisions and outcomes produced by assistant command handling.
# @PRE: user_id must identify the actor for every record.
# @POST: Audit payload remains available for compliance and debugging.
class AssistantAuditRecord(Base):
__tablename__ = "assistant_audit"
id = Column(String, primary_key=True)
user_id = Column(String, index=True, nullable=False)
conversation_id = Column(String, index=True, nullable=True)
decision = Column(String, nullable=True)
task_id = Column(String, nullable=True)
message = Column(Text, nullable=True)
payload = Column(JSON, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
# [/DEF:AssistantAuditRecord:Class]
# [DEF:AssistantMessageRecord:Class]
# @TIER: STANDARD
# @PURPOSE: Persist chat history entries for assistant conversations.
# @PRE: user_id, conversation_id, role and text must be present.
# @POST: Message row can be queried in chronological order.
class AssistantMessageRecord(Base):
__tablename__ = "assistant_messages"
id = Column(String, primary_key=True)
user_id = Column(String, index=True, nullable=False)
conversation_id = Column(String, index=True, nullable=False)
role = Column(String, nullable=False) # user | assistant
text = Column(Text, nullable=False)
state = Column(String, nullable=True)
task_id = Column(String, nullable=True)
confirmation_id = Column(String, nullable=True)
payload = Column(JSON, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
# [/DEF:AssistantMessageRecord:Class]
# [DEF:AssistantConfirmationRecord:Class]
# @TIER: STANDARD
# @PURPOSE: Persist risky operation confirmation tokens with lifecycle state.
# @PRE: intent/dispatch and expiry timestamp must be provided.
# @POST: State transitions can be tracked and audited.
class AssistantConfirmationRecord(Base):
__tablename__ = "assistant_confirmations"
id = Column(String, primary_key=True)
user_id = Column(String, index=True, nullable=False)
conversation_id = Column(String, index=True, nullable=False)
state = Column(String, index=True, nullable=False, default="pending")
intent = Column(JSON, nullable=False)
dispatch = Column(JSON, nullable=False)
expires_at = Column(DateTime, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
consumed_at = Column(DateTime, nullable=True)
# [/DEF:AssistantConfirmationRecord:Class]
# [/DEF:backend.src.models.assistant:Module]

View File

@@ -11,7 +11,7 @@
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, Table, Enum from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, Table
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from .mapping import Base from .mapping import Base
# [/SECTION] # [/SECTION]

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

@@ -8,7 +8,6 @@
import enum import enum
from datetime import datetime from datetime import datetime
from sqlalchemy import Column, String, Integer, DateTime, Enum, ForeignKey, Boolean from sqlalchemy import Column, String, Integer, DateTime, Enum, ForeignKey, Boolean
from sqlalchemy.dialects.postgresql import UUID
import uuid import uuid
from src.core.database import Base from src.core.database import Base

View File

@@ -5,7 +5,7 @@
# @LAYER: Domain # @LAYER: Domain
# @RELATION: INHERITS_FROM -> backend.src.models.mapping.Base # @RELATION: INHERITS_FROM -> backend.src.models.mapping.Base
from sqlalchemy import Column, String, Boolean, DateTime, JSON, Enum, Text from sqlalchemy import Column, String, Boolean, DateTime, JSON, Text
from datetime import datetime from datetime import datetime
import uuid import uuid
from .mapping import Base from .mapping import Base

View File

@@ -0,0 +1,149 @@
# [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]
# @TIER: CRITICAL
# @INVARIANT: Must contain valid generic task type mappings.
# @SEMANTICS: enum, type, task
# @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]
# @TIER: CRITICAL
# @INVARIANT: TaskStatus enum mapping logic holds.
# @SEMANTICS: enum, status, task
# @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]
# @TIER: CRITICAL
# @INVARIANT: The properties accurately describe error state.
# @SEMANTICS: error, context, payload
# @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]
# @TIER: CRITICAL
# @INVARIANT: Must represent canonical task record attributes.
# @SEMANTICS: report, model, summary
# @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]
# @TIER: CRITICAL
# @INVARIANT: Time and pagination queries are mutually consistent.
# @SEMANTICS: query, filter, search
# @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]
# @TIER: CRITICAL
# @INVARIANT: Represents paginated data correctly.
# @SEMANTICS: collection, pagination
# @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]
# @TIER: CRITICAL
# @INVARIANT: Incorporates a report and logs correctly.
# @SEMANTICS: view, detail, logs
# @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. # @PURPOSE: Configuration model for the storage system, defining paths and naming patterns.
class StorageConfig(BaseModel): class StorageConfig(BaseModel):
root_path: str = Field(default="backups", description="Absolute path to the storage root directory.") 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.") 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.") repo_structure_pattern: str = Field(default="{category}/", description="Pattern for repository directory structure.")
filename_pattern: str = Field(default="{name}_{timestamp}", description="Pattern for filenames.") filename_pattern: str = Field(default="{name}_{timestamp}", description="Pattern for filenames.")

View File

@@ -95,7 +95,7 @@ class BackupPlugin(PluginBase):
with belief_scope("get_schema"): with belief_scope("get_schema"):
config_manager = get_config_manager() config_manager = get_config_manager()
envs = [e.name for e in config_manager.get_environments()] envs = [e.name for e in config_manager.get_environments()]
default_path = config_manager.get_config().settings.storage.root_path config_manager.get_config().settings.storage.root_path
return { return {
"type": "object", "type": "object",
@@ -113,14 +113,21 @@ class BackupPlugin(PluginBase):
# [DEF:execute:Function] # [DEF:execute:Function]
# @PURPOSE: Executes the dashboard backup logic with TaskContext support. # @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. # @PARAM: context (Optional[TaskContext]) - Task context for logging with source attribution.
# @PRE: Target environment must be configured. params must be a dictionary. # @PRE: Target environment must be configured. params must be a dictionary.
# @POST: All dashboards are exported and archived. # @POST: All dashboards are exported and archived.
async def execute(self, params: Dict[str, Any], context: Optional[TaskContext] = None): async def execute(self, params: Dict[str, Any], context: Optional[TaskContext] = None):
with belief_scope("execute"): with belief_scope("execute"):
config_manager = get_config_manager() 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 # Resolve environment name if environment_id is provided
if env_id: if env_id:
@@ -131,6 +138,8 @@ class BackupPlugin(PluginBase):
env = params.get("env") env = params.get("env")
if not env: if not env:
raise KeyError("env") raise KeyError("env")
log.info(f"Backup started for environment: {env}, selected dashboards: {dashboard_ids}")
storage_settings = config_manager.get_config().settings.storage storage_settings = config_manager.get_config().settings.storage
# Use 'backups' subfolder within the storage root # Use 'backups' subfolder within the storage root
@@ -145,10 +154,10 @@ class BackupPlugin(PluginBase):
log.info(f"Starting backup for environment: {env}") log.info(f"Starting backup for environment: {env}")
try: try:
config_manager = get_config_manager() config_manager = get_config_manager()
if not config_manager.has_environments(): if not config_manager.has_environments():
raise ValueError("No Superset environments configured. Please add an environment in Settings.") raise ValueError("No Superset environments configured. Please add an environment in Settings.")
env_config = config_manager.get_environment(env) env_config = config_manager.get_environment(env)
if not env_config: if not env_config:
@@ -156,19 +165,42 @@ class BackupPlugin(PluginBase):
client = SupersetClient(env_config) client = SupersetClient(env_config)
dashboard_count, dashboard_meta = client.get_dashboards() # Get all dashboards
superset_log.info(f"Found {dashboard_count} dashboards to export") 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: if dashboard_count == 0:
log.info("No dashboards to back up") log.info("No dashboards to back up")
return return {
"status": "NO_DASHBOARDS",
total = len(dashboard_meta) "environment": env,
for idx, db in enumerate(dashboard_meta, 1): "backup_root": str(backup_path / env.upper()),
dashboard_id = db.get('id') "total_dashboards": 0,
dashboard_title = db.get('dashboard_title', 'Unknown Dashboard') "backed_up_dashboards": 0,
if not dashboard_id: "failed_dashboards": 0,
continue "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')
if not dashboard_id:
continue
# Report progress # Report progress
progress_pct = (idx / total) * 100 progress_pct = (idx / total) * 100
@@ -189,21 +221,41 @@ class BackupPlugin(PluginBase):
unpack=False unpack=False
) )
archive_exports(str(dashboard_dir), policy=RetentionPolicy()) archive_exports(str(dashboard_dir), policy=RetentionPolicy())
storage_log.debug(f"Archived dashboard: {dashboard_title}") storage_log.debug(f"Archived dashboard: {dashboard_title}")
backed_up_dashboards.append({
except (SupersetAPIError, RequestException, IOError, OSError) as db_error: "id": dashboard_id,
log.error(f"Failed to export dashboard {dashboard_title} (ID: {dashboard_id}): {db_error}") "title": dashboard_title,
continue "path": str(dashboard_dir)
})
consolidate_archive_folders(backup_path / env.upper())
remove_empty_directories(str(backup_path / env.upper())) except (SupersetAPIError, RequestException, IOError, OSError) as db_error:
log.error(f"Failed to export dashboard {dashboard_title} (ID: {dashboard_id}): {db_error}")
log.info(f"Backup completed successfully for {env}") failed_dashboards.append({
"id": dashboard_id,
except (RequestException, IOError, KeyError) as e: "title": dashboard_title,
log.error(f"Fatal error during backup for {env}: {e}") "error": str(db_error)
raise e })
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}")
raise e
# [/DEF:execute:Function] # [/DEF:execute:Function]
# [/DEF:BackupPlugin:Class] # [/DEF:BackupPlugin:Class]
# [/DEF:BackupPlugin:Module] # [/DEF:BackupPlugin:Module]

View File

@@ -5,11 +5,11 @@
# @LAYER: Domain # @LAYER: Domain
# @RELATION: DEPENDS_ON -> backend.src.plugins.llm_analysis.service.LLMClient # @RELATION: DEPENDS_ON -> backend.src.plugins.llm_analysis.service.LLMClient
from typing import List, Optional from typing import List
from tenacity import retry, stop_after_attempt, wait_exponential from tenacity import retry, stop_after_attempt, wait_exponential
from ..llm_analysis.service import LLMClient from ..llm_analysis.service import LLMClient
from ..llm_analysis.models import LLMProviderType
from ...core.logger import belief_scope, logger from ...core.logger import belief_scope, logger
from ...services.llm_prompt_templates import DEFAULT_LLM_PROMPTS, render_prompt
# [DEF:GitLLMExtension:Class] # [DEF:GitLLMExtension:Class]
# @PURPOSE: Provides LLM capabilities to the Git plugin. # @PURPOSE: Provides LLM capabilities to the Git plugin.
@@ -27,21 +27,18 @@ class GitLLMExtension:
wait=wait_exponential(multiplier=1, min=2, max=10), wait=wait_exponential(multiplier=1, min=2, max=10),
reraise=True reraise=True
) )
async def suggest_commit_message(self, diff: str, history: List[str]) -> str: async def suggest_commit_message(
self,
diff: str,
history: List[str],
prompt_template: str = DEFAULT_LLM_PROMPTS["git_commit_prompt"],
) -> str:
with belief_scope("suggest_commit_message"): with belief_scope("suggest_commit_message"):
history_text = "\n".join(history) history_text = "\n".join(history)
prompt = f""" prompt = render_prompt(
Generate a concise and professional git commit message based on the following diff and recent history. prompt_template,
Use Conventional Commits format (e.g., feat: ..., fix: ..., docs: ...). {"history": history_text, "diff": diff},
)
Recent History:
{history_text}
Diff:
{diff}
Commit Message:
"""
logger.debug(f"[suggest_commit_message] Calling LLM with model: {self.client.default_model}") logger.debug(f"[suggest_commit_message] Calling LLM with model: {self.client.default_model}")
response = await self.client.client.chat.completions.create( response = await self.client.client.chat.completions.create(
@@ -64,4 +61,4 @@ class GitLLMExtension:
# [/DEF:suggest_commit_message:Function] # [/DEF:suggest_commit_message:Function]
# [/DEF:GitLLMExtension:Class] # [/DEF:GitLLMExtension:Class]
# [/DEF:backend/src/plugins/git/llm_extension:Module] # [/DEF:backend/src/plugins/git/llm_extension:Module]

View File

@@ -54,7 +54,7 @@ class GitPlugin(PluginBase):
self.config_manager = config_manager self.config_manager = config_manager
app_logger.info("GitPlugin initialized using shared config_manager.") app_logger.info("GitPlugin initialized using shared config_manager.")
return return
except: except Exception:
config_path = "config.json" config_path = "config.json"
self.config_manager = ConfigManager(config_path) self.config_manager = ConfigManager(config_path)
@@ -135,7 +135,7 @@ class GitPlugin(PluginBase):
# @POST: Плагин готов к выполнению задач. # @POST: Плагин готов к выполнению задач.
async def initialize(self): async def initialize(self):
with belief_scope("GitPlugin.initialize"): with belief_scope("GitPlugin.initialize"):
logger.info("[GitPlugin.initialize][Action] Initializing Git Integration Plugin logic.") app_logger.info("[GitPlugin.initialize][Action] Initializing Git Integration Plugin logic.")
# [DEF:execute:Function] # [DEF:execute:Function]
# @PURPOSE: Основной метод выполнения задач плагина с поддержкой TaskContext. # @PURPOSE: Основной метод выполнения задач плагина с поддержкой TaskContext.
@@ -246,15 +246,15 @@ class GitPlugin(PluginBase):
# 5. Автоматический staging изменений (не коммит, чтобы юзер мог проверить diff) # 5. Автоматический staging изменений (не коммит, чтобы юзер мог проверить diff)
try: try:
repo.git.add(A=True) repo.git.add(A=True)
logger.info(f"[_handle_sync][Action] Changes staged in git") app_logger.info("[_handle_sync][Action] Changes staged in git")
except Exception as ge: except Exception as ge:
logger.warning(f"[_handle_sync][Action] Failed to stage changes: {ge}") app_logger.warning(f"[_handle_sync][Action] Failed to stage changes: {ge}")
logger.info(f"[_handle_sync][Coherence:OK] Dashboard {dashboard_id} synced successfully.") app_logger.info(f"[_handle_sync][Coherence:OK] Dashboard {dashboard_id} synced successfully.")
return {"status": "success", "message": "Dashboard synced and flattened in local repository"} return {"status": "success", "message": "Dashboard synced and flattened in local repository"}
except Exception as e: except Exception as e:
logger.error(f"[_handle_sync][Coherence:Failed] Sync failed: {e}") app_logger.error(f"[_handle_sync][Coherence:Failed] Sync failed: {e}")
raise raise
# [/DEF:_handle_sync:Function] # [/DEF:_handle_sync:Function]
@@ -292,7 +292,8 @@ class GitPlugin(PluginBase):
if ".git" in dirs: if ".git" in dirs:
dirs.remove(".git") dirs.remove(".git")
for file in files: for file in files:
if file == ".git" or file.endswith(".zip"): continue if file == ".git" or file.endswith(".zip"):
continue
file_path = Path(root) / file file_path = Path(root) / file
# Prepend the root directory name to the archive path # Prepend the root directory name to the archive path
arcname = Path(root_dir_name) / file_path.relative_to(repo_path) arcname = Path(root_dir_name) / file_path.relative_to(repo_path)
@@ -315,16 +316,16 @@ class GitPlugin(PluginBase):
f.write(zip_buffer.getvalue()) f.write(zip_buffer.getvalue())
try: try:
logger.info(f"[_handle_deploy][Action] Importing dashboard to {env.name}") app_logger.info(f"[_handle_deploy][Action] Importing dashboard to {env.name}")
result = client.import_dashboard(temp_zip_path) result = client.import_dashboard(temp_zip_path)
logger.info(f"[_handle_deploy][Coherence:OK] Deployment successful for dashboard {dashboard_id}.") app_logger.info(f"[_handle_deploy][Coherence:OK] Deployment successful for dashboard {dashboard_id}.")
return {"status": "success", "message": f"Dashboard deployed to {env.name}", "details": result} return {"status": "success", "message": f"Dashboard deployed to {env.name}", "details": result}
finally: finally:
if temp_zip_path.exists(): if temp_zip_path.exists():
os.remove(temp_zip_path) os.remove(temp_zip_path)
except Exception as e: except Exception as e:
logger.error(f"[_handle_deploy][Coherence:Failed] Deployment failed: {e}") app_logger.error(f"[_handle_deploy][Coherence:Failed] Deployment failed: {e}")
raise raise
# [/DEF:_handle_deploy:Function] # [/DEF:_handle_deploy:Function]
@@ -336,13 +337,13 @@ class GitPlugin(PluginBase):
# @RETURN: Environment - Объект конфигурации окружения. # @RETURN: Environment - Объект конфигурации окружения.
def _get_env(self, env_id: Optional[str] = None): def _get_env(self, env_id: Optional[str] = None):
with belief_scope("GitPlugin._get_env"): with belief_scope("GitPlugin._get_env"):
logger.info(f"[_get_env][Entry] Fetching environment for ID: {env_id}") app_logger.info(f"[_get_env][Entry] Fetching environment for ID: {env_id}")
# Priority 1: ConfigManager (config.json) # Priority 1: ConfigManager (config.json)
if env_id: if env_id:
env = self.config_manager.get_environment(env_id) env = self.config_manager.get_environment(env_id)
if env: if env:
logger.info(f"[_get_env][Exit] Found environment by ID in ConfigManager: {env.name}") app_logger.info(f"[_get_env][Exit] Found environment by ID in ConfigManager: {env.name}")
return env return env
# Priority 2: Database (DeploymentEnvironment) # Priority 2: Database (DeploymentEnvironment)
@@ -355,12 +356,12 @@ class GitPlugin(PluginBase):
db_env = db.query(DeploymentEnvironment).filter(DeploymentEnvironment.id == env_id).first() db_env = db.query(DeploymentEnvironment).filter(DeploymentEnvironment.id == env_id).first()
else: else:
# If no ID, try to find active or any environment in DB # If no ID, try to find active or any environment in DB
db_env = db.query(DeploymentEnvironment).filter(DeploymentEnvironment.is_active == True).first() db_env = db.query(DeploymentEnvironment).filter(DeploymentEnvironment.is_active).first()
if not db_env: if not db_env:
db_env = db.query(DeploymentEnvironment).first() db_env = db.query(DeploymentEnvironment).first()
if db_env: if db_env:
logger.info(f"[_get_env][Exit] Found environment in DB: {db_env.name}") app_logger.info(f"[_get_env][Exit] Found environment in DB: {db_env.name}")
from src.core.config_models import Environment from src.core.config_models import Environment
# Use token as password for SupersetClient # Use token as password for SupersetClient
return Environment( return Environment(
@@ -382,14 +383,14 @@ class GitPlugin(PluginBase):
# but we have other envs, maybe it's one of them? # but we have other envs, maybe it's one of them?
env = next((e for e in envs if e.id == env_id), None) env = next((e for e in envs if e.id == env_id), None)
if env: if env:
logger.info(f"[_get_env][Exit] Found environment {env_id} in ConfigManager list") app_logger.info(f"[_get_env][Exit] Found environment {env_id} in ConfigManager list")
return env return env
if not env_id: if not env_id:
logger.info(f"[_get_env][Exit] Using first environment from ConfigManager: {envs[0].name}") app_logger.info(f"[_get_env][Exit] Using first environment from ConfigManager: {envs[0].name}")
return envs[0] return envs[0]
logger.error(f"[_get_env][Coherence:Failed] No environments configured (searched config.json and DB). env_id={env_id}") app_logger.error(f"[_get_env][Coherence:Failed] No environments configured (searched config.json and DB). env_id={env_id}")
raise ValueError("No environments configured. Please add a Superset Environment in Settings.") raise ValueError("No environments configured. Please add a Superset Environment in Settings.")
# [/DEF:_get_env:Function] # [/DEF:_get_env:Function]

View File

@@ -9,4 +9,6 @@ LLM Analysis Plugin for automated dashboard validation and dataset documentation
from .plugin import DashboardValidationPlugin, DocumentationPlugin from .plugin import DashboardValidationPlugin, DocumentationPlugin
__all__ = ['DashboardValidationPlugin', 'DocumentationPlugin']
# [/DEF:backend/src/plugins/llm_analysis/__init__.py:Module] # [/DEF:backend/src/plugins/llm_analysis/__init__.py:Module]

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