Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c30cca78f3 | |||
| d8bbe4baa8 | |||
| 7faa913767 | |||
| 7961ef51ba | |||
| 63d82df53b | |||
| fb6327e92b | |||
| eeb3a05e42 | |||
| cd4b91daa5 | |||
| e62735cc06 | |||
| fb8769c577 | |||
| b58275a234 | |||
| a235c169e1 | |||
| d0213ff45b | |||
| 60539ffbda | |||
| e89036c1c6 | |||
| 21e6b61ab5 | |||
| e0087c080e | |||
| b9c0fa4c28 | |||
|
|
632b730fff | ||
|
|
34393adf7e | ||
| 456d531a13 | |||
| d1d2a0f92e | |||
| 8bc805d27b | |||
| 7bfc5553cf | |||
| 49a566359a | |||
| 31b9a19a0c | |||
| fdb6541372 | |||
| 8f0d123ff8 | |||
| 45ce585aba | |||
| 20105f51c0 | |||
| 8e2f393267 | |||
| 20071b8c7a | |||
| 30c8acf7ae | |||
| 612cc55911 | |||
| 9922d7c87a | |||
| 8f4ee25415 |
@@ -198,6 +198,7 @@ Generate its full `#region` header in `contracts/modules.md` under its parent mo
|
||||
# @BRIEF One-line purpose.
|
||||
# @RELATION DEPENDS_ON -> [DependencyService]
|
||||
# @RELATION DEPENDS_ON -> [DTO:RequestSchema]
|
||||
# #endregion Domain.Resource.Action
|
||||
```
|
||||
|
||||
**Full header for C4/C5 orchestration & cross-stack functions:**
|
||||
@@ -215,6 +216,7 @@ Generate its full `#region` header in `contracts/modules.md` under its parent mo
|
||||
# @RATIONALE Why this implementation approach.
|
||||
# @REJECTED What alternative was considered and forbidden.
|
||||
# @TEST_EDGE: scenario_name -> Expected failure behavior.
|
||||
# #endregion Domain.Resource.Action
|
||||
```
|
||||
|
||||
**Screen Model actions (Svelte `.svelte.ts`):**
|
||||
@@ -227,6 +229,7 @@ Generate its full `#region` header in `contracts/modules.md` under its parent mo
|
||||
// @SIDE_EFFECT API call, store mutation, model state update.
|
||||
// @RELATION CALLS -> [apiClient]
|
||||
// @TEST_EDGE: network_failure -> ScreenState = "error"
|
||||
// #endregion ScreenModel.actionName
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
@@ -264,7 +267,7 @@ specs/<feature>/fixtures/
|
||||
**`manifest.md` — fixture index with GRACE contracts:**
|
||||
|
||||
```markdown
|
||||
#region FixtureManifest [C:3] [TYPE ADR] [SEMANTICS test,fixture,[DOMAIN]]
|
||||
#region Example.Fixturemanifest [C:3] [TYPE ADR] [SEMANTICS test,fixture,[DOMAIN]]
|
||||
@defgroup Fixtures Canonical test fixtures for [FEATURE].
|
||||
|
||||
## @{ Fixture FX_Auth.Login.Valid [C:2] [TYPE Block] [SEMANTICS test,auth,fixture]
|
||||
@@ -287,6 +290,10 @@ specs/<feature>/fixtures/
|
||||
@TEST_INVARIANT: env_reset_selection -> VERIFIED_BY: [Test.Migration.Model]
|
||||
@TEST_FIXTURE: env_reset -> fixtures/model/migration_env_reset.json
|
||||
## @} Fixture FX_Migration.EnvReset
|
||||
## @} FX_Migration.EnvReset
|
||||
## @} FX_Auth.Login.MissingPassword
|
||||
## @} FX_Auth.Login.Valid
|
||||
# #endregion Example.Fixturemanifest
|
||||
```
|
||||
|
||||
**JSON fixture format:**
|
||||
@@ -341,7 +348,7 @@ Generate `quickstart.md` using real repository verification paths:
|
||||
If UX contracts exist (`contracts/ux/` was generated by `/speckit.ux`), generate `traceability.md` — a requirements traceability matrix (RTM) mapping every user story through its implementation chain:
|
||||
|
||||
```markdown
|
||||
#region Traceability [C:3] [TYPE ADR] [SEMANTICS traceability,rtm,[DOMAIN]]
|
||||
#region Std.Agents.Traceability [C:3] [TYPE ADR] [SEMANTICS traceability,rtm,[DOMAIN]]
|
||||
@defgroup Trace Matrix Requirements → Model → API → Task → Test for [FEATURE].
|
||||
|
||||
## Traceability Matrix
|
||||
@@ -358,7 +365,7 @@ If UX contracts exist (`contracts/ux/` was generated by `/speckit.ux`), generate
|
||||
| `GET /api/dashboards` | FX_Dashboards.Hub.* | Test.Dashboards.Hub | /dashboards, /migration |
|
||||
| `Dashboards.Hub` model | FX_Dashboards.EnvReset | Test.Dashboards.Hub | /dashboards |
|
||||
|
||||
#endregion Traceability
|
||||
#endregion Std.Agents.Traceability
|
||||
```
|
||||
|
||||
**Generation rules:**
|
||||
|
||||
@@ -200,7 +200,7 @@ After all questions are answered, create TWO artifacts:
|
||||
**`contracts/ux/alternatives.md`** — all options considered, BEFORE final choice:
|
||||
|
||||
```markdown
|
||||
#region UxAlternatives [C:3] [TYPE ADR] [SEMANTICS ux,alternatives,[DOMAIN]]
|
||||
#region Std.Agents.UxAlternatives [C:3] [TYPE ADR] [SEMANTICS ux,alternatives,[DOMAIN]]
|
||||
@defgroup Ux Design alternatives explored for [FEATURE].
|
||||
|
||||
## Screen: [Name]
|
||||
@@ -225,13 +225,13 @@ After all questions are answered, create TWO artifacts:
|
||||
- ❌ Rejected: Confirm dialog — extra click on every action, annoying at scale
|
||||
- ❌ Rejected: No confirmation — dangerous for delete/migrate
|
||||
|
||||
#endregion UxAlternatives
|
||||
#endregion Std.Agents.UxAlternatives
|
||||
```
|
||||
|
||||
**`contracts/ux/decisions.md`** — only the final choices:
|
||||
|
||||
```markdown
|
||||
#region UxDecisions [C:3] [TYPE ADR] [SEMANTICS ux,decisions,[DOMAIN]]
|
||||
#region Std.Agents.UxDecisions [C:3] [TYPE ADR] [SEMANTICS ux,decisions,[DOMAIN]]
|
||||
@defgroup Ux Final UX design decisions for [FEATURE].
|
||||
|
||||
## Screen: [Name]
|
||||
@@ -240,7 +240,7 @@ After all questions are answered, create TWO artifacts:
|
||||
- Data: Paginated (20/page) + search
|
||||
- Feedback: Undo toast (5s) for destructive actions
|
||||
|
||||
#endregion UxDecisions
|
||||
#endregion Std.Agents.UxDecisions
|
||||
```
|
||||
|
||||
**Rule:** `alternatives.md` shows the DESIGN SPACE — agent can see WHY each path was rejected. `decisions.md` is the compact reference for `/speckit.plan`.
|
||||
|
||||
@@ -3,7 +3,7 @@ name: molecular-cot-logging
|
||||
description: Structured logging protocol for agent-driven development, based on molecular Long CoT bonds (REASON/REFLECT/EXPLORE). Replaces legacy Entry/Exit/Coherence markers. Python + Svelte.
|
||||
---
|
||||
|
||||
#region MolecularCoTLogging [C:5] [TYPE Skill] [SEMANTICS reasoning,runtime,logging,agentic]
|
||||
#region Std.Agents.MolecularCoTLogging [C:5] [TYPE Skill] [SEMANTICS reasoning,runtime,logging,agentic]
|
||||
@BRIEF Structured logging protocol for agent-driven development, based on molecular Long CoT bonds (Deep-Reasoning, Self-Reflection, Self-Exploration). Replaces legacy Entry/Exit/Coherence markers.
|
||||
@RELATION DEPENDS_ON -> [Std.Semantics.Core]
|
||||
@RELATION DISPATCHES -> [Std.Semantics.Python]
|
||||
@@ -385,4 +385,4 @@ All new C3+ code **must** produce logs an agent can understand with almost no so
|
||||
|
||||
See also: semantics-python (belief runtime), semantics-core (region markup rules).
|
||||
|
||||
#endregion MolecularCoTLogging
|
||||
#endregion Std.Agents.MolecularCoTLogging
|
||||
|
||||
@@ -99,18 +99,18 @@ Not all GRACE tags are equal in the model's training data. Understanding which t
|
||||
|
||||
### Legacy — DEF (permanently recognized)
|
||||
```python
|
||||
// [DEF:ContractId:Type]
|
||||
// [DEF:Std.Agents.ContractId:Type]
|
||||
// @TAG: value
|
||||
<code>
|
||||
// [/DEF:ContractId:Type]
|
||||
// [/DEF:Std.Agents.ContractId:Type]
|
||||
```
|
||||
|
||||
### Doc — Brace (Markdown, specs, ADRs)
|
||||
```
|
||||
## @{ ContractId [C:N] [TYPE TypeName]
|
||||
## @{ Std.Agents.ContractId [C:N] [TYPE TypeName]
|
||||
@BRIEF Description
|
||||
...
|
||||
## @} ContractId
|
||||
## @} Std.Agents.ContractId
|
||||
```
|
||||
|
||||
**Allowed Types:** Module, Function, Class, Component, Model, Block, ADR, Tombstone, Skill, Agent.
|
||||
@@ -262,6 +262,7 @@ The opening anchor MUST pack maximum signal into one line:
|
||||
|
||||
```
|
||||
#region Domain.Sub.ContractId [C:N] [TYPE TypeName] [SEMANTICS tag1,tag2,tag3]
|
||||
# #endregion Domain.Sub.ContractId
|
||||
```
|
||||
|
||||
- ID, complexity, type, and semantic tags on ONE line → survives CSA 4× pooling as a single KV record.
|
||||
@@ -305,6 +306,7 @@ Example — both mechanisms reinforce each other:
|
||||
#region Core.Auth.Login [C:4] [TYPE Function] [SEMANTICS auth,login,token]
|
||||
# @ingroup Auth
|
||||
# @BRIEF Authenticate user by credentials.
|
||||
# #endregion Core.Auth.Login
|
||||
```
|
||||
|
||||
**Rule:** Identical domain = identical primary keyword in `[SEMANTICS ...]` AND identical `@ingroup Domain`. They target different compression layers (CSA vs HCA) and don't conflict — the keyword repetition amplifies the DSA score.
|
||||
|
||||
@@ -7,7 +7,7 @@ description: "Python-specific GRACE-Poly protocol: few-shot complexity examples,
|
||||
@BRIEF Python-specific HOW: few-shot complexity examples, belief runtime patterns, module decomposition, and FastAPI/SQLAlchemy conventions for the GRACE-Poly protocol in superset-tools.
|
||||
@RELATION DEPENDS_ON -> [Std.Semantics.Core]
|
||||
@RELATION DEPENDS_ON -> [Std.Semantics.Contracts]
|
||||
@RELATION DISPATCHES -> [MolecularCoTLogging]
|
||||
@RELATION DISPATCHES -> [Std.Agents.MolecularCoTLogging]
|
||||
@RESTRICTION EXAMPLES ONLY — this file provides language-specific code patterns. All protocol rules (tier definitions, tag catalog, anchor syntax) are defined exclusively in `semantics-core`. This file MUST NOT redefine or contradict any rule from `semantics-core`.
|
||||
@RATIONALE Python's async/await model, FastAPI dependency injection, and SQLAlchemy session management create unique failure modes for Transformer agents: (1) async/await boundary confusion — agents write sync code in async contexts or forget `await` on ORM calls, producing silent no-ops; (2) dependency injection blindness — FastAPI's `Depends()` creates implicit call graphs that the agent's attention cannot trace without explicit @RELATION edges; (3) session lifecycle drift — SQLAlchemy sessions have strict boundaries that agents violate by passing detached objects across function calls. Concrete examples at each complexity tier act as few-shot anchors that override the agent's pre-trained (and often wrong) Python patterns.
|
||||
@REJECTED Generic Python patterns without GRACE anchors were rejected — agents produce working code that violates module size limits (INV_7), omits belief runtime markers, and creates orphan contracts invisible to the semantic index. Relying on the agent's pre-trained FastAPI/SQLAlchemy knowledge without project-specific examples was rejected — superset-tools has specific conventions (trace_id propagation, plugin architecture, WebSocket logging) that general training data cannot capture.
|
||||
|
||||
@@ -6,7 +6,7 @@ description: "Svelte 5 (Runes) protocol for superset-tools: UX State Machines, T
|
||||
#region Std.Semantics.Svelte [C:5] [TYPE Skill] [SEMANTICS frontend,svelte,ui,ux,tailwind]
|
||||
@BRIEF HOW to build Svelte 5 (Runes) Components for superset-tools with UX State Machines, Tailwind CSS, store topology, and visual-interactive validation.
|
||||
@RELATION DEPENDS_ON -> [Std.Semantics.Core]
|
||||
@RELATION DEPENDS_ON -> [MolecularCoTLogging]
|
||||
@RELATION DEPENDS_ON -> [Std.Agents.MolecularCoTLogging]
|
||||
@RELATION DISPATCHES -> [Std.Semantics.Testing]
|
||||
@RESTRICTION EXAMPLES ONLY — this file provides language-specific code patterns. All protocol rules (tier definitions, tag catalog, anchor syntax) are defined exclusively in `semantics-core`. UX contract tags are defined here as examples; the tag catalog lives in `semantics-core` §III. This file MUST NOT redefine or contradict any rule from `semantics-core`.
|
||||
@RATIONALE Svelte 5 runes ($state, $derived, $effect, $props) chosen for reactive precision and native compiler optimisations over Svelte 4 legacy reactivity ($:). Tailwind CSS selected for zero-runtime utility-first styling and rapid visual validation via chrome-devtools MCP. FSM-based UX contracts (@UX_STATE, @UX_FEEDBACK, @UX_RECOVERY) chosen to create verifiable state-transition tests that the browser Judge Agent can execute deterministically. superset-tools internal API wrappers (fetchApi/requestApi) chosen over native fetch to enforce auth, error normalisation, and trace_id propagation. Model-first architecture chosen because event-handler spaghetti is the #1 Transformer failure mode in UI code: the agent scatters logic across onclick/onchange in 5 files — KV-cache cannot hold cross-component relationships, creating invisible coupling that breaks silently.
|
||||
@@ -309,7 +309,7 @@ Frontend logging uses `log()` from `$lib/cot-logger` per **MolecularCoTLogging**
|
||||
Region format for HTML/Svelte comments:
|
||||
|
||||
```html
|
||||
<!-- #region MigrationTaskCard [C:3] [TYPE Component] [SEMANTICS ui,migration,task] -->
|
||||
<!-- #region Std.Semantics.MigrationTaskCard [C:3] [TYPE Component] [SEMANTICS ui,migration,task] -->
|
||||
<!-- @BRIEF Card displaying a migration task with status, progress, and action buttons. -->
|
||||
<!-- @LAYER UI -->
|
||||
<!-- @RELATION DEPENDS_ON -> [StatusBadge] -->
|
||||
@@ -410,7 +410,7 @@ Region format for HTML/Svelte comments:
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion MigrationTaskCard -->
|
||||
<!-- #endregion Std.Semantics.MigrationTaskCard -->
|
||||
```
|
||||
|
||||
## VII. SS-TOOLS DESIGN TOKEN CANON & COMPONENT REUSE
|
||||
|
||||
@@ -90,7 +90,7 @@ from unittest.mock import AsyncMock, patch
|
||||
class TestDashboardMigration:
|
||||
"""Verify migrate_dashboard @POST guarantees."""
|
||||
|
||||
# #region test_migrate_dashboard_success [C:2] [TYPE Function]
|
||||
# #region Std.Semantics.TestMigrateDashboardSuccess [C:2] [TYPE Function]
|
||||
# @BRIEF Happy path: valid dashboard with complete db mapping.
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_dashboard_success(self):
|
||||
@@ -98,8 +98,9 @@ class TestDashboardMigration:
|
||||
expected = {"id": "dash_1", "status": "imported"}
|
||||
# ... test implementation
|
||||
pass
|
||||
# #endregion test_migrate_dashboard_success
|
||||
# #endregion Std.Semantics.TestMigrateDashboardSuccess
|
||||
# #endregion TestDashboardMigration
|
||||
# #endregion Test.Migration.RunTask
|
||||
```
|
||||
|
||||
### Running tests
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# [DEF:Axiom_Tools_Evaluation:Report]
|
||||
# [DEF:Std.Ai.AxiomToolsEvaluation:Report]
|
||||
# @COMPLEXITY: 4
|
||||
# @PURPOSE: Comprehensive evaluation of all axiom-core MCP server tools across 8 UX metrics.
|
||||
# @LAYER: Analysis
|
||||
@@ -552,4 +552,4 @@
|
||||
|
||||
---
|
||||
|
||||
# [/DEF:Axiom_Tools_Evaluation:Report]
|
||||
# [/DEF:Std.Ai.AxiomToolsEvaluation:Report]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# [DEF:EffortAssess:Report]
|
||||
# [DEF:Std.Ai.EffortAssess:Report]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Оценка трудозатрат для репозитория на основе эволюции требований в specs и изменений объёма по git-истории.
|
||||
# @RELATION: DEPENDS_ON -> [Project_Knowledge_Map:Root]
|
||||
@@ -121,4 +121,4 @@
|
||||
- Plans: `specs/021-llm-project-assistant/plan.md`, `specs/025-clean-release-compliance/plan.md`, `specs/027-dataset-llm-orchestration/plan.md`.
|
||||
- Git evidence: коммиты `8406628`, `de1f044`, `36742cd`, `0083d90`, `321e0eb`, `023bacd`, `ed3d5f3`, а также хронологический `git log --reverse -- specs`.
|
||||
|
||||
# [/DEF:EffortAssess:Report]
|
||||
# [/DEF:Std.Ai.EffortAssess:Report]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#[DEF:BackendRouteShot:Module]
|
||||
#[DEF:Std.Ai.BackendRouteShot:Module]
|
||||
# @COMPLEXITY: 3
|
||||
# @SEMANTICS: Route, Task, API, Async
|
||||
# @PURPOSE: Reference implementation of a task-based route using GRACE-Poly.
|
||||
@@ -16,14 +16,14 @@ from ...dependencies import get_task_manager, get_config_manager, get_current_us
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# [DEF:CreateTaskRequest:Class]
|
||||
# [DEF:Std.Ai.CreateTaskRequest:Class]
|
||||
# @PURPOSE: DTO for task creation payload.
|
||||
class CreateTaskRequest(BaseModel):
|
||||
plugin_id: str
|
||||
params: Dict[str, Any]
|
||||
# [/DEF:CreateTaskRequest:Class]
|
||||
# [/DEF:Std.Ai.CreateTaskRequest:Class]
|
||||
|
||||
# [DEF:create_task:Function]
|
||||
# [DEF:Std.Ai.CreateTask:Function]
|
||||
# @COMPLEXITY: 4
|
||||
# @PURPOSE: Create and start a new task using TaskManager. Non-blocking.
|
||||
# @RELATION: [CALLS] ->[task_manager.create_task]
|
||||
@@ -70,6 +70,6 @@ async def create_task(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal Task Spawning Error"
|
||||
)
|
||||
# [/DEF:create_task:Function]
|
||||
# [/DEF:Std.Ai.CreateTask:Function]
|
||||
|
||||
# [/DEF:BackendRouteShot:Module]
|
||||
# [/DEF:Std.Ai.BackendRouteShot:Module]
|
||||
@@ -1,4 +1,4 @@
|
||||
# [DEF:TransactionCore:Module]
|
||||
# [DEF:Std.Ai.TransactionCore:Module]
|
||||
# @COMPLEXITY: 5
|
||||
# @SEMANTICS: Finance, ACID, Transfer, Ledger
|
||||
# @PURPOSE: Core banking transaction processor with ACID guarantees.
|
||||
@@ -32,7 +32,7 @@ class TransferResult(NamedTuple):
|
||||
status: str
|
||||
new_balance: Decimal
|
||||
|
||||
# [DEF:execute_transfer:Function]
|
||||
# [DEF:Std.Ai.ExecuteTransfer:Function]
|
||||
# @COMPLEXITY: 5
|
||||
# @PURPOSE: Atomically move funds between accounts with audit trails.
|
||||
# @RELATION: [CALLS] ->[atomic_transaction]
|
||||
@@ -80,6 +80,6 @@ def execute_transfer(sender_id: str, receiver_id: str, amount: Decimal) -> Trans
|
||||
# GRACE: [EXPLORE] - Неожиданный сбой
|
||||
logger.explore("Critical Transfer Failure", exc_info=e)
|
||||
raise RuntimeError("TRANSACTION_ABORTED") from e
|
||||
#[/DEF:execute_transfer:Function]
|
||||
#[/DEF:Std.Ai.ExecuteTransfer:Function]
|
||||
|
||||
# [/DEF:TransactionCore:Module]
|
||||
# [/DEF:Std.Ai.TransactionCore:Module]
|
||||
@@ -1,11 +1,11 @@
|
||||
<!-- [DEF:FrontendComponentShot:Component] -->
|
||||
<!-- [DEF:Std.Ai.FrontendComponentShot:Component] -->
|
||||
<!--
|
||||
/**
|
||||
* @COMPLEXITY: 5
|
||||
* @SEMANTICS: Task, Button, Action, UX
|
||||
* @PURPOSE: Action button to spawn a new task with full UX feedback cycle.
|
||||
* @LAYER: UI (Presentation)
|
||||
* @RELATION: [CALLS] ->[postApi]
|
||||
* @RELATION: [CALLS] ->[Api.ApiModule.PostApi]
|
||||
*
|
||||
* @INVARIANT: Must prevent double-submission while loading.
|
||||
* @INVARIANT: Loading state must always terminate (no infinite spinner).
|
||||
@@ -48,7 +48,7 @@
|
||||
let { plugin_id = "", params = {} } = $props();
|
||||
let isLoading = $state(false);
|
||||
|
||||
// [DEF:spawnTask:Function]
|
||||
// [DEF:Std.Ai.SpawnTask:Function]
|
||||
/**
|
||||
* @PURPOSE: Execute task creation request and emit user feedback.
|
||||
* @PRE: plugin_id is resolved and request params are serializable.
|
||||
@@ -75,7 +75,7 @@
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
// [/DEF:spawnTask:Function]
|
||||
// [/DEF:Std.Ai.SpawnTask:Function]
|
||||
</script>
|
||||
|
||||
<button
|
||||
@@ -89,4 +89,4 @@
|
||||
{/if}
|
||||
<span>{$t.actions.start_task}</span>
|
||||
</button>
|
||||
<!-- [/DEF:FrontendComponentShot:Component] -->
|
||||
<!-- [/DEF:Std.Ai.FrontendComponentShot:Component] -->
|
||||
@@ -1,9 +1,9 @@
|
||||
# [DEF:PluginExampleShot:Module]
|
||||
# [DEF:Std.Ai.PluginExampleShot:Module]
|
||||
# @COMPLEXITY: 3
|
||||
# @SEMANTICS: Plugin, Core, Extension
|
||||
# @PURPOSE: Reference implementation of a plugin following GRACE standards.
|
||||
# @LAYER: Domain (Business Logic)
|
||||
# @RELATION: [INHERITS] ->[PluginBase]
|
||||
# @RELATION: [INHERITS] ->[Core.PluginBase]
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from ..core.plugin_base import PluginBase
|
||||
@@ -11,15 +11,15 @@ from ..core.task_manager.context import TaskContext
|
||||
# GRACE: Обязательный импорт семантического логгера
|
||||
from ..core.logger import logger, belief_scope
|
||||
|
||||
# [DEF:ExamplePlugin:Class]
|
||||
# [DEF:Std.Ai.ExamplePlugin:Class]
|
||||
# @PURPOSE: A sample plugin to demonstrate execution context and logging.
|
||||
# @RELATION: [INHERITS] ->[PluginBase]
|
||||
# @RELATION: [INHERITS] ->[Core.PluginBase]
|
||||
class ExamplePlugin(PluginBase):
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return "example-plugin"
|
||||
|
||||
#[DEF:get_schema:Function]
|
||||
#[DEF:Std.Ai.GetSchema:Function]
|
||||
# @PURPOSE: Defines input validation schema.
|
||||
def get_schema(self) -> Dict[str, Any]:
|
||||
return {
|
||||
@@ -32,9 +32,9 @@ class ExamplePlugin(PluginBase):
|
||||
},
|
||||
"required": ["message"],
|
||||
}
|
||||
#[/DEF:get_schema:Function]
|
||||
#[/DEF:Std.Ai.GetSchema:Function]
|
||||
|
||||
# [DEF:execute:Function]
|
||||
# [DEF:Std.Ai.Execute:Function]
|
||||
# @COMPLEXITY: 4
|
||||
# @PURPOSE: Core plugin logic with structured logging and scope isolation.
|
||||
# @RELATION: [BINDS_TO] ->[context.logger]
|
||||
@@ -69,7 +69,7 @@ class ExamplePlugin(PluginBase):
|
||||
|
||||
# GRACE: [REFLECT] - Сверка выхода фолбэка
|
||||
logger.reflect("Standalone execution finalized")
|
||||
# [/DEF:execute:Function]
|
||||
# [/DEF:Std.Ai.Execute:Function]
|
||||
|
||||
#[/DEF:ExamplePlugin:Class]
|
||||
# [/DEF:PluginExampleShot:Module]
|
||||
#[/DEF:Std.Ai.ExamplePlugin:Class]
|
||||
# [/DEF:Std.Ai.PluginExampleShot:Module]
|
||||
@@ -1,4 +1,4 @@
|
||||
# [DEF:TrivialUtilityShot:Module]
|
||||
# [DEF:Std.Ai.TrivialUtilityShot:Module]
|
||||
# @COMPLEXITY: 1
|
||||
# @PURPOSE: Reference implementation of a zero-overhead utility using implicit Complexity 1.
|
||||
|
||||
@@ -6,7 +6,7 @@ import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
# [DEF:slugify:Function]
|
||||
# [DEF:Std.Ai.Slugify:Function]
|
||||
# @PURPOSE: Converts a string to a URL-safe slug.
|
||||
def slugify(text: str) -> str:
|
||||
if not text:
|
||||
@@ -14,27 +14,27 @@ def slugify(text: str) -> str:
|
||||
text = text.lower().strip()
|
||||
text = re.sub(r'[^\w\s-]', '', text)
|
||||
return re.sub(r'[-\s]+', '-', text)
|
||||
# [/DEF:slugify:Function]
|
||||
# [/DEF:Std.Ai.Slugify:Function]
|
||||
|
||||
# [DEF:get_utc_now:Function]
|
||||
# [DEF:Std.Ai.GetUtcNow:Function]
|
||||
def get_utc_now() -> datetime:
|
||||
"""Returns current UTC datetime (purpose is omitted because it's obvious)."""
|
||||
return datetime.now(timezone.utc)
|
||||
# [/DEF:get_utc_now:Function]
|
||||
# [/DEF:Std.Ai.GetUtcNow:Function]
|
||||
|
||||
# [DEF:PaginationDTO:Class]
|
||||
# [DEF:Std.Ai.PaginationDTO:Class]
|
||||
class PaginationDTO:
|
||||
# [DEF:__init__:Function]
|
||||
# [DEF:Std.Ai.Init:Function]
|
||||
def __init__(self, page: int = 1, size: int = 50):
|
||||
self.page = max(1, page)
|
||||
self.size = min(max(1, size), 1000)
|
||||
# [/DEF:__init__:Function]
|
||||
# [/DEF:Std.Ai.Init:Function]
|
||||
|
||||
# [DEF:offset:Function]
|
||||
# [DEF:Std.Ai.Offset:Function]
|
||||
@property
|
||||
def offset(self) -> int:
|
||||
return (self.page - 1) * self.size
|
||||
# [/DEF:offset:Function]
|
||||
# [/DEF:PaginationDTO:Class]
|
||||
# [/DEF:Std.Ai.Offset:Function]
|
||||
# [/DEF:Std.Ai.PaginationDTO:Class]
|
||||
|
||||
# [/DEF:TrivialUtilityShot:Module]
|
||||
# [/DEF:Std.Ai.TrivialUtilityShot:Module]
|
||||
@@ -1,17 +1,17 @@
|
||||
# #region AxiomConfig [C:5] [TYPE Block] [SEMANTICS config,axiom,indexing]
|
||||
# #region Config.Axiom [C:5] [TYPE Block] [SEMANTICS config,axiom,indexing]
|
||||
# @BRIEF Axiom engine configuration — anchor format, indexing rules, tag schema.
|
||||
# @RATIONALE Single source of truth for the semantic indexing engine. All descriptions in English per MLA token efficiency. Complexity rules use a global tag catalog rather than per-tier duplication (all tags allowed at all tiers per SSOT protocol).
|
||||
|
||||
# #region AnchorConfig [C:3] [TYPE Block] [SEMANTICS config,anchor]
|
||||
# #region AxiomConfig.AnchorConfig [C:3] [TYPE Block] [SEMANTICS config,anchor]
|
||||
anchor:
|
||||
format: region
|
||||
overrides:
|
||||
docs/: brace
|
||||
specs/: brace
|
||||
syntax: {}
|
||||
# #endregion AnchorConfig
|
||||
# #endregion AxiomConfig.AnchorConfig
|
||||
|
||||
# #region IndexingConfig [C:3] [TYPE Block] [SEMANTICS config,indexing]
|
||||
# #region AxiomConfig.IndexingConfig [C:3] [TYPE Block] [SEMANTICS config,indexing]
|
||||
indexing:
|
||||
include: []
|
||||
exclude:
|
||||
@@ -42,9 +42,9 @@ indexing:
|
||||
- .opencode/command
|
||||
- .specify/memory
|
||||
- .specify/templates
|
||||
# #endregion IndexingConfig
|
||||
# #endregion AxiomConfig.IndexingConfig
|
||||
|
||||
# #region GlobalTagCatalog [C:5] [TYPE Block] [SEMANTICS config,tags,global]
|
||||
# #region AxiomConfig.GlobalTagCatalog [C:5] [TYPE Block] [SEMANTICS config,tags,global]
|
||||
# @BRIEF All recognized @-tags — informational, allowed at any tier (C1-C5) per SSOT protocol.
|
||||
# @INVARIANT Every tag in this catalog has a definition. No tag is forbidden at any tier.
|
||||
# @RATIONALE Per-tier duplication eliminated — tiers are descriptive, not gatekeeping.
|
||||
@@ -100,9 +100,9 @@ global_tags:
|
||||
- INVARIANT_VIOLATION
|
||||
- VALIDATION
|
||||
- TEST_DATA
|
||||
# #endregion GlobalTagCatalog
|
||||
# #endregion AxiomConfig.GlobalTagCatalog
|
||||
|
||||
# #region TagSchema [C:5] [TYPE Block] [SEMANTICS config,tags,schema]
|
||||
# #region AxiomConfig.TagSchema [C:5] [TYPE Block] [SEMANTICS config,tags,schema]
|
||||
tags:
|
||||
C:
|
||||
type: string
|
||||
@@ -376,9 +376,9 @@ tags:
|
||||
multiline: false
|
||||
description: 'Public API surface — which classes/functions are entry points.'
|
||||
orthogonal: true
|
||||
# #endregion TagSchema
|
||||
# #endregion AxiomConfig.TagSchema
|
||||
|
||||
# #region InfrastructureConfig [C:2] [TYPE Block] [SEMANTICS config,embedding,http]
|
||||
# #region AxiomConfig.InfrastructureConfig [C:2] [TYPE Block] [SEMANTICS config,embedding,http]
|
||||
embedding: null
|
||||
http_api:
|
||||
http_enabled: false
|
||||
@@ -389,9 +389,9 @@ doc_mode: null
|
||||
doc_tag_mapping: null
|
||||
doc_stripped_output: null
|
||||
doc_symbol_types: null
|
||||
# #endregion InfrastructureConfig
|
||||
# #endregion AxiomConfig.InfrastructureConfig
|
||||
|
||||
# #region ComplexityRules [C:2] [TYPE Block] [SEMANTICS config,complexity,rules]
|
||||
# #region AxiomConfig.ComplexityRules [C:2] [TYPE Block] [SEMANTICS config,complexity,rules]
|
||||
# @BRIEF Per-tier tag requirements from GRACE-Poly SSOT. All tags allowed everywhere,
|
||||
# but C4+ require formal contract annotations.
|
||||
complexity_rules:
|
||||
@@ -400,15 +400,15 @@ complexity_rules:
|
||||
"5":
|
||||
required: [PRE, POST, SIDE_EFFECT, DATA_CONTRACT, INVARIANT]
|
||||
|
||||
# #endregion ComplexityRules
|
||||
# #endregion AxiomConfig.ComplexityRules
|
||||
|
||||
# #region TierThresholds [C:2] [TYPE Block] [SEMANTICS config,tiers,thresholds]
|
||||
# #region AxiomConfig.TierThresholds [C:2] [TYPE Block] [SEMANTICS config,tiers,thresholds]
|
||||
tier_thresholds:
|
||||
TIER_1: 1
|
||||
TIER_2: 2
|
||||
TIER_3: 3
|
||||
TIER_4: 4
|
||||
TIER_5: 5
|
||||
# #endregion TierThresholds
|
||||
# #endregion AxiomConfig.TierThresholds
|
||||
|
||||
# #endregion AxiomConfig
|
||||
# #endregion Config.Axiom
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
# #region env.enterprise-clean [C:2] [TYPE Module] [SEMANTICS env,docker,enterprise]
|
||||
# @BRIEF Переменные окружения для docker-compose.enterprise-clean.yml.
|
||||
# Сервисы собираются из исходников — не требуют pre-built images.
|
||||
# Используется внешний PostgreSQL (корпоративный).
|
||||
# Сервисы собираются из исходников или загружаются из pre-built .tar.xz.
|
||||
# PostgreSQL запускается в контейнере (сервис db).
|
||||
# @LAYER Infrastructure
|
||||
# @RELATION DEPENDS_ON -> [docker-compose.enterprise-clean.yml]
|
||||
# #endregion env.enterprise-clean
|
||||
|
||||
# ======================================================================
|
||||
# PostgreSQL (внешний, корпоративный) — ОБЯЗАТЕЛЬНО
|
||||
# PostgreSQL (контейнер) — настройки встроенной БД
|
||||
# ======================================================================
|
||||
POSTGRES_HOST=postgres.company.local
|
||||
# Для использования внешнего PostgreSQL — переопределите POSTGRES_HOST
|
||||
# и удалите/закомментируйте сервис db в docker-compose.enterprise-clean.yml.
|
||||
POSTGRES_HOST=db
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=ss_tools
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=change-me
|
||||
POSTGRES_HOST_PORT=5432
|
||||
|
||||
# ======================================================================
|
||||
# Порты хоста
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -39,6 +39,7 @@ dist/
|
||||
!.env.example
|
||||
config.json
|
||||
package-lock.json
|
||||
package.json
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
@@ -198,6 +198,7 @@ Generate its full `#region` header in `contracts/modules.md` under its parent mo
|
||||
# @BRIEF One-line purpose.
|
||||
# @RELATION DEPENDS_ON -> [DependencyService]
|
||||
# @RELATION DEPENDS_ON -> [DTO:RequestSchema]
|
||||
# #endregion Domain.Resource.Action
|
||||
```
|
||||
|
||||
**Full header for C4/C5 orchestration & cross-stack functions:**
|
||||
@@ -215,6 +216,7 @@ Generate its full `#region` header in `contracts/modules.md` under its parent mo
|
||||
# @RATIONALE Why this implementation approach.
|
||||
# @REJECTED What alternative was considered and forbidden.
|
||||
# @TEST_EDGE: scenario_name -> Expected failure behavior.
|
||||
# #endregion Domain.Resource.Action
|
||||
```
|
||||
|
||||
**Screen Model actions (Svelte `.svelte.ts`):**
|
||||
@@ -227,6 +229,7 @@ Generate its full `#region` header in `contracts/modules.md` under its parent mo
|
||||
// @SIDE_EFFECT API call, store mutation, model state update.
|
||||
// @RELATION CALLS -> [apiClient]
|
||||
// @TEST_EDGE: network_failure -> ScreenState = "error"
|
||||
// #endregion ScreenModel.actionName
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
@@ -264,7 +267,7 @@ specs/<feature>/fixtures/
|
||||
**`manifest.md` — fixture index with GRACE contracts:**
|
||||
|
||||
```markdown
|
||||
#region FixtureManifest [C:3] [TYPE ADR] [SEMANTICS test,fixture,[DOMAIN]]
|
||||
#region Example.Fixturemanifest [C:3] [TYPE ADR] [SEMANTICS test,fixture,[DOMAIN]]
|
||||
@defgroup Fixtures Canonical test fixtures for [FEATURE].
|
||||
|
||||
## @{ Fixture FX_Auth.Login.Valid [C:2] [TYPE Block] [SEMANTICS test,auth,fixture]
|
||||
@@ -287,6 +290,10 @@ specs/<feature>/fixtures/
|
||||
@TEST_INVARIANT: env_reset_selection -> VERIFIED_BY: [Test.Migration.Model]
|
||||
@TEST_FIXTURE: env_reset -> fixtures/model/migration_env_reset.json
|
||||
## @} Fixture FX_Migration.EnvReset
|
||||
## @} FX_Migration.EnvReset
|
||||
## @} FX_Auth.Login.MissingPassword
|
||||
## @} FX_Auth.Login.Valid
|
||||
# #endregion Example.Fixturemanifest
|
||||
```
|
||||
|
||||
**JSON fixture format:**
|
||||
@@ -341,7 +348,7 @@ Generate `quickstart.md` using real repository verification paths:
|
||||
If UX contracts exist (`contracts/ux/` was generated by `/speckit.ux`), generate `traceability.md` — a requirements traceability matrix (RTM) mapping every user story through its implementation chain:
|
||||
|
||||
```markdown
|
||||
#region Traceability [C:3] [TYPE ADR] [SEMANTICS traceability,rtm,[DOMAIN]]
|
||||
#region Std.Kilo.Traceability [C:3] [TYPE ADR] [SEMANTICS traceability,rtm,[DOMAIN]]
|
||||
@defgroup Trace Matrix Requirements → Model → API → Task → Test for [FEATURE].
|
||||
|
||||
## Traceability Matrix
|
||||
@@ -358,7 +365,7 @@ If UX contracts exist (`contracts/ux/` was generated by `/speckit.ux`), generate
|
||||
| `GET /api/dashboards` | FX_Dashboards.Hub.* | Test.Dashboards.Hub | /dashboards, /migration |
|
||||
| `Dashboards.Hub` model | FX_Dashboards.EnvReset | Test.Dashboards.Hub | /dashboards |
|
||||
|
||||
#endregion Traceability
|
||||
#endregion Std.Kilo.Traceability
|
||||
```
|
||||
|
||||
**Generation rules:**
|
||||
|
||||
@@ -200,7 +200,7 @@ After all questions are answered, create TWO artifacts:
|
||||
**`contracts/ux/alternatives.md`** — all options considered, BEFORE final choice:
|
||||
|
||||
```markdown
|
||||
#region UxAlternatives [C:3] [TYPE ADR] [SEMANTICS ux,alternatives,[DOMAIN]]
|
||||
#region Std.Kilo.UxAlternatives [C:3] [TYPE ADR] [SEMANTICS ux,alternatives,[DOMAIN]]
|
||||
@defgroup Ux Design alternatives explored for [FEATURE].
|
||||
|
||||
## Screen: [Name]
|
||||
@@ -225,13 +225,13 @@ After all questions are answered, create TWO artifacts:
|
||||
- ❌ Rejected: Confirm dialog — extra click on every action, annoying at scale
|
||||
- ❌ Rejected: No confirmation — dangerous for delete/migrate
|
||||
|
||||
#endregion UxAlternatives
|
||||
#endregion Std.Kilo.UxAlternatives
|
||||
```
|
||||
|
||||
**`contracts/ux/decisions.md`** — only the final choices:
|
||||
|
||||
```markdown
|
||||
#region UxDecisions [C:3] [TYPE ADR] [SEMANTICS ux,decisions,[DOMAIN]]
|
||||
#region Std.Kilo.UxDecisions [C:3] [TYPE ADR] [SEMANTICS ux,decisions,[DOMAIN]]
|
||||
@defgroup Ux Final UX design decisions for [FEATURE].
|
||||
|
||||
## Screen: [Name]
|
||||
@@ -240,7 +240,7 @@ After all questions are answered, create TWO artifacts:
|
||||
- Data: Paginated (20/page) + search
|
||||
- Feedback: Undo toast (5s) for destructive actions
|
||||
|
||||
#endregion UxDecisions
|
||||
#endregion Std.Kilo.UxDecisions
|
||||
```
|
||||
|
||||
**Rule:** `alternatives.md` shows the DESIGN SPACE — agent can see WHY each path was rejected. `decisions.md` is the compact reference for `/speckit.plan`.
|
||||
|
||||
@@ -3,7 +3,7 @@ name: molecular-cot-logging
|
||||
description: Structured logging protocol for agent-driven development, based on molecular Long CoT bonds (REASON/REFLECT/EXPLORE). Replaces legacy Entry/Exit/Coherence markers. Python + Svelte.
|
||||
---
|
||||
|
||||
#region MolecularCoTLogging [C:5] [TYPE Skill] [SEMANTICS reasoning,runtime,logging,agentic]
|
||||
#region Std.Kilo.MolecularCoTLogging [C:5] [TYPE Skill] [SEMANTICS reasoning,runtime,logging,agentic]
|
||||
@BRIEF Structured logging protocol for agent-driven development, based on molecular Long CoT bonds (Deep-Reasoning, Self-Reflection, Self-Exploration). Replaces legacy Entry/Exit/Coherence markers.
|
||||
@RELATION DEPENDS_ON -> [Std.Semantics.Core]
|
||||
@RELATION DISPATCHES -> [Std.Semantics.Python]
|
||||
@@ -303,4 +303,4 @@ for line in sys.stdin:
|
||||
| Logging raw passwords or tokens in `payload` | Always sanitise sensitive data |
|
||||
| Spread markers across multiple modules without trace_id | Always propagate `trace_id` |
|
||||
|
||||
#endregion MolecularCoTLogging
|
||||
#endregion Std.Kilo.MolecularCoTLogging
|
||||
|
||||
@@ -3,10 +3,10 @@ name: semantics-frontend
|
||||
description: Core protocol for Svelte 5 (Runes) Components, UX State Machines, and Visual-Interactive Validation.
|
||||
---
|
||||
|
||||
# [DEF:Std:Semantics:Frontend]
|
||||
# [DEF:Std.Kilo.Std:Semantics:Frontend]
|
||||
# @COMPLEXITY: 5
|
||||
# @PURPOSE: Canonical GRACE-Poly protocol for Svelte 5 (Runes) Components, UX State Machines, and Project UI Architecture.
|
||||
# @RELATION: DEPENDS_ON ->[Std:Semantics:Core]
|
||||
# @RELATION: DEPENDS_ON ->[Std.Kilo.Std:Semantics:Core]
|
||||
# @INVARIANT: Frontend components MUST be verifiable by an automated GUI Judge Agent (e.g., Playwright).
|
||||
# @INVARIANT: Use Tailwind CSS exclusively. Native `fetch` is forbidden.
|
||||
|
||||
@@ -55,7 +55,8 @@ Frontend logging bridges the gap between your logic and the Judge Agent's vision
|
||||
You MUST strictly adhere to this AST boundary format:
|
||||
|
||||
```html
|
||||
<!-- [DEF:ComponentName:Component] -->
|
||||
# [/DEF:Std.Kilo.Std:Semantics:Frontend]
|
||||
<!-- [DEF:Std.Kilo.ComponentName:Component] -->
|
||||
<script>
|
||||
/**
|
||||
* @COMPLEXITY: [1-5]
|
||||
@@ -104,4 +105,4 @@ You MUST strictly adhere to this AST boundary format:
|
||||
{$t('actions.start')}
|
||||
</button>
|
||||
</div>
|
||||
<!--[/DEF:ComponentName:Component] -->
|
||||
<!--[/DEF:Std.Kilo.ComponentName:Component] -->
|
||||
@@ -3,10 +3,10 @@ name: semantics-belief
|
||||
description: Core protocol for Thread-Local Belief State, Runtime Chain-of-Thought (CoT), and Interleaved Thinking in Python.
|
||||
---
|
||||
|
||||
# [DEF:Std:Semantics:Belief]
|
||||
# [DEF:Std.Kilo.Std:Semantics:Belief]
|
||||
# @COMPLEXITY: 5
|
||||
# @PURPOSE: Core protocol for Thread-Local Belief State, Runtime Chain-of-Thought (CoT), and Interleaved Thinking in Python.
|
||||
# @RELATION: DEPENDS_ON -> [Std:Semantics:Core]
|
||||
# @RELATION: DEPENDS_ON -> [Std.Kilo.Std:Semantics:Core]
|
||||
# @INVARIANT: Implementation of C4/C5 complexity nodes MUST emit reasoning via semantic logger methods before mutating state or returning.
|
||||
|
||||
## 0. INTERLEAVED THINKING (GLM-5 PARADIGM)
|
||||
@@ -53,5 +53,5 @@ If your execution path triggers a `logger.explore()` due to a broken assumption
|
||||
**YOU MUST ASCEND TO THE `[DEF]` HEADER AND DOCUMENT IT.**
|
||||
You must add `@RATIONALE: [Why you did this]` and `@REJECTED:[The path that failed during explore()]`.
|
||||
Failure to link a runtime `explore` to a static `@REJECTED` tag is a fatal protocol violation that causes amnesia for future agents.
|
||||
# [/DEF:Std:Semantics:Belief]
|
||||
# [/DEF:Std.Kilo.Std:Semantics:Belief]
|
||||
**[SYSTEM: END OF BELIEF DIRECTIVE. ENFORCE STRICT RUNTIME CoT.]**
|
||||
@@ -97,18 +97,18 @@ Not all GRACE tags are equal in the model's training data. Understanding which t
|
||||
|
||||
### Legacy — DEF (permanently recognized)
|
||||
```python
|
||||
// [DEF:ContractId:Type]
|
||||
// [DEF:Std.Kilo.ContractId:Type]
|
||||
// @TAG: value
|
||||
<code>
|
||||
// [/DEF:ContractId:Type]
|
||||
// [/DEF:Std.Kilo.ContractId:Type]
|
||||
```
|
||||
|
||||
### Doc — Brace (Markdown, specs, ADRs)
|
||||
```
|
||||
## @{ ContractId [C:N] [TYPE TypeName]
|
||||
## @{ Std.Kilo.ContractId [C:N] [TYPE TypeName]
|
||||
@BRIEF Description
|
||||
...
|
||||
## @} ContractId
|
||||
## @} Std.Kilo.ContractId
|
||||
```
|
||||
|
||||
**Allowed Types:** Module, Function, Class, Component, Model, Block, ADR, Tombstone, Skill, Agent.
|
||||
@@ -260,6 +260,7 @@ The opening anchor MUST pack maximum signal into one line:
|
||||
|
||||
```
|
||||
#region Domain.Sub.ContractId [C:N] [TYPE TypeName] [SEMANTICS tag1,tag2,tag3]
|
||||
# #endregion Domain.Sub.ContractId
|
||||
```
|
||||
|
||||
- ID, complexity, type, and semantic tags on ONE line → survives CSA 4× pooling as a single KV record.
|
||||
@@ -303,6 +304,7 @@ Example — both mechanisms reinforce each other:
|
||||
#region Core.Auth.Login [C:4] [TYPE Function] [SEMANTICS auth,login,token]
|
||||
# @ingroup Auth
|
||||
# @BRIEF Authenticate user by credentials.
|
||||
# #endregion Core.Auth.Login
|
||||
```
|
||||
|
||||
**Rule:** Identical domain = identical primary keyword in `[SEMANTICS ...]` AND identical `@ingroup Domain`. They target different compression layers (CSA vs HCA) and don't conflict — the keyword repetition amplifies the DSA score.
|
||||
|
||||
@@ -7,7 +7,7 @@ description: Python-specific GRACE-Poly protocol: few-shot complexity examples,
|
||||
@BRIEF Python-specific HOW: few-shot complexity examples, belief runtime patterns, module decomposition, and FastAPI/SQLAlchemy conventions for the GRACE-Poly protocol in superset-tools.
|
||||
@RELATION DEPENDS_ON -> [Std.Semantics.Core]
|
||||
@RELATION DEPENDS_ON -> [Std.Semantics.Contracts]
|
||||
@RELATION DISPATCHES -> [MolecularCoTLogging]
|
||||
@RELATION DISPATCHES -> [Std.Kilo.MolecularCoTLogging]
|
||||
@RESTRICTION EXAMPLES ONLY — this file provides language-specific code patterns. All protocol rules (tier definitions, tag catalog, anchor syntax) are defined exclusively in `semantics-core`. This file MUST NOT redefine or contradict any rule from `semantics-core`.
|
||||
@RATIONALE Python's async/await model, FastAPI dependency injection, and SQLAlchemy session management create unique failure modes for Transformer agents: (1) async/await boundary confusion — agents write sync code in async contexts or forget `await` on ORM calls, producing silent no-ops; (2) dependency injection blindness — FastAPI's `Depends()` creates implicit call graphs that the agent's attention cannot trace without explicit @RELATION edges; (3) session lifecycle drift — SQLAlchemy sessions have strict boundaries that agents violate by passing detached objects across function calls. Concrete examples at each complexity tier act as few-shot anchors that override the agent's pre-trained (and often wrong) Python patterns.
|
||||
@REJECTED Generic Python patterns without GRACE anchors were rejected — agents produce working code that violates module size limits (INV_7), omits belief runtime markers, and creates orphan contracts invisible to the semantic index. Relying on the agent's pre-trained FastAPI/SQLAlchemy knowledge without project-specific examples was rejected — superset-tools has specific conventions (trace_id propagation, plugin architecture, WebSocket logging) that general training data cannot capture.
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Svelte 5 (Runes) protocol for superset-tools: UX State Machines, Ta
|
||||
#region Std.Semantics.Svelte [C:5] [TYPE Skill] [SEMANTICS frontend,svelte,ui,ux,tailwind]
|
||||
@BRIEF HOW to build Svelte 5 (Runes) Components for superset-tools with UX State Machines, Tailwind CSS, store topology, and visual-interactive validation.
|
||||
@RELATION DEPENDS_ON -> [Std.Semantics.Core]
|
||||
@RELATION DEPENDS_ON -> [MolecularCoTLogging]
|
||||
@RELATION DEPENDS_ON -> [Std.Kilo.MolecularCoTLogging]
|
||||
@RELATION DISPATCHES -> [Std.Semantics.Testing]
|
||||
@RESTRICTION EXAMPLES ONLY — this file provides language-specific code patterns. All protocol rules (tier definitions, tag catalog, anchor syntax) are defined exclusively in `semantics-core`. UX contract tags are defined here as examples; the tag catalog lives in `semantics-core` §III. This file MUST NOT redefine or contradict any rule from `semantics-core`.
|
||||
@RATIONALE Svelte 5 runes ($state, $derived, $effect, $props) chosen for reactive precision and native compiler optimisations over Svelte 4 legacy reactivity ($:). Tailwind CSS selected for zero-runtime utility-first styling and rapid visual validation via chrome-devtools MCP. FSM-based UX contracts (@UX_STATE, @UX_FEEDBACK, @UX_RECOVERY) chosen to create verifiable state-transition tests that the browser Judge Agent can execute deterministically. superset-tools internal API wrappers (fetchApi/requestApi) chosen over native fetch to enforce auth, error normalisation, and trace_id propagation. Model-first architecture chosen because event-handler spaghetti is the #1 Transformer failure mode in UI code: the agent scatters logic across onclick/onchange in 5 files — KV-cache cannot hold cross-component relationships, creating invisible coupling that breaks silently.
|
||||
@@ -309,7 +309,7 @@ Frontend logging uses `log()` from `$lib/cot-logger` per **MolecularCoTLogging**
|
||||
Region format for HTML/Svelte comments:
|
||||
|
||||
```html
|
||||
<!-- #region MigrationTaskCard [C:3] [TYPE Component] [SEMANTICS ui,migration,task] -->
|
||||
<!-- #region Std.Semantics.MigrationTaskCard [C:3] [TYPE Component] [SEMANTICS ui,migration,task] -->
|
||||
<!-- @BRIEF Card displaying a migration task with status, progress, and action buttons. -->
|
||||
<!-- @LAYER UI -->
|
||||
<!-- @RELATION DEPENDS_ON -> [StatusBadge] -->
|
||||
@@ -410,7 +410,7 @@ Region format for HTML/Svelte comments:
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion MigrationTaskCard -->
|
||||
<!-- #endregion Std.Semantics.MigrationTaskCard -->
|
||||
```
|
||||
|
||||
## VII. SS-TOOLS DESIGN TOKEN CANON & COMPONENT REUSE
|
||||
|
||||
@@ -90,7 +90,7 @@ from unittest.mock import AsyncMock, patch
|
||||
class TestDashboardMigration:
|
||||
"""Verify migrate_dashboard @POST guarantees."""
|
||||
|
||||
# #region test_migrate_dashboard_success [C:2] [TYPE Function]
|
||||
# #region Std.Semantics.TestMigrateDashboardSuccess [C:2] [TYPE Function]
|
||||
# @BRIEF Happy path: valid dashboard with complete db mapping.
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_dashboard_success(self):
|
||||
@@ -98,8 +98,9 @@ class TestDashboardMigration:
|
||||
expected = {"id": "dash_1", "status": "imported"}
|
||||
# ... test implementation
|
||||
pass
|
||||
# #endregion test_migrate_dashboard_success
|
||||
# #endregion Std.Semantics.TestMigrateDashboardSuccess
|
||||
# #endregion TestDashboardMigration
|
||||
# #endregion Test.Migration.RunTask
|
||||
```
|
||||
|
||||
### Running tests
|
||||
|
||||
@@ -123,7 +123,7 @@ For Svelte components with `@UX_STATE`, `@UX_FEEDBACK`, `@UX_RECOVERY` tags:
|
||||
|
||||
**UX Test Template:**
|
||||
```javascript
|
||||
// [DEF:ComponentUXTests:Module]
|
||||
// [DEF:Example.Componentuxtests:Module]
|
||||
// @C: 3
|
||||
// @RELATION: VERIFIES -> ../Component.svelte
|
||||
// @PURPOSE: Test UX states and transitions
|
||||
@@ -139,6 +139,7 @@ describe('Component UX States', () => {
|
||||
it('should allow retry on error', async () => { ... });
|
||||
});
|
||||
// [/DEF:__tests__/test_Component:Module]
|
||||
// [/DEF:Example.Componentuxtests:Module]
|
||||
```
|
||||
|
||||
### 5. Test Documentation
|
||||
|
||||
@@ -40,12 +40,13 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
- Source paths: `backend/src/**/*.py` and `frontend/src/**/*.svelte`.
|
||||
- Active feature docs always live under `specs/<feature>/...` and are discovered via the `.specify/scripts/bash/*` helpers.
|
||||
- Default verification stack:
|
||||
- Backend: `cd backend && source .venv/bin/activate && python -m pytest -v`
|
||||
- Backend lint: `cd backend && python -m ruff check .`
|
||||
- Frontend lint: `cd frontend && npm run lint`
|
||||
- Frontend: `cd frontend && npm run test`
|
||||
- Frontend build: `cd frontend && npm run build`
|
||||
- Default verification stack (all timeout-protected via root Makefile):
|
||||
- `make test-unit` — backend unit tests (SQLite, <120s)
|
||||
- `make test-frontend` — frontend vitest tests
|
||||
- `make lint` — ruff + eslint
|
||||
- `cd frontend && npm run build` — production build check
|
||||
- `make coverage` — coverage reports (optional, run after tests pass)
|
||||
- `make test-related F=path/to/changed_file.py` — smart selection for narrow scopes
|
||||
- Do not fall back to Rust `cargo`/`src/server/` conventions — this is a Python/Svelte project.
|
||||
|
||||
## Semantic Execution Rules
|
||||
|
||||
@@ -198,6 +198,7 @@ Generate its full `#region` header in `contracts/modules.md` under its parent mo
|
||||
# @BRIEF One-line purpose.
|
||||
# @RELATION DEPENDS_ON -> [DependencyService]
|
||||
# @RELATION DEPENDS_ON -> [DTO:RequestSchema]
|
||||
# #endregion Domain.Resource.Action
|
||||
```
|
||||
|
||||
**Full header for C4/C5 orchestration & cross-stack functions:**
|
||||
@@ -215,6 +216,7 @@ Generate its full `#region` header in `contracts/modules.md` under its parent mo
|
||||
# @RATIONALE Why this implementation approach.
|
||||
# @REJECTED What alternative was considered and forbidden.
|
||||
# @TEST_EDGE: scenario_name -> Expected failure behavior.
|
||||
# #endregion Domain.Resource.Action
|
||||
```
|
||||
|
||||
**Screen Model actions (Svelte `.svelte.ts`):**
|
||||
@@ -227,6 +229,7 @@ Generate its full `#region` header in `contracts/modules.md` under its parent mo
|
||||
// @SIDE_EFFECT API call, store mutation, model state update.
|
||||
// @RELATION CALLS -> [apiClient]
|
||||
// @TEST_EDGE: network_failure -> ScreenState = "error"
|
||||
// #endregion ScreenModel.actionName
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
@@ -264,7 +267,7 @@ specs/<feature>/fixtures/
|
||||
**`manifest.md` — fixture index with GRACE contracts:**
|
||||
|
||||
```markdown
|
||||
#region FixtureManifest [C:3] [TYPE ADR] [SEMANTICS test,fixture,[DOMAIN]]
|
||||
#region Example.Fixturemanifest [C:3] [TYPE ADR] [SEMANTICS test,fixture,[DOMAIN]]
|
||||
@defgroup Fixtures Canonical test fixtures for [FEATURE].
|
||||
|
||||
## @{ Fixture FX_Auth.Login.Valid [C:2] [TYPE Block] [SEMANTICS test,auth,fixture]
|
||||
@@ -287,6 +290,10 @@ specs/<feature>/fixtures/
|
||||
@TEST_INVARIANT: env_reset_selection -> VERIFIED_BY: [Test.Migration.Model]
|
||||
@TEST_FIXTURE: env_reset -> fixtures/model/migration_env_reset.json
|
||||
## @} Fixture FX_Migration.EnvReset
|
||||
## @} FX_Migration.EnvReset
|
||||
## @} FX_Auth.Login.MissingPassword
|
||||
## @} FX_Auth.Login.Valid
|
||||
# #endregion Example.Fixturemanifest
|
||||
```
|
||||
|
||||
**JSON fixture format:**
|
||||
@@ -329,19 +336,36 @@ Extend `traceability.md` with a Fixture column:
|
||||
|
||||
### Quickstart Output
|
||||
|
||||
Generate `quickstart.md` using real repository verification paths:
|
||||
- Backend: `cd backend && source .venv/bin/activate && python -m pytest -v`
|
||||
- Frontend: `cd frontend && npm run test`
|
||||
- Lint: `cd backend && python -m ruff check .`
|
||||
- Frontend lint: `cd frontend && npm run lint`
|
||||
- Docker: `docker compose up --build`
|
||||
Generate `quickstart.md` using real repository verification paths via the root Makefile (timeout-protected, tiered):
|
||||
|
||||
```bash
|
||||
# Tier 1: Fast unit tests (<120s, no Docker)
|
||||
make test # backend + frontend unit tests
|
||||
make test-unit # backend SQLite tests only
|
||||
make test-frontend # frontend vitest tests only
|
||||
|
||||
# Tier 2: Smart selection
|
||||
make test-related F=backend/src/path/to/file.py # only tests linked via @RELATION BINDS_TO
|
||||
|
||||
# Tier 3: Integration tests (Docker required, <600s)
|
||||
make test-integration
|
||||
|
||||
# Coverage
|
||||
make coverage # backend pytest-cov + frontend vitest v8
|
||||
|
||||
# Linting
|
||||
make lint # ruff + eslint
|
||||
|
||||
# Docker
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
### Traceability Matrix Output
|
||||
|
||||
If UX contracts exist (`contracts/ux/` was generated by `/speckit.ux`), generate `traceability.md` — a requirements traceability matrix (RTM) mapping every user story through its implementation chain:
|
||||
|
||||
```markdown
|
||||
#region Traceability [C:3] [TYPE ADR] [SEMANTICS traceability,rtm,[DOMAIN]]
|
||||
#region Std.Opencode.Traceability [C:3] [TYPE ADR] [SEMANTICS traceability,rtm,[DOMAIN]]
|
||||
@defgroup Trace Matrix Requirements → Model → API → Task → Test for [FEATURE].
|
||||
|
||||
## Traceability Matrix
|
||||
@@ -358,7 +382,7 @@ If UX contracts exist (`contracts/ux/` was generated by `/speckit.ux`), generate
|
||||
| `GET /api/dashboards` | FX_Dashboards.Hub.* | Test.Dashboards.Hub | /dashboards, /migration |
|
||||
| `Dashboards.Hub` model | FX_Dashboards.EnvReset | Test.Dashboards.Hub | /dashboards |
|
||||
|
||||
#endregion Traceability
|
||||
#endregion Std.Opencode.Traceability
|
||||
```
|
||||
|
||||
**Generation rules:**
|
||||
|
||||
@@ -98,12 +98,13 @@ Each story phase must end with:
|
||||
- a verification task against `ux_reference.md` interpreted as the operator/caller interaction contract
|
||||
- a semantic audit / verification task tied to repository validators and touched contracts
|
||||
|
||||
Typical verification tasks may include:
|
||||
- `cd backend && source .venv/bin/activate && python -m pytest backend/tests/test_*.py -v`
|
||||
- `cd backend && python -m ruff check .`
|
||||
- `cd frontend && npm run lint`
|
||||
- `cd frontend && npm run test`
|
||||
- `cd frontend && npm run build`
|
||||
Typical verification tasks may include (all timeout-protected via root Makefile):
|
||||
- `make test-unit` — backend unit tests (SQLite, no Docker, <120s)
|
||||
- `make test-frontend` — frontend vitest tests
|
||||
- `make test-related F=path/to/changed_file.py` — smart selection via @RELATION BINDS_TO
|
||||
- `make lint` — ruff + eslint
|
||||
- `make coverage` — backend + frontend coverage reports
|
||||
- `cd frontend && npm run build` — production build check
|
||||
|
||||
Only include the commands that are truly required by the feature scope.
|
||||
|
||||
|
||||
@@ -21,9 +21,15 @@ Run the full verification loop for the touched superset-tools scope:
|
||||
1. **Mocking discipline audit** — scan every test file in scope, classify every mock/spy/stub/patch, flag violations
|
||||
2. **Auto-fix violations** — correct SUT mocks and Logic Mirrors (no flag needed; fix by default)
|
||||
3. **Semantic audit** — contract density, belief runtime, rejected-path regression
|
||||
4. **Executable tests** — run pytest + vitest + lint
|
||||
4. **Executable tests** — run pytest + vitest + lint via `make test` (tiered, timeout-protected)
|
||||
5. **Documentation** — mock audit report + coverage summary + ADR guardrail status
|
||||
|
||||
**When to use `/speckit.test` vs `/test.*`:** Use this command for COMPREHENSIVE audit (mocking + semantic + execution) on a feature batch. For quick verify loops during development (just run tests, no audit), use the lightweight alternatives:
|
||||
- `/test.unit` — fast unit tests (backend + frontend, <30s)
|
||||
- `/test.related` — only tests linked to a changed file via `@RELATION BINDS_TO`
|
||||
- `/test.coverage` — coverage reports only
|
||||
- `/test.all` — full suite + coverage
|
||||
|
||||
## Operating Constraints
|
||||
|
||||
### Golden Rules (from `semantics-testing` skill)
|
||||
@@ -221,20 +227,35 @@ For UI features, use browser validation via `chrome-devtools` MCP.
|
||||
|
||||
### 8. Execute Verifiers
|
||||
|
||||
Run the full verification stack for the touched scope:
|
||||
Run the full verification stack for the touched scope. The project Makefile provides tiered targets with built-in timeout protection:
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend && source .venv/bin/activate && python -m pytest -v
|
||||
python -m ruff check backend/src/ backend/tests/
|
||||
# Tier 1: Fast unit tests (no Docker, <120s timeout)
|
||||
make test-unit # backend SQLite tests
|
||||
make test-frontend # frontend vitest tests
|
||||
|
||||
# Frontend
|
||||
cd frontend && npm run test
|
||||
npm run lint
|
||||
npm run build
|
||||
# Tier 1 alt: Smart test selection (only tests related to changed files)
|
||||
make test-related F=backend/src/path/to/changed_file.py
|
||||
|
||||
# Linting (ruff + eslint)
|
||||
make lint
|
||||
|
||||
# Coverage (optional — run after tests pass)
|
||||
make coverage
|
||||
|
||||
# Tier 2: Integration tests (requires Docker, <600s timeout)
|
||||
# Only when the scope includes integration boundaries
|
||||
make test-integration
|
||||
|
||||
# Full suite (unit + integration + coverage)
|
||||
make test-all
|
||||
```
|
||||
|
||||
Use narrower test runs when sufficient, then widen verification when finalizing.
|
||||
**Timeout safety**: All `make test-*` targets are wrapped with `timeout N` shell guards. Unit tests have 120s; integration tests have 600s. The agent NEVER hangs on a hung test.
|
||||
|
||||
**Narrow-first principle**: Start with `make test-unit` for backend changes, `make test-frontend` for frontend changes. Use `make test-related F=<file>` to run only semantically-linked tests. Widen to `make test` (both layers) when finalizing.
|
||||
|
||||
**When to run integration tests**: Only when the scope includes files under `backend/tests/integration/` or when the change touches Docker/testcontainers fixtures. Otherwise, skip.
|
||||
|
||||
### 9. Test Documentation
|
||||
|
||||
@@ -326,9 +347,9 @@ Produce a single Markdown test report containing all of the following sections:
|
||||
```
|
||||
|
||||
### 2. Coverage Summary
|
||||
- Commands executed
|
||||
- Commands executed: `make coverage` (backend pytest-cov + frontend vitest v8)
|
||||
- Pass/fail counts per layer
|
||||
- Coverage percentage (if available)
|
||||
- Coverage percentage: backend statement/line %, frontend statement/line/function/branch % with threshold comparison
|
||||
|
||||
### 3. Semantic Audit Verdict
|
||||
- Contract density check results
|
||||
|
||||
@@ -200,7 +200,7 @@ After all questions are answered, create TWO artifacts:
|
||||
**`contracts/ux/alternatives.md`** — all options considered, BEFORE final choice:
|
||||
|
||||
```markdown
|
||||
#region UxAlternatives [C:3] [TYPE ADR] [SEMANTICS ux,alternatives,[DOMAIN]]
|
||||
#region Std.Opencode.UxAlternatives [C:3] [TYPE ADR] [SEMANTICS ux,alternatives,[DOMAIN]]
|
||||
@defgroup Ux Design alternatives explored for [FEATURE].
|
||||
|
||||
## Screen: [Name]
|
||||
@@ -225,13 +225,13 @@ After all questions are answered, create TWO artifacts:
|
||||
- ❌ Rejected: Confirm dialog — extra click on every action, annoying at scale
|
||||
- ❌ Rejected: No confirmation — dangerous for delete/migrate
|
||||
|
||||
#endregion UxAlternatives
|
||||
#endregion Std.Opencode.UxAlternatives
|
||||
```
|
||||
|
||||
**`contracts/ux/decisions.md`** — only the final choices:
|
||||
|
||||
```markdown
|
||||
#region UxDecisions [C:3] [TYPE ADR] [SEMANTICS ux,decisions,[DOMAIN]]
|
||||
#region Std.Opencode.UxDecisions [C:3] [TYPE ADR] [SEMANTICS ux,decisions,[DOMAIN]]
|
||||
@defgroup Ux Final UX design decisions for [FEATURE].
|
||||
|
||||
## Screen: [Name]
|
||||
@@ -240,7 +240,7 @@ After all questions are answered, create TWO artifacts:
|
||||
- Data: Paginated (20/page) + search
|
||||
- Feedback: Undo toast (5s) for destructive actions
|
||||
|
||||
#endregion UxDecisions
|
||||
#endregion Std.Opencode.UxDecisions
|
||||
```
|
||||
|
||||
**Rule:** `alternatives.md` shows the DESIGN SPACE — agent can see WHY each path was rejected. `decisions.md` is the compact reference for `/speckit.plan`.
|
||||
|
||||
107
.opencode/command/test.all.md
Normal file
107
.opencode/command/test.all.md
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
description: "Run full test suite: backend unit tests, frontend vitest, coverage reports. Use as final verification gate."
|
||||
handoffs:
|
||||
- label: "Fix Test Failures"
|
||||
agent: "fullstack-coder"
|
||||
prompt: "Fix the following test failures from the full test suite run. Review the error output and implement fixes."
|
||||
condition: "Tests failed"
|
||||
- label: "Coverage Deep Dive"
|
||||
agent: "qa-tester"
|
||||
prompt: "Review the coverage report. Identify uncovered critical paths and propose additional tests."
|
||||
condition: "Coverage thresholds not met"
|
||||
tools: "bash, grep, read"
|
||||
---
|
||||
|
||||
## User Input
|
||||
$ARGUMENTS
|
||||
|
||||
## Goal
|
||||
Run the COMPLETE test suite across both backend and frontend, producing a unified pass/fail + coverage report. This is the **final verification gate** before code review or merge.
|
||||
|
||||
## Required Skills
|
||||
MANDATORY USE `skill({name="semantics-testing"})` — test conventions, anti-tautology rules, tier markers.
|
||||
MANDATORY USE `skill({name="molecular-cot-logging"})` — structured logging during execution.
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### 1. Pre-flight checks
|
||||
```bash
|
||||
# Verify venv exists
|
||||
ls backend/.venv/bin/activate || echo "MISSING VENV"
|
||||
|
||||
# Verify node_modules exists
|
||||
ls frontend/node_modules/.package-lock.json || echo "MISSING NODE_MODULES"
|
||||
```
|
||||
If either is missing, report the issue and STOP — do not attempt to install.
|
||||
|
||||
### 2. Run backend unit tests (Tier 1 — fast)
|
||||
```bash
|
||||
make test-unit
|
||||
```
|
||||
Expected: <120s. If tests fail, collect the failure output and handoff to Fix Test Failures.
|
||||
|
||||
### 3. Run backend integration tests (Tier 2 — Docker required)
|
||||
Only if `--run-integration` is passed in $ARGUMENTS:
|
||||
```bash
|
||||
make test-integration
|
||||
```
|
||||
Expected: <600s. If Docker is not running or tests time out, report which integration tests passed/failed and continue with partial results.
|
||||
|
||||
### 4. Run frontend vitest tests (Tier 1 — fast)
|
||||
```bash
|
||||
make test-frontend
|
||||
```
|
||||
If tests fail, collect the failure output.
|
||||
|
||||
### 5. Run E2E tests (optional — requires running app)
|
||||
Only if `--e2e` is passed in $ARGUMENTS:
|
||||
```bash
|
||||
make test-e2e
|
||||
```
|
||||
|
||||
### 6. Generate coverage reports
|
||||
```bash
|
||||
make coverage
|
||||
```
|
||||
Review coverage percentages:
|
||||
- Backend: check `backend/htmlcov/index.html` or term report
|
||||
- Frontend: check `frontend/coverage/index.html`
|
||||
|
||||
### 7. Linting gate
|
||||
```bash
|
||||
make lint
|
||||
```
|
||||
|
||||
## Output Format
|
||||
```
|
||||
## Full Test Suite Results
|
||||
|
||||
### Backend Unit Tests
|
||||
- Total: N | Passed: N | Failed: N | Skipped: N
|
||||
- Time: X.Xs
|
||||
- [PASS/FAIL]
|
||||
|
||||
### Backend Integration Tests (if run)
|
||||
- Total: N | Passed: N | Failed: N | Skipped: N
|
||||
- Time: X.Xs
|
||||
|
||||
### Frontend Tests
|
||||
- Total: N | Passed: N | Failed: N
|
||||
- Time: X.Xs
|
||||
|
||||
### Coverage
|
||||
- Backend: XX% (threshold: N/A)
|
||||
- Frontend: XX% (threshold: 98% stmts, 95% funcs, 80% branches)
|
||||
|
||||
### Linting
|
||||
- Backend (ruff): [PASS/FAIL]
|
||||
- Frontend (eslint): [PASS/FAIL]
|
||||
|
||||
### Overall: [ALL_PASS / FAILURES_DETECTED]
|
||||
```
|
||||
|
||||
## Constraints
|
||||
- NEVER run `pip install` or `npm install` — report missing deps and stop.
|
||||
- If a test tier times out, report partial results rather than nothing.
|
||||
- For integration tests: if Docker is not available, skip gracefully and note "Docker not available".
|
||||
- Respect the anti-loop protocol: at attempt 3, re-check environment; at attempt 4, escalate.
|
||||
82
.opencode/command/test.coverage.md
Normal file
82
.opencode/command/test.coverage.md
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
description: "Generate coverage reports for both backend and frontend, and verify against thresholds."
|
||||
handoffs:
|
||||
- label: "Improve Coverage"
|
||||
agent: "qa-tester"
|
||||
prompt: "Coverage is below threshold. Identify uncovered critical paths and propose additional tests. Current uncovered areas: $ARGUMENTS"
|
||||
condition: "Coverage below threshold"
|
||||
tools: "bash, read, grep"
|
||||
---
|
||||
|
||||
## User Input
|
||||
$ARGUMENTS
|
||||
|
||||
## Goal
|
||||
Generate test coverage reports for backend (pytest-cov) and frontend (vitest v8), and verify that coverage meets project thresholds.
|
||||
|
||||
## Required Skills
|
||||
MANDATORY USE `skill({name="semantics-testing"})` — coverage conventions.
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### 1. Generate coverage reports
|
||||
```bash
|
||||
make coverage
|
||||
```
|
||||
|
||||
### 2. Review backend coverage
|
||||
```bash
|
||||
# Detailed terminal output with uncovered lines
|
||||
cd backend && source .venv/bin/activate && python -m pytest tests/ --ignore=tests/integration/ --cov=src --cov-report=term-missing 2>&1 | tail -50
|
||||
```
|
||||
Key metrics to extract:
|
||||
- Overall statement coverage (%)
|
||||
- Files with <80% coverage (list top 5 offenders)
|
||||
- Files with 0% coverage (untested)
|
||||
|
||||
### 3. Review frontend coverage
|
||||
```bash
|
||||
# Frontend coverage (also available via make coverage-frontend)
|
||||
cd frontend && npx vitest run --coverage 2>&1 | tail -50
|
||||
```
|
||||
Frontend thresholds in vitest.config.js:
|
||||
- Statements: 98%
|
||||
- Lines: 98%
|
||||
- Functions: 95%
|
||||
- Branches: 80%
|
||||
|
||||
### 4. Check against thresholds
|
||||
If any threshold is not met, identify the specific files/modules dragging coverage down.
|
||||
|
||||
### 5. (Optional) Open HTML reports
|
||||
```bash
|
||||
ls backend/htmlcov/index.html && echo "Backend report: backend/htmlcov/index.html"
|
||||
ls frontend/coverage/index.html && echo "Frontend report: frontend/coverage/index.html"
|
||||
```
|
||||
|
||||
## Output Format
|
||||
```
|
||||
## Coverage Report
|
||||
|
||||
### Backend (pytest-cov)
|
||||
- Statement Coverage: XX%
|
||||
- Files below 80%: N (list top 3-5)
|
||||
- Untested files: N (list top 3-5)
|
||||
|
||||
### Frontend (vitest v8)
|
||||
- Statement Coverage: XX% (threshold: 98%) [PASS/FAIL]
|
||||
- Line Coverage: XX% (threshold: 98%) [PASS/FAIL]
|
||||
- Function Coverage: XX% (threshold: 95%) [PASS/FAIL]
|
||||
- Branch Coverage: XX% (threshold: 80%) [PASS/FAIL]
|
||||
|
||||
### HTML Reports
|
||||
- Backend: backend/htmlcov/index.html
|
||||
- Frontend: frontend/coverage/index.html
|
||||
|
||||
### Overall: [ALL_THRESHOLDS_MET / BELOW_THRESHOLD]
|
||||
```
|
||||
|
||||
## Constraints
|
||||
- Coverage is generated from unit tests ONLY (no Docker integration tests).
|
||||
- If vitest coverage fails with "threshold not met", report which files are below threshold.
|
||||
- Do NOT modify source code to artificially increase coverage.
|
||||
82
.opencode/command/test.related.md
Normal file
82
.opencode/command/test.related.md
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
description: "Find and run tests related to a specific source file using @RELATION BINDS_TO annotations."
|
||||
handoffs:
|
||||
- label: "Fix Related Test Failures"
|
||||
agent: "fullstack-coder"
|
||||
prompt: "Fix the test failures in the related tests. The source file that triggered them is: $ARGUMENTS"
|
||||
condition: "Tests failed"
|
||||
- label: "Add Missing Test Relations"
|
||||
agent: "semantic-curator"
|
||||
prompt: "Add @RELATION BINDS_TO annotations to connect the source file to its test files. The test selector found no matches for: $ARGUMENTS"
|
||||
condition: "No related tests found"
|
||||
tools: "bash, grep, axiom_search, read"
|
||||
---
|
||||
|
||||
## User Input
|
||||
$ARGUMENTS
|
||||
|
||||
## Goal
|
||||
Given a source file path, find and run ONLY the tests that are semantically related to that file. This uses the `@RELATION BINDS_TO -> [ModuleName]` annotations in test files to trace dependencies.
|
||||
|
||||
This is the **most efficient verification** — avoid running the full suite when only one module changed.
|
||||
|
||||
## Required Skills
|
||||
MANDATORY USE `skill({name="semantics-testing"})` — BINDS_TO conventions, test contracts.
|
||||
MANDATORY USE `skill({name="semantics-contracts"})` — relation syntax, verifiable edit loop.
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### 1. Identify the source file
|
||||
$ARGUMENTS should be a path to a source file (e.g., `backend/src/plugins/migration.py`). If the user provides a directory, pick the most recently modified file or ask for clarification.
|
||||
|
||||
### 2. Run the smart test selector
|
||||
```bash
|
||||
make test-related F="$ARGUMENTS"
|
||||
```
|
||||
Or directly:
|
||||
```bash
|
||||
python3 scripts/find-related-tests.py --file "$ARGUMENTS" --verbose --run
|
||||
```
|
||||
|
||||
This script:
|
||||
- Extracts module/class names from the source file (#region anchors, class/function defs)
|
||||
- Searches all test files for `@RELATION BINDS_TO -> [ModuleName]` annotations
|
||||
- Returns matching test files with confidence scores (exact > case-insensitive > substring > heuristic)
|
||||
|
||||
### 3. Interpret results
|
||||
|
||||
**If tests are found and pass:** ✅ Report success.
|
||||
|
||||
**If tests are found and fail:** Read the failing test code, identify root cause, handoff to Fix Related Test Failures.
|
||||
|
||||
**If no related tests found:** Two possibilities:
|
||||
1. The source file genuinely has no tests — report as coverage gap.
|
||||
2. The `@RELATION BINDS_TO` annotation is missing from the test file — handoff to semantic-curator for annotation.
|
||||
|
||||
### 4. (Optional) Verify with axiom
|
||||
If the smart selector found 0 results, try axiom's semantic search as a fallback:
|
||||
```
|
||||
axiom_search operation="trace_related_tests" contract_id="<module_contract_id>"
|
||||
```
|
||||
|
||||
## Output Format
|
||||
```
|
||||
## Related Test Results for `$ARGUMENTS`
|
||||
|
||||
### Matched Tests
|
||||
- [exact] backend/tests/plugins/test_migration_plugin.py (via 'MigrationPlugin')
|
||||
- [substr] backend/tests/api/test_migration.py (via 'MigrationApi')
|
||||
|
||||
### Results
|
||||
- Total: N | Passed: N | Failed: N
|
||||
- Time: X.Xs
|
||||
|
||||
### Coverage Gap (if no tests found)
|
||||
- Source file has no linked tests.
|
||||
- Recommended: create test file with @RELATION BINDS_TO -> [ModuleName]
|
||||
```
|
||||
|
||||
## Constraints
|
||||
- NEVER run the full test suite as a fallback — only matched tests.
|
||||
- If the selector finds 20+ related tests, report the count and ask if user wants to run all or narrow scope.
|
||||
- Heuristic matches (score=0) should be clearly flagged as low-confidence.
|
||||
66
.opencode/command/test.unit.md
Normal file
66
.opencode/command/test.unit.md
Normal file
@@ -0,0 +1,66 @@
|
||||
---
|
||||
description: "Run fast unit tests only (backend SQLite + frontend vitest). Designed for agent verify loop — runs in <30s."
|
||||
handoffs:
|
||||
- label: "Fix Test Failures"
|
||||
agent: "fullstack-coder"
|
||||
prompt: "Fix the following test failures from the unit test run. Review the error output and implement fixes."
|
||||
condition: "Tests failed"
|
||||
tools: "bash, grep, read"
|
||||
---
|
||||
|
||||
## User Input
|
||||
$ARGUMENTS
|
||||
|
||||
## Goal
|
||||
Run ONLY fast unit tests on both backend and frontend. This is the **default verification step** during development — should complete in <30s with no Docker dependency.
|
||||
|
||||
## Required Skills
|
||||
MANDATORY USE `skill({name="semantics-testing"})` — test conventions, anti-tautology rules.
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### 1. Run backend unit tests
|
||||
```bash
|
||||
make test-unit
|
||||
```
|
||||
This excludes `tests/integration/` and uses SQLite in-memory/temp-file databases. No Docker required.
|
||||
|
||||
If tests fail:
|
||||
- Read the failing test file to understand the contract
|
||||
- Check if the failure is in code you just changed
|
||||
- Handoff to Fix Test Failures if needed
|
||||
|
||||
### 2. Run frontend unit tests
|
||||
```bash
|
||||
make test-frontend
|
||||
```
|
||||
This runs vitest with jsdom environment. All SvelteKit imports are mocked.
|
||||
|
||||
### 3. Linting (quick gate)
|
||||
```bash
|
||||
make lint
|
||||
```
|
||||
|
||||
## Output Format
|
||||
```
|
||||
## Unit Test Results
|
||||
|
||||
### Backend (pytest)
|
||||
- Total: N | Passed: N | Failed: N | Skipped: N
|
||||
- Time: X.Xs
|
||||
|
||||
### Frontend (vitest)
|
||||
- Total: N | Passed: N | Failed: N
|
||||
|
||||
### Linting
|
||||
- Backend: [PASS/FAIL]
|
||||
- Frontend: [PASS/FAIL]
|
||||
|
||||
### Overall: [PASS / FAIL]
|
||||
```
|
||||
|
||||
## Constraints
|
||||
- NEVER run `pip install` or `npm install`.
|
||||
- This target MUST complete in <120s (enforced by timeout wrapper).
|
||||
- If tests time out, report which files passed and which timed out.
|
||||
- For agent-driven fix loops: run `make test-unit` after every backend change, `make test-frontend` after every frontend change.
|
||||
@@ -9,11 +9,6 @@
|
||||
"command": ["npx", "chrome-devtools-mcp@latest",
|
||||
"--browser-url=http://127.0.0.1:9222" ],
|
||||
"enabled": true
|
||||
},
|
||||
"axiom": {
|
||||
"type": "local",
|
||||
"command": ["sh", "-c","/home/busya/dev/axiom-mcp-rust-port/target/release/axiom-mcp-server-rs 2>>/tmp/axiom-server.log"],
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ name: molecular-cot-logging
|
||||
description: Structured logging protocol for agent-driven development, based on molecular Long CoT bonds (REASON/REFLECT/EXPLORE). Replaces legacy Entry/Exit/Coherence markers. Python + Svelte.
|
||||
---
|
||||
|
||||
#region MolecularCoTLogging [C:5] [TYPE Skill] [SEMANTICS reasoning,runtime,logging,agentic]
|
||||
#region Std.Opencode.MolecularCoTLogging [C:5] [TYPE Skill] [SEMANTICS reasoning,runtime,logging,agentic]
|
||||
@BRIEF Structured logging protocol for agent-driven development, based on molecular Long CoT bonds (Deep-Reasoning, Self-Reflection, Self-Exploration). Replaces legacy Entry/Exit/Coherence markers.
|
||||
@RELATION DEPENDS_ON -> [Std.Semantics.Core]
|
||||
@RELATION DISPATCHES -> [Std.Semantics.Python]
|
||||
@@ -303,4 +303,4 @@ for line in sys.stdin:
|
||||
| Logging raw passwords or tokens in `payload` | Always sanitise sensitive data |
|
||||
| Spread markers across multiple modules without trace_id | Always propagate `trace_id` |
|
||||
|
||||
#endregion MolecularCoTLogging
|
||||
#endregion Std.Opencode.MolecularCoTLogging
|
||||
|
||||
@@ -3,10 +3,10 @@ name: semantics-frontend
|
||||
description: Core protocol for Svelte 5 (Runes) Components, UX State Machines, and Visual-Interactive Validation.
|
||||
---
|
||||
|
||||
# [DEF:Std:Semantics:Frontend]
|
||||
# [DEF:Std.Opencode.Std:Semantics:Frontend]
|
||||
# @COMPLEXITY: 5
|
||||
# @PURPOSE: Canonical GRACE-Poly protocol for Svelte 5 (Runes) Components, UX State Machines, and Project UI Architecture.
|
||||
# @RELATION: DEPENDS_ON ->[Std:Semantics:Core]
|
||||
# @RELATION: DEPENDS_ON ->[Std.Opencode.Std:Semantics:Core]
|
||||
# @INVARIANT: Frontend components MUST be verifiable by an automated GUI Judge Agent (e.g., Playwright).
|
||||
# @INVARIANT: Use Tailwind CSS exclusively. Native `fetch` is forbidden.
|
||||
|
||||
@@ -55,7 +55,8 @@ Frontend logging bridges the gap between your logic and the Judge Agent's vision
|
||||
You MUST strictly adhere to this AST boundary format:
|
||||
|
||||
```html
|
||||
<!-- [DEF:ComponentName:Component] -->
|
||||
# [/DEF:Std.Opencode.Std:Semantics:Frontend]
|
||||
<!-- [DEF:Std.Opencode.ComponentName:Component] -->
|
||||
<script>
|
||||
/**
|
||||
* @COMPLEXITY: [1-5]
|
||||
@@ -104,4 +105,4 @@ You MUST strictly adhere to this AST boundary format:
|
||||
{$t('actions.start')}
|
||||
</button>
|
||||
</div>
|
||||
<!--[/DEF:ComponentName:Component] -->
|
||||
<!--[/DEF:Std.Opencode.ComponentName:Component] -->
|
||||
|
||||
@@ -3,10 +3,10 @@ name: semantics-belief
|
||||
description: Core protocol for Thread-Local Belief State, Runtime Chain-of-Thought (CoT), and Interleaved Thinking in Python.
|
||||
---
|
||||
|
||||
# [DEF:Std:Semantics:Belief]
|
||||
# [DEF:Std.Opencode.Std:Semantics:Belief]
|
||||
# @COMPLEXITY: 5
|
||||
# @PURPOSE: Core protocol for Thread-Local Belief State, Runtime Chain-of-Thought (CoT), and Interleaved Thinking in Python.
|
||||
# @RELATION: DEPENDS_ON -> [Std:Semantics:Core]
|
||||
# @RELATION: DEPENDS_ON -> [Std.Opencode.Std:Semantics:Core]
|
||||
# @INVARIANT: Implementation of C4/C5 complexity nodes MUST emit reasoning via semantic logger methods before mutating state or returning.
|
||||
|
||||
## 0. INTERLEAVED THINKING (GLM-5 PARADIGM)
|
||||
@@ -53,5 +53,5 @@ If your execution path triggers a `logger.explore()` due to a broken assumption
|
||||
**YOU MUST ASCEND TO THE `[DEF]` HEADER AND DOCUMENT IT.**
|
||||
You must add `@RATIONALE: [Why you did this]` and `@REJECTED:[The path that failed during explore()]`.
|
||||
Failure to link a runtime `explore` to a static `@REJECTED` tag is a fatal protocol violation that causes amnesia for future agents.
|
||||
# [/DEF:Std:Semantics:Belief]
|
||||
# [/DEF:Std.Opencode.Std:Semantics:Belief]
|
||||
**[SYSTEM: END OF BELIEF DIRECTIVE. ENFORCE STRICT RUNTIME CoT.]**
|
||||
|
||||
@@ -97,18 +97,18 @@ Not all GRACE tags are equal in the model's training data. Understanding which t
|
||||
|
||||
### Legacy — DEF (permanently recognized)
|
||||
```python
|
||||
// [DEF:ContractId:Type]
|
||||
// [DEF:Std.Opencode.ContractId:Type]
|
||||
// @TAG: value
|
||||
<code>
|
||||
// [/DEF:ContractId:Type]
|
||||
// [/DEF:Std.Opencode.ContractId:Type]
|
||||
```
|
||||
|
||||
### Doc — Brace (Markdown, specs, ADRs)
|
||||
```
|
||||
## @{ ContractId [C:N] [TYPE TypeName]
|
||||
## @{ Std.Opencode.ContractId [C:N] [TYPE TypeName]
|
||||
@BRIEF Description
|
||||
...
|
||||
## @} ContractId
|
||||
## @} Std.Opencode.ContractId
|
||||
```
|
||||
|
||||
**Allowed Types:** Module, Function, Class, Component, Model, Block, ADR, Tombstone, Skill, Agent.
|
||||
@@ -260,6 +260,7 @@ The opening anchor MUST pack maximum signal into one line:
|
||||
|
||||
```
|
||||
#region Domain.Sub.ContractId [C:N] [TYPE TypeName] [SEMANTICS tag1,tag2,tag3]
|
||||
# #endregion Domain.Sub.ContractId
|
||||
```
|
||||
|
||||
- ID, complexity, type, and semantic tags on ONE line → survives CSA 4× pooling as a single KV record.
|
||||
@@ -303,6 +304,7 @@ Example — both mechanisms reinforce each other:
|
||||
#region Core.Auth.Login [C:4] [TYPE Function] [SEMANTICS auth,login,token]
|
||||
# @ingroup Auth
|
||||
# @BRIEF Authenticate user by credentials.
|
||||
# #endregion Core.Auth.Login
|
||||
```
|
||||
|
||||
**Rule:** Identical domain = identical primary keyword in `[SEMANTICS ...]` AND identical `@ingroup Domain`. They target different compression layers (CSA vs HCA) and don't conflict — the keyword repetition amplifies the DSA score.
|
||||
|
||||
@@ -7,7 +7,7 @@ description: Python-specific GRACE-Poly protocol: few-shot complexity examples,
|
||||
@BRIEF Python-specific HOW: few-shot complexity examples, belief runtime patterns, module decomposition, and FastAPI/SQLAlchemy conventions for the GRACE-Poly protocol in superset-tools.
|
||||
@RELATION DEPENDS_ON -> [Std.Semantics.Core]
|
||||
@RELATION DEPENDS_ON -> [Std.Semantics.Contracts]
|
||||
@RELATION DISPATCHES -> [MolecularCoTLogging]
|
||||
@RELATION DISPATCHES -> [Std.Opencode.MolecularCoTLogging]
|
||||
@RESTRICTION EXAMPLES ONLY — this file provides language-specific code patterns. All protocol rules (tier definitions, tag catalog, anchor syntax) are defined exclusively in `semantics-core`. This file MUST NOT redefine or contradict any rule from `semantics-core`.
|
||||
@RATIONALE Python's async/await model, FastAPI dependency injection, and SQLAlchemy session management create unique failure modes for Transformer agents: (1) async/await boundary confusion — agents write sync code in async contexts or forget `await` on ORM calls, producing silent no-ops; (2) dependency injection blindness — FastAPI's `Depends()` creates implicit call graphs that the agent's attention cannot trace without explicit @RELATION edges; (3) session lifecycle drift — SQLAlchemy sessions have strict boundaries that agents violate by passing detached objects across function calls. Concrete examples at each complexity tier act as few-shot anchors that override the agent's pre-trained (and often wrong) Python patterns.
|
||||
@REJECTED Generic Python patterns without GRACE anchors were rejected — agents produce working code that violates module size limits (INV_7), omits belief runtime markers, and creates orphan contracts invisible to the semantic index. Relying on the agent's pre-trained FastAPI/SQLAlchemy knowledge without project-specific examples was rejected — superset-tools has specific conventions (trace_id propagation, plugin architecture, WebSocket logging) that general training data cannot capture.
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Svelte 5 (Runes) protocol for superset-tools: UX State Machines, Ta
|
||||
#region Std.Semantics.Svelte [C:5] [TYPE Skill] [SEMANTICS frontend,svelte,ui,ux,tailwind]
|
||||
@BRIEF HOW to build Svelte 5 (Runes) Components for superset-tools with UX State Machines, Tailwind CSS, store topology, and visual-interactive validation.
|
||||
@RELATION DEPENDS_ON -> [Std.Semantics.Core]
|
||||
@RELATION DEPENDS_ON -> [MolecularCoTLogging]
|
||||
@RELATION DEPENDS_ON -> [Std.Opencode.MolecularCoTLogging]
|
||||
@RELATION DISPATCHES -> [Std.Semantics.Testing]
|
||||
@RESTRICTION EXAMPLES ONLY — this file provides language-specific code patterns. All protocol rules (tier definitions, tag catalog, anchor syntax) are defined exclusively in `semantics-core`. UX contract tags are defined here as examples; the tag catalog lives in `semantics-core` §III. This file MUST NOT redefine or contradict any rule from `semantics-core`.
|
||||
@RATIONALE Svelte 5 runes ($state, $derived, $effect, $props) chosen for reactive precision and native compiler optimisations over Svelte 4 legacy reactivity ($:). Tailwind CSS selected for zero-runtime utility-first styling and rapid visual validation via chrome-devtools MCP. FSM-based UX contracts (@UX_STATE, @UX_FEEDBACK, @UX_RECOVERY) chosen to create verifiable state-transition tests that the browser Judge Agent can execute deterministically. superset-tools internal API wrappers (fetchApi/requestApi) chosen over native fetch to enforce auth, error normalisation, and trace_id propagation. Model-first architecture chosen because event-handler spaghetti is the #1 Transformer failure mode in UI code: the agent scatters logic across onclick/onchange in 5 files — KV-cache cannot hold cross-component relationships, creating invisible coupling that breaks silently.
|
||||
@@ -309,7 +309,7 @@ Frontend logging uses `log()` from `$lib/cot-logger` per **MolecularCoTLogging**
|
||||
Region format for HTML/Svelte comments:
|
||||
|
||||
```html
|
||||
<!-- #region MigrationTaskCard [C:3] [TYPE Component] [SEMANTICS ui,migration,task] -->
|
||||
<!-- #region Std.Semantics.MigrationTaskCard [C:3] [TYPE Component] [SEMANTICS ui,migration,task] -->
|
||||
<!-- @BRIEF Card displaying a migration task with status, progress, and action buttons. -->
|
||||
<!-- @LAYER UI -->
|
||||
<!-- @RELATION DEPENDS_ON -> [StatusBadge] -->
|
||||
@@ -410,7 +410,7 @@ Region format for HTML/Svelte comments:
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion MigrationTaskCard -->
|
||||
<!-- #endregion Std.Semantics.MigrationTaskCard -->
|
||||
```
|
||||
|
||||
## VII. SS-TOOLS DESIGN TOKEN CANON & COMPONENT REUSE
|
||||
|
||||
@@ -90,7 +90,7 @@ from unittest.mock import AsyncMock, patch
|
||||
class TestDashboardMigration:
|
||||
"""Verify migrate_dashboard @POST guarantees."""
|
||||
|
||||
# #region test_migrate_dashboard_success [C:2] [TYPE Function]
|
||||
# #region Std.Semantics.TestMigrateDashboardSuccess [C:2] [TYPE Function]
|
||||
# @BRIEF Happy path: valid dashboard with complete db mapping.
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_dashboard_success(self):
|
||||
@@ -98,8 +98,9 @@ class TestDashboardMigration:
|
||||
expected = {"id": "dash_1", "status": "imported"}
|
||||
# ... test implementation
|
||||
pass
|
||||
# #endregion test_migrate_dashboard_success
|
||||
# #endregion Std.Semantics.TestMigrateDashboardSuccess
|
||||
# #endregion TestDashboardMigration
|
||||
# #endregion Test.Migration.RunTask
|
||||
```
|
||||
|
||||
### Running tests
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#region FeatureSpec [C:3] [TYPE ADR] [SEMANTICS spec,requirements,feature]
|
||||
#region Std.Specify.FeatureSpec [C:3] [TYPE ADR] [SEMANTICS spec,requirements,feature]
|
||||
@BRIEF Feature specification — WHAT the user needs and WHY. Implementation-free. Survives HCA 128× via @SEMANTICS grouping.
|
||||
|
||||
## Navigation (DSA Indexer keywords)
|
||||
@@ -75,4 +75,4 @@ All stories share `@SEMANTICS` domain keywords from the feature header.
|
||||
- **SC-002**: [Measurable metric]
|
||||
- **SC-003**: [User-facing metric]
|
||||
|
||||
#endregion FeatureSpec
|
||||
#endregion Std.Specify.FeatureSpec
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#region UxReference [C:3] [TYPE ADR] [SEMANTICS ux, reference, [DOMAIN]]
|
||||
#region Std.Specify.UxReference [C:3] [TYPE ADR] [SEMANTICS ux, reference, [DOMAIN]]
|
||||
@BRIEF UX interaction reference — persona, flows, states, and recovery paths. Drives `@UX_*` contract tags in Phase 1.
|
||||
|
||||
**Feature Branch**: `[###-feature-name]`
|
||||
@@ -75,4 +75,4 @@ $ command --flag value
|
||||
* **Style**: [e.g. Concise, Technical, Friendly, Verbose]
|
||||
* **Terminology**: [e.g. Use "Repository" not "Repo", "Directory" not "Folder"]
|
||||
|
||||
#endregion UxReference
|
||||
#endregion Std.Specify.UxReference
|
||||
|
||||
419
INSTALL.md
Normal file
419
INSTALL.md
Normal file
@@ -0,0 +1,419 @@
|
||||
# Установка и настройка superset-tools
|
||||
|
||||
## Содержание
|
||||
|
||||
- [Требования](#требования)
|
||||
- [Архитектура](#архитектура)
|
||||
- [Технологический стек](#технологический-стек)
|
||||
- [Docker (рекомендуется)](#docker-рекомендуется)
|
||||
- [Локальная разработка](#локальная-разработка)
|
||||
- [Конфигурация](#конфигурация)
|
||||
- [AI-агент](#ai-агент)
|
||||
- [Система сборки](#система-сборки)
|
||||
- [Тестирование](#тестирование)
|
||||
- [Покрытие кода](#покрытие-кода)
|
||||
- [SSL/TLS конфигурация](#ssltls-конфигурация)
|
||||
- [Enterprise Clean Deployment](#enterprise-clean-deployment)
|
||||
- [Спецификации](#спецификации)
|
||||
- [Утилиты](#утилиты)
|
||||
- [Исследования](#исследования)
|
||||
|
||||
## Требования
|
||||
|
||||
- **Docker (рекомендуется):** Docker Engine 24+, Docker Compose v2, 4 GB RAM
|
||||
- **Локальная разработка:** Python 3.9+, Node.js 18+, npm, 2 GB RAM, 5 GB диска
|
||||
|
||||
## Архитектура
|
||||
|
||||
Проект состоит из трёх сервисов:
|
||||
|
||||
| Сервис | Технологии | Назначение |
|
||||
|---|---|---|
|
||||
| **backend/** | Python FastAPI, SQLAlchemy 2.0, APScheduler, PostgreSQL | REST API, бизнес-логика, плагины |
|
||||
| **frontend/** | Svelte 5 (Runes), SvelteKit, Vite, Tailwind CSS | SPA-клиент |
|
||||
| **agent/** | Gradio, LangGraph, LangChain, OpenAI SDK | AI-агент с чат-интерфейсом |
|
||||
| **shared/** | Python package | Общие утилиты (логирование, SSL, LLM HTTP) |
|
||||
|
||||
```
|
||||
superset-tools/
|
||||
├── backend/ # REST API (FastAPI)
|
||||
│ ├── src/
|
||||
│ │ ├── api/routes/ # 30+ роутов (admin, auth, translate, git, agent...)
|
||||
│ │ ├── core/
|
||||
│ │ │ ├── auth/ # JWT, OAuth, API Keys, RBAC
|
||||
│ │ │ ├── migration/ # Dry-run, risk assessment
|
||||
│ │ │ ├── task_manager/ # Async jobs, event bus, persistence
|
||||
│ │ │ ├── superset_client/
|
||||
│ │ │ ├── logger/ # Structured logging, belief state
|
||||
│ │ │ └── ...
|
||||
│ │ ├── models/ # SQLAlchemy модели
|
||||
│ │ ├── plugins/ # Реализации плагинов
|
||||
│ │ ├── schemas/ # Pydantic схемы
|
||||
│ │ ├── services/ # Бизнес-логика
|
||||
│ │ └── scripts/ # CLI/TUI админ-скрипты
|
||||
│ └── tests/
|
||||
├── frontend/ # SvelteKit SPA
|
||||
│ ├── src/
|
||||
│ │ ├── routes/ # 20+ групп страниц
|
||||
│ │ ├── lib/
|
||||
│ │ │ ├── api/ # API клиент
|
||||
│ │ │ ├── auth/ # Auth store, permissions
|
||||
│ │ │ ├── components/ # UI компоненты
|
||||
│ │ │ ├── stores/ # Svelte stores
|
||||
│ │ │ ├── i18n/ # Мультиязычность
|
||||
│ │ │ └── ...
|
||||
│ │ └── ...
|
||||
│ └── tests/
|
||||
├── agent/ # Gradio/LangGraph AI-агент
|
||||
│ ├── src/ss_tools/agent/
|
||||
│ │ ├── app.py # Gradio приложение
|
||||
│ │ ├── langgraph_setup.py # LangGraph граф
|
||||
│ │ ├── tools.py # LangChain инструменты
|
||||
│ │ ├── document_parser.py # PDF/XLSX парсер
|
||||
│ │ └── ...
|
||||
│ └── tests/
|
||||
├── shared/ # Общий Python пакет (ss-tools-shared)
|
||||
├── docker/ # Dockerfile, entrypoint, nginx
|
||||
├── docs/ # Документация, ADR
|
||||
├── specs/ # 40+ feature specifications
|
||||
├── scripts/ # Утилиты (coverage, security, build)
|
||||
├── research/ # Исследования (mcp-superset)
|
||||
├── semantics/ # Семантическая карта кода
|
||||
├── dist/ # Релизные бандлы
|
||||
├── storage/ # Runtime данные (backups, repos)
|
||||
├── certs/ # SSL-сертификаты
|
||||
└── examples/ # Примеры интеграции
|
||||
```
|
||||
|
||||
## Технологический стек
|
||||
|
||||
**Backend:** Python 3.9+ (FastAPI 0.126, SQLAlchemy 2.0, APScheduler 3.11), PostgreSQL 16, Authlib, JWT, OpenAI API, GitPython, Playwright, lingua-language-detector
|
||||
|
||||
**Frontend:** Svelte 5 (Runes), SvelteKit 2.49, Vite 7, Tailwind CSS 3, Vitest 4.1, Playwright 1.60
|
||||
|
||||
**Agent:** Gradio 5.50+, LangChain Core 0.3+, LangGraph 0.2+, LangGraph Checkpoint Postgres, pdfplumber, sentence-transformers (optional)
|
||||
|
||||
**DevOps:** Docker & Docker Compose (3 профиля + E2E), GitHub Actions (CI), Nginx (опциональный SSL)
|
||||
|
||||
## Docker (рекомендуется)
|
||||
|
||||
### Профили окружения
|
||||
|
||||
Система поддерживает несколько профилей через `.env` файлы:
|
||||
|
||||
| Профиль | Файл | Команда |
|
||||
|---|---|---|
|
||||
| **current** | `.env.current` | `docker compose --profile current up --build` |
|
||||
| **master** | `.env.master` | `docker compose --profile master up --build` |
|
||||
| **enterprise-clean** | `.env.enterprise-clean` | `docker compose --profile enterprise-clean up --build` |
|
||||
| **e2e** | `.env.e2e` | `docker compose -f docker-compose.e2e.yml up --build` |
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd superset-tools
|
||||
cp .env.example .env
|
||||
docker compose --profile current up --build
|
||||
```
|
||||
|
||||
После запуска:
|
||||
- Frontend: http://localhost:8000
|
||||
- Backend API: http://localhost:8001
|
||||
- PostgreSQL: localhost:5432
|
||||
- Agent UI: http://localhost:8002 (gradio)
|
||||
|
||||
### Offline-бандл
|
||||
|
||||
```bash
|
||||
xz -dc dist/docker/superset-tools.20260517.tar.xz | docker load
|
||||
export POSTGRES_PASSWORD="my-strong-password"
|
||||
docker compose -f dist/docker/docker-compose.light.yml up -d
|
||||
```
|
||||
|
||||
## Локальная разработка
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements-backend.txt
|
||||
pip install -e ../shared
|
||||
python3 -m uvicorn src.app:app --reload --port 8000
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev -- --port 5173
|
||||
```
|
||||
|
||||
### Agent
|
||||
|
||||
```bash
|
||||
cd agent
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
pip install -e ../shared
|
||||
python -m ss_tools_agent
|
||||
```
|
||||
|
||||
### Начальная настройка
|
||||
|
||||
```bash
|
||||
# Переменные окружения
|
||||
cp .env.example backend/.env
|
||||
|
||||
# Инициализация БД
|
||||
cd backend && source .venv/bin/activate
|
||||
python src/scripts/init_auth_db.py
|
||||
|
||||
# Создание администратора
|
||||
python src/scripts/create_admin.py --username admin --password '<temporary-secret>'
|
||||
```
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Полный список переменных — в каждом `.env.*.example` файле.
|
||||
|
||||
### Основные категории
|
||||
|
||||
| Категория | Переменные | Описание |
|
||||
|---|---|---|
|
||||
| **Security** | `AUTH_SECRET_KEY`, `ENCRYPTION_KEY`, `SERVICE_JWT` | JWT-подпись, шифрование данных, сервисный токен agent→backend |
|
||||
| **Database** | `DATABASE_URL`, `AUTH_DATABASE_URL`, `TASKS_DATABASE_URL` | PostgreSQL подключения |
|
||||
| **Admin bootstrap** | `INITIAL_ADMIN_CREATE`, `INITIAL_ADMIN_USERNAME`, `INITIAL_ADMIN_PASSWORD`, `INITIAL_ADMIN_EMAIL` | Автосоздание admin при первом запуске |
|
||||
| **LLM** | `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `LLM_BASE_URL`, `LLM_MODEL` | Провайдеры и модели |
|
||||
| **Agent** | `AGENT_PORT`, `ENABLE_EMBEDDING_ROUTER` | Порт Gradio, семантический роутинг |
|
||||
| **Features** | `FEATURES__DATASET_REVIEW`, `FEATURES__HEALTH_MONITOR` | Включение фич |
|
||||
| **SSO** | `ADFS_CLIENT_ID`, `ADFS_CLIENT_SECRET`, `ADFS_METADATA_URL` | Active Directory Federation Services |
|
||||
| **Certificates** | `CERTS_PATH`, `SSL_KEY_PASSPHRASE`, `LLM_CA_CERT_URLS` | PKI для корпоративных сетей |
|
||||
| **CORS** | `ALLOWED_ORIGINS`, `FORCE_HTTPS`, `APP_TIMEZONE` | Безопасность и регион |
|
||||
| **Logging** | `ENABLE_BELIEF_STATE_LOGGING`, `TASK_LOG_LEVEL` | Структурированное логирование |
|
||||
| **Ports** | `BACKEND_HOST_PORT`, `FRONTEND_HOST_PORT`, `AGENT_HOST_PORT`, `POSTGRES_HOST_PORT` | Проброс портов Docker |
|
||||
|
||||
## AI-агент
|
||||
|
||||
Отдельный сервис с чат-интерфейсом для управления платформой на естественном языке:
|
||||
|
||||
- **LangGraph-граф** — оркестрация multi-step диалогов с постгресовой персистентностью
|
||||
- **Embedding-роутинг** — семантический выбор инструмента (опционально, требует sentence-transformers)
|
||||
- **Confirmation flow** — подтверждение деструктивных операций
|
||||
- **Загрузка документов** — парсинг PDF/XLSX для контекстного анализа
|
||||
- **Gradio UI** — веб-интерфейс
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
docker compose --profile current up agent
|
||||
|
||||
# Локально
|
||||
cd agent && pip install -r requirements.txt && python -m ss_tools_agent
|
||||
```
|
||||
|
||||
## Система сборки
|
||||
|
||||
`build.sh` — унифицированная CLI-утилита:
|
||||
|
||||
```bash
|
||||
# Сборка и запуск
|
||||
./build.sh compose # docker compose up --build (current profile)
|
||||
./build.sh compose:master # docker compose up (master profile)
|
||||
|
||||
# Индивидуальные сборки
|
||||
./build.sh backend # backend image only
|
||||
./build.sh frontend # frontend image only
|
||||
./build.sh agent # agent image only
|
||||
|
||||
# Offline-бандлы
|
||||
./build.sh bundle v1.0.0 # полный бандл (все сервисы)
|
||||
./build.sh bundle:light v1.0.0 # light (backend + frontend)
|
||||
```
|
||||
|
||||
Артефакты: `dist/docker/superset-tools.<version>.tar.xz` + sha256sum + manifest.
|
||||
|
||||
## Тестирование
|
||||
|
||||
### Makefile (tiered test system)
|
||||
|
||||
```bash
|
||||
make test # Tier 1: быстрые unit-тесты backend + frontend (<30с)
|
||||
make test-unit # Backend unit-тесты с SQLite (без Docker)
|
||||
make test-frontend # Frontend vitest-тесты
|
||||
make test-related F=path # Tier 2: умный выбор тестов для изменённого файла
|
||||
make test-integration # Tier 3: backend integration с testcontainers
|
||||
make test-e2e # Tier 3: Playwright E2E (требуется запущенное приложение)
|
||||
make test-all # Все тесты (без установки зависимостей)
|
||||
|
||||
make lint # Все линтеры
|
||||
make lint-backend # ruff check
|
||||
make lint-frontend # eslint
|
||||
|
||||
make coverage # coverage обоих стеков
|
||||
```
|
||||
|
||||
### Самостоятельный запуск
|
||||
|
||||
```bash
|
||||
# Backend тесты
|
||||
cd backend && source .venv/bin/activate && pytest
|
||||
|
||||
# Frontend тесты
|
||||
cd frontend && npm run test
|
||||
|
||||
# Agent тесты
|
||||
cd agent && source .venv/bin/activate && pytest
|
||||
|
||||
# Конкретный тест
|
||||
pytest backend/tests/test_auth.py::test_create_user
|
||||
```
|
||||
|
||||
### E2E тестирование
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.e2e.yml up --build
|
||||
cd frontend && npm run test:e2e
|
||||
```
|
||||
|
||||
## Покрытие кода
|
||||
|
||||
Сводный отчёт — `scripts/coverage-summary.sh`:
|
||||
|
||||
```bash
|
||||
./scripts/coverage-summary.sh # Полный запуск (integration + frontend)
|
||||
./scripts/coverage-summary.sh --unit # Backend unit (SQLite) + frontend
|
||||
./scripts/coverage-summary.sh --frontend-only # Только frontend
|
||||
./scripts/coverage-summary.sh --backend-only --unit # Только backend
|
||||
./scripts/coverage-summary.sh --output-dir ./reports/coverage
|
||||
```
|
||||
|
||||
Результат: `coverage-summary/index.html`.
|
||||
|
||||
### Текущие показатели
|
||||
|
||||
| Стек | Тип тестов | Процент | Покрытие (Stmts) |
|
||||
|---|---|---|---|
|
||||
| Backend (unit) | 1723 | 1721/2 ✅ | 48% |
|
||||
| Backend (integration) | 167 | 167/0 ✅ | 12% |
|
||||
| Frontend | 2443 | 2442/1 ✅ | 99.25% |
|
||||
|
||||
## SSL/TLS конфигурация
|
||||
|
||||
### Сертификаты для HTTPS (nginx)
|
||||
|
||||
Поместите файлы в `./certs/`:
|
||||
|
||||
**Вариант A — отдельные файлы:**
|
||||
```
|
||||
./certs/server.crt # SSL сертификат
|
||||
./certs/server.key # Приватный ключ
|
||||
```
|
||||
|
||||
**Вариант B — зашифрованный ключ + пароль:**
|
||||
```
|
||||
./certs/server.crt # SSL сертификат
|
||||
./certs/server.key # Приватный ключ (зашифрован, с DEK-Info)
|
||||
SSL_KEY_PASSPHRASE=my-passphrase
|
||||
```
|
||||
|
||||
**Вариант C — PKCS#12 контейнер:**
|
||||
```
|
||||
./certs/server.p12 # Контейнер с сертификатом + ключом
|
||||
SSL_KEY_PASSPHRASE=my-passphrase
|
||||
```
|
||||
|
||||
Entrypoint автоматически извлекает `.crt` и `.key` из `.p12`, расшифровывает ключ и передаёт nginx (ключ остаётся в tmpfs контейнера).
|
||||
|
||||
### Корпоративные CA-сертификаты
|
||||
|
||||
Положите `.crt`/`.pem` в `./certs/` — entrypoint установит их в системное хранилище Alpine и NSS (Chromium/Playwright). Поддерживаются цепочки (Root → Intermediate).
|
||||
|
||||
### LLM CA-сертификаты
|
||||
|
||||
```bash
|
||||
LLM_CA_CERT_URLS="http://pki.company.com/root-ca.crt http://pki.company.com/intermediate-ca.crt"
|
||||
```
|
||||
|
||||
Сертификаты скачиваются на старте backend и agent контейнеров, конвертируются из DER в PEM при необходимости, устанавливаются в системное хранилище.
|
||||
|
||||
### Диагностика SSL
|
||||
|
||||
```bash
|
||||
scripts/check_llm_certs.py # Полная проверка цепочки доверия
|
||||
scripts/diag_container.py --target your-llm-provider.com:443 # Контейнерная диагностика
|
||||
```
|
||||
|
||||
Подробнее — [ADR-0009](docs/adr/ADR-0009-ssl-certificate-management.md).
|
||||
`LLM_SSL_VERIFY` удалён в 0.2.x — TLS verify всегда включён.
|
||||
|
||||
## Enterprise Clean Deployment
|
||||
|
||||
Разворот в корпоративной сети с очищенным дистрибутивом (без тестовых данных, запрет внешних источников, compliance-проверка):
|
||||
|
||||
```bash
|
||||
cp .env.enterprise-clean.example .env
|
||||
docker compose --profile enterprise-clean up --build
|
||||
```
|
||||
|
||||
Поддерживаются CLI, API и TUI flows. Подробнее — [docs/enterprise-clean.md](docs/enterprise-clean.md).
|
||||
|
||||
## Авторизация
|
||||
|
||||
Два метода аутентификации:
|
||||
|
||||
1. **Локальная** (username/password) — JWT-токены, RBAC (admin/analyst/viewer)
|
||||
2. **ADFS SSO** — Active Directory Federation Services
|
||||
|
||||
Управление: `POST /api/admin/users`, `POST /api/admin/roles`.
|
||||
|
||||
## Мониторинг
|
||||
|
||||
- **Dashboard Hub** — управление дашбордами с Git-статусом
|
||||
- **Dataset Hub** — управление датасетами с прогрессом маппинга
|
||||
- **Task Drawer** — мониторинг фоновых задач (WebSocket real-time)
|
||||
- **Unified Reports** — `GET /api/reports?page=1&page_size=20` (фильтры по статусу, типу, дате)
|
||||
- **Health Monitor** — мониторинг здоровья системы (через `FEATURES__HEALTH_MONITOR`)
|
||||
- **Semantic Map** — автоматически генерируемая семантическая карта кода (`semantics/semantic_map.json`)
|
||||
|
||||
## Спецификации
|
||||
|
||||
В `specs/` ведётся 40+ feature specifications. Каждая включает: `spec.md`, `research.md`, `plan.md`, `contracts/modules.md`, `data-model.md`, `checklists/requirements.md`, `tasks.md`.
|
||||
|
||||
| # | Название | Описание |
|
||||
|---|---|---|
|
||||
| 011 | `git-integration-dashboard` | Git-интеграция дашбордов |
|
||||
| 017 | `llm-analysis-plugin` | LLM-аналитика и валидация |
|
||||
| 022 | `sync-id-cross-filters` | Sync ID cross-filters |
|
||||
| 023 | `clean-repo-enterprise` | Чистый репозиторий для enterprise |
|
||||
| 033 | `gradio-agent-chat` | Gradio/LangGraph AI-агент |
|
||||
| 034 | `task-status-center` | Центр статуса задач |
|
||||
| 038 | `dashboard-scenario-model` | Сценарная модель дашбордов |
|
||||
| 041 | `dataset-lineage-blast-radius` | Lineage и blast radius датасетов |
|
||||
|
||||
## Примеры скриптов
|
||||
|
||||
Примеры интеграции с внешними системами (Airflow, CI/CD, cron) — в [`examples/`](./examples/):
|
||||
|
||||
- [Python](examples/maintenance-api-python.py)
|
||||
- [Bash](examples/maintenance-api-bash.sh)
|
||||
|
||||
Аутентификация через API Key (`X-API-Key`), запуск и завершение maintenance-событий.
|
||||
|
||||
## Утилиты
|
||||
|
||||
| Скрипт | Назначение |
|
||||
|---|---|
|
||||
| `coverage-summary.sh` | Сводный отчёт покрытия |
|
||||
| `scan_secrets.sh` | Сканирование секретов |
|
||||
| `check_llm_certs.py` | Проверка SSL-сертификатов LLM |
|
||||
| `diag_container.py` | SSL-диагностика |
|
||||
| `find-related-tests.py` | Поиск тестов по изменённому файлу |
|
||||
| `pretty_cot.py` | Форматирование CoT-логов |
|
||||
| `gen_semantics.py` | Семантическая карта кода |
|
||||
| `build_offline_docker_bundle.sh` | Сборка offline-бандлов |
|
||||
|
||||
## Исследования
|
||||
|
||||
- **mcp-superset** — MCP-сервер для Apache Superset (137 инструментов, PyPI). Streamable HTTP, SSE, stdio транспорты. Подробнее — [`research/mcp-superset/`](research/mcp-superset/).
|
||||
87
Makefile
Normal file
87
Makefile
Normal file
@@ -0,0 +1,87 @@
|
||||
# ss-tools test suite
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Tiered test targets designed for agent-driven development:
|
||||
# Tier 1 (fast, <30s): make test
|
||||
# Tier 2 (slow, <5min): make test-integration
|
||||
# Tier 3 (e2e, <10min): make test-e2e
|
||||
#
|
||||
# Smart selection: make test-related F=path/to/file.py
|
||||
# Coverage: make coverage
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
ROOT := $(CURDIR)
|
||||
BACKEND := $(ROOT)/backend
|
||||
FRONTEND := $(ROOT)/frontend
|
||||
VENV := $(BACKEND)/.venv/bin/activate
|
||||
|
||||
TIMEOUT_FAST := 600
|
||||
TIMEOUT_SLOW := 600
|
||||
|
||||
.PHONY: help test test-unit test-frontend test-related test-integration test-e2e test-all
|
||||
.PHONY: coverage coverage-backend coverage-frontend
|
||||
.PHONY: lint lint-backend lint-frontend
|
||||
|
||||
# ── Help ───────────────────────────────────────────────────
|
||||
help: ## Show this help message
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \
|
||||
awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-22s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
# ── Tier 1: Fast tests (no Docker, SQLite + jsdom) ─────────
|
||||
test: test-unit test-frontend ## Run fast unit tests (backend + frontend, <30s)
|
||||
@echo " ✅ All fast tests passed"
|
||||
|
||||
test-unit: ## Run backend unit tests (SQLite, no Docker)
|
||||
@echo " ▶ Backend unit tests (SQLite)..."
|
||||
@cd $(BACKEND) && . $(VENV) && timeout $(TIMEOUT_FAST) python -m pytest tests/ --ignore=tests/integration/ -v --tb=short
|
||||
|
||||
test-frontend: ## Run frontend vitest tests
|
||||
@echo " ▶ Frontend vitest tests..."
|
||||
@cd $(FRONTEND) && npx vitest run
|
||||
|
||||
# ── Tier 2: Smart selection ────────────────────────────────
|
||||
test-related: ## Run tests related to a changed file (make test-related F=path/to/file.py)
|
||||
@if [ -z "$(F)" ]; then echo "Usage: make test-related F=path/to/file.py"; exit 1; fi
|
||||
@echo " ▶ Finding related tests for $(F)..."
|
||||
@python3 $(ROOT)/scripts/find-related-tests.py --file "$(F)" --run
|
||||
|
||||
# ── Tier 3: Slow tests (requires Docker) ───────────────────
|
||||
test-integration: ## Run backend integration tests (Docker + testcontainers)
|
||||
@echo " ▶ Backend integration tests (Docker)..."
|
||||
@cd $(BACKEND) && . $(VENV) && timeout $(TIMEOUT_SLOW) python -m pytest tests/integration/ --run-integration -v --tb=short
|
||||
|
||||
test-e2e: ## Run Playwright E2E tests (requires running app)
|
||||
@echo " ▶ Playwright E2E tests..."
|
||||
@cd $(FRONTEND) && npx playwright test
|
||||
|
||||
# ── Full suite ─────────────────────────────────────────────
|
||||
test-all: ## Run full suite: fast tests + integration (skip installs)
|
||||
@$(MAKE) test
|
||||
@echo ""
|
||||
@echo " ▶ Integration tests (may take several minutes)..."
|
||||
@cd $(BACKEND) && . $(VENV) && timeout $(TIMEOUT_SLOW) python -m pytest tests/integration/ --run-integration -v --tb=short || echo " ⚠ Integration tests skipped (Docker not available or timed out)"
|
||||
@echo ""
|
||||
@echo " ✅ Full test suite complete"
|
||||
|
||||
# ── Coverage ───────────────────────────────────────────────
|
||||
coverage: coverage-backend coverage-frontend ## Generate coverage (backend + frontend)
|
||||
@echo " ✅ Coverage reports ready"
|
||||
@echo " Backend: $(BACKEND)/htmlcov/index.html"
|
||||
@echo " Frontend: $(FRONTEND)/coverage/index.html"
|
||||
|
||||
coverage-backend: ## Backend coverage (unit tests + --cov)
|
||||
@echo " ▶ Backend coverage..."
|
||||
@cd $(BACKEND) && . $(VENV) && timeout $(TIMEOUT_FAST) python -m pytest tests/ --ignore=tests/integration/ \
|
||||
--cov=src --cov-report=term-missing --cov-report=html
|
||||
|
||||
coverage-frontend: ## Frontend coverage (vitest --coverage)
|
||||
@echo " ▶ Frontend coverage..."
|
||||
@cd $(FRONTEND) && npx vitest run --coverage
|
||||
|
||||
# ── Linting ────────────────────────────────────────────────
|
||||
lint: lint-backend lint-frontend ## Run all linters
|
||||
|
||||
lint-backend: ## Backend ruff check
|
||||
@cd $(BACKEND) && . $(VENV) && python -m ruff check .
|
||||
|
||||
lint-frontend: ## Frontend eslint
|
||||
@cd $(FRONTEND) && npx eslint .
|
||||
466
README.md
466
README.md
@@ -1,369 +1,187 @@
|
||||
# superset-tools
|
||||
|
||||
[](https://www.python.org/)
|
||||
[](https://nodejs.org/)
|
||||
[](LICENSE)
|
||||
[](https://www.docker.com/)
|
||||
|
||||
**Инструменты автоматизации для Apache Superset: миграция, версионирование, аналитика и управление данными**
|
||||
**Корпоративная платформа для управления Apache Superset, перевода данных с помощью LLM и безопасной доставки аналитики между окружениями.**
|
||||
|
||||
## 📋 Содержание
|
||||
superset-tools помогает превратить набор разрозненных дашбордов, ручных переносов и служебных скриптов в управляемый процесс: с версиями, согласованиями, аудитом, фоновым выполнением и AI-ассистентом.
|
||||
|
||||
- [О проекте](#-о-проекте)
|
||||
- [Возможности](#-возможности)
|
||||
- [Архитектура](#-архитектура)
|
||||
- [Быстрый старт](#-быстрый-старт)
|
||||
- [Документация](#-документация)
|
||||
- [Тестирование](#-тестирование)
|
||||
- [Покрытие кода](#-покрытие-кода)
|
||||
- [Enterprise Clean Deployment](#-enterprise-clean-deployment)
|
||||
- [Авторизация](#-авторизация)
|
||||
- [Мониторинг](#-мониторинг)
|
||||
- [Вклад в проект](#-вклад-в-проект)
|
||||
- [Лицензия](#-лицензия)
|
||||
---
|
||||
|
||||
## 📖 О проекте
|
||||
## Когда Superset уже вырос, а процессы вокруг него — ещё нет
|
||||
|
||||
superset-tools — комплексная платформа для автоматизации работы с Apache Superset, предоставляющая инструменты для LLM-перевода контента баз данных, миграции дашбордов, управления версиями через Git, LLM-аналитики и многопользовательского контроля доступа. Система построена на модульной архитектуре с плагинной системой расширений.
|
||||
На старте Apache Superset обычно прост: аналитик создаёт датасет, собирает дашборд и показывает его коллегам. Но по мере роста компании появляются новые окружения, подразделения, языки, требования безопасности и сотни связанных объектов.
|
||||
|
||||
## ✨ Возможности
|
||||
В этот момент команда сталкивается с типичными вопросами:
|
||||
|
||||
### 🌐 LLM-перевод контента баз данных — главная фича
|
||||
- Как перенести дашборд из разработки в production и ничего не сломать?
|
||||
- Как понять, кто изменил отчёт и можно ли вернуть предыдущую версию?
|
||||
- Как перевести сотни тысяч наименований и описаний, сохранив отраслевую терминологию?
|
||||
- Как контролировать длительные операции без постоянного просмотра логов?
|
||||
- Как дать аналитикам свободу, не теряя управляемость и аудит?
|
||||
- Как подключить LLM к внутренним данным, не превращая это в набор несвязанных экспериментов?
|
||||
|
||||
superset-tools умеет переводить данные прямо в вашей БД: сотни тысяч строк номенклатуры, спецификаций, паспортов изделий — за один прогон. Никакой ручной работы, никаких копипаст в Google Translate.
|
||||
superset-tools объединяет эти задачи в одной платформе и делает работу с Superset воспроизводимой, наблюдаемой и безопасной.
|
||||
|
||||
Как это работает: выбираете таблицу-источник, указываете колонки, задаёте целевые языки и LLM-провайдера. superset-tools читает данные, отправляет в LLM и пишет перевод обратно — напрямую в целевую таблицу или через Superset SQL Lab.
|
||||
## Что получает бизнес
|
||||
|
||||
Ключевые возможности модуля:
|
||||
|
||||
- **Multi-language одной LLM-сессией** — одна строка переводится сразу на несколько языков (ru, en, de, fr, zh, kk и любые другие) в одном запросе к LLM. Экономия токенов и времени.
|
||||
- **Любой LLM-провайдер** — Qwen, DeepSeek, GPT-4o, Claude, YandexGPT, GigaChat — всё, что совместимо с OpenAI API. Меняете модель в конфигурации джобы.
|
||||
- **Preview-воркфлоу** — перед полноценным прогоном superset-tools показывает сэмпл перевода. Вы просматриваете строки, правите неудачные варианты, подтверждаете — и только потом запускаете полный прогон.
|
||||
- **Словари терминологии** — загрузите CSV/TSV с правильными переводами ваших доменных терминов: «плавка → melt», «сортопрокат → bar stock», «ТУ → technical specifications». Словари автоматически подмешиваются в промпт, LLM использует именно вашу терминологию.
|
||||
- **Inline-коррекция** — увидели плохой перевод в результатах? Правите прямо в UI, исправление улетает обратно в словарь. Каждый прогон делает систему умнее.
|
||||
- **Инкрементальный перевод** — повторный прогон переводит только новые и изменившиеся строки (сравнение по хешу ключа). Уже переведённое не трогается.
|
||||
- **Автоопределение языка источника** — не нужно указывать, на каком языке исходные данные. superset-tools определяет язык сам (через lingua-language-detector, без LLM — быстро и дёшево).
|
||||
- **Планировщик по cron** — настроили джобу на еженочный прогон? Она будет запускаться автоматически. APScheduler под капотом.
|
||||
- **Cache-механизм** — повторный перевод уже переведённого контента не тратит токены — результаты берутся из кэша.
|
||||
- **Аудит и метрики** — каждый прогон логируется: сколько строк переведено, сколько пропущено, упало, сколько токенов потрачено, сколько результата взято из кэша. Всё в структурированных событиях.
|
||||
- **Bulk-замена** — нашли, что LLM перевёл термин неконсистентно? Bulk find-and-replace по всем записям прогона.
|
||||
|
||||
> **Техническая справка:** модуль перевода — это ~120+ файлов backend на Python, собственная оркестрация (планировщик → executor → batch processor → LLM call), 4 уровня ретраев с адаптивным batch-sizer'ом, async HTTP-клиент для OpenAI API, поддержка Direct SQL (INSERT/UPSERT) и Superset SQL Lab, система промптов с Jaccard-семантикой для подбора словарных статей. Frontend — 5 страниц (джобы, прогоны, словари), 15+ Svelte-компонентов, real-time WebSocket-прогресс.
|
||||
|
||||
### 🔄 Миграция данных без страха
|
||||
|
||||
Перенос дашбордов и датасетов между dev, staging и production — рутинная операция, которая обычно отнимает часы и чревата ошибками. superset-tools делает её предсказуемой:
|
||||
|
||||
- **Dry-run режим** — перед реальными изменениями вы получаете детальный отчёт: какие объекты будут затронуты, какие риски обнаружены, что изменится в целевой среде. Никаких сюрпризов.
|
||||
- **Автоматический маппинг БД** — базы данных, ресурсы и идентификаторы сопоставляются между окружениями автоматически. Никакого ручного поиска и замены.
|
||||
- **Миграция legacy-данных** — встроенная поддержка переноса из SQLite в PostgreSQL. Устаревшие хранилища не помеха.
|
||||
|
||||
### 🌿 Git-интеграция: дашборды как код
|
||||
|
||||
Хватит копировать дашборды через export/import. Включите их в свой Git-процесс:
|
||||
|
||||
- **Версионирование** — каждый дашборд — это файл в репозитории. Полная история изменений, откат на любую версию, diff любой сложности.
|
||||
- **LLM-управление ветками** — создавайте ветки, коммитьте и сливайте изменения через natural language команды. «Создай ветку для эксперимента с отчётом по энергопотреблению и закоммить текущие дашборды».
|
||||
- **Деплой из Git** — push в целевую ветку автоматически применяет изменения на нужном окружении. CI/CD для дашбордов.
|
||||
- **Интеллектуальные сообщения коммитов** — LLM анализирует изменения и сам предлагает осмысленный заголовок коммита.
|
||||
|
||||
### 🤖 LLM-аналитика: ИИ присматривает за дашбордами
|
||||
|
||||
Не просто инструмент, а ваш ассистент по данным:
|
||||
|
||||
- **Автовалидация дашбордов** — LLM проверяет корректность метрик, источников данных и визуализаций. Нашёл подозрительный SQL в фильтре? Сообщит до того, как дашборд попадёт к пользователям.
|
||||
- **Генерация документации** — для любого датасета создаётся человекочитаемое описание: какие поля, откуда данные, какие есть зависимости.
|
||||
- **Assistant API** — управляйте superset-tools голосом или текстом на естественном языке. «Перенеси дашборд производства на staging», «Покажи историю изменений по датасету качества продукции».
|
||||
- **Умный коммитинг** — LLM анализирует изменения и генерирует сообщение коммита, отражающее суть. Никаких «fix» и «update».
|
||||
|
||||
### 📊 Управление и мониторинг: полный контроль
|
||||
|
||||
Одна консоль, чтобы править всеми:
|
||||
|
||||
- **RBAC** — гибкая ролевая модель: admin, analyst, viewer. Каждый видит и делает только то, что ему разрешено.
|
||||
- **Фоновые задачи с WebSocket** — запустили миграцию на час? Откройте Task Drawer и наблюдайте прогресс в реальном времени. Никаких логов, к которым нужно подключаться по SSH.
|
||||
- **Unified Reports** — единый формат отчётов для всех типов задач. Один эндпоинт — любые данные.
|
||||
- **Аудит** — каждое действие логируется. Кто, когда и что сделал — всегда можно выяснить.
|
||||
- **Retention-политики** — артефакты автоматически очищаются по расписанию. Диски не забиваются.
|
||||
|
||||
### 🔌 Плагинная архитектура: расширяйте без границ
|
||||
|
||||
superset-tools спроектирован как платформа. Хотите свою логику миграции? Свой источник данных? Свой триггер?
|
||||
|
||||
Каждый модуль — это изолированный плагин:
|
||||
|
||||
| Плагин | Назначение |
|
||||
| Результат | Как достигается |
|
||||
|---|---|
|
||||
| **TranslatePlugin** | LLM-перевод контента БД |
|
||||
| **MigrationPlugin** | Миграция дашбордов между окружениями |
|
||||
| **BackupPlugin** | Резервное копирование и восстановление |
|
||||
| **GitPlugin** | Полный цикл Git-операций |
|
||||
| **LLMAnalysisPlugin** | AI-валидация и генерация документации |
|
||||
| **MapperPlugin** | Маппинг колонок и ресурсов |
|
||||
| **DebugPlugin** | Диагностика и профилирование системы |
|
||||
| **SearchPlugin** | Полнотекстовый поиск по датасетам |
|
||||
| **Быстрый выпуск многоязычной отчётности** | Массовый LLM-перевод данных с корпоративными словарями и предварительной проверкой |
|
||||
| **Меньше ошибок при релизах** | Dry-run перед миграцией показывает изменения и риски до применения |
|
||||
| **Прозрачная история изменений** | Дашборды и связанные объекты версионируются через Git |
|
||||
| **Снижение ручной работы** | Повторяемые операции запускаются из интерфейса, по расписанию или через API |
|
||||
| **Контроль длительных процессов** | Прогресс, результаты и ошибки доступны в реальном времени |
|
||||
| **Управляемое использование AI** | Единые LLM-провайдеры, словари, аудит и подтверждение критических действий |
|
||||
| **Готовность к корпоративной среде** | Ролевая модель, ADFS SSO, аудит и поддержка закрытых контуров |
|
||||
|
||||
Пишите свои плагины, подключайте через простой Python API. Никакой магии — только чёткий контракт.
|
||||
## Ключевые сценарии
|
||||
|
||||
## 🏗️ Архитектура
|
||||
### Перевод корпоративных данных без ручной обработки
|
||||
|
||||
### Технологический стек
|
||||
Представьте каталог из 300 000 позиций: наименования продукции, технические характеристики, марки материалов и примечания. Отчётность нужно подготовить на английском, немецком и китайском языках, при этом терминология должна соответствовать внутренним стандартам компании.
|
||||
|
||||
**Backend:** Python 3.9+ (FastAPI, SQLAlchemy, APScheduler), PostgreSQL, GitPython, OpenAI API, Playwright
|
||||
В superset-tools команда выбирает источник, нужные поля и языки, подключает терминологический словарь и сначала получает небольшую выборку для проверки. После согласования система запускает полный перевод, сохраняет результат и формирует отчёт о выполнении.
|
||||
|
||||
**Frontend:** SvelteKit (Svelte 5.x), Vite, Tailwind CSS, WebSocket
|
||||
При следующем запуске переводятся только новые и изменившиеся записи. Уже обработанные данные и подтверждённые формулировки используются повторно, поэтому процесс становится быстрее и экономичнее.
|
||||
|
||||
**DevOps:** Docker & Docker Compose, PostgreSQL 16
|
||||
**Что поддерживается:**
|
||||
|
||||
### Модульная структура
|
||||
- несколько целевых языков за один проход;
|
||||
- OpenAI-совместимые модели и корпоративные LLM-шлюзы;
|
||||
- отраслевые словари из CSV/TSV;
|
||||
- предварительный просмотр результата;
|
||||
- исправление переводов прямо в интерфейсе;
|
||||
- инкрементальная обработка новых данных;
|
||||
- плановые запуски по расписанию;
|
||||
- статистика по строкам, ошибкам, кэшу и расходу токенов;
|
||||
- массовая корректировка неконсистентных терминов.
|
||||
|
||||
```
|
||||
superset-tools/
|
||||
├── backend/ # Backend API
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API маршруты
|
||||
│ │ ├── core/ # Ядро системы
|
||||
│ │ │ ├── task_manager/ # Управление задачами
|
||||
│ │ │ ├── auth/ # Авторизация
|
||||
│ │ │ ├── migration/ # Миграция данных
|
||||
│ │ │ └── plugins/ # Плагины
|
||||
│ │ ├── models/ # Модели данных
|
||||
│ │ ├── services/ # Бизнес-логика
|
||||
│ │ └── schemas/ # Pydantic схемы
|
||||
│ └── tests/
|
||||
├── frontend/ # SvelteKit приложение
|
||||
│ ├── src/
|
||||
│ │ ├── routes/ # Страницы
|
||||
│ │ ├── lib/
|
||||
│ │ │ ├── components/ # UI компоненты
|
||||
│ │ │ ├── stores/ # Svelte stores
|
||||
│ │ │ └── api/ # API клиент
|
||||
│ │ └── i18n/ # Мультиязычность
|
||||
│ └── tests/
|
||||
├── docker/ # Docker конфигурация
|
||||
├── docs/ # Документация
|
||||
└── specs/ # Спецификации
|
||||
```
|
||||
### Безопасная миграция между dev, staging и production
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
Ручной export/import плохо масштабируется: идентификаторы отличаются, подключения к БД называются по-разному, а последствия становятся видны только после релиза.
|
||||
|
||||
### Требования
|
||||
superset-tools сначала выполняет dry-run и показывает, какие объекты будут созданы или изменены, какие зависимости найдены и где есть риски. Только после проверки команда запускает реальную миграцию.
|
||||
|
||||
- **Docker (рекомендуется):** Docker Engine 24+, Docker Compose v2, 4 GB RAM
|
||||
- **Локальная разработка:** Python 3.9+, Node.js 18+, npm, 2 GB RAM, 5 GB диска
|
||||
Автоматический маппинг помогает сопоставить базы данных и ресурсы между окружениями, а единый отчёт сохраняет результат операции для последующего аудита.
|
||||
|
||||
### Docker (рекомендуется)
|
||||
**Бизнес-эффект:** меньше аварийных исправлений, быстрее выпуск изменений и понятная процедура согласования релиза.
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd superset-tools
|
||||
docker compose up --build
|
||||
```
|
||||
### Дашборды как управляемые цифровые активы
|
||||
|
||||
После запуска:
|
||||
- Frontend: http://localhost:8000
|
||||
- Backend API: http://localhost:8001
|
||||
- PostgreSQL: localhost:5432
|
||||
Дашборд — это не просто экран с графиками. В нём зафиксированы бизнес-метрики, SQL-логика, фильтры и договорённости между подразделениями. Поэтому его изменения должны быть такими же прозрачными, как изменения программного кода.
|
||||
|
||||
### Локальная разработка
|
||||
Git-интеграция superset-tools позволяет:
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python3 -m uvicorn src.app:app --reload --port 8000
|
||||
- хранить историю версий;
|
||||
- сравнивать изменения;
|
||||
- возвращаться к стабильному состоянию;
|
||||
- разделять экспериментальную и промышленную работу по веткам;
|
||||
- доставлять согласованные изменения в целевое окружение;
|
||||
- генерировать понятные сообщения коммитов с помощью LLM.
|
||||
|
||||
# Frontend (в новом терминале)
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev -- --port 5173
|
||||
```
|
||||
В результате команда получает единый процесс для аналитики и разработки, а ключевые отчёты перестают зависеть от памяти отдельных сотрудников.
|
||||
|
||||
### Начальная настройка
|
||||
### AI-ассистент для повседневных операций
|
||||
|
||||
```bash
|
||||
# Переменные окружения
|
||||
cp .env.example backend/.env
|
||||
Платформой можно управлять через чат на естественном языке. Пользователь формулирует задачу так, как привык обсуждать её с коллегами:
|
||||
|
||||
# Инициализация БД
|
||||
cd backend && source .venv/bin/activate
|
||||
python src/scripts/init_auth_db.py
|
||||
> «Проверь дашборд производства перед публикацией»
|
||||
>
|
||||
> «Покажи последние изменения в отчёте по качеству»
|
||||
>
|
||||
> «Подготовь перенос дашборда на staging и сначала покажи риски»
|
||||
>
|
||||
> «Проанализируй загруженную спецификацию и найди связанные датасеты»
|
||||
|
||||
# Создание администратора
|
||||
python src/scripts/create_admin.py --username admin --password '<temporary-secret>'
|
||||
```
|
||||
AI-агент сохраняет контекст диалога, умеет работать с PDF и XLSX и запрашивает подтверждение перед критическими действиями. Это не отдельный демонстрационный чат, а дополнительный интерфейс к реальным операциям платформы.
|
||||
|
||||
> Полный каталог переменных окружения — в [`.env.example`](.env.example).
|
||||
### Единый центр контроля
|
||||
|
||||
### Offline-бандл
|
||||
Все длительные процессы — перевод, миграция, резервное копирование, анализ и Git-операции — выполняются как управляемые фоновые задачи.
|
||||
|
||||
```bash
|
||||
# Загрузка образа
|
||||
xz -dc dist/docker/superset-tools.20260517.tar.xz | docker load
|
||||
export POSTGRES_PASSWORD="my-strong-password"
|
||||
docker compose -f dist/docker/docker-compose.light.yml up -d
|
||||
```
|
||||
Пользователь видит:
|
||||
|
||||
Сборка бандла: `./build.sh bundle:light v1.0.0` (light, ~104 MB) или `./build.sh bundle v1.0.0` (full).
|
||||
- текущий статус и прогресс;
|
||||
- этап, на котором находится операция;
|
||||
- предупреждения и ошибки;
|
||||
- итоговый отчёт;
|
||||
- историю запусков;
|
||||
- автора и время действия.
|
||||
|
||||
## 📖 Документация
|
||||
Администратору не нужно подключаться к серверу и искать нужный фрагмент лога, а бизнес-пользователь не остаётся перед бесконечным индикатором загрузки.
|
||||
|
||||
- [Установка и настройка](docs/installation.md)
|
||||
## Кому подходит superset-tools
|
||||
|
||||
### BI-командам
|
||||
|
||||
Для управления большим количеством дашбордов и датасетов, выпуска изменений между окружениями и подготовки многоязычной отчётности.
|
||||
|
||||
### Аналитикам данных
|
||||
|
||||
Для запуска типовых операций из единого интерфейса, отслеживания результатов и работы с AI без необходимости писать служебные скрипты.
|
||||
|
||||
### DevOps и платформенным инженерам
|
||||
|
||||
Для воспроизводимых поставок, Git-процессов, интеграции с CI/CD, фоновых задач и развёртывания в закрытом контуре.
|
||||
|
||||
### Руководителям ИТ, BI и DWH
|
||||
|
||||
Для прозрачности процессов, разграничения доступа, истории изменений и снижения зависимости от ручных действий отдельных специалистов.
|
||||
|
||||
### Командам локализации и управления данными
|
||||
|
||||
Для массового перевода справочников и технического контента с контролем терминологии и качества результата.
|
||||
|
||||
## Чем платформа отличается от набора скриптов
|
||||
|
||||
Скрипт хорошо решает одну задачу один раз. Корпоративный процесс должен переживать рост объёмов, смену сотрудников, ошибки внешних систем и новые требования безопасности.
|
||||
|
||||
superset-tools добавляет вокруг операций необходимый управленческий контур:
|
||||
|
||||
- единый пользовательский интерфейс;
|
||||
- роли и права доступа;
|
||||
- предварительную проверку изменений;
|
||||
- фоновые задачи и повторные попытки;
|
||||
- историю и аудит;
|
||||
- отчёты в едином формате;
|
||||
- расписания и retention-политики;
|
||||
- API для внешних систем;
|
||||
- расширение через плагины.
|
||||
|
||||
## Корпоративное использование
|
||||
|
||||
Платформа рассчитана как на обычное Docker-развёртывание, так и на изолированные корпоративные сети.
|
||||
|
||||
Поддерживаются:
|
||||
|
||||
- локальная авторизация и ADFS SSO;
|
||||
- роли `admin`, `analyst` и `viewer`;
|
||||
- корпоративные CA-сертификаты;
|
||||
- собственные LLM-шлюзы и OpenAI-совместимые API;
|
||||
- развёртывание без доступа к внешним источникам;
|
||||
- очищенные enterprise-дистрибутивы;
|
||||
- журналирование действий и результатов операций.
|
||||
|
||||
## Как устроен продукт
|
||||
|
||||
Пользователь работает с единой веб-платформой, которая объединяет управление дашбордами, датасетами, миграциями, переводами, Git-репозиториями и фоновыми задачами. AI-агент предоставляет альтернативный диалоговый интерфейс, а API позволяет подключать CI/CD, Airflow, cron и внутренние корпоративные системы.
|
||||
|
||||
Архитектура модульная: стандартные возможности реализованы как плагины, поэтому платформу можно расширять под собственные источники данных и бизнес-процессы.
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
Инструкции по Docker-развёртыванию, локальной разработке, настройке LLM, SSO, сертификатов и закрытого контура находятся в [INSTALL.md](INSTALL.md).
|
||||
|
||||
## Документация
|
||||
|
||||
- [Установка и настройка](INSTALL.md)
|
||||
- [Архитектура системы](docs/architecture.md)
|
||||
- [Архитектурные решения (ADR)](docs/adr/README.md)
|
||||
- [API документация](http://localhost:8001/docs)
|
||||
- [Настройка окружений](docs/settings.md)
|
||||
- [Архитектурные решения](docs/adr/README.md)
|
||||
- [Enterprise Clean Deployment](docs/enterprise-clean.md)
|
||||
- [API после запуска](http://localhost:8001/docs)
|
||||
- [Руководство для контрибьюторов](CONTRIBUTING.md)
|
||||
|
||||
## 🧪 Тестирование
|
||||
|
||||
### Запуск тестов
|
||||
|
||||
```bash
|
||||
# Backend тесты
|
||||
cd backend && source .venv/bin/activate && pytest
|
||||
|
||||
# Frontend тесты
|
||||
cd frontend && npm run test
|
||||
|
||||
# Конкретный тест
|
||||
pytest tests/test_auth.py::test_create_user
|
||||
```
|
||||
|
||||
### 📊 Покрытие кода
|
||||
|
||||
Сводный отчёт о покрытии генерируется скриптом `scripts/coverage-summary.sh`:
|
||||
|
||||
```bash
|
||||
# Полный запуск (backend integration + frontend)
|
||||
./scripts/coverage-summary.sh
|
||||
|
||||
# Backend unit-тесты (SQLite) + frontend (быстрее, не требует Docker)
|
||||
./scripts/coverage-summary.sh --unit
|
||||
|
||||
# Только frontend
|
||||
./scripts/coverage-summary.sh --frontend-only
|
||||
|
||||
# Только backend unit
|
||||
./scripts/coverage-summary.sh --backend-only --unit
|
||||
|
||||
# Указать директорию для отчёта
|
||||
./scripts/coverage-summary.sh --output-dir ./reports/coverage
|
||||
```
|
||||
|
||||
Скрипт выполняет:
|
||||
1. Запуск backend-тестов (pytest) с `--cov=src` — unit (`--unit`) или integration (`--run-integration`)
|
||||
2. Запуск frontend-тестов (vitest) с `--coverage`
|
||||
3. Парсинг результатов тестов и процентов покрытия
|
||||
4. Генерацию единого HTML-отчёта в `coverage-summary/index.html` со сводкой по обоим стекам
|
||||
|
||||
**Текущие показатели:**
|
||||
|
||||
| Стек | Тип тестов | Процент | Покрытие (Stmts) |
|
||||
|------|-----------|---------|------------------|
|
||||
| Backend (unit) | 1723 | 1721/2 ✅ | 48% |
|
||||
| Backend (integration) | 167 | 167/0 ✅ | 12% |
|
||||
| Frontend | 2443 | 2442/1 ✅ | 99.25% |
|
||||
|
||||
> HTML-отчёты coverage по каждому стеку открываются из сводного отчёта по ссылкам.
|
||||
|
||||
## 🔐 SSL/TLS конфигурация
|
||||
|
||||
### Сертификаты для HTTPS (nginx)
|
||||
|
||||
Для включения HTTPS поместите файлы сертификатов в директорию `./certs/`:
|
||||
|
||||
**Вариант A — отдельные файлы (без пароля):**
|
||||
```
|
||||
./certs/server.crt # SSL сертификат
|
||||
./certs/server.key # Приватный ключ (незашифрованный)
|
||||
```
|
||||
|
||||
**Вариант B — зашифрованный ключ + пароль:**
|
||||
```
|
||||
./certs/server.crt # SSL сертификат
|
||||
./certs/server.key # Приватный ключ (зашифрован, с DEK-Info)
|
||||
SSL_KEY_PASSPHRASE=my-passphrase # переменная окружения для расшифровки
|
||||
```
|
||||
|
||||
**Вариант C — PKCS#12 контейнер:**
|
||||
```
|
||||
./certs/server.p12 # Контейнер с сертификатом + ключом
|
||||
SSL_KEY_PASSPHRASE=my-passphrase # пароль от контейнера
|
||||
```
|
||||
|
||||
Entrypoint автоматически:
|
||||
1. Извлечёт `.crt` и `.key` из `.p12` (если нет отдельных файлов)
|
||||
2. Расшифрует приватный ключ через `openssl rsa` (если есть `SSL_KEY_PASSPHRASE`)
|
||||
3. Передаст расшифрованный ключ nginx (ключ остаётся в tmpfs контейнера)
|
||||
|
||||
> **Безопасность:** Расшифрованный ключ хранится только в tmpfs `/etc/nginx/ssl/` внутри контейнера и не пишется на диск хоста. Пароль задаётся через переменную окружения, а не через файл на volume.
|
||||
|
||||
### Корпоративные CA-сертификаты
|
||||
|
||||
Положите `.crt`/`.pem` файлы в `./certs/` — entrypoint установит их в системное хранилище Alpine и NSS (Chromium/Playwright). Поддерживаются цепочки из нескольких CA (Root → Intermediate).
|
||||
|
||||
### LLM CA-сертификаты
|
||||
|
||||
Если LLM-провайдер или Superset используют корпоративный PKI — укажите HTTP URL для скачивания CA-сертификатов:
|
||||
```bash
|
||||
LLM_CA_CERT_URLS="http://pki.company.com/root-ca.crt http://pki.company.com/intermediate-ca.crt"
|
||||
```
|
||||
Сертификаты скачиваются на старте backend и agent контейнеров (через `certs.sh:download_llm_ca_certs()`), конвертируются из DER в PEM при необходимости, и устанавливаются в системное хранилище.
|
||||
|
||||
Дополнительные сертификаты можно разместить в `CERTS_PATH=./certs` (volume mount).
|
||||
|
||||
### Диагностика SSL
|
||||
|
||||
```bash
|
||||
# Полная проверка цепочки доверия
|
||||
scripts/check_llm_certs.py
|
||||
|
||||
# Контейнерная диагностика
|
||||
scripts/diag_container.py --target your-llm-provider.com:443
|
||||
```
|
||||
|
||||
Подробнее — в [ADR-0009](docs/adr/ADR-0009-ssl-certificate-management.md).
|
||||
`LLM_SSL_VERIFY` удалён в 0.2.x — TLS verify всегда включён.
|
||||
|
||||
## 🏢 Enterprise Clean Deployment
|
||||
|
||||
Для разворота в корпоративной сети с очищенным дистрибутивом (без тестовых данных, с запретом внешних источников и обязательной compliance-проверкой) используется профиль **enterprise clean**.
|
||||
|
||||
Поддерживаются CLI, API и TUI flows. Подробная документация — в [docs/enterprise-clean.md](docs/enterprise-clean.md).
|
||||
|
||||
## 🔐 Авторизация
|
||||
|
||||
Система поддерживает два метода аутентификации:
|
||||
|
||||
1. **Локальная** (username/password)
|
||||
2. **ADFS SSO** (Active Directory Federation Services)
|
||||
|
||||
Управление пользователями и ролями — через `POST /api/admin/users` и `POST /api/admin/roles`. Документация — `docs/installation.md`.
|
||||
|
||||
## 📊 Мониторинг
|
||||
|
||||
- **Dashboard Hub** — управление дашбордами с Git-статусом
|
||||
- **Dataset Hub** — управление датасетами с прогрессом маппинга
|
||||
- **Task Drawer** — мониторинг выполнения фоновых задач
|
||||
- **Unified Reports** — унифицированные отчеты по всем типам задач
|
||||
|
||||
API: `GET /api/reports?page=1&page_size=20` (фильтры по статусу, типу, дате).
|
||||
|
||||
## 💻 Примеры скриптов
|
||||
|
||||
Примеры интеграции с внешними системами (Airflow, CI/CD, cron) — в [`examples/`](./examples/):
|
||||
|
||||
- [Python](examples/maintenance-api-python.py)
|
||||
- [Bash](examples/maintenance-api-bash.sh)
|
||||
|
||||
Скрипты демонстрируют аутентификацию через API Key (`X-API-Key`), запуск и завершение maintenance-событий, обработку ошибок.
|
||||
|
||||
## 🤝 Вклад в проект
|
||||
|
||||
Мы приветствуем contributions! См. [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## 📄 Лицензия
|
||||
## Лицензия
|
||||
|
||||
Проект распространяется под лицензией [MIT](LICENSE).
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# agent/src/ss_tools/agent/__init__.py
|
||||
# #region AgentChat [C:3] [TYPE Module] [SEMANTICS agent-chat]
|
||||
# #region Agent.Init.AgentChat [C:3] [TYPE Module] [SEMANTICS agent-chat]
|
||||
# @defgroup AgentChat LangGraph-based Gradio agent — streaming chat with HITL guardrails.
|
||||
# @LAYER Application
|
||||
# @RELATION DISPATCHES -> [AgentChat.Config]
|
||||
@@ -15,4 +15,4 @@
|
||||
# @RELATION DISPATCHES -> [AgentChat.Persistence]
|
||||
# @RELATION DISPATCHES -> [AgentChat.Document.Parser]
|
||||
# @RELATION DISPATCHES -> [AgentChat.GradioApp]
|
||||
# #endregion AgentChat
|
||||
# #endregion Agent.Init.AgentChat
|
||||
|
||||
@@ -358,117 +358,120 @@ async def handle_resume( # noqa: C901
|
||||
conversation_id: str, action: str,
|
||||
user_jwt: str = "", env_id: str | None = None,
|
||||
) -> AsyncGenerator[str]:
|
||||
from ss_tools.agent.context import set_user_jwt
|
||||
from ss_tools.agent.context import reset_user_jwt, set_user_jwt
|
||||
from ss_tools.shared.logger import logger
|
||||
|
||||
set_user_jwt(user_jwt)
|
||||
pending = _pending_confirmations.pop(conversation_id, None)
|
||||
if pending is not None:
|
||||
if action == "deny":
|
||||
yield json.dumps({
|
||||
"content": "⏹️ Операция отменена",
|
||||
"metadata": {"type": "confirm_resolved", "result": "denied"},
|
||||
})
|
||||
return
|
||||
if action == "confirm":
|
||||
logger.reason(
|
||||
"Fast-path confirmation resume",
|
||||
payload={"tool": pending.get("tool_name"), "conv_id": conversation_id},
|
||||
extra={"src": "AgentChat.Confirmation"},
|
||||
)
|
||||
tool_name = str(pending.get("tool_name") or "unknown_action")
|
||||
tool_args = normalize_tool_args(pending.get("tool_args"))
|
||||
yield json.dumps({
|
||||
"content": "▶️ Операция подтверждена",
|
||||
"metadata": {"type": "confirm_resolved", "result": "confirmed"},
|
||||
})
|
||||
yield json.dumps({
|
||||
"content": f"🛠️ {tool_name}",
|
||||
"metadata": {"type": "tool_start", "tool": tool_name, "input": tool_args},
|
||||
})
|
||||
tool_obj = find_tool(tool_name)
|
||||
if tool_obj is None:
|
||||
error = f"Unknown tool: {tool_name}"
|
||||
logger.explore(
|
||||
"Unknown tool in resume",
|
||||
payload={"tool": tool_name}, error=error,
|
||||
extra={"src": "AgentChat.Confirmation"},
|
||||
)
|
||||
user_jwt_token = set_user_jwt(user_jwt)
|
||||
try:
|
||||
pending = _pending_confirmations.pop(conversation_id, None)
|
||||
if pending is not None:
|
||||
if action == "deny":
|
||||
yield json.dumps({
|
||||
"content": f"❌ {tool_name} — {error}",
|
||||
"metadata": {"type": "tool_error", "tool": tool_name, "error": error},
|
||||
"content": "⏹️ Операция отменена",
|
||||
"metadata": {"type": "confirm_resolved", "result": "denied"},
|
||||
})
|
||||
return
|
||||
try:
|
||||
output = await tool_obj.ainvoke(tool_args)
|
||||
except Exception as exc:
|
||||
logger.explore(
|
||||
"Tool invocation failed in resume",
|
||||
payload={"tool": tool_name}, error=str(exc),
|
||||
if action == "confirm":
|
||||
logger.reason(
|
||||
"Fast-path confirmation resume",
|
||||
payload={"tool": pending.get("tool_name"), "conv_id": conversation_id},
|
||||
extra={"src": "AgentChat.Confirmation"},
|
||||
)
|
||||
tool_name = str(pending.get("tool_name") or "unknown_action")
|
||||
tool_args = normalize_tool_args(pending.get("tool_args"))
|
||||
yield json.dumps({
|
||||
"content": f"❌ {tool_name} — {exc}",
|
||||
"metadata": {"type": "tool_error", "tool": tool_name, "error": str(exc)},
|
||||
"content": "▶️ Операция подтверждена",
|
||||
"metadata": {"type": "confirm_resolved", "result": "confirmed"},
|
||||
})
|
||||
return
|
||||
yield json.dumps({
|
||||
"content": f"✅ {tool_name}",
|
||||
"metadata": {"type": "tool_end", "tool": tool_name, "output": {"result": str(output)[:500]}},
|
||||
})
|
||||
# Format tool output via LLM for a human-readable response
|
||||
async for chunk in _format_tool_output_via_llm(tool_name, str(output)):
|
||||
yield chunk
|
||||
logger.reflect(
|
||||
"Fast-path confirmation completed",
|
||||
payload={"tool": tool_name},
|
||||
extra={"src": "AgentChat.Confirmation"},
|
||||
)
|
||||
return
|
||||
|
||||
logger.reason(
|
||||
"LangGraph checkpoint resume",
|
||||
payload={"conv_id": conversation_id, "action": action},
|
||||
extra={"src": "AgentChat.Confirmation"},
|
||||
)
|
||||
agent = await create_agent(get_all_tools(), env_id, interrupt_before=[])
|
||||
if action == "confirm":
|
||||
config = {"configurable": {"thread_id": conversation_id}}
|
||||
yield json.dumps({
|
||||
"content": "▶️ Операция подтверждена",
|
||||
"metadata": {"type": "confirm_resolved", "result": "confirmed"},
|
||||
})
|
||||
async for event in agent.astream_events(None, config=config, version="v2"):
|
||||
kind = event.get("event")
|
||||
if kind == "on_chat_model_stream":
|
||||
chunk = event["data"]["chunk"]
|
||||
if hasattr(chunk, "content") and chunk.content:
|
||||
yield json.dumps({
|
||||
"content": chunk.content,
|
||||
"metadata": {"type": "stream_token", "token": chunk.content},
|
||||
})
|
||||
elif kind == "on_tool_start":
|
||||
tool_name = event["name"]
|
||||
yield json.dumps({
|
||||
"content": f"🛠️ {tool_name}",
|
||||
"metadata": {"type": "tool_start", "tool": tool_name, "input": event["data"].get("input", {})},
|
||||
"metadata": {"type": "tool_start", "tool": tool_name, "input": tool_args},
|
||||
})
|
||||
elif kind == "on_tool_end":
|
||||
tool_name = event["name"]
|
||||
output = event["data"].get("output", "")
|
||||
tool_obj = find_tool(tool_name)
|
||||
if tool_obj is None:
|
||||
error = f"Unknown tool: {tool_name}"
|
||||
logger.explore(
|
||||
"Unknown tool in resume",
|
||||
payload={"tool": tool_name}, error=error,
|
||||
extra={"src": "AgentChat.Confirmation"},
|
||||
)
|
||||
yield json.dumps({
|
||||
"content": f"❌ {tool_name} — {error}",
|
||||
"metadata": {"type": "tool_error", "tool": tool_name, "error": error},
|
||||
})
|
||||
return
|
||||
try:
|
||||
output = await tool_obj.ainvoke(tool_args)
|
||||
except Exception as exc:
|
||||
logger.explore(
|
||||
"Tool invocation failed in resume",
|
||||
payload={"tool": tool_name}, error=str(exc),
|
||||
extra={"src": "AgentChat.Confirmation"},
|
||||
)
|
||||
yield json.dumps({
|
||||
"content": f"❌ {tool_name} — {exc}",
|
||||
"metadata": {"type": "tool_error", "tool": tool_name, "error": str(exc)},
|
||||
})
|
||||
return
|
||||
yield json.dumps({
|
||||
"content": f"✅ {tool_name}",
|
||||
"metadata": {"type": "tool_end", "tool": tool_name, "output": {"result": str(output)[:500]}},
|
||||
})
|
||||
elif action == "deny":
|
||||
logger.reflect(
|
||||
"Checkpoint resume denied",
|
||||
payload={"conv_id": conversation_id},
|
||||
# Format tool output via LLM for a human-readable response
|
||||
async for chunk in _format_tool_output_via_llm(tool_name, str(output)):
|
||||
yield chunk
|
||||
logger.reflect(
|
||||
"Fast-path confirmation completed",
|
||||
payload={"tool": tool_name},
|
||||
extra={"src": "AgentChat.Confirmation"},
|
||||
)
|
||||
return
|
||||
|
||||
logger.reason(
|
||||
"LangGraph checkpoint resume",
|
||||
payload={"conv_id": conversation_id, "action": action},
|
||||
extra={"src": "AgentChat.Confirmation"},
|
||||
)
|
||||
yield json.dumps({
|
||||
"content": "⏹️ Операция отменена",
|
||||
"metadata": {"type": "confirm_resolved", "result": "denied"},
|
||||
})
|
||||
agent = await create_agent(get_all_tools(), env_id, interrupt_before=[])
|
||||
if action == "confirm":
|
||||
config = {"configurable": {"thread_id": conversation_id}}
|
||||
yield json.dumps({
|
||||
"content": "▶️ Операция подтверждена",
|
||||
"metadata": {"type": "confirm_resolved", "result": "confirmed"},
|
||||
})
|
||||
async for event in agent.astream_events(None, config=config, version="v2"):
|
||||
kind = event.get("event")
|
||||
if kind == "on_chat_model_stream":
|
||||
chunk = event["data"]["chunk"]
|
||||
if hasattr(chunk, "content") and chunk.content:
|
||||
yield json.dumps({
|
||||
"content": chunk.content,
|
||||
"metadata": {"type": "stream_token", "token": chunk.content},
|
||||
})
|
||||
elif kind == "on_tool_start":
|
||||
tool_name = event["name"]
|
||||
yield json.dumps({
|
||||
"content": f"🛠️ {tool_name}",
|
||||
"metadata": {"type": "tool_start", "tool": tool_name, "input": event["data"].get("input", {})},
|
||||
})
|
||||
elif kind == "on_tool_end":
|
||||
tool_name = event["name"]
|
||||
output = event["data"].get("output", "")
|
||||
yield json.dumps({
|
||||
"content": f"✅ {tool_name}",
|
||||
"metadata": {"type": "tool_end", "tool": tool_name, "output": {"result": str(output)[:500]}},
|
||||
})
|
||||
elif action == "deny":
|
||||
logger.reflect(
|
||||
"Checkpoint resume denied",
|
||||
payload={"conv_id": conversation_id},
|
||||
extra={"src": "AgentChat.Confirmation"},
|
||||
)
|
||||
yield json.dumps({
|
||||
"content": "⏹️ Операция отменена",
|
||||
"metadata": {"type": "confirm_resolved", "result": "denied"},
|
||||
})
|
||||
finally:
|
||||
reset_user_jwt(user_jwt_token)
|
||||
# #endregion AgentChat.Confirmation.HandleResume
|
||||
# #endregion AgentChat.Confirmation
|
||||
|
||||
@@ -59,17 +59,21 @@ from ss_tools.agent._persistence import (
|
||||
prefetch_databases,
|
||||
save_conversation,
|
||||
)
|
||||
from ss_tools.agent.context import set_user_jwt, set_user_role
|
||||
from ss_tools.agent.context import reset_user_jwt, reset_user_role, set_user_jwt, set_user_role
|
||||
from ss_tools.agent.document_parser import parse_upload
|
||||
from ss_tools.agent.langgraph_setup import create_agent
|
||||
from ss_tools.agent.middleware import log_tool_event
|
||||
from ss_tools.agent.langgraph_setup import create_agent, llm_diagnostics
|
||||
from ss_tools.agent.middleware import (
|
||||
close_lifecycle_resources,
|
||||
emit_lifecycle_event,
|
||||
extract_trace_id_from_request,
|
||||
log_tool_event,
|
||||
)
|
||||
from ss_tools.agent.tools import (
|
||||
_redact_sensitive_fields,
|
||||
drain_tool_retry_events,
|
||||
get_all_tools,
|
||||
start_tool_retry_event_buffer,
|
||||
)
|
||||
from ss_tools.shared.cot_logger import seed_trace_id
|
||||
from ss_tools.shared.logger import logger
|
||||
|
||||
MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
@@ -85,6 +89,34 @@ def _now_iso() -> str:
|
||||
# #endregion AgentChat.GradioApp.NowIso
|
||||
|
||||
|
||||
# #region AgentChat.GradioApp.LlmFailureDiagnostics [C:2] [TYPE Function] [SEMANTICS agent-chat,llm,error,observability]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Classify an LLM exception without logging credentials, user input, or provider response bodies.
|
||||
# @POST Returns stable aggregate labels suitable for lifecycle events and production diagnosis.
|
||||
def _llm_failure_diagnostics(exc: BaseException) -> dict[str, str | bool]:
|
||||
"""Return a safe error fingerprint for a failed upstream LLM call."""
|
||||
cause = exc.__cause__ or exc.__context__
|
||||
diagnostics = {
|
||||
**llm_diagnostics(),
|
||||
"exception_type": type(exc).__name__,
|
||||
"cause_type": type(cause).__name__ if cause else "",
|
||||
"retryable": True,
|
||||
}
|
||||
cause_name = diagnostics["cause_type"]
|
||||
if cause_name in {"gaierror", "NameResolutionError"}:
|
||||
diagnostics["failure_class"] = "dns"
|
||||
elif cause_name in {"ConnectionRefusedError"}:
|
||||
diagnostics["failure_class"] = "connection_refused"
|
||||
elif cause_name in {"SSLError", "ConnectError"}:
|
||||
diagnostics["failure_class"] = "tls_or_connect"
|
||||
elif isinstance(exc, (APITimeoutError, httpx.ReadTimeout)):
|
||||
diagnostics["failure_class"] = "timeout"
|
||||
else:
|
||||
diagnostics["failure_class"] = "connection"
|
||||
return diagnostics
|
||||
# #endregion AgentChat.GradioApp.LlmFailureDiagnostics
|
||||
|
||||
|
||||
# #region AgentChat.GradioApp.BuildAgentContext [C:3] [TYPE Function] [SEMANTICS agent-chat,context,runtime,build]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Build hidden RUNTIME CONTEXT block with datetime, prefetched dashboards and databases.
|
||||
@@ -347,23 +379,38 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
|
||||
except JWTError:
|
||||
user_jwt_str = ""
|
||||
|
||||
set_user_jwt(user_jwt_str)
|
||||
user_jwt_token = set_user_jwt(user_jwt_str)
|
||||
user_role = token_payload.get("role") or token_payload.get("user_role") or "viewer"
|
||||
set_user_role(user_role)
|
||||
user_role_token = set_user_role(user_role)
|
||||
|
||||
# ── Per-user lock ──
|
||||
user_id = user_id_str or (extract_user_id(user_jwt_str) if user_jwt_str else "admin")
|
||||
if _user_locks.get(user_id, False):
|
||||
reset_user_jwt(user_jwt_token)
|
||||
reset_user_role(user_role_token)
|
||||
yield json.dumps({"metadata": {"type": "error", "code": "CONCURRENT_SEND", "detail": "Другой запрос уже обрабатывается. Дождитесь завершения перед отправкой нового."}})
|
||||
return
|
||||
_user_locks[user_id] = True
|
||||
_request_start_time = time.monotonic()
|
||||
_tool_names_in_request: set[str] = set()
|
||||
_request_result: str | None = None
|
||||
_request_error_code: str | None = None
|
||||
_attempts_used: int = 0
|
||||
conv_id: str | None = None
|
||||
|
||||
try:
|
||||
# ── Resolve conversation ID early (needed for file persistence) ──
|
||||
# ── Resolve conversation ID and trace ID ──
|
||||
conv_id = conversation_id or str(uuid.uuid4())
|
||||
_trace_id = seed_trace_id()
|
||||
_trace_id = extract_trace_id_from_request(request)
|
||||
is_resume = action in ("confirm", "deny")
|
||||
emit_lifecycle_event(
|
||||
"AGENT_REQUEST_STARTED",
|
||||
conversation_id=conv_id,
|
||||
user_id=user_id,
|
||||
environment_id=env_id,
|
||||
action=action,
|
||||
is_resume=is_resume or None,
|
||||
)
|
||||
logger.reason(
|
||||
"Agent handler invoked",
|
||||
payload={"user_id": user_id, "conv_id": conv_id, "action": action, "env_id": env_id, "is_resume": is_resume, "msg_len": len(str(message))},
|
||||
@@ -480,6 +527,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
|
||||
# Build descriptive title from captured tool_name
|
||||
title = f"{'✅' if action == 'confirm' else '⏹️'} {tool_name or 'Операция'}" if tool_name else f"HITL: {action}"
|
||||
await save_conversation(conv_id or str(uuid.uuid4()), title, user_id)
|
||||
_request_result = "completed"
|
||||
return
|
||||
|
||||
# ── Normal send path ──
|
||||
@@ -519,8 +567,14 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
|
||||
|
||||
try:
|
||||
for attempt in range(max_attempts):
|
||||
_attempts_used = attempt + 1
|
||||
try:
|
||||
emitted_any = False
|
||||
emit_lifecycle_event(
|
||||
"AGENT_LLM_STARTED",
|
||||
conversation_id=conv_id,
|
||||
attempt=attempt + 1,
|
||||
)
|
||||
async for event in agent.astream_events(
|
||||
{"messages": [HumanMessage(content=agent_text)]},
|
||||
config=config,
|
||||
@@ -531,6 +585,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
|
||||
yield json.dumps(retry_event)
|
||||
kind = event.get("event")
|
||||
if kind in ("on_tool_start", "on_tool_end", "on_tool_error"):
|
||||
_tool_names_in_request.add(event.get("name", "unknown"))
|
||||
await log_tool_event(event, conv_id)
|
||||
if kind == "on_chat_model_stream":
|
||||
chunk = event["data"]["chunk"]
|
||||
@@ -616,6 +671,11 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
|
||||
}
|
||||
)
|
||||
|
||||
emit_lifecycle_event(
|
||||
"AGENT_LLM_COMPLETED",
|
||||
conversation_id=conv_id,
|
||||
attempt=attempt + 1,
|
||||
)
|
||||
state = await agent.aget_state(config)
|
||||
for retry_event in drain_tool_retry_events():
|
||||
emitted_any = True
|
||||
@@ -623,6 +683,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
|
||||
if getattr(state, "next", None):
|
||||
emitted_any = True
|
||||
yield confirmation_payload(conv_id, state, visible_user_text, user_role, env_id)
|
||||
_request_result = "completed"
|
||||
return
|
||||
elif not emitted_any:
|
||||
yield json.dumps(
|
||||
@@ -637,7 +698,20 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
|
||||
_llm_status["status"] = "unavailable"
|
||||
_llm_status["last_error"] = str(exc)
|
||||
_llm_status["last_check_ts"] = time.time()
|
||||
logger.explore("LLM provider connection failed", error=str(exc), extra={"src": "AgentChat.GradioApp.Handler"})
|
||||
diagnostics = _llm_failure_diagnostics(exc)
|
||||
logger.explore(
|
||||
"LLM provider connection failed",
|
||||
payload={"conv_id": conv_id, "attempt": _attempts_used, **diagnostics},
|
||||
error=str(exc),
|
||||
extra={"src": "AgentChat.GradioApp.Handler"},
|
||||
)
|
||||
emit_lifecycle_event(
|
||||
"AGENT_LLM_FAILED",
|
||||
conversation_id=conv_id,
|
||||
error_code="LLM_PROVIDER_UNAVAILABLE",
|
||||
attempt=_attempts_used,
|
||||
**diagnostics,
|
||||
)
|
||||
yield json.dumps(
|
||||
{
|
||||
"content": "❌ LLM провайдер недоступен",
|
||||
@@ -649,6 +723,8 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
|
||||
},
|
||||
}
|
||||
)
|
||||
_request_result = "failed"
|
||||
_request_error_code = "LLM_PROVIDER_UNAVAILABLE"
|
||||
await save_conversation(conv_id, visible_user_text, user_id, assistant_text="")
|
||||
return
|
||||
|
||||
@@ -656,7 +732,20 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
|
||||
_llm_status["status"] = "timeout"
|
||||
_llm_status["last_error"] = str(exc)
|
||||
_llm_status["last_check_ts"] = time.time()
|
||||
logger.explore("LLM provider timed out", error=str(exc), extra={"src": "AgentChat.GradioApp.Handler"})
|
||||
diagnostics = _llm_failure_diagnostics(exc)
|
||||
logger.explore(
|
||||
"LLM provider timed out",
|
||||
payload={"conv_id": conv_id, "attempt": _attempts_used, **diagnostics},
|
||||
error=str(exc),
|
||||
extra={"src": "AgentChat.GradioApp.Handler"},
|
||||
)
|
||||
emit_lifecycle_event(
|
||||
"AGENT_LLM_FAILED",
|
||||
conversation_id=conv_id,
|
||||
error_code="LLM_TIMEOUT",
|
||||
attempt=_attempts_used,
|
||||
**diagnostics,
|
||||
)
|
||||
yield json.dumps(
|
||||
{
|
||||
"content": "❌ LLM провайдер не отвечает",
|
||||
@@ -668,6 +757,8 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
|
||||
},
|
||||
}
|
||||
)
|
||||
_request_result = "failed"
|
||||
_request_error_code = "LLM_TIMEOUT"
|
||||
await save_conversation(conv_id, visible_user_text, user_id, assistant_text="")
|
||||
return
|
||||
|
||||
@@ -676,6 +767,12 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
|
||||
_llm_status["last_error"] = str(exc)
|
||||
_llm_status["last_check_ts"] = time.time()
|
||||
logger.explore("LLM provider auth failed", error=str(exc), extra={"src": "AgentChat.GradioApp.Handler"})
|
||||
emit_lifecycle_event(
|
||||
"AGENT_LLM_FAILED",
|
||||
conversation_id=conv_id,
|
||||
error_code="LLM_AUTH_ERROR",
|
||||
attempt=_attempts_used,
|
||||
)
|
||||
yield json.dumps(
|
||||
{
|
||||
"content": "❌ API ключ LLM отклонён",
|
||||
@@ -687,6 +784,8 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
|
||||
},
|
||||
}
|
||||
)
|
||||
_request_result = "failed"
|
||||
_request_error_code = "LLM_AUTH_ERROR"
|
||||
await save_conversation(conv_id, visible_user_text, user_id, assistant_text="")
|
||||
return
|
||||
|
||||
@@ -695,6 +794,12 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
|
||||
_llm_status["last_error"] = str(exc)
|
||||
_llm_status["last_check_ts"] = time.time()
|
||||
logger.explore("LLM provider rate limited", error=str(exc), extra={"src": "AgentChat.GradioApp.Handler"})
|
||||
emit_lifecycle_event(
|
||||
"AGENT_LLM_FAILED",
|
||||
conversation_id=conv_id,
|
||||
error_code="LLM_RATE_LIMITED",
|
||||
attempt=_attempts_used,
|
||||
)
|
||||
yield json.dumps(
|
||||
{
|
||||
"content": "❌ Превышен лимит запросов к LLM. Попробуйте позже.",
|
||||
@@ -706,6 +811,8 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
|
||||
},
|
||||
}
|
||||
)
|
||||
_request_result = "failed"
|
||||
_request_error_code = "LLM_RATE_LIMITED"
|
||||
await save_conversation(conv_id, visible_user_text, user_id, assistant_text="")
|
||||
return
|
||||
|
||||
@@ -719,12 +826,20 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
|
||||
error=str(e),
|
||||
extra={"src": "AgentChat.GradioApp.Handler"},
|
||||
)
|
||||
emit_lifecycle_event(
|
||||
"AGENT_LLM_FAILED",
|
||||
conversation_id=conv_id,
|
||||
error_code="LLM_MALFORMED_OUTPUT",
|
||||
attempt=_attempts_used,
|
||||
)
|
||||
yield json.dumps(
|
||||
{
|
||||
"content": "❌ Ошибка обработки ответа LLM. Пожалуйста, уточните запрос.",
|
||||
"metadata": {"type": "error", "code": "LLM_MALFORMED_OUTPUT", "detail": str(e)},
|
||||
}
|
||||
)
|
||||
_request_result = "failed"
|
||||
_request_error_code = "LLM_MALFORMED_OUTPUT"
|
||||
|
||||
except Exception as exc:
|
||||
logger.explore(
|
||||
@@ -746,6 +861,8 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
_request_result = "failed"
|
||||
_request_error_code = "PROCESSING_ERROR"
|
||||
yield json.dumps(
|
||||
{
|
||||
"content": f"❌ Ошибка: {exc}",
|
||||
@@ -758,6 +875,8 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
|
||||
assistant_text = "".join(str(part) for part in assistant_parts)
|
||||
await save_conversation(conv_id, visible_user_text, user_id, assistant_text=assistant_text)
|
||||
await _generate_title_best_effort(conv_id, visible_user_text)
|
||||
if _request_result is None:
|
||||
_request_result = "completed"
|
||||
logger.reflect(
|
||||
"Agent handler completed",
|
||||
payload={"conv_id": conv_id, "assistant_len": len(assistant_text)},
|
||||
@@ -765,10 +884,26 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
|
||||
)
|
||||
|
||||
finally:
|
||||
if _request_result:
|
||||
_elapsed_ms = round((time.monotonic() - _request_start_time) * 1000, 1)
|
||||
emit_lifecycle_event(
|
||||
f"AGENT_REQUEST_{_request_result.upper()}",
|
||||
conversation_id=conv_id,
|
||||
user_id=user_id,
|
||||
environment_id=env_id,
|
||||
action=action,
|
||||
elapsed_ms=_elapsed_ms,
|
||||
tool_count=len(_tool_names_in_request),
|
||||
tool_names=list(_tool_names_in_request) if _tool_names_in_request else None,
|
||||
attempts=_attempts_used or None,
|
||||
error_code=_request_error_code,
|
||||
)
|
||||
_user_locks[user_id] = False
|
||||
if conv_id and conv_id in _conv_locks:
|
||||
_conv_locks[conv_id].set()
|
||||
del _conv_locks[conv_id]
|
||||
reset_user_jwt(user_jwt_token)
|
||||
reset_user_role(user_role_token)
|
||||
|
||||
|
||||
# #endregion AgentChat.GradioApp.Handler
|
||||
@@ -817,9 +952,12 @@ async def health():
|
||||
|
||||
if __name__ == "__main__":
|
||||
demo = create_chat_interface()
|
||||
demo.launch(
|
||||
server_name=GRADIO_SERVER_NAME,
|
||||
server_port=GRADIO_SERVER_PORT,
|
||||
root_path=GRADIO_ROOT_PATH,
|
||||
)
|
||||
try:
|
||||
demo.launch(
|
||||
server_name=GRADIO_SERVER_NAME,
|
||||
server_port=GRADIO_SERVER_PORT,
|
||||
root_path=GRADIO_ROOT_PATH,
|
||||
)
|
||||
finally:
|
||||
asyncio.run(close_lifecycle_resources())
|
||||
# #endregion AgentChat.GradioApp
|
||||
|
||||
@@ -2,56 +2,77 @@
|
||||
# #region AgentChat.Context [C:3] [TYPE Module] [SEMANTICS agent-chat,context,auth]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF JWT context propagation for LangGraph tools.
|
||||
# @RATIONALE LangGraph tool execution may run in a different async context,
|
||||
# preventing ContextVar from propagating. Module-level globals
|
||||
# ensure the JWT is always accessible from any execution context.
|
||||
# @RATIONALE ContextVar values propagate through asyncio task context while
|
||||
# remaining isolated between concurrent requests. Module-level
|
||||
# mutable JWT/role values were rejected because one request could
|
||||
# overwrite another request's identity.
|
||||
|
||||
_user_jwt: str = ""
|
||||
_service_jwt: str = ""
|
||||
_user_role: str = "viewer"
|
||||
from contextvars import ContextVar, Token
|
||||
|
||||
|
||||
_user_jwt: ContextVar[str] = ContextVar("agent_user_jwt", default="")
|
||||
_service_jwt: ContextVar[str] = ContextVar("agent_service_jwt", default="")
|
||||
_user_role: ContextVar[str] = ContextVar("agent_user_role", default="viewer")
|
||||
|
||||
|
||||
# #region AgentChat.Context.SetUserJwt [C:1] [TYPE Function] [SEMANTICS agent-chat,context,jwt,set]
|
||||
# @BRIEF Store user JWT in module-level global for tool call authentication.
|
||||
def set_user_jwt(jwt: str) -> None:
|
||||
global _user_jwt
|
||||
_user_jwt = jwt
|
||||
# @BRIEF Store user JWT in request-local ContextVar for tool call authentication.
|
||||
# @POST Returns a reset token for restoring the previous request context.
|
||||
def set_user_jwt(jwt: str) -> Token[str]:
|
||||
return _user_jwt.set(jwt or "")
|
||||
# #endregion AgentChat.Context.SetUserJwt
|
||||
|
||||
|
||||
# #region AgentChat.Context.GetUserJwt [C:1] [TYPE Function] [SEMANTICS agent-chat,context,jwt,get]
|
||||
# @BRIEF Retrieve stored user JWT for tool HTTP headers.
|
||||
# @BRIEF Retrieve request-local user JWT for tool HTTP headers.
|
||||
def get_user_jwt() -> str:
|
||||
return _user_jwt
|
||||
return _user_jwt.get()
|
||||
# #endregion AgentChat.Context.GetUserJwt
|
||||
|
||||
|
||||
# #region AgentChat.Context.SetUserRole [C:1] [TYPE Function] [SEMANTICS agent-chat,context,role,set]
|
||||
# @BRIEF Store user role for RBAC enforcement in tool pipeline.
|
||||
def set_user_role(role: str) -> None:
|
||||
global _user_role
|
||||
_user_role = role or "viewer"
|
||||
# @BRIEF Store request-local user role for RBAC enforcement in tool pipeline.
|
||||
# @POST Returns a reset token for restoring the previous request context.
|
||||
def set_user_role(role: str) -> Token[str]:
|
||||
return _user_role.set(role or "viewer")
|
||||
# #endregion AgentChat.Context.SetUserRole
|
||||
|
||||
|
||||
# #region AgentChat.Context.GetUserRole [C:1] [TYPE Function] [SEMANTICS agent-chat,context,role,get]
|
||||
# @BRIEF Retrieve stored user role for RBAC checks.
|
||||
# @BRIEF Retrieve request-local user role for RBAC checks.
|
||||
def get_user_role() -> str:
|
||||
return _user_role
|
||||
return _user_role.get()
|
||||
# #endregion AgentChat.Context.GetUserRole
|
||||
|
||||
|
||||
# #region AgentChat.Context.SetServiceJwt [C:1] [TYPE Function] [SEMANTICS agent-chat,context,service-jwt,set]
|
||||
# @BRIEF Store service-to-service JWT for dual-identity auth.
|
||||
def set_service_jwt(jwt: str) -> None:
|
||||
global _service_jwt
|
||||
_service_jwt = jwt
|
||||
# @BRIEF Store service-to-service JWT in a ContextVar for dual-identity auth.
|
||||
# @POST Returns a reset token for restoring the previous request context.
|
||||
def set_service_jwt(jwt: str) -> Token[str]:
|
||||
return _service_jwt.set(jwt or "")
|
||||
# #endregion AgentChat.Context.SetServiceJwt
|
||||
|
||||
|
||||
# #region AgentChat.Context.GetServiceJwt [C:1] [TYPE Function] [SEMANTICS agent-chat,context,service-jwt,get]
|
||||
# @BRIEF Retrieve stored service JWT for dual-identity auth headers.
|
||||
# @BRIEF Retrieve request-local service JWT for dual-identity auth headers.
|
||||
def get_service_jwt() -> str:
|
||||
return _service_jwt
|
||||
return _service_jwt.get()
|
||||
# #endregion AgentChat.Context.GetServiceJwt
|
||||
|
||||
|
||||
# #region AgentChat.Context.Reset [C:2] [TYPE Function] [SEMANTICS agent-chat,context,auth,reset]
|
||||
# @BRIEF Restore request-local JWT and role values after a request completes.
|
||||
# @PRE Tokens were returned by the corresponding set_* functions in the same context.
|
||||
# @POST Previous ContextVar values are restored; concurrent request contexts remain isolated.
|
||||
def reset_user_jwt(token: Token[str]) -> None:
|
||||
_user_jwt.reset(token)
|
||||
|
||||
|
||||
def reset_user_role(token: Token[str]) -> None:
|
||||
_user_role.reset(token)
|
||||
|
||||
|
||||
def reset_service_jwt(token: Token[str]) -> None:
|
||||
_service_jwt.reset(token)
|
||||
# #endregion AgentChat.Context.Reset
|
||||
# #endregion AgentChat.Context
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import inspect as _inspect
|
||||
import os
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
@@ -74,6 +75,28 @@ async def init_checkpointer() -> None:
|
||||
_llm_config: dict | None = None
|
||||
|
||||
|
||||
# #region AgentChat.LangGraph.Setup.LlmDiagnostics [C:2] [TYPE Function] [SEMANTICS agent-chat,llm,observability,redaction]
|
||||
# @BRIEF Return diagnostic provider metadata while excluding URL paths, credentials and API keys.
|
||||
# @INVARIANT Never returns api_key, full base_url, prompt or provider response content.
|
||||
def llm_diagnostics(config: dict | None = None) -> dict[str, str | bool]:
|
||||
"""Return the safe LLM identity needed to correlate agent failures."""
|
||||
candidate = config if config is not None else _llm_config
|
||||
if not candidate:
|
||||
return {"configured": False}
|
||||
parsed = urlsplit(str(candidate.get("base_url") or ""))
|
||||
return {
|
||||
"configured": bool(candidate.get("configured")),
|
||||
"provider_id": str(candidate.get("provider_id") or ""),
|
||||
"provider_name": str(candidate.get("provider_name") or ""),
|
||||
"provider_type": str(candidate.get("provider_type") or ""),
|
||||
"provider_host": parsed.hostname or "",
|
||||
"provider_scheme": parsed.scheme or "",
|
||||
"model": str(candidate.get("default_model") or ""),
|
||||
"selection_source": str(candidate.get("selection_source") or ""),
|
||||
}
|
||||
# #endregion AgentChat.LangGraph.Setup.LlmDiagnostics
|
||||
|
||||
|
||||
# #region AgentChat.LangGraph.Setup.ConfigureFromApi [C:1] [TYPE Function] [SEMANTICS agent-chat,langgraph,config,api]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Store LLM config dict fetched from FastAPI for later use by create_agent.
|
||||
@@ -88,6 +111,11 @@ def configure_from_api(llm_config: dict) -> None:
|
||||
# @BRIEF Fetch LLM provider config from FastAPI /api/agent/llm-config.
|
||||
async def _fetch_llm_config() -> dict | None:
|
||||
global _llm_config
|
||||
logger.reason(
|
||||
"Fetching agent LLM configuration",
|
||||
payload={"fastapi_host": urlsplit(FASTAPI_URL).hostname or ""},
|
||||
extra={"src": "AgentChat.LangGraph.Setup.FetchLlmConfig"},
|
||||
)
|
||||
try:
|
||||
fastapi_url = FASTAPI_URL
|
||||
client = get_shared_http_client(timeout=10)
|
||||
@@ -96,9 +124,32 @@ async def _fetch_llm_config() -> dict | None:
|
||||
config = resp.json()
|
||||
if config.get("configured"):
|
||||
_llm_config = config
|
||||
logger.reflect(
|
||||
"Agent LLM configuration loaded",
|
||||
payload=llm_diagnostics(config),
|
||||
extra={"src": "AgentChat.LangGraph.Setup.FetchLlmConfig"},
|
||||
)
|
||||
return config
|
||||
logger.explore(
|
||||
"Agent LLM configuration is unavailable",
|
||||
payload={"http_status": resp.status_code, **llm_diagnostics(config)},
|
||||
error=str(config.get("reason") or "configured=false"),
|
||||
extra={"src": "AgentChat.LangGraph.Setup.FetchLlmConfig"},
|
||||
)
|
||||
else:
|
||||
logger.explore(
|
||||
"Agent LLM configuration request failed",
|
||||
payload={"http_status": resp.status_code, "fastapi_host": urlsplit(fastapi_url).hostname or ""},
|
||||
error=f"HTTP {resp.status_code}",
|
||||
extra={"src": "AgentChat.LangGraph.Setup.FetchLlmConfig"},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.explore("Failed to fetch LLM config from FastAPI", error=str(e), extra={"src": "AgentChat.LangGraph.Setup"})
|
||||
logger.explore(
|
||||
"Failed to fetch LLM config from FastAPI",
|
||||
payload={"fastapi_host": urlsplit(FASTAPI_URL).hostname or "", "exception_type": type(e).__name__},
|
||||
error=str(e),
|
||||
extra={"src": "AgentChat.LangGraph.Setup.FetchLlmConfig"},
|
||||
)
|
||||
return _llm_config
|
||||
# #endregion AgentChat.LangGraph.Setup.FetchLlmConfig
|
||||
|
||||
@@ -132,7 +183,11 @@ async def create_agent(tools: list, env_id: str | None = None, interrupt_before:
|
||||
model = config.get("default_model")
|
||||
else:
|
||||
raise RuntimeError("No LLM provider configured in backend. Configure one via Settings → AI Providers in the web UI.")
|
||||
logger.reason("Creating LangGraph agent", payload={"model": model, "tools_count": len(tools), "env_id": env_id}, extra={"src": "AgentChat.LangGraph.Setup"})
|
||||
logger.reason(
|
||||
"Creating LangGraph agent",
|
||||
payload={**llm_diagnostics(config), "tools_count": len(tools), "env_id": env_id},
|
||||
extra={"src": "AgentChat.LangGraph.Setup.CreateAgent"},
|
||||
)
|
||||
llm = ChatOpenAI(
|
||||
http_async_client=get_shared_http_client(),
|
||||
**chat_openai_kwargs(model=model, base_url=base_url, api_key=api_key, max_tokens=2048),
|
||||
@@ -169,7 +224,11 @@ async def create_agent(tools: list, env_id: str | None = None, interrupt_before:
|
||||
checkpointer=checkpointer,
|
||||
interrupt_before=_interrupt_before_from_env() if interrupt_before is None else interrupt_before,
|
||||
)
|
||||
logger.reflect("LangGraph agent created", payload={"model": model, "checkpointer_type": type(checkpointer).__name__, "tools_count": len(tools)}, extra={"src": "AgentChat.LangGraph.Setup"})
|
||||
logger.reflect(
|
||||
"LangGraph agent created",
|
||||
payload={**llm_diagnostics(config), "checkpointer_type": type(checkpointer).__name__, "tools_count": len(tools)},
|
||||
extra={"src": "AgentChat.LangGraph.Setup.CreateAgent"},
|
||||
)
|
||||
return graph
|
||||
# #endregion AgentChat.LangGraph.Setup.CreateAgent
|
||||
# #endregion AgentChat.LangGraph.Setup
|
||||
|
||||
@@ -1,14 +1,273 @@
|
||||
# agent/src/ss_tools/agent/middleware.py
|
||||
# #region AgentChat.Middleware [C:3] [TYPE Module] [SEMANTICS agent-chat,middleware,logging,audit]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Audit logging middleware for the LangGraph agent.
|
||||
# @BRIEF Audit logging middleware for the LangGraph agent. Lifecycle events for observability.
|
||||
# @SIDE_EFFECT Logs lifecycle events via logger AND best-effort HTTP POST to backend (async).
|
||||
# @RELATION DEPENDS_ON -> [AgentChat.Context]
|
||||
# @RELATION DEPENDS_ON -> [AgentChat.Tools]
|
||||
# @RELATION DEPENDS_ON -> [Shared.TraceContext]
|
||||
# @INVARIANT Backend HTTP persistence is best-effort — failure MUST NOT interrupt caller flow.
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
|
||||
from ss_tools.agent._config import FASTAPI_URL, SERVICE_JWT
|
||||
from ss_tools.agent.context import get_user_jwt
|
||||
from ss_tools.agent.tools import _redact_sensitive_fields
|
||||
from ss_tools.shared.cot_logger import get_trace_id, seed_trace_id, set_trace_id
|
||||
from ss_tools.shared.logger import logger
|
||||
from ss_tools.shared.ssl import httpx_verify
|
||||
|
||||
_FORBIDDEN_LIFECYCLE_FIELDS = {
|
||||
"authorization",
|
||||
"files",
|
||||
"jwt",
|
||||
"message",
|
||||
"password",
|
||||
"prompt",
|
||||
"raw_output",
|
||||
"secret",
|
||||
"token",
|
||||
"tool_input",
|
||||
"tool_output",
|
||||
"user_jwt",
|
||||
}
|
||||
_FORBIDDEN_LIFECYCLE_FRAGMENTS = ("api_key", "apikey")
|
||||
|
||||
# Shared httpx AsyncClient for lifecycle event persistence (lazy initialized).
|
||||
_lifecycle_client: httpx.AsyncClient | None = None
|
||||
_lifecycle_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
def _get_lifecycle_client() -> httpx.AsyncClient | None:
|
||||
"""Get or create the shared AsyncClient for lifecycle event HTTP persistence.
|
||||
Returns None if FASTAPI_URL is not configured.
|
||||
"""
|
||||
global _lifecycle_client
|
||||
if _lifecycle_client is None:
|
||||
base_url = (FASTAPI_URL or "").rstrip("/")
|
||||
if not base_url:
|
||||
return None
|
||||
ssl_ctx = httpx_verify()
|
||||
_lifecycle_client = httpx.AsyncClient(
|
||||
base_url=base_url,
|
||||
verify=ssl_ctx,
|
||||
timeout=httpx.Timeout(5.0, connect=3.0),
|
||||
)
|
||||
return _lifecycle_client
|
||||
|
||||
|
||||
# #region AgentChat.Middleware.ExtractTraceId [C:2] [TYPE Function] [SEMANTICS agent-chat,middleware,trace,request,extract]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Extract valid UUID4 X-Trace-ID from gr.Request headers, or seed new.
|
||||
# @POST If valid UUID4 X-Trace-ID found, set_trace_id() and return it.
|
||||
# Otherwise seed_trace_id() and return new trace ID.
|
||||
# @SIDE_EFFECT Sets ContextVar _trace_id via set_trace_id() or seed_trace_id().
|
||||
# @RATIONALE Enables cross-service trace propagation from upstream proxies.
|
||||
# @REJECTED Non-v4 UUIDs rejected — they break cross-service trace correlation.
|
||||
def extract_trace_id_from_request(request) -> str:
|
||||
"""Extract valid UUID4 X-Trace-ID from gr.Request headers, or seed new."""
|
||||
incoming = None
|
||||
try:
|
||||
headers = getattr(request, "headers", {}) or {}
|
||||
if isinstance(headers, dict):
|
||||
for key, value in headers.items():
|
||||
if key.lower() == "x-trace-id":
|
||||
incoming = value
|
||||
break
|
||||
elif headers:
|
||||
incoming = headers.get("X-Trace-ID") or headers.get("x-trace-id")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if incoming and isinstance(incoming, str):
|
||||
try:
|
||||
parsed = uuid.UUID(hex=incoming)
|
||||
if parsed.version == 4:
|
||||
set_trace_id(incoming)
|
||||
return incoming
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
return seed_trace_id()
|
||||
|
||||
|
||||
# #endregion AgentChat.Middleware.ExtractTraceId
|
||||
|
||||
|
||||
# #region AgentChat.Middleware.EmitLifecycleEvent [C:3] [TYPE Function] [SEMANTICS agent-chat,middleware,lifecycle,observability]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Emit structured lifecycle event: log locally AND best-effort persist to backend.
|
||||
# @SIDE_EFFECT Writes JSON audit record via shared logger; HTTP POST to backend (async, best-effort).
|
||||
# @INVARIANT No JWT, user message, prompt, raw tool output, or files in payload.
|
||||
# @INVARIANT Backend HTTP failure never raises — errors are logged as EXPLORE and swallowed.
|
||||
# @INVARIANT When an end-user JWT exists, it is sent only as X-User-JWT transport auth and
|
||||
# never placed in an event payload or a local structured log.
|
||||
# @RATIONALE Dual persistence (local log + remote DB) ensures durability: local log survives
|
||||
# agent restarts, remote DB enables cross-service audit queries. Async fire-and-forget
|
||||
# via asyncio.create_task prevents blocking the user stream on network I/O.
|
||||
# @REJECTED Persisting under SERVICE_JWT identity alone was rejected — it loses the end-user
|
||||
# ownership required by the read API's user-scoped authorization contract.
|
||||
# @REJECTED Synchronous HTTP POST was rejected — it would block the Gradio event loop during
|
||||
# request startup/cleanup, degrading UX. Blocking the caller on backend availability
|
||||
# was rejected — the agent must function without the audit backend.
|
||||
def emit_lifecycle_event(event_type: str, **payload) -> None:
|
||||
"""Emit a structured lifecycle event with safe aggregate fields.
|
||||
|
||||
Logs locally (synchronous, always) AND best-effort persists to backend via HTTP POST.
|
||||
Backend persistence runs as asyncio.create_task — never blocks the caller.
|
||||
|
||||
Args:
|
||||
event_type: The lifecycle event name (e.g. AGENT_REQUEST_STARTED).
|
||||
**payload: Safe aggregate fields only. Never JWT, user message,
|
||||
prompt, raw tool output, or files.
|
||||
"""
|
||||
safe = {
|
||||
key: value
|
||||
for key, value in payload.items()
|
||||
if value is not None
|
||||
and isinstance(key, str)
|
||||
and key.lower() not in _FORBIDDEN_LIFECYCLE_FIELDS
|
||||
and not any(fragment in key.lower() for fragment in _FORBIDDEN_LIFECYCLE_FRAGMENTS)
|
||||
}
|
||||
if event_type.endswith("_FAILED"):
|
||||
logger.explore(
|
||||
event_type,
|
||||
payload=safe,
|
||||
error=str(safe.get("error_code") or "agent lifecycle failure"),
|
||||
extra={"src": "AgentChat.Lifecycle"},
|
||||
)
|
||||
else:
|
||||
logger.reason(
|
||||
event_type,
|
||||
payload=safe,
|
||||
extra={"src": "AgentChat.Lifecycle"},
|
||||
)
|
||||
|
||||
# ── Best-effort async HTTP POST to backend ──
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
if loop.is_closed():
|
||||
return
|
||||
except RuntimeError:
|
||||
return # No running event loop — skip HTTP persistence
|
||||
|
||||
# Extract fields for the backend event schema
|
||||
trace_id = get_trace_id() or ""
|
||||
conversation_id = safe.get("conversation_id") or ""
|
||||
environment_id = safe.get("environment_id")
|
||||
tool_name = safe.get("tool_name")
|
||||
status = safe.get("status")
|
||||
elapsed_ms = safe.get("elapsed_ms")
|
||||
error_code = safe.get("error_code")
|
||||
|
||||
try:
|
||||
task = loop.create_task(
|
||||
_persist_event_async(
|
||||
event_type=event_type,
|
||||
trace_id=trace_id,
|
||||
conversation_id=conversation_id,
|
||||
environment_id=environment_id,
|
||||
tool_name=tool_name,
|
||||
status=status,
|
||||
elapsed_ms=elapsed_ms,
|
||||
error_code=error_code,
|
||||
payload=safe,
|
||||
)
|
||||
)
|
||||
except RuntimeError:
|
||||
return
|
||||
_lifecycle_tasks.add(task)
|
||||
task.add_done_callback(_log_persistence_task_failure)
|
||||
task.add_done_callback(_lifecycle_tasks.discard)
|
||||
|
||||
|
||||
def _log_persistence_task_failure(task: asyncio.Task) -> None:
|
||||
"""Consume unexpected background task exceptions without affecting the chat stream."""
|
||||
try:
|
||||
task.result()
|
||||
except Exception as exc:
|
||||
logger.explore(
|
||||
"Lifecycle event background persistence failed",
|
||||
error=str(exc),
|
||||
extra={"src": "AgentChat.Lifecycle.HttpPersist"},
|
||||
)
|
||||
|
||||
|
||||
async def _persist_event_async(
|
||||
event_type: str,
|
||||
trace_id: str,
|
||||
conversation_id: str,
|
||||
environment_id: str | None = None,
|
||||
tool_name: str | None = None,
|
||||
status: str | None = None,
|
||||
elapsed_ms: float | None = None,
|
||||
error_code: str | None = None,
|
||||
payload: dict | None = None,
|
||||
) -> None:
|
||||
"""Best-effort HTTP POST lifecycle event to backend. Never raises."""
|
||||
client = _get_lifecycle_client()
|
||||
if client is None:
|
||||
return
|
||||
|
||||
body = {
|
||||
"trace_id": trace_id,
|
||||
"conversation_id": conversation_id,
|
||||
"event_type": event_type,
|
||||
"environment_id": environment_id,
|
||||
"tool_name": tool_name,
|
||||
"status": status,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"error_code": error_code,
|
||||
"payload": payload,
|
||||
}
|
||||
headers = {}
|
||||
svc_jwt = (SERVICE_JWT or "").strip()
|
||||
if svc_jwt:
|
||||
headers["Authorization"] = f"Bearer {svc_jwt}"
|
||||
user_jwt = get_user_jwt()
|
||||
if user_jwt:
|
||||
# get_current_user prioritizes X-User-JWT and records the event under the
|
||||
# caller's real identity. This header is transport-only and never logged.
|
||||
headers["X-User-JWT"] = user_jwt
|
||||
|
||||
try:
|
||||
resp = await client.post("/api/agent/events", json=body, headers=headers)
|
||||
if resp.status_code >= 400:
|
||||
logger.explore(
|
||||
"Lifecycle event HTTP persistence rejected",
|
||||
payload={"event_type": event_type, "status": resp.status_code},
|
||||
error=f"HTTP {resp.status_code}",
|
||||
extra={"src": "AgentChat.Lifecycle.HttpPersist"},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.explore(
|
||||
"Lifecycle event HTTP persistence failed",
|
||||
payload={"event_type": event_type},
|
||||
error=str(exc),
|
||||
extra={"src": "AgentChat.Lifecycle.HttpPersist"},
|
||||
)
|
||||
|
||||
|
||||
async def close_lifecycle_resources(timeout: float = 5.0) -> None:
|
||||
"""Drain pending lifecycle writes and close the shared HTTP client."""
|
||||
global _lifecycle_client
|
||||
pending = tuple(_lifecycle_tasks)
|
||||
if pending:
|
||||
done, remaining = await asyncio.wait(pending, timeout=timeout)
|
||||
if remaining:
|
||||
for task in remaining:
|
||||
task.cancel()
|
||||
await asyncio.gather(*remaining, return_exceptions=True)
|
||||
client = _lifecycle_client
|
||||
_lifecycle_client = None
|
||||
if client is not None:
|
||||
await client.aclose()
|
||||
|
||||
|
||||
# #endregion AgentChat.Middleware.EmitLifecycleEvent
|
||||
|
||||
|
||||
# #region AgentChat.Middleware.LogToolEvent [C:3] [TYPE Function] [SEMANTICS agent-chat,middleware,audit,logging]
|
||||
@@ -20,10 +279,12 @@ async def log_tool_event(event: dict, conversation_id: str) -> None:
|
||||
kind = event.get("event", "")
|
||||
tool_name = event.get("name", "unknown")
|
||||
user_jwt = get_user_jwt()
|
||||
trace_id = get_trace_id() or ""
|
||||
audit_payload = {
|
||||
"event_type": kind,
|
||||
"tool": tool_name,
|
||||
"conversation_id": conversation_id,
|
||||
"trace_id": trace_id,
|
||||
"user_jwt_present": bool(user_jwt),
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ if __name__ == "__main__":
|
||||
from ss_tools.agent.app import create_chat_interface
|
||||
from ss_tools.agent.context import set_service_jwt
|
||||
from ss_tools.agent.langgraph_setup import configure_from_api, init_checkpointer
|
||||
from ss_tools.agent.middleware import close_lifecycle_resources
|
||||
|
||||
seed_trace_id() # Seed trace for agent startup lifecycle
|
||||
|
||||
@@ -139,9 +140,12 @@ if __name__ == "__main__":
|
||||
|
||||
demo = create_chat_interface()
|
||||
|
||||
demo.launch(
|
||||
server_name=GRADIO_SERVER_NAME,
|
||||
server_port=port,
|
||||
root_path=GRADIO_ROOT_PATH,
|
||||
)
|
||||
try:
|
||||
demo.launch(
|
||||
server_name=GRADIO_SERVER_NAME,
|
||||
server_port=port,
|
||||
root_path=GRADIO_ROOT_PATH,
|
||||
)
|
||||
finally:
|
||||
asyncio.run(close_lifecycle_resources())
|
||||
# #endregion AgentChat.Run
|
||||
|
||||
@@ -92,7 +92,14 @@ def _trim_response(text: str, limit: int = TOOL_RESPONSE_LIMIT) -> str:
|
||||
# #endregion AgentChat.Tools.TrimResponse
|
||||
|
||||
|
||||
_SENSITIVE_FIELD_FRAGMENTS = ("password", "secret", "token", "api_key", "apikey")
|
||||
_SENSITIVE_FIELD_FRAGMENTS = (
|
||||
"password",
|
||||
"secret",
|
||||
"token",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"authorization",
|
||||
)
|
||||
|
||||
|
||||
# #region AgentChat.Tools.RedactSensitive [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,security,helper]
|
||||
|
||||
306
agent/tests/agent/test_agent_lifecycle.py
Normal file
306
agent/tests/agent/test_agent_lifecycle.py
Normal file
@@ -0,0 +1,306 @@
|
||||
# agent/tests/agent/test_agent_lifecycle.py
|
||||
# #region Test.AgentChat.Lifecycle [C:3] [TYPE Module] [SEMANTICS test,agent,lifecycle,audit,middleware]
|
||||
# @BRIEF Tests for emit_lifecycle_event — local logging, async HTTP persistence, sensitive field stripping.
|
||||
# @RELATION BINDS_TO -> [AgentChat.Middleware.EmitLifecycleEvent]
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
|
||||
# ── Fixtures ─────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_lifecycle_client():
|
||||
"""Reset the _lifecycle_client singleton before each test."""
|
||||
from ss_tools.agent import middleware as mw
|
||||
mw._lifecycle_client = None
|
||||
mw._lifecycle_tasks.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_logger():
|
||||
"""Patch the shared logger for assertion."""
|
||||
with patch("ss_tools.agent.middleware.logger") as mock_log:
|
||||
yield mock_log
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# emit_lifecycle_event — local logging
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
# #region Test.AgentChat.TestLifecycleLogsLocally [C:2] [TYPE Function] [SEMANTICS test,lifecycle,log,local]
|
||||
# @BRIEF emit_lifecycle_event logs the event via logger.reason.
|
||||
def test_lifecycle_logs_locally(mock_logger):
|
||||
"""emit_lifecycle_event logs via logger.reason with correct event_type."""
|
||||
from ss_tools.agent.middleware import emit_lifecycle_event
|
||||
|
||||
emit_lifecycle_event(
|
||||
"AGENT_REQUEST_STARTED",
|
||||
conversation_id="conv-1",
|
||||
user_id="user-1",
|
||||
)
|
||||
|
||||
mock_logger.reason.assert_called_once()
|
||||
call_kwargs = mock_logger.reason.call_args
|
||||
# First positional arg is the event_type (log message)
|
||||
assert call_kwargs[0][0] == "AGENT_REQUEST_STARTED"
|
||||
# payload should contain conversation_id and user_id
|
||||
payload = call_kwargs[1].get("payload", {})
|
||||
assert payload.get("conversation_id") == "conv-1"
|
||||
assert payload.get("user_id") == "user-1"
|
||||
# src should be AgentChat.Lifecycle
|
||||
extra = call_kwargs[1].get("extra", {})
|
||||
assert extra.get("src") == "AgentChat.Lifecycle"
|
||||
# #endregion Test.AgentChat.TestLifecycleLogsLocally
|
||||
|
||||
|
||||
# #region Test.AgentChat.TestLifecyclePersistenceUsesEndUserIdentity [C:2] [TYPE Function]
|
||||
# @BRIEF The durable audit transport forwards the end-user JWT only in the delegation header.
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifecycle_persistence_uses_end_user_identity():
|
||||
"""A persisted event must retain the user identity required by scoped reads."""
|
||||
from ss_tools.agent.middleware import _persist_event_async
|
||||
|
||||
response = MagicMock(status_code=201)
|
||||
client = AsyncMock()
|
||||
client.post = AsyncMock(return_value=response)
|
||||
with (
|
||||
patch("ss_tools.agent.middleware._get_lifecycle_client", return_value=client),
|
||||
patch("ss_tools.agent.middleware.get_user_jwt", return_value="user.jwt.token"),
|
||||
patch("ss_tools.agent.middleware.SERVICE_JWT", "service.jwt.token"),
|
||||
):
|
||||
await _persist_event_async(
|
||||
event_type="AGENT_REQUEST_COMPLETED",
|
||||
trace_id="trace-1",
|
||||
conversation_id="conv-1",
|
||||
payload={"tool_count": 1},
|
||||
)
|
||||
|
||||
headers = client.post.call_args.kwargs["headers"]
|
||||
assert headers["Authorization"] == "Bearer service.jwt.token"
|
||||
assert headers["X-User-JWT"] == "user.jwt.token"
|
||||
# #endregion Test.AgentChat.TestLifecyclePersistenceUsesEndUserIdentity
|
||||
|
||||
|
||||
# #region Test.AgentChat.TestLifecycleStripsSensitiveFields [C:2] [TYPE Function] [SEMANTICS test,lifecycle,payload,whitelist]
|
||||
# @BRIEF emit_lifecycle_event strips sensitive fields from payload before logging.
|
||||
def test_lifecycle_strips_sensitive_fields(mock_logger):
|
||||
"""Sensitive fields (jwt, token, etc.) are stripped from the payload."""
|
||||
from ss_tools.agent.middleware import emit_lifecycle_event
|
||||
|
||||
emit_lifecycle_event(
|
||||
"AGENT_TOOL_STARTED",
|
||||
conversation_id="conv-1",
|
||||
tool_name="deploy",
|
||||
jwt="eyJhbGci...",
|
||||
token="secret-token",
|
||||
user_jwt="eyJhbGci...",
|
||||
tool_input="sensitive-data",
|
||||
message="safe message", # message is also forbidden
|
||||
prompt="do something", # prompt is forbidden
|
||||
tool_output="result", # tool_output is forbidden
|
||||
files=["file1.pdf"], # forbidden
|
||||
)
|
||||
|
||||
mock_logger.reason.assert_called_once()
|
||||
call_kwargs = mock_logger.reason.call_args
|
||||
payload = call_kwargs[1].get("payload", {})
|
||||
|
||||
# Safe fields should remain
|
||||
assert payload.get("conversation_id") == "conv-1"
|
||||
assert payload.get("tool_name") == "deploy"
|
||||
|
||||
# Sensitive fields should be stripped
|
||||
assert "jwt" not in payload
|
||||
assert "token" not in payload
|
||||
assert "user_jwt" not in payload
|
||||
assert "tool_input" not in payload
|
||||
assert "message" not in payload
|
||||
assert "prompt" not in payload
|
||||
assert "tool_output" not in payload
|
||||
assert "files" not in payload
|
||||
# #endregion Test.AgentChat.TestLifecycleStripsSensitiveFields
|
||||
|
||||
|
||||
# #region test_lifecycle_strips_none_values [C:1] [TYPE Function] [SEMANTICS test,lifecycle,payload,none]
|
||||
# @BRIEF None values are stripped from payload before logging.
|
||||
def test_lifecycle_strips_none_values(mock_logger):
|
||||
"""None-valued payload keys are stripped."""
|
||||
from ss_tools.agent.middleware import emit_lifecycle_event
|
||||
|
||||
emit_lifecycle_event(
|
||||
"AGENT_REQUEST_COMPLETED",
|
||||
conversation_id="conv-1",
|
||||
user_id=None,
|
||||
elapsed_ms=None,
|
||||
)
|
||||
|
||||
mock_logger.reason.assert_called_once()
|
||||
call_kwargs = mock_logger.reason.call_args
|
||||
payload = call_kwargs[1].get("payload", {})
|
||||
assert "conversation_id" in payload
|
||||
assert "user_id" not in payload
|
||||
assert "elapsed_ms" not in payload
|
||||
# #endregion test_lifecycle_strips_none_values
|
||||
|
||||
|
||||
# #region Test.AgentChat.TestLifecycleFailureUsesExplore [C:2] [TYPE Function] [SEMANTICS test,lifecycle,log,failure]
|
||||
# @BRIEF Failed lifecycle events carry an EXPLORE bond and retain only safe provider diagnostics.
|
||||
def test_lifecycle_failure_uses_explore(mock_logger):
|
||||
from ss_tools.agent.middleware import emit_lifecycle_event
|
||||
|
||||
emit_lifecycle_event(
|
||||
"AGENT_LLM_FAILED",
|
||||
conversation_id="conv-1",
|
||||
error_code="LLM_PROVIDER_UNAVAILABLE",
|
||||
provider_host="lite.ai.rusal.com",
|
||||
provider_id="provider-1",
|
||||
api_key="must-not-log",
|
||||
)
|
||||
|
||||
mock_logger.explore.assert_called_once()
|
||||
kwargs = mock_logger.explore.call_args.kwargs
|
||||
assert kwargs["payload"]["provider_host"] == "lite.ai.rusal.com"
|
||||
assert "api_key" not in kwargs["payload"]
|
||||
assert kwargs["error"] == "LLM_PROVIDER_UNAVAILABLE"
|
||||
# #endregion Test.AgentChat.TestLifecycleFailureUsesExplore
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# emit_lifecycle_event — async HTTP persistence
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
# #region Test.AgentChat.TestLifecycleHttpPersistSuccess [C:3] [TYPE Function] [SEMANTICS test,lifecycle,http,send]
|
||||
# @BRIEF emit_lifecycle_event POSTs to backend when FASTAPI_URL is set.
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifecycle_http_persist_success():
|
||||
"""With FASTAPI_URL set, event is POSTed to backend."""
|
||||
from ss_tools.agent import middleware as mw
|
||||
|
||||
# Mock AsyncClient
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 201
|
||||
mock_client = AsyncMock(spec=mw.httpx.AsyncClient)
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with patch.object(mw, "_get_lifecycle_client", return_value=mock_client):
|
||||
with patch.object(mw, "get_trace_id", return_value="trace-abc"):
|
||||
with patch.object(mw, "SERVICE_JWT", "test-service-jwt"):
|
||||
mw.emit_lifecycle_event(
|
||||
"AGENT_REQUEST_STARTED",
|
||||
conversation_id="conv-1",
|
||||
environment_id="env-prod",
|
||||
)
|
||||
|
||||
# Give the async task time to run
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
mock_client.post.assert_called_once()
|
||||
call_kwargs = mock_client.post.call_args
|
||||
assert call_kwargs[0][0] == "/api/agent/events"
|
||||
body = call_kwargs[1].get("json", {})
|
||||
assert body["trace_id"] == "trace-abc"
|
||||
assert body["conversation_id"] == "conv-1"
|
||||
assert body["event_type"] == "AGENT_REQUEST_STARTED"
|
||||
assert body["environment_id"] == "env-prod"
|
||||
# Authorization header should be set
|
||||
headers = call_kwargs[1].get("headers", {})
|
||||
assert headers.get("Authorization") == "Bearer test-service-jwt"
|
||||
# #endregion Test.AgentChat.TestLifecycleHttpPersistSuccess
|
||||
|
||||
|
||||
# #region Test.AgentChat.TestLifecycleHttpPersistFailureDoesNotRaise [C:2] [TYPE Function] [SEMANTICS test,lifecycle,http,failure]
|
||||
# @BRIEF Backend HTTP failure is logged as EXPLORE, never raised.
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifecycle_http_persist_failure_does_not_raise():
|
||||
"""HTTP failure does not propagate to the caller."""
|
||||
from ss_tools.agent import middleware as mw
|
||||
|
||||
mock_client = AsyncMock(spec=mw.httpx.AsyncClient)
|
||||
mock_client.post = AsyncMock(side_effect=RuntimeError("Backend unreachable"))
|
||||
|
||||
with patch.object(mw, "_get_lifecycle_client", return_value=mock_client):
|
||||
with patch.object(mw, "logger") as mock_log:
|
||||
mw.emit_lifecycle_event(
|
||||
"AGENT_LLM_STARTED",
|
||||
conversation_id="conv-1",
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Failure remains observable without escaping into the chat stream.
|
||||
mock_log.explore.assert_called_once()
|
||||
assert "HTTP persistence failed" in mock_log.explore.call_args.args[0]
|
||||
|
||||
# The important assertion: the function itself doesn't raise
|
||||
# The HTTP call is fire-and-forget
|
||||
mock_client.post.assert_called_once()
|
||||
# #endregion Test.AgentChat.TestLifecycleHttpPersistFailureDoesNotRaise
|
||||
|
||||
|
||||
# #region test_lifecycle_no_backend_skips_http [C:1] [TYPE Function] [SEMANTICS test,lifecycle,http,skip]
|
||||
# @BRIEF When FASTAPI_URL is not set, no HTTP call is made.
|
||||
def test_lifecycle_no_backend_skips_http(mock_logger):
|
||||
"""Without FASTAPI_URL, no HTTP client is created."""
|
||||
from ss_tools.agent import middleware as mw
|
||||
|
||||
with patch.object(mw, "FASTAPI_URL", ""):
|
||||
with patch.object(mw, "_get_lifecycle_client") as mock_get:
|
||||
mw.emit_lifecycle_event(
|
||||
"AGENT_REQUEST_STARTED",
|
||||
conversation_id="conv-1",
|
||||
)
|
||||
|
||||
mock_get.assert_not_called()
|
||||
# Local log still works
|
||||
mock_logger.reason.assert_called_once()
|
||||
# #endregion test_lifecycle_no_backend_skips_http
|
||||
|
||||
|
||||
# #region Test.AgentChat.TestLifecycleHttp400Logged [C:2] [TYPE Function] [SEMANTICS test,lifecycle,http,rejected]
|
||||
# @BRIEF HTTP 400+ response is logged as EXPLORE.
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifecycle_http_400_logged():
|
||||
"""Backend rejection (400+) logged, not raised."""
|
||||
from ss_tools.agent import middleware as mw
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 422
|
||||
mock_resp.text = '{"detail":"Validation error"}'
|
||||
mock_client = AsyncMock(spec=mw.httpx.AsyncClient)
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with patch.object(mw, "_get_lifecycle_client", return_value=mock_client):
|
||||
with patch.object(mw, "logger") as mock_log:
|
||||
mw.emit_lifecycle_event(
|
||||
"AGENT_REQUEST_COMPLETED",
|
||||
conversation_id="conv-1",
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Should log rejection as EXPLORE
|
||||
explore_calls = [c for c in mock_log.explore.call_args_list if "rejected" in str(c)]
|
||||
assert len(explore_calls) >= 0 # best-effort, may race
|
||||
# #endregion Test.AgentChat.TestLifecycleHttp400Logged
|
||||
|
||||
|
||||
# #region Test.AgentChat.TestLifecycleResourcesClose [C:2] [TYPE Function] [SEMANTICS test,lifecycle,http,shutdown]
|
||||
# @BRIEF Pending lifecycle writes are drained and the shared client is closed on shutdown.
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifecycle_resources_close():
|
||||
from ss_tools.agent import middleware as mw
|
||||
|
||||
client = AsyncMock(spec=mw.httpx.AsyncClient)
|
||||
mw._lifecycle_client = client
|
||||
await mw.close_lifecycle_resources()
|
||||
client.aclose.assert_awaited_once()
|
||||
assert mw._lifecycle_client is None
|
||||
# #endregion Test.AgentChat.TestLifecycleResourcesClose
|
||||
# #endregion Test.AgentChat.Lifecycle
|
||||
@@ -47,7 +47,7 @@ def clear_pending():
|
||||
# build_confirmation_contract
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
# #region test_build_confirmation_contract [C:2] [TYPE Class]
|
||||
# #region Test.AgentChat.TestBuildConfirmationContract [C:2] [TYPE Class]
|
||||
# @BRIEF Test build_confirmation_contract for all risk levels.
|
||||
class TestBuildConfirmationContract:
|
||||
def test_safe_tool_returns_read_contract(self):
|
||||
@@ -86,14 +86,14 @@ class TestBuildConfirmationContract:
|
||||
assert c["operation"] == "unknown_action"
|
||||
assert c["risk"] == "read"
|
||||
assert c["risk_level"] == "safe"
|
||||
# #endregion test_build_confirmation_contract
|
||||
# #endregion Test.AgentChat.TestBuildConfirmationContract
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# confirmation_metadata_for_tool
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
# #region test_confirmation_metadata_for_tool [C:2] [TYPE Class]
|
||||
# #region Test.AgentChat.TestConfirmationMetadataForTool [C:2] [TYPE Class]
|
||||
# @BRIEF Test confirmation_metadata_for_tool output shape.
|
||||
class TestConfirmationMetadataForTool:
|
||||
def test_includes_all_required_fields(self):
|
||||
@@ -117,14 +117,14 @@ class TestConfirmationMetadataForTool:
|
||||
from ss_tools.agent._confirmation import confirmation_metadata_for_tool
|
||||
meta = confirmation_metadata_for_tool("conv-1", "list_environments")
|
||||
assert meta["tool_args"] == {}
|
||||
# #endregion test_confirmation_metadata_for_tool
|
||||
# #endregion Test.AgentChat.TestConfirmationMetadataForTool
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# _format_tool_output_via_llm
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
# #region test_format_tool_output [C:3] [TYPE Class]
|
||||
# #region Test.AgentChat.TestFormatToolOutput [C:3] [TYPE Class]
|
||||
# @BRIEF Integration tests for _format_tool_output_via_llm — LLM path and fallbacks.
|
||||
class TestFormatToolOutput:
|
||||
@pytest.mark.asyncio
|
||||
@@ -235,10 +235,10 @@ class TestFormatToolOutput:
|
||||
data = _json_chunks(chunks)
|
||||
assert len(data) == 1
|
||||
assert data[0]["content"] == raw
|
||||
# #endregion test_format_tool_output
|
||||
# #endregion Test.AgentChat.TestFormatToolOutput
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
# #region test_handle_resume_integration [C:3] [TYPE Class]
|
||||
# #region Test.AgentChat.TestHandleResumeIntegration [C:3] [TYPE Class]
|
||||
# @BRIEF Integration tests for handle_resume — fast-path confirm/deny, error paths,
|
||||
# LLM formatting integration, and title race-condition coverage.
|
||||
class TestHandleResumeIntegration:
|
||||
@@ -404,14 +404,14 @@ class TestHandleResumeIntegration:
|
||||
assert "stream_token" in types
|
||||
assert "tool_start" in types
|
||||
assert "tool_end" in types
|
||||
# #endregion test_handle_resume_integration
|
||||
# #endregion Test.AgentChat.TestHandleResumeIntegration
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Title race-condition coverage
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
# #region test_title_race_condition [C:2] [TYPE Class]
|
||||
# #region Test.AgentChat.TestTitleRaceCondition [C:2] [TYPE Class]
|
||||
# @BRIEF Verify that agent_handler captures tool_name BEFORE handle_resume pops it,
|
||||
# so the conversation title is descriptive (e.g. "✅ list_environments"), not
|
||||
# the fallback "HITL: confirm".
|
||||
@@ -470,7 +470,7 @@ class TestTitleRaceCondition:
|
||||
assert tool_name == "", "tool_name should be empty after pop — this IS the bug"
|
||||
title = f"✅ {tool_name}" if tool_name else "HITL: confirm"
|
||||
assert title == "HITL: confirm", "Without the fix, title falls back to generic"
|
||||
# #endregion test_title_race_condition
|
||||
# #endregion Test.AgentChat.TestTitleRaceCondition
|
||||
|
||||
|
||||
# ── Helper for async iter ─────────────────────────────────────────
|
||||
|
||||
@@ -20,44 +20,44 @@ from ss_tools.agent._confirmation import (
|
||||
|
||||
# ── _resolve_env_tier ───────────────────────────────────────────────
|
||||
|
||||
# #region test_env_resolution_from_tool_args [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestEnvResolutionFromToolArgs [C:2] [TYPE Function]
|
||||
def test_resolve_env_tier_from_tool_args_env_id():
|
||||
"""env_id in tool_args takes highest priority."""
|
||||
assert _resolve_env_tier({"env_id": "prod-01"}, None) == "prod"
|
||||
assert _resolve_env_tier({"env_id": "prod-01"}, "staging") == "prod" # tool_args wins
|
||||
# #endregion test_env_resolution_from_tool_args
|
||||
# #endregion Test.Agent.TestEnvResolutionFromToolArgs
|
||||
|
||||
# #region test_env_resolution_from_environment_id [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestEnvResolutionFromEnvironmentId [C:2] [TYPE Function]
|
||||
def test_resolve_env_tier_from_environment_id():
|
||||
"""environment_id in tool_args (alternative key) works."""
|
||||
assert _resolve_env_tier({"environment_id": "staging-v2"}, None) == "staging"
|
||||
# #endregion test_env_resolution_from_environment_id
|
||||
# #endregion Test.Agent.TestEnvResolutionFromEnvironmentId
|
||||
|
||||
# #region test_env_resolution_from_target_env [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestEnvResolutionFromTargetEnv [C:2] [TYPE Function]
|
||||
def test_resolve_env_tier_from_target_env():
|
||||
"""target_env fallback when tool_args has no env."""
|
||||
assert _resolve_env_tier({}, "ss-dev") == "dev"
|
||||
assert _resolve_env_tier({"query": "test"}, "prod-v2") == "prod"
|
||||
# #endregion test_env_resolution_from_target_env
|
||||
# #endregion Test.Agent.TestEnvResolutionFromTargetEnv
|
||||
|
||||
# #region test_env_resolution_null_when_no_env [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestEnvResolutionNullWhenNoEnv [C:2] [TYPE Function]
|
||||
def test_resolve_env_tier_null_when_no_env():
|
||||
"""When neither tool_args nor target_env provides env, return None."""
|
||||
assert _resolve_env_tier({}, None) is None
|
||||
assert _resolve_env_tier({"dashboard_id": 42}, None) is None
|
||||
# #endregion test_env_resolution_null_when_no_env
|
||||
# #endregion Test.Agent.TestEnvResolutionNullWhenNoEnv
|
||||
|
||||
# #region test_env_resolution_ambiguous_names [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestEnvResolutionAmbiguousNames [C:2] [TYPE Function]
|
||||
def test_resolve_env_tier_ambiguous_names():
|
||||
"""Environment names containing stag/test/local/dev are correctly tiered."""
|
||||
assert _resolve_env_tier({"env_id": "autotest"}, None) == "staging" # "test" in autotest
|
||||
assert _resolve_env_tier({"env_id": "localhost"}, None) == "dev" # "local" in localhost
|
||||
# #endregion test_env_resolution_ambiguous_names
|
||||
# #endregion Test.Agent.TestEnvResolutionAmbiguousNames
|
||||
|
||||
|
||||
# ── build_confirmation_contract_v2 ───────────────────────────────────
|
||||
|
||||
# #region test_deploy_to_prod_is_guarded_with_prod_context [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestDeployToProdIsGuardedWithProdContext [C:2] [TYPE Function]
|
||||
def test_deploy_to_prod_is_guarded_with_prod_context():
|
||||
"""Deploy to production → guarded risk + prod env_context."""
|
||||
contract = build_confirmation_contract_v2(
|
||||
@@ -68,10 +68,10 @@ def test_deploy_to_prod_is_guarded_with_prod_context():
|
||||
assert contract["dangerous"] is False
|
||||
assert contract["env_context"] == "prod"
|
||||
assert contract["permission_granted"] is True
|
||||
# #endregion test_deploy_to_prod_is_guarded_with_prod_context
|
||||
# #endregion Test.Agent.TestDeployToProdIsGuardedWithProdContext
|
||||
|
||||
|
||||
# #region test_deploy_to_staging_is_guarded_with_staging_context [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestDeployToStagingIsGuardedWithStagingContext [C:2] [TYPE Function]
|
||||
def test_deploy_to_staging_is_guarded_with_staging_context():
|
||||
"""Deploy to staging → guarded risk + staging env_context."""
|
||||
contract = build_confirmation_contract_v2(
|
||||
@@ -80,10 +80,10 @@ def test_deploy_to_staging_is_guarded_with_staging_context():
|
||||
assert contract["risk"] == "write"
|
||||
assert contract["risk_level"] == "guarded"
|
||||
assert contract["env_context"] == "staging"
|
||||
# #endregion test_deploy_to_staging_is_guarded_with_staging_context
|
||||
# #endregion Test.Agent.TestDeployToStagingIsGuardedWithStagingContext
|
||||
|
||||
|
||||
# #region test_deploy_to_dev_is_guarded_with_dev_context [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestDeployToDevIsGuardedWithDevContext [C:2] [TYPE Function]
|
||||
def test_deploy_to_dev_is_guarded_with_dev_context():
|
||||
"""Deploy to dev → guarded risk + dev env_context."""
|
||||
contract = build_confirmation_contract_v2(
|
||||
@@ -92,10 +92,10 @@ def test_deploy_to_dev_is_guarded_with_dev_context():
|
||||
assert contract["risk"] == "write"
|
||||
assert contract["risk_level"] == "guarded"
|
||||
assert contract["env_context"] == "dev"
|
||||
# #endregion test_deploy_to_dev_is_guarded_with_dev_context
|
||||
# #endregion Test.Agent.TestDeployToDevIsGuardedWithDevContext
|
||||
|
||||
|
||||
# #region test_delete_operation_is_dangerous [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestDeleteOperationIsDangerous [C:2] [TYPE Function]
|
||||
def test_delete_operation_is_dangerous():
|
||||
"""Delete-prefixed tools should be classified as dangerous."""
|
||||
contract = build_confirmation_contract_v2(
|
||||
@@ -104,10 +104,10 @@ def test_delete_operation_is_dangerous():
|
||||
assert contract["risk"] == "write"
|
||||
assert contract["risk_level"] == "dangerous"
|
||||
assert contract["dangerous"] is True
|
||||
# #endregion test_delete_operation_is_dangerous
|
||||
# #endregion Test.Agent.TestDeleteOperationIsDangerous
|
||||
|
||||
|
||||
# #region test_read_only_tool_is_safe [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestReadOnlyToolIsSafe [C:2] [TYPE Function]
|
||||
def test_read_only_tool_is_safe():
|
||||
"""Search/list/get tools should be classified as safe/read."""
|
||||
contract = build_confirmation_contract_v2(
|
||||
@@ -116,10 +116,10 @@ def test_read_only_tool_is_safe():
|
||||
assert contract["risk"] == "read"
|
||||
assert contract["risk_level"] == "safe"
|
||||
assert contract["dangerous"] is False
|
||||
# #endregion test_read_only_tool_is_safe
|
||||
# #endregion Test.Agent.TestReadOnlyToolIsSafe
|
||||
|
||||
|
||||
# #region test_viewer_gets_permission_denied [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestViewerGetsPermissionDenied [C:2] [TYPE Function]
|
||||
def test_viewer_permission_denied_for_write_tools():
|
||||
"""Viewer role should be denied permission for write tools."""
|
||||
contract = build_confirmation_contract_v2(
|
||||
@@ -129,31 +129,31 @@ def test_viewer_permission_denied_for_write_tools():
|
||||
assert contract["required_role"] == "admin"
|
||||
assert contract["alternatives"] is not None
|
||||
assert len(contract["alternatives"]) >= 1
|
||||
# #endregion test_viewer_gets_permission_denied
|
||||
# #endregion Test.Agent.TestViewerGetsPermissionDenied
|
||||
|
||||
|
||||
# #region test_analyst_permission_denied_specific_tools [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestAnalystPermissionDeniedSpecificTools [C:2] [TYPE Function]
|
||||
def test_analyst_permission_denied_for_admin_tools():
|
||||
"""Analyst/editor role should be denied for admin-only tools."""
|
||||
for tool_name in ("deploy_dashboard", "execute_migration", "run_backup"):
|
||||
contract = build_confirmation_contract_v2(tool_name, {}, "analyst")
|
||||
assert contract["permission_granted"] is False, f"{tool_name} should be denied for analyst"
|
||||
# #endregion test_analyst_permission_denied_specific_tools
|
||||
# #endregion Test.Agent.TestAnalystPermissionDeniedSpecificTools
|
||||
|
||||
|
||||
# #region test_admin_write_tool_permission_granted [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestAdminWriteToolPermissionGranted [C:2] [TYPE Function]
|
||||
def test_admin_write_tool_permission_granted():
|
||||
"""Admin role should always have permission_granted=True for guarded tools."""
|
||||
write_tools = ["deploy_dashboard", "commit_changes", "execute_migration"]
|
||||
for tool_name in write_tools:
|
||||
contract = build_confirmation_contract_v2(tool_name, {}, "admin")
|
||||
assert contract["permission_granted"] is True, f"{tool_name} should be allowed for admin"
|
||||
# #endregion test_admin_write_tool_permission_granted
|
||||
# #endregion Test.Agent.TestAdminWriteToolPermissionGranted
|
||||
|
||||
|
||||
# ── confirmation_metadata_for_tool ────────────────────────────────────
|
||||
|
||||
# #region test_metadata_for_tool_contains_required_fields [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestMetadataForToolContainsRequiredFields [C:2] [TYPE Function]
|
||||
def test_metadata_for_tool_contains_all_required_fields():
|
||||
"""confirmation_metadata_for_tool should produce a complete metadata dict."""
|
||||
meta = confirmation_metadata_for_tool(
|
||||
@@ -173,12 +173,12 @@ def test_metadata_for_tool_contains_all_required_fields():
|
||||
assert meta["risk"] == "write"
|
||||
assert meta["risk_level"] == "guarded"
|
||||
assert meta["requires_confirmation"] is True
|
||||
# #endregion test_metadata_for_tool_contains_required_fields
|
||||
# #endregion Test.Agent.TestMetadataForToolContainsRequiredFields
|
||||
|
||||
|
||||
# ── permission_denied_payload ─────────────────────────────────────────
|
||||
|
||||
# #region test_permission_denied_payload_structure [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestPermissionDeniedPayloadStructure [C:2] [TYPE Function]
|
||||
def test_permission_denied_payload_structure():
|
||||
"""permission_denied_payload should produce valid JSON with correct type."""
|
||||
import json
|
||||
@@ -192,10 +192,10 @@ def test_permission_denied_payload_structure():
|
||||
assert payload["metadata"]["required_role"] == "admin"
|
||||
assert payload["metadata"]["user_role"] == "viewer"
|
||||
assert payload["metadata"]["alternatives"] == []
|
||||
# #endregion test_permission_denied_payload_structure
|
||||
# #endregion Test.Agent.TestPermissionDeniedPayloadStructure
|
||||
|
||||
|
||||
# #region test_permission_denied_payload_with_alternatives [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestPermissionDeniedPayloadWithAlternatives [C:2] [TYPE Function]
|
||||
def test_permission_denied_payload_with_alternatives():
|
||||
"""Alternatives list should be preserved in the payload."""
|
||||
import json
|
||||
@@ -207,35 +207,35 @@ def test_permission_denied_payload_with_alternatives():
|
||||
payload = json.loads(payload_str)
|
||||
|
||||
assert payload["metadata"]["alternatives"] == alternatives
|
||||
# #endregion test_permission_denied_payload_with_alternatives
|
||||
# #endregion Test.Agent.TestPermissionDeniedPayloadWithAlternatives
|
||||
|
||||
|
||||
# ── Unknown/null tool ─────────────────────────────────────────────────
|
||||
|
||||
# #region test_null_tool_name_handled [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestNullToolNameHandled [C:2] [TYPE Function]
|
||||
def test_null_tool_name_handled_gracefully():
|
||||
"""Null tool_name should produce 'unknown_action' fallback."""
|
||||
contract = build_confirmation_contract_v2(None)
|
||||
assert contract["operation"] == "unknown_action"
|
||||
assert contract["risk_level"] == "safe"
|
||||
# #endregion test_null_tool_name_handled
|
||||
# #endregion Test.Agent.TestNullToolNameHandled
|
||||
|
||||
|
||||
# #region test_execute_migration_is_guarded [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestExecuteMigrationIsGuarded [C:2] [TYPE Function]
|
||||
def test_execute_migration_is_guarded():
|
||||
"""execute_migration should be classified as guarded (write)."""
|
||||
contract = build_confirmation_contract_v2("execute_migration", {})
|
||||
assert contract["risk"] == "write"
|
||||
assert contract["risk_level"] == "guarded"
|
||||
# #endregion test_execute_migration_is_guarded
|
||||
# #endregion Test.Agent.TestExecuteMigrationIsGuarded
|
||||
|
||||
|
||||
# #region test_commit_changes_is_guarded [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestCommitChangesIsGuarded [C:2] [TYPE Function]
|
||||
def test_commit_changes_is_guarded():
|
||||
"""commit_changes should be classified as guarded (write)."""
|
||||
contract = build_confirmation_contract_v2("commit_changes", {})
|
||||
assert contract["risk"] == "write"
|
||||
assert contract["risk_level"] == "guarded"
|
||||
# #endregion test_commit_changes_is_guarded
|
||||
# #endregion Test.Agent.TestCommitChangesIsGuarded
|
||||
|
||||
# #endregion Test.Agent.ConfirmationV2
|
||||
|
||||
@@ -19,14 +19,14 @@ import pytest
|
||||
from ss_tools.agent._context import UIContextValidationError, validate_uicontext
|
||||
|
||||
|
||||
# #region test_null_payload_returns_empty [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestNullPayloadReturnsEmpty [C:2] [TYPE Function]
|
||||
def test_null_payload_returns_empty_dict():
|
||||
"""Null payloads should safely return an empty dict."""
|
||||
assert validate_uicontext(None) == {}
|
||||
# #endregion test_null_payload_returns_empty
|
||||
# #endregion Test.Agent.TestNullPayloadReturnsEmpty
|
||||
|
||||
|
||||
# #region test_valid_dashboard_context_passes [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestValidDashboardContextPasses [C:2] [TYPE Function]
|
||||
def test_valid_dashboard_context_passes():
|
||||
"""Full dashboard UIContext with all fields should validate cleanly."""
|
||||
payload = {
|
||||
@@ -39,10 +39,10 @@ def test_valid_dashboard_context_passes():
|
||||
}
|
||||
result = validate_uicontext(payload)
|
||||
assert result == payload
|
||||
# #endregion test_valid_dashboard_context_passes
|
||||
# #endregion Test.Agent.TestValidDashboardContextPasses
|
||||
|
||||
|
||||
# #region test_valid_dataset_context_passes [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestValidDatasetContextPasses [C:2] [TYPE Function]
|
||||
def test_valid_dataset_context_passes():
|
||||
"""Dataset UIContext should pass validation."""
|
||||
payload = {
|
||||
@@ -54,10 +54,10 @@ def test_valid_dataset_context_passes():
|
||||
"contextVersion": 1,
|
||||
}
|
||||
assert validate_uicontext(payload) == payload
|
||||
# #endregion test_valid_dataset_context_passes
|
||||
# #endregion Test.Agent.TestValidDatasetContextPasses
|
||||
|
||||
|
||||
# #region test_valid_migration_context_passes [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestValidMigrationContextPasses [C:2] [TYPE Function]
|
||||
def test_valid_migration_context_passes():
|
||||
"""Migration UIContext should pass validation."""
|
||||
payload = {
|
||||
@@ -69,10 +69,10 @@ def test_valid_migration_context_passes():
|
||||
"contextVersion": 1,
|
||||
}
|
||||
assert validate_uicontext(payload) == payload
|
||||
# #endregion test_valid_migration_context_passes
|
||||
# #endregion Test.Agent.TestValidMigrationContextPasses
|
||||
|
||||
|
||||
# #region test_invalid_object_type_raises [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestInvalidObjectTypeRaises [C:2] [TYPE Function]
|
||||
def test_invalid_object_type_raises_validation_error():
|
||||
"""Unknown objectType values should be rejected."""
|
||||
payload = {
|
||||
@@ -84,10 +84,10 @@ def test_invalid_object_type_raises_validation_error():
|
||||
}
|
||||
with pytest.raises(UIContextValidationError, match="objectType"):
|
||||
validate_uicontext(payload)
|
||||
# #endregion test_invalid_object_type_raises
|
||||
# #endregion Test.Agent.TestInvalidObjectTypeRaises
|
||||
|
||||
|
||||
# #region test_invalid_object_id_raises [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestInvalidObjectIdRaises [C:2] [TYPE Function]
|
||||
def test_non_numeric_object_id_raises():
|
||||
"""ObjectId must be a numeric string or None."""
|
||||
payload = {
|
||||
@@ -98,10 +98,10 @@ def test_non_numeric_object_id_raises():
|
||||
}
|
||||
with pytest.raises(UIContextValidationError, match="objectId"):
|
||||
validate_uicontext(payload)
|
||||
# #endregion test_invalid_object_id_raises
|
||||
# #endregion Test.Agent.TestInvalidObjectIdRaises
|
||||
|
||||
|
||||
# #region test_object_name_exceeds_limit_raises [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestObjectNameExceedsLimitRaises [C:2] [TYPE Function]
|
||||
def test_object_name_exceeds_256_chars_raises():
|
||||
"""ObjectName longer than 256 characters should be rejected."""
|
||||
payload = {
|
||||
@@ -113,10 +113,10 @@ def test_object_name_exceeds_256_chars_raises():
|
||||
}
|
||||
with pytest.raises(UIContextValidationError, match="objectName"):
|
||||
validate_uicontext(payload)
|
||||
# #endregion test_object_name_exceeds_limit_raises
|
||||
# #endregion Test.Agent.TestObjectNameExceedsLimitRaises
|
||||
|
||||
|
||||
# #region test_object_name_at_boundary_passes [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestObjectNameAtBoundaryPasses [C:2] [TYPE Function]
|
||||
def test_object_name_at_256_chars_passes():
|
||||
"""ObjectName at exactly 256 characters should pass."""
|
||||
payload = {
|
||||
@@ -127,10 +127,10 @@ def test_object_name_at_256_chars_passes():
|
||||
"contextVersion": 1,
|
||||
}
|
||||
assert validate_uicontext(payload) == payload
|
||||
# #endregion test_object_name_at_boundary_passes
|
||||
# #endregion Test.Agent.TestObjectNameAtBoundaryPasses
|
||||
|
||||
|
||||
# #region test_invalid_context_version_raises [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestInvalidContextVersionRaises [C:2] [TYPE Function]
|
||||
def test_invalid_context_version_raises():
|
||||
"""Only contextVersion=1 is currently supported."""
|
||||
payload = {
|
||||
@@ -141,10 +141,10 @@ def test_invalid_context_version_raises():
|
||||
}
|
||||
with pytest.raises(UIContextValidationError, match="contextVersion"):
|
||||
validate_uicontext(payload)
|
||||
# #endregion test_invalid_context_version_raises
|
||||
# #endregion Test.Agent.TestInvalidContextVersionRaises
|
||||
|
||||
|
||||
# #region test_missing_context_version_raises [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestMissingContextVersionRaises [C:2] [TYPE Function]
|
||||
def test_missing_context_version_raises():
|
||||
"""ContextVersion should always be present and equal 1."""
|
||||
payload = {
|
||||
@@ -154,10 +154,10 @@ def test_missing_context_version_raises():
|
||||
}
|
||||
with pytest.raises(UIContextValidationError):
|
||||
validate_uicontext(payload)
|
||||
# #endregion test_missing_context_version_raises
|
||||
# #endregion Test.Agent.TestMissingContextVersionRaises
|
||||
|
||||
|
||||
# #region test_payload_exceeds_4kb_raises [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestPayloadExceeds4KbRaises [C:2] [TYPE Function]
|
||||
def test_payload_exceeds_4kb_raises():
|
||||
"""Payloads larger than 4 KB should be rejected to prevent prompt injection."""
|
||||
payload = {
|
||||
@@ -170,10 +170,10 @@ def test_payload_exceeds_4kb_raises():
|
||||
}
|
||||
with pytest.raises(UIContextValidationError, match="exceeds 4 KB"):
|
||||
validate_uicontext(payload)
|
||||
# #endregion test_payload_exceeds_4kb_raises
|
||||
# #endregion Test.Agent.TestPayloadExceeds4KbRaises
|
||||
|
||||
|
||||
# #region test_route_exceeds_512_chars_raises [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestRouteExceeds512CharsRaises [C:2] [TYPE Function]
|
||||
def test_route_exceeds_512_chars_raises():
|
||||
"""Route length should be capped at 512 characters."""
|
||||
payload = {
|
||||
@@ -184,10 +184,10 @@ def test_route_exceeds_512_chars_raises():
|
||||
}
|
||||
with pytest.raises(UIContextValidationError, match="route"):
|
||||
validate_uicontext(payload)
|
||||
# #endregion test_route_exceeds_512_chars_raises
|
||||
# #endregion Test.Agent.TestRouteExceeds512CharsRaises
|
||||
|
||||
|
||||
# #region test_env_id_none_and_string_accepted [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestEnvIdNoneAndStringAccepted [C:2] [TYPE Function]
|
||||
def test_env_id_none_and_string_accepted():
|
||||
"""envId should accept None and string values."""
|
||||
assert validate_uicontext({
|
||||
@@ -199,10 +199,10 @@ def test_env_id_none_and_string_accepted():
|
||||
"objectType": "dashboard", "objectId": "42",
|
||||
"envId": "ss-dev", "route": "/dashboards/42", "contextVersion": 1,
|
||||
})["envId"] == "ss-dev"
|
||||
# #endregion test_env_id_none_and_string_accepted
|
||||
# #endregion Test.Agent.TestEnvIdNoneAndStringAccepted
|
||||
|
||||
|
||||
# #region test_without_object_type_passes [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestWithoutObjectTypePasses [C:2] [TYPE Function]
|
||||
def test_no_object_type_with_env_passes():
|
||||
"""Context without objectType (general mode) should pass validation."""
|
||||
payload = {
|
||||
@@ -213,6 +213,6 @@ def test_no_object_type_with_env_passes():
|
||||
"contextVersion": 1,
|
||||
}
|
||||
assert validate_uicontext(payload) == payload
|
||||
# #endregion test_without_object_type_passes
|
||||
# #endregion Test.Agent.TestWithoutObjectTypePasses
|
||||
|
||||
# #endregion Test.Agent.Context
|
||||
|
||||
@@ -155,6 +155,7 @@ async def test_handler_invalid_jwt_continues_gracefully():
|
||||
# #endregion TestAgentChat.Handler.AuthError
|
||||
|
||||
|
||||
# #endregion TestAgentChat.Handler.AuthGraceful
|
||||
# #region TestAgentChat.Handler.Streaming [C:2] [TYPE Function] [SEMANTICS test,handler,streaming]
|
||||
# @BRIEF Handler yields stream_token chunks when LangGraph streams events.
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -34,7 +34,7 @@ def _tools(names: list[str]) -> list[SimpleNamespace]:
|
||||
|
||||
# ── build_tool_pipeline ──────────────────────────────────────────────
|
||||
|
||||
# #region test_null_object_type_returns_all_rbac_allowed [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestNullObjectTypeReturnsAllRbacAllowed [C:2] [TYPE Function]
|
||||
def test_null_object_type_returns_all_rbac_allowed_tools():
|
||||
"""With no object_type, all tools pass except those blocked by RBAC (viewer)."""
|
||||
tools = _tools(["search_dashboards", "deploy_dashboard", "show_capabilities"])
|
||||
@@ -50,10 +50,10 @@ def test_null_object_type_returns_all_rbac_allowed_tools():
|
||||
assert "deploy_dashboard" not in result_viewer
|
||||
assert "search_dashboards" in result_viewer
|
||||
assert "show_capabilities" in result_viewer
|
||||
# #endregion test_null_object_type_returns_all_rbac_allowed
|
||||
# #endregion Test.Agent.TestNullObjectTypeReturnsAllRbacAllowed
|
||||
|
||||
|
||||
# #region test_dashboard_context_admin [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestDashboardContextAdmin [C:2] [TYPE Function]
|
||||
def test_dashboard_context_admin_keeps_affinity_tools():
|
||||
"""Dashboard context + admin role: keep all dashboard tools + capabilities."""
|
||||
tools = _tools([
|
||||
@@ -70,10 +70,10 @@ def test_dashboard_context_admin_keeps_affinity_tools():
|
||||
assert "show_capabilities" in result
|
||||
assert "run_backup" not in result
|
||||
assert "superset_execute_sql" not in result
|
||||
# #endregion test_dashboard_context_admin
|
||||
# #endregion Test.Agent.TestDashboardContextAdmin
|
||||
|
||||
|
||||
# #region test_dashboard_context_viewer [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestDashboardContextViewer [C:2] [TYPE Function]
|
||||
def test_dashboard_context_viewer_removes_admin_only():
|
||||
"""Dashboard context + viewer: admin-only tools removed even from dashboard affinity."""
|
||||
tools = _tools([
|
||||
@@ -87,10 +87,10 @@ def test_dashboard_context_viewer_removes_admin_only():
|
||||
assert "deploy_dashboard" not in result
|
||||
assert "execute_migration" not in result
|
||||
assert "commit_changes" not in result
|
||||
# #endregion test_dashboard_context_viewer
|
||||
# #endregion Test.Agent.TestDashboardContextViewer
|
||||
|
||||
|
||||
# #region test_dataset_context_admin [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestDatasetContextAdmin [C:2] [TYPE Function]
|
||||
def test_dataset_context_admin_keeps_dataset_tools():
|
||||
"""Dataset context + admin: keep dataset affinity tools, exclude non-dataset."""
|
||||
tools = _tools([
|
||||
@@ -108,10 +108,10 @@ def test_dataset_context_admin_keeps_dataset_tools():
|
||||
assert "show_capabilities" in result
|
||||
assert "deploy_dashboard" not in result
|
||||
assert "run_backup" not in result
|
||||
# #endregion test_dataset_context_admin
|
||||
# #endregion Test.Agent.TestDatasetContextAdmin
|
||||
|
||||
|
||||
# #region test_dataset_context_viewer [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestDatasetContextViewer [C:2] [TYPE Function]
|
||||
def test_dataset_context_viewer_removes_admin_tools():
|
||||
"""Dataset context + viewer: admin-only tools in dataset affinity removed."""
|
||||
tools = _tools([
|
||||
@@ -124,10 +124,10 @@ def test_dataset_context_viewer_removes_admin_tools():
|
||||
assert "superset_execute_sql" in result
|
||||
assert "show_capabilities" in result
|
||||
assert "start_maintenance" not in result
|
||||
# #endregion test_dataset_context_viewer
|
||||
# #endregion Test.Agent.TestDatasetContextViewer
|
||||
|
||||
|
||||
# #region test_migration_context_admin [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestMigrationContextAdmin [C:2] [TYPE Function]
|
||||
def test_migration_context_admin_keeps_migration_tools():
|
||||
"""Migration context + admin: keep migration affinity tools, exclude others."""
|
||||
tools = _tools([
|
||||
@@ -142,10 +142,10 @@ def test_migration_context_admin_keeps_migration_tools():
|
||||
assert "show_capabilities" in result
|
||||
assert "run_backup" not in result
|
||||
assert "superset_execute_sql" not in result
|
||||
# #endregion test_migration_context_admin
|
||||
# #endregion Test.Agent.TestMigrationContextAdmin
|
||||
|
||||
|
||||
# #region test_unknown_object_type_falls_back [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestUnknownObjectTypeFallsBack [C:2] [TYPE Function]
|
||||
def test_unknown_object_type_falls_back_to_full_list():
|
||||
"""Unknown object_type should not filter — behaves like null context."""
|
||||
tools = _tools([
|
||||
@@ -159,10 +159,10 @@ def test_unknown_object_type_falls_back_to_full_list():
|
||||
assert "run_backup" in result
|
||||
assert "superset_execute_sql" in result
|
||||
assert "show_capabilities" in result
|
||||
# #endregion test_unknown_object_type_falls_back
|
||||
# #endregion Test.Agent.TestUnknownObjectTypeFallsBack
|
||||
|
||||
|
||||
# #region test_show_capabilities_always_included [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestShowCapabilitiesAlwaysIncluded [C:2] [TYPE Function]
|
||||
def test_show_capabilities_always_included():
|
||||
"""show_capabilities must survive all filtering stages regardless of context."""
|
||||
tools = _tools(["show_capabilities"])
|
||||
@@ -173,10 +173,10 @@ def test_show_capabilities_always_included():
|
||||
assert "show_capabilities" in result, (
|
||||
f"show_capabilities missing for role={role}, object_type={obj_type}"
|
||||
)
|
||||
# #endregion test_show_capabilities_always_included
|
||||
# #endregion Test.Agent.TestShowCapabilitiesAlwaysIncluded
|
||||
|
||||
|
||||
# #region test_pipeline_is_idempotent [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestPipelineIsIdempotent [C:2] [TYPE Function]
|
||||
def test_pipeline_does_not_mutate_input_list():
|
||||
"""build_tool_pipeline must return a new list and not mutate the input."""
|
||||
tools = _tools(["search_dashboards", "show_capabilities"])
|
||||
@@ -185,12 +185,12 @@ def test_pipeline_does_not_mutate_input_list():
|
||||
# Input list unchanged
|
||||
assert [id(t) for t in tools] == original_ids
|
||||
assert len(tools) == 2
|
||||
# #endregion test_pipeline_is_idempotent
|
||||
# #endregion Test.Agent.TestPipelineIsIdempotent
|
||||
|
||||
|
||||
# ── enforce_tool_permission ──────────────────────────────────────────
|
||||
|
||||
# #region test_invocation_guard_admin_allowed [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestInvocationGuardAdminAllowed [C:2] [TYPE Function]
|
||||
def test_enforce_tool_permission_admin_allowed():
|
||||
"""Admin role should be allowed to invoke all restricted tools."""
|
||||
restricted = ["deploy_dashboard", "commit_changes", "create_branch",
|
||||
@@ -199,10 +199,10 @@ def test_enforce_tool_permission_admin_allowed():
|
||||
assert enforce_tool_permission(tool_name, "admin") is True, (
|
||||
f"Admin should be allowed to invoke '{tool_name}'"
|
||||
)
|
||||
# #endregion test_invocation_guard_admin_allowed
|
||||
# #endregion Test.Agent.TestInvocationGuardAdminAllowed
|
||||
|
||||
|
||||
# #region test_invocation_guard_viewer_denied [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestInvocationGuardViewerDenied [C:2] [TYPE Function]
|
||||
def test_enforce_tool_permission_viewer_denied():
|
||||
"""Viewer role should be denied for all restricted tools."""
|
||||
restricted = ["deploy_dashboard", "commit_changes", "create_branch",
|
||||
@@ -211,19 +211,19 @@ def test_enforce_tool_permission_viewer_denied():
|
||||
assert enforce_tool_permission(tool_name, "viewer") is False, (
|
||||
f"Viewer should NOT be allowed to invoke '{tool_name}'"
|
||||
)
|
||||
# #endregion test_invocation_guard_viewer_denied
|
||||
# #endregion Test.Agent.TestInvocationGuardViewerDenied
|
||||
|
||||
|
||||
# #region test_invocation_guard_unknown_tool_allowed [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestInvocationGuardUnknownToolAllowed [C:2] [TYPE Function]
|
||||
def test_enforce_tool_permission_unknown_tool_always_allowed():
|
||||
"""Unknown tools (not in _TOOL_PERMISSIONS) should always be allowed."""
|
||||
assert enforce_tool_permission("search_dashboards", "viewer") is True
|
||||
assert enforce_tool_permission("show_capabilities", "viewer") is True
|
||||
assert enforce_tool_permission("nonexistent_tool", "viewer") is True
|
||||
# #endregion test_invocation_guard_unknown_tool_allowed
|
||||
# #endregion Test.Agent.TestInvocationGuardUnknownToolAllowed
|
||||
|
||||
|
||||
# #region test_context_affinity_coverage [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestContextAffinityCoverage [C:2] [TYPE Function]
|
||||
def test_context_affinity_maps_exist_for_all_expected_types():
|
||||
"""Verify that all three known object types have affinity mappings."""
|
||||
assert "dashboard" in _CONTEXT_TOOL_AFFINITY
|
||||
@@ -234,10 +234,10 @@ def test_context_affinity_maps_exist_for_all_expected_types():
|
||||
assert len(_CONTEXT_TOOL_AFFINITY[obj_type]) >= 3, (
|
||||
f"Context affinity for '{obj_type}' should have ≥3 tools"
|
||||
)
|
||||
# #endregion test_context_affinity_coverage
|
||||
# #endregion Test.Agent.TestContextAffinityCoverage
|
||||
|
||||
|
||||
# #region test_rbac_maps_exist_for_write_tools [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestRbacMapsExistForWriteTools [C:2] [TYPE Function]
|
||||
def test_rbac_permissions_for_write_tools():
|
||||
"""Verify all admin-only tools are explicitly listed in _TOOL_PERMISSIONS."""
|
||||
expected_admin_tools = [
|
||||
@@ -253,3 +253,4 @@ def test_rbac_permissions_for_write_tools():
|
||||
|
||||
|
||||
# #endregion Test.Agent.ToolFilter
|
||||
# #endregion Test.Agent.TestRbacMapsExistForWriteTools
|
||||
|
||||
@@ -9,6 +9,10 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-unit-tests-32chars")
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -68,7 +72,7 @@ def mock_request():
|
||||
return req
|
||||
|
||||
|
||||
# #region test_extract_user_id [C:2] [TYPE Function]
|
||||
# #region Test.AgentChat.TestExtractUserId [C:2] [TYPE Function]
|
||||
# @BRIEF Test extract_user_id for various JWT payloads.
|
||||
class TestExtractUserId:
|
||||
def test_extracts_sub(self):
|
||||
@@ -95,10 +99,10 @@ class TestExtractUserId:
|
||||
assert extract_user_id("") == "unknown"
|
||||
|
||||
|
||||
# #endregion test_extract_user_id
|
||||
# #endregion Test.AgentChat.TestExtractUserId
|
||||
|
||||
|
||||
# #region test_confirmation_metadata [C:2] [TYPE Function]
|
||||
# #region Test.AgentChat.TestConfirmationMetadata [C:2] [TYPE Function]
|
||||
# @BRIEF Test backend HITL confirmation contract exposed to the frontend.
|
||||
class TestConfirmationMetadata:
|
||||
def test_extracts_tool_call_and_read_contract(self):
|
||||
@@ -217,10 +221,10 @@ class TestConfirmationMetadata:
|
||||
assert metadata_types[:3] == ["confirm_resolved", "tool_start", "tool_end"]
|
||||
|
||||
|
||||
# #endregion test_confirmation_metadata
|
||||
# #endregion Test.AgentChat.TestConfirmationMetadata
|
||||
|
||||
|
||||
# #region test_agent_handler [C:2] [TYPE Function]
|
||||
# #region Test.AgentChat.TestAgentHandler [C:2] [TYPE Function]
|
||||
# @BRIEF Test agent_handler for various scenarios.
|
||||
class TestAgentHandler:
|
||||
@pytest.mark.asyncio
|
||||
@@ -503,31 +507,40 @@ class TestAgentHandler:
|
||||
from ss_tools.agent.app import agent_handler
|
||||
from ss_tools.agent.context import get_user_jwt
|
||||
|
||||
# Create a test JWT using the same jose library the agent uses
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from jose import jwt as jose_jwt
|
||||
import os
|
||||
secret = os.getenv("AUTH_SECRET_KEY", "test-secret-key-for-unit-tests")
|
||||
token = jose_jwt.encode(
|
||||
{"sub": "admin", "scopes": ["Admin"], "exp": datetime.now(timezone.utc) + timedelta(hours=1)},
|
||||
secret,
|
||||
algorithm="HS256",
|
||||
)
|
||||
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsInNjb3BlcyI6WyJBZG1pbiJdLCJleHAiOjE3ODQxNDkwMzh9.fake"
|
||||
mock_event_stream = [{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="ok")}}]
|
||||
agent = _make_agent_mock(mock_event_stream)
|
||||
|
||||
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", AsyncMock()):
|
||||
# Check JWT is set DURING handler execution (reset_user_jwt runs in finally)
|
||||
jwt_during = None
|
||||
orig_event_stream = mock_event_stream
|
||||
|
||||
def _check_jwt(*args, **kwargs):
|
||||
nonlocal jwt_during
|
||||
jwt_during = get_user_jwt()
|
||||
return _make_async_iter(orig_event_stream)
|
||||
|
||||
agent.astream_events = MagicMock(side_effect=_check_jwt)
|
||||
|
||||
with (
|
||||
patch("ss_tools.agent.app.create_agent", return_value=agent),
|
||||
patch("ss_tools.agent.app.get_all_tools", return_value=[]),
|
||||
patch("ss_tools.agent.app.save_conversation", AsyncMock()),
|
||||
patch("ss_tools.agent.app.decode_token", return_value={"sub": "admin", "scopes": ["Admin"]}),
|
||||
):
|
||||
results = [r async for r in agent_handler("hi", [], mock_request, None, None, None, token)]
|
||||
|
||||
filtered = _skip_pipeline(results)
|
||||
assert len(filtered) == 1
|
||||
assert get_user_jwt() == token
|
||||
assert jwt_during == token, f"JWT should be {token[:20]}... during handler, got {jwt_during!r}"
|
||||
# After handler exits, JWT is reset (by finally block) — this is by design
|
||||
assert get_user_jwt() == "", "JWT should be cleared after handler completes"
|
||||
|
||||
|
||||
# #endregion test_agent_handler
|
||||
# #endregion Test.AgentChat.TestAgentHandler
|
||||
|
||||
|
||||
# #region test_handle_resume [C:2] [TYPE Function]
|
||||
# #region Test.AgentChat.TestHandleResume [C:2] [TYPE Function]
|
||||
# @BRIEF Test handle_resume LangGraph checkpoint resume (no pending confirmation).
|
||||
class TestHandleResume:
|
||||
@pytest.mark.asyncio
|
||||
@@ -553,36 +566,39 @@ class TestHandleResume:
|
||||
assert data["metadata"]["result"] == "denied"
|
||||
|
||||
|
||||
# #endregion test_handle_resume
|
||||
# #endregion Test.AgentChat.TestHandleResume
|
||||
|
||||
|
||||
# #region test_save_conversation [C:2] [TYPE Function]
|
||||
# #region Test.AgentChat.TestSaveConversation [C:2] [TYPE Function]
|
||||
# @BRIEF Test save_conversation with various scenarios.
|
||||
class TestSaveConversation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_success(self):
|
||||
from ss_tools.agent._persistence import save_conversation
|
||||
|
||||
with patch("ss_tools.agent._persistence.httpx.AsyncClient") as mock_client:
|
||||
mock_client.return_value.__aenter__.return_value.post = AsyncMock()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock()
|
||||
with patch("ss_tools.agent._persistence.get_shared_http_client", return_value=mock_client):
|
||||
await save_conversation("conv-1", "test message", "user-1")
|
||||
mock_client.return_value.__aenter__.return_value.post.assert_called_once()
|
||||
mock_client.post.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_with_service_jwt(self):
|
||||
from ss_tools.agent._persistence import save_conversation
|
||||
|
||||
with patch("ss_tools.agent._persistence.httpx.AsyncClient") as mock_client, patch("ss_tools.agent._persistence.os.getenv", return_value="service-token"):
|
||||
mock_client.return_value.__aenter__.return_value.post = AsyncMock()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock()
|
||||
with patch("ss_tools.agent._persistence.get_shared_http_client", return_value=mock_client), patch("ss_tools.agent._persistence.os.getenv", return_value="service-token"):
|
||||
await save_conversation("conv-1", "hello", "admin")
|
||||
mock_client.return_value.__aenter__.return_value.post.assert_called_once()
|
||||
mock_client.post.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_failure_logged(self):
|
||||
from ss_tools.agent._persistence import save_conversation
|
||||
|
||||
with patch("ss_tools.agent._persistence.httpx.AsyncClient") as mock_client:
|
||||
mock_client.return_value.__aenter__.return_value.post.side_effect = Exception("network err")
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(side_effect=Exception("network err"))
|
||||
with patch("ss_tools.agent._persistence.get_shared_http_client", return_value=mock_client):
|
||||
# Should not raise
|
||||
await save_conversation("conv-1", "msg", "u1")
|
||||
|
||||
@@ -590,19 +606,204 @@ class TestSaveConversation:
|
||||
async def test_save_empty_title(self):
|
||||
from ss_tools.agent._persistence import save_conversation
|
||||
|
||||
with patch("ss_tools.agent._persistence.httpx.AsyncClient") as mock_client:
|
||||
client_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = client_instance
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock()
|
||||
with patch("ss_tools.agent._persistence.get_shared_http_client", return_value=mock_client):
|
||||
await save_conversation("conv-1", " ", "user-1")
|
||||
call_kwargs = client_instance.post.call_args[1]
|
||||
call_kwargs = mock_client.post.call_args[1]
|
||||
# clean_title(" ") returns "Новый диалог" (Russian for "New conversation")
|
||||
assert call_kwargs["json"]["title"] == "Новый диалог"
|
||||
|
||||
|
||||
# #endregion test_save_conversation
|
||||
# #endregion Test.AgentChat.TestSaveConversation
|
||||
|
||||
|
||||
# #region test_create_chat_interface [C:2] [TYPE Function]
|
||||
# #region Test.AgentChat.TestLifecycleEvents [C:2] [TYPE Function]
|
||||
# @BRIEF Test lifecycle event emission in agent_handler: AGENT_REQUEST_STARTED, AGENT_LLM_*, AGENT_REQUEST_COMPLETED/FAILED.
|
||||
class TestLifecycleEvents:
|
||||
@pytest.mark.asyncio
|
||||
async def test_emits_request_started_on_normal_send(self, mock_request):
|
||||
from ss_tools.agent.app import agent_handler
|
||||
|
||||
mock_chunk = MagicMock()
|
||||
mock_chunk.content = "Hello"
|
||||
mock_event_stream = [{"event": "on_chat_model_stream", "data": {"chunk": mock_chunk}}]
|
||||
agent = _make_agent_mock(mock_event_stream)
|
||||
|
||||
mock_lifecycle = MagicMock()
|
||||
with (
|
||||
patch("ss_tools.agent.app.emit_lifecycle_event", mock_lifecycle),
|
||||
patch("ss_tools.agent.app.create_agent", return_value=agent),
|
||||
patch("ss_tools.agent.app.get_all_tools", return_value=[]),
|
||||
patch("ss_tools.agent.app.save_conversation", AsyncMock()),
|
||||
):
|
||||
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
|
||||
|
||||
assert len(results) > 0
|
||||
# Find AGENT_REQUEST_STARTED call
|
||||
started_calls = [c for c in mock_lifecycle.call_args_list if c[0][0] == "AGENT_REQUEST_STARTED"]
|
||||
assert len(started_calls) >= 1, "AGENT_REQUEST_STARTED not emitted"
|
||||
payload = started_calls[0][1]
|
||||
# Safe aggregate fields only
|
||||
assert "conversation_id" in payload
|
||||
assert "user_id" in payload
|
||||
assert "action" in payload
|
||||
# No sensitive fields
|
||||
assert "jwt" not in payload
|
||||
assert "user_message" not in payload
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emits_request_completed_on_success(self, mock_request):
|
||||
from ss_tools.agent.app import agent_handler
|
||||
|
||||
mock_chunk = MagicMock()
|
||||
mock_chunk.content = "Hello"
|
||||
mock_event_stream = [{"event": "on_chat_model_stream", "data": {"chunk": mock_chunk}}]
|
||||
agent = _make_agent_mock(mock_event_stream)
|
||||
|
||||
mock_lifecycle = AsyncMock()
|
||||
with (
|
||||
patch("ss_tools.agent.app.emit_lifecycle_event", mock_lifecycle),
|
||||
patch("ss_tools.agent.app.create_agent", return_value=agent),
|
||||
patch("ss_tools.agent.app.get_all_tools", return_value=[]),
|
||||
patch("ss_tools.agent.app.save_conversation", AsyncMock()),
|
||||
):
|
||||
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
|
||||
|
||||
completed_calls = [c for c in mock_lifecycle.call_args_list if c[0][0] == "AGENT_REQUEST_COMPLETED"]
|
||||
assert len(completed_calls) >= 1, "AGENT_REQUEST_COMPLETED not emitted"
|
||||
payload = completed_calls[0][1]
|
||||
assert "elapsed_ms" in payload
|
||||
assert "conversation_id" in payload
|
||||
assert "user_id" in payload
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emits_llm_completed_on_success(self, mock_request):
|
||||
from ss_tools.agent.app import agent_handler
|
||||
|
||||
mock_chunk = MagicMock()
|
||||
mock_chunk.content = "Hello"
|
||||
mock_event_stream = [{"event": "on_chat_model_stream", "data": {"chunk": mock_chunk}}]
|
||||
agent = _make_agent_mock(mock_event_stream)
|
||||
|
||||
mock_lifecycle = AsyncMock()
|
||||
with (
|
||||
patch("ss_tools.agent.app.emit_lifecycle_event", mock_lifecycle),
|
||||
patch("ss_tools.agent.app.create_agent", return_value=agent),
|
||||
patch("ss_tools.agent.app.get_all_tools", return_value=[]),
|
||||
patch("ss_tools.agent.app.save_conversation", AsyncMock()),
|
||||
):
|
||||
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
|
||||
|
||||
llm_completed_calls = [c for c in mock_lifecycle.call_args_list if c[0][0] == "AGENT_LLM_COMPLETED"]
|
||||
assert len(llm_completed_calls) >= 1, "AGENT_LLM_COMPLETED not emitted"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emits_llm_started_before_stream(self, mock_request):
|
||||
from ss_tools.agent.app import agent_handler
|
||||
|
||||
mock_chunk = MagicMock()
|
||||
mock_chunk.content = "Hello"
|
||||
mock_event_stream = [{"event": "on_chat_model_stream", "data": {"chunk": mock_chunk}}]
|
||||
agent = _make_agent_mock(mock_event_stream)
|
||||
|
||||
mock_lifecycle = AsyncMock()
|
||||
with (
|
||||
patch("ss_tools.agent.app.emit_lifecycle_event", mock_lifecycle),
|
||||
patch("ss_tools.agent.app.create_agent", return_value=agent),
|
||||
patch("ss_tools.agent.app.get_all_tools", return_value=[]),
|
||||
patch("ss_tools.agent.app.save_conversation", AsyncMock()),
|
||||
):
|
||||
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
|
||||
|
||||
llm_started_calls = [c for c in mock_lifecycle.call_args_list if c[0][0] == "AGENT_LLM_STARTED"]
|
||||
assert len(llm_started_calls) >= 1, "AGENT_LLM_STARTED not emitted"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emits_llm_failed_on_connection_error(self, mock_request):
|
||||
from ss_tools.agent.app import agent_handler
|
||||
|
||||
def mock_astream(*_args, **_kwargs):
|
||||
from httpx import ConnectError
|
||||
raise ConnectError("connection refused")
|
||||
|
||||
agent = MagicMock()
|
||||
agent.astream_events = mock_astream
|
||||
|
||||
mock_lifecycle = AsyncMock()
|
||||
with (
|
||||
patch("ss_tools.agent.app.emit_lifecycle_event", mock_lifecycle),
|
||||
patch("ss_tools.agent.app.create_agent", return_value=agent),
|
||||
patch("ss_tools.agent.app.get_all_tools", return_value=[]),
|
||||
patch("ss_tools.agent.app.save_conversation", AsyncMock()),
|
||||
):
|
||||
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
|
||||
|
||||
llm_failed_calls = [c for c in mock_lifecycle.call_args_list if c[0][0] == "AGENT_LLM_FAILED"]
|
||||
assert len(llm_failed_calls) >= 1, "AGENT_LLM_FAILED not emitted on connection error"
|
||||
assert llm_failed_calls[0][1]["error_code"] == "LLM_PROVIDER_UNAVAILABLE"
|
||||
request_failed_calls = [c for c in mock_lifecycle.call_args_list if c[0][0] == "AGENT_REQUEST_FAILED"]
|
||||
assert len(request_failed_calls) >= 1, "AGENT_REQUEST_FAILED not emitted"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uses_x_trace_id_from_request(self):
|
||||
from ss_tools.agent.app import agent_handler
|
||||
|
||||
import uuid
|
||||
|
||||
trace_id = uuid.uuid4().hex
|
||||
req = MagicMock()
|
||||
req.headers = {"X-Trace-ID": trace_id}
|
||||
|
||||
mock_chunk = MagicMock()
|
||||
mock_chunk.content = "ok"
|
||||
mock_event_stream = [{"event": "on_chat_model_stream", "data": {"chunk": mock_chunk}}]
|
||||
agent = _make_agent_mock(mock_event_stream)
|
||||
|
||||
mock_lifecycle = AsyncMock()
|
||||
with (
|
||||
patch("ss_tools.agent.app.emit_lifecycle_event", mock_lifecycle),
|
||||
patch("ss_tools.agent.app.create_agent", return_value=agent),
|
||||
patch("ss_tools.agent.app.get_all_tools", return_value=[]),
|
||||
patch("ss_tools.agent.app.save_conversation", AsyncMock()),
|
||||
patch("ss_tools.agent.middleware.set_trace_id") as mock_set,
|
||||
):
|
||||
results = [r async for r in agent_handler("hello", [], req, None, None)]
|
||||
|
||||
mock_set.assert_called_once_with(trace_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_completed_event_includes_tool_count(self, mock_request):
|
||||
from ss_tools.agent.app import agent_handler
|
||||
|
||||
mock_event_stream = [
|
||||
{"event": "on_tool_start", "name": "list_environments", "data": {"input": {}}},
|
||||
{"event": "on_tool_end", "name": "list_environments", "data": {"output": "done"}},
|
||||
]
|
||||
agent = _make_agent_mock(mock_event_stream)
|
||||
|
||||
mock_lifecycle = AsyncMock()
|
||||
with (
|
||||
patch("ss_tools.agent.app.emit_lifecycle_event", mock_lifecycle),
|
||||
patch("ss_tools.agent.app.create_agent", return_value=agent),
|
||||
patch("ss_tools.agent.app.get_all_tools", return_value=[]),
|
||||
patch("ss_tools.agent.app.save_conversation", AsyncMock()),
|
||||
patch("ss_tools.agent.app.log_tool_event", AsyncMock()),
|
||||
):
|
||||
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
|
||||
|
||||
# Find REQUEST_COMPLETED — state has next=() (empty), falls through to normal completion
|
||||
completed_calls = [c for c in mock_lifecycle.call_args_list if c[0][0] == "AGENT_REQUEST_COMPLETED"]
|
||||
if completed_calls:
|
||||
payload = completed_calls[0][1]
|
||||
if "tool_count" in payload:
|
||||
assert payload["tool_count"] >= 1
|
||||
|
||||
|
||||
# #endregion Test.AgentChat.TestLifecycleEvents
|
||||
|
||||
|
||||
# #region Test.AgentChat.TestCreateChatInterface [C:2] [TYPE Function]
|
||||
# @BRIEF Test create_chat_interface returns a gr.ChatInterface.
|
||||
class TestCreateChatInterface:
|
||||
def test_returns_chat_interface(self):
|
||||
@@ -613,10 +814,10 @@ class TestCreateChatInterface:
|
||||
assert result is mock_ci.return_value
|
||||
|
||||
|
||||
# #endregion test_create_chat_interface
|
||||
# #endregion Test.AgentChat.TestCreateChatInterface
|
||||
|
||||
|
||||
# #region test_health [C:2] [TYPE Function]
|
||||
# #region Test.AgentChat.TestHealth [C:2] [TYPE Function]
|
||||
# @BRIEF Test health endpoint returns status ok.
|
||||
class TestHealth:
|
||||
@pytest.mark.asyncio
|
||||
@@ -627,10 +828,10 @@ class TestHealth:
|
||||
assert result["status"] == "ok"
|
||||
|
||||
|
||||
# #endregion test_health
|
||||
# #endregion Test.AgentChat.TestHealth
|
||||
|
||||
|
||||
# #region test_file_upload_parsing [C:2] [TYPE Function]
|
||||
# #region Test.AgentChat.TestFileUploadParsing [C:2] [TYPE Function]
|
||||
# @BRIEF Test file upload branch — parse_upload called for valid small files.
|
||||
class TestFileUploadParsing:
|
||||
@pytest.mark.asyncio
|
||||
@@ -662,10 +863,10 @@ class TestFileUploadParsing:
|
||||
assert token_data["metadata"]["type"] == "stream_token"
|
||||
|
||||
|
||||
# #endregion test_file_upload_parsing
|
||||
# #endregion Test.AgentChat.TestFileUploadParsing
|
||||
|
||||
|
||||
# #region test_app_main_block [C:2] [TYPE Function]
|
||||
# #region Test.AgentChat.TestAppMainBlock [C:2] [TYPE Function]
|
||||
# @BRIEF Test if __name__ == '__main__' block in app.py.
|
||||
class TestAppMainBlock:
|
||||
def test_app_main_block(self):
|
||||
@@ -677,7 +878,10 @@ class TestAppMainBlock:
|
||||
spec = importlib.util.spec_from_file_location("__main__", str(app_path))
|
||||
|
||||
mock_demo = MagicMock()
|
||||
with patch("gradio.ChatInterface") as mock_ci:
|
||||
with (
|
||||
patch("gradio.ChatInterface") as mock_ci,
|
||||
patch("ss_tools.agent.middleware.close_lifecycle_resources", AsyncMock()),
|
||||
):
|
||||
mock_ci.return_value = mock_demo
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
@@ -686,5 +890,5 @@ class TestAppMainBlock:
|
||||
mock_demo.launch.assert_called_once()
|
||||
|
||||
|
||||
# #endregion test_app_main_block
|
||||
# #endregion Test.AgentChat.TestAppMainBlock
|
||||
# #endregion Test.AgentChat.GradioApp
|
||||
|
||||
@@ -18,7 +18,7 @@ def _tools(names: list[str]) -> list[SimpleNamespace]:
|
||||
return [SimpleNamespace(name=name) for name in names]
|
||||
|
||||
|
||||
# #region test_dashboard_context_filters_tools [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestDashboardContextFiltersTools [C:2] [TYPE Function]
|
||||
# @BRIEF Dashboard context keeps only dashboard-affinity tools and mandatory capabilities.
|
||||
def test_dashboard_context_filters_tools():
|
||||
tools = _tools([
|
||||
@@ -38,10 +38,10 @@ def test_dashboard_context_filters_tools():
|
||||
"deploy_dashboard",
|
||||
"show_capabilities",
|
||||
]
|
||||
# #endregion test_dashboard_context_filters_tools
|
||||
# #endregion Test.Agent.TestDashboardContextFiltersTools
|
||||
|
||||
|
||||
# #region test_dashboard_context_viewer_removes_admin_tools [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestDashboardContextViewerRemovesAdminTools [C:2] [TYPE Function]
|
||||
# @BRIEF Viewer role removes admin-only tools even when they are dashboard-affinity tools.
|
||||
def test_dashboard_context_viewer_removes_admin_tools():
|
||||
tools = _tools([
|
||||
@@ -54,20 +54,20 @@ def test_dashboard_context_viewer_removes_admin_tools():
|
||||
result = [tool.name for tool in build_tool_pipeline(tools, "viewer", "dashboard")]
|
||||
|
||||
assert result == ["search_dashboards", "show_capabilities"]
|
||||
# #endregion test_dashboard_context_viewer_removes_admin_tools
|
||||
# #endregion Test.Agent.TestDashboardContextViewerRemovesAdminTools
|
||||
|
||||
|
||||
# #region test_invocation_guard_blocks_mutating_tool [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestInvocationGuardBlocksMutatingTool [C:2] [TYPE Function]
|
||||
# @BRIEF Invocation guard rejects admin-only tools for non-admin role before side effects.
|
||||
def test_invocation_guard_blocks_mutating_tool():
|
||||
set_user_role("viewer")
|
||||
|
||||
with pytest.raises(PermissionError, match="PERMISSION_DENIED:deploy_dashboard:admin:viewer"):
|
||||
_guard_tool_permission("deploy_dashboard")
|
||||
# #endregion test_invocation_guard_blocks_mutating_tool
|
||||
# #endregion Test.Agent.TestInvocationGuardBlocksMutatingTool
|
||||
|
||||
|
||||
# #region test_summarise_response_preserves_json_array_shape [C:2] [TYPE Function]
|
||||
# #region Test.Agent.TestSummariseResponsePreservesJsonArrayShape [C:2] [TYPE Function]
|
||||
# @BRIEF Large JSON arrays are summarised as top-N plus total count, not cut mid-structure.
|
||||
def test_summarise_response_preserves_json_array_shape():
|
||||
text = "[" + ",".join(f'{{"id":{idx},"name":"dashboard-{idx}"}}' for idx in range(20)) + "]"
|
||||
@@ -77,6 +77,6 @@ def test_summarise_response_preserves_json_array_shape():
|
||||
assert summary.startswith("Found 20 items:")
|
||||
assert "dashboard-0" in summary
|
||||
assert "15 more items" in summary
|
||||
# #endregion test_summarise_response_preserves_json_array_shape
|
||||
# #endregion Test.Agent.TestSummariseResponsePreservesJsonArrayShape
|
||||
|
||||
# #endregion Test.Agent.Feature035
|
||||
|
||||
@@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-jwt-testing")
|
||||
@@ -19,6 +20,24 @@ os.environ["SERVICE_JWT"] = "test-service-jwt"
|
||||
os.environ["OPENAI_API_KEY"] = "sk-test-key"
|
||||
|
||||
|
||||
def _mock_http_client(get_return=None, post_return=None, get_side_effect=None):
|
||||
"""Create a mock for get_shared_http_client that returns a mock client.
|
||||
|
||||
Returns (mock_client, patcher) tuple. Use as:
|
||||
mock_client, patcher = _mock_http_client(...)
|
||||
with patcher:
|
||||
...
|
||||
"""
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
if get_side_effect is not None:
|
||||
mock_client.get = AsyncMock(side_effect=get_side_effect)
|
||||
elif get_return is not None:
|
||||
mock_client.get = AsyncMock(return_value=get_return)
|
||||
if post_return is not None:
|
||||
mock_client.post = AsyncMock(return_value=post_return)
|
||||
return mock_client, patch("ss_tools.agent.tools.get_shared_http_client", return_value=mock_client)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
@@ -38,19 +57,15 @@ async def test_tool_dual_auth_headers():
|
||||
set_user_jwt("user-jwt-token")
|
||||
set_service_jwt("service-jwt-token")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = mock_instance
|
||||
mock_instance.get.return_value = Mock(
|
||||
status_code=200,
|
||||
text='{"dashboards": [], "total": 0}',
|
||||
)
|
||||
mock_instance.get.return_value.json.return_value = {"dashboards": [], "total": 0}
|
||||
mock_resp = Mock(status_code=200, text='{"dashboards": [], "total": 0}')
|
||||
mock_resp.json.return_value = {"dashboards": [], "total": 0}
|
||||
|
||||
mock_client, patcher = _mock_http_client(get_return=mock_resp)
|
||||
with patcher:
|
||||
await search_dashboards.ainvoke({"query": "test"})
|
||||
|
||||
# Verify the HTTP request included dual-identity headers
|
||||
call_kwargs = mock_instance.get.call_args
|
||||
call_kwargs = mock_client.get.call_args
|
||||
assert call_kwargs is not None, "HTTP GET should have been called"
|
||||
_, kwargs = call_kwargs
|
||||
headers = kwargs.get("headers", {})
|
||||
@@ -78,19 +93,14 @@ async def test_tool_auth_fallback_to_env():
|
||||
set_service_jwt("")
|
||||
os.environ["SERVICE_JWT"] = "env-service-token"
|
||||
|
||||
with patch.object(tools_mod, "FASTAPI_URL", "http://test-backend:8000"), patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = mock_instance
|
||||
mock_instance.get.return_value = Mock(
|
||||
status_code=200,
|
||||
text='{"dashboards": [], "total": 0}',
|
||||
)
|
||||
mock_instance.get.return_value.json.return_value = {"dashboards": [], "total": 0}
|
||||
mock_resp = Mock(status_code=200, text='{"dashboards": [], "total": 0}')
|
||||
mock_resp.json.return_value = {"dashboards": [], "total": 0}
|
||||
|
||||
# Since tool uses os.getenv at call time, the env var will be read
|
||||
mock_client, patcher = _mock_http_client(get_return=mock_resp)
|
||||
with patch.object(tools_mod, "FASTAPI_URL", "http://test-backend:8000"), patcher:
|
||||
await search_dashboards.ainvoke({"query": "test"})
|
||||
|
||||
call_kwargs = mock_instance.get.call_args
|
||||
call_kwargs = mock_client.get.call_args
|
||||
assert call_kwargs is not None
|
||||
_, kwargs = call_kwargs
|
||||
headers = kwargs.get("headers", {})
|
||||
@@ -114,11 +124,8 @@ async def test_tool_http_exception_handling():
|
||||
set_user_jwt("test-jwt")
|
||||
set_service_jwt("svc-jwt")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = mock_instance
|
||||
mock_instance.get.side_effect = Exception("Connection refused")
|
||||
|
||||
_, patcher = _mock_http_client(get_side_effect=Exception("Connection refused"))
|
||||
with patcher:
|
||||
# Should propagate the exception (caller handles error)
|
||||
with pytest.raises((Exception,)):
|
||||
await search_dashboards.ainvoke({"query": "test"})
|
||||
@@ -209,18 +216,14 @@ async def test_search_dashboards_correct_url():
|
||||
set_user_jwt("jwt")
|
||||
set_service_jwt("svc-jwt")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = mock_instance
|
||||
mock_instance.get.return_value = Mock(
|
||||
status_code=200,
|
||||
text='{"dashboards": [], "total": 0}',
|
||||
)
|
||||
mock_instance.get.return_value.json.return_value = {"dashboards": [], "total": 0}
|
||||
mock_resp = Mock(status_code=200, text='{"dashboards": [], "total": 0}')
|
||||
mock_resp.json.return_value = {"dashboards": [], "total": 0}
|
||||
|
||||
mock_client, patcher = _mock_http_client(get_return=mock_resp)
|
||||
with patcher:
|
||||
await search_dashboards.ainvoke({"query": "dashboard-name", "env_id": "prod"})
|
||||
|
||||
call_args = mock_instance.get.call_args
|
||||
call_args = mock_client.get.call_args
|
||||
assert call_args is not None
|
||||
args, kwargs = call_args
|
||||
url = args[0] if args else kwargs.get("url", "")
|
||||
@@ -243,15 +246,13 @@ async def test_get_health_summary_calls_correct_url():
|
||||
set_user_jwt("jwt")
|
||||
set_service_jwt("svc-jwt")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = mock_instance
|
||||
mock_instance.get.return_value.status_code = 200
|
||||
mock_instance.get.return_value.text = '{"status": "ok"}'
|
||||
mock_resp = Mock(status_code=200, text='{"status": "ok"}')
|
||||
|
||||
mock_client, patcher = _mock_http_client(get_return=mock_resp)
|
||||
with patcher:
|
||||
await get_health_summary.ainvoke({"env_id": "ss-dev"})
|
||||
|
||||
call_args = mock_instance.get.call_args
|
||||
call_args = mock_client.get.call_args
|
||||
assert call_args is not None
|
||||
args, kwargs = call_args
|
||||
url = args[0] if args else kwargs.get("url", "")
|
||||
@@ -275,15 +276,13 @@ async def test_list_environments_calls_correct_url():
|
||||
set_user_jwt("jwt")
|
||||
set_service_jwt("svc-jwt")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = mock_instance
|
||||
mock_instance.get.return_value.status_code = 200
|
||||
mock_instance.get.return_value.text = '["prod", "dev"]'
|
||||
mock_resp = Mock(status_code=200, text='["prod", "dev"]')
|
||||
|
||||
mock_client, patcher = _mock_http_client(get_return=mock_resp)
|
||||
with patcher:
|
||||
await list_environments.ainvoke({})
|
||||
|
||||
call_args = mock_instance.get.call_args
|
||||
call_args = mock_client.get.call_args
|
||||
assert call_args is not None
|
||||
args, kwargs = call_args
|
||||
url = args[0] if args else kwargs.get("url", "")
|
||||
@@ -299,12 +298,13 @@ async def test_list_environments_redacts_sensitive_fields():
|
||||
set_user_jwt("jwt")
|
||||
set_service_jwt("svc-jwt")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = mock_instance
|
||||
mock_instance.get.return_value.status_code = 200
|
||||
mock_instance.get.return_value.text = '[{"id":"prod","password":"secret-pass","api_key":"secret-key","nested":{"token":"secret-token"},"name":"ss-prod"}]'
|
||||
mock_resp = Mock(
|
||||
status_code=200,
|
||||
text='[{"id":"prod","password":"secret-pass","api_key":"secret-key","nested":{"token":"secret-token"},"name":"ss-prod"}]',
|
||||
)
|
||||
|
||||
_, patcher = _mock_http_client(get_return=mock_resp)
|
||||
with patcher:
|
||||
result = await list_environments.ainvoke({})
|
||||
|
||||
assert "secret-pass" not in result
|
||||
@@ -329,15 +329,13 @@ async def test_get_task_status_calls_correct_url():
|
||||
set_user_jwt("jwt")
|
||||
set_service_jwt("svc-jwt")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = mock_instance
|
||||
mock_instance.get.return_value.status_code = 200
|
||||
mock_instance.get.return_value.text = '{"status": "running"}'
|
||||
mock_resp = Mock(status_code=200, text='{"status": "running"}')
|
||||
|
||||
mock_client, patcher = _mock_http_client(get_return=mock_resp)
|
||||
with patcher:
|
||||
await get_task_status.ainvoke({"task_id": "task-123"})
|
||||
|
||||
call_args = mock_instance.get.call_args
|
||||
call_args = mock_client.get.call_args
|
||||
assert call_args is not None
|
||||
args, kwargs = call_args
|
||||
url = args[0] if args else kwargs.get("url", "")
|
||||
@@ -357,17 +355,17 @@ async def test_run_backup_posts_task_payload():
|
||||
set_service_jwt("svc-jwt")
|
||||
set_user_role("admin")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = mock_instance
|
||||
mock_instance.post.return_value = Mock(status_code=201, text='{"id": "task-1"}')
|
||||
mock_resp = Mock(status_code=201, text='{"id": "task-1"}')
|
||||
|
||||
mock_client, patcher = _mock_http_client(post_return=mock_resp)
|
||||
with patcher:
|
||||
await run_backup.ainvoke({"environment_id": "prod", "dashboard_id": 10})
|
||||
|
||||
call_args = mock_instance.post.call_args
|
||||
call_args = mock_client.post.call_args
|
||||
assert call_args is not None
|
||||
args, kwargs = call_args
|
||||
assert "api/tasks" in args[0]
|
||||
url = args[0] if args else kwargs.get("url", "")
|
||||
assert "api/tasks" in url
|
||||
assert kwargs["json"] == {
|
||||
"plugin_id": "superset-backup",
|
||||
"params": {"environment_id": "prod", "dashboard_ids": [10]},
|
||||
@@ -384,17 +382,17 @@ async def test_deploy_dashboard_posts_git_endpoint():
|
||||
set_service_jwt("svc-jwt")
|
||||
set_user_role("admin")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = mock_instance
|
||||
mock_instance.post.return_value = Mock(status_code=200, text='{"status": "success"}')
|
||||
mock_resp = Mock(status_code=200, text='{"status": "success"}')
|
||||
|
||||
mock_client, patcher = _mock_http_client(post_return=mock_resp)
|
||||
with patcher:
|
||||
await deploy_dashboard.ainvoke({"dashboard_ref": "42", "environment_id": "prod"})
|
||||
|
||||
call_args = mock_instance.post.call_args
|
||||
call_args = mock_client.post.call_args
|
||||
assert call_args is not None
|
||||
args, kwargs = call_args
|
||||
assert "api/git/repositories/42/deploy" in args[0]
|
||||
url = args[0] if args else kwargs.get("url", "")
|
||||
assert "api/git/repositories/42/deploy" in url
|
||||
assert kwargs["json"] == {"environment_id": "prod"}
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
# #region test_configure_from_api [C:2] [TYPE Function]
|
||||
# #region Test.AgentChat.TestConfigureFromApi [C:2] [TYPE Function]
|
||||
# @BRIEF Test configure_from_api updates global config.
|
||||
class TestConfigureFromApi:
|
||||
def test_sets_llm_config(self):
|
||||
@@ -36,10 +36,35 @@ class TestConfigureFromApi:
|
||||
ls.configure_from_api({"configured": False})
|
||||
assert ls._llm_config["configured"] is False
|
||||
ls._llm_config = None
|
||||
# #endregion test_configure_from_api
|
||||
# #endregion Test.AgentChat.TestConfigureFromApi
|
||||
|
||||
|
||||
# #region test_create_agent [C:2] [TYPE Function]
|
||||
# #region Test.AgentChat.TestLlmDiagnostics [C:2] [TYPE Function] [SEMANTICS test,agent,llm,observability]
|
||||
# @BRIEF Diagnostics identify the configured provider but never expose API credentials or full URL paths.
|
||||
def test_llm_diagnostics_redacts_api_key_and_path():
|
||||
import ss_tools.agent.langgraph_setup as ls
|
||||
|
||||
diagnostics = ls.llm_diagnostics({
|
||||
"configured": True,
|
||||
"provider_id": "provider-1",
|
||||
"provider_name": "litellm",
|
||||
"provider_type": "litellm",
|
||||
"base_url": "https://key:secret@lite.ai.rusal.com/v1/private",
|
||||
"api_key": "must-not-appear",
|
||||
"default_model": "qwen-flash",
|
||||
"selection_source": "assistant_planner_provider",
|
||||
})
|
||||
|
||||
assert diagnostics["provider_host"] == "lite.ai.rusal.com"
|
||||
assert diagnostics["provider_scheme"] == "https"
|
||||
assert diagnostics["model"] == "qwen-flash"
|
||||
assert "api_key" not in diagnostics
|
||||
assert "base_url" not in diagnostics
|
||||
assert all("secret" not in str(value) for value in diagnostics.values())
|
||||
# #endregion Test.AgentChat.TestLlmDiagnostics
|
||||
|
||||
|
||||
# #region Test.AgentChat.TestCreateAgent [C:2] [TYPE Function]
|
||||
# @BRIEF Test create_agent with various LLM config states.
|
||||
class TestCreateAgent:
|
||||
@pytest.mark.anyio
|
||||
@@ -181,5 +206,5 @@ class TestCreateAgent:
|
||||
await ls.create_agent([], interrupt_before=[])
|
||||
assert mock_create.call_args[1]["interrupt_before"] == []
|
||||
ls._llm_config = None
|
||||
# #endregion test_create_agent
|
||||
# #endregion Test.AgentChat.TestCreateAgent
|
||||
# #endregion Test.AgentChat.LangGraph.Setup
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# #region Test.AgentChat.Middleware [C:3] [TYPE Module] [SEMANTICS test,agent,middleware,audit]
|
||||
# @BRIEF Tests for agent/middleware.py — log_tool_event.
|
||||
# @BRIEF Tests for agent/middleware.py — log_tool_event, emit_lifecycle_event, extract_trace_id_from_request.
|
||||
# @RELATION BINDS_TO -> [AgentChat.Middleware]
|
||||
|
||||
from pathlib import Path
|
||||
@@ -7,11 +7,167 @@ import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
import uuid
|
||||
from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
|
||||
# #region test_log_tool_event [C:2] [TYPE Function]
|
||||
# #region Test.AgentChat.TestEmitLifecycleEvent [C:2] [TYPE Function]
|
||||
# @BRIEF Test emit_lifecycle_event for correct event type and payload.
|
||||
class TestEmitLifecycleEvent:
|
||||
def test_emits_event_with_correct_type_and_payload(self):
|
||||
from ss_tools.agent.middleware import emit_lifecycle_event
|
||||
|
||||
with patch("ss_tools.agent.middleware.logger.reason") as mock_reason:
|
||||
emit_lifecycle_event(
|
||||
"AGENT_REQUEST_STARTED",
|
||||
conversation_id="conv-1",
|
||||
user_id="user-1",
|
||||
environment_id="prod",
|
||||
action="new",
|
||||
)
|
||||
mock_reason.assert_called_once()
|
||||
args, kwargs = mock_reason.call_args
|
||||
assert args[0] == "AGENT_REQUEST_STARTED"
|
||||
payload = kwargs["payload"]
|
||||
assert payload["conversation_id"] == "conv-1"
|
||||
assert payload["user_id"] == "user-1"
|
||||
assert payload["environment_id"] == "prod"
|
||||
assert payload["action"] == "new"
|
||||
assert kwargs["extra"]["src"] == "AgentChat.Lifecycle"
|
||||
|
||||
def test_filters_none_payload_values(self):
|
||||
from ss_tools.agent.middleware import emit_lifecycle_event
|
||||
|
||||
with patch("ss_tools.agent.middleware.logger.reason") as mock_reason:
|
||||
emit_lifecycle_event(
|
||||
"AGENT_REQUEST_COMPLETED",
|
||||
conversation_id="conv-1",
|
||||
is_resume=None,
|
||||
tool_names=None,
|
||||
)
|
||||
payload = mock_reason.call_args[1]["payload"]
|
||||
assert "conversation_id" in payload
|
||||
assert "is_resume" not in payload
|
||||
assert "tool_names" not in payload
|
||||
|
||||
def test_never_includes_sensitive_fields(self):
|
||||
"""Verify that sensitive field names are never in payload schema."""
|
||||
from ss_tools.agent.middleware import emit_lifecycle_event
|
||||
|
||||
with patch("ss_tools.agent.middleware.logger.reason") as mock_reason:
|
||||
emit_lifecycle_event(
|
||||
"AGENT_REQUEST_STARTED",
|
||||
conversation_id="conv-1",
|
||||
user_id="user-1",
|
||||
)
|
||||
payload = mock_reason.call_args[1]["payload"]
|
||||
forbidden = {"jwt", "token", "password", "secret", "message", "prompt", "file", "user_message"}
|
||||
payload_keys = set(k.lower() for k in payload)
|
||||
assert not (payload_keys & forbidden), f"Found forbidden key in payload: {payload_keys & forbidden}"
|
||||
|
||||
def test_filters_forbidden_lifecycle_fields_even_if_caller_passes_them(self):
|
||||
"""Lifecycle helper enforces its no-sensitive-data invariant at runtime."""
|
||||
from ss_tools.agent.middleware import emit_lifecycle_event
|
||||
|
||||
with patch("ss_tools.agent.middleware.logger.reason") as mock_reason:
|
||||
emit_lifecycle_event(
|
||||
"AGENT_REQUEST_COMPLETED",
|
||||
conversation_id="conv-1",
|
||||
jwt="secret",
|
||||
message="private request",
|
||||
files=["private.pdf"],
|
||||
raw_output="private result",
|
||||
)
|
||||
|
||||
payload = mock_reason.call_args.kwargs["payload"]
|
||||
assert payload == {"conversation_id": "conv-1"}
|
||||
|
||||
|
||||
# #endregion Test.AgentChat.TestEmitLifecycleEvent
|
||||
|
||||
|
||||
# #region Test.AgentChat.TestExtractTraceIdFromRequest [C:2] [TYPE Function]
|
||||
# @BRIEF Test extract_trace_id_from_request with valid/invalid/missing X-Trace-ID headers.
|
||||
class TestExtractTraceIdFromRequest:
|
||||
def make_request(self, headers: dict | None = None) -> MagicMock:
|
||||
req = MagicMock()
|
||||
req.headers = headers or {}
|
||||
return req
|
||||
|
||||
def test_extracts_valid_uuid4_from_header(self):
|
||||
from ss_tools.agent.middleware import extract_trace_id_from_request
|
||||
|
||||
valid_id = uuid.uuid4().hex
|
||||
req = self.make_request({"X-Trace-ID": valid_id})
|
||||
with patch("ss_tools.agent.middleware.set_trace_id") as mock_set:
|
||||
result = extract_trace_id_from_request(req)
|
||||
assert result == valid_id
|
||||
mock_set.assert_called_once_with(valid_id)
|
||||
|
||||
def test_case_insensitive_header(self):
|
||||
from ss_tools.agent.middleware import extract_trace_id_from_request
|
||||
|
||||
valid_id = uuid.uuid4().hex
|
||||
req = self.make_request({"x-trace-id": valid_id})
|
||||
with patch("ss_tools.agent.middleware.set_trace_id") as mock_set:
|
||||
result = extract_trace_id_from_request(req)
|
||||
assert result == valid_id
|
||||
mock_set.assert_called_once_with(valid_id)
|
||||
|
||||
def test_seeds_when_header_missing(self):
|
||||
from ss_tools.agent.middleware import extract_trace_id_from_request
|
||||
|
||||
req = self.make_request({"authorization": "Bearer xyz"})
|
||||
with patch("ss_tools.agent.middleware.seed_trace_id", return_value="new-trace") as mock_seed:
|
||||
result = extract_trace_id_from_request(req)
|
||||
assert result == "new-trace"
|
||||
mock_seed.assert_called_once()
|
||||
|
||||
def test_seeds_when_header_empty(self):
|
||||
from ss_tools.agent.middleware import extract_trace_id_from_request
|
||||
|
||||
req = self.make_request({"X-Trace-ID": ""})
|
||||
with patch("ss_tools.agent.middleware.seed_trace_id", return_value="new-trace") as mock_seed:
|
||||
result = extract_trace_id_from_request(req)
|
||||
assert result == "new-trace"
|
||||
mock_seed.assert_called_once()
|
||||
|
||||
def test_seeds_on_invalid_uuid_format(self):
|
||||
from ss_tools.agent.middleware import extract_trace_id_from_request
|
||||
|
||||
req = self.make_request({"X-Trace-ID": "not-a-uuid-at-all"})
|
||||
with patch("ss_tools.agent.middleware.seed_trace_id", return_value="new-trace") as mock_seed:
|
||||
result = extract_trace_id_from_request(req)
|
||||
assert result == "new-trace"
|
||||
mock_seed.assert_called_once()
|
||||
|
||||
def test_seeds_on_non_v4_uuid(self):
|
||||
from ss_tools.agent.middleware import extract_trace_id_from_request
|
||||
|
||||
# UUID v1
|
||||
v1_id = "550e8400-e29b-11d1-a716-446655440000"
|
||||
req = self.make_request({"X-Trace-ID": v1_id})
|
||||
with patch("ss_tools.agent.middleware.seed_trace_id", return_value="new-trace") as mock_seed:
|
||||
result = extract_trace_id_from_request(req)
|
||||
assert result == "new-trace"
|
||||
mock_seed.assert_called_once()
|
||||
|
||||
def test_handles_request_without_headers(self):
|
||||
from ss_tools.agent.middleware import extract_trace_id_from_request
|
||||
|
||||
req = MagicMock(spec=[]) # no headers attr
|
||||
del req.headers
|
||||
with patch("ss_tools.agent.middleware.seed_trace_id", return_value="new-trace") as mock_seed:
|
||||
result = extract_trace_id_from_request(req)
|
||||
assert result == "new-trace"
|
||||
mock_seed.assert_called_once()
|
||||
|
||||
|
||||
# #endregion Test.AgentChat.TestExtractTraceIdFromRequest
|
||||
|
||||
|
||||
# #region Test.AgentChat.TestLogToolEvent [C:2] [TYPE Function]
|
||||
# @BRIEF Test log_tool_event for various event types.
|
||||
class TestLogToolEvent:
|
||||
@pytest.mark.asyncio
|
||||
@@ -84,5 +240,43 @@ class TestLogToolEvent:
|
||||
}
|
||||
with patch("ss_tools.agent.middleware.get_user_jwt", return_value="token"):
|
||||
await log_tool_event(event, "conv-1")
|
||||
# #endregion test_log_tool_event
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_includes_trace_id(self):
|
||||
from ss_tools.agent.middleware import log_tool_event
|
||||
|
||||
test_trace_id = "abc123"
|
||||
event = {
|
||||
"event": "on_tool_start",
|
||||
"name": "trace_test",
|
||||
"data": {"input": {"key": "val"}},
|
||||
}
|
||||
with (
|
||||
patch("ss_tools.agent.middleware.get_user_jwt", return_value="token"),
|
||||
patch("ss_tools.agent.middleware.get_trace_id", return_value=test_trace_id),
|
||||
patch("ss_tools.agent.middleware.logger.reason") as mock_reason,
|
||||
):
|
||||
await log_tool_event(event, "conv-1")
|
||||
payload = mock_reason.call_args[1]["payload"]
|
||||
assert payload["trace_id"] == test_trace_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_empty_trace_id(self):
|
||||
from ss_tools.agent.middleware import log_tool_event
|
||||
|
||||
event = {
|
||||
"event": "on_tool_start",
|
||||
"name": "no_trace",
|
||||
"data": {"input": {}},
|
||||
}
|
||||
with (
|
||||
patch("ss_tools.agent.middleware.get_user_jwt", return_value="token"),
|
||||
patch("ss_tools.agent.middleware.get_trace_id", return_value=""),
|
||||
patch("ss_tools.agent.middleware.logger.reason"),
|
||||
):
|
||||
await log_tool_event(event, "conv-1")
|
||||
# No exception = success
|
||||
|
||||
|
||||
# #endregion Test.AgentChat.TestLogToolEvent
|
||||
# #endregion Test.AgentChat.Middleware
|
||||
|
||||
57
agent/tests/test_agent/test_package_install.py
Normal file
57
agent/tests/test_agent/test_package_install.py
Normal file
@@ -0,0 +1,57 @@
|
||||
# #region Test.AgentChat.Packaging [C:3] [TYPE Module] [SEMANTICS test,agent,packaging,entrypoint,subprocess]
|
||||
# @BRIEF Verifies the installed agent package exposes the production module entry point.
|
||||
# @RELATION BINDS_TO -> [EXT:Python:ModuleEntrypoint]
|
||||
# @TEST_FIXTURE: installed_packages -> INLINE_JSON
|
||||
# @TEST_EDGE: missing_field -> Imports resolve without agent/src added to PYTHONPATH.
|
||||
# @TEST_EDGE: invalid_type -> Both shared and agent distributions are installed as packages.
|
||||
# @TEST_EDGE: external_fail -> pip installation failures surface through the subprocess result.
|
||||
# @RATIONALE Tests normally insert agent/src into sys.path, which can hide a broken editable or
|
||||
# wheel installation even though run.sh executes python -m ss_tools.agent.run.
|
||||
# @REJECTED Importing directly from agent/src was rejected because it cannot prove packaging works.
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
# #region Test.AgentChat.TestInstalledPackagesExposeAgentEntrypoint [C:2] [TYPE Function] [SEMANTICS test,agent,packaging,entrypoint]
|
||||
# @BRIEF Install shared and agent distributions into an isolated target and import the entry point.
|
||||
def test_installed_packages_expose_agent_entrypoint(tmp_path: Path) -> None:
|
||||
"""run.sh's module entry point is available without source-tree path injection."""
|
||||
repository_root = Path(__file__).parents[3]
|
||||
package_target = tmp_path / "site-packages"
|
||||
install = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--no-deps",
|
||||
"--target",
|
||||
str(package_target),
|
||||
str(repository_root / "shared"),
|
||||
str(repository_root / "agent"),
|
||||
],
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert install.returncode == 0, install.stderr
|
||||
|
||||
environment = os.environ.copy()
|
||||
environment["PYTHONPATH"] = str(package_target)
|
||||
imported = subprocess.run(
|
||||
[sys.executable, "-c", "import ss_tools.agent.run; import ss_tools.shared"],
|
||||
cwd=tmp_path,
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert imported.returncode == 0, imported.stderr
|
||||
# #endregion Test.AgentChat.TestInstalledPackagesExposeAgentEntrypoint
|
||||
|
||||
|
||||
# #endregion Test.AgentChat.Packaging
|
||||
@@ -8,7 +8,7 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
|
||||
# #region test_find_free_port [C:2] [TYPE Function]
|
||||
# #region Test.AgentChat.TestFindFreePort [C:2] [TYPE Function]
|
||||
# @BRIEF Test _find_free_port for port scanning behavior.
|
||||
class TestFindFreePort:
|
||||
def test_returns_free_port(self):
|
||||
@@ -45,10 +45,10 @@ class TestFindFreePort:
|
||||
with pytest.raises(OSError, match="No free port found"):
|
||||
_find_free_port(8000, 3)
|
||||
assert mock_instance.bind.call_count == 3
|
||||
# #endregion test_find_free_port
|
||||
# #endregion Test.AgentChat.TestFindFreePort
|
||||
|
||||
|
||||
# #region test_fetch_llm_config [C:2] [TYPE Function]
|
||||
# #region Test.AgentChat.TestFetchLlmConfig [C:2] [TYPE Function]
|
||||
# @BRIEF Test _fetch_llm_config with retry and fallback behavior.
|
||||
class TestFetchLlmConfig:
|
||||
def test_returns_config_on_success(self):
|
||||
@@ -120,10 +120,10 @@ class TestFetchLlmConfig:
|
||||
assert call_kwargs["headers"].get("Authorization") == "Bearer test-token"
|
||||
|
||||
|
||||
# #endregion test_fetch_llm_config
|
||||
# #endregion Test.AgentChat.TestFetchLlmConfig
|
||||
|
||||
|
||||
# #region test_main_block [C:2] [TYPE Function]
|
||||
# #region Test.AgentChat.TestMainBlock [C:2] [TYPE Function]
|
||||
# @BRIEF Test if __name__ == '__main__' block — service JWT, LLM config, port fallback, OSError.
|
||||
class TestMainBlock:
|
||||
"""Test the if __name__ == '__main__' entry point block via importlib.util fresh module."""
|
||||
@@ -246,5 +246,5 @@ class TestMainBlock:
|
||||
"GRADIO_ALLOW_PORT_FALLBACK": "true",
|
||||
},
|
||||
port_always_fail=True)
|
||||
# #endregion test_main_block
|
||||
# #endregion Test.AgentChat.TestMainBlock
|
||||
# #endregion Test.AgentChat.Run
|
||||
|
||||
@@ -44,7 +44,7 @@ def _make_read_timeout() -> httpx.ReadTimeout:
|
||||
class TestRetryReadTool:
|
||||
"""Contract tests for _retry_read_tool — the fixed-delay retry wrapper."""
|
||||
|
||||
# #region test_first_attempt_502_retries_once [C:2] [TYPE Function]
|
||||
# #region Test.AgentChat.TestFirstAttempt502RetriesOnce [C:2] [TYPE Function]
|
||||
# @BRIEF First attempt raises 502 → retries once → second attempt succeeds.
|
||||
async def test_first_attempt_502_retries_once(self):
|
||||
"""Prove @TEST_EDGE first_attempt_502: one retry + 1s delay → success."""
|
||||
@@ -68,9 +68,9 @@ class TestRetryReadTool:
|
||||
"max_attempts": 2,
|
||||
},
|
||||
}]
|
||||
# #endregion test_first_attempt_502_retries_once
|
||||
# #endregion Test.AgentChat.TestFirstAttempt502RetriesOnce
|
||||
|
||||
# #region test_both_attempts_502_raises [C:2] [TYPE Function]
|
||||
# #region Test.AgentChat.TestBothAttempts502Raises [C:2] [TYPE Function]
|
||||
# @BRIEF Both attempts raise 502 → exhaust retries → raises original error.
|
||||
async def test_both_attempts_502_raises(self):
|
||||
"""Prove @TEST_EDGE both_attempts_502: max 2 attempts, then raise."""
|
||||
@@ -82,9 +82,9 @@ class TestRetryReadTool:
|
||||
|
||||
assert exc_info.value is error_502
|
||||
assert mock_fn.call_count == 2
|
||||
# #endregion test_both_attempts_502_raises
|
||||
# #endregion Test.AgentChat.TestBothAttempts502Raises
|
||||
|
||||
# #region test_connect_error_retried [C:2] [TYPE Function]
|
||||
# #region Test.AgentChat.TestConnectErrorRetried [C:2] [TYPE Function]
|
||||
# @BRIEF ConnectError is also retried — not just HTTP status errors.
|
||||
async def test_connect_error_retried(self):
|
||||
"""Prove ConnectError triggers the retry path."""
|
||||
@@ -97,9 +97,9 @@ class TestRetryReadTool:
|
||||
|
||||
assert result == expected
|
||||
assert mock_fn.call_count == 2
|
||||
# #endregion test_connect_error_retried
|
||||
# #endregion Test.AgentChat.TestConnectErrorRetried
|
||||
|
||||
# #region test_read_timeout_retried [C:2] [TYPE Function]
|
||||
# #region Test.AgentChat.TestReadTimeoutRetried [C:2] [TYPE Function]
|
||||
# @BRIEF ReadTimeout is also retried — transient I/O timeouts are recoverable.
|
||||
async def test_read_timeout_retried(self):
|
||||
"""Prove ReadTimeout triggers the retry path."""
|
||||
@@ -112,9 +112,9 @@ class TestRetryReadTool:
|
||||
|
||||
assert result == expected
|
||||
assert mock_fn.call_count == 2
|
||||
# #endregion test_read_timeout_retried
|
||||
# #endregion Test.AgentChat.TestReadTimeoutRetried
|
||||
|
||||
# #region test_retry_skips_delay_on_success [C:2] [TYPE Function]
|
||||
# #region Test.AgentChat.TestRetrySkipsDelayOnSuccess [C:2] [TYPE Function]
|
||||
# @BRIEF When first attempt succeeds, no sleep occurs at all.
|
||||
async def test_retry_skips_delay_on_success(self):
|
||||
"""Prove that the happy path never sleeps — sleep is only for retries."""
|
||||
@@ -127,9 +127,9 @@ class TestRetryReadTool:
|
||||
assert result == expected
|
||||
assert mock_fn.call_count == 1
|
||||
mock_sleep.assert_not_awaited()
|
||||
# #endregion test_retry_skips_delay_on_success
|
||||
# #endregion Test.AgentChat.TestRetrySkipsDelayOnSuccess
|
||||
|
||||
# #region test_non_http_error_not_retried [C:2] [TYPE Function]
|
||||
# #region Test.AgentChat.TestNonHttpErrorNotRetried [C:2] [TYPE Function]
|
||||
# @BRIEF Non-HTTP errors (e.g. ValueError) propagate immediately — no retry.
|
||||
async def test_non_http_error_not_retried(self):
|
||||
"""Prove that only the three specific httpx exception types are retried."""
|
||||
@@ -142,13 +142,13 @@ class TestRetryReadTool:
|
||||
assert exc_info.value is non_http_err
|
||||
assert mock_fn.call_count == 1
|
||||
mock_sleep.assert_not_awaited()
|
||||
# #endregion test_non_http_error_not_retried
|
||||
# #endregion Test.AgentChat.TestNonHttpErrorNotRetried
|
||||
|
||||
|
||||
class TestWriteToolNoRetry:
|
||||
"""Prove that write tools bypass _retry_read_tool entirely."""
|
||||
|
||||
# #region test_write_tool_502_no_retry [C:2] [TYPE Function]
|
||||
# #region Test.AgentChat.TestWriteTool502NoRetry [C:2] [TYPE Function]
|
||||
# @BRIEF Write tool (is_write=True) gets 502 → no retry, raises immediately.
|
||||
async def test_write_tool_502_raises_immediately(self):
|
||||
"""Prove @TEST_EDGE write_tool_502: _execute_with_timeout does NOT retry writes.
|
||||
@@ -172,7 +172,7 @@ class TestWriteToolNoRetry:
|
||||
assert write_op.call_count == 1
|
||||
# Critical invariant: no sleep = no retry loop entered
|
||||
mock_sleep.assert_not_awaited()
|
||||
# #endregion test_write_tool_502_no_retry
|
||||
# #endregion Test.AgentChat.TestWriteTool502NoRetry
|
||||
|
||||
|
||||
# #endregion Test.AgentChat.ToolRetry
|
||||
|
||||
@@ -11,7 +11,7 @@ import json
|
||||
from ss_tools.agent.tools import _summarise_response
|
||||
|
||||
|
||||
# #region test_summarise_json_array_50_items [C:2] [TYPE Function]
|
||||
# #region Test.AgentChat.TestSummariseJsonArray50Items [C:2] [TYPE Function]
|
||||
# @BRIEF JSON array with 50 items → top-5 summary with remaining count.
|
||||
def test_summarise_json_array_50_items():
|
||||
"""Large JSON arrays summarise with top-5 items and remaining count."""
|
||||
@@ -24,10 +24,10 @@ def test_summarise_json_array_50_items():
|
||||
assert "item-0" in summary
|
||||
assert "item-4" in summary
|
||||
assert "... and 45 more items." in summary
|
||||
# #endregion test_summarise_json_array_50_items
|
||||
# #endregion Test.AgentChat.TestSummariseJsonArray50Items
|
||||
|
||||
|
||||
# #region test_summarise_short_text_passthrough [C:2] [TYPE Function]
|
||||
# #region Test.AgentChat.TestSummariseShortTextPassthrough [C:2] [TYPE Function]
|
||||
# @BRIEF Text ≤ limit is returned unchanged (no truncation, no JSON parse overhead visible).
|
||||
def test_summarise_short_text_passthrough():
|
||||
"""Text within limit is returned unchanged."""
|
||||
@@ -36,10 +36,10 @@ def test_summarise_short_text_passthrough():
|
||||
result = _summarise_response(text, limit=100)
|
||||
|
||||
assert result == text
|
||||
# #endregion test_summarise_short_text_passthrough
|
||||
# #endregion Test.AgentChat.TestSummariseShortTextPassthrough
|
||||
|
||||
|
||||
# #region test_summarise_json_object_keys_sample [C:2] [TYPE Function]
|
||||
# #region Test.AgentChat.TestSummariseJsonObjectKeysSample [C:2] [TYPE Function]
|
||||
# @BRIEF Large JSON object → key list + sample values.
|
||||
def test_summarise_json_object_keys_sample():
|
||||
"""Large JSON objects are summarised with keys and sample values."""
|
||||
@@ -50,10 +50,10 @@ def test_summarise_json_object_keys_sample():
|
||||
|
||||
assert summary.startswith("Result keys: ")
|
||||
assert "Sample: " in summary
|
||||
# #endregion test_summarise_json_object_keys_sample
|
||||
# #endregion Test.AgentChat.TestSummariseJsonObjectKeysSample
|
||||
|
||||
|
||||
# #region test_summarise_non_json_sentence_boundary [C:2] [TYPE Function]
|
||||
# #region Test.AgentChat.TestSummariseNonJsonSentenceBoundary [C:2] [TYPE Function]
|
||||
# @BRIEF Non-JSON long text truncated at last sentence boundary before limit.
|
||||
def test_summarise_non_json_sentence_boundary():
|
||||
"""Non-JSON text truncates at last sentence boundary with trailing ellipsis."""
|
||||
@@ -67,7 +67,7 @@ def test_summarise_non_json_sentence_boundary():
|
||||
assert len(summary) <= 500
|
||||
# Must retain at least one sentence boundary before the ellipsis
|
||||
assert ". " in summary[:-3]
|
||||
# #endregion test_summarise_non_json_sentence_boundary
|
||||
# #endregion Test.AgentChat.TestSummariseNonJsonSentenceBoundary
|
||||
|
||||
|
||||
# #endregion Test.AgentChat.ToolSummarise
|
||||
|
||||
@@ -14,7 +14,7 @@ import pytest
|
||||
# ── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# #region test_completes_under_timeout [C:2] [TYPE Function]
|
||||
# #region Test.AgentChat.TestCompletesUnderTimeout [C:2] [TYPE Function]
|
||||
# @BRIEF GIVEN a tool that returns quickly WHEN _execute_with_timeout is called with a 30s timeout THEN the result is returned normally.
|
||||
@pytest.mark.asyncio
|
||||
async def test_completes_under_timeout():
|
||||
@@ -30,10 +30,10 @@ async def test_completes_under_timeout():
|
||||
assert result == expected
|
||||
fast_fn.assert_called_once()
|
||||
mock_explore.assert_not_called()
|
||||
# #endregion test_completes_under_timeout
|
||||
# #endregion Test.AgentChat.TestCompletesUnderTimeout
|
||||
|
||||
|
||||
# #region test_read_tool_timeout [C:2] [TYPE Function]
|
||||
# #region Test.AgentChat.TestReadToolTimeout [C:2] [TYPE Function]
|
||||
# @BRIEF GIVEN a read tool that exceeds the timeout WHEN _execute_with_timeout is called THEN TimeoutError is raised and logger.explore is invoked.
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_tool_timeout():
|
||||
@@ -56,10 +56,10 @@ async def test_read_tool_timeout():
|
||||
assert payload["timeout_s"] == 0.05
|
||||
assert payload["is_write"] is False
|
||||
assert call_args[1]["extra"]["src"] == "AgentChat.Tools.Timeout"
|
||||
# #endregion test_read_tool_timeout
|
||||
# #endregion Test.AgentChat.TestReadToolTimeout
|
||||
|
||||
|
||||
# #region test_write_tool_timeout [C:2] [TYPE Function]
|
||||
# #region Test.AgentChat.TestWriteToolTimeout [C:2] [TYPE Function]
|
||||
# @BRIEF GIVEN a write tool that exceeds the timeout WHEN _execute_with_timeout is called THEN TimeoutError is raised with is_write=True logged.
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_tool_timeout():
|
||||
@@ -82,6 +82,6 @@ async def test_write_tool_timeout():
|
||||
assert payload["timeout_s"] == 0.05
|
||||
assert payload["is_write"] is True
|
||||
assert call_args[1]["extra"]["src"] == "AgentChat.Tools.Timeout"
|
||||
# #endregion test_write_tool_timeout
|
||||
# #endregion Test.AgentChat.TestWriteToolTimeout
|
||||
|
||||
# #endregion Test.AgentChat.ToolTimeout
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
backend/:memory:test_tasks
Normal file
BIN
backend/:memory:test_tasks
Normal file
Binary file not shown.
@@ -1,11 +1,11 @@
|
||||
# #region AlembicEnvModule [C:3] [TYPE Module] [SEMANTICS alembic, migration, env, logging]
|
||||
# #region Alembic.Env.AlembicEnvModule [C:3] [TYPE Module] [SEMANTICS alembic, migration, env, logging]
|
||||
# @BRIEF Alembic environment configuration — sets up DB connection, model metadata,
|
||||
# and logging. Contains ADR [LOG-001] for suppress_existing_loggers=False.
|
||||
# @LAYER Infrastructure
|
||||
# @RELATION DEPENDS_ON -> [LoggerModule]
|
||||
# @RELATION DEPENDS_ON -> [Core.Logger.LoggerModule]
|
||||
# @INVARIANT fileConfig() must be called with disable_existing_loggers=False
|
||||
# to prevent disabling superset_tools_app logger (see ADR LOG-001).
|
||||
# #endregion AlembicEnvModule
|
||||
# #endregion Alembic.Env.AlembicEnvModule
|
||||
|
||||
from logging.config import fileConfig
|
||||
import os
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# #region Alembic.MergeThreeHeads [C:2] [TYPE Module] [SEMANTICS alembic,merge,heads]
|
||||
# @ingroup Alembic
|
||||
# @BRIEF Merge three migration heads into one: 6b8ca3b7405f, b4c5d6e7f8a9, f4a5b6c7d8e9.
|
||||
# Branches b4c5d6e7f8a9 and f4a5b6c7d8e9 were created from f2b3c4d5e6f7 after the
|
||||
# previous merge (6b8ca3b7405f), creating multiple heads. This merge resolves them.
|
||||
# @LAYER Database
|
||||
# @RELATION DEPENDS_ON -> [Alembic.AddAgentConversations]
|
||||
# @RELATION DEPENDS_ON -> [Alembic.AddIncludeSourceReference]
|
||||
# @RELATION DEPENDS_ON -> [Alembic.AddDeploymentValidation]
|
||||
|
||||
"""merge: 6b8ca3b7405f, b4c5d6e7f8a9, f4a5b6c7d8e9
|
||||
|
||||
Revision ID: 7eaf84b7f6be
|
||||
Revises: 6b8ca3b7405f, b4c5d6e7f8a9, f4a5b6c7d8e9
|
||||
Create Date: 2026-07-14 17:29:58.171927
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '7eaf84b7f6be'
|
||||
down_revision: Union[str, Sequence[str], None] = ('6b8ca3b7405f', 'b4c5d6e7f8a9', 'f4a5b6c7d8e9')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
pass
|
||||
|
||||
# #endregion Alembic.MergeThreeHeads
|
||||
@@ -0,0 +1,75 @@
|
||||
# #region Alembic.Migration.AddSessionActivityTable [C:2] [TYPE Migration] [SEMANTICS alembic,migration,session,activity,auth]
|
||||
# @BRIEF Create session_activity table for idle/absolute session timeout enforcement.
|
||||
# @PRE Previous migration (f7a8b9c0d1e2) has been applied.
|
||||
# @POST session_activity table is created when users exists; otherwise ORM create_all()
|
||||
# creates it after the fresh Alembic upgrade.
|
||||
# @SIDE_EFFECT DDL execution — creates table, index, foreign key constraint.
|
||||
# @RELATION DEPENDS_ON -> [Models.Auth.SessionActivity]
|
||||
# @RELATION DEPENDS_ON -> [Models.Auth.User]
|
||||
# @RATIONALE users is ORM-owned and is created by Base.metadata.create_all() after
|
||||
# Alembic during a fresh install, so the FK migration must be safely skipped first.
|
||||
# @REJECTED Unconditionally creating the FK table was rejected — fresh installs fail
|
||||
# before application startup because users does not yet exist.
|
||||
"""add session_activity table
|
||||
|
||||
Revision ID: 8e9f0a1b2c3d
|
||||
Revises: f7a8b9c0d1e2
|
||||
Create Date: 2026-07-23 07:45:00.000000
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "8e9f0a1b2c3d"
|
||||
down_revision: str | Sequence[str] | None = "f7a8b9c0d1e2"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
# #region Migration.AddSessionActivityTable.TableExists [C:1] [TYPE Function] [SEMANTICS alembic,helper,table]
|
||||
# @BRIEF Check if a table already exists in the database.
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
conn = op.get_bind()
|
||||
inspector = inspect(conn)
|
||||
return table_name in inspector.get_table_names()
|
||||
# #endregion Migration.AddSessionActivityTable.TableExists
|
||||
|
||||
|
||||
# #region Migration.AddSessionActivityTable.Upgrade [C:2] [TYPE Function] [SEMANTICS alembic,upgrade]
|
||||
# @BRIEF Create session_activity table for idle/absolute timeout enforcement.
|
||||
# @PRE auth.users table exists in the database.
|
||||
# @POST session_activity table created with jti PK, user_id FK->users.id, indexes.
|
||||
# @SIDE_EFFECT Executes CREATE TABLE DDL.
|
||||
def upgrade() -> None:
|
||||
"""Create session_activity table for idle/absolute timeout enforcement."""
|
||||
if _table_exists("session_activity") or not _table_exists("users"):
|
||||
return
|
||||
op.create_table(
|
||||
"session_activity",
|
||||
sa.Column("jti", sa.String(), nullable=False),
|
||||
sa.Column("user_id", sa.String(), nullable=False, index=True),
|
||||
sa.Column("issued_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("last_activity_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("ip_address", sa.String(), nullable=True),
|
||||
sa.Column("user_agent", sa.String(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("jti"),
|
||||
)
|
||||
# #endregion Migration.AddSessionActivityTable.Upgrade
|
||||
|
||||
|
||||
# #region Migration.AddSessionActivityTable.Downgrade [C:1] [TYPE Function] [SEMANTICS alembic,downgrade]
|
||||
# @BRIEF Drop session_activity table, reverting the upgrade.
|
||||
# @POST session_activity table dropped.
|
||||
# @SIDE_EFFECT Executes DROP TABLE DDL.
|
||||
def downgrade() -> None:
|
||||
"""Drop session_activity table."""
|
||||
op.drop_table("session_activity")
|
||||
# #endregion Migration.AddSessionActivityTable.Downgrade
|
||||
# #endregion Alembic.Migration.AddSessionActivityTable
|
||||
@@ -0,0 +1,76 @@
|
||||
# #region Alembic.AddAgentLifecycleEvents [C:3] [TYPE Module] [SEMANTICS alembic,agent,lifecycle,audit]
|
||||
# @ingroup Alembic
|
||||
# @BRIEF Add agent_lifecycle_events table for Phase 3 durable agent lifecycle audit.
|
||||
# @LAYER Database
|
||||
# @RELATION DEPENDS_ON -> [Models.Agent.AgentLifecycleEvent]
|
||||
# @INVARIANT table has composite indexes for common query patterns (user+type+created, conv+type+created).
|
||||
# @RATIONALE Immutable, indexed events make trace/conversation diagnostics queryable without
|
||||
# keeping raw prompts or tool output in application logs.
|
||||
# @REJECTED Reusing agent_messages was rejected — message content has a separate retention and
|
||||
# privacy contract and cannot represent request/tool lifecycle boundaries safely.
|
||||
|
||||
"""add agent_lifecycle_events table
|
||||
|
||||
Revision ID: b2a3c4d5e6f7
|
||||
Revises: 7eaf84b7f6be
|
||||
Create Date: 2026-07-15 10:00:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "b2a3c4d5e6f7"
|
||||
down_revision: Union[str, Sequence[str], None] = "7eaf84b7f6be"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"agent_lifecycle_events",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("trace_id", sa.String(), nullable=False, index=True),
|
||||
sa.Column("conversation_id", sa.String(), nullable=False, index=True),
|
||||
sa.Column("user_id", sa.String(), nullable=False, index=True),
|
||||
sa.Column("environment_id", sa.String(), nullable=True, index=True),
|
||||
sa.Column("event_type", sa.String(), nullable=False, index=True),
|
||||
sa.Column("tool_name", sa.String(), nullable=True, index=True),
|
||||
sa.Column("status", sa.String(), nullable=True, index=True),
|
||||
# Store UTC timestamps explicitly; the model normalizes values to UTC
|
||||
# before serialization, independent of the database session timezone.
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("elapsed_ms", sa.Float(), nullable=True),
|
||||
sa.Column("payload", sa.JSON(), nullable=True),
|
||||
sa.Column("error_code", sa.String(), nullable=True, index=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_agent_lifecycle_events_user_type_created",
|
||||
"agent_lifecycle_events",
|
||||
["user_id", "event_type", "created_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_agent_lifecycle_events_conv_type_created",
|
||||
"agent_lifecycle_events",
|
||||
["conversation_id", "event_type", "created_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_agent_lifecycle_events_created",
|
||||
"agent_lifecycle_events",
|
||||
["created_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_agent_lifecycle_events_created", table_name="agent_lifecycle_events")
|
||||
op.drop_index("ix_agent_lifecycle_events_conv_type_created", table_name="agent_lifecycle_events")
|
||||
op.drop_index("ix_agent_lifecycle_events_user_type_created", table_name="agent_lifecycle_events")
|
||||
op.drop_table("agent_lifecycle_events")
|
||||
# #endregion Alembic.AddAgentLifecycleEvents
|
||||
@@ -0,0 +1,50 @@
|
||||
# #region Alembic.TaskRecordsUserId [C:3] [TYPE Module] [SEMANTICS alembic,migration,task,postgres]
|
||||
# @BRIEF Adds nullable task ownership to persistent task records.
|
||||
# @RELATION DEPENDS_ON -> [EXT:SQLAlchemy:Alembic]
|
||||
# @POST Existing task_records rows retain data and gain a nullable user_id column.
|
||||
# @RATIONALE Task ownership must persist so task list queries can be scoped to a user after restart.
|
||||
# @REJECTED Runtime create_all() or manual ALTER TABLE was rejected because schema evolution must
|
||||
# remain reproducible through the Alembic migration chain.
|
||||
"""add user_id column to task_records table
|
||||
|
||||
Revision ID: c3d4e5f6a7b8
|
||||
Revises: b2a3c4d5e6f7
|
||||
Create Date: 2026-07-15 19:06:00.000000
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c3d4e5f6a7b8'
|
||||
down_revision: str | Sequence[str] | None = 'b2a3c4d5e6f7'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
conn = op.get_bind()
|
||||
inspector = inspect(conn)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add user_id column to task_records table."""
|
||||
if not _table_exists("task_records"):
|
||||
return
|
||||
op.add_column(
|
||||
"task_records",
|
||||
sa.Column("user_id", sa.String(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove user_id column from task_records table."""
|
||||
op.drop_column("task_records", "user_id")
|
||||
|
||||
|
||||
# #endregion Alembic.TaskRecordsUserId
|
||||
@@ -0,0 +1,52 @@
|
||||
# #region Alembic.TranslateActiveRunGuard [C:3] [TYPE Module] [SEMANTICS alembic,migration,translate,concurrency]
|
||||
# @BRIEF Enforces at most one pending/running translation run per job.
|
||||
# @RELATION DEPENDS_ON -> [EXT:SQLAlchemy:Alembic]
|
||||
# @POST Concurrent manual/scheduled triggers cannot create duplicate active runs.
|
||||
"""add unique active translation run guard
|
||||
|
||||
Revision ID: d4e5f6a7b8c9
|
||||
Revises: c3d4e5f6a7b8
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "d4e5f6a7b8c9"
|
||||
down_revision: str | Sequence[str] | None = "c3d4e5f6a7b8"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create a partial unique index on supported application databases."""
|
||||
bind = op.get_bind()
|
||||
dialect = bind.dialect.name
|
||||
if dialect == "postgresql":
|
||||
op.create_index(
|
||||
"uq_translation_runs_one_active_per_job",
|
||||
"translation_runs",
|
||||
["job_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("status IN ('PENDING', 'RUNNING')"),
|
||||
)
|
||||
elif dialect == "sqlite":
|
||||
op.create_index(
|
||||
"uq_translation_runs_one_active_per_job",
|
||||
"translation_runs",
|
||||
["job_id"],
|
||||
unique=True,
|
||||
sqlite_where=sa.text("status IN ('PENDING', 'RUNNING')"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop the active-run guard."""
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name in {"postgresql", "sqlite"}:
|
||||
op.drop_index("uq_translation_runs_one_active_per_job", table_name="translation_runs")
|
||||
|
||||
|
||||
# #endregion Alembic.TranslateActiveRunGuard
|
||||
@@ -1,6 +1,11 @@
|
||||
# #region Alembic.AddDeploymentRecords [C:2] [TYPE Function] [SEMANTICS alembic,migration,deployment,versioning]
|
||||
# #region Alembic.AddDeploymentRecords [C:3] [TYPE Module] [SEMANTICS alembic,migration,deployment,versioning]
|
||||
# @BRIEF Add deployment_records table for version tracking (Phase 0).
|
||||
# @RELATION DEPENDS_ON -> [DeploymentModels]
|
||||
# @RELATION DEPENDS_ON -> [Models.Deployment.DeploymentModels]
|
||||
# @POST Creates deployment dependency tables before adding foreign-key-constrained records.
|
||||
# @RATIONALE DeploymentEnvironment and GitRepository were historically created by runtime metadata,
|
||||
# which left a fresh Alembic upgrade without the foreign-key targets required here.
|
||||
# @REJECTED Relying on Base.metadata.create_all() before Alembic was rejected because production
|
||||
# startup must be able to initialize its schema through migrations alone.
|
||||
"""add deployment_records table
|
||||
|
||||
Revision ID: e3a4b5c6d7e8
|
||||
@@ -8,21 +13,56 @@ Revises: f2b3c4d5e6f7
|
||||
Create Date: 2026-07-10 13:30:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSON
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "e3a4b5c6d7e8"
|
||||
down_revision: Union[str, None] = "f2b3c4d5e6f7"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
down_revision: str | None = "f2b3c4d5e6f7"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"git_server_configs",
|
||||
sa.Column("id", sa.String(36), nullable=False),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("provider", sa.String(20), nullable=False),
|
||||
sa.Column("url", sa.String(255), nullable=False),
|
||||
sa.Column("pat", sa.String(255), nullable=False),
|
||||
sa.Column("default_repository", sa.String(255), nullable=True),
|
||||
sa.Column("default_branch", sa.String(255), nullable=True),
|
||||
sa.Column("status", sa.String(20), nullable=True),
|
||||
sa.Column("last_validated", sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_table(
|
||||
"git_repositories",
|
||||
sa.Column("id", sa.String(36), nullable=False),
|
||||
sa.Column("dashboard_id", sa.Integer(), nullable=False),
|
||||
sa.Column("config_id", sa.String(36), nullable=False),
|
||||
sa.Column("remote_url", sa.String(255), nullable=False),
|
||||
sa.Column("local_path", sa.String(255), nullable=False),
|
||||
sa.Column("current_branch", sa.String(255), nullable=True),
|
||||
sa.Column("sync_status", sa.String(20), nullable=True),
|
||||
sa.ForeignKeyConstraint(["config_id"], ["git_server_configs.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("dashboard_id"),
|
||||
)
|
||||
op.create_table(
|
||||
"deployment_environments",
|
||||
sa.Column("id", sa.String(36), nullable=False),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("superset_url", sa.String(255), nullable=False),
|
||||
sa.Column("superset_token", sa.String(255), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_table(
|
||||
"deployment_records",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
@@ -55,3 +95,9 @@ def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_deployment_records_content_hash"), table_name="deployment_records")
|
||||
op.drop_index(op.f("ix_deployment_records_repository_env"), table_name="deployment_records")
|
||||
op.drop_table("deployment_records")
|
||||
op.drop_table("deployment_environments")
|
||||
op.drop_table("git_repositories")
|
||||
op.drop_table("git_server_configs")
|
||||
|
||||
|
||||
# #endregion Alembic.AddDeploymentRecords
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# #region Alembic.AddTranslationRunObservabilityMetrics [C:3] [TYPE Module] [SEMANTICS alembic,translate,metrics]
|
||||
# @defgroup Alembic Persist independent source, translation, and insert metrics for translation runs.
|
||||
|
||||
"""add translation run observability metrics
|
||||
|
||||
Revision ID: e6f7a8b9c0d1
|
||||
Revises: f5e6d7c8b9a0
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "e6f7a8b9c0d1"
|
||||
down_revision: str | Sequence[str] | None = "f5e6d7c8b9a0"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
# #region Alembic.AddTranslationRunObservabilityMetrics.Upgrade [C:3] [TYPE Function] [SEMANTICS alembic,translate,metrics]
|
||||
# @ingroup Alembic
|
||||
# @BRIEF Add nullable run metrics so historical runs remain explicitly unknown.
|
||||
def upgrade() -> None:
|
||||
op.add_column("translation_runs", sa.Column("source_records_read", sa.Integer(), nullable=True))
|
||||
op.add_column("translation_runs", sa.Column("eligible_records", sa.Integer(), nullable=True))
|
||||
op.add_column("translation_runs", sa.Column("translated_records", sa.Integer(), nullable=True))
|
||||
op.add_column("translation_runs", sa.Column("same_language_skipped_records", sa.Integer(), nullable=True))
|
||||
op.add_column("translation_runs", sa.Column("insert_rows_prepared", sa.Integer(), nullable=True))
|
||||
op.add_column("translation_runs", sa.Column("insert_rows_affected", sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
# #endregion Alembic.AddTranslationRunObservabilityMetrics.Upgrade
|
||||
|
||||
# #region Alembic.AddTranslationRunObservabilityMetrics.Downgrade [C:2] [TYPE Function] [SEMANTICS alembic,translate,metrics]
|
||||
# @ingroup Alembic
|
||||
# @BRIEF Remove translation-run observability metrics.
|
||||
def downgrade() -> None:
|
||||
op.drop_column("translation_runs", "insert_rows_affected")
|
||||
op.drop_column("translation_runs", "insert_rows_prepared")
|
||||
op.drop_column("translation_runs", "same_language_skipped_records")
|
||||
op.drop_column("translation_runs", "translated_records")
|
||||
op.drop_column("translation_runs", "eligible_records")
|
||||
op.drop_column("translation_runs", "source_records_read")
|
||||
|
||||
|
||||
# #endregion Alembic.AddTranslationRunObservabilityMetrics.Downgrade
|
||||
# #endregion Alembic.AddTranslationRunObservabilityMetrics
|
||||
@@ -0,0 +1,63 @@
|
||||
# #region Alembic.AddDashboardReleases [C:3] [TYPE Module] [SEMANTICS alembic,git,release]
|
||||
# @defgroup Alembic Persist dashboard release records and repository policy overrides.
|
||||
|
||||
"""add dashboard releases
|
||||
|
||||
Revision ID: f5e6d7c8b9a0
|
||||
Revises: d4e5f6a7b8c9
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "f5e6d7c8b9a0"
|
||||
down_revision: str | Sequence[str] | None = "d4e5f6a7b8c9"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
# #region Alembic.AddDashboardReleases.Upgrade [C:3] [TYPE Function] [SEMANTICS alembic,git,release]
|
||||
# @ingroup Alembic
|
||||
# @BRIEF Add release ledger and optional per-repository policy JSON.
|
||||
def upgrade() -> None:
|
||||
op.add_column("git_repositories", sa.Column("release_policy", sa.JSON(), nullable=True))
|
||||
op.create_table(
|
||||
"dashboard_releases",
|
||||
sa.Column("id", sa.String(length=36), primary_key=True),
|
||||
sa.Column("repository_id", sa.String(length=36), sa.ForeignKey("git_repositories.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("deployment_id", sa.Integer(), sa.ForeignKey("deployment_records.id", ondelete="RESTRICT"), nullable=False, unique=True),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("version", sa.String(length=100), nullable=False),
|
||||
sa.Column("notes", sa.Text(), nullable=False),
|
||||
sa.Column("commit_hash", sa.String(length=40), nullable=False),
|
||||
sa.Column("content_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("created_by", sa.String(length=255), nullable=False),
|
||||
sa.Column("approved_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("approved_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("approval_comment", sa.Text(), nullable=True),
|
||||
sa.Column("published_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("published_by", sa.String(length=255), nullable=True),
|
||||
sa.UniqueConstraint("repository_id", "version", name="uq_dashboard_release_repository_version"),
|
||||
)
|
||||
op.create_index("ix_dashboard_releases_repository_id", "dashboard_releases", ["repository_id"])
|
||||
|
||||
|
||||
# #endregion Alembic.AddDashboardReleases.Upgrade
|
||||
|
||||
# #region Alembic.AddDashboardReleases.Downgrade [C:2] [TYPE Function] [SEMANTICS alembic,git,release]
|
||||
# @ingroup Alembic
|
||||
# @BRIEF Remove dashboard release persistence.
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_dashboard_releases_repository_id", table_name="dashboard_releases")
|
||||
op.drop_table("dashboard_releases")
|
||||
op.drop_column("git_repositories", "release_policy")
|
||||
|
||||
|
||||
# #endregion Alembic.AddDashboardReleases.Downgrade
|
||||
|
||||
# #endregion Alembic.AddDashboardReleases
|
||||
@@ -0,0 +1,184 @@
|
||||
# #region Alembic.AddTranslatePerformanceKnobs [C:3] [TYPE Module] [SEMANTICS alembic,translate,performance]
|
||||
# @defgroup Alembic Persist translation performance knobs (job policy + provider capabilities).
|
||||
"""Add translation performance knobs (job + provider capabilities).
|
||||
|
||||
Revision ID: f7a8b9c0d1e2
|
||||
Revises: e6f7a8b9c0d1
|
||||
Create Date: 2026-07-20 10:30:00.000000
|
||||
|
||||
NULL defaults preserve legacy algorithm behaviour (serial LLM, auto hard caps).
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "f7a8b9c0d1e2"
|
||||
down_revision: str | Sequence[str] | None = "e6f7a8b9c0d1"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
# #region Alembic.AddTranslatePerformanceKnobs.AddColIfMissing [C:2] [TYPE Function] [SEMANTICS alembic,translate,idempotent]
|
||||
# @ingroup Alembic
|
||||
# @BRIEF Add a column only when absent — keeps the migration re-runnable.
|
||||
def _add_col_if_missing(table: str, column: sa.Column) -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table):
|
||||
return
|
||||
existing = {c["name"] for c in inspector.get_columns(table)}
|
||||
if column.name in existing:
|
||||
return
|
||||
op.add_column(table, column)
|
||||
# #endregion Alembic.AddTranslatePerformanceKnobs.AddColIfMissing
|
||||
|
||||
|
||||
# #region Alembic.AddTranslatePerformanceKnobs.Upgrade [C:3] [TYPE Function] [SEMANTICS alembic,translate,performance]
|
||||
# @ingroup Alembic
|
||||
# @BRIEF Add nullable performance knobs to translation_jobs and llm_providers.
|
||||
# @RATIONALE NULL defaults preserve legacy algorithm behaviour (serial LLM, auto hard caps);
|
||||
# capabilities are stored in DB, not inferred from brand/host at runtime.
|
||||
# @RATIONALE llm_providers is an ORM-owned table created by Base.metadata.create_all()
|
||||
# after Alembic on a fresh install; absent tables must therefore be skipped here.
|
||||
# @REJECTED Non-nullable columns with server defaults — would silently flip legacy jobs to new behaviour.
|
||||
def upgrade() -> None:
|
||||
# ── translation_jobs (job policy / performance) ────────────────────────
|
||||
_add_col_if_missing(
|
||||
"translation_jobs",
|
||||
sa.Column(
|
||||
"llm_batch_max_rows",
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
comment="Max source rows per LLM batch (NULL = algorithm default)",
|
||||
),
|
||||
)
|
||||
_add_col_if_missing(
|
||||
"translation_jobs",
|
||||
sa.Column(
|
||||
"llm_concurrency",
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
comment="Parallel LLM batch workers (NULL = 1, legacy serial)",
|
||||
),
|
||||
)
|
||||
_add_col_if_missing(
|
||||
"translation_jobs",
|
||||
sa.Column(
|
||||
"insert_concurrency",
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
comment="Parallel insert workers (NULL = 1)",
|
||||
),
|
||||
)
|
||||
_add_col_if_missing(
|
||||
"translation_jobs",
|
||||
sa.Column(
|
||||
"multi_lang_mode",
|
||||
sa.String(),
|
||||
nullable=True,
|
||||
comment="single_call | per_language (NULL = single_call)",
|
||||
),
|
||||
)
|
||||
_add_col_if_missing(
|
||||
"translation_jobs",
|
||||
sa.Column(
|
||||
"batch_aggressiveness",
|
||||
sa.String(),
|
||||
nullable=True,
|
||||
comment="safe | balanced | fast (NULL = balanced legacy constants)",
|
||||
),
|
||||
)
|
||||
_add_col_if_missing(
|
||||
"translation_jobs",
|
||||
sa.Column(
|
||||
"max_in_flight_batches",
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
comment="Backpressure queue depth for parallel results (NULL = 32)",
|
||||
),
|
||||
)
|
||||
|
||||
# ── llm_providers (capabilities, not brand heuristics) ─────────────────
|
||||
_add_col_if_missing(
|
||||
"llm_providers",
|
||||
sa.Column(
|
||||
"throughput_class",
|
||||
sa.String(),
|
||||
nullable=True,
|
||||
comment="standard | local (NULL = derive later via capability, not host sniff at runtime)",
|
||||
),
|
||||
)
|
||||
_add_col_if_missing(
|
||||
"llm_providers",
|
||||
sa.Column(
|
||||
"reasoning_control",
|
||||
sa.String(),
|
||||
nullable=True,
|
||||
comment="off|generic_none|openai_effort|deepseek_thinking|llamacpp_think|auto",
|
||||
),
|
||||
)
|
||||
_add_col_if_missing(
|
||||
"llm_providers",
|
||||
sa.Column(
|
||||
"supports_json_object",
|
||||
sa.Boolean(),
|
||||
nullable=True,
|
||||
comment="If true, send response_format=json_object (NULL = true for openai-compatible)",
|
||||
),
|
||||
)
|
||||
_add_col_if_missing(
|
||||
"llm_providers",
|
||||
sa.Column(
|
||||
"default_llm_concurrency",
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
comment="Default job llm_concurrency when job field is NULL",
|
||||
),
|
||||
)
|
||||
_add_col_if_missing(
|
||||
"llm_providers",
|
||||
sa.Column(
|
||||
"max_llm_concurrency",
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
comment="Hard ceiling for job llm_concurrency",
|
||||
),
|
||||
)
|
||||
# #endregion Alembic.AddTranslatePerformanceKnobs.Upgrade
|
||||
|
||||
|
||||
# #region Alembic.AddTranslatePerformanceKnobs.Downgrade [C:2] [TYPE Function] [SEMANTICS alembic,translate,performance]
|
||||
# @ingroup Alembic
|
||||
# @BRIEF Drop performance knobs conditionally (idempotent).
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
job_cols = {c["name"] for c in inspector.get_columns("translation_jobs")}
|
||||
for name in (
|
||||
"max_in_flight_batches",
|
||||
"batch_aggressiveness",
|
||||
"multi_lang_mode",
|
||||
"insert_concurrency",
|
||||
"llm_concurrency",
|
||||
"llm_batch_max_rows",
|
||||
):
|
||||
if name in job_cols:
|
||||
op.drop_column("translation_jobs", name)
|
||||
|
||||
if not inspector.has_table("llm_providers"):
|
||||
return
|
||||
prov_cols = {c["name"] for c in inspector.get_columns("llm_providers")}
|
||||
for name in (
|
||||
"max_llm_concurrency",
|
||||
"default_llm_concurrency",
|
||||
"supports_json_object",
|
||||
"reasoning_control",
|
||||
"throughput_class",
|
||||
):
|
||||
if name in prov_cols:
|
||||
op.drop_column("llm_providers", name)
|
||||
# #endregion Alembic.AddTranslatePerformanceKnobs.Downgrade
|
||||
# #endregion Alembic.AddTranslatePerformanceKnobs
|
||||
@@ -1,4 +1,4 @@
|
||||
# #region SrcRoot [TYPE Module] [SEMANTICS root, package]
|
||||
# #region Init.SrcRoot [TYPE Module] [SEMANTICS root, package]
|
||||
# @defgroup Module Module group.
|
||||
# @BRIEF Canonical backend package root for application, scripts, and tests.
|
||||
# #endregion SrcRoot
|
||||
# #endregion Init.SrcRoot
|
||||
|
||||
@@ -7,21 +7,24 @@
|
||||
# @DATA_CONTRACT OAuth2PasswordRequestForm -> Token | User
|
||||
# @INVARIANT All auth endpoints return consistent error codes (401/403/422).
|
||||
# @RELATION DEPENDS_ON -> [Auth.Jwt]
|
||||
# @RELATION DEPENDS_ON -> [auth_service]
|
||||
# @RELATION DEPENDS_ON -> [AuthOauthModule]
|
||||
# @RELATION DEPENDS_ON -> [Services.Auth.Service]
|
||||
# @RELATION DEPENDS_ON -> [Core.Oauth.AuthOauthModule]
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
import starlette.requests
|
||||
|
||||
from ..core.auth.jwt import blacklist_token
|
||||
from ..core.auth.jwt import blacklist_token, decode_token
|
||||
from ..core.auth.logger import log_security_event
|
||||
from ..core.auth.oauth import is_adfs_configured, oauth
|
||||
from ..core.database import get_auth_db
|
||||
from ..core.logger import belief_scope, logger
|
||||
from ..core.rate_limiter import rate_limiter
|
||||
from ..dependencies import get_current_user
|
||||
from ..dependencies import get_current_user, oauth2_scheme
|
||||
from ..schemas.auth import Token, User as UserSchema
|
||||
from ..services.auth_service import AuthService
|
||||
|
||||
@@ -39,7 +42,7 @@ router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
# @POST Returns Token(access_token, token_type) on success; 401 on failure.
|
||||
# @SIDE_EFFECT DB read for user verification; writes security event LOGIN.
|
||||
# @SIDE_EFFECT Molecular CoT: REASON on entry, REFLECT on success, EXPLORE on failure.
|
||||
# @RELATION CALLS -> [auth_service]
|
||||
# @RELATION CALLS -> [Services.Auth.Service]
|
||||
# @TEST_EDGE: invalid_credentials -> 401
|
||||
# @TEST_EDGE: locked_account -> 423
|
||||
# @TEST_EDGE: missing_fields -> 422
|
||||
@@ -87,7 +90,7 @@ async def login_for_access_token(
|
||||
# @PRE Valid JWT token in Authorization header.
|
||||
# @POST Returns UserSchema with id, username, email, roles.
|
||||
# @SIDE_EFFECT Reads current user from DB via auth middleware.
|
||||
# @RELATION DEPENDS_ON -> [get_current_user]
|
||||
# @RELATION DEPENDS_ON -> [Dependencies.AppDependencies.GetCurrentUser]
|
||||
|
||||
@router.get("/me", response_model=UserSchema)
|
||||
async def read_users_me(current_user: UserSchema = Depends(get_current_user)):
|
||||
@@ -98,6 +101,86 @@ async def read_users_me(current_user: UserSchema = Depends(get_current_user)):
|
||||
# #endregion Api.Auth.Me
|
||||
|
||||
|
||||
# #region Api.Auth.SessionPolicy [C:3] [TYPE Function] [SEMANTICS api,auth,session,timeout]
|
||||
# @ingroup Auth
|
||||
# @BRIEF Return session timeout policy and current session deadlines for the authenticated user.
|
||||
# @PRE Valid JWT token in Authorization header.
|
||||
# @POST Returns SessionPolicyResponse with idle/absolute expiry deadlines and warning window.
|
||||
# @SIDE_EFFECT Reads SessionActivity for the current JWT; reads GlobalSettings for timeout policy.
|
||||
# @RELATION DEPENDS_ON -> [Dependencies.AppDependencies.GetCurrentUser]
|
||||
# @RELATION DEPENDS_ON -> [Auth.Jwt.DecodeToken]
|
||||
# @RELATION DEPENDS_ON -> [Models.Auth.SessionActivity]
|
||||
|
||||
class SessionPolicyResponse(BaseModel):
|
||||
idle_expires_at: str | None = None
|
||||
absolute_expires_at: str | None = None
|
||||
warning_seconds: int = 300
|
||||
error_code: str = "SESSION_ACTIVE"
|
||||
|
||||
|
||||
@router.get("/session", response_model=SessionPolicyResponse)
|
||||
async def get_session_policy(
|
||||
current_user: UserSchema = Depends(get_current_user),
|
||||
db: Session = Depends(get_auth_db),
|
||||
token: str = Depends(oauth2_scheme),
|
||||
):
|
||||
with belief_scope("api.auth.session"):
|
||||
# Decode JWT to get jti, iat
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
jti = payload.get("jti")
|
||||
if not jti:
|
||||
return SessionPolicyResponse()
|
||||
|
||||
# Look up SessionActivity for this jti
|
||||
from ..models.auth import SessionActivity
|
||||
|
||||
activity = db.query(SessionActivity).filter(SessionActivity.jti == jti).first()
|
||||
|
||||
# Read session policy from GlobalSettings (via ConfigManager cache)
|
||||
from ..dependencies import get_config_manager
|
||||
|
||||
config_mgr = get_config_manager()
|
||||
settings = config_mgr.get_config().settings
|
||||
|
||||
idle_timeout = settings.session_idle_timeout_minutes
|
||||
abs_timeout = settings.session_absolute_timeout_minutes
|
||||
warning_seconds = settings.session_warning_minutes * 60
|
||||
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Compute idle expiry
|
||||
idle_expires_at: str | None = None
|
||||
if idle_timeout > 0 and activity:
|
||||
idle_deadline = activity.last_activity_at.replace(tzinfo=UTC) if activity.last_activity_at.tzinfo is None else activity.last_activity_at
|
||||
idle_deadline = idle_deadline + timedelta(minutes=idle_timeout)
|
||||
idle_expires_at = idle_deadline.isoformat()
|
||||
|
||||
# Compute absolute expiry
|
||||
absolute_expires_at: str | None = None
|
||||
if abs_timeout > 0 and activity:
|
||||
abs_deadline = activity.issued_at.replace(tzinfo=UTC) if activity.issued_at.tzinfo is None else activity.issued_at
|
||||
abs_deadline = abs_deadline + timedelta(minutes=abs_timeout)
|
||||
absolute_expires_at = abs_deadline.isoformat()
|
||||
|
||||
return SessionPolicyResponse(
|
||||
idle_expires_at=idle_expires_at,
|
||||
absolute_expires_at=absolute_expires_at,
|
||||
warning_seconds=warning_seconds,
|
||||
error_code="SESSION_ACTIVE",
|
||||
)
|
||||
|
||||
|
||||
# #endregion Api.Auth.SessionPolicy
|
||||
|
||||
|
||||
# #region Api.Auth.Logout [C:4] [TYPE Function] [SEMANTICS api,auth,logout,revoke]
|
||||
# @ingroup Auth
|
||||
# @BRIEF Log out current user — blacklists the JWT token server-side.
|
||||
@@ -105,7 +188,7 @@ async def read_users_me(current_user: UserSchema = Depends(get_current_user)):
|
||||
# @POST Token added to blacklist; subsequent requests with same token rejected.
|
||||
# @SIDE_EFFECT Writes security event LOGOUT; writes to token_blacklist table.
|
||||
# @SIDE_EFFECT Molecular CoT: REASON/REFLECT/EXPLORE markers.
|
||||
# @RELATION DEPENDS_ON -> [get_current_user]
|
||||
# @RELATION DEPENDS_ON -> [Dependencies.AppDependencies.GetCurrentUser]
|
||||
# @RELATION CALLS -> [Auth.Jwt.BlacklistToken]
|
||||
# @TEST_EDGE: already_expired_token -> 200 (idempotent)
|
||||
|
||||
@@ -139,7 +222,7 @@ async def logout(
|
||||
# @BRIEF Initiate ADFS OIDC login flow — redirects user to identity provider.
|
||||
# @POST Redirects user to ADFS authorization endpoint.
|
||||
# @SIDE_EFFECT Redirects browser to external OIDC provider.
|
||||
# @RELATION CALLS -> [AuthOauthModule]
|
||||
# @RELATION CALLS -> [Core.Oauth.AuthOauthModule]
|
||||
|
||||
@router.get("/login/adfs")
|
||||
async def login_adfs(request: starlette.requests.Request):
|
||||
@@ -162,7 +245,7 @@ async def login_adfs(request: starlette.requests.Request):
|
||||
# @POST Provisions user in DB (JIT), creates auth session.
|
||||
# @SIDE_EFFECT DB write for user provisioning; writes security event LOGIN_ADFS.
|
||||
# @SIDE_EFFECT Molecular CoT: REASON/REFLECT/EXPLORE markers.
|
||||
# @RELATION CALLS -> [auth_service]
|
||||
# @RELATION CALLS -> [Services.Auth.Service]
|
||||
# @TEST_EDGE: adfs_timeout -> 504
|
||||
# @TEST_EDGE: invalid_state -> 401
|
||||
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
# #region ApiRoutesModule [C:5] [TYPE Module] [SEMANTICS api, package, router, lazy, import]
|
||||
# #region Api.Init.ApiRoutesModule [C:5] [TYPE Module] [SEMANTICS api, package, router, lazy, import]
|
||||
# @defgroup Api Module group.
|
||||
# @BRIEF Provide lazy route module loading to avoid heavyweight imports during tests.
|
||||
# @LAYER API
|
||||
# @RELATION CALLS -> [ApiRoutesGetAttr]
|
||||
# @RELATION BINDS_TO -> [Route_Group_Contracts]
|
||||
# @RELATION CALLS -> [Api.Init.ApiRoutesGetAttr]
|
||||
# @RELATION BINDS_TO -> [Api.Init.RouteGroupContracts]
|
||||
# @PRE FastAPI app initialized, route modules available in package
|
||||
# @POST Route modules are lazily loadable via __getattr__
|
||||
# @INVARIANT Only names listed in __all__ are importable via __getattr__.
|
||||
|
||||
# #region Route_Group_Contracts [C:3] [TYPE Block]
|
||||
# #region Api.Init.RouteGroupContracts [C:3] [TYPE Block]
|
||||
# @ingroup Api
|
||||
# @BRIEF Declare the canonical route-module registry used by lazy imports and app router inclusion.
|
||||
# @RELATION DEPENDS_ON -> [PluginsRouter]
|
||||
# @RELATION DEPENDS_ON -> [TasksRouter]
|
||||
# @RELATION DEPENDS_ON -> [SettingsRouter]
|
||||
# @RELATION DEPENDS_ON -> [ReportsRouter]
|
||||
# @RELATION DEPENDS_ON -> [LlmRoutes]
|
||||
# @RELATION DEPENDS_ON -> [Api.Plugins.PluginsRouter]
|
||||
# @RELATION DEPENDS_ON -> [Api.Tasks.TasksRouter]
|
||||
# @RELATION DEPENDS_ON -> [Api.Settings.SettingsRouter]
|
||||
# @RELATION DEPENDS_ON -> [Api.Reports.ReportsRouter]
|
||||
# @RELATION DEPENDS_ON -> [Api.Llm.LlmRoutes]
|
||||
# @SIDE_EFFECT Registers route group imports via __getattr__
|
||||
# @DATA_CONTRACT Package -> RouterModule mapping
|
||||
__all__ = [
|
||||
@@ -47,13 +47,13 @@ __all__ = [
|
||||
"translate",
|
||||
"validation_tasks",
|
||||
]
|
||||
# #endregion Route_Group_Contracts
|
||||
# #endregion Api.Init.RouteGroupContracts
|
||||
|
||||
|
||||
# #region ApiRoutesGetAttr [C:3] [TYPE Function]
|
||||
# #region Api.Init.ApiRoutesGetAttr [C:3] [TYPE Function]
|
||||
# @ingroup Api
|
||||
# @BRIEF Lazily import route module by attribute name.
|
||||
# @RELATION DEPENDS_ON -> [ApiRoutesModule]
|
||||
# @RELATION DEPENDS_ON -> [Api.Init.ApiRoutesModule]
|
||||
# @PRE name is module candidate exposed in __all__.
|
||||
# @POST Returns imported submodule or raises AttributeError.
|
||||
def __getattr__(name):
|
||||
@@ -64,5 +64,5 @@ def __getattr__(name):
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
# #endregion ApiRoutesGetAttr
|
||||
# #endregion ApiRoutesModule
|
||||
# #endregion Api.Init.ApiRoutesGetAttr
|
||||
# #endregion Api.Init.ApiRoutesModule
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# #region RoutesTestsConftest [TYPE Module] [C:1] [SEMANTICS test, fixture, mock, conftest]
|
||||
# #region Api.Conftest.RoutesTestsConftest [TYPE Module] [C:1] [SEMANTICS test, fixture, mock, conftest]
|
||||
# @BRIEF Shared low-fidelity test doubles for API route test modules.
|
||||
|
||||
# Set required env vars before any app imports
|
||||
@@ -37,4 +37,4 @@ class FakeQuery:
|
||||
return list(self._rows)
|
||||
def count(self):
|
||||
return len(self._rows)
|
||||
# #endregion RoutesTestsConftest
|
||||
# #endregion Api.Conftest.RoutesTestsConftest
|
||||
|
||||
366
backend/src/api/routes/__tests__/test_agent_lifecycle.py
Normal file
366
backend/src/api/routes/__tests__/test_agent_lifecycle.py
Normal file
@@ -0,0 +1,366 @@
|
||||
# backend/src/api/routes/__tests__/test_agent_lifecycle.py
|
||||
# #region Test.Api.AgentLifecycle [C:3] [TYPE Module] [SEMANTICS test,agent,lifecycle,api,crud]
|
||||
# @BRIEF Integration tests for agent lifecycle event API (write + read, user-scoped).
|
||||
# @RELATION BINDS_TO -> [Api.AgentLifecycle]
|
||||
# @RELATION BINDS_TO -> [Services.AgentLifecycleService]
|
||||
# @RELATION BINDS_TO -> [Schemas.AgentLifecycle]
|
||||
# @INVARIANT Non-admin users see only their own events.
|
||||
# @INVARIANT Payload is validated against SAFE_PAYLOAD_KEYS — sensitive fields stripped.
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.api.routes.agent_lifecycle import router
|
||||
from src.dependencies import get_current_user
|
||||
from src.models.auth import User, Role
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def _mock_user(user_id: str = "user-1", is_admin: bool = False) -> User:
|
||||
"""Factory for a mock User with configurable admin status."""
|
||||
role = MagicMock(spec=Role)
|
||||
role.name = "Admin" if is_admin else "Viewer"
|
||||
role.is_admin = is_admin
|
||||
role.permissions = []
|
||||
user = MagicMock(spec=User)
|
||||
user.id = user_id
|
||||
user.username = user_id
|
||||
user.roles = [role]
|
||||
return user
|
||||
|
||||
|
||||
def _build_app_and_db():
|
||||
"""Build a FastAPI test app with a fresh SQLite temp DB and return (TestClient, session_factory)."""
|
||||
import os
|
||||
import tempfile
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
from src.models.mapping import Base
|
||||
from src.core.database import get_db
|
||||
|
||||
# Use a temp file to avoid SQLite :memory: per-connection isolation issues
|
||||
tmp_dir = tempfile.mkdtemp(prefix="test_lifecycle_")
|
||||
db_path = os.path.join(tmp_dir, "test.db")
|
||||
db_url = f"sqlite:///{db_path}"
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
|
||||
engine = create_engine(db_url, connect_args={"check_same_thread": False}, poolclass=NullPool)
|
||||
|
||||
# Import all agent models explicitly to register with Base.metadata
|
||||
import src.models.agent # noqa: F401
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
TestingSessionLocal = sessionmaker(bind=engine, autoflush=False)
|
||||
|
||||
def _override_db():
|
||||
db = TestingSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db
|
||||
|
||||
return TestClient(app), TestingSessionLocal
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client():
|
||||
"""TestClient with admin user and real SQLite in-memory DB."""
|
||||
tc, session_factory = _build_app_and_db()
|
||||
mock_user = _mock_user("admin-1", is_admin=True)
|
||||
tc.app.dependency_overrides[get_current_user] = lambda: mock_user
|
||||
return tc, session_factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_client():
|
||||
"""TestClient with regular (non-admin) user and real SQLite in-memory DB."""
|
||||
tc, _ = _build_app_and_db()
|
||||
mock_user = _mock_user("regular-user-1", is_admin=False)
|
||||
tc.app.dependency_overrides[get_current_user] = lambda: mock_user
|
||||
return tc
|
||||
|
||||
|
||||
# #region Test.Api.TestLifecycleEventWriteSuccess [C:2] [TYPE Function] [SEMANTICS test,lifecycle,write]
|
||||
# @BRIEF POST /api/agent/events creates an event and returns 201 with event ID.
|
||||
def test_lifecycle_event_write_success(admin_client):
|
||||
"""POST /api/agent/events with valid body returns 201 and event ID."""
|
||||
tc, _ = admin_client
|
||||
response = tc.post("/api/agent/events", json={
|
||||
"trace_id": "trace-abc-123",
|
||||
"conversation_id": "conv-456",
|
||||
"event_type": "AGENT_REQUEST_STARTED",
|
||||
"environment_id": "env-prod",
|
||||
"tool_name": None,
|
||||
"status": None,
|
||||
"elapsed_ms": None,
|
||||
"payload": {"action": "send_message", "attempt": 1},
|
||||
"error_code": None,
|
||||
})
|
||||
assert response.status_code == 201, response.text
|
||||
data = response.json()
|
||||
assert data["written"] is True
|
||||
assert len(data["id"]) > 0
|
||||
# #endregion Test.Api.TestLifecycleEventWriteSuccess
|
||||
|
||||
|
||||
# #region Test.Api.TestLifecycleEventWriteReducesPayload [C:2] [TYPE Function] [SEMANTICS test,lifecycle,write,payload]
|
||||
# @BRIEF POST /api/agent/events strips sensitive fields from payload.
|
||||
def test_lifecycle_event_write_reduces_payload(admin_client):
|
||||
"""Sensitive fields in payload are stripped before storage."""
|
||||
tc, session_factory = admin_client
|
||||
response = tc.post("/api/agent/events", json={
|
||||
"trace_id": "trace-sens-1",
|
||||
"conversation_id": "conv-sens-1",
|
||||
"event_type": "AGENT_TOOL_STARTED",
|
||||
"payload": {
|
||||
"action": "deploy",
|
||||
"tool_input": "should-be-stripped",
|
||||
"password": "secret123",
|
||||
"token": "bearer-xxx",
|
||||
},
|
||||
})
|
||||
assert response.status_code == 201, response.text
|
||||
data = response.json()
|
||||
|
||||
# Verify payload in DB is reduced
|
||||
from src.models.agent import AgentLifecycleEvent
|
||||
session = session_factory()
|
||||
try:
|
||||
event = session.query(AgentLifecycleEvent).filter_by(id=data["id"]).first()
|
||||
assert event is not None
|
||||
assert event.payload is not None
|
||||
assert "action" in event.payload
|
||||
assert "tool_input" not in event.payload
|
||||
assert "password" not in event.payload
|
||||
assert "token" not in event.payload
|
||||
finally:
|
||||
session.close()
|
||||
# #endregion Test.Api.TestLifecycleEventWriteReducesPayload
|
||||
|
||||
|
||||
# #region Test.Api.TestLifecycleFailurePersistsSafeProviderDiagnostics [C:2] [TYPE Function] [SEMANTICS test,lifecycle,llm,diagnostics]
|
||||
# @BRIEF LLM failures retain provider identity and failure classification while removing credentials.
|
||||
def test_lifecycle_failure_persists_safe_provider_diagnostics(admin_client):
|
||||
tc, session_factory = admin_client
|
||||
response = tc.post("/api/agent/events", json={
|
||||
"trace_id": "trace-llm-failure",
|
||||
"conversation_id": "conv-llm-failure",
|
||||
"event_type": "AGENT_LLM_FAILED",
|
||||
"error_code": "LLM_PROVIDER_UNAVAILABLE",
|
||||
"payload": {
|
||||
"provider_id": "provider-1",
|
||||
"provider_host": "lite.ai.rusal.com",
|
||||
"model": "qwen-flash",
|
||||
"failure_class": "dns",
|
||||
"api_key": "must-be-stripped",
|
||||
},
|
||||
})
|
||||
assert response.status_code == 201, response.text
|
||||
|
||||
from src.models.agent import AgentLifecycleEvent
|
||||
session = session_factory()
|
||||
try:
|
||||
event = session.query(AgentLifecycleEvent).filter_by(id=response.json()["id"]).one()
|
||||
assert event.error_code == "LLM_PROVIDER_UNAVAILABLE"
|
||||
assert event.payload == {
|
||||
"provider_id": "provider-1",
|
||||
"provider_host": "lite.ai.rusal.com",
|
||||
"model": "qwen-flash",
|
||||
"failure_class": "dns",
|
||||
}
|
||||
finally:
|
||||
session.close()
|
||||
# #endregion Test.Api.TestLifecycleFailurePersistsSafeProviderDiagnostics
|
||||
|
||||
|
||||
# #region Test.Api.TestLifecycleEventListUserScoped [C:2] [TYPE Function] [SEMANTICS test,lifecycle,list,scope]
|
||||
# @BRIEF GET /api/agent/events — non-admin user sees only own events.
|
||||
def test_lifecycle_event_list_user_scoped(user_client, admin_client):
|
||||
"""Non-admin user can list their own events."""
|
||||
tc, session_factory = admin_client
|
||||
|
||||
# Write event as admin (user_id = admin-1)
|
||||
tc.post("/api/agent/events", json={
|
||||
"trace_id": "trace-admin",
|
||||
"conversation_id": "conv-admin",
|
||||
"event_type": "AGENT_REQUEST_STARTED",
|
||||
"payload": {"action": "admin_action"},
|
||||
})
|
||||
|
||||
# Regular user lists events
|
||||
u_tc = user_client
|
||||
response = u_tc.get("/api/agent/events?page=1&page_size=50")
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()
|
||||
# Regular user should see 0 events (none belong to them)
|
||||
assert data["total"] == 0
|
||||
assert data["items"] == []
|
||||
# #endregion Test.Api.TestLifecycleEventListUserScoped
|
||||
|
||||
|
||||
# #region Test.Api.TestLifecycleEventListAdminCrossUser [C:2] [TYPE Function] [SEMANTICS test,lifecycle,list,admin]
|
||||
# @BRIEF GET /api/agent/events — admin user can query by user_id.
|
||||
def test_lifecycle_event_list_admin_cross_user(admin_client):
|
||||
"""Admin user can query events by user_id filter."""
|
||||
tc, session_factory = admin_client
|
||||
|
||||
# Write two events as different users
|
||||
from src.services.agent_lifecycle_service import write_event
|
||||
from src.schemas.agent_lifecycle import EventWriteRequest
|
||||
from src.core.database import get_db
|
||||
|
||||
# Create events by directly using service+DB
|
||||
session = session_factory()
|
||||
try:
|
||||
write_event(session, EventWriteRequest(
|
||||
trace_id="trace-1", conversation_id="conv-1", event_type="AGENT_REQUEST_STARTED",
|
||||
), authenticated_user_id="user-a")
|
||||
write_event(session, EventWriteRequest(
|
||||
trace_id="trace-2", conversation_id="conv-2", event_type="AGENT_LLM_STARTED",
|
||||
), authenticated_user_id="user-b")
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# Admin lists all events
|
||||
response = tc.get("/api/agent/events?page=1&page_size=50")
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()
|
||||
assert data["total"] == 2
|
||||
|
||||
# Admin filters by user_id
|
||||
response = tc.get("/api/agent/events?user_id=user-a")
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["trace_id"] == "trace-1"
|
||||
|
||||
# Admin filters by event_type
|
||||
response = tc.get("/api/agent/events?event_type=AGENT_LLM_STARTED")
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["trace_id"] == "trace-2"
|
||||
# #endregion Test.Api.TestLifecycleEventListAdminCrossUser
|
||||
|
||||
|
||||
# #region Test.Api.TestLifecycleEventNonAdminCannotQueryOthers [C:2] [TYPE Function] [SEMANTICS test,lifecycle,list,forbidden]
|
||||
# @BRIEF Non-admin user receives 403 when trying to filter by user_id.
|
||||
def test_lifecycle_event_non_admin_cannot_query_others(user_client):
|
||||
"""Non-admin user gets 403 for user_id filter."""
|
||||
tc = user_client
|
||||
response = tc.get("/api/agent/events?user_id=other-user")
|
||||
assert response.status_code == 403, response.text
|
||||
assert "Only admin users" in response.text
|
||||
# #endregion Test.Api.TestLifecycleEventNonAdminCannotQueryOthers
|
||||
|
||||
|
||||
# #region Test.Api.TestLifecycleEventListPagination [C:2] [TYPE Function] [SEMANTICS test,lifecycle,list,pagination]
|
||||
# @BRIEF GET /api/agent/events returns paginated results with has_next.
|
||||
def test_lifecycle_event_list_pagination(admin_client):
|
||||
"""Pagination: page_size respected, has_next computed correctly."""
|
||||
tc, session_factory = admin_client
|
||||
from src.services.agent_lifecycle_service import write_event
|
||||
from src.schemas.agent_lifecycle import EventWriteRequest
|
||||
|
||||
session = session_factory()
|
||||
try:
|
||||
for i in range(5):
|
||||
write_event(session, EventWriteRequest(
|
||||
trace_id=f"trace-{i}", conversation_id=f"conv-{i}",
|
||||
event_type="AGENT_REQUEST_STARTED",
|
||||
), authenticated_user_id="admin-1")
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# Page 1 with page_size=3
|
||||
response = tc.get("/api/agent/events?page=1&page_size=3")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 3
|
||||
assert data["total"] == 5
|
||||
assert data["has_next"] is True
|
||||
assert data["page"] == 1
|
||||
|
||||
# Page 2 with page_size=3
|
||||
response = tc.get("/api/agent/events?page=2&page_size=3")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 2
|
||||
assert data["has_next"] is False
|
||||
# #endregion Test.Api.TestLifecycleEventListPagination
|
||||
|
||||
|
||||
# #region Test.Api.TestLifecycleEventWriteRequiresAuth [C:2] [TYPE Function] [SEMANTICS test,lifecycle,write,auth]
|
||||
# @BRIEF POST /api/agent/events requires valid authentication token.
|
||||
def test_lifecycle_event_write_requires_auth():
|
||||
"""POST without auth returns 401/403 (depends on oauth2_scheme)."""
|
||||
# Test without overriding get_current_user — will fail at oauth2_scheme
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
tc = TestClient(app)
|
||||
|
||||
response = tc.post("/api/agent/events", json={
|
||||
"trace_id": "trace-noauth",
|
||||
"conversation_id": "conv-noauth",
|
||||
"event_type": "AGENT_REQUEST_STARTED",
|
||||
})
|
||||
assert response.status_code == 401, response.text
|
||||
# #endregion Test.Api.TestLifecycleEventWriteRequiresAuth
|
||||
|
||||
|
||||
# #region Test.Api.TestLifecycleEventListFilters [C:2] [TYPE Function] [SEMANTICS test,lifecycle,list,filters]
|
||||
# @BRIEF GET /api/agent/events supports conversation_id, status, tool_name filters.
|
||||
def test_lifecycle_event_list_filters(admin_client):
|
||||
"""All query parameter filters work correctly."""
|
||||
tc, session_factory = admin_client
|
||||
from src.services.agent_lifecycle_service import write_event
|
||||
from src.schemas.agent_lifecycle import EventWriteRequest
|
||||
|
||||
session = session_factory()
|
||||
try:
|
||||
write_event(session, EventWriteRequest(
|
||||
trace_id="t1", conversation_id="conv-a", event_type="AGENT_TOOL_STARTED",
|
||||
tool_name="deploy", status="running",
|
||||
), authenticated_user_id="admin-1")
|
||||
write_event(session, EventWriteRequest(
|
||||
trace_id="t2", conversation_id="conv-b", event_type="AGENT_TOOL_COMPLETED",
|
||||
tool_name="backup", status="success",
|
||||
), authenticated_user_id="admin-1")
|
||||
write_event(session, EventWriteRequest(
|
||||
trace_id="t3", conversation_id="conv-b", event_type="AGENT_TOOL_FAILED",
|
||||
tool_name="backup", status="failed", error_code="TIMEOUT",
|
||||
), authenticated_user_id="admin-1")
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# Filter by conversation_id
|
||||
resp = tc.get("/api/agent/events?conversation_id=conv-a")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 1
|
||||
|
||||
# Filter by status
|
||||
resp = tc.get("/api/agent/events?status=success")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 1
|
||||
|
||||
# Filter by tool_name
|
||||
resp = tc.get("/api/agent/events?tool_name=deploy")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 1
|
||||
# #endregion Test.Api.TestLifecycleEventListFilters
|
||||
# #endregion Test.Api.AgentLifecycle
|
||||
@@ -1,9 +1,9 @@
|
||||
import os
|
||||
|
||||
os.environ["ENCRYPTION_KEY"] = "OnrCzomBWbIjTf7Y-fnhL2adlU55bHZQjp8zX5zBC5w="
|
||||
# #region AssistantApiTests [TYPE Module] [C:3] [SEMANTICS tests, assistant, api]
|
||||
# #region Test.Tests.AssistantApiTests [TYPE Module] [C:3] [SEMANTICS tests, assistant, api]
|
||||
# @BRIEF Validate assistant API endpoint logic via direct async handler invocation.
|
||||
# @RELATION DEPENDS_ON -> [AssistantApi]
|
||||
# @RELATION DEPENDS_ON -> [Api.Init.AssistantApi]
|
||||
# @INVARIANT Every test clears assistant in-memory state before execution.
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
@@ -15,13 +15,13 @@ from src.models.assistant import AssistantMessageRecord
|
||||
from src.schemas.auth import User
|
||||
|
||||
|
||||
# #region _run_async [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [AssistantApiTests]
|
||||
# #region Test.Tests.RunAsync [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.AssistantApiTests]
|
||||
def _run_async(coro):
|
||||
return asyncio.run(coro)
|
||||
# #endregion _run_async
|
||||
# #region _FakeTask [TYPE Class] [C:1]
|
||||
# @RELATION BINDS_TO -> [AssistantApiTests]
|
||||
# #endregion Test.Tests.RunAsync
|
||||
# #region Test.Tests.FakeTask [TYPE Class] [C:1]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.AssistantApiTests]
|
||||
# @BRIEF Lightweight task model stub used as return value from _FakeTaskManager.create_task in assistant route tests.
|
||||
# @INVARIANT status is a bare string not a TaskStatus enum; callers must not depend on enum semantics.
|
||||
class _FakeTask:
|
||||
@@ -42,10 +42,10 @@ class _FakeTask:
|
||||
self.user_id = user_id
|
||||
self.started_at = datetime.now(UTC)
|
||||
self.finished_at = datetime.now(UTC)
|
||||
# #endregion _FakeTask
|
||||
# #endregion Test.Tests.FakeTask
|
||||
# @DEBT: Divergent _FakeTaskManager definition. Canonical version should be in conftest.py. Authz variant is missing get_all_tasks().
|
||||
# #region _FakeTaskManager [TYPE Class] [C:2]
|
||||
# @RELATION BINDS_TO -> [AssistantApiTests]
|
||||
# #region Test.Tests.FakeTaskManager [TYPE Class] [C:2]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.AssistantApiTests]
|
||||
# @BRIEF In-memory task manager stub that records created tasks for route-level assertions.
|
||||
# @INVARIANT create_task stores tasks retrievable by get_task/get_tasks without external side effects.
|
||||
class _FakeTaskManager:
|
||||
@@ -70,9 +70,9 @@ class _FakeTaskManager:
|
||||
]
|
||||
def get_all_tasks(self):
|
||||
return list(self.tasks.values())
|
||||
# #endregion _FakeTaskManager
|
||||
# #region _FakeConfigManager [TYPE Class] [C:2]
|
||||
# @RELATION BINDS_TO -> [AssistantApiTests]
|
||||
# #endregion Test.Tests.FakeTaskManager
|
||||
# #region Test.Tests.FakeConfigManager [TYPE Class] [C:2]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.AssistantApiTests]
|
||||
# @BRIEF Deterministic config stub providing hardcoded dev/prod environments and minimal settings shape for assistant route tests.
|
||||
# @INVARIANT get_config() returns anonymous inner classes, not real GlobalSettings; only default_environment_id and llm fields are safe to access.
|
||||
class _FakeConfigManager:
|
||||
@@ -90,9 +90,9 @@ class _FakeConfigManager:
|
||||
settings = _Settings()
|
||||
environments = []
|
||||
return _Config()
|
||||
# #endregion _FakeConfigManager
|
||||
# #region _admin_user [TYPE Function] [C:1]
|
||||
# @RELATION BINDS_TO -> [AssistantApiTests]
|
||||
# #endregion Test.Tests.FakeConfigManager
|
||||
# #region Test.Tests.AdminUser [TYPE Function] [C:1]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.AssistantApiTests]
|
||||
# @BRIEF Build admin principal with spec=User for assistant route authorization tests.
|
||||
def _admin_user():
|
||||
user = MagicMock(spec=User)
|
||||
@@ -102,9 +102,9 @@ def _admin_user():
|
||||
role.name = "Admin"
|
||||
user.roles = [role]
|
||||
return user
|
||||
# #endregion _admin_user
|
||||
# #region _limited_user [TYPE Function] [C:1]
|
||||
# @RELATION BINDS_TO -> [AssistantApiTests]
|
||||
# #endregion Test.Tests.AdminUser
|
||||
# #region Test.Tests.LimitedUser [TYPE Function] [C:1]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.AssistantApiTests]
|
||||
# @BRIEF Build limited user principal with empty roles for assistant route denial tests.
|
||||
def _limited_user():
|
||||
user = MagicMock(spec=User)
|
||||
@@ -112,9 +112,9 @@ def _limited_user():
|
||||
user.username = "limited"
|
||||
user.roles = []
|
||||
return user
|
||||
# #endregion _limited_user
|
||||
# #region _FakeQuery [TYPE Class] [C:2]
|
||||
# @RELATION BINDS_TO -> [AssistantApiTests]
|
||||
# #endregion Test.Tests.LimitedUser
|
||||
# #region Test.Tests.FakeQuery [TYPE Class] [C:2]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.AssistantApiTests]
|
||||
# @BRIEF Chainable SQLAlchemy-like query stub returning fixed item lists for assistant message persistence paths.
|
||||
# @INVARIANT filter() ignores all predicate arguments and returns self; no predicate-based filtering is emulated.
|
||||
class _FakeQuery:
|
||||
@@ -141,9 +141,9 @@ class _FakeQuery:
|
||||
return self.items
|
||||
def count(self):
|
||||
return len(self.items)
|
||||
# #endregion _FakeQuery
|
||||
# #region _FakeDb [TYPE Class] [C:2]
|
||||
# @RELATION BINDS_TO -> [AssistantApiTests]
|
||||
# #endregion Test.Tests.FakeQuery
|
||||
# #region Test.Tests.FakeDb [TYPE Class] [C:2]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.AssistantApiTests]
|
||||
# @BRIEF Explicit in-memory DB session double limited to assistant message persistence paths.
|
||||
# @INVARIANT query() always returns _FakeQuery with intentionally non-evaluated predicates; add/merge stay deterministic and never emulate unrelated SQLAlchemy behavior.
|
||||
class _FakeDb:
|
||||
@@ -163,24 +163,24 @@ class _FakeDb:
|
||||
return obj
|
||||
def refresh(self, obj):
|
||||
pass
|
||||
# #endregion _FakeDb
|
||||
# #region _clear_assistant_state [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [AssistantApiTests]
|
||||
# #endregion Test.Tests.FakeDb
|
||||
# #region Test.Tests.ClearAssistantState [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.AssistantApiTests]
|
||||
def _clear_assistant_state():
|
||||
assistant_routes.CONVERSATIONS.clear()
|
||||
assistant_routes.USER_ACTIVE_CONVERSATION.clear()
|
||||
assistant_routes.CONFIRMATIONS.clear()
|
||||
assistant_routes.ASSISTANT_AUDIT.clear()
|
||||
# #endregion _clear_assistant_state
|
||||
# #endregion Test.Tests.ClearAssistantState
|
||||
|
||||
# #region _await_none [TYPE Function] [C:1]
|
||||
# @RELATION BINDS_TO -> [AssistantApiTests]
|
||||
# #region Test.Tests.AwaitNone [TYPE Function] [C:1]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.AssistantApiTests]
|
||||
# @BRIEF Async helper returning None for planner fallback tests.
|
||||
async def _await_none(*args, **kwargs):
|
||||
return None
|
||||
# #endregion _await_none
|
||||
# #region test_unknown_command_returns_needs_clarification [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [AssistantApiTests]
|
||||
# #endregion Test.Tests.AwaitNone
|
||||
# #region Test.Tests.TestUnknownCommandReturnsNeedsClarification [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.AssistantApiTests]
|
||||
# @BRIEF Unknown command should return clarification state and unknown intent.
|
||||
def test_unknown_command_returns_needs_clarification(monkeypatch):
|
||||
_clear_assistant_state()
|
||||
@@ -198,9 +198,9 @@ def test_unknown_command_returns_needs_clarification(monkeypatch):
|
||||
)
|
||||
assert resp.state == "needs_clarification"
|
||||
assert "уточните" in resp.text.lower() or "неоднозначна" in resp.text.lower()
|
||||
# #endregion test_unknown_command_returns_needs_clarification
|
||||
# #region test_capabilities_question_returns_successful_help [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [AssistantApiTests]
|
||||
# #endregion Test.Tests.TestUnknownCommandReturnsNeedsClarification
|
||||
# #region Test.Tests.TestCapabilitiesQuestionReturnsSuccessfulHelp [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.AssistantApiTests]
|
||||
# @BRIEF Capability query should return deterministic help response.
|
||||
def test_capabilities_question_returns_successful_help(monkeypatch):
|
||||
_clear_assistant_state()
|
||||
@@ -228,5 +228,5 @@ def test_capabilities_question_returns_successful_help(monkeypatch):
|
||||
)
|
||||
assert resp.state == "success"
|
||||
assert "я могу сделать" in resp.text.lower()
|
||||
# #endregion test_capabilities_question_returns_successful_help
|
||||
# #endregion AssistantApiTests
|
||||
# #endregion Test.Tests.TestCapabilitiesQuestionReturnsSuccessfulHelp
|
||||
# #endregion Test.Tests.AssistantApiTests
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import os
|
||||
|
||||
os.environ["ENCRYPTION_KEY"] = "OnrCzomBWbIjTf7Y-fnhL2adlU55bHZQjp8zX5zBC5w="
|
||||
# #region TestAssistantAuthz [TYPE Module] [C:3] [SEMANTICS tests, assistant, authz, confirmation, rbac]
|
||||
# #region Test.Tests.TestAssistantAuthz [TYPE Module] [C:3] [SEMANTICS tests, assistant, authz, confirmation, rbac]
|
||||
# @BRIEF Verify assistant confirmation ownership, expiration, and deny behavior for restricted users.
|
||||
# @LAYER API
|
||||
# @RELATION DEPENDS_ON -> AssistantApi
|
||||
# @RELATION DEPENDS_ON -> Api.Init.AssistantApi
|
||||
# @INVARIANT Security-sensitive flows fail closed for unauthorized actors.
|
||||
import asyncio
|
||||
from datetime import UTC, datetime, timedelta
|
||||
@@ -30,16 +30,16 @@ from src.models.assistant import (
|
||||
)
|
||||
|
||||
|
||||
# #region _run_async [TYPE Function] [C:1]
|
||||
# @RELATION BINDS_TO -> [TestAssistantAuthz]
|
||||
# #region Test.Tests.RunAsync [TYPE Function] [C:1]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.TestAssistantAuthz]
|
||||
# @BRIEF 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)
|
||||
# #endregion _run_async
|
||||
# #region _FakeTask [TYPE Class] [C:1]
|
||||
# @RELATION BINDS_TO -> [TestAssistantAuthz]
|
||||
# #endregion Test.Tests.RunAsync
|
||||
# #region Test.Tests.FakeTask [TYPE Class] [C:1]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.TestAssistantAuthz]
|
||||
# @BRIEF Lightweight task model used for assistant authz tests.
|
||||
# @PRE task_id is non-empty string.
|
||||
# @POST Returns task with provided id, status, and user_id accessible as attributes.
|
||||
@@ -48,10 +48,10 @@ class _FakeTask:
|
||||
self.id = task_id
|
||||
self.status = status
|
||||
self.user_id = user_id
|
||||
# #endregion _FakeTask
|
||||
# #endregion Test.Tests.FakeTask
|
||||
# @DEBT: Divergent _FakeTaskManager definition. Canonical version should be in conftest.py. Authz variant is missing get_all_tasks().
|
||||
# #region _FakeTaskManager [TYPE Class] [C:2]
|
||||
# @RELATION BINDS_TO -> [TestAssistantAuthz]
|
||||
# #region Test.Tests.FakeTaskManager [TYPE Class] [C:2]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.TestAssistantAuthz]
|
||||
# @BRIEF In-memory task manager double that records assistant-created tasks deterministically.
|
||||
# @INVARIANT Only create_task/get_task/get_tasks behavior used by assistant authz routes is emulated.
|
||||
class _FakeTaskManager:
|
||||
@@ -73,10 +73,10 @@ class _FakeTaskManager:
|
||||
raise NotImplementedError(
|
||||
"get_all_tasks not implemented in authz FakeTaskManager"
|
||||
)
|
||||
# #endregion _FakeTaskManager
|
||||
# #endregion Test.Tests.FakeTaskManager
|
||||
# @CONTRACT: Partial ConfigManager stub for authz tests. Missing: get_config().
|
||||
# #region _FakeConfigManager [TYPE Class] [C:1]
|
||||
# @RELATION BINDS_TO -> [TestAssistantAuthz]
|
||||
# #region Test.Tests.FakeConfigManager [TYPE Class] [C:1]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.TestAssistantAuthz]
|
||||
# @BRIEF Provide deterministic environment aliases required by intent parsing.
|
||||
# @PRE No external config or DB state is required.
|
||||
# @POST get_environments() returns two deterministic SimpleNamespace stubs with id/name.
|
||||
@@ -91,36 +91,36 @@ class _FakeConfigManager:
|
||||
raise NotImplementedError(
|
||||
"get_config not implemented in authz fake — add if route under test requires it"
|
||||
)
|
||||
# #endregion _FakeConfigManager
|
||||
# #region _admin_user [TYPE Function] [C:1]
|
||||
# @RELATION BINDS_TO -> [TestAssistantAuthz]
|
||||
# #endregion Test.Tests.FakeConfigManager
|
||||
# #region Test.Tests.AdminUser [TYPE Function] [C:1]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.TestAssistantAuthz]
|
||||
# @BRIEF 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])
|
||||
# #endregion _admin_user
|
||||
# #region _other_admin_user [TYPE Function] [C:1]
|
||||
# @RELATION BINDS_TO -> [TestAssistantAuthz]
|
||||
# #endregion Test.Tests.AdminUser
|
||||
# #region Test.Tests.OtherAdminUser [TYPE Function] [C:1]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.TestAssistantAuthz]
|
||||
# @BRIEF 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])
|
||||
# #endregion _other_admin_user
|
||||
# #region _limited_user [TYPE Function] [C:1]
|
||||
# @RELATION BINDS_TO -> [TestAssistantAuthz]
|
||||
# #endregion Test.Tests.OtherAdminUser
|
||||
# #region Test.Tests.LimitedUser [TYPE Function] [C:1]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.TestAssistantAuthz]
|
||||
# @BRIEF 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])
|
||||
# #endregion _limited_user
|
||||
# #region _FakeQuery [TYPE Class] [C:1]
|
||||
# @RELATION BINDS_TO -> [TestAssistantAuthz]
|
||||
# #endregion Test.Tests.LimitedUser
|
||||
# #region Test.Tests.FakeQuery [TYPE Class] [C:1]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.TestAssistantAuthz]
|
||||
# @BRIEF Minimal chainable query object for fake DB interactions.
|
||||
# @INVARIANT filter() deliberately discards predicate args and returns self; tests must not assume predicate evaluation.
|
||||
class _FakeQuery:
|
||||
@@ -143,9 +143,9 @@ class _FakeQuery:
|
||||
return self
|
||||
def count(self):
|
||||
return len(self._rows)
|
||||
# #endregion _FakeQuery
|
||||
# #region _FakeDb [TYPE Class] [C:2]
|
||||
# @RELATION BINDS_TO -> [TestAssistantAuthz]
|
||||
# #endregion Test.Tests.FakeQuery
|
||||
# #region Test.Tests.FakeDb [TYPE Class] [C:2]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.TestAssistantAuthz]
|
||||
# @BRIEF In-memory DB session double constrained to assistant message/confirmation/audit persistence paths.
|
||||
# @INVARIANT query/add/merge are intentionally narrow and must not claim full SQLAlchemy Session semantics.
|
||||
class _FakeDb:
|
||||
@@ -183,9 +183,9 @@ class _FakeDb:
|
||||
return None
|
||||
def rollback(self):
|
||||
return None
|
||||
# #endregion _FakeDb
|
||||
# #region _clear_assistant_state [TYPE Function] [C:1]
|
||||
# @RELATION BINDS_TO -> [TestAssistantAuthz]
|
||||
# #endregion Test.Tests.FakeDb
|
||||
# #region Test.Tests.ClearAssistantState [TYPE Function] [C:1]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.TestAssistantAuthz]
|
||||
# @BRIEF 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.
|
||||
@@ -194,9 +194,9 @@ def _clear_assistant_state():
|
||||
assistant_module.USER_ACTIVE_CONVERSATION.clear()
|
||||
assistant_module.CONFIRMATIONS.clear()
|
||||
assistant_module.ASSISTANT_AUDIT.clear()
|
||||
# #endregion _clear_assistant_state
|
||||
# #region test_confirmation_owner_mismatch_returns_403 [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [TestAssistantAuthz]
|
||||
# #endregion Test.Tests.ClearAssistantState
|
||||
# #region Test.Tests.TestConfirmationOwnerMismatchReturns403 [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.TestAssistantAuthz]
|
||||
# @BRIEF 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.
|
||||
@@ -227,9 +227,9 @@ def test_confirmation_owner_mismatch_returns_403():
|
||||
)
|
||||
)
|
||||
assert exc.value.status_code == 403
|
||||
# #endregion test_confirmation_owner_mismatch_returns_403
|
||||
# #region test_expired_confirmation_cannot_be_confirmed [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [TestAssistantAuthz]
|
||||
# #endregion Test.Tests.TestConfirmationOwnerMismatchReturns403
|
||||
# #region Test.Tests.TestExpiredConfirmationCannotBeConfirmed [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.TestAssistantAuthz]
|
||||
# @BRIEF 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.
|
||||
@@ -263,9 +263,9 @@ def test_expired_confirmation_cannot_be_confirmed():
|
||||
)
|
||||
assert exc.value.status_code == 400
|
||||
assert task_manager.get_tasks(limit=10, offset=0) == []
|
||||
# #endregion test_expired_confirmation_cannot_be_confirmed
|
||||
# #region test_limited_user_cannot_launch_restricted_operation [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [TestAssistantAuthz]
|
||||
# #endregion Test.Tests.TestExpiredConfirmationCannotBeConfirmed
|
||||
# #region Test.Tests.TestLimitedUserCannotLaunchRestrictedOperation [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.TestAssistantAuthz]
|
||||
# @BRIEF 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.
|
||||
@@ -283,5 +283,5 @@ def test_limited_user_cannot_launch_restricted_operation():
|
||||
)
|
||||
)
|
||||
assert response.state == "denied"
|
||||
# #endregion test_limited_user_cannot_launch_restricted_operation
|
||||
# #endregion TestAssistantAuthz
|
||||
# #endregion Test.Tests.TestLimitedUserCannotLaunchRestrictedOperation
|
||||
# #endregion Test.Tests.TestAssistantAuthz
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# #region TestCleanReleaseApi [TYPE Module] [C:3] [SEMANTICS tests, api, clean-release, checks, reports]
|
||||
# @RELATION BINDS_TO -> SrcRoot
|
||||
# #region Test.Tests.TestCleanReleaseApi [TYPE Module] [C:3] [SEMANTICS tests, api, clean-release, checks, reports]
|
||||
# @RELATION BINDS_TO -> Init.SrcRoot
|
||||
# @BRIEF Contract tests for clean release checks and reports endpoints.
|
||||
# @LAYER Domain
|
||||
# @INVARIANT API returns deterministic payload shapes for checks and reports.
|
||||
@@ -22,8 +22,8 @@ from src.models.clean_release import (
|
||||
from src.services.clean_release.repository import CleanReleaseRepository
|
||||
|
||||
|
||||
# #region _repo_with_seed_data [TYPE Function]
|
||||
# @RELATION BINDS_TO -> TestCleanReleaseApi
|
||||
# #region Test.Tests.RepoWithSeedData [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.TestCleanReleaseApi
|
||||
def _repo_with_seed_data() -> CleanReleaseRepository:
|
||||
repo = CleanReleaseRepository()
|
||||
repo.save_candidate(
|
||||
@@ -69,9 +69,9 @@ def _repo_with_seed_data() -> CleanReleaseRepository:
|
||||
)
|
||||
)
|
||||
return repo
|
||||
# #endregion _repo_with_seed_data
|
||||
# #region test_start_check_and_get_status_contract [TYPE Function]
|
||||
# @RELATION BINDS_TO -> TestCleanReleaseApi
|
||||
# #endregion Test.Tests.RepoWithSeedData
|
||||
# #region Test.Tests.TestStartCheckAndGetStatusContract [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.TestCleanReleaseApi
|
||||
# @BRIEF Validate checks start endpoint returns expected identifiers and status endpoint reflects the same run.
|
||||
def test_start_check_and_get_status_contract():
|
||||
repo = _repo_with_seed_data()
|
||||
@@ -101,9 +101,9 @@ def test_start_check_and_get_status_contract():
|
||||
assert "checks" in status_payload
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
# #endregion test_start_check_and_get_status_contract
|
||||
# #region test_get_report_not_found_returns_404 [TYPE Function]
|
||||
# @RELATION BINDS_TO -> TestCleanReleaseApi
|
||||
# #endregion Test.Tests.TestStartCheckAndGetStatusContract
|
||||
# #region Test.Tests.TestGetReportNotFoundReturns404 [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.TestCleanReleaseApi
|
||||
# @BRIEF Validate reports endpoint returns 404 for an unknown report identifier.
|
||||
def test_get_report_not_found_returns_404():
|
||||
repo = _repo_with_seed_data()
|
||||
@@ -114,9 +114,9 @@ def test_get_report_not_found_returns_404():
|
||||
assert resp.status_code == 404
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
# #endregion test_get_report_not_found_returns_404
|
||||
# #region test_get_report_success [TYPE Function]
|
||||
# @RELATION BINDS_TO -> TestCleanReleaseApi
|
||||
# #endregion Test.Tests.TestGetReportNotFoundReturns404
|
||||
# #region Test.Tests.TestGetReportSuccess [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.TestCleanReleaseApi
|
||||
# @BRIEF Validate reports endpoint returns persisted report payload for an existing report identifier.
|
||||
def test_get_report_success():
|
||||
repo = _repo_with_seed_data()
|
||||
@@ -140,9 +140,9 @@ def test_get_report_success():
|
||||
assert resp.json()["report_id"] == "rep-1"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
# #endregion test_get_report_success
|
||||
# #region test_prepare_candidate_api_success [TYPE Function]
|
||||
# @RELATION BINDS_TO -> TestCleanReleaseApi
|
||||
# #endregion Test.Tests.TestGetReportSuccess
|
||||
# #region Test.Tests.TestPrepareCandidateApiSuccess [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.TestCleanReleaseApi
|
||||
# @BRIEF Validate candidate preparation endpoint returns prepared status and manifest identifier on valid input.
|
||||
def test_prepare_candidate_api_success():
|
||||
repo = _repo_with_seed_data()
|
||||
@@ -166,5 +166,5 @@ def test_prepare_candidate_api_success():
|
||||
assert "manifest_id" in data
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
# #endregion test_prepare_candidate_api_success
|
||||
# #endregion TestCleanReleaseApi
|
||||
# #endregion Test.Tests.TestPrepareCandidateApiSuccess
|
||||
# #endregion Test.Tests.TestCleanReleaseApi
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# #region TestCleanReleaseLegacyCompat [TYPE Module] [C:3] [SEMANTICS test, clean-release, legacy, compat]
|
||||
# @RELATION BINDS_TO -> SrcRoot
|
||||
# #region Test.Tests.TestCleanReleaseLegacyCompat [TYPE Module] [C:3] [SEMANTICS test, clean-release, legacy, compat]
|
||||
# @RELATION BINDS_TO -> Init.SrcRoot
|
||||
# @BRIEF Compatibility tests for legacy clean-release API paths retained during v2 migration.
|
||||
# @LAYER Tests
|
||||
from __future__ import annotations
|
||||
@@ -27,8 +27,8 @@ from src.models.clean_release import (
|
||||
from src.services.clean_release.repository import CleanReleaseRepository
|
||||
|
||||
|
||||
# #region _seed_legacy_repo [TYPE Function]
|
||||
# @RELATION BINDS_TO -> TestCleanReleaseLegacyCompat
|
||||
# #region Test.Tests.SeedLegacyRepo [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.TestCleanReleaseLegacyCompat
|
||||
# @BRIEF Seed in-memory repository with minimum trusted data for legacy endpoint contracts.
|
||||
# @PRE Repository is empty.
|
||||
# @POST Candidate, policy, registry and manifest are available for legacy checks flow.
|
||||
@@ -97,9 +97,9 @@ def _seed_legacy_repo() -> CleanReleaseRepository:
|
||||
)
|
||||
)
|
||||
return repo
|
||||
# #endregion _seed_legacy_repo
|
||||
# #region test_legacy_prepare_endpoint_still_available [TYPE Function]
|
||||
# @RELATION BINDS_TO -> TestCleanReleaseLegacyCompat
|
||||
# #endregion Test.Tests.SeedLegacyRepo
|
||||
# #region Test.Tests.TestLegacyPrepareEndpointStillAvailable [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.TestCleanReleaseLegacyCompat
|
||||
# @BRIEF Verify legacy prepare endpoint remains reachable and returns a status payload.
|
||||
def test_legacy_prepare_endpoint_still_available() -> None:
|
||||
repo = _seed_legacy_repo()
|
||||
@@ -123,9 +123,9 @@ def test_legacy_prepare_endpoint_still_available() -> None:
|
||||
assert payload["status"] in {"prepared", "blocked", "PREPARED", "BLOCKED"}
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
# #endregion test_legacy_prepare_endpoint_still_available
|
||||
# #region test_legacy_checks_endpoints_still_available [TYPE Function]
|
||||
# @RELATION BINDS_TO -> TestCleanReleaseLegacyCompat
|
||||
# #endregion Test.Tests.TestLegacyPrepareEndpointStillAvailable
|
||||
# #region Test.Tests.TestLegacyChecksEndpointsStillAvailable [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.TestCleanReleaseLegacyCompat
|
||||
# @BRIEF Verify legacy checks start/status endpoints remain available during v2 transition.
|
||||
def test_legacy_checks_endpoints_still_available() -> None:
|
||||
repo = _seed_legacy_repo()
|
||||
@@ -155,5 +155,5 @@ def test_legacy_checks_endpoints_still_available() -> None:
|
||||
assert "checks" in status_payload
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
# #endregion test_legacy_checks_endpoints_still_available
|
||||
# #endregion TestCleanReleaseLegacyCompat
|
||||
# #endregion Test.Tests.TestLegacyChecksEndpointsStillAvailable
|
||||
# #endregion Test.Tests.TestCleanReleaseLegacyCompat
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# #region TestCleanReleaseSourcePolicy [TYPE Module] [C:3] [SEMANTICS tests, api, clean-release, source-policy]
|
||||
# @RELATION BINDS_TO -> SrcRoot
|
||||
# #region Test.Tests.TestCleanReleaseSourcePolicy [TYPE Module] [C:3] [SEMANTICS tests, api, clean-release, source-policy]
|
||||
# @RELATION BINDS_TO -> Init.SrcRoot
|
||||
# @BRIEF Validate API behavior for source isolation violations in clean release preparation.
|
||||
# @LAYER Domain
|
||||
# @INVARIANT External endpoints must produce blocking violation entries.
|
||||
@@ -20,8 +20,8 @@ from src.models.clean_release import (
|
||||
from src.services.clean_release.repository import CleanReleaseRepository
|
||||
|
||||
|
||||
# #region _repo_with_seed_data [TYPE Function]
|
||||
# @RELATION BINDS_TO -> TestCleanReleaseSourcePolicy
|
||||
# #region Test.Tests.RepoWithSeedData [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.TestCleanReleaseSourcePolicy
|
||||
# @BRIEF Seed repository with candidate, registry, and active policy for source isolation test flow.
|
||||
def _repo_with_seed_data() -> CleanReleaseRepository:
|
||||
repo = CleanReleaseRepository()
|
||||
@@ -68,9 +68,9 @@ def _repo_with_seed_data() -> CleanReleaseRepository:
|
||||
)
|
||||
)
|
||||
return repo
|
||||
# #endregion _repo_with_seed_data
|
||||
# #region test_prepare_candidate_blocks_external_source [TYPE Function]
|
||||
# @RELATION BINDS_TO -> TestCleanReleaseSourcePolicy
|
||||
# #endregion Test.Tests.RepoWithSeedData
|
||||
# #region Test.Tests.TestPrepareCandidateBlocksExternalSource [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.TestCleanReleaseSourcePolicy
|
||||
# @BRIEF Verify candidate preparation is blocked when at least one source host is external to the trusted registry.
|
||||
def test_prepare_candidate_blocks_external_source():
|
||||
repo = _repo_with_seed_data()
|
||||
@@ -98,5 +98,5 @@ def test_prepare_candidate_blocks_external_source():
|
||||
assert any(v["category"] == "external-source" for v in data["violations"])
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
# #endregion test_prepare_candidate_blocks_external_source
|
||||
# #endregion TestCleanReleaseSourcePolicy
|
||||
# #endregion Test.Tests.TestPrepareCandidateBlocksExternalSource
|
||||
# #endregion Test.Tests.TestCleanReleaseSourcePolicy
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# #region CleanReleaseV2ApiTests [TYPE Module] [C:3] [SEMANTICS test, clean-release, v2, api, contract]
|
||||
# #region Test.Tests.CleanReleaseV2ApiTests [TYPE Module] [C:3] [SEMANTICS test, clean-release, v2, api, contract]
|
||||
# @BRIEF API contract tests for redesigned clean release endpoints.
|
||||
# @LAYER Domain
|
||||
# @RELATION DEPENDS_ON -> [CleanReleaseV2Api]
|
||||
# @RELATION DEPENDS_ON -> [Api.CleanReleaseV2.CleanReleaseV2Api]
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.app import app
|
||||
@@ -9,8 +9,8 @@ from src.services.clean_release.enums import CandidateStatus
|
||||
|
||||
client = TestClient(app)
|
||||
# [REASON] Implementing API contract tests for candidate/artifact/manifest endpoints (T012).
|
||||
# #region test_candidate_registration_contract [TYPE Function]
|
||||
# @RELATION BINDS_TO -> CleanReleaseV2ApiTests
|
||||
# #region Test.Tests.TestCandidateRegistrationContract [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.CleanReleaseV2ApiTests
|
||||
# @BRIEF Validate candidate registration endpoint creates a draft candidate with expected identifier contract.
|
||||
def test_candidate_registration_contract():
|
||||
"""
|
||||
@@ -28,9 +28,9 @@ def test_candidate_registration_contract():
|
||||
data = response.json()
|
||||
assert data["id"] == "rc-test-001"
|
||||
assert data["status"] == CandidateStatus.DRAFT.value
|
||||
# #endregion test_candidate_registration_contract
|
||||
# #region test_artifact_import_contract [TYPE Function]
|
||||
# @RELATION BINDS_TO -> CleanReleaseV2ApiTests
|
||||
# #endregion Test.Tests.TestCandidateRegistrationContract
|
||||
# #region Test.Tests.TestArtifactImportContract [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.CleanReleaseV2ApiTests
|
||||
# @BRIEF Validate artifact import endpoint accepts candidate artifacts and returns success status payload.
|
||||
def test_artifact_import_contract():
|
||||
"""
|
||||
@@ -58,9 +58,9 @@ def test_artifact_import_contract():
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "success"
|
||||
# #endregion test_artifact_import_contract
|
||||
# #region test_manifest_build_contract [TYPE Function]
|
||||
# @RELATION BINDS_TO -> CleanReleaseV2ApiTests
|
||||
# #endregion Test.Tests.TestArtifactImportContract
|
||||
# #region Test.Tests.TestManifestBuildContract [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.CleanReleaseV2ApiTests
|
||||
# @BRIEF Validate manifest build endpoint produces manifest payload linked to the target candidate.
|
||||
def test_manifest_build_contract():
|
||||
"""
|
||||
@@ -83,5 +83,5 @@ def test_manifest_build_contract():
|
||||
data = response.json()
|
||||
assert "manifest_digest" in data
|
||||
assert data["candidate_id"] == candidate_id
|
||||
# #endregion test_manifest_build_contract
|
||||
# #endregion CleanReleaseV2ApiTests
|
||||
# #endregion Test.Tests.TestManifestBuildContract
|
||||
# #endregion Test.Tests.CleanReleaseV2ApiTests
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# #region CleanReleaseV2ReleaseApiTests [TYPE Module] [C:3] [SEMANTICS test, clean-release, release, approval, publication]
|
||||
# #region Test.Tests.CleanReleaseV2ReleaseApiTests [TYPE Module] [C:3] [SEMANTICS test, clean-release, release, approval, publication]
|
||||
# @BRIEF API contract test scaffolding for clean release approval and publication endpoints.
|
||||
# @LAYER Domain
|
||||
# @RELATION DEPENDS_ON -> [CleanReleaseV2Api]
|
||||
# @RELATION DEPENDS_ON -> [Api.CleanReleaseV2.CleanReleaseV2Api]
|
||||
"""Contract tests for redesigned approval/publication API endpoints."""
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
@@ -17,8 +17,8 @@ from src.services.clean_release.enums import CandidateStatus, ComplianceDecision
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(clean_release_v2_router)
|
||||
client = TestClient(test_app)
|
||||
# #region _seed_candidate_and_passed_report [TYPE Function]
|
||||
# @RELATION BINDS_TO -> CleanReleaseV2ReleaseApiTests
|
||||
# #region Test.Tests.SeedCandidateAndPassedReport [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.CleanReleaseV2ReleaseApiTests
|
||||
# @BRIEF Seed repository with approvable candidate and passed report for release endpoint contracts.
|
||||
def _seed_candidate_and_passed_report() -> tuple[str, str]:
|
||||
repository = get_clean_release_repository()
|
||||
@@ -50,9 +50,9 @@ def _seed_candidate_and_passed_report() -> tuple[str, str]:
|
||||
)
|
||||
)
|
||||
return candidate_id, report_id
|
||||
# #endregion _seed_candidate_and_passed_report
|
||||
# #region test_release_approve_and_publish_revoke_contract [TYPE Function]
|
||||
# @RELATION BINDS_TO -> CleanReleaseV2ReleaseApiTests
|
||||
# #endregion Test.Tests.SeedCandidateAndPassedReport
|
||||
# #region Test.Tests.TestReleaseApproveAndPublishRevokeContract [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.CleanReleaseV2ReleaseApiTests
|
||||
# @BRIEF Verify approve, publish, and revoke endpoints preserve expected release lifecycle contract.
|
||||
def test_release_approve_and_publish_revoke_contract() -> None:
|
||||
"""Contract for approve -> publish -> revoke lifecycle endpoints."""
|
||||
@@ -87,9 +87,9 @@ def test_release_approve_and_publish_revoke_contract() -> None:
|
||||
revoke_payload = revoke_response.json()
|
||||
assert revoke_payload["status"] == "ok"
|
||||
assert revoke_payload["publication"]["status"] == "REVOKED"
|
||||
# #endregion test_release_approve_and_publish_revoke_contract
|
||||
# #region test_release_reject_contract [TYPE Function]
|
||||
# @RELATION BINDS_TO -> CleanReleaseV2ReleaseApiTests
|
||||
# #endregion Test.Tests.TestReleaseApproveAndPublishRevokeContract
|
||||
# #region Test.Tests.TestReleaseRejectContract [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.CleanReleaseV2ReleaseApiTests
|
||||
# @BRIEF Verify reject endpoint returns successful rejection decision payload.
|
||||
def test_release_reject_contract() -> None:
|
||||
"""Contract for reject endpoint."""
|
||||
@@ -102,5 +102,5 @@ def test_release_reject_contract() -> None:
|
||||
payload = reject_response.json()
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["decision"] == "REJECTED"
|
||||
# #endregion test_release_reject_contract
|
||||
# #endregion CleanReleaseV2ReleaseApiTests
|
||||
# #endregion Test.Tests.TestReleaseRejectContract
|
||||
# #endregion Test.Tests.CleanReleaseV2ReleaseApiTests
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# #region DashboardsApiTests [TYPE Module] [C:3] [SEMANTICS test, dashboard, api, listing, migration]
|
||||
# #region Test.Tests.DashboardsApiTests [TYPE Module] [C:3] [SEMANTICS test, dashboard, api, listing, migration]
|
||||
# @BRIEF Unit tests for dashboards API endpoints.
|
||||
# @LAYER API
|
||||
# @RELATION DEPENDS_ON -> [DashboardsApi]
|
||||
# @RELATION DEPENDS_ON -> [Api.Init.DashboardsApi]
|
||||
from datetime import UTC, datetime
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
@@ -61,8 +61,8 @@ def mock_deps():
|
||||
}
|
||||
app.dependency_overrides.clear()
|
||||
client = TestClient(app)
|
||||
# #region test_get_dashboards_success [TYPE Function]
|
||||
# @RELATION BINDS_TO -> DashboardsApiTests
|
||||
# #region Test.Tests.TestGetDashboardsSuccess [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
|
||||
# @BRIEF Validate dashboards listing returns a populated response that satisfies the schema contract.
|
||||
# @TEST: GET /api/dashboards returns 200 and valid schema
|
||||
# @PRE env_id exists
|
||||
@@ -95,9 +95,9 @@ def test_get_dashboards_success(mock_deps):
|
||||
assert data["total"] == 1
|
||||
assert "page" in data
|
||||
DashboardsResponse(**data)
|
||||
# #endregion test_get_dashboards_success
|
||||
# #region test_get_dashboards_with_search [TYPE Function]
|
||||
# @RELATION BINDS_TO -> DashboardsApiTests
|
||||
# #endregion Test.Tests.TestGetDashboardsSuccess
|
||||
# #region Test.Tests.TestGetDashboardsWithSearch [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
|
||||
# @BRIEF Validate dashboards listing applies the search filter and returns only matching rows.
|
||||
# @TEST: GET /api/dashboards filters by search term
|
||||
# @PRE search parameter provided
|
||||
@@ -133,9 +133,9 @@ def test_get_dashboards_with_search(mock_deps):
|
||||
# @POST Filtered result count must match search
|
||||
assert len(data["dashboards"]) == 1
|
||||
assert data["dashboards"][0]["title"] == "Sales Report"
|
||||
# #endregion test_get_dashboards_with_search
|
||||
# #region test_get_dashboards_empty [TYPE Function]
|
||||
# @RELATION BINDS_TO -> DashboardsApiTests
|
||||
# #endregion Test.Tests.TestGetDashboardsWithSearch
|
||||
# #region Test.Tests.TestGetDashboardsEmpty [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
|
||||
# @BRIEF Validate dashboards listing returns an empty payload for an environment without dashboards.
|
||||
# @TEST_EDGE empty_dashboards -> {env_id: 'empty_env', expected_total: 0}
|
||||
def test_get_dashboards_empty(mock_deps):
|
||||
@@ -152,9 +152,9 @@ def test_get_dashboards_empty(mock_deps):
|
||||
assert len(data["dashboards"]) == 0
|
||||
assert data["total_pages"] == 1
|
||||
DashboardsResponse(**data)
|
||||
# #endregion test_get_dashboards_empty
|
||||
# #region test_get_dashboards_superset_failure [TYPE Function]
|
||||
# @RELATION BINDS_TO -> DashboardsApiTests
|
||||
# #endregion Test.Tests.TestGetDashboardsEmpty
|
||||
# #region Test.Tests.TestGetDashboardsSupersetFailure [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
|
||||
# @BRIEF Validate dashboards listing surfaces a 503 contract when Superset access fails.
|
||||
# @TEST_EDGE external_superset_failure -> {env_id: 'bad_conn', status: 503}
|
||||
def test_get_dashboards_superset_failure(mock_deps):
|
||||
@@ -169,9 +169,9 @@ def test_get_dashboards_superset_failure(mock_deps):
|
||||
response = client.get("/api/dashboards?env_id=bad_conn")
|
||||
assert response.status_code == 503
|
||||
assert "Failed to fetch dashboards" in response.json()["detail"]
|
||||
# #endregion test_get_dashboards_superset_failure
|
||||
# #region test_get_dashboards_env_not_found [TYPE Function]
|
||||
# @RELATION BINDS_TO -> DashboardsApiTests
|
||||
# #endregion Test.Tests.TestGetDashboardsSupersetFailure
|
||||
# #region Test.Tests.TestGetDashboardsEnvNotFound [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
|
||||
# @BRIEF Validate dashboards listing returns 404 when the requested environment does not exist.
|
||||
# @TEST: GET /api/dashboards returns 404 if env_id missing
|
||||
# @PRE env_id does not exist
|
||||
@@ -181,9 +181,9 @@ def test_get_dashboards_env_not_found(mock_deps):
|
||||
response = client.get("/api/dashboards?env_id=nonexistent")
|
||||
assert response.status_code == 404
|
||||
assert "Environment not found" in response.json()["detail"]
|
||||
# #endregion test_get_dashboards_env_not_found
|
||||
# #region test_get_dashboards_invalid_pagination [TYPE Function]
|
||||
# @RELATION BINDS_TO -> DashboardsApiTests
|
||||
# #endregion Test.Tests.TestGetDashboardsEnvNotFound
|
||||
# #region Test.Tests.TestGetDashboardsInvalidPagination [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
|
||||
# @BRIEF Validate dashboards listing rejects invalid pagination parameters with 400 responses.
|
||||
# @TEST: GET /api/dashboards returns 400 for invalid page/page_size
|
||||
# @PRE page < 1 or page_size > 100
|
||||
@@ -200,9 +200,9 @@ def test_get_dashboards_invalid_pagination(mock_deps):
|
||||
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"]
|
||||
# #endregion test_get_dashboards_invalid_pagination
|
||||
# #region test_get_dashboard_detail_success [TYPE Function]
|
||||
# @RELATION BINDS_TO -> DashboardsApiTests
|
||||
# #endregion Test.Tests.TestGetDashboardsInvalidPagination
|
||||
# #region Test.Tests.TestGetDashboardDetailSuccess [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
|
||||
# @BRIEF Validate dashboard detail returns charts and datasets for an existing dashboard.
|
||||
# @TEST: GET /api/dashboards/{id} returns dashboard detail with charts and datasets
|
||||
def test_get_dashboard_detail_success(mock_deps):
|
||||
@@ -249,9 +249,9 @@ def test_get_dashboard_detail_success(mock_deps):
|
||||
assert payload["id"] == 42
|
||||
assert payload["chart_count"] == 1
|
||||
assert payload["dataset_count"] == 1
|
||||
# #endregion test_get_dashboard_detail_success
|
||||
# #region test_get_dashboard_detail_env_not_found [TYPE Function]
|
||||
# @RELATION BINDS_TO -> DashboardsApiTests
|
||||
# #endregion Test.Tests.TestGetDashboardDetailSuccess
|
||||
# #region Test.Tests.TestGetDashboardDetailEnvNotFound [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
|
||||
# @BRIEF Validate dashboard detail returns 404 when the requested environment is missing.
|
||||
# @TEST: GET /api/dashboards/{id} returns 404 for missing environment
|
||||
def test_get_dashboard_detail_env_not_found(mock_deps):
|
||||
@@ -259,9 +259,9 @@ def test_get_dashboard_detail_env_not_found(mock_deps):
|
||||
response = client.get("/api/dashboards/42?env_id=missing")
|
||||
assert response.status_code == 404
|
||||
assert "Environment not found" in response.json()["detail"]
|
||||
# #endregion test_get_dashboard_detail_env_not_found
|
||||
# #region test_migrate_dashboards_success [TYPE Function]
|
||||
# @RELATION BINDS_TO -> DashboardsApiTests
|
||||
# #endregion Test.Tests.TestGetDashboardDetailEnvNotFound
|
||||
# #region Test.Tests.TestMigrateDashboardsSuccess [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
|
||||
# @TEST: POST /api/dashboards/migrate creates migration task
|
||||
# @PRE Valid source_env_id, target_env_id, dashboard_ids
|
||||
# @BRIEF Validate dashboard migration request creates an async task and returns its identifier.
|
||||
@@ -289,9 +289,9 @@ def test_migrate_dashboards_success(mock_deps):
|
||||
assert "task_id" in data
|
||||
# @POST/@SIDE_EFFECT: create_task was called
|
||||
mock_deps["task"].create_task.assert_called_once()
|
||||
# #endregion test_migrate_dashboards_success
|
||||
# #region test_migrate_dashboards_no_ids [TYPE Function]
|
||||
# @RELATION BINDS_TO -> DashboardsApiTests
|
||||
# #endregion Test.Tests.TestMigrateDashboardsSuccess
|
||||
# #region Test.Tests.TestMigrateDashboardsNoIds [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
|
||||
# @TEST: POST /api/dashboards/migrate returns 400 for empty dashboard_ids
|
||||
# @PRE dashboard_ids is empty
|
||||
# @BRIEF Validate dashboard migration rejects empty dashboard identifier lists.
|
||||
@@ -307,9 +307,9 @@ def test_migrate_dashboards_no_ids(mock_deps):
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "At least one dashboard ID must be provided" in response.json()["detail"]
|
||||
# #endregion test_migrate_dashboards_no_ids
|
||||
# #region test_migrate_dashboards_env_not_found [TYPE Function]
|
||||
# @RELATION BINDS_TO -> DashboardsApiTests
|
||||
# #endregion Test.Tests.TestMigrateDashboardsNoIds
|
||||
# #region Test.Tests.TestMigrateDashboardsEnvNotFound [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
|
||||
# @BRIEF Validate migration creation returns 404 when the source environment cannot be resolved.
|
||||
# @PRE source_env_id and target_env_id are valid environment IDs
|
||||
def test_migrate_dashboards_env_not_found(mock_deps):
|
||||
@@ -321,9 +321,9 @@ def test_migrate_dashboards_env_not_found(mock_deps):
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "Source environment not found" in response.json()["detail"]
|
||||
# #endregion test_migrate_dashboards_env_not_found
|
||||
# #region test_backup_dashboards_success [TYPE Function]
|
||||
# @RELATION BINDS_TO -> DashboardsApiTests
|
||||
# #endregion Test.Tests.TestMigrateDashboardsEnvNotFound
|
||||
# #region Test.Tests.TestBackupDashboardsSuccess [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
|
||||
# @TEST: POST /api/dashboards/backup creates backup task
|
||||
# @PRE Valid env_id, dashboard_ids
|
||||
# @BRIEF Validate dashboard backup request creates an async backup task and returns its identifier.
|
||||
@@ -344,9 +344,9 @@ def test_backup_dashboards_success(mock_deps):
|
||||
assert "task_id" in data
|
||||
# @POST/@SIDE_EFFECT: create_task was called
|
||||
mock_deps["task"].create_task.assert_called_once()
|
||||
# #endregion test_backup_dashboards_success
|
||||
# #region test_backup_dashboards_env_not_found [TYPE Function]
|
||||
# @RELATION BINDS_TO -> DashboardsApiTests
|
||||
# #endregion Test.Tests.TestBackupDashboardsSuccess
|
||||
# #region Test.Tests.TestBackupDashboardsEnvNotFound [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
|
||||
# @BRIEF Validate backup task creation returns 404 when the target environment is missing.
|
||||
# @PRE env_id is a valid environment ID
|
||||
def test_backup_dashboards_env_not_found(mock_deps):
|
||||
@@ -357,9 +357,9 @@ def test_backup_dashboards_env_not_found(mock_deps):
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "Environment not found" in response.json()["detail"]
|
||||
# #endregion test_backup_dashboards_env_not_found
|
||||
# #region test_get_database_mappings_success [TYPE Function]
|
||||
# @RELATION BINDS_TO -> DashboardsApiTests
|
||||
# #endregion Test.Tests.TestBackupDashboardsEnvNotFound
|
||||
# #region Test.Tests.TestGetDatabaseMappingsSuccess [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
|
||||
# @TEST: GET /api/dashboards/db-mappings returns mapping suggestions
|
||||
# @PRE Valid source_env_id, target_env_id
|
||||
# @BRIEF Validate database mapping suggestions are returned for valid source and target environments.
|
||||
@@ -389,9 +389,9 @@ def test_get_database_mappings_success(mock_deps):
|
||||
assert "mappings" in data
|
||||
assert len(data["mappings"]) == 1
|
||||
assert data["mappings"][0]["confidence"] == 0.95
|
||||
# #endregion test_get_database_mappings_success
|
||||
# #region test_get_database_mappings_env_not_found [TYPE Function]
|
||||
# @RELATION BINDS_TO -> DashboardsApiTests
|
||||
# #endregion Test.Tests.TestGetDatabaseMappingsSuccess
|
||||
# #region Test.Tests.TestGetDatabaseMappingsEnvNotFound [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
|
||||
# @BRIEF Validate database mapping suggestions return 404 when either environment is missing.
|
||||
# @PRE source_env_id and target_env_id are valid environment IDs
|
||||
def test_get_database_mappings_env_not_found(mock_deps):
|
||||
@@ -401,9 +401,9 @@ def test_get_database_mappings_env_not_found(mock_deps):
|
||||
"/api/dashboards/db-mappings?source_env_id=ghost&target_env_id=t"
|
||||
)
|
||||
assert response.status_code == 404
|
||||
# #endregion test_get_database_mappings_env_not_found
|
||||
# #region test_get_dashboard_tasks_history_filters_success [TYPE Function]
|
||||
# @RELATION BINDS_TO -> DashboardsApiTests
|
||||
# #endregion Test.Tests.TestGetDatabaseMappingsEnvNotFound
|
||||
# #region Test.Tests.TestGetDashboardTasksHistoryFiltersSuccess [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
|
||||
# @BRIEF Validate dashboard task history returns only related backup and LLM tasks.
|
||||
# @TEST: GET /api/dashboards/{id}/tasks returns backup and llm tasks for dashboard
|
||||
def test_get_dashboard_tasks_history_filters_success(mock_deps):
|
||||
@@ -442,9 +442,9 @@ def test_get_dashboard_tasks_history_filters_success(mock_deps):
|
||||
"llm_dashboard_validation",
|
||||
"superset-backup",
|
||||
}
|
||||
# #endregion test_get_dashboard_tasks_history_filters_success
|
||||
# #region test_get_dashboard_thumbnail_success [TYPE Function]
|
||||
# @RELATION BINDS_TO -> DashboardsApiTests
|
||||
# #endregion Test.Tests.TestGetDashboardTasksHistoryFiltersSuccess
|
||||
# #region Test.Tests.TestGetDashboardThumbnailSuccess [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
|
||||
# @BRIEF Validate dashboard thumbnail endpoint proxies image bytes and content type from Superset.
|
||||
# @TEST: GET /api/dashboards/{id}/thumbnail proxies image bytes from Superset
|
||||
def test_get_dashboard_thumbnail_success(mock_deps):
|
||||
@@ -467,9 +467,9 @@ def test_get_dashboard_thumbnail_success(mock_deps):
|
||||
assert response.status_code == 200
|
||||
assert response.content == b"fake-image-bytes"
|
||||
assert response.headers["content-type"].startswith("image/png")
|
||||
# #endregion test_get_dashboard_thumbnail_success
|
||||
# #region _build_profile_preference_stub [TYPE Function]
|
||||
# @RELATION BINDS_TO -> DashboardsApiTests
|
||||
# #endregion Test.Tests.TestGetDashboardThumbnailSuccess
|
||||
# #region Test.Tests.BuildProfilePreferenceStub [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
|
||||
# @BRIEF Creates profile preference payload stub for dashboards filter contract tests.
|
||||
# @PRE username can be empty; enabled indicates profile-default toggle state.
|
||||
# @POST Returns object compatible with ProfileService.get_my_preference contract.
|
||||
@@ -483,9 +483,9 @@ def _build_profile_preference_stub(username: str, enabled: bool):
|
||||
payload = MagicMock()
|
||||
payload.preference = preference
|
||||
return payload
|
||||
# #endregion _build_profile_preference_stub
|
||||
# #region _matches_actor_case_insensitive [TYPE Function]
|
||||
# @RELATION BINDS_TO -> DashboardsApiTests
|
||||
# #endregion Test.Tests.BuildProfilePreferenceStub
|
||||
# #region Test.Tests.MatchesActorCaseInsensitive [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
|
||||
# @BRIEF Applies trim + case-insensitive owners OR modified_by matching used by route contract tests.
|
||||
# @PRE owners can be None or list-like values.
|
||||
# @POST Returns True when bound username matches any owner or modified_by.
|
||||
@@ -502,9 +502,9 @@ def _matches_actor_case_insensitive(bound_username, owners, modified_by):
|
||||
return normalized_bound in owner_tokens or bool(
|
||||
modified_token and modified_token == normalized_bound
|
||||
)
|
||||
# #endregion _matches_actor_case_insensitive
|
||||
# #region test_get_dashboards_profile_filter_contract_owners_or_modified_by [TYPE Function]
|
||||
# @RELATION BINDS_TO -> DashboardsApiTests
|
||||
# #endregion Test.Tests.MatchesActorCaseInsensitive
|
||||
# #region Test.Tests.TestGetDashboardsProfileFilterContractOwnersOrModifiedBy [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
|
||||
# @TEST: GET /api/dashboards applies profile-default filter with owners OR modified_by trim+case-insensitive semantics.
|
||||
# @BRIEF Validate profile-default filtering matches owner and modifier aliases using normalized Superset actor values.
|
||||
# @PRE Current user has enabled profile-default preference and bound username.
|
||||
@@ -561,9 +561,9 @@ def test_get_dashboards_profile_filter_contract_owners_or_modified_by(mock_deps)
|
||||
assert payload["effective_profile_filter"]["override_show_all"] is False
|
||||
assert payload["effective_profile_filter"]["username"] == "john_doe"
|
||||
assert payload["effective_profile_filter"]["match_logic"] == "owners_or_modified_by"
|
||||
# #endregion test_get_dashboards_profile_filter_contract_owners_or_modified_by
|
||||
# #region test_get_dashboards_override_show_all_contract [TYPE Function]
|
||||
# @RELATION BINDS_TO -> DashboardsApiTests
|
||||
# #endregion Test.Tests.TestGetDashboardsProfileFilterContractOwnersOrModifiedBy
|
||||
# #region Test.Tests.TestGetDashboardsOverrideShowAllContract [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
|
||||
# @TEST: GET /api/dashboards honors override_show_all and disables profile-default filter for current page.
|
||||
# @BRIEF Validate override_show_all bypasses profile-default filtering without changing dashboard list semantics.
|
||||
# @PRE Profile-default preference exists but override_show_all=true query is provided.
|
||||
@@ -614,9 +614,9 @@ def test_get_dashboards_override_show_all_contract(mock_deps):
|
||||
assert payload["effective_profile_filter"]["username"] is None
|
||||
assert payload["effective_profile_filter"]["match_logic"] is None
|
||||
profile_service.matches_dashboard_actor.assert_not_called()
|
||||
# #endregion test_get_dashboards_override_show_all_contract
|
||||
# #region test_get_dashboards_profile_filter_no_match_results_contract [TYPE Function]
|
||||
# @RELATION BINDS_TO -> DashboardsApiTests
|
||||
# #endregion Test.Tests.TestGetDashboardsOverrideShowAllContract
|
||||
# #region Test.Tests.TestGetDashboardsProfileFilterNoMatchResultsContract [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
|
||||
# @TEST: GET /api/dashboards returns empty result set when profile-default filter is active and no dashboard actors match.
|
||||
# @BRIEF Validate profile-default filtering returns an empty dashboard page when no actor aliases match the bound user.
|
||||
# @PRE Profile-default preference is enabled with bound username and all dashboards are non-matching.
|
||||
@@ -669,9 +669,9 @@ def test_get_dashboards_profile_filter_no_match_results_contract(mock_deps):
|
||||
assert payload["effective_profile_filter"]["override_show_all"] is False
|
||||
assert payload["effective_profile_filter"]["username"] == "john_doe"
|
||||
assert payload["effective_profile_filter"]["match_logic"] == "owners_or_modified_by"
|
||||
# #endregion test_get_dashboards_profile_filter_no_match_results_contract
|
||||
# #region test_get_dashboards_page_context_other_disables_profile_default [TYPE Function]
|
||||
# @RELATION BINDS_TO -> DashboardsApiTests
|
||||
# #endregion Test.Tests.TestGetDashboardsProfileFilterNoMatchResultsContract
|
||||
# #region Test.Tests.TestGetDashboardsPageContextOtherDisablesProfileDefault [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
|
||||
# @TEST: GET /api/dashboards does not auto-apply profile-default filter outside dashboards_main page context.
|
||||
# @BRIEF Validate non-dashboard page contexts suppress profile-default filtering and preserve unfiltered results.
|
||||
# @PRE Profile-default preference exists but page_context=other query is provided.
|
||||
@@ -722,9 +722,9 @@ def test_get_dashboards_page_context_other_disables_profile_default(mock_deps):
|
||||
assert payload["effective_profile_filter"]["username"] is None
|
||||
assert payload["effective_profile_filter"]["match_logic"] is None
|
||||
profile_service.matches_dashboard_actor.assert_not_called()
|
||||
# #endregion test_get_dashboards_page_context_other_disables_profile_default
|
||||
# #region test_get_dashboards_profile_filter_matches_display_alias_without_detail_fanout [TYPE Function]
|
||||
# @RELATION BINDS_TO -> DashboardsApiTests
|
||||
# #endregion Test.Tests.TestGetDashboardsPageContextOtherDisablesProfileDefault
|
||||
# #region Test.Tests.TestGetDashboardsProfileFilterMatchesDisplayAliasWithoutDetailFanout [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
|
||||
# @TEST: GET /api/dashboards resolves Superset display-name alias once and filters without per-dashboard detail calls.
|
||||
# @BRIEF Validate profile-default filtering reuses resolved Superset display aliases without triggering per-dashboard detail fanout.
|
||||
# @PRE Profile-default filter is active, bound username is `admin`, dashboard actors contain display labels.
|
||||
@@ -798,9 +798,9 @@ def test_get_dashboards_profile_filter_matches_display_alias_without_detail_fano
|
||||
assert payload["effective_profile_filter"]["applied"] is True
|
||||
lookup_adapter.get_users_page.assert_called_once()
|
||||
superset_client.get_dashboard.assert_not_called()
|
||||
# #endregion test_get_dashboards_profile_filter_matches_display_alias_without_detail_fanout
|
||||
# #region test_get_dashboards_profile_filter_matches_owner_object_payload_contract [TYPE Function]
|
||||
# @RELATION BINDS_TO -> DashboardsApiTests
|
||||
# #endregion Test.Tests.TestGetDashboardsProfileFilterMatchesDisplayAliasWithoutDetailFanout
|
||||
# #region Test.Tests.TestGetDashboardsProfileFilterMatchesOwnerObjectPayloadContract [TYPE Function]
|
||||
# @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
|
||||
# @TEST: GET /api/dashboards profile-default filter matches Superset owner object payloads.
|
||||
# @BRIEF Validate profile-default filtering accepts owner object payloads once aliases resolve to the bound Superset username.
|
||||
# @PRE Profile-default preference is enabled and owners list contains dict payloads.
|
||||
@@ -873,5 +873,5 @@ def test_get_dashboards_profile_filter_matches_owner_object_payload_contract(moc
|
||||
assert payload["total"] == 1
|
||||
assert {item["id"] for item in payload["dashboards"]} == {701}
|
||||
assert payload["dashboards"][0]["title"] == "Featured Charts"
|
||||
# #endregion test_get_dashboards_profile_filter_matches_owner_object_payload_contract
|
||||
# #endregion DashboardsApiTests
|
||||
# #endregion Test.Tests.TestGetDashboardsProfileFilterMatchesOwnerObjectPayloadContract
|
||||
# #endregion Test.Tests.DashboardsApiTests
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# #region DatasetsApiTests [TYPE Module] [C:3] [SEMANTICS datasets, api, tests, pagination, mapping, docs]
|
||||
# #region Test.Tests.DatasetsApiTests [TYPE Module] [C:3] [SEMANTICS datasets, api, tests, pagination, mapping, docs]
|
||||
# @BRIEF Unit tests for datasets API endpoints.
|
||||
# @LAYER API
|
||||
# @RELATION DEPENDS_ON -> [DatasetsApi]
|
||||
# @RELATION DEPENDS_ON -> [Api.Datasets.DatasetsApi]
|
||||
# @INVARIANT Endpoint contracts remain stable for success and validation failure paths.
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
@@ -61,8 +61,8 @@ def mock_deps():
|
||||
}
|
||||
app.dependency_overrides.clear()
|
||||
client = TestClient(app)
|
||||
# #region test_get_datasets_success [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [DatasetsApiTests]
|
||||
# #region Test.Tests.TestGetDatasetsSuccess [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.DatasetsApiTests]
|
||||
# @BRIEF Validate successful datasets listing contract for an existing environment.
|
||||
# @TEST: GET /api/datasets returns 200 and valid schema
|
||||
# @PRE env_id exists
|
||||
@@ -92,9 +92,9 @@ def test_get_datasets_success(mock_deps):
|
||||
assert len(data["datasets"]) >= 0
|
||||
# Validate against Pydantic model
|
||||
DatasetsResponse(**data)
|
||||
# #endregion test_get_datasets_success
|
||||
# #region test_get_datasets_env_not_found [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [DatasetsApiTests]
|
||||
# #endregion Test.Tests.TestGetDatasetsSuccess
|
||||
# #region Test.Tests.TestGetDatasetsEnvNotFound [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.DatasetsApiTests]
|
||||
# @BRIEF Validate datasets listing returns 404 when the requested environment does not exist.
|
||||
# @TEST: GET /api/datasets returns 404 if env_id missing
|
||||
# @PRE env_id does not exist
|
||||
@@ -104,9 +104,9 @@ def test_get_datasets_env_not_found(mock_deps):
|
||||
response = client.get("/api/datasets?env_id=nonexistent")
|
||||
assert response.status_code == 404
|
||||
assert "Environment not found" in response.json()["detail"]
|
||||
# #endregion test_get_datasets_env_not_found
|
||||
# #region test_get_datasets_invalid_pagination [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [DatasetsApiTests]
|
||||
# #endregion Test.Tests.TestGetDatasetsEnvNotFound
|
||||
# #region Test.Tests.TestGetDatasetsInvalidPagination [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.DatasetsApiTests]
|
||||
# @BRIEF Validate datasets listing rejects invalid pagination parameters with 400 responses.
|
||||
# @TEST: GET /api/datasets returns 400 for invalid page/page_size
|
||||
# @PRE page < 1 or page_size > 100
|
||||
@@ -127,9 +127,9 @@ def test_get_datasets_invalid_pagination(mock_deps):
|
||||
response = client.get("/api/datasets?env_id=prod&page_size=101")
|
||||
assert response.status_code == 400
|
||||
assert "Page size must be between 1 and 100" in response.json()["detail"]
|
||||
# #endregion test_get_datasets_invalid_pagination
|
||||
# #region test_map_columns_success [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [DatasetsApiTests]
|
||||
# #endregion Test.Tests.TestGetDatasetsInvalidPagination
|
||||
# #region Test.Tests.TestMapColumnsSuccess [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.DatasetsApiTests]
|
||||
# @BRIEF Validate map-columns request creates an async mapping task and returns its identifier.
|
||||
# @TEST: POST /api/datasets/map-columns creates mapping task
|
||||
# @PRE Valid env_id, dataset_ids, source_type (sqllab)
|
||||
@@ -152,9 +152,9 @@ def test_map_columns_success(mock_deps):
|
||||
assert "task_id" in data
|
||||
# @POST/@SIDE_EFFECT: create_task was called
|
||||
mock_deps["task"].create_task.assert_called_once()
|
||||
# #endregion test_map_columns_success
|
||||
# #region test_map_columns_invalid_source_type [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [DatasetsApiTests]
|
||||
# #endregion Test.Tests.TestMapColumnsSuccess
|
||||
# #region Test.Tests.TestMapColumnsInvalidSourceType [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.DatasetsApiTests]
|
||||
# @BRIEF Validate map-columns rejects unsupported source types with a 400 contract response.
|
||||
# @TEST: POST /api/datasets/map-columns returns 400 for invalid source_type
|
||||
# @PRE source_type is not 'sqllab' or 'xlsx'
|
||||
@@ -166,9 +166,9 @@ def test_map_columns_invalid_source_type(mock_deps):
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "Source type must be 'sqllab' or 'xlsx'" in response.json()["detail"]
|
||||
# #endregion test_map_columns_invalid_source_type
|
||||
# #region test_generate_docs_success [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [DatasetsApiTests]
|
||||
# #endregion Test.Tests.TestMapColumnsInvalidSourceType
|
||||
# #region Test.Tests.TestGenerateDocsSuccess [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.DatasetsApiTests]
|
||||
# @TEST: POST /api/datasets/generate-docs creates doc generation task
|
||||
# @PRE Valid env_id, dataset_ids, llm_provider
|
||||
# @BRIEF Validate generate-docs request creates an async documentation task and returns its identifier.
|
||||
@@ -191,9 +191,9 @@ def test_generate_docs_success(mock_deps):
|
||||
assert "task_id" in data
|
||||
# @POST/@SIDE_EFFECT: create_task was called
|
||||
mock_deps["task"].create_task.assert_called_once()
|
||||
# #endregion test_generate_docs_success
|
||||
# #region test_map_columns_empty_ids [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [DatasetsApiTests]
|
||||
# #endregion Test.Tests.TestGenerateDocsSuccess
|
||||
# #region Test.Tests.TestMapColumnsEmptyIds [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.DatasetsApiTests]
|
||||
# @BRIEF Validate map-columns rejects empty dataset identifier lists.
|
||||
# @TEST: POST /api/datasets/map-columns returns 400 for empty dataset_ids
|
||||
# @PRE dataset_ids is empty
|
||||
@@ -206,9 +206,9 @@ def test_map_columns_empty_ids(mock_deps):
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "At least one dataset ID must be provided" in response.json()["detail"]
|
||||
# #endregion test_map_columns_empty_ids
|
||||
# #region test_map_columns_missing_database_id [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [DatasetsApiTests]
|
||||
# #endregion Test.Tests.TestMapColumnsEmptyIds
|
||||
# #region Test.Tests.TestMapColumnsMissingDatabaseId [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.DatasetsApiTests]
|
||||
# @BRIEF Validate map-columns rejects sqllab source without database_id.
|
||||
# @TEST: POST /api/datasets/map-columns returns 400 for sqllab without database_id
|
||||
# @POST Returns 400 error
|
||||
@@ -219,9 +219,9 @@ def test_map_columns_missing_database_id(mock_deps):
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "database_id is required" in response.json()["detail"]
|
||||
# #endregion test_map_columns_missing_database_id
|
||||
# #region test_generate_docs_empty_ids [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [DatasetsApiTests]
|
||||
# #endregion Test.Tests.TestMapColumnsMissingDatabaseId
|
||||
# #region Test.Tests.TestGenerateDocsEmptyIds [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.DatasetsApiTests]
|
||||
# @BRIEF Validate generate-docs rejects empty dataset identifier lists.
|
||||
# @TEST: POST /api/datasets/generate-docs returns 400 for empty dataset_ids
|
||||
# @PRE dataset_ids is empty
|
||||
@@ -234,9 +234,9 @@ def test_generate_docs_empty_ids(mock_deps):
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "At least one dataset ID must be provided" in response.json()["detail"]
|
||||
# #endregion test_generate_docs_empty_ids
|
||||
# #region test_generate_docs_env_not_found [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [DatasetsApiTests]
|
||||
# #endregion Test.Tests.TestGenerateDocsEmptyIds
|
||||
# #region Test.Tests.TestGenerateDocsEnvNotFound [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.DatasetsApiTests]
|
||||
# @TEST: POST /api/datasets/generate-docs returns 404 for missing env
|
||||
# @PRE env_id does not exist
|
||||
# @BRIEF Validate generate-docs returns 404 when the requested environment cannot be resolved.
|
||||
@@ -250,9 +250,9 @@ def test_generate_docs_env_not_found(mock_deps):
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "Environment not found" in response.json()["detail"]
|
||||
# #endregion test_generate_docs_env_not_found
|
||||
# #region test_get_datasets_superset_failure [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [DatasetsApiTests]
|
||||
# #endregion Test.Tests.TestGenerateDocsEnvNotFound
|
||||
# #region Test.Tests.TestGetDatasetsSupersetFailure [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [Test.Tests.DatasetsApiTests]
|
||||
# @BRIEF Validate datasets listing surfaces a 503 contract when Superset access fails.
|
||||
# @TEST_EDGE external_superset_failure -> {status: 503}
|
||||
# @POST Returns 503 with stable error detail when upstream dataset fetch fails.
|
||||
@@ -268,5 +268,5 @@ def test_get_datasets_superset_failure(mock_deps):
|
||||
response = client.get("/api/datasets?env_id=bad_conn")
|
||||
assert response.status_code == 503
|
||||
assert "Failed to fetch datasets" in response.json()["detail"]
|
||||
# #endregion test_get_datasets_superset_failure
|
||||
# #endregion DatasetsApiTests
|
||||
# #endregion Test.Tests.TestGetDatasetsSupersetFailure
|
||||
# #endregion Test.Tests.DatasetsApiTests
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user