Compare commits

..

3 Commits

Author SHA1 Message Date
0cf0ef25f1 db + docker 2026-02-20 20:47:39 +03:00
af74841765 semantic update 2026-02-20 10:41:15 +03:00
d7e4919d54 few shots update 2026-02-20 10:26:01 +03:00
38 changed files with 4567 additions and 1821 deletions

1250
.ai/MODULE_MAP.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
> Compressed view for AI Context. Generated automatically. > Compressed view for AI Context. Generated automatically.
- 📦 **generate_semantic_map** (`Module`) `[CRITICAL]` - 📦 **generate_semantic_map** (`Module`) `[CRITICAL]`
- 📝 Scans the codebase to generate a Semantic Map and Compliance Report based on the System Standard. - 📝 Scans the codebase to generate a Semantic Map, Module Map, and Compliance Report based on the System Standard.
- 🏗️ Layer: DevOps/Tooling - 🏗️ Layer: DevOps/Tooling
- 🔒 Invariant: All DEF anchors must have matching closing anchors; TIER determines validation strictness. - 🔒 Invariant: All DEF anchors must have matching closing anchors; TIER determines validation strictness.
- ƒ **__init__** (`Function`) `[TRIVIAL]` - ƒ **__init__** (`Function`) `[TRIVIAL]`
@@ -71,8 +71,57 @@
- 📝 Generates the token-optimized project map with enhanced Svelte details. - 📝 Generates the token-optimized project map with enhanced Svelte details.
- ƒ **_write_entity_md** (`Function`) `[CRITICAL]` - ƒ **_write_entity_md** (`Function`) `[CRITICAL]`
- 📝 Recursive helper to write entity tree to Markdown with tier badges and enhanced details. - 📝 Recursive helper to write entity tree to Markdown with tier badges and enhanced details.
- ƒ **_generate_module_map** (`Function`) `[CRITICAL]`
- 📝 Generates a module-centric map grouping entities by directory structure.
- ƒ **_get_module_path** (`Function`)
- 📝 Extracts the module path from a file path.
- ƒ **_collect_all_entities** (`Function`)
- 📝 Flattens entity tree for easier grouping.
- ƒ **to_dict** (`Function`) `[TRIVIAL]` - ƒ **to_dict** (`Function`) `[TRIVIAL]`
- 📝 Auto-detected function (orphan) - 📝 Auto-detected function (orphan)
- 📦 **TransactionCore** (`Module`) `[CRITICAL]`
- 📝 Core banking transaction processor with ACID guarantees.
- 🏗️ Layer: Domain (Core)
- 🔒 Invariant: Negative transfers are strictly forbidden.
- 🔗 DEPENDS_ON -> `[DEF:Infra:PostgresDB]`
- 🔗 DEPENDS_ON -> `[DEF:Infra:AuditLog]`
- ƒ **execute_transfer** (`Function`)
- 📝 Atomically move funds between accounts with audit trails.
- 🔗 CALLS -> `atomic_transaction`
- 📦 **PluginExampleShot** (`Module`)
- 📝 Reference implementation of a plugin following GRACE standards.
- 🏗️ Layer: Domain (Business Logic)
- 🔒 Invariant: get_schema must return valid JSON Schema.
- 🔗 INHERITS -> `PluginBase`
- ƒ **get_schema** (`Function`)
- 📝 Defines input validation schema.
- ƒ **execute** (`Function`)
- 📝 Core plugin logic with structured logging and scope isolation.
- ƒ **id** (`Function`) `[TRIVIAL]`
- 📝 Auto-detected function (orphan)
- 📦 **BackendRouteShot** (`Module`)
- 📝 Reference implementation of a task-based route using GRACE-Poly.
- 🏗️ Layer: Interface (API)
- 🔒 Invariant: TaskManager must be available in dependency graph.
- 🔗 IMPLEMENTS -> `[DEF:Std:API_FastAPI]`
- ƒ **create_task** (`Function`)
- 📝 Create and start a new task using TaskManager. Non-blocking.
- 🔗 CALLS -> `task_manager.create_task`
- 🧩 **FrontendComponentShot** (`Component`) `[CRITICAL]`
- 📝 Action button to spawn a new task with full UX feedback cycle.
- 🏗️ Layer: UI (Presentation)
- 🔒 Invariant: Must prevent double-submission while loading.
- 📥 Props: plugin_id: any, params: any
- ⬅️ READS_FROM `lib`
- ⬅️ READS_FROM `t`
- ƒ **spawnTask** (`Function`)
- 📦 **DashboardTypes** (`Module`) `[TRIVIAL]`
- 📝 TypeScript interfaces for Dashboard entities
- 🏗️ Layer: Domain
- 🧩 **Counter** (`Component`) `[TRIVIAL]`
- 📝 Simple counter demo component
- 🏗️ Layer: UI
- ➡️ WRITES_TO `state`
- 📦 **stores_module** (`Module`) - 📦 **stores_module** (`Module`)
- 📝 Global state management using Svelte stores. - 📝 Global state management using Svelte stores.
- 🏗️ Layer: UI-State - 🏗️ Layer: UI-State
@@ -116,6 +165,11 @@
- 📝 Generic request wrapper. - 📝 Generic request wrapper.
- 📦 **api** (`Data`) - 📦 **api** (`Data`)
- 📝 API client object with specific methods. - 📝 API client object with specific methods.
- 📦 **Utils** (`Module`) `[TRIVIAL]`
- 📝 General utility functions (class merging)
- 🏗️ Layer: Infra
- ƒ **cn** (`Function`) `[TRIVIAL]`
- 📝 Auto-detected function (orphan)
- 🗄️ **authStore** (`Store`) - 🗄️ **authStore** (`Store`)
- 📝 Manages the global authentication state on the frontend. - 📝 Manages the global authentication state on the frontend.
- 🏗️ Layer: Feature - 🏗️ Layer: Feature
@@ -131,9 +185,9 @@
- 📝 Clears authentication state and storage. - 📝 Clears authentication state and storage.
- ƒ **setLoading** (`Function`) - ƒ **setLoading** (`Function`)
- 📝 Updates the loading state. - 📝 Updates the loading state.
- 📦 **debounce** (`Module`) `[TRIVIAL]` - 📦 **Debounce** (`Module`) `[TRIVIAL]`
- 📝 Auto-generated module for frontend/src/lib/utils/debounce.js - 📝 Debounce utility for limiting function execution rate
- 🏗️ Layer: Unknown - 🏗️ Layer: Infra
- ƒ **debounce** (`Function`) `[TRIVIAL]` - ƒ **debounce** (`Function`) `[TRIVIAL]`
- 📝 Auto-detected function (orphan) - 📝 Auto-detected function (orphan)
- 🗄️ **taskDrawer** (`Store`) `[CRITICAL]` - 🗄️ **taskDrawer** (`Store`) `[CRITICAL]`
@@ -181,8 +235,9 @@
- 📝 Unit tests for sidebar store - 📝 Unit tests for sidebar store
- 🏗️ Layer: Domain (Tests) - 🏗️ Layer: Domain (Tests)
- ƒ **test_sidebar_initial_state** (`Function`) - ƒ **test_sidebar_initial_state** (`Function`)
- ƒ **test_toggleSidebar** (`Function`) - ƒ **test_toggleSidebar** (`Function`)
- ƒ **test_setActiveItem** (`Function`) - ƒ **test_setActiveItem** (`Function`)
- ƒ **test_mobile_functions** (`Function`)
- 📦 **frontend.src.lib.stores.__tests__.test_activity** (`Module`) - 📦 **frontend.src.lib.stores.__tests__.test_activity** (`Module`)
- 📝 Unit tests for activity store - 📝 Unit tests for activity store
- 🏗️ Layer: UI - 🏗️ Layer: UI
@@ -202,7 +257,9 @@
- 🧩 **Select** (`Component`) `[TRIVIAL]` - 🧩 **Select** (`Component`) `[TRIVIAL]`
- 📝 Standardized dropdown selection component. - 📝 Standardized dropdown selection component.
- 🏗️ Layer: Atom - 🏗️ Layer: Atom
- 📥 Props: label: string , value: string | number , disabled: boolean - ⬅️ READS_FROM `lib`
- ➡️ WRITES_TO `bindable`
- ➡️ WRITES_TO `props`
- 📦 **ui** (`Module`) `[TRIVIAL]` - 📦 **ui** (`Module`) `[TRIVIAL]`
- 📝 Central export point for standardized UI components. - 📝 Central export point for standardized UI components.
- 🏗️ Layer: Atom - 🏗️ Layer: Atom
@@ -210,21 +267,26 @@
- 🧩 **PageHeader** (`Component`) `[TRIVIAL]` - 🧩 **PageHeader** (`Component`) `[TRIVIAL]`
- 📝 Standardized page header with title and action area. - 📝 Standardized page header with title and action area.
- 🏗️ Layer: Atom - 🏗️ Layer: Atom
- 📥 Props: title: string - ⬅️ READS_FROM `lib`
- ➡️ WRITES_TO `props`
- 🧩 **Card** (`Component`) `[TRIVIAL]` - 🧩 **Card** (`Component`) `[TRIVIAL]`
- 📝 Standardized container with padding and elevation. - 📝 Standardized container with padding and elevation.
- 🏗️ Layer: Atom - 🏗️ Layer: Atom
- 📥 Props: title: string - ⬅️ READS_FROM `lib`
- ➡️ WRITES_TO `props`
- 🧩 **Button** (`Component`) `[TRIVIAL]` - 🧩 **Button** (`Component`) `[TRIVIAL]`
- 📝 Define component interface and default values. - 📝 Define component interface and default values (Svelte 5 Runes).
- 🏗️ Layer: Atom - 🏗️ Layer: Atom
- 🔒 Invariant: Supports accessible labels and keyboard navigation. - 🔒 Invariant: Supports accessible labels and keyboard navigation.
- 📥 Props: isLoading: boolean , disabled: boolean - ⬅️ READS_FROM `lib`
- ➡️ WRITES_TO `props`
- 🧩 **Input** (`Component`) `[TRIVIAL]` - 🧩 **Input** (`Component`) `[TRIVIAL]`
- 📝 Standardized text input component with label and error handling. - 📝 Standardized text input component with label and error handling.
- 🏗️ Layer: Atom - 🏗️ Layer: Atom
- 🔒 Invariant: Consistent spacing and focus states. - 🔒 Invariant: Consistent spacing and focus states.
- 📥 Props: label: string , value: string , placeholder: string , error: string , disabled: boolean - ⬅️ READS_FROM `lib`
- ➡️ WRITES_TO `bindable`
- ➡️ WRITES_TO `props`
- 🧩 **LanguageSwitcher** (`Component`) `[TRIVIAL]` - 🧩 **LanguageSwitcher** (`Component`) `[TRIVIAL]`
- 📝 Dropdown component to switch between supported languages. - 📝 Dropdown component to switch between supported languages.
- 🏗️ Layer: Atom - 🏗️ Layer: Atom
@@ -293,10 +355,9 @@
- 📝 Display page hierarchy navigation - 📝 Display page hierarchy navigation
- 🏗️ Layer: UI - 🏗️ Layer: UI
- 🔒 Invariant: Always shows current page path - 🔒 Invariant: Always shows current page path
- 📥 Props: maxVisible: any
- ⬅️ READS_FROM `app` - ⬅️ READS_FROM `app`
- ⬅️ READS_FROM `lib` - ⬅️ READS_FROM `lib`
- READS_FROM `page` - WRITES_TO `props`
- 📦 **Breadcrumbs** (`Module`) `[TRIVIAL]` - 📦 **Breadcrumbs** (`Module`) `[TRIVIAL]`
- 📝 Auto-generated module for frontend/src/lib/components/layout/Breadcrumbs.svelte - 📝 Auto-generated module for frontend/src/lib/components/layout/Breadcrumbs.svelte
- 🏗️ Layer: Unknown - 🏗️ Layer: Unknown
@@ -328,6 +389,12 @@
- 📝 Auto-detected function (orphan) - 📝 Auto-detected function (orphan)
- ƒ **disconnectWebSocket** (`Function`) `[TRIVIAL]` - ƒ **disconnectWebSocket** (`Function`) `[TRIVIAL]`
- 📝 Auto-detected function (orphan) - 📝 Auto-detected function (orphan)
- 📦 **ErrorPage** (`Page`)
- 📝 Global error page displaying HTTP status and messages
- 🏗️ Layer: UI
- 📦 **RootLayoutConfig** (`Module`) `[TRIVIAL]`
- 📝 Root layout configuration (SPA mode)
- 🏗️ Layer: Infra
- 📦 **HomePage** (`Page`) `[CRITICAL]` - 📦 **HomePage** (`Page`) `[CRITICAL]`
- 📝 Redirect to Dashboard Hub as per UX requirements - 📝 Redirect to Dashboard Hub as per UX requirements
- 🏗️ Layer: UI - 🏗️ Layer: UI
@@ -692,8 +759,10 @@
- 🧩 **PasswordPrompt** (`Component`) - 🧩 **PasswordPrompt** (`Component`)
- 📝 A modal component to prompt the user for database passwords when a migration task is paused. - 📝 A modal component to prompt the user for database passwords when a migration task is paused.
- 🏗️ Layer: UI - 🏗️ Layer: UI
- 📥 Props: show: any, databases: any, errorMessage: any
- ⚡ Events: cancel, resume - ⚡ Events: cancel, resume
- ➡️ WRITES_TO `props`
- ➡️ WRITES_TO `state`
- ⬅️ READS_FROM `effect`
- ƒ **handleSubmit** (`Function`) - ƒ **handleSubmit** (`Function`)
- 📝 Validates and dispatches the passwords to resume the task. - 📝 Validates and dispatches the passwords to resume the task.
- ƒ **handleCancel** (`Function`) - ƒ **handleCancel** (`Function`)
@@ -703,6 +772,7 @@
- 🏗️ Layer: Feature - 🏗️ Layer: Feature
- 🔒 Invariant: Each source database can be mapped to one target database. - 🔒 Invariant: Each source database can be mapped to one target database.
- ⚡ Events: update - ⚡ Events: update
- ➡️ WRITES_TO `props`
- ƒ **updateMapping** (`Function`) - ƒ **updateMapping** (`Function`)
- 📝 Updates a mapping for a specific source database. - 📝 Updates a mapping for a specific source database.
- ƒ **getSuggestion** (`Function`) - ƒ **getSuggestion** (`Function`)
@@ -711,13 +781,12 @@
- 📝 Displays detailed logs for a specific task inline or in a modal using TaskLogPanel. - 📝 Displays detailed logs for a specific task inline or in a modal using TaskLogPanel.
- 🏗️ Layer: UI - 🏗️ Layer: UI
- 🔒 Invariant: Real-time logs are always appended without duplicates. - 🔒 Invariant: Real-time logs are always appended without duplicates.
- 📥 Props: show: any, inline: any, taskId: any, taskStatus: any, realTimeLogs: any
- ⚡ Events: close - ⚡ Events: close
- READS_FROM `t` - WRITES_TO `bindable`
- ➡️ WRITES_TO `props`
- ➡️ WRITES_TO `state`
- 📦 **handleRealTimeLogs** (`Action`) - 📦 **handleRealTimeLogs** (`Action`)
- 📝 Append real-time logs as they arrive from WebSocket, preventing duplicates */
- ƒ **fetchLogs** (`Function`) - ƒ **fetchLogs** (`Function`)
- 📝 Fetches logs for the current task from API (polling fallback).
- 📦 **TaskLogViewer** (`Module`) `[TRIVIAL]` - 📦 **TaskLogViewer** (`Module`) `[TRIVIAL]`
- 📝 Auto-generated module for frontend/src/components/TaskLogViewer.svelte - 📝 Auto-generated module for frontend/src/components/TaskLogViewer.svelte
- 🏗️ Layer: Unknown - 🏗️ Layer: Unknown
@@ -732,8 +801,8 @@
- 📝 Prompts the user to provide a database mapping when one is missing during migration. - 📝 Prompts the user to provide a database mapping when one is missing during migration.
- 🏗️ Layer: Feature - 🏗️ Layer: Feature
- 🔒 Invariant: Modal blocks migration progress until resolved or cancelled. - 🔒 Invariant: Modal blocks migration progress until resolved or cancelled.
- 📥 Props: show: boolean , sourceDbName: string , sourceDbUuid: string
- ⚡ Events: cancel, resolve - ⚡ Events: cancel, resolve
- ➡️ WRITES_TO `props`
- ƒ **resolve** (`Function`) - ƒ **resolve** (`Function`)
- 📝 Dispatches the resolution event with the selected mapping. - 📝 Dispatches the resolution event with the selected mapping.
- ƒ **cancel** (`Function`) - ƒ **cancel** (`Function`)
@@ -742,10 +811,10 @@
- 📝 Displays a grid of dashboards with selection and pagination. - 📝 Displays a grid of dashboards with selection and pagination.
- 🏗️ Layer: Component - 🏗️ Layer: Component
- 🔒 Invariant: Selected IDs must be a subset of available dashboards. - 🔒 Invariant: Selected IDs must be a subset of available dashboards.
- 📥 Props: dashboards: DashboardMetadata[] , selectedIds: number[] , environmentId: string
- ⚡ Events: selectionChanged - ⚡ Events: selectionChanged
- ➡️ WRITES_TO `props`
- ➡️ WRITES_TO `derived`
- ➡️ WRITES_TO `t` - ➡️ WRITES_TO `t`
- ⬅️ READS_FROM `t`
- ƒ **handleValidate** (`Function`) - ƒ **handleValidate** (`Function`)
- 📝 Triggers dashboard validation task. - 📝 Triggers dashboard validation task.
- ƒ **handleSort** (`Function`) - ƒ **handleSort** (`Function`)
@@ -816,8 +885,8 @@
- 🧩 **TaskList** (`Component`) - 🧩 **TaskList** (`Component`)
- 📝 Displays a list of tasks with their status and execution details. - 📝 Displays a list of tasks with their status and execution details.
- 🏗️ Layer: Component - 🏗️ Layer: Component
- 📥 Props: tasks: Array<any> , loading: boolean
- ⚡ Events: select - ⚡ Events: select
- ➡️ WRITES_TO `props`
- ➡️ WRITES_TO `t` - ➡️ WRITES_TO `t`
- ⬅️ READS_FROM `t` - ⬅️ READS_FROM `t`
- ƒ **getStatusColor** (`Function`) - ƒ **getStatusColor** (`Function`)
@@ -829,8 +898,8 @@
- 🧩 **DynamicForm** (`Component`) - 🧩 **DynamicForm** (`Component`)
- 📝 Generates a form dynamically based on a JSON schema. - 📝 Generates a form dynamically based on a JSON schema.
- 🏗️ Layer: UI - 🏗️ Layer: UI
- 📥 Props: schema: any
- ⚡ Events: submit - ⚡ Events: submit
- ➡️ WRITES_TO `props`
- ƒ **handleSubmit** (`Function`) - ƒ **handleSubmit** (`Function`)
- 📝 Dispatches the submit event with the form data. - 📝 Dispatches the submit event with the form data.
- ƒ **initializeForm** (`Function`) - ƒ **initializeForm** (`Function`)
@@ -839,8 +908,8 @@
- 📝 Provides a UI component for selecting source and target environments. - 📝 Provides a UI component for selecting source and target environments.
- 🏗️ Layer: Feature - 🏗️ Layer: Feature
- 🔒 Invariant: Source and target environments must be selectable from the list of configured environments. - 🔒 Invariant: Source and target environments must be selectable from the list of configured environments.
- 📥 Props: label: string , selectedId: string
- ⚡ Events: change - ⚡ Events: change
- ➡️ WRITES_TO `props`
- ƒ **handleSelect** (`Function`) - ƒ **handleSelect** (`Function`)
- 📝 Dispatches the selection change event. - 📝 Dispatches the selection change event.
- 🧩 **ProtectedRoute** (`Component`) `[TRIVIAL]` - 🧩 **ProtectedRoute** (`Component`) `[TRIVIAL]`
@@ -850,11 +919,13 @@
- ⬅️ READS_FROM `app` - ⬅️ READS_FROM `app`
- ⬅️ READS_FROM `auth` - ⬅️ READS_FROM `auth`
- 🧩 **TaskLogPanel** (`Component`) - 🧩 **TaskLogPanel** (`Component`)
- 📝 Component properties and state. - 📝 Combines log filtering and display into a single cohesive dark-themed panel.
- 🏗️ Layer: UI - 🏗️ Layer: UI
- 🔒 Invariant: Must always display logs in chronological order and respect auto-scroll preference. - 🔒 Invariant: Must always display logs in chronological order and respect auto-scroll preference.
- 📥 Props: logs: any, autoScroll: any
- ⚡ Events: filterChange - ⚡ Events: filterChange
- ➡️ WRITES_TO `bindable`
- ➡️ WRITES_TO `props`
- ➡️ WRITES_TO `state`
- 📦 **TaskLogPanel** (`Module`) `[TRIVIAL]` - 📦 **TaskLogPanel** (`Module`) `[TRIVIAL]`
- 📝 Auto-generated module for frontend/src/components/tasks/TaskLogPanel.svelte - 📝 Auto-generated module for frontend/src/components/tasks/TaskLogPanel.svelte
- 🏗️ Layer: Unknown - 🏗️ Layer: Unknown
@@ -869,7 +940,9 @@
- 🧩 **LogFilterBar** (`Component`) - 🧩 **LogFilterBar** (`Component`)
- 📝 Compact filter toolbar for logs — level, source, and text search in a single dense row. - 📝 Compact filter toolbar for logs — level, source, and text search in a single dense row.
- 🏗️ Layer: UI - 🏗️ Layer: UI
- 📥 Props: availableSources: any, selectedLevel: any, selectedSource: any, searchText: any - ➡️ WRITES_TO `bindable`
- ➡️ WRITES_TO `props`
- ➡️ WRITES_TO `derived`
- 📦 **LogFilterBar** (`Module`) `[TRIVIAL]` - 📦 **LogFilterBar** (`Module`) `[TRIVIAL]`
- 📝 Auto-generated module for frontend/src/components/tasks/LogFilterBar.svelte - 📝 Auto-generated module for frontend/src/components/tasks/LogFilterBar.svelte
- 🏗️ Layer: Unknown - 🏗️ Layer: Unknown
@@ -884,21 +957,15 @@
- 🧩 **LogEntryRow** (`Component`) - 🧩 **LogEntryRow** (`Component`)
- 📝 Renders a single log entry with stacked layout optimized for narrow drawer panels. - 📝 Renders a single log entry with stacked layout optimized for narrow drawer panels.
- 🏗️ Layer: UI - 🏗️ Layer: UI
- 📥 Props: log: any, showSource: any - ➡️ WRITES_TO `props`
- ➡️ WRITES_TO `derived`
- ƒ **formatTime** (`Function`) - ƒ **formatTime** (`Function`)
- 📝 Format ISO timestamp to HH:MM:SS */ - 📝 Format ISO timestamp to HH:MM:SS */
- 📦 **LogEntryRow** (`Module`) `[TRIVIAL]`
- 📝 Auto-generated module for frontend/src/components/tasks/LogEntryRow.svelte
- 🏗️ Layer: Unknown
- ƒ **getLevelClass** (`Function`) `[TRIVIAL]`
- 📝 Auto-detected function (orphan)
- ƒ **getSourceClass** (`Function`) `[TRIVIAL]`
- 📝 Auto-detected function (orphan)
- 🧩 **FileList** (`Component`) - 🧩 **FileList** (`Component`)
- 📝 Displays a table of files with metadata and actions. - 📝 Displays a table of files with metadata and actions.
- 🏗️ Layer: UI - 🏗️ Layer: UI
- 📥 Props: files: any
- ⚡ Events: delete, navigate - ⚡ Events: delete, navigate
- ➡️ WRITES_TO `props`
- ➡️ WRITES_TO `t` - ➡️ WRITES_TO `t`
- ⬅️ READS_FROM `t` - ⬅️ READS_FROM `t`
- ƒ **isDirectory** (`Function`) - ƒ **isDirectory** (`Function`)
@@ -911,6 +978,7 @@
- 📝 Provides a form for uploading files to a specific category. - 📝 Provides a form for uploading files to a specific category.
- 🏗️ Layer: UI - 🏗️ Layer: UI
- ⚡ Events: uploaded - ⚡ Events: uploaded
- ➡️ WRITES_TO `props`
- ⬅️ READS_FROM `t` - ⬅️ READS_FROM `t`
- ➡️ WRITES_TO `t` - ➡️ WRITES_TO `t`
- ƒ **handleUpload** (`Function`) - ƒ **handleUpload** (`Function`)
@@ -964,7 +1032,7 @@
- 🧩 **CommitHistory** (`Component`) - 🧩 **CommitHistory** (`Component`)
- 📝 Displays the commit history for a specific dashboard. - 📝 Displays the commit history for a specific dashboard.
- 🏗️ Layer: Component - 🏗️ Layer: Component
- 📥 Props: dashboardId: any - ➡️ WRITES_TO `props`
- ⬅️ READS_FROM `t` - ⬅️ READS_FROM `t`
- ➡️ WRITES_TO `t` - ➡️ WRITES_TO `t`
- ƒ **onMount** (`Function`) - ƒ **onMount** (`Function`)
@@ -975,8 +1043,9 @@
- 📝 Modal for deploying a dashboard to a target environment. - 📝 Modal for deploying a dashboard to a target environment.
- 🏗️ Layer: Component - 🏗️ Layer: Component
- 🔒 Invariant: Cannot deploy without a selected environment. - 🔒 Invariant: Cannot deploy without a selected environment.
- 📥 Props: dashboardId: any, show: any
- ⚡ Events: deploy - ⚡ Events: deploy
- ➡️ WRITES_TO `props`
- ⬅️ READS_FROM `effect`
- 📦 **loadStatus** (`Watcher`) - 📦 **loadStatus** (`Watcher`)
- ƒ **loadEnvironments** (`Function`) - ƒ **loadEnvironments** (`Function`)
- 📝 Fetch available environments from API. - 📝 Fetch available environments from API.
@@ -986,8 +1055,8 @@
- 📝 UI for resolving merge conflicts (Keep Mine / Keep Theirs). - 📝 UI for resolving merge conflicts (Keep Mine / Keep Theirs).
- 🏗️ Layer: Component - 🏗️ Layer: Component
- 🔒 Invariant: User must resolve all conflicts before saving. - 🔒 Invariant: User must resolve all conflicts before saving.
- 📥 Props: conflicts: any, show: any
- ⚡ Events: resolve - ⚡ Events: resolve
- ➡️ WRITES_TO `props`
- ƒ **resolve** (`Function`) - ƒ **resolve** (`Function`)
- 📝 Set resolution strategy for a file. - 📝 Set resolution strategy for a file.
- ƒ **handleSave** (`Function`) - ƒ **handleSave** (`Function`)
@@ -995,8 +1064,9 @@
- 🧩 **CommitModal** (`Component`) - 🧩 **CommitModal** (`Component`)
- 📝 Модальное окно для создания коммита с просмотром изменений (diff). - 📝 Модальное окно для создания коммита с просмотром изменений (diff).
- 🏗️ Layer: Component - 🏗️ Layer: Component
- 📥 Props: dashboardId: any, show: any
- ⚡ Events: commit - ⚡ Events: commit
- ➡️ WRITES_TO `props`
- ⬅️ READS_FROM `effect`
- ƒ **handleGenerateMessage** (`Function`) - ƒ **handleGenerateMessage** (`Function`)
- 📝 Generates a commit message using LLM. - 📝 Generates a commit message using LLM.
- ƒ **loadStatus** (`Function`) - ƒ **loadStatus** (`Function`)
@@ -1006,8 +1076,8 @@
- 🧩 **BranchSelector** (`Component`) - 🧩 **BranchSelector** (`Component`)
- 📝 UI для выбора и создания веток Git. - 📝 UI для выбора и создания веток Git.
- 🏗️ Layer: Component - 🏗️ Layer: Component
- 📥 Props: dashboardId: any, currentBranch: any
- ⚡ Events: change - ⚡ Events: change
- ➡️ WRITES_TO `props`
- ⬅️ READS_FROM `t` - ⬅️ READS_FROM `t`
- ƒ **onMount** (`Function`) - ƒ **onMount** (`Function`)
- 📝 Load branches when component is mounted. - 📝 Load branches when component is mounted.
@@ -1022,7 +1092,7 @@
- 🧩 **GitManager** (`Component`) - 🧩 **GitManager** (`Component`)
- 📝 Центральный компонент для управления Git-операциями конкретного дашборда. - 📝 Центральный компонент для управления Git-операциями конкретного дашборда.
- 🏗️ Layer: Component - 🏗️ Layer: Component
- 📥 Props: dashboardId: any, dashboardTitle: any, show: any - ➡️ WRITES_TO `props`
- ➡️ WRITES_TO `t` - ➡️ WRITES_TO `t`
- ⬅️ READS_FROM `t` - ⬅️ READS_FROM `t`
- ƒ **checkStatus** (`Function`) - ƒ **checkStatus** (`Function`)
@@ -1038,7 +1108,7 @@
- 🧩 **DocPreview** (`Component`) - 🧩 **DocPreview** (`Component`)
- 📝 UI component for previewing generated dataset documentation before saving. - 📝 UI component for previewing generated dataset documentation before saving.
- 🏗️ Layer: UI - 🏗️ Layer: UI
- 📥 Props: documentation: any, onSave: any, onCancel: any - ➡️ WRITES_TO `props`
- ➡️ WRITES_TO `t` - ➡️ WRITES_TO `t`
- ⬅️ READS_FROM `t` - ⬅️ READS_FROM `t`
- 📦 **DocPreview** (`Module`) `[TRIVIAL]` - 📦 **DocPreview** (`Module`) `[TRIVIAL]`
@@ -1049,7 +1119,7 @@
- 🧩 **ProviderConfig** (`Component`) - 🧩 **ProviderConfig** (`Component`)
- 📝 UI form for managing LLM provider configurations. - 📝 UI form for managing LLM provider configurations.
- 🏗️ Layer: UI - 🏗️ Layer: UI
- 📥 Props: providers: any, onSave: any - ➡️ WRITES_TO `props`
- ➡️ WRITES_TO `t` - ➡️ WRITES_TO `t`
- ⬅️ READS_FROM `t` - ⬅️ READS_FROM `t`
- 📦 **ProviderConfig** (`Module`) `[TRIVIAL]` - 📦 **ProviderConfig** (`Module`) `[TRIVIAL]`
@@ -1099,14 +1169,14 @@
- 📝 Provides a WebSocket endpoint for real-time log streaming of a task with server-side filtering. - 📝 Provides a WebSocket endpoint for real-time log streaming of a task with server-side filtering.
- 📦 **StaticFiles** (`Mount`) - 📦 **StaticFiles** (`Mount`)
- 📝 Mounts the frontend build directory to serve static assets. - 📝 Mounts the frontend build directory to serve static assets.
- ƒ **serve_spa** (`Function`)
- 📝 Serves the SPA frontend for any path not matched by API routes.
- ƒ **read_root** (`Function`) - ƒ **read_root** (`Function`)
- 📝 A simple root endpoint to confirm that the API is running when frontend is missing. - 📝 A simple root endpoint to confirm that the API is running when frontend is missing.
- ƒ **network_error_handler** (`Function`) `[TRIVIAL]` - ƒ **network_error_handler** (`Function`) `[TRIVIAL]`
- 📝 Auto-detected function (orphan) - 📝 Auto-detected function (orphan)
- ƒ **matches_filters** (`Function`) `[TRIVIAL]` - ƒ **matches_filters** (`Function`) `[TRIVIAL]`
- 📝 Auto-detected function (orphan) - 📝 Auto-detected function (orphan)
- ƒ **serve_spa** (`Function`) `[TRIVIAL]`
- 📝 Auto-detected function (orphan)
- 📦 **Dependencies** (`Module`) - 📦 **Dependencies** (`Module`)
- 📝 Manages creation and provision of shared application dependencies, such as PluginLoader and TaskManager, to avoid circular imports. - 📝 Manages creation and provision of shared application dependencies, such as PluginLoader and TaskManager, to avoid circular imports.
- 🏗️ Layer: Core - 🏗️ Layer: Core

View File

@@ -1,7 +1,7 @@
# [DEF:Project_Knowledge_Map:Root] # [DEF:Project_Knowledge_Map:Root]
# @TIER: CRITICAL # @TIER: CRITICAL
# @PURPOSE: Global navigation map for AI-Agent (GRACE Knowledge Graph). # @PURPOSE: Global navigation map for AI-Agent (GRACE Knowledge Graph).
# @LAST_UPDATE: 2026-02-19 # @LAST_UPDATE: 2026-02-20
## 1. SYSTEM STANDARDS (Rules of the Game) ## 1. SYSTEM STANDARDS (Rules of the Game)
Strict policies and formatting rules. Strict policies and formatting rules.
@@ -26,8 +26,11 @@ Use these for code generation (Style Transfer).
* Ref: `.ai/shots/frontend_component.svelte` -> `[DEF:Shot:Svelte_Component]` * Ref: `.ai/shots/frontend_component.svelte` -> `[DEF:Shot:Svelte_Component]`
* **Plugin Module:** Reference implementation of a task plugin. * **Plugin Module:** Reference implementation of a task plugin.
* Ref: `.ai/shots/plugin_example.py` -> `[DEF:Shot:Plugin_Example]` * Ref: `.ai/shots/plugin_example.py` -> `[DEF:Shot:Plugin_Example]`
* **Critical Module:** Core banking transaction processor with ACID guarantees.
* Ref: `.ai/shots/critical_module.py` -> `[DEF:Shot:Critical_Module]`
## 3. DOMAIN MAP (Modules) ## 3. DOMAIN MAP (Modules)
* **Module Map:** `.ai/MODULE_MAP.md` -> `[DEF:Module_Map]`
* **Project Map:** `.ai/PROJECT_MAP.md` -> `[DEF:Project_Map]` * **Project Map:** `.ai/PROJECT_MAP.md` -> `[DEF:Project_Map]`
* **Backend Core:** `backend/src/core` -> `[DEF:Module:Backend_Core]` * **Backend Core:** `backend/src/core` -> `[DEF:Module:Backend_Core]`
* **Backend API:** `backend/src/api` -> `[DEF:Module:Backend_API]` * **Backend API:** `backend/src/api` -> `[DEF:Module:Backend_API]`

View File

@@ -1,14 +1,18 @@
# [DEF:Shot:FastAPI_Route:Example] # [DEF:BackendRouteShot:Module]
# @TIER: STANDARD
# @SEMANTICS: Route, Task, API, Async
# @PURPOSE: Reference implementation of a task-based route using GRACE-Poly. # @PURPOSE: Reference implementation of a task-based route using GRACE-Poly.
# @LAYER: Interface (API)
# @RELATION: IMPLEMENTS -> [DEF:Std:API_FastAPI] # @RELATION: IMPLEMENTS -> [DEF:Std:API_FastAPI]
# @INVARIANT: TaskManager must be available in dependency graph.
from typing import List, Dict, Any, Optional from typing import Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel from pydantic import BaseModel
from ...core.logger import belief_scope from ...core.logger import belief_scope
from ...core.task_manager import TaskManager, Task from ...core.task_manager import TaskManager, Task
from ...core.config_manager import ConfigManager from ...core.config_manager import ConfigManager
from ...dependencies import get_task_manager, get_config_manager, has_permission, get_current_user from ...dependencies import get_task_manager, get_config_manager, get_current_user
router = APIRouter() router = APIRouter()
@@ -21,37 +25,41 @@ class CreateTaskRequest(BaseModel):
# @PURPOSE: Create and start a new task using TaskManager. Non-blocking. # @PURPOSE: Create and start a new task using TaskManager. Non-blocking.
# @PARAM: request (CreateTaskRequest) - Plugin and params. # @PARAM: request (CreateTaskRequest) - Plugin and params.
# @PARAM: task_manager (TaskManager) - Async task executor. # @PARAM: task_manager (TaskManager) - Async task executor.
# @PARAM: config (ConfigManager) - Centralized configuration. # @PRE: plugin_id must match a registered plugin.
# @PRE: plugin_id must exist; config must be initialized.
# @POST: A new task is spawned; Task ID returned immediately. # @POST: A new task is spawned; Task ID returned immediately.
# @SIDE_EFFECT: Writes to DB, Trigger background worker.
async def create_task( async def create_task(
request: CreateTaskRequest, request: CreateTaskRequest,
task_manager: TaskManager = Depends(get_task_manager), task_manager: TaskManager = Depends(get_task_manager),
config: ConfigManager = Depends(get_config_manager), config: ConfigManager = Depends(get_config_manager),
current_user = Depends(get_current_user) current_user = Depends(get_current_user)
): ):
# RBAC: Dynamic permission check # Context Logging
has_permission(f"plugin:{request.plugin_id}", "EXECUTE")(current_user)
with belief_scope("create_task"): with belief_scope("create_task"):
try: try:
# 1. Action: Resolve setting using ConfigManager (Example) # 1. Action: Configuration Resolution
timeout = config.get("TASKS_DEFAULT_TIMEOUT", 3600) timeout = config.get("TASKS_DEFAULT_TIMEOUT", 3600)
# 2. Action: Spawn async task via TaskManager # 2. Action: Spawn async task
# @RELATION: CALLS -> task_manager.create_task # @RELATION: CALLS -> task_manager.create_task
task = await task_manager.create_task( task = await task_manager.create_task(
plugin_id=request.plugin_id, plugin_id=request.plugin_id,
params={**request.params, "timeout": timeout} params={**request.params, "timeout": timeout}
) )
return task return task
except ValueError as e:
# 3. Recovery: Domain logic error mapping
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e: except Exception as e:
# Evaluation: Proper error mapping and logging # @UX_STATE: Error feedback -> 500 Internal Error
# @UX_STATE: Error feedback to frontend
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Task creation failed: {str(e)}" detail="Internal Task Spawning Error"
) )
# [/DEF:create_task:Function] # [/DEF:create_task:Function]
# [/DEF:Shot:FastAPI_Route] # [/DEF:BackendRouteShot:Module]

View File

@@ -0,0 +1,79 @@
# [DEF:TransactionCore:Module]
# @TIER: CRITICAL
# @SEMANTICS: Finance, ACID, Transfer, Ledger
# @PURPOSE: Core banking transaction processor with ACID guarantees.
# @LAYER: Domain (Core)
# @RELATION: DEPENDS_ON -> [DEF:Infra:PostgresDB]
# @RELATION: DEPENDS_ON -> [DEF:Infra:AuditLog]
# @INVARIANT: Total system balance must remain constant (Double-Entry Bookkeeping).
# @INVARIANT: Negative transfers are strictly forbidden.
# @TEST_DATA: sufficient_funds -> {"from": "acc_A", "to": "acc_B", "amt": 100.00}
# @TEST_DATA: insufficient_funds -> {"from": "acc_empty", "to": "acc_B", "amt": 1000.00}
# @TEST_DATA: concurrency_lock -> {./fixtures/transactions.json#race_condition}
from decimal import Decimal
from typing import NamedTuple
from ...core.logger import belief_scope
from ...core.db import atomic_transaction, get_balance, update_balance
from ...core.exceptions import BusinessRuleViolation
class TransferResult(NamedTuple):
tx_id: str
status: str
new_balance: Decimal
# [DEF:execute_transfer:Function]
# @PURPOSE: Atomically move funds between accounts with audit trails.
# @PARAM: sender_id (str) - Source account.
# @PARAM: receiver_id (str) - Destination account.
# @PARAM: amount (Decimal) - Positive amount to transfer.
# @PRE: amount > 0; sender != receiver; sender_balance >= amount.
# @POST: sender_balance -= amount; receiver_balance += amount; Audit Record Created.
# @SIDE_EFFECT: Database mutation (Rows locked), Audit IO.
#
# @UX_STATE: Success -> Returns 200 OK + Transaction Receipt.
# @UX_STATE: Error(LowBalance) -> 422 Unprocessable -> UI shows "Top-up needed" modal.
# @UX_STATE: Error(System) -> 500 Internal -> UI shows "Retry later" toast.
def execute_transfer(sender_id: str, receiver_id: str, amount: Decimal) -> TransferResult:
# Guard: Input Validation
if amount <= Decimal("0.00"):
raise BusinessRuleViolation("Transfer amount must be positive.")
if sender_id == receiver_id:
raise BusinessRuleViolation("Cannot transfer to self.")
with belief_scope("execute_transfer") as context:
context.logger.info("Initiating transfer", data={"from": sender_id, "to": receiver_id})
try:
# 1. Action: Atomic DB Transaction
# @RELATION: CALLS -> atomic_transaction
with atomic_transaction():
# Guard: State Validation (Strict)
current_balance = get_balance(sender_id, for_update=True)
if current_balance < amount:
# @UX_FEEDBACK: Triggers specific UI flow for insufficient funds
context.logger.warn("Insufficient funds", data={"balance": current_balance})
raise BusinessRuleViolation("INSUFFICIENT_FUNDS")
# 2. Action: Mutation
new_src_bal = update_balance(sender_id, -amount)
new_dst_bal = update_balance(receiver_id, +amount)
# 3. Action: Audit
tx_id = context.audit.log_transfer(sender_id, receiver_id, amount)
context.logger.info("Transfer committed", data={"tx_id": tx_id})
return TransferResult(tx_id, "COMPLETED", new_src_bal)
except BusinessRuleViolation as e:
# Logic: Explicit re-raise for UI mapping
raise e
except Exception as e:
# Logic: Catch-all safety net
context.logger.error("Critical Transfer Failure", error=e)
raise RuntimeError("TRANSACTION_ABORTED") from e
# [/DEF:execute_transfer:Function]
# [/DEF:TransactionCore:Module]

View File

@@ -1,19 +1,23 @@
<!-- [DEF:Shot:Svelte_Component:Example] --> <!-- [DEF:FrontendComponentShot:Component] -->
# @PURPOSE: Reference implementation of a task-spawning component using Constitution rules.
# @RELATION: IMPLEMENTS -> [DEF:Std:UI_Svelte]
<script> <script>
/** /**
* @TIER: STANDARD * @TIER: CRITICAL
* @PURPOSE: Action button to spawn a new task. * @SEMANTICS: Task, Button, Action, UX
* @LAYER: UI * @PURPOSE: Action button to spawn a new task with full UX feedback cycle.
* @SEMANTICS: Task, Creation, Button * @LAYER: UI (Presentation)
* @RELATION: CALLS -> postApi * @RELATION: CALLS -> postApi
* @INVARIANT: Must prevent double-submission while loading.
* *
* @UX_STATE: Idle -> Button enabled with primary color. * @TEST_DATA: idle_state -> {"isLoading": false}
* @UX_STATE: Loading -> Button disabled with spinner while postApi resolves. * @TEST_DATA: loading_state -> {"isLoading": true}
* @UX_FEEDBACK: toast.success on completion; toast.error on failure. *
* @UX_TEST: Idle -> {click: spawnTask, expected: loading state then success} * @UX_STATE: Idle -> Button enabled, primary color.
* @UX_STATE: Loading -> Button disabled, spinner visible.
* @UX_STATE: Error -> Toast notification triggers.
*
* @UX_FEEDBACK: Toast success/error.
* @UX_TEST: Idle -> {click: spawnTask, expected: isLoading=true}
* @UX_TEST: Success -> {api_resolve: 200, expected: toast.success called}
*/ */
import { postApi } from "$lib/api.js"; import { postApi } from "$lib/api.js";
import { t } from "$lib/i18n"; import { t } from "$lib/i18n";
@@ -24,40 +28,43 @@
let isLoading = false; let isLoading = false;
async def spawnTask() { // [DEF:spawnTask:Function]
async function spawnTask() {
isLoading = true; isLoading = true;
console.log("[FrontendComponentShot][Loading] Spawning task...");
try { try {
// 1. Action: Constitution Rule - MUST use postApi wrapper // 1. Action: API Call
const response = await postApi("/api/tasks", { const response = await postApi("/api/tasks", {
plugin_id, plugin_id,
params params
}); });
// 2. Feedback: UX state management // 2. Feedback: Success
if (response.task_id) { if (response.task_id) {
console.log("[FrontendComponentShot][Success] Task created.");
toast.success($t.tasks.spawned_success); toast.success($t.tasks.spawned_success);
} }
} catch (error) { } catch (error) {
// 3. Recovery: Evaluation & UI reporting // 3. Recovery: User notification
console.log("[FrontendComponentShot][Error] Failed:", error);
toast.error(`${$t.errors.task_failed}: ${error.message}`); toast.error(`${$t.errors.task_failed}: ${error.message}`);
} finally { } finally {
isLoading = false; isLoading = false;
} }
} }
// [/DEF:spawnTask:Function]
</script> </script>
<button <button
on:click={spawnTask} on:click={spawnTask}
disabled={isLoading} disabled={isLoading}
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2" class="btn-primary flex items-center gap-2"
aria-busy={isLoading}
> >
{#if isLoading} {#if isLoading}
<span class="animate-spin text-sm">🌀</span> <span class="animate-spin" aria-label="Loading">🌀</span>
{/if} {/if}
<span>{$t.actions.start_task}</span> <span>{$t.actions.start_task}</span>
</button> </button>
<!-- [/DEF:FrontendComponentShot:Component] -->
<style>
/* Local styles minimized as per Constitution Rule III */
</style>
<!-- [/DEF:Shot:Svelte_Component] -->

View File

@@ -1,6 +1,10 @@
# [DEF:Shot:Plugin_Example:Example] # [DEF:PluginExampleShot:Module]
# @TIER: STANDARD
# @SEMANTICS: Plugin, Core, Extension
# @PURPOSE: Reference implementation of a plugin following GRACE standards. # @PURPOSE: Reference implementation of a plugin following GRACE standards.
# @RELATION: IMPLEMENTS -> [DEF:Std:Plugin] # @LAYER: Domain (Business Logic)
# @RELATION: INHERITS -> PluginBase
# @INVARIANT: get_schema must return valid JSON Schema.
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
from ..core.plugin_base import PluginBase from ..core.plugin_base import PluginBase
@@ -11,28 +15,15 @@ class ExamplePlugin(PluginBase):
def id(self) -> str: def id(self) -> str:
return "example-plugin" return "example-plugin"
@property
def name(self) -> str:
return "Example Plugin"
@property
def description(self) -> str:
return "A simple plugin that demonstrates structured logging and progress tracking."
@property
def version(self) -> str:
return "1.0.0"
# [DEF:get_schema:Function] # [DEF:get_schema:Function]
# @PURPOSE: Defines input validation schema. # @PURPOSE: Defines input validation schema.
# @POST: Returns dict compliant with JSON Schema draft 7.
def get_schema(self) -> Dict[str, Any]: def get_schema(self) -> Dict[str, Any]:
return { return {
"type": "object", "type": "object",
"properties": { "properties": {
"message": { "message": {
"type": "string", "type": "string",
"title": "Message",
"description": "A message to log.",
"default": "Hello, GRACE!", "default": "Hello, GRACE!",
} }
}, },
@@ -41,27 +32,33 @@ class ExamplePlugin(PluginBase):
# [/DEF:get_schema:Function] # [/DEF:get_schema:Function]
# [DEF:execute:Function] # [DEF:execute:Function]
# @PURPOSE: Core plugin logic with structured logging and progress reporting. # @PURPOSE: Core plugin logic with structured logging and scope isolation.
# @PARAM: params (Dict) - Validated input parameters. # @PARAM: params (Dict) - Validated input parameters.
# @PARAM: context (TaskContext) - Execution context with logging and progress tools. # @PARAM: context (TaskContext) - Execution tools (log, progress).
async def execute(self, params: Dict[str, Any], context: Optional[TaskContext] = None): # @SIDE_EFFECT: Emits logs to centralized system.
message = params["message"] async def execute(self, params: Dict, context: Optional = None):
message = params
# 1. Action: Structured Logging with Source Attribution # 1. Action: System-level tracing (Rule VI)
if context: with belief_scope("example_plugin_exec") as b_scope:
log = context.logger.with_source("example_plugin") if context:
log.info(f"Starting execution with message: {message}") # Task Logs: Пишем в пользовательский контекст выполнения задачи
# @RELATION: BINDS_TO -> context.logger
# 2. Action: Progress Reporting log = context.logger.with_source("example_plugin")
log.progress("Processing step 1...", percent=25)
# Simulating some async work... b_scope.logger.info("Using provided TaskContext") # System log
# await some_async_op() log.info("Starting execution", data={"msg": message}) # Task log
log.progress("Processing step 2...", percent=75) # 2. Action: Progress Reporting
log.info("Execution completed successfully.") log.progress("Processing...", percent=50)
else:
# Fallback for manual/standalone execution # 3. Action: Finalize
print(f"Standalone execution: {message}") log.info("Execution completed.")
else:
# Standalone Fallback: Замыкаемся на системный scope
b_scope.logger.warning("No TaskContext provided. Running standalone.")
b_scope.logger.info("Standalone execution", data={"msg": message})
print(f"Standalone: {message}")
# [/DEF:execute:Function] # [/DEF:execute:Function]
# [/DEF:Shot:Plugin_Example] # [/DEF:PluginExampleShot:Module]

27
.dockerignore Normal file
View File

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

36
Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
# Stage 1: Build frontend static assets
FROM node:20-alpine AS frontend-build
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# Stage 2: Runtime image for backend + static frontend
FROM python:3.11-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV BACKEND_PORT=8000
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt /app/backend/requirements.txt
RUN pip install --no-cache-dir -r /app/backend/requirements.txt
COPY backend/ /app/backend/
COPY --from=frontend-build /app/frontend/build /app/frontend/build
WORKDIR /app/backend
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "src.app:app", "--host", "0.0.0.0", "--port", "8000"]

View File

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

Binary file not shown.

View File

@@ -241,6 +241,10 @@ frontend_path = project_root / "frontend" / "build"
if frontend_path.exists(): if frontend_path.exists():
app.mount("/_app", StaticFiles(directory=str(frontend_path / "_app")), name="static") app.mount("/_app", StaticFiles(directory=str(frontend_path / "_app")), name="static")
# [DEF:serve_spa:Function]
# @PURPOSE: Serves the SPA frontend for any path not matched by API routes.
# @PRE: frontend_path exists.
# @POST: Returns the requested file or index.html.
@app.get("/{file_path:path}", include_in_schema=False) @app.get("/{file_path:path}", include_in_schema=False)
async def serve_spa(file_path: str): async def serve_spa(file_path: str):
# Only serve SPA for non-API paths # Only serve SPA for non-API paths

View File

@@ -24,7 +24,10 @@ class AuthConfig(BaseSettings):
REFRESH_TOKEN_EXPIRE_DAYS: int = 7 REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# Database Settings # Database Settings
AUTH_DATABASE_URL: str = Field(default="sqlite:///./backend/auth.db", env="AUTH_DATABASE_URL") AUTH_DATABASE_URL: str = Field(
default="postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools",
env="AUTH_DATABASE_URL",
)
# ADFS Settings # ADFS Settings
ADFS_CLIENT_ID: str = Field(default="", env="ADFS_CLIENT_ID") ADFS_CLIENT_ID: str = Field(default="", env="ADFS_CLIENT_ID")
@@ -41,4 +44,4 @@ class AuthConfig(BaseSettings):
auth_config = AuthConfig() auth_config = AuthConfig()
# [/DEF:auth_config:Variable] # [/DEF:auth_config:Variable]
# [/DEF:backend.src.core.auth.config:Module] # [/DEF:backend.src.core.auth.config:Module]

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

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

View File

@@ -3,7 +3,7 @@
# @SEMANTICS: config, models, pydantic # @SEMANTICS: config, models, pydantic
# @PURPOSE: Defines the data models for application configuration using Pydantic. # @PURPOSE: Defines the data models for application configuration using Pydantic.
# @LAYER: Core # @LAYER: Core
# @RELATION: READS_FROM -> config.json # @RELATION: READS_FROM -> app_configurations (database)
# @RELATION: USED_BY -> ConfigManager # @RELATION: USED_BY -> ConfigManager
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -33,10 +33,10 @@ class Environment(BaseModel):
# [DEF:LoggingConfig:DataClass] # [DEF:LoggingConfig:DataClass]
# @PURPOSE: Defines the configuration for the application's logging system. # @PURPOSE: Defines the configuration for the application's logging system.
class LoggingConfig(BaseModel): class LoggingConfig(BaseModel):
level: str = "INFO" level: str = "INFO"
task_log_level: str = "INFO" # Minimum level for task-specific logs (DEBUG, INFO, WARNING, ERROR) task_log_level: str = "INFO" # Minimum level for task-specific logs (DEBUG, INFO, WARNING, ERROR)
file_path: Optional[str] = "logs/app.log" file_path: Optional[str] = None
max_bytes: int = 10 * 1024 * 1024 max_bytes: int = 10 * 1024 * 1024
backup_count: int = 5 backup_count: int = 5
enable_belief_state: bool = True enable_belief_state: bool = True

View File

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

View File

@@ -20,14 +20,14 @@ from .core.auth.jwt import decode_token
from .core.auth.repository import AuthRepository from .core.auth.repository import AuthRepository
from .models.auth import User from .models.auth import User
# Initialize singletons # Initialize singletons
# Use absolute path relative to this file to ensure plugins are found regardless of CWD # Use absolute path relative to this file to ensure plugins are found regardless of CWD
project_root = Path(__file__).parent.parent.parent project_root = Path(__file__).parent.parent.parent
config_path = project_root / "config.json" config_path = project_root / "config.json"
config_manager = ConfigManager(config_path=str(config_path))
# Initialize database before services that use persisted configuration.
# Initialize database before any other services that might use it init_db()
init_db() config_manager = ConfigManager(config_path=str(config_path))
# [DEF:get_config_manager:Function] # [DEF:get_config_manager:Function]
# @PURPOSE: Dependency injector for ConfigManager. # @PURPOSE: Dependency injector for ConfigManager.

View File

@@ -0,0 +1,26 @@
# [DEF:backend.src.models.config:Module]
#
# @TIER: STANDARD
# @SEMANTICS: database, config, settings, sqlalchemy
# @PURPOSE: Defines database schema for persisted application configuration.
# @LAYER: Domain
# @RELATION: DEPENDS_ON -> sqlalchemy
from sqlalchemy import Column, String, DateTime, JSON
from sqlalchemy.sql import func
from .mapping import Base
# [DEF:AppConfigRecord:Class]
# @PURPOSE: Stores the single source of truth for application configuration.
class AppConfigRecord(Base):
__tablename__ = "app_configurations"
id = Column(String, primary_key=True)
payload = Column(JSON, nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
# [/DEF:AppConfigRecord:Class]
# [/DEF:backend.src.models.config:Module]

View File

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

View File

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

View File

@@ -18,3 +18,4 @@ def __getattr__(name):
from .resource_service import ResourceService from .resource_service import ResourceService
return ResourceService return ResourceService
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
# [/DEF:backend.src.services:Module]

Binary file not shown.

42
docker-compose.yml Normal file
View File

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

View File

@@ -13,8 +13,8 @@
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
let passwords = {}; let passwords = $state({});
let submitting = false; let submitting = $state(false);
// [DEF:handleSubmit:Function] // [DEF:handleSubmit:Function]
// @PURPOSE: Validates and dispatches the passwords to resume the task. // @PURPOSE: Validates and dispatches the passwords to resume the task.
@@ -69,7 +69,7 @@
<div <div
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
aria-hidden="true" aria-hidden="true"
on:click={handleCancel} onclick={handleCancel}
></div> ></div>
<span <span
@@ -126,7 +126,7 @@
{/if} {/if}
<form <form
on:submit|preventDefault={handleSubmit} onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}
class="space-y-4" class="space-y-4"
> >
{#each databases as dbName} {#each databases as dbName}
@@ -158,7 +158,7 @@
<button <button
type="button" type="button"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
on:click={handleSubmit} onclick={handleSubmit}
disabled={submitting} disabled={submitting}
> >
{submitting ? "Resuming..." : "Resume Migration"} {submitting ? "Resuming..." : "Resume Migration"}
@@ -166,7 +166,7 @@
<button <button
type="button" type="button"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
on:click={handleCancel} onclick={handleCancel}
disabled={submitting} disabled={submitting}
> >
Cancel Cancel

View File

@@ -123,7 +123,7 @@
<span>{error}</span> <span>{error}</span>
<button <button
class="bg-terminal-surface text-terminal-text-subtle border border-terminal-border rounded-md px-3 py-1 text-xs cursor-pointer transition-all hover:bg-terminal-border hover:text-terminal-text-bright" class="bg-terminal-surface text-terminal-text-subtle border border-terminal-border rounded-md px-3 py-1 text-xs cursor-pointer transition-all hover:bg-terminal-border hover:text-terminal-text-bright"
on:click={handleRefresh}>Retry</button onclick={handleRefresh}>Retry</button
> >
</div> </div>
{:else} {:else}
@@ -149,11 +149,11 @@
<div <div
class="fixed inset-0 bg-gray-500/75 transition-opacity" class="fixed inset-0 bg-gray-500/75 transition-opacity"
aria-hidden="true" aria-hidden="true"
on:click={() => { onclick={() => {
show = false; show = false;
dispatch("close"); dispatch("close");
}} }}
on:keydown={(e) => e.key === "Escape" && (show = false)} onkeydown={(e) => e.key === "Escape" && (show = false)}
role="presentation" role="presentation"
></div> ></div>
@@ -170,7 +170,7 @@
</h3> </h3>
<button <button
class="text-gray-500 hover:text-gray-300" class="text-gray-500 hover:text-gray-300"
on:click={() => { onclick={() => {
show = false; show = false;
dispatch("close"); dispatch("close");
}} }}

View File

@@ -7,67 +7,74 @@
--> -->
<script> <script>
import { onMount } from 'svelte'; import { onMount } from "svelte";
import { t } from '../../lib/i18n'; import { t } from "../../lib/i18n";
import { requestApi } from '../../lib/api'; import { requestApi } from "../../lib/api";
/** @type {Array} */ /** @type {Array} */
let { let { providers = [], onSave = () => {} } = $props();
provider,
config = {},
} = $props();
let editingProvider = null; let editingProvider = null;
let showForm = false; let showForm = false;
let formData = { let formData = {
name: '', name: "",
provider_type: 'openai', provider_type: "openai",
base_url: 'https://api.openai.com/v1', base_url: "https://api.openai.com/v1",
api_key: '', api_key: "",
default_model: 'gpt-4o', default_model: "gpt-4o",
is_active: true is_active: true,
}; };
let testStatus = { type: '', message: '' }; let testStatus = { type: "", message: "" };
let isTesting = false; let isTesting = false;
function resetForm() { function resetForm() {
formData = { formData = {
name: '', name: "",
provider_type: 'openai', provider_type: "openai",
base_url: 'https://api.openai.com/v1', base_url: "https://api.openai.com/v1",
api_key: '', api_key: "",
default_model: 'gpt-4o', default_model: "gpt-4o",
is_active: true is_active: true,
}; };
editingProvider = null; editingProvider = null;
testStatus = { type: '', message: '' }; testStatus = { type: "", message: "" };
} }
function handleEdit(provider) { function handleEdit(provider) {
editingProvider = provider; editingProvider = provider;
formData = { ...provider, api_key: '' }; // Don't populate key for security formData = { ...provider, api_key: "" }; // Don't populate key for security
showForm = true; showForm = true;
} }
async function testConnection() { async function testConnection() {
console.log("[ProviderConfig][Action] Testing connection", formData); console.log("[ProviderConfig][Action] Testing connection", formData);
isTesting = true; isTesting = true;
testStatus = { type: 'info', message: $t.llm.testing }; testStatus = { type: "info", message: $t.llm.testing };
try { try {
const endpoint = editingProvider ? `/llm/providers/${editingProvider.id}/test` : '/llm/providers/test'; const endpoint = editingProvider
const result = await requestApi(endpoint, 'POST', formData); ? `/llm/providers/${editingProvider.id}/test`
: "/llm/providers/test";
const result = await requestApi(endpoint, "POST", formData);
if (result.success) { if (result.success) {
testStatus = { type: 'success', message: $t.llm.connection_success }; testStatus = { type: "success", message: $t.llm.connection_success };
} else { } else {
testStatus = { type: 'error', message: $t.llm.connection_failed.replace('{error}', result.error || 'Unknown error') }; testStatus = {
type: "error",
message: $t.llm.connection_failed.replace(
"{error}",
result.error || "Unknown error",
),
};
} }
} catch (err) { } catch (err) {
testStatus = { type: 'error', message: $t.llm.connection_failed.replace('{error}', err.message) }; testStatus = {
type: "error",
message: $t.llm.connection_failed.replace("{error}", err.message),
};
} finally { } finally {
isTesting = false; isTesting = false;
} }
@@ -75,8 +82,10 @@
async function handleSubmit() { async function handleSubmit() {
console.log("[ProviderConfig][Action] Submitting provider config"); console.log("[ProviderConfig][Action] Submitting provider config");
const method = editingProvider ? 'PUT' : 'POST'; const method = editingProvider ? "PUT" : "POST";
const endpoint = editingProvider ? `/llm/providers/${editingProvider.id}` : '/llm/providers'; const endpoint = editingProvider
? `/llm/providers/${editingProvider.id}`
: "/llm/providers";
// When editing, only include api_key if user entered a new one // When editing, only include api_key if user entered a new one
const submitData = { ...formData }; const submitData = { ...formData };
@@ -97,9 +106,9 @@
async function toggleActive(provider) { async function toggleActive(provider) {
try { try {
await requestApi(`/llm/providers/${provider.id}`, 'PUT', { await requestApi(`/llm/providers/${provider.id}`, "PUT", {
...provider, ...provider,
is_active: !provider.is_active is_active: !provider.is_active,
}); });
onSave(); onSave();
} catch (err) { } catch (err) {
@@ -111,28 +120,53 @@
<div class="p-4"> <div class="p-4">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold">{$t.llm.providers_title}</h2> <h2 class="text-xl font-bold">{$t.llm.providers_title}</h2>
<button <button
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition"
on:click={() => { resetForm(); showForm = true; }} on:click={() => {
resetForm();
showForm = true;
}}
> >
{$t.llm.add_provider} {$t.llm.add_provider}
</button> </button>
</div> </div>
{#if showForm} {#if showForm}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
>
<div class="bg-white p-6 rounded-lg shadow-xl w-full max-w-md"> <div class="bg-white p-6 rounded-lg shadow-xl w-full max-w-md">
<h3 class="text-lg font-semibold mb-4">{editingProvider ? $t.llm.edit_provider : $t.llm.new_provider}</h3> <h3 class="text-lg font-semibold mb-4">
{editingProvider ? $t.llm.edit_provider : $t.llm.new_provider}
</h3>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label for="provider-name" class="block text-sm font-medium text-gray-700">{$t.llm.name}</label> <label
<input id="provider-name" type="text" bind:value={formData.name} class="mt-1 block w-full border rounded-md p-2" placeholder="e.g. My OpenAI" /> for="provider-name"
class="block text-sm font-medium text-gray-700"
>{$t.llm.name}</label
>
<input
id="provider-name"
type="text"
bind:value={formData.name}
class="mt-1 block w-full border rounded-md p-2"
placeholder="e.g. My OpenAI"
/>
</div> </div>
<div> <div>
<label for="provider-type" class="block text-sm font-medium text-gray-700">{$t.llm.type}</label> <label
<select id="provider-type" bind:value={formData.provider_type} class="mt-1 block w-full border rounded-md p-2"> for="provider-type"
class="block text-sm font-medium text-gray-700"
>{$t.llm.type}</label
>
<select
id="provider-type"
bind:value={formData.provider_type}
class="mt-1 block w-full border rounded-md p-2"
>
<option value="openai">OpenAI</option> <option value="openai">OpenAI</option>
<option value="openrouter">OpenRouter</option> <option value="openrouter">OpenRouter</option>
<option value="kilo">Kilo</option> <option value="kilo">Kilo</option>
@@ -140,47 +174,88 @@
</div> </div>
<div> <div>
<label for="provider-base-url" class="block text-sm font-medium text-gray-700">{$t.llm.base_url}</label> <label
<input id="provider-base-url" type="text" bind:value={formData.base_url} class="mt-1 block w-full border rounded-md p-2" /> for="provider-base-url"
class="block text-sm font-medium text-gray-700"
>{$t.llm.base_url}</label
>
<input
id="provider-base-url"
type="text"
bind:value={formData.base_url}
class="mt-1 block w-full border rounded-md p-2"
/>
</div> </div>
<div> <div>
<label for="provider-api-key" class="block text-sm font-medium text-gray-700">{$t.llm.api_key}</label> <label
<input id="provider-api-key" type="password" bind:value={formData.api_key} class="mt-1 block w-full border rounded-md p-2" placeholder={editingProvider ? "••••••••" : "sk-..."} /> for="provider-api-key"
class="block text-sm font-medium text-gray-700"
>{$t.llm.api_key}</label
>
<input
id="provider-api-key"
type="password"
bind:value={formData.api_key}
class="mt-1 block w-full border rounded-md p-2"
placeholder={editingProvider ? "••••••••" : "sk-..."}
/>
</div> </div>
<div> <div>
<label for="provider-default-model" class="block text-sm font-medium text-gray-700">{$t.llm.default_model}</label> <label
<input id="provider-default-model" type="text" bind:value={formData.default_model} class="mt-1 block w-full border rounded-md p-2" placeholder="gpt-4o" /> for="provider-default-model"
class="block text-sm font-medium text-gray-700"
>{$t.llm.default_model}</label
>
<input
id="provider-default-model"
type="text"
bind:value={formData.default_model}
class="mt-1 block w-full border rounded-md p-2"
placeholder="gpt-4o"
/>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<input id="provider-active" type="checkbox" bind:checked={formData.is_active} class="mr-2" /> <input
<label for="provider-active" class="text-sm font-medium text-gray-700">{$t.llm.active}</label> id="provider-active"
type="checkbox"
bind:checked={formData.is_active}
class="mr-2"
/>
<label
for="provider-active"
class="text-sm font-medium text-gray-700">{$t.llm.active}</label
>
</div> </div>
</div> </div>
{#if testStatus.message} {#if testStatus.message}
<div class={`mt-4 p-2 rounded text-sm ${testStatus.type === 'success' ? 'bg-green-100 text-green-800' : testStatus.type === 'error' ? 'bg-red-100 text-red-800' : 'bg-blue-100 text-blue-800'}`}> <div
class={`mt-4 p-2 rounded text-sm ${testStatus.type === "success" ? "bg-green-100 text-green-800" : testStatus.type === "error" ? "bg-red-100 text-red-800" : "bg-blue-100 text-blue-800"}`}
>
{testStatus.message} {testStatus.message}
</div> </div>
{/if} {/if}
<div class="mt-6 flex justify-between gap-2"> <div class="mt-6 flex justify-between gap-2">
<button <button
class="px-4 py-2 border rounded hover:bg-gray-50 flex-1" class="px-4 py-2 border rounded hover:bg-gray-50 flex-1"
on:click={() => { showForm = false; }} on:click={() => {
showForm = false;
}}
> >
{$t.llm.cancel} {$t.llm.cancel}
</button> </button>
<button <button
class="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 flex-1" class="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 flex-1"
disabled={isTesting} disabled={isTesting}
on:click={testConnection} on:click={testConnection}
> >
{isTesting ? $t.llm.testing : $t.llm.test} {isTesting ? $t.llm.testing : $t.llm.test}
</button> </button>
<button <button
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 flex-1" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 flex-1"
on:click={handleSubmit} on:click={handleSubmit}
> >
@@ -193,37 +268,45 @@
<div class="grid gap-4"> <div class="grid gap-4">
{#each providers as provider} {#each providers as provider}
<div class="border rounded-lg p-4 flex justify-between items-center bg-white shadow-sm"> <div
class="border rounded-lg p-4 flex justify-between items-center bg-white shadow-sm"
>
<div> <div>
<div class="font-bold flex items-center gap-2"> <div class="font-bold flex items-center gap-2">
{provider.name} {provider.name}
<span class={`text-xs px-2 py-0.5 rounded-full ${provider.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}> <span
{provider.is_active ? $t.llm.active : 'Inactive'} class={`text-xs px-2 py-0.5 rounded-full ${provider.is_active ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"}`}
>
{provider.is_active ? $t.llm.active : "Inactive"}
</span> </span>
</div> </div>
<div class="text-sm text-gray-500">{provider.provider_type}{provider.default_model}</div> <div class="text-sm text-gray-500">
{provider.provider_type}{provider.default_model}
</div>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
class="text-sm text-blue-600 hover:underline" class="text-sm text-blue-600 hover:underline"
on:click={() => handleEdit(provider)} on:click={() => handleEdit(provider)}
> >
{$t.common.edit} {$t.common.edit}
</button> </button>
<button <button
class={`text-sm ${provider.is_active ? 'text-orange-600' : 'text-green-600'} hover:underline`} class={`text-sm ${provider.is_active ? "text-orange-600" : "text-green-600"} hover:underline`}
on:click={() => toggleActive(provider)} on:click={() => toggleActive(provider)}
> >
{provider.is_active ? 'Deactivate' : 'Activate'} {provider.is_active ? "Deactivate" : "Activate"}
</button> </button>
</div> </div>
</div> </div>
{:else} {:else}
<div class="text-center py-8 text-gray-500 border-2 border-dashed rounded-lg"> <div
class="text-center py-8 text-gray-500 border-2 border-dashed rounded-lg"
>
{$t.llm.no_providers} {$t.llm.no_providers}
</div> </div>
{/each} {/each}
</div> </div>
</div> </div>
<!-- [/DEF:ProviderConfig:Component] --> <!-- [/DEF:ProviderConfig:Component] -->

View File

@@ -74,7 +74,7 @@
class="bg-terminal-surface text-terminal-text-subtle border border-terminal-border rounded px-2 py-[0.3125rem] text-xs cursor-pointer shrink-0 appearance-none pr-6 focus:outline-none focus:border-primary-ring" class="bg-terminal-surface text-terminal-text-subtle border border-terminal-border rounded px-2 py-[0.3125rem] text-xs cursor-pointer shrink-0 appearance-none pr-6 focus:outline-none focus:border-primary-ring"
style="background-image: url(&quot;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E&quot;); background-repeat: no-repeat; background-position: right 0.375rem center;" style="background-image: url(&quot;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E&quot;); background-repeat: no-repeat; background-position: right 0.375rem center;"
value={selectedLevel} value={selectedLevel}
on:change={handleLevelChange} onchange={handleLevelChange}
aria-label="Filter by level" aria-label="Filter by level"
> >
{#each levelOptions as option} {#each levelOptions as option}
@@ -86,7 +86,7 @@
class="bg-terminal-surface text-terminal-text-subtle border border-terminal-border rounded px-2 py-[0.3125rem] text-xs cursor-pointer shrink-0 appearance-none pr-6 focus:outline-none focus:border-primary-ring" class="bg-terminal-surface text-terminal-text-subtle border border-terminal-border rounded px-2 py-[0.3125rem] text-xs cursor-pointer shrink-0 appearance-none pr-6 focus:outline-none focus:border-primary-ring"
style="background-image: url(&quot;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E&quot;); background-repeat: no-repeat; background-position: right 0.375rem center;" style="background-image: url(&quot;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E&quot;); background-repeat: no-repeat; background-position: right 0.375rem center;"
value={selectedSource} value={selectedSource}
on:change={handleSourceChange} onchange={handleSourceChange}
aria-label="Filter by source" aria-label="Filter by source"
> >
<option value="">All Sources</option> <option value="">All Sources</option>
@@ -114,7 +114,7 @@
class="w-full bg-terminal-surface text-terminal-text-bright border border-terminal-border rounded py-[0.3125rem] px-2 pl-7 text-xs placeholder:text-terminal-text-muted focus:outline-none focus:border-primary-ring" class="w-full bg-terminal-surface text-terminal-text-bright border border-terminal-border rounded py-[0.3125rem] px-2 pl-7 text-xs placeholder:text-terminal-text-muted focus:outline-none focus:border-primary-ring"
placeholder="Search..." placeholder="Search..."
value={searchText} value={searchText}
on:input={handleSearchChange} oninput={handleSearchChange}
aria-label="Search logs" aria-label="Search logs"
/> />
</div> </div>
@@ -123,7 +123,7 @@
{#if hasActiveFilters} {#if hasActiveFilters}
<button <button
class="flex items-center justify-center p-[0.3125rem] bg-transparent border border-terminal-border rounded text-terminal-text-subtle shrink-0 cursor-pointer transition-all hover:text-log-error hover:border-log-error hover:bg-log-error/10" class="flex items-center justify-center p-[0.3125rem] bg-transparent border border-terminal-border rounded text-terminal-text-subtle shrink-0 cursor-pointer transition-all hover:text-log-error hover:border-log-error hover:bg-log-error/10"
on:click={clearFilters} onclick={clearFilters}
aria-label="Clear filters" aria-label="Clear filters"
> >
<svg <svg

View File

@@ -134,7 +134,7 @@
<button <button
class="flex items-center gap-1.5 bg-transparent border-none text-terminal-text-muted text-[0.6875rem] cursor-pointer py-px px-1.5 rounded transition-all hover:bg-terminal-surface hover:text-terminal-text-subtle class="flex items-center gap-1.5 bg-transparent border-none text-terminal-text-muted text-[0.6875rem] cursor-pointer py-px px-1.5 rounded transition-all hover:bg-terminal-surface hover:text-terminal-text-subtle
{autoScroll ? 'text-terminal-accent' : ''}" {autoScroll ? 'text-terminal-accent' : ''}"
on:click={toggleAutoScroll} onclick={toggleAutoScroll}
aria-label="Toggle auto-scroll" aria-label="Toggle auto-scroll"
> >
{#if autoScroll} {#if autoScroll}

View File

@@ -1,10 +1,17 @@
<!-- [DEF:Counter:Component] -->
<!--
@TIER: TRIVIAL
@PURPOSE: Simple counter demo component
@LAYER: UI
-->
<script> <script>
let count = $state(0) let count = $state(0);
const increment = () => { const increment = () => {
count += 1 count += 1;
} };
</script> </script>
<button onclick={increment}> <button onclick={increment}>
count is {count} count is {count}
</button> </button>
<!-- [/DEF:Counter:Component] -->

View File

@@ -21,13 +21,14 @@ describe('SidebarStore', () => {
describe('initial state', () => { describe('initial state', () => {
it('should have default values when no localStorage', () => { it('should have default values when no localStorage', () => {
const state = get(sidebarStore); const state = get(sidebarStore);
expect(state.isExpanded).toBe(true); expect(state.isExpanded).toBe(true);
expect(state.activeCategory).toBe('dashboards'); expect(state.activeCategory).toBe('dashboards');
expect(state.activeItem).toBe('/dashboards'); expect(state.activeItem).toBe('/dashboards');
expect(state.isMobileOpen).toBe(false); expect(state.isMobileOpen).toBe(false);
}); });
}); });
// [/DEF:test_sidebar_initial_state:Function]
// [DEF:test_toggleSidebar:Function] // [DEF:test_toggleSidebar:Function]
// @TEST: toggleSidebar toggles isExpanded state // @TEST: toggleSidebar toggles isExpanded state
@@ -37,9 +38,9 @@ describe('SidebarStore', () => {
it('should toggle isExpanded from true to false', () => { it('should toggle isExpanded from true to false', () => {
const initialState = get(sidebarStore); const initialState = get(sidebarStore);
expect(initialState.isExpanded).toBe(true); expect(initialState.isExpanded).toBe(true);
toggleSidebar(); toggleSidebar();
const newState = get(sidebarStore); const newState = get(sidebarStore);
expect(newState.isExpanded).toBe(false); expect(newState.isExpanded).toBe(false);
}); });
@@ -47,11 +48,12 @@ describe('SidebarStore', () => {
it('should toggle isExpanded from false to true', () => { it('should toggle isExpanded from false to true', () => {
toggleSidebar(); // Now false toggleSidebar(); // Now false
toggleSidebar(); // Should be true again toggleSidebar(); // Should be true again
const state = get(sidebarStore); const state = get(sidebarStore);
expect(state.isExpanded).toBe(true); expect(state.isExpanded).toBe(true);
}); });
}); });
// [/DEF:test_toggleSidebar:Function]
// [DEF:test_setActiveItem:Function] // [DEF:test_setActiveItem:Function]
// @TEST: setActiveItem updates activeCategory and activeItem // @TEST: setActiveItem updates activeCategory and activeItem
@@ -60,7 +62,7 @@ describe('SidebarStore', () => {
describe('setActiveItem', () => { describe('setActiveItem', () => {
it('should update activeCategory and activeItem', () => { it('should update activeCategory and activeItem', () => {
setActiveItem('datasets', '/datasets'); setActiveItem('datasets', '/datasets');
const state = get(sidebarStore); const state = get(sidebarStore);
expect(state.activeCategory).toBe('datasets'); expect(state.activeCategory).toBe('datasets');
expect(state.activeItem).toBe('/datasets'); expect(state.activeItem).toBe('/datasets');
@@ -68,12 +70,13 @@ describe('SidebarStore', () => {
it('should update to admin category', () => { it('should update to admin category', () => {
setActiveItem('admin', '/settings'); setActiveItem('admin', '/settings');
const state = get(sidebarStore); const state = get(sidebarStore);
expect(state.activeCategory).toBe('admin'); expect(state.activeCategory).toBe('admin');
expect(state.activeItem).toBe('/settings'); expect(state.activeItem).toBe('/settings');
}); });
}); });
// [/DEF:test_setActiveItem:Function]
// [DEF:test_mobile_functions:Function] // [DEF:test_mobile_functions:Function]
// @TEST: Mobile functions correctly update isMobileOpen // @TEST: Mobile functions correctly update isMobileOpen
@@ -82,7 +85,7 @@ describe('SidebarStore', () => {
describe('mobile functions', () => { describe('mobile functions', () => {
it('should set isMobileOpen to true with setMobileOpen', () => { it('should set isMobileOpen to true with setMobileOpen', () => {
setMobileOpen(true); setMobileOpen(true);
const state = get(sidebarStore); const state = get(sidebarStore);
expect(state.isMobileOpen).toBe(true); expect(state.isMobileOpen).toBe(true);
}); });
@@ -90,7 +93,7 @@ describe('SidebarStore', () => {
it('should set isMobileOpen to false with closeMobile', () => { it('should set isMobileOpen to false with closeMobile', () => {
setMobileOpen(true); setMobileOpen(true);
closeMobile(); closeMobile();
const state = get(sidebarStore); const state = get(sidebarStore);
expect(state.isMobileOpen).toBe(false); expect(state.isMobileOpen).toBe(false);
}); });
@@ -98,18 +101,19 @@ describe('SidebarStore', () => {
it('should toggle isMobileOpen with toggleMobileSidebar', () => { it('should toggle isMobileOpen with toggleMobileSidebar', () => {
const initialState = get(sidebarStore); const initialState = get(sidebarStore);
const initialMobileOpen = initialState.isMobileOpen; const initialMobileOpen = initialState.isMobileOpen;
toggleMobileSidebar(); toggleMobileSidebar();
const state1 = get(sidebarStore); const state1 = get(sidebarStore);
expect(state1.isMobileOpen).toBe(!initialMobileOpen); expect(state1.isMobileOpen).toBe(!initialMobileOpen);
toggleMobileSidebar(); toggleMobileSidebar();
const state2 = get(sidebarStore); const state2 = get(sidebarStore);
expect(state2.isMobileOpen).toBe(initialMobileOpen); expect(state2.isMobileOpen).toBe(initialMobileOpen);
}); });
}); });
// [/DEF:test_mobile_functions:Function]
}); });
// [/DEF:frontend.src.lib.stores.__tests__.sidebar:Module] // [/DEF:frontend.src.lib.stores.__tests__.sidebar:Module]

View File

@@ -1,4 +1,9 @@
// [DEF:Utils:Module]
/** /**
* @TIER: TRIVIAL
* @PURPOSE: General utility functions (class merging)
* @LAYER: Infra
*
* Merges class names into a single string. * Merges class names into a single string.
* @param {...(string | undefined | null | false)} inputs * @param {...(string | undefined | null | false)} inputs
* @returns {string} * @returns {string}
@@ -6,3 +11,4 @@
export function cn(...inputs) { export function cn(...inputs) {
return inputs.filter(Boolean).join(" "); return inputs.filter(Boolean).join(" ");
} }
// [/DEF:Utils:Module]

View File

@@ -1,4 +1,9 @@
// [DEF:Debounce:Module]
/** /**
* @TIER: TRIVIAL
* @PURPOSE: Debounce utility for limiting function execution rate
* @LAYER: Infra
*
* Debounce utility function * Debounce utility function
* Delays the execution of a function until a specified time has passed since the last call * Delays the execution of a function until a specified time has passed since the last call
* *
@@ -17,3 +22,4 @@ export function debounce(func, wait) {
timeout = setTimeout(later, wait); timeout = setTimeout(later, wait);
}; };
} }
// [/DEF:Debounce:Module]

View File

@@ -1,11 +1,24 @@
<!-- [DEF:ErrorPage:Page] -->
<script> <script>
import { page } from '$app/stores'; /**
* @TIER: STANDARD
* @PURPOSE: Global error page displaying HTTP status and messages
* @LAYER: UI
* @UX_STATE: Error -> Displays error code and message with home link
*/
import { page } from "$app/stores";
</script> </script>
<div class="container mx-auto p-4 text-center mt-20"> <div class="container mx-auto p-4 text-center mt-20">
<h1 class="text-6xl font-bold text-gray-800 mb-4">{$page.status}</h1> <h1 class="text-6xl font-bold text-gray-800 mb-4">{$page.status}</h1>
<p class="text-2xl text-gray-600 mb-8">{$page.error?.message || 'Page not found'}</p> <p class="text-2xl text-gray-600 mb-8">
<a href="/" class="bg-blue-500 text-white px-6 py-3 rounded-lg hover:bg-blue-600 transition-colors"> {$page.error?.message || "Page not found"}
</p>
<a
href="/"
class="bg-blue-500 text-white px-6 py-3 rounded-lg hover:bg-blue-600 transition-colors"
>
Back to Dashboard Back to Dashboard
</a> </a>
</div> </div>
<!-- [/DEF:ErrorPage:Page] -->

View File

@@ -1,2 +1,9 @@
// [DEF:RootLayoutConfig:Module]
/**
* @TIER: TRIVIAL
* @PURPOSE: Root layout configuration (SPA mode)
* @LAYER: Infra
*/
export const ssr = false; export const ssr = false;
export const prerender = false; export const prerender = false;
// [/DEF:RootLayoutConfig:Module]

View File

@@ -1,3 +1,9 @@
// [DEF:DashboardTypes:Module]
/**
* @TIER: TRIVIAL
* @PURPOSE: TypeScript interfaces for Dashboard entities
* @LAYER: Domain
*/
export interface DashboardMetadata { export interface DashboardMetadata {
id: number; id: number;
title: string; title: string;
@@ -10,4 +16,5 @@ export interface DashboardSelection {
source_env_id: string; source_env_id: string;
target_env_id: string; target_env_id: string;
replace_db_config?: boolean; replace_db_config?: boolean;
} }
// [/DEF:DashboardTypes:Module]

View File

@@ -1,13 +1,14 @@
# [DEF:generate_semantic_map:Module] # [DEF:generate_semantic_map:Module]
# #
# @TIER: CRITICAL # @TIER: CRITICAL
# @SEMANTICS: semantic_analysis, parser, map_generator, compliance_checker, tier_validation, svelte_props, data_flow # @SEMANTICS: semantic_analysis, parser, map_generator, compliance_checker, tier_validation, svelte_props, data_flow, module_map
# @PURPOSE: Scans the codebase to generate a Semantic Map and Compliance Report based on the System Standard. # @PURPOSE: Scans the codebase to generate a Semantic Map, Module Map, and Compliance Report based on the System Standard.
# @LAYER: DevOps/Tooling # @LAYER: DevOps/Tooling
# @INVARIANT: All DEF anchors must have matching closing anchors; TIER determines validation strictness. # @INVARIANT: All DEF anchors must have matching closing anchors; TIER determines validation strictness.
# @RELATION: READS -> FileSystem # @RELATION: READS -> FileSystem
# @RELATION: PRODUCES -> semantics/semantic_map.json # @RELATION: PRODUCES -> semantics/semantic_map.json
# @RELATION: PRODUCES -> .ai/PROJECT_MAP.md # @RELATION: PRODUCES -> .ai/PROJECT_MAP.md
# @RELATION: PRODUCES -> .ai/MODULE_MAP.md
# @RELATION: PRODUCES -> semantics/reports/semantic_report_*.md # @RELATION: PRODUCES -> semantics/reports/semantic_report_*.md
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
@@ -83,6 +84,7 @@ IGNORE_FILES = {
} }
OUTPUT_JSON = "semantics/semantic_map.json" OUTPUT_JSON = "semantics/semantic_map.json"
OUTPUT_COMPRESSED_MD = ".ai/PROJECT_MAP.md" OUTPUT_COMPRESSED_MD = ".ai/PROJECT_MAP.md"
OUTPUT_MODULE_MAP_MD = ".ai/MODULE_MAP.md"
REPORTS_DIR = "semantics/reports" REPORTS_DIR = "semantics/reports"
# Tier-based mandatory tags # Tier-based mandatory tags
@@ -830,6 +832,7 @@ class SemanticMapGenerator:
self._generate_report() self._generate_report()
self._generate_compressed_map() self._generate_compressed_map()
self._generate_module_map()
# [/DEF:_generate_artifacts:Function] # [/DEF:_generate_artifacts:Function]
# [DEF:_generate_report:Function] # [DEF:_generate_report:Function]
@@ -990,6 +993,163 @@ class SemanticMapGenerator:
self._write_entity_md(f, child, level + 1) self._write_entity_md(f, child, level + 1)
# [/DEF:_write_entity_md:Function] # [/DEF:_write_entity_md:Function]
# [DEF:_generate_module_map:Function]
# @TIER: CRITICAL
# @PURPOSE: Generates a module-centric map grouping entities by directory structure.
# @PRE: Entities have been processed.
# @POST: Markdown module map is written to .ai/MODULE_MAP.md.
def _generate_module_map(self):
with belief_scope("_generate_module_map"):
os.makedirs(os.path.dirname(OUTPUT_MODULE_MAP_MD), exist_ok=True)
# Group entities by directory/module
modules: Dict[str, Dict[str, Any]] = {}
# [DEF:_get_module_path:Function]
# @TIER: STANDARD
# @PURPOSE: Extracts the module path from a file path.
# @PRE: file_path is a valid relative path.
# @POST: Returns a module path string.
def _get_module_path(file_path: str) -> str:
# Convert file path to module-like path
parts = file_path.replace(os.sep, '/').split('/')
# Remove filename
if len(parts) > 1:
return '/'.join(parts[:-1])
return 'root'
# [/DEF:_get_module_path:Function]
# [DEF:_collect_all_entities:Function]
# @TIER: STANDARD
# @PURPOSE: Flattens entity tree for easier grouping.
# @PRE: entity list is valid.
# @POST: Returns flat list of all entities with their hierarchy.
def _collect_all_entities(entities: List[SemanticEntity], result: List[Tuple[str, SemanticEntity]]):
for e in entities:
result.append((_get_module_path(e.file_path), e))
_collect_all_entities(e.children, result)
# [/DEF:_collect_all_entities:Function]
# Collect all entities
all_entities: List[Tuple[str, SemanticEntity]] = []
_collect_all_entities(self.entities, all_entities)
# Group by module path
for module_path, entity in all_entities:
if module_path not in modules:
modules[module_path] = {
'entities': [],
'files': set(),
'layers': set(),
'tiers': {'CRITICAL': 0, 'STANDARD': 0, 'TRIVIAL': 0},
'relations': []
}
modules[module_path]['entities'].append(entity)
modules[module_path]['files'].add(entity.file_path)
if entity.tags.get('LAYER'):
modules[module_path]['layers'].add(entity.tags.get('LAYER'))
tier = entity.get_tier().value
modules[module_path]['tiers'][tier] = modules[module_path]['tiers'].get(tier, 0) + 1
for rel in entity.relations:
modules[module_path]['relations'].append(rel)
# Write module map
with open(OUTPUT_MODULE_MAP_MD, 'w', encoding='utf-8') as f:
f.write("# Module Map\n\n")
f.write("> High-level module structure for AI Context. Generated automatically.\n\n")
f.write(f"**Generated:** {datetime.datetime.now().isoformat()}\n\n")
# Summary statistics
total_modules = len(modules)
total_entities = len(all_entities)
f.write("## Summary\n\n")
f.write(f"- **Total Modules:** {total_modules}\n")
f.write(f"- **Total Entities:** {total_entities}\n\n")
# Module hierarchy
f.write("## Module Hierarchy\n\n")
# Sort modules by path for consistent output
sorted_modules = sorted(modules.items(), key=lambda x: x[0])
for module_path, data in sorted_modules:
# Calculate module depth for indentation
depth = module_path.count('/')
indent = " " * depth
# Module header
module_name = module_path.split('/')[-1] if module_path != 'root' else 'root'
f.write(f"{indent}### 📁 `{module_name}/`\n\n")
# Module metadata
if data['layers']:
layers_str = ", ".join(sorted(data['layers']))
f.write(f"{indent}- 🏗️ **Layers:** {layers_str}\n")
tiers_summary = []
for tier_name, count in data['tiers'].items():
if count > 0:
tiers_summary.append(f"{tier_name}: {count}")
if tiers_summary:
f.write(f"{indent}- 📊 **Tiers:** {', '.join(tiers_summary)}\n")
f.write(f"{indent}- 📄 **Files:** {len(data['files'])}\n")
f.write(f"{indent}- 📦 **Entities:** {len(data['entities'])}\n")
# List key entities (Modules, Classes, Components only)
key_entities = [e for e in data['entities'] if e.type in ['Module', 'Class', 'Component', 'Store']]
if key_entities:
f.write(f"\n{indent}**Key Entities:**\n\n")
for entity in sorted(key_entities, key=lambda x: (x.type, x.name))[:10]:
icon = "📦" if entity.type == "Module" else "" if entity.type == "Class" else "🧩" if entity.type == "Component" else "🗄️"
tier_badge = ""
if entity.get_tier() == Tier.CRITICAL:
tier_badge = " `[CRITICAL]`"
elif entity.get_tier() == Tier.TRIVIAL:
tier_badge = " `[TRIVIAL]`"
purpose = entity.tags.get('PURPOSE', '')[:60] + "..." if entity.tags.get('PURPOSE') and len(entity.tags.get('PURPOSE', '')) > 60 else entity.tags.get('PURPOSE', '')
f.write(f"{indent} - {icon} **{entity.name}** ({entity.type}){tier_badge}\n")
if purpose:
f.write(f"{indent} - {purpose}\n")
# External relations
external_relations = [r for r in data['relations'] if r['type'] in ['DEPENDS_ON', 'IMPLEMENTS', 'INHERITS']]
if external_relations:
unique_deps = {}
for rel in external_relations:
key = f"{rel['type']} -> {rel['target']}"
unique_deps[key] = rel
f.write(f"\n{indent}**Dependencies:**\n\n")
for rel_str in sorted(unique_deps.keys())[:5]:
f.write(f"{indent} - 🔗 {rel_str}\n")
f.write("\n")
# Cross-module dependency graph
f.write("## Cross-Module Dependencies\n\n")
f.write("```mermaid\n")
f.write("graph TD\n")
# Find inter-module dependencies
for module_path, data in sorted_modules:
module_name = module_path.split('/')[-1] if module_path != 'root' else 'root'
safe_name = module_name.replace('-', '_').replace('.', '_')
for rel in data['relations']:
target = rel.get('target', '')
# Check if target references another module
for other_module in modules:
if other_module != module_path and other_module in target:
other_name = other_module.split('/')[-1]
safe_other = other_name.replace('-', '_').replace('.', '_')
f.write(f" {safe_name}-->|{rel['type']}|{safe_other}\n")
break
f.write("```\n")
print(f"Generated {OUTPUT_MODULE_MAP_MD}")
# [/DEF:_generate_module_map:Function]
# [/DEF:SemanticMapGenerator:Class] # [/DEF:SemanticMapGenerator:Class]

File diff suppressed because it is too large Load Diff

15
ut Normal file
View File

@@ -0,0 +1,15 @@
Prepended http:// to './RealiTLScanner'
--2026-02-20 11:14:59-- http://./RealiTLScanner
Распознаётся . (.)… ошибка: С именем узла не связано ни одного адреса.
wget: не удаётся разрешить адрес .
Prepended http:// to 'www.microsoft.com'
--2026-02-20 11:14:59-- http://www.microsoft.com/
Распознаётся www.microsoft.com (www.microsoft.com)… 95.100.178.81
Подключение к www.microsoft.com (www.microsoft.com)|95.100.178.81|:80... соединение установлено.
HTTP-запрос отправлен. Ожидание ответа… 403 Forbidden
2026-02-20 11:15:00 ОШИБКА 403: Forbidden.
Prepended http:// to 'file.csv'
--2026-02-20 11:15:00-- http://file.csv/
Распознаётся file.csv (file.csv)… ошибка: Неизвестное имя или служба.
wget: не удаётся разрешить адрес file.csv