marvisx-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- core/api/__init__.py +0 -0
- core/api/agents/__init__.py +0 -0
- core/api/agents/session_health.py +59 -0
- core/api/agents/session_manager.py +206 -0
- core/api/bin/marvisx-state-hook.py +182 -0
- core/api/config.py +533 -0
- core/api/db.py +1516 -0
- core/api/dependencies/__init__.py +0 -0
- core/api/dependencies/tenant.py +34 -0
- core/api/main.py +1641 -0
- core/api/mcp/__init__.py +8 -0
- core/api/mcp/_adapter.py +184 -0
- core/api/mcp/server.py +58 -0
- core/api/mcp/tools/__init__.py +59 -0
- core/api/mcp/tools/brain.py +599 -0
- core/api/mcp/tools/graph.py +380 -0
- core/api/mcp/tools/handoffs.py +112 -0
- core/api/mcp/tools/ingest.py +326 -0
- core/api/mcp/tools/learnings.py +144 -0
- core/api/mcp/tools/projects.py +99 -0
- core/api/mcp/tools/pull_requests.py +173 -0
- core/api/mcp/tools/safety.py +111 -0
- core/api/mcp/tools/search.py +79 -0
- core/api/mcp/tools/tasks.py +258 -0
- core/api/middleware/__init__.py +0 -0
- core/api/middleware/tool_call_audit.py +111 -0
- core/api/models/__init__.py +346 -0
- core/api/models/auth.py +48 -0
- core/api/models/brain.py +1006 -0
- core/api/models/common.py +76 -0
- core/api/models/costs.py +91 -0
- core/api/models/graph.py +66 -0
- core/api/models/graph_cosmo.py +125 -0
- core/api/models/graph_pr_impact.py +257 -0
- core/api/models/graph_ux.py +141 -0
- core/api/models/inbox.py +230 -0
- core/api/models/ingest_keys.py +108 -0
- core/api/models/kg.py +41 -0
- core/api/models/llm_config.py +56 -0
- core/api/models/monitoring.py +234 -0
- core/api/models/projects.py +161 -0
- core/api/models/search.py +42 -0
- core/api/models/sessions.py +322 -0
- core/api/models/tasks.py +184 -0
- core/api/models/teams.py +63 -0
- core/api/models/users.py +184 -0
- core/api/observability/__init__.py +0 -0
- core/api/observability/tracing.py +92 -0
- core/api/paths.py +26 -0
- core/api/rate_limit.py +24 -0
- core/api/rbac.py +112 -0
- core/api/routers/__init__.py +0 -0
- core/api/routers/_adapter.py +24 -0
- core/api/routers/admin_pr_impact.py +230 -0
- core/api/routers/admin_settings.py +147 -0
- core/api/routers/agent.py +1079 -0
- core/api/routers/agent_tokens.py +276 -0
- core/api/routers/app_settings.py +112 -0
- core/api/routers/audit.py +89 -0
- core/api/routers/auth.py +586 -0
- core/api/routers/bench.py +161 -0
- core/api/routers/brain.py +881 -0
- core/api/routers/brain_directions.py +527 -0
- core/api/routers/ci_checks.py +140 -0
- core/api/routers/comments.py +273 -0
- core/api/routers/costs.py +148 -0
- core/api/routers/docs_coverage.py +217 -0
- core/api/routers/docs_governance.py +63 -0
- core/api/routers/documents.py +318 -0
- core/api/routers/files.py +163 -0
- core/api/routers/finder.py +987 -0
- core/api/routers/graph.py +836 -0
- core/api/routers/handoffs.py +156 -0
- core/api/routers/inbox.py +496 -0
- core/api/routers/ingest_api_keys.py +205 -0
- core/api/routers/ingest_triage.py +1227 -0
- core/api/routers/judge.py +306 -0
- core/api/routers/kg.py +336 -0
- core/api/routers/learnings.py +253 -0
- core/api/routers/llm_config.py +130 -0
- core/api/routers/monitoring.py +347 -0
- core/api/routers/notifications.py +125 -0
- core/api/routers/pr_impact.py +315 -0
- core/api/routers/projects.py +1061 -0
- core/api/routers/pull_requests.py +312 -0
- core/api/routers/push.py +67 -0
- core/api/routers/raci.py +228 -0
- core/api/routers/search.py +125 -0
- core/api/routers/sessions.py +3100 -0
- core/api/routers/settings.py +90 -0
- core/api/routers/share_repo.py +68 -0
- core/api/routers/status_updates.py +96 -0
- core/api/routers/tags.py +45 -0
- core/api/routers/tasks.py +526 -0
- core/api/routers/teams.py +425 -0
- core/api/routers/terminal.py +105 -0
- core/api/routers/users.py +331 -0
- core/api/routers/webhooks.py +330 -0
- core/api/runtime_settings.py +84 -0
- core/api/security.py +652 -0
- core/api/services/__init__.py +0 -0
- core/api/services/audit.py +58 -0
- core/api/services/auto_approval.py +82 -0
- core/api/services/brain/__init__.py +52 -0
- core/api/services/brain/baseline.py +230 -0
- core/api/services/brain/capabilities.py +75 -0
- core/api/services/brain/cascade_rollup.py +388 -0
- core/api/services/brain/compound_bridge.py +215 -0
- core/api/services/brain/cycle.py +1242 -0
- core/api/services/brain/cycle_snapshot.py +371 -0
- core/api/services/brain/digest_collector.py +147 -0
- core/api/services/brain/direction.py +421 -0
- core/api/services/brain/drift.py +356 -0
- core/api/services/brain/drift_router.py +409 -0
- core/api/services/brain/edge_metrics.py +79 -0
- core/api/services/brain/events_reader.py +222 -0
- core/api/services/brain/findings.py +1379 -0
- core/api/services/brain/findings_reader.py +1006 -0
- core/api/services/brain/jobs.py +733 -0
- core/api/services/brain/journal.py +206 -0
- core/api/services/brain/knowledge_forms.py +92 -0
- core/api/services/brain/llm/__init__.py +37 -0
- core/api/services/brain/llm/_runner.py +62 -0
- core/api/services/brain/llm/base.py +70 -0
- core/api/services/brain/llm/cache.py +99 -0
- core/api/services/brain/llm/constants.py +46 -0
- core/api/services/brain/llm/direction_alignment.py +289 -0
- core/api/services/brain/llm/factory.py +132 -0
- core/api/services/brain/llm/finding_reasoning.py +98 -0
- core/api/services/brain/llm/finding_summary.py +92 -0
- core/api/services/brain/llm/grounding.py +46 -0
- core/api/services/brain/llm/journal_polish.py +96 -0
- core/api/services/brain/llm/local_gateway.py +426 -0
- core/api/services/brain/llm/parsers.py +71 -0
- core/api/services/brain/llm/router_glue.py +422 -0
- core/api/services/brain/memory_ops.py +1677 -0
- core/api/services/brain/models.py +140 -0
- core/api/services/brain/owner_hint.py +211 -0
- core/api/services/brain/recap.py +307 -0
- core/api/services/brain/rules/__init__.py +65 -0
- core/api/services/brain/rules/_signals.py +205 -0
- core/api/services/brain/rules/dr1_activity_without_status.py +108 -0
- core/api/services/brain/rules/dr2_decision_without_adr.py +110 -0
- core/api/services/brain/rules/dr3_stale_open_loop.py +127 -0
- core/api/services/brain/rules/dr4_docs_governance_drift.py +79 -0
- core/api/services/brain/rules/dr5_playbook_changed.py +99 -0
- core/api/services/brain/rules/dr6_external_update_unpropagated.py +100 -0
- core/api/services/brain/rules/dr7_claimed_decision_gap.py +123 -0
- core/api/services/brain/rules/dr8_direction_misalignment.py +230 -0
- core/api/services/brain/runs_reader.py +485 -0
- core/api/services/brain/scope.py +79 -0
- core/api/services/brain/sources/__init__.py +46 -0
- core/api/services/brain/sources/base.py +86 -0
- core/api/services/brain/sources/git_kg.py +393 -0
- core/api/services/brain/sources/handoffs.py +157 -0
- core/api/services/brain/sources/ingestor.py +130 -0
- core/api/services/brain/sources/learnings.py +121 -0
- core/api/services/brain/sources/pir_tasks.py +245 -0
- core/api/services/brain/watermarks.py +147 -0
- core/api/services/brain/ws_emitter.py +170 -0
- core/api/services/cc_tasks_reader.py +76 -0
- core/api/services/ci_service.py +263 -0
- core/api/services/claude_metrics.py +796 -0
- core/api/services/codex_metrics.py +364 -0
- core/api/services/conversation_reader.py +102 -0
- core/api/services/cost_service.py +243 -0
- core/api/services/crypto.py +147 -0
- core/api/services/docs_governance/__init__.py +1 -0
- core/api/services/docs_governance/confidence.py +230 -0
- core/api/services/docs_governance/config.py +87 -0
- core/api/services/docs_governance/enrichment.py +65 -0
- core/api/services/docs_governance/frontmatter_validator.py +83 -0
- core/api/services/docs_governance/hard_gates.py +221 -0
- core/api/services/docs_governance/triage_orchestrator.py +98 -0
- core/api/services/embedding_internal.py +395 -0
- core/api/services/embedding_service.py +832 -0
- core/api/services/event_dispatcher.py +167 -0
- core/api/services/events.py +70 -0
- core/api/services/git_ops.py +621 -0
- core/api/services/graph_cosmo_service.py +440 -0
- core/api/services/graph_ranker.py +306 -0
- core/api/services/graph_service.py +1589 -0
- core/api/services/inbox.py +800 -0
- core/api/services/inbox_digest.py +221 -0
- core/api/services/inbox_digest_deep_research.py +80 -0
- core/api/services/inbox_digest_jobs.py +595 -0
- core/api/services/inbox_gmail_sync.py +167 -0
- core/api/services/inbox_llm_classifier.py +906 -0
- core/api/services/inbox_source_identity.py +116 -0
- core/api/services/inbox_sources.py +456 -0
- core/api/services/inbox_taxonomy.py +195 -0
- core/api/services/inbox_tldr.py +1079 -0
- core/api/services/inbox_triage.py +899 -0
- core/api/services/ingest/__init__.py +13 -0
- core/api/services/ingest/api_key_auth.py +136 -0
- core/api/services/ingest/auto_approve.py +120 -0
- core/api/services/ingest/classifier.py +138 -0
- core/api/services/ingest/confidence.py +173 -0
- core/api/services/ingest/dispatch.py +88 -0
- core/api/services/ingest/embedding_router.py +272 -0
- core/api/services/ingest/events.py +33 -0
- core/api/services/ingest/ignore_patterns.py +79 -0
- core/api/services/ingest/image_probe.py +218 -0
- core/api/services/ingest/ingress.py +263 -0
- core/api/services/ingest/insert_saga.py +793 -0
- core/api/services/ingest/llm/__init__.py +13 -0
- core/api/services/ingest/llm/anthropic_haiku.py +23 -0
- core/api/services/ingest/llm/base.py +59 -0
- core/api/services/ingest/llm/byok_provider.py +130 -0
- core/api/services/ingest/llm/classification_context.py +301 -0
- core/api/services/ingest/llm/config_store.py +246 -0
- core/api/services/ingest/llm/factory.py +24 -0
- core/api/services/ingest/llm/kg_enricher.py +306 -0
- core/api/services/ingest/llm/local_gateway.py +821 -0
- core/api/services/ingest/llm/local_vllm.py +23 -0
- core/api/services/ingest/llm/openai_nano.py +349 -0
- core/api/services/ingest/lock_advisory.py +57 -0
- core/api/services/ingest/parser_router.py +1756 -0
- core/api/services/ingest/parsers/__init__.py +1 -0
- core/api/services/ingest/parsers/docling_parser.py +142 -0
- core/api/services/ingest/parsers/docparse_gateway.py +178 -0
- core/api/services/ingest/parsers/docx_parser.py +127 -0
- core/api/services/ingest/parsers/folder_unpacker.py +85 -0
- core/api/services/ingest/parsers/gateway_aux.py +147 -0
- core/api/services/ingest/parsers/image_parser.py +251 -0
- core/api/services/ingest/parsers/internal_markdown.py +89 -0
- core/api/services/ingest/parsers/ocr_gateway.py +117 -0
- core/api/services/ingest/parsers/ocr_pdf_parser.py +112 -0
- core/api/services/ingest/parsers/pdf_types.py +13 -0
- core/api/services/ingest/parsers/transcript_parser.py +445 -0
- core/api/services/ingest/parsers/vision_gateway.py +186 -0
- core/api/services/ingest/parsers/xlsx_parser.py +91 -0
- core/api/services/ingest/parsers/zip_unpacker.py +126 -0
- core/api/services/ingest/preflight.py +393 -0
- core/api/services/ingest/retry_voyage.py +88 -0
- core/api/services/ingest/routing_policy.py +307 -0
- core/api/services/ingest/serializers/__init__.py +1 -0
- core/api/services/ingest/serializers/xlsx_to_markdown.py +80 -0
- core/api/services/ingest/skip_log.py +74 -0
- core/api/services/ingest/watcher.py +637 -0
- core/api/services/kg/__init__.py +0 -0
- core/api/services/kg/audit.py +49 -0
- core/api/services/kg/hybrid_search.py +691 -0
- core/api/services/kg/lens.py +339 -0
- core/api/services/kg/pr_impact.py +770 -0
- core/api/services/kg/queries.py +152 -0
- core/api/services/kg/ranking.py +89 -0
- core/api/services/kg/rrf.py +143 -0
- core/api/services/kg_watcher_control.py +161 -0
- core/api/services/local_llm/__init__.py +19 -0
- core/api/services/local_llm/async_client.py +385 -0
- core/api/services/local_llm/client.py +173 -0
- core/api/services/local_llm/url_validator.py +44 -0
- core/api/services/metrics_collector.py +646 -0
- core/api/services/metrics_providers.py +65 -0
- core/api/services/model_registry.py +266 -0
- core/api/services/model_router.py +137 -0
- core/api/services/n8n_client.py +77 -0
- core/api/services/newsletter_llm_gateway.py +66 -0
- core/api/services/notification_service.py +134 -0
- core/api/services/openai_responses.py +55 -0
- core/api/services/opencode_metrics.py +375 -0
- core/api/services/opencode_sessions.py +173 -0
- core/api/services/pii_redactor.py +138 -0
- core/api/services/pr_impact_pipeline/__init__.py +21 -0
- core/api/services/pr_impact_pipeline/differ.py +421 -0
- core/api/services/pr_impact_pipeline/dispatcher.py +415 -0
- core/api/services/pr_impact_pipeline/gc.py +93 -0
- core/api/services/pr_impact_pipeline/languages.py +192 -0
- core/api/services/pr_impact_pipeline/parser.py +178 -0
- core/api/services/pr_impact_pipeline/writer.py +394 -0
- core/api/services/pr_service.py +1393 -0
- core/api/services/project_paths.py +70 -0
- core/api/services/project_status_updates.py +265 -0
- core/api/services/providers.py +276 -0
- core/api/services/push_service.py +170 -0
- core/api/services/reminder_service.py +89 -0
- core/api/services/runas.py +41 -0
- core/api/services/salience_service.py +69 -0
- core/api/services/security_collector.py +281 -0
- core/api/services/session_catalog.py +385 -0
- core/api/services/session_metrics_service.py +301 -0
- core/api/services/session_ops.py +272 -0
- core/api/services/session_state.py +173 -0
- core/api/services/share_links.py +222 -0
- core/api/services/task_transitions.py +146 -0
- core/api/services/terminal_metrics.py +462 -0
- core/api/services/terminal_metrics_dump.py +203 -0
- core/api/services/tmux.py +1205 -0
- core/api/services/webhook_service.py +422 -0
- core/api/services/workspace_sync.py +164 -0
- core/api/templates/__init__.py +1 -0
- core/api/templates/markdown_share.py +164 -0
- core/api/terminal.py +1031 -0
- core/api/tests/__init__.py +0 -0
- core/api/tests/test_agent_facing_auth_dependencies.py +132 -0
- core/api/tests/test_audit_permissions.py +133 -0
- core/api/tests/test_backfill_session_conversations.py +90 -0
- core/api/tests/test_backfill_working_seconds_msg.py +129 -0
- core/api/tests/test_claude_metrics.py +326 -0
- core/api/tests/test_codex_metrics.py +189 -0
- core/api/tests/test_finder_paths.py +74 -0
- core/api/tests/test_git_ops_merge.py +155 -0
- core/api/tests/test_learnings_check_search.py +81 -0
- core/api/tests/test_metrics_providers.py +133 -0
- core/api/tests/test_migration_087.py +164 -0
- core/api/tests/test_migration_088.py +94 -0
- core/api/tests/test_migration_089.py +116 -0
- core/api/tests/test_openai_responses.py +24 -0
- core/api/tests/test_opencode_metrics.py +740 -0
- core/api/tests/test_opencode_sessions.py +321 -0
- core/api/tests/test_pr_workflow_e2e.py +457 -0
- core/api/tests/test_projects_handoffs.py +31 -0
- core/api/tests/test_providers.py +138 -0
- core/api/tests/test_safety_bridge.py +347 -0
- core/api/tests/test_session_catalog.py +142 -0
- core/api/tests/test_session_conversations.py +512 -0
- core/api/tests/test_session_metrics_service.py +270 -0
- core/api/tests/test_session_resume_paths.py +548 -0
- core/api/tests/test_session_theme_mode_migration.py +56 -0
- core/api/tests/test_sessions_rbac.py +131 -0
- core/api/tests/test_share_edit.py +398 -0
- core/api/tests/test_share_repo.py +200 -0
- core/api/tests/test_terminal_session_manager.py +98 -0
- core/api/tests/test_terminal_upload.py +34 -0
- core/api/tests/test_tmux.py +272 -0
- core/api/tests/test_workspace_sync.py +186 -0
- core/api/tests/test_ws_ticket_in_memory.py +73 -0
- core/api/use_cases/__init__.py +11 -0
- core/api/use_cases/_context.py +89 -0
- core/api/use_cases/_errors.py +62 -0
- core/api/use_cases/_roles.py +16 -0
- core/api/use_cases/audit.py +171 -0
- core/api/use_cases/brain.py +1232 -0
- core/api/use_cases/costs.py +249 -0
- core/api/use_cases/graph.py +1153 -0
- core/api/use_cases/handoffs.py +506 -0
- core/api/use_cases/ingest_triage.py +1229 -0
- core/api/use_cases/learnings.py +538 -0
- core/api/use_cases/projects.py +705 -0
- core/api/use_cases/pull_requests.py +415 -0
- core/api/use_cases/search.py +926 -0
- core/api/use_cases/tasks.py +1495 -0
- core/api/visibility.py +141 -0
- core/cli/__init__.py +5 -0
- core/cli/_index_source.py +632 -0
- core/cli/_runtime_ctx.py +160 -0
- core/cli/_transmute.py +241 -0
- core/cli/marvis_doctor.py +704 -0
- core/cli/marvis_feedback.py +396 -0
- core/cli/marvis_governance.py +315 -0
- core/cli/marvis_hooks.py +515 -0
- core/cli/marvis_init.py +757 -0
- core/cli/marvis_mcp.py +401 -0
- core/cli/marvis_runtime.py +855 -0
- core/cli/marvis_telemetry.py +228 -0
- core/scripts/_drift_check.py +716 -0
- core/scripts/_frontmatter.py +66 -0
- core/scripts/_graph_writer.py +189 -0
- core/scripts/ast_parser.py +1553 -0
- core/scripts/install_hooks/__init__.py +1 -0
- core/scripts/install_hooks/_config.sh +109 -0
- core/scripts/install_hooks/block-dangerous-bash.sh +23 -0
- core/scripts/install_hooks/block-db-direct-write.sh +23 -0
- core/scripts/install_hooks/block-push-no-task.sh +23 -0
- core/scripts/install_hooks/block-staging-to-prod.sh +23 -0
- core/scripts/install_hooks/block-subtree-push.sh +23 -0
- core/scripts/install_hooks/config.json +53 -0
- core/scripts/install_hooks/enforce-no-merge-main.sh +23 -0
- core/scripts/install_hooks/enforce-worktree.sh +23 -0
- core/scripts/install_hooks/quality-gate.sh +170 -0
- core/scripts/install_hooks/safety_bridge.py +968 -0
- core/scripts/install_hooks/secret-scan.sh +23 -0
- core/scripts/migrate_spike_node_ids.py +122 -0
- core/scripts/populate_artifacts.py +2198 -0
- core/scripts/populate_cross_project.py +2457 -0
- core/scripts/populate_inbox_nodes.py +357 -0
- core/scripts/populate_pr_impact.py +267 -0
- core/scripts/populate_project_nodes.py +603 -0
- core/scripts/populate_touch_counter.py +337 -0
- core/scripts/reparse_failed.py +57 -0
- core/scripts/safety_bridge.py +968 -0
- core/telemetry/__init__.py +9 -0
- core/telemetry/client.py +405 -0
- core/telemetry/schema.py +122 -0
- core/wizard/__init__.py +65 -0
- core/wizard/byok_vault.py +147 -0
- core/wizard/defaults.py +58 -0
- core/wizard/state.py +117 -0
- core/wizard/steps.py +70 -0
- core/wizard/validation.py +136 -0
- marvisx_cli-0.1.0.dist-info/METADATA +201 -0
- marvisx_cli-0.1.0.dist-info/RECORD +587 -0
- marvisx_cli-0.1.0.dist-info/WHEEL +5 -0
- marvisx_cli-0.1.0.dist-info/entry_points.txt +3 -0
- marvisx_cli-0.1.0.dist-info/licenses/LICENSE +98 -0
- marvisx_cli-0.1.0.dist-info/top_level.txt +3 -0
- migrations/001_initial.sql +33 -0
- migrations/002_tasks.sql +30 -0
- migrations/003_session_management.sql +7 -0
- migrations/004_projects_comments.sql +65 -0
- migrations/005_session_intelligence.sql +15 -0
- migrations/006_settings.sql +12 -0
- migrations/007_task_scoring.sql +12 -0
- migrations/008_cost_tracking.sql +31 -0
- migrations/009_session_card_metrics.sql +3 -0
- migrations/010_monitoring.sql +55 -0
- migrations/012_agent_api.sql +21 -0
- migrations/013_session_complete.sql +8 -0
- migrations/015_pull_requests.sql +43 -0
- migrations/015_pull_requests_down.sql +5 -0
- migrations/016_users_raci.sql +116 -0
- migrations/017_task_cost_entries.sql +87 -0
- migrations/018_agents.sql +73 -0
- migrations/018_agents_down.sql +13 -0
- migrations/019_review_feedback.sql +18 -0
- migrations/020_pr_commit_sha.sql +4 -0
- migrations/021_webhook_events.sql +18 -0
- migrations/022_devx_agent_managed.sql +11 -0
- migrations/022_devx_agent_managed_down.sql +6 -0
- migrations/023_devx_p1_gate.sql +7 -0
- migrations/023_devx_p1_gate_down.sql +3 -0
- migrations/024_chat_messages.sql +16 -0
- migrations/024_pr_conversation_id.sql +8 -0
- migrations/024_task_indexes.sql +21 -0
- migrations/024_task_indexes_down.sql +7 -0
- migrations/025_audit_log.sql +17 -0
- migrations/026_agent_tokens.sql +20 -0
- migrations/027_teams_auth_phase_b.sql +35 -0
- migrations/028_learnings.sql +23 -0
- migrations/029_team_roles.sql +14 -0
- migrations/030_finder_pins.sql +10 -0
- migrations/031_pr_deploy_status.sql +9 -0
- migrations/032_task_reminders.sql +7 -0
- migrations/033_events_retry_count.sql +6 -0
- migrations/033_session_owner.sql +9 -0
- migrations/034_notifications.sql +38 -0
- migrations/035_shared_links.sql +15 -0
- migrations/036_session_index_upgrade.sql +29 -0
- migrations/037_pr_approval.sql +15 -0
- migrations/038_pr_submitted_by.sql +6 -0
- migrations/039_push_subscriptions.sql +17 -0
- migrations/040_semantic_search.sql +16 -0
- migrations/041_workspaces.sql +63 -0
- migrations/042_oidc_providers.sql +24 -0
- migrations/043_ci_checks.sql +31 -0
- migrations/044_agent_metrics.sql +30 -0
- migrations/045_documents_doc_type.sql +5 -0
- migrations/046_salience.sql +13 -0
- migrations/047_seed_missing_agents.sql +6 -0
- migrations/048_fix_agent_paths_roles.sql +5 -0
- migrations/049_agent_role_and_learnings_schema.sql +3 -0
- migrations/050_session_provider.sql +2 -0
- migrations/051_session_launch_profile.sql +4 -0
- migrations/052_session_theme_mode.sql +2 -0
- migrations/052_task_kind.sql +4 -0
- migrations/053_inbox_items.sql +31 -0
- migrations/054_inbox_triage_contract.sql +30 -0
- migrations/055_inbox_topic_treatment.sql +12 -0
- migrations/056_inbox_treatment_read_save.sql +57 -0
- migrations/057_session_theme_mode_backfill.sql +4 -0
- migrations/058_inbox_item_status_lifecycle.sql +13 -0
- migrations/059_inbox_tldr_and_source_scores.sql +18 -0
- migrations/060_newsletter.sql +16 -0
- migrations/061_inbox_redesign.sql +69 -0
- migrations/062_fix_inbox_sources_backfill.sql +37 -0
- migrations/063_task_completion_mode.sql +23 -0
- migrations/064_judge_mode_setting.sql +4 -0
- migrations/065_knowledge_graph_spike.sql +40 -0
- migrations/066_digest_ranking_inputs.sql +10 -0
- migrations/066_kg_artifact_nodes.sql +129 -0
- migrations/067_inbox_digest_selections.sql +28 -0
- migrations/067_kg_temporal.sql +53 -0
- migrations/068_inbox_digest_app_settings.sql +9 -0
- migrations/068_kg_touch_counter.sql +52 -0
- migrations/069_kg_doc_types.sql +117 -0
- migrations/070_digest_ranking_inputs_recovery.sql +3 -0
- migrations/071_inbox_digest_selections_recovery.sql +3 -0
- migrations/072_inbox_digest_app_settings_recovery.sql +3 -0
- migrations/073_kg_cross_project.sql +216 -0
- migrations/073_kg_cross_project_down.sql +77 -0
- migrations/074_kg_infra_types.sql +208 -0
- migrations/074_kg_infra_types_down.sql +80 -0
- migrations/075_kg_file_state_recovery.sql +35 -0
- migrations/075_kg_file_state_recovery_down.sql +5 -0
- migrations/076_kg_watcher_state.sql +33 -0
- migrations/076_kg_watcher_state_down.sql +3 -0
- migrations/077_kg_doc_types_extend.sql +226 -0
- migrations/077_kg_doc_types_extend_down.sql +80 -0
- migrations/078_kg_fts5.sql +102 -0
- migrations/078_kg_fts5_down.sql +14 -0
- migrations/079_kg_missing_indexes.sql +31 -0
- migrations/079_kg_missing_indexes_down.sql +10 -0
- migrations/080_kg_fts5_extended.sql +232 -0
- migrations/080_kg_fts5_extended_down.sql +25 -0
- migrations/081_kg_lens_indexes.sql +9 -0
- migrations/081_kg_lens_indexes_down.sql +3 -0
- migrations/082_kg_pins.sql +26 -0
- migrations/082_kg_pins_down.sql +14 -0
- migrations/083_kg_graph_nodes_degree.sql +20 -0
- migrations/083_kg_graph_nodes_degree_down.sql +15 -0
- migrations/084_drop_legacy_scheduler_tables.sql +58 -0
- migrations/084_drop_legacy_scheduler_tables_down.sql +112 -0
- migrations/085_kg_edge_resolves_to.sql +142 -0
- migrations/085_kg_edge_resolves_to_down.sql +66 -0
- migrations/086_project_status_updates_feed.sql +20 -0
- migrations/086_project_status_updates_feed_down.sql +36 -0
- migrations/087_session_metrics_dual.sql +50 -0
- migrations/087_session_metrics_dual_down.sql +21 -0
- migrations/088_rename_context_pct_legacy.sql +23 -0
- migrations/088_rename_context_pct_legacy_down.sql +8 -0
- migrations/089_session_metrics_equivalent_cost.sql +26 -0
- migrations/089_session_metrics_equivalent_cost_down.sql +11 -0
- migrations/090_kg_inbox_node_type.sql +26 -0
- migrations/090_kg_inbox_node_type_down.sql +20 -0
- migrations/091_kg_inbox_node_type_check.sql +265 -0
- migrations/091_kg_inbox_node_type_check_down.sql +129 -0
- migrations/092_sessions_activity_state_ts.sql +29 -0
- migrations/092_sessions_activity_state_ts_down.sql +14 -0
- migrations/093_sessions_activity_state_column.sql +29 -0
- migrations/093_sessions_activity_state_column_down.sql +10 -0
- migrations/094_ingest_pending.sql +55 -0
- migrations/094_ingest_pending_down.sql +15 -0
- migrations/095_kg_intent_first.sql +77 -0
- migrations/095_kg_intent_first_down.sql +25 -0
- migrations/096_kg_xlsx_artifact_prefix.sql +17 -0
- migrations/096_kg_xlsx_artifact_prefix_down.sql +11 -0
- migrations/097_ingest_change_history.sql +37 -0
- migrations/097_ingest_change_history_down.sql +13 -0
- migrations/098_kg_node_type_business.sql +254 -0
- migrations/098_kg_node_type_business_down.sql +195 -0
- migrations/099_kg_edges_restore_weight.sql +58 -0
- migrations/099_kg_edges_restore_weight_down.sql +12 -0
- migrations/100_kg_enriched_at.sql +25 -0
- migrations/100_kg_enriched_at_down.sql +12 -0
- migrations/101_local_llm_shadow_comparisons.sql +66 -0
- migrations/101_local_llm_shadow_comparisons_down.sql +15 -0
- migrations/102_promote_llm_costs.sql +69 -0
- migrations/102_promote_llm_costs_down.sql +19 -0
- migrations/103_ingest_skipped_log.sql +46 -0
- migrations/103_ingest_skipped_log_down.sql +15 -0
- migrations/120_docs_governance.sql +50 -0
- migrations/120_docs_governance_down.sql +11 -0
- migrations/121_notification_event_fk_cleanup.sql +21 -0
- migrations/121_notification_event_fk_cleanup_down.sql +10 -0
- migrations/122_docs_drift_history.sql +34 -0
- migrations/122_docs_drift_history_down.sql +15 -0
- migrations/123_ingest_parser_waiting_status.sql +69 -0
- migrations/123_ingest_parser_waiting_status_down.sql +69 -0
- migrations/124_heypocket_recordings.sql +63 -0
- migrations/124_heypocket_recordings_down.sql +13 -0
- migrations/125_kg_node_type_record.sql +219 -0
- migrations/125_kg_node_type_record_down.sql +205 -0
- migrations/126_ingest_terminal_upload_source_kind.sql +69 -0
- migrations/126_ingest_terminal_upload_source_kind_down.sql +69 -0
- migrations/127_brain_v1_substrate.sql +200 -0
- migrations/127_brain_v1_substrate_down.sql +32 -0
- migrations/128_brain_drift_signals.sql +157 -0
- migrations/128_brain_drift_signals_down.sql +23 -0
- migrations/129_brain_memory_operations.sql +232 -0
- migrations/129_brain_memory_operations_down.sql +27 -0
- migrations/130_brain_findings.sql +258 -0
- migrations/130_brain_findings_down.sql +29 -0
- migrations/132_kg_pr_modifies.sql +242 -0
- migrations/132_kg_pr_modifies_down.sql +99 -0
- migrations/133_brain_v1_2_direction_schema.sql +476 -0
- migrations/133_brain_v1_2_direction_schema_down.sql +273 -0
- migrations/134_brain_journal_narrative_polished.sql +8 -0
- migrations/134_brain_journal_narrative_polished_down.sql +6 -0
- migrations/135_kg_edges_provider.sql +21 -0
- migrations/136_documents_fts.sql +56 -0
- migrations/137_promote_llm_costs.sql +59 -0
- migrations/137_promote_llm_costs_down.sql +19 -0
- migrations/138_ingest_api_keys.sql +39 -0
- migrations/138_ingest_api_keys_down.sql +8 -0
- migrations/139_ingest_pending_ingress.sql +91 -0
- migrations/139_ingest_pending_ingress_down.sql +73 -0
- migrations/140_ingest_idempotency_quota.sql +45 -0
- migrations/140_ingest_idempotency_quota_down.sql +9 -0
- migrations/141_ingest_pending_metadata.sql +16 -0
- migrations/141_ingest_pending_metadata_down.sql +7 -0
- migrations/142_llm_function_config.sql +36 -0
- migrations/142_llm_function_config_down.sql +8 -0
- migrations/143_kg_code_embeddings.sql +25 -0
- migrations/143_kg_code_embeddings_down.sql +5 -0
- migrations/__init__.py +4 -0
- projects/_template/project.yaml +46 -0
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
# v2.0.0 - 2026-05-27 - S1 F1.8: thin adapter over use_cases.graph (visibility + rate-limit + share kept at adapter)
|
|
2
|
+
# v1.8.0 - 2026-04-17 - P2: 5 new UX endpoints (landing/pins/resolve/overview/orphans) + slowapi + filter_visible_edges
|
|
3
|
+
# v1.7.0 - 2026-04-16 - H2: graph ACL — get_visible_projects check on ?project=X endpoints
|
|
4
|
+
# v1.5.0 - 2026-04-14 - KG Fase 2: cross-project (project filter + edge_types + BFS cycle detection)
|
|
5
|
+
# v1.4.0 - 2026-04-14 - KG Fase 1f: impact + context + pattern endpoints
|
|
6
|
+
"""HTTP adapter for the Knowledge Graph domain (S1 collapse-runtime).
|
|
7
|
+
|
|
8
|
+
This router is a thin transport adapter. All query orchestration + validation +
|
|
9
|
+
error mapping lives in :mod:`core.api.use_cases.graph` (pure, fastapi-free). Each
|
|
10
|
+
handler resolves identity into a :class:`CallerContext`, calls the use_case inside
|
|
11
|
+
``try/except`` -> ``to_http_legacy``, and owns the transport concerns.
|
|
12
|
+
|
|
13
|
+
Endpoints:
|
|
14
|
+
GET /api/v1/graph/neighbors/{node_id} — live view (Fase 1a)
|
|
15
|
+
GET /api/v1/graph/neighbors/{node_id}?rank=X — ranked (Fase 1b)
|
|
16
|
+
GET /api/v1/graph/neighbors/{node_id}?as_of=<iso> — time-travel (Fase 1d)
|
|
17
|
+
GET /api/v1/graph/neighbors/{node_id}?project=X&edge_types=Y&edge_types=Z — cross-project (Fase 2)
|
|
18
|
+
GET /api/v1/graph/hotspots?window=30d&limit=20 — churn hotspots (Fase 1e)
|
|
19
|
+
GET /api/v1/graph/impact/{node_id}?depth=2&limit=50 — reverse impact (Fase 1f)
|
|
20
|
+
GET /api/v1/graph/impact/{id}?project=X&edge_types=mentions — multi-hop cross-project (Fase 2)
|
|
21
|
+
GET /api/v1/graph/context/{node_id} — rationale chain (Fase 1f)
|
|
22
|
+
GET /api/v1/graph/pattern?scope=<path|module> — applicable learnings (Fase 1f)
|
|
23
|
+
GET /api/v1/graph/function-share/{qualified_name} — share + KG context bundle (vision audit P1)
|
|
24
|
+
--- P2 UX endpoints ---
|
|
25
|
+
GET /api/v1/graph/landing — hotspots + recent + pins bundle
|
|
26
|
+
GET /api/v1/graph/pins — list user pins (user-scoped)
|
|
27
|
+
POST /api/v1/graph/pins — upsert pin (Bearer required, CSRF via Bearer)
|
|
28
|
+
DELETE /api/v1/graph/pins/{node_id} — delete pin
|
|
29
|
+
GET /api/v1/graph/resolve?path=... — resolve file path to node_id
|
|
30
|
+
GET /api/v1/graph/overview?level=macro|module — LOD overview graph
|
|
31
|
+
GET /api/v1/graph/orphans?scope=... — orphan files (LEFT JOIN anti-join)
|
|
32
|
+
GET /api/v1/graph/cosmo — Cosmo canvas dataset
|
|
33
|
+
|
|
34
|
+
Read-only — uses get_db (read-only pool). Auth via the standard dual
|
|
35
|
+
cookie+Bearer dependency `get_current_user_or_agent`.
|
|
36
|
+
|
|
37
|
+
CSRF on pin writes: POST/DELETE /graph/pins require Authorization: Bearer <token>.
|
|
38
|
+
The cookie-based session is not accepted for mutations (Bearer-required approach).
|
|
39
|
+
|
|
40
|
+
STAYS IN THE ADAPTER (transport concerns):
|
|
41
|
+
* VISIBILITY enforcement (use_cases.graph DECISION B): ``get_visible_projects``
|
|
42
|
+
is resolved here AND the gate fires here, because the per-endpoint outcome is
|
|
43
|
+
test-pinned to a plain-string body — project-scoped reads → 403
|
|
44
|
+
"Project not accessible" (test_kg_security_h2_h3), oracle reads → 404
|
|
45
|
+
"Not found". For resolve/overview/orphans the resolved ``visible_projects`` is
|
|
46
|
+
PASSED INTO the use_case (DECISION 1), which raises NotFoundError on a miss.
|
|
47
|
+
``filter_visible_edges`` (overview macro RBAC) is imported here and INJECTED
|
|
48
|
+
into the use_case: it imports fastapi and the overview test pins it to THIS
|
|
49
|
+
namespace (``patch("api.routers.graph.filter_visible_edges")``).
|
|
50
|
+
* RATE LIMITS (@limiter.limit) — slowapi decorators stay on the handlers.
|
|
51
|
+
* SHARE-URL side effect for ``function-share`` (use_cases.graph DECISION C):
|
|
52
|
+
role enforcement + signed URL via ``share_links`` (imports fastapi) + the
|
|
53
|
+
on-disk preview read stay here; the pure KG-context pieces live in the use_case.
|
|
54
|
+
* PIN-WRITE TTLCache invalidation (the caches are module-level transport state).
|
|
55
|
+
|
|
56
|
+
ERROR BOUNDARY: graph's HTTPException detail bodies are rich plain strings (part
|
|
57
|
+
of the agent-facing contract, NOT the structured ``{code,message}`` shape). The
|
|
58
|
+
use_case raises :class:`ServiceError` whose ``message`` is byte-identical to the
|
|
59
|
+
legacy detail; ``to_http_legacy`` re-raises ``HTTPException(status, message)`` so
|
|
60
|
+
bodies are unchanged.
|
|
61
|
+
"""
|
|
62
|
+
from __future__ import annotations
|
|
63
|
+
|
|
64
|
+
import logging
|
|
65
|
+
import re
|
|
66
|
+
from typing import Literal
|
|
67
|
+
|
|
68
|
+
import aiosqlite
|
|
69
|
+
from cachetools import TTLCache
|
|
70
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
71
|
+
|
|
72
|
+
from core.api.db import get_db, get_write_db
|
|
73
|
+
from core.api.models import UserInfo
|
|
74
|
+
from core.api.models.graph import GraphCapabilities, RankType
|
|
75
|
+
from core.api.models.graph_ux import (
|
|
76
|
+
LandingBundle,
|
|
77
|
+
OrphansBundle,
|
|
78
|
+
OverviewBundle,
|
|
79
|
+
PinIn,
|
|
80
|
+
PinOut,
|
|
81
|
+
ResolveOut,
|
|
82
|
+
)
|
|
83
|
+
from core.api.models.graph_cosmo import GraphCosmoOut
|
|
84
|
+
from core.api.rate_limit import limiter
|
|
85
|
+
from core.api.security import get_current_user_or_agent
|
|
86
|
+
from core.api.services import graph_cosmo_service
|
|
87
|
+
from core.api.use_cases import graph as uc
|
|
88
|
+
from core.api.use_cases._context import CallerContext
|
|
89
|
+
from core.api.use_cases._errors import ServiceError
|
|
90
|
+
from core.api.visibility import filter_visible_edges, get_visible_projects
|
|
91
|
+
from core.api.services.share_links import (
|
|
92
|
+
create_shared_link_record,
|
|
93
|
+
enforce_workspace_share_role,
|
|
94
|
+
public_repo_path,
|
|
95
|
+
stored_repo_path,
|
|
96
|
+
validate_repo_path,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
logger = logging.getLogger(__name__)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def to_http_legacy(err: ServiceError) -> HTTPException:
|
|
103
|
+
"""Map a domain ``ServiceError`` to an ``HTTPException`` with the LEGACY body.
|
|
104
|
+
|
|
105
|
+
Graph endpoints have always returned a plain-string ``detail`` (the rich,
|
|
106
|
+
agent-facing message). The structured ``{code,message}`` body produced by
|
|
107
|
+
``routers/_adapter.to_http`` would change every response and break the
|
|
108
|
+
string-substring assertions (test_kg_security_h2_h3, test_kg_share_function).
|
|
109
|
+
So we preserve the plain-string body here.
|
|
110
|
+
"""
|
|
111
|
+
return HTTPException(status_code=err.http_status, detail=err.message)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# Relation types backed by the latest graph_edges CHECK constraint (mig 132).
|
|
115
|
+
# Kept here (not imported from graph_service) because FastAPI Literal typing
|
|
116
|
+
# requires a literal value set for static introspection. Keep synchronized
|
|
117
|
+
# with graph_service.EDGE_TYPES + mcp-pir edgeTypeEnum + docs kg-fetcher.ts +
|
|
118
|
+
# kg-schema-snapshot.json + latest migration CHECK (see scripts/_drift_check.py
|
|
119
|
+
# check B for the 6-source sync mandate).
|
|
120
|
+
EdgeType = Literal[
|
|
121
|
+
"calls", "imports", "defines",
|
|
122
|
+
"produces", "contains",
|
|
123
|
+
"describes", "documents", "cites", "applies_to",
|
|
124
|
+
"depends_on", "mentions", "refers_to", "shares_tag", "similar_to",
|
|
125
|
+
"resolves_to", # Phase 7.2: module stub -> file canonical bridge
|
|
126
|
+
"modifies", # KG PR-Impact (mig 132): pr_artifact -> function_artifact
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
PROJECT_SLUG_QUERY_PATTERN = r"^[a-z0-9][a-z0-9&\-]+$"
|
|
130
|
+
|
|
131
|
+
router = APIRouter(prefix="/api/v1/graph", tags=["graph"])
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@router.get("/neighbors/{node_id}")
|
|
135
|
+
async def graph_neighbors(
|
|
136
|
+
node_id: str,
|
|
137
|
+
relation: str | None = Query(
|
|
138
|
+
None, description="Deprecated (Fase 1). Prefer `edge_types`."
|
|
139
|
+
),
|
|
140
|
+
edge_types: list[EdgeType] | None = Query(
|
|
141
|
+
None,
|
|
142
|
+
description="Fase 2: repeatable filter on edge relations. "
|
|
143
|
+
"Usage: ?edge_types=calls&edge_types=depends_on — union semantics. "
|
|
144
|
+
"Overrides `relation` when both are set.",
|
|
145
|
+
),
|
|
146
|
+
project: str | None = Query(
|
|
147
|
+
None,
|
|
148
|
+
max_length=50,
|
|
149
|
+
pattern=PROJECT_SLUG_QUERY_PATTERN,
|
|
150
|
+
description="Fase 2: filter neighbours by project_id (ARCH-01 "
|
|
151
|
+
"project_scope=source). Pass slug like 'marvisx' or 'c&i-normativa'.",
|
|
152
|
+
),
|
|
153
|
+
direction: Literal["incoming", "outgoing", "both"] = Query(
|
|
154
|
+
"both",
|
|
155
|
+
description="incoming = who points to this; outgoing = what this points to",
|
|
156
|
+
),
|
|
157
|
+
limit: int = Query(50, ge=1, le=200),
|
|
158
|
+
rank: RankType = Query(
|
|
159
|
+
"none",
|
|
160
|
+
description="Ranker strategy: none (raw) or suspect_write (Fase 1b)",
|
|
161
|
+
),
|
|
162
|
+
as_of: str | None = Query(
|
|
163
|
+
None,
|
|
164
|
+
description="ISO timestamp for time-travel query (YYYY-MM-DD[ HH:MM:SS]). "
|
|
165
|
+
"When set, returns the graph state as it was at that moment. "
|
|
166
|
+
"When omitted, returns the live view (excludes deprecated nodes/edges).",
|
|
167
|
+
max_length=32,
|
|
168
|
+
pattern=r"^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}:\d{2}(\.\d+)?)?Z?$",
|
|
169
|
+
),
|
|
170
|
+
user: UserInfo = Depends(get_current_user_or_agent),
|
|
171
|
+
db: aiosqlite.Connection = Depends(get_db),
|
|
172
|
+
) -> dict:
|
|
173
|
+
"""Return neighbours of a graph node, optionally ranked / time-travelled / cross-project.
|
|
174
|
+
|
|
175
|
+
422 on malformed `node_id`, `relation`, `edge_types`, `project`, `rank`, or `as_of`.
|
|
176
|
+
404 if the node does not exist in the graph (at `as_of` if specified).
|
|
177
|
+
"""
|
|
178
|
+
# H2: visibility check — only when caller scopes to a specific project
|
|
179
|
+
if project:
|
|
180
|
+
visible = await get_visible_projects(db, user)
|
|
181
|
+
if visible is not None and project not in visible:
|
|
182
|
+
raise HTTPException(status_code=403, detail="Project not accessible")
|
|
183
|
+
|
|
184
|
+
ctx = CallerContext.from_user_info(user, is_human_session=False)
|
|
185
|
+
try:
|
|
186
|
+
return await uc.graph_neighbors(
|
|
187
|
+
ctx, db,
|
|
188
|
+
node_id=node_id, relation=relation, edge_types=edge_types,
|
|
189
|
+
project=project, direction=direction, limit=limit, rank=rank, as_of=as_of,
|
|
190
|
+
)
|
|
191
|
+
except ServiceError as e:
|
|
192
|
+
raise to_http_legacy(e)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@router.get("/hotspots")
|
|
196
|
+
async def graph_hotspots(
|
|
197
|
+
window: Literal["7d", "30d", "total"] = Query(
|
|
198
|
+
"30d",
|
|
199
|
+
description="Rolling window: 7d/30d for recent churn, total for all-time.",
|
|
200
|
+
),
|
|
201
|
+
limit: int = Query(20, ge=1, le=100),
|
|
202
|
+
type_filter: Literal["function", "file", "all"] = Query(
|
|
203
|
+
"file",
|
|
204
|
+
description="Filter by node type (file = default, all = function+file).",
|
|
205
|
+
),
|
|
206
|
+
project: str | None = Query(
|
|
207
|
+
None,
|
|
208
|
+
max_length=50,
|
|
209
|
+
pattern=PROJECT_SLUG_QUERY_PATTERN,
|
|
210
|
+
description="Fase 2: filter hotspots to this project (default: all projects).",
|
|
211
|
+
),
|
|
212
|
+
user: UserInfo = Depends(get_current_user_or_agent),
|
|
213
|
+
db: aiosqlite.Connection = Depends(get_db),
|
|
214
|
+
) -> dict:
|
|
215
|
+
"""Top N hotspot nodes ordered by touch count in the chosen window.
|
|
216
|
+
|
|
217
|
+
422 on malformed `window`, `type_filter`, or `project`.
|
|
218
|
+
"""
|
|
219
|
+
# H2: visibility check — only when caller scopes to a specific project
|
|
220
|
+
if project:
|
|
221
|
+
visible = await get_visible_projects(db, user)
|
|
222
|
+
if visible is not None and project not in visible:
|
|
223
|
+
raise HTTPException(status_code=403, detail="Project not accessible")
|
|
224
|
+
|
|
225
|
+
ctx = CallerContext.from_user_info(user, is_human_session=False)
|
|
226
|
+
try:
|
|
227
|
+
return await uc.graph_hotspots(
|
|
228
|
+
ctx, db,
|
|
229
|
+
window=window, limit=limit, type_filter=type_filter, project=project,
|
|
230
|
+
)
|
|
231
|
+
except ServiceError as e:
|
|
232
|
+
raise to_http_legacy(e)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
# Fase 1f — graph_impact / graph_context / graph_pattern
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@router.get("/impact/{node_id}")
|
|
241
|
+
async def graph_impact_endpoint(
|
|
242
|
+
node_id: str,
|
|
243
|
+
depth: int = Query(
|
|
244
|
+
2, ge=1, le=5,
|
|
245
|
+
description="BFS hops for transitive caller discovery (1 = direct only). "
|
|
246
|
+
"Fase 2 caps at min(requested, 4) for multi-hop safety.",
|
|
247
|
+
),
|
|
248
|
+
limit: int = Query(
|
|
249
|
+
50, ge=1, le=200,
|
|
250
|
+
description="Max transitive callers returned (hard-capped at 200).",
|
|
251
|
+
),
|
|
252
|
+
edge_types: list[EdgeType] | None = Query(
|
|
253
|
+
None,
|
|
254
|
+
description="Fase 2: repeatable filter on edge relations. "
|
|
255
|
+
"Default (None) walks along `calls` edges. Pass e.g. "
|
|
256
|
+
"?edge_types=calls&edge_types=depends_on&edge_types=mentions for "
|
|
257
|
+
"cross-project multi-hop impact.",
|
|
258
|
+
),
|
|
259
|
+
project: str | None = Query(
|
|
260
|
+
None,
|
|
261
|
+
max_length=50,
|
|
262
|
+
pattern=PROJECT_SLUG_QUERY_PATTERN,
|
|
263
|
+
description="Fase 2: scope walk to source nodes belonging to this project.",
|
|
264
|
+
),
|
|
265
|
+
user: UserInfo = Depends(get_current_user_or_agent),
|
|
266
|
+
db: aiosqlite.Connection = Depends(get_db),
|
|
267
|
+
) -> dict:
|
|
268
|
+
"""Reverse impact analysis for a graph node.
|
|
269
|
+
|
|
270
|
+
422 on malformed `node_id`, `edge_types`, or `project`.
|
|
271
|
+
404 if the node does not exist.
|
|
272
|
+
"""
|
|
273
|
+
# H2: visibility check — only when caller scopes to a specific project
|
|
274
|
+
if project:
|
|
275
|
+
visible = await get_visible_projects(db, user)
|
|
276
|
+
if visible is not None and project not in visible:
|
|
277
|
+
raise HTTPException(status_code=403, detail="Project not accessible")
|
|
278
|
+
|
|
279
|
+
ctx = CallerContext.from_user_info(user, is_human_session=False)
|
|
280
|
+
try:
|
|
281
|
+
return await uc.graph_impact(
|
|
282
|
+
ctx, db,
|
|
283
|
+
node_id=node_id, depth=depth, limit=limit,
|
|
284
|
+
edge_types=edge_types, project=project,
|
|
285
|
+
)
|
|
286
|
+
except ServiceError as e:
|
|
287
|
+
raise to_http_legacy(e)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@router.get("/context/{node_id}")
|
|
291
|
+
async def graph_context_endpoint(
|
|
292
|
+
node_id: str,
|
|
293
|
+
per_category_limit: int = Query(
|
|
294
|
+
5, ge=1, le=20,
|
|
295
|
+
description="Max items per category (commits/prs/tasks/handoffs/learnings).",
|
|
296
|
+
),
|
|
297
|
+
project: str | None = Query(
|
|
298
|
+
None,
|
|
299
|
+
max_length=50,
|
|
300
|
+
pattern=PROJECT_SLUG_QUERY_PATTERN,
|
|
301
|
+
description="Fase 2: scope source node to this project (ARCH-01). "
|
|
302
|
+
"The chain nodes can still cross projects for multi-hop discovery.",
|
|
303
|
+
),
|
|
304
|
+
user: UserInfo = Depends(get_current_user_or_agent),
|
|
305
|
+
db: aiosqlite.Connection = Depends(get_db),
|
|
306
|
+
) -> dict:
|
|
307
|
+
"""Trace the rationale chain for a node.
|
|
308
|
+
|
|
309
|
+
422 on malformed `node_id` or `project`.
|
|
310
|
+
404 if the node does not exist.
|
|
311
|
+
"""
|
|
312
|
+
# H2: visibility check — only when caller scopes to a specific project
|
|
313
|
+
if project:
|
|
314
|
+
visible = await get_visible_projects(db, user)
|
|
315
|
+
if visible is not None and project not in visible:
|
|
316
|
+
raise HTTPException(status_code=403, detail="Project not accessible")
|
|
317
|
+
|
|
318
|
+
ctx = CallerContext.from_user_info(user, is_human_session=False)
|
|
319
|
+
try:
|
|
320
|
+
return await uc.graph_context(
|
|
321
|
+
ctx, db,
|
|
322
|
+
node_id=node_id, per_category_limit=per_category_limit, project=project,
|
|
323
|
+
)
|
|
324
|
+
except ServiceError as e:
|
|
325
|
+
raise to_http_legacy(e)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@router.get("/pattern")
|
|
329
|
+
async def graph_pattern_endpoint(
|
|
330
|
+
scope: str = Query(
|
|
331
|
+
..., max_length=256,
|
|
332
|
+
description="File path (api/db.py), module dotted name (api.db), or full node id.",
|
|
333
|
+
),
|
|
334
|
+
limit: int = Query(
|
|
335
|
+
20, ge=1, le=100,
|
|
336
|
+
description="Max learnings returned.",
|
|
337
|
+
),
|
|
338
|
+
project: str | None = Query(
|
|
339
|
+
None,
|
|
340
|
+
max_length=50,
|
|
341
|
+
pattern=PROJECT_SLUG_QUERY_PATTERN,
|
|
342
|
+
description="Fase 2: filter learnings to those whose `applies_to` target "
|
|
343
|
+
"lives in this project.",
|
|
344
|
+
),
|
|
345
|
+
user: UserInfo = Depends(get_current_user_or_agent),
|
|
346
|
+
db: aiosqlite.Connection = Depends(get_db),
|
|
347
|
+
) -> dict:
|
|
348
|
+
"""Learnings applicable to a scope (file, module, or specific function).
|
|
349
|
+
|
|
350
|
+
422 on malformed `scope` or `project`.
|
|
351
|
+
"""
|
|
352
|
+
# H2: visibility check — only when caller scopes to a specific project
|
|
353
|
+
if project:
|
|
354
|
+
visible = await get_visible_projects(db, user)
|
|
355
|
+
if visible is not None and project not in visible:
|
|
356
|
+
raise HTTPException(status_code=403, detail="Project not accessible")
|
|
357
|
+
|
|
358
|
+
ctx = CallerContext.from_user_info(user, is_human_session=False)
|
|
359
|
+
try:
|
|
360
|
+
return await uc.graph_pattern(
|
|
361
|
+
ctx, db, scope=scope, limit=limit, project=project,
|
|
362
|
+
)
|
|
363
|
+
except ServiceError as e:
|
|
364
|
+
raise to_http_legacy(e)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# ---------------------------------------------------------------------------
|
|
368
|
+
# capabilities — KG schema metadata for agent discovery (Phase 7.x Pilastro 5)
|
|
369
|
+
# ---------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@router.get(
|
|
373
|
+
"/capabilities",
|
|
374
|
+
response_model=GraphCapabilities,
|
|
375
|
+
summary="KG schema metadata for agent discovery",
|
|
376
|
+
)
|
|
377
|
+
async def get_capabilities(
|
|
378
|
+
user: UserInfo = Depends(get_current_user_or_agent),
|
|
379
|
+
db: aiosqlite.Connection = Depends(get_db),
|
|
380
|
+
) -> GraphCapabilities:
|
|
381
|
+
"""Returns valid edge_types, node_kinds, node_prefixes + schema_version.
|
|
382
|
+
|
|
383
|
+
Agents should call this on cold-start (before building graph_* queries)
|
|
384
|
+
to discover the current taxonomy instead of hardcoding enums.
|
|
385
|
+
|
|
386
|
+
Security (deepen section 9): mcp_version NOT exposed (no fingerprinting).
|
|
387
|
+
"""
|
|
388
|
+
ctx = CallerContext.from_user_info(user, is_human_session=False)
|
|
389
|
+
try:
|
|
390
|
+
return await uc.get_capabilities(ctx, db)
|
|
391
|
+
except ServiceError as e:
|
|
392
|
+
raise to_http_legacy(e)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# ---------------------------------------------------------------------------
|
|
396
|
+
# share_function — daily-QoL tool combining share_file + KG context bundle
|
|
397
|
+
# ---------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
SHARE_FUNCTION_DEFAULT_HOURS: int = uc.SHARE_FUNCTION_DEFAULT_HOURS
|
|
400
|
+
PREVIEW_LINES: int = uc.PREVIEW_LINES
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _read_file_preview(
|
|
404
|
+
file_path: str, line_number: int | None, n_lines: int = PREVIEW_LINES
|
|
405
|
+
) -> dict:
|
|
406
|
+
"""Read `n_lines` from `file_path` starting at `line_number` (1-based).
|
|
407
|
+
|
|
408
|
+
Transport concern: ``validate_repo_path`` (share_links) imports fastapi, and
|
|
409
|
+
reading off disk is an I/O side effect — kept in the adapter. Returns
|
|
410
|
+
`{start_line, end_line, lines: [str]}` or `{error: ...}` so the rest of the
|
|
411
|
+
bundle survives.
|
|
412
|
+
"""
|
|
413
|
+
try:
|
|
414
|
+
target = validate_repo_path(file_path)
|
|
415
|
+
except HTTPException as exc:
|
|
416
|
+
return {"error": f"path validation failed: {exc.detail}"}
|
|
417
|
+
if not target.is_file():
|
|
418
|
+
return {"error": "file not found on disk"}
|
|
419
|
+
try:
|
|
420
|
+
content = target.read_text(encoding="utf-8", errors="replace")
|
|
421
|
+
except OSError as exc:
|
|
422
|
+
return {"error": f"read failed: {exc}"}
|
|
423
|
+
|
|
424
|
+
all_lines = content.splitlines()
|
|
425
|
+
total = len(all_lines)
|
|
426
|
+
start = max(1, int(line_number or 1))
|
|
427
|
+
# Clamp start to file length to avoid empty preview on stale line_number.
|
|
428
|
+
if start > total:
|
|
429
|
+
start = max(1, total - n_lines + 1)
|
|
430
|
+
end = min(total, start + n_lines - 1)
|
|
431
|
+
# Slice uses 0-based indexing.
|
|
432
|
+
lines = all_lines[start - 1 : end]
|
|
433
|
+
return {
|
|
434
|
+
"start_line": start,
|
|
435
|
+
"end_line": end,
|
|
436
|
+
"total_lines": total,
|
|
437
|
+
"lines": lines,
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
@router.get("/function-share/{qualified_name:path}")
|
|
442
|
+
async def share_function_endpoint(
|
|
443
|
+
qualified_name: str,
|
|
444
|
+
include: str | None = Query(
|
|
445
|
+
None,
|
|
446
|
+
max_length=128,
|
|
447
|
+
description="CSV list of blocks to include. Allowed: "
|
|
448
|
+
"preview,neighbors,context,hotspot. Omit or leave empty for all four.",
|
|
449
|
+
),
|
|
450
|
+
hours: int = Query(
|
|
451
|
+
SHARE_FUNCTION_DEFAULT_HOURS,
|
|
452
|
+
ge=1, le=720,
|
|
453
|
+
description="Hours until the share URL expires (1-720, default 24).",
|
|
454
|
+
),
|
|
455
|
+
current_user: UserInfo = Depends(get_current_user_or_agent),
|
|
456
|
+
db: aiosqlite.Connection = Depends(get_write_db),
|
|
457
|
+
) -> dict:
|
|
458
|
+
"""Hand off a function with full KG context in a single call.
|
|
459
|
+
|
|
460
|
+
Given a Python `qualified_name` (e.g. `api.db.get_db`), returns share_url +
|
|
461
|
+
preview + neighbors + context + hotspot. See use_cases.graph DECISION C for
|
|
462
|
+
the transport/pure split.
|
|
463
|
+
|
|
464
|
+
422 on malformed `qualified_name` or unknown `include` tokens.
|
|
465
|
+
404 if no `type='function'` node matches `qualified_name` in the live graph.
|
|
466
|
+
"""
|
|
467
|
+
ctx = CallerContext.from_user_info(current_user, is_human_session=False)
|
|
468
|
+
|
|
469
|
+
# 1. Validate qualified_name + includes (pure → use_case, 422 via ServiceError).
|
|
470
|
+
try:
|
|
471
|
+
uc.validate_qualified_name(qualified_name)
|
|
472
|
+
include_list = uc.parse_include_csv(include)
|
|
473
|
+
except ValueError as e:
|
|
474
|
+
# parse_include_csv raises ValueError (pure helper) — map to 422.
|
|
475
|
+
raise HTTPException(status_code=422, detail=str(e))
|
|
476
|
+
except ServiceError as e:
|
|
477
|
+
raise to_http_legacy(e)
|
|
478
|
+
|
|
479
|
+
# 2. Enforce workspace-share role (transport — share_links imports fastapi).
|
|
480
|
+
enforce_workspace_share_role(current_user)
|
|
481
|
+
|
|
482
|
+
# 3. Lookup the function node (pure → use_case, 404 via ServiceError).
|
|
483
|
+
try:
|
|
484
|
+
node_row = await uc.lookup_function_node(db, qualified_name=qualified_name)
|
|
485
|
+
except ServiceError as e:
|
|
486
|
+
raise to_http_legacy(e)
|
|
487
|
+
|
|
488
|
+
node_id = node_row["id"]
|
|
489
|
+
file_path = node_row["file_path"]
|
|
490
|
+
line_number = node_row["line_number"]
|
|
491
|
+
|
|
492
|
+
# 4. Generate the share URL (transport side effect — primary pull-factor).
|
|
493
|
+
# Pattern mirrors finder._create_share_link_impl workspace branch.
|
|
494
|
+
try:
|
|
495
|
+
target = validate_repo_path(file_path)
|
|
496
|
+
except HTTPException as exc:
|
|
497
|
+
raise HTTPException(
|
|
498
|
+
status_code=404,
|
|
499
|
+
detail=(
|
|
500
|
+
f"File path for {qualified_name!r} rejected by validate_repo_path: {exc.detail}. "
|
|
501
|
+
f"Recorded file_path in graph_nodes: {file_path!r}. "
|
|
502
|
+
"Reason: the indexed path resolves outside the allowed share root, or contains traversal tokens. "
|
|
503
|
+
"Fix: re-run the indexer with the correct repo_share_root, or fix the graph_nodes row manually."
|
|
504
|
+
),
|
|
505
|
+
) from exc
|
|
506
|
+
if not target.is_file():
|
|
507
|
+
raise HTTPException(
|
|
508
|
+
status_code=404,
|
|
509
|
+
detail=(
|
|
510
|
+
f"File {file_path!r} no longer exists on disk. "
|
|
511
|
+
"Reason: the graph_nodes row is stale — the file was moved or deleted since indexing. "
|
|
512
|
+
"Fix: re-run scripts/populate_knowledge_graph.py to refresh, "
|
|
513
|
+
"or locate the new path with `git log --diff-filter=D --name-only` (deleted) or mcp__pir__search."
|
|
514
|
+
),
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
share_record = await create_shared_link_record(
|
|
518
|
+
stored_path=stored_repo_path(file_path),
|
|
519
|
+
public_path=public_repo_path(file_path),
|
|
520
|
+
current_user=current_user,
|
|
521
|
+
db=db,
|
|
522
|
+
hours=hours,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
response: dict = {
|
|
526
|
+
"qualified_name": qualified_name,
|
|
527
|
+
"node_id": node_id,
|
|
528
|
+
"file_path": file_path,
|
|
529
|
+
"line_number": line_number,
|
|
530
|
+
"share_url": share_record["url"],
|
|
531
|
+
"share_token": share_record["token"],
|
|
532
|
+
"share_expires_at": share_record["expires_at"],
|
|
533
|
+
"includes": include_list,
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
# 5. Preview block (transport — disk read kept in the adapter).
|
|
537
|
+
if "preview" in include_list:
|
|
538
|
+
response["preview"] = _read_file_preview(
|
|
539
|
+
file_path=file_path, line_number=line_number,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
# 6. Pure KG-context blocks (neighbors / context / hotspot → use_case).
|
|
543
|
+
try:
|
|
544
|
+
response.update(
|
|
545
|
+
await uc.build_share_function_blocks(
|
|
546
|
+
db, node_id=node_id, node_row=node_row, include_list=include_list,
|
|
547
|
+
)
|
|
548
|
+
)
|
|
549
|
+
except ServiceError as e:
|
|
550
|
+
raise to_http_legacy(e)
|
|
551
|
+
|
|
552
|
+
return response
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
# ---------------------------------------------------------------------------
|
|
556
|
+
# P2 UX endpoints — landing / pins / resolve / overview / orphans
|
|
557
|
+
# ---------------------------------------------------------------------------
|
|
558
|
+
|
|
559
|
+
# Split TTL caches (transport state — kept in the adapter):
|
|
560
|
+
# hotspots_cache[workspace_id] — 60s TTL, expensive query
|
|
561
|
+
# recent_cache[workspace_id] — 60s TTL, medium query
|
|
562
|
+
# pins_cache[user_id] — 60s TTL, cheap query, invalidated on write
|
|
563
|
+
_hotspots_cache: TTLCache = TTLCache(maxsize=128, ttl=60)
|
|
564
|
+
_recent_cache: TTLCache = TTLCache(maxsize=128, ttl=60)
|
|
565
|
+
_pins_cache: TTLCache = TTLCache(maxsize=512, ttl=60)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
# --- Landing ---
|
|
569
|
+
|
|
570
|
+
@router.get("/landing", response_model=LandingBundle, summary="KG landing bundle")
|
|
571
|
+
@limiter.limit("60/minute")
|
|
572
|
+
async def graph_landing(
|
|
573
|
+
request: Request,
|
|
574
|
+
user: UserInfo = Depends(get_current_user_or_agent),
|
|
575
|
+
db: aiosqlite.Connection = Depends(get_db),
|
|
576
|
+
) -> LandingBundle:
|
|
577
|
+
"""Aggregated landing bundle: top-10 hotspots (30d) + last-20 recent artifacts + saved pins.
|
|
578
|
+
|
|
579
|
+
Uses split TTLCache (60s) keyed by workspace_id for hotspots/recent, and
|
|
580
|
+
user_id for pins. All three slices are merged at request time.
|
|
581
|
+
|
|
582
|
+
Pins on soft-deleted nodes are excluded (JOIN with graph_nodes WHERE deprecated_at IS NULL).
|
|
583
|
+
|
|
584
|
+
Rate limit: 60/minute/user.
|
|
585
|
+
"""
|
|
586
|
+
ws_id = getattr(user, "workspace_id", "ws_default") or "ws_default"
|
|
587
|
+
user_id = getattr(user, "user_id", "unknown")
|
|
588
|
+
|
|
589
|
+
hotspots_key = f"hotspots:{ws_id}"
|
|
590
|
+
recent_key = f"recent:{ws_id}"
|
|
591
|
+
pins_key = f"pins:{user_id}"
|
|
592
|
+
|
|
593
|
+
ctx = CallerContext.from_user_info(user, is_human_session=False)
|
|
594
|
+
try:
|
|
595
|
+
bundle, hotspots, recent, saved = await uc.graph_landing(
|
|
596
|
+
ctx, db,
|
|
597
|
+
workspace_id=ws_id, user_id=user_id,
|
|
598
|
+
hotspots_cached=_hotspots_cache.get(hotspots_key),
|
|
599
|
+
recent_cached=_recent_cache.get(recent_key),
|
|
600
|
+
pins_cached=_pins_cache.get(pins_key),
|
|
601
|
+
)
|
|
602
|
+
except ServiceError as e:
|
|
603
|
+
raise to_http_legacy(e)
|
|
604
|
+
|
|
605
|
+
# Repopulate caches with the (possibly freshly-computed) slices.
|
|
606
|
+
_hotspots_cache[hotspots_key] = hotspots
|
|
607
|
+
_recent_cache[recent_key] = recent
|
|
608
|
+
_pins_cache[pins_key] = saved
|
|
609
|
+
return bundle
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
# --- List pins ---
|
|
613
|
+
|
|
614
|
+
@router.get("/pins", response_model=list[PinOut], summary="List user KG pins")
|
|
615
|
+
@limiter.limit("120/minute")
|
|
616
|
+
async def list_graph_pins(
|
|
617
|
+
request: Request,
|
|
618
|
+
user: UserInfo = Depends(get_current_user_or_agent),
|
|
619
|
+
db: aiosqlite.Connection = Depends(get_db),
|
|
620
|
+
) -> list[PinOut]:
|
|
621
|
+
"""List all pins for the current user, ordered by pinned_at DESC.
|
|
622
|
+
|
|
623
|
+
Pins on soft-deleted nodes are excluded (JOIN graph_nodes WHERE deprecated_at IS NULL).
|
|
624
|
+
Rate limit: 120/minute/user.
|
|
625
|
+
"""
|
|
626
|
+
user_id = getattr(user, "user_id", "unknown")
|
|
627
|
+
ctx = CallerContext.from_user_info(user, is_human_session=False)
|
|
628
|
+
try:
|
|
629
|
+
return await uc.list_graph_pins(ctx, db, user_id=user_id)
|
|
630
|
+
except ServiceError as e:
|
|
631
|
+
raise to_http_legacy(e)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
# --- Create/upsert pin ---
|
|
635
|
+
# CSRF mitigation: Bearer-required (get_current_user_or_agent accepts Bearer).
|
|
636
|
+
# POST with Authorization header triggers CORS preflight for cross-origin requests,
|
|
637
|
+
# blocking CSRF. Cookie-only sessions are still accepted for same-origin Console.
|
|
638
|
+
|
|
639
|
+
@router.post("/pins", response_model=PinOut, summary="Pin a KG node (upsert)")
|
|
640
|
+
@limiter.limit("30/minute")
|
|
641
|
+
async def create_graph_pin(
|
|
642
|
+
body: PinIn,
|
|
643
|
+
request: Request,
|
|
644
|
+
user: UserInfo = Depends(get_current_user_or_agent),
|
|
645
|
+
db: aiosqlite.Connection = Depends(get_write_db),
|
|
646
|
+
) -> PinOut:
|
|
647
|
+
"""Upsert a node pin for the current user.
|
|
648
|
+
|
|
649
|
+
Idempotent: POST twice with the same node_id → 200, single row.
|
|
650
|
+
Updates `note` on conflict. Returns 200 (not 201) to signal idempotency.
|
|
651
|
+
|
|
652
|
+
CSRF: requires Authorization: Bearer header (or valid session cookie for same-origin).
|
|
653
|
+
Rate limit: 30/minute/user.
|
|
654
|
+
|
|
655
|
+
Invalidates pins_cache[user_id] for landing bundle consistency.
|
|
656
|
+
"""
|
|
657
|
+
user_id = getattr(user, "user_id", "unknown")
|
|
658
|
+
ws_id = getattr(user, "workspace_id", "ws_default") or "ws_default"
|
|
659
|
+
|
|
660
|
+
ctx = CallerContext.from_user_info(user, is_human_session=False)
|
|
661
|
+
try:
|
|
662
|
+
result = await uc.create_graph_pin(
|
|
663
|
+
ctx, db,
|
|
664
|
+
workspace_id=ws_id, user_id=user_id, node_id=body.node_id, note=body.note,
|
|
665
|
+
)
|
|
666
|
+
except ServiceError as e:
|
|
667
|
+
raise to_http_legacy(e)
|
|
668
|
+
|
|
669
|
+
# Invalidate pins_cache for this user (transport).
|
|
670
|
+
_pins_cache.pop(f"pins:{user_id}", None)
|
|
671
|
+
return result
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
# --- Delete pin ---
|
|
675
|
+
|
|
676
|
+
@router.delete("/pins/{node_id}", response_model=dict, summary="Unpin a KG node")
|
|
677
|
+
@limiter.limit("30/minute")
|
|
678
|
+
async def delete_graph_pin(
|
|
679
|
+
node_id: str,
|
|
680
|
+
request: Request,
|
|
681
|
+
user: UserInfo = Depends(get_current_user_or_agent),
|
|
682
|
+
db: aiosqlite.Connection = Depends(get_write_db),
|
|
683
|
+
) -> dict:
|
|
684
|
+
"""Remove a pin for the current user.
|
|
685
|
+
|
|
686
|
+
Returns 200 on success, 404 if the pin does not exist for this user.
|
|
687
|
+
CSRF: same policy as POST /graph/pins (Bearer-required mitigates CSRF).
|
|
688
|
+
Rate limit: 30/minute/user.
|
|
689
|
+
|
|
690
|
+
Invalidates pins_cache[user_id] for landing bundle consistency.
|
|
691
|
+
"""
|
|
692
|
+
user_id = getattr(user, "user_id", "unknown")
|
|
693
|
+
ctx = CallerContext.from_user_info(user, is_human_session=False)
|
|
694
|
+
try:
|
|
695
|
+
result = await uc.delete_graph_pin(ctx, db, user_id=user_id, node_id=node_id)
|
|
696
|
+
except ServiceError as e:
|
|
697
|
+
raise to_http_legacy(e)
|
|
698
|
+
|
|
699
|
+
# Invalidate pins_cache for this user (transport).
|
|
700
|
+
_pins_cache.pop(f"pins:{user_id}", None)
|
|
701
|
+
return result
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
# --- Resolve file path → node_id ---
|
|
705
|
+
|
|
706
|
+
@router.get("/resolve", response_model=ResolveOut, summary="Resolve file path to KG node")
|
|
707
|
+
@limiter.limit("120/minute")
|
|
708
|
+
async def graph_resolve(
|
|
709
|
+
request: Request,
|
|
710
|
+
path: str = Query(..., max_length=1024, description="Relative file path (no .. or absolute)"),
|
|
711
|
+
user: UserInfo = Depends(get_current_user_or_agent),
|
|
712
|
+
db: aiosqlite.Connection = Depends(get_db),
|
|
713
|
+
) -> ResolveOut:
|
|
714
|
+
"""Resolve a file path to its graph_nodes id.
|
|
715
|
+
|
|
716
|
+
Security (P0): rejects null bytes / absolute / `..` with 404 (never 403 —
|
|
717
|
+
no oracle). Returns 404 (not 403) on a visibility miss.
|
|
718
|
+
|
|
719
|
+
Rate limit: 120/minute/user.
|
|
720
|
+
"""
|
|
721
|
+
# DECISION 1: resolve visibility at the boundary; the use_case enforces (404).
|
|
722
|
+
visible = await get_visible_projects(db, user)
|
|
723
|
+
ctx = CallerContext.from_user_info(user, is_human_session=False)
|
|
724
|
+
try:
|
|
725
|
+
return await uc.graph_resolve(ctx, db, path=path, visible_projects=visible)
|
|
726
|
+
except ServiceError as e:
|
|
727
|
+
raise to_http_legacy(e)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
# --- Overview (macro / module LOD) ---
|
|
731
|
+
|
|
732
|
+
@router.get("/overview", response_model=OverviewBundle, summary="KG overview (macro/module LOD)")
|
|
733
|
+
@limiter.limit("6/minute")
|
|
734
|
+
async def graph_overview(
|
|
735
|
+
request: Request,
|
|
736
|
+
level: Literal["macro", "module"] = Query(
|
|
737
|
+
..., description="Detail level: macro = project nodes, module = module nodes"
|
|
738
|
+
),
|
|
739
|
+
scope: str | None = Query(
|
|
740
|
+
None,
|
|
741
|
+
max_length=256,
|
|
742
|
+
description="Required for level=module. Format: project:artifact:<slug>",
|
|
743
|
+
),
|
|
744
|
+
cross_project: bool = Query(
|
|
745
|
+
True,
|
|
746
|
+
description="Include cross-project edges (default: True, D14)",
|
|
747
|
+
),
|
|
748
|
+
limit: int = Query(300, ge=1, le=300),
|
|
749
|
+
user: UserInfo = Depends(get_current_user_or_agent),
|
|
750
|
+
db: aiosqlite.Connection = Depends(get_db),
|
|
751
|
+
) -> OverviewBundle:
|
|
752
|
+
"""Return graph overview at macro or module level of detail.
|
|
753
|
+
|
|
754
|
+
macro: project:artifact:* nodes + aggregated cross-project edges, capped by degree.
|
|
755
|
+
module: module nodes for a given project scope.
|
|
756
|
+
|
|
757
|
+
RBAC: filter_visible_edges applied (macro) — hidden_cross_project_count returned;
|
|
758
|
+
module-level visibility miss → 404.
|
|
759
|
+
Rate limit: 6/minute/user (expensive).
|
|
760
|
+
"""
|
|
761
|
+
# DECISION 1: resolve visibility at the boundary for the module-level RBAC
|
|
762
|
+
# check; the macro path applies filter_visible_edges inside the use_case
|
|
763
|
+
# (needs the full UserInfo, passed through). filter_visible_edges is INJECTED
|
|
764
|
+
# so the overview test can patch it at this namespace (DECISION A).
|
|
765
|
+
visible = await get_visible_projects(db, user)
|
|
766
|
+
ctx = CallerContext.from_user_info(user, is_human_session=False)
|
|
767
|
+
try:
|
|
768
|
+
return await uc.graph_overview(
|
|
769
|
+
ctx, db,
|
|
770
|
+
level=level, scope=scope, cross_project=cross_project, limit=limit,
|
|
771
|
+
user=user, visible_projects=visible,
|
|
772
|
+
filter_visible_edges=filter_visible_edges,
|
|
773
|
+
)
|
|
774
|
+
except ServiceError as e:
|
|
775
|
+
raise to_http_legacy(e)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
# --- Orphans ---
|
|
779
|
+
|
|
780
|
+
_ORPHAN_SCOPE_PATTERN = r"^(project|module):artifact:.+$"
|
|
781
|
+
|
|
782
|
+
@router.get("/orphans", response_model=OrphansBundle, summary="KG orphan files (unlinked)")
|
|
783
|
+
@limiter.limit("30/minute")
|
|
784
|
+
async def graph_orphans(
|
|
785
|
+
request: Request,
|
|
786
|
+
scope: str = Query(
|
|
787
|
+
...,
|
|
788
|
+
max_length=256,
|
|
789
|
+
pattern=_ORPHAN_SCOPE_PATTERN,
|
|
790
|
+
description="Scope to search for orphans: project:artifact:<slug> or module:artifact:<project>/<folder>",
|
|
791
|
+
),
|
|
792
|
+
user: UserInfo = Depends(get_current_user_or_agent),
|
|
793
|
+
db: aiosqlite.Connection = Depends(get_db),
|
|
794
|
+
) -> OrphansBundle:
|
|
795
|
+
"""Return file nodes with no edges (orphans) within a scope.
|
|
796
|
+
|
|
797
|
+
Uses a LEFT JOIN anti-join — NOT a NOT IN subquery — so the query planner
|
|
798
|
+
can use idx_graph_edges_source_id and idx_graph_edges_target_id.
|
|
799
|
+
|
|
800
|
+
Files grouped by first path segment, capped at 30 per sub-cluster.
|
|
801
|
+
Rate limit: 30/minute/user.
|
|
802
|
+
"""
|
|
803
|
+
# DECISION 1: resolve visibility at the boundary; the use_case enforces (404).
|
|
804
|
+
visible = await get_visible_projects(db, user)
|
|
805
|
+
ctx = CallerContext.from_user_info(user, is_human_session=False)
|
|
806
|
+
try:
|
|
807
|
+
return await uc.graph_orphans(ctx, db, scope=scope, visible_projects=visible)
|
|
808
|
+
except ServiceError as e:
|
|
809
|
+
raise to_http_legacy(e)
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
# --- Cosmo canvas adapter (PR #3) ---
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
@router.get(
|
|
816
|
+
"/cosmo",
|
|
817
|
+
response_model=GraphCosmoOut,
|
|
818
|
+
summary="Cosmo canvas dataset: project super-nodes + aggregated cross-project edges",
|
|
819
|
+
)
|
|
820
|
+
@limiter.limit("30/minute")
|
|
821
|
+
async def graph_cosmo(
|
|
822
|
+
request: Request,
|
|
823
|
+
user: UserInfo = Depends(get_current_user_or_agent),
|
|
824
|
+
db: aiosqlite.Connection = Depends(get_db),
|
|
825
|
+
) -> GraphCosmoOut:
|
|
826
|
+
"""Bundle per il canvas `/graph`: project super-nodi + aggregated edges.
|
|
827
|
+
|
|
828
|
+
Ogni project ha: degree (count edges uscenti cross-project), satellites
|
|
829
|
+
top-8 per recency (kind derivato dal prefix node_id). Edges scartati se
|
|
830
|
+
uno degli endpoint non e' visibile all'utente. Rate limit 30/min.
|
|
831
|
+
|
|
832
|
+
``graph_cosmo_service.fetch_cosmo_graph`` is fully self-contained (it does its
|
|
833
|
+
own get_visible_projects internally and takes the raw UserInfo), so this
|
|
834
|
+
handler stays a thin pass-through.
|
|
835
|
+
"""
|
|
836
|
+
return await graph_cosmo_service.fetch_cosmo_graph(db, user)
|