alysis-code 0.13.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- alysis_code/__init__.py +3 -0
- alysis_code/__main__.py +4 -0
- alysis_code/_build_info.py +14 -0
- alysis_code/account_login.py +468 -0
- alysis_code/agent/README.md +35 -0
- alysis_code/agent/__init__.py +11 -0
- alysis_code/agent/acceptance_contract.py +2217 -0
- alysis_code/agent/blast_radius.py +1403 -0
- alysis_code/agent/cache_keepalive.py +227 -0
- alysis_code/agent/completion_certificate.py +366 -0
- alysis_code/agent/completion_gate.py +306 -0
- alysis_code/agent/empty_response_stall.py +403 -0
- alysis_code/agent/errors.py +28 -0
- alysis_code/agent/llm_calls.py +475 -0
- alysis_code/agent/mutation_classification.py +227 -0
- alysis_code/agent/prompt_context.py +2508 -0
- alysis_code/agent/read_ledger.py +253 -0
- alysis_code/agent/regression_baseline.py +642 -0
- alysis_code/agent/reproduction_first.py +610 -0
- alysis_code/agent/sensitive_output.py +629 -0
- alysis_code/agent/session.py +3218 -0
- alysis_code/agent/steering.py +191 -0
- alysis_code/agent/subagent_execution.py +5177 -0
- alysis_code/agent/subagent_workspace.py +666 -0
- alysis_code/agent/tools_assembly.py +4728 -0
- alysis_code/agent/turn/__init__.py +101 -0
- alysis_code/agent/turn/core.py +8483 -0
- alysis_code/agent/turn/events.py +113 -0
- alysis_code/agent/turn/exploration.py +590 -0
- alysis_code/agent/turn/interventions.py +65 -0
- alysis_code/agent/turn/read_cache.py +420 -0
- alysis_code/agent/turn/snapshot.py +179 -0
- alysis_code/agent/turn_contract.py +661 -0
- alysis_code/agent/turn_path.py +129 -0
- alysis_code/agent/verification.py +2885 -0
- alysis_code/agent/verification_commands.py +512 -0
- alysis_code/agent/verification_evidence.py +738 -0
- alysis_code/agent_loop.py +693 -0
- alysis_code/agent_runtimes/__init__.py +51 -0
- alysis_code/agent_runtimes/base.py +114 -0
- alysis_code/agent_runtimes/builtins.py +129 -0
- alysis_code/agent_runtimes/codex_cli.py +664 -0
- alysis_code/agent_runtimes/host.py +263 -0
- alysis_code/agent_runtimes/registry.py +64 -0
- alysis_code/agent_runtimes/service.py +150 -0
- alysis_code/agentbox_client.py +416 -0
- alysis_code/agentbox_integration.py +310 -0
- alysis_code/alysis_cloud.py +152 -0
- alysis_code/approval_scope.py +276 -0
- alysis_code/assets/README.md +33 -0
- alysis_code/assets/__init__.py +126 -0
- alysis_code/assets/asset_read_core.py +281 -0
- alysis_code/assets/budget_allocator.py +456 -0
- alysis_code/assets/comprehender.py +759 -0
- alysis_code/assets/index.py +654 -0
- alysis_code/assets/ingestion.py +275 -0
- alysis_code/assets/legacy_migration.py +413 -0
- alysis_code/assets/models.py +263 -0
- alysis_code/assets/ocr.py +239 -0
- alysis_code/assets/owl/ascii/f-000.txt +13 -0
- alysis_code/assets/owl/ascii/f-001.txt +13 -0
- alysis_code/assets/owl/ascii/f-002.txt +13 -0
- alysis_code/assets/owl/ascii/f-003.txt +13 -0
- alysis_code/assets/owl/ascii/f-004.txt +13 -0
- alysis_code/assets/owl/ascii/f-005.txt +13 -0
- alysis_code/assets/owl/ascii/f-006.txt +13 -0
- alysis_code/assets/owl/ascii/f-007.txt +13 -0
- alysis_code/assets/owl/ascii/f-008.txt +13 -0
- alysis_code/assets/owl/ascii/f-009.txt +13 -0
- alysis_code/assets/owl/ascii/f-010.txt +13 -0
- alysis_code/assets/owl/ascii/f-011.txt +13 -0
- alysis_code/assets/owl/ascii/f-012.txt +13 -0
- alysis_code/assets/owl/ascii/f-013.txt +13 -0
- alysis_code/assets/owl/ascii/f-014.txt +13 -0
- alysis_code/assets/owl/ascii/f-015.txt +13 -0
- alysis_code/assets/owl/ascii/f-016.txt +13 -0
- alysis_code/assets/owl/ascii/f-017.txt +13 -0
- alysis_code/assets/owl/ascii/f-018.txt +13 -0
- alysis_code/assets/owl/ascii/f-019.txt +13 -0
- alysis_code/assets/owl/ascii/f-020.txt +13 -0
- alysis_code/assets/owl/index.html +98 -0
- alysis_code/assets/owl/show-owl.sh +761 -0
- alysis_code/assets/paths.py +49 -0
- alysis_code/assets/plan_binding.py +326 -0
- alysis_code/assets/planner_context.py +466 -0
- alysis_code/assets/planner_tools.py +184 -0
- alysis_code/assets/prompts.py +101 -0
- alysis_code/assets/replanner_context.py +239 -0
- alysis_code/assets/surface.py +521 -0
- alysis_code/assets/untrusted_content.py +48 -0
- alysis_code/assets/usage_logger.py +94 -0
- alysis_code/assets/worker_mirror.py +428 -0
- alysis_code/assets/worker_section.py +303 -0
- alysis_code/assets/worker_tools.py +468 -0
- alysis_code/atomic_io.py +83 -0
- alysis_code/auth_diagnostics.py +272 -0
- alysis_code/background_runner.py +366 -0
- alysis_code/branding.py +270 -0
- alysis_code/budget_policy.py +390 -0
- alysis_code/build_identity.py +465 -0
- alysis_code/builtin_hooks/__init__.py +7 -0
- alysis_code/builtin_hooks/notify_done_windows.py +65 -0
- alysis_code/bwrap_etc.py +76 -0
- alysis_code/cancellation.py +41 -0
- alysis_code/capabilities.py +137 -0
- alysis_code/chatgpt_codex_static_provider.py +133 -0
- alysis_code/cli.py +51 -0
- alysis_code/cli_impl/__init__.py +1 -0
- alysis_code/cli_impl/assets_cli.py +537 -0
- alysis_code/cli_impl/assets_modal.py +412 -0
- alysis_code/cli_impl/chat/__init__.py +156 -0
- alysis_code/cli_impl/chat/commands.py +2616 -0
- alysis_code/cli_impl/chat/loop.py +4508 -0
- alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
- alysis_code/cli_impl/chat/rendering.py +444 -0
- alysis_code/cli_impl/chat/state.py +124 -0
- alysis_code/cli_impl/chat_resume.py +830 -0
- alysis_code/cli_impl/chat_slash_completer.py +258 -0
- alysis_code/cli_impl/commands/__init__.py +11 -0
- alysis_code/cli_impl/commands/_shared.py +89 -0
- alysis_code/cli_impl/commands/auth.py +623 -0
- alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
- alysis_code/cli_impl/commands/chat_state.py +158 -0
- alysis_code/cli_impl/commands/chat_status.py +1248 -0
- alysis_code/cli_impl/commands/chat_terminal.py +942 -0
- alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
- alysis_code/cli_impl/commands/cli_common.py +1223 -0
- alysis_code/cli_impl/commands/cli_surface.py +77 -0
- alysis_code/cli_impl/commands/config.py +131 -0
- alysis_code/cli_impl/commands/conventions.py +85 -0
- alysis_code/cli_impl/commands/execution_helpers.py +350 -0
- alysis_code/cli_impl/commands/extensions.py +401 -0
- alysis_code/cli_impl/commands/forge.py +1282 -0
- alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
- alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
- alysis_code/cli_impl/commands/hooks.py +737 -0
- alysis_code/cli_impl/commands/ide_bridge.py +31 -0
- alysis_code/cli_impl/commands/mcp.py +700 -0
- alysis_code/cli_impl/commands/profile.py +453 -0
- alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
- alysis_code/cli_impl/commands/report.py +88 -0
- alysis_code/cli_impl/commands/root.py +1118 -0
- alysis_code/cli_impl/commands/sandbox.py +184 -0
- alysis_code/cli_impl/commands/server.py +54 -0
- alysis_code/cli_impl/commands/sessions.py +252 -0
- alysis_code/cli_impl/commands/skills.py +404 -0
- alysis_code/cli_impl/commands/startup.py +946 -0
- alysis_code/cli_impl/commands/tools.py +335 -0
- alysis_code/cli_impl/commands/update.py +364 -0
- alysis_code/cli_impl/commands/welcome.py +972 -0
- alysis_code/cli_impl/config_menu.py +3882 -0
- alysis_code/cli_impl/forge.py +4509 -0
- alysis_code/cli_impl/forge_recovery.py +485 -0
- alysis_code/cli_impl/setup_wizard.py +2409 -0
- alysis_code/cli_impl/tui/__init__.py +58 -0
- alysis_code/cli_impl/tui/app.py +4551 -0
- alysis_code/cli_impl/tui/config.py +32 -0
- alysis_code/cli_impl/tui/config_flow.py +2754 -0
- alysis_code/cli_impl/tui/config_overlay.py +566 -0
- alysis_code/cli_impl/tui/content.py +78 -0
- alysis_code/cli_impl/tui/footer.py +218 -0
- alysis_code/cli_impl/tui/forge_status.py +136 -0
- alysis_code/cli_impl/tui/markdown.py +244 -0
- alysis_code/cli_impl/tui/owl.py +109 -0
- alysis_code/cli_impl/tui/plan_meta.py +477 -0
- alysis_code/cli_impl/tui/setup_app.py +519 -0
- alysis_code/cli_impl/tui/setup_flow.py +1622 -0
- alysis_code/cli_impl/tui/state.py +101 -0
- alysis_code/cli_impl/tui/subagent_identity.py +66 -0
- alysis_code/cli_impl/tui/subagent_panel.py +186 -0
- alysis_code/cli_impl/tui/surface.py +796 -0
- alysis_code/cli_impl/tui/transcript.py +514 -0
- alysis_code/cli_impl/tui/update_prompt.py +79 -0
- alysis_code/cli_impl/tui/workspace_guard.py +384 -0
- alysis_code/clipboard.py +172 -0
- alysis_code/code_review.py +1211 -0
- alysis_code/compaction/__init__.py +28 -0
- alysis_code/compaction/conversation_compactor.py +2932 -0
- alysis_code/compaction/importance.py +177 -0
- alysis_code/compaction/settings.py +297 -0
- alysis_code/compaction/tool_output_offload.py +447 -0
- alysis_code/config.py +3509 -0
- alysis_code/conflict_auto_resolver.py +895 -0
- alysis_code/context/__init__.py +1 -0
- alysis_code/context/tool_schema_budgeter.py +220 -0
- alysis_code/crash_diagnostics.py +282 -0
- alysis_code/custom_tools/README.md +34 -0
- alysis_code/custom_tools/__init__.py +43 -0
- alysis_code/custom_tools/discovery.py +903 -0
- alysis_code/custom_tools/runtime.py +1516 -0
- alysis_code/custom_tools/session.py +227 -0
- alysis_code/custom_tools/trust.py +232 -0
- alysis_code/diff_paths.py +113 -0
- alysis_code/direction_change.py +293 -0
- alysis_code/dispatch_timing.py +306 -0
- alysis_code/durable_service_manager.py +1236 -0
- alysis_code/edit_discipline.py +659 -0
- alysis_code/error_text.py +73 -0
- alysis_code/execution_budget.py +411 -0
- alysis_code/execution_context.py +915 -0
- alysis_code/execution_deadline.py +1065 -0
- alysis_code/execution_shared.py +1904 -0
- alysis_code/extensions/README.md +30 -0
- alysis_code/extensions/__init__.py +93 -0
- alysis_code/extensions/activation.py +138 -0
- alysis_code/extensions/install.py +1436 -0
- alysis_code/extensions/manifest.py +487 -0
- alysis_code/extensions/models.py +74 -0
- alysis_code/extensions/paths.py +56 -0
- alysis_code/extensions/registry.json +4 -0
- alysis_code/extensions/registry.py +52 -0
- alysis_code/extensions/state.py +83 -0
- alysis_code/extensions/workspace_trust.py +101 -0
- alysis_code/failed_task_evidence.py +369 -0
- alysis_code/failure_category.py +315 -0
- alysis_code/feedback_report.py +1647 -0
- alysis_code/file_classification.py +485 -0
- alysis_code/forge.py +2064 -0
- alysis_code/forge_completion.py +362 -0
- alysis_code/forge_events.py +475 -0
- alysis_code/frontmatter_utils.py +95 -0
- alysis_code/git_evidence.py +1181 -0
- alysis_code/git_ops.py +560 -0
- alysis_code/git_safe.py +62 -0
- alysis_code/git_worktrees.py +190 -0
- alysis_code/hooks/README.md +33 -0
- alysis_code/hooks/__init__.py +67 -0
- alysis_code/hooks/audit.py +171 -0
- alysis_code/hooks/config.py +225 -0
- alysis_code/hooks/dispatcher.py +1110 -0
- alysis_code/hooks/models.py +447 -0
- alysis_code/hooks/trust.py +202 -0
- alysis_code/host_actions.py +543 -0
- alysis_code/host_browser.py +103 -0
- alysis_code/ide/__init__.py +5 -0
- alysis_code/ide/activity_events.py +399 -0
- alysis_code/ide/approvals.py +337 -0
- alysis_code/ide/artifacts.py +153 -0
- alysis_code/ide/browser_egress_proxy.py +1076 -0
- alysis_code/ide/cdp_websocket_transport.py +1192 -0
- alysis_code/ide/change_ledger.py +1721 -0
- alysis_code/ide/context_blocks.py +979 -0
- alysis_code/ide/event_stream.py +531 -0
- alysis_code/ide/forge_protocol.py +3112 -0
- alysis_code/ide/forge_request_ledger.py +737 -0
- alysis_code/ide/health.py +965 -0
- alysis_code/ide/managed_browser.py +2251 -0
- alysis_code/ide/management_protocol.py +3414 -0
- alysis_code/ide/mcp_oauth_coordinator.py +744 -0
- alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
- alysis_code/ide/prompt_queue.py +1070 -0
- alysis_code/ide/protocol.py +191 -0
- alysis_code/ide/resumable_swarm.py +1543 -0
- alysis_code/ide/session_search.py +295 -0
- alysis_code/ide/stdio_bridge.py +9935 -0
- alysis_code/ide/structured_state.py +1579 -0
- alysis_code/ide/swarm_protocol.py +816 -0
- alysis_code/integration_gate.py +506 -0
- alysis_code/interactive_input_guard.py +39 -0
- alysis_code/interactive_plan_mode.py +26 -0
- alysis_code/internal_artifacts.py +179 -0
- alysis_code/knowledge_base.py +1409 -0
- alysis_code/knowledge_capture.py +1190 -0
- alysis_code/knowledge_librarian.py +605 -0
- alysis_code/language_policy.py +34 -0
- alysis_code/litellm_static_provider.py +535 -0
- alysis_code/llm/__init__.py +1 -0
- alysis_code/llm/anthropic_messages.py +2288 -0
- alysis_code/llm/base.py +71 -0
- alysis_code/llm/cache_capabilities.py +985 -0
- alysis_code/llm/cache_control_blocks.py +244 -0
- alysis_code/llm/cache_policy.py +388 -0
- alysis_code/llm/factory.py +373 -0
- alysis_code/llm/gemini_generate_content.py +2652 -0
- alysis_code/llm/gemini_interactions.py +739 -0
- alysis_code/llm/metadata.py +450 -0
- alysis_code/llm/openai_compat.py +2947 -0
- alysis_code/llm/openai_responses.py +2604 -0
- alysis_code/llm/protocols.py +609 -0
- alysis_code/llm/provider_limits.py +525 -0
- alysis_code/llm/request_plan.py +389 -0
- alysis_code/llm/request_shape.py +238 -0
- alysis_code/llm/streaming.py +108 -0
- alysis_code/llm/temperature_compat.py +78 -0
- alysis_code/llm/types.py +195 -0
- alysis_code/llm/usage_normalization.py +222 -0
- alysis_code/llm_error_display.py +315 -0
- alysis_code/logging_redaction.py +326 -0
- alysis_code/managed_host_deadline.py +196 -0
- alysis_code/mcp/README.md +33 -0
- alysis_code/mcp/__init__.py +24 -0
- alysis_code/mcp/client.py +1137 -0
- alysis_code/mcp/config.py +597 -0
- alysis_code/mcp/errors.py +113 -0
- alysis_code/mcp/forge_scope.py +154 -0
- alysis_code/mcp/jsonrpc.py +214 -0
- alysis_code/mcp/manager.py +2308 -0
- alysis_code/mcp/models.py +666 -0
- alysis_code/mcp/oauth.py +972 -0
- alysis_code/mcp/oauth_runtime.py +310 -0
- alysis_code/mcp/oauth_store.py +276 -0
- alysis_code/mcp/prompts.py +329 -0
- alysis_code/mcp/resources.py +295 -0
- alysis_code/mcp/roots.py +106 -0
- alysis_code/mcp/server_requests.py +75 -0
- alysis_code/mcp/token_store.py +859 -0
- alysis_code/mcp/transport_http.py +1338 -0
- alysis_code/mcp/transport_stdio.py +1267 -0
- alysis_code/mcp/untrusted_content.py +119 -0
- alysis_code/merge_conflict_reviewer.py +729 -0
- alysis_code/model_catalog/__init__.py +1 -0
- alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
- alysis_code/model_metadata_policy.py +223 -0
- alysis_code/model_metadata_utils.py +103 -0
- alysis_code/model_registry.py +1420 -0
- alysis_code/model_router.py +147 -0
- alysis_code/permission_policy.py +1016 -0
- alysis_code/personas.py +451 -0
- alysis_code/pipeline_facts.py +233 -0
- alysis_code/plan_assistant.py +4763 -0
- alysis_code/plan_mode.py +393 -0
- alysis_code/plan_reconciliation.py +1228 -0
- alysis_code/plan_repair.py +652 -0
- alysis_code/plan_validation.py +1099 -0
- alysis_code/planning_constraints.py +904 -0
- alysis_code/policy.py +95 -0
- alysis_code/preview_server.py +457 -0
- alysis_code/process_reaping.py +566 -0
- alysis_code/profile_presets.py +1834 -0
- alysis_code/profiles.py +666 -0
- alysis_code/provider_auth/__init__.py +29 -0
- alysis_code/provider_auth/base.py +99 -0
- alysis_code/provider_auth/openai_codex.py +951 -0
- alysis_code/provider_auth/registry.py +76 -0
- alysis_code/provider_auth/store.py +125 -0
- alysis_code/provider_diagnostics.py +1209 -0
- alysis_code/provider_model_catalog.py +685 -0
- alysis_code/provider_telemetry.py +1699 -0
- alysis_code/provider_url.py +75 -0
- alysis_code/reasoning_contracts.py +911 -0
- alysis_code/remote_sync.py +350 -0
- alysis_code/replanning.py +1195 -0
- alysis_code/repo_scan.py +1152 -0
- alysis_code/request_estimation.py +296 -0
- alysis_code/review_gate.py +617 -0
- alysis_code/run_lock.py +1141 -0
- alysis_code/run_outcome.py +58 -0
- alysis_code/run_provenance.py +774 -0
- alysis_code/run_state.py +445 -0
- alysis_code/runtime_artifacts.py +116 -0
- alysis_code/runtime_context_features.py +78 -0
- alysis_code/runtime_kind.py +52 -0
- alysis_code/safety/__init__.py +11 -0
- alysis_code/safety/mcp_sanitize.py +29 -0
- alysis_code/safety/safe_http.py +297 -0
- alysis_code/safety/subagent_report.py +184 -0
- alysis_code/sandbox_doctor.py +682 -0
- alysis_code/sandbox_runner.py +1025 -0
- alysis_code/sandbox_settings.py +423 -0
- alysis_code/serialized_paths.py +355 -0
- alysis_code/server/__init__.py +3 -0
- alysis_code/server/app.py +367 -0
- alysis_code/server/auth.py +34 -0
- alysis_code/server/job_config.py +30 -0
- alysis_code/server/settings.py +215 -0
- alysis_code/server/store.py +193 -0
- alysis_code/server/worker_runner.py +657 -0
- alysis_code/service_persistence.py +355 -0
- alysis_code/session_artifacts.py +108 -0
- alysis_code/session_metrics.py +331 -0
- alysis_code/session_store.py +624 -0
- alysis_code/skills/README.md +34 -0
- alysis_code/skills/__init__.py +104 -0
- alysis_code/skills/conventions.py +84 -0
- alysis_code/skills/discovery.py +176 -0
- alysis_code/skills/eval_models.py +232 -0
- alysis_code/skills/eval_runner.py +372 -0
- alysis_code/skills/evals.py +1344 -0
- alysis_code/skills/install.py +293 -0
- alysis_code/skills/loader.py +118 -0
- alysis_code/skills/matching.py +103 -0
- alysis_code/skills/models.py +71 -0
- alysis_code/skills/paths.py +56 -0
- alysis_code/skills/prompting.py +500 -0
- alysis_code/skills/scaffold.py +142 -0
- alysis_code/skills/state.py +441 -0
- alysis_code/skills/transactions.py +125 -0
- alysis_code/skills/validation.py +304 -0
- alysis_code/step_budget.py +238 -0
- alysis_code/subagent_labels.py +49 -0
- alysis_code/subagents.py +1072 -0
- alysis_code/surface/__init__.py +80 -0
- alysis_code/surface/base.py +305 -0
- alysis_code/surface/console.py +387 -0
- alysis_code/surface/events.py +372 -0
- alysis_code/surface/hidden_surface.py +529 -0
- alysis_code/surface/noop_surface.py +219 -0
- alysis_code/surface/rich_surface.py +1555 -0
- alysis_code/surface/styles.py +67 -0
- alysis_code/surface/theme.py +455 -0
- alysis_code/surface/types.py +100 -0
- alysis_code/swarm_backend.py +926 -0
- alysis_code/swarm_orchestrator.py +4020 -0
- alysis_code/swarm_scheduler.py +441 -0
- alysis_code/swarm_trace.py +429 -0
- alysis_code/swarm_worker.py +2119 -0
- alysis_code/swarm_write_guard.py +348 -0
- alysis_code/task_dependencies.py +170 -0
- alysis_code/task_readiness.py +992 -0
- alysis_code/task_scope.py +2148 -0
- alysis_code/terminal_manager.py +762 -0
- alysis_code/terminal_ownership.py +460 -0
- alysis_code/text_normalization.py +30 -0
- alysis_code/token_budget.py +97 -0
- alysis_code/tools/README.md +34 -0
- alysis_code/tools/__init__.py +1 -0
- alysis_code/tools/artifacts.py +127 -0
- alysis_code/tools/availability.py +188 -0
- alysis_code/tools/fs.py +1456 -0
- alysis_code/tools/git.py +461 -0
- alysis_code/tools/history.py +229 -0
- alysis_code/tools/http_timeout.py +78 -0
- alysis_code/tools/image_generation.py +552 -0
- alysis_code/tools/registry.py +2936 -0
- alysis_code/tools/repo_map.py +476 -0
- alysis_code/tools/search.py +563 -0
- alysis_code/tools/shell.py +135 -0
- alysis_code/tools/symbols.py +1350 -0
- alysis_code/tools/test_discovery.py +643 -0
- alysis_code/tools/web.py +482 -0
- alysis_code/tools/web_search.py +2012 -0
- alysis_code/tools/web_search_dashscope.py +557 -0
- alysis_code/tools/web_search_ddgs.py +221 -0
- alysis_code/tools/web_search_provider_adapters.py +1429 -0
- alysis_code/tools/web_search_tavily.py +194 -0
- alysis_code/updates.py +933 -0
- alysis_code/usage_tracker.py +1990 -0
- alysis_code/verification_command_analysis.py +1004 -0
- alysis_code/verification_contract.py +574 -0
- alysis_code/verification_failure_summary.py +273 -0
- alysis_code/verification_repair.py +385 -0
- alysis_code/verify_gate.py +3129 -0
- alysis_code/web_research.py +1872 -0
- alysis_code/web_search_adapters.py +66 -0
- alysis_code/web_search_policy.py +27 -0
- alysis_code/workspace_binding.py +389 -0
- alysis_code/workspace_binding_ui.py +408 -0
- alysis_code/workspace_context.py +273 -0
- alysis_code/workspace_isolation.py +138 -0
- alysis_code/workspace_provisioning.py +455 -0
- alysis_code-0.13.0.dist-info/METADATA +507 -0
- alysis_code-0.13.0.dist-info/RECORD +458 -0
- alysis_code-0.13.0.dist-info/WHEEL +4 -0
- alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
- alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
- alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,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
|
+
]
|