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,97 @@
1
+ import hashlib
2
+ import json
3
+ import re
4
+ from dataclasses import asdict, dataclass
5
+ from pathlib import PurePosixPath
6
+
7
+
8
+ class RunnerProtocolError(RuntimeError):
9
+ pass
10
+
11
+
12
+ REPOSITORY_NAME = re.compile(r"^[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)+$")
13
+ COMMIT_SHA = re.compile(r"^[0-9a-f]{40,64}$")
14
+ SESSION_KEY = re.compile(r"^[0-9a-f]{32}$")
15
+ SOURCE_SHA256 = re.compile(r"^[0-9a-f]{64}$")
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class RunnerSessionSpec:
20
+ run_id: int
21
+ session_key: str
22
+ repository: str
23
+ commit_sha: str
24
+ image: str
25
+ # The uploaded snapshot of the commit's files the session unpacks.
26
+ source_sha256: str
27
+ # Record the unpacked files, so the agent's changes can be collected.
28
+ # The control plane sets it from the run's plan, never from repository input.
29
+ collect_changes: bool = False
30
+ # An uploaded archive of earlier runs' saved files, mounted read-only.
31
+ # Empty when the run has none.
32
+ history_sha256: str = ""
33
+ # An uploaded archive of a skill from another repository, mounted
34
+ # read-only. Empty when the skill is in the run's repository or there is
35
+ # none. The control plane builds it, so the skill's repository never
36
+ # chooses where it goes or how it is mounted.
37
+ skill_sha256: str = ""
38
+
39
+ def __post_init__(self) -> None:
40
+ if self.run_id < 1:
41
+ raise ValueError("run_id must be positive")
42
+ if not SESSION_KEY.fullmatch(self.session_key):
43
+ raise ValueError("session_key must be 32 lowercase hexadecimal characters")
44
+ if not REPOSITORY_NAME.fullmatch(self.repository):
45
+ raise ValueError("repository must be a normalized owner/name path")
46
+ if any(part in {".", ".."} for part in self.repository.split("/")):
47
+ raise ValueError("repository must be a normalized owner/name path")
48
+ if not COMMIT_SHA.fullmatch(self.commit_sha):
49
+ raise ValueError("commit_sha must be a full lowercase hexadecimal SHA")
50
+ if not self.image.strip():
51
+ raise ValueError("a server-owned image is required")
52
+ if not SOURCE_SHA256.fullmatch(self.source_sha256):
53
+ raise ValueError("source_sha256 must be a lowercase SHA-256")
54
+ if self.history_sha256 and not SOURCE_SHA256.fullmatch(self.history_sha256):
55
+ raise ValueError("history_sha256 must be empty or a lowercase SHA-256")
56
+ if self.skill_sha256 and not SOURCE_SHA256.fullmatch(self.skill_sha256):
57
+ raise ValueError("skill_sha256 must be empty or a lowercase SHA-256")
58
+
59
+ @property
60
+ def session_id(self) -> str:
61
+ # The key is random per run, so a reset or restored database that
62
+ # reuses a run ID never matches an older session.
63
+ return f"run-{self.run_id}-{self.session_key}"
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class CommandRequest:
68
+ call_id: str
69
+ argv: tuple[str, ...]
70
+ cwd: str = "."
71
+ timeout_seconds: int = 120
72
+
73
+ def __post_init__(self) -> None:
74
+ if not self.call_id.strip():
75
+ raise ValueError("call_id is required")
76
+ if not self.argv or any(
77
+ not argument or "\0" in argument for argument in self.argv
78
+ ):
79
+ raise ValueError("argv must contain non-empty arguments")
80
+ path = PurePosixPath(self.cwd)
81
+ if path.is_absolute() or ".." in path.parts:
82
+ raise ValueError("cwd must stay inside the workspace")
83
+ if not 1 <= self.timeout_seconds <= 900:
84
+ raise ValueError("timeout_seconds must be between 1 and 900")
85
+
86
+ @property
87
+ def fingerprint(self) -> str:
88
+ encoded = json.dumps(asdict(self), sort_keys=True, separators=(",", ":"))
89
+ return hashlib.sha256(encoded.encode()).hexdigest()
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class CommandResult:
94
+ call_id: str
95
+ exit_code: int | None
96
+ output: str
97
+ timed_out: bool = False
@@ -0,0 +1,36 @@
1
+ from ninja import Schema
2
+ from pydantic import ConfigDict, Field
3
+
4
+
5
+ class StrictSchema(Schema):
6
+ model_config = ConfigDict(extra="forbid")
7
+
8
+
9
+ class SessionInput(StrictSchema):
10
+ run_id: int = Field(gt=0)
11
+ session_key: str = Field(min_length=32, max_length=32)
12
+ repository: str = Field(min_length=3, max_length=300)
13
+ commit_sha: str = Field(min_length=40, max_length=64)
14
+ image: str = Field(min_length=1, max_length=300)
15
+ source_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
16
+ collect_changes: bool = False
17
+ history_sha256: str = Field(default="", pattern=r"^(?:[0-9a-f]{64})?$")
18
+ skill_sha256: str = Field(default="", pattern=r"^(?:[0-9a-f]{64})?$")
19
+
20
+
21
+ class SessionOutput(Schema):
22
+ session_id: str
23
+
24
+
25
+ class CommandInput(StrictSchema):
26
+ call_id: str = Field(min_length=1, max_length=200)
27
+ argv: list[str] = Field(min_length=1, max_length=256)
28
+ cwd: str = Field(default=".", max_length=1000)
29
+ timeout_seconds: int = Field(default=120, ge=1, le=900)
30
+
31
+
32
+ class CommandOutput(Schema):
33
+ call_id: str
34
+ exit_code: int | None
35
+ output: str
36
+ timed_out: bool
@@ -0,0 +1,465 @@
1
+ import json
2
+ import os
3
+ import shutil
4
+ import subprocess
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ from django.conf import settings
9
+ from django.db import IntegrityError, transaction
10
+
11
+ from secondshift.config.constants import (
12
+ SANDBOX_HISTORY_DIR,
13
+ SANDBOX_OUTPUTS_DIR,
14
+ SANDBOX_SKILL_DIR,
15
+ SandboxRuntime,
16
+ )
17
+ from secondshift.runner.collection import OUTPUTS_DIR, collect, record_baseline
18
+ from secondshift.runner.dao import (
19
+ complete_command,
20
+ create_command,
21
+ create_session,
22
+ get_session,
23
+ lock_session,
24
+ session_command,
25
+ set_session_status,
26
+ )
27
+ from secondshift.runner.models import RunnerCommand, RunnerSession
28
+ from secondshift.runner.protocol import (
29
+ CommandRequest,
30
+ CommandResult,
31
+ RunnerProtocolError,
32
+ RunnerSessionSpec,
33
+ )
34
+ from secondshift.runner.sources import sweep_sources, unpack_source
35
+
36
+ TRUNCATION_MARKER = b"\n[... command output truncated by SecondShift ...]\n"
37
+ # Beside the workspace: the files earlier runs of the job saved.
38
+ HISTORY_DIR = "history"
39
+ # Beside the workspace: a skill from another repository.
40
+ SKILL_DIR = "skill"
41
+
42
+
43
+ def sandbox_name(session_id: str) -> str:
44
+ return f"secondshift-{session_id}"
45
+
46
+
47
+ def agent_name(session_id: str) -> str:
48
+ return f"secondshift-agent-{session_id}"
49
+
50
+
51
+ def sandbox_flags() -> list[str]:
52
+ """The runtime, hardening, limits and user every sandbox container gets.
53
+
54
+ Callers add only their own mounts, network and temporary files.
55
+ """
56
+ flags = []
57
+ if settings.RUNNER_RUNTIME != SandboxRuntime.DOCKER:
58
+ flags += ["--runtime", settings.RUNNER_RUNTIME]
59
+ return [
60
+ *flags,
61
+ "--read-only",
62
+ "--cap-drop=ALL",
63
+ "--security-opt=no-new-privileges:true",
64
+ f"--pids-limit={settings.RUNNER_PIDS_LIMIT}",
65
+ f"--memory={settings.RUNNER_MEMORY}",
66
+ # Docker otherwise lets the sandbox swap as much again as its memory.
67
+ f"--memory-swap={settings.RUNNER_MEMORY}",
68
+ f"--cpus={settings.RUNNER_CPUS}",
69
+ f"--user={settings.RUNNER_SANDBOX_UID}:{settings.RUNNER_SANDBOX_GID}",
70
+ ]
71
+
72
+
73
+ class RunnerConfigurationError(RuntimeError):
74
+ pass
75
+
76
+
77
+ class DockerRunnerService:
78
+ """Container operations and ORM-backed idempotency for the runner service."""
79
+
80
+ def verify_configuration(self) -> None:
81
+ settings.RUNNER_WORK_ROOT.mkdir(parents=True, exist_ok=True, mode=0o700)
82
+ sweep_sources()
83
+ self.verify_runtime()
84
+ self.ensure_image()
85
+
86
+ def ensure_image(self) -> None:
87
+ """Pull the sandbox image at startup so the first run doesn't wait on it."""
88
+ image = settings.RUNNER_IMAGE
89
+ if not self.run_command(
90
+ ["docker", "image", "inspect", image], timeout=15
91
+ ).returncode:
92
+ return
93
+ pulled = self.run_command(["docker", "pull", image], timeout=600)
94
+ if pulled.returncode:
95
+ detail = (pulled.stderr or pulled.stdout).strip().splitlines()
96
+ reason = detail[-1] if detail else "no output"
97
+ raise RunnerConfigurationError(
98
+ f"Couldn't pull the sandbox image {image}: {reason}"
99
+ )
100
+
101
+ def verify_runtime(self) -> None:
102
+ completed = self.run_command(
103
+ ["docker", "info", "--format", "{{json .Runtimes}}"], timeout=15
104
+ )
105
+ if completed.returncode:
106
+ raise RunnerConfigurationError("Docker is unavailable to the runner.")
107
+ if settings.RUNNER_RUNTIME == SandboxRuntime.DOCKER:
108
+ return
109
+ try:
110
+ runtimes = json.loads(completed.stdout)
111
+ except (TypeError, ValueError) as error:
112
+ raise RunnerConfigurationError(
113
+ "Docker returned an invalid runtime list."
114
+ ) from error
115
+ if settings.RUNNER_RUNTIME not in runtimes:
116
+ raise RunnerConfigurationError(
117
+ f"Docker runtime {settings.RUNNER_RUNTIME} is not registered."
118
+ )
119
+
120
+ def start(self, spec: RunnerSessionSpec) -> str:
121
+ if spec.image != settings.RUNNER_IMAGE:
122
+ raise RunnerProtocolError(
123
+ "The requested image is not enabled by this runner."
124
+ )
125
+ session_id = spec.session_id
126
+ container_name = sandbox_name(session_id)
127
+ session_root = (settings.RUNNER_WORK_ROOT / "sessions" / session_id).resolve()
128
+ self.require_owned_path(session_root)
129
+ workspace = session_root / "workspace"
130
+
131
+ with transaction.atomic():
132
+ session = lock_session(session_id)
133
+ if session is None:
134
+ try:
135
+ session = create_session(
136
+ session_id=session_id,
137
+ run_id=spec.run_id,
138
+ repository=spec.repository,
139
+ commit_sha=spec.commit_sha,
140
+ image=spec.image,
141
+ source_sha256=spec.source_sha256,
142
+ collect_changes=spec.collect_changes,
143
+ history_sha256=spec.history_sha256,
144
+ skill_sha256=spec.skill_sha256,
145
+ container_name=container_name,
146
+ workspace_path=str(workspace),
147
+ )
148
+ except IntegrityError as error:
149
+ raise RunnerProtocolError(
150
+ "The run already has a runner session."
151
+ ) from error
152
+ else:
153
+ self.require_matching_spec(session, spec)
154
+ if session.status == RunnerSession.Status.ACTIVE:
155
+ if not self.container_exists(container_name):
156
+ raise RunnerProtocolError(
157
+ "The recorded runner session has lost its container."
158
+ )
159
+ return session_id
160
+ raise RunnerProtocolError(
161
+ "The previous session outcome is not reusable; it will not be repeated."
162
+ )
163
+
164
+ self.remove_container(container_name)
165
+ self.remove_workspace(session_root)
166
+ workspace.mkdir(parents=True, mode=0o700)
167
+ outputs = session_root / OUTPUTS_DIR
168
+ outputs.mkdir(mode=0o700)
169
+ (session_root / HISTORY_DIR).mkdir(mode=0o700)
170
+ (session_root / SKILL_DIR).mkdir(mode=0o700)
171
+ try:
172
+ self.prepare_sandbox(spec, container_name, workspace, outputs)
173
+ except Exception:
174
+ self.remove_container(container_name)
175
+ self.remove_workspace(session_root)
176
+ set_session_status(session_id, RunnerSession.Status.ERROR)
177
+ raise
178
+ set_session_status(session_id, RunnerSession.Status.ACTIVE)
179
+ return session_id
180
+
181
+ def prepare_sandbox(
182
+ self,
183
+ spec: RunnerSessionSpec,
184
+ container_name: str,
185
+ workspace: Path,
186
+ outputs: Path,
187
+ ) -> None:
188
+ """Unpack the commit, any earlier files and any skill from another
189
+ repository, record the commit if asked, and start the container."""
190
+ unpack_source(spec.source_sha256, workspace)
191
+ history = workspace.parent / HISTORY_DIR
192
+ if spec.history_sha256:
193
+ unpack_source(spec.history_sha256, history)
194
+ skill = workspace.parent / SKILL_DIR
195
+ if spec.skill_sha256:
196
+ unpack_source(spec.skill_sha256, skill)
197
+ if spec.collect_changes:
198
+ record_baseline(workspace.parent, workspace)
199
+ self.prepare_workspace_permissions(workspace)
200
+ self.prepare_workspace_permissions(outputs)
201
+ self.prepare_workspace_permissions(history)
202
+ self.prepare_workspace_permissions(skill)
203
+ completed = self.run_command(
204
+ self.create_command(spec, container_name, workspace)
205
+ )
206
+ if completed.returncode:
207
+ raise RunnerProtocolError("The runner could not create the sandbox.")
208
+ completed = self.run_command(["docker", "start", container_name])
209
+ if completed.returncode:
210
+ raise RunnerProtocolError("The runner could not start the sandbox.")
211
+
212
+ def execute(self, session_id: str, request: CommandRequest) -> CommandResult:
213
+ cached = self.reserve_command(session_id, request)
214
+ if cached is not None:
215
+ return cached
216
+ session = get_session(session_id)
217
+ timeout = min(request.timeout_seconds, settings.RUNNER_COMMAND_TIMEOUT_SECONDS)
218
+ workdir = "/workspace"
219
+ if request.cwd != ".":
220
+ workdir += f"/{request.cwd}"
221
+ command = [
222
+ "docker",
223
+ "exec",
224
+ "--workdir",
225
+ workdir,
226
+ session.container_name,
227
+ *request.argv,
228
+ ]
229
+ output_descriptor, output_name = tempfile.mkstemp(
230
+ dir=settings.RUNNER_WORK_ROOT, prefix="command-output-"
231
+ )
232
+ output_path = Path(output_name)
233
+ timed_out = False
234
+ exit_code: int | None = None
235
+ try:
236
+ with os.fdopen(output_descriptor, "wb") as output:
237
+ try:
238
+ completed = subprocess.run(
239
+ command,
240
+ stdout=output,
241
+ stderr=subprocess.STDOUT,
242
+ timeout=timeout,
243
+ check=False,
244
+ )
245
+ exit_code = completed.returncode
246
+ except subprocess.TimeoutExpired:
247
+ timed_out = True
248
+ result = CommandResult(
249
+ call_id=request.call_id,
250
+ exit_code=exit_code,
251
+ output=self.read_output(output_path),
252
+ timed_out=timed_out,
253
+ )
254
+ complete_command(
255
+ session,
256
+ request.call_id,
257
+ result.exit_code,
258
+ result.output,
259
+ result.timed_out,
260
+ )
261
+ if timed_out:
262
+ self.stop(session_id)
263
+ elif exit_code:
264
+ # gVisor ends the whole sandbox when it runs out of memory, so
265
+ # later commands could never succeed. Docker sets OOMKilled at
266
+ # once but reports the container running a little longer.
267
+ if self.container_oom_killed(session.container_name):
268
+ self.stop(session_id)
269
+ raise RunnerProtocolError("The sandbox ran out of memory.")
270
+ if not self.container_running(session.container_name):
271
+ self.stop(session_id)
272
+ raise RunnerProtocolError("The sandbox stopped unexpectedly.")
273
+ return result
274
+ finally:
275
+ output_path.unlink(missing_ok=True)
276
+
277
+ def collect(self, session_id: str) -> Path:
278
+ """Stop the sandbox and archive its changes and saved files.
279
+
280
+ The sandbox stops first, so nothing changes the files while the runner
281
+ reads them. Asking again returns the same archive.
282
+ """
283
+ try:
284
+ session = get_session(session_id)
285
+ except RunnerSession.DoesNotExist as error:
286
+ raise RunnerProtocolError("Unknown runner session.") from error
287
+ if session.status != RunnerSession.Status.ACTIVE:
288
+ raise RunnerProtocolError("The session has ended, so its files are gone.")
289
+ workspace = Path(session.workspace_path)
290
+ session_root = workspace.parent.resolve()
291
+ self.require_owned_path(session_root)
292
+ self.remove_container(agent_name(session_id))
293
+ self.run_command(["docker", "kill", session.container_name])
294
+ if self.container_running(session.container_name):
295
+ raise RunnerProtocolError("The runner could not stop the sandbox.")
296
+ return collect(session_root, workspace)
297
+
298
+ def stop(self, session_id: str) -> None:
299
+ with transaction.atomic():
300
+ session = lock_session(session_id)
301
+ if session is None:
302
+ return
303
+ session.status = RunnerSession.Status.STOPPED
304
+ session.save(update_fields=("status", "updated_at"))
305
+ container_name = session.container_name
306
+ workspace = Path(session.workspace_path)
307
+ self.remove_container(agent_name(session_id))
308
+ self.remove_container(container_name)
309
+ self.remove_workspace(workspace.parent)
310
+
311
+ def reserve_command(
312
+ self, session_id: str, request: CommandRequest
313
+ ) -> CommandResult | None:
314
+ with transaction.atomic():
315
+ session = lock_session(session_id, active_only=True)
316
+ if session is None:
317
+ raise RunnerProtocolError("Unknown or inactive runner session.")
318
+ command = session_command(session, request.call_id)
319
+ if command is None:
320
+ create_command(session, request.call_id, request.fingerprint)
321
+ return None
322
+ if command.fingerprint != request.fingerprint:
323
+ raise RunnerProtocolError(
324
+ "A tool call ID was reused with different arguments."
325
+ )
326
+ if command.status != RunnerCommand.Status.COMPLETED:
327
+ raise RunnerProtocolError(
328
+ "The previous execution outcome is unknown; it will not be repeated."
329
+ )
330
+ return CommandResult(
331
+ call_id=command.call_id,
332
+ exit_code=command.exit_code,
333
+ output=command.output,
334
+ timed_out=command.timed_out,
335
+ )
336
+
337
+ @staticmethod
338
+ def require_matching_spec(session: RunnerSession, spec: RunnerSessionSpec) -> None:
339
+ recorded = (
340
+ session.run_id,
341
+ session.repository,
342
+ session.commit_sha,
343
+ session.image,
344
+ session.source_sha256,
345
+ session.collect_changes,
346
+ session.history_sha256,
347
+ session.skill_sha256,
348
+ )
349
+ requested = (
350
+ spec.run_id,
351
+ spec.repository,
352
+ spec.commit_sha,
353
+ spec.image,
354
+ spec.source_sha256,
355
+ spec.collect_changes,
356
+ spec.history_sha256,
357
+ spec.skill_sha256,
358
+ )
359
+ if recorded != requested:
360
+ raise RunnerProtocolError("The run already has a different session spec.")
361
+
362
+ @staticmethod
363
+ def create_command(
364
+ spec: RunnerSessionSpec, container_name: str, workspace: Path
365
+ ) -> list[str]:
366
+ return [
367
+ "docker",
368
+ "create",
369
+ "--name",
370
+ container_name,
371
+ *sandbox_flags(),
372
+ "--network=none",
373
+ "--tmpfs=/tmp:rw,noexec,nosuid,size=256m",
374
+ # Bind mounts are writable by default. Docker rejects a bare "rw" field.
375
+ f"--mount=type=bind,src={workspace},dst=/workspace",
376
+ # Files the agent hands back. The runner collects them at the end.
377
+ f"--mount=type=bind,src={workspace.parent / OUTPUTS_DIR},"
378
+ f"dst={SANDBOX_OUTPUTS_DIR}",
379
+ # Files earlier runs of the job saved. The sandbox can only read them.
380
+ f"--mount=type=bind,src={workspace.parent / HISTORY_DIR},"
381
+ f"dst={SANDBOX_HISTORY_DIR},readonly",
382
+ # A skill from another repository. The sandbox can only read it,
383
+ # and it is never part of the repository's changes.
384
+ f"--mount=type=bind,src={workspace.parent / SKILL_DIR},"
385
+ f"dst={SANDBOX_SKILL_DIR},readonly",
386
+ "--workdir=/workspace",
387
+ "--env=HOME=/tmp",
388
+ # Skills that run Python must not leave bytecode in the repository.
389
+ "--env=PYTHONDONTWRITEBYTECODE=1",
390
+ spec.image,
391
+ "sleep",
392
+ "infinity",
393
+ ]
394
+
395
+ @staticmethod
396
+ def prepare_workspace_permissions(workspace: Path) -> None:
397
+ uid = settings.RUNNER_SANDBOX_UID
398
+ gid = settings.RUNNER_SANDBOX_GID
399
+ for root, directories, files in os.walk(workspace):
400
+ for name in [*directories, *files]:
401
+ os.chown(Path(root) / name, uid, gid, follow_symlinks=False)
402
+ os.chown(workspace, uid, gid, follow_symlinks=False)
403
+
404
+ @staticmethod
405
+ def read_output(path: Path) -> str:
406
+ limit = settings.RUNNER_COMMAND_OUTPUT_MAX_BYTES
407
+ size = path.stat().st_size
408
+ with path.open("rb") as stream:
409
+ if size <= limit:
410
+ content = stream.read()
411
+ else:
412
+ remaining = max(limit - len(TRUNCATION_MARKER), 0)
413
+ head_size = remaining // 2
414
+ tail_size = remaining - head_size
415
+ head = stream.read(head_size)
416
+ stream.seek(size - tail_size)
417
+ content = head + TRUNCATION_MARKER + stream.read(tail_size)
418
+ return content[:limit].decode(errors="replace")
419
+
420
+ def container_exists(self, name: str) -> bool:
421
+ return self.run_command(["docker", "inspect", name], timeout=10).returncode == 0
422
+
423
+ def container_state(self, name: str, field: str) -> bool:
424
+ completed = self.run_command(
425
+ ["docker", "inspect", "--format", f"{{{{.State.{field}}}}}", name],
426
+ timeout=10,
427
+ )
428
+ return completed.returncode == 0 and completed.stdout.strip() == "true"
429
+
430
+ def container_running(self, name: str) -> bool:
431
+ return self.container_state(name, "Running")
432
+
433
+ def container_oom_killed(self, name: str) -> bool:
434
+ return self.container_state(name, "OOMKilled")
435
+
436
+ def remove_container(self, name: str) -> None:
437
+ self.run_command(["docker", "rm", "--force", name], timeout=30)
438
+
439
+ def remove_workspace(self, path: Path) -> None:
440
+ path = path.resolve()
441
+ self.require_owned_path(path)
442
+ if path.exists():
443
+ shutil.rmtree(path)
444
+
445
+ @staticmethod
446
+ def require_owned_path(path: Path) -> None:
447
+ sessions_root = (settings.RUNNER_WORK_ROOT / "sessions").resolve()
448
+ if path == sessions_root or not path.is_relative_to(sessions_root):
449
+ raise RunnerProtocolError("Runner workspace escaped its owned root.")
450
+
451
+ @staticmethod
452
+ def run_command(
453
+ command: list[str], timeout: int = 30
454
+ ) -> subprocess.CompletedProcess[str]:
455
+ return subprocess.run(
456
+ command,
457
+ capture_output=True,
458
+ text=True,
459
+ timeout=timeout,
460
+ check=False,
461
+ )
462
+
463
+
464
+ def get_runner_service() -> DockerRunnerService:
465
+ return DockerRunnerService()