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
core/api/main.py
ADDED
|
@@ -0,0 +1,1641 @@
|
|
|
1
|
+
# v1.18.0 - 2026-05-22 - P1.5.E1: remove PaddleOCR local (broken warm-up since 30-apr); OCR live via Mac Gateway tier-ocr
|
|
2
|
+
# v1.17.0 - 2026-04-30 - P1.5.E0: add PaddleOCR async warm + /health/ocr backend status
|
|
3
|
+
# v1.16.0 - 2026-04-17 - P2: add slowapi rate limiting middleware for /graph endpoints
|
|
4
|
+
# v1.15.0 - 2026-03-15 - Add push delivery + Voyage init in lifespan + orphan proxy cleanup
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import logging
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
10
|
+
from contextlib import asynccontextmanager
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
|
|
13
|
+
import aiosqlite
|
|
14
|
+
from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect
|
|
15
|
+
from fastapi.encoders import jsonable_encoder
|
|
16
|
+
from fastapi.exceptions import RequestValidationError
|
|
17
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
18
|
+
from fastapi.responses import JSONResponse
|
|
19
|
+
from slowapi import _rate_limit_exceeded_handler
|
|
20
|
+
from slowapi.errors import RateLimitExceeded
|
|
21
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
22
|
+
|
|
23
|
+
from core.api.config import settings
|
|
24
|
+
from core.api.db import cleanup_expired_blacklist, cleanup_expired_tickets, run_migrations
|
|
25
|
+
from core.api.use_cases._errors import ServiceError
|
|
26
|
+
from core.api.routers import auth, sessions
|
|
27
|
+
from core.api.routers.comments import router as comments_router
|
|
28
|
+
from core.api.routers.costs import router as costs_router
|
|
29
|
+
from core.api.routers.files import router as files_router
|
|
30
|
+
from core.api.routers.projects import router as projects_router
|
|
31
|
+
from core.api.routers.admin_settings import router as admin_settings_router
|
|
32
|
+
from core.api.routers.settings import router as settings_router
|
|
33
|
+
from core.api.routers.status_updates import router as status_updates_router
|
|
34
|
+
from core.api.routers.tasks import router as tasks_router
|
|
35
|
+
from core.api.services import claude_metrics, codex_metrics, opencode_sessions, tmux
|
|
36
|
+
from core.api.services.metrics_providers import get_metrics_provider
|
|
37
|
+
from core.api.services.providers import get_provider
|
|
38
|
+
from core.api.services.session_metrics_service import (
|
|
39
|
+
compute_cost_session_extended,
|
|
40
|
+
on_conversation_id_changed,
|
|
41
|
+
)
|
|
42
|
+
from core.api.routers.agent import router as agent_router
|
|
43
|
+
from core.api.routers.monitoring import router as monitoring_router
|
|
44
|
+
from core.api.routers.tags import router as tags_router
|
|
45
|
+
from core.api.routers.finder import router as finder_router
|
|
46
|
+
from core.api.routers.finder import share_router as share_manage_router
|
|
47
|
+
from core.api.routers.finder import shared_router as shared_file_router
|
|
48
|
+
from core.api.routers.kg import router as kg_router
|
|
49
|
+
from core.api.routers.share_repo import router as repo_share_router
|
|
50
|
+
from core.api.routers.share_repo import shared_repo_router
|
|
51
|
+
from core.api.routers.pull_requests import router as pull_requests_router
|
|
52
|
+
from core.api.routers.users import router as users_router
|
|
53
|
+
from core.api.routers.raci import router as raci_router
|
|
54
|
+
from core.api.routers.audit import router as audit_router
|
|
55
|
+
from core.api.routers.webhooks import router as webhooks_router
|
|
56
|
+
from core.api.routers.admin_pr_impact import router as admin_pr_impact_router
|
|
57
|
+
from core.api.routers.pr_impact import router as pr_impact_router
|
|
58
|
+
from core.api.routers.agent_tokens import router as agent_tokens_router
|
|
59
|
+
from core.api.routers.handoffs import router as handoffs_router
|
|
60
|
+
from core.api.routers.teams import router as teams_router
|
|
61
|
+
from core.api.routers.graph import router as graph_router
|
|
62
|
+
from core.api.routers.learnings import router as learnings_router
|
|
63
|
+
try:
|
|
64
|
+
from core.api.routers.automations import router as automations_router
|
|
65
|
+
except ImportError: # SaaS-only module, absent in OSS mirror
|
|
66
|
+
automations_router = None
|
|
67
|
+
from core.api.routers.notifications import router as notifications_router
|
|
68
|
+
from core.api.routers.push import router as push_router
|
|
69
|
+
from core.api.routers.ci_checks import router as ci_checks_router
|
|
70
|
+
from core.api.routers.search import router as search_router
|
|
71
|
+
from core.api.routers.documents import router as documents_router
|
|
72
|
+
from core.api.routers.docs_coverage import router as docs_coverage_router
|
|
73
|
+
from core.api.routers.docs_governance import router as docs_governance_router
|
|
74
|
+
from core.api.routers.judge import router as judge_router
|
|
75
|
+
from core.api.routers.inbox import router as inbox_router
|
|
76
|
+
from core.api.routers.ingest_triage import router as ingest_triage_router
|
|
77
|
+
from core.api.routers.ingest_api_keys import router as ingest_api_keys_router
|
|
78
|
+
from core.api.routers.llm_config import router as llm_config_router
|
|
79
|
+
try:
|
|
80
|
+
from core.api.routers.newsletter import router as newsletter_router
|
|
81
|
+
except ImportError: # SaaS-only module, absent in OSS mirror
|
|
82
|
+
newsletter_router = None
|
|
83
|
+
from core.api.routers.bench import router as bench_router
|
|
84
|
+
from core.api.routers.app_settings import router as app_settings_router
|
|
85
|
+
try:
|
|
86
|
+
from core.api.routers.reddit import router as reddit_router
|
|
87
|
+
except ImportError: # SaaS-only module, absent in OSS mirror
|
|
88
|
+
reddit_router = None
|
|
89
|
+
from core.api.routers.terminal import router as terminal_metrics_router
|
|
90
|
+
from core.api.routers.brain import router as brain_router
|
|
91
|
+
from core.api.routers.brain_directions import router as brain_directions_router
|
|
92
|
+
from core.api.services.metrics_collector import metrics_collector
|
|
93
|
+
from core.api.services.security_collector import security_collector
|
|
94
|
+
from core.api.terminal import router as terminal_router
|
|
95
|
+
|
|
96
|
+
logging.basicConfig(
|
|
97
|
+
level=logging.INFO,
|
|
98
|
+
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
99
|
+
)
|
|
100
|
+
logger = logging.getLogger(__name__)
|
|
101
|
+
|
|
102
|
+
_cleanup_task: asyncio.Task | None = None
|
|
103
|
+
_session_maintenance_task: asyncio.Task | None = None
|
|
104
|
+
_metrics_task: asyncio.Task | None = None
|
|
105
|
+
_security_task: asyncio.Task | None = None
|
|
106
|
+
_maintenance_task: asyncio.Task | None = None
|
|
107
|
+
_reminder_task: asyncio.Task | None = None
|
|
108
|
+
_n8n_dispatch_task: asyncio.Task | None = None
|
|
109
|
+
_push_delivery_task: asyncio.Task | None = None
|
|
110
|
+
_notifications_sync_task: asyncio.Task | None = None
|
|
111
|
+
_inbox_digest_task: asyncio.Task | None = None
|
|
112
|
+
_brain_jobs_task: asyncio.Task | None = None
|
|
113
|
+
_wal_checkpoint_task: asyncio.Task | None = None
|
|
114
|
+
_terminal_metrics_dump_task: asyncio.Task | None = None
|
|
115
|
+
_pr_impact_sweep_task: asyncio.Task | None = None
|
|
116
|
+
_pr_impact_gc_task: asyncio.Task | None = None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def _session_has_active_cli_for_provider(
|
|
120
|
+
name: str,
|
|
121
|
+
provider: str | None,
|
|
122
|
+
status: str | None,
|
|
123
|
+
cache: dict[tuple[str, str], bool] | None = None,
|
|
124
|
+
) -> bool:
|
|
125
|
+
"""Return true when tmux foreground or child process matches the provider CLI."""
|
|
126
|
+
from core.api.services.providers import ALL_KNOWN_PROCESS_NAMES, get_provider
|
|
127
|
+
|
|
128
|
+
if status in ALL_KNOWN_PROCESS_NAMES:
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
provider_name = provider or "claude"
|
|
132
|
+
cache_key = (name, provider_name)
|
|
133
|
+
if cache is not None and cache_key in cache:
|
|
134
|
+
return cache[cache_key]
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
process_names = get_provider(provider_name).process_names
|
|
138
|
+
except ValueError:
|
|
139
|
+
process_names = ALL_KNOWN_PROCESS_NAMES
|
|
140
|
+
|
|
141
|
+
active = await tmux.get_cli_pid(name, process_names) is not None
|
|
142
|
+
if cache is not None:
|
|
143
|
+
cache[cache_key] = active
|
|
144
|
+
return active
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def _periodic_cleanup() -> None:
|
|
148
|
+
"""Background task: clean expired tickets and blacklist entries every hour."""
|
|
149
|
+
while True:
|
|
150
|
+
try:
|
|
151
|
+
await asyncio.sleep(3600)
|
|
152
|
+
from core.api.db import write_db
|
|
153
|
+
|
|
154
|
+
from core.api.services.ingest.ingress import cleanup_ingest_ephemeral
|
|
155
|
+
|
|
156
|
+
async with write_db() as db:
|
|
157
|
+
tickets = await cleanup_expired_tickets(db)
|
|
158
|
+
blacklist = await cleanup_expired_blacklist(db)
|
|
159
|
+
idem, rate, quota = await cleanup_ingest_ephemeral(db)
|
|
160
|
+
if tickets or blacklist or idem or rate or quota:
|
|
161
|
+
logger.info(
|
|
162
|
+
"Cleanup: %d tickets, %d blacklist, %d idempotency, "
|
|
163
|
+
"%d rate, %d quota rows pruned",
|
|
164
|
+
tickets,
|
|
165
|
+
blacklist,
|
|
166
|
+
idem,
|
|
167
|
+
rate,
|
|
168
|
+
quota,
|
|
169
|
+
)
|
|
170
|
+
except asyncio.CancelledError:
|
|
171
|
+
break
|
|
172
|
+
except Exception:
|
|
173
|
+
logger.exception("Periodic cleanup error")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def _periodic_session_maintenance() -> None:
|
|
177
|
+
"""Background task: auto-hibernate idle sessions + persist costs. Every 2 minutes.
|
|
178
|
+
|
|
179
|
+
Structured as gather-then-write: all external I/O (tmux, metrics parsing,
|
|
180
|
+
file checks) happens OUTSIDE the DB write lock. SQL operations are collected
|
|
181
|
+
as (sql, params) tuples and executed in a single write_db() batch at the end.
|
|
182
|
+
Target: write lock held <100ms (was 4-12s before refactor).
|
|
183
|
+
"""
|
|
184
|
+
io_pool = ThreadPoolExecutor(max_workers=8, thread_name_prefix="cost-io")
|
|
185
|
+
loop = asyncio.get_running_loop()
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
while True:
|
|
189
|
+
try:
|
|
190
|
+
await asyncio.sleep(120) # Check every 2 minutes
|
|
191
|
+
|
|
192
|
+
from core.api.db import acquire_db, write_db
|
|
193
|
+
import time as _time
|
|
194
|
+
|
|
195
|
+
# Pending SQL operations: list of (sql, params) tuples
|
|
196
|
+
pending_ops: list[tuple[str, tuple]] = []
|
|
197
|
+
active_cli_cache: dict[tuple[str, str], bool] = {}
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
reconciled = await sessions.reconcile_sessions_metadata()
|
|
201
|
+
if reconciled:
|
|
202
|
+
logger.info(
|
|
203
|
+
"Session maintenance: reconciled %d session metadata rows",
|
|
204
|
+
reconciled,
|
|
205
|
+
)
|
|
206
|
+
except Exception:
|
|
207
|
+
logger.exception("Session metadata reconciliation failed")
|
|
208
|
+
|
|
209
|
+
# ============================================================
|
|
210
|
+
# PHASE 1: READ — fetch all needed data from DB (read pool)
|
|
211
|
+
# ============================================================
|
|
212
|
+
async with acquire_db() as rdb:
|
|
213
|
+
cursor = await rdb.execute(
|
|
214
|
+
"""SELECT name, auto_hibernate_minutes, conversation_id, project_slug, provider
|
|
215
|
+
FROM sessions_meta
|
|
216
|
+
WHERE hibernated = 0
|
|
217
|
+
AND (auto_hibernate_minutes > 0 OR auto_hibernate_minutes IS NULL)"""
|
|
218
|
+
)
|
|
219
|
+
hibernate_rows = [dict(r) for r in await cursor.fetchall()]
|
|
220
|
+
if not settings.auto_hibernate_enabled:
|
|
221
|
+
hibernate_rows = []
|
|
222
|
+
|
|
223
|
+
detect_cursor = await rdb.execute(
|
|
224
|
+
"""SELECT name, project_slug, provider FROM sessions_meta
|
|
225
|
+
WHERE hibernated = 0 AND conversation_id IS NULL"""
|
|
226
|
+
)
|
|
227
|
+
detect_rows = [dict(r) for r in await detect_cursor.fetchall()]
|
|
228
|
+
|
|
229
|
+
cost_cursor = await rdb.execute(
|
|
230
|
+
"SELECT name, conversation_id, project_slug, provider FROM sessions_meta "
|
|
231
|
+
"WHERE conversation_id IS NOT NULL"
|
|
232
|
+
)
|
|
233
|
+
cost_rows = [dict(r) for r in await cost_cursor.fetchall()]
|
|
234
|
+
|
|
235
|
+
metrics_cursor = await rdb.execute(
|
|
236
|
+
"""SELECT name, conversation_id, provider FROM sessions_meta
|
|
237
|
+
WHERE hibernated = 0 AND conversation_id IS NOT NULL"""
|
|
238
|
+
)
|
|
239
|
+
metrics_rows = [dict(r) for r in await metrics_cursor.fetchall()]
|
|
240
|
+
|
|
241
|
+
# PR3: Step 3 (pane-scraped working_seconds accumulator) removed.
|
|
242
|
+
# `working_seconds_msg` (migration 087) is the single source of
|
|
243
|
+
# truth — populated by parse_session via provider-specific
|
|
244
|
+
# message-gap analysis (claude_metrics, opencode_metrics).
|
|
245
|
+
|
|
246
|
+
# ============================================================
|
|
247
|
+
# PHASE 2: GATHER — all external I/O outside any DB context
|
|
248
|
+
# ============================================================
|
|
249
|
+
|
|
250
|
+
# Get tmux statuses once (reused by all steps)
|
|
251
|
+
statuses = (
|
|
252
|
+
await tmux.get_all_session_statuses()
|
|
253
|
+
if (
|
|
254
|
+
hibernate_rows
|
|
255
|
+
or detect_rows
|
|
256
|
+
or cost_rows
|
|
257
|
+
or metrics_rows
|
|
258
|
+
)
|
|
259
|
+
else {}
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Cache metrics parsed during hibernate check for reuse in cost upsert
|
|
263
|
+
parsed_metrics: dict[str, claude_metrics.SessionMetrics] = {}
|
|
264
|
+
|
|
265
|
+
# --- Step 1: Auto-hibernate gather ---
|
|
266
|
+
for row in hibernate_rows:
|
|
267
|
+
name = row["name"]
|
|
268
|
+
max_idle_min = row["auto_hibernate_minutes"] or 240
|
|
269
|
+
status = statuses.get(name)
|
|
270
|
+
row_provider = row["provider"] if row["provider"] else "claude"
|
|
271
|
+
|
|
272
|
+
if not await _session_has_active_cli_for_provider(
|
|
273
|
+
name, row_provider, status, active_cli_cache
|
|
274
|
+
):
|
|
275
|
+
continue
|
|
276
|
+
mp = get_metrics_provider(row_provider)
|
|
277
|
+
if mp is None:
|
|
278
|
+
continue
|
|
279
|
+
# Auto-hibernate detection currently has Claude-only fallbacks
|
|
280
|
+
# (pane_start → JSONL timestamp). Non-Claude providers need a
|
|
281
|
+
# stored conversation_id to be auto-hibernatable. PR2 wires
|
|
282
|
+
# OpenCode detection through opencode_sessions.
|
|
283
|
+
conv_id = row["conversation_id"]
|
|
284
|
+
if not conv_id and row_provider == "claude":
|
|
285
|
+
pane_start = await tmux.get_pane_start_time(name)
|
|
286
|
+
if pane_start:
|
|
287
|
+
conv_id = claude_metrics.detect_conversation_for_session(
|
|
288
|
+
pane_start
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
if not conv_id:
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
metrics = await loop.run_in_executor(
|
|
295
|
+
io_pool, mp.parse_session, conv_id, None
|
|
296
|
+
)
|
|
297
|
+
if metrics:
|
|
298
|
+
parsed_metrics[conv_id] = metrics
|
|
299
|
+
|
|
300
|
+
if not metrics or not metrics.last_timestamp:
|
|
301
|
+
continue
|
|
302
|
+
|
|
303
|
+
last_activity = datetime.fromisoformat(
|
|
304
|
+
metrics.last_timestamp.replace("Z", "+00:00")
|
|
305
|
+
)
|
|
306
|
+
idle_minutes = (
|
|
307
|
+
datetime.now(timezone.utc) - last_activity
|
|
308
|
+
).total_seconds() / 60
|
|
309
|
+
|
|
310
|
+
if idle_minutes >= max_idle_min:
|
|
311
|
+
# Guard: check JSONL mtime
|
|
312
|
+
_jsonl_stale = True
|
|
313
|
+
try:
|
|
314
|
+
for _pdir in claude_metrics.CLAUDE_PROJECTS_DIR.iterdir():
|
|
315
|
+
_jsonl_path = _pdir / f"{conv_id}.jsonl"
|
|
316
|
+
if _jsonl_path.exists():
|
|
317
|
+
_file_age_min = (
|
|
318
|
+
_time.time() - _jsonl_path.stat().st_mtime
|
|
319
|
+
) / 60
|
|
320
|
+
_jsonl_stale = _file_age_min >= max_idle_min
|
|
321
|
+
break
|
|
322
|
+
except Exception:
|
|
323
|
+
pass
|
|
324
|
+
if not _jsonl_stale:
|
|
325
|
+
logger.debug(
|
|
326
|
+
"Session %s: last assistant %.0f min ago but JSONL recently written, skipping hibernate",
|
|
327
|
+
name,
|
|
328
|
+
idle_minutes,
|
|
329
|
+
)
|
|
330
|
+
continue
|
|
331
|
+
|
|
332
|
+
logger.info(
|
|
333
|
+
"Auto-hibernating session %s (idle %.0f min, threshold %d min)",
|
|
334
|
+
name,
|
|
335
|
+
idle_minutes,
|
|
336
|
+
max_idle_min,
|
|
337
|
+
)
|
|
338
|
+
# Send tmux keys to hibernate (slow I/O, outside DB lock)
|
|
339
|
+
await tmux.send_keys_raw(name, "C-c")
|
|
340
|
+
await asyncio.sleep(1.0)
|
|
341
|
+
await tmux.send_keys_raw(name, "C-u")
|
|
342
|
+
await asyncio.sleep(0.3)
|
|
343
|
+
await tmux.send_keys_raw(name, "/exit")
|
|
344
|
+
await asyncio.sleep(0.5)
|
|
345
|
+
await tmux.send_keys_raw(name, "Escape")
|
|
346
|
+
await asyncio.sleep(0.3)
|
|
347
|
+
await tmux.send_keys_raw(name, "Enter")
|
|
348
|
+
await asyncio.sleep(2.0)
|
|
349
|
+
|
|
350
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
351
|
+
# PR3: drop last_context_pct from hibernate write.
|
|
352
|
+
# Real/scaled columns (migration 087) are populated by
|
|
353
|
+
# Step 2.5 a few lines below; last_context_pct_legacy
|
|
354
|
+
# stays for forensic reads only.
|
|
355
|
+
pending_ops.append(
|
|
356
|
+
(
|
|
357
|
+
"""UPDATE sessions_meta SET
|
|
358
|
+
hibernated = 1, hibernated_at = ?,
|
|
359
|
+
conversation_id = ?, model = ?,
|
|
360
|
+
last_cost_usd = ?,
|
|
361
|
+
last_message_count = ?
|
|
362
|
+
WHERE name = ?""",
|
|
363
|
+
(
|
|
364
|
+
now,
|
|
365
|
+
conv_id,
|
|
366
|
+
metrics.model,
|
|
367
|
+
metrics.cost_usd,
|
|
368
|
+
metrics.message_count,
|
|
369
|
+
name,
|
|
370
|
+
),
|
|
371
|
+
)
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# --- Step 1.5: Detect conversation_id gather ---
|
|
375
|
+
newly_detected: dict[str, set[str]] = {}
|
|
376
|
+
for row in detect_rows:
|
|
377
|
+
name = row["name"]
|
|
378
|
+
status = statuses.get(name)
|
|
379
|
+
row_provider = row["provider"] if row["provider"] else "claude"
|
|
380
|
+
if not await _session_has_active_cli_for_provider(
|
|
381
|
+
name, row_provider, status, active_cli_cache
|
|
382
|
+
):
|
|
383
|
+
continue
|
|
384
|
+
|
|
385
|
+
conv_id: str | None = None
|
|
386
|
+
|
|
387
|
+
if row_provider == "claude":
|
|
388
|
+
pane_id = await tmux.get_pane_id(name)
|
|
389
|
+
if pane_id:
|
|
390
|
+
conv_id = await loop.run_in_executor(
|
|
391
|
+
io_pool, claude_metrics.read_pane_session_id, pane_id
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
if not conv_id:
|
|
395
|
+
claude_pid = await tmux.get_claude_pid(name)
|
|
396
|
+
if claude_pid:
|
|
397
|
+
conv_id = claude_metrics.detect_conversation_by_pid(
|
|
398
|
+
claude_pid
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
if not conv_id:
|
|
402
|
+
pane_start = await tmux.get_pane_start_time(name)
|
|
403
|
+
if pane_start:
|
|
404
|
+
conv_id = await loop.run_in_executor(
|
|
405
|
+
io_pool,
|
|
406
|
+
claude_metrics.detect_conversation_for_session,
|
|
407
|
+
pane_start,
|
|
408
|
+
)
|
|
409
|
+
elif row_provider == "opencode":
|
|
410
|
+
# Detect by pane cwd + time_created window. Needed for
|
|
411
|
+
# sessions that launched OpenCode manually (bypassing
|
|
412
|
+
# the Console "New Session" modal flow, which wires
|
|
413
|
+
# conversation_id via wait_for_new_session_id).
|
|
414
|
+
pane_cwd = await tmux.get_pane_cwd(name)
|
|
415
|
+
if pane_cwd:
|
|
416
|
+
pane_start = await tmux.get_pane_start_time(name)
|
|
417
|
+
pane_start_ms = (
|
|
418
|
+
int(pane_start * 1000) if pane_start else None
|
|
419
|
+
)
|
|
420
|
+
# Exclude ids already bound to OTHER tmux sessions
|
|
421
|
+
# so we don't steal another pane's conversation
|
|
422
|
+
# when cwds happen to coincide.
|
|
423
|
+
already_linked = [
|
|
424
|
+
r["conversation_id"]
|
|
425
|
+
for r in cost_rows
|
|
426
|
+
if r["name"] != name
|
|
427
|
+
and r["conversation_id"]
|
|
428
|
+
and (r["provider"] or "claude") == "opencode"
|
|
429
|
+
]
|
|
430
|
+
conv_id = await loop.run_in_executor(
|
|
431
|
+
io_pool,
|
|
432
|
+
opencode_sessions.detect_opencode_for_session,
|
|
433
|
+
pane_cwd,
|
|
434
|
+
pane_start_ms,
|
|
435
|
+
already_linked,
|
|
436
|
+
)
|
|
437
|
+
elif row_provider == "codex":
|
|
438
|
+
already_linked = [
|
|
439
|
+
r["conversation_id"]
|
|
440
|
+
for r in cost_rows
|
|
441
|
+
if r["name"] != name
|
|
442
|
+
and r["conversation_id"]
|
|
443
|
+
and (r["provider"] or "claude") == "codex"
|
|
444
|
+
]
|
|
445
|
+
already_linked.extend(newly_detected.get("codex", set()))
|
|
446
|
+
codex_pid = await tmux.get_cli_pid(
|
|
447
|
+
name, get_provider("codex").process_names
|
|
448
|
+
)
|
|
449
|
+
if codex_pid:
|
|
450
|
+
conv_id = await loop.run_in_executor(
|
|
451
|
+
io_pool,
|
|
452
|
+
codex_metrics.detect_codex_for_process,
|
|
453
|
+
codex_pid,
|
|
454
|
+
already_linked,
|
|
455
|
+
)
|
|
456
|
+
if not conv_id:
|
|
457
|
+
pane_start = await tmux.get_pane_start_time(name)
|
|
458
|
+
pane_cwd = await tmux.get_pane_cwd(name)
|
|
459
|
+
conv_id = await loop.run_in_executor(
|
|
460
|
+
io_pool,
|
|
461
|
+
codex_metrics.detect_codex_for_session,
|
|
462
|
+
pane_start,
|
|
463
|
+
pane_cwd,
|
|
464
|
+
already_linked,
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
if conv_id:
|
|
468
|
+
newly_detected.setdefault(row_provider, set()).add(conv_id)
|
|
469
|
+
if row_provider == "codex":
|
|
470
|
+
# Codex footer fields come from sessions_meta; refresh
|
|
471
|
+
# the newly linked JSONL in this maintenance cycle
|
|
472
|
+
# instead of waiting for the next 2-minute pass.
|
|
473
|
+
linked_row = {
|
|
474
|
+
"name": name,
|
|
475
|
+
"conversation_id": conv_id,
|
|
476
|
+
"project_slug": row["project_slug"],
|
|
477
|
+
"provider": row_provider,
|
|
478
|
+
}
|
|
479
|
+
cost_rows.append(linked_row)
|
|
480
|
+
metrics_rows.append(linked_row)
|
|
481
|
+
pending_ops.append(
|
|
482
|
+
(
|
|
483
|
+
"UPDATE sessions_meta SET conversation_id = ? WHERE name = ?",
|
|
484
|
+
(conv_id, name),
|
|
485
|
+
)
|
|
486
|
+
)
|
|
487
|
+
# PR2: track into resume chain. INSERT OR IGNORE so
|
|
488
|
+
# repeated detections of the same conv_id are no-ops.
|
|
489
|
+
pending_ops.append(
|
|
490
|
+
(
|
|
491
|
+
"INSERT OR IGNORE INTO session_conversations "
|
|
492
|
+
"(session_name, conversation_id, ord, created_at) "
|
|
493
|
+
"VALUES (?, ?, "
|
|
494
|
+
"COALESCE((SELECT MAX(ord)+1 FROM session_conversations WHERE session_name=?), 0), "
|
|
495
|
+
"?)",
|
|
496
|
+
(
|
|
497
|
+
name,
|
|
498
|
+
conv_id,
|
|
499
|
+
name,
|
|
500
|
+
datetime.now(timezone.utc).isoformat(),
|
|
501
|
+
),
|
|
502
|
+
)
|
|
503
|
+
)
|
|
504
|
+
logger.info(
|
|
505
|
+
"Detected conversation_id for %s session %s: %s",
|
|
506
|
+
row_provider,
|
|
507
|
+
name,
|
|
508
|
+
conv_id,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
# --- Step 2: Cost persistence gather ---
|
|
512
|
+
upserted = 0
|
|
513
|
+
for row in cost_rows:
|
|
514
|
+
try:
|
|
515
|
+
conv_id = row["conversation_id"]
|
|
516
|
+
row_provider = row["provider"] if row["provider"] else "claude"
|
|
517
|
+
mp = get_metrics_provider(row_provider)
|
|
518
|
+
if mp is None:
|
|
519
|
+
continue
|
|
520
|
+
metrics = parsed_metrics.get(conv_id)
|
|
521
|
+
if not metrics:
|
|
522
|
+
metrics = await loop.run_in_executor(
|
|
523
|
+
io_pool,
|
|
524
|
+
mp.parse_session,
|
|
525
|
+
conv_id,
|
|
526
|
+
None,
|
|
527
|
+
)
|
|
528
|
+
if not metrics:
|
|
529
|
+
continue
|
|
530
|
+
|
|
531
|
+
pending_ops.append(
|
|
532
|
+
(
|
|
533
|
+
"""
|
|
534
|
+
INSERT INTO session_costs (conversation_id, session_name, project_slug, model,
|
|
535
|
+
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
|
536
|
+
cost_usd, message_count, updated_at)
|
|
537
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
538
|
+
ON CONFLICT(conversation_id) DO UPDATE SET
|
|
539
|
+
session_name = excluded.session_name,
|
|
540
|
+
project_slug = excluded.project_slug,
|
|
541
|
+
model = excluded.model,
|
|
542
|
+
input_tokens = excluded.input_tokens,
|
|
543
|
+
output_tokens = excluded.output_tokens,
|
|
544
|
+
cache_read_tokens = excluded.cache_read_tokens,
|
|
545
|
+
cache_write_tokens = excluded.cache_write_tokens,
|
|
546
|
+
cost_usd = excluded.cost_usd,
|
|
547
|
+
message_count = excluded.message_count,
|
|
548
|
+
updated_at = excluded.updated_at
|
|
549
|
+
""",
|
|
550
|
+
(
|
|
551
|
+
conv_id,
|
|
552
|
+
row["name"],
|
|
553
|
+
row["project_slug"],
|
|
554
|
+
metrics.model,
|
|
555
|
+
metrics.input_tokens,
|
|
556
|
+
metrics.output_tokens,
|
|
557
|
+
metrics.cache_read_tokens,
|
|
558
|
+
metrics.cache_write_tokens,
|
|
559
|
+
metrics.cost_usd,
|
|
560
|
+
metrics.message_count,
|
|
561
|
+
),
|
|
562
|
+
)
|
|
563
|
+
)
|
|
564
|
+
# PR2: dual cost (conversation vs session-cumulative).
|
|
565
|
+
# PR4: plus shadow-cost (equivalent) aggregated across
|
|
566
|
+
# the same resume chain. Session cost aggregates across
|
|
567
|
+
# `session_conversations`; equivalent remains None when
|
|
568
|
+
# no conversation in chain had known pricing (skip).
|
|
569
|
+
cost_session_total: float = metrics.cost_conversation_usd or metrics.cost_usd or 0.0
|
|
570
|
+
cost_session_equivalent: float | None = (
|
|
571
|
+
metrics.cost_session_equivalent_usd
|
|
572
|
+
if metrics.cost_session_equivalent_usd is not None
|
|
573
|
+
else metrics.cost_conversation_equivalent_usd
|
|
574
|
+
)
|
|
575
|
+
equivalent_version = metrics.cost_equivalent_pricing_version
|
|
576
|
+
cost_session_incomplete = False
|
|
577
|
+
try:
|
|
578
|
+
async with acquire_db() as _rdb:
|
|
579
|
+
(
|
|
580
|
+
cost_session_total,
|
|
581
|
+
equiv_total,
|
|
582
|
+
equiv_version_db,
|
|
583
|
+
cost_session_complete,
|
|
584
|
+
) = await compute_cost_session_extended(
|
|
585
|
+
_rdb, row["name"], row_provider
|
|
586
|
+
)
|
|
587
|
+
cost_session_incomplete = not cost_session_complete
|
|
588
|
+
if equiv_total is not None:
|
|
589
|
+
cost_session_equivalent = equiv_total
|
|
590
|
+
if equiv_version_db:
|
|
591
|
+
equivalent_version = equiv_version_db
|
|
592
|
+
except Exception:
|
|
593
|
+
logger.debug(
|
|
594
|
+
"cost_session aggregation failed for %s (falling back to conversation-only)",
|
|
595
|
+
row["name"],
|
|
596
|
+
exc_info=True,
|
|
597
|
+
)
|
|
598
|
+
cost_session_total = metrics.cost_conversation_usd or metrics.cost_usd or 0.0
|
|
599
|
+
cost_session_incomplete = True
|
|
600
|
+
|
|
601
|
+
pending_ops.append(
|
|
602
|
+
(
|
|
603
|
+
"""UPDATE sessions_meta SET
|
|
604
|
+
last_cost_usd = ?,
|
|
605
|
+
last_cost_conversation_usd = ?,
|
|
606
|
+
last_cost_session_usd = ?,
|
|
607
|
+
last_cost_session_incomplete = ?,
|
|
608
|
+
last_cost_conversation_equivalent_usd = ?,
|
|
609
|
+
last_cost_session_equivalent_usd = ?,
|
|
610
|
+
last_cost_equivalent_pricing_version = ?,
|
|
611
|
+
last_context_pct_real = ?,
|
|
612
|
+
last_context_pct_scaled = ?,
|
|
613
|
+
last_input_tokens = ?,
|
|
614
|
+
last_output_tokens = ?,
|
|
615
|
+
last_reasoning_tokens = ?,
|
|
616
|
+
working_seconds_msg = ?,
|
|
617
|
+
metrics_refreshed_at = datetime('now'),
|
|
618
|
+
pricing_version = ?,
|
|
619
|
+
last_message_count = ?,
|
|
620
|
+
model = COALESCE(?, model)
|
|
621
|
+
WHERE name = ?""",
|
|
622
|
+
(
|
|
623
|
+
metrics.cost_usd, # legacy alias (= conversation)
|
|
624
|
+
metrics.cost_conversation_usd or metrics.cost_usd,
|
|
625
|
+
cost_session_total,
|
|
626
|
+
1 if cost_session_incomplete else 0,
|
|
627
|
+
metrics.cost_conversation_equivalent_usd,
|
|
628
|
+
cost_session_equivalent,
|
|
629
|
+
equivalent_version,
|
|
630
|
+
metrics.context_pct_real,
|
|
631
|
+
metrics.context_pct_scaled,
|
|
632
|
+
metrics.input_tokens,
|
|
633
|
+
metrics.output_tokens,
|
|
634
|
+
metrics.reasoning_tokens,
|
|
635
|
+
metrics.working_seconds_msg,
|
|
636
|
+
metrics.pricing_version,
|
|
637
|
+
metrics.message_count,
|
|
638
|
+
metrics.model,
|
|
639
|
+
row["name"],
|
|
640
|
+
),
|
|
641
|
+
)
|
|
642
|
+
)
|
|
643
|
+
upserted += 1
|
|
644
|
+
except Exception:
|
|
645
|
+
logger.exception("Cost gather failed for %s", row["name"])
|
|
646
|
+
|
|
647
|
+
# --- Step 2.5: Refresh context_pct gather ---
|
|
648
|
+
metrics_updated = 0
|
|
649
|
+
for row in metrics_rows:
|
|
650
|
+
name = row["name"]
|
|
651
|
+
conv_id = row["conversation_id"]
|
|
652
|
+
status = statuses.get(name)
|
|
653
|
+
row_provider = row["provider"] if row["provider"] else "claude"
|
|
654
|
+
if not await _session_has_active_cli_for_provider(
|
|
655
|
+
name, row_provider, status, active_cli_cache
|
|
656
|
+
):
|
|
657
|
+
continue
|
|
658
|
+
mp = get_metrics_provider(row_provider)
|
|
659
|
+
if mp is None:
|
|
660
|
+
continue
|
|
661
|
+
|
|
662
|
+
# Claude-only fast path: statusline.sh writes pane-metrics on
|
|
663
|
+
# every tick; avoids a JSONL tail read when available.
|
|
664
|
+
# PR3: write the real ratio, not the /84 auto-compact fudge.
|
|
665
|
+
# context_pct_scaled is populated by the parser (PR2) —
|
|
666
|
+
# don't duplicate it here.
|
|
667
|
+
if row_provider == "claude":
|
|
668
|
+
pane_id_for_metrics = await tmux.get_pane_id(name)
|
|
669
|
+
if pane_id_for_metrics:
|
|
670
|
+
pm = await loop.run_in_executor(
|
|
671
|
+
io_pool,
|
|
672
|
+
claude_metrics.read_pane_metrics,
|
|
673
|
+
pane_id_for_metrics,
|
|
674
|
+
)
|
|
675
|
+
if pm and pm.session_id == conv_id:
|
|
676
|
+
real_pct = min(round(pm.used_pct, 1), 100.0)
|
|
677
|
+
pending_ops.append(
|
|
678
|
+
(
|
|
679
|
+
"UPDATE sessions_meta SET last_context_pct_real = ? WHERE name = ?",
|
|
680
|
+
(real_pct, name),
|
|
681
|
+
)
|
|
682
|
+
)
|
|
683
|
+
metrics_updated += 1
|
|
684
|
+
continue
|
|
685
|
+
|
|
686
|
+
context_pct = await loop.run_in_executor(
|
|
687
|
+
io_pool, mp.get_last_context_pct, conv_id, None
|
|
688
|
+
)
|
|
689
|
+
if context_pct is not None:
|
|
690
|
+
pending_ops.append(
|
|
691
|
+
(
|
|
692
|
+
"UPDATE sessions_meta SET last_context_pct_real = ? WHERE name = ?",
|
|
693
|
+
(context_pct, name),
|
|
694
|
+
)
|
|
695
|
+
)
|
|
696
|
+
metrics_updated += 1
|
|
697
|
+
|
|
698
|
+
# PR3: Step 3 removed — see note in PHASE 1 query block above.
|
|
699
|
+
|
|
700
|
+
# ============================================================
|
|
701
|
+
# PHASE 3: WRITE — single batch, lock held <100ms
|
|
702
|
+
# ============================================================
|
|
703
|
+
if pending_ops:
|
|
704
|
+
async with write_db() as db:
|
|
705
|
+
for sql, params in pending_ops:
|
|
706
|
+
await db.execute(sql, params)
|
|
707
|
+
# write_db() auto-commits on exit
|
|
708
|
+
|
|
709
|
+
if upserted > 0:
|
|
710
|
+
logger.info(
|
|
711
|
+
"Session maintenance: upserted %d cost records", upserted
|
|
712
|
+
)
|
|
713
|
+
if metrics_updated > 0:
|
|
714
|
+
logger.info(
|
|
715
|
+
"Session maintenance: refreshed context_pct for %d sessions",
|
|
716
|
+
metrics_updated,
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
except asyncio.CancelledError:
|
|
720
|
+
raise
|
|
721
|
+
except Exception:
|
|
722
|
+
logger.exception("Session maintenance error")
|
|
723
|
+
except asyncio.CancelledError:
|
|
724
|
+
pass
|
|
725
|
+
finally:
|
|
726
|
+
io_pool.shutdown(wait=False)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
async def _periodic_metrics_collection() -> None:
|
|
730
|
+
"""Background task: collect server metrics.
|
|
731
|
+
|
|
732
|
+
System metrics (CPU/RAM/disk/network) every monitoring_metrics_interval (10s).
|
|
733
|
+
Docker stats every monitoring_docker_interval (60s) — separate to avoid
|
|
734
|
+
blocking the event loop with N JSON parses every 10s.
|
|
735
|
+
"""
|
|
736
|
+
docker_counter = 0
|
|
737
|
+
docker_every_n = max(
|
|
738
|
+
1, settings.monitoring_docker_interval // settings.monitoring_metrics_interval
|
|
739
|
+
)
|
|
740
|
+
while True:
|
|
741
|
+
try:
|
|
742
|
+
await asyncio.sleep(settings.monitoring_metrics_interval)
|
|
743
|
+
docker_counter += 1
|
|
744
|
+
include_docker = docker_counter % docker_every_n == 0
|
|
745
|
+
snapshot = await metrics_collector.collect_all(
|
|
746
|
+
include_docker=include_docker
|
|
747
|
+
)
|
|
748
|
+
await metrics_collector.save_to_db(snapshot)
|
|
749
|
+
except asyncio.CancelledError:
|
|
750
|
+
break
|
|
751
|
+
except Exception:
|
|
752
|
+
logger.exception("Metrics collection error")
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
async def _periodic_security_collection() -> None:
|
|
756
|
+
"""Background task: collect security events every 60 seconds."""
|
|
757
|
+
while True:
|
|
758
|
+
try:
|
|
759
|
+
await asyncio.sleep(settings.monitoring_security_interval)
|
|
760
|
+
events = await asyncio.to_thread(security_collector.read_new_ssh_events)
|
|
761
|
+
if events:
|
|
762
|
+
await security_collector.save_events_to_db(events)
|
|
763
|
+
except asyncio.CancelledError:
|
|
764
|
+
break
|
|
765
|
+
except Exception:
|
|
766
|
+
logger.exception("Security collection error")
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
async def _periodic_metrics_maintenance() -> None:
|
|
770
|
+
"""Background task: aggregate candles and cleanup old data. Runs hourly."""
|
|
771
|
+
while True:
|
|
772
|
+
try:
|
|
773
|
+
await asyncio.sleep(3600)
|
|
774
|
+
# Aggregate BEFORE cleanup to avoid data loss
|
|
775
|
+
candles = await metrics_collector.aggregate_to_candles()
|
|
776
|
+
raw = await metrics_collector.cleanup_old_raw(
|
|
777
|
+
hours=settings.monitoring_retention_raw_hours
|
|
778
|
+
)
|
|
779
|
+
old_candles = await metrics_collector.cleanup_old_candles(
|
|
780
|
+
days=settings.monitoring_retention_candles_days
|
|
781
|
+
)
|
|
782
|
+
old_events = await security_collector.cleanup_old_events(
|
|
783
|
+
days=settings.monitoring_retention_events_days
|
|
784
|
+
)
|
|
785
|
+
if candles or raw or old_candles or old_events:
|
|
786
|
+
logger.info(
|
|
787
|
+
"Monitoring maintenance: %d candles, cleaned %d raw, %d candles, %d events",
|
|
788
|
+
candles,
|
|
789
|
+
raw,
|
|
790
|
+
old_candles,
|
|
791
|
+
old_events,
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
except asyncio.CancelledError:
|
|
795
|
+
break
|
|
796
|
+
except Exception:
|
|
797
|
+
logger.exception("Metrics maintenance error")
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
async def _periodic_n8n_dispatch() -> None:
|
|
801
|
+
"""Background task: dispatch pending events to n8n webhook."""
|
|
802
|
+
from core.api.services.event_dispatcher import dispatch_pending_events
|
|
803
|
+
|
|
804
|
+
while True:
|
|
805
|
+
try:
|
|
806
|
+
await asyncio.sleep(settings.n8n_dispatch_interval)
|
|
807
|
+
await dispatch_pending_events()
|
|
808
|
+
except asyncio.CancelledError:
|
|
809
|
+
logger.info("n8n dispatcher shutting down, final drain...")
|
|
810
|
+
try:
|
|
811
|
+
await asyncio.wait_for(dispatch_pending_events(), timeout=30.0)
|
|
812
|
+
except (asyncio.TimeoutError, Exception):
|
|
813
|
+
pass
|
|
814
|
+
break
|
|
815
|
+
except Exception:
|
|
816
|
+
logger.exception("n8n dispatch error")
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
async def _periodic_reminder_check() -> None:
|
|
820
|
+
"""Background task: check and send task due-date reminders every 60 minutes."""
|
|
821
|
+
while True:
|
|
822
|
+
try:
|
|
823
|
+
await asyncio.sleep(3600)
|
|
824
|
+
from core.api.services.reminder_service import check_and_send_reminders
|
|
825
|
+
|
|
826
|
+
sent = await check_and_send_reminders()
|
|
827
|
+
if sent:
|
|
828
|
+
logger.info("Reminder check: sent %d reminder(s)", sent)
|
|
829
|
+
except asyncio.CancelledError:
|
|
830
|
+
break
|
|
831
|
+
except Exception:
|
|
832
|
+
logger.exception("Reminder check error")
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
async def _periodic_push_delivery() -> None:
|
|
836
|
+
"""Background task: drain unpushed notifications and send web push every 5s."""
|
|
837
|
+
from core.api.services.push_service import periodic_push_delivery
|
|
838
|
+
|
|
839
|
+
while True:
|
|
840
|
+
try:
|
|
841
|
+
await asyncio.sleep(5)
|
|
842
|
+
await periodic_push_delivery()
|
|
843
|
+
except asyncio.CancelledError:
|
|
844
|
+
# Final drain before shutdown
|
|
845
|
+
try:
|
|
846
|
+
await asyncio.wait_for(periodic_push_delivery(), timeout=10.0)
|
|
847
|
+
except (asyncio.TimeoutError, Exception):
|
|
848
|
+
pass
|
|
849
|
+
break
|
|
850
|
+
except Exception:
|
|
851
|
+
logger.exception("Push delivery error")
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
async def _notifications_acted_at_sync_loop() -> None:
|
|
855
|
+
"""Background task: auto-mark task_pending notifications as acted/read when
|
|
856
|
+
corresponding task is no longer pending. Runs every 60s to avoid inline
|
|
857
|
+
UPDATE on the GET /notifications endpoint (which caused SQLITE_BUSY).
|
|
858
|
+
|
|
859
|
+
Uses write_db() for single-writer pattern (serialized background writes).
|
|
860
|
+
|
|
861
|
+
Sleeps FIRST to avoid grabbing _write_lock at startup when the writer is
|
|
862
|
+
still settling after init_pool(). Grabbing the lock in the first tick
|
|
863
|
+
caused a startup deadlock that froze all writes (login hangs, 524 gateway).
|
|
864
|
+
"""
|
|
865
|
+
while True:
|
|
866
|
+
try:
|
|
867
|
+
await asyncio.sleep(60)
|
|
868
|
+
from core.api.db import write_db
|
|
869
|
+
|
|
870
|
+
async with write_db() as db:
|
|
871
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
872
|
+
await db.execute(
|
|
873
|
+
"""UPDATE notifications
|
|
874
|
+
SET acted_at = ?, read_at = COALESCE(read_at, ?)
|
|
875
|
+
WHERE acted_at IS NULL AND type = 'task_pending'
|
|
876
|
+
AND target_type = 'task' AND target_id IN (
|
|
877
|
+
SELECT id FROM tasks WHERE status != 'pending'
|
|
878
|
+
)""",
|
|
879
|
+
(now, now),
|
|
880
|
+
)
|
|
881
|
+
except asyncio.CancelledError:
|
|
882
|
+
break
|
|
883
|
+
except Exception as exc:
|
|
884
|
+
logger.warning("notifications_acted_at_sync_loop error: %s", exc)
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
async def _periodic_wal_checkpoint() -> None:
|
|
888
|
+
"""Background task: PRAGMA wal_checkpoint(TRUNCATE) every 30 minutes.
|
|
889
|
+
|
|
890
|
+
Prevents WAL from growing unbounded (incident 2026-04-10: 68MB WAL → 960ms queries).
|
|
891
|
+
Sleep-first pattern per single-writer learning 2026-04-15.
|
|
892
|
+
"""
|
|
893
|
+
while True:
|
|
894
|
+
try:
|
|
895
|
+
await asyncio.sleep(1800)
|
|
896
|
+
from core.api.db import wal_checkpoint
|
|
897
|
+
|
|
898
|
+
busy, log, checkpointed = await wal_checkpoint()
|
|
899
|
+
if log > 0:
|
|
900
|
+
logger.info(
|
|
901
|
+
"WAL checkpoint: busy=%d log=%d checkpointed=%d",
|
|
902
|
+
busy,
|
|
903
|
+
log,
|
|
904
|
+
checkpointed,
|
|
905
|
+
)
|
|
906
|
+
except asyncio.CancelledError:
|
|
907
|
+
break
|
|
908
|
+
except Exception:
|
|
909
|
+
logger.exception("WAL checkpoint error")
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
async def _periodic_inbox_digest() -> None:
|
|
913
|
+
"""Background task: run inbox digest expiry/freeze checks periodically."""
|
|
914
|
+
from core.api.services.inbox_digest_jobs import run_digest_jobs_if_due
|
|
915
|
+
|
|
916
|
+
while True:
|
|
917
|
+
try:
|
|
918
|
+
await asyncio.sleep(settings.inbox_digest_scheduler_interval_seconds)
|
|
919
|
+
result = await run_digest_jobs_if_due()
|
|
920
|
+
if result.get("status") == "ok":
|
|
921
|
+
logger.info(
|
|
922
|
+
"Inbox digest cycle %s: %d visible, %d overflow, %d expired",
|
|
923
|
+
result.get("cycle_key"),
|
|
924
|
+
result.get("visible", 0),
|
|
925
|
+
result.get("overflow", 0),
|
|
926
|
+
result.get("expired", 0),
|
|
927
|
+
)
|
|
928
|
+
except asyncio.CancelledError:
|
|
929
|
+
break
|
|
930
|
+
except Exception:
|
|
931
|
+
logger.exception("Inbox digest scheduler error")
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
async def _periodic_brain_jobs() -> None:
|
|
935
|
+
"""Background task: run Brain digest/journal cycle once per cycle_key.
|
|
936
|
+
|
|
937
|
+
Sleep BEFORE the work (production incident 5c06a53 — sleeping after the
|
|
938
|
+
lock froze login startup). The job itself is idempotent per cycle_key via
|
|
939
|
+
`brain_last_cycle_key` short-circuit + lease.
|
|
940
|
+
"""
|
|
941
|
+
from core.api.services.brain import run_brain_jobs_if_due
|
|
942
|
+
|
|
943
|
+
while True:
|
|
944
|
+
try:
|
|
945
|
+
await asyncio.sleep(settings.brain_scheduler_interval_seconds)
|
|
946
|
+
result = await run_brain_jobs_if_due()
|
|
947
|
+
status = result.get("status")
|
|
948
|
+
if status in ("ok", "partial"):
|
|
949
|
+
logger.info(
|
|
950
|
+
"Brain cycle %s %s: %d events, %d journals",
|
|
951
|
+
result.get("cycle_key"),
|
|
952
|
+
status,
|
|
953
|
+
result.get("event_count", 0),
|
|
954
|
+
result.get("journal_count", 0),
|
|
955
|
+
)
|
|
956
|
+
except asyncio.CancelledError:
|
|
957
|
+
break
|
|
958
|
+
except Exception:
|
|
959
|
+
logger.exception("Brain scheduler error")
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
@asynccontextmanager
|
|
963
|
+
async def lifespan(app: FastAPI):
|
|
964
|
+
"""Startup and shutdown events."""
|
|
965
|
+
global \
|
|
966
|
+
_cleanup_task, \
|
|
967
|
+
_session_maintenance_task, \
|
|
968
|
+
_metrics_task, \
|
|
969
|
+
_security_task, \
|
|
970
|
+
_maintenance_task, \
|
|
971
|
+
_reminder_task, \
|
|
972
|
+
_n8n_dispatch_task, \
|
|
973
|
+
_push_delivery_task, \
|
|
974
|
+
_notifications_sync_task, \
|
|
975
|
+
_inbox_digest_task, \
|
|
976
|
+
_brain_jobs_task, \
|
|
977
|
+
_wal_checkpoint_task, \
|
|
978
|
+
_terminal_metrics_dump_task, \
|
|
979
|
+
_pr_impact_sweep_task, \
|
|
980
|
+
_pr_impact_gc_task
|
|
981
|
+
|
|
982
|
+
# Startup
|
|
983
|
+
logger.info("Starting Console PiR API (env=%s)", settings.pir_env)
|
|
984
|
+
run_migrations()
|
|
985
|
+
|
|
986
|
+
# Phase 1.5 D14: Phoenix client-side OTel SDK (feature flag — default OFF).
|
|
987
|
+
# Set TRACING_ENABLED=true env var to enable end-to-end trace chain through
|
|
988
|
+
# Mac Queue Gateway. Idempotent + safe: missing deps → log warning + skip.
|
|
989
|
+
try:
|
|
990
|
+
from core.api.observability.tracing import init_tracing
|
|
991
|
+
init_tracing(app)
|
|
992
|
+
except Exception:
|
|
993
|
+
logger.exception("init_tracing failed — continuing without Phoenix")
|
|
994
|
+
|
|
995
|
+
# Connection pool (bounded, pre-configured PRAGMAs)
|
|
996
|
+
from core.api.db import init_pool
|
|
997
|
+
|
|
998
|
+
await init_pool(size=settings.db_pool_size)
|
|
999
|
+
|
|
1000
|
+
# Kill orphaned tmux-proxy processes from previous API instance
|
|
1001
|
+
try:
|
|
1002
|
+
from core.api.terminal import kill_orphan_proxies
|
|
1003
|
+
|
|
1004
|
+
await kill_orphan_proxies()
|
|
1005
|
+
except Exception:
|
|
1006
|
+
logger.warning("Failed to kill orphan tmux-proxy processes", exc_info=True)
|
|
1007
|
+
|
|
1008
|
+
try:
|
|
1009
|
+
await tmux.configure_history_limits()
|
|
1010
|
+
except Exception:
|
|
1011
|
+
logger.warning("Failed to configure tmux history limits", exc_info=True)
|
|
1012
|
+
|
|
1013
|
+
# Register SIGCHLD handler to automatically reap zombie children.
|
|
1014
|
+
# os.fork() children (PTY proxies) are not tracked by uvloop, so without
|
|
1015
|
+
# this handler SIGCHLD is unhandled and zombie processes accumulate indefinitely.
|
|
1016
|
+
import signal as _signal
|
|
1017
|
+
from core.api.terminal import reap_zombie_children
|
|
1018
|
+
|
|
1019
|
+
try:
|
|
1020
|
+
loop = asyncio.get_event_loop()
|
|
1021
|
+
loop.add_signal_handler(_signal.SIGCHLD, reap_zombie_children)
|
|
1022
|
+
logger.info("SIGCHLD handler registered (zombie reaping active)")
|
|
1023
|
+
except Exception:
|
|
1024
|
+
logger.warning("Failed to register SIGCHLD handler", exc_info=True)
|
|
1025
|
+
|
|
1026
|
+
from core.api.services.terminal_metrics import TerminalMetricsCollector
|
|
1027
|
+
from core.api.services.terminal_metrics_dump import terminal_metrics_background_loop
|
|
1028
|
+
|
|
1029
|
+
app.state.terminal_metrics = TerminalMetricsCollector()
|
|
1030
|
+
_terminal_metrics_dump_task = asyncio.create_task(
|
|
1031
|
+
terminal_metrics_background_loop(app.state.terminal_metrics)
|
|
1032
|
+
)
|
|
1033
|
+
|
|
1034
|
+
# Load project directories from DB settings
|
|
1035
|
+
import json
|
|
1036
|
+
import sqlite3
|
|
1037
|
+
from pathlib import Path as _Path
|
|
1038
|
+
|
|
1039
|
+
try:
|
|
1040
|
+
conn = sqlite3.connect(settings.db_path)
|
|
1041
|
+
conn.row_factory = sqlite3.Row
|
|
1042
|
+
row = conn.execute(
|
|
1043
|
+
"SELECT value FROM settings WHERE key = 'project_dirs'"
|
|
1044
|
+
).fetchone()
|
|
1045
|
+
conn.close()
|
|
1046
|
+
if row:
|
|
1047
|
+
from core.api.routers.projects import _set_project_dirs
|
|
1048
|
+
|
|
1049
|
+
dirs = [_Path(d).expanduser() for d in json.loads(row["value"])]
|
|
1050
|
+
_set_project_dirs(dirs)
|
|
1051
|
+
logger.info("Project dirs loaded from DB: %s", dirs)
|
|
1052
|
+
except Exception:
|
|
1053
|
+
logger.exception("Failed to load project dirs from DB, using defaults")
|
|
1054
|
+
|
|
1055
|
+
canary_mode = settings.pir_instance == "canary"
|
|
1056
|
+
|
|
1057
|
+
_cleanup_task = asyncio.create_task(_periodic_cleanup())
|
|
1058
|
+
_session_maintenance_task = asyncio.create_task(_periodic_session_maintenance())
|
|
1059
|
+
if canary_mode:
|
|
1060
|
+
logger.info(
|
|
1061
|
+
"[canary] skipping reminder, notification sync, inbox digest, brain jobs"
|
|
1062
|
+
)
|
|
1063
|
+
else:
|
|
1064
|
+
_reminder_task = asyncio.create_task(_periodic_reminder_check())
|
|
1065
|
+
_notifications_sync_task = asyncio.create_task(
|
|
1066
|
+
_notifications_acted_at_sync_loop()
|
|
1067
|
+
)
|
|
1068
|
+
_inbox_digest_task = asyncio.create_task(_periodic_inbox_digest())
|
|
1069
|
+
# Brain v1.0.1: register the five canonical source collectors
|
|
1070
|
+
# BEFORE spawning _periodic_brain_jobs so the first tick already
|
|
1071
|
+
# sees them. Idempotent — safe across lifespan restarts.
|
|
1072
|
+
from core.api.services.brain.sources import register_all_collectors
|
|
1073
|
+
|
|
1074
|
+
register_all_collectors()
|
|
1075
|
+
_brain_jobs_task = asyncio.create_task(_periodic_brain_jobs())
|
|
1076
|
+
_wal_checkpoint_task = asyncio.create_task(_periodic_wal_checkpoint())
|
|
1077
|
+
|
|
1078
|
+
# n8n integration
|
|
1079
|
+
if canary_mode and settings.n8n_webhook_url:
|
|
1080
|
+
logger.info("[canary] skipping n8n event dispatcher")
|
|
1081
|
+
elif settings.n8n_webhook_url:
|
|
1082
|
+
from core.api.services.event_dispatcher import init_session
|
|
1083
|
+
|
|
1084
|
+
await init_session()
|
|
1085
|
+
_n8n_dispatch_task = asyncio.create_task(_periodic_n8n_dispatch())
|
|
1086
|
+
logger.info(
|
|
1087
|
+
"n8n event dispatcher started (interval=%ds)",
|
|
1088
|
+
settings.n8n_dispatch_interval,
|
|
1089
|
+
)
|
|
1090
|
+
if settings.n8n_api_url:
|
|
1091
|
+
from core.api.services.n8n_client import init_api_session
|
|
1092
|
+
|
|
1093
|
+
await init_api_session()
|
|
1094
|
+
logger.info("n8n API client initialized")
|
|
1095
|
+
|
|
1096
|
+
# Web Push delivery (outbox pattern)
|
|
1097
|
+
if canary_mode and settings.vapid_private_key:
|
|
1098
|
+
logger.info("[canary] skipping web push delivery")
|
|
1099
|
+
elif settings.vapid_private_key:
|
|
1100
|
+
from core.api.services.push_service import init_push_session
|
|
1101
|
+
|
|
1102
|
+
await init_push_session()
|
|
1103
|
+
_push_delivery_task = asyncio.create_task(_periodic_push_delivery())
|
|
1104
|
+
logger.info("Push delivery started (interval=5s)")
|
|
1105
|
+
|
|
1106
|
+
# Voyage AI client for embeddings
|
|
1107
|
+
from core.api.services.embedding_service import init_voyage_client
|
|
1108
|
+
|
|
1109
|
+
if settings.pir_voyage_disabled:
|
|
1110
|
+
logger.info("[canary] skipping Voyage client initialization")
|
|
1111
|
+
else:
|
|
1112
|
+
init_voyage_client()
|
|
1113
|
+
|
|
1114
|
+
# Monitoring collectors
|
|
1115
|
+
await metrics_collector.start()
|
|
1116
|
+
if canary_mode:
|
|
1117
|
+
logger.info("[canary] skipping security collector")
|
|
1118
|
+
else:
|
|
1119
|
+
await security_collector.start()
|
|
1120
|
+
_metrics_task = asyncio.create_task(_periodic_metrics_collection())
|
|
1121
|
+
if not canary_mode:
|
|
1122
|
+
_security_task = asyncio.create_task(_periodic_security_collection())
|
|
1123
|
+
_maintenance_task = asyncio.create_task(_periodic_metrics_maintenance())
|
|
1124
|
+
logger.info("Monitoring collectors started")
|
|
1125
|
+
|
|
1126
|
+
# KG PR-Impact sub-01 D4 + D8: dispatcher sweep + webhook GC.
|
|
1127
|
+
# Both honored only when the feature flag is not 'off' so we don't run
|
|
1128
|
+
# background work for an inert pipeline.
|
|
1129
|
+
pr_impact_enabled = getattr(settings, "pr_impact_enabled", "shadow")
|
|
1130
|
+
if pr_impact_enabled != "off" and not canary_mode:
|
|
1131
|
+
from core.api.services.pr_impact_pipeline.dispatcher import (
|
|
1132
|
+
periodic_pr_impact_sweep,
|
|
1133
|
+
restart_replay,
|
|
1134
|
+
)
|
|
1135
|
+
from core.api.services.pr_impact_pipeline.gc import periodic_webhook_gc
|
|
1136
|
+
|
|
1137
|
+
try:
|
|
1138
|
+
await restart_replay(settings.db_path)
|
|
1139
|
+
except Exception:
|
|
1140
|
+
logger.exception("pr_impact restart_replay failed — continuing")
|
|
1141
|
+
_pr_impact_sweep_task = asyncio.create_task(
|
|
1142
|
+
periodic_pr_impact_sweep(settings.db_path)
|
|
1143
|
+
)
|
|
1144
|
+
_pr_impact_gc_task = asyncio.create_task(
|
|
1145
|
+
periodic_webhook_gc(settings.db_path)
|
|
1146
|
+
)
|
|
1147
|
+
logger.info(
|
|
1148
|
+
"pr_impact background tasks started (mode=%s)", pr_impact_enabled
|
|
1149
|
+
)
|
|
1150
|
+
|
|
1151
|
+
logger.info("API ready")
|
|
1152
|
+
|
|
1153
|
+
yield
|
|
1154
|
+
|
|
1155
|
+
# Shutdown
|
|
1156
|
+
logger.info("Shutting down...")
|
|
1157
|
+
if _cleanup_task:
|
|
1158
|
+
_cleanup_task.cancel()
|
|
1159
|
+
try:
|
|
1160
|
+
await _cleanup_task
|
|
1161
|
+
except asyncio.CancelledError:
|
|
1162
|
+
pass
|
|
1163
|
+
if _session_maintenance_task:
|
|
1164
|
+
_session_maintenance_task.cancel()
|
|
1165
|
+
try:
|
|
1166
|
+
await _session_maintenance_task
|
|
1167
|
+
except asyncio.CancelledError:
|
|
1168
|
+
pass
|
|
1169
|
+
if _reminder_task:
|
|
1170
|
+
_reminder_task.cancel()
|
|
1171
|
+
try:
|
|
1172
|
+
await _reminder_task
|
|
1173
|
+
except asyncio.CancelledError:
|
|
1174
|
+
pass
|
|
1175
|
+
if _notifications_sync_task:
|
|
1176
|
+
_notifications_sync_task.cancel()
|
|
1177
|
+
try:
|
|
1178
|
+
await _notifications_sync_task
|
|
1179
|
+
except asyncio.CancelledError:
|
|
1180
|
+
pass
|
|
1181
|
+
if _inbox_digest_task:
|
|
1182
|
+
_inbox_digest_task.cancel()
|
|
1183
|
+
try:
|
|
1184
|
+
await _inbox_digest_task
|
|
1185
|
+
except asyncio.CancelledError:
|
|
1186
|
+
pass
|
|
1187
|
+
if _brain_jobs_task:
|
|
1188
|
+
_brain_jobs_task.cancel()
|
|
1189
|
+
try:
|
|
1190
|
+
await _brain_jobs_task
|
|
1191
|
+
except asyncio.CancelledError:
|
|
1192
|
+
pass
|
|
1193
|
+
if _wal_checkpoint_task:
|
|
1194
|
+
_wal_checkpoint_task.cancel()
|
|
1195
|
+
try:
|
|
1196
|
+
await _wal_checkpoint_task
|
|
1197
|
+
except asyncio.CancelledError:
|
|
1198
|
+
pass
|
|
1199
|
+
if _terminal_metrics_dump_task:
|
|
1200
|
+
_terminal_metrics_dump_task.cancel()
|
|
1201
|
+
try:
|
|
1202
|
+
await _terminal_metrics_dump_task
|
|
1203
|
+
except asyncio.CancelledError:
|
|
1204
|
+
pass
|
|
1205
|
+
|
|
1206
|
+
# Stop push delivery
|
|
1207
|
+
if _push_delivery_task:
|
|
1208
|
+
_push_delivery_task.cancel()
|
|
1209
|
+
try:
|
|
1210
|
+
await _push_delivery_task
|
|
1211
|
+
except asyncio.CancelledError:
|
|
1212
|
+
pass
|
|
1213
|
+
from core.api.services.push_service import close_push_session
|
|
1214
|
+
|
|
1215
|
+
await close_push_session()
|
|
1216
|
+
|
|
1217
|
+
# Stop n8n dispatcher + close API client
|
|
1218
|
+
if _n8n_dispatch_task:
|
|
1219
|
+
_n8n_dispatch_task.cancel()
|
|
1220
|
+
try:
|
|
1221
|
+
await _n8n_dispatch_task
|
|
1222
|
+
except asyncio.CancelledError:
|
|
1223
|
+
pass
|
|
1224
|
+
from core.api.services.event_dispatcher import close_session
|
|
1225
|
+
|
|
1226
|
+
await close_session()
|
|
1227
|
+
if settings.n8n_api_url:
|
|
1228
|
+
from core.api.services.n8n_client import close_api_session
|
|
1229
|
+
|
|
1230
|
+
await close_api_session()
|
|
1231
|
+
|
|
1232
|
+
# Stop monitoring collectors
|
|
1233
|
+
for task in (_metrics_task, _security_task, _maintenance_task):
|
|
1234
|
+
if task:
|
|
1235
|
+
task.cancel()
|
|
1236
|
+
try:
|
|
1237
|
+
await task
|
|
1238
|
+
except asyncio.CancelledError:
|
|
1239
|
+
pass
|
|
1240
|
+
await metrics_collector.stop()
|
|
1241
|
+
await security_collector.stop()
|
|
1242
|
+
|
|
1243
|
+
# Close connection pool
|
|
1244
|
+
from core.api.db import close_pool
|
|
1245
|
+
|
|
1246
|
+
await close_pool()
|
|
1247
|
+
|
|
1248
|
+
# Cleanup terminal sessions if terminal module loaded
|
|
1249
|
+
try:
|
|
1250
|
+
from core.api.terminal import session_manager
|
|
1251
|
+
|
|
1252
|
+
await session_manager.cleanup_all()
|
|
1253
|
+
except ImportError:
|
|
1254
|
+
pass
|
|
1255
|
+
|
|
1256
|
+
logger.info("Shutdown complete")
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
# Shared limiter instance (defined in api.rate_limit to avoid circular imports)
|
|
1260
|
+
from core.api.rate_limit import limiter # noqa: E402
|
|
1261
|
+
|
|
1262
|
+
app = FastAPI(
|
|
1263
|
+
title="Console PiR API",
|
|
1264
|
+
version="1.0.0",
|
|
1265
|
+
docs_url="/docs" if settings.expose_openapi else None,
|
|
1266
|
+
redoc_url="/redoc" if settings.expose_openapi else None,
|
|
1267
|
+
openapi_url="/openapi.json" if settings.expose_openapi else None,
|
|
1268
|
+
lifespan=lifespan,
|
|
1269
|
+
)
|
|
1270
|
+
|
|
1271
|
+
# slowapi: attach limiter to app state + register 429 handler
|
|
1272
|
+
app.state.limiter = limiter
|
|
1273
|
+
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
|
1274
|
+
|
|
1275
|
+
# CORS
|
|
1276
|
+
app.add_middleware(
|
|
1277
|
+
CORSMiddleware,
|
|
1278
|
+
allow_origins=settings.cors_origins,
|
|
1279
|
+
allow_credentials=True,
|
|
1280
|
+
allow_methods=["GET", "POST", "PATCH", "PUT", "DELETE"],
|
|
1281
|
+
allow_headers=["*"],
|
|
1282
|
+
)
|
|
1283
|
+
|
|
1284
|
+
# Phase 6.5: audit_log row per MCP tool call (allowlist in middleware).
|
|
1285
|
+
from core.api.middleware.tool_call_audit import MCPToolCallAuditMiddleware # noqa: E402
|
|
1286
|
+
app.add_middleware(MCPToolCallAuditMiddleware)
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
@app.middleware("http")
|
|
1290
|
+
async def security_headers(request: Request, call_next) -> Response:
|
|
1291
|
+
"""Add security headers to all responses."""
|
|
1292
|
+
response = await call_next(request)
|
|
1293
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
1294
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
1295
|
+
response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
|
|
1296
|
+
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
|
1297
|
+
if settings.is_production:
|
|
1298
|
+
response.headers["Strict-Transport-Security"] = "max-age=31536000"
|
|
1299
|
+
return response
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
# Routers
|
|
1303
|
+
app.include_router(auth.router, prefix="/api/v1")
|
|
1304
|
+
app.include_router(sessions.router, prefix="/api/v1")
|
|
1305
|
+
app.include_router(tasks_router)
|
|
1306
|
+
app.include_router(projects_router)
|
|
1307
|
+
app.include_router(costs_router)
|
|
1308
|
+
app.include_router(comments_router)
|
|
1309
|
+
app.include_router(status_updates_router)
|
|
1310
|
+
app.include_router(settings_router)
|
|
1311
|
+
app.include_router(admin_settings_router)
|
|
1312
|
+
app.include_router(files_router)
|
|
1313
|
+
app.include_router(terminal_metrics_router)
|
|
1314
|
+
app.include_router(terminal_router)
|
|
1315
|
+
app.include_router(monitoring_router)
|
|
1316
|
+
app.include_router(kg_router)
|
|
1317
|
+
app.include_router(agent_router)
|
|
1318
|
+
app.include_router(tags_router)
|
|
1319
|
+
app.include_router(finder_router)
|
|
1320
|
+
app.include_router(share_manage_router)
|
|
1321
|
+
app.include_router(shared_file_router)
|
|
1322
|
+
app.include_router(repo_share_router)
|
|
1323
|
+
app.include_router(shared_repo_router)
|
|
1324
|
+
app.include_router(pull_requests_router)
|
|
1325
|
+
app.include_router(users_router)
|
|
1326
|
+
app.include_router(raci_router)
|
|
1327
|
+
app.include_router(audit_router)
|
|
1328
|
+
app.include_router(webhooks_router)
|
|
1329
|
+
app.include_router(admin_pr_impact_router)
|
|
1330
|
+
app.include_router(pr_impact_router)
|
|
1331
|
+
app.include_router(agent_tokens_router)
|
|
1332
|
+
app.include_router(handoffs_router)
|
|
1333
|
+
app.include_router(teams_router)
|
|
1334
|
+
app.include_router(learnings_router)
|
|
1335
|
+
app.include_router(graph_router)
|
|
1336
|
+
if automations_router is not None:
|
|
1337
|
+
app.include_router(automations_router)
|
|
1338
|
+
else:
|
|
1339
|
+
logger.info("automations router absent (SaaS-only module) — skipping")
|
|
1340
|
+
app.include_router(notifications_router)
|
|
1341
|
+
app.include_router(push_router)
|
|
1342
|
+
app.include_router(ci_checks_router)
|
|
1343
|
+
app.include_router(search_router)
|
|
1344
|
+
app.include_router(documents_router)
|
|
1345
|
+
app.include_router(docs_coverage_router)
|
|
1346
|
+
app.include_router(docs_governance_router)
|
|
1347
|
+
app.include_router(judge_router)
|
|
1348
|
+
app.include_router(inbox_router)
|
|
1349
|
+
app.include_router(ingest_triage_router)
|
|
1350
|
+
app.include_router(ingest_api_keys_router)
|
|
1351
|
+
app.include_router(llm_config_router)
|
|
1352
|
+
if newsletter_router is not None:
|
|
1353
|
+
app.include_router(newsletter_router)
|
|
1354
|
+
else:
|
|
1355
|
+
logger.info("newsletter router absent (SaaS-only module) — skipping")
|
|
1356
|
+
app.include_router(bench_router)
|
|
1357
|
+
app.include_router(app_settings_router)
|
|
1358
|
+
if reddit_router is not None:
|
|
1359
|
+
app.include_router(reddit_router)
|
|
1360
|
+
else:
|
|
1361
|
+
logger.info("reddit router absent (SaaS-only module) — skipping")
|
|
1362
|
+
app.include_router(brain_router)
|
|
1363
|
+
app.include_router(brain_directions_router)
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
def _cors_headers_for(request: Request) -> dict[str, str]:
|
|
1367
|
+
"""Replicate CORSMiddleware logic for error responses.
|
|
1368
|
+
|
|
1369
|
+
Starlette CORSMiddleware does NOT add headers to responses generated
|
|
1370
|
+
from unhandled exceptions (encode/starlette#1175). Without these, the
|
|
1371
|
+
browser reports a misleading 'CORS error' for every 500/422/404.
|
|
1372
|
+
"""
|
|
1373
|
+
origin = request.headers.get("origin", "")
|
|
1374
|
+
allowed = settings.cors_origins or []
|
|
1375
|
+
if "*" in allowed or origin in allowed:
|
|
1376
|
+
return {
|
|
1377
|
+
"Access-Control-Allow-Origin": origin or "*",
|
|
1378
|
+
"Access-Control-Allow-Credentials": "true",
|
|
1379
|
+
"Vary": "Origin",
|
|
1380
|
+
}
|
|
1381
|
+
return {}
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
@app.exception_handler(StarletteHTTPException)
|
|
1385
|
+
async def http_exception_handler_cors(request: Request, exc: StarletteHTTPException):
|
|
1386
|
+
return JSONResponse(
|
|
1387
|
+
status_code=exc.status_code,
|
|
1388
|
+
content={"detail": exc.detail},
|
|
1389
|
+
headers=_cors_headers_for(request),
|
|
1390
|
+
)
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
@app.exception_handler(RequestValidationError)
|
|
1394
|
+
async def validation_exception_handler_cors(
|
|
1395
|
+
request: Request, exc: RequestValidationError
|
|
1396
|
+
):
|
|
1397
|
+
# jsonable_encoder handles non-JSON-serializable items inside exc.errors()
|
|
1398
|
+
# (e.g. ValueError/ctx objects from Pydantic custom validators).
|
|
1399
|
+
return JSONResponse(
|
|
1400
|
+
status_code=422,
|
|
1401
|
+
content={"detail": jsonable_encoder(exc.errors())},
|
|
1402
|
+
headers=_cors_headers_for(request),
|
|
1403
|
+
)
|
|
1404
|
+
|
|
1405
|
+
|
|
1406
|
+
@app.exception_handler(Exception)
|
|
1407
|
+
async def global_exception_handler_cors(request: Request, exc: Exception):
|
|
1408
|
+
logger.exception(
|
|
1409
|
+
"Unhandled exception on %s %s: %s",
|
|
1410
|
+
request.method,
|
|
1411
|
+
request.url.path,
|
|
1412
|
+
exc,
|
|
1413
|
+
)
|
|
1414
|
+
return JSONResponse(
|
|
1415
|
+
status_code=500,
|
|
1416
|
+
content={"detail": "Internal server error", "type": type(exc).__name__},
|
|
1417
|
+
headers=_cors_headers_for(request),
|
|
1418
|
+
)
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
@app.exception_handler(ServiceError)
|
|
1422
|
+
async def service_error_handler(request: Request, exc: ServiceError):
|
|
1423
|
+
"""Map any domain ServiceError escaping a router to the right HTTP status.
|
|
1424
|
+
|
|
1425
|
+
Makes the incremental use_cases extraction (S1) regression-proof: even a
|
|
1426
|
+
not-yet-refactored router that surfaces a ServiceError gets the correct
|
|
1427
|
+
status + {code, message} body via this single handler.
|
|
1428
|
+
"""
|
|
1429
|
+
return JSONResponse(
|
|
1430
|
+
status_code=exc.http_status,
|
|
1431
|
+
content={"code": exc.code, "message": exc.message},
|
|
1432
|
+
headers=_cors_headers_for(request),
|
|
1433
|
+
)
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
@app.get("/health", tags=["health"])
|
|
1437
|
+
async def health_check() -> dict:
|
|
1438
|
+
"""Liveness probe — returns service status and version."""
|
|
1439
|
+
return {"status": "ok", "version": app.version}
|
|
1440
|
+
|
|
1441
|
+
|
|
1442
|
+
# Brain v1 — WebSocket endpoint (sub-05 S5, §4.16).
|
|
1443
|
+
# Emits `marvisx:brain_cycle_changed` payloads to subscribed clients after
|
|
1444
|
+
# each cycle phase. Visibility is enforced at emit time, per-subscriber.
|
|
1445
|
+
async def _resolve_ws_user(ws: WebSocket):
|
|
1446
|
+
"""Authenticate a /ws/brain client via cookie OR Bearer token."""
|
|
1447
|
+
from core.api.db import acquire_db
|
|
1448
|
+
from core.api.security import _lookup_agent_token, _resolve_agent_userinfo
|
|
1449
|
+
from core.api.security import verify_session_jwt
|
|
1450
|
+
|
|
1451
|
+
# 1. Bearer (agents) — header OR `?token=` query (browsers can't set WS headers)
|
|
1452
|
+
auth_header = ws.headers.get("authorization", "")
|
|
1453
|
+
bearer_token: str | None = None
|
|
1454
|
+
if auth_header.startswith("Bearer "):
|
|
1455
|
+
bearer_token = auth_header[7:]
|
|
1456
|
+
else:
|
|
1457
|
+
token_qp = ws.query_params.get("token")
|
|
1458
|
+
if token_qp:
|
|
1459
|
+
bearer_token = token_qp
|
|
1460
|
+
|
|
1461
|
+
if bearer_token:
|
|
1462
|
+
async with acquire_db() as db:
|
|
1463
|
+
db.row_factory = aiosqlite.Row
|
|
1464
|
+
resolved = await _lookup_agent_token(bearer_token, db)
|
|
1465
|
+
if resolved is not None:
|
|
1466
|
+
agent_name, scopes, ws_id = resolved
|
|
1467
|
+
return await _resolve_agent_userinfo(agent_name, db, scopes, ws_id)
|
|
1468
|
+
if settings.tasks_api_token and bearer_token == settings.tasks_api_token:
|
|
1469
|
+
return await _resolve_agent_userinfo(
|
|
1470
|
+
"agent", db, workspace_id="ws_default"
|
|
1471
|
+
)
|
|
1472
|
+
return None
|
|
1473
|
+
|
|
1474
|
+
# 2. Cookie (Console PiR web)
|
|
1475
|
+
cookie_token = ws.cookies.get("pir_session")
|
|
1476
|
+
if not cookie_token:
|
|
1477
|
+
return None
|
|
1478
|
+
try:
|
|
1479
|
+
payload = verify_session_jwt(cookie_token)
|
|
1480
|
+
except Exception:
|
|
1481
|
+
return None
|
|
1482
|
+
slug = payload.get("sub")
|
|
1483
|
+
if not slug:
|
|
1484
|
+
return None
|
|
1485
|
+
async with acquire_db() as db:
|
|
1486
|
+
db.row_factory = aiosqlite.Row
|
|
1487
|
+
async with db.execute(
|
|
1488
|
+
"SELECT id, system_role, display_name, workspace_id FROM users "
|
|
1489
|
+
"WHERE slug = ? AND deleted_at IS NULL",
|
|
1490
|
+
(slug,),
|
|
1491
|
+
) as cur:
|
|
1492
|
+
row = await cur.fetchone()
|
|
1493
|
+
if not row:
|
|
1494
|
+
return None
|
|
1495
|
+
from core.api.models import UserInfo
|
|
1496
|
+
|
|
1497
|
+
return UserInfo(
|
|
1498
|
+
username=slug,
|
|
1499
|
+
user_id=row["id"],
|
|
1500
|
+
system_role=row["system_role"],
|
|
1501
|
+
display_name=row["display_name"],
|
|
1502
|
+
workspace_id=(
|
|
1503
|
+
payload.get("workspace_id")
|
|
1504
|
+
or (row["workspace_id"] if "workspace_id" in row.keys() else None)
|
|
1505
|
+
or "ws_default"
|
|
1506
|
+
),
|
|
1507
|
+
)
|
|
1508
|
+
|
|
1509
|
+
|
|
1510
|
+
@app.websocket("/ws/brain")
|
|
1511
|
+
async def brain_websocket(ws: WebSocket) -> None:
|
|
1512
|
+
"""Brain v1 — real-time cycle phase events.
|
|
1513
|
+
|
|
1514
|
+
Auth via cookie OR Bearer (header / `?token=`). Visibility is applied at
|
|
1515
|
+
emit time per-subscriber — admins/super_admins bypass. Heartbeat ping
|
|
1516
|
+
every 30s; reconnect is client-side (no server replay buffer in v1).
|
|
1517
|
+
"""
|
|
1518
|
+
from core.api.services.brain.ws_emitter import Subscriber, _serialize, get_hub
|
|
1519
|
+
|
|
1520
|
+
await ws.accept()
|
|
1521
|
+
user = await _resolve_ws_user(ws)
|
|
1522
|
+
if user is None:
|
|
1523
|
+
await ws.send_text(
|
|
1524
|
+
_serialize({"type": "error", "code": "unauthorized"})
|
|
1525
|
+
)
|
|
1526
|
+
await ws.close(code=4401)
|
|
1527
|
+
return
|
|
1528
|
+
|
|
1529
|
+
is_unrestricted = user.system_role in ("admin", "super_admin")
|
|
1530
|
+
visible_projects: set[str] = set()
|
|
1531
|
+
if not is_unrestricted:
|
|
1532
|
+
try:
|
|
1533
|
+
from core.api.db import acquire_db
|
|
1534
|
+
from core.api.visibility import get_visible_projects
|
|
1535
|
+
|
|
1536
|
+
async with acquire_db() as db:
|
|
1537
|
+
db.row_factory = aiosqlite.Row
|
|
1538
|
+
visible_projects = set(
|
|
1539
|
+
await get_visible_projects(
|
|
1540
|
+
db,
|
|
1541
|
+
user,
|
|
1542
|
+
user.workspace_id or "ws_default",
|
|
1543
|
+
)
|
|
1544
|
+
)
|
|
1545
|
+
except Exception:
|
|
1546
|
+
logger.exception("brain ws: visible_projects resolution failed")
|
|
1547
|
+
|
|
1548
|
+
subscriber = Subscriber(
|
|
1549
|
+
ws=ws,
|
|
1550
|
+
user_id=user.user_id or user.username or "",
|
|
1551
|
+
system_role=user.system_role,
|
|
1552
|
+
workspace_id=user.workspace_id or "ws_default",
|
|
1553
|
+
visible_projects=visible_projects,
|
|
1554
|
+
is_unrestricted=is_unrestricted,
|
|
1555
|
+
)
|
|
1556
|
+
hub = get_hub()
|
|
1557
|
+
await hub.register(subscriber)
|
|
1558
|
+
|
|
1559
|
+
try:
|
|
1560
|
+
await ws.send_text(
|
|
1561
|
+
_serialize({
|
|
1562
|
+
"type": "marvisx:brain_subscribed",
|
|
1563
|
+
"user_id": subscriber.user_id,
|
|
1564
|
+
"role": subscriber.system_role,
|
|
1565
|
+
})
|
|
1566
|
+
)
|
|
1567
|
+
|
|
1568
|
+
async def _send_loop() -> None:
|
|
1569
|
+
while True:
|
|
1570
|
+
payload = await subscriber.queue.get()
|
|
1571
|
+
await ws.send_text(_serialize(payload))
|
|
1572
|
+
|
|
1573
|
+
async def _heartbeat_loop() -> None:
|
|
1574
|
+
while True:
|
|
1575
|
+
await asyncio.sleep(30)
|
|
1576
|
+
try:
|
|
1577
|
+
await ws.send_text(_serialize({"type": "ping"}))
|
|
1578
|
+
except Exception:
|
|
1579
|
+
return
|
|
1580
|
+
|
|
1581
|
+
async def _recv_loop() -> None:
|
|
1582
|
+
while True:
|
|
1583
|
+
# The client may send pongs / explicit close frames. Discard
|
|
1584
|
+
# text frames; on disconnect WebSocketDisconnect bubbles up.
|
|
1585
|
+
await ws.receive_text()
|
|
1586
|
+
|
|
1587
|
+
send_task = asyncio.create_task(_send_loop())
|
|
1588
|
+
heartbeat_task = asyncio.create_task(_heartbeat_loop())
|
|
1589
|
+
recv_task = asyncio.create_task(_recv_loop())
|
|
1590
|
+
|
|
1591
|
+
done, pending = await asyncio.wait(
|
|
1592
|
+
{send_task, heartbeat_task, recv_task},
|
|
1593
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
1594
|
+
)
|
|
1595
|
+
for task in pending:
|
|
1596
|
+
task.cancel()
|
|
1597
|
+
for task in done:
|
|
1598
|
+
exc = task.exception()
|
|
1599
|
+
if exc and not isinstance(exc, (WebSocketDisconnect, asyncio.CancelledError)):
|
|
1600
|
+
logger.exception(
|
|
1601
|
+
"brain ws: task ended with error", exc_info=exc
|
|
1602
|
+
)
|
|
1603
|
+
except WebSocketDisconnect:
|
|
1604
|
+
pass
|
|
1605
|
+
except Exception:
|
|
1606
|
+
logger.exception("brain ws: unhandled error")
|
|
1607
|
+
finally:
|
|
1608
|
+
await hub.unregister(subscriber)
|
|
1609
|
+
try:
|
|
1610
|
+
await ws.close()
|
|
1611
|
+
except Exception:
|
|
1612
|
+
pass
|
|
1613
|
+
|
|
1614
|
+
|
|
1615
|
+
@app.get("/health/ocr", tags=["health"])
|
|
1616
|
+
async def health_ocr() -> dict:
|
|
1617
|
+
"""OCR backend health (P1.5.E1).
|
|
1618
|
+
|
|
1619
|
+
Reports whether the tesseract system binary is callable with the Italian
|
|
1620
|
+
language pack installed. PaddleOCR-VL lives upstream on the Mac Gateway
|
|
1621
|
+
(tier-ocr/tier-docparse) and is monitored separately. Used as post-deploy
|
|
1622
|
+
gate to verify the local OCR pipeline never silently regresses to
|
|
1623
|
+
``parser_used=null``.
|
|
1624
|
+
"""
|
|
1625
|
+
import subprocess
|
|
1626
|
+
|
|
1627
|
+
result = {"tesseract": False, "italian_lang": False}
|
|
1628
|
+
try:
|
|
1629
|
+
completed = await asyncio.to_thread(
|
|
1630
|
+
subprocess.run,
|
|
1631
|
+
["tesseract", "--list-langs"],
|
|
1632
|
+
capture_output=True,
|
|
1633
|
+
timeout=5,
|
|
1634
|
+
)
|
|
1635
|
+
result["tesseract"] = completed.returncode == 0
|
|
1636
|
+
result["italian_lang"] = b"ita" in (completed.stdout or b"")
|
|
1637
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
1638
|
+
pass
|
|
1639
|
+
except Exception:
|
|
1640
|
+
logger.exception("Unexpected error probing tesseract")
|
|
1641
|
+
return result
|