chore(semantic): checkpoint remediation progress

This commit is contained in:
2026-03-15 21:08:00 +03:00
parent 15d3141aef
commit 84a2cd5429
25 changed files with 1935 additions and 1559 deletions

View File

@@ -1,118 +1,125 @@
# [DEF:backend.src.api.auth:Module] # [DEF:backend.src.api.auth:Module]
# #
# @SEMANTICS: api, auth, routes, login, logout # @TIER: STANDARD
# @PURPOSE: Authentication API endpoints. # @SEMANTICS: api, auth, routes, login, logout
# @LAYER: API # @PURPOSE: Authentication API endpoints.
# @RELATION: USES -> backend.src.services.auth_service.AuthService # @LAYER: API
# @RELATION: USES -> backend.src.core.database.get_auth_db # @RELATION: USES ->[backend.src.services.auth_service.AuthService]
# # @RELATION: USES ->[backend.src.core.database.get_auth_db]
# @INVARIANT: All auth endpoints must return consistent error codes. #
# @INVARIANT: All auth endpoints must return consistent error codes.
# [SECTION: IMPORTS]
from fastapi import APIRouter, Depends, HTTPException, status # [SECTION: IMPORTS]
from fastapi.security import OAuth2PasswordRequestForm from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from fastapi.security import OAuth2PasswordRequestForm
from ..core.database import get_auth_db from sqlalchemy.orm import Session
from ..services.auth_service import AuthService from ..core.database import get_auth_db
from ..schemas.auth import Token, User as UserSchema from ..services.auth_service import AuthService
from ..dependencies import get_current_user from ..schemas.auth import Token, User as UserSchema
from ..core.auth.oauth import oauth, is_adfs_configured from ..dependencies import get_current_user
from ..core.auth.logger import log_security_event from ..core.auth.oauth import oauth, is_adfs_configured
from ..core.logger import belief_scope from ..core.auth.logger import log_security_event
import starlette.requests from ..core.logger import belief_scope
# [/SECTION] import starlette.requests
# [/SECTION]
# [DEF:router:Variable]
# @PURPOSE: APIRouter instance for authentication routes. # [DEF:router:Variable]
router = APIRouter(prefix="/api/auth", tags=["auth"]) # @TIER: TRIVIAL
# [/DEF:router:Variable] # @PURPOSE: APIRouter instance for authentication routes.
router = APIRouter(prefix="/api/auth", tags=["auth"])
# [DEF:login_for_access_token:Function] # [/DEF:router:Variable]
# @PURPOSE: Authenticates a user and returns a JWT access token.
# @PRE: form_data contains username and password. # [DEF:login_for_access_token:Function]
# @POST: Returns a Token object on success. # @TIER: STANDARD
# @THROW: HTTPException 401 if authentication fails. # @PURPOSE: Authenticates a user and returns a JWT access token.
# @PARAM: form_data (OAuth2PasswordRequestForm) - Login credentials. # @PRE: form_data contains username and password.
# @PARAM: db (Session) - Auth database session. # @POST: Returns a Token object on success.
# @RETURN: Token - The generated JWT token. # @THROW: HTTPException 401 if authentication fails.
@router.post("/login", response_model=Token) # @PARAM: form_data (OAuth2PasswordRequestForm) - Login credentials.
async def login_for_access_token( # @PARAM: db (Session) - Auth database session.
form_data: OAuth2PasswordRequestForm = Depends(), # @RETURN: Token - The generated JWT token.
db: Session = Depends(get_auth_db) @router.post("/login", response_model=Token)
): async def login_for_access_token(
with belief_scope("api.auth.login"): form_data: OAuth2PasswordRequestForm = Depends(),
auth_service = AuthService(db) db: Session = Depends(get_auth_db)
user = auth_service.authenticate_user(form_data.username, form_data.password) ):
if not user: with belief_scope("api.auth.login"):
log_security_event("LOGIN_FAILED", form_data.username, {"reason": "Invalid credentials"}) auth_service = AuthService(db)
raise HTTPException( user = auth_service.authenticate_user(form_data.username, form_data.password)
status_code=status.HTTP_401_UNAUTHORIZED, if not user:
detail="Incorrect username or password", log_security_event("LOGIN_FAILED", form_data.username, {"reason": "Invalid credentials"})
headers={"WWW-Authenticate": "Bearer"}, raise HTTPException(
) status_code=status.HTTP_401_UNAUTHORIZED,
log_security_event("LOGIN_SUCCESS", user.username, {"source": "LOCAL"}) detail="Incorrect username or password",
return auth_service.create_session(user) headers={"WWW-Authenticate": "Bearer"},
# [/DEF:login_for_access_token:Function] )
log_security_event("LOGIN_SUCCESS", user.username, {"source": "LOCAL"})
# [DEF:read_users_me:Function] return auth_service.create_session(user)
# @PURPOSE: Retrieves the profile of the currently authenticated user. # [/DEF:login_for_access_token:Function]
# @PRE: Valid JWT token provided.
# @POST: Returns the current user's data. # [DEF:read_users_me:Function]
# @PARAM: current_user (UserSchema) - The user extracted from the token. # @TIER: STANDARD
# @RETURN: UserSchema - The current user profile. # @PURPOSE: Retrieves the profile of the currently authenticated user.
@router.get("/me", response_model=UserSchema) # @PRE: Valid JWT token provided.
async def read_users_me(current_user: UserSchema = Depends(get_current_user)): # @POST: Returns the current user's data.
with belief_scope("api.auth.me"): # @PARAM: current_user (UserSchema) - The user extracted from the token.
return current_user # @RETURN: UserSchema - The current user profile.
# [/DEF:read_users_me:Function] @router.get("/me", response_model=UserSchema)
async def read_users_me(current_user: UserSchema = Depends(get_current_user)):
# [DEF:logout:Function] with belief_scope("api.auth.me"):
# @PURPOSE: Logs out the current user (placeholder for session revocation). return current_user
# @PRE: Valid JWT token provided. # [/DEF:read_users_me:Function]
# @POST: Returns success message.
@router.post("/logout") # [DEF:logout:Function]
async def logout(current_user: UserSchema = Depends(get_current_user)): # @TIER: STANDARD
with belief_scope("api.auth.logout"): # @PURPOSE: Logs out the current user (placeholder for session revocation).
log_security_event("LOGOUT", current_user.username) # @PRE: Valid JWT token provided.
# In a stateless JWT setup, client-side token deletion is primary. # @POST: Returns success message.
# Server-side revocation (blacklisting) can be added here if needed. @router.post("/logout")
return {"message": "Successfully logged out"} async def logout(current_user: UserSchema = Depends(get_current_user)):
# [/DEF:logout:Function] with belief_scope("api.auth.logout"):
log_security_event("LOGOUT", current_user.username)
# [DEF:login_adfs:Function] # In a stateless JWT setup, client-side token deletion is primary.
# @PURPOSE: Initiates the ADFS OIDC login flow. # Server-side revocation (blacklisting) can be added here if needed.
# @POST: Redirects the user to ADFS. return {"message": "Successfully logged out"}
@router.get("/login/adfs") # [/DEF:logout:Function]
async def login_adfs(request: starlette.requests.Request):
with belief_scope("api.auth.login_adfs"): # [DEF:login_adfs:Function]
if not is_adfs_configured(): # @TIER: STANDARD
raise HTTPException( # @PURPOSE: Initiates the ADFS OIDC login flow.
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, # @POST: Redirects the user to ADFS.
detail="ADFS is not configured. Please set ADFS_CLIENT_ID, ADFS_CLIENT_SECRET, and ADFS_METADATA_URL environment variables." @router.get("/login/adfs")
) async def login_adfs(request: starlette.requests.Request):
redirect_uri = request.url_for('auth_callback_adfs') with belief_scope("api.auth.login_adfs"):
return await oauth.adfs.authorize_redirect(request, str(redirect_uri)) if not is_adfs_configured():
# [/DEF:login_adfs:Function] raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
# [DEF:auth_callback_adfs:Function] detail="ADFS is not configured. Please set ADFS_CLIENT_ID, ADFS_CLIENT_SECRET, and ADFS_METADATA_URL environment variables."
# @PURPOSE: Handles the callback from ADFS after successful authentication. )
# @POST: Provisions user JIT and returns session token. redirect_uri = request.url_for('auth_callback_adfs')
@router.get("/callback/adfs", name="auth_callback_adfs") return await oauth.adfs.authorize_redirect(request, str(redirect_uri))
async def auth_callback_adfs(request: starlette.requests.Request, db: Session = Depends(get_auth_db)): # [/DEF:login_adfs:Function]
with belief_scope("api.auth.callback_adfs"):
if not is_adfs_configured(): # [DEF:auth_callback_adfs:Function]
raise HTTPException( # @TIER: STANDARD
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, # @PURPOSE: Handles the callback from ADFS after successful authentication.
detail="ADFS is not configured. Please set ADFS_CLIENT_ID, ADFS_CLIENT_SECRET, and ADFS_METADATA_URL environment variables." # @POST: Provisions user JIT and returns session token.
) @router.get("/callback/adfs", name="auth_callback_adfs")
token = await oauth.adfs.authorize_access_token(request) async def auth_callback_adfs(request: starlette.requests.Request, db: Session = Depends(get_auth_db)):
user_info = token.get('userinfo') with belief_scope("api.auth.callback_adfs"):
if not user_info: if not is_adfs_configured():
raise HTTPException(status_code=400, detail="Failed to retrieve user info from ADFS") raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
auth_service = AuthService(db) detail="ADFS is not configured. Please set ADFS_CLIENT_ID, ADFS_CLIENT_SECRET, and ADFS_METADATA_URL environment variables."
user = auth_service.provision_adfs_user(user_info) )
return auth_service.create_session(user) token = await oauth.adfs.authorize_access_token(request)
# [/DEF:auth_callback_adfs:Function] user_info = token.get('userinfo')
if not user_info:
raise HTTPException(status_code=400, detail="Failed to retrieve user info from ADFS")
auth_service = AuthService(db)
user = auth_service.provision_adfs_user(user_info)
return auth_service.create_session(user)
# [/DEF:auth_callback_adfs:Function]
# [/DEF:backend.src.api.auth:Module] # [/DEF:backend.src.api.auth:Module]

View File

@@ -4,8 +4,8 @@
# @SEMANTICS: api, admin, users, roles, permissions # @SEMANTICS: api, admin, users, roles, permissions
# @PURPOSE: Admin API endpoints for user and role management. # @PURPOSE: Admin API endpoints for user and role management.
# @LAYER: API # @LAYER: API
# @RELATION: USES -> backend.src.core.auth.repository.AuthRepository # @RELATION: [USES] ->[backend.src.core.auth.repository.AuthRepository]
# @RELATION: USES -> backend.src.dependencies.has_permission # @RELATION: [USES] ->[backend.src.dependencies.has_permission]
# #
# @INVARIANT: All endpoints in this module require 'Admin' role or 'admin' scope. # @INVARIANT: All endpoints in this module require 'Admin' role or 'admin' scope.
@@ -36,6 +36,7 @@ router = APIRouter(prefix="/api/admin", tags=["admin"])
# [/DEF:router:Variable] # [/DEF:router:Variable]
# [DEF:list_users:Function] # [DEF:list_users:Function]
# @TIER: STANDARD
# @PURPOSE: Lists all registered users. # @PURPOSE: Lists all registered users.
# @PRE: Current user has 'Admin' role. # @PRE: Current user has 'Admin' role.
# @POST: Returns a list of UserSchema objects. # @POST: Returns a list of UserSchema objects.
@@ -52,6 +53,7 @@ async def list_users(
# [/DEF:list_users:Function] # [/DEF:list_users:Function]
# [DEF:create_user:Function] # [DEF:create_user:Function]
# @TIER: STANDARD
# @PURPOSE: Creates a new local user. # @PURPOSE: Creates a new local user.
# @PRE: Current user has 'Admin' role. # @PRE: Current user has 'Admin' role.
# @POST: New user is created in the database. # @POST: New user is created in the database.
@@ -89,6 +91,7 @@ async def create_user(
# [/DEF:create_user:Function] # [/DEF:create_user:Function]
# [DEF:update_user:Function] # [DEF:update_user:Function]
# @TIER: STANDARD
# @PURPOSE: Updates an existing user. # @PURPOSE: Updates an existing user.
@router.put("/users/{user_id}", response_model=UserSchema) @router.put("/users/{user_id}", response_model=UserSchema)
async def update_user( async def update_user(
@@ -123,6 +126,7 @@ async def update_user(
# [/DEF:update_user:Function] # [/DEF:update_user:Function]
# [DEF:delete_user:Function] # [DEF:delete_user:Function]
# @TIER: STANDARD
# @PURPOSE: Deletes a user. # @PURPOSE: Deletes a user.
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user( async def delete_user(
@@ -146,6 +150,7 @@ async def delete_user(
# [/DEF:delete_user:Function] # [/DEF:delete_user:Function]
# [DEF:list_roles:Function] # [DEF:list_roles:Function]
# @TIER: STANDARD
# @PURPOSE: Lists all available roles. # @PURPOSE: Lists all available roles.
# @RETURN: List[RoleSchema] - List of roles. # @RETURN: List[RoleSchema] - List of roles.
# @RELATION: CALLS -> backend.src.models.auth.Role # @RELATION: CALLS -> backend.src.models.auth.Role
@@ -159,6 +164,7 @@ async def list_roles(
# [/DEF:list_roles:Function] # [/DEF:list_roles:Function]
# [DEF:create_role:Function] # [DEF:create_role:Function]
# @TIER: STANDARD
# @PURPOSE: Creates a new system role with associated permissions. # @PURPOSE: Creates a new system role with associated permissions.
# @PRE: Role name must be unique. # @PRE: Role name must be unique.
# @POST: New Role record is created in auth.db. # @POST: New Role record is created in auth.db.
@@ -196,6 +202,7 @@ async def create_role(
# [/DEF:create_role:Function] # [/DEF:create_role:Function]
# [DEF:update_role:Function] # [DEF:update_role:Function]
# @TIER: STANDARD
# @PURPOSE: Updates an existing role's metadata and permissions. # @PURPOSE: Updates an existing role's metadata and permissions.
# @PRE: role_id must be a valid existing role UUID. # @PRE: role_id must be a valid existing role UUID.
# @POST: Role record is updated in auth.db. # @POST: Role record is updated in auth.db.
@@ -240,6 +247,7 @@ async def update_role(
# [/DEF:update_role:Function] # [/DEF:update_role:Function]
# [DEF:delete_role:Function] # [DEF:delete_role:Function]
# @TIER: STANDARD
# @PURPOSE: Removes a role from the system. # @PURPOSE: Removes a role from the system.
# @PRE: role_id must be a valid existing role UUID. # @PRE: role_id must be a valid existing role UUID.
# @POST: Role record is removed from auth.db. # @POST: Role record is removed from auth.db.
@@ -266,6 +274,7 @@ async def delete_role(
# [/DEF:delete_role:Function] # [/DEF:delete_role:Function]
# [DEF:list_permissions:Function] # [DEF:list_permissions:Function]
# @TIER: STANDARD
# @PURPOSE: Lists all available system permissions for assignment. # @PURPOSE: Lists all available system permissions for assignment.
# @POST: Returns a list of all PermissionSchema objects. # @POST: Returns a list of all PermissionSchema objects.
# @PARAM: db (Session) - Auth database session. # @PARAM: db (Session) - Auth database session.
@@ -291,6 +300,7 @@ async def list_permissions(
# [/DEF:list_permissions:Function] # [/DEF:list_permissions:Function]
# [DEF:list_ad_mappings:Function] # [DEF:list_ad_mappings:Function]
# @TIER: STANDARD
# @PURPOSE: Lists all AD Group to Role mappings. # @PURPOSE: Lists all AD Group to Role mappings.
@router.get("/ad-mappings", response_model=List[ADGroupMappingSchema]) @router.get("/ad-mappings", response_model=List[ADGroupMappingSchema])
async def list_ad_mappings( async def list_ad_mappings(
@@ -302,6 +312,7 @@ async def list_ad_mappings(
# [/DEF:list_ad_mappings:Function] # [/DEF:list_ad_mappings:Function]
# [DEF:create_ad_mapping:Function] # [DEF:create_ad_mapping:Function]
# @TIER: STANDARD
# @PURPOSE: Creates a new AD Group mapping. # @PURPOSE: Creates a new AD Group mapping.
@router.post("/ad-mappings", response_model=ADGroupMappingSchema) @router.post("/ad-mappings", response_model=ADGroupMappingSchema)
async def create_ad_mapping( async def create_ad_mapping(

View File

@@ -3,8 +3,8 @@
# @SEMANTICS: api, assistant, chat, command, confirmation # @SEMANTICS: api, assistant, chat, command, confirmation
# @PURPOSE: API routes for LLM assistant command parsing and safe execution orchestration. # @PURPOSE: API routes for LLM assistant command parsing and safe execution orchestration.
# @LAYER: API # @LAYER: API
# @RELATION: DEPENDS_ON -> backend.src.core.task_manager # @RELATION: [DEPENDS_ON] ->[backend.src.core.task_manager.manager.TaskManager]
# @RELATION: DEPENDS_ON -> backend.src.models.assistant # @RELATION: [DEPENDS_ON] ->[backend.src.models.assistant]
# @INVARIANT: Risky operations are never executed without valid confirmation token. # @INVARIANT: Risky operations are never executed without valid confirmation token.
from __future__ import annotations from __future__ import annotations
@@ -125,6 +125,7 @@ INTENT_PERMISSION_CHECKS: Dict[str, List[Tuple[str, str]]] = {
# [DEF:_append_history:Function] # [DEF:_append_history:Function]
# @TIER: STANDARD
# @PURPOSE: Append conversation message to in-memory history buffer. # @PURPOSE: Append conversation message to in-memory history buffer.
# @PRE: user_id and conversation_id identify target conversation bucket. # @PRE: user_id and conversation_id identify target conversation bucket.
# @POST: Message entry is appended to CONVERSATIONS key list. # @POST: Message entry is appended to CONVERSATIONS key list.
@@ -156,6 +157,7 @@ def _append_history(
# [DEF:_persist_message:Function] # [DEF:_persist_message:Function]
# @TIER: STANDARD
# @PURPOSE: Persist assistant/user message record to database. # @PURPOSE: Persist assistant/user message record to database.
# @PRE: db session is writable and message payload is serializable. # @PRE: db session is writable and message payload is serializable.
# @POST: Message row is committed or persistence failure is logged. # @POST: Message row is committed or persistence failure is logged.
@@ -191,6 +193,7 @@ def _persist_message(
# [DEF:_audit:Function] # [DEF:_audit:Function]
# @TIER: STANDARD
# @PURPOSE: Append in-memory audit record for assistant decision trace. # @PURPOSE: Append in-memory audit record for assistant decision trace.
# @PRE: payload describes decision/outcome fields. # @PRE: payload describes decision/outcome fields.
# @POST: ASSISTANT_AUDIT list for user contains new timestamped entry. # @POST: ASSISTANT_AUDIT list for user contains new timestamped entry.
@@ -203,6 +206,7 @@ def _audit(user_id: str, payload: Dict[str, Any]):
# [DEF:_persist_audit:Function] # [DEF:_persist_audit:Function]
# @TIER: STANDARD
# @PURPOSE: Persist structured assistant audit payload in database. # @PURPOSE: Persist structured assistant audit payload in database.
# @PRE: db session is writable and payload is JSON-serializable. # @PRE: db session is writable and payload is JSON-serializable.
# @POST: Audit row is committed or failure is logged with rollback. # @POST: Audit row is committed or failure is logged with rollback.
@@ -226,6 +230,7 @@ def _persist_audit(db: Session, user_id: str, payload: Dict[str, Any], conversat
# [DEF:_persist_confirmation:Function] # [DEF:_persist_confirmation:Function]
# @TIER: STANDARD
# @PURPOSE: Persist confirmation token record to database. # @PURPOSE: Persist confirmation token record to database.
# @PRE: record contains id/user/intent/dispatch/expiry fields. # @PRE: record contains id/user/intent/dispatch/expiry fields.
# @POST: Confirmation row exists in persistent storage. # @POST: Confirmation row exists in persistent storage.
@@ -251,6 +256,7 @@ def _persist_confirmation(db: Session, record: ConfirmationRecord):
# [DEF:_update_confirmation_state:Function] # [DEF:_update_confirmation_state:Function]
# @TIER: STANDARD
# @PURPOSE: Update persistent confirmation token lifecycle state. # @PURPOSE: Update persistent confirmation token lifecycle state.
# @PRE: confirmation_id references existing row. # @PRE: confirmation_id references existing row.
# @POST: State and consumed_at fields are updated when applicable. # @POST: State and consumed_at fields are updated when applicable.
@@ -270,6 +276,7 @@ def _update_confirmation_state(db: Session, confirmation_id: str, state: str):
# [DEF:_load_confirmation_from_db:Function] # [DEF:_load_confirmation_from_db:Function]
# @TIER: STANDARD
# @PURPOSE: Load confirmation token from database into in-memory model. # @PURPOSE: Load confirmation token from database into in-memory model.
# @PRE: confirmation_id may or may not exist in storage. # @PRE: confirmation_id may or may not exist in storage.
# @POST: Returns ConfirmationRecord when found, otherwise None. # @POST: Returns ConfirmationRecord when found, otherwise None.
@@ -295,6 +302,7 @@ def _load_confirmation_from_db(db: Session, confirmation_id: str) -> Optional[Co
# [DEF:_ensure_conversation:Function] # [DEF:_ensure_conversation:Function]
# @TIER: STANDARD
# @PURPOSE: Resolve active conversation id in memory or create a new one. # @PURPOSE: Resolve active conversation id in memory or create a new one.
# @PRE: user_id identifies current actor. # @PRE: user_id identifies current actor.
# @POST: Returns stable conversation id and updates USER_ACTIVE_CONVERSATION. # @POST: Returns stable conversation id and updates USER_ACTIVE_CONVERSATION.
@@ -314,6 +322,7 @@ def _ensure_conversation(user_id: str, conversation_id: Optional[str]) -> str:
# [DEF:_resolve_or_create_conversation:Function] # [DEF:_resolve_or_create_conversation:Function]
# @TIER: STANDARD
# @PURPOSE: Resolve active conversation using explicit id, memory cache, or persisted history. # @PURPOSE: Resolve active conversation using explicit id, memory cache, or persisted history.
# @PRE: user_id and db session are available. # @PRE: user_id and db session are available.
# @POST: Returns conversation id and updates USER_ACTIVE_CONVERSATION cache. # @POST: Returns conversation id and updates USER_ACTIVE_CONVERSATION cache.
@@ -343,6 +352,7 @@ def _resolve_or_create_conversation(user_id: str, conversation_id: Optional[str]
# [DEF:_cleanup_history_ttl:Function] # [DEF:_cleanup_history_ttl:Function]
# @TIER: STANDARD
# @PURPOSE: Enforce assistant message retention window by deleting expired rows and in-memory records. # @PURPOSE: Enforce assistant message retention window by deleting expired rows and in-memory records.
# @PRE: db session is available and user_id references current actor scope. # @PRE: db session is available and user_id references current actor scope.
# @POST: Messages older than ASSISTANT_MESSAGE_TTL_DAYS are removed from persistence and memory mirrors. # @POST: Messages older than ASSISTANT_MESSAGE_TTL_DAYS are removed from persistence and memory mirrors.
@@ -380,6 +390,7 @@ def _cleanup_history_ttl(db: Session, user_id: str):
# [DEF:_is_conversation_archived:Function] # [DEF:_is_conversation_archived:Function]
# @TIER: STANDARD
# @PURPOSE: Determine archived state for a conversation based on last update timestamp. # @PURPOSE: Determine archived state for a conversation based on last update timestamp.
# @PRE: updated_at can be null for empty conversations. # @PRE: updated_at can be null for empty conversations.
# @POST: Returns True when conversation inactivity exceeds archive threshold. # @POST: Returns True when conversation inactivity exceeds archive threshold.
@@ -392,6 +403,7 @@ def _is_conversation_archived(updated_at: Optional[datetime]) -> bool:
# [DEF:_coerce_query_bool:Function] # [DEF:_coerce_query_bool:Function]
# @TIER: STANDARD
# @PURPOSE: Normalize bool-like query values for compatibility in direct handler invocations/tests. # @PURPOSE: Normalize bool-like query values for compatibility in direct handler invocations/tests.
# @PRE: value may be bool, string, or FastAPI Query metadata object. # @PRE: value may be bool, string, or FastAPI Query metadata object.
# @POST: Returns deterministic boolean flag. # @POST: Returns deterministic boolean flag.
@@ -405,6 +417,7 @@ def _coerce_query_bool(value: Any) -> bool:
# [DEF:_extract_id:Function] # [DEF:_extract_id:Function]
# @TIER: STANDARD
# @PURPOSE: Extract first regex match group from text by ordered pattern list. # @PURPOSE: Extract first regex match group from text by ordered pattern list.
# @PRE: patterns contain at least one capture group. # @PRE: patterns contain at least one capture group.
# @POST: Returns first matched token or None. # @POST: Returns first matched token or None.
@@ -418,6 +431,7 @@ def _extract_id(text: str, patterns: List[str]) -> Optional[str]:
# [DEF:_resolve_env_id:Function] # [DEF:_resolve_env_id:Function]
# @TIER: STANDARD
# @PURPOSE: Resolve environment identifier/name token to canonical environment id. # @PURPOSE: Resolve environment identifier/name token to canonical environment id.
# @PRE: config_manager provides environment list. # @PRE: config_manager provides environment list.
# @POST: Returns matched environment id or None. # @POST: Returns matched environment id or None.
@@ -435,6 +449,7 @@ def _resolve_env_id(token: Optional[str], config_manager: ConfigManager) -> Opti
# [DEF:_is_production_env:Function] # [DEF:_is_production_env:Function]
# @TIER: STANDARD
# @PURPOSE: Determine whether environment token resolves to production-like target. # @PURPOSE: Determine whether environment token resolves to production-like target.
# @PRE: config_manager provides environments or token text is provided. # @PRE: config_manager provides environments or token text is provided.
# @POST: Returns True for production/prod synonyms, else False. # @POST: Returns True for production/prod synonyms, else False.
@@ -452,6 +467,7 @@ def _is_production_env(token: Optional[str], config_manager: ConfigManager) -> b
# [DEF:_resolve_provider_id:Function] # [DEF:_resolve_provider_id:Function]
# @TIER: STANDARD
# @PURPOSE: Resolve provider token to provider id with active/default fallback. # @PURPOSE: Resolve provider token to provider id with active/default fallback.
# @PRE: db session can load provider list through LLMProviderService. # @PRE: db session can load provider list through LLMProviderService.
# @POST: Returns provider id or None when no providers configured. # @POST: Returns provider id or None when no providers configured.
@@ -487,6 +503,7 @@ def _resolve_provider_id(
# [DEF:_get_default_environment_id:Function] # [DEF:_get_default_environment_id:Function]
# @TIER: STANDARD
# @PURPOSE: Resolve default environment id from settings or first configured environment. # @PURPOSE: Resolve default environment id from settings or first configured environment.
# @PRE: config_manager returns environments list. # @PRE: config_manager returns environments list.
# @POST: Returns default environment id or None when environment list is empty. # @POST: Returns default environment id or None when environment list is empty.
@@ -508,6 +525,7 @@ def _get_default_environment_id(config_manager: ConfigManager) -> Optional[str]:
# [DEF:_resolve_dashboard_id_by_ref:Function] # [DEF:_resolve_dashboard_id_by_ref:Function]
# @TIER: STANDARD
# @PURPOSE: Resolve dashboard id by title or slug reference in selected environment. # @PURPOSE: Resolve dashboard id by title or slug reference in selected environment.
# @PRE: dashboard_ref is a non-empty string-like token. # @PRE: dashboard_ref is a non-empty string-like token.
# @POST: Returns dashboard id when uniquely matched, otherwise None. # @POST: Returns dashboard id when uniquely matched, otherwise None.
@@ -550,6 +568,7 @@ def _resolve_dashboard_id_by_ref(
# [DEF:_resolve_dashboard_id_entity:Function] # [DEF:_resolve_dashboard_id_entity:Function]
# @TIER: STANDARD
# @PURPOSE: Resolve dashboard id from intent entities using numeric id or dashboard_ref fallback. # @PURPOSE: Resolve dashboard id from intent entities using numeric id or dashboard_ref fallback.
# @PRE: entities may contain dashboard_id as int/str and optional dashboard_ref. # @PRE: entities may contain dashboard_id as int/str and optional dashboard_ref.
# @POST: Returns resolved dashboard id or None when ambiguous/unresolvable. # @POST: Returns resolved dashboard id or None when ambiguous/unresolvable.
@@ -581,6 +600,7 @@ def _resolve_dashboard_id_entity(
# [DEF:_get_environment_name_by_id:Function] # [DEF:_get_environment_name_by_id:Function]
# @TIER: STANDARD
# @PURPOSE: Resolve human-readable environment name by id. # @PURPOSE: Resolve human-readable environment name by id.
# @PRE: environment id may be None. # @PRE: environment id may be None.
# @POST: Returns matching environment name or fallback id. # @POST: Returns matching environment name or fallback id.
@@ -593,6 +613,7 @@ def _get_environment_name_by_id(env_id: Optional[str], config_manager: ConfigMan
# [DEF:_extract_result_deep_links:Function] # [DEF:_extract_result_deep_links:Function]
# @TIER: STANDARD
# @PURPOSE: Build deep-link actions to verify task result from assistant chat. # @PURPOSE: Build deep-link actions to verify task result from assistant chat.
# @PRE: task object is available. # @PRE: task object is available.
# @POST: Returns zero or more assistant actions for dashboard open/diff. # @POST: Returns zero or more assistant actions for dashboard open/diff.
@@ -649,6 +670,7 @@ def _extract_result_deep_links(task: Any, config_manager: ConfigManager) -> List
# [DEF:_build_task_observability_summary:Function] # [DEF:_build_task_observability_summary:Function]
# @TIER: STANDARD
# @PURPOSE: Build compact textual summary for completed tasks to reduce "black box" effect. # @PURPOSE: Build compact textual summary for completed tasks to reduce "black box" effect.
# @PRE: task may contain plugin-specific result payload. # @PRE: task may contain plugin-specific result payload.
# @POST: Returns non-empty summary line for known task types or empty string fallback. # @POST: Returns non-empty summary line for known task types or empty string fallback.
@@ -712,6 +734,7 @@ def _build_task_observability_summary(task: Any, config_manager: ConfigManager)
# [DEF:_parse_command:Function] # [DEF:_parse_command:Function]
# @TIER: STANDARD
# @PURPOSE: Deterministically parse RU/EN command text into intent payload. # @PURPOSE: Deterministically parse RU/EN command text into intent payload.
# @PRE: message contains raw user text and config manager resolves environments. # @PRE: message contains raw user text and config manager resolves environments.
# @POST: Returns intent dict with domain/operation/entities/confidence/risk fields. # @POST: Returns intent dict with domain/operation/entities/confidence/risk fields.
@@ -905,6 +928,7 @@ def _parse_command(message: str, config_manager: ConfigManager) -> Dict[str, Any
# [DEF:_check_any_permission:Function] # [DEF:_check_any_permission:Function]
# @TIER: STANDARD
# @PURPOSE: Validate user against alternative permission checks (logical OR). # @PURPOSE: Validate user against alternative permission checks (logical OR).
# @PRE: checks list contains resource-action tuples. # @PRE: checks list contains resource-action tuples.
# @POST: Returns on first successful permission; raises 403-like HTTPException otherwise. # @POST: Returns on first successful permission; raises 403-like HTTPException otherwise.
@@ -922,6 +946,7 @@ def _check_any_permission(current_user: User, checks: List[Tuple[str, str]]):
# [DEF:_has_any_permission:Function] # [DEF:_has_any_permission:Function]
# @TIER: STANDARD
# @PURPOSE: Check whether user has at least one permission tuple from the provided list. # @PURPOSE: Check whether user has at least one permission tuple from the provided list.
# @PRE: current_user and checks list are valid. # @PRE: current_user and checks list are valid.
# @POST: Returns True when at least one permission check passes. # @POST: Returns True when at least one permission check passes.
@@ -935,6 +960,7 @@ def _has_any_permission(current_user: User, checks: List[Tuple[str, str]]) -> bo
# [DEF:_build_tool_catalog:Function] # [DEF:_build_tool_catalog:Function]
# @TIER: STANDARD
# @PURPOSE: Build current-user tool catalog for LLM planner with operation contracts and defaults. # @PURPOSE: Build current-user tool catalog for LLM planner with operation contracts and defaults.
# @PRE: current_user is authenticated; config/db are available. # @PRE: current_user is authenticated; config/db are available.
# @POST: Returns list of executable tools filtered by permission and runtime availability. # @POST: Returns list of executable tools filtered by permission and runtime availability.
@@ -1058,6 +1084,7 @@ def _build_tool_catalog(current_user: User, config_manager: ConfigManager, db: S
# [DEF:_coerce_intent_entities:Function] # [DEF:_coerce_intent_entities:Function]
# @TIER: STANDARD
# @PURPOSE: Normalize intent entity value types from LLM output to route-compatible values. # @PURPOSE: Normalize intent entity value types from LLM output to route-compatible values.
# @PRE: intent contains entities dict or missing entities. # @PRE: intent contains entities dict or missing entities.
# @POST: Returned intent has numeric ids coerced where possible and string values stripped. # @POST: Returned intent has numeric ids coerced where possible and string values stripped.
@@ -1082,6 +1109,7 @@ _SAFE_OPS = {"show_capabilities", "get_task_status", "get_health_summary"}
# [DEF:_confirmation_summary:Function] # [DEF:_confirmation_summary:Function]
# @TIER: STANDARD
# @PURPOSE: Build human-readable confirmation prompt for an intent before execution. # @PURPOSE: Build human-readable confirmation prompt for an intent before execution.
# @PRE: intent contains operation and entities fields. # @PRE: intent contains operation and entities fields.
# @POST: Returns descriptive Russian-language text ending with confirmation prompt. # @POST: Returns descriptive Russian-language text ending with confirmation prompt.
@@ -1177,6 +1205,7 @@ async def _async_confirmation_summary(intent: Dict[str, Any], config_manager: Co
# [DEF:_clarification_text_for_intent:Function] # [DEF:_clarification_text_for_intent:Function]
# @TIER: STANDARD
# @PURPOSE: Convert technical missing-parameter errors into user-facing clarification prompts. # @PURPOSE: Convert technical missing-parameter errors into user-facing clarification prompts.
# @PRE: state was classified as needs_clarification for current intent/error combination. # @PRE: state was classified as needs_clarification for current intent/error combination.
# @POST: Returned text is human-readable and actionable for target operation. # @POST: Returned text is human-readable and actionable for target operation.
@@ -1200,6 +1229,7 @@ def _clarification_text_for_intent(intent: Optional[Dict[str, Any]], detail_text
# [DEF:_plan_intent_with_llm:Function] # [DEF:_plan_intent_with_llm:Function]
# @TIER: STANDARD
# @PURPOSE: Use active LLM provider to select best tool/operation from dynamic catalog. # @PURPOSE: Use active LLM provider to select best tool/operation from dynamic catalog.
# @PRE: tools list contains allowed operations for current user. # @PRE: tools list contains allowed operations for current user.
# @POST: Returns normalized intent dict when planning succeeds; otherwise None. # @POST: Returns normalized intent dict when planning succeeds; otherwise None.
@@ -1310,6 +1340,7 @@ async def _plan_intent_with_llm(
# [DEF:_authorize_intent:Function] # [DEF:_authorize_intent:Function]
# @TIER: STANDARD
# @PURPOSE: Validate user permissions for parsed intent before confirmation/dispatch. # @PURPOSE: Validate user permissions for parsed intent before confirmation/dispatch.
# @PRE: intent.operation is present for known assistant command domains. # @PRE: intent.operation is present for known assistant command domains.
# @POST: Returns if authorized; raises HTTPException(403) when denied. # @POST: Returns if authorized; raises HTTPException(403) when denied.
@@ -1321,6 +1352,7 @@ def _authorize_intent(intent: Dict[str, Any], current_user: User):
# [DEF:_dispatch_intent:Function] # [DEF:_dispatch_intent:Function]
# @TIER: STANDARD
# @PURPOSE: Execute parsed assistant intent via existing task/plugin/git services. # @PURPOSE: Execute parsed assistant intent via existing task/plugin/git services.
# @PRE: intent operation is known and actor permissions are validated per operation. # @PRE: intent operation is known and actor permissions are validated per operation.
# @POST: Returns response text, optional task id, and UI actions for follow-up. # @POST: Returns response text, optional task id, and UI actions for follow-up.
@@ -1642,6 +1674,7 @@ async def _dispatch_intent(
@router.post("/messages", response_model=AssistantMessageResponse) @router.post("/messages", response_model=AssistantMessageResponse)
# [DEF:send_message:Function] # [DEF:send_message:Function]
# @TIER: STANDARD
# @PURPOSE: Parse assistant command, enforce safety gates, and dispatch executable intent. # @PURPOSE: Parse assistant command, enforce safety gates, and dispatch executable intent.
# @PRE: Authenticated user is available and message text is non-empty. # @PRE: Authenticated user is available and message text is non-empty.
# @POST: Response state is one of clarification/confirmation/started/success/denied/failed. # @POST: Response state is one of clarification/confirmation/started/success/denied/failed.
@@ -1811,6 +1844,7 @@ async def send_message(
@router.post("/confirmations/{confirmation_id}/confirm", response_model=AssistantMessageResponse) @router.post("/confirmations/{confirmation_id}/confirm", response_model=AssistantMessageResponse)
# [DEF:confirm_operation:Function] # [DEF:confirm_operation:Function]
# @TIER: STANDARD
# @PURPOSE: Execute previously requested risky operation after explicit user confirmation. # @PURPOSE: Execute previously requested risky operation after explicit user confirmation.
# @PRE: confirmation_id exists, belongs to current user, is pending, and not expired. # @PRE: confirmation_id exists, belongs to current user, is pending, and not expired.
# @POST: Confirmation state becomes consumed and operation result is persisted in history. # @POST: Confirmation state becomes consumed and operation result is persisted in history.
@@ -1877,6 +1911,7 @@ async def confirm_operation(
@router.post("/confirmations/{confirmation_id}/cancel", response_model=AssistantMessageResponse) @router.post("/confirmations/{confirmation_id}/cancel", response_model=AssistantMessageResponse)
# [DEF:cancel_operation:Function] # [DEF:cancel_operation:Function]
# @TIER: STANDARD
# @PURPOSE: Cancel pending risky operation and mark confirmation token as cancelled. # @PURPOSE: Cancel pending risky operation and mark confirmation token as cancelled.
# @PRE: confirmation_id exists, belongs to current user, and is still pending. # @PRE: confirmation_id exists, belongs to current user, and is still pending.
# @POST: Confirmation becomes cancelled and cannot be executed anymore. # @POST: Confirmation becomes cancelled and cannot be executed anymore.
@@ -1933,6 +1968,7 @@ async def cancel_operation(
# [DEF:list_conversations:Function] # [DEF:list_conversations:Function]
# @TIER: STANDARD
# @PURPOSE: Return paginated conversation list for current user with archived flag and last message preview. # @PURPOSE: Return paginated conversation list for current user with archived flag and last message preview.
# @PRE: Authenticated user context and valid pagination params. # @PRE: Authenticated user context and valid pagination params.
# @POST: Conversations are grouped by conversation_id sorted by latest activity descending. # @POST: Conversations are grouped by conversation_id sorted by latest activity descending.
@@ -2020,6 +2056,7 @@ async def list_conversations(
# [DEF:delete_conversation:Function] # [DEF:delete_conversation:Function]
# @TIER: STANDARD
# @PURPOSE: Soft-delete or hard-delete a conversation and clear its in-memory trace. # @PURPOSE: Soft-delete or hard-delete a conversation and clear its in-memory trace.
# @PRE: conversation_id belongs to current_user. # @PRE: conversation_id belongs to current_user.
# @POST: Conversation records are removed from DB and CONVERSATIONS cache. # @POST: Conversation records are removed from DB and CONVERSATIONS cache.

View File

@@ -4,12 +4,17 @@
# @SEMANTICS: api, dashboards, resources, hub # @SEMANTICS: api, dashboards, resources, hub
# @PURPOSE: API endpoints for the Dashboard Hub - listing dashboards with Git and task status # @PURPOSE: API endpoints for the Dashboard Hub - listing dashboards with Git and task status
# @LAYER: API # @LAYER: API
# @RELATION: DEPENDS_ON -> backend.src.dependencies # @RELATION: DEPENDS_ON ->[backend.src.dependencies:Dependencies]
# @RELATION: DEPENDS_ON -> backend.src.services.resource_service # @RELATION: DEPENDS_ON ->[backend.src.services.resource_service:ResourceService]
# @RELATION: DEPENDS_ON -> backend.src.core.superset_client # @RELATION: DEPENDS_ON ->[backend.src.core.superset_client:SupersetClient]
# #
# @INVARIANT: All dashboard responses include git_status and last_task metadata # @INVARIANT: All dashboard responses include git_status and last_task metadata
# #
# @PRE: Valid environment configurations exist in ConfigManager.
# @POST: Dashboard responses are projected into DashboardsResponse DTO.
# @SIDE_EFFECT: Performs external calls to Superset API and potentially Git providers.
# @DATA_CONTRACT: Input(env_id, filters) -> Output(DashboardsResponse)
#
# @TEST_CONTRACT: DashboardsAPI -> { # @TEST_CONTRACT: DashboardsAPI -> {
# required_fields: {env_id: string, page: integer, page_size: integer}, # required_fields: {env_id: string, page: integer, page_size: integer},
# optional_fields: {search: string}, # optional_fields: {search: string},
@@ -61,6 +66,8 @@ from ...services.resource_service import ResourceService
router = APIRouter(prefix="/api/dashboards", tags=["Dashboards"]) router = APIRouter(prefix="/api/dashboards", tags=["Dashboards"])
# [DEF:GitStatus:DataClass] # [DEF:GitStatus:DataClass]
# @TIER: STANDARD
# @PURPOSE: DTO for dashboard Git synchronization status.
class GitStatus(BaseModel): class GitStatus(BaseModel):
branch: Optional[str] = None branch: Optional[str] = None
sync_status: Optional[str] = Field(None, pattern="^OK|DIFF|NO_REPO|ERROR$") sync_status: Optional[str] = Field(None, pattern="^OK|DIFF|NO_REPO|ERROR$")
@@ -69,6 +76,8 @@ class GitStatus(BaseModel):
# [/DEF:GitStatus:DataClass] # [/DEF:GitStatus:DataClass]
# [DEF:LastTask:DataClass] # [DEF:LastTask:DataClass]
# @TIER: STANDARD
# @PURPOSE: DTO for the most recent background task associated with a dashboard.
class LastTask(BaseModel): class LastTask(BaseModel):
task_id: Optional[str] = None task_id: Optional[str] = None
status: Optional[str] = Field( status: Optional[str] = Field(
@@ -79,6 +88,8 @@ class LastTask(BaseModel):
# [/DEF:LastTask:DataClass] # [/DEF:LastTask:DataClass]
# [DEF:DashboardItem:DataClass] # [DEF:DashboardItem:DataClass]
# @TIER: STANDARD
# @PURPOSE: DTO representing a single dashboard with projected metadata.
class DashboardItem(BaseModel): class DashboardItem(BaseModel):
id: int id: int
title: str title: str
@@ -93,6 +104,8 @@ class DashboardItem(BaseModel):
# [/DEF:DashboardItem:DataClass] # [/DEF:DashboardItem:DataClass]
# [DEF:EffectiveProfileFilter:DataClass] # [DEF:EffectiveProfileFilter:DataClass]
# @TIER: STANDARD
# @PURPOSE: Metadata about applied profile filters for UI context.
class EffectiveProfileFilter(BaseModel): class EffectiveProfileFilter(BaseModel):
applied: bool applied: bool
source_page: Literal["dashboards_main", "other"] = "dashboards_main" source_page: Literal["dashboards_main", "other"] = "dashboards_main"
@@ -104,6 +117,8 @@ class EffectiveProfileFilter(BaseModel):
# [/DEF:EffectiveProfileFilter:DataClass] # [/DEF:EffectiveProfileFilter:DataClass]
# [DEF:DashboardsResponse:DataClass] # [DEF:DashboardsResponse:DataClass]
# @TIER: STANDARD
# @PURPOSE: Envelope DTO for paginated dashboards list.
class DashboardsResponse(BaseModel): class DashboardsResponse(BaseModel):
dashboards: List[DashboardItem] dashboards: List[DashboardItem]
total: int total: int
@@ -114,6 +129,8 @@ class DashboardsResponse(BaseModel):
# [/DEF:DashboardsResponse:DataClass] # [/DEF:DashboardsResponse:DataClass]
# [DEF:DashboardChartItem:DataClass] # [DEF:DashboardChartItem:DataClass]
# @TIER: STANDARD
# @PURPOSE: DTO for a chart linked to a dashboard.
class DashboardChartItem(BaseModel): class DashboardChartItem(BaseModel):
id: int id: int
title: str title: str
@@ -124,6 +141,8 @@ class DashboardChartItem(BaseModel):
# [/DEF:DashboardChartItem:DataClass] # [/DEF:DashboardChartItem:DataClass]
# [DEF:DashboardDatasetItem:DataClass] # [DEF:DashboardDatasetItem:DataClass]
# @TIER: STANDARD
# @PURPOSE: DTO for a dataset associated with a dashboard.
class DashboardDatasetItem(BaseModel): class DashboardDatasetItem(BaseModel):
id: int id: int
table_name: str table_name: str
@@ -134,6 +153,8 @@ class DashboardDatasetItem(BaseModel):
# [/DEF:DashboardDatasetItem:DataClass] # [/DEF:DashboardDatasetItem:DataClass]
# [DEF:DashboardDetailResponse:DataClass] # [DEF:DashboardDetailResponse:DataClass]
# @TIER: STANDARD
# @PURPOSE: Detailed dashboard metadata including children.
class DashboardDetailResponse(BaseModel): class DashboardDetailResponse(BaseModel):
id: int id: int
title: str title: str
@@ -149,6 +170,8 @@ class DashboardDetailResponse(BaseModel):
# [/DEF:DashboardDetailResponse:DataClass] # [/DEF:DashboardDetailResponse:DataClass]
# [DEF:DashboardTaskHistoryItem:DataClass] # [DEF:DashboardTaskHistoryItem:DataClass]
# @TIER: STANDARD
# @PURPOSE: Individual history record entry.
class DashboardTaskHistoryItem(BaseModel): class DashboardTaskHistoryItem(BaseModel):
id: str id: str
plugin_id: str plugin_id: str
@@ -161,12 +184,16 @@ class DashboardTaskHistoryItem(BaseModel):
# [/DEF:DashboardTaskHistoryItem:DataClass] # [/DEF:DashboardTaskHistoryItem:DataClass]
# [DEF:DashboardTaskHistoryResponse:DataClass] # [DEF:DashboardTaskHistoryResponse:DataClass]
# @TIER: STANDARD
# @PURPOSE: Collection DTO for task history.
class DashboardTaskHistoryResponse(BaseModel): class DashboardTaskHistoryResponse(BaseModel):
dashboard_id: int dashboard_id: int
items: List[DashboardTaskHistoryItem] items: List[DashboardTaskHistoryItem]
# [/DEF:DashboardTaskHistoryResponse:DataClass] # [/DEF:DashboardTaskHistoryResponse:DataClass]
# [DEF:DatabaseMapping:DataClass] # [DEF:DatabaseMapping:DataClass]
# @TIER: STANDARD
# @PURPOSE: DTO for cross-environment database ID mapping.
class DatabaseMapping(BaseModel): class DatabaseMapping(BaseModel):
source_db: str source_db: str
target_db: str target_db: str
@@ -176,12 +203,15 @@ class DatabaseMapping(BaseModel):
# [/DEF:DatabaseMapping:DataClass] # [/DEF:DatabaseMapping:DataClass]
# [DEF:DatabaseMappingsResponse:DataClass] # [DEF:DatabaseMappingsResponse:DataClass]
# @TIER: STANDARD
# @PURPOSE: Wrapper for database mappings.
class DatabaseMappingsResponse(BaseModel): class DatabaseMappingsResponse(BaseModel):
mappings: List[DatabaseMapping] mappings: List[DatabaseMapping]
# [/DEF:DatabaseMappingsResponse:DataClass] # [/DEF:DatabaseMappingsResponse:DataClass]
# [DEF:_find_dashboard_id_by_slug:Function] # [DEF:_find_dashboard_id_by_slug:Function]
# @TIER: STANDARD
# @PURPOSE: Resolve dashboard numeric ID by slug using Superset list endpoint. # @PURPOSE: Resolve dashboard numeric ID by slug using Superset list endpoint.
# @PRE: `dashboard_slug` is non-empty. # @PRE: `dashboard_slug` is non-empty.
# @POST: Returns dashboard ID when found, otherwise None. # @POST: Returns dashboard ID when found, otherwise None.
@@ -209,6 +239,7 @@ def _find_dashboard_id_by_slug(
# [DEF:_resolve_dashboard_id_from_ref:Function] # [DEF:_resolve_dashboard_id_from_ref:Function]
# @TIER: STANDARD
# @PURPOSE: Resolve dashboard ID from slug-first reference with numeric fallback. # @PURPOSE: Resolve dashboard ID from slug-first reference with numeric fallback.
# @PRE: `dashboard_ref` is provided in route path. # @PRE: `dashboard_ref` is provided in route path.
# @POST: Returns a valid dashboard ID or raises HTTPException(404). # @POST: Returns a valid dashboard ID or raises HTTPException(404).
@@ -233,6 +264,7 @@ def _resolve_dashboard_id_from_ref(
# [DEF:_find_dashboard_id_by_slug_async:Function] # [DEF:_find_dashboard_id_by_slug_async:Function]
# @TIER: STANDARD
# @PURPOSE: Resolve dashboard numeric ID by slug using async Superset list endpoint. # @PURPOSE: Resolve dashboard numeric ID by slug using async Superset list endpoint.
# @PRE: dashboard_slug is non-empty. # @PRE: dashboard_slug is non-empty.
# @POST: Returns dashboard ID when found, otherwise None. # @POST: Returns dashboard ID when found, otherwise None.
@@ -260,6 +292,7 @@ async def _find_dashboard_id_by_slug_async(
# [DEF:_resolve_dashboard_id_from_ref_async:Function] # [DEF:_resolve_dashboard_id_from_ref_async:Function]
# @TIER: STANDARD
# @PURPOSE: Resolve dashboard ID from slug-first reference using async Superset client. # @PURPOSE: Resolve dashboard ID from slug-first reference using async Superset client.
# @PRE: dashboard_ref is provided in route path. # @PRE: dashboard_ref is provided in route path.
# @POST: Returns valid dashboard ID or raises HTTPException(404). # @POST: Returns valid dashboard ID or raises HTTPException(404).
@@ -283,6 +316,7 @@ async def _resolve_dashboard_id_from_ref_async(
# [DEF:_normalize_filter_values:Function] # [DEF:_normalize_filter_values:Function]
# @TIER: STANDARD
# @PURPOSE: Normalize query filter values to lower-cased non-empty tokens. # @PURPOSE: Normalize query filter values to lower-cased non-empty tokens.
# @PRE: values may be None or list of strings. # @PRE: values may be None or list of strings.
# @POST: Returns trimmed normalized list preserving input order. # @POST: Returns trimmed normalized list preserving input order.
@@ -299,6 +333,7 @@ def _normalize_filter_values(values: Optional[List[str]]) -> List[str]:
# [DEF:_dashboard_git_filter_value:Function] # [DEF:_dashboard_git_filter_value:Function]
# @TIER: STANDARD
# @PURPOSE: Build comparable git status token for dashboards filtering. # @PURPOSE: Build comparable git status token for dashboards filtering.
# @PRE: dashboard payload may contain git_status or None. # @PRE: dashboard payload may contain git_status or None.
# @POST: Returns one of ok|diff|no_repo|error|pending. # @POST: Returns one of ok|diff|no_repo|error|pending.
@@ -318,6 +353,7 @@ def _dashboard_git_filter_value(dashboard: Dict[str, Any]) -> str:
# [/DEF:_dashboard_git_filter_value:Function] # [/DEF:_dashboard_git_filter_value:Function]
# [DEF:_normalize_actor_alias_token:Function] # [DEF:_normalize_actor_alias_token:Function]
# @TIER: STANDARD
# @PURPOSE: Normalize actor alias token to comparable trim+lower text. # @PURPOSE: Normalize actor alias token to comparable trim+lower text.
# @PRE: value can be scalar/None. # @PRE: value can be scalar/None.
# @POST: Returns normalized token or None. # @POST: Returns normalized token or None.
@@ -328,6 +364,7 @@ def _normalize_actor_alias_token(value: Any) -> Optional[str]:
# [DEF:_normalize_owner_display_token:Function] # [DEF:_normalize_owner_display_token:Function]
# @TIER: STANDARD
# @PURPOSE: Project owner payload value into stable display string for API response contracts. # @PURPOSE: Project owner payload value into stable display string for API response contracts.
# @PRE: owner can be scalar, dict or None. # @PRE: owner can be scalar, dict or None.
# @POST: Returns trimmed non-empty owner display token or None. # @POST: Returns trimmed non-empty owner display token or None.
@@ -354,6 +391,7 @@ def _normalize_owner_display_token(owner: Any) -> Optional[str]:
# [DEF:_normalize_dashboard_owner_values:Function] # [DEF:_normalize_dashboard_owner_values:Function]
# @TIER: STANDARD
# @PURPOSE: Normalize dashboard owners payload to optional list of display strings. # @PURPOSE: Normalize dashboard owners payload to optional list of display strings.
# @PRE: owners payload can be None, scalar, or list with mixed values. # @PRE: owners payload can be None, scalar, or list with mixed values.
# @POST: Returns deduplicated owner labels preserving order, or None when absent. # @POST: Returns deduplicated owner labels preserving order, or None when absent.
@@ -378,6 +416,7 @@ def _normalize_dashboard_owner_values(owners: Any) -> Optional[List[str]]:
# [DEF:_project_dashboard_response_items:Function] # [DEF:_project_dashboard_response_items:Function]
# @TIER: STANDARD
# @PURPOSE: Project dashboard payloads to response-contract-safe shape. # @PURPOSE: Project dashboard payloads to response-contract-safe shape.
# @PRE: dashboards is a list of dict-like dashboard payloads. # @PRE: dashboards is a list of dict-like dashboard payloads.
# @POST: Returned items satisfy DashboardItem owners=list[str]|None contract. # @POST: Returned items satisfy DashboardItem owners=list[str]|None contract.
@@ -394,6 +433,7 @@ def _project_dashboard_response_items(dashboards: List[Dict[str, Any]]) -> List[
# [DEF:_resolve_profile_actor_aliases:Function] # [DEF:_resolve_profile_actor_aliases:Function]
# @TIER: STANDARD
# @PURPOSE: Resolve stable actor aliases for profile filtering without per-dashboard detail fan-out. # @PURPOSE: Resolve stable actor aliases for profile filtering without per-dashboard detail fan-out.
# @PRE: bound username is available and env is valid. # @PRE: bound username is available and env is valid.
# @POST: Returns at least normalized username; may include Superset display-name alias. # @POST: Returns at least normalized username; may include Superset display-name alias.
@@ -458,6 +498,7 @@ def _resolve_profile_actor_aliases(env: Any, bound_username: str) -> List[str]:
# [DEF:_matches_dashboard_actor_aliases:Function] # [DEF:_matches_dashboard_actor_aliases:Function]
# @TIER: STANDARD
# @PURPOSE: Apply profile actor matching against multiple aliases (username + optional display name). # @PURPOSE: Apply profile actor matching against multiple aliases (username + optional display name).
# @PRE: actor_aliases contains normalized non-empty tokens. # @PRE: actor_aliases contains normalized non-empty tokens.
# @POST: Returns True when any alias matches owners OR modified_by. # @POST: Returns True when any alias matches owners OR modified_by.
@@ -479,6 +520,7 @@ def _matches_dashboard_actor_aliases(
# [DEF:get_dashboards:Function] # [DEF:get_dashboards:Function]
# @TIER: STANDARD
# @PURPOSE: Fetch list of dashboards from a specific environment with Git status and last task status # @PURPOSE: Fetch list of dashboards from a specific environment with Git status and last task status
# @PRE: env_id must be a valid environment ID # @PRE: env_id must be a valid environment ID
# @PRE: page must be >= 1 if provided # @PRE: page must be >= 1 if provided
@@ -491,7 +533,7 @@ def _matches_dashboard_actor_aliases(
# @PARAM: page (Optional[int]) - Page number (default: 1) # @PARAM: page (Optional[int]) - Page number (default: 1)
# @PARAM: page_size (Optional[int]) - Items per page (default: 10, max: 100) # @PARAM: page_size (Optional[int]) - Items per page (default: 10, max: 100)
# @RETURN: DashboardsResponse - List of dashboards with status metadata # @RETURN: DashboardsResponse - List of dashboards with status metadata
# @RELATION: CALLS -> ResourceService.get_dashboards_with_status # @RELATION: CALLS ->[ResourceService:get_dashboards_with_status]
@router.get("", response_model=DashboardsResponse) @router.get("", response_model=DashboardsResponse)
async def get_dashboards( async def get_dashboards(
env_id: str, env_id: str,
@@ -781,6 +823,7 @@ async def get_dashboards(
# [/DEF:get_dashboards:Function] # [/DEF:get_dashboards:Function]
# [DEF:get_database_mappings:Function] # [DEF:get_database_mappings:Function]
# @TIER: STANDARD
# @PURPOSE: Get database mapping suggestions between source and target environments # @PURPOSE: Get database mapping suggestions between source and target environments
# @PRE: User has permission plugin:migration:read # @PRE: User has permission plugin:migration:read
# @PRE: source_env_id and target_env_id are valid environment IDs # @PRE: source_env_id and target_env_id are valid environment IDs
@@ -788,7 +831,7 @@ async def get_dashboards(
# @PARAM: source_env_id (str) - Source environment ID # @PARAM: source_env_id (str) - Source environment ID
# @PARAM: target_env_id (str) - Target environment ID # @PARAM: target_env_id (str) - Target environment ID
# @RETURN: DatabaseMappingsResponse - List of suggested mappings # @RETURN: DatabaseMappingsResponse - List of suggested mappings
# @RELATION: CALLS -> MappingService.get_suggestions # @RELATION: CALLS ->[MappingService:get_suggestions]
@router.get("/db-mappings", response_model=DatabaseMappingsResponse) @router.get("/db-mappings", response_model=DatabaseMappingsResponse)
async def get_database_mappings( async def get_database_mappings(
source_env_id: str, source_env_id: str,
@@ -836,10 +879,11 @@ async def get_database_mappings(
# [/DEF:get_database_mappings:Function] # [/DEF:get_database_mappings:Function]
# [DEF:get_dashboard_detail:Function] # [DEF:get_dashboard_detail:Function]
# @TIER: STANDARD
# @PURPOSE: Fetch detailed dashboard info with related charts and datasets # @PURPOSE: Fetch detailed dashboard info with related charts and datasets
# @PRE: env_id must be valid and dashboard ref (slug or id) must exist # @PRE: env_id must be valid and dashboard ref (slug or id) must exist
# @POST: Returns dashboard detail payload for overview page # @POST: Returns dashboard detail payload for overview page
# @RELATION: CALLS -> SupersetClient.get_dashboard_detail # @RELATION: CALLS ->[AsyncSupersetClient:get_dashboard_detail_async]
@router.get("/{dashboard_ref}", response_model=DashboardDetailResponse) @router.get("/{dashboard_ref}", response_model=DashboardDetailResponse)
async def get_dashboard_detail( async def get_dashboard_detail(
dashboard_ref: str, dashboard_ref: str,
@@ -873,6 +917,7 @@ async def get_dashboard_detail(
# [DEF:_task_matches_dashboard:Function] # [DEF:_task_matches_dashboard:Function]
# @TIER: STANDARD
# @PURPOSE: Checks whether task params are tied to a specific dashboard and environment. # @PURPOSE: Checks whether task params are tied to a specific dashboard and environment.
# @PRE: task-like object exposes plugin_id and params fields. # @PRE: task-like object exposes plugin_id and params fields.
# @POST: Returns True only for supported task plugins tied to dashboard_id (+optional env_id). # @POST: Returns True only for supported task plugins tied to dashboard_id (+optional env_id).
@@ -906,6 +951,7 @@ def _task_matches_dashboard(task: Any, dashboard_id: int, env_id: Optional[str])
# [DEF:get_dashboard_tasks_history:Function] # [DEF:get_dashboard_tasks_history:Function]
# @TIER: STANDARD
# @PURPOSE: Returns history of backup and LLM validation tasks for a dashboard. # @PURPOSE: Returns history of backup and LLM validation tasks for a dashboard.
# @PRE: dashboard ref (slug or id) is valid. # @PRE: dashboard ref (slug or id) is valid.
# @POST: Response contains sorted task history (newest first). # @POST: Response contains sorted task history (newest first).
@@ -992,6 +1038,7 @@ async def get_dashboard_tasks_history(
# [DEF:get_dashboard_thumbnail:Function] # [DEF:get_dashboard_thumbnail:Function]
# @TIER: STANDARD
# @PURPOSE: Proxies Superset dashboard thumbnail with cache support. # @PURPOSE: Proxies Superset dashboard thumbnail with cache support.
# @PRE: env_id must exist. # @PRE: env_id must exist.
# @POST: Returns image bytes or 202 when thumbnail is being prepared by Superset. # @POST: Returns image bytes or 202 when thumbnail is being prepared by Superset.
@@ -1072,7 +1119,7 @@ async def get_dashboard_thumbnail(
content_type = thumb_response.headers.get("Content-Type", "image/png") content_type = thumb_response.headers.get("Content-Type", "image/png")
return Response(content=thumb_response.content, media_type=content_type) return Response(content=thumb_response.content, media_type=content_type)
except DashboardNotFoundError as e: except DashboardNotFoundError as e:
logger.error(f"[get_dashboard_thumbnail][Coherence:Failed] Dashboard not found for thumbnail: {e}") logger.error(f"[get_dashboard_thumbnail][Coherence:Failed] Dashboard not found for thumbnail: {e}")
raise HTTPException(status_code=404, detail="Dashboard thumbnail not found") raise HTTPException(status_code=404, detail="Dashboard thumbnail not found")
except HTTPException: except HTTPException:
@@ -1085,6 +1132,8 @@ async def get_dashboard_thumbnail(
# [/DEF:get_dashboard_thumbnail:Function] # [/DEF:get_dashboard_thumbnail:Function]
# [DEF:MigrateRequest:DataClass] # [DEF:MigrateRequest:DataClass]
# @TIER: STANDARD
# @PURPOSE: DTO for dashboard migration requests.
class MigrateRequest(BaseModel): class MigrateRequest(BaseModel):
source_env_id: str = Field(..., description="Source environment ID") source_env_id: str = Field(..., description="Source environment ID")
target_env_id: str = Field(..., description="Target environment ID") target_env_id: str = Field(..., description="Target environment ID")
@@ -1094,11 +1143,14 @@ class MigrateRequest(BaseModel):
# [/DEF:MigrateRequest:DataClass] # [/DEF:MigrateRequest:DataClass]
# [DEF:TaskResponse:DataClass] # [DEF:TaskResponse:DataClass]
# @TIER: STANDARD
# @PURPOSE: DTO for async task ID return.
class TaskResponse(BaseModel): class TaskResponse(BaseModel):
task_id: str task_id: str
# [/DEF:TaskResponse:DataClass] # [/DEF:TaskResponse:DataClass]
# [DEF:migrate_dashboards:Function] # [DEF:migrate_dashboards:Function]
# @TIER: STANDARD
# @PURPOSE: Trigger bulk migration of dashboards from source to target environment # @PURPOSE: Trigger bulk migration of dashboards from source to target environment
# @PRE: User has permission plugin:migration:execute # @PRE: User has permission plugin:migration:execute
# @PRE: source_env_id and target_env_id are valid environment IDs # @PRE: source_env_id and target_env_id are valid environment IDs
@@ -1107,8 +1159,8 @@ class TaskResponse(BaseModel):
# @POST: Task is created and queued for execution # @POST: Task is created and queued for execution
# @PARAM: request (MigrateRequest) - Migration request with source, target, and dashboard IDs # @PARAM: request (MigrateRequest) - Migration request with source, target, and dashboard IDs
# @RETURN: TaskResponse - Task ID for tracking # @RETURN: TaskResponse - Task ID for tracking
# @RELATION: DISPATCHES -> MigrationPlugin # @RELATION: DISPATCHES ->[MigrationPlugin:execute]
# @RELATION: CALLS -> task_manager.create_task # @RELATION: CALLS ->[task_manager:create_task]
@router.post("/migrate", response_model=TaskResponse) @router.post("/migrate", response_model=TaskResponse)
async def migrate_dashboards( async def migrate_dashboards(
request: MigrateRequest, request: MigrateRequest,
@@ -1159,6 +1211,8 @@ async def migrate_dashboards(
# [/DEF:migrate_dashboards:Function] # [/DEF:migrate_dashboards:Function]
# [DEF:BackupRequest:DataClass] # [DEF:BackupRequest:DataClass]
# @TIER: STANDARD
# @PURPOSE: DTO for dashboard backup requests.
class BackupRequest(BaseModel): class BackupRequest(BaseModel):
env_id: str = Field(..., description="Environment ID") env_id: str = Field(..., description="Environment ID")
dashboard_ids: List[int] = Field(..., description="List of dashboard IDs to backup") dashboard_ids: List[int] = Field(..., description="List of dashboard IDs to backup")
@@ -1166,6 +1220,7 @@ class BackupRequest(BaseModel):
# [/DEF:BackupRequest:DataClass] # [/DEF:BackupRequest:DataClass]
# [DEF:backup_dashboards:Function] # [DEF:backup_dashboards:Function]
# @TIER: STANDARD
# @PURPOSE: Trigger bulk backup of dashboards with optional cron schedule # @PURPOSE: Trigger bulk backup of dashboards with optional cron schedule
# @PRE: User has permission plugin:backup:execute # @PRE: User has permission plugin:backup:execute
# @PRE: env_id is a valid environment ID # @PRE: env_id is a valid environment ID
@@ -1175,8 +1230,8 @@ class BackupRequest(BaseModel):
# @POST: If schedule is provided, a scheduled task is created # @POST: If schedule is provided, a scheduled task is created
# @PARAM: request (BackupRequest) - Backup request with environment and dashboard IDs # @PARAM: request (BackupRequest) - Backup request with environment and dashboard IDs
# @RETURN: TaskResponse - Task ID for tracking # @RETURN: TaskResponse - Task ID for tracking
# @RELATION: DISPATCHES -> BackupPlugin # @RELATION: DISPATCHES ->[BackupPlugin:execute]
# @RELATION: CALLS -> task_manager.create_task # @RELATION: CALLS ->[task_manager:create_task]
@router.post("/backup", response_model=TaskResponse) @router.post("/backup", response_model=TaskResponse)
async def backup_dashboards( async def backup_dashboards(
request: BackupRequest, request: BackupRequest,

View File

@@ -4,9 +4,9 @@
# @SEMANTICS: api, datasets, resources, hub # @SEMANTICS: api, datasets, resources, hub
# @PURPOSE: API endpoints for the Dataset Hub - listing datasets with mapping progress # @PURPOSE: API endpoints for the Dataset Hub - listing datasets with mapping progress
# @LAYER: API # @LAYER: API
# @RELATION: DEPENDS_ON -> backend.src.dependencies # @RELATION: DEPENDS_ON ->[backend.src.dependencies]
# @RELATION: DEPENDS_ON -> backend.src.services.resource_service # @RELATION: DEPENDS_ON ->[backend.src.services.resource_service]
# @RELATION: DEPENDS_ON -> backend.src.core.superset_client # @RELATION: DEPENDS_ON ->[backend.src.core.superset_client]
# #
# @INVARIANT: All dataset responses include last_task metadata # @INVARIANT: All dataset responses include last_task metadata
@@ -22,28 +22,39 @@ from ...core.superset_client import SupersetClient
router = APIRouter(prefix="/api/datasets", tags=["Datasets"]) router = APIRouter(prefix="/api/datasets", tags=["Datasets"])
# [DEF:MappedFields:DataClass] # [DEF:MappedFields:DataClass]
# @TIER: TRIVIAL
# @PURPOSE: DTO for dataset mapping progress statistics
class MappedFields(BaseModel): class MappedFields(BaseModel):
total: int total: int
mapped: int mapped: int
# [/DEF:MappedFields:DataClass] # [/DEF:MappedFields:DataClass]
# [DEF:LastTask:DataClass] # [DEF:LastTask:DataClass]
# @TIER: TRIVIAL
# @PURPOSE: DTO for the most recent task associated with a dataset
class LastTask(BaseModel): class LastTask(BaseModel):
task_id: Optional[str] = None task_id: Optional[str] = None
status: Optional[str] = Field(None, pattern="^RUNNING|SUCCESS|ERROR|WAITING_INPUT$") status: Optional[str] = Field(None, pattern="^RUNNING|SUCCESS|ERROR|WAITING_INPUT$")
# [/DEF:LastTask:DataClass] # [/DEF:LastTask:DataClass]
# [DEF:DatasetItem:DataClass] # [DEF:DatasetItem:DataClass]
# @TIER: TRIVIAL
# @PURPOSE: Summary DTO for a dataset in the hub listing
class DatasetItem(BaseModel): class DatasetItem(BaseModel):
id: int id: int
table_name: str table_name: str
schema: str schema_name: str = Field(..., alias="schema")
database: str database: str
mapped_fields: Optional[MappedFields] = None mapped_fields: Optional[MappedFields] = None
last_task: Optional[LastTask] = None last_task: Optional[LastTask] = None
class Config:
allow_population_by_field_name = True
# [/DEF:DatasetItem:DataClass] # [/DEF:DatasetItem:DataClass]
# [DEF:LinkedDashboard:DataClass] # [DEF:LinkedDashboard:DataClass]
# @TIER: TRIVIAL
# @PURPOSE: DTO for a dashboard linked to a dataset
class LinkedDashboard(BaseModel): class LinkedDashboard(BaseModel):
id: int id: int
title: str title: str
@@ -51,6 +62,8 @@ class LinkedDashboard(BaseModel):
# [/DEF:LinkedDashboard:DataClass] # [/DEF:LinkedDashboard:DataClass]
# [DEF:DatasetColumn:DataClass] # [DEF:DatasetColumn:DataClass]
# @TIER: TRIVIAL
# @PURPOSE: DTO for a single dataset column's metadata
class DatasetColumn(BaseModel): class DatasetColumn(BaseModel):
id: int id: int
name: str name: str
@@ -61,10 +74,12 @@ class DatasetColumn(BaseModel):
# [/DEF:DatasetColumn:DataClass] # [/DEF:DatasetColumn:DataClass]
# [DEF:DatasetDetailResponse:DataClass] # [DEF:DatasetDetailResponse:DataClass]
# @TIER: TRIVIAL
# @PURPOSE: Detailed DTO for a dataset including columns and links
class DatasetDetailResponse(BaseModel): class DatasetDetailResponse(BaseModel):
id: int id: int
table_name: Optional[str] = None table_name: Optional[str] = None
schema: Optional[str] = None schema_name: Optional[str] = Field(None, alias="schema")
database: str database: str
description: Optional[str] = None description: Optional[str] = None
columns: List[DatasetColumn] columns: List[DatasetColumn]
@@ -75,9 +90,14 @@ class DatasetDetailResponse(BaseModel):
is_sqllab_view: bool = False is_sqllab_view: bool = False
created_on: Optional[str] = None created_on: Optional[str] = None
changed_on: Optional[str] = None changed_on: Optional[str] = None
class Config:
allow_population_by_field_name = True
# [/DEF:DatasetDetailResponse:DataClass] # [/DEF:DatasetDetailResponse:DataClass]
# [DEF:DatasetsResponse:DataClass] # [DEF:DatasetsResponse:DataClass]
# @TIER: TRIVIAL
# @PURPOSE: Paginated response DTO for dataset listings
class DatasetsResponse(BaseModel): class DatasetsResponse(BaseModel):
datasets: List[DatasetItem] datasets: List[DatasetItem]
total: int total: int
@@ -87,18 +107,21 @@ class DatasetsResponse(BaseModel):
# [/DEF:DatasetsResponse:DataClass] # [/DEF:DatasetsResponse:DataClass]
# [DEF:TaskResponse:DataClass] # [DEF:TaskResponse:DataClass]
# @TIER: TRIVIAL
# @PURPOSE: Response DTO containing a task ID for tracking
class TaskResponse(BaseModel): class TaskResponse(BaseModel):
task_id: str task_id: str
# [/DEF:TaskResponse:DataClass] # [/DEF:TaskResponse:DataClass]
# [DEF:get_dataset_ids:Function] # [DEF:get_dataset_ids:Function]
# @TIER: STANDARD
# @PURPOSE: Fetch list of all dataset IDs from a specific environment (without pagination) # @PURPOSE: Fetch list of all dataset IDs from a specific environment (without pagination)
# @PRE: env_id must be a valid environment ID # @PRE: env_id must be a valid environment ID
# @POST: Returns a list of all dataset IDs # @POST: Returns a list of all dataset IDs
# @PARAM: env_id (str) - The environment ID to fetch datasets from # @PARAM: env_id (str) - The environment ID to fetch datasets from
# @PARAM: search (Optional[str]) - Filter by table name # @PARAM: search (Optional[str]) - Filter by table name
# @RETURN: List[int] - List of dataset IDs # @RETURN: List[int] - List of dataset IDs
# @RELATION: CALLS -> ResourceService.get_datasets_with_status # @RELATION: CALLS ->[backend.src.services.resource_service.ResourceService:get_datasets_with_status]
@router.get("/ids") @router.get("/ids")
async def get_dataset_ids( async def get_dataset_ids(
env_id: str, env_id: str,
@@ -143,6 +166,7 @@ async def get_dataset_ids(
# [/DEF:get_dataset_ids:Function] # [/DEF:get_dataset_ids:Function]
# [DEF:get_datasets:Function] # [DEF:get_datasets:Function]
# @TIER: STANDARD
# @PURPOSE: Fetch list of datasets from a specific environment with mapping progress # @PURPOSE: Fetch list of datasets from a specific environment with mapping progress
# @PRE: env_id must be a valid environment ID # @PRE: env_id must be a valid environment ID
# @PRE: page must be >= 1 if provided # @PRE: page must be >= 1 if provided
@@ -154,7 +178,7 @@ async def get_dataset_ids(
# @PARAM: page (Optional[int]) - Page number (default: 1) # @PARAM: page (Optional[int]) - Page number (default: 1)
# @PARAM: page_size (Optional[int]) - Items per page (default: 10, max: 100) # @PARAM: page_size (Optional[int]) - Items per page (default: 10, max: 100)
# @RETURN: DatasetsResponse - List of datasets with status metadata # @RETURN: DatasetsResponse - List of datasets with status metadata
# @RELATION: CALLS -> ResourceService.get_datasets_with_status # @RELATION: CALLS ->[backend.src.services.resource_service.ResourceService:get_datasets_with_status]
@router.get("", response_model=DatasetsResponse) @router.get("", response_model=DatasetsResponse)
async def get_datasets( async def get_datasets(
env_id: str, env_id: str,
@@ -222,6 +246,8 @@ async def get_datasets(
# [/DEF:get_datasets:Function] # [/DEF:get_datasets:Function]
# [DEF:MapColumnsRequest:DataClass] # [DEF:MapColumnsRequest:DataClass]
# @TIER: TRIVIAL
# @PURPOSE: Request DTO for initiating column mapping
class MapColumnsRequest(BaseModel): class MapColumnsRequest(BaseModel):
env_id: str = Field(..., description="Environment ID") env_id: str = Field(..., description="Environment ID")
dataset_ids: List[int] = Field(..., description="List of dataset IDs to map") dataset_ids: List[int] = Field(..., description="List of dataset IDs to map")
@@ -231,6 +257,7 @@ class MapColumnsRequest(BaseModel):
# [/DEF:MapColumnsRequest:DataClass] # [/DEF:MapColumnsRequest:DataClass]
# [DEF:map_columns:Function] # [DEF:map_columns:Function]
# @TIER: STANDARD
# @PURPOSE: Trigger bulk column mapping for datasets # @PURPOSE: Trigger bulk column mapping for datasets
# @PRE: User has permission plugin:mapper:execute # @PRE: User has permission plugin:mapper:execute
# @PRE: env_id is a valid environment ID # @PRE: env_id is a valid environment ID
@@ -239,8 +266,8 @@ class MapColumnsRequest(BaseModel):
# @POST: Task is created and queued for execution # @POST: Task is created and queued for execution
# @PARAM: request (MapColumnsRequest) - Mapping request with environment and dataset IDs # @PARAM: request (MapColumnsRequest) - Mapping request with environment and dataset IDs
# @RETURN: TaskResponse - Task ID for tracking # @RETURN: TaskResponse - Task ID for tracking
# @RELATION: DISPATCHES -> MapperPlugin # @RELATION: DISPATCHES ->[backend.src.plugins.mapper.MapperPlugin]
# @RELATION: CALLS -> task_manager.create_task # @RELATION: CALLS ->[backend.src.core.task_manager.manager.TaskManager:create_task]
@router.post("/map-columns", response_model=TaskResponse) @router.post("/map-columns", response_model=TaskResponse)
async def map_columns( async def map_columns(
request: MapColumnsRequest, request: MapColumnsRequest,
@@ -292,6 +319,8 @@ async def map_columns(
# [/DEF:map_columns:Function] # [/DEF:map_columns:Function]
# [DEF:GenerateDocsRequest:DataClass] # [DEF:GenerateDocsRequest:DataClass]
# @TIER: TRIVIAL
# @PURPOSE: Request DTO for initiating documentation generation
class GenerateDocsRequest(BaseModel): class GenerateDocsRequest(BaseModel):
env_id: str = Field(..., description="Environment ID") env_id: str = Field(..., description="Environment ID")
dataset_ids: List[int] = Field(..., description="List of dataset IDs to generate docs for") dataset_ids: List[int] = Field(..., description="List of dataset IDs to generate docs for")
@@ -300,6 +329,7 @@ class GenerateDocsRequest(BaseModel):
# [/DEF:GenerateDocsRequest:DataClass] # [/DEF:GenerateDocsRequest:DataClass]
# [DEF:generate_docs:Function] # [DEF:generate_docs:Function]
# @TIER: STANDARD
# @PURPOSE: Trigger bulk documentation generation for datasets # @PURPOSE: Trigger bulk documentation generation for datasets
# @PRE: User has permission plugin:llm_analysis:execute # @PRE: User has permission plugin:llm_analysis:execute
# @PRE: env_id is a valid environment ID # @PRE: env_id is a valid environment ID
@@ -308,8 +338,8 @@ class GenerateDocsRequest(BaseModel):
# @POST: Task is created and queued for execution # @POST: Task is created and queued for execution
# @PARAM: request (GenerateDocsRequest) - Documentation generation request # @PARAM: request (GenerateDocsRequest) - Documentation generation request
# @RETURN: TaskResponse - Task ID for tracking # @RETURN: TaskResponse - Task ID for tracking
# @RELATION: DISPATCHES -> LLMAnalysisPlugin # @RELATION: DISPATCHES ->[backend.src.plugins.llm_analysis.plugin.DocumentationPlugin]
# @RELATION: CALLS -> task_manager.create_task # @RELATION: CALLS ->[backend.src.core.task_manager.manager.TaskManager:create_task]
@router.post("/generate-docs", response_model=TaskResponse) @router.post("/generate-docs", response_model=TaskResponse)
async def generate_docs( async def generate_docs(
request: GenerateDocsRequest, request: GenerateDocsRequest,
@@ -355,6 +385,7 @@ async def generate_docs(
# [/DEF:generate_docs:Function] # [/DEF:generate_docs:Function]
# [DEF:get_dataset_detail:Function] # [DEF:get_dataset_detail:Function]
# @TIER: STANDARD
# @PURPOSE: Get detailed dataset information including columns and linked dashboards # @PURPOSE: Get detailed dataset information including columns and linked dashboards
# @PRE: env_id is a valid environment ID # @PRE: env_id is a valid environment ID
# @PRE: dataset_id is a valid dataset ID # @PRE: dataset_id is a valid dataset ID
@@ -362,7 +393,7 @@ async def generate_docs(
# @PARAM: env_id (str) - The environment ID # @PARAM: env_id (str) - The environment ID
# @PARAM: dataset_id (int) - The dataset ID # @PARAM: dataset_id (int) - The dataset ID
# @RETURN: DatasetDetailResponse - Detailed dataset information # @RETURN: DatasetDetailResponse - Detailed dataset information
# @RELATION: CALLS -> SupersetClient.get_dataset_detail # @RELATION: CALLS ->[backend.src.core.superset_client.SupersetClient:get_dataset_detail]
@router.get("/{dataset_id}", response_model=DatasetDetailResponse) @router.get("/{dataset_id}", response_model=DatasetDetailResponse)
async def get_dataset_detail( async def get_dataset_detail(
env_id: str, env_id: str,

View File

@@ -4,9 +4,9 @@
# @SEMANTICS: git, routes, api, fastapi, repository, deployment # @SEMANTICS: git, routes, api, fastapi, repository, deployment
# @PURPOSE: Provides FastAPI endpoints for Git integration operations. # @PURPOSE: Provides FastAPI endpoints for Git integration operations.
# @LAYER: API # @LAYER: API
# @RELATION: USES -> src.services.git_service.GitService # @RELATION: USES -> [backend.src.services.git_service.GitService]
# @RELATION: USES -> src.api.routes.git_schemas # @RELATION: USES -> [backend.src.api.routes.git_schemas]
# @RELATION: USES -> src.models.git # @RELATION: USES -> [backend.src.models.git]
# #
# @INVARIANT: All Git operations must be routed through GitService. # @INVARIANT: All Git operations must be routed through GitService.
@@ -48,6 +48,7 @@ MAX_REPOSITORY_STATUS_BATCH = 50
# [DEF:_build_no_repo_status_payload:Function] # [DEF:_build_no_repo_status_payload:Function]
# @TIER: TRIVIAL
# @PURPOSE: Build a consistent status payload for dashboards without initialized repositories. # @PURPOSE: Build a consistent status payload for dashboards without initialized repositories.
# @PRE: None. # @PRE: None.
# @POST: Returns a stable payload compatible with frontend repository status parsing. # @POST: Returns a stable payload compatible with frontend repository status parsing.
@@ -72,6 +73,7 @@ def _build_no_repo_status_payload() -> dict:
# [DEF:_handle_unexpected_git_route_error:Function] # [DEF:_handle_unexpected_git_route_error:Function]
# @TIER: TRIVIAL
# @PURPOSE: Convert unexpected route-level exceptions to stable 500 API responses. # @PURPOSE: Convert unexpected route-level exceptions to stable 500 API responses.
# @PRE: `error` is a non-HTTPException instance. # @PRE: `error` is a non-HTTPException instance.
# @POST: Raises HTTPException(500) with route-specific context. # @POST: Raises HTTPException(500) with route-specific context.
@@ -84,6 +86,7 @@ def _handle_unexpected_git_route_error(route_name: str, error: Exception) -> Non
# [DEF:_resolve_repository_status:Function] # [DEF:_resolve_repository_status:Function]
# @TIER: STANDARD
# @PURPOSE: Resolve repository status for one dashboard with graceful NO_REPO semantics. # @PURPOSE: Resolve repository status for one dashboard with graceful NO_REPO semantics.
# @PRE: `dashboard_id` is a valid integer. # @PRE: `dashboard_id` is a valid integer.
# @POST: Returns standard status payload or `NO_REPO` payload when repository path is absent. # @POST: Returns standard status payload or `NO_REPO` payload when repository path is absent.
@@ -110,6 +113,7 @@ def _resolve_repository_status(dashboard_id: int) -> dict:
# [DEF:_get_git_config_or_404:Function] # [DEF:_get_git_config_or_404:Function]
# @TIER: STANDARD
# @PURPOSE: Resolve GitServerConfig by id or raise 404. # @PURPOSE: Resolve GitServerConfig by id or raise 404.
# @PRE: db session is available. # @PRE: db session is available.
# @POST: Returns GitServerConfig model. # @POST: Returns GitServerConfig model.
@@ -122,6 +126,7 @@ def _get_git_config_or_404(db: Session, config_id: str) -> GitServerConfig:
# [DEF:_find_dashboard_id_by_slug:Function] # [DEF:_find_dashboard_id_by_slug:Function]
# @TIER: STANDARD
# @PURPOSE: Resolve dashboard numeric ID by slug in a specific environment. # @PURPOSE: Resolve dashboard numeric ID by slug in a specific environment.
# @PRE: dashboard_slug is non-empty. # @PRE: dashboard_slug is non-empty.
# @POST: Returns dashboard ID or None when not found. # @POST: Returns dashboard ID or None when not found.
@@ -148,6 +153,7 @@ def _find_dashboard_id_by_slug(
# [DEF:_resolve_dashboard_id_from_ref:Function] # [DEF:_resolve_dashboard_id_from_ref:Function]
# @TIER: STANDARD
# @PURPOSE: Resolve dashboard ID from slug-or-id reference for Git routes. # @PURPOSE: Resolve dashboard ID from slug-or-id reference for Git routes.
# @PRE: dashboard_ref is provided; env_id is required for slug values. # @PRE: dashboard_ref is provided; env_id is required for slug values.
# @POST: Returns numeric dashboard ID or raises HTTPException. # @POST: Returns numeric dashboard ID or raises HTTPException.
@@ -182,6 +188,7 @@ def _resolve_dashboard_id_from_ref(
# [DEF:_find_dashboard_id_by_slug_async:Function] # [DEF:_find_dashboard_id_by_slug_async:Function]
# @TIER: STANDARD
# @PURPOSE: Resolve dashboard numeric ID by slug asynchronously for hot-path Git routes. # @PURPOSE: Resolve dashboard numeric ID by slug asynchronously for hot-path Git routes.
# @PRE: dashboard_slug is non-empty. # @PRE: dashboard_slug is non-empty.
# @POST: Returns dashboard ID or None when not found. # @POST: Returns dashboard ID or None when not found.
@@ -208,6 +215,7 @@ async def _find_dashboard_id_by_slug_async(
# [DEF:_resolve_dashboard_id_from_ref_async:Function] # [DEF:_resolve_dashboard_id_from_ref_async:Function]
# @TIER: STANDARD
# @PURPOSE: Resolve dashboard ID asynchronously from slug-or-id reference for hot Git routes. # @PURPOSE: Resolve dashboard ID asynchronously from slug-or-id reference for hot Git routes.
# @PRE: dashboard_ref is provided; env_id is required for slug values. # @PRE: dashboard_ref is provided; env_id is required for slug values.
# @POST: Returns numeric dashboard ID or raises HTTPException. # @POST: Returns numeric dashboard ID or raises HTTPException.
@@ -246,6 +254,7 @@ async def _resolve_dashboard_id_from_ref_async(
# [DEF:_resolve_repo_key_from_ref:Function] # [DEF:_resolve_repo_key_from_ref:Function]
# @TIER: STANDARD
# @PURPOSE: Resolve repository folder key with slug-first strategy and deterministic fallback. # @PURPOSE: Resolve repository folder key with slug-first strategy and deterministic fallback.
# @PRE: dashboard_id is resolved and valid. # @PRE: dashboard_id is resolved and valid.
# @POST: Returns safe key to be used in local repository path. # @POST: Returns safe key to be used in local repository path.
@@ -278,6 +287,7 @@ def _resolve_repo_key_from_ref(
# [DEF:_sanitize_optional_identity_value:Function] # [DEF:_sanitize_optional_identity_value:Function]
# @TIER: TRIVIAL
# @PURPOSE: Normalize optional identity value into trimmed string or None. # @PURPOSE: Normalize optional identity value into trimmed string or None.
# @PRE: value may be None or blank. # @PRE: value may be None or blank.
# @POST: Returns sanitized value suitable for git identity configuration. # @POST: Returns sanitized value suitable for git identity configuration.
@@ -291,6 +301,7 @@ def _sanitize_optional_identity_value(value: Optional[str]) -> Optional[str]:
# [DEF:_resolve_current_user_git_identity:Function] # [DEF:_resolve_current_user_git_identity:Function]
# @TIER: STANDARD
# @PURPOSE: Resolve configured Git username/email from current user's profile preferences. # @PURPOSE: Resolve configured Git username/email from current user's profile preferences.
# @PRE: `db` may be stubbed in tests; `current_user` may be absent for direct handler invocations. # @PRE: `db` may be stubbed in tests; `current_user` may be absent for direct handler invocations.
# @POST: Returns tuple(username, email) only when both values are configured. # @POST: Returns tuple(username, email) only when both values are configured.
@@ -332,6 +343,7 @@ def _resolve_current_user_git_identity(
# [DEF:_apply_git_identity_from_profile:Function] # [DEF:_apply_git_identity_from_profile:Function]
# @TIER: STANDARD
# @PURPOSE: Apply user-scoped Git identity to repository-local config before write/pull operations. # @PURPOSE: Apply user-scoped Git identity to repository-local config before write/pull operations.
# @PRE: dashboard_id is resolved; db/current_user may be missing in direct test invocation context. # @PRE: dashboard_id is resolved; db/current_user may be missing in direct test invocation context.
# @POST: git_service.configure_identity is called only when identity and method are available. # @POST: git_service.configure_identity is called only when identity and method are available.
@@ -355,6 +367,7 @@ def _apply_git_identity_from_profile(
# [DEF:get_git_configs:Function] # [DEF:get_git_configs:Function]
# @TIER: STANDARD
# @PURPOSE: List all configured Git servers. # @PURPOSE: List all configured Git servers.
# @PRE: Database session `db` is available. # @PRE: Database session `db` is available.
# @POST: Returns a list of all GitServerConfig objects from the database. # @POST: Returns a list of all GitServerConfig objects from the database.
@@ -375,6 +388,7 @@ async def get_git_configs(
# [/DEF:get_git_configs:Function] # [/DEF:get_git_configs:Function]
# [DEF:create_git_config:Function] # [DEF:create_git_config:Function]
# @TIER: STANDARD
# @PURPOSE: Register a new Git server configuration. # @PURPOSE: Register a new Git server configuration.
# @PRE: `config` contains valid GitServerConfigCreate data. # @PRE: `config` contains valid GitServerConfigCreate data.
# @POST: A new GitServerConfig record is created in the database. # @POST: A new GitServerConfig record is created in the database.
@@ -396,6 +410,7 @@ async def create_git_config(
# [/DEF:create_git_config:Function] # [/DEF:create_git_config:Function]
# [DEF:update_git_config:Function] # [DEF:update_git_config:Function]
# @TIER: STANDARD
# @PURPOSE: Update an existing Git server configuration. # @PURPOSE: Update an existing Git server configuration.
# @PRE: `config_id` corresponds to an existing configuration. # @PRE: `config_id` corresponds to an existing configuration.
# @POST: The configuration record is updated in the database. # @POST: The configuration record is updated in the database.
@@ -430,6 +445,7 @@ async def update_git_config(
# [/DEF:update_git_config:Function] # [/DEF:update_git_config:Function]
# [DEF:delete_git_config:Function] # [DEF:delete_git_config:Function]
# @TIER: STANDARD
# @PURPOSE: Remove a Git server configuration. # @PURPOSE: Remove a Git server configuration.
# @PRE: `config_id` corresponds to an existing configuration. # @PRE: `config_id` corresponds to an existing configuration.
# @POST: The configuration record is removed from the database. # @POST: The configuration record is removed from the database.
@@ -451,6 +467,7 @@ async def delete_git_config(
# [/DEF:delete_git_config:Function] # [/DEF:delete_git_config:Function]
# [DEF:test_git_config:Function] # [DEF:test_git_config:Function]
# @TIER: STANDARD
# @PURPOSE: Validate connection to a Git server using provided credentials. # @PURPOSE: Validate connection to a Git server using provided credentials.
# @PRE: `config` contains provider, url, and pat. # @PRE: `config` contains provider, url, and pat.
# @POST: Returns success if the connection is validated via GitService. # @POST: Returns success if the connection is validated via GitService.
@@ -482,6 +499,7 @@ async def test_git_config(
# [DEF:list_gitea_repositories:Function] # [DEF:list_gitea_repositories:Function]
# @TIER: STANDARD
# @PURPOSE: List repositories in Gitea for a saved Gitea config. # @PURPOSE: List repositories in Gitea for a saved Gitea config.
# @PRE: config_id exists and provider is GITEA. # @PRE: config_id exists and provider is GITEA.
# @POST: Returns repositories visible to PAT user. # @POST: Returns repositories visible to PAT user.
@@ -512,6 +530,7 @@ async def list_gitea_repositories(
# [DEF:create_gitea_repository:Function] # [DEF:create_gitea_repository:Function]
# @TIER: STANDARD
# @PURPOSE: Create a repository in Gitea for a saved Gitea config. # @PURPOSE: Create a repository in Gitea for a saved Gitea config.
# @PRE: config_id exists and provider is GITEA. # @PRE: config_id exists and provider is GITEA.
# @POST: Returns created repository payload. # @POST: Returns created repository payload.
@@ -548,6 +567,7 @@ async def create_gitea_repository(
# [DEF:create_remote_repository:Function] # [DEF:create_remote_repository:Function]
# @TIER: STANDARD
# @PURPOSE: Create repository on remote Git server using selected provider config. # @PURPOSE: Create repository on remote Git server using selected provider config.
# @PRE: config_id exists and PAT has creation permissions. # @PRE: config_id exists and PAT has creation permissions.
# @POST: Returns normalized remote repository payload. # @POST: Returns normalized remote repository payload.
@@ -608,6 +628,7 @@ async def create_remote_repository(
# [DEF:delete_gitea_repository:Function] # [DEF:delete_gitea_repository:Function]
# @TIER: STANDARD
# @PURPOSE: Delete repository in Gitea for a saved Gitea config. # @PURPOSE: Delete repository in Gitea for a saved Gitea config.
# @PRE: config_id exists and provider is GITEA. # @PRE: config_id exists and provider is GITEA.
# @POST: Target repository is deleted on Gitea. # @POST: Target repository is deleted on Gitea.
@@ -633,6 +654,7 @@ async def delete_gitea_repository(
# [/DEF:delete_gitea_repository:Function] # [/DEF:delete_gitea_repository:Function]
# [DEF:init_repository:Function] # [DEF:init_repository:Function]
# @TIER: STANDARD
# @PURPOSE: Link a dashboard to a Git repository and perform initial clone/init. # @PURPOSE: Link a dashboard to a Git repository and perform initial clone/init.
# @PRE: `dashboard_ref` exists and `init_data` contains valid config_id and remote_url. # @PRE: `dashboard_ref` exists and `init_data` contains valid config_id and remote_url.
# @POST: Repository is initialized on disk and a GitRepository record is saved in DB. # @POST: Repository is initialized on disk and a GitRepository record is saved in DB.
@@ -690,6 +712,7 @@ async def init_repository(
# [/DEF:init_repository:Function] # [/DEF:init_repository:Function]
# [DEF:get_repository_binding:Function] # [DEF:get_repository_binding:Function]
# @TIER: STANDARD
# @PURPOSE: Return repository binding with provider metadata for selected dashboard. # @PURPOSE: Return repository binding with provider metadata for selected dashboard.
# @PRE: `dashboard_ref` resolves to a valid dashboard and repository is initialized. # @PRE: `dashboard_ref` resolves to a valid dashboard and repository is initialized.
# @POST: Returns dashboard repository binding and linked provider. # @POST: Returns dashboard repository binding and linked provider.
@@ -724,6 +747,7 @@ async def get_repository_binding(
# [/DEF:get_repository_binding:Function] # [/DEF:get_repository_binding:Function]
# [DEF:delete_repository:Function] # [DEF:delete_repository:Function]
# @TIER: STANDARD
# @PURPOSE: Delete local repository workspace and DB binding for selected dashboard. # @PURPOSE: Delete local repository workspace and DB binding for selected dashboard.
# @PRE: `dashboard_ref` resolves to a valid dashboard. # @PRE: `dashboard_ref` resolves to a valid dashboard.
# @POST: Repository files and binding record are removed when present. # @POST: Repository files and binding record are removed when present.
@@ -748,6 +772,7 @@ async def delete_repository(
# [/DEF:delete_repository:Function] # [/DEF:delete_repository:Function]
# [DEF:get_branches:Function] # [DEF:get_branches:Function]
# @TIER: STANDARD
# @PURPOSE: List all branches for a dashboard's repository. # @PURPOSE: List all branches for a dashboard's repository.
# @PRE: Repository for `dashboard_ref` is initialized. # @PRE: Repository for `dashboard_ref` is initialized.
# @POST: Returns a list of branches from the local repository. # @POST: Returns a list of branches from the local repository.
@@ -771,6 +796,7 @@ async def get_branches(
# [/DEF:get_branches:Function] # [/DEF:get_branches:Function]
# [DEF:create_branch:Function] # [DEF:create_branch:Function]
# @TIER: STANDARD
# @PURPOSE: Create a new branch in the dashboard's repository. # @PURPOSE: Create a new branch in the dashboard's repository.
# @PRE: `dashboard_ref` repository exists and `branch_data` has name and from_branch. # @PRE: `dashboard_ref` repository exists and `branch_data` has name and from_branch.
# @POST: A new branch is created in the local repository. # @POST: A new branch is created in the local repository.
@@ -799,6 +825,7 @@ async def create_branch(
# [/DEF:create_branch:Function] # [/DEF:create_branch:Function]
# [DEF:checkout_branch:Function] # [DEF:checkout_branch:Function]
# @TIER: STANDARD
# @PURPOSE: Switch the dashboard's repository to a specific branch. # @PURPOSE: Switch the dashboard's repository to a specific branch.
# @PRE: `dashboard_ref` repository exists and branch `checkout_data.name` exists. # @PRE: `dashboard_ref` repository exists and branch `checkout_data.name` exists.
# @POST: The local repository HEAD is moved to the specified branch. # @POST: The local repository HEAD is moved to the specified branch.
@@ -824,6 +851,7 @@ async def checkout_branch(
# [/DEF:checkout_branch:Function] # [/DEF:checkout_branch:Function]
# [DEF:commit_changes:Function] # [DEF:commit_changes:Function]
# @TIER: STANDARD
# @PURPOSE: Stage and commit changes in the dashboard's repository. # @PURPOSE: Stage and commit changes in the dashboard's repository.
# @PRE: `dashboard_ref` repository exists and `commit_data` has message and files. # @PRE: `dashboard_ref` repository exists and `commit_data` has message and files.
# @POST: Specified files are staged and a new commit is created. # @POST: Specified files are staged and a new commit is created.
@@ -852,6 +880,7 @@ async def commit_changes(
# [/DEF:commit_changes:Function] # [/DEF:commit_changes:Function]
# [DEF:push_changes:Function] # [DEF:push_changes:Function]
# @TIER: STANDARD
# @PURPOSE: Push local commits to the remote repository. # @PURPOSE: Push local commits to the remote repository.
# @PRE: `dashboard_ref` repository exists and has a remote configured. # @PRE: `dashboard_ref` repository exists and has a remote configured.
# @POST: Local commits are pushed to the remote repository. # @POST: Local commits are pushed to the remote repository.
@@ -875,6 +904,7 @@ async def push_changes(
# [/DEF:push_changes:Function] # [/DEF:push_changes:Function]
# [DEF:pull_changes:Function] # [DEF:pull_changes:Function]
# @TIER: STANDARD
# @PURPOSE: Pull changes from the remote repository. # @PURPOSE: Pull changes from the remote repository.
# @PRE: `dashboard_ref` repository exists and has a remote configured. # @PRE: `dashboard_ref` repository exists and has a remote configured.
# @POST: Remote changes are fetched and merged into the local branch. # @POST: Remote changes are fetched and merged into the local branch.
@@ -922,6 +952,7 @@ async def pull_changes(
# [/DEF:pull_changes:Function] # [/DEF:pull_changes:Function]
# [DEF:get_merge_status:Function] # [DEF:get_merge_status:Function]
# @TIER: STANDARD
# @PURPOSE: Return unfinished-merge status for repository (web-only recovery support). # @PURPOSE: Return unfinished-merge status for repository (web-only recovery support).
# @PRE: `dashboard_ref` resolves to a valid dashboard repository. # @PRE: `dashboard_ref` resolves to a valid dashboard repository.
# @POST: Returns merge status payload. # @POST: Returns merge status payload.
@@ -944,6 +975,7 @@ async def get_merge_status(
# [DEF:get_merge_conflicts:Function] # [DEF:get_merge_conflicts:Function]
# @TIER: STANDARD
# @PURPOSE: Return conflicted files with mine/theirs previews for web conflict resolver. # @PURPOSE: Return conflicted files with mine/theirs previews for web conflict resolver.
# @PRE: `dashboard_ref` resolves to a valid dashboard repository. # @PRE: `dashboard_ref` resolves to a valid dashboard repository.
# @POST: Returns conflict file list. # @POST: Returns conflict file list.
@@ -966,6 +998,7 @@ async def get_merge_conflicts(
# [DEF:resolve_merge_conflicts:Function] # [DEF:resolve_merge_conflicts:Function]
# @TIER: STANDARD
# @PURPOSE: Apply mine/theirs/manual conflict resolutions from WebUI and stage files. # @PURPOSE: Apply mine/theirs/manual conflict resolutions from WebUI and stage files.
# @PRE: `dashboard_ref` resolves; request contains at least one resolution item. # @PRE: `dashboard_ref` resolves; request contains at least one resolution item.
# @POST: Resolved files are staged in index. # @POST: Resolved files are staged in index.
@@ -993,6 +1026,7 @@ async def resolve_merge_conflicts(
# [DEF:abort_merge:Function] # [DEF:abort_merge:Function]
# @TIER: STANDARD
# @PURPOSE: Abort unfinished merge from WebUI flow. # @PURPOSE: Abort unfinished merge from WebUI flow.
# @PRE: `dashboard_ref` resolves to repository. # @PRE: `dashboard_ref` resolves to repository.
# @POST: Merge operation is aborted or reports no active merge. # @POST: Merge operation is aborted or reports no active merge.
@@ -1015,6 +1049,7 @@ async def abort_merge(
# [DEF:continue_merge:Function] # [DEF:continue_merge:Function]
# @TIER: STANDARD
# @PURPOSE: Finalize unfinished merge from WebUI flow. # @PURPOSE: Finalize unfinished merge from WebUI flow.
# @PRE: All conflicts are resolved and staged. # @PRE: All conflicts are resolved and staged.
# @POST: Merge commit is created. # @POST: Merge commit is created.
@@ -1038,6 +1073,7 @@ async def continue_merge(
# [DEF:sync_dashboard:Function] # [DEF:sync_dashboard:Function]
# @TIER: STANDARD
# @PURPOSE: Sync dashboard state from Superset to Git using the GitPlugin. # @PURPOSE: Sync dashboard state from Superset to Git using the GitPlugin.
# @PRE: `dashboard_ref` is valid; GitPlugin is available. # @PRE: `dashboard_ref` is valid; GitPlugin is available.
# @POST: Dashboard YAMLs are exported from Superset and committed to Git. # @POST: Dashboard YAMLs are exported from Superset and committed to Git.
@@ -1069,6 +1105,7 @@ async def sync_dashboard(
# [DEF:promote_dashboard:Function] # [DEF:promote_dashboard:Function]
# @TIER: STANDARD
# @PURPOSE: Promote changes between branches via MR or direct merge. # @PURPOSE: Promote changes between branches via MR or direct merge.
# @PRE: dashboard repository is initialized and Git config is valid. # @PRE: dashboard repository is initialized and Git config is valid.
# @POST: Returns promotion result metadata. # @POST: Returns promotion result metadata.
@@ -1171,6 +1208,7 @@ async def promote_dashboard(
# [/DEF:promote_dashboard:Function] # [/DEF:promote_dashboard:Function]
# [DEF:get_environments:Function] # [DEF:get_environments:Function]
# @TIER: STANDARD
# @PURPOSE: List all deployment environments. # @PURPOSE: List all deployment environments.
# @PRE: Config manager is accessible. # @PRE: Config manager is accessible.
# @POST: Returns a list of DeploymentEnvironmentSchema objects. # @POST: Returns a list of DeploymentEnvironmentSchema objects.
@@ -1193,6 +1231,7 @@ async def get_environments(
# [/DEF:get_environments:Function] # [/DEF:get_environments:Function]
# [DEF:deploy_dashboard:Function] # [DEF:deploy_dashboard:Function]
# @TIER: STANDARD
# @PURPOSE: Deploy dashboard from Git to a target environment. # @PURPOSE: Deploy dashboard from Git to a target environment.
# @PRE: `dashboard_ref` and `deploy_data.environment_id` are valid. # @PRE: `dashboard_ref` and `deploy_data.environment_id` are valid.
# @POST: Dashboard YAMLs are read from Git and imported into the target Superset. # @POST: Dashboard YAMLs are read from Git and imported into the target Superset.
@@ -1223,6 +1262,7 @@ async def deploy_dashboard(
# [/DEF:deploy_dashboard:Function] # [/DEF:deploy_dashboard:Function]
# [DEF:get_history:Function] # [DEF:get_history:Function]
# @TIER: STANDARD
# @PURPOSE: View commit history for a dashboard's repository. # @PURPOSE: View commit history for a dashboard's repository.
# @PRE: `dashboard_ref` repository exists. # @PRE: `dashboard_ref` repository exists.
# @POST: Returns a list of recent commits from the repository. # @POST: Returns a list of recent commits from the repository.
@@ -1248,6 +1288,7 @@ async def get_history(
# [/DEF:get_history:Function] # [/DEF:get_history:Function]
# [DEF:get_repository_status:Function] # [DEF:get_repository_status:Function]
# @TIER: STANDARD
# @PURPOSE: Get current Git status for a dashboard repository. # @PURPOSE: Get current Git status for a dashboard repository.
# @PRE: `dashboard_ref` resolves to a valid dashboard. # @PRE: `dashboard_ref` resolves to a valid dashboard.
# @POST: Returns repository status; if repo is not initialized, returns `NO_REPO` payload. # @POST: Returns repository status; if repo is not initialized, returns `NO_REPO` payload.
@@ -1272,6 +1313,7 @@ async def get_repository_status(
# [DEF:get_repository_status_batch:Function] # [DEF:get_repository_status_batch:Function]
# @TIER: STANDARD
# @PURPOSE: Get Git statuses for multiple dashboard repositories in one request. # @PURPOSE: Get Git statuses for multiple dashboard repositories in one request.
# @PRE: `request.dashboard_ids` is provided. # @PRE: `request.dashboard_ids` is provided.
# @POST: Returns `statuses` map where each key is dashboard ID and value is repository status payload. # @POST: Returns `statuses` map where each key is dashboard ID and value is repository status payload.
@@ -1315,6 +1357,7 @@ async def get_repository_status_batch(
# [/DEF:get_repository_status_batch:Function] # [/DEF:get_repository_status_batch:Function]
# [DEF:get_repository_diff:Function] # [DEF:get_repository_diff:Function]
# @TIER: STANDARD
# @PURPOSE: Get Git diff for a dashboard repository. # @PURPOSE: Get Git diff for a dashboard repository.
# @PRE: `dashboard_ref` repository exists. # @PRE: `dashboard_ref` repository exists.
# @POST: Returns the diff text for the specified file or all changes. # @POST: Returns the diff text for the specified file or all changes.
@@ -1343,6 +1386,7 @@ async def get_repository_diff(
# [/DEF:get_repository_diff:Function] # [/DEF:get_repository_diff:Function]
# [DEF:generate_commit_message:Function] # [DEF:generate_commit_message:Function]
# @TIER: STANDARD
# @PURPOSE: Generate a suggested commit message using LLM. # @PURPOSE: Generate a suggested commit message using LLM.
# @PRE: Repository for `dashboard_ref` is initialized. # @PRE: Repository for `dashboard_ref` is initialized.
# @POST: Returns a suggested commit message string. # @POST: Returns a suggested commit message string.

View File

@@ -3,14 +3,18 @@
# @SEMANTICS: api, migration, dashboards, sync, dry-run # @SEMANTICS: api, migration, dashboards, sync, dry-run
# @PURPOSE: HTTP contract layer for migration orchestration, settings, dry-run, and mapping sync endpoints. # @PURPOSE: HTTP contract layer for migration orchestration, settings, dry-run, and mapping sync endpoints.
# @LAYER: Infra # @LAYER: Infra
# @RELATION: [DEPENDS_ON] ->[backend.src.dependencies] # @RELATION: DEPENDS_ON ->[backend.src.dependencies]
# @RELATION: [DEPENDS_ON] ->[backend.src.core.database] # @RELATION: DEPENDS_ON ->[backend.src.core.database]
# @RELATION: [DEPENDS_ON] ->[backend.src.core.superset_client] # @RELATION: DEPENDS_ON ->[backend.src.core.superset_client.SupersetClient]
# @RELATION: [DEPENDS_ON] ->[backend.src.core.migration.dry_run_orchestrator] # @RELATION: DEPENDS_ON ->[backend.src.core.migration.dry_run_orchestrator.MigrationDryRunService]
# @RELATION: [DEPENDS_ON] ->[backend.src.core.mapping_service] # @RELATION: DEPENDS_ON ->[backend.src.core.mapping_service.IdMappingService]
# @RELATION: [DEPENDS_ON] ->[backend.src.models.dashboard] # @RELATION: DEPENDS_ON ->[backend.src.models.dashboard]
# @RELATION: [DEPENDS_ON] ->[backend.src.models.mapping] # @RELATION: DEPENDS_ON ->[backend.src.models.mapping]
# @INVARIANT: Migration endpoints never execute with invalid environment references and always return explicit HTTP errors on guard failures. # @INVARIANT: Migration endpoints never execute with invalid environment references and always return explicit HTTP errors on guard failures.
# @PRE: Backend core services initialized and Database session available.
# @POST: Migration tasks are enqueued or dry-run results are computed and returned.
# @SIDE_EFFECT: Enqueues long-running tasks, potentially mutates ResourceMapping table, and performs remote Superset API calls.
# @DATA_CONTRACT: [DashboardSelection | QueryParams] -> [TaskResponse | DryRunResult | MappingSummary]
# @TEST_CONTRACT: [DashboardSelection + configured envs] -> [task_id | dry-run result | sync summary] # @TEST_CONTRACT: [DashboardSelection + configured envs] -> [task_id | dry-run result | sync summary]
# @TEST_SCENARIO: [invalid_environment] -> [HTTP_400_or_404] # @TEST_SCENARIO: [invalid_environment] -> [HTTP_400_or_404]
# @TEST_SCENARIO: [valid_execution] -> [success_payload_with_required_fields] # @TEST_SCENARIO: [valid_execution] -> [success_payload_with_required_fields]
@@ -34,6 +38,7 @@ from ...models.mapping import ResourceMapping
router = APIRouter(prefix="/api", tags=["migration"]) router = APIRouter(prefix="/api", tags=["migration"])
# [DEF:get_dashboards:Function] # [DEF:get_dashboards:Function]
# @TIER: STANDARD
# @PURPOSE: Fetch dashboard metadata from a requested environment for migration selection UI. # @PURPOSE: Fetch dashboard metadata from a requested environment for migration selection UI.
# @PRE: env_id is provided and exists in configured environments. # @PRE: env_id is provided and exists in configured environments.
# @POST: Returns List[DashboardMetadata] for the resolved environment; emits HTTP_404 when environment is absent. # @POST: Returns List[DashboardMetadata] for the resolved environment; emits HTTP_404 when environment is absent.
@@ -61,6 +66,7 @@ async def get_dashboards(
# [/DEF:get_dashboards:Function] # [/DEF:get_dashboards:Function]
# [DEF:execute_migration:Function] # [DEF:execute_migration:Function]
# @TIER: CRITICAL
# @PURPOSE: Validate migration selection and enqueue asynchronous migration task execution. # @PURPOSE: Validate migration selection and enqueue asynchronous migration task execution.
# @PRE: DashboardSelection payload is valid and both source/target environments exist. # @PRE: DashboardSelection payload is valid and both source/target environments exist.
# @POST: Returns {"task_id": str, "message": str} when task creation succeeds; emits HTTP_400/HTTP_500 on failure. # @POST: Returns {"task_id": str, "message": str} when task creation succeeds; emits HTTP_400/HTTP_500 on failure.
@@ -102,6 +108,7 @@ async def execute_migration(
# [DEF:dry_run_migration:Function] # [DEF:dry_run_migration:Function]
# @TIER: CRITICAL
# @PURPOSE: Build pre-flight migration diff and risk summary without mutating target systems. # @PURPOSE: Build pre-flight migration diff and risk summary without mutating target systems.
# @PRE: DashboardSelection is valid, source and target environments exist, differ, and selected_ids is non-empty. # @PRE: DashboardSelection is valid, source and target environments exist, differ, and selected_ids is non-empty.
# @POST: Returns deterministic dry-run payload; emits HTTP_400 for guard violations and HTTP_500 for orchestrator value errors. # @POST: Returns deterministic dry-run payload; emits HTTP_400 for guard violations and HTTP_500 for orchestrator value errors.
@@ -153,6 +160,7 @@ async def dry_run_migration(
# [/DEF:dry_run_migration:Function] # [/DEF:dry_run_migration:Function]
# [DEF:get_migration_settings:Function] # [DEF:get_migration_settings:Function]
# @TIER: STANDARD
# @PURPOSE: Read and return configured migration synchronization cron expression. # @PURPOSE: Read and return configured migration synchronization cron expression.
# @PRE: Configuration store is available and requester has READ permission. # @PRE: Configuration store is available and requester has READ permission.
# @POST: Returns {"cron": str} reflecting current persisted settings value. # @POST: Returns {"cron": str} reflecting current persisted settings value.
@@ -170,6 +178,7 @@ async def get_migration_settings(
# [/DEF:get_migration_settings:Function] # [/DEF:get_migration_settings:Function]
# [DEF:update_migration_settings:Function] # [DEF:update_migration_settings:Function]
# @TIER: STANDARD
# @PURPOSE: Validate and persist migration synchronization cron expression update. # @PURPOSE: Validate and persist migration synchronization cron expression update.
# @PRE: Payload includes "cron" key and requester has WRITE permission. # @PRE: Payload includes "cron" key and requester has WRITE permission.
# @POST: Returns {"cron": str, "status": "updated"} and persists updated cron value. # @POST: Returns {"cron": str, "status": "updated"} and persists updated cron value.
@@ -195,6 +204,7 @@ async def update_migration_settings(
# [/DEF:update_migration_settings:Function] # [/DEF:update_migration_settings:Function]
# [DEF:get_resource_mappings:Function] # [DEF:get_resource_mappings:Function]
# @TIER: STANDARD
# @PURPOSE: Fetch synchronized resource mappings with optional filters and pagination for migration mappings view. # @PURPOSE: Fetch synchronized resource mappings with optional filters and pagination for migration mappings view.
# @PRE: skip>=0, 1<=limit<=500, DB session is active, requester has READ permission. # @PRE: skip>=0, 1<=limit<=500, DB session is active, requester has READ permission.
# @POST: Returns {"items": [...], "total": int} where items reflect applied filters and pagination. # @POST: Returns {"items": [...], "total": int} where items reflect applied filters and pagination.
@@ -245,6 +255,7 @@ async def get_resource_mappings(
# [/DEF:get_resource_mappings:Function] # [/DEF:get_resource_mappings:Function]
# [DEF:trigger_sync_now:Function] # [DEF:trigger_sync_now:Function]
# @TIER: STANDARD
# @PURPOSE: Trigger immediate ID synchronization for every configured environment. # @PURPOSE: Trigger immediate ID synchronization for every configured environment.
# @PRE: At least one environment is configured and requester has EXECUTE permission. # @PRE: At least one environment is configured and requester has EXECUTE permission.
# @POST: Returns sync summary with synced/failed counts after attempting all environments. # @POST: Returns sync summary with synced/failed counts after attempting all environments.

View File

@@ -3,9 +3,13 @@
# @SEMANTICS: api, reports, list, detail, pagination, filters # @SEMANTICS: api, reports, list, detail, pagination, filters
# @PURPOSE: FastAPI router for unified task report list and detail retrieval endpoints. # @PURPOSE: FastAPI router for unified task report list and detail retrieval endpoints.
# @LAYER: UI (API) # @LAYER: UI (API)
# @RELATION: DEPENDS_ON -> backend.src.services.reports.report_service.ReportsService # @RELATION: DEPENDS_ON -> [backend.src.services.reports.report_service.ReportsService]
# @RELATION: DEPENDS_ON -> backend.src.dependencies # @RELATION: DEPENDS_ON -> [backend.src.dependencies]
# @INVARIANT: Endpoints are read-only and do not trigger long-running tasks. # @INVARIANT: Endpoints are read-only and do not trigger long-running tasks.
# @PRE: Reports service and dependencies are initialized.
# @POST: Router is configured and endpoints are ready for registration.
# @SIDE_EFFECT: None
# @DATA_CONTRACT: [ReportQuery] -> [ReportCollection | ReportDetailView]
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
from datetime import datetime from datetime import datetime
@@ -25,6 +29,7 @@ router = APIRouter(prefix="/api/reports", tags=["Reports"])
# [DEF:_parse_csv_enum_list:Function] # [DEF:_parse_csv_enum_list:Function]
# @TIER: TRIVIAL
# @PURPOSE: Parse comma-separated query value into enum list. # @PURPOSE: Parse comma-separated query value into enum list.
# @PRE: raw may be None/empty or comma-separated values. # @PRE: raw may be None/empty or comma-separated values.
# @POST: Returns enum list or raises HTTP 400 with deterministic machine-readable payload. # @POST: Returns enum list or raises HTTP 400 with deterministic machine-readable payload.
@@ -59,6 +64,7 @@ def _parse_csv_enum_list(raw: Optional[str], enum_cls, field_name: str) -> List:
# [DEF:list_reports:Function] # [DEF:list_reports:Function]
# @TIER: STANDARD
# @PURPOSE: Return paginated unified reports list. # @PURPOSE: Return paginated unified reports list.
# @PRE: authenticated/authorized request and validated query params. # @PRE: authenticated/authorized request and validated query params.
# @POST: returns {items,total,page,page_size,has_next,applied_filters}. # @POST: returns {items,total,page,page_size,has_next,applied_filters}.
@@ -125,6 +131,7 @@ async def list_reports(
# [DEF:get_report_detail:Function] # [DEF:get_report_detail:Function]
# @TIER: STANDARD
# @PURPOSE: Return one normalized report detail with diagnostics and next actions. # @PURPOSE: Return one normalized report detail with diagnostics and next actions.
# @PRE: authenticated/authorized request and existing report_id. # @PRE: authenticated/authorized request and existing report_id.
# @POST: returns normalized detail envelope or 404 when report is not found. # @POST: returns normalized detail envelope or 404 when report is not found.

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
# @SEMANTICS: storage, files, upload, download, backup, repository # @SEMANTICS: storage, files, upload, download, backup, repository
# @PURPOSE: API endpoints for file storage management (backups and repositories). # @PURPOSE: API endpoints for file storage management (backups and repositories).
# @LAYER: API # @LAYER: API
# @RELATION: DEPENDS_ON -> backend.src.models.storage # @RELATION: DEPENDS_ON -> [backend.src.models.storage]
# #
# @INVARIANT: All paths must be validated against path traversal. # @INVARIANT: All paths must be validated against path traversal.
@@ -22,6 +22,7 @@ from ...core.logger import belief_scope
router = APIRouter(tags=["storage"]) router = APIRouter(tags=["storage"])
# [DEF:list_files:Function] # [DEF:list_files:Function]
# @TIER: STANDARD
# @PURPOSE: List all files and directories in the storage system. # @PURPOSE: List all files and directories in the storage system.
# #
# @PRE: None. # @PRE: None.
@@ -31,7 +32,7 @@ router = APIRouter(tags=["storage"])
# @PARAM: path (Optional[str]) - Subpath within the category. # @PARAM: path (Optional[str]) - Subpath within the category.
# @RETURN: List[StoredFile] - List of files/directories. # @RETURN: List[StoredFile] - List of files/directories.
# #
# @RELATION: CALLS -> StoragePlugin.list_files # @RELATION: CALLS -> [backend.src.plugins.storage.plugin.StoragePlugin.list_files]
@router.get("/files", response_model=List[StoredFile]) @router.get("/files", response_model=List[StoredFile])
async def list_files( async def list_files(
category: Optional[FileCategory] = None, category: Optional[FileCategory] = None,
@@ -48,6 +49,7 @@ async def list_files(
# [/DEF:list_files:Function] # [/DEF:list_files:Function]
# [DEF:upload_file:Function] # [DEF:upload_file:Function]
# @TIER: STANDARD
# @PURPOSE: Upload a file to the storage system. # @PURPOSE: Upload a file to the storage system.
# #
# @PRE: category must be a valid FileCategory. # @PRE: category must be a valid FileCategory.
@@ -61,7 +63,7 @@ async def list_files(
# #
# @SIDE_EFFECT: Writes file to the filesystem. # @SIDE_EFFECT: Writes file to the filesystem.
# #
# @RELATION: CALLS -> StoragePlugin.save_file # @RELATION: CALLS -> [backend.src.plugins.storage.plugin.StoragePlugin.save_file]
@router.post("/upload", response_model=StoredFile, status_code=201) @router.post("/upload", response_model=StoredFile, status_code=201)
async def upload_file( async def upload_file(
category: FileCategory = Form(...), category: FileCategory = Form(...),
@@ -81,6 +83,7 @@ async def upload_file(
# [/DEF:upload_file:Function] # [/DEF:upload_file:Function]
# [DEF:delete_file:Function] # [DEF:delete_file:Function]
# @TIER: STANDARD
# @PURPOSE: Delete a specific file or directory. # @PURPOSE: Delete a specific file or directory.
# #
# @PRE: category must be a valid FileCategory. # @PRE: category must be a valid FileCategory.
@@ -92,7 +95,7 @@ async def upload_file(
# #
# @SIDE_EFFECT: Deletes item from the filesystem. # @SIDE_EFFECT: Deletes item from the filesystem.
# #
# @RELATION: CALLS -> StoragePlugin.delete_file # @RELATION: CALLS -> [backend.src.plugins.storage.plugin.StoragePlugin.delete_file]
@router.delete("/files/{category}/{path:path}", status_code=204) @router.delete("/files/{category}/{path:path}", status_code=204)
async def delete_file( async def delete_file(
category: FileCategory, category: FileCategory,
@@ -113,6 +116,7 @@ async def delete_file(
# [/DEF:delete_file:Function] # [/DEF:delete_file:Function]
# [DEF:download_file:Function] # [DEF:download_file:Function]
# @TIER: STANDARD
# @PURPOSE: Retrieve a file for download. # @PURPOSE: Retrieve a file for download.
# #
# @PRE: category must be a valid FileCategory. # @PRE: category must be a valid FileCategory.
@@ -122,7 +126,7 @@ async def delete_file(
# @PARAM: path (str) - Relative path of the file. # @PARAM: path (str) - Relative path of the file.
# @RETURN: FileResponse - The file content. # @RETURN: FileResponse - The file content.
# #
# @RELATION: CALLS -> StoragePlugin.get_file_path # @RELATION: CALLS -> [backend.src.plugins.storage.plugin.StoragePlugin.get_file_path]
@router.get("/download/{category}/{path:path}") @router.get("/download/{category}/{path:path}")
async def download_file( async def download_file(
category: FileCategory, category: FileCategory,
@@ -145,6 +149,7 @@ async def download_file(
# [/DEF:download_file:Function] # [/DEF:download_file:Function]
# [DEF:get_file_by_path:Function] # [DEF:get_file_by_path:Function]
# @TIER: STANDARD
# @PURPOSE: Retrieve a file by validated absolute/relative path under storage root. # @PURPOSE: Retrieve a file by validated absolute/relative path under storage root.
# #
# @PRE: path must resolve under configured storage root. # @PRE: path must resolve under configured storage root.
@@ -153,8 +158,8 @@ async def download_file(
# @PARAM: path (str) - Absolute or storage-root-relative file path. # @PARAM: path (str) - Absolute or storage-root-relative file path.
# @RETURN: FileResponse - The file content. # @RETURN: FileResponse - The file content.
# #
# @RELATION: CALLS -> StoragePlugin.get_storage_root # @RELATION: CALLS -> [backend.src.plugins.storage.plugin.StoragePlugin.get_storage_root]
# @RELATION: CALLS -> StoragePlugin.validate_path # @RELATION: CALLS -> [backend.src.plugins.storage.plugin.StoragePlugin.validate_path]
@router.get("/file") @router.get("/file")
async def get_file_by_path( async def get_file_by_path(
path: str, path: str,

View File

@@ -1,348 +1,324 @@
# [DEF:TasksRouter:Module] # [DEF:TasksRouter:Module]
# @TIER: STANDARD # @TIER: STANDARD
# @SEMANTICS: api, router, tasks, create, list, get, logs # @SEMANTICS: api, router, tasks, create, list, get, logs
# @PURPOSE: Defines the FastAPI router for task-related endpoints, allowing clients to create, list, and get the status of tasks. # @PURPOSE: Defines the FastAPI router for task-related endpoints, allowing clients to create, list, and get the status of tasks.
# @LAYER: UI (API) # @LAYER: UI (API)
# @RELATION: Depends on the TaskManager. It is included by the main app. # @RELATION: DEPENDS_ON -> [backend.src.core.task_manager.manager.TaskManager]
from typing import List, Dict, Any, Optional # @RELATION: DEPENDS_ON -> [backend.src.core.config_manager.ConfigManager]
from fastapi import APIRouter, Depends, HTTPException, status, Query # @RELATION: DEPENDS_ON -> [backend.src.services.llm_provider.LLMProviderService]
from pydantic import BaseModel
from ...core.logger import belief_scope # [SECTION: IMPORTS]
from typing import List, Dict, Any, Optional
from ...core.task_manager import TaskManager, Task, TaskStatus, LogEntry from fastapi import APIRouter, Depends, HTTPException, status, Query
from ...core.task_manager.models import LogFilter, LogStats from pydantic import BaseModel
from ...dependencies import get_task_manager, has_permission, get_current_user, get_config_manager from ...core.logger import belief_scope
from ...core.config_manager import ConfigManager
from ...services.llm_prompt_templates import ( from ...core.task_manager import TaskManager, Task, TaskStatus, LogEntry
is_multimodal_model, from ...core.task_manager.models import LogFilter, LogStats
normalize_llm_settings, from ...dependencies import get_task_manager, has_permission, get_current_user, get_config_manager
resolve_bound_provider_id, from ...core.config_manager import ConfigManager
) from ...services.llm_prompt_templates import (
is_multimodal_model,
router = APIRouter() normalize_llm_settings,
resolve_bound_provider_id,
TASK_TYPE_PLUGIN_MAP = { )
"llm_validation": ["llm_dashboard_validation"], # [/SECTION]
"backup": ["superset-backup"],
"migration": ["superset-migration"], router = APIRouter()
}
TASK_TYPE_PLUGIN_MAP = {
class CreateTaskRequest(BaseModel): "llm_validation": ["llm_dashboard_validation"],
plugin_id: str "backup": ["superset-backup"],
params: Dict[str, Any] "migration": ["superset-migration"],
}
class ResolveTaskRequest(BaseModel):
resolution_params: Dict[str, Any] class CreateTaskRequest(BaseModel):
plugin_id: str
class ResumeTaskRequest(BaseModel): params: Dict[str, Any]
passwords: Dict[str, str]
class ResolveTaskRequest(BaseModel):
@router.post("", response_model=Task, status_code=status.HTTP_201_CREATED) resolution_params: Dict[str, Any]
# [DEF:create_task:Function]
# @PURPOSE: Create and start a new task for a given plugin. class ResumeTaskRequest(BaseModel):
# @PARAM: request (CreateTaskRequest) - The request body containing plugin_id and params. passwords: Dict[str, str]
# @PARAM: task_manager (TaskManager) - The task manager instance.
# @PRE: plugin_id must exist and params must be valid for that plugin. # [DEF:create_task:Function]
# @POST: A new task is created and started. # @TIER: STANDARD
# @RETURN: Task - The created task instance. # @PURPOSE: Create and start a new task for a given plugin.
async def create_task( # @PARAM: request (CreateTaskRequest) - The request body containing plugin_id and params.
request: CreateTaskRequest, # @PARAM: task_manager (TaskManager) - The task manager instance.
task_manager: TaskManager = Depends(get_task_manager), # @PRE: plugin_id must exist and params must be valid for that plugin.
current_user = Depends(get_current_user), # @POST: A new task is created and started.
config_manager: ConfigManager = Depends(get_config_manager), # @RETURN: Task - The created task instance.
): @router.post("", response_model=Task, status_code=status.HTTP_201_CREATED)
# Dynamic permission check based on plugin_id async def create_task(
has_permission(f"plugin:{request.plugin_id}", "EXECUTE")(current_user) request: CreateTaskRequest,
""" task_manager: TaskManager = Depends(get_task_manager),
Create and start a new task for a given plugin. current_user = Depends(get_current_user),
""" config_manager: ConfigManager = Depends(get_config_manager),
with belief_scope("create_task"): ):
try: # Dynamic permission check based on plugin_id
# Special handling for LLM tasks to resolve provider config by task binding. has_permission(f"plugin:{request.plugin_id}", "EXECUTE")(current_user)
if request.plugin_id in {"llm_dashboard_validation", "llm_documentation"}: with belief_scope("create_task"):
from ...core.database import SessionLocal try:
from ...services.llm_provider import LLMProviderService # Special handling for LLM tasks to resolve provider config by task binding.
db = SessionLocal() if request.plugin_id in {"llm_dashboard_validation", "llm_documentation"}:
try: from ...core.database import SessionLocal
llm_service = LLMProviderService(db) from ...services.llm_provider import LLMProviderService
provider_id = request.params.get("provider_id") db = SessionLocal()
if not provider_id: try:
llm_settings = normalize_llm_settings(config_manager.get_config().settings.llm) llm_service = LLMProviderService(db)
binding_key = "dashboard_validation" if request.plugin_id == "llm_dashboard_validation" else "documentation" provider_id = request.params.get("provider_id")
provider_id = resolve_bound_provider_id(llm_settings, binding_key) if not provider_id:
if provider_id: llm_settings = normalize_llm_settings(config_manager.get_config().settings.llm)
request.params["provider_id"] = provider_id binding_key = "dashboard_validation" if request.plugin_id == "llm_dashboard_validation" else "documentation"
if not provider_id: provider_id = resolve_bound_provider_id(llm_settings, binding_key)
providers = llm_service.get_all_providers() if provider_id:
active_provider = next((p for p in providers if p.is_active), None) request.params["provider_id"] = provider_id
if active_provider: if not provider_id:
provider_id = active_provider.id providers = llm_service.get_all_providers()
request.params["provider_id"] = provider_id active_provider = next((p for p in providers if p.is_active), None)
if active_provider:
if provider_id: provider_id = active_provider.id
db_provider = llm_service.get_provider(provider_id) request.params["provider_id"] = provider_id
if not db_provider:
raise ValueError(f"LLM Provider {provider_id} not found") if provider_id:
if request.plugin_id == "llm_dashboard_validation" and not is_multimodal_model( db_provider = llm_service.get_provider(provider_id)
db_provider.default_model, if not db_provider:
db_provider.provider_type, raise ValueError(f"LLM Provider {provider_id} not found")
): if request.plugin_id == "llm_dashboard_validation" and not is_multimodal_model(
raise HTTPException( db_provider.default_model,
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, db_provider.provider_type,
detail="Selected provider model is not multimodal for dashboard validation", ):
) raise HTTPException(
finally: status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
db.close() detail="Selected provider model is not multimodal for dashboard validation",
)
task = await task_manager.create_task( finally:
plugin_id=request.plugin_id, db.close()
params=request.params
) task = await task_manager.create_task(
return task plugin_id=request.plugin_id,
except ValueError as e: params=request.params
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) )
# [/DEF:create_task:Function] return task
except ValueError as e:
@router.get("", response_model=List[Task]) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [DEF:list_tasks:Function] # [/DEF:create_task:Function]
# @PURPOSE: Retrieve a list of tasks with pagination and optional status filter.
# @PARAM: limit (int) - Maximum number of tasks to return. # [DEF:list_tasks:Function]
# @PARAM: offset (int) - Number of tasks to skip. # @TIER: STANDARD
# @PARAM: status (Optional[TaskStatus]) - Filter by task status. # @PURPOSE: Retrieve a list of tasks with pagination and optional status filter.
# @PARAM: task_manager (TaskManager) - The task manager instance. # @PARAM: limit (int) - Maximum number of tasks to return.
# @PRE: task_manager must be available. # @PARAM: offset (int) - Number of tasks to skip.
# @POST: Returns a list of tasks. # @PARAM: status (Optional[TaskStatus]) - Filter by task status.
# @RETURN: List[Task] - List of tasks. # @PARAM: task_manager (TaskManager) - The task manager instance.
async def list_tasks( # @PRE: task_manager must be available.
limit: int = 10, # @POST: Returns a list of tasks.
offset: int = 0, # @RETURN: List[Task] - List of tasks.
status_filter: Optional[TaskStatus] = Query(None, alias="status"), @router.get("", response_model=List[Task])
task_type: Optional[str] = Query(None, description="Task category: llm_validation, backup, migration"), async def list_tasks(
plugin_id: Optional[List[str]] = Query(None, description="Filter by plugin_id (repeatable query param)"), limit: int = 10,
completed_only: bool = Query(False, description="Return only completed tasks (SUCCESS/FAILED)"), offset: int = 0,
task_manager: TaskManager = Depends(get_task_manager), status_filter: Optional[TaskStatus] = Query(None, alias="status"),
_ = Depends(has_permission("tasks", "READ")) task_type: Optional[str] = Query(None, description="Task category: llm_validation, backup, migration"),
): plugin_id: Optional[List[str]] = Query(None, description="Filter by plugin_id (repeatable query param)"),
""" completed_only: bool = Query(False, description="Return only completed tasks (SUCCESS/FAILED)"),
Retrieve a list of tasks with pagination and optional status filter. task_manager: TaskManager = Depends(get_task_manager),
""" _ = Depends(has_permission("tasks", "READ"))
with belief_scope("list_tasks"): ):
plugin_filters = list(plugin_id) if plugin_id else [] with belief_scope("list_tasks"):
if task_type: plugin_filters = list(plugin_id) if plugin_id else []
if task_type not in TASK_TYPE_PLUGIN_MAP: if task_type:
raise HTTPException( if task_type not in TASK_TYPE_PLUGIN_MAP:
status_code=status.HTTP_400_BAD_REQUEST, raise HTTPException(
detail=f"Unsupported task_type '{task_type}'. Allowed: {', '.join(TASK_TYPE_PLUGIN_MAP.keys())}" status_code=status.HTTP_400_BAD_REQUEST,
) detail=f"Unsupported task_type '{task_type}'. Allowed: {', '.join(TASK_TYPE_PLUGIN_MAP.keys())}"
plugin_filters.extend(TASK_TYPE_PLUGIN_MAP[task_type]) )
plugin_filters.extend(TASK_TYPE_PLUGIN_MAP[task_type])
return task_manager.get_tasks(
limit=limit, return task_manager.get_tasks(
offset=offset, limit=limit,
status=status_filter, offset=offset,
plugin_ids=plugin_filters or None, status=status_filter,
completed_only=completed_only plugin_ids=plugin_filters or None,
) completed_only=completed_only
# [/DEF:list_tasks:Function] )
# [/DEF:list_tasks:Function]
@router.get("/{task_id}", response_model=Task)
# [DEF:get_task:Function] # [DEF:get_task:Function]
# @PURPOSE: Retrieve the details of a specific task. # @TIER: STANDARD
# @PARAM: task_id (str) - The unique identifier of the task. # @PURPOSE: Retrieve the details of a specific task.
# @PARAM: task_manager (TaskManager) - The task manager instance. # @PARAM: task_id (str) - The unique identifier of the task.
# @PRE: task_id must exist. # @PARAM: task_manager (TaskManager) - The task manager instance.
# @POST: Returns task details or raises 404. # @PRE: task_id must exist.
# @RETURN: Task - The task details. # @POST: Returns task details or raises 404.
async def get_task( # @RETURN: Task - The task details.
task_id: str, @router.get("/{task_id}", response_model=Task)
task_manager: TaskManager = Depends(get_task_manager), async def get_task(
_ = Depends(has_permission("tasks", "READ")) task_id: str,
): task_manager: TaskManager = Depends(get_task_manager),
""" _ = Depends(has_permission("tasks", "READ"))
Retrieve the details of a specific task. ):
""" with belief_scope("get_task"):
with belief_scope("get_task"): task = task_manager.get_task(task_id)
task = task_manager.get_task(task_id) if not task:
if not task: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found") return task
return task # [/DEF:get_task:Function]
# [/DEF:get_task:Function]
# [DEF:get_task_logs:Function]
@router.get("/{task_id}/logs", response_model=List[LogEntry]) # @TIER: CRITICAL
# [DEF:get_task_logs:Function] # @PURPOSE: Retrieve logs for a specific task with optional filtering.
# @PURPOSE: Retrieve logs for a specific task with optional filtering. # @PARAM: task_id (str) - The unique identifier of the task.
# @PARAM: task_id (str) - The unique identifier of the task. # @PARAM: level (Optional[str]) - Filter by log level (DEBUG, INFO, WARNING, ERROR).
# @PARAM: level (Optional[str]) - Filter by log level (DEBUG, INFO, WARNING, ERROR). # @PARAM: source (Optional[str]) - Filter by source component.
# @PARAM: source (Optional[str]) - Filter by source component. # @PARAM: search (Optional[str]) - Text search in message.
# @PARAM: search (Optional[str]) - Text search in message. # @PARAM: offset (int) - Number of logs to skip.
# @PARAM: offset (int) - Number of logs to skip. # @PARAM: limit (int) - Maximum number of logs to return.
# @PARAM: limit (int) - Maximum number of logs to return. # @PARAM: task_manager (TaskManager) - The task manager instance.
# @PARAM: task_manager (TaskManager) - The task manager instance. # @PRE: task_id must exist.
# @PRE: task_id must exist. # @POST: Returns a list of log entries or raises 404.
# @POST: Returns a list of log entries or raises 404. # @RETURN: List[LogEntry] - List of log entries.
# @RETURN: List[LogEntry] - List of log entries. # @TEST_CONTRACT: TaskLogQueryInput -> List[LogEntry]
# @TIER: CRITICAL # @TEST_SCENARIO: existing_task_logs_filtered -> Returns filtered logs by level/source/search with pagination.
# @TEST_CONTRACT get_task_logs_api -> # @TEST_FIXTURE: valid_task_with_mixed_logs -> backend/tests/fixtures/task_logs/valid_task_with_mixed_logs.json
# { # @TEST_EDGE: missing_task -> Unknown task_id returns 404 Task not found.
# required_params: {task_id: str}, # @TEST_EDGE: invalid_level_type -> Non-string/invalid level query rejected by validation or yields empty result.
# optional_params: {level: str, source: str, search: str}, # @TEST_EDGE: pagination_bounds -> offset=0 and limit=1000 remain within API bounds and do not overflow.
# invariants: ["returns 404 for non-existent task", "applies filters correctly"] # @TEST_INVARIANT: logs_only_for_existing_task -> VERIFIED_BY: [existing_task_logs_filtered, missing_task]
# } @router.get("/{task_id}/logs", response_model=List[LogEntry])
# @TEST_FIXTURE valid_task_logs_request -> {"task_id": "test_1", "level": "INFO"} async def get_task_logs(
# @TEST_EDGE task_not_found -> raises 404 task_id: str,
# @TEST_EDGE invalid_limit -> Query(limit=0) returns 422 level: Optional[str] = Query(None, description="Filter by log level (DEBUG, INFO, WARNING, ERROR)"),
# @TEST_INVARIANT response_purity -> verifies: [valid_task_logs_request] source: Optional[str] = Query(None, description="Filter by source component"),
# @TEST_CONTRACT: TaskLogQueryInput -> List[LogEntry] search: Optional[str] = Query(None, description="Text search in message"),
# @TEST_SCENARIO: existing_task_logs_filtered -> Returns filtered logs by level/source/search with pagination. offset: int = Query(0, ge=0, description="Number of logs to skip"),
# @TEST_FIXTURE: valid_task_with_mixed_logs -> backend/tests/fixtures/task_logs/valid_task_with_mixed_logs.json limit: int = Query(100, ge=1, le=1000, description="Maximum number of logs to return"),
# @TEST_EDGE: missing_task -> Unknown task_id returns 404 Task not found. task_manager: TaskManager = Depends(get_task_manager),
# @TEST_EDGE: invalid_level_type -> Non-string/invalid level query rejected by validation or yields empty result. _ = Depends(has_permission("tasks", "READ"))
# @TEST_EDGE: pagination_bounds -> offset=0 and limit=1000 remain within API bounds and do not overflow. ):
# @TEST_INVARIANT: logs_only_for_existing_task -> VERIFIED_BY: [existing_task_logs_filtered, missing_task] with belief_scope("get_task_logs"):
async def get_task_logs( task = task_manager.get_task(task_id)
task_id: str, if not task:
level: Optional[str] = Query(None, description="Filter by log level (DEBUG, INFO, WARNING, ERROR)"), raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
source: Optional[str] = Query(None, description="Filter by source component"),
search: Optional[str] = Query(None, description="Text search in message"), log_filter = LogFilter(
offset: int = Query(0, ge=0, description="Number of logs to skip"), level=level.upper() if level else None,
limit: int = Query(100, ge=1, le=1000, description="Maximum number of logs to return"), source=source,
task_manager: TaskManager = Depends(get_task_manager), search=search,
_ = Depends(has_permission("tasks", "READ")) offset=offset,
): limit=limit
""" )
Retrieve logs for a specific task with optional filtering. return task_manager.get_task_logs(task_id, log_filter)
Supports filtering by level, source, and text search. # [/DEF:get_task_logs:Function]
"""
with belief_scope("get_task_logs"): # [DEF:get_task_log_stats:Function]
task = task_manager.get_task(task_id) # @TIER: STANDARD
if not task: # @PURPOSE: Get statistics about logs for a task (counts by level and source).
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found") # @PARAM: task_id (str) - The unique identifier of the task.
# @PARAM: task_manager (TaskManager) - The task manager instance.
log_filter = LogFilter( # @PRE: task_id must exist.
level=level.upper() if level else None, # @POST: Returns log statistics or raises 404.
source=source, # @RETURN: LogStats - Statistics about task logs.
search=search, @router.get("/{task_id}/logs/stats", response_model=LogStats)
offset=offset, async def get_task_log_stats(
limit=limit task_id: str,
) task_manager: TaskManager = Depends(get_task_manager),
return task_manager.get_task_logs(task_id, log_filter) _ = Depends(has_permission("tasks", "READ"))
# [/DEF:get_task_logs:Function] ):
with belief_scope("get_task_log_stats"):
@router.get("/{task_id}/logs/stats", response_model=LogStats) task = task_manager.get_task(task_id)
# [DEF:get_task_log_stats:Function] if not task:
# @PURPOSE: Get statistics about logs for a task (counts by level and source). raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
# @PARAM: task_id (str) - The unique identifier of the task. return task_manager.get_task_log_stats(task_id)
# @PARAM: task_manager (TaskManager) - The task manager instance. # [/DEF:get_task_log_stats:Function]
# @PRE: task_id must exist.
# @POST: Returns log statistics or raises 404. # [DEF:get_task_log_sources:Function]
# @RETURN: LogStats - Statistics about task logs. # @TIER: STANDARD
async def get_task_log_stats( # @PURPOSE: Get unique sources for a task's logs.
task_id: str, # @PARAM: task_id (str) - The unique identifier of the task.
task_manager: TaskManager = Depends(get_task_manager), # @PARAM: task_manager (TaskManager) - The task manager instance.
_ = Depends(has_permission("tasks", "READ")) # @PRE: task_id must exist.
): # @POST: Returns list of unique source names or raises 404.
""" # @RETURN: List[str] - Unique source names.
Get statistics about logs for a task (counts by level and source). @router.get("/{task_id}/logs/sources", response_model=List[str])
""" async def get_task_log_sources(
with belief_scope("get_task_log_stats"): task_id: str,
task = task_manager.get_task(task_id) task_manager: TaskManager = Depends(get_task_manager),
if not task: _ = Depends(has_permission("tasks", "READ"))
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found") ):
return task_manager.get_task_log_stats(task_id) with belief_scope("get_task_log_sources"):
# [/DEF:get_task_log_stats:Function] task = task_manager.get_task(task_id)
if not task:
@router.get("/{task_id}/logs/sources", response_model=List[str]) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
# [DEF:get_task_log_sources:Function] return task_manager.get_task_log_sources(task_id)
# @PURPOSE: Get unique sources for a task's logs. # [/DEF:get_task_log_sources:Function]
# @PARAM: task_id (str) - The unique identifier of the task.
# @PARAM: task_manager (TaskManager) - The task manager instance. # [DEF:resolve_task:Function]
# @PRE: task_id must exist. # @TIER: STANDARD
# @POST: Returns list of unique source names or raises 404. # @PURPOSE: Resolve a task that is awaiting mapping.
# @RETURN: List[str] - Unique source names. # @PARAM: task_id (str) - The unique identifier of the task.
async def get_task_log_sources( # @PARAM: request (ResolveTaskRequest) - The resolution parameters.
task_id: str, # @PARAM: task_manager (TaskManager) - The task manager instance.
task_manager: TaskManager = Depends(get_task_manager), # @PRE: task must be in AWAITING_MAPPING status.
_ = Depends(has_permission("tasks", "READ")) # @POST: Task is resolved and resumes execution.
): # @RETURN: Task - The updated task object.
""" @router.post("/{task_id}/resolve", response_model=Task)
Get unique sources for a task's logs. async def resolve_task(
""" task_id: str,
with belief_scope("get_task_log_sources"): request: ResolveTaskRequest,
task = task_manager.get_task(task_id) task_manager: TaskManager = Depends(get_task_manager),
if not task: _ = Depends(has_permission("tasks", "WRITE"))
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found") ):
return task_manager.get_task_log_sources(task_id) with belief_scope("resolve_task"):
# [/DEF:get_task_log_sources:Function] try:
await task_manager.resolve_task(task_id, request.resolution_params)
@router.post("/{task_id}/resolve", response_model=Task) return task_manager.get_task(task_id)
# [DEF:resolve_task:Function] except ValueError as e:
# @PURPOSE: Resolve a task that is awaiting mapping. raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# @PARAM: task_id (str) - The unique identifier of the task. # [/DEF:resolve_task:Function]
# @PARAM: request (ResolveTaskRequest) - The resolution parameters.
# @PARAM: task_manager (TaskManager) - The task manager instance. # [DEF:resume_task:Function]
# @PRE: task must be in AWAITING_MAPPING status. # @TIER: STANDARD
# @POST: Task is resolved and resumes execution. # @PURPOSE: Resume a task that is awaiting input (e.g., passwords).
# @RETURN: Task - The updated task object. # @PARAM: task_id (str) - The unique identifier of the task.
async def resolve_task( # @PARAM: request (ResumeTaskRequest) - The input (passwords).
task_id: str, # @PARAM: task_manager (TaskManager) - The task manager instance.
request: ResolveTaskRequest, # @PRE: task must be in AWAITING_INPUT status.
task_manager: TaskManager = Depends(get_task_manager), # @POST: Task resumes execution with provided input.
_ = Depends(has_permission("tasks", "WRITE")) # @RETURN: Task - The updated task object.
): @router.post("/{task_id}/resume", response_model=Task)
""" async def resume_task(
Resolve a task that is awaiting mapping. task_id: str,
""" request: ResumeTaskRequest,
with belief_scope("resolve_task"): task_manager: TaskManager = Depends(get_task_manager),
try: _ = Depends(has_permission("tasks", "WRITE"))
await task_manager.resolve_task(task_id, request.resolution_params) ):
return task_manager.get_task(task_id) with belief_scope("resume_task"):
except ValueError as e: try:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) task_manager.resume_task_with_password(task_id, request.passwords)
# [/DEF:resolve_task:Function] return task_manager.get_task(task_id)
except ValueError as e:
@router.post("/{task_id}/resume", response_model=Task) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [DEF:resume_task:Function] # [/DEF:resume_task:Function]
# @PURPOSE: Resume a task that is awaiting input (e.g., passwords).
# @PARAM: task_id (str) - The unique identifier of the task. # [DEF:clear_tasks:Function]
# @PARAM: request (ResumeTaskRequest) - The input (passwords). # @TIER: STANDARD
# @PARAM: task_manager (TaskManager) - The task manager instance. # @PURPOSE: Clear tasks matching the status filter.
# @PRE: task must be in AWAITING_INPUT status. # @PARAM: status (Optional[TaskStatus]) - Filter by task status.
# @POST: Task resumes execution with provided input. # @PARAM: task_manager (TaskManager) - The task manager instance.
# @RETURN: Task - The updated task object. # @PRE: task_manager is available.
async def resume_task( # @POST: Tasks are removed from memory/persistence.
task_id: str, @router.delete("", status_code=status.HTTP_204_NO_CONTENT)
request: ResumeTaskRequest, async def clear_tasks(
task_manager: TaskManager = Depends(get_task_manager), status: Optional[TaskStatus] = None,
_ = Depends(has_permission("tasks", "WRITE")) task_manager: TaskManager = Depends(get_task_manager),
): _ = Depends(has_permission("tasks", "WRITE"))
""" ):
Resume a task that is awaiting input (e.g., passwords). with belief_scope("clear_tasks", f"status={status}"):
""" task_manager.clear_tasks(status)
with belief_scope("resume_task"): return
try: # [/DEF:clear_tasks:Function]
task_manager.resume_task_with_password(task_id, request.passwords)
return task_manager.get_task(task_id) # [/DEF:TasksRouter:Module]
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:resume_task:Function]
@router.delete("", status_code=status.HTTP_204_NO_CONTENT)
# [DEF:clear_tasks:Function]
# @PURPOSE: Clear tasks matching the status filter.
# @PARAM: status (Optional[TaskStatus]) - Filter by task status.
# @PARAM: task_manager (TaskManager) - The task manager instance.
# @PRE: task_manager is available.
# @POST: Tasks are removed from memory/persistence.
async def clear_tasks(
status: Optional[TaskStatus] = None,
task_manager: TaskManager = Depends(get_task_manager),
_ = Depends(has_permission("tasks", "WRITE"))
):
"""
Clear tasks matching the status filter. If no filter, clears all non-running tasks.
"""
with belief_scope("clear_tasks", f"status={status}"):
task_manager.clear_tasks(status)
return
# [/DEF:clear_tasks:Function]
# [/DEF:TasksRouter:Module]

View File

@@ -1,21 +1,27 @@
# [DEF:AppModule:Module] # [DEF:AppModule:Module]
# @TIER: CRITICAL # @TIER: CRITICAL
# @SEMANTICS: app, main, entrypoint, fastapi # @SEMANTICS: app, main, entrypoint, fastapi
# @PURPOSE: The main entry point for the FastAPI application. It initializes the app, configures CORS, sets up dependencies, includes API routers, and defines the WebSocket endpoint for log streaming. # @PURPOSE: The main entry point for the FastAPI application. It initializes the app, configures CORS, sets up dependencies, includes API routers, and defines the WebSocket endpoint for log streaming.
# @LAYER: UI (API) # @LAYER: UI (API)
# @RELATION: Depends on the dependency module and API route modules. # @RELATION: DEPENDS_ON ->[backend.src.dependencies]
# @INVARIANT: Only one FastAPI app instance exists per process. # @RELATION: DEPENDS_ON ->[backend.src.api.routes]
# @INVARIANT: All WebSocket connections must be properly cleaned up on disconnect. # @INVARIANT: Only one FastAPI app instance exists per process.
from pathlib import Path # @INVARIANT: All WebSocket connections must be properly cleaned up on disconnect.
# @PRE: Python environment and dependencies installed; configuration database available.
# project_root is used for static files mounting # @POST: FastAPI app instance is created, middleware configured, and routes registered.
project_root = Path(__file__).resolve().parent.parent.parent # @SIDE_EFFECT: Starts background scheduler and binds network ports for HTTP/WS traffic.
# @DATA_CONTRACT: [HTTP Request | WS Message] -> [HTTP Response | JSON Log Stream]
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, HTTPException
from starlette.middleware.sessions import SessionMiddleware from pathlib import Path
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles # project_root is used for static files mounting
from fastapi.responses import FileResponse project_root = Path(__file__).resolve().parent.parent.parent
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, HTTPException
from starlette.middleware.sessions import SessionMiddleware
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
import asyncio import asyncio
from .dependencies import get_task_manager, get_scheduler_service from .dependencies import get_task_manager, get_scheduler_service
@@ -24,22 +30,24 @@ from .core.utils.network import NetworkError
from .core.logger import logger, belief_scope from .core.logger import logger, belief_scope
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin, llm, dashboards, datasets, reports, assistant, clean_release, clean_release_v2, profile, health from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin, llm, dashboards, datasets, reports, assistant, clean_release, clean_release_v2, profile, health
from .api import auth from .api import auth
# [DEF:App:Global] # [DEF:App:Global]
# @SEMANTICS: app, fastapi, instance # @TIER: TRIVIAL
# @PURPOSE: The global FastAPI application instance. # @SEMANTICS: app, fastapi, instance
app = FastAPI( # @PURPOSE: The global FastAPI application instance.
title="Superset Tools API", app = FastAPI(
description="API for managing Superset automation tools and plugins.", title="Superset Tools API",
version="1.0.0", description="API for managing Superset automation tools and plugins.",
) version="1.0.0",
# [/DEF:App:Global] )
# [/DEF:App:Global]
# [DEF:startup_event:Function]
# @PURPOSE: Handles application startup tasks, such as starting the scheduler. # [DEF:startup_event:Function]
# @PRE: None. # @TIER: STANDARD
# @POST: Scheduler is started. # @PURPOSE: Handles application startup tasks, such as starting the scheduler.
# Startup event # @PRE: None.
# @POST: Scheduler is started.
# Startup event
@app.on_event("startup") @app.on_event("startup")
async def startup_event(): async def startup_event():
with belief_scope("startup_event"): with belief_scope("startup_event"):
@@ -47,259 +55,268 @@ async def startup_event():
scheduler = get_scheduler_service() scheduler = get_scheduler_service()
scheduler.start() scheduler.start()
# [/DEF:startup_event:Function] # [/DEF:startup_event:Function]
# [DEF:shutdown_event:Function] # [DEF:shutdown_event:Function]
# @PURPOSE: Handles application shutdown tasks, such as stopping the scheduler. # @TIER: STANDARD
# @PRE: None. # @PURPOSE: Handles application shutdown tasks, such as stopping the scheduler.
# @POST: Scheduler is stopped. # @PRE: None.
# Shutdown event # @POST: Scheduler is stopped.
@app.on_event("shutdown") # Shutdown event
async def shutdown_event(): @app.on_event("shutdown")
with belief_scope("shutdown_event"): async def shutdown_event():
scheduler = get_scheduler_service() with belief_scope("shutdown_event"):
scheduler.stop() scheduler = get_scheduler_service()
# [/DEF:shutdown_event:Function] scheduler.stop()
# [/DEF:shutdown_event:Function]
# Configure Session Middleware (required by Authlib for OAuth2 flow)
from .core.auth.config import auth_config # Configure Session Middleware (required by Authlib for OAuth2 flow)
app.add_middleware(SessionMiddleware, secret_key=auth_config.SECRET_KEY) from .core.auth.config import auth_config
app.add_middleware(SessionMiddleware, secret_key=auth_config.SECRET_KEY)
# Configure CORS
app.add_middleware( # Configure CORS
CORSMiddleware, app.add_middleware(
allow_origins=["*"], # Adjust this in production CORSMiddleware,
allow_credentials=True, allow_origins=["*"], # Adjust this in production
allow_methods=["*"], allow_credentials=True,
allow_headers=["*"], allow_methods=["*"],
) allow_headers=["*"],
)
# [DEF:network_error_handler:Function]
# @PURPOSE: Global exception handler for NetworkError. # [DEF:network_error_handler:Function]
# @PRE: request is a FastAPI Request object. # @TIER: TRIVIAL
# @POST: Returns 503 HTTP Exception. # @PURPOSE: Global exception handler for NetworkError.
# @PARAM: request (Request) - The incoming request object. # @PRE: request is a FastAPI Request object.
# @PARAM: exc (NetworkError) - The exception instance. # @POST: Returns 503 HTTP Exception.
@app.exception_handler(NetworkError) # @PARAM: request (Request) - The incoming request object.
async def network_error_handler(request: Request, exc: NetworkError): # @PARAM: exc (NetworkError) - The exception instance.
with belief_scope("network_error_handler"): @app.exception_handler(NetworkError)
logger.error(f"Network error: {exc}") async def network_error_handler(request: Request, exc: NetworkError):
return HTTPException( with belief_scope("network_error_handler"):
status_code=503, logger.error(f"Network error: {exc}")
detail="Environment unavailable. Please check if the Superset instance is running." return HTTPException(
) status_code=503,
# [/DEF:network_error_handler:Function] detail="Environment unavailable. Please check if the Superset instance is running."
)
# [DEF:log_requests:Function] # [/DEF:network_error_handler:Function]
# @PURPOSE: Middleware to log incoming HTTP requests and their response status.
# @PRE: request is a FastAPI Request object. # [DEF:log_requests:Function]
# @POST: Logs request and response details. # @TIER: STANDARD
# @PARAM: request (Request) - The incoming request object. # @PURPOSE: Middleware to log incoming HTTP requests and their response status.
# @PARAM: call_next (Callable) - The next middleware or route handler. # @PRE: request is a FastAPI Request object.
@app.middleware("http") # @POST: Logs request and response details.
async def log_requests(request: Request, call_next): # @PARAM: request (Request) - The incoming request object.
with belief_scope("log_requests"): # @PARAM: call_next (Callable) - The next middleware or route handler.
# Avoid spamming logs for polling endpoints @app.middleware("http")
is_polling = request.url.path.endswith("/api/tasks") and request.method == "GET" async def log_requests(request: Request, call_next):
with belief_scope("log_requests"):
if not is_polling: # Avoid spamming logs for polling endpoints
logger.info(f"Incoming request: {request.method} {request.url.path}") is_polling = request.url.path.endswith("/api/tasks") and request.method == "GET"
try: if not is_polling:
response = await call_next(request) logger.info(f"Incoming request: {request.method} {request.url.path}")
if not is_polling:
logger.info(f"Response status: {response.status_code} for {request.url.path}") try:
return response response = await call_next(request)
except NetworkError as e: if not is_polling:
logger.error(f"Network error caught in middleware: {e}") logger.info(f"Response status: {response.status_code} for {request.url.path}")
raise HTTPException( return response
status_code=503, except NetworkError as e:
detail="Environment unavailable. Please check if the Superset instance is running." logger.error(f"Network error caught in middleware: {e}")
) raise HTTPException(
# [/DEF:log_requests:Function] status_code=503,
detail="Environment unavailable. Please check if the Superset instance is running."
# Include API routes )
app.include_router(auth.router) # [/DEF:log_requests:Function]
app.include_router(admin.router)
app.include_router(plugins.router, prefix="/api/plugins", tags=["Plugins"]) # Include API routes
app.include_router(tasks.router, prefix="/api/tasks", tags=["Tasks"]) app.include_router(auth.router)
app.include_router(settings.router, prefix="/api/settings", tags=["Settings"]) app.include_router(admin.router)
app.include_router(connections.router, prefix="/api/settings/connections", tags=["Connections"]) app.include_router(plugins.router, prefix="/api/plugins", tags=["Plugins"])
app.include_router(environments.router, tags=["Environments"]) app.include_router(tasks.router, prefix="/api/tasks", tags=["Tasks"])
app.include_router(mappings.router, prefix="/api/mappings", tags=["Mappings"]) app.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
app.include_router(migration.router) app.include_router(connections.router, prefix="/api/settings/connections", tags=["Connections"])
app.include_router(git.router, prefix="/api/git", tags=["Git"]) app.include_router(environments.router, tags=["Environments"])
app.include_router(llm.router, prefix="/api/llm", tags=["LLM"]) app.include_router(mappings.router, prefix="/api/mappings", tags=["Mappings"])
app.include_router(storage.router, prefix="/api/storage", tags=["Storage"]) app.include_router(migration.router)
app.include_router(dashboards.router) app.include_router(git.router, prefix="/api/git", tags=["Git"])
app.include_router(datasets.router) app.include_router(llm.router, prefix="/api/llm", tags=["LLM"])
app.include_router(reports.router) app.include_router(storage.router, prefix="/api/storage", tags=["Storage"])
app.include_router(assistant.router, prefix="/api/assistant", tags=["Assistant"]) app.include_router(dashboards.router)
app.include_router(clean_release.router) app.include_router(datasets.router)
app.include_router(clean_release_v2.router) app.include_router(reports.router)
app.include_router(profile.router) app.include_router(assistant.router, prefix="/api/assistant", tags=["Assistant"])
app.include_router(health.router) app.include_router(clean_release.router)
app.include_router(clean_release_v2.router)
app.include_router(profile.router)
# [DEF:api.include_routers:Action] app.include_router(health.router)
# @PURPOSE: Registers all API routers with the FastAPI application.
# @LAYER: API
# @SEMANTICS: routes, registration, api # [DEF:api.include_routers:Action]
# [/DEF:api.include_routers:Action] # @TIER: TRIVIAL
# @PURPOSE: Registers all API routers with the FastAPI application.
# [DEF:websocket_endpoint:Function] # @LAYER: API
# @PURPOSE: Provides a WebSocket endpoint for real-time log streaming of a task with server-side filtering. # @SEMANTICS: routes, registration, api
# @PRE: task_id must be a valid task ID. # [/DEF:api.include_routers:Action]
# @POST: WebSocket connection is managed and logs are streamed until disconnect.
# @TIER: CRITICAL # [DEF:websocket_endpoint:Function]
# @UX_STATE: Connecting -> Streaming -> (Disconnected) # @TIER: CRITICAL
# # @PURPOSE: Provides a WebSocket endpoint for real-time log streaming of a task with server-side filtering.
# @TEST_CONTRACT: WebSocketLogStreamApi -> # @PRE: task_id must be a valid task ID.
# { # @POST: WebSocket connection is managed and logs are streamed until disconnect.
# required_fields: {websocket: WebSocket, task_id: str}, # @SIDE_EFFECT: Subscribes to TaskManager log queue and broadcasts messages over network.
# optional_fields: {source: str, level: str}, # @DATA_CONTRACT: [task_id: str, source: str, level: str] -> [JSON log entry objects]
# invariants: [ # @UX_STATE: Connecting -> Streaming -> (Disconnected)
# "Accepts the WebSocket connection", #
# "Applies source and level filters correctly to streamed logs", # @TEST_CONTRACT: WebSocketLogStreamApi ->
# "Cleans up subscriptions on disconnect" # {
# ] # required_fields: {websocket: WebSocket, task_id: str},
# } # optional_fields: {source: str, level: str},
# @TEST_FIXTURE: valid_ws_connection -> {"task_id": "test_1", "source": "plugin"} # invariants: [
# @TEST_EDGE: task_not_found_ws -> closes connection or sends error # "Accepts the WebSocket connection",
# @TEST_EDGE: empty_task_logs -> waits for new logs # "Applies source and level filters correctly to streamed logs",
# @TEST_INVARIANT: consistent_streaming -> verifies: [valid_ws_connection] # "Cleans up subscriptions on disconnect"
@app.websocket("/ws/logs/{task_id}") # ]
async def websocket_endpoint( # }
websocket: WebSocket, # @TEST_FIXTURE: valid_ws_connection -> {"task_id": "test_1", "source": "plugin"}
task_id: str, # @TEST_EDGE: task_not_found_ws -> closes connection or sends error
source: str = None, # @TEST_EDGE: empty_task_logs -> waits for new logs
level: str = None # @TEST_INVARIANT: consistent_streaming -> verifies: [valid_ws_connection]
): @app.websocket("/ws/logs/{task_id}")
""" async def websocket_endpoint(
WebSocket endpoint for real-time log streaming with optional server-side filtering. websocket: WebSocket,
task_id: str,
Query Parameters: source: str = None,
source: Filter logs by source component (e.g., "plugin", "superset_api") level: str = None
level: Filter logs by minimum level (DEBUG, INFO, WARNING, ERROR) ):
""" """
with belief_scope("websocket_endpoint", f"task_id={task_id}"): WebSocket endpoint for real-time log streaming with optional server-side filtering.
await websocket.accept()
Query Parameters:
# Normalize filter parameters source: Filter logs by source component (e.g., "plugin", "superset_api")
source_filter = source.lower() if source else None level: Filter logs by minimum level (DEBUG, INFO, WARNING, ERROR)
level_filter = level.upper() if level else None """
with belief_scope("websocket_endpoint", f"task_id={task_id}"):
# Level hierarchy for filtering await websocket.accept()
level_hierarchy = {"DEBUG": 0, "INFO": 1, "WARNING": 2, "ERROR": 3}
min_level = level_hierarchy.get(level_filter, 0) if level_filter else 0 # Normalize filter parameters
source_filter = source.lower() if source else None
logger.info(f"WebSocket connection accepted for task {task_id} (source={source_filter}, level={level_filter})") level_filter = level.upper() if level else None
task_manager = get_task_manager()
queue = await task_manager.subscribe_logs(task_id) # Level hierarchy for filtering
level_hierarchy = {"DEBUG": 0, "INFO": 1, "WARNING": 2, "ERROR": 3}
def matches_filters(log_entry) -> bool: min_level = level_hierarchy.get(level_filter, 0) if level_filter else 0
"""Check if log entry matches the filter criteria."""
# Check source filter logger.info(f"WebSocket connection accepted for task {task_id} (source={source_filter}, level={level_filter})")
if source_filter and log_entry.source.lower() != source_filter: task_manager = get_task_manager()
return False queue = await task_manager.subscribe_logs(task_id)
# Check level filter def matches_filters(log_entry) -> bool:
if level_filter: """Check if log entry matches the filter criteria."""
log_level = level_hierarchy.get(log_entry.level.upper(), 0) # Check source filter
if log_level < min_level: if source_filter and log_entry.source.lower() != source_filter:
return False return False
return True # Check level filter
if level_filter:
try: log_level = level_hierarchy.get(log_entry.level.upper(), 0)
# Stream new logs if log_level < min_level:
logger.info(f"Starting log stream for task {task_id}") return False
# Send initial logs first to build context (apply filters) return True
initial_logs = task_manager.get_task_logs(task_id)
for log_entry in initial_logs: try:
if matches_filters(log_entry): # Stream new logs
log_dict = log_entry.dict() logger.info(f"Starting log stream for task {task_id}")
log_dict['timestamp'] = log_dict['timestamp'].isoformat()
await websocket.send_json(log_dict) # Send initial logs first to build context (apply filters)
initial_logs = task_manager.get_task_logs(task_id)
# Force a check for AWAITING_INPUT status immediately upon connection for log_entry in initial_logs:
# This ensures that if the task is already waiting when the user connects, they get the prompt. if matches_filters(log_entry):
task = task_manager.get_task(task_id) log_dict = log_entry.dict()
if task and task.status == "AWAITING_INPUT" and task.input_request: log_dict['timestamp'] = log_dict['timestamp'].isoformat()
# Construct a synthetic log entry to trigger the frontend handler await websocket.send_json(log_dict)
# This is a bit of a hack but avoids changing the websocket protocol significantly
synthetic_log = { # Force a check for AWAITING_INPUT status immediately upon connection
"timestamp": task.logs[-1].timestamp.isoformat() if task.logs else "2024-01-01T00:00:00", # This ensures that if the task is already waiting when the user connects, they get the prompt.
"level": "INFO", task = task_manager.get_task(task_id)
"message": "Task paused for user input (Connection Re-established)", if task and task.status == "AWAITING_INPUT" and task.input_request:
"context": {"input_request": task.input_request} # Construct a synthetic log entry to trigger the frontend handler
} # This is a bit of a hack but avoids changing the websocket protocol significantly
await websocket.send_json(synthetic_log) synthetic_log = {
"timestamp": task.logs[-1].timestamp.isoformat() if task.logs else "2024-01-01T00:00:00",
while True: "level": "INFO",
log_entry = await queue.get() "message": "Task paused for user input (Connection Re-established)",
"context": {"input_request": task.input_request}
# Apply server-side filtering }
if not matches_filters(log_entry): await websocket.send_json(synthetic_log)
continue
while True:
log_dict = log_entry.dict() log_entry = await queue.get()
log_dict['timestamp'] = log_dict['timestamp'].isoformat()
await websocket.send_json(log_dict) # Apply server-side filtering
if not matches_filters(log_entry):
# If task is finished, we could potentially close the connection continue
# but let's keep it open for a bit or until the client disconnects
if "Task completed successfully" in log_entry.message or "Task failed" in log_entry.message: log_dict = log_entry.dict()
# Wait a bit to ensure client receives the last message log_dict['timestamp'] = log_dict['timestamp'].isoformat()
await asyncio.sleep(2) await websocket.send_json(log_dict)
# DO NOT BREAK here - allow client to keep connection open if they want to review logs
# or until they disconnect. Breaking closes the socket immediately. # If task is finished, we could potentially close the connection
# break # but let's keep it open for a bit or until the client disconnects
if "Task completed successfully" in log_entry.message or "Task failed" in log_entry.message:
except WebSocketDisconnect: # Wait a bit to ensure client receives the last message
logger.info(f"WebSocket connection disconnected for task {task_id}") await asyncio.sleep(2)
except Exception as e: # DO NOT BREAK here - allow client to keep connection open if they want to review logs
logger.error(f"WebSocket error for task {task_id}: {e}") # or until they disconnect. Breaking closes the socket immediately.
finally: # break
task_manager.unsubscribe_logs(task_id, queue)
# [/DEF:websocket_endpoint:Function] except WebSocketDisconnect:
logger.info(f"WebSocket connection disconnected for task {task_id}")
# [DEF:StaticFiles:Mount] except Exception as e:
# @SEMANTICS: static, frontend, spa logger.error(f"WebSocket error for task {task_id}: {e}")
# @PURPOSE: Mounts the frontend build directory to serve static assets. finally:
frontend_path = project_root / "frontend" / "build" task_manager.unsubscribe_logs(task_id, queue)
if frontend_path.exists(): # [/DEF:websocket_endpoint:Function]
app.mount("/_app", StaticFiles(directory=str(frontend_path / "_app")), name="static")
# [DEF:StaticFiles:Mount]
# [DEF:serve_spa:Function] # @TIER: TRIVIAL
# @PURPOSE: Serves the SPA frontend for any path not matched by API routes. # @SEMANTICS: static, frontend, spa
# @PRE: frontend_path exists. # @PURPOSE: Mounts the frontend build directory to serve static assets.
# @POST: Returns the requested file or index.html. frontend_path = project_root / "frontend" / "build"
@app.get("/{file_path:path}", include_in_schema=False) if frontend_path.exists():
async def serve_spa(file_path: str): app.mount("/_app", StaticFiles(directory=str(frontend_path / "_app")), name="static")
with belief_scope("serve_spa"):
# Only serve SPA for non-API paths # [DEF:serve_spa:Function]
# API routes are registered separately and should be matched by FastAPI first # @TIER: TRIVIAL
if file_path and (file_path.startswith("api/") or file_path.startswith("/api/") or file_path == "api"): # @PURPOSE: Serves the SPA frontend for any path not matched by API routes.
# This should not happen if API routers are properly registered # @PRE: frontend_path exists.
# Return 404 instead of serving HTML # @POST: Returns the requested file or index.html.
raise HTTPException(status_code=404, detail=f"API endpoint not found: {file_path}") @app.get("/{file_path:path}", include_in_schema=False)
async def serve_spa(file_path: str):
full_path = frontend_path / file_path with belief_scope("serve_spa"):
if file_path and full_path.is_file(): # Only serve SPA for non-API paths
return FileResponse(str(full_path)) # API routes are registered separately and should be matched by FastAPI first
return FileResponse(str(frontend_path / "index.html")) if file_path and (file_path.startswith("api/") or file_path.startswith("/api/") or file_path == "api"):
# [/DEF:serve_spa:Function] # This should not happen if API routers are properly registered
else: # Return 404 instead of serving HTML
# [DEF:read_root:Function] raise HTTPException(status_code=404, detail=f"API endpoint not found: {file_path}")
# @PURPOSE: A simple root endpoint to confirm that the API is running when frontend is missing.
# @PRE: None. full_path = frontend_path / file_path
# @POST: Returns a JSON message indicating API status. if file_path and full_path.is_file():
@app.get("/") return FileResponse(str(full_path))
async def read_root(): return FileResponse(str(frontend_path / "index.html"))
with belief_scope("read_root"): # [/DEF:serve_spa:Function]
return {"message": "Superset Tools API is running (Frontend build not found)"} else:
# [/DEF:read_root:Function] # [DEF:read_root:Function]
# [/DEF:StaticFiles:Mount] # @TIER: TRIVIAL
# [/DEF:AppModule:Module] # @PURPOSE: A simple root endpoint to confirm that the API is running when frontend is missing.
# @PRE: None.
# @POST: Returns a JSON message indicating API status.
@app.get("/")
async def read_root():
with belief_scope("read_root"):
return {"message": "Superset Tools API is running (Frontend build not found)"}
# [/DEF:read_root:Function]
# [/DEF:StaticFiles:Mount]
# [/DEF:AppModule:Module]

View File

@@ -4,8 +4,12 @@
# @SEMANTICS: superset, async, client, httpx, dashboards, datasets # @SEMANTICS: superset, async, client, httpx, dashboards, datasets
# @PURPOSE: Async Superset client for dashboard hot-path requests without blocking FastAPI event loop. # @PURPOSE: Async Superset client for dashboard hot-path requests without blocking FastAPI event loop.
# @LAYER: Core # @LAYER: Core
# @RELATION: DEPENDS_ON -> backend.src.core.superset_client # @PRE: Environment configuration is valid and Superset endpoint is reachable.
# @RELATION: DEPENDS_ON -> backend.src.core.utils.async_network.AsyncAPIClient # @POST: Provides non-blocking API access to Superset resources.
# @SIDE_EFFECT: Performs network I/O via httpx.
# @DATA_CONTRACT: Input[Environment] -> Model[dashboard, chart, dataset]
# @RELATION: [DEPENDS_ON] ->[backend.src.core.superset_client]
# @RELATION: [DEPENDS_ON] ->[backend.src.core.utils.async_network.AsyncAPIClient]
# @INVARIANT: Async dashboard operations reuse shared auth cache and avoid sync requests in async routes. # @INVARIANT: Async dashboard operations reuse shared auth cache and avoid sync requests in async routes.
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
@@ -22,9 +26,11 @@ from .utils.async_network import AsyncAPIClient
# [DEF:AsyncSupersetClient:Class] # [DEF:AsyncSupersetClient:Class]
# @TIER: STANDARD
# @PURPOSE: Async sibling of SupersetClient for dashboard read paths. # @PURPOSE: Async sibling of SupersetClient for dashboard read paths.
class AsyncSupersetClient(SupersetClient): class AsyncSupersetClient(SupersetClient):
# [DEF:__init__:Function] # [DEF:__init__:Function]
# @TIER: STANDARD
# @PURPOSE: Initialize async Superset client with AsyncAPIClient transport. # @PURPOSE: Initialize async Superset client with AsyncAPIClient transport.
# @PRE: env is valid. # @PRE: env is valid.
# @POST: Client uses async network transport and inherited projection helpers. # @POST: Client uses async network transport and inherited projection helpers.
@@ -45,6 +51,7 @@ class AsyncSupersetClient(SupersetClient):
# [/DEF:__init__:Function] # [/DEF:__init__:Function]
# [DEF:aclose:Function] # [DEF:aclose:Function]
# @TIER: STANDARD
# @PURPOSE: Close async transport resources. # @PURPOSE: Close async transport resources.
# @POST: Underlying AsyncAPIClient is closed. # @POST: Underlying AsyncAPIClient is closed.
async def aclose(self) -> None: async def aclose(self) -> None:
@@ -52,6 +59,7 @@ class AsyncSupersetClient(SupersetClient):
# [/DEF:aclose:Function] # [/DEF:aclose:Function]
# [DEF:get_dashboards_page_async:Function] # [DEF:get_dashboards_page_async:Function]
# @TIER: STANDARD
# @PURPOSE: Fetch one dashboards page asynchronously. # @PURPOSE: Fetch one dashboards page asynchronously.
# @POST: Returns total count and page result list. # @POST: Returns total count and page result list.
async def get_dashboards_page_async(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]: async def get_dashboards_page_async(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
@@ -85,6 +93,7 @@ class AsyncSupersetClient(SupersetClient):
# [/DEF:get_dashboards_page_async:Function] # [/DEF:get_dashboards_page_async:Function]
# [DEF:get_dashboard_async:Function] # [DEF:get_dashboard_async:Function]
# @TIER: STANDARD
# @PURPOSE: Fetch one dashboard payload asynchronously. # @PURPOSE: Fetch one dashboard payload asynchronously.
# @POST: Returns raw dashboard payload from Superset API. # @POST: Returns raw dashboard payload from Superset API.
async def get_dashboard_async(self, dashboard_id: int) -> Dict: async def get_dashboard_async(self, dashboard_id: int) -> Dict:
@@ -94,6 +103,7 @@ class AsyncSupersetClient(SupersetClient):
# [/DEF:get_dashboard_async:Function] # [/DEF:get_dashboard_async:Function]
# [DEF:get_chart_async:Function] # [DEF:get_chart_async:Function]
# @TIER: STANDARD
# @PURPOSE: Fetch one chart payload asynchronously. # @PURPOSE: Fetch one chart payload asynchronously.
# @POST: Returns raw chart payload from Superset API. # @POST: Returns raw chart payload from Superset API.
async def get_chart_async(self, chart_id: int) -> Dict: async def get_chart_async(self, chart_id: int) -> Dict:
@@ -103,6 +113,7 @@ class AsyncSupersetClient(SupersetClient):
# [/DEF:get_chart_async:Function] # [/DEF:get_chart_async:Function]
# [DEF:get_dashboard_detail_async:Function] # [DEF:get_dashboard_detail_async:Function]
# @TIER: STANDARD
# @PURPOSE: Fetch dashboard detail asynchronously with concurrent charts/datasets requests. # @PURPOSE: Fetch dashboard detail asynchronously with concurrent charts/datasets requests.
# @POST: Returns dashboard detail payload for overview page. # @POST: Returns dashboard detail payload for overview page.
async def get_dashboard_detail_async(self, dashboard_id: int) -> Dict: async def get_dashboard_detail_async(self, dashboard_id: int) -> Dict:

View File

@@ -4,6 +4,10 @@
# @SEMANTICS: auth, repository, database, user, role, permission # @SEMANTICS: auth, repository, database, user, role, permission
# @PURPOSE: Data access layer for authentication and user preference entities. # @PURPOSE: Data access layer for authentication and user preference entities.
# @LAYER: Domain # @LAYER: Domain
# @PRE: SQLAlchemy session manager and auth models are available.
# @POST: Provides transactional access to Auth-related database entities.
# @SIDE_EFFECT: Performs database I/O via SQLAlchemy sessions.
# @DATA_CONTRACT: Input[Session] -> Model[User, Role, Permission, UserDashboardPreference]
# @RELATION: [DEPENDS_ON] ->[sqlalchemy.orm.Session] # @RELATION: [DEPENDS_ON] ->[sqlalchemy.orm.Session]
# @RELATION: [DEPENDS_ON] ->[backend.src.models.auth] # @RELATION: [DEPENDS_ON] ->[backend.src.models.auth]
# @RELATION: [DEPENDS_ON] ->[backend.src.models.profile] # @RELATION: [DEPENDS_ON] ->[backend.src.models.profile]
@@ -21,10 +25,12 @@ from ..logger import belief_scope, logger
# [/SECTION] # [/SECTION]
# [DEF:AuthRepository:Class] # [DEF:AuthRepository:Class]
# @TIER: CRITICAL
# @PURPOSE: Encapsulates database operations for authentication-related entities. # @PURPOSE: Encapsulates database operations for authentication-related entities.
# @RELATION: [DEPENDS_ON] ->[sqlalchemy.orm.Session] # @RELATION: [DEPENDS_ON] ->[sqlalchemy.orm.Session]
class AuthRepository: class AuthRepository:
# [DEF:__init__:Function] # [DEF:__init__:Function]
# @TIER: CRITICAL
# @PURPOSE: Bind repository instance to an existing SQLAlchemy session. # @PURPOSE: Bind repository instance to an existing SQLAlchemy session.
# @PRE: db is an initialized sqlalchemy.orm.Session instance. # @PRE: db is an initialized sqlalchemy.orm.Session instance.
# @POST: self.db points to the provided session and is used by all repository methods. # @POST: self.db points to the provided session and is used by all repository methods.
@@ -42,6 +48,7 @@ class AuthRepository:
# [/DEF:__init__:Function] # [/DEF:__init__:Function]
# [DEF:get_user_by_username:Function] # [DEF:get_user_by_username:Function]
# @TIER: CRITICAL
# @PURPOSE: Retrieve a user entity by unique username. # @PURPOSE: Retrieve a user entity by unique username.
# @PRE: username is a non-empty str and self.db is a valid open Session. # @PRE: username is a non-empty str and self.db is a valid open Session.
# @POST: Returns matching User entity when present, otherwise None. # @POST: Returns matching User entity when present, otherwise None.
@@ -68,6 +75,7 @@ class AuthRepository:
# [/DEF:get_user_by_username:Function] # [/DEF:get_user_by_username:Function]
# [DEF:get_user_by_id:Function] # [DEF:get_user_by_id:Function]
# @TIER: CRITICAL
# @PURPOSE: Retrieve a user entity by identifier. # @PURPOSE: Retrieve a user entity by identifier.
# @PRE: user_id is a non-empty str and self.db is a valid open Session. # @PRE: user_id is a non-empty str and self.db is a valid open Session.
# @POST: Returns matching User entity when present, otherwise None. # @POST: Returns matching User entity when present, otherwise None.
@@ -89,6 +97,7 @@ class AuthRepository:
# [/DEF:get_user_by_id:Function] # [/DEF:get_user_by_id:Function]
# [DEF:get_role_by_name:Function] # [DEF:get_role_by_name:Function]
# @TIER: CRITICAL
# @PURPOSE: Retrieve a role entity by role name. # @PURPOSE: Retrieve a role entity by role name.
# @PRE: name is a non-empty str and self.db is a valid open Session. # @PRE: name is a non-empty str and self.db is a valid open Session.
# @POST: Returns matching Role entity when present, otherwise None. # @POST: Returns matching Role entity when present, otherwise None.
@@ -100,6 +109,7 @@ class AuthRepository:
# [/DEF:get_role_by_name:Function] # [/DEF:get_role_by_name:Function]
# [DEF:update_last_login:Function] # [DEF:update_last_login:Function]
# @TIER: CRITICAL
# @PURPOSE: Update last_login timestamp for the provided user entity. # @PURPOSE: Update last_login timestamp for the provided user entity.
# @PRE: user is a managed User instance and self.db is a valid open Session. # @PRE: user is a managed User instance and self.db is a valid open Session.
# @POST: user.last_login is set to current UTC timestamp and transaction is committed. # @POST: user.last_login is set to current UTC timestamp and transaction is committed.
@@ -119,6 +129,7 @@ class AuthRepository:
# [/DEF:update_last_login:Function] # [/DEF:update_last_login:Function]
# [DEF:get_role_by_id:Function] # [DEF:get_role_by_id:Function]
# @TIER: CRITICAL
# @PURPOSE: Retrieve a role entity by identifier. # @PURPOSE: Retrieve a role entity by identifier.
# @PRE: role_id is a non-empty str and self.db is a valid open Session. # @PRE: role_id is a non-empty str and self.db is a valid open Session.
# @POST: Returns matching Role entity when present, otherwise None. # @POST: Returns matching Role entity when present, otherwise None.
@@ -130,6 +141,7 @@ class AuthRepository:
# [/DEF:get_role_by_id:Function] # [/DEF:get_role_by_id:Function]
# [DEF:get_permission_by_id:Function] # [DEF:get_permission_by_id:Function]
# @TIER: CRITICAL
# @PURPOSE: Retrieve a permission entity by identifier. # @PURPOSE: Retrieve a permission entity by identifier.
# @PRE: perm_id is a non-empty str and self.db is a valid open Session. # @PRE: perm_id is a non-empty str and self.db is a valid open Session.
# @POST: Returns matching Permission entity when present, otherwise None. # @POST: Returns matching Permission entity when present, otherwise None.
@@ -141,6 +153,7 @@ class AuthRepository:
# [/DEF:get_permission_by_id:Function] # [/DEF:get_permission_by_id:Function]
# [DEF:get_permission_by_resource_action:Function] # [DEF:get_permission_by_resource_action:Function]
# @TIER: CRITICAL
# @PURPOSE: Retrieve a permission entity by resource and action pair. # @PURPOSE: Retrieve a permission entity by resource and action pair.
# @PRE: resource and action are non-empty str values; self.db is a valid open Session. # @PRE: resource and action are non-empty str values; self.db is a valid open Session.
# @POST: Returns matching Permission entity when present, otherwise None. # @POST: Returns matching Permission entity when present, otherwise None.
@@ -155,6 +168,7 @@ class AuthRepository:
# [/DEF:get_permission_by_resource_action:Function] # [/DEF:get_permission_by_resource_action:Function]
# [DEF:get_user_dashboard_preference:Function] # [DEF:get_user_dashboard_preference:Function]
# @TIER: CRITICAL
# @PURPOSE: Retrieve dashboard preference entity owned by specified user. # @PURPOSE: Retrieve dashboard preference entity owned by specified user.
# @PRE: user_id is a non-empty str and self.db is a valid open Session. # @PRE: user_id is a non-empty str and self.db is a valid open Session.
# @POST: Returns matching UserDashboardPreference entity when present, otherwise None. # @POST: Returns matching UserDashboardPreference entity when present, otherwise None.
@@ -170,6 +184,7 @@ class AuthRepository:
# [/DEF:get_user_dashboard_preference:Function] # [/DEF:get_user_dashboard_preference:Function]
# [DEF:save_user_dashboard_preference:Function] # [DEF:save_user_dashboard_preference:Function]
# @TIER: CRITICAL
# @PURPOSE: Persist dashboard preference entity and return refreshed persistent row. # @PURPOSE: Persist dashboard preference entity and return refreshed persistent row.
# @PRE: preference is a valid UserDashboardPreference entity and self.db is a valid open Session. # @PRE: preference is a valid UserDashboardPreference entity and self.db is a valid open Session.
# @POST: preference is committed to DB, refreshed from DB state, and returned. # @POST: preference is committed to DB, refreshed from DB state, and returned.
@@ -192,6 +207,7 @@ class AuthRepository:
# [/DEF:save_user_dashboard_preference:Function] # [/DEF:save_user_dashboard_preference:Function]
# [DEF:list_permissions:Function] # [DEF:list_permissions:Function]
# @TIER: CRITICAL
# @PURPOSE: List all permission entities available in storage. # @PURPOSE: List all permission entities available in storage.
# @PRE: self.db is a valid open Session. # @PRE: self.db is a valid open Session.
# @POST: Returns list containing all Permission entities visible to the session. # @POST: Returns list containing all Permission entities visible to the session.

View File

@@ -4,12 +4,15 @@
# @SEMANTICS: config, manager, persistence, migration, postgresql # @SEMANTICS: config, manager, persistence, migration, postgresql
# @PURPOSE: Manages application configuration persistence in DB with one-time migration from legacy JSON. # @PURPOSE: Manages application configuration persistence in DB with one-time migration from legacy JSON.
# @LAYER: Domain # @LAYER: Domain
# @RELATION: [DEPENDS_ON] ->[ConfigModels] # @PRE: Database schema for AppConfigRecord must be initialized.
# @RELATION: [DEPENDS_ON] ->[SessionLocal] # @POST: Configuration is loaded into memory and logger is configured.
# @RELATION: [DEPENDS_ON] ->[AppConfigRecord] # @SIDE_EFFECT: Performs DB I/O and may update global logging level.
# @RELATION: [CALLS] ->[logger] # @DATA_CONTRACT: Input[json, record] -> Model[AppConfig]
# @RELATION: [CALLS] ->[configure_logger] # @RELATION: [DEPENDS_ON] ->[backend.src.core.config_models.AppConfig]
# @RELATION: [BINDS_TO] ->[ConfigManager] # @RELATION: [DEPENDS_ON] ->[backend.src.core.database.SessionLocal]
# @RELATION: [DEPENDS_ON] ->[backend.src.models.config.AppConfigRecord]
# @RELATION: [CALLS] ->[backend.src.core.logger.logger]
# @RELATION: [CALLS] ->[backend.src.core.logger.configure_logger]
# @INVARIANT: Configuration must always be representable by AppConfig and persisted under global record id. # @INVARIANT: Configuration must always be representable by AppConfig and persisted under global record id.
# #
import json import json
@@ -57,6 +60,7 @@ class ConfigManager:
# [/DEF:__init__:Function] # [/DEF:__init__:Function]
# [DEF:_default_config:Function] # [DEF:_default_config:Function]
# @TIER: STANDARD
# @PURPOSE: Build default application configuration fallback. # @PURPOSE: Build default application configuration fallback.
# @PRE: None. # @PRE: None.
# @POST: Returns valid AppConfig with empty environments and default storage settings. # @POST: Returns valid AppConfig with empty environments and default storage settings.
@@ -71,6 +75,7 @@ class ConfigManager:
# [/DEF:_default_config:Function] # [/DEF:_default_config:Function]
# [DEF:_sync_raw_payload_from_config:Function] # [DEF:_sync_raw_payload_from_config:Function]
# @TIER: STANDARD
# @PURPOSE: Merge typed AppConfig state into raw payload while preserving unsupported legacy sections. # @PURPOSE: Merge typed AppConfig state into raw payload while preserving unsupported legacy sections.
# @PRE: self.config is initialized as AppConfig. # @PRE: self.config is initialized as AppConfig.
# @POST: self.raw_payload contains AppConfig fields refreshed from self.config. # @POST: self.raw_payload contains AppConfig fields refreshed from self.config.
@@ -85,6 +90,7 @@ class ConfigManager:
# [/DEF:_sync_raw_payload_from_config:Function] # [/DEF:_sync_raw_payload_from_config:Function]
# [DEF:_load_from_legacy_file:Function] # [DEF:_load_from_legacy_file:Function]
# @TIER: STANDARD
# @PURPOSE: Load legacy JSON configuration for migration fallback path. # @PURPOSE: Load legacy JSON configuration for migration fallback path.
# @PRE: self.config_path is initialized. # @PRE: self.config_path is initialized.
# @POST: Returns AppConfig from file payload or safe default. # @POST: Returns AppConfig from file payload or safe default.
@@ -110,6 +116,7 @@ class ConfigManager:
# [/DEF:_load_from_legacy_file:Function] # [/DEF:_load_from_legacy_file:Function]
# [DEF:_get_record:Function] # [DEF:_get_record:Function]
# @TIER: STANDARD
# @PURPOSE: Resolve global configuration record from DB. # @PURPOSE: Resolve global configuration record from DB.
# @PRE: session is an active SQLAlchemy Session. # @PRE: session is an active SQLAlchemy Session.
# @POST: Returns record when present, otherwise None. # @POST: Returns record when present, otherwise None.
@@ -121,6 +128,7 @@ class ConfigManager:
# [/DEF:_get_record:Function] # [/DEF:_get_record:Function]
# [DEF:_load_config:Function] # [DEF:_load_config:Function]
# @TIER: STANDARD
# @PURPOSE: Load configuration from DB or perform one-time migration from legacy JSON. # @PURPOSE: Load configuration from DB or perform one-time migration from legacy JSON.
# @PRE: SessionLocal factory is available and AppConfigRecord schema is accessible. # @PRE: SessionLocal factory is available and AppConfigRecord schema is accessible.
# @POST: Returns valid AppConfig and closes opened DB session. # @POST: Returns valid AppConfig and closes opened DB session.
@@ -152,6 +160,7 @@ class ConfigManager:
# [/DEF:_load_config:Function] # [/DEF:_load_config:Function]
# [DEF:_save_config_to_db:Function] # [DEF:_save_config_to_db:Function]
# @TIER: STANDARD
# @PURPOSE: Persist provided AppConfig into the global DB configuration record. # @PURPOSE: Persist provided AppConfig into the global DB configuration record.
# @PRE: config is AppConfig; session is either None or an active Session. # @PRE: config is AppConfig; session is either None or an active Session.
# @POST: Global DB record payload equals config.model_dump() when commit succeeds. # @POST: Global DB record payload equals config.model_dump() when commit succeeds.
@@ -186,6 +195,7 @@ class ConfigManager:
# [/DEF:_save_config_to_db:Function] # [/DEF:_save_config_to_db:Function]
# [DEF:save:Function] # [DEF:save:Function]
# @TIER: STANDARD
# @PURPOSE: Persist current in-memory configuration state. # @PURPOSE: Persist current in-memory configuration state.
# @PRE: self.config is initialized. # @PRE: self.config is initialized.
# @POST: Current self.config is written to DB global record. # @POST: Current self.config is written to DB global record.
@@ -197,6 +207,7 @@ class ConfigManager:
# [/DEF:save:Function] # [/DEF:save:Function]
# [DEF:get_config:Function] # [DEF:get_config:Function]
# @TIER: STANDARD
# @PURPOSE: Return current in-memory configuration snapshot. # @PURPOSE: Return current in-memory configuration snapshot.
# @PRE: self.config is initialized. # @PRE: self.config is initialized.
# @POST: Returns AppConfig reference stored in manager. # @POST: Returns AppConfig reference stored in manager.
@@ -208,6 +219,7 @@ class ConfigManager:
# [/DEF:get_config:Function] # [/DEF:get_config:Function]
# [DEF:get_payload:Function] # [DEF:get_payload:Function]
# @TIER: STANDARD
# @PURPOSE: Return full persisted payload including sections outside typed AppConfig schema. # @PURPOSE: Return full persisted payload including sections outside typed AppConfig schema.
# @PRE: Manager state is initialized. # @PRE: Manager state is initialized.
# @POST: Returns dict payload with current AppConfig fields synchronized. # @POST: Returns dict payload with current AppConfig fields synchronized.
@@ -219,6 +231,7 @@ class ConfigManager:
# [/DEF:get_payload:Function] # [/DEF:get_payload:Function]
# [DEF:save_config:Function] # [DEF:save_config:Function]
# @TIER: STANDARD
# @PURPOSE: Persist configuration provided either as typed AppConfig or raw payload dict. # @PURPOSE: Persist configuration provided either as typed AppConfig or raw payload dict.
# @PRE: config is AppConfig or dict compatible with AppConfig core schema. # @PRE: config is AppConfig or dict compatible with AppConfig core schema.
# @POST: self.config and self.raw_payload are synchronized and persisted to DB. # @POST: self.config and self.raw_payload are synchronized and persisted to DB.
@@ -240,6 +253,7 @@ class ConfigManager:
# [/DEF:save_config:Function] # [/DEF:save_config:Function]
# [DEF:update_global_settings:Function] # [DEF:update_global_settings:Function]
# @TIER: STANDARD
# @PURPOSE: Replace global settings and persist the resulting configuration. # @PURPOSE: Replace global settings and persist the resulting configuration.
# @PRE: settings is GlobalSettings. # @PRE: settings is GlobalSettings.
# @POST: self.config.settings equals provided settings and DB state is updated. # @POST: self.config.settings equals provided settings and DB state is updated.
@@ -258,6 +272,7 @@ class ConfigManager:
# [/DEF:update_global_settings:Function] # [/DEF:update_global_settings:Function]
# [DEF:validate_path:Function] # [DEF:validate_path:Function]
# @TIER: STANDARD
# @PURPOSE: Validate that path exists and is writable, creating it when absent. # @PURPOSE: Validate that path exists and is writable, creating it when absent.
# @PRE: path is a string path candidate. # @PRE: path is a string path candidate.
# @POST: Returns (True, msg) for writable path, else (False, reason). # @POST: Returns (True, msg) for writable path, else (False, reason).
@@ -279,6 +294,7 @@ class ConfigManager:
# [/DEF:validate_path:Function] # [/DEF:validate_path:Function]
# [DEF:get_environments:Function] # [DEF:get_environments:Function]
# @TIER: STANDARD
# @PURPOSE: Return all configured environments. # @PURPOSE: Return all configured environments.
# @PRE: self.config is initialized. # @PRE: self.config is initialized.
# @POST: Returns list of Environment models from current configuration. # @POST: Returns list of Environment models from current configuration.
@@ -290,6 +306,7 @@ class ConfigManager:
# [/DEF:get_environments:Function] # [/DEF:get_environments:Function]
# [DEF:has_environments:Function] # [DEF:has_environments:Function]
# @TIER: STANDARD
# @PURPOSE: Check whether at least one environment exists in configuration. # @PURPOSE: Check whether at least one environment exists in configuration.
# @PRE: self.config is initialized. # @PRE: self.config is initialized.
# @POST: Returns True iff environment list length is greater than zero. # @POST: Returns True iff environment list length is greater than zero.
@@ -301,6 +318,7 @@ class ConfigManager:
# [/DEF:has_environments:Function] # [/DEF:has_environments:Function]
# [DEF:get_environment:Function] # [DEF:get_environment:Function]
# @TIER: STANDARD
# @PURPOSE: Resolve a configured environment by identifier. # @PURPOSE: Resolve a configured environment by identifier.
# @PRE: env_id is string identifier. # @PRE: env_id is string identifier.
# @POST: Returns matching Environment when found; otherwise None. # @POST: Returns matching Environment when found; otherwise None.
@@ -315,6 +333,7 @@ class ConfigManager:
# [/DEF:get_environment:Function] # [/DEF:get_environment:Function]
# [DEF:add_environment:Function] # [DEF:add_environment:Function]
# @TIER: STANDARD
# @PURPOSE: Upsert environment by id into configuration and persist. # @PURPOSE: Upsert environment by id into configuration and persist.
# @PRE: env is Environment. # @PRE: env is Environment.
# @POST: Configuration contains provided env id with new payload persisted. # @POST: Configuration contains provided env id with new payload persisted.
@@ -333,6 +352,7 @@ class ConfigManager:
# [/DEF:add_environment:Function] # [/DEF:add_environment:Function]
# [DEF:update_environment:Function] # [DEF:update_environment:Function]
# @TIER: STANDARD
# @PURPOSE: Update existing environment by id and preserve masked password placeholder behavior. # @PURPOSE: Update existing environment by id and preserve masked password placeholder behavior.
# @PRE: env_id is non-empty string and updated_env is Environment. # @PRE: env_id is non-empty string and updated_env is Environment.
# @POST: Returns True and persists update when target exists; else returns False. # @POST: Returns True and persists update when target exists; else returns False.
@@ -362,6 +382,7 @@ class ConfigManager:
# [/DEF:update_environment:Function] # [/DEF:update_environment:Function]
# [DEF:delete_environment:Function] # [DEF:delete_environment:Function]
# @TIER: STANDARD
# @PURPOSE: Delete environment by id and persist when deletion occurs. # @PURPOSE: Delete environment by id and persist when deletion occurs.
# @PRE: env_id is non-empty string. # @PRE: env_id is non-empty string.
# @POST: Environment is removed when present; otherwise configuration is unchanged. # @POST: Environment is removed when present; otherwise configuration is unchanged.

View File

@@ -4,9 +4,9 @@
# @SEMANTICS: database, postgresql, sqlalchemy, session, persistence # @SEMANTICS: database, postgresql, sqlalchemy, session, persistence
# @PURPOSE: Configures database connection and session management (PostgreSQL-first). # @PURPOSE: Configures database connection and session management (PostgreSQL-first).
# @LAYER: Core # @LAYER: Core
# @RELATION: DEPENDS_ON -> sqlalchemy # @RELATION: DEPENDS_ON ->[sqlalchemy]
# @RELATION: DEPENDS_ON -> backend.src.models.mapping # @RELATION: DEPENDS_ON ->[backend.src.models.mapping]
# @RELATION: DEPENDS_ON -> backend.src.core.auth.config # @RELATION: DEPENDS_ON ->[backend.src.core.auth.config]
# #
# @INVARIANT: A single engine instance is used for the entire application. # @INVARIANT: A single engine instance is used for the entire application.
@@ -31,11 +31,13 @@ from pathlib import Path
# [/SECTION] # [/SECTION]
# [DEF:BASE_DIR:Variable] # [DEF:BASE_DIR:Variable]
# @TIER: TRIVIAL
# @PURPOSE: Base directory for the backend. # @PURPOSE: Base directory for the backend.
BASE_DIR = Path(__file__).resolve().parent.parent.parent BASE_DIR = Path(__file__).resolve().parent.parent.parent
# [/DEF:BASE_DIR:Variable] # [/DEF:BASE_DIR:Variable]
# [DEF:DATABASE_URL:Constant] # [DEF:DATABASE_URL:Constant]
# @TIER: TRIVIAL
# @PURPOSE: URL for the main application database. # @PURPOSE: URL for the main application database.
DEFAULT_POSTGRES_URL = os.getenv( DEFAULT_POSTGRES_URL = os.getenv(
"POSTGRES_URL", "POSTGRES_URL",
@@ -45,34 +47,39 @@ DATABASE_URL = os.getenv("DATABASE_URL", DEFAULT_POSTGRES_URL)
# [/DEF:DATABASE_URL:Constant] # [/DEF:DATABASE_URL:Constant]
# [DEF:TASKS_DATABASE_URL:Constant] # [DEF:TASKS_DATABASE_URL:Constant]
# @TIER: TRIVIAL
# @PURPOSE: URL for the tasks execution database. # @PURPOSE: URL for the tasks execution database.
# Defaults to DATABASE_URL to keep task logs in the same PostgreSQL instance. # Defaults to DATABASE_URL to keep task logs in the same PostgreSQL instance.
TASKS_DATABASE_URL = os.getenv("TASKS_DATABASE_URL", DATABASE_URL) TASKS_DATABASE_URL = os.getenv("TASKS_DATABASE_URL", DATABASE_URL)
# [/DEF:TASKS_DATABASE_URL:Constant] # [/DEF:TASKS_DATABASE_URL:Constant]
# [DEF:AUTH_DATABASE_URL:Constant] # [DEF:AUTH_DATABASE_URL:Constant]
# @TIER: TRIVIAL
# @PURPOSE: URL for the authentication database. # @PURPOSE: URL for the authentication database.
AUTH_DATABASE_URL = os.getenv("AUTH_DATABASE_URL", auth_config.AUTH_DATABASE_URL) AUTH_DATABASE_URL = os.getenv("AUTH_DATABASE_URL", auth_config.AUTH_DATABASE_URL)
# [/DEF:AUTH_DATABASE_URL:Constant] # [/DEF:AUTH_DATABASE_URL:Constant]
# [DEF:engine:Variable] # [DEF:engine:Variable]
# @TIER: TRIVIAL
# @PURPOSE: SQLAlchemy engine for mappings database.
# @SIDE_EFFECT: Creates database engine and manages connection pool.
def _build_engine(db_url: str): def _build_engine(db_url: str):
with belief_scope("_build_engine"): with belief_scope("_build_engine"):
if db_url.startswith("sqlite"): if db_url.startswith("sqlite"):
return create_engine(db_url, connect_args={"check_same_thread": False}) return create_engine(db_url, connect_args={"check_same_thread": False})
return create_engine(db_url, pool_pre_ping=True) return create_engine(db_url, pool_pre_ping=True)
# @PURPOSE: SQLAlchemy engine for mappings database.
engine = _build_engine(DATABASE_URL) engine = _build_engine(DATABASE_URL)
# [/DEF:engine:Variable] # [/DEF:engine:Variable]
# [DEF:tasks_engine:Variable] # [DEF:tasks_engine:Variable]
# @TIER: TRIVIAL
# @PURPOSE: SQLAlchemy engine for tasks database. # @PURPOSE: SQLAlchemy engine for tasks database.
tasks_engine = _build_engine(TASKS_DATABASE_URL) tasks_engine = _build_engine(TASKS_DATABASE_URL)
# [/DEF:tasks_engine:Variable] # [/DEF:tasks_engine:Variable]
# [DEF:auth_engine:Variable] # [DEF:auth_engine:Variable]
# @TIER: TRIVIAL
# @PURPOSE: SQLAlchemy engine for authentication database. # @PURPOSE: SQLAlchemy engine for authentication database.
auth_engine = _build_engine(AUTH_DATABASE_URL) auth_engine = _build_engine(AUTH_DATABASE_URL)
# [/DEF:auth_engine:Variable] # [/DEF:auth_engine:Variable]
@@ -99,6 +106,7 @@ AuthSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=auth_eng
# [/DEF:AuthSessionLocal:Class] # [/DEF:AuthSessionLocal:Class]
# [DEF:_ensure_user_dashboard_preferences_columns:Function] # [DEF:_ensure_user_dashboard_preferences_columns:Function]
# @TIER: STANDARD
# @PURPOSE: Applies additive schema upgrades for user_dashboard_preferences table. # @PURPOSE: Applies additive schema upgrades for user_dashboard_preferences table.
# @PRE: bind_engine points to application database where profile table is stored. # @PRE: bind_engine points to application database where profile table is stored.
# @POST: Missing columns are added without data loss. # @POST: Missing columns are added without data loss.
@@ -165,6 +173,7 @@ def _ensure_user_dashboard_preferences_columns(bind_engine):
# [DEF:_ensure_user_dashboard_preferences_health_columns:Function] # [DEF:_ensure_user_dashboard_preferences_health_columns:Function]
# @TIER: STANDARD
# @PURPOSE: Applies additive schema upgrades for user_dashboard_preferences table (health fields). # @PURPOSE: Applies additive schema upgrades for user_dashboard_preferences table (health fields).
def _ensure_user_dashboard_preferences_health_columns(bind_engine): def _ensure_user_dashboard_preferences_health_columns(bind_engine):
with belief_scope("_ensure_user_dashboard_preferences_health_columns"): with belief_scope("_ensure_user_dashboard_preferences_health_columns"):
@@ -208,6 +217,7 @@ def _ensure_user_dashboard_preferences_health_columns(bind_engine):
# [DEF:_ensure_llm_validation_results_columns:Function] # [DEF:_ensure_llm_validation_results_columns:Function]
# @TIER: STANDARD
# @PURPOSE: Applies additive schema upgrades for llm_validation_results table. # @PURPOSE: Applies additive schema upgrades for llm_validation_results table.
def _ensure_llm_validation_results_columns(bind_engine): def _ensure_llm_validation_results_columns(bind_engine):
with belief_scope("_ensure_llm_validation_results_columns"): with belief_scope("_ensure_llm_validation_results_columns"):
@@ -247,6 +257,7 @@ def _ensure_llm_validation_results_columns(bind_engine):
# [DEF:_ensure_git_server_configs_columns:Function] # [DEF:_ensure_git_server_configs_columns:Function]
# @TIER: STANDARD
# @PURPOSE: Applies additive schema upgrades for git_server_configs table. # @PURPOSE: Applies additive schema upgrades for git_server_configs table.
# @PRE: bind_engine points to application database. # @PRE: bind_engine points to application database.
# @POST: Missing columns are added without data loss. # @POST: Missing columns are added without data loss.
@@ -284,6 +295,7 @@ def _ensure_git_server_configs_columns(bind_engine):
# [DEF:ensure_connection_configs_table:Function] # [DEF:ensure_connection_configs_table:Function]
# @TIER: STANDARD
# @PURPOSE: Ensures the external connection registry table exists in the main database. # @PURPOSE: Ensures the external connection registry table exists in the main database.
# @PRE: bind_engine points to the application database. # @PRE: bind_engine points to the application database.
# @POST: connection_configs table exists without dropping existing data. # @POST: connection_configs table exists without dropping existing data.
@@ -301,6 +313,7 @@ def ensure_connection_configs_table(bind_engine):
# [DEF:init_db:Function] # [DEF:init_db:Function]
# @TIER: STANDARD
# @PURPOSE: Initializes the database by creating all tables. # @PURPOSE: Initializes the database by creating all tables.
# @PRE: engine, tasks_engine and auth_engine are initialized. # @PRE: engine, tasks_engine and auth_engine are initialized.
# @POST: Database tables created in all databases. # @POST: Database tables created in all databases.
@@ -318,6 +331,7 @@ def init_db():
# [/DEF:init_db:Function] # [/DEF:init_db:Function]
# [DEF:get_db:Function] # [DEF:get_db:Function]
# @TIER: STANDARD
# @PURPOSE: Dependency for getting a database session. # @PURPOSE: Dependency for getting a database session.
# @PRE: SessionLocal is initialized. # @PRE: SessionLocal is initialized.
# @POST: Session is closed after use. # @POST: Session is closed after use.
@@ -332,6 +346,7 @@ def get_db():
# [/DEF:get_db:Function] # [/DEF:get_db:Function]
# [DEF:get_tasks_db:Function] # [DEF:get_tasks_db:Function]
# @TIER: STANDARD
# @PURPOSE: Dependency for getting a tasks database session. # @PURPOSE: Dependency for getting a tasks database session.
# @PRE: TasksSessionLocal is initialized. # @PRE: TasksSessionLocal is initialized.
# @POST: Session is closed after use. # @POST: Session is closed after use.
@@ -346,6 +361,7 @@ def get_tasks_db():
# [/DEF:get_tasks_db:Function] # [/DEF:get_tasks_db:Function]
# [DEF:get_auth_db:Function] # [DEF:get_auth_db:Function]
# @TIER: STANDARD
# @PURPOSE: Dependency for getting an authentication database session. # @PURPOSE: Dependency for getting an authentication database session.
# @PRE: AuthSessionLocal is initialized. # @PRE: AuthSessionLocal is initialized.
# @POST: Session is closed after use. # @POST: Session is closed after use.

View File

@@ -3,7 +3,12 @@
# @SEMANTICS: task, manager, lifecycle, execution, state # @SEMANTICS: task, manager, lifecycle, execution, state
# @PURPOSE: Manages the lifecycle of tasks, including their creation, execution, and state tracking. It uses a thread pool to run plugins asynchronously. # @PURPOSE: Manages the lifecycle of tasks, including their creation, execution, and state tracking. It uses a thread pool to run plugins asynchronously.
# @LAYER: Core # @LAYER: Core
# @RELATION: Depends on PluginLoader to get plugin instances. It is used by the API layer to create and query tasks. # @PRE: Plugin loader and database sessions are initialized.
# @POST: Orchestrates task execution and persistence.
# @SIDE_EFFECT: Spawns worker threads and flushes logs to DB.
# @DATA_CONTRACT: Input[plugin_id, params] -> Model[Task, LogEntry]
# @RELATION: [DEPENDS_ON] ->[backend.src.core.plugin_loader]
# @RELATION: [DEPENDS_ON] ->[backend.src.core.task_manager.persistence]
# @INVARIANT: Task IDs are unique. # @INVARIANT: Task IDs are unique.
# @CONSTRAINT: Must use belief_scope for logging. # @CONSTRAINT: Must use belief_scope for logging.
# @TEST_CONTRACT: TaskManagerModule -> { # @TEST_CONTRACT: TaskManagerModule -> {
@@ -33,9 +38,9 @@ from ..logger import logger, belief_scope, should_log_task_level
# [/SECTION] # [/SECTION]
# [DEF:TaskManager:Class] # [DEF:TaskManager:Class]
# @TIER: CRITICAL
# @SEMANTICS: task, manager, lifecycle, execution, state # @SEMANTICS: task, manager, lifecycle, execution, state
# @PURPOSE: Manages the lifecycle of tasks, including their creation, execution, and state tracking. # @PURPOSE: Manages the lifecycle of tasks, including their creation, execution, and state tracking.
# @TIER: CRITICAL
# @INVARIANT: Task IDs are unique within the registry. # @INVARIANT: Task IDs are unique within the registry.
# @INVARIANT: Each task has exactly one status at any time. # @INVARIANT: Each task has exactly one status at any time.
# @INVARIANT: Log entries are never deleted after being added to a task. # @INVARIANT: Log entries are never deleted after being added to a task.
@@ -62,6 +67,7 @@ class TaskManager:
LOG_FLUSH_INTERVAL = 2.0 LOG_FLUSH_INTERVAL = 2.0
# [DEF:__init__:Function] # [DEF:__init__:Function]
# @TIER: CRITICAL
# @PURPOSE: Initialize the TaskManager with dependencies. # @PURPOSE: Initialize the TaskManager with dependencies.
# @PRE: plugin_loader is initialized. # @PRE: plugin_loader is initialized.
# @POST: TaskManager is ready to accept tasks. # @POST: TaskManager is ready to accept tasks.
@@ -95,6 +101,7 @@ class TaskManager:
# [/DEF:__init__:Function] # [/DEF:__init__:Function]
# [DEF:_flusher_loop:Function] # [DEF:_flusher_loop:Function]
# @TIER: STANDARD
# @PURPOSE: Background thread that periodically flushes log buffer to database. # @PURPOSE: Background thread that periodically flushes log buffer to database.
# @PRE: TaskManager is initialized. # @PRE: TaskManager is initialized.
# @POST: Logs are batch-written to database every LOG_FLUSH_INTERVAL seconds. # @POST: Logs are batch-written to database every LOG_FLUSH_INTERVAL seconds.
@@ -106,6 +113,7 @@ class TaskManager:
# [/DEF:_flusher_loop:Function] # [/DEF:_flusher_loop:Function]
# [DEF:_flush_logs:Function] # [DEF:_flush_logs:Function]
# @TIER: STANDARD
# @PURPOSE: Flush all buffered logs to the database. # @PURPOSE: Flush all buffered logs to the database.
# @PRE: None. # @PRE: None.
# @POST: All buffered logs are written to task_logs table. # @POST: All buffered logs are written to task_logs table.
@@ -132,6 +140,7 @@ class TaskManager:
# [/DEF:_flush_logs:Function] # [/DEF:_flush_logs:Function]
# [DEF:_flush_task_logs:Function] # [DEF:_flush_task_logs:Function]
# @TIER: STANDARD
# @PURPOSE: Flush logs for a specific task immediately. # @PURPOSE: Flush logs for a specific task immediately.
# @PRE: task_id exists. # @PRE: task_id exists.
# @POST: Task's buffered logs are written to database. # @POST: Task's buffered logs are written to database.
@@ -150,6 +159,7 @@ class TaskManager:
# [/DEF:_flush_task_logs:Function] # [/DEF:_flush_task_logs:Function]
# [DEF:create_task:Function] # [DEF:create_task:Function]
# @TIER: STANDARD
# @PURPOSE: Creates and queues a new task for execution. # @PURPOSE: Creates and queues a new task for execution.
# @PRE: Plugin with plugin_id exists. Params are valid. # @PRE: Plugin with plugin_id exists. Params are valid.
# @POST: Task is created, added to registry, and scheduled for execution. # @POST: Task is created, added to registry, and scheduled for execution.
@@ -179,6 +189,7 @@ class TaskManager:
# [/DEF:create_task:Function] # [/DEF:create_task:Function]
# [DEF:_run_task:Function] # [DEF:_run_task:Function]
# @TIER: STANDARD
# @PURPOSE: Internal method to execute a task with TaskContext support. # @PURPOSE: Internal method to execute a task with TaskContext support.
# @PRE: Task exists in registry. # @PRE: Task exists in registry.
# @POST: Task is executed, status updated to SUCCESS or FAILED. # @POST: Task is executed, status updated to SUCCESS or FAILED.
@@ -246,6 +257,7 @@ class TaskManager:
# [/DEF:_run_task:Function] # [/DEF:_run_task:Function]
# [DEF:resolve_task:Function] # [DEF:resolve_task:Function]
# @TIER: STANDARD
# @PURPOSE: Resumes a task that is awaiting mapping. # @PURPOSE: Resumes a task that is awaiting mapping.
# @PRE: Task exists and is in AWAITING_MAPPING state. # @PRE: Task exists and is in AWAITING_MAPPING state.
# @POST: Task status updated to RUNNING, params updated, execution resumed. # @POST: Task status updated to RUNNING, params updated, execution resumed.
@@ -270,6 +282,7 @@ class TaskManager:
# [/DEF:resolve_task:Function] # [/DEF:resolve_task:Function]
# [DEF:wait_for_resolution:Function] # [DEF:wait_for_resolution:Function]
# @TIER: STANDARD
# @PURPOSE: Pauses execution and waits for a resolution signal. # @PURPOSE: Pauses execution and waits for a resolution signal.
# @PRE: Task exists. # @PRE: Task exists.
# @POST: Execution pauses until future is set. # @POST: Execution pauses until future is set.
@@ -292,6 +305,7 @@ class TaskManager:
# [/DEF:wait_for_resolution:Function] # [/DEF:wait_for_resolution:Function]
# [DEF:wait_for_input:Function] # [DEF:wait_for_input:Function]
# @TIER: STANDARD
# @PURPOSE: Pauses execution and waits for user input. # @PURPOSE: Pauses execution and waits for user input.
# @PRE: Task exists. # @PRE: Task exists.
# @POST: Execution pauses until future is set via resume_task_with_password. # @POST: Execution pauses until future is set via resume_task_with_password.
@@ -313,6 +327,7 @@ class TaskManager:
# [/DEF:wait_for_input:Function] # [/DEF:wait_for_input:Function]
# [DEF:get_task:Function] # [DEF:get_task:Function]
# @TIER: STANDARD
# @PURPOSE: Retrieves a task by its ID. # @PURPOSE: Retrieves a task by its ID.
# @PRE: task_id is a string. # @PRE: task_id is a string.
# @POST: Returns Task object or None. # @POST: Returns Task object or None.
@@ -324,6 +339,7 @@ class TaskManager:
# [/DEF:get_task:Function] # [/DEF:get_task:Function]
# [DEF:get_all_tasks:Function] # [DEF:get_all_tasks:Function]
# @TIER: STANDARD
# @PURPOSE: Retrieves all registered tasks. # @PURPOSE: Retrieves all registered tasks.
# @PRE: None. # @PRE: None.
# @POST: Returns list of all Task objects. # @POST: Returns list of all Task objects.
@@ -334,6 +350,7 @@ class TaskManager:
# [/DEF:get_all_tasks:Function] # [/DEF:get_all_tasks:Function]
# [DEF:get_tasks:Function] # [DEF:get_tasks:Function]
# @TIER: STANDARD
# @PURPOSE: Retrieves tasks with pagination and optional status filter. # @PURPOSE: Retrieves tasks with pagination and optional status filter.
# @PRE: limit and offset are non-negative integers. # @PRE: limit and offset are non-negative integers.
# @POST: Returns a list of tasks sorted by start_time descending. # @POST: Returns a list of tasks sorted by start_time descending.
@@ -374,6 +391,7 @@ class TaskManager:
# [/DEF:get_tasks:Function] # [/DEF:get_tasks:Function]
# [DEF:get_task_logs:Function] # [DEF:get_task_logs:Function]
# @TIER: STANDARD
# @PURPOSE: Retrieves logs for a specific task (from memory for running, persistence for completed). # @PURPOSE: Retrieves logs for a specific task (from memory for running, persistence for completed).
# @PRE: task_id is a string. # @PRE: task_id is a string.
# @POST: Returns list of LogEntry or TaskLog objects. # @POST: Returns list of LogEntry or TaskLog objects.
@@ -406,6 +424,7 @@ class TaskManager:
# [/DEF:get_task_logs:Function] # [/DEF:get_task_logs:Function]
# [DEF:get_task_log_stats:Function] # [DEF:get_task_log_stats:Function]
# @TIER: STANDARD
# @PURPOSE: Get statistics about logs for a task. # @PURPOSE: Get statistics about logs for a task.
# @PRE: task_id is a valid task ID. # @PRE: task_id is a valid task ID.
# @POST: Returns LogStats with counts by level and source. # @POST: Returns LogStats with counts by level and source.
@@ -417,6 +436,7 @@ class TaskManager:
# [/DEF:get_task_log_stats:Function] # [/DEF:get_task_log_stats:Function]
# [DEF:get_task_log_sources:Function] # [DEF:get_task_log_sources:Function]
# @TIER: STANDARD
# @PURPOSE: Get unique sources for a task's logs. # @PURPOSE: Get unique sources for a task's logs.
# @PRE: task_id is a valid task ID. # @PRE: task_id is a valid task ID.
# @POST: Returns list of unique source strings. # @POST: Returns list of unique source strings.
@@ -428,6 +448,7 @@ class TaskManager:
# [/DEF:get_task_log_sources:Function] # [/DEF:get_task_log_sources:Function]
# [DEF:_add_log:Function] # [DEF:_add_log:Function]
# @TIER: STANDARD
# @PURPOSE: Adds a log entry to a task buffer and notifies subscribers. # @PURPOSE: Adds a log entry to a task buffer and notifies subscribers.
# @PRE: Task exists. # @PRE: Task exists.
# @POST: Log added to buffer and pushed to queues (if level meets task_log_level filter). # @POST: Log added to buffer and pushed to queues (if level meets task_log_level filter).
@@ -480,6 +501,7 @@ class TaskManager:
# [/DEF:_add_log:Function] # [/DEF:_add_log:Function]
# [DEF:subscribe_logs:Function] # [DEF:subscribe_logs:Function]
# @TIER: STANDARD
# @PURPOSE: Subscribes to real-time logs for a task. # @PURPOSE: Subscribes to real-time logs for a task.
# @PRE: task_id is a string. # @PRE: task_id is a string.
# @POST: Returns an asyncio.Queue for log entries. # @POST: Returns an asyncio.Queue for log entries.
@@ -495,6 +517,7 @@ class TaskManager:
# [/DEF:subscribe_logs:Function] # [/DEF:subscribe_logs:Function]
# [DEF:unsubscribe_logs:Function] # [DEF:unsubscribe_logs:Function]
# @TIER: STANDARD
# @PURPOSE: Unsubscribes from real-time logs for a task. # @PURPOSE: Unsubscribes from real-time logs for a task.
# @PRE: task_id is a string, queue is asyncio.Queue. # @PRE: task_id is a string, queue is asyncio.Queue.
# @POST: Queue removed from subscribers. # @POST: Queue removed from subscribers.
@@ -510,6 +533,7 @@ class TaskManager:
# [/DEF:unsubscribe_logs:Function] # [/DEF:unsubscribe_logs:Function]
# [DEF:load_persisted_tasks:Function] # [DEF:load_persisted_tasks:Function]
# @TIER: STANDARD
# @PURPOSE: Load persisted tasks using persistence service. # @PURPOSE: Load persisted tasks using persistence service.
# @PRE: None. # @PRE: None.
# @POST: Persisted tasks loaded into self.tasks. # @POST: Persisted tasks loaded into self.tasks.
@@ -522,6 +546,7 @@ class TaskManager:
# [/DEF:load_persisted_tasks:Function] # [/DEF:load_persisted_tasks:Function]
# [DEF:await_input:Function] # [DEF:await_input:Function]
# @TIER: STANDARD
# @PURPOSE: Transition a task to AWAITING_INPUT state with input request. # @PURPOSE: Transition a task to AWAITING_INPUT state with input request.
# @PRE: Task exists and is in RUNNING state. # @PRE: Task exists and is in RUNNING state.
# @POST: Task status changed to AWAITING_INPUT, input_request set, persisted. # @POST: Task status changed to AWAITING_INPUT, input_request set, persisted.
@@ -544,6 +569,7 @@ class TaskManager:
# [/DEF:await_input:Function] # [/DEF:await_input:Function]
# [DEF:resume_task_with_password:Function] # [DEF:resume_task_with_password:Function]
# @TIER: STANDARD
# @PURPOSE: Resume a task that is awaiting input with provided passwords. # @PURPOSE: Resume a task that is awaiting input with provided passwords.
# @PRE: Task exists and is in AWAITING_INPUT state. # @PRE: Task exists and is in AWAITING_INPUT state.
# @POST: Task status changed to RUNNING, passwords injected, task resumed. # @POST: Task status changed to RUNNING, passwords injected, task resumed.
@@ -573,6 +599,7 @@ class TaskManager:
# [/DEF:resume_task_with_password:Function] # [/DEF:resume_task_with_password:Function]
# [DEF:clear_tasks:Function] # [DEF:clear_tasks:Function]
# @TIER: STANDARD
# @PURPOSE: Clears tasks based on status filter (also deletes associated logs). # @PURPOSE: Clears tasks based on status filter (also deletes associated logs).
# @PRE: status is Optional[TaskStatus]. # @PRE: status is Optional[TaskStatus].
# @POST: Tasks matching filter (or all non-active) cleared from registry and database. # @POST: Tasks matching filter (or all non-active) cleared from registry and database.

View File

@@ -3,7 +3,12 @@
# @SEMANTICS: persistence, sqlite, sqlalchemy, task, storage # @SEMANTICS: persistence, sqlite, sqlalchemy, task, storage
# @PURPOSE: Handles the persistence of tasks using SQLAlchemy and the tasks.db database. # @PURPOSE: Handles the persistence of tasks using SQLAlchemy and the tasks.db database.
# @LAYER: Core # @LAYER: Core
# @RELATION: Used by TaskManager to save and load tasks. # @PRE: Tasks database must be initialized with TaskRecord and TaskLogRecord schemas.
# @POST: Provides reliable storage and retrieval for task metadata and logs.
# @SIDE_EFFECT: Performs database I/O on tasks.db.
# @DATA_CONTRACT: Input[Task, LogEntry] -> Model[TaskRecord, TaskLogRecord]
# @RELATION: [USED_BY] ->[backend.src.core.task_manager.manager.TaskManager]
# @RELATION: [DEPENDS_ON] ->[backend.src.core.database.TasksSessionLocal]
# @INVARIANT: Database schema must match the TaskRecord model structure. # @INVARIANT: Database schema must match the TaskRecord model structure.
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
@@ -118,6 +123,7 @@ class TaskPersistenceService:
# [/DEF:_resolve_environment_id:Function] # [/DEF:_resolve_environment_id:Function]
# [DEF:__init__:Function] # [DEF:__init__:Function]
# @TIER: STANDARD
# @PURPOSE: Initializes the persistence service. # @PURPOSE: Initializes the persistence service.
# @PRE: None. # @PRE: None.
# @POST: Service is ready. # @POST: Service is ready.
@@ -128,6 +134,7 @@ class TaskPersistenceService:
# [/DEF:__init__:Function] # [/DEF:__init__:Function]
# [DEF:persist_task:Function] # [DEF:persist_task:Function]
# @TIER: STANDARD
# @PURPOSE: Persists or updates a single task in the database. # @PURPOSE: Persists or updates a single task in the database.
# @PRE: isinstance(task, Task) # @PRE: isinstance(task, Task)
# @POST: Task record created or updated in database. # @POST: Task record created or updated in database.
@@ -190,6 +197,7 @@ class TaskPersistenceService:
# [/DEF:persist_task:Function] # [/DEF:persist_task:Function]
# [DEF:persist_tasks:Function] # [DEF:persist_tasks:Function]
# @TIER: STANDARD
# @PURPOSE: Persists multiple tasks. # @PURPOSE: Persists multiple tasks.
# @PRE: isinstance(tasks, list) # @PRE: isinstance(tasks, list)
# @POST: All tasks in list are persisted. # @POST: All tasks in list are persisted.
@@ -201,6 +209,7 @@ class TaskPersistenceService:
# [/DEF:persist_tasks:Function] # [/DEF:persist_tasks:Function]
# [DEF:load_tasks:Function] # [DEF:load_tasks:Function]
# @TIER: STANDARD
# @PURPOSE: Loads tasks from the database. # @PURPOSE: Loads tasks from the database.
# @PRE: limit is an integer. # @PRE: limit is an integer.
# @POST: Returns list of Task objects. # @POST: Returns list of Task objects.
@@ -255,6 +264,7 @@ class TaskPersistenceService:
# [/DEF:load_tasks:Function] # [/DEF:load_tasks:Function]
# [DEF:delete_tasks:Function] # [DEF:delete_tasks:Function]
# @TIER: STANDARD
# @PURPOSE: Deletes specific tasks from the database. # @PURPOSE: Deletes specific tasks from the database.
# @PRE: task_ids is a list of strings. # @PRE: task_ids is a list of strings.
# @POST: Specified task records deleted from database. # @POST: Specified task records deleted from database.
@@ -277,9 +287,9 @@ class TaskPersistenceService:
# [/DEF:TaskPersistenceService:Class] # [/DEF:TaskPersistenceService:Class]
# [DEF:TaskLogPersistenceService:Class] # [DEF:TaskLogPersistenceService:Class]
# @TIER: CRITICAL
# @SEMANTICS: persistence, service, database, log, sqlalchemy # @SEMANTICS: persistence, service, database, log, sqlalchemy
# @PURPOSE: Provides methods to save and query task logs from the task_logs table. # @PURPOSE: Provides methods to save and query task logs from the task_logs table.
# @TIER: CRITICAL
# @RELATION: DEPENDS_ON -> TaskLogRecord # @RELATION: DEPENDS_ON -> TaskLogRecord
# @INVARIANT: Log entries are batch-inserted for performance. # @INVARIANT: Log entries are batch-inserted for performance.
# #
@@ -311,6 +321,7 @@ class TaskLogPersistenceService:
# [/DEF:__init__:Function] # [/DEF:__init__:Function]
# [DEF:add_logs:Function] # [DEF:add_logs:Function]
# @TIER: STANDARD
# @PURPOSE: Batch insert log entries for a task. # @PURPOSE: Batch insert log entries for a task.
# @PRE: logs is a list of LogEntry objects. # @PRE: logs is a list of LogEntry objects.
# @POST: All logs inserted into task_logs table. # @POST: All logs inserted into task_logs table.
@@ -342,6 +353,7 @@ class TaskLogPersistenceService:
# [/DEF:add_logs:Function] # [/DEF:add_logs:Function]
# [DEF:get_logs:Function] # [DEF:get_logs:Function]
# @TIER: STANDARD
# @PURPOSE: Query logs for a task with filtering and pagination. # @PURPOSE: Query logs for a task with filtering and pagination.
# @PRE: task_id is a valid task ID. # @PRE: task_id is a valid task ID.
# @POST: Returns list of TaskLog objects matching filters. # @POST: Returns list of TaskLog objects matching filters.
@@ -394,6 +406,7 @@ class TaskLogPersistenceService:
# [/DEF:get_logs:Function] # [/DEF:get_logs:Function]
# [DEF:get_log_stats:Function] # [DEF:get_log_stats:Function]
# @TIER: STANDARD
# @PURPOSE: Get statistics about logs for a task. # @PURPOSE: Get statistics about logs for a task.
# @PRE: task_id is a valid task ID. # @PRE: task_id is a valid task ID.
# @POST: Returns LogStats with counts by level and source. # @POST: Returns LogStats with counts by level and source.
@@ -439,6 +452,7 @@ class TaskLogPersistenceService:
# [/DEF:get_log_stats:Function] # [/DEF:get_log_stats:Function]
# [DEF:get_sources:Function] # [DEF:get_sources:Function]
# @TIER: STANDARD
# @PURPOSE: Get unique sources for a task's logs. # @PURPOSE: Get unique sources for a task's logs.
# @PRE: task_id is a valid task ID. # @PRE: task_id is a valid task ID.
# @POST: Returns list of unique source strings. # @POST: Returns list of unique source strings.
@@ -458,6 +472,7 @@ class TaskLogPersistenceService:
# [/DEF:get_sources:Function] # [/DEF:get_sources:Function]
# [DEF:delete_logs_for_task:Function] # [DEF:delete_logs_for_task:Function]
# @TIER: STANDARD
# @PURPOSE: Delete all logs for a specific task. # @PURPOSE: Delete all logs for a specific task.
# @PRE: task_id is a valid task ID. # @PRE: task_id is a valid task ID.
# @POST: All logs for the task are deleted. # @POST: All logs for the task are deleted.
@@ -479,6 +494,7 @@ class TaskLogPersistenceService:
# [/DEF:delete_logs_for_task:Function] # [/DEF:delete_logs_for_task:Function]
# [DEF:delete_logs_for_tasks:Function] # [DEF:delete_logs_for_tasks:Function]
# @TIER: STANDARD
# @PURPOSE: Delete all logs for multiple tasks. # @PURPOSE: Delete all logs for multiple tasks.
# @PRE: task_ids is a list of task IDs. # @PRE: task_ids is a list of task IDs.
# @POST: All logs for the tasks are deleted. # @POST: All logs for the tasks are deleted.

View File

@@ -1,225 +1,237 @@
# [DEF:Dependencies:Module] # [DEF:Dependencies:Module]
# @SEMANTICS: dependency, injection, singleton, factory, auth, jwt # @TIER: STANDARD
# @PURPOSE: Manages creation and provision of shared application dependencies, such as PluginLoader and TaskManager, to avoid circular imports. # @SEMANTICS: dependency, injection, singleton, factory, auth, jwt
# @LAYER: Core # @PURPOSE: Manages creation and provision of shared application dependencies, such as PluginLoader and TaskManager, to avoid circular imports.
# @RELATION: Used by main app and API routers to get access to shared instances. # @LAYER: Core
# @RELATION: Used by main app and API routers to get access to shared instances.
from pathlib import Path
from fastapi import Depends, HTTPException, status from pathlib import Path
from fastapi.security import OAuth2PasswordBearer from fastapi import Depends, HTTPException, status
from jose import JWTError from fastapi.security import OAuth2PasswordBearer
from .core.plugin_loader import PluginLoader from jose import JWTError
from .core.task_manager import TaskManager from .core.plugin_loader import PluginLoader
from .core.config_manager import ConfigManager from .core.task_manager import TaskManager
from .core.scheduler import SchedulerService from .core.config_manager import ConfigManager
from .services.resource_service import ResourceService from .core.scheduler import SchedulerService
from .services.mapping_service import MappingService from .services.resource_service import ResourceService
from .services.clean_release.repositories import ( from .services.mapping_service import MappingService
CandidateRepository, ArtifactRepository, ManifestRepository, from .services.clean_release.repositories import (
PolicyRepository, ComplianceRepository, ReportRepository, CandidateRepository, ArtifactRepository, ManifestRepository,
ApprovalRepository, PublicationRepository, AuditRepository, PolicyRepository, ComplianceRepository, ReportRepository,
CleanReleaseAuditLog ApprovalRepository, PublicationRepository, AuditRepository,
) CleanReleaseAuditLog
from .services.clean_release.repository import CleanReleaseRepository )
from .services.clean_release.facade import CleanReleaseFacade from .services.clean_release.repository import CleanReleaseRepository
from .services.reports.report_service import ReportsService from .services.clean_release.facade import CleanReleaseFacade
from .core.database import init_db, get_auth_db, get_db from .services.reports.report_service import ReportsService
from .core.logger import logger from .core.database import init_db, get_auth_db, get_db
from .core.auth.jwt import decode_token from .core.logger import logger
from .core.auth.repository import AuthRepository from .core.auth.jwt import decode_token
from .models.auth import User from .core.auth.repository import AuthRepository
from .models.auth import User
# Initialize singletons
# Use absolute path relative to this file to ensure plugins are found regardless of CWD # Initialize singletons
project_root = Path(__file__).parent.parent.parent # Use absolute path relative to this file to ensure plugins are found regardless of CWD
config_path = project_root / "config.json" project_root = Path(__file__).parent.parent.parent
config_path = project_root / "config.json"
# Initialize database before services that use persisted configuration.
init_db() # Initialize database before services that use persisted configuration.
config_manager = ConfigManager(config_path=str(config_path)) init_db()
config_manager = ConfigManager(config_path=str(config_path))
# [DEF:get_config_manager:Function]
# @PURPOSE: Dependency injector for ConfigManager. # [DEF:get_config_manager:Function]
# @PRE: Global config_manager must be initialized. # @TIER: TRIVIAL
# @POST: Returns shared ConfigManager instance. # @PURPOSE: Dependency injector for ConfigManager.
# @RETURN: ConfigManager - The shared config manager instance. # @PRE: Global config_manager must be initialized.
def get_config_manager() -> ConfigManager: # @POST: Returns shared ConfigManager instance.
"""Dependency injector for ConfigManager.""" # @RETURN: ConfigManager - The shared config manager instance.
return config_manager def get_config_manager() -> ConfigManager:
# [/DEF:get_config_manager:Function] """Dependency injector for ConfigManager."""
return config_manager
plugin_dir = Path(__file__).parent / "plugins" # [/DEF:get_config_manager:Function]
plugin_loader = PluginLoader(plugin_dir=str(plugin_dir)) plugin_dir = Path(__file__).parent / "plugins"
logger.info(f"PluginLoader initialized with directory: {plugin_dir}")
logger.info(f"Available plugins: {[config.name for config in plugin_loader.get_all_plugin_configs()]}") plugin_loader = PluginLoader(plugin_dir=str(plugin_dir))
logger.info(f"PluginLoader initialized with directory: {plugin_dir}")
task_manager = TaskManager(plugin_loader) logger.info(f"Available plugins: {[config.name for config in plugin_loader.get_all_plugin_configs()]}")
logger.info("TaskManager initialized")
task_manager = TaskManager(plugin_loader)
scheduler_service = SchedulerService(task_manager, config_manager) logger.info("TaskManager initialized")
logger.info("SchedulerService initialized")
scheduler_service = SchedulerService(task_manager, config_manager)
resource_service = ResourceService() logger.info("SchedulerService initialized")
logger.info("ResourceService initialized")
resource_service = ResourceService()
# Clean Release Redesign Singletons logger.info("ResourceService initialized")
# Note: These use get_db() which is a generator, so we need a way to provide a session.
# For singletons in dependencies.py, we might need a different approach or # Clean Release Redesign Singletons
# initialize them inside the dependency functions. # Note: These use get_db() which is a generator, so we need a way to provide a session.
# For singletons in dependencies.py, we might need a different approach or
# [DEF:get_plugin_loader:Function] # initialize them inside the dependency functions.
# @PURPOSE: Dependency injector for PluginLoader.
# @PRE: Global plugin_loader must be initialized. # [DEF:get_plugin_loader:Function]
# @POST: Returns shared PluginLoader instance. # @TIER: TRIVIAL
# @RETURN: PluginLoader - The shared plugin loader instance. # @PURPOSE: Dependency injector for PluginLoader.
def get_plugin_loader() -> PluginLoader: # @PRE: Global plugin_loader must be initialized.
"""Dependency injector for PluginLoader.""" # @POST: Returns shared PluginLoader instance.
return plugin_loader # @RETURN: PluginLoader - The shared plugin loader instance.
# [/DEF:get_plugin_loader:Function] def get_plugin_loader() -> PluginLoader:
"""Dependency injector for PluginLoader."""
# [DEF:get_task_manager:Function] return plugin_loader
# @PURPOSE: Dependency injector for TaskManager. # [/DEF:get_plugin_loader:Function]
# @PRE: Global task_manager must be initialized.
# @POST: Returns shared TaskManager instance. # [DEF:get_task_manager:Function]
# @RETURN: TaskManager - The shared task manager instance. # @TIER: TRIVIAL
def get_task_manager() -> TaskManager: # @PURPOSE: Dependency injector for TaskManager.
"""Dependency injector for TaskManager.""" # @PRE: Global task_manager must be initialized.
return task_manager # @POST: Returns shared TaskManager instance.
# [/DEF:get_task_manager:Function] # @RETURN: TaskManager - The shared task manager instance.
def get_task_manager() -> TaskManager:
# [DEF:get_scheduler_service:Function] """Dependency injector for TaskManager."""
# @PURPOSE: Dependency injector for SchedulerService. return task_manager
# @PRE: Global scheduler_service must be initialized. # [/DEF:get_task_manager:Function]
# @POST: Returns shared SchedulerService instance.
# @RETURN: SchedulerService - The shared scheduler service instance. # [DEF:get_scheduler_service:Function]
def get_scheduler_service() -> SchedulerService: # @TIER: TRIVIAL
"""Dependency injector for SchedulerService.""" # @PURPOSE: Dependency injector for SchedulerService.
return scheduler_service # @PRE: Global scheduler_service must be initialized.
# [/DEF:get_scheduler_service:Function] # @POST: Returns shared SchedulerService instance.
# @RETURN: SchedulerService - The shared scheduler service instance.
# [DEF:get_resource_service:Function] def get_scheduler_service() -> SchedulerService:
# @PURPOSE: Dependency injector for ResourceService. """Dependency injector for SchedulerService."""
# @PRE: Global resource_service must be initialized. return scheduler_service
# @POST: Returns shared ResourceService instance. # [/DEF:get_scheduler_service:Function]
# @RETURN: ResourceService - The shared resource service instance.
def get_resource_service() -> ResourceService: # [DEF:get_resource_service:Function]
"""Dependency injector for ResourceService.""" # @TIER: TRIVIAL
return resource_service # @PURPOSE: Dependency injector for ResourceService.
# [/DEF:get_resource_service:Function] # @PRE: Global resource_service must be initialized.
# @POST: Returns shared ResourceService instance.
# [DEF:get_mapping_service:Function] # @RETURN: ResourceService - The shared resource service instance.
# @PURPOSE: Dependency injector for MappingService. def get_resource_service() -> ResourceService:
# @PRE: Global config_manager must be initialized. """Dependency injector for ResourceService."""
# @POST: Returns new MappingService instance. return resource_service
# @RETURN: MappingService - A new mapping service instance. # [/DEF:get_resource_service:Function]
def get_mapping_service() -> MappingService:
"""Dependency injector for MappingService.""" # [DEF:get_mapping_service:Function]
return MappingService(config_manager) # @TIER: TRIVIAL
# [/DEF:get_mapping_service:Function] # @PURPOSE: Dependency injector for MappingService.
# @PRE: Global config_manager must be initialized.
# @POST: Returns new MappingService instance.
_clean_release_repository = CleanReleaseRepository() # @RETURN: MappingService - A new mapping service instance.
def get_mapping_service() -> MappingService:
# [DEF:get_clean_release_repository:Function] """Dependency injector for MappingService."""
# @PURPOSE: Legacy compatibility shim for CleanReleaseRepository. return MappingService(config_manager)
# @POST: Returns a shared CleanReleaseRepository instance. # [/DEF:get_mapping_service:Function]
def get_clean_release_repository() -> CleanReleaseRepository:
"""Legacy compatibility shim for CleanReleaseRepository."""
return _clean_release_repository _clean_release_repository = CleanReleaseRepository()
# [/DEF:get_clean_release_repository:Function]
# [DEF:get_clean_release_repository:Function]
# @TIER: TRIVIAL
# [DEF:get_clean_release_facade:Function] # @PURPOSE: Legacy compatibility shim for CleanReleaseRepository.
# @PURPOSE: Dependency injector for CleanReleaseFacade. # @POST: Returns a shared CleanReleaseRepository instance.
# @POST: Returns a facade instance with a fresh DB session. def get_clean_release_repository() -> CleanReleaseRepository:
def get_clean_release_facade(db = Depends(get_db)) -> CleanReleaseFacade: """Legacy compatibility shim for CleanReleaseRepository."""
candidate_repo = CandidateRepository(db) return _clean_release_repository
artifact_repo = ArtifactRepository(db) # [/DEF:get_clean_release_repository:Function]
manifest_repo = ManifestRepository(db)
policy_repo = PolicyRepository(db)
compliance_repo = ComplianceRepository(db) # [DEF:get_clean_release_facade:Function]
report_repo = ReportRepository(db) # @TIER: TRIVIAL
approval_repo = ApprovalRepository(db) # @PURPOSE: Dependency injector for CleanReleaseFacade.
publication_repo = PublicationRepository(db) # @POST: Returns a facade instance with a fresh DB session.
audit_repo = AuditRepository(db) def get_clean_release_facade(db = Depends(get_db)) -> CleanReleaseFacade:
candidate_repo = CandidateRepository(db)
return CleanReleaseFacade( artifact_repo = ArtifactRepository(db)
candidate_repo=candidate_repo, manifest_repo = ManifestRepository(db)
artifact_repo=artifact_repo, policy_repo = PolicyRepository(db)
manifest_repo=manifest_repo, compliance_repo = ComplianceRepository(db)
policy_repo=policy_repo, report_repo = ReportRepository(db)
compliance_repo=compliance_repo, approval_repo = ApprovalRepository(db)
report_repo=report_repo, publication_repo = PublicationRepository(db)
approval_repo=approval_repo, audit_repo = AuditRepository(db)
publication_repo=publication_repo,
audit_repo=audit_repo, return CleanReleaseFacade(
config_manager=config_manager candidate_repo=candidate_repo,
) artifact_repo=artifact_repo,
# [/DEF:get_clean_release_facade:Function] manifest_repo=manifest_repo,
policy_repo=policy_repo,
# [DEF:oauth2_scheme:Variable] compliance_repo=compliance_repo,
# @PURPOSE: OAuth2 password bearer scheme for token extraction. report_repo=report_repo,
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") approval_repo=approval_repo,
# [/DEF:oauth2_scheme:Variable] publication_repo=publication_repo,
audit_repo=audit_repo,
# [DEF:get_current_user:Function] config_manager=config_manager
# @PURPOSE: Dependency for retrieving currently authenticated user from a JWT. )
# @PRE: JWT token provided in Authorization header. # [/DEF:get_clean_release_facade:Function]
# @POST: Returns User object if token is valid.
# @THROW: HTTPException 401 if token is invalid or user not found. # [DEF:oauth2_scheme:Variable]
# @PARAM: token (str) - Extracted JWT token. # @TIER: TRIVIAL
# @PARAM: db (Session) - Auth database session. # @PURPOSE: OAuth2 password bearer scheme for token extraction.
# @RETURN: User - The authenticated user. oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
def get_current_user(token: str = Depends(oauth2_scheme), db = Depends(get_auth_db)): # [/DEF:oauth2_scheme:Variable]
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, # [DEF:get_current_user:Function]
detail="Could not validate credentials", # @TIER: STANDARD
headers={"WWW-Authenticate": "Bearer"}, # @PURPOSE: Dependency for retrieving currently authenticated user from a JWT.
) # @PRE: JWT token provided in Authorization header.
try: # @POST: Returns User object if token is valid.
payload = decode_token(token) # @THROW: HTTPException 401 if token is invalid or user not found.
username: str = payload.get("sub") # @PARAM: token (str) - Extracted JWT token.
if username is None: # @PARAM: db (Session) - Auth database session.
raise credentials_exception # @RETURN: User - The authenticated user.
except JWTError: def get_current_user(token: str = Depends(oauth2_scheme), db = Depends(get_auth_db)):
raise credentials_exception credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
repo = AuthRepository(db) detail="Could not validate credentials",
user = repo.get_user_by_username(username) headers={"WWW-Authenticate": "Bearer"},
if user is None: )
raise credentials_exception try:
return user payload = decode_token(token)
# [/DEF:get_current_user:Function] username: str = payload.get("sub")
if username is None:
# [DEF:has_permission:Function] raise credentials_exception
# @PURPOSE: Dependency for checking if the current user has a specific permission. except JWTError:
# @PRE: User is authenticated. raise credentials_exception
# @POST: Returns True if user has permission.
# @THROW: HTTPException 403 if permission is denied. repo = AuthRepository(db)
# @PARAM: resource (str) - The resource identifier. user = repo.get_user_by_username(username)
# @PARAM: action (str) - The action identifier (READ, EXECUTE, WRITE). if user is None:
# @RETURN: User - The authenticated user if permission granted. raise credentials_exception
def has_permission(resource: str, action: str): return user
def permission_checker(current_user: User = Depends(get_current_user)): # [/DEF:get_current_user:Function]
# Union of all permissions across all roles
for role in current_user.roles: # [DEF:has_permission:Function]
for perm in role.permissions: # @TIER: STANDARD
if perm.resource == resource and perm.action == action: # @PURPOSE: Dependency for checking if the current user has a specific permission.
return current_user # @PRE: User is authenticated.
# @POST: Returns True if user has permission.
# Special case for Admin role (full access) # @THROW: HTTPException 403 if permission is denied.
if any(role.name == "Admin" for role in current_user.roles): # @PARAM: resource (str) - The resource identifier.
return current_user # @PARAM: action (str) - The action identifier (READ, EXECUTE, WRITE).
# @RETURN: User - The authenticated user if permission granted.
from .core.auth.logger import log_security_event def has_permission(resource: str, action: str):
log_security_event("PERMISSION_DENIED", current_user.username, {"resource": resource, "action": action}) def permission_checker(current_user: User = Depends(get_current_user)):
# Union of all permissions across all roles
raise HTTPException( for role in current_user.roles:
status_code=status.HTTP_403_FORBIDDEN, for perm in role.permissions:
detail=f"Permission denied for {resource}:{action}" if perm.resource == resource and perm.action == action:
) return current_user
return permission_checker
# [/DEF:has_permission:Function] # Special case for Admin role (full access)
if any(role.name == "Admin" for role in current_user.roles):
# [/DEF:Dependencies:Module] return current_user
from .core.auth.logger import log_security_event
log_security_event("PERMISSION_DENIED", current_user.username, {"resource": resource, "action": action})
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Permission denied for {resource}:{action}"
)
return permission_checker
# [/DEF:has_permission:Function]
# [/DEF:Dependencies:Module]

View File

@@ -3,6 +3,10 @@
# @SEMANTICS: clean-release, models, lifecycle, compliance, evidence, immutability # @SEMANTICS: clean-release, models, lifecycle, compliance, evidence, immutability
# @PURPOSE: Define canonical clean release domain entities and lifecycle guards. # @PURPOSE: Define canonical clean release domain entities and lifecycle guards.
# @LAYER: Domain # @LAYER: Domain
# @PRE: Base mapping model and release enums are available.
# @POST: Provides SQLAlchemy and dataclass definitions for governance domain.
# @SIDE_EFFECT: None (schema definition).
# @DATA_CONTRACT: Model[ReleaseCandidate, CandidateArtifact, DistributionManifest, ComplianceRun, ComplianceReport]
# @INVARIANT: Immutable snapshots are never mutated; forbidden lifecycle transitions are rejected. # @INVARIANT: Immutable snapshots are never mutated; forbidden lifecycle transitions are rejected.
from datetime import datetime from datetime import datetime

View File

@@ -3,7 +3,11 @@
# @SEMANTICS: reports, models, pydantic, normalization, pagination # @SEMANTICS: reports, models, pydantic, normalization, pagination
# @PURPOSE: Canonical report schemas for unified task reporting across heterogeneous task types. # @PURPOSE: Canonical report schemas for unified task reporting across heterogeneous task types.
# @LAYER: Domain # @LAYER: Domain
# @RELATION: DEPENDS_ON -> backend.src.core.task_manager.models # @PRE: Pydantic library and task manager models are available.
# @POST: Provides validated schemas for cross-plugin reporting and UI consumption.
# @SIDE_EFFECT: None (schema definition).
# @DATA_CONTRACT: Model[TaskReport, ReportCollection, ReportDetailView]
# @RELATION: [DEPENDS_ON] ->[backend.src.core.task_manager.models]
# @INVARIANT: Canonical report fields are always present for every report item. # @INVARIANT: Canonical report fields are always present for every report item.
# [SECTION: IMPORTS] # [SECTION: IMPORTS]

View File

@@ -11,6 +11,10 @@
# @RELATION: [DEPENDS_ON] ->[backend.src.models.auth.Role] # @RELATION: [DEPENDS_ON] ->[backend.src.models.auth.Role]
# #
# @INVARIANT: Authentication succeeds only for active users with valid credentials; issued sessions encode subject and scopes from assigned roles. # @INVARIANT: Authentication succeeds only for active users with valid credentials; issued sessions encode subject and scopes from assigned roles.
# @PRE: Core auth models and security utilities available.
# @POST: User identity verified and session tokens issued according to role scopes.
# @SIDE_EFFECT: Writes last login timestamps and JIT-provisions external users.
# @DATA_CONTRACT: [Credentials | ADFSClaims] -> [UserEntity | SessionToken]
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
from typing import Dict, Any from typing import Dict, Any
@@ -23,9 +27,11 @@ from ..core.logger import belief_scope
# [/SECTION] # [/SECTION]
# [DEF:AuthService:Class] # [DEF:AuthService:Class]
# @TIER: STANDARD
# @PURPOSE: Provides high-level authentication services. # @PURPOSE: Provides high-level authentication services.
class AuthService: class AuthService:
# [DEF:__init__:Function] # [DEF:__init__:Function]
# @TIER: TRIVIAL
# @PURPOSE: Initializes the authentication service with repository access over an active DB session. # @PURPOSE: Initializes the authentication service with repository access over an active DB session.
# @PRE: db is a valid SQLAlchemy Session instance bound to the auth persistence context. # @PRE: db is a valid SQLAlchemy Session instance bound to the auth persistence context.
# @POST: self.repo is initialized and ready for auth user/role CRUD operations. # @POST: self.repo is initialized and ready for auth user/role CRUD operations.
@@ -37,6 +43,7 @@ class AuthService:
# [/DEF:__init__:Function] # [/DEF:__init__:Function]
# [DEF:authenticate_user:Function] # [DEF:authenticate_user:Function]
# @TIER: STANDARD
# @PURPOSE: Validates credentials and account state for local username/password authentication. # @PURPOSE: Validates credentials and account state for local username/password authentication.
# @PRE: username and password are non-empty credential inputs. # @PRE: username and password are non-empty credential inputs.
# @POST: Returns User only when user exists, is active, and password hash verification succeeds; otherwise returns None. # @POST: Returns User only when user exists, is active, and password hash verification succeeds; otherwise returns None.
@@ -62,6 +69,7 @@ class AuthService:
# [/DEF:authenticate_user:Function] # [/DEF:authenticate_user:Function]
# [DEF:create_session:Function] # [DEF:create_session:Function]
# @TIER: STANDARD
# @PURPOSE: Issues an access token payload for an already authenticated user. # @PURPOSE: Issues an access token payload for an already authenticated user.
# @PRE: user is a valid User entity containing username and iterable roles with role.name values. # @PRE: user is a valid User entity containing username and iterable roles with role.name values.
# @POST: Returns session dict with non-empty access_token and token_type='bearer'. # @POST: Returns session dict with non-empty access_token and token_type='bearer'.
@@ -87,6 +95,7 @@ class AuthService:
# [/DEF:create_session:Function] # [/DEF:create_session:Function]
# [DEF:provision_adfs_user:Function] # [DEF:provision_adfs_user:Function]
# @TIER: STANDARD
# @PURPOSE: Performs ADFS Just-In-Time provisioning and role synchronization from AD group mappings. # @PURPOSE: Performs ADFS Just-In-Time provisioning and role synchronization from AD group mappings.
# @PRE: user_info contains identity claims where at least one of 'upn' or 'email' is present; 'groups' may be absent. # @PRE: user_info contains identity claims where at least one of 'upn' or 'email' is present; 'groups' may be absent.
# @POST: Returns persisted user entity with roles synchronized to mapped AD groups and refreshed state. # @POST: Returns persisted user entity with roles synchronized to mapped AD groups and refreshed state.
@@ -121,7 +130,6 @@ class AuthService:
self.repo.db.refresh(user) self.repo.db.refresh(user)
return user return user
# [/DEF:provision_adfs_user:Function] # [/DEF:provision_adfs_user:Function]
# [/DEF:AuthService:Class] # [/DEF:AuthService:Class]
# [/DEF:backend.src.services.auth_service:Module] # [/DEF:backend.src.services.auth_service:Module]

View File

@@ -3,9 +3,9 @@
# @SEMANTICS: health, aggregation, dashboards # @SEMANTICS: health, aggregation, dashboards
# @PURPOSE: Business logic for aggregating dashboard health status from validation records. # @PURPOSE: Business logic for aggregating dashboard health status from validation records.
# @LAYER: Domain/Service # @LAYER: Domain/Service
# @RELATION: DEPENDS_ON -> ValidationRecord # @RELATION: [DEPENDS_ON] ->[backend.src.models.llm.ValidationRecord]
# @RELATION: DEPENDS_ON -> backend.src.core.superset_client.SupersetClient # @RELATION: [DEPENDS_ON] ->[backend.src.core.superset_client.SupersetClient]
# @RELATION: DEPENDS_ON -> backend.src.core.task_manager.cleanup.TaskCleanupService # @RELATION: [DEPENDS_ON] ->[backend.src.core.task_manager.cleanup.TaskCleanupService]
from typing import List, Dict, Any, Optional, Tuple from typing import List, Dict, Any, Optional, Tuple
import time import time

View File

@@ -18,10 +18,12 @@ from ..core.logger import logger, belief_scope
# [/SECTION] # [/SECTION]
# [DEF:ResourceService:Class] # [DEF:ResourceService:Class]
# @TIER: STANDARD
# @PURPOSE: Provides centralized access to resource data with enhanced metadata # @PURPOSE: Provides centralized access to resource data with enhanced metadata
class ResourceService: class ResourceService:
# [DEF:__init__:Function] # [DEF:__init__:Function]
# @TIER: TRIVIAL
# @PURPOSE: Initialize the resource service with dependencies # @PURPOSE: Initialize the resource service with dependencies
# @PRE: None # @PRE: None
# @POST: ResourceService is ready to fetch resources # @POST: ResourceService is ready to fetch resources
@@ -32,15 +34,16 @@ class ResourceService:
# [/DEF:__init__:Function] # [/DEF:__init__:Function]
# [DEF:get_dashboards_with_status:Function] # [DEF:get_dashboards_with_status:Function]
# @TIER: STANDARD
# @PURPOSE: Fetch dashboards from environment with Git status and last task status # @PURPOSE: Fetch dashboards from environment with Git status and last task status
# @PRE: env is a valid Environment object # @PRE: env is a valid Environment object
# @POST: Returns list of dashboards with enhanced metadata # @POST: Returns list of dashboards with enhanced metadata
# @PARAM: env (Environment) - The environment to fetch from # @PARAM: env (Environment) - The environment to fetch from
# @PARAM: tasks (List[Task]) - List of tasks to check for status # @PARAM: tasks (List[Task]) - List of tasks to check for status
# @RETURN: List[Dict] - Dashboards with git_status and last_task fields # @RETURN: List[Dict] - Dashboards with git_status and last_task fields
# @RELATION: CALLS -> SupersetClient.get_dashboards_summary # @RELATION: CALLS ->[SupersetClient:get_dashboards_summary]
# @RELATION: CALLS -> self._get_git_status_for_dashboard # @RELATION: CALLS ->[self:_get_git_status_for_dashboard]
# @RELATION: CALLS -> self._get_last_llm_task_for_dashboard # @RELATION: CALLS ->[self:_get_last_llm_task_for_dashboard]
async def get_dashboards_with_status( async def get_dashboards_with_status(
self, self,
env: Any, env: Any,
@@ -81,6 +84,7 @@ class ResourceService:
# [/DEF:get_dashboards_with_status:Function] # [/DEF:get_dashboards_with_status:Function]
# [DEF:get_dashboards_page_with_status:Function] # [DEF:get_dashboards_page_with_status:Function]
# @TIER: STANDARD
# @PURPOSE: Fetch one dashboard page from environment and enrich only that page with status metadata. # @PURPOSE: Fetch one dashboard page from environment and enrich only that page with status metadata.
# @PRE: env is valid; page >= 1; page_size > 0. # @PRE: env is valid; page >= 1; page_size > 0.
# @POST: Returns page items plus total counters without scanning all pages locally. # @POST: Returns page items plus total counters without scanning all pages locally.
@@ -144,6 +148,7 @@ class ResourceService:
# [/DEF:get_dashboards_page_with_status:Function] # [/DEF:get_dashboards_page_with_status:Function]
# [DEF:_get_last_llm_task_for_dashboard:Function] # [DEF:_get_last_llm_task_for_dashboard:Function]
# @TIER: STANDARD
# @PURPOSE: Get most recent LLM validation task for a dashboard in an environment # @PURPOSE: Get most recent LLM validation task for a dashboard in an environment
# @PRE: dashboard_id is a valid integer identifier # @PRE: dashboard_id is a valid integer identifier
# @POST: Returns the newest llm_dashboard_validation task summary or None # @POST: Returns the newest llm_dashboard_validation task summary or None
@@ -224,6 +229,7 @@ class ResourceService:
# [/DEF:_get_last_llm_task_for_dashboard:Function] # [/DEF:_get_last_llm_task_for_dashboard:Function]
# [DEF:_normalize_task_status:Function] # [DEF:_normalize_task_status:Function]
# @TIER: STANDARD
# @PURPOSE: Normalize task status to stable uppercase values for UI/API projections # @PURPOSE: Normalize task status to stable uppercase values for UI/API projections
# @PRE: raw_status can be enum or string # @PRE: raw_status can be enum or string
# @POST: Returns uppercase status without enum class prefix # @POST: Returns uppercase status without enum class prefix
@@ -240,6 +246,7 @@ class ResourceService:
# [/DEF:_normalize_task_status:Function] # [/DEF:_normalize_task_status:Function]
# [DEF:_normalize_validation_status:Function] # [DEF:_normalize_validation_status:Function]
# @TIER: STANDARD
# @PURPOSE: Normalize LLM validation status to PASS/FAIL/WARN/UNKNOWN # @PURPOSE: Normalize LLM validation status to PASS/FAIL/WARN/UNKNOWN
# @PRE: raw_status can be any scalar type # @PRE: raw_status can be any scalar type
# @POST: Returns normalized validation status token or None # @POST: Returns normalized validation status token or None
@@ -255,6 +262,7 @@ class ResourceService:
# [/DEF:_normalize_validation_status:Function] # [/DEF:_normalize_validation_status:Function]
# [DEF:_normalize_datetime_for_compare:Function] # [DEF:_normalize_datetime_for_compare:Function]
# @TIER: STANDARD
# @PURPOSE: Normalize datetime values to UTC-aware values for safe comparisons. # @PURPOSE: Normalize datetime values to UTC-aware values for safe comparisons.
# @PRE: value may be datetime or any scalar. # @PRE: value may be datetime or any scalar.
# @POST: Returns UTC-aware datetime; non-datetime values map to minimal UTC datetime. # @POST: Returns UTC-aware datetime; non-datetime values map to minimal UTC datetime.
@@ -269,14 +277,15 @@ class ResourceService:
# [/DEF:_normalize_datetime_for_compare:Function] # [/DEF:_normalize_datetime_for_compare:Function]
# [DEF:get_datasets_with_status:Function] # [DEF:get_datasets_with_status:Function]
# @TIER: STANDARD
# @PURPOSE: Fetch datasets from environment with mapping progress and last task status # @PURPOSE: Fetch datasets from environment with mapping progress and last task status
# @PRE: env is a valid Environment object # @PRE: env is a valid Environment object
# @POST: Returns list of datasets with enhanced metadata # @POST: Returns list of datasets with enhanced metadata
# @PARAM: env (Environment) - The environment to fetch from # @PARAM: env (Environment) - The environment to fetch from
# @PARAM: tasks (List[Task]) - List of tasks to check for status # @PARAM: tasks (List[Task]) - List of tasks to check for status
# @RETURN: List[Dict] - Datasets with mapped_fields and last_task fields # @RETURN: List[Dict] - Datasets with mapped_fields and last_task fields
# @RELATION: CALLS -> SupersetClient.get_datasets_summary # @RELATION: CALLS ->[SupersetClient:get_datasets_summary]
# @RELATION: CALLS -> self._get_last_task_for_resource # @RELATION: CALLS ->[self:_get_last_task_for_resource]
async def get_datasets_with_status( async def get_datasets_with_status(
self, self,
env: Any, env: Any,
@@ -307,6 +316,7 @@ class ResourceService:
# [/DEF:get_datasets_with_status:Function] # [/DEF:get_datasets_with_status:Function]
# [DEF:get_activity_summary:Function] # [DEF:get_activity_summary:Function]
# @TIER: STANDARD
# @PURPOSE: Get summary of active and recent tasks for the activity indicator # @PURPOSE: Get summary of active and recent tasks for the activity indicator
# @PRE: tasks is a list of Task objects # @PRE: tasks is a list of Task objects
# @POST: Returns summary with active_count and recent_tasks # @POST: Returns summary with active_count and recent_tasks
@@ -346,12 +356,13 @@ class ResourceService:
# [/DEF:get_activity_summary:Function] # [/DEF:get_activity_summary:Function]
# [DEF:_get_git_status_for_dashboard:Function] # [DEF:_get_git_status_for_dashboard:Function]
# @TIER: STANDARD
# @PURPOSE: Get Git sync status for a dashboard # @PURPOSE: Get Git sync status for a dashboard
# @PRE: dashboard_id is a valid integer # @PRE: dashboard_id is a valid integer
# @POST: Returns git status or None if no repo exists # @POST: Returns git status or None if no repo exists
# @PARAM: dashboard_id (int) - The dashboard ID # @PARAM: dashboard_id (int) - The dashboard ID
# @RETURN: Optional[Dict] - Git status with branch and sync_status # @RETURN: Optional[Dict] - Git status with branch and sync_status
# @RELATION: CALLS -> GitService.get_repo # @RELATION: CALLS ->[GitService:get_repo]
def _get_git_status_for_dashboard(self, dashboard_id: int) -> Optional[Dict[str, Any]]: def _get_git_status_for_dashboard(self, dashboard_id: int) -> Optional[Dict[str, Any]]:
try: try:
repo = self.git_service.get_repo(dashboard_id) repo = self.git_service.get_repo(dashboard_id)
@@ -405,6 +416,7 @@ class ResourceService:
# [/DEF:_get_git_status_for_dashboard:Function] # [/DEF:_get_git_status_for_dashboard:Function]
# [DEF:_get_last_task_for_resource:Function] # [DEF:_get_last_task_for_resource:Function]
# @TIER: STANDARD
# @PURPOSE: Get the most recent task for a specific resource # @PURPOSE: Get the most recent task for a specific resource
# @PRE: resource_id is a valid string # @PRE: resource_id is a valid string
# @POST: Returns task summary or None if no tasks found # @POST: Returns task summary or None if no tasks found
@@ -442,6 +454,7 @@ class ResourceService:
# [/DEF:_get_last_task_for_resource:Function] # [/DEF:_get_last_task_for_resource:Function]
# [DEF:_extract_resource_name_from_task:Function] # [DEF:_extract_resource_name_from_task:Function]
# @TIER: STANDARD
# @PURPOSE: Extract resource name from task params # @PURPOSE: Extract resource name from task params
# @PRE: task is a valid Task object # @PRE: task is a valid Task object
# @POST: Returns resource name or task ID # @POST: Returns resource name or task ID
@@ -453,6 +466,7 @@ class ResourceService:
# [/DEF:_extract_resource_name_from_task:Function] # [/DEF:_extract_resource_name_from_task:Function]
# [DEF:_extract_resource_type_from_task:Function] # [DEF:_extract_resource_type_from_task:Function]
# @TIER: STANDARD
# @PURPOSE: Extract resource type from task params # @PURPOSE: Extract resource type from task params
# @PRE: task is a valid Task object # @PRE: task is a valid Task object
# @POST: Returns resource type or 'unknown' # @POST: Returns resource type or 'unknown'
@@ -462,6 +476,5 @@ class ResourceService:
params = task.params or {} params = task.params or {}
return params.get('resource_type', 'unknown') return params.get('resource_type', 'unknown')
# [/DEF:_extract_resource_type_from_task:Function] # [/DEF:_extract_resource_type_from_task:Function]
# [/DEF:ResourceService:Class] # [/DEF:ResourceService:Class]
# [/DEF:backend.src.services.resource_service:Module] # [/DEF:backend.src.services.resource_service:Module]

View File

@@ -130,6 +130,10 @@
- [x] T043 Update semantic map generator to ignore few-shot examples under `.ai/shots/` (`generate_semantic_map.py`) - [x] T043 Update semantic map generator to ignore few-shot examples under `.ai/shots/` (`generate_semantic_map.py`)
- [x] T044 Fix semantic map generator Svelte relation parsing and prevent path-based CRITICAL over-promotion for nested route entities (`generate_semantic_map.py`) - [x] T044 Fix semantic map generator Svelte relation parsing and prevent path-based CRITICAL over-promotion for nested route entities (`generate_semantic_map.py`)
- [x] T045 Preserve latest dashboard validation task identity while falling back only the displayed validation result and broaden Svelte multiline `@RELATION` parsing (`backend/src/services/resource_service.py`, `backend/src/services/__tests__/test_resource_service.py`, `generate_semantic_map.py`) - [x] T045 Preserve latest dashboard validation task identity while falling back only the displayed validation result and broaden Svelte multiline `@RELATION` parsing (`backend/src/services/resource_service.py`, `backend/src/services/__tests__/test_resource_service.py`, `generate_semantic_map.py`)
- [-] T046 Execute `speckit.semantics` workflow with `fix` for semantic remediation
- **Outcome**: A semantic remediation pass was completed, but review gate verdict is **FAIL**. Semantic maintenance advanced the work but did not achieve closure/PASS.
- **Metrics**: Improvements from 1845 -> 1884 contracts and 432 -> 486 relations, with orphans reduced from 1530 -> 1398. Reviewer found current gate metrics at 1884 contracts / 486 relations / 1610 orphans / 301 unresolved relations.
- **Blockers**: CRITICAL metadata incompleteness in `backend/src/core/auth/repository.py`, `backend/src/core/config_manager.py`, `backend/src/core/task_manager/manager.py`, `backend/src/models/report.py`, systemic unresolved relations in `backend/src/api/routes/admin.py` and `backend/src/api/routes/assistant.py`, and missing frontend semantic coverage such as `frontend/src/routes/reports/+page.svelte`.
--- ---