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,640 @@
1
+ """Queries and writes on runs, tasks, run events, deliveries, issues and leases."""
2
+
3
+ from collections.abc import Collection, Iterable
4
+ from datetime import date, datetime
5
+ from typing import Any
6
+
7
+ from django.db import transaction
8
+ from django.db.models import Count, Exists, F, OuterRef, Q, QuerySet, Subquery, Sum
9
+ from django.db.models.functions import Coalesce, TruncDate
10
+
11
+ from secondshift.config.constants import AgentBackend
12
+ from secondshift.connections.models import Connection
13
+ from secondshift.jobs.dao import active_jobs
14
+ from secondshift.jobs.models import Job
15
+ from secondshift.jobs.plan import ExecutionPlan
16
+ from secondshift.runner.agent_image.outcomes import CACHE_READ_DIVISOR
17
+ from secondshift.runs.models import (
18
+ AgentLease,
19
+ Run,
20
+ RunAccessNeed,
21
+ RunDelivery,
22
+ RunEvent,
23
+ RunFailure,
24
+ RunIssue,
25
+ Task,
26
+ )
27
+
28
+ # Runs the queue could not hand off. They never started, so they do not count
29
+ # as earlier or recent runs.
30
+ ENQUEUE_FAILED = Q(failure=RunFailure.NOT_QUEUED)
31
+ NEWEST_FIRST = ("-created_at", "-pk")
32
+
33
+
34
+ # Runs
35
+
36
+
37
+ def get_run(run_id: int, *related: str) -> Run:
38
+ """The run with these relations loaded. Raises Run.DoesNotExist when missing."""
39
+ return Run.objects.select_related(*related).get(pk=run_id)
40
+
41
+
42
+ def lock_run(run_id: int, *related: str) -> Run:
43
+ """Lock the run for this transaction, with these relations loaded."""
44
+ return Run.objects.select_for_update().select_related(*related).get(pk=run_id)
45
+
46
+
47
+ def lock_run_in(run_id: int, statuses: Collection[str]) -> Run | None:
48
+ """Lock the run while it is in one of these statuses. None otherwise."""
49
+ return (
50
+ Run.objects.select_for_update().filter(pk=run_id, status__in=statuses).first()
51
+ )
52
+
53
+
54
+ def create_run_record(**fields: Any) -> Run:
55
+ """A run counts against its agent login only when its plan was ready."""
56
+ plan = fields.get("execution_plan")
57
+ if plan:
58
+ typed = ExecutionPlan.model_validate(plan)
59
+ if typed.ready:
60
+ fields["agent_connection_id"] = typed.execution.connection_id
61
+ return Run.objects.create(**fields)
62
+
63
+
64
+ def update_run(run_id: int, **fields: Any) -> None:
65
+ """Save these fields on the run. Its status changes only in runs.transitions."""
66
+ if "status" in fields:
67
+ raise ValueError("Change a run's status through runs.transitions.")
68
+ Run.objects.filter(pk=run_id).update(**fields)
69
+
70
+
71
+ def with_model_usage(runs: QuerySet[Run]) -> QuerySet[Run]:
72
+ """Annotate each run with its model usage totals and agent login usage."""
73
+ agent_usage = (
74
+ RunEvent.objects.filter(
75
+ run_id=OuterRef("pk"),
76
+ metadata__backend__in=[
77
+ backend for backend in AgentBackend if not backend.uses_model_api
78
+ ],
79
+ metadata__has_key="usage",
80
+ )
81
+ .order_by("-pk")
82
+ .values("metadata__usage")[:1]
83
+ )
84
+ return runs.annotate(
85
+ agent_usage=Subquery(agent_usage),
86
+ model_tokens=Sum(
87
+ F("model_usage__input_tokens")
88
+ + F("model_usage__output_tokens")
89
+ + F("model_usage__cache_read_tokens")
90
+ + F("model_usage__cache_write_tokens")
91
+ ),
92
+ # Cached prompt tokens are still input the model read.
93
+ model_input_tokens=Sum(
94
+ F("model_usage__input_tokens")
95
+ + F("model_usage__cache_read_tokens")
96
+ + F("model_usage__cache_write_tokens")
97
+ ),
98
+ model_output_tokens=Sum("model_usage__output_tokens"),
99
+ model_cache_read_tokens=Sum("model_usage__cache_read_tokens"),
100
+ # The gateway's count, request by request: cache reads as a tenth,
101
+ # rounded up. Integer division matches it exactly.
102
+ model_budget_tokens=Sum(
103
+ F("model_usage__input_tokens")
104
+ + F("model_usage__output_tokens")
105
+ + F("model_usage__cache_write_tokens")
106
+ + (F("model_usage__cache_read_tokens") + CACHE_READ_DIVISOR - 1)
107
+ / CACHE_READ_DIVISOR
108
+ ),
109
+ model_requests=Count("model_usage"),
110
+ model_cost_usd=Sum("model_usage__cost_usd"),
111
+ unpriced_requests=Count(
112
+ "model_usage", filter=Q(model_usage__cost_usd__isnull=True)
113
+ ),
114
+ )
115
+
116
+
117
+ def find_workspace_run(workspace_id: int | None, run_id: int) -> Run | None:
118
+ """The workspace's run with its job, task, repository and usage."""
119
+ return (
120
+ with_model_usage(
121
+ Run.objects.select_related("job__repository", "task__repository")
122
+ )
123
+ .filter(task__repository__workspace_id=workspace_id, pk=run_id)
124
+ .first()
125
+ )
126
+
127
+
128
+ def workspace_runs(workspace_id: int | None) -> QuerySet[Run]:
129
+ """The workspace's job runs and ad hoc tasks."""
130
+ return Run.objects.filter(task__repository__workspace_id=workspace_id)
131
+
132
+
133
+ def runs_by_id(run_ids: Iterable[int]) -> dict[int, Run]:
134
+ return Run.objects.in_bulk(list(run_ids))
135
+
136
+
137
+ def job_runs_by_id(job_id: int, run_ids: Iterable[int]) -> dict[int, Run]:
138
+ """These runs, only where they belong to the job."""
139
+ return Run.objects.filter(job_id=job_id).in_bulk(list(run_ids))
140
+
141
+
142
+ def newest_job_runs(job: Job) -> QuerySet[Run]:
143
+ """The job's runs with their repository, newest first."""
144
+ return job.runs.select_related("job__repository").order_by(*NEWEST_FIRST)
145
+
146
+
147
+ def succeeded_run_ids(job: Job, count: int) -> list[int]:
148
+ """The job's newest successful runs, newest first."""
149
+ runs = Run.objects.filter(job=job, status=Run.Status.SUCCEEDED)
150
+ return list(runs.order_by("-created_at").values_list("pk", flat=True)[:count])
151
+
152
+
153
+ def job_run_for_slot(job: Job, scheduled_for: datetime) -> Run | None:
154
+ """The run already created for this scheduled time, if any."""
155
+ return job.runs.filter(scheduled_for=scheduled_for).first()
156
+
157
+
158
+ def active_runs() -> QuerySet[Run]:
159
+ """Runs that have not finished yet."""
160
+ return Run.objects.filter(status__in=Run.ACTIVE_STATUSES)
161
+
162
+
163
+ def latest_active_run(job: Job) -> Run | None:
164
+ """The job's newest unfinished run, if it has one."""
165
+ return active_runs().filter(job=job).order_by("-pk").first()
166
+
167
+
168
+ def active_tasks(repository_id: int) -> QuerySet[Run]:
169
+ """Unfinished ad hoc task runs in the repository."""
170
+ return active_runs().filter(job__isnull=True, task__repository_id=repository_id)
171
+
172
+
173
+ def active_task_count(repository_id: int) -> int:
174
+ return active_tasks(repository_id).count()
175
+
176
+
177
+ def has_dispatched_task(repository_id: int) -> bool:
178
+ """Whether an unfinished task in the repository is already with the queue."""
179
+ return active_tasks(repository_id).exclude(dbos_workflow_id="").exists()
180
+
181
+
182
+ def repository_task_run(repository_id: int, run_id: int) -> Run:
183
+ """The repository's ad hoc task run, with its task."""
184
+ return (
185
+ Run.objects.select_related("task")
186
+ .filter(job__isnull=True, task__repository_id=repository_id)
187
+ .get(pk=run_id)
188
+ )
189
+
190
+
191
+ def first_due_task(repository_id: int, now: datetime) -> Run | None:
192
+ """The repository's oldest queued task that is due and not yet dispatched."""
193
+ return (
194
+ Run.objects.filter(
195
+ job__isnull=True,
196
+ task__repository_id=repository_id,
197
+ status=Run.Status.QUEUED,
198
+ scheduled_for__lte=now,
199
+ dbos_workflow_id="",
200
+ )
201
+ .order_by("scheduled_for", "created_at", "pk")
202
+ .first()
203
+ )
204
+
205
+
206
+ def waiting_tasks(now: datetime, limit: int) -> list[Run]:
207
+ """Due tasks not yet dispatched, in repositories with nothing dispatched."""
208
+ blocked = (
209
+ active_runs()
210
+ .filter(job__isnull=True, task__repository_id=OuterRef("task__repository_id"))
211
+ .exclude(dbos_workflow_id="")
212
+ )
213
+ return list(
214
+ Run.objects.filter(
215
+ job__isnull=True,
216
+ status=Run.Status.QUEUED,
217
+ dbos_workflow_id="",
218
+ scheduled_for__lte=now,
219
+ )
220
+ .filter(~Exists(blocked))
221
+ .select_related("task")
222
+ .order_by("scheduled_for", "created_at", "pk")[:limit]
223
+ )
224
+
225
+
226
+ def active_workflows(limit: int) -> list[tuple[int, str]]:
227
+ """Unfinished runs that the queue accepted, as (run id, workflow id)."""
228
+ return list(
229
+ active_runs()
230
+ .exclude(dbos_workflow_id="")
231
+ .values_list("pk", "dbos_workflow_id")[:limit]
232
+ )
233
+
234
+
235
+ def run_for_repeat_key(job: Job, key: str, finished_since: datetime) -> Run | None:
236
+ """The job's newest run for this event that is active or finished since then."""
237
+ return (
238
+ Run.objects.filter(job=job, input_snapshot__repeat_key=key)
239
+ .filter(Q(status__in=Run.ACTIVE_STATUSES) | Q(finished_at__gte=finished_since))
240
+ .exclude(ENQUEUE_FAILED)
241
+ .order_by("-pk")
242
+ .first()
243
+ )
244
+
245
+
246
+ def started_run_count(job: Job, started_by: str, since: datetime) -> int:
247
+ """How many runs this source started for the job since then."""
248
+ return (
249
+ Run.objects.filter(job=job, started_by=started_by, created_at__gte=since)
250
+ .exclude(ENQUEUE_FAILED)
251
+ .count()
252
+ )
253
+
254
+
255
+ def finished_runs_before(cutoff: datetime) -> list[Run]:
256
+ """Finished runs that ended before the cutoff, with their jobs."""
257
+ return list(
258
+ Run.objects.filter(
259
+ status__in=Run.FINISHED_STATUSES, finished_at__lt=cutoff
260
+ ).select_related("job")
261
+ )
262
+
263
+
264
+ @transaction.atomic
265
+ def delete_runs(runs: list[Run]) -> None:
266
+ """Delete the runs, and their tasks once no other attempt is left."""
267
+ task_ids = {run.task_id for run in runs if run.task_id is not None}
268
+ Run.objects.filter(pk__in=[run.pk for run in runs]).delete()
269
+ # Never sweep unrelated assignments here.
270
+ Task.objects.filter(pk__in=task_ids, runs__isnull=True).delete()
271
+
272
+
273
+ # Reporting
274
+
275
+
276
+ def run_counts(workspace_id: int, start: datetime, previous_start: datetime) -> dict:
277
+ """Runs since `start`, finished and succeeded among them, and runs before."""
278
+ recent = Q(created_at__gte=start)
279
+ return workspace_runs(workspace_id).aggregate(
280
+ runs=Count("pk", filter=recent),
281
+ previous=Count(
282
+ "pk", filter=Q(created_at__gte=previous_start, created_at__lt=start)
283
+ ),
284
+ finished=Count("pk", filter=recent & Q(status__in=Run.FINISHED_STATUSES)),
285
+ succeeded=Count("pk", filter=recent & Q(status=Run.Status.SUCCEEDED)),
286
+ )
287
+
288
+
289
+ def run_counts_today(workspace_id: int, today: date) -> dict:
290
+ """Runs created today, and failed runs at any time."""
291
+ return workspace_runs(workspace_id).aggregate(
292
+ today=Count("pk", filter=Q(created_at__date=today)),
293
+ failed=Count("pk", filter=Q(status=Run.Status.FAILED)),
294
+ )
295
+
296
+
297
+ def finished_runs_per_day(
298
+ workspace_id: int, first: date, last: date
299
+ ) -> list[dict[str, Any]]:
300
+ """Finished runs per local day and status, as rows of day, status and count."""
301
+ return list(
302
+ workspace_runs(workspace_id)
303
+ .filter(
304
+ status__in=Run.FINISHED_STATUSES,
305
+ created_at__date__gte=first,
306
+ created_at__date__lte=last,
307
+ )
308
+ .annotate(day=TruncDate("created_at"))
309
+ .values("day", "status")
310
+ .annotate(count=Count("pk"))
311
+ .order_by()
312
+ )
313
+
314
+
315
+ def search_runs(runs: QuerySet[Run], q: str) -> QuerySet[Run]:
316
+ """Runs whose job, repository, task name or summary contains the text."""
317
+ q = q.strip()
318
+ if not q:
319
+ return runs
320
+ match = (
321
+ Q(job__name__icontains=q)
322
+ | Q(job__key__icontains=q)
323
+ | Q(task__repository__full_name__icontains=q)
324
+ | Q(input_snapshot__name__icontains=q)
325
+ | Q(summary__icontains=q)
326
+ )
327
+ # "#42" or "42" also finds run 42.
328
+ if (number := q.removeprefix("#")).isdigit():
329
+ match |= Q(pk=int(number))
330
+ return runs.filter(match)
331
+
332
+
333
+ def run_listing(
334
+ workspace_id: int,
335
+ *,
336
+ q: str,
337
+ statuses: Collection[str],
338
+ job_id: int | None,
339
+ repository_id: int | None,
340
+ since: datetime | None,
341
+ by_cost: bool,
342
+ page: int,
343
+ page_size: int,
344
+ ) -> tuple[list[Run], int, int, dict[str, int]]:
345
+ """One page of matching runs, with usage.
346
+
347
+ Returns the runs, how many match, the page actually shown (the last one
348
+ when `page` is past it), and counts per status before the status filter.
349
+ """
350
+ runs = workspace_runs(workspace_id)
351
+ if job_id is not None:
352
+ runs = runs.filter(job_id=job_id)
353
+ if repository_id is not None:
354
+ runs = runs.filter(task__repository_id=repository_id)
355
+ if since is not None:
356
+ runs = runs.filter(created_at__gte=since)
357
+ runs = search_runs(runs, q)
358
+ # Counts ignore the status filter, so each status chip shows what it would add.
359
+ status_counts = {
360
+ row["status"]: row["total"]
361
+ for row in runs.order_by().values("status").annotate(total=Count("pk"))
362
+ }
363
+ if statuses:
364
+ runs = runs.filter(status__in=statuses)
365
+ count = runs.count()
366
+ last_page = max((count + page_size - 1) // page_size, 1)
367
+ page = min(page, last_page)
368
+ start = (page - 1) * page_size
369
+ page_slice = slice(start, start + page_size)
370
+ related = ("job", "task__repository")
371
+ if by_cost:
372
+ # Cost comes from usage, so aggregate every run before taking the page.
373
+ ordered = with_model_usage(runs.select_related(*related)).order_by(
374
+ F("model_cost_usd").desc(nulls_last=True), *NEWEST_FIRST
375
+ )[page_slice]
376
+ else:
377
+ # Take the page's run IDs first, then aggregate usage for those runs.
378
+ page_ids = runs.order_by(*NEWEST_FIRST).values("pk")[page_slice]
379
+ page_runs = Run.objects.filter(pk__in=Subquery(page_ids))
380
+ ordered = with_model_usage(page_runs.select_related(*related)).order_by(
381
+ *NEWEST_FIRST
382
+ )
383
+ return list(ordered), count, page, status_counts
384
+
385
+
386
+ # Tasks
387
+
388
+
389
+ def create_task(**fields: Any) -> Task:
390
+ return Task.objects.create(**fields)
391
+
392
+
393
+ def find_submitted_task(repository_id: int, submission_key: str) -> Task | None:
394
+ """The repository's task submitted with this idempotency key, if any."""
395
+ return Task.objects.filter(
396
+ repository_id=repository_id, submission_key=submission_key
397
+ ).first()
398
+
399
+
400
+ def first_attempt(task: Task) -> Run | None:
401
+ return task.runs.order_by("created_at", "pk").first()
402
+
403
+
404
+ # Run events
405
+
406
+
407
+ def add_event(
408
+ run_id: int,
409
+ message: str,
410
+ *,
411
+ level: str = "info",
412
+ metadata: dict[str, Any] | None = None,
413
+ ) -> RunEvent:
414
+ """Add a line to the run's timeline."""
415
+ return RunEvent.objects.create(
416
+ run_id=run_id, message=message, level=level, metadata=metadata or {}
417
+ )
418
+
419
+
420
+ def add_event_once(run_id: int, message: str, level: str) -> None:
421
+ """Add a line to the run's timeline unless it is already there."""
422
+ RunEvent.objects.get_or_create(
423
+ run_id=run_id, message=message, defaults={"level": level}
424
+ )
425
+
426
+
427
+ def run_events(run: Run) -> QuerySet[RunEvent]:
428
+ """The run's timeline, oldest first."""
429
+ return run.events.order_by("created_at", "pk")
430
+
431
+
432
+ def subagent_start(run_id: int, marker: str, call_id: str) -> RunEvent | None:
433
+ """The start already recorded for this sub-agent call, if any."""
434
+ return RunEvent.objects.filter(
435
+ run_id=run_id, metadata__subagent=marker, metadata__call_id=call_id
436
+ ).first()
437
+
438
+
439
+ def subagent_event_count(run_id: int, marker: str) -> int:
440
+ return RunEvent.objects.filter(run_id=run_id, metadata__subagent=marker).count()
441
+
442
+
443
+ def has_subagent_event(run_id: int, marker: str, call_id: str) -> bool:
444
+ return RunEvent.objects.filter(
445
+ run_id=run_id, metadata__subagent=marker, metadata__call_id=call_id
446
+ ).exists()
447
+
448
+
449
+ # Deliveries
450
+
451
+
452
+ def create_delivery(run: Run, kind: str, target: str) -> RunDelivery:
453
+ return RunDelivery.objects.create(run=run, kind=kind, target=target)
454
+
455
+
456
+ def run_delivery(run: Run, kind: str, delivered: bool = False) -> RunDelivery | None:
457
+ """The run's delivery of this kind. With `delivered`, only a delivered one."""
458
+ deliveries = run.deliveries.filter(kind=kind)
459
+ if delivered:
460
+ deliveries = deliveries.filter(status=RunDelivery.Status.DELIVERED)
461
+ return deliveries.first()
462
+
463
+
464
+ def lock_delivery(run: Run, delivery_id: int) -> RunDelivery:
465
+ """Lock the run's delivery, with its connection. Raises when missing."""
466
+ return (
467
+ RunDelivery.objects.select_for_update()
468
+ .select_related("connection")
469
+ .get(pk=delivery_id, run=run)
470
+ )
471
+
472
+
473
+ def notification_delivery(run: Run, connection: Connection) -> RunDelivery:
474
+ """The run's notification delivery to this connection, created when missing."""
475
+ delivery, _ = RunDelivery.objects.get_or_create(
476
+ run=run,
477
+ kind=RunDelivery.Kind.NOTIFICATION,
478
+ connection=connection,
479
+ defaults={"target": connection.binding_key or connection.display_name},
480
+ )
481
+ return delivery
482
+
483
+
484
+ def finish_delivery(
485
+ delivery: RunDelivery, status: str, message: str, *, announce: bool = True
486
+ ) -> None:
487
+ """Save the delivery's outcome, and say it in the run's events."""
488
+ delivery.status = status
489
+ delivery.message = message
490
+ delivery.save(
491
+ update_fields=[
492
+ "status",
493
+ "message",
494
+ "attempts",
495
+ "status_code",
496
+ "external_url",
497
+ "updated_at",
498
+ ]
499
+ )
500
+ if announce:
501
+ add_event(
502
+ delivery.run_id,
503
+ f"{delivery.event_name}: {message}",
504
+ level="warning" if status == RunDelivery.Status.FAILED else "info",
505
+ metadata={
506
+ "delivery_id": delivery.pk,
507
+ "connection_id": delivery.connection_id,
508
+ "status_code": delivery.status_code,
509
+ "url": delivery.external_url,
510
+ },
511
+ )
512
+
513
+
514
+ def fail_notification_delivery(run_id: int, connection_id: int, now: datetime) -> None:
515
+ """Mark the notification failed, unless it was delivered."""
516
+ RunDelivery.objects.filter(
517
+ run_id=run_id,
518
+ kind=RunDelivery.Kind.NOTIFICATION,
519
+ connection_id=connection_id,
520
+ ).exclude(status=RunDelivery.Status.DELIVERED).update(
521
+ status=RunDelivery.Status.FAILED, updated_at=now
522
+ )
523
+
524
+
525
+ # Issues
526
+
527
+
528
+ def has_proposed_issues(run: Run) -> bool:
529
+ return run.issues.exists()
530
+
531
+
532
+ def unopened_issues(run: Run) -> QuerySet[RunIssue]:
533
+ return run.issues.filter(number__isnull=True)
534
+
535
+
536
+ def opened_issues(run: Run) -> list[RunIssue]:
537
+ return list(run.issues.exclude(number__isnull=True))
538
+
539
+
540
+ def proposed_issue(run_id: int, call_id: str) -> bool:
541
+ """Whether this tool call already proposed an issue."""
542
+ return RunIssue.objects.filter(run_id=run_id, call_id=call_id).exists()
543
+
544
+
545
+ def proposed_issue_titles(run_id: int) -> list[str]:
546
+ return list(RunIssue.objects.filter(run_id=run_id).values_list("title", flat=True))
547
+
548
+
549
+ def proposed_issue_count(run_id: int) -> int:
550
+ return RunIssue.objects.filter(run_id=run_id).count()
551
+
552
+
553
+ def create_issue_proposal(run_id: int, call_id: str, title: str, body: str) -> RunIssue:
554
+ return RunIssue.objects.create(
555
+ run_id=run_id, call_id=call_id, title=title, body=body
556
+ )
557
+
558
+
559
+ # Access the agent needed
560
+
561
+
562
+ def access_need_for_call(run_id: int, call_id: str) -> RunAccessNeed | None:
563
+ return RunAccessNeed.objects.filter(run_id=run_id, call_id=call_id).first()
564
+
565
+
566
+ def run_access_needs(run_id: int) -> list[RunAccessNeed]:
567
+ return list(RunAccessNeed.objects.filter(run_id=run_id))
568
+
569
+
570
+ def create_access_need(
571
+ run_id: int, call_id: str, service: str, tools: list[str], reason: str
572
+ ) -> RunAccessNeed:
573
+ return RunAccessNeed.objects.create(
574
+ run_id=run_id, call_id=call_id, service=service, tools=tools, reason=reason
575
+ )
576
+
577
+
578
+ def update_access_need_tools(need: RunAccessNeed, tools: list[str]) -> None:
579
+ need.tools = tools
580
+ need.save(update_fields=["tools"])
581
+
582
+
583
+ # Agent leases
584
+
585
+
586
+ def run_has_lease(run: Run) -> bool:
587
+ return AgentLease.objects.filter(run=run).exists()
588
+
589
+
590
+ def create_lease(run: Run, token_hash: str, session_id: str) -> AgentLease:
591
+ return AgentLease.objects.create(
592
+ run=run, token_hash=token_hash, session_id=session_id
593
+ )
594
+
595
+
596
+ def end_lease(lease_id: int) -> None:
597
+ AgentLease.objects.filter(pk=lease_id).update(active=False)
598
+
599
+
600
+ def lock_lease(lease_id: int) -> AgentLease:
601
+ return AgentLease.objects.select_for_update().get(pk=lease_id)
602
+
603
+
604
+ def active_lease(token_hash: str) -> AgentLease | None:
605
+ return AgentLease.objects.filter(token_hash=token_hash, active=True).first()
606
+
607
+
608
+ def with_last_run(jobs: QuerySet[Job]) -> QuerySet[Job]:
609
+ """Annotate each job with its newest run, so a list needs no query per job."""
610
+ newest = Run.objects.filter(job=OuterRef("pk")).order_by("-created_at", "-pk")
611
+ return jobs.annotate(
612
+ last_run_id=Subquery(newest.values("pk")[:1]),
613
+ last_run_status=Subquery(newest.values("status")[:1]),
614
+ last_run_at=Subquery(
615
+ newest.annotate(at=Coalesce("finished_at", "created_at")).values("at")[:1]
616
+ ),
617
+ )
618
+
619
+
620
+ def job_with_last_run(job_id: int) -> Job:
621
+ return with_last_run(Job.objects.filter(pk=job_id)).get()
622
+
623
+
624
+ def jobs_whose_last_run_failed(workspace_id: int) -> list[Job]:
625
+ """Active jobs whose newest finished run failed, by name.
626
+
627
+ Each job gets `latest_run_id`.
628
+ """
629
+ latest = Run.objects.filter(
630
+ job=OuterRef("pk"), status__in=Run.FINISHED_STATUSES
631
+ ).order_by("-created_at", "-pk")
632
+ return list(
633
+ active_jobs(workspace_id)
634
+ .annotate(
635
+ latest_run_id=Subquery(latest.values("pk")[:1]),
636
+ latest_status=Subquery(latest.values("status")[:1]),
637
+ )
638
+ .filter(latest_status=Run.Status.FAILED)
639
+ .order_by("name")
640
+ )