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,364 @@
1
+ import hashlib
2
+ import json
3
+ import re
4
+ from pathlib import Path, PurePosixPath
5
+ from typing import Annotated, ClassVar, Self
6
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
7
+
8
+ import yaml
9
+ from croniter import croniter
10
+ from django.conf import settings
11
+ from pydantic import (
12
+ BaseModel,
13
+ ConfigDict,
14
+ Field,
15
+ ValidationError,
16
+ field_validator,
17
+ model_validator,
18
+ )
19
+ from pydantic_core import ErrorDetails
20
+
21
+ from secondshift.config.constants import (
22
+ COMMIT_SHA_PATTERN,
23
+ GITHUB_REPOSITORY_PATTERN,
24
+ MAX_RUN_ISSUES,
25
+ TriggerKind,
26
+ )
27
+
28
+
29
+ class CapabilityManifest(BaseModel):
30
+ model_config = ConfigDict(extra="forbid")
31
+
32
+ binding: str = Field(pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$", max_length=120)
33
+ tools: list[str] = Field(min_length=1)
34
+ required: bool = True
35
+
36
+ @field_validator("tools")
37
+ @classmethod
38
+ def validate_tools(cls, values: list[str]) -> list[str]:
39
+ if any(not value.strip() for value in values):
40
+ raise ValueError("tool names cannot be empty")
41
+ if len(values) != len(set(values)):
42
+ raise ValueError("tool names must be unique within a binding")
43
+ return values
44
+
45
+
46
+ class RequirementsManifest(BaseModel):
47
+ model_config = ConfigDict(extra="forbid")
48
+
49
+ mcps: list[CapabilityManifest] = Field(default_factory=list)
50
+ apis: list[CapabilityManifest] = Field(default_factory=list)
51
+
52
+
53
+ class NotificationManifest(BaseModel):
54
+ model_config = ConfigDict(extra="forbid")
55
+
56
+ binding: str = Field(pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$", max_length=120)
57
+ required: bool = True
58
+
59
+
60
+ # A dotted path to a field in a JSON body, such as "status" or "labels.team".
61
+ FieldPath = Annotated[
62
+ str,
63
+ Field(pattern=r"^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$", max_length=200),
64
+ ]
65
+ DURATION = re.compile(r"^(\d+)([mhd])$")
66
+ DURATION_UNITS = {"m": 60, "h": 3600, "d": 86400}
67
+ REPEAT_WINDOW_MAX_SECONDS = 7 * 86400
68
+
69
+
70
+ def duration_seconds(value: str) -> int:
71
+ """Seconds in a duration such as "30m", "1h" or "2d"."""
72
+ match = DURATION.match(value)
73
+ if match is None:
74
+ raise ValueError("must be a number followed by m, h or d, such as 1h")
75
+ return int(match.group(1)) * DURATION_UNITS[match.group(2)]
76
+
77
+
78
+ # Used when a job sets repeat_key without repeat_window.
79
+ REPEAT_WINDOW_DEFAULT = "1h"
80
+
81
+
82
+ class WebhookManifest(BaseModel):
83
+ """Which webhook requests start a run. Field names are the sender's."""
84
+
85
+ model_config = ConfigDict(extra="forbid")
86
+
87
+ # Skip a request when every listed field has the listed value.
88
+ skip_when: dict[FieldPath, str | int | float | bool] = Field(default_factory=dict)
89
+ # Requests with the same value in this field are the same event.
90
+ repeat_key: FieldPath | None = None
91
+ # How long after its run a repeat is still skipped.
92
+ repeat_window: str | None = None
93
+
94
+ @field_validator("repeat_window")
95
+ @classmethod
96
+ def validate_repeat_window(cls, value: str | None) -> str | None:
97
+ if value is None:
98
+ return value
99
+ seconds = duration_seconds(value)
100
+ if not 0 < seconds <= REPEAT_WINDOW_MAX_SECONDS:
101
+ raise ValueError("must be between 1m and 7d")
102
+ return value
103
+
104
+ @property
105
+ def window(self) -> str:
106
+ """How long repeats are skipped, as written in the manifest."""
107
+ return self.repeat_window or REPEAT_WINDOW_DEFAULT
108
+
109
+ @model_validator(mode="after")
110
+ def validate_window_has_key(self) -> Self:
111
+ if self.repeat_window is not None and self.repeat_key is None:
112
+ raise ValueError("repeat_window needs a repeat_key")
113
+ return self
114
+
115
+
116
+ class SkillSourceManifest(BaseModel):
117
+ """Another public GitHub repository that holds the job's skill.
118
+
119
+ The job's `skill` is then a path in that repository. Only the skill's
120
+ folder reaches the run, read-only at /skill.
121
+ """
122
+
123
+ model_config = ConfigDict(extra="forbid")
124
+
125
+ repository: str = Field(pattern=rf"^{GITHUB_REPOSITORY_PATTERN}$", max_length=200)
126
+ # A job pins a full commit SHA. A one-off task may name a branch or tag,
127
+ # or leave it out for the default branch.
128
+ ref: str | None = Field(default=None, min_length=1, max_length=200)
129
+
130
+ @property
131
+ def pinned(self) -> bool:
132
+ return self.ref is not None and bool(re.fullmatch(COMMIT_SHA_PATTERN, self.ref))
133
+
134
+
135
+ IssueLabel = Annotated[
136
+ str, Field(min_length=1, max_length=50, pattern=r"^\S(?:.*\S)?$")
137
+ ]
138
+
139
+
140
+ class OpenIssuesManifest(BaseModel):
141
+ """Where SecondShift opens the issues a job's agent proposes, and how many."""
142
+
143
+ model_config = ConfigDict(extra="forbid")
144
+
145
+ # Added to every issue. The agent cannot choose labels.
146
+ labels: list[IssueLabel] = Field(default_factory=list, max_length=10)
147
+ max: int = Field(default=MAX_RUN_ISSUES, ge=1, le=MAX_RUN_ISSUES)
148
+
149
+ @field_validator("labels")
150
+ @classmethod
151
+ def distinct_labels(cls, value: list[str]) -> list[str]:
152
+ if len({label.casefold() for label in value}) != len(value):
153
+ raise ValueError("list each label once")
154
+ return value
155
+
156
+
157
+ class JobManifest(BaseModel):
158
+ model_config = ConfigDict(extra="forbid")
159
+ # A saved job runs only a skill someone reviewed, so its source is pinned.
160
+ pinned_sources: ClassVar[bool] = True
161
+
162
+ name: str = Field(min_length=1, max_length=200)
163
+ skill: str = Field(min_length=1, max_length=500)
164
+ # Where the skill comes from, when it is not in this repository.
165
+ skill_source: SkillSourceManifest | None = None
166
+ # Without a schedule, the job runs only when started with Run now.
167
+ schedule: str | None = Field(default=None, min_length=1, max_length=100)
168
+ timezone: str = "UTC"
169
+ # How many of the job's own earlier reports each run starts with.
170
+ history: int = Field(default=0, ge=0)
171
+ # How many sub-agents one run may start. They share the run's limits.
172
+ subagents: int = Field(default=0, ge=0)
173
+ requires: RequirementsManifest = Field(default_factory=RequirementsManifest)
174
+ notify: list[NotificationManifest] = Field(default_factory=list)
175
+ # Events that may start the job. An owner still turns each one on.
176
+ triggers: list[TriggerKind] = Field(default_factory=list)
177
+ webhook: WebhookManifest | None = None
178
+ # Open a draft pull request with the agent's changes. An owner still
179
+ # allows pull requests for the repository.
180
+ open_pull_request: bool = False
181
+ # Open the issues the agent proposes. true uses the defaults. An owner
182
+ # still allows issues for the repository.
183
+ open_issues: OpenIssuesManifest | None = None
184
+
185
+ @field_validator("open_issues", mode="before")
186
+ @classmethod
187
+ def open_issues_short_form(cls, value: object) -> object:
188
+ if value is True:
189
+ return {}
190
+ if value is False:
191
+ return None
192
+ return value
193
+
194
+ @field_validator("skill")
195
+ @classmethod
196
+ def validate_skill_path(cls, value: str) -> str:
197
+ path = PurePosixPath(value)
198
+ if path.is_absolute() or ".." in path.parts:
199
+ raise ValueError("skill must be a repository-relative path")
200
+ if path.name != "SKILL.md":
201
+ raise ValueError("skill must point to a SKILL.md file")
202
+ return value
203
+
204
+ @field_validator("triggers")
205
+ @classmethod
206
+ def validate_triggers(cls, values: list[TriggerKind]) -> list[TriggerKind]:
207
+ if len(values) != len(set(values)):
208
+ raise ValueError("triggers must be unique within a job")
209
+ return values
210
+
211
+ @field_validator("history")
212
+ @classmethod
213
+ def validate_history(cls, value: int) -> int:
214
+ if value > settings.JOB_HISTORY_MAX:
215
+ raise ValueError(
216
+ f"history cannot be above {settings.JOB_HISTORY_MAX} on this server"
217
+ )
218
+ return value
219
+
220
+ @field_validator("subagents")
221
+ @classmethod
222
+ def validate_subagents(cls, value: int) -> int:
223
+ if value > settings.RUN_SUBAGENTS_MAX:
224
+ raise ValueError(
225
+ f"subagents cannot be above {settings.RUN_SUBAGENTS_MAX} on this server"
226
+ )
227
+ return value
228
+
229
+ @field_validator("schedule")
230
+ @classmethod
231
+ def validate_schedule(cls, value: str | None) -> str | None:
232
+ if value is not None and not croniter.is_valid(value):
233
+ raise ValueError("schedule must be a valid five-field cron expression")
234
+ return value
235
+
236
+ @field_validator("timezone")
237
+ @classmethod
238
+ def validate_timezone(cls, value: str) -> str:
239
+ try:
240
+ ZoneInfo(value)
241
+ except ZoneInfoNotFoundError as error:
242
+ raise ValueError("timezone must be an IANA timezone") from error
243
+ return value
244
+
245
+ @model_validator(mode="after")
246
+ def validate_skill_source(self) -> Self:
247
+ if self.skill_source is None:
248
+ return self
249
+ if self.skill is None:
250
+ raise ValueError("skill_source needs a skill path in that repository")
251
+ if self.pinned_sources and not self.skill_source.pinned:
252
+ raise ValueError(
253
+ "skill_source.ref must be a full commit SHA, so a sync never "
254
+ "runs a skill nobody reviewed"
255
+ )
256
+ return self
257
+
258
+ @model_validator(mode="after")
259
+ def validate_webhook_is_a_trigger(self) -> Self:
260
+ if self.webhook is not None and TriggerKind.WEBHOOK not in self.triggers:
261
+ raise ValueError("webhook settings need triggers: [webhook]")
262
+ return self
263
+
264
+ @model_validator(mode="after")
265
+ def validate_unique_bindings(self) -> Self:
266
+ bindings = [
267
+ item.binding
268
+ for item in [*self.requires.mcps, *self.requires.apis, *self.notify]
269
+ ]
270
+ if len(bindings) != len(set(bindings)):
271
+ raise ValueError("bindings must be unique within a job")
272
+ return self
273
+
274
+
275
+ class TaskManifest(JobManifest):
276
+ """One-off work may supply instructions instead of a committed skill."""
277
+
278
+ pinned_sources: ClassVar[bool] = False
279
+
280
+ skill: str | None = Field(default=None, min_length=1, max_length=500) # type: ignore[assignment]
281
+
282
+ @field_validator("skill")
283
+ @classmethod
284
+ def validate_skill_path(cls, value: str | None) -> str | None:
285
+ return JobManifest.validate_skill_path(value) if value is not None else None
286
+
287
+
288
+ class RepositoryManifest(BaseModel):
289
+ model_config = ConfigDict(extra="forbid")
290
+
291
+ version: int = 1
292
+ jobs: dict[str, JobManifest]
293
+
294
+ @field_validator("version")
295
+ @classmethod
296
+ def validate_version(cls, value: int) -> int:
297
+ if value != 1:
298
+ raise ValueError("only manifest version 1 is supported")
299
+ return value
300
+
301
+
302
+ def load_manifest(path: Path) -> RepositoryManifest:
303
+ return parse_manifest(path.read_text())
304
+
305
+
306
+ def parse_manifest(text: str) -> RepositoryManifest:
307
+ raw = yaml.safe_load(text)
308
+ if not isinstance(raw, dict):
309
+ raise ManifestError("The file must be a mapping.")
310
+ try:
311
+ return RepositoryManifest.model_validate(raw)
312
+ except ValidationError as error:
313
+ raise ManifestError(describe_errors(error)) from error
314
+
315
+
316
+ class ManifestError(ValueError):
317
+ """A manifest problem, worded for the person who wrote the file."""
318
+
319
+
320
+ def describe_errors(error: ValidationError) -> str:
321
+ unknown = [
322
+ field_path(item) for item in error.errors() if item["type"] == "extra_forbidden"
323
+ ]
324
+ problems = [
325
+ describe_error(item)
326
+ for item in error.errors()
327
+ if item["type"] != "extra_forbidden"
328
+ ]
329
+ if unknown:
330
+ label = "Unknown field" if len(unknown) == 1 else "Unknown fields"
331
+ problems.append(
332
+ f"{label}: {', '.join(unknown)}. Check the spelling, or update "
333
+ "SecondShift if the file was written for a newer version."
334
+ )
335
+ return " ".join(problems)
336
+
337
+
338
+ def describe_error(item: ErrorDetails) -> str:
339
+ path = field_path(item)
340
+ if item["type"] == "missing":
341
+ return f"{path} is required."
342
+ message = item["msg"].removeprefix("Value error, ").rstrip(".")
343
+ message = message[:1].lower() + message[1:]
344
+ return f"{path}: {message}." if path else f"{message[:1].upper()}{message[1:]}."
345
+
346
+
347
+ def field_path(item: ErrorDetails) -> str:
348
+ return ".".join(str(part) for part in item["loc"])
349
+
350
+
351
+ def skill_key(job: JobManifest) -> str:
352
+ """Names the job's skill file among those a sync reads."""
353
+ if source := job.skill_source:
354
+ return f"{source.repository}@{source.ref}:{job.skill}"
355
+ return job.skill
356
+
357
+
358
+ def manifest_hash(job: JobManifest) -> str:
359
+ data = job.model_dump()
360
+ if data["skill_source"] is None:
361
+ # Jobs written before skill sources keep the hash they had.
362
+ del data["skill_source"]
363
+ canonical = json.dumps(data, sort_keys=True, separators=(",", ":"))
364
+ return hashlib.sha256(canonical.encode()).hexdigest()
@@ -0,0 +1,87 @@
1
+ # Generated by Django 6.0.9 on 2026-10-26 14:22
2
+
3
+ import django.db.models.deletion
4
+ from django.conf import settings
5
+ from django.db import migrations, models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ initial = True
11
+
12
+ dependencies = [
13
+ ('connections', '0001_initial'),
14
+ ('repositories', '0001_initial'),
15
+ ('workspaces', '0001_initial'),
16
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
17
+ ]
18
+
19
+ operations = [
20
+ migrations.CreateModel(
21
+ name='Job',
22
+ fields=[
23
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
24
+ ('max_turns', models.PositiveIntegerField(blank=True, null=True)),
25
+ ('max_tool_calls', models.PositiveIntegerField(blank=True, null=True)),
26
+ ('token_budget', models.PositiveBigIntegerField(blank=True, null=True)),
27
+ ('timeout_seconds', models.PositiveIntegerField(blank=True, null=True)),
28
+ ('key', models.SlugField(max_length=120)),
29
+ ('name', models.CharField(max_length=200)),
30
+ ('skill_path', models.CharField(max_length=500)),
31
+ ('cron', models.CharField(blank=True, default='', max_length=100)),
32
+ ('timezone', models.CharField(default='UTC', max_length=64)),
33
+ ('enabled', models.BooleanField(default=False)),
34
+ ('next_run_at', models.DateTimeField(blank=True, null=True)),
35
+ ('config_hash', models.CharField(max_length=64)),
36
+ ('created_at', models.DateTimeField(auto_now_add=True)),
37
+ ('updated_at', models.DateTimeField(auto_now=True)),
38
+ ('repository', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='repositories.repository')),
39
+ ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='workspaces.workspace')),
40
+ ],
41
+ options={
42
+ 'ordering': ['repository__full_name', 'name'],
43
+ 'abstract': False,
44
+ },
45
+ ),
46
+ migrations.CreateModel(
47
+ name='CapabilityRequest',
48
+ fields=[
49
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
50
+ ('binding', models.SlugField(max_length=120)),
51
+ ('kind', models.CharField(choices=[('mcp', 'MCP'), ('api', 'API'), ('notification', 'Notification')], max_length=24)),
52
+ ('requested_tools', models.JSONField(blank=True, default=list)),
53
+ ('required', models.BooleanField(default=True)),
54
+ ('created_at', models.DateTimeField(auto_now_add=True)),
55
+ ('updated_at', models.DateTimeField(auto_now=True)),
56
+ ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='capability_requests', to='jobs.job')),
57
+ ],
58
+ options={
59
+ 'ordering': ['job', 'binding'],
60
+ },
61
+ ),
62
+ migrations.CreateModel(
63
+ name='JobGrant',
64
+ fields=[
65
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
66
+ ('status', models.CharField(choices=[('pending', 'Pending'), ('approved', 'Approved'), ('denied', 'Denied')], default='pending', max_length=16)),
67
+ ('allowed_tools', models.JSONField(blank=True, default=list)),
68
+ ('reviewed_at', models.DateTimeField(blank=True, null=True)),
69
+ ('created_at', models.DateTimeField(auto_now_add=True)),
70
+ ('updated_at', models.DateTimeField(auto_now=True)),
71
+ ('capability_request', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='grant', to='jobs.capabilityrequest')),
72
+ ('connection', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='job_grants', to='connections.connection')),
73
+ ('reviewed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='reviewed_job_grants', to=settings.AUTH_USER_MODEL)),
74
+ ],
75
+ options={
76
+ 'ordering': ['capability_request__job', 'capability_request__binding'],
77
+ },
78
+ ),
79
+ migrations.AddConstraint(
80
+ model_name='job',
81
+ constraint=models.UniqueConstraint(fields=('repository', 'key'), name='unique_repository_job_key'),
82
+ ),
83
+ migrations.AddConstraint(
84
+ model_name='capabilityrequest',
85
+ constraint=models.UniqueConstraint(fields=('job', 'binding'), name='unique_job_capability_binding'),
86
+ ),
87
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Django 6.0.9 on 2026-10-27 12:35
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('jobs', '0001_initial'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='job',
15
+ name='history',
16
+ field=models.PositiveSmallIntegerField(default=0),
17
+ ),
18
+ ]
@@ -0,0 +1,23 @@
1
+ # Generated by Django 6.0.9 on 2026-10-29 09:13
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('jobs', '0002_job_history'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='job',
15
+ name='skill_text',
16
+ field=models.TextField(blank=True, default=''),
17
+ ),
18
+ migrations.AddField(
19
+ model_name='job',
20
+ name='skill_truncated',
21
+ field=models.BooleanField(default=False),
22
+ ),
23
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Django 6.0.9 on 2026-11-01 11:51
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('jobs', '0003_job_skill_text'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='jobgrant',
15
+ name='tool_rules',
16
+ field=models.JSONField(blank=True, default=dict),
17
+ ),
18
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Django 6.0.9 on 2026-11-01 17:20
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('jobs', '0004_jobgrant_tool_rules'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='job',
15
+ name='triggers',
16
+ field=models.JSONField(blank=True, default=list),
17
+ ),
18
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Django 6.0.9 on 2026-11-01 18:05
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('jobs', '0005_job_triggers'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='job',
15
+ name='webhook',
16
+ field=models.JSONField(blank=True, default=dict),
17
+ ),
18
+ ]
@@ -0,0 +1,23 @@
1
+ # Generated by Django 6.0.9 on 2026-11-04 11:50
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('jobs', '0006_job_webhook'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='job',
15
+ name='open_issues',
16
+ field=models.JSONField(blank=True, null=True),
17
+ ),
18
+ migrations.AddField(
19
+ model_name='job',
20
+ name='open_pull_request',
21
+ field=models.BooleanField(default=False),
22
+ ),
23
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Django 6.0.9 on 2026-11-07 14:45
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('jobs', '0007_job_deliveries'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='job',
15
+ name='removed_at',
16
+ field=models.DateTimeField(blank=True, null=True),
17
+ ),
18
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Django 6.0.9 on 2026-11-07 16:01
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('jobs', '0008_job_removed_at'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='job',
15
+ name='subagents',
16
+ field=models.PositiveSmallIntegerField(default=0),
17
+ ),
18
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Django 6.0.9 on 2026-11-11 19:20
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('jobs', '0009_job_subagents'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='job',
15
+ name='skill_source',
16
+ field=models.JSONField(blank=True, null=True),
17
+ ),
18
+ ]
File without changes