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,144 @@
1
+ """Open the issues a finished run's agent proposed.
2
+
3
+ This reads only what the run stored, so a retry needs no agent or sandbox.
4
+ The token is limited to this repository and to issues. It never reaches the
5
+ sandbox or the model.
6
+ """
7
+
8
+ import logging
9
+
10
+ from secondshift.connections.github import shows_attribution
11
+ from secondshift.connections.models import Connection
12
+ from secondshift.github import credentials
13
+ from secondshift.github.errors import GitHubError
14
+ from secondshift.github.issues import open_issue, open_issue_titled
15
+ from secondshift.repositories.github_access import github_access
16
+ from secondshift.repositories.models import Repository
17
+ from secondshift.runs.dao import (
18
+ create_delivery,
19
+ finish_delivery,
20
+ get_run,
21
+ has_proposed_issues,
22
+ opened_issues,
23
+ run_delivery,
24
+ unopened_issues,
25
+ )
26
+ from secondshift.runs.execution.issues import issue_labels, issue_limit
27
+ from secondshift.runs.models import Run, RunDelivery, RunIssue
28
+ from secondshift.runs.reporting.collection import plural
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ APP_WRITE_HINT = (
33
+ "GitHub refused to open issues. Give the GitHub App Issues write "
34
+ "permission, then retry."
35
+ )
36
+
37
+
38
+ def record_pending_issues(run: Run) -> None:
39
+ """Shown as pending from the start, and kept if opening fails."""
40
+ if issue_limit(run.plan):
41
+ create_delivery(
42
+ run, RunDelivery.Kind.ISSUES, f"Issues in {run.repository.full_name}"
43
+ )
44
+
45
+
46
+ def issues_delivery(run: Run) -> RunDelivery | None:
47
+ return run_delivery(run, RunDelivery.Kind.ISSUES)
48
+
49
+
50
+ def skip_reason(run: Run, repository: Repository) -> str:
51
+ """Why this run opens no issues, or an empty string if it should."""
52
+ if repository.source_type != Repository.SourceType.GITHUB:
53
+ return "Local repositories cannot receive issues."
54
+ if not repository.allow_issues:
55
+ return "An owner has not allowed issues for this repository."
56
+ if run.status != Run.Status.SUCCEEDED:
57
+ return "The run did not succeed."
58
+ if not has_proposed_issues(run):
59
+ return "The agent proposed no issues."
60
+ return ""
61
+
62
+
63
+ def issue_body(run: Run, issue: RunIssue) -> str:
64
+ """The agent's text, and a footer when the connection shows attribution."""
65
+ if not shows_attribution(run.repository.github_connection):
66
+ return issue.body
67
+ footer = f"Opened by SecondShift from run #{run.pk} at {run.commit_sha[:12]}."
68
+ return f"{issue.body}\n\n---\n\n{footer}" if issue.body else footer
69
+
70
+
71
+ def open_run_issues(run_id: int) -> None:
72
+ run = get_run(
73
+ run_id,
74
+ "job__repository__github_connection",
75
+ "task__repository__github_connection",
76
+ )
77
+ delivery = issues_delivery(run)
78
+ if delivery is None or delivery.status == RunDelivery.Status.DELIVERED:
79
+ return
80
+ repository = run.repository
81
+ if reason := skip_reason(run, repository):
82
+ finish_delivery(delivery, RunDelivery.Status.SKIPPED, reason)
83
+ return
84
+ delivery.attempts += 1
85
+ try:
86
+ access = github_access(repository, credentials.ISSUES_WRITE)
87
+ except GitHubError as error:
88
+ with_token = (
89
+ repository.github_connection is not None
90
+ and repository.github_connection.auth_strategy
91
+ == Connection.AuthStrategy.API_KEY
92
+ )
93
+ # Token errors already say what to fix, such as an expired token.
94
+ finish_delivery(
95
+ delivery,
96
+ RunDelivery.Status.FAILED,
97
+ str(error) if with_token else APP_WRITE_HINT,
98
+ )
99
+ return
100
+ try:
101
+ # Issues opened by an earlier attempt keep their number.
102
+ for issue in unopened_issues(run):
103
+ found = open_issue_titled(access.token, access.full_name, issue.title)
104
+ if found is None:
105
+ found = open_issue(
106
+ access.token,
107
+ access.full_name,
108
+ issue.title,
109
+ issue_body(run, issue),
110
+ issue_labels(run.plan),
111
+ )
112
+ else:
113
+ issue.already_open = True
114
+ issue.number, issue.url = found.number, found.url
115
+ issue.save(update_fields=["number", "url", "already_open"])
116
+ except GitHubError as error:
117
+ # These messages never contain the token.
118
+ finish_delivery(
119
+ delivery, RunDelivery.Status.FAILED, f"{done(run)}{error}".strip()
120
+ )
121
+ return
122
+ except Exception:
123
+ logger.exception("opening issues for run %s failed", run_id)
124
+ finish_delivery(
125
+ delivery,
126
+ RunDelivery.Status.FAILED,
127
+ f"{done(run)}Opening issues failed unexpectedly.".strip(),
128
+ )
129
+ return
130
+ finish_delivery(delivery, RunDelivery.Status.DELIVERED, done(run).strip())
131
+
132
+
133
+ def done(run: Run) -> str:
134
+ """What has happened so far, as a sentence or two followed by a space."""
135
+ issues = opened_issues(run)
136
+ opened = sum(1 for issue in issues if not issue.already_open)
137
+ already = len(issues) - opened
138
+ parts = []
139
+ if opened:
140
+ parts.append(f"Opened {plural(opened, 'issue')}.")
141
+ if already:
142
+ verb = "was" if already == 1 else "were"
143
+ parts.append(f"{plural(already, 'issue')} {verb} already open.")
144
+ return " ".join(parts) + " " if parts else ""
File without changes
File without changes
@@ -0,0 +1,12 @@
1
+ import djclick as click
2
+
3
+ from secondshift.runs.retention import prune_runs
4
+
5
+
6
+ @click.command()
7
+ @click.option(
8
+ "--days", type=int, default=None, help="Override SECONDSHIFT_RUN_RETENTION_DAYS."
9
+ )
10
+ def command(days: int | None) -> None:
11
+ """Delete finished runs past retention, with their output and DBOS records."""
12
+ click.echo(f"Pruned {prune_runs(days)} run(s)")
@@ -0,0 +1,129 @@
1
+ import logging
2
+ import signal
3
+ import threading
4
+ from pathlib import Path
5
+ from types import FrameType
6
+
7
+ import djclick as click
8
+ from dbos import DBOS, DBOSClient
9
+ from django.conf import settings
10
+
11
+ from secondshift.connections.agents import retry_credential_cleanups
12
+ from secondshift.jobs.autosync import server_syncs, sync_moved_repositories
13
+ from secondshift.mcp_server.audit import prune_tool_calls
14
+ from secondshift.repositories.github_check import check_due_sources
15
+ from secondshift.repositories.sources import prune_source_cache
16
+ from secondshift.runs.orchestration.dbos_runtime import (
17
+ launch_dbos_worker,
18
+ new_dbos_client,
19
+ )
20
+ from secondshift.runs.orchestration.dispatch import dispatch_waiting_tasks
21
+ from secondshift.runs.orchestration.periodic import PeriodicTask
22
+ from secondshift.runs.orchestration.reconciliation import reconcile_terminal_workflows
23
+ from secondshift.runs.orchestration.schedules import synchronize_schedules
24
+ from secondshift.runs.retention import prune_runs
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ PRUNE_INTERVAL_SECONDS = 24 * 60 * 60
29
+ # How often to look for GitHub connections due for their daily check.
30
+ SOURCE_CHECK_POLL_SECONDS = 60 * 60
31
+ # How often to ask the runner again to remove logins it kept.
32
+ CREDENTIAL_CLEANUP_SECONDS = 60
33
+
34
+
35
+ def remove_cached_logins() -> None:
36
+ if removed := retry_credential_cleanups():
37
+ logger.info("removed %s cached agent login(s) from the runner", removed)
38
+
39
+
40
+ def worker_tasks(client: DBOSClient) -> list[PeriodicTask]:
41
+ """What the worker repeats, in the order it runs them on each pass."""
42
+
43
+ def reconcile() -> None:
44
+ if count := reconcile_terminal_workflows(client):
45
+ logger.warning("reconciled %s run(s) from terminal DBOS workflows", count)
46
+
47
+ def schedules() -> None:
48
+ logger.debug("synchronized %s DBOS schedule(s)", synchronize_schedules(client))
49
+
50
+ def prune() -> None:
51
+ logger.info("pruned %s run(s) past retention", prune_runs(client=client))
52
+ logger.info("pruned %s MCP tool call(s) past retention", prune_tool_calls())
53
+ cache = prune_source_cache()
54
+ logger.info(
55
+ "pruned %s snapshot(s) and %s mirror(s) from the source cache, %s bytes",
56
+ cache.snapshots,
57
+ cache.mirrors,
58
+ cache.bytes,
59
+ )
60
+
61
+ def repositories() -> None:
62
+ if synced := sync_moved_repositories():
63
+ logger.info("synced %s repository(s) with new commits", synced)
64
+
65
+ def sources() -> None:
66
+ if checked := check_due_sources():
67
+ logger.info("checked %s GitHub connection(s)", checked)
68
+
69
+ return [
70
+ PeriodicTask("reconcile runs", every=0, run=reconcile),
71
+ PeriodicTask("dispatch waiting tasks", every=0, run=dispatch_waiting_tasks),
72
+ PeriodicTask("sync schedules", every=0, run=schedules),
73
+ PeriodicTask("prune runs", every=PRUNE_INTERVAL_SECONDS, run=prune),
74
+ PeriodicTask(
75
+ "sync repositories",
76
+ every=settings.REPOSITORY_SYNC_INTERVAL_SECONDS,
77
+ run=repositories,
78
+ enabled=server_syncs,
79
+ ),
80
+ PeriodicTask(
81
+ "check GitHub connections",
82
+ every=SOURCE_CHECK_POLL_SECONDS,
83
+ run=sources,
84
+ ),
85
+ PeriodicTask(
86
+ "remove cached agent logins",
87
+ every=CREDENTIAL_CLEANUP_SECONDS,
88
+ run=remove_cached_logins,
89
+ ),
90
+ ]
91
+
92
+
93
+ @click.command()
94
+ @click.option("--sync-interval", type=int, default=15, show_default=True)
95
+ @click.option("--ready-file", type=click.Path(path_type=Path), hidden=True)
96
+ def command(sync_interval: int, ready_file: Path | None) -> None:
97
+ """Run DBOS workers and reconcile database-backed schedules."""
98
+ stopped = threading.Event()
99
+
100
+ def stop(signum: int, frame: FrameType | None) -> None:
101
+ stopped.set()
102
+
103
+ previous = signal.signal(signal.SIGTERM, stop)
104
+ client = None
105
+ try:
106
+ launch_dbos_worker()
107
+ click.echo("DBOS worker started")
108
+ client = new_dbos_client()
109
+ tasks = worker_tasks(client)
110
+ if ready_file is not None:
111
+ ready_file.touch(mode=0o600)
112
+ while not stopped.is_set():
113
+ for task in tasks:
114
+ if stopped.is_set():
115
+ break
116
+ task.run_if_due()
117
+ if stopped.wait(sync_interval):
118
+ break
119
+ except KeyboardInterrupt:
120
+ click.echo("Stopping DBOS worker")
121
+ finally:
122
+ try:
123
+ if client is not None:
124
+ client.destroy()
125
+ finally:
126
+ try:
127
+ DBOS.destroy(workflow_completion_timeout_sec=10)
128
+ finally:
129
+ signal.signal(signal.SIGTERM, previous)
@@ -0,0 +1,313 @@
1
+ """Versioned data operations used by generated runs migrations.
2
+
3
+ Keep these functions independent of current models and job configuration.
4
+ """
5
+
6
+ from typing import Any
7
+
8
+ from django.apps.registry import Apps
9
+ from django.conf import settings
10
+ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
11
+
12
+
13
+ def check_constraints_now_v1(schema_editor: BaseDatabaseSchemaEditor) -> None:
14
+ """Run the foreign key checks that row changes left for commit.
15
+
16
+ A squashed migration runs these steps in one transaction. PostgreSQL
17
+ refuses to alter a table while those checks are still pending.
18
+ """
19
+ if schema_editor.connection.vendor == "postgresql":
20
+ schema_editor.execute("SET CONSTRAINTS ALL IMMEDIATE")
21
+
22
+
23
+ def backfill_tasks_v1(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
24
+ """Preserve the assignment actually recorded on each historical run."""
25
+ run_model = apps.get_model("runs", "Run")
26
+ task_model = apps.get_model("runs", "Task")
27
+ alias = schema_editor.connection.alias
28
+ while runs := list(
29
+ run_model.objects.using(alias).filter(task__isnull=True).order_by("pk")[:500]
30
+ ):
31
+ tasks = task_model.objects.using(alias).bulk_create(
32
+ [
33
+ task_model(
34
+ job_id=run.job_id,
35
+ origin=run.started_by,
36
+ commit_sha=run.commit_sha,
37
+ input_snapshot=run.input_snapshot,
38
+ scheduled_for=run.scheduled_for,
39
+ created_at=run.created_at,
40
+ )
41
+ for run in runs
42
+ ]
43
+ )
44
+ for run, task in zip(runs, tasks, strict=True):
45
+ run.task_id = task.pk
46
+ run_model.objects.using(alias).bulk_update(runs, ["task"])
47
+
48
+
49
+ def backfill_agent_connections_v1(
50
+ apps: Apps, schema_editor: BaseDatabaseSchemaEditor
51
+ ) -> None:
52
+ """Record the agent login each ready plan chose, so its cap counts it."""
53
+ run_model = apps.get_model("runs", "Run")
54
+ login_model = apps.get_model("connections", "AgentConnection")
55
+ alias = schema_editor.connection.alias
56
+ logins = set(login_model.objects.using(alias).values_list("pk", flat=True))
57
+ runs = run_model.objects.using(alias).filter(
58
+ execution_plan__ready=True,
59
+ execution_plan__execution__connection_id__isnull=False,
60
+ )
61
+ changed = []
62
+ for run in runs.iterator():
63
+ login = run.execution_plan["execution"]["connection_id"]
64
+ if login in logins:
65
+ run.agent_connection_id = login
66
+ changed.append(run)
67
+ run_model.objects.using(alias).bulk_update(changed, ["agent_connection"], 500)
68
+ check_constraints_now_v1(schema_editor)
69
+
70
+
71
+ PLAN_LIMIT_SETTINGS_V1 = {
72
+ "max_turns": "AGENT_MAX_TURNS",
73
+ "max_tool_calls": "AGENT_MAX_TOOL_CALLS",
74
+ "max_tool_result_chars": "AGENT_MAX_TOOL_RESULT_CHARS",
75
+ "token_budget": "MODEL_RUN_TOKEN_BUDGET",
76
+ "timeout_seconds": "RUN_TIMEOUT_SECONDS",
77
+ }
78
+
79
+
80
+ def picked_v1(values: Any, defaults: dict[str, Any]) -> dict[str, Any]:
81
+ """The known keys of a stored block, with defaults for missing ones."""
82
+ values = values if isinstance(values, dict) else {}
83
+ return {key: values.get(key, default) for key, default in defaults.items()}
84
+
85
+
86
+ def normalized_plan_v1(
87
+ plan: dict[str, Any], fallback: dict[str, Any]
88
+ ) -> dict[str, Any]:
89
+ """One stored plan in the shape every reader now expects.
90
+
91
+ Plans from older releases lack blocks added later. A run created without
92
+ a plan keeps it as not ready, with its job and commit from the run.
93
+ """
94
+ agent = plan.get("agent") or {}
95
+ limits = agent.get("limits") or {}
96
+ model = plan.get("model")
97
+ return {
98
+ "execution": picked_v1(
99
+ plan.get("execution"),
100
+ {
101
+ "backend": "pydantic",
102
+ "label": "",
103
+ "ready": True,
104
+ "problem": "",
105
+ "connection_id": None,
106
+ "credential_key": None,
107
+ "owner_id": None,
108
+ },
109
+ ),
110
+ "ready": bool(plan.get("ready", False)),
111
+ "agent": {
112
+ "limits": {
113
+ key: int(limits.get(key, getattr(settings, name)))
114
+ for key, name in PLAN_LIMIT_SETTINGS_V1.items()
115
+ },
116
+ "tools": list(agent.get("tools") or []),
117
+ "subagents": int(agent.get("subagents") or 0),
118
+ },
119
+ "job": picked_v1(
120
+ plan.get("job") or fallback["job"],
121
+ {"key": "", "skill_path": "", "config_hash": ""},
122
+ ),
123
+ "repository": picked_v1(
124
+ plan.get("repository") or fallback["repository"],
125
+ {"full_name": "", "config_sha": ""},
126
+ ),
127
+ "runner": picked_v1(
128
+ plan.get("runner"),
129
+ {
130
+ "image": settings.RUNNER_IMAGE,
131
+ "network_policy": "none",
132
+ "collect_changes": False,
133
+ },
134
+ ),
135
+ "pull_request": (
136
+ picked_v1(plan["pull_request"], {"base": "", "run_ci": False})
137
+ if plan.get("pull_request")
138
+ else None
139
+ ),
140
+ "issues": (
141
+ picked_v1(plan["issues"], {"max": 0, "labels": []})
142
+ if plan.get("issues")
143
+ else None
144
+ ),
145
+ "model": (
146
+ picked_v1(
147
+ model,
148
+ {
149
+ "connection_id": 0,
150
+ "connection_name": "",
151
+ "provider": "",
152
+ "family": "",
153
+ "default_model": "",
154
+ "allowed_models": [],
155
+ },
156
+ )
157
+ if isinstance(model, dict)
158
+ else None
159
+ ),
160
+ "capabilities": [
161
+ picked_v1(
162
+ capability,
163
+ {
164
+ "binding": "",
165
+ "kind": "",
166
+ "required": True,
167
+ "requested_tools": [],
168
+ "connection_id": None,
169
+ "connection_name": None,
170
+ "effective_tools": [],
171
+ "authorized": False,
172
+ "reasons": [],
173
+ "missing": None,
174
+ "external": None,
175
+ "tool_rules": {},
176
+ },
177
+ )
178
+ for capability in plan.get("capabilities") or []
179
+ ],
180
+ }
181
+
182
+
183
+ def normalize_execution_plans_v1(
184
+ apps: Apps, schema_editor: BaseDatabaseSchemaEditor
185
+ ) -> None:
186
+ """Rewrite every stored plan in the current shape."""
187
+ run_model = apps.get_model("runs", "Run")
188
+ alias = schema_editor.connection.alias
189
+ runs = run_model.objects.using(alias).select_related(
190
+ "job__repository", "task__repository"
191
+ )
192
+ changed = []
193
+ for run in runs.iterator(chunk_size=500):
194
+ job = run.job
195
+ repository = job.repository if job else run.task.repository
196
+ fallback = {
197
+ "job": {
198
+ "key": job.key if job else "",
199
+ "skill_path": job.skill_path if job else "",
200
+ "config_hash": job.config_hash if job else "",
201
+ },
202
+ "repository": {
203
+ "full_name": repository.full_name if repository else "",
204
+ "config_sha": run.commit_sha,
205
+ },
206
+ }
207
+ normalized = normalized_plan_v1(run.execution_plan or {}, fallback)
208
+ if normalized != run.execution_plan:
209
+ run.execution_plan = normalized
210
+ changed.append(run)
211
+ run_model.objects.using(alias).bulk_update(changed, ["execution_plan"], 500)
212
+ check_constraints_now_v1(schema_editor)
213
+
214
+
215
+ def record_run_starts_v1(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
216
+ """Keep what started each run on the run, before tasks drop their origin.
217
+
218
+ MCP starts were recorded as manual runs with an MCP task. Runs from
219
+ before starts were recorded count as scheduled.
220
+ """
221
+ run_model = apps.get_model("runs", "Run")
222
+ alias = schema_editor.connection.alias
223
+ runs = run_model.objects.using(alias)
224
+ runs.filter(task__origin="mcp").update(started_by="mcp")
225
+ runs.filter(started_by="").update(started_by="schedule")
226
+ check_constraints_now_v1(schema_editor)
227
+
228
+
229
+ def assign_task_repositories_v1(
230
+ apps: Apps, schema_editor: BaseDatabaseSchemaEditor
231
+ ) -> None:
232
+ """Give every run a task, and every task its repository and fingerprint."""
233
+ run_model = apps.get_model("runs", "Run")
234
+ task_model = apps.get_model("runs", "Task")
235
+ alias = schema_editor.connection.alias
236
+ for run in (
237
+ run_model.objects.using(alias)
238
+ .filter(task__isnull=True)
239
+ .select_related("job")
240
+ .iterator()
241
+ ):
242
+ run.task = task_model.objects.using(alias).create(
243
+ job_id=run.job_id,
244
+ repository_id=run.job.repository_id,
245
+ commit_sha=run.commit_sha,
246
+ scheduled_for=run.scheduled_for,
247
+ created_at=run.created_at,
248
+ )
249
+ run.save(update_fields=["task"])
250
+ tasks = task_model.objects.using(alias).select_related("job")
251
+ changed = []
252
+ for task in tasks.iterator():
253
+ if task.repository_id is None:
254
+ task.repository_id = task.job.repository_id
255
+ task.fingerprint = str(task.input_snapshot.get("submission_fingerprint", ""))
256
+ changed.append(task)
257
+ task_model.objects.using(alias).bulk_update(
258
+ changed, ["repository", "fingerprint"], 500
259
+ )
260
+ check_constraints_now_v1(schema_editor)
261
+
262
+
263
+ # The summaries v0.1.0b10 wrote, by the failure each one names.
264
+ FAILURE_HEADLINES_V1 = {
265
+ "Timed out": "timeout",
266
+ "Budget used up": "budget",
267
+ "The run could not start": "starting",
268
+ "Couldn't check out the repository": "checkout",
269
+ "Couldn't start the sandbox": "sandbox",
270
+ "The skill run failed": "skill",
271
+ "The run could not be recorded": "finishing",
272
+ }
273
+ STAGES_V1 = {"starting", "checkout", "sandbox", "skill", "finishing"}
274
+ ENQUEUE_FAILED_V1 = "The durable workflow could not be enqueued."
275
+ MODEL_PROVIDER_PREFIX_V1 = "The model provider answered "
276
+ TOOL_RULE_PREFIX_V1 = "Blocked by a tool restriction: "
277
+ WORKFLOW_PREFIX_V1 = "The durable workflow failed unexpectedly ("
278
+
279
+
280
+ def failure_from_summary_v1(summary: str) -> tuple[str, str]:
281
+ """The failure kind and reason an older failed run's summary names."""
282
+ if summary == ENQUEUE_FAILED_V1:
283
+ return "not_queued", summary
284
+ text = " ".join(summary.split())
285
+ if text.startswith(WORKFLOW_PREFIX_V1) and text.endswith(")."):
286
+ return "other", f"Unexpected {text[len(WORKFLOW_PREFIX_V1) : -2]}."
287
+ for headline, kind in FAILURE_HEADLINES_V1.items():
288
+ if not text.startswith(f"{headline}: "):
289
+ continue
290
+ detail = text[len(headline) + 2 :]
291
+ if kind in STAGES_V1 and detail.startswith(MODEL_PROVIDER_PREFIX_V1):
292
+ reason = detail.removeprefix(MODEL_PROVIDER_PREFIX_V1)
293
+ return "model_provider", reason.replace(": ", " · ", 1)
294
+ if kind in STAGES_V1 and detail.startswith(TOOL_RULE_PREFIX_V1):
295
+ return "tool_rule", detail.removeprefix(TOOL_RULE_PREFIX_V1)
296
+ return kind, detail
297
+ return "other", summary
298
+
299
+
300
+ def record_run_failures_v1(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
301
+ """Record why each earlier failed run failed, from its summary."""
302
+ run_model = apps.get_model("runs", "Run")
303
+ runs = run_model.objects.using(schema_editor.connection.alias).filter(
304
+ status="failed", failure=""
305
+ )
306
+ changed = []
307
+ for run in runs.only("pk", "summary").iterator():
308
+ run.failure, run.failure_reason = failure_from_summary_v1(run.summary)
309
+ changed.append(run)
310
+ run_model.objects.using(schema_editor.connection.alias).bulk_update(
311
+ changed, ["failure", "failure_reason"], 500
312
+ )
313
+ check_constraints_now_v1(schema_editor)
@@ -0,0 +1,69 @@
1
+ # Generated by Django 6.0.9 on 2026-10-26 14:22
2
+
3
+ import django.db.models.deletion
4
+ import uuid
5
+ from django.db import migrations, models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ initial = True
11
+
12
+ dependencies = [
13
+ ('jobs', '0001_initial'),
14
+ ]
15
+
16
+ operations = [
17
+ migrations.CreateModel(
18
+ name='Run',
19
+ fields=[
20
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
21
+ ('status', models.CharField(choices=[('queued', 'Queued'), ('running', 'Running'), ('succeeded', 'Succeeded'), ('failed', 'Failed'), ('canceled', 'Canceled')], default='queued', max_length=16)),
22
+ ('commit_sha', models.CharField(max_length=64)),
23
+ ('scheduled_for', models.DateTimeField()),
24
+ ('started_at', models.DateTimeField(blank=True, null=True)),
25
+ ('finished_at', models.DateTimeField(blank=True, null=True)),
26
+ ('sandbox_runtime', models.CharField(blank=True, max_length=32)),
27
+ ('exit_code', models.IntegerField(blank=True, null=True)),
28
+ ('summary', models.TextField(blank=True)),
29
+ ('input_snapshot', models.JSONField(blank=True, default=dict)),
30
+ ('execution_plan', models.JSONField(blank=True, default=dict)),
31
+ ('output_excerpt', models.TextField(blank=True)),
32
+ ('input_path', models.CharField(blank=True, max_length=500)),
33
+ ('input_size_bytes', models.BigIntegerField(blank=True, null=True)),
34
+ ('output_path', models.CharField(blank=True, max_length=500)),
35
+ ('output_size_bytes', models.BigIntegerField(blank=True, null=True)),
36
+ ('output_sha256', models.CharField(blank=True, max_length=64)),
37
+ ('output_truncated', models.BooleanField(default=False)),
38
+ ('dbos_workflow_id', models.CharField(blank=True, db_index=True, max_length=200)),
39
+ ('runner_session_key', models.UUIDField(default=uuid.uuid4, editable=False)),
40
+ ('created_at', models.DateTimeField(auto_now_add=True)),
41
+ ('job', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='runs', to='jobs.job')),
42
+ ],
43
+ options={
44
+ 'ordering': ['-created_at'],
45
+ },
46
+ ),
47
+ migrations.CreateModel(
48
+ name='RunEvent',
49
+ fields=[
50
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
51
+ ('level', models.CharField(default='info', max_length=16)),
52
+ ('message', models.TextField()),
53
+ ('metadata', models.JSONField(blank=True, default=dict)),
54
+ ('created_at', models.DateTimeField(auto_now_add=True)),
55
+ ('run', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='runs.run')),
56
+ ],
57
+ options={
58
+ 'ordering': ['created_at'],
59
+ },
60
+ ),
61
+ migrations.AddIndex(
62
+ model_name='run',
63
+ index=models.Index(fields=['status', 'created_at'], name='runs_status_created_at_idx'),
64
+ ),
65
+ migrations.AddConstraint(
66
+ model_name='run',
67
+ constraint=models.UniqueConstraint(fields=('job', 'scheduled_for'), name='unique_job_scheduled_run'),
68
+ ),
69
+ ]