secondshift 0.1.0b12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (371) hide show
  1. secondshift/__init__.py +1 -0
  2. secondshift/__main__.py +3 -0
  3. secondshift/cli.py +17 -0
  4. secondshift/config/__init__.py +0 -0
  5. secondshift/config/api.py +23 -0
  6. secondshift/config/app.py +62 -0
  7. secondshift/config/constants.py +279 -0
  8. secondshift/config/container_env.py +118 -0
  9. secondshift/config/first_run.py +40 -0
  10. secondshift/config/formatting.py +5 -0
  11. secondshift/config/gunicorn.py +31 -0
  12. secondshift/config/logging.py +40 -0
  13. secondshift/config/settings/__init__.py +0 -0
  14. secondshift/config/settings/base.py +524 -0
  15. secondshift/config/settings/dev.py +14 -0
  16. secondshift/config/settings/local.py +23 -0
  17. secondshift/config/settings/production.py +113 -0
  18. secondshift/config/settings/runner.py +140 -0
  19. secondshift/config/settings/test.py +47 -0
  20. secondshift/config/static_storage.py +45 -0
  21. secondshift/config/urls.py +42 -0
  22. secondshift/connections/__init__.py +0 -0
  23. secondshift/connections/access.py +27 -0
  24. secondshift/connections/admin.py +25 -0
  25. secondshift/connections/agents.py +224 -0
  26. secondshift/connections/api.py +747 -0
  27. secondshift/connections/apps.py +6 -0
  28. secondshift/connections/catalog.py +96 -0
  29. secondshift/connections/checks.py +22 -0
  30. secondshift/connections/dao.py +305 -0
  31. secondshift/connections/errors.py +13 -0
  32. secondshift/connections/execution.py +262 -0
  33. secondshift/connections/external.py +258 -0
  34. secondshift/connections/github.py +123 -0
  35. secondshift/connections/http_api/__init__.py +1 -0
  36. secondshift/connections/http_api/client.py +161 -0
  37. secondshift/connections/http_api/connections.py +187 -0
  38. secondshift/connections/http_api/spec.py +382 -0
  39. secondshift/connections/mcp/__init__.py +1 -0
  40. secondshift/connections/mcp/base.py +139 -0
  41. secondshift/connections/mcp/client.py +353 -0
  42. secondshift/connections/mcp/custom.py +86 -0
  43. secondshift/connections/mcp/github.py +256 -0
  44. secondshift/connections/mcp/oauth.py +352 -0
  45. secondshift/connections/mcp/providers.py +15 -0
  46. secondshift/connections/mcp/schemas.py +77 -0
  47. secondshift/connections/mcp/sentry.py +135 -0
  48. secondshift/connections/mcp/signin.py +206 -0
  49. secondshift/connections/migration_helpers.py +28 -0
  50. secondshift/connections/migrations/0001_initial.py +101 -0
  51. secondshift/connections/migrations/0002_alter_connection_kind.py +18 -0
  52. secondshift/connections/migrations/0003_require_approval.py +23 -0
  53. secondshift/connections/migrations/0004_new_connections_skip_approval.py +18 -0
  54. secondshift/connections/migrations/0005_agentconnection.py +36 -0
  55. secondshift/connections/migrations/0006_remove_is_default.py +23 -0
  56. secondshift/connections/migrations/0007_agent_last_check.py +18 -0
  57. secondshift/connections/migrations/0008_agentcredentialcleanup.py +25 -0
  58. secondshift/connections/migrations/0009_release_0_1_0b11.py +52 -0
  59. secondshift/connections/migrations/0010_connection_attribution.py +18 -0
  60. secondshift/connections/migrations/__init__.py +0 -0
  61. secondshift/connections/model_providers.py +441 -0
  62. secondshift/connections/models.py +246 -0
  63. secondshift/connections/outbound.py +317 -0
  64. secondshift/connections/repository_ref.py +19 -0
  65. secondshift/connections/schemas.py +313 -0
  66. secondshift/connections/serialization.py +100 -0
  67. secondshift/connections/services.py +94 -0
  68. secondshift/connections/tools.py +38 -0
  69. secondshift/connections/webhooks.py +107 -0
  70. secondshift/core/__init__.py +0 -0
  71. secondshift/core/adapters.py +38 -0
  72. secondshift/core/admin.py +5 -0
  73. secondshift/core/api.py +104 -0
  74. secondshift/core/apps.py +20 -0
  75. secondshift/core/launcher.py +311 -0
  76. secondshift/core/local_agents.py +90 -0
  77. secondshift/core/local_output.py +120 -0
  78. secondshift/core/management/__init__.py +0 -0
  79. secondshift/core/management/commands/__init__.py +0 -0
  80. secondshift/core/management/commands/bootstrap_dev.py +33 -0
  81. secondshift/core/management/commands/dev_reset_db.py +204 -0
  82. secondshift/core/management/commands/login.py +23 -0
  83. secondshift/core/management/commands/logs.py +58 -0
  84. secondshift/core/management/commands/status.py +46 -0
  85. secondshift/core/management/commands/up.py +244 -0
  86. secondshift/core/middleware.py +21 -0
  87. secondshift/core/migrations/__init__.py +0 -0
  88. secondshift/core/schemas.py +71 -0
  89. secondshift/core/signin.py +58 -0
  90. secondshift/core/static/core/account.css +149 -0
  91. secondshift/core/static/core/admin.css +135 -0
  92. secondshift/core/templatetags/__init__.py +0 -0
  93. secondshift/core/templatetags/accounts.py +20 -0
  94. secondshift/core/terminal.py +27 -0
  95. secondshift/core/views.py +10 -0
  96. secondshift/gateway/__init__.py +0 -0
  97. secondshift/gateway/apps.py +6 -0
  98. secondshift/gateway/dao.py +137 -0
  99. secondshift/gateway/metering.py +171 -0
  100. secondshift/gateway/migrations/0001_initial.py +54 -0
  101. secondshift/gateway/migrations/0002_modelusage_gateway_usage_created_at_idx.py +19 -0
  102. secondshift/gateway/migrations/__init__.py +0 -0
  103. secondshift/gateway/models.py +61 -0
  104. secondshift/gateway/pricing.py +39 -0
  105. secondshift/gateway/proxy.py +369 -0
  106. secondshift/gateway/tokens.py +68 -0
  107. secondshift/gateway/urls.py +17 -0
  108. secondshift/gateway/views.py +11 -0
  109. secondshift/github/__init__.py +6 -0
  110. secondshift/github/app.py +109 -0
  111. secondshift/github/authors.py +56 -0
  112. secondshift/github/credentials.py +59 -0
  113. secondshift/github/errors.py +2 -0
  114. secondshift/github/git.py +56 -0
  115. secondshift/github/identity.py +35 -0
  116. secondshift/github/installations.py +74 -0
  117. secondshift/github/issues.py +75 -0
  118. secondshift/github/permissions.py +49 -0
  119. secondshift/github/pulls.py +49 -0
  120. secondshift/github/repos.py +52 -0
  121. secondshift/github/rest.py +93 -0
  122. secondshift/github/ruff.toml +16 -0
  123. secondshift/github/tokens.py +52 -0
  124. secondshift/jobs/__init__.py +0 -0
  125. secondshift/jobs/access.py +12 -0
  126. secondshift/jobs/admin.py +83 -0
  127. secondshift/jobs/api.py +184 -0
  128. secondshift/jobs/apps.py +6 -0
  129. secondshift/jobs/automatic_grants.py +115 -0
  130. secondshift/jobs/autosync.py +78 -0
  131. secondshift/jobs/capabilities.py +43 -0
  132. secondshift/jobs/dao.py +258 -0
  133. secondshift/jobs/drafts.py +112 -0
  134. secondshift/jobs/grants.py +116 -0
  135. secondshift/jobs/limits.py +26 -0
  136. secondshift/jobs/management/__init__.py +0 -0
  137. secondshift/jobs/management/commands/__init__.py +0 -0
  138. secondshift/jobs/manifest.py +364 -0
  139. secondshift/jobs/migrations/0001_initial.py +87 -0
  140. secondshift/jobs/migrations/0002_job_history.py +18 -0
  141. secondshift/jobs/migrations/0003_job_skill_text.py +23 -0
  142. secondshift/jobs/migrations/0004_jobgrant_tool_rules.py +18 -0
  143. secondshift/jobs/migrations/0005_job_triggers.py +18 -0
  144. secondshift/jobs/migrations/0006_job_webhook.py +18 -0
  145. secondshift/jobs/migrations/0007_job_deliveries.py +23 -0
  146. secondshift/jobs/migrations/0008_job_removed_at.py +18 -0
  147. secondshift/jobs/migrations/0009_job_subagents.py +18 -0
  148. secondshift/jobs/migrations/0010_job_skill_source.py +18 -0
  149. secondshift/jobs/migrations/__init__.py +0 -0
  150. secondshift/jobs/models.py +199 -0
  151. secondshift/jobs/needs.py +154 -0
  152. secondshift/jobs/plan.py +176 -0
  153. secondshift/jobs/planning.py +386 -0
  154. secondshift/jobs/readiness.py +46 -0
  155. secondshift/jobs/repository_sync.py +199 -0
  156. secondshift/jobs/scheduling.py +26 -0
  157. secondshift/jobs/schemas.py +207 -0
  158. secondshift/jobs/serialization.py +218 -0
  159. secondshift/jobs/skill_needs.py +189 -0
  160. secondshift/jobs/sync.py +252 -0
  161. secondshift/jobs/tool_policy.py +82 -0
  162. secondshift/jobs/tool_rules.py +200 -0
  163. secondshift/mcp_server/__init__.py +6 -0
  164. secondshift/mcp_server/api.py +79 -0
  165. secondshift/mcp_server/apps.py +7 -0
  166. secondshift/mcp_server/audit.py +22 -0
  167. secondshift/mcp_server/core.py +12 -0
  168. secondshift/mcp_server/dao.py +23 -0
  169. secondshift/mcp_server/migrations/0001_initial.py +33 -0
  170. secondshift/mcp_server/migrations/__init__.py +0 -0
  171. secondshift/mcp_server/models.py +40 -0
  172. secondshift/mcp_server/schemas.py +204 -0
  173. secondshift/mcp_server/tools/__init__.py +9 -0
  174. secondshift/mcp_server/tools/jobs.py +106 -0
  175. secondshift/mcp_server/tools/manifests.py +127 -0
  176. secondshift/mcp_server/tools/repositories.py +42 -0
  177. secondshift/mcp_server/tools/runs.py +86 -0
  178. secondshift/mcp_server/tools/tasks.py +103 -0
  179. secondshift/mcp_server/tools_registry.py +81 -0
  180. secondshift/repositories/__init__.py +0 -0
  181. secondshift/repositories/access.py +13 -0
  182. secondshift/repositories/admin.py +19 -0
  183. secondshift/repositories/api.py +296 -0
  184. secondshift/repositories/apps.py +6 -0
  185. secondshift/repositories/dao.py +114 -0
  186. secondshift/repositories/external_skills.py +224 -0
  187. secondshift/repositories/github.py +27 -0
  188. secondshift/repositories/github_access.py +57 -0
  189. secondshift/repositories/github_check.py +279 -0
  190. secondshift/repositories/github_sync.py +181 -0
  191. secondshift/repositories/github_views.py +90 -0
  192. secondshift/repositories/local.py +198 -0
  193. secondshift/repositories/management/__init__.py +0 -0
  194. secondshift/repositories/management/commands/__init__.py +0 -0
  195. secondshift/repositories/management/commands/prune_source_cache.py +15 -0
  196. secondshift/repositories/migrations/0001_initial.py +45 -0
  197. secondshift/repositories/migrations/0002_repository_last_sync_error.py +18 -0
  198. secondshift/repositories/migrations/0003_repository_checked_sha.py +18 -0
  199. secondshift/repositories/migrations/0004_pull_request_settings.py +23 -0
  200. secondshift/repositories/migrations/0005_remove_repository_installation_id.py +17 -0
  201. secondshift/repositories/migrations/0006_allow_issues.py +18 -0
  202. secondshift/repositories/migrations/__init__.py +0 -0
  203. secondshift/repositories/mirrors.py +50 -0
  204. secondshift/repositories/models.py +104 -0
  205. secondshift/repositories/pull_requests.py +199 -0
  206. secondshift/repositories/schemas.py +102 -0
  207. secondshift/repositories/serialization.py +133 -0
  208. secondshift/repositories/skill_source.py +64 -0
  209. secondshift/repositories/snapshots.py +238 -0
  210. secondshift/repositories/sources.py +256 -0
  211. secondshift/repositories/status.py +67 -0
  212. secondshift/runner/README.md +65 -0
  213. secondshift/runner/__init__.py +1 -0
  214. secondshift/runner/agent_credentials.py +89 -0
  215. secondshift/runner/agent_image/Dockerfile +16 -0
  216. secondshift/runner/agent_image/agent.py +132 -0
  217. secondshift/runner/agent_image/claude.py +254 -0
  218. secondshift/runner/agent_image/codex.py +84 -0
  219. secondshift/runner/agent_image/outcomes.py +120 -0
  220. secondshift/runner/agent_image/subagent_hook.py +71 -0
  221. secondshift/runner/agent_protocol.py +41 -0
  222. secondshift/runner/agents.py +254 -0
  223. secondshift/runner/api.py +123 -0
  224. secondshift/runner/apps.py +6 -0
  225. secondshift/runner/client.py +168 -0
  226. secondshift/runner/collection.py +276 -0
  227. secondshift/runner/dao.py +64 -0
  228. secondshift/runner/dev.py +19 -0
  229. secondshift/runner/images.py +57 -0
  230. secondshift/runner/migrations/0001_initial.py +48 -0
  231. secondshift/runner/migrations/0002_session_source_sha256.py +18 -0
  232. secondshift/runner/migrations/0003_session_collect_changes.py +18 -0
  233. secondshift/runner/migrations/0004_session_history_sha256.py +18 -0
  234. secondshift/runner/migrations/0005_require_source_sha256.py +18 -0
  235. secondshift/runner/migrations/0006_session_skill_sha256.py +18 -0
  236. secondshift/runner/migrations/__init__.py +0 -0
  237. secondshift/runner/models.py +53 -0
  238. secondshift/runner/protocol.py +97 -0
  239. secondshift/runner/schemas.py +36 -0
  240. secondshift/runner/services.py +465 -0
  241. secondshift/runner/sources.py +130 -0
  242. secondshift/runner/urls.py +7 -0
  243. secondshift/runs/__init__.py +0 -0
  244. secondshift/runs/access.py +13 -0
  245. secondshift/runs/admin.py +89 -0
  246. secondshift/runs/api.py +211 -0
  247. secondshift/runs/apps.py +11 -0
  248. secondshift/runs/artifacts.py +261 -0
  249. secondshift/runs/dao.py +640 -0
  250. secondshift/runs/deliveries.py +80 -0
  251. secondshift/runs/execution/__init__.py +0 -0
  252. secondshift/runs/execution/access_needs.py +89 -0
  253. secondshift/runs/execution/agent.py +91 -0
  254. secondshift/runs/execution/agent_broker.py +198 -0
  255. secondshift/runs/execution/budgets.py +177 -0
  256. secondshift/runs/execution/cli_agent.py +167 -0
  257. secondshift/runs/execution/consent.py +58 -0
  258. secondshift/runs/execution/deadlines.py +47 -0
  259. secondshift/runs/execution/external_tools.py +84 -0
  260. secondshift/runs/execution/issues.py +99 -0
  261. secondshift/runs/execution/model_gateway.py +90 -0
  262. secondshift/runs/execution/payload.py +63 -0
  263. secondshift/runs/execution/result.py +59 -0
  264. secondshift/runs/execution/run.py +345 -0
  265. secondshift/runs/execution/subagents.py +246 -0
  266. secondshift/runs/execution/tool_broker.py +123 -0
  267. secondshift/runs/execution/workspace_tools.py +248 -0
  268. secondshift/runs/history.py +182 -0
  269. secondshift/runs/inputs.py +49 -0
  270. secondshift/runs/issue_delivery.py +144 -0
  271. secondshift/runs/management/__init__.py +0 -0
  272. secondshift/runs/management/commands/__init__.py +0 -0
  273. secondshift/runs/management/commands/prune_runs.py +12 -0
  274. secondshift/runs/management/commands/run_dbos_worker.py +129 -0
  275. secondshift/runs/migration_helpers.py +313 -0
  276. secondshift/runs/migrations/0001_initial.py +69 -0
  277. secondshift/runs/migrations/0002_run_started_by.py +18 -0
  278. secondshift/runs/migrations/0003_agentlease.py +25 -0
  279. secondshift/runs/migrations/0004_task_assignment.py +39 -0
  280. secondshift/runs/migrations/0005_backfill_tasks.py +19 -0
  281. secondshift/runs/migrations/0006_standalone_tasks.py +50 -0
  282. secondshift/runs/migrations/0007_run_delivery.py +35 -0
  283. secondshift/runs/migrations/0008_run_outputs.py +18 -0
  284. secondshift/runs/migrations/0009_pull_request_delivery.py +28 -0
  285. secondshift/runs/migrations/0010_run_issue.py +32 -0
  286. secondshift/runs/migrations/0011_issues_delivery.py +23 -0
  287. secondshift/runs/migrations/0012_alter_rundelivery_status.py +18 -0
  288. secondshift/runs/migrations/0013_release_0_1_0b11.py +113 -0
  289. secondshift/runs/migrations/0014_run_access_need.py +30 -0
  290. secondshift/runs/migrations/__init__.py +0 -0
  291. secondshift/runs/models.py +340 -0
  292. secondshift/runs/orchestration/__init__.py +0 -0
  293. secondshift/runs/orchestration/admission.py +15 -0
  294. secondshift/runs/orchestration/dbos_runtime.py +43 -0
  295. secondshift/runs/orchestration/dispatch.py +130 -0
  296. secondshift/runs/orchestration/periodic.py +39 -0
  297. secondshift/runs/orchestration/reconciliation.py +98 -0
  298. secondshift/runs/orchestration/schedules.py +51 -0
  299. secondshift/runs/orchestration/workflows.py +480 -0
  300. secondshift/runs/outputs.py +52 -0
  301. secondshift/runs/publishing.py +162 -0
  302. secondshift/runs/queue.py +26 -0
  303. secondshift/runs/reporting/__init__.py +0 -0
  304. secondshift/runs/reporting/collection.py +26 -0
  305. secondshift/runs/reporting/connection_usage.py +45 -0
  306. secondshift/runs/reporting/failures.py +150 -0
  307. secondshift/runs/reporting/health.py +158 -0
  308. secondshift/runs/reporting/notifications.py +258 -0
  309. secondshift/runs/reporting/queries.py +60 -0
  310. secondshift/runs/retention.py +35 -0
  311. secondshift/runs/schemas.py +192 -0
  312. secondshift/runs/serialization.py +245 -0
  313. secondshift/runs/services.py +176 -0
  314. secondshift/runs/signals.py +25 -0
  315. secondshift/runs/submissions.py +236 -0
  316. secondshift/runs/transitions.py +84 -0
  317. secondshift/templates/account/password_reset_help_text.html +1 -0
  318. secondshift/templates/admin/base_site.html +24 -0
  319. secondshift/templates/allauth/elements/provider.html +9 -0
  320. secondshift/templates/allauth/layouts/base.html +67 -0
  321. secondshift/templates/socialaccount/snippets/login.html +9 -0
  322. secondshift/templates/workspaces/invite_unusable.html +10 -0
  323. secondshift/triggers/__init__.py +0 -0
  324. secondshift/triggers/admin.py +19 -0
  325. secondshift/triggers/api.py +116 -0
  326. secondshift/triggers/apps.py +6 -0
  327. secondshift/triggers/dao.py +66 -0
  328. secondshift/triggers/filters.py +48 -0
  329. secondshift/triggers/migrations/0001_initial.py +55 -0
  330. secondshift/triggers/migrations/0002_ignored_events_and_hourly_limit.py +23 -0
  331. secondshift/triggers/migrations/__init__.py +0 -0
  332. secondshift/triggers/models.py +83 -0
  333. secondshift/triggers/schemas.py +58 -0
  334. secondshift/triggers/serialization.py +91 -0
  335. secondshift/triggers/services.py +246 -0
  336. secondshift/triggers/verification.py +84 -0
  337. secondshift/web/assets/index-Bwy0hvk_.js +2419 -0
  338. secondshift/web/assets/index-D27gB9_L.css +1 -0
  339. secondshift/web/favicon.svg +19 -0
  340. secondshift/web/index.html +16 -0
  341. secondshift/web/secondshift-mark.svg +16 -0
  342. secondshift/workspaces/__init__.py +0 -0
  343. secondshift/workspaces/access.py +181 -0
  344. secondshift/workspaces/admin.py +37 -0
  345. secondshift/workspaces/api.py +264 -0
  346. secondshift/workspaces/apps.py +6 -0
  347. secondshift/workspaces/context_processors.py +11 -0
  348. secondshift/workspaces/dao.py +281 -0
  349. secondshift/workspaces/invites.py +150 -0
  350. secondshift/workspaces/limits.py +197 -0
  351. secondshift/workspaces/management/__init__.py +0 -0
  352. secondshift/workspaces/management/commands/__init__.py +0 -0
  353. secondshift/workspaces/management/commands/create_workspace.py +39 -0
  354. secondshift/workspaces/migrations/0001_initial.py +67 -0
  355. secondshift/workspaces/migrations/0002_api_token.py +34 -0
  356. secondshift/workspaces/migrations/0003_execution_default.py +29 -0
  357. secondshift/workspaces/migrations/0004_copy_default_model_key.py +26 -0
  358. secondshift/workspaces/migrations/__init__.py +0 -0
  359. secondshift/workspaces/models.py +169 -0
  360. secondshift/workspaces/owners.py +58 -0
  361. secondshift/workspaces/schemas.py +149 -0
  362. secondshift/workspaces/serialization.py +138 -0
  363. secondshift/workspaces/server_settings.py +140 -0
  364. secondshift/workspaces/services.py +94 -0
  365. secondshift/workspaces/tokens.py +108 -0
  366. secondshift/workspaces/views.py +32 -0
  367. secondshift-0.1.0b12.dist-info/METADATA +270 -0
  368. secondshift-0.1.0b12.dist-info/RECORD +371 -0
  369. secondshift-0.1.0b12.dist-info/WHEEL +4 -0
  370. secondshift-0.1.0b12.dist-info/entry_points.txt +2 -0
  371. secondshift-0.1.0b12.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,162 @@
1
+ """Publish a finished run's changes as a draft pull request.
2
+
3
+ Publishing reads only what the run stored: the pinned commit, the collected
4
+ patch and the report. A retry repeats this without the agent or a sandbox.
5
+
6
+ When the GitHub connection shows attribution, the branch, the description and
7
+ the commit's trailers name SecondShift. When it does not, nothing does: the
8
+ branch is named after the title and the description is the report alone.
9
+ """
10
+
11
+ import logging
12
+ import tempfile
13
+ from pathlib import Path
14
+
15
+ from django.utils.text import slugify
16
+
17
+ from secondshift.config.constants import MAX_CHANGES_BYTES
18
+ from secondshift.connections.github import shows_attribution
19
+ from secondshift.github.pulls import MAX_BODY_CHARS
20
+ from secondshift.repositories.models import Repository
21
+ from secondshift.repositories.pull_requests import (
22
+ DraftPullRequest,
23
+ PublishError,
24
+ publish_pull_request,
25
+ )
26
+ from secondshift.runs.artifacts import artifact_storage, read_text
27
+ from secondshift.runs.dao import (
28
+ create_delivery,
29
+ finish_delivery,
30
+ get_run,
31
+ run_delivery,
32
+ )
33
+ from secondshift.runs.models import Run, RunDelivery
34
+ from secondshift.runs.reporting.collection import plural
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ SKIP_CI = "[skip ci]"
39
+ BRANCH_PREFIX = "secondshift/run-"
40
+ # Long enough to read, short enough for a branch list.
41
+ BRANCH_SLUG_CHARS = 40
42
+
43
+
44
+ def record_pending_pull_request(run: Run) -> None:
45
+ """Shown as pending from the start, and kept if publishing fails."""
46
+ plan = run.plan.pull_request
47
+ if plan:
48
+ create_delivery(
49
+ run,
50
+ RunDelivery.Kind.PULL_REQUEST,
51
+ f"{run.repository.full_name} into {plan.base}",
52
+ )
53
+
54
+
55
+ def pull_request_delivery(run: Run) -> RunDelivery | None:
56
+ return run_delivery(run, RunDelivery.Kind.PULL_REQUEST)
57
+
58
+
59
+ def skip_reason(run: Run, repository: Repository) -> str:
60
+ """Why this run opens no pull request, or an empty string if it should."""
61
+ if repository.source_type != Repository.SourceType.GITHUB:
62
+ return "Local repositories cannot open pull requests."
63
+ if not repository.allow_pull_requests:
64
+ return "An owner has not allowed pull requests for this repository."
65
+ if run.status != Run.Status.SUCCEEDED:
66
+ return "The run did not succeed."
67
+ if run.collected is None:
68
+ return "The run's changes could not be collected."
69
+ changes = run.collected.changes
70
+ if changes.too_large:
71
+ return (
72
+ f"The changes were larger than {MAX_CHANGES_BYTES // 2**20} MiB, "
73
+ "so they were not kept."
74
+ )
75
+ if not changes.path:
76
+ return "The agent made no changes."
77
+ return ""
78
+
79
+
80
+ def draft_for(run: Run) -> DraftPullRequest:
81
+ if run.finished_at is None:
82
+ raise PublishError("The run has not finished.")
83
+ plan = run.plan.pull_request
84
+ if plan is None:
85
+ raise PublishError("The run did not ask for a pull request.")
86
+ title = run.display_name[:200]
87
+ report = ""
88
+ if run.output_path:
89
+ report, _ = read_text(run.output_path, MAX_BODY_CHARS)
90
+ attribution = shows_attribution(run.repository.github_connection)
91
+ body = report.strip() or run.summary
92
+ paragraphs = [title]
93
+ if not plan.run_ci:
94
+ # CI waits for a person, because the patch has not been reviewed yet.
95
+ paragraphs.append(SKIP_CI)
96
+ if attribution:
97
+ body = f"{body}\n\n---\n\n{footer(run)}"
98
+ paragraphs.append(trailers(run))
99
+ return DraftPullRequest(
100
+ branch=branch_name(run, attribution),
101
+ base=plan.base,
102
+ title=title,
103
+ body=body,
104
+ message="\n\n".join(paragraphs),
105
+ # The same date on every attempt gives the same commit.
106
+ committed_at=run.finished_at,
107
+ )
108
+
109
+
110
+ def footer(run: Run) -> str:
111
+ return f"Opened by SecondShift from run #{run.pk} at {run.commit_sha[:12]}."
112
+
113
+
114
+ def trailers(run: Run) -> str:
115
+ """Git trailers, so tools can find the run behind a commit."""
116
+ lines = [f"SecondShift-Run: {run.pk}"]
117
+ if run.job is not None:
118
+ lines.append(f"SecondShift-Job: {run.job.name}")
119
+ return "\n".join(lines)
120
+
121
+
122
+ def branch_name(run: Run, attribution: bool) -> str:
123
+ """The same name on every attempt, so a retry pushes the same branch."""
124
+ if attribution:
125
+ return f"{BRANCH_PREFIX}{run.pk}"
126
+ slug = slugify(run.display_name)[:BRANCH_SLUG_CHARS].strip("-") or "changes"
127
+ return f"{slug}-{run.pk}"
128
+
129
+
130
+ def publish_run(run_id: int) -> None:
131
+ run = get_run(run_id, "job", "task__repository__github_connection")
132
+ delivery = pull_request_delivery(run)
133
+ if delivery is None or delivery.status == RunDelivery.Status.DELIVERED:
134
+ return
135
+ if reason := skip_reason(run, run.repository):
136
+ finish_delivery(delivery, RunDelivery.Status.SKIPPED, reason)
137
+ return
138
+ delivery.attempts += 1
139
+ try:
140
+ with tempfile.TemporaryDirectory(prefix="secondshift-patch-") as folder:
141
+ patch = Path(folder) / "changes.patch"
142
+ with artifact_storage().open(run.collected.changes.path, "rb") as src:
143
+ patch.write_bytes(src.read())
144
+ opened = publish_pull_request(
145
+ run.repository, run.commit_sha, patch, draft_for(run)
146
+ )
147
+ except PublishError as error:
148
+ finish_delivery(delivery, RunDelivery.Status.FAILED, str(error))
149
+ return
150
+ except Exception:
151
+ logger.exception("publishing run %s failed", run_id)
152
+ finish_delivery(
153
+ delivery, RunDelivery.Status.FAILED, "Publishing failed unexpectedly."
154
+ )
155
+ return
156
+ delivery.external_url = opened.url
157
+ count = run.collected.changes.file_count
158
+ finish_delivery(
159
+ delivery,
160
+ RunDelivery.Status.DELIVERED,
161
+ f"Opened draft #{opened.number} with changes to {plural(count, 'file')}.",
162
+ )
@@ -0,0 +1,26 @@
1
+ """Changes a person may make to a task that has not started."""
2
+
3
+ from django.db import transaction
4
+
5
+ from secondshift.repositories.dao import lock_repository
6
+ from secondshift.runs.dao import add_event, lock_run
7
+ from secondshift.runs.models import Run
8
+ from secondshift.runs.services import RunRefused
9
+ from secondshift.runs.transitions import finish_run
10
+
11
+
12
+ @transaction.atomic
13
+ def cancel_queued_task(run: Run) -> Run:
14
+ if run.job_id is not None:
15
+ raise RunRefused("Only queued ad hoc tasks can be canceled here.")
16
+ lock_repository(run.task.repository_id)
17
+ run = lock_run(run.pk, "task__repository")
18
+ if run.status == Run.Status.CANCELED:
19
+ return run
20
+ if run.status != Run.Status.QUEUED:
21
+ raise RunRefused("This task has already started or finished.")
22
+ summary = "Canceled before execution."
23
+ finish_run(run.pk, Run.Status.CANCELED, summary, only_from={Run.Status.QUEUED})
24
+ run.refresh_from_db(fields=["status", "summary", "finished_at"])
25
+ add_event(run.pk, summary)
26
+ return run
File without changes
@@ -0,0 +1,26 @@
1
+ from secondshift.config.constants import MAX_CHANGES_BYTES
2
+ from secondshift.runs.outputs import RunOutputs
3
+
4
+
5
+ def plural(count: int, noun: str) -> str:
6
+ return f"{count} {noun}" if count == 1 else f"{count} {noun}s"
7
+
8
+
9
+ def collection_summary(outputs: RunOutputs) -> str:
10
+ """One timeline line about what the sandbox left behind, or none."""
11
+ changes = outputs.changes
12
+ parts: list[str] = []
13
+ if changes.path:
14
+ parts.append(f"changes to {plural(changes.file_count, 'file')}")
15
+ if outputs.files:
16
+ parts.append(plural(len(outputs.files), "saved file"))
17
+ lines = [f"Collected {' and '.join(parts)}."] if parts else []
18
+ if changes.too_large:
19
+ limit = MAX_CHANGES_BYTES // (1024 * 1024)
20
+ lines.append(
21
+ f"Changes to {plural(changes.file_count, 'file')} were larger than "
22
+ f"{limit} MiB, so they were not kept."
23
+ )
24
+ if outputs.skipped:
25
+ lines.append(f"Skipped {plural(len(outputs.skipped), 'saved file')}.")
26
+ return " ".join(lines)
@@ -0,0 +1,45 @@
1
+ """How each connection is used: by which jobs, and for model providers, at what cost."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime, timedelta
5
+ from decimal import Decimal
6
+
7
+ from django.utils import timezone
8
+
9
+ from secondshift.gateway.dao import usage_by_connection
10
+ from secondshift.jobs.dao import approved_jobs_per_connection
11
+ from secondshift.workspaces.models import Workspace
12
+
13
+ WINDOW = timedelta(days=7)
14
+ # The window an agent login's run cap counts over.
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ConnectionUsage:
19
+ # Jobs with approved access through this connection.
20
+ jobs: int = 0
21
+ runs_7d: int = 0
22
+ spend_7d_usd: Decimal = Decimal(0)
23
+ # Some model calls had no known price, so spend is a lower bound.
24
+ spend_partial: bool = False
25
+ # Last model call. Services don't record individual calls.
26
+ last_used_at: datetime | None = None
27
+
28
+
29
+ def connection_usage(
30
+ workspace: Workspace, now: datetime | None = None
31
+ ) -> dict[int, ConnectionUsage]:
32
+ start = (now or timezone.now()) - WINDOW
33
+ jobs = approved_jobs_per_connection(workspace.pk)
34
+ models = usage_by_connection(workspace.pk, start)
35
+ usage: dict[int, ConnectionUsage] = {}
36
+ for connection_id in jobs.keys() | models.keys():
37
+ row = models.get(connection_id, {})
38
+ usage[connection_id] = ConnectionUsage(
39
+ jobs=jobs.get(connection_id, 0),
40
+ runs_7d=row.get("runs", 0),
41
+ spend_7d_usd=row.get("spend") or Decimal(0),
42
+ spend_partial=row.get("unpriced", 0) > 0,
43
+ last_used_at=row.get("last"),
44
+ )
45
+ return usage
@@ -0,0 +1,150 @@
1
+ """Turn workflow exceptions into run summaries a person can act on."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from pydantic_ai.exceptions import ModelHTTPError
6
+
7
+ from secondshift.connections.errors import ExternalRequestError, ExternalToolError
8
+ from secondshift.github.errors import GitHubError
9
+ from secondshift.jobs.tool_rules import ToolRuleViolation
10
+ from secondshift.runner.protocol import RunnerProtocolError
11
+ from secondshift.runs.models import Run, RunFailure
12
+
13
+ STAGE_FAILURES: dict[str, str] = {
14
+ RunFailure.STARTING: "The run could not start",
15
+ RunFailure.CHECKOUT: "Couldn't check out the repository",
16
+ RunFailure.SANDBOX: "Couldn't start the sandbox",
17
+ RunFailure.SKILL: "The skill run failed",
18
+ RunFailure.FINISHING: "The run could not be recorded",
19
+ }
20
+ MAX_DETAIL_CHARS = 300
21
+ TOOL_RULE_NEXT_STEP = (
22
+ "The model used a tool in a way the job's access review does not allow. "
23
+ "Change the restriction or the skill, then run again."
24
+ )
25
+
26
+ # Messages of these types come from SecondShift or Pydantic AI (whose run errors
27
+ # are RuntimeErrors) and never carry credentials, so they are shown. Anything
28
+ # else could echo arbitrary data, so only its type is shown.
29
+ SHOWN_ERROR_TYPES: tuple[type[BaseException], ...] = (
30
+ GitHubError,
31
+ RunnerProtocolError,
32
+ RuntimeError,
33
+ )
34
+
35
+
36
+ def root_error(error: BaseException) -> BaseException:
37
+ """The first underlying error, unwrapping exhausted step retries.
38
+
39
+ Later retry attempts often fail for a different, misleading reason (for
40
+ example, a conflict because the first attempt already ran), so the first
41
+ attempt is the one that explains the failure.
42
+ """
43
+ attempts = getattr(error, "errors", None)
44
+ if isinstance(attempts, list) and attempts:
45
+ first = attempts[0]
46
+ if isinstance(first, BaseException):
47
+ return first
48
+ return error
49
+
50
+
51
+ def provider_message(body: object) -> str:
52
+ """The human message from an OpenAI- or Anthropic-shaped error body."""
53
+ if not isinstance(body, dict):
54
+ return ""
55
+ nested = body.get("error")
56
+ if isinstance(nested, dict) and isinstance(nested.get("message"), str):
57
+ return nested["message"]
58
+ message = body.get("message")
59
+ return message if isinstance(message, str) else ""
60
+
61
+
62
+ def one_line(text: str) -> str:
63
+ line = " ".join(text.split())
64
+ if len(line) > MAX_DETAIL_CHARS:
65
+ line = line[: MAX_DETAIL_CHARS - 1] + "…"
66
+ return line
67
+
68
+
69
+ def provider_reply(error: ModelHTTPError, separator: str) -> str:
70
+ """`HTTP 401: User not found.`, or `HTTP 401.` when the body says nothing."""
71
+ message = provider_message(error.body)
72
+ reply = f"HTTP {error.status_code}"
73
+ return one_line(f"{reply}{separator}{message}" if message else f"{reply}.")
74
+
75
+
76
+ def failure_detail(error: BaseException) -> str:
77
+ if isinstance(error, ModelHTTPError):
78
+ return one_line(f"The model provider answered {provider_reply(error, ': ')}")
79
+ if isinstance(error, ToolRuleViolation):
80
+ # Names the argument and the rule, never the value the model sent.
81
+ return one_line(f"Blocked by a tool restriction: {error}")
82
+ if isinstance(error, SHOWN_ERROR_TYPES) and str(error).strip():
83
+ return one_line(str(error))
84
+ # SecondShift writes these for people. Request errors may quote the
85
+ # remote service, so they stay hidden.
86
+ shown_tool_error = isinstance(error, ExternalToolError) and not isinstance(
87
+ error, ExternalRequestError
88
+ )
89
+ if shown_tool_error and str(error).strip():
90
+ return one_line(str(error))
91
+ return f"Unexpected {type(error).__name__}."
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class StageFailure:
96
+ kind: RunFailure
97
+ reason: str
98
+ # One line naming the failed stage and the original cause.
99
+ summary: str
100
+
101
+
102
+ def stage_failure(stage: RunFailure, error: BaseException) -> StageFailure:
103
+ """What a workflow error at this stage means for the run."""
104
+ root = root_error(error)
105
+ detail = failure_detail(root)
106
+ summary = f"{STAGE_FAILURES[stage]}: {detail}"
107
+ if isinstance(root, ModelHTTPError):
108
+ # model_provider_next_step reads the status from the start of this.
109
+ reason = provider_reply(root, " · ")
110
+ return StageFailure(RunFailure.MODEL_PROVIDER, reason, summary)
111
+ if isinstance(root, ToolRuleViolation):
112
+ return StageFailure(RunFailure.TOOL_RULE, one_line(str(root)), summary)
113
+ return StageFailure(stage, detail, summary)
114
+
115
+
116
+ def failure_metadata(stage: str, error: BaseException) -> dict[str, object]:
117
+ attempts = getattr(error, "errors", None)
118
+ return {
119
+ "stage": stage,
120
+ "error_type": type(root_error(error)).__name__,
121
+ "attempts": len(attempts) if isinstance(attempts, list) else 1,
122
+ }
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class FailureView:
127
+ """A failure split for compact display: where it happened, and why."""
128
+
129
+ label: str
130
+ reason: str
131
+
132
+
133
+ def describe_failure(run: Run) -> FailureView:
134
+ kind = RunFailure(run.failure or RunFailure.OTHER)
135
+ reason = " ".join(run.failure_reason.split())
136
+ return FailureView(kind.label, reason or "No reason was recorded.")
137
+
138
+
139
+ def model_provider_next_step(reason: str, connection_name: str) -> str:
140
+ """What to do about a model provider failure, in plain words."""
141
+ if reason.startswith(("HTTP 401", "HTTP 403")):
142
+ return (
143
+ f"The API key for {connection_name} was refused. "
144
+ "Update the key, then run again."
145
+ )
146
+ if reason.startswith("HTTP 429"):
147
+ return "The provider is limiting requests. Wait, then run again."
148
+ if reason.startswith("HTTP 5"):
149
+ return "The provider had a problem. Run again later."
150
+ return f"Check {connection_name}, then run again."
@@ -0,0 +1,158 @@
1
+ """Workspace health for the overview: what is failing now, and what it costs."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import date, datetime, timedelta
5
+ from decimal import Decimal
6
+
7
+ from django.db.models import Q
8
+ from django.utils import timezone
9
+
10
+ from secondshift.gateway.dao import spend_per_job, spend_totals
11
+ from secondshift.jobs.dao import jobs_by_id
12
+ from secondshift.runs.dao import (
13
+ finished_runs_per_day,
14
+ jobs_whose_last_run_failed,
15
+ run_counts,
16
+ runs_by_id,
17
+ )
18
+ from secondshift.runs.models import Run
19
+ from secondshift.runs.reporting.failures import describe_failure
20
+ from secondshift.workspaces.models import Workspace
21
+
22
+ WINDOW = timedelta(days=7)
23
+ # Two weeks, so the overview chart shows this week against the one before.
24
+ CHART_DAYS = 14
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class FailingJob:
29
+ job_id: int
30
+ job: str
31
+ repository: str
32
+ run_id: int
33
+ failed_at: datetime | None
34
+ label: str
35
+ reason: str
36
+ # Scheduled but switched off, so nothing will run it again on its own.
37
+ paused: bool
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class WeekSummary:
42
+ runs: int
43
+ previous_runs: int
44
+ finished: int
45
+ succeeded: int
46
+ spend_usd: Decimal
47
+ previous_spend_usd: Decimal
48
+ # Some model calls had no known price, so spend is a lower bound.
49
+ spend_partial: bool
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class RunDay:
54
+ """Finished runs on one local day, by outcome."""
55
+
56
+ day: date
57
+ succeeded: int
58
+ failed: int
59
+ canceled: int
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class JobSpend:
64
+ job_id: int
65
+ job: str
66
+ repository: str
67
+ runs: int
68
+ spend_usd: Decimal
69
+ # Some of this job's model calls had no known price.
70
+ partial: bool
71
+
72
+
73
+ def failing_jobs(workspace: Workspace) -> list[FailingJob]:
74
+ """Jobs whose most recent finished run failed.
75
+
76
+ A job that failed last week but has succeeded since is healthy, so this
77
+ looks only at each job's latest outcome, not at failure history. Paused
78
+ jobs still count, marked as paused, since their last run is still broken.
79
+ """
80
+ jobs = jobs_whose_last_run_failed(workspace.pk)
81
+ runs = runs_by_id(job.latest_run_id for job in jobs)
82
+ failing: list[FailingJob] = []
83
+ for job in jobs:
84
+ run = runs[job.latest_run_id]
85
+ view = describe_failure(run)
86
+ failing.append(
87
+ FailingJob(
88
+ job_id=job.pk,
89
+ job=job.name,
90
+ repository=job.repository.display_name,
91
+ run_id=run.pk,
92
+ failed_at=run.finished_at,
93
+ label=view.label,
94
+ reason=view.reason,
95
+ paused=bool(job.cron) and not job.enabled,
96
+ )
97
+ )
98
+ return failing
99
+
100
+
101
+ def week_summary(workspace: Workspace, now: datetime | None = None) -> WeekSummary:
102
+ """Runs and model spend in the last seven days, with the week before for trend."""
103
+ now = now or timezone.now()
104
+ start, previous_start = now - WINDOW, now - 2 * WINDOW
105
+ runs = run_counts(workspace.pk, start, previous_start)
106
+ totals = spend_totals(
107
+ Q(run__task__repository__workspace_id=workspace.pk), start, previous_start
108
+ )
109
+ return WeekSummary(
110
+ runs=runs["runs"],
111
+ previous_runs=runs["previous"],
112
+ finished=runs["finished"],
113
+ succeeded=runs["succeeded"],
114
+ spend_usd=totals["spend"] or Decimal(0),
115
+ previous_spend_usd=totals["previous"] or Decimal(0),
116
+ spend_partial=totals["unpriced"] > 0,
117
+ )
118
+
119
+
120
+ def runs_by_day(
121
+ workspace: Workspace, now: datetime | None = None, days: int = CHART_DAYS
122
+ ) -> list[RunDay]:
123
+ """Finished runs per local day, oldest first. Days without runs count as zero."""
124
+ today = timezone.localdate(now or timezone.now())
125
+ first = today - timedelta(days=days - 1)
126
+ rows = finished_runs_per_day(workspace.pk, first, today)
127
+ counts = {(row["day"], row["status"]): row["count"] for row in rows}
128
+ return [
129
+ RunDay(
130
+ day=day,
131
+ succeeded=counts.get((day, Run.Status.SUCCEEDED), 0),
132
+ failed=counts.get((day, Run.Status.FAILED), 0),
133
+ canceled=counts.get((day, Run.Status.CANCELED), 0),
134
+ )
135
+ for day in (first + timedelta(days=offset) for offset in range(days))
136
+ ]
137
+
138
+
139
+ def spend_by_job(
140
+ workspace: Workspace, now: datetime | None = None, limit: int = 5
141
+ ) -> list[JobSpend]:
142
+ """The jobs that spent the most on models in the last seven days."""
143
+ start = (now or timezone.now()) - WINDOW
144
+ rows = spend_per_job(workspace.pk, start)
145
+ ranked = sorted(rows, key=lambda row: row["spend"] or Decimal(0), reverse=True)
146
+ ranked = ranked[:limit]
147
+ jobs = jobs_by_id(row["run__job_id"] for row in ranked)
148
+ return [
149
+ JobSpend(
150
+ job_id=row["run__job_id"],
151
+ job=jobs[row["run__job_id"]].name,
152
+ repository=jobs[row["run__job_id"]].repository.display_name,
153
+ runs=row["runs"],
154
+ spend_usd=row["spend"] or Decimal(0),
155
+ partial=row["unpriced"] > 0,
156
+ )
157
+ for row in ranked
158
+ ]