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,51 @@
1
+ """DBOS schedules: one per enabled job with a cron schedule."""
2
+
3
+ from dbos import DBOSClient
4
+ from django.conf import settings
5
+
6
+ from secondshift.config.constants import SCHEDULED_JOB_WORKFLOW
7
+ from secondshift.jobs.dao import schedulable_jobs
8
+ from secondshift.runs.orchestration.dbos_runtime import new_dbos_client
9
+
10
+ SCHEDULE_PREFIX = "secondshift-job-"
11
+
12
+
13
+ def schedule_name(job_id: int) -> str:
14
+ return f"{SCHEDULE_PREFIX}{job_id}"
15
+
16
+
17
+ def desired_schedules() -> list[dict[str, object]]:
18
+ return [
19
+ {
20
+ "schedule_name": schedule_name(job.pk),
21
+ "workflow_name": SCHEDULED_JOB_WORKFLOW,
22
+ "workflow_class_name": None,
23
+ "schedule": job.cron,
24
+ "context": {"job_id": job.pk},
25
+ "automatic_backfill": False,
26
+ "cron_timezone": job.timezone,
27
+ "queue_name": settings.DBOS_QUEUE_NAME,
28
+ "application_name": settings.DBOS_APPLICATION_NAME,
29
+ }
30
+ for job in schedulable_jobs()
31
+ ]
32
+
33
+
34
+ def synchronize_schedules(client: DBOSClient | None = None) -> int:
35
+ schedules = desired_schedules()
36
+ desired_names = {str(item["schedule_name"]) for item in schedules}
37
+ owns_client = client is None
38
+ client = client or new_dbos_client()
39
+ try:
40
+ client.apply_schedules(schedules) # type: ignore[arg-type]
41
+ existing = client.list_schedules(
42
+ schedule_name_prefix=SCHEDULE_PREFIX,
43
+ application_name=settings.DBOS_APPLICATION_NAME,
44
+ )
45
+ for schedule in existing:
46
+ if schedule["schedule_name"] not in desired_names:
47
+ client.delete_schedule(schedule["schedule_name"])
48
+ finally:
49
+ if owns_client:
50
+ client.destroy()
51
+ return len(schedules)
@@ -0,0 +1,480 @@
1
+ import logging
2
+ import tempfile
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from dbos import DBOS
8
+ from django.db import transaction
9
+ from django.utils import timezone
10
+
11
+ from secondshift.config.constants import (
12
+ EXECUTE_RUN_WORKFLOW,
13
+ RETRY_ISSUES_WORKFLOW,
14
+ RETRY_NOTIFICATION_WORKFLOW,
15
+ RETRY_PULL_REQUEST_WORKFLOW,
16
+ SANDBOX_SKILL_DIR,
17
+ SCHEDULED_JOB_WORKFLOW,
18
+ RunSandbox,
19
+ )
20
+ from secondshift.connections.dao import connection_binding, get_connection
21
+ from secondshift.connections.webhooks import deliver_webhook
22
+ from secondshift.jobs.dao import scheduled_job
23
+ from secondshift.jobs.readiness import execution_problems
24
+ from secondshift.jobs.scheduling import next_occurrence
25
+ from secondshift.repositories.dao import lock_repository
26
+ from secondshift.repositories.external_skills import skill_snapshot
27
+ from secondshift.repositories.github_access import github_access
28
+ from secondshift.repositories.models import Repository
29
+ from secondshift.repositories.sources import source_snapshot
30
+ from secondshift.runner.client import get_runner_client
31
+ from secondshift.runner.protocol import RunnerSessionSpec
32
+ from secondshift.runs.artifacts import read_output_excerpt, store_collection
33
+ from secondshift.runs.dao import (
34
+ add_event,
35
+ fail_notification_delivery,
36
+ finish_delivery,
37
+ get_run,
38
+ notification_delivery,
39
+ update_run,
40
+ )
41
+ from secondshift.runs.execution.budgets import Budget, budget_reached_message
42
+ from secondshift.runs.execution.deadlines import run_deadline, timeout_failure
43
+ from secondshift.runs.execution.result import AgentResult
44
+ from secondshift.runs.execution.run import execute_agent_run
45
+ from secondshift.runs.history import pack_earlier_files
46
+ from secondshift.runs.issue_delivery import open_run_issues
47
+ from secondshift.runs.models import Run, RunDelivery, RunFailure
48
+ from secondshift.runs.orchestration.admission import task_admission_problem
49
+ from secondshift.runs.orchestration.reconciliation import (
50
+ WorkflowStatus,
51
+ mark_run_terminal,
52
+ )
53
+ from secondshift.runs.publishing import publish_run
54
+ from secondshift.runs.reporting.collection import collection_summary
55
+ from secondshift.runs.reporting.failures import (
56
+ StageFailure,
57
+ failure_metadata,
58
+ stage_failure,
59
+ )
60
+ from secondshift.runs.reporting.notifications import (
61
+ notification_targets,
62
+ run_report_payload,
63
+ )
64
+ from secondshift.runs.services import create_scheduled_run
65
+ from secondshift.runs.transitions import finish_run, mark_running
66
+
67
+ logger = logging.getLogger(__name__)
68
+
69
+
70
+ @DBOS.step(name="secondshift.create_scheduled_run")
71
+ def create_scheduled_run_step(scheduled_time: datetime, context: Any) -> int | None:
72
+ job_id = int(context["job_id"])
73
+ job = scheduled_job(job_id)
74
+ if job is None:
75
+ logger.info("skipping disabled or deleted scheduled job %s", job_id)
76
+ return None
77
+
78
+ run = create_scheduled_run(job, scheduled_time)
79
+ job.next_run_at = next_occurrence(job, after=scheduled_time)
80
+ job.save(update_fields=["next_run_at", "updated_at"])
81
+ if run is None:
82
+ logger.info("skipped scheduled job %s: a run has not finished", job_id)
83
+ return None
84
+ return run.pk
85
+
86
+
87
+ class RunNotReadyError(RuntimeError):
88
+ """The run's access plan was not ready when it was created.
89
+
90
+ A RuntimeError, so its message, which SecondShift writes, shows in the summary.
91
+ """
92
+
93
+
94
+ @DBOS.step(name="secondshift.mark_run_started")
95
+ @transaction.atomic
96
+ def mark_run_started_step(run_id: int) -> bool:
97
+ """Start a queued run. Raises RunNotReadyError before any work is done."""
98
+ run = get_run(run_id, "task")
99
+ if run.job_id is None:
100
+ repository = lock_repository(run.task.repository_id)
101
+ run.refresh_from_db()
102
+ if problem := task_admission_problem(run, repository):
103
+ mark_run_terminal(
104
+ run.pk,
105
+ Run.Status.FAILED,
106
+ problem,
107
+ workflow_status=WorkflowStatus.NOT_ADMITTED,
108
+ )
109
+ return False
110
+ if run.status == Run.Status.RUNNING and run.dbos_workflow_id == DBOS.workflow_id:
111
+ # The application commit may precede DBOS persisting this step's result.
112
+ return True
113
+ plan = run.plan
114
+ if run.status == Run.Status.QUEUED and not plan.ready:
115
+ # A scheduled tick creates the run even when the job lost its access.
116
+ problems = execution_problems(run.repository, plan)
117
+ raise RunNotReadyError(
118
+ " ".join(problems) or "The frozen execution plan is not ready."
119
+ )
120
+ started = mark_running(run_id, DBOS.workflow_id or run.dbos_workflow_id)
121
+ if started:
122
+ add_event(run_id, "Run started.")
123
+ return bool(started)
124
+
125
+
126
+ @DBOS.step(name="secondshift.record_run_event")
127
+ def record_run_event_step(run_id: int, message: str) -> None:
128
+ add_event(run_id, message)
129
+
130
+
131
+ @DBOS.step(
132
+ name="secondshift.prepare_source",
133
+ retries_allowed=True,
134
+ max_attempts=3,
135
+ )
136
+ def prepare_source_step(run_id: int) -> None:
137
+ """Build the snapshot of the run's commit, fetching it if needed."""
138
+ run = get_run(run_id, "job__repository", "task__repository")
139
+ repository = run.repository
140
+ if repository.source_type != Repository.SourceType.LOCAL:
141
+ # Lost access stops the run here, even if the snapshot is cached.
142
+ github_access(repository)
143
+ snapshot = source_snapshot(repository, run.commit_sha)
144
+ add_event(
145
+ run.pk,
146
+ f"Checked out the repository at {run.commit_sha[:12]} "
147
+ f"({snapshot.size_bytes / (1024 * 1024):.1f} MiB compressed).",
148
+ )
149
+ job = run.plan.job
150
+ if source := job.skill_source:
151
+ skill_snapshot(source.repository, job.skill_path, source.commit_sha)
152
+ add_event(
153
+ run.pk,
154
+ f"Fetched the skill {job.skill_path} from {source.repository} "
155
+ f"at {source.commit_sha[:12]}. The sandbox reads it at "
156
+ f"{SANDBOX_SKILL_DIR}.",
157
+ )
158
+
159
+
160
+ @DBOS.step(
161
+ name="secondshift.start_runner_session",
162
+ retries_allowed=True,
163
+ max_attempts=3,
164
+ )
165
+ def start_runner_session_step(run_id: int) -> str:
166
+ run = get_run(run_id, "job__repository", "task__repository")
167
+ plan = run.plan
168
+ if plan.repository.config_sha != run.commit_sha:
169
+ raise RuntimeError("The run commit does not match its execution plan.")
170
+ snapshot = source_snapshot(run.repository, run.commit_sha)
171
+ client = get_runner_client()
172
+ client.upload_source(snapshot.sha256, snapshot.path)
173
+ with tempfile.TemporaryDirectory(prefix="secondshift-history-") as folder:
174
+ archive = Path(folder) / "history.tar.gz"
175
+ history = pack_earlier_files(run, archive)
176
+ if history is not None:
177
+ client.upload_source(history.sha256, archive)
178
+ inputs = run.inputs.model_copy(update={"history_files": history.files})
179
+ update_run(run.pk, input_snapshot=inputs.stored())
180
+ skill_sha256 = ""
181
+ if source := plan.job.skill_source:
182
+ skill = skill_snapshot(
183
+ source.repository, plan.job.skill_path, source.commit_sha
184
+ )
185
+ client.upload_source(skill.sha256, skill.path)
186
+ skill_sha256 = skill.sha256
187
+ session_id = client.start(
188
+ RunnerSessionSpec(
189
+ run_id=run.pk,
190
+ session_key=run.runner_session_key.hex,
191
+ repository=plan.repository.full_name,
192
+ commit_sha=run.commit_sha,
193
+ image=plan.runner.image,
194
+ source_sha256=snapshot.sha256,
195
+ collect_changes=plan.runner.collect_changes,
196
+ history_sha256=history.sha256 if history is not None else "",
197
+ skill_sha256=skill_sha256,
198
+ )
199
+ )
200
+ update_run(run.pk, sandbox_runtime=RunSandbox.ISOLATED_RUNNER)
201
+ add_event(run.pk, "Sandbox started without network access.")
202
+ return session_id
203
+
204
+
205
+ @DBOS.step(
206
+ name="secondshift.collect_run_outputs",
207
+ retries_allowed=True,
208
+ max_attempts=3,
209
+ )
210
+ def collect_outputs_step(run_id: int, session_id: str) -> None:
211
+ """Keep the agent's file changes and saved files before the sandbox goes."""
212
+ run = get_run(run_id, "job", "task__repository")
213
+ with tempfile.TemporaryDirectory(prefix="secondshift-collection-") as folder:
214
+ archive = Path(folder) / "collection.tar.gz"
215
+ get_runner_client().collect(session_id, archive)
216
+ outputs = store_collection(run, archive)
217
+ update_run(run_id, outputs=outputs.stored())
218
+ if message := collection_summary(outputs):
219
+ add_event(run_id, message)
220
+
221
+
222
+ @DBOS.step(name="secondshift.record_collection_failure")
223
+ def record_collection_failure_step(run_id: int) -> None:
224
+ add_event(
225
+ run_id,
226
+ "The run's file changes and saved files could not be collected.",
227
+ level="warning",
228
+ )
229
+
230
+
231
+ def collect_outputs(run_id: int, session_id: str) -> None:
232
+ # The report is already stored, so a failure here never changes the outcome.
233
+ try:
234
+ collect_outputs_step(run_id, session_id)
235
+ except Exception:
236
+ logger.warning("could not collect outputs for run %s", run_id)
237
+ record_collection_failure_step(run_id)
238
+
239
+
240
+ @DBOS.step(
241
+ name="secondshift.stop_runner_session",
242
+ retries_allowed=True,
243
+ max_attempts=3,
244
+ )
245
+ def stop_runner_session_step(session_id: str) -> None:
246
+ get_runner_client().stop(session_id)
247
+
248
+
249
+ @DBOS.step(name="secondshift.finish_run")
250
+ @transaction.atomic
251
+ def finish_run_step(run_id: int, stored: dict[str, Any]) -> None:
252
+ result = AgentResult.model_validate(stored)
253
+ status = Run.Status.SUCCEEDED if result.succeeded else Run.Status.FAILED
254
+ fields: dict[str, object] = {"exit_code": result.exit_code}
255
+ if output := result.output:
256
+ fields |= {
257
+ "output_path": output.path,
258
+ "output_size_bytes": output.size_bytes,
259
+ "output_sha256": output.sha256,
260
+ "output_truncated": output.truncated,
261
+ "output_excerpt": read_output_excerpt(output.path),
262
+ }
263
+ run = finish_run(
264
+ run_id,
265
+ status,
266
+ result.summary,
267
+ only_from={Run.Status.RUNNING},
268
+ fields=fields,
269
+ failure=result.failure,
270
+ reason=result.reason,
271
+ )
272
+ if run is None:
273
+ # A replay, or the run was canceled while it ran. Its result stands.
274
+ logger.info("run %s had already finished", run_id)
275
+ return
276
+ if reached := result.details.get("budget_reached"):
277
+ add_event(
278
+ run.pk,
279
+ budget_reached_message(Budget(reached)),
280
+ level="warning",
281
+ metadata=result.details,
282
+ )
283
+ add_event(
284
+ run.pk,
285
+ "Run succeeded. The report is stored as the run's output."
286
+ if result.succeeded
287
+ else result.summary,
288
+ level="info" if result.succeeded else "error",
289
+ metadata=result.details,
290
+ )
291
+
292
+
293
+ class RetryableDeliveryError(Exception):
294
+ pass
295
+
296
+
297
+ @DBOS.step(name="secondshift.notification_targets")
298
+ def notification_targets_step(run_id: int) -> list[int]:
299
+ return notification_targets(get_run(run_id, "job"))
300
+
301
+
302
+ @DBOS.step(
303
+ name="secondshift.deliver_run_notification",
304
+ retries_allowed=True,
305
+ max_attempts=3,
306
+ )
307
+ def deliver_run_notification_step(run_id: int, connection_id: int) -> None:
308
+ # One step per target, so a retry never re-sends to targets that succeeded.
309
+ run = get_run(run_id, "job__repository", "task__repository")
310
+ connection = get_connection(connection_id)
311
+ delivery = notification_delivery(run, connection)
312
+ if delivery.status == RunDelivery.Status.DELIVERED:
313
+ return
314
+ # A grant revoked or a connection disconnected since planning stops delivery,
315
+ # including when a failed delivery is retried later.
316
+ if connection_id not in notification_targets(run):
317
+ finish_delivery(
318
+ delivery, RunDelivery.Status.SKIPPED, "No longer approved.", announce=False
319
+ )
320
+ return
321
+ result = deliver_webhook(connection, run_report_payload(run))
322
+ delivery.attempts += 1
323
+ delivery.status_code = result.status_code
324
+ if result.retryable:
325
+ finish_delivery(
326
+ delivery, RunDelivery.Status.RETRYING, result.message, announce=False
327
+ )
328
+ raise RetryableDeliveryError(result.message)
329
+ finish_delivery(
330
+ delivery,
331
+ RunDelivery.Status.DELIVERED if result.ok else RunDelivery.Status.FAILED,
332
+ result.message,
333
+ )
334
+
335
+
336
+ @DBOS.step(name="secondshift.record_notification_failure")
337
+ def record_notification_failure_step(run_id: int, connection_id: int) -> None:
338
+ binding = connection_binding(connection_id)
339
+ # The last attempt's message stays, since it says why delivery failed.
340
+ fail_notification_delivery(run_id, connection_id, timezone.now())
341
+ add_event(
342
+ run_id,
343
+ f"Notification to {binding or 'a connection'} failed after retries.",
344
+ level="warning",
345
+ metadata={"connection_id": connection_id},
346
+ )
347
+
348
+
349
+ def deliver_notification(run_id: int, connection_id: int) -> None:
350
+ try:
351
+ deliver_run_notification_step(run_id, connection_id)
352
+ except Exception:
353
+ logger.warning(
354
+ "notification for run %s to connection %s failed",
355
+ run_id,
356
+ connection_id,
357
+ )
358
+ record_notification_failure_step(run_id, connection_id)
359
+
360
+
361
+ @DBOS.step(name="secondshift.publish_pull_request")
362
+ def publish_pull_request_step(run_id: int) -> None:
363
+ publish_run(run_id)
364
+
365
+
366
+ @DBOS.workflow(name=RETRY_PULL_REQUEST_WORKFLOW)
367
+ def retry_pull_request_workflow(run_id: int) -> None:
368
+ publish_pull_request_step(run_id)
369
+
370
+
371
+ @DBOS.step(name="secondshift.open_issues")
372
+ def open_issues_step(run_id: int) -> None:
373
+ open_run_issues(run_id)
374
+
375
+
376
+ @DBOS.workflow(name=RETRY_ISSUES_WORKFLOW)
377
+ def retry_issues_workflow(run_id: int) -> None:
378
+ open_issues_step(run_id)
379
+
380
+
381
+ def deliver(run_id: int) -> None:
382
+ """Publish first, so the notification can link to the pull request and issues."""
383
+ try:
384
+ publish_pull_request_step(run_id)
385
+ except Exception:
386
+ logger.exception("could not publish run %s", run_id)
387
+ try:
388
+ open_issues_step(run_id)
389
+ except Exception:
390
+ logger.exception("could not open issues for run %s", run_id)
391
+ notify(run_id)
392
+
393
+
394
+ def notify(run_id: int) -> None:
395
+ # Delivery problems are recorded on the run but never change its outcome.
396
+ for connection_id in notification_targets_step(run_id):
397
+ deliver_notification(run_id, connection_id)
398
+
399
+
400
+ @DBOS.workflow(name=RETRY_NOTIFICATION_WORKFLOW)
401
+ def retry_notification_workflow(run_id: int, connection_id: int) -> None:
402
+ deliver_notification(run_id, connection_id)
403
+
404
+
405
+ @DBOS.step(name="secondshift.fail_run")
406
+ def fail_run_step(
407
+ run_id: int, summary: str, failure: str, reason: str, details: dict[str, object]
408
+ ) -> None:
409
+ mark_run_terminal(
410
+ run_id,
411
+ Run.Status.FAILED,
412
+ summary,
413
+ workflow_status=WorkflowStatus.ERROR,
414
+ metadata=details,
415
+ failure=RunFailure(failure),
416
+ reason=reason,
417
+ )
418
+
419
+
420
+ def execute_run(run_id: int) -> None:
421
+ session_id: str | None = None
422
+ deadline: datetime | None = None
423
+ timeout: StageFailure | None = None
424
+ stage = RunFailure.STARTING
425
+ try:
426
+ if not mark_run_started_step(run_id):
427
+ return
428
+ stage = RunFailure.CHECKOUT
429
+ prepare_source_step(run_id)
430
+ stage = RunFailure.SANDBOX
431
+ session_id = start_runner_session_step(run_id)
432
+ stage = RunFailure.SKILL
433
+ run = get_run(run_id, "job")
434
+ record_run_event_step(
435
+ run_id,
436
+ f"Running {run.plan.job.skill_path or 'the submitted task'}.",
437
+ )
438
+ deadline = run_deadline(run)
439
+ timeout = timeout_failure(run)
440
+ result = execute_agent_run(run, session_id)
441
+ stage = RunFailure.FINISHING
442
+ collect_outputs(run_id, session_id)
443
+ stop_runner_session_step(session_id)
444
+ session_id = None
445
+ finish_run_step(run_id, result.model_dump(mode="json"))
446
+ except Exception as error:
447
+ if session_id is not None:
448
+ try:
449
+ stop_runner_session_step(session_id)
450
+ except Exception:
451
+ logger.exception("failed to stop runner session %s", session_id)
452
+ details = failure_metadata(stage, error)
453
+ timed_out = deadline is not None and timezone.now() >= deadline
454
+ if stage == RunFailure.SKILL and timeout is not None and timed_out:
455
+ # The skill stopped after its time limit, so the error is the model
456
+ # call or tool call that hit the deadline.
457
+ failure = timeout
458
+ details["timed_out"] = True
459
+ else:
460
+ failure = stage_failure(stage, error)
461
+ fail_run_step(run_id, failure.summary, failure.kind, failure.reason, details)
462
+ # A failed run notifies too, so a scheduled job never fails silently.
463
+ try:
464
+ deliver(run_id)
465
+ except Exception:
466
+ logger.exception("could not notify about failed run %s", run_id)
467
+ raise
468
+ deliver(run_id)
469
+
470
+
471
+ @DBOS.workflow(name=EXECUTE_RUN_WORKFLOW)
472
+ def execute_run_workflow(run_id: int) -> None:
473
+ execute_run(run_id)
474
+
475
+
476
+ @DBOS.workflow(name=SCHEDULED_JOB_WORKFLOW)
477
+ def scheduled_job_workflow(scheduled_time: datetime, context: Any) -> None:
478
+ run_id = create_scheduled_run_step(scheduled_time, context)
479
+ if run_id is not None:
480
+ execute_run(run_id)
@@ -0,0 +1,52 @@
1
+ """What the sandbox left behind after a run, as stored in Run.outputs."""
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+
5
+
6
+ class Record(BaseModel):
7
+ model_config = ConfigDict(frozen=True)
8
+
9
+
10
+ class SavedFile(Record):
11
+ """A file the agent saved for people to download."""
12
+
13
+ name: str
14
+ path: str
15
+ size_bytes: int
16
+ sha256: str = ""
17
+
18
+
19
+ class SkippedFile(Record):
20
+ name: str
21
+ reason: str
22
+
23
+
24
+ class Changes(Record):
25
+ """The agent's changes to the repository. `path` is empty when not kept."""
26
+
27
+ file_count: int = 0
28
+ files: tuple[str, ...] = ()
29
+ too_large: bool = False
30
+ path: str = ""
31
+ size_bytes: int | None = None
32
+ sha256: str = ""
33
+
34
+ @property
35
+ def kept_file_count(self) -> int:
36
+ return self.file_count if self.path else 0
37
+
38
+
39
+ class RunOutputs(Record):
40
+ changes: Changes = Changes()
41
+ files: tuple[SavedFile, ...] = ()
42
+ skipped: tuple[SkippedFile, ...] = ()
43
+
44
+ def saved_file(self, name: str) -> SavedFile | None:
45
+ return next((item for item in self.files if item.name == name), None)
46
+
47
+ def stored_paths(self) -> list[str]:
48
+ paths = [item.path for item in self.files] + [self.changes.path]
49
+ return [path for path in paths if path]
50
+
51
+ def stored(self) -> dict:
52
+ return self.model_dump(mode="json")