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