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,238 @@
1
+ """Build the files of one commit into a snapshot a run can unpack.
2
+
3
+ A snapshot is a gzip tar of the commit's tree, built from Git objects directly.
4
+ It does not use `git archive`, because that follows the repository's own
5
+ .gitattributes, and `export-ignore` or `export-subst` could make the files a
6
+ run gets differ from the commit. The same commit always gives the same bytes,
7
+ so a retried run start sends exactly what the first attempt sent.
8
+ """
9
+
10
+ import gzip
11
+ import io
12
+ import re
13
+ import subprocess
14
+ import tarfile
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import IO
18
+
19
+ from secondshift.config.constants import (
20
+ MAX_SKILL_FILES,
21
+ MAX_SKILL_SNAPSHOT_BYTES,
22
+ MAX_SKILL_UNPACKED_BYTES,
23
+ MAX_SOURCE_FILES,
24
+ MAX_SOURCE_SNAPSHOT_BYTES,
25
+ MAX_SOURCE_UNPACKED_BYTES,
26
+ )
27
+
28
+ REGULAR_FILE_MODES = {"100644": 0o644, "100755": 0o755}
29
+ SYMLINK_MODE = "120000"
30
+ SUBMODULE_MODE = "160000"
31
+ COMMIT_SHA = re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}")
32
+
33
+
34
+ class SnapshotError(RuntimeError):
35
+ pass
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class SnapshotLimits:
40
+ files: int
41
+ unpacked_bytes: int
42
+ snapshot_bytes: int
43
+ # What the snapshot is of, in error messages.
44
+ label: str
45
+ # A repository keeps its links and submodules. A skill from another
46
+ # repository may hold only regular files.
47
+ regular_files_only: bool = False
48
+
49
+
50
+ REPOSITORY_LIMITS = SnapshotLimits(
51
+ MAX_SOURCE_FILES,
52
+ MAX_SOURCE_UNPACKED_BYTES,
53
+ MAX_SOURCE_SNAPSHOT_BYTES,
54
+ "The commit",
55
+ )
56
+ SKILL_LIMITS = SnapshotLimits(
57
+ MAX_SKILL_FILES,
58
+ MAX_SKILL_UNPACKED_BYTES,
59
+ MAX_SKILL_SNAPSHOT_BYTES,
60
+ "The skill",
61
+ regular_files_only=True,
62
+ )
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class TreeEntry:
67
+ mode: str
68
+ object_id: str
69
+ size: int
70
+ path: str
71
+
72
+
73
+ def tree_entries(git_dir: Path, commit_sha: str, folder: str = "") -> list[TreeEntry]:
74
+ """Every file, link and submodule in the commit, in Git's order.
75
+
76
+ With a folder, only what is inside it, with paths relative to it.
77
+ """
78
+ if not COMMIT_SHA.fullmatch(commit_sha):
79
+ raise SnapshotError("A full commit SHA is required.")
80
+ tree = f"{commit_sha}:{folder}" if folder else f"{commit_sha}^{{tree}}"
81
+ output = run_git_bytes(git_dir, "ls-tree", "-r", "-z", "-l", "--full-tree", tree)
82
+ entries = []
83
+ for record in output.split(b"\0"):
84
+ if not record:
85
+ continue
86
+ header, path = record.split(b"\t", 1)
87
+ mode, _, object_id, size = header.decode().split()
88
+ entries.append(
89
+ TreeEntry(
90
+ mode=mode,
91
+ object_id=object_id,
92
+ size=0 if size == "-" else int(size),
93
+ path=path.decode("utf-8", errors="surrogateescape"),
94
+ )
95
+ )
96
+ return entries
97
+
98
+
99
+ def write_snapshot(
100
+ git_dir: Path,
101
+ commit_sha: str,
102
+ output: IO[bytes],
103
+ folder: str = "",
104
+ limits: SnapshotLimits = REPOSITORY_LIMITS,
105
+ ) -> None:
106
+ """Write the commit's files, or one folder's, to `output` as a
107
+ reproducible .tar.gz."""
108
+ entries = tree_entries(git_dir, commit_sha, folder)
109
+ if len(entries) > limits.files:
110
+ raise SnapshotError(f"{limits.label} has more than {limits.files:,} files.")
111
+ if sum(entry.size for entry in entries) > limits.unpacked_bytes:
112
+ raise SnapshotError(f"{limits.label}'s files are too large to run.")
113
+ if limits.regular_files_only and (
114
+ links := [
115
+ entry.path for entry in entries if entry.mode not in REGULAR_FILE_MODES
116
+ ]
117
+ ):
118
+ raise SnapshotError(
119
+ f"{limits.label} may hold only regular files. Not allowed: "
120
+ + ", ".join(links[:5])
121
+ )
122
+ limited = LimitedWriter(output, limits.snapshot_bytes, limits.label)
123
+ with (
124
+ gzip.GzipFile(
125
+ fileobj=limited, mode="wb", mtime=0, filename="", compresslevel=6
126
+ ) as compressed,
127
+ tarfile.open(
128
+ fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT
129
+ ) as archive,
130
+ BlobReader(git_dir) as blobs,
131
+ ):
132
+ for entry in entries:
133
+ info = tarfile.TarInfo(entry.path)
134
+ info.mtime = 0
135
+ if entry.mode in REGULAR_FILE_MODES:
136
+ info.mode = REGULAR_FILE_MODES[entry.mode]
137
+ info.size = entry.size
138
+ archive.addfile(info, blobs.stream(entry.object_id, entry.size))
139
+ blobs.finish()
140
+ elif entry.mode == SYMLINK_MODE:
141
+ info.type = tarfile.SYMTYPE
142
+ info.mode = 0o777
143
+ info.linkname = blobs.read(entry.object_id, entry.size).decode(
144
+ "utf-8", errors="surrogateescape"
145
+ )
146
+ archive.addfile(info)
147
+ elif entry.mode == SUBMODULE_MODE:
148
+ # Submodule contents are not fetched. Like a checkout without
149
+ # them, the folder exists but is empty.
150
+ info.type = tarfile.DIRTYPE
151
+ info.mode = 0o755
152
+ archive.addfile(info)
153
+ else:
154
+ raise SnapshotError(f"Unsupported Git file mode {entry.mode}.")
155
+
156
+
157
+ class LimitedWriter(io.RawIOBase):
158
+ def __init__(self, output: IO[bytes], limit: int, label: str) -> None:
159
+ self.output = output
160
+ self.limit = limit
161
+ self.remaining = limit
162
+ self.label = label
163
+
164
+ def writable(self) -> bool:
165
+ return True
166
+
167
+ def write(self, data: bytes) -> int: # type: ignore[override]
168
+ self.remaining -= len(data)
169
+ if self.remaining < 0:
170
+ raise SnapshotError(
171
+ f"{self.label}'s files are larger than "
172
+ f"{self.limit // (1024 * 1024)} MiB compressed."
173
+ )
174
+ return self.output.write(data)
175
+
176
+
177
+ class BlobReader:
178
+ """Reads blobs through one `git cat-file --batch` process."""
179
+
180
+ def __init__(self, git_dir: Path) -> None:
181
+ self.git_dir = git_dir
182
+ self.process: subprocess.Popen[bytes] | None = None
183
+
184
+ def __enter__(self) -> "BlobReader":
185
+ self.process = subprocess.Popen(
186
+ ["git", "-C", str(self.git_dir), "cat-file", "--batch"],
187
+ stdin=subprocess.PIPE,
188
+ stdout=subprocess.PIPE,
189
+ stderr=subprocess.DEVNULL,
190
+ )
191
+ return self
192
+
193
+ def __exit__(self, *exc_info: object) -> None:
194
+ if self.process is None:
195
+ return
196
+ if self.process.stdin:
197
+ self.process.stdin.close()
198
+ self.process.kill()
199
+ self.process.wait(timeout=10)
200
+ if self.process.stdout:
201
+ self.process.stdout.close()
202
+
203
+ def stream(self, object_id: str, size: int) -> IO[bytes]:
204
+ """The blob's content. Read exactly `size` bytes, then call finish()."""
205
+ process = self.process
206
+ if process is None or process.stdin is None or process.stdout is None:
207
+ raise SnapshotError("The blob reader is not open.")
208
+ process.stdin.write(f"{object_id}\n".encode())
209
+ process.stdin.flush()
210
+ header = process.stdout.readline().decode().split()
211
+ if len(header) != 3 or header[1] != "blob" or int(header[2]) != size:
212
+ raise SnapshotError(f"Git could not read object {object_id}.")
213
+ return process.stdout
214
+
215
+ def finish(self) -> None:
216
+ # cat-file ends each object with a newline.
217
+ if self.process and self.process.stdout:
218
+ self.process.stdout.read(1)
219
+
220
+ def read(self, object_id: str, size: int) -> bytes:
221
+ content = self.stream(object_id, size).read(size)
222
+ self.finish()
223
+ return content
224
+
225
+
226
+ def run_git_bytes(git_dir: Path, *arguments: str) -> bytes:
227
+ try:
228
+ completed = subprocess.run(
229
+ ["git", "-C", str(git_dir), *arguments],
230
+ capture_output=True,
231
+ check=False,
232
+ timeout=120,
233
+ )
234
+ except subprocess.TimeoutExpired as error:
235
+ raise SnapshotError("Git timed out while reading the commit.") from error
236
+ if completed.returncode:
237
+ raise SnapshotError("Git could not read the commit.")
238
+ return completed.stdout
@@ -0,0 +1,256 @@
1
+ """The snapshot of a commit's files that a run gets.
2
+
3
+ Snapshots are built on the control plane, cached by repository and commit, and
4
+ uploaded to the runner when a run starts. The cache can be deleted at any time.
5
+ A missing snapshot is built again from the mirror, or the local folder. The
6
+ cache is kept under SOURCE_CACHE_MAX_BYTES, least recently used first.
7
+ """
8
+
9
+ import hashlib
10
+ import os
11
+ import re
12
+ import shutil
13
+ import tempfile
14
+ import time
15
+ from collections.abc import Collection
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ from django.conf import settings
20
+
21
+ from secondshift.config.constants import COMMIT_SHA_PATTERN
22
+ from secondshift.repositories.dao import active_repository_ids
23
+ from secondshift.repositories.github_access import github_access
24
+ from secondshift.repositories.github_sync import fetch_github_commit
25
+ from secondshift.repositories.mirrors import has_commit, locked_mirror
26
+ from secondshift.repositories.models import Repository
27
+ from secondshift.repositories.snapshots import (
28
+ REPOSITORY_LIMITS,
29
+ SnapshotError,
30
+ SnapshotLimits,
31
+ write_snapshot,
32
+ )
33
+
34
+ COMMIT_SHA = re.compile(COMMIT_SHA_PATTERN)
35
+ # A skill from another repository that no run used for this long is removed.
36
+ SKILL_UNUSED_SECONDS = 30 * 24 * 60 * 60
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class SourceSnapshot:
41
+ path: Path
42
+ sha256: str
43
+ size_bytes: int
44
+
45
+
46
+ def snapshots_root() -> Path:
47
+ return settings.SOURCE_CACHE_ROOT / "snapshots"
48
+
49
+
50
+ def skill_snapshots_root() -> Path:
51
+ """Folders of skills from other repositories. See external_skills."""
52
+ return settings.SOURCE_CACHE_ROOT / "skill-snapshots"
53
+
54
+
55
+ def skill_mirrors_root() -> Path:
56
+ return settings.SOURCE_CACHE_ROOT / "skill-mirrors"
57
+
58
+
59
+ def snapshot_path(repository_id: int, commit_sha: str) -> Path:
60
+ return snapshots_root() / str(repository_id) / f"{commit_sha}.tar.gz"
61
+
62
+
63
+ def source_snapshot(repository: Repository, commit_sha: str) -> SourceSnapshot:
64
+ """The commit's snapshot, built and cached if needed."""
65
+ if not COMMIT_SHA.fullmatch(commit_sha):
66
+ raise SnapshotError("A full commit SHA is required.")
67
+ path = snapshot_path(repository.pk, commit_sha)
68
+ if path.exists():
69
+ # Mark it recently used, so cache cleanup removes older ones first.
70
+ path.touch()
71
+ else:
72
+ build_snapshot(repository, commit_sha, path)
73
+ trim_snapshots(keep=path)
74
+ return SourceSnapshot(
75
+ path=path, sha256=file_sha256(path), size_bytes=path.stat().st_size
76
+ )
77
+
78
+
79
+ def build_snapshot(repository: Repository, commit_sha: str, path: Path) -> None:
80
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
81
+ if repository.source_type == Repository.SourceType.LOCAL:
82
+ if not settings.LOCAL_REPOSITORIES_ENABLED:
83
+ raise SnapshotError(
84
+ "Local repositories are available only on a single host."
85
+ )
86
+ write_atomically(Path(repository.local_path), commit_sha, path)
87
+ return
88
+ with locked_mirror(repository.pk) as mirror:
89
+ if not has_commit(mirror, commit_sha):
90
+ fetch_github_commit(github_access(repository), commit_sha, mirror)
91
+ write_atomically(mirror, commit_sha, path)
92
+
93
+
94
+ def write_atomically(
95
+ git_dir: Path,
96
+ commit_sha: str,
97
+ path: Path,
98
+ folder: str = "",
99
+ limits: SnapshotLimits = REPOSITORY_LIMITS,
100
+ ) -> None:
101
+ # Two runs of one commit may build at once. Both write the same bytes, and
102
+ # each replaces the file whole, so neither ever reads a partial snapshot.
103
+ descriptor, name = tempfile.mkstemp(dir=path.parent, prefix="building-")
104
+ temporary = Path(name)
105
+ try:
106
+ with os.fdopen(descriptor, "wb") as output:
107
+ write_snapshot(git_dir, commit_sha, output, folder, limits)
108
+ os.replace(temporary, path)
109
+ finally:
110
+ temporary.unlink(missing_ok=True)
111
+
112
+
113
+ def file_sha256(path: Path) -> str:
114
+ digest = hashlib.sha256()
115
+ with path.open("rb") as stream:
116
+ while chunk := stream.read(1024 * 1024):
117
+ digest.update(chunk)
118
+ return digest.hexdigest()
119
+
120
+
121
+ @dataclass
122
+ class CachePrune:
123
+ snapshots: int = 0
124
+ mirrors: int = 0
125
+ bytes: int = 0
126
+
127
+ def add(self, path: Path) -> None:
128
+ self.bytes += tree_size(path)
129
+ if path.name.endswith(".git"):
130
+ self.mirrors += 1
131
+ else:
132
+ self.snapshots += 1
133
+
134
+
135
+ def prune_source_cache(dry_run: bool = False) -> CachePrune:
136
+ """Remove cached sources no repository needs, then trim snapshots to size.
137
+
138
+ Only entries named by a repository ID are touched, and only inside the
139
+ cache folder. Removed repositories lose their mirror and snapshots.
140
+ """
141
+ pruned = CachePrune()
142
+ active = active_repository_ids()
143
+ removed = set()
144
+ for folder in cache_entries(snapshots_root(), suffix=""):
145
+ if int(folder.name) not in active:
146
+ removed.add(int(folder.name))
147
+ for snapshot in folder.glob("*.tar.gz"):
148
+ pruned.add(snapshot)
149
+ if not dry_run:
150
+ shutil.rmtree(folder)
151
+ for mirror in cache_entries(settings.SOURCE_CACHE_ROOT / "mirrors", ".git"):
152
+ repository_id = int(mirror.name.removesuffix(".git"))
153
+ if repository_id not in active:
154
+ pruned.add(mirror)
155
+ if not dry_run:
156
+ with locked_mirror(repository_id) as path:
157
+ shutil.rmtree(path)
158
+ remove_stale_builds(pruned, dry_run)
159
+ trim_snapshots(pruned, dry_run=dry_run, skip=removed)
160
+ prune_unused_skills(pruned, dry_run)
161
+ return pruned
162
+
163
+
164
+ def prune_unused_skills(pruned: CachePrune, dry_run: bool) -> None:
165
+ """Remove skills from other repositories that no run used for a while."""
166
+ cutoff = time.time() - SKILL_UNUSED_SECONDS
167
+ for path in [
168
+ *cached_paths(skill_snapshots_root(), "*.tar.gz"),
169
+ *cached_paths(skill_mirrors_root(), "*.git"),
170
+ ]:
171
+ if path.stat().st_mtime >= cutoff:
172
+ continue
173
+ pruned.add(path)
174
+ if dry_run:
175
+ continue
176
+ if path.is_dir():
177
+ shutil.rmtree(path, ignore_errors=True)
178
+ else:
179
+ path.unlink(missing_ok=True)
180
+
181
+
182
+ def cached_paths(root: Path, pattern: str) -> list[Path]:
183
+ if not root.is_dir():
184
+ return []
185
+ return [path for path in root.glob(pattern) if not path.is_symlink()]
186
+
187
+
188
+ def trim_snapshots(
189
+ pruned: CachePrune | None = None,
190
+ dry_run: bool = False,
191
+ keep: Path | None = None,
192
+ skip: Collection[int] = (),
193
+ ) -> CachePrune:
194
+ """Remove the least recently used snapshots past SOURCE_CACHE_MAX_BYTES."""
195
+ pruned = pruned or CachePrune()
196
+ files = sorted(
197
+ (
198
+ path
199
+ for folder in cache_entries(snapshots_root(), suffix="")
200
+ if int(folder.name) not in skip
201
+ for path in folder.glob("*.tar.gz")
202
+ if path.is_file() and not path.is_symlink()
203
+ ),
204
+ key=lambda path: path.stat().st_mtime,
205
+ )
206
+ total = sum(path.stat().st_size for path in files)
207
+ for path in files:
208
+ if total <= settings.SOURCE_CACHE_MAX_BYTES:
209
+ break
210
+ if path == keep:
211
+ continue
212
+ size = path.stat().st_size
213
+ total -= size
214
+ pruned.snapshots += 1
215
+ pruned.bytes += size
216
+ if not dry_run:
217
+ path.unlink(missing_ok=True)
218
+ return pruned
219
+
220
+
221
+ def remove_stale_builds(pruned: CachePrune, dry_run: bool) -> None:
222
+ """Remove partial snapshots left by a build that crashed."""
223
+ for folder in cache_entries(snapshots_root(), suffix=""):
224
+ for path in folder.glob("building-*"):
225
+ if path.is_file() and not path.is_symlink() and is_stale(path):
226
+ pruned.bytes += path.stat().st_size
227
+ if not dry_run:
228
+ path.unlink(missing_ok=True)
229
+
230
+
231
+ def cache_entries(root: Path, suffix: str) -> list[Path]:
232
+ """Folders in the cache named `<repository id><suffix>`, never links."""
233
+ if not root.is_dir():
234
+ return []
235
+ return [
236
+ path
237
+ for path in root.iterdir()
238
+ if path.name.removesuffix(suffix).isdigit()
239
+ and path.name.endswith(suffix)
240
+ and path.is_dir()
241
+ and not path.is_symlink()
242
+ ]
243
+
244
+
245
+ def is_stale(path: Path) -> bool:
246
+ return path.stat().st_mtime < time.time() - 60 * 60
247
+
248
+
249
+ def tree_size(path: Path) -> int:
250
+ if path.is_file():
251
+ return path.stat().st_size
252
+ return sum(
253
+ (Path(root) / name).lstat().st_size
254
+ for root, _, names in os.walk(path)
255
+ for name in names
256
+ )
@@ -0,0 +1,67 @@
1
+ """What a repository's sync state is, from what its last syncs saved.
2
+
3
+ A sync saves `checked_sha`, the commit it read, and `last_sync_error` when that
4
+ commit's configuration was broken. A good sync also saves `config_sha`, the
5
+ commit jobs run from. New commits on the branch are reported apart from the
6
+ state, because runs keep using the last good sync.
7
+ """
8
+
9
+ from dataclasses import dataclass
10
+ from enum import StrEnum
11
+
12
+ from secondshift.repositories.models import Repository
13
+
14
+
15
+ class ValidationStatus(StrEnum):
16
+ VALID = "valid"
17
+ WARNING = "warning"
18
+ ERROR = "error"
19
+
20
+
21
+ class SyncState(StrEnum):
22
+ NEVER_SYNCED = "never_synced"
23
+ # A configuration problem before any good sync. Setup is not finished.
24
+ SETUP_PROBLEM = "setup_problem"
25
+ # A configuration problem after a good sync. Jobs keep the last good one.
26
+ BROKEN = "broken"
27
+ SYNCED = "synced"
28
+
29
+
30
+ # How each state reads. A state with no message shows the problem itself.
31
+ STATE_STATUS: dict[SyncState, tuple[ValidationStatus, str]] = {
32
+ SyncState.NEVER_SYNCED: (
33
+ ValidationStatus.WARNING,
34
+ "Sync the repository before enabling schedules.",
35
+ ),
36
+ SyncState.SETUP_PROBLEM: (ValidationStatus.WARNING, ""),
37
+ SyncState.BROKEN: (ValidationStatus.ERROR, ""),
38
+ SyncState.SYNCED: (ValidationStatus.VALID, "Repository synchronized."),
39
+ }
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class SourceStatus:
44
+ status: ValidationStatus
45
+ message: str
46
+ needs_sync: bool
47
+
48
+
49
+ def sync_state(repository: Repository, problem: str) -> SyncState:
50
+ """`problem` is the configuration problem at the checked commit, if any."""
51
+ if problem:
52
+ return SyncState.BROKEN if repository.config_sha else SyncState.SETUP_PROBLEM
53
+ return SyncState.SYNCED if repository.config_sha else SyncState.NEVER_SYNCED
54
+
55
+
56
+ def source_status(
57
+ repository: Repository, head: str | None, problem: str
58
+ ) -> SourceStatus:
59
+ """The status of a repository whose branch is at `head`, None when unknown."""
60
+ status, message = STATE_STATUS[sync_state(repository, problem)]
61
+ return SourceStatus(
62
+ status=status,
63
+ message=message or problem,
64
+ # Before the first sync there is nothing to be behind.
65
+ needs_sync=bool(head and repository.checked_sha)
66
+ and head != repository.checked_sha,
67
+ )
@@ -0,0 +1,65 @@
1
+ # Runner service
2
+
3
+ The runner is a small, conventional Django deployment with its own settings,
4
+ installed apps, database, credentials, and network policy. Reusing Django gives
5
+ it models, generated migrations, transactions, validation, and a typed Ninja
6
+ API without giving it access to the control-plane applications.
7
+
8
+ Only the runner deployment receives `/var/run/docker.sock`. The web, worker,
9
+ gateway, and runner use separate settings and database credentials. The runner
10
+ does not receive provider keys, OAuth credentials, encrypted connection data, or
11
+ the control-plane database URL.
12
+
13
+ Its API is deliberately narrow:
14
+
15
+ - `HEAD /v1/sources/{sha256}` asks whether a source snapshot is already here
16
+ - `PUT /v1/sources/{sha256}` uploads a snapshot of one commit's files
17
+ - `POST /v1/sessions` creates a sandbox from one uploaded snapshot
18
+ - `POST /v1/sessions/{id}/commands` executes one idempotent tool call
19
+ - `POST /v1/sessions/{id}/collection` stops the sandbox and returns its file
20
+ changes and saved files as one archive
21
+ - `DELETE /v1/sessions/{id}` destroys the container and workspace
22
+ - `GET /-/health/` is the only unauthenticated endpoint.
23
+
24
+ All other routes require `SECONDSHIFT_RUNNER_SERVICE_TOKEN`. Session and command
25
+ state live in runner-owned Django models and a runner-only database.
26
+
27
+ The runner keeps no repositories. When a run starts, the control plane uploads a
28
+ snapshot of the run's commit, named by its SHA-256. The runner checks the bytes
29
+ against that name, unpacks them into the session workspace with size, file
30
+ count and entry type limits, and deletes snapshots an hour after their last
31
+ use. The runner never accepts a checkout path or clone credential.
32
+
33
+ Production runners must:
34
+
35
+ - verify `runsc` is registered and refuse to start otherwise
36
+ - unpack only the uploaded snapshot named in the session spec
37
+ - accept only server-owned images, mounts and OCI flags
38
+ - give command sandboxes no network and no credentials
39
+ - use a dedicated sandbox per run with bounded resources
40
+ - keep sandbox images free of the control-plane application and Docker client
41
+ - deduplicate tool calls by their stable run and call identifiers
42
+ - send logs and the final result back through an authenticated channel
43
+ - destroy the workspace after the result is acknowledged.
44
+
45
+ Start the service only after applying its own migrations:
46
+
47
+ ```bash
48
+ DJANGO_SETTINGS_MODULE=secondshift.config.settings.runner uv run python manage.py migrate
49
+ SECONDSHIFT_SERVICE=runner uv run gunicorn --config python:secondshift.config.gunicorn
50
+ ```
51
+
52
+ Required configuration is in `secondshift/config/settings/runner.py`. Production should use
53
+ `runsc`. Standard Docker is reserved for trusted development and single-tenant
54
+ self-hosting. Service startup verifies the selected runtime and fails closed.
55
+ The runner uses the same image as the control plane, with its own command,
56
+ root user override, database and network identity.
57
+ Sandboxes use `SECONDSHIFT_RUNNER_IMAGE` (default: the pinned official
58
+ `python:3.13-slim`, see `secondshift/config/constants.py`). The runner pulls it at startup and
59
+ refuses to start if the pull fails.
60
+
61
+ Runner and control-plane apps share one test suite:
62
+
63
+ ```bash
64
+ uv run pytest
65
+ ```
@@ -0,0 +1 @@
1
+ """Standalone command runner security boundary."""