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,276 @@
1
+ """What a session leaves behind: its file changes and the files it saved.
2
+
3
+ When a session that collects changes starts, the runner records the unpacked
4
+ commit in a git folder beside the workspace, outside the sandbox's mounts, so
5
+ the agent cannot change it. Recording costs one write per file, so only runs
6
+ that publish changes ask for it. After the agent finishes, the runner stops the sandbox and compares the
7
+ workspace with that record. The result is one archive:
8
+
9
+ - ``manifest.json`` describes the changes and the saved files
10
+ - ``changes.patch`` is a binary-safe git diff, if anything changed
11
+ - ``outputs/<name>`` holds each regular file the agent saved in /outputs
12
+
13
+ Git runs here with no system, global or repository settings that the
14
+ workspace could supply, and never runs hooks, filters or external diff tools.
15
+ """
16
+
17
+ import hashlib
18
+ import io
19
+ import json
20
+ import os
21
+ import stat
22
+ import subprocess
23
+ import tarfile
24
+ from pathlib import Path, PurePosixPath
25
+ from typing import IO, Any
26
+
27
+ from secondshift.config.constants import (
28
+ MAX_CHANGES_BYTES,
29
+ MAX_OUTPUT_BYTES,
30
+ MAX_OUTPUT_FILES,
31
+ )
32
+ from secondshift.runner.protocol import RunnerProtocolError
33
+
34
+ BASELINE_DIR = "baseline.git"
35
+ OUTPUTS_DIR = "outputs"
36
+ COLLECTION_FILE = "collection.tar.gz"
37
+ MANIFEST_NAME = "manifest.json"
38
+ CHANGES_NAME = "changes.patch"
39
+ # A long list of names helps nobody. The patch itself holds every path.
40
+ MAX_LISTED_CHANGES = 1000
41
+ GIT_TIMEOUT_SECONDS = 300
42
+ NO_CHANGES = {
43
+ "file_count": 0,
44
+ "files": [],
45
+ "size_bytes": 0,
46
+ "captured": False,
47
+ "too_large": False,
48
+ }
49
+
50
+
51
+ def git(
52
+ session_root: Path,
53
+ workspace: Path,
54
+ *args: str,
55
+ stdout: IO[bytes] | None = None,
56
+ work_tree: bool = True,
57
+ ) -> subprocess.CompletedProcess[bytes]:
58
+ git_dir = session_root / BASELINE_DIR
59
+ command = [
60
+ "git",
61
+ # The workspace belongs to the sandbox user, not to the runner.
62
+ "-c",
63
+ "safe.directory=*",
64
+ "-c",
65
+ "core.hooksPath=/dev/null",
66
+ "-c",
67
+ "core.fsmonitor=false",
68
+ "-c",
69
+ "core.autocrlf=false",
70
+ "-c",
71
+ "core.symlinks=true",
72
+ f"--git-dir={git_dir}",
73
+ # git init refuses a work tree. Every other command compares one.
74
+ *([f"--work-tree={workspace}"] if work_tree else []),
75
+ *args,
76
+ ]
77
+ environment = {
78
+ "PATH": os.environ.get("PATH", "/usr/bin:/bin"),
79
+ "HOME": str(git_dir),
80
+ "LC_ALL": "C",
81
+ "GIT_CONFIG_NOSYSTEM": "1",
82
+ "GIT_CONFIG_GLOBAL": os.devnull,
83
+ "GIT_AUTHOR_NAME": "SecondShift",
84
+ "GIT_AUTHOR_EMAIL": "runner@secondshift.invalid",
85
+ "GIT_COMMITTER_NAME": "SecondShift",
86
+ "GIT_COMMITTER_EMAIL": "runner@secondshift.invalid",
87
+ }
88
+ try:
89
+ return subprocess.run(
90
+ command,
91
+ cwd=workspace,
92
+ env=environment,
93
+ stdout=stdout if stdout is not None else subprocess.PIPE,
94
+ stderr=subprocess.PIPE,
95
+ timeout=GIT_TIMEOUT_SECONDS,
96
+ check=False,
97
+ )
98
+ except (OSError, subprocess.TimeoutExpired) as error:
99
+ raise RunnerProtocolError("The runner could not compare the files.") from error
100
+
101
+
102
+ def require_git(result: subprocess.CompletedProcess[bytes]) -> None:
103
+ if result.returncode:
104
+ raise RunnerProtocolError("The runner could not compare the files.")
105
+
106
+
107
+ def record_baseline(session_root: Path, workspace: Path) -> None:
108
+ """Commit the unpacked files, before the sandbox can change them."""
109
+ git_dir = session_root / BASELINE_DIR
110
+ git_dir.mkdir(mode=0o700)
111
+ require_git(
112
+ git(session_root, workspace, "init", "--quiet", "--bare", work_tree=False)
113
+ )
114
+ # Forced, so a committed file that matches .gitignore is compared too.
115
+ require_git(git(session_root, workspace, "add", "--all", "--force", "."))
116
+ require_git(
117
+ git(
118
+ session_root,
119
+ workspace,
120
+ "commit",
121
+ "--quiet",
122
+ "--no-verify",
123
+ "--allow-empty",
124
+ "--message=Run baseline",
125
+ )
126
+ )
127
+
128
+
129
+ def collect(session_root: Path, workspace: Path) -> Path:
130
+ """Build the session's archive once. Later calls return the same one.
131
+
132
+ The sandbox must already be stopped, so nothing changes the files while
133
+ they are read.
134
+ """
135
+ archive_path = session_root / COLLECTION_FILE
136
+ if archive_path.is_file():
137
+ return archive_path
138
+ partial = session_root / f"{COLLECTION_FILE}.partial"
139
+ patch_path = session_root / CHANGES_NAME
140
+ # Sessions that did not ask for changes have no baseline to compare.
141
+ manifest: dict[str, Any] = {
142
+ "changes": compare(session_root, workspace, patch_path)
143
+ if (session_root / BASELINE_DIR).is_dir()
144
+ else NO_CHANGES
145
+ }
146
+ with tarfile.open(partial, mode="w:gz") as archive:
147
+ if manifest["changes"]["captured"]:
148
+ add_file(archive, patch_path, CHANGES_NAME)
149
+ manifest["outputs"], manifest["skipped"] = add_outputs(
150
+ archive, session_root / OUTPUTS_DIR
151
+ )
152
+ add_bytes(archive, MANIFEST_NAME, json.dumps(manifest).encode())
153
+ patch_path.unlink(missing_ok=True)
154
+ os.replace(partial, archive_path)
155
+ return archive_path
156
+
157
+
158
+ def compare(session_root: Path, workspace: Path, patch_path: Path) -> dict[str, Any]:
159
+ """Write the changes since the baseline, if they fit, and describe them."""
160
+ # Not forced: new files that match .gitignore, such as build output, stay out.
161
+ require_git(git(session_root, workspace, "add", "--all", "."))
162
+ names = git(session_root, workspace, "diff", "--cached", "--name-only", "-z")
163
+ require_git(names)
164
+ paths = [
165
+ item.decode(errors="replace") for item in names.stdout.split(b"\0") if item
166
+ ]
167
+ with patch_path.open("wb") as stream:
168
+ require_git(
169
+ git(
170
+ session_root,
171
+ workspace,
172
+ "diff",
173
+ "--cached",
174
+ "--binary",
175
+ "--no-color",
176
+ "--no-ext-diff",
177
+ "--no-textconv",
178
+ "--full-index",
179
+ stdout=stream,
180
+ )
181
+ )
182
+ size = patch_path.stat().st_size
183
+ return {
184
+ "file_count": len(paths),
185
+ "files": paths[:MAX_LISTED_CHANGES],
186
+ "size_bytes": size,
187
+ "captured": bool(paths) and size <= MAX_CHANGES_BYTES,
188
+ "too_large": size > MAX_CHANGES_BYTES,
189
+ }
190
+
191
+
192
+ def add_outputs(
193
+ archive: tarfile.TarFile, outputs: Path
194
+ ) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
195
+ saved: list[dict[str, Any]] = []
196
+ skipped: list[dict[str, str]] = []
197
+ if not outputs.is_dir():
198
+ return saved, skipped
199
+ total = 0
200
+ for root, directories, files in os.walk(outputs, followlinks=False):
201
+ # Linked folders are never entered, and are listed as skipped.
202
+ for name in [item for item in directories if (Path(root) / item).is_symlink()]:
203
+ directories.remove(name)
204
+ relative = (Path(root) / name).relative_to(outputs).as_posix()
205
+ skipped.append({"name": relative, "reason": "Not a regular file."})
206
+ directories.sort()
207
+ for name in sorted(files):
208
+ path = Path(root) / name
209
+ relative = path.relative_to(outputs).as_posix()
210
+ reason = output_problem(path, relative, len(saved), total)
211
+ if reason:
212
+ skipped.append({"name": relative, "reason": reason})
213
+ continue
214
+ size, digest = add_file(archive, path, f"{OUTPUTS_DIR}/{relative}")
215
+ total += size
216
+ saved.append({"name": relative, "size_bytes": size, "sha256": digest})
217
+ return saved, skipped
218
+
219
+
220
+ def output_problem(path: Path, relative: str, count: int, total: int) -> str:
221
+ info = path.lstat()
222
+ if not stat.S_ISREG(info.st_mode):
223
+ return "Not a regular file."
224
+ if not safe_output_name(relative):
225
+ return "The file name is not allowed."
226
+ if count >= MAX_OUTPUT_FILES:
227
+ return f"Only the first {MAX_OUTPUT_FILES} files are kept."
228
+ if total + info.st_size > MAX_OUTPUT_BYTES:
229
+ return "The files are larger than the limit."
230
+ return ""
231
+
232
+
233
+ def safe_output_name(name: str) -> bool:
234
+ """A relative path of plain parts, safe to use as a storage path."""
235
+ path = PurePosixPath(name)
236
+ return (
237
+ 0 < len(name) <= 255
238
+ and not path.is_absolute()
239
+ and all(part not in {"", ".", ".."} for part in path.parts)
240
+ and all(char.isprintable() and char != "\\" for char in name)
241
+ )
242
+
243
+
244
+ def add_file(archive: tarfile.TarFile, path: Path, name: str) -> tuple[int, str]:
245
+ """Add one regular file under a fixed owner and mode. Links are refused."""
246
+ descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
247
+ with os.fdopen(descriptor, "rb") as stream:
248
+ info = os.fstat(stream.fileno())
249
+ if not stat.S_ISREG(info.st_mode):
250
+ raise RunnerProtocolError("A collected file is not a regular file.")
251
+ member = tarfile.TarInfo(name)
252
+ member.size = info.st_size
253
+ member.mode = 0o600
254
+ reader = HashingReader(stream)
255
+ archive.addfile(member, reader)
256
+ return info.st_size, reader.digest.hexdigest()
257
+
258
+
259
+ class HashingReader:
260
+ """Passes a stream to tarfile and keeps the SHA-256 of what it read."""
261
+
262
+ def __init__(self, stream: IO[bytes]) -> None:
263
+ self.stream = stream
264
+ self.digest = hashlib.sha256()
265
+
266
+ def read(self, size: int = -1) -> bytes:
267
+ chunk = self.stream.read(size)
268
+ self.digest.update(chunk)
269
+ return chunk
270
+
271
+
272
+ def add_bytes(archive: tarfile.TarFile, name: str, content: bytes) -> None:
273
+ member = tarfile.TarInfo(name)
274
+ member.size = len(content)
275
+ member.mode = 0o600
276
+ archive.addfile(member, io.BytesIO(content))
@@ -0,0 +1,64 @@
1
+ """Queries and writes on runner sessions and the commands run in them."""
2
+
3
+ from typing import Any
4
+
5
+ from secondshift.runner.models import RunnerCommand, RunnerSession
6
+
7
+
8
+ def has_sessions() -> bool:
9
+ """Whether any session exists. The health check uses it to reach the database."""
10
+ return RunnerSession.objects.exists()
11
+
12
+
13
+ def get_session(session_id: str) -> RunnerSession:
14
+ """The session. Raises RunnerSession.DoesNotExist when there is none."""
15
+ return RunnerSession.objects.get(session_id=session_id)
16
+
17
+
18
+ def lock_session(session_id: str, active_only: bool = False) -> RunnerSession | None:
19
+ """Lock the session for this transaction. None when it does not exist."""
20
+ sessions = RunnerSession.objects.select_for_update().filter(session_id=session_id)
21
+ if active_only:
22
+ sessions = sessions.filter(status=RunnerSession.Status.ACTIVE)
23
+ return sessions.first()
24
+
25
+
26
+ def is_session_active(session_id: str) -> bool:
27
+ return RunnerSession.objects.filter(
28
+ session_id=session_id, status=RunnerSession.Status.ACTIVE
29
+ ).exists()
30
+
31
+
32
+ def create_session(**fields: Any) -> RunnerSession:
33
+ """Record a new session. Raises IntegrityError when the run already has one."""
34
+ return RunnerSession.objects.create(**fields)
35
+
36
+
37
+ def set_session_status(session_id: str, status: RunnerSession.Status) -> None:
38
+ RunnerSession.objects.filter(session_id=session_id).update(status=status)
39
+
40
+
41
+ def session_command(session: RunnerSession, call_id: str) -> RunnerCommand | None:
42
+ """The command already reserved for this call, if any."""
43
+ return RunnerCommand.objects.filter(session=session, call_id=call_id).first()
44
+
45
+
46
+ def create_command(
47
+ session: RunnerSession, call_id: str, fingerprint: str
48
+ ) -> RunnerCommand:
49
+ """Reserve a call before running it."""
50
+ return RunnerCommand.objects.create(
51
+ session=session, call_id=call_id, fingerprint=fingerprint
52
+ )
53
+
54
+
55
+ def complete_command(
56
+ session: RunnerSession, call_id: str, exit_code: int, output: str, timed_out: bool
57
+ ) -> None:
58
+ """Store the outcome of a reserved call."""
59
+ RunnerCommand.objects.filter(session=session, call_id=call_id).update(
60
+ status=RunnerCommand.Status.COMPLETED,
61
+ exit_code=exit_code,
62
+ output=output,
63
+ timed_out=timed_out,
64
+ )
@@ -0,0 +1,19 @@
1
+ """Start the development runner with the exact locally built agent image."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ from secondshift.config.constants import AppEnv
7
+ from secondshift.runner.images import resolve_image_id
8
+
9
+
10
+ def main() -> None:
11
+ image = os.environ.get(AppEnv.AGENT_IMAGE, "")
12
+ if not image:
13
+ raise ValueError("The development agent image must be configured.")
14
+ os.environ[AppEnv.AGENT_IMAGE] = resolve_image_id(image)
15
+ os.execv(sys.executable, [sys.executable, "-m", "gunicorn", "--reload"])
16
+
17
+
18
+ if __name__ == "__main__":
19
+ main()
@@ -0,0 +1,57 @@
1
+ """Build the agent image from the files shipped in the package."""
2
+
3
+ import re
4
+ import shutil
5
+ import subprocess
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ from secondshift.config.constants import LOCAL_AGENT_TAG
10
+
11
+ AGENT_IMAGE_DIR = Path(__file__).parent / "agent_image"
12
+ BUILD_SECONDS = 1800
13
+
14
+
15
+ def resolve_image_id(image: str) -> str:
16
+ """The immutable ID behind a local image name."""
17
+ result = subprocess.run(
18
+ ["docker", "image", "inspect", "--format", "{{.Id}}", image],
19
+ capture_output=True,
20
+ text=True,
21
+ check=True,
22
+ timeout=30,
23
+ )
24
+ image_id = result.stdout.strip()
25
+ if not re.fullmatch(r"sha256:[0-9a-f]{64}", image_id):
26
+ raise ValueError("Docker did not return an immutable agent image ID.")
27
+ return image_id
28
+
29
+
30
+ def build_agent_image(tag: str = LOCAL_AGENT_TAG) -> str:
31
+ """Build the pinned agent image and return its immutable ID.
32
+
33
+ The build context holds only the agent files, laid out as in the checkout,
34
+ so Docker never uploads the rest of the installed packages.
35
+ """
36
+ with tempfile.TemporaryDirectory(prefix="secondshift-agent-") as context:
37
+ target = Path(context) / "secondshift" / "runner" / "agent_image"
38
+ shutil.copytree(
39
+ AGENT_IMAGE_DIR, target, ignore=shutil.ignore_patterns("__pycache__")
40
+ )
41
+ subprocess.run(
42
+ [
43
+ "docker",
44
+ "build",
45
+ "--quiet",
46
+ "-f",
47
+ str(target / "Dockerfile"),
48
+ "-t",
49
+ tag,
50
+ context,
51
+ ],
52
+ capture_output=True,
53
+ text=True,
54
+ check=True,
55
+ timeout=BUILD_SECONDS,
56
+ )
57
+ return resolve_image_id(tag)
@@ -0,0 +1,48 @@
1
+ # Generated by Django 6.0.9 on 2026-10-26 14:22
2
+
3
+ import django.db.models.deletion
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ initial = True
10
+
11
+ dependencies = [
12
+ ]
13
+
14
+ operations = [
15
+ migrations.CreateModel(
16
+ name='RunnerSession',
17
+ fields=[
18
+ ('session_id', models.CharField(max_length=80, primary_key=True, serialize=False)),
19
+ ('run_id', models.PositiveBigIntegerField(db_index=True)),
20
+ ('repository', models.CharField(max_length=300)),
21
+ ('commit_sha', models.CharField(max_length=64)),
22
+ ('image', models.CharField(max_length=300)),
23
+ ('container_name', models.CharField(max_length=120, unique=True)),
24
+ ('workspace_path', models.TextField()),
25
+ ('status', models.CharField(choices=[('starting', 'Starting'), ('active', 'Active'), ('stopped', 'Stopped'), ('error', 'Error')], default='starting', max_length=16)),
26
+ ('created_at', models.DateTimeField(auto_now_add=True)),
27
+ ('updated_at', models.DateTimeField(auto_now=True)),
28
+ ],
29
+ ),
30
+ migrations.CreateModel(
31
+ name='RunnerCommand',
32
+ fields=[
33
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
34
+ ('call_id', models.CharField(max_length=200)),
35
+ ('fingerprint', models.CharField(max_length=64)),
36
+ ('status', models.CharField(choices=[('running', 'Running'), ('completed', 'Completed')], default='running', max_length=16)),
37
+ ('exit_code', models.IntegerField(blank=True, null=True)),
38
+ ('output', models.TextField(default='')),
39
+ ('timed_out', models.BooleanField(default=False)),
40
+ ('created_at', models.DateTimeField(auto_now_add=True)),
41
+ ('updated_at', models.DateTimeField(auto_now=True)),
42
+ ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='commands', to='runner.runnersession')),
43
+ ],
44
+ options={
45
+ 'constraints': [models.UniqueConstraint(fields=('session', 'call_id'), name='runner_unique_session_call')],
46
+ },
47
+ ),
48
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Django 6.0.9 on 2026-10-29 22:44
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('runner', '0001_initial'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='runnersession',
15
+ name='source_sha256',
16
+ field=models.CharField(blank=True, max_length=64),
17
+ ),
18
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Django 6.0.9 on 2026-11-03 20:24
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('runner', '0002_session_source_sha256'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='runnersession',
15
+ name='collect_changes',
16
+ field=models.BooleanField(default=False),
17
+ ),
18
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Django 6.0.9 on 2026-11-07 15:03
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('runner', '0003_session_collect_changes'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='runnersession',
15
+ name='history_sha256',
16
+ field=models.CharField(blank=True, max_length=64),
17
+ ),
18
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Django 6.0.9 on 2026-11-08 23:43
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('runner', '0004_session_history_sha256'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AlterField(
14
+ model_name='runnersession',
15
+ name='source_sha256',
16
+ field=models.CharField(max_length=64),
17
+ ),
18
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Django 6.0.9 on 2026-11-11 19:21
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('runner', '0005_require_source_sha256'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='runnersession',
15
+ name='skill_sha256',
16
+ field=models.CharField(blank=True, max_length=64),
17
+ ),
18
+ ]
File without changes
@@ -0,0 +1,53 @@
1
+ from django.db import models
2
+
3
+
4
+ class RunnerSession(models.Model):
5
+ class Status(models.TextChoices):
6
+ STARTING = "starting", "Starting"
7
+ ACTIVE = "active", "Active"
8
+ STOPPED = "stopped", "Stopped"
9
+ ERROR = "error", "Error"
10
+
11
+ session_id = models.CharField(primary_key=True, max_length=80)
12
+ run_id = models.PositiveBigIntegerField(db_index=True)
13
+ repository = models.CharField(max_length=300)
14
+ commit_sha = models.CharField(max_length=64)
15
+ image = models.CharField(max_length=300)
16
+ source_sha256 = models.CharField(max_length=64)
17
+ collect_changes = models.BooleanField(default=False)
18
+ history_sha256 = models.CharField(max_length=64, blank=True)
19
+ skill_sha256 = models.CharField(max_length=64, blank=True)
20
+ container_name = models.CharField(max_length=120, unique=True)
21
+ workspace_path = models.TextField()
22
+ status = models.CharField(
23
+ max_length=16, choices=Status.choices, default=Status.STARTING
24
+ )
25
+ created_at = models.DateTimeField(auto_now_add=True)
26
+ updated_at = models.DateTimeField(auto_now=True)
27
+
28
+
29
+ class RunnerCommand(models.Model):
30
+ class Status(models.TextChoices):
31
+ RUNNING = "running", "Running"
32
+ COMPLETED = "completed", "Completed"
33
+
34
+ session = models.ForeignKey(
35
+ RunnerSession, on_delete=models.CASCADE, related_name="commands"
36
+ )
37
+ call_id = models.CharField(max_length=200)
38
+ fingerprint = models.CharField(max_length=64)
39
+ status = models.CharField(
40
+ max_length=16, choices=Status.choices, default=Status.RUNNING
41
+ )
42
+ exit_code = models.IntegerField(null=True, blank=True)
43
+ output = models.TextField(default="")
44
+ timed_out = models.BooleanField(default=False)
45
+ created_at = models.DateTimeField(auto_now_add=True)
46
+ updated_at = models.DateTimeField(auto_now=True)
47
+
48
+ class Meta:
49
+ constraints = [
50
+ models.UniqueConstraint(
51
+ fields=("session", "call_id"), name="runner_unique_session_call"
52
+ )
53
+ ]