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,1543 @@
1
+ """Durable, fenced coordination for resumable IDE swarm executions.
2
+
3
+ This module deliberately does not start threads or import the Forge runner. It
4
+ is the persistence and authority boundary a bridge can place around any
5
+ background executor:
6
+
7
+ 1. create a job with a non-secret execution specification and permission scope,
8
+ 2. claim it and hand the returned :class:`SwarmWorkerLease` to one worker,
9
+ 3. renew the lease while executing and record bounded usage,
10
+ 4. complete, interrupt, or fail using that same lease, and
11
+ 5. after a crash, explicitly resume an expired/interrupted job only if the
12
+ freshly evaluated permission scope still has the same fingerprint.
13
+
14
+ SQLite ``BEGIN IMMEDIATE`` transactions serialize cross-process state changes.
15
+ Worker and owner authority values are stored only as SHA-256 digests; public
16
+ records never contain a lease token, worker identity, idempotency key, execution
17
+ specification, or permission descriptor.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import hmac
24
+ import json
25
+ import math
26
+ import os
27
+ import re
28
+ import secrets
29
+ import sqlite3
30
+ import threading
31
+ import time
32
+ import uuid
33
+ from collections.abc import Callable, Mapping
34
+ from contextlib import contextmanager
35
+ from dataclasses import dataclass, field
36
+ from enum import Enum
37
+ from pathlib import Path
38
+ from typing import Any, TypeAlias
39
+
40
+ from ..branding import canonical_user_data_dir, env_get
41
+
42
+ JsonScalar: TypeAlias = None | bool | int | float | str
43
+ JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
44
+
45
+ SCHEMA_VERSION = 1
46
+ DEFAULT_LEASE_SECONDS = 120.0
47
+ MIN_LEASE_SECONDS = 1.0
48
+ MAX_LEASE_SECONDS = 24.0 * 60.0 * 60.0
49
+ MAX_USAGE_VALUE = 9_000_000_000_000_000_000
50
+
51
+ _IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]*$")
52
+ _ERROR_CODE_RE = re.compile(r"^[a-z][a-z0-9_.-]*$")
53
+ _CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]+")
54
+ _SECRET_ASSIGNMENT_RE = re.compile(
55
+ r"(?i)\b(api[_-]?key|access[_-]?token|auth[_-]?token|password|passwd|secret|credential)"
56
+ r"\s*[:=]\s*(?:\"[^\"]*\"|'[^']*'|[^\s,;]+)"
57
+ )
58
+ _SECRET_TOKEN_RE = re.compile(
59
+ r"(?i)(?:sk-[A-Za-z0-9_-]{8,}|gh[opusr]_[A-Za-z0-9]{12,}|"
60
+ r"xox[baprs]-[A-Za-z0-9-]{8,}|Bearer\s+[A-Za-z0-9._~+/=-]{8,})"
61
+ )
62
+ _SENSITIVE_KEY_PARTS = frozenset(
63
+ {
64
+ "api_key",
65
+ "apikey",
66
+ "access_token",
67
+ "auth_token",
68
+ "authorization",
69
+ "client_secret",
70
+ "cookie",
71
+ "cookies",
72
+ "credential",
73
+ "credentials",
74
+ "lease_token",
75
+ "password",
76
+ "passwd",
77
+ "private_key",
78
+ "refresh_token",
79
+ "secret",
80
+ "set_cookie",
81
+ "token",
82
+ }
83
+ )
84
+ _SENSITIVE_KEY_SUFFIXES = (
85
+ "_api_key",
86
+ "_access_token",
87
+ "_auth_token",
88
+ "_refresh_token",
89
+ "_client_secret",
90
+ "_credential",
91
+ "_credentials",
92
+ "_lease_token",
93
+ "_password",
94
+ "_private_key",
95
+ "_secret",
96
+ )
97
+ _SENSITIVE_KEY_PREFIXES = (
98
+ "api_key_",
99
+ "access_token_",
100
+ "auth_token_",
101
+ "client_secret_",
102
+ "cookie_",
103
+ "credential_",
104
+ "lease_token_",
105
+ "password_",
106
+ "private_key_",
107
+ "refresh_token_",
108
+ )
109
+ _REDACTED = "[REDACTED]"
110
+
111
+
112
+ class SwarmJobState(str, Enum):
113
+ QUEUED = "queued"
114
+ RUNNING = "running"
115
+ INTERRUPTED = "interrupted"
116
+ SUCCEEDED = "succeeded"
117
+ FAILED = "failed"
118
+ CANCELLED = "cancelled"
119
+
120
+
121
+ TERMINAL_STATES = frozenset(
122
+ {SwarmJobState.SUCCEEDED, SwarmJobState.FAILED, SwarmJobState.CANCELLED}
123
+ )
124
+
125
+
126
+ class ResumableSwarmError(RuntimeError):
127
+ """Base error whose message is safe to surface to an IDE client."""
128
+
129
+
130
+ class SwarmValidationError(ResumableSwarmError, ValueError):
131
+ pass
132
+
133
+
134
+ class SwarmStorageError(ResumableSwarmError):
135
+ pass
136
+
137
+
138
+ class SwarmJobNotFound(ResumableSwarmError):
139
+ pass
140
+
141
+
142
+ class SwarmJobStateError(ResumableSwarmError):
143
+ pass
144
+
145
+
146
+ class SwarmLeaseLost(ResumableSwarmError):
147
+ pass
148
+
149
+
150
+ class SwarmPermissionScopeChanged(ResumableSwarmError):
151
+ pass
152
+
153
+
154
+ class SwarmFreshPermissionGrantRequired(ResumableSwarmError):
155
+ pass
156
+
157
+
158
+ class SwarmRevisionConflict(ResumableSwarmError):
159
+ def __init__(self, current_revision: int) -> None:
160
+ super().__init__("Swarm job changed; reload it before updating.")
161
+ self.current_revision = current_revision
162
+
163
+
164
+ class SwarmIdempotencyConflict(ResumableSwarmError):
165
+ pass
166
+
167
+
168
+ class SwarmCapacityError(ResumableSwarmError):
169
+ pass
170
+
171
+
172
+ @dataclass(frozen=True, slots=True)
173
+ class ResumableSwarmConfig:
174
+ max_execution_spec_bytes: int = 256 * 1024
175
+ max_result_bytes: int = 1024 * 1024
176
+ max_json_depth: int = 20
177
+ max_json_nodes: int = 20_000
178
+ max_string_chars: int = 16_384
179
+ max_error_summary_chars: int = 600
180
+ max_outstanding_per_session: int = 100
181
+ max_list_limit: int = 200
182
+ busy_timeout_seconds: float = 10.0
183
+
184
+ def __post_init__(self) -> None:
185
+ integers = (
186
+ self.max_execution_spec_bytes,
187
+ self.max_result_bytes,
188
+ self.max_json_depth,
189
+ self.max_json_nodes,
190
+ self.max_string_chars,
191
+ self.max_error_summary_chars,
192
+ self.max_outstanding_per_session,
193
+ self.max_list_limit,
194
+ )
195
+ if any(
196
+ isinstance(value, bool) or not isinstance(value, int) or value <= 0
197
+ for value in integers
198
+ ):
199
+ raise SwarmValidationError("Resumable swarm limits must be positive integers.")
200
+ if self.max_error_summary_chars < 4:
201
+ raise SwarmValidationError("Resumable swarm error summary limit is too small.")
202
+ if (
203
+ self.max_execution_spec_bytes > 4 * 1024 * 1024
204
+ or self.max_result_bytes > 8 * 1024 * 1024
205
+ or self.max_json_depth > 64
206
+ or self.max_json_nodes > 200_000
207
+ or self.max_string_chars > 256 * 1024
208
+ or self.max_error_summary_chars > 4_096
209
+ or self.max_outstanding_per_session > 10_000
210
+ or self.max_list_limit > 1_000
211
+ ):
212
+ raise SwarmValidationError("Resumable swarm limits exceed safe bounds.")
213
+ timeout = self.busy_timeout_seconds
214
+ if (
215
+ isinstance(timeout, bool)
216
+ or not isinstance(timeout, (int, float))
217
+ or not math.isfinite(timeout)
218
+ or not 0 < timeout <= 60
219
+ ):
220
+ raise SwarmValidationError("Resumable swarm busy timeout is invalid.")
221
+
222
+
223
+ @dataclass(frozen=True, slots=True)
224
+ class SwarmUsage:
225
+ calls: int = 0
226
+ input_tokens: int = 0
227
+ output_tokens: int = 0
228
+ cached_input_tokens: int = 0
229
+ total_tokens: int = 0
230
+
231
+ def public_payload(self) -> dict[str, int]:
232
+ return {
233
+ "calls": self.calls,
234
+ "input_tokens": self.input_tokens,
235
+ "output_tokens": self.output_tokens,
236
+ "cached_input_tokens": self.cached_input_tokens,
237
+ "total_tokens": self.total_tokens,
238
+ }
239
+
240
+
241
+ @dataclass(frozen=True, slots=True)
242
+ class SwarmJobStatus:
243
+ job_id: str
244
+ state: SwarmJobState
245
+ revision: int
246
+ attempts: int
247
+ resume_count: int
248
+ created_at: float
249
+ updated_at: float
250
+ started_at: float | None
251
+ terminal_at: float | None
252
+ lease_expires_at: float | None
253
+ result_available: bool
254
+ error_code: str | None
255
+ error_summary: str | None
256
+ usage: SwarmUsage
257
+
258
+ def public_payload(self) -> dict[str, Any]:
259
+ return {
260
+ "job_id": self.job_id,
261
+ "state": self.state.value,
262
+ "revision": self.revision,
263
+ "attempts": self.attempts,
264
+ "resume_count": self.resume_count,
265
+ "created_at": self.created_at,
266
+ "updated_at": self.updated_at,
267
+ "started_at": self.started_at,
268
+ "terminal_at": self.terminal_at,
269
+ "lease_expires_at": self.lease_expires_at,
270
+ "result_available": self.result_available,
271
+ "error_code": self.error_code,
272
+ "error_summary": self.error_summary,
273
+ "usage": self.usage.public_payload(),
274
+ "resumable": self.state in {SwarmJobState.INTERRUPTED, SwarmJobState.FAILED},
275
+ }
276
+
277
+
278
+ @dataclass(frozen=True, slots=True)
279
+ class SwarmJobResult:
280
+ status: SwarmJobStatus
281
+ result: dict[str, JsonValue] | None
282
+
283
+ def public_payload(self) -> dict[str, Any]:
284
+ return {**self.status.public_payload(), "result": self.result}
285
+
286
+
287
+ @dataclass(frozen=True, slots=True)
288
+ class SwarmJobCreation:
289
+ status: SwarmJobStatus
290
+ created: bool
291
+
292
+
293
+ @dataclass(frozen=True, slots=True)
294
+ class SwarmWorkerLease:
295
+ """Private worker authority; never serialize or return from a bridge method."""
296
+
297
+ job_id: str
298
+ session_id: str
299
+ generation: int
300
+ expires_at: float
301
+ execution_spec: dict[str, JsonValue] = field(repr=False)
302
+ lease_token: str = field(repr=False)
303
+
304
+
305
+ def default_resumable_swarm_path() -> Path:
306
+ override = str(env_get("ALYSIS_DATA_DIR") or "").strip()
307
+ data_dir = Path(override).expanduser() if override else canonical_user_data_dir()
308
+ return data_dir / "ide" / "resumable-swarm.sqlite3"
309
+
310
+
311
+ class DurableResumableSwarmCoordinator:
312
+ """Owner/workspace-scoped durable job coordinator.
313
+
314
+ The caller remains responsible for running the actual worker and stopping
315
+ its process tree. This class fences all durable acceptance so a cancelled,
316
+ expired, or superseded worker cannot publish usage or a final result.
317
+ """
318
+
319
+ def __init__(
320
+ self,
321
+ *,
322
+ owner_id: str,
323
+ workspace_root: str | os.PathLike[str],
324
+ path: str | os.PathLike[str] | None = None,
325
+ config: ResumableSwarmConfig | None = None,
326
+ clock: Callable[[], float] = time.time,
327
+ id_factory: Callable[[], str] | None = None,
328
+ lease_factory: Callable[[], str] | None = None,
329
+ ) -> None:
330
+ owner_id = _identifier(owner_id, "owner", maximum=256)
331
+ try:
332
+ workspace = Path(workspace_root).expanduser().resolve(strict=True)
333
+ except (OSError, RuntimeError, TypeError, ValueError):
334
+ raise SwarmValidationError("Resumable swarm workspace path is invalid.") from None
335
+ if not workspace.is_dir():
336
+ raise SwarmValidationError("Resumable swarm workspace must be a directory.")
337
+ try:
338
+ storage = (
339
+ Path(path).expanduser() if path is not None else default_resumable_swarm_path()
340
+ )
341
+ except (OSError, RuntimeError, TypeError, ValueError):
342
+ raise SwarmValidationError("Resumable swarm storage path is invalid.") from None
343
+ self.workspace_root = workspace
344
+ self.path = storage
345
+ self.config = config or ResumableSwarmConfig()
346
+ self._clock = clock
347
+ self._id_factory = id_factory or (lambda: uuid.uuid4().hex)
348
+ self._lease_factory = lease_factory or (lambda: f"lease_{secrets.token_urlsafe(32)}")
349
+ self._owner_key = _digest(owner_id)
350
+ self._workspace_key = _digest(os.path.normcase(os.fspath(workspace)))
351
+ self._lock = threading.RLock()
352
+ self._validate_storage_location()
353
+ self._prepare_storage()
354
+
355
+ def start_job(
356
+ self,
357
+ *,
358
+ session_id: str,
359
+ idempotency_key: str,
360
+ execution_spec: Mapping[str, Any],
361
+ permission_scope: Mapping[str, Any],
362
+ ) -> SwarmJobCreation:
363
+ """Create a queued job or return the identical prior request."""
364
+
365
+ session_id = _identifier(session_id, "session", maximum=256)
366
+ idempotency_key = _identifier(idempotency_key, "idempotency", maximum=256)
367
+ spec_json, _ = _serialize_json_object(
368
+ execution_spec,
369
+ config=self.config,
370
+ maximum_bytes=self.config.max_execution_spec_bytes,
371
+ label="execution specification",
372
+ redact=True,
373
+ )
374
+ spec_hash = hashlib.sha256(spec_json.encode("utf-8")).hexdigest()
375
+ permission_hash = _permission_fingerprint(permission_scope, self.config)
376
+ idempotency_hash = _digest(idempotency_key)
377
+ now = self._now()
378
+ with self._lock, self._transaction() as connection:
379
+ existing = connection.execute(
380
+ """
381
+ SELECT * FROM ide_swarm_jobs
382
+ WHERE owner_key = ? AND workspace_key = ? AND session_id = ?
383
+ AND idempotency_hash = ?
384
+ """,
385
+ (self._owner_key, self._workspace_key, session_id, idempotency_hash),
386
+ ).fetchone()
387
+ if existing is not None:
388
+ if (
389
+ existing["execution_spec_sha256"] != spec_hash
390
+ or existing["permission_scope_sha256"] != permission_hash
391
+ ):
392
+ raise SwarmIdempotencyConflict(
393
+ "Idempotency key was already used for a different swarm job."
394
+ )
395
+ return SwarmJobCreation(status=self._status(existing), created=False)
396
+ outstanding = int(
397
+ connection.execute(
398
+ """
399
+ SELECT COUNT(*) FROM ide_swarm_jobs
400
+ WHERE owner_key = ? AND workspace_key = ? AND session_id = ?
401
+ AND state IN ('queued', 'running', 'interrupted')
402
+ """,
403
+ (self._owner_key, self._workspace_key, session_id),
404
+ ).fetchone()[0]
405
+ )
406
+ if outstanding >= self.config.max_outstanding_per_session:
407
+ raise SwarmCapacityError("Session has too many outstanding swarm jobs.")
408
+ job_id = _identifier(self._id_factory(), "generated job", maximum=128)
409
+ try:
410
+ connection.execute(
411
+ """
412
+ INSERT INTO ide_swarm_jobs (
413
+ owner_key, workspace_key, session_id, job_id, idempotency_hash,
414
+ execution_spec_json, execution_spec_sha256,
415
+ permission_scope_sha256, state, revision, generation, attempts,
416
+ resume_count, created_at, updated_at, usage_calls,
417
+ usage_input_tokens, usage_output_tokens,
418
+ usage_cached_input_tokens, usage_total_tokens
419
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', 1, 0, 0, 0, ?, ?, 0, 0, 0, 0, 0)
420
+ """,
421
+ (
422
+ self._owner_key,
423
+ self._workspace_key,
424
+ session_id,
425
+ job_id,
426
+ idempotency_hash,
427
+ spec_json,
428
+ spec_hash,
429
+ permission_hash,
430
+ now,
431
+ now,
432
+ ),
433
+ )
434
+ except sqlite3.IntegrityError:
435
+ raise SwarmStorageError("Could not allocate a unique swarm job.") from None
436
+ row = self._job_row(connection, session_id=session_id, job_id=job_id)
437
+ return SwarmJobCreation(status=self._status(row), created=True)
438
+
439
+ def claim_job(
440
+ self,
441
+ *,
442
+ session_id: str,
443
+ job_id: str,
444
+ worker_id: str,
445
+ permission_scope: Mapping[str, Any],
446
+ lease_seconds: float = DEFAULT_LEASE_SECONDS,
447
+ ) -> SwarmWorkerLease:
448
+ """Claim one queued job after rechecking its current permission scope."""
449
+
450
+ session_id, job_id = _job_scope(session_id, job_id)
451
+ worker_id = _identifier(worker_id, "worker", maximum=256)
452
+ duration = _duration(lease_seconds)
453
+ permission_hash = _permission_fingerprint(permission_scope, self.config)
454
+ now = self._now()
455
+ expires_at = _deadline(now, duration)
456
+ token = _lease_token(self._lease_factory())
457
+ token_hash = _digest(token)
458
+ with self._lock, self._transaction() as connection:
459
+ row = self._job_row(connection, session_id=session_id, job_id=job_id)
460
+ _require_permission(row, permission_hash)
461
+ if row["state"] != SwarmJobState.QUEUED.value:
462
+ raise SwarmJobStateError("Swarm job is not queued for execution.")
463
+ generation = int(row["generation"]) + 1
464
+ connection.execute(
465
+ """
466
+ UPDATE ide_swarm_jobs
467
+ SET state = 'running', revision = revision + 1, generation = ?,
468
+ attempts = attempts + 1, updated_at = ?,
469
+ started_at = COALESCE(started_at, ?), lease_token_sha256 = ?,
470
+ lease_worker_sha256 = ?, lease_expires_at = ?,
471
+ terminal_at = NULL, error_code = NULL, error_summary = NULL,
472
+ result_json = NULL
473
+ WHERE owner_key = ? AND workspace_key = ? AND session_id = ? AND job_id = ?
474
+ """,
475
+ (
476
+ generation,
477
+ now,
478
+ now,
479
+ token_hash,
480
+ _digest(worker_id),
481
+ expires_at,
482
+ self._owner_key,
483
+ self._workspace_key,
484
+ session_id,
485
+ job_id,
486
+ ),
487
+ )
488
+ spec = _decode_stored_object(
489
+ row["execution_spec_json"],
490
+ label="execution specification",
491
+ config=self.config,
492
+ maximum_bytes=self.config.max_execution_spec_bytes,
493
+ )
494
+ return SwarmWorkerLease(
495
+ job_id=job_id,
496
+ session_id=session_id,
497
+ generation=generation,
498
+ expires_at=expires_at,
499
+ execution_spec=spec,
500
+ lease_token=token,
501
+ )
502
+
503
+ def renew(
504
+ self,
505
+ lease: SwarmWorkerLease,
506
+ *,
507
+ lease_seconds: float = DEFAULT_LEASE_SECONDS,
508
+ ) -> SwarmWorkerLease:
509
+ duration = _duration(lease_seconds)
510
+ now = self._now()
511
+ expires_at = _deadline(now, duration)
512
+ with self._lock, self._transaction() as connection:
513
+ row = self._require_live_lease(connection, lease=lease, now=now)
514
+ connection.execute(
515
+ """
516
+ UPDATE ide_swarm_jobs SET lease_expires_at = ?, updated_at = ?,
517
+ revision = revision + 1
518
+ WHERE owner_key = ? AND workspace_key = ? AND session_id = ? AND job_id = ?
519
+ """,
520
+ (
521
+ expires_at,
522
+ now,
523
+ self._owner_key,
524
+ self._workspace_key,
525
+ lease.session_id,
526
+ lease.job_id,
527
+ ),
528
+ )
529
+ return SwarmWorkerLease(
530
+ job_id=lease.job_id,
531
+ session_id=lease.session_id,
532
+ generation=lease.generation,
533
+ expires_at=expires_at,
534
+ execution_spec=_decode_stored_object(
535
+ row["execution_spec_json"],
536
+ label="execution specification",
537
+ config=self.config,
538
+ maximum_bytes=self.config.max_execution_spec_bytes,
539
+ ),
540
+ lease_token=lease.lease_token,
541
+ )
542
+
543
+ def record_usage(
544
+ self,
545
+ lease: SwarmWorkerLease,
546
+ usage: SwarmUsage,
547
+ *,
548
+ idempotency_key: str,
549
+ ) -> SwarmJobStatus:
550
+ """Add one exactly-once usage event while the worker lease is live."""
551
+
552
+ delta = _validated_usage(usage)
553
+ event_hash = _digest(_identifier(idempotency_key, "usage event", maximum=256))
554
+ now = self._now()
555
+ with self._lock, self._transaction() as connection:
556
+ row = self._require_live_lease(connection, lease=lease, now=now)
557
+ prior = connection.execute(
558
+ """
559
+ SELECT * FROM ide_swarm_usage_events
560
+ WHERE owner_key = ? AND workspace_key = ? AND session_id = ?
561
+ AND job_id = ? AND event_hash = ?
562
+ """,
563
+ (
564
+ self._owner_key,
565
+ self._workspace_key,
566
+ lease.session_id,
567
+ lease.job_id,
568
+ event_hash,
569
+ ),
570
+ ).fetchone()
571
+ if prior is not None:
572
+ prior_usage = SwarmUsage(
573
+ calls=_stored_nonnegative_int(prior["calls"]),
574
+ input_tokens=_stored_nonnegative_int(prior["input_tokens"]),
575
+ output_tokens=_stored_nonnegative_int(prior["output_tokens"]),
576
+ cached_input_tokens=_stored_nonnegative_int(prior["cached_input_tokens"]),
577
+ total_tokens=_stored_nonnegative_int(prior["total_tokens"]),
578
+ )
579
+ if prior_usage != delta:
580
+ raise SwarmIdempotencyConflict(
581
+ "Usage idempotency key was already used for a different event."
582
+ )
583
+ return self._status(row)
584
+ current = _usage_from_row(row)
585
+ combined = SwarmUsage(
586
+ calls=_safe_usage_sum(current.calls, delta.calls),
587
+ input_tokens=_safe_usage_sum(current.input_tokens, delta.input_tokens),
588
+ output_tokens=_safe_usage_sum(current.output_tokens, delta.output_tokens),
589
+ cached_input_tokens=_safe_usage_sum(
590
+ current.cached_input_tokens, delta.cached_input_tokens
591
+ ),
592
+ total_tokens=_safe_usage_sum(current.total_tokens, delta.total_tokens),
593
+ )
594
+ connection.execute(
595
+ """
596
+ INSERT INTO ide_swarm_usage_events (
597
+ owner_key, workspace_key, session_id, job_id, event_hash,
598
+ calls, input_tokens, output_tokens, cached_input_tokens,
599
+ total_tokens, recorded_at
600
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
601
+ """,
602
+ (
603
+ self._owner_key,
604
+ self._workspace_key,
605
+ lease.session_id,
606
+ lease.job_id,
607
+ event_hash,
608
+ delta.calls,
609
+ delta.input_tokens,
610
+ delta.output_tokens,
611
+ delta.cached_input_tokens,
612
+ delta.total_tokens,
613
+ now,
614
+ ),
615
+ )
616
+ connection.execute(
617
+ """
618
+ UPDATE ide_swarm_jobs
619
+ SET usage_calls = ?, usage_input_tokens = ?, usage_output_tokens = ?,
620
+ usage_cached_input_tokens = ?, usage_total_tokens = ?, updated_at = ?,
621
+ revision = revision + 1
622
+ WHERE owner_key = ? AND workspace_key = ? AND session_id = ? AND job_id = ?
623
+ """,
624
+ (
625
+ combined.calls,
626
+ combined.input_tokens,
627
+ combined.output_tokens,
628
+ combined.cached_input_tokens,
629
+ combined.total_tokens,
630
+ now,
631
+ self._owner_key,
632
+ self._workspace_key,
633
+ lease.session_id,
634
+ lease.job_id,
635
+ ),
636
+ )
637
+ return self._status(
638
+ self._job_row(connection, session_id=lease.session_id, job_id=lease.job_id)
639
+ )
640
+
641
+ def complete(
642
+ self,
643
+ lease: SwarmWorkerLease,
644
+ *,
645
+ result: Mapping[str, Any],
646
+ ) -> SwarmJobResult:
647
+ result_json, normalized = _serialize_json_object(
648
+ result,
649
+ config=self.config,
650
+ maximum_bytes=self.config.max_result_bytes,
651
+ label="swarm result",
652
+ redact=True,
653
+ )
654
+ now = self._now()
655
+ with self._lock, self._transaction() as connection:
656
+ self._require_live_lease(connection, lease=lease, now=now)
657
+ self._finish_locked(
658
+ connection,
659
+ lease=lease,
660
+ state=SwarmJobState.SUCCEEDED,
661
+ now=now,
662
+ result_json=result_json,
663
+ error_code=None,
664
+ error_summary=None,
665
+ )
666
+ status = self._status(
667
+ self._job_row(connection, session_id=lease.session_id, job_id=lease.job_id)
668
+ )
669
+ return SwarmJobResult(status=status, result=normalized)
670
+
671
+ def interrupt(
672
+ self,
673
+ lease: SwarmWorkerLease,
674
+ *,
675
+ error_code: str = "worker_interrupted",
676
+ error_summary: str = "The swarm worker was interrupted.",
677
+ ) -> SwarmJobStatus:
678
+ code = _error_code(error_code)
679
+ summary = _public_text(error_summary, maximum=self.config.max_error_summary_chars)
680
+ now = self._now()
681
+ with self._lock, self._transaction() as connection:
682
+ self._require_live_lease(connection, lease=lease, now=now)
683
+ self._finish_locked(
684
+ connection,
685
+ lease=lease,
686
+ state=SwarmJobState.INTERRUPTED,
687
+ now=now,
688
+ result_json=None,
689
+ error_code=code,
690
+ error_summary=summary,
691
+ )
692
+ return self._status(
693
+ self._job_row(connection, session_id=lease.session_id, job_id=lease.job_id)
694
+ )
695
+
696
+ def fail(
697
+ self,
698
+ lease: SwarmWorkerLease,
699
+ *,
700
+ error_code: str,
701
+ error_summary: str,
702
+ ) -> SwarmJobStatus:
703
+ code = _error_code(error_code)
704
+ summary = _public_text(error_summary, maximum=self.config.max_error_summary_chars)
705
+ now = self._now()
706
+ with self._lock, self._transaction() as connection:
707
+ self._require_live_lease(connection, lease=lease, now=now)
708
+ self._finish_locked(
709
+ connection,
710
+ lease=lease,
711
+ state=SwarmJobState.FAILED,
712
+ now=now,
713
+ result_json=None,
714
+ error_code=code,
715
+ error_summary=summary,
716
+ )
717
+ return self._status(
718
+ self._job_row(connection, session_id=lease.session_id, job_id=lease.job_id)
719
+ )
720
+
721
+ def cancel_job(
722
+ self,
723
+ *,
724
+ session_id: str,
725
+ job_id: str,
726
+ expected_revision: int | None = None,
727
+ ) -> SwarmJobStatus:
728
+ """Fence the active worker before reporting a job as cancelled."""
729
+
730
+ session_id, job_id = _job_scope(session_id, job_id)
731
+ revision = _optional_revision(expected_revision)
732
+ now = self._now()
733
+ with self._lock, self._transaction() as connection:
734
+ row = self._job_row(connection, session_id=session_id, job_id=job_id)
735
+ _check_revision(row, revision)
736
+ state = SwarmJobState(row["state"])
737
+ if state is SwarmJobState.CANCELLED:
738
+ return self._status(row)
739
+ if state in {SwarmJobState.SUCCEEDED, SwarmJobState.FAILED}:
740
+ raise SwarmJobStateError("Completed swarm job cannot be cancelled.")
741
+ connection.execute(
742
+ """
743
+ UPDATE ide_swarm_jobs
744
+ SET state = 'cancelled', revision = revision + 1,
745
+ generation = generation + 1, updated_at = ?, terminal_at = ?,
746
+ lease_token_sha256 = NULL, lease_worker_sha256 = NULL,
747
+ lease_expires_at = NULL, result_json = NULL,
748
+ error_code = 'cancelled', error_summary = 'The swarm job was cancelled.'
749
+ WHERE owner_key = ? AND workspace_key = ? AND session_id = ? AND job_id = ?
750
+ """,
751
+ (now, now, self._owner_key, self._workspace_key, session_id, job_id),
752
+ )
753
+ return self._status(self._job_row(connection, session_id=session_id, job_id=job_id))
754
+
755
+ def recover_stale_jobs(self, *, session_id: str | None = None) -> tuple[SwarmJobStatus, ...]:
756
+ """Fence expired workers and mark their jobs interrupted.
757
+
758
+ Recovery never silently requeues work. The caller must invoke
759
+ :meth:`resume_job`, which performs a fresh permission-scope check.
760
+ """
761
+
762
+ normalized_session = (
763
+ None if session_id is None else _identifier(session_id, "session", maximum=256)
764
+ )
765
+ now = self._now()
766
+ with self._lock, self._transaction() as connection:
767
+ clauses = ["owner_key = ?", "workspace_key = ?", "state = 'running'"]
768
+ values: list[Any] = [self._owner_key, self._workspace_key]
769
+ if normalized_session is not None:
770
+ clauses.append("session_id = ?")
771
+ values.append(normalized_session)
772
+ clauses.append("lease_expires_at <= ?")
773
+ values.append(now)
774
+ rows = connection.execute(
775
+ f"SELECT session_id, job_id FROM ide_swarm_jobs WHERE {' AND '.join(clauses)}", # noqa: S608 - clauses are fixed literals
776
+ values,
777
+ ).fetchall()
778
+ for row in rows:
779
+ connection.execute(
780
+ """
781
+ UPDATE ide_swarm_jobs
782
+ SET state = 'interrupted', revision = revision + 1,
783
+ generation = generation + 1, updated_at = ?,
784
+ lease_token_sha256 = NULL, lease_worker_sha256 = NULL,
785
+ lease_expires_at = NULL, result_json = NULL,
786
+ error_code = 'worker_lease_expired',
787
+ error_summary = 'The prior swarm worker lease expired.'
788
+ WHERE owner_key = ? AND workspace_key = ?
789
+ AND session_id = ? AND job_id = ? AND state = 'running'
790
+ AND lease_expires_at <= ?
791
+ """,
792
+ (
793
+ now,
794
+ self._owner_key,
795
+ self._workspace_key,
796
+ row["session_id"],
797
+ row["job_id"],
798
+ now,
799
+ ),
800
+ )
801
+ return tuple(
802
+ self._status(
803
+ self._job_row(
804
+ connection, session_id=str(row["session_id"]), job_id=str(row["job_id"])
805
+ )
806
+ )
807
+ for row in rows
808
+ )
809
+
810
+ def resume_job(
811
+ self,
812
+ *,
813
+ session_id: str,
814
+ job_id: str,
815
+ permission_scope: Mapping[str, Any],
816
+ fresh_permission_grant: Mapping[str, Any] | None = None,
817
+ expected_revision: int | None = None,
818
+ ) -> SwarmJobStatus:
819
+ """Explicitly requeue interrupted/failed work after a fresh scope check."""
820
+
821
+ session_id, job_id = _job_scope(session_id, job_id)
822
+ revision = _optional_revision(expected_revision)
823
+ permission_hash = _permission_fingerprint(permission_scope, self.config)
824
+ now = self._now()
825
+ with self._lock, self._transaction() as connection:
826
+ row = self._job_row(connection, session_id=session_id, job_id=job_id)
827
+ _check_revision(row, revision)
828
+ _require_fresh_permission_grant(
829
+ fresh_permission_grant,
830
+ row=row,
831
+ session_id=session_id,
832
+ job_id=job_id,
833
+ )
834
+ if (
835
+ row["state"] == SwarmJobState.RUNNING.value
836
+ and row["lease_expires_at"] is not None
837
+ and float(row["lease_expires_at"]) <= now
838
+ ):
839
+ connection.execute(
840
+ """
841
+ UPDATE ide_swarm_jobs
842
+ SET state = 'interrupted', revision = revision + 1,
843
+ generation = generation + 1, updated_at = ?,
844
+ lease_token_sha256 = NULL, lease_worker_sha256 = NULL,
845
+ lease_expires_at = NULL, result_json = NULL,
846
+ error_code = 'worker_lease_expired',
847
+ error_summary = 'The prior swarm worker lease expired.'
848
+ WHERE owner_key = ? AND workspace_key = ? AND session_id = ? AND job_id = ?
849
+ """,
850
+ (now, self._owner_key, self._workspace_key, session_id, job_id),
851
+ )
852
+ row = self._job_row(connection, session_id=session_id, job_id=job_id)
853
+ _require_permission(row, permission_hash)
854
+ if row["state"] not in {
855
+ SwarmJobState.INTERRUPTED.value,
856
+ SwarmJobState.FAILED.value,
857
+ }:
858
+ raise SwarmJobStateError("Swarm job is not in a resumable state.")
859
+ connection.execute(
860
+ """
861
+ UPDATE ide_swarm_jobs
862
+ SET state = 'queued', revision = revision + 1,
863
+ generation = generation + 1, resume_count = resume_count + 1,
864
+ updated_at = ?, terminal_at = NULL,
865
+ lease_token_sha256 = NULL, lease_worker_sha256 = NULL,
866
+ lease_expires_at = NULL, result_json = NULL,
867
+ error_code = NULL, error_summary = NULL
868
+ WHERE owner_key = ? AND workspace_key = ? AND session_id = ? AND job_id = ?
869
+ """,
870
+ (now, self._owner_key, self._workspace_key, session_id, job_id),
871
+ )
872
+ return self._status(self._job_row(connection, session_id=session_id, job_id=job_id))
873
+
874
+ def should_cancel(self, lease: SwarmWorkerLease) -> bool:
875
+ """Return true when a worker no longer owns live result authority."""
876
+
877
+ try:
878
+ now = self._now()
879
+ with self._connection() as connection:
880
+ self._require_live_lease(connection, lease=lease, now=now)
881
+ return False
882
+ except (SwarmJobNotFound, SwarmLeaseLost):
883
+ return True
884
+
885
+ def get_status(self, *, session_id: str, job_id: str) -> SwarmJobStatus:
886
+ session_id, job_id = _job_scope(session_id, job_id)
887
+ with self._connection() as connection:
888
+ return self._status(self._job_row(connection, session_id=session_id, job_id=job_id))
889
+
890
+ def get_result(self, *, session_id: str, job_id: str) -> SwarmJobResult:
891
+ session_id, job_id = _job_scope(session_id, job_id)
892
+ with self._connection() as connection:
893
+ row = self._job_row(connection, session_id=session_id, job_id=job_id)
894
+ result = (
895
+ None
896
+ if row["result_json"] is None
897
+ else _decode_stored_object(
898
+ row["result_json"],
899
+ label="swarm result",
900
+ config=self.config,
901
+ maximum_bytes=self.config.max_result_bytes,
902
+ )
903
+ )
904
+ return SwarmJobResult(status=self._status(row), result=result)
905
+
906
+ def list_jobs(
907
+ self,
908
+ *,
909
+ session_id: str,
910
+ limit: int = 100,
911
+ ) -> tuple[SwarmJobStatus, ...]:
912
+ session_id = _identifier(session_id, "session", maximum=256)
913
+ limit = _limit(limit, maximum=self.config.max_list_limit)
914
+ with self._connection() as connection:
915
+ rows = connection.execute(
916
+ """
917
+ SELECT * FROM ide_swarm_jobs
918
+ WHERE owner_key = ? AND workspace_key = ? AND session_id = ?
919
+ ORDER BY sequence DESC LIMIT ?
920
+ """,
921
+ (self._owner_key, self._workspace_key, session_id, limit),
922
+ ).fetchall()
923
+ return tuple(self._status(row) for row in rows)
924
+
925
+ def _status(self, row: sqlite3.Row) -> SwarmJobStatus:
926
+ return _status_from_row(
927
+ row,
928
+ maximum_error_chars=self.config.max_error_summary_chars,
929
+ )
930
+
931
+ def _finish_locked(
932
+ self,
933
+ connection: sqlite3.Connection,
934
+ *,
935
+ lease: SwarmWorkerLease,
936
+ state: SwarmJobState,
937
+ now: float,
938
+ result_json: str | None,
939
+ error_code: str | None,
940
+ error_summary: str | None,
941
+ ) -> None:
942
+ terminal_at = now if state in TERMINAL_STATES else None
943
+ connection.execute(
944
+ """
945
+ UPDATE ide_swarm_jobs
946
+ SET state = ?, revision = revision + 1, generation = generation + 1,
947
+ updated_at = ?, terminal_at = ?, lease_token_sha256 = NULL,
948
+ lease_worker_sha256 = NULL, lease_expires_at = NULL,
949
+ result_json = ?, error_code = ?, error_summary = ?
950
+ WHERE owner_key = ? AND workspace_key = ? AND session_id = ? AND job_id = ?
951
+ """,
952
+ (
953
+ state.value,
954
+ now,
955
+ terminal_at,
956
+ result_json,
957
+ error_code,
958
+ error_summary,
959
+ self._owner_key,
960
+ self._workspace_key,
961
+ lease.session_id,
962
+ lease.job_id,
963
+ ),
964
+ )
965
+
966
+ def _require_live_lease(
967
+ self,
968
+ connection: sqlite3.Connection,
969
+ *,
970
+ lease: SwarmWorkerLease,
971
+ now: float,
972
+ ) -> sqlite3.Row:
973
+ if not isinstance(lease, SwarmWorkerLease):
974
+ raise SwarmValidationError("A swarm worker lease is required.")
975
+ session_id, job_id = _job_scope(lease.session_id, lease.job_id)
976
+ row = self._job_row(connection, session_id=session_id, job_id=job_id)
977
+ stored_hash = row["lease_token_sha256"]
978
+ valid_token = isinstance(stored_hash, str) and hmac.compare_digest(
979
+ stored_hash, _digest(lease.lease_token)
980
+ )
981
+ if (
982
+ row["state"] != SwarmJobState.RUNNING.value
983
+ or int(row["generation"]) != lease.generation
984
+ or row["lease_expires_at"] is None
985
+ or float(row["lease_expires_at"]) <= now
986
+ or not valid_token
987
+ ):
988
+ raise SwarmLeaseLost("Swarm worker lease is no longer valid.")
989
+ return row
990
+
991
+ def _job_row(
992
+ self,
993
+ connection: sqlite3.Connection,
994
+ *,
995
+ session_id: str,
996
+ job_id: str,
997
+ ) -> sqlite3.Row:
998
+ row = connection.execute(
999
+ """
1000
+ SELECT * FROM ide_swarm_jobs
1001
+ WHERE owner_key = ? AND workspace_key = ? AND session_id = ? AND job_id = ?
1002
+ """,
1003
+ (self._owner_key, self._workspace_key, session_id, job_id),
1004
+ ).fetchone()
1005
+ if row is None:
1006
+ raise SwarmJobNotFound("Swarm job was not found.")
1007
+ return row
1008
+
1009
+ def _now(self) -> float:
1010
+ try:
1011
+ now = float(self._clock())
1012
+ except (TypeError, ValueError, OverflowError) as exc:
1013
+ raise SwarmValidationError("Resumable swarm clock is invalid.") from exc
1014
+ if not math.isfinite(now) or now < 0:
1015
+ raise SwarmValidationError("Resumable swarm clock is invalid.")
1016
+ return now
1017
+
1018
+ def _validate_storage_location(self) -> None:
1019
+ try:
1020
+ is_symlink = self.path.is_symlink()
1021
+ except OSError:
1022
+ raise SwarmValidationError("Resumable swarm storage path is invalid.") from None
1023
+ if is_symlink:
1024
+ raise SwarmValidationError("Resumable swarm database cannot be a symlink.")
1025
+ try:
1026
+ resolved = self.path.parent.resolve(strict=False) / self.path.name
1027
+ self.path = resolved
1028
+ resolved.relative_to(self.workspace_root)
1029
+ except ValueError:
1030
+ return
1031
+ except OSError:
1032
+ raise SwarmValidationError("Resumable swarm storage path is invalid.") from None
1033
+ raise SwarmValidationError("Resumable swarm storage must be outside the workspace.")
1034
+
1035
+ def _prepare_storage(self) -> None:
1036
+ try:
1037
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
1038
+ _restrict_permissions(self.path.parent, 0o700)
1039
+ with self._connection() as connection:
1040
+ version = int(connection.execute("PRAGMA user_version").fetchone()[0])
1041
+ if version not in (0, SCHEMA_VERSION):
1042
+ raise SwarmStorageError(
1043
+ "Resumable swarm database schema is not supported by this version."
1044
+ )
1045
+ connection.execute("PRAGMA journal_mode = WAL")
1046
+ connection.execute("PRAGMA synchronous = FULL")
1047
+ connection.executescript(
1048
+ """
1049
+ CREATE TABLE IF NOT EXISTS ide_swarm_jobs (
1050
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT,
1051
+ owner_key TEXT NOT NULL,
1052
+ workspace_key TEXT NOT NULL,
1053
+ session_id TEXT NOT NULL,
1054
+ job_id TEXT NOT NULL,
1055
+ idempotency_hash TEXT NOT NULL,
1056
+ execution_spec_json TEXT NOT NULL,
1057
+ execution_spec_sha256 TEXT NOT NULL,
1058
+ permission_scope_sha256 TEXT NOT NULL,
1059
+ state TEXT NOT NULL CHECK (
1060
+ state IN ('queued', 'running', 'interrupted', 'succeeded',
1061
+ 'failed', 'cancelled')
1062
+ ),
1063
+ revision INTEGER NOT NULL CHECK (revision >= 1),
1064
+ generation INTEGER NOT NULL CHECK (generation >= 0),
1065
+ attempts INTEGER NOT NULL CHECK (attempts >= 0),
1066
+ resume_count INTEGER NOT NULL CHECK (resume_count >= 0),
1067
+ created_at REAL NOT NULL,
1068
+ updated_at REAL NOT NULL,
1069
+ started_at REAL,
1070
+ terminal_at REAL,
1071
+ lease_token_sha256 TEXT,
1072
+ lease_worker_sha256 TEXT,
1073
+ lease_expires_at REAL,
1074
+ result_json TEXT,
1075
+ error_code TEXT,
1076
+ error_summary TEXT,
1077
+ usage_calls INTEGER NOT NULL CHECK (usage_calls >= 0),
1078
+ usage_input_tokens INTEGER NOT NULL CHECK (usage_input_tokens >= 0),
1079
+ usage_output_tokens INTEGER NOT NULL CHECK (usage_output_tokens >= 0),
1080
+ usage_cached_input_tokens INTEGER NOT NULL CHECK (
1081
+ usage_cached_input_tokens >= 0
1082
+ ),
1083
+ usage_total_tokens INTEGER NOT NULL CHECK (usage_total_tokens >= 0),
1084
+ UNIQUE (owner_key, workspace_key, session_id, job_id),
1085
+ UNIQUE (owner_key, workspace_key, session_id, idempotency_hash),
1086
+ CHECK (
1087
+ (state = 'running' AND lease_token_sha256 IS NOT NULL
1088
+ AND lease_worker_sha256 IS NOT NULL
1089
+ AND lease_expires_at IS NOT NULL)
1090
+ OR
1091
+ (state != 'running' AND lease_token_sha256 IS NULL
1092
+ AND lease_worker_sha256 IS NULL
1093
+ AND lease_expires_at IS NULL)
1094
+ ),
1095
+ CHECK (
1096
+ (state = 'succeeded' AND result_json IS NOT NULL
1097
+ AND terminal_at IS NOT NULL)
1098
+ OR
1099
+ (state IN ('failed', 'cancelled') AND result_json IS NULL
1100
+ AND terminal_at IS NOT NULL)
1101
+ OR
1102
+ (state IN ('queued', 'running', 'interrupted')
1103
+ AND result_json IS NULL AND terminal_at IS NULL)
1104
+ )
1105
+ );
1106
+ CREATE INDEX IF NOT EXISTS ide_swarm_jobs_session_order
1107
+ ON ide_swarm_jobs(owner_key, workspace_key, session_id, sequence DESC);
1108
+ CREATE INDEX IF NOT EXISTS ide_swarm_jobs_recovery
1109
+ ON ide_swarm_jobs(owner_key, workspace_key, state, lease_expires_at);
1110
+ CREATE TABLE IF NOT EXISTS ide_swarm_usage_events (
1111
+ owner_key TEXT NOT NULL,
1112
+ workspace_key TEXT NOT NULL,
1113
+ session_id TEXT NOT NULL,
1114
+ job_id TEXT NOT NULL,
1115
+ event_hash TEXT NOT NULL,
1116
+ calls INTEGER NOT NULL CHECK (calls >= 0),
1117
+ input_tokens INTEGER NOT NULL CHECK (input_tokens >= 0),
1118
+ output_tokens INTEGER NOT NULL CHECK (output_tokens >= 0),
1119
+ cached_input_tokens INTEGER NOT NULL CHECK (cached_input_tokens >= 0),
1120
+ total_tokens INTEGER NOT NULL CHECK (total_tokens >= 0),
1121
+ recorded_at REAL NOT NULL,
1122
+ PRIMARY KEY (
1123
+ owner_key, workspace_key, session_id, job_id, event_hash
1124
+ ),
1125
+ FOREIGN KEY (owner_key, workspace_key, session_id, job_id)
1126
+ REFERENCES ide_swarm_jobs(
1127
+ owner_key, workspace_key, session_id, job_id
1128
+ ) ON DELETE CASCADE
1129
+ );
1130
+ """
1131
+ )
1132
+ connection.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
1133
+ _restrict_permissions(self.path, 0o600)
1134
+ except ResumableSwarmError:
1135
+ raise
1136
+ except (OSError, sqlite3.Error):
1137
+ raise SwarmStorageError("Could not initialize resumable swarm storage.") from None
1138
+
1139
+ @contextmanager
1140
+ def _connection(self) -> Any:
1141
+ connection: sqlite3.Connection | None = None
1142
+ try:
1143
+ connection = sqlite3.connect(
1144
+ self.path,
1145
+ timeout=self.config.busy_timeout_seconds,
1146
+ isolation_level=None,
1147
+ )
1148
+ connection.row_factory = sqlite3.Row
1149
+ connection.execute(
1150
+ f"PRAGMA busy_timeout = {int(self.config.busy_timeout_seconds * 1000)}"
1151
+ )
1152
+ connection.execute("PRAGMA foreign_keys = ON")
1153
+ except sqlite3.Error:
1154
+ if connection is not None:
1155
+ try:
1156
+ connection.close()
1157
+ except sqlite3.Error:
1158
+ pass
1159
+ raise SwarmStorageError("Could not open resumable swarm storage.") from None
1160
+ try:
1161
+ yield connection
1162
+ except ResumableSwarmError:
1163
+ raise
1164
+ except sqlite3.Error:
1165
+ raise SwarmStorageError("Resumable swarm storage operation failed.") from None
1166
+ finally:
1167
+ try:
1168
+ connection.close()
1169
+ except sqlite3.Error:
1170
+ pass
1171
+
1172
+ @contextmanager
1173
+ def _transaction(self) -> Any:
1174
+ with self._connection() as connection:
1175
+ try:
1176
+ connection.execute("BEGIN IMMEDIATE")
1177
+ yield connection
1178
+ connection.execute("COMMIT")
1179
+ except ResumableSwarmError:
1180
+ _rollback_quietly(connection)
1181
+ raise
1182
+ except sqlite3.Error:
1183
+ _rollback_quietly(connection)
1184
+ raise SwarmStorageError("Resumable swarm storage operation failed.") from None
1185
+ except BaseException:
1186
+ _rollback_quietly(connection)
1187
+ raise
1188
+
1189
+
1190
+ def _serialize_json_object(
1191
+ value: Mapping[str, Any],
1192
+ *,
1193
+ config: ResumableSwarmConfig,
1194
+ maximum_bytes: int,
1195
+ label: str,
1196
+ redact: bool,
1197
+ ) -> tuple[str, dict[str, JsonValue]]:
1198
+ if not isinstance(value, Mapping):
1199
+ raise SwarmValidationError(f"Swarm {label} must be a JSON object.")
1200
+ normalized = _normalize_json(value, config=config, redact=redact)
1201
+ if not isinstance(normalized, dict):
1202
+ raise SwarmValidationError(f"Swarm {label} must be a JSON object.")
1203
+ try:
1204
+ serialized = json.dumps(
1205
+ normalized,
1206
+ ensure_ascii=False,
1207
+ allow_nan=False,
1208
+ separators=(",", ":"),
1209
+ sort_keys=True,
1210
+ )
1211
+ except (TypeError, ValueError):
1212
+ raise SwarmValidationError(f"Swarm {label} is not valid JSON.") from None
1213
+ if len(serialized.encode("utf-8")) > maximum_bytes:
1214
+ raise SwarmValidationError(f"Swarm {label} exceeds the size limit.")
1215
+ return serialized, normalized
1216
+
1217
+
1218
+ def _normalize_json(
1219
+ value: Any,
1220
+ *,
1221
+ config: ResumableSwarmConfig,
1222
+ redact: bool,
1223
+ ) -> JsonValue:
1224
+ nodes = 0
1225
+
1226
+ def visit(item: Any, depth: int, *, sensitive: bool = False) -> JsonValue:
1227
+ nonlocal nodes
1228
+ nodes += 1
1229
+ if nodes > config.max_json_nodes:
1230
+ raise SwarmValidationError("Swarm JSON has too many values.")
1231
+ if depth > config.max_json_depth:
1232
+ raise SwarmValidationError("Swarm JSON is nested too deeply.")
1233
+ if sensitive and redact:
1234
+ return _REDACTED
1235
+ if item is None or isinstance(item, bool):
1236
+ return item
1237
+ if isinstance(item, int):
1238
+ if abs(item) > MAX_USAGE_VALUE:
1239
+ raise SwarmValidationError("Swarm JSON integer exceeds the safe range.")
1240
+ return item
1241
+ if isinstance(item, float):
1242
+ if not math.isfinite(item):
1243
+ raise SwarmValidationError("Swarm JSON contains an invalid number.")
1244
+ return item
1245
+ if isinstance(item, str):
1246
+ if len(item) > config.max_string_chars:
1247
+ raise SwarmValidationError("Swarm JSON string exceeds the size limit.")
1248
+ return _redact_text(item) if redact else item
1249
+ if isinstance(item, Mapping):
1250
+ result: dict[str, JsonValue] = {}
1251
+ for key, child in item.items():
1252
+ if not isinstance(key, str):
1253
+ raise SwarmValidationError("Swarm JSON object keys must be strings.")
1254
+ if len(key) > 512 or _CONTROL_RE.search(key):
1255
+ raise SwarmValidationError("Swarm JSON object key is invalid.")
1256
+ result[key] = visit(
1257
+ child,
1258
+ depth + 1,
1259
+ sensitive=_is_sensitive_key(key),
1260
+ )
1261
+ return result
1262
+ if isinstance(item, (list, tuple)):
1263
+ return [visit(child, depth + 1) for child in item]
1264
+ raise SwarmValidationError("Swarm JSON contains a non-JSON value.")
1265
+
1266
+ return visit(value, 0)
1267
+
1268
+
1269
+ def _permission_fingerprint(scope: Mapping[str, Any], config: ResumableSwarmConfig) -> str:
1270
+ serialized, _ = _serialize_json_object(
1271
+ scope,
1272
+ config=config,
1273
+ maximum_bytes=min(config.max_execution_spec_bytes, 256 * 1024),
1274
+ label="permission scope",
1275
+ redact=False,
1276
+ )
1277
+ return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
1278
+
1279
+
1280
+ def _status_from_row(
1281
+ row: sqlite3.Row,
1282
+ *,
1283
+ maximum_error_chars: int,
1284
+ ) -> SwarmJobStatus:
1285
+ try:
1286
+ state = SwarmJobState(row["state"])
1287
+ status = SwarmJobStatus(
1288
+ job_id=_identifier(row["job_id"], "stored job", maximum=128),
1289
+ state=state,
1290
+ revision=_stored_nonnegative_int(row["revision"], minimum=1),
1291
+ attempts=_stored_nonnegative_int(row["attempts"]),
1292
+ resume_count=_stored_nonnegative_int(row["resume_count"]),
1293
+ created_at=_stored_time(row["created_at"]),
1294
+ updated_at=_stored_time(row["updated_at"]),
1295
+ started_at=_optional_stored_time(row["started_at"]),
1296
+ terminal_at=_optional_stored_time(row["terminal_at"]),
1297
+ lease_expires_at=_optional_stored_time(row["lease_expires_at"]),
1298
+ result_available=row["result_json"] is not None,
1299
+ error_code=(None if row["error_code"] is None else _error_code(row["error_code"])),
1300
+ error_summary=(
1301
+ None
1302
+ if row["error_summary"] is None
1303
+ else _public_text(row["error_summary"], maximum=maximum_error_chars)
1304
+ ),
1305
+ usage=_usage_from_row(row),
1306
+ )
1307
+ except (KeyError, TypeError, ValueError, OverflowError, SwarmValidationError):
1308
+ raise SwarmStorageError("Stored swarm job is invalid.") from None
1309
+ if status.state is SwarmJobState.RUNNING and status.lease_expires_at is None:
1310
+ raise SwarmStorageError("Stored swarm job is invalid.")
1311
+ if status.state is SwarmJobState.SUCCEEDED and not status.result_available:
1312
+ raise SwarmStorageError("Stored swarm job is invalid.")
1313
+ return status
1314
+
1315
+
1316
+ def _usage_from_row(row: sqlite3.Row) -> SwarmUsage:
1317
+ return SwarmUsage(
1318
+ calls=_stored_nonnegative_int(row["usage_calls"]),
1319
+ input_tokens=_stored_nonnegative_int(row["usage_input_tokens"]),
1320
+ output_tokens=_stored_nonnegative_int(row["usage_output_tokens"]),
1321
+ cached_input_tokens=_stored_nonnegative_int(row["usage_cached_input_tokens"]),
1322
+ total_tokens=_stored_nonnegative_int(row["usage_total_tokens"]),
1323
+ )
1324
+
1325
+
1326
+ def _validated_usage(usage: SwarmUsage) -> SwarmUsage:
1327
+ if not isinstance(usage, SwarmUsage):
1328
+ raise SwarmValidationError("Swarm usage must be a SwarmUsage value.")
1329
+ for value in (
1330
+ usage.calls,
1331
+ usage.input_tokens,
1332
+ usage.output_tokens,
1333
+ usage.cached_input_tokens,
1334
+ usage.total_tokens,
1335
+ ):
1336
+ if (
1337
+ isinstance(value, bool)
1338
+ or not isinstance(value, int)
1339
+ or not 0 <= value <= MAX_USAGE_VALUE
1340
+ ):
1341
+ raise SwarmValidationError("Swarm usage values must be non-negative integers.")
1342
+ if usage.total_tokens < usage.input_tokens + usage.output_tokens:
1343
+ raise SwarmValidationError("Swarm total token usage is inconsistent.")
1344
+ return usage
1345
+
1346
+
1347
+ def _safe_usage_sum(left: int, right: int) -> int:
1348
+ result = left + right
1349
+ if result > MAX_USAGE_VALUE:
1350
+ raise SwarmValidationError("Swarm usage total exceeds the safe range.")
1351
+ return result
1352
+
1353
+
1354
+ def _require_permission(row: sqlite3.Row, current_hash: str) -> None:
1355
+ stored = row["permission_scope_sha256"]
1356
+ if not isinstance(stored, str) or not hmac.compare_digest(stored, current_hash):
1357
+ raise SwarmPermissionScopeChanged(
1358
+ "Swarm permission scope changed; start a new reviewed job."
1359
+ )
1360
+
1361
+
1362
+ def _require_fresh_permission_grant(
1363
+ grant: Mapping[str, Any] | None,
1364
+ *,
1365
+ row: sqlite3.Row,
1366
+ session_id: str,
1367
+ job_id: str,
1368
+ ) -> None:
1369
+ if (
1370
+ not isinstance(grant, Mapping)
1371
+ or set(grant) != {"schema_version", "session_id", "job_id", "revision"}
1372
+ or grant.get("schema_version") != 1
1373
+ or grant.get("session_id") != session_id
1374
+ or grant.get("job_id") != job_id
1375
+ or isinstance(grant.get("revision"), bool)
1376
+ or not isinstance(grant.get("revision"), int)
1377
+ or int(grant["revision"]) != int(row["revision"])
1378
+ ):
1379
+ raise SwarmFreshPermissionGrantRequired(
1380
+ "A fresh, job-scoped permission grant is required to resume this swarm job."
1381
+ )
1382
+
1383
+
1384
+ def _decode_stored_object(
1385
+ raw: Any,
1386
+ *,
1387
+ label: str,
1388
+ config: ResumableSwarmConfig,
1389
+ maximum_bytes: int,
1390
+ ) -> dict[str, JsonValue]:
1391
+ if not isinstance(raw, str) or len(raw.encode("utf-8")) > maximum_bytes:
1392
+ raise SwarmStorageError(f"Stored {label} is invalid.")
1393
+ try:
1394
+ decoded = json.loads(raw)
1395
+ except (TypeError, ValueError, json.JSONDecodeError):
1396
+ raise SwarmStorageError(f"Stored {label} is invalid.") from None
1397
+ if not isinstance(decoded, dict):
1398
+ raise SwarmStorageError(f"Stored {label} is invalid.")
1399
+ try:
1400
+ _, normalized = _serialize_json_object(
1401
+ decoded,
1402
+ config=config,
1403
+ maximum_bytes=maximum_bytes,
1404
+ label=label,
1405
+ redact=True,
1406
+ )
1407
+ except SwarmValidationError:
1408
+ raise SwarmStorageError(f"Stored {label} is invalid.") from None
1409
+ return normalized
1410
+
1411
+
1412
+ def _identifier(value: Any, kind: str, *, maximum: int) -> str:
1413
+ if not isinstance(value, str):
1414
+ raise SwarmValidationError(f"Swarm {kind} identifier is invalid.")
1415
+ normalized = value.strip()
1416
+ if (
1417
+ not normalized
1418
+ or len(normalized) > maximum
1419
+ or not _IDENTIFIER_RE.fullmatch(normalized)
1420
+ or ".." in normalized.split("/")
1421
+ ):
1422
+ raise SwarmValidationError(f"Swarm {kind} identifier is invalid.")
1423
+ return normalized
1424
+
1425
+
1426
+ def _job_scope(session_id: Any, job_id: Any) -> tuple[str, str]:
1427
+ return (
1428
+ _identifier(session_id, "session", maximum=256),
1429
+ _identifier(job_id, "job", maximum=128),
1430
+ )
1431
+
1432
+
1433
+ def _error_code(value: Any) -> str:
1434
+ if not isinstance(value, str):
1435
+ raise SwarmValidationError("Swarm error code is invalid.")
1436
+ normalized = value.strip()
1437
+ if len(normalized) > 80 or not _ERROR_CODE_RE.fullmatch(normalized):
1438
+ raise SwarmValidationError("Swarm error code is invalid.")
1439
+ return normalized
1440
+
1441
+
1442
+ def _public_text(value: Any, *, maximum: int) -> str:
1443
+ if not isinstance(value, str):
1444
+ raise SwarmValidationError("Swarm display text is invalid.")
1445
+ cleaned = " ".join(_CONTROL_RE.sub(" ", _redact_text(value)).split())
1446
+ if not cleaned:
1447
+ raise SwarmValidationError("Swarm display text is invalid.")
1448
+ return cleaned if len(cleaned) <= maximum else cleaned[: maximum - 3] + "..."
1449
+
1450
+
1451
+ def _redact_text(value: str) -> str:
1452
+ return _SECRET_TOKEN_RE.sub(_REDACTED, _SECRET_ASSIGNMENT_RE.sub(_REDACTED, value))
1453
+
1454
+
1455
+ def _is_sensitive_key(key: str) -> bool:
1456
+ normalized = re.sub(r"[^a-z0-9]+", "_", key.casefold()).strip("_")
1457
+ return (
1458
+ normalized in _SENSITIVE_KEY_PARTS
1459
+ or normalized.endswith(_SENSITIVE_KEY_SUFFIXES)
1460
+ or normalized.startswith(_SENSITIVE_KEY_PREFIXES)
1461
+ )
1462
+
1463
+
1464
+ def _duration(value: Any) -> float:
1465
+ if (
1466
+ isinstance(value, bool)
1467
+ or not isinstance(value, (int, float))
1468
+ or not math.isfinite(value)
1469
+ or not MIN_LEASE_SECONDS <= float(value) <= MAX_LEASE_SECONDS
1470
+ ):
1471
+ raise SwarmValidationError("Swarm lease duration is invalid.")
1472
+ return float(value)
1473
+
1474
+
1475
+ def _deadline(now: float, duration: float) -> float:
1476
+ deadline = now + duration
1477
+ if not math.isfinite(deadline):
1478
+ raise SwarmValidationError("Swarm lease deadline is invalid.")
1479
+ return deadline
1480
+
1481
+
1482
+ def _lease_token(value: Any) -> str:
1483
+ if not isinstance(value, str) or len(value) < 32 or len(value) > 512:
1484
+ raise SwarmValidationError("Generated swarm lease token is invalid.")
1485
+ return value
1486
+
1487
+
1488
+ def _digest(value: str) -> str:
1489
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
1490
+
1491
+
1492
+ def _optional_revision(value: Any) -> int | None:
1493
+ if value is None:
1494
+ return None
1495
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
1496
+ raise SwarmValidationError("Swarm revision is invalid.")
1497
+ return value
1498
+
1499
+
1500
+ def _check_revision(row: sqlite3.Row, expected: int | None) -> None:
1501
+ if expected is not None and int(row["revision"]) != expected:
1502
+ raise SwarmRevisionConflict(int(row["revision"]))
1503
+
1504
+
1505
+ def _limit(value: Any, *, maximum: int) -> int:
1506
+ if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= maximum:
1507
+ raise SwarmValidationError("Swarm list limit is invalid.")
1508
+ return value
1509
+
1510
+
1511
+ def _stored_nonnegative_int(value: Any, *, minimum: int = 0) -> int:
1512
+ if isinstance(value, bool) or not isinstance(value, int):
1513
+ raise ValueError
1514
+ if value < minimum or value > MAX_USAGE_VALUE:
1515
+ raise ValueError
1516
+ return value
1517
+
1518
+
1519
+ def _stored_time(value: Any) -> float:
1520
+ normalized = float(value)
1521
+ if not math.isfinite(normalized) or normalized < 0:
1522
+ raise ValueError
1523
+ return normalized
1524
+
1525
+
1526
+ def _optional_stored_time(value: Any) -> float | None:
1527
+ return None if value is None else _stored_time(value)
1528
+
1529
+
1530
+ def _restrict_permissions(path: Path, mode: int) -> None:
1531
+ if os.name == "nt":
1532
+ return
1533
+ try:
1534
+ path.chmod(mode)
1535
+ except OSError:
1536
+ raise SwarmStorageError("Could not secure resumable swarm storage.") from None
1537
+
1538
+
1539
+ def _rollback_quietly(connection: sqlite3.Connection) -> None:
1540
+ try:
1541
+ connection.execute("ROLLBACK")
1542
+ except sqlite3.Error:
1543
+ pass