marvisx-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- core/api/__init__.py +0 -0
- core/api/agents/__init__.py +0 -0
- core/api/agents/session_health.py +59 -0
- core/api/agents/session_manager.py +206 -0
- core/api/bin/marvisx-state-hook.py +182 -0
- core/api/config.py +533 -0
- core/api/db.py +1516 -0
- core/api/dependencies/__init__.py +0 -0
- core/api/dependencies/tenant.py +34 -0
- core/api/main.py +1641 -0
- core/api/mcp/__init__.py +8 -0
- core/api/mcp/_adapter.py +184 -0
- core/api/mcp/server.py +58 -0
- core/api/mcp/tools/__init__.py +59 -0
- core/api/mcp/tools/brain.py +599 -0
- core/api/mcp/tools/graph.py +380 -0
- core/api/mcp/tools/handoffs.py +112 -0
- core/api/mcp/tools/ingest.py +326 -0
- core/api/mcp/tools/learnings.py +144 -0
- core/api/mcp/tools/projects.py +99 -0
- core/api/mcp/tools/pull_requests.py +173 -0
- core/api/mcp/tools/safety.py +111 -0
- core/api/mcp/tools/search.py +79 -0
- core/api/mcp/tools/tasks.py +258 -0
- core/api/middleware/__init__.py +0 -0
- core/api/middleware/tool_call_audit.py +111 -0
- core/api/models/__init__.py +346 -0
- core/api/models/auth.py +48 -0
- core/api/models/brain.py +1006 -0
- core/api/models/common.py +76 -0
- core/api/models/costs.py +91 -0
- core/api/models/graph.py +66 -0
- core/api/models/graph_cosmo.py +125 -0
- core/api/models/graph_pr_impact.py +257 -0
- core/api/models/graph_ux.py +141 -0
- core/api/models/inbox.py +230 -0
- core/api/models/ingest_keys.py +108 -0
- core/api/models/kg.py +41 -0
- core/api/models/llm_config.py +56 -0
- core/api/models/monitoring.py +234 -0
- core/api/models/projects.py +161 -0
- core/api/models/search.py +42 -0
- core/api/models/sessions.py +322 -0
- core/api/models/tasks.py +184 -0
- core/api/models/teams.py +63 -0
- core/api/models/users.py +184 -0
- core/api/observability/__init__.py +0 -0
- core/api/observability/tracing.py +92 -0
- core/api/paths.py +26 -0
- core/api/rate_limit.py +24 -0
- core/api/rbac.py +112 -0
- core/api/routers/__init__.py +0 -0
- core/api/routers/_adapter.py +24 -0
- core/api/routers/admin_pr_impact.py +230 -0
- core/api/routers/admin_settings.py +147 -0
- core/api/routers/agent.py +1079 -0
- core/api/routers/agent_tokens.py +276 -0
- core/api/routers/app_settings.py +112 -0
- core/api/routers/audit.py +89 -0
- core/api/routers/auth.py +586 -0
- core/api/routers/bench.py +161 -0
- core/api/routers/brain.py +881 -0
- core/api/routers/brain_directions.py +527 -0
- core/api/routers/ci_checks.py +140 -0
- core/api/routers/comments.py +273 -0
- core/api/routers/costs.py +148 -0
- core/api/routers/docs_coverage.py +217 -0
- core/api/routers/docs_governance.py +63 -0
- core/api/routers/documents.py +318 -0
- core/api/routers/files.py +163 -0
- core/api/routers/finder.py +987 -0
- core/api/routers/graph.py +836 -0
- core/api/routers/handoffs.py +156 -0
- core/api/routers/inbox.py +496 -0
- core/api/routers/ingest_api_keys.py +205 -0
- core/api/routers/ingest_triage.py +1227 -0
- core/api/routers/judge.py +306 -0
- core/api/routers/kg.py +336 -0
- core/api/routers/learnings.py +253 -0
- core/api/routers/llm_config.py +130 -0
- core/api/routers/monitoring.py +347 -0
- core/api/routers/notifications.py +125 -0
- core/api/routers/pr_impact.py +315 -0
- core/api/routers/projects.py +1061 -0
- core/api/routers/pull_requests.py +312 -0
- core/api/routers/push.py +67 -0
- core/api/routers/raci.py +228 -0
- core/api/routers/search.py +125 -0
- core/api/routers/sessions.py +3100 -0
- core/api/routers/settings.py +90 -0
- core/api/routers/share_repo.py +68 -0
- core/api/routers/status_updates.py +96 -0
- core/api/routers/tags.py +45 -0
- core/api/routers/tasks.py +526 -0
- core/api/routers/teams.py +425 -0
- core/api/routers/terminal.py +105 -0
- core/api/routers/users.py +331 -0
- core/api/routers/webhooks.py +330 -0
- core/api/runtime_settings.py +84 -0
- core/api/security.py +652 -0
- core/api/services/__init__.py +0 -0
- core/api/services/audit.py +58 -0
- core/api/services/auto_approval.py +82 -0
- core/api/services/brain/__init__.py +52 -0
- core/api/services/brain/baseline.py +230 -0
- core/api/services/brain/capabilities.py +75 -0
- core/api/services/brain/cascade_rollup.py +388 -0
- core/api/services/brain/compound_bridge.py +215 -0
- core/api/services/brain/cycle.py +1242 -0
- core/api/services/brain/cycle_snapshot.py +371 -0
- core/api/services/brain/digest_collector.py +147 -0
- core/api/services/brain/direction.py +421 -0
- core/api/services/brain/drift.py +356 -0
- core/api/services/brain/drift_router.py +409 -0
- core/api/services/brain/edge_metrics.py +79 -0
- core/api/services/brain/events_reader.py +222 -0
- core/api/services/brain/findings.py +1379 -0
- core/api/services/brain/findings_reader.py +1006 -0
- core/api/services/brain/jobs.py +733 -0
- core/api/services/brain/journal.py +206 -0
- core/api/services/brain/knowledge_forms.py +92 -0
- core/api/services/brain/llm/__init__.py +37 -0
- core/api/services/brain/llm/_runner.py +62 -0
- core/api/services/brain/llm/base.py +70 -0
- core/api/services/brain/llm/cache.py +99 -0
- core/api/services/brain/llm/constants.py +46 -0
- core/api/services/brain/llm/direction_alignment.py +289 -0
- core/api/services/brain/llm/factory.py +132 -0
- core/api/services/brain/llm/finding_reasoning.py +98 -0
- core/api/services/brain/llm/finding_summary.py +92 -0
- core/api/services/brain/llm/grounding.py +46 -0
- core/api/services/brain/llm/journal_polish.py +96 -0
- core/api/services/brain/llm/local_gateway.py +426 -0
- core/api/services/brain/llm/parsers.py +71 -0
- core/api/services/brain/llm/router_glue.py +422 -0
- core/api/services/brain/memory_ops.py +1677 -0
- core/api/services/brain/models.py +140 -0
- core/api/services/brain/owner_hint.py +211 -0
- core/api/services/brain/recap.py +307 -0
- core/api/services/brain/rules/__init__.py +65 -0
- core/api/services/brain/rules/_signals.py +205 -0
- core/api/services/brain/rules/dr1_activity_without_status.py +108 -0
- core/api/services/brain/rules/dr2_decision_without_adr.py +110 -0
- core/api/services/brain/rules/dr3_stale_open_loop.py +127 -0
- core/api/services/brain/rules/dr4_docs_governance_drift.py +79 -0
- core/api/services/brain/rules/dr5_playbook_changed.py +99 -0
- core/api/services/brain/rules/dr6_external_update_unpropagated.py +100 -0
- core/api/services/brain/rules/dr7_claimed_decision_gap.py +123 -0
- core/api/services/brain/rules/dr8_direction_misalignment.py +230 -0
- core/api/services/brain/runs_reader.py +485 -0
- core/api/services/brain/scope.py +79 -0
- core/api/services/brain/sources/__init__.py +46 -0
- core/api/services/brain/sources/base.py +86 -0
- core/api/services/brain/sources/git_kg.py +393 -0
- core/api/services/brain/sources/handoffs.py +157 -0
- core/api/services/brain/sources/ingestor.py +130 -0
- core/api/services/brain/sources/learnings.py +121 -0
- core/api/services/brain/sources/pir_tasks.py +245 -0
- core/api/services/brain/watermarks.py +147 -0
- core/api/services/brain/ws_emitter.py +170 -0
- core/api/services/cc_tasks_reader.py +76 -0
- core/api/services/ci_service.py +263 -0
- core/api/services/claude_metrics.py +796 -0
- core/api/services/codex_metrics.py +364 -0
- core/api/services/conversation_reader.py +102 -0
- core/api/services/cost_service.py +243 -0
- core/api/services/crypto.py +147 -0
- core/api/services/docs_governance/__init__.py +1 -0
- core/api/services/docs_governance/confidence.py +230 -0
- core/api/services/docs_governance/config.py +87 -0
- core/api/services/docs_governance/enrichment.py +65 -0
- core/api/services/docs_governance/frontmatter_validator.py +83 -0
- core/api/services/docs_governance/hard_gates.py +221 -0
- core/api/services/docs_governance/triage_orchestrator.py +98 -0
- core/api/services/embedding_internal.py +395 -0
- core/api/services/embedding_service.py +832 -0
- core/api/services/event_dispatcher.py +167 -0
- core/api/services/events.py +70 -0
- core/api/services/git_ops.py +621 -0
- core/api/services/graph_cosmo_service.py +440 -0
- core/api/services/graph_ranker.py +306 -0
- core/api/services/graph_service.py +1589 -0
- core/api/services/inbox.py +800 -0
- core/api/services/inbox_digest.py +221 -0
- core/api/services/inbox_digest_deep_research.py +80 -0
- core/api/services/inbox_digest_jobs.py +595 -0
- core/api/services/inbox_gmail_sync.py +167 -0
- core/api/services/inbox_llm_classifier.py +906 -0
- core/api/services/inbox_source_identity.py +116 -0
- core/api/services/inbox_sources.py +456 -0
- core/api/services/inbox_taxonomy.py +195 -0
- core/api/services/inbox_tldr.py +1079 -0
- core/api/services/inbox_triage.py +899 -0
- core/api/services/ingest/__init__.py +13 -0
- core/api/services/ingest/api_key_auth.py +136 -0
- core/api/services/ingest/auto_approve.py +120 -0
- core/api/services/ingest/classifier.py +138 -0
- core/api/services/ingest/confidence.py +173 -0
- core/api/services/ingest/dispatch.py +88 -0
- core/api/services/ingest/embedding_router.py +272 -0
- core/api/services/ingest/events.py +33 -0
- core/api/services/ingest/ignore_patterns.py +79 -0
- core/api/services/ingest/image_probe.py +218 -0
- core/api/services/ingest/ingress.py +263 -0
- core/api/services/ingest/insert_saga.py +793 -0
- core/api/services/ingest/llm/__init__.py +13 -0
- core/api/services/ingest/llm/anthropic_haiku.py +23 -0
- core/api/services/ingest/llm/base.py +59 -0
- core/api/services/ingest/llm/byok_provider.py +130 -0
- core/api/services/ingest/llm/classification_context.py +301 -0
- core/api/services/ingest/llm/config_store.py +246 -0
- core/api/services/ingest/llm/factory.py +24 -0
- core/api/services/ingest/llm/kg_enricher.py +306 -0
- core/api/services/ingest/llm/local_gateway.py +821 -0
- core/api/services/ingest/llm/local_vllm.py +23 -0
- core/api/services/ingest/llm/openai_nano.py +349 -0
- core/api/services/ingest/lock_advisory.py +57 -0
- core/api/services/ingest/parser_router.py +1756 -0
- core/api/services/ingest/parsers/__init__.py +1 -0
- core/api/services/ingest/parsers/docling_parser.py +142 -0
- core/api/services/ingest/parsers/docparse_gateway.py +178 -0
- core/api/services/ingest/parsers/docx_parser.py +127 -0
- core/api/services/ingest/parsers/folder_unpacker.py +85 -0
- core/api/services/ingest/parsers/gateway_aux.py +147 -0
- core/api/services/ingest/parsers/image_parser.py +251 -0
- core/api/services/ingest/parsers/internal_markdown.py +89 -0
- core/api/services/ingest/parsers/ocr_gateway.py +117 -0
- core/api/services/ingest/parsers/ocr_pdf_parser.py +112 -0
- core/api/services/ingest/parsers/pdf_types.py +13 -0
- core/api/services/ingest/parsers/transcript_parser.py +445 -0
- core/api/services/ingest/parsers/vision_gateway.py +186 -0
- core/api/services/ingest/parsers/xlsx_parser.py +91 -0
- core/api/services/ingest/parsers/zip_unpacker.py +126 -0
- core/api/services/ingest/preflight.py +393 -0
- core/api/services/ingest/retry_voyage.py +88 -0
- core/api/services/ingest/routing_policy.py +307 -0
- core/api/services/ingest/serializers/__init__.py +1 -0
- core/api/services/ingest/serializers/xlsx_to_markdown.py +80 -0
- core/api/services/ingest/skip_log.py +74 -0
- core/api/services/ingest/watcher.py +637 -0
- core/api/services/kg/__init__.py +0 -0
- core/api/services/kg/audit.py +49 -0
- core/api/services/kg/hybrid_search.py +691 -0
- core/api/services/kg/lens.py +339 -0
- core/api/services/kg/pr_impact.py +770 -0
- core/api/services/kg/queries.py +152 -0
- core/api/services/kg/ranking.py +89 -0
- core/api/services/kg/rrf.py +143 -0
- core/api/services/kg_watcher_control.py +161 -0
- core/api/services/local_llm/__init__.py +19 -0
- core/api/services/local_llm/async_client.py +385 -0
- core/api/services/local_llm/client.py +173 -0
- core/api/services/local_llm/url_validator.py +44 -0
- core/api/services/metrics_collector.py +646 -0
- core/api/services/metrics_providers.py +65 -0
- core/api/services/model_registry.py +266 -0
- core/api/services/model_router.py +137 -0
- core/api/services/n8n_client.py +77 -0
- core/api/services/newsletter_llm_gateway.py +66 -0
- core/api/services/notification_service.py +134 -0
- core/api/services/openai_responses.py +55 -0
- core/api/services/opencode_metrics.py +375 -0
- core/api/services/opencode_sessions.py +173 -0
- core/api/services/pii_redactor.py +138 -0
- core/api/services/pr_impact_pipeline/__init__.py +21 -0
- core/api/services/pr_impact_pipeline/differ.py +421 -0
- core/api/services/pr_impact_pipeline/dispatcher.py +415 -0
- core/api/services/pr_impact_pipeline/gc.py +93 -0
- core/api/services/pr_impact_pipeline/languages.py +192 -0
- core/api/services/pr_impact_pipeline/parser.py +178 -0
- core/api/services/pr_impact_pipeline/writer.py +394 -0
- core/api/services/pr_service.py +1393 -0
- core/api/services/project_paths.py +70 -0
- core/api/services/project_status_updates.py +265 -0
- core/api/services/providers.py +276 -0
- core/api/services/push_service.py +170 -0
- core/api/services/reminder_service.py +89 -0
- core/api/services/runas.py +41 -0
- core/api/services/salience_service.py +69 -0
- core/api/services/security_collector.py +281 -0
- core/api/services/session_catalog.py +385 -0
- core/api/services/session_metrics_service.py +301 -0
- core/api/services/session_ops.py +272 -0
- core/api/services/session_state.py +173 -0
- core/api/services/share_links.py +222 -0
- core/api/services/task_transitions.py +146 -0
- core/api/services/terminal_metrics.py +462 -0
- core/api/services/terminal_metrics_dump.py +203 -0
- core/api/services/tmux.py +1205 -0
- core/api/services/webhook_service.py +422 -0
- core/api/services/workspace_sync.py +164 -0
- core/api/templates/__init__.py +1 -0
- core/api/templates/markdown_share.py +164 -0
- core/api/terminal.py +1031 -0
- core/api/tests/__init__.py +0 -0
- core/api/tests/test_agent_facing_auth_dependencies.py +132 -0
- core/api/tests/test_audit_permissions.py +133 -0
- core/api/tests/test_backfill_session_conversations.py +90 -0
- core/api/tests/test_backfill_working_seconds_msg.py +129 -0
- core/api/tests/test_claude_metrics.py +326 -0
- core/api/tests/test_codex_metrics.py +189 -0
- core/api/tests/test_finder_paths.py +74 -0
- core/api/tests/test_git_ops_merge.py +155 -0
- core/api/tests/test_learnings_check_search.py +81 -0
- core/api/tests/test_metrics_providers.py +133 -0
- core/api/tests/test_migration_087.py +164 -0
- core/api/tests/test_migration_088.py +94 -0
- core/api/tests/test_migration_089.py +116 -0
- core/api/tests/test_openai_responses.py +24 -0
- core/api/tests/test_opencode_metrics.py +740 -0
- core/api/tests/test_opencode_sessions.py +321 -0
- core/api/tests/test_pr_workflow_e2e.py +457 -0
- core/api/tests/test_projects_handoffs.py +31 -0
- core/api/tests/test_providers.py +138 -0
- core/api/tests/test_safety_bridge.py +347 -0
- core/api/tests/test_session_catalog.py +142 -0
- core/api/tests/test_session_conversations.py +512 -0
- core/api/tests/test_session_metrics_service.py +270 -0
- core/api/tests/test_session_resume_paths.py +548 -0
- core/api/tests/test_session_theme_mode_migration.py +56 -0
- core/api/tests/test_sessions_rbac.py +131 -0
- core/api/tests/test_share_edit.py +398 -0
- core/api/tests/test_share_repo.py +200 -0
- core/api/tests/test_terminal_session_manager.py +98 -0
- core/api/tests/test_terminal_upload.py +34 -0
- core/api/tests/test_tmux.py +272 -0
- core/api/tests/test_workspace_sync.py +186 -0
- core/api/tests/test_ws_ticket_in_memory.py +73 -0
- core/api/use_cases/__init__.py +11 -0
- core/api/use_cases/_context.py +89 -0
- core/api/use_cases/_errors.py +62 -0
- core/api/use_cases/_roles.py +16 -0
- core/api/use_cases/audit.py +171 -0
- core/api/use_cases/brain.py +1232 -0
- core/api/use_cases/costs.py +249 -0
- core/api/use_cases/graph.py +1153 -0
- core/api/use_cases/handoffs.py +506 -0
- core/api/use_cases/ingest_triage.py +1229 -0
- core/api/use_cases/learnings.py +538 -0
- core/api/use_cases/projects.py +705 -0
- core/api/use_cases/pull_requests.py +415 -0
- core/api/use_cases/search.py +926 -0
- core/api/use_cases/tasks.py +1495 -0
- core/api/visibility.py +141 -0
- core/cli/__init__.py +5 -0
- core/cli/_index_source.py +632 -0
- core/cli/_runtime_ctx.py +160 -0
- core/cli/_transmute.py +241 -0
- core/cli/marvis_doctor.py +704 -0
- core/cli/marvis_feedback.py +396 -0
- core/cli/marvis_governance.py +315 -0
- core/cli/marvis_hooks.py +515 -0
- core/cli/marvis_init.py +757 -0
- core/cli/marvis_mcp.py +401 -0
- core/cli/marvis_runtime.py +855 -0
- core/cli/marvis_telemetry.py +228 -0
- core/scripts/_drift_check.py +716 -0
- core/scripts/_frontmatter.py +66 -0
- core/scripts/_graph_writer.py +189 -0
- core/scripts/ast_parser.py +1553 -0
- core/scripts/install_hooks/__init__.py +1 -0
- core/scripts/install_hooks/_config.sh +109 -0
- core/scripts/install_hooks/block-dangerous-bash.sh +23 -0
- core/scripts/install_hooks/block-db-direct-write.sh +23 -0
- core/scripts/install_hooks/block-push-no-task.sh +23 -0
- core/scripts/install_hooks/block-staging-to-prod.sh +23 -0
- core/scripts/install_hooks/block-subtree-push.sh +23 -0
- core/scripts/install_hooks/config.json +53 -0
- core/scripts/install_hooks/enforce-no-merge-main.sh +23 -0
- core/scripts/install_hooks/enforce-worktree.sh +23 -0
- core/scripts/install_hooks/quality-gate.sh +170 -0
- core/scripts/install_hooks/safety_bridge.py +968 -0
- core/scripts/install_hooks/secret-scan.sh +23 -0
- core/scripts/migrate_spike_node_ids.py +122 -0
- core/scripts/populate_artifacts.py +2198 -0
- core/scripts/populate_cross_project.py +2457 -0
- core/scripts/populate_inbox_nodes.py +357 -0
- core/scripts/populate_pr_impact.py +267 -0
- core/scripts/populate_project_nodes.py +603 -0
- core/scripts/populate_touch_counter.py +337 -0
- core/scripts/reparse_failed.py +57 -0
- core/scripts/safety_bridge.py +968 -0
- core/telemetry/__init__.py +9 -0
- core/telemetry/client.py +405 -0
- core/telemetry/schema.py +122 -0
- core/wizard/__init__.py +65 -0
- core/wizard/byok_vault.py +147 -0
- core/wizard/defaults.py +58 -0
- core/wizard/state.py +117 -0
- core/wizard/steps.py +70 -0
- core/wizard/validation.py +136 -0
- marvisx_cli-0.1.0.dist-info/METADATA +201 -0
- marvisx_cli-0.1.0.dist-info/RECORD +587 -0
- marvisx_cli-0.1.0.dist-info/WHEEL +5 -0
- marvisx_cli-0.1.0.dist-info/entry_points.txt +3 -0
- marvisx_cli-0.1.0.dist-info/licenses/LICENSE +98 -0
- marvisx_cli-0.1.0.dist-info/top_level.txt +3 -0
- migrations/001_initial.sql +33 -0
- migrations/002_tasks.sql +30 -0
- migrations/003_session_management.sql +7 -0
- migrations/004_projects_comments.sql +65 -0
- migrations/005_session_intelligence.sql +15 -0
- migrations/006_settings.sql +12 -0
- migrations/007_task_scoring.sql +12 -0
- migrations/008_cost_tracking.sql +31 -0
- migrations/009_session_card_metrics.sql +3 -0
- migrations/010_monitoring.sql +55 -0
- migrations/012_agent_api.sql +21 -0
- migrations/013_session_complete.sql +8 -0
- migrations/015_pull_requests.sql +43 -0
- migrations/015_pull_requests_down.sql +5 -0
- migrations/016_users_raci.sql +116 -0
- migrations/017_task_cost_entries.sql +87 -0
- migrations/018_agents.sql +73 -0
- migrations/018_agents_down.sql +13 -0
- migrations/019_review_feedback.sql +18 -0
- migrations/020_pr_commit_sha.sql +4 -0
- migrations/021_webhook_events.sql +18 -0
- migrations/022_devx_agent_managed.sql +11 -0
- migrations/022_devx_agent_managed_down.sql +6 -0
- migrations/023_devx_p1_gate.sql +7 -0
- migrations/023_devx_p1_gate_down.sql +3 -0
- migrations/024_chat_messages.sql +16 -0
- migrations/024_pr_conversation_id.sql +8 -0
- migrations/024_task_indexes.sql +21 -0
- migrations/024_task_indexes_down.sql +7 -0
- migrations/025_audit_log.sql +17 -0
- migrations/026_agent_tokens.sql +20 -0
- migrations/027_teams_auth_phase_b.sql +35 -0
- migrations/028_learnings.sql +23 -0
- migrations/029_team_roles.sql +14 -0
- migrations/030_finder_pins.sql +10 -0
- migrations/031_pr_deploy_status.sql +9 -0
- migrations/032_task_reminders.sql +7 -0
- migrations/033_events_retry_count.sql +6 -0
- migrations/033_session_owner.sql +9 -0
- migrations/034_notifications.sql +38 -0
- migrations/035_shared_links.sql +15 -0
- migrations/036_session_index_upgrade.sql +29 -0
- migrations/037_pr_approval.sql +15 -0
- migrations/038_pr_submitted_by.sql +6 -0
- migrations/039_push_subscriptions.sql +17 -0
- migrations/040_semantic_search.sql +16 -0
- migrations/041_workspaces.sql +63 -0
- migrations/042_oidc_providers.sql +24 -0
- migrations/043_ci_checks.sql +31 -0
- migrations/044_agent_metrics.sql +30 -0
- migrations/045_documents_doc_type.sql +5 -0
- migrations/046_salience.sql +13 -0
- migrations/047_seed_missing_agents.sql +6 -0
- migrations/048_fix_agent_paths_roles.sql +5 -0
- migrations/049_agent_role_and_learnings_schema.sql +3 -0
- migrations/050_session_provider.sql +2 -0
- migrations/051_session_launch_profile.sql +4 -0
- migrations/052_session_theme_mode.sql +2 -0
- migrations/052_task_kind.sql +4 -0
- migrations/053_inbox_items.sql +31 -0
- migrations/054_inbox_triage_contract.sql +30 -0
- migrations/055_inbox_topic_treatment.sql +12 -0
- migrations/056_inbox_treatment_read_save.sql +57 -0
- migrations/057_session_theme_mode_backfill.sql +4 -0
- migrations/058_inbox_item_status_lifecycle.sql +13 -0
- migrations/059_inbox_tldr_and_source_scores.sql +18 -0
- migrations/060_newsletter.sql +16 -0
- migrations/061_inbox_redesign.sql +69 -0
- migrations/062_fix_inbox_sources_backfill.sql +37 -0
- migrations/063_task_completion_mode.sql +23 -0
- migrations/064_judge_mode_setting.sql +4 -0
- migrations/065_knowledge_graph_spike.sql +40 -0
- migrations/066_digest_ranking_inputs.sql +10 -0
- migrations/066_kg_artifact_nodes.sql +129 -0
- migrations/067_inbox_digest_selections.sql +28 -0
- migrations/067_kg_temporal.sql +53 -0
- migrations/068_inbox_digest_app_settings.sql +9 -0
- migrations/068_kg_touch_counter.sql +52 -0
- migrations/069_kg_doc_types.sql +117 -0
- migrations/070_digest_ranking_inputs_recovery.sql +3 -0
- migrations/071_inbox_digest_selections_recovery.sql +3 -0
- migrations/072_inbox_digest_app_settings_recovery.sql +3 -0
- migrations/073_kg_cross_project.sql +216 -0
- migrations/073_kg_cross_project_down.sql +77 -0
- migrations/074_kg_infra_types.sql +208 -0
- migrations/074_kg_infra_types_down.sql +80 -0
- migrations/075_kg_file_state_recovery.sql +35 -0
- migrations/075_kg_file_state_recovery_down.sql +5 -0
- migrations/076_kg_watcher_state.sql +33 -0
- migrations/076_kg_watcher_state_down.sql +3 -0
- migrations/077_kg_doc_types_extend.sql +226 -0
- migrations/077_kg_doc_types_extend_down.sql +80 -0
- migrations/078_kg_fts5.sql +102 -0
- migrations/078_kg_fts5_down.sql +14 -0
- migrations/079_kg_missing_indexes.sql +31 -0
- migrations/079_kg_missing_indexes_down.sql +10 -0
- migrations/080_kg_fts5_extended.sql +232 -0
- migrations/080_kg_fts5_extended_down.sql +25 -0
- migrations/081_kg_lens_indexes.sql +9 -0
- migrations/081_kg_lens_indexes_down.sql +3 -0
- migrations/082_kg_pins.sql +26 -0
- migrations/082_kg_pins_down.sql +14 -0
- migrations/083_kg_graph_nodes_degree.sql +20 -0
- migrations/083_kg_graph_nodes_degree_down.sql +15 -0
- migrations/084_drop_legacy_scheduler_tables.sql +58 -0
- migrations/084_drop_legacy_scheduler_tables_down.sql +112 -0
- migrations/085_kg_edge_resolves_to.sql +142 -0
- migrations/085_kg_edge_resolves_to_down.sql +66 -0
- migrations/086_project_status_updates_feed.sql +20 -0
- migrations/086_project_status_updates_feed_down.sql +36 -0
- migrations/087_session_metrics_dual.sql +50 -0
- migrations/087_session_metrics_dual_down.sql +21 -0
- migrations/088_rename_context_pct_legacy.sql +23 -0
- migrations/088_rename_context_pct_legacy_down.sql +8 -0
- migrations/089_session_metrics_equivalent_cost.sql +26 -0
- migrations/089_session_metrics_equivalent_cost_down.sql +11 -0
- migrations/090_kg_inbox_node_type.sql +26 -0
- migrations/090_kg_inbox_node_type_down.sql +20 -0
- migrations/091_kg_inbox_node_type_check.sql +265 -0
- migrations/091_kg_inbox_node_type_check_down.sql +129 -0
- migrations/092_sessions_activity_state_ts.sql +29 -0
- migrations/092_sessions_activity_state_ts_down.sql +14 -0
- migrations/093_sessions_activity_state_column.sql +29 -0
- migrations/093_sessions_activity_state_column_down.sql +10 -0
- migrations/094_ingest_pending.sql +55 -0
- migrations/094_ingest_pending_down.sql +15 -0
- migrations/095_kg_intent_first.sql +77 -0
- migrations/095_kg_intent_first_down.sql +25 -0
- migrations/096_kg_xlsx_artifact_prefix.sql +17 -0
- migrations/096_kg_xlsx_artifact_prefix_down.sql +11 -0
- migrations/097_ingest_change_history.sql +37 -0
- migrations/097_ingest_change_history_down.sql +13 -0
- migrations/098_kg_node_type_business.sql +254 -0
- migrations/098_kg_node_type_business_down.sql +195 -0
- migrations/099_kg_edges_restore_weight.sql +58 -0
- migrations/099_kg_edges_restore_weight_down.sql +12 -0
- migrations/100_kg_enriched_at.sql +25 -0
- migrations/100_kg_enriched_at_down.sql +12 -0
- migrations/101_local_llm_shadow_comparisons.sql +66 -0
- migrations/101_local_llm_shadow_comparisons_down.sql +15 -0
- migrations/102_promote_llm_costs.sql +69 -0
- migrations/102_promote_llm_costs_down.sql +19 -0
- migrations/103_ingest_skipped_log.sql +46 -0
- migrations/103_ingest_skipped_log_down.sql +15 -0
- migrations/120_docs_governance.sql +50 -0
- migrations/120_docs_governance_down.sql +11 -0
- migrations/121_notification_event_fk_cleanup.sql +21 -0
- migrations/121_notification_event_fk_cleanup_down.sql +10 -0
- migrations/122_docs_drift_history.sql +34 -0
- migrations/122_docs_drift_history_down.sql +15 -0
- migrations/123_ingest_parser_waiting_status.sql +69 -0
- migrations/123_ingest_parser_waiting_status_down.sql +69 -0
- migrations/124_heypocket_recordings.sql +63 -0
- migrations/124_heypocket_recordings_down.sql +13 -0
- migrations/125_kg_node_type_record.sql +219 -0
- migrations/125_kg_node_type_record_down.sql +205 -0
- migrations/126_ingest_terminal_upload_source_kind.sql +69 -0
- migrations/126_ingest_terminal_upload_source_kind_down.sql +69 -0
- migrations/127_brain_v1_substrate.sql +200 -0
- migrations/127_brain_v1_substrate_down.sql +32 -0
- migrations/128_brain_drift_signals.sql +157 -0
- migrations/128_brain_drift_signals_down.sql +23 -0
- migrations/129_brain_memory_operations.sql +232 -0
- migrations/129_brain_memory_operations_down.sql +27 -0
- migrations/130_brain_findings.sql +258 -0
- migrations/130_brain_findings_down.sql +29 -0
- migrations/132_kg_pr_modifies.sql +242 -0
- migrations/132_kg_pr_modifies_down.sql +99 -0
- migrations/133_brain_v1_2_direction_schema.sql +476 -0
- migrations/133_brain_v1_2_direction_schema_down.sql +273 -0
- migrations/134_brain_journal_narrative_polished.sql +8 -0
- migrations/134_brain_journal_narrative_polished_down.sql +6 -0
- migrations/135_kg_edges_provider.sql +21 -0
- migrations/136_documents_fts.sql +56 -0
- migrations/137_promote_llm_costs.sql +59 -0
- migrations/137_promote_llm_costs_down.sql +19 -0
- migrations/138_ingest_api_keys.sql +39 -0
- migrations/138_ingest_api_keys_down.sql +8 -0
- migrations/139_ingest_pending_ingress.sql +91 -0
- migrations/139_ingest_pending_ingress_down.sql +73 -0
- migrations/140_ingest_idempotency_quota.sql +45 -0
- migrations/140_ingest_idempotency_quota_down.sql +9 -0
- migrations/141_ingest_pending_metadata.sql +16 -0
- migrations/141_ingest_pending_metadata_down.sql +7 -0
- migrations/142_llm_function_config.sql +36 -0
- migrations/142_llm_function_config_down.sql +8 -0
- migrations/143_kg_code_embeddings.sql +25 -0
- migrations/143_kg_code_embeddings_down.sql +5 -0
- migrations/__init__.py +4 -0
- projects/_template/project.yaml +46 -0
|
@@ -0,0 +1,1061 @@
|
|
|
1
|
+
# v4.0.0 - 2026-05-27 - S1 F1.5: thin adapter over use_cases.projects (filesystem/git/index helpers retained + re-exported)
|
|
2
|
+
# v3.7.0 - 2026-04-14 - git_push/git_pull use get_write_db (refactor batch 4/6)
|
|
3
|
+
"""HTTP adapter for the projects domain (S1 collapse-runtime, follows the learnings TEMPLATE).
|
|
4
|
+
|
|
5
|
+
This router is a thin transport adapter for the CRUD/query/visibility logic, which
|
|
6
|
+
lives in :mod:`core.api.use_cases.projects` (pure, fastapi-free). Each handler:
|
|
7
|
+
|
|
8
|
+
1. resolves identity into a :class:`CallerContext` (``from_user_info``);
|
|
9
|
+
2. for slug-scoped reads, resolves visibility (``get_visible_projects``) at the
|
|
10
|
+
boundary and passes it in — the use_case ENFORCES it (DECISION 1: 404, does not
|
|
11
|
+
reveal existence — parity with ``check_project_access``);
|
|
12
|
+
3. calls the use_case inside ``try/except ServiceError`` -> ``to_http``;
|
|
13
|
+
4. for ``get_project``, attaches ``deep`` KG context (rate-limit + log + lens) at
|
|
14
|
+
the boundary (DECISION 2 — a per-surface concern).
|
|
15
|
+
|
|
16
|
+
CENTRAL-router note: the filesystem/discovery/git helpers + the project-index
|
|
17
|
+
globals (``PROJECT_DIRS``, ``_set_project_dirs``, ``_build_project_index``,
|
|
18
|
+
``_project_index``, ``_index_built_at``, ``_INDEX_TTL``, ``_find_project_entry``,
|
|
19
|
+
``_find_project_path``, ``_find_git_path``, ``_read_project_yaml``,
|
|
20
|
+
``_get_programs``, ``_parse_handoffs``, …) are INFRASTRUCTURE imported by ~12 other
|
|
21
|
+
modules directly from this path. They STAY here unchanged (mutable globals +
|
|
22
|
+
run-as-aware git command) and remain importable. The use_case reaches for them
|
|
23
|
+
function-locally so it stays fastapi-free.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import shutil
|
|
33
|
+
import time
|
|
34
|
+
from collections import defaultdict
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from datetime import date
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Literal
|
|
39
|
+
|
|
40
|
+
import aiosqlite
|
|
41
|
+
import yaml
|
|
42
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
43
|
+
from fastapi import Path as PathParam
|
|
44
|
+
|
|
45
|
+
from core.api.config import settings
|
|
46
|
+
from core.api.db import get_db, get_write_db
|
|
47
|
+
from core.api.models import (
|
|
48
|
+
DocEntry,
|
|
49
|
+
HandoffEntry,
|
|
50
|
+
ProgramInfo,
|
|
51
|
+
ProjectCreateRequest,
|
|
52
|
+
ProjectCreateResponse,
|
|
53
|
+
ProjectDetail,
|
|
54
|
+
ProjectInfo,
|
|
55
|
+
StatusCounts,
|
|
56
|
+
StatusUpdateFeedCreateRequest,
|
|
57
|
+
StatusUpdateFeedItem,
|
|
58
|
+
StatusUpdateFeedResponse,
|
|
59
|
+
StatusUpdateResponse,
|
|
60
|
+
UserInfo,
|
|
61
|
+
)
|
|
62
|
+
from core.api.rbac import require_role
|
|
63
|
+
from core.api.routers._adapter import to_http
|
|
64
|
+
from core.api.security import get_current_user, get_current_user_or_agent
|
|
65
|
+
from core.api.services import project_status_updates as status_feed_service # noqa: F401 (re-export / used by use_case)
|
|
66
|
+
from core.api.visibility import check_project_access, get_visible_projects # noqa: F401 (check_project_access kept as re-export seam)
|
|
67
|
+
from core.api.services.kg.audit import check_deep_rate_limit, log_kg_deep_access
|
|
68
|
+
from core.api.services.kg.lens import build_kg_context_for_project
|
|
69
|
+
from core.api.use_cases import projects as uc
|
|
70
|
+
from core.api.use_cases._context import CallerContext
|
|
71
|
+
from core.api.use_cases._errors import ServiceError
|
|
72
|
+
|
|
73
|
+
logger = logging.getLogger(__name__)
|
|
74
|
+
|
|
75
|
+
router = APIRouter(prefix="/api/v1/projects", tags=["projects"])
|
|
76
|
+
|
|
77
|
+
# Project base directories (default, overridden by DB settings)
|
|
78
|
+
PROJECT_DIRS: list[Path] = [
|
|
79
|
+
Path.home() / "workspace" / "projects",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _set_project_dirs(dirs: list[Path]) -> None:
|
|
84
|
+
"""Update project directories (called from settings router or startup)."""
|
|
85
|
+
global PROJECT_DIRS
|
|
86
|
+
PROJECT_DIRS = dirs
|
|
87
|
+
|
|
88
|
+
MAX_FILE_SIZE = 500_000 # 500KB per file
|
|
89
|
+
|
|
90
|
+
# --- ProjectIndexEntry: rich index with type awareness ---
|
|
91
|
+
|
|
92
|
+
ProjectType = Literal["work", "code", "system"]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class ProjectIndexEntry:
|
|
97
|
+
slug: str
|
|
98
|
+
metadata_path: Path
|
|
99
|
+
repo_path: Path | None
|
|
100
|
+
project_type: ProjectType
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
from core.api.config import ALLOWED_REPO_PARENTS # centralized in config.py
|
|
104
|
+
from core.api.services.runas import GIT_CMD as _RUNAS_GIT_CMD
|
|
105
|
+
|
|
106
|
+
_project_index: dict[str, ProjectIndexEntry] = {}
|
|
107
|
+
_index_built_at: float = 0
|
|
108
|
+
_INDEX_TTL = 300 # 5 minutes
|
|
109
|
+
|
|
110
|
+
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _build_project_index() -> dict[str, ProjectIndexEntry]:
|
|
114
|
+
"""Build slug -> ProjectIndexEntry mapping from .task files + project.yaml."""
|
|
115
|
+
global _project_index, _index_built_at
|
|
116
|
+
index: dict[str, ProjectIndexEntry] = {}
|
|
117
|
+
for base in PROJECT_DIRS:
|
|
118
|
+
if not base.exists():
|
|
119
|
+
continue
|
|
120
|
+
for d in sorted(base.iterdir()):
|
|
121
|
+
if not d.is_dir() or d.is_symlink():
|
|
122
|
+
continue
|
|
123
|
+
task_file = d / ".task"
|
|
124
|
+
if task_file.exists():
|
|
125
|
+
try:
|
|
126
|
+
slug = task_file.read_text().strip()[:64]
|
|
127
|
+
except OSError:
|
|
128
|
+
continue
|
|
129
|
+
if not slug or not _SLUG_RE.match(slug):
|
|
130
|
+
continue
|
|
131
|
+
else:
|
|
132
|
+
if _SLUG_RE.match(d.name):
|
|
133
|
+
slug = d.name
|
|
134
|
+
else:
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
# Read project.yaml for type and repo_path
|
|
138
|
+
yaml_data = _read_project_yaml(d)
|
|
139
|
+
project_type: ProjectType = "work"
|
|
140
|
+
repo_path: Path | None = None
|
|
141
|
+
|
|
142
|
+
if yaml_data:
|
|
143
|
+
raw_type = yaml_data.get("type")
|
|
144
|
+
if raw_type in ("work", "code", "system"):
|
|
145
|
+
project_type = raw_type
|
|
146
|
+
else:
|
|
147
|
+
project_type = "code" if (d / ".git").exists() else "work"
|
|
148
|
+
|
|
149
|
+
repo_path_str = yaml_data.get("repo_path")
|
|
150
|
+
if repo_path_str:
|
|
151
|
+
rp = Path(repo_path_str).resolve()
|
|
152
|
+
if any(rp.is_relative_to(p) for p in ALLOWED_REPO_PARENTS):
|
|
153
|
+
repo_path = rp
|
|
154
|
+
else:
|
|
155
|
+
logger.warning("Invalid repo_path for %s: %s", slug, repo_path_str)
|
|
156
|
+
elif (d / ".git").exists():
|
|
157
|
+
repo_path = d.resolve()
|
|
158
|
+
else:
|
|
159
|
+
project_type = "code" if (d / ".git").exists() else "work"
|
|
160
|
+
if (d / ".git").exists():
|
|
161
|
+
repo_path = d.resolve()
|
|
162
|
+
|
|
163
|
+
if slug in index:
|
|
164
|
+
logger.warning("Duplicate slug '%s': %s vs %s", slug, index[slug].metadata_path, d)
|
|
165
|
+
|
|
166
|
+
index[slug] = ProjectIndexEntry(
|
|
167
|
+
slug=slug,
|
|
168
|
+
metadata_path=d,
|
|
169
|
+
repo_path=repo_path,
|
|
170
|
+
project_type=project_type,
|
|
171
|
+
)
|
|
172
|
+
_project_index = index
|
|
173
|
+
_index_built_at = time.monotonic()
|
|
174
|
+
return index
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _find_project_path(slug: str) -> Path | None:
|
|
178
|
+
"""O(1) lookup returning metadata_path with containment check."""
|
|
179
|
+
global _project_index, _index_built_at
|
|
180
|
+
if not _SLUG_RE.match(slug):
|
|
181
|
+
return None
|
|
182
|
+
if time.monotonic() - _index_built_at > _INDEX_TTL:
|
|
183
|
+
_build_project_index()
|
|
184
|
+
entry = _project_index.get(slug)
|
|
185
|
+
if entry is None:
|
|
186
|
+
return None
|
|
187
|
+
resolved = entry.metadata_path.resolve()
|
|
188
|
+
for base in PROJECT_DIRS:
|
|
189
|
+
if resolved.is_relative_to(base.resolve()):
|
|
190
|
+
return resolved
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _find_project_entry(slug: str) -> ProjectIndexEntry | None:
|
|
195
|
+
"""O(1) lookup returning full ProjectIndexEntry."""
|
|
196
|
+
if not _SLUG_RE.match(slug):
|
|
197
|
+
return None
|
|
198
|
+
if time.monotonic() - _index_built_at > _INDEX_TTL:
|
|
199
|
+
_build_project_index()
|
|
200
|
+
return _project_index.get(slug)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _find_git_path(slug: str) -> Path | None:
|
|
204
|
+
"""Resolve slug to git repo path for git operations."""
|
|
205
|
+
entry = _find_project_entry(slug)
|
|
206
|
+
if not entry or not entry.repo_path:
|
|
207
|
+
return None
|
|
208
|
+
repo = entry.repo_path.resolve()
|
|
209
|
+
if not any(repo.is_relative_to(p) for p in ALLOWED_REPO_PARENTS):
|
|
210
|
+
logger.warning("repo_path containment violation for %s: %s", slug, repo)
|
|
211
|
+
return None
|
|
212
|
+
if not (repo / ".git").exists():
|
|
213
|
+
return None
|
|
214
|
+
return repo
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# --- programs.yaml cache ---
|
|
218
|
+
|
|
219
|
+
_programs_cache: dict | None = None
|
|
220
|
+
_programs_mtime: float = 0
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _get_programs() -> dict:
|
|
224
|
+
"""Load programs.yaml with mtime-based cache."""
|
|
225
|
+
global _programs_cache, _programs_mtime
|
|
226
|
+
yaml_path = Path.home() / "workspace" / "programs.yaml"
|
|
227
|
+
if not yaml_path.exists():
|
|
228
|
+
return {}
|
|
229
|
+
try:
|
|
230
|
+
current_mtime = yaml_path.stat().st_mtime
|
|
231
|
+
if _programs_cache is not None and current_mtime == _programs_mtime:
|
|
232
|
+
return _programs_cache
|
|
233
|
+
_programs_cache = yaml.safe_load(yaml_path.read_text()) or {}
|
|
234
|
+
_programs_mtime = current_mtime
|
|
235
|
+
return _programs_cache
|
|
236
|
+
except Exception:
|
|
237
|
+
logger.exception("Failed to load programs.yaml")
|
|
238
|
+
return _programs_cache or {}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# --- project.yaml mtime cache ---
|
|
242
|
+
|
|
243
|
+
_project_yaml_cache: dict[str, dict | None] = {}
|
|
244
|
+
_project_yaml_mtime: dict[str, float] = {}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _read_project_yaml(project_path: Path) -> dict | None:
|
|
248
|
+
"""Read project.yaml with mtime-based cache. Returns dict or None."""
|
|
249
|
+
yaml_path = project_path / "project.yaml"
|
|
250
|
+
if not yaml_path.exists():
|
|
251
|
+
return None
|
|
252
|
+
slug = project_path.name
|
|
253
|
+
try:
|
|
254
|
+
current_mtime = yaml_path.stat().st_mtime
|
|
255
|
+
if slug in _project_yaml_cache and _project_yaml_mtime.get(slug) == current_mtime:
|
|
256
|
+
return _project_yaml_cache[slug]
|
|
257
|
+
content = _safe_read_file(project_path, "project.yaml")
|
|
258
|
+
if not content:
|
|
259
|
+
return None
|
|
260
|
+
data = yaml.safe_load(content)
|
|
261
|
+
if not isinstance(data, dict):
|
|
262
|
+
return None
|
|
263
|
+
_project_yaml_cache[slug] = data
|
|
264
|
+
_project_yaml_mtime[slug] = current_mtime
|
|
265
|
+
return data
|
|
266
|
+
except Exception:
|
|
267
|
+
logger.warning("Failed to parse project.yaml for %s", slug, exc_info=True)
|
|
268
|
+
return _project_yaml_cache.get(slug)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _get_project_metadata(project_path: Path) -> dict:
|
|
272
|
+
"""Get project metadata from project.yaml, fallback to context.md Config."""
|
|
273
|
+
yaml_data = _read_project_yaml(project_path)
|
|
274
|
+
if yaml_data:
|
|
275
|
+
return yaml_data
|
|
276
|
+
ctx = _safe_read_file(project_path, "context.md")
|
|
277
|
+
if ctx:
|
|
278
|
+
return _parse_context_config(ctx)
|
|
279
|
+
return {}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# --- Safe file reading ---
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _safe_read_file(project_path: Path, relative_path: str) -> str | None:
|
|
286
|
+
"""Read file within project dir with containment + size limit."""
|
|
287
|
+
target = (project_path / relative_path).resolve()
|
|
288
|
+
if not target.is_relative_to(project_path.resolve()):
|
|
289
|
+
return None
|
|
290
|
+
if (project_path / relative_path).is_symlink():
|
|
291
|
+
return None
|
|
292
|
+
if not target.is_file():
|
|
293
|
+
return None
|
|
294
|
+
try:
|
|
295
|
+
if target.stat().st_size > MAX_FILE_SIZE:
|
|
296
|
+
return None
|
|
297
|
+
return target.read_text(encoding="utf-8", errors="replace")
|
|
298
|
+
except OSError:
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# --- Task counts batch query ---
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
async def _get_all_task_counts(db: aiosqlite.Connection) -> dict[str, dict[str, int]]:
|
|
306
|
+
"""Single GROUP BY query for all projects."""
|
|
307
|
+
cursor = await db.execute(
|
|
308
|
+
"SELECT project, status, COUNT(*) as cnt "
|
|
309
|
+
"FROM tasks WHERE deleted_at IS NULL "
|
|
310
|
+
"GROUP BY project, status"
|
|
311
|
+
)
|
|
312
|
+
result: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
|
313
|
+
async for row in cursor:
|
|
314
|
+
result[row["project"]][row["status"]] = row["cnt"]
|
|
315
|
+
return dict(result)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# --- Latest status update per project ---
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
async def _get_latest_status_updates(db: aiosqlite.Connection) -> dict[str, tuple[str, str]]:
|
|
322
|
+
"""Latest status update (status, date) per project."""
|
|
323
|
+
cursor = await db.execute(
|
|
324
|
+
"SELECT project, status, created_at FROM project_status_updates "
|
|
325
|
+
"WHERE id IN (SELECT MAX(id) FROM project_status_updates GROUP BY project)"
|
|
326
|
+
)
|
|
327
|
+
result: dict[str, tuple[str, str]] = {}
|
|
328
|
+
async for row in cursor:
|
|
329
|
+
result[row["project"]] = (row["status"], row["created_at"])
|
|
330
|
+
return dict(result)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# --- Helpers ---
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _parse_context_config(content: str) -> dict:
|
|
337
|
+
"""Extract key-value pairs from ## Config section of context.md."""
|
|
338
|
+
config: dict[str, str] = {}
|
|
339
|
+
in_config = False
|
|
340
|
+
for line in content.splitlines():
|
|
341
|
+
if line.strip().startswith("## Config"):
|
|
342
|
+
in_config = True
|
|
343
|
+
continue
|
|
344
|
+
if in_config and line.strip().startswith("## "):
|
|
345
|
+
break
|
|
346
|
+
if in_config and line.strip().startswith("- "):
|
|
347
|
+
parts = line.strip()[2:].split(":", 1)
|
|
348
|
+
if len(parts) == 2:
|
|
349
|
+
key = parts[0].strip().strip("*")
|
|
350
|
+
config[key.lower()] = parts[1].strip()
|
|
351
|
+
return config
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _parse_handoffs(project_path: Path) -> list[HandoffEntry]:
|
|
355
|
+
"""Parse memory/handoff-*.md files. Reads YAML frontmatter if present, fallback to filename."""
|
|
356
|
+
memory_dir = project_path / "memory"
|
|
357
|
+
if not memory_dir.is_dir():
|
|
358
|
+
return []
|
|
359
|
+
entries: list[HandoffEntry] = []
|
|
360
|
+
for f in sorted(memory_dir.glob("handoff-*.md"), reverse=True):
|
|
361
|
+
if f.is_symlink():
|
|
362
|
+
continue
|
|
363
|
+
content = _safe_read_file(project_path, f"memory/{f.name}")
|
|
364
|
+
if not content:
|
|
365
|
+
continue
|
|
366
|
+
|
|
367
|
+
date = ""
|
|
368
|
+
session = None
|
|
369
|
+
branch = None
|
|
370
|
+
tags: list[str] = []
|
|
371
|
+
|
|
372
|
+
# Try YAML frontmatter first
|
|
373
|
+
if content.startswith("---"):
|
|
374
|
+
end = content.find("---", 3)
|
|
375
|
+
if end > 0:
|
|
376
|
+
try:
|
|
377
|
+
fm = yaml.safe_load(content[3:end])
|
|
378
|
+
if isinstance(fm, dict):
|
|
379
|
+
date = str(fm["date"]) if "date" in fm else ""
|
|
380
|
+
raw_session = fm.get("session")
|
|
381
|
+
if raw_session not in (None, ""):
|
|
382
|
+
session = str(raw_session)
|
|
383
|
+
branch = fm.get("branch")
|
|
384
|
+
tags = fm.get("tags", [])
|
|
385
|
+
if isinstance(tags, str):
|
|
386
|
+
tags = [tags]
|
|
387
|
+
except Exception:
|
|
388
|
+
pass
|
|
389
|
+
|
|
390
|
+
# Fallback: extract date from filename
|
|
391
|
+
if not date:
|
|
392
|
+
date_match = re.search(r"handoff-(\d{4}-\d{2}-\d{2})", f.name)
|
|
393
|
+
date = date_match.group(1) if date_match else ""
|
|
394
|
+
|
|
395
|
+
# Extract summary from ## Summary section (always, regardless of frontmatter)
|
|
396
|
+
summary = ""
|
|
397
|
+
in_summary = False
|
|
398
|
+
for line in content.splitlines():
|
|
399
|
+
if line.strip().startswith("## Summary"):
|
|
400
|
+
in_summary = True
|
|
401
|
+
continue
|
|
402
|
+
if in_summary and line.strip().startswith("## "):
|
|
403
|
+
break
|
|
404
|
+
if in_summary and line.strip():
|
|
405
|
+
summary += line.strip() + " "
|
|
406
|
+
if len(summary) > 200:
|
|
407
|
+
break
|
|
408
|
+
|
|
409
|
+
entries.append(HandoffEntry(
|
|
410
|
+
filename=f"memory/{f.name}",
|
|
411
|
+
date=date,
|
|
412
|
+
summary=summary.strip()[:200],
|
|
413
|
+
session=session,
|
|
414
|
+
branch=branch,
|
|
415
|
+
tags=tags,
|
|
416
|
+
))
|
|
417
|
+
return entries
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _parse_docs(project_path: Path, subdir: str) -> list[DocEntry]:
|
|
421
|
+
"""Parse docs/plans/ or docs/solutions/ files."""
|
|
422
|
+
docs_dir = project_path / "docs" / subdir
|
|
423
|
+
if not docs_dir.is_dir():
|
|
424
|
+
return []
|
|
425
|
+
entries: list[DocEntry] = []
|
|
426
|
+
for f in sorted(docs_dir.glob("*.md"), reverse=True):
|
|
427
|
+
if f.is_symlink():
|
|
428
|
+
continue
|
|
429
|
+
content = _safe_read_file(project_path, f"docs/{subdir}/{f.name}")
|
|
430
|
+
title = None
|
|
431
|
+
date = None
|
|
432
|
+
category = None
|
|
433
|
+
if content:
|
|
434
|
+
# Parse YAML frontmatter
|
|
435
|
+
if content.startswith("---"):
|
|
436
|
+
end = content.find("---", 3)
|
|
437
|
+
if end > 0:
|
|
438
|
+
try:
|
|
439
|
+
fm = yaml.safe_load(content[3:end])
|
|
440
|
+
if isinstance(fm, dict):
|
|
441
|
+
title = fm.get("title")
|
|
442
|
+
date = str(fm["date"]) if "date" in fm else None
|
|
443
|
+
category = fm.get("category") or fm.get("type")
|
|
444
|
+
except Exception:
|
|
445
|
+
pass
|
|
446
|
+
if not date:
|
|
447
|
+
date_match = re.search(r"(\d{4}-\d{2}-\d{2})", f.name)
|
|
448
|
+
date = date_match.group(1) if date_match else None
|
|
449
|
+
if not title:
|
|
450
|
+
title = f.stem
|
|
451
|
+
entries.append(DocEntry(filename=f"docs/{subdir}/{f.name}", date=date, title=title, category=category))
|
|
452
|
+
return entries
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
# --- Git operations ---
|
|
456
|
+
|
|
457
|
+
# Run-as-aware git (see services/runas.py): `sudo -u <user> git` when a run-as
|
|
458
|
+
# user is configured, else plain `git`. Tuple so it unpacks into
|
|
459
|
+
# create_subprocess_exec(*_GIT_CMD, "log", ...).
|
|
460
|
+
_GIT_CMD: tuple[str, ...] = tuple(_RUNAS_GIT_CMD)
|
|
461
|
+
|
|
462
|
+
_GIT_REMOTE_RE = re.compile(r"^(https://github\.com/|git@github\.com:)")
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
async def _validate_git_remote(project_path: Path) -> None:
|
|
466
|
+
"""SSRF prevention: only allow operations to known remotes."""
|
|
467
|
+
proc = await asyncio.create_subprocess_exec(
|
|
468
|
+
*_GIT_CMD, "remote", "get-url", "origin",
|
|
469
|
+
cwd=str(project_path),
|
|
470
|
+
stdout=asyncio.subprocess.PIPE,
|
|
471
|
+
stderr=asyncio.subprocess.PIPE,
|
|
472
|
+
)
|
|
473
|
+
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5)
|
|
474
|
+
remote_url = stdout.decode().strip()
|
|
475
|
+
if not _GIT_REMOTE_RE.match(remote_url):
|
|
476
|
+
raise HTTPException(403, "Git remote not allowed (only github.com)")
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
async def _git_log(project_path: Path, limit: int = 20) -> list[dict]:
|
|
480
|
+
"""Get git log as list of commit dicts."""
|
|
481
|
+
limit = max(1, min(limit, 100))
|
|
482
|
+
proc = await asyncio.create_subprocess_exec(
|
|
483
|
+
*_GIT_CMD, "log", f"--max-count={limit}",
|
|
484
|
+
"--format=%H%x09%h%x09%s%x09%an%x09%aI",
|
|
485
|
+
cwd=str(project_path),
|
|
486
|
+
stdout=asyncio.subprocess.PIPE,
|
|
487
|
+
stderr=asyncio.subprocess.PIPE,
|
|
488
|
+
)
|
|
489
|
+
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=30)
|
|
490
|
+
if proc.returncode != 0:
|
|
491
|
+
return []
|
|
492
|
+
commits = []
|
|
493
|
+
for line in stdout.decode().strip().splitlines():
|
|
494
|
+
parts = line.split("\t")
|
|
495
|
+
if len(parts) >= 5:
|
|
496
|
+
commits.append({
|
|
497
|
+
"hash": parts[0],
|
|
498
|
+
"hash_short": parts[1],
|
|
499
|
+
"message": parts[2],
|
|
500
|
+
"author": parts[3],
|
|
501
|
+
"date": parts[4],
|
|
502
|
+
})
|
|
503
|
+
return commits
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
async def _git_diff(project_path: Path) -> str:
|
|
507
|
+
"""Get git diff (staged + unstaged), truncated to 100KB."""
|
|
508
|
+
proc = await asyncio.create_subprocess_exec(
|
|
509
|
+
*_GIT_CMD, "diff", "--stat",
|
|
510
|
+
cwd=str(project_path),
|
|
511
|
+
stdout=asyncio.subprocess.PIPE,
|
|
512
|
+
stderr=asyncio.subprocess.PIPE,
|
|
513
|
+
)
|
|
514
|
+
stat_out, _ = await asyncio.wait_for(proc.communicate(), timeout=30)
|
|
515
|
+
|
|
516
|
+
proc2 = await asyncio.create_subprocess_exec(
|
|
517
|
+
*_GIT_CMD, "diff",
|
|
518
|
+
cwd=str(project_path),
|
|
519
|
+
stdout=asyncio.subprocess.PIPE,
|
|
520
|
+
stderr=asyncio.subprocess.PIPE,
|
|
521
|
+
)
|
|
522
|
+
diff_out, _ = await asyncio.wait_for(proc2.communicate(), timeout=30)
|
|
523
|
+
|
|
524
|
+
stat_str = stat_out.decode(errors="replace")
|
|
525
|
+
diff_str = diff_out.decode(errors="replace")
|
|
526
|
+
# Truncate to 100KB total
|
|
527
|
+
combined = f"{stat_str}\n---\n{diff_str}"
|
|
528
|
+
return combined[:100_000]
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
async def _git_branches(project_path: Path) -> list[dict]:
|
|
532
|
+
"""Get list of branches with current indicator."""
|
|
533
|
+
proc = await asyncio.create_subprocess_exec(
|
|
534
|
+
*_GIT_CMD, "branch", "--format=%(refname:short)%09%(HEAD)",
|
|
535
|
+
cwd=str(project_path),
|
|
536
|
+
stdout=asyncio.subprocess.PIPE,
|
|
537
|
+
stderr=asyncio.subprocess.PIPE,
|
|
538
|
+
)
|
|
539
|
+
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10)
|
|
540
|
+
if proc.returncode != 0:
|
|
541
|
+
return []
|
|
542
|
+
branches = []
|
|
543
|
+
for line in stdout.decode().strip().splitlines():
|
|
544
|
+
parts = line.split("\t")
|
|
545
|
+
if len(parts) >= 2:
|
|
546
|
+
branches.append({
|
|
547
|
+
"name": parts[0],
|
|
548
|
+
"is_current": parts[1].strip() == "*",
|
|
549
|
+
})
|
|
550
|
+
return branches
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
async def _git_graph_log(
|
|
554
|
+
project_path: Path, limit: int = 50, skip: int = 0, all_branches: bool = True,
|
|
555
|
+
) -> list[dict]:
|
|
556
|
+
"""Get git log with parent hashes and decorations for graph rendering."""
|
|
557
|
+
limit = max(1, min(limit, 200))
|
|
558
|
+
cmd = [
|
|
559
|
+
*_GIT_CMD, "log", "--topo-order",
|
|
560
|
+
f"--max-count={limit}", f"--skip={skip}",
|
|
561
|
+
"--format=%H%x00%P%x00%D%x00%s%x00%an%x00%aI",
|
|
562
|
+
]
|
|
563
|
+
if all_branches:
|
|
564
|
+
cmd.insert(len(_GIT_CMD) + 1, "--all")
|
|
565
|
+
proc = await asyncio.create_subprocess_exec(
|
|
566
|
+
*cmd, cwd=str(project_path),
|
|
567
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
568
|
+
)
|
|
569
|
+
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=30)
|
|
570
|
+
if proc.returncode != 0:
|
|
571
|
+
return []
|
|
572
|
+
commits = []
|
|
573
|
+
for line in stdout.decode().strip().splitlines():
|
|
574
|
+
if not line:
|
|
575
|
+
continue
|
|
576
|
+
parts = line.split("\x00")
|
|
577
|
+
if len(parts) >= 6:
|
|
578
|
+
commits.append({
|
|
579
|
+
"hash": parts[0],
|
|
580
|
+
"hash_short": parts[0][:7],
|
|
581
|
+
"parents": parts[1].split() if parts[1] else [],
|
|
582
|
+
"refs": [r.strip() for r in parts[2].split(", ")] if parts[2] else [],
|
|
583
|
+
"message": parts[3],
|
|
584
|
+
"author": parts[4],
|
|
585
|
+
"date": parts[5],
|
|
586
|
+
})
|
|
587
|
+
return commits
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
async def _git_refs(project_path: Path) -> list[dict]:
|
|
591
|
+
"""Get all refs (branches + tags) with their target commits."""
|
|
592
|
+
proc = await asyncio.create_subprocess_exec(
|
|
593
|
+
*_GIT_CMD, "for-each-ref",
|
|
594
|
+
"--format=%(refname:short)%x00%(objectname:short)%x00%(objecttype)",
|
|
595
|
+
"refs/heads", "refs/tags",
|
|
596
|
+
cwd=str(project_path),
|
|
597
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
598
|
+
)
|
|
599
|
+
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10)
|
|
600
|
+
if proc.returncode != 0:
|
|
601
|
+
return []
|
|
602
|
+
refs = []
|
|
603
|
+
for line in stdout.decode().strip().splitlines():
|
|
604
|
+
if not line:
|
|
605
|
+
continue
|
|
606
|
+
parts = line.split("\x00")
|
|
607
|
+
if len(parts) >= 3:
|
|
608
|
+
refs.append({"name": parts[0], "hash_short": parts[1], "type": parts[2]})
|
|
609
|
+
return refs
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
async def _git_commit_detail(project_path: Path, commit_hash: str) -> dict | None:
|
|
613
|
+
"""Get detailed info for a single commit."""
|
|
614
|
+
proc = await asyncio.create_subprocess_exec(
|
|
615
|
+
*_GIT_CMD, "show", "--stat", "--format=%H%x00%B%x00%an%x00%ae%x00%aI",
|
|
616
|
+
commit_hash,
|
|
617
|
+
cwd=str(project_path),
|
|
618
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
619
|
+
)
|
|
620
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
|
|
621
|
+
if proc.returncode != 0:
|
|
622
|
+
return None
|
|
623
|
+
output = stdout.decode(errors="replace")
|
|
624
|
+
# Format output: first line is header fields separated by \x00, then a blank line,
|
|
625
|
+
# then the body (which may contain \x00 from the %B placeholder),
|
|
626
|
+
# then --stat output.
|
|
627
|
+
# We use a simpler approach: split on first \x00 occurrences for header fields.
|
|
628
|
+
first_null = output.find("\x00")
|
|
629
|
+
if first_null < 0:
|
|
630
|
+
return None
|
|
631
|
+
full_hash = output[:first_null]
|
|
632
|
+
rest = output[first_null + 1:]
|
|
633
|
+
# Find remaining fields: body\x00author\x00email\x00date
|
|
634
|
+
# Body can be multiline so we find from the end
|
|
635
|
+
parts = rest.split("\x00")
|
|
636
|
+
if len(parts) < 4:
|
|
637
|
+
return None
|
|
638
|
+
date_str = parts[-1].split("\n")[0].strip()
|
|
639
|
+
email = parts[-2].strip()
|
|
640
|
+
author = parts[-3].strip()
|
|
641
|
+
body = "\x00".join(parts[:-3]).strip()
|
|
642
|
+
# Extract stat lines (after body, typically after an empty line + stat output)
|
|
643
|
+
stat_lines = []
|
|
644
|
+
in_stats = False
|
|
645
|
+
for line in output.splitlines():
|
|
646
|
+
if line.strip().startswith("|") or (line.strip() and "changed" in line and ("insertion" in line or "deletion" in line)):
|
|
647
|
+
in_stats = True
|
|
648
|
+
if in_stats and line.strip():
|
|
649
|
+
stat_lines.append(line.strip())
|
|
650
|
+
return {
|
|
651
|
+
"hash": full_hash,
|
|
652
|
+
"body": body,
|
|
653
|
+
"author": author,
|
|
654
|
+
"email": email,
|
|
655
|
+
"date": date_str,
|
|
656
|
+
"stats": stat_lines,
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
# --- Endpoints ---
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
async def _resolve_git_repo(
|
|
664
|
+
slug: str, user: UserInfo, db: aiosqlite.Connection
|
|
665
|
+
) -> Path:
|
|
666
|
+
"""Adapter helper for the ``git/*`` endpoints: resolve slug -> repo Path.
|
|
667
|
+
|
|
668
|
+
Resolves visibility at the boundary (DECISION 1) and delegates to
|
|
669
|
+
``uc.resolve_git_repo`` which enforces it (404), then maps the domain error to
|
|
670
|
+
HTTP: missing project -> 404, present-but-non-git -> 400 (via
|
|
671
|
+
``NoGitRepoError.http_status``). Same 404-vs-400 split as the pre-refactor
|
|
672
|
+
router, just routed through ``to_http``.
|
|
673
|
+
"""
|
|
674
|
+
visible_projects = await get_visible_projects(db, user)
|
|
675
|
+
ctx = CallerContext.from_user_info(user, is_human_session=False)
|
|
676
|
+
try:
|
|
677
|
+
return await uc.resolve_git_repo(ctx, db, slug=slug, visible_projects=visible_projects)
|
|
678
|
+
except ServiceError as e:
|
|
679
|
+
raise to_http(e)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
@router.get("")
|
|
683
|
+
async def list_programs(
|
|
684
|
+
user: UserInfo = Depends(get_current_user_or_agent),
|
|
685
|
+
db: aiosqlite.Connection = Depends(get_db),
|
|
686
|
+
) -> list[ProgramInfo]:
|
|
687
|
+
"""List all programs with their projects and task counts."""
|
|
688
|
+
# DECISION 1 (visibility template): resolve at the boundary (needs
|
|
689
|
+
# UserInfo.teams/user_id, not carried by CallerContext) and pass it in; the
|
|
690
|
+
# use_case applies it as an inline filter (aggregate listing, no raise).
|
|
691
|
+
visible_projects = await get_visible_projects(db, user)
|
|
692
|
+
ctx = CallerContext.from_user_info(user, is_human_session=False)
|
|
693
|
+
try:
|
|
694
|
+
return await uc.list_programs(ctx, db, visible_projects=visible_projects)
|
|
695
|
+
except ServiceError as e:
|
|
696
|
+
raise to_http(e)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
# Default data dir for new work projects
|
|
700
|
+
_DATA_PROJECT_DIR = Path("/data/projects")
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _create_project_on_disk(
|
|
704
|
+
*,
|
|
705
|
+
slug: str,
|
|
706
|
+
display_name: str,
|
|
707
|
+
program: str | None,
|
|
708
|
+
scope: str | None,
|
|
709
|
+
description: str | None,
|
|
710
|
+
lifecycle: str,
|
|
711
|
+
language: str | None,
|
|
712
|
+
type: str,
|
|
713
|
+
) -> str:
|
|
714
|
+
"""Create the project directory tree on disk and return its path.
|
|
715
|
+
|
|
716
|
+
Filesystem side-effect kept in the router (out of the pure use_case): builds
|
|
717
|
+
``project.yaml`` + ``context.md`` + ``.task`` + subdirs under
|
|
718
|
+
``/data/projects/{slug}/``, invalidates the project index, and returns the
|
|
719
|
+
metadata path. Raises a :class:`ServiceError` (``http_status = 500``) on
|
|
720
|
+
``OSError`` with cleanup — parity with the original ``HTTPException(500)``.
|
|
721
|
+
Callers (the use_case) must have already validated non-existence + RBAC.
|
|
722
|
+
"""
|
|
723
|
+
project_dir = _DATA_PROJECT_DIR / slug
|
|
724
|
+
today = date.today().isoformat()
|
|
725
|
+
|
|
726
|
+
yaml_data = {
|
|
727
|
+
"project": slug,
|
|
728
|
+
"program": program,
|
|
729
|
+
"scope": scope or "work",
|
|
730
|
+
"description": description or "",
|
|
731
|
+
"lifecycle": lifecycle,
|
|
732
|
+
"phase": "",
|
|
733
|
+
"language": language or "none",
|
|
734
|
+
"stack": [],
|
|
735
|
+
"type": type,
|
|
736
|
+
"repo_path": None,
|
|
737
|
+
"last_session": 0,
|
|
738
|
+
"last_work": None,
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
context_md = (
|
|
742
|
+
f"# {display_name}\n"
|
|
743
|
+
f"\n"
|
|
744
|
+
f"> Metadati strutturati in `project.yaml`\n"
|
|
745
|
+
f"\n"
|
|
746
|
+
f"## Obiettivo\n"
|
|
747
|
+
f"\n"
|
|
748
|
+
f"{description or ''}\n"
|
|
749
|
+
f"\n"
|
|
750
|
+
f"## Status\n"
|
|
751
|
+
f"- Ultimo lavoro: {today}\n"
|
|
752
|
+
f"\n"
|
|
753
|
+
f"## Task Attivi\n"
|
|
754
|
+
f"> Tracciati su Task API: `GET /api/v1/tasks?project={slug}&status=pending`\n"
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
try:
|
|
758
|
+
project_dir.mkdir(parents=False, exist_ok=False)
|
|
759
|
+
for subdir in ["memory", "docs/brainstorms", "docs/plans", "docs/solutions",
|
|
760
|
+
"input", "output", "scripts"]:
|
|
761
|
+
(project_dir / subdir).mkdir(parents=True, exist_ok=True)
|
|
762
|
+
for keep_dir in ["input", "output", "scripts"]:
|
|
763
|
+
(project_dir / keep_dir / ".gitkeep").touch()
|
|
764
|
+
(project_dir / ".task").write_text(slug + "\n")
|
|
765
|
+
(project_dir / "project.yaml").write_text(
|
|
766
|
+
yaml.dump(yaml_data, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
767
|
+
)
|
|
768
|
+
(project_dir / "context.md").write_text(context_md)
|
|
769
|
+
except OSError as exc:
|
|
770
|
+
if project_dir.exists():
|
|
771
|
+
shutil.rmtree(project_dir, ignore_errors=True)
|
|
772
|
+
raise ServiceError(
|
|
773
|
+
code="project_create_failed",
|
|
774
|
+
message=f"Failed to create project directory: {exc}",
|
|
775
|
+
) from exc
|
|
776
|
+
|
|
777
|
+
# Invalidate project index so the new project is immediately visible
|
|
778
|
+
global _index_built_at
|
|
779
|
+
_index_built_at = 0
|
|
780
|
+
|
|
781
|
+
return str(project_dir)
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
@router.post("", status_code=201)
|
|
785
|
+
async def create_project(
|
|
786
|
+
body: ProjectCreateRequest,
|
|
787
|
+
user: UserInfo = Depends(require_role("operator")),
|
|
788
|
+
db: aiosqlite.Connection = Depends(get_db),
|
|
789
|
+
) -> ProjectCreateResponse:
|
|
790
|
+
"""Create a new work project on the server.
|
|
791
|
+
|
|
792
|
+
Creates the directory structure, project.yaml, context.md, and .task file
|
|
793
|
+
under /data/projects/{slug}/.
|
|
794
|
+
"""
|
|
795
|
+
ctx = CallerContext.from_user_info(user, is_human_session=False)
|
|
796
|
+
try:
|
|
797
|
+
return await uc.create_project(
|
|
798
|
+
ctx,
|
|
799
|
+
db,
|
|
800
|
+
slug=body.slug,
|
|
801
|
+
name=body.name,
|
|
802
|
+
program=body.program,
|
|
803
|
+
scope=body.scope,
|
|
804
|
+
description=body.description,
|
|
805
|
+
lifecycle=body.lifecycle,
|
|
806
|
+
language=body.language,
|
|
807
|
+
type=body.type,
|
|
808
|
+
)
|
|
809
|
+
except ServiceError as e:
|
|
810
|
+
raise to_http(e)
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
@router.get("/{slug}")
|
|
814
|
+
async def get_project(
|
|
815
|
+
slug: str = PathParam(..., pattern=r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$"),
|
|
816
|
+
deep_param: bool | None = Query(None, alias="deep"),
|
|
817
|
+
user: UserInfo = Depends(get_current_user_or_agent),
|
|
818
|
+
db: aiosqlite.Connection = Depends(get_db),
|
|
819
|
+
) -> ProjectDetail:
|
|
820
|
+
"""Get project detail with context.md, config, handoffs, plans, solutions."""
|
|
821
|
+
visible_projects = await get_visible_projects(db, user)
|
|
822
|
+
ctx = CallerContext.from_user_info(user, is_human_session=False)
|
|
823
|
+
try:
|
|
824
|
+
detail = await uc.get_project(ctx, db, slug=slug, visible_projects=visible_projects)
|
|
825
|
+
except ServiceError as e:
|
|
826
|
+
raise to_http(e)
|
|
827
|
+
|
|
828
|
+
# DECISION 2: deep KG enrichment is a per-surface (transport) concern — the
|
|
829
|
+
# rate-limit + access-log + lens stay in the adapter, attached after the
|
|
830
|
+
# core ProjectDetail is built. Behavior identical to the pre-refactor router.
|
|
831
|
+
deep = deep_param if deep_param is not None else settings.kg_http_deep_default
|
|
832
|
+
deep_source = "client" if deep_param is not None else "env"
|
|
833
|
+
if deep:
|
|
834
|
+
check_deep_rate_limit(user.username)
|
|
835
|
+
log_kg_deep_access(user.username, "get_project", slug)
|
|
836
|
+
kg_ctx = await build_kg_context_for_project(db, slug, deep=True)
|
|
837
|
+
if kg_ctx is not None:
|
|
838
|
+
kg_ctx.setdefault("meta", {})
|
|
839
|
+
kg_ctx["meta"]["deep_effective"] = deep
|
|
840
|
+
kg_ctx["meta"]["deep_default_source"] = deep_source
|
|
841
|
+
detail.kg_context = kg_ctx
|
|
842
|
+
|
|
843
|
+
return detail
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
@router.get("/{slug}/brief")
|
|
847
|
+
async def get_session_brief(
|
|
848
|
+
slug: str = PathParam(..., pattern=r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$"),
|
|
849
|
+
deep: bool = Query(
|
|
850
|
+
False,
|
|
851
|
+
description="False (default): 5 items per KG bucket. True: 10-15 items — heavier, for cold-start or deep debug.",
|
|
852
|
+
),
|
|
853
|
+
user: UserInfo = Depends(get_current_user_or_agent),
|
|
854
|
+
db: aiosqlite.Connection = Depends(get_db),
|
|
855
|
+
) -> dict:
|
|
856
|
+
"""Pre-assembled context bundle for agent cold-start.
|
|
857
|
+
|
|
858
|
+
Collapses 5+ sequential MCP calls into a single round-trip. Phase 6.5 B
|
|
859
|
+
adds a ``kg_context`` section (hotspots, recent active nodes, cross-project
|
|
860
|
+
mentions, applicable learnings) gated by ``deep`` for effort budget.
|
|
861
|
+
|
|
862
|
+
Target latency: <500ms standard, <1000ms with ``deep=true``.
|
|
863
|
+
"""
|
|
864
|
+
visible_projects = await get_visible_projects(db, user)
|
|
865
|
+
ctx = CallerContext.from_user_info(user, is_human_session=False)
|
|
866
|
+
try:
|
|
867
|
+
return await uc.get_session_brief(
|
|
868
|
+
ctx, db, slug=slug, deep=deep, visible_projects=visible_projects
|
|
869
|
+
)
|
|
870
|
+
except ServiceError as e:
|
|
871
|
+
raise to_http(e)
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
@router.get("/{slug}/handoffs")
|
|
875
|
+
async def get_handoffs(
|
|
876
|
+
slug: str = PathParam(..., pattern=r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$"),
|
|
877
|
+
user: UserInfo = Depends(get_current_user_or_agent),
|
|
878
|
+
db: aiosqlite.Connection = Depends(get_db),
|
|
879
|
+
) -> list[HandoffEntry]:
|
|
880
|
+
"""List handoff files for a project."""
|
|
881
|
+
visible_projects = await get_visible_projects(db, user)
|
|
882
|
+
ctx = CallerContext.from_user_info(user, is_human_session=False)
|
|
883
|
+
try:
|
|
884
|
+
return await uc.get_handoffs(ctx, db, slug=slug, visible_projects=visible_projects)
|
|
885
|
+
except ServiceError as e:
|
|
886
|
+
raise to_http(e)
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
@router.get("/{slug}/status-updates")
|
|
890
|
+
async def list_project_status_updates_feed(
|
|
891
|
+
slug: str = PathParam(..., pattern=r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$"),
|
|
892
|
+
limit: int = Query(20, ge=1, le=50),
|
|
893
|
+
user: UserInfo = Depends(get_current_user_or_agent),
|
|
894
|
+
db: aiosqlite.Connection = Depends(get_db),
|
|
895
|
+
) -> StatusUpdateFeedResponse:
|
|
896
|
+
"""Feed-style status updates for /projects/detail single-pager v2.
|
|
897
|
+
|
|
898
|
+
Merges persisted rows from `project_status_updates` with on-the-fly
|
|
899
|
+
derived entries from recent handoffs (memory/handoff-*.md) and git
|
|
900
|
+
commits (repo_path). Read access is gated by project visibility.
|
|
901
|
+
"""
|
|
902
|
+
visible_projects = await get_visible_projects(db, user)
|
|
903
|
+
ctx = CallerContext.from_user_info(user, is_human_session=False)
|
|
904
|
+
try:
|
|
905
|
+
return await uc.list_status_updates_feed(
|
|
906
|
+
ctx, db, slug=slug, limit=limit, visible_projects=visible_projects
|
|
907
|
+
)
|
|
908
|
+
except ServiceError as e:
|
|
909
|
+
raise to_http(e)
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
@router.post("/{slug}/status-updates", status_code=201)
|
|
913
|
+
async def create_project_status_update_feed(
|
|
914
|
+
body: StatusUpdateFeedCreateRequest,
|
|
915
|
+
slug: str = PathParam(..., pattern=r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$"),
|
|
916
|
+
user: UserInfo = Depends(require_role("operator")),
|
|
917
|
+
db: aiosqlite.Connection = Depends(get_write_db),
|
|
918
|
+
) -> StatusUpdateFeedItem:
|
|
919
|
+
"""Create a manual feed entry for a project (operator+)."""
|
|
920
|
+
visible_projects = await get_visible_projects(db, user)
|
|
921
|
+
author_display = getattr(user, "display_name", None) or user.username
|
|
922
|
+
ctx = CallerContext.from_user_info(user, is_human_session=False)
|
|
923
|
+
try:
|
|
924
|
+
return await uc.create_status_update_feed(
|
|
925
|
+
ctx,
|
|
926
|
+
db,
|
|
927
|
+
slug=slug,
|
|
928
|
+
content_md=body.content_md,
|
|
929
|
+
author_display=author_display,
|
|
930
|
+
visible_projects=visible_projects,
|
|
931
|
+
)
|
|
932
|
+
except ServiceError as e:
|
|
933
|
+
raise to_http(e)
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
@router.get("/{slug}/plans")
|
|
937
|
+
async def get_plans(
|
|
938
|
+
slug: str = PathParam(..., pattern=r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$"),
|
|
939
|
+
user: UserInfo = Depends(get_current_user_or_agent),
|
|
940
|
+
db: aiosqlite.Connection = Depends(get_db),
|
|
941
|
+
) -> list[DocEntry]:
|
|
942
|
+
"""List all docs for a project (iterates all subdirs of docs/ dynamically)."""
|
|
943
|
+
visible_projects = await get_visible_projects(db, user)
|
|
944
|
+
ctx = CallerContext.from_user_info(user, is_human_session=False)
|
|
945
|
+
try:
|
|
946
|
+
return await uc.get_plans(ctx, db, slug=slug, visible_projects=visible_projects)
|
|
947
|
+
except ServiceError as e:
|
|
948
|
+
raise to_http(e)
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
@router.get("/{slug}/git/log")
|
|
952
|
+
async def get_git_log(
|
|
953
|
+
slug: str = PathParam(..., pattern=r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$"),
|
|
954
|
+
limit: int = Query(20, ge=1, le=100),
|
|
955
|
+
user: UserInfo = Depends(get_current_user_or_agent),
|
|
956
|
+
db: aiosqlite.Connection = Depends(get_db),
|
|
957
|
+
) -> list[dict]:
|
|
958
|
+
"""Get git log for a project."""
|
|
959
|
+
repo = await _resolve_git_repo(slug, user, db)
|
|
960
|
+
return await _git_log(repo, limit)
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
@router.get("/{slug}/git/diff")
|
|
964
|
+
async def get_git_diff(
|
|
965
|
+
slug: str = PathParam(..., pattern=r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$"),
|
|
966
|
+
user: UserInfo = Depends(get_current_user_or_agent),
|
|
967
|
+
db: aiosqlite.Connection = Depends(get_db),
|
|
968
|
+
) -> dict:
|
|
969
|
+
"""Get git diff for a project."""
|
|
970
|
+
repo = await _resolve_git_repo(slug, user, db)
|
|
971
|
+
return {"diff": await _git_diff(repo)}
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
@router.get("/{slug}/git/branches")
|
|
975
|
+
async def get_git_branches(
|
|
976
|
+
slug: str = PathParam(..., pattern=r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$"),
|
|
977
|
+
user: UserInfo = Depends(get_current_user_or_agent),
|
|
978
|
+
db: aiosqlite.Connection = Depends(get_db),
|
|
979
|
+
) -> list[dict]:
|
|
980
|
+
"""Get git branches for a project."""
|
|
981
|
+
repo = await _resolve_git_repo(slug, user, db)
|
|
982
|
+
return await _git_branches(repo)
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
@router.post("/{slug}/git/push")
|
|
986
|
+
async def git_push(
|
|
987
|
+
slug: str = PathParam(..., pattern=r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$"),
|
|
988
|
+
user: UserInfo = Depends(get_current_user),
|
|
989
|
+
db: aiosqlite.Connection = Depends(get_write_db),
|
|
990
|
+
) -> dict:
|
|
991
|
+
"""Git push (user only, requires confirmation)."""
|
|
992
|
+
repo = await _resolve_git_repo(slug, user, db)
|
|
993
|
+
await _validate_git_remote(repo)
|
|
994
|
+
proc = await asyncio.create_subprocess_exec(
|
|
995
|
+
*_GIT_CMD, "push",
|
|
996
|
+
cwd=str(repo),
|
|
997
|
+
stdout=asyncio.subprocess.PIPE,
|
|
998
|
+
stderr=asyncio.subprocess.PIPE,
|
|
999
|
+
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
|
|
1000
|
+
)
|
|
1001
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=60)
|
|
1002
|
+
if proc.returncode != 0:
|
|
1003
|
+
error_msg = re.sub(r"https?://[^@]*@", "https://***@", stderr.decode().strip())
|
|
1004
|
+
return {"success": False, "error": error_msg}
|
|
1005
|
+
return {"success": True, "output": stdout.decode().strip()}
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
@router.post("/{slug}/git/pull")
|
|
1009
|
+
async def git_pull(
|
|
1010
|
+
slug: str = PathParam(..., pattern=r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$"),
|
|
1011
|
+
user: UserInfo = Depends(get_current_user),
|
|
1012
|
+
db: aiosqlite.Connection = Depends(get_write_db),
|
|
1013
|
+
) -> dict:
|
|
1014
|
+
"""Git pull (user only)."""
|
|
1015
|
+
repo = await _resolve_git_repo(slug, user, db)
|
|
1016
|
+
await _validate_git_remote(repo)
|
|
1017
|
+
proc = await asyncio.create_subprocess_exec(
|
|
1018
|
+
*_GIT_CMD, "pull",
|
|
1019
|
+
cwd=str(repo),
|
|
1020
|
+
stdout=asyncio.subprocess.PIPE,
|
|
1021
|
+
stderr=asyncio.subprocess.PIPE,
|
|
1022
|
+
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
|
|
1023
|
+
)
|
|
1024
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=60)
|
|
1025
|
+
if proc.returncode != 0:
|
|
1026
|
+
error_msg = re.sub(r"https?://[^@]*@", "https://***@", stderr.decode().strip())
|
|
1027
|
+
return {"success": False, "error": error_msg}
|
|
1028
|
+
return {"success": True, "output": stdout.decode().strip()}
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
@router.get("/{slug}/git/graph")
|
|
1032
|
+
async def get_git_graph(
|
|
1033
|
+
slug: str = PathParam(..., pattern=r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$"),
|
|
1034
|
+
limit: int = Query(50, ge=1, le=200),
|
|
1035
|
+
skip: int = Query(0, ge=0),
|
|
1036
|
+
all_branches: bool = Query(True),
|
|
1037
|
+
user: UserInfo = Depends(get_current_user_or_agent),
|
|
1038
|
+
db: aiosqlite.Connection = Depends(get_db),
|
|
1039
|
+
) -> dict:
|
|
1040
|
+
"""Get git graph data: commits with parent hashes + refs for visualization."""
|
|
1041
|
+
repo = await _resolve_git_repo(slug, user, db)
|
|
1042
|
+
commits, refs = await asyncio.gather(
|
|
1043
|
+
_git_graph_log(repo, limit, skip, all_branches),
|
|
1044
|
+
_git_refs(repo),
|
|
1045
|
+
)
|
|
1046
|
+
return {"commits": commits, "refs": refs, "has_more": len(commits) == limit}
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
@router.get("/{slug}/git/commit/{commit_hash}")
|
|
1050
|
+
async def get_commit_detail(
|
|
1051
|
+
slug: str = PathParam(..., pattern=r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$"),
|
|
1052
|
+
commit_hash: str = PathParam(..., pattern=r"^[a-f0-9]{7,40}$"),
|
|
1053
|
+
user: UserInfo = Depends(get_current_user_or_agent),
|
|
1054
|
+
db: aiosqlite.Connection = Depends(get_db),
|
|
1055
|
+
) -> dict:
|
|
1056
|
+
"""Get detailed info for a single commit (full message, files changed)."""
|
|
1057
|
+
repo = await _resolve_git_repo(slug, user, db)
|
|
1058
|
+
result = await _git_commit_detail(repo, commit_hash)
|
|
1059
|
+
if result is None:
|
|
1060
|
+
raise HTTPException(404, "Commit not found")
|
|
1061
|
+
return result
|