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,654 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import socket
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
import weakref
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from contextlib import suppress
|
|
12
|
+
from dataclasses import replace
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Literal
|
|
15
|
+
|
|
16
|
+
from ..atomic_io import atomic_write_json
|
|
17
|
+
from ..forge import RunPaths, now_iso
|
|
18
|
+
from .models import (
|
|
19
|
+
AssetAlreadyExistsError,
|
|
20
|
+
AssetError,
|
|
21
|
+
AssetNotFoundError,
|
|
22
|
+
AssetRecord,
|
|
23
|
+
ComprehensionRecord,
|
|
24
|
+
)
|
|
25
|
+
from .paths import (
|
|
26
|
+
asset_comprehension_current_path,
|
|
27
|
+
asset_comprehension_version_path,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
ASSET_INDEX_SCHEMA_VERSION = 2
|
|
31
|
+
_LOCK_POLL_SECONDS = 0.02
|
|
32
|
+
_LOCK_TIMEOUT_SECONDS = 5.0
|
|
33
|
+
_PROCESS_LOCKS_GUARD = threading.Lock()
|
|
34
|
+
_PROCESS_LOCKS: weakref.WeakValueDictionary[str, threading.Lock] = weakref.WeakValueDictionary()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _process_lock_for_path(path: Path) -> threading.Lock:
|
|
38
|
+
key = os.path.normcase(os.path.abspath(os.fspath(path)))
|
|
39
|
+
with _PROCESS_LOCKS_GUARD:
|
|
40
|
+
lock = _PROCESS_LOCKS.get(key)
|
|
41
|
+
if lock is None:
|
|
42
|
+
lock = threading.Lock()
|
|
43
|
+
_PROCESS_LOCKS[key] = lock
|
|
44
|
+
return lock
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AssetIndex:
|
|
48
|
+
def __init__(self, run_paths: RunPaths) -> None:
|
|
49
|
+
self.run_paths = run_paths
|
|
50
|
+
|
|
51
|
+
def load(self) -> dict[str, Any]:
|
|
52
|
+
path = self.run_paths.assets_index_path
|
|
53
|
+
try:
|
|
54
|
+
raw_text = path.read_text(encoding="utf-8")
|
|
55
|
+
except FileNotFoundError:
|
|
56
|
+
return self._empty_payload()
|
|
57
|
+
try:
|
|
58
|
+
payload = json.loads(raw_text)
|
|
59
|
+
except json.JSONDecodeError as e:
|
|
60
|
+
raise AssetError(f"Invalid asset index JSON: {path}") from e
|
|
61
|
+
if not isinstance(payload, dict):
|
|
62
|
+
raise AssetError(f"Invalid asset index structure: {path}")
|
|
63
|
+
schema_version = int(payload.get("schema_version") or 0)
|
|
64
|
+
if schema_version != ASSET_INDEX_SCHEMA_VERSION:
|
|
65
|
+
raise AssetError(
|
|
66
|
+
"Unsupported asset index schema_version "
|
|
67
|
+
f"{schema_version}; run the legacy asset migration before loading this index."
|
|
68
|
+
)
|
|
69
|
+
assets = payload.get("assets")
|
|
70
|
+
if not isinstance(assets, list):
|
|
71
|
+
raise AssetError("Invalid asset index: assets must be an array")
|
|
72
|
+
by_sha256 = payload.get("by_sha256")
|
|
73
|
+
if not isinstance(by_sha256, dict):
|
|
74
|
+
raise AssetError("Invalid asset index: by_sha256 must be an object")
|
|
75
|
+
return payload
|
|
76
|
+
|
|
77
|
+
def records(self, *, include_deleted: bool = False) -> list[AssetRecord]:
|
|
78
|
+
records = [AssetRecord.from_dict(item) for item in self.load().get("assets", [])]
|
|
79
|
+
if include_deleted:
|
|
80
|
+
return records
|
|
81
|
+
return [record for record in records if record.deleted_at is None]
|
|
82
|
+
|
|
83
|
+
def get(self, asset_id: str, *, include_deleted: bool = False) -> AssetRecord:
|
|
84
|
+
for record in self.records(include_deleted=include_deleted):
|
|
85
|
+
if record.id == asset_id:
|
|
86
|
+
return record
|
|
87
|
+
raise AssetNotFoundError(f"Asset not found: {asset_id}")
|
|
88
|
+
|
|
89
|
+
def find_by_sha256(self, sha256: str, *, include_deleted: bool = False) -> AssetRecord | None:
|
|
90
|
+
digest = sha256.strip().lower()
|
|
91
|
+
for record in self.records(include_deleted=include_deleted):
|
|
92
|
+
if record.sha256 == digest:
|
|
93
|
+
return record
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
def add(
|
|
97
|
+
self,
|
|
98
|
+
record: AssetRecord,
|
|
99
|
+
*,
|
|
100
|
+
dedupe_policy: Literal["reject", "link"] = "reject",
|
|
101
|
+
) -> AssetRecord:
|
|
102
|
+
with _AssetIndexLock(self.run_paths.assets_index_lock_path):
|
|
103
|
+
payload = self.load()
|
|
104
|
+
existing = self._find_active_by_sha(payload, record.sha256)
|
|
105
|
+
if existing is not None:
|
|
106
|
+
if dedupe_policy == "link":
|
|
107
|
+
return existing
|
|
108
|
+
raise AssetAlreadyExistsError(existing.id)
|
|
109
|
+
records = [AssetRecord.from_dict(item) for item in payload.get("assets", [])]
|
|
110
|
+
if any(item.id == record.id for item in records):
|
|
111
|
+
raise AssetError(f"Asset id collision: {record.id}")
|
|
112
|
+
records.append(record)
|
|
113
|
+
self._write_records(records)
|
|
114
|
+
return record
|
|
115
|
+
|
|
116
|
+
def add_with_publish(
|
|
117
|
+
self,
|
|
118
|
+
*,
|
|
119
|
+
sha256: str,
|
|
120
|
+
dedupe_policy: Literal["reject", "link"],
|
|
121
|
+
build_record: Callable[[set[str]], AssetRecord],
|
|
122
|
+
publish: Callable[[AssetRecord], None],
|
|
123
|
+
rollback: Callable[[AssetRecord], None] | None = None,
|
|
124
|
+
) -> AssetRecord:
|
|
125
|
+
with _AssetIndexLock(self.run_paths.assets_index_lock_path):
|
|
126
|
+
payload = self.load()
|
|
127
|
+
existing = self._find_active_by_sha(payload, sha256)
|
|
128
|
+
if existing is not None:
|
|
129
|
+
if dedupe_policy == "link":
|
|
130
|
+
return existing
|
|
131
|
+
raise AssetAlreadyExistsError(existing.id)
|
|
132
|
+
records = [AssetRecord.from_dict(item) for item in payload.get("assets", [])]
|
|
133
|
+
record = build_record({item.id for item in records})
|
|
134
|
+
if any(item.id == record.id for item in records):
|
|
135
|
+
raise AssetError(f"Asset id collision: {record.id}")
|
|
136
|
+
try:
|
|
137
|
+
publish(record)
|
|
138
|
+
records.append(record)
|
|
139
|
+
self._write_records(records)
|
|
140
|
+
except Exception:
|
|
141
|
+
if rollback is not None:
|
|
142
|
+
rollback(record)
|
|
143
|
+
raise
|
|
144
|
+
return record
|
|
145
|
+
|
|
146
|
+
def update(self, record: AssetRecord) -> AssetRecord:
|
|
147
|
+
with _AssetIndexLock(self.run_paths.assets_index_lock_path):
|
|
148
|
+
records = self.records(include_deleted=True)
|
|
149
|
+
updated: list[AssetRecord] = []
|
|
150
|
+
found = False
|
|
151
|
+
for current in records:
|
|
152
|
+
if current.id == record.id:
|
|
153
|
+
updated.append(record)
|
|
154
|
+
found = True
|
|
155
|
+
else:
|
|
156
|
+
updated.append(current)
|
|
157
|
+
if not found:
|
|
158
|
+
raise AssetNotFoundError(f"Asset not found: {record.id}")
|
|
159
|
+
self._write_records(updated)
|
|
160
|
+
return record
|
|
161
|
+
|
|
162
|
+
def delete(self, asset_id: str) -> AssetRecord:
|
|
163
|
+
with _AssetIndexLock(self.run_paths.assets_index_lock_path):
|
|
164
|
+
records = self.records(include_deleted=True)
|
|
165
|
+
updated: list[AssetRecord] = []
|
|
166
|
+
deleted: AssetRecord | None = None
|
|
167
|
+
for current in records:
|
|
168
|
+
if current.id == asset_id:
|
|
169
|
+
deleted = (
|
|
170
|
+
current if current.deleted_at else replace(current, deleted_at=now_iso())
|
|
171
|
+
)
|
|
172
|
+
updated.append(deleted)
|
|
173
|
+
else:
|
|
174
|
+
updated.append(current)
|
|
175
|
+
if deleted is None:
|
|
176
|
+
raise AssetNotFoundError(f"Asset not found: {asset_id}")
|
|
177
|
+
self._write_records(updated)
|
|
178
|
+
return deleted
|
|
179
|
+
|
|
180
|
+
def update_comprehension_version(
|
|
181
|
+
self,
|
|
182
|
+
asset_id: str,
|
|
183
|
+
*,
|
|
184
|
+
status: Literal["ready", "failed", "minimal"],
|
|
185
|
+
version: int,
|
|
186
|
+
) -> AssetRecord:
|
|
187
|
+
current = self.get(asset_id, include_deleted=True)
|
|
188
|
+
return self.update(
|
|
189
|
+
replace(
|
|
190
|
+
current,
|
|
191
|
+
comprehension_status=status,
|
|
192
|
+
comprehension_current_version=version,
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def write_comprehension(self, record: ComprehensionRecord) -> ComprehensionRecord:
|
|
197
|
+
with _AssetIndexLock(self.run_paths.assets_index_lock_path):
|
|
198
|
+
asset = self.get(record.asset_id, include_deleted=True)
|
|
199
|
+
current_version = max(
|
|
200
|
+
int(asset.comprehension_current_version or 0),
|
|
201
|
+
_latest_comprehension_version(self.run_paths, record.asset_id) or 0,
|
|
202
|
+
)
|
|
203
|
+
next_version = current_version + 1
|
|
204
|
+
versioned = replace(record, version=next_version)
|
|
205
|
+
version_path = asset_comprehension_version_path(
|
|
206
|
+
self.run_paths,
|
|
207
|
+
record.asset_id,
|
|
208
|
+
next_version,
|
|
209
|
+
)
|
|
210
|
+
current_path = asset_comprehension_current_path(self.run_paths, record.asset_id)
|
|
211
|
+
atomic_write_json(version_path, versioned.to_dict())
|
|
212
|
+
records = self.records(include_deleted=True)
|
|
213
|
+
updated = [
|
|
214
|
+
replace(
|
|
215
|
+
item,
|
|
216
|
+
comprehension_status=versioned.status,
|
|
217
|
+
comprehension_current_version=next_version,
|
|
218
|
+
)
|
|
219
|
+
if item.id == record.asset_id
|
|
220
|
+
else item
|
|
221
|
+
for item in records
|
|
222
|
+
]
|
|
223
|
+
self._write_records(updated)
|
|
224
|
+
atomic_write_json(
|
|
225
|
+
current_path,
|
|
226
|
+
{
|
|
227
|
+
"asset_id": record.asset_id,
|
|
228
|
+
"updated_at": now_iso(),
|
|
229
|
+
"version": next_version,
|
|
230
|
+
},
|
|
231
|
+
)
|
|
232
|
+
return versioned
|
|
233
|
+
|
|
234
|
+
def read_comprehension(self, asset_id: str, version: int | None = None) -> ComprehensionRecord:
|
|
235
|
+
if version is None:
|
|
236
|
+
candidate_versions: list[int] = []
|
|
237
|
+
pointer_path = asset_comprehension_current_path(self.run_paths, asset_id)
|
|
238
|
+
try:
|
|
239
|
+
pointer = json.loads(pointer_path.read_text(encoding="utf-8"))
|
|
240
|
+
except FileNotFoundError:
|
|
241
|
+
pointer = None
|
|
242
|
+
if pointer is not None:
|
|
243
|
+
if not isinstance(pointer, dict):
|
|
244
|
+
raise AssetError(f"Invalid comprehension pointer: {pointer_path}")
|
|
245
|
+
pointer_version = int(pointer.get("version") or 0)
|
|
246
|
+
if (
|
|
247
|
+
pointer_version > 0
|
|
248
|
+
and asset_comprehension_version_path(
|
|
249
|
+
self.run_paths, asset_id, pointer_version
|
|
250
|
+
).exists()
|
|
251
|
+
):
|
|
252
|
+
candidate_versions.append(pointer_version)
|
|
253
|
+
try:
|
|
254
|
+
asset_version = int(
|
|
255
|
+
self.get(asset_id, include_deleted=True).comprehension_current_version or 0
|
|
256
|
+
)
|
|
257
|
+
except AssetNotFoundError:
|
|
258
|
+
asset_version = 0
|
|
259
|
+
if (
|
|
260
|
+
asset_version > 0
|
|
261
|
+
and asset_comprehension_version_path(
|
|
262
|
+
self.run_paths, asset_id, asset_version
|
|
263
|
+
).exists()
|
|
264
|
+
):
|
|
265
|
+
candidate_versions.append(asset_version)
|
|
266
|
+
latest_version = _latest_comprehension_version(self.run_paths, asset_id)
|
|
267
|
+
if latest_version is not None:
|
|
268
|
+
candidate_versions.append(latest_version)
|
|
269
|
+
if not candidate_versions:
|
|
270
|
+
raise AssetNotFoundError(f"No comprehension exists for asset: {asset_id}")
|
|
271
|
+
version = max(candidate_versions)
|
|
272
|
+
if version <= 0:
|
|
273
|
+
raise AssetNotFoundError(f"No comprehension version exists for asset: {asset_id}")
|
|
274
|
+
path = asset_comprehension_version_path(self.run_paths, asset_id, version)
|
|
275
|
+
try:
|
|
276
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
277
|
+
except FileNotFoundError as e:
|
|
278
|
+
raise AssetNotFoundError(
|
|
279
|
+
f"Comprehension version not found: {asset_id} v{version}"
|
|
280
|
+
) from e
|
|
281
|
+
if not isinstance(payload, dict):
|
|
282
|
+
raise AssetError(f"Invalid comprehension record: {path}")
|
|
283
|
+
return ComprehensionRecord.from_dict(payload)
|
|
284
|
+
|
|
285
|
+
def find_existing_comprehension_by_sha256(
|
|
286
|
+
self,
|
|
287
|
+
sha256: str,
|
|
288
|
+
*,
|
|
289
|
+
model: str | None,
|
|
290
|
+
role: str | None,
|
|
291
|
+
) -> ComprehensionRecord | None:
|
|
292
|
+
digest = sha256.strip().lower()
|
|
293
|
+
for asset in self.records(include_deleted=False):
|
|
294
|
+
if asset.sha256 != digest:
|
|
295
|
+
continue
|
|
296
|
+
try:
|
|
297
|
+
comprehension = self.read_comprehension(asset.id)
|
|
298
|
+
except AssetError:
|
|
299
|
+
continue
|
|
300
|
+
if (
|
|
301
|
+
comprehension.status == "ready"
|
|
302
|
+
and comprehension.model == model
|
|
303
|
+
and comprehension.role == role
|
|
304
|
+
):
|
|
305
|
+
return comprehension
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
def diff_comprehension(
|
|
309
|
+
self,
|
|
310
|
+
asset_id: str,
|
|
311
|
+
version_a: int,
|
|
312
|
+
version_b: int,
|
|
313
|
+
) -> dict[str, Any]:
|
|
314
|
+
left = self.read_comprehension(asset_id, version_a)
|
|
315
|
+
right = self.read_comprehension(asset_id, version_b)
|
|
316
|
+
left_data = left.data
|
|
317
|
+
right_data = right.data
|
|
318
|
+
changed_fields: dict[str, dict[str, Any]] = {}
|
|
319
|
+
for field_name in ("semantic_summary", "relations_hint", "classification", "confidence"):
|
|
320
|
+
left_value = getattr(left_data, field_name)
|
|
321
|
+
right_value = getattr(right_data, field_name)
|
|
322
|
+
if left_value != right_value:
|
|
323
|
+
changed_fields[field_name] = {"from": left_value, "to": right_value}
|
|
324
|
+
return {
|
|
325
|
+
"asset_id": asset_id,
|
|
326
|
+
"version_a": version_a,
|
|
327
|
+
"version_b": version_b,
|
|
328
|
+
"changed_fields": changed_fields,
|
|
329
|
+
"key_entities": _list_diff(left_data.key_entities, right_data.key_entities),
|
|
330
|
+
"stated_facts": _list_diff(left_data.stated_facts, right_data.stated_facts),
|
|
331
|
+
"stated_decisions": _list_diff(left_data.stated_decisions, right_data.stated_decisions),
|
|
332
|
+
"stated_constraints": _list_diff(
|
|
333
|
+
left_data.stated_constraints, right_data.stated_constraints
|
|
334
|
+
),
|
|
335
|
+
"actionable_signals": _list_diff(
|
|
336
|
+
left_data.actionable_signals,
|
|
337
|
+
right_data.actionable_signals,
|
|
338
|
+
),
|
|
339
|
+
"open_questions": _list_diff(left_data.open_questions, right_data.open_questions),
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
def _empty_payload(self) -> dict[str, Any]:
|
|
343
|
+
return {
|
|
344
|
+
"schema_version": ASSET_INDEX_SCHEMA_VERSION,
|
|
345
|
+
"run_id": self.run_paths.run_id,
|
|
346
|
+
"updated_at": now_iso(),
|
|
347
|
+
"assets": [],
|
|
348
|
+
"by_sha256": {},
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
def _write_records(self, records: list[AssetRecord]) -> None:
|
|
352
|
+
sorted_records = sorted(records, key=lambda item: (item.added_at, item.id))
|
|
353
|
+
by_sha256 = {
|
|
354
|
+
record.sha256: record.id
|
|
355
|
+
for record in sorted_records
|
|
356
|
+
if record.deleted_at is None and record.sha256
|
|
357
|
+
}
|
|
358
|
+
atomic_write_json(
|
|
359
|
+
self.run_paths.assets_index_path,
|
|
360
|
+
{
|
|
361
|
+
"schema_version": ASSET_INDEX_SCHEMA_VERSION,
|
|
362
|
+
"run_id": self.run_paths.run_id,
|
|
363
|
+
"updated_at": now_iso(),
|
|
364
|
+
"assets": [record.to_dict() for record in sorted_records],
|
|
365
|
+
"by_sha256": by_sha256,
|
|
366
|
+
},
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
@staticmethod
|
|
370
|
+
def _find_active_by_sha(payload: dict[str, Any], sha256: str) -> AssetRecord | None:
|
|
371
|
+
digest = sha256.strip().lower()
|
|
372
|
+
for item in payload.get("assets", []):
|
|
373
|
+
if not isinstance(item, dict):
|
|
374
|
+
continue
|
|
375
|
+
record = AssetRecord.from_dict(item)
|
|
376
|
+
if record.deleted_at is None and record.sha256 == digest:
|
|
377
|
+
return record
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
class _AssetIndexLock:
|
|
382
|
+
def __init__(self, path: Path) -> None:
|
|
383
|
+
self.path = path
|
|
384
|
+
self.recovery_path = path.with_name(f"{path.name}.recovering")
|
|
385
|
+
self.owner_token = uuid.uuid4().hex
|
|
386
|
+
self._process_lock = _process_lock_for_path(path) if os.name == "nt" else None
|
|
387
|
+
self._process_lock_acquired = False
|
|
388
|
+
|
|
389
|
+
def __enter__(self) -> _AssetIndexLock:
|
|
390
|
+
if self._process_lock is not None:
|
|
391
|
+
self._process_lock.acquire()
|
|
392
|
+
self._process_lock_acquired = True
|
|
393
|
+
try:
|
|
394
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
395
|
+
payload = _build_lock_metadata(owner_token=self.owner_token, kind="lock")
|
|
396
|
+
payload_text = _metadata_text(payload)
|
|
397
|
+
deadline = time.monotonic() + _LOCK_TIMEOUT_SECONDS
|
|
398
|
+
while True:
|
|
399
|
+
_clear_stale_recovery_claim(self.recovery_path)
|
|
400
|
+
try:
|
|
401
|
+
_write_exclusive(self.path, payload_text)
|
|
402
|
+
return self
|
|
403
|
+
except OSError as exc:
|
|
404
|
+
if not _is_existing_lock_contention(exc, self.path):
|
|
405
|
+
raise
|
|
406
|
+
existing_metadata, existing_text = _read_lock_metadata_with_text(self.path)
|
|
407
|
+
if (
|
|
408
|
+
existing_metadata is not None
|
|
409
|
+
and existing_text is not None
|
|
410
|
+
and _lock_metadata_is_stale(existing_metadata)
|
|
411
|
+
and self._recover_stale_lock(
|
|
412
|
+
existing_metadata=existing_metadata,
|
|
413
|
+
existing_text=existing_text,
|
|
414
|
+
replacement_text=payload_text,
|
|
415
|
+
)
|
|
416
|
+
):
|
|
417
|
+
return self
|
|
418
|
+
if time.monotonic() >= deadline:
|
|
419
|
+
raise AssetError(
|
|
420
|
+
f"Timed out waiting for asset index lock: {self.path}"
|
|
421
|
+
) from None
|
|
422
|
+
time.sleep(_LOCK_POLL_SECONDS)
|
|
423
|
+
except BaseException:
|
|
424
|
+
self._release_process_lock()
|
|
425
|
+
raise
|
|
426
|
+
|
|
427
|
+
def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
|
|
428
|
+
_ = exc_type
|
|
429
|
+
_ = exc
|
|
430
|
+
_ = tb
|
|
431
|
+
self.release()
|
|
432
|
+
|
|
433
|
+
def release(self) -> None:
|
|
434
|
+
try:
|
|
435
|
+
deadline = time.monotonic() + _LOCK_TIMEOUT_SECONDS
|
|
436
|
+
while True:
|
|
437
|
+
try:
|
|
438
|
+
text = self.path.read_text(encoding="utf-8")
|
|
439
|
+
except FileNotFoundError:
|
|
440
|
+
return
|
|
441
|
+
except PermissionError:
|
|
442
|
+
if time.monotonic() >= deadline:
|
|
443
|
+
raise AssetError(
|
|
444
|
+
f"Timed out releasing asset index lock: {self.path}"
|
|
445
|
+
) from None
|
|
446
|
+
time.sleep(_LOCK_POLL_SECONDS / 4)
|
|
447
|
+
continue
|
|
448
|
+
try:
|
|
449
|
+
metadata = json.loads(text)
|
|
450
|
+
except json.JSONDecodeError:
|
|
451
|
+
return
|
|
452
|
+
if not isinstance(metadata, dict):
|
|
453
|
+
return
|
|
454
|
+
if str(metadata.get("owner_token") or "") != self.owner_token:
|
|
455
|
+
return
|
|
456
|
+
try:
|
|
457
|
+
self.path.unlink()
|
|
458
|
+
return
|
|
459
|
+
except FileNotFoundError:
|
|
460
|
+
return
|
|
461
|
+
except PermissionError:
|
|
462
|
+
# Windows readers do not share delete access by default. A
|
|
463
|
+
# waiter inspecting this lock can therefore make unlink
|
|
464
|
+
# fail briefly even after the creating file descriptor is
|
|
465
|
+
# closed.
|
|
466
|
+
if time.monotonic() >= deadline:
|
|
467
|
+
raise AssetError(
|
|
468
|
+
f"Timed out releasing asset index lock: {self.path}"
|
|
469
|
+
) from None
|
|
470
|
+
time.sleep(_LOCK_POLL_SECONDS / 4)
|
|
471
|
+
finally:
|
|
472
|
+
self._release_process_lock()
|
|
473
|
+
|
|
474
|
+
def _release_process_lock(self) -> None:
|
|
475
|
+
if not self._process_lock_acquired:
|
|
476
|
+
return
|
|
477
|
+
self._process_lock_acquired = False
|
|
478
|
+
if self._process_lock is not None:
|
|
479
|
+
self._process_lock.release()
|
|
480
|
+
|
|
481
|
+
def _recover_stale_lock(
|
|
482
|
+
self,
|
|
483
|
+
*,
|
|
484
|
+
existing_metadata: dict[str, Any],
|
|
485
|
+
existing_text: str,
|
|
486
|
+
replacement_text: str,
|
|
487
|
+
) -> bool:
|
|
488
|
+
recovery_payload = _build_lock_metadata(
|
|
489
|
+
owner_token=self.owner_token,
|
|
490
|
+
kind="recovery",
|
|
491
|
+
recovery_reason="stale asset index lock",
|
|
492
|
+
observed_owner_token=str(existing_metadata.get("owner_token") or ""),
|
|
493
|
+
)
|
|
494
|
+
try:
|
|
495
|
+
_write_exclusive(self.recovery_path, _metadata_text(recovery_payload))
|
|
496
|
+
except OSError as exc:
|
|
497
|
+
if not _is_existing_lock_contention(exc, self.recovery_path):
|
|
498
|
+
raise
|
|
499
|
+
_clear_stale_recovery_claim(self.recovery_path)
|
|
500
|
+
return False
|
|
501
|
+
try:
|
|
502
|
+
current_metadata, current_text = _read_lock_metadata_with_text(self.path)
|
|
503
|
+
if current_metadata is None or current_text != existing_text:
|
|
504
|
+
return False
|
|
505
|
+
if not _lock_metadata_is_stale(current_metadata):
|
|
506
|
+
return False
|
|
507
|
+
try:
|
|
508
|
+
self.path.unlink()
|
|
509
|
+
except FileNotFoundError:
|
|
510
|
+
return False
|
|
511
|
+
try:
|
|
512
|
+
_write_exclusive(self.path, replacement_text)
|
|
513
|
+
except OSError as exc:
|
|
514
|
+
if not _is_existing_lock_contention(exc, self.path):
|
|
515
|
+
raise
|
|
516
|
+
return False
|
|
517
|
+
return True
|
|
518
|
+
finally:
|
|
519
|
+
recovery_metadata = _read_lock_metadata(self.recovery_path)
|
|
520
|
+
if str((recovery_metadata or {}).get("owner_token") or "") == self.owner_token:
|
|
521
|
+
with suppress(FileNotFoundError):
|
|
522
|
+
self.recovery_path.unlink()
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _write_exclusive(path: Path, text: str) -> None:
|
|
526
|
+
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
|
|
527
|
+
fd = os.open(path, flags, 0o644)
|
|
528
|
+
try:
|
|
529
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle:
|
|
530
|
+
handle.write(text)
|
|
531
|
+
handle.flush()
|
|
532
|
+
os.fsync(handle.fileno())
|
|
533
|
+
except Exception:
|
|
534
|
+
with suppress(FileNotFoundError):
|
|
535
|
+
path.unlink()
|
|
536
|
+
raise
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _is_existing_lock_contention(error: OSError, path: Path) -> bool:
|
|
540
|
+
"""Return true when exclusive-create lost a race to an existing lock.
|
|
541
|
+
|
|
542
|
+
POSIX reports ``EEXIST``/``FileExistsError`` for this case. Windows can
|
|
543
|
+
instead report ``EACCES``/``PermissionError`` while the winning thread is
|
|
544
|
+
still writing the newly created file. Treat that Windows-only shape as
|
|
545
|
+
normal contention only when the target now exists; genuine permission
|
|
546
|
+
failures on an absent target must continue to propagate.
|
|
547
|
+
"""
|
|
548
|
+
|
|
549
|
+
return isinstance(error, FileExistsError) or (
|
|
550
|
+
os.name == "nt" and isinstance(error, PermissionError) and path.exists()
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _build_lock_metadata(
|
|
555
|
+
*,
|
|
556
|
+
owner_token: str,
|
|
557
|
+
kind: str,
|
|
558
|
+
recovery_reason: str | None = None,
|
|
559
|
+
observed_owner_token: str | None = None,
|
|
560
|
+
) -> dict[str, Any]:
|
|
561
|
+
payload: dict[str, Any] = {
|
|
562
|
+
"schema_version": 1,
|
|
563
|
+
"pid": os.getpid(),
|
|
564
|
+
"hostname": socket.gethostname(),
|
|
565
|
+
"owner_token": owner_token,
|
|
566
|
+
"acquired_at": now_iso(),
|
|
567
|
+
"kind": kind,
|
|
568
|
+
}
|
|
569
|
+
if recovery_reason:
|
|
570
|
+
payload["recovery_reason"] = recovery_reason
|
|
571
|
+
if observed_owner_token:
|
|
572
|
+
payload["observed_owner_token"] = observed_owner_token
|
|
573
|
+
return payload
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _metadata_text(payload: dict[str, Any]) -> str:
|
|
577
|
+
return json.dumps(payload, indent=2, sort_keys=True) + "\n"
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _read_lock_metadata(path: Path) -> dict[str, Any] | None:
|
|
581
|
+
metadata, _text = _read_lock_metadata_with_text(path)
|
|
582
|
+
return metadata
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _read_lock_metadata_with_text(path: Path) -> tuple[dict[str, Any] | None, str | None]:
|
|
586
|
+
try:
|
|
587
|
+
text = path.read_text(encoding="utf-8")
|
|
588
|
+
raw = json.loads(text)
|
|
589
|
+
except (FileNotFoundError, OSError, json.JSONDecodeError):
|
|
590
|
+
return None, None
|
|
591
|
+
return (raw, text) if isinstance(raw, dict) else (None, text)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _clear_stale_recovery_claim(path: Path) -> None:
|
|
595
|
+
metadata = _read_lock_metadata(path)
|
|
596
|
+
if metadata is not None and _lock_metadata_is_stale(metadata):
|
|
597
|
+
with suppress(FileNotFoundError):
|
|
598
|
+
path.unlink()
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _lock_is_stale(path: Path) -> bool:
|
|
602
|
+
metadata = _read_lock_metadata(path)
|
|
603
|
+
return metadata is not None and _lock_metadata_is_stale(metadata)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _lock_metadata_is_stale(metadata: dict[str, Any]) -> bool:
|
|
607
|
+
if not metadata or str(metadata.get("hostname") or "") != socket.gethostname():
|
|
608
|
+
return False
|
|
609
|
+
try:
|
|
610
|
+
pid = int(metadata.get("pid") or 0)
|
|
611
|
+
except (TypeError, ValueError):
|
|
612
|
+
return False
|
|
613
|
+
if pid <= 0:
|
|
614
|
+
return False
|
|
615
|
+
try:
|
|
616
|
+
os.kill(pid, 0)
|
|
617
|
+
except ProcessLookupError:
|
|
618
|
+
return True
|
|
619
|
+
except (PermissionError, OSError):
|
|
620
|
+
return False
|
|
621
|
+
return False
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _latest_comprehension_version(run_paths: RunPaths, asset_id: str) -> int | None:
|
|
625
|
+
directory = run_paths.assets_comprehensions_dir / asset_id
|
|
626
|
+
try:
|
|
627
|
+
candidates = list(directory.glob("v*.json"))
|
|
628
|
+
except OSError:
|
|
629
|
+
return None
|
|
630
|
+
versions: list[int] = []
|
|
631
|
+
for path in candidates:
|
|
632
|
+
stem = path.stem
|
|
633
|
+
if not stem.startswith("v"):
|
|
634
|
+
continue
|
|
635
|
+
try:
|
|
636
|
+
version = int(stem[1:])
|
|
637
|
+
except ValueError:
|
|
638
|
+
continue
|
|
639
|
+
if version > 0:
|
|
640
|
+
versions.append(version)
|
|
641
|
+
return max(versions) if versions else None
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _list_diff(left: list[Any], right: list[Any]) -> dict[str, list[Any]]:
|
|
645
|
+
left_map = {_stable_item_key(item): item for item in left}
|
|
646
|
+
right_map = {_stable_item_key(item): item for item in right}
|
|
647
|
+
return {
|
|
648
|
+
"added": [right_map[key] for key in sorted(set(right_map) - set(left_map))],
|
|
649
|
+
"removed": [left_map[key] for key in sorted(set(left_map) - set(right_map))],
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _stable_item_key(value: Any) -> str:
|
|
654
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True)
|