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,169 @@
1
+ from django.conf import settings
2
+ from django.db import models
3
+
4
+
5
+ class RunLimitOverrides(models.Model):
6
+ """Run limits an owner chose. Empty means the next level up decides.
7
+
8
+ A job falls back to its workspace, and a workspace to the server defaults.
9
+ The server maximums cap every value (see secondshift.workspaces.limits).
10
+ """
11
+
12
+ max_turns = models.PositiveIntegerField(null=True, blank=True)
13
+ max_tool_calls = models.PositiveIntegerField(null=True, blank=True)
14
+ token_budget = models.PositiveBigIntegerField(null=True, blank=True)
15
+ timeout_seconds = models.PositiveIntegerField(null=True, blank=True)
16
+
17
+ class Meta:
18
+ abstract = True
19
+
20
+
21
+ class Workspace(RunLimitOverrides):
22
+ """Who belongs and with which role is kept in WorkspaceMembership only."""
23
+
24
+ # What runs the jobs: one model key or one subscription login, never both.
25
+ # Neither set means nothing is chosen and runs cannot start.
26
+ default_model_connection = models.ForeignKey(
27
+ "connections.Connection",
28
+ on_delete=models.SET_NULL,
29
+ null=True,
30
+ blank=True,
31
+ limit_choices_to={"kind": "model"},
32
+ related_name="default_workspaces",
33
+ )
34
+ default_agent_connection = models.ForeignKey(
35
+ "connections.AgentConnection",
36
+ on_delete=models.RESTRICT,
37
+ null=True,
38
+ blank=True,
39
+ related_name="default_workspaces",
40
+ )
41
+ name = models.CharField(max_length=160)
42
+ slug = models.SlugField(unique=True)
43
+ created_at = models.DateTimeField(auto_now_add=True)
44
+ updated_at = models.DateTimeField(auto_now=True)
45
+
46
+ class Meta(RunLimitOverrides.Meta):
47
+ ordering = ["name"]
48
+ constraints = [
49
+ models.CheckConstraint(
50
+ condition=models.Q(default_model_connection__isnull=True)
51
+ | models.Q(default_agent_connection__isnull=True),
52
+ name="workspace_one_execution_default",
53
+ )
54
+ ]
55
+
56
+ def __str__(self) -> str:
57
+ return self.name
58
+
59
+
60
+ class WorkspaceMembership(models.Model):
61
+ class Role(models.TextChoices):
62
+ OWNER = "owner", "Owner"
63
+ ADMIN = "admin", "Admin"
64
+ MEMBER = "member", "Member"
65
+
66
+ workspace = models.ForeignKey(
67
+ Workspace,
68
+ on_delete=models.CASCADE,
69
+ related_name="memberships",
70
+ )
71
+ user = models.ForeignKey(
72
+ settings.AUTH_USER_MODEL,
73
+ on_delete=models.CASCADE,
74
+ related_name="workspace_memberships",
75
+ )
76
+ role = models.CharField(max_length=16, choices=Role.choices, default=Role.MEMBER)
77
+ created_at = models.DateTimeField(auto_now_add=True)
78
+
79
+ class Meta:
80
+ constraints = [
81
+ models.UniqueConstraint(
82
+ fields=["workspace", "user"],
83
+ name="unique_workspace_membership",
84
+ )
85
+ ]
86
+
87
+ def __str__(self) -> str:
88
+ return f"{self.user} in {self.workspace}"
89
+
90
+
91
+ class WorkspaceInvite(models.Model):
92
+ """A one-time link that lets someone join a workspace with a role.
93
+
94
+ Only a hash of the link's token is stored, so the database alone cannot
95
+ be used to join. See secondshift.workspaces.invites.
96
+ """
97
+
98
+ workspace = models.ForeignKey(
99
+ Workspace,
100
+ on_delete=models.CASCADE,
101
+ related_name="invites",
102
+ )
103
+ role = models.CharField(
104
+ max_length=16,
105
+ choices=WorkspaceMembership.Role.choices,
106
+ default=WorkspaceMembership.Role.MEMBER,
107
+ )
108
+ # Who the link is for, so owners can tell pending invites apart.
109
+ note = models.CharField(max_length=160, blank=True)
110
+ token_hash = models.CharField(max_length=64, unique=True)
111
+ created_by = models.ForeignKey(
112
+ settings.AUTH_USER_MODEL,
113
+ on_delete=models.SET_NULL,
114
+ null=True,
115
+ blank=True,
116
+ related_name="+",
117
+ )
118
+ created_at = models.DateTimeField(auto_now_add=True)
119
+ expires_at = models.DateTimeField()
120
+ accepted_by = models.ForeignKey(
121
+ settings.AUTH_USER_MODEL,
122
+ on_delete=models.SET_NULL,
123
+ null=True,
124
+ blank=True,
125
+ related_name="+",
126
+ )
127
+ accepted_at = models.DateTimeField(null=True, blank=True)
128
+ revoked_at = models.DateTimeField(null=True, blank=True)
129
+
130
+ class Meta:
131
+ ordering = ["-created_at", "-pk"]
132
+
133
+ def __str__(self) -> str:
134
+ return f"{self.get_role_display()} invite to {self.workspace}"
135
+
136
+
137
+ class ApiToken(models.Model):
138
+ """A bearer token for the MCP server, for one person in one workspace.
139
+
140
+ Only a hash of the token is stored. See secondshift.workspaces.tokens.
141
+ """
142
+
143
+ class Scope(models.TextChoices):
144
+ READ = "read", "Read"
145
+ RUN = "run", "Read and run"
146
+
147
+ workspace = models.ForeignKey(
148
+ Workspace,
149
+ on_delete=models.CASCADE,
150
+ related_name="api_tokens",
151
+ )
152
+ created_by = models.ForeignKey(
153
+ settings.AUTH_USER_MODEL,
154
+ on_delete=models.CASCADE,
155
+ related_name="api_tokens",
156
+ )
157
+ name = models.CharField(max_length=160)
158
+ scope = models.CharField(max_length=16, choices=Scope.choices, default=Scope.READ)
159
+ token_hash = models.CharField(max_length=64, unique=True)
160
+ created_at = models.DateTimeField(auto_now_add=True)
161
+ expires_at = models.DateTimeField()
162
+ last_used_at = models.DateTimeField(null=True, blank=True)
163
+ revoked_at = models.DateTimeField(null=True, blank=True)
164
+
165
+ class Meta:
166
+ ordering = ["-created_at", "-pk"]
167
+
168
+ def __str__(self) -> str:
169
+ return f"{self.name} ({self.get_scope_display()})"
@@ -0,0 +1,58 @@
1
+ """The first account on a single-host install and the workspace it owns."""
2
+
3
+ from django.contrib.auth.models import AbstractUser
4
+
5
+ from secondshift.workspaces.dao import (
6
+ create_workspace,
7
+ ensure_membership,
8
+ find_workspace_by_slug,
9
+ get_or_create_user,
10
+ get_workspace_by_slug,
11
+ workspace_owner,
12
+ workspace_slug_has_owner,
13
+ )
14
+ from secondshift.workspaces.models import Workspace, WorkspaceMembership
15
+
16
+ WORKSPACE_SLUG = "secondshift"
17
+ WORKSPACE_NAME = "SecondShift"
18
+ # The owner's username when `secondshift up` creates it without asking.
19
+ LOCAL_OWNER_USERNAME = "owner"
20
+
21
+
22
+ def has_owner() -> bool:
23
+ return workspace_slug_has_owner(WORKSPACE_SLUG)
24
+
25
+
26
+ def get_owner() -> AbstractUser:
27
+ return workspace_owner(get_workspace_by_slug(WORKSPACE_SLUG)) # type: ignore[return-value]
28
+
29
+
30
+ def ensure_owner(email: str = "", password: str | None = None) -> Workspace:
31
+ """Create the owner and the workspace when they do not exist yet.
32
+
33
+ Without a password the owner can only sign in with a link from
34
+ `secondshift up` or `secondshift login`.
35
+ """
36
+ user, created = get_or_create_user(
37
+ email or LOCAL_OWNER_USERNAME,
38
+ {"email": email, "is_staff": True, "is_superuser": True},
39
+ )
40
+ if created:
41
+ if password:
42
+ user.set_password(password)
43
+ else:
44
+ user.set_unusable_password()
45
+ user.save()
46
+ workspace = find_workspace_by_slug(WORKSPACE_SLUG)
47
+ if workspace is None:
48
+ return create_workspace(WORKSPACE_NAME, WORKSPACE_SLUG, user)
49
+ ensure_membership(workspace, user, WorkspaceMembership.Role.OWNER)
50
+ return workspace
51
+
52
+
53
+ def set_owner_credentials(email: str, password: str) -> None:
54
+ """Let the owner sign in with an email and password too."""
55
+ user = get_owner()
56
+ user.email = email
57
+ user.set_password(password)
58
+ user.save()
@@ -0,0 +1,149 @@
1
+ from datetime import datetime
2
+ from typing import Literal
3
+
4
+ from ninja import Schema
5
+ from pydantic import Field
6
+
7
+
8
+ class LimitSchema(Schema):
9
+ key: str
10
+ label: str
11
+ help: str
12
+ unit: str
13
+ value: int
14
+ override: int | None
15
+ inherited: int
16
+ minimum: int
17
+ maximum: int
18
+
19
+
20
+ class RunLimitsUpdateSchema(Schema):
21
+ """Fields left out stay as they are. Null uses the inherited value."""
22
+
23
+ max_turns: int | None = None
24
+ max_tool_calls: int | None = None
25
+ token_budget: int | None = None
26
+ timeout_seconds: int | None = None
27
+
28
+
29
+ class SettingValueSchema(Schema):
30
+ label: str
31
+ value: str
32
+ env: str
33
+ help: str
34
+
35
+
36
+ class SettingGroupSchema(Schema):
37
+ title: str
38
+ items: list[SettingValueSchema]
39
+
40
+
41
+ class WorkspaceSettingsSchema(Schema):
42
+ workspace_name: str | None
43
+ workspace_slug: str | None
44
+ environment: str
45
+ sandbox: str
46
+ groups: list[SettingGroupSchema]
47
+ # Run limits for the whole workspace. Each job can choose its own.
48
+ limits: list[LimitSchema]
49
+ can_edit_limits: bool
50
+
51
+
52
+ class WorkspaceChoiceSchema(Schema):
53
+ id: int
54
+ name: str
55
+ slug: str
56
+ role: str
57
+ current: bool
58
+
59
+
60
+ class WorkspaceSelectSchema(Schema):
61
+ workspace_id: int
62
+
63
+
64
+ class WorkspaceCreateSchema(Schema):
65
+ name: str = Field(max_length=160)
66
+
67
+
68
+ class MemberSchema(Schema):
69
+ id: int
70
+ email: str
71
+ role: str
72
+ joined_at: datetime
73
+ you: bool
74
+
75
+
76
+ RoleName = Literal["owner", "admin", "member"]
77
+
78
+
79
+ class RoleChangeSchema(Schema):
80
+ role: RoleName
81
+
82
+
83
+ class InviteCreateSchema(Schema):
84
+ role: RoleName = "member"
85
+ note: str = Field("", max_length=160)
86
+
87
+
88
+ class InviteSchema(Schema):
89
+ id: int
90
+ role: str
91
+ note: str
92
+ created_by: str | None
93
+ created_at: datetime
94
+ expires_at: datetime
95
+
96
+
97
+ class NewInviteSchema(InviteSchema):
98
+ # Shown once, right after creating it. It cannot be looked up later.
99
+ link: str
100
+
101
+
102
+ class PendingInviteSchema(Schema):
103
+ workspace_name: str
104
+ role: str
105
+ invited_by: str | None
106
+
107
+
108
+ ScopeName = Literal["read", "run"]
109
+
110
+
111
+ class ApiTokenSchema(Schema):
112
+ id: int
113
+ name: str
114
+ scope: str
115
+ workspace_id: int
116
+ workspace_name: str
117
+ workspace_slug: str
118
+ created_at: datetime
119
+ expires_at: datetime
120
+ last_used_at: datetime | None
121
+
122
+
123
+ class TokenWorkspaceSchema(Schema):
124
+ """A workspace the person can create a token for."""
125
+
126
+ id: int
127
+ name: str
128
+ slug: str
129
+ current: bool
130
+ can_create_run_tokens: bool
131
+
132
+
133
+ class ApiTokenListSchema(Schema):
134
+ items: list[ApiTokenSchema]
135
+ workspaces: list[TokenWorkspaceSchema]
136
+ # The longest expiry, in days, a new token can have.
137
+ max_days: int
138
+
139
+
140
+ class ApiTokenCreateSchema(Schema):
141
+ workspace_id: int
142
+ name: str = Field(min_length=1, max_length=160)
143
+ scope: ScopeName = "read"
144
+ expires_in_days: int
145
+
146
+
147
+ class NewApiTokenSchema(ApiTokenSchema):
148
+ # Shown once, right after creating it. It cannot be looked up later.
149
+ token: str
@@ -0,0 +1,138 @@
1
+ from django.http import HttpRequest
2
+
3
+ from secondshift.workspaces.access import (
4
+ Action,
5
+ allows,
6
+ is_operator,
7
+ )
8
+ from secondshift.workspaces.limits import LimitError, LimitView, workspace_limit_views
9
+ from secondshift.workspaces.models import (
10
+ ApiToken,
11
+ Workspace,
12
+ WorkspaceInvite,
13
+ WorkspaceMembership,
14
+ )
15
+ from secondshift.workspaces.schemas import (
16
+ ApiTokenSchema,
17
+ InviteSchema,
18
+ LimitSchema,
19
+ MemberSchema,
20
+ SettingGroupSchema,
21
+ SettingValueSchema,
22
+ TokenWorkspaceSchema,
23
+ WorkspaceChoiceSchema,
24
+ WorkspaceSettingsSchema,
25
+ )
26
+ from secondshift.workspaces.server_settings import (
27
+ SettingValue,
28
+ environment_label,
29
+ sandbox_label,
30
+ setting_groups,
31
+ )
32
+ from secondshift.workspaces.tokens import scope_allowed
33
+
34
+
35
+ def serialize_setting(request: HttpRequest, item: SettingValue) -> SettingValueSchema:
36
+ # Only people who run the server can set the variable, so only they see it.
37
+ env = item.env if is_operator(request) else ""
38
+ return SettingValueSchema(**{**vars(item), "env": env})
39
+
40
+
41
+ def serialize_settings(
42
+ request: HttpRequest, workspace: Workspace | None
43
+ ) -> WorkspaceSettingsSchema:
44
+ return WorkspaceSettingsSchema(
45
+ workspace_name=workspace.name if workspace else None,
46
+ workspace_slug=workspace.slug if workspace else None,
47
+ environment=environment_label(),
48
+ sandbox=sandbox_label(),
49
+ groups=[
50
+ SettingGroupSchema(
51
+ title=group.title,
52
+ items=[serialize_setting(request, item) for item in group.items],
53
+ )
54
+ for group in setting_groups()
55
+ ],
56
+ limits=serialize_limits(workspace_limit_views(workspace)) if workspace else [],
57
+ can_edit_limits=allows(request, Action.CHANGE_RUN_LIMITS),
58
+ )
59
+
60
+
61
+ def serialize_choice(
62
+ membership: WorkspaceMembership, current_id: int | None
63
+ ) -> WorkspaceChoiceSchema:
64
+ workspace = membership.workspace
65
+ return WorkspaceChoiceSchema(
66
+ id=workspace.pk,
67
+ name=workspace.name,
68
+ slug=workspace.slug,
69
+ role=membership.role,
70
+ current=workspace.pk == current_id,
71
+ )
72
+
73
+
74
+ def person_label(user: object | None) -> str | None:
75
+ if user is None:
76
+ return None
77
+ return getattr(user, "email", "") or user.get_username()
78
+
79
+
80
+ def serialize_invite(invite: WorkspaceInvite) -> InviteSchema:
81
+ return InviteSchema(
82
+ id=invite.pk,
83
+ role=invite.role,
84
+ note=invite.note,
85
+ created_by=person_label(invite.created_by),
86
+ created_at=invite.created_at,
87
+ expires_at=invite.expires_at,
88
+ )
89
+
90
+
91
+ def serialize_member(
92
+ request: HttpRequest, membership: WorkspaceMembership
93
+ ) -> MemberSchema:
94
+ return MemberSchema(
95
+ id=membership.pk,
96
+ email=person_label(membership.user) or "",
97
+ role=membership.role,
98
+ joined_at=membership.created_at,
99
+ you=membership.user_id == request.user.pk,
100
+ )
101
+
102
+
103
+ def serialize_api_token(token: ApiToken) -> ApiTokenSchema:
104
+ return ApiTokenSchema(
105
+ id=token.pk,
106
+ name=token.name,
107
+ scope=token.scope,
108
+ workspace_id=token.workspace_id,
109
+ workspace_name=token.workspace.name,
110
+ workspace_slug=token.workspace.slug,
111
+ created_at=token.created_at,
112
+ expires_at=token.expires_at,
113
+ last_used_at=token.last_used_at,
114
+ )
115
+
116
+
117
+ def serialize_token_workspace(
118
+ membership: WorkspaceMembership, current_id: int | None
119
+ ) -> TokenWorkspaceSchema:
120
+ workspace = membership.workspace
121
+ return TokenWorkspaceSchema(
122
+ id=workspace.pk,
123
+ name=workspace.name,
124
+ slug=workspace.slug,
125
+ current=workspace.pk == current_id,
126
+ can_create_run_tokens=scope_allowed(membership.role, ApiToken.Scope.RUN),
127
+ )
128
+
129
+
130
+ def serialize_limits(views: list[LimitView]) -> list[LimitSchema]:
131
+ return [LimitSchema(**vars(view)) for view in views]
132
+
133
+
134
+ def limit_error_detail(request: HttpRequest, error: LimitError) -> str:
135
+ """Only people who run the server can raise a maximum, so only they get the hint."""
136
+ if error.ceiling_env and is_operator(request):
137
+ return f"{error} Set {error.ceiling_env} to allow more."
138
+ return str(error)
@@ -0,0 +1,140 @@
1
+ """The settings page: what the server enforces.
2
+
3
+ Runner settings are server-owned (see AGENTS.md), so they are shown with the
4
+ environment variable that changes them rather than edited here. Run limits are
5
+ edited on the page within server maximums (see secondshift.workspaces.limits).
6
+ Nothing secret is included: no keys, connection URLs or filesystem paths.
7
+ """
8
+
9
+ from dataclasses import dataclass
10
+
11
+ from django.conf import settings
12
+
13
+ from secondshift.config.constants import (
14
+ DEFAULT_SANDBOX_IMAGE,
15
+ AppEnv,
16
+ ArtifactStorage,
17
+ Environment,
18
+ SandboxRuntime,
19
+ )
20
+ from secondshift.config.formatting import duration_label
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class SettingValue:
25
+ label: str
26
+ value: str
27
+ env: str
28
+ help: str
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class SettingGroup:
33
+ title: str
34
+ items: list[SettingValue]
35
+
36
+
37
+ def size_label(value: int) -> str:
38
+ for unit, factor in (("MB", 1024 * 1024), ("KB", 1024)):
39
+ if value >= factor and value % factor == 0:
40
+ return f"{value // factor} {unit}"
41
+ return f"{value:,} bytes"
42
+
43
+
44
+ def environment_label() -> str:
45
+ return {Environment.DEV: "Development", Environment.LOCAL: "This computer"}.get(
46
+ settings.ENVIRONMENT, "Production"
47
+ )
48
+
49
+
50
+ def sandbox_label() -> str:
51
+ """How command sandboxes are isolated, in words."""
52
+ if settings.RUNNER_RUNTIME == SandboxRuntime.GVISOR:
53
+ return "gVisor sandbox"
54
+ return "Trusted Docker"
55
+
56
+
57
+ def image_label(image: str) -> str:
58
+ """The image name with a short digest, e.g. python:3.13-slim @ 57b29a7c050c."""
59
+ name, _, digest = image.partition("@sha256:")
60
+ return f"{name} @ {digest[:12]}" if digest else image
61
+
62
+
63
+ def repository_groups() -> list[SettingGroup]:
64
+ """Background sync applies only to local repositories."""
65
+ if not settings.LOCAL_REPOSITORIES_ENABLED:
66
+ return []
67
+ interval = settings.REPOSITORY_SYNC_INTERVAL_SECONDS
68
+ return [
69
+ SettingGroup(
70
+ "Repositories",
71
+ [
72
+ SettingValue(
73
+ "Background sync",
74
+ f"Every {duration_label(interval)}" if interval > 0 else "Off",
75
+ AppEnv.REPOSITORY_SYNC_INTERVAL_SECONDS,
76
+ "New commits on a repository's branch apply on their own. "
77
+ "Each repository can turn this off.",
78
+ ),
79
+ ],
80
+ )
81
+ ]
82
+
83
+
84
+ def setting_groups() -> list[SettingGroup]:
85
+ retention = settings.RUN_RETENTION_DAYS
86
+ return [
87
+ SettingGroup(
88
+ "Runs",
89
+ [
90
+ SettingValue(
91
+ "History kept for",
92
+ f"{retention} days" if retention > 0 else "Forever",
93
+ AppEnv.RUN_RETENTION_DAYS,
94
+ "Finished runs and their files are deleted after this.",
95
+ ),
96
+ SettingValue(
97
+ "Largest stored output",
98
+ size_label(settings.RUN_OUTPUT_MAX_BYTES),
99
+ AppEnv.RUN_OUTPUT_MAX_BYTES,
100
+ "Longer reports keep their start and end.",
101
+ ),
102
+ SettingValue(
103
+ "File storage",
104
+ "Object storage (S3)"
105
+ if settings.ARTIFACT_STORAGE == ArtifactStorage.S3
106
+ else "Server filesystem",
107
+ AppEnv.ARTIFACT_STORAGE,
108
+ "Where run input and output files are kept.",
109
+ ),
110
+ ],
111
+ ),
112
+ *repository_groups(),
113
+ SettingGroup(
114
+ "Sandbox",
115
+ [
116
+ SettingValue(
117
+ "Isolation",
118
+ sandbox_label(),
119
+ AppEnv.RUNNER_RUNTIME,
120
+ "Production requires gVisor and refuses to start without it.",
121
+ ),
122
+ SettingValue(
123
+ "Network",
124
+ "None",
125
+ "",
126
+ "Skills cannot reach the network. Model and approved MCP "
127
+ "calls go through SecondShift.",
128
+ ),
129
+ SettingValue(
130
+ "Image",
131
+ image_label(settings.RUNNER_IMAGE),
132
+ AppEnv.RUNNER_IMAGE,
133
+ "Repositories cannot choose the image."
134
+ if settings.RUNNER_IMAGE != DEFAULT_SANDBOX_IMAGE
135
+ else "Docker's official Python image, pinned. "
136
+ "Repositories cannot choose the image.",
137
+ ),
138
+ ],
139
+ ),
140
+ ]