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,150 @@
1
+ """One-time links that let someone join a workspace with a role.
2
+
3
+ An owner creates an invite and shares its link. The token appears only in
4
+ that link. The database keeps a hash of it. A link works once, until it
5
+ expires or an owner revokes it. Joining always needs the person to confirm
6
+ while signed in, so opening a link alone never adds anyone to a workspace.
7
+ """
8
+
9
+ import hashlib
10
+ import secrets
11
+ from dataclasses import dataclass
12
+ from datetime import timedelta
13
+
14
+ from django.contrib.auth.models import AbstractBaseUser
15
+ from django.db import transaction
16
+ from django.http import HttpRequest
17
+ from django.urls import reverse
18
+ from django.utils import timezone
19
+
20
+ from secondshift.workspaces.dao import (
21
+ create_invite_record,
22
+ create_membership,
23
+ find_membership,
24
+ find_usable_invite,
25
+ get_or_create_workspace,
26
+ lock_usable_invite,
27
+ workspace_has_owner,
28
+ )
29
+ from secondshift.workspaces.models import (
30
+ Workspace,
31
+ WorkspaceInvite,
32
+ WorkspaceMembership,
33
+ )
34
+
35
+ INVITE_LIFETIME = timedelta(days=7)
36
+ # An opened invite link waits in the session under this key until the person
37
+ # confirms or dismisses it.
38
+ PENDING_INVITE_SESSION_KEY = "secondshift.invite"
39
+ INVITE_UNUSABLE_MESSAGE = (
40
+ "This invitation has expired or was already used. "
41
+ "Ask a workspace owner for a new one."
42
+ )
43
+
44
+
45
+ class InviteError(Exception):
46
+ pass
47
+
48
+
49
+ class WorkspaceTaken(Exception):
50
+ pass
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class NewInvite:
55
+ invite: WorkspaceInvite
56
+ # Only here, right after creating it. It is never stored.
57
+ token: str
58
+
59
+
60
+ def token_hash(token: str) -> str:
61
+ return hashlib.sha256(token.encode()).hexdigest()
62
+
63
+
64
+ def invite_path(token: str) -> str:
65
+ return reverse("invite", args=[token])
66
+
67
+
68
+ def create_invite(
69
+ workspace: Workspace,
70
+ role: str,
71
+ created_by: AbstractBaseUser | None,
72
+ note: str = "",
73
+ ) -> NewInvite:
74
+ token = secrets.token_urlsafe(32)
75
+ invite = create_invite_record(
76
+ workspace,
77
+ WorkspaceMembership.Role(role),
78
+ note,
79
+ token_hash(token),
80
+ created_by,
81
+ timezone.now() + INVITE_LIFETIME,
82
+ )
83
+ return NewInvite(invite, token)
84
+
85
+
86
+ def find_invite(token: str) -> WorkspaceInvite | None:
87
+ """The invite for a token while it can still be accepted."""
88
+ return find_usable_invite(token_hash(token))
89
+
90
+
91
+ @transaction.atomic
92
+ def accept_invite(token: str, user: AbstractBaseUser) -> WorkspaceMembership:
93
+ """Add the person to the invite's workspace and use the invite up.
94
+
95
+ Someone who already belongs keeps their role, and the link stays unused.
96
+ """
97
+ invite = lock_usable_invite(token_hash(token))
98
+ if invite is None:
99
+ raise InviteError(INVITE_UNUSABLE_MESSAGE)
100
+ existing = find_membership(invite.workspace_id, user.pk)
101
+ if existing:
102
+ return existing
103
+ membership = create_membership(invite.workspace_id, user, invite.role)
104
+ invite.accepted_by = user
105
+ invite.accepted_at = timezone.now()
106
+ invite.save(update_fields=["accepted_by", "accepted_at"])
107
+ return membership
108
+
109
+
110
+ def revoke_invite(invite: WorkspaceInvite) -> None:
111
+ invite.revoked_at = timezone.now()
112
+ invite.save(update_fields=["revoked_at"])
113
+
114
+
115
+ def remember_invite(request: HttpRequest, token: str) -> None:
116
+ request.session[PENDING_INVITE_SESSION_KEY] = token
117
+
118
+
119
+ def pending_invite(request: HttpRequest) -> WorkspaceInvite | None:
120
+ """The invite this browser opened, while it can still be accepted."""
121
+ token = request.session.get(PENDING_INVITE_SESSION_KEY)
122
+ return find_invite(token) if token else None
123
+
124
+
125
+ def forget_invite(request: HttpRequest) -> None:
126
+ request.session.pop(PENDING_INVITE_SESSION_KEY, None)
127
+
128
+
129
+ def join_pending_invite(request: HttpRequest) -> WorkspaceMembership:
130
+ """Accept the invite waiting in the session, for the signed-in person."""
131
+ token = request.session.get(PENDING_INVITE_SESSION_KEY)
132
+ forget_invite(request)
133
+ if not token:
134
+ raise InviteError(INVITE_UNUSABLE_MESSAGE)
135
+ return accept_invite(token, request.user)
136
+
137
+
138
+ @transaction.atomic
139
+ def invite_first_owner(name: str, slug: str) -> NewInvite:
140
+ """An owner invite for a new workspace, or one that has no owner yet.
141
+
142
+ This is how a server gets its first owner without any account existing.
143
+ """
144
+ workspace = get_or_create_workspace(slug, name)
145
+ if workspace_has_owner(workspace.pk):
146
+ raise WorkspaceTaken(
147
+ f"{workspace.name} already has an owner. Owners invite people "
148
+ "from the Settings page."
149
+ )
150
+ return create_invite(workspace, WorkspaceMembership.Role.OWNER, None)
@@ -0,0 +1,197 @@
1
+ """Run limits: how far one run may go.
2
+
3
+ Each limit has a server default and a server maximum, both set through
4
+ environment variables. A workspace owner can choose a value for the workspace
5
+ and for each job, up to the maximum. A job's value wins over the workspace's,
6
+ and the workspace's wins over the default. Repository configuration cannot set
7
+ limits.
8
+
9
+ The values that apply are frozen into the run's execution plan when the run is
10
+ created. Changing a limit never affects a run that is already queued or running.
11
+ """
12
+
13
+ from collections.abc import Callable, Mapping
14
+ from dataclasses import dataclass
15
+ from enum import StrEnum
16
+
17
+ from django.conf import settings
18
+
19
+ from secondshift.workspaces.models import RunLimitOverrides, Workspace
20
+
21
+
22
+ class RunLimit(StrEnum):
23
+ MAX_TURNS = "max_turns"
24
+ MAX_TOOL_CALLS = "max_tool_calls"
25
+ TOKEN_BUDGET = "token_budget"
26
+ TIMEOUT_SECONDS = "timeout_seconds"
27
+
28
+
29
+ class LimitUnit(StrEnum):
30
+ COUNT = "count"
31
+ TOKENS = "tokens"
32
+ SECONDS = "seconds"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class LimitSpec:
37
+ label: str
38
+ help: str
39
+ unit: LimitUnit
40
+ # The Django setting for the default. Its maximum adds "_CEILING".
41
+ setting: str
42
+ minimum: int
43
+
44
+ @property
45
+ def env(self) -> str:
46
+ return f"SECONDSHIFT_{self.setting}"
47
+
48
+ @property
49
+ def ceiling_env(self) -> str:
50
+ return f"{self.env}_CEILING"
51
+
52
+ def default(self) -> int:
53
+ return int(getattr(settings, self.setting))
54
+
55
+ def ceiling(self) -> int:
56
+ return int(getattr(settings, f"{self.setting}_CEILING"))
57
+
58
+
59
+ LIMIT_SPECS = {
60
+ RunLimit.MAX_TURNS: LimitSpec(
61
+ "Model calls",
62
+ "Shown on runs as turns, e.g. 5/24. The last one writes the report.",
63
+ LimitUnit.COUNT,
64
+ "AGENT_MAX_TURNS",
65
+ minimum=1,
66
+ ),
67
+ RunLimit.MAX_TOOL_CALLS: LimitSpec(
68
+ "Tool calls",
69
+ "Reading files, searching, running commands and calling connected services.",
70
+ LimitUnit.COUNT,
71
+ "AGENT_MAX_TOOL_CALLS",
72
+ minimum=0,
73
+ ),
74
+ RunLimit.TOKEN_BUDGET: LimitSpec(
75
+ "Tokens",
76
+ "Input and output tokens. Cached input counts as a tenth. Model calls past this are refused.",
77
+ LimitUnit.TOKENS,
78
+ "MODEL_RUN_TOKEN_BUDGET",
79
+ minimum=1_000,
80
+ ),
81
+ RunLimit.TIMEOUT_SECONDS: LimitSpec(
82
+ "Time",
83
+ "Counted from when the run starts. Waiting to start does not count.",
84
+ LimitUnit.SECONDS,
85
+ "RUN_TIMEOUT_SECONDS",
86
+ minimum=60,
87
+ ),
88
+ }
89
+
90
+
91
+ class LimitError(ValueError):
92
+ def __init__(self, message: str, ceiling_env: str = "") -> None:
93
+ super().__init__(message)
94
+ # The variable that raises the maximum, for people who run the server.
95
+ self.ceiling_env = ceiling_env
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class LimitView:
100
+ """One limit as an owner sees it at one level (workspace or job)."""
101
+
102
+ key: str
103
+ label: str
104
+ help: str
105
+ unit: str
106
+ # What a run would get now.
107
+ value: int
108
+ # What this level chose, or None to use `inherited`.
109
+ override: int | None
110
+ # What applies when this level chooses nothing.
111
+ inherited: int
112
+ minimum: int
113
+ maximum: int
114
+
115
+
116
+ def server_default(limit: RunLimit) -> int:
117
+ # Settings refuse a maximum below its default, so no cap is needed.
118
+ return LIMIT_SPECS[limit].default()
119
+
120
+
121
+ def capped(limit: RunLimit, value: int) -> int:
122
+ # A maximum lowered after an owner chose a value still applies.
123
+ return min(value, LIMIT_SPECS[limit].ceiling())
124
+
125
+
126
+ def workspace_value(workspace: Workspace, limit: RunLimit) -> int:
127
+ chosen = getattr(workspace, limit)
128
+ return capped(limit, chosen) if chosen is not None else server_default(limit)
129
+
130
+
131
+ def workspace_limits(workspace: Workspace) -> dict[str, int]:
132
+ """The limits a run gets from the workspace alone, for its execution plan."""
133
+ return {str(limit): workspace_value(workspace, limit) for limit in RunLimit}
134
+
135
+
136
+ def limit_views(
137
+ overrides: RunLimitOverrides, inherited: Callable[[RunLimit], int]
138
+ ) -> list[LimitView]:
139
+ """Each limit at one level: what it chose, and what applies without a choice."""
140
+ views: list[LimitView] = []
141
+ for limit, spec in LIMIT_SPECS.items():
142
+ override = getattr(overrides, limit)
143
+ fallback = inherited(limit)
144
+ views.append(
145
+ LimitView(
146
+ key=str(limit),
147
+ label=spec.label,
148
+ help=spec.help,
149
+ unit=str(spec.unit),
150
+ value=capped(limit, override) if override is not None else fallback,
151
+ override=override,
152
+ inherited=fallback,
153
+ minimum=spec.minimum,
154
+ maximum=spec.ceiling(),
155
+ )
156
+ )
157
+ return views
158
+
159
+
160
+ def workspace_limit_views(workspace: Workspace) -> list[LimitView]:
161
+ return limit_views(workspace, server_default)
162
+
163
+
164
+ def validate_overrides(values: Mapping[str, int | None]) -> dict[str, int | None]:
165
+ """Check owner input against the server bounds. Unknown keys are refused."""
166
+ unknown = set(values) - {str(limit) for limit in RunLimit}
167
+ if unknown:
168
+ raise LimitError(f"Unknown limit: {', '.join(sorted(unknown))}.")
169
+ checked: dict[str, int | None] = {}
170
+ for key, value in values.items():
171
+ spec = LIMIT_SPECS[RunLimit(key)]
172
+ if value is not None and not spec.minimum <= value <= spec.ceiling():
173
+ raise LimitError(
174
+ f"{spec.label} must be between {spec.minimum:,} and "
175
+ f"{spec.ceiling():,}.",
176
+ ceiling_env=spec.ceiling_env,
177
+ )
178
+ checked[key] = value
179
+ return checked
180
+
181
+
182
+ def save_overrides(target: RunLimitOverrides, values: Mapping[str, int | None]) -> None:
183
+ checked = validate_overrides(values)
184
+ for key, value in checked.items():
185
+ setattr(target, key, value)
186
+ target.save(update_fields=[*checked, "updated_at"])
187
+
188
+
189
+ def raise_hint(limit: RunLimit, value: int) -> str:
190
+ """How to allow more than `value`, for messages about a stopped run.
191
+
192
+ The message is stored with the run and shown to every member, so it does
193
+ not name the server variable. Only staff can set it.
194
+ """
195
+ if value < LIMIT_SPECS[limit].ceiling():
196
+ return "Raise it in the job's limits."
197
+ return "That is the server maximum. Whoever runs this server can raise it."
File without changes
File without changes
@@ -0,0 +1,39 @@
1
+ import djclick as click
2
+ from django.conf import settings
3
+ from django.utils.text import slugify
4
+
5
+ from secondshift.workspaces.invites import (
6
+ INVITE_LIFETIME,
7
+ WorkspaceTaken,
8
+ invite_first_owner,
9
+ invite_path,
10
+ )
11
+
12
+
13
+ @click.command()
14
+ @click.argument("name")
15
+ @click.option("--slug", help="Short name used in the database. From NAME by default.")
16
+ @click.option(
17
+ "--url",
18
+ default=settings.FRONTEND_URL,
19
+ show_default=True,
20
+ help="Where people open SecondShift.",
21
+ )
22
+ def command(name: str, slug: str | None, url: str) -> None:
23
+ """Create a workspace and print a link that makes its first owner.
24
+
25
+ Run it again for a workspace whose link expired before anyone used it.
26
+ """
27
+ slug = slug or slugify(name)
28
+ if not slug:
29
+ raise click.ClickException("Choose a --slug with letters or numbers.")
30
+ try:
31
+ new = invite_first_owner(name, slug)
32
+ except WorkspaceTaken as error:
33
+ raise click.ClickException(str(error)) from error
34
+ click.echo(f"Workspace {new.invite.workspace.name} ({slug}) is ready.")
35
+ click.echo(
36
+ f"Open this link to sign in or sign up as its owner. "
37
+ f"It works once, within {INVITE_LIFETIME.days} days:"
38
+ )
39
+ click.echo(f" {url.rstrip('/')}{invite_path(new.token)}")
@@ -0,0 +1,67 @@
1
+ # Generated by Django 6.0.9 on 2026-10-26 14:22
2
+
3
+ import django.db.models.deletion
4
+ from django.conf import settings
5
+ from django.db import migrations, models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ initial = True
11
+
12
+ dependencies = [
13
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14
+ ]
15
+
16
+ operations = [
17
+ migrations.CreateModel(
18
+ name='Workspace',
19
+ fields=[
20
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
21
+ ('max_turns', models.PositiveIntegerField(blank=True, null=True)),
22
+ ('max_tool_calls', models.PositiveIntegerField(blank=True, null=True)),
23
+ ('token_budget', models.PositiveBigIntegerField(blank=True, null=True)),
24
+ ('timeout_seconds', models.PositiveIntegerField(blank=True, null=True)),
25
+ ('name', models.CharField(max_length=160)),
26
+ ('slug', models.SlugField(unique=True)),
27
+ ('created_at', models.DateTimeField(auto_now_add=True)),
28
+ ('updated_at', models.DateTimeField(auto_now=True)),
29
+ ],
30
+ options={
31
+ 'ordering': ['name'],
32
+ 'abstract': False,
33
+ },
34
+ ),
35
+ migrations.CreateModel(
36
+ name='WorkspaceInvite',
37
+ fields=[
38
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
39
+ ('role', models.CharField(choices=[('owner', 'Owner'), ('admin', 'Admin'), ('member', 'Member')], default='member', max_length=16)),
40
+ ('note', models.CharField(blank=True, max_length=160)),
41
+ ('token_hash', models.CharField(max_length=64, unique=True)),
42
+ ('created_at', models.DateTimeField(auto_now_add=True)),
43
+ ('expires_at', models.DateTimeField()),
44
+ ('accepted_at', models.DateTimeField(blank=True, null=True)),
45
+ ('revoked_at', models.DateTimeField(blank=True, null=True)),
46
+ ('accepted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
47
+ ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
48
+ ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites', to='workspaces.workspace')),
49
+ ],
50
+ options={
51
+ 'ordering': ['-created_at', '-pk'],
52
+ },
53
+ ),
54
+ migrations.CreateModel(
55
+ name='WorkspaceMembership',
56
+ fields=[
57
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
58
+ ('role', models.CharField(choices=[('owner', 'Owner'), ('admin', 'Admin'), ('member', 'Member')], default='member', max_length=16)),
59
+ ('created_at', models.DateTimeField(auto_now_add=True)),
60
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_memberships', to=settings.AUTH_USER_MODEL)),
61
+ ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='workspaces.workspace')),
62
+ ],
63
+ options={
64
+ 'constraints': [models.UniqueConstraint(fields=('workspace', 'user'), name='unique_workspace_membership')],
65
+ },
66
+ ),
67
+ ]
@@ -0,0 +1,34 @@
1
+ # Generated by Django 6.0.9 on 2026-10-29 07:44
2
+
3
+ import django.db.models.deletion
4
+ from django.conf import settings
5
+ from django.db import migrations, models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ dependencies = [
11
+ ('workspaces', '0001_initial'),
12
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13
+ ]
14
+
15
+ operations = [
16
+ migrations.CreateModel(
17
+ name='ApiToken',
18
+ fields=[
19
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
20
+ ('name', models.CharField(max_length=160)),
21
+ ('scope', models.CharField(choices=[('read', 'Read'), ('run', 'Read and run')], default='read', max_length=16)),
22
+ ('token_hash', models.CharField(max_length=64, unique=True)),
23
+ ('created_at', models.DateTimeField(auto_now_add=True)),
24
+ ('expires_at', models.DateTimeField()),
25
+ ('last_used_at', models.DateTimeField(blank=True, null=True)),
26
+ ('revoked_at', models.DateTimeField(blank=True, null=True)),
27
+ ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to=settings.AUTH_USER_MODEL)),
28
+ ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to='workspaces.workspace')),
29
+ ],
30
+ options={
31
+ 'ordering': ['-created_at', '-pk'],
32
+ },
33
+ ),
34
+ ]
@@ -0,0 +1,29 @@
1
+ # Generated by Django 6.0.9 on 2026-11-03 14:14
2
+
3
+ import django.db.models.deletion
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ dependencies = [
10
+ ('connections', '0005_agentconnection'),
11
+ ('workspaces', '0002_api_token'),
12
+ ]
13
+
14
+ operations = [
15
+ migrations.AddField(
16
+ model_name='workspace',
17
+ name='default_agent_connection',
18
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='default_workspaces', to='connections.agentconnection'),
19
+ ),
20
+ migrations.AddField(
21
+ model_name='workspace',
22
+ name='default_model_connection',
23
+ field=models.ForeignKey(blank=True, limit_choices_to={'kind': 'model'}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_workspaces', to='connections.connection'),
24
+ ),
25
+ migrations.AddConstraint(
26
+ model_name='workspace',
27
+ constraint=models.CheckConstraint(condition=models.Q(('default_model_connection__isnull', True), ('default_agent_connection__isnull', True), _connector='OR'), name='workspace_one_execution_default'),
28
+ ),
29
+ ]
@@ -0,0 +1,26 @@
1
+ # Generated by Django 6.0.9 on 2026-11-03 14:14
2
+
3
+ from django.apps.registry import Apps
4
+ from django.db import migrations
5
+ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
6
+
7
+
8
+ def copy_default_model_key(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
9
+ """Released installs marked their default model key on the key itself."""
10
+ Connection = apps.get_model("connections", "Connection")
11
+ Workspace = apps.get_model("workspaces", "Workspace")
12
+ for connection in Connection.objects.filter(is_default=True):
13
+ Workspace.objects.filter(pk=connection.workspace_id).update(
14
+ default_model_connection=connection
15
+ )
16
+
17
+
18
+ class Migration(migrations.Migration):
19
+
20
+ dependencies = [
21
+ ('workspaces', '0003_execution_default'),
22
+ ]
23
+
24
+ operations = [
25
+ migrations.RunPython(copy_default_model_key, migrations.RunPython.noop),
26
+ ]
File without changes