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.
Files changed (458) hide show
  1. alysis_code/__init__.py +3 -0
  2. alysis_code/__main__.py +4 -0
  3. alysis_code/_build_info.py +14 -0
  4. alysis_code/account_login.py +468 -0
  5. alysis_code/agent/README.md +35 -0
  6. alysis_code/agent/__init__.py +11 -0
  7. alysis_code/agent/acceptance_contract.py +2217 -0
  8. alysis_code/agent/blast_radius.py +1403 -0
  9. alysis_code/agent/cache_keepalive.py +227 -0
  10. alysis_code/agent/completion_certificate.py +366 -0
  11. alysis_code/agent/completion_gate.py +306 -0
  12. alysis_code/agent/empty_response_stall.py +403 -0
  13. alysis_code/agent/errors.py +28 -0
  14. alysis_code/agent/llm_calls.py +475 -0
  15. alysis_code/agent/mutation_classification.py +227 -0
  16. alysis_code/agent/prompt_context.py +2508 -0
  17. alysis_code/agent/read_ledger.py +253 -0
  18. alysis_code/agent/regression_baseline.py +642 -0
  19. alysis_code/agent/reproduction_first.py +610 -0
  20. alysis_code/agent/sensitive_output.py +629 -0
  21. alysis_code/agent/session.py +3218 -0
  22. alysis_code/agent/steering.py +191 -0
  23. alysis_code/agent/subagent_execution.py +5177 -0
  24. alysis_code/agent/subagent_workspace.py +666 -0
  25. alysis_code/agent/tools_assembly.py +4728 -0
  26. alysis_code/agent/turn/__init__.py +101 -0
  27. alysis_code/agent/turn/core.py +8483 -0
  28. alysis_code/agent/turn/events.py +113 -0
  29. alysis_code/agent/turn/exploration.py +590 -0
  30. alysis_code/agent/turn/interventions.py +65 -0
  31. alysis_code/agent/turn/read_cache.py +420 -0
  32. alysis_code/agent/turn/snapshot.py +179 -0
  33. alysis_code/agent/turn_contract.py +661 -0
  34. alysis_code/agent/turn_path.py +129 -0
  35. alysis_code/agent/verification.py +2885 -0
  36. alysis_code/agent/verification_commands.py +512 -0
  37. alysis_code/agent/verification_evidence.py +738 -0
  38. alysis_code/agent_loop.py +693 -0
  39. alysis_code/agent_runtimes/__init__.py +51 -0
  40. alysis_code/agent_runtimes/base.py +114 -0
  41. alysis_code/agent_runtimes/builtins.py +129 -0
  42. alysis_code/agent_runtimes/codex_cli.py +664 -0
  43. alysis_code/agent_runtimes/host.py +263 -0
  44. alysis_code/agent_runtimes/registry.py +64 -0
  45. alysis_code/agent_runtimes/service.py +150 -0
  46. alysis_code/agentbox_client.py +416 -0
  47. alysis_code/agentbox_integration.py +310 -0
  48. alysis_code/alysis_cloud.py +152 -0
  49. alysis_code/approval_scope.py +276 -0
  50. alysis_code/assets/README.md +33 -0
  51. alysis_code/assets/__init__.py +126 -0
  52. alysis_code/assets/asset_read_core.py +281 -0
  53. alysis_code/assets/budget_allocator.py +456 -0
  54. alysis_code/assets/comprehender.py +759 -0
  55. alysis_code/assets/index.py +654 -0
  56. alysis_code/assets/ingestion.py +275 -0
  57. alysis_code/assets/legacy_migration.py +413 -0
  58. alysis_code/assets/models.py +263 -0
  59. alysis_code/assets/ocr.py +239 -0
  60. alysis_code/assets/owl/ascii/f-000.txt +13 -0
  61. alysis_code/assets/owl/ascii/f-001.txt +13 -0
  62. alysis_code/assets/owl/ascii/f-002.txt +13 -0
  63. alysis_code/assets/owl/ascii/f-003.txt +13 -0
  64. alysis_code/assets/owl/ascii/f-004.txt +13 -0
  65. alysis_code/assets/owl/ascii/f-005.txt +13 -0
  66. alysis_code/assets/owl/ascii/f-006.txt +13 -0
  67. alysis_code/assets/owl/ascii/f-007.txt +13 -0
  68. alysis_code/assets/owl/ascii/f-008.txt +13 -0
  69. alysis_code/assets/owl/ascii/f-009.txt +13 -0
  70. alysis_code/assets/owl/ascii/f-010.txt +13 -0
  71. alysis_code/assets/owl/ascii/f-011.txt +13 -0
  72. alysis_code/assets/owl/ascii/f-012.txt +13 -0
  73. alysis_code/assets/owl/ascii/f-013.txt +13 -0
  74. alysis_code/assets/owl/ascii/f-014.txt +13 -0
  75. alysis_code/assets/owl/ascii/f-015.txt +13 -0
  76. alysis_code/assets/owl/ascii/f-016.txt +13 -0
  77. alysis_code/assets/owl/ascii/f-017.txt +13 -0
  78. alysis_code/assets/owl/ascii/f-018.txt +13 -0
  79. alysis_code/assets/owl/ascii/f-019.txt +13 -0
  80. alysis_code/assets/owl/ascii/f-020.txt +13 -0
  81. alysis_code/assets/owl/index.html +98 -0
  82. alysis_code/assets/owl/show-owl.sh +761 -0
  83. alysis_code/assets/paths.py +49 -0
  84. alysis_code/assets/plan_binding.py +326 -0
  85. alysis_code/assets/planner_context.py +466 -0
  86. alysis_code/assets/planner_tools.py +184 -0
  87. alysis_code/assets/prompts.py +101 -0
  88. alysis_code/assets/replanner_context.py +239 -0
  89. alysis_code/assets/surface.py +521 -0
  90. alysis_code/assets/untrusted_content.py +48 -0
  91. alysis_code/assets/usage_logger.py +94 -0
  92. alysis_code/assets/worker_mirror.py +428 -0
  93. alysis_code/assets/worker_section.py +303 -0
  94. alysis_code/assets/worker_tools.py +468 -0
  95. alysis_code/atomic_io.py +83 -0
  96. alysis_code/auth_diagnostics.py +272 -0
  97. alysis_code/background_runner.py +366 -0
  98. alysis_code/branding.py +270 -0
  99. alysis_code/budget_policy.py +390 -0
  100. alysis_code/build_identity.py +465 -0
  101. alysis_code/builtin_hooks/__init__.py +7 -0
  102. alysis_code/builtin_hooks/notify_done_windows.py +65 -0
  103. alysis_code/bwrap_etc.py +76 -0
  104. alysis_code/cancellation.py +41 -0
  105. alysis_code/capabilities.py +137 -0
  106. alysis_code/chatgpt_codex_static_provider.py +133 -0
  107. alysis_code/cli.py +51 -0
  108. alysis_code/cli_impl/__init__.py +1 -0
  109. alysis_code/cli_impl/assets_cli.py +537 -0
  110. alysis_code/cli_impl/assets_modal.py +412 -0
  111. alysis_code/cli_impl/chat/__init__.py +156 -0
  112. alysis_code/cli_impl/chat/commands.py +2616 -0
  113. alysis_code/cli_impl/chat/loop.py +4508 -0
  114. alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
  115. alysis_code/cli_impl/chat/rendering.py +444 -0
  116. alysis_code/cli_impl/chat/state.py +124 -0
  117. alysis_code/cli_impl/chat_resume.py +830 -0
  118. alysis_code/cli_impl/chat_slash_completer.py +258 -0
  119. alysis_code/cli_impl/commands/__init__.py +11 -0
  120. alysis_code/cli_impl/commands/_shared.py +89 -0
  121. alysis_code/cli_impl/commands/auth.py +623 -0
  122. alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
  123. alysis_code/cli_impl/commands/chat_state.py +158 -0
  124. alysis_code/cli_impl/commands/chat_status.py +1248 -0
  125. alysis_code/cli_impl/commands/chat_terminal.py +942 -0
  126. alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
  127. alysis_code/cli_impl/commands/cli_common.py +1223 -0
  128. alysis_code/cli_impl/commands/cli_surface.py +77 -0
  129. alysis_code/cli_impl/commands/config.py +131 -0
  130. alysis_code/cli_impl/commands/conventions.py +85 -0
  131. alysis_code/cli_impl/commands/execution_helpers.py +350 -0
  132. alysis_code/cli_impl/commands/extensions.py +401 -0
  133. alysis_code/cli_impl/commands/forge.py +1282 -0
  134. alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
  135. alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
  136. alysis_code/cli_impl/commands/hooks.py +737 -0
  137. alysis_code/cli_impl/commands/ide_bridge.py +31 -0
  138. alysis_code/cli_impl/commands/mcp.py +700 -0
  139. alysis_code/cli_impl/commands/profile.py +453 -0
  140. alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
  141. alysis_code/cli_impl/commands/report.py +88 -0
  142. alysis_code/cli_impl/commands/root.py +1118 -0
  143. alysis_code/cli_impl/commands/sandbox.py +184 -0
  144. alysis_code/cli_impl/commands/server.py +54 -0
  145. alysis_code/cli_impl/commands/sessions.py +252 -0
  146. alysis_code/cli_impl/commands/skills.py +404 -0
  147. alysis_code/cli_impl/commands/startup.py +946 -0
  148. alysis_code/cli_impl/commands/tools.py +335 -0
  149. alysis_code/cli_impl/commands/update.py +364 -0
  150. alysis_code/cli_impl/commands/welcome.py +972 -0
  151. alysis_code/cli_impl/config_menu.py +3882 -0
  152. alysis_code/cli_impl/forge.py +4509 -0
  153. alysis_code/cli_impl/forge_recovery.py +485 -0
  154. alysis_code/cli_impl/setup_wizard.py +2409 -0
  155. alysis_code/cli_impl/tui/__init__.py +58 -0
  156. alysis_code/cli_impl/tui/app.py +4551 -0
  157. alysis_code/cli_impl/tui/config.py +32 -0
  158. alysis_code/cli_impl/tui/config_flow.py +2754 -0
  159. alysis_code/cli_impl/tui/config_overlay.py +566 -0
  160. alysis_code/cli_impl/tui/content.py +78 -0
  161. alysis_code/cli_impl/tui/footer.py +218 -0
  162. alysis_code/cli_impl/tui/forge_status.py +136 -0
  163. alysis_code/cli_impl/tui/markdown.py +244 -0
  164. alysis_code/cli_impl/tui/owl.py +109 -0
  165. alysis_code/cli_impl/tui/plan_meta.py +477 -0
  166. alysis_code/cli_impl/tui/setup_app.py +519 -0
  167. alysis_code/cli_impl/tui/setup_flow.py +1622 -0
  168. alysis_code/cli_impl/tui/state.py +101 -0
  169. alysis_code/cli_impl/tui/subagent_identity.py +66 -0
  170. alysis_code/cli_impl/tui/subagent_panel.py +186 -0
  171. alysis_code/cli_impl/tui/surface.py +796 -0
  172. alysis_code/cli_impl/tui/transcript.py +514 -0
  173. alysis_code/cli_impl/tui/update_prompt.py +79 -0
  174. alysis_code/cli_impl/tui/workspace_guard.py +384 -0
  175. alysis_code/clipboard.py +172 -0
  176. alysis_code/code_review.py +1211 -0
  177. alysis_code/compaction/__init__.py +28 -0
  178. alysis_code/compaction/conversation_compactor.py +2932 -0
  179. alysis_code/compaction/importance.py +177 -0
  180. alysis_code/compaction/settings.py +297 -0
  181. alysis_code/compaction/tool_output_offload.py +447 -0
  182. alysis_code/config.py +3509 -0
  183. alysis_code/conflict_auto_resolver.py +895 -0
  184. alysis_code/context/__init__.py +1 -0
  185. alysis_code/context/tool_schema_budgeter.py +220 -0
  186. alysis_code/crash_diagnostics.py +282 -0
  187. alysis_code/custom_tools/README.md +34 -0
  188. alysis_code/custom_tools/__init__.py +43 -0
  189. alysis_code/custom_tools/discovery.py +903 -0
  190. alysis_code/custom_tools/runtime.py +1516 -0
  191. alysis_code/custom_tools/session.py +227 -0
  192. alysis_code/custom_tools/trust.py +232 -0
  193. alysis_code/diff_paths.py +113 -0
  194. alysis_code/direction_change.py +293 -0
  195. alysis_code/dispatch_timing.py +306 -0
  196. alysis_code/durable_service_manager.py +1236 -0
  197. alysis_code/edit_discipline.py +659 -0
  198. alysis_code/error_text.py +73 -0
  199. alysis_code/execution_budget.py +411 -0
  200. alysis_code/execution_context.py +915 -0
  201. alysis_code/execution_deadline.py +1065 -0
  202. alysis_code/execution_shared.py +1904 -0
  203. alysis_code/extensions/README.md +30 -0
  204. alysis_code/extensions/__init__.py +93 -0
  205. alysis_code/extensions/activation.py +138 -0
  206. alysis_code/extensions/install.py +1436 -0
  207. alysis_code/extensions/manifest.py +487 -0
  208. alysis_code/extensions/models.py +74 -0
  209. alysis_code/extensions/paths.py +56 -0
  210. alysis_code/extensions/registry.json +4 -0
  211. alysis_code/extensions/registry.py +52 -0
  212. alysis_code/extensions/state.py +83 -0
  213. alysis_code/extensions/workspace_trust.py +101 -0
  214. alysis_code/failed_task_evidence.py +369 -0
  215. alysis_code/failure_category.py +315 -0
  216. alysis_code/feedback_report.py +1647 -0
  217. alysis_code/file_classification.py +485 -0
  218. alysis_code/forge.py +2064 -0
  219. alysis_code/forge_completion.py +362 -0
  220. alysis_code/forge_events.py +475 -0
  221. alysis_code/frontmatter_utils.py +95 -0
  222. alysis_code/git_evidence.py +1181 -0
  223. alysis_code/git_ops.py +560 -0
  224. alysis_code/git_safe.py +62 -0
  225. alysis_code/git_worktrees.py +190 -0
  226. alysis_code/hooks/README.md +33 -0
  227. alysis_code/hooks/__init__.py +67 -0
  228. alysis_code/hooks/audit.py +171 -0
  229. alysis_code/hooks/config.py +225 -0
  230. alysis_code/hooks/dispatcher.py +1110 -0
  231. alysis_code/hooks/models.py +447 -0
  232. alysis_code/hooks/trust.py +202 -0
  233. alysis_code/host_actions.py +543 -0
  234. alysis_code/host_browser.py +103 -0
  235. alysis_code/ide/__init__.py +5 -0
  236. alysis_code/ide/activity_events.py +399 -0
  237. alysis_code/ide/approvals.py +337 -0
  238. alysis_code/ide/artifacts.py +153 -0
  239. alysis_code/ide/browser_egress_proxy.py +1076 -0
  240. alysis_code/ide/cdp_websocket_transport.py +1192 -0
  241. alysis_code/ide/change_ledger.py +1721 -0
  242. alysis_code/ide/context_blocks.py +979 -0
  243. alysis_code/ide/event_stream.py +531 -0
  244. alysis_code/ide/forge_protocol.py +3112 -0
  245. alysis_code/ide/forge_request_ledger.py +737 -0
  246. alysis_code/ide/health.py +965 -0
  247. alysis_code/ide/managed_browser.py +2251 -0
  248. alysis_code/ide/management_protocol.py +3414 -0
  249. alysis_code/ide/mcp_oauth_coordinator.py +744 -0
  250. alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
  251. alysis_code/ide/prompt_queue.py +1070 -0
  252. alysis_code/ide/protocol.py +191 -0
  253. alysis_code/ide/resumable_swarm.py +1543 -0
  254. alysis_code/ide/session_search.py +295 -0
  255. alysis_code/ide/stdio_bridge.py +9935 -0
  256. alysis_code/ide/structured_state.py +1579 -0
  257. alysis_code/ide/swarm_protocol.py +816 -0
  258. alysis_code/integration_gate.py +506 -0
  259. alysis_code/interactive_input_guard.py +39 -0
  260. alysis_code/interactive_plan_mode.py +26 -0
  261. alysis_code/internal_artifacts.py +179 -0
  262. alysis_code/knowledge_base.py +1409 -0
  263. alysis_code/knowledge_capture.py +1190 -0
  264. alysis_code/knowledge_librarian.py +605 -0
  265. alysis_code/language_policy.py +34 -0
  266. alysis_code/litellm_static_provider.py +535 -0
  267. alysis_code/llm/__init__.py +1 -0
  268. alysis_code/llm/anthropic_messages.py +2288 -0
  269. alysis_code/llm/base.py +71 -0
  270. alysis_code/llm/cache_capabilities.py +985 -0
  271. alysis_code/llm/cache_control_blocks.py +244 -0
  272. alysis_code/llm/cache_policy.py +388 -0
  273. alysis_code/llm/factory.py +373 -0
  274. alysis_code/llm/gemini_generate_content.py +2652 -0
  275. alysis_code/llm/gemini_interactions.py +739 -0
  276. alysis_code/llm/metadata.py +450 -0
  277. alysis_code/llm/openai_compat.py +2947 -0
  278. alysis_code/llm/openai_responses.py +2604 -0
  279. alysis_code/llm/protocols.py +609 -0
  280. alysis_code/llm/provider_limits.py +525 -0
  281. alysis_code/llm/request_plan.py +389 -0
  282. alysis_code/llm/request_shape.py +238 -0
  283. alysis_code/llm/streaming.py +108 -0
  284. alysis_code/llm/temperature_compat.py +78 -0
  285. alysis_code/llm/types.py +195 -0
  286. alysis_code/llm/usage_normalization.py +222 -0
  287. alysis_code/llm_error_display.py +315 -0
  288. alysis_code/logging_redaction.py +326 -0
  289. alysis_code/managed_host_deadline.py +196 -0
  290. alysis_code/mcp/README.md +33 -0
  291. alysis_code/mcp/__init__.py +24 -0
  292. alysis_code/mcp/client.py +1137 -0
  293. alysis_code/mcp/config.py +597 -0
  294. alysis_code/mcp/errors.py +113 -0
  295. alysis_code/mcp/forge_scope.py +154 -0
  296. alysis_code/mcp/jsonrpc.py +214 -0
  297. alysis_code/mcp/manager.py +2308 -0
  298. alysis_code/mcp/models.py +666 -0
  299. alysis_code/mcp/oauth.py +972 -0
  300. alysis_code/mcp/oauth_runtime.py +310 -0
  301. alysis_code/mcp/oauth_store.py +276 -0
  302. alysis_code/mcp/prompts.py +329 -0
  303. alysis_code/mcp/resources.py +295 -0
  304. alysis_code/mcp/roots.py +106 -0
  305. alysis_code/mcp/server_requests.py +75 -0
  306. alysis_code/mcp/token_store.py +859 -0
  307. alysis_code/mcp/transport_http.py +1338 -0
  308. alysis_code/mcp/transport_stdio.py +1267 -0
  309. alysis_code/mcp/untrusted_content.py +119 -0
  310. alysis_code/merge_conflict_reviewer.py +729 -0
  311. alysis_code/model_catalog/__init__.py +1 -0
  312. alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
  313. alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
  314. alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
  315. alysis_code/model_metadata_policy.py +223 -0
  316. alysis_code/model_metadata_utils.py +103 -0
  317. alysis_code/model_registry.py +1420 -0
  318. alysis_code/model_router.py +147 -0
  319. alysis_code/permission_policy.py +1016 -0
  320. alysis_code/personas.py +451 -0
  321. alysis_code/pipeline_facts.py +233 -0
  322. alysis_code/plan_assistant.py +4763 -0
  323. alysis_code/plan_mode.py +393 -0
  324. alysis_code/plan_reconciliation.py +1228 -0
  325. alysis_code/plan_repair.py +652 -0
  326. alysis_code/plan_validation.py +1099 -0
  327. alysis_code/planning_constraints.py +904 -0
  328. alysis_code/policy.py +95 -0
  329. alysis_code/preview_server.py +457 -0
  330. alysis_code/process_reaping.py +566 -0
  331. alysis_code/profile_presets.py +1834 -0
  332. alysis_code/profiles.py +666 -0
  333. alysis_code/provider_auth/__init__.py +29 -0
  334. alysis_code/provider_auth/base.py +99 -0
  335. alysis_code/provider_auth/openai_codex.py +951 -0
  336. alysis_code/provider_auth/registry.py +76 -0
  337. alysis_code/provider_auth/store.py +125 -0
  338. alysis_code/provider_diagnostics.py +1209 -0
  339. alysis_code/provider_model_catalog.py +685 -0
  340. alysis_code/provider_telemetry.py +1699 -0
  341. alysis_code/provider_url.py +75 -0
  342. alysis_code/reasoning_contracts.py +911 -0
  343. alysis_code/remote_sync.py +350 -0
  344. alysis_code/replanning.py +1195 -0
  345. alysis_code/repo_scan.py +1152 -0
  346. alysis_code/request_estimation.py +296 -0
  347. alysis_code/review_gate.py +617 -0
  348. alysis_code/run_lock.py +1141 -0
  349. alysis_code/run_outcome.py +58 -0
  350. alysis_code/run_provenance.py +774 -0
  351. alysis_code/run_state.py +445 -0
  352. alysis_code/runtime_artifacts.py +116 -0
  353. alysis_code/runtime_context_features.py +78 -0
  354. alysis_code/runtime_kind.py +52 -0
  355. alysis_code/safety/__init__.py +11 -0
  356. alysis_code/safety/mcp_sanitize.py +29 -0
  357. alysis_code/safety/safe_http.py +297 -0
  358. alysis_code/safety/subagent_report.py +184 -0
  359. alysis_code/sandbox_doctor.py +682 -0
  360. alysis_code/sandbox_runner.py +1025 -0
  361. alysis_code/sandbox_settings.py +423 -0
  362. alysis_code/serialized_paths.py +355 -0
  363. alysis_code/server/__init__.py +3 -0
  364. alysis_code/server/app.py +367 -0
  365. alysis_code/server/auth.py +34 -0
  366. alysis_code/server/job_config.py +30 -0
  367. alysis_code/server/settings.py +215 -0
  368. alysis_code/server/store.py +193 -0
  369. alysis_code/server/worker_runner.py +657 -0
  370. alysis_code/service_persistence.py +355 -0
  371. alysis_code/session_artifacts.py +108 -0
  372. alysis_code/session_metrics.py +331 -0
  373. alysis_code/session_store.py +624 -0
  374. alysis_code/skills/README.md +34 -0
  375. alysis_code/skills/__init__.py +104 -0
  376. alysis_code/skills/conventions.py +84 -0
  377. alysis_code/skills/discovery.py +176 -0
  378. alysis_code/skills/eval_models.py +232 -0
  379. alysis_code/skills/eval_runner.py +372 -0
  380. alysis_code/skills/evals.py +1344 -0
  381. alysis_code/skills/install.py +293 -0
  382. alysis_code/skills/loader.py +118 -0
  383. alysis_code/skills/matching.py +103 -0
  384. alysis_code/skills/models.py +71 -0
  385. alysis_code/skills/paths.py +56 -0
  386. alysis_code/skills/prompting.py +500 -0
  387. alysis_code/skills/scaffold.py +142 -0
  388. alysis_code/skills/state.py +441 -0
  389. alysis_code/skills/transactions.py +125 -0
  390. alysis_code/skills/validation.py +304 -0
  391. alysis_code/step_budget.py +238 -0
  392. alysis_code/subagent_labels.py +49 -0
  393. alysis_code/subagents.py +1072 -0
  394. alysis_code/surface/__init__.py +80 -0
  395. alysis_code/surface/base.py +305 -0
  396. alysis_code/surface/console.py +387 -0
  397. alysis_code/surface/events.py +372 -0
  398. alysis_code/surface/hidden_surface.py +529 -0
  399. alysis_code/surface/noop_surface.py +219 -0
  400. alysis_code/surface/rich_surface.py +1555 -0
  401. alysis_code/surface/styles.py +67 -0
  402. alysis_code/surface/theme.py +455 -0
  403. alysis_code/surface/types.py +100 -0
  404. alysis_code/swarm_backend.py +926 -0
  405. alysis_code/swarm_orchestrator.py +4020 -0
  406. alysis_code/swarm_scheduler.py +441 -0
  407. alysis_code/swarm_trace.py +429 -0
  408. alysis_code/swarm_worker.py +2119 -0
  409. alysis_code/swarm_write_guard.py +348 -0
  410. alysis_code/task_dependencies.py +170 -0
  411. alysis_code/task_readiness.py +992 -0
  412. alysis_code/task_scope.py +2148 -0
  413. alysis_code/terminal_manager.py +762 -0
  414. alysis_code/terminal_ownership.py +460 -0
  415. alysis_code/text_normalization.py +30 -0
  416. alysis_code/token_budget.py +97 -0
  417. alysis_code/tools/README.md +34 -0
  418. alysis_code/tools/__init__.py +1 -0
  419. alysis_code/tools/artifacts.py +127 -0
  420. alysis_code/tools/availability.py +188 -0
  421. alysis_code/tools/fs.py +1456 -0
  422. alysis_code/tools/git.py +461 -0
  423. alysis_code/tools/history.py +229 -0
  424. alysis_code/tools/http_timeout.py +78 -0
  425. alysis_code/tools/image_generation.py +552 -0
  426. alysis_code/tools/registry.py +2936 -0
  427. alysis_code/tools/repo_map.py +476 -0
  428. alysis_code/tools/search.py +563 -0
  429. alysis_code/tools/shell.py +135 -0
  430. alysis_code/tools/symbols.py +1350 -0
  431. alysis_code/tools/test_discovery.py +643 -0
  432. alysis_code/tools/web.py +482 -0
  433. alysis_code/tools/web_search.py +2012 -0
  434. alysis_code/tools/web_search_dashscope.py +557 -0
  435. alysis_code/tools/web_search_ddgs.py +221 -0
  436. alysis_code/tools/web_search_provider_adapters.py +1429 -0
  437. alysis_code/tools/web_search_tavily.py +194 -0
  438. alysis_code/updates.py +933 -0
  439. alysis_code/usage_tracker.py +1990 -0
  440. alysis_code/verification_command_analysis.py +1004 -0
  441. alysis_code/verification_contract.py +574 -0
  442. alysis_code/verification_failure_summary.py +273 -0
  443. alysis_code/verification_repair.py +385 -0
  444. alysis_code/verify_gate.py +3129 -0
  445. alysis_code/web_research.py +1872 -0
  446. alysis_code/web_search_adapters.py +66 -0
  447. alysis_code/web_search_policy.py +27 -0
  448. alysis_code/workspace_binding.py +389 -0
  449. alysis_code/workspace_binding_ui.py +408 -0
  450. alysis_code/workspace_context.py +273 -0
  451. alysis_code/workspace_isolation.py +138 -0
  452. alysis_code/workspace_provisioning.py +455 -0
  453. alysis_code-0.13.0.dist-info/METADATA +507 -0
  454. alysis_code-0.13.0.dist-info/RECORD +458 -0
  455. alysis_code-0.13.0.dist-info/WHEEL +4 -0
  456. alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
  457. alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
  458. 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
+ ]