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,1153 @@
|
|
|
1
|
+
# v1.0.0 - 2026-05-27 - S1 F1.8: graph use_cases extracted from router (Knowledge Graph)
|
|
2
|
+
"""Knowledge-Graph use_cases — pure domain logic, transport-agnostic (no ``fastapi``).
|
|
3
|
+
|
|
4
|
+
Sibling of :mod:`core.api.routers.graph` in the S1 "collapse runtime" refactor.
|
|
5
|
+
Each query operation is a pure async function ``(ctx, db, *typed_args) -> <DTO>``;
|
|
6
|
+
the router is a thin adapter that resolves identity, enforces visibility, applies
|
|
7
|
+
rate limits, and maps :class:`ServiceError` -> ``HTTPException``.
|
|
8
|
+
|
|
9
|
+
Four router-specific decisions (deviations / specialisations of the template):
|
|
10
|
+
|
|
11
|
+
DECISION A — kg services that drag ``fastapi`` are kept out of this module.
|
|
12
|
+
``graph_service`` / ``graph_ranker`` are fastapi-free → module-top imports.
|
|
13
|
+
But ``graph_cosmo_service`` (cosmo) and ``services.share_links``
|
|
14
|
+
(share_function URL) transitively import ``fastapi`` and live entirely in the
|
|
15
|
+
adapter. ``visibility.filter_visible_edges`` (overview macro RBAC) also imports
|
|
16
|
+
``fastapi`` AND is test-pinned to the ``routers.graph`` namespace
|
|
17
|
+
(``patch("api.routers.graph.filter_visible_edges")`` in
|
|
18
|
+
test_overview_rbac_hides_edges) — so it is INJECTED into ``graph_overview`` as
|
|
19
|
+
a callable resolved at the adapter boundary (DECISION 1 style), never imported
|
|
20
|
+
here. To keep THIS module fastapi-free at import time (the import-linter
|
|
21
|
+
contract + the smoke test assert), no fastapi-importing module is referenced.
|
|
22
|
+
|
|
23
|
+
DECISION B — visibility ENFORCEMENT stays in the adapter (graph deviation from
|
|
24
|
+
the template's DECISION 1). ``get_visible_projects`` is resolved in the
|
|
25
|
+
router (DECISION 1 resolves at the boundary), but graph endpoints enforce
|
|
26
|
+
with DIFFERENT transport outcomes that are part of the API contract and are
|
|
27
|
+
test-pinned to a *plain-string* detail body:
|
|
28
|
+
* project-scoped reads (neighbors/hotspots/impact/context/pattern) →
|
|
29
|
+
``403 "Project not accessible"`` (test_kg_security_h2_h3 asserts the
|
|
30
|
+
substring "not accessible").
|
|
31
|
+
* oracle-avoidance reads (resolve / overview module / orphans) →
|
|
32
|
+
``404 "Not found"`` on a visibility miss.
|
|
33
|
+
Routing those through ``ServiceError`` + the structured ``to_http`` body
|
|
34
|
+
would change every response body, and the test patches
|
|
35
|
+
``api.routers.graph.get_visible_projects`` (so the call must stay in the
|
|
36
|
+
router namespace). The check therefore lives where it fires today; the
|
|
37
|
+
use_case does the pure data work.
|
|
38
|
+
|
|
39
|
+
DECISION C — the deep/share transport layer stays in the adapter.
|
|
40
|
+
``share_function`` couples a *workspace-share* side effect (signed URL via
|
|
41
|
+
``share_links`` + ``enforce_workspace_share_role``, on the write pool) with a
|
|
42
|
+
pure KG-context bundle. URL generation + role enforcement are transport
|
|
43
|
+
concerns kept in the adapter; the pure pieces (include parsing, node lookup
|
|
44
|
+
error mapping, preview/hotspot/neighbors/context assembly) live here.
|
|
45
|
+
|
|
46
|
+
DECISION D — domain errors carry the LEGACY plain-string body.
|
|
47
|
+
Today every graph ``HTTPException`` uses a rich human-readable string detail
|
|
48
|
+
(documented in the endpoint docstrings, part of the agent-facing contract),
|
|
49
|
+
NOT the structured ``{code,message}`` shape. The use_case raises
|
|
50
|
+
:class:`ServiceError` whose ``message`` is byte-identical to the legacy
|
|
51
|
+
detail; the adapter re-raises it as ``HTTPException(status, message)`` (the
|
|
52
|
+
``to_http_legacy`` helper) so bodies are unchanged.
|
|
53
|
+
"""
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import json as json_mod
|
|
57
|
+
import re
|
|
58
|
+
from datetime import datetime, timezone
|
|
59
|
+
|
|
60
|
+
import aiosqlite
|
|
61
|
+
|
|
62
|
+
from core.api.models.graph import GraphCapabilities, RankType
|
|
63
|
+
from core.api.models.graph_ux import (
|
|
64
|
+
HotspotItem,
|
|
65
|
+
LandingBundle,
|
|
66
|
+
OrphanFile,
|
|
67
|
+
OrphanSubCluster,
|
|
68
|
+
OrphansBundle,
|
|
69
|
+
OverviewBundle,
|
|
70
|
+
OverviewEdge,
|
|
71
|
+
OverviewNode,
|
|
72
|
+
PinOut,
|
|
73
|
+
RecentItem,
|
|
74
|
+
ResolveOut,
|
|
75
|
+
)
|
|
76
|
+
from core.api.services import graph_ranker, graph_service
|
|
77
|
+
from core.api.use_cases._context import CallerContext
|
|
78
|
+
from core.api.use_cases._errors import NotFoundError, ServiceError, ValidationError
|
|
79
|
+
|
|
80
|
+
class _PinWriteFailedError(ServiceError):
|
|
81
|
+
"""500 — post-upsert fetch miss (should never happen). Plain-string body."""
|
|
82
|
+
|
|
83
|
+
http_status = 500
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# Domain constants (pure — moved verbatim from the router)
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
QUALIFIED_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9_.\-]+$")
|
|
91
|
+
MAX_QUALIFIED_NAME_LEN = 256
|
|
92
|
+
|
|
93
|
+
SHARE_FUNCTION_INCLUDES: frozenset[str] = frozenset(
|
|
94
|
+
{"preview", "neighbors", "context", "hotspot"}
|
|
95
|
+
)
|
|
96
|
+
DEFAULT_SHARE_FUNCTION_INCLUDE: tuple[str, ...] = (
|
|
97
|
+
"preview", "neighbors", "context", "hotspot"
|
|
98
|
+
)
|
|
99
|
+
PREVIEW_LINES: int = 20
|
|
100
|
+
NEIGHBORS_LIMIT: int = 10
|
|
101
|
+
SHARE_FUNCTION_DEFAULT_HOURS: int = 24
|
|
102
|
+
|
|
103
|
+
# Node ID regex for path params (same as PinIn field pattern)
|
|
104
|
+
_NODE_ID_RE = re.compile(r"^[a-z]+:[a-z]+:.+$")
|
|
105
|
+
|
|
106
|
+
# Orphan sub-cluster color map (deterministic by folder prefix)
|
|
107
|
+
_ORPHAN_COLORS: dict[str, str] = {
|
|
108
|
+
"docs": "#D4A017", # ocra
|
|
109
|
+
"memory": "#7B2D8B", # viola
|
|
110
|
+
"scripts": "#17A2B8", # ciano
|
|
111
|
+
"kb": "#556B2F", # oliva
|
|
112
|
+
"tests": "#FF69B4", # rosa
|
|
113
|
+
"output": "#808080", # grey
|
|
114
|
+
"data": "#20B2AA", # seafoam
|
|
115
|
+
}
|
|
116
|
+
_ORPHAN_COLOR_DEFAULT = "#A0A0A0" # neutral
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# Pure helpers (moved verbatim from the router)
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def parse_include_csv(include: str | None) -> list[str]:
|
|
125
|
+
"""Parse CSV `include` param into validated list of blocks.
|
|
126
|
+
|
|
127
|
+
Empty / None → default (all four blocks). Unknown tokens → ValueError.
|
|
128
|
+
"""
|
|
129
|
+
if include is None or not include.strip():
|
|
130
|
+
return list(DEFAULT_SHARE_FUNCTION_INCLUDE)
|
|
131
|
+
tokens = [t.strip() for t in include.split(",") if t.strip()]
|
|
132
|
+
if not tokens:
|
|
133
|
+
return list(DEFAULT_SHARE_FUNCTION_INCLUDE)
|
|
134
|
+
unknown = [t for t in tokens if t not in SHARE_FUNCTION_INCLUDES]
|
|
135
|
+
if unknown:
|
|
136
|
+
raise ValueError(
|
|
137
|
+
f"Unknown include tokens: {unknown!r}. "
|
|
138
|
+
f"Allowed: {sorted(SHARE_FUNCTION_INCLUDES)}"
|
|
139
|
+
)
|
|
140
|
+
# Preserve order, dedupe.
|
|
141
|
+
seen: set[str] = set()
|
|
142
|
+
out: list[str] = []
|
|
143
|
+
for t in tokens:
|
|
144
|
+
if t not in seen:
|
|
145
|
+
seen.add(t)
|
|
146
|
+
out.append(t)
|
|
147
|
+
return out
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def extract_hotspot(node_row: aiosqlite.Row) -> dict:
|
|
151
|
+
"""Pull touch counters + top 3 authors from a graph_nodes row."""
|
|
152
|
+
authors_raw = node_row["touch_authors"] if "touch_authors" in node_row.keys() else None
|
|
153
|
+
try:
|
|
154
|
+
authors = json_mod.loads(authors_raw) if authors_raw else []
|
|
155
|
+
except (TypeError, ValueError):
|
|
156
|
+
authors = []
|
|
157
|
+
if not isinstance(authors, list):
|
|
158
|
+
authors = []
|
|
159
|
+
return {
|
|
160
|
+
"touch_count_total": node_row["touch_count_total"] if "touch_count_total" in node_row.keys() else 0,
|
|
161
|
+
"touch_count_30d": node_row["touch_count_30d"] if "touch_count_30d" in node_row.keys() else 0,
|
|
162
|
+
"touch_count_7d": node_row["touch_count_7d"] if "touch_count_7d" in node_row.keys() else 0,
|
|
163
|
+
"touch_last_at": node_row["touch_last_at"] if "touch_last_at" in node_row.keys() else None,
|
|
164
|
+
"top_authors": authors[:3],
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def parse_db_datetime(value: str | None) -> datetime:
|
|
169
|
+
"""Parse a SQLite datetime string to UTC-aware datetime.
|
|
170
|
+
|
|
171
|
+
SQLite stores timestamps as TEXT in ISO8601 format (various flavors).
|
|
172
|
+
This helper normalises them to UTC-aware Python datetime objects.
|
|
173
|
+
"""
|
|
174
|
+
if not value:
|
|
175
|
+
return datetime.now(timezone.utc)
|
|
176
|
+
try:
|
|
177
|
+
# Handle both 'Z' suffix and offset-naive (assumed UTC)
|
|
178
|
+
s = value.replace("Z", "+00:00")
|
|
179
|
+
dt = datetime.fromisoformat(s)
|
|
180
|
+
if dt.tzinfo is None:
|
|
181
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
182
|
+
return dt
|
|
183
|
+
except (ValueError, AttributeError):
|
|
184
|
+
return datetime.now(timezone.utc)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def orphan_color(folder: str) -> str:
|
|
188
|
+
"""Return deterministic color for a folder based on its prefix."""
|
|
189
|
+
for prefix, color in _ORPHAN_COLORS.items():
|
|
190
|
+
if folder == prefix or folder.startswith(prefix + "/"):
|
|
191
|
+
return color
|
|
192
|
+
return _ORPHAN_COLOR_DEFAULT
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
# neighbors / hotspots / impact / context / pattern (read queries)
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async def graph_neighbors(
|
|
201
|
+
ctx: CallerContext,
|
|
202
|
+
db: aiosqlite.Connection,
|
|
203
|
+
*,
|
|
204
|
+
node_id: str,
|
|
205
|
+
relation: str | None = None,
|
|
206
|
+
edge_types: list[str] | None = None,
|
|
207
|
+
project: str | None = None,
|
|
208
|
+
direction: str = "both",
|
|
209
|
+
limit: int = 50,
|
|
210
|
+
rank: RankType = "none",
|
|
211
|
+
as_of: str | None = None,
|
|
212
|
+
) -> dict:
|
|
213
|
+
"""Return neighbours of a graph node, optionally ranked / time-travelled / cross-project.
|
|
214
|
+
|
|
215
|
+
Visibility (project) is enforced by the adapter (DECISION B). 422 (ValidationError)
|
|
216
|
+
on malformed inputs; 404 (NotFoundError) if the node does not exist at ``as_of``.
|
|
217
|
+
"""
|
|
218
|
+
try:
|
|
219
|
+
raw = await graph_service.get_neighbors_with_metadata(
|
|
220
|
+
db,
|
|
221
|
+
node_id=node_id,
|
|
222
|
+
relation=relation,
|
|
223
|
+
direction=direction,
|
|
224
|
+
limit=limit,
|
|
225
|
+
as_of=as_of,
|
|
226
|
+
project=project,
|
|
227
|
+
edge_types=edge_types,
|
|
228
|
+
)
|
|
229
|
+
except ValueError as e:
|
|
230
|
+
raise ValidationError(code="invalid_request", message=str(e))
|
|
231
|
+
|
|
232
|
+
if not raw:
|
|
233
|
+
# Distinguish "node missing" from "node has no matching neighbours".
|
|
234
|
+
# Use temporal-aware existence check so a node that was deprecated
|
|
235
|
+
# pre-`as_of` is still reachable via time-travel.
|
|
236
|
+
try:
|
|
237
|
+
exists = await graph_service.node_exists_at(db, node_id, as_of=as_of)
|
|
238
|
+
except ValueError as e:
|
|
239
|
+
raise ValidationError(code="invalid_request", message=str(e))
|
|
240
|
+
if not exists:
|
|
241
|
+
raise NotFoundError(
|
|
242
|
+
code="node_not_found",
|
|
243
|
+
message=(
|
|
244
|
+
f"Node not found in the knowledge graph: {node_id!r}"
|
|
245
|
+
+ (f" (at as_of={as_of!r})" if as_of else "")
|
|
246
|
+
+ ". Reason: no row in graph_nodes with this id (and, if as_of was given, "
|
|
247
|
+
"not in the historical state at that timestamp). "
|
|
248
|
+
"Fix: (1) verify node_id format: '{lang}:{type}:{qualified_name}'; "
|
|
249
|
+
"(2) use mcp__pir__search to find similar ids; "
|
|
250
|
+
"(3) if the code was recently added, run scripts/populate_knowledge_graph.py to index it."
|
|
251
|
+
),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
response: dict = {
|
|
255
|
+
"node_id": node_id,
|
|
256
|
+
"neighbors": raw,
|
|
257
|
+
"count": len(raw),
|
|
258
|
+
}
|
|
259
|
+
if as_of is not None:
|
|
260
|
+
response["as_of"] = as_of
|
|
261
|
+
|
|
262
|
+
if rank == "none":
|
|
263
|
+
return response
|
|
264
|
+
|
|
265
|
+
ranker_fn = graph_ranker.RANKERS.get(rank)
|
|
266
|
+
if ranker_fn is None:
|
|
267
|
+
# FastAPI's Literal typing already rejects unknown values with a 422,
|
|
268
|
+
# but guard defensively for typos between router/registry.
|
|
269
|
+
raise ValidationError(
|
|
270
|
+
code="unknown_rank",
|
|
271
|
+
message=(
|
|
272
|
+
f"Unknown rank: {rank!r}. "
|
|
273
|
+
f"Allowed values: {sorted(graph_ranker.RANKERS.keys())}. "
|
|
274
|
+
"Fix: pick a registered ranker, or rank='none' to skip ranking."
|
|
275
|
+
),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
ranked = await ranker_fn(db, raw)
|
|
279
|
+
response["neighbors"] = ranked
|
|
280
|
+
response["count"] = len(ranked)
|
|
281
|
+
response["rank"] = rank
|
|
282
|
+
return response
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
async def graph_hotspots(
|
|
286
|
+
ctx: CallerContext,
|
|
287
|
+
db: aiosqlite.Connection,
|
|
288
|
+
*,
|
|
289
|
+
window: str = "30d",
|
|
290
|
+
limit: int = 20,
|
|
291
|
+
type_filter: str = "file",
|
|
292
|
+
project: str | None = None,
|
|
293
|
+
) -> dict:
|
|
294
|
+
"""Top N hotspot nodes ordered by touch count in the chosen window.
|
|
295
|
+
|
|
296
|
+
Visibility enforced by the adapter (DECISION B). 422 on malformed inputs.
|
|
297
|
+
"""
|
|
298
|
+
try:
|
|
299
|
+
graph_service.validate_project(project)
|
|
300
|
+
hotspots = await graph_service.get_hotspots(
|
|
301
|
+
db,
|
|
302
|
+
window=window,
|
|
303
|
+
limit=limit,
|
|
304
|
+
type_filter=type_filter,
|
|
305
|
+
project=project,
|
|
306
|
+
)
|
|
307
|
+
except ValueError as e:
|
|
308
|
+
raise ValidationError(code="invalid_request", message=str(e))
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
"window": window,
|
|
312
|
+
"type_filter": type_filter,
|
|
313
|
+
"project": project,
|
|
314
|
+
"hotspots": hotspots,
|
|
315
|
+
"count": len(hotspots),
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
async def graph_impact(
|
|
320
|
+
ctx: CallerContext,
|
|
321
|
+
db: aiosqlite.Connection,
|
|
322
|
+
*,
|
|
323
|
+
node_id: str,
|
|
324
|
+
depth: int = 2,
|
|
325
|
+
limit: int = 50,
|
|
326
|
+
edge_types: list[str] | None = None,
|
|
327
|
+
project: str | None = None,
|
|
328
|
+
) -> dict:
|
|
329
|
+
"""Reverse impact analysis for a graph node.
|
|
330
|
+
|
|
331
|
+
Visibility enforced by the adapter (DECISION B). 422 on malformed inputs,
|
|
332
|
+
404 if the node does not exist.
|
|
333
|
+
"""
|
|
334
|
+
try:
|
|
335
|
+
graph_service.validate_node_id(node_id)
|
|
336
|
+
graph_service.validate_project(project)
|
|
337
|
+
graph_service.validate_edge_types(edge_types)
|
|
338
|
+
except ValueError as e:
|
|
339
|
+
raise ValidationError(code="invalid_request", message=str(e))
|
|
340
|
+
|
|
341
|
+
if not await graph_service.node_exists_at(db, node_id):
|
|
342
|
+
raise NotFoundError(
|
|
343
|
+
code="node_not_found",
|
|
344
|
+
message=(
|
|
345
|
+
f"Node not found in the knowledge graph: {node_id!r}. "
|
|
346
|
+
"Reason: no row in graph_nodes with this id. "
|
|
347
|
+
"Fix: (1) verify format '{lang}:{type}:{qualified_name}' "
|
|
348
|
+
"(e.g. 'py:function:api.db.get_db'); "
|
|
349
|
+
"(2) use mcp__pir__search(q=<substring>) to find similar ids; "
|
|
350
|
+
"(3) run scripts/populate_knowledge_graph.py if the code was recently added."
|
|
351
|
+
),
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
try:
|
|
355
|
+
return await graph_service.graph_impact(
|
|
356
|
+
db, node_id=node_id, depth=depth, limit=limit,
|
|
357
|
+
project=project, edge_types=edge_types,
|
|
358
|
+
)
|
|
359
|
+
except ValueError as e:
|
|
360
|
+
raise ValidationError(code="invalid_request", message=str(e))
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
async def graph_context(
|
|
364
|
+
ctx: CallerContext,
|
|
365
|
+
db: aiosqlite.Connection,
|
|
366
|
+
*,
|
|
367
|
+
node_id: str,
|
|
368
|
+
per_category_limit: int = 5,
|
|
369
|
+
project: str | None = None,
|
|
370
|
+
) -> dict:
|
|
371
|
+
"""Trace the rationale chain for a node.
|
|
372
|
+
|
|
373
|
+
Visibility enforced by the adapter (DECISION B). 422 on malformed inputs,
|
|
374
|
+
404 if the node does not exist.
|
|
375
|
+
"""
|
|
376
|
+
try:
|
|
377
|
+
graph_service.validate_node_id(node_id)
|
|
378
|
+
graph_service.validate_project(project)
|
|
379
|
+
except ValueError as e:
|
|
380
|
+
raise ValidationError(code="invalid_request", message=str(e))
|
|
381
|
+
|
|
382
|
+
if not await graph_service.node_exists_at(db, node_id):
|
|
383
|
+
raise NotFoundError(
|
|
384
|
+
code="node_not_found",
|
|
385
|
+
message=(
|
|
386
|
+
f"Node not found in the knowledge graph: {node_id!r}. "
|
|
387
|
+
"Reason: no row in graph_nodes with this id. "
|
|
388
|
+
"Fix: (1) verify format '{lang}:{type}:{qualified_name}' "
|
|
389
|
+
"(e.g. 'py:function:api.db.get_db'); "
|
|
390
|
+
"(2) use mcp__pir__search(q=<substring>) to find similar ids; "
|
|
391
|
+
"(3) run scripts/populate_knowledge_graph.py if the code was recently added."
|
|
392
|
+
),
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
try:
|
|
396
|
+
return await graph_service.graph_context(
|
|
397
|
+
db, node_id=node_id, per_category_limit=per_category_limit,
|
|
398
|
+
project=project,
|
|
399
|
+
)
|
|
400
|
+
except ValueError as e:
|
|
401
|
+
raise ValidationError(code="invalid_request", message=str(e))
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
async def graph_pattern(
|
|
405
|
+
ctx: CallerContext,
|
|
406
|
+
db: aiosqlite.Connection,
|
|
407
|
+
*,
|
|
408
|
+
scope: str,
|
|
409
|
+
limit: int = 20,
|
|
410
|
+
project: str | None = None,
|
|
411
|
+
) -> dict:
|
|
412
|
+
"""Learnings applicable to a scope (file, module, or specific function).
|
|
413
|
+
|
|
414
|
+
Visibility enforced by the adapter (DECISION B). 422 on malformed inputs.
|
|
415
|
+
"""
|
|
416
|
+
try:
|
|
417
|
+
graph_service.validate_project(project)
|
|
418
|
+
return await graph_service.graph_pattern(
|
|
419
|
+
db, scope=scope, limit=limit, project=project,
|
|
420
|
+
)
|
|
421
|
+
except ValueError as e:
|
|
422
|
+
raise ValidationError(code="invalid_request", message=str(e))
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
# ---------------------------------------------------------------------------
|
|
426
|
+
# capabilities — KG schema metadata
|
|
427
|
+
# ---------------------------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
async def get_capabilities(
|
|
431
|
+
ctx: CallerContext,
|
|
432
|
+
db: aiosqlite.Connection,
|
|
433
|
+
) -> GraphCapabilities:
|
|
434
|
+
"""Returns valid edge_types, node_kinds, node_prefixes + schema_version.
|
|
435
|
+
|
|
436
|
+
Security: mcp_version NOT exposed (no fingerprinting).
|
|
437
|
+
"""
|
|
438
|
+
# graph_service is fastapi-free; EDGE_TYPES/NODE_KINDS/NODE_PREFIXES are
|
|
439
|
+
# module constants — imported at module top via ``graph_service``.
|
|
440
|
+
cursor = await db.execute("SELECT MAX(version) FROM schema_versions")
|
|
441
|
+
row = await cursor.fetchone()
|
|
442
|
+
schema_version = row[0] if row and row[0] is not None else None
|
|
443
|
+
return GraphCapabilities(
|
|
444
|
+
edge_types=list(graph_service.EDGE_TYPES),
|
|
445
|
+
node_kinds=list(graph_service.NODE_KINDS),
|
|
446
|
+
node_prefixes=list(graph_service.NODE_PREFIXES),
|
|
447
|
+
schema_version=schema_version,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# ---------------------------------------------------------------------------
|
|
452
|
+
# share_function — pure pieces (DECISION C)
|
|
453
|
+
# ---------------------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def validate_qualified_name(qualified_name: str) -> str:
|
|
457
|
+
"""Validate the share_function qualified_name. Raises ValidationError (422)."""
|
|
458
|
+
if not qualified_name or len(qualified_name) > MAX_QUALIFIED_NAME_LEN:
|
|
459
|
+
raise ValidationError(
|
|
460
|
+
code="invalid_qualified_name",
|
|
461
|
+
message=(
|
|
462
|
+
f"qualified_name too long or empty (got length {len(qualified_name)}, "
|
|
463
|
+
f"max {MAX_QUALIFIED_NAME_LEN}). "
|
|
464
|
+
"Expected: dotted-lowercase Python path like 'api.db.get_write_db'. "
|
|
465
|
+
"Fix: pass a non-empty name under the limit; check for accidentally concatenated values."
|
|
466
|
+
),
|
|
467
|
+
)
|
|
468
|
+
if not QUALIFIED_NAME_PATTERN.match(qualified_name):
|
|
469
|
+
raise ValidationError(
|
|
470
|
+
code="invalid_qualified_name",
|
|
471
|
+
message=(
|
|
472
|
+
f"Invalid qualified_name: {qualified_name!r}. "
|
|
473
|
+
f"Expected pattern: {QUALIFIED_NAME_PATTERN.pattern} "
|
|
474
|
+
"(dotted-lowercase tokens, e.g. 'api.db.get_db' or 'api.services.graph_service.node_exists'). "
|
|
475
|
+
"Fix: use the Python import path, not a file path — no slashes, no uppercase, no trailing '()'."
|
|
476
|
+
),
|
|
477
|
+
)
|
|
478
|
+
return qualified_name
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
async def lookup_function_node(
|
|
482
|
+
db: aiosqlite.Connection,
|
|
483
|
+
*,
|
|
484
|
+
qualified_name: str,
|
|
485
|
+
) -> aiosqlite.Row:
|
|
486
|
+
"""Look up the live ``type='function'`` node row for a qualified_name.
|
|
487
|
+
|
|
488
|
+
Raises NotFoundError (404) when no live node matches or when the matched
|
|
489
|
+
node has no recorded file_path.
|
|
490
|
+
"""
|
|
491
|
+
db.row_factory = aiosqlite.Row
|
|
492
|
+
cur = await db.execute(
|
|
493
|
+
"SELECT id, type, name, qualified_name, file_path, line_number, "
|
|
494
|
+
"touch_count_total, touch_count_7d, touch_count_30d, "
|
|
495
|
+
"touch_authors, touch_last_at "
|
|
496
|
+
"FROM graph_nodes "
|
|
497
|
+
"WHERE qualified_name = ? AND type = 'function' "
|
|
498
|
+
"AND deprecated_at IS NULL "
|
|
499
|
+
"LIMIT 1",
|
|
500
|
+
(qualified_name,),
|
|
501
|
+
)
|
|
502
|
+
node_row = await cur.fetchone()
|
|
503
|
+
if node_row is None:
|
|
504
|
+
raise NotFoundError(
|
|
505
|
+
code="function_not_found",
|
|
506
|
+
message=(
|
|
507
|
+
f"Function not found in graph: {qualified_name!r}. "
|
|
508
|
+
"Reason: no graph_nodes row with type='function' + this qualified_name (live view excludes deprecated nodes). "
|
|
509
|
+
"Fix: (1) check for typos in the dotted path (e.g. 'api.db.get_db' not 'api.db.get_DB'); "
|
|
510
|
+
"(2) search alternates with mcp__pir__search(q=<name substring>); "
|
|
511
|
+
"(3) re-index with scripts/populate_knowledge_graph.py if the function is freshly added; "
|
|
512
|
+
"(4) query with as_of=<past timestamp> if the function was recently deprecated."
|
|
513
|
+
),
|
|
514
|
+
)
|
|
515
|
+
if not node_row["file_path"]:
|
|
516
|
+
raise NotFoundError(
|
|
517
|
+
code="function_no_file_path",
|
|
518
|
+
message=(
|
|
519
|
+
f"Function {qualified_name!r} has no file_path recorded in graph_nodes. "
|
|
520
|
+
"Reason: the node was indexed without a source file (legacy/partial import). "
|
|
521
|
+
"Fix: re-run scripts/populate_knowledge_graph.py with the ast_parser to fill in file_path, "
|
|
522
|
+
"or use mcp__pir__share_file if you already know the file manually."
|
|
523
|
+
),
|
|
524
|
+
)
|
|
525
|
+
return node_row
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
async def build_share_function_blocks(
|
|
529
|
+
db: aiosqlite.Connection,
|
|
530
|
+
*,
|
|
531
|
+
node_id: str,
|
|
532
|
+
node_row: aiosqlite.Row,
|
|
533
|
+
include_list: list[str],
|
|
534
|
+
) -> dict:
|
|
535
|
+
"""Assemble the optional KG-context blocks (neighbors/context/hotspot).
|
|
536
|
+
|
|
537
|
+
Pure data assembly (preview is added by the adapter, which owns the disk read
|
|
538
|
+
via ``share_links.validate_repo_path``). 422 on internal graph inconsistency.
|
|
539
|
+
"""
|
|
540
|
+
blocks: dict = {}
|
|
541
|
+
|
|
542
|
+
if "neighbors" in include_list:
|
|
543
|
+
try:
|
|
544
|
+
raw = await graph_service.get_neighbors_with_metadata(
|
|
545
|
+
db,
|
|
546
|
+
node_id=node_id,
|
|
547
|
+
direction="incoming",
|
|
548
|
+
limit=NEIGHBORS_LIMIT,
|
|
549
|
+
)
|
|
550
|
+
ranker_fn = graph_ranker.RANKERS.get("suspect_write")
|
|
551
|
+
if ranker_fn is not None:
|
|
552
|
+
ranked = await ranker_fn(db, raw)
|
|
553
|
+
else:
|
|
554
|
+
ranked = raw
|
|
555
|
+
blocks["neighbors"] = {
|
|
556
|
+
"count": len(ranked),
|
|
557
|
+
"rank": "suspect_write",
|
|
558
|
+
"items": ranked,
|
|
559
|
+
}
|
|
560
|
+
except ValueError as e:
|
|
561
|
+
# Internal consistency error — surface rather than swallow.
|
|
562
|
+
raise ValidationError(code="invalid_request", message=str(e))
|
|
563
|
+
|
|
564
|
+
if "context" in include_list:
|
|
565
|
+
try:
|
|
566
|
+
blocks["context"] = await graph_service.graph_context(
|
|
567
|
+
db, node_id=node_id, per_category_limit=5,
|
|
568
|
+
)
|
|
569
|
+
except ValueError as e:
|
|
570
|
+
raise ValidationError(code="invalid_request", message=str(e))
|
|
571
|
+
|
|
572
|
+
if "hotspot" in include_list:
|
|
573
|
+
blocks["hotspot"] = extract_hotspot(node_row)
|
|
574
|
+
|
|
575
|
+
return blocks
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
# ---------------------------------------------------------------------------
|
|
579
|
+
# P2 UX — landing / list_pins / resolve / overview / orphans (read queries)
|
|
580
|
+
# ---------------------------------------------------------------------------
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
async def graph_landing(
|
|
584
|
+
ctx: CallerContext,
|
|
585
|
+
db: aiosqlite.Connection,
|
|
586
|
+
*,
|
|
587
|
+
workspace_id: str,
|
|
588
|
+
user_id: str,
|
|
589
|
+
hotspots_cached: list[HotspotItem] | None = None,
|
|
590
|
+
recent_cached: list[RecentItem] | None = None,
|
|
591
|
+
pins_cached: list[PinOut] | None = None,
|
|
592
|
+
) -> tuple[LandingBundle, list[HotspotItem], list[RecentItem], list[PinOut]]:
|
|
593
|
+
"""Aggregated landing bundle: top-10 hotspots (30d) + last-20 recent + saved pins.
|
|
594
|
+
|
|
595
|
+
The TTLCache lives in the adapter (transport). The adapter passes any cached
|
|
596
|
+
slices in; this function computes the misses and returns the bundle PLUS the
|
|
597
|
+
(possibly freshly-computed) slices so the adapter can repopulate its cache.
|
|
598
|
+
"""
|
|
599
|
+
db.row_factory = aiosqlite.Row
|
|
600
|
+
|
|
601
|
+
# --- Hotspots slice ---
|
|
602
|
+
hotspots = hotspots_cached
|
|
603
|
+
if hotspots is None:
|
|
604
|
+
cur = await db.execute(
|
|
605
|
+
"""
|
|
606
|
+
SELECT id, type, name, qualified_name,
|
|
607
|
+
touch_count_30d, touch_authors
|
|
608
|
+
FROM graph_nodes
|
|
609
|
+
WHERE deprecated_at IS NULL
|
|
610
|
+
ORDER BY touch_count_30d DESC, touch_last_at DESC
|
|
611
|
+
LIMIT 10
|
|
612
|
+
"""
|
|
613
|
+
)
|
|
614
|
+
rows = await cur.fetchall()
|
|
615
|
+
hotspots = []
|
|
616
|
+
for r in rows:
|
|
617
|
+
try:
|
|
618
|
+
authors = json_mod.loads(r["touch_authors"]) if r["touch_authors"] else []
|
|
619
|
+
except (TypeError, ValueError):
|
|
620
|
+
authors = []
|
|
621
|
+
hotspots.append(HotspotItem(
|
|
622
|
+
node_id=r["id"],
|
|
623
|
+
label=r["qualified_name"] or r["name"],
|
|
624
|
+
kind=r["type"],
|
|
625
|
+
touch_count=r["touch_count_30d"] or 0,
|
|
626
|
+
authors=authors[:3],
|
|
627
|
+
))
|
|
628
|
+
|
|
629
|
+
# --- Recent artifacts slice ---
|
|
630
|
+
recent = recent_cached
|
|
631
|
+
if recent is None:
|
|
632
|
+
cur = await db.execute(
|
|
633
|
+
"""
|
|
634
|
+
SELECT id, type, name, created_at
|
|
635
|
+
FROM graph_nodes
|
|
636
|
+
WHERE type IN ('commit', 'pr', 'task', 'handoff')
|
|
637
|
+
AND deprecated_at IS NULL
|
|
638
|
+
ORDER BY created_at DESC
|
|
639
|
+
LIMIT 20
|
|
640
|
+
"""
|
|
641
|
+
)
|
|
642
|
+
rows = await cur.fetchall()
|
|
643
|
+
recent = []
|
|
644
|
+
for r in rows:
|
|
645
|
+
kind_val = r["type"]
|
|
646
|
+
if kind_val not in ("commit", "pr", "task", "handoff"):
|
|
647
|
+
continue
|
|
648
|
+
recent.append(RecentItem(
|
|
649
|
+
kind=kind_val, # type: ignore[arg-type]
|
|
650
|
+
node_id=r["id"],
|
|
651
|
+
label=r["name"],
|
|
652
|
+
at=parse_db_datetime(r["created_at"]),
|
|
653
|
+
))
|
|
654
|
+
|
|
655
|
+
# --- Pins slice (user-scoped) ---
|
|
656
|
+
saved = pins_cached
|
|
657
|
+
if saved is None:
|
|
658
|
+
cur = await db.execute(
|
|
659
|
+
"""
|
|
660
|
+
SELECT p.node_id, p.pinned_at, p.note,
|
|
661
|
+
n.deprecated_at AS node_deprecated_at
|
|
662
|
+
FROM kg_pins p
|
|
663
|
+
JOIN graph_nodes n ON n.id = p.node_id
|
|
664
|
+
WHERE p.user_id = ?
|
|
665
|
+
AND n.deprecated_at IS NULL
|
|
666
|
+
ORDER BY p.pinned_at DESC
|
|
667
|
+
""",
|
|
668
|
+
(user_id,),
|
|
669
|
+
)
|
|
670
|
+
rows = await cur.fetchall()
|
|
671
|
+
saved = [
|
|
672
|
+
PinOut(
|
|
673
|
+
node_id=r["node_id"],
|
|
674
|
+
pinned_at=parse_db_datetime(r["pinned_at"]),
|
|
675
|
+
note=r["note"],
|
|
676
|
+
is_stale=False,
|
|
677
|
+
)
|
|
678
|
+
for r in rows
|
|
679
|
+
]
|
|
680
|
+
|
|
681
|
+
bundle = LandingBundle(hotspots=hotspots, recent=recent, saved_nodes=saved)
|
|
682
|
+
return bundle, hotspots, recent, saved
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
async def list_graph_pins(
|
|
686
|
+
ctx: CallerContext,
|
|
687
|
+
db: aiosqlite.Connection,
|
|
688
|
+
*,
|
|
689
|
+
user_id: str,
|
|
690
|
+
) -> list[PinOut]:
|
|
691
|
+
"""List all pins for the current user, ordered by pinned_at DESC.
|
|
692
|
+
|
|
693
|
+
Pins on soft-deleted nodes are excluded (JOIN graph_nodes WHERE deprecated_at IS NULL).
|
|
694
|
+
"""
|
|
695
|
+
db.row_factory = aiosqlite.Row
|
|
696
|
+
cur = await db.execute(
|
|
697
|
+
"""
|
|
698
|
+
SELECT p.node_id, p.pinned_at, p.note
|
|
699
|
+
FROM kg_pins p
|
|
700
|
+
JOIN graph_nodes n ON n.id = p.node_id
|
|
701
|
+
WHERE p.user_id = ?
|
|
702
|
+
AND n.deprecated_at IS NULL
|
|
703
|
+
ORDER BY p.pinned_at DESC
|
|
704
|
+
""",
|
|
705
|
+
(user_id,),
|
|
706
|
+
)
|
|
707
|
+
rows = await cur.fetchall()
|
|
708
|
+
return [
|
|
709
|
+
PinOut(
|
|
710
|
+
node_id=r["node_id"],
|
|
711
|
+
pinned_at=parse_db_datetime(r["pinned_at"]),
|
|
712
|
+
note=r["note"],
|
|
713
|
+
is_stale=False,
|
|
714
|
+
)
|
|
715
|
+
for r in rows
|
|
716
|
+
]
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
async def create_graph_pin(
|
|
720
|
+
ctx: CallerContext,
|
|
721
|
+
db: aiosqlite.Connection,
|
|
722
|
+
*,
|
|
723
|
+
workspace_id: str,
|
|
724
|
+
user_id: str,
|
|
725
|
+
node_id: str,
|
|
726
|
+
note: str | None,
|
|
727
|
+
) -> PinOut:
|
|
728
|
+
"""Upsert a node pin for the current user.
|
|
729
|
+
|
|
730
|
+
Idempotent: POST twice with the same node_id → single row, updates `note`.
|
|
731
|
+
Commits the write (the write pool documents "caller must commit"). The
|
|
732
|
+
adapter is responsible for cache invalidation (transport).
|
|
733
|
+
|
|
734
|
+
404 (NotFoundError) when the node does not exist / is soft-deleted.
|
|
735
|
+
"""
|
|
736
|
+
# Verify the node exists (not soft-deleted)
|
|
737
|
+
db.row_factory = aiosqlite.Row
|
|
738
|
+
cur = await db.execute(
|
|
739
|
+
"SELECT id FROM graph_nodes WHERE id = ? AND deprecated_at IS NULL LIMIT 1",
|
|
740
|
+
(node_id,),
|
|
741
|
+
)
|
|
742
|
+
node_row = await cur.fetchone()
|
|
743
|
+
if node_row is None:
|
|
744
|
+
raise NotFoundError(
|
|
745
|
+
code="node_not_found",
|
|
746
|
+
message=(
|
|
747
|
+
f"Node not found: {node_id!r}. "
|
|
748
|
+
"Either the node does not exist or has been soft-deleted. "
|
|
749
|
+
"Fix: verify node_id format and that the node is in the live graph."
|
|
750
|
+
),
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
# UPSERT — ON CONFLICT updates note and pinned_at
|
|
754
|
+
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%f") + "Z"
|
|
755
|
+
await db.execute(
|
|
756
|
+
"""
|
|
757
|
+
INSERT INTO kg_pins (workspace_id, user_id, node_id, pinned_at, note)
|
|
758
|
+
VALUES (?, ?, ?, ?, ?)
|
|
759
|
+
ON CONFLICT(workspace_id, user_id, node_id) DO UPDATE SET
|
|
760
|
+
note = excluded.note,
|
|
761
|
+
pinned_at = excluded.pinned_at
|
|
762
|
+
""",
|
|
763
|
+
(workspace_id, user_id, node_id, now, note),
|
|
764
|
+
)
|
|
765
|
+
# get_write_db() documents "caller must commit" — flush WAL immediately so
|
|
766
|
+
# the write is visible to the read-pool without waiting for periodic flush.
|
|
767
|
+
await db.commit()
|
|
768
|
+
|
|
769
|
+
# Fetch back the stored row
|
|
770
|
+
db.row_factory = aiosqlite.Row
|
|
771
|
+
cur = await db.execute(
|
|
772
|
+
"SELECT node_id, pinned_at, note FROM kg_pins WHERE user_id = ? AND node_id = ? LIMIT 1",
|
|
773
|
+
(user_id, node_id),
|
|
774
|
+
)
|
|
775
|
+
row = await cur.fetchone()
|
|
776
|
+
if row is None:
|
|
777
|
+
# Should not happen after a successful upsert. Legacy router raised
|
|
778
|
+
# HTTPException(500, "Pin write succeeded but fetch failed") — preserve
|
|
779
|
+
# the status (500) and plain-string body via a ServiceError subclass.
|
|
780
|
+
raise _PinWriteFailedError(
|
|
781
|
+
code="pin_write_failed",
|
|
782
|
+
message="Pin write succeeded but fetch failed",
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
return PinOut(
|
|
786
|
+
node_id=row["node_id"],
|
|
787
|
+
pinned_at=parse_db_datetime(row["pinned_at"]),
|
|
788
|
+
note=row["note"],
|
|
789
|
+
is_stale=False,
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
async def delete_graph_pin(
|
|
794
|
+
ctx: CallerContext,
|
|
795
|
+
db: aiosqlite.Connection,
|
|
796
|
+
*,
|
|
797
|
+
user_id: str,
|
|
798
|
+
node_id: str,
|
|
799
|
+
) -> dict:
|
|
800
|
+
"""Remove a pin for the current user.
|
|
801
|
+
|
|
802
|
+
422 (ValidationError) on malformed node_id; 404 (NotFoundError) if the pin
|
|
803
|
+
does not exist for this user. Commits the delete. The adapter invalidates
|
|
804
|
+
the pins cache (transport).
|
|
805
|
+
"""
|
|
806
|
+
# Validate node_id format
|
|
807
|
+
if not _NODE_ID_RE.match(node_id):
|
|
808
|
+
raise ValidationError(
|
|
809
|
+
code="invalid_node_id",
|
|
810
|
+
message=f"Invalid node_id format: {node_id!r}. Expected pattern: prefix:kind:slug.",
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
# Check existence first (to return proper 404)
|
|
814
|
+
db.row_factory = aiosqlite.Row
|
|
815
|
+
cur = await db.execute(
|
|
816
|
+
"SELECT id FROM kg_pins WHERE user_id = ? AND node_id = ? LIMIT 1",
|
|
817
|
+
(user_id, node_id),
|
|
818
|
+
)
|
|
819
|
+
existing = await cur.fetchone()
|
|
820
|
+
if existing is None:
|
|
821
|
+
raise NotFoundError(
|
|
822
|
+
code="pin_not_found",
|
|
823
|
+
message=f"Pin not found for node {node_id!r} by this user.",
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
await db.execute(
|
|
827
|
+
"DELETE FROM kg_pins WHERE user_id = ? AND node_id = ?",
|
|
828
|
+
(user_id, node_id),
|
|
829
|
+
)
|
|
830
|
+
# get_write_db() documents "caller must commit" — flush WAL immediately so
|
|
831
|
+
# the deletion is visible to the read-pool without waiting for periodic flush.
|
|
832
|
+
await db.commit()
|
|
833
|
+
|
|
834
|
+
return {"deleted": True, "node_id": node_id}
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
async def graph_resolve(
|
|
838
|
+
ctx: CallerContext,
|
|
839
|
+
db: aiosqlite.Connection,
|
|
840
|
+
*,
|
|
841
|
+
path: str,
|
|
842
|
+
visible_projects: set[str] | None,
|
|
843
|
+
) -> ResolveOut:
|
|
844
|
+
"""Resolve a file path to its graph_nodes id.
|
|
845
|
+
|
|
846
|
+
Security (P0):
|
|
847
|
+
- Rejects null bytes, absolute paths, and `..` path components with 404
|
|
848
|
+
(never 403 — avoid oracle).
|
|
849
|
+
- Returns 404 (not 403) on visibility miss (no oracle).
|
|
850
|
+
|
|
851
|
+
``visible_projects`` is resolved at the adapter boundary (DECISION 1):
|
|
852
|
+
``None`` means unrestricted (local/agent-bypass). All misses → 404 NotFoundError.
|
|
853
|
+
"""
|
|
854
|
+
# --- Path security validation (mirrors finder._validate_path) ---
|
|
855
|
+
if "\x00" in path:
|
|
856
|
+
raise NotFoundError(code="not_found", message="Not found")
|
|
857
|
+
if path.startswith("/"):
|
|
858
|
+
raise NotFoundError(code="not_found", message="Not found")
|
|
859
|
+
parts = path.replace("\\", "/").split("/")
|
|
860
|
+
if ".." in parts:
|
|
861
|
+
raise NotFoundError(code="not_found", message="Not found")
|
|
862
|
+
|
|
863
|
+
# --- Lookup via file_path column (AST parser writes path here, not metadata.path) ---
|
|
864
|
+
db.row_factory = aiosqlite.Row
|
|
865
|
+
cur = await db.execute(
|
|
866
|
+
"""
|
|
867
|
+
SELECT id, type, project_id
|
|
868
|
+
FROM graph_nodes
|
|
869
|
+
WHERE type = 'file'
|
|
870
|
+
AND file_path = ?
|
|
871
|
+
AND deprecated_at IS NULL
|
|
872
|
+
LIMIT 1
|
|
873
|
+
""",
|
|
874
|
+
(path,),
|
|
875
|
+
)
|
|
876
|
+
row = await cur.fetchone()
|
|
877
|
+
if row is None:
|
|
878
|
+
raise NotFoundError(code="not_found", message="Not found")
|
|
879
|
+
|
|
880
|
+
# --- RBAC visibility check (404, not 403) ---
|
|
881
|
+
project_id = row["project_id"]
|
|
882
|
+
if project_id and visible_projects is not None and project_id not in visible_projects:
|
|
883
|
+
raise NotFoundError(code="not_found", message="Not found")
|
|
884
|
+
|
|
885
|
+
return ResolveOut(node_id=row["id"], kind=row["type"])
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
async def graph_overview(
|
|
889
|
+
ctx: CallerContext,
|
|
890
|
+
db: aiosqlite.Connection,
|
|
891
|
+
*,
|
|
892
|
+
level: str,
|
|
893
|
+
scope: str | None,
|
|
894
|
+
cross_project: bool,
|
|
895
|
+
limit: int,
|
|
896
|
+
user,
|
|
897
|
+
visible_projects: set[str] | None,
|
|
898
|
+
filter_visible_edges,
|
|
899
|
+
) -> OverviewBundle:
|
|
900
|
+
"""Return graph overview at macro or module level of detail.
|
|
901
|
+
|
|
902
|
+
``user`` is the raw UserInfo (passed through for ``filter_visible_edges``,
|
|
903
|
+
which needs ``UserInfo.teams``). ``filter_visible_edges`` is the RBAC edge
|
|
904
|
+
filter callable, INJECTED by the adapter (DECISION A): it imports fastapi and
|
|
905
|
+
is test-pinned to the ``routers.graph`` namespace, so the use_case never
|
|
906
|
+
imports it. ``visible_projects`` is resolved at the boundary for the
|
|
907
|
+
module-level RBAC check (DECISION 1). 422 on malformed inputs; 404 on a
|
|
908
|
+
module visibility miss (oracle-avoidance).
|
|
909
|
+
"""
|
|
910
|
+
if level == "module" and not scope:
|
|
911
|
+
raise ValidationError(
|
|
912
|
+
code="scope_required",
|
|
913
|
+
message="scope is required for level=module. Format: project:artifact:<slug>",
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
db.row_factory = aiosqlite.Row
|
|
917
|
+
hidden_count = 0
|
|
918
|
+
|
|
919
|
+
if level == "macro":
|
|
920
|
+
# Fetch project hub nodes (top-K by degree)
|
|
921
|
+
cur = await db.execute(
|
|
922
|
+
"""
|
|
923
|
+
SELECT id, type, name, qualified_name, metadata, degree
|
|
924
|
+
FROM graph_nodes
|
|
925
|
+
WHERE type = 'project'
|
|
926
|
+
AND deprecated_at IS NULL
|
|
927
|
+
ORDER BY degree DESC
|
|
928
|
+
LIMIT ?
|
|
929
|
+
""",
|
|
930
|
+
(limit,),
|
|
931
|
+
)
|
|
932
|
+
node_rows = await cur.fetchall()
|
|
933
|
+
|
|
934
|
+
nodes: list[OverviewNode] = []
|
|
935
|
+
node_ids: set[str] = set()
|
|
936
|
+
for r in node_rows:
|
|
937
|
+
try:
|
|
938
|
+
meta = json_mod.loads(r["metadata"]) if r["metadata"] else {}
|
|
939
|
+
except (TypeError, ValueError):
|
|
940
|
+
meta = {}
|
|
941
|
+
nodes.append(OverviewNode(
|
|
942
|
+
id=r["id"],
|
|
943
|
+
type=r["type"],
|
|
944
|
+
label=r["qualified_name"] or r["name"],
|
|
945
|
+
sub_nodes=None,
|
|
946
|
+
metadata=meta,
|
|
947
|
+
))
|
|
948
|
+
node_ids.add(r["id"])
|
|
949
|
+
|
|
950
|
+
# Fetch edges between these project nodes
|
|
951
|
+
if node_ids:
|
|
952
|
+
placeholders = ",".join("?" * len(node_ids))
|
|
953
|
+
id_list = list(node_ids)
|
|
954
|
+
cur = await db.execute(
|
|
955
|
+
f"""
|
|
956
|
+
SELECT e.source_id, e.target_id, e.relation, COUNT(*) AS weight,
|
|
957
|
+
sn.project_id AS source_project, tn.project_id AS target_project
|
|
958
|
+
FROM graph_edges e
|
|
959
|
+
JOIN graph_nodes sn ON sn.id = e.source_id
|
|
960
|
+
JOIN graph_nodes tn ON tn.id = e.target_id
|
|
961
|
+
WHERE e.source_id IN ({placeholders})
|
|
962
|
+
AND e.target_id IN ({placeholders})
|
|
963
|
+
GROUP BY e.source_id, e.target_id, e.relation
|
|
964
|
+
""",
|
|
965
|
+
id_list + id_list,
|
|
966
|
+
)
|
|
967
|
+
raw_edges = [dict(r) for r in await cur.fetchall()]
|
|
968
|
+
else:
|
|
969
|
+
raw_edges = []
|
|
970
|
+
|
|
971
|
+
# DECISION A: filter_visible_edges (api.visibility) imports fastapi and is
|
|
972
|
+
# test-pinned to the routers.graph namespace, so it is injected by the
|
|
973
|
+
# adapter — this module never imports it.
|
|
974
|
+
# Apply RBAC filter
|
|
975
|
+
if cross_project:
|
|
976
|
+
filtered_edges, hidden_count = await filter_visible_edges(db, user, raw_edges)
|
|
977
|
+
else:
|
|
978
|
+
# Filter to only intra-project edges (same project_id source/target)
|
|
979
|
+
filtered_edges = [
|
|
980
|
+
e for e in raw_edges
|
|
981
|
+
if e.get("source_project") == e.get("target_project")
|
|
982
|
+
]
|
|
983
|
+
# RBAC on top
|
|
984
|
+
filtered_edges, hidden_count = await filter_visible_edges(db, user, filtered_edges)
|
|
985
|
+
|
|
986
|
+
edges = [
|
|
987
|
+
OverviewEdge(
|
|
988
|
+
source=e["source_id"],
|
|
989
|
+
target=e["target_id"],
|
|
990
|
+
relation=e["relation"],
|
|
991
|
+
weight=e.get("weight", 1),
|
|
992
|
+
)
|
|
993
|
+
for e in filtered_edges
|
|
994
|
+
]
|
|
995
|
+
|
|
996
|
+
return OverviewBundle(
|
|
997
|
+
level="macro",
|
|
998
|
+
scope=None,
|
|
999
|
+
nodes=nodes,
|
|
1000
|
+
edges=edges,
|
|
1001
|
+
hidden_cross_project_count=hidden_count,
|
|
1002
|
+
)
|
|
1003
|
+
|
|
1004
|
+
else: # level == "module"
|
|
1005
|
+
# Extract project slug from scope (project:artifact:<slug>)
|
|
1006
|
+
parts = scope.split(":", 2) if scope else []
|
|
1007
|
+
if len(parts) < 3 or parts[0] != "project":
|
|
1008
|
+
raise ValidationError(
|
|
1009
|
+
code="invalid_scope",
|
|
1010
|
+
message=f"Invalid scope format: {scope!r}. Expected: project:artifact:<slug>",
|
|
1011
|
+
)
|
|
1012
|
+
project_slug = parts[2]
|
|
1013
|
+
|
|
1014
|
+
# RBAC check for this project (404 oracle-avoidance)
|
|
1015
|
+
if visible_projects is not None and project_slug not in visible_projects:
|
|
1016
|
+
raise NotFoundError(code="not_found", message="Not found")
|
|
1017
|
+
|
|
1018
|
+
# Fetch file nodes for this project, group by first path segment
|
|
1019
|
+
cur = await db.execute(
|
|
1020
|
+
"""
|
|
1021
|
+
SELECT id, type, name, qualified_name, metadata
|
|
1022
|
+
FROM graph_nodes
|
|
1023
|
+
WHERE type = 'file'
|
|
1024
|
+
AND project_id = ?
|
|
1025
|
+
AND deprecated_at IS NULL
|
|
1026
|
+
ORDER BY degree DESC
|
|
1027
|
+
LIMIT ?
|
|
1028
|
+
""",
|
|
1029
|
+
(project_slug, limit),
|
|
1030
|
+
)
|
|
1031
|
+
file_rows = await cur.fetchall()
|
|
1032
|
+
|
|
1033
|
+
# Build module nodes by grouping on first path segment
|
|
1034
|
+
module_map: dict[str, list] = {}
|
|
1035
|
+
for r in file_rows:
|
|
1036
|
+
try:
|
|
1037
|
+
meta = json_mod.loads(r["metadata"]) if r["metadata"] else {}
|
|
1038
|
+
except (TypeError, ValueError):
|
|
1039
|
+
meta = {}
|
|
1040
|
+
path_val = meta.get("path", r["name"] or "")
|
|
1041
|
+
first_seg = path_val.split("/")[0] if "/" in path_val else path_val
|
|
1042
|
+
if first_seg not in module_map:
|
|
1043
|
+
module_map[first_seg] = []
|
|
1044
|
+
module_map[first_seg].append(r)
|
|
1045
|
+
|
|
1046
|
+
nodes = []
|
|
1047
|
+
for folder, file_list in module_map.items():
|
|
1048
|
+
node_id = f"module:artifact:{project_slug}/{folder}"
|
|
1049
|
+
nodes.append(OverviewNode(
|
|
1050
|
+
id=node_id,
|
|
1051
|
+
type="module",
|
|
1052
|
+
label=folder,
|
|
1053
|
+
sub_nodes=len(file_list),
|
|
1054
|
+
metadata={"folder": folder, "project": project_slug},
|
|
1055
|
+
))
|
|
1056
|
+
|
|
1057
|
+
return OverviewBundle(
|
|
1058
|
+
level="module",
|
|
1059
|
+
scope=scope,
|
|
1060
|
+
nodes=nodes,
|
|
1061
|
+
edges=[], # module-level edges are computed on demand (P3/P4)
|
|
1062
|
+
hidden_cross_project_count=0,
|
|
1063
|
+
)
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
async def graph_orphans(
|
|
1067
|
+
ctx: CallerContext,
|
|
1068
|
+
db: aiosqlite.Connection,
|
|
1069
|
+
*,
|
|
1070
|
+
scope: str,
|
|
1071
|
+
visible_projects: set[str] | None,
|
|
1072
|
+
) -> OrphansBundle:
|
|
1073
|
+
"""Return file nodes with no edges (orphans) within a scope.
|
|
1074
|
+
|
|
1075
|
+
Uses a LEFT JOIN anti-join so the planner can use the source/target indexes.
|
|
1076
|
+
Files grouped by first path segment, capped at 30 per sub-cluster.
|
|
1077
|
+
|
|
1078
|
+
``visible_projects`` resolved at the boundary (DECISION 1); 404 on miss
|
|
1079
|
+
(oracle-avoidance). 422 on malformed scope (defence-in-depth; the router's
|
|
1080
|
+
Query pattern also gates it).
|
|
1081
|
+
"""
|
|
1082
|
+
# Extract project_id from scope
|
|
1083
|
+
parts = scope.split(":", 2)
|
|
1084
|
+
if len(parts) < 3:
|
|
1085
|
+
raise ValidationError(code="invalid_scope", message=f"Invalid scope: {scope!r}")
|
|
1086
|
+
|
|
1087
|
+
project_slug = parts[2].split("/")[0] if "/" in parts[2] else parts[2]
|
|
1088
|
+
|
|
1089
|
+
# RBAC: check project visibility (404 oracle-avoidance)
|
|
1090
|
+
if visible_projects is not None and project_slug not in visible_projects:
|
|
1091
|
+
raise NotFoundError(code="not_found", message="Not found")
|
|
1092
|
+
|
|
1093
|
+
db.row_factory = aiosqlite.Row
|
|
1094
|
+
|
|
1095
|
+
# LEFT JOIN anti-join query — must use indexed columns source_id / target_id
|
|
1096
|
+
cur = await db.execute(
|
|
1097
|
+
"""
|
|
1098
|
+
SELECT n.id, n.name, n.qualified_name, n.metadata, n.updated_at
|
|
1099
|
+
FROM graph_nodes n
|
|
1100
|
+
LEFT JOIN graph_edges e1 ON e1.source_id = n.id
|
|
1101
|
+
LEFT JOIN graph_edges e2 ON e2.target_id = n.id
|
|
1102
|
+
WHERE n.type = 'file'
|
|
1103
|
+
AND e1.source_id IS NULL
|
|
1104
|
+
AND e2.target_id IS NULL
|
|
1105
|
+
AND n.deprecated_at IS NULL
|
|
1106
|
+
AND n.project_id = ?
|
|
1107
|
+
ORDER BY n.name
|
|
1108
|
+
""",
|
|
1109
|
+
(project_slug,),
|
|
1110
|
+
)
|
|
1111
|
+
rows = await cur.fetchall()
|
|
1112
|
+
|
|
1113
|
+
# Group by first path segment
|
|
1114
|
+
cluster_map: dict[str, list[dict]] = {}
|
|
1115
|
+
for r in rows:
|
|
1116
|
+
try:
|
|
1117
|
+
meta = json_mod.loads(r["metadata"]) if r["metadata"] else {}
|
|
1118
|
+
except (TypeError, ValueError):
|
|
1119
|
+
meta = {}
|
|
1120
|
+
path_val = meta.get("path", r["name"] or "")
|
|
1121
|
+
folder = path_val.split("/")[0] if "/" in path_val else "(root)"
|
|
1122
|
+
if folder not in cluster_map:
|
|
1123
|
+
cluster_map[folder] = []
|
|
1124
|
+
cluster_map[folder].append({
|
|
1125
|
+
"node_id": r["id"],
|
|
1126
|
+
"label": r["qualified_name"] or r["name"],
|
|
1127
|
+
"path": path_val,
|
|
1128
|
+
"last_modified": r["updated_at"],
|
|
1129
|
+
})
|
|
1130
|
+
|
|
1131
|
+
# Build sub-clusters with 30-cap
|
|
1132
|
+
sub_clusters: list[OrphanSubCluster] = []
|
|
1133
|
+
for folder, file_list in sorted(cluster_map.items()):
|
|
1134
|
+
total = len(file_list)
|
|
1135
|
+
capped = file_list[:30]
|
|
1136
|
+
overflow = total - len(capped)
|
|
1137
|
+
sub_clusters.append(OrphanSubCluster(
|
|
1138
|
+
folder=folder,
|
|
1139
|
+
color=orphan_color(folder),
|
|
1140
|
+
count=total,
|
|
1141
|
+
files=[
|
|
1142
|
+
OrphanFile(
|
|
1143
|
+
node_id=f["node_id"],
|
|
1144
|
+
label=f["label"],
|
|
1145
|
+
path=f["path"],
|
|
1146
|
+
last_modified=parse_db_datetime(f["last_modified"]) if f["last_modified"] else None,
|
|
1147
|
+
)
|
|
1148
|
+
for f in capped
|
|
1149
|
+
],
|
|
1150
|
+
overflow_count=overflow,
|
|
1151
|
+
))
|
|
1152
|
+
|
|
1153
|
+
return OrphansBundle(scope=scope, sub_clusters=sub_clusters)
|