devops-bot-sdk 1.6.38__tar.gz → 1.6.39__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/devops_bot_sdk.egg-info/SOURCES.txt +2 -0
  4. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/__init__.py +1 -1
  5. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ipc/handlers.py +24 -3
  6. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/platform_compat.py +31 -0
  7. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/run_auto.py +33 -13
  8. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/updater.py +227 -67
  9. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_firewall_open_on_update.py +40 -6
  10. devops_bot_sdk-1.6.39/tests/test_run_verdict_is_honest.py +147 -0
  11. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_task_file_and_plan_run.py +6 -2
  12. devops_bot_sdk-1.6.39/tests/test_updater_reports_the_truth.py +328 -0
  13. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/README.md +0 -0
  14. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  15. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  16. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/devops_bot_sdk.egg-info/requires.txt +0 -0
  17. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  18. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/pyproject.toml +0 -0
  19. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/agentd.py +0 -0
  20. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/agents/__init__.py +0 -0
  21. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/agents/activity.py +0 -0
  22. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/agents/builder.py +0 -0
  23. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/agents/chat.py +0 -0
  24. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/agents/connectors.py +0 -0
  25. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/agents/engines/__init__.py +0 -0
  26. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/agents/engines/claude_code.py +0 -0
  27. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/agents/engines/http.py +0 -0
  28. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/agents/engines/mcp.py +0 -0
  29. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/agents/engines/n8n.py +0 -0
  30. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/agents/engines/shell.py +0 -0
  31. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/agents/integrations.py +0 -0
  32. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/agents/inventory.py +0 -0
  33. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/agents/library/__init__.py +0 -0
  34. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/agents/manifest.py +0 -0
  35. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/agents/prompt_studio.py +0 -0
  36. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/agents/prompt_template.py +0 -0
  37. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/agents/prompt_vars.py +0 -0
  38. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/agents/run_log.py +0 -0
  39. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/agents/schedules.py +0 -0
  40. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/agents/secrets.py +0 -0
  41. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/agents/ticket_run.py +0 -0
  42. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/agents/usage.py +0 -0
  43. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/agents/varstore.py +0 -0
  44. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/browser_profile.py +0 -0
  45. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/cli.py +0 -0
  46. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/client.py +0 -0
  47. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/collectors/__init__.py +0 -0
  48. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/collectors/files.py +0 -0
  49. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/collectors/process.py +0 -0
  50. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/collectors/screenshot.py +0 -0
  51. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/config.py +0 -0
  52. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/crucial.py +0 -0
  53. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/departments.py +0 -0
  54. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/design_verify/__init__.py +0 -0
  55. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/design_verify/assets.py +0 -0
  56. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/design_verify/bootstrap.py +0 -0
  57. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/design_verify/browser.py +0 -0
  58. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/design_verify/compare.py +0 -0
  59. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/design_verify/loop.py +0 -0
  60. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/estimator.py +0 -0
  61. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/exceptions.py +0 -0
  62. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/firewall.py +0 -0
  63. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/formats.py +0 -0
  64. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/git_ops.py +0 -0
  65. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/github_fetch.py +0 -0
  66. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/github_ingest.py +0 -0
  67. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/graph_recon.py +0 -0
  68. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/graphify.py +0 -0
  69. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/hooks/__init__.py +0 -0
  70. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/hooks/crucial_guard.py +0 -0
  71. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/intake.py +0 -0
  72. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ipc/__init__.py +0 -0
  73. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ipc/electron_bridge.py +0 -0
  74. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/journal.py +0 -0
  75. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/local_exec.py +0 -0
  76. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/models/__init__.py +0 -0
  77. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/models/envelope.py +0 -0
  78. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/models/requests.py +0 -0
  79. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/models/responses.py +0 -0
  80. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/models/snapshots.py +0 -0
  81. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/phase_timer.py +0 -0
  82. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/plan_run.py +0 -0
  83. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/prd_progress.py +0 -0
  84. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/proof_readers.py +0 -0
  85. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/providers.py +0 -0
  86. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/py.typed +0 -0
  87. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/resume.py +0 -0
  88. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/router.py +0 -0
  89. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/scheduler.py +0 -0
  90. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/self_report.py +0 -0
  91. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/self_report_local.py +0 -0
  92. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/sse.py +0 -0
  93. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/swagger.py +0 -0
  94. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/task_file.py +0 -0
  95. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/task_types.py +0 -0
  96. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/test.py +0 -0
  97. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/test_pipeline.py +0 -0
  98. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ticket_fields.py +0 -0
  99. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/tiers/__init__.py +0 -0
  100. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/tiers/mini_agent.py +0 -0
  101. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ui/__init__.py +0 -0
  102. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ui/api.py +0 -0
  103. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ui/dashboard.py +0 -0
  104. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ui/health.py +0 -0
  105. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ui/ip_allowlist.py +0 -0
  106. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ui/jobs.py +0 -0
  107. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ui/models.py +0 -0
  108. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ui/runner.py +0 -0
  109. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ui/security.py +0 -0
  110. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ui/server.py +0 -0
  111. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ui/service.py +0 -0
  112. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ui/session.py +0 -0
  113. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ui/setup_manager.py +0 -0
  114. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ui/static/agents.html +0 -0
  115. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ui/static/app.js +0 -0
  116. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ui/static/chat.html +0 -0
  117. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ui/static/custom-agents.html +0 -0
  118. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ui/static/default-agents.html +0 -0
  119. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ui/static/index.html +0 -0
  120. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ui/static/report.html +0 -0
  121. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ui/static/runs.html +0 -0
  122. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ui/static/schedules.html +0 -0
  123. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ui/static/setup.html +0 -0
  124. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ui/static/styles.css +0 -0
  125. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ui/static/tickets.html +0 -0
  126. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/ui/swagger.py +0 -0
  127. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/url_access.py +0 -0
  128. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/vps_health.py +0 -0
  129. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/sdk/write_hook.py +0 -0
  130. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/setup.cfg +0 -0
  131. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_agent_activity.py +0 -0
  132. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_agent_activity_tracking.py +0 -0
  133. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_agent_chat_features.py +0 -0
  134. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_agent_listing.py +0 -0
  135. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_agent_prompt_async.py +0 -0
  136. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_agent_prompt_models.py +0 -0
  137. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_agent_run_ticket.py +0 -0
  138. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_agent_schedules.py +0 -0
  139. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_agent_workspace_settings.py +0 -0
  140. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_agents_connectors_builder.py +0 -0
  141. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_allowed_tools_config.py +0 -0
  142. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_auto_agent_dispatch.py +0 -0
  143. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_browser_profile.py +0 -0
  144. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_cheap_tier.py +0 -0
  145. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_client_jira_projects.py +0 -0
  146. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_config_token_diagnostics.py +0 -0
  147. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_configure_token_owner.py +0 -0
  148. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_console_encoding.py +0 -0
  149. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_departments.py +0 -0
  150. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_epic_decompose_run.py +0 -0
  151. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_epic_inherited_agent_id.py +0 -0
  152. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_epic_session_isolation.py +0 -0
  153. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_estimator.py +0 -0
  154. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_formats.py +0 -0
  155. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_git_action.py +0 -0
  156. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_github_fetch.py +0 -0
  157. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_github_ingest.py +0 -0
  158. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_graph_recon.py +0 -0
  159. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_graphify_build.py +0 -0
  160. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_graphify_hook_install.py +0 -0
  161. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_health.py +0 -0
  162. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_intake_job_outcome.py +0 -0
  163. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_intake_plan_review.py +0 -0
  164. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_journal.py +0 -0
  165. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_local_exec_provider.py +0 -0
  166. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_phase_timer.py +0 -0
  167. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_plan_new_command.py +0 -0
  168. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_plan_run_activity.py +0 -0
  169. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_prd_artifact.py +0 -0
  170. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_proof_readers.py +0 -0
  171. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_providers.py +0 -0
  172. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_recon_session_isolation.py +0 -0
  173. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_resume.py +0 -0
  174. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_router.py +0 -0
  175. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_run_auto_agent_only.py +0 -0
  176. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_run_outputs.py +0 -0
  177. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_schedule_api.py +0 -0
  178. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_scheduler.py +0 -0
  179. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_self_report.py +0 -0
  180. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_service_token_warning.py +0 -0
  181. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_swagger_command.py +0 -0
  182. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_task_file_format_doc.py +0 -0
  183. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_ticket_fields.py +0 -0
  184. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_ticket_run_resolve_by_id.py +0 -0
  185. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_ticket_speed_fixes.py +0 -0
  186. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_tier_flag_parity.py +0 -0
  187. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_ui_gate.py +0 -0
  188. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_ui_lifespan.py +0 -0
  189. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_update_timer.py +0 -0
  190. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_updater_extras.py +0 -0
  191. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_url_access.py +0 -0
  192. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_vps_health.py +0 -0
  193. {devops_bot_sdk-1.6.38 → devops_bot_sdk-1.6.39}/tests/test_write_hook.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.6.38
3
+ Version: 1.6.39
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.6.38
3
+ Version: 1.6.39
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -169,6 +169,7 @@ tests/test_resume.py
169
169
  tests/test_router.py
170
170
  tests/test_run_auto_agent_only.py
171
171
  tests/test_run_outputs.py
172
+ tests/test_run_verdict_is_honest.py
172
173
  tests/test_schedule_api.py
173
174
  tests/test_scheduler.py
174
175
  tests/test_self_report.py
@@ -184,6 +185,7 @@ tests/test_ui_gate.py
184
185
  tests/test_ui_lifespan.py
185
186
  tests/test_update_timer.py
186
187
  tests/test_updater_extras.py
188
+ tests/test_updater_reports_the_truth.py
187
189
  tests/test_url_access.py
188
190
  tests/test_vps_health.py
189
191
  tests/test_write_hook.py
@@ -34,7 +34,7 @@ Rules:
34
34
  - All data egress through submit_webhook only
35
35
  """
36
36
 
37
- __version__ = "1.6.38" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
37
+ __version__ = "1.6.39" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
38
38
  __author__ = "AgentOS" # sdk.client.SDK_VERSION and the UI server version all read this.
39
39
 
40
40
  from sdk.client import BackendClient
@@ -2083,10 +2083,20 @@ async def _run_local_pipeline(
2083
2083
  await asyncio.sleep(_KICKOFF_BACKOFF_S * kick_attempt)
2084
2084
 
2085
2085
  if not session_id:
2086
+ # Name the call and the window. "ReadTimeout" on its own reads like a
2087
+ # local fault and gets diagnosed as one (a proxy, a permissions problem,
2088
+ # the wrong user account); what it actually means here is that the backend
2089
+ # accepted the connection and then did not ack the kickoff in time.
2090
+ detail = last_error
2091
+ if "Timeout" in last_error:
2092
+ detail = (f"{last_error} — POST /api/v1/orchestrate connected but sent "
2093
+ f"no session_id within {_kick_read_s:.0f}s. The backend is "
2094
+ f"slow or wedged, not this machine; raise the window with "
2095
+ f"AGENTOS_ORCHESTRATE_KICKOFF_TIMEOUT_S if it is merely slow.")
2086
2096
  await send(_error_envelope(
2087
2097
  thread_id,
2088
2098
  RuntimeError(
2089
- f"orchestrate failed after {_KICKOFF_ATTEMPTS} attempt(s): {last_error}"
2099
+ f"orchestrate failed after {_KICKOFF_ATTEMPTS} attempt(s): {detail}"
2090
2100
  ),
2091
2101
  ).model_dump())
2092
2102
  return
@@ -5212,10 +5222,21 @@ async def handle_orchestrate_auto_local_run(
5212
5222
  ).model_dump())
5213
5223
  continue
5214
5224
 
5225
+ # The run's status is what it ACHIEVED, not the fact that the loop reached
5226
+ # its end. Every per-task failure above is handled (logged, ticket moved to
5227
+ # Backlog, `continue`) so the loop always arrives here — and reporting that
5228
+ # as "completed" told an operator whose only ticket had just died on a
5229
+ # kickoff timeout that the run had succeeded, with `processed: 0` the sole
5230
+ # contradicting evidence. `processed` counts the tasks that were actually
5231
+ # carried through, so it is the honest basis for the verdict.
5232
+ status = ("completed" if processed == total
5233
+ else "failed" if processed == 0
5234
+ else "partial")
5215
5235
  await send(Envelope(
5216
5236
  type=Envelope.TYPE_DONE, thread_id=thread_id,
5217
- data={"mode": "orchestrate-auto-local", "status": "completed",
5218
- "processed": processed, "task_count": total},
5237
+ data={"mode": "orchestrate-auto-local", "status": status,
5238
+ "processed": processed, "task_count": total,
5239
+ "failed": total - processed},
5219
5240
  ).model_dump())
5220
5241
 
5221
5242
  except Exception as exc:
@@ -9,6 +9,37 @@ from __future__ import annotations
9
9
  import os
10
10
  import sys
11
11
 
12
+ #: Non-cp1252 glyphs the SDK prints → an ASCII stand-in. See :func:`safe_print`.
13
+ _ASCII_FALLBACK = str.maketrans({
14
+ "✓": "OK", "✗": "x", "→": "->", "↻": "~", "⚠": "!", "…": "...",
15
+ "▶": ">", "•": "-", "⏸": "||", "·": "-", "◐": "~",
16
+ })
17
+
18
+
19
+ def safe_print(message: str = "") -> None:
20
+ """``print`` that cannot take a command down on a legacy Windows codepage.
21
+
22
+ :func:`ensure_utf8_stdio` is the real fix and every entry point installs it.
23
+ This is the backstop for the case it cannot reach: a stdout with no
24
+ ``reconfigure`` (pythonw, or a stream already swapped for a plain file
25
+ object), where the first ``✓``/``▶`` still raises ``UnicodeEncodeError``.
26
+
27
+ Worth the belt and braces because the failure mode is not a garbled line but
28
+ a dead run — and it lands in the two places that hurt most. In the updater,
29
+ the banner prints BEFORE the upgrade that would fix it, so the crash recurs
30
+ on every run and the release that repairs it can never install itself. In
31
+ ``run_auto._send``, the glyph raises inside the ERROR handler, so the real
32
+ error is replaced by a UnicodeEncodeError traceback and is never seen at all.
33
+ """
34
+ try:
35
+ print(message)
36
+ except UnicodeEncodeError:
37
+ try:
38
+ print(message.translate(_ASCII_FALLBACK))
39
+ except UnicodeEncodeError:
40
+ # A glyph outside the table (engine output echoed into the line).
41
+ print(message.encode("ascii", "replace").decode("ascii"))
42
+
12
43
 
13
44
  def ensure_utf8_stdio() -> None:
14
45
  """Make stdout/stderr able to carry the SDK's ``✓ ↻ → ⚠`` glyphs ANYWHERE.
@@ -67,6 +67,10 @@ import os
67
67
  import sys
68
68
  from pathlib import Path
69
69
 
70
+ # Import-cheap and dependency-free. safe_print degrades ✓/▶/✗ to ASCII when the
71
+ # stream cannot hold them — the backstop behind ensure_utf8_stdio; see _send.
72
+ from sdk.platform_compat import safe_print as _print
73
+
70
74
  _AGENTOS_DIR = Path.home() / ".agentos"
71
75
  _STOP_FILE = _AGENTOS_DIR / "auto.stop"
72
76
  _PID_FILE = _AGENTOS_DIR / "auto.pid"
@@ -540,8 +544,8 @@ def _run_report(argv: list[str]) -> None:
540
544
  mirrored into ``~/.agentos/auto.log`` so it is visible in
541
545
  ``agentos-auto logs -f``, which is where the summary line lands anyway.
542
546
  """
543
- from sdk.exceptions import TokenNotConfigured
544
547
  from sdk import self_report
548
+ from sdk.exceptions import TokenNotConfigured
545
549
 
546
550
  texts = _flag_values(argv, "--text")
547
551
  files = _flag_values(argv, "--file")
@@ -911,6 +915,14 @@ _current_task: str | None = None
911
915
 
912
916
 
913
917
  async def _send(env: dict) -> None:
918
+ """Render one pipeline envelope to the console.
919
+
920
+ Prints through :func:`~sdk.platform_compat.safe_print`, not ``print``. Every
921
+ line here carries a glyph cp1252 cannot hold, and this function is where a
922
+ Windows encoding crash did the most damage: the ``✗`` below runs INSIDE the
923
+ handler's error path, so a stdout the shim could not reconfigure turned every
924
+ real failure into a UnicodeEncodeError traceback with the actual error lost.
925
+ """
914
926
  etype = env.get("type", "")
915
927
  data = env.get("data", {}) or {}
916
928
 
@@ -930,22 +942,22 @@ async def _send(env: dict) -> None:
930
942
  # A task-file run has nothing to do with the ticket queue; calling it
931
943
  # "orchestrate-auto" in the log sends the reader looking for a board.
932
944
  repos = ", ".join(Path(r).name for r in (data.get("repos") or []))
933
- print(f"\n▶ plan (no ticket) — {data.get('title') or 'task file'}"
945
+ _print(f"\n▶ plan (no ticket) — {data.get('title') or 'task file'}"
934
946
  + (f" · {repos}" if repos else "") + "\n")
935
947
  else:
936
- print(f"\n▶ orchestrate-auto (local) — {data.get('task_count', 0)} task(s)\n")
948
+ _print(f"\n▶ orchestrate-auto (local) — {data.get('task_count', 0)} task(s)\n")
937
949
  elif etype == "step_update":
938
950
  step = data.get("current_step", "")
939
951
  pp = data.get("project_path")
940
- print(f" • {step}" + (f" [{pp}]" if pp else "") + _agent_suffix(data))
952
+ _print(f" • {step}" + (f" [{pp}]" if pp else "") + _agent_suffix(data))
941
953
  elif etype == "awaiting_approval":
942
- print(f" ⏸ approval gate: {data.get('summary', data.get('jira_key', ''))}")
954
+ _print(f" ⏸ approval gate: {data.get('summary', data.get('jira_key', ''))}")
943
955
  elif etype == "error":
944
- print(f" ✗ {data.get('message') or data.get('code') or data}")
956
+ _print(f" ✗ {data.get('message') or data.get('code') or data}")
945
957
  elif etype == "done" and data.get("mode") == "plan-local":
946
958
  status = data.get("status", "")
947
959
  mark = {"completed": "✓", "partial": "◐"}.get(status, "✗")
948
- print(f"\n{mark} plan {status} — {data.get('files_changed', 0)} file(s) changed")
960
+ _print(f"\n{mark} plan {status} — {data.get('files_changed', 0)} file(s) changed")
949
961
  for repo in data.get("repos") or []:
950
962
  line = f" {'✓' if repo.get('succeeded') else '✗'} {Path(repo.get('repo') or '').name}"
951
963
  if repo.get("branch"):
@@ -954,19 +966,27 @@ async def _send(env: dict) -> None:
954
966
  line += f" · {repo['pr']}"
955
967
  if repo.get("note"):
956
968
  line += f" · {repo['note']}"
957
- print(line)
969
+ _print(line)
958
970
  for issue in repo.get("issues") or []:
959
- print(f" ! {issue}")
960
- print()
971
+ _print(f" ! {issue}")
972
+ _print()
961
973
  elif etype == "done":
962
974
  status = data.get("status", "")
975
+ done_n, total_n = data.get("processed", 0), data.get("task_count", 0)
963
976
  if status == "paused":
964
- print(f"\n⏸ paused after {data.get('processed', 0)}/{data.get('task_count', 0)} "
977
+ _print(f"\n⏸ paused after {done_n}/{total_n} "
965
978
  f"task(s). run `agentos-auto resume` to continue.\n")
979
+ elif status in ("completed", "partial", "failed"):
980
+ # A ✓ on a run that carried nothing through is the same lie the
981
+ # handler used to tell (see its status line): mark what happened.
982
+ mark = {"completed": "✓", "partial": "◐"}.get(status, "✗")
983
+ count = f"{done_n}/{total_n}" if total_n else str(done_n)
984
+ _print(f"\n{mark} {status} — {count} task(s) carried through"
985
+ + (" (see the failures above)" if done_n < total_n else "") + "\n")
966
986
  else:
967
- print(f"\n✓ done — {json.dumps(data)[:400]}\n")
987
+ _print(f"\n✓ done — {json.dumps(data)[:400]}\n")
968
988
  else:
969
- print(f" [{etype}] {json.dumps(data)[:200]}")
989
+ _print(f" [{etype}] {json.dumps(data)[:200]}")
970
990
 
971
991
 
972
992
  def _run_loop(workspace_root: str | None, only_keys: list[str] | None = None,
@@ -18,6 +18,13 @@ import os
18
18
  import platform
19
19
  import subprocess
20
20
  import sys
21
+ from typing import NamedTuple
22
+
23
+ # Import-cheap and dependency-free (it runs before anything else at startup).
24
+ # _ASCII_FALLBACK is re-exported so a glyph added to an updater message is still
25
+ # checked against a stand-in — tests/test_console_encoding.py.
26
+ from sdk.platform_compat import _ASCII_FALLBACK # noqa: F401
27
+ from sdk.platform_compat import safe_print as _say
21
28
 
22
29
  _DIST = "devops-bot-sdk"
23
30
  _EXTRAS = "[ui]" # control-panel deps (fastapi/uvicorn/starlette) — keep on every self-update
@@ -26,27 +33,6 @@ _GITHUB_SSH = f"git+ssh://git@github.com/{_GITHUB_REPO}.git"
26
33
  _PYPI_JSON = f"https://pypi.org/pypi/{_DIST}/json"
27
34
 
28
35
 
29
- #: Non-cp1252 glyphs this module prints → an ASCII stand-in. See :func:`_say`.
30
- _ASCII_FALLBACK = str.maketrans({"✓": "OK", "→": "->", "↻": "~", "⚠": "!", "…": "..."})
31
-
32
-
33
- def _say(message: str) -> None:
34
- """``print`` that can never take the updater down on a legacy Windows codepage.
35
-
36
- The real fix is ``platform_compat.ensure_utf8_stdio``, which every entry point
37
- installs. This is the backstop for the one module that must not be allowed to
38
- die: on the PyPI path the ``updating … → …`` banner prints BEFORE
39
- ``self_update()`` runs, so a stdout that cannot encode ``→`` would deadlock the
40
- updater against its own repair — the crash would recur on every single run,
41
- and the release that fixes it could never install itself. Degrading to ASCII
42
- keeps that from ever being possible again.
43
- """
44
- try:
45
- print(message)
46
- except UnicodeEncodeError:
47
- print(message.translate(_ASCII_FALLBACK))
48
-
49
-
50
36
  def _with_extras(target: str) -> str:
51
37
  """Attach the ``[ui]`` extra so a self-update never drops the control-panel
52
38
  deps — the reason ``agentos-ui`` broke (``No module named 'starlette'``) after
@@ -243,18 +229,79 @@ def _install_candidates() -> list[str]:
243
229
  return _unique([_with_extras(c) for c in cands])
244
230
 
245
231
 
246
- def _pip_install(url: str, *, force: bool = False) -> bool:
247
- """pip install --upgrade <url>. force=True adds --force-reinstall (repair)."""
248
- extra = ["--force-reinstall", "--no-deps"] if force else []
232
+ def _redact(text: str) -> str:
233
+ """Strip credentials out of pip output before it is printed or logged.
234
+
235
+ One install candidate is ``git+https://<token>@github.com/...`` built from the
236
+ user's GitHub OAuth token, and pip echoes the URL it failed on. Reporting pip
237
+ failures (which this module now does) must not write that token into
238
+ ``~/.agentos/auto.log`` or the panel's captured output.
239
+ """
240
+ import re
241
+ return re.sub(r"(https?://)[^/\s:@]+(?::[^/\s@]*)?@", r"\1***@", text)
242
+
243
+
244
+ def _pip_install(
245
+ url: str, *, force: bool = False, no_deps: bool | None = None,
246
+ no_cache: bool = False,
247
+ ) -> tuple[bool, str]:
248
+ """``pip install --upgrade <url>``. Returns (exited-0, redacted output).
249
+
250
+ The output is returned rather than discarded because "pip exited non-zero" is
251
+ the single most useful thing to know about a box that will not update, and it
252
+ used to be swallowed whole — leaving a silent no-op that read as success.
253
+
254
+ ``force`` adds ``--force-reinstall`` (defaulting ``no_deps`` on, for the
255
+ corrupt-install repair path, which wants to replace our own files and nothing
256
+ else). ``no_cache`` bypasses pip's wheel cache — a cached wheel built from an
257
+ earlier clone of the same git ref is one of the ways an upgrade silently
258
+ installs the version it already had.
259
+ """
260
+ if no_deps is None:
261
+ no_deps = force
262
+ extra: list[str] = []
263
+ if force:
264
+ extra.append("--force-reinstall")
265
+ if no_deps:
266
+ extra.append("--no-deps")
267
+ if no_cache:
268
+ extra.append("--no-cache-dir")
249
269
  try:
250
- subprocess.run(
270
+ proc = subprocess.run(
251
271
  [sys.executable, "-m", "pip", "install", "--upgrade", "--quiet",
252
272
  *extra, *_pip_flags(), url],
253
- check=True, capture_output=True,
273
+ capture_output=True, text=True, errors="replace", timeout=900,
274
+ check=False, # the exit code is the answer, not an exception
254
275
  )
255
- return True
256
- except Exception: # noqa: BLE001
257
- return False
276
+ out = _redact(((proc.stderr or "") + (proc.stdout or "")).strip())
277
+ return (proc.returncode == 0, out)
278
+ except Exception as exc: # noqa: BLE001 — pip missing, timeout, unreadable env
279
+ return (False, _redact(str(exc)))
280
+
281
+
282
+ def installed_version() -> str:
283
+ """The version pip has ON DISK, read in a FRESH interpreter.
284
+
285
+ :func:`current_version` answers from THIS process, and after a pip upgrade
286
+ that answer cannot be trusted: ``importlib.metadata`` caches its path scan,
287
+ and its ``sdk.__version__`` fallback is a module that was imported before the
288
+ upgrade ran. Comparing a post-upgrade reading against a pre-upgrade one is
289
+ exactly the check that decides whether we say "updated" or "already up to
290
+ date", so it has to come from a process that started after pip finished.
291
+ """
292
+ try:
293
+ r = subprocess.run(
294
+ [sys.executable, "-c",
295
+ f"from importlib.metadata import version; print(version({_DIST!r}))"],
296
+ capture_output=True, text=True, errors="replace", timeout=60,
297
+ check=False,
298
+ )
299
+ ver = (r.stdout or "").strip().splitlines()
300
+ if r.returncode == 0 and ver and ver[-1].strip():
301
+ return ver[-1].strip()
302
+ except Exception: # noqa: BLE001,S110 — an unreadable child is not fatal here
303
+ pass
304
+ return current_version()
258
305
 
259
306
 
260
307
  def _verify_install() -> bool:
@@ -277,21 +324,106 @@ def _verify_install() -> bool:
277
324
  def _repair_install() -> bool:
278
325
  """Force-reinstall to recover from a corrupt upgrade; verifies the result."""
279
326
  for url in _install_candidates():
280
- if _pip_install(url, force=True) and _verify_install():
327
+ ok, _out = _pip_install(url, force=True)
328
+ if ok and _verify_install():
281
329
  return True
282
330
  return False
283
331
 
284
332
 
333
+ def _source_label(url: str) -> str:
334
+ """A short, token-free name for an install candidate, for failure messages."""
335
+ if "ssh://" in url:
336
+ return "git+ssh"
337
+ if "https://" in url:
338
+ return "git+https"
339
+ return "pypi"
340
+
341
+
342
+ def _tail(text: str, lines: int = 2, width: int = 300) -> str:
343
+ """The last few lines of pip output — enough to name the cause, short enough
344
+ to sit in a log line."""
345
+ kept = [ln.strip() for ln in (text or "").splitlines() if ln.strip()][-lines:]
346
+ joined = " / ".join(kept)
347
+ return joined[:width] + ("…" if len(joined) > width else "")
348
+
349
+
350
+ class UpgradeResult(NamedTuple):
351
+ """What an upgrade attempt actually did.
352
+
353
+ ``changed`` is the only claim worth making to a user: it is measured from the
354
+ version on disk before and after, not inferred from pip's exit code.
355
+ """
356
+ changed: bool # the installed version really moved
357
+ before: str # version on disk before the attempt
358
+ after: str # version on disk after it
359
+ ran: bool # at least one candidate's pip exited 0
360
+ detail: str # why nothing moved / why every candidate failed
361
+
362
+
363
+ def upgrade(target: str | None = None) -> UpgradeResult:
364
+ """Upgrade the installed SDK via pip, and VERIFY that it happened.
365
+
366
+ Tries the candidates in order (SSH → detected URL → token-HTTPS → PyPI) and,
367
+ after each pip that exits 0, re-reads the version from disk in a fresh
368
+ interpreter. A candidate that exits 0 without moving the version is a no-op,
369
+ not a success, so the walk continues to the next one instead of stopping
370
+ there.
371
+
372
+ That distinction is the whole point of this function. Treating pip's exit code
373
+ as the answer is what let a box sit for weeks on an ancient SDK while printing
374
+ "✓ already up to date" on every run: a git candidate resolved to a wheel it had
375
+ already installed, exited 0, short-circuited the candidate list before the PyPI
376
+ fallback was ever tried, and the unchanged version read as "current".
377
+
378
+ ``target`` is the version we know is published (from PyPI). When it is given
379
+ and the ordinary pass does not reach it, a second forced pass runs with
380
+ ``--force-reinstall --no-cache-dir`` — the escape hatch for a stale wheel
381
+ cache or a same-version direct reference pip decides to skip. Without it a
382
+ wedged box can never recover on its own, which is the state this repair is for.
383
+ """
384
+ before = installed_version()
385
+ candidates = _install_candidates() # resolved once: it costs a token fetch
386
+ ran = False
387
+ notes: list[str] = []
388
+
389
+ def _reached(after: str) -> bool:
390
+ return bool(target) and _vtuple(after) >= _vtuple(target)
391
+
392
+ for forced in (False, True):
393
+ if forced and (not target or _reached(installed_version())):
394
+ break # nothing published beyond us, or we already got there
395
+ for url in candidates:
396
+ ok, out = _pip_install(url, force=forced, no_deps=False, no_cache=forced)
397
+ label = _source_label(url) + (" (forced)" if forced else "")
398
+ if not ok:
399
+ notes.append(f"{label}: {_tail(out)}")
400
+ continue
401
+ ran = True
402
+ after = installed_version()
403
+ if _vtuple(after) != _vtuple(before) or _reached(after):
404
+ return UpgradeResult(after != before, before, after, True, "")
405
+ if not target:
406
+ # pip exited 0 and the version did not move. With no published
407
+ # version to check against, "no-op" and "already current" are the
408
+ # same observation — so stop here rather than reinstalling from
409
+ # three more candidates on every routine run.
410
+ return UpgradeResult(False, before, after, True, "")
411
+ notes.append(f"{label}: pip exited 0 but the installed version "
412
+ f"stayed {after}")
413
+
414
+ after = installed_version()
415
+ return UpgradeResult(after != before, before, after, ran, "; ".join(notes))
416
+
417
+
285
418
  def self_update() -> bool:
286
- """Upgrade the installed SDK via pip. Returns True on success.
419
+ """Upgrade the installed SDK via pip. True only if the version really moved.
287
420
 
288
- Tries candidates in order (detected URL → SSH → token-HTTPS → PyPI),
289
- stopping at the first success.
421
+ Thin wrapper over :func:`upgrade` — note that a box already on the newest
422
+ version returns False here (nothing changed), which is why callers that need
423
+ to tell "already current" from "the upgrade did not take" use ``upgrade()``
424
+ and read :attr:`UpgradeResult.after` instead of this boolean.
290
425
  """
291
- for url in _install_candidates():
292
- if _pip_install(url):
293
- return True
294
- return False
426
+ return upgrade().changed
295
427
 
296
428
 
297
429
  def _ensure_service(quiet: bool = False, *, restart: bool = False) -> None:
@@ -367,9 +499,15 @@ def _ensure_firewall(quiet: bool = False) -> None:
367
499
  def auto_update(quiet: bool = False) -> tuple[bool, str, str | None]:
368
500
  """Check for a newer version and upgrade if one exists.
369
501
 
370
- GitHub installs: upgrades and compares version before/after (pip's git
371
- backend does a fast fetch and only reinstalls if HEAD changed).
372
- PyPI installs: checks PyPI version metadata first to avoid unnecessary work.
502
+ PyPI's published version is fetched on BOTH install paths, and not only to
503
+ skip needless work: it is the one independent fact available about whether
504
+ this box is current. A git install can legitimately be ahead of PyPI, so a
505
+ published version that is merely equal or older proves nothing — but a
506
+ published version STRICTLY NEWER than what is on disk after an upgrade
507
+ attempt proves the upgrade did not take, no matter what pip's exit code said.
508
+ That case is now reported (with pip's own reason) instead of being announced
509
+ as "✓ already up to date", and :func:`upgrade` has already tried a forced,
510
+ cache-bypassing reinstall to fix it.
373
511
 
374
512
  Returns (updated, version_before, version_after_or_none).
375
513
  No-op when AGENTOS_NO_UPDATE=1 or already up to date.
@@ -378,40 +516,62 @@ def auto_update(quiet: bool = False) -> tuple[bool, str, str | None]:
378
516
  if os.getenv("AGENTOS_NO_UPDATE"):
379
517
  return (False, cur, None)
380
518
 
519
+ latest = latest_pypi_version()
381
520
  is_github = _detect_install_url().startswith("git+")
521
+ behind_pypi = bool(latest) and _vtuple(latest) > _vtuple(cur)
382
522
 
383
- if not is_github:
384
- latest = latest_pypi_version()
385
- if not latest or _vtuple(latest) <= _vtuple(cur):
386
- _ensure_service(quiet, restart=False) # already current — just self-heal the service
387
- _ensure_firewall(quiet)
388
- return (False, cur, latest)
389
- if not quiet:
390
- _say(f"updating {_DIST} {cur} → {latest} …")
391
- ok = self_update()
392
- if not quiet and ok:
393
- _say("✓ updated.")
394
- # Ensure the service exists + runs the new code (restart only on a real
395
- # upgrade); also self-heals a missing/down service when already current.
396
- _ensure_service(quiet, restart=ok)
523
+ # Nothing to do on a PyPI install that is already at (or past) the published
524
+ # version — a git install still checks its branch, which PyPI cannot answer for.
525
+ if not is_github and not behind_pypi:
526
+ _ensure_service(quiet, restart=False) # already current — just self-heal the service
397
527
  _ensure_firewall(quiet)
398
- return (ok, cur, latest if ok else None)
528
+ return (False, cur, latest)
399
529
 
400
- # GitHub path
401
530
  if not quiet:
402
- _say("checking for SDK updates …")
403
- ok = self_update()
404
- after = current_version()
405
- updated = ok and after != cur
531
+ _say(f"updating {_DIST} {cur} → {latest} …" if behind_pypi
532
+ else "checking for SDK updates …")
533
+
534
+ result = upgrade(target=latest if behind_pypi else None)
406
535
  if not quiet:
407
- if updated:
408
- _say(f"✓ updated {cur} → {after}.")
409
- elif ok:
410
- _say(f"✓ already up to date (v{cur}).")
411
- # silent on failure — continue on the existing version without noise
412
- _ensure_service(quiet, restart=updated)
536
+ _report(result, latest)
537
+
538
+ # Ensure the service exists + runs the new code (restart only on a real
539
+ # upgrade); also self-heals a missing/down service when already current.
540
+ _ensure_service(quiet, restart=result.changed)
413
541
  _ensure_firewall(quiet)
414
- return (updated, cur, after if updated else None)
542
+ return (result.changed, cur, result.after if result.changed else None)
543
+
544
+
545
+ def _report(result: UpgradeResult, latest: str | None) -> None:
546
+ """Say what the upgrade attempt actually did.
547
+
548
+ The silent case is deliberate and narrow: on a git install with nothing newer
549
+ published, an upgrade that changed nothing is the ordinary state and we still
550
+ have no way to see the branch, so "already up to date (v…)" is the best claim
551
+ available. Everything else is either a real upgrade or a real problem, and a
552
+ problem the operator can act on beats a green tick they cannot trust.
553
+ """
554
+ if result.changed:
555
+ _say(f"✓ updated {result.before} → {result.after}.")
556
+ return
557
+
558
+ if latest and _vtuple(latest) > _vtuple(result.after):
559
+ _say(f"⚠ still on v{result.after} — v{latest} is published but the "
560
+ f"upgrade did not take.")
561
+ if result.detail:
562
+ _say(f" pip said: {result.detail}")
563
+ _say(" fix it by hand with:\n"
564
+ f' "{sys.executable}" -m pip install --upgrade --force-reinstall '
565
+ f'--no-cache-dir "{_DIST}{_EXTRAS}"')
566
+ return
567
+
568
+ if result.ran:
569
+ _say(f"✓ already up to date (v{result.after}).")
570
+ elif result.detail:
571
+ # Every candidate failed: no SSH key, no token, no network. Previously
572
+ # silent, which is indistinguishable from a successful check.
573
+ _say(f"⚠ could not check for updates (staying on v{result.after}).")
574
+ _say(f" pip said: {result.detail}")
415
575
 
416
576
 
417
577
  def auto_update_and_reexec(module: str, argv: list[str]) -> None:
@@ -13,6 +13,7 @@ Two gaps this covers:
13
13
  """
14
14
  from __future__ import annotations
15
15
 
16
+ import pytest
16
17
  from unittest.mock import patch
17
18
 
18
19
  from sdk import firewall, updater
@@ -159,12 +160,45 @@ class TestEnsureOpen:
159
160
 
160
161
  # ── The post-update hook ──────────────────────────────────────────────
161
162
  class TestEnsureFirewallHook:
162
- def test_it_runs_on_every_auto_update_path(self):
163
- """Three return paths in `auto_update`; all of them must check."""
164
- import inspect
165
-
166
- src = inspect.getsource(updater.auto_update)
167
- assert src.count("_ensure_firewall(quiet)") == 3
163
+ @pytest.mark.parametrize(
164
+ "install_url, pypi_latest, result",
165
+ [
166
+ # PyPI install, nothing newer published — the early return.
167
+ ("devops-bot-sdk", "1.0.0", None),
168
+ # An upgrade that took.
169
+ ("git+ssh://git@github.com/x/y.git", "1.1.0",
170
+ updater.UpgradeResult(True, "1.0.0", "1.1.0", True, "")),
171
+ # An upgrade that did NOT take — the case that used to report success.
172
+ ("git+ssh://git@github.com/x/y.git", "1.1.0",
173
+ updater.UpgradeResult(False, "1.0.0", "1.0.0", True, "pip no-op")),
174
+ ],
175
+ )
176
+ def test_it_runs_on_every_auto_update_path(self, install_url, pypi_latest, result):
177
+ """Every way out of `auto_update` must have checked the firewall first.
178
+
179
+ Driven through the function rather than counted in its source: the number
180
+ of return paths is an implementation detail (merging the PyPI and GitHub
181
+ branches changed it), while "no outcome skips the check" is the property
182
+ that keeps a host with a shut port from being reported healthy forever.
183
+ """
184
+ calls: list[bool] = []
185
+ with patch.object(updater, "current_version", return_value="1.0.0"), \
186
+ patch.object(updater, "latest_pypi_version", return_value=pypi_latest), \
187
+ patch.object(updater, "_detect_install_url", return_value=install_url), \
188
+ patch.object(updater, "upgrade", return_value=result), \
189
+ patch.object(updater, "_ensure_service", lambda *a, **k: None), \
190
+ patch.object(updater, "_ensure_firewall", lambda *a, **k: calls.append(True)):
191
+ updater.auto_update(quiet=True)
192
+ assert calls, "auto_update returned without checking the firewall"
193
+
194
+ def test_the_opt_out_return_checks_nothing(self):
195
+ """AGENTOS_NO_UPDATE is the one exemption: it returns before the updater
196
+ does anything at all."""
197
+ calls: list[bool] = []
198
+ with patch.dict("os.environ", {"AGENTOS_NO_UPDATE": "1"}), \
199
+ patch.object(updater, "_ensure_firewall", lambda *a, **k: calls.append(True)):
200
+ updater.auto_update(quiet=True)
201
+ assert not calls
168
202
 
169
203
  def test_opt_out_env_var_skips_it(self):
170
204
  with patch.dict("os.environ", {"AGENTOS_NO_FIREWALL": "1"}), \