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,1579 @@
1
+ """Durable structured task and user-question state for IDE clients.
2
+
3
+ The store is deliberately independent from the agent/provider runtime. It is
4
+ bound to one local owner and one canonical workspace, and every query includes
5
+ that scope. Public records contain only bounded, redacted display text.
6
+
7
+ SQLite ``BEGIN IMMEDIATE`` transactions provide atomic cross-process writes.
8
+ Question resolution uses opaque, hashed lease tokens so a stale process cannot
9
+ answer after another process has recovered an expired claim.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import hmac
16
+ import json
17
+ import math
18
+ import os
19
+ import re
20
+ import secrets
21
+ import sqlite3
22
+ import threading
23
+ import time
24
+ import uuid
25
+ from collections.abc import Callable, Iterable, Mapping, Sequence
26
+ from contextlib import contextmanager
27
+ from dataclasses import dataclass, field
28
+ from enum import Enum
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from ..branding import canonical_user_data_dir, env_get
33
+
34
+ SCHEMA_VERSION = 1
35
+ DEFAULT_QUESTION_LEASE_SECONDS = 120.0
36
+ MIN_QUESTION_LEASE_SECONDS = 1.0
37
+ MAX_QUESTION_LEASE_SECONDS = 60.0 * 60.0
38
+ MIN_QUESTION_TTL_SECONDS = 5.0
39
+ MAX_QUESTION_TTL_SECONDS = 7.0 * 24.0 * 60.0 * 60.0
40
+
41
+ _IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]*$")
42
+ _CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]+")
43
+ _SPACE_RE = re.compile(r"\s+")
44
+ _SECRET_ASSIGNMENT_RE = re.compile(
45
+ r"(?i)\b(api[_-]?key|access[_-]?token|auth[_-]?token|password|passwd|secret)"
46
+ r"\s*[:=]\s*(?:\"[^\"]*\"|'[^']*'|[^\s,;]+)"
47
+ )
48
+ _SECRET_TOKEN_RE = re.compile(
49
+ r"(?i)(?:"
50
+ r"sk-[A-Za-z0-9_-]{8,}|"
51
+ r"gh[opusr]_[A-Za-z0-9]{12,}|"
52
+ r"xox[baprs]-[A-Za-z0-9-]{8,}|"
53
+ r"Bearer\s+[A-Za-z0-9._~+/=-]{8,}"
54
+ r")"
55
+ )
56
+ _REDACTED = "[REDACTED]"
57
+
58
+
59
+ class TaskStatus(str, Enum):
60
+ PENDING = "pending"
61
+ IN_PROGRESS = "in_progress"
62
+ COMPLETED = "completed"
63
+ BLOCKED = "blocked"
64
+
65
+
66
+ class QuestionSetStatus(str, Enum):
67
+ PENDING = "pending"
68
+ RESOLVING = "resolving"
69
+ ANSWERED = "answered"
70
+ CANCELLED = "cancelled"
71
+ EXPIRED = "expired"
72
+
73
+
74
+ TERMINAL_QUESTION_STATUSES = frozenset(
75
+ {
76
+ QuestionSetStatus.ANSWERED,
77
+ QuestionSetStatus.CANCELLED,
78
+ QuestionSetStatus.EXPIRED,
79
+ }
80
+ )
81
+
82
+
83
+ class StructuredStateError(RuntimeError):
84
+ """Base error whose message is safe to surface to an IDE client."""
85
+
86
+
87
+ class StructuredStateValidationError(StructuredStateError, ValueError):
88
+ pass
89
+
90
+
91
+ class StructuredStateStorageError(StructuredStateError):
92
+ pass
93
+
94
+
95
+ class _StructuredStateTransientStorageError(StructuredStateStorageError):
96
+ """Internal marker for SQLite busy/locked errors that are safe to retry."""
97
+
98
+
99
+ class TaskRevisionConflict(StructuredStateError):
100
+ def __init__(self, current_revision: int) -> None:
101
+ super().__init__("Task ledger changed; reload it before updating.")
102
+ self.current_revision = current_revision
103
+
104
+
105
+ class QuestionIdempotencyConflict(StructuredStateError):
106
+ pass
107
+
108
+
109
+ class QuestionNotFound(StructuredStateError):
110
+ pass
111
+
112
+
113
+ class QuestionStateError(StructuredStateError):
114
+ pass
115
+
116
+
117
+ class QuestionLeaseLost(StructuredStateError):
118
+ pass
119
+
120
+
121
+ class StructuredStateCapacityError(StructuredStateError):
122
+ pass
123
+
124
+
125
+ @dataclass(frozen=True, slots=True)
126
+ class StructuredStateConfig:
127
+ max_tasks_per_session: int = 100
128
+ max_task_title_chars: int = 240
129
+ max_question_sets_per_session: int = 50
130
+ max_question_prompt_chars: int = 600
131
+ max_option_label_chars: int = 100
132
+ max_option_description_chars: int = 300
133
+ max_raw_text_chars: int = 4096
134
+ max_list_limit: int = 200
135
+ busy_timeout_seconds: float = 10.0
136
+
137
+ def __post_init__(self) -> None:
138
+ integers = (
139
+ self.max_tasks_per_session,
140
+ self.max_task_title_chars,
141
+ self.max_question_sets_per_session,
142
+ self.max_question_prompt_chars,
143
+ self.max_option_label_chars,
144
+ self.max_option_description_chars,
145
+ self.max_raw_text_chars,
146
+ self.max_list_limit,
147
+ )
148
+ if any(
149
+ isinstance(value, bool) or not isinstance(value, int) or value <= 0
150
+ for value in integers
151
+ ):
152
+ raise StructuredStateValidationError(
153
+ "Structured-state limits must be positive integers."
154
+ )
155
+ if (
156
+ self.max_tasks_per_session > 1_000
157
+ or self.max_question_sets_per_session > 1_000
158
+ or self.max_list_limit > 1_000
159
+ or self.max_raw_text_chars > 65_536
160
+ or self.max_task_title_chars > self.max_raw_text_chars
161
+ or self.max_question_prompt_chars > self.max_raw_text_chars
162
+ or self.max_option_label_chars > self.max_raw_text_chars
163
+ or self.max_option_description_chars > self.max_raw_text_chars
164
+ ):
165
+ raise StructuredStateValidationError("Structured-state limits exceed safe bounds.")
166
+ if (
167
+ isinstance(self.busy_timeout_seconds, bool)
168
+ or not isinstance(self.busy_timeout_seconds, (int, float))
169
+ or not math.isfinite(self.busy_timeout_seconds)
170
+ or not 0 < self.busy_timeout_seconds <= 60
171
+ ):
172
+ raise StructuredStateValidationError("Structured-state busy timeout is invalid.")
173
+
174
+
175
+ @dataclass(frozen=True, slots=True)
176
+ class TaskItem:
177
+ task_id: str
178
+ title: str
179
+ status: TaskStatus
180
+
181
+ def public_payload(self) -> dict[str, str]:
182
+ return {"task_id": self.task_id, "title": self.title, "status": self.status.value}
183
+
184
+
185
+ @dataclass(frozen=True, slots=True)
186
+ class TaskLedger:
187
+ revision: int
188
+ tasks: tuple[TaskItem, ...]
189
+ updated_at: float | None
190
+
191
+ def public_payload(self) -> dict[str, Any]:
192
+ return {
193
+ "revision": self.revision,
194
+ "tasks": [task.public_payload() for task in self.tasks],
195
+ "updated_at": self.updated_at,
196
+ }
197
+
198
+
199
+ @dataclass(frozen=True, slots=True)
200
+ class QuestionOption:
201
+ option_id: str
202
+ label: str
203
+ description: str
204
+
205
+ def public_payload(self) -> dict[str, str]:
206
+ return {
207
+ "option_id": self.option_id,
208
+ "label": self.label,
209
+ "description": self.description,
210
+ }
211
+
212
+
213
+ @dataclass(frozen=True, slots=True)
214
+ class StructuredQuestion:
215
+ question_id: str
216
+ prompt: str
217
+ options: tuple[QuestionOption, ...]
218
+
219
+ def public_payload(self) -> dict[str, Any]:
220
+ return {
221
+ "question_id": self.question_id,
222
+ "prompt": self.prompt,
223
+ "options": [option.public_payload() for option in self.options],
224
+ }
225
+
226
+
227
+ @dataclass(frozen=True, slots=True)
228
+ class QuestionAnswer:
229
+ question_id: str
230
+ option_id: str
231
+
232
+ def public_payload(self) -> dict[str, str]:
233
+ return {"question_id": self.question_id, "option_id": self.option_id}
234
+
235
+
236
+ @dataclass(frozen=True, slots=True)
237
+ class QuestionSet:
238
+ question_set_id: str
239
+ status: QuestionSetStatus
240
+ revision: int
241
+ questions: tuple[StructuredQuestion, ...]
242
+ answers: tuple[QuestionAnswer, ...]
243
+ created_at: float
244
+ updated_at: float
245
+ expires_at: float
246
+ terminal_at: float | None
247
+ resolution_attempts: int
248
+
249
+ def public_payload(self) -> dict[str, Any]:
250
+ return {
251
+ "question_set_id": self.question_set_id,
252
+ "status": self.status.value,
253
+ "revision": self.revision,
254
+ "questions": [question.public_payload() for question in self.questions],
255
+ "answers": [answer.public_payload() for answer in self.answers],
256
+ "created_at": self.created_at,
257
+ "updated_at": self.updated_at,
258
+ "expires_at": self.expires_at,
259
+ "terminal_at": self.terminal_at,
260
+ "resolution_attempts": self.resolution_attempts,
261
+ }
262
+
263
+
264
+ @dataclass(frozen=True, slots=True)
265
+ class QuestionCreationResult:
266
+ question_set: QuestionSet
267
+ created: bool
268
+
269
+
270
+ @dataclass(frozen=True, slots=True)
271
+ class QuestionResolutionLease:
272
+ question_set: QuestionSet
273
+ lease_token: str = field(repr=False)
274
+ expires_at: float
275
+
276
+
277
+ def default_structured_state_path() -> Path:
278
+ """Return a user-private state path, outside any project by default."""
279
+
280
+ override = str(env_get("ALYSIS_DATA_DIR") or "").strip()
281
+ data_dir = Path(override).expanduser() if override else canonical_user_data_dir()
282
+ return data_dir / "ide" / "structured-state.sqlite3"
283
+
284
+
285
+ class DurableStructuredState:
286
+ """Workspace/owner-scoped durable task and question registry."""
287
+
288
+ def __init__(
289
+ self,
290
+ *,
291
+ owner_id: str,
292
+ workspace_root: str | os.PathLike[str],
293
+ path: str | os.PathLike[str] | None = None,
294
+ config: StructuredStateConfig | None = None,
295
+ clock: Callable[[], float] = time.time,
296
+ id_factory: Callable[[], str] | None = None,
297
+ lease_factory: Callable[[], str] | None = None,
298
+ ) -> None:
299
+ owner_id = _validate_identifier(owner_id, "owner", max_length=256)
300
+ try:
301
+ workspace = Path(workspace_root).expanduser().resolve(strict=True)
302
+ except (OSError, RuntimeError, TypeError, ValueError):
303
+ raise StructuredStateValidationError(
304
+ "Structured-state workspace path is invalid."
305
+ ) from None
306
+ if not workspace.is_dir():
307
+ raise StructuredStateValidationError("Structured-state workspace must be a directory.")
308
+ self.workspace_root = workspace
309
+ try:
310
+ self.path = (
311
+ Path(path).expanduser() if path is not None else default_structured_state_path()
312
+ )
313
+ except (OSError, RuntimeError, TypeError, ValueError):
314
+ raise StructuredStateValidationError(
315
+ "Structured-state storage path is invalid."
316
+ ) from None
317
+ self.config = config or StructuredStateConfig()
318
+ self._clock = clock
319
+ self._id_factory = id_factory or (lambda: uuid.uuid4().hex)
320
+ self._lease_factory = lease_factory or (lambda: f"lease_{secrets.token_urlsafe(32)}")
321
+ self._owner_key = _scope_digest(owner_id)
322
+ self._workspace_key = _scope_digest(os.path.normcase(os.fspath(workspace)))
323
+ self._lock = threading.RLock()
324
+ self._validate_storage_location()
325
+ self._prepare_storage()
326
+
327
+ def get_task_ledger(self, *, session_id: str) -> TaskLedger:
328
+ session_id = _validate_identifier(session_id, "session", max_length=256)
329
+ with self._connection() as connection:
330
+ return self._task_ledger_locked(connection, session_id)
331
+
332
+ def replace_tasks(
333
+ self,
334
+ *,
335
+ session_id: str,
336
+ expected_revision: int,
337
+ tasks: Sequence[TaskItem | Mapping[str, Any]],
338
+ ) -> TaskLedger:
339
+ """Atomically replace a session ledger using revision compare-and-swap."""
340
+
341
+ session_id = _validate_identifier(session_id, "session", max_length=256)
342
+ expected_revision = _validate_revision(expected_revision)
343
+ normalized = self._normalize_tasks(tasks)
344
+ now = self._now()
345
+ with self._lock, self._transaction() as connection:
346
+ current = self._task_ledger_locked(connection, session_id)
347
+ if current.revision != expected_revision:
348
+ raise TaskRevisionConflict(current.revision)
349
+ next_revision = current.revision + 1
350
+ connection.execute(
351
+ """
352
+ INSERT INTO ide_task_ledgers (
353
+ owner_key, workspace_key, session_id, revision, updated_at
354
+ ) VALUES (?, ?, ?, ?, ?)
355
+ ON CONFLICT(owner_key, workspace_key, session_id)
356
+ DO UPDATE SET revision = excluded.revision, updated_at = excluded.updated_at
357
+ """,
358
+ (self._owner_key, self._workspace_key, session_id, next_revision, now),
359
+ )
360
+ connection.execute(
361
+ """
362
+ DELETE FROM ide_tasks
363
+ WHERE owner_key = ? AND workspace_key = ? AND session_id = ?
364
+ """,
365
+ (self._owner_key, self._workspace_key, session_id),
366
+ )
367
+ connection.executemany(
368
+ """
369
+ INSERT INTO ide_tasks (
370
+ owner_key, workspace_key, session_id, position, task_id, title, status
371
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
372
+ """,
373
+ [
374
+ (
375
+ self._owner_key,
376
+ self._workspace_key,
377
+ session_id,
378
+ position,
379
+ task.task_id,
380
+ task.title,
381
+ task.status.value,
382
+ )
383
+ for position, task in enumerate(normalized)
384
+ ],
385
+ )
386
+ return TaskLedger(revision=next_revision, tasks=normalized, updated_at=now)
387
+
388
+ def create_question_set(
389
+ self,
390
+ *,
391
+ session_id: str,
392
+ idempotency_key: str,
393
+ questions: Sequence[StructuredQuestion | Mapping[str, Any]],
394
+ expires_in_seconds: float,
395
+ ) -> QuestionCreationResult:
396
+ session_id = _validate_identifier(session_id, "session", max_length=256)
397
+ idempotency_key = _validate_identifier(idempotency_key, "idempotency", max_length=256)
398
+ ttl = _validate_duration(
399
+ expires_in_seconds,
400
+ minimum=MIN_QUESTION_TTL_SECONDS,
401
+ maximum=MAX_QUESTION_TTL_SECONDS,
402
+ kind="question expiry",
403
+ )
404
+ normalized = self._normalize_questions(questions)
405
+ questions_json = _questions_json(normalized)
406
+ fingerprint = hashlib.sha256(
407
+ json.dumps(
408
+ {"questions": json.loads(questions_json), "ttl": ttl},
409
+ ensure_ascii=False,
410
+ allow_nan=False,
411
+ separators=(",", ":"),
412
+ sort_keys=True,
413
+ ).encode("utf-8")
414
+ ).hexdigest()
415
+ idempotency_hash = _scope_digest(idempotency_key)
416
+ now = self._now()
417
+ expires_at = _safe_deadline(now, ttl)
418
+ with self._lock, self._transaction() as connection:
419
+ self._refresh_questions_locked(connection, session_id=session_id, now=now)
420
+ existing = connection.execute(
421
+ """
422
+ SELECT * FROM ide_question_sets
423
+ WHERE owner_key = ? AND workspace_key = ? AND session_id = ?
424
+ AND idempotency_hash = ?
425
+ """,
426
+ (self._owner_key, self._workspace_key, session_id, idempotency_hash),
427
+ ).fetchone()
428
+ if existing is not None:
429
+ if not hmac.compare_digest(str(existing["payload_sha256"]), fingerprint):
430
+ raise QuestionIdempotencyConflict(
431
+ "Idempotency key was already used for different questions."
432
+ )
433
+ return QuestionCreationResult(
434
+ question_set=_question_set_from_row(existing, config=self.config), created=False
435
+ )
436
+
437
+ outstanding = int(
438
+ connection.execute(
439
+ """
440
+ SELECT COUNT(*) FROM ide_question_sets
441
+ WHERE owner_key = ? AND workspace_key = ? AND session_id = ?
442
+ AND status IN ('pending', 'resolving')
443
+ """,
444
+ (self._owner_key, self._workspace_key, session_id),
445
+ ).fetchone()[0]
446
+ )
447
+ if outstanding >= self.config.max_question_sets_per_session:
448
+ raise StructuredStateCapacityError("Session has too many open question sets.")
449
+ question_set_id = _validate_identifier(
450
+ self._id_factory(), "generated question set", max_length=256
451
+ )
452
+ try:
453
+ connection.execute(
454
+ """
455
+ INSERT INTO ide_question_sets (
456
+ owner_key, workspace_key, session_id, question_set_id,
457
+ idempotency_hash, payload_json, payload_sha256, ttl_seconds,
458
+ status, revision, created_at, updated_at, expires_at
459
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 1, ?, ?, ?)
460
+ """,
461
+ (
462
+ self._owner_key,
463
+ self._workspace_key,
464
+ session_id,
465
+ question_set_id,
466
+ idempotency_hash,
467
+ questions_json,
468
+ fingerprint,
469
+ ttl,
470
+ now,
471
+ now,
472
+ expires_at,
473
+ ),
474
+ )
475
+ except sqlite3.IntegrityError:
476
+ existing = connection.execute(
477
+ """
478
+ SELECT * FROM ide_question_sets
479
+ WHERE owner_key = ? AND workspace_key = ? AND session_id = ?
480
+ AND idempotency_hash = ?
481
+ """,
482
+ (self._owner_key, self._workspace_key, session_id, idempotency_hash),
483
+ ).fetchone()
484
+ if existing is None:
485
+ raise StructuredStateStorageError("Could not persist question set.") from None
486
+ if not hmac.compare_digest(str(existing["payload_sha256"]), fingerprint):
487
+ raise QuestionIdempotencyConflict(
488
+ "Idempotency key was already used for different questions."
489
+ ) from None
490
+ return QuestionCreationResult(
491
+ question_set=_question_set_from_row(existing, config=self.config), created=False
492
+ )
493
+ row = self._question_row_locked(connection, session_id, question_set_id)
494
+ return QuestionCreationResult(
495
+ question_set=_question_set_from_row(row, config=self.config), created=True
496
+ )
497
+
498
+ def get_question_set(self, *, session_id: str, question_set_id: str) -> QuestionSet | None:
499
+ session_id, question_set_id = _validate_question_identifiers(session_id, question_set_id)
500
+ now = self._now()
501
+ with self._lock, self._transaction() as connection:
502
+ self._refresh_questions_locked(connection, session_id=session_id, now=now)
503
+ row = self._optional_question_row_locked(connection, session_id, question_set_id)
504
+ return None if row is None else _question_set_from_row(row, config=self.config)
505
+
506
+ def list_question_sets(
507
+ self,
508
+ *,
509
+ session_id: str,
510
+ statuses: Iterable[QuestionSetStatus | str] | None = None,
511
+ limit: int = 50,
512
+ ) -> list[QuestionSet]:
513
+ session_id = _validate_identifier(session_id, "session", max_length=256)
514
+ status_values = _normalize_question_statuses(statuses)
515
+ if (
516
+ isinstance(limit, bool)
517
+ or not isinstance(limit, int)
518
+ or not 1 <= limit <= self.config.max_list_limit
519
+ ):
520
+ raise StructuredStateValidationError("Question-set list limit is invalid.")
521
+ now = self._now()
522
+ with self._lock, self._transaction() as connection:
523
+ self._refresh_questions_locked(connection, session_id=session_id, now=now)
524
+ params: list[Any] = [self._owner_key, self._workspace_key, session_id]
525
+ where = ""
526
+ if status_values:
527
+ placeholders = ",".join("?" for _ in status_values)
528
+ where = f" AND status IN ({placeholders})"
529
+ params.extend(status_values)
530
+ params.append(limit)
531
+ rows = connection.execute(
532
+ f"""
533
+ SELECT * FROM ide_question_sets
534
+ WHERE owner_key = ? AND workspace_key = ? AND session_id = ?{where}
535
+ ORDER BY created_at DESC, question_set_id DESC
536
+ LIMIT ?
537
+ """,
538
+ params,
539
+ ).fetchall()
540
+ return [_question_set_from_row(row, config=self.config) for row in rows]
541
+
542
+ def claim_question_set(
543
+ self,
544
+ *,
545
+ session_id: str,
546
+ question_set_id: str,
547
+ resolver_id: str,
548
+ lease_seconds: float = DEFAULT_QUESTION_LEASE_SECONDS,
549
+ ) -> QuestionResolutionLease | None:
550
+ session_id, question_set_id = _validate_question_identifiers(session_id, question_set_id)
551
+ resolver_id = _validate_identifier(resolver_id, "resolver", max_length=256)
552
+ lease_seconds = _validate_duration(
553
+ lease_seconds,
554
+ minimum=MIN_QUESTION_LEASE_SECONDS,
555
+ maximum=MAX_QUESTION_LEASE_SECONDS,
556
+ kind="question lease",
557
+ )
558
+ now = self._now()
559
+ with self._lock, self._transaction() as connection:
560
+ self._refresh_questions_locked(connection, session_id=session_id, now=now)
561
+ row = self._question_row_locked(connection, session_id, question_set_id)
562
+ status = QuestionSetStatus(row["status"])
563
+ if status in TERMINAL_QUESTION_STATUSES:
564
+ raise QuestionStateError("Question set is already terminal.")
565
+ if status is QuestionSetStatus.RESOLVING:
566
+ return None
567
+ token = _validate_identifier(
568
+ self._lease_factory(), "generated question lease", max_length=256
569
+ )
570
+ expires_at = min(_safe_deadline(now, lease_seconds), float(row["expires_at"]))
571
+ connection.execute(
572
+ """
573
+ UPDATE ide_question_sets
574
+ SET status = 'resolving', revision = revision + 1,
575
+ updated_at = ?, resolution_attempts = resolution_attempts + 1,
576
+ resolution_token_hash = ?, resolution_owner_hash = ?,
577
+ resolution_expires_at = ?
578
+ WHERE owner_key = ? AND workspace_key = ? AND session_id = ?
579
+ AND question_set_id = ? AND status = 'pending'
580
+ """,
581
+ (
582
+ now,
583
+ _scope_digest(token),
584
+ _scope_digest(resolver_id),
585
+ expires_at,
586
+ self._owner_key,
587
+ self._workspace_key,
588
+ session_id,
589
+ question_set_id,
590
+ ),
591
+ )
592
+ claimed = self._question_row_locked(connection, session_id, question_set_id)
593
+ if claimed["status"] != QuestionSetStatus.RESOLVING.value:
594
+ return None
595
+ return QuestionResolutionLease(
596
+ question_set=_question_set_from_row(claimed, config=self.config),
597
+ lease_token=token,
598
+ expires_at=expires_at,
599
+ )
600
+
601
+ def renew_question_lease(
602
+ self,
603
+ *,
604
+ session_id: str,
605
+ question_set_id: str,
606
+ lease_token: str,
607
+ lease_seconds: float = DEFAULT_QUESTION_LEASE_SECONDS,
608
+ ) -> QuestionResolutionLease:
609
+ session_id, question_set_id, lease_token = _validate_question_lease_identifiers(
610
+ session_id, question_set_id, lease_token
611
+ )
612
+ lease_seconds = _validate_duration(
613
+ lease_seconds,
614
+ minimum=MIN_QUESTION_LEASE_SECONDS,
615
+ maximum=MAX_QUESTION_LEASE_SECONDS,
616
+ kind="question lease",
617
+ )
618
+ now = self._now()
619
+ with self._lock, self._transaction() as connection:
620
+ row = self._require_live_question_lease(
621
+ connection,
622
+ session_id=session_id,
623
+ question_set_id=question_set_id,
624
+ lease_token=lease_token,
625
+ now=now,
626
+ )
627
+ expires_at = min(_safe_deadline(now, lease_seconds), float(row["expires_at"]))
628
+ connection.execute(
629
+ """
630
+ UPDATE ide_question_sets
631
+ SET revision = revision + 1, updated_at = ?, resolution_expires_at = ?
632
+ WHERE owner_key = ? AND workspace_key = ? AND session_id = ?
633
+ AND question_set_id = ?
634
+ """,
635
+ (
636
+ now,
637
+ expires_at,
638
+ self._owner_key,
639
+ self._workspace_key,
640
+ session_id,
641
+ question_set_id,
642
+ ),
643
+ )
644
+ renewed = self._question_row_locked(connection, session_id, question_set_id)
645
+ return QuestionResolutionLease(
646
+ question_set=_question_set_from_row(renewed, config=self.config),
647
+ lease_token=lease_token,
648
+ expires_at=expires_at,
649
+ )
650
+
651
+ def release_question_set(
652
+ self, *, session_id: str, question_set_id: str, lease_token: str
653
+ ) -> QuestionSet:
654
+ session_id, question_set_id, lease_token = _validate_question_lease_identifiers(
655
+ session_id, question_set_id, lease_token
656
+ )
657
+ now = self._now()
658
+ with self._lock, self._transaction() as connection:
659
+ self._require_live_question_lease(
660
+ connection,
661
+ session_id=session_id,
662
+ question_set_id=question_set_id,
663
+ lease_token=lease_token,
664
+ now=now,
665
+ )
666
+ self._set_question_pending_locked(connection, session_id, question_set_id, now)
667
+ return _question_set_from_row(
668
+ self._question_row_locked(connection, session_id, question_set_id),
669
+ config=self.config,
670
+ )
671
+
672
+ def answer_question_set(
673
+ self,
674
+ *,
675
+ session_id: str,
676
+ question_set_id: str,
677
+ lease_token: str,
678
+ answers: Mapping[str, str],
679
+ ) -> QuestionSet:
680
+ session_id, question_set_id, lease_token = _validate_question_lease_identifiers(
681
+ session_id, question_set_id, lease_token
682
+ )
683
+ if not isinstance(answers, Mapping):
684
+ raise StructuredStateValidationError("Question answers must be an object.")
685
+ now = self._now()
686
+ with self._lock, self._transaction() as connection:
687
+ row = self._require_live_question_lease(
688
+ connection,
689
+ session_id=session_id,
690
+ question_set_id=question_set_id,
691
+ lease_token=lease_token,
692
+ now=now,
693
+ )
694
+ questions = _questions_from_json(row["payload_json"], config=self.config)
695
+ normalized_answers = _normalize_answers(answers, questions)
696
+ answers_json = json.dumps(
697
+ {answer.question_id: answer.option_id for answer in normalized_answers},
698
+ ensure_ascii=True,
699
+ separators=(",", ":"),
700
+ sort_keys=True,
701
+ )
702
+ connection.execute(
703
+ """
704
+ UPDATE ide_question_sets
705
+ SET status = 'answered', revision = revision + 1, updated_at = ?,
706
+ answers_json = ?, terminal_at = ?, resolution_token_hash = NULL,
707
+ resolution_owner_hash = NULL, resolution_expires_at = NULL
708
+ WHERE owner_key = ? AND workspace_key = ? AND session_id = ?
709
+ AND question_set_id = ?
710
+ """,
711
+ (
712
+ now,
713
+ answers_json,
714
+ now,
715
+ self._owner_key,
716
+ self._workspace_key,
717
+ session_id,
718
+ question_set_id,
719
+ ),
720
+ )
721
+ return _question_set_from_row(
722
+ self._question_row_locked(connection, session_id, question_set_id),
723
+ config=self.config,
724
+ )
725
+
726
+ def cancel_question_set(
727
+ self,
728
+ *,
729
+ session_id: str,
730
+ question_set_id: str,
731
+ lease_token: str | None = None,
732
+ ) -> QuestionSet:
733
+ session_id, question_set_id = _validate_question_identifiers(session_id, question_set_id)
734
+ if lease_token is not None:
735
+ lease_token = _validate_identifier(lease_token, "question lease", max_length=256)
736
+ now = self._now()
737
+ with self._lock, self._transaction() as connection:
738
+ self._refresh_questions_locked(connection, session_id=session_id, now=now)
739
+ row = self._question_row_locked(connection, session_id, question_set_id)
740
+ status = QuestionSetStatus(row["status"])
741
+ if status in TERMINAL_QUESTION_STATUSES:
742
+ raise QuestionStateError("Question set is already terminal.")
743
+ if status is QuestionSetStatus.RESOLVING:
744
+ if lease_token is None or not _token_matches(
745
+ row["resolution_token_hash"], lease_token
746
+ ):
747
+ raise QuestionLeaseLost("Question resolution lease is no longer valid.")
748
+ if (
749
+ row["resolution_expires_at"] is None
750
+ or float(row["resolution_expires_at"]) <= now
751
+ ):
752
+ raise QuestionLeaseLost("Question resolution lease is no longer valid.")
753
+ elif lease_token is not None:
754
+ # A token is evidence that the caller is attempting a fenced
755
+ # resolver operation. Once released or expired it must never
756
+ # gain the authority of an ordinary unclaimed UI cancellation.
757
+ raise QuestionLeaseLost("Question resolution lease is no longer valid.")
758
+ connection.execute(
759
+ """
760
+ UPDATE ide_question_sets
761
+ SET status = 'cancelled', revision = revision + 1, updated_at = ?,
762
+ terminal_at = ?, resolution_token_hash = NULL,
763
+ resolution_owner_hash = NULL, resolution_expires_at = NULL
764
+ WHERE owner_key = ? AND workspace_key = ? AND session_id = ?
765
+ AND question_set_id = ?
766
+ """,
767
+ (
768
+ now,
769
+ now,
770
+ self._owner_key,
771
+ self._workspace_key,
772
+ session_id,
773
+ question_set_id,
774
+ ),
775
+ )
776
+ return _question_set_from_row(
777
+ self._question_row_locked(connection, session_id, question_set_id),
778
+ config=self.config,
779
+ )
780
+
781
+ def _normalize_tasks(
782
+ self, tasks: Sequence[TaskItem | Mapping[str, Any]]
783
+ ) -> tuple[TaskItem, ...]:
784
+ if isinstance(tasks, (str, bytes)) or not isinstance(tasks, Sequence):
785
+ raise StructuredStateValidationError("Tasks must be an array.")
786
+ if len(tasks) > self.config.max_tasks_per_session:
787
+ raise StructuredStateCapacityError("Task ledger exceeds its task limit.")
788
+ normalized: list[TaskItem] = []
789
+ task_ids: set[str] = set()
790
+ in_progress = 0
791
+ for raw in tasks:
792
+ if isinstance(raw, TaskItem):
793
+ task_id, title, status_raw = raw.task_id, raw.title, raw.status
794
+ elif isinstance(raw, Mapping):
795
+ extra = set(raw) - {"task_id", "title", "status"}
796
+ if extra:
797
+ raise StructuredStateValidationError("Task contains unsupported fields.")
798
+ task_id, title, status_raw = raw.get("task_id"), raw.get("title"), raw.get("status")
799
+ else:
800
+ raise StructuredStateValidationError("Task entry is invalid.")
801
+ task_id = _validate_identifier(task_id, "task", max_length=128)
802
+ if task_id in task_ids:
803
+ raise StructuredStateValidationError("Task identifiers must be unique.")
804
+ task_ids.add(task_id)
805
+ title = _public_text(
806
+ title,
807
+ kind="task title",
808
+ maximum=self.config.max_task_title_chars,
809
+ raw_maximum=self.config.max_raw_text_chars,
810
+ )
811
+ try:
812
+ status = TaskStatus(status_raw)
813
+ except (TypeError, ValueError):
814
+ raise StructuredStateValidationError("Task status is invalid.") from None
815
+ in_progress += status is TaskStatus.IN_PROGRESS
816
+ normalized.append(TaskItem(task_id=task_id, title=title, status=status))
817
+ if in_progress > 1:
818
+ raise StructuredStateValidationError("Only one task may be in progress.")
819
+ return tuple(normalized)
820
+
821
+ def _normalize_questions(
822
+ self, questions: Sequence[StructuredQuestion | Mapping[str, Any]]
823
+ ) -> tuple[StructuredQuestion, ...]:
824
+ if isinstance(questions, (str, bytes)) or not isinstance(questions, Sequence):
825
+ raise StructuredStateValidationError("Questions must be an array.")
826
+ if not 1 <= len(questions) <= 3:
827
+ raise StructuredStateValidationError(
828
+ "A question set must contain one to three questions."
829
+ )
830
+ result: list[StructuredQuestion] = []
831
+ question_ids: set[str] = set()
832
+ for raw_question in questions:
833
+ if isinstance(raw_question, StructuredQuestion):
834
+ question_id = raw_question.question_id
835
+ prompt = raw_question.prompt
836
+ raw_options: Any = raw_question.options
837
+ elif isinstance(raw_question, Mapping):
838
+ extra = set(raw_question) - {"question_id", "prompt", "options"}
839
+ if extra:
840
+ raise StructuredStateValidationError("Question contains unsupported fields.")
841
+ question_id = raw_question.get("question_id")
842
+ prompt = raw_question.get("prompt")
843
+ raw_options = raw_question.get("options")
844
+ else:
845
+ raise StructuredStateValidationError("Question entry is invalid.")
846
+ question_id = _validate_identifier(question_id, "question", max_length=128)
847
+ if question_id in question_ids:
848
+ raise StructuredStateValidationError("Question identifiers must be unique.")
849
+ question_ids.add(question_id)
850
+ prompt = _public_text(
851
+ prompt,
852
+ kind="question prompt",
853
+ maximum=self.config.max_question_prompt_chars,
854
+ raw_maximum=self.config.max_raw_text_chars,
855
+ )
856
+ if isinstance(raw_options, (str, bytes)) or not isinstance(raw_options, Sequence):
857
+ raise StructuredStateValidationError("Question options must be an array.")
858
+ if not 2 <= len(raw_options) <= 3:
859
+ raise StructuredStateValidationError(
860
+ "Each question must have two or three options."
861
+ )
862
+ options: list[QuestionOption] = []
863
+ option_ids: set[str] = set()
864
+ option_labels: set[str] = set()
865
+ for raw_option in raw_options:
866
+ if isinstance(raw_option, QuestionOption):
867
+ option_id = raw_option.option_id
868
+ label = raw_option.label
869
+ description = raw_option.description
870
+ elif isinstance(raw_option, Mapping):
871
+ extra = set(raw_option) - {"option_id", "label", "description"}
872
+ if extra:
873
+ raise StructuredStateValidationError(
874
+ "Question option contains unsupported fields."
875
+ )
876
+ option_id = raw_option.get("option_id")
877
+ label = raw_option.get("label")
878
+ description = raw_option.get("description")
879
+ else:
880
+ raise StructuredStateValidationError("Question option is invalid.")
881
+ option_id = _validate_identifier(option_id, "option", max_length=128)
882
+ if option_id in option_ids:
883
+ raise StructuredStateValidationError(
884
+ "Option identifiers must be unique per question."
885
+ )
886
+ option_ids.add(option_id)
887
+ label = _public_text(
888
+ label,
889
+ kind="option label",
890
+ maximum=self.config.max_option_label_chars,
891
+ raw_maximum=self.config.max_raw_text_chars,
892
+ )
893
+ label_key = label.casefold()
894
+ if label_key in option_labels:
895
+ raise StructuredStateValidationError(
896
+ "Option labels must be distinct per question."
897
+ )
898
+ option_labels.add(label_key)
899
+ description = _public_text(
900
+ description,
901
+ kind="option description",
902
+ maximum=self.config.max_option_description_chars,
903
+ raw_maximum=self.config.max_raw_text_chars,
904
+ )
905
+ options.append(
906
+ QuestionOption(option_id=option_id, label=label, description=description)
907
+ )
908
+ result.append(
909
+ StructuredQuestion(question_id=question_id, prompt=prompt, options=tuple(options))
910
+ )
911
+ return tuple(result)
912
+
913
+ def _task_ledger_locked(self, connection: sqlite3.Connection, session_id: str) -> TaskLedger:
914
+ ledger = connection.execute(
915
+ """
916
+ SELECT revision, updated_at FROM ide_task_ledgers
917
+ WHERE owner_key = ? AND workspace_key = ? AND session_id = ?
918
+ """,
919
+ (self._owner_key, self._workspace_key, session_id),
920
+ ).fetchone()
921
+ rows = connection.execute(
922
+ """
923
+ SELECT position, task_id, title, status FROM ide_tasks
924
+ WHERE owner_key = ? AND workspace_key = ? AND session_id = ?
925
+ ORDER BY position ASC
926
+ """,
927
+ (self._owner_key, self._workspace_key, session_id),
928
+ ).fetchall()
929
+ try:
930
+ invalid_positions = any(
931
+ int(row["position"]) != position for position, row in enumerate(rows)
932
+ )
933
+ except (TypeError, ValueError):
934
+ raise StructuredStateStorageError("Stored task ledger is invalid.") from None
935
+ if len(rows) > self.config.max_tasks_per_session or invalid_positions:
936
+ raise StructuredStateStorageError("Stored task ledger is invalid.")
937
+ try:
938
+ tasks = tuple(
939
+ TaskItem(
940
+ task_id=_stored_identifier(row["task_id"], "task", max_length=128),
941
+ title=_stored_public_text(
942
+ row["title"],
943
+ kind="task title",
944
+ maximum=self.config.max_task_title_chars,
945
+ raw_maximum=self.config.max_raw_text_chars,
946
+ ),
947
+ status=TaskStatus(row["status"]),
948
+ )
949
+ for row in rows
950
+ )
951
+ except (TypeError, ValueError):
952
+ raise StructuredStateStorageError("Stored task ledger is invalid.") from None
953
+ if ledger is None:
954
+ if tasks:
955
+ raise StructuredStateStorageError("Stored task ledger is inconsistent.")
956
+ return TaskLedger(revision=0, tasks=(), updated_at=None)
957
+ try:
958
+ revision = int(ledger["revision"])
959
+ updated_at = _stored_nonnegative_number(ledger["updated_at"])
960
+ if revision < 0:
961
+ raise ValueError
962
+ except (TypeError, ValueError):
963
+ raise StructuredStateStorageError("Stored task ledger is invalid.") from None
964
+ return TaskLedger(revision=revision, tasks=tasks, updated_at=updated_at)
965
+
966
+ def _refresh_questions_locked(
967
+ self, connection: sqlite3.Connection, *, session_id: str, now: float
968
+ ) -> None:
969
+ scope = (self._owner_key, self._workspace_key, session_id)
970
+ connection.execute(
971
+ """
972
+ UPDATE ide_question_sets
973
+ SET status = 'expired', revision = revision + 1, updated_at = ?, terminal_at = ?,
974
+ resolution_token_hash = NULL, resolution_owner_hash = NULL,
975
+ resolution_expires_at = NULL
976
+ WHERE owner_key = ? AND workspace_key = ? AND session_id = ?
977
+ AND status IN ('pending', 'resolving') AND expires_at <= ?
978
+ """,
979
+ (now, now, *scope, now),
980
+ )
981
+ connection.execute(
982
+ """
983
+ UPDATE ide_question_sets
984
+ SET status = 'pending', revision = revision + 1, updated_at = ?,
985
+ resolution_token_hash = NULL, resolution_owner_hash = NULL,
986
+ resolution_expires_at = NULL
987
+ WHERE owner_key = ? AND workspace_key = ? AND session_id = ?
988
+ AND status = 'resolving' AND resolution_expires_at <= ? AND expires_at > ?
989
+ """,
990
+ (now, *scope, now, now),
991
+ )
992
+
993
+ def _set_question_pending_locked(
994
+ self, connection: sqlite3.Connection, session_id: str, question_set_id: str, now: float
995
+ ) -> None:
996
+ connection.execute(
997
+ """
998
+ UPDATE ide_question_sets
999
+ SET status = 'pending', revision = revision + 1, updated_at = ?,
1000
+ resolution_token_hash = NULL, resolution_owner_hash = NULL,
1001
+ resolution_expires_at = NULL
1002
+ WHERE owner_key = ? AND workspace_key = ? AND session_id = ?
1003
+ AND question_set_id = ?
1004
+ """,
1005
+ (
1006
+ now,
1007
+ self._owner_key,
1008
+ self._workspace_key,
1009
+ session_id,
1010
+ question_set_id,
1011
+ ),
1012
+ )
1013
+
1014
+ def _require_live_question_lease(
1015
+ self,
1016
+ connection: sqlite3.Connection,
1017
+ *,
1018
+ session_id: str,
1019
+ question_set_id: str,
1020
+ lease_token: str,
1021
+ now: float,
1022
+ ) -> sqlite3.Row:
1023
+ self._refresh_questions_locked(connection, session_id=session_id, now=now)
1024
+ row = self._question_row_locked(connection, session_id, question_set_id)
1025
+ if (
1026
+ row["status"] != QuestionSetStatus.RESOLVING.value
1027
+ or row["resolution_expires_at"] is None
1028
+ or float(row["resolution_expires_at"]) <= now
1029
+ or not _token_matches(row["resolution_token_hash"], lease_token)
1030
+ ):
1031
+ raise QuestionLeaseLost("Question resolution lease is no longer valid.")
1032
+ return row
1033
+
1034
+ def _optional_question_row_locked(
1035
+ self, connection: sqlite3.Connection, session_id: str, question_set_id: str
1036
+ ) -> sqlite3.Row | None:
1037
+ return connection.execute(
1038
+ """
1039
+ SELECT * FROM ide_question_sets
1040
+ WHERE owner_key = ? AND workspace_key = ? AND session_id = ?
1041
+ AND question_set_id = ?
1042
+ """,
1043
+ (self._owner_key, self._workspace_key, session_id, question_set_id),
1044
+ ).fetchone()
1045
+
1046
+ def _question_row_locked(
1047
+ self, connection: sqlite3.Connection, session_id: str, question_set_id: str
1048
+ ) -> sqlite3.Row:
1049
+ row = self._optional_question_row_locked(connection, session_id, question_set_id)
1050
+ if row is None:
1051
+ raise QuestionNotFound("Question set was not found.")
1052
+ return row
1053
+
1054
+ def _now(self) -> float:
1055
+ try:
1056
+ now = float(self._clock())
1057
+ except (TypeError, ValueError, OverflowError) as exc:
1058
+ raise StructuredStateValidationError("Structured-state clock is invalid.") from exc
1059
+ if not math.isfinite(now) or now < 0:
1060
+ raise StructuredStateValidationError("Structured-state clock is invalid.")
1061
+ return now
1062
+
1063
+ def _validate_storage_location(self) -> None:
1064
+ try:
1065
+ is_symlink = self.path.is_symlink()
1066
+ except OSError:
1067
+ raise StructuredStateValidationError(
1068
+ "Structured-state storage path is invalid."
1069
+ ) from None
1070
+ if is_symlink:
1071
+ raise StructuredStateValidationError("Structured-state database cannot be a symlink.")
1072
+ try:
1073
+ resolved = self.path.parent.resolve(strict=False) / self.path.name
1074
+ self.path = resolved
1075
+ resolved.relative_to(self.workspace_root)
1076
+ except ValueError:
1077
+ return
1078
+ except OSError:
1079
+ raise StructuredStateValidationError(
1080
+ "Structured-state storage path is invalid."
1081
+ ) from None
1082
+ raise StructuredStateValidationError(
1083
+ "Structured-state storage must be outside the workspace."
1084
+ )
1085
+
1086
+ def _prepare_storage(self) -> None:
1087
+ deadline = time.monotonic() + self.config.busy_timeout_seconds
1088
+ while True:
1089
+ try:
1090
+ self._prepare_storage_once()
1091
+ return
1092
+ except _StructuredStateTransientStorageError:
1093
+ remaining = deadline - time.monotonic()
1094
+ if remaining <= 0:
1095
+ raise StructuredStateStorageError(
1096
+ "Could not initialize structured-state storage."
1097
+ ) from None
1098
+ time.sleep(min(0.05, remaining))
1099
+
1100
+ def _prepare_storage_once(self) -> None:
1101
+ try:
1102
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
1103
+ _restrict_permissions(self.path.parent, 0o700)
1104
+ with self._connection() as connection:
1105
+ version = int(connection.execute("PRAGMA user_version").fetchone()[0])
1106
+ if version not in (0, SCHEMA_VERSION):
1107
+ raise StructuredStateStorageError(
1108
+ "Structured-state database schema is not supported by this version."
1109
+ )
1110
+ connection.execute("PRAGMA journal_mode = WAL")
1111
+ connection.execute("PRAGMA synchronous = FULL")
1112
+ connection.executescript(
1113
+ """
1114
+ CREATE TABLE IF NOT EXISTS ide_task_ledgers (
1115
+ owner_key TEXT NOT NULL,
1116
+ workspace_key TEXT NOT NULL,
1117
+ session_id TEXT NOT NULL,
1118
+ revision INTEGER NOT NULL CHECK (revision >= 0),
1119
+ updated_at REAL NOT NULL,
1120
+ PRIMARY KEY (owner_key, workspace_key, session_id)
1121
+ );
1122
+ CREATE TABLE IF NOT EXISTS ide_tasks (
1123
+ owner_key TEXT NOT NULL,
1124
+ workspace_key TEXT NOT NULL,
1125
+ session_id TEXT NOT NULL,
1126
+ position INTEGER NOT NULL CHECK (position >= 0),
1127
+ task_id TEXT NOT NULL,
1128
+ title TEXT NOT NULL,
1129
+ status TEXT NOT NULL CHECK (
1130
+ status IN ('pending', 'in_progress', 'completed', 'blocked')
1131
+ ),
1132
+ PRIMARY KEY (owner_key, workspace_key, session_id, task_id),
1133
+ UNIQUE (owner_key, workspace_key, session_id, position),
1134
+ FOREIGN KEY (owner_key, workspace_key, session_id)
1135
+ REFERENCES ide_task_ledgers(owner_key, workspace_key, session_id)
1136
+ ON DELETE CASCADE
1137
+ );
1138
+ CREATE UNIQUE INDEX IF NOT EXISTS ide_tasks_one_in_progress
1139
+ ON ide_tasks(owner_key, workspace_key, session_id)
1140
+ WHERE status = 'in_progress';
1141
+ CREATE TABLE IF NOT EXISTS ide_question_sets (
1142
+ owner_key TEXT NOT NULL,
1143
+ workspace_key TEXT NOT NULL,
1144
+ session_id TEXT NOT NULL,
1145
+ question_set_id TEXT NOT NULL,
1146
+ idempotency_hash TEXT NOT NULL,
1147
+ payload_json TEXT NOT NULL,
1148
+ payload_sha256 TEXT NOT NULL,
1149
+ ttl_seconds REAL NOT NULL,
1150
+ status TEXT NOT NULL CHECK (
1151
+ status IN ('pending', 'resolving', 'answered', 'cancelled', 'expired')
1152
+ ),
1153
+ revision INTEGER NOT NULL CHECK (revision >= 1),
1154
+ created_at REAL NOT NULL,
1155
+ updated_at REAL NOT NULL,
1156
+ expires_at REAL NOT NULL,
1157
+ terminal_at REAL,
1158
+ resolution_attempts INTEGER NOT NULL DEFAULT 0 CHECK (
1159
+ resolution_attempts >= 0
1160
+ ),
1161
+ resolution_token_hash TEXT,
1162
+ resolution_owner_hash TEXT,
1163
+ resolution_expires_at REAL,
1164
+ answers_json TEXT,
1165
+ PRIMARY KEY (owner_key, workspace_key, session_id, question_set_id),
1166
+ UNIQUE (owner_key, workspace_key, session_id, idempotency_hash),
1167
+ CHECK (
1168
+ (status = 'resolving' AND resolution_token_hash IS NOT NULL
1169
+ AND resolution_owner_hash IS NOT NULL
1170
+ AND resolution_expires_at IS NOT NULL)
1171
+ OR
1172
+ (status != 'resolving' AND resolution_token_hash IS NULL
1173
+ AND resolution_owner_hash IS NULL
1174
+ AND resolution_expires_at IS NULL)
1175
+ ),
1176
+ CHECK (
1177
+ (status = 'answered' AND answers_json IS NOT NULL
1178
+ AND terminal_at IS NOT NULL)
1179
+ OR
1180
+ (status IN ('cancelled', 'expired') AND answers_json IS NULL
1181
+ AND terminal_at IS NOT NULL)
1182
+ OR
1183
+ (status IN ('pending', 'resolving') AND answers_json IS NULL
1184
+ AND terminal_at IS NULL)
1185
+ )
1186
+ );
1187
+ CREATE INDEX IF NOT EXISTS ide_question_sets_session_order
1188
+ ON ide_question_sets(
1189
+ owner_key, workspace_key, session_id, created_at DESC
1190
+ );
1191
+ CREATE INDEX IF NOT EXISTS ide_question_sets_recovery
1192
+ ON ide_question_sets(status, expires_at, resolution_expires_at);
1193
+ """
1194
+ )
1195
+ connection.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
1196
+ _restrict_permissions(self.path, 0o600)
1197
+ except StructuredStateError:
1198
+ raise
1199
+ except (OSError, sqlite3.Error):
1200
+ raise StructuredStateStorageError(
1201
+ "Could not initialize structured-state storage."
1202
+ ) from None
1203
+
1204
+ @contextmanager
1205
+ def _connection(self) -> Any:
1206
+ connection: sqlite3.Connection | None = None
1207
+ try:
1208
+ connection = sqlite3.connect(
1209
+ self.path,
1210
+ timeout=self.config.busy_timeout_seconds,
1211
+ isolation_level=None,
1212
+ )
1213
+ connection.row_factory = sqlite3.Row
1214
+ connection.execute(
1215
+ f"PRAGMA busy_timeout = {int(self.config.busy_timeout_seconds * 1000)}"
1216
+ )
1217
+ connection.execute("PRAGMA foreign_keys = ON")
1218
+ except sqlite3.Error as exc:
1219
+ if connection is not None:
1220
+ try:
1221
+ connection.close()
1222
+ except sqlite3.Error:
1223
+ pass
1224
+ if _is_transient_sqlite_error(exc):
1225
+ raise _StructuredStateTransientStorageError(
1226
+ "Structured-state storage is temporarily busy."
1227
+ ) from None
1228
+ raise StructuredStateStorageError("Could not open structured-state storage.") from None
1229
+ try:
1230
+ yield connection
1231
+ except StructuredStateError:
1232
+ raise
1233
+ except sqlite3.Error as exc:
1234
+ if _is_transient_sqlite_error(exc):
1235
+ raise _StructuredStateTransientStorageError(
1236
+ "Structured-state storage is temporarily busy."
1237
+ ) from None
1238
+ raise StructuredStateStorageError("Structured-state operation failed.") from None
1239
+ finally:
1240
+ try:
1241
+ connection.close()
1242
+ except sqlite3.Error:
1243
+ pass
1244
+
1245
+ @contextmanager
1246
+ def _transaction(self) -> Any:
1247
+ with self._connection() as connection:
1248
+ try:
1249
+ connection.execute("BEGIN IMMEDIATE")
1250
+ yield connection
1251
+ connection.execute("COMMIT")
1252
+ except StructuredStateError:
1253
+ _rollback_quietly(connection)
1254
+ raise
1255
+ except sqlite3.Error:
1256
+ _rollback_quietly(connection)
1257
+ raise StructuredStateStorageError("Structured-state operation failed.") from None
1258
+ except BaseException:
1259
+ _rollback_quietly(connection)
1260
+ raise
1261
+
1262
+
1263
+ def _questions_json(questions: Sequence[StructuredQuestion]) -> str:
1264
+ return json.dumps(
1265
+ [question.public_payload() for question in questions],
1266
+ ensure_ascii=False,
1267
+ allow_nan=False,
1268
+ separators=(",", ":"),
1269
+ sort_keys=True,
1270
+ )
1271
+
1272
+
1273
+ def _questions_from_json(
1274
+ raw: Any, *, config: StructuredStateConfig
1275
+ ) -> tuple[StructuredQuestion, ...]:
1276
+ try:
1277
+ if not isinstance(raw, str):
1278
+ raise ValueError
1279
+ decoded = json.loads(raw)
1280
+ if not isinstance(decoded, list) or not 1 <= len(decoded) <= 3:
1281
+ raise ValueError
1282
+ questions: list[StructuredQuestion] = []
1283
+ question_ids: set[str] = set()
1284
+ for entry in decoded:
1285
+ if not isinstance(entry, dict) or set(entry) != {"question_id", "prompt", "options"}:
1286
+ raise ValueError
1287
+ question_id = _stored_identifier(entry["question_id"], "question", max_length=128)
1288
+ if question_id in question_ids:
1289
+ raise ValueError
1290
+ question_ids.add(question_id)
1291
+ prompt = _stored_public_text(
1292
+ entry["prompt"],
1293
+ kind="question prompt",
1294
+ maximum=config.max_question_prompt_chars,
1295
+ raw_maximum=config.max_raw_text_chars,
1296
+ )
1297
+ options_raw = entry["options"]
1298
+ if not isinstance(options_raw, list) or not 2 <= len(options_raw) <= 3:
1299
+ raise ValueError
1300
+ options: list[QuestionOption] = []
1301
+ option_ids: set[str] = set()
1302
+ option_labels: set[str] = set()
1303
+ for option in options_raw:
1304
+ if not isinstance(option, dict) or set(option) != {
1305
+ "option_id",
1306
+ "label",
1307
+ "description",
1308
+ }:
1309
+ raise ValueError
1310
+ option_id = _stored_identifier(option["option_id"], "option", max_length=128)
1311
+ label = _stored_public_text(
1312
+ option["label"],
1313
+ kind="option label",
1314
+ maximum=config.max_option_label_chars,
1315
+ raw_maximum=config.max_raw_text_chars,
1316
+ )
1317
+ if option_id in option_ids or label.casefold() in option_labels:
1318
+ raise ValueError
1319
+ option_ids.add(option_id)
1320
+ option_labels.add(label.casefold())
1321
+ options.append(
1322
+ QuestionOption(
1323
+ option_id=option_id,
1324
+ label=label,
1325
+ description=_stored_public_text(
1326
+ option["description"],
1327
+ kind="option description",
1328
+ maximum=config.max_option_description_chars,
1329
+ raw_maximum=config.max_raw_text_chars,
1330
+ ),
1331
+ )
1332
+ )
1333
+ questions.append(
1334
+ StructuredQuestion(
1335
+ question_id=question_id,
1336
+ prompt=prompt,
1337
+ options=tuple(options),
1338
+ )
1339
+ )
1340
+ return tuple(questions)
1341
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError):
1342
+ raise StructuredStateStorageError("Stored question payload is invalid.") from None
1343
+
1344
+
1345
+ def _question_set_from_row(row: sqlite3.Row, *, config: StructuredStateConfig) -> QuestionSet:
1346
+ questions = _questions_from_json(row["payload_json"], config=config)
1347
+ answers: tuple[QuestionAnswer, ...] = ()
1348
+ if row["answers_json"] is not None:
1349
+ try:
1350
+ decoded = json.loads(row["answers_json"])
1351
+ if not isinstance(decoded, dict):
1352
+ raise ValueError
1353
+ expected = {question.question_id for question in questions}
1354
+ if set(decoded) != expected or any(
1355
+ not isinstance(value, str) for value in decoded.values()
1356
+ ):
1357
+ raise ValueError
1358
+ answers = tuple(
1359
+ QuestionAnswer(
1360
+ question_id=question.question_id, option_id=decoded[question.question_id]
1361
+ )
1362
+ for question in questions
1363
+ )
1364
+ for question, answer in zip(questions, answers, strict=True):
1365
+ if answer.option_id not in {option.option_id for option in question.options}:
1366
+ raise ValueError
1367
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError):
1368
+ raise StructuredStateStorageError("Stored question answers are invalid.") from None
1369
+ try:
1370
+ question_set_id = _stored_identifier(row["question_set_id"], "question set", max_length=256)
1371
+ status = QuestionSetStatus(row["status"])
1372
+ revision = int(row["revision"])
1373
+ created_at = _stored_nonnegative_number(row["created_at"])
1374
+ updated_at = _stored_nonnegative_number(row["updated_at"])
1375
+ expires_at = _stored_nonnegative_number(row["expires_at"])
1376
+ terminal_at = (
1377
+ None if row["terminal_at"] is None else _stored_nonnegative_number(row["terminal_at"])
1378
+ )
1379
+ resolution_attempts = int(row["resolution_attempts"])
1380
+ if revision < 1 or resolution_attempts < 0 or updated_at < created_at:
1381
+ raise ValueError
1382
+ if expires_at <= created_at or (status is QuestionSetStatus.ANSWERED) != bool(answers):
1383
+ raise ValueError
1384
+ return QuestionSet(
1385
+ question_set_id=question_set_id,
1386
+ status=status,
1387
+ revision=revision,
1388
+ questions=questions,
1389
+ answers=answers,
1390
+ created_at=created_at,
1391
+ updated_at=updated_at,
1392
+ expires_at=expires_at,
1393
+ terminal_at=terminal_at,
1394
+ resolution_attempts=resolution_attempts,
1395
+ )
1396
+ except (TypeError, ValueError):
1397
+ raise StructuredStateStorageError("Stored question state is invalid.") from None
1398
+
1399
+
1400
+ def _normalize_answers(
1401
+ answers: Mapping[str, str], questions: Sequence[StructuredQuestion]
1402
+ ) -> tuple[QuestionAnswer, ...]:
1403
+ expected = {question.question_id for question in questions}
1404
+ if any(not isinstance(key, str) for key in answers) or set(answers) != expected:
1405
+ raise StructuredStateValidationError("Answers must select one option for every question.")
1406
+ result: list[QuestionAnswer] = []
1407
+ for question in questions:
1408
+ option_id = _validate_identifier(
1409
+ answers[question.question_id], "selected option", max_length=128
1410
+ )
1411
+ if option_id not in {option.option_id for option in question.options}:
1412
+ raise StructuredStateValidationError("Selected question option is invalid.")
1413
+ result.append(QuestionAnswer(question_id=question.question_id, option_id=option_id))
1414
+ return tuple(result)
1415
+
1416
+
1417
+ def _normalize_question_statuses(
1418
+ statuses: Iterable[QuestionSetStatus | str] | None,
1419
+ ) -> list[str]:
1420
+ if statuses is None:
1421
+ return []
1422
+ if isinstance(statuses, (str, bytes)):
1423
+ raise StructuredStateValidationError("Question-set statuses must be an array.")
1424
+ try:
1425
+ return list(dict.fromkeys(QuestionSetStatus(status).value for status in statuses))
1426
+ except (TypeError, ValueError):
1427
+ raise StructuredStateValidationError("Question-set status filter is invalid.") from None
1428
+
1429
+
1430
+ def _public_text(value: Any, *, kind: str, maximum: int, raw_maximum: int) -> str:
1431
+ if not isinstance(value, str):
1432
+ raise StructuredStateValidationError(f"Structured {kind} must be a string.")
1433
+ if len(value) > raw_maximum:
1434
+ raise StructuredStateValidationError(f"Structured {kind} exceeds its input limit.")
1435
+ cleaned = _SPACE_RE.sub(" ", _CONTROL_RE.sub(" ", value)).strip()
1436
+ if not cleaned:
1437
+ raise StructuredStateValidationError(f"Structured {kind} cannot be empty.")
1438
+ cleaned = _SECRET_ASSIGNMENT_RE.sub(lambda match: f"{match.group(1)}={_REDACTED}", cleaned)
1439
+ cleaned = _SECRET_TOKEN_RE.sub(_REDACTED, cleaned)
1440
+ if len(cleaned) > maximum:
1441
+ cleaned = f"{cleaned[: max(1, maximum - 1)].rstrip()}…"
1442
+ return cleaned
1443
+
1444
+
1445
+ def _stored_public_text(value: Any, *, kind: str, maximum: int, raw_maximum: int) -> str:
1446
+ if not isinstance(value, str):
1447
+ raise StructuredStateValidationError("Stored public text is invalid.")
1448
+ normalized = _public_text(
1449
+ value,
1450
+ kind=kind,
1451
+ maximum=maximum,
1452
+ raw_maximum=raw_maximum,
1453
+ )
1454
+ if normalized != value:
1455
+ raise StructuredStateValidationError("Stored public text is invalid.")
1456
+ return value
1457
+
1458
+
1459
+ def _stored_identifier(value: Any, kind: str, *, max_length: int) -> str:
1460
+ return _validate_identifier(value, kind, max_length=max_length)
1461
+
1462
+
1463
+ def _stored_nonnegative_number(value: Any) -> float:
1464
+ result = float(value)
1465
+ if not math.isfinite(result) or result < 0:
1466
+ raise ValueError
1467
+ return result
1468
+
1469
+
1470
+ def _safe_deadline(now: float, duration: float) -> float:
1471
+ result = now + duration
1472
+ if not math.isfinite(result):
1473
+ raise StructuredStateValidationError("Structured-state deadline is invalid.")
1474
+ return result
1475
+
1476
+
1477
+ def _validate_identifier(value: Any, kind: str, *, max_length: int) -> str:
1478
+ if not isinstance(value, str) or not value or len(value) > max_length:
1479
+ raise StructuredStateValidationError(f"Structured-state {kind} identifier is invalid.")
1480
+ if not _IDENTIFIER_RE.fullmatch(value):
1481
+ raise StructuredStateValidationError(f"Structured-state {kind} identifier is invalid.")
1482
+ return value
1483
+
1484
+
1485
+ def _validate_question_identifiers(session_id: Any, question_set_id: Any) -> tuple[str, str]:
1486
+ return (
1487
+ _validate_identifier(session_id, "session", max_length=256),
1488
+ _validate_identifier(question_set_id, "question set", max_length=256),
1489
+ )
1490
+
1491
+
1492
+ def _validate_question_lease_identifiers(
1493
+ session_id: Any, question_set_id: Any, lease_token: Any
1494
+ ) -> tuple[str, str, str]:
1495
+ session, question_set = _validate_question_identifiers(session_id, question_set_id)
1496
+ return (
1497
+ session,
1498
+ question_set,
1499
+ _validate_identifier(lease_token, "question lease", max_length=256),
1500
+ )
1501
+
1502
+
1503
+ def _validate_revision(value: Any) -> int:
1504
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
1505
+ raise StructuredStateValidationError("Task ledger revision is invalid.")
1506
+ return value
1507
+
1508
+
1509
+ def _validate_duration(value: Any, *, minimum: float, maximum: float, kind: str) -> float:
1510
+ if isinstance(value, bool):
1511
+ raise StructuredStateValidationError(f"Structured-state {kind} is invalid.")
1512
+ try:
1513
+ result = float(value)
1514
+ except (TypeError, ValueError, OverflowError):
1515
+ raise StructuredStateValidationError(f"Structured-state {kind} is invalid.") from None
1516
+ if not math.isfinite(result) or not minimum <= result <= maximum:
1517
+ raise StructuredStateValidationError(
1518
+ f"Structured-state {kind} is outside the allowed range."
1519
+ )
1520
+ return result
1521
+
1522
+
1523
+ def _scope_digest(value: str) -> str:
1524
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
1525
+
1526
+
1527
+ def _token_matches(stored_hash: Any, token: str) -> bool:
1528
+ return isinstance(stored_hash, str) and hmac.compare_digest(stored_hash, _scope_digest(token))
1529
+
1530
+
1531
+ def _is_transient_sqlite_error(exc: sqlite3.Error) -> bool:
1532
+ code = getattr(exc, "sqlite_errorcode", None)
1533
+ return isinstance(code, int) and (code & 0xFF) in {
1534
+ sqlite3.SQLITE_BUSY,
1535
+ sqlite3.SQLITE_LOCKED,
1536
+ }
1537
+
1538
+
1539
+ def _restrict_permissions(path: Path, mode: int) -> None:
1540
+ if os.name == "nt" or not path.exists():
1541
+ return
1542
+ try:
1543
+ path.chmod(mode)
1544
+ except OSError:
1545
+ pass
1546
+
1547
+
1548
+ def _rollback_quietly(connection: sqlite3.Connection) -> None:
1549
+ try:
1550
+ connection.execute("ROLLBACK")
1551
+ except sqlite3.Error:
1552
+ pass
1553
+
1554
+
1555
+ __all__ = [
1556
+ "DEFAULT_QUESTION_LEASE_SECONDS",
1557
+ "DurableStructuredState",
1558
+ "QuestionAnswer",
1559
+ "QuestionCreationResult",
1560
+ "QuestionIdempotencyConflict",
1561
+ "QuestionLeaseLost",
1562
+ "QuestionNotFound",
1563
+ "QuestionOption",
1564
+ "QuestionResolutionLease",
1565
+ "QuestionSet",
1566
+ "QuestionSetStatus",
1567
+ "QuestionStateError",
1568
+ "StructuredQuestion",
1569
+ "StructuredStateCapacityError",
1570
+ "StructuredStateConfig",
1571
+ "StructuredStateError",
1572
+ "StructuredStateStorageError",
1573
+ "StructuredStateValidationError",
1574
+ "TaskItem",
1575
+ "TaskLedger",
1576
+ "TaskRevisionConflict",
1577
+ "TaskStatus",
1578
+ "default_structured_state_path",
1579
+ ]