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,1070 @@
|
|
|
1
|
+
"""Durable, fenced prompt queue for IDE clients.
|
|
2
|
+
|
|
3
|
+
The queue intentionally owns no execution threads. A bridge enqueues a JSON
|
|
4
|
+
payload, claims the next item for a session, and completes (or fails) that item
|
|
5
|
+
with the returned opaque lease token. SQLite ``BEGIN IMMEDIATE`` transactions
|
|
6
|
+
and a per-session partial unique index prevent two bridge processes from
|
|
7
|
+
holding live work for the same session at once.
|
|
8
|
+
|
|
9
|
+
Prompt payloads are never included in dataclass representations or exception
|
|
10
|
+
messages. Storage uses JSON rather than pickle so reopening a database cannot
|
|
11
|
+
execute serialized code.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import math
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import sqlite3
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
import uuid
|
|
25
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
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, TypeAlias
|
|
31
|
+
|
|
32
|
+
from ..branding import canonical_user_data_dir, env_get
|
|
33
|
+
|
|
34
|
+
JsonScalar: TypeAlias = None | bool | int | float | str
|
|
35
|
+
JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
|
|
36
|
+
|
|
37
|
+
SCHEMA_VERSION = 2
|
|
38
|
+
DEFAULT_LEASE_SECONDS = 120.0
|
|
39
|
+
MIN_LEASE_SECONDS = 1.0
|
|
40
|
+
MAX_LEASE_SECONDS = 24 * 60 * 60.0
|
|
41
|
+
_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]*$")
|
|
42
|
+
_ERROR_CODE_RE = re.compile(r"^[a-z][a-z0-9_.-]*$")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PromptState(str, Enum):
|
|
46
|
+
PENDING = "pending"
|
|
47
|
+
RUNNING = "running"
|
|
48
|
+
COMPLETED = "completed"
|
|
49
|
+
CANCELLED = "cancelled"
|
|
50
|
+
FAILED = "failed"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
TERMINAL_STATES = frozenset({PromptState.COMPLETED, PromptState.CANCELLED, PromptState.FAILED})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PromptQueueError(RuntimeError):
|
|
57
|
+
"""Base error with messages safe to surface or log."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PromptQueueValidationError(PromptQueueError, ValueError):
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class PromptQueueCapacityError(PromptQueueError):
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class PromptIdempotencyConflict(PromptQueueError):
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class PromptNotFound(PromptQueueError):
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class PromptStateError(PromptQueueError):
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class PromptLeaseLost(PromptQueueError):
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class PromptQueueStorageError(PromptQueueError):
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True, slots=True)
|
|
89
|
+
class PromptQueueConfig:
|
|
90
|
+
max_payload_bytes: int = 1024 * 1024
|
|
91
|
+
max_json_depth: int = 24
|
|
92
|
+
max_json_nodes: int = 50_000
|
|
93
|
+
max_outstanding_per_session: int = 100
|
|
94
|
+
max_list_limit: int = 500
|
|
95
|
+
busy_timeout_seconds: float = 10.0
|
|
96
|
+
|
|
97
|
+
def __post_init__(self) -> None:
|
|
98
|
+
positive_ints = (
|
|
99
|
+
self.max_payload_bytes,
|
|
100
|
+
self.max_json_depth,
|
|
101
|
+
self.max_json_nodes,
|
|
102
|
+
self.max_outstanding_per_session,
|
|
103
|
+
self.max_list_limit,
|
|
104
|
+
)
|
|
105
|
+
if any(
|
|
106
|
+
isinstance(value, bool) or not isinstance(value, int) or value <= 0
|
|
107
|
+
for value in positive_ints
|
|
108
|
+
):
|
|
109
|
+
raise PromptQueueValidationError("Queue limits must be positive integers.")
|
|
110
|
+
if (
|
|
111
|
+
isinstance(self.busy_timeout_seconds, bool)
|
|
112
|
+
or not isinstance(self.busy_timeout_seconds, (int, float))
|
|
113
|
+
or not math.isfinite(self.busy_timeout_seconds)
|
|
114
|
+
or self.busy_timeout_seconds <= 0
|
|
115
|
+
or self.busy_timeout_seconds > 60
|
|
116
|
+
):
|
|
117
|
+
raise PromptQueueValidationError("Queue busy timeout is outside the allowed range.")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True, slots=True)
|
|
121
|
+
class PromptQueueItem:
|
|
122
|
+
prompt_id: str
|
|
123
|
+
sequence: int
|
|
124
|
+
session_id: str = field(repr=False)
|
|
125
|
+
idempotency_key: str = field(repr=False)
|
|
126
|
+
payload: dict[str, JsonValue] = field(repr=False)
|
|
127
|
+
state: PromptState
|
|
128
|
+
created_at: float
|
|
129
|
+
updated_at: float
|
|
130
|
+
attempts: int
|
|
131
|
+
lease_expires_at: float | None = field(default=None, repr=False)
|
|
132
|
+
execution_started_at: float | None = None
|
|
133
|
+
terminal_at: float | None = None
|
|
134
|
+
error_code: str | None = None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True, slots=True)
|
|
138
|
+
class EnqueueResult:
|
|
139
|
+
item: PromptQueueItem
|
|
140
|
+
created: bool
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True, slots=True)
|
|
144
|
+
class PromptLease:
|
|
145
|
+
item: PromptQueueItem
|
|
146
|
+
lease_token: str = field(repr=False)
|
|
147
|
+
owner_id: str = field(repr=False)
|
|
148
|
+
expires_at: float
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def default_prompt_queue_path() -> Path:
|
|
152
|
+
"""Return the user-data queue path, deliberately outside any workspace."""
|
|
153
|
+
|
|
154
|
+
override = str(env_get("ALYSIS_DATA_DIR") or "").strip()
|
|
155
|
+
data_dir = Path(override).expanduser() if override else canonical_user_data_dir()
|
|
156
|
+
return data_dir / "ide" / "prompt-queue.sqlite3"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class DurablePromptQueue:
|
|
160
|
+
"""SQLite-backed ordered prompt queue.
|
|
161
|
+
|
|
162
|
+
Connections are short-lived and are never shared between threads. SQLite
|
|
163
|
+
serializes writers across processes; an in-process lock also prevents
|
|
164
|
+
schema setup and writes from needlessly contending in one bridge.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
def __init__(
|
|
168
|
+
self,
|
|
169
|
+
path: str | os.PathLike[str] | None = None,
|
|
170
|
+
*,
|
|
171
|
+
config: PromptQueueConfig | None = None,
|
|
172
|
+
clock: Callable[[], float] = time.time,
|
|
173
|
+
id_factory: Callable[[], str] | None = None,
|
|
174
|
+
) -> None:
|
|
175
|
+
self.path = Path(path) if path is not None else default_prompt_queue_path()
|
|
176
|
+
self.config = config or PromptQueueConfig()
|
|
177
|
+
self._clock = clock
|
|
178
|
+
self._id_factory = id_factory or (lambda: uuid.uuid4().hex)
|
|
179
|
+
self._lock = threading.RLock()
|
|
180
|
+
self._prepare_storage()
|
|
181
|
+
|
|
182
|
+
def enqueue(
|
|
183
|
+
self,
|
|
184
|
+
*,
|
|
185
|
+
session_id: str,
|
|
186
|
+
idempotency_key: str,
|
|
187
|
+
payload: Mapping[str, Any],
|
|
188
|
+
) -> EnqueueResult:
|
|
189
|
+
"""Append a prompt, or return its prior row for an identical retry.
|
|
190
|
+
|
|
191
|
+
Reusing ``(session_id, idempotency_key)`` with a different payload is
|
|
192
|
+
rejected. No payload fragments appear in that error.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
session_id = _validate_identifier(session_id, "session", max_length=256)
|
|
196
|
+
idempotency_key = _validate_identifier(idempotency_key, "idempotency", max_length=256)
|
|
197
|
+
payload_json, normalized_payload = _serialize_payload(payload, self.config)
|
|
198
|
+
payload_sha256 = hashlib.sha256(payload_json.encode("utf-8")).hexdigest()
|
|
199
|
+
|
|
200
|
+
with self._lock, self._transaction() as connection:
|
|
201
|
+
existing = connection.execute(
|
|
202
|
+
"""
|
|
203
|
+
SELECT * FROM prompt_queue_items
|
|
204
|
+
WHERE session_id = ? AND idempotency_key = ?
|
|
205
|
+
""",
|
|
206
|
+
(session_id, idempotency_key),
|
|
207
|
+
).fetchone()
|
|
208
|
+
if existing is not None:
|
|
209
|
+
if existing["payload_sha256"] != payload_sha256:
|
|
210
|
+
raise PromptIdempotencyConflict(
|
|
211
|
+
"Idempotency key was already used for a different prompt."
|
|
212
|
+
)
|
|
213
|
+
return EnqueueResult(item=_item_from_row(existing), created=False)
|
|
214
|
+
|
|
215
|
+
outstanding = connection.execute(
|
|
216
|
+
"""
|
|
217
|
+
SELECT COUNT(*) FROM prompt_queue_items
|
|
218
|
+
WHERE session_id = ? AND state IN ('pending', 'running')
|
|
219
|
+
""",
|
|
220
|
+
(session_id,),
|
|
221
|
+
).fetchone()[0]
|
|
222
|
+
if outstanding >= self.config.max_outstanding_per_session:
|
|
223
|
+
raise PromptQueueCapacityError("Session prompt queue is full.")
|
|
224
|
+
|
|
225
|
+
prompt_id = _validate_identifier(self._id_factory(), "generated prompt", max_length=256)
|
|
226
|
+
now = self._now()
|
|
227
|
+
try:
|
|
228
|
+
connection.execute(
|
|
229
|
+
"""
|
|
230
|
+
INSERT INTO prompt_queue_items (
|
|
231
|
+
prompt_id, session_id, idempotency_key,
|
|
232
|
+
payload_json, payload_sha256, state,
|
|
233
|
+
created_at, updated_at
|
|
234
|
+
) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?)
|
|
235
|
+
""",
|
|
236
|
+
(
|
|
237
|
+
prompt_id,
|
|
238
|
+
session_id,
|
|
239
|
+
idempotency_key,
|
|
240
|
+
payload_json,
|
|
241
|
+
payload_sha256,
|
|
242
|
+
now,
|
|
243
|
+
now,
|
|
244
|
+
),
|
|
245
|
+
)
|
|
246
|
+
except sqlite3.IntegrityError:
|
|
247
|
+
# Another process may have inserted the same idempotency key
|
|
248
|
+
# after our first SELECT. Inspect it without exposing values.
|
|
249
|
+
existing = connection.execute(
|
|
250
|
+
"""
|
|
251
|
+
SELECT * FROM prompt_queue_items
|
|
252
|
+
WHERE session_id = ? AND idempotency_key = ?
|
|
253
|
+
""",
|
|
254
|
+
(session_id, idempotency_key),
|
|
255
|
+
).fetchone()
|
|
256
|
+
if existing is None:
|
|
257
|
+
raise PromptQueueStorageError("Could not persist queued prompt.") from None
|
|
258
|
+
if existing["payload_sha256"] != payload_sha256:
|
|
259
|
+
raise PromptIdempotencyConflict(
|
|
260
|
+
"Idempotency key was already used for a different prompt."
|
|
261
|
+
) from None
|
|
262
|
+
return EnqueueResult(item=_item_from_row(existing), created=False)
|
|
263
|
+
|
|
264
|
+
row = connection.execute(
|
|
265
|
+
"SELECT * FROM prompt_queue_items WHERE prompt_id = ?", (prompt_id,)
|
|
266
|
+
).fetchone()
|
|
267
|
+
if row is None: # defensive; insert and lookup share a transaction
|
|
268
|
+
raise PromptQueueStorageError("Could not load queued prompt.")
|
|
269
|
+
item = _item_from_row(row, payload=normalized_payload)
|
|
270
|
+
return EnqueueResult(item=item, created=True)
|
|
271
|
+
|
|
272
|
+
def get(self, *, session_id: str, prompt_id: str) -> PromptQueueItem | None:
|
|
273
|
+
session_id = _validate_identifier(session_id, "session", max_length=256)
|
|
274
|
+
prompt_id = _validate_identifier(prompt_id, "prompt", max_length=256)
|
|
275
|
+
with self._connection() as connection:
|
|
276
|
+
row = connection.execute(
|
|
277
|
+
"""
|
|
278
|
+
SELECT * FROM prompt_queue_items
|
|
279
|
+
WHERE session_id = ? AND prompt_id = ?
|
|
280
|
+
""",
|
|
281
|
+
(session_id, prompt_id),
|
|
282
|
+
).fetchone()
|
|
283
|
+
return None if row is None else _item_from_row(row)
|
|
284
|
+
|
|
285
|
+
def list(
|
|
286
|
+
self,
|
|
287
|
+
*,
|
|
288
|
+
session_id: str,
|
|
289
|
+
states: Iterable[PromptState | str] | None = None,
|
|
290
|
+
limit: int = 100,
|
|
291
|
+
after_sequence: int = 0,
|
|
292
|
+
) -> list[PromptQueueItem]:
|
|
293
|
+
session_id = _validate_identifier(session_id, "session", max_length=256)
|
|
294
|
+
if isinstance(limit, bool) or not isinstance(limit, int):
|
|
295
|
+
raise PromptQueueValidationError("Queue list limit must be an integer.")
|
|
296
|
+
if limit < 1 or limit > self.config.max_list_limit:
|
|
297
|
+
raise PromptQueueValidationError("Queue list limit is outside the allowed range.")
|
|
298
|
+
if isinstance(after_sequence, bool) or not isinstance(after_sequence, int):
|
|
299
|
+
raise PromptQueueValidationError("Queue cursor must be an integer.")
|
|
300
|
+
if after_sequence < 0:
|
|
301
|
+
raise PromptQueueValidationError("Queue cursor cannot be negative.")
|
|
302
|
+
|
|
303
|
+
state_values = _normalize_states(states)
|
|
304
|
+
sql = "SELECT * FROM prompt_queue_items WHERE session_id = ? AND sequence > ?"
|
|
305
|
+
params: list[Any] = [session_id, after_sequence]
|
|
306
|
+
if state_values:
|
|
307
|
+
placeholders = ", ".join("?" for _ in state_values)
|
|
308
|
+
sql += f" AND state IN ({placeholders})"
|
|
309
|
+
params.extend(state_values)
|
|
310
|
+
sql += " ORDER BY sequence ASC LIMIT ?"
|
|
311
|
+
params.append(limit)
|
|
312
|
+
with self._connection() as connection:
|
|
313
|
+
rows = connection.execute(sql, params).fetchall()
|
|
314
|
+
return [_item_from_row(row) for row in rows]
|
|
315
|
+
|
|
316
|
+
def is_reclaimable(self, *, session_id: str, prompt_id: str) -> bool:
|
|
317
|
+
"""Return whether a logical session may claim this outstanding prompt."""
|
|
318
|
+
|
|
319
|
+
session_id = _validate_identifier(session_id, "session", max_length=256)
|
|
320
|
+
prompt_id = _validate_identifier(prompt_id, "prompt", max_length=256)
|
|
321
|
+
now = self._now()
|
|
322
|
+
with self._connection() as connection:
|
|
323
|
+
row = connection.execute(
|
|
324
|
+
"""
|
|
325
|
+
SELECT state, lease_expires_at FROM prompt_queue_items
|
|
326
|
+
WHERE session_id = ? AND prompt_id = ?
|
|
327
|
+
""",
|
|
328
|
+
(session_id, prompt_id),
|
|
329
|
+
).fetchone()
|
|
330
|
+
if row is None:
|
|
331
|
+
return False
|
|
332
|
+
return row["state"] == PromptState.PENDING.value or (
|
|
333
|
+
row["state"] == PromptState.RUNNING.value
|
|
334
|
+
and row["lease_expires_at"] is not None
|
|
335
|
+
and float(row["lease_expires_at"]) <= now
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
def claim_next(
|
|
339
|
+
self,
|
|
340
|
+
*,
|
|
341
|
+
session_id: str,
|
|
342
|
+
owner_id: str,
|
|
343
|
+
lease_seconds: float = DEFAULT_LEASE_SECONDS,
|
|
344
|
+
) -> PromptLease | None:
|
|
345
|
+
"""Atomically claim the oldest eligible prompt for a session.
|
|
346
|
+
|
|
347
|
+
A non-expired running item blocks later prompts, preserving session
|
|
348
|
+
order. An expired running item is reclaimed with a new fencing token
|
|
349
|
+
and an incremented attempt count.
|
|
350
|
+
"""
|
|
351
|
+
|
|
352
|
+
session_id = _validate_identifier(session_id, "session", max_length=256)
|
|
353
|
+
owner_id = _validate_identifier(owner_id, "owner", max_length=256)
|
|
354
|
+
lease_seconds = _validate_lease_seconds(lease_seconds)
|
|
355
|
+
now = self._now()
|
|
356
|
+
expires_at = now + lease_seconds
|
|
357
|
+
lease_token = uuid.uuid4().hex
|
|
358
|
+
|
|
359
|
+
with self._lock, self._transaction() as connection:
|
|
360
|
+
active = connection.execute(
|
|
361
|
+
"""
|
|
362
|
+
SELECT 1 FROM prompt_queue_items
|
|
363
|
+
WHERE session_id = ? AND state = 'running'
|
|
364
|
+
AND lease_expires_at > ?
|
|
365
|
+
LIMIT 1
|
|
366
|
+
""",
|
|
367
|
+
(session_id, now),
|
|
368
|
+
).fetchone()
|
|
369
|
+
if active is not None:
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
row = connection.execute(
|
|
373
|
+
"""
|
|
374
|
+
SELECT * FROM prompt_queue_items
|
|
375
|
+
WHERE session_id = ?
|
|
376
|
+
AND (
|
|
377
|
+
state = 'pending'
|
|
378
|
+
OR (state = 'running' AND lease_expires_at <= ?)
|
|
379
|
+
)
|
|
380
|
+
ORDER BY sequence ASC
|
|
381
|
+
LIMIT 1
|
|
382
|
+
""",
|
|
383
|
+
(session_id, now),
|
|
384
|
+
).fetchone()
|
|
385
|
+
if row is None:
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
connection.execute(
|
|
389
|
+
"""
|
|
390
|
+
UPDATE prompt_queue_items
|
|
391
|
+
SET state = 'running', lease_token = ?, lease_owner = ?,
|
|
392
|
+
lease_session_id = ?, lease_expires_at = ?, attempts = attempts + 1,
|
|
393
|
+
updated_at = ?, terminal_at = NULL, error_code = NULL
|
|
394
|
+
WHERE sequence = ?
|
|
395
|
+
""",
|
|
396
|
+
(lease_token, owner_id, session_id, expires_at, now, row["sequence"]),
|
|
397
|
+
)
|
|
398
|
+
claimed = connection.execute(
|
|
399
|
+
"SELECT * FROM prompt_queue_items WHERE sequence = ?",
|
|
400
|
+
(row["sequence"],),
|
|
401
|
+
).fetchone()
|
|
402
|
+
if claimed is None:
|
|
403
|
+
raise PromptQueueStorageError("Could not load claimed prompt.")
|
|
404
|
+
return PromptLease(
|
|
405
|
+
item=_item_from_row(claimed),
|
|
406
|
+
lease_token=lease_token,
|
|
407
|
+
owner_id=owner_id,
|
|
408
|
+
expires_at=expires_at,
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
def renew(
|
|
412
|
+
self,
|
|
413
|
+
*,
|
|
414
|
+
session_id: str,
|
|
415
|
+
prompt_id: str,
|
|
416
|
+
lease_token: str,
|
|
417
|
+
lease_seconds: float = DEFAULT_LEASE_SECONDS,
|
|
418
|
+
) -> PromptLease:
|
|
419
|
+
session_id, prompt_id, lease_token = _validate_lease_identifiers(
|
|
420
|
+
session_id, prompt_id, lease_token
|
|
421
|
+
)
|
|
422
|
+
lease_seconds = _validate_lease_seconds(lease_seconds)
|
|
423
|
+
now = self._now()
|
|
424
|
+
expires_at = now + lease_seconds
|
|
425
|
+
with self._lock, self._transaction() as connection:
|
|
426
|
+
row = self._require_live_lease(
|
|
427
|
+
connection,
|
|
428
|
+
session_id=session_id,
|
|
429
|
+
prompt_id=prompt_id,
|
|
430
|
+
lease_token=lease_token,
|
|
431
|
+
now=now,
|
|
432
|
+
)
|
|
433
|
+
connection.execute(
|
|
434
|
+
"""
|
|
435
|
+
UPDATE prompt_queue_items
|
|
436
|
+
SET lease_expires_at = ?, updated_at = ?
|
|
437
|
+
WHERE sequence = ?
|
|
438
|
+
""",
|
|
439
|
+
(expires_at, now, row["sequence"]),
|
|
440
|
+
)
|
|
441
|
+
renewed = connection.execute(
|
|
442
|
+
"SELECT * FROM prompt_queue_items WHERE sequence = ?", (row["sequence"],)
|
|
443
|
+
).fetchone()
|
|
444
|
+
assert renewed is not None
|
|
445
|
+
return PromptLease(
|
|
446
|
+
item=_item_from_row(renewed),
|
|
447
|
+
lease_token=lease_token,
|
|
448
|
+
owner_id=renewed["lease_owner"],
|
|
449
|
+
expires_at=expires_at,
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
def complete(self, *, session_id: str, prompt_id: str, lease_token: str) -> PromptQueueItem:
|
|
453
|
+
return self._finish(
|
|
454
|
+
session_id=session_id,
|
|
455
|
+
prompt_id=prompt_id,
|
|
456
|
+
lease_token=lease_token,
|
|
457
|
+
state=PromptState.COMPLETED,
|
|
458
|
+
error_code=None,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
def mark_execution_started(
|
|
462
|
+
self, *, session_id: str, prompt_id: str, lease_token: str
|
|
463
|
+
) -> PromptQueueItem:
|
|
464
|
+
"""Durably cross the at-most-once execution boundary for a live lease.
|
|
465
|
+
|
|
466
|
+
The marker belongs to the prompt identity rather than a SessionStore.
|
|
467
|
+
It therefore survives old-session to new-session rebinding. An expired
|
|
468
|
+
prompt carrying this marker must be failed as indeterminate instead of
|
|
469
|
+
being executed automatically again.
|
|
470
|
+
"""
|
|
471
|
+
|
|
472
|
+
session_id, prompt_id, lease_token = _validate_lease_identifiers(
|
|
473
|
+
session_id, prompt_id, lease_token
|
|
474
|
+
)
|
|
475
|
+
now = self._now()
|
|
476
|
+
with self._lock, self._transaction() as connection:
|
|
477
|
+
row = self._require_live_lease(
|
|
478
|
+
connection,
|
|
479
|
+
session_id=session_id,
|
|
480
|
+
prompt_id=prompt_id,
|
|
481
|
+
lease_token=lease_token,
|
|
482
|
+
now=now,
|
|
483
|
+
)
|
|
484
|
+
if row["execution_started_at"] is not None:
|
|
485
|
+
raise PromptStateError("Prompt execution has already started.")
|
|
486
|
+
connection.execute(
|
|
487
|
+
"""
|
|
488
|
+
UPDATE prompt_queue_items
|
|
489
|
+
SET execution_started_at = ?, updated_at = ?
|
|
490
|
+
WHERE sequence = ?
|
|
491
|
+
""",
|
|
492
|
+
(now, now, row["sequence"]),
|
|
493
|
+
)
|
|
494
|
+
started = connection.execute(
|
|
495
|
+
"SELECT * FROM prompt_queue_items WHERE sequence = ?", (row["sequence"],)
|
|
496
|
+
).fetchone()
|
|
497
|
+
assert started is not None
|
|
498
|
+
return _item_from_row(started)
|
|
499
|
+
|
|
500
|
+
def fail(
|
|
501
|
+
self,
|
|
502
|
+
*,
|
|
503
|
+
session_id: str,
|
|
504
|
+
prompt_id: str,
|
|
505
|
+
lease_token: str,
|
|
506
|
+
error_code: str = "execution_failed",
|
|
507
|
+
) -> PromptQueueItem:
|
|
508
|
+
if (
|
|
509
|
+
not isinstance(error_code, str)
|
|
510
|
+
or len(error_code) > 64
|
|
511
|
+
or not _ERROR_CODE_RE.fullmatch(error_code)
|
|
512
|
+
):
|
|
513
|
+
raise PromptQueueValidationError("Prompt failure code is invalid.")
|
|
514
|
+
return self._finish(
|
|
515
|
+
session_id=session_id,
|
|
516
|
+
prompt_id=prompt_id,
|
|
517
|
+
lease_token=lease_token,
|
|
518
|
+
state=PromptState.FAILED,
|
|
519
|
+
error_code=error_code,
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
def release(self, *, session_id: str, prompt_id: str, lease_token: str) -> PromptQueueItem:
|
|
523
|
+
"""Return a live claim to pending during a graceful bridge shutdown."""
|
|
524
|
+
|
|
525
|
+
session_id, prompt_id, lease_token = _validate_lease_identifiers(
|
|
526
|
+
session_id, prompt_id, lease_token
|
|
527
|
+
)
|
|
528
|
+
now = self._now()
|
|
529
|
+
with self._lock, self._transaction() as connection:
|
|
530
|
+
row = self._require_live_lease(
|
|
531
|
+
connection,
|
|
532
|
+
session_id=session_id,
|
|
533
|
+
prompt_id=prompt_id,
|
|
534
|
+
lease_token=lease_token,
|
|
535
|
+
now=now,
|
|
536
|
+
)
|
|
537
|
+
connection.execute(
|
|
538
|
+
"""
|
|
539
|
+
UPDATE prompt_queue_items
|
|
540
|
+
SET state = 'pending', lease_token = NULL, lease_owner = NULL,
|
|
541
|
+
lease_session_id = NULL, lease_expires_at = NULL, updated_at = ?
|
|
542
|
+
WHERE sequence = ?
|
|
543
|
+
""",
|
|
544
|
+
(now, row["sequence"]),
|
|
545
|
+
)
|
|
546
|
+
released = connection.execute(
|
|
547
|
+
"SELECT * FROM prompt_queue_items WHERE sequence = ?", (row["sequence"],)
|
|
548
|
+
).fetchone()
|
|
549
|
+
assert released is not None
|
|
550
|
+
return _item_from_row(released)
|
|
551
|
+
|
|
552
|
+
def delete_pending(self, *, session_id: str, prompt_id: str) -> PromptQueueItem:
|
|
553
|
+
"""Cancel, but retain, a pending prompt for audit and idempotency."""
|
|
554
|
+
|
|
555
|
+
session_id = _validate_identifier(session_id, "session", max_length=256)
|
|
556
|
+
prompt_id = _validate_identifier(prompt_id, "prompt", max_length=256)
|
|
557
|
+
now = self._now()
|
|
558
|
+
with self._lock, self._transaction() as connection:
|
|
559
|
+
row = connection.execute(
|
|
560
|
+
"""
|
|
561
|
+
SELECT * FROM prompt_queue_items
|
|
562
|
+
WHERE session_id = ? AND prompt_id = ?
|
|
563
|
+
""",
|
|
564
|
+
(session_id, prompt_id),
|
|
565
|
+
).fetchone()
|
|
566
|
+
if row is None:
|
|
567
|
+
raise PromptNotFound("Queued prompt was not found.")
|
|
568
|
+
if row["state"] != PromptState.PENDING.value:
|
|
569
|
+
raise PromptStateError("Only pending prompts can be deleted.")
|
|
570
|
+
connection.execute(
|
|
571
|
+
"""
|
|
572
|
+
UPDATE prompt_queue_items
|
|
573
|
+
SET state = 'cancelled', updated_at = ?, terminal_at = ?
|
|
574
|
+
WHERE sequence = ?
|
|
575
|
+
""",
|
|
576
|
+
(now, now, row["sequence"]),
|
|
577
|
+
)
|
|
578
|
+
cancelled = connection.execute(
|
|
579
|
+
"SELECT * FROM prompt_queue_items WHERE sequence = ?", (row["sequence"],)
|
|
580
|
+
).fetchone()
|
|
581
|
+
assert cancelled is not None
|
|
582
|
+
return _item_from_row(cancelled)
|
|
583
|
+
|
|
584
|
+
def cancel_pending_for_session(self, *, session_id: str) -> int:
|
|
585
|
+
"""Atomically cancel every pending prompt for an explicit session."""
|
|
586
|
+
|
|
587
|
+
session_id = _validate_identifier(session_id, "session", max_length=256)
|
|
588
|
+
now = self._now()
|
|
589
|
+
with self._lock, self._transaction() as connection:
|
|
590
|
+
cursor = connection.execute(
|
|
591
|
+
"""
|
|
592
|
+
UPDATE prompt_queue_items
|
|
593
|
+
SET state = 'cancelled', updated_at = ?, terminal_at = ?
|
|
594
|
+
WHERE session_id = ? AND state = 'pending'
|
|
595
|
+
""",
|
|
596
|
+
(now, now, session_id),
|
|
597
|
+
)
|
|
598
|
+
return max(0, int(cursor.rowcount))
|
|
599
|
+
|
|
600
|
+
def cancel_claimed(
|
|
601
|
+
self, *, session_id: str, prompt_id: str, lease_token: str
|
|
602
|
+
) -> PromptQueueItem:
|
|
603
|
+
"""Mark work cancelled after its fenced executor has stopped."""
|
|
604
|
+
|
|
605
|
+
return self._finish(
|
|
606
|
+
session_id=session_id,
|
|
607
|
+
prompt_id=prompt_id,
|
|
608
|
+
lease_token=lease_token,
|
|
609
|
+
state=PromptState.CANCELLED,
|
|
610
|
+
error_code="cancelled",
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
def rebind_recoverable(
|
|
614
|
+
self, *, source_session_id: str, target_session_id: str
|
|
615
|
+
) -> dict[str, int]:
|
|
616
|
+
"""Rebind outstanding work while preserving a live executor's fence.
|
|
617
|
+
|
|
618
|
+
``session_id`` is the logical queue owner shown to the resumed client;
|
|
619
|
+
``lease_session_id`` remains the session accepted from the executor
|
|
620
|
+
holding the opaque lease. Moving a live row is therefore safe: it blocks
|
|
621
|
+
later target-session prompts, while only the old fenced worker can renew
|
|
622
|
+
or finish it. Expired rows become pending under the new session.
|
|
623
|
+
"""
|
|
624
|
+
|
|
625
|
+
source = _validate_identifier(source_session_id, "source session", max_length=256)
|
|
626
|
+
target = _validate_identifier(target_session_id, "target session", max_length=256)
|
|
627
|
+
if source == target:
|
|
628
|
+
return {"pending": 0, "recovered": 0, "active": 0}
|
|
629
|
+
now = self._now()
|
|
630
|
+
with self._lock, self._transaction() as connection:
|
|
631
|
+
conflict = connection.execute(
|
|
632
|
+
"""
|
|
633
|
+
SELECT 1
|
|
634
|
+
FROM prompt_queue_items source
|
|
635
|
+
JOIN prompt_queue_items target
|
|
636
|
+
ON target.session_id = ?
|
|
637
|
+
AND target.idempotency_key = source.idempotency_key
|
|
638
|
+
WHERE source.session_id = ?
|
|
639
|
+
AND source.state IN ('pending', 'running')
|
|
640
|
+
LIMIT 1
|
|
641
|
+
""",
|
|
642
|
+
(target, source),
|
|
643
|
+
).fetchone()
|
|
644
|
+
if conflict is not None:
|
|
645
|
+
raise PromptIdempotencyConflict(
|
|
646
|
+
"Recoverable session contains a conflicting queued prompt."
|
|
647
|
+
)
|
|
648
|
+
target_running = connection.execute(
|
|
649
|
+
"""
|
|
650
|
+
SELECT 1 FROM prompt_queue_items
|
|
651
|
+
WHERE session_id = ? AND state = 'running'
|
|
652
|
+
LIMIT 1
|
|
653
|
+
""",
|
|
654
|
+
(target,),
|
|
655
|
+
).fetchone()
|
|
656
|
+
source_running = connection.execute(
|
|
657
|
+
"""
|
|
658
|
+
SELECT 1 FROM prompt_queue_items
|
|
659
|
+
WHERE session_id = ? AND state = 'running'
|
|
660
|
+
LIMIT 1
|
|
661
|
+
""",
|
|
662
|
+
(source,),
|
|
663
|
+
).fetchone()
|
|
664
|
+
if target_running is not None and source_running is not None:
|
|
665
|
+
raise PromptStateError("Target session already has active queued work.")
|
|
666
|
+
|
|
667
|
+
pending = connection.execute(
|
|
668
|
+
"""
|
|
669
|
+
UPDATE prompt_queue_items
|
|
670
|
+
SET session_id = ?, updated_at = ?
|
|
671
|
+
WHERE session_id = ? AND state = 'pending'
|
|
672
|
+
""",
|
|
673
|
+
(target, now, source),
|
|
674
|
+
).rowcount
|
|
675
|
+
recovered = connection.execute(
|
|
676
|
+
"""
|
|
677
|
+
UPDATE prompt_queue_items
|
|
678
|
+
SET session_id = ?, state = 'pending', lease_token = NULL,
|
|
679
|
+
lease_owner = NULL, lease_session_id = NULL,
|
|
680
|
+
lease_expires_at = NULL, updated_at = ?
|
|
681
|
+
WHERE session_id = ? AND state = 'running' AND lease_expires_at <= ?
|
|
682
|
+
""",
|
|
683
|
+
(target, now, source, now),
|
|
684
|
+
).rowcount
|
|
685
|
+
active = connection.execute(
|
|
686
|
+
"""
|
|
687
|
+
UPDATE prompt_queue_items
|
|
688
|
+
SET session_id = ?, updated_at = ?
|
|
689
|
+
WHERE session_id = ? AND state = 'running' AND lease_expires_at > ?
|
|
690
|
+
""",
|
|
691
|
+
(target, now, source, now),
|
|
692
|
+
).rowcount
|
|
693
|
+
return {
|
|
694
|
+
"pending": max(0, pending),
|
|
695
|
+
"recovered": max(0, recovered),
|
|
696
|
+
"active": max(0, active),
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
def _finish(
|
|
700
|
+
self,
|
|
701
|
+
*,
|
|
702
|
+
session_id: str,
|
|
703
|
+
prompt_id: str,
|
|
704
|
+
lease_token: str,
|
|
705
|
+
state: PromptState,
|
|
706
|
+
error_code: str | None,
|
|
707
|
+
) -> PromptQueueItem:
|
|
708
|
+
session_id, prompt_id, lease_token = _validate_lease_identifiers(
|
|
709
|
+
session_id, prompt_id, lease_token
|
|
710
|
+
)
|
|
711
|
+
now = self._now()
|
|
712
|
+
with self._lock, self._transaction() as connection:
|
|
713
|
+
row = self._require_live_lease(
|
|
714
|
+
connection,
|
|
715
|
+
session_id=session_id,
|
|
716
|
+
prompt_id=prompt_id,
|
|
717
|
+
lease_token=lease_token,
|
|
718
|
+
now=now,
|
|
719
|
+
)
|
|
720
|
+
connection.execute(
|
|
721
|
+
"""
|
|
722
|
+
UPDATE prompt_queue_items
|
|
723
|
+
SET state = ?, lease_token = NULL, lease_owner = NULL,
|
|
724
|
+
lease_session_id = NULL, lease_expires_at = NULL,
|
|
725
|
+
updated_at = ?, terminal_at = ?,
|
|
726
|
+
error_code = ?
|
|
727
|
+
WHERE sequence = ?
|
|
728
|
+
""",
|
|
729
|
+
(state.value, now, now, error_code, row["sequence"]),
|
|
730
|
+
)
|
|
731
|
+
finished = connection.execute(
|
|
732
|
+
"SELECT * FROM prompt_queue_items WHERE sequence = ?", (row["sequence"],)
|
|
733
|
+
).fetchone()
|
|
734
|
+
assert finished is not None
|
|
735
|
+
return _item_from_row(finished)
|
|
736
|
+
|
|
737
|
+
def _require_live_lease(
|
|
738
|
+
self,
|
|
739
|
+
connection: sqlite3.Connection,
|
|
740
|
+
*,
|
|
741
|
+
session_id: str,
|
|
742
|
+
prompt_id: str,
|
|
743
|
+
lease_token: str,
|
|
744
|
+
now: float,
|
|
745
|
+
) -> sqlite3.Row:
|
|
746
|
+
row = connection.execute(
|
|
747
|
+
"""
|
|
748
|
+
SELECT * FROM prompt_queue_items
|
|
749
|
+
WHERE prompt_id = ?
|
|
750
|
+
""",
|
|
751
|
+
(prompt_id,),
|
|
752
|
+
).fetchone()
|
|
753
|
+
if row is None:
|
|
754
|
+
raise PromptNotFound("Queued prompt was not found.")
|
|
755
|
+
if (
|
|
756
|
+
session_id not in {row["session_id"], row["lease_session_id"]}
|
|
757
|
+
or row["state"] != PromptState.RUNNING.value
|
|
758
|
+
or row["lease_token"] != lease_token
|
|
759
|
+
or row["lease_expires_at"] is None
|
|
760
|
+
or row["lease_expires_at"] <= now
|
|
761
|
+
):
|
|
762
|
+
raise PromptLeaseLost("Prompt lease is no longer valid.")
|
|
763
|
+
return row
|
|
764
|
+
|
|
765
|
+
def _now(self) -> float:
|
|
766
|
+
try:
|
|
767
|
+
now = float(self._clock())
|
|
768
|
+
except (TypeError, ValueError, OverflowError) as exc:
|
|
769
|
+
raise PromptQueueValidationError("Queue clock returned an invalid value.") from exc
|
|
770
|
+
if not math.isfinite(now) or now < 0:
|
|
771
|
+
raise PromptQueueValidationError("Queue clock returned an invalid value.")
|
|
772
|
+
return now
|
|
773
|
+
|
|
774
|
+
def _prepare_storage(self) -> None:
|
|
775
|
+
try:
|
|
776
|
+
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
777
|
+
_restrict_permissions(self.path.parent, 0o700)
|
|
778
|
+
with self._connection() as connection:
|
|
779
|
+
version = int(connection.execute("PRAGMA user_version").fetchone()[0])
|
|
780
|
+
if version not in (0, 1, SCHEMA_VERSION):
|
|
781
|
+
raise PromptQueueStorageError(
|
|
782
|
+
"Prompt queue database schema is not supported by this version."
|
|
783
|
+
)
|
|
784
|
+
connection.execute("PRAGMA journal_mode = WAL")
|
|
785
|
+
connection.execute("PRAGMA synchronous = FULL")
|
|
786
|
+
connection.executescript(
|
|
787
|
+
"""
|
|
788
|
+
CREATE TABLE IF NOT EXISTS prompt_queue_items (
|
|
789
|
+
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
790
|
+
prompt_id TEXT NOT NULL UNIQUE,
|
|
791
|
+
session_id TEXT NOT NULL,
|
|
792
|
+
idempotency_key TEXT NOT NULL,
|
|
793
|
+
payload_json TEXT NOT NULL,
|
|
794
|
+
payload_sha256 TEXT NOT NULL,
|
|
795
|
+
state TEXT NOT NULL CHECK (
|
|
796
|
+
state IN ('pending', 'running', 'completed', 'cancelled', 'failed')
|
|
797
|
+
),
|
|
798
|
+
created_at REAL NOT NULL,
|
|
799
|
+
updated_at REAL NOT NULL,
|
|
800
|
+
attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),
|
|
801
|
+
lease_token TEXT,
|
|
802
|
+
lease_owner TEXT,
|
|
803
|
+
lease_session_id TEXT,
|
|
804
|
+
lease_expires_at REAL,
|
|
805
|
+
execution_started_at REAL,
|
|
806
|
+
terminal_at REAL,
|
|
807
|
+
error_code TEXT,
|
|
808
|
+
UNIQUE (session_id, idempotency_key),
|
|
809
|
+
CHECK (
|
|
810
|
+
(state = 'running' AND lease_token IS NOT NULL
|
|
811
|
+
AND lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL)
|
|
812
|
+
OR
|
|
813
|
+
(state != 'running' AND lease_token IS NULL
|
|
814
|
+
AND lease_owner IS NULL AND lease_expires_at IS NULL)
|
|
815
|
+
)
|
|
816
|
+
);
|
|
817
|
+
CREATE INDEX IF NOT EXISTS prompt_queue_session_order
|
|
818
|
+
ON prompt_queue_items (session_id, sequence);
|
|
819
|
+
CREATE INDEX IF NOT EXISTS prompt_queue_session_state_order
|
|
820
|
+
ON prompt_queue_items (session_id, state, sequence);
|
|
821
|
+
CREATE UNIQUE INDEX IF NOT EXISTS prompt_queue_one_running_per_session
|
|
822
|
+
ON prompt_queue_items (session_id) WHERE state = 'running';
|
|
823
|
+
"""
|
|
824
|
+
)
|
|
825
|
+
columns = {
|
|
826
|
+
str(row["name"])
|
|
827
|
+
for row in connection.execute("PRAGMA table_info(prompt_queue_items)")
|
|
828
|
+
}
|
|
829
|
+
if "lease_session_id" not in columns:
|
|
830
|
+
connection.execute(
|
|
831
|
+
"ALTER TABLE prompt_queue_items ADD COLUMN lease_session_id TEXT"
|
|
832
|
+
)
|
|
833
|
+
if "execution_started_at" not in columns:
|
|
834
|
+
connection.execute(
|
|
835
|
+
"ALTER TABLE prompt_queue_items ADD COLUMN execution_started_at REAL"
|
|
836
|
+
)
|
|
837
|
+
connection.execute(
|
|
838
|
+
"""
|
|
839
|
+
UPDATE prompt_queue_items
|
|
840
|
+
SET lease_session_id = session_id
|
|
841
|
+
WHERE state = 'running' AND lease_session_id IS NULL
|
|
842
|
+
"""
|
|
843
|
+
)
|
|
844
|
+
connection.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
|
845
|
+
_restrict_permissions(self.path, 0o600)
|
|
846
|
+
except PromptQueueError:
|
|
847
|
+
raise
|
|
848
|
+
except (OSError, sqlite3.Error):
|
|
849
|
+
raise PromptQueueStorageError("Could not initialize prompt queue storage.") from None
|
|
850
|
+
|
|
851
|
+
@contextmanager
|
|
852
|
+
def _connection(self) -> Any:
|
|
853
|
+
connection: sqlite3.Connection | None = None
|
|
854
|
+
try:
|
|
855
|
+
connection = sqlite3.connect(
|
|
856
|
+
self.path,
|
|
857
|
+
timeout=self.config.busy_timeout_seconds,
|
|
858
|
+
isolation_level=None,
|
|
859
|
+
)
|
|
860
|
+
connection.row_factory = sqlite3.Row
|
|
861
|
+
busy_timeout_ms = int(self.config.busy_timeout_seconds * 1000)
|
|
862
|
+
connection.execute(f"PRAGMA busy_timeout = {busy_timeout_ms}")
|
|
863
|
+
connection.execute("PRAGMA foreign_keys = ON")
|
|
864
|
+
except sqlite3.Error:
|
|
865
|
+
if connection is not None:
|
|
866
|
+
try:
|
|
867
|
+
connection.close()
|
|
868
|
+
except sqlite3.Error:
|
|
869
|
+
pass
|
|
870
|
+
raise PromptQueueStorageError("Could not open prompt queue storage.") from None
|
|
871
|
+
try:
|
|
872
|
+
yield connection
|
|
873
|
+
finally:
|
|
874
|
+
try:
|
|
875
|
+
connection.close()
|
|
876
|
+
except sqlite3.Error:
|
|
877
|
+
pass
|
|
878
|
+
|
|
879
|
+
@contextmanager
|
|
880
|
+
def _transaction(self) -> Any:
|
|
881
|
+
with self._connection() as connection:
|
|
882
|
+
try:
|
|
883
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
884
|
+
yield connection
|
|
885
|
+
connection.execute("COMMIT")
|
|
886
|
+
except PromptQueueError:
|
|
887
|
+
_rollback_quietly(connection)
|
|
888
|
+
raise
|
|
889
|
+
except sqlite3.Error:
|
|
890
|
+
_rollback_quietly(connection)
|
|
891
|
+
raise PromptQueueStorageError("Prompt queue operation failed.") from None
|
|
892
|
+
except BaseException:
|
|
893
|
+
_rollback_quietly(connection)
|
|
894
|
+
raise
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _serialize_payload(
|
|
898
|
+
payload: Mapping[str, Any], config: PromptQueueConfig
|
|
899
|
+
) -> tuple[str, dict[str, JsonValue]]:
|
|
900
|
+
if not isinstance(payload, Mapping):
|
|
901
|
+
raise PromptQueueValidationError("Prompt payload must be a JSON object.")
|
|
902
|
+
normalized = _normalize_json(payload, config=config)
|
|
903
|
+
if not isinstance(normalized, dict): # Mapping always normalizes to a dict
|
|
904
|
+
raise PromptQueueValidationError("Prompt payload must be a JSON object.")
|
|
905
|
+
try:
|
|
906
|
+
serialized = json.dumps(
|
|
907
|
+
normalized,
|
|
908
|
+
ensure_ascii=False,
|
|
909
|
+
allow_nan=False,
|
|
910
|
+
separators=(",", ":"),
|
|
911
|
+
sort_keys=True,
|
|
912
|
+
)
|
|
913
|
+
except (TypeError, ValueError):
|
|
914
|
+
raise PromptQueueValidationError("Prompt payload is not valid JSON.") from None
|
|
915
|
+
if len(serialized.encode("utf-8")) > config.max_payload_bytes:
|
|
916
|
+
raise PromptQueueValidationError("Prompt payload exceeds the queue size limit.")
|
|
917
|
+
return serialized, normalized
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _normalize_json(value: Any, *, config: PromptQueueConfig) -> JsonValue:
|
|
921
|
+
nodes = 0
|
|
922
|
+
|
|
923
|
+
def visit(item: Any, depth: int) -> JsonValue:
|
|
924
|
+
nonlocal nodes
|
|
925
|
+
nodes += 1
|
|
926
|
+
if nodes > config.max_json_nodes:
|
|
927
|
+
raise PromptQueueValidationError("Prompt payload has too many JSON values.")
|
|
928
|
+
if depth > config.max_json_depth:
|
|
929
|
+
raise PromptQueueValidationError("Prompt payload is nested too deeply.")
|
|
930
|
+
if item is None or isinstance(item, (bool, str)):
|
|
931
|
+
return item
|
|
932
|
+
if isinstance(item, int):
|
|
933
|
+
return item
|
|
934
|
+
if isinstance(item, float):
|
|
935
|
+
if not math.isfinite(item):
|
|
936
|
+
raise PromptQueueValidationError("Prompt payload contains an invalid number.")
|
|
937
|
+
return item
|
|
938
|
+
if isinstance(item, Mapping):
|
|
939
|
+
result: dict[str, JsonValue] = {}
|
|
940
|
+
for key, child in item.items():
|
|
941
|
+
if not isinstance(key, str):
|
|
942
|
+
raise PromptQueueValidationError("Prompt payload object keys must be strings.")
|
|
943
|
+
if len(key) > 512 or _contains_control_character(key):
|
|
944
|
+
raise PromptQueueValidationError("Prompt payload object key is invalid.")
|
|
945
|
+
result[key] = visit(child, depth + 1)
|
|
946
|
+
return result
|
|
947
|
+
if isinstance(item, (list, tuple)):
|
|
948
|
+
return [visit(child, depth + 1) for child in item]
|
|
949
|
+
raise PromptQueueValidationError("Prompt payload contains a non-JSON value.")
|
|
950
|
+
|
|
951
|
+
return visit(value, 0)
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def _item_from_row(
|
|
955
|
+
row: sqlite3.Row, *, payload: dict[str, JsonValue] | None = None
|
|
956
|
+
) -> PromptQueueItem:
|
|
957
|
+
if payload is None:
|
|
958
|
+
try:
|
|
959
|
+
decoded = json.loads(row["payload_json"])
|
|
960
|
+
except (TypeError, ValueError, json.JSONDecodeError):
|
|
961
|
+
raise PromptQueueStorageError("Stored prompt payload is invalid.") from None
|
|
962
|
+
if not isinstance(decoded, dict):
|
|
963
|
+
raise PromptQueueStorageError("Stored prompt payload is invalid.")
|
|
964
|
+
payload = decoded
|
|
965
|
+
return PromptQueueItem(
|
|
966
|
+
prompt_id=row["prompt_id"],
|
|
967
|
+
sequence=int(row["sequence"]),
|
|
968
|
+
session_id=row["session_id"],
|
|
969
|
+
idempotency_key=row["idempotency_key"],
|
|
970
|
+
payload=payload,
|
|
971
|
+
state=PromptState(row["state"]),
|
|
972
|
+
created_at=float(row["created_at"]),
|
|
973
|
+
updated_at=float(row["updated_at"]),
|
|
974
|
+
attempts=int(row["attempts"]),
|
|
975
|
+
lease_expires_at=(
|
|
976
|
+
None if row["lease_expires_at"] is None else float(row["lease_expires_at"])
|
|
977
|
+
),
|
|
978
|
+
execution_started_at=(
|
|
979
|
+
None if row["execution_started_at"] is None else float(row["execution_started_at"])
|
|
980
|
+
),
|
|
981
|
+
terminal_at=None if row["terminal_at"] is None else float(row["terminal_at"]),
|
|
982
|
+
error_code=row["error_code"],
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def _normalize_states(states: Iterable[PromptState | str] | None) -> list[str]:
|
|
987
|
+
if states is None:
|
|
988
|
+
return []
|
|
989
|
+
if isinstance(states, (str, bytes)):
|
|
990
|
+
raise PromptQueueValidationError("Queue states must be a collection.")
|
|
991
|
+
normalized: list[str] = []
|
|
992
|
+
try:
|
|
993
|
+
for state in states:
|
|
994
|
+
normalized.append(PromptState(state).value)
|
|
995
|
+
except (TypeError, ValueError):
|
|
996
|
+
raise PromptQueueValidationError("Queue state filter is invalid.") from None
|
|
997
|
+
return list(dict.fromkeys(normalized))
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def _validate_identifier(value: Any, kind: str, *, max_length: int) -> str:
|
|
1001
|
+
if not isinstance(value, str):
|
|
1002
|
+
raise PromptQueueValidationError(f"Prompt {kind} identifier must be a string.")
|
|
1003
|
+
if not value or len(value) > max_length or not _IDENTIFIER_RE.fullmatch(value):
|
|
1004
|
+
raise PromptQueueValidationError(f"Prompt {kind} identifier is invalid.")
|
|
1005
|
+
return value
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _validate_lease_identifiers(
|
|
1009
|
+
session_id: Any, prompt_id: Any, lease_token: Any
|
|
1010
|
+
) -> tuple[str, str, str]:
|
|
1011
|
+
return (
|
|
1012
|
+
_validate_identifier(session_id, "session", max_length=256),
|
|
1013
|
+
_validate_identifier(prompt_id, "prompt", max_length=256),
|
|
1014
|
+
_validate_identifier(lease_token, "lease", max_length=256),
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def _validate_lease_seconds(value: Any) -> float:
|
|
1019
|
+
if isinstance(value, bool):
|
|
1020
|
+
raise PromptQueueValidationError("Prompt lease duration is invalid.")
|
|
1021
|
+
try:
|
|
1022
|
+
parsed = float(value)
|
|
1023
|
+
except (TypeError, ValueError, OverflowError):
|
|
1024
|
+
raise PromptQueueValidationError("Prompt lease duration is invalid.") from None
|
|
1025
|
+
if not math.isfinite(parsed) or parsed < MIN_LEASE_SECONDS or parsed > MAX_LEASE_SECONDS:
|
|
1026
|
+
raise PromptQueueValidationError("Prompt lease duration is outside the allowed range.")
|
|
1027
|
+
return parsed
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def _contains_control_character(value: str) -> bool:
|
|
1031
|
+
return any(ord(char) < 32 or ord(char) == 127 for char in value)
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def _restrict_permissions(path: Path, mode: int) -> None:
|
|
1035
|
+
if os.name == "nt" or not path.exists():
|
|
1036
|
+
return
|
|
1037
|
+
try:
|
|
1038
|
+
path.chmod(mode)
|
|
1039
|
+
except OSError:
|
|
1040
|
+
# Directory ownership/ACLs may be managed by the host. SQLite remains
|
|
1041
|
+
# usable; deployments can enforce their own ACL policy.
|
|
1042
|
+
pass
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def _rollback_quietly(connection: sqlite3.Connection) -> None:
|
|
1046
|
+
try:
|
|
1047
|
+
connection.execute("ROLLBACK")
|
|
1048
|
+
except sqlite3.Error:
|
|
1049
|
+
pass
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
__all__ = [
|
|
1053
|
+
"DEFAULT_LEASE_SECONDS",
|
|
1054
|
+
"DurablePromptQueue",
|
|
1055
|
+
"EnqueueResult",
|
|
1056
|
+
"PromptIdempotencyConflict",
|
|
1057
|
+
"PromptLease",
|
|
1058
|
+
"PromptLeaseLost",
|
|
1059
|
+
"PromptNotFound",
|
|
1060
|
+
"PromptQueueCapacityError",
|
|
1061
|
+
"PromptQueueConfig",
|
|
1062
|
+
"PromptQueueError",
|
|
1063
|
+
"PromptQueueItem",
|
|
1064
|
+
"PromptQueueStorageError",
|
|
1065
|
+
"PromptQueueValidationError",
|
|
1066
|
+
"PromptState",
|
|
1067
|
+
"PromptStateError",
|
|
1068
|
+
"TERMINAL_STATES",
|
|
1069
|
+
"default_prompt_queue_path",
|
|
1070
|
+
]
|