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,258 @@
1
+ """Queries and writes on jobs, capability requests and grants."""
2
+
3
+ from collections.abc import Collection, Iterable
4
+ from datetime import datetime
5
+ from typing import Any
6
+
7
+ from django.db.models import Count, QuerySet
8
+
9
+ from secondshift.connections.models import Connection
10
+ from secondshift.jobs.models import CapabilityRequest, Job, JobGrant
11
+ from secondshift.repositories.models import Repository
12
+ from secondshift.workspaces.models import Workspace
13
+
14
+ # Fields a grant review changes. Bulk updates save only these.
15
+ GRANT_REVIEW_FIELDS = [
16
+ "connection",
17
+ "status",
18
+ "allowed_tools",
19
+ "reviewed_by",
20
+ "reviewed_at",
21
+ "updated_at",
22
+ ]
23
+
24
+
25
+ # Jobs
26
+
27
+
28
+ def create_job(**fields: Any) -> Job:
29
+ return Job.objects.create(**fields)
30
+
31
+
32
+ def create_jobs(jobs: list[Job]) -> None:
33
+ Job.objects.bulk_create(jobs)
34
+
35
+
36
+ def active_jobs(
37
+ workspace_id: int, repository_id: int | None = None, enabled: bool | None = None
38
+ ) -> QuerySet[Job]:
39
+ """The workspace's jobs whose repository was not removed, with the repository."""
40
+ jobs = Job.objects.filter(
41
+ workspace_id=workspace_id, repository__removed_at__isnull=True
42
+ ).select_related("repository")
43
+ if repository_id is not None:
44
+ jobs = jobs.filter(repository_id=repository_id)
45
+ if enabled is not None:
46
+ jobs = jobs.filter(enabled=enabled)
47
+ return jobs
48
+
49
+
50
+ def active_jobs_with_grants(
51
+ workspace_id: int, repository_id: int | None = None, enabled: bool | None = None
52
+ ) -> QuerySet[Job]:
53
+ """active_jobs with each capability request's grant and connection."""
54
+ return active_jobs(workspace_id, repository_id, enabled).prefetch_related(
55
+ "capability_requests__grant__connection"
56
+ )
57
+
58
+
59
+ def enabled_job_count(workspace_id: int) -> int:
60
+ return active_jobs(workspace_id, enabled=True).count()
61
+
62
+
63
+ def find_active_job(workspace: Workspace | None, job_id: int) -> Job | None:
64
+ """The job, with its repository, workspace, requests, grants and connections."""
65
+ return (
66
+ Job.objects.select_related("repository", "workspace")
67
+ .prefetch_related("capability_requests__grant__connection")
68
+ .filter(pk=job_id, workspace=workspace, repository__removed_at__isnull=True)
69
+ .first()
70
+ )
71
+
72
+
73
+ def jobs_by_id(job_ids: Iterable[int]) -> dict[int, Job]:
74
+ """The jobs with these ids, with their repositories."""
75
+ return Job.objects.select_related("repository").in_bulk(list(job_ids))
76
+
77
+
78
+ def schedulable_jobs() -> QuerySet[Job]:
79
+ """Enabled jobs with a schedule in enabled repositories, oldest first."""
80
+ return (
81
+ Job.objects.filter(enabled=True, repository__enabled=True)
82
+ .exclude(cron="")
83
+ .order_by("pk")
84
+ )
85
+
86
+
87
+ def scheduled_job(job_id: int) -> Job | None:
88
+ """The job with its repository while both are enabled."""
89
+ return (
90
+ Job.objects.select_related("repository")
91
+ .filter(pk=job_id, enabled=True, repository__enabled=True)
92
+ .first()
93
+ )
94
+
95
+
96
+ def save_next_runs(jobs: list[Job]) -> None:
97
+ Job.objects.bulk_update(jobs, ["next_run_at"])
98
+
99
+
100
+ def lock_job(job: Job) -> None:
101
+ """Lock the job for this transaction, so two starts wait for each other."""
102
+ Job.objects.select_for_update().filter(pk=job.pk).first()
103
+
104
+
105
+ def retire_missing_jobs(
106
+ repository: Repository, keys: Collection[str], now: datetime
107
+ ) -> None:
108
+ """Disable and mark removed the repository's jobs the manifest no longer names."""
109
+ repository.jobs.exclude(key__in=keys).filter(removed_at__isnull=True).update(
110
+ enabled=False, removed_at=now
111
+ )
112
+
113
+
114
+ def enabled_repository_jobs(repository: Repository) -> list[Job]:
115
+ return list(repository.jobs.filter(enabled=True))
116
+
117
+
118
+ def disable_repository_jobs(repository: Repository) -> None:
119
+ repository.jobs.update(enabled=False)
120
+
121
+
122
+ def repository_jobs_with_grants(repository: Repository) -> QuerySet[Job]:
123
+ return repository.jobs.prefetch_related("capability_requests__grant")
124
+
125
+
126
+ # Capability requests
127
+
128
+
129
+ def create_capability_requests(requests: list[CapabilityRequest]) -> None:
130
+ CapabilityRequest.objects.bulk_create(requests)
131
+
132
+
133
+ def delete_capability_requests(request_ids: list[int]) -> None:
134
+ CapabilityRequest.objects.filter(pk__in=request_ids).delete()
135
+
136
+
137
+ def repository_requests(repository: Repository) -> QuerySet[CapabilityRequest]:
138
+ """Every request of the repository's jobs, with its grant."""
139
+ return CapabilityRequest.objects.filter(job__repository=repository).select_related(
140
+ "grant"
141
+ )
142
+
143
+
144
+ def job_requests(job: Job) -> QuerySet[CapabilityRequest]:
145
+ return CapabilityRequest.objects.filter(job=job)
146
+
147
+
148
+ def job_requests_with_grants(job: Job) -> QuerySet[CapabilityRequest]:
149
+ """The job's requests with their grants and granted connections."""
150
+ return job.capability_requests.select_related("grant__connection")
151
+
152
+
153
+ def find_request(
154
+ repository: Repository, job_key: str, binding: str, kind: str
155
+ ) -> CapabilityRequest | None:
156
+ """The request of the repository's job for this binding, with its grant."""
157
+ return (
158
+ CapabilityRequest.objects.filter(
159
+ job__repository=repository, job__key=job_key, binding=binding, kind=kind
160
+ )
161
+ .select_related("grant")
162
+ .first()
163
+ )
164
+
165
+
166
+ def workspace_requests(workspace: Workspace) -> QuerySet[CapabilityRequest]:
167
+ """Requests of the workspace's active jobs, by binding then job name."""
168
+ return (
169
+ CapabilityRequest.objects.filter(
170
+ job__workspace=workspace, job__repository__removed_at__isnull=True
171
+ )
172
+ .select_related("job__repository", "grant")
173
+ .order_by("binding", "job__name")
174
+ )
175
+
176
+
177
+ def active_requests() -> QuerySet[CapabilityRequest]:
178
+ """Requests of jobs whose repository was not removed, with grant and job."""
179
+ return CapabilityRequest.objects.filter(
180
+ job__repository__removed_at__isnull=True
181
+ ).select_related("grant", "job__repository")
182
+
183
+
184
+ def repository_active_requests(repository: Repository) -> list[CapabilityRequest]:
185
+ return list(active_requests().filter(job__repository=repository))
186
+
187
+
188
+ def binding_active_requests(workspace_id: int, binding: str) -> list[CapabilityRequest]:
189
+ """The workspace's active requests for this binding."""
190
+ return list(
191
+ active_requests().filter(job__workspace_id=workspace_id, binding=binding)
192
+ )
193
+
194
+
195
+ # Grants
196
+
197
+
198
+ def create_grants(grants: list[JobGrant]) -> None:
199
+ JobGrant.objects.bulk_create(grants)
200
+
201
+
202
+ def save_grant_reviews(grants: list[JobGrant]) -> None:
203
+ JobGrant.objects.bulk_update(grants, GRANT_REVIEW_FIELDS)
204
+
205
+
206
+ def find_grant(request: CapabilityRequest) -> JobGrant | None:
207
+ return JobGrant.objects.filter(capability_request=request).first()
208
+
209
+
210
+ def approved_tool_grant(
211
+ job: Job, kind: str, binding: str, connection_id: int | None
212
+ ) -> JobGrant | None:
213
+ """The job's approved grant for this binding and connection, if any."""
214
+ return (
215
+ JobGrant.objects.select_related("connection", "capability_request")
216
+ .filter(
217
+ capability_request__job=job,
218
+ capability_request__kind=kind,
219
+ capability_request__binding=binding,
220
+ connection_id=connection_id,
221
+ status=JobGrant.Status.APPROVED,
222
+ )
223
+ .first()
224
+ )
225
+
226
+
227
+ def approved_jobs_per_connection(workspace_id: int) -> dict[int, int]:
228
+ """How many jobs each of the workspace's connections is approved for."""
229
+ return {
230
+ row["connection_id"]: row["jobs"]
231
+ for row in JobGrant.objects.filter(
232
+ connection__workspace_id=workspace_id, status=JobGrant.Status.APPROVED
233
+ )
234
+ .values("connection_id")
235
+ .annotate(jobs=Count("capability_request__job", distinct=True))
236
+ .order_by()
237
+ }
238
+
239
+
240
+ def reopen_github_mcp_grants(repository: Repository) -> None:
241
+ """Send the repository's approved GitHub MCP grants back for review."""
242
+ JobGrant.objects.filter(
243
+ capability_request__job__repository=repository,
244
+ capability_request__kind=Connection.Kind.MCP,
245
+ connection__provider=Connection.Provider.GITHUB,
246
+ status=JobGrant.Status.APPROVED,
247
+ ).update(status=JobGrant.Status.PENDING, reviewed_by=None, reviewed_at=None)
248
+
249
+
250
+ def release_connection_grants(connection: Connection) -> None:
251
+ """Return the connection's grants to pending, without a connection."""
252
+ connection.job_grants.update(
253
+ connection=None,
254
+ status=JobGrant.Status.PENDING,
255
+ allowed_tools=[],
256
+ reviewed_by=None,
257
+ reviewed_at=None,
258
+ )
@@ -0,0 +1,112 @@
1
+ """Check a draft .secondshift.yaml against a workspace, before it is committed.
2
+
3
+ Nothing is saved. Skill files are not read, so a missing skill shows up only
4
+ when the committed manifest is synced. A binding is ready only when the same
5
+ job already has an approved grant that covers what the draft asks for.
6
+ Syncing never grants access, so anything new waits for an owner.
7
+ """
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+ import yaml
12
+
13
+ from secondshift.config.constants import MANIFEST_FILE
14
+ from secondshift.connections.dao import connections_by_binding
15
+ from secondshift.connections.models import Connection
16
+ from secondshift.jobs.dao import find_request
17
+ from secondshift.jobs.manifest import JobManifest, ManifestError, parse_manifest
18
+ from secondshift.jobs.models import CapabilityRequest, JobGrant
19
+ from secondshift.jobs.needs import NeedStatus, connection_problem
20
+ from secondshift.repositories.models import Repository
21
+ from secondshift.workspaces.models import Workspace
22
+
23
+ NEW_ACCESS_DETAIL = "Connected. An owner approves it after the manifest is synced."
24
+ APPROVED_DETAIL = "Connected and already approved for this job."
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class DraftBinding:
29
+ binding: str
30
+ kind: str
31
+ required: bool
32
+ requested_tools: list[str]
33
+ status: NeedStatus
34
+ detail: str
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class DraftJob:
39
+ key: str
40
+ name: str
41
+ schedule: str | None
42
+ bindings: list[DraftBinding] = field(default_factory=list)
43
+
44
+
45
+ def requested(job: JobManifest) -> list[tuple[str, str, bool, list[str]]]:
46
+ """Each binding the job asks for: binding, kind, required and tools."""
47
+ return [
48
+ *(
49
+ (item.binding, CapabilityRequest.Kind.MCP, item.required, item.tools)
50
+ for item in job.requires.mcps
51
+ ),
52
+ *(
53
+ (item.binding, CapabilityRequest.Kind.API, item.required, item.tools)
54
+ for item in job.requires.apis
55
+ ),
56
+ *(
57
+ (item.binding, CapabilityRequest.Kind.NOTIFICATION, item.required, [])
58
+ for item in job.notify
59
+ ),
60
+ ]
61
+
62
+
63
+ def approved_grant(
64
+ repository: Repository | None,
65
+ key: str,
66
+ binding: str,
67
+ kind: str,
68
+ connection: Connection,
69
+ tools: set[str],
70
+ ) -> bool:
71
+ if repository is None:
72
+ return False
73
+ request = find_request(repository, key, binding, kind)
74
+ grant = getattr(request, "grant", None) if request else None
75
+ return bool(
76
+ grant
77
+ and grant.status == JobGrant.Status.APPROVED
78
+ and grant.connection_id == connection.pk
79
+ and tools <= set(grant.allowed_tools)
80
+ )
81
+
82
+
83
+ def check_draft(
84
+ workspace: Workspace, text: str, repository: Repository | None = None
85
+ ) -> list[DraftJob]:
86
+ """Each draft job's bindings and their status. Raises ManifestError."""
87
+ try:
88
+ manifest = parse_manifest(text)
89
+ except (ValueError, yaml.YAMLError) as error:
90
+ # The same wording a sync shows for the committed file.
91
+ raise ManifestError(f"Invalid {MANIFEST_FILE}: {error}") from error
92
+ connections = connections_by_binding(workspace.pk)
93
+ repositories = [repository] if repository else []
94
+ jobs = []
95
+ for key, job in manifest.jobs.items():
96
+ bindings = []
97
+ for binding, kind, required, tools in requested(job):
98
+ connection = connections.get(binding)
99
+ problem = connection_problem(kind, connection, set(tools), repositories)
100
+ if problem:
101
+ status, detail = problem
102
+ elif connection and approved_grant(
103
+ repository, key, binding, kind, connection, set(tools)
104
+ ):
105
+ status, detail = "ready", APPROVED_DETAIL
106
+ else:
107
+ status, detail = "needs_approval", NEW_ACCESS_DETAIL
108
+ bindings.append(
109
+ DraftBinding(binding, kind, required, sorted(tools), status, detail)
110
+ )
111
+ jobs.append(DraftJob(key, job.name, job.schedule, bindings))
112
+ return jobs
@@ -0,0 +1,116 @@
1
+ """An owner's decision on the access a job requests."""
2
+
3
+ from django.contrib.auth.models import AbstractBaseUser
4
+ from django.db import transaction
5
+ from django.utils import timezone
6
+
7
+ from secondshift.connections.external import external_access
8
+ from secondshift.connections.models import Connection
9
+ from secondshift.connections.services import ConnectionConflict
10
+ from secondshift.jobs.dao import find_grant, release_connection_grants
11
+ from secondshift.jobs.models import CapabilityRequest, JobGrant
12
+ from secondshift.jobs.tool_rules import (
13
+ ToolRuleError,
14
+ ToolRules,
15
+ kept_rules,
16
+ validate_rules,
17
+ )
18
+ from secondshift.repositories.dao import has_repositories
19
+ from secondshift.repositories.github import repository_ref
20
+
21
+
22
+ class GrantError(Exception):
23
+ def __init__(self, status: int, message: str) -> None:
24
+ super().__init__(message)
25
+ # The HTTP status the API answers with.
26
+ self.status = status
27
+
28
+
29
+ def check_connection(
30
+ capability: CapabilityRequest, connection: Connection, approving: bool
31
+ ) -> None:
32
+ """The connection must fit the request, be connected, and reach the repository."""
33
+ if (
34
+ connection.kind != capability.kind
35
+ or connection.binding_key != capability.binding
36
+ ):
37
+ raise GrantError(422, "Connection kind and binding must match capability.")
38
+ if not connection.is_connected:
39
+ raise GrantError(409, "The selected connection is not connected.")
40
+ if not approving:
41
+ return
42
+ access = external_access(connection, repository_ref(capability.job.repository))
43
+ if access and not access.ok:
44
+ raise GrantError(409, access.text)
45
+
46
+
47
+ def granted_tools(
48
+ capability: CapabilityRequest,
49
+ decision: str,
50
+ connection: Connection | None,
51
+ chosen: list[str] | None,
52
+ ) -> set[str]:
53
+ """The tools the decision allows. Approving means every requested tool."""
54
+ requested = set(capability.requested_tools)
55
+ allowed = set(capability.requested_tools if chosen is None else chosen)
56
+ if decision == JobGrant.Status.DENIED:
57
+ return set()
58
+ if allowed - requested:
59
+ raise GrantError(422, "Allowed tools must be requested by the job.")
60
+ if decision == JobGrant.Status.APPROVED:
61
+ if allowed != requested:
62
+ raise GrantError(
63
+ 422, "Approve every requested tool or deny the capability."
64
+ )
65
+ if connection and requested - set(connection.available_tools):
66
+ raise GrantError(409, "The connection lacks a requested tool.")
67
+ return allowed
68
+
69
+
70
+ @transaction.atomic
71
+ def decide_grant(
72
+ capability: CapabilityRequest,
73
+ decision: str,
74
+ connection: Connection | None,
75
+ chosen_tools: list[str] | None,
76
+ reviewer: AbstractBaseUser,
77
+ tool_rules: ToolRules | None = None,
78
+ ) -> JobGrant:
79
+ """`tool_rules` None keeps the rules for tools that stay approved."""
80
+ approving = decision == JobGrant.Status.APPROVED
81
+ if connection is not None:
82
+ check_connection(capability, connection, approving)
83
+ elif approving:
84
+ raise GrantError(422, "Choose a connection before approving the capability.")
85
+ allowed = granted_tools(capability, decision, connection, chosen_tools)
86
+
87
+ grant = find_grant(capability) or JobGrant(capability_request=capability)
88
+ rules = kept_rules(grant.tool_rules if tool_rules is None else tool_rules, allowed)
89
+ if tool_rules is not None and set(tool_rules) - allowed:
90
+ raise GrantError(422, "Rules can only apply to approved tools.")
91
+ if rules and connection is not None:
92
+ try:
93
+ validate_rules(rules, connection.tool_definitions, sorted(allowed))
94
+ except ToolRuleError as error:
95
+ raise GrantError(422, str(error)) from error
96
+ grant.connection = connection
97
+ grant.status = decision
98
+ grant.allowed_tools = sorted(allowed)
99
+ grant.tool_rules = rules
100
+ grant.reviewed_by = reviewer
101
+ grant.reviewed_at = timezone.now()
102
+ grant.full_clean()
103
+ grant.save()
104
+ return grant
105
+
106
+
107
+ @transaction.atomic
108
+ def delete_connection(connection: Connection) -> None:
109
+ if connection.kind == Connection.Kind.SOURCE and has_repositories(connection):
110
+ raise ConnectionConflict(
111
+ "This installation has repository history. Disconnect it instead."
112
+ )
113
+ # Grants are review records: they return to pending instead of disappearing,
114
+ # so the jobs show up again under "Needed by your jobs".
115
+ release_connection_grants(connection)
116
+ connection.delete()
@@ -0,0 +1,26 @@
1
+ """The run limits of one job. See secondshift.workspaces.limits."""
2
+
3
+ from secondshift.jobs.models import Job
4
+ from secondshift.workspaces.limits import (
5
+ LimitView,
6
+ RunLimit,
7
+ capped,
8
+ limit_views,
9
+ workspace_value,
10
+ )
11
+
12
+
13
+ def job_value(job: Job, limit: RunLimit) -> int:
14
+ chosen = getattr(job, limit)
15
+ if chosen is not None:
16
+ return capped(limit, chosen)
17
+ return workspace_value(job.workspace, limit)
18
+
19
+
20
+ def effective_limits(job: Job) -> dict[str, int]:
21
+ """The limits a run of this job gets, for its execution plan."""
22
+ return {str(limit): job_value(job, limit) for limit in RunLimit}
23
+
24
+
25
+ def job_limit_views(job: Job) -> list[LimitView]:
26
+ return limit_views(job, lambda limit: workspace_value(job.workspace, limit))
File without changes
File without changes