#region ModuleContracts [C:5] [TYPE ADR] [SEMANTICS contracts, modules, grace, task-status-center, backend, frontend] @BRIEF GRACE-Poly v2.6 module contracts for the Task Status Center feature — backend API, service, and frontend Model/Component contracts with complexity anchors, decision memory, and cross-stack relations. @RATIONALE Contracts are the executable specification bridging spec.md ↔ code. Without them, agents optimize for token likelihood rather than spec compliance. @RELATION DEPENDS_ON -> [ReportModels] @RELATION DEPENDS_ON -> [TaskCenterModel] @RELATION DEPENDS_ON -> [TaskDrawer] ## 1. Complexity Inventory | Module | Language | Complexity | Justification | |--------|----------|:----------:|---------------| | `Reports.Summary` (api route) | Python | C4 | Multi-step: RBAC check, task loading, normalization, aggregation. Side effects: DB read. | | `Reports.SummaryService` (service) | Python | C4 | Stateful: accesses TaskManager singleton, applies RBAC filtering. | | `Reports.List` (api route, enhanced) | Python | C4 | Enhanced with RBAC row-filtering. Existing C3→C4. | | `TaskCenterModel` (model) | TypeScript/Svelte | C4 | Stateful: 15+ atoms, WebSocket lifecycle, debounce, URL sync. Side effects: WS connect/disconnect, REST calls. | | `SummaryPanel` (component) | Svelte 5 | C4 | WebSocket-reactive derived display. UX states: idle, loading, empty, disconnected. Side effects: click → filter. | | `FilterBar` (component) | Svelte 5 | C3 | Multi-select inputs, search, time range. No side effects beyond model mutation. | | `TaskList` (component) | Svelte 5 | C4 | Pagination, sort, click→drawer. UX states: loading, empty, error. | | `TaskCenterReportsPage` (page) | Svelte 5 | C4 | Page-level orchestration: Model init, layout, UX state machine. | | `ReportsTypes` (types) | TypeScript | C2 | DTO definitions only. | --- ## 2. Backend Contracts ### 2.1 Reports.SummaryRouter [C:4] [TYPE Module] ```python # backend/src/api/routes/reports.py (ADD to existing) # #region Reports.SummaryRouter [C:4] [TYPE Module] [SEMANTICS api, reports, summary, task-status-center] # @ingroup Reports # @BRIEF Task summary endpoint — aggregated counts by type × status with RBAC filtering. # @RELATION CALLS -> [Reports.SummaryService] # @RELATION DEPENDS_ON -> [TaskSummary] # @RELATION DEPENDS_ON -> [Auth.Dependencies] # @RATIONALE Separate endpoint from list_reports (R1) — summary is a lightweight aggregation suitable for independent caching. # @REJECTED Combining summary into list_reports header — rejected because it bloats paginated list responses and doubles server work. # @REJECTED WebSocket-only summary delivery — rejected because initial page load needs guaranteed REST delivery (SC-001). @router.get("/summary", response_model=TaskSummary) async def get_task_summary( current_user: User = Depends(get_current_user), task_manager: TaskManager = Depends(get_task_manager), clean_release_repo=Depends(get_clean_release_repository), ) -> TaskSummary: """ Get aggregated task summary by type and status. Permission: tasks:READ (checked via get_current_user). RBAC filtering: admin sees all, analyst sees own+system, viewer sees own only. """ ... # #endregion Reports.SummaryRouter ``` ### 2.2 Reports.SummaryService [C:4] [TYPE Module] ```python # backend/src/services/reports/report_service.py (ADD method) # #region Reports.SummaryService [C:4] [TYPE Module] [SEMANTICS service, summary, aggregation, rbac, task-status-center] # @ingroup Reports # @BRIEF Compute aggregated task summary with RBAC-aware filtering. # @RELATION CALLS -> [TaskManager.get_all_tasks] # @RELATION CALLS -> [Reports.Normalizer] # @PRE task_manager is initialized and has tasks loaded. # @PRE current_user is an authenticated User with tasks:READ permission. # @POST Returns TaskSummary with counts filtered by user's role. # @SIDE_EFFECT None (read-only aggregation). # @RATIONALE RBAC filtering happens here (R4) — service layer, not API layer, keeps business logic testable. # @REJECTED Filtering at TaskGraph level — rejected because graph is a data structure, not an authorization boundary. def get_summary(self, current_user: User) -> TaskSummary: """ Compute aggregated task counts by (task_type × status). RBAC rules: - admin: all tasks - analyst: own tasks + tasks with user_id=None (system) - viewer: own tasks only """ ... # #endregion Reports.SummaryService ``` ### 2.3 Reports.ListEnhanced [C:4] [TYPE Module] ```python # backend/src/api/routes/reports.py (MODIFY existing) # #region Reports.ListEnhanced [C:4] [TYPE Module] [SEMANTICS api, reports, list, rbac, task-status-center] # @ingroup Reports # @BRIEF Enhanced report list endpoint with RBAC row-level filtering. # @PRE current_user is authenticated. # @POST Returns ReportCollection filtered by user role. # @RATIONALE Enhancement of existing C3 endpoint to C4 — adds RBAC filtering per TSC-FR-009. # @REJECTED Separate admin/user endpoints — rejected because single endpoint with role-based filtering avoids route duplication. @router.get("", response_model=ReportCollection) async def list_reports( # ... existing params ... current_user: User = Depends(get_current_user), # ... ) -> ReportCollection: # Unchanged signature, enhanced implementation: # - task_filter applies RBAC before normalization # - sort_by now accepts "created_at" ... # #endregion Reports.ListEnhanced ``` ### 2.4 RBAC Task Filter [C:3] [TYPE Function] ```python # backend/src/services/reports/report_service.py (ADD helper) # #region Reports.RbacTaskFilter [C:3] [TYPE Function] [SEMANTICS service, rbac, filter, task-status-center] # @ingroup Reports # @BRIEF Filter tasks by user role for RBAC compliance. # @PRE current_user is an authenticated User object. # @POST Returns list of tasks visible to the user. # @RATIONALE Extracted as a pure function for testability — no side effects, no service dependencies. # @REJECTED Inline filtering in list_reports — rejected because it mixes authorization with query logic, making unit testing harder. def _filter_tasks_by_rbac(tasks: list[Task], current_user: User) -> list[Task]: """ Filter tasks based on user role. Rules (from TSC-FR-009): - admin → all tasks - analyst → own tasks + system tasks (user_id=None) - viewer → own tasks only """ if any(role.name == "Admin" or getattr(role, "is_admin", False) for role in current_user.roles): return tasks if any(role.name == "analyst" for role in current_user.roles if not getattr(role, "is_admin", False)): return [t for t in tasks if t.user_id == current_user.id or t.user_id is None] # viewer return [t for t in tasks if t.user_id == current_user.id] # #endregion Reports.RbacTaskFilter ``` --- ## 3. Frontend Contracts ### 3.1 TaskCenterModel [C:4] [TYPE Model] ```typescript // frontend/src/lib/models/TaskCenterModel.svelte.ts (NEW FILE) // #region TaskCenterModel [C:4] [TYPE Model] [SEMANTICS model, task-center, summary, filtering, websocket, task-status-center] // @BRIEF Screen Model for the Task Status Center — aggregates summary, task list, filtering, WebSocket subscription, and Task Drawer integration. // @LAYER Store // @PRE Auth token in localStorage, user authenticated via root +layout.svelte. // @INVARIANT Active tasks (pending, running, awaiting_input) are always sorted before completed tasks. // @INVARIANT visibleSummary excludes types in disabledTypes (admin) AND not in visibleTypes (operator). // @INVARIANT DECOMPOSITION GATE: 400 lines, 40 methods (ADR-0010). // @STATE tasks, summary, filters, screenState, wsConnected, selectedTaskId, page, pageSize, disabledTypes, visibleTypes, lastUpdated // @ACTION loadInitialData, applyFilter, clearFilters, selectTask, refreshSummary, connectWebSocket, disconnectWebSocket, destroy, toggleTypeVisibility // @RELATION CALLS -> [ReportsApi] // @RELATION CALLS -> [ApiModule.getTaskEventsWsUrl] // @RELATION BINDS_TO -> [TaskDrawerStore] // @RELATION BINDS_TO -> [SummaryPanel] // @RELATION BINDS_TO -> [FilterBar] // @RELATION BINDS_TO -> [TaskList] // @DATA_CONTRACT Input: TaskCenterFilter → Output: filteredTasks: TaskReport[], visibleSummary: TaskSummary // @POST All public methods that mutate state trigger $derived recomputation. // @POST WebSocket connection lifecycle is managed: connect on mount, disconnect on destroy. // @SIDE_EFFECT Connects to /ws/task-events WebSocket. Calls fetch API. Updates browser URL via replaceState. // @SIDE_EFFECT Opens TaskDrawer via taskDrawerStore.openDrawerForTask(). Reads/writes localStorage for visibleTypes. // @RATIONALE Single model (R3) chosen because the 3 user stories are tightly coupled — summary, filtering, and list share the same task dataset. Premature decomposition would add indirection without benefit. // @REJECTED Submodels from day one — rejected because estimated model size is ~250 lines, well below ADR-0010 400-line threshold. // @REJECTED Global store — rejected because Task Center state is page-scoped, not cross-route. // @REJECTED Polling-based refresh — rejected because spec requires real-time WebSocket updates (SC-002). export class TaskCenterModel { // === ATOMS === tasks: TaskReport[] = $state([]); summary: TaskSummary | null = $state(null); filters: TaskCenterFilter = $state({ ...DEFAULT_FILTERS }); screenState: ScreenState = $state('loading'); wsConnected: boolean = $state(false); selectedTaskId: string | null = $state(null); page: number = $state(1); pageSize: number = $state(20); disabledTypes: TaskType[] = $state([]); // admin-disabled types (from backend) visibleTypes: TaskType[] = $state(loadVisibleTypes()); // operator preference (localStorage) lastUpdated: Date | null = $state(null); // === DERIVED === get filteredTasks(): TaskReport[] { ... } // filter + sort (active first) get visibleSummary(): TaskSummary | null { ... } // exclude disabledTypes AND not in visibleTypes get isEmpty(): boolean { ... } get isFilteredEmpty(): boolean { ... } get hasActiveFilters(): boolean { ... } get totalPages(): number { ... } get hasNextPage(): boolean { ... } // === LIFECYCLE === async loadInitialData(initialFilters?: Partial): Promise { ... } connectWebSocket(): void { ... } disconnectWebSocket(): void { ... } destroy(): void { ... } // === ACTIONS === applyFilter(partial: Partial): void { ... } clearFilters(): void { ... } selectTask(taskId: string): void { ... } async refreshSummary(): Promise { ... } toggleTypeVisibility(type: TaskType): void { ... } // localStorage persist // === PRIVATE METHODS === private _onWsMessage(event: MessageEvent): void { ... } private _flushEventBuffer(): void { ... } // 100ms debounce private _scheduleReconnect(): void { ... } // exponential backoff private _syncFiltersToUrl(): void { ... } // replaceState, 300ms debounce private _buildQueryString(): string { ... } } // #endregion TaskCenterModel ``` ### 3.2 SummaryPanel [C:4] [TYPE Component] ```svelte {#if screenState === 'loading'} {:else if summary}
{#each summary.by_type as typeSummary} {#each STATUS_KEYS as status} onFilterByTypeAndStatus(typeSummary.task_type, status)}> {/each} {/each}
{/if} ``` ### 3.3 TaskCenterReportsPage [C:4] [TYPE Page] ```svelte {#if m.screenState === 'disconnected'}
Соединение потеряно. Попытка переподключения...
{/if} m.applyFilter({ task_types: [type], statuses: [status] })} /> { m.page = p; }} /> ``` ### 3.4 ReportsApiClient [C:3] [TYPE Module] ```typescript // frontend/src/lib/api/reports.ts (ENHANCE existing) // #region ReportsApiClient [C:3] [TYPE Module] [SEMANTICS api, client, reports, typed, task-status-center] // @BRIEF Typed API client for reports endpoints including the new summary endpoint. // @LAYER API Client // @RELATION CALLS -> [ApiModule.fetchApi] // @RELATION CALLS -> [ApiModule.getTaskEventsWsUrl] // @DATA_CONTRACT Input: ReportQuery → Output: ReportCollection // @DATA_CONTRACT Input: (none) → Output: TaskSummary // @PRE Auth token exists in localStorage. // @POST All responses are typed (replaces generic ). // EXISTING — add types import type { TaskReport, ReportCollection, ReportDetailView, ReportQuery } from '$types/reports'; // NEW import type { TaskSummary } from '$types/reports'; /** Fetch aggregated task summary. */ export async function getReportsSummary(): Promise { const response = await api.fetchApi('/reports/summary'); return response.json(); } // EXISTING — enhance with types export async function getReports(options: Partial): Promise { const query = buildReportQueryString(options); const response = await api.fetchApi(`/reports${query}`); return response.json(); } export async function getReportDetail(reportId: string): Promise { const response = await api.fetchApi(`/reports/${reportId}`); return response.json(); } // #endregion ReportsApiClient ``` ### 3.5 FilterBar [C:3] [TYPE Component] ```svelte ``` ### 3.6 TaskList [C:4] [TYPE Component] ```svelte ``` ### 3.7 ReportsTypeProfiles [C:2] [TYPE Module] ```typescript // frontend/src/lib/components/reports/reportTypeProfiles.ts (MODIFY existing — add clean_release) // #region ReportsTypeProfiles [C:2] [TYPE Module] [SEMANTICS profile, task-type, visual, mapping, task-status-center] // @BRIEF Visual profiles for canonical task types — synchronized with backend type_profiles.py. // @RATIONALE R8: Frontend profiles must match backend TaskType enum. Added clean_release entry. // @RELATION DEPENDS_ON -> [Backend.type_profiles] via @DATA_CONTRACT export function getReportTypeProfile(taskType: string): TypeProfile { const profiles: Record = { llm_verification: { key: 'llm_verification', label: 'LLM Verification', variant: 'info', icon: 'sparkles', fallback: false }, backup: { key: 'backup', label: 'Backup', variant: 'default', icon: 'archive', fallback: false }, migration: { key: 'migration', label: 'Migration', variant: 'default', icon: 'shuffle', fallback: false }, documentation: { key: 'documentation', label: 'Documentation', variant: 'default', icon: 'file-text', fallback: false }, clean_release: { key: 'clean_release', label: 'Clean Release', variant: 'success', icon: 'shield-check', fallback: false }, // ← NEW }; return profiles[taskType] ?? profiles.unknown; } // #endregion ReportsTypeProfiles ``` --- ## 4. Contract Traceability Matrix | Spec FR | Contract(s) | Type | Complexity | |---------|-------------|------|:----------:| | TSC-FR-001 | `SummaryPanel`, `Reports.SummaryRouter`, `Reports.SummaryService` | Component, API, Service | C4, C4, C4 | | TSC-FR-002 | `TaskCenterModel._onWsMessage`, `SummaryPanel` | Model, Component | C4, C4 | | TSC-FR-003 | `TaskList`, `Reports.ListEnhanced` | Component, API | C4, C4 | | TSC-FR-004 | `FilterBar`, `TaskCenterModel.applyFilter` | Component, Model | C3, C4 | | TSC-FR-005 | `TaskList` (row template), `TaskReport` | Component, Model | C4, C3 | | TSC-FR-006 | `TaskCenterModel.selectTask`, `TaskDrawer` | Model, Component | C4, existing | | TSC-FR-007 | `TaskList` (awaiting_input indicator) | Component | C4 | | TSC-FR-008 | `TaskCenterModel` (reconnect logic), `TaskCenterReportsPage` | Model, Page | C4, C4 | | TSC-FR-009 | `Reports.RbacTaskFilter`, `Reports.SummaryService` | Function, Service | C3, C4 | | TSC-FR-010 | `TaskCenterModel.filteredTasks` ($derived) | Model | C4 | #endregion ModuleContracts