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,130 @@
1
+ """Source snapshots the control plane uploads for a run to unpack.
2
+
3
+ The runner keeps no repositories. Each snapshot is named by its SHA-256, kept
4
+ only until its session starts, and swept after an hour if no session uses it.
5
+ """
6
+
7
+ import hashlib
8
+ import os
9
+ import re
10
+ import tarfile
11
+ import tempfile
12
+ import time
13
+ from pathlib import Path
14
+ from typing import IO
15
+
16
+ from django.conf import settings
17
+
18
+ from secondshift.config.constants import (
19
+ MAX_SOURCE_FILES,
20
+ MAX_SOURCE_SNAPSHOT_BYTES,
21
+ MAX_SOURCE_UNPACKED_BYTES,
22
+ )
23
+ from secondshift.runner.protocol import RunnerProtocolError
24
+
25
+ SOURCE_SHA256 = re.compile(r"[0-9a-f]{64}")
26
+ SOURCE_MAX_AGE_SECONDS = 60 * 60
27
+ CHUNK_BYTES = 1024 * 1024
28
+
29
+
30
+ def sources_root() -> Path:
31
+ return (settings.RUNNER_WORK_ROOT / "sources").resolve()
32
+
33
+
34
+ def source_path(sha256: str) -> Path:
35
+ if not SOURCE_SHA256.fullmatch(sha256):
36
+ raise RunnerProtocolError("A source is named by its SHA-256.")
37
+ return sources_root() / f"{sha256}.tar.gz"
38
+
39
+
40
+ def has_source(sha256: str) -> bool:
41
+ """Whether the snapshot is here. Asking keeps it for another hour."""
42
+ path = source_path(sha256)
43
+ try:
44
+ os.utime(path)
45
+ except FileNotFoundError:
46
+ return False
47
+ return True
48
+
49
+
50
+ def receive_source(sha256: str, stream: IO[bytes], length: int | None) -> None:
51
+ """Store an uploaded snapshot if its bytes match its name."""
52
+ target = source_path(sha256)
53
+ if length is None:
54
+ raise RunnerProtocolError("The source upload needs a Content-Length.")
55
+ if length > MAX_SOURCE_SNAPSHOT_BYTES:
56
+ raise RunnerProtocolError("The source is too large.")
57
+ root = sources_root()
58
+ root.mkdir(parents=True, exist_ok=True, mode=0o700)
59
+ sweep_sources()
60
+ descriptor, name = tempfile.mkstemp(dir=root, prefix="upload-")
61
+ temporary = Path(name)
62
+ try:
63
+ digest = hashlib.sha256()
64
+ remaining = length
65
+ with os.fdopen(descriptor, "wb") as output:
66
+ while remaining:
67
+ chunk = stream.read(min(CHUNK_BYTES, remaining))
68
+ if not chunk:
69
+ raise RunnerProtocolError("The source upload ended early.")
70
+ remaining -= len(chunk)
71
+ digest.update(chunk)
72
+ output.write(chunk)
73
+ if digest.hexdigest() != sha256:
74
+ raise RunnerProtocolError("The source does not match its SHA-256.")
75
+ os.replace(temporary, target)
76
+ finally:
77
+ temporary.unlink(missing_ok=True)
78
+
79
+
80
+ def sweep_sources() -> None:
81
+ """Remove snapshots and partial uploads no session started with."""
82
+ root = sources_root()
83
+ if not root.is_dir():
84
+ return
85
+ cutoff = time.time() - SOURCE_MAX_AGE_SECONDS
86
+ for path in root.iterdir():
87
+ try:
88
+ if path.is_file() and path.stat().st_mtime < cutoff:
89
+ path.unlink()
90
+ except FileNotFoundError:
91
+ continue
92
+
93
+
94
+ def unpack_source(sha256: str, workspace: Path) -> None:
95
+ """Unpack an uploaded snapshot into a session's empty workspace."""
96
+ path = source_path(sha256)
97
+ if not path.is_file():
98
+ raise RunnerProtocolError("The run's source files were not uploaded.")
99
+ if file_sha256(path) != sha256:
100
+ raise RunnerProtocolError("The run's source files do not match their SHA-256.")
101
+ try:
102
+ with tarfile.open(path, mode="r:gz") as archive:
103
+ check_members(archive)
104
+ archive.extractall(workspace, filter="data")
105
+ except (tarfile.TarError, OSError, EOFError) as error:
106
+ raise RunnerProtocolError(
107
+ "The runner could not unpack the run's source files."
108
+ ) from error
109
+
110
+
111
+ def check_members(archive: tarfile.TarFile) -> None:
112
+ # Stop at the first entry past a limit, so a compression bomb is never
113
+ # read to the end.
114
+ size = 0
115
+ for files, member in enumerate(archive, start=1):
116
+ size += member.size
117
+ if files > MAX_SOURCE_FILES or size > MAX_SOURCE_UNPACKED_BYTES:
118
+ raise RunnerProtocolError("The run's source files are too large.")
119
+ if not (member.isreg() or member.isdir() or member.issym()):
120
+ raise RunnerProtocolError(
121
+ "The run's source files may only hold files, folders and links."
122
+ )
123
+
124
+
125
+ def file_sha256(path: Path) -> str:
126
+ digest = hashlib.sha256()
127
+ with path.open("rb") as stream:
128
+ while chunk := stream.read(CHUNK_BYTES):
129
+ digest.update(chunk)
130
+ return digest.hexdigest()
@@ -0,0 +1,7 @@
1
+ """The runner deployment exposes only its narrow machine-to-machine API."""
2
+
3
+ from django.urls import path
4
+
5
+ from secondshift.runner.api import api
6
+
7
+ urlpatterns = [path("", api.urls)]
File without changes
@@ -0,0 +1,13 @@
1
+ from django.http import Http404, HttpRequest
2
+
3
+ from secondshift.runs.dao import find_workspace_run
4
+ from secondshift.runs.models import Run
5
+ from secondshift.workspaces.access import current_workspace
6
+
7
+
8
+ def run_for_request(request: HttpRequest, run_id: int) -> Run:
9
+ workspace = current_workspace(request)
10
+ run = find_workspace_run(workspace.pk if workspace else None, run_id)
11
+ if run is None:
12
+ raise Http404
13
+ return run
@@ -0,0 +1,89 @@
1
+ from django.contrib import admin
2
+ from django.http import FileResponse, Http404, HttpRequest
3
+ from django.urls import URLPattern, URLResolver, path, reverse
4
+ from django.utils.html import format_html
5
+
6
+ from secondshift.runs.artifacts import artifact_storage
7
+ from secondshift.runs.dao import get_run
8
+ from secondshift.runs.models import Run, RunEvent
9
+
10
+
11
+ class RunEventInline(admin.TabularInline):
12
+ model = RunEvent
13
+ extra = 0
14
+ can_delete = False
15
+ readonly_fields = ("level", "message", "metadata", "created_at")
16
+
17
+
18
+ @admin.register(Run)
19
+ class RunAdmin(admin.ModelAdmin):
20
+ list_display = (
21
+ "display_name",
22
+ "status",
23
+ "scheduled_for",
24
+ "sandbox_runtime",
25
+ "finished_at",
26
+ )
27
+ list_filter = ("status", "sandbox_runtime")
28
+ search_fields = ("job__name", "job__repository__full_name", "commit_sha")
29
+ readonly_fields = (
30
+ "job",
31
+ "task",
32
+ "status",
33
+ "commit_sha",
34
+ "scheduled_for",
35
+ "started_at",
36
+ "finished_at",
37
+ "sandbox_runtime",
38
+ "exit_code",
39
+ "dbos_workflow_id",
40
+ "summary",
41
+ "input_snapshot",
42
+ "execution_plan",
43
+ "output_excerpt",
44
+ "full_output",
45
+ "output_size_bytes",
46
+ "output_truncated",
47
+ "output_sha256",
48
+ "created_at",
49
+ )
50
+ exclude = ("output_path",)
51
+ inlines = [RunEventInline]
52
+
53
+ def get_urls(self) -> list[URLPattern | URLResolver]:
54
+ return [
55
+ path(
56
+ "<int:run_id>/output/",
57
+ self.admin_site.admin_view(self.output_view),
58
+ name="runs_run_output",
59
+ ),
60
+ *super().get_urls(),
61
+ ]
62
+
63
+ @admin.display(description="Full output")
64
+ def full_output(self, run: Run) -> str:
65
+ if not run.output_path:
66
+ return "-"
67
+ url = reverse("admin:runs_run_output", args=[run.pk])
68
+ return format_html('<a href="{}">Download output.log</a>', url)
69
+
70
+ def output_view(self, request: HttpRequest, run_id: int) -> FileResponse:
71
+ try:
72
+ run = get_run(run_id)
73
+ except Run.DoesNotExist:
74
+ raise Http404 from None
75
+ if not self.has_view_permission(request, run) or not run.output_path:
76
+ raise Http404
77
+ return FileResponse(
78
+ artifact_storage().open(run.output_path, "rb"),
79
+ as_attachment=True,
80
+ filename=f"run-{run.pk}-output.log",
81
+ content_type="text/plain; charset=utf-8",
82
+ )
83
+
84
+
85
+ @admin.register(RunEvent)
86
+ class RunEventAdmin(admin.ModelAdmin):
87
+ list_display = ("run", "level", "message", "created_at")
88
+ list_filter = ("level",)
89
+ readonly_fields = ("run", "level", "message", "metadata", "created_at")
@@ -0,0 +1,211 @@
1
+ from pathlib import PurePosixPath
2
+
3
+ from django.http import FileResponse, Http404, HttpRequest
4
+ from ninja import Query, Router, Status
5
+ from ninja.errors import HttpError
6
+ from ninja.security import django_auth
7
+
8
+ from secondshift.config.constants import RunStart
9
+ from secondshift.jobs.access import job_for_request
10
+ from secondshift.repositories.access import repository_for_request
11
+ from secondshift.runs.access import run_for_request
12
+ from secondshift.runs.artifacts import artifact_storage
13
+ from secondshift.runs.deliveries import DeliveryQueueUnavailable, retry_delivery
14
+ from secondshift.runs.models import RunDelivery
15
+ from secondshift.runs.queue import cancel_queued_task
16
+ from secondshift.runs.reporting.queries import RunFilters, run_page
17
+ from secondshift.runs.schemas import (
18
+ RunAgainSchema,
19
+ RunDeliverySchema,
20
+ RunDetailSchema,
21
+ RunFileName,
22
+ RunInProgressSchema,
23
+ RunListQuery,
24
+ RunPageSchema,
25
+ RunSchema,
26
+ TaskSubmissionSchema,
27
+ )
28
+ from secondshift.runs.serialization import (
29
+ run_file_path,
30
+ saved_file_path,
31
+ serialize_delivery,
32
+ serialize_run,
33
+ serialize_run_detail,
34
+ serialize_submission,
35
+ )
36
+ from secondshift.runs.services import RunInProgress, RunRefused, start_manual_run
37
+ from secondshift.runs.submissions import SkillSubmission, resubmission, submit_skill
38
+ from secondshift.workspaces.access import (
39
+ Action,
40
+ current_workspace,
41
+ require,
42
+ )
43
+
44
+ router = Router(auth=django_auth, tags=["runs"])
45
+
46
+
47
+ @router.post("/runs/{run_id}/cancel", response=RunSchema)
48
+ def cancel_task(request: HttpRequest, run_id: int) -> RunSchema:
49
+ require(request, Action.RUN_JOBS)
50
+ run = run_for_request(request, run_id)
51
+ try:
52
+ return serialize_run(cancel_queued_task(run))
53
+ except RunRefused as error:
54
+ raise HttpError(409, str(error)) from error
55
+
56
+
57
+ @router.post("/runs/{run_id}/again", response=TaskSubmissionSchema)
58
+ def run_task_again(
59
+ request: HttpRequest, run_id: int, payload: RunAgainSchema
60
+ ) -> TaskSubmissionSchema:
61
+ """Submit a task run's task again, as it was first submitted."""
62
+ require(request, Action.RUN_JOBS)
63
+ run = run_for_request(request, run_id)
64
+ try:
65
+ submission = resubmission(run, payload.idempotency_key)
66
+ result = submit_skill(run.repository, submission, request.user, RunStart.MANUAL)
67
+ except RunRefused as error:
68
+ raise HttpError(409, str(error)) from error
69
+ return serialize_submission(result.run, result.started)
70
+
71
+
72
+ @router.post(
73
+ "/runs/{run_id}/deliveries/{delivery_id}/retry", response=RunDeliverySchema
74
+ )
75
+ def retry_run_delivery(
76
+ request: HttpRequest, run_id: int, delivery_id: int
77
+ ) -> RunDeliverySchema:
78
+ require(request, Action.RUN_JOBS)
79
+ run = run_for_request(request, run_id)
80
+ try:
81
+ return serialize_delivery(retry_delivery(run, delivery_id))
82
+ except RunDelivery.DoesNotExist as error:
83
+ raise Http404 from error
84
+ except RunRefused as error:
85
+ raise HttpError(409, str(error)) from error
86
+ except DeliveryQueueUnavailable as error:
87
+ raise HttpError(
88
+ 503, "The execution queue is unavailable. Try again shortly."
89
+ ) from error
90
+
91
+
92
+ @router.get("/runs", response=RunPageSchema)
93
+ def runs(request: HttpRequest, query: Query[RunListQuery]) -> RunPageSchema:
94
+ workspace = current_workspace(request)
95
+ if not workspace:
96
+ return RunPageSchema(
97
+ items=[], count=0, page=1, page_size=query.page_size, status_counts={}
98
+ )
99
+ result = run_page(
100
+ workspace,
101
+ RunFilters(
102
+ q=query.q,
103
+ statuses=[str(status) for status in query.status],
104
+ job_id=query.job_id,
105
+ repository_id=query.repository_id,
106
+ period=query.period,
107
+ sort=query.sort,
108
+ ),
109
+ query.page,
110
+ query.page_size,
111
+ )
112
+ return RunPageSchema(
113
+ items=[serialize_run(run) for run in result.runs],
114
+ count=result.count,
115
+ page=result.page,
116
+ page_size=result.page_size,
117
+ status_counts=result.status_counts,
118
+ )
119
+
120
+
121
+ @router.get("/runs/{run_id}", response=RunDetailSchema)
122
+ def run_detail(request: HttpRequest, run_id: int) -> RunDetailSchema:
123
+ return serialize_run_detail(run_for_request(request, run_id))
124
+
125
+
126
+ def stored_file_response(
127
+ path: str, filename: str, download: bool, content_type: str
128
+ ) -> FileResponse:
129
+ response = FileResponse(
130
+ artifact_storage().open(path, "rb"),
131
+ as_attachment=download,
132
+ filename=filename,
133
+ content_type=content_type,
134
+ )
135
+ response["X-Content-Type-Options"] = "nosniff"
136
+ response["Cache-Control"] = "private, no-store"
137
+ return response
138
+
139
+
140
+ @router.get("/runs/{run_id}/files/{name}")
141
+ def run_file(
142
+ request: HttpRequest, run_id: int, name: RunFileName, download: bool = False
143
+ ) -> FileResponse:
144
+ """Stream a stored run file through the configured storage backend.
145
+
146
+ Always served as plain text, so a browser never renders repository or model
147
+ content as HTML; the dashboard renders Markdown itself, sanitized.
148
+ """
149
+ run = run_for_request(request, run_id)
150
+ path = run_file_path(run, name)
151
+ if not path:
152
+ raise Http404
153
+ return stored_file_response(
154
+ path, f"run-{run.pk}-{name}.md", download, "text/plain; charset=utf-8"
155
+ )
156
+
157
+
158
+ @router.get("/runs/{run_id}/changes")
159
+ def run_changes_patch(
160
+ request: HttpRequest, run_id: int, download: bool = False
161
+ ) -> FileResponse:
162
+ """The agent's file changes as a patch, always served as plain text."""
163
+ run = run_for_request(request, run_id)
164
+ path = run.collected.changes.path if run.collected else ""
165
+ if not path:
166
+ raise Http404
167
+ return stored_file_response(
168
+ path, f"run-{run.pk}-changes.patch", download, "text/plain; charset=utf-8"
169
+ )
170
+
171
+
172
+ @router.get("/runs/{run_id}/saved-files/{path:name}")
173
+ def run_saved_file(request: HttpRequest, run_id: int, name: str) -> FileResponse:
174
+ """One file the agent saved. Always a download, never rendered.
175
+
176
+ The agent chose its name and content, so the browser must not open it as
177
+ a page on this site.
178
+ """
179
+ run = run_for_request(request, run_id)
180
+ path = saved_file_path(run, name)
181
+ if not path:
182
+ raise Http404
183
+ return stored_file_response(
184
+ path, PurePosixPath(name).name, True, "application/octet-stream"
185
+ )
186
+
187
+
188
+ @router.post("/jobs/{job_id}/run", response={200: RunSchema, 409: RunInProgressSchema})
189
+ def run_job(
190
+ request: HttpRequest, job_id: int
191
+ ) -> RunSchema | Status[RunInProgressSchema]:
192
+ job = job_for_request(request, job_id)
193
+ require(request, Action.RUN_JOBS)
194
+ try:
195
+ run = start_manual_run(job, submitted_by=request.user)
196
+ except RunInProgress as error:
197
+ return Status(409, RunInProgressSchema(detail=str(error), run_id=error.run.pk))
198
+ except RunRefused as error:
199
+ raise HttpError(409, str(error)) from error
200
+ return serialize_run(run)
201
+
202
+
203
+ @router.post("/tasks", response=TaskSubmissionSchema)
204
+ def submit_task(request: HttpRequest, payload: SkillSubmission) -> TaskSubmissionSchema:
205
+ require(request, Action.RUN_JOBS)
206
+ repository = repository_for_request(request, payload.repository_id)
207
+ try:
208
+ result = submit_skill(repository, payload, request.user, RunStart.MANUAL)
209
+ except RunRefused as error:
210
+ raise HttpError(409, str(error)) from error
211
+ return serialize_submission(result.run, result.started)
@@ -0,0 +1,11 @@
1
+ from importlib import import_module
2
+
3
+ from django.apps import AppConfig
4
+
5
+
6
+ class RunsConfig(AppConfig):
7
+ default_auto_field = "django.db.models.BigAutoField"
8
+ name = "secondshift.runs"
9
+
10
+ def ready(self) -> None:
11
+ import_module("secondshift.runs.signals")