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.
- secondshift/__init__.py +1 -0
- secondshift/__main__.py +3 -0
- secondshift/cli.py +17 -0
- secondshift/config/__init__.py +0 -0
- secondshift/config/api.py +23 -0
- secondshift/config/app.py +62 -0
- secondshift/config/constants.py +279 -0
- secondshift/config/container_env.py +118 -0
- secondshift/config/first_run.py +40 -0
- secondshift/config/formatting.py +5 -0
- secondshift/config/gunicorn.py +31 -0
- secondshift/config/logging.py +40 -0
- secondshift/config/settings/__init__.py +0 -0
- secondshift/config/settings/base.py +524 -0
- secondshift/config/settings/dev.py +14 -0
- secondshift/config/settings/local.py +23 -0
- secondshift/config/settings/production.py +113 -0
- secondshift/config/settings/runner.py +140 -0
- secondshift/config/settings/test.py +47 -0
- secondshift/config/static_storage.py +45 -0
- secondshift/config/urls.py +42 -0
- secondshift/connections/__init__.py +0 -0
- secondshift/connections/access.py +27 -0
- secondshift/connections/admin.py +25 -0
- secondshift/connections/agents.py +224 -0
- secondshift/connections/api.py +747 -0
- secondshift/connections/apps.py +6 -0
- secondshift/connections/catalog.py +96 -0
- secondshift/connections/checks.py +22 -0
- secondshift/connections/dao.py +305 -0
- secondshift/connections/errors.py +13 -0
- secondshift/connections/execution.py +262 -0
- secondshift/connections/external.py +258 -0
- secondshift/connections/github.py +123 -0
- secondshift/connections/http_api/__init__.py +1 -0
- secondshift/connections/http_api/client.py +161 -0
- secondshift/connections/http_api/connections.py +187 -0
- secondshift/connections/http_api/spec.py +382 -0
- secondshift/connections/mcp/__init__.py +1 -0
- secondshift/connections/mcp/base.py +139 -0
- secondshift/connections/mcp/client.py +353 -0
- secondshift/connections/mcp/custom.py +86 -0
- secondshift/connections/mcp/github.py +256 -0
- secondshift/connections/mcp/oauth.py +352 -0
- secondshift/connections/mcp/providers.py +15 -0
- secondshift/connections/mcp/schemas.py +77 -0
- secondshift/connections/mcp/sentry.py +135 -0
- secondshift/connections/mcp/signin.py +206 -0
- secondshift/connections/migration_helpers.py +28 -0
- secondshift/connections/migrations/0001_initial.py +101 -0
- secondshift/connections/migrations/0002_alter_connection_kind.py +18 -0
- secondshift/connections/migrations/0003_require_approval.py +23 -0
- secondshift/connections/migrations/0004_new_connections_skip_approval.py +18 -0
- secondshift/connections/migrations/0005_agentconnection.py +36 -0
- secondshift/connections/migrations/0006_remove_is_default.py +23 -0
- secondshift/connections/migrations/0007_agent_last_check.py +18 -0
- secondshift/connections/migrations/0008_agentcredentialcleanup.py +25 -0
- secondshift/connections/migrations/0009_release_0_1_0b11.py +52 -0
- secondshift/connections/migrations/0010_connection_attribution.py +18 -0
- secondshift/connections/migrations/__init__.py +0 -0
- secondshift/connections/model_providers.py +441 -0
- secondshift/connections/models.py +246 -0
- secondshift/connections/outbound.py +317 -0
- secondshift/connections/repository_ref.py +19 -0
- secondshift/connections/schemas.py +313 -0
- secondshift/connections/serialization.py +100 -0
- secondshift/connections/services.py +94 -0
- secondshift/connections/tools.py +38 -0
- secondshift/connections/webhooks.py +107 -0
- secondshift/core/__init__.py +0 -0
- secondshift/core/adapters.py +38 -0
- secondshift/core/admin.py +5 -0
- secondshift/core/api.py +104 -0
- secondshift/core/apps.py +20 -0
- secondshift/core/launcher.py +311 -0
- secondshift/core/local_agents.py +90 -0
- secondshift/core/local_output.py +120 -0
- secondshift/core/management/__init__.py +0 -0
- secondshift/core/management/commands/__init__.py +0 -0
- secondshift/core/management/commands/bootstrap_dev.py +33 -0
- secondshift/core/management/commands/dev_reset_db.py +204 -0
- secondshift/core/management/commands/login.py +23 -0
- secondshift/core/management/commands/logs.py +58 -0
- secondshift/core/management/commands/status.py +46 -0
- secondshift/core/management/commands/up.py +244 -0
- secondshift/core/middleware.py +21 -0
- secondshift/core/migrations/__init__.py +0 -0
- secondshift/core/schemas.py +71 -0
- secondshift/core/signin.py +58 -0
- secondshift/core/static/core/account.css +149 -0
- secondshift/core/static/core/admin.css +135 -0
- secondshift/core/templatetags/__init__.py +0 -0
- secondshift/core/templatetags/accounts.py +20 -0
- secondshift/core/terminal.py +27 -0
- secondshift/core/views.py +10 -0
- secondshift/gateway/__init__.py +0 -0
- secondshift/gateway/apps.py +6 -0
- secondshift/gateway/dao.py +137 -0
- secondshift/gateway/metering.py +171 -0
- secondshift/gateway/migrations/0001_initial.py +54 -0
- secondshift/gateway/migrations/0002_modelusage_gateway_usage_created_at_idx.py +19 -0
- secondshift/gateway/migrations/__init__.py +0 -0
- secondshift/gateway/models.py +61 -0
- secondshift/gateway/pricing.py +39 -0
- secondshift/gateway/proxy.py +369 -0
- secondshift/gateway/tokens.py +68 -0
- secondshift/gateway/urls.py +17 -0
- secondshift/gateway/views.py +11 -0
- secondshift/github/__init__.py +6 -0
- secondshift/github/app.py +109 -0
- secondshift/github/authors.py +56 -0
- secondshift/github/credentials.py +59 -0
- secondshift/github/errors.py +2 -0
- secondshift/github/git.py +56 -0
- secondshift/github/identity.py +35 -0
- secondshift/github/installations.py +74 -0
- secondshift/github/issues.py +75 -0
- secondshift/github/permissions.py +49 -0
- secondshift/github/pulls.py +49 -0
- secondshift/github/repos.py +52 -0
- secondshift/github/rest.py +93 -0
- secondshift/github/ruff.toml +16 -0
- secondshift/github/tokens.py +52 -0
- secondshift/jobs/__init__.py +0 -0
- secondshift/jobs/access.py +12 -0
- secondshift/jobs/admin.py +83 -0
- secondshift/jobs/api.py +184 -0
- secondshift/jobs/apps.py +6 -0
- secondshift/jobs/automatic_grants.py +115 -0
- secondshift/jobs/autosync.py +78 -0
- secondshift/jobs/capabilities.py +43 -0
- secondshift/jobs/dao.py +258 -0
- secondshift/jobs/drafts.py +112 -0
- secondshift/jobs/grants.py +116 -0
- secondshift/jobs/limits.py +26 -0
- secondshift/jobs/management/__init__.py +0 -0
- secondshift/jobs/management/commands/__init__.py +0 -0
- secondshift/jobs/manifest.py +364 -0
- secondshift/jobs/migrations/0001_initial.py +87 -0
- secondshift/jobs/migrations/0002_job_history.py +18 -0
- secondshift/jobs/migrations/0003_job_skill_text.py +23 -0
- secondshift/jobs/migrations/0004_jobgrant_tool_rules.py +18 -0
- secondshift/jobs/migrations/0005_job_triggers.py +18 -0
- secondshift/jobs/migrations/0006_job_webhook.py +18 -0
- secondshift/jobs/migrations/0007_job_deliveries.py +23 -0
- secondshift/jobs/migrations/0008_job_removed_at.py +18 -0
- secondshift/jobs/migrations/0009_job_subagents.py +18 -0
- secondshift/jobs/migrations/0010_job_skill_source.py +18 -0
- secondshift/jobs/migrations/__init__.py +0 -0
- secondshift/jobs/models.py +199 -0
- secondshift/jobs/needs.py +154 -0
- secondshift/jobs/plan.py +176 -0
- secondshift/jobs/planning.py +386 -0
- secondshift/jobs/readiness.py +46 -0
- secondshift/jobs/repository_sync.py +199 -0
- secondshift/jobs/scheduling.py +26 -0
- secondshift/jobs/schemas.py +207 -0
- secondshift/jobs/serialization.py +218 -0
- secondshift/jobs/skill_needs.py +189 -0
- secondshift/jobs/sync.py +252 -0
- secondshift/jobs/tool_policy.py +82 -0
- secondshift/jobs/tool_rules.py +200 -0
- secondshift/mcp_server/__init__.py +6 -0
- secondshift/mcp_server/api.py +79 -0
- secondshift/mcp_server/apps.py +7 -0
- secondshift/mcp_server/audit.py +22 -0
- secondshift/mcp_server/core.py +12 -0
- secondshift/mcp_server/dao.py +23 -0
- secondshift/mcp_server/migrations/0001_initial.py +33 -0
- secondshift/mcp_server/migrations/__init__.py +0 -0
- secondshift/mcp_server/models.py +40 -0
- secondshift/mcp_server/schemas.py +204 -0
- secondshift/mcp_server/tools/__init__.py +9 -0
- secondshift/mcp_server/tools/jobs.py +106 -0
- secondshift/mcp_server/tools/manifests.py +127 -0
- secondshift/mcp_server/tools/repositories.py +42 -0
- secondshift/mcp_server/tools/runs.py +86 -0
- secondshift/mcp_server/tools/tasks.py +103 -0
- secondshift/mcp_server/tools_registry.py +81 -0
- secondshift/repositories/__init__.py +0 -0
- secondshift/repositories/access.py +13 -0
- secondshift/repositories/admin.py +19 -0
- secondshift/repositories/api.py +296 -0
- secondshift/repositories/apps.py +6 -0
- secondshift/repositories/dao.py +114 -0
- secondshift/repositories/external_skills.py +224 -0
- secondshift/repositories/github.py +27 -0
- secondshift/repositories/github_access.py +57 -0
- secondshift/repositories/github_check.py +279 -0
- secondshift/repositories/github_sync.py +181 -0
- secondshift/repositories/github_views.py +90 -0
- secondshift/repositories/local.py +198 -0
- secondshift/repositories/management/__init__.py +0 -0
- secondshift/repositories/management/commands/__init__.py +0 -0
- secondshift/repositories/management/commands/prune_source_cache.py +15 -0
- secondshift/repositories/migrations/0001_initial.py +45 -0
- secondshift/repositories/migrations/0002_repository_last_sync_error.py +18 -0
- secondshift/repositories/migrations/0003_repository_checked_sha.py +18 -0
- secondshift/repositories/migrations/0004_pull_request_settings.py +23 -0
- secondshift/repositories/migrations/0005_remove_repository_installation_id.py +17 -0
- secondshift/repositories/migrations/0006_allow_issues.py +18 -0
- secondshift/repositories/migrations/__init__.py +0 -0
- secondshift/repositories/mirrors.py +50 -0
- secondshift/repositories/models.py +104 -0
- secondshift/repositories/pull_requests.py +199 -0
- secondshift/repositories/schemas.py +102 -0
- secondshift/repositories/serialization.py +133 -0
- secondshift/repositories/skill_source.py +64 -0
- secondshift/repositories/snapshots.py +238 -0
- secondshift/repositories/sources.py +256 -0
- secondshift/repositories/status.py +67 -0
- secondshift/runner/README.md +65 -0
- secondshift/runner/__init__.py +1 -0
- secondshift/runner/agent_credentials.py +89 -0
- secondshift/runner/agent_image/Dockerfile +16 -0
- secondshift/runner/agent_image/agent.py +132 -0
- secondshift/runner/agent_image/claude.py +254 -0
- secondshift/runner/agent_image/codex.py +84 -0
- secondshift/runner/agent_image/outcomes.py +120 -0
- secondshift/runner/agent_image/subagent_hook.py +71 -0
- secondshift/runner/agent_protocol.py +41 -0
- secondshift/runner/agents.py +254 -0
- secondshift/runner/api.py +123 -0
- secondshift/runner/apps.py +6 -0
- secondshift/runner/client.py +168 -0
- secondshift/runner/collection.py +276 -0
- secondshift/runner/dao.py +64 -0
- secondshift/runner/dev.py +19 -0
- secondshift/runner/images.py +57 -0
- secondshift/runner/migrations/0001_initial.py +48 -0
- secondshift/runner/migrations/0002_session_source_sha256.py +18 -0
- secondshift/runner/migrations/0003_session_collect_changes.py +18 -0
- secondshift/runner/migrations/0004_session_history_sha256.py +18 -0
- secondshift/runner/migrations/0005_require_source_sha256.py +18 -0
- secondshift/runner/migrations/0006_session_skill_sha256.py +18 -0
- secondshift/runner/migrations/__init__.py +0 -0
- secondshift/runner/models.py +53 -0
- secondshift/runner/protocol.py +97 -0
- secondshift/runner/schemas.py +36 -0
- secondshift/runner/services.py +465 -0
- secondshift/runner/sources.py +130 -0
- secondshift/runner/urls.py +7 -0
- secondshift/runs/__init__.py +0 -0
- secondshift/runs/access.py +13 -0
- secondshift/runs/admin.py +89 -0
- secondshift/runs/api.py +211 -0
- secondshift/runs/apps.py +11 -0
- secondshift/runs/artifacts.py +261 -0
- secondshift/runs/dao.py +640 -0
- secondshift/runs/deliveries.py +80 -0
- secondshift/runs/execution/__init__.py +0 -0
- secondshift/runs/execution/access_needs.py +89 -0
- secondshift/runs/execution/agent.py +91 -0
- secondshift/runs/execution/agent_broker.py +198 -0
- secondshift/runs/execution/budgets.py +177 -0
- secondshift/runs/execution/cli_agent.py +167 -0
- secondshift/runs/execution/consent.py +58 -0
- secondshift/runs/execution/deadlines.py +47 -0
- secondshift/runs/execution/external_tools.py +84 -0
- secondshift/runs/execution/issues.py +99 -0
- secondshift/runs/execution/model_gateway.py +90 -0
- secondshift/runs/execution/payload.py +63 -0
- secondshift/runs/execution/result.py +59 -0
- secondshift/runs/execution/run.py +345 -0
- secondshift/runs/execution/subagents.py +246 -0
- secondshift/runs/execution/tool_broker.py +123 -0
- secondshift/runs/execution/workspace_tools.py +248 -0
- secondshift/runs/history.py +182 -0
- secondshift/runs/inputs.py +49 -0
- secondshift/runs/issue_delivery.py +144 -0
- secondshift/runs/management/__init__.py +0 -0
- secondshift/runs/management/commands/__init__.py +0 -0
- secondshift/runs/management/commands/prune_runs.py +12 -0
- secondshift/runs/management/commands/run_dbos_worker.py +129 -0
- secondshift/runs/migration_helpers.py +313 -0
- secondshift/runs/migrations/0001_initial.py +69 -0
- secondshift/runs/migrations/0002_run_started_by.py +18 -0
- secondshift/runs/migrations/0003_agentlease.py +25 -0
- secondshift/runs/migrations/0004_task_assignment.py +39 -0
- secondshift/runs/migrations/0005_backfill_tasks.py +19 -0
- secondshift/runs/migrations/0006_standalone_tasks.py +50 -0
- secondshift/runs/migrations/0007_run_delivery.py +35 -0
- secondshift/runs/migrations/0008_run_outputs.py +18 -0
- secondshift/runs/migrations/0009_pull_request_delivery.py +28 -0
- secondshift/runs/migrations/0010_run_issue.py +32 -0
- secondshift/runs/migrations/0011_issues_delivery.py +23 -0
- secondshift/runs/migrations/0012_alter_rundelivery_status.py +18 -0
- secondshift/runs/migrations/0013_release_0_1_0b11.py +113 -0
- secondshift/runs/migrations/0014_run_access_need.py +30 -0
- secondshift/runs/migrations/__init__.py +0 -0
- secondshift/runs/models.py +340 -0
- secondshift/runs/orchestration/__init__.py +0 -0
- secondshift/runs/orchestration/admission.py +15 -0
- secondshift/runs/orchestration/dbos_runtime.py +43 -0
- secondshift/runs/orchestration/dispatch.py +130 -0
- secondshift/runs/orchestration/periodic.py +39 -0
- secondshift/runs/orchestration/reconciliation.py +98 -0
- secondshift/runs/orchestration/schedules.py +51 -0
- secondshift/runs/orchestration/workflows.py +480 -0
- secondshift/runs/outputs.py +52 -0
- secondshift/runs/publishing.py +162 -0
- secondshift/runs/queue.py +26 -0
- secondshift/runs/reporting/__init__.py +0 -0
- secondshift/runs/reporting/collection.py +26 -0
- secondshift/runs/reporting/connection_usage.py +45 -0
- secondshift/runs/reporting/failures.py +150 -0
- secondshift/runs/reporting/health.py +158 -0
- secondshift/runs/reporting/notifications.py +258 -0
- secondshift/runs/reporting/queries.py +60 -0
- secondshift/runs/retention.py +35 -0
- secondshift/runs/schemas.py +192 -0
- secondshift/runs/serialization.py +245 -0
- secondshift/runs/services.py +176 -0
- secondshift/runs/signals.py +25 -0
- secondshift/runs/submissions.py +236 -0
- secondshift/runs/transitions.py +84 -0
- secondshift/templates/account/password_reset_help_text.html +1 -0
- secondshift/templates/admin/base_site.html +24 -0
- secondshift/templates/allauth/elements/provider.html +9 -0
- secondshift/templates/allauth/layouts/base.html +67 -0
- secondshift/templates/socialaccount/snippets/login.html +9 -0
- secondshift/templates/workspaces/invite_unusable.html +10 -0
- secondshift/triggers/__init__.py +0 -0
- secondshift/triggers/admin.py +19 -0
- secondshift/triggers/api.py +116 -0
- secondshift/triggers/apps.py +6 -0
- secondshift/triggers/dao.py +66 -0
- secondshift/triggers/filters.py +48 -0
- secondshift/triggers/migrations/0001_initial.py +55 -0
- secondshift/triggers/migrations/0002_ignored_events_and_hourly_limit.py +23 -0
- secondshift/triggers/migrations/__init__.py +0 -0
- secondshift/triggers/models.py +83 -0
- secondshift/triggers/schemas.py +58 -0
- secondshift/triggers/serialization.py +91 -0
- secondshift/triggers/services.py +246 -0
- secondshift/triggers/verification.py +84 -0
- secondshift/web/assets/index-Bwy0hvk_.js +2419 -0
- secondshift/web/assets/index-D27gB9_L.css +1 -0
- secondshift/web/favicon.svg +19 -0
- secondshift/web/index.html +16 -0
- secondshift/web/secondshift-mark.svg +16 -0
- secondshift/workspaces/__init__.py +0 -0
- secondshift/workspaces/access.py +181 -0
- secondshift/workspaces/admin.py +37 -0
- secondshift/workspaces/api.py +264 -0
- secondshift/workspaces/apps.py +6 -0
- secondshift/workspaces/context_processors.py +11 -0
- secondshift/workspaces/dao.py +281 -0
- secondshift/workspaces/invites.py +150 -0
- secondshift/workspaces/limits.py +197 -0
- secondshift/workspaces/management/__init__.py +0 -0
- secondshift/workspaces/management/commands/__init__.py +0 -0
- secondshift/workspaces/management/commands/create_workspace.py +39 -0
- secondshift/workspaces/migrations/0001_initial.py +67 -0
- secondshift/workspaces/migrations/0002_api_token.py +34 -0
- secondshift/workspaces/migrations/0003_execution_default.py +29 -0
- secondshift/workspaces/migrations/0004_copy_default_model_key.py +26 -0
- secondshift/workspaces/migrations/__init__.py +0 -0
- secondshift/workspaces/models.py +169 -0
- secondshift/workspaces/owners.py +58 -0
- secondshift/workspaces/schemas.py +149 -0
- secondshift/workspaces/serialization.py +138 -0
- secondshift/workspaces/server_settings.py +140 -0
- secondshift/workspaces/services.py +94 -0
- secondshift/workspaces/tokens.py +108 -0
- secondshift/workspaces/views.py +32 -0
- secondshift-0.1.0b12.dist-info/METADATA +270 -0
- secondshift-0.1.0b12.dist-info/RECORD +371 -0
- secondshift-0.1.0b12.dist-info/WHEEL +4 -0
- secondshift-0.1.0b12.dist-info/entry_points.txt +2 -0
- secondshift-0.1.0b12.dist-info/licenses/LICENSE +202 -0
secondshift/jobs/dao.py
ADDED
|
@@ -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
|