36 Commits
0.5.5 ... 0.6.1

Author SHA1 Message Date
c30cca78f3 smoke alembic 2026-07-23 18:53:45 +03:00
d8bbe4baa8 text 2026-07-23 16:51:36 +03:00
7faa913767 fix: stabilize storage and test coverage 2026-07-23 16:45:15 +03:00
7961ef51ba feat(migration): improve failure diagnostics 2026-07-23 16:44:27 +03:00
63d82df53b test(coverage): add 200+ tests to push frontend + backend coverage above thresholds
Backend (4 files, 73 tests):
- test_agent_superset_routes.py (27 tests, 35% -> 92%)
- test_agent_lifecycle_routes.py (11 tests, 50% -> 100%)
- test_agent_status_routes.py (6 tests, 57% -> 100%)
- test_git_release_routes.py (31 tests, 30% -> 99%)

Frontend (~15 files, ~120 tests):
- cron.ts: 0% -> 100%
- ReportsLogModel: 0% -> 99%
- parseCot.ts: 10% -> 100%
- sessionTimeout.ts: 64% -> 93%
- MappingsModel: 65% -> 100%
- TranslateHistoryModel: 65% -> 93%
- Migration.ExecutorModel: 70% -> 100%
- GitManagerModel: 78% -> 90%
- TranslationJobModel: 77% -> 80%
- ConfirmDialog: 59% -> 80%
- api.ts: 78% -> 80%

Coverage: frontend 0 violations, backend 7518 passed.
2026-07-23 15:49:45 +03:00
fb6327e92b docs(specs): complete dashboard testing contracts 2026-07-23 14:08:09 +03:00
eeb3a05e42 fix(translate): preserve ClickHouse datetime keys 2026-07-23 12:42:56 +03:00
cd4b91daa5 fix(rbac): close critical auth gaps — full RBAC audit remediation
CRITICAL — unauthenticated endpoints (CWE-306):
- agent_superset.py: 10 SQL/dashboard/dataset proxy endpoints → plugin:superset_proxy:EXECUTE
- agent_superset_explore.py: 10 database explore endpoints → plugin:superset_proxy:READ
- clean_release.py / clean_release_v2.py: router-level deny-by-default → clean_release:MANAGE
- settings.py: PUT/DELETE/test environment → admin:settings:WRITE/READ
- tasks.py: log/stats/sources/export → tasks:READ

HIGH — authorization gaps (CWE-285, CWE-613):
- require_api_key_or_jwt: add token blacklist + is_active + is_admin flag checks
- get_current_user: add is_active check
- WebSocket: add _authorize_websocket() RBAC helper, permission-gate all 6 WS endpoints
- agent_conversations: add Depends(get_current_user) to save endpoint + router-level guard
- legacy validation redirect: add validation.task:VIEW guard

MEDIUM — consistency & architecture:
- admin.py: fix permission parsing split(':',1) → rsplit(':',1)
- app.py lifespan: sync RBAC permission catalog at startup
- schemas/auth.py: add is_admin to RoleSchema (with BeforeValidator), RoleCreate, RoleUpdate
- models/auth.py: add is_admin support in create_role/update_role handlers
- permissions.ts: expand KNOWN_ACTIONS (VIEW/CREATE/EDIT/MANAGE/APPROVE/PREVIEW/LAUNCH/LAUNCH_PROD)
- permissions.ts: isAdminUser checks is_admin flag from /auth/me
- Navbar.svelte: replace exact role name check with hasPermission()
- admin/+page.svelte, admin/settings/llm/+page.svelte: add ProtectedRoute guards

TESTS:
- test_dependencies_unit.py: fix 5 tests for new is_token_blacklisted + is_admin checks
- test_api_key_auth.py: fix test_jwt_precedence for is_token_blacklisted mock
- permissions.test.ts: update non-KNOWN_ACTION test to use unknown suffix 'xyz'

VERIFIED: 230 backend tests pass, 3257 frontend tests pass, index rebuilt (7373 contracts, 3832 edges)
2026-07-23 12:38:19 +03:00
e62735cc06 docs: refresh business overview and installation guide 2026-07-23 12:33:19 +03:00
fb8769c577 fix(tests): repair 39 broken tests after auth/authz hardening
- Clean Release API (33 tests): added get_current_user dependency override
  with mock admin user in _make_client — router now requires has_permission

- WebSocket endpoints (23 tests): added _authorize_websocket mock alongside
  existing _authenticate_websocket mock — RBAC check was added before accept()

- Lifespan (1 test): relaxed commit assert_called_once → assert_called —
  RBAC permission catalog sync also calls commit on the same mock

All 7445 backend tests pass (0 failures).
2026-07-23 12:32:50 +03:00
b58275a234 fix(tests): use module-level _should_retry instead of LLMClient._should_retry
_should_retry is a module-level function in service.py:885, not a class
method on LLMClient. Updated 6 tests in TestShouldRetryEdgeCases and
TestShouldRetryProviderFailures to import and call _should_retry directly.
2026-07-23 11:59:44 +03:00
a235c169e1 fix(routes): use ROUTES.*() for all internal route navigation
- login page: goto('/', ...) → goto(ROUTES.home(), ...)
- TopNavbar: goto(`/agent?...`) → goto(ROUTES.agent(query))
- AssistantChatPanel: href="/agent" → href={ROUTES.agent()}
- routes.ts: add missing ROUTES.agent(query?) builder
- link-integrity test: add /agent to INTERNAL_PREFIXES
- link-integrity test: add targeted check for goto('/', ...) patterns
2026-07-23 11:32:24 +03:00
d0213ff45b feat: session timeout management + LLM provider error hardening
Backend:
- Typed LLM provider exception hierarchy (auth, config, transport, rate limit)
- Permanent provider errors (401/403) propagate as ProviderAuthenticationFailure
  instead of being swallowed as UNKNOWN — Task becomes FAILED, not SUCCESS
- Provider error normalization maps SDK exceptions to typed hierarchy
- _should_retry extracted to module level for cross-method reuse
- SessionActivity model (jti, user_id, issued_at, expires_at, last_activity_at)
- Backend-enforced idle + absolute session timeout in dependencies.py
- Session policy endpoint GET /api/auth/session
- GlobalSettings extended: session_idle_timeout, session_absolute_timeout,
  session_warning_minutes
- Consolidated settings API returns session policy fields
- Alembic migration 8e9f0a1b2c3d for session_activity table

Frontend:
- Global 401 session-expired handler in api.ts with dedup guard
- Health polling stops on 401 (isDisabled=true)
- SessionTimeoutGuard component (+layout.svelte) tracks idle/absolute deadlines
- SessionTimeoutDialog modal with countdown (Continue/Logout)
- BroadcastChannel cross-tab activity sync
- Login returnUrl support (validates, prevents open redirect)
- SystemSettings card for session timeout configuration
- ROUTES.login(returnUrl?) for all login redirects
- i18n (EN/RU) for session security UI
- 3256 frontend tests pass, build clean
2026-07-23 11:27:17 +03:00
60539ffbda fix(git): update branch name guidance when branch type changes 2026-07-22 20:30:23 +03:00
e89036c1c6 fix(git): lift nested branch dialogs above GitManager modal 2026-07-22 20:18:18 +03:00
21e6b61ab5 test(smoke): project-wide dialog t-Proxy regression guard (replaces BranchDialogs)
Merges BranchDialogs.smoke.test.ts (CreateBranchDialog + MergeDialog, 4 tests)
into DialogsSuite.smoke.test.ts — now covers ALL 8 dialog components in one file:

  CreateBranchDialog   — render + input check
  MergeDialog          — role=dialog assertion
  ConflictResolver     — overlay + content check + show=false safety
  GitMergeDialog       — context propagation + role=dialog
  DeploymentModal      — overlay existence
  CommitModal          — overlay existence
  PasswordPrompt       — import-only smoke (model-bound)
  MissingMappingModal  — import-only smoke (model-bound)

Each test asserts:
  (a) render does not throw (t() Proxy guard)
  (b) dialog overlay exists in DOM

Adding a new dialog = one test block here.

11/11 green; all 4 git-suite failures are pre-existing GitDeploymentPipeline locale.
2026-07-22 19:46:15 +03:00
e0087c080e fix(git): branch dialogs never opened — t() call on i18n Proxy killed subtree
CreateBranchDialog ("Новая доработка") and MergeDialog silently failed to
open: both called t() as a function, but t is a Proxy object
(i18n/index.svelte.ts), so rendering {#if show} threw
"TypeError: t is not a function" with zero console output — Svelte dropped
the dialog subtree on every open attempt.

- CreateBranchDialog.svelte, MergeDialog.svelte: t().git/t().common -> t.git/t.common
- Project-wide grep confirms no other t() violations in src/lib + src/routes
- Regression guard: BranchDialogs.smoke.test.ts renders both dialogs with
  show=true and asserts the subtree exists + no throw on Proxy access (4/4 green)

Verified in browser: "Создать новую ветку" opens with type selector
(Feature/Hotfix/Bugfix/Custom prefixes), name input, source branch picker.
MergeDialog verified via smoke test (no feature branches in current repo
to trigger it live). Build green.
2026-07-22 19:40:36 +03:00
b9c0fa4c28 feat(git): BI-first UI/UX rework of /git — wizard modal, smart grid, undo, pre-flight
Grid (Phase 1):
- Smart row actions by sync status (Connect Git / Save version (N) / Manage / Diagnose)
- "What changed" column with compact stacked category badges
- Status filter chips, sticky bulk panel with live progress, inline row errors + retry
- Dedupe repo-status batch requests (grid feeds the Repositories tab)
- DashboardDataGrid: new actionsCell snippet

GitManager modal (Phases 2-4):
- Simple/Full mode toggle (localStorage), Simple = 3-step wizard
  (Changes → Verify → Publish) via new GitWizardStepper
- Undo center + undo toast: soft-undo unpublished commit
  (backend POST /repositories/{ref}/undo-commit, reset --soft HEAD~1,
  409 guards for pushed/detached/empty HEAD)
- Commit draft autosave per dashboard slug
- Keyboard: Ctrl+Enter commit, 1/2/3 step/tab navigation
- Contextual "You are here: step N" help on the /git page

Excellence (Phase 5):
- First-run GuidedTour (4 spotlight steps, restartable from help panel)
- Pre-flight checklist before create/publish release (ConfirmDialog children
  + confirmDisabled; red checks block the action)
- Rollback confirmation with revert-preview diff; guided conflict progress bar
- Preview link to PREPROD before publishing; human-readable version labels
- prefers-reduced-motion guard; page <title>; aria-live wizard announcements
- docs/design/git-ux-glossary.md — canonical action verbs, ru/en normalized

UX fixes (user feedback):
- Compact change chips (vertical stack, 10px) — no table horizontal scroll
- "Insert into version description" button next to AI key-changes summary
- Guided recovery for "binding belongs to another Git server" (CTA to settings)
- Instant rollback button reveal (CSS visibility via :global, no opacity repaint)

QA fixes:
- Glossary compliance: 0 "commit/коммит" in user-facing strings
- Contract coverage for rollback functions in CommitHistory
- Rollback label aligned to glossary ("Откат к версии" / "Revert to version")

Tests: backend 446 git passed + 5 new undo-commit edge cases;
frontend 3204 passed (8 pre-existing failures unrelated: pipeline locale,
ConfirmationCard, PasswordPrompt, assistant_chat, test_tasks);
new GitReleasePanel pre-flight tests 3/3 green; vite build green.
2026-07-22 18:06:26 +03:00
root
632b730fff chore: migrate GRACE-Poly anchors to hierarchical dotted naming
Systematic rename of all semantic anchors (#region, [DEF], @RELATION)
across 1400+ files — backend Python, frontend Svelte/TS, specs, docs:
- Flat anchors become Namespace.Module.Entity
- @RELATION references updated to match new anchor paths
- Zero business logic changes
2026-07-22 11:48:15 +03:00
root
34393adf7e run.sh: bind backend/frontend to 0.0.0.0; ignore root package.json 2026-07-21 21:12:49 +03:00
456d531a13 feat: harden migration flows and integration coverage 2026-07-21 18:59:26 +03:00
d1d2a0f92e feat: tiered test infrastructure with Makefile + smart selector + OpenCode commands
Root Makefile with timeout-protected test targets:
  - Tier 1 (<30s):   make test, make test-unit, make test-frontend
  - Tier 2 (smart):  make test-related F=file.py (via @RELATION BINDS_TO)
  - Tier 3 (<5min):  make test-integration (Docker, --run-integration)
  - Coverage:        make coverage (backend + frontend)
  - Lint:            make lint (ruff + eslint)

Smart test selector (scripts/find-related-tests.py):
  - Extracts module names from #region anchors, class/function defs
  - Searches 400 BINDS_TO entries across all test files
  - Confidence scoring: exact > case-insensitive > substring > heuristic
  - Fallback: filename-based fuzzy matching

OpenCode commands:
  - /test.all      — full suite + coverage
  - /test.unit     — fast unit tests (<30s)
  - /test.related  — smart selection by file
  - /test.coverage — coverage reports with thresholds

Speckit workflow updated:
  - speckit.test.md:   raw pytest → make targets with timeout safety
  - speckit.plan.md:   quickstart uses make targets
  - speckit.tasks.md:  verification uses make targets
  - speckit.implement.md: default stack uses make targets

Frontend: added 'coverage' script to package.json
2026-07-21 18:24:53 +03:00
8bc805d27b fix(i18n): replace hardcoded UI labels with locale keys
Add missing en/ru translations and wire reports, agent, git, settings,
mapper, and tasks surfaces so UI copy follows the active locale.
2026-07-21 09:15:25 +03:00
7bfc5553cf feat: live app log console, cross-filter, JSONL export
Backend:
- /ws/app-logs — real-time app/cot log stream (raw JSONL)
- /api/logs/recent — REST snapshot of ring buffer
- GET /tasks/{id}/logs/export — streaming JSONL export with CoT parse + redaction
- Thread-safe ring buffer (seq-based polling) replaces unsafe asyncio.Queue
- GIL-friendly multi-row Core insert for log persistence
- task_id ContextVar propagates into CotJsonFormatter for CoT correlation
- Hot-apply logging level on settings update (FR-005)
- Buffer trim under DEBUG floods; drop DEBUG first, preserve ERROR/WARNING
- List projection (include_result=False) keeps reports list slim
- Security event consolidated to single REASON atom

Frontend:
- ReportsLogModel + ReportsLogPanel — full live JSONL console
- Cross-filter pinning: Tasks → Logs tab with task chip badges
- LogEntryRow — CoT-aware rendering (marker icons, expandable payload)
- TaskFilterChip, taskChipMeta — scannable type/id/env chips
- Global drawer push via CSS variable (lg+ padding, not overlay)
- i18n en/ru for all log console strings
- Ctrl+A in log panel selects only log lines (window-level handler)

QA fixes:
- Svelte 5 reactivity: SvelteDate/SvelteSet/SvelteURLSearchParams
- Fix seed_trace_id shadowing (F823) in lifecycle.py
- Remove dead code (selectedEnvironment, goToReportsPage)
- Add @BRIEF to C2 test functions, missing {#each} keys
- Remove unused import json as _json from app.py

All 3200+ frontend tests pass; backend lint clean.
2026-07-20 21:41:34 +03:00
49a566359a feat: translate module — runtime knobs, GRACE anchors, two-layer testing, QA fixes
Implementation:
- Performance knobs: llm_batch_max_rows, llm_concurrency, insert_concurrency,
  multi_lang_mode, batch_aggressiveness, max_in_flight_batches
- Alembic migration f7a8b9c0d1e2 (idempotent, nullable, non-destructive)
- LLM provider capabilities: throughput_class, reasoning_control,
  supports_json_object, default/max_llm_concurrency
- TargetSchemaValidationRequest with conditional validator (sqllab/direct_db)
- Scheduler: background dispatch, local imports for lazy bootstrap

GRACE-Poly compliance:
- Semantic anchors on orchestrator_aggregator, orchestrator_sql, llm_provider
- Shared module _llm_http.py: _apply_reasoning_control extraction (INV_4)
- Alembic migration anchors per C3/C1 template
- Four renamed Svelte components: RunOutcomeSummary, DetectionQualityCard,
  LanguageStatList, SourceLanguageOverride

QA (this session):
- 11 backend test regressions fixed: spec'd MagicMock null fields for new
  columns, _check_translation_cache_bulk retarget, scheduler mock wiring,
  language_detection=auto assertions, token budget constant update
- 4 frontend eslint errors fixed: SvelteSet/SvelteDate imports,
  unused lang parameter, dead isTransientError
- Production bugfix: job_to_response() mapped 6 missing fields
- Pre-existing auth test failure documented (dependencies.py untouched)
- Axiom: 6440 contracts, 0 warnings
2026-07-20 14:19:47 +03:00
31b9a19a0c chore: commit remaining workspace updates
Agent:
- lifecycle: run tracking, middleware hardening, langgraph setup
- tests: agent lifecycle + langgraph setup coverage

Backend:
- async_job_runner: resilience hardening, tests
- agent_conversations: run lifecycle integration
- translate: scheduler + orchestrator SQL adjustments
- schemas/services: agent_lifecycle model extensions

Frontend:
- TaskDrawer: UX improvements
- TaskLogPanel/Viewer: safety hardening, i18n (en/ru)
- FilterBar: report filters contract + tests
- Reports page: layout adjustments

Specs:
- 036-agent-test-stabilization: runs contract, modules, events
- 037-superset-baseline-engine: catalog schema, testing API, modules
- 038-dashboard-scenario-model: scenario schema, capture profile, modules
- 039-dashboard-scenario-ui: screen models, release verification UX, modules
- dashboard-verification-usecases: new cross-cutting spec
2026-07-17 19:11:09 +03:00
fdb6541372 docs(adr): ADR-0019 — механизм импорта дашбордов Superset
Зафиксированы архитектурные решения миграции:
- UUID-трансформация БД через _transform_database_yaml() (вместо strip_databases)
- Cross-filter patching через IdMappingService + mapping_service в dry-run
- Password injection flow: await_input → wait_for_input → retry import
- Разделение dry-run (read-only) и execute (запись)
- Парсинг имён YAML-файлов БД с точками в имени

Задокументированы исправления production-багов 2026-07-16:
- add_log_callback в await_input (менеджер управляет сам)
- mapping_service=None в dry-run (лишал cross-filter patching)
- strip_databases=True → каскадный сбой 1010
2026-07-17 19:08:28 +03:00
8f0d123ff8 fix migration resume and release workflow 2026-07-16 12:31:59 +03:00
45ce585aba chore: commit remaining workspace updates 2026-07-16 07:53:41 +03:00
20105f51c0 feat(translate): add run preflight and focus execution UX 2026-07-16 07:52:52 +03:00
8e2f393267 refactor(frontend): remove addToast bridge — migrate all call sites to notifications API
BREAKING: addToast() removed. Use notify() or notifications facade instead.

- Replace addToast(msg, type, duration?) → notify({ message, type, duration? })
- Introduce notifications.success/info/warning/error/show() semantic facade
- Add dismissAllToasts(), timer management (clearTimeout on remove)
- Unify Toast component: single viewport, a11y (role/aria-live by type)
- Migrate 16 models, 2 stores, 38 components, 23 pages (76 files total)
- 147 test files / 3152 tests pass with updated mocks and assertions
2026-07-16 07:40:54 +03:00
20071b8c7a security: fullstack hardening — task ownership, mapping validation, API-key scoping, test fixes
Backend:
- Add validate_mapping_database_ownership() to verify source/target UUIDs
  belong to declared environments before persisting mappings (mappings.py)
- Add API-key environment scoping to get_mappings (filter) and
  suggest_mappings_api (enforce) (mappings.py)
- Add user_id Column to TaskRecord model + Alembic migration (task.py)
- Persist task.user_id on save, restore on load (persistence.py)
- Wire current_user.id into migrate_dashboards + backup_dashboards
  task creation (_action_routes.py)
- Fix test_migration_routes.py: module-level patch leak → autouse fixture,
  SupersetClient→AsyncSupersetClient, AsyncMock for sync_environment/run
- Fix 7 Pydantic serializer warnings: 'PENDING'→TaskStatus.PENDING
  in test_tasks.py + import TaskStatus

Frontend:
- Deepen isDryRunResult(): validate selection field, risk.items entries
  (all 5 fields), and diff object uuids individually (ExecutorModel.svelte.ts)

Prior work included: task password redaction, resume ownership checks,
canonical dry-run DTO alignment, migration UI callback fixes, credential
exposure reduction, assistant dry-run await fix.
2026-07-15 23:02:23 +03:00
30c8acf7ae fix(translate): handle invalid LLM JSON responses 2026-07-15 20:06:39 +03:00
612cc55911 test(translate): cover required field checklist 2026-07-15 16:53:25 +03:00
9922d7c87a fix(translate,scheduler): language detection, LLM error handling, and scheduler persistence
Translation pipeline fixes (from production error log analysis):

Lang detect:
- Add "ru" to _COMMON_SOURCE_CODES for Cyrillic source detection
- Version detector cache keys (v2:) and include detector version in
  source hash — stale cache entries from old algorithm are invalidated
- Integrate _character_block_fallback into batch_detect() pipeline;
  only assign Cyrillic fallback when exactly one Cyrillic target exists
  (multiple ru/uk/be targets stay undetermined for LLM arbitration)

Batch processing:
- Case-insensitive cache language matching (cached_by_lang lookup)
- Propagate needs_review=True for undetected language rows in pre/cache path

LLM call (critical — fixes silent error hiding):
- Validate LLM row IDs against expected set; log unknown identifiers
- Retry only missing rows on incomplete response (bounded by recursion depth)
- Exhausted retries → FAILED (new _handle_incomplete_response method)
- Parse failures → FAILED instead of SKIPPED (_handle_parse_failure)
- NULL/Empty translations → FAILED via new _add_failed helper
- Source-language identity mapping preserved as TranslationLanguage entry
  (carries source_language_detected metadata that _build_insert_rows
  relies on for detected_src_lang derivation)
- finish_reason propagated to parser for truncation diagnostics

LLM parse:
- Structured incomplete-set logging with expected/received/missing counts

Target schema validation:
- Handle all non-success SQL Lab statuses (failed, timeout, error, stopped)
- Timeout gets explicit message prefix
- Route returns HTTP 502 with correct HTTPException passthrough

Scheduler persistence fix (production pickle error):
- Backup and validation APScheduler jobs now use module-level callbacks
  (execute_scheduled_backup, execute_scheduled_validation) instead of
  bound SchedulerService methods — prevents pickle failure from
  serializing TaskManager + dynamically loaded plugin classes
- Callback func identity verified via pickle round-trip smoke test

Tests:
- Update assertions: FAILED replaces SKIPPED for LLM error paths
- Restore source-language contract tests with identity-mapped values
- Add scheduler callback identity and args verification tests
- Update detector cache key tests for versioned format
- Update target schema error route test: 200→502

Orthogonal code review: MEDIUM finding (empty rec.languages when all
targets match detected source → _build_insert_rows fallback to "und")
fixed by preserving source-language TranslationLanguage entries.
2026-07-15 13:07:41 +03:00
8f4ee25415 fix(alembic): merge three migration heads + add smoke test for chain integrity
- Created merge migration 7eaf84b7f6be joining heads:
  - 6b8ca3b7405f (previous merge of c0d1e2f3a4b5 + f2b3c4d5e6f7)
  - b4c5d6e7f8a9 (include_source_reference to translation_jobs)
  - f4a5b6c7d8e9 (preproduction validation to deployment records)

- Added smoke test (test_smoke_migration_chain.py) that:
  - Checks exactly 1 head (catches branch divergence)
  - Walks full chain verifying all down_revision links exist
  - Confirms all .py files are loaded as revisions
  - Runs WITHOUT a database (real ScriptDirectory, no mocks)
  - Catches what existing tests missed:
    * test_alembic_migrations.py skips on non-PostgreSQL
    * test_check_migration_chain.py uses mocks, not real files
2026-07-14 17:33:16 +03:00
1628 changed files with 57964 additions and 21781 deletions

View File

@@ -198,6 +198,7 @@ Generate its full `#region` header in `contracts/modules.md` under its parent mo
# @BRIEF One-line purpose. # @BRIEF One-line purpose.
# @RELATION DEPENDS_ON -> [DependencyService] # @RELATION DEPENDS_ON -> [DependencyService]
# @RELATION DEPENDS_ON -> [DTO:RequestSchema] # @RELATION DEPENDS_ON -> [DTO:RequestSchema]
# #endregion Domain.Resource.Action
``` ```
**Full header for C4/C5 orchestration & cross-stack functions:** **Full header for C4/C5 orchestration & cross-stack functions:**
@@ -215,6 +216,7 @@ Generate its full `#region` header in `contracts/modules.md` under its parent mo
# @RATIONALE Why this implementation approach. # @RATIONALE Why this implementation approach.
# @REJECTED What alternative was considered and forbidden. # @REJECTED What alternative was considered and forbidden.
# @TEST_EDGE: scenario_name -> Expected failure behavior. # @TEST_EDGE: scenario_name -> Expected failure behavior.
# #endregion Domain.Resource.Action
``` ```
**Screen Model actions (Svelte `.svelte.ts`):** **Screen Model actions (Svelte `.svelte.ts`):**
@@ -227,6 +229,7 @@ Generate its full `#region` header in `contracts/modules.md` under its parent mo
// @SIDE_EFFECT API call, store mutation, model state update. // @SIDE_EFFECT API call, store mutation, model state update.
// @RELATION CALLS -> [apiClient] // @RELATION CALLS -> [apiClient]
// @TEST_EDGE: network_failure -> ScreenState = "error" // @TEST_EDGE: network_failure -> ScreenState = "error"
// #endregion ScreenModel.actionName
``` ```
**Rules:** **Rules:**
@@ -264,7 +267,7 @@ specs/<feature>/fixtures/
**`manifest.md` — fixture index with GRACE contracts:** **`manifest.md` — fixture index with GRACE contracts:**
```markdown ```markdown
#region FixtureManifest [C:3] [TYPE ADR] [SEMANTICS test,fixture,[DOMAIN]] #region Example.Fixturemanifest [C:3] [TYPE ADR] [SEMANTICS test,fixture,[DOMAIN]]
@defgroup Fixtures Canonical test fixtures for [FEATURE]. @defgroup Fixtures Canonical test fixtures for [FEATURE].
## @{ Fixture FX_Auth.Login.Valid [C:2] [TYPE Block] [SEMANTICS test,auth,fixture] ## @{ Fixture FX_Auth.Login.Valid [C:2] [TYPE Block] [SEMANTICS test,auth,fixture]
@@ -287,6 +290,10 @@ specs/<feature>/fixtures/
@TEST_INVARIANT: env_reset_selection -> VERIFIED_BY: [Test.Migration.Model] @TEST_INVARIANT: env_reset_selection -> VERIFIED_BY: [Test.Migration.Model]
@TEST_FIXTURE: env_reset -> fixtures/model/migration_env_reset.json @TEST_FIXTURE: env_reset -> fixtures/model/migration_env_reset.json
## @} Fixture FX_Migration.EnvReset ## @} Fixture FX_Migration.EnvReset
## @} FX_Migration.EnvReset
## @} FX_Auth.Login.MissingPassword
## @} FX_Auth.Login.Valid
# #endregion Example.Fixturemanifest
``` ```
**JSON fixture format:** **JSON fixture format:**
@@ -341,7 +348,7 @@ Generate `quickstart.md` using real repository verification paths:
If UX contracts exist (`contracts/ux/` was generated by `/speckit.ux`), generate `traceability.md` — a requirements traceability matrix (RTM) mapping every user story through its implementation chain: If UX contracts exist (`contracts/ux/` was generated by `/speckit.ux`), generate `traceability.md` — a requirements traceability matrix (RTM) mapping every user story through its implementation chain:
```markdown ```markdown
#region Traceability [C:3] [TYPE ADR] [SEMANTICS traceability,rtm,[DOMAIN]] #region Std.Agents.Traceability [C:3] [TYPE ADR] [SEMANTICS traceability,rtm,[DOMAIN]]
@defgroup Trace Matrix Requirements → Model → API → Task → Test for [FEATURE]. @defgroup Trace Matrix Requirements → Model → API → Task → Test for [FEATURE].
## Traceability Matrix ## Traceability Matrix
@@ -358,7 +365,7 @@ If UX contracts exist (`contracts/ux/` was generated by `/speckit.ux`), generate
| `GET /api/dashboards` | FX_Dashboards.Hub.* | Test.Dashboards.Hub | /dashboards, /migration | | `GET /api/dashboards` | FX_Dashboards.Hub.* | Test.Dashboards.Hub | /dashboards, /migration |
| `Dashboards.Hub` model | FX_Dashboards.EnvReset | Test.Dashboards.Hub | /dashboards | | `Dashboards.Hub` model | FX_Dashboards.EnvReset | Test.Dashboards.Hub | /dashboards |
#endregion Traceability #endregion Std.Agents.Traceability
``` ```
**Generation rules:** **Generation rules:**

View File

@@ -200,7 +200,7 @@ After all questions are answered, create TWO artifacts:
**`contracts/ux/alternatives.md`** — all options considered, BEFORE final choice: **`contracts/ux/alternatives.md`** — all options considered, BEFORE final choice:
```markdown ```markdown
#region UxAlternatives [C:3] [TYPE ADR] [SEMANTICS ux,alternatives,[DOMAIN]] #region Std.Agents.UxAlternatives [C:3] [TYPE ADR] [SEMANTICS ux,alternatives,[DOMAIN]]
@defgroup Ux Design alternatives explored for [FEATURE]. @defgroup Ux Design alternatives explored for [FEATURE].
## Screen: [Name] ## Screen: [Name]
@@ -225,13 +225,13 @@ After all questions are answered, create TWO artifacts:
- ❌ Rejected: Confirm dialog — extra click on every action, annoying at scale - ❌ Rejected: Confirm dialog — extra click on every action, annoying at scale
- ❌ Rejected: No confirmation — dangerous for delete/migrate - ❌ Rejected: No confirmation — dangerous for delete/migrate
#endregion UxAlternatives #endregion Std.Agents.UxAlternatives
``` ```
**`contracts/ux/decisions.md`** — only the final choices: **`contracts/ux/decisions.md`** — only the final choices:
```markdown ```markdown
#region UxDecisions [C:3] [TYPE ADR] [SEMANTICS ux,decisions,[DOMAIN]] #region Std.Agents.UxDecisions [C:3] [TYPE ADR] [SEMANTICS ux,decisions,[DOMAIN]]
@defgroup Ux Final UX design decisions for [FEATURE]. @defgroup Ux Final UX design decisions for [FEATURE].
## Screen: [Name] ## Screen: [Name]
@@ -240,7 +240,7 @@ After all questions are answered, create TWO artifacts:
- Data: Paginated (20/page) + search - Data: Paginated (20/page) + search
- Feedback: Undo toast (5s) for destructive actions - Feedback: Undo toast (5s) for destructive actions
#endregion UxDecisions #endregion Std.Agents.UxDecisions
``` ```
**Rule:** `alternatives.md` shows the DESIGN SPACE — agent can see WHY each path was rejected. `decisions.md` is the compact reference for `/speckit.plan`. **Rule:** `alternatives.md` shows the DESIGN SPACE — agent can see WHY each path was rejected. `decisions.md` is the compact reference for `/speckit.plan`.

View File

@@ -3,7 +3,7 @@ name: molecular-cot-logging
description: Structured logging protocol for agent-driven development, based on molecular Long CoT bonds (REASON/REFLECT/EXPLORE). Replaces legacy Entry/Exit/Coherence markers. Python + Svelte. description: Structured logging protocol for agent-driven development, based on molecular Long CoT bonds (REASON/REFLECT/EXPLORE). Replaces legacy Entry/Exit/Coherence markers. Python + Svelte.
--- ---
#region MolecularCoTLogging [C:5] [TYPE Skill] [SEMANTICS reasoning,runtime,logging,agentic] #region Std.Agents.MolecularCoTLogging [C:5] [TYPE Skill] [SEMANTICS reasoning,runtime,logging,agentic]
@BRIEF Structured logging protocol for agent-driven development, based on molecular Long CoT bonds (Deep-Reasoning, Self-Reflection, Self-Exploration). Replaces legacy Entry/Exit/Coherence markers. @BRIEF Structured logging protocol for agent-driven development, based on molecular Long CoT bonds (Deep-Reasoning, Self-Reflection, Self-Exploration). Replaces legacy Entry/Exit/Coherence markers.
@RELATION DEPENDS_ON -> [Std.Semantics.Core] @RELATION DEPENDS_ON -> [Std.Semantics.Core]
@RELATION DISPATCHES -> [Std.Semantics.Python] @RELATION DISPATCHES -> [Std.Semantics.Python]
@@ -385,4 +385,4 @@ All new C3+ code **must** produce logs an agent can understand with almost no so
See also: semantics-python (belief runtime), semantics-core (region markup rules). See also: semantics-python (belief runtime), semantics-core (region markup rules).
#endregion MolecularCoTLogging #endregion Std.Agents.MolecularCoTLogging

View File

@@ -99,18 +99,18 @@ Not all GRACE tags are equal in the model's training data. Understanding which t
### Legacy — DEF (permanently recognized) ### Legacy — DEF (permanently recognized)
```python ```python
// [DEF:ContractId:Type] // [DEF:Std.Agents.ContractId:Type]
// @TAG: value // @TAG: value
<code> <code>
// [/DEF:ContractId:Type] // [/DEF:Std.Agents.ContractId:Type]
``` ```
### Doc — Brace (Markdown, specs, ADRs) ### Doc — Brace (Markdown, specs, ADRs)
``` ```
## @{ ContractId [C:N] [TYPE TypeName] ## @{ Std.Agents.ContractId [C:N] [TYPE TypeName]
@BRIEF Description @BRIEF Description
... ...
## @} ContractId ## @} Std.Agents.ContractId
``` ```
**Allowed Types:** Module, Function, Class, Component, Model, Block, ADR, Tombstone, Skill, Agent. **Allowed Types:** Module, Function, Class, Component, Model, Block, ADR, Tombstone, Skill, Agent.
@@ -262,6 +262,7 @@ The opening anchor MUST pack maximum signal into one line:
``` ```
#region Domain.Sub.ContractId [C:N] [TYPE TypeName] [SEMANTICS tag1,tag2,tag3] #region Domain.Sub.ContractId [C:N] [TYPE TypeName] [SEMANTICS tag1,tag2,tag3]
# #endregion Domain.Sub.ContractId
``` ```
- ID, complexity, type, and semantic tags on ONE line → survives CSA 4× pooling as a single KV record. - ID, complexity, type, and semantic tags on ONE line → survives CSA 4× pooling as a single KV record.
@@ -305,6 +306,7 @@ Example — both mechanisms reinforce each other:
#region Core.Auth.Login [C:4] [TYPE Function] [SEMANTICS auth,login,token] #region Core.Auth.Login [C:4] [TYPE Function] [SEMANTICS auth,login,token]
# @ingroup Auth # @ingroup Auth
# @BRIEF Authenticate user by credentials. # @BRIEF Authenticate user by credentials.
# #endregion Core.Auth.Login
``` ```
**Rule:** Identical domain = identical primary keyword in `[SEMANTICS ...]` AND identical `@ingroup Domain`. They target different compression layers (CSA vs HCA) and don't conflict — the keyword repetition amplifies the DSA score. **Rule:** Identical domain = identical primary keyword in `[SEMANTICS ...]` AND identical `@ingroup Domain`. They target different compression layers (CSA vs HCA) and don't conflict — the keyword repetition amplifies the DSA score.

View File

@@ -7,7 +7,7 @@ description: "Python-specific GRACE-Poly protocol: few-shot complexity examples,
@BRIEF Python-specific HOW: few-shot complexity examples, belief runtime patterns, module decomposition, and FastAPI/SQLAlchemy conventions for the GRACE-Poly protocol in superset-tools. @BRIEF Python-specific HOW: few-shot complexity examples, belief runtime patterns, module decomposition, and FastAPI/SQLAlchemy conventions for the GRACE-Poly protocol in superset-tools.
@RELATION DEPENDS_ON -> [Std.Semantics.Core] @RELATION DEPENDS_ON -> [Std.Semantics.Core]
@RELATION DEPENDS_ON -> [Std.Semantics.Contracts] @RELATION DEPENDS_ON -> [Std.Semantics.Contracts]
@RELATION DISPATCHES -> [MolecularCoTLogging] @RELATION DISPATCHES -> [Std.Agents.MolecularCoTLogging]
@RESTRICTION EXAMPLES ONLY — this file provides language-specific code patterns. All protocol rules (tier definitions, tag catalog, anchor syntax) are defined exclusively in `semantics-core`. This file MUST NOT redefine or contradict any rule from `semantics-core`. @RESTRICTION EXAMPLES ONLY — this file provides language-specific code patterns. All protocol rules (tier definitions, tag catalog, anchor syntax) are defined exclusively in `semantics-core`. This file MUST NOT redefine or contradict any rule from `semantics-core`.
@RATIONALE Python's async/await model, FastAPI dependency injection, and SQLAlchemy session management create unique failure modes for Transformer agents: (1) async/await boundary confusion — agents write sync code in async contexts or forget `await` on ORM calls, producing silent no-ops; (2) dependency injection blindness — FastAPI's `Depends()` creates implicit call graphs that the agent's attention cannot trace without explicit @RELATION edges; (3) session lifecycle drift — SQLAlchemy sessions have strict boundaries that agents violate by passing detached objects across function calls. Concrete examples at each complexity tier act as few-shot anchors that override the agent's pre-trained (and often wrong) Python patterns. @RATIONALE Python's async/await model, FastAPI dependency injection, and SQLAlchemy session management create unique failure modes for Transformer agents: (1) async/await boundary confusion — agents write sync code in async contexts or forget `await` on ORM calls, producing silent no-ops; (2) dependency injection blindness — FastAPI's `Depends()` creates implicit call graphs that the agent's attention cannot trace without explicit @RELATION edges; (3) session lifecycle drift — SQLAlchemy sessions have strict boundaries that agents violate by passing detached objects across function calls. Concrete examples at each complexity tier act as few-shot anchors that override the agent's pre-trained (and often wrong) Python patterns.
@REJECTED Generic Python patterns without GRACE anchors were rejected — agents produce working code that violates module size limits (INV_7), omits belief runtime markers, and creates orphan contracts invisible to the semantic index. Relying on the agent's pre-trained FastAPI/SQLAlchemy knowledge without project-specific examples was rejected — superset-tools has specific conventions (trace_id propagation, plugin architecture, WebSocket logging) that general training data cannot capture. @REJECTED Generic Python patterns without GRACE anchors were rejected — agents produce working code that violates module size limits (INV_7), omits belief runtime markers, and creates orphan contracts invisible to the semantic index. Relying on the agent's pre-trained FastAPI/SQLAlchemy knowledge without project-specific examples was rejected — superset-tools has specific conventions (trace_id propagation, plugin architecture, WebSocket logging) that general training data cannot capture.

View File

@@ -6,7 +6,7 @@ description: "Svelte 5 (Runes) protocol for superset-tools: UX State Machines, T
#region Std.Semantics.Svelte [C:5] [TYPE Skill] [SEMANTICS frontend,svelte,ui,ux,tailwind] #region Std.Semantics.Svelte [C:5] [TYPE Skill] [SEMANTICS frontend,svelte,ui,ux,tailwind]
@BRIEF HOW to build Svelte 5 (Runes) Components for superset-tools with UX State Machines, Tailwind CSS, store topology, and visual-interactive validation. @BRIEF HOW to build Svelte 5 (Runes) Components for superset-tools with UX State Machines, Tailwind CSS, store topology, and visual-interactive validation.
@RELATION DEPENDS_ON -> [Std.Semantics.Core] @RELATION DEPENDS_ON -> [Std.Semantics.Core]
@RELATION DEPENDS_ON -> [MolecularCoTLogging] @RELATION DEPENDS_ON -> [Std.Agents.MolecularCoTLogging]
@RELATION DISPATCHES -> [Std.Semantics.Testing] @RELATION DISPATCHES -> [Std.Semantics.Testing]
@RESTRICTION EXAMPLES ONLY — this file provides language-specific code patterns. All protocol rules (tier definitions, tag catalog, anchor syntax) are defined exclusively in `semantics-core`. UX contract tags are defined here as examples; the tag catalog lives in `semantics-core` §III. This file MUST NOT redefine or contradict any rule from `semantics-core`. @RESTRICTION EXAMPLES ONLY — this file provides language-specific code patterns. All protocol rules (tier definitions, tag catalog, anchor syntax) are defined exclusively in `semantics-core`. UX contract tags are defined here as examples; the tag catalog lives in `semantics-core` §III. This file MUST NOT redefine or contradict any rule from `semantics-core`.
@RATIONALE Svelte 5 runes ($state, $derived, $effect, $props) chosen for reactive precision and native compiler optimisations over Svelte 4 legacy reactivity ($:). Tailwind CSS selected for zero-runtime utility-first styling and rapid visual validation via chrome-devtools MCP. FSM-based UX contracts (@UX_STATE, @UX_FEEDBACK, @UX_RECOVERY) chosen to create verifiable state-transition tests that the browser Judge Agent can execute deterministically. superset-tools internal API wrappers (fetchApi/requestApi) chosen over native fetch to enforce auth, error normalisation, and trace_id propagation. Model-first architecture chosen because event-handler spaghetti is the #1 Transformer failure mode in UI code: the agent scatters logic across onclick/onchange in 5 files — KV-cache cannot hold cross-component relationships, creating invisible coupling that breaks silently. @RATIONALE Svelte 5 runes ($state, $derived, $effect, $props) chosen for reactive precision and native compiler optimisations over Svelte 4 legacy reactivity ($:). Tailwind CSS selected for zero-runtime utility-first styling and rapid visual validation via chrome-devtools MCP. FSM-based UX contracts (@UX_STATE, @UX_FEEDBACK, @UX_RECOVERY) chosen to create verifiable state-transition tests that the browser Judge Agent can execute deterministically. superset-tools internal API wrappers (fetchApi/requestApi) chosen over native fetch to enforce auth, error normalisation, and trace_id propagation. Model-first architecture chosen because event-handler spaghetti is the #1 Transformer failure mode in UI code: the agent scatters logic across onclick/onchange in 5 files — KV-cache cannot hold cross-component relationships, creating invisible coupling that breaks silently.
@@ -309,7 +309,7 @@ Frontend logging uses `log()` from `$lib/cot-logger` per **MolecularCoTLogging**
Region format for HTML/Svelte comments: Region format for HTML/Svelte comments:
```html ```html
<!-- #region MigrationTaskCard [C:3] [TYPE Component] [SEMANTICS ui,migration,task] --> <!-- #region Std.Semantics.MigrationTaskCard [C:3] [TYPE Component] [SEMANTICS ui,migration,task] -->
<!-- @BRIEF Card displaying a migration task with status, progress, and action buttons. --> <!-- @BRIEF Card displaying a migration task with status, progress, and action buttons. -->
<!-- @LAYER UI --> <!-- @LAYER UI -->
<!-- @RELATION DEPENDS_ON -> [StatusBadge] --> <!-- @RELATION DEPENDS_ON -> [StatusBadge] -->
@@ -410,7 +410,7 @@ Region format for HTML/Svelte comments:
</Button> </Button>
</div> </div>
</div> </div>
<!-- #endregion MigrationTaskCard --> <!-- #endregion Std.Semantics.MigrationTaskCard -->
``` ```
## VII. SS-TOOLS DESIGN TOKEN CANON & COMPONENT REUSE ## VII. SS-TOOLS DESIGN TOKEN CANON & COMPONENT REUSE

View File

@@ -90,7 +90,7 @@ from unittest.mock import AsyncMock, patch
class TestDashboardMigration: class TestDashboardMigration:
"""Verify migrate_dashboard @POST guarantees.""" """Verify migrate_dashboard @POST guarantees."""
# #region test_migrate_dashboard_success [C:2] [TYPE Function] # #region Std.Semantics.TestMigrateDashboardSuccess [C:2] [TYPE Function]
# @BRIEF Happy path: valid dashboard with complete db mapping. # @BRIEF Happy path: valid dashboard with complete db mapping.
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_migrate_dashboard_success(self): async def test_migrate_dashboard_success(self):
@@ -98,8 +98,9 @@ class TestDashboardMigration:
expected = {"id": "dash_1", "status": "imported"} expected = {"id": "dash_1", "status": "imported"}
# ... test implementation # ... test implementation
pass pass
# #endregion test_migrate_dashboard_success # #endregion Std.Semantics.TestMigrateDashboardSuccess
# #endregion TestDashboardMigration # #endregion TestDashboardMigration
# #endregion Test.Migration.RunTask
``` ```
### Running tests ### Running tests

View File

@@ -1,4 +1,4 @@
# [DEF:Axiom_Tools_Evaluation:Report] # [DEF:Std.Ai.AxiomToolsEvaluation:Report]
# @COMPLEXITY: 4 # @COMPLEXITY: 4
# @PURPOSE: Comprehensive evaluation of all axiom-core MCP server tools across 8 UX metrics. # @PURPOSE: Comprehensive evaluation of all axiom-core MCP server tools across 8 UX metrics.
# @LAYER: Analysis # @LAYER: Analysis
@@ -552,4 +552,4 @@
--- ---
# [/DEF:Axiom_Tools_Evaluation:Report] # [/DEF:Std.Ai.AxiomToolsEvaluation:Report]

View File

@@ -1,4 +1,4 @@
# [DEF:EffortAssess:Report] # [DEF:Std.Ai.EffortAssess:Report]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
# @PURPOSE: Оценка трудозатрат для репозитория на основе эволюции требований в specs и изменений объёма по git-истории. # @PURPOSE: Оценка трудозатрат для репозитория на основе эволюции требований в specs и изменений объёма по git-истории.
# @RELATION: DEPENDS_ON -> [Project_Knowledge_Map:Root] # @RELATION: DEPENDS_ON -> [Project_Knowledge_Map:Root]
@@ -121,4 +121,4 @@
- Plans: `specs/021-llm-project-assistant/plan.md`, `specs/025-clean-release-compliance/plan.md`, `specs/027-dataset-llm-orchestration/plan.md`. - Plans: `specs/021-llm-project-assistant/plan.md`, `specs/025-clean-release-compliance/plan.md`, `specs/027-dataset-llm-orchestration/plan.md`.
- Git evidence: коммиты `8406628`, `de1f044`, `36742cd`, `0083d90`, `321e0eb`, `023bacd`, `ed3d5f3`, а также хронологический `git log --reverse -- specs`. - Git evidence: коммиты `8406628`, `de1f044`, `36742cd`, `0083d90`, `321e0eb`, `023bacd`, `ed3d5f3`, а также хронологический `git log --reverse -- specs`.
# [/DEF:EffortAssess:Report] # [/DEF:Std.Ai.EffortAssess:Report]

View File

@@ -1,4 +1,4 @@
#[DEF:BackendRouteShot:Module] #[DEF:Std.Ai.BackendRouteShot:Module]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
# @SEMANTICS: Route, Task, API, Async # @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.
@@ -16,14 +16,14 @@ from ...dependencies import get_task_manager, get_config_manager, get_current_us
router = APIRouter() router = APIRouter()
# [DEF:CreateTaskRequest:Class] # [DEF:Std.Ai.CreateTaskRequest:Class]
# @PURPOSE: DTO for task creation payload. # @PURPOSE: DTO for task creation payload.
class CreateTaskRequest(BaseModel): class CreateTaskRequest(BaseModel):
plugin_id: str plugin_id: str
params: Dict[str, Any] params: Dict[str, Any]
# [/DEF:CreateTaskRequest:Class] # [/DEF:Std.Ai.CreateTaskRequest:Class]
# [DEF:create_task:Function] # [DEF:Std.Ai.CreateTask:Function]
# @COMPLEXITY: 4 # @COMPLEXITY: 4
# @PURPOSE: Create and start a new task using TaskManager. Non-blocking. # @PURPOSE: Create and start a new task using TaskManager. Non-blocking.
# @RELATION: [CALLS] ->[task_manager.create_task] # @RELATION: [CALLS] ->[task_manager.create_task]
@@ -70,6 +70,6 @@ async def create_task(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Task Spawning Error" detail="Internal Task Spawning Error"
) )
# [/DEF:create_task:Function] # [/DEF:Std.Ai.CreateTask:Function]
# [/DEF:BackendRouteShot:Module] # [/DEF:Std.Ai.BackendRouteShot:Module]

View File

@@ -1,4 +1,4 @@
# [DEF:TransactionCore:Module] # [DEF:Std.Ai.TransactionCore:Module]
# @COMPLEXITY: 5 # @COMPLEXITY: 5
# @SEMANTICS: Finance, ACID, Transfer, Ledger # @SEMANTICS: Finance, ACID, Transfer, Ledger
# @PURPOSE: Core banking transaction processor with ACID guarantees. # @PURPOSE: Core banking transaction processor with ACID guarantees.
@@ -32,7 +32,7 @@ class TransferResult(NamedTuple):
status: str status: str
new_balance: Decimal new_balance: Decimal
# [DEF:execute_transfer:Function] # [DEF:Std.Ai.ExecuteTransfer:Function]
# @COMPLEXITY: 5 # @COMPLEXITY: 5
# @PURPOSE: Atomically move funds between accounts with audit trails. # @PURPOSE: Atomically move funds between accounts with audit trails.
# @RELATION: [CALLS] ->[atomic_transaction] # @RELATION: [CALLS] ->[atomic_transaction]
@@ -80,6 +80,6 @@ def execute_transfer(sender_id: str, receiver_id: str, amount: Decimal) -> Trans
# GRACE: [EXPLORE] - Неожиданный сбой # GRACE: [EXPLORE] - Неожиданный сбой
logger.explore("Critical Transfer Failure", exc_info=e) logger.explore("Critical Transfer Failure", exc_info=e)
raise RuntimeError("TRANSACTION_ABORTED") from e raise RuntimeError("TRANSACTION_ABORTED") from e
#[/DEF:execute_transfer:Function] #[/DEF:Std.Ai.ExecuteTransfer:Function]
# [/DEF:TransactionCore:Module] # [/DEF:Std.Ai.TransactionCore:Module]

View File

@@ -1,11 +1,11 @@
<!-- [DEF:FrontendComponentShot:Component] --> <!-- [DEF:Std.Ai.FrontendComponentShot:Component] -->
<!-- <!--
/** /**
* @COMPLEXITY: 5 * @COMPLEXITY: 5
* @SEMANTICS: Task, Button, Action, UX * @SEMANTICS: Task, Button, Action, UX
* @PURPOSE: Action button to spawn a new task with full UX feedback cycle. * @PURPOSE: Action button to spawn a new task with full UX feedback cycle.
* @LAYER: UI (Presentation) * @LAYER: UI (Presentation)
* @RELATION: [CALLS] ->[postApi] * @RELATION: [CALLS] ->[Api.ApiModule.PostApi]
* *
* @INVARIANT: Must prevent double-submission while loading. * @INVARIANT: Must prevent double-submission while loading.
* @INVARIANT: Loading state must always terminate (no infinite spinner). * @INVARIANT: Loading state must always terminate (no infinite spinner).
@@ -48,7 +48,7 @@
let { plugin_id = "", params = {} } = $props(); let { plugin_id = "", params = {} } = $props();
let isLoading = $state(false); let isLoading = $state(false);
// [DEF:spawnTask:Function] // [DEF:Std.Ai.SpawnTask:Function]
/** /**
* @PURPOSE: Execute task creation request and emit user feedback. * @PURPOSE: Execute task creation request and emit user feedback.
* @PRE: plugin_id is resolved and request params are serializable. * @PRE: plugin_id is resolved and request params are serializable.
@@ -75,7 +75,7 @@
isLoading = false; isLoading = false;
} }
} }
// [/DEF:spawnTask:Function] // [/DEF:Std.Ai.SpawnTask:Function]
</script> </script>
<button <button
@@ -89,4 +89,4 @@
{/if} {/if}
<span>{$t.actions.start_task}</span> <span>{$t.actions.start_task}</span>
</button> </button>
<!-- [/DEF:FrontendComponentShot:Component] --> <!-- [/DEF:Std.Ai.FrontendComponentShot:Component] -->

View File

@@ -1,9 +1,9 @@
# [DEF:PluginExampleShot:Module] # [DEF:Std.Ai.PluginExampleShot:Module]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
# @SEMANTICS: Plugin, Core, Extension # @SEMANTICS: Plugin, Core, Extension
# @PURPOSE: Reference implementation of a plugin following GRACE standards. # @PURPOSE: Reference implementation of a plugin following GRACE standards.
# @LAYER: Domain (Business Logic) # @LAYER: Domain (Business Logic)
# @RELATION: [INHERITS] ->[PluginBase] # @RELATION: [INHERITS] ->[Core.PluginBase]
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,15 +11,15 @@ from ..core.task_manager.context import TaskContext
# GRACE: Обязательный импорт семантического логгера # GRACE: Обязательный импорт семантического логгера
from ..core.logger import logger, belief_scope from ..core.logger import logger, belief_scope
# [DEF:ExamplePlugin:Class] # [DEF:Std.Ai.ExamplePlugin:Class]
# @PURPOSE: A sample plugin to demonstrate execution context and logging. # @PURPOSE: A sample plugin to demonstrate execution context and logging.
# @RELATION: [INHERITS] ->[PluginBase] # @RELATION: [INHERITS] ->[Core.PluginBase]
class ExamplePlugin(PluginBase): class ExamplePlugin(PluginBase):
@property @property
def id(self) -> str: def id(self) -> str:
return "example-plugin" return "example-plugin"
#[DEF:get_schema:Function] #[DEF:Std.Ai.GetSchema:Function]
# @PURPOSE: Defines input validation schema. # @PURPOSE: Defines input validation schema.
def get_schema(self) -> Dict[str, Any]: def get_schema(self) -> Dict[str, Any]:
return { return {
@@ -32,9 +32,9 @@ class ExamplePlugin(PluginBase):
}, },
"required": ["message"], "required": ["message"],
} }
#[/DEF:get_schema:Function] #[/DEF:Std.Ai.GetSchema:Function]
# [DEF:execute:Function] # [DEF:Std.Ai.Execute:Function]
# @COMPLEXITY: 4 # @COMPLEXITY: 4
# @PURPOSE: Core plugin logic with structured logging and scope isolation. # @PURPOSE: Core plugin logic with structured logging and scope isolation.
# @RELATION: [BINDS_TO] ->[context.logger] # @RELATION: [BINDS_TO] ->[context.logger]
@@ -69,7 +69,7 @@ class ExamplePlugin(PluginBase):
# GRACE: [REFLECT] - Сверка выхода фолбэка # GRACE: [REFLECT] - Сверка выхода фолбэка
logger.reflect("Standalone execution finalized") logger.reflect("Standalone execution finalized")
# [/DEF:execute:Function] # [/DEF:Std.Ai.Execute:Function]
#[/DEF:ExamplePlugin:Class] #[/DEF:Std.Ai.ExamplePlugin:Class]
# [/DEF:PluginExampleShot:Module] # [/DEF:Std.Ai.PluginExampleShot:Module]

View File

@@ -1,4 +1,4 @@
# [DEF:TrivialUtilityShot:Module] # [DEF:Std.Ai.TrivialUtilityShot:Module]
# @COMPLEXITY: 1 # @COMPLEXITY: 1
# @PURPOSE: Reference implementation of a zero-overhead utility using implicit Complexity 1. # @PURPOSE: Reference implementation of a zero-overhead utility using implicit Complexity 1.
@@ -6,7 +6,7 @@ import re
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
# [DEF:slugify:Function] # [DEF:Std.Ai.Slugify:Function]
# @PURPOSE: Converts a string to a URL-safe slug. # @PURPOSE: Converts a string to a URL-safe slug.
def slugify(text: str) -> str: def slugify(text: str) -> str:
if not text: if not text:
@@ -14,27 +14,27 @@ def slugify(text: str) -> str:
text = text.lower().strip() text = text.lower().strip()
text = re.sub(r'[^\w\s-]', '', text) text = re.sub(r'[^\w\s-]', '', text)
return re.sub(r'[-\s]+', '-', text) return re.sub(r'[-\s]+', '-', text)
# [/DEF:slugify:Function] # [/DEF:Std.Ai.Slugify:Function]
# [DEF:get_utc_now:Function] # [DEF:Std.Ai.GetUtcNow:Function]
def get_utc_now() -> datetime: def get_utc_now() -> datetime:
"""Returns current UTC datetime (purpose is omitted because it's obvious).""" """Returns current UTC datetime (purpose is omitted because it's obvious)."""
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
# [/DEF:get_utc_now:Function] # [/DEF:Std.Ai.GetUtcNow:Function]
# [DEF:PaginationDTO:Class] # [DEF:Std.Ai.PaginationDTO:Class]
class PaginationDTO: class PaginationDTO:
# [DEF:__init__:Function] # [DEF:Std.Ai.Init:Function]
def __init__(self, page: int = 1, size: int = 50): def __init__(self, page: int = 1, size: int = 50):
self.page = max(1, page) self.page = max(1, page)
self.size = min(max(1, size), 1000) self.size = min(max(1, size), 1000)
# [/DEF:__init__:Function] # [/DEF:Std.Ai.Init:Function]
# [DEF:offset:Function] # [DEF:Std.Ai.Offset:Function]
@property @property
def offset(self) -> int: def offset(self) -> int:
return (self.page - 1) * self.size return (self.page - 1) * self.size
# [/DEF:offset:Function] # [/DEF:Std.Ai.Offset:Function]
# [/DEF:PaginationDTO:Class] # [/DEF:Std.Ai.PaginationDTO:Class]
# [/DEF:TrivialUtilityShot:Module] # [/DEF:Std.Ai.TrivialUtilityShot:Module]

View File

@@ -1,17 +1,17 @@
# #region AxiomConfig [C:5] [TYPE Block] [SEMANTICS config,axiom,indexing] # #region Config.Axiom [C:5] [TYPE Block] [SEMANTICS config,axiom,indexing]
# @BRIEF Axiom engine configuration — anchor format, indexing rules, tag schema. # @BRIEF Axiom engine configuration — anchor format, indexing rules, tag schema.
# @RATIONALE Single source of truth for the semantic indexing engine. All descriptions in English per MLA token efficiency. Complexity rules use a global tag catalog rather than per-tier duplication (all tags allowed at all tiers per SSOT protocol). # @RATIONALE Single source of truth for the semantic indexing engine. All descriptions in English per MLA token efficiency. Complexity rules use a global tag catalog rather than per-tier duplication (all tags allowed at all tiers per SSOT protocol).
# #region AnchorConfig [C:3] [TYPE Block] [SEMANTICS config,anchor] # #region AxiomConfig.AnchorConfig [C:3] [TYPE Block] [SEMANTICS config,anchor]
anchor: anchor:
format: region format: region
overrides: overrides:
docs/: brace docs/: brace
specs/: brace specs/: brace
syntax: {} syntax: {}
# #endregion AnchorConfig # #endregion AxiomConfig.AnchorConfig
# #region IndexingConfig [C:3] [TYPE Block] [SEMANTICS config,indexing] # #region AxiomConfig.IndexingConfig [C:3] [TYPE Block] [SEMANTICS config,indexing]
indexing: indexing:
include: [] include: []
exclude: exclude:
@@ -42,9 +42,9 @@ indexing:
- .opencode/command - .opencode/command
- .specify/memory - .specify/memory
- .specify/templates - .specify/templates
# #endregion IndexingConfig # #endregion AxiomConfig.IndexingConfig
# #region GlobalTagCatalog [C:5] [TYPE Block] [SEMANTICS config,tags,global] # #region AxiomConfig.GlobalTagCatalog [C:5] [TYPE Block] [SEMANTICS config,tags,global]
# @BRIEF All recognized @-tags — informational, allowed at any tier (C1-C5) per SSOT protocol. # @BRIEF All recognized @-tags — informational, allowed at any tier (C1-C5) per SSOT protocol.
# @INVARIANT Every tag in this catalog has a definition. No tag is forbidden at any tier. # @INVARIANT Every tag in this catalog has a definition. No tag is forbidden at any tier.
# @RATIONALE Per-tier duplication eliminated — tiers are descriptive, not gatekeeping. # @RATIONALE Per-tier duplication eliminated — tiers are descriptive, not gatekeeping.
@@ -100,9 +100,9 @@ global_tags:
- INVARIANT_VIOLATION - INVARIANT_VIOLATION
- VALIDATION - VALIDATION
- TEST_DATA - TEST_DATA
# #endregion GlobalTagCatalog # #endregion AxiomConfig.GlobalTagCatalog
# #region TagSchema [C:5] [TYPE Block] [SEMANTICS config,tags,schema] # #region AxiomConfig.TagSchema [C:5] [TYPE Block] [SEMANTICS config,tags,schema]
tags: tags:
C: C:
type: string type: string
@@ -376,9 +376,9 @@ tags:
multiline: false multiline: false
description: 'Public API surface — which classes/functions are entry points.' description: 'Public API surface — which classes/functions are entry points.'
orthogonal: true orthogonal: true
# #endregion TagSchema # #endregion AxiomConfig.TagSchema
# #region InfrastructureConfig [C:2] [TYPE Block] [SEMANTICS config,embedding,http] # #region AxiomConfig.InfrastructureConfig [C:2] [TYPE Block] [SEMANTICS config,embedding,http]
embedding: null embedding: null
http_api: http_api:
http_enabled: false http_enabled: false
@@ -389,9 +389,9 @@ doc_mode: null
doc_tag_mapping: null doc_tag_mapping: null
doc_stripped_output: null doc_stripped_output: null
doc_symbol_types: null doc_symbol_types: null
# #endregion InfrastructureConfig # #endregion AxiomConfig.InfrastructureConfig
# #region ComplexityRules [C:2] [TYPE Block] [SEMANTICS config,complexity,rules] # #region AxiomConfig.ComplexityRules [C:2] [TYPE Block] [SEMANTICS config,complexity,rules]
# @BRIEF Per-tier tag requirements from GRACE-Poly SSOT. All tags allowed everywhere, # @BRIEF Per-tier tag requirements from GRACE-Poly SSOT. All tags allowed everywhere,
# but C4+ require formal contract annotations. # but C4+ require formal contract annotations.
complexity_rules: complexity_rules:
@@ -400,15 +400,15 @@ complexity_rules:
"5": "5":
required: [PRE, POST, SIDE_EFFECT, DATA_CONTRACT, INVARIANT] required: [PRE, POST, SIDE_EFFECT, DATA_CONTRACT, INVARIANT]
# #endregion ComplexityRules # #endregion AxiomConfig.ComplexityRules
# #region TierThresholds [C:2] [TYPE Block] [SEMANTICS config,tiers,thresholds] # #region AxiomConfig.TierThresholds [C:2] [TYPE Block] [SEMANTICS config,tiers,thresholds]
tier_thresholds: tier_thresholds:
TIER_1: 1 TIER_1: 1
TIER_2: 2 TIER_2: 2
TIER_3: 3 TIER_3: 3
TIER_4: 4 TIER_4: 4
TIER_5: 5 TIER_5: 5
# #endregion TierThresholds # #endregion AxiomConfig.TierThresholds
# #endregion AxiomConfig # #endregion Config.Axiom

View File

@@ -1,19 +1,22 @@
# #region env.enterprise-clean [C:2] [TYPE Module] [SEMANTICS env,docker,enterprise] # #region env.enterprise-clean [C:2] [TYPE Module] [SEMANTICS env,docker,enterprise]
# @BRIEF Переменные окружения для docker-compose.enterprise-clean.yml. # @BRIEF Переменные окружения для docker-compose.enterprise-clean.yml.
# Сервисы собираются из исходников — не требуют pre-built images. # Сервисы собираются из исходников или загружаются из pre-built .tar.xz.
# Используется внешний PostgreSQL (корпоративный). # PostgreSQL запускается в контейнере (сервис db).
# @LAYER Infrastructure # @LAYER Infrastructure
# @RELATION DEPENDS_ON -> [docker-compose.enterprise-clean.yml] # @RELATION DEPENDS_ON -> [docker-compose.enterprise-clean.yml]
# #endregion env.enterprise-clean # #endregion env.enterprise-clean
# ====================================================================== # ======================================================================
# PostgreSQL (внешний, корпоративный) — ОБЯЗАТЕЛЬНО # PostgreSQL (контейнер) — настройки встроенной БД
# ====================================================================== # ======================================================================
POSTGRES_HOST=postgres.company.local # Для использования внешнего PostgreSQL — переопределите POSTGRES_HOST
# и удалите/закомментируйте сервис db в docker-compose.enterprise-clean.yml.
POSTGRES_HOST=db
POSTGRES_PORT=5432 POSTGRES_PORT=5432
POSTGRES_DB=ss_tools POSTGRES_DB=ss_tools
POSTGRES_USER=postgres POSTGRES_USER=postgres
POSTGRES_PASSWORD=change-me POSTGRES_PASSWORD=change-me
POSTGRES_HOST_PORT=5432
# ====================================================================== # ======================================================================
# Порты хоста # Порты хоста

1
.gitignore vendored
View File

@@ -39,6 +39,7 @@ dist/
!.env.example !.env.example
config.json config.json
package-lock.json package-lock.json
package.json
# Logs # Logs
*.log *.log

View File

@@ -198,6 +198,7 @@ Generate its full `#region` header in `contracts/modules.md` under its parent mo
# @BRIEF One-line purpose. # @BRIEF One-line purpose.
# @RELATION DEPENDS_ON -> [DependencyService] # @RELATION DEPENDS_ON -> [DependencyService]
# @RELATION DEPENDS_ON -> [DTO:RequestSchema] # @RELATION DEPENDS_ON -> [DTO:RequestSchema]
# #endregion Domain.Resource.Action
``` ```
**Full header for C4/C5 orchestration & cross-stack functions:** **Full header for C4/C5 orchestration & cross-stack functions:**
@@ -215,6 +216,7 @@ Generate its full `#region` header in `contracts/modules.md` under its parent mo
# @RATIONALE Why this implementation approach. # @RATIONALE Why this implementation approach.
# @REJECTED What alternative was considered and forbidden. # @REJECTED What alternative was considered and forbidden.
# @TEST_EDGE: scenario_name -> Expected failure behavior. # @TEST_EDGE: scenario_name -> Expected failure behavior.
# #endregion Domain.Resource.Action
``` ```
**Screen Model actions (Svelte `.svelte.ts`):** **Screen Model actions (Svelte `.svelte.ts`):**
@@ -227,6 +229,7 @@ Generate its full `#region` header in `contracts/modules.md` under its parent mo
// @SIDE_EFFECT API call, store mutation, model state update. // @SIDE_EFFECT API call, store mutation, model state update.
// @RELATION CALLS -> [apiClient] // @RELATION CALLS -> [apiClient]
// @TEST_EDGE: network_failure -> ScreenState = "error" // @TEST_EDGE: network_failure -> ScreenState = "error"
// #endregion ScreenModel.actionName
``` ```
**Rules:** **Rules:**
@@ -264,7 +267,7 @@ specs/<feature>/fixtures/
**`manifest.md` — fixture index with GRACE contracts:** **`manifest.md` — fixture index with GRACE contracts:**
```markdown ```markdown
#region FixtureManifest [C:3] [TYPE ADR] [SEMANTICS test,fixture,[DOMAIN]] #region Example.Fixturemanifest [C:3] [TYPE ADR] [SEMANTICS test,fixture,[DOMAIN]]
@defgroup Fixtures Canonical test fixtures for [FEATURE]. @defgroup Fixtures Canonical test fixtures for [FEATURE].
## @{ Fixture FX_Auth.Login.Valid [C:2] [TYPE Block] [SEMANTICS test,auth,fixture] ## @{ Fixture FX_Auth.Login.Valid [C:2] [TYPE Block] [SEMANTICS test,auth,fixture]
@@ -287,6 +290,10 @@ specs/<feature>/fixtures/
@TEST_INVARIANT: env_reset_selection -> VERIFIED_BY: [Test.Migration.Model] @TEST_INVARIANT: env_reset_selection -> VERIFIED_BY: [Test.Migration.Model]
@TEST_FIXTURE: env_reset -> fixtures/model/migration_env_reset.json @TEST_FIXTURE: env_reset -> fixtures/model/migration_env_reset.json
## @} Fixture FX_Migration.EnvReset ## @} Fixture FX_Migration.EnvReset
## @} FX_Migration.EnvReset
## @} FX_Auth.Login.MissingPassword
## @} FX_Auth.Login.Valid
# #endregion Example.Fixturemanifest
``` ```
**JSON fixture format:** **JSON fixture format:**
@@ -341,7 +348,7 @@ Generate `quickstart.md` using real repository verification paths:
If UX contracts exist (`contracts/ux/` was generated by `/speckit.ux`), generate `traceability.md` — a requirements traceability matrix (RTM) mapping every user story through its implementation chain: If UX contracts exist (`contracts/ux/` was generated by `/speckit.ux`), generate `traceability.md` — a requirements traceability matrix (RTM) mapping every user story through its implementation chain:
```markdown ```markdown
#region Traceability [C:3] [TYPE ADR] [SEMANTICS traceability,rtm,[DOMAIN]] #region Std.Kilo.Traceability [C:3] [TYPE ADR] [SEMANTICS traceability,rtm,[DOMAIN]]
@defgroup Trace Matrix Requirements → Model → API → Task → Test for [FEATURE]. @defgroup Trace Matrix Requirements → Model → API → Task → Test for [FEATURE].
## Traceability Matrix ## Traceability Matrix
@@ -358,7 +365,7 @@ If UX contracts exist (`contracts/ux/` was generated by `/speckit.ux`), generate
| `GET /api/dashboards` | FX_Dashboards.Hub.* | Test.Dashboards.Hub | /dashboards, /migration | | `GET /api/dashboards` | FX_Dashboards.Hub.* | Test.Dashboards.Hub | /dashboards, /migration |
| `Dashboards.Hub` model | FX_Dashboards.EnvReset | Test.Dashboards.Hub | /dashboards | | `Dashboards.Hub` model | FX_Dashboards.EnvReset | Test.Dashboards.Hub | /dashboards |
#endregion Traceability #endregion Std.Kilo.Traceability
``` ```
**Generation rules:** **Generation rules:**

View File

@@ -200,7 +200,7 @@ After all questions are answered, create TWO artifacts:
**`contracts/ux/alternatives.md`** — all options considered, BEFORE final choice: **`contracts/ux/alternatives.md`** — all options considered, BEFORE final choice:
```markdown ```markdown
#region UxAlternatives [C:3] [TYPE ADR] [SEMANTICS ux,alternatives,[DOMAIN]] #region Std.Kilo.UxAlternatives [C:3] [TYPE ADR] [SEMANTICS ux,alternatives,[DOMAIN]]
@defgroup Ux Design alternatives explored for [FEATURE]. @defgroup Ux Design alternatives explored for [FEATURE].
## Screen: [Name] ## Screen: [Name]
@@ -225,13 +225,13 @@ After all questions are answered, create TWO artifacts:
- ❌ Rejected: Confirm dialog — extra click on every action, annoying at scale - ❌ Rejected: Confirm dialog — extra click on every action, annoying at scale
- ❌ Rejected: No confirmation — dangerous for delete/migrate - ❌ Rejected: No confirmation — dangerous for delete/migrate
#endregion UxAlternatives #endregion Std.Kilo.UxAlternatives
``` ```
**`contracts/ux/decisions.md`** — only the final choices: **`contracts/ux/decisions.md`** — only the final choices:
```markdown ```markdown
#region UxDecisions [C:3] [TYPE ADR] [SEMANTICS ux,decisions,[DOMAIN]] #region Std.Kilo.UxDecisions [C:3] [TYPE ADR] [SEMANTICS ux,decisions,[DOMAIN]]
@defgroup Ux Final UX design decisions for [FEATURE]. @defgroup Ux Final UX design decisions for [FEATURE].
## Screen: [Name] ## Screen: [Name]
@@ -240,7 +240,7 @@ After all questions are answered, create TWO artifacts:
- Data: Paginated (20/page) + search - Data: Paginated (20/page) + search
- Feedback: Undo toast (5s) for destructive actions - Feedback: Undo toast (5s) for destructive actions
#endregion UxDecisions #endregion Std.Kilo.UxDecisions
``` ```
**Rule:** `alternatives.md` shows the DESIGN SPACE — agent can see WHY each path was rejected. `decisions.md` is the compact reference for `/speckit.plan`. **Rule:** `alternatives.md` shows the DESIGN SPACE — agent can see WHY each path was rejected. `decisions.md` is the compact reference for `/speckit.plan`.

View File

@@ -3,7 +3,7 @@ name: molecular-cot-logging
description: Structured logging protocol for agent-driven development, based on molecular Long CoT bonds (REASON/REFLECT/EXPLORE). Replaces legacy Entry/Exit/Coherence markers. Python + Svelte. description: Structured logging protocol for agent-driven development, based on molecular Long CoT bonds (REASON/REFLECT/EXPLORE). Replaces legacy Entry/Exit/Coherence markers. Python + Svelte.
--- ---
#region MolecularCoTLogging [C:5] [TYPE Skill] [SEMANTICS reasoning,runtime,logging,agentic] #region Std.Kilo.MolecularCoTLogging [C:5] [TYPE Skill] [SEMANTICS reasoning,runtime,logging,agentic]
@BRIEF Structured logging protocol for agent-driven development, based on molecular Long CoT bonds (Deep-Reasoning, Self-Reflection, Self-Exploration). Replaces legacy Entry/Exit/Coherence markers. @BRIEF Structured logging protocol for agent-driven development, based on molecular Long CoT bonds (Deep-Reasoning, Self-Reflection, Self-Exploration). Replaces legacy Entry/Exit/Coherence markers.
@RELATION DEPENDS_ON -> [Std.Semantics.Core] @RELATION DEPENDS_ON -> [Std.Semantics.Core]
@RELATION DISPATCHES -> [Std.Semantics.Python] @RELATION DISPATCHES -> [Std.Semantics.Python]
@@ -303,4 +303,4 @@ for line in sys.stdin:
| Logging raw passwords or tokens in `payload` | Always sanitise sensitive data | | Logging raw passwords or tokens in `payload` | Always sanitise sensitive data |
| Spread markers across multiple modules without trace_id | Always propagate `trace_id` | | Spread markers across multiple modules without trace_id | Always propagate `trace_id` |
#endregion MolecularCoTLogging #endregion Std.Kilo.MolecularCoTLogging

View File

@@ -3,10 +3,10 @@ name: semantics-frontend
description: Core protocol for Svelte 5 (Runes) Components, UX State Machines, and Visual-Interactive Validation. description: Core protocol for Svelte 5 (Runes) Components, UX State Machines, and Visual-Interactive Validation.
--- ---
# [DEF:Std:Semantics:Frontend] # [DEF:Std.Kilo.Std:Semantics:Frontend]
# @COMPLEXITY: 5 # @COMPLEXITY: 5
# @PURPOSE: Canonical GRACE-Poly protocol for Svelte 5 (Runes) Components, UX State Machines, and Project UI Architecture. # @PURPOSE: Canonical GRACE-Poly protocol for Svelte 5 (Runes) Components, UX State Machines, and Project UI Architecture.
# @RELATION: DEPENDS_ON ->[Std:Semantics:Core] # @RELATION: DEPENDS_ON ->[Std.Kilo.Std:Semantics:Core]
# @INVARIANT: Frontend components MUST be verifiable by an automated GUI Judge Agent (e.g., Playwright). # @INVARIANT: Frontend components MUST be verifiable by an automated GUI Judge Agent (e.g., Playwright).
# @INVARIANT: Use Tailwind CSS exclusively. Native `fetch` is forbidden. # @INVARIANT: Use Tailwind CSS exclusively. Native `fetch` is forbidden.
@@ -55,7 +55,8 @@ Frontend logging bridges the gap between your logic and the Judge Agent's vision
You MUST strictly adhere to this AST boundary format: You MUST strictly adhere to this AST boundary format:
```html ```html
<!-- [DEF:ComponentName:Component] --> # [/DEF:Std.Kilo.Std:Semantics:Frontend]
<!-- [DEF:Std.Kilo.ComponentName:Component] -->
<script> <script>
/** /**
* @COMPLEXITY: [1-5] * @COMPLEXITY: [1-5]
@@ -104,4 +105,4 @@ You MUST strictly adhere to this AST boundary format:
{$t('actions.start')} {$t('actions.start')}
</button> </button>
</div> </div>
<!--[/DEF:ComponentName:Component] --> <!--[/DEF:Std.Kilo.ComponentName:Component] -->

View File

@@ -3,10 +3,10 @@ name: semantics-belief
description: Core protocol for Thread-Local Belief State, Runtime Chain-of-Thought (CoT), and Interleaved Thinking in Python. description: Core protocol for Thread-Local Belief State, Runtime Chain-of-Thought (CoT), and Interleaved Thinking in Python.
--- ---
# [DEF:Std:Semantics:Belief] # [DEF:Std.Kilo.Std:Semantics:Belief]
# @COMPLEXITY: 5 # @COMPLEXITY: 5
# @PURPOSE: Core protocol for Thread-Local Belief State, Runtime Chain-of-Thought (CoT), and Interleaved Thinking in Python. # @PURPOSE: Core protocol for Thread-Local Belief State, Runtime Chain-of-Thought (CoT), and Interleaved Thinking in Python.
# @RELATION: DEPENDS_ON -> [Std:Semantics:Core] # @RELATION: DEPENDS_ON -> [Std.Kilo.Std:Semantics:Core]
# @INVARIANT: Implementation of C4/C5 complexity nodes MUST emit reasoning via semantic logger methods before mutating state or returning. # @INVARIANT: Implementation of C4/C5 complexity nodes MUST emit reasoning via semantic logger methods before mutating state or returning.
## 0. INTERLEAVED THINKING (GLM-5 PARADIGM) ## 0. INTERLEAVED THINKING (GLM-5 PARADIGM)
@@ -53,5 +53,5 @@ If your execution path triggers a `logger.explore()` due to a broken assumption
**YOU MUST ASCEND TO THE `[DEF]` HEADER AND DOCUMENT IT.** **YOU MUST ASCEND TO THE `[DEF]` HEADER AND DOCUMENT IT.**
You must add `@RATIONALE: [Why you did this]` and `@REJECTED:[The path that failed during explore()]`. You must add `@RATIONALE: [Why you did this]` and `@REJECTED:[The path that failed during explore()]`.
Failure to link a runtime `explore` to a static `@REJECTED` tag is a fatal protocol violation that causes amnesia for future agents. Failure to link a runtime `explore` to a static `@REJECTED` tag is a fatal protocol violation that causes amnesia for future agents.
# [/DEF:Std:Semantics:Belief] # [/DEF:Std.Kilo.Std:Semantics:Belief]
**[SYSTEM: END OF BELIEF DIRECTIVE. ENFORCE STRICT RUNTIME CoT.]** **[SYSTEM: END OF BELIEF DIRECTIVE. ENFORCE STRICT RUNTIME CoT.]**

View File

@@ -97,18 +97,18 @@ Not all GRACE tags are equal in the model's training data. Understanding which t
### Legacy — DEF (permanently recognized) ### Legacy — DEF (permanently recognized)
```python ```python
// [DEF:ContractId:Type] // [DEF:Std.Kilo.ContractId:Type]
// @TAG: value // @TAG: value
<code> <code>
// [/DEF:ContractId:Type] // [/DEF:Std.Kilo.ContractId:Type]
``` ```
### Doc — Brace (Markdown, specs, ADRs) ### Doc — Brace (Markdown, specs, ADRs)
``` ```
## @{ ContractId [C:N] [TYPE TypeName] ## @{ Std.Kilo.ContractId [C:N] [TYPE TypeName]
@BRIEF Description @BRIEF Description
... ...
## @} ContractId ## @} Std.Kilo.ContractId
``` ```
**Allowed Types:** Module, Function, Class, Component, Model, Block, ADR, Tombstone, Skill, Agent. **Allowed Types:** Module, Function, Class, Component, Model, Block, ADR, Tombstone, Skill, Agent.
@@ -260,6 +260,7 @@ The opening anchor MUST pack maximum signal into one line:
``` ```
#region Domain.Sub.ContractId [C:N] [TYPE TypeName] [SEMANTICS tag1,tag2,tag3] #region Domain.Sub.ContractId [C:N] [TYPE TypeName] [SEMANTICS tag1,tag2,tag3]
# #endregion Domain.Sub.ContractId
``` ```
- ID, complexity, type, and semantic tags on ONE line → survives CSA 4× pooling as a single KV record. - ID, complexity, type, and semantic tags on ONE line → survives CSA 4× pooling as a single KV record.
@@ -303,6 +304,7 @@ Example — both mechanisms reinforce each other:
#region Core.Auth.Login [C:4] [TYPE Function] [SEMANTICS auth,login,token] #region Core.Auth.Login [C:4] [TYPE Function] [SEMANTICS auth,login,token]
# @ingroup Auth # @ingroup Auth
# @BRIEF Authenticate user by credentials. # @BRIEF Authenticate user by credentials.
# #endregion Core.Auth.Login
``` ```
**Rule:** Identical domain = identical primary keyword in `[SEMANTICS ...]` AND identical `@ingroup Domain`. They target different compression layers (CSA vs HCA) and don't conflict — the keyword repetition amplifies the DSA score. **Rule:** Identical domain = identical primary keyword in `[SEMANTICS ...]` AND identical `@ingroup Domain`. They target different compression layers (CSA vs HCA) and don't conflict — the keyword repetition amplifies the DSA score.

View File

@@ -7,7 +7,7 @@ description: Python-specific GRACE-Poly protocol: few-shot complexity examples,
@BRIEF Python-specific HOW: few-shot complexity examples, belief runtime patterns, module decomposition, and FastAPI/SQLAlchemy conventions for the GRACE-Poly protocol in superset-tools. @BRIEF Python-specific HOW: few-shot complexity examples, belief runtime patterns, module decomposition, and FastAPI/SQLAlchemy conventions for the GRACE-Poly protocol in superset-tools.
@RELATION DEPENDS_ON -> [Std.Semantics.Core] @RELATION DEPENDS_ON -> [Std.Semantics.Core]
@RELATION DEPENDS_ON -> [Std.Semantics.Contracts] @RELATION DEPENDS_ON -> [Std.Semantics.Contracts]
@RELATION DISPATCHES -> [MolecularCoTLogging] @RELATION DISPATCHES -> [Std.Kilo.MolecularCoTLogging]
@RESTRICTION EXAMPLES ONLY — this file provides language-specific code patterns. All protocol rules (tier definitions, tag catalog, anchor syntax) are defined exclusively in `semantics-core`. This file MUST NOT redefine or contradict any rule from `semantics-core`. @RESTRICTION EXAMPLES ONLY — this file provides language-specific code patterns. All protocol rules (tier definitions, tag catalog, anchor syntax) are defined exclusively in `semantics-core`. This file MUST NOT redefine or contradict any rule from `semantics-core`.
@RATIONALE Python's async/await model, FastAPI dependency injection, and SQLAlchemy session management create unique failure modes for Transformer agents: (1) async/await boundary confusion — agents write sync code in async contexts or forget `await` on ORM calls, producing silent no-ops; (2) dependency injection blindness — FastAPI's `Depends()` creates implicit call graphs that the agent's attention cannot trace without explicit @RELATION edges; (3) session lifecycle drift — SQLAlchemy sessions have strict boundaries that agents violate by passing detached objects across function calls. Concrete examples at each complexity tier act as few-shot anchors that override the agent's pre-trained (and often wrong) Python patterns. @RATIONALE Python's async/await model, FastAPI dependency injection, and SQLAlchemy session management create unique failure modes for Transformer agents: (1) async/await boundary confusion — agents write sync code in async contexts or forget `await` on ORM calls, producing silent no-ops; (2) dependency injection blindness — FastAPI's `Depends()` creates implicit call graphs that the agent's attention cannot trace without explicit @RELATION edges; (3) session lifecycle drift — SQLAlchemy sessions have strict boundaries that agents violate by passing detached objects across function calls. Concrete examples at each complexity tier act as few-shot anchors that override the agent's pre-trained (and often wrong) Python patterns.
@REJECTED Generic Python patterns without GRACE anchors were rejected — agents produce working code that violates module size limits (INV_7), omits belief runtime markers, and creates orphan contracts invisible to the semantic index. Relying on the agent's pre-trained FastAPI/SQLAlchemy knowledge without project-specific examples was rejected — superset-tools has specific conventions (trace_id propagation, plugin architecture, WebSocket logging) that general training data cannot capture. @REJECTED Generic Python patterns without GRACE anchors were rejected — agents produce working code that violates module size limits (INV_7), omits belief runtime markers, and creates orphan contracts invisible to the semantic index. Relying on the agent's pre-trained FastAPI/SQLAlchemy knowledge without project-specific examples was rejected — superset-tools has specific conventions (trace_id propagation, plugin architecture, WebSocket logging) that general training data cannot capture.

View File

@@ -6,7 +6,7 @@ description: Svelte 5 (Runes) protocol for superset-tools: UX State Machines, Ta
#region Std.Semantics.Svelte [C:5] [TYPE Skill] [SEMANTICS frontend,svelte,ui,ux,tailwind] #region Std.Semantics.Svelte [C:5] [TYPE Skill] [SEMANTICS frontend,svelte,ui,ux,tailwind]
@BRIEF HOW to build Svelte 5 (Runes) Components for superset-tools with UX State Machines, Tailwind CSS, store topology, and visual-interactive validation. @BRIEF HOW to build Svelte 5 (Runes) Components for superset-tools with UX State Machines, Tailwind CSS, store topology, and visual-interactive validation.
@RELATION DEPENDS_ON -> [Std.Semantics.Core] @RELATION DEPENDS_ON -> [Std.Semantics.Core]
@RELATION DEPENDS_ON -> [MolecularCoTLogging] @RELATION DEPENDS_ON -> [Std.Kilo.MolecularCoTLogging]
@RELATION DISPATCHES -> [Std.Semantics.Testing] @RELATION DISPATCHES -> [Std.Semantics.Testing]
@RESTRICTION EXAMPLES ONLY — this file provides language-specific code patterns. All protocol rules (tier definitions, tag catalog, anchor syntax) are defined exclusively in `semantics-core`. UX contract tags are defined here as examples; the tag catalog lives in `semantics-core` §III. This file MUST NOT redefine or contradict any rule from `semantics-core`. @RESTRICTION EXAMPLES ONLY — this file provides language-specific code patterns. All protocol rules (tier definitions, tag catalog, anchor syntax) are defined exclusively in `semantics-core`. UX contract tags are defined here as examples; the tag catalog lives in `semantics-core` §III. This file MUST NOT redefine or contradict any rule from `semantics-core`.
@RATIONALE Svelte 5 runes ($state, $derived, $effect, $props) chosen for reactive precision and native compiler optimisations over Svelte 4 legacy reactivity ($:). Tailwind CSS selected for zero-runtime utility-first styling and rapid visual validation via chrome-devtools MCP. FSM-based UX contracts (@UX_STATE, @UX_FEEDBACK, @UX_RECOVERY) chosen to create verifiable state-transition tests that the browser Judge Agent can execute deterministically. superset-tools internal API wrappers (fetchApi/requestApi) chosen over native fetch to enforce auth, error normalisation, and trace_id propagation. Model-first architecture chosen because event-handler spaghetti is the #1 Transformer failure mode in UI code: the agent scatters logic across onclick/onchange in 5 files — KV-cache cannot hold cross-component relationships, creating invisible coupling that breaks silently. @RATIONALE Svelte 5 runes ($state, $derived, $effect, $props) chosen for reactive precision and native compiler optimisations over Svelte 4 legacy reactivity ($:). Tailwind CSS selected for zero-runtime utility-first styling and rapid visual validation via chrome-devtools MCP. FSM-based UX contracts (@UX_STATE, @UX_FEEDBACK, @UX_RECOVERY) chosen to create verifiable state-transition tests that the browser Judge Agent can execute deterministically. superset-tools internal API wrappers (fetchApi/requestApi) chosen over native fetch to enforce auth, error normalisation, and trace_id propagation. Model-first architecture chosen because event-handler spaghetti is the #1 Transformer failure mode in UI code: the agent scatters logic across onclick/onchange in 5 files — KV-cache cannot hold cross-component relationships, creating invisible coupling that breaks silently.
@@ -309,7 +309,7 @@ Frontend logging uses `log()` from `$lib/cot-logger` per **MolecularCoTLogging**
Region format for HTML/Svelte comments: Region format for HTML/Svelte comments:
```html ```html
<!-- #region MigrationTaskCard [C:3] [TYPE Component] [SEMANTICS ui,migration,task] --> <!-- #region Std.Semantics.MigrationTaskCard [C:3] [TYPE Component] [SEMANTICS ui,migration,task] -->
<!-- @BRIEF Card displaying a migration task with status, progress, and action buttons. --> <!-- @BRIEF Card displaying a migration task with status, progress, and action buttons. -->
<!-- @LAYER UI --> <!-- @LAYER UI -->
<!-- @RELATION DEPENDS_ON -> [StatusBadge] --> <!-- @RELATION DEPENDS_ON -> [StatusBadge] -->
@@ -410,7 +410,7 @@ Region format for HTML/Svelte comments:
</Button> </Button>
</div> </div>
</div> </div>
<!-- #endregion MigrationTaskCard --> <!-- #endregion Std.Semantics.MigrationTaskCard -->
``` ```
## VII. SS-TOOLS DESIGN TOKEN CANON & COMPONENT REUSE ## VII. SS-TOOLS DESIGN TOKEN CANON & COMPONENT REUSE

View File

@@ -90,7 +90,7 @@ from unittest.mock import AsyncMock, patch
class TestDashboardMigration: class TestDashboardMigration:
"""Verify migrate_dashboard @POST guarantees.""" """Verify migrate_dashboard @POST guarantees."""
# #region test_migrate_dashboard_success [C:2] [TYPE Function] # #region Std.Semantics.TestMigrateDashboardSuccess [C:2] [TYPE Function]
# @BRIEF Happy path: valid dashboard with complete db mapping. # @BRIEF Happy path: valid dashboard with complete db mapping.
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_migrate_dashboard_success(self): async def test_migrate_dashboard_success(self):
@@ -98,8 +98,9 @@ class TestDashboardMigration:
expected = {"id": "dash_1", "status": "imported"} expected = {"id": "dash_1", "status": "imported"}
# ... test implementation # ... test implementation
pass pass
# #endregion test_migrate_dashboard_success # #endregion Std.Semantics.TestMigrateDashboardSuccess
# #endregion TestDashboardMigration # #endregion TestDashboardMigration
# #endregion Test.Migration.RunTask
``` ```
### Running tests ### Running tests

View File

@@ -123,7 +123,7 @@ For Svelte components with `@UX_STATE`, `@UX_FEEDBACK`, `@UX_RECOVERY` tags:
**UX Test Template:** **UX Test Template:**
```javascript ```javascript
// [DEF:ComponentUXTests:Module] // [DEF:Example.Componentuxtests:Module]
// @C: 3 // @C: 3
// @RELATION: VERIFIES -> ../Component.svelte // @RELATION: VERIFIES -> ../Component.svelte
// @PURPOSE: Test UX states and transitions // @PURPOSE: Test UX states and transitions
@@ -139,6 +139,7 @@ describe('Component UX States', () => {
it('should allow retry on error', async () => { ... }); it('should allow retry on error', async () => { ... });
}); });
// [/DEF:__tests__/test_Component:Module] // [/DEF:__tests__/test_Component:Module]
// [/DEF:Example.Componentuxtests:Module]
``` ```
### 5. Test Documentation ### 5. Test Documentation

View File

@@ -40,12 +40,13 @@ You **MUST** consider the user input before proceeding (if not empty).
- Source paths: `backend/src/**/*.py` and `frontend/src/**/*.svelte`. - Source paths: `backend/src/**/*.py` and `frontend/src/**/*.svelte`.
- Active feature docs always live under `specs/<feature>/...` and are discovered via the `.specify/scripts/bash/*` helpers. - Active feature docs always live under `specs/<feature>/...` and are discovered via the `.specify/scripts/bash/*` helpers.
- Default verification stack: - Default verification stack (all timeout-protected via root Makefile):
- Backend: `cd backend && source .venv/bin/activate && python -m pytest -v` - `make test-unit` — backend unit tests (SQLite, <120s)
- Backend lint: `cd backend && python -m ruff check .` - `make test-frontend` frontend vitest tests
- Frontend lint: `cd frontend && npm run lint` - `make lint` ruff + eslint
- Frontend: `cd frontend && npm run test` - `cd frontend && npm run build` production build check
- Frontend build: `cd frontend && npm run build` - `make coverage` coverage reports (optional, run after tests pass)
- `make test-related F=path/to/changed_file.py` smart selection for narrow scopes
- Do not fall back to Rust `cargo`/`src/server/` conventions this is a Python/Svelte project. - Do not fall back to Rust `cargo`/`src/server/` conventions this is a Python/Svelte project.
## Semantic Execution Rules ## Semantic Execution Rules

View File

@@ -198,6 +198,7 @@ Generate its full `#region` header in `contracts/modules.md` under its parent mo
# @BRIEF One-line purpose. # @BRIEF One-line purpose.
# @RELATION DEPENDS_ON -> [DependencyService] # @RELATION DEPENDS_ON -> [DependencyService]
# @RELATION DEPENDS_ON -> [DTO:RequestSchema] # @RELATION DEPENDS_ON -> [DTO:RequestSchema]
# #endregion Domain.Resource.Action
``` ```
**Full header for C4/C5 orchestration & cross-stack functions:** **Full header for C4/C5 orchestration & cross-stack functions:**
@@ -215,6 +216,7 @@ Generate its full `#region` header in `contracts/modules.md` under its parent mo
# @RATIONALE Why this implementation approach. # @RATIONALE Why this implementation approach.
# @REJECTED What alternative was considered and forbidden. # @REJECTED What alternative was considered and forbidden.
# @TEST_EDGE: scenario_name -> Expected failure behavior. # @TEST_EDGE: scenario_name -> Expected failure behavior.
# #endregion Domain.Resource.Action
``` ```
**Screen Model actions (Svelte `.svelte.ts`):** **Screen Model actions (Svelte `.svelte.ts`):**
@@ -227,6 +229,7 @@ Generate its full `#region` header in `contracts/modules.md` under its parent mo
// @SIDE_EFFECT API call, store mutation, model state update. // @SIDE_EFFECT API call, store mutation, model state update.
// @RELATION CALLS -> [apiClient] // @RELATION CALLS -> [apiClient]
// @TEST_EDGE: network_failure -> ScreenState = "error" // @TEST_EDGE: network_failure -> ScreenState = "error"
// #endregion ScreenModel.actionName
``` ```
**Rules:** **Rules:**
@@ -264,7 +267,7 @@ specs/<feature>/fixtures/
**`manifest.md` — fixture index with GRACE contracts:** **`manifest.md` — fixture index with GRACE contracts:**
```markdown ```markdown
#region FixtureManifest [C:3] [TYPE ADR] [SEMANTICS test,fixture,[DOMAIN]] #region Example.Fixturemanifest [C:3] [TYPE ADR] [SEMANTICS test,fixture,[DOMAIN]]
@defgroup Fixtures Canonical test fixtures for [FEATURE]. @defgroup Fixtures Canonical test fixtures for [FEATURE].
## @{ Fixture FX_Auth.Login.Valid [C:2] [TYPE Block] [SEMANTICS test,auth,fixture] ## @{ Fixture FX_Auth.Login.Valid [C:2] [TYPE Block] [SEMANTICS test,auth,fixture]
@@ -287,6 +290,10 @@ specs/<feature>/fixtures/
@TEST_INVARIANT: env_reset_selection -> VERIFIED_BY: [Test.Migration.Model] @TEST_INVARIANT: env_reset_selection -> VERIFIED_BY: [Test.Migration.Model]
@TEST_FIXTURE: env_reset -> fixtures/model/migration_env_reset.json @TEST_FIXTURE: env_reset -> fixtures/model/migration_env_reset.json
## @} Fixture FX_Migration.EnvReset ## @} Fixture FX_Migration.EnvReset
## @} FX_Migration.EnvReset
## @} FX_Auth.Login.MissingPassword
## @} FX_Auth.Login.Valid
# #endregion Example.Fixturemanifest
``` ```
**JSON fixture format:** **JSON fixture format:**
@@ -329,19 +336,36 @@ Extend `traceability.md` with a Fixture column:
### Quickstart Output ### Quickstart Output
Generate `quickstart.md` using real repository verification paths: Generate `quickstart.md` using real repository verification paths via the root Makefile (timeout-protected, tiered):
- Backend: `cd backend && source .venv/bin/activate && python -m pytest -v`
- Frontend: `cd frontend && npm run test` ```bash
- Lint: `cd backend && python -m ruff check .` # Tier 1: Fast unit tests (<120s, no Docker)
- Frontend lint: `cd frontend && npm run lint` make test # backend + frontend unit tests
- Docker: `docker compose up --build` make test-unit # backend SQLite tests only
make test-frontend # frontend vitest tests only
# Tier 2: Smart selection
make test-related F=backend/src/path/to/file.py # only tests linked via @RELATION BINDS_TO
# Tier 3: Integration tests (Docker required, <600s)
make test-integration
# Coverage
make coverage # backend pytest-cov + frontend vitest v8
# Linting
make lint # ruff + eslint
# Docker
docker compose up --build
```
### Traceability Matrix Output ### Traceability Matrix Output
If UX contracts exist (`contracts/ux/` was generated by `/speckit.ux`), generate `traceability.md` — a requirements traceability matrix (RTM) mapping every user story through its implementation chain: If UX contracts exist (`contracts/ux/` was generated by `/speckit.ux`), generate `traceability.md` — a requirements traceability matrix (RTM) mapping every user story through its implementation chain:
```markdown ```markdown
#region Traceability [C:3] [TYPE ADR] [SEMANTICS traceability,rtm,[DOMAIN]] #region Std.Opencode.Traceability [C:3] [TYPE ADR] [SEMANTICS traceability,rtm,[DOMAIN]]
@defgroup Trace Matrix Requirements → Model → API → Task → Test for [FEATURE]. @defgroup Trace Matrix Requirements → Model → API → Task → Test for [FEATURE].
## Traceability Matrix ## Traceability Matrix
@@ -358,7 +382,7 @@ If UX contracts exist (`contracts/ux/` was generated by `/speckit.ux`), generate
| `GET /api/dashboards` | FX_Dashboards.Hub.* | Test.Dashboards.Hub | /dashboards, /migration | | `GET /api/dashboards` | FX_Dashboards.Hub.* | Test.Dashboards.Hub | /dashboards, /migration |
| `Dashboards.Hub` model | FX_Dashboards.EnvReset | Test.Dashboards.Hub | /dashboards | | `Dashboards.Hub` model | FX_Dashboards.EnvReset | Test.Dashboards.Hub | /dashboards |
#endregion Traceability #endregion Std.Opencode.Traceability
``` ```
**Generation rules:** **Generation rules:**

View File

@@ -98,12 +98,13 @@ Each story phase must end with:
- a verification task against `ux_reference.md` interpreted as the operator/caller interaction contract - a verification task against `ux_reference.md` interpreted as the operator/caller interaction contract
- a semantic audit / verification task tied to repository validators and touched contracts - a semantic audit / verification task tied to repository validators and touched contracts
Typical verification tasks may include: Typical verification tasks may include (all timeout-protected via root Makefile):
- `cd backend && source .venv/bin/activate && python -m pytest backend/tests/test_*.py -v` - `make test-unit` — backend unit tests (SQLite, no Docker, <120s)
- `cd backend && python -m ruff check .` - `make test-frontend` frontend vitest tests
- `cd frontend && npm run lint` - `make test-related F=path/to/changed_file.py` smart selection via @RELATION BINDS_TO
- `cd frontend && npm run test` - `make lint` ruff + eslint
- `cd frontend && npm run build` - `make coverage` backend + frontend coverage reports
- `cd frontend && npm run build` production build check
Only include the commands that are truly required by the feature scope. Only include the commands that are truly required by the feature scope.

View File

@@ -21,9 +21,15 @@ Run the full verification loop for the touched superset-tools scope:
1. **Mocking discipline audit** — scan every test file in scope, classify every mock/spy/stub/patch, flag violations 1. **Mocking discipline audit** — scan every test file in scope, classify every mock/spy/stub/patch, flag violations
2. **Auto-fix violations** — correct SUT mocks and Logic Mirrors (no flag needed; fix by default) 2. **Auto-fix violations** — correct SUT mocks and Logic Mirrors (no flag needed; fix by default)
3. **Semantic audit** — contract density, belief runtime, rejected-path regression 3. **Semantic audit** — contract density, belief runtime, rejected-path regression
4. **Executable tests** — run pytest + vitest + lint 4. **Executable tests** — run pytest + vitest + lint via `make test` (tiered, timeout-protected)
5. **Documentation** — mock audit report + coverage summary + ADR guardrail status 5. **Documentation** — mock audit report + coverage summary + ADR guardrail status
**When to use `/speckit.test` vs `/test.*`:** Use this command for COMPREHENSIVE audit (mocking + semantic + execution) on a feature batch. For quick verify loops during development (just run tests, no audit), use the lightweight alternatives:
- `/test.unit` — fast unit tests (backend + frontend, <30s)
- `/test.related` only tests linked to a changed file via `@RELATION BINDS_TO`
- `/test.coverage` coverage reports only
- `/test.all` full suite + coverage
## Operating Constraints ## Operating Constraints
### Golden Rules (from `semantics-testing` skill) ### Golden Rules (from `semantics-testing` skill)
@@ -221,20 +227,35 @@ For UI features, use browser validation via `chrome-devtools` MCP.
### 8. Execute Verifiers ### 8. Execute Verifiers
Run the full verification stack for the touched scope: Run the full verification stack for the touched scope. The project Makefile provides tiered targets with built-in timeout protection:
```bash ```bash
# Backend # Tier 1: Fast unit tests (no Docker, <120s timeout)
cd backend && source .venv/bin/activate && python -m pytest -v make test-unit # backend SQLite tests
python -m ruff check backend/src/ backend/tests/ make test-frontend # frontend vitest tests
# Frontend # Tier 1 alt: Smart test selection (only tests related to changed files)
cd frontend && npm run test make test-related F=backend/src/path/to/changed_file.py
npm run lint
npm run build # Linting (ruff + eslint)
make lint
# Coverage (optional — run after tests pass)
make coverage
# Tier 2: Integration tests (requires Docker, <600s timeout)
# Only when the scope includes integration boundaries
make test-integration
# Full suite (unit + integration + coverage)
make test-all
``` ```
Use narrower test runs when sufficient, then widen verification when finalizing. **Timeout safety**: All `make test-*` targets are wrapped with `timeout N` shell guards. Unit tests have 120s; integration tests have 600s. The agent NEVER hangs on a hung test.
**Narrow-first principle**: Start with `make test-unit` for backend changes, `make test-frontend` for frontend changes. Use `make test-related F=<file>` to run only semantically-linked tests. Widen to `make test` (both layers) when finalizing.
**When to run integration tests**: Only when the scope includes files under `backend/tests/integration/` or when the change touches Docker/testcontainers fixtures. Otherwise, skip.
### 9. Test Documentation ### 9. Test Documentation
@@ -326,9 +347,9 @@ Produce a single Markdown test report containing all of the following sections:
``` ```
### 2. Coverage Summary ### 2. Coverage Summary
- Commands executed - Commands executed: `make coverage` (backend pytest-cov + frontend vitest v8)
- Pass/fail counts per layer - Pass/fail counts per layer
- Coverage percentage (if available) - Coverage percentage: backend statement/line %, frontend statement/line/function/branch % with threshold comparison
### 3. Semantic Audit Verdict ### 3. Semantic Audit Verdict
- Contract density check results - Contract density check results

View File

@@ -200,7 +200,7 @@ After all questions are answered, create TWO artifacts:
**`contracts/ux/alternatives.md`** — all options considered, BEFORE final choice: **`contracts/ux/alternatives.md`** — all options considered, BEFORE final choice:
```markdown ```markdown
#region UxAlternatives [C:3] [TYPE ADR] [SEMANTICS ux,alternatives,[DOMAIN]] #region Std.Opencode.UxAlternatives [C:3] [TYPE ADR] [SEMANTICS ux,alternatives,[DOMAIN]]
@defgroup Ux Design alternatives explored for [FEATURE]. @defgroup Ux Design alternatives explored for [FEATURE].
## Screen: [Name] ## Screen: [Name]
@@ -225,13 +225,13 @@ After all questions are answered, create TWO artifacts:
- ❌ Rejected: Confirm dialog — extra click on every action, annoying at scale - ❌ Rejected: Confirm dialog — extra click on every action, annoying at scale
- ❌ Rejected: No confirmation — dangerous for delete/migrate - ❌ Rejected: No confirmation — dangerous for delete/migrate
#endregion UxAlternatives #endregion Std.Opencode.UxAlternatives
``` ```
**`contracts/ux/decisions.md`** — only the final choices: **`contracts/ux/decisions.md`** — only the final choices:
```markdown ```markdown
#region UxDecisions [C:3] [TYPE ADR] [SEMANTICS ux,decisions,[DOMAIN]] #region Std.Opencode.UxDecisions [C:3] [TYPE ADR] [SEMANTICS ux,decisions,[DOMAIN]]
@defgroup Ux Final UX design decisions for [FEATURE]. @defgroup Ux Final UX design decisions for [FEATURE].
## Screen: [Name] ## Screen: [Name]
@@ -240,7 +240,7 @@ After all questions are answered, create TWO artifacts:
- Data: Paginated (20/page) + search - Data: Paginated (20/page) + search
- Feedback: Undo toast (5s) for destructive actions - Feedback: Undo toast (5s) for destructive actions
#endregion UxDecisions #endregion Std.Opencode.UxDecisions
``` ```
**Rule:** `alternatives.md` shows the DESIGN SPACE — agent can see WHY each path was rejected. `decisions.md` is the compact reference for `/speckit.plan`. **Rule:** `alternatives.md` shows the DESIGN SPACE — agent can see WHY each path was rejected. `decisions.md` is the compact reference for `/speckit.plan`.

View File

@@ -0,0 +1,107 @@
---
description: "Run full test suite: backend unit tests, frontend vitest, coverage reports. Use as final verification gate."
handoffs:
- label: "Fix Test Failures"
agent: "fullstack-coder"
prompt: "Fix the following test failures from the full test suite run. Review the error output and implement fixes."
condition: "Tests failed"
- label: "Coverage Deep Dive"
agent: "qa-tester"
prompt: "Review the coverage report. Identify uncovered critical paths and propose additional tests."
condition: "Coverage thresholds not met"
tools: "bash, grep, read"
---
## User Input
$ARGUMENTS
## Goal
Run the COMPLETE test suite across both backend and frontend, producing a unified pass/fail + coverage report. This is the **final verification gate** before code review or merge.
## Required Skills
MANDATORY USE `skill({name="semantics-testing"})` — test conventions, anti-tautology rules, tier markers.
MANDATORY USE `skill({name="molecular-cot-logging"})` — structured logging during execution.
## Execution Steps
### 1. Pre-flight checks
```bash
# Verify venv exists
ls backend/.venv/bin/activate || echo "MISSING VENV"
# Verify node_modules exists
ls frontend/node_modules/.package-lock.json || echo "MISSING NODE_MODULES"
```
If either is missing, report the issue and STOP — do not attempt to install.
### 2. Run backend unit tests (Tier 1 — fast)
```bash
make test-unit
```
Expected: <120s. If tests fail, collect the failure output and handoff to Fix Test Failures.
### 3. Run backend integration tests (Tier 2 — Docker required)
Only if `--run-integration` is passed in $ARGUMENTS:
```bash
make test-integration
```
Expected: <600s. If Docker is not running or tests time out, report which integration tests passed/failed and continue with partial results.
### 4. Run frontend vitest tests (Tier 1 — fast)
```bash
make test-frontend
```
If tests fail, collect the failure output.
### 5. Run E2E tests (optional — requires running app)
Only if `--e2e` is passed in $ARGUMENTS:
```bash
make test-e2e
```
### 6. Generate coverage reports
```bash
make coverage
```
Review coverage percentages:
- Backend: check `backend/htmlcov/index.html` or term report
- Frontend: check `frontend/coverage/index.html`
### 7. Linting gate
```bash
make lint
```
## Output Format
```
## Full Test Suite Results
### Backend Unit Tests
- Total: N | Passed: N | Failed: N | Skipped: N
- Time: X.Xs
- [PASS/FAIL]
### Backend Integration Tests (if run)
- Total: N | Passed: N | Failed: N | Skipped: N
- Time: X.Xs
### Frontend Tests
- Total: N | Passed: N | Failed: N
- Time: X.Xs
### Coverage
- Backend: XX% (threshold: N/A)
- Frontend: XX% (threshold: 98% stmts, 95% funcs, 80% branches)
### Linting
- Backend (ruff): [PASS/FAIL]
- Frontend (eslint): [PASS/FAIL]
### Overall: [ALL_PASS / FAILURES_DETECTED]
```
## Constraints
- NEVER run `pip install` or `npm install` report missing deps and stop.
- If a test tier times out, report partial results rather than nothing.
- For integration tests: if Docker is not available, skip gracefully and note "Docker not available".
- Respect the anti-loop protocol: at attempt 3, re-check environment; at attempt 4, escalate.

View File

@@ -0,0 +1,82 @@
---
description: "Generate coverage reports for both backend and frontend, and verify against thresholds."
handoffs:
- label: "Improve Coverage"
agent: "qa-tester"
prompt: "Coverage is below threshold. Identify uncovered critical paths and propose additional tests. Current uncovered areas: $ARGUMENTS"
condition: "Coverage below threshold"
tools: "bash, read, grep"
---
## User Input
$ARGUMENTS
## Goal
Generate test coverage reports for backend (pytest-cov) and frontend (vitest v8), and verify that coverage meets project thresholds.
## Required Skills
MANDATORY USE `skill({name="semantics-testing"})` — coverage conventions.
## Execution Steps
### 1. Generate coverage reports
```bash
make coverage
```
### 2. Review backend coverage
```bash
# Detailed terminal output with uncovered lines
cd backend && source .venv/bin/activate && python -m pytest tests/ --ignore=tests/integration/ --cov=src --cov-report=term-missing 2>&1 | tail -50
```
Key metrics to extract:
- Overall statement coverage (%)
- Files with <80% coverage (list top 5 offenders)
- Files with 0% coverage (untested)
### 3. Review frontend coverage
```bash
# Frontend coverage (also available via make coverage-frontend)
cd frontend && npx vitest run --coverage 2>&1 | tail -50
```
Frontend thresholds in vitest.config.js:
- Statements: 98%
- Lines: 98%
- Functions: 95%
- Branches: 80%
### 4. Check against thresholds
If any threshold is not met, identify the specific files/modules dragging coverage down.
### 5. (Optional) Open HTML reports
```bash
ls backend/htmlcov/index.html && echo "Backend report: backend/htmlcov/index.html"
ls frontend/coverage/index.html && echo "Frontend report: frontend/coverage/index.html"
```
## Output Format
```
## Coverage Report
### Backend (pytest-cov)
- Statement Coverage: XX%
- Files below 80%: N (list top 3-5)
- Untested files: N (list top 3-5)
### Frontend (vitest v8)
- Statement Coverage: XX% (threshold: 98%) [PASS/FAIL]
- Line Coverage: XX% (threshold: 98%) [PASS/FAIL]
- Function Coverage: XX% (threshold: 95%) [PASS/FAIL]
- Branch Coverage: XX% (threshold: 80%) [PASS/FAIL]
### HTML Reports
- Backend: backend/htmlcov/index.html
- Frontend: frontend/coverage/index.html
### Overall: [ALL_THRESHOLDS_MET / BELOW_THRESHOLD]
```
## Constraints
- Coverage is generated from unit tests ONLY (no Docker integration tests).
- If vitest coverage fails with "threshold not met", report which files are below threshold.
- Do NOT modify source code to artificially increase coverage.

View File

@@ -0,0 +1,82 @@
---
description: "Find and run tests related to a specific source file using @RELATION BINDS_TO annotations."
handoffs:
- label: "Fix Related Test Failures"
agent: "fullstack-coder"
prompt: "Fix the test failures in the related tests. The source file that triggered them is: $ARGUMENTS"
condition: "Tests failed"
- label: "Add Missing Test Relations"
agent: "semantic-curator"
prompt: "Add @RELATION BINDS_TO annotations to connect the source file to its test files. The test selector found no matches for: $ARGUMENTS"
condition: "No related tests found"
tools: "bash, grep, axiom_search, read"
---
## User Input
$ARGUMENTS
## Goal
Given a source file path, find and run ONLY the tests that are semantically related to that file. This uses the `@RELATION BINDS_TO -> [ModuleName]` annotations in test files to trace dependencies.
This is the **most efficient verification** — avoid running the full suite when only one module changed.
## Required Skills
MANDATORY USE `skill({name="semantics-testing"})` — BINDS_TO conventions, test contracts.
MANDATORY USE `skill({name="semantics-contracts"})` — relation syntax, verifiable edit loop.
## Execution Steps
### 1. Identify the source file
$ARGUMENTS should be a path to a source file (e.g., `backend/src/plugins/migration.py`). If the user provides a directory, pick the most recently modified file or ask for clarification.
### 2. Run the smart test selector
```bash
make test-related F="$ARGUMENTS"
```
Or directly:
```bash
python3 scripts/find-related-tests.py --file "$ARGUMENTS" --verbose --run
```
This script:
- Extracts module/class names from the source file (#region anchors, class/function defs)
- Searches all test files for `@RELATION BINDS_TO -> [ModuleName]` annotations
- Returns matching test files with confidence scores (exact > case-insensitive > substring > heuristic)
### 3. Interpret results
**If tests are found and pass:** ✅ Report success.
**If tests are found and fail:** Read the failing test code, identify root cause, handoff to Fix Related Test Failures.
**If no related tests found:** Two possibilities:
1. The source file genuinely has no tests — report as coverage gap.
2. The `@RELATION BINDS_TO` annotation is missing from the test file — handoff to semantic-curator for annotation.
### 4. (Optional) Verify with axiom
If the smart selector found 0 results, try axiom's semantic search as a fallback:
```
axiom_search operation="trace_related_tests" contract_id="<module_contract_id>"
```
## Output Format
```
## Related Test Results for `$ARGUMENTS`
### Matched Tests
- [exact] backend/tests/plugins/test_migration_plugin.py (via 'MigrationPlugin')
- [substr] backend/tests/api/test_migration.py (via 'MigrationApi')
### Results
- Total: N | Passed: N | Failed: N
- Time: X.Xs
### Coverage Gap (if no tests found)
- Source file has no linked tests.
- Recommended: create test file with @RELATION BINDS_TO -> [ModuleName]
```
## Constraints
- NEVER run the full test suite as a fallback — only matched tests.
- If the selector finds 20+ related tests, report the count and ask if user wants to run all or narrow scope.
- Heuristic matches (score=0) should be clearly flagged as low-confidence.

View File

@@ -0,0 +1,66 @@
---
description: "Run fast unit tests only (backend SQLite + frontend vitest). Designed for agent verify loop — runs in <30s."
handoffs:
- label: "Fix Test Failures"
agent: "fullstack-coder"
prompt: "Fix the following test failures from the unit test run. Review the error output and implement fixes."
condition: "Tests failed"
tools: "bash, grep, read"
---
## User Input
$ARGUMENTS
## Goal
Run ONLY fast unit tests on both backend and frontend. This is the **default verification step** during development should complete in <30s with no Docker dependency.
## Required Skills
MANDATORY USE `skill({name="semantics-testing"})` test conventions, anti-tautology rules.
## Execution Steps
### 1. Run backend unit tests
```bash
make test-unit
```
This excludes `tests/integration/` and uses SQLite in-memory/temp-file databases. No Docker required.
If tests fail:
- Read the failing test file to understand the contract
- Check if the failure is in code you just changed
- Handoff to Fix Test Failures if needed
### 2. Run frontend unit tests
```bash
make test-frontend
```
This runs vitest with jsdom environment. All SvelteKit imports are mocked.
### 3. Linting (quick gate)
```bash
make lint
```
## Output Format
```
## Unit Test Results
### Backend (pytest)
- Total: N | Passed: N | Failed: N | Skipped: N
- Time: X.Xs
### Frontend (vitest)
- Total: N | Passed: N | Failed: N
### Linting
- Backend: [PASS/FAIL]
- Frontend: [PASS/FAIL]
### Overall: [PASS / FAIL]
```
## Constraints
- NEVER run `pip install` or `npm install`.
- This target MUST complete in <120s (enforced by timeout wrapper).
- If tests time out, report which files passed and which timed out.
- For agent-driven fix loops: run `make test-unit` after every backend change, `make test-frontend` after every frontend change.

View File

@@ -9,11 +9,6 @@
"command": ["npx", "chrome-devtools-mcp@latest", "command": ["npx", "chrome-devtools-mcp@latest",
"--browser-url=http://127.0.0.1:9222" ], "--browser-url=http://127.0.0.1:9222" ],
"enabled": true "enabled": true
},
"axiom": {
"type": "local",
"command": ["sh", "-c","/home/busya/dev/axiom-mcp-rust-port/target/release/axiom-mcp-server-rs 2>>/tmp/axiom-server.log"],
"enabled": true
} }
} }
} }

View File

@@ -3,7 +3,7 @@ name: molecular-cot-logging
description: Structured logging protocol for agent-driven development, based on molecular Long CoT bonds (REASON/REFLECT/EXPLORE). Replaces legacy Entry/Exit/Coherence markers. Python + Svelte. description: Structured logging protocol for agent-driven development, based on molecular Long CoT bonds (REASON/REFLECT/EXPLORE). Replaces legacy Entry/Exit/Coherence markers. Python + Svelte.
--- ---
#region MolecularCoTLogging [C:5] [TYPE Skill] [SEMANTICS reasoning,runtime,logging,agentic] #region Std.Opencode.MolecularCoTLogging [C:5] [TYPE Skill] [SEMANTICS reasoning,runtime,logging,agentic]
@BRIEF Structured logging protocol for agent-driven development, based on molecular Long CoT bonds (Deep-Reasoning, Self-Reflection, Self-Exploration). Replaces legacy Entry/Exit/Coherence markers. @BRIEF Structured logging protocol for agent-driven development, based on molecular Long CoT bonds (Deep-Reasoning, Self-Reflection, Self-Exploration). Replaces legacy Entry/Exit/Coherence markers.
@RELATION DEPENDS_ON -> [Std.Semantics.Core] @RELATION DEPENDS_ON -> [Std.Semantics.Core]
@RELATION DISPATCHES -> [Std.Semantics.Python] @RELATION DISPATCHES -> [Std.Semantics.Python]
@@ -303,4 +303,4 @@ for line in sys.stdin:
| Logging raw passwords or tokens in `payload` | Always sanitise sensitive data | | Logging raw passwords or tokens in `payload` | Always sanitise sensitive data |
| Spread markers across multiple modules without trace_id | Always propagate `trace_id` | | Spread markers across multiple modules without trace_id | Always propagate `trace_id` |
#endregion MolecularCoTLogging #endregion Std.Opencode.MolecularCoTLogging

View File

@@ -3,10 +3,10 @@ name: semantics-frontend
description: Core protocol for Svelte 5 (Runes) Components, UX State Machines, and Visual-Interactive Validation. description: Core protocol for Svelte 5 (Runes) Components, UX State Machines, and Visual-Interactive Validation.
--- ---
# [DEF:Std:Semantics:Frontend] # [DEF:Std.Opencode.Std:Semantics:Frontend]
# @COMPLEXITY: 5 # @COMPLEXITY: 5
# @PURPOSE: Canonical GRACE-Poly protocol for Svelte 5 (Runes) Components, UX State Machines, and Project UI Architecture. # @PURPOSE: Canonical GRACE-Poly protocol for Svelte 5 (Runes) Components, UX State Machines, and Project UI Architecture.
# @RELATION: DEPENDS_ON ->[Std:Semantics:Core] # @RELATION: DEPENDS_ON ->[Std.Opencode.Std:Semantics:Core]
# @INVARIANT: Frontend components MUST be verifiable by an automated GUI Judge Agent (e.g., Playwright). # @INVARIANT: Frontend components MUST be verifiable by an automated GUI Judge Agent (e.g., Playwright).
# @INVARIANT: Use Tailwind CSS exclusively. Native `fetch` is forbidden. # @INVARIANT: Use Tailwind CSS exclusively. Native `fetch` is forbidden.
@@ -55,7 +55,8 @@ Frontend logging bridges the gap between your logic and the Judge Agent's vision
You MUST strictly adhere to this AST boundary format: You MUST strictly adhere to this AST boundary format:
```html ```html
<!-- [DEF:ComponentName:Component] --> # [/DEF:Std.Opencode.Std:Semantics:Frontend]
<!-- [DEF:Std.Opencode.ComponentName:Component] -->
<script> <script>
/** /**
* @COMPLEXITY: [1-5] * @COMPLEXITY: [1-5]
@@ -104,4 +105,4 @@ You MUST strictly adhere to this AST boundary format:
{$t('actions.start')} {$t('actions.start')}
</button> </button>
</div> </div>
<!--[/DEF:ComponentName:Component] --> <!--[/DEF:Std.Opencode.ComponentName:Component] -->

View File

@@ -3,10 +3,10 @@ name: semantics-belief
description: Core protocol for Thread-Local Belief State, Runtime Chain-of-Thought (CoT), and Interleaved Thinking in Python. description: Core protocol for Thread-Local Belief State, Runtime Chain-of-Thought (CoT), and Interleaved Thinking in Python.
--- ---
# [DEF:Std:Semantics:Belief] # [DEF:Std.Opencode.Std:Semantics:Belief]
# @COMPLEXITY: 5 # @COMPLEXITY: 5
# @PURPOSE: Core protocol for Thread-Local Belief State, Runtime Chain-of-Thought (CoT), and Interleaved Thinking in Python. # @PURPOSE: Core protocol for Thread-Local Belief State, Runtime Chain-of-Thought (CoT), and Interleaved Thinking in Python.
# @RELATION: DEPENDS_ON -> [Std:Semantics:Core] # @RELATION: DEPENDS_ON -> [Std.Opencode.Std:Semantics:Core]
# @INVARIANT: Implementation of C4/C5 complexity nodes MUST emit reasoning via semantic logger methods before mutating state or returning. # @INVARIANT: Implementation of C4/C5 complexity nodes MUST emit reasoning via semantic logger methods before mutating state or returning.
## 0. INTERLEAVED THINKING (GLM-5 PARADIGM) ## 0. INTERLEAVED THINKING (GLM-5 PARADIGM)
@@ -53,5 +53,5 @@ If your execution path triggers a `logger.explore()` due to a broken assumption
**YOU MUST ASCEND TO THE `[DEF]` HEADER AND DOCUMENT IT.** **YOU MUST ASCEND TO THE `[DEF]` HEADER AND DOCUMENT IT.**
You must add `@RATIONALE: [Why you did this]` and `@REJECTED:[The path that failed during explore()]`. You must add `@RATIONALE: [Why you did this]` and `@REJECTED:[The path that failed during explore()]`.
Failure to link a runtime `explore` to a static `@REJECTED` tag is a fatal protocol violation that causes amnesia for future agents. Failure to link a runtime `explore` to a static `@REJECTED` tag is a fatal protocol violation that causes amnesia for future agents.
# [/DEF:Std:Semantics:Belief] # [/DEF:Std.Opencode.Std:Semantics:Belief]
**[SYSTEM: END OF BELIEF DIRECTIVE. ENFORCE STRICT RUNTIME CoT.]** **[SYSTEM: END OF BELIEF DIRECTIVE. ENFORCE STRICT RUNTIME CoT.]**

View File

@@ -97,18 +97,18 @@ Not all GRACE tags are equal in the model's training data. Understanding which t
### Legacy — DEF (permanently recognized) ### Legacy — DEF (permanently recognized)
```python ```python
// [DEF:ContractId:Type] // [DEF:Std.Opencode.ContractId:Type]
// @TAG: value // @TAG: value
<code> <code>
// [/DEF:ContractId:Type] // [/DEF:Std.Opencode.ContractId:Type]
``` ```
### Doc — Brace (Markdown, specs, ADRs) ### Doc — Brace (Markdown, specs, ADRs)
``` ```
## @{ ContractId [C:N] [TYPE TypeName] ## @{ Std.Opencode.ContractId [C:N] [TYPE TypeName]
@BRIEF Description @BRIEF Description
... ...
## @} ContractId ## @} Std.Opencode.ContractId
``` ```
**Allowed Types:** Module, Function, Class, Component, Model, Block, ADR, Tombstone, Skill, Agent. **Allowed Types:** Module, Function, Class, Component, Model, Block, ADR, Tombstone, Skill, Agent.
@@ -260,6 +260,7 @@ The opening anchor MUST pack maximum signal into one line:
``` ```
#region Domain.Sub.ContractId [C:N] [TYPE TypeName] [SEMANTICS tag1,tag2,tag3] #region Domain.Sub.ContractId [C:N] [TYPE TypeName] [SEMANTICS tag1,tag2,tag3]
# #endregion Domain.Sub.ContractId
``` ```
- ID, complexity, type, and semantic tags on ONE line → survives CSA 4× pooling as a single KV record. - ID, complexity, type, and semantic tags on ONE line → survives CSA 4× pooling as a single KV record.
@@ -303,6 +304,7 @@ Example — both mechanisms reinforce each other:
#region Core.Auth.Login [C:4] [TYPE Function] [SEMANTICS auth,login,token] #region Core.Auth.Login [C:4] [TYPE Function] [SEMANTICS auth,login,token]
# @ingroup Auth # @ingroup Auth
# @BRIEF Authenticate user by credentials. # @BRIEF Authenticate user by credentials.
# #endregion Core.Auth.Login
``` ```
**Rule:** Identical domain = identical primary keyword in `[SEMANTICS ...]` AND identical `@ingroup Domain`. They target different compression layers (CSA vs HCA) and don't conflict — the keyword repetition amplifies the DSA score. **Rule:** Identical domain = identical primary keyword in `[SEMANTICS ...]` AND identical `@ingroup Domain`. They target different compression layers (CSA vs HCA) and don't conflict — the keyword repetition amplifies the DSA score.

View File

@@ -7,7 +7,7 @@ description: Python-specific GRACE-Poly protocol: few-shot complexity examples,
@BRIEF Python-specific HOW: few-shot complexity examples, belief runtime patterns, module decomposition, and FastAPI/SQLAlchemy conventions for the GRACE-Poly protocol in superset-tools. @BRIEF Python-specific HOW: few-shot complexity examples, belief runtime patterns, module decomposition, and FastAPI/SQLAlchemy conventions for the GRACE-Poly protocol in superset-tools.
@RELATION DEPENDS_ON -> [Std.Semantics.Core] @RELATION DEPENDS_ON -> [Std.Semantics.Core]
@RELATION DEPENDS_ON -> [Std.Semantics.Contracts] @RELATION DEPENDS_ON -> [Std.Semantics.Contracts]
@RELATION DISPATCHES -> [MolecularCoTLogging] @RELATION DISPATCHES -> [Std.Opencode.MolecularCoTLogging]
@RESTRICTION EXAMPLES ONLY — this file provides language-specific code patterns. All protocol rules (tier definitions, tag catalog, anchor syntax) are defined exclusively in `semantics-core`. This file MUST NOT redefine or contradict any rule from `semantics-core`. @RESTRICTION EXAMPLES ONLY — this file provides language-specific code patterns. All protocol rules (tier definitions, tag catalog, anchor syntax) are defined exclusively in `semantics-core`. This file MUST NOT redefine or contradict any rule from `semantics-core`.
@RATIONALE Python's async/await model, FastAPI dependency injection, and SQLAlchemy session management create unique failure modes for Transformer agents: (1) async/await boundary confusion — agents write sync code in async contexts or forget `await` on ORM calls, producing silent no-ops; (2) dependency injection blindness — FastAPI's `Depends()` creates implicit call graphs that the agent's attention cannot trace without explicit @RELATION edges; (3) session lifecycle drift — SQLAlchemy sessions have strict boundaries that agents violate by passing detached objects across function calls. Concrete examples at each complexity tier act as few-shot anchors that override the agent's pre-trained (and often wrong) Python patterns. @RATIONALE Python's async/await model, FastAPI dependency injection, and SQLAlchemy session management create unique failure modes for Transformer agents: (1) async/await boundary confusion — agents write sync code in async contexts or forget `await` on ORM calls, producing silent no-ops; (2) dependency injection blindness — FastAPI's `Depends()` creates implicit call graphs that the agent's attention cannot trace without explicit @RELATION edges; (3) session lifecycle drift — SQLAlchemy sessions have strict boundaries that agents violate by passing detached objects across function calls. Concrete examples at each complexity tier act as few-shot anchors that override the agent's pre-trained (and often wrong) Python patterns.
@REJECTED Generic Python patterns without GRACE anchors were rejected — agents produce working code that violates module size limits (INV_7), omits belief runtime markers, and creates orphan contracts invisible to the semantic index. Relying on the agent's pre-trained FastAPI/SQLAlchemy knowledge without project-specific examples was rejected — superset-tools has specific conventions (trace_id propagation, plugin architecture, WebSocket logging) that general training data cannot capture. @REJECTED Generic Python patterns without GRACE anchors were rejected — agents produce working code that violates module size limits (INV_7), omits belief runtime markers, and creates orphan contracts invisible to the semantic index. Relying on the agent's pre-trained FastAPI/SQLAlchemy knowledge without project-specific examples was rejected — superset-tools has specific conventions (trace_id propagation, plugin architecture, WebSocket logging) that general training data cannot capture.

View File

@@ -6,7 +6,7 @@ description: Svelte 5 (Runes) protocol for superset-tools: UX State Machines, Ta
#region Std.Semantics.Svelte [C:5] [TYPE Skill] [SEMANTICS frontend,svelte,ui,ux,tailwind] #region Std.Semantics.Svelte [C:5] [TYPE Skill] [SEMANTICS frontend,svelte,ui,ux,tailwind]
@BRIEF HOW to build Svelte 5 (Runes) Components for superset-tools with UX State Machines, Tailwind CSS, store topology, and visual-interactive validation. @BRIEF HOW to build Svelte 5 (Runes) Components for superset-tools with UX State Machines, Tailwind CSS, store topology, and visual-interactive validation.
@RELATION DEPENDS_ON -> [Std.Semantics.Core] @RELATION DEPENDS_ON -> [Std.Semantics.Core]
@RELATION DEPENDS_ON -> [MolecularCoTLogging] @RELATION DEPENDS_ON -> [Std.Opencode.MolecularCoTLogging]
@RELATION DISPATCHES -> [Std.Semantics.Testing] @RELATION DISPATCHES -> [Std.Semantics.Testing]
@RESTRICTION EXAMPLES ONLY — this file provides language-specific code patterns. All protocol rules (tier definitions, tag catalog, anchor syntax) are defined exclusively in `semantics-core`. UX contract tags are defined here as examples; the tag catalog lives in `semantics-core` §III. This file MUST NOT redefine or contradict any rule from `semantics-core`. @RESTRICTION EXAMPLES ONLY — this file provides language-specific code patterns. All protocol rules (tier definitions, tag catalog, anchor syntax) are defined exclusively in `semantics-core`. UX contract tags are defined here as examples; the tag catalog lives in `semantics-core` §III. This file MUST NOT redefine or contradict any rule from `semantics-core`.
@RATIONALE Svelte 5 runes ($state, $derived, $effect, $props) chosen for reactive precision and native compiler optimisations over Svelte 4 legacy reactivity ($:). Tailwind CSS selected for zero-runtime utility-first styling and rapid visual validation via chrome-devtools MCP. FSM-based UX contracts (@UX_STATE, @UX_FEEDBACK, @UX_RECOVERY) chosen to create verifiable state-transition tests that the browser Judge Agent can execute deterministically. superset-tools internal API wrappers (fetchApi/requestApi) chosen over native fetch to enforce auth, error normalisation, and trace_id propagation. Model-first architecture chosen because event-handler spaghetti is the #1 Transformer failure mode in UI code: the agent scatters logic across onclick/onchange in 5 files — KV-cache cannot hold cross-component relationships, creating invisible coupling that breaks silently. @RATIONALE Svelte 5 runes ($state, $derived, $effect, $props) chosen for reactive precision and native compiler optimisations over Svelte 4 legacy reactivity ($:). Tailwind CSS selected for zero-runtime utility-first styling and rapid visual validation via chrome-devtools MCP. FSM-based UX contracts (@UX_STATE, @UX_FEEDBACK, @UX_RECOVERY) chosen to create verifiable state-transition tests that the browser Judge Agent can execute deterministically. superset-tools internal API wrappers (fetchApi/requestApi) chosen over native fetch to enforce auth, error normalisation, and trace_id propagation. Model-first architecture chosen because event-handler spaghetti is the #1 Transformer failure mode in UI code: the agent scatters logic across onclick/onchange in 5 files — KV-cache cannot hold cross-component relationships, creating invisible coupling that breaks silently.
@@ -309,7 +309,7 @@ Frontend logging uses `log()` from `$lib/cot-logger` per **MolecularCoTLogging**
Region format for HTML/Svelte comments: Region format for HTML/Svelte comments:
```html ```html
<!-- #region MigrationTaskCard [C:3] [TYPE Component] [SEMANTICS ui,migration,task] --> <!-- #region Std.Semantics.MigrationTaskCard [C:3] [TYPE Component] [SEMANTICS ui,migration,task] -->
<!-- @BRIEF Card displaying a migration task with status, progress, and action buttons. --> <!-- @BRIEF Card displaying a migration task with status, progress, and action buttons. -->
<!-- @LAYER UI --> <!-- @LAYER UI -->
<!-- @RELATION DEPENDS_ON -> [StatusBadge] --> <!-- @RELATION DEPENDS_ON -> [StatusBadge] -->
@@ -410,7 +410,7 @@ Region format for HTML/Svelte comments:
</Button> </Button>
</div> </div>
</div> </div>
<!-- #endregion MigrationTaskCard --> <!-- #endregion Std.Semantics.MigrationTaskCard -->
``` ```
## VII. SS-TOOLS DESIGN TOKEN CANON & COMPONENT REUSE ## VII. SS-TOOLS DESIGN TOKEN CANON & COMPONENT REUSE

View File

@@ -90,7 +90,7 @@ from unittest.mock import AsyncMock, patch
class TestDashboardMigration: class TestDashboardMigration:
"""Verify migrate_dashboard @POST guarantees.""" """Verify migrate_dashboard @POST guarantees."""
# #region test_migrate_dashboard_success [C:2] [TYPE Function] # #region Std.Semantics.TestMigrateDashboardSuccess [C:2] [TYPE Function]
# @BRIEF Happy path: valid dashboard with complete db mapping. # @BRIEF Happy path: valid dashboard with complete db mapping.
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_migrate_dashboard_success(self): async def test_migrate_dashboard_success(self):
@@ -98,8 +98,9 @@ class TestDashboardMigration:
expected = {"id": "dash_1", "status": "imported"} expected = {"id": "dash_1", "status": "imported"}
# ... test implementation # ... test implementation
pass pass
# #endregion test_migrate_dashboard_success # #endregion Std.Semantics.TestMigrateDashboardSuccess
# #endregion TestDashboardMigration # #endregion TestDashboardMigration
# #endregion Test.Migration.RunTask
``` ```
### Running tests ### Running tests

View File

@@ -1,4 +1,4 @@
#region FeatureSpec [C:3] [TYPE ADR] [SEMANTICS spec,requirements,feature] #region Std.Specify.FeatureSpec [C:3] [TYPE ADR] [SEMANTICS spec,requirements,feature]
@BRIEF Feature specification — WHAT the user needs and WHY. Implementation-free. Survives HCA 128× via @SEMANTICS grouping. @BRIEF Feature specification — WHAT the user needs and WHY. Implementation-free. Survives HCA 128× via @SEMANTICS grouping.
## Navigation (DSA Indexer keywords) ## Navigation (DSA Indexer keywords)
@@ -75,4 +75,4 @@ All stories share `@SEMANTICS` domain keywords from the feature header.
- **SC-002**: [Measurable metric] - **SC-002**: [Measurable metric]
- **SC-003**: [User-facing metric] - **SC-003**: [User-facing metric]
#endregion FeatureSpec #endregion Std.Specify.FeatureSpec

View File

@@ -1,4 +1,4 @@
#region UxReference [C:3] [TYPE ADR] [SEMANTICS ux, reference, [DOMAIN]] #region Std.Specify.UxReference [C:3] [TYPE ADR] [SEMANTICS ux, reference, [DOMAIN]]
@BRIEF UX interaction reference — persona, flows, states, and recovery paths. Drives `@UX_*` contract tags in Phase 1. @BRIEF UX interaction reference — persona, flows, states, and recovery paths. Drives `@UX_*` contract tags in Phase 1.
**Feature Branch**: `[###-feature-name]` **Feature Branch**: `[###-feature-name]`
@@ -75,4 +75,4 @@ $ command --flag value
* **Style**: [e.g. Concise, Technical, Friendly, Verbose] * **Style**: [e.g. Concise, Technical, Friendly, Verbose]
* **Terminology**: [e.g. Use "Repository" not "Repo", "Directory" not "Folder"] * **Terminology**: [e.g. Use "Repository" not "Repo", "Directory" not "Folder"]
#endregion UxReference #endregion Std.Specify.UxReference

419
INSTALL.md Normal file
View File

@@ -0,0 +1,419 @@
# Установка и настройка superset-tools
## Содержание
- [Требования](#требования)
- [Архитектура](#архитектура)
- [Технологический стек](#технологический-стек)
- [Docker (рекомендуется)](#docker-рекомендуется)
- [Локальная разработка](#локальная-разработка)
- [Конфигурация](#конфигурация)
- [AI-агент](#ai-агент)
- [Система сборки](#система-сборки)
- [Тестирование](#тестирование)
- [Покрытие кода](#покрытие-кода)
- [SSL/TLS конфигурация](#ssltls-конфигурация)
- [Enterprise Clean Deployment](#enterprise-clean-deployment)
- [Спецификации](#спецификации)
- [Утилиты](#утилиты)
- [Исследования](#исследования)
## Требования
- **Docker (рекомендуется):** Docker Engine 24+, Docker Compose v2, 4 GB RAM
- **Локальная разработка:** Python 3.9+, Node.js 18+, npm, 2 GB RAM, 5 GB диска
## Архитектура
Проект состоит из трёх сервисов:
| Сервис | Технологии | Назначение |
|---|---|---|
| **backend/** | Python FastAPI, SQLAlchemy 2.0, APScheduler, PostgreSQL | REST API, бизнес-логика, плагины |
| **frontend/** | Svelte 5 (Runes), SvelteKit, Vite, Tailwind CSS | SPA-клиент |
| **agent/** | Gradio, LangGraph, LangChain, OpenAI SDK | AI-агент с чат-интерфейсом |
| **shared/** | Python package | Общие утилиты (логирование, SSL, LLM HTTP) |
```
superset-tools/
├── backend/ # REST API (FastAPI)
│ ├── src/
│ │ ├── api/routes/ # 30+ роутов (admin, auth, translate, git, agent...)
│ │ ├── core/
│ │ │ ├── auth/ # JWT, OAuth, API Keys, RBAC
│ │ │ ├── migration/ # Dry-run, risk assessment
│ │ │ ├── task_manager/ # Async jobs, event bus, persistence
│ │ │ ├── superset_client/
│ │ │ ├── logger/ # Structured logging, belief state
│ │ │ └── ...
│ │ ├── models/ # SQLAlchemy модели
│ │ ├── plugins/ # Реализации плагинов
│ │ ├── schemas/ # Pydantic схемы
│ │ ├── services/ # Бизнес-логика
│ │ └── scripts/ # CLI/TUI админ-скрипты
│ └── tests/
├── frontend/ # SvelteKit SPA
│ ├── src/
│ │ ├── routes/ # 20+ групп страниц
│ │ ├── lib/
│ │ │ ├── api/ # API клиент
│ │ │ ├── auth/ # Auth store, permissions
│ │ │ ├── components/ # UI компоненты
│ │ │ ├── stores/ # Svelte stores
│ │ │ ├── i18n/ # Мультиязычность
│ │ │ └── ...
│ │ └── ...
│ └── tests/
├── agent/ # Gradio/LangGraph AI-агент
│ ├── src/ss_tools/agent/
│ │ ├── app.py # Gradio приложение
│ │ ├── langgraph_setup.py # LangGraph граф
│ │ ├── tools.py # LangChain инструменты
│ │ ├── document_parser.py # PDF/XLSX парсер
│ │ └── ...
│ └── tests/
├── shared/ # Общий Python пакет (ss-tools-shared)
├── docker/ # Dockerfile, entrypoint, nginx
├── docs/ # Документация, ADR
├── specs/ # 40+ feature specifications
├── scripts/ # Утилиты (coverage, security, build)
├── research/ # Исследования (mcp-superset)
├── semantics/ # Семантическая карта кода
├── dist/ # Релизные бандлы
├── storage/ # Runtime данные (backups, repos)
├── certs/ # SSL-сертификаты
└── examples/ # Примеры интеграции
```
## Технологический стек
**Backend:** Python 3.9+ (FastAPI 0.126, SQLAlchemy 2.0, APScheduler 3.11), PostgreSQL 16, Authlib, JWT, OpenAI API, GitPython, Playwright, lingua-language-detector
**Frontend:** Svelte 5 (Runes), SvelteKit 2.49, Vite 7, Tailwind CSS 3, Vitest 4.1, Playwright 1.60
**Agent:** Gradio 5.50+, LangChain Core 0.3+, LangGraph 0.2+, LangGraph Checkpoint Postgres, pdfplumber, sentence-transformers (optional)
**DevOps:** Docker & Docker Compose (3 профиля + E2E), GitHub Actions (CI), Nginx (опциональный SSL)
## Docker (рекомендуется)
### Профили окружения
Система поддерживает несколько профилей через `.env` файлы:
| Профиль | Файл | Команда |
|---|---|---|
| **current** | `.env.current` | `docker compose --profile current up --build` |
| **master** | `.env.master` | `docker compose --profile master up --build` |
| **enterprise-clean** | `.env.enterprise-clean` | `docker compose --profile enterprise-clean up --build` |
| **e2e** | `.env.e2e` | `docker compose -f docker-compose.e2e.yml up --build` |
```bash
git clone <repository-url>
cd superset-tools
cp .env.example .env
docker compose --profile current up --build
```
После запуска:
- Frontend: http://localhost:8000
- Backend API: http://localhost:8001
- PostgreSQL: localhost:5432
- Agent UI: http://localhost:8002 (gradio)
### Offline-бандл
```bash
xz -dc dist/docker/superset-tools.20260517.tar.xz | docker load
export POSTGRES_PASSWORD="my-strong-password"
docker compose -f dist/docker/docker-compose.light.yml up -d
```
## Локальная разработка
### Backend
```bash
cd backend
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements-backend.txt
pip install -e ../shared
python3 -m uvicorn src.app:app --reload --port 8000
```
### Frontend
```bash
cd frontend
npm install
npm run dev -- --port 5173
```
### Agent
```bash
cd agent
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
pip install -e ../shared
python -m ss_tools_agent
```
### Начальная настройка
```bash
# Переменные окружения
cp .env.example backend/.env
# Инициализация БД
cd backend && source .venv/bin/activate
python src/scripts/init_auth_db.py
# Создание администратора
python src/scripts/create_admin.py --username admin --password '<temporary-secret>'
```
## Конфигурация
Полный список переменных — в каждом `.env.*.example` файле.
### Основные категории
| Категория | Переменные | Описание |
|---|---|---|
| **Security** | `AUTH_SECRET_KEY`, `ENCRYPTION_KEY`, `SERVICE_JWT` | JWT-подпись, шифрование данных, сервисный токен agent→backend |
| **Database** | `DATABASE_URL`, `AUTH_DATABASE_URL`, `TASKS_DATABASE_URL` | PostgreSQL подключения |
| **Admin bootstrap** | `INITIAL_ADMIN_CREATE`, `INITIAL_ADMIN_USERNAME`, `INITIAL_ADMIN_PASSWORD`, `INITIAL_ADMIN_EMAIL` | Автосоздание admin при первом запуске |
| **LLM** | `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `LLM_BASE_URL`, `LLM_MODEL` | Провайдеры и модели |
| **Agent** | `AGENT_PORT`, `ENABLE_EMBEDDING_ROUTER` | Порт Gradio, семантический роутинг |
| **Features** | `FEATURES__DATASET_REVIEW`, `FEATURES__HEALTH_MONITOR` | Включение фич |
| **SSO** | `ADFS_CLIENT_ID`, `ADFS_CLIENT_SECRET`, `ADFS_METADATA_URL` | Active Directory Federation Services |
| **Certificates** | `CERTS_PATH`, `SSL_KEY_PASSPHRASE`, `LLM_CA_CERT_URLS` | PKI для корпоративных сетей |
| **CORS** | `ALLOWED_ORIGINS`, `FORCE_HTTPS`, `APP_TIMEZONE` | Безопасность и регион |
| **Logging** | `ENABLE_BELIEF_STATE_LOGGING`, `TASK_LOG_LEVEL` | Структурированное логирование |
| **Ports** | `BACKEND_HOST_PORT`, `FRONTEND_HOST_PORT`, `AGENT_HOST_PORT`, `POSTGRES_HOST_PORT` | Проброс портов Docker |
## AI-агент
Отдельный сервис с чат-интерфейсом для управления платформой на естественном языке:
- **LangGraph-граф** — оркестрация multi-step диалогов с постгресовой персистентностью
- **Embedding-роутинг** — семантический выбор инструмента (опционально, требует sentence-transformers)
- **Confirmation flow** — подтверждение деструктивных операций
- **Загрузка документов** — парсинг PDF/XLSX для контекстного анализа
- **Gradio UI** — веб-интерфейс
```bash
# Docker
docker compose --profile current up agent
# Локально
cd agent && pip install -r requirements.txt && python -m ss_tools_agent
```
## Система сборки
`build.sh` — унифицированная CLI-утилита:
```bash
# Сборка и запуск
./build.sh compose # docker compose up --build (current profile)
./build.sh compose:master # docker compose up (master profile)
# Индивидуальные сборки
./build.sh backend # backend image only
./build.sh frontend # frontend image only
./build.sh agent # agent image only
# Offline-бандлы
./build.sh bundle v1.0.0 # полный бандл (все сервисы)
./build.sh bundle:light v1.0.0 # light (backend + frontend)
```
Артефакты: `dist/docker/superset-tools.<version>.tar.xz` + sha256sum + manifest.
## Тестирование
### Makefile (tiered test system)
```bash
make test # Tier 1: быстрые unit-тесты backend + frontend (<30с)
make test-unit # Backend unit-тесты с SQLite (без Docker)
make test-frontend # Frontend vitest-тесты
make test-related F=path # Tier 2: умный выбор тестов для изменённого файла
make test-integration # Tier 3: backend integration с testcontainers
make test-e2e # Tier 3: Playwright E2E (требуется запущенное приложение)
make test-all # Все тесты (без установки зависимостей)
make lint # Все линтеры
make lint-backend # ruff check
make lint-frontend # eslint
make coverage # coverage обоих стеков
```
### Самостоятельный запуск
```bash
# Backend тесты
cd backend && source .venv/bin/activate && pytest
# Frontend тесты
cd frontend && npm run test
# Agent тесты
cd agent && source .venv/bin/activate && pytest
# Конкретный тест
pytest backend/tests/test_auth.py::test_create_user
```
### E2E тестирование
```bash
docker compose -f docker-compose.e2e.yml up --build
cd frontend && npm run test:e2e
```
## Покрытие кода
Сводный отчёт — `scripts/coverage-summary.sh`:
```bash
./scripts/coverage-summary.sh # Полный запуск (integration + frontend)
./scripts/coverage-summary.sh --unit # Backend unit (SQLite) + frontend
./scripts/coverage-summary.sh --frontend-only # Только frontend
./scripts/coverage-summary.sh --backend-only --unit # Только backend
./scripts/coverage-summary.sh --output-dir ./reports/coverage
```
Результат: `coverage-summary/index.html`.
### Текущие показатели
| Стек | Тип тестов | Процент | Покрытие (Stmts) |
|---|---|---|---|
| Backend (unit) | 1723 | 1721/2 ✅ | 48% |
| Backend (integration) | 167 | 167/0 ✅ | 12% |
| Frontend | 2443 | 2442/1 ✅ | 99.25% |
## SSL/TLS конфигурация
### Сертификаты для HTTPS (nginx)
Поместите файлы в `./certs/`:
**Вариант A — отдельные файлы:**
```
./certs/server.crt # SSL сертификат
./certs/server.key # Приватный ключ
```
**Вариант B — зашифрованный ключ + пароль:**
```
./certs/server.crt # SSL сертификат
./certs/server.key # Приватный ключ (зашифрован, с DEK-Info)
SSL_KEY_PASSPHRASE=my-passphrase
```
**Вариант C — PKCS#12 контейнер:**
```
./certs/server.p12 # Контейнер с сертификатом + ключом
SSL_KEY_PASSPHRASE=my-passphrase
```
Entrypoint автоматически извлекает `.crt` и `.key` из `.p12`, расшифровывает ключ и передаёт nginx (ключ остаётся в tmpfs контейнера).
### Корпоративные CA-сертификаты
Положите `.crt`/`.pem` в `./certs/` — entrypoint установит их в системное хранилище Alpine и NSS (Chromium/Playwright). Поддерживаются цепочки (Root → Intermediate).
### LLM CA-сертификаты
```bash
LLM_CA_CERT_URLS="http://pki.company.com/root-ca.crt http://pki.company.com/intermediate-ca.crt"
```
Сертификаты скачиваются на старте backend и agent контейнеров, конвертируются из DER в PEM при необходимости, устанавливаются в системное хранилище.
### Диагностика SSL
```bash
scripts/check_llm_certs.py # Полная проверка цепочки доверия
scripts/diag_container.py --target your-llm-provider.com:443 # Контейнерная диагностика
```
Подробнее — [ADR-0009](docs/adr/ADR-0009-ssl-certificate-management.md).
`LLM_SSL_VERIFY` удалён в 0.2.x — TLS verify всегда включён.
## Enterprise Clean Deployment
Разворот в корпоративной сети с очищенным дистрибутивом (без тестовых данных, запрет внешних источников, compliance-проверка):
```bash
cp .env.enterprise-clean.example .env
docker compose --profile enterprise-clean up --build
```
Поддерживаются CLI, API и TUI flows. Подробнее — [docs/enterprise-clean.md](docs/enterprise-clean.md).
## Авторизация
Два метода аутентификации:
1. **Локальная** (username/password) — JWT-токены, RBAC (admin/analyst/viewer)
2. **ADFS SSO** — Active Directory Federation Services
Управление: `POST /api/admin/users`, `POST /api/admin/roles`.
## Мониторинг
- **Dashboard Hub** — управление дашбордами с Git-статусом
- **Dataset Hub** — управление датасетами с прогрессом маппинга
- **Task Drawer** — мониторинг фоновых задач (WebSocket real-time)
- **Unified Reports** — `GET /api/reports?page=1&page_size=20` (фильтры по статусу, типу, дате)
- **Health Monitor** — мониторинг здоровья системы (через `FEATURES__HEALTH_MONITOR`)
- **Semantic Map** — автоматически генерируемая семантическая карта кода (`semantics/semantic_map.json`)
## Спецификации
В `specs/` ведётся 40+ feature specifications. Каждая включает: `spec.md`, `research.md`, `plan.md`, `contracts/modules.md`, `data-model.md`, `checklists/requirements.md`, `tasks.md`.
| # | Название | Описание |
|---|---|---|
| 011 | `git-integration-dashboard` | Git-интеграция дашбордов |
| 017 | `llm-analysis-plugin` | LLM-аналитика и валидация |
| 022 | `sync-id-cross-filters` | Sync ID cross-filters |
| 023 | `clean-repo-enterprise` | Чистый репозиторий для enterprise |
| 033 | `gradio-agent-chat` | Gradio/LangGraph AI-агент |
| 034 | `task-status-center` | Центр статуса задач |
| 038 | `dashboard-scenario-model` | Сценарная модель дашбордов |
| 041 | `dataset-lineage-blast-radius` | Lineage и blast radius датасетов |
## Примеры скриптов
Примеры интеграции с внешними системами (Airflow, CI/CD, cron) — в [`examples/`](./examples/):
- [Python](examples/maintenance-api-python.py)
- [Bash](examples/maintenance-api-bash.sh)
Аутентификация через API Key (`X-API-Key`), запуск и завершение maintenance-событий.
## Утилиты
| Скрипт | Назначение |
|---|---|
| `coverage-summary.sh` | Сводный отчёт покрытия |
| `scan_secrets.sh` | Сканирование секретов |
| `check_llm_certs.py` | Проверка SSL-сертификатов LLM |
| `diag_container.py` | SSL-диагностика |
| `find-related-tests.py` | Поиск тестов по изменённому файлу |
| `pretty_cot.py` | Форматирование CoT-логов |
| `gen_semantics.py` | Семантическая карта кода |
| `build_offline_docker_bundle.sh` | Сборка offline-бандлов |
## Исследования
- **mcp-superset** — MCP-сервер для Apache Superset (137 инструментов, PyPI). Streamable HTTP, SSE, stdio транспорты. Подробнее — [`research/mcp-superset/`](research/mcp-superset/).

87
Makefile Normal file
View File

@@ -0,0 +1,87 @@
# ss-tools test suite
# ─────────────────────────────────────────────────────────────
# Tiered test targets designed for agent-driven development:
# Tier 1 (fast, <30s): make test
# Tier 2 (slow, <5min): make test-integration
# Tier 3 (e2e, <10min): make test-e2e
#
# Smart selection: make test-related F=path/to/file.py
# Coverage: make coverage
# ─────────────────────────────────────────────────────────────
ROOT := $(CURDIR)
BACKEND := $(ROOT)/backend
FRONTEND := $(ROOT)/frontend
VENV := $(BACKEND)/.venv/bin/activate
TIMEOUT_FAST := 600
TIMEOUT_SLOW := 600
.PHONY: help test test-unit test-frontend test-related test-integration test-e2e test-all
.PHONY: coverage coverage-backend coverage-frontend
.PHONY: lint lint-backend lint-frontend
# ── Help ───────────────────────────────────────────────────
help: ## Show this help message
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \
awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-22s\033[0m %s\n", $$1, $$2}'
# ── Tier 1: Fast tests (no Docker, SQLite + jsdom) ─────────
test: test-unit test-frontend ## Run fast unit tests (backend + frontend, <30s)
@echo " ✅ All fast tests passed"
test-unit: ## Run backend unit tests (SQLite, no Docker)
@echo " ▶ Backend unit tests (SQLite)..."
@cd $(BACKEND) && . $(VENV) && timeout $(TIMEOUT_FAST) python -m pytest tests/ --ignore=tests/integration/ -v --tb=short
test-frontend: ## Run frontend vitest tests
@echo " ▶ Frontend vitest tests..."
@cd $(FRONTEND) && npx vitest run
# ── Tier 2: Smart selection ────────────────────────────────
test-related: ## Run tests related to a changed file (make test-related F=path/to/file.py)
@if [ -z "$(F)" ]; then echo "Usage: make test-related F=path/to/file.py"; exit 1; fi
@echo " ▶ Finding related tests for $(F)..."
@python3 $(ROOT)/scripts/find-related-tests.py --file "$(F)" --run
# ── Tier 3: Slow tests (requires Docker) ───────────────────
test-integration: ## Run backend integration tests (Docker + testcontainers)
@echo " ▶ Backend integration tests (Docker)..."
@cd $(BACKEND) && . $(VENV) && timeout $(TIMEOUT_SLOW) python -m pytest tests/integration/ --run-integration -v --tb=short
test-e2e: ## Run Playwright E2E tests (requires running app)
@echo " ▶ Playwright E2E tests..."
@cd $(FRONTEND) && npx playwright test
# ── Full suite ─────────────────────────────────────────────
test-all: ## Run full suite: fast tests + integration (skip installs)
@$(MAKE) test
@echo ""
@echo " ▶ Integration tests (may take several minutes)..."
@cd $(BACKEND) && . $(VENV) && timeout $(TIMEOUT_SLOW) python -m pytest tests/integration/ --run-integration -v --tb=short || echo " ⚠ Integration tests skipped (Docker not available or timed out)"
@echo ""
@echo " ✅ Full test suite complete"
# ── Coverage ───────────────────────────────────────────────
coverage: coverage-backend coverage-frontend ## Generate coverage (backend + frontend)
@echo " ✅ Coverage reports ready"
@echo " Backend: $(BACKEND)/htmlcov/index.html"
@echo " Frontend: $(FRONTEND)/coverage/index.html"
coverage-backend: ## Backend coverage (unit tests + --cov)
@echo " ▶ Backend coverage..."
@cd $(BACKEND) && . $(VENV) && timeout $(TIMEOUT_FAST) python -m pytest tests/ --ignore=tests/integration/ \
--cov=src --cov-report=term-missing --cov-report=html
coverage-frontend: ## Frontend coverage (vitest --coverage)
@echo " ▶ Frontend coverage..."
@cd $(FRONTEND) && npx vitest run --coverage
# ── Linting ────────────────────────────────────────────────
lint: lint-backend lint-frontend ## Run all linters
lint-backend: ## Backend ruff check
@cd $(BACKEND) && . $(VENV) && python -m ruff check .
lint-frontend: ## Frontend eslint
@cd $(FRONTEND) && npx eslint .

466
README.md
View File

@@ -1,369 +1,187 @@
# superset-tools # superset-tools
[![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue?logo=python)](https://www.python.org/)
[![Node 18+](https://img.shields.io/badge/node-18+-green?logo=node.js)](https://nodejs.org/)
[![License: MIT](https://img.shields.io/badge/license-MIT-yellow)](LICENSE) [![License: MIT](https://img.shields.io/badge/license-MIT-yellow)](LICENSE)
[![Docker](https://img.shields.io/badge/docker-24+-blue?logo=docker)](https://www.docker.com/)
**Инструменты автоматизации для Apache Superset: миграция, версионирование, аналитика и управление данными** **Корпоративная платформа для управления Apache Superset, перевода данных с помощью LLM и безопасной доставки аналитики между окружениями.**
## 📋 Содержание superset-tools помогает превратить набор разрозненных дашбордов, ручных переносов и служебных скриптов в управляемый процесс: с версиями, согласованиями, аудитом, фоновым выполнением и AI-ассистентом.
- [О проекте](#-о-проекте) ---
- [Возможности](#-возможности)
- [Архитектура](#-архитектура)
- [Быстрый старт](#-быстрый-старт)
- [Документация](#-документация)
- [Тестирование](#-тестирование)
- [Покрытие кода](#-покрытие-кода)
- [Enterprise Clean Deployment](#-enterprise-clean-deployment)
- [Авторизация](#-авторизация)
- [Мониторинг](#-мониторинг)
- [Вклад в проект](#-вклад-в-проект)
- [Лицензия](#-лицензия)
## 📖 О проекте ## Когда Superset уже вырос, а процессы вокруг него — ещё нет
superset-tools — комплексная платформа для автоматизации работы с Apache Superset, предоставляющая инструменты для LLM-перевода контента баз данных, миграции дашбордов, управления версиями через Git, LLM-аналитики и многопользовательского контроля доступа. Система построена на модульной архитектуре с плагинной системой расширений. На старте Apache Superset обычно прост: аналитик создаёт датасет, собирает дашборд и показывает его коллегам. Но по мере роста компании появляются новые окружения, подразделения, языки, требования безопасности и сотни связанных объектов.
## ✨ Возможности В этот момент команда сталкивается с типичными вопросами:
### 🌐 LLM-перевод контента баз данных — главная фича - Как перенести дашборд из разработки в production и ничего не сломать?
- Как понять, кто изменил отчёт и можно ли вернуть предыдущую версию?
- Как перевести сотни тысяч наименований и описаний, сохранив отраслевую терминологию?
- Как контролировать длительные операции без постоянного просмотра логов?
- Как дать аналитикам свободу, не теряя управляемость и аудит?
- Как подключить LLM к внутренним данным, не превращая это в набор несвязанных экспериментов?
superset-tools умеет переводить данные прямо в вашей БД: сотни тысяч строк номенклатуры, спецификаций, паспортов изделий — за один прогон. Никакой ручной работы, никаких копипаст в Google Translate. superset-tools объединяет эти задачи в одной платформе и делает работу с Superset воспроизводимой, наблюдаемой и безопасной.
Как это работает: выбираете таблицу-источник, указываете колонки, задаёте целевые языки и LLM-провайдера. superset-tools читает данные, отправляет в LLM и пишет перевод обратно — напрямую в целевую таблицу или через Superset SQL Lab. ## Что получает бизнес
Ключевые возможности модуля: | Результат | Как достигается |
- **Multi-language одной LLM-сессией** — одна строка переводится сразу на несколько языков (ru, en, de, fr, zh, kk и любые другие) в одном запросе к LLM. Экономия токенов и времени.
- **Любой LLM-провайдер** — Qwen, DeepSeek, GPT-4o, Claude, YandexGPT, GigaChat — всё, что совместимо с OpenAI API. Меняете модель в конфигурации джобы.
- **Preview-воркфлоу** — перед полноценным прогоном superset-tools показывает сэмпл перевода. Вы просматриваете строки, правите неудачные варианты, подтверждаете — и только потом запускаете полный прогон.
- **Словари терминологии** — загрузите CSV/TSV с правильными переводами ваших доменных терминов: «плавка → melt», «сортопрокат → bar stock», «ТУ → technical specifications». Словари автоматически подмешиваются в промпт, LLM использует именно вашу терминологию.
- **Inline-коррекция** — увидели плохой перевод в результатах? Правите прямо в UI, исправление улетает обратно в словарь. Каждый прогон делает систему умнее.
- **Инкрементальный перевод** — повторный прогон переводит только новые и изменившиеся строки (сравнение по хешу ключа). Уже переведённое не трогается.
- **Автоопределение языка источника** — не нужно указывать, на каком языке исходные данные. superset-tools определяет язык сам (через lingua-language-detector, без LLM — быстро и дёшево).
- **Планировщик по cron** — настроили джобу на еженочный прогон? Она будет запускаться автоматически. APScheduler под капотом.
- **Cache-механизм** — повторный перевод уже переведённого контента не тратит токены — результаты берутся из кэша.
- **Аудит и метрики** — каждый прогон логируется: сколько строк переведено, сколько пропущено, упало, сколько токенов потрачено, сколько результата взято из кэша. Всё в структурированных событиях.
- **Bulk-замена** — нашли, что LLM перевёл термин неконсистентно? Bulk find-and-replace по всем записям прогона.
> **Техническая справка:** модуль перевода — это ~120+ файлов backend на Python, собственная оркестрация (планировщик → executor → batch processor → LLM call), 4 уровня ретраев с адаптивным batch-sizer'ом, async HTTP-клиент для OpenAI API, поддержка Direct SQL (INSERT/UPSERT) и Superset SQL Lab, система промптов с Jaccard-семантикой для подбора словарных статей. Frontend — 5 страниц (джобы, прогоны, словари), 15+ Svelte-компонентов, real-time WebSocket-прогресс.
### 🔄 Миграция данных без страха
Перенос дашбордов и датасетов между dev, staging и production — рутинная операция, которая обычно отнимает часы и чревата ошибками. superset-tools делает её предсказуемой:
- **Dry-run режим** — перед реальными изменениями вы получаете детальный отчёт: какие объекты будут затронуты, какие риски обнаружены, что изменится в целевой среде. Никаких сюрпризов.
- **Автоматический маппинг БД** — базы данных, ресурсы и идентификаторы сопоставляются между окружениями автоматически. Никакого ручного поиска и замены.
- **Миграция legacy-данных** — встроенная поддержка переноса из SQLite в PostgreSQL. Устаревшие хранилища не помеха.
### 🌿 Git-интеграция: дашборды как код
Хватит копировать дашборды через export/import. Включите их в свой Git-процесс:
- **Версионирование** — каждый дашборд — это файл в репозитории. Полная история изменений, откат на любую версию, diff любой сложности.
- **LLM-управление ветками** — создавайте ветки, коммитьте и сливайте изменения через natural language команды. «Создай ветку для эксперимента с отчётом по энергопотреблению и закоммить текущие дашборды».
- **Деплой из Git** — push в целевую ветку автоматически применяет изменения на нужном окружении. CI/CD для дашбордов.
- **Интеллектуальные сообщения коммитов** — LLM анализирует изменения и сам предлагает осмысленный заголовок коммита.
### 🤖 LLM-аналитика: ИИ присматривает за дашбордами
Не просто инструмент, а ваш ассистент по данным:
- **Автовалидация дашбордов** — LLM проверяет корректность метрик, источников данных и визуализаций. Нашёл подозрительный SQL в фильтре? Сообщит до того, как дашборд попадёт к пользователям.
- **Генерация документации** — для любого датасета создаётся человекочитаемое описание: какие поля, откуда данные, какие есть зависимости.
- **Assistant API** — управляйте superset-tools голосом или текстом на естественном языке. «Перенеси дашборд производства на staging», «Покажи историю изменений по датасету качества продукции».
- **Умный коммитинг** — LLM анализирует изменения и генерирует сообщение коммита, отражающее суть. Никаких «fix» и «update».
### 📊 Управление и мониторинг: полный контроль
Одна консоль, чтобы править всеми:
- **RBAC** — гибкая ролевая модель: admin, analyst, viewer. Каждый видит и делает только то, что ему разрешено.
- **Фоновые задачи с WebSocket** — запустили миграцию на час? Откройте Task Drawer и наблюдайте прогресс в реальном времени. Никаких логов, к которым нужно подключаться по SSH.
- **Unified Reports** — единый формат отчётов для всех типов задач. Один эндпоинт — любые данные.
- **Аудит** — каждое действие логируется. Кто, когда и что сделал — всегда можно выяснить.
- **Retention-политики** — артефакты автоматически очищаются по расписанию. Диски не забиваются.
### 🔌 Плагинная архитектура: расширяйте без границ
superset-tools спроектирован как платформа. Хотите свою логику миграции? Свой источник данных? Свой триггер?
Каждый модуль — это изолированный плагин:
| Плагин | Назначение |
|---|---| |---|---|
| **TranslatePlugin** | LLM-перевод контента БД | | **Быстрый выпуск многоязычной отчётности** | Массовый LLM-перевод данных с корпоративными словарями и предварительной проверкой |
| **MigrationPlugin** | Миграция дашбордов между окружениями | | **Меньше ошибок при релизах** | Dry-run перед миграцией показывает изменения и риски до применения |
| **BackupPlugin** | Резервное копирование и восстановление | | **Прозрачная история изменений** | Дашборды и связанные объекты версионируются через Git |
| **GitPlugin** | Полный цикл Git-операций | | **Снижение ручной работы** | Повторяемые операции запускаются из интерфейса, по расписанию или через API |
| **LLMAnalysisPlugin** | AI-валидация и генерация документации | | **Контроль длительных процессов** | Прогресс, результаты и ошибки доступны в реальном времени |
| **MapperPlugin** | Маппинг колонок и ресурсов | | **Управляемое использование AI** | Единые LLM-провайдеры, словари, аудит и подтверждение критических действий |
| **DebugPlugin** | Диагностика и профилирование системы | | **Готовность к корпоративной среде** | Ролевая модель, ADFS SSO, аудит и поддержка закрытых контуров |
| **SearchPlugin** | Полнотекстовый поиск по датасетам |
Пишите свои плагины, подключайте через простой Python API. Никакой магии — только чёткий контракт. ## Ключевые сценарии
## 🏗️ Архитектура ### Перевод корпоративных данных без ручной обработки
### Технологический стек Представьте каталог из 300 000 позиций: наименования продукции, технические характеристики, марки материалов и примечания. Отчётность нужно подготовить на английском, немецком и китайском языках, при этом терминология должна соответствовать внутренним стандартам компании.
**Backend:** Python 3.9+ (FastAPI, SQLAlchemy, APScheduler), PostgreSQL, GitPython, OpenAI API, Playwright В superset-tools команда выбирает источник, нужные поля и языки, подключает терминологический словарь и сначала получает небольшую выборку для проверки. После согласования система запускает полный перевод, сохраняет результат и формирует отчёт о выполнении.
**Frontend:** SvelteKit (Svelte 5.x), Vite, Tailwind CSS, WebSocket При следующем запуске переводятся только новые и изменившиеся записи. Уже обработанные данные и подтверждённые формулировки используются повторно, поэтому процесс становится быстрее и экономичнее.
**DevOps:** Docker & Docker Compose, PostgreSQL 16 **Что поддерживается:**
### Модульная структура - несколько целевых языков за один проход;
- OpenAI-совместимые модели и корпоративные LLM-шлюзы;
- отраслевые словари из CSV/TSV;
- предварительный просмотр результата;
- исправление переводов прямо в интерфейсе;
- инкрементальная обработка новых данных;
- плановые запуски по расписанию;
- статистика по строкам, ошибкам, кэшу и расходу токенов;
- массовая корректировка неконсистентных терминов.
``` ### Безопасная миграция между dev, staging и production
superset-tools/
├── backend/ # Backend API
│ ├── src/
│ │ ├── api/ # API маршруты
│ │ ├── core/ # Ядро системы
│ │ │ ├── task_manager/ # Управление задачами
│ │ │ ├── auth/ # Авторизация
│ │ │ ├── migration/ # Миграция данных
│ │ │ └── plugins/ # Плагины
│ │ ├── models/ # Модели данных
│ │ ├── services/ # Бизнес-логика
│ │ └── schemas/ # Pydantic схемы
│ └── tests/
├── frontend/ # SvelteKit приложение
│ ├── src/
│ │ ├── routes/ # Страницы
│ │ ├── lib/
│ │ │ ├── components/ # UI компоненты
│ │ │ ├── stores/ # Svelte stores
│ │ │ └── api/ # API клиент
│ │ └── i18n/ # Мультиязычность
│ └── tests/
├── docker/ # Docker конфигурация
├── docs/ # Документация
└── specs/ # Спецификации
```
## 🚀 Быстрый старт Ручной export/import плохо масштабируется: идентификаторы отличаются, подключения к БД называются по-разному, а последствия становятся видны только после релиза.
### Требования superset-tools сначала выполняет dry-run и показывает, какие объекты будут созданы или изменены, какие зависимости найдены и где есть риски. Только после проверки команда запускает реальную миграцию.
- **Docker (рекомендуется):** Docker Engine 24+, Docker Compose v2, 4 GB RAM Автоматический маппинг помогает сопоставить базы данных и ресурсы между окружениями, а единый отчёт сохраняет результат операции для последующего аудита.
- **Локальная разработка:** Python 3.9+, Node.js 18+, npm, 2 GB RAM, 5 GB диска
### Docker (рекомендуется) **Бизнес-эффект:** меньше аварийных исправлений, быстрее выпуск изменений и понятная процедура согласования релиза.
```bash ### Дашборды как управляемые цифровые активы
git clone <repository-url>
cd superset-tools
docker compose up --build
```
После запуска: Дашборд — это не просто экран с графиками. В нём зафиксированы бизнес-метрики, SQL-логика, фильтры и договорённости между подразделениями. Поэтому его изменения должны быть такими же прозрачными, как изменения программного кода.
- Frontend: http://localhost:8000
- Backend API: http://localhost:8001
- PostgreSQL: localhost:5432
### Локальная разработка Git-интеграция superset-tools позволяет:
```bash - хранить историю версий;
# Backend - сравнивать изменения;
cd backend - возвращаться к стабильному состоянию;
python3 -m venv .venv - разделять экспериментальную и промышленную работу по веткам;
source .venv/bin/activate - доставлять согласованные изменения в целевое окружение;
pip install -r requirements.txt - генерировать понятные сообщения коммитов с помощью LLM.
python3 -m uvicorn src.app:app --reload --port 8000
# Frontend (в новом терминале) В результате команда получает единый процесс для аналитики и разработки, а ключевые отчёты перестают зависеть от памяти отдельных сотрудников.
cd frontend
npm install
npm run dev -- --port 5173
```
### Начальная настройка ### AI-ассистент для повседневных операций
```bash Платформой можно управлять через чат на естественном языке. Пользователь формулирует задачу так, как привык обсуждать её с коллегами:
# Переменные окружения
cp .env.example backend/.env
# Инициализация БД > «Проверь дашборд производства перед публикацией»
cd backend && source .venv/bin/activate >
python src/scripts/init_auth_db.py > «Покажи последние изменения в отчёте по качеству»
>
> «Подготовь перенос дашборда на staging и сначала покажи риски»
>
> «Проанализируй загруженную спецификацию и найди связанные датасеты»
# Создание администратора AI-агент сохраняет контекст диалога, умеет работать с PDF и XLSX и запрашивает подтверждение перед критическими действиями. Это не отдельный демонстрационный чат, а дополнительный интерфейс к реальным операциям платформы.
python src/scripts/create_admin.py --username admin --password '<temporary-secret>'
```
> Полный каталог переменных окружения — в [`.env.example`](.env.example). ### Единый центр контроля
### Offline-бандл Все длительные процессы — перевод, миграция, резервное копирование, анализ и Git-операции — выполняются как управляемые фоновые задачи.
```bash Пользователь видит:
# Загрузка образа
xz -dc dist/docker/superset-tools.20260517.tar.xz | docker load
export POSTGRES_PASSWORD="my-strong-password"
docker compose -f dist/docker/docker-compose.light.yml up -d
```
Сборка бандла: `./build.sh bundle:light v1.0.0` (light, ~104 MB) или `./build.sh bundle v1.0.0` (full). - текущий статус и прогресс;
- этап, на котором находится операция;
- предупреждения и ошибки;
- итоговый отчёт;
- историю запусков;
- автора и время действия.
## 📖 Документация Администратору не нужно подключаться к серверу и искать нужный фрагмент лога, а бизнес-пользователь не остаётся перед бесконечным индикатором загрузки.
- [Установка и настройка](docs/installation.md) ## Кому подходит superset-tools
### BI-командам
Для управления большим количеством дашбордов и датасетов, выпуска изменений между окружениями и подготовки многоязычной отчётности.
### Аналитикам данных
Для запуска типовых операций из единого интерфейса, отслеживания результатов и работы с AI без необходимости писать служебные скрипты.
### DevOps и платформенным инженерам
Для воспроизводимых поставок, Git-процессов, интеграции с CI/CD, фоновых задач и развёртывания в закрытом контуре.
### Руководителям ИТ, BI и DWH
Для прозрачности процессов, разграничения доступа, истории изменений и снижения зависимости от ручных действий отдельных специалистов.
### Командам локализации и управления данными
Для массового перевода справочников и технического контента с контролем терминологии и качества результата.
## Чем платформа отличается от набора скриптов
Скрипт хорошо решает одну задачу один раз. Корпоративный процесс должен переживать рост объёмов, смену сотрудников, ошибки внешних систем и новые требования безопасности.
superset-tools добавляет вокруг операций необходимый управленческий контур:
- единый пользовательский интерфейс;
- роли и права доступа;
- предварительную проверку изменений;
- фоновые задачи и повторные попытки;
- историю и аудит;
- отчёты в едином формате;
- расписания и retention-политики;
- API для внешних систем;
- расширение через плагины.
## Корпоративное использование
Платформа рассчитана как на обычное Docker-развёртывание, так и на изолированные корпоративные сети.
Поддерживаются:
- локальная авторизация и ADFS SSO;
- роли `admin`, `analyst` и `viewer`;
- корпоративные CA-сертификаты;
- собственные LLM-шлюзы и OpenAI-совместимые API;
- развёртывание без доступа к внешним источникам;
- очищенные enterprise-дистрибутивы;
- журналирование действий и результатов операций.
## Как устроен продукт
Пользователь работает с единой веб-платформой, которая объединяет управление дашбордами, датасетами, миграциями, переводами, Git-репозиториями и фоновыми задачами. AI-агент предоставляет альтернативный диалоговый интерфейс, а API позволяет подключать CI/CD, Airflow, cron и внутренние корпоративные системы.
Архитектура модульная: стандартные возможности реализованы как плагины, поэтому платформу можно расширять под собственные источники данных и бизнес-процессы.
## Быстрый старт
Инструкции по Docker-развёртыванию, локальной разработке, настройке LLM, SSO, сертификатов и закрытого контура находятся в [INSTALL.md](INSTALL.md).
## Документация
- [Установка и настройка](INSTALL.md)
- [Архитектура системы](docs/architecture.md) - [Архитектура системы](docs/architecture.md)
- [Архитектурные решения (ADR)](docs/adr/README.md) - [Архитектурные решения](docs/adr/README.md)
- [API документация](http://localhost:8001/docs) - [Enterprise Clean Deployment](docs/enterprise-clean.md)
- [Настройка окружений](docs/settings.md) - [API после запуска](http://localhost:8001/docs)
- [Руководство для контрибьюторов](CONTRIBUTING.md)
## 🧪 Тестирование ## Лицензия
### Запуск тестов
```bash
# Backend тесты
cd backend && source .venv/bin/activate && pytest
# Frontend тесты
cd frontend && npm run test
# Конкретный тест
pytest tests/test_auth.py::test_create_user
```
### 📊 Покрытие кода
Сводный отчёт о покрытии генерируется скриптом `scripts/coverage-summary.sh`:
```bash
# Полный запуск (backend integration + frontend)
./scripts/coverage-summary.sh
# Backend unit-тесты (SQLite) + frontend (быстрее, не требует Docker)
./scripts/coverage-summary.sh --unit
# Только frontend
./scripts/coverage-summary.sh --frontend-only
# Только backend unit
./scripts/coverage-summary.sh --backend-only --unit
# Указать директорию для отчёта
./scripts/coverage-summary.sh --output-dir ./reports/coverage
```
Скрипт выполняет:
1. Запуск backend-тестов (pytest) с `--cov=src` — unit (`--unit`) или integration (`--run-integration`)
2. Запуск frontend-тестов (vitest) с `--coverage`
3. Парсинг результатов тестов и процентов покрытия
4. Генерацию единого HTML-отчёта в `coverage-summary/index.html` со сводкой по обоим стекам
**Текущие показатели:**
| Стек | Тип тестов | Процент | Покрытие (Stmts) |
|------|-----------|---------|------------------|
| Backend (unit) | 1723 | 1721/2 ✅ | 48% |
| Backend (integration) | 167 | 167/0 ✅ | 12% |
| Frontend | 2443 | 2442/1 ✅ | 99.25% |
> HTML-отчёты coverage по каждому стеку открываются из сводного отчёта по ссылкам.
## 🔐 SSL/TLS конфигурация
### Сертификаты для HTTPS (nginx)
Для включения HTTPS поместите файлы сертификатов в директорию `./certs/`:
**Вариант A — отдельные файлы (без пароля):**
```
./certs/server.crt # SSL сертификат
./certs/server.key # Приватный ключ (незашифрованный)
```
**Вариант B — зашифрованный ключ + пароль:**
```
./certs/server.crt # SSL сертификат
./certs/server.key # Приватный ключ (зашифрован, с DEK-Info)
SSL_KEY_PASSPHRASE=my-passphrase # переменная окружения для расшифровки
```
**Вариант C — PKCS#12 контейнер:**
```
./certs/server.p12 # Контейнер с сертификатом + ключом
SSL_KEY_PASSPHRASE=my-passphrase # пароль от контейнера
```
Entrypoint автоматически:
1. Извлечёт `.crt` и `.key` из `.p12` (если нет отдельных файлов)
2. Расшифрует приватный ключ через `openssl rsa` (если есть `SSL_KEY_PASSPHRASE`)
3. Передаст расшифрованный ключ nginx (ключ остаётся в tmpfs контейнера)
> **Безопасность:** Расшифрованный ключ хранится только в tmpfs `/etc/nginx/ssl/` внутри контейнера и не пишется на диск хоста. Пароль задаётся через переменную окружения, а не через файл на volume.
### Корпоративные CA-сертификаты
Положите `.crt`/`.pem` файлы в `./certs/` — entrypoint установит их в системное хранилище Alpine и NSS (Chromium/Playwright). Поддерживаются цепочки из нескольких CA (Root → Intermediate).
### LLM CA-сертификаты
Если LLM-провайдер или Superset используют корпоративный PKI — укажите HTTP URL для скачивания CA-сертификатов:
```bash
LLM_CA_CERT_URLS="http://pki.company.com/root-ca.crt http://pki.company.com/intermediate-ca.crt"
```
Сертификаты скачиваются на старте backend и agent контейнеров (через `certs.sh:download_llm_ca_certs()`), конвертируются из DER в PEM при необходимости, и устанавливаются в системное хранилище.
Дополнительные сертификаты можно разместить в `CERTS_PATH=./certs` (volume mount).
### Диагностика SSL
```bash
# Полная проверка цепочки доверия
scripts/check_llm_certs.py
# Контейнерная диагностика
scripts/diag_container.py --target your-llm-provider.com:443
```
Подробнее — в [ADR-0009](docs/adr/ADR-0009-ssl-certificate-management.md).
`LLM_SSL_VERIFY` удалён в 0.2.x — TLS verify всегда включён.
## 🏢 Enterprise Clean Deployment
Для разворота в корпоративной сети с очищенным дистрибутивом (без тестовых данных, с запретом внешних источников и обязательной compliance-проверкой) используется профиль **enterprise clean**.
Поддерживаются CLI, API и TUI flows. Подробная документация — в [docs/enterprise-clean.md](docs/enterprise-clean.md).
## 🔐 Авторизация
Система поддерживает два метода аутентификации:
1. **Локальная** (username/password)
2. **ADFS SSO** (Active Directory Federation Services)
Управление пользователями и ролями — через `POST /api/admin/users` и `POST /api/admin/roles`. Документация — `docs/installation.md`.
## 📊 Мониторинг
- **Dashboard Hub** — управление дашбордами с Git-статусом
- **Dataset Hub** — управление датасетами с прогрессом маппинга
- **Task Drawer** — мониторинг выполнения фоновых задач
- **Unified Reports** — унифицированные отчеты по всем типам задач
API: `GET /api/reports?page=1&page_size=20` (фильтры по статусу, типу, дате).
## 💻 Примеры скриптов
Примеры интеграции с внешними системами (Airflow, CI/CD, cron) — в [`examples/`](./examples/):
- [Python](examples/maintenance-api-python.py)
- [Bash](examples/maintenance-api-bash.sh)
Скрипты демонстрируют аутентификацию через API Key (`X-API-Key`), запуск и завершение maintenance-событий, обработку ошибок.
## 🤝 Вклад в проект
Мы приветствуем contributions! См. [CONTRIBUTING.md](CONTRIBUTING.md).
## 📄 Лицензия
Проект распространяется под лицензией [MIT](LICENSE). Проект распространяется под лицензией [MIT](LICENSE).

View File

@@ -1,5 +1,5 @@
# agent/src/ss_tools/agent/__init__.py # agent/src/ss_tools/agent/__init__.py
# #region AgentChat [C:3] [TYPE Module] [SEMANTICS agent-chat] # #region Agent.Init.AgentChat [C:3] [TYPE Module] [SEMANTICS agent-chat]
# @defgroup AgentChat LangGraph-based Gradio agent — streaming chat with HITL guardrails. # @defgroup AgentChat LangGraph-based Gradio agent — streaming chat with HITL guardrails.
# @LAYER Application # @LAYER Application
# @RELATION DISPATCHES -> [AgentChat.Config] # @RELATION DISPATCHES -> [AgentChat.Config]
@@ -15,4 +15,4 @@
# @RELATION DISPATCHES -> [AgentChat.Persistence] # @RELATION DISPATCHES -> [AgentChat.Persistence]
# @RELATION DISPATCHES -> [AgentChat.Document.Parser] # @RELATION DISPATCHES -> [AgentChat.Document.Parser]
# @RELATION DISPATCHES -> [AgentChat.GradioApp] # @RELATION DISPATCHES -> [AgentChat.GradioApp]
# #endregion AgentChat # #endregion Agent.Init.AgentChat

View File

@@ -358,117 +358,120 @@ async def handle_resume( # noqa: C901
conversation_id: str, action: str, conversation_id: str, action: str,
user_jwt: str = "", env_id: str | None = None, user_jwt: str = "", env_id: str | None = None,
) -> AsyncGenerator[str]: ) -> AsyncGenerator[str]:
from ss_tools.agent.context import set_user_jwt from ss_tools.agent.context import reset_user_jwt, set_user_jwt
from ss_tools.shared.logger import logger from ss_tools.shared.logger import logger
set_user_jwt(user_jwt) user_jwt_token = set_user_jwt(user_jwt)
pending = _pending_confirmations.pop(conversation_id, None) try:
if pending is not None: pending = _pending_confirmations.pop(conversation_id, None)
if action == "deny": if pending is not None:
yield json.dumps({ if action == "deny":
"content": "⏹️ Операция отменена",
"metadata": {"type": "confirm_resolved", "result": "denied"},
})
return
if action == "confirm":
logger.reason(
"Fast-path confirmation resume",
payload={"tool": pending.get("tool_name"), "conv_id": conversation_id},
extra={"src": "AgentChat.Confirmation"},
)
tool_name = str(pending.get("tool_name") or "unknown_action")
tool_args = normalize_tool_args(pending.get("tool_args"))
yield json.dumps({
"content": "▶️ Операция подтверждена",
"metadata": {"type": "confirm_resolved", "result": "confirmed"},
})
yield json.dumps({
"content": f"🛠️ {tool_name}",
"metadata": {"type": "tool_start", "tool": tool_name, "input": tool_args},
})
tool_obj = find_tool(tool_name)
if tool_obj is None:
error = f"Unknown tool: {tool_name}"
logger.explore(
"Unknown tool in resume",
payload={"tool": tool_name}, error=error,
extra={"src": "AgentChat.Confirmation"},
)
yield json.dumps({ yield json.dumps({
"content": f"{tool_name}{error}", "content": "⏹️ Операция отменена",
"metadata": {"type": "tool_error", "tool": tool_name, "error": error}, "metadata": {"type": "confirm_resolved", "result": "denied"},
}) })
return return
try: if action == "confirm":
output = await tool_obj.ainvoke(tool_args) logger.reason(
except Exception as exc: "Fast-path confirmation resume",
logger.explore( payload={"tool": pending.get("tool_name"), "conv_id": conversation_id},
"Tool invocation failed in resume",
payload={"tool": tool_name}, error=str(exc),
extra={"src": "AgentChat.Confirmation"}, extra={"src": "AgentChat.Confirmation"},
) )
tool_name = str(pending.get("tool_name") or "unknown_action")
tool_args = normalize_tool_args(pending.get("tool_args"))
yield json.dumps({ yield json.dumps({
"content": f"{tool_name}{exc}", "content": "▶️ Операция подтверждена",
"metadata": {"type": "tool_error", "tool": tool_name, "error": str(exc)}, "metadata": {"type": "confirm_resolved", "result": "confirmed"},
}) })
return
yield json.dumps({
"content": f"{tool_name}",
"metadata": {"type": "tool_end", "tool": tool_name, "output": {"result": str(output)[:500]}},
})
# Format tool output via LLM for a human-readable response
async for chunk in _format_tool_output_via_llm(tool_name, str(output)):
yield chunk
logger.reflect(
"Fast-path confirmation completed",
payload={"tool": tool_name},
extra={"src": "AgentChat.Confirmation"},
)
return
logger.reason(
"LangGraph checkpoint resume",
payload={"conv_id": conversation_id, "action": action},
extra={"src": "AgentChat.Confirmation"},
)
agent = await create_agent(get_all_tools(), env_id, interrupt_before=[])
if action == "confirm":
config = {"configurable": {"thread_id": conversation_id}}
yield json.dumps({
"content": "▶️ Операция подтверждена",
"metadata": {"type": "confirm_resolved", "result": "confirmed"},
})
async for event in agent.astream_events(None, config=config, version="v2"):
kind = event.get("event")
if kind == "on_chat_model_stream":
chunk = event["data"]["chunk"]
if hasattr(chunk, "content") and chunk.content:
yield json.dumps({
"content": chunk.content,
"metadata": {"type": "stream_token", "token": chunk.content},
})
elif kind == "on_tool_start":
tool_name = event["name"]
yield json.dumps({ yield json.dumps({
"content": f"🛠️ {tool_name}", "content": f"🛠️ {tool_name}",
"metadata": {"type": "tool_start", "tool": tool_name, "input": event["data"].get("input", {})}, "metadata": {"type": "tool_start", "tool": tool_name, "input": tool_args},
}) })
elif kind == "on_tool_end": tool_obj = find_tool(tool_name)
tool_name = event["name"] if tool_obj is None:
output = event["data"].get("output", "") error = f"Unknown tool: {tool_name}"
logger.explore(
"Unknown tool in resume",
payload={"tool": tool_name}, error=error,
extra={"src": "AgentChat.Confirmation"},
)
yield json.dumps({
"content": f"{tool_name}{error}",
"metadata": {"type": "tool_error", "tool": tool_name, "error": error},
})
return
try:
output = await tool_obj.ainvoke(tool_args)
except Exception as exc:
logger.explore(
"Tool invocation failed in resume",
payload={"tool": tool_name}, error=str(exc),
extra={"src": "AgentChat.Confirmation"},
)
yield json.dumps({
"content": f"{tool_name}{exc}",
"metadata": {"type": "tool_error", "tool": tool_name, "error": str(exc)},
})
return
yield json.dumps({ yield json.dumps({
"content": f"{tool_name}", "content": f"{tool_name}",
"metadata": {"type": "tool_end", "tool": tool_name, "output": {"result": str(output)[:500]}}, "metadata": {"type": "tool_end", "tool": tool_name, "output": {"result": str(output)[:500]}},
}) })
elif action == "deny": # Format tool output via LLM for a human-readable response
logger.reflect( async for chunk in _format_tool_output_via_llm(tool_name, str(output)):
"Checkpoint resume denied", yield chunk
payload={"conv_id": conversation_id}, logger.reflect(
"Fast-path confirmation completed",
payload={"tool": tool_name},
extra={"src": "AgentChat.Confirmation"},
)
return
logger.reason(
"LangGraph checkpoint resume",
payload={"conv_id": conversation_id, "action": action},
extra={"src": "AgentChat.Confirmation"}, extra={"src": "AgentChat.Confirmation"},
) )
yield json.dumps({ agent = await create_agent(get_all_tools(), env_id, interrupt_before=[])
"content": "⏹️ Операция отменена", if action == "confirm":
"metadata": {"type": "confirm_resolved", "result": "denied"}, config = {"configurable": {"thread_id": conversation_id}}
}) yield json.dumps({
"content": "▶️ Операция подтверждена",
"metadata": {"type": "confirm_resolved", "result": "confirmed"},
})
async for event in agent.astream_events(None, config=config, version="v2"):
kind = event.get("event")
if kind == "on_chat_model_stream":
chunk = event["data"]["chunk"]
if hasattr(chunk, "content") and chunk.content:
yield json.dumps({
"content": chunk.content,
"metadata": {"type": "stream_token", "token": chunk.content},
})
elif kind == "on_tool_start":
tool_name = event["name"]
yield json.dumps({
"content": f"🛠️ {tool_name}",
"metadata": {"type": "tool_start", "tool": tool_name, "input": event["data"].get("input", {})},
})
elif kind == "on_tool_end":
tool_name = event["name"]
output = event["data"].get("output", "")
yield json.dumps({
"content": f"{tool_name}",
"metadata": {"type": "tool_end", "tool": tool_name, "output": {"result": str(output)[:500]}},
})
elif action == "deny":
logger.reflect(
"Checkpoint resume denied",
payload={"conv_id": conversation_id},
extra={"src": "AgentChat.Confirmation"},
)
yield json.dumps({
"content": "⏹️ Операция отменена",
"metadata": {"type": "confirm_resolved", "result": "denied"},
})
finally:
reset_user_jwt(user_jwt_token)
# #endregion AgentChat.Confirmation.HandleResume # #endregion AgentChat.Confirmation.HandleResume
# #endregion AgentChat.Confirmation # #endregion AgentChat.Confirmation

View File

@@ -59,17 +59,21 @@ from ss_tools.agent._persistence import (
prefetch_databases, prefetch_databases,
save_conversation, save_conversation,
) )
from ss_tools.agent.context import set_user_jwt, set_user_role from ss_tools.agent.context import reset_user_jwt, reset_user_role, set_user_jwt, set_user_role
from ss_tools.agent.document_parser import parse_upload from ss_tools.agent.document_parser import parse_upload
from ss_tools.agent.langgraph_setup import create_agent from ss_tools.agent.langgraph_setup import create_agent, llm_diagnostics
from ss_tools.agent.middleware import log_tool_event from ss_tools.agent.middleware import (
close_lifecycle_resources,
emit_lifecycle_event,
extract_trace_id_from_request,
log_tool_event,
)
from ss_tools.agent.tools import ( from ss_tools.agent.tools import (
_redact_sensitive_fields, _redact_sensitive_fields,
drain_tool_retry_events, drain_tool_retry_events,
get_all_tools, get_all_tools,
start_tool_retry_event_buffer, start_tool_retry_event_buffer,
) )
from ss_tools.shared.cot_logger import seed_trace_id
from ss_tools.shared.logger import logger from ss_tools.shared.logger import logger
MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
@@ -85,6 +89,34 @@ def _now_iso() -> str:
# #endregion AgentChat.GradioApp.NowIso # #endregion AgentChat.GradioApp.NowIso
# #region AgentChat.GradioApp.LlmFailureDiagnostics [C:2] [TYPE Function] [SEMANTICS agent-chat,llm,error,observability]
# @ingroup AgentChat
# @BRIEF Classify an LLM exception without logging credentials, user input, or provider response bodies.
# @POST Returns stable aggregate labels suitable for lifecycle events and production diagnosis.
def _llm_failure_diagnostics(exc: BaseException) -> dict[str, str | bool]:
"""Return a safe error fingerprint for a failed upstream LLM call."""
cause = exc.__cause__ or exc.__context__
diagnostics = {
**llm_diagnostics(),
"exception_type": type(exc).__name__,
"cause_type": type(cause).__name__ if cause else "",
"retryable": True,
}
cause_name = diagnostics["cause_type"]
if cause_name in {"gaierror", "NameResolutionError"}:
diagnostics["failure_class"] = "dns"
elif cause_name in {"ConnectionRefusedError"}:
diagnostics["failure_class"] = "connection_refused"
elif cause_name in {"SSLError", "ConnectError"}:
diagnostics["failure_class"] = "tls_or_connect"
elif isinstance(exc, (APITimeoutError, httpx.ReadTimeout)):
diagnostics["failure_class"] = "timeout"
else:
diagnostics["failure_class"] = "connection"
return diagnostics
# #endregion AgentChat.GradioApp.LlmFailureDiagnostics
# #region AgentChat.GradioApp.BuildAgentContext [C:3] [TYPE Function] [SEMANTICS agent-chat,context,runtime,build] # #region AgentChat.GradioApp.BuildAgentContext [C:3] [TYPE Function] [SEMANTICS agent-chat,context,runtime,build]
# @ingroup AgentChat # @ingroup AgentChat
# @BRIEF Build hidden RUNTIME CONTEXT block with datetime, prefetched dashboards and databases. # @BRIEF Build hidden RUNTIME CONTEXT block with datetime, prefetched dashboards and databases.
@@ -347,23 +379,38 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
except JWTError: except JWTError:
user_jwt_str = "" user_jwt_str = ""
set_user_jwt(user_jwt_str) user_jwt_token = set_user_jwt(user_jwt_str)
user_role = token_payload.get("role") or token_payload.get("user_role") or "viewer" user_role = token_payload.get("role") or token_payload.get("user_role") or "viewer"
set_user_role(user_role) user_role_token = set_user_role(user_role)
# ── Per-user lock ── # ── Per-user lock ──
user_id = user_id_str or (extract_user_id(user_jwt_str) if user_jwt_str else "admin") user_id = user_id_str or (extract_user_id(user_jwt_str) if user_jwt_str else "admin")
if _user_locks.get(user_id, False): if _user_locks.get(user_id, False):
reset_user_jwt(user_jwt_token)
reset_user_role(user_role_token)
yield json.dumps({"metadata": {"type": "error", "code": "CONCURRENT_SEND", "detail": "Другой запрос уже обрабатывается. Дождитесь завершения перед отправкой нового."}}) yield json.dumps({"metadata": {"type": "error", "code": "CONCURRENT_SEND", "detail": "Другой запрос уже обрабатывается. Дождитесь завершения перед отправкой нового."}})
return return
_user_locks[user_id] = True _user_locks[user_id] = True
_request_start_time = time.monotonic()
_tool_names_in_request: set[str] = set()
_request_result: str | None = None
_request_error_code: str | None = None
_attempts_used: int = 0
conv_id: str | None = None conv_id: str | None = None
try: try:
# ── Resolve conversation ID early (needed for file persistence) ── # ── Resolve conversation ID and trace ID ──
conv_id = conversation_id or str(uuid.uuid4()) conv_id = conversation_id or str(uuid.uuid4())
_trace_id = seed_trace_id() _trace_id = extract_trace_id_from_request(request)
is_resume = action in ("confirm", "deny") is_resume = action in ("confirm", "deny")
emit_lifecycle_event(
"AGENT_REQUEST_STARTED",
conversation_id=conv_id,
user_id=user_id,
environment_id=env_id,
action=action,
is_resume=is_resume or None,
)
logger.reason( logger.reason(
"Agent handler invoked", "Agent handler invoked",
payload={"user_id": user_id, "conv_id": conv_id, "action": action, "env_id": env_id, "is_resume": is_resume, "msg_len": len(str(message))}, payload={"user_id": user_id, "conv_id": conv_id, "action": action, "env_id": env_id, "is_resume": is_resume, "msg_len": len(str(message))},
@@ -480,6 +527,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
# Build descriptive title from captured tool_name # Build descriptive title from captured tool_name
title = f"{'' if action == 'confirm' else '⏹️'} {tool_name or 'Операция'}" if tool_name else f"HITL: {action}" title = f"{'' if action == 'confirm' else '⏹️'} {tool_name or 'Операция'}" if tool_name else f"HITL: {action}"
await save_conversation(conv_id or str(uuid.uuid4()), title, user_id) await save_conversation(conv_id or str(uuid.uuid4()), title, user_id)
_request_result = "completed"
return return
# ── Normal send path ── # ── Normal send path ──
@@ -519,8 +567,14 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
try: try:
for attempt in range(max_attempts): for attempt in range(max_attempts):
_attempts_used = attempt + 1
try: try:
emitted_any = False emitted_any = False
emit_lifecycle_event(
"AGENT_LLM_STARTED",
conversation_id=conv_id,
attempt=attempt + 1,
)
async for event in agent.astream_events( async for event in agent.astream_events(
{"messages": [HumanMessage(content=agent_text)]}, {"messages": [HumanMessage(content=agent_text)]},
config=config, config=config,
@@ -531,6 +585,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
yield json.dumps(retry_event) yield json.dumps(retry_event)
kind = event.get("event") kind = event.get("event")
if kind in ("on_tool_start", "on_tool_end", "on_tool_error"): if kind in ("on_tool_start", "on_tool_end", "on_tool_error"):
_tool_names_in_request.add(event.get("name", "unknown"))
await log_tool_event(event, conv_id) await log_tool_event(event, conv_id)
if kind == "on_chat_model_stream": if kind == "on_chat_model_stream":
chunk = event["data"]["chunk"] chunk = event["data"]["chunk"]
@@ -616,6 +671,11 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
} }
) )
emit_lifecycle_event(
"AGENT_LLM_COMPLETED",
conversation_id=conv_id,
attempt=attempt + 1,
)
state = await agent.aget_state(config) state = await agent.aget_state(config)
for retry_event in drain_tool_retry_events(): for retry_event in drain_tool_retry_events():
emitted_any = True emitted_any = True
@@ -623,6 +683,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
if getattr(state, "next", None): if getattr(state, "next", None):
emitted_any = True emitted_any = True
yield confirmation_payload(conv_id, state, visible_user_text, user_role, env_id) yield confirmation_payload(conv_id, state, visible_user_text, user_role, env_id)
_request_result = "completed"
return return
elif not emitted_any: elif not emitted_any:
yield json.dumps( yield json.dumps(
@@ -637,7 +698,20 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
_llm_status["status"] = "unavailable" _llm_status["status"] = "unavailable"
_llm_status["last_error"] = str(exc) _llm_status["last_error"] = str(exc)
_llm_status["last_check_ts"] = time.time() _llm_status["last_check_ts"] = time.time()
logger.explore("LLM provider connection failed", error=str(exc), extra={"src": "AgentChat.GradioApp.Handler"}) diagnostics = _llm_failure_diagnostics(exc)
logger.explore(
"LLM provider connection failed",
payload={"conv_id": conv_id, "attempt": _attempts_used, **diagnostics},
error=str(exc),
extra={"src": "AgentChat.GradioApp.Handler"},
)
emit_lifecycle_event(
"AGENT_LLM_FAILED",
conversation_id=conv_id,
error_code="LLM_PROVIDER_UNAVAILABLE",
attempt=_attempts_used,
**diagnostics,
)
yield json.dumps( yield json.dumps(
{ {
"content": "❌ LLM провайдер недоступен", "content": "❌ LLM провайдер недоступен",
@@ -649,6 +723,8 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
}, },
} }
) )
_request_result = "failed"
_request_error_code = "LLM_PROVIDER_UNAVAILABLE"
await save_conversation(conv_id, visible_user_text, user_id, assistant_text="") await save_conversation(conv_id, visible_user_text, user_id, assistant_text="")
return return
@@ -656,7 +732,20 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
_llm_status["status"] = "timeout" _llm_status["status"] = "timeout"
_llm_status["last_error"] = str(exc) _llm_status["last_error"] = str(exc)
_llm_status["last_check_ts"] = time.time() _llm_status["last_check_ts"] = time.time()
logger.explore("LLM provider timed out", error=str(exc), extra={"src": "AgentChat.GradioApp.Handler"}) diagnostics = _llm_failure_diagnostics(exc)
logger.explore(
"LLM provider timed out",
payload={"conv_id": conv_id, "attempt": _attempts_used, **diagnostics},
error=str(exc),
extra={"src": "AgentChat.GradioApp.Handler"},
)
emit_lifecycle_event(
"AGENT_LLM_FAILED",
conversation_id=conv_id,
error_code="LLM_TIMEOUT",
attempt=_attempts_used,
**diagnostics,
)
yield json.dumps( yield json.dumps(
{ {
"content": "❌ LLM провайдер не отвечает", "content": "❌ LLM провайдер не отвечает",
@@ -668,6 +757,8 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
}, },
} }
) )
_request_result = "failed"
_request_error_code = "LLM_TIMEOUT"
await save_conversation(conv_id, visible_user_text, user_id, assistant_text="") await save_conversation(conv_id, visible_user_text, user_id, assistant_text="")
return return
@@ -676,6 +767,12 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
_llm_status["last_error"] = str(exc) _llm_status["last_error"] = str(exc)
_llm_status["last_check_ts"] = time.time() _llm_status["last_check_ts"] = time.time()
logger.explore("LLM provider auth failed", error=str(exc), extra={"src": "AgentChat.GradioApp.Handler"}) logger.explore("LLM provider auth failed", error=str(exc), extra={"src": "AgentChat.GradioApp.Handler"})
emit_lifecycle_event(
"AGENT_LLM_FAILED",
conversation_id=conv_id,
error_code="LLM_AUTH_ERROR",
attempt=_attempts_used,
)
yield json.dumps( yield json.dumps(
{ {
"content": "❌ API ключ LLM отклонён", "content": "❌ API ключ LLM отклонён",
@@ -687,6 +784,8 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
}, },
} }
) )
_request_result = "failed"
_request_error_code = "LLM_AUTH_ERROR"
await save_conversation(conv_id, visible_user_text, user_id, assistant_text="") await save_conversation(conv_id, visible_user_text, user_id, assistant_text="")
return return
@@ -695,6 +794,12 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
_llm_status["last_error"] = str(exc) _llm_status["last_error"] = str(exc)
_llm_status["last_check_ts"] = time.time() _llm_status["last_check_ts"] = time.time()
logger.explore("LLM provider rate limited", error=str(exc), extra={"src": "AgentChat.GradioApp.Handler"}) logger.explore("LLM provider rate limited", error=str(exc), extra={"src": "AgentChat.GradioApp.Handler"})
emit_lifecycle_event(
"AGENT_LLM_FAILED",
conversation_id=conv_id,
error_code="LLM_RATE_LIMITED",
attempt=_attempts_used,
)
yield json.dumps( yield json.dumps(
{ {
"content": "❌ Превышен лимит запросов к LLM. Попробуйте позже.", "content": "❌ Превышен лимит запросов к LLM. Попробуйте позже.",
@@ -706,6 +811,8 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
}, },
} }
) )
_request_result = "failed"
_request_error_code = "LLM_RATE_LIMITED"
await save_conversation(conv_id, visible_user_text, user_id, assistant_text="") await save_conversation(conv_id, visible_user_text, user_id, assistant_text="")
return return
@@ -719,12 +826,20 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
error=str(e), error=str(e),
extra={"src": "AgentChat.GradioApp.Handler"}, extra={"src": "AgentChat.GradioApp.Handler"},
) )
emit_lifecycle_event(
"AGENT_LLM_FAILED",
conversation_id=conv_id,
error_code="LLM_MALFORMED_OUTPUT",
attempt=_attempts_used,
)
yield json.dumps( yield json.dumps(
{ {
"content": "❌ Ошибка обработки ответа LLM. Пожалуйста, уточните запрос.", "content": "❌ Ошибка обработки ответа LLM. Пожалуйста, уточните запрос.",
"metadata": {"type": "error", "code": "LLM_MALFORMED_OUTPUT", "detail": str(e)}, "metadata": {"type": "error", "code": "LLM_MALFORMED_OUTPUT", "detail": str(e)},
} }
) )
_request_result = "failed"
_request_error_code = "LLM_MALFORMED_OUTPUT"
except Exception as exc: except Exception as exc:
logger.explore( logger.explore(
@@ -746,6 +861,8 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
return return
except Exception: except Exception:
pass pass
_request_result = "failed"
_request_error_code = "PROCESSING_ERROR"
yield json.dumps( yield json.dumps(
{ {
"content": f"❌ Ошибка: {exc}", "content": f"❌ Ошибка: {exc}",
@@ -758,6 +875,8 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
assistant_text = "".join(str(part) for part in assistant_parts) assistant_text = "".join(str(part) for part in assistant_parts)
await save_conversation(conv_id, visible_user_text, user_id, assistant_text=assistant_text) await save_conversation(conv_id, visible_user_text, user_id, assistant_text=assistant_text)
await _generate_title_best_effort(conv_id, visible_user_text) await _generate_title_best_effort(conv_id, visible_user_text)
if _request_result is None:
_request_result = "completed"
logger.reflect( logger.reflect(
"Agent handler completed", "Agent handler completed",
payload={"conv_id": conv_id, "assistant_len": len(assistant_text)}, payload={"conv_id": conv_id, "assistant_len": len(assistant_text)},
@@ -765,10 +884,26 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
) )
finally: finally:
if _request_result:
_elapsed_ms = round((time.monotonic() - _request_start_time) * 1000, 1)
emit_lifecycle_event(
f"AGENT_REQUEST_{_request_result.upper()}",
conversation_id=conv_id,
user_id=user_id,
environment_id=env_id,
action=action,
elapsed_ms=_elapsed_ms,
tool_count=len(_tool_names_in_request),
tool_names=list(_tool_names_in_request) if _tool_names_in_request else None,
attempts=_attempts_used or None,
error_code=_request_error_code,
)
_user_locks[user_id] = False _user_locks[user_id] = False
if conv_id and conv_id in _conv_locks: if conv_id and conv_id in _conv_locks:
_conv_locks[conv_id].set() _conv_locks[conv_id].set()
del _conv_locks[conv_id] del _conv_locks[conv_id]
reset_user_jwt(user_jwt_token)
reset_user_role(user_role_token)
# #endregion AgentChat.GradioApp.Handler # #endregion AgentChat.GradioApp.Handler
@@ -817,9 +952,12 @@ async def health():
if __name__ == "__main__": if __name__ == "__main__":
demo = create_chat_interface() demo = create_chat_interface()
demo.launch( try:
server_name=GRADIO_SERVER_NAME, demo.launch(
server_port=GRADIO_SERVER_PORT, server_name=GRADIO_SERVER_NAME,
root_path=GRADIO_ROOT_PATH, server_port=GRADIO_SERVER_PORT,
) root_path=GRADIO_ROOT_PATH,
)
finally:
asyncio.run(close_lifecycle_resources())
# #endregion AgentChat.GradioApp # #endregion AgentChat.GradioApp

View File

@@ -2,56 +2,77 @@
# #region AgentChat.Context [C:3] [TYPE Module] [SEMANTICS agent-chat,context,auth] # #region AgentChat.Context [C:3] [TYPE Module] [SEMANTICS agent-chat,context,auth]
# @ingroup AgentChat # @ingroup AgentChat
# @BRIEF JWT context propagation for LangGraph tools. # @BRIEF JWT context propagation for LangGraph tools.
# @RATIONALE LangGraph tool execution may run in a different async context, # @RATIONALE ContextVar values propagate through asyncio task context while
# preventing ContextVar from propagating. Module-level globals # remaining isolated between concurrent requests. Module-level
# ensure the JWT is always accessible from any execution context. # mutable JWT/role values were rejected because one request could
# overwrite another request's identity.
_user_jwt: str = "" from contextvars import ContextVar, Token
_service_jwt: str = ""
_user_role: str = "viewer"
_user_jwt: ContextVar[str] = ContextVar("agent_user_jwt", default="")
_service_jwt: ContextVar[str] = ContextVar("agent_service_jwt", default="")
_user_role: ContextVar[str] = ContextVar("agent_user_role", default="viewer")
# #region AgentChat.Context.SetUserJwt [C:1] [TYPE Function] [SEMANTICS agent-chat,context,jwt,set] # #region AgentChat.Context.SetUserJwt [C:1] [TYPE Function] [SEMANTICS agent-chat,context,jwt,set]
# @BRIEF Store user JWT in module-level global for tool call authentication. # @BRIEF Store user JWT in request-local ContextVar for tool call authentication.
def set_user_jwt(jwt: str) -> None: # @POST Returns a reset token for restoring the previous request context.
global _user_jwt def set_user_jwt(jwt: str) -> Token[str]:
_user_jwt = jwt return _user_jwt.set(jwt or "")
# #endregion AgentChat.Context.SetUserJwt # #endregion AgentChat.Context.SetUserJwt
# #region AgentChat.Context.GetUserJwt [C:1] [TYPE Function] [SEMANTICS agent-chat,context,jwt,get] # #region AgentChat.Context.GetUserJwt [C:1] [TYPE Function] [SEMANTICS agent-chat,context,jwt,get]
# @BRIEF Retrieve stored user JWT for tool HTTP headers. # @BRIEF Retrieve request-local user JWT for tool HTTP headers.
def get_user_jwt() -> str: def get_user_jwt() -> str:
return _user_jwt return _user_jwt.get()
# #endregion AgentChat.Context.GetUserJwt # #endregion AgentChat.Context.GetUserJwt
# #region AgentChat.Context.SetUserRole [C:1] [TYPE Function] [SEMANTICS agent-chat,context,role,set] # #region AgentChat.Context.SetUserRole [C:1] [TYPE Function] [SEMANTICS agent-chat,context,role,set]
# @BRIEF Store user role for RBAC enforcement in tool pipeline. # @BRIEF Store request-local user role for RBAC enforcement in tool pipeline.
def set_user_role(role: str) -> None: # @POST Returns a reset token for restoring the previous request context.
global _user_role def set_user_role(role: str) -> Token[str]:
_user_role = role or "viewer" return _user_role.set(role or "viewer")
# #endregion AgentChat.Context.SetUserRole # #endregion AgentChat.Context.SetUserRole
# #region AgentChat.Context.GetUserRole [C:1] [TYPE Function] [SEMANTICS agent-chat,context,role,get] # #region AgentChat.Context.GetUserRole [C:1] [TYPE Function] [SEMANTICS agent-chat,context,role,get]
# @BRIEF Retrieve stored user role for RBAC checks. # @BRIEF Retrieve request-local user role for RBAC checks.
def get_user_role() -> str: def get_user_role() -> str:
return _user_role return _user_role.get()
# #endregion AgentChat.Context.GetUserRole # #endregion AgentChat.Context.GetUserRole
# #region AgentChat.Context.SetServiceJwt [C:1] [TYPE Function] [SEMANTICS agent-chat,context,service-jwt,set] # #region AgentChat.Context.SetServiceJwt [C:1] [TYPE Function] [SEMANTICS agent-chat,context,service-jwt,set]
# @BRIEF Store service-to-service JWT for dual-identity auth. # @BRIEF Store service-to-service JWT in a ContextVar for dual-identity auth.
def set_service_jwt(jwt: str) -> None: # @POST Returns a reset token for restoring the previous request context.
global _service_jwt def set_service_jwt(jwt: str) -> Token[str]:
_service_jwt = jwt return _service_jwt.set(jwt or "")
# #endregion AgentChat.Context.SetServiceJwt # #endregion AgentChat.Context.SetServiceJwt
# #region AgentChat.Context.GetServiceJwt [C:1] [TYPE Function] [SEMANTICS agent-chat,context,service-jwt,get] # #region AgentChat.Context.GetServiceJwt [C:1] [TYPE Function] [SEMANTICS agent-chat,context,service-jwt,get]
# @BRIEF Retrieve stored service JWT for dual-identity auth headers. # @BRIEF Retrieve request-local service JWT for dual-identity auth headers.
def get_service_jwt() -> str: def get_service_jwt() -> str:
return _service_jwt return _service_jwt.get()
# #endregion AgentChat.Context.GetServiceJwt # #endregion AgentChat.Context.GetServiceJwt
# #region AgentChat.Context.Reset [C:2] [TYPE Function] [SEMANTICS agent-chat,context,auth,reset]
# @BRIEF Restore request-local JWT and role values after a request completes.
# @PRE Tokens were returned by the corresponding set_* functions in the same context.
# @POST Previous ContextVar values are restored; concurrent request contexts remain isolated.
def reset_user_jwt(token: Token[str]) -> None:
_user_jwt.reset(token)
def reset_user_role(token: Token[str]) -> None:
_user_role.reset(token)
def reset_service_jwt(token: Token[str]) -> None:
_service_jwt.reset(token)
# #endregion AgentChat.Context.Reset
# #endregion AgentChat.Context # #endregion AgentChat.Context

View File

@@ -9,6 +9,7 @@
import inspect as _inspect import inspect as _inspect
import os import os
from urllib.parse import urlsplit
from langchain_openai import ChatOpenAI from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import InMemorySaver from langgraph.checkpoint.memory import InMemorySaver
@@ -74,6 +75,28 @@ async def init_checkpointer() -> None:
_llm_config: dict | None = None _llm_config: dict | None = None
# #region AgentChat.LangGraph.Setup.LlmDiagnostics [C:2] [TYPE Function] [SEMANTICS agent-chat,llm,observability,redaction]
# @BRIEF Return diagnostic provider metadata while excluding URL paths, credentials and API keys.
# @INVARIANT Never returns api_key, full base_url, prompt or provider response content.
def llm_diagnostics(config: dict | None = None) -> dict[str, str | bool]:
"""Return the safe LLM identity needed to correlate agent failures."""
candidate = config if config is not None else _llm_config
if not candidate:
return {"configured": False}
parsed = urlsplit(str(candidate.get("base_url") or ""))
return {
"configured": bool(candidate.get("configured")),
"provider_id": str(candidate.get("provider_id") or ""),
"provider_name": str(candidate.get("provider_name") or ""),
"provider_type": str(candidate.get("provider_type") or ""),
"provider_host": parsed.hostname or "",
"provider_scheme": parsed.scheme or "",
"model": str(candidate.get("default_model") or ""),
"selection_source": str(candidate.get("selection_source") or ""),
}
# #endregion AgentChat.LangGraph.Setup.LlmDiagnostics
# #region AgentChat.LangGraph.Setup.ConfigureFromApi [C:1] [TYPE Function] [SEMANTICS agent-chat,langgraph,config,api] # #region AgentChat.LangGraph.Setup.ConfigureFromApi [C:1] [TYPE Function] [SEMANTICS agent-chat,langgraph,config,api]
# @ingroup AgentChat # @ingroup AgentChat
# @BRIEF Store LLM config dict fetched from FastAPI for later use by create_agent. # @BRIEF Store LLM config dict fetched from FastAPI for later use by create_agent.
@@ -88,6 +111,11 @@ def configure_from_api(llm_config: dict) -> None:
# @BRIEF Fetch LLM provider config from FastAPI /api/agent/llm-config. # @BRIEF Fetch LLM provider config from FastAPI /api/agent/llm-config.
async def _fetch_llm_config() -> dict | None: async def _fetch_llm_config() -> dict | None:
global _llm_config global _llm_config
logger.reason(
"Fetching agent LLM configuration",
payload={"fastapi_host": urlsplit(FASTAPI_URL).hostname or ""},
extra={"src": "AgentChat.LangGraph.Setup.FetchLlmConfig"},
)
try: try:
fastapi_url = FASTAPI_URL fastapi_url = FASTAPI_URL
client = get_shared_http_client(timeout=10) client = get_shared_http_client(timeout=10)
@@ -96,9 +124,32 @@ async def _fetch_llm_config() -> dict | None:
config = resp.json() config = resp.json()
if config.get("configured"): if config.get("configured"):
_llm_config = config _llm_config = config
logger.reflect(
"Agent LLM configuration loaded",
payload=llm_diagnostics(config),
extra={"src": "AgentChat.LangGraph.Setup.FetchLlmConfig"},
)
return config return config
logger.explore(
"Agent LLM configuration is unavailable",
payload={"http_status": resp.status_code, **llm_diagnostics(config)},
error=str(config.get("reason") or "configured=false"),
extra={"src": "AgentChat.LangGraph.Setup.FetchLlmConfig"},
)
else:
logger.explore(
"Agent LLM configuration request failed",
payload={"http_status": resp.status_code, "fastapi_host": urlsplit(fastapi_url).hostname or ""},
error=f"HTTP {resp.status_code}",
extra={"src": "AgentChat.LangGraph.Setup.FetchLlmConfig"},
)
except Exception as e: except Exception as e:
logger.explore("Failed to fetch LLM config from FastAPI", error=str(e), extra={"src": "AgentChat.LangGraph.Setup"}) logger.explore(
"Failed to fetch LLM config from FastAPI",
payload={"fastapi_host": urlsplit(FASTAPI_URL).hostname or "", "exception_type": type(e).__name__},
error=str(e),
extra={"src": "AgentChat.LangGraph.Setup.FetchLlmConfig"},
)
return _llm_config return _llm_config
# #endregion AgentChat.LangGraph.Setup.FetchLlmConfig # #endregion AgentChat.LangGraph.Setup.FetchLlmConfig
@@ -132,7 +183,11 @@ async def create_agent(tools: list, env_id: str | None = None, interrupt_before:
model = config.get("default_model") model = config.get("default_model")
else: else:
raise RuntimeError("No LLM provider configured in backend. Configure one via Settings → AI Providers in the web UI.") raise RuntimeError("No LLM provider configured in backend. Configure one via Settings → AI Providers in the web UI.")
logger.reason("Creating LangGraph agent", payload={"model": model, "tools_count": len(tools), "env_id": env_id}, extra={"src": "AgentChat.LangGraph.Setup"}) logger.reason(
"Creating LangGraph agent",
payload={**llm_diagnostics(config), "tools_count": len(tools), "env_id": env_id},
extra={"src": "AgentChat.LangGraph.Setup.CreateAgent"},
)
llm = ChatOpenAI( llm = ChatOpenAI(
http_async_client=get_shared_http_client(), http_async_client=get_shared_http_client(),
**chat_openai_kwargs(model=model, base_url=base_url, api_key=api_key, max_tokens=2048), **chat_openai_kwargs(model=model, base_url=base_url, api_key=api_key, max_tokens=2048),
@@ -169,7 +224,11 @@ async def create_agent(tools: list, env_id: str | None = None, interrupt_before:
checkpointer=checkpointer, checkpointer=checkpointer,
interrupt_before=_interrupt_before_from_env() if interrupt_before is None else interrupt_before, interrupt_before=_interrupt_before_from_env() if interrupt_before is None else interrupt_before,
) )
logger.reflect("LangGraph agent created", payload={"model": model, "checkpointer_type": type(checkpointer).__name__, "tools_count": len(tools)}, extra={"src": "AgentChat.LangGraph.Setup"}) logger.reflect(
"LangGraph agent created",
payload={**llm_diagnostics(config), "checkpointer_type": type(checkpointer).__name__, "tools_count": len(tools)},
extra={"src": "AgentChat.LangGraph.Setup.CreateAgent"},
)
return graph return graph
# #endregion AgentChat.LangGraph.Setup.CreateAgent # #endregion AgentChat.LangGraph.Setup.CreateAgent
# #endregion AgentChat.LangGraph.Setup # #endregion AgentChat.LangGraph.Setup

View File

@@ -1,14 +1,273 @@
# agent/src/ss_tools/agent/middleware.py # agent/src/ss_tools/agent/middleware.py
# #region AgentChat.Middleware [C:3] [TYPE Module] [SEMANTICS agent-chat,middleware,logging,audit] # #region AgentChat.Middleware [C:3] [TYPE Module] [SEMANTICS agent-chat,middleware,logging,audit]
# @ingroup AgentChat # @ingroup AgentChat
# @BRIEF Audit logging middleware for the LangGraph agent. # @BRIEF Audit logging middleware for the LangGraph agent. Lifecycle events for observability.
# @SIDE_EFFECT Logs lifecycle events via logger AND best-effort HTTP POST to backend (async).
# @RELATION DEPENDS_ON -> [AgentChat.Context] # @RELATION DEPENDS_ON -> [AgentChat.Context]
# @RELATION DEPENDS_ON -> [AgentChat.Tools] # @RELATION DEPENDS_ON -> [AgentChat.Tools]
# @RELATION DEPENDS_ON -> [Shared.TraceContext]
# @INVARIANT Backend HTTP persistence is best-effort — failure MUST NOT interrupt caller flow.
import asyncio
from datetime import UTC, datetime from datetime import UTC, datetime
import uuid
import httpx
from ss_tools.agent._config import FASTAPI_URL, SERVICE_JWT
from ss_tools.agent.context import get_user_jwt from ss_tools.agent.context import get_user_jwt
from ss_tools.agent.tools import _redact_sensitive_fields from ss_tools.agent.tools import _redact_sensitive_fields
from ss_tools.shared.cot_logger import get_trace_id, seed_trace_id, set_trace_id
from ss_tools.shared.logger import logger from ss_tools.shared.logger import logger
from ss_tools.shared.ssl import httpx_verify
_FORBIDDEN_LIFECYCLE_FIELDS = {
"authorization",
"files",
"jwt",
"message",
"password",
"prompt",
"raw_output",
"secret",
"token",
"tool_input",
"tool_output",
"user_jwt",
}
_FORBIDDEN_LIFECYCLE_FRAGMENTS = ("api_key", "apikey")
# Shared httpx AsyncClient for lifecycle event persistence (lazy initialized).
_lifecycle_client: httpx.AsyncClient | None = None
_lifecycle_tasks: set[asyncio.Task] = set()
def _get_lifecycle_client() -> httpx.AsyncClient | None:
"""Get or create the shared AsyncClient for lifecycle event HTTP persistence.
Returns None if FASTAPI_URL is not configured.
"""
global _lifecycle_client
if _lifecycle_client is None:
base_url = (FASTAPI_URL or "").rstrip("/")
if not base_url:
return None
ssl_ctx = httpx_verify()
_lifecycle_client = httpx.AsyncClient(
base_url=base_url,
verify=ssl_ctx,
timeout=httpx.Timeout(5.0, connect=3.0),
)
return _lifecycle_client
# #region AgentChat.Middleware.ExtractTraceId [C:2] [TYPE Function] [SEMANTICS agent-chat,middleware,trace,request,extract]
# @ingroup AgentChat
# @BRIEF Extract valid UUID4 X-Trace-ID from gr.Request headers, or seed new.
# @POST If valid UUID4 X-Trace-ID found, set_trace_id() and return it.
# Otherwise seed_trace_id() and return new trace ID.
# @SIDE_EFFECT Sets ContextVar _trace_id via set_trace_id() or seed_trace_id().
# @RATIONALE Enables cross-service trace propagation from upstream proxies.
# @REJECTED Non-v4 UUIDs rejected — they break cross-service trace correlation.
def extract_trace_id_from_request(request) -> str:
"""Extract valid UUID4 X-Trace-ID from gr.Request headers, or seed new."""
incoming = None
try:
headers = getattr(request, "headers", {}) or {}
if isinstance(headers, dict):
for key, value in headers.items():
if key.lower() == "x-trace-id":
incoming = value
break
elif headers:
incoming = headers.get("X-Trace-ID") or headers.get("x-trace-id")
except Exception:
pass
if incoming and isinstance(incoming, str):
try:
parsed = uuid.UUID(hex=incoming)
if parsed.version == 4:
set_trace_id(incoming)
return incoming
except (ValueError, AttributeError):
pass
return seed_trace_id()
# #endregion AgentChat.Middleware.ExtractTraceId
# #region AgentChat.Middleware.EmitLifecycleEvent [C:3] [TYPE Function] [SEMANTICS agent-chat,middleware,lifecycle,observability]
# @ingroup AgentChat
# @BRIEF Emit structured lifecycle event: log locally AND best-effort persist to backend.
# @SIDE_EFFECT Writes JSON audit record via shared logger; HTTP POST to backend (async, best-effort).
# @INVARIANT No JWT, user message, prompt, raw tool output, or files in payload.
# @INVARIANT Backend HTTP failure never raises — errors are logged as EXPLORE and swallowed.
# @INVARIANT When an end-user JWT exists, it is sent only as X-User-JWT transport auth and
# never placed in an event payload or a local structured log.
# @RATIONALE Dual persistence (local log + remote DB) ensures durability: local log survives
# agent restarts, remote DB enables cross-service audit queries. Async fire-and-forget
# via asyncio.create_task prevents blocking the user stream on network I/O.
# @REJECTED Persisting under SERVICE_JWT identity alone was rejected — it loses the end-user
# ownership required by the read API's user-scoped authorization contract.
# @REJECTED Synchronous HTTP POST was rejected — it would block the Gradio event loop during
# request startup/cleanup, degrading UX. Blocking the caller on backend availability
# was rejected — the agent must function without the audit backend.
def emit_lifecycle_event(event_type: str, **payload) -> None:
"""Emit a structured lifecycle event with safe aggregate fields.
Logs locally (synchronous, always) AND best-effort persists to backend via HTTP POST.
Backend persistence runs as asyncio.create_task — never blocks the caller.
Args:
event_type: The lifecycle event name (e.g. AGENT_REQUEST_STARTED).
**payload: Safe aggregate fields only. Never JWT, user message,
prompt, raw tool output, or files.
"""
safe = {
key: value
for key, value in payload.items()
if value is not None
and isinstance(key, str)
and key.lower() not in _FORBIDDEN_LIFECYCLE_FIELDS
and not any(fragment in key.lower() for fragment in _FORBIDDEN_LIFECYCLE_FRAGMENTS)
}
if event_type.endswith("_FAILED"):
logger.explore(
event_type,
payload=safe,
error=str(safe.get("error_code") or "agent lifecycle failure"),
extra={"src": "AgentChat.Lifecycle"},
)
else:
logger.reason(
event_type,
payload=safe,
extra={"src": "AgentChat.Lifecycle"},
)
# ── Best-effort async HTTP POST to backend ──
try:
loop = asyncio.get_running_loop()
if loop.is_closed():
return
except RuntimeError:
return # No running event loop — skip HTTP persistence
# Extract fields for the backend event schema
trace_id = get_trace_id() or ""
conversation_id = safe.get("conversation_id") or ""
environment_id = safe.get("environment_id")
tool_name = safe.get("tool_name")
status = safe.get("status")
elapsed_ms = safe.get("elapsed_ms")
error_code = safe.get("error_code")
try:
task = loop.create_task(
_persist_event_async(
event_type=event_type,
trace_id=trace_id,
conversation_id=conversation_id,
environment_id=environment_id,
tool_name=tool_name,
status=status,
elapsed_ms=elapsed_ms,
error_code=error_code,
payload=safe,
)
)
except RuntimeError:
return
_lifecycle_tasks.add(task)
task.add_done_callback(_log_persistence_task_failure)
task.add_done_callback(_lifecycle_tasks.discard)
def _log_persistence_task_failure(task: asyncio.Task) -> None:
"""Consume unexpected background task exceptions without affecting the chat stream."""
try:
task.result()
except Exception as exc:
logger.explore(
"Lifecycle event background persistence failed",
error=str(exc),
extra={"src": "AgentChat.Lifecycle.HttpPersist"},
)
async def _persist_event_async(
event_type: str,
trace_id: str,
conversation_id: str,
environment_id: str | None = None,
tool_name: str | None = None,
status: str | None = None,
elapsed_ms: float | None = None,
error_code: str | None = None,
payload: dict | None = None,
) -> None:
"""Best-effort HTTP POST lifecycle event to backend. Never raises."""
client = _get_lifecycle_client()
if client is None:
return
body = {
"trace_id": trace_id,
"conversation_id": conversation_id,
"event_type": event_type,
"environment_id": environment_id,
"tool_name": tool_name,
"status": status,
"elapsed_ms": elapsed_ms,
"error_code": error_code,
"payload": payload,
}
headers = {}
svc_jwt = (SERVICE_JWT or "").strip()
if svc_jwt:
headers["Authorization"] = f"Bearer {svc_jwt}"
user_jwt = get_user_jwt()
if user_jwt:
# get_current_user prioritizes X-User-JWT and records the event under the
# caller's real identity. This header is transport-only and never logged.
headers["X-User-JWT"] = user_jwt
try:
resp = await client.post("/api/agent/events", json=body, headers=headers)
if resp.status_code >= 400:
logger.explore(
"Lifecycle event HTTP persistence rejected",
payload={"event_type": event_type, "status": resp.status_code},
error=f"HTTP {resp.status_code}",
extra={"src": "AgentChat.Lifecycle.HttpPersist"},
)
except Exception as exc:
logger.explore(
"Lifecycle event HTTP persistence failed",
payload={"event_type": event_type},
error=str(exc),
extra={"src": "AgentChat.Lifecycle.HttpPersist"},
)
async def close_lifecycle_resources(timeout: float = 5.0) -> None:
"""Drain pending lifecycle writes and close the shared HTTP client."""
global _lifecycle_client
pending = tuple(_lifecycle_tasks)
if pending:
done, remaining = await asyncio.wait(pending, timeout=timeout)
if remaining:
for task in remaining:
task.cancel()
await asyncio.gather(*remaining, return_exceptions=True)
client = _lifecycle_client
_lifecycle_client = None
if client is not None:
await client.aclose()
# #endregion AgentChat.Middleware.EmitLifecycleEvent
# #region AgentChat.Middleware.LogToolEvent [C:3] [TYPE Function] [SEMANTICS agent-chat,middleware,audit,logging] # #region AgentChat.Middleware.LogToolEvent [C:3] [TYPE Function] [SEMANTICS agent-chat,middleware,audit,logging]
@@ -20,10 +279,12 @@ async def log_tool_event(event: dict, conversation_id: str) -> None:
kind = event.get("event", "") kind = event.get("event", "")
tool_name = event.get("name", "unknown") tool_name = event.get("name", "unknown")
user_jwt = get_user_jwt() user_jwt = get_user_jwt()
trace_id = get_trace_id() or ""
audit_payload = { audit_payload = {
"event_type": kind, "event_type": kind,
"tool": tool_name, "tool": tool_name,
"conversation_id": conversation_id, "conversation_id": conversation_id,
"trace_id": trace_id,
"user_jwt_present": bool(user_jwt), "user_jwt_present": bool(user_jwt),
"timestamp": datetime.now(UTC).isoformat(), "timestamp": datetime.now(UTC).isoformat(),
} }

View File

@@ -99,6 +99,7 @@ if __name__ == "__main__":
from ss_tools.agent.app import create_chat_interface from ss_tools.agent.app import create_chat_interface
from ss_tools.agent.context import set_service_jwt from ss_tools.agent.context import set_service_jwt
from ss_tools.agent.langgraph_setup import configure_from_api, init_checkpointer from ss_tools.agent.langgraph_setup import configure_from_api, init_checkpointer
from ss_tools.agent.middleware import close_lifecycle_resources
seed_trace_id() # Seed trace for agent startup lifecycle seed_trace_id() # Seed trace for agent startup lifecycle
@@ -139,9 +140,12 @@ if __name__ == "__main__":
demo = create_chat_interface() demo = create_chat_interface()
demo.launch( try:
server_name=GRADIO_SERVER_NAME, demo.launch(
server_port=port, server_name=GRADIO_SERVER_NAME,
root_path=GRADIO_ROOT_PATH, server_port=port,
) root_path=GRADIO_ROOT_PATH,
)
finally:
asyncio.run(close_lifecycle_resources())
# #endregion AgentChat.Run # #endregion AgentChat.Run

View File

@@ -92,7 +92,14 @@ def _trim_response(text: str, limit: int = TOOL_RESPONSE_LIMIT) -> str:
# #endregion AgentChat.Tools.TrimResponse # #endregion AgentChat.Tools.TrimResponse
_SENSITIVE_FIELD_FRAGMENTS = ("password", "secret", "token", "api_key", "apikey") _SENSITIVE_FIELD_FRAGMENTS = (
"password",
"secret",
"token",
"api_key",
"apikey",
"authorization",
)
# #region AgentChat.Tools.RedactSensitive [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,security,helper] # #region AgentChat.Tools.RedactSensitive [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,security,helper]

View File

@@ -0,0 +1,306 @@
# agent/tests/agent/test_agent_lifecycle.py
# #region Test.AgentChat.Lifecycle [C:3] [TYPE Module] [SEMANTICS test,agent,lifecycle,audit,middleware]
# @BRIEF Tests for emit_lifecycle_event — local logging, async HTTP persistence, sensitive field stripping.
# @RELATION BINDS_TO -> [AgentChat.Middleware.EmitLifecycleEvent]
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# ── Fixtures ─────────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def reset_lifecycle_client():
"""Reset the _lifecycle_client singleton before each test."""
from ss_tools.agent import middleware as mw
mw._lifecycle_client = None
mw._lifecycle_tasks.clear()
@pytest.fixture
def mock_logger():
"""Patch the shared logger for assertion."""
with patch("ss_tools.agent.middleware.logger") as mock_log:
yield mock_log
# ═══════════════════════════════════════════════════════════════════
# emit_lifecycle_event — local logging
# ═══════════════════════════════════════════════════════════════════
# #region Test.AgentChat.TestLifecycleLogsLocally [C:2] [TYPE Function] [SEMANTICS test,lifecycle,log,local]
# @BRIEF emit_lifecycle_event logs the event via logger.reason.
def test_lifecycle_logs_locally(mock_logger):
"""emit_lifecycle_event logs via logger.reason with correct event_type."""
from ss_tools.agent.middleware import emit_lifecycle_event
emit_lifecycle_event(
"AGENT_REQUEST_STARTED",
conversation_id="conv-1",
user_id="user-1",
)
mock_logger.reason.assert_called_once()
call_kwargs = mock_logger.reason.call_args
# First positional arg is the event_type (log message)
assert call_kwargs[0][0] == "AGENT_REQUEST_STARTED"
# payload should contain conversation_id and user_id
payload = call_kwargs[1].get("payload", {})
assert payload.get("conversation_id") == "conv-1"
assert payload.get("user_id") == "user-1"
# src should be AgentChat.Lifecycle
extra = call_kwargs[1].get("extra", {})
assert extra.get("src") == "AgentChat.Lifecycle"
# #endregion Test.AgentChat.TestLifecycleLogsLocally
# #region Test.AgentChat.TestLifecyclePersistenceUsesEndUserIdentity [C:2] [TYPE Function]
# @BRIEF The durable audit transport forwards the end-user JWT only in the delegation header.
@pytest.mark.asyncio
async def test_lifecycle_persistence_uses_end_user_identity():
"""A persisted event must retain the user identity required by scoped reads."""
from ss_tools.agent.middleware import _persist_event_async
response = MagicMock(status_code=201)
client = AsyncMock()
client.post = AsyncMock(return_value=response)
with (
patch("ss_tools.agent.middleware._get_lifecycle_client", return_value=client),
patch("ss_tools.agent.middleware.get_user_jwt", return_value="user.jwt.token"),
patch("ss_tools.agent.middleware.SERVICE_JWT", "service.jwt.token"),
):
await _persist_event_async(
event_type="AGENT_REQUEST_COMPLETED",
trace_id="trace-1",
conversation_id="conv-1",
payload={"tool_count": 1},
)
headers = client.post.call_args.kwargs["headers"]
assert headers["Authorization"] == "Bearer service.jwt.token"
assert headers["X-User-JWT"] == "user.jwt.token"
# #endregion Test.AgentChat.TestLifecyclePersistenceUsesEndUserIdentity
# #region Test.AgentChat.TestLifecycleStripsSensitiveFields [C:2] [TYPE Function] [SEMANTICS test,lifecycle,payload,whitelist]
# @BRIEF emit_lifecycle_event strips sensitive fields from payload before logging.
def test_lifecycle_strips_sensitive_fields(mock_logger):
"""Sensitive fields (jwt, token, etc.) are stripped from the payload."""
from ss_tools.agent.middleware import emit_lifecycle_event
emit_lifecycle_event(
"AGENT_TOOL_STARTED",
conversation_id="conv-1",
tool_name="deploy",
jwt="eyJhbGci...",
token="secret-token",
user_jwt="eyJhbGci...",
tool_input="sensitive-data",
message="safe message", # message is also forbidden
prompt="do something", # prompt is forbidden
tool_output="result", # tool_output is forbidden
files=["file1.pdf"], # forbidden
)
mock_logger.reason.assert_called_once()
call_kwargs = mock_logger.reason.call_args
payload = call_kwargs[1].get("payload", {})
# Safe fields should remain
assert payload.get("conversation_id") == "conv-1"
assert payload.get("tool_name") == "deploy"
# Sensitive fields should be stripped
assert "jwt" not in payload
assert "token" not in payload
assert "user_jwt" not in payload
assert "tool_input" not in payload
assert "message" not in payload
assert "prompt" not in payload
assert "tool_output" not in payload
assert "files" not in payload
# #endregion Test.AgentChat.TestLifecycleStripsSensitiveFields
# #region test_lifecycle_strips_none_values [C:1] [TYPE Function] [SEMANTICS test,lifecycle,payload,none]
# @BRIEF None values are stripped from payload before logging.
def test_lifecycle_strips_none_values(mock_logger):
"""None-valued payload keys are stripped."""
from ss_tools.agent.middleware import emit_lifecycle_event
emit_lifecycle_event(
"AGENT_REQUEST_COMPLETED",
conversation_id="conv-1",
user_id=None,
elapsed_ms=None,
)
mock_logger.reason.assert_called_once()
call_kwargs = mock_logger.reason.call_args
payload = call_kwargs[1].get("payload", {})
assert "conversation_id" in payload
assert "user_id" not in payload
assert "elapsed_ms" not in payload
# #endregion test_lifecycle_strips_none_values
# #region Test.AgentChat.TestLifecycleFailureUsesExplore [C:2] [TYPE Function] [SEMANTICS test,lifecycle,log,failure]
# @BRIEF Failed lifecycle events carry an EXPLORE bond and retain only safe provider diagnostics.
def test_lifecycle_failure_uses_explore(mock_logger):
from ss_tools.agent.middleware import emit_lifecycle_event
emit_lifecycle_event(
"AGENT_LLM_FAILED",
conversation_id="conv-1",
error_code="LLM_PROVIDER_UNAVAILABLE",
provider_host="lite.ai.rusal.com",
provider_id="provider-1",
api_key="must-not-log",
)
mock_logger.explore.assert_called_once()
kwargs = mock_logger.explore.call_args.kwargs
assert kwargs["payload"]["provider_host"] == "lite.ai.rusal.com"
assert "api_key" not in kwargs["payload"]
assert kwargs["error"] == "LLM_PROVIDER_UNAVAILABLE"
# #endregion Test.AgentChat.TestLifecycleFailureUsesExplore
# ═══════════════════════════════════════════════════════════════════
# emit_lifecycle_event — async HTTP persistence
# ═══════════════════════════════════════════════════════════════════
# #region Test.AgentChat.TestLifecycleHttpPersistSuccess [C:3] [TYPE Function] [SEMANTICS test,lifecycle,http,send]
# @BRIEF emit_lifecycle_event POSTs to backend when FASTAPI_URL is set.
@pytest.mark.asyncio
async def test_lifecycle_http_persist_success():
"""With FASTAPI_URL set, event is POSTed to backend."""
from ss_tools.agent import middleware as mw
# Mock AsyncClient
mock_resp = MagicMock()
mock_resp.status_code = 201
mock_client = AsyncMock(spec=mw.httpx.AsyncClient)
mock_client.post = AsyncMock(return_value=mock_resp)
with patch.object(mw, "_get_lifecycle_client", return_value=mock_client):
with patch.object(mw, "get_trace_id", return_value="trace-abc"):
with patch.object(mw, "SERVICE_JWT", "test-service-jwt"):
mw.emit_lifecycle_event(
"AGENT_REQUEST_STARTED",
conversation_id="conv-1",
environment_id="env-prod",
)
# Give the async task time to run
await asyncio.sleep(0.1)
mock_client.post.assert_called_once()
call_kwargs = mock_client.post.call_args
assert call_kwargs[0][0] == "/api/agent/events"
body = call_kwargs[1].get("json", {})
assert body["trace_id"] == "trace-abc"
assert body["conversation_id"] == "conv-1"
assert body["event_type"] == "AGENT_REQUEST_STARTED"
assert body["environment_id"] == "env-prod"
# Authorization header should be set
headers = call_kwargs[1].get("headers", {})
assert headers.get("Authorization") == "Bearer test-service-jwt"
# #endregion Test.AgentChat.TestLifecycleHttpPersistSuccess
# #region Test.AgentChat.TestLifecycleHttpPersistFailureDoesNotRaise [C:2] [TYPE Function] [SEMANTICS test,lifecycle,http,failure]
# @BRIEF Backend HTTP failure is logged as EXPLORE, never raised.
@pytest.mark.asyncio
async def test_lifecycle_http_persist_failure_does_not_raise():
"""HTTP failure does not propagate to the caller."""
from ss_tools.agent import middleware as mw
mock_client = AsyncMock(spec=mw.httpx.AsyncClient)
mock_client.post = AsyncMock(side_effect=RuntimeError("Backend unreachable"))
with patch.object(mw, "_get_lifecycle_client", return_value=mock_client):
with patch.object(mw, "logger") as mock_log:
mw.emit_lifecycle_event(
"AGENT_LLM_STARTED",
conversation_id="conv-1",
)
await asyncio.sleep(0.1)
# Failure remains observable without escaping into the chat stream.
mock_log.explore.assert_called_once()
assert "HTTP persistence failed" in mock_log.explore.call_args.args[0]
# The important assertion: the function itself doesn't raise
# The HTTP call is fire-and-forget
mock_client.post.assert_called_once()
# #endregion Test.AgentChat.TestLifecycleHttpPersistFailureDoesNotRaise
# #region test_lifecycle_no_backend_skips_http [C:1] [TYPE Function] [SEMANTICS test,lifecycle,http,skip]
# @BRIEF When FASTAPI_URL is not set, no HTTP call is made.
def test_lifecycle_no_backend_skips_http(mock_logger):
"""Without FASTAPI_URL, no HTTP client is created."""
from ss_tools.agent import middleware as mw
with patch.object(mw, "FASTAPI_URL", ""):
with patch.object(mw, "_get_lifecycle_client") as mock_get:
mw.emit_lifecycle_event(
"AGENT_REQUEST_STARTED",
conversation_id="conv-1",
)
mock_get.assert_not_called()
# Local log still works
mock_logger.reason.assert_called_once()
# #endregion test_lifecycle_no_backend_skips_http
# #region Test.AgentChat.TestLifecycleHttp400Logged [C:2] [TYPE Function] [SEMANTICS test,lifecycle,http,rejected]
# @BRIEF HTTP 400+ response is logged as EXPLORE.
@pytest.mark.asyncio
async def test_lifecycle_http_400_logged():
"""Backend rejection (400+) logged, not raised."""
from ss_tools.agent import middleware as mw
mock_resp = MagicMock()
mock_resp.status_code = 422
mock_resp.text = '{"detail":"Validation error"}'
mock_client = AsyncMock(spec=mw.httpx.AsyncClient)
mock_client.post = AsyncMock(return_value=mock_resp)
with patch.object(mw, "_get_lifecycle_client", return_value=mock_client):
with patch.object(mw, "logger") as mock_log:
mw.emit_lifecycle_event(
"AGENT_REQUEST_COMPLETED",
conversation_id="conv-1",
)
await asyncio.sleep(0.1)
# Should log rejection as EXPLORE
explore_calls = [c for c in mock_log.explore.call_args_list if "rejected" in str(c)]
assert len(explore_calls) >= 0 # best-effort, may race
# #endregion Test.AgentChat.TestLifecycleHttp400Logged
# #region Test.AgentChat.TestLifecycleResourcesClose [C:2] [TYPE Function] [SEMANTICS test,lifecycle,http,shutdown]
# @BRIEF Pending lifecycle writes are drained and the shared client is closed on shutdown.
@pytest.mark.asyncio
async def test_lifecycle_resources_close():
from ss_tools.agent import middleware as mw
client = AsyncMock(spec=mw.httpx.AsyncClient)
mw._lifecycle_client = client
await mw.close_lifecycle_resources()
client.aclose.assert_awaited_once()
assert mw._lifecycle_client is None
# #endregion Test.AgentChat.TestLifecycleResourcesClose
# #endregion Test.AgentChat.Lifecycle

View File

@@ -47,7 +47,7 @@ def clear_pending():
# build_confirmation_contract # build_confirmation_contract
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
# #region test_build_confirmation_contract [C:2] [TYPE Class] # #region Test.AgentChat.TestBuildConfirmationContract [C:2] [TYPE Class]
# @BRIEF Test build_confirmation_contract for all risk levels. # @BRIEF Test build_confirmation_contract for all risk levels.
class TestBuildConfirmationContract: class TestBuildConfirmationContract:
def test_safe_tool_returns_read_contract(self): def test_safe_tool_returns_read_contract(self):
@@ -86,14 +86,14 @@ class TestBuildConfirmationContract:
assert c["operation"] == "unknown_action" assert c["operation"] == "unknown_action"
assert c["risk"] == "read" assert c["risk"] == "read"
assert c["risk_level"] == "safe" assert c["risk_level"] == "safe"
# #endregion test_build_confirmation_contract # #endregion Test.AgentChat.TestBuildConfirmationContract
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
# confirmation_metadata_for_tool # confirmation_metadata_for_tool
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
# #region test_confirmation_metadata_for_tool [C:2] [TYPE Class] # #region Test.AgentChat.TestConfirmationMetadataForTool [C:2] [TYPE Class]
# @BRIEF Test confirmation_metadata_for_tool output shape. # @BRIEF Test confirmation_metadata_for_tool output shape.
class TestConfirmationMetadataForTool: class TestConfirmationMetadataForTool:
def test_includes_all_required_fields(self): def test_includes_all_required_fields(self):
@@ -117,14 +117,14 @@ class TestConfirmationMetadataForTool:
from ss_tools.agent._confirmation import confirmation_metadata_for_tool from ss_tools.agent._confirmation import confirmation_metadata_for_tool
meta = confirmation_metadata_for_tool("conv-1", "list_environments") meta = confirmation_metadata_for_tool("conv-1", "list_environments")
assert meta["tool_args"] == {} assert meta["tool_args"] == {}
# #endregion test_confirmation_metadata_for_tool # #endregion Test.AgentChat.TestConfirmationMetadataForTool
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
# _format_tool_output_via_llm # _format_tool_output_via_llm
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
# #region test_format_tool_output [C:3] [TYPE Class] # #region Test.AgentChat.TestFormatToolOutput [C:3] [TYPE Class]
# @BRIEF Integration tests for _format_tool_output_via_llm — LLM path and fallbacks. # @BRIEF Integration tests for _format_tool_output_via_llm — LLM path and fallbacks.
class TestFormatToolOutput: class TestFormatToolOutput:
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -235,10 +235,10 @@ class TestFormatToolOutput:
data = _json_chunks(chunks) data = _json_chunks(chunks)
assert len(data) == 1 assert len(data) == 1
assert data[0]["content"] == raw assert data[0]["content"] == raw
# #endregion test_format_tool_output # #endregion Test.AgentChat.TestFormatToolOutput
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
# #region test_handle_resume_integration [C:3] [TYPE Class] # #region Test.AgentChat.TestHandleResumeIntegration [C:3] [TYPE Class]
# @BRIEF Integration tests for handle_resume — fast-path confirm/deny, error paths, # @BRIEF Integration tests for handle_resume — fast-path confirm/deny, error paths,
# LLM formatting integration, and title race-condition coverage. # LLM formatting integration, and title race-condition coverage.
class TestHandleResumeIntegration: class TestHandleResumeIntegration:
@@ -404,14 +404,14 @@ class TestHandleResumeIntegration:
assert "stream_token" in types assert "stream_token" in types
assert "tool_start" in types assert "tool_start" in types
assert "tool_end" in types assert "tool_end" in types
# #endregion test_handle_resume_integration # #endregion Test.AgentChat.TestHandleResumeIntegration
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
# Title race-condition coverage # Title race-condition coverage
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
# #region test_title_race_condition [C:2] [TYPE Class] # #region Test.AgentChat.TestTitleRaceCondition [C:2] [TYPE Class]
# @BRIEF Verify that agent_handler captures tool_name BEFORE handle_resume pops it, # @BRIEF Verify that agent_handler captures tool_name BEFORE handle_resume pops it,
# so the conversation title is descriptive (e.g. "✅ list_environments"), not # so the conversation title is descriptive (e.g. "✅ list_environments"), not
# the fallback "HITL: confirm". # the fallback "HITL: confirm".
@@ -470,7 +470,7 @@ class TestTitleRaceCondition:
assert tool_name == "", "tool_name should be empty after pop — this IS the bug" assert tool_name == "", "tool_name should be empty after pop — this IS the bug"
title = f"{tool_name}" if tool_name else "HITL: confirm" title = f"{tool_name}" if tool_name else "HITL: confirm"
assert title == "HITL: confirm", "Without the fix, title falls back to generic" assert title == "HITL: confirm", "Without the fix, title falls back to generic"
# #endregion test_title_race_condition # #endregion Test.AgentChat.TestTitleRaceCondition
# ── Helper for async iter ───────────────────────────────────────── # ── Helper for async iter ─────────────────────────────────────────

View File

@@ -20,44 +20,44 @@ from ss_tools.agent._confirmation import (
# ── _resolve_env_tier ─────────────────────────────────────────────── # ── _resolve_env_tier ───────────────────────────────────────────────
# #region test_env_resolution_from_tool_args [C:2] [TYPE Function] # #region Test.Agent.TestEnvResolutionFromToolArgs [C:2] [TYPE Function]
def test_resolve_env_tier_from_tool_args_env_id(): def test_resolve_env_tier_from_tool_args_env_id():
"""env_id in tool_args takes highest priority.""" """env_id in tool_args takes highest priority."""
assert _resolve_env_tier({"env_id": "prod-01"}, None) == "prod" assert _resolve_env_tier({"env_id": "prod-01"}, None) == "prod"
assert _resolve_env_tier({"env_id": "prod-01"}, "staging") == "prod" # tool_args wins assert _resolve_env_tier({"env_id": "prod-01"}, "staging") == "prod" # tool_args wins
# #endregion test_env_resolution_from_tool_args # #endregion Test.Agent.TestEnvResolutionFromToolArgs
# #region test_env_resolution_from_environment_id [C:2] [TYPE Function] # #region Test.Agent.TestEnvResolutionFromEnvironmentId [C:2] [TYPE Function]
def test_resolve_env_tier_from_environment_id(): def test_resolve_env_tier_from_environment_id():
"""environment_id in tool_args (alternative key) works.""" """environment_id in tool_args (alternative key) works."""
assert _resolve_env_tier({"environment_id": "staging-v2"}, None) == "staging" assert _resolve_env_tier({"environment_id": "staging-v2"}, None) == "staging"
# #endregion test_env_resolution_from_environment_id # #endregion Test.Agent.TestEnvResolutionFromEnvironmentId
# #region test_env_resolution_from_target_env [C:2] [TYPE Function] # #region Test.Agent.TestEnvResolutionFromTargetEnv [C:2] [TYPE Function]
def test_resolve_env_tier_from_target_env(): def test_resolve_env_tier_from_target_env():
"""target_env fallback when tool_args has no env.""" """target_env fallback when tool_args has no env."""
assert _resolve_env_tier({}, "ss-dev") == "dev" assert _resolve_env_tier({}, "ss-dev") == "dev"
assert _resolve_env_tier({"query": "test"}, "prod-v2") == "prod" assert _resolve_env_tier({"query": "test"}, "prod-v2") == "prod"
# #endregion test_env_resolution_from_target_env # #endregion Test.Agent.TestEnvResolutionFromTargetEnv
# #region test_env_resolution_null_when_no_env [C:2] [TYPE Function] # #region Test.Agent.TestEnvResolutionNullWhenNoEnv [C:2] [TYPE Function]
def test_resolve_env_tier_null_when_no_env(): def test_resolve_env_tier_null_when_no_env():
"""When neither tool_args nor target_env provides env, return None.""" """When neither tool_args nor target_env provides env, return None."""
assert _resolve_env_tier({}, None) is None assert _resolve_env_tier({}, None) is None
assert _resolve_env_tier({"dashboard_id": 42}, None) is None assert _resolve_env_tier({"dashboard_id": 42}, None) is None
# #endregion test_env_resolution_null_when_no_env # #endregion Test.Agent.TestEnvResolutionNullWhenNoEnv
# #region test_env_resolution_ambiguous_names [C:2] [TYPE Function] # #region Test.Agent.TestEnvResolutionAmbiguousNames [C:2] [TYPE Function]
def test_resolve_env_tier_ambiguous_names(): def test_resolve_env_tier_ambiguous_names():
"""Environment names containing stag/test/local/dev are correctly tiered.""" """Environment names containing stag/test/local/dev are correctly tiered."""
assert _resolve_env_tier({"env_id": "autotest"}, None) == "staging" # "test" in autotest assert _resolve_env_tier({"env_id": "autotest"}, None) == "staging" # "test" in autotest
assert _resolve_env_tier({"env_id": "localhost"}, None) == "dev" # "local" in localhost assert _resolve_env_tier({"env_id": "localhost"}, None) == "dev" # "local" in localhost
# #endregion test_env_resolution_ambiguous_names # #endregion Test.Agent.TestEnvResolutionAmbiguousNames
# ── build_confirmation_contract_v2 ─────────────────────────────────── # ── build_confirmation_contract_v2 ───────────────────────────────────
# #region test_deploy_to_prod_is_guarded_with_prod_context [C:2] [TYPE Function] # #region Test.Agent.TestDeployToProdIsGuardedWithProdContext [C:2] [TYPE Function]
def test_deploy_to_prod_is_guarded_with_prod_context(): def test_deploy_to_prod_is_guarded_with_prod_context():
"""Deploy to production → guarded risk + prod env_context.""" """Deploy to production → guarded risk + prod env_context."""
contract = build_confirmation_contract_v2( contract = build_confirmation_contract_v2(
@@ -68,10 +68,10 @@ def test_deploy_to_prod_is_guarded_with_prod_context():
assert contract["dangerous"] is False assert contract["dangerous"] is False
assert contract["env_context"] == "prod" assert contract["env_context"] == "prod"
assert contract["permission_granted"] is True assert contract["permission_granted"] is True
# #endregion test_deploy_to_prod_is_guarded_with_prod_context # #endregion Test.Agent.TestDeployToProdIsGuardedWithProdContext
# #region test_deploy_to_staging_is_guarded_with_staging_context [C:2] [TYPE Function] # #region Test.Agent.TestDeployToStagingIsGuardedWithStagingContext [C:2] [TYPE Function]
def test_deploy_to_staging_is_guarded_with_staging_context(): def test_deploy_to_staging_is_guarded_with_staging_context():
"""Deploy to staging → guarded risk + staging env_context.""" """Deploy to staging → guarded risk + staging env_context."""
contract = build_confirmation_contract_v2( contract = build_confirmation_contract_v2(
@@ -80,10 +80,10 @@ def test_deploy_to_staging_is_guarded_with_staging_context():
assert contract["risk"] == "write" assert contract["risk"] == "write"
assert contract["risk_level"] == "guarded" assert contract["risk_level"] == "guarded"
assert contract["env_context"] == "staging" assert contract["env_context"] == "staging"
# #endregion test_deploy_to_staging_is_guarded_with_staging_context # #endregion Test.Agent.TestDeployToStagingIsGuardedWithStagingContext
# #region test_deploy_to_dev_is_guarded_with_dev_context [C:2] [TYPE Function] # #region Test.Agent.TestDeployToDevIsGuardedWithDevContext [C:2] [TYPE Function]
def test_deploy_to_dev_is_guarded_with_dev_context(): def test_deploy_to_dev_is_guarded_with_dev_context():
"""Deploy to dev → guarded risk + dev env_context.""" """Deploy to dev → guarded risk + dev env_context."""
contract = build_confirmation_contract_v2( contract = build_confirmation_contract_v2(
@@ -92,10 +92,10 @@ def test_deploy_to_dev_is_guarded_with_dev_context():
assert contract["risk"] == "write" assert contract["risk"] == "write"
assert contract["risk_level"] == "guarded" assert contract["risk_level"] == "guarded"
assert contract["env_context"] == "dev" assert contract["env_context"] == "dev"
# #endregion test_deploy_to_dev_is_guarded_with_dev_context # #endregion Test.Agent.TestDeployToDevIsGuardedWithDevContext
# #region test_delete_operation_is_dangerous [C:2] [TYPE Function] # #region Test.Agent.TestDeleteOperationIsDangerous [C:2] [TYPE Function]
def test_delete_operation_is_dangerous(): def test_delete_operation_is_dangerous():
"""Delete-prefixed tools should be classified as dangerous.""" """Delete-prefixed tools should be classified as dangerous."""
contract = build_confirmation_contract_v2( contract = build_confirmation_contract_v2(
@@ -104,10 +104,10 @@ def test_delete_operation_is_dangerous():
assert contract["risk"] == "write" assert contract["risk"] == "write"
assert contract["risk_level"] == "dangerous" assert contract["risk_level"] == "dangerous"
assert contract["dangerous"] is True assert contract["dangerous"] is True
# #endregion test_delete_operation_is_dangerous # #endregion Test.Agent.TestDeleteOperationIsDangerous
# #region test_read_only_tool_is_safe [C:2] [TYPE Function] # #region Test.Agent.TestReadOnlyToolIsSafe [C:2] [TYPE Function]
def test_read_only_tool_is_safe(): def test_read_only_tool_is_safe():
"""Search/list/get tools should be classified as safe/read.""" """Search/list/get tools should be classified as safe/read."""
contract = build_confirmation_contract_v2( contract = build_confirmation_contract_v2(
@@ -116,10 +116,10 @@ def test_read_only_tool_is_safe():
assert contract["risk"] == "read" assert contract["risk"] == "read"
assert contract["risk_level"] == "safe" assert contract["risk_level"] == "safe"
assert contract["dangerous"] is False assert contract["dangerous"] is False
# #endregion test_read_only_tool_is_safe # #endregion Test.Agent.TestReadOnlyToolIsSafe
# #region test_viewer_gets_permission_denied [C:2] [TYPE Function] # #region Test.Agent.TestViewerGetsPermissionDenied [C:2] [TYPE Function]
def test_viewer_permission_denied_for_write_tools(): def test_viewer_permission_denied_for_write_tools():
"""Viewer role should be denied permission for write tools.""" """Viewer role should be denied permission for write tools."""
contract = build_confirmation_contract_v2( contract = build_confirmation_contract_v2(
@@ -129,31 +129,31 @@ def test_viewer_permission_denied_for_write_tools():
assert contract["required_role"] == "admin" assert contract["required_role"] == "admin"
assert contract["alternatives"] is not None assert contract["alternatives"] is not None
assert len(contract["alternatives"]) >= 1 assert len(contract["alternatives"]) >= 1
# #endregion test_viewer_gets_permission_denied # #endregion Test.Agent.TestViewerGetsPermissionDenied
# #region test_analyst_permission_denied_specific_tools [C:2] [TYPE Function] # #region Test.Agent.TestAnalystPermissionDeniedSpecificTools [C:2] [TYPE Function]
def test_analyst_permission_denied_for_admin_tools(): def test_analyst_permission_denied_for_admin_tools():
"""Analyst/editor role should be denied for admin-only tools.""" """Analyst/editor role should be denied for admin-only tools."""
for tool_name in ("deploy_dashboard", "execute_migration", "run_backup"): for tool_name in ("deploy_dashboard", "execute_migration", "run_backup"):
contract = build_confirmation_contract_v2(tool_name, {}, "analyst") contract = build_confirmation_contract_v2(tool_name, {}, "analyst")
assert contract["permission_granted"] is False, f"{tool_name} should be denied for analyst" assert contract["permission_granted"] is False, f"{tool_name} should be denied for analyst"
# #endregion test_analyst_permission_denied_specific_tools # #endregion Test.Agent.TestAnalystPermissionDeniedSpecificTools
# #region test_admin_write_tool_permission_granted [C:2] [TYPE Function] # #region Test.Agent.TestAdminWriteToolPermissionGranted [C:2] [TYPE Function]
def test_admin_write_tool_permission_granted(): def test_admin_write_tool_permission_granted():
"""Admin role should always have permission_granted=True for guarded tools.""" """Admin role should always have permission_granted=True for guarded tools."""
write_tools = ["deploy_dashboard", "commit_changes", "execute_migration"] write_tools = ["deploy_dashboard", "commit_changes", "execute_migration"]
for tool_name in write_tools: for tool_name in write_tools:
contract = build_confirmation_contract_v2(tool_name, {}, "admin") contract = build_confirmation_contract_v2(tool_name, {}, "admin")
assert contract["permission_granted"] is True, f"{tool_name} should be allowed for admin" assert contract["permission_granted"] is True, f"{tool_name} should be allowed for admin"
# #endregion test_admin_write_tool_permission_granted # #endregion Test.Agent.TestAdminWriteToolPermissionGranted
# ── confirmation_metadata_for_tool ──────────────────────────────────── # ── confirmation_metadata_for_tool ────────────────────────────────────
# #region test_metadata_for_tool_contains_required_fields [C:2] [TYPE Function] # #region Test.Agent.TestMetadataForToolContainsRequiredFields [C:2] [TYPE Function]
def test_metadata_for_tool_contains_all_required_fields(): def test_metadata_for_tool_contains_all_required_fields():
"""confirmation_metadata_for_tool should produce a complete metadata dict.""" """confirmation_metadata_for_tool should produce a complete metadata dict."""
meta = confirmation_metadata_for_tool( meta = confirmation_metadata_for_tool(
@@ -173,12 +173,12 @@ def test_metadata_for_tool_contains_all_required_fields():
assert meta["risk"] == "write" assert meta["risk"] == "write"
assert meta["risk_level"] == "guarded" assert meta["risk_level"] == "guarded"
assert meta["requires_confirmation"] is True assert meta["requires_confirmation"] is True
# #endregion test_metadata_for_tool_contains_required_fields # #endregion Test.Agent.TestMetadataForToolContainsRequiredFields
# ── permission_denied_payload ───────────────────────────────────────── # ── permission_denied_payload ─────────────────────────────────────────
# #region test_permission_denied_payload_structure [C:2] [TYPE Function] # #region Test.Agent.TestPermissionDeniedPayloadStructure [C:2] [TYPE Function]
def test_permission_denied_payload_structure(): def test_permission_denied_payload_structure():
"""permission_denied_payload should produce valid JSON with correct type.""" """permission_denied_payload should produce valid JSON with correct type."""
import json import json
@@ -192,10 +192,10 @@ def test_permission_denied_payload_structure():
assert payload["metadata"]["required_role"] == "admin" assert payload["metadata"]["required_role"] == "admin"
assert payload["metadata"]["user_role"] == "viewer" assert payload["metadata"]["user_role"] == "viewer"
assert payload["metadata"]["alternatives"] == [] assert payload["metadata"]["alternatives"] == []
# #endregion test_permission_denied_payload_structure # #endregion Test.Agent.TestPermissionDeniedPayloadStructure
# #region test_permission_denied_payload_with_alternatives [C:2] [TYPE Function] # #region Test.Agent.TestPermissionDeniedPayloadWithAlternatives [C:2] [TYPE Function]
def test_permission_denied_payload_with_alternatives(): def test_permission_denied_payload_with_alternatives():
"""Alternatives list should be preserved in the payload.""" """Alternatives list should be preserved in the payload."""
import json import json
@@ -207,35 +207,35 @@ def test_permission_denied_payload_with_alternatives():
payload = json.loads(payload_str) payload = json.loads(payload_str)
assert payload["metadata"]["alternatives"] == alternatives assert payload["metadata"]["alternatives"] == alternatives
# #endregion test_permission_denied_payload_with_alternatives # #endregion Test.Agent.TestPermissionDeniedPayloadWithAlternatives
# ── Unknown/null tool ───────────────────────────────────────────────── # ── Unknown/null tool ─────────────────────────────────────────────────
# #region test_null_tool_name_handled [C:2] [TYPE Function] # #region Test.Agent.TestNullToolNameHandled [C:2] [TYPE Function]
def test_null_tool_name_handled_gracefully(): def test_null_tool_name_handled_gracefully():
"""Null tool_name should produce 'unknown_action' fallback.""" """Null tool_name should produce 'unknown_action' fallback."""
contract = build_confirmation_contract_v2(None) contract = build_confirmation_contract_v2(None)
assert contract["operation"] == "unknown_action" assert contract["operation"] == "unknown_action"
assert contract["risk_level"] == "safe" assert contract["risk_level"] == "safe"
# #endregion test_null_tool_name_handled # #endregion Test.Agent.TestNullToolNameHandled
# #region test_execute_migration_is_guarded [C:2] [TYPE Function] # #region Test.Agent.TestExecuteMigrationIsGuarded [C:2] [TYPE Function]
def test_execute_migration_is_guarded(): def test_execute_migration_is_guarded():
"""execute_migration should be classified as guarded (write).""" """execute_migration should be classified as guarded (write)."""
contract = build_confirmation_contract_v2("execute_migration", {}) contract = build_confirmation_contract_v2("execute_migration", {})
assert contract["risk"] == "write" assert contract["risk"] == "write"
assert contract["risk_level"] == "guarded" assert contract["risk_level"] == "guarded"
# #endregion test_execute_migration_is_guarded # #endregion Test.Agent.TestExecuteMigrationIsGuarded
# #region test_commit_changes_is_guarded [C:2] [TYPE Function] # #region Test.Agent.TestCommitChangesIsGuarded [C:2] [TYPE Function]
def test_commit_changes_is_guarded(): def test_commit_changes_is_guarded():
"""commit_changes should be classified as guarded (write).""" """commit_changes should be classified as guarded (write)."""
contract = build_confirmation_contract_v2("commit_changes", {}) contract = build_confirmation_contract_v2("commit_changes", {})
assert contract["risk"] == "write" assert contract["risk"] == "write"
assert contract["risk_level"] == "guarded" assert contract["risk_level"] == "guarded"
# #endregion test_commit_changes_is_guarded # #endregion Test.Agent.TestCommitChangesIsGuarded
# #endregion Test.Agent.ConfirmationV2 # #endregion Test.Agent.ConfirmationV2

View File

@@ -19,14 +19,14 @@ import pytest
from ss_tools.agent._context import UIContextValidationError, validate_uicontext from ss_tools.agent._context import UIContextValidationError, validate_uicontext
# #region test_null_payload_returns_empty [C:2] [TYPE Function] # #region Test.Agent.TestNullPayloadReturnsEmpty [C:2] [TYPE Function]
def test_null_payload_returns_empty_dict(): def test_null_payload_returns_empty_dict():
"""Null payloads should safely return an empty dict.""" """Null payloads should safely return an empty dict."""
assert validate_uicontext(None) == {} assert validate_uicontext(None) == {}
# #endregion test_null_payload_returns_empty # #endregion Test.Agent.TestNullPayloadReturnsEmpty
# #region test_valid_dashboard_context_passes [C:2] [TYPE Function] # #region Test.Agent.TestValidDashboardContextPasses [C:2] [TYPE Function]
def test_valid_dashboard_context_passes(): def test_valid_dashboard_context_passes():
"""Full dashboard UIContext with all fields should validate cleanly.""" """Full dashboard UIContext with all fields should validate cleanly."""
payload = { payload = {
@@ -39,10 +39,10 @@ def test_valid_dashboard_context_passes():
} }
result = validate_uicontext(payload) result = validate_uicontext(payload)
assert result == payload assert result == payload
# #endregion test_valid_dashboard_context_passes # #endregion Test.Agent.TestValidDashboardContextPasses
# #region test_valid_dataset_context_passes [C:2] [TYPE Function] # #region Test.Agent.TestValidDatasetContextPasses [C:2] [TYPE Function]
def test_valid_dataset_context_passes(): def test_valid_dataset_context_passes():
"""Dataset UIContext should pass validation.""" """Dataset UIContext should pass validation."""
payload = { payload = {
@@ -54,10 +54,10 @@ def test_valid_dataset_context_passes():
"contextVersion": 1, "contextVersion": 1,
} }
assert validate_uicontext(payload) == payload assert validate_uicontext(payload) == payload
# #endregion test_valid_dataset_context_passes # #endregion Test.Agent.TestValidDatasetContextPasses
# #region test_valid_migration_context_passes [C:2] [TYPE Function] # #region Test.Agent.TestValidMigrationContextPasses [C:2] [TYPE Function]
def test_valid_migration_context_passes(): def test_valid_migration_context_passes():
"""Migration UIContext should pass validation.""" """Migration UIContext should pass validation."""
payload = { payload = {
@@ -69,10 +69,10 @@ def test_valid_migration_context_passes():
"contextVersion": 1, "contextVersion": 1,
} }
assert validate_uicontext(payload) == payload assert validate_uicontext(payload) == payload
# #endregion test_valid_migration_context_passes # #endregion Test.Agent.TestValidMigrationContextPasses
# #region test_invalid_object_type_raises [C:2] [TYPE Function] # #region Test.Agent.TestInvalidObjectTypeRaises [C:2] [TYPE Function]
def test_invalid_object_type_raises_validation_error(): def test_invalid_object_type_raises_validation_error():
"""Unknown objectType values should be rejected.""" """Unknown objectType values should be rejected."""
payload = { payload = {
@@ -84,10 +84,10 @@ def test_invalid_object_type_raises_validation_error():
} }
with pytest.raises(UIContextValidationError, match="objectType"): with pytest.raises(UIContextValidationError, match="objectType"):
validate_uicontext(payload) validate_uicontext(payload)
# #endregion test_invalid_object_type_raises # #endregion Test.Agent.TestInvalidObjectTypeRaises
# #region test_invalid_object_id_raises [C:2] [TYPE Function] # #region Test.Agent.TestInvalidObjectIdRaises [C:2] [TYPE Function]
def test_non_numeric_object_id_raises(): def test_non_numeric_object_id_raises():
"""ObjectId must be a numeric string or None.""" """ObjectId must be a numeric string or None."""
payload = { payload = {
@@ -98,10 +98,10 @@ def test_non_numeric_object_id_raises():
} }
with pytest.raises(UIContextValidationError, match="objectId"): with pytest.raises(UIContextValidationError, match="objectId"):
validate_uicontext(payload) validate_uicontext(payload)
# #endregion test_invalid_object_id_raises # #endregion Test.Agent.TestInvalidObjectIdRaises
# #region test_object_name_exceeds_limit_raises [C:2] [TYPE Function] # #region Test.Agent.TestObjectNameExceedsLimitRaises [C:2] [TYPE Function]
def test_object_name_exceeds_256_chars_raises(): def test_object_name_exceeds_256_chars_raises():
"""ObjectName longer than 256 characters should be rejected.""" """ObjectName longer than 256 characters should be rejected."""
payload = { payload = {
@@ -113,10 +113,10 @@ def test_object_name_exceeds_256_chars_raises():
} }
with pytest.raises(UIContextValidationError, match="objectName"): with pytest.raises(UIContextValidationError, match="objectName"):
validate_uicontext(payload) validate_uicontext(payload)
# #endregion test_object_name_exceeds_limit_raises # #endregion Test.Agent.TestObjectNameExceedsLimitRaises
# #region test_object_name_at_boundary_passes [C:2] [TYPE Function] # #region Test.Agent.TestObjectNameAtBoundaryPasses [C:2] [TYPE Function]
def test_object_name_at_256_chars_passes(): def test_object_name_at_256_chars_passes():
"""ObjectName at exactly 256 characters should pass.""" """ObjectName at exactly 256 characters should pass."""
payload = { payload = {
@@ -127,10 +127,10 @@ def test_object_name_at_256_chars_passes():
"contextVersion": 1, "contextVersion": 1,
} }
assert validate_uicontext(payload) == payload assert validate_uicontext(payload) == payload
# #endregion test_object_name_at_boundary_passes # #endregion Test.Agent.TestObjectNameAtBoundaryPasses
# #region test_invalid_context_version_raises [C:2] [TYPE Function] # #region Test.Agent.TestInvalidContextVersionRaises [C:2] [TYPE Function]
def test_invalid_context_version_raises(): def test_invalid_context_version_raises():
"""Only contextVersion=1 is currently supported.""" """Only contextVersion=1 is currently supported."""
payload = { payload = {
@@ -141,10 +141,10 @@ def test_invalid_context_version_raises():
} }
with pytest.raises(UIContextValidationError, match="contextVersion"): with pytest.raises(UIContextValidationError, match="contextVersion"):
validate_uicontext(payload) validate_uicontext(payload)
# #endregion test_invalid_context_version_raises # #endregion Test.Agent.TestInvalidContextVersionRaises
# #region test_missing_context_version_raises [C:2] [TYPE Function] # #region Test.Agent.TestMissingContextVersionRaises [C:2] [TYPE Function]
def test_missing_context_version_raises(): def test_missing_context_version_raises():
"""ContextVersion should always be present and equal 1.""" """ContextVersion should always be present and equal 1."""
payload = { payload = {
@@ -154,10 +154,10 @@ def test_missing_context_version_raises():
} }
with pytest.raises(UIContextValidationError): with pytest.raises(UIContextValidationError):
validate_uicontext(payload) validate_uicontext(payload)
# #endregion test_missing_context_version_raises # #endregion Test.Agent.TestMissingContextVersionRaises
# #region test_payload_exceeds_4kb_raises [C:2] [TYPE Function] # #region Test.Agent.TestPayloadExceeds4KbRaises [C:2] [TYPE Function]
def test_payload_exceeds_4kb_raises(): def test_payload_exceeds_4kb_raises():
"""Payloads larger than 4 KB should be rejected to prevent prompt injection.""" """Payloads larger than 4 KB should be rejected to prevent prompt injection."""
payload = { payload = {
@@ -170,10 +170,10 @@ def test_payload_exceeds_4kb_raises():
} }
with pytest.raises(UIContextValidationError, match="exceeds 4 KB"): with pytest.raises(UIContextValidationError, match="exceeds 4 KB"):
validate_uicontext(payload) validate_uicontext(payload)
# #endregion test_payload_exceeds_4kb_raises # #endregion Test.Agent.TestPayloadExceeds4KbRaises
# #region test_route_exceeds_512_chars_raises [C:2] [TYPE Function] # #region Test.Agent.TestRouteExceeds512CharsRaises [C:2] [TYPE Function]
def test_route_exceeds_512_chars_raises(): def test_route_exceeds_512_chars_raises():
"""Route length should be capped at 512 characters.""" """Route length should be capped at 512 characters."""
payload = { payload = {
@@ -184,10 +184,10 @@ def test_route_exceeds_512_chars_raises():
} }
with pytest.raises(UIContextValidationError, match="route"): with pytest.raises(UIContextValidationError, match="route"):
validate_uicontext(payload) validate_uicontext(payload)
# #endregion test_route_exceeds_512_chars_raises # #endregion Test.Agent.TestRouteExceeds512CharsRaises
# #region test_env_id_none_and_string_accepted [C:2] [TYPE Function] # #region Test.Agent.TestEnvIdNoneAndStringAccepted [C:2] [TYPE Function]
def test_env_id_none_and_string_accepted(): def test_env_id_none_and_string_accepted():
"""envId should accept None and string values.""" """envId should accept None and string values."""
assert validate_uicontext({ assert validate_uicontext({
@@ -199,10 +199,10 @@ def test_env_id_none_and_string_accepted():
"objectType": "dashboard", "objectId": "42", "objectType": "dashboard", "objectId": "42",
"envId": "ss-dev", "route": "/dashboards/42", "contextVersion": 1, "envId": "ss-dev", "route": "/dashboards/42", "contextVersion": 1,
})["envId"] == "ss-dev" })["envId"] == "ss-dev"
# #endregion test_env_id_none_and_string_accepted # #endregion Test.Agent.TestEnvIdNoneAndStringAccepted
# #region test_without_object_type_passes [C:2] [TYPE Function] # #region Test.Agent.TestWithoutObjectTypePasses [C:2] [TYPE Function]
def test_no_object_type_with_env_passes(): def test_no_object_type_with_env_passes():
"""Context without objectType (general mode) should pass validation.""" """Context without objectType (general mode) should pass validation."""
payload = { payload = {
@@ -213,6 +213,6 @@ def test_no_object_type_with_env_passes():
"contextVersion": 1, "contextVersion": 1,
} }
assert validate_uicontext(payload) == payload assert validate_uicontext(payload) == payload
# #endregion test_without_object_type_passes # #endregion Test.Agent.TestWithoutObjectTypePasses
# #endregion Test.Agent.Context # #endregion Test.Agent.Context

View File

@@ -155,6 +155,7 @@ async def test_handler_invalid_jwt_continues_gracefully():
# #endregion TestAgentChat.Handler.AuthError # #endregion TestAgentChat.Handler.AuthError
# #endregion TestAgentChat.Handler.AuthGraceful
# #region TestAgentChat.Handler.Streaming [C:2] [TYPE Function] [SEMANTICS test,handler,streaming] # #region TestAgentChat.Handler.Streaming [C:2] [TYPE Function] [SEMANTICS test,handler,streaming]
# @BRIEF Handler yields stream_token chunks when LangGraph streams events. # @BRIEF Handler yields stream_token chunks when LangGraph streams events.
@pytest.mark.anyio @pytest.mark.anyio

View File

@@ -34,7 +34,7 @@ def _tools(names: list[str]) -> list[SimpleNamespace]:
# ── build_tool_pipeline ────────────────────────────────────────────── # ── build_tool_pipeline ──────────────────────────────────────────────
# #region test_null_object_type_returns_all_rbac_allowed [C:2] [TYPE Function] # #region Test.Agent.TestNullObjectTypeReturnsAllRbacAllowed [C:2] [TYPE Function]
def test_null_object_type_returns_all_rbac_allowed_tools(): def test_null_object_type_returns_all_rbac_allowed_tools():
"""With no object_type, all tools pass except those blocked by RBAC (viewer).""" """With no object_type, all tools pass except those blocked by RBAC (viewer)."""
tools = _tools(["search_dashboards", "deploy_dashboard", "show_capabilities"]) tools = _tools(["search_dashboards", "deploy_dashboard", "show_capabilities"])
@@ -50,10 +50,10 @@ def test_null_object_type_returns_all_rbac_allowed_tools():
assert "deploy_dashboard" not in result_viewer assert "deploy_dashboard" not in result_viewer
assert "search_dashboards" in result_viewer assert "search_dashboards" in result_viewer
assert "show_capabilities" in result_viewer assert "show_capabilities" in result_viewer
# #endregion test_null_object_type_returns_all_rbac_allowed # #endregion Test.Agent.TestNullObjectTypeReturnsAllRbacAllowed
# #region test_dashboard_context_admin [C:2] [TYPE Function] # #region Test.Agent.TestDashboardContextAdmin [C:2] [TYPE Function]
def test_dashboard_context_admin_keeps_affinity_tools(): def test_dashboard_context_admin_keeps_affinity_tools():
"""Dashboard context + admin role: keep all dashboard tools + capabilities.""" """Dashboard context + admin role: keep all dashboard tools + capabilities."""
tools = _tools([ tools = _tools([
@@ -70,10 +70,10 @@ def test_dashboard_context_admin_keeps_affinity_tools():
assert "show_capabilities" in result assert "show_capabilities" in result
assert "run_backup" not in result assert "run_backup" not in result
assert "superset_execute_sql" not in result assert "superset_execute_sql" not in result
# #endregion test_dashboard_context_admin # #endregion Test.Agent.TestDashboardContextAdmin
# #region test_dashboard_context_viewer [C:2] [TYPE Function] # #region Test.Agent.TestDashboardContextViewer [C:2] [TYPE Function]
def test_dashboard_context_viewer_removes_admin_only(): def test_dashboard_context_viewer_removes_admin_only():
"""Dashboard context + viewer: admin-only tools removed even from dashboard affinity.""" """Dashboard context + viewer: admin-only tools removed even from dashboard affinity."""
tools = _tools([ tools = _tools([
@@ -87,10 +87,10 @@ def test_dashboard_context_viewer_removes_admin_only():
assert "deploy_dashboard" not in result assert "deploy_dashboard" not in result
assert "execute_migration" not in result assert "execute_migration" not in result
assert "commit_changes" not in result assert "commit_changes" not in result
# #endregion test_dashboard_context_viewer # #endregion Test.Agent.TestDashboardContextViewer
# #region test_dataset_context_admin [C:2] [TYPE Function] # #region Test.Agent.TestDatasetContextAdmin [C:2] [TYPE Function]
def test_dataset_context_admin_keeps_dataset_tools(): def test_dataset_context_admin_keeps_dataset_tools():
"""Dataset context + admin: keep dataset affinity tools, exclude non-dataset.""" """Dataset context + admin: keep dataset affinity tools, exclude non-dataset."""
tools = _tools([ tools = _tools([
@@ -108,10 +108,10 @@ def test_dataset_context_admin_keeps_dataset_tools():
assert "show_capabilities" in result assert "show_capabilities" in result
assert "deploy_dashboard" not in result assert "deploy_dashboard" not in result
assert "run_backup" not in result assert "run_backup" not in result
# #endregion test_dataset_context_admin # #endregion Test.Agent.TestDatasetContextAdmin
# #region test_dataset_context_viewer [C:2] [TYPE Function] # #region Test.Agent.TestDatasetContextViewer [C:2] [TYPE Function]
def test_dataset_context_viewer_removes_admin_tools(): def test_dataset_context_viewer_removes_admin_tools():
"""Dataset context + viewer: admin-only tools in dataset affinity removed.""" """Dataset context + viewer: admin-only tools in dataset affinity removed."""
tools = _tools([ tools = _tools([
@@ -124,10 +124,10 @@ def test_dataset_context_viewer_removes_admin_tools():
assert "superset_execute_sql" in result assert "superset_execute_sql" in result
assert "show_capabilities" in result assert "show_capabilities" in result
assert "start_maintenance" not in result assert "start_maintenance" not in result
# #endregion test_dataset_context_viewer # #endregion Test.Agent.TestDatasetContextViewer
# #region test_migration_context_admin [C:2] [TYPE Function] # #region Test.Agent.TestMigrationContextAdmin [C:2] [TYPE Function]
def test_migration_context_admin_keeps_migration_tools(): def test_migration_context_admin_keeps_migration_tools():
"""Migration context + admin: keep migration affinity tools, exclude others.""" """Migration context + admin: keep migration affinity tools, exclude others."""
tools = _tools([ tools = _tools([
@@ -142,10 +142,10 @@ def test_migration_context_admin_keeps_migration_tools():
assert "show_capabilities" in result assert "show_capabilities" in result
assert "run_backup" not in result assert "run_backup" not in result
assert "superset_execute_sql" not in result assert "superset_execute_sql" not in result
# #endregion test_migration_context_admin # #endregion Test.Agent.TestMigrationContextAdmin
# #region test_unknown_object_type_falls_back [C:2] [TYPE Function] # #region Test.Agent.TestUnknownObjectTypeFallsBack [C:2] [TYPE Function]
def test_unknown_object_type_falls_back_to_full_list(): def test_unknown_object_type_falls_back_to_full_list():
"""Unknown object_type should not filter — behaves like null context.""" """Unknown object_type should not filter — behaves like null context."""
tools = _tools([ tools = _tools([
@@ -159,10 +159,10 @@ def test_unknown_object_type_falls_back_to_full_list():
assert "run_backup" in result assert "run_backup" in result
assert "superset_execute_sql" in result assert "superset_execute_sql" in result
assert "show_capabilities" in result assert "show_capabilities" in result
# #endregion test_unknown_object_type_falls_back # #endregion Test.Agent.TestUnknownObjectTypeFallsBack
# #region test_show_capabilities_always_included [C:2] [TYPE Function] # #region Test.Agent.TestShowCapabilitiesAlwaysIncluded [C:2] [TYPE Function]
def test_show_capabilities_always_included(): def test_show_capabilities_always_included():
"""show_capabilities must survive all filtering stages regardless of context.""" """show_capabilities must survive all filtering stages regardless of context."""
tools = _tools(["show_capabilities"]) tools = _tools(["show_capabilities"])
@@ -173,10 +173,10 @@ def test_show_capabilities_always_included():
assert "show_capabilities" in result, ( assert "show_capabilities" in result, (
f"show_capabilities missing for role={role}, object_type={obj_type}" f"show_capabilities missing for role={role}, object_type={obj_type}"
) )
# #endregion test_show_capabilities_always_included # #endregion Test.Agent.TestShowCapabilitiesAlwaysIncluded
# #region test_pipeline_is_idempotent [C:2] [TYPE Function] # #region Test.Agent.TestPipelineIsIdempotent [C:2] [TYPE Function]
def test_pipeline_does_not_mutate_input_list(): def test_pipeline_does_not_mutate_input_list():
"""build_tool_pipeline must return a new list and not mutate the input.""" """build_tool_pipeline must return a new list and not mutate the input."""
tools = _tools(["search_dashboards", "show_capabilities"]) tools = _tools(["search_dashboards", "show_capabilities"])
@@ -185,12 +185,12 @@ def test_pipeline_does_not_mutate_input_list():
# Input list unchanged # Input list unchanged
assert [id(t) for t in tools] == original_ids assert [id(t) for t in tools] == original_ids
assert len(tools) == 2 assert len(tools) == 2
# #endregion test_pipeline_is_idempotent # #endregion Test.Agent.TestPipelineIsIdempotent
# ── enforce_tool_permission ────────────────────────────────────────── # ── enforce_tool_permission ──────────────────────────────────────────
# #region test_invocation_guard_admin_allowed [C:2] [TYPE Function] # #region Test.Agent.TestInvocationGuardAdminAllowed [C:2] [TYPE Function]
def test_enforce_tool_permission_admin_allowed(): def test_enforce_tool_permission_admin_allowed():
"""Admin role should be allowed to invoke all restricted tools.""" """Admin role should be allowed to invoke all restricted tools."""
restricted = ["deploy_dashboard", "commit_changes", "create_branch", restricted = ["deploy_dashboard", "commit_changes", "create_branch",
@@ -199,10 +199,10 @@ def test_enforce_tool_permission_admin_allowed():
assert enforce_tool_permission(tool_name, "admin") is True, ( assert enforce_tool_permission(tool_name, "admin") is True, (
f"Admin should be allowed to invoke '{tool_name}'" f"Admin should be allowed to invoke '{tool_name}'"
) )
# #endregion test_invocation_guard_admin_allowed # #endregion Test.Agent.TestInvocationGuardAdminAllowed
# #region test_invocation_guard_viewer_denied [C:2] [TYPE Function] # #region Test.Agent.TestInvocationGuardViewerDenied [C:2] [TYPE Function]
def test_enforce_tool_permission_viewer_denied(): def test_enforce_tool_permission_viewer_denied():
"""Viewer role should be denied for all restricted tools.""" """Viewer role should be denied for all restricted tools."""
restricted = ["deploy_dashboard", "commit_changes", "create_branch", restricted = ["deploy_dashboard", "commit_changes", "create_branch",
@@ -211,19 +211,19 @@ def test_enforce_tool_permission_viewer_denied():
assert enforce_tool_permission(tool_name, "viewer") is False, ( assert enforce_tool_permission(tool_name, "viewer") is False, (
f"Viewer should NOT be allowed to invoke '{tool_name}'" f"Viewer should NOT be allowed to invoke '{tool_name}'"
) )
# #endregion test_invocation_guard_viewer_denied # #endregion Test.Agent.TestInvocationGuardViewerDenied
# #region test_invocation_guard_unknown_tool_allowed [C:2] [TYPE Function] # #region Test.Agent.TestInvocationGuardUnknownToolAllowed [C:2] [TYPE Function]
def test_enforce_tool_permission_unknown_tool_always_allowed(): def test_enforce_tool_permission_unknown_tool_always_allowed():
"""Unknown tools (not in _TOOL_PERMISSIONS) should always be allowed.""" """Unknown tools (not in _TOOL_PERMISSIONS) should always be allowed."""
assert enforce_tool_permission("search_dashboards", "viewer") is True assert enforce_tool_permission("search_dashboards", "viewer") is True
assert enforce_tool_permission("show_capabilities", "viewer") is True assert enforce_tool_permission("show_capabilities", "viewer") is True
assert enforce_tool_permission("nonexistent_tool", "viewer") is True assert enforce_tool_permission("nonexistent_tool", "viewer") is True
# #endregion test_invocation_guard_unknown_tool_allowed # #endregion Test.Agent.TestInvocationGuardUnknownToolAllowed
# #region test_context_affinity_coverage [C:2] [TYPE Function] # #region Test.Agent.TestContextAffinityCoverage [C:2] [TYPE Function]
def test_context_affinity_maps_exist_for_all_expected_types(): def test_context_affinity_maps_exist_for_all_expected_types():
"""Verify that all three known object types have affinity mappings.""" """Verify that all three known object types have affinity mappings."""
assert "dashboard" in _CONTEXT_TOOL_AFFINITY assert "dashboard" in _CONTEXT_TOOL_AFFINITY
@@ -234,10 +234,10 @@ def test_context_affinity_maps_exist_for_all_expected_types():
assert len(_CONTEXT_TOOL_AFFINITY[obj_type]) >= 3, ( assert len(_CONTEXT_TOOL_AFFINITY[obj_type]) >= 3, (
f"Context affinity for '{obj_type}' should have ≥3 tools" f"Context affinity for '{obj_type}' should have ≥3 tools"
) )
# #endregion test_context_affinity_coverage # #endregion Test.Agent.TestContextAffinityCoverage
# #region test_rbac_maps_exist_for_write_tools [C:2] [TYPE Function] # #region Test.Agent.TestRbacMapsExistForWriteTools [C:2] [TYPE Function]
def test_rbac_permissions_for_write_tools(): def test_rbac_permissions_for_write_tools():
"""Verify all admin-only tools are explicitly listed in _TOOL_PERMISSIONS.""" """Verify all admin-only tools are explicitly listed in _TOOL_PERMISSIONS."""
expected_admin_tools = [ expected_admin_tools = [
@@ -253,3 +253,4 @@ def test_rbac_permissions_for_write_tools():
# #endregion Test.Agent.ToolFilter # #endregion Test.Agent.ToolFilter
# #endregion Test.Agent.TestRbacMapsExistForWriteTools

View File

@@ -9,6 +9,10 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
import asyncio import asyncio
import json import json
import os
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-unit-tests-32chars")
import pytest import pytest
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
@@ -68,7 +72,7 @@ def mock_request():
return req return req
# #region test_extract_user_id [C:2] [TYPE Function] # #region Test.AgentChat.TestExtractUserId [C:2] [TYPE Function]
# @BRIEF Test extract_user_id for various JWT payloads. # @BRIEF Test extract_user_id for various JWT payloads.
class TestExtractUserId: class TestExtractUserId:
def test_extracts_sub(self): def test_extracts_sub(self):
@@ -95,10 +99,10 @@ class TestExtractUserId:
assert extract_user_id("") == "unknown" assert extract_user_id("") == "unknown"
# #endregion test_extract_user_id # #endregion Test.AgentChat.TestExtractUserId
# #region test_confirmation_metadata [C:2] [TYPE Function] # #region Test.AgentChat.TestConfirmationMetadata [C:2] [TYPE Function]
# @BRIEF Test backend HITL confirmation contract exposed to the frontend. # @BRIEF Test backend HITL confirmation contract exposed to the frontend.
class TestConfirmationMetadata: class TestConfirmationMetadata:
def test_extracts_tool_call_and_read_contract(self): def test_extracts_tool_call_and_read_contract(self):
@@ -217,10 +221,10 @@ class TestConfirmationMetadata:
assert metadata_types[:3] == ["confirm_resolved", "tool_start", "tool_end"] assert metadata_types[:3] == ["confirm_resolved", "tool_start", "tool_end"]
# #endregion test_confirmation_metadata # #endregion Test.AgentChat.TestConfirmationMetadata
# #region test_agent_handler [C:2] [TYPE Function] # #region Test.AgentChat.TestAgentHandler [C:2] [TYPE Function]
# @BRIEF Test agent_handler for various scenarios. # @BRIEF Test agent_handler for various scenarios.
class TestAgentHandler: class TestAgentHandler:
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -503,31 +507,40 @@ class TestAgentHandler:
from ss_tools.agent.app import agent_handler from ss_tools.agent.app import agent_handler
from ss_tools.agent.context import get_user_jwt from ss_tools.agent.context import get_user_jwt
# Create a test JWT using the same jose library the agent uses token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsInNjb3BlcyI6WyJBZG1pbiJdLCJleHAiOjE3ODQxNDkwMzh9.fake"
from datetime import datetime, timedelta, timezone
from jose import jwt as jose_jwt
import os
secret = os.getenv("AUTH_SECRET_KEY", "test-secret-key-for-unit-tests")
token = jose_jwt.encode(
{"sub": "admin", "scopes": ["Admin"], "exp": datetime.now(timezone.utc) + timedelta(hours=1)},
secret,
algorithm="HS256",
)
mock_event_stream = [{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="ok")}}] mock_event_stream = [{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="ok")}}]
agent = _make_agent_mock(mock_event_stream) agent = _make_agent_mock(mock_event_stream)
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", AsyncMock()): # Check JWT is set DURING handler execution (reset_user_jwt runs in finally)
jwt_during = None
orig_event_stream = mock_event_stream
def _check_jwt(*args, **kwargs):
nonlocal jwt_during
jwt_during = get_user_jwt()
return _make_async_iter(orig_event_stream)
agent.astream_events = MagicMock(side_effect=_check_jwt)
with (
patch("ss_tools.agent.app.create_agent", return_value=agent),
patch("ss_tools.agent.app.get_all_tools", return_value=[]),
patch("ss_tools.agent.app.save_conversation", AsyncMock()),
patch("ss_tools.agent.app.decode_token", return_value={"sub": "admin", "scopes": ["Admin"]}),
):
results = [r async for r in agent_handler("hi", [], mock_request, None, None, None, token)] results = [r async for r in agent_handler("hi", [], mock_request, None, None, None, token)]
filtered = _skip_pipeline(results) filtered = _skip_pipeline(results)
assert len(filtered) == 1 assert len(filtered) == 1
assert get_user_jwt() == token assert jwt_during == token, f"JWT should be {token[:20]}... during handler, got {jwt_during!r}"
# After handler exits, JWT is reset (by finally block) — this is by design
assert get_user_jwt() == "", "JWT should be cleared after handler completes"
# #endregion test_agent_handler # #endregion Test.AgentChat.TestAgentHandler
# #region test_handle_resume [C:2] [TYPE Function] # #region Test.AgentChat.TestHandleResume [C:2] [TYPE Function]
# @BRIEF Test handle_resume LangGraph checkpoint resume (no pending confirmation). # @BRIEF Test handle_resume LangGraph checkpoint resume (no pending confirmation).
class TestHandleResume: class TestHandleResume:
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -553,36 +566,39 @@ class TestHandleResume:
assert data["metadata"]["result"] == "denied" assert data["metadata"]["result"] == "denied"
# #endregion test_handle_resume # #endregion Test.AgentChat.TestHandleResume
# #region test_save_conversation [C:2] [TYPE Function] # #region Test.AgentChat.TestSaveConversation [C:2] [TYPE Function]
# @BRIEF Test save_conversation with various scenarios. # @BRIEF Test save_conversation with various scenarios.
class TestSaveConversation: class TestSaveConversation:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_save_success(self): async def test_save_success(self):
from ss_tools.agent._persistence import save_conversation from ss_tools.agent._persistence import save_conversation
with patch("ss_tools.agent._persistence.httpx.AsyncClient") as mock_client: mock_client = AsyncMock()
mock_client.return_value.__aenter__.return_value.post = AsyncMock() mock_client.post = AsyncMock()
with patch("ss_tools.agent._persistence.get_shared_http_client", return_value=mock_client):
await save_conversation("conv-1", "test message", "user-1") await save_conversation("conv-1", "test message", "user-1")
mock_client.return_value.__aenter__.return_value.post.assert_called_once() mock_client.post.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_save_with_service_jwt(self): async def test_save_with_service_jwt(self):
from ss_tools.agent._persistence import save_conversation from ss_tools.agent._persistence import save_conversation
with patch("ss_tools.agent._persistence.httpx.AsyncClient") as mock_client, patch("ss_tools.agent._persistence.os.getenv", return_value="service-token"): mock_client = AsyncMock()
mock_client.return_value.__aenter__.return_value.post = AsyncMock() mock_client.post = AsyncMock()
with patch("ss_tools.agent._persistence.get_shared_http_client", return_value=mock_client), patch("ss_tools.agent._persistence.os.getenv", return_value="service-token"):
await save_conversation("conv-1", "hello", "admin") await save_conversation("conv-1", "hello", "admin")
mock_client.return_value.__aenter__.return_value.post.assert_called_once() mock_client.post.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_save_failure_logged(self): async def test_save_failure_logged(self):
from ss_tools.agent._persistence import save_conversation from ss_tools.agent._persistence import save_conversation
with patch("ss_tools.agent._persistence.httpx.AsyncClient") as mock_client: mock_client = AsyncMock()
mock_client.return_value.__aenter__.return_value.post.side_effect = Exception("network err") mock_client.post = AsyncMock(side_effect=Exception("network err"))
with patch("ss_tools.agent._persistence.get_shared_http_client", return_value=mock_client):
# Should not raise # Should not raise
await save_conversation("conv-1", "msg", "u1") await save_conversation("conv-1", "msg", "u1")
@@ -590,19 +606,204 @@ class TestSaveConversation:
async def test_save_empty_title(self): async def test_save_empty_title(self):
from ss_tools.agent._persistence import save_conversation from ss_tools.agent._persistence import save_conversation
with patch("ss_tools.agent._persistence.httpx.AsyncClient") as mock_client: mock_client = AsyncMock()
client_instance = AsyncMock() mock_client.post = AsyncMock()
mock_client.return_value.__aenter__.return_value = client_instance with patch("ss_tools.agent._persistence.get_shared_http_client", return_value=mock_client):
await save_conversation("conv-1", " ", "user-1") await save_conversation("conv-1", " ", "user-1")
call_kwargs = client_instance.post.call_args[1] call_kwargs = mock_client.post.call_args[1]
# clean_title(" ") returns "Новый диалог" (Russian for "New conversation") # clean_title(" ") returns "Новый диалог" (Russian for "New conversation")
assert call_kwargs["json"]["title"] == "Новый диалог" assert call_kwargs["json"]["title"] == "Новый диалог"
# #endregion test_save_conversation # #endregion Test.AgentChat.TestSaveConversation
# #region test_create_chat_interface [C:2] [TYPE Function] # #region Test.AgentChat.TestLifecycleEvents [C:2] [TYPE Function]
# @BRIEF Test lifecycle event emission in agent_handler: AGENT_REQUEST_STARTED, AGENT_LLM_*, AGENT_REQUEST_COMPLETED/FAILED.
class TestLifecycleEvents:
@pytest.mark.asyncio
async def test_emits_request_started_on_normal_send(self, mock_request):
from ss_tools.agent.app import agent_handler
mock_chunk = MagicMock()
mock_chunk.content = "Hello"
mock_event_stream = [{"event": "on_chat_model_stream", "data": {"chunk": mock_chunk}}]
agent = _make_agent_mock(mock_event_stream)
mock_lifecycle = MagicMock()
with (
patch("ss_tools.agent.app.emit_lifecycle_event", mock_lifecycle),
patch("ss_tools.agent.app.create_agent", return_value=agent),
patch("ss_tools.agent.app.get_all_tools", return_value=[]),
patch("ss_tools.agent.app.save_conversation", AsyncMock()),
):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
assert len(results) > 0
# Find AGENT_REQUEST_STARTED call
started_calls = [c for c in mock_lifecycle.call_args_list if c[0][0] == "AGENT_REQUEST_STARTED"]
assert len(started_calls) >= 1, "AGENT_REQUEST_STARTED not emitted"
payload = started_calls[0][1]
# Safe aggregate fields only
assert "conversation_id" in payload
assert "user_id" in payload
assert "action" in payload
# No sensitive fields
assert "jwt" not in payload
assert "user_message" not in payload
@pytest.mark.asyncio
async def test_emits_request_completed_on_success(self, mock_request):
from ss_tools.agent.app import agent_handler
mock_chunk = MagicMock()
mock_chunk.content = "Hello"
mock_event_stream = [{"event": "on_chat_model_stream", "data": {"chunk": mock_chunk}}]
agent = _make_agent_mock(mock_event_stream)
mock_lifecycle = AsyncMock()
with (
patch("ss_tools.agent.app.emit_lifecycle_event", mock_lifecycle),
patch("ss_tools.agent.app.create_agent", return_value=agent),
patch("ss_tools.agent.app.get_all_tools", return_value=[]),
patch("ss_tools.agent.app.save_conversation", AsyncMock()),
):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
completed_calls = [c for c in mock_lifecycle.call_args_list if c[0][0] == "AGENT_REQUEST_COMPLETED"]
assert len(completed_calls) >= 1, "AGENT_REQUEST_COMPLETED not emitted"
payload = completed_calls[0][1]
assert "elapsed_ms" in payload
assert "conversation_id" in payload
assert "user_id" in payload
@pytest.mark.asyncio
async def test_emits_llm_completed_on_success(self, mock_request):
from ss_tools.agent.app import agent_handler
mock_chunk = MagicMock()
mock_chunk.content = "Hello"
mock_event_stream = [{"event": "on_chat_model_stream", "data": {"chunk": mock_chunk}}]
agent = _make_agent_mock(mock_event_stream)
mock_lifecycle = AsyncMock()
with (
patch("ss_tools.agent.app.emit_lifecycle_event", mock_lifecycle),
patch("ss_tools.agent.app.create_agent", return_value=agent),
patch("ss_tools.agent.app.get_all_tools", return_value=[]),
patch("ss_tools.agent.app.save_conversation", AsyncMock()),
):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
llm_completed_calls = [c for c in mock_lifecycle.call_args_list if c[0][0] == "AGENT_LLM_COMPLETED"]
assert len(llm_completed_calls) >= 1, "AGENT_LLM_COMPLETED not emitted"
@pytest.mark.asyncio
async def test_emits_llm_started_before_stream(self, mock_request):
from ss_tools.agent.app import agent_handler
mock_chunk = MagicMock()
mock_chunk.content = "Hello"
mock_event_stream = [{"event": "on_chat_model_stream", "data": {"chunk": mock_chunk}}]
agent = _make_agent_mock(mock_event_stream)
mock_lifecycle = AsyncMock()
with (
patch("ss_tools.agent.app.emit_lifecycle_event", mock_lifecycle),
patch("ss_tools.agent.app.create_agent", return_value=agent),
patch("ss_tools.agent.app.get_all_tools", return_value=[]),
patch("ss_tools.agent.app.save_conversation", AsyncMock()),
):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
llm_started_calls = [c for c in mock_lifecycle.call_args_list if c[0][0] == "AGENT_LLM_STARTED"]
assert len(llm_started_calls) >= 1, "AGENT_LLM_STARTED not emitted"
@pytest.mark.asyncio
async def test_emits_llm_failed_on_connection_error(self, mock_request):
from ss_tools.agent.app import agent_handler
def mock_astream(*_args, **_kwargs):
from httpx import ConnectError
raise ConnectError("connection refused")
agent = MagicMock()
agent.astream_events = mock_astream
mock_lifecycle = AsyncMock()
with (
patch("ss_tools.agent.app.emit_lifecycle_event", mock_lifecycle),
patch("ss_tools.agent.app.create_agent", return_value=agent),
patch("ss_tools.agent.app.get_all_tools", return_value=[]),
patch("ss_tools.agent.app.save_conversation", AsyncMock()),
):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
llm_failed_calls = [c for c in mock_lifecycle.call_args_list if c[0][0] == "AGENT_LLM_FAILED"]
assert len(llm_failed_calls) >= 1, "AGENT_LLM_FAILED not emitted on connection error"
assert llm_failed_calls[0][1]["error_code"] == "LLM_PROVIDER_UNAVAILABLE"
request_failed_calls = [c for c in mock_lifecycle.call_args_list if c[0][0] == "AGENT_REQUEST_FAILED"]
assert len(request_failed_calls) >= 1, "AGENT_REQUEST_FAILED not emitted"
@pytest.mark.asyncio
async def test_uses_x_trace_id_from_request(self):
from ss_tools.agent.app import agent_handler
import uuid
trace_id = uuid.uuid4().hex
req = MagicMock()
req.headers = {"X-Trace-ID": trace_id}
mock_chunk = MagicMock()
mock_chunk.content = "ok"
mock_event_stream = [{"event": "on_chat_model_stream", "data": {"chunk": mock_chunk}}]
agent = _make_agent_mock(mock_event_stream)
mock_lifecycle = AsyncMock()
with (
patch("ss_tools.agent.app.emit_lifecycle_event", mock_lifecycle),
patch("ss_tools.agent.app.create_agent", return_value=agent),
patch("ss_tools.agent.app.get_all_tools", return_value=[]),
patch("ss_tools.agent.app.save_conversation", AsyncMock()),
patch("ss_tools.agent.middleware.set_trace_id") as mock_set,
):
results = [r async for r in agent_handler("hello", [], req, None, None)]
mock_set.assert_called_once_with(trace_id)
@pytest.mark.asyncio
async def test_completed_event_includes_tool_count(self, mock_request):
from ss_tools.agent.app import agent_handler
mock_event_stream = [
{"event": "on_tool_start", "name": "list_environments", "data": {"input": {}}},
{"event": "on_tool_end", "name": "list_environments", "data": {"output": "done"}},
]
agent = _make_agent_mock(mock_event_stream)
mock_lifecycle = AsyncMock()
with (
patch("ss_tools.agent.app.emit_lifecycle_event", mock_lifecycle),
patch("ss_tools.agent.app.create_agent", return_value=agent),
patch("ss_tools.agent.app.get_all_tools", return_value=[]),
patch("ss_tools.agent.app.save_conversation", AsyncMock()),
patch("ss_tools.agent.app.log_tool_event", AsyncMock()),
):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
# Find REQUEST_COMPLETED — state has next=() (empty), falls through to normal completion
completed_calls = [c for c in mock_lifecycle.call_args_list if c[0][0] == "AGENT_REQUEST_COMPLETED"]
if completed_calls:
payload = completed_calls[0][1]
if "tool_count" in payload:
assert payload["tool_count"] >= 1
# #endregion Test.AgentChat.TestLifecycleEvents
# #region Test.AgentChat.TestCreateChatInterface [C:2] [TYPE Function]
# @BRIEF Test create_chat_interface returns a gr.ChatInterface. # @BRIEF Test create_chat_interface returns a gr.ChatInterface.
class TestCreateChatInterface: class TestCreateChatInterface:
def test_returns_chat_interface(self): def test_returns_chat_interface(self):
@@ -613,10 +814,10 @@ class TestCreateChatInterface:
assert result is mock_ci.return_value assert result is mock_ci.return_value
# #endregion test_create_chat_interface # #endregion Test.AgentChat.TestCreateChatInterface
# #region test_health [C:2] [TYPE Function] # #region Test.AgentChat.TestHealth [C:2] [TYPE Function]
# @BRIEF Test health endpoint returns status ok. # @BRIEF Test health endpoint returns status ok.
class TestHealth: class TestHealth:
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -627,10 +828,10 @@ class TestHealth:
assert result["status"] == "ok" assert result["status"] == "ok"
# #endregion test_health # #endregion Test.AgentChat.TestHealth
# #region test_file_upload_parsing [C:2] [TYPE Function] # #region Test.AgentChat.TestFileUploadParsing [C:2] [TYPE Function]
# @BRIEF Test file upload branch — parse_upload called for valid small files. # @BRIEF Test file upload branch — parse_upload called for valid small files.
class TestFileUploadParsing: class TestFileUploadParsing:
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -662,10 +863,10 @@ class TestFileUploadParsing:
assert token_data["metadata"]["type"] == "stream_token" assert token_data["metadata"]["type"] == "stream_token"
# #endregion test_file_upload_parsing # #endregion Test.AgentChat.TestFileUploadParsing
# #region test_app_main_block [C:2] [TYPE Function] # #region Test.AgentChat.TestAppMainBlock [C:2] [TYPE Function]
# @BRIEF Test if __name__ == '__main__' block in app.py. # @BRIEF Test if __name__ == '__main__' block in app.py.
class TestAppMainBlock: class TestAppMainBlock:
def test_app_main_block(self): def test_app_main_block(self):
@@ -677,7 +878,10 @@ class TestAppMainBlock:
spec = importlib.util.spec_from_file_location("__main__", str(app_path)) spec = importlib.util.spec_from_file_location("__main__", str(app_path))
mock_demo = MagicMock() mock_demo = MagicMock()
with patch("gradio.ChatInterface") as mock_ci: with (
patch("gradio.ChatInterface") as mock_ci,
patch("ss_tools.agent.middleware.close_lifecycle_resources", AsyncMock()),
):
mock_ci.return_value = mock_demo mock_ci.return_value = mock_demo
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
@@ -686,5 +890,5 @@ class TestAppMainBlock:
mock_demo.launch.assert_called_once() mock_demo.launch.assert_called_once()
# #endregion test_app_main_block # #endregion Test.AgentChat.TestAppMainBlock
# #endregion Test.AgentChat.GradioApp # #endregion Test.AgentChat.GradioApp

View File

@@ -18,7 +18,7 @@ def _tools(names: list[str]) -> list[SimpleNamespace]:
return [SimpleNamespace(name=name) for name in names] return [SimpleNamespace(name=name) for name in names]
# #region test_dashboard_context_filters_tools [C:2] [TYPE Function] # #region Test.Agent.TestDashboardContextFiltersTools [C:2] [TYPE Function]
# @BRIEF Dashboard context keeps only dashboard-affinity tools and mandatory capabilities. # @BRIEF Dashboard context keeps only dashboard-affinity tools and mandatory capabilities.
def test_dashboard_context_filters_tools(): def test_dashboard_context_filters_tools():
tools = _tools([ tools = _tools([
@@ -38,10 +38,10 @@ def test_dashboard_context_filters_tools():
"deploy_dashboard", "deploy_dashboard",
"show_capabilities", "show_capabilities",
] ]
# #endregion test_dashboard_context_filters_tools # #endregion Test.Agent.TestDashboardContextFiltersTools
# #region test_dashboard_context_viewer_removes_admin_tools [C:2] [TYPE Function] # #region Test.Agent.TestDashboardContextViewerRemovesAdminTools [C:2] [TYPE Function]
# @BRIEF Viewer role removes admin-only tools even when they are dashboard-affinity tools. # @BRIEF Viewer role removes admin-only tools even when they are dashboard-affinity tools.
def test_dashboard_context_viewer_removes_admin_tools(): def test_dashboard_context_viewer_removes_admin_tools():
tools = _tools([ tools = _tools([
@@ -54,20 +54,20 @@ def test_dashboard_context_viewer_removes_admin_tools():
result = [tool.name for tool in build_tool_pipeline(tools, "viewer", "dashboard")] result = [tool.name for tool in build_tool_pipeline(tools, "viewer", "dashboard")]
assert result == ["search_dashboards", "show_capabilities"] assert result == ["search_dashboards", "show_capabilities"]
# #endregion test_dashboard_context_viewer_removes_admin_tools # #endregion Test.Agent.TestDashboardContextViewerRemovesAdminTools
# #region test_invocation_guard_blocks_mutating_tool [C:2] [TYPE Function] # #region Test.Agent.TestInvocationGuardBlocksMutatingTool [C:2] [TYPE Function]
# @BRIEF Invocation guard rejects admin-only tools for non-admin role before side effects. # @BRIEF Invocation guard rejects admin-only tools for non-admin role before side effects.
def test_invocation_guard_blocks_mutating_tool(): def test_invocation_guard_blocks_mutating_tool():
set_user_role("viewer") set_user_role("viewer")
with pytest.raises(PermissionError, match="PERMISSION_DENIED:deploy_dashboard:admin:viewer"): with pytest.raises(PermissionError, match="PERMISSION_DENIED:deploy_dashboard:admin:viewer"):
_guard_tool_permission("deploy_dashboard") _guard_tool_permission("deploy_dashboard")
# #endregion test_invocation_guard_blocks_mutating_tool # #endregion Test.Agent.TestInvocationGuardBlocksMutatingTool
# #region test_summarise_response_preserves_json_array_shape [C:2] [TYPE Function] # #region Test.Agent.TestSummariseResponsePreservesJsonArrayShape [C:2] [TYPE Function]
# @BRIEF Large JSON arrays are summarised as top-N plus total count, not cut mid-structure. # @BRIEF Large JSON arrays are summarised as top-N plus total count, not cut mid-structure.
def test_summarise_response_preserves_json_array_shape(): def test_summarise_response_preserves_json_array_shape():
text = "[" + ",".join(f'{{"id":{idx},"name":"dashboard-{idx}"}}' for idx in range(20)) + "]" text = "[" + ",".join(f'{{"id":{idx},"name":"dashboard-{idx}"}}' for idx in range(20)) + "]"
@@ -77,6 +77,6 @@ def test_summarise_response_preserves_json_array_shape():
assert summary.startswith("Found 20 items:") assert summary.startswith("Found 20 items:")
assert "dashboard-0" in summary assert "dashboard-0" in summary
assert "15 more items" in summary assert "15 more items" in summary
# #endregion test_summarise_response_preserves_json_array_shape # #endregion Test.Agent.TestSummariseResponsePreservesJsonArrayShape
# #endregion Test.Agent.Feature035 # #endregion Test.Agent.Feature035

View File

@@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, Mock, patch
sys.path.append(str(Path(__file__).parent.parent.parent / "src")) sys.path.append(str(Path(__file__).parent.parent.parent / "src"))
import httpx
import pytest import pytest
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-jwt-testing") os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-jwt-testing")
@@ -19,6 +20,24 @@ os.environ["SERVICE_JWT"] = "test-service-jwt"
os.environ["OPENAI_API_KEY"] = "sk-test-key" os.environ["OPENAI_API_KEY"] = "sk-test-key"
def _mock_http_client(get_return=None, post_return=None, get_side_effect=None):
"""Create a mock for get_shared_http_client that returns a mock client.
Returns (mock_client, patcher) tuple. Use as:
mock_client, patcher = _mock_http_client(...)
with patcher:
...
"""
mock_client = AsyncMock(spec=httpx.AsyncClient)
if get_side_effect is not None:
mock_client.get = AsyncMock(side_effect=get_side_effect)
elif get_return is not None:
mock_client.get = AsyncMock(return_value=get_return)
if post_return is not None:
mock_client.post = AsyncMock(return_value=post_return)
return mock_client, patch("ss_tools.agent.tools.get_shared_http_client", return_value=mock_client)
@pytest.fixture @pytest.fixture
def anyio_backend(): def anyio_backend():
return "asyncio" return "asyncio"
@@ -38,19 +57,15 @@ async def test_tool_dual_auth_headers():
set_user_jwt("user-jwt-token") set_user_jwt("user-jwt-token")
set_service_jwt("service-jwt-token") set_service_jwt("service-jwt-token")
with patch("httpx.AsyncClient") as mock_client: mock_resp = Mock(status_code=200, text='{"dashboards": [], "total": 0}')
mock_instance = AsyncMock() mock_resp.json.return_value = {"dashboards": [], "total": 0}
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.get.return_value = Mock(
status_code=200,
text='{"dashboards": [], "total": 0}',
)
mock_instance.get.return_value.json.return_value = {"dashboards": [], "total": 0}
mock_client, patcher = _mock_http_client(get_return=mock_resp)
with patcher:
await search_dashboards.ainvoke({"query": "test"}) await search_dashboards.ainvoke({"query": "test"})
# Verify the HTTP request included dual-identity headers # Verify the HTTP request included dual-identity headers
call_kwargs = mock_instance.get.call_args call_kwargs = mock_client.get.call_args
assert call_kwargs is not None, "HTTP GET should have been called" assert call_kwargs is not None, "HTTP GET should have been called"
_, kwargs = call_kwargs _, kwargs = call_kwargs
headers = kwargs.get("headers", {}) headers = kwargs.get("headers", {})
@@ -78,19 +93,14 @@ async def test_tool_auth_fallback_to_env():
set_service_jwt("") set_service_jwt("")
os.environ["SERVICE_JWT"] = "env-service-token" os.environ["SERVICE_JWT"] = "env-service-token"
with patch.object(tools_mod, "FASTAPI_URL", "http://test-backend:8000"), patch("httpx.AsyncClient") as mock_client: mock_resp = Mock(status_code=200, text='{"dashboards": [], "total": 0}')
mock_instance = AsyncMock() mock_resp.json.return_value = {"dashboards": [], "total": 0}
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.get.return_value = Mock(
status_code=200,
text='{"dashboards": [], "total": 0}',
)
mock_instance.get.return_value.json.return_value = {"dashboards": [], "total": 0}
# Since tool uses os.getenv at call time, the env var will be read mock_client, patcher = _mock_http_client(get_return=mock_resp)
with patch.object(tools_mod, "FASTAPI_URL", "http://test-backend:8000"), patcher:
await search_dashboards.ainvoke({"query": "test"}) await search_dashboards.ainvoke({"query": "test"})
call_kwargs = mock_instance.get.call_args call_kwargs = mock_client.get.call_args
assert call_kwargs is not None assert call_kwargs is not None
_, kwargs = call_kwargs _, kwargs = call_kwargs
headers = kwargs.get("headers", {}) headers = kwargs.get("headers", {})
@@ -114,11 +124,8 @@ async def test_tool_http_exception_handling():
set_user_jwt("test-jwt") set_user_jwt("test-jwt")
set_service_jwt("svc-jwt") set_service_jwt("svc-jwt")
with patch("httpx.AsyncClient") as mock_client: _, patcher = _mock_http_client(get_side_effect=Exception("Connection refused"))
mock_instance = AsyncMock() with patcher:
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.get.side_effect = Exception("Connection refused")
# Should propagate the exception (caller handles error) # Should propagate the exception (caller handles error)
with pytest.raises((Exception,)): with pytest.raises((Exception,)):
await search_dashboards.ainvoke({"query": "test"}) await search_dashboards.ainvoke({"query": "test"})
@@ -209,18 +216,14 @@ async def test_search_dashboards_correct_url():
set_user_jwt("jwt") set_user_jwt("jwt")
set_service_jwt("svc-jwt") set_service_jwt("svc-jwt")
with patch("httpx.AsyncClient") as mock_client: mock_resp = Mock(status_code=200, text='{"dashboards": [], "total": 0}')
mock_instance = AsyncMock() mock_resp.json.return_value = {"dashboards": [], "total": 0}
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.get.return_value = Mock(
status_code=200,
text='{"dashboards": [], "total": 0}',
)
mock_instance.get.return_value.json.return_value = {"dashboards": [], "total": 0}
mock_client, patcher = _mock_http_client(get_return=mock_resp)
with patcher:
await search_dashboards.ainvoke({"query": "dashboard-name", "env_id": "prod"}) await search_dashboards.ainvoke({"query": "dashboard-name", "env_id": "prod"})
call_args = mock_instance.get.call_args call_args = mock_client.get.call_args
assert call_args is not None assert call_args is not None
args, kwargs = call_args args, kwargs = call_args
url = args[0] if args else kwargs.get("url", "") url = args[0] if args else kwargs.get("url", "")
@@ -243,15 +246,13 @@ async def test_get_health_summary_calls_correct_url():
set_user_jwt("jwt") set_user_jwt("jwt")
set_service_jwt("svc-jwt") set_service_jwt("svc-jwt")
with patch("httpx.AsyncClient") as mock_client: mock_resp = Mock(status_code=200, text='{"status": "ok"}')
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.get.return_value.status_code = 200
mock_instance.get.return_value.text = '{"status": "ok"}'
mock_client, patcher = _mock_http_client(get_return=mock_resp)
with patcher:
await get_health_summary.ainvoke({"env_id": "ss-dev"}) await get_health_summary.ainvoke({"env_id": "ss-dev"})
call_args = mock_instance.get.call_args call_args = mock_client.get.call_args
assert call_args is not None assert call_args is not None
args, kwargs = call_args args, kwargs = call_args
url = args[0] if args else kwargs.get("url", "") url = args[0] if args else kwargs.get("url", "")
@@ -275,15 +276,13 @@ async def test_list_environments_calls_correct_url():
set_user_jwt("jwt") set_user_jwt("jwt")
set_service_jwt("svc-jwt") set_service_jwt("svc-jwt")
with patch("httpx.AsyncClient") as mock_client: mock_resp = Mock(status_code=200, text='["prod", "dev"]')
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.get.return_value.status_code = 200
mock_instance.get.return_value.text = '["prod", "dev"]'
mock_client, patcher = _mock_http_client(get_return=mock_resp)
with patcher:
await list_environments.ainvoke({}) await list_environments.ainvoke({})
call_args = mock_instance.get.call_args call_args = mock_client.get.call_args
assert call_args is not None assert call_args is not None
args, kwargs = call_args args, kwargs = call_args
url = args[0] if args else kwargs.get("url", "") url = args[0] if args else kwargs.get("url", "")
@@ -299,12 +298,13 @@ async def test_list_environments_redacts_sensitive_fields():
set_user_jwt("jwt") set_user_jwt("jwt")
set_service_jwt("svc-jwt") set_service_jwt("svc-jwt")
with patch("httpx.AsyncClient") as mock_client: mock_resp = Mock(
mock_instance = AsyncMock() status_code=200,
mock_client.return_value.__aenter__.return_value = mock_instance text='[{"id":"prod","password":"secret-pass","api_key":"secret-key","nested":{"token":"secret-token"},"name":"ss-prod"}]',
mock_instance.get.return_value.status_code = 200 )
mock_instance.get.return_value.text = '[{"id":"prod","password":"secret-pass","api_key":"secret-key","nested":{"token":"secret-token"},"name":"ss-prod"}]'
_, patcher = _mock_http_client(get_return=mock_resp)
with patcher:
result = await list_environments.ainvoke({}) result = await list_environments.ainvoke({})
assert "secret-pass" not in result assert "secret-pass" not in result
@@ -329,15 +329,13 @@ async def test_get_task_status_calls_correct_url():
set_user_jwt("jwt") set_user_jwt("jwt")
set_service_jwt("svc-jwt") set_service_jwt("svc-jwt")
with patch("httpx.AsyncClient") as mock_client: mock_resp = Mock(status_code=200, text='{"status": "running"}')
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.get.return_value.status_code = 200
mock_instance.get.return_value.text = '{"status": "running"}'
mock_client, patcher = _mock_http_client(get_return=mock_resp)
with patcher:
await get_task_status.ainvoke({"task_id": "task-123"}) await get_task_status.ainvoke({"task_id": "task-123"})
call_args = mock_instance.get.call_args call_args = mock_client.get.call_args
assert call_args is not None assert call_args is not None
args, kwargs = call_args args, kwargs = call_args
url = args[0] if args else kwargs.get("url", "") url = args[0] if args else kwargs.get("url", "")
@@ -357,17 +355,17 @@ async def test_run_backup_posts_task_payload():
set_service_jwt("svc-jwt") set_service_jwt("svc-jwt")
set_user_role("admin") set_user_role("admin")
with patch("httpx.AsyncClient") as mock_client: mock_resp = Mock(status_code=201, text='{"id": "task-1"}')
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.post.return_value = Mock(status_code=201, text='{"id": "task-1"}')
mock_client, patcher = _mock_http_client(post_return=mock_resp)
with patcher:
await run_backup.ainvoke({"environment_id": "prod", "dashboard_id": 10}) await run_backup.ainvoke({"environment_id": "prod", "dashboard_id": 10})
call_args = mock_instance.post.call_args call_args = mock_client.post.call_args
assert call_args is not None assert call_args is not None
args, kwargs = call_args args, kwargs = call_args
assert "api/tasks" in args[0] url = args[0] if args else kwargs.get("url", "")
assert "api/tasks" in url
assert kwargs["json"] == { assert kwargs["json"] == {
"plugin_id": "superset-backup", "plugin_id": "superset-backup",
"params": {"environment_id": "prod", "dashboard_ids": [10]}, "params": {"environment_id": "prod", "dashboard_ids": [10]},
@@ -384,17 +382,17 @@ async def test_deploy_dashboard_posts_git_endpoint():
set_service_jwt("svc-jwt") set_service_jwt("svc-jwt")
set_user_role("admin") set_user_role("admin")
with patch("httpx.AsyncClient") as mock_client: mock_resp = Mock(status_code=200, text='{"status": "success"}')
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.post.return_value = Mock(status_code=200, text='{"status": "success"}')
mock_client, patcher = _mock_http_client(post_return=mock_resp)
with patcher:
await deploy_dashboard.ainvoke({"dashboard_ref": "42", "environment_id": "prod"}) await deploy_dashboard.ainvoke({"dashboard_ref": "42", "environment_id": "prod"})
call_args = mock_instance.post.call_args call_args = mock_client.post.call_args
assert call_args is not None assert call_args is not None
args, kwargs = call_args args, kwargs = call_args
assert "api/git/repositories/42/deploy" in args[0] url = args[0] if args else kwargs.get("url", "")
assert "api/git/repositories/42/deploy" in url
assert kwargs["json"] == {"environment_id": "prod"} assert kwargs["json"] == {"environment_id": "prod"}

View File

@@ -16,7 +16,7 @@ def anyio_backend():
return "asyncio" return "asyncio"
# #region test_configure_from_api [C:2] [TYPE Function] # #region Test.AgentChat.TestConfigureFromApi [C:2] [TYPE Function]
# @BRIEF Test configure_from_api updates global config. # @BRIEF Test configure_from_api updates global config.
class TestConfigureFromApi: class TestConfigureFromApi:
def test_sets_llm_config(self): def test_sets_llm_config(self):
@@ -36,10 +36,35 @@ class TestConfigureFromApi:
ls.configure_from_api({"configured": False}) ls.configure_from_api({"configured": False})
assert ls._llm_config["configured"] is False assert ls._llm_config["configured"] is False
ls._llm_config = None ls._llm_config = None
# #endregion test_configure_from_api # #endregion Test.AgentChat.TestConfigureFromApi
# #region test_create_agent [C:2] [TYPE Function] # #region Test.AgentChat.TestLlmDiagnostics [C:2] [TYPE Function] [SEMANTICS test,agent,llm,observability]
# @BRIEF Diagnostics identify the configured provider but never expose API credentials or full URL paths.
def test_llm_diagnostics_redacts_api_key_and_path():
import ss_tools.agent.langgraph_setup as ls
diagnostics = ls.llm_diagnostics({
"configured": True,
"provider_id": "provider-1",
"provider_name": "litellm",
"provider_type": "litellm",
"base_url": "https://key:secret@lite.ai.rusal.com/v1/private",
"api_key": "must-not-appear",
"default_model": "qwen-flash",
"selection_source": "assistant_planner_provider",
})
assert diagnostics["provider_host"] == "lite.ai.rusal.com"
assert diagnostics["provider_scheme"] == "https"
assert diagnostics["model"] == "qwen-flash"
assert "api_key" not in diagnostics
assert "base_url" not in diagnostics
assert all("secret" not in str(value) for value in diagnostics.values())
# #endregion Test.AgentChat.TestLlmDiagnostics
# #region Test.AgentChat.TestCreateAgent [C:2] [TYPE Function]
# @BRIEF Test create_agent with various LLM config states. # @BRIEF Test create_agent with various LLM config states.
class TestCreateAgent: class TestCreateAgent:
@pytest.mark.anyio @pytest.mark.anyio
@@ -181,5 +206,5 @@ class TestCreateAgent:
await ls.create_agent([], interrupt_before=[]) await ls.create_agent([], interrupt_before=[])
assert mock_create.call_args[1]["interrupt_before"] == [] assert mock_create.call_args[1]["interrupt_before"] == []
ls._llm_config = None ls._llm_config = None
# #endregion test_create_agent # #endregion Test.AgentChat.TestCreateAgent
# #endregion Test.AgentChat.LangGraph.Setup # #endregion Test.AgentChat.LangGraph.Setup

View File

@@ -1,5 +1,5 @@
# #region Test.AgentChat.Middleware [C:3] [TYPE Module] [SEMANTICS test,agent,middleware,audit] # #region Test.AgentChat.Middleware [C:3] [TYPE Module] [SEMANTICS test,agent,middleware,audit]
# @BRIEF Tests for agent/middleware.py — log_tool_event. # @BRIEF Tests for agent/middleware.py — log_tool_event, emit_lifecycle_event, extract_trace_id_from_request.
# @RELATION BINDS_TO -> [AgentChat.Middleware] # @RELATION BINDS_TO -> [AgentChat.Middleware]
from pathlib import Path from pathlib import Path
@@ -7,11 +7,167 @@ import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
import uuid
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
# #region test_log_tool_event [C:2] [TYPE Function] # #region Test.AgentChat.TestEmitLifecycleEvent [C:2] [TYPE Function]
# @BRIEF Test emit_lifecycle_event for correct event type and payload.
class TestEmitLifecycleEvent:
def test_emits_event_with_correct_type_and_payload(self):
from ss_tools.agent.middleware import emit_lifecycle_event
with patch("ss_tools.agent.middleware.logger.reason") as mock_reason:
emit_lifecycle_event(
"AGENT_REQUEST_STARTED",
conversation_id="conv-1",
user_id="user-1",
environment_id="prod",
action="new",
)
mock_reason.assert_called_once()
args, kwargs = mock_reason.call_args
assert args[0] == "AGENT_REQUEST_STARTED"
payload = kwargs["payload"]
assert payload["conversation_id"] == "conv-1"
assert payload["user_id"] == "user-1"
assert payload["environment_id"] == "prod"
assert payload["action"] == "new"
assert kwargs["extra"]["src"] == "AgentChat.Lifecycle"
def test_filters_none_payload_values(self):
from ss_tools.agent.middleware import emit_lifecycle_event
with patch("ss_tools.agent.middleware.logger.reason") as mock_reason:
emit_lifecycle_event(
"AGENT_REQUEST_COMPLETED",
conversation_id="conv-1",
is_resume=None,
tool_names=None,
)
payload = mock_reason.call_args[1]["payload"]
assert "conversation_id" in payload
assert "is_resume" not in payload
assert "tool_names" not in payload
def test_never_includes_sensitive_fields(self):
"""Verify that sensitive field names are never in payload schema."""
from ss_tools.agent.middleware import emit_lifecycle_event
with patch("ss_tools.agent.middleware.logger.reason") as mock_reason:
emit_lifecycle_event(
"AGENT_REQUEST_STARTED",
conversation_id="conv-1",
user_id="user-1",
)
payload = mock_reason.call_args[1]["payload"]
forbidden = {"jwt", "token", "password", "secret", "message", "prompt", "file", "user_message"}
payload_keys = set(k.lower() for k in payload)
assert not (payload_keys & forbidden), f"Found forbidden key in payload: {payload_keys & forbidden}"
def test_filters_forbidden_lifecycle_fields_even_if_caller_passes_them(self):
"""Lifecycle helper enforces its no-sensitive-data invariant at runtime."""
from ss_tools.agent.middleware import emit_lifecycle_event
with patch("ss_tools.agent.middleware.logger.reason") as mock_reason:
emit_lifecycle_event(
"AGENT_REQUEST_COMPLETED",
conversation_id="conv-1",
jwt="secret",
message="private request",
files=["private.pdf"],
raw_output="private result",
)
payload = mock_reason.call_args.kwargs["payload"]
assert payload == {"conversation_id": "conv-1"}
# #endregion Test.AgentChat.TestEmitLifecycleEvent
# #region Test.AgentChat.TestExtractTraceIdFromRequest [C:2] [TYPE Function]
# @BRIEF Test extract_trace_id_from_request with valid/invalid/missing X-Trace-ID headers.
class TestExtractTraceIdFromRequest:
def make_request(self, headers: dict | None = None) -> MagicMock:
req = MagicMock()
req.headers = headers or {}
return req
def test_extracts_valid_uuid4_from_header(self):
from ss_tools.agent.middleware import extract_trace_id_from_request
valid_id = uuid.uuid4().hex
req = self.make_request({"X-Trace-ID": valid_id})
with patch("ss_tools.agent.middleware.set_trace_id") as mock_set:
result = extract_trace_id_from_request(req)
assert result == valid_id
mock_set.assert_called_once_with(valid_id)
def test_case_insensitive_header(self):
from ss_tools.agent.middleware import extract_trace_id_from_request
valid_id = uuid.uuid4().hex
req = self.make_request({"x-trace-id": valid_id})
with patch("ss_tools.agent.middleware.set_trace_id") as mock_set:
result = extract_trace_id_from_request(req)
assert result == valid_id
mock_set.assert_called_once_with(valid_id)
def test_seeds_when_header_missing(self):
from ss_tools.agent.middleware import extract_trace_id_from_request
req = self.make_request({"authorization": "Bearer xyz"})
with patch("ss_tools.agent.middleware.seed_trace_id", return_value="new-trace") as mock_seed:
result = extract_trace_id_from_request(req)
assert result == "new-trace"
mock_seed.assert_called_once()
def test_seeds_when_header_empty(self):
from ss_tools.agent.middleware import extract_trace_id_from_request
req = self.make_request({"X-Trace-ID": ""})
with patch("ss_tools.agent.middleware.seed_trace_id", return_value="new-trace") as mock_seed:
result = extract_trace_id_from_request(req)
assert result == "new-trace"
mock_seed.assert_called_once()
def test_seeds_on_invalid_uuid_format(self):
from ss_tools.agent.middleware import extract_trace_id_from_request
req = self.make_request({"X-Trace-ID": "not-a-uuid-at-all"})
with patch("ss_tools.agent.middleware.seed_trace_id", return_value="new-trace") as mock_seed:
result = extract_trace_id_from_request(req)
assert result == "new-trace"
mock_seed.assert_called_once()
def test_seeds_on_non_v4_uuid(self):
from ss_tools.agent.middleware import extract_trace_id_from_request
# UUID v1
v1_id = "550e8400-e29b-11d1-a716-446655440000"
req = self.make_request({"X-Trace-ID": v1_id})
with patch("ss_tools.agent.middleware.seed_trace_id", return_value="new-trace") as mock_seed:
result = extract_trace_id_from_request(req)
assert result == "new-trace"
mock_seed.assert_called_once()
def test_handles_request_without_headers(self):
from ss_tools.agent.middleware import extract_trace_id_from_request
req = MagicMock(spec=[]) # no headers attr
del req.headers
with patch("ss_tools.agent.middleware.seed_trace_id", return_value="new-trace") as mock_seed:
result = extract_trace_id_from_request(req)
assert result == "new-trace"
mock_seed.assert_called_once()
# #endregion Test.AgentChat.TestExtractTraceIdFromRequest
# #region Test.AgentChat.TestLogToolEvent [C:2] [TYPE Function]
# @BRIEF Test log_tool_event for various event types. # @BRIEF Test log_tool_event for various event types.
class TestLogToolEvent: class TestLogToolEvent:
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -84,5 +240,43 @@ class TestLogToolEvent:
} }
with patch("ss_tools.agent.middleware.get_user_jwt", return_value="token"): with patch("ss_tools.agent.middleware.get_user_jwt", return_value="token"):
await log_tool_event(event, "conv-1") await log_tool_event(event, "conv-1")
# #endregion test_log_tool_event
@pytest.mark.asyncio
async def test_includes_trace_id(self):
from ss_tools.agent.middleware import log_tool_event
test_trace_id = "abc123"
event = {
"event": "on_tool_start",
"name": "trace_test",
"data": {"input": {"key": "val"}},
}
with (
patch("ss_tools.agent.middleware.get_user_jwt", return_value="token"),
patch("ss_tools.agent.middleware.get_trace_id", return_value=test_trace_id),
patch("ss_tools.agent.middleware.logger.reason") as mock_reason,
):
await log_tool_event(event, "conv-1")
payload = mock_reason.call_args[1]["payload"]
assert payload["trace_id"] == test_trace_id
@pytest.mark.asyncio
async def test_handles_empty_trace_id(self):
from ss_tools.agent.middleware import log_tool_event
event = {
"event": "on_tool_start",
"name": "no_trace",
"data": {"input": {}},
}
with (
patch("ss_tools.agent.middleware.get_user_jwt", return_value="token"),
patch("ss_tools.agent.middleware.get_trace_id", return_value=""),
patch("ss_tools.agent.middleware.logger.reason"),
):
await log_tool_event(event, "conv-1")
# No exception = success
# #endregion Test.AgentChat.TestLogToolEvent
# #endregion Test.AgentChat.Middleware # #endregion Test.AgentChat.Middleware

View File

@@ -0,0 +1,57 @@
# #region Test.AgentChat.Packaging [C:3] [TYPE Module] [SEMANTICS test,agent,packaging,entrypoint,subprocess]
# @BRIEF Verifies the installed agent package exposes the production module entry point.
# @RELATION BINDS_TO -> [EXT:Python:ModuleEntrypoint]
# @TEST_FIXTURE: installed_packages -> INLINE_JSON
# @TEST_EDGE: missing_field -> Imports resolve without agent/src added to PYTHONPATH.
# @TEST_EDGE: invalid_type -> Both shared and agent distributions are installed as packages.
# @TEST_EDGE: external_fail -> pip installation failures surface through the subprocess result.
# @RATIONALE Tests normally insert agent/src into sys.path, which can hide a broken editable or
# wheel installation even though run.sh executes python -m ss_tools.agent.run.
# @REJECTED Importing directly from agent/src was rejected because it cannot prove packaging works.
import os
from pathlib import Path
import subprocess
import sys
# #region Test.AgentChat.TestInstalledPackagesExposeAgentEntrypoint [C:2] [TYPE Function] [SEMANTICS test,agent,packaging,entrypoint]
# @BRIEF Install shared and agent distributions into an isolated target and import the entry point.
def test_installed_packages_expose_agent_entrypoint(tmp_path: Path) -> None:
"""run.sh's module entry point is available without source-tree path injection."""
repository_root = Path(__file__).parents[3]
package_target = tmp_path / "site-packages"
install = subprocess.run(
[
sys.executable,
"-m",
"pip",
"install",
"--no-deps",
"--target",
str(package_target),
str(repository_root / "shared"),
str(repository_root / "agent"),
],
cwd=tmp_path,
capture_output=True,
text=True,
check=False,
)
assert install.returncode == 0, install.stderr
environment = os.environ.copy()
environment["PYTHONPATH"] = str(package_target)
imported = subprocess.run(
[sys.executable, "-c", "import ss_tools.agent.run; import ss_tools.shared"],
cwd=tmp_path,
env=environment,
capture_output=True,
text=True,
check=False,
)
assert imported.returncode == 0, imported.stderr
# #endregion Test.AgentChat.TestInstalledPackagesExposeAgentEntrypoint
# #endregion Test.AgentChat.Packaging

View File

@@ -8,7 +8,7 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
# #region test_find_free_port [C:2] [TYPE Function] # #region Test.AgentChat.TestFindFreePort [C:2] [TYPE Function]
# @BRIEF Test _find_free_port for port scanning behavior. # @BRIEF Test _find_free_port for port scanning behavior.
class TestFindFreePort: class TestFindFreePort:
def test_returns_free_port(self): def test_returns_free_port(self):
@@ -45,10 +45,10 @@ class TestFindFreePort:
with pytest.raises(OSError, match="No free port found"): with pytest.raises(OSError, match="No free port found"):
_find_free_port(8000, 3) _find_free_port(8000, 3)
assert mock_instance.bind.call_count == 3 assert mock_instance.bind.call_count == 3
# #endregion test_find_free_port # #endregion Test.AgentChat.TestFindFreePort
# #region test_fetch_llm_config [C:2] [TYPE Function] # #region Test.AgentChat.TestFetchLlmConfig [C:2] [TYPE Function]
# @BRIEF Test _fetch_llm_config with retry and fallback behavior. # @BRIEF Test _fetch_llm_config with retry and fallback behavior.
class TestFetchLlmConfig: class TestFetchLlmConfig:
def test_returns_config_on_success(self): def test_returns_config_on_success(self):
@@ -120,10 +120,10 @@ class TestFetchLlmConfig:
assert call_kwargs["headers"].get("Authorization") == "Bearer test-token" assert call_kwargs["headers"].get("Authorization") == "Bearer test-token"
# #endregion test_fetch_llm_config # #endregion Test.AgentChat.TestFetchLlmConfig
# #region test_main_block [C:2] [TYPE Function] # #region Test.AgentChat.TestMainBlock [C:2] [TYPE Function]
# @BRIEF Test if __name__ == '__main__' block — service JWT, LLM config, port fallback, OSError. # @BRIEF Test if __name__ == '__main__' block — service JWT, LLM config, port fallback, OSError.
class TestMainBlock: class TestMainBlock:
"""Test the if __name__ == '__main__' entry point block via importlib.util fresh module.""" """Test the if __name__ == '__main__' entry point block via importlib.util fresh module."""
@@ -246,5 +246,5 @@ class TestMainBlock:
"GRADIO_ALLOW_PORT_FALLBACK": "true", "GRADIO_ALLOW_PORT_FALLBACK": "true",
}, },
port_always_fail=True) port_always_fail=True)
# #endregion test_main_block # #endregion Test.AgentChat.TestMainBlock
# #endregion Test.AgentChat.Run # #endregion Test.AgentChat.Run

View File

@@ -44,7 +44,7 @@ def _make_read_timeout() -> httpx.ReadTimeout:
class TestRetryReadTool: class TestRetryReadTool:
"""Contract tests for _retry_read_tool — the fixed-delay retry wrapper.""" """Contract tests for _retry_read_tool — the fixed-delay retry wrapper."""
# #region test_first_attempt_502_retries_once [C:2] [TYPE Function] # #region Test.AgentChat.TestFirstAttempt502RetriesOnce [C:2] [TYPE Function]
# @BRIEF First attempt raises 502 → retries once → second attempt succeeds. # @BRIEF First attempt raises 502 → retries once → second attempt succeeds.
async def test_first_attempt_502_retries_once(self): async def test_first_attempt_502_retries_once(self):
"""Prove @TEST_EDGE first_attempt_502: one retry + 1s delay → success.""" """Prove @TEST_EDGE first_attempt_502: one retry + 1s delay → success."""
@@ -68,9 +68,9 @@ class TestRetryReadTool:
"max_attempts": 2, "max_attempts": 2,
}, },
}] }]
# #endregion test_first_attempt_502_retries_once # #endregion Test.AgentChat.TestFirstAttempt502RetriesOnce
# #region test_both_attempts_502_raises [C:2] [TYPE Function] # #region Test.AgentChat.TestBothAttempts502Raises [C:2] [TYPE Function]
# @BRIEF Both attempts raise 502 → exhaust retries → raises original error. # @BRIEF Both attempts raise 502 → exhaust retries → raises original error.
async def test_both_attempts_502_raises(self): async def test_both_attempts_502_raises(self):
"""Prove @TEST_EDGE both_attempts_502: max 2 attempts, then raise.""" """Prove @TEST_EDGE both_attempts_502: max 2 attempts, then raise."""
@@ -82,9 +82,9 @@ class TestRetryReadTool:
assert exc_info.value is error_502 assert exc_info.value is error_502
assert mock_fn.call_count == 2 assert mock_fn.call_count == 2
# #endregion test_both_attempts_502_raises # #endregion Test.AgentChat.TestBothAttempts502Raises
# #region test_connect_error_retried [C:2] [TYPE Function] # #region Test.AgentChat.TestConnectErrorRetried [C:2] [TYPE Function]
# @BRIEF ConnectError is also retried — not just HTTP status errors. # @BRIEF ConnectError is also retried — not just HTTP status errors.
async def test_connect_error_retried(self): async def test_connect_error_retried(self):
"""Prove ConnectError triggers the retry path.""" """Prove ConnectError triggers the retry path."""
@@ -97,9 +97,9 @@ class TestRetryReadTool:
assert result == expected assert result == expected
assert mock_fn.call_count == 2 assert mock_fn.call_count == 2
# #endregion test_connect_error_retried # #endregion Test.AgentChat.TestConnectErrorRetried
# #region test_read_timeout_retried [C:2] [TYPE Function] # #region Test.AgentChat.TestReadTimeoutRetried [C:2] [TYPE Function]
# @BRIEF ReadTimeout is also retried — transient I/O timeouts are recoverable. # @BRIEF ReadTimeout is also retried — transient I/O timeouts are recoverable.
async def test_read_timeout_retried(self): async def test_read_timeout_retried(self):
"""Prove ReadTimeout triggers the retry path.""" """Prove ReadTimeout triggers the retry path."""
@@ -112,9 +112,9 @@ class TestRetryReadTool:
assert result == expected assert result == expected
assert mock_fn.call_count == 2 assert mock_fn.call_count == 2
# #endregion test_read_timeout_retried # #endregion Test.AgentChat.TestReadTimeoutRetried
# #region test_retry_skips_delay_on_success [C:2] [TYPE Function] # #region Test.AgentChat.TestRetrySkipsDelayOnSuccess [C:2] [TYPE Function]
# @BRIEF When first attempt succeeds, no sleep occurs at all. # @BRIEF When first attempt succeeds, no sleep occurs at all.
async def test_retry_skips_delay_on_success(self): async def test_retry_skips_delay_on_success(self):
"""Prove that the happy path never sleeps — sleep is only for retries.""" """Prove that the happy path never sleeps — sleep is only for retries."""
@@ -127,9 +127,9 @@ class TestRetryReadTool:
assert result == expected assert result == expected
assert mock_fn.call_count == 1 assert mock_fn.call_count == 1
mock_sleep.assert_not_awaited() mock_sleep.assert_not_awaited()
# #endregion test_retry_skips_delay_on_success # #endregion Test.AgentChat.TestRetrySkipsDelayOnSuccess
# #region test_non_http_error_not_retried [C:2] [TYPE Function] # #region Test.AgentChat.TestNonHttpErrorNotRetried [C:2] [TYPE Function]
# @BRIEF Non-HTTP errors (e.g. ValueError) propagate immediately — no retry. # @BRIEF Non-HTTP errors (e.g. ValueError) propagate immediately — no retry.
async def test_non_http_error_not_retried(self): async def test_non_http_error_not_retried(self):
"""Prove that only the three specific httpx exception types are retried.""" """Prove that only the three specific httpx exception types are retried."""
@@ -142,13 +142,13 @@ class TestRetryReadTool:
assert exc_info.value is non_http_err assert exc_info.value is non_http_err
assert mock_fn.call_count == 1 assert mock_fn.call_count == 1
mock_sleep.assert_not_awaited() mock_sleep.assert_not_awaited()
# #endregion test_non_http_error_not_retried # #endregion Test.AgentChat.TestNonHttpErrorNotRetried
class TestWriteToolNoRetry: class TestWriteToolNoRetry:
"""Prove that write tools bypass _retry_read_tool entirely.""" """Prove that write tools bypass _retry_read_tool entirely."""
# #region test_write_tool_502_no_retry [C:2] [TYPE Function] # #region Test.AgentChat.TestWriteTool502NoRetry [C:2] [TYPE Function]
# @BRIEF Write tool (is_write=True) gets 502 → no retry, raises immediately. # @BRIEF Write tool (is_write=True) gets 502 → no retry, raises immediately.
async def test_write_tool_502_raises_immediately(self): async def test_write_tool_502_raises_immediately(self):
"""Prove @TEST_EDGE write_tool_502: _execute_with_timeout does NOT retry writes. """Prove @TEST_EDGE write_tool_502: _execute_with_timeout does NOT retry writes.
@@ -172,7 +172,7 @@ class TestWriteToolNoRetry:
assert write_op.call_count == 1 assert write_op.call_count == 1
# Critical invariant: no sleep = no retry loop entered # Critical invariant: no sleep = no retry loop entered
mock_sleep.assert_not_awaited() mock_sleep.assert_not_awaited()
# #endregion test_write_tool_502_no_retry # #endregion Test.AgentChat.TestWriteTool502NoRetry
# #endregion Test.AgentChat.ToolRetry # #endregion Test.AgentChat.ToolRetry

View File

@@ -11,7 +11,7 @@ import json
from ss_tools.agent.tools import _summarise_response from ss_tools.agent.tools import _summarise_response
# #region test_summarise_json_array_50_items [C:2] [TYPE Function] # #region Test.AgentChat.TestSummariseJsonArray50Items [C:2] [TYPE Function]
# @BRIEF JSON array with 50 items → top-5 summary with remaining count. # @BRIEF JSON array with 50 items → top-5 summary with remaining count.
def test_summarise_json_array_50_items(): def test_summarise_json_array_50_items():
"""Large JSON arrays summarise with top-5 items and remaining count.""" """Large JSON arrays summarise with top-5 items and remaining count."""
@@ -24,10 +24,10 @@ def test_summarise_json_array_50_items():
assert "item-0" in summary assert "item-0" in summary
assert "item-4" in summary assert "item-4" in summary
assert "... and 45 more items." in summary assert "... and 45 more items." in summary
# #endregion test_summarise_json_array_50_items # #endregion Test.AgentChat.TestSummariseJsonArray50Items
# #region test_summarise_short_text_passthrough [C:2] [TYPE Function] # #region Test.AgentChat.TestSummariseShortTextPassthrough [C:2] [TYPE Function]
# @BRIEF Text ≤ limit is returned unchanged (no truncation, no JSON parse overhead visible). # @BRIEF Text ≤ limit is returned unchanged (no truncation, no JSON parse overhead visible).
def test_summarise_short_text_passthrough(): def test_summarise_short_text_passthrough():
"""Text within limit is returned unchanged.""" """Text within limit is returned unchanged."""
@@ -36,10 +36,10 @@ def test_summarise_short_text_passthrough():
result = _summarise_response(text, limit=100) result = _summarise_response(text, limit=100)
assert result == text assert result == text
# #endregion test_summarise_short_text_passthrough # #endregion Test.AgentChat.TestSummariseShortTextPassthrough
# #region test_summarise_json_object_keys_sample [C:2] [TYPE Function] # #region Test.AgentChat.TestSummariseJsonObjectKeysSample [C:2] [TYPE Function]
# @BRIEF Large JSON object → key list + sample values. # @BRIEF Large JSON object → key list + sample values.
def test_summarise_json_object_keys_sample(): def test_summarise_json_object_keys_sample():
"""Large JSON objects are summarised with keys and sample values.""" """Large JSON objects are summarised with keys and sample values."""
@@ -50,10 +50,10 @@ def test_summarise_json_object_keys_sample():
assert summary.startswith("Result keys: ") assert summary.startswith("Result keys: ")
assert "Sample: " in summary assert "Sample: " in summary
# #endregion test_summarise_json_object_keys_sample # #endregion Test.AgentChat.TestSummariseJsonObjectKeysSample
# #region test_summarise_non_json_sentence_boundary [C:2] [TYPE Function] # #region Test.AgentChat.TestSummariseNonJsonSentenceBoundary [C:2] [TYPE Function]
# @BRIEF Non-JSON long text truncated at last sentence boundary before limit. # @BRIEF Non-JSON long text truncated at last sentence boundary before limit.
def test_summarise_non_json_sentence_boundary(): def test_summarise_non_json_sentence_boundary():
"""Non-JSON text truncates at last sentence boundary with trailing ellipsis.""" """Non-JSON text truncates at last sentence boundary with trailing ellipsis."""
@@ -67,7 +67,7 @@ def test_summarise_non_json_sentence_boundary():
assert len(summary) <= 500 assert len(summary) <= 500
# Must retain at least one sentence boundary before the ellipsis # Must retain at least one sentence boundary before the ellipsis
assert ". " in summary[:-3] assert ". " in summary[:-3]
# #endregion test_summarise_non_json_sentence_boundary # #endregion Test.AgentChat.TestSummariseNonJsonSentenceBoundary
# #endregion Test.AgentChat.ToolSummarise # #endregion Test.AgentChat.ToolSummarise

View File

@@ -14,7 +14,7 @@ import pytest
# ── Tests ─────────────────────────────────────────────────────────── # ── Tests ───────────────────────────────────────────────────────────
# #region test_completes_under_timeout [C:2] [TYPE Function] # #region Test.AgentChat.TestCompletesUnderTimeout [C:2] [TYPE Function]
# @BRIEF GIVEN a tool that returns quickly WHEN _execute_with_timeout is called with a 30s timeout THEN the result is returned normally. # @BRIEF GIVEN a tool that returns quickly WHEN _execute_with_timeout is called with a 30s timeout THEN the result is returned normally.
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_completes_under_timeout(): async def test_completes_under_timeout():
@@ -30,10 +30,10 @@ async def test_completes_under_timeout():
assert result == expected assert result == expected
fast_fn.assert_called_once() fast_fn.assert_called_once()
mock_explore.assert_not_called() mock_explore.assert_not_called()
# #endregion test_completes_under_timeout # #endregion Test.AgentChat.TestCompletesUnderTimeout
# #region test_read_tool_timeout [C:2] [TYPE Function] # #region Test.AgentChat.TestReadToolTimeout [C:2] [TYPE Function]
# @BRIEF GIVEN a read tool that exceeds the timeout WHEN _execute_with_timeout is called THEN TimeoutError is raised and logger.explore is invoked. # @BRIEF GIVEN a read tool that exceeds the timeout WHEN _execute_with_timeout is called THEN TimeoutError is raised and logger.explore is invoked.
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_read_tool_timeout(): async def test_read_tool_timeout():
@@ -56,10 +56,10 @@ async def test_read_tool_timeout():
assert payload["timeout_s"] == 0.05 assert payload["timeout_s"] == 0.05
assert payload["is_write"] is False assert payload["is_write"] is False
assert call_args[1]["extra"]["src"] == "AgentChat.Tools.Timeout" assert call_args[1]["extra"]["src"] == "AgentChat.Tools.Timeout"
# #endregion test_read_tool_timeout # #endregion Test.AgentChat.TestReadToolTimeout
# #region test_write_tool_timeout [C:2] [TYPE Function] # #region Test.AgentChat.TestWriteToolTimeout [C:2] [TYPE Function]
# @BRIEF GIVEN a write tool that exceeds the timeout WHEN _execute_with_timeout is called THEN TimeoutError is raised with is_write=True logged. # @BRIEF GIVEN a write tool that exceeds the timeout WHEN _execute_with_timeout is called THEN TimeoutError is raised with is_write=True logged.
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_write_tool_timeout(): async def test_write_tool_timeout():
@@ -82,6 +82,6 @@ async def test_write_tool_timeout():
assert payload["timeout_s"] == 0.05 assert payload["timeout_s"] == 0.05
assert payload["is_write"] is True assert payload["is_write"] is True
assert call_args[1]["extra"]["src"] == "AgentChat.Tools.Timeout" assert call_args[1]["extra"]["src"] == "AgentChat.Tools.Timeout"
# #endregion test_write_tool_timeout # #endregion Test.AgentChat.TestWriteToolTimeout
# #endregion Test.AgentChat.ToolTimeout # #endregion Test.AgentChat.ToolTimeout

Binary file not shown.

Binary file not shown.

BIN
backend/:memory:test_tasks Normal file

Binary file not shown.

View File

@@ -1,11 +1,11 @@
# #region AlembicEnvModule [C:3] [TYPE Module] [SEMANTICS alembic, migration, env, logging] # #region Alembic.Env.AlembicEnvModule [C:3] [TYPE Module] [SEMANTICS alembic, migration, env, logging]
# @BRIEF Alembic environment configuration — sets up DB connection, model metadata, # @BRIEF Alembic environment configuration — sets up DB connection, model metadata,
# and logging. Contains ADR [LOG-001] for suppress_existing_loggers=False. # and logging. Contains ADR [LOG-001] for suppress_existing_loggers=False.
# @LAYER Infrastructure # @LAYER Infrastructure
# @RELATION DEPENDS_ON -> [LoggerModule] # @RELATION DEPENDS_ON -> [Core.Logger.LoggerModule]
# @INVARIANT fileConfig() must be called with disable_existing_loggers=False # @INVARIANT fileConfig() must be called with disable_existing_loggers=False
# to prevent disabling superset_tools_app logger (see ADR LOG-001). # to prevent disabling superset_tools_app logger (see ADR LOG-001).
# #endregion AlembicEnvModule # #endregion Alembic.Env.AlembicEnvModule
from logging.config import fileConfig from logging.config import fileConfig
import os import os

View File

@@ -0,0 +1,40 @@
# #region Alembic.MergeThreeHeads [C:2] [TYPE Module] [SEMANTICS alembic,merge,heads]
# @ingroup Alembic
# @BRIEF Merge three migration heads into one: 6b8ca3b7405f, b4c5d6e7f8a9, f4a5b6c7d8e9.
# Branches b4c5d6e7f8a9 and f4a5b6c7d8e9 were created from f2b3c4d5e6f7 after the
# previous merge (6b8ca3b7405f), creating multiple heads. This merge resolves them.
# @LAYER Database
# @RELATION DEPENDS_ON -> [Alembic.AddAgentConversations]
# @RELATION DEPENDS_ON -> [Alembic.AddIncludeSourceReference]
# @RELATION DEPENDS_ON -> [Alembic.AddDeploymentValidation]
"""merge: 6b8ca3b7405f, b4c5d6e7f8a9, f4a5b6c7d8e9
Revision ID: 7eaf84b7f6be
Revises: 6b8ca3b7405f, b4c5d6e7f8a9, f4a5b6c7d8e9
Create Date: 2026-07-14 17:29:58.171927
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7eaf84b7f6be'
down_revision: Union[str, Sequence[str], None] = ('6b8ca3b7405f', 'b4c5d6e7f8a9', 'f4a5b6c7d8e9')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
pass
def downgrade() -> None:
"""Downgrade schema."""
pass
# #endregion Alembic.MergeThreeHeads

View File

@@ -0,0 +1,75 @@
# #region Alembic.Migration.AddSessionActivityTable [C:2] [TYPE Migration] [SEMANTICS alembic,migration,session,activity,auth]
# @BRIEF Create session_activity table for idle/absolute session timeout enforcement.
# @PRE Previous migration (f7a8b9c0d1e2) has been applied.
# @POST session_activity table is created when users exists; otherwise ORM create_all()
# creates it after the fresh Alembic upgrade.
# @SIDE_EFFECT DDL execution — creates table, index, foreign key constraint.
# @RELATION DEPENDS_ON -> [Models.Auth.SessionActivity]
# @RELATION DEPENDS_ON -> [Models.Auth.User]
# @RATIONALE users is ORM-owned and is created by Base.metadata.create_all() after
# Alembic during a fresh install, so the FK migration must be safely skipped first.
# @REJECTED Unconditionally creating the FK table was rejected — fresh installs fail
# before application startup because users does not yet exist.
"""add session_activity table
Revision ID: 8e9f0a1b2c3d
Revises: f7a8b9c0d1e2
Create Date: 2026-07-23 07:45:00.000000
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = "8e9f0a1b2c3d"
down_revision: str | Sequence[str] | None = "f7a8b9c0d1e2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# #region Migration.AddSessionActivityTable.TableExists [C:1] [TYPE Function] [SEMANTICS alembic,helper,table]
# @BRIEF Check if a table already exists in the database.
def _table_exists(table_name: str) -> bool:
conn = op.get_bind()
inspector = inspect(conn)
return table_name in inspector.get_table_names()
# #endregion Migration.AddSessionActivityTable.TableExists
# #region Migration.AddSessionActivityTable.Upgrade [C:2] [TYPE Function] [SEMANTICS alembic,upgrade]
# @BRIEF Create session_activity table for idle/absolute timeout enforcement.
# @PRE auth.users table exists in the database.
# @POST session_activity table created with jti PK, user_id FK->users.id, indexes.
# @SIDE_EFFECT Executes CREATE TABLE DDL.
def upgrade() -> None:
"""Create session_activity table for idle/absolute timeout enforcement."""
if _table_exists("session_activity") or not _table_exists("users"):
return
op.create_table(
"session_activity",
sa.Column("jti", sa.String(), nullable=False),
sa.Column("user_id", sa.String(), nullable=False, index=True),
sa.Column("issued_at", sa.DateTime(), nullable=False),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.Column("last_activity_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("ip_address", sa.String(), nullable=True),
sa.Column("user_agent", sa.String(), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("jti"),
)
# #endregion Migration.AddSessionActivityTable.Upgrade
# #region Migration.AddSessionActivityTable.Downgrade [C:1] [TYPE Function] [SEMANTICS alembic,downgrade]
# @BRIEF Drop session_activity table, reverting the upgrade.
# @POST session_activity table dropped.
# @SIDE_EFFECT Executes DROP TABLE DDL.
def downgrade() -> None:
"""Drop session_activity table."""
op.drop_table("session_activity")
# #endregion Migration.AddSessionActivityTable.Downgrade
# #endregion Alembic.Migration.AddSessionActivityTable

View File

@@ -0,0 +1,76 @@
# #region Alembic.AddAgentLifecycleEvents [C:3] [TYPE Module] [SEMANTICS alembic,agent,lifecycle,audit]
# @ingroup Alembic
# @BRIEF Add agent_lifecycle_events table for Phase 3 durable agent lifecycle audit.
# @LAYER Database
# @RELATION DEPENDS_ON -> [Models.Agent.AgentLifecycleEvent]
# @INVARIANT table has composite indexes for common query patterns (user+type+created, conv+type+created).
# @RATIONALE Immutable, indexed events make trace/conversation diagnostics queryable without
# keeping raw prompts or tool output in application logs.
# @REJECTED Reusing agent_messages was rejected — message content has a separate retention and
# privacy contract and cannot represent request/tool lifecycle boundaries safely.
"""add agent_lifecycle_events table
Revision ID: b2a3c4d5e6f7
Revises: 7eaf84b7f6be
Create Date: 2026-07-15 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "b2a3c4d5e6f7"
down_revision: Union[str, Sequence[str], None] = "7eaf84b7f6be"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"agent_lifecycle_events",
sa.Column("id", sa.String(), nullable=False),
sa.Column("trace_id", sa.String(), nullable=False, index=True),
sa.Column("conversation_id", sa.String(), nullable=False, index=True),
sa.Column("user_id", sa.String(), nullable=False, index=True),
sa.Column("environment_id", sa.String(), nullable=True, index=True),
sa.Column("event_type", sa.String(), nullable=False, index=True),
sa.Column("tool_name", sa.String(), nullable=True, index=True),
sa.Column("status", sa.String(), nullable=True, index=True),
# Store UTC timestamps explicitly; the model normalizes values to UTC
# before serialization, independent of the database session timezone.
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("elapsed_ms", sa.Float(), nullable=True),
sa.Column("payload", sa.JSON(), nullable=True),
sa.Column("error_code", sa.String(), nullable=True, index=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_agent_lifecycle_events_user_type_created",
"agent_lifecycle_events",
["user_id", "event_type", "created_at"],
unique=False,
)
op.create_index(
"ix_agent_lifecycle_events_conv_type_created",
"agent_lifecycle_events",
["conversation_id", "event_type", "created_at"],
unique=False,
)
op.create_index(
"ix_agent_lifecycle_events_created",
"agent_lifecycle_events",
["created_at"],
unique=False,
)
def downgrade() -> None:
op.drop_index("ix_agent_lifecycle_events_created", table_name="agent_lifecycle_events")
op.drop_index("ix_agent_lifecycle_events_conv_type_created", table_name="agent_lifecycle_events")
op.drop_index("ix_agent_lifecycle_events_user_type_created", table_name="agent_lifecycle_events")
op.drop_table("agent_lifecycle_events")
# #endregion Alembic.AddAgentLifecycleEvents

View File

@@ -0,0 +1,50 @@
# #region Alembic.TaskRecordsUserId [C:3] [TYPE Module] [SEMANTICS alembic,migration,task,postgres]
# @BRIEF Adds nullable task ownership to persistent task records.
# @RELATION DEPENDS_ON -> [EXT:SQLAlchemy:Alembic]
# @POST Existing task_records rows retain data and gain a nullable user_id column.
# @RATIONALE Task ownership must persist so task list queries can be scoped to a user after restart.
# @REJECTED Runtime create_all() or manual ALTER TABLE was rejected because schema evolution must
# remain reproducible through the Alembic migration chain.
"""add user_id column to task_records table
Revision ID: c3d4e5f6a7b8
Revises: b2a3c4d5e6f7
Create Date: 2026-07-15 19:06:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'c3d4e5f6a7b8'
down_revision: str | Sequence[str] | None = 'b2a3c4d5e6f7'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _table_exists(table_name: str) -> bool:
conn = op.get_bind()
inspector = inspect(conn)
return table_name in inspector.get_table_names()
def upgrade() -> None:
"""Add user_id column to task_records table."""
if not _table_exists("task_records"):
return
op.add_column(
"task_records",
sa.Column("user_id", sa.String(), nullable=True),
)
def downgrade() -> None:
"""Remove user_id column from task_records table."""
op.drop_column("task_records", "user_id")
# #endregion Alembic.TaskRecordsUserId

View File

@@ -0,0 +1,52 @@
# #region Alembic.TranslateActiveRunGuard [C:3] [TYPE Module] [SEMANTICS alembic,migration,translate,concurrency]
# @BRIEF Enforces at most one pending/running translation run per job.
# @RELATION DEPENDS_ON -> [EXT:SQLAlchemy:Alembic]
# @POST Concurrent manual/scheduled triggers cannot create duplicate active runs.
"""add unique active translation run guard
Revision ID: d4e5f6a7b8c9
Revises: c3d4e5f6a7b8
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "d4e5f6a7b8c9"
down_revision: str | Sequence[str] | None = "c3d4e5f6a7b8"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Create a partial unique index on supported application databases."""
bind = op.get_bind()
dialect = bind.dialect.name
if dialect == "postgresql":
op.create_index(
"uq_translation_runs_one_active_per_job",
"translation_runs",
["job_id"],
unique=True,
postgresql_where=sa.text("status IN ('PENDING', 'RUNNING')"),
)
elif dialect == "sqlite":
op.create_index(
"uq_translation_runs_one_active_per_job",
"translation_runs",
["job_id"],
unique=True,
sqlite_where=sa.text("status IN ('PENDING', 'RUNNING')"),
)
def downgrade() -> None:
"""Drop the active-run guard."""
bind = op.get_bind()
if bind.dialect.name in {"postgresql", "sqlite"}:
op.drop_index("uq_translation_runs_one_active_per_job", table_name="translation_runs")
# #endregion Alembic.TranslateActiveRunGuard

View File

@@ -1,6 +1,11 @@
# #region Alembic.AddDeploymentRecords [C:2] [TYPE Function] [SEMANTICS alembic,migration,deployment,versioning] # #region Alembic.AddDeploymentRecords [C:3] [TYPE Module] [SEMANTICS alembic,migration,deployment,versioning]
# @BRIEF Add deployment_records table for version tracking (Phase 0). # @BRIEF Add deployment_records table for version tracking (Phase 0).
# @RELATION DEPENDS_ON -> [DeploymentModels] # @RELATION DEPENDS_ON -> [Models.Deployment.DeploymentModels]
# @POST Creates deployment dependency tables before adding foreign-key-constrained records.
# @RATIONALE DeploymentEnvironment and GitRepository were historically created by runtime metadata,
# which left a fresh Alembic upgrade without the foreign-key targets required here.
# @REJECTED Relying on Base.metadata.create_all() before Alembic was rejected because production
# startup must be able to initialize its schema through migrations alone.
"""add deployment_records table """add deployment_records table
Revision ID: e3a4b5c6d7e8 Revision ID: e3a4b5c6d7e8
@@ -8,21 +13,56 @@ Revises: f2b3c4d5e6f7
Create Date: 2026-07-10 13:30:00.000000 Create Date: 2026-07-10 13:30:00.000000
""" """
from typing import Sequence, Union from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSON from sqlalchemy.dialects.postgresql import JSON
from alembic import op
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "e3a4b5c6d7e8" revision: str = "e3a4b5c6d7e8"
down_revision: Union[str, None] = "f2b3c4d5e6f7" down_revision: str | None = "f2b3c4d5e6f7"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: str | Sequence[str] | None = None
depends_on: Union[str, Sequence[str], None] = None depends_on: str | Sequence[str] | None = None
def upgrade() -> None: def upgrade() -> None:
op.create_table(
"git_server_configs",
sa.Column("id", sa.String(36), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("provider", sa.String(20), nullable=False),
sa.Column("url", sa.String(255), nullable=False),
sa.Column("pat", sa.String(255), nullable=False),
sa.Column("default_repository", sa.String(255), nullable=True),
sa.Column("default_branch", sa.String(255), nullable=True),
sa.Column("status", sa.String(20), nullable=True),
sa.Column("last_validated", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"git_repositories",
sa.Column("id", sa.String(36), nullable=False),
sa.Column("dashboard_id", sa.Integer(), nullable=False),
sa.Column("config_id", sa.String(36), nullable=False),
sa.Column("remote_url", sa.String(255), nullable=False),
sa.Column("local_path", sa.String(255), nullable=False),
sa.Column("current_branch", sa.String(255), nullable=True),
sa.Column("sync_status", sa.String(20), nullable=True),
sa.ForeignKeyConstraint(["config_id"], ["git_server_configs.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("dashboard_id"),
)
op.create_table(
"deployment_environments",
sa.Column("id", sa.String(36), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("superset_url", sa.String(255), nullable=False),
sa.Column("superset_token", sa.String(255), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_table( op.create_table(
"deployment_records", "deployment_records",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
@@ -55,3 +95,9 @@ def downgrade() -> None:
op.drop_index(op.f("ix_deployment_records_content_hash"), table_name="deployment_records") op.drop_index(op.f("ix_deployment_records_content_hash"), table_name="deployment_records")
op.drop_index(op.f("ix_deployment_records_repository_env"), table_name="deployment_records") op.drop_index(op.f("ix_deployment_records_repository_env"), table_name="deployment_records")
op.drop_table("deployment_records") op.drop_table("deployment_records")
op.drop_table("deployment_environments")
op.drop_table("git_repositories")
op.drop_table("git_server_configs")
# #endregion Alembic.AddDeploymentRecords

View File

@@ -0,0 +1,49 @@
# #region Alembic.AddTranslationRunObservabilityMetrics [C:3] [TYPE Module] [SEMANTICS alembic,translate,metrics]
# @defgroup Alembic Persist independent source, translation, and insert metrics for translation runs.
"""add translation run observability metrics
Revision ID: e6f7a8b9c0d1
Revises: f5e6d7c8b9a0
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "e6f7a8b9c0d1"
down_revision: str | Sequence[str] | None = "f5e6d7c8b9a0"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# #region Alembic.AddTranslationRunObservabilityMetrics.Upgrade [C:3] [TYPE Function] [SEMANTICS alembic,translate,metrics]
# @ingroup Alembic
# @BRIEF Add nullable run metrics so historical runs remain explicitly unknown.
def upgrade() -> None:
op.add_column("translation_runs", sa.Column("source_records_read", sa.Integer(), nullable=True))
op.add_column("translation_runs", sa.Column("eligible_records", sa.Integer(), nullable=True))
op.add_column("translation_runs", sa.Column("translated_records", sa.Integer(), nullable=True))
op.add_column("translation_runs", sa.Column("same_language_skipped_records", sa.Integer(), nullable=True))
op.add_column("translation_runs", sa.Column("insert_rows_prepared", sa.Integer(), nullable=True))
op.add_column("translation_runs", sa.Column("insert_rows_affected", sa.Integer(), nullable=True))
# #endregion Alembic.AddTranslationRunObservabilityMetrics.Upgrade
# #region Alembic.AddTranslationRunObservabilityMetrics.Downgrade [C:2] [TYPE Function] [SEMANTICS alembic,translate,metrics]
# @ingroup Alembic
# @BRIEF Remove translation-run observability metrics.
def downgrade() -> None:
op.drop_column("translation_runs", "insert_rows_affected")
op.drop_column("translation_runs", "insert_rows_prepared")
op.drop_column("translation_runs", "same_language_skipped_records")
op.drop_column("translation_runs", "translated_records")
op.drop_column("translation_runs", "eligible_records")
op.drop_column("translation_runs", "source_records_read")
# #endregion Alembic.AddTranslationRunObservabilityMetrics.Downgrade
# #endregion Alembic.AddTranslationRunObservabilityMetrics

View File

@@ -0,0 +1,63 @@
# #region Alembic.AddDashboardReleases [C:3] [TYPE Module] [SEMANTICS alembic,git,release]
# @defgroup Alembic Persist dashboard release records and repository policy overrides.
"""add dashboard releases
Revision ID: f5e6d7c8b9a0
Revises: d4e5f6a7b8c9
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "f5e6d7c8b9a0"
down_revision: str | Sequence[str] | None = "d4e5f6a7b8c9"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# #region Alembic.AddDashboardReleases.Upgrade [C:3] [TYPE Function] [SEMANTICS alembic,git,release]
# @ingroup Alembic
# @BRIEF Add release ledger and optional per-repository policy JSON.
def upgrade() -> None:
op.add_column("git_repositories", sa.Column("release_policy", sa.JSON(), nullable=True))
op.create_table(
"dashboard_releases",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column("repository_id", sa.String(length=36), sa.ForeignKey("git_repositories.id", ondelete="CASCADE"), nullable=False),
sa.Column("deployment_id", sa.Integer(), sa.ForeignKey("deployment_records.id", ondelete="RESTRICT"), nullable=False, unique=True),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("version", sa.String(length=100), nullable=False),
sa.Column("notes", sa.Text(), nullable=False),
sa.Column("commit_hash", sa.String(length=40), nullable=False),
sa.Column("content_hash", sa.String(length=64), nullable=False),
sa.Column("status", sa.String(length=32), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("created_by", sa.String(length=255), nullable=False),
sa.Column("approved_at", sa.DateTime(), nullable=True),
sa.Column("approved_by", sa.String(length=255), nullable=True),
sa.Column("approval_comment", sa.Text(), nullable=True),
sa.Column("published_at", sa.DateTime(), nullable=True),
sa.Column("published_by", sa.String(length=255), nullable=True),
sa.UniqueConstraint("repository_id", "version", name="uq_dashboard_release_repository_version"),
)
op.create_index("ix_dashboard_releases_repository_id", "dashboard_releases", ["repository_id"])
# #endregion Alembic.AddDashboardReleases.Upgrade
# #region Alembic.AddDashboardReleases.Downgrade [C:2] [TYPE Function] [SEMANTICS alembic,git,release]
# @ingroup Alembic
# @BRIEF Remove dashboard release persistence.
def downgrade() -> None:
op.drop_index("ix_dashboard_releases_repository_id", table_name="dashboard_releases")
op.drop_table("dashboard_releases")
op.drop_column("git_repositories", "release_policy")
# #endregion Alembic.AddDashboardReleases.Downgrade
# #endregion Alembic.AddDashboardReleases

View File

@@ -0,0 +1,184 @@
# #region Alembic.AddTranslatePerformanceKnobs [C:3] [TYPE Module] [SEMANTICS alembic,translate,performance]
# @defgroup Alembic Persist translation performance knobs (job policy + provider capabilities).
"""Add translation performance knobs (job + provider capabilities).
Revision ID: f7a8b9c0d1e2
Revises: e6f7a8b9c0d1
Create Date: 2026-07-20 10:30:00.000000
NULL defaults preserve legacy algorithm behaviour (serial LLM, auto hard caps).
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "f7a8b9c0d1e2"
down_revision: str | Sequence[str] | None = "e6f7a8b9c0d1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# #region Alembic.AddTranslatePerformanceKnobs.AddColIfMissing [C:2] [TYPE Function] [SEMANTICS alembic,translate,idempotent]
# @ingroup Alembic
# @BRIEF Add a column only when absent — keeps the migration re-runnable.
def _add_col_if_missing(table: str, column: sa.Column) -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not inspector.has_table(table):
return
existing = {c["name"] for c in inspector.get_columns(table)}
if column.name in existing:
return
op.add_column(table, column)
# #endregion Alembic.AddTranslatePerformanceKnobs.AddColIfMissing
# #region Alembic.AddTranslatePerformanceKnobs.Upgrade [C:3] [TYPE Function] [SEMANTICS alembic,translate,performance]
# @ingroup Alembic
# @BRIEF Add nullable performance knobs to translation_jobs and llm_providers.
# @RATIONALE NULL defaults preserve legacy algorithm behaviour (serial LLM, auto hard caps);
# capabilities are stored in DB, not inferred from brand/host at runtime.
# @RATIONALE llm_providers is an ORM-owned table created by Base.metadata.create_all()
# after Alembic on a fresh install; absent tables must therefore be skipped here.
# @REJECTED Non-nullable columns with server defaults — would silently flip legacy jobs to new behaviour.
def upgrade() -> None:
# ── translation_jobs (job policy / performance) ────────────────────────
_add_col_if_missing(
"translation_jobs",
sa.Column(
"llm_batch_max_rows",
sa.Integer(),
nullable=True,
comment="Max source rows per LLM batch (NULL = algorithm default)",
),
)
_add_col_if_missing(
"translation_jobs",
sa.Column(
"llm_concurrency",
sa.Integer(),
nullable=True,
comment="Parallel LLM batch workers (NULL = 1, legacy serial)",
),
)
_add_col_if_missing(
"translation_jobs",
sa.Column(
"insert_concurrency",
sa.Integer(),
nullable=True,
comment="Parallel insert workers (NULL = 1)",
),
)
_add_col_if_missing(
"translation_jobs",
sa.Column(
"multi_lang_mode",
sa.String(),
nullable=True,
comment="single_call | per_language (NULL = single_call)",
),
)
_add_col_if_missing(
"translation_jobs",
sa.Column(
"batch_aggressiveness",
sa.String(),
nullable=True,
comment="safe | balanced | fast (NULL = balanced legacy constants)",
),
)
_add_col_if_missing(
"translation_jobs",
sa.Column(
"max_in_flight_batches",
sa.Integer(),
nullable=True,
comment="Backpressure queue depth for parallel results (NULL = 32)",
),
)
# ── llm_providers (capabilities, not brand heuristics) ─────────────────
_add_col_if_missing(
"llm_providers",
sa.Column(
"throughput_class",
sa.String(),
nullable=True,
comment="standard | local (NULL = derive later via capability, not host sniff at runtime)",
),
)
_add_col_if_missing(
"llm_providers",
sa.Column(
"reasoning_control",
sa.String(),
nullable=True,
comment="off|generic_none|openai_effort|deepseek_thinking|llamacpp_think|auto",
),
)
_add_col_if_missing(
"llm_providers",
sa.Column(
"supports_json_object",
sa.Boolean(),
nullable=True,
comment="If true, send response_format=json_object (NULL = true for openai-compatible)",
),
)
_add_col_if_missing(
"llm_providers",
sa.Column(
"default_llm_concurrency",
sa.Integer(),
nullable=True,
comment="Default job llm_concurrency when job field is NULL",
),
)
_add_col_if_missing(
"llm_providers",
sa.Column(
"max_llm_concurrency",
sa.Integer(),
nullable=True,
comment="Hard ceiling for job llm_concurrency",
),
)
# #endregion Alembic.AddTranslatePerformanceKnobs.Upgrade
# #region Alembic.AddTranslatePerformanceKnobs.Downgrade [C:2] [TYPE Function] [SEMANTICS alembic,translate,performance]
# @ingroup Alembic
# @BRIEF Drop performance knobs conditionally (idempotent).
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
job_cols = {c["name"] for c in inspector.get_columns("translation_jobs")}
for name in (
"max_in_flight_batches",
"batch_aggressiveness",
"multi_lang_mode",
"insert_concurrency",
"llm_concurrency",
"llm_batch_max_rows",
):
if name in job_cols:
op.drop_column("translation_jobs", name)
if not inspector.has_table("llm_providers"):
return
prov_cols = {c["name"] for c in inspector.get_columns("llm_providers")}
for name in (
"max_llm_concurrency",
"default_llm_concurrency",
"supports_json_object",
"reasoning_control",
"throughput_class",
):
if name in prov_cols:
op.drop_column("llm_providers", name)
# #endregion Alembic.AddTranslatePerformanceKnobs.Downgrade
# #endregion Alembic.AddTranslatePerformanceKnobs

View File

@@ -1,4 +1,4 @@
# #region SrcRoot [TYPE Module] [SEMANTICS root, package] # #region Init.SrcRoot [TYPE Module] [SEMANTICS root, package]
# @defgroup Module Module group. # @defgroup Module Module group.
# @BRIEF Canonical backend package root for application, scripts, and tests. # @BRIEF Canonical backend package root for application, scripts, and tests.
# #endregion SrcRoot # #endregion Init.SrcRoot

View File

@@ -7,21 +7,24 @@
# @DATA_CONTRACT OAuth2PasswordRequestForm -> Token | User # @DATA_CONTRACT OAuth2PasswordRequestForm -> Token | User
# @INVARIANT All auth endpoints return consistent error codes (401/403/422). # @INVARIANT All auth endpoints return consistent error codes (401/403/422).
# @RELATION DEPENDS_ON -> [Auth.Jwt] # @RELATION DEPENDS_ON -> [Auth.Jwt]
# @RELATION DEPENDS_ON -> [auth_service] # @RELATION DEPENDS_ON -> [Services.Auth.Service]
# @RELATION DEPENDS_ON -> [AuthOauthModule] # @RELATION DEPENDS_ON -> [Core.Oauth.AuthOauthModule]
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
import starlette.requests import starlette.requests
from ..core.auth.jwt import blacklist_token from ..core.auth.jwt import blacklist_token, decode_token
from ..core.auth.logger import log_security_event from ..core.auth.logger import log_security_event
from ..core.auth.oauth import is_adfs_configured, oauth from ..core.auth.oauth import is_adfs_configured, oauth
from ..core.database import get_auth_db from ..core.database import get_auth_db
from ..core.logger import belief_scope, logger from ..core.logger import belief_scope, logger
from ..core.rate_limiter import rate_limiter from ..core.rate_limiter import rate_limiter
from ..dependencies import get_current_user from ..dependencies import get_current_user, oauth2_scheme
from ..schemas.auth import Token, User as UserSchema from ..schemas.auth import Token, User as UserSchema
from ..services.auth_service import AuthService from ..services.auth_service import AuthService
@@ -39,7 +42,7 @@ router = APIRouter(prefix="/api/auth", tags=["auth"])
# @POST Returns Token(access_token, token_type) on success; 401 on failure. # @POST Returns Token(access_token, token_type) on success; 401 on failure.
# @SIDE_EFFECT DB read for user verification; writes security event LOGIN. # @SIDE_EFFECT DB read for user verification; writes security event LOGIN.
# @SIDE_EFFECT Molecular CoT: REASON on entry, REFLECT on success, EXPLORE on failure. # @SIDE_EFFECT Molecular CoT: REASON on entry, REFLECT on success, EXPLORE on failure.
# @RELATION CALLS -> [auth_service] # @RELATION CALLS -> [Services.Auth.Service]
# @TEST_EDGE: invalid_credentials -> 401 # @TEST_EDGE: invalid_credentials -> 401
# @TEST_EDGE: locked_account -> 423 # @TEST_EDGE: locked_account -> 423
# @TEST_EDGE: missing_fields -> 422 # @TEST_EDGE: missing_fields -> 422
@@ -87,7 +90,7 @@ async def login_for_access_token(
# @PRE Valid JWT token in Authorization header. # @PRE Valid JWT token in Authorization header.
# @POST Returns UserSchema with id, username, email, roles. # @POST Returns UserSchema with id, username, email, roles.
# @SIDE_EFFECT Reads current user from DB via auth middleware. # @SIDE_EFFECT Reads current user from DB via auth middleware.
# @RELATION DEPENDS_ON -> [get_current_user] # @RELATION DEPENDS_ON -> [Dependencies.AppDependencies.GetCurrentUser]
@router.get("/me", response_model=UserSchema) @router.get("/me", response_model=UserSchema)
async def read_users_me(current_user: UserSchema = Depends(get_current_user)): async def read_users_me(current_user: UserSchema = Depends(get_current_user)):
@@ -98,6 +101,86 @@ async def read_users_me(current_user: UserSchema = Depends(get_current_user)):
# #endregion Api.Auth.Me # #endregion Api.Auth.Me
# #region Api.Auth.SessionPolicy [C:3] [TYPE Function] [SEMANTICS api,auth,session,timeout]
# @ingroup Auth
# @BRIEF Return session timeout policy and current session deadlines for the authenticated user.
# @PRE Valid JWT token in Authorization header.
# @POST Returns SessionPolicyResponse with idle/absolute expiry deadlines and warning window.
# @SIDE_EFFECT Reads SessionActivity for the current JWT; reads GlobalSettings for timeout policy.
# @RELATION DEPENDS_ON -> [Dependencies.AppDependencies.GetCurrentUser]
# @RELATION DEPENDS_ON -> [Auth.Jwt.DecodeToken]
# @RELATION DEPENDS_ON -> [Models.Auth.SessionActivity]
class SessionPolicyResponse(BaseModel):
idle_expires_at: str | None = None
absolute_expires_at: str | None = None
warning_seconds: int = 300
error_code: str = "SESSION_ACTIVE"
@router.get("/session", response_model=SessionPolicyResponse)
async def get_session_policy(
current_user: UserSchema = Depends(get_current_user),
db: Session = Depends(get_auth_db),
token: str = Depends(oauth2_scheme),
):
with belief_scope("api.auth.session"):
# Decode JWT to get jti, iat
try:
payload = decode_token(token)
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
jti = payload.get("jti")
if not jti:
return SessionPolicyResponse()
# Look up SessionActivity for this jti
from ..models.auth import SessionActivity
activity = db.query(SessionActivity).filter(SessionActivity.jti == jti).first()
# Read session policy from GlobalSettings (via ConfigManager cache)
from ..dependencies import get_config_manager
config_mgr = get_config_manager()
settings = config_mgr.get_config().settings
idle_timeout = settings.session_idle_timeout_minutes
abs_timeout = settings.session_absolute_timeout_minutes
warning_seconds = settings.session_warning_minutes * 60
now = datetime.now(UTC)
# Compute idle expiry
idle_expires_at: str | None = None
if idle_timeout > 0 and activity:
idle_deadline = activity.last_activity_at.replace(tzinfo=UTC) if activity.last_activity_at.tzinfo is None else activity.last_activity_at
idle_deadline = idle_deadline + timedelta(minutes=idle_timeout)
idle_expires_at = idle_deadline.isoformat()
# Compute absolute expiry
absolute_expires_at: str | None = None
if abs_timeout > 0 and activity:
abs_deadline = activity.issued_at.replace(tzinfo=UTC) if activity.issued_at.tzinfo is None else activity.issued_at
abs_deadline = abs_deadline + timedelta(minutes=abs_timeout)
absolute_expires_at = abs_deadline.isoformat()
return SessionPolicyResponse(
idle_expires_at=idle_expires_at,
absolute_expires_at=absolute_expires_at,
warning_seconds=warning_seconds,
error_code="SESSION_ACTIVE",
)
# #endregion Api.Auth.SessionPolicy
# #region Api.Auth.Logout [C:4] [TYPE Function] [SEMANTICS api,auth,logout,revoke] # #region Api.Auth.Logout [C:4] [TYPE Function] [SEMANTICS api,auth,logout,revoke]
# @ingroup Auth # @ingroup Auth
# @BRIEF Log out current user — blacklists the JWT token server-side. # @BRIEF Log out current user — blacklists the JWT token server-side.
@@ -105,7 +188,7 @@ async def read_users_me(current_user: UserSchema = Depends(get_current_user)):
# @POST Token added to blacklist; subsequent requests with same token rejected. # @POST Token added to blacklist; subsequent requests with same token rejected.
# @SIDE_EFFECT Writes security event LOGOUT; writes to token_blacklist table. # @SIDE_EFFECT Writes security event LOGOUT; writes to token_blacklist table.
# @SIDE_EFFECT Molecular CoT: REASON/REFLECT/EXPLORE markers. # @SIDE_EFFECT Molecular CoT: REASON/REFLECT/EXPLORE markers.
# @RELATION DEPENDS_ON -> [get_current_user] # @RELATION DEPENDS_ON -> [Dependencies.AppDependencies.GetCurrentUser]
# @RELATION CALLS -> [Auth.Jwt.BlacklistToken] # @RELATION CALLS -> [Auth.Jwt.BlacklistToken]
# @TEST_EDGE: already_expired_token -> 200 (idempotent) # @TEST_EDGE: already_expired_token -> 200 (idempotent)
@@ -139,7 +222,7 @@ async def logout(
# @BRIEF Initiate ADFS OIDC login flow — redirects user to identity provider. # @BRIEF Initiate ADFS OIDC login flow — redirects user to identity provider.
# @POST Redirects user to ADFS authorization endpoint. # @POST Redirects user to ADFS authorization endpoint.
# @SIDE_EFFECT Redirects browser to external OIDC provider. # @SIDE_EFFECT Redirects browser to external OIDC provider.
# @RELATION CALLS -> [AuthOauthModule] # @RELATION CALLS -> [Core.Oauth.AuthOauthModule]
@router.get("/login/adfs") @router.get("/login/adfs")
async def login_adfs(request: starlette.requests.Request): async def login_adfs(request: starlette.requests.Request):
@@ -162,7 +245,7 @@ async def login_adfs(request: starlette.requests.Request):
# @POST Provisions user in DB (JIT), creates auth session. # @POST Provisions user in DB (JIT), creates auth session.
# @SIDE_EFFECT DB write for user provisioning; writes security event LOGIN_ADFS. # @SIDE_EFFECT DB write for user provisioning; writes security event LOGIN_ADFS.
# @SIDE_EFFECT Molecular CoT: REASON/REFLECT/EXPLORE markers. # @SIDE_EFFECT Molecular CoT: REASON/REFLECT/EXPLORE markers.
# @RELATION CALLS -> [auth_service] # @RELATION CALLS -> [Services.Auth.Service]
# @TEST_EDGE: adfs_timeout -> 504 # @TEST_EDGE: adfs_timeout -> 504
# @TEST_EDGE: invalid_state -> 401 # @TEST_EDGE: invalid_state -> 401

View File

@@ -1,21 +1,21 @@
# #region ApiRoutesModule [C:5] [TYPE Module] [SEMANTICS api, package, router, lazy, import] # #region Api.Init.ApiRoutesModule [C:5] [TYPE Module] [SEMANTICS api, package, router, lazy, import]
# @defgroup Api Module group. # @defgroup Api Module group.
# @BRIEF Provide lazy route module loading to avoid heavyweight imports during tests. # @BRIEF Provide lazy route module loading to avoid heavyweight imports during tests.
# @LAYER API # @LAYER API
# @RELATION CALLS -> [ApiRoutesGetAttr] # @RELATION CALLS -> [Api.Init.ApiRoutesGetAttr]
# @RELATION BINDS_TO -> [Route_Group_Contracts] # @RELATION BINDS_TO -> [Api.Init.RouteGroupContracts]
# @PRE FastAPI app initialized, route modules available in package # @PRE FastAPI app initialized, route modules available in package
# @POST Route modules are lazily loadable via __getattr__ # @POST Route modules are lazily loadable via __getattr__
# @INVARIANT Only names listed in __all__ are importable via __getattr__. # @INVARIANT Only names listed in __all__ are importable via __getattr__.
# #region Route_Group_Contracts [C:3] [TYPE Block] # #region Api.Init.RouteGroupContracts [C:3] [TYPE Block]
# @ingroup Api # @ingroup Api
# @BRIEF Declare the canonical route-module registry used by lazy imports and app router inclusion. # @BRIEF Declare the canonical route-module registry used by lazy imports and app router inclusion.
# @RELATION DEPENDS_ON -> [PluginsRouter] # @RELATION DEPENDS_ON -> [Api.Plugins.PluginsRouter]
# @RELATION DEPENDS_ON -> [TasksRouter] # @RELATION DEPENDS_ON -> [Api.Tasks.TasksRouter]
# @RELATION DEPENDS_ON -> [SettingsRouter] # @RELATION DEPENDS_ON -> [Api.Settings.SettingsRouter]
# @RELATION DEPENDS_ON -> [ReportsRouter] # @RELATION DEPENDS_ON -> [Api.Reports.ReportsRouter]
# @RELATION DEPENDS_ON -> [LlmRoutes] # @RELATION DEPENDS_ON -> [Api.Llm.LlmRoutes]
# @SIDE_EFFECT Registers route group imports via __getattr__ # @SIDE_EFFECT Registers route group imports via __getattr__
# @DATA_CONTRACT Package -> RouterModule mapping # @DATA_CONTRACT Package -> RouterModule mapping
__all__ = [ __all__ = [
@@ -47,13 +47,13 @@ __all__ = [
"translate", "translate",
"validation_tasks", "validation_tasks",
] ]
# #endregion Route_Group_Contracts # #endregion Api.Init.RouteGroupContracts
# #region ApiRoutesGetAttr [C:3] [TYPE Function] # #region Api.Init.ApiRoutesGetAttr [C:3] [TYPE Function]
# @ingroup Api # @ingroup Api
# @BRIEF Lazily import route module by attribute name. # @BRIEF Lazily import route module by attribute name.
# @RELATION DEPENDS_ON -> [ApiRoutesModule] # @RELATION DEPENDS_ON -> [Api.Init.ApiRoutesModule]
# @PRE name is module candidate exposed in __all__. # @PRE name is module candidate exposed in __all__.
# @POST Returns imported submodule or raises AttributeError. # @POST Returns imported submodule or raises AttributeError.
def __getattr__(name): def __getattr__(name):
@@ -64,5 +64,5 @@ def __getattr__(name):
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
# #endregion ApiRoutesGetAttr # #endregion Api.Init.ApiRoutesGetAttr
# #endregion ApiRoutesModule # #endregion Api.Init.ApiRoutesModule

View File

@@ -1,4 +1,4 @@
# #region RoutesTestsConftest [TYPE Module] [C:1] [SEMANTICS test, fixture, mock, conftest] # #region Api.Conftest.RoutesTestsConftest [TYPE Module] [C:1] [SEMANTICS test, fixture, mock, conftest]
# @BRIEF Shared low-fidelity test doubles for API route test modules. # @BRIEF Shared low-fidelity test doubles for API route test modules.
# Set required env vars before any app imports # Set required env vars before any app imports
@@ -37,4 +37,4 @@ class FakeQuery:
return list(self._rows) return list(self._rows)
def count(self): def count(self):
return len(self._rows) return len(self._rows)
# #endregion RoutesTestsConftest # #endregion Api.Conftest.RoutesTestsConftest

View File

@@ -0,0 +1,366 @@
# backend/src/api/routes/__tests__/test_agent_lifecycle.py
# #region Test.Api.AgentLifecycle [C:3] [TYPE Module] [SEMANTICS test,agent,lifecycle,api,crud]
# @BRIEF Integration tests for agent lifecycle event API (write + read, user-scoped).
# @RELATION BINDS_TO -> [Api.AgentLifecycle]
# @RELATION BINDS_TO -> [Services.AgentLifecycleService]
# @RELATION BINDS_TO -> [Schemas.AgentLifecycle]
# @INVARIANT Non-admin users see only their own events.
# @INVARIANT Payload is validated against SAFE_PAYLOAD_KEYS — sensitive fields stripped.
import pytest
from unittest.mock import MagicMock
from fastapi import FastAPI
from fastapi.testclient import TestClient
from src.api.routes.agent_lifecycle import router
from src.dependencies import get_current_user
from src.models.auth import User, Role
# ── Helpers ──────────────────────────────────────────────────────────
def _mock_user(user_id: str = "user-1", is_admin: bool = False) -> User:
"""Factory for a mock User with configurable admin status."""
role = MagicMock(spec=Role)
role.name = "Admin" if is_admin else "Viewer"
role.is_admin = is_admin
role.permissions = []
user = MagicMock(spec=User)
user.id = user_id
user.username = user_id
user.roles = [role]
return user
def _build_app_and_db():
"""Build a FastAPI test app with a fresh SQLite temp DB and return (TestClient, session_factory)."""
import os
import tempfile
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import NullPool
from src.models.mapping import Base
from src.core.database import get_db
# Use a temp file to avoid SQLite :memory: per-connection isolation issues
tmp_dir = tempfile.mkdtemp(prefix="test_lifecycle_")
db_path = os.path.join(tmp_dir, "test.db")
db_url = f"sqlite:///{db_path}"
app = FastAPI()
app.include_router(router)
engine = create_engine(db_url, connect_args={"check_same_thread": False}, poolclass=NullPool)
# Import all agent models explicitly to register with Base.metadata
import src.models.agent # noqa: F401
Base.metadata.create_all(bind=engine)
TestingSessionLocal = sessionmaker(bind=engine, autoflush=False)
def _override_db():
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = _override_db
return TestClient(app), TestingSessionLocal
@pytest.fixture
def admin_client():
"""TestClient with admin user and real SQLite in-memory DB."""
tc, session_factory = _build_app_and_db()
mock_user = _mock_user("admin-1", is_admin=True)
tc.app.dependency_overrides[get_current_user] = lambda: mock_user
return tc, session_factory
@pytest.fixture
def user_client():
"""TestClient with regular (non-admin) user and real SQLite in-memory DB."""
tc, _ = _build_app_and_db()
mock_user = _mock_user("regular-user-1", is_admin=False)
tc.app.dependency_overrides[get_current_user] = lambda: mock_user
return tc
# #region Test.Api.TestLifecycleEventWriteSuccess [C:2] [TYPE Function] [SEMANTICS test,lifecycle,write]
# @BRIEF POST /api/agent/events creates an event and returns 201 with event ID.
def test_lifecycle_event_write_success(admin_client):
"""POST /api/agent/events with valid body returns 201 and event ID."""
tc, _ = admin_client
response = tc.post("/api/agent/events", json={
"trace_id": "trace-abc-123",
"conversation_id": "conv-456",
"event_type": "AGENT_REQUEST_STARTED",
"environment_id": "env-prod",
"tool_name": None,
"status": None,
"elapsed_ms": None,
"payload": {"action": "send_message", "attempt": 1},
"error_code": None,
})
assert response.status_code == 201, response.text
data = response.json()
assert data["written"] is True
assert len(data["id"]) > 0
# #endregion Test.Api.TestLifecycleEventWriteSuccess
# #region Test.Api.TestLifecycleEventWriteReducesPayload [C:2] [TYPE Function] [SEMANTICS test,lifecycle,write,payload]
# @BRIEF POST /api/agent/events strips sensitive fields from payload.
def test_lifecycle_event_write_reduces_payload(admin_client):
"""Sensitive fields in payload are stripped before storage."""
tc, session_factory = admin_client
response = tc.post("/api/agent/events", json={
"trace_id": "trace-sens-1",
"conversation_id": "conv-sens-1",
"event_type": "AGENT_TOOL_STARTED",
"payload": {
"action": "deploy",
"tool_input": "should-be-stripped",
"password": "secret123",
"token": "bearer-xxx",
},
})
assert response.status_code == 201, response.text
data = response.json()
# Verify payload in DB is reduced
from src.models.agent import AgentLifecycleEvent
session = session_factory()
try:
event = session.query(AgentLifecycleEvent).filter_by(id=data["id"]).first()
assert event is not None
assert event.payload is not None
assert "action" in event.payload
assert "tool_input" not in event.payload
assert "password" not in event.payload
assert "token" not in event.payload
finally:
session.close()
# #endregion Test.Api.TestLifecycleEventWriteReducesPayload
# #region Test.Api.TestLifecycleFailurePersistsSafeProviderDiagnostics [C:2] [TYPE Function] [SEMANTICS test,lifecycle,llm,diagnostics]
# @BRIEF LLM failures retain provider identity and failure classification while removing credentials.
def test_lifecycle_failure_persists_safe_provider_diagnostics(admin_client):
tc, session_factory = admin_client
response = tc.post("/api/agent/events", json={
"trace_id": "trace-llm-failure",
"conversation_id": "conv-llm-failure",
"event_type": "AGENT_LLM_FAILED",
"error_code": "LLM_PROVIDER_UNAVAILABLE",
"payload": {
"provider_id": "provider-1",
"provider_host": "lite.ai.rusal.com",
"model": "qwen-flash",
"failure_class": "dns",
"api_key": "must-be-stripped",
},
})
assert response.status_code == 201, response.text
from src.models.agent import AgentLifecycleEvent
session = session_factory()
try:
event = session.query(AgentLifecycleEvent).filter_by(id=response.json()["id"]).one()
assert event.error_code == "LLM_PROVIDER_UNAVAILABLE"
assert event.payload == {
"provider_id": "provider-1",
"provider_host": "lite.ai.rusal.com",
"model": "qwen-flash",
"failure_class": "dns",
}
finally:
session.close()
# #endregion Test.Api.TestLifecycleFailurePersistsSafeProviderDiagnostics
# #region Test.Api.TestLifecycleEventListUserScoped [C:2] [TYPE Function] [SEMANTICS test,lifecycle,list,scope]
# @BRIEF GET /api/agent/events — non-admin user sees only own events.
def test_lifecycle_event_list_user_scoped(user_client, admin_client):
"""Non-admin user can list their own events."""
tc, session_factory = admin_client
# Write event as admin (user_id = admin-1)
tc.post("/api/agent/events", json={
"trace_id": "trace-admin",
"conversation_id": "conv-admin",
"event_type": "AGENT_REQUEST_STARTED",
"payload": {"action": "admin_action"},
})
# Regular user lists events
u_tc = user_client
response = u_tc.get("/api/agent/events?page=1&page_size=50")
assert response.status_code == 200, response.text
data = response.json()
# Regular user should see 0 events (none belong to them)
assert data["total"] == 0
assert data["items"] == []
# #endregion Test.Api.TestLifecycleEventListUserScoped
# #region Test.Api.TestLifecycleEventListAdminCrossUser [C:2] [TYPE Function] [SEMANTICS test,lifecycle,list,admin]
# @BRIEF GET /api/agent/events — admin user can query by user_id.
def test_lifecycle_event_list_admin_cross_user(admin_client):
"""Admin user can query events by user_id filter."""
tc, session_factory = admin_client
# Write two events as different users
from src.services.agent_lifecycle_service import write_event
from src.schemas.agent_lifecycle import EventWriteRequest
from src.core.database import get_db
# Create events by directly using service+DB
session = session_factory()
try:
write_event(session, EventWriteRequest(
trace_id="trace-1", conversation_id="conv-1", event_type="AGENT_REQUEST_STARTED",
), authenticated_user_id="user-a")
write_event(session, EventWriteRequest(
trace_id="trace-2", conversation_id="conv-2", event_type="AGENT_LLM_STARTED",
), authenticated_user_id="user-b")
session.commit()
finally:
session.close()
# Admin lists all events
response = tc.get("/api/agent/events?page=1&page_size=50")
assert response.status_code == 200, response.text
data = response.json()
assert data["total"] == 2
# Admin filters by user_id
response = tc.get("/api/agent/events?user_id=user-a")
assert response.status_code == 200, response.text
data = response.json()
assert data["total"] == 1
assert data["items"][0]["trace_id"] == "trace-1"
# Admin filters by event_type
response = tc.get("/api/agent/events?event_type=AGENT_LLM_STARTED")
assert response.status_code == 200, response.text
data = response.json()
assert data["total"] == 1
assert data["items"][0]["trace_id"] == "trace-2"
# #endregion Test.Api.TestLifecycleEventListAdminCrossUser
# #region Test.Api.TestLifecycleEventNonAdminCannotQueryOthers [C:2] [TYPE Function] [SEMANTICS test,lifecycle,list,forbidden]
# @BRIEF Non-admin user receives 403 when trying to filter by user_id.
def test_lifecycle_event_non_admin_cannot_query_others(user_client):
"""Non-admin user gets 403 for user_id filter."""
tc = user_client
response = tc.get("/api/agent/events?user_id=other-user")
assert response.status_code == 403, response.text
assert "Only admin users" in response.text
# #endregion Test.Api.TestLifecycleEventNonAdminCannotQueryOthers
# #region Test.Api.TestLifecycleEventListPagination [C:2] [TYPE Function] [SEMANTICS test,lifecycle,list,pagination]
# @BRIEF GET /api/agent/events returns paginated results with has_next.
def test_lifecycle_event_list_pagination(admin_client):
"""Pagination: page_size respected, has_next computed correctly."""
tc, session_factory = admin_client
from src.services.agent_lifecycle_service import write_event
from src.schemas.agent_lifecycle import EventWriteRequest
session = session_factory()
try:
for i in range(5):
write_event(session, EventWriteRequest(
trace_id=f"trace-{i}", conversation_id=f"conv-{i}",
event_type="AGENT_REQUEST_STARTED",
), authenticated_user_id="admin-1")
session.commit()
finally:
session.close()
# Page 1 with page_size=3
response = tc.get("/api/agent/events?page=1&page_size=3")
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 3
assert data["total"] == 5
assert data["has_next"] is True
assert data["page"] == 1
# Page 2 with page_size=3
response = tc.get("/api/agent/events?page=2&page_size=3")
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 2
assert data["has_next"] is False
# #endregion Test.Api.TestLifecycleEventListPagination
# #region Test.Api.TestLifecycleEventWriteRequiresAuth [C:2] [TYPE Function] [SEMANTICS test,lifecycle,write,auth]
# @BRIEF POST /api/agent/events requires valid authentication token.
def test_lifecycle_event_write_requires_auth():
"""POST without auth returns 401/403 (depends on oauth2_scheme)."""
# Test without overriding get_current_user — will fail at oauth2_scheme
app = FastAPI()
app.include_router(router)
tc = TestClient(app)
response = tc.post("/api/agent/events", json={
"trace_id": "trace-noauth",
"conversation_id": "conv-noauth",
"event_type": "AGENT_REQUEST_STARTED",
})
assert response.status_code == 401, response.text
# #endregion Test.Api.TestLifecycleEventWriteRequiresAuth
# #region Test.Api.TestLifecycleEventListFilters [C:2] [TYPE Function] [SEMANTICS test,lifecycle,list,filters]
# @BRIEF GET /api/agent/events supports conversation_id, status, tool_name filters.
def test_lifecycle_event_list_filters(admin_client):
"""All query parameter filters work correctly."""
tc, session_factory = admin_client
from src.services.agent_lifecycle_service import write_event
from src.schemas.agent_lifecycle import EventWriteRequest
session = session_factory()
try:
write_event(session, EventWriteRequest(
trace_id="t1", conversation_id="conv-a", event_type="AGENT_TOOL_STARTED",
tool_name="deploy", status="running",
), authenticated_user_id="admin-1")
write_event(session, EventWriteRequest(
trace_id="t2", conversation_id="conv-b", event_type="AGENT_TOOL_COMPLETED",
tool_name="backup", status="success",
), authenticated_user_id="admin-1")
write_event(session, EventWriteRequest(
trace_id="t3", conversation_id="conv-b", event_type="AGENT_TOOL_FAILED",
tool_name="backup", status="failed", error_code="TIMEOUT",
), authenticated_user_id="admin-1")
session.commit()
finally:
session.close()
# Filter by conversation_id
resp = tc.get("/api/agent/events?conversation_id=conv-a")
assert resp.status_code == 200
assert resp.json()["total"] == 1
# Filter by status
resp = tc.get("/api/agent/events?status=success")
assert resp.status_code == 200
assert resp.json()["total"] == 1
# Filter by tool_name
resp = tc.get("/api/agent/events?tool_name=deploy")
assert resp.status_code == 200
assert resp.json()["total"] == 1
# #endregion Test.Api.TestLifecycleEventListFilters
# #endregion Test.Api.AgentLifecycle

View File

@@ -1,9 +1,9 @@
import os import os
os.environ["ENCRYPTION_KEY"] = "OnrCzomBWbIjTf7Y-fnhL2adlU55bHZQjp8zX5zBC5w=" os.environ["ENCRYPTION_KEY"] = "OnrCzomBWbIjTf7Y-fnhL2adlU55bHZQjp8zX5zBC5w="
# #region AssistantApiTests [TYPE Module] [C:3] [SEMANTICS tests, assistant, api] # #region Test.Tests.AssistantApiTests [TYPE Module] [C:3] [SEMANTICS tests, assistant, api]
# @BRIEF Validate assistant API endpoint logic via direct async handler invocation. # @BRIEF Validate assistant API endpoint logic via direct async handler invocation.
# @RELATION DEPENDS_ON -> [AssistantApi] # @RELATION DEPENDS_ON -> [Api.Init.AssistantApi]
# @INVARIANT Every test clears assistant in-memory state before execution. # @INVARIANT Every test clears assistant in-memory state before execution.
import asyncio import asyncio
from datetime import UTC, datetime from datetime import UTC, datetime
@@ -15,13 +15,13 @@ from src.models.assistant import AssistantMessageRecord
from src.schemas.auth import User from src.schemas.auth import User
# #region _run_async [TYPE Function] # #region Test.Tests.RunAsync [TYPE Function]
# @RELATION BINDS_TO -> [AssistantApiTests] # @RELATION BINDS_TO -> [Test.Tests.AssistantApiTests]
def _run_async(coro): def _run_async(coro):
return asyncio.run(coro) return asyncio.run(coro)
# #endregion _run_async # #endregion Test.Tests.RunAsync
# #region _FakeTask [TYPE Class] [C:1] # #region Test.Tests.FakeTask [TYPE Class] [C:1]
# @RELATION BINDS_TO -> [AssistantApiTests] # @RELATION BINDS_TO -> [Test.Tests.AssistantApiTests]
# @BRIEF Lightweight task model stub used as return value from _FakeTaskManager.create_task in assistant route tests. # @BRIEF Lightweight task model stub used as return value from _FakeTaskManager.create_task in assistant route tests.
# @INVARIANT status is a bare string not a TaskStatus enum; callers must not depend on enum semantics. # @INVARIANT status is a bare string not a TaskStatus enum; callers must not depend on enum semantics.
class _FakeTask: class _FakeTask:
@@ -42,10 +42,10 @@ class _FakeTask:
self.user_id = user_id self.user_id = user_id
self.started_at = datetime.now(UTC) self.started_at = datetime.now(UTC)
self.finished_at = datetime.now(UTC) self.finished_at = datetime.now(UTC)
# #endregion _FakeTask # #endregion Test.Tests.FakeTask
# @DEBT: Divergent _FakeTaskManager definition. Canonical version should be in conftest.py. Authz variant is missing get_all_tasks(). # @DEBT: Divergent _FakeTaskManager definition. Canonical version should be in conftest.py. Authz variant is missing get_all_tasks().
# #region _FakeTaskManager [TYPE Class] [C:2] # #region Test.Tests.FakeTaskManager [TYPE Class] [C:2]
# @RELATION BINDS_TO -> [AssistantApiTests] # @RELATION BINDS_TO -> [Test.Tests.AssistantApiTests]
# @BRIEF In-memory task manager stub that records created tasks for route-level assertions. # @BRIEF In-memory task manager stub that records created tasks for route-level assertions.
# @INVARIANT create_task stores tasks retrievable by get_task/get_tasks without external side effects. # @INVARIANT create_task stores tasks retrievable by get_task/get_tasks without external side effects.
class _FakeTaskManager: class _FakeTaskManager:
@@ -70,9 +70,9 @@ class _FakeTaskManager:
] ]
def get_all_tasks(self): def get_all_tasks(self):
return list(self.tasks.values()) return list(self.tasks.values())
# #endregion _FakeTaskManager # #endregion Test.Tests.FakeTaskManager
# #region _FakeConfigManager [TYPE Class] [C:2] # #region Test.Tests.FakeConfigManager [TYPE Class] [C:2]
# @RELATION BINDS_TO -> [AssistantApiTests] # @RELATION BINDS_TO -> [Test.Tests.AssistantApiTests]
# @BRIEF Deterministic config stub providing hardcoded dev/prod environments and minimal settings shape for assistant route tests. # @BRIEF Deterministic config stub providing hardcoded dev/prod environments and minimal settings shape for assistant route tests.
# @INVARIANT get_config() returns anonymous inner classes, not real GlobalSettings; only default_environment_id and llm fields are safe to access. # @INVARIANT get_config() returns anonymous inner classes, not real GlobalSettings; only default_environment_id and llm fields are safe to access.
class _FakeConfigManager: class _FakeConfigManager:
@@ -90,9 +90,9 @@ class _FakeConfigManager:
settings = _Settings() settings = _Settings()
environments = [] environments = []
return _Config() return _Config()
# #endregion _FakeConfigManager # #endregion Test.Tests.FakeConfigManager
# #region _admin_user [TYPE Function] [C:1] # #region Test.Tests.AdminUser [TYPE Function] [C:1]
# @RELATION BINDS_TO -> [AssistantApiTests] # @RELATION BINDS_TO -> [Test.Tests.AssistantApiTests]
# @BRIEF Build admin principal with spec=User for assistant route authorization tests. # @BRIEF Build admin principal with spec=User for assistant route authorization tests.
def _admin_user(): def _admin_user():
user = MagicMock(spec=User) user = MagicMock(spec=User)
@@ -102,9 +102,9 @@ def _admin_user():
role.name = "Admin" role.name = "Admin"
user.roles = [role] user.roles = [role]
return user return user
# #endregion _admin_user # #endregion Test.Tests.AdminUser
# #region _limited_user [TYPE Function] [C:1] # #region Test.Tests.LimitedUser [TYPE Function] [C:1]
# @RELATION BINDS_TO -> [AssistantApiTests] # @RELATION BINDS_TO -> [Test.Tests.AssistantApiTests]
# @BRIEF Build limited user principal with empty roles for assistant route denial tests. # @BRIEF Build limited user principal with empty roles for assistant route denial tests.
def _limited_user(): def _limited_user():
user = MagicMock(spec=User) user = MagicMock(spec=User)
@@ -112,9 +112,9 @@ def _limited_user():
user.username = "limited" user.username = "limited"
user.roles = [] user.roles = []
return user return user
# #endregion _limited_user # #endregion Test.Tests.LimitedUser
# #region _FakeQuery [TYPE Class] [C:2] # #region Test.Tests.FakeQuery [TYPE Class] [C:2]
# @RELATION BINDS_TO -> [AssistantApiTests] # @RELATION BINDS_TO -> [Test.Tests.AssistantApiTests]
# @BRIEF Chainable SQLAlchemy-like query stub returning fixed item lists for assistant message persistence paths. # @BRIEF Chainable SQLAlchemy-like query stub returning fixed item lists for assistant message persistence paths.
# @INVARIANT filter() ignores all predicate arguments and returns self; no predicate-based filtering is emulated. # @INVARIANT filter() ignores all predicate arguments and returns self; no predicate-based filtering is emulated.
class _FakeQuery: class _FakeQuery:
@@ -141,9 +141,9 @@ class _FakeQuery:
return self.items return self.items
def count(self): def count(self):
return len(self.items) return len(self.items)
# #endregion _FakeQuery # #endregion Test.Tests.FakeQuery
# #region _FakeDb [TYPE Class] [C:2] # #region Test.Tests.FakeDb [TYPE Class] [C:2]
# @RELATION BINDS_TO -> [AssistantApiTests] # @RELATION BINDS_TO -> [Test.Tests.AssistantApiTests]
# @BRIEF Explicit in-memory DB session double limited to assistant message persistence paths. # @BRIEF Explicit in-memory DB session double limited to assistant message persistence paths.
# @INVARIANT query() always returns _FakeQuery with intentionally non-evaluated predicates; add/merge stay deterministic and never emulate unrelated SQLAlchemy behavior. # @INVARIANT query() always returns _FakeQuery with intentionally non-evaluated predicates; add/merge stay deterministic and never emulate unrelated SQLAlchemy behavior.
class _FakeDb: class _FakeDb:
@@ -163,24 +163,24 @@ class _FakeDb:
return obj return obj
def refresh(self, obj): def refresh(self, obj):
pass pass
# #endregion _FakeDb # #endregion Test.Tests.FakeDb
# #region _clear_assistant_state [TYPE Function] # #region Test.Tests.ClearAssistantState [TYPE Function]
# @RELATION BINDS_TO -> [AssistantApiTests] # @RELATION BINDS_TO -> [Test.Tests.AssistantApiTests]
def _clear_assistant_state(): def _clear_assistant_state():
assistant_routes.CONVERSATIONS.clear() assistant_routes.CONVERSATIONS.clear()
assistant_routes.USER_ACTIVE_CONVERSATION.clear() assistant_routes.USER_ACTIVE_CONVERSATION.clear()
assistant_routes.CONFIRMATIONS.clear() assistant_routes.CONFIRMATIONS.clear()
assistant_routes.ASSISTANT_AUDIT.clear() assistant_routes.ASSISTANT_AUDIT.clear()
# #endregion _clear_assistant_state # #endregion Test.Tests.ClearAssistantState
# #region _await_none [TYPE Function] [C:1] # #region Test.Tests.AwaitNone [TYPE Function] [C:1]
# @RELATION BINDS_TO -> [AssistantApiTests] # @RELATION BINDS_TO -> [Test.Tests.AssistantApiTests]
# @BRIEF Async helper returning None for planner fallback tests. # @BRIEF Async helper returning None for planner fallback tests.
async def _await_none(*args, **kwargs): async def _await_none(*args, **kwargs):
return None return None
# #endregion _await_none # #endregion Test.Tests.AwaitNone
# #region test_unknown_command_returns_needs_clarification [TYPE Function] # #region Test.Tests.TestUnknownCommandReturnsNeedsClarification [TYPE Function]
# @RELATION BINDS_TO -> [AssistantApiTests] # @RELATION BINDS_TO -> [Test.Tests.AssistantApiTests]
# @BRIEF Unknown command should return clarification state and unknown intent. # @BRIEF Unknown command should return clarification state and unknown intent.
def test_unknown_command_returns_needs_clarification(monkeypatch): def test_unknown_command_returns_needs_clarification(monkeypatch):
_clear_assistant_state() _clear_assistant_state()
@@ -198,9 +198,9 @@ def test_unknown_command_returns_needs_clarification(monkeypatch):
) )
assert resp.state == "needs_clarification" assert resp.state == "needs_clarification"
assert "уточните" in resp.text.lower() or "неоднозначна" in resp.text.lower() assert "уточните" in resp.text.lower() or "неоднозначна" in resp.text.lower()
# #endregion test_unknown_command_returns_needs_clarification # #endregion Test.Tests.TestUnknownCommandReturnsNeedsClarification
# #region test_capabilities_question_returns_successful_help [TYPE Function] # #region Test.Tests.TestCapabilitiesQuestionReturnsSuccessfulHelp [TYPE Function]
# @RELATION BINDS_TO -> [AssistantApiTests] # @RELATION BINDS_TO -> [Test.Tests.AssistantApiTests]
# @BRIEF Capability query should return deterministic help response. # @BRIEF Capability query should return deterministic help response.
def test_capabilities_question_returns_successful_help(monkeypatch): def test_capabilities_question_returns_successful_help(monkeypatch):
_clear_assistant_state() _clear_assistant_state()
@@ -228,5 +228,5 @@ def test_capabilities_question_returns_successful_help(monkeypatch):
) )
assert resp.state == "success" assert resp.state == "success"
assert "я могу сделать" in resp.text.lower() assert "я могу сделать" in resp.text.lower()
# #endregion test_capabilities_question_returns_successful_help # #endregion Test.Tests.TestCapabilitiesQuestionReturnsSuccessfulHelp
# #endregion AssistantApiTests # #endregion Test.Tests.AssistantApiTests

View File

@@ -1,10 +1,10 @@
import os import os
os.environ["ENCRYPTION_KEY"] = "OnrCzomBWbIjTf7Y-fnhL2adlU55bHZQjp8zX5zBC5w=" os.environ["ENCRYPTION_KEY"] = "OnrCzomBWbIjTf7Y-fnhL2adlU55bHZQjp8zX5zBC5w="
# #region TestAssistantAuthz [TYPE Module] [C:3] [SEMANTICS tests, assistant, authz, confirmation, rbac] # #region Test.Tests.TestAssistantAuthz [TYPE Module] [C:3] [SEMANTICS tests, assistant, authz, confirmation, rbac]
# @BRIEF Verify assistant confirmation ownership, expiration, and deny behavior for restricted users. # @BRIEF Verify assistant confirmation ownership, expiration, and deny behavior for restricted users.
# @LAYER API # @LAYER API
# @RELATION DEPENDS_ON -> AssistantApi # @RELATION DEPENDS_ON -> Api.Init.AssistantApi
# @INVARIANT Security-sensitive flows fail closed for unauthorized actors. # @INVARIANT Security-sensitive flows fail closed for unauthorized actors.
import asyncio import asyncio
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
@@ -30,16 +30,16 @@ from src.models.assistant import (
) )
# #region _run_async [TYPE Function] [C:1] # #region Test.Tests.RunAsync [TYPE Function] [C:1]
# @RELATION BINDS_TO -> [TestAssistantAuthz] # @RELATION BINDS_TO -> [Test.Tests.TestAssistantAuthz]
# @BRIEF Execute async endpoint handler in synchronous test context. # @BRIEF Execute async endpoint handler in synchronous test context.
# @PRE coroutine is awaitable endpoint invocation. # @PRE coroutine is awaitable endpoint invocation.
# @POST Returns coroutine result or raises propagated exception. # @POST Returns coroutine result or raises propagated exception.
def _run_async(coroutine): def _run_async(coroutine):
return asyncio.run(coroutine) return asyncio.run(coroutine)
# #endregion _run_async # #endregion Test.Tests.RunAsync
# #region _FakeTask [TYPE Class] [C:1] # #region Test.Tests.FakeTask [TYPE Class] [C:1]
# @RELATION BINDS_TO -> [TestAssistantAuthz] # @RELATION BINDS_TO -> [Test.Tests.TestAssistantAuthz]
# @BRIEF Lightweight task model used for assistant authz tests. # @BRIEF Lightweight task model used for assistant authz tests.
# @PRE task_id is non-empty string. # @PRE task_id is non-empty string.
# @POST Returns task with provided id, status, and user_id accessible as attributes. # @POST Returns task with provided id, status, and user_id accessible as attributes.
@@ -48,10 +48,10 @@ class _FakeTask:
self.id = task_id self.id = task_id
self.status = status self.status = status
self.user_id = user_id self.user_id = user_id
# #endregion _FakeTask # #endregion Test.Tests.FakeTask
# @DEBT: Divergent _FakeTaskManager definition. Canonical version should be in conftest.py. Authz variant is missing get_all_tasks(). # @DEBT: Divergent _FakeTaskManager definition. Canonical version should be in conftest.py. Authz variant is missing get_all_tasks().
# #region _FakeTaskManager [TYPE Class] [C:2] # #region Test.Tests.FakeTaskManager [TYPE Class] [C:2]
# @RELATION BINDS_TO -> [TestAssistantAuthz] # @RELATION BINDS_TO -> [Test.Tests.TestAssistantAuthz]
# @BRIEF In-memory task manager double that records assistant-created tasks deterministically. # @BRIEF In-memory task manager double that records assistant-created tasks deterministically.
# @INVARIANT Only create_task/get_task/get_tasks behavior used by assistant authz routes is emulated. # @INVARIANT Only create_task/get_task/get_tasks behavior used by assistant authz routes is emulated.
class _FakeTaskManager: class _FakeTaskManager:
@@ -73,10 +73,10 @@ class _FakeTaskManager:
raise NotImplementedError( raise NotImplementedError(
"get_all_tasks not implemented in authz FakeTaskManager" "get_all_tasks not implemented in authz FakeTaskManager"
) )
# #endregion _FakeTaskManager # #endregion Test.Tests.FakeTaskManager
# @CONTRACT: Partial ConfigManager stub for authz tests. Missing: get_config(). # @CONTRACT: Partial ConfigManager stub for authz tests. Missing: get_config().
# #region _FakeConfigManager [TYPE Class] [C:1] # #region Test.Tests.FakeConfigManager [TYPE Class] [C:1]
# @RELATION BINDS_TO -> [TestAssistantAuthz] # @RELATION BINDS_TO -> [Test.Tests.TestAssistantAuthz]
# @BRIEF Provide deterministic environment aliases required by intent parsing. # @BRIEF Provide deterministic environment aliases required by intent parsing.
# @PRE No external config or DB state is required. # @PRE No external config or DB state is required.
# @POST get_environments() returns two deterministic SimpleNamespace stubs with id/name. # @POST get_environments() returns two deterministic SimpleNamespace stubs with id/name.
@@ -91,36 +91,36 @@ class _FakeConfigManager:
raise NotImplementedError( raise NotImplementedError(
"get_config not implemented in authz fake — add if route under test requires it" "get_config not implemented in authz fake — add if route under test requires it"
) )
# #endregion _FakeConfigManager # #endregion Test.Tests.FakeConfigManager
# #region _admin_user [TYPE Function] [C:1] # #region Test.Tests.AdminUser [TYPE Function] [C:1]
# @RELATION BINDS_TO -> [TestAssistantAuthz] # @RELATION BINDS_TO -> [Test.Tests.TestAssistantAuthz]
# @BRIEF Build admin principal fixture. # @BRIEF Build admin principal fixture.
# @PRE Test requires privileged principal for risky operations. # @PRE Test requires privileged principal for risky operations.
# @POST Returns admin-like user stub with Admin role. # @POST Returns admin-like user stub with Admin role.
def _admin_user(): def _admin_user():
role = SimpleNamespace(name="Admin", permissions=[]) role = SimpleNamespace(name="Admin", permissions=[])
return SimpleNamespace(id="u-admin", username="admin", roles=[role]) return SimpleNamespace(id="u-admin", username="admin", roles=[role])
# #endregion _admin_user # #endregion Test.Tests.AdminUser
# #region _other_admin_user [TYPE Function] [C:1] # #region Test.Tests.OtherAdminUser [TYPE Function] [C:1]
# @RELATION BINDS_TO -> [TestAssistantAuthz] # @RELATION BINDS_TO -> [Test.Tests.TestAssistantAuthz]
# @BRIEF Build second admin principal fixture for ownership tests. # @BRIEF Build second admin principal fixture for ownership tests.
# @PRE Ownership mismatch scenario needs distinct authenticated actor. # @PRE Ownership mismatch scenario needs distinct authenticated actor.
# @POST Returns alternate admin-like user stub. # @POST Returns alternate admin-like user stub.
def _other_admin_user(): def _other_admin_user():
role = SimpleNamespace(name="Admin", permissions=[]) role = SimpleNamespace(name="Admin", permissions=[])
return SimpleNamespace(id="u-admin-2", username="admin2", roles=[role]) return SimpleNamespace(id="u-admin-2", username="admin2", roles=[role])
# #endregion _other_admin_user # #endregion Test.Tests.OtherAdminUser
# #region _limited_user [TYPE Function] [C:1] # #region Test.Tests.LimitedUser [TYPE Function] [C:1]
# @RELATION BINDS_TO -> [TestAssistantAuthz] # @RELATION BINDS_TO -> [Test.Tests.TestAssistantAuthz]
# @BRIEF Build limited principal without required assistant execution privileges. # @BRIEF Build limited principal without required assistant execution privileges.
# @PRE Permission denial scenario needs non-admin actor. # @PRE Permission denial scenario needs non-admin actor.
# @POST Returns restricted user stub. # @POST Returns restricted user stub.
def _limited_user(): def _limited_user():
role = SimpleNamespace(name="Operator", permissions=[]) role = SimpleNamespace(name="Operator", permissions=[])
return SimpleNamespace(id="u-limited", username="limited", roles=[role]) return SimpleNamespace(id="u-limited", username="limited", roles=[role])
# #endregion _limited_user # #endregion Test.Tests.LimitedUser
# #region _FakeQuery [TYPE Class] [C:1] # #region Test.Tests.FakeQuery [TYPE Class] [C:1]
# @RELATION BINDS_TO -> [TestAssistantAuthz] # @RELATION BINDS_TO -> [Test.Tests.TestAssistantAuthz]
# @BRIEF Minimal chainable query object for fake DB interactions. # @BRIEF Minimal chainable query object for fake DB interactions.
# @INVARIANT filter() deliberately discards predicate args and returns self; tests must not assume predicate evaluation. # @INVARIANT filter() deliberately discards predicate args and returns self; tests must not assume predicate evaluation.
class _FakeQuery: class _FakeQuery:
@@ -143,9 +143,9 @@ class _FakeQuery:
return self return self
def count(self): def count(self):
return len(self._rows) return len(self._rows)
# #endregion _FakeQuery # #endregion Test.Tests.FakeQuery
# #region _FakeDb [TYPE Class] [C:2] # #region Test.Tests.FakeDb [TYPE Class] [C:2]
# @RELATION BINDS_TO -> [TestAssistantAuthz] # @RELATION BINDS_TO -> [Test.Tests.TestAssistantAuthz]
# @BRIEF In-memory DB session double constrained to assistant message/confirmation/audit persistence paths. # @BRIEF In-memory DB session double constrained to assistant message/confirmation/audit persistence paths.
# @INVARIANT query/add/merge are intentionally narrow and must not claim full SQLAlchemy Session semantics. # @INVARIANT query/add/merge are intentionally narrow and must not claim full SQLAlchemy Session semantics.
class _FakeDb: class _FakeDb:
@@ -183,9 +183,9 @@ class _FakeDb:
return None return None
def rollback(self): def rollback(self):
return None return None
# #endregion _FakeDb # #endregion Test.Tests.FakeDb
# #region _clear_assistant_state [TYPE Function] [C:1] # #region Test.Tests.ClearAssistantState [TYPE Function] [C:1]
# @RELATION BINDS_TO -> [TestAssistantAuthz] # @RELATION BINDS_TO -> [Test.Tests.TestAssistantAuthz]
# @BRIEF Reset assistant process-local state between test cases. # @BRIEF Reset assistant process-local state between test cases.
# @PRE Assistant globals may contain state from prior tests. # @PRE Assistant globals may contain state from prior tests.
# @POST Assistant in-memory state dictionaries are cleared. # @POST Assistant in-memory state dictionaries are cleared.
@@ -194,9 +194,9 @@ def _clear_assistant_state():
assistant_module.USER_ACTIVE_CONVERSATION.clear() assistant_module.USER_ACTIVE_CONVERSATION.clear()
assistant_module.CONFIRMATIONS.clear() assistant_module.CONFIRMATIONS.clear()
assistant_module.ASSISTANT_AUDIT.clear() assistant_module.ASSISTANT_AUDIT.clear()
# #endregion _clear_assistant_state # #endregion Test.Tests.ClearAssistantState
# #region test_confirmation_owner_mismatch_returns_403 [TYPE Function] # #region Test.Tests.TestConfirmationOwnerMismatchReturns403 [TYPE Function]
# @RELATION BINDS_TO -> [TestAssistantAuthz] # @RELATION BINDS_TO -> [Test.Tests.TestAssistantAuthz]
# @BRIEF Confirm endpoint should reject requests from user that does not own the confirmation token. # @BRIEF Confirm endpoint should reject requests from user that does not own the confirmation token.
# @PRE Confirmation token is created by first admin actor. # @PRE Confirmation token is created by first admin actor.
# @POST Second actor receives 403 on confirm operation. # @POST Second actor receives 403 on confirm operation.
@@ -227,9 +227,9 @@ def test_confirmation_owner_mismatch_returns_403():
) )
) )
assert exc.value.status_code == 403 assert exc.value.status_code == 403
# #endregion test_confirmation_owner_mismatch_returns_403 # #endregion Test.Tests.TestConfirmationOwnerMismatchReturns403
# #region test_expired_confirmation_cannot_be_confirmed [TYPE Function] # #region Test.Tests.TestExpiredConfirmationCannotBeConfirmed [TYPE Function]
# @RELATION BINDS_TO -> [TestAssistantAuthz] # @RELATION BINDS_TO -> [Test.Tests.TestAssistantAuthz]
# @BRIEF Expired confirmation token should be rejected and not create task. # @BRIEF Expired confirmation token should be rejected and not create task.
# @PRE Confirmation token exists and is manually expired before confirm request. # @PRE Confirmation token exists and is manually expired before confirm request.
# @POST Confirm endpoint raises 400 and no task is created. # @POST Confirm endpoint raises 400 and no task is created.
@@ -263,9 +263,9 @@ def test_expired_confirmation_cannot_be_confirmed():
) )
assert exc.value.status_code == 400 assert exc.value.status_code == 400
assert task_manager.get_tasks(limit=10, offset=0) == [] assert task_manager.get_tasks(limit=10, offset=0) == []
# #endregion test_expired_confirmation_cannot_be_confirmed # #endregion Test.Tests.TestExpiredConfirmationCannotBeConfirmed
# #region test_limited_user_cannot_launch_restricted_operation [TYPE Function] # #region Test.Tests.TestLimitedUserCannotLaunchRestrictedOperation [TYPE Function]
# @RELATION BINDS_TO -> [TestAssistantAuthz] # @RELATION BINDS_TO -> [Test.Tests.TestAssistantAuthz]
# @BRIEF Limited user should receive denied state for privileged operation. # @BRIEF Limited user should receive denied state for privileged operation.
# @PRE Restricted user attempts dangerous deploy command. # @PRE Restricted user attempts dangerous deploy command.
# @POST Assistant returns denied state and does not execute operation. # @POST Assistant returns denied state and does not execute operation.
@@ -283,5 +283,5 @@ def test_limited_user_cannot_launch_restricted_operation():
) )
) )
assert response.state == "denied" assert response.state == "denied"
# #endregion test_limited_user_cannot_launch_restricted_operation # #endregion Test.Tests.TestLimitedUserCannotLaunchRestrictedOperation
# #endregion TestAssistantAuthz # #endregion Test.Tests.TestAssistantAuthz

View File

@@ -1,5 +1,5 @@
# #region TestCleanReleaseApi [TYPE Module] [C:3] [SEMANTICS tests, api, clean-release, checks, reports] # #region Test.Tests.TestCleanReleaseApi [TYPE Module] [C:3] [SEMANTICS tests, api, clean-release, checks, reports]
# @RELATION BINDS_TO -> SrcRoot # @RELATION BINDS_TO -> Init.SrcRoot
# @BRIEF Contract tests for clean release checks and reports endpoints. # @BRIEF Contract tests for clean release checks and reports endpoints.
# @LAYER Domain # @LAYER Domain
# @INVARIANT API returns deterministic payload shapes for checks and reports. # @INVARIANT API returns deterministic payload shapes for checks and reports.
@@ -22,8 +22,8 @@ from src.models.clean_release import (
from src.services.clean_release.repository import CleanReleaseRepository from src.services.clean_release.repository import CleanReleaseRepository
# #region _repo_with_seed_data [TYPE Function] # #region Test.Tests.RepoWithSeedData [TYPE Function]
# @RELATION BINDS_TO -> TestCleanReleaseApi # @RELATION BINDS_TO -> Test.Tests.TestCleanReleaseApi
def _repo_with_seed_data() -> CleanReleaseRepository: def _repo_with_seed_data() -> CleanReleaseRepository:
repo = CleanReleaseRepository() repo = CleanReleaseRepository()
repo.save_candidate( repo.save_candidate(
@@ -69,9 +69,9 @@ def _repo_with_seed_data() -> CleanReleaseRepository:
) )
) )
return repo return repo
# #endregion _repo_with_seed_data # #endregion Test.Tests.RepoWithSeedData
# #region test_start_check_and_get_status_contract [TYPE Function] # #region Test.Tests.TestStartCheckAndGetStatusContract [TYPE Function]
# @RELATION BINDS_TO -> TestCleanReleaseApi # @RELATION BINDS_TO -> Test.Tests.TestCleanReleaseApi
# @BRIEF Validate checks start endpoint returns expected identifiers and status endpoint reflects the same run. # @BRIEF Validate checks start endpoint returns expected identifiers and status endpoint reflects the same run.
def test_start_check_and_get_status_contract(): def test_start_check_and_get_status_contract():
repo = _repo_with_seed_data() repo = _repo_with_seed_data()
@@ -101,9 +101,9 @@ def test_start_check_and_get_status_contract():
assert "checks" in status_payload assert "checks" in status_payload
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()
# #endregion test_start_check_and_get_status_contract # #endregion Test.Tests.TestStartCheckAndGetStatusContract
# #region test_get_report_not_found_returns_404 [TYPE Function] # #region Test.Tests.TestGetReportNotFoundReturns404 [TYPE Function]
# @RELATION BINDS_TO -> TestCleanReleaseApi # @RELATION BINDS_TO -> Test.Tests.TestCleanReleaseApi
# @BRIEF Validate reports endpoint returns 404 for an unknown report identifier. # @BRIEF Validate reports endpoint returns 404 for an unknown report identifier.
def test_get_report_not_found_returns_404(): def test_get_report_not_found_returns_404():
repo = _repo_with_seed_data() repo = _repo_with_seed_data()
@@ -114,9 +114,9 @@ def test_get_report_not_found_returns_404():
assert resp.status_code == 404 assert resp.status_code == 404
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()
# #endregion test_get_report_not_found_returns_404 # #endregion Test.Tests.TestGetReportNotFoundReturns404
# #region test_get_report_success [TYPE Function] # #region Test.Tests.TestGetReportSuccess [TYPE Function]
# @RELATION BINDS_TO -> TestCleanReleaseApi # @RELATION BINDS_TO -> Test.Tests.TestCleanReleaseApi
# @BRIEF Validate reports endpoint returns persisted report payload for an existing report identifier. # @BRIEF Validate reports endpoint returns persisted report payload for an existing report identifier.
def test_get_report_success(): def test_get_report_success():
repo = _repo_with_seed_data() repo = _repo_with_seed_data()
@@ -140,9 +140,9 @@ def test_get_report_success():
assert resp.json()["report_id"] == "rep-1" assert resp.json()["report_id"] == "rep-1"
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()
# #endregion test_get_report_success # #endregion Test.Tests.TestGetReportSuccess
# #region test_prepare_candidate_api_success [TYPE Function] # #region Test.Tests.TestPrepareCandidateApiSuccess [TYPE Function]
# @RELATION BINDS_TO -> TestCleanReleaseApi # @RELATION BINDS_TO -> Test.Tests.TestCleanReleaseApi
# @BRIEF Validate candidate preparation endpoint returns prepared status and manifest identifier on valid input. # @BRIEF Validate candidate preparation endpoint returns prepared status and manifest identifier on valid input.
def test_prepare_candidate_api_success(): def test_prepare_candidate_api_success():
repo = _repo_with_seed_data() repo = _repo_with_seed_data()
@@ -166,5 +166,5 @@ def test_prepare_candidate_api_success():
assert "manifest_id" in data assert "manifest_id" in data
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()
# #endregion test_prepare_candidate_api_success # #endregion Test.Tests.TestPrepareCandidateApiSuccess
# #endregion TestCleanReleaseApi # #endregion Test.Tests.TestCleanReleaseApi

View File

@@ -1,5 +1,5 @@
# #region TestCleanReleaseLegacyCompat [TYPE Module] [C:3] [SEMANTICS test, clean-release, legacy, compat] # #region Test.Tests.TestCleanReleaseLegacyCompat [TYPE Module] [C:3] [SEMANTICS test, clean-release, legacy, compat]
# @RELATION BINDS_TO -> SrcRoot # @RELATION BINDS_TO -> Init.SrcRoot
# @BRIEF Compatibility tests for legacy clean-release API paths retained during v2 migration. # @BRIEF Compatibility tests for legacy clean-release API paths retained during v2 migration.
# @LAYER Tests # @LAYER Tests
from __future__ import annotations from __future__ import annotations
@@ -27,8 +27,8 @@ from src.models.clean_release import (
from src.services.clean_release.repository import CleanReleaseRepository from src.services.clean_release.repository import CleanReleaseRepository
# #region _seed_legacy_repo [TYPE Function] # #region Test.Tests.SeedLegacyRepo [TYPE Function]
# @RELATION BINDS_TO -> TestCleanReleaseLegacyCompat # @RELATION BINDS_TO -> Test.Tests.TestCleanReleaseLegacyCompat
# @BRIEF Seed in-memory repository with minimum trusted data for legacy endpoint contracts. # @BRIEF Seed in-memory repository with minimum trusted data for legacy endpoint contracts.
# @PRE Repository is empty. # @PRE Repository is empty.
# @POST Candidate, policy, registry and manifest are available for legacy checks flow. # @POST Candidate, policy, registry and manifest are available for legacy checks flow.
@@ -97,9 +97,9 @@ def _seed_legacy_repo() -> CleanReleaseRepository:
) )
) )
return repo return repo
# #endregion _seed_legacy_repo # #endregion Test.Tests.SeedLegacyRepo
# #region test_legacy_prepare_endpoint_still_available [TYPE Function] # #region Test.Tests.TestLegacyPrepareEndpointStillAvailable [TYPE Function]
# @RELATION BINDS_TO -> TestCleanReleaseLegacyCompat # @RELATION BINDS_TO -> Test.Tests.TestCleanReleaseLegacyCompat
# @BRIEF Verify legacy prepare endpoint remains reachable and returns a status payload. # @BRIEF Verify legacy prepare endpoint remains reachable and returns a status payload.
def test_legacy_prepare_endpoint_still_available() -> None: def test_legacy_prepare_endpoint_still_available() -> None:
repo = _seed_legacy_repo() repo = _seed_legacy_repo()
@@ -123,9 +123,9 @@ def test_legacy_prepare_endpoint_still_available() -> None:
assert payload["status"] in {"prepared", "blocked", "PREPARED", "BLOCKED"} assert payload["status"] in {"prepared", "blocked", "PREPARED", "BLOCKED"}
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()
# #endregion test_legacy_prepare_endpoint_still_available # #endregion Test.Tests.TestLegacyPrepareEndpointStillAvailable
# #region test_legacy_checks_endpoints_still_available [TYPE Function] # #region Test.Tests.TestLegacyChecksEndpointsStillAvailable [TYPE Function]
# @RELATION BINDS_TO -> TestCleanReleaseLegacyCompat # @RELATION BINDS_TO -> Test.Tests.TestCleanReleaseLegacyCompat
# @BRIEF Verify legacy checks start/status endpoints remain available during v2 transition. # @BRIEF Verify legacy checks start/status endpoints remain available during v2 transition.
def test_legacy_checks_endpoints_still_available() -> None: def test_legacy_checks_endpoints_still_available() -> None:
repo = _seed_legacy_repo() repo = _seed_legacy_repo()
@@ -155,5 +155,5 @@ def test_legacy_checks_endpoints_still_available() -> None:
assert "checks" in status_payload assert "checks" in status_payload
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()
# #endregion test_legacy_checks_endpoints_still_available # #endregion Test.Tests.TestLegacyChecksEndpointsStillAvailable
# #endregion TestCleanReleaseLegacyCompat # #endregion Test.Tests.TestCleanReleaseLegacyCompat

View File

@@ -1,5 +1,5 @@
# #region TestCleanReleaseSourcePolicy [TYPE Module] [C:3] [SEMANTICS tests, api, clean-release, source-policy] # #region Test.Tests.TestCleanReleaseSourcePolicy [TYPE Module] [C:3] [SEMANTICS tests, api, clean-release, source-policy]
# @RELATION BINDS_TO -> SrcRoot # @RELATION BINDS_TO -> Init.SrcRoot
# @BRIEF Validate API behavior for source isolation violations in clean release preparation. # @BRIEF Validate API behavior for source isolation violations in clean release preparation.
# @LAYER Domain # @LAYER Domain
# @INVARIANT External endpoints must produce blocking violation entries. # @INVARIANT External endpoints must produce blocking violation entries.
@@ -20,8 +20,8 @@ from src.models.clean_release import (
from src.services.clean_release.repository import CleanReleaseRepository from src.services.clean_release.repository import CleanReleaseRepository
# #region _repo_with_seed_data [TYPE Function] # #region Test.Tests.RepoWithSeedData [TYPE Function]
# @RELATION BINDS_TO -> TestCleanReleaseSourcePolicy # @RELATION BINDS_TO -> Test.Tests.TestCleanReleaseSourcePolicy
# @BRIEF Seed repository with candidate, registry, and active policy for source isolation test flow. # @BRIEF Seed repository with candidate, registry, and active policy for source isolation test flow.
def _repo_with_seed_data() -> CleanReleaseRepository: def _repo_with_seed_data() -> CleanReleaseRepository:
repo = CleanReleaseRepository() repo = CleanReleaseRepository()
@@ -68,9 +68,9 @@ def _repo_with_seed_data() -> CleanReleaseRepository:
) )
) )
return repo return repo
# #endregion _repo_with_seed_data # #endregion Test.Tests.RepoWithSeedData
# #region test_prepare_candidate_blocks_external_source [TYPE Function] # #region Test.Tests.TestPrepareCandidateBlocksExternalSource [TYPE Function]
# @RELATION BINDS_TO -> TestCleanReleaseSourcePolicy # @RELATION BINDS_TO -> Test.Tests.TestCleanReleaseSourcePolicy
# @BRIEF Verify candidate preparation is blocked when at least one source host is external to the trusted registry. # @BRIEF Verify candidate preparation is blocked when at least one source host is external to the trusted registry.
def test_prepare_candidate_blocks_external_source(): def test_prepare_candidate_blocks_external_source():
repo = _repo_with_seed_data() repo = _repo_with_seed_data()
@@ -98,5 +98,5 @@ def test_prepare_candidate_blocks_external_source():
assert any(v["category"] == "external-source" for v in data["violations"]) assert any(v["category"] == "external-source" for v in data["violations"])
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()
# #endregion test_prepare_candidate_blocks_external_source # #endregion Test.Tests.TestPrepareCandidateBlocksExternalSource
# #endregion TestCleanReleaseSourcePolicy # #endregion Test.Tests.TestCleanReleaseSourcePolicy

View File

@@ -1,7 +1,7 @@
# #region CleanReleaseV2ApiTests [TYPE Module] [C:3] [SEMANTICS test, clean-release, v2, api, contract] # #region Test.Tests.CleanReleaseV2ApiTests [TYPE Module] [C:3] [SEMANTICS test, clean-release, v2, api, contract]
# @BRIEF API contract tests for redesigned clean release endpoints. # @BRIEF API contract tests for redesigned clean release endpoints.
# @LAYER Domain # @LAYER Domain
# @RELATION DEPENDS_ON -> [CleanReleaseV2Api] # @RELATION DEPENDS_ON -> [Api.CleanReleaseV2.CleanReleaseV2Api]
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from src.app import app from src.app import app
@@ -9,8 +9,8 @@ from src.services.clean_release.enums import CandidateStatus
client = TestClient(app) client = TestClient(app)
# [REASON] Implementing API contract tests for candidate/artifact/manifest endpoints (T012). # [REASON] Implementing API contract tests for candidate/artifact/manifest endpoints (T012).
# #region test_candidate_registration_contract [TYPE Function] # #region Test.Tests.TestCandidateRegistrationContract [TYPE Function]
# @RELATION BINDS_TO -> CleanReleaseV2ApiTests # @RELATION BINDS_TO -> Test.Tests.CleanReleaseV2ApiTests
# @BRIEF Validate candidate registration endpoint creates a draft candidate with expected identifier contract. # @BRIEF Validate candidate registration endpoint creates a draft candidate with expected identifier contract.
def test_candidate_registration_contract(): def test_candidate_registration_contract():
""" """
@@ -28,9 +28,9 @@ def test_candidate_registration_contract():
data = response.json() data = response.json()
assert data["id"] == "rc-test-001" assert data["id"] == "rc-test-001"
assert data["status"] == CandidateStatus.DRAFT.value assert data["status"] == CandidateStatus.DRAFT.value
# #endregion test_candidate_registration_contract # #endregion Test.Tests.TestCandidateRegistrationContract
# #region test_artifact_import_contract [TYPE Function] # #region Test.Tests.TestArtifactImportContract [TYPE Function]
# @RELATION BINDS_TO -> CleanReleaseV2ApiTests # @RELATION BINDS_TO -> Test.Tests.CleanReleaseV2ApiTests
# @BRIEF Validate artifact import endpoint accepts candidate artifacts and returns success status payload. # @BRIEF Validate artifact import endpoint accepts candidate artifacts and returns success status payload.
def test_artifact_import_contract(): def test_artifact_import_contract():
""" """
@@ -58,9 +58,9 @@ def test_artifact_import_contract():
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["status"] == "success" assert response.json()["status"] == "success"
# #endregion test_artifact_import_contract # #endregion Test.Tests.TestArtifactImportContract
# #region test_manifest_build_contract [TYPE Function] # #region Test.Tests.TestManifestBuildContract [TYPE Function]
# @RELATION BINDS_TO -> CleanReleaseV2ApiTests # @RELATION BINDS_TO -> Test.Tests.CleanReleaseV2ApiTests
# @BRIEF Validate manifest build endpoint produces manifest payload linked to the target candidate. # @BRIEF Validate manifest build endpoint produces manifest payload linked to the target candidate.
def test_manifest_build_contract(): def test_manifest_build_contract():
""" """
@@ -83,5 +83,5 @@ def test_manifest_build_contract():
data = response.json() data = response.json()
assert "manifest_digest" in data assert "manifest_digest" in data
assert data["candidate_id"] == candidate_id assert data["candidate_id"] == candidate_id
# #endregion test_manifest_build_contract # #endregion Test.Tests.TestManifestBuildContract
# #endregion CleanReleaseV2ApiTests # #endregion Test.Tests.CleanReleaseV2ApiTests

View File

@@ -1,7 +1,7 @@
# #region CleanReleaseV2ReleaseApiTests [TYPE Module] [C:3] [SEMANTICS test, clean-release, release, approval, publication] # #region Test.Tests.CleanReleaseV2ReleaseApiTests [TYPE Module] [C:3] [SEMANTICS test, clean-release, release, approval, publication]
# @BRIEF API contract test scaffolding for clean release approval and publication endpoints. # @BRIEF API contract test scaffolding for clean release approval and publication endpoints.
# @LAYER Domain # @LAYER Domain
# @RELATION DEPENDS_ON -> [CleanReleaseV2Api] # @RELATION DEPENDS_ON -> [Api.CleanReleaseV2.CleanReleaseV2Api]
"""Contract tests for redesigned approval/publication API endpoints.""" """Contract tests for redesigned approval/publication API endpoints."""
from datetime import UTC, datetime from datetime import UTC, datetime
from uuid import uuid4 from uuid import uuid4
@@ -17,8 +17,8 @@ from src.services.clean_release.enums import CandidateStatus, ComplianceDecision
test_app = FastAPI() test_app = FastAPI()
test_app.include_router(clean_release_v2_router) test_app.include_router(clean_release_v2_router)
client = TestClient(test_app) client = TestClient(test_app)
# #region _seed_candidate_and_passed_report [TYPE Function] # #region Test.Tests.SeedCandidateAndPassedReport [TYPE Function]
# @RELATION BINDS_TO -> CleanReleaseV2ReleaseApiTests # @RELATION BINDS_TO -> Test.Tests.CleanReleaseV2ReleaseApiTests
# @BRIEF Seed repository with approvable candidate and passed report for release endpoint contracts. # @BRIEF Seed repository with approvable candidate and passed report for release endpoint contracts.
def _seed_candidate_and_passed_report() -> tuple[str, str]: def _seed_candidate_and_passed_report() -> tuple[str, str]:
repository = get_clean_release_repository() repository = get_clean_release_repository()
@@ -50,9 +50,9 @@ def _seed_candidate_and_passed_report() -> tuple[str, str]:
) )
) )
return candidate_id, report_id return candidate_id, report_id
# #endregion _seed_candidate_and_passed_report # #endregion Test.Tests.SeedCandidateAndPassedReport
# #region test_release_approve_and_publish_revoke_contract [TYPE Function] # #region Test.Tests.TestReleaseApproveAndPublishRevokeContract [TYPE Function]
# @RELATION BINDS_TO -> CleanReleaseV2ReleaseApiTests # @RELATION BINDS_TO -> Test.Tests.CleanReleaseV2ReleaseApiTests
# @BRIEF Verify approve, publish, and revoke endpoints preserve expected release lifecycle contract. # @BRIEF Verify approve, publish, and revoke endpoints preserve expected release lifecycle contract.
def test_release_approve_and_publish_revoke_contract() -> None: def test_release_approve_and_publish_revoke_contract() -> None:
"""Contract for approve -> publish -> revoke lifecycle endpoints.""" """Contract for approve -> publish -> revoke lifecycle endpoints."""
@@ -87,9 +87,9 @@ def test_release_approve_and_publish_revoke_contract() -> None:
revoke_payload = revoke_response.json() revoke_payload = revoke_response.json()
assert revoke_payload["status"] == "ok" assert revoke_payload["status"] == "ok"
assert revoke_payload["publication"]["status"] == "REVOKED" assert revoke_payload["publication"]["status"] == "REVOKED"
# #endregion test_release_approve_and_publish_revoke_contract # #endregion Test.Tests.TestReleaseApproveAndPublishRevokeContract
# #region test_release_reject_contract [TYPE Function] # #region Test.Tests.TestReleaseRejectContract [TYPE Function]
# @RELATION BINDS_TO -> CleanReleaseV2ReleaseApiTests # @RELATION BINDS_TO -> Test.Tests.CleanReleaseV2ReleaseApiTests
# @BRIEF Verify reject endpoint returns successful rejection decision payload. # @BRIEF Verify reject endpoint returns successful rejection decision payload.
def test_release_reject_contract() -> None: def test_release_reject_contract() -> None:
"""Contract for reject endpoint.""" """Contract for reject endpoint."""
@@ -102,5 +102,5 @@ def test_release_reject_contract() -> None:
payload = reject_response.json() payload = reject_response.json()
assert payload["status"] == "ok" assert payload["status"] == "ok"
assert payload["decision"] == "REJECTED" assert payload["decision"] == "REJECTED"
# #endregion test_release_reject_contract # #endregion Test.Tests.TestReleaseRejectContract
# #endregion CleanReleaseV2ReleaseApiTests # #endregion Test.Tests.CleanReleaseV2ReleaseApiTests

View File

@@ -1,7 +1,7 @@
# #region DashboardsApiTests [TYPE Module] [C:3] [SEMANTICS test, dashboard, api, listing, migration] # #region Test.Tests.DashboardsApiTests [TYPE Module] [C:3] [SEMANTICS test, dashboard, api, listing, migration]
# @BRIEF Unit tests for dashboards API endpoints. # @BRIEF Unit tests for dashboards API endpoints.
# @LAYER API # @LAYER API
# @RELATION DEPENDS_ON -> [DashboardsApi] # @RELATION DEPENDS_ON -> [Api.Init.DashboardsApi]
from datetime import UTC, datetime from datetime import UTC, datetime
import pytest import pytest
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
@@ -61,8 +61,8 @@ def mock_deps():
} }
app.dependency_overrides.clear() app.dependency_overrides.clear()
client = TestClient(app) client = TestClient(app)
# #region test_get_dashboards_success [TYPE Function] # #region Test.Tests.TestGetDashboardsSuccess [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests # @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
# @BRIEF Validate dashboards listing returns a populated response that satisfies the schema contract. # @BRIEF Validate dashboards listing returns a populated response that satisfies the schema contract.
# @TEST: GET /api/dashboards returns 200 and valid schema # @TEST: GET /api/dashboards returns 200 and valid schema
# @PRE env_id exists # @PRE env_id exists
@@ -95,9 +95,9 @@ def test_get_dashboards_success(mock_deps):
assert data["total"] == 1 assert data["total"] == 1
assert "page" in data assert "page" in data
DashboardsResponse(**data) DashboardsResponse(**data)
# #endregion test_get_dashboards_success # #endregion Test.Tests.TestGetDashboardsSuccess
# #region test_get_dashboards_with_search [TYPE Function] # #region Test.Tests.TestGetDashboardsWithSearch [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests # @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
# @BRIEF Validate dashboards listing applies the search filter and returns only matching rows. # @BRIEF Validate dashboards listing applies the search filter and returns only matching rows.
# @TEST: GET /api/dashboards filters by search term # @TEST: GET /api/dashboards filters by search term
# @PRE search parameter provided # @PRE search parameter provided
@@ -133,9 +133,9 @@ def test_get_dashboards_with_search(mock_deps):
# @POST Filtered result count must match search # @POST Filtered result count must match search
assert len(data["dashboards"]) == 1 assert len(data["dashboards"]) == 1
assert data["dashboards"][0]["title"] == "Sales Report" assert data["dashboards"][0]["title"] == "Sales Report"
# #endregion test_get_dashboards_with_search # #endregion Test.Tests.TestGetDashboardsWithSearch
# #region test_get_dashboards_empty [TYPE Function] # #region Test.Tests.TestGetDashboardsEmpty [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests # @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
# @BRIEF Validate dashboards listing returns an empty payload for an environment without dashboards. # @BRIEF Validate dashboards listing returns an empty payload for an environment without dashboards.
# @TEST_EDGE empty_dashboards -> {env_id: 'empty_env', expected_total: 0} # @TEST_EDGE empty_dashboards -> {env_id: 'empty_env', expected_total: 0}
def test_get_dashboards_empty(mock_deps): def test_get_dashboards_empty(mock_deps):
@@ -152,9 +152,9 @@ def test_get_dashboards_empty(mock_deps):
assert len(data["dashboards"]) == 0 assert len(data["dashboards"]) == 0
assert data["total_pages"] == 1 assert data["total_pages"] == 1
DashboardsResponse(**data) DashboardsResponse(**data)
# #endregion test_get_dashboards_empty # #endregion Test.Tests.TestGetDashboardsEmpty
# #region test_get_dashboards_superset_failure [TYPE Function] # #region Test.Tests.TestGetDashboardsSupersetFailure [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests # @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
# @BRIEF Validate dashboards listing surfaces a 503 contract when Superset access fails. # @BRIEF Validate dashboards listing surfaces a 503 contract when Superset access fails.
# @TEST_EDGE external_superset_failure -> {env_id: 'bad_conn', status: 503} # @TEST_EDGE external_superset_failure -> {env_id: 'bad_conn', status: 503}
def test_get_dashboards_superset_failure(mock_deps): def test_get_dashboards_superset_failure(mock_deps):
@@ -169,9 +169,9 @@ def test_get_dashboards_superset_failure(mock_deps):
response = client.get("/api/dashboards?env_id=bad_conn") response = client.get("/api/dashboards?env_id=bad_conn")
assert response.status_code == 503 assert response.status_code == 503
assert "Failed to fetch dashboards" in response.json()["detail"] assert "Failed to fetch dashboards" in response.json()["detail"]
# #endregion test_get_dashboards_superset_failure # #endregion Test.Tests.TestGetDashboardsSupersetFailure
# #region test_get_dashboards_env_not_found [TYPE Function] # #region Test.Tests.TestGetDashboardsEnvNotFound [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests # @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
# @BRIEF Validate dashboards listing returns 404 when the requested environment does not exist. # @BRIEF Validate dashboards listing returns 404 when the requested environment does not exist.
# @TEST: GET /api/dashboards returns 404 if env_id missing # @TEST: GET /api/dashboards returns 404 if env_id missing
# @PRE env_id does not exist # @PRE env_id does not exist
@@ -181,9 +181,9 @@ def test_get_dashboards_env_not_found(mock_deps):
response = client.get("/api/dashboards?env_id=nonexistent") response = client.get("/api/dashboards?env_id=nonexistent")
assert response.status_code == 404 assert response.status_code == 404
assert "Environment not found" in response.json()["detail"] assert "Environment not found" in response.json()["detail"]
# #endregion test_get_dashboards_env_not_found # #endregion Test.Tests.TestGetDashboardsEnvNotFound
# #region test_get_dashboards_invalid_pagination [TYPE Function] # #region Test.Tests.TestGetDashboardsInvalidPagination [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests # @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
# @BRIEF Validate dashboards listing rejects invalid pagination parameters with 400 responses. # @BRIEF Validate dashboards listing rejects invalid pagination parameters with 400 responses.
# @TEST: GET /api/dashboards returns 400 for invalid page/page_size # @TEST: GET /api/dashboards returns 400 for invalid page/page_size
# @PRE page < 1 or page_size > 100 # @PRE page < 1 or page_size > 100
@@ -200,9 +200,9 @@ def test_get_dashboards_invalid_pagination(mock_deps):
response = client.get("/api/dashboards?env_id=prod&page_size=101") response = client.get("/api/dashboards?env_id=prod&page_size=101")
assert response.status_code == 400 assert response.status_code == 400
assert "Page size must be between 1 and 100" in response.json()["detail"] assert "Page size must be between 1 and 100" in response.json()["detail"]
# #endregion test_get_dashboards_invalid_pagination # #endregion Test.Tests.TestGetDashboardsInvalidPagination
# #region test_get_dashboard_detail_success [TYPE Function] # #region Test.Tests.TestGetDashboardDetailSuccess [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests # @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
# @BRIEF Validate dashboard detail returns charts and datasets for an existing dashboard. # @BRIEF Validate dashboard detail returns charts and datasets for an existing dashboard.
# @TEST: GET /api/dashboards/{id} returns dashboard detail with charts and datasets # @TEST: GET /api/dashboards/{id} returns dashboard detail with charts and datasets
def test_get_dashboard_detail_success(mock_deps): def test_get_dashboard_detail_success(mock_deps):
@@ -249,9 +249,9 @@ def test_get_dashboard_detail_success(mock_deps):
assert payload["id"] == 42 assert payload["id"] == 42
assert payload["chart_count"] == 1 assert payload["chart_count"] == 1
assert payload["dataset_count"] == 1 assert payload["dataset_count"] == 1
# #endregion test_get_dashboard_detail_success # #endregion Test.Tests.TestGetDashboardDetailSuccess
# #region test_get_dashboard_detail_env_not_found [TYPE Function] # #region Test.Tests.TestGetDashboardDetailEnvNotFound [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests # @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
# @BRIEF Validate dashboard detail returns 404 when the requested environment is missing. # @BRIEF Validate dashboard detail returns 404 when the requested environment is missing.
# @TEST: GET /api/dashboards/{id} returns 404 for missing environment # @TEST: GET /api/dashboards/{id} returns 404 for missing environment
def test_get_dashboard_detail_env_not_found(mock_deps): def test_get_dashboard_detail_env_not_found(mock_deps):
@@ -259,9 +259,9 @@ def test_get_dashboard_detail_env_not_found(mock_deps):
response = client.get("/api/dashboards/42?env_id=missing") response = client.get("/api/dashboards/42?env_id=missing")
assert response.status_code == 404 assert response.status_code == 404
assert "Environment not found" in response.json()["detail"] assert "Environment not found" in response.json()["detail"]
# #endregion test_get_dashboard_detail_env_not_found # #endregion Test.Tests.TestGetDashboardDetailEnvNotFound
# #region test_migrate_dashboards_success [TYPE Function] # #region Test.Tests.TestMigrateDashboardsSuccess [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests # @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
# @TEST: POST /api/dashboards/migrate creates migration task # @TEST: POST /api/dashboards/migrate creates migration task
# @PRE Valid source_env_id, target_env_id, dashboard_ids # @PRE Valid source_env_id, target_env_id, dashboard_ids
# @BRIEF Validate dashboard migration request creates an async task and returns its identifier. # @BRIEF Validate dashboard migration request creates an async task and returns its identifier.
@@ -289,9 +289,9 @@ def test_migrate_dashboards_success(mock_deps):
assert "task_id" in data assert "task_id" in data
# @POST/@SIDE_EFFECT: create_task was called # @POST/@SIDE_EFFECT: create_task was called
mock_deps["task"].create_task.assert_called_once() mock_deps["task"].create_task.assert_called_once()
# #endregion test_migrate_dashboards_success # #endregion Test.Tests.TestMigrateDashboardsSuccess
# #region test_migrate_dashboards_no_ids [TYPE Function] # #region Test.Tests.TestMigrateDashboardsNoIds [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests # @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
# @TEST: POST /api/dashboards/migrate returns 400 for empty dashboard_ids # @TEST: POST /api/dashboards/migrate returns 400 for empty dashboard_ids
# @PRE dashboard_ids is empty # @PRE dashboard_ids is empty
# @BRIEF Validate dashboard migration rejects empty dashboard identifier lists. # @BRIEF Validate dashboard migration rejects empty dashboard identifier lists.
@@ -307,9 +307,9 @@ def test_migrate_dashboards_no_ids(mock_deps):
) )
assert response.status_code == 400 assert response.status_code == 400
assert "At least one dashboard ID must be provided" in response.json()["detail"] assert "At least one dashboard ID must be provided" in response.json()["detail"]
# #endregion test_migrate_dashboards_no_ids # #endregion Test.Tests.TestMigrateDashboardsNoIds
# #region test_migrate_dashboards_env_not_found [TYPE Function] # #region Test.Tests.TestMigrateDashboardsEnvNotFound [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests # @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
# @BRIEF Validate migration creation returns 404 when the source environment cannot be resolved. # @BRIEF Validate migration creation returns 404 when the source environment cannot be resolved.
# @PRE source_env_id and target_env_id are valid environment IDs # @PRE source_env_id and target_env_id are valid environment IDs
def test_migrate_dashboards_env_not_found(mock_deps): def test_migrate_dashboards_env_not_found(mock_deps):
@@ -321,9 +321,9 @@ def test_migrate_dashboards_env_not_found(mock_deps):
) )
assert response.status_code == 404 assert response.status_code == 404
assert "Source environment not found" in response.json()["detail"] assert "Source environment not found" in response.json()["detail"]
# #endregion test_migrate_dashboards_env_not_found # #endregion Test.Tests.TestMigrateDashboardsEnvNotFound
# #region test_backup_dashboards_success [TYPE Function] # #region Test.Tests.TestBackupDashboardsSuccess [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests # @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
# @TEST: POST /api/dashboards/backup creates backup task # @TEST: POST /api/dashboards/backup creates backup task
# @PRE Valid env_id, dashboard_ids # @PRE Valid env_id, dashboard_ids
# @BRIEF Validate dashboard backup request creates an async backup task and returns its identifier. # @BRIEF Validate dashboard backup request creates an async backup task and returns its identifier.
@@ -344,9 +344,9 @@ def test_backup_dashboards_success(mock_deps):
assert "task_id" in data assert "task_id" in data
# @POST/@SIDE_EFFECT: create_task was called # @POST/@SIDE_EFFECT: create_task was called
mock_deps["task"].create_task.assert_called_once() mock_deps["task"].create_task.assert_called_once()
# #endregion test_backup_dashboards_success # #endregion Test.Tests.TestBackupDashboardsSuccess
# #region test_backup_dashboards_env_not_found [TYPE Function] # #region Test.Tests.TestBackupDashboardsEnvNotFound [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests # @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
# @BRIEF Validate backup task creation returns 404 when the target environment is missing. # @BRIEF Validate backup task creation returns 404 when the target environment is missing.
# @PRE env_id is a valid environment ID # @PRE env_id is a valid environment ID
def test_backup_dashboards_env_not_found(mock_deps): def test_backup_dashboards_env_not_found(mock_deps):
@@ -357,9 +357,9 @@ def test_backup_dashboards_env_not_found(mock_deps):
) )
assert response.status_code == 404 assert response.status_code == 404
assert "Environment not found" in response.json()["detail"] assert "Environment not found" in response.json()["detail"]
# #endregion test_backup_dashboards_env_not_found # #endregion Test.Tests.TestBackupDashboardsEnvNotFound
# #region test_get_database_mappings_success [TYPE Function] # #region Test.Tests.TestGetDatabaseMappingsSuccess [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests # @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
# @TEST: GET /api/dashboards/db-mappings returns mapping suggestions # @TEST: GET /api/dashboards/db-mappings returns mapping suggestions
# @PRE Valid source_env_id, target_env_id # @PRE Valid source_env_id, target_env_id
# @BRIEF Validate database mapping suggestions are returned for valid source and target environments. # @BRIEF Validate database mapping suggestions are returned for valid source and target environments.
@@ -389,9 +389,9 @@ def test_get_database_mappings_success(mock_deps):
assert "mappings" in data assert "mappings" in data
assert len(data["mappings"]) == 1 assert len(data["mappings"]) == 1
assert data["mappings"][0]["confidence"] == 0.95 assert data["mappings"][0]["confidence"] == 0.95
# #endregion test_get_database_mappings_success # #endregion Test.Tests.TestGetDatabaseMappingsSuccess
# #region test_get_database_mappings_env_not_found [TYPE Function] # #region Test.Tests.TestGetDatabaseMappingsEnvNotFound [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests # @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
# @BRIEF Validate database mapping suggestions return 404 when either environment is missing. # @BRIEF Validate database mapping suggestions return 404 when either environment is missing.
# @PRE source_env_id and target_env_id are valid environment IDs # @PRE source_env_id and target_env_id are valid environment IDs
def test_get_database_mappings_env_not_found(mock_deps): def test_get_database_mappings_env_not_found(mock_deps):
@@ -401,9 +401,9 @@ def test_get_database_mappings_env_not_found(mock_deps):
"/api/dashboards/db-mappings?source_env_id=ghost&target_env_id=t" "/api/dashboards/db-mappings?source_env_id=ghost&target_env_id=t"
) )
assert response.status_code == 404 assert response.status_code == 404
# #endregion test_get_database_mappings_env_not_found # #endregion Test.Tests.TestGetDatabaseMappingsEnvNotFound
# #region test_get_dashboard_tasks_history_filters_success [TYPE Function] # #region Test.Tests.TestGetDashboardTasksHistoryFiltersSuccess [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests # @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
# @BRIEF Validate dashboard task history returns only related backup and LLM tasks. # @BRIEF Validate dashboard task history returns only related backup and LLM tasks.
# @TEST: GET /api/dashboards/{id}/tasks returns backup and llm tasks for dashboard # @TEST: GET /api/dashboards/{id}/tasks returns backup and llm tasks for dashboard
def test_get_dashboard_tasks_history_filters_success(mock_deps): def test_get_dashboard_tasks_history_filters_success(mock_deps):
@@ -442,9 +442,9 @@ def test_get_dashboard_tasks_history_filters_success(mock_deps):
"llm_dashboard_validation", "llm_dashboard_validation",
"superset-backup", "superset-backup",
} }
# #endregion test_get_dashboard_tasks_history_filters_success # #endregion Test.Tests.TestGetDashboardTasksHistoryFiltersSuccess
# #region test_get_dashboard_thumbnail_success [TYPE Function] # #region Test.Tests.TestGetDashboardThumbnailSuccess [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests # @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
# @BRIEF Validate dashboard thumbnail endpoint proxies image bytes and content type from Superset. # @BRIEF Validate dashboard thumbnail endpoint proxies image bytes and content type from Superset.
# @TEST: GET /api/dashboards/{id}/thumbnail proxies image bytes from Superset # @TEST: GET /api/dashboards/{id}/thumbnail proxies image bytes from Superset
def test_get_dashboard_thumbnail_success(mock_deps): def test_get_dashboard_thumbnail_success(mock_deps):
@@ -467,9 +467,9 @@ def test_get_dashboard_thumbnail_success(mock_deps):
assert response.status_code == 200 assert response.status_code == 200
assert response.content == b"fake-image-bytes" assert response.content == b"fake-image-bytes"
assert response.headers["content-type"].startswith("image/png") assert response.headers["content-type"].startswith("image/png")
# #endregion test_get_dashboard_thumbnail_success # #endregion Test.Tests.TestGetDashboardThumbnailSuccess
# #region _build_profile_preference_stub [TYPE Function] # #region Test.Tests.BuildProfilePreferenceStub [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests # @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
# @BRIEF Creates profile preference payload stub for dashboards filter contract tests. # @BRIEF Creates profile preference payload stub for dashboards filter contract tests.
# @PRE username can be empty; enabled indicates profile-default toggle state. # @PRE username can be empty; enabled indicates profile-default toggle state.
# @POST Returns object compatible with ProfileService.get_my_preference contract. # @POST Returns object compatible with ProfileService.get_my_preference contract.
@@ -483,9 +483,9 @@ def _build_profile_preference_stub(username: str, enabled: bool):
payload = MagicMock() payload = MagicMock()
payload.preference = preference payload.preference = preference
return payload return payload
# #endregion _build_profile_preference_stub # #endregion Test.Tests.BuildProfilePreferenceStub
# #region _matches_actor_case_insensitive [TYPE Function] # #region Test.Tests.MatchesActorCaseInsensitive [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests # @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
# @BRIEF Applies trim + case-insensitive owners OR modified_by matching used by route contract tests. # @BRIEF Applies trim + case-insensitive owners OR modified_by matching used by route contract tests.
# @PRE owners can be None or list-like values. # @PRE owners can be None or list-like values.
# @POST Returns True when bound username matches any owner or modified_by. # @POST Returns True when bound username matches any owner or modified_by.
@@ -502,9 +502,9 @@ def _matches_actor_case_insensitive(bound_username, owners, modified_by):
return normalized_bound in owner_tokens or bool( return normalized_bound in owner_tokens or bool(
modified_token and modified_token == normalized_bound modified_token and modified_token == normalized_bound
) )
# #endregion _matches_actor_case_insensitive # #endregion Test.Tests.MatchesActorCaseInsensitive
# #region test_get_dashboards_profile_filter_contract_owners_or_modified_by [TYPE Function] # #region Test.Tests.TestGetDashboardsProfileFilterContractOwnersOrModifiedBy [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests # @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
# @TEST: GET /api/dashboards applies profile-default filter with owners OR modified_by trim+case-insensitive semantics. # @TEST: GET /api/dashboards applies profile-default filter with owners OR modified_by trim+case-insensitive semantics.
# @BRIEF Validate profile-default filtering matches owner and modifier aliases using normalized Superset actor values. # @BRIEF Validate profile-default filtering matches owner and modifier aliases using normalized Superset actor values.
# @PRE Current user has enabled profile-default preference and bound username. # @PRE Current user has enabled profile-default preference and bound username.
@@ -561,9 +561,9 @@ def test_get_dashboards_profile_filter_contract_owners_or_modified_by(mock_deps)
assert payload["effective_profile_filter"]["override_show_all"] is False assert payload["effective_profile_filter"]["override_show_all"] is False
assert payload["effective_profile_filter"]["username"] == "john_doe" assert payload["effective_profile_filter"]["username"] == "john_doe"
assert payload["effective_profile_filter"]["match_logic"] == "owners_or_modified_by" assert payload["effective_profile_filter"]["match_logic"] == "owners_or_modified_by"
# #endregion test_get_dashboards_profile_filter_contract_owners_or_modified_by # #endregion Test.Tests.TestGetDashboardsProfileFilterContractOwnersOrModifiedBy
# #region test_get_dashboards_override_show_all_contract [TYPE Function] # #region Test.Tests.TestGetDashboardsOverrideShowAllContract [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests # @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
# @TEST: GET /api/dashboards honors override_show_all and disables profile-default filter for current page. # @TEST: GET /api/dashboards honors override_show_all and disables profile-default filter for current page.
# @BRIEF Validate override_show_all bypasses profile-default filtering without changing dashboard list semantics. # @BRIEF Validate override_show_all bypasses profile-default filtering without changing dashboard list semantics.
# @PRE Profile-default preference exists but override_show_all=true query is provided. # @PRE Profile-default preference exists but override_show_all=true query is provided.
@@ -614,9 +614,9 @@ def test_get_dashboards_override_show_all_contract(mock_deps):
assert payload["effective_profile_filter"]["username"] is None assert payload["effective_profile_filter"]["username"] is None
assert payload["effective_profile_filter"]["match_logic"] is None assert payload["effective_profile_filter"]["match_logic"] is None
profile_service.matches_dashboard_actor.assert_not_called() profile_service.matches_dashboard_actor.assert_not_called()
# #endregion test_get_dashboards_override_show_all_contract # #endregion Test.Tests.TestGetDashboardsOverrideShowAllContract
# #region test_get_dashboards_profile_filter_no_match_results_contract [TYPE Function] # #region Test.Tests.TestGetDashboardsProfileFilterNoMatchResultsContract [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests # @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
# @TEST: GET /api/dashboards returns empty result set when profile-default filter is active and no dashboard actors match. # @TEST: GET /api/dashboards returns empty result set when profile-default filter is active and no dashboard actors match.
# @BRIEF Validate profile-default filtering returns an empty dashboard page when no actor aliases match the bound user. # @BRIEF Validate profile-default filtering returns an empty dashboard page when no actor aliases match the bound user.
# @PRE Profile-default preference is enabled with bound username and all dashboards are non-matching. # @PRE Profile-default preference is enabled with bound username and all dashboards are non-matching.
@@ -669,9 +669,9 @@ def test_get_dashboards_profile_filter_no_match_results_contract(mock_deps):
assert payload["effective_profile_filter"]["override_show_all"] is False assert payload["effective_profile_filter"]["override_show_all"] is False
assert payload["effective_profile_filter"]["username"] == "john_doe" assert payload["effective_profile_filter"]["username"] == "john_doe"
assert payload["effective_profile_filter"]["match_logic"] == "owners_or_modified_by" assert payload["effective_profile_filter"]["match_logic"] == "owners_or_modified_by"
# #endregion test_get_dashboards_profile_filter_no_match_results_contract # #endregion Test.Tests.TestGetDashboardsProfileFilterNoMatchResultsContract
# #region test_get_dashboards_page_context_other_disables_profile_default [TYPE Function] # #region Test.Tests.TestGetDashboardsPageContextOtherDisablesProfileDefault [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests # @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
# @TEST: GET /api/dashboards does not auto-apply profile-default filter outside dashboards_main page context. # @TEST: GET /api/dashboards does not auto-apply profile-default filter outside dashboards_main page context.
# @BRIEF Validate non-dashboard page contexts suppress profile-default filtering and preserve unfiltered results. # @BRIEF Validate non-dashboard page contexts suppress profile-default filtering and preserve unfiltered results.
# @PRE Profile-default preference exists but page_context=other query is provided. # @PRE Profile-default preference exists but page_context=other query is provided.
@@ -722,9 +722,9 @@ def test_get_dashboards_page_context_other_disables_profile_default(mock_deps):
assert payload["effective_profile_filter"]["username"] is None assert payload["effective_profile_filter"]["username"] is None
assert payload["effective_profile_filter"]["match_logic"] is None assert payload["effective_profile_filter"]["match_logic"] is None
profile_service.matches_dashboard_actor.assert_not_called() profile_service.matches_dashboard_actor.assert_not_called()
# #endregion test_get_dashboards_page_context_other_disables_profile_default # #endregion Test.Tests.TestGetDashboardsPageContextOtherDisablesProfileDefault
# #region test_get_dashboards_profile_filter_matches_display_alias_without_detail_fanout [TYPE Function] # #region Test.Tests.TestGetDashboardsProfileFilterMatchesDisplayAliasWithoutDetailFanout [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests # @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
# @TEST: GET /api/dashboards resolves Superset display-name alias once and filters without per-dashboard detail calls. # @TEST: GET /api/dashboards resolves Superset display-name alias once and filters without per-dashboard detail calls.
# @BRIEF Validate profile-default filtering reuses resolved Superset display aliases without triggering per-dashboard detail fanout. # @BRIEF Validate profile-default filtering reuses resolved Superset display aliases without triggering per-dashboard detail fanout.
# @PRE Profile-default filter is active, bound username is `admin`, dashboard actors contain display labels. # @PRE Profile-default filter is active, bound username is `admin`, dashboard actors contain display labels.
@@ -798,9 +798,9 @@ def test_get_dashboards_profile_filter_matches_display_alias_without_detail_fano
assert payload["effective_profile_filter"]["applied"] is True assert payload["effective_profile_filter"]["applied"] is True
lookup_adapter.get_users_page.assert_called_once() lookup_adapter.get_users_page.assert_called_once()
superset_client.get_dashboard.assert_not_called() superset_client.get_dashboard.assert_not_called()
# #endregion test_get_dashboards_profile_filter_matches_display_alias_without_detail_fanout # #endregion Test.Tests.TestGetDashboardsProfileFilterMatchesDisplayAliasWithoutDetailFanout
# #region test_get_dashboards_profile_filter_matches_owner_object_payload_contract [TYPE Function] # #region Test.Tests.TestGetDashboardsProfileFilterMatchesOwnerObjectPayloadContract [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests # @RELATION BINDS_TO -> Test.Tests.DashboardsApiTests
# @TEST: GET /api/dashboards profile-default filter matches Superset owner object payloads. # @TEST: GET /api/dashboards profile-default filter matches Superset owner object payloads.
# @BRIEF Validate profile-default filtering accepts owner object payloads once aliases resolve to the bound Superset username. # @BRIEF Validate profile-default filtering accepts owner object payloads once aliases resolve to the bound Superset username.
# @PRE Profile-default preference is enabled and owners list contains dict payloads. # @PRE Profile-default preference is enabled and owners list contains dict payloads.
@@ -873,5 +873,5 @@ def test_get_dashboards_profile_filter_matches_owner_object_payload_contract(moc
assert payload["total"] == 1 assert payload["total"] == 1
assert {item["id"] for item in payload["dashboards"]} == {701} assert {item["id"] for item in payload["dashboards"]} == {701}
assert payload["dashboards"][0]["title"] == "Featured Charts" assert payload["dashboards"][0]["title"] == "Featured Charts"
# #endregion test_get_dashboards_profile_filter_matches_owner_object_payload_contract # #endregion Test.Tests.TestGetDashboardsProfileFilterMatchesOwnerObjectPayloadContract
# #endregion DashboardsApiTests # #endregion Test.Tests.DashboardsApiTests

View File

@@ -1,7 +1,7 @@
# #region DatasetsApiTests [TYPE Module] [C:3] [SEMANTICS datasets, api, tests, pagination, mapping, docs] # #region Test.Tests.DatasetsApiTests [TYPE Module] [C:3] [SEMANTICS datasets, api, tests, pagination, mapping, docs]
# @BRIEF Unit tests for datasets API endpoints. # @BRIEF Unit tests for datasets API endpoints.
# @LAYER API # @LAYER API
# @RELATION DEPENDS_ON -> [DatasetsApi] # @RELATION DEPENDS_ON -> [Api.Datasets.DatasetsApi]
# @INVARIANT Endpoint contracts remain stable for success and validation failure paths. # @INVARIANT Endpoint contracts remain stable for success and validation failure paths.
import pytest import pytest
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
@@ -61,8 +61,8 @@ def mock_deps():
} }
app.dependency_overrides.clear() app.dependency_overrides.clear()
client = TestClient(app) client = TestClient(app)
# #region test_get_datasets_success [TYPE Function] # #region Test.Tests.TestGetDatasetsSuccess [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests] # @RELATION BINDS_TO -> [Test.Tests.DatasetsApiTests]
# @BRIEF Validate successful datasets listing contract for an existing environment. # @BRIEF Validate successful datasets listing contract for an existing environment.
# @TEST: GET /api/datasets returns 200 and valid schema # @TEST: GET /api/datasets returns 200 and valid schema
# @PRE env_id exists # @PRE env_id exists
@@ -92,9 +92,9 @@ def test_get_datasets_success(mock_deps):
assert len(data["datasets"]) >= 0 assert len(data["datasets"]) >= 0
# Validate against Pydantic model # Validate against Pydantic model
DatasetsResponse(**data) DatasetsResponse(**data)
# #endregion test_get_datasets_success # #endregion Test.Tests.TestGetDatasetsSuccess
# #region test_get_datasets_env_not_found [TYPE Function] # #region Test.Tests.TestGetDatasetsEnvNotFound [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests] # @RELATION BINDS_TO -> [Test.Tests.DatasetsApiTests]
# @BRIEF Validate datasets listing returns 404 when the requested environment does not exist. # @BRIEF Validate datasets listing returns 404 when the requested environment does not exist.
# @TEST: GET /api/datasets returns 404 if env_id missing # @TEST: GET /api/datasets returns 404 if env_id missing
# @PRE env_id does not exist # @PRE env_id does not exist
@@ -104,9 +104,9 @@ def test_get_datasets_env_not_found(mock_deps):
response = client.get("/api/datasets?env_id=nonexistent") response = client.get("/api/datasets?env_id=nonexistent")
assert response.status_code == 404 assert response.status_code == 404
assert "Environment not found" in response.json()["detail"] assert "Environment not found" in response.json()["detail"]
# #endregion test_get_datasets_env_not_found # #endregion Test.Tests.TestGetDatasetsEnvNotFound
# #region test_get_datasets_invalid_pagination [TYPE Function] # #region Test.Tests.TestGetDatasetsInvalidPagination [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests] # @RELATION BINDS_TO -> [Test.Tests.DatasetsApiTests]
# @BRIEF Validate datasets listing rejects invalid pagination parameters with 400 responses. # @BRIEF Validate datasets listing rejects invalid pagination parameters with 400 responses.
# @TEST: GET /api/datasets returns 400 for invalid page/page_size # @TEST: GET /api/datasets returns 400 for invalid page/page_size
# @PRE page < 1 or page_size > 100 # @PRE page < 1 or page_size > 100
@@ -127,9 +127,9 @@ def test_get_datasets_invalid_pagination(mock_deps):
response = client.get("/api/datasets?env_id=prod&page_size=101") response = client.get("/api/datasets?env_id=prod&page_size=101")
assert response.status_code == 400 assert response.status_code == 400
assert "Page size must be between 1 and 100" in response.json()["detail"] assert "Page size must be between 1 and 100" in response.json()["detail"]
# #endregion test_get_datasets_invalid_pagination # #endregion Test.Tests.TestGetDatasetsInvalidPagination
# #region test_map_columns_success [TYPE Function] # #region Test.Tests.TestMapColumnsSuccess [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests] # @RELATION BINDS_TO -> [Test.Tests.DatasetsApiTests]
# @BRIEF Validate map-columns request creates an async mapping task and returns its identifier. # @BRIEF Validate map-columns request creates an async mapping task and returns its identifier.
# @TEST: POST /api/datasets/map-columns creates mapping task # @TEST: POST /api/datasets/map-columns creates mapping task
# @PRE Valid env_id, dataset_ids, source_type (sqllab) # @PRE Valid env_id, dataset_ids, source_type (sqllab)
@@ -152,9 +152,9 @@ def test_map_columns_success(mock_deps):
assert "task_id" in data assert "task_id" in data
# @POST/@SIDE_EFFECT: create_task was called # @POST/@SIDE_EFFECT: create_task was called
mock_deps["task"].create_task.assert_called_once() mock_deps["task"].create_task.assert_called_once()
# #endregion test_map_columns_success # #endregion Test.Tests.TestMapColumnsSuccess
# #region test_map_columns_invalid_source_type [TYPE Function] # #region Test.Tests.TestMapColumnsInvalidSourceType [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests] # @RELATION BINDS_TO -> [Test.Tests.DatasetsApiTests]
# @BRIEF Validate map-columns rejects unsupported source types with a 400 contract response. # @BRIEF Validate map-columns rejects unsupported source types with a 400 contract response.
# @TEST: POST /api/datasets/map-columns returns 400 for invalid source_type # @TEST: POST /api/datasets/map-columns returns 400 for invalid source_type
# @PRE source_type is not 'sqllab' or 'xlsx' # @PRE source_type is not 'sqllab' or 'xlsx'
@@ -166,9 +166,9 @@ def test_map_columns_invalid_source_type(mock_deps):
) )
assert response.status_code == 400 assert response.status_code == 400
assert "Source type must be 'sqllab' or 'xlsx'" in response.json()["detail"] assert "Source type must be 'sqllab' or 'xlsx'" in response.json()["detail"]
# #endregion test_map_columns_invalid_source_type # #endregion Test.Tests.TestMapColumnsInvalidSourceType
# #region test_generate_docs_success [TYPE Function] # #region Test.Tests.TestGenerateDocsSuccess [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests] # @RELATION BINDS_TO -> [Test.Tests.DatasetsApiTests]
# @TEST: POST /api/datasets/generate-docs creates doc generation task # @TEST: POST /api/datasets/generate-docs creates doc generation task
# @PRE Valid env_id, dataset_ids, llm_provider # @PRE Valid env_id, dataset_ids, llm_provider
# @BRIEF Validate generate-docs request creates an async documentation task and returns its identifier. # @BRIEF Validate generate-docs request creates an async documentation task and returns its identifier.
@@ -191,9 +191,9 @@ def test_generate_docs_success(mock_deps):
assert "task_id" in data assert "task_id" in data
# @POST/@SIDE_EFFECT: create_task was called # @POST/@SIDE_EFFECT: create_task was called
mock_deps["task"].create_task.assert_called_once() mock_deps["task"].create_task.assert_called_once()
# #endregion test_generate_docs_success # #endregion Test.Tests.TestGenerateDocsSuccess
# #region test_map_columns_empty_ids [TYPE Function] # #region Test.Tests.TestMapColumnsEmptyIds [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests] # @RELATION BINDS_TO -> [Test.Tests.DatasetsApiTests]
# @BRIEF Validate map-columns rejects empty dataset identifier lists. # @BRIEF Validate map-columns rejects empty dataset identifier lists.
# @TEST: POST /api/datasets/map-columns returns 400 for empty dataset_ids # @TEST: POST /api/datasets/map-columns returns 400 for empty dataset_ids
# @PRE dataset_ids is empty # @PRE dataset_ids is empty
@@ -206,9 +206,9 @@ def test_map_columns_empty_ids(mock_deps):
) )
assert response.status_code == 400 assert response.status_code == 400
assert "At least one dataset ID must be provided" in response.json()["detail"] assert "At least one dataset ID must be provided" in response.json()["detail"]
# #endregion test_map_columns_empty_ids # #endregion Test.Tests.TestMapColumnsEmptyIds
# #region test_map_columns_missing_database_id [TYPE Function] # #region Test.Tests.TestMapColumnsMissingDatabaseId [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests] # @RELATION BINDS_TO -> [Test.Tests.DatasetsApiTests]
# @BRIEF Validate map-columns rejects sqllab source without database_id. # @BRIEF Validate map-columns rejects sqllab source without database_id.
# @TEST: POST /api/datasets/map-columns returns 400 for sqllab without database_id # @TEST: POST /api/datasets/map-columns returns 400 for sqllab without database_id
# @POST Returns 400 error # @POST Returns 400 error
@@ -219,9 +219,9 @@ def test_map_columns_missing_database_id(mock_deps):
) )
assert response.status_code == 400 assert response.status_code == 400
assert "database_id is required" in response.json()["detail"] assert "database_id is required" in response.json()["detail"]
# #endregion test_map_columns_missing_database_id # #endregion Test.Tests.TestMapColumnsMissingDatabaseId
# #region test_generate_docs_empty_ids [TYPE Function] # #region Test.Tests.TestGenerateDocsEmptyIds [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests] # @RELATION BINDS_TO -> [Test.Tests.DatasetsApiTests]
# @BRIEF Validate generate-docs rejects empty dataset identifier lists. # @BRIEF Validate generate-docs rejects empty dataset identifier lists.
# @TEST: POST /api/datasets/generate-docs returns 400 for empty dataset_ids # @TEST: POST /api/datasets/generate-docs returns 400 for empty dataset_ids
# @PRE dataset_ids is empty # @PRE dataset_ids is empty
@@ -234,9 +234,9 @@ def test_generate_docs_empty_ids(mock_deps):
) )
assert response.status_code == 400 assert response.status_code == 400
assert "At least one dataset ID must be provided" in response.json()["detail"] assert "At least one dataset ID must be provided" in response.json()["detail"]
# #endregion test_generate_docs_empty_ids # #endregion Test.Tests.TestGenerateDocsEmptyIds
# #region test_generate_docs_env_not_found [TYPE Function] # #region Test.Tests.TestGenerateDocsEnvNotFound [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests] # @RELATION BINDS_TO -> [Test.Tests.DatasetsApiTests]
# @TEST: POST /api/datasets/generate-docs returns 404 for missing env # @TEST: POST /api/datasets/generate-docs returns 404 for missing env
# @PRE env_id does not exist # @PRE env_id does not exist
# @BRIEF Validate generate-docs returns 404 when the requested environment cannot be resolved. # @BRIEF Validate generate-docs returns 404 when the requested environment cannot be resolved.
@@ -250,9 +250,9 @@ def test_generate_docs_env_not_found(mock_deps):
) )
assert response.status_code == 404 assert response.status_code == 404
assert "Environment not found" in response.json()["detail"] assert "Environment not found" in response.json()["detail"]
# #endregion test_generate_docs_env_not_found # #endregion Test.Tests.TestGenerateDocsEnvNotFound
# #region test_get_datasets_superset_failure [TYPE Function] # #region Test.Tests.TestGetDatasetsSupersetFailure [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests] # @RELATION BINDS_TO -> [Test.Tests.DatasetsApiTests]
# @BRIEF Validate datasets listing surfaces a 503 contract when Superset access fails. # @BRIEF Validate datasets listing surfaces a 503 contract when Superset access fails.
# @TEST_EDGE external_superset_failure -> {status: 503} # @TEST_EDGE external_superset_failure -> {status: 503}
# @POST Returns 503 with stable error detail when upstream dataset fetch fails. # @POST Returns 503 with stable error detail when upstream dataset fetch fails.
@@ -268,5 +268,5 @@ def test_get_datasets_superset_failure(mock_deps):
response = client.get("/api/datasets?env_id=bad_conn") response = client.get("/api/datasets?env_id=bad_conn")
assert response.status_code == 503 assert response.status_code == 503
assert "Failed to fetch datasets" in response.json()["detail"] assert "Failed to fetch datasets" in response.json()["detail"]
# #endregion test_get_datasets_superset_failure # #endregion Test.Tests.TestGetDatasetsSupersetFailure
# #endregion DatasetsApiTests # #endregion Test.Tests.DatasetsApiTests

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