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,262 @@
1
+ """What runs a workspace's jobs: one model key or one agent login."""
2
+
3
+ from collections import Counter
4
+ from dataclasses import dataclass
5
+ from datetime import timedelta
6
+ from typing import Any
7
+
8
+ from django.conf import settings
9
+ from django.contrib.auth.models import User
10
+ from django.core.exceptions import ValidationError
11
+ from django.utils import timezone
12
+
13
+ from secondshift.config.constants import AgentBackend
14
+ from secondshift.connections.agents import (
15
+ product_name,
16
+ sign_in_refused,
17
+ usable_agent_connection,
18
+ )
19
+ from secondshift.connections.dao import (
20
+ connected_logins,
21
+ has_models_or_logins,
22
+ login_run_counts,
23
+ usable_models,
24
+ )
25
+ from secondshift.connections.models import AgentConnection, Connection
26
+ from secondshift.workspaces.models import Workspace
27
+
28
+ # Choice keys name one model key or one login, for example "model:3" or "agent:7".
29
+ MODEL_PREFIX = "model:"
30
+ AGENT_PREFIX = "agent:"
31
+ NOTHING_CHOSEN = "Choose a model API key or a subscription to run jobs."
32
+ # A login's runs per day count over this rolling window.
33
+ RUN_CAP_WINDOW = timedelta(hours=24)
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ExecutionSelection:
38
+ key: str
39
+ label: str
40
+ backend: AgentBackend
41
+ connection: AgentConnection | None = None
42
+ model: Connection | None = None
43
+ problem: str = ""
44
+
45
+ @property
46
+ def ready(self) -> bool:
47
+ return not self.problem and (
48
+ self.connection is not None or self.model is not None
49
+ )
50
+
51
+
52
+ def recent_login_runs(workspace_id: int) -> Counter[int]:
53
+ """Runs per agent login in the cap window. Only ready plans used a login."""
54
+ return login_run_counts(workspace_id, timezone.now() - RUN_CAP_WINDOW)
55
+
56
+
57
+ def cli_enabled() -> bool:
58
+ return bool(settings.AGENT_IMAGE and settings.AGENT_BROKER_URL)
59
+
60
+
61
+ def model_problem(model: Connection) -> str:
62
+ """Why runs cannot use this model key, or empty when they can."""
63
+ if model.status != Connection.Status.CONNECTED:
64
+ return f"The model provider {model.display_name} is disconnected. Reconnect it."
65
+ if not model.default_model.strip():
66
+ return f"The model provider {model.display_name} has no model selected."
67
+ if model.failed_last_check:
68
+ return f"The model provider {model.display_name} failed its last check."
69
+ return ""
70
+
71
+
72
+ def workspace_model(workspace: Workspace) -> Connection | None:
73
+ """The chosen model provider, if runs can use it."""
74
+ model = workspace.default_model_connection
75
+ return None if model is None or model_problem(model) else model
76
+
77
+
78
+ def model_selection(connection: Connection) -> ExecutionSelection:
79
+ problem = model_problem(connection)
80
+ return ExecutionSelection(
81
+ key=f"{MODEL_PREFIX}{connection.pk}",
82
+ label=f"Model provider · {connection.display_name}"
83
+ + ("" if problem else f" · {connection.default_model}"),
84
+ backend=AgentBackend.PYDANTIC,
85
+ model=None if problem else connection,
86
+ problem=problem,
87
+ )
88
+
89
+
90
+ def subscription_problem(connection: AgentConnection, workspace: Workspace) -> str:
91
+ """Why runs cannot use this agent login, or empty when they can."""
92
+ if not cli_enabled():
93
+ return "Agent subscriptions are not set up on this server."
94
+ if not connection.connected:
95
+ return (
96
+ f"The agent login {connection.display_name} is disconnected. Reconnect it."
97
+ )
98
+ if sign_in_refused(connection):
99
+ return f"The agent login {connection.display_name} was refused at its last check. Reconnect it."
100
+ if not usable_agent_connection(connection, workspace.pk):
101
+ return f"The agent login {connection.display_name} belongs to someone who is no longer a workspace owner."
102
+ cap = connection.max_runs_per_day
103
+ if cap is not None and recent_login_runs(workspace.pk)[connection.pk] >= cap:
104
+ return (
105
+ f"The agent login {connection.display_name} reached its limit of "
106
+ f"{cap} runs in 24 hours. Raise its limit or wait."
107
+ )
108
+ return ""
109
+
110
+
111
+ def subscription_selection(
112
+ connection: AgentConnection, workspace: Workspace
113
+ ) -> ExecutionSelection:
114
+ backend = AgentBackend(connection.backend)
115
+ product = product_name(backend)
116
+ return ExecutionSelection(
117
+ key=f"{AGENT_PREFIX}{connection.pk}",
118
+ label=f"{product} subscription · {connection.display_name}",
119
+ backend=backend,
120
+ connection=connection,
121
+ problem=subscription_problem(connection, workspace),
122
+ )
123
+
124
+
125
+ def workspace_execution(workspace: Workspace) -> ExecutionSelection:
126
+ """What runs the workspace's jobs. It changes only when an owner changes it."""
127
+ if workspace.default_agent_connection_id:
128
+ return subscription_selection(workspace.default_agent_connection, workspace)
129
+ if workspace.default_model_connection_id:
130
+ return model_selection(workspace.default_model_connection)
131
+ return ExecutionSelection(
132
+ key="",
133
+ label="Nothing chosen",
134
+ backend=AgentBackend.PYDANTIC,
135
+ problem=NOTHING_CHOSEN,
136
+ )
137
+
138
+
139
+ def execution_choices(workspace: Workspace, user: User) -> list[ExecutionSelection]:
140
+ """The workspace's model keys, then the user's own logins."""
141
+ models = usable_models(workspace)
142
+ logins = connected_logins(workspace, user)
143
+ return [
144
+ *(model_selection(model) for model in models),
145
+ *(subscription_selection(login, workspace) for login in logins),
146
+ ]
147
+
148
+
149
+ def execution_configuration(workspace: Workspace, user: User) -> dict[str, Any]:
150
+ selected = workspace_execution(workspace)
151
+ choices = [
152
+ {
153
+ "key": choice.key,
154
+ "label": choice.label,
155
+ "disabled": not choice.ready,
156
+ "problem": choice.problem,
157
+ }
158
+ for choice in execution_choices(workspace, user)
159
+ ]
160
+ if selected.key and selected.key not in {choice["key"] for choice in choices}:
161
+ # Another owner's login or a disconnected provider. Shown, not selectable.
162
+ choices.append(
163
+ {
164
+ "key": selected.key,
165
+ "label": selected.label,
166
+ "disabled": True,
167
+ "problem": selected.problem,
168
+ }
169
+ )
170
+ return {
171
+ "enabled": cli_enabled(),
172
+ "choices": choices,
173
+ "selected": selected.key,
174
+ "label": selected.label,
175
+ "backend": selected.backend,
176
+ "ready": selected.ready,
177
+ "problem": selected.problem,
178
+ }
179
+
180
+
181
+ def execution_status(workspace: Workspace) -> dict[str, Any]:
182
+ """What runs the jobs, why runs would fail, and where to fix it."""
183
+ selected = workspace_execution(workspace)
184
+ nothing_chosen = not selected.key
185
+ return {
186
+ "label": selected.label,
187
+ "backend": selected.backend,
188
+ "problem": selected.problem,
189
+ "chosen": not nothing_chosen,
190
+ "needs_setup": nothing_chosen and not has_models_or_logins(workspace.pk),
191
+ # The chosen model provider, so the page can open it ready to fix.
192
+ "fix_connection_id": workspace.default_model_connection_id
193
+ if selected.problem and not nothing_chosen and selected.backend.uses_model_api
194
+ else None,
195
+ }
196
+
197
+
198
+ def choose_execution(
199
+ workspace: Workspace,
200
+ model: Connection | None = None,
201
+ login: AgentConnection | None = None,
202
+ ) -> None:
203
+ """Point the workspace at one model key or one login. None of both clears it."""
204
+ workspace.default_model_connection = model
205
+ workspace.default_agent_connection = login
206
+ workspace.save(
207
+ update_fields=[
208
+ "default_model_connection",
209
+ "default_agent_connection",
210
+ "updated_at",
211
+ ]
212
+ )
213
+
214
+
215
+ def save_execution_preference(workspace: Workspace, key: str, user: User) -> None:
216
+ choice = next(
217
+ (
218
+ choice
219
+ for choice in execution_choices(workspace, user)
220
+ if choice.key == key and choice.ready
221
+ ),
222
+ None,
223
+ )
224
+ if choice is None:
225
+ raise ValidationError(
226
+ "Choose a working model provider or one of your own working logins."
227
+ )
228
+ choose_execution(workspace, model=choice.model, login=choice.connection)
229
+
230
+
231
+ def use_when_nothing_runs(
232
+ workspace: Workspace,
233
+ model: Connection | None = None,
234
+ login: AgentConnection | None = None,
235
+ ) -> None:
236
+ """A new provider or login runs the jobs when nothing chosen can.
237
+
238
+ A broken newcomer only fills an empty choice, so it never hides a working
239
+ fix behind another problem. Settings shows the result.
240
+ """
241
+ current = workspace_execution(workspace)
242
+ if current.ready:
243
+ return
244
+ candidate = model_selection(model) if model else None
245
+ if current.key and candidate is not None and not candidate.ready:
246
+ return
247
+ choose_execution(workspace, model=model, login=login)
248
+
249
+
250
+ def apply_model_default(connection: Connection, is_default: bool | None) -> None:
251
+ """The model provider form's default switch.
252
+
253
+ None means the form did not say, so the provider is used only when nothing
254
+ chosen can run jobs.
255
+ """
256
+ workspace = connection.workspace
257
+ if is_default:
258
+ choose_execution(workspace, model=connection)
259
+ elif is_default is None:
260
+ use_when_nothing_runs(workspace, model=connection)
261
+ elif workspace.default_model_connection_id == connection.pk:
262
+ choose_execution(workspace)
@@ -0,0 +1,258 @@
1
+ """External tools by connection kind: MCP servers and HTTP APIs.
2
+
3
+ The planner, broker, and access review are the same for every kind. Each kind
4
+ supplies only what differs: what a job may reach, what is frozen into a run
5
+ plan, how a connection is checked against that plan, and how a call is made.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from typing import Any, Protocol
10
+
11
+ from asgiref.sync import async_to_sync
12
+
13
+ from secondshift.connections.access import Access, AccessReason
14
+ from secondshift.connections.errors import ExternalToolError
15
+ from secondshift.connections.http_api.client import (
16
+ build_request,
17
+ send,
18
+ validate_api_connection,
19
+ )
20
+ from secondshift.connections.mcp.client import (
21
+ borrows_source,
22
+ mcp_request,
23
+ validate_mcp_connection,
24
+ )
25
+ from secondshift.connections.mcp.oauth import fresh_connection
26
+ from secondshift.connections.mcp.providers import (
27
+ MCP_PROVIDERS,
28
+ mcp_provider,
29
+ )
30
+ from secondshift.connections.models import Connection
31
+ from secondshift.connections.outbound import host_label
32
+ from secondshift.connections.repository_ref import RepositoryRef
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Authorization:
37
+ """A connection checked against the run's frozen plan, ready to call."""
38
+
39
+ connection: Connection
40
+ scope: str
41
+ # The frozen tool definition: description and inputSchema at least.
42
+ definition: dict[str, Any]
43
+ # Start of the model-facing tool name, such as "github" or "api".
44
+ name_prefix: str
45
+ scope_label: str
46
+ # The run's repository. Connections that borrow its source need it.
47
+ repository: RepositoryRef | None = None
48
+
49
+
50
+ class ExternalKind(Protocol):
51
+ def access(self, connection: Connection, repository: RepositoryRef) -> Access: ...
52
+
53
+ def freeze(
54
+ self, connection: Connection, scope: str, tools: set[str]
55
+ ) -> dict[str, Any]: ...
56
+
57
+ def authorize(
58
+ self,
59
+ connection: Connection,
60
+ frozen: dict[str, Any],
61
+ repository: RepositoryRef,
62
+ tool: str,
63
+ ) -> Authorization: ...
64
+
65
+ def call(
66
+ self, authorization: Authorization, tool: str, arguments: dict[str, Any]
67
+ ) -> dict[str, Any]: ...
68
+
69
+
70
+ def frozen_tools(connection: Connection, tools: set[str]) -> dict[str, Any]:
71
+ definitions = connection.tool_definitions
72
+ return {name: definitions.get(name) for name in sorted(tools)}
73
+
74
+
75
+ def frozen_definition(
76
+ connection: Connection, frozen: dict[str, Any], tool: str, noun: str
77
+ ) -> dict[str, Any]:
78
+ definition = frozen.get("tools", {}).get(tool)
79
+ if not definition or definition != connection.tool_definitions.get(tool):
80
+ raise ExternalToolError(f"The {noun} definition changed. Start a new run.")
81
+ return definition
82
+
83
+
84
+ class MCPKind:
85
+ def access(self, connection: Connection, repository: RepositoryRef) -> Access:
86
+ provider = MCP_PROVIDERS.get(connection.provider)
87
+ if provider is None:
88
+ return Access(
89
+ False,
90
+ None,
91
+ "This MCP provider is not supported.",
92
+ AccessReason.MCP_PROVIDER_UNSUPPORTED,
93
+ )
94
+ scope = provider.scope(connection.settings, repository)
95
+ if not scope:
96
+ return Access(
97
+ False,
98
+ None,
99
+ provider.missing_scope_message,
100
+ provider.missing_scope_reason,
101
+ )
102
+ if borrows_source(connection) and not provider.source_reachable(repository):
103
+ return Access(
104
+ False,
105
+ None,
106
+ provider.missing_source_message,
107
+ AccessReason.MCP_SOURCE_MISSING,
108
+ )
109
+ return Access(
110
+ True,
111
+ scope,
112
+ f"Allow these tools to {provider.access_verb} "
113
+ f"{provider.describe_scope(scope)}.",
114
+ )
115
+
116
+ def freeze(
117
+ self, connection: Connection, scope: str, tools: set[str]
118
+ ) -> dict[str, Any]:
119
+ """The approved scope and tool definitions. Never credentials."""
120
+ return {
121
+ "provider": connection.provider,
122
+ "scope": scope,
123
+ "tools": frozen_tools(connection, tools),
124
+ }
125
+
126
+ def authorize(
127
+ self,
128
+ connection: Connection,
129
+ frozen: dict[str, Any],
130
+ repository: RepositoryRef,
131
+ tool: str,
132
+ ) -> Authorization:
133
+ # Signed-in connections renew their token here, before the checks below.
134
+ fresh_connection(connection)
135
+ provider = validate_mcp_connection(connection)
136
+ if (
137
+ frozen.get("provider") != provider.key
138
+ or not frozen.get("scope")
139
+ or frozen["scope"] != provider.scope(connection.settings, repository)
140
+ ):
141
+ raise ExternalToolError(
142
+ f"The job {provider.scope_label} scope changed. Start a new run."
143
+ )
144
+ return Authorization(
145
+ connection=connection,
146
+ scope=frozen["scope"],
147
+ definition=frozen_definition(connection, frozen, tool, "MCP tool"),
148
+ name_prefix=provider.key,
149
+ scope_label=provider.scope_label,
150
+ repository=repository,
151
+ )
152
+
153
+ def call(
154
+ self, authorization: Authorization, tool: str, arguments: dict[str, Any]
155
+ ) -> dict[str, Any]:
156
+ connection = authorization.connection
157
+ provider = mcp_provider(connection.provider)
158
+ scoped = provider.scope_arguments(authorization.scope, tool, arguments)
159
+ token = ""
160
+ if borrows_source(connection):
161
+ if provider.source_token is None or authorization.repository is None:
162
+ raise ExternalToolError("This MCP connection has no repository.")
163
+ # Minted for this call and this repository only. Never stored.
164
+ token = provider.source_token(authorization.repository)
165
+ return async_to_sync(mcp_request)(connection, tool, scoped, token)
166
+
167
+
168
+ class APIKind:
169
+ def access(self, connection: Connection, repository: RepositoryRef) -> Access:
170
+ del repository
171
+ base_url = connection.endpoint_url
172
+ if not base_url:
173
+ return Access(
174
+ False,
175
+ None,
176
+ "This API connection has no base URL.",
177
+ AccessReason.API_BASE_URL_UNKNOWN,
178
+ )
179
+ host = host_label(base_url)
180
+ return Access(
181
+ True,
182
+ host,
183
+ f"Allow these operations to use anything the credential can reach on {host}.",
184
+ )
185
+
186
+ def freeze(
187
+ self, connection: Connection, scope: str, tools: set[str]
188
+ ) -> dict[str, Any]:
189
+ return {
190
+ "base_url": connection.endpoint_url,
191
+ "scope": scope,
192
+ "tools": frozen_tools(connection, tools),
193
+ }
194
+
195
+ def authorize(
196
+ self,
197
+ connection: Connection,
198
+ frozen: dict[str, Any],
199
+ repository: RepositoryRef,
200
+ tool: str,
201
+ ) -> Authorization:
202
+ del repository
203
+ validate_api_connection(connection)
204
+ if not frozen.get("base_url") or frozen["base_url"] != connection.endpoint_url:
205
+ raise ExternalToolError("The API base URL changed. Start a new run.")
206
+ return Authorization(
207
+ connection=connection,
208
+ scope=frozen["scope"],
209
+ definition=frozen_definition(connection, frozen, tool, "API operation"),
210
+ name_prefix="api",
211
+ scope_label="API",
212
+ )
213
+
214
+ def call(
215
+ self, authorization: Authorization, tool: str, arguments: dict[str, Any]
216
+ ) -> dict[str, Any]:
217
+ del tool
218
+ request = build_request(
219
+ authorization.connection.endpoint_url, authorization.definition, arguments
220
+ )
221
+ return async_to_sync(send)(authorization.connection, request)
222
+
223
+
224
+ EXTERNAL_KINDS: dict[str, ExternalKind] = {
225
+ Connection.Kind.MCP: MCPKind(),
226
+ Connection.Kind.API: APIKind(),
227
+ }
228
+
229
+
230
+ def external_kind(kind: str) -> ExternalKind:
231
+ try:
232
+ return EXTERNAL_KINDS[kind]
233
+ except KeyError:
234
+ raise ExternalToolError("This external tool is not supported.") from None
235
+
236
+
237
+ def external_access(connection: Connection, repository: RepositoryRef) -> Access | None:
238
+ """None for connections that are not external tools, such as webhooks."""
239
+ adapter = EXTERNAL_KINDS.get(connection.kind)
240
+ return adapter.access(connection, repository) if adapter else None
241
+
242
+
243
+ def changing_tools(connection: Connection, tools: list[str]) -> list[str]:
244
+ """Requested tools that may change data, for the access review.
245
+
246
+ Built-in MCP providers only offer allowlisted read tools. Custom servers
247
+ and HTTP APIs rely on the server's own read-only labels and HTTP methods.
248
+ """
249
+ if connection.kind == Connection.Kind.MCP:
250
+ provider = MCP_PROVIDERS.get(connection.provider)
251
+ if provider is None or not provider.owner_selects_tools:
252
+ return []
253
+ elif connection.kind != Connection.Kind.API:
254
+ return []
255
+ offered = connection.offered_tools
256
+ return [
257
+ name for name in tools if name in offered and not offered[name].get("readOnly")
258
+ ]
@@ -0,0 +1,123 @@
1
+ """GitHub source connections: how a workspace reaches its repositories."""
2
+
3
+ from secondshift.connections.dao import save_github_source
4
+ from secondshift.connections.models import Connection
5
+ from secondshift.github import credentials
6
+ from secondshift.github.app import configured
7
+ from secondshift.github.errors import GitHubError
8
+ from secondshift.github.installations import Installation
9
+ from secondshift.github.tokens import inspect_token
10
+ from secondshift.workspaces.models import Workspace
11
+
12
+
13
+ def link_installations(
14
+ workspace: Workspace, installations: list[Installation]
15
+ ) -> list[Connection]:
16
+ """Add or reconnect one source connection per App installation."""
17
+ return [
18
+ save_github_source(
19
+ workspace,
20
+ installation.installation_id,
21
+ {
22
+ "kind": Connection.Kind.SOURCE,
23
+ "auth_strategy": Connection.AuthStrategy.MANAGED,
24
+ "display_name": f"GitHub · {installation.account}",
25
+ "status": Connection.Status.CONNECTED,
26
+ },
27
+ )
28
+ for installation in installations
29
+ ]
30
+
31
+
32
+ # Keeps token connections apart from installations, whose IDs are numbers.
33
+ TOKEN_ACCOUNT_PREFIX = "token:"
34
+
35
+
36
+ def connect_token_source(
37
+ workspace: Workspace, token: str, replacing: Connection | None = None
38
+ ) -> Connection:
39
+ """Add a personal access token as a source, or replace the owner's last one.
40
+
41
+ GitHub checks the token first. A token for the same GitHub account
42
+ replaces the stored one, so its repositories keep working. When
43
+ replacing a given connection, the token must belong to its account.
44
+ """
45
+ owner = inspect_token(token.strip())
46
+ account_id = f"{TOKEN_ACCOUNT_PREFIX}{owner.login}"
47
+ if replacing is not None and replacing.external_account_id != account_id:
48
+ expected = replacing.external_account_id.removeprefix(TOKEN_ACCOUNT_PREFIX)
49
+ raise GitHubError(
50
+ f"This token belongs to {owner.login}. Use a token from {expected}."
51
+ )
52
+ return save_github_source(
53
+ workspace,
54
+ account_id,
55
+ {
56
+ "kind": Connection.Kind.SOURCE,
57
+ "auth_strategy": Connection.AuthStrategy.API_KEY,
58
+ "display_name": f"GitHub · {owner.login}",
59
+ "access_token": token.strip(),
60
+ "expires_at": owner.expires_at,
61
+ "status": Connection.Status.CONNECTED,
62
+ },
63
+ )
64
+
65
+
66
+ def is_github_source(connection: Connection) -> bool:
67
+ return (
68
+ connection.provider == Connection.Provider.GITHUB
69
+ and connection.kind == Connection.Kind.SOURCE
70
+ )
71
+
72
+
73
+ def shows_attribution(connection: Connection | None) -> bool:
74
+ """Whether what SecondShift publishes through this connection names it.
75
+
76
+ On by default for the App, whose bot already shows SecondShift's work.
77
+ Off by default for a personal token, which posts as a person who may
78
+ not want to say which tools they use.
79
+ """
80
+ if connection is None or not is_github_source(connection):
81
+ return True
82
+ if connection.attribution is not None:
83
+ return connection.attribution
84
+ return connection.auth_strategy != Connection.AuthStrategy.API_KEY
85
+
86
+
87
+ def set_attribution(connection: Connection, attribution: bool) -> Connection:
88
+ connection.attribution = attribution
89
+ connection.save(update_fields=["attribution", "updated_at"])
90
+ return connection
91
+
92
+
93
+ def usable_source(connection: Connection | None) -> bool:
94
+ if not (
95
+ connection
96
+ and connection.provider == Connection.Provider.GITHUB
97
+ and connection.kind == Connection.Kind.SOURCE
98
+ and connection.status == Connection.Status.CONNECTED
99
+ ):
100
+ return False
101
+ if connection.auth_strategy == Connection.AuthStrategy.MANAGED:
102
+ return True
103
+ return connection.auth_strategy == Connection.AuthStrategy.API_KEY and bool(
104
+ connection.access_token
105
+ )
106
+
107
+
108
+ def can_check(connection: Connection | None) -> bool:
109
+ """App sources need the App on this server. Token sources need only themselves."""
110
+ return usable_source(connection) and (
111
+ connection.auth_strategy != Connection.AuthStrategy.MANAGED or configured()
112
+ )
113
+
114
+
115
+ def connection_source(connection: Connection | None) -> credentials.Source:
116
+ """The credential behind a source connection. Never returned to a client."""
117
+ if not usable_source(connection):
118
+ raise GitHubError("The GitHub connection is disconnected.")
119
+ if connection.auth_strategy == Connection.AuthStrategy.MANAGED:
120
+ return credentials.AppInstallation(connection.external_account_id)
121
+ if connection.current_status == Connection.Status.EXPIRED:
122
+ raise GitHubError("The GitHub token expired. Enter a new one.")
123
+ return credentials.PersonalToken(connection.access_token)
@@ -0,0 +1 @@
1
+ """HTTP API connections: operations from an OpenAPI spec, called by SecondShift."""