alysis-code 0.13.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.
- alysis_code/__init__.py +3 -0
- alysis_code/__main__.py +4 -0
- alysis_code/_build_info.py +14 -0
- alysis_code/account_login.py +468 -0
- alysis_code/agent/README.md +35 -0
- alysis_code/agent/__init__.py +11 -0
- alysis_code/agent/acceptance_contract.py +2217 -0
- alysis_code/agent/blast_radius.py +1403 -0
- alysis_code/agent/cache_keepalive.py +227 -0
- alysis_code/agent/completion_certificate.py +366 -0
- alysis_code/agent/completion_gate.py +306 -0
- alysis_code/agent/empty_response_stall.py +403 -0
- alysis_code/agent/errors.py +28 -0
- alysis_code/agent/llm_calls.py +475 -0
- alysis_code/agent/mutation_classification.py +227 -0
- alysis_code/agent/prompt_context.py +2508 -0
- alysis_code/agent/read_ledger.py +253 -0
- alysis_code/agent/regression_baseline.py +642 -0
- alysis_code/agent/reproduction_first.py +610 -0
- alysis_code/agent/sensitive_output.py +629 -0
- alysis_code/agent/session.py +3218 -0
- alysis_code/agent/steering.py +191 -0
- alysis_code/agent/subagent_execution.py +5177 -0
- alysis_code/agent/subagent_workspace.py +666 -0
- alysis_code/agent/tools_assembly.py +4728 -0
- alysis_code/agent/turn/__init__.py +101 -0
- alysis_code/agent/turn/core.py +8483 -0
- alysis_code/agent/turn/events.py +113 -0
- alysis_code/agent/turn/exploration.py +590 -0
- alysis_code/agent/turn/interventions.py +65 -0
- alysis_code/agent/turn/read_cache.py +420 -0
- alysis_code/agent/turn/snapshot.py +179 -0
- alysis_code/agent/turn_contract.py +661 -0
- alysis_code/agent/turn_path.py +129 -0
- alysis_code/agent/verification.py +2885 -0
- alysis_code/agent/verification_commands.py +512 -0
- alysis_code/agent/verification_evidence.py +738 -0
- alysis_code/agent_loop.py +693 -0
- alysis_code/agent_runtimes/__init__.py +51 -0
- alysis_code/agent_runtimes/base.py +114 -0
- alysis_code/agent_runtimes/builtins.py +129 -0
- alysis_code/agent_runtimes/codex_cli.py +664 -0
- alysis_code/agent_runtimes/host.py +263 -0
- alysis_code/agent_runtimes/registry.py +64 -0
- alysis_code/agent_runtimes/service.py +150 -0
- alysis_code/agentbox_client.py +416 -0
- alysis_code/agentbox_integration.py +310 -0
- alysis_code/alysis_cloud.py +152 -0
- alysis_code/approval_scope.py +276 -0
- alysis_code/assets/README.md +33 -0
- alysis_code/assets/__init__.py +126 -0
- alysis_code/assets/asset_read_core.py +281 -0
- alysis_code/assets/budget_allocator.py +456 -0
- alysis_code/assets/comprehender.py +759 -0
- alysis_code/assets/index.py +654 -0
- alysis_code/assets/ingestion.py +275 -0
- alysis_code/assets/legacy_migration.py +413 -0
- alysis_code/assets/models.py +263 -0
- alysis_code/assets/ocr.py +239 -0
- alysis_code/assets/owl/ascii/f-000.txt +13 -0
- alysis_code/assets/owl/ascii/f-001.txt +13 -0
- alysis_code/assets/owl/ascii/f-002.txt +13 -0
- alysis_code/assets/owl/ascii/f-003.txt +13 -0
- alysis_code/assets/owl/ascii/f-004.txt +13 -0
- alysis_code/assets/owl/ascii/f-005.txt +13 -0
- alysis_code/assets/owl/ascii/f-006.txt +13 -0
- alysis_code/assets/owl/ascii/f-007.txt +13 -0
- alysis_code/assets/owl/ascii/f-008.txt +13 -0
- alysis_code/assets/owl/ascii/f-009.txt +13 -0
- alysis_code/assets/owl/ascii/f-010.txt +13 -0
- alysis_code/assets/owl/ascii/f-011.txt +13 -0
- alysis_code/assets/owl/ascii/f-012.txt +13 -0
- alysis_code/assets/owl/ascii/f-013.txt +13 -0
- alysis_code/assets/owl/ascii/f-014.txt +13 -0
- alysis_code/assets/owl/ascii/f-015.txt +13 -0
- alysis_code/assets/owl/ascii/f-016.txt +13 -0
- alysis_code/assets/owl/ascii/f-017.txt +13 -0
- alysis_code/assets/owl/ascii/f-018.txt +13 -0
- alysis_code/assets/owl/ascii/f-019.txt +13 -0
- alysis_code/assets/owl/ascii/f-020.txt +13 -0
- alysis_code/assets/owl/index.html +98 -0
- alysis_code/assets/owl/show-owl.sh +761 -0
- alysis_code/assets/paths.py +49 -0
- alysis_code/assets/plan_binding.py +326 -0
- alysis_code/assets/planner_context.py +466 -0
- alysis_code/assets/planner_tools.py +184 -0
- alysis_code/assets/prompts.py +101 -0
- alysis_code/assets/replanner_context.py +239 -0
- alysis_code/assets/surface.py +521 -0
- alysis_code/assets/untrusted_content.py +48 -0
- alysis_code/assets/usage_logger.py +94 -0
- alysis_code/assets/worker_mirror.py +428 -0
- alysis_code/assets/worker_section.py +303 -0
- alysis_code/assets/worker_tools.py +468 -0
- alysis_code/atomic_io.py +83 -0
- alysis_code/auth_diagnostics.py +272 -0
- alysis_code/background_runner.py +366 -0
- alysis_code/branding.py +270 -0
- alysis_code/budget_policy.py +390 -0
- alysis_code/build_identity.py +465 -0
- alysis_code/builtin_hooks/__init__.py +7 -0
- alysis_code/builtin_hooks/notify_done_windows.py +65 -0
- alysis_code/bwrap_etc.py +76 -0
- alysis_code/cancellation.py +41 -0
- alysis_code/capabilities.py +137 -0
- alysis_code/chatgpt_codex_static_provider.py +133 -0
- alysis_code/cli.py +51 -0
- alysis_code/cli_impl/__init__.py +1 -0
- alysis_code/cli_impl/assets_cli.py +537 -0
- alysis_code/cli_impl/assets_modal.py +412 -0
- alysis_code/cli_impl/chat/__init__.py +156 -0
- alysis_code/cli_impl/chat/commands.py +2616 -0
- alysis_code/cli_impl/chat/loop.py +4508 -0
- alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
- alysis_code/cli_impl/chat/rendering.py +444 -0
- alysis_code/cli_impl/chat/state.py +124 -0
- alysis_code/cli_impl/chat_resume.py +830 -0
- alysis_code/cli_impl/chat_slash_completer.py +258 -0
- alysis_code/cli_impl/commands/__init__.py +11 -0
- alysis_code/cli_impl/commands/_shared.py +89 -0
- alysis_code/cli_impl/commands/auth.py +623 -0
- alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
- alysis_code/cli_impl/commands/chat_state.py +158 -0
- alysis_code/cli_impl/commands/chat_status.py +1248 -0
- alysis_code/cli_impl/commands/chat_terminal.py +942 -0
- alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
- alysis_code/cli_impl/commands/cli_common.py +1223 -0
- alysis_code/cli_impl/commands/cli_surface.py +77 -0
- alysis_code/cli_impl/commands/config.py +131 -0
- alysis_code/cli_impl/commands/conventions.py +85 -0
- alysis_code/cli_impl/commands/execution_helpers.py +350 -0
- alysis_code/cli_impl/commands/extensions.py +401 -0
- alysis_code/cli_impl/commands/forge.py +1282 -0
- alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
- alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
- alysis_code/cli_impl/commands/hooks.py +737 -0
- alysis_code/cli_impl/commands/ide_bridge.py +31 -0
- alysis_code/cli_impl/commands/mcp.py +700 -0
- alysis_code/cli_impl/commands/profile.py +453 -0
- alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
- alysis_code/cli_impl/commands/report.py +88 -0
- alysis_code/cli_impl/commands/root.py +1118 -0
- alysis_code/cli_impl/commands/sandbox.py +184 -0
- alysis_code/cli_impl/commands/server.py +54 -0
- alysis_code/cli_impl/commands/sessions.py +252 -0
- alysis_code/cli_impl/commands/skills.py +404 -0
- alysis_code/cli_impl/commands/startup.py +946 -0
- alysis_code/cli_impl/commands/tools.py +335 -0
- alysis_code/cli_impl/commands/update.py +364 -0
- alysis_code/cli_impl/commands/welcome.py +972 -0
- alysis_code/cli_impl/config_menu.py +3882 -0
- alysis_code/cli_impl/forge.py +4509 -0
- alysis_code/cli_impl/forge_recovery.py +485 -0
- alysis_code/cli_impl/setup_wizard.py +2409 -0
- alysis_code/cli_impl/tui/__init__.py +58 -0
- alysis_code/cli_impl/tui/app.py +4551 -0
- alysis_code/cli_impl/tui/config.py +32 -0
- alysis_code/cli_impl/tui/config_flow.py +2754 -0
- alysis_code/cli_impl/tui/config_overlay.py +566 -0
- alysis_code/cli_impl/tui/content.py +78 -0
- alysis_code/cli_impl/tui/footer.py +218 -0
- alysis_code/cli_impl/tui/forge_status.py +136 -0
- alysis_code/cli_impl/tui/markdown.py +244 -0
- alysis_code/cli_impl/tui/owl.py +109 -0
- alysis_code/cli_impl/tui/plan_meta.py +477 -0
- alysis_code/cli_impl/tui/setup_app.py +519 -0
- alysis_code/cli_impl/tui/setup_flow.py +1622 -0
- alysis_code/cli_impl/tui/state.py +101 -0
- alysis_code/cli_impl/tui/subagent_identity.py +66 -0
- alysis_code/cli_impl/tui/subagent_panel.py +186 -0
- alysis_code/cli_impl/tui/surface.py +796 -0
- alysis_code/cli_impl/tui/transcript.py +514 -0
- alysis_code/cli_impl/tui/update_prompt.py +79 -0
- alysis_code/cli_impl/tui/workspace_guard.py +384 -0
- alysis_code/clipboard.py +172 -0
- alysis_code/code_review.py +1211 -0
- alysis_code/compaction/__init__.py +28 -0
- alysis_code/compaction/conversation_compactor.py +2932 -0
- alysis_code/compaction/importance.py +177 -0
- alysis_code/compaction/settings.py +297 -0
- alysis_code/compaction/tool_output_offload.py +447 -0
- alysis_code/config.py +3509 -0
- alysis_code/conflict_auto_resolver.py +895 -0
- alysis_code/context/__init__.py +1 -0
- alysis_code/context/tool_schema_budgeter.py +220 -0
- alysis_code/crash_diagnostics.py +282 -0
- alysis_code/custom_tools/README.md +34 -0
- alysis_code/custom_tools/__init__.py +43 -0
- alysis_code/custom_tools/discovery.py +903 -0
- alysis_code/custom_tools/runtime.py +1516 -0
- alysis_code/custom_tools/session.py +227 -0
- alysis_code/custom_tools/trust.py +232 -0
- alysis_code/diff_paths.py +113 -0
- alysis_code/direction_change.py +293 -0
- alysis_code/dispatch_timing.py +306 -0
- alysis_code/durable_service_manager.py +1236 -0
- alysis_code/edit_discipline.py +659 -0
- alysis_code/error_text.py +73 -0
- alysis_code/execution_budget.py +411 -0
- alysis_code/execution_context.py +915 -0
- alysis_code/execution_deadline.py +1065 -0
- alysis_code/execution_shared.py +1904 -0
- alysis_code/extensions/README.md +30 -0
- alysis_code/extensions/__init__.py +93 -0
- alysis_code/extensions/activation.py +138 -0
- alysis_code/extensions/install.py +1436 -0
- alysis_code/extensions/manifest.py +487 -0
- alysis_code/extensions/models.py +74 -0
- alysis_code/extensions/paths.py +56 -0
- alysis_code/extensions/registry.json +4 -0
- alysis_code/extensions/registry.py +52 -0
- alysis_code/extensions/state.py +83 -0
- alysis_code/extensions/workspace_trust.py +101 -0
- alysis_code/failed_task_evidence.py +369 -0
- alysis_code/failure_category.py +315 -0
- alysis_code/feedback_report.py +1647 -0
- alysis_code/file_classification.py +485 -0
- alysis_code/forge.py +2064 -0
- alysis_code/forge_completion.py +362 -0
- alysis_code/forge_events.py +475 -0
- alysis_code/frontmatter_utils.py +95 -0
- alysis_code/git_evidence.py +1181 -0
- alysis_code/git_ops.py +560 -0
- alysis_code/git_safe.py +62 -0
- alysis_code/git_worktrees.py +190 -0
- alysis_code/hooks/README.md +33 -0
- alysis_code/hooks/__init__.py +67 -0
- alysis_code/hooks/audit.py +171 -0
- alysis_code/hooks/config.py +225 -0
- alysis_code/hooks/dispatcher.py +1110 -0
- alysis_code/hooks/models.py +447 -0
- alysis_code/hooks/trust.py +202 -0
- alysis_code/host_actions.py +543 -0
- alysis_code/host_browser.py +103 -0
- alysis_code/ide/__init__.py +5 -0
- alysis_code/ide/activity_events.py +399 -0
- alysis_code/ide/approvals.py +337 -0
- alysis_code/ide/artifacts.py +153 -0
- alysis_code/ide/browser_egress_proxy.py +1076 -0
- alysis_code/ide/cdp_websocket_transport.py +1192 -0
- alysis_code/ide/change_ledger.py +1721 -0
- alysis_code/ide/context_blocks.py +979 -0
- alysis_code/ide/event_stream.py +531 -0
- alysis_code/ide/forge_protocol.py +3112 -0
- alysis_code/ide/forge_request_ledger.py +737 -0
- alysis_code/ide/health.py +965 -0
- alysis_code/ide/managed_browser.py +2251 -0
- alysis_code/ide/management_protocol.py +3414 -0
- alysis_code/ide/mcp_oauth_coordinator.py +744 -0
- alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
- alysis_code/ide/prompt_queue.py +1070 -0
- alysis_code/ide/protocol.py +191 -0
- alysis_code/ide/resumable_swarm.py +1543 -0
- alysis_code/ide/session_search.py +295 -0
- alysis_code/ide/stdio_bridge.py +9935 -0
- alysis_code/ide/structured_state.py +1579 -0
- alysis_code/ide/swarm_protocol.py +816 -0
- alysis_code/integration_gate.py +506 -0
- alysis_code/interactive_input_guard.py +39 -0
- alysis_code/interactive_plan_mode.py +26 -0
- alysis_code/internal_artifacts.py +179 -0
- alysis_code/knowledge_base.py +1409 -0
- alysis_code/knowledge_capture.py +1190 -0
- alysis_code/knowledge_librarian.py +605 -0
- alysis_code/language_policy.py +34 -0
- alysis_code/litellm_static_provider.py +535 -0
- alysis_code/llm/__init__.py +1 -0
- alysis_code/llm/anthropic_messages.py +2288 -0
- alysis_code/llm/base.py +71 -0
- alysis_code/llm/cache_capabilities.py +985 -0
- alysis_code/llm/cache_control_blocks.py +244 -0
- alysis_code/llm/cache_policy.py +388 -0
- alysis_code/llm/factory.py +373 -0
- alysis_code/llm/gemini_generate_content.py +2652 -0
- alysis_code/llm/gemini_interactions.py +739 -0
- alysis_code/llm/metadata.py +450 -0
- alysis_code/llm/openai_compat.py +2947 -0
- alysis_code/llm/openai_responses.py +2604 -0
- alysis_code/llm/protocols.py +609 -0
- alysis_code/llm/provider_limits.py +525 -0
- alysis_code/llm/request_plan.py +389 -0
- alysis_code/llm/request_shape.py +238 -0
- alysis_code/llm/streaming.py +108 -0
- alysis_code/llm/temperature_compat.py +78 -0
- alysis_code/llm/types.py +195 -0
- alysis_code/llm/usage_normalization.py +222 -0
- alysis_code/llm_error_display.py +315 -0
- alysis_code/logging_redaction.py +326 -0
- alysis_code/managed_host_deadline.py +196 -0
- alysis_code/mcp/README.md +33 -0
- alysis_code/mcp/__init__.py +24 -0
- alysis_code/mcp/client.py +1137 -0
- alysis_code/mcp/config.py +597 -0
- alysis_code/mcp/errors.py +113 -0
- alysis_code/mcp/forge_scope.py +154 -0
- alysis_code/mcp/jsonrpc.py +214 -0
- alysis_code/mcp/manager.py +2308 -0
- alysis_code/mcp/models.py +666 -0
- alysis_code/mcp/oauth.py +972 -0
- alysis_code/mcp/oauth_runtime.py +310 -0
- alysis_code/mcp/oauth_store.py +276 -0
- alysis_code/mcp/prompts.py +329 -0
- alysis_code/mcp/resources.py +295 -0
- alysis_code/mcp/roots.py +106 -0
- alysis_code/mcp/server_requests.py +75 -0
- alysis_code/mcp/token_store.py +859 -0
- alysis_code/mcp/transport_http.py +1338 -0
- alysis_code/mcp/transport_stdio.py +1267 -0
- alysis_code/mcp/untrusted_content.py +119 -0
- alysis_code/merge_conflict_reviewer.py +729 -0
- alysis_code/model_catalog/__init__.py +1 -0
- alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
- alysis_code/model_metadata_policy.py +223 -0
- alysis_code/model_metadata_utils.py +103 -0
- alysis_code/model_registry.py +1420 -0
- alysis_code/model_router.py +147 -0
- alysis_code/permission_policy.py +1016 -0
- alysis_code/personas.py +451 -0
- alysis_code/pipeline_facts.py +233 -0
- alysis_code/plan_assistant.py +4763 -0
- alysis_code/plan_mode.py +393 -0
- alysis_code/plan_reconciliation.py +1228 -0
- alysis_code/plan_repair.py +652 -0
- alysis_code/plan_validation.py +1099 -0
- alysis_code/planning_constraints.py +904 -0
- alysis_code/policy.py +95 -0
- alysis_code/preview_server.py +457 -0
- alysis_code/process_reaping.py +566 -0
- alysis_code/profile_presets.py +1834 -0
- alysis_code/profiles.py +666 -0
- alysis_code/provider_auth/__init__.py +29 -0
- alysis_code/provider_auth/base.py +99 -0
- alysis_code/provider_auth/openai_codex.py +951 -0
- alysis_code/provider_auth/registry.py +76 -0
- alysis_code/provider_auth/store.py +125 -0
- alysis_code/provider_diagnostics.py +1209 -0
- alysis_code/provider_model_catalog.py +685 -0
- alysis_code/provider_telemetry.py +1699 -0
- alysis_code/provider_url.py +75 -0
- alysis_code/reasoning_contracts.py +911 -0
- alysis_code/remote_sync.py +350 -0
- alysis_code/replanning.py +1195 -0
- alysis_code/repo_scan.py +1152 -0
- alysis_code/request_estimation.py +296 -0
- alysis_code/review_gate.py +617 -0
- alysis_code/run_lock.py +1141 -0
- alysis_code/run_outcome.py +58 -0
- alysis_code/run_provenance.py +774 -0
- alysis_code/run_state.py +445 -0
- alysis_code/runtime_artifacts.py +116 -0
- alysis_code/runtime_context_features.py +78 -0
- alysis_code/runtime_kind.py +52 -0
- alysis_code/safety/__init__.py +11 -0
- alysis_code/safety/mcp_sanitize.py +29 -0
- alysis_code/safety/safe_http.py +297 -0
- alysis_code/safety/subagent_report.py +184 -0
- alysis_code/sandbox_doctor.py +682 -0
- alysis_code/sandbox_runner.py +1025 -0
- alysis_code/sandbox_settings.py +423 -0
- alysis_code/serialized_paths.py +355 -0
- alysis_code/server/__init__.py +3 -0
- alysis_code/server/app.py +367 -0
- alysis_code/server/auth.py +34 -0
- alysis_code/server/job_config.py +30 -0
- alysis_code/server/settings.py +215 -0
- alysis_code/server/store.py +193 -0
- alysis_code/server/worker_runner.py +657 -0
- alysis_code/service_persistence.py +355 -0
- alysis_code/session_artifacts.py +108 -0
- alysis_code/session_metrics.py +331 -0
- alysis_code/session_store.py +624 -0
- alysis_code/skills/README.md +34 -0
- alysis_code/skills/__init__.py +104 -0
- alysis_code/skills/conventions.py +84 -0
- alysis_code/skills/discovery.py +176 -0
- alysis_code/skills/eval_models.py +232 -0
- alysis_code/skills/eval_runner.py +372 -0
- alysis_code/skills/evals.py +1344 -0
- alysis_code/skills/install.py +293 -0
- alysis_code/skills/loader.py +118 -0
- alysis_code/skills/matching.py +103 -0
- alysis_code/skills/models.py +71 -0
- alysis_code/skills/paths.py +56 -0
- alysis_code/skills/prompting.py +500 -0
- alysis_code/skills/scaffold.py +142 -0
- alysis_code/skills/state.py +441 -0
- alysis_code/skills/transactions.py +125 -0
- alysis_code/skills/validation.py +304 -0
- alysis_code/step_budget.py +238 -0
- alysis_code/subagent_labels.py +49 -0
- alysis_code/subagents.py +1072 -0
- alysis_code/surface/__init__.py +80 -0
- alysis_code/surface/base.py +305 -0
- alysis_code/surface/console.py +387 -0
- alysis_code/surface/events.py +372 -0
- alysis_code/surface/hidden_surface.py +529 -0
- alysis_code/surface/noop_surface.py +219 -0
- alysis_code/surface/rich_surface.py +1555 -0
- alysis_code/surface/styles.py +67 -0
- alysis_code/surface/theme.py +455 -0
- alysis_code/surface/types.py +100 -0
- alysis_code/swarm_backend.py +926 -0
- alysis_code/swarm_orchestrator.py +4020 -0
- alysis_code/swarm_scheduler.py +441 -0
- alysis_code/swarm_trace.py +429 -0
- alysis_code/swarm_worker.py +2119 -0
- alysis_code/swarm_write_guard.py +348 -0
- alysis_code/task_dependencies.py +170 -0
- alysis_code/task_readiness.py +992 -0
- alysis_code/task_scope.py +2148 -0
- alysis_code/terminal_manager.py +762 -0
- alysis_code/terminal_ownership.py +460 -0
- alysis_code/text_normalization.py +30 -0
- alysis_code/token_budget.py +97 -0
- alysis_code/tools/README.md +34 -0
- alysis_code/tools/__init__.py +1 -0
- alysis_code/tools/artifacts.py +127 -0
- alysis_code/tools/availability.py +188 -0
- alysis_code/tools/fs.py +1456 -0
- alysis_code/tools/git.py +461 -0
- alysis_code/tools/history.py +229 -0
- alysis_code/tools/http_timeout.py +78 -0
- alysis_code/tools/image_generation.py +552 -0
- alysis_code/tools/registry.py +2936 -0
- alysis_code/tools/repo_map.py +476 -0
- alysis_code/tools/search.py +563 -0
- alysis_code/tools/shell.py +135 -0
- alysis_code/tools/symbols.py +1350 -0
- alysis_code/tools/test_discovery.py +643 -0
- alysis_code/tools/web.py +482 -0
- alysis_code/tools/web_search.py +2012 -0
- alysis_code/tools/web_search_dashscope.py +557 -0
- alysis_code/tools/web_search_ddgs.py +221 -0
- alysis_code/tools/web_search_provider_adapters.py +1429 -0
- alysis_code/tools/web_search_tavily.py +194 -0
- alysis_code/updates.py +933 -0
- alysis_code/usage_tracker.py +1990 -0
- alysis_code/verification_command_analysis.py +1004 -0
- alysis_code/verification_contract.py +574 -0
- alysis_code/verification_failure_summary.py +273 -0
- alysis_code/verification_repair.py +385 -0
- alysis_code/verify_gate.py +3129 -0
- alysis_code/web_research.py +1872 -0
- alysis_code/web_search_adapters.py +66 -0
- alysis_code/web_search_policy.py +27 -0
- alysis_code/workspace_binding.py +389 -0
- alysis_code/workspace_binding_ui.py +408 -0
- alysis_code/workspace_context.py +273 -0
- alysis_code/workspace_isolation.py +138 -0
- alysis_code/workspace_provisioning.py +455 -0
- alysis_code-0.13.0.dist-info/METADATA +507 -0
- alysis_code-0.13.0.dist-info/RECORD +458 -0
- alysis_code-0.13.0.dist-info/WHEEL +4 -0
- alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
- alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
- alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import stat
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .git_ops import (
|
|
11
|
+
GitOpsError,
|
|
12
|
+
clean_untracked,
|
|
13
|
+
ensure_runtime_artifact_excludes,
|
|
14
|
+
reset_hard,
|
|
15
|
+
status_porcelain,
|
|
16
|
+
)
|
|
17
|
+
from .git_safe import build_git_cmd
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _run_git_checked(
|
|
21
|
+
root: Path,
|
|
22
|
+
args: list[str],
|
|
23
|
+
*,
|
|
24
|
+
error_message: str,
|
|
25
|
+
extra_config: dict[str, str] | None = None,
|
|
26
|
+
) -> subprocess.CompletedProcess[str]:
|
|
27
|
+
cmd = build_git_cmd(root, args, extra_config=extra_config)
|
|
28
|
+
try:
|
|
29
|
+
cp = subprocess.run(
|
|
30
|
+
cmd,
|
|
31
|
+
check=False,
|
|
32
|
+
capture_output=True,
|
|
33
|
+
text=True,
|
|
34
|
+
)
|
|
35
|
+
except OSError as e:
|
|
36
|
+
raise GitOpsError("failed to run git") from e
|
|
37
|
+
if cp.returncode != 0:
|
|
38
|
+
detail = (cp.stderr or cp.stdout).strip()
|
|
39
|
+
raise GitOpsError(f"{error_message}: {detail or 'unknown error'}")
|
|
40
|
+
return cp
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _inspect_existing_git_workspace(
|
|
44
|
+
*,
|
|
45
|
+
worktree_path: Path,
|
|
46
|
+
expected_branch: str,
|
|
47
|
+
) -> tuple[bool, bool, str]:
|
|
48
|
+
try:
|
|
49
|
+
inside = subprocess.run(
|
|
50
|
+
build_git_cmd(worktree_path, ["rev-parse", "--is-inside-work-tree"]),
|
|
51
|
+
check=False,
|
|
52
|
+
capture_output=True,
|
|
53
|
+
text=True,
|
|
54
|
+
)
|
|
55
|
+
except OSError as e:
|
|
56
|
+
raise GitOpsError("failed to run git") from e
|
|
57
|
+
if inside.returncode != 0 or inside.stdout.strip().lower() != "true":
|
|
58
|
+
return False, False, "path exists but is not a valid git worktree"
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
branch_cp = subprocess.run(
|
|
62
|
+
build_git_cmd(worktree_path, ["rev-parse", "--abbrev-ref", "HEAD"]),
|
|
63
|
+
check=False,
|
|
64
|
+
capture_output=True,
|
|
65
|
+
text=True,
|
|
66
|
+
)
|
|
67
|
+
except OSError as e:
|
|
68
|
+
raise GitOpsError("failed to run git") from e
|
|
69
|
+
if branch_cp.returncode != 0:
|
|
70
|
+
return False, False, "failed to resolve worktree branch"
|
|
71
|
+
current_branch = branch_cp.stdout.strip()
|
|
72
|
+
if current_branch != expected_branch:
|
|
73
|
+
return (
|
|
74
|
+
False,
|
|
75
|
+
False,
|
|
76
|
+
f"worktree branch mismatch (expected {expected_branch}, got {current_branch or '(empty)'})",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
ensure_runtime_artifact_excludes(worktree_path)
|
|
80
|
+
status_lines = status_porcelain(worktree_path)
|
|
81
|
+
if status_lines:
|
|
82
|
+
preview = ", ".join(status_lines[:5])
|
|
83
|
+
if len(status_lines) > 5:
|
|
84
|
+
preview += ", ..."
|
|
85
|
+
return True, False, f"worktree has stale filesystem state ({preview})"
|
|
86
|
+
return True, True, ""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _sanitize_existing_git_workspace(*, worktree_path: Path) -> None:
|
|
90
|
+
ensure_runtime_artifact_excludes(worktree_path)
|
|
91
|
+
reset_hard(worktree_path)
|
|
92
|
+
clean_untracked(worktree_path)
|
|
93
|
+
status_lines = status_porcelain(worktree_path)
|
|
94
|
+
if status_lines:
|
|
95
|
+
preview = ", ".join(status_lines[:5])
|
|
96
|
+
if len(status_lines) > 5:
|
|
97
|
+
preview += ", ..."
|
|
98
|
+
raise GitOpsError(f"failed to sanitize worktree state ({preview})")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _reset_git_workspace_to_target(*, worktree_path: Path, target: str) -> None:
|
|
102
|
+
ensure_runtime_artifact_excludes(worktree_path)
|
|
103
|
+
reset_hard(worktree_path, target=target)
|
|
104
|
+
clean_untracked(worktree_path)
|
|
105
|
+
status_lines = status_porcelain(worktree_path)
|
|
106
|
+
if status_lines:
|
|
107
|
+
preview = ", ".join(status_lines[:5])
|
|
108
|
+
if len(status_lines) > 5:
|
|
109
|
+
preview += ", ..."
|
|
110
|
+
raise GitOpsError(f"failed to reset workspace to {target} ({preview})")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _cleanup_workspace_path(path: Path) -> None:
|
|
114
|
+
if not path.exists():
|
|
115
|
+
return
|
|
116
|
+
if path.is_dir():
|
|
117
|
+
shutil.rmtree(path, onerror=_retry_readonly_removal)
|
|
118
|
+
else:
|
|
119
|
+
try:
|
|
120
|
+
path.unlink()
|
|
121
|
+
except OSError:
|
|
122
|
+
pass
|
|
123
|
+
if path.exists():
|
|
124
|
+
raise GitOpsError(f"failed to cleanup workspace path {path}")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _retry_readonly_removal(
|
|
128
|
+
remove: Any,
|
|
129
|
+
path: str,
|
|
130
|
+
exc_info: tuple[type[BaseException], BaseException, Any],
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Retry a Windows removal after clearing Git's read-only file attribute."""
|
|
133
|
+
|
|
134
|
+
error = exc_info[1]
|
|
135
|
+
if not isinstance(error, PermissionError):
|
|
136
|
+
raise error
|
|
137
|
+
os.chmod(path, stat.S_IWRITE | stat.S_IREAD)
|
|
138
|
+
remove(path)
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""Pre-provisioning of a workspace's *declared* test runner.
|
|
2
|
+
|
|
3
|
+
Most autonomous sessions in a Python repo start the same way: run the tests,
|
|
4
|
+
watch them fail with ``No module named pytest``, install pytest, run them
|
|
5
|
+
again. The gap is knowable before the first command -- the repo declares its
|
|
6
|
+
runner in a config file -- so discovering it by failing a test run is pure
|
|
7
|
+
waste, repeated once per session.
|
|
8
|
+
|
|
9
|
+
This module closes exactly that gap and nothing wider:
|
|
10
|
+
|
|
11
|
+
* Detection is **config-file evidence only**. A repo declares pytest in
|
|
12
|
+
``pytest.ini``, ``pyproject.toml`` (``[tool.pytest.ini_options]``),
|
|
13
|
+
``tox.ini`` (``[pytest]``), or ``setup.cfg`` (``[tool:pytest]``). Source
|
|
14
|
+
trees are never scanned, and the presence of ``tests/`` or a ``test_*.py``
|
|
15
|
+
file is *not* evidence -- those say tests exist, not which runner the repo
|
|
16
|
+
declared.
|
|
17
|
+
* The only package that can ever be installed is the declared runner, and only
|
|
18
|
+
when it is not importable. An importable runner is never upgraded or
|
|
19
|
+
reinstalled, and no other package is ever touched.
|
|
20
|
+
* Both the "is it importable" probe and the install run **through the session's
|
|
21
|
+
own shell execution path**, never through the agent's interpreter. That is the
|
|
22
|
+
environment the agent's test command will actually resolve, so it is the only
|
|
23
|
+
environment whose answer means anything -- and routing through it also keeps
|
|
24
|
+
the operator's shell-sandbox policy (network, filesystem) binding on an action
|
|
25
|
+
the agent would otherwise have taken itself. A session with the shell disabled
|
|
26
|
+
provisions nothing.
|
|
27
|
+
* Installation happens only in a **top-level autonomous run**, is attempted
|
|
28
|
+
**once** per process, and is never retried after a failure. Interactive runs
|
|
29
|
+
and nested sessions (subagents and conflict resolvers) never
|
|
30
|
+
install: an interactive run reports the gap and leaves the call to the agent
|
|
31
|
+
or the user, and a nested run inherits whatever its parent already decided.
|
|
32
|
+
|
|
33
|
+
The decision is a pure function over facts, so the policy is testable without a
|
|
34
|
+
filesystem, a subprocess, or an LLM.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import shlex
|
|
40
|
+
import threading
|
|
41
|
+
from collections.abc import Callable
|
|
42
|
+
from dataclasses import dataclass
|
|
43
|
+
from enum import StrEnum
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
from typing import Any
|
|
46
|
+
|
|
47
|
+
from .branding import env_get
|
|
48
|
+
from .runtime_kind import RuntimeKind, normalize_runtime_kind
|
|
49
|
+
|
|
50
|
+
PYTEST_PACKAGE = "pytest"
|
|
51
|
+
PROVISION_TIMEOUT_SECONDS = 60.0
|
|
52
|
+
IMPORT_PROBE_TIMEOUT_SECONDS = 20.0
|
|
53
|
+
# Chosen to collide with nothing a shell or interpreter produces on its own:
|
|
54
|
+
# 1/2 are generic errors, 125/126/127 are container and exec failures.
|
|
55
|
+
PACKAGE_MISSING_EXIT_CODE = 3
|
|
56
|
+
_MAX_CONFIG_BYTES = 1_000_000
|
|
57
|
+
|
|
58
|
+
# Runtime kinds that are a top-level autonomous execution run. Mirrors the
|
|
59
|
+
# non-interactive predicate already used by the deadline logic in agent_loop and
|
|
60
|
+
# the turn loop; nested kinds are deliberately absent (see the module docstring).
|
|
61
|
+
AUTONOMOUS_PROVISIONING_RUNTIME_KINDS = frozenset(
|
|
62
|
+
{RuntimeKind.ONE_SHOT, RuntimeKind.FORGE_EXEC, RuntimeKind.SWARM_WORKER}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
_attempt_lock = threading.Lock()
|
|
66
|
+
_attempted: set[str] = set()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _workspace_provisioning_enabled(cfg: Any | None) -> bool:
|
|
70
|
+
"""Kill-switch for workspace test-runner pre-provisioning (step 5).
|
|
71
|
+
|
|
72
|
+
``ALYSIS_WORKSPACE_PROVISIONING`` (off/0/false/no/disabled) wins over the
|
|
73
|
+
config value; default is on. When off, nothing is detected, installed, or
|
|
74
|
+
reported -- fully legacy behaviour.
|
|
75
|
+
"""
|
|
76
|
+
env_value = env_get("ALYSIS_WORKSPACE_PROVISIONING")
|
|
77
|
+
if env_value is not None:
|
|
78
|
+
normalized = str(env_value).strip().lower()
|
|
79
|
+
if normalized in {"off", "0", "false", "no", "disabled"}:
|
|
80
|
+
return False
|
|
81
|
+
if normalized in {"on", "1", "true", "yes", "enabled"}:
|
|
82
|
+
return True
|
|
83
|
+
return bool(getattr(cfg, "workspace_provisioning_enabled", True))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# Detection (config files only)
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class DeclaredTestRunner:
|
|
93
|
+
package: str
|
|
94
|
+
trigger_config_file: str
|
|
95
|
+
evidence: str
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _read_bounded(path: Path) -> str | None:
|
|
99
|
+
"""Read at most ``_MAX_CONFIG_BYTES`` of a repo config file, or ``None``.
|
|
100
|
+
|
|
101
|
+
Bounded for every format, TOML included: this runs during session startup on
|
|
102
|
+
a file the repo controls, and startup must not be held hostage to its size.
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
with path.open("rb") as handle:
|
|
106
|
+
raw = handle.read(_MAX_CONFIG_BYTES)
|
|
107
|
+
except (OSError, MemoryError):
|
|
108
|
+
return None
|
|
109
|
+
return raw.decode("utf-8", errors="replace")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def ini_has_section(text: str, section: str) -> bool:
|
|
113
|
+
"""True when an INI document contains a ``[section]`` header.
|
|
114
|
+
|
|
115
|
+
A hand-rolled header scan rather than :mod:`configparser`: the file belongs
|
|
116
|
+
to the user's repo and may be invalid, use duplicate keys, or use syntax
|
|
117
|
+
configparser rejects. Only the header line matters here, and a malformed
|
|
118
|
+
file must degrade to "not declared" rather than raise.
|
|
119
|
+
|
|
120
|
+
Indented lines are skipped, not stripped: in INI an indented line continues
|
|
121
|
+
the previous value, so ``[pytest]`` inside a multi-line value is text, not a
|
|
122
|
+
section -- reading it as a declaration would invent one the repo never made.
|
|
123
|
+
"""
|
|
124
|
+
target = f"[{section}]"
|
|
125
|
+
for raw_line in str(text or "").splitlines():
|
|
126
|
+
if raw_line[:1] in {" ", "\t"}:
|
|
127
|
+
continue
|
|
128
|
+
line = raw_line.strip()
|
|
129
|
+
if not line or line.startswith("#") or line.startswith(";"):
|
|
130
|
+
continue
|
|
131
|
+
if not line.startswith("["):
|
|
132
|
+
continue
|
|
133
|
+
end = line.find("]")
|
|
134
|
+
if end == -1:
|
|
135
|
+
continue
|
|
136
|
+
if line[: end + 1] == target:
|
|
137
|
+
return True
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _pyproject_declares_pytest(path: Path) -> bool:
|
|
142
|
+
try:
|
|
143
|
+
import tomllib
|
|
144
|
+
except ImportError: # pragma: no cover - stdlib on every supported version
|
|
145
|
+
return False
|
|
146
|
+
raw = _read_bounded(path)
|
|
147
|
+
if raw is None:
|
|
148
|
+
return False
|
|
149
|
+
try:
|
|
150
|
+
payload = tomllib.loads(raw)
|
|
151
|
+
except (tomllib.TOMLDecodeError, ValueError):
|
|
152
|
+
return False
|
|
153
|
+
tool = payload.get("tool")
|
|
154
|
+
if not isinstance(tool, dict):
|
|
155
|
+
return False
|
|
156
|
+
pytest_table = tool.get("pytest")
|
|
157
|
+
if not isinstance(pytest_table, dict):
|
|
158
|
+
return False
|
|
159
|
+
return isinstance(pytest_table.get("ini_options"), dict)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def detect_declared_test_runner(root: Path) -> DeclaredTestRunner | None:
|
|
163
|
+
"""Return the runner the repo declares in config, or ``None``.
|
|
164
|
+
|
|
165
|
+
Files are checked in pytest's own configuration precedence order, so the
|
|
166
|
+
reported trigger is the file pytest would actually honour.
|
|
167
|
+
"""
|
|
168
|
+
workspace_root = Path(root)
|
|
169
|
+
|
|
170
|
+
pytest_ini = workspace_root / "pytest.ini"
|
|
171
|
+
if pytest_ini.is_file():
|
|
172
|
+
# pytest.ini exists only to configure pytest; its presence is the
|
|
173
|
+
# declaration, and it is valid for the file to be empty.
|
|
174
|
+
return DeclaredTestRunner(
|
|
175
|
+
package=PYTEST_PACKAGE,
|
|
176
|
+
trigger_config_file="pytest.ini",
|
|
177
|
+
evidence="pytest_ini_present",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
pyproject = workspace_root / "pyproject.toml"
|
|
181
|
+
if pyproject.is_file() and _pyproject_declares_pytest(pyproject):
|
|
182
|
+
return DeclaredTestRunner(
|
|
183
|
+
package=PYTEST_PACKAGE,
|
|
184
|
+
trigger_config_file="pyproject.toml",
|
|
185
|
+
evidence="tool.pytest.ini_options",
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
tox_ini = workspace_root / "tox.ini"
|
|
189
|
+
if tox_ini.is_file():
|
|
190
|
+
text = _read_bounded(tox_ini)
|
|
191
|
+
if text is not None and ini_has_section(text, "pytest"):
|
|
192
|
+
return DeclaredTestRunner(
|
|
193
|
+
package=PYTEST_PACKAGE,
|
|
194
|
+
trigger_config_file="tox.ini",
|
|
195
|
+
evidence="[pytest]",
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
setup_cfg = workspace_root / "setup.cfg"
|
|
199
|
+
if setup_cfg.is_file():
|
|
200
|
+
text = _read_bounded(setup_cfg)
|
|
201
|
+
if text is not None and ini_has_section(text, "tool:pytest"):
|
|
202
|
+
return DeclaredTestRunner(
|
|
203
|
+
package=PYTEST_PACKAGE,
|
|
204
|
+
trigger_config_file="setup.cfg",
|
|
205
|
+
evidence="[tool:pytest]",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass(frozen=True)
|
|
212
|
+
class ShellProbeResult:
|
|
213
|
+
exit_code: int | None
|
|
214
|
+
stderr: str = ""
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def ran(self) -> bool:
|
|
218
|
+
return self.exit_code is not None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# A shell command runner: takes a command string and a timeout, reports what
|
|
222
|
+
# happened. The session supplies one backed by its own (possibly sandboxed)
|
|
223
|
+
# shell runner, so the probe and the install see exactly the environment the
|
|
224
|
+
# agent's own commands will see.
|
|
225
|
+
ShellCommandRunner = Callable[[str, float], ShellProbeResult]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# Resolve the interpreter the way the workspace itself would. Plenty of hosts
|
|
229
|
+
# ship only `python3`; without this the probe exits 127, the answer is "unknown",
|
|
230
|
+
# and the whole feature silently never fires there.
|
|
231
|
+
_PYTHON_PREFIX = "PY=python; command -v python >/dev/null 2>&1 || PY=python3; "
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def build_import_probe_command(package: str) -> str:
|
|
235
|
+
"""A probe whose exit code distinguishes "missing" from "could not ask".
|
|
236
|
+
|
|
237
|
+
Exit 0 means present and ``PACKAGE_MISSING_EXIT_CODE`` means definitively
|
|
238
|
+
absent. Any other code -- 127 from a missing interpreter, a sandbox backend
|
|
239
|
+
that failed to start, a shell that could not spawn -- means the question was
|
|
240
|
+
never answered, and answering it wrongly would install into an environment
|
|
241
|
+
nobody probed.
|
|
242
|
+
"""
|
|
243
|
+
script = (
|
|
244
|
+
"import importlib.util,sys;"
|
|
245
|
+
f"sys.exit(0 if importlib.util.find_spec({package!r}) else {PACKAGE_MISSING_EXIT_CODE})"
|
|
246
|
+
)
|
|
247
|
+
return f'{_PYTHON_PREFIX}"$PY" -c {shlex.quote(script)}'
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def build_provisioning_command(package: str) -> str:
|
|
251
|
+
return f'{_PYTHON_PREFIX}"$PY" -m pip install {shlex.quote(package)} --quiet'
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def probe_runner_importable(
|
|
255
|
+
package: str,
|
|
256
|
+
*,
|
|
257
|
+
run_command: ShellCommandRunner,
|
|
258
|
+
timeout_s: float = IMPORT_PROBE_TIMEOUT_SECONDS,
|
|
259
|
+
) -> bool | None:
|
|
260
|
+
"""Is ``package`` importable in the environment the agent's commands use?
|
|
261
|
+
|
|
262
|
+
``None`` when the probe could not answer -- the shell is disabled, the
|
|
263
|
+
sandbox backend is unavailable, the interpreter is not on PATH. The decision
|
|
264
|
+
treats that as "do nothing" rather than guessing in either direction.
|
|
265
|
+
"""
|
|
266
|
+
result = run_command(build_import_probe_command(package), timeout_s)
|
|
267
|
+
if not result.ran:
|
|
268
|
+
return None
|
|
269
|
+
if result.exit_code == 0:
|
|
270
|
+
return True
|
|
271
|
+
if result.exit_code == PACKAGE_MISSING_EXIT_CODE:
|
|
272
|
+
return False
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
# Decision (pure)
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class ProvisioningAction(StrEnum):
|
|
282
|
+
PROVISION = "provision"
|
|
283
|
+
REPORT_GAP = "report_gap"
|
|
284
|
+
SKIP = "skip"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@dataclass(frozen=True)
|
|
288
|
+
class ProvisioningDecision:
|
|
289
|
+
action: ProvisioningAction
|
|
290
|
+
reason: str
|
|
291
|
+
package: str = ""
|
|
292
|
+
trigger_config_file: str = ""
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def runtime_kind_provisions_autonomously(kind: RuntimeKind | str | None) -> bool:
|
|
296
|
+
"""True only for a top-level autonomous execution run.
|
|
297
|
+
|
|
298
|
+
Nested kinds (subagent and conflict resolver) are excluded on
|
|
299
|
+
purpose: they can be spawned from an interactive chat, and letting them
|
|
300
|
+
install would smuggle into that session exactly the silent environment
|
|
301
|
+
mutation it already declined to make. An unknown kind never installs.
|
|
302
|
+
"""
|
|
303
|
+
try:
|
|
304
|
+
normalized = normalize_runtime_kind(kind, fallback=RuntimeKind.INTERACTIVE_CHAT)
|
|
305
|
+
except Exception: # noqa: BLE001 - an unknown kind must never install
|
|
306
|
+
return False
|
|
307
|
+
return normalized in AUTONOMOUS_PROVISIONING_RUNTIME_KINDS
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def resolve_provisioning_decision(
|
|
311
|
+
*,
|
|
312
|
+
declared: DeclaredTestRunner | None,
|
|
313
|
+
importable: bool | None,
|
|
314
|
+
runtime_kind: RuntimeKind | str | None,
|
|
315
|
+
enabled: bool,
|
|
316
|
+
subagent_depth: int = 0,
|
|
317
|
+
already_attempted: bool = False,
|
|
318
|
+
) -> ProvisioningDecision:
|
|
319
|
+
"""Decide whether to install, report, or do nothing. Pure.
|
|
320
|
+
|
|
321
|
+
Only one combination installs: the feature is on, the repo declared a runner
|
|
322
|
+
in a config file, that runner is *confirmed* missing from the environment the
|
|
323
|
+
agent's commands run in, the run is a top-level autonomous one, and no
|
|
324
|
+
attempt has been made yet. Everything else is a no-op or a report.
|
|
325
|
+
"""
|
|
326
|
+
if not enabled:
|
|
327
|
+
return ProvisioningDecision(ProvisioningAction.SKIP, "workspace_provisioning_disabled")
|
|
328
|
+
if declared is None:
|
|
329
|
+
return ProvisioningDecision(ProvisioningAction.SKIP, "no_declared_test_runner")
|
|
330
|
+
if importable is None:
|
|
331
|
+
return ProvisioningDecision(
|
|
332
|
+
ProvisioningAction.SKIP,
|
|
333
|
+
"import_probe_unavailable",
|
|
334
|
+
package=declared.package,
|
|
335
|
+
trigger_config_file=declared.trigger_config_file,
|
|
336
|
+
)
|
|
337
|
+
if importable:
|
|
338
|
+
return ProvisioningDecision(
|
|
339
|
+
ProvisioningAction.SKIP,
|
|
340
|
+
"declared_test_runner_importable",
|
|
341
|
+
package=declared.package,
|
|
342
|
+
trigger_config_file=declared.trigger_config_file,
|
|
343
|
+
)
|
|
344
|
+
if subagent_depth > 0:
|
|
345
|
+
return ProvisioningDecision(
|
|
346
|
+
ProvisioningAction.SKIP,
|
|
347
|
+
"nested_session_defers_to_parent",
|
|
348
|
+
package=declared.package,
|
|
349
|
+
trigger_config_file=declared.trigger_config_file,
|
|
350
|
+
)
|
|
351
|
+
if not runtime_kind_provisions_autonomously(runtime_kind):
|
|
352
|
+
return ProvisioningDecision(
|
|
353
|
+
ProvisioningAction.REPORT_GAP,
|
|
354
|
+
"interactive_run_does_not_install",
|
|
355
|
+
package=declared.package,
|
|
356
|
+
trigger_config_file=declared.trigger_config_file,
|
|
357
|
+
)
|
|
358
|
+
if already_attempted:
|
|
359
|
+
return ProvisioningDecision(
|
|
360
|
+
ProvisioningAction.REPORT_GAP,
|
|
361
|
+
"provisioning_already_attempted",
|
|
362
|
+
package=declared.package,
|
|
363
|
+
trigger_config_file=declared.trigger_config_file,
|
|
364
|
+
)
|
|
365
|
+
return ProvisioningDecision(
|
|
366
|
+
ProvisioningAction.PROVISION,
|
|
367
|
+
"declared_test_runner_missing",
|
|
368
|
+
package=declared.package,
|
|
369
|
+
trigger_config_file=declared.trigger_config_file,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
# ---------------------------------------------------------------------------
|
|
374
|
+
# Execution (one bounded attempt)
|
|
375
|
+
# ---------------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@dataclass(frozen=True)
|
|
379
|
+
class ProvisioningOutcome:
|
|
380
|
+
package: str
|
|
381
|
+
trigger_config_file: str
|
|
382
|
+
success: bool
|
|
383
|
+
command: str
|
|
384
|
+
exit_code: int | None = None
|
|
385
|
+
error: str = ""
|
|
386
|
+
|
|
387
|
+
def payload(self) -> dict[str, Any]:
|
|
388
|
+
out: dict[str, Any] = {
|
|
389
|
+
"package": self.package,
|
|
390
|
+
"trigger_config_file": self.trigger_config_file,
|
|
391
|
+
"success": self.success,
|
|
392
|
+
"command": self.command,
|
|
393
|
+
}
|
|
394
|
+
if self.exit_code is not None:
|
|
395
|
+
out["exit_code"] = self.exit_code
|
|
396
|
+
if self.error:
|
|
397
|
+
out["error"] = self.error
|
|
398
|
+
return out
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def provisioning_already_attempted(package: str) -> bool:
|
|
402
|
+
with _attempt_lock:
|
|
403
|
+
return package in _attempted
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _claim_provisioning_attempt(package: str) -> bool:
|
|
407
|
+
"""Claim the single attempt for ``package``. False when someone else holds it."""
|
|
408
|
+
with _attempt_lock:
|
|
409
|
+
if package in _attempted:
|
|
410
|
+
return False
|
|
411
|
+
_attempted.add(package)
|
|
412
|
+
return True
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def reset_provisioning_attempts() -> None:
|
|
416
|
+
"""Clear the one-attempt ledger. Test seam only."""
|
|
417
|
+
with _attempt_lock:
|
|
418
|
+
_attempted.clear()
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def provision_test_runner(
|
|
422
|
+
decision: ProvisioningDecision,
|
|
423
|
+
*,
|
|
424
|
+
run_command: ShellCommandRunner,
|
|
425
|
+
timeout_s: float = PROVISION_TIMEOUT_SECONDS,
|
|
426
|
+
) -> ProvisioningOutcome | None:
|
|
427
|
+
"""Run the single install attempt described by ``decision``.
|
|
428
|
+
|
|
429
|
+
Returns ``None`` when another session in this process already claimed the
|
|
430
|
+
attempt. That is not a failure and must not be reported as one, or every
|
|
431
|
+
swarm worker but the first would log an install failure that never happened.
|
|
432
|
+
The claim is taken before the command runs, so a failure is never retried: a
|
|
433
|
+
broken or offline environment costs one install, not one per session.
|
|
434
|
+
"""
|
|
435
|
+
command = build_provisioning_command(decision.package)
|
|
436
|
+
if not _claim_provisioning_attempt(decision.package):
|
|
437
|
+
return None
|
|
438
|
+
result = run_command(command, timeout_s)
|
|
439
|
+
if not result.ran:
|
|
440
|
+
return ProvisioningOutcome(
|
|
441
|
+
package=decision.package,
|
|
442
|
+
trigger_config_file=decision.trigger_config_file,
|
|
443
|
+
success=False,
|
|
444
|
+
command=command,
|
|
445
|
+
error=result.stderr.strip()[:2000] or "the install command could not be executed",
|
|
446
|
+
)
|
|
447
|
+
exit_code = int(result.exit_code or 0)
|
|
448
|
+
return ProvisioningOutcome(
|
|
449
|
+
package=decision.package,
|
|
450
|
+
trigger_config_file=decision.trigger_config_file,
|
|
451
|
+
success=exit_code == 0,
|
|
452
|
+
command=command,
|
|
453
|
+
exit_code=exit_code,
|
|
454
|
+
error=("" if exit_code == 0 else result.stderr.strip()[:2000]),
|
|
455
|
+
)
|