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,386 @@
1
+ import re
2
+ from collections.abc import Sequence
3
+ from dataclasses import dataclass, field
4
+
5
+ from django.conf import settings
6
+
7
+ from secondshift.config.constants import (
8
+ COMMIT_SHA_PATTERN,
9
+ )
10
+ from secondshift.connections.dao import connection_for_binding, connections_by_binding
11
+ from secondshift.connections.execution import workspace_execution, workspace_model
12
+ from secondshift.connections.external import EXTERNAL_KINDS
13
+ from secondshift.connections.model_providers import provider_family
14
+ from secondshift.connections.models import Connection
15
+ from secondshift.jobs.automatic_grants import approves_automatically
16
+ from secondshift.jobs.capabilities import RequestedCapability, manifest_capabilities
17
+ from secondshift.jobs.dao import job_requests_with_grants
18
+ from secondshift.jobs.limits import effective_limits
19
+ from secondshift.jobs.manifest import JobManifest, TaskManifest, manifest_hash
20
+ from secondshift.jobs.models import Job, JobGrant
21
+ from secondshift.jobs.plan import (
22
+ AgentPlan,
23
+ CapabilityPlan,
24
+ ExecutionChoice,
25
+ ExecutionPlan,
26
+ IssuesPlan,
27
+ JobPlan,
28
+ Missing,
29
+ ModelPlan,
30
+ PlanLimits,
31
+ PlanReason,
32
+ PullRequestPlan,
33
+ RepositoryPlan,
34
+ RunnerPlan,
35
+ SkillSourcePlan,
36
+ )
37
+ from secondshift.jobs.tool_policy import plan_tools
38
+ from secondshift.jobs.tool_rules import ToolRules, broken_rules, kept_rules
39
+ from secondshift.repositories.github import repository_ref
40
+ from secondshift.repositories.models import Repository
41
+ from secondshift.workspaces.limits import RunLimit, workspace_limits
42
+ from secondshift.workspaces.models import Workspace
43
+
44
+
45
+ def model_plan(workspace: Workspace) -> ModelPlan | None:
46
+ """The workspace's default model provider, frozen into the run's plan."""
47
+ connection = workspace_model(workspace)
48
+ if connection is None:
49
+ return None
50
+ return ModelPlan(
51
+ connection_id=connection.pk,
52
+ connection_name=connection.display_name,
53
+ provider=connection.provider,
54
+ family=provider_family(connection),
55
+ default_model=connection.default_model,
56
+ allowed_models=tuple(connection.allowed_models),
57
+ )
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class CapabilityApproval:
62
+ """Server-resolved policy. Never accepted from repository or client input."""
63
+
64
+ status: str
65
+ connection: Connection | None
66
+ allowed_tools: tuple[str, ...]
67
+ tool_rules: ToolRules = field(default_factory=dict)
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class PlannedCapability:
72
+ request: RequestedCapability
73
+ approval: CapabilityApproval | None = None
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class ExecutionDefinition:
78
+ key: str
79
+ skill_path: str
80
+ config_hash: str
81
+ skill_source: SkillSourcePlan | None = None
82
+
83
+
84
+ GRANT_REASONS = {
85
+ JobGrant.Status.PENDING: PlanReason.GRANT_PENDING,
86
+ JobGrant.Status.DENIED: PlanReason.GRANT_DENIED,
87
+ }
88
+ CONNECTION_REASONS = {
89
+ Connection.Status.EXPIRED: PlanReason.CONNECTION_EXPIRED,
90
+ Connection.Status.REVOKED: PlanReason.CONNECTION_REVOKED,
91
+ }
92
+ WAITING_REASONS = {
93
+ PlanReason.NO_GRANT,
94
+ PlanReason.GRANT_PENDING,
95
+ PlanReason.TOOLS_NOT_GRANTED,
96
+ }
97
+
98
+
99
+ def blocking_reasons(
100
+ workspace: Workspace,
101
+ request: RequestedCapability,
102
+ grant: CapabilityApproval | None,
103
+ connection: Connection | None,
104
+ ) -> list[str]:
105
+ """Why a grant cannot be used for this request. Empty means it can."""
106
+ requested = set(request.requested_tools)
107
+ reasons: list[str] = []
108
+ if not grant:
109
+ reasons.append(PlanReason.NO_GRANT)
110
+ elif grant.status != JobGrant.Status.APPROVED:
111
+ reasons.append(GRANT_REASONS[grant.status])
112
+ if connection:
113
+ reasons.extend(connection_reasons(workspace, request, connection))
114
+ if grant and grant.status == JobGrant.Status.APPROVED and connection is None:
115
+ reasons.append(PlanReason.CONNECTION_MISSING)
116
+ if grant and requested - set(grant.allowed_tools):
117
+ reasons.append(PlanReason.TOOLS_NOT_GRANTED)
118
+ if connection and requested - set(connection.available_tools):
119
+ reasons.append(PlanReason.TOOLS_UNAVAILABLE)
120
+ return reasons
121
+
122
+
123
+ def connection_reasons(
124
+ workspace: Workspace, request: RequestedCapability, connection: Connection
125
+ ) -> list[str]:
126
+ reasons = []
127
+ if connection.current_status != Connection.Status.CONNECTED:
128
+ reasons.append(CONNECTION_REASONS[connection.current_status])
129
+ if connection.workspace_id != workspace.pk:
130
+ reasons.append(PlanReason.CONNECTION_WORKSPACE_MISMATCH)
131
+ if connection.kind != request.kind:
132
+ reasons.append(PlanReason.CONNECTION_KIND_MISMATCH)
133
+ if connection.binding_key != request.binding:
134
+ reasons.append(PlanReason.CONNECTION_BINDING_MISMATCH)
135
+ return reasons
136
+
137
+
138
+ def rules_broken(grant: CapabilityApproval, connection: Connection) -> bool:
139
+ """The connection's schemas changed under the owner's rules. Fail closed."""
140
+ definitions = connection.tool_definitions
141
+ return bool(grant.tool_rules and broken_rules(grant.tool_rules, definitions))
142
+
143
+
144
+ def missing_reason(
145
+ workspace: Workspace,
146
+ request: RequestedCapability,
147
+ grant: CapabilityApproval | None,
148
+ reasons: list[str],
149
+ ) -> Missing:
150
+ if grant and grant.status == JobGrant.Status.DENIED:
151
+ return Missing.DENIED
152
+ connection = (grant.connection if grant else None) or connection_for_binding(
153
+ workspace.pk, request.binding
154
+ )
155
+ if connection is None or connection.status != Connection.Status.CONNECTED:
156
+ return Missing.NOT_CONNECTED
157
+ if WAITING_REASONS & set(reasons):
158
+ return Missing.WAITING_FOR_APPROVAL
159
+ return Missing.UNAVAILABLE
160
+
161
+
162
+ def capability_plan(
163
+ workspace: Workspace, repository: Repository, capability: PlannedCapability
164
+ ) -> CapabilityPlan:
165
+ """One requested capability, with the tools the run may use."""
166
+ request = capability.request
167
+ grant = capability.approval
168
+ connection = grant.connection if grant else None
169
+ requested = set(request.requested_tools)
170
+ allowed = set(grant.allowed_tools) if grant else set()
171
+ available = set(connection.available_tools) if connection else set()
172
+ effective = requested & allowed & available
173
+ reasons = blocking_reasons(workspace, request, grant, connection)
174
+ if grant and connection and rules_broken(grant, connection):
175
+ reasons.append(PlanReason.TOOL_RULES_BROKEN)
176
+
177
+ adapter = EXTERNAL_KINDS.get(request.kind)
178
+ access = (
179
+ adapter.access(connection, repository_ref(repository))
180
+ if adapter and connection and connection.kind == request.kind
181
+ else None
182
+ )
183
+ if access and not access.ok:
184
+ reasons.append(access.reason)
185
+
186
+ authorized = not reasons
187
+ frozen = bool(adapter and connection and access and access.scope and authorized)
188
+ return CapabilityPlan(
189
+ binding=request.binding,
190
+ kind=request.kind,
191
+ required=request.required,
192
+ requested_tools=tuple(sorted(requested)),
193
+ connection_id=connection.pk if connection else None,
194
+ connection_name=connection.display_name if connection else None,
195
+ effective_tools=tuple(sorted(effective)) if authorized else (),
196
+ authorized=authorized,
197
+ reasons=tuple(reasons),
198
+ missing=(
199
+ None if authorized else missing_reason(workspace, request, grant, reasons)
200
+ ),
201
+ external=(
202
+ adapter.freeze(connection, access.scope, effective) if frozen else None
203
+ ),
204
+ tool_rules=kept_rules(grant.tool_rules, effective) if frozen else {},
205
+ )
206
+
207
+
208
+ def compile_execution_plan(
209
+ workspace: Workspace,
210
+ repository: Repository,
211
+ definition: ExecutionDefinition,
212
+ resolved_capabilities: Sequence[PlannedCapability],
213
+ limits: dict[str, int],
214
+ commit_sha: str,
215
+ pull_request: PullRequestPlan | None = None,
216
+ issues: IssuesPlan | None = None,
217
+ subagents: int = 0,
218
+ ) -> ExecutionPlan:
219
+ """Compile server-resolved policy into the plan a run keeps."""
220
+ if repository.workspace_id != workspace.pk:
221
+ raise ValueError("Repository and execution policy must share a workspace.")
222
+ capabilities = tuple(
223
+ capability_plan(workspace, repository, item) for item in resolved_capabilities
224
+ )
225
+ selection = workspace_execution(workspace)
226
+ connection = selection.connection
227
+ ready = selection.ready and all(
228
+ item.authorized for item in capabilities if item.required
229
+ )
230
+ agent = AgentPlan(
231
+ limits=PlanLimits(
232
+ max_turns=limits[RunLimit.MAX_TURNS],
233
+ max_tool_calls=limits[RunLimit.MAX_TOOL_CALLS],
234
+ max_tool_result_chars=settings.AGENT_MAX_TOOL_RESULT_CHARS,
235
+ token_budget=limits[RunLimit.TOKEN_BUDGET],
236
+ timeout_seconds=limits[RunLimit.TIMEOUT_SECONDS],
237
+ ),
238
+ tools=tuple(tool.name for tool in plan_tools(ready, capabilities)),
239
+ # Backends that cannot start sub-agents get none. Lowering the server
240
+ # maximum applies before the manifest is synced again.
241
+ subagents=(
242
+ min(subagents, settings.RUN_SUBAGENTS_MAX)
243
+ if selection.backend.starts_subagents
244
+ else 0
245
+ ),
246
+ )
247
+ return ExecutionPlan(
248
+ execution=ExecutionChoice(
249
+ backend=selection.backend,
250
+ label=selection.label,
251
+ ready=selection.ready,
252
+ problem=selection.problem,
253
+ connection_id=connection.pk if connection else None,
254
+ credential_key=str(connection.credential_key) if connection else None,
255
+ owner_id=connection.owner_id if connection else None,
256
+ ),
257
+ ready=ready,
258
+ agent=agent,
259
+ job=JobPlan(
260
+ key=definition.key,
261
+ skill_path=definition.skill_path,
262
+ config_hash=definition.config_hash,
263
+ skill_source=definition.skill_source,
264
+ ),
265
+ repository=RepositoryPlan(
266
+ full_name=repository.full_name, config_sha=commit_sha
267
+ ),
268
+ runner=RunnerPlan(
269
+ image=settings.RUNNER_IMAGE,
270
+ network_policy="none",
271
+ collect_changes=pull_request is not None,
272
+ ),
273
+ pull_request=pull_request,
274
+ issues=issues,
275
+ model=model_plan(workspace) if selection.backend.uses_model_api else None,
276
+ capabilities=capabilities,
277
+ )
278
+
279
+
280
+ def job_skill_source(job: Job) -> SkillSourcePlan | None:
281
+ """A synced job's source is always pinned to a commit."""
282
+ if not job.skill_source:
283
+ return None
284
+ return SkillSourcePlan(
285
+ repository=job.skill_source["repository"],
286
+ commit_sha=job.skill_source["ref"],
287
+ )
288
+
289
+
290
+ def build_execution_plan(job: Job) -> ExecutionPlan:
291
+ """The plan for a run of this saved job, with its grants."""
292
+ capabilities = []
293
+ for request in job_requests_with_grants(job):
294
+ grant = getattr(request, "grant", None)
295
+ capabilities.append(
296
+ PlannedCapability(
297
+ RequestedCapability(
298
+ request.binding,
299
+ request.kind,
300
+ tuple(request.requested_tools),
301
+ request.required,
302
+ ),
303
+ CapabilityApproval(
304
+ grant.status,
305
+ grant.connection,
306
+ tuple(grant.allowed_tools),
307
+ grant.tool_rules,
308
+ )
309
+ if grant is not None
310
+ else None,
311
+ )
312
+ )
313
+ return compile_execution_plan(
314
+ job.workspace,
315
+ job.repository,
316
+ ExecutionDefinition(
317
+ job.key,
318
+ job.skill_path,
319
+ job.config_hash,
320
+ job_skill_source(job),
321
+ ),
322
+ capabilities,
323
+ effective_limits(job),
324
+ job.repository.config_sha,
325
+ # Jobs run on the followed branch, so their drafts target it.
326
+ PullRequestPlan(
327
+ base=job.repository.default_branch, run_ci=job.repository.pull_request_ci
328
+ )
329
+ if job.open_pull_request
330
+ else None,
331
+ issues=(
332
+ IssuesPlan(max=issues.max, labels=tuple(issues.labels))
333
+ if (issues := job.issue_settings)
334
+ else None
335
+ ),
336
+ subagents=job.subagents,
337
+ )
338
+
339
+
340
+ def build_skill_execution_plan(
341
+ repository: Repository,
342
+ definition: JobManifest | TaskManifest,
343
+ *,
344
+ key: str,
345
+ commit_sha: str,
346
+ pull_request: PullRequestPlan | None = None,
347
+ skill_source: SkillSourcePlan | None = None,
348
+ ) -> ExecutionPlan:
349
+ """Plan an ad hoc repository skill without saving jobs, requests or grants.
350
+
351
+ The caller resolves the repository revision to a commit before planning,
352
+ and a skill from another repository to its commit.
353
+ Logical capabilities use only connections that allow automatic approval;
354
+ grants for other jobs are never inherited. Limits and execution selection
355
+ come from the workspace, and sandbox policy comes from server settings.
356
+ This does not check out skill files or submit an execution.
357
+ """
358
+ if not re.fullmatch(COMMIT_SHA_PATTERN, commit_sha):
359
+ raise ValueError("A full commit SHA is required before planning a skill.")
360
+ requests = manifest_capabilities(definition)
361
+ connections = connections_by_binding(
362
+ repository.workspace_id, [request.binding for request in requests]
363
+ )
364
+ capabilities = []
365
+ for request in requests:
366
+ connection = connections.get(request.binding)
367
+ approval = None
368
+ if connection is not None and approves_automatically(
369
+ request, connection, repository
370
+ ):
371
+ approval = CapabilityApproval(
372
+ JobGrant.Status.APPROVED, connection, request.requested_tools
373
+ )
374
+ capabilities.append(PlannedCapability(request, approval))
375
+ return compile_execution_plan(
376
+ repository.workspace,
377
+ repository,
378
+ ExecutionDefinition(
379
+ key, definition.skill or "", manifest_hash(definition), skill_source
380
+ ),
381
+ capabilities,
382
+ workspace_limits(repository.workspace),
383
+ commit_sha,
384
+ pull_request,
385
+ subagents=definition.subagents,
386
+ )
@@ -0,0 +1,46 @@
1
+ """Why a job cannot run yet, in plain words."""
2
+
3
+ from secondshift.jobs.models import Job
4
+ from secondshift.jobs.plan import ExecutionPlan, PlanReason
5
+ from secondshift.jobs.planning import build_execution_plan
6
+ from secondshift.repositories.models import Repository
7
+
8
+ MISMATCH = "is approved for a connection that does not match"
9
+ REASON_TEXT = {
10
+ PlanReason.NO_GRANT: "waits for an owner's approval",
11
+ PlanReason.GRANT_PENDING: "waits for an owner's approval",
12
+ PlanReason.GRANT_DENIED: "was denied by an owner",
13
+ PlanReason.CONNECTION_MISSING: "has no approved connection",
14
+ PlanReason.CONNECTION_EXPIRED: "uses a connection whose sign-in expired",
15
+ PlanReason.CONNECTION_REVOKED: "uses a connection that was disconnected",
16
+ PlanReason.CONNECTION_WORKSPACE_MISMATCH: MISMATCH,
17
+ PlanReason.CONNECTION_KIND_MISMATCH: MISMATCH,
18
+ PlanReason.CONNECTION_BINDING_MISMATCH: MISMATCH,
19
+ PlanReason.TOOLS_NOT_GRANTED: "requests tools the owner did not approve",
20
+ PlanReason.TOOLS_UNAVAILABLE: "requests tools the connection does not offer",
21
+ PlanReason.TOOL_RULES_BROKEN: (
22
+ "has tool restrictions that no longer fit the connection"
23
+ ),
24
+ }
25
+ OTHER_REASON = "cannot reach what the job needs through its connection"
26
+
27
+
28
+ def execution_problems(repository: Repository, plan: ExecutionPlan) -> list[str]:
29
+ problems = []
30
+ if not repository.accepts_runs:
31
+ problems.append("The repository is disabled.")
32
+ if not plan.execution.ready:
33
+ problems.append(plan.execution.problem)
34
+ for capability in plan.capabilities:
35
+ if not capability.required or capability.authorized:
36
+ continue
37
+ texts = dict.fromkeys(
38
+ REASON_TEXT.get(reason, OTHER_REASON) for reason in capability.reasons
39
+ )
40
+ problems.append(f"{capability.binding} {' and '.join(texts)}.")
41
+ return problems
42
+
43
+
44
+ def readiness_problems(job: Job) -> list[str]:
45
+ """Empty when Run now would start the job."""
46
+ return execution_problems(job.repository, build_execution_plan(job))
@@ -0,0 +1,199 @@
1
+ """Bring a repository's jobs up to date with its committed configuration.
2
+
3
+ The repositories app reads the source. This module reads the manifest and
4
+ skills from it and syncs the jobs, so source code never knows about jobs.
5
+ Git runs outside database transactions.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ import yaml
11
+ from django.db import transaction
12
+
13
+ from secondshift.config.constants import MANIFEST_FILE
14
+ from secondshift.connections.models import Connection
15
+ from secondshift.github.errors import GitHubError
16
+ from secondshift.jobs.automatic_grants import approve_waiting
17
+ from secondshift.jobs.dao import reopen_github_mcp_grants, repository_active_requests
18
+ from secondshift.jobs.manifest import RepositoryManifest, parse_manifest, skill_key
19
+ from secondshift.jobs.sync import sync_jobs
20
+ from secondshift.repositories.dao import (
21
+ get_repository_with_connection,
22
+ restore_repository_fields,
23
+ set_sync_error,
24
+ )
25
+ from secondshift.repositories.external_skills import (
26
+ ExternalSkillError,
27
+ resolve_external_skill,
28
+ )
29
+ from secondshift.repositories.github_access import connection_access
30
+ from secondshift.repositories.github_sync import (
31
+ fetched_branch,
32
+ record_synced_revision,
33
+ save_attached_repository,
34
+ )
35
+ from secondshift.repositories.local import (
36
+ LocalRepositoryError,
37
+ LocalRepositoryLocation,
38
+ committed_file,
39
+ locate_local_repository,
40
+ save_local_location,
41
+ )
42
+ from secondshift.repositories.models import Repository
43
+ from secondshift.workspaces.models import Workspace
44
+
45
+
46
+ class RepositoryConfigError(LocalRepositoryError):
47
+ """The branch's manifest, or a skill it names, is missing or invalid.
48
+
49
+ The repository can still be added. Its page shows the problem.
50
+ """
51
+
52
+
53
+ class SyncError(Exception):
54
+ pass
55
+
56
+
57
+ def read_committed_config(
58
+ root: Path, revision: str, branch: str
59
+ ) -> tuple[RepositoryManifest, dict[str, str]]:
60
+ """The manifest and its skills at a commit. Raises RepositoryConfigError."""
61
+ try:
62
+ text = committed_file(root, revision, MANIFEST_FILE)
63
+ except LocalRepositoryError as error:
64
+ raise RepositoryConfigError(
65
+ f"The branch {branch} has no committed {MANIFEST_FILE} at its root."
66
+ ) from error
67
+ try:
68
+ manifest = parse_manifest(text)
69
+ except (ValueError, yaml.YAMLError) as error:
70
+ raise RepositoryConfigError(f"Invalid {MANIFEST_FILE}: {error}") from error
71
+ skills = {}
72
+ try:
73
+ for job in manifest.jobs.values():
74
+ if source := job.skill_source:
75
+ skills[skill_key(job)] = resolve_external_skill(
76
+ source.repository, job.skill, source.ref
77
+ ).text
78
+ else:
79
+ skills[skill_key(job)] = committed_file(root, revision, job.skill)
80
+ except ExternalSkillError as error:
81
+ raise RepositoryConfigError(f"Invalid {MANIFEST_FILE}: {error}") from error
82
+ except LocalRepositoryError as error:
83
+ raise RepositoryConfigError(str(error)) from error
84
+ return manifest, skills
85
+
86
+
87
+ def inspect_local_repository(
88
+ raw_path: str, branch: str | None = None
89
+ ) -> LocalRepositoryLocation:
90
+ """Check the newest commit on a branch has a valid configuration.
91
+
92
+ Only committed files count, so edits in the working folder never change
93
+ what runs.
94
+ """
95
+ location = locate_local_repository(raw_path, branch)
96
+ read_committed_config(location.root, location.revision, location.branch)
97
+ return location
98
+
99
+
100
+ def attach_local_repository(
101
+ workspace: Workspace, raw_path: str, branch: str | None = None
102
+ ) -> Repository:
103
+ """Add a local repository, or sync one again from its branch.
104
+
105
+ A missing or invalid configuration does not stop it. The repository is
106
+ saved with the problem in last_sync_error, and its jobs stay as the last
107
+ good sync left them.
108
+ """
109
+ location = locate_local_repository(raw_path, branch)
110
+ try:
111
+ manifest, skills = read_committed_config(
112
+ location.root, location.revision, location.branch
113
+ )
114
+ config_error = ""
115
+ except RepositoryConfigError as error:
116
+ manifest, skills, config_error = None, {}, str(error)
117
+ with transaction.atomic():
118
+ repository, origin_changed = save_local_location(
119
+ workspace, location, config_error, manifest is not None
120
+ )
121
+ if manifest is not None:
122
+ sync_jobs(repository, manifest, skills)
123
+ if origin_changed:
124
+ reopen_github_mcp_grants(repository)
125
+ approve_waiting(repository_active_requests(repository))
126
+ return repository
127
+
128
+
129
+ def sync_github_repository(repository: Repository) -> Repository:
130
+ """Fetch the branch into the mirror and sync jobs from its newest commit.
131
+
132
+ A missing or invalid configuration is saved in last_sync_error, and the
133
+ jobs stay as the last good sync left them. The mirror's lock keeps two
134
+ syncs of one repository in order, so an older one never saves last.
135
+ """
136
+ repository = get_repository_with_connection(repository.pk)
137
+ with fetched_branch(repository) as branch:
138
+ try:
139
+ manifest, skills = read_committed_config(
140
+ branch.mirror, branch.revision, repository.default_branch
141
+ )
142
+ except RepositoryConfigError as error:
143
+ # Keep the last good sync. Runs go on using it.
144
+ set_sync_error(repository.pk, str(error), checked_sha=branch.revision)
145
+ repository.refresh_from_db()
146
+ return repository
147
+ branch.mark_synced()
148
+ with transaction.atomic():
149
+ locked = record_synced_revision(repository.pk, branch.revision)
150
+ sync_jobs(locked, manifest, skills)
151
+ repository.refresh_from_db()
152
+ return repository
153
+
154
+
155
+ def attach_github_repository(
156
+ workspace: Workspace, connection: Connection, repository_id: int, branch: str
157
+ ) -> Repository:
158
+ """Add a repository, or restore a removed one, then sync it.
159
+
160
+ The row is saved first in a short transaction, so the GitHub fetch never
161
+ holds a database lock. If the first sync fails, the row is put back.
162
+ """
163
+ metadata = connection_access(connection, repository_id).metadata
164
+ with transaction.atomic():
165
+ repository, previous = save_attached_repository(
166
+ workspace, connection, repository_id, branch, metadata
167
+ )
168
+ try:
169
+ return sync_github_repository(repository)
170
+ except Exception:
171
+ if previous is None:
172
+ repository.delete()
173
+ else:
174
+ restore_repository_fields(repository.pk, previous)
175
+ raise
176
+
177
+
178
+ def sync_repository(repository: Repository) -> Repository:
179
+ """Sync from the branch's newest commit, from GitHub or the local folder."""
180
+ if repository.source_type == Repository.SourceType.GITHUB:
181
+ return sync_github_repository(repository)
182
+ return attach_local_repository(
183
+ repository.workspace, repository.local_path, repository.default_branch
184
+ )
185
+
186
+
187
+ def resync(repository: Repository) -> Repository:
188
+ """Read the manifest and skills again from the branch's newest commit.
189
+
190
+ A configuration problem is saved on the repository and raised, so the
191
+ person who pressed Resync sees it.
192
+ """
193
+ try:
194
+ synced = sync_repository(repository)
195
+ except (GitHubError, LocalRepositoryError) as error:
196
+ raise SyncError(str(error)) from error
197
+ if synced.last_sync_error:
198
+ raise SyncError(synced.last_sync_error)
199
+ return synced
@@ -0,0 +1,26 @@
1
+ from datetime import datetime
2
+ from zoneinfo import ZoneInfo
3
+
4
+ from croniter import croniter
5
+ from django.utils import timezone
6
+
7
+ from secondshift.jobs.dao import save_next_runs
8
+ from secondshift.jobs.models import Job
9
+
10
+
11
+ def next_occurrence(job: Job, after: datetime | None = None) -> datetime:
12
+ base = after or timezone.now()
13
+ local_base = base.astimezone(ZoneInfo(job.timezone))
14
+ next_local = croniter(job.cron, local_base).get_next(datetime)
15
+ return next_local.astimezone(ZoneInfo("UTC"))
16
+
17
+
18
+ def restart_schedules(jobs: list[Job]) -> None:
19
+ """Set the next run from now for jobs whose schedule was off.
20
+
21
+ A job keeps its old next run while it is off. That time is in the past
22
+ when the job turns back on.
23
+ """
24
+ for job in jobs:
25
+ job.next_run_at = next_occurrence(job) if job.cron else None
26
+ save_next_runs(jobs)