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,1721 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import sqlite3
|
|
9
|
+
import stat
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
import threading
|
|
13
|
+
import uuid
|
|
14
|
+
from collections.abc import Iterable, Iterator
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from ..branding import canonical_user_data_dir, env_get
|
|
22
|
+
from ..git_safe import build_git_process_env
|
|
23
|
+
from ..tools.fs import classify_sensitive_path
|
|
24
|
+
|
|
25
|
+
_SESSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
|
26
|
+
_KINDS = frozenset({"baseline", "turn", "step", "revert", "redo"})
|
|
27
|
+
_MAX_DIFF_BYTES = 2 * 1024 * 1024
|
|
28
|
+
_DEFAULT_MAX_FILE_BYTES = 25 * 1024 * 1024
|
|
29
|
+
_DEFAULT_MAX_SNAPSHOT_BYTES = 256 * 1024 * 1024
|
|
30
|
+
_ZERO_OID = "0" * 40
|
|
31
|
+
_METADATA_PREFIX = "Alysis Code-Checkpoint-Metadata: "
|
|
32
|
+
_PENDING_OPERATION_VERSION = 1
|
|
33
|
+
_CHECKPOINT_METADATA_VERSION = 2
|
|
34
|
+
_MAX_OMITTED_PATHS = 10_000
|
|
35
|
+
_ALL_PATHS_NON_REVERTIBLE = "*"
|
|
36
|
+
_EXCLUDES = """\
|
|
37
|
+
/.git/
|
|
38
|
+
/.alysis/
|
|
39
|
+
**/.env
|
|
40
|
+
**/.env.*
|
|
41
|
+
!**/.env.example
|
|
42
|
+
!**/.env.sample
|
|
43
|
+
!**/.env.template
|
|
44
|
+
**/*.pem
|
|
45
|
+
**/*.key
|
|
46
|
+
**/*.p12
|
|
47
|
+
**/*.pfx
|
|
48
|
+
**/id_rsa
|
|
49
|
+
**/id_ed25519
|
|
50
|
+
**/credentials.json
|
|
51
|
+
**/service-account*.json
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ChangeLedgerError(RuntimeError):
|
|
56
|
+
"""A safe, user-displayable checkpoint failure."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class StaleWorkspaceError(ChangeLedgerError):
|
|
60
|
+
"""The workspace no longer matches the state being reversed."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class ChangeRecord:
|
|
65
|
+
status: str
|
|
66
|
+
path: str
|
|
67
|
+
previous_path: str | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class Checkpoint:
|
|
72
|
+
checkpoint_id: str
|
|
73
|
+
session_id: str
|
|
74
|
+
turn_id: str | None
|
|
75
|
+
step_id: str | None
|
|
76
|
+
parent_id: str | None
|
|
77
|
+
commit_oid: str
|
|
78
|
+
kind: str
|
|
79
|
+
created_at: str
|
|
80
|
+
message: str
|
|
81
|
+
changes: tuple[ChangeRecord, ...]
|
|
82
|
+
omitted_paths: tuple[str, ...]
|
|
83
|
+
non_revertible_paths: tuple[str, ...]
|
|
84
|
+
reverts_id: str | None = None
|
|
85
|
+
redoes_id: str | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class _StageResult:
|
|
90
|
+
omitted_paths: tuple[str, ...]
|
|
91
|
+
non_revertible_paths: tuple[str, ...]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class _RestoreMutation:
|
|
96
|
+
rel_path: str
|
|
97
|
+
before_identity: tuple[str, str] | None
|
|
98
|
+
after_identity: tuple[str, str] | None
|
|
99
|
+
displaced_path: Path | None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ChangeLedger:
|
|
103
|
+
"""Durable, external Git-backed checkpoints for one workspace.
|
|
104
|
+
|
|
105
|
+
The object database, index, refs, and metadata live below the Alysis Code user
|
|
106
|
+
data directory (or an injected test directory). The developer's repository,
|
|
107
|
+
index, branches, configuration, and hooks are never used or changed.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
workspace_root: Path,
|
|
113
|
+
*,
|
|
114
|
+
storage_root: Path | None = None,
|
|
115
|
+
max_file_bytes: int = _DEFAULT_MAX_FILE_BYTES,
|
|
116
|
+
max_snapshot_bytes: int = _DEFAULT_MAX_SNAPSHOT_BYTES,
|
|
117
|
+
git_timeout_seconds: float = 30.0,
|
|
118
|
+
) -> None:
|
|
119
|
+
self.workspace_root = workspace_root.expanduser().resolve(strict=True)
|
|
120
|
+
if not self.workspace_root.is_dir():
|
|
121
|
+
raise ChangeLedgerError("Checkpoint workspace must be a directory.")
|
|
122
|
+
workspace_key = hashlib.sha256(
|
|
123
|
+
os.path.normcase(os.fspath(self.workspace_root)).encode("utf-8")
|
|
124
|
+
).hexdigest()[:24]
|
|
125
|
+
override = str(env_get("ALYSIS_DATA_DIR") or "").strip()
|
|
126
|
+
default_data_dir = Path(override).expanduser() if override else canonical_user_data_dir()
|
|
127
|
+
base = (storage_root or default_data_dir / "checkpoints").expanduser().resolve()
|
|
128
|
+
try:
|
|
129
|
+
base.relative_to(self.workspace_root)
|
|
130
|
+
except ValueError:
|
|
131
|
+
pass
|
|
132
|
+
else:
|
|
133
|
+
raise ChangeLedgerError("Checkpoint storage must be outside the workspace.")
|
|
134
|
+
self.storage_dir = base / workspace_key
|
|
135
|
+
self.git_dir = self.storage_dir / "objects.git"
|
|
136
|
+
self.index_dir = self.storage_dir / "indexes"
|
|
137
|
+
self.database_path = self.storage_dir / "ledger.sqlite3"
|
|
138
|
+
self.pending_operation_path = self.storage_dir / "pending-operation.json"
|
|
139
|
+
self.max_file_bytes = max(1, int(max_file_bytes))
|
|
140
|
+
self.max_snapshot_bytes = max(self.max_file_bytes, int(max_snapshot_bytes))
|
|
141
|
+
self.git_timeout_seconds = max(1.0, float(git_timeout_seconds))
|
|
142
|
+
self._lock = threading.RLock()
|
|
143
|
+
self._initialize()
|
|
144
|
+
|
|
145
|
+
def ensure_baseline(self, session_id: str) -> Checkpoint:
|
|
146
|
+
session = _validate_session_id(session_id)
|
|
147
|
+
with self._lock:
|
|
148
|
+
self._reconcile_storage(session_id=session)
|
|
149
|
+
with self._lock, self._connect() as db:
|
|
150
|
+
db.execute("BEGIN IMMEDIATE")
|
|
151
|
+
owner = self._lineage_owner_locked(db, session)
|
|
152
|
+
current = self._head_locked(db, owner)
|
|
153
|
+
if current is not None:
|
|
154
|
+
db.commit()
|
|
155
|
+
return current
|
|
156
|
+
result = self._capture_locked(
|
|
157
|
+
db,
|
|
158
|
+
session_id=owner,
|
|
159
|
+
turn_id=None,
|
|
160
|
+
step_id=None,
|
|
161
|
+
kind="baseline",
|
|
162
|
+
message="Initial workspace baseline",
|
|
163
|
+
paths=None,
|
|
164
|
+
)
|
|
165
|
+
db.commit()
|
|
166
|
+
return result
|
|
167
|
+
|
|
168
|
+
def capture(
|
|
169
|
+
self,
|
|
170
|
+
session_id: str,
|
|
171
|
+
*,
|
|
172
|
+
turn_id: str | None,
|
|
173
|
+
step_id: str | None = None,
|
|
174
|
+
kind: str = "turn",
|
|
175
|
+
message: str = "",
|
|
176
|
+
paths: Iterable[str],
|
|
177
|
+
) -> Checkpoint:
|
|
178
|
+
session = _validate_session_id(session_id)
|
|
179
|
+
normalized_kind = str(kind or "").strip().lower()
|
|
180
|
+
if normalized_kind not in _KINDS:
|
|
181
|
+
raise ChangeLedgerError("Unsupported checkpoint kind.")
|
|
182
|
+
scoped_paths = _normalize_scope_paths(self.workspace_root, paths)
|
|
183
|
+
with self._lock:
|
|
184
|
+
self._reconcile_storage(session_id=session)
|
|
185
|
+
with self._lock, self._connect() as db:
|
|
186
|
+
db.execute("BEGIN IMMEDIATE")
|
|
187
|
+
owner = self._lineage_owner_locked(db, session)
|
|
188
|
+
result = self._capture_locked(
|
|
189
|
+
db,
|
|
190
|
+
session_id=owner,
|
|
191
|
+
turn_id=_bounded_label(turn_id),
|
|
192
|
+
step_id=_bounded_label(step_id),
|
|
193
|
+
kind=normalized_kind,
|
|
194
|
+
message=_bounded_message(message),
|
|
195
|
+
paths=scoped_paths,
|
|
196
|
+
)
|
|
197
|
+
db.commit()
|
|
198
|
+
return result
|
|
199
|
+
|
|
200
|
+
def list(self, session_id: str, *, limit: int = 100) -> tuple[Checkpoint, ...]:
|
|
201
|
+
session = _validate_session_id(session_id)
|
|
202
|
+
count = min(500, max(1, int(limit)))
|
|
203
|
+
with self._lock:
|
|
204
|
+
self._reconcile_storage(session_id=session)
|
|
205
|
+
with self._connect() as db:
|
|
206
|
+
owner = self._lineage_owner_locked(db, session)
|
|
207
|
+
rows = db.execute(
|
|
208
|
+
"SELECT * FROM checkpoints WHERE session_id = ? ORDER BY sequence DESC LIMIT ?",
|
|
209
|
+
(owner, count),
|
|
210
|
+
).fetchall()
|
|
211
|
+
return tuple(self._checkpoint_from_row(row) for row in rows)
|
|
212
|
+
|
|
213
|
+
def head(self, session_id: str) -> Checkpoint | None:
|
|
214
|
+
session = _validate_session_id(session_id)
|
|
215
|
+
with self._lock:
|
|
216
|
+
self._reconcile_storage(session_id=session)
|
|
217
|
+
with self._connect() as db:
|
|
218
|
+
owner = self._lineage_owner_locked(db, session)
|
|
219
|
+
return self._head_locked(db, owner)
|
|
220
|
+
|
|
221
|
+
def diff(
|
|
222
|
+
self,
|
|
223
|
+
session_id: str,
|
|
224
|
+
checkpoint_id: str,
|
|
225
|
+
*,
|
|
226
|
+
max_bytes: int = _MAX_DIFF_BYTES,
|
|
227
|
+
) -> dict[str, Any]:
|
|
228
|
+
checkpoint = self.get_for_session(session_id, checkpoint_id)
|
|
229
|
+
if checkpoint.parent_id is None:
|
|
230
|
+
args = ["show", "--format=", "--binary", checkpoint.commit_oid]
|
|
231
|
+
else:
|
|
232
|
+
parent = self.get_for_session(session_id, checkpoint.parent_id)
|
|
233
|
+
args = ["diff", "--binary", "--no-ext-diff", parent.commit_oid, checkpoint.commit_oid]
|
|
234
|
+
raw = self._git(args, check=True).stdout
|
|
235
|
+
cap = min(16 * 1024 * 1024, max(1024, int(max_bytes)))
|
|
236
|
+
truncated = len(raw) > cap
|
|
237
|
+
visible = raw[:cap]
|
|
238
|
+
return {
|
|
239
|
+
"checkpoint_id": checkpoint.checkpoint_id,
|
|
240
|
+
"diff": visible.decode("utf-8", errors="replace"),
|
|
241
|
+
"truncated": truncated,
|
|
242
|
+
"original_bytes": len(raw),
|
|
243
|
+
"changes": [record.__dict__ for record in checkpoint.changes],
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
def get(self, checkpoint_id: str) -> Checkpoint:
|
|
247
|
+
clean = str(checkpoint_id or "").strip()
|
|
248
|
+
with self._connect() as db:
|
|
249
|
+
row = db.execute(
|
|
250
|
+
"SELECT * FROM checkpoints WHERE checkpoint_id = ?", (clean,)
|
|
251
|
+
).fetchone()
|
|
252
|
+
if row is None:
|
|
253
|
+
raise ChangeLedgerError("Checkpoint was not found.")
|
|
254
|
+
return self._checkpoint_from_row(row)
|
|
255
|
+
|
|
256
|
+
def get_for_session(self, session_id: str, checkpoint_id: str) -> Checkpoint:
|
|
257
|
+
session = _validate_session_id(session_id)
|
|
258
|
+
clean = str(checkpoint_id or "").strip()
|
|
259
|
+
with self._lock:
|
|
260
|
+
self._reconcile_storage(session_id=session)
|
|
261
|
+
with self._connect() as db:
|
|
262
|
+
owner = self._lineage_owner_locked(db, session)
|
|
263
|
+
row = db.execute(
|
|
264
|
+
"SELECT * FROM checkpoints WHERE checkpoint_id = ? AND session_id = ?",
|
|
265
|
+
(clean, owner),
|
|
266
|
+
).fetchone()
|
|
267
|
+
if row is None:
|
|
268
|
+
raise ChangeLedgerError("Checkpoint was not found for this session.")
|
|
269
|
+
return self._checkpoint_from_row(row)
|
|
270
|
+
|
|
271
|
+
def adopt_session(self, session_id: str, source_session_id: str) -> str | None:
|
|
272
|
+
"""Bind a fresh session to an existing durable checkpoint lineage.
|
|
273
|
+
|
|
274
|
+
The binding is explicit, durable, and one-way. A session that already
|
|
275
|
+
captured a turn cannot silently exchange its history for another one.
|
|
276
|
+
Repeating the same adoption is idempotent.
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
session = _validate_session_id(session_id)
|
|
280
|
+
source = _validate_session_id(source_session_id)
|
|
281
|
+
with self._lock:
|
|
282
|
+
self._reconcile_storage(session_id=source)
|
|
283
|
+
with self._connect() as db:
|
|
284
|
+
db.execute("BEGIN IMMEDIATE")
|
|
285
|
+
source_owner = self._lineage_owner_locked(db, source)
|
|
286
|
+
source_head = self._head_locked(db, source_owner)
|
|
287
|
+
if source_head is None:
|
|
288
|
+
db.commit()
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
alias = db.execute(
|
|
292
|
+
"SELECT owner_session_id FROM session_lineages WHERE session_id = ?",
|
|
293
|
+
(session,),
|
|
294
|
+
).fetchone()
|
|
295
|
+
if alias is not None:
|
|
296
|
+
existing_owner = self._lineage_owner_locked(db, session)
|
|
297
|
+
if existing_owner != source_owner:
|
|
298
|
+
raise ChangeLedgerError(
|
|
299
|
+
"Session is already bound to a different checkpoint lineage."
|
|
300
|
+
)
|
|
301
|
+
db.commit()
|
|
302
|
+
return source_owner
|
|
303
|
+
|
|
304
|
+
if session == source_owner:
|
|
305
|
+
db.commit()
|
|
306
|
+
return source_owner
|
|
307
|
+
|
|
308
|
+
own_rows = db.execute(
|
|
309
|
+
"SELECT kind, parent_id FROM checkpoints WHERE session_id = ? "
|
|
310
|
+
"ORDER BY sequence ASC",
|
|
311
|
+
(session,),
|
|
312
|
+
).fetchall()
|
|
313
|
+
if own_rows and not (
|
|
314
|
+
len(own_rows) == 1
|
|
315
|
+
and own_rows[0]["kind"] == "baseline"
|
|
316
|
+
and own_rows[0]["parent_id"] is None
|
|
317
|
+
):
|
|
318
|
+
raise ChangeLedgerError(
|
|
319
|
+
"Session already has checkpoint history and cannot adopt another lineage."
|
|
320
|
+
)
|
|
321
|
+
db.execute(
|
|
322
|
+
"INSERT INTO session_lineages(session_id, owner_session_id) VALUES (?, ?)",
|
|
323
|
+
(session, source_owner),
|
|
324
|
+
)
|
|
325
|
+
db.commit()
|
|
326
|
+
return source_owner
|
|
327
|
+
|
|
328
|
+
def revert(self, session_id: str, checkpoint_id: str) -> Checkpoint:
|
|
329
|
+
session = _validate_session_id(session_id)
|
|
330
|
+
with self._lock:
|
|
331
|
+
self._reconcile_storage(session_id=session)
|
|
332
|
+
with self._lock, self._connect() as db:
|
|
333
|
+
db.execute("BEGIN IMMEDIATE")
|
|
334
|
+
owner = self._lineage_owner_locked(db, session)
|
|
335
|
+
target = self._checkpoint_locked(db, checkpoint_id)
|
|
336
|
+
head = self._head_locked(db, owner)
|
|
337
|
+
if (
|
|
338
|
+
target.session_id != owner
|
|
339
|
+
or head is None
|
|
340
|
+
or head.checkpoint_id != target.checkpoint_id
|
|
341
|
+
):
|
|
342
|
+
raise ChangeLedgerError(
|
|
343
|
+
"Only the current session checkpoint can be reverted safely."
|
|
344
|
+
)
|
|
345
|
+
if target.parent_id is None:
|
|
346
|
+
raise ChangeLedgerError("The initial workspace baseline cannot be reverted.")
|
|
347
|
+
parent = self._checkpoint_locked(db, target.parent_id)
|
|
348
|
+
paths = _record_paths(target.changes)
|
|
349
|
+
self._require_paths_revertible(paths, target, parent)
|
|
350
|
+
self._require_paths_match(target.commit_oid, paths)
|
|
351
|
+
pending = self._pending_restore_payload(
|
|
352
|
+
session_id=owner,
|
|
353
|
+
action="revert",
|
|
354
|
+
source=target,
|
|
355
|
+
restore_oid=parent.commit_oid,
|
|
356
|
+
paths=paths,
|
|
357
|
+
turn_id=target.turn_id,
|
|
358
|
+
step_id=target.step_id,
|
|
359
|
+
kind="revert",
|
|
360
|
+
message=f"Revert {target.checkpoint_id}",
|
|
361
|
+
reverts_id=target.checkpoint_id,
|
|
362
|
+
)
|
|
363
|
+
self._write_pending_operation(pending)
|
|
364
|
+
self._restore_paths(parent.commit_oid, paths, rollback_oid=target.commit_oid)
|
|
365
|
+
result = self._capture_locked(
|
|
366
|
+
db,
|
|
367
|
+
session_id=owner,
|
|
368
|
+
turn_id=target.turn_id,
|
|
369
|
+
step_id=target.step_id,
|
|
370
|
+
kind="revert",
|
|
371
|
+
message=f"Revert {target.checkpoint_id}",
|
|
372
|
+
reverts_id=target.checkpoint_id,
|
|
373
|
+
paths=paths,
|
|
374
|
+
checkpoint_id=str(pending["checkpoint_id"]),
|
|
375
|
+
)
|
|
376
|
+
db.commit()
|
|
377
|
+
self.pending_operation_path.unlink(missing_ok=True)
|
|
378
|
+
return result
|
|
379
|
+
|
|
380
|
+
def redo(self, session_id: str) -> Checkpoint:
|
|
381
|
+
session = _validate_session_id(session_id)
|
|
382
|
+
with self._lock:
|
|
383
|
+
self._reconcile_storage(session_id=session)
|
|
384
|
+
with self._lock, self._connect() as db:
|
|
385
|
+
db.execute("BEGIN IMMEDIATE")
|
|
386
|
+
owner = self._lineage_owner_locked(db, session)
|
|
387
|
+
head = self._head_locked(db, owner)
|
|
388
|
+
if head is None or head.kind != "revert" or not head.reverts_id:
|
|
389
|
+
raise ChangeLedgerError("The current checkpoint is not redoable.")
|
|
390
|
+
original = self._checkpoint_locked(db, head.reverts_id)
|
|
391
|
+
paths = _record_paths(original.changes)
|
|
392
|
+
self._require_paths_revertible(paths, original, head)
|
|
393
|
+
self._require_paths_match(head.commit_oid, paths)
|
|
394
|
+
pending = self._pending_restore_payload(
|
|
395
|
+
session_id=owner,
|
|
396
|
+
action="redo",
|
|
397
|
+
source=head,
|
|
398
|
+
restore_oid=original.commit_oid,
|
|
399
|
+
paths=paths,
|
|
400
|
+
turn_id=original.turn_id,
|
|
401
|
+
step_id=original.step_id,
|
|
402
|
+
kind="redo",
|
|
403
|
+
message=f"Redo {original.checkpoint_id}",
|
|
404
|
+
redoes_id=original.checkpoint_id,
|
|
405
|
+
)
|
|
406
|
+
self._write_pending_operation(pending)
|
|
407
|
+
self._restore_paths(original.commit_oid, paths, rollback_oid=head.commit_oid)
|
|
408
|
+
result = self._capture_locked(
|
|
409
|
+
db,
|
|
410
|
+
session_id=owner,
|
|
411
|
+
turn_id=original.turn_id,
|
|
412
|
+
step_id=original.step_id,
|
|
413
|
+
kind="redo",
|
|
414
|
+
message=f"Redo {original.checkpoint_id}",
|
|
415
|
+
redoes_id=original.checkpoint_id,
|
|
416
|
+
paths=paths,
|
|
417
|
+
checkpoint_id=str(pending["checkpoint_id"]),
|
|
418
|
+
)
|
|
419
|
+
db.commit()
|
|
420
|
+
self.pending_operation_path.unlink(missing_ok=True)
|
|
421
|
+
return result
|
|
422
|
+
|
|
423
|
+
def create_branch(self, session_id: str, name: str, checkpoint_id: str) -> str:
|
|
424
|
+
checkpoint = self.get_for_session(session_id, checkpoint_id)
|
|
425
|
+
clean = re.sub(r"[^A-Za-z0-9._/-]+", "-", str(name or "").strip()).strip("-./")
|
|
426
|
+
if not clean or len(clean) > 120 or ".." in clean or clean.endswith(".lock"):
|
|
427
|
+
raise ChangeLedgerError("Invalid checkpoint branch name.")
|
|
428
|
+
ref = f"refs/heads/checkpoints/{clean}"
|
|
429
|
+
self._git(["check-ref-format", ref], check=True)
|
|
430
|
+
self._git(["update-ref", ref, checkpoint.commit_oid], check=True)
|
|
431
|
+
return ref
|
|
432
|
+
|
|
433
|
+
def _initialize(self) -> None:
|
|
434
|
+
self.storage_dir.mkdir(parents=True, exist_ok=True)
|
|
435
|
+
self.index_dir.mkdir(parents=True, exist_ok=True)
|
|
436
|
+
if not self.git_dir.exists():
|
|
437
|
+
result = subprocess.run(
|
|
438
|
+
["git", "init", "--bare", "--quiet", os.fspath(self.git_dir)],
|
|
439
|
+
capture_output=True,
|
|
440
|
+
timeout=self.git_timeout_seconds,
|
|
441
|
+
check=False,
|
|
442
|
+
env=build_git_process_env(),
|
|
443
|
+
)
|
|
444
|
+
if result.returncode != 0:
|
|
445
|
+
raise ChangeLedgerError("Unable to initialize external checkpoint storage.")
|
|
446
|
+
info_dir = self.git_dir / "info"
|
|
447
|
+
info_dir.mkdir(parents=True, exist_ok=True)
|
|
448
|
+
exclude_path = info_dir / "exclude"
|
|
449
|
+
if not exclude_path.exists() or exclude_path.read_text(encoding="utf-8") != _EXCLUDES:
|
|
450
|
+
_atomic_write(exclude_path, _EXCLUDES.encode("utf-8"), mode=None)
|
|
451
|
+
with self._connect() as db:
|
|
452
|
+
db.executescript(
|
|
453
|
+
"""
|
|
454
|
+
CREATE TABLE IF NOT EXISTS checkpoints (
|
|
455
|
+
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
456
|
+
checkpoint_id TEXT NOT NULL UNIQUE,
|
|
457
|
+
session_id TEXT NOT NULL,
|
|
458
|
+
turn_id TEXT,
|
|
459
|
+
step_id TEXT,
|
|
460
|
+
parent_id TEXT,
|
|
461
|
+
commit_oid TEXT NOT NULL,
|
|
462
|
+
kind TEXT NOT NULL,
|
|
463
|
+
created_at TEXT NOT NULL,
|
|
464
|
+
message TEXT NOT NULL,
|
|
465
|
+
changes_json TEXT NOT NULL,
|
|
466
|
+
omitted_json TEXT NOT NULL,
|
|
467
|
+
non_revertible_json TEXT NOT NULL DEFAULT '[]',
|
|
468
|
+
reverts_id TEXT,
|
|
469
|
+
redoes_id TEXT
|
|
470
|
+
);
|
|
471
|
+
CREATE INDEX IF NOT EXISTS checkpoint_session_sequence
|
|
472
|
+
ON checkpoints(session_id, sequence DESC);
|
|
473
|
+
CREATE UNIQUE INDEX IF NOT EXISTS checkpoint_commit_oid
|
|
474
|
+
ON checkpoints(commit_oid);
|
|
475
|
+
CREATE TABLE IF NOT EXISTS session_heads (
|
|
476
|
+
session_id TEXT PRIMARY KEY,
|
|
477
|
+
checkpoint_id TEXT NOT NULL
|
|
478
|
+
);
|
|
479
|
+
CREATE TABLE IF NOT EXISTS session_lineages (
|
|
480
|
+
session_id TEXT PRIMARY KEY,
|
|
481
|
+
owner_session_id TEXT NOT NULL
|
|
482
|
+
);
|
|
483
|
+
CREATE TABLE IF NOT EXISTS ledger_schema (
|
|
484
|
+
key TEXT PRIMARY KEY,
|
|
485
|
+
value TEXT NOT NULL
|
|
486
|
+
);
|
|
487
|
+
"""
|
|
488
|
+
)
|
|
489
|
+
columns = {
|
|
490
|
+
str(row["name"]) for row in db.execute("PRAGMA table_info(checkpoints)").fetchall()
|
|
491
|
+
}
|
|
492
|
+
added_non_revertible_column = "non_revertible_json" not in columns
|
|
493
|
+
if added_non_revertible_column:
|
|
494
|
+
db.execute(
|
|
495
|
+
"ALTER TABLE checkpoints ADD COLUMN non_revertible_json "
|
|
496
|
+
"TEXT NOT NULL DEFAULT '[]'"
|
|
497
|
+
)
|
|
498
|
+
schema_row = db.execute(
|
|
499
|
+
"SELECT value FROM ledger_schema WHERE key = 'omission_safety_version'"
|
|
500
|
+
).fetchone()
|
|
501
|
+
migration_required = schema_row is None or str(schema_row["value"]) != "2"
|
|
502
|
+
self._backfill_non_revertible_metadata_locked(
|
|
503
|
+
db,
|
|
504
|
+
migration_required=migration_required,
|
|
505
|
+
)
|
|
506
|
+
db.execute(
|
|
507
|
+
"INSERT INTO ledger_schema(key, value) VALUES ('omission_safety_version', '2') "
|
|
508
|
+
"ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
|
509
|
+
)
|
|
510
|
+
db.commit()
|
|
511
|
+
self._reconcile_storage()
|
|
512
|
+
|
|
513
|
+
@contextmanager
|
|
514
|
+
def _connect(self) -> Iterator[sqlite3.Connection]:
|
|
515
|
+
db = sqlite3.connect(self.database_path, timeout=30.0)
|
|
516
|
+
try:
|
|
517
|
+
db.row_factory = sqlite3.Row
|
|
518
|
+
db.execute("PRAGMA journal_mode = WAL")
|
|
519
|
+
db.execute("PRAGMA synchronous = FULL")
|
|
520
|
+
db.execute("PRAGMA foreign_keys = ON")
|
|
521
|
+
yield db
|
|
522
|
+
finally:
|
|
523
|
+
db.close()
|
|
524
|
+
|
|
525
|
+
def _backfill_non_revertible_metadata_locked(
|
|
526
|
+
self,
|
|
527
|
+
db: sqlite3.Connection,
|
|
528
|
+
*,
|
|
529
|
+
migration_required: bool,
|
|
530
|
+
) -> None:
|
|
531
|
+
"""Migrate omission safety without pretending old snapshots are complete.
|
|
532
|
+
|
|
533
|
+
Version-one baselines did not catalog every ignored or sensitive path.
|
|
534
|
+
Their exact omission set cannot be reconstructed after the fact, so the
|
|
535
|
+
only honest migration is to make the lineage globally non-revertible.
|
|
536
|
+
New metadata carries an exact, cumulative protected-path set.
|
|
537
|
+
"""
|
|
538
|
+
|
|
539
|
+
if not migration_required:
|
|
540
|
+
return
|
|
541
|
+
|
|
542
|
+
inherited_by_id: dict[str, set[str]] = {}
|
|
543
|
+
rows = db.execute("SELECT * FROM checkpoints ORDER BY sequence ASC").fetchall()
|
|
544
|
+
for row in rows:
|
|
545
|
+
parent_id = str(row["parent_id"] or "")
|
|
546
|
+
protected = set(inherited_by_id.get(parent_id, set()))
|
|
547
|
+
stored = _metadata_path_set(row["non_revertible_json"])
|
|
548
|
+
protected.update(stored)
|
|
549
|
+
metadata, _message, _created_at = self._commit_metadata(str(row["commit_oid"]))
|
|
550
|
+
version = int(metadata.get("version") or 0)
|
|
551
|
+
if row["parent_id"] is None and version < 2:
|
|
552
|
+
protected.add(_ALL_PATHS_NON_REVERTIBLE)
|
|
553
|
+
else:
|
|
554
|
+
omitted = _metadata_path_set(row["omitted_json"])
|
|
555
|
+
for path in omitted:
|
|
556
|
+
if (
|
|
557
|
+
path.endswith("/")
|
|
558
|
+
or self._tree_identity(str(row["commit_oid"]), path) is None
|
|
559
|
+
):
|
|
560
|
+
protected.add(path)
|
|
561
|
+
normalized = sorted(protected)
|
|
562
|
+
db.execute(
|
|
563
|
+
"UPDATE checkpoints SET non_revertible_json = ? WHERE checkpoint_id = ?",
|
|
564
|
+
(json.dumps(normalized, separators=(",", ":")), row["checkpoint_id"]),
|
|
565
|
+
)
|
|
566
|
+
inherited_by_id[str(row["checkpoint_id"])] = set(normalized)
|
|
567
|
+
|
|
568
|
+
def _lineage_owner_locked(self, db: sqlite3.Connection, session_id: str) -> str:
|
|
569
|
+
current = _validate_session_id(session_id)
|
|
570
|
+
seen: set[str] = set()
|
|
571
|
+
while True:
|
|
572
|
+
if current in seen:
|
|
573
|
+
raise ChangeLedgerError("Checkpoint session lineage contains a cycle.")
|
|
574
|
+
seen.add(current)
|
|
575
|
+
row = db.execute(
|
|
576
|
+
"SELECT owner_session_id FROM session_lineages WHERE session_id = ?",
|
|
577
|
+
(current,),
|
|
578
|
+
).fetchone()
|
|
579
|
+
if row is None:
|
|
580
|
+
return current
|
|
581
|
+
current = _validate_session_id(str(row["owner_session_id"] or ""))
|
|
582
|
+
|
|
583
|
+
def _reconcile_storage(self, *, session_id: str | None = None) -> None:
|
|
584
|
+
"""Repair durable ref/database and restore-operation crash windows.
|
|
585
|
+
|
|
586
|
+
Git refs are published before SQLite metadata is committed, while restore
|
|
587
|
+
intents are journaled before any workspace path is displaced. Holding a
|
|
588
|
+
SQLite write transaction here serializes recovery with every live ledger
|
|
589
|
+
mutation; after a process crash, the ref and journal contain enough data
|
|
590
|
+
to either finish the operation or roll its partial workspace changes back.
|
|
591
|
+
"""
|
|
592
|
+
|
|
593
|
+
pending = self._read_pending_operation()
|
|
594
|
+
sessions: set[str] = set()
|
|
595
|
+
if session_id is not None:
|
|
596
|
+
sessions.add(_validate_session_id(session_id))
|
|
597
|
+
if pending is not None:
|
|
598
|
+
sessions.add(_validate_session_id(str(pending.get("session_id") or "")))
|
|
599
|
+
|
|
600
|
+
clear_pending = False
|
|
601
|
+
with self._connect() as db:
|
|
602
|
+
db.execute("BEGIN IMMEDIATE")
|
|
603
|
+
sessions.update(
|
|
604
|
+
str(row["session_id"])
|
|
605
|
+
for row in db.execute("SELECT DISTINCT session_id FROM checkpoints").fetchall()
|
|
606
|
+
)
|
|
607
|
+
for session in sorted(sessions):
|
|
608
|
+
self._reconcile_session_ref_locked(db, session)
|
|
609
|
+
if pending is not None:
|
|
610
|
+
clear_pending = self._reconcile_pending_restore_locked(db, pending)
|
|
611
|
+
db.commit()
|
|
612
|
+
if clear_pending:
|
|
613
|
+
self.pending_operation_path.unlink(missing_ok=True)
|
|
614
|
+
|
|
615
|
+
def _reconcile_session_ref_locked(
|
|
616
|
+
self,
|
|
617
|
+
db: sqlite3.Connection,
|
|
618
|
+
session_id: str,
|
|
619
|
+
) -> None:
|
|
620
|
+
head = self._head_locked(db, session_id)
|
|
621
|
+
ref = f"refs/heads/sessions/{_session_key(session_id)}"
|
|
622
|
+
ref_oid = self._ref_oid(ref)
|
|
623
|
+
if head is None and ref_oid is None:
|
|
624
|
+
return
|
|
625
|
+
if head is not None and ref_oid == head.commit_oid:
|
|
626
|
+
return
|
|
627
|
+
if ref_oid is None:
|
|
628
|
+
assert head is not None
|
|
629
|
+
self._git(["update-ref", ref, head.commit_oid, _ZERO_OID], check=True)
|
|
630
|
+
return
|
|
631
|
+
if head is None:
|
|
632
|
+
commits = self._revision_list(ref_oid)
|
|
633
|
+
self._recover_ref_commits_locked(db, session_id, parent=None, commits=commits)
|
|
634
|
+
return
|
|
635
|
+
if self._is_ancestor(head.commit_oid, ref_oid):
|
|
636
|
+
commits = self._revision_list(f"{head.commit_oid}..{ref_oid}")
|
|
637
|
+
self._recover_ref_commits_locked(db, session_id, parent=head, commits=commits)
|
|
638
|
+
return
|
|
639
|
+
if self._is_ancestor(ref_oid, head.commit_oid):
|
|
640
|
+
self._git(["update-ref", ref, head.commit_oid, ref_oid], check=True)
|
|
641
|
+
return
|
|
642
|
+
raise ChangeLedgerError(
|
|
643
|
+
"Checkpoint metadata and its durable Git ref diverged; refusing unsafe recovery."
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
def _recover_ref_commits_locked(
|
|
647
|
+
self,
|
|
648
|
+
db: sqlite3.Connection,
|
|
649
|
+
session_id: str,
|
|
650
|
+
*,
|
|
651
|
+
parent: Checkpoint | None,
|
|
652
|
+
commits: tuple[str, ...],
|
|
653
|
+
) -> None:
|
|
654
|
+
previous = parent
|
|
655
|
+
previous_oid = parent.commit_oid if parent is not None else None
|
|
656
|
+
for commit_oid in commits:
|
|
657
|
+
commit_parent = self._commit_parent_oid(commit_oid)
|
|
658
|
+
if commit_parent != previous_oid:
|
|
659
|
+
raise ChangeLedgerError(
|
|
660
|
+
"Checkpoint ref history is not a linear continuation of SQLite metadata."
|
|
661
|
+
)
|
|
662
|
+
metadata, fallback_message, committed_at = self._commit_metadata(commit_oid)
|
|
663
|
+
metadata_session = str(metadata.get("session_id") or session_id)
|
|
664
|
+
if metadata_session != session_id:
|
|
665
|
+
raise ChangeLedgerError("Checkpoint ref metadata belongs to another session.")
|
|
666
|
+
checkpoint_id = str(metadata.get("checkpoint_id") or uuid.uuid4())
|
|
667
|
+
existing = db.execute(
|
|
668
|
+
"SELECT * FROM checkpoints WHERE checkpoint_id = ? OR commit_oid = ?",
|
|
669
|
+
(checkpoint_id, commit_oid),
|
|
670
|
+
).fetchone()
|
|
671
|
+
if existing is not None:
|
|
672
|
+
recovered = self._checkpoint_from_row(existing)
|
|
673
|
+
if recovered.commit_oid != commit_oid or recovered.session_id != session_id:
|
|
674
|
+
raise ChangeLedgerError("Checkpoint recovery found conflicting metadata.")
|
|
675
|
+
previous = recovered
|
|
676
|
+
previous_oid = commit_oid
|
|
677
|
+
continue
|
|
678
|
+
raw_kind = str(metadata.get("kind") or ("baseline" if previous is None else "turn"))
|
|
679
|
+
kind = raw_kind if raw_kind in _KINDS else "turn"
|
|
680
|
+
message = _bounded_message(str(metadata.get("message") or fallback_message))
|
|
681
|
+
changes = self._changes_between(previous_oid, commit_oid)
|
|
682
|
+
omitted_value = metadata.get("omitted_paths")
|
|
683
|
+
omitted = sorted(_metadata_path_set(omitted_value or []))
|
|
684
|
+
protected = set(previous.non_revertible_paths if previous is not None else ())
|
|
685
|
+
protected_value = metadata.get("non_revertible_paths")
|
|
686
|
+
if isinstance(protected_value, list):
|
|
687
|
+
protected.update(_metadata_path_set(protected_value))
|
|
688
|
+
if previous is None and int(metadata.get("version") or 0) < 2:
|
|
689
|
+
protected.add(_ALL_PATHS_NON_REVERTIBLE)
|
|
690
|
+
else:
|
|
691
|
+
for path in omitted:
|
|
692
|
+
if path.endswith("/") or self._tree_identity(commit_oid, path) is None:
|
|
693
|
+
protected.add(path)
|
|
694
|
+
db.execute(
|
|
695
|
+
"INSERT INTO checkpoints (checkpoint_id, session_id, turn_id, step_id, parent_id, "
|
|
696
|
+
"commit_oid, kind, created_at, message, changes_json, omitted_json, "
|
|
697
|
+
"non_revertible_json, reverts_id, redoes_id) "
|
|
698
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
699
|
+
(
|
|
700
|
+
checkpoint_id,
|
|
701
|
+
session_id,
|
|
702
|
+
_bounded_label(metadata.get("turn_id")),
|
|
703
|
+
_bounded_label(metadata.get("step_id")),
|
|
704
|
+
previous.checkpoint_id if previous is not None else None,
|
|
705
|
+
commit_oid,
|
|
706
|
+
kind,
|
|
707
|
+
str(metadata.get("created_at") or committed_at),
|
|
708
|
+
message,
|
|
709
|
+
json.dumps([record.__dict__ for record in changes], separators=(",", ":")),
|
|
710
|
+
json.dumps(omitted, separators=(",", ":")),
|
|
711
|
+
json.dumps(sorted(protected), separators=(",", ":")),
|
|
712
|
+
_bounded_label(metadata.get("reverts_id")),
|
|
713
|
+
_bounded_label(metadata.get("redoes_id")),
|
|
714
|
+
),
|
|
715
|
+
)
|
|
716
|
+
previous = self._checkpoint_locked(db, checkpoint_id)
|
|
717
|
+
previous_oid = commit_oid
|
|
718
|
+
if previous is not None:
|
|
719
|
+
db.execute(
|
|
720
|
+
"INSERT INTO session_heads(session_id, checkpoint_id) VALUES (?, ?) "
|
|
721
|
+
"ON CONFLICT(session_id) DO UPDATE SET checkpoint_id = excluded.checkpoint_id",
|
|
722
|
+
(session_id, previous.checkpoint_id),
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
def _reconcile_pending_restore_locked(
|
|
726
|
+
self,
|
|
727
|
+
db: sqlite3.Connection,
|
|
728
|
+
pending: dict[str, Any],
|
|
729
|
+
) -> bool:
|
|
730
|
+
session_id = _validate_session_id(str(pending.get("session_id") or ""))
|
|
731
|
+
checkpoint_id = str(pending.get("checkpoint_id") or "")
|
|
732
|
+
finalized = db.execute(
|
|
733
|
+
"SELECT checkpoint_id FROM checkpoints WHERE checkpoint_id = ?",
|
|
734
|
+
(checkpoint_id,),
|
|
735
|
+
).fetchone()
|
|
736
|
+
if finalized is not None:
|
|
737
|
+
return True
|
|
738
|
+
|
|
739
|
+
source = self._checkpoint_locked(db, str(pending.get("source_checkpoint_id") or ""))
|
|
740
|
+
head = self._head_locked(db, session_id)
|
|
741
|
+
if head is None or head.checkpoint_id != source.checkpoint_id:
|
|
742
|
+
raise ChangeLedgerError(
|
|
743
|
+
"A pending checkpoint restore no longer matches the recorded session head."
|
|
744
|
+
)
|
|
745
|
+
source_oid = str(pending.get("source_oid") or "")
|
|
746
|
+
restore_oid = str(pending.get("restore_oid") or "")
|
|
747
|
+
if source.commit_oid != source_oid or not restore_oid:
|
|
748
|
+
raise ChangeLedgerError("Pending checkpoint recovery metadata is invalid.")
|
|
749
|
+
paths = _normalize_scope_paths(self.workspace_root, pending.get("paths") or [])
|
|
750
|
+
protected_checkpoints = [source]
|
|
751
|
+
related_id = str(pending.get("redoes_id") or pending.get("reverts_id") or "").strip()
|
|
752
|
+
if related_id:
|
|
753
|
+
protected_checkpoints.append(self._checkpoint_locked(db, related_id))
|
|
754
|
+
self._require_paths_revertible(paths, *protected_checkpoints)
|
|
755
|
+
|
|
756
|
+
states: dict[str, str] = {}
|
|
757
|
+
displaced_entries: dict[str, tuple[Path, ...]] = {}
|
|
758
|
+
for path in paths:
|
|
759
|
+
current = self._worktree_identity(path)
|
|
760
|
+
source_identity = self._tree_identity(source_oid, path)
|
|
761
|
+
restore_identity = self._tree_identity(restore_oid, path)
|
|
762
|
+
displaced = self._matching_displaced_entries(path, source_identity)
|
|
763
|
+
displaced_entries[path] = displaced
|
|
764
|
+
if current is None and source_identity is not None and displaced:
|
|
765
|
+
# The process died after moving the source inode aside but before
|
|
766
|
+
# publishing the requested tree entry. Restore the durable source
|
|
767
|
+
# namespace first; the operation can then be rolled back honestly.
|
|
768
|
+
if not _publish_entry_no_replace(
|
|
769
|
+
displaced[0], _safe_workspace_path(self.workspace_root, path)
|
|
770
|
+
):
|
|
771
|
+
raise ChangeLedgerError(
|
|
772
|
+
"A workspace path was claimed while checkpoint recovery was restoring it."
|
|
773
|
+
)
|
|
774
|
+
current = self._worktree_identity(path)
|
|
775
|
+
if current == restore_identity:
|
|
776
|
+
states[path] = "restored"
|
|
777
|
+
elif current == source_identity:
|
|
778
|
+
states[path] = "source"
|
|
779
|
+
else:
|
|
780
|
+
states[path] = "external"
|
|
781
|
+
|
|
782
|
+
if all(state == "restored" for state in states.values()):
|
|
783
|
+
self._capture_locked(
|
|
784
|
+
db,
|
|
785
|
+
session_id=session_id,
|
|
786
|
+
turn_id=_bounded_label(pending.get("turn_id")),
|
|
787
|
+
step_id=_bounded_label(pending.get("step_id")),
|
|
788
|
+
kind=str(pending.get("kind") or "turn"),
|
|
789
|
+
message=_bounded_message(str(pending.get("message") or "Recovered checkpoint")),
|
|
790
|
+
reverts_id=_bounded_label(pending.get("reverts_id")),
|
|
791
|
+
redoes_id=_bounded_label(pending.get("redoes_id")),
|
|
792
|
+
paths=paths,
|
|
793
|
+
checkpoint_id=checkpoint_id,
|
|
794
|
+
)
|
|
795
|
+
self._remove_recovered_displaced_entries(displaced_entries)
|
|
796
|
+
return True
|
|
797
|
+
|
|
798
|
+
rollback_paths = tuple(
|
|
799
|
+
path
|
|
800
|
+
for path, state in states.items()
|
|
801
|
+
if state == "restored"
|
|
802
|
+
and self._tree_identity(source_oid, path) != self._tree_identity(restore_oid, path)
|
|
803
|
+
)
|
|
804
|
+
if rollback_paths:
|
|
805
|
+
self._restore_paths(source_oid, rollback_paths, rollback_oid=restore_oid)
|
|
806
|
+
self._remove_recovered_displaced_entries(displaced_entries)
|
|
807
|
+
# Paths changed by a human or another process are deliberately preserved.
|
|
808
|
+
# The SQLite/ref head remains at ``source`` so a later user-requested
|
|
809
|
+
# revert will surface the ordinary stale-workspace conflict.
|
|
810
|
+
return True
|
|
811
|
+
|
|
812
|
+
def _matching_displaced_entries(
|
|
813
|
+
self,
|
|
814
|
+
rel_path: str,
|
|
815
|
+
source_identity: tuple[str, str] | None,
|
|
816
|
+
) -> tuple[Path, ...]:
|
|
817
|
+
if source_identity is None:
|
|
818
|
+
return ()
|
|
819
|
+
target = _safe_workspace_path(self.workspace_root, rel_path)
|
|
820
|
+
prefix = f".{target.name}."
|
|
821
|
+
matches: list[Path] = []
|
|
822
|
+
try:
|
|
823
|
+
siblings = tuple(target.parent.iterdir())
|
|
824
|
+
except OSError:
|
|
825
|
+
return ()
|
|
826
|
+
for candidate in siblings:
|
|
827
|
+
if (
|
|
828
|
+
candidate.name.startswith(prefix)
|
|
829
|
+
and candidate.name.endswith(".displaced")
|
|
830
|
+
and self._path_identity(candidate) == source_identity
|
|
831
|
+
):
|
|
832
|
+
matches.append(candidate)
|
|
833
|
+
return tuple(matches)
|
|
834
|
+
|
|
835
|
+
@staticmethod
|
|
836
|
+
def _remove_recovered_displaced_entries(entries: dict[str, tuple[Path, ...]]) -> None:
|
|
837
|
+
for candidates in entries.values():
|
|
838
|
+
for candidate in candidates:
|
|
839
|
+
candidate.unlink(missing_ok=True)
|
|
840
|
+
|
|
841
|
+
def _pending_restore_payload(
|
|
842
|
+
self,
|
|
843
|
+
*,
|
|
844
|
+
session_id: str,
|
|
845
|
+
action: str,
|
|
846
|
+
source: Checkpoint,
|
|
847
|
+
restore_oid: str,
|
|
848
|
+
paths: tuple[str, ...],
|
|
849
|
+
turn_id: str | None,
|
|
850
|
+
step_id: str | None,
|
|
851
|
+
kind: str,
|
|
852
|
+
message: str,
|
|
853
|
+
reverts_id: str | None = None,
|
|
854
|
+
redoes_id: str | None = None,
|
|
855
|
+
) -> dict[str, Any]:
|
|
856
|
+
return {
|
|
857
|
+
"version": _PENDING_OPERATION_VERSION,
|
|
858
|
+
"operation_id": str(uuid.uuid4()),
|
|
859
|
+
"checkpoint_id": str(uuid.uuid4()),
|
|
860
|
+
"session_id": session_id,
|
|
861
|
+
"action": action,
|
|
862
|
+
"source_checkpoint_id": source.checkpoint_id,
|
|
863
|
+
"source_oid": source.commit_oid,
|
|
864
|
+
"restore_oid": restore_oid,
|
|
865
|
+
"paths": list(paths),
|
|
866
|
+
"turn_id": turn_id,
|
|
867
|
+
"step_id": step_id,
|
|
868
|
+
"kind": kind,
|
|
869
|
+
"message": message,
|
|
870
|
+
"reverts_id": reverts_id,
|
|
871
|
+
"redoes_id": redoes_id,
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
def _write_pending_operation(self, payload: dict[str, Any]) -> None:
|
|
875
|
+
_atomic_write(
|
|
876
|
+
self.pending_operation_path,
|
|
877
|
+
json.dumps(payload, separators=(",", ":")).encode("utf-8"),
|
|
878
|
+
mode=0o600,
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
def _read_pending_operation(self) -> dict[str, Any] | None:
|
|
882
|
+
if not self.pending_operation_path.exists():
|
|
883
|
+
return None
|
|
884
|
+
try:
|
|
885
|
+
payload = json.loads(self.pending_operation_path.read_text(encoding="utf-8"))
|
|
886
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
887
|
+
raise ChangeLedgerError("Pending checkpoint recovery metadata is unreadable.") from exc
|
|
888
|
+
if not isinstance(payload, dict) or payload.get("version") != _PENDING_OPERATION_VERSION:
|
|
889
|
+
raise ChangeLedgerError("Pending checkpoint recovery metadata is invalid.")
|
|
890
|
+
return payload
|
|
891
|
+
|
|
892
|
+
def _ref_oid(self, ref: str) -> str | None:
|
|
893
|
+
result = self._git(["rev-parse", "--verify", ref], check=False)
|
|
894
|
+
if result.returncode != 0:
|
|
895
|
+
return None
|
|
896
|
+
return result.stdout.decode("ascii", errors="strict").strip()
|
|
897
|
+
|
|
898
|
+
def _revision_list(self, revision: str) -> tuple[str, ...]:
|
|
899
|
+
output = self._git(["rev-list", "--reverse", revision], check=True).stdout
|
|
900
|
+
return tuple(line for line in output.decode("ascii").splitlines() if line)
|
|
901
|
+
|
|
902
|
+
def _is_ancestor(self, older_oid: str, newer_oid: str) -> bool:
|
|
903
|
+
result = self._git(["merge-base", "--is-ancestor", older_oid, newer_oid], check=False)
|
|
904
|
+
if result.returncode not in {0, 1}:
|
|
905
|
+
raise ChangeLedgerError("Checkpoint Git ancestry could not be verified.")
|
|
906
|
+
return result.returncode == 0
|
|
907
|
+
|
|
908
|
+
def _commit_parent_oid(self, commit_oid: str) -> str | None:
|
|
909
|
+
output = self._git(["rev-list", "--parents", "-n", "1", commit_oid], check=True).stdout
|
|
910
|
+
parts = output.decode("ascii").strip().split()
|
|
911
|
+
if not parts or parts[0] != commit_oid or len(parts) > 2:
|
|
912
|
+
raise ChangeLedgerError("Checkpoint commit history is invalid.")
|
|
913
|
+
return parts[1] if len(parts) == 2 else None
|
|
914
|
+
|
|
915
|
+
def _commit_metadata(self, commit_oid: str) -> tuple[dict[str, Any], str, str]:
|
|
916
|
+
output = self._git(
|
|
917
|
+
["show", "-s", "--format=%B%x00%cI", commit_oid],
|
|
918
|
+
check=True,
|
|
919
|
+
).stdout.decode("utf-8", errors="replace")
|
|
920
|
+
message, separator, committed_at = output.rpartition("\x00")
|
|
921
|
+
if not separator:
|
|
922
|
+
raise ChangeLedgerError("Checkpoint commit metadata is invalid.")
|
|
923
|
+
metadata: dict[str, Any] = {}
|
|
924
|
+
display_lines: list[str] = []
|
|
925
|
+
for line in message.rstrip().splitlines():
|
|
926
|
+
if line.startswith(_METADATA_PREFIX):
|
|
927
|
+
encoded = line[len(_METADATA_PREFIX) :].strip()
|
|
928
|
+
try:
|
|
929
|
+
decoded = base64.urlsafe_b64decode(encoded.encode("ascii"))
|
|
930
|
+
parsed = json.loads(decoded.decode("utf-8"))
|
|
931
|
+
except (ValueError, UnicodeError, json.JSONDecodeError):
|
|
932
|
+
raise ChangeLedgerError("Checkpoint commit metadata is invalid.") from None
|
|
933
|
+
if not isinstance(parsed, dict):
|
|
934
|
+
raise ChangeLedgerError("Checkpoint commit metadata is invalid.")
|
|
935
|
+
metadata = parsed
|
|
936
|
+
else:
|
|
937
|
+
display_lines.append(line)
|
|
938
|
+
return metadata, _bounded_message("\n".join(display_lines).strip()), committed_at.strip()
|
|
939
|
+
|
|
940
|
+
def _capture_locked(
|
|
941
|
+
self,
|
|
942
|
+
db: sqlite3.Connection,
|
|
943
|
+
*,
|
|
944
|
+
session_id: str,
|
|
945
|
+
turn_id: str | None,
|
|
946
|
+
step_id: str | None,
|
|
947
|
+
kind: str,
|
|
948
|
+
message: str,
|
|
949
|
+
reverts_id: str | None = None,
|
|
950
|
+
redoes_id: str | None = None,
|
|
951
|
+
paths: tuple[str, ...] | None,
|
|
952
|
+
checkpoint_id: str | None = None,
|
|
953
|
+
) -> Checkpoint:
|
|
954
|
+
parent = self._head_locked(db, session_id)
|
|
955
|
+
parent_oid = parent.commit_oid if parent else None
|
|
956
|
+
env = self._session_git_env(session_id)
|
|
957
|
+
if parent_oid:
|
|
958
|
+
self._git(["read-tree", parent_oid], env=env, check=True)
|
|
959
|
+
else:
|
|
960
|
+
self._git(["read-tree", "--empty"], env=env, check=True)
|
|
961
|
+
staged = self._stage_workspace(
|
|
962
|
+
env=env,
|
|
963
|
+
paths=paths,
|
|
964
|
+
parent_oid=parent_oid,
|
|
965
|
+
inherited_non_revertible=(parent.non_revertible_paths if parent is not None else ()),
|
|
966
|
+
)
|
|
967
|
+
tree_oid = self._git(["write-tree"], env=env, check=True).stdout.decode().strip()
|
|
968
|
+
commit_args = ["commit-tree", tree_oid]
|
|
969
|
+
if parent_oid:
|
|
970
|
+
commit_args.extend(["-p", parent_oid])
|
|
971
|
+
created_at = datetime.now(UTC).isoformat()
|
|
972
|
+
commit_env = dict(env)
|
|
973
|
+
commit_env.update(
|
|
974
|
+
{
|
|
975
|
+
"GIT_AUTHOR_NAME": "Alysis Code Checkpoint",
|
|
976
|
+
"GIT_AUTHOR_EMAIL": "checkpoint@localhost",
|
|
977
|
+
"GIT_COMMITTER_NAME": "Alysis Code Checkpoint",
|
|
978
|
+
"GIT_COMMITTER_EMAIL": "checkpoint@localhost",
|
|
979
|
+
"GIT_AUTHOR_DATE": created_at,
|
|
980
|
+
"GIT_COMMITTER_DATE": created_at,
|
|
981
|
+
}
|
|
982
|
+
)
|
|
983
|
+
safe_message = message or f"{kind} checkpoint"
|
|
984
|
+
resolved_checkpoint_id = checkpoint_id or str(uuid.uuid4())
|
|
985
|
+
commit_metadata = {
|
|
986
|
+
"version": _CHECKPOINT_METADATA_VERSION,
|
|
987
|
+
"checkpoint_id": resolved_checkpoint_id,
|
|
988
|
+
"session_id": session_id,
|
|
989
|
+
"turn_id": turn_id,
|
|
990
|
+
"step_id": step_id,
|
|
991
|
+
"kind": kind,
|
|
992
|
+
"created_at": created_at,
|
|
993
|
+
"message": safe_message,
|
|
994
|
+
"omitted_paths": list(staged.omitted_paths),
|
|
995
|
+
"non_revertible_paths": list(staged.non_revertible_paths),
|
|
996
|
+
"reverts_id": reverts_id,
|
|
997
|
+
"redoes_id": redoes_id,
|
|
998
|
+
}
|
|
999
|
+
encoded_metadata = base64.urlsafe_b64encode(
|
|
1000
|
+
json.dumps(commit_metadata, separators=(",", ":")).encode("utf-8")
|
|
1001
|
+
).decode("ascii")
|
|
1002
|
+
commit_message = f"{safe_message}\n\n{_METADATA_PREFIX}{encoded_metadata}"
|
|
1003
|
+
commit_oid = (
|
|
1004
|
+
self._git(
|
|
1005
|
+
commit_args,
|
|
1006
|
+
env=commit_env,
|
|
1007
|
+
input_bytes=commit_message.encode("utf-8"),
|
|
1008
|
+
check=True,
|
|
1009
|
+
)
|
|
1010
|
+
.stdout.decode()
|
|
1011
|
+
.strip()
|
|
1012
|
+
)
|
|
1013
|
+
ref = f"refs/heads/sessions/{_session_key(session_id)}"
|
|
1014
|
+
update_args = ["update-ref", ref, commit_oid, parent_oid or _ZERO_OID]
|
|
1015
|
+
self._git(update_args, check=True)
|
|
1016
|
+
changes = self._changes_between(parent_oid, commit_oid)
|
|
1017
|
+
db.execute(
|
|
1018
|
+
"INSERT INTO checkpoints (checkpoint_id, session_id, turn_id, step_id, parent_id, "
|
|
1019
|
+
"commit_oid, kind, created_at, message, changes_json, omitted_json, "
|
|
1020
|
+
"non_revertible_json, reverts_id, redoes_id) "
|
|
1021
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
1022
|
+
(
|
|
1023
|
+
resolved_checkpoint_id,
|
|
1024
|
+
session_id,
|
|
1025
|
+
turn_id,
|
|
1026
|
+
step_id,
|
|
1027
|
+
parent.checkpoint_id if parent else None,
|
|
1028
|
+
commit_oid,
|
|
1029
|
+
kind,
|
|
1030
|
+
created_at,
|
|
1031
|
+
safe_message,
|
|
1032
|
+
json.dumps([record.__dict__ for record in changes], separators=(",", ":")),
|
|
1033
|
+
json.dumps(staged.omitted_paths, separators=(",", ":")),
|
|
1034
|
+
json.dumps(staged.non_revertible_paths, separators=(",", ":")),
|
|
1035
|
+
reverts_id,
|
|
1036
|
+
redoes_id,
|
|
1037
|
+
),
|
|
1038
|
+
)
|
|
1039
|
+
db.execute(
|
|
1040
|
+
"INSERT INTO session_heads(session_id, checkpoint_id) VALUES (?, ?) "
|
|
1041
|
+
"ON CONFLICT(session_id) DO UPDATE SET checkpoint_id = excluded.checkpoint_id",
|
|
1042
|
+
(session_id, resolved_checkpoint_id),
|
|
1043
|
+
)
|
|
1044
|
+
return self._checkpoint_locked(db, resolved_checkpoint_id)
|
|
1045
|
+
|
|
1046
|
+
def _stage_workspace(
|
|
1047
|
+
self,
|
|
1048
|
+
*,
|
|
1049
|
+
env: dict[str, str],
|
|
1050
|
+
paths: tuple[str, ...] | None,
|
|
1051
|
+
parent_oid: str | None,
|
|
1052
|
+
inherited_non_revertible: tuple[str, ...],
|
|
1053
|
+
) -> _StageResult:
|
|
1054
|
+
if paths == ():
|
|
1055
|
+
return _StageResult((), tuple(sorted(set(inherited_non_revertible))))
|
|
1056
|
+
scopes = [None] if paths is None else _batches(list(paths))
|
|
1057
|
+
tracked: set[str] = set()
|
|
1058
|
+
candidate_set: set[str] = set()
|
|
1059
|
+
ignored_set: set[str] = set()
|
|
1060
|
+
for scope in scopes:
|
|
1061
|
+
pathspec = [] if scope is None else ["--", *scope]
|
|
1062
|
+
tracked_raw = self._git(["ls-files", "-z", *pathspec], env=env, check=True).stdout
|
|
1063
|
+
tracked.update(_decode_git_path(part) for part in tracked_raw.split(b"\0") if part)
|
|
1064
|
+
candidates_raw = self._git(
|
|
1065
|
+
["ls-files", "-co", "--exclude-standard", "-z", *pathspec],
|
|
1066
|
+
env=env,
|
|
1067
|
+
check=True,
|
|
1068
|
+
).stdout
|
|
1069
|
+
candidate_set.update(
|
|
1070
|
+
_decode_git_path(part) for part in candidates_raw.split(b"\0") if part
|
|
1071
|
+
)
|
|
1072
|
+
ignored_raw = self._git(
|
|
1073
|
+
[
|
|
1074
|
+
"ls-files",
|
|
1075
|
+
"-o",
|
|
1076
|
+
"-i",
|
|
1077
|
+
"--exclude-standard",
|
|
1078
|
+
"--directory",
|
|
1079
|
+
"-z",
|
|
1080
|
+
*pathspec,
|
|
1081
|
+
],
|
|
1082
|
+
env=env,
|
|
1083
|
+
check=True,
|
|
1084
|
+
).stdout
|
|
1085
|
+
ignored_set.update(_decode_git_path(part) for part in ignored_raw.split(b"\0") if part)
|
|
1086
|
+
candidates = sorted(candidate_set)
|
|
1087
|
+
protected = set(inherited_non_revertible)
|
|
1088
|
+
omitted: set[str] = set(ignored_set)
|
|
1089
|
+
deleted: list[str] = []
|
|
1090
|
+
for path in sorted(tracked):
|
|
1091
|
+
if os.path.lexists(self.workspace_root / path):
|
|
1092
|
+
continue
|
|
1093
|
+
if _is_runtime_or_sensitive(path) or _path_is_non_revertible(path, protected):
|
|
1094
|
+
omitted.add(path)
|
|
1095
|
+
else:
|
|
1096
|
+
deleted.append(path)
|
|
1097
|
+
expected_identities = {path: None for path in deleted}
|
|
1098
|
+
for batch in _batches(deleted):
|
|
1099
|
+
self._git(["update-index", "--remove", "--", *batch], env=env, check=True)
|
|
1100
|
+
|
|
1101
|
+
accepted: list[str] = []
|
|
1102
|
+
total = 0
|
|
1103
|
+
for rel_path in candidates:
|
|
1104
|
+
path = self.workspace_root / rel_path
|
|
1105
|
+
if (
|
|
1106
|
+
_is_runtime_or_sensitive(rel_path)
|
|
1107
|
+
or _path_is_non_revertible(rel_path, protected)
|
|
1108
|
+
or not os.path.lexists(path)
|
|
1109
|
+
):
|
|
1110
|
+
omitted.add(rel_path)
|
|
1111
|
+
continue
|
|
1112
|
+
try:
|
|
1113
|
+
size = (
|
|
1114
|
+
len(os.readlink(path).encode("utf-8"))
|
|
1115
|
+
if path.is_symlink()
|
|
1116
|
+
else path.stat().st_size
|
|
1117
|
+
)
|
|
1118
|
+
except OSError:
|
|
1119
|
+
omitted.add(rel_path)
|
|
1120
|
+
continue
|
|
1121
|
+
if size > self.max_file_bytes or total + size > self.max_snapshot_bytes:
|
|
1122
|
+
omitted.add(rel_path)
|
|
1123
|
+
continue
|
|
1124
|
+
identity = self._worktree_identity(rel_path)
|
|
1125
|
+
if identity is None or identity == ("unstable", "unstable"):
|
|
1126
|
+
raise StaleWorkspaceError(
|
|
1127
|
+
"Workspace files changed while the checkpoint was being captured; retry."
|
|
1128
|
+
)
|
|
1129
|
+
total += size
|
|
1130
|
+
accepted.append(rel_path)
|
|
1131
|
+
expected_identities[rel_path] = identity
|
|
1132
|
+
for batch in _batches(accepted):
|
|
1133
|
+
self._git(["add", "--", *batch], env=env, check=True)
|
|
1134
|
+
stale = [
|
|
1135
|
+
path
|
|
1136
|
+
for path, expected in expected_identities.items()
|
|
1137
|
+
if self._worktree_identity(path) != expected
|
|
1138
|
+
]
|
|
1139
|
+
if stale:
|
|
1140
|
+
raise StaleWorkspaceError(
|
|
1141
|
+
"Workspace files changed while the checkpoint was being captured; retry."
|
|
1142
|
+
)
|
|
1143
|
+
for path in omitted:
|
|
1144
|
+
if (
|
|
1145
|
+
path.endswith("/")
|
|
1146
|
+
or parent_oid is None
|
|
1147
|
+
or self._tree_identity(parent_oid, path) is None
|
|
1148
|
+
):
|
|
1149
|
+
protected.add(path)
|
|
1150
|
+
if len(omitted) > _MAX_OMITTED_PATHS or len(protected) > _MAX_OMITTED_PATHS:
|
|
1151
|
+
raise ChangeLedgerError(
|
|
1152
|
+
"Checkpoint omission metadata exceeds the safe limit; refusing an incomplete snapshot."
|
|
1153
|
+
)
|
|
1154
|
+
return _StageResult(tuple(sorted(omitted)), tuple(sorted(protected)))
|
|
1155
|
+
|
|
1156
|
+
def _changes_between(self, parent_oid: str | None, commit_oid: str) -> tuple[ChangeRecord, ...]:
|
|
1157
|
+
if parent_oid:
|
|
1158
|
+
args = [
|
|
1159
|
+
"diff-tree",
|
|
1160
|
+
"--no-commit-id",
|
|
1161
|
+
"--name-status",
|
|
1162
|
+
"-r",
|
|
1163
|
+
"-z",
|
|
1164
|
+
parent_oid,
|
|
1165
|
+
commit_oid,
|
|
1166
|
+
]
|
|
1167
|
+
else:
|
|
1168
|
+
args = [
|
|
1169
|
+
"diff-tree",
|
|
1170
|
+
"--root",
|
|
1171
|
+
"--no-commit-id",
|
|
1172
|
+
"--name-status",
|
|
1173
|
+
"-r",
|
|
1174
|
+
"-z",
|
|
1175
|
+
commit_oid,
|
|
1176
|
+
]
|
|
1177
|
+
parts = [part for part in self._git(args, check=True).stdout.split(b"\0") if part]
|
|
1178
|
+
records: list[ChangeRecord] = []
|
|
1179
|
+
index = 0
|
|
1180
|
+
while index < len(parts):
|
|
1181
|
+
status_text = parts[index].decode("ascii", errors="replace")
|
|
1182
|
+
index += 1
|
|
1183
|
+
status_code = status_text[:1]
|
|
1184
|
+
if status_code in {"R", "C"} and index + 1 < len(parts):
|
|
1185
|
+
old_path = _decode_git_path(parts[index])
|
|
1186
|
+
new_path = _decode_git_path(parts[index + 1])
|
|
1187
|
+
index += 2
|
|
1188
|
+
records.append(
|
|
1189
|
+
ChangeRecord(status=status_code, path=new_path, previous_path=old_path)
|
|
1190
|
+
)
|
|
1191
|
+
elif index < len(parts):
|
|
1192
|
+
records.append(
|
|
1193
|
+
ChangeRecord(status=status_code, path=_decode_git_path(parts[index]))
|
|
1194
|
+
)
|
|
1195
|
+
index += 1
|
|
1196
|
+
return tuple(records)
|
|
1197
|
+
|
|
1198
|
+
@staticmethod
|
|
1199
|
+
def _require_paths_revertible(paths: tuple[str, ...], *checkpoints: Checkpoint) -> None:
|
|
1200
|
+
protected = {path for checkpoint in checkpoints for path in checkpoint.non_revertible_paths}
|
|
1201
|
+
unsafe = [path for path in paths if _path_is_non_revertible(path, protected)]
|
|
1202
|
+
if unsafe:
|
|
1203
|
+
raise ChangeLedgerError(
|
|
1204
|
+
"Checkpoint contains paths whose earlier bytes were not captured; "
|
|
1205
|
+
"refusing a non-revertible restore."
|
|
1206
|
+
)
|
|
1207
|
+
|
|
1208
|
+
def _require_paths_match(self, commit_oid: str, paths: tuple[str, ...]) -> None:
|
|
1209
|
+
stale = [
|
|
1210
|
+
path
|
|
1211
|
+
for path in paths
|
|
1212
|
+
if self._worktree_identity(path) != self._tree_identity(commit_oid, path)
|
|
1213
|
+
]
|
|
1214
|
+
if stale:
|
|
1215
|
+
raise StaleWorkspaceError(
|
|
1216
|
+
"Workspace files changed after the checkpoint; refusing to overwrite newer work."
|
|
1217
|
+
)
|
|
1218
|
+
|
|
1219
|
+
def _restore_paths(self, commit_oid: str, paths: tuple[str, ...], *, rollback_oid: str) -> None:
|
|
1220
|
+
restored: list[_RestoreMutation] = []
|
|
1221
|
+
try:
|
|
1222
|
+
for rel_path in paths:
|
|
1223
|
+
restored.append(
|
|
1224
|
+
self._restore_one(
|
|
1225
|
+
commit_oid,
|
|
1226
|
+
rel_path,
|
|
1227
|
+
expected_identity=self._tree_identity(rollback_oid, rel_path),
|
|
1228
|
+
)
|
|
1229
|
+
)
|
|
1230
|
+
except Exception as exc:
|
|
1231
|
+
rollback_failed = False
|
|
1232
|
+
for mutation in reversed(restored):
|
|
1233
|
+
try:
|
|
1234
|
+
self._rollback_restore(mutation)
|
|
1235
|
+
except Exception:
|
|
1236
|
+
rollback_failed = True
|
|
1237
|
+
if rollback_failed:
|
|
1238
|
+
raise ChangeLedgerError(
|
|
1239
|
+
"A concurrent edit interrupted checkpoint restore and automatic rollback; "
|
|
1240
|
+
"newer workspace content was preserved."
|
|
1241
|
+
) from exc
|
|
1242
|
+
if isinstance(exc, ChangeLedgerError):
|
|
1243
|
+
raise
|
|
1244
|
+
raise ChangeLedgerError("Unable to restore the checkpoint safely.") from None
|
|
1245
|
+
for mutation in restored:
|
|
1246
|
+
self._finalize_restore(mutation)
|
|
1247
|
+
|
|
1248
|
+
def _restore_one(
|
|
1249
|
+
self,
|
|
1250
|
+
commit_oid: str,
|
|
1251
|
+
rel_path: str,
|
|
1252
|
+
*,
|
|
1253
|
+
expected_identity: tuple[str, str] | None = None,
|
|
1254
|
+
) -> _RestoreMutation:
|
|
1255
|
+
target = _safe_workspace_path(self.workspace_root, rel_path)
|
|
1256
|
+
identity = self._tree_identity(commit_oid, rel_path)
|
|
1257
|
+
blob: bytes | None = None
|
|
1258
|
+
if identity is not None:
|
|
1259
|
+
mode, _oid = identity
|
|
1260
|
+
if mode == "160000":
|
|
1261
|
+
raise ChangeLedgerError("Submodule entries are not modified by checkpoints.")
|
|
1262
|
+
blob = self._git(["show", f"{commit_oid}:{rel_path}"], check=True).stdout
|
|
1263
|
+
|
|
1264
|
+
displaced = self._displace_matching(
|
|
1265
|
+
target,
|
|
1266
|
+
rel_path=rel_path,
|
|
1267
|
+
expected_identity=expected_identity,
|
|
1268
|
+
)
|
|
1269
|
+
try:
|
|
1270
|
+
self._install_tree_entry_no_replace(target, identity=identity, blob=blob)
|
|
1271
|
+
except FileExistsError as exc:
|
|
1272
|
+
# A writer claimed the name after the verified version was moved
|
|
1273
|
+
# aside. Their content wins. The old agent version remains durable
|
|
1274
|
+
# in ``rollback_oid``, so the private duplicate can be removed.
|
|
1275
|
+
if displaced is not None:
|
|
1276
|
+
displaced.unlink(missing_ok=True)
|
|
1277
|
+
raise StaleWorkspaceError(
|
|
1278
|
+
"Workspace files changed after the checkpoint; refusing to overwrite newer work."
|
|
1279
|
+
) from exc
|
|
1280
|
+
except Exception:
|
|
1281
|
+
if displaced is not None and not _publish_entry_no_replace(displaced, target):
|
|
1282
|
+
raise ChangeLedgerError(
|
|
1283
|
+
f"Unable to restore {rel_path}; a recovery copy remains at {displaced.name}."
|
|
1284
|
+
) from None
|
|
1285
|
+
raise
|
|
1286
|
+
|
|
1287
|
+
if identity is None:
|
|
1288
|
+
_remove_empty_parents(target.parent, stop=self.workspace_root)
|
|
1289
|
+
return _RestoreMutation(
|
|
1290
|
+
rel_path=rel_path,
|
|
1291
|
+
before_identity=expected_identity,
|
|
1292
|
+
after_identity=identity,
|
|
1293
|
+
displaced_path=displaced,
|
|
1294
|
+
)
|
|
1295
|
+
|
|
1296
|
+
def _displace_matching(
|
|
1297
|
+
self,
|
|
1298
|
+
target: Path,
|
|
1299
|
+
*,
|
|
1300
|
+
rel_path: str,
|
|
1301
|
+
expected_identity: tuple[str, str] | None,
|
|
1302
|
+
) -> Path | None:
|
|
1303
|
+
if self._path_identity(target) != expected_identity:
|
|
1304
|
+
raise StaleWorkspaceError(
|
|
1305
|
+
"Workspace files changed after the checkpoint; refusing to overwrite newer work."
|
|
1306
|
+
)
|
|
1307
|
+
if expected_identity is None:
|
|
1308
|
+
return None
|
|
1309
|
+
|
|
1310
|
+
displaced = _reserve_displaced_path(target)
|
|
1311
|
+
target_was_displaced = False
|
|
1312
|
+
try:
|
|
1313
|
+
try:
|
|
1314
|
+
os.replace(target, displaced)
|
|
1315
|
+
target_was_displaced = True
|
|
1316
|
+
except OSError as exc:
|
|
1317
|
+
raise StaleWorkspaceError(
|
|
1318
|
+
"Workspace files changed after the checkpoint; refusing to overwrite newer work."
|
|
1319
|
+
) from exc
|
|
1320
|
+
if self._path_identity(displaced) != expected_identity:
|
|
1321
|
+
if not _publish_entry_no_replace(displaced, target):
|
|
1322
|
+
raise ChangeLedgerError(
|
|
1323
|
+
f"A concurrent edit to {rel_path} was preserved at {displaced.name}."
|
|
1324
|
+
)
|
|
1325
|
+
raise StaleWorkspaceError(
|
|
1326
|
+
"Workspace files changed after the checkpoint; refusing to overwrite newer work."
|
|
1327
|
+
)
|
|
1328
|
+
return displaced
|
|
1329
|
+
finally:
|
|
1330
|
+
if not target_was_displaced:
|
|
1331
|
+
displaced.unlink(missing_ok=True)
|
|
1332
|
+
|
|
1333
|
+
def _install_tree_entry_no_replace(
|
|
1334
|
+
self,
|
|
1335
|
+
target: Path,
|
|
1336
|
+
*,
|
|
1337
|
+
identity: tuple[str, str] | None,
|
|
1338
|
+
blob: bytes | None,
|
|
1339
|
+
) -> None:
|
|
1340
|
+
if identity is None:
|
|
1341
|
+
if os.path.lexists(target):
|
|
1342
|
+
raise FileExistsError(os.fspath(target))
|
|
1343
|
+
return
|
|
1344
|
+
mode, _oid = identity
|
|
1345
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1346
|
+
if mode == "120000":
|
|
1347
|
+
assert blob is not None
|
|
1348
|
+
os.symlink(blob.decode("utf-8"), target)
|
|
1349
|
+
return
|
|
1350
|
+
if mode not in {"100644", "100755"}:
|
|
1351
|
+
raise ChangeLedgerError("Unsupported checkpoint entry type.")
|
|
1352
|
+
assert blob is not None
|
|
1353
|
+
permissions = 0o755 if mode == "100755" else 0o644
|
|
1354
|
+
staged = _stage_file(target, blob, mode=permissions)
|
|
1355
|
+
try:
|
|
1356
|
+
if not _publish_entry_no_replace(staged, target):
|
|
1357
|
+
raise FileExistsError(os.fspath(target))
|
|
1358
|
+
finally:
|
|
1359
|
+
staged.unlink(missing_ok=True)
|
|
1360
|
+
|
|
1361
|
+
def _rollback_restore(self, mutation: _RestoreMutation) -> None:
|
|
1362
|
+
target = _safe_workspace_path(self.workspace_root, mutation.rel_path)
|
|
1363
|
+
discard = self._displace_matching(
|
|
1364
|
+
target,
|
|
1365
|
+
rel_path=mutation.rel_path,
|
|
1366
|
+
expected_identity=mutation.after_identity,
|
|
1367
|
+
)
|
|
1368
|
+
try:
|
|
1369
|
+
if mutation.displaced_path is not None:
|
|
1370
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1371
|
+
if not _publish_entry_no_replace(mutation.displaced_path, target):
|
|
1372
|
+
raise ChangeLedgerError(
|
|
1373
|
+
f"Unable to roll back {mutation.rel_path}; newer content was preserved."
|
|
1374
|
+
)
|
|
1375
|
+
elif os.path.lexists(target):
|
|
1376
|
+
raise StaleWorkspaceError(
|
|
1377
|
+
"Workspace files changed during rollback; refusing to overwrite newer work."
|
|
1378
|
+
)
|
|
1379
|
+
finally:
|
|
1380
|
+
if discard is not None:
|
|
1381
|
+
discard.unlink(missing_ok=True)
|
|
1382
|
+
|
|
1383
|
+
@staticmethod
|
|
1384
|
+
def _finalize_restore(mutation: _RestoreMutation) -> None:
|
|
1385
|
+
if mutation.displaced_path is not None:
|
|
1386
|
+
mutation.displaced_path.unlink(missing_ok=True)
|
|
1387
|
+
|
|
1388
|
+
def _tree_identity(self, commit_oid: str, rel_path: str) -> tuple[str, str] | None:
|
|
1389
|
+
raw = self._git(["ls-tree", "-z", commit_oid, "--", rel_path], check=True).stdout
|
|
1390
|
+
if not raw:
|
|
1391
|
+
return None
|
|
1392
|
+
header, _separator, _path = raw.partition(b"\t")
|
|
1393
|
+
mode, _kind, oid = header.decode("ascii").split(" ", 2)
|
|
1394
|
+
return mode, oid
|
|
1395
|
+
|
|
1396
|
+
def _worktree_identity(self, rel_path: str) -> tuple[str, str] | None:
|
|
1397
|
+
path = _safe_workspace_path(self.workspace_root, rel_path)
|
|
1398
|
+
return self._path_identity(path)
|
|
1399
|
+
|
|
1400
|
+
def _path_identity(self, path: Path) -> tuple[str, str] | None:
|
|
1401
|
+
if not os.path.lexists(path):
|
|
1402
|
+
return None
|
|
1403
|
+
if path.is_symlink():
|
|
1404
|
+
before = path.lstat()
|
|
1405
|
+
data = os.readlink(path).encode("utf-8")
|
|
1406
|
+
after = path.lstat()
|
|
1407
|
+
if _stat_fingerprint(before) != _stat_fingerprint(after):
|
|
1408
|
+
return "unstable", "unstable"
|
|
1409
|
+
oid = (
|
|
1410
|
+
self._git(["hash-object", "--stdin"], input_bytes=data, check=True)
|
|
1411
|
+
.stdout.decode()
|
|
1412
|
+
.strip()
|
|
1413
|
+
)
|
|
1414
|
+
return "120000", oid
|
|
1415
|
+
if not path.is_file():
|
|
1416
|
+
return "040000", "directory"
|
|
1417
|
+
try:
|
|
1418
|
+
with path.open("rb") as stream:
|
|
1419
|
+
before = os.fstat(stream.fileno())
|
|
1420
|
+
data = stream.read()
|
|
1421
|
+
after = os.fstat(stream.fileno())
|
|
1422
|
+
except (FileNotFoundError, IsADirectoryError, PermissionError):
|
|
1423
|
+
return "unstable", "unstable"
|
|
1424
|
+
if _stat_fingerprint(before) != _stat_fingerprint(after):
|
|
1425
|
+
return "unstable", "unstable"
|
|
1426
|
+
oid = (
|
|
1427
|
+
self._git(["hash-object", "--stdin"], input_bytes=data, check=True)
|
|
1428
|
+
.stdout.decode()
|
|
1429
|
+
.strip()
|
|
1430
|
+
)
|
|
1431
|
+
executable = bool(after.st_mode & stat.S_IXUSR) and os.name != "nt"
|
|
1432
|
+
return ("100755" if executable else "100644"), oid
|
|
1433
|
+
|
|
1434
|
+
def _session_git_env(self, session_id: str) -> dict[str, str]:
|
|
1435
|
+
env = build_git_process_env()
|
|
1436
|
+
env["GIT_INDEX_FILE"] = os.fspath(self.index_dir / f"{_session_key(session_id)}.index")
|
|
1437
|
+
return env
|
|
1438
|
+
|
|
1439
|
+
def _git(
|
|
1440
|
+
self,
|
|
1441
|
+
args: list[str],
|
|
1442
|
+
*,
|
|
1443
|
+
env: dict[str, str] | None = None,
|
|
1444
|
+
input_bytes: bytes | None = None,
|
|
1445
|
+
check: bool,
|
|
1446
|
+
) -> subprocess.CompletedProcess[bytes]:
|
|
1447
|
+
process_env = dict(env or build_git_process_env())
|
|
1448
|
+
command = [
|
|
1449
|
+
"git",
|
|
1450
|
+
f"--git-dir={os.fspath(self.git_dir)}",
|
|
1451
|
+
f"--work-tree={os.fspath(self.workspace_root)}",
|
|
1452
|
+
"-c",
|
|
1453
|
+
"core.autocrlf=false",
|
|
1454
|
+
"-c",
|
|
1455
|
+
"core.filemode=false" if os.name == "nt" else "core.filemode=true",
|
|
1456
|
+
*args,
|
|
1457
|
+
]
|
|
1458
|
+
try:
|
|
1459
|
+
result = subprocess.run(
|
|
1460
|
+
command,
|
|
1461
|
+
input=input_bytes,
|
|
1462
|
+
capture_output=True,
|
|
1463
|
+
timeout=self.git_timeout_seconds,
|
|
1464
|
+
check=False,
|
|
1465
|
+
env=process_env,
|
|
1466
|
+
)
|
|
1467
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
1468
|
+
raise ChangeLedgerError("Checkpoint storage is unavailable.") from None
|
|
1469
|
+
if check and result.returncode != 0:
|
|
1470
|
+
raise ChangeLedgerError("Checkpoint Git operation failed.")
|
|
1471
|
+
return result
|
|
1472
|
+
|
|
1473
|
+
def _head_locked(self, db: sqlite3.Connection, session_id: str) -> Checkpoint | None:
|
|
1474
|
+
row = db.execute(
|
|
1475
|
+
"SELECT c.* FROM session_heads h JOIN checkpoints c "
|
|
1476
|
+
"ON c.checkpoint_id = h.checkpoint_id WHERE h.session_id = ?",
|
|
1477
|
+
(session_id,),
|
|
1478
|
+
).fetchone()
|
|
1479
|
+
return self._checkpoint_from_row(row) if row is not None else None
|
|
1480
|
+
|
|
1481
|
+
def _checkpoint_locked(self, db: sqlite3.Connection, checkpoint_id: str) -> Checkpoint:
|
|
1482
|
+
clean = str(checkpoint_id or "").strip()
|
|
1483
|
+
row = db.execute("SELECT * FROM checkpoints WHERE checkpoint_id = ?", (clean,)).fetchone()
|
|
1484
|
+
if row is None:
|
|
1485
|
+
raise ChangeLedgerError("Checkpoint was not found.")
|
|
1486
|
+
return self._checkpoint_from_row(row)
|
|
1487
|
+
|
|
1488
|
+
@staticmethod
|
|
1489
|
+
def _checkpoint_from_row(row: sqlite3.Row) -> Checkpoint:
|
|
1490
|
+
changes = tuple(ChangeRecord(**item) for item in json.loads(row["changes_json"]))
|
|
1491
|
+
return Checkpoint(
|
|
1492
|
+
checkpoint_id=row["checkpoint_id"],
|
|
1493
|
+
session_id=row["session_id"],
|
|
1494
|
+
turn_id=row["turn_id"],
|
|
1495
|
+
step_id=row["step_id"],
|
|
1496
|
+
parent_id=row["parent_id"],
|
|
1497
|
+
commit_oid=row["commit_oid"],
|
|
1498
|
+
kind=row["kind"],
|
|
1499
|
+
created_at=row["created_at"],
|
|
1500
|
+
message=row["message"],
|
|
1501
|
+
changes=changes,
|
|
1502
|
+
omitted_paths=tuple(json.loads(row["omitted_json"])),
|
|
1503
|
+
non_revertible_paths=tuple(json.loads(row["non_revertible_json"])),
|
|
1504
|
+
reverts_id=row["reverts_id"],
|
|
1505
|
+
redoes_id=row["redoes_id"],
|
|
1506
|
+
)
|
|
1507
|
+
|
|
1508
|
+
|
|
1509
|
+
def _validate_session_id(value: str) -> str:
|
|
1510
|
+
clean = str(value or "").strip()
|
|
1511
|
+
if not _SESSION_RE.fullmatch(clean):
|
|
1512
|
+
raise ChangeLedgerError("Invalid checkpoint session identifier.")
|
|
1513
|
+
return clean
|
|
1514
|
+
|
|
1515
|
+
|
|
1516
|
+
def _bounded_label(value: str | None) -> str | None:
|
|
1517
|
+
clean = str(value or "").strip()
|
|
1518
|
+
return clean[:256] or None
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
def _bounded_message(value: str) -> str:
|
|
1522
|
+
return str(value or "").replace("\x00", "")[:1000]
|
|
1523
|
+
|
|
1524
|
+
|
|
1525
|
+
def _session_key(session_id: str) -> str:
|
|
1526
|
+
return hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:24]
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
def _decode_git_path(raw: bytes) -> str:
|
|
1530
|
+
return raw.decode("utf-8", errors="surrogateescape").replace("\\", "/")
|
|
1531
|
+
|
|
1532
|
+
|
|
1533
|
+
def _metadata_path_set(raw: object) -> set[str]:
|
|
1534
|
+
try:
|
|
1535
|
+
values = json.loads(str(raw)) if isinstance(raw, str) else raw
|
|
1536
|
+
except json.JSONDecodeError as exc:
|
|
1537
|
+
raise ChangeLedgerError("Checkpoint omission metadata is invalid.") from exc
|
|
1538
|
+
if not isinstance(values, (list, tuple)):
|
|
1539
|
+
raise ChangeLedgerError("Checkpoint omission metadata is invalid.")
|
|
1540
|
+
result: set[str] = set()
|
|
1541
|
+
for value in values:
|
|
1542
|
+
if not isinstance(value, str):
|
|
1543
|
+
raise ChangeLedgerError("Checkpoint omission metadata is invalid.")
|
|
1544
|
+
if value == _ALL_PATHS_NON_REVERTIBLE:
|
|
1545
|
+
result.add(value)
|
|
1546
|
+
continue
|
|
1547
|
+
trailing_slash = value.replace("\\", "/").endswith("/")
|
|
1548
|
+
normalized = value.replace("\\", "/").strip("/")
|
|
1549
|
+
if (
|
|
1550
|
+
not normalized
|
|
1551
|
+
or normalized.startswith("../")
|
|
1552
|
+
or "/../" in f"/{normalized}/"
|
|
1553
|
+
or "\x00" in normalized
|
|
1554
|
+
):
|
|
1555
|
+
raise ChangeLedgerError("Checkpoint omission metadata is invalid.")
|
|
1556
|
+
result.add(f"{normalized}/" if trailing_slash else normalized)
|
|
1557
|
+
return result
|
|
1558
|
+
|
|
1559
|
+
|
|
1560
|
+
def _path_is_non_revertible(rel_path: str, protected: set[str]) -> bool:
|
|
1561
|
+
normalized = rel_path.replace("\\", "/").strip("/")
|
|
1562
|
+
if _ALL_PATHS_NON_REVERTIBLE in protected or normalized in protected:
|
|
1563
|
+
return True
|
|
1564
|
+
return any(
|
|
1565
|
+
item.endswith("/") and normalized.startswith(item)
|
|
1566
|
+
for item in protected
|
|
1567
|
+
if item != _ALL_PATHS_NON_REVERTIBLE
|
|
1568
|
+
)
|
|
1569
|
+
|
|
1570
|
+
|
|
1571
|
+
def _is_runtime_or_sensitive(rel_path: str) -> bool:
|
|
1572
|
+
parts = tuple(part.lower() for part in Path(rel_path).parts)
|
|
1573
|
+
if not parts or parts[0] in {".git", ".alysis"}:
|
|
1574
|
+
return True
|
|
1575
|
+
# Keep the checkpoint boundary aligned with the filesystem tools' canonical
|
|
1576
|
+
# credential policy. A narrower second list here previously allowed files
|
|
1577
|
+
# such as .npmrc, .netrc, cloud credentials, secret YAML, and keystores to
|
|
1578
|
+
# be copied into the external Git object database.
|
|
1579
|
+
return classify_sensitive_path(rel_path).sensitive
|
|
1580
|
+
|
|
1581
|
+
|
|
1582
|
+
def _safe_workspace_path(root: Path, rel_path: str) -> Path:
|
|
1583
|
+
normalized = rel_path.replace("\\", "/").lstrip("/")
|
|
1584
|
+
unresolved = root / normalized
|
|
1585
|
+
# Resolve the parent chain to reject directory symlink escapes, but retain
|
|
1586
|
+
# the final component so a checkpointed symlink is inspected/restored as a
|
|
1587
|
+
# link rather than following it to its destination.
|
|
1588
|
+
candidate = unresolved.parent.resolve(strict=False) / unresolved.name
|
|
1589
|
+
try:
|
|
1590
|
+
candidate.relative_to(root)
|
|
1591
|
+
except ValueError:
|
|
1592
|
+
raise ChangeLedgerError("Checkpoint path escapes the workspace.") from None
|
|
1593
|
+
return candidate
|
|
1594
|
+
|
|
1595
|
+
|
|
1596
|
+
def _normalize_scope_paths(root: Path, paths: Iterable[object]) -> tuple[str, ...]:
|
|
1597
|
+
normalized_paths: set[str] = set()
|
|
1598
|
+
for raw in paths:
|
|
1599
|
+
value = str(raw or "").replace("\\", "/").strip()
|
|
1600
|
+
if not value:
|
|
1601
|
+
continue
|
|
1602
|
+
candidate_path = Path(value)
|
|
1603
|
+
if candidate_path.is_absolute():
|
|
1604
|
+
raise ChangeLedgerError("Checkpoint paths must be workspace-relative.")
|
|
1605
|
+
parts = tuple(part for part in value.split("/") if part not in {"", "."})
|
|
1606
|
+
if not parts or any(part == ".." for part in parts):
|
|
1607
|
+
raise ChangeLedgerError("Checkpoint paths must identify entries inside the workspace.")
|
|
1608
|
+
normalized = "/".join(parts)
|
|
1609
|
+
_safe_workspace_path(root, normalized)
|
|
1610
|
+
normalized_paths.add(normalized)
|
|
1611
|
+
return tuple(sorted(normalized_paths))
|
|
1612
|
+
|
|
1613
|
+
|
|
1614
|
+
def _record_paths(records: tuple[ChangeRecord, ...]) -> tuple[str, ...]:
|
|
1615
|
+
paths: set[str] = set()
|
|
1616
|
+
for record in records:
|
|
1617
|
+
paths.add(record.path)
|
|
1618
|
+
if record.previous_path:
|
|
1619
|
+
paths.add(record.previous_path)
|
|
1620
|
+
return tuple(sorted(paths))
|
|
1621
|
+
|
|
1622
|
+
|
|
1623
|
+
def _batches(paths: list[str], size: int = 128) -> list[list[str]]:
|
|
1624
|
+
return [paths[index : index + size] for index in range(0, len(paths), size)]
|
|
1625
|
+
|
|
1626
|
+
|
|
1627
|
+
def _atomic_write(path: Path, data: bytes, *, mode: int | None) -> None:
|
|
1628
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1629
|
+
handle, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
1630
|
+
temporary_path = Path(temporary)
|
|
1631
|
+
try:
|
|
1632
|
+
with os.fdopen(handle, "wb") as stream:
|
|
1633
|
+
stream.write(data)
|
|
1634
|
+
stream.flush()
|
|
1635
|
+
os.fsync(stream.fileno())
|
|
1636
|
+
if mode is not None:
|
|
1637
|
+
os.chmod(temporary_path, mode)
|
|
1638
|
+
os.replace(temporary_path, path)
|
|
1639
|
+
finally:
|
|
1640
|
+
if temporary_path.exists():
|
|
1641
|
+
temporary_path.unlink()
|
|
1642
|
+
|
|
1643
|
+
|
|
1644
|
+
def _stage_file(target: Path, data: bytes, *, mode: int) -> Path:
|
|
1645
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1646
|
+
handle, temporary = tempfile.mkstemp(
|
|
1647
|
+
prefix=f".{target.name}.", suffix=".staged", dir=target.parent
|
|
1648
|
+
)
|
|
1649
|
+
temporary_path = Path(temporary)
|
|
1650
|
+
try:
|
|
1651
|
+
with os.fdopen(handle, "wb") as stream:
|
|
1652
|
+
stream.write(data)
|
|
1653
|
+
stream.flush()
|
|
1654
|
+
os.fsync(stream.fileno())
|
|
1655
|
+
os.chmod(temporary_path, mode)
|
|
1656
|
+
return temporary_path
|
|
1657
|
+
except Exception:
|
|
1658
|
+
temporary_path.unlink(missing_ok=True)
|
|
1659
|
+
raise
|
|
1660
|
+
|
|
1661
|
+
|
|
1662
|
+
def _reserve_displaced_path(target: Path) -> Path:
|
|
1663
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1664
|
+
handle, temporary = tempfile.mkstemp(
|
|
1665
|
+
prefix=f".{target.name}.", suffix=".displaced", dir=target.parent
|
|
1666
|
+
)
|
|
1667
|
+
os.close(handle)
|
|
1668
|
+
return Path(temporary)
|
|
1669
|
+
|
|
1670
|
+
|
|
1671
|
+
def _publish_entry_no_replace(source: Path, target: Path) -> bool:
|
|
1672
|
+
"""Publish ``source`` at an unclaimed name and consume it on success.
|
|
1673
|
+
|
|
1674
|
+
``False`` means only that another writer already owns ``target``. Other
|
|
1675
|
+
filesystem failures must remain distinguishable so restore callers keep
|
|
1676
|
+
the displaced recovery entry instead of treating an I/O failure as a
|
|
1677
|
+
harmless compare-and-swap miss.
|
|
1678
|
+
"""
|
|
1679
|
+
|
|
1680
|
+
try:
|
|
1681
|
+
info = source.lstat()
|
|
1682
|
+
if stat.S_ISREG(info.st_mode):
|
|
1683
|
+
os.link(source, target, follow_symlinks=False)
|
|
1684
|
+
elif stat.S_ISLNK(info.st_mode):
|
|
1685
|
+
os.symlink(os.readlink(source), target)
|
|
1686
|
+
else:
|
|
1687
|
+
raise ChangeLedgerError("Unsupported checkpoint recovery entry type.")
|
|
1688
|
+
except FileExistsError:
|
|
1689
|
+
return False
|
|
1690
|
+
source.unlink()
|
|
1691
|
+
return True
|
|
1692
|
+
|
|
1693
|
+
|
|
1694
|
+
def _stat_fingerprint(value: os.stat_result) -> tuple[int, int, int, int, int, int]:
|
|
1695
|
+
return (
|
|
1696
|
+
int(getattr(value, "st_dev", 0)),
|
|
1697
|
+
int(getattr(value, "st_ino", 0)),
|
|
1698
|
+
int(value.st_size),
|
|
1699
|
+
int(getattr(value, "st_mtime_ns", int(value.st_mtime * 1_000_000_000))),
|
|
1700
|
+
int(getattr(value, "st_ctime_ns", int(value.st_ctime * 1_000_000_000))),
|
|
1701
|
+
int(value.st_mode),
|
|
1702
|
+
)
|
|
1703
|
+
|
|
1704
|
+
|
|
1705
|
+
def _remove_empty_parents(path: Path, *, stop: Path) -> None:
|
|
1706
|
+
current = path
|
|
1707
|
+
while current != stop:
|
|
1708
|
+
try:
|
|
1709
|
+
current.rmdir()
|
|
1710
|
+
except OSError:
|
|
1711
|
+
return
|
|
1712
|
+
current = current.parent
|
|
1713
|
+
|
|
1714
|
+
|
|
1715
|
+
__all__ = [
|
|
1716
|
+
"ChangeLedger",
|
|
1717
|
+
"ChangeLedgerError",
|
|
1718
|
+
"ChangeRecord",
|
|
1719
|
+
"Checkpoint",
|
|
1720
|
+
"StaleWorkspaceError",
|
|
1721
|
+
]
|