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,737 @@
|
|
|
1
|
+
"""Durable exactly-once acceptance for IDE Forge plan requests.
|
|
2
|
+
|
|
3
|
+
Only hashes of idempotency keys and request payloads are persisted. The
|
|
4
|
+
instruction itself is deliberately never written to this database. Workers
|
|
5
|
+
hold short, renewable, hashed leases so a stale process cannot publish a
|
|
6
|
+
result after ownership has moved. An expired *running* lease is terminally
|
|
7
|
+
classified as indeterminate: callers must not automatically execute it again.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import secrets
|
|
18
|
+
import sqlite3
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
import uuid
|
|
22
|
+
from collections.abc import Callable, Mapping
|
|
23
|
+
from contextlib import contextmanager, suppress
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from enum import Enum
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from ..branding import canonical_user_data_dir, env_get
|
|
30
|
+
|
|
31
|
+
SCHEMA_VERSION = 1
|
|
32
|
+
DEFAULT_LEASE_SECONDS = 30.0
|
|
33
|
+
MIN_LEASE_SECONDS = 1.0
|
|
34
|
+
MAX_LEASE_SECONDS = 15 * 60.0
|
|
35
|
+
_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]*$")
|
|
36
|
+
_ERROR_CODE_RE = re.compile(r"^[a-z][a-z0-9_.-]*$")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ForgeRequestState(str, Enum):
|
|
40
|
+
QUEUED = "queued"
|
|
41
|
+
RUNNING = "running"
|
|
42
|
+
COMPLETED = "completed"
|
|
43
|
+
FAILED = "failed"
|
|
44
|
+
CANCELLED = "cancelled"
|
|
45
|
+
INDETERMINATE = "indeterminate"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
TERMINAL_STATES = frozenset(
|
|
49
|
+
{
|
|
50
|
+
ForgeRequestState.COMPLETED,
|
|
51
|
+
ForgeRequestState.FAILED,
|
|
52
|
+
ForgeRequestState.CANCELLED,
|
|
53
|
+
ForgeRequestState.INDETERMINATE,
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ForgeRequestLedgerError(RuntimeError):
|
|
59
|
+
"""Base error whose message is safe to surface or log."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ForgeRequestValidationError(ForgeRequestLedgerError, ValueError):
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ForgeRequestIdempotencyConflict(ForgeRequestLedgerError):
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ForgeRequestNotFound(ForgeRequestLedgerError):
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ForgeRequestLeaseLost(ForgeRequestLedgerError):
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ForgeRequestStorageError(ForgeRequestLedgerError):
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class ForgeRequestLedgerConfig:
|
|
84
|
+
busy_timeout_seconds: float = 10.0
|
|
85
|
+
lease_seconds: float = DEFAULT_LEASE_SECONDS
|
|
86
|
+
max_records: int = 50_000
|
|
87
|
+
terminal_retention_seconds: float = 90 * 24 * 60 * 60.0
|
|
88
|
+
|
|
89
|
+
def __post_init__(self) -> None:
|
|
90
|
+
_duration(self.busy_timeout_seconds, maximum=60.0, label="busy timeout")
|
|
91
|
+
_duration(self.lease_seconds, maximum=MAX_LEASE_SECONDS, label="lease")
|
|
92
|
+
if isinstance(self.max_records, bool) or not isinstance(self.max_records, int):
|
|
93
|
+
raise ForgeRequestValidationError("Forge request record limit must be an integer.")
|
|
94
|
+
if self.max_records < 100 or self.max_records > 1_000_000:
|
|
95
|
+
raise ForgeRequestValidationError(
|
|
96
|
+
"Forge request record limit is outside the allowed range."
|
|
97
|
+
)
|
|
98
|
+
_duration(
|
|
99
|
+
self.terminal_retention_seconds,
|
|
100
|
+
maximum=10 * 365 * 24 * 60 * 60.0,
|
|
101
|
+
label="terminal retention",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True, slots=True)
|
|
106
|
+
class ForgeRequestRecord:
|
|
107
|
+
job_id: str
|
|
108
|
+
session_id: str
|
|
109
|
+
workspace_root: Path = field(repr=False)
|
|
110
|
+
state: ForgeRequestState
|
|
111
|
+
created_at: float
|
|
112
|
+
updated_at: float
|
|
113
|
+
started_at: float | None = None
|
|
114
|
+
terminal_at: float | None = None
|
|
115
|
+
plan_id: str | None = None
|
|
116
|
+
error_code: str | None = None
|
|
117
|
+
attempts: int = 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True, slots=True)
|
|
121
|
+
class ForgeDispatchLease:
|
|
122
|
+
job_id: str
|
|
123
|
+
generation: int
|
|
124
|
+
expires_at: float
|
|
125
|
+
token: str = field(repr=False)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True, slots=True)
|
|
129
|
+
class ForgeWorkerLease:
|
|
130
|
+
job_id: str
|
|
131
|
+
generation: int
|
|
132
|
+
expires_at: float
|
|
133
|
+
token: str = field(repr=False)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True, slots=True)
|
|
137
|
+
class ForgeRequestAcceptance:
|
|
138
|
+
record: ForgeRequestRecord
|
|
139
|
+
created: bool
|
|
140
|
+
dispatch_lease: ForgeDispatchLease | None = field(default=None, repr=False)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def default_forge_request_ledger_path() -> Path:
|
|
144
|
+
"""Return the private user-data path, outside workspaces by default."""
|
|
145
|
+
|
|
146
|
+
override = str(env_get("ALYSIS_DATA_DIR") or "").strip()
|
|
147
|
+
data_dir = Path(override).expanduser() if override else canonical_user_data_dir()
|
|
148
|
+
return data_dir / "ide" / "forge-plan-requests.sqlite3"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class DurableForgeRequestLedger:
|
|
152
|
+
"""SQLite-backed Forge request acceptance and fenced worker ownership."""
|
|
153
|
+
|
|
154
|
+
def __init__(
|
|
155
|
+
self,
|
|
156
|
+
path: str | os.PathLike[str] | None = None,
|
|
157
|
+
*,
|
|
158
|
+
config: ForgeRequestLedgerConfig | None = None,
|
|
159
|
+
clock: Callable[[], float] = time.time,
|
|
160
|
+
job_id_factory: Callable[[], str] | None = None,
|
|
161
|
+
lease_token_factory: Callable[[], str] | None = None,
|
|
162
|
+
) -> None:
|
|
163
|
+
self.path = Path(path) if path is not None else default_forge_request_ledger_path()
|
|
164
|
+
self.config = config or ForgeRequestLedgerConfig()
|
|
165
|
+
self._clock = clock
|
|
166
|
+
self._job_id_factory = job_id_factory or (lambda: f"job_{uuid.uuid4().hex}")
|
|
167
|
+
self._lease_token_factory = lease_token_factory or (
|
|
168
|
+
lambda: f"lease_{secrets.token_urlsafe(32)}"
|
|
169
|
+
)
|
|
170
|
+
self._lock = threading.RLock()
|
|
171
|
+
self._prepare_storage()
|
|
172
|
+
|
|
173
|
+
def accept(
|
|
174
|
+
self,
|
|
175
|
+
*,
|
|
176
|
+
workspace_root: str | os.PathLike[str],
|
|
177
|
+
session_id: str,
|
|
178
|
+
idempotency_key: str,
|
|
179
|
+
payload: Mapping[str, Any],
|
|
180
|
+
) -> ForgeRequestAcceptance:
|
|
181
|
+
"""Durably accept a request, or return its stable prior job.
|
|
182
|
+
|
|
183
|
+
A stale queued dispatch can be safely fenced and redispatched because a
|
|
184
|
+
worker must transition it to ``running`` before invoking the planner.
|
|
185
|
+
A stale running request is never redispatched.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
workspace = _workspace(workspace_root)
|
|
189
|
+
session = _identifier(session_id, "session", maximum=256)
|
|
190
|
+
key = _identifier(idempotency_key, "idempotency", maximum=256)
|
|
191
|
+
workspace_key = _digest(os.path.normcase(os.fspath(workspace)))
|
|
192
|
+
idempotency_hash = _digest(key)
|
|
193
|
+
payload_hash = _payload_digest(payload)
|
|
194
|
+
now = self._now()
|
|
195
|
+
duration = self.config.lease_seconds
|
|
196
|
+
|
|
197
|
+
with self._lock, self._transaction() as connection:
|
|
198
|
+
row = connection.execute(
|
|
199
|
+
"""
|
|
200
|
+
SELECT * FROM ide_forge_plan_requests
|
|
201
|
+
WHERE workspace_sha256 = ? AND idempotency_sha256 = ?
|
|
202
|
+
""",
|
|
203
|
+
(workspace_key, idempotency_hash),
|
|
204
|
+
).fetchone()
|
|
205
|
+
if row is not None:
|
|
206
|
+
if str(row["payload_sha256"]) != payload_hash:
|
|
207
|
+
raise ForgeRequestIdempotencyConflict(
|
|
208
|
+
"Idempotency key was already used for a different Forge plan request."
|
|
209
|
+
)
|
|
210
|
+
row = self._reconcile_expired_running(connection, row=row, now=now)
|
|
211
|
+
state = ForgeRequestState(str(row["state"]))
|
|
212
|
+
connection.execute(
|
|
213
|
+
"""
|
|
214
|
+
UPDATE ide_forge_plan_requests
|
|
215
|
+
SET session_id = ?, updated_at = CASE
|
|
216
|
+
WHEN session_id = ? THEN updated_at ELSE ? END
|
|
217
|
+
WHERE job_id = ?
|
|
218
|
+
""",
|
|
219
|
+
(session, session, now, row["job_id"]),
|
|
220
|
+
)
|
|
221
|
+
row = self._row_by_job(connection, str(row["job_id"]))
|
|
222
|
+
if (
|
|
223
|
+
state is ForgeRequestState.QUEUED
|
|
224
|
+
and float(row["lease_expires_at"] or 0.0) <= now
|
|
225
|
+
):
|
|
226
|
+
lease = self._replace_dispatch_lease(connection, row=row, now=now)
|
|
227
|
+
return ForgeRequestAcceptance(
|
|
228
|
+
record=self._record(self._row_by_job(connection, lease.job_id)),
|
|
229
|
+
created=False,
|
|
230
|
+
dispatch_lease=lease,
|
|
231
|
+
)
|
|
232
|
+
return ForgeRequestAcceptance(record=self._record(row), created=False)
|
|
233
|
+
|
|
234
|
+
job_id = _identifier(self._job_id_factory(), "generated job", maximum=128)
|
|
235
|
+
self._prune_for_insert(connection, now=now)
|
|
236
|
+
token = _lease_token(self._lease_token_factory())
|
|
237
|
+
generation = 1
|
|
238
|
+
expires_at = now + duration
|
|
239
|
+
try:
|
|
240
|
+
connection.execute(
|
|
241
|
+
"""
|
|
242
|
+
INSERT INTO ide_forge_plan_requests (
|
|
243
|
+
job_id, workspace_sha256, workspace_root, session_id,
|
|
244
|
+
idempotency_sha256, payload_sha256, state, generation,
|
|
245
|
+
attempts, lease_token_sha256, lease_expires_at,
|
|
246
|
+
created_at, updated_at
|
|
247
|
+
) VALUES (?, ?, ?, ?, ?, ?, 'queued', ?, 0, ?, ?, ?, ?)
|
|
248
|
+
""",
|
|
249
|
+
(
|
|
250
|
+
job_id,
|
|
251
|
+
workspace_key,
|
|
252
|
+
os.fspath(workspace),
|
|
253
|
+
session,
|
|
254
|
+
idempotency_hash,
|
|
255
|
+
payload_hash,
|
|
256
|
+
generation,
|
|
257
|
+
_digest(token),
|
|
258
|
+
expires_at,
|
|
259
|
+
now,
|
|
260
|
+
now,
|
|
261
|
+
),
|
|
262
|
+
)
|
|
263
|
+
except sqlite3.IntegrityError:
|
|
264
|
+
raise ForgeRequestStorageError(
|
|
265
|
+
"Could not allocate a durable Forge plan request."
|
|
266
|
+
) from None
|
|
267
|
+
row = self._row_by_job(connection, job_id)
|
|
268
|
+
return ForgeRequestAcceptance(
|
|
269
|
+
record=self._record(row),
|
|
270
|
+
created=True,
|
|
271
|
+
dispatch_lease=ForgeDispatchLease(
|
|
272
|
+
job_id=job_id,
|
|
273
|
+
generation=generation,
|
|
274
|
+
expires_at=expires_at,
|
|
275
|
+
token=token,
|
|
276
|
+
),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def begin(self, lease: ForgeDispatchLease) -> ForgeWorkerLease:
|
|
280
|
+
"""Claim execution before the planner is invoked."""
|
|
281
|
+
|
|
282
|
+
now = self._now()
|
|
283
|
+
expires_at = now + self.config.lease_seconds
|
|
284
|
+
with self._lock, self._transaction() as connection:
|
|
285
|
+
row = self._require_lease(
|
|
286
|
+
connection,
|
|
287
|
+
job_id=lease.job_id,
|
|
288
|
+
generation=lease.generation,
|
|
289
|
+
token=lease.token,
|
|
290
|
+
expected_state=ForgeRequestState.QUEUED,
|
|
291
|
+
now=now,
|
|
292
|
+
)
|
|
293
|
+
connection.execute(
|
|
294
|
+
"""
|
|
295
|
+
UPDATE ide_forge_plan_requests
|
|
296
|
+
SET state = 'running', attempts = attempts + 1,
|
|
297
|
+
started_at = COALESCE(started_at, ?), updated_at = ?,
|
|
298
|
+
lease_expires_at = ?
|
|
299
|
+
WHERE job_id = ?
|
|
300
|
+
""",
|
|
301
|
+
(now, now, expires_at, row["job_id"]),
|
|
302
|
+
)
|
|
303
|
+
return ForgeWorkerLease(
|
|
304
|
+
job_id=lease.job_id,
|
|
305
|
+
generation=lease.generation,
|
|
306
|
+
expires_at=expires_at,
|
|
307
|
+
token=lease.token,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
def renew(self, lease: ForgeWorkerLease) -> ForgeWorkerLease:
|
|
311
|
+
now = self._now()
|
|
312
|
+
expires_at = now + self.config.lease_seconds
|
|
313
|
+
with self._lock, self._transaction() as connection:
|
|
314
|
+
self._require_lease(
|
|
315
|
+
connection,
|
|
316
|
+
job_id=lease.job_id,
|
|
317
|
+
generation=lease.generation,
|
|
318
|
+
token=lease.token,
|
|
319
|
+
expected_state=ForgeRequestState.RUNNING,
|
|
320
|
+
now=now,
|
|
321
|
+
)
|
|
322
|
+
connection.execute(
|
|
323
|
+
"""
|
|
324
|
+
UPDATE ide_forge_plan_requests
|
|
325
|
+
SET lease_expires_at = ?, updated_at = ? WHERE job_id = ?
|
|
326
|
+
""",
|
|
327
|
+
(expires_at, now, lease.job_id),
|
|
328
|
+
)
|
|
329
|
+
return ForgeWorkerLease(
|
|
330
|
+
job_id=lease.job_id,
|
|
331
|
+
generation=lease.generation,
|
|
332
|
+
expires_at=expires_at,
|
|
333
|
+
token=lease.token,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
def complete(self, lease: ForgeWorkerLease, *, plan_id: str) -> ForgeRequestRecord:
|
|
337
|
+
clean_plan_id = _identifier(plan_id, "plan", maximum=128)
|
|
338
|
+
return self._finish(
|
|
339
|
+
lease,
|
|
340
|
+
state=ForgeRequestState.COMPLETED,
|
|
341
|
+
plan_id=clean_plan_id,
|
|
342
|
+
error_code=None,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
def fail(self, lease: ForgeWorkerLease, *, error_code: str) -> ForgeRequestRecord:
|
|
346
|
+
return self._finish(
|
|
347
|
+
lease,
|
|
348
|
+
state=ForgeRequestState.FAILED,
|
|
349
|
+
plan_id=None,
|
|
350
|
+
error_code=_error_code(error_code),
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
def cancel(self, lease: ForgeWorkerLease, *, error_code: str) -> ForgeRequestRecord:
|
|
354
|
+
return self._finish(
|
|
355
|
+
lease,
|
|
356
|
+
state=ForgeRequestState.CANCELLED,
|
|
357
|
+
plan_id=None,
|
|
358
|
+
error_code=_error_code(error_code),
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
def reject(self, lease: ForgeDispatchLease, *, error_code: str) -> ForgeRequestRecord:
|
|
362
|
+
"""Terminally reject accepted work that cannot be dispatched."""
|
|
363
|
+
|
|
364
|
+
now = self._now()
|
|
365
|
+
with self._lock, self._transaction() as connection:
|
|
366
|
+
self._require_lease(
|
|
367
|
+
connection,
|
|
368
|
+
job_id=lease.job_id,
|
|
369
|
+
generation=lease.generation,
|
|
370
|
+
token=lease.token,
|
|
371
|
+
expected_state=ForgeRequestState.QUEUED,
|
|
372
|
+
now=now,
|
|
373
|
+
)
|
|
374
|
+
connection.execute(
|
|
375
|
+
"""
|
|
376
|
+
UPDATE ide_forge_plan_requests
|
|
377
|
+
SET state = 'failed', error_code = ?, terminal_at = ?, updated_at = ?,
|
|
378
|
+
lease_token_sha256 = NULL, lease_expires_at = NULL
|
|
379
|
+
WHERE job_id = ?
|
|
380
|
+
""",
|
|
381
|
+
(_error_code(error_code), now, now, lease.job_id),
|
|
382
|
+
)
|
|
383
|
+
return self._record(self._row_by_job(connection, lease.job_id))
|
|
384
|
+
|
|
385
|
+
def get(self, job_id: str) -> ForgeRequestRecord | None:
|
|
386
|
+
clean = _identifier(job_id, "job", maximum=128)
|
|
387
|
+
now = self._now()
|
|
388
|
+
with self._lock, self._transaction() as connection:
|
|
389
|
+
row = connection.execute(
|
|
390
|
+
"SELECT * FROM ide_forge_plan_requests WHERE job_id = ?", (clean,)
|
|
391
|
+
).fetchone()
|
|
392
|
+
if row is None:
|
|
393
|
+
return None
|
|
394
|
+
row = self._reconcile_expired_running(connection, row=row, now=now)
|
|
395
|
+
return self._record(row)
|
|
396
|
+
|
|
397
|
+
def attach(
|
|
398
|
+
self,
|
|
399
|
+
*,
|
|
400
|
+
job_id: str,
|
|
401
|
+
session_id: str,
|
|
402
|
+
workspace_root: str | os.PathLike[str],
|
|
403
|
+
) -> ForgeRequestRecord:
|
|
404
|
+
"""Attach a recovered durable job to a live bridge session."""
|
|
405
|
+
|
|
406
|
+
clean_job = _identifier(job_id, "job", maximum=128)
|
|
407
|
+
clean_session = _identifier(session_id, "session", maximum=256)
|
|
408
|
+
workspace = _workspace(workspace_root)
|
|
409
|
+
workspace_key = _digest(os.path.normcase(os.fspath(workspace)))
|
|
410
|
+
now = self._now()
|
|
411
|
+
with self._lock, self._transaction() as connection:
|
|
412
|
+
row = self._row_by_job(connection, clean_job)
|
|
413
|
+
if not secrets.compare_digest(str(row["workspace_sha256"]), workspace_key):
|
|
414
|
+
raise ForgeRequestValidationError(
|
|
415
|
+
"Durable Forge plan request workspace binding does not match."
|
|
416
|
+
)
|
|
417
|
+
connection.execute(
|
|
418
|
+
"""
|
|
419
|
+
UPDATE ide_forge_plan_requests SET session_id = ?, updated_at = ?
|
|
420
|
+
WHERE job_id = ?
|
|
421
|
+
""",
|
|
422
|
+
(clean_session, now, clean_job),
|
|
423
|
+
)
|
|
424
|
+
return self._record(self._row_by_job(connection, clean_job))
|
|
425
|
+
|
|
426
|
+
def _finish(
|
|
427
|
+
self,
|
|
428
|
+
lease: ForgeWorkerLease,
|
|
429
|
+
*,
|
|
430
|
+
state: ForgeRequestState,
|
|
431
|
+
plan_id: str | None,
|
|
432
|
+
error_code: str | None,
|
|
433
|
+
) -> ForgeRequestRecord:
|
|
434
|
+
now = self._now()
|
|
435
|
+
with self._lock, self._transaction() as connection:
|
|
436
|
+
self._require_lease(
|
|
437
|
+
connection,
|
|
438
|
+
job_id=lease.job_id,
|
|
439
|
+
generation=lease.generation,
|
|
440
|
+
token=lease.token,
|
|
441
|
+
expected_state=ForgeRequestState.RUNNING,
|
|
442
|
+
now=now,
|
|
443
|
+
)
|
|
444
|
+
connection.execute(
|
|
445
|
+
"""
|
|
446
|
+
UPDATE ide_forge_plan_requests
|
|
447
|
+
SET state = ?, plan_id = ?, error_code = ?, terminal_at = ?,
|
|
448
|
+
updated_at = ?, lease_token_sha256 = NULL,
|
|
449
|
+
lease_expires_at = NULL
|
|
450
|
+
WHERE job_id = ?
|
|
451
|
+
""",
|
|
452
|
+
(state.value, plan_id, error_code, now, now, lease.job_id),
|
|
453
|
+
)
|
|
454
|
+
return self._record(self._row_by_job(connection, lease.job_id))
|
|
455
|
+
|
|
456
|
+
def _replace_dispatch_lease(
|
|
457
|
+
self, connection: sqlite3.Connection, *, row: sqlite3.Row, now: float
|
|
458
|
+
) -> ForgeDispatchLease:
|
|
459
|
+
token = _lease_token(self._lease_token_factory())
|
|
460
|
+
generation = int(row["generation"]) + 1
|
|
461
|
+
expires_at = now + self.config.lease_seconds
|
|
462
|
+
connection.execute(
|
|
463
|
+
"""
|
|
464
|
+
UPDATE ide_forge_plan_requests
|
|
465
|
+
SET generation = ?, lease_token_sha256 = ?, lease_expires_at = ?,
|
|
466
|
+
updated_at = ? WHERE job_id = ? AND state = 'queued'
|
|
467
|
+
""",
|
|
468
|
+
(generation, _digest(token), expires_at, now, row["job_id"]),
|
|
469
|
+
)
|
|
470
|
+
return ForgeDispatchLease(
|
|
471
|
+
job_id=str(row["job_id"]),
|
|
472
|
+
generation=generation,
|
|
473
|
+
expires_at=expires_at,
|
|
474
|
+
token=token,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
def _require_lease(
|
|
478
|
+
self,
|
|
479
|
+
connection: sqlite3.Connection,
|
|
480
|
+
*,
|
|
481
|
+
job_id: str,
|
|
482
|
+
generation: int,
|
|
483
|
+
token: str,
|
|
484
|
+
expected_state: ForgeRequestState,
|
|
485
|
+
now: float,
|
|
486
|
+
) -> sqlite3.Row:
|
|
487
|
+
row = self._row_by_job(connection, _identifier(job_id, "job", maximum=128))
|
|
488
|
+
if (
|
|
489
|
+
str(row["state"]) != expected_state.value
|
|
490
|
+
or int(row["generation"]) != generation
|
|
491
|
+
or not secrets.compare_digest(str(row["lease_token_sha256"] or ""), _digest(token))
|
|
492
|
+
or float(row["lease_expires_at"] or 0.0) <= now
|
|
493
|
+
):
|
|
494
|
+
raise ForgeRequestLeaseLost("Forge plan worker lease is no longer valid.")
|
|
495
|
+
return row
|
|
496
|
+
|
|
497
|
+
def _reconcile_expired_running(
|
|
498
|
+
self, connection: sqlite3.Connection, *, row: sqlite3.Row, now: float
|
|
499
|
+
) -> sqlite3.Row:
|
|
500
|
+
if str(row["state"]) != ForgeRequestState.RUNNING.value:
|
|
501
|
+
return row
|
|
502
|
+
if float(row["lease_expires_at"] or 0.0) > now:
|
|
503
|
+
return row
|
|
504
|
+
connection.execute(
|
|
505
|
+
"""
|
|
506
|
+
UPDATE ide_forge_plan_requests
|
|
507
|
+
SET state = 'indeterminate', error_code = 'worker_lease_expired',
|
|
508
|
+
terminal_at = ?, updated_at = ?, lease_token_sha256 = NULL,
|
|
509
|
+
lease_expires_at = NULL
|
|
510
|
+
WHERE job_id = ? AND state = 'running' AND lease_expires_at <= ?
|
|
511
|
+
""",
|
|
512
|
+
(now, now, row["job_id"], now),
|
|
513
|
+
)
|
|
514
|
+
return self._row_by_job(connection, str(row["job_id"]))
|
|
515
|
+
|
|
516
|
+
def _prune_for_insert(self, connection: sqlite3.Connection, *, now: float) -> None:
|
|
517
|
+
cutoff = now - self.config.terminal_retention_seconds
|
|
518
|
+
connection.execute(
|
|
519
|
+
"""
|
|
520
|
+
DELETE FROM ide_forge_plan_requests
|
|
521
|
+
WHERE state IN ('completed', 'failed', 'cancelled', 'indeterminate')
|
|
522
|
+
AND terminal_at < ?
|
|
523
|
+
""",
|
|
524
|
+
(cutoff,),
|
|
525
|
+
)
|
|
526
|
+
count = int(
|
|
527
|
+
connection.execute("SELECT COUNT(*) FROM ide_forge_plan_requests").fetchone()[0]
|
|
528
|
+
)
|
|
529
|
+
overflow = count - self.config.max_records + 1
|
|
530
|
+
if overflow > 0:
|
|
531
|
+
connection.execute(
|
|
532
|
+
"""
|
|
533
|
+
DELETE FROM ide_forge_plan_requests WHERE job_id IN (
|
|
534
|
+
SELECT job_id FROM ide_forge_plan_requests
|
|
535
|
+
WHERE state IN ('completed', 'failed', 'cancelled', 'indeterminate')
|
|
536
|
+
ORDER BY terminal_at ASC LIMIT ?
|
|
537
|
+
)
|
|
538
|
+
""",
|
|
539
|
+
(overflow,),
|
|
540
|
+
)
|
|
541
|
+
count = int(
|
|
542
|
+
connection.execute("SELECT COUNT(*) FROM ide_forge_plan_requests").fetchone()[0]
|
|
543
|
+
)
|
|
544
|
+
if count >= self.config.max_records:
|
|
545
|
+
raise ForgeRequestStorageError("Durable Forge plan request storage is full.")
|
|
546
|
+
|
|
547
|
+
def _record(self, row: sqlite3.Row) -> ForgeRequestRecord:
|
|
548
|
+
return ForgeRequestRecord(
|
|
549
|
+
job_id=str(row["job_id"]),
|
|
550
|
+
session_id=str(row["session_id"]),
|
|
551
|
+
workspace_root=Path(str(row["workspace_root"])),
|
|
552
|
+
state=ForgeRequestState(str(row["state"])),
|
|
553
|
+
created_at=float(row["created_at"]),
|
|
554
|
+
updated_at=float(row["updated_at"]),
|
|
555
|
+
started_at=(None if row["started_at"] is None else float(row["started_at"])),
|
|
556
|
+
terminal_at=(None if row["terminal_at"] is None else float(row["terminal_at"])),
|
|
557
|
+
plan_id=(None if row["plan_id"] is None else str(row["plan_id"])),
|
|
558
|
+
error_code=(None if row["error_code"] is None else str(row["error_code"])),
|
|
559
|
+
attempts=int(row["attempts"]),
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
def _row_by_job(self, connection: sqlite3.Connection, job_id: str) -> sqlite3.Row:
|
|
563
|
+
row = connection.execute(
|
|
564
|
+
"SELECT * FROM ide_forge_plan_requests WHERE job_id = ?", (job_id,)
|
|
565
|
+
).fetchone()
|
|
566
|
+
if row is None:
|
|
567
|
+
raise ForgeRequestNotFound("Durable Forge plan request was not found.")
|
|
568
|
+
return row
|
|
569
|
+
|
|
570
|
+
def _prepare_storage(self) -> None:
|
|
571
|
+
try:
|
|
572
|
+
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
573
|
+
with suppress(OSError):
|
|
574
|
+
self.path.parent.chmod(0o700)
|
|
575
|
+
with self._connection() as connection:
|
|
576
|
+
version = int(connection.execute("PRAGMA user_version").fetchone()[0])
|
|
577
|
+
if version not in {0, SCHEMA_VERSION}:
|
|
578
|
+
raise ForgeRequestStorageError(
|
|
579
|
+
"Durable Forge plan request storage schema is unsupported."
|
|
580
|
+
)
|
|
581
|
+
connection.execute(
|
|
582
|
+
"""
|
|
583
|
+
CREATE TABLE IF NOT EXISTS ide_forge_plan_requests (
|
|
584
|
+
job_id TEXT PRIMARY KEY,
|
|
585
|
+
workspace_sha256 TEXT NOT NULL,
|
|
586
|
+
workspace_root TEXT NOT NULL,
|
|
587
|
+
session_id TEXT NOT NULL,
|
|
588
|
+
idempotency_sha256 TEXT NOT NULL,
|
|
589
|
+
payload_sha256 TEXT NOT NULL,
|
|
590
|
+
state TEXT NOT NULL CHECK (
|
|
591
|
+
state IN ('queued', 'running', 'completed', 'failed',
|
|
592
|
+
'cancelled', 'indeterminate')
|
|
593
|
+
),
|
|
594
|
+
generation INTEGER NOT NULL DEFAULT 1,
|
|
595
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
596
|
+
lease_token_sha256 TEXT,
|
|
597
|
+
lease_expires_at REAL,
|
|
598
|
+
plan_id TEXT,
|
|
599
|
+
error_code TEXT,
|
|
600
|
+
created_at REAL NOT NULL,
|
|
601
|
+
updated_at REAL NOT NULL,
|
|
602
|
+
started_at REAL,
|
|
603
|
+
terminal_at REAL,
|
|
604
|
+
UNIQUE(workspace_sha256, idempotency_sha256)
|
|
605
|
+
)
|
|
606
|
+
"""
|
|
607
|
+
)
|
|
608
|
+
connection.execute(
|
|
609
|
+
"CREATE INDEX IF NOT EXISTS idx_ide_forge_plan_requests_state "
|
|
610
|
+
"ON ide_forge_plan_requests(state, lease_expires_at)"
|
|
611
|
+
)
|
|
612
|
+
connection.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
|
613
|
+
with suppress(OSError):
|
|
614
|
+
self.path.chmod(0o600)
|
|
615
|
+
except (OSError, sqlite3.Error) as exc:
|
|
616
|
+
raise ForgeRequestStorageError(
|
|
617
|
+
"Could not prepare durable Forge plan request storage."
|
|
618
|
+
) from exc
|
|
619
|
+
|
|
620
|
+
@contextmanager
|
|
621
|
+
def _connection(self):
|
|
622
|
+
connection: sqlite3.Connection | None = None
|
|
623
|
+
try:
|
|
624
|
+
connection = sqlite3.connect(
|
|
625
|
+
self.path,
|
|
626
|
+
timeout=self.config.busy_timeout_seconds,
|
|
627
|
+
isolation_level=None,
|
|
628
|
+
)
|
|
629
|
+
connection.row_factory = sqlite3.Row
|
|
630
|
+
connection.execute(
|
|
631
|
+
f"PRAGMA busy_timeout = {int(self.config.busy_timeout_seconds * 1000)}"
|
|
632
|
+
)
|
|
633
|
+
connection.execute("PRAGMA journal_mode = WAL")
|
|
634
|
+
connection.execute("PRAGMA synchronous = FULL")
|
|
635
|
+
yield connection
|
|
636
|
+
except ForgeRequestLedgerError:
|
|
637
|
+
raise
|
|
638
|
+
except (OSError, sqlite3.Error) as exc:
|
|
639
|
+
raise ForgeRequestStorageError("Durable Forge plan request storage failed.") from exc
|
|
640
|
+
finally:
|
|
641
|
+
if connection is not None:
|
|
642
|
+
connection.close()
|
|
643
|
+
|
|
644
|
+
@contextmanager
|
|
645
|
+
def _transaction(self):
|
|
646
|
+
with self._connection() as connection:
|
|
647
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
648
|
+
try:
|
|
649
|
+
yield connection
|
|
650
|
+
except BaseException:
|
|
651
|
+
connection.rollback()
|
|
652
|
+
raise
|
|
653
|
+
else:
|
|
654
|
+
connection.commit()
|
|
655
|
+
|
|
656
|
+
def _now(self) -> float:
|
|
657
|
+
try:
|
|
658
|
+
value = float(self._clock())
|
|
659
|
+
except (TypeError, ValueError, OverflowError):
|
|
660
|
+
raise ForgeRequestStorageError("Forge request ledger clock failed.") from None
|
|
661
|
+
if not math.isfinite(value) or value < 0:
|
|
662
|
+
raise ForgeRequestStorageError("Forge request ledger clock failed.")
|
|
663
|
+
return value
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _workspace(value: str | os.PathLike[str]) -> Path:
|
|
667
|
+
try:
|
|
668
|
+
path = Path(value).expanduser().resolve(strict=True)
|
|
669
|
+
except (OSError, RuntimeError, TypeError, ValueError):
|
|
670
|
+
raise ForgeRequestValidationError("Forge request workspace is invalid.") from None
|
|
671
|
+
if not path.is_dir():
|
|
672
|
+
raise ForgeRequestValidationError("Forge request workspace must be a directory.")
|
|
673
|
+
return path
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _identifier(value: Any, label: str, *, maximum: int) -> str:
|
|
677
|
+
clean = str(value or "").strip()
|
|
678
|
+
if not clean or len(clean) > maximum or _IDENTIFIER_RE.fullmatch(clean) is None:
|
|
679
|
+
raise ForgeRequestValidationError(f"Forge request {label} identifier is invalid.")
|
|
680
|
+
return clean
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _lease_token(value: Any) -> str:
|
|
684
|
+
return _identifier(value, "lease", maximum=256)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _error_code(value: Any) -> str:
|
|
688
|
+
clean = str(value or "").strip()
|
|
689
|
+
if not clean or len(clean) > 128 or _ERROR_CODE_RE.fullmatch(clean) is None:
|
|
690
|
+
raise ForgeRequestValidationError("Forge request error code is invalid.")
|
|
691
|
+
return clean
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _duration(value: Any, *, maximum: float, label: str) -> float:
|
|
695
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
696
|
+
raise ForgeRequestValidationError(f"Forge request {label} must be numeric.")
|
|
697
|
+
clean = float(value)
|
|
698
|
+
if not math.isfinite(clean) or clean < MIN_LEASE_SECONDS or clean > maximum:
|
|
699
|
+
raise ForgeRequestValidationError(f"Forge request {label} is outside the allowed range.")
|
|
700
|
+
return clean
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _payload_digest(payload: Mapping[str, Any]) -> str:
|
|
704
|
+
try:
|
|
705
|
+
encoded = json.dumps(
|
|
706
|
+
dict(payload),
|
|
707
|
+
ensure_ascii=False,
|
|
708
|
+
allow_nan=False,
|
|
709
|
+
separators=(",", ":"),
|
|
710
|
+
sort_keys=True,
|
|
711
|
+
).encode("utf-8")
|
|
712
|
+
except (TypeError, ValueError):
|
|
713
|
+
raise ForgeRequestValidationError("Forge request payload is not valid JSON.") from None
|
|
714
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _digest(value: str) -> str:
|
|
718
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
__all__ = [
|
|
722
|
+
"DEFAULT_LEASE_SECONDS",
|
|
723
|
+
"DurableForgeRequestLedger",
|
|
724
|
+
"ForgeDispatchLease",
|
|
725
|
+
"ForgeRequestAcceptance",
|
|
726
|
+
"ForgeRequestIdempotencyConflict",
|
|
727
|
+
"ForgeRequestLeaseLost",
|
|
728
|
+
"ForgeRequestLedgerConfig",
|
|
729
|
+
"ForgeRequestLedgerError",
|
|
730
|
+
"ForgeRequestNotFound",
|
|
731
|
+
"ForgeRequestRecord",
|
|
732
|
+
"ForgeRequestState",
|
|
733
|
+
"ForgeRequestStorageError",
|
|
734
|
+
"ForgeRequestValidationError",
|
|
735
|
+
"ForgeWorkerLease",
|
|
736
|
+
"default_forge_request_ledger_path",
|
|
737
|
+
]
|