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
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
from django.core.exceptions import ValidationError
|
|
2
|
+
from django.db import models
|
|
3
|
+
|
|
4
|
+
from secondshift.config.constants import MANIFEST_FILE
|
|
5
|
+
from secondshift.connections.models import Connection
|
|
6
|
+
from secondshift.jobs.manifest import OpenIssuesManifest, WebhookManifest
|
|
7
|
+
from secondshift.repositories.models import Repository
|
|
8
|
+
from secondshift.workspaces.models import RunLimitOverrides, Workspace
|
|
9
|
+
|
|
10
|
+
# Why a job removed from the manifest cannot run or be resumed.
|
|
11
|
+
JOB_REMOVED = f"This job is no longer in {MANIFEST_FILE}. Add it back to run it."
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Job(RunLimitOverrides):
|
|
15
|
+
workspace = models.ForeignKey(
|
|
16
|
+
Workspace,
|
|
17
|
+
on_delete=models.CASCADE,
|
|
18
|
+
related_name="jobs",
|
|
19
|
+
)
|
|
20
|
+
repository = models.ForeignKey(
|
|
21
|
+
Repository,
|
|
22
|
+
on_delete=models.CASCADE,
|
|
23
|
+
related_name="jobs",
|
|
24
|
+
)
|
|
25
|
+
key = models.SlugField(max_length=120)
|
|
26
|
+
name = models.CharField(max_length=200)
|
|
27
|
+
skill_path = models.CharField(max_length=500)
|
|
28
|
+
# {"repository": "owner/name", "ref": <commit SHA>} when the skill is in
|
|
29
|
+
# another public GitHub repository. skill_path is then a path there.
|
|
30
|
+
skill_source = models.JSONField(null=True, blank=True)
|
|
31
|
+
# The skill file at the synced commit, for people to read before a run.
|
|
32
|
+
# Runs read the file from the commit, never from here.
|
|
33
|
+
skill_text = models.TextField(blank=True, default="")
|
|
34
|
+
skill_truncated = models.BooleanField(default=False)
|
|
35
|
+
# Empty for a job that runs only when started manually.
|
|
36
|
+
cron = models.CharField(max_length=100, blank=True, default="")
|
|
37
|
+
timezone = models.CharField(max_length=64, default="UTC")
|
|
38
|
+
# How many of its own earlier reports each run starts with.
|
|
39
|
+
history = models.PositiveSmallIntegerField(default=0)
|
|
40
|
+
# How many sub-agents one run may start.
|
|
41
|
+
subagents = models.PositiveSmallIntegerField(default=0)
|
|
42
|
+
# Trigger kinds the repository names, such as "webhook". See JobTrigger.
|
|
43
|
+
triggers = models.JSONField(default=list, blank=True)
|
|
44
|
+
# The repository's webhook settings: skip_when, repeat_key, repeat_window.
|
|
45
|
+
webhook = models.JSONField(default=dict, blank=True)
|
|
46
|
+
# The repository asks for a draft pull request with each run's changes.
|
|
47
|
+
open_pull_request = models.BooleanField(default=False)
|
|
48
|
+
# The repository asks to open the issues each run's agent proposes:
|
|
49
|
+
# {"labels": [...], "max": n}. None when it does not.
|
|
50
|
+
open_issues = models.JSONField(null=True, blank=True)
|
|
51
|
+
enabled = models.BooleanField(default=False)
|
|
52
|
+
next_run_at = models.DateTimeField(null=True, blank=True)
|
|
53
|
+
# Set when a sync no longer finds the job in the manifest. The job and its
|
|
54
|
+
# runs stay for history, but it cannot run until the manifest names it again.
|
|
55
|
+
removed_at = models.DateTimeField(null=True, blank=True)
|
|
56
|
+
config_hash = models.CharField(max_length=64)
|
|
57
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
58
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
59
|
+
|
|
60
|
+
class Meta(RunLimitOverrides.Meta):
|
|
61
|
+
constraints = [
|
|
62
|
+
models.UniqueConstraint(
|
|
63
|
+
fields=["repository", "key"],
|
|
64
|
+
name="unique_repository_job_key",
|
|
65
|
+
)
|
|
66
|
+
]
|
|
67
|
+
ordering = ["repository__full_name", "name"]
|
|
68
|
+
|
|
69
|
+
def __str__(self) -> str:
|
|
70
|
+
return f"{self.repository}: {self.name}"
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def webhook_settings(self) -> WebhookManifest | None:
|
|
74
|
+
return WebhookManifest.model_validate(self.webhook) if self.webhook else None
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def issue_settings(self) -> OpenIssuesManifest | None:
|
|
78
|
+
if not self.open_issues:
|
|
79
|
+
return None
|
|
80
|
+
return OpenIssuesManifest.model_validate(self.open_issues)
|
|
81
|
+
|
|
82
|
+
def capability_grant_status(self) -> str:
|
|
83
|
+
"""Summarize the required grants already loaded for this job."""
|
|
84
|
+
requests = [
|
|
85
|
+
request for request in self.capability_requests.all() if request.required
|
|
86
|
+
]
|
|
87
|
+
statuses = [
|
|
88
|
+
getattr(request, "grant", None).status
|
|
89
|
+
if hasattr(request, "grant")
|
|
90
|
+
else JobGrant.Status.PENDING
|
|
91
|
+
for request in requests
|
|
92
|
+
]
|
|
93
|
+
if JobGrant.Status.DENIED in statuses:
|
|
94
|
+
return JobGrant.Status.DENIED
|
|
95
|
+
if statuses and all(status == JobGrant.Status.APPROVED for status in statuses):
|
|
96
|
+
return JobGrant.Status.APPROVED
|
|
97
|
+
return JobGrant.Status.PENDING if requests else "not_required"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class CapabilityRequest(models.Model):
|
|
101
|
+
class Kind(models.TextChoices):
|
|
102
|
+
MCP = "mcp", "MCP"
|
|
103
|
+
API = "api", "API"
|
|
104
|
+
NOTIFICATION = "notification", "Notification"
|
|
105
|
+
|
|
106
|
+
job = models.ForeignKey(
|
|
107
|
+
Job,
|
|
108
|
+
on_delete=models.CASCADE,
|
|
109
|
+
related_name="capability_requests",
|
|
110
|
+
)
|
|
111
|
+
binding = models.SlugField(max_length=120)
|
|
112
|
+
kind = models.CharField(max_length=24, choices=Kind.choices)
|
|
113
|
+
requested_tools = models.JSONField(default=list, blank=True)
|
|
114
|
+
required = models.BooleanField(default=True)
|
|
115
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
116
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
117
|
+
|
|
118
|
+
class Meta:
|
|
119
|
+
constraints = [
|
|
120
|
+
models.UniqueConstraint(
|
|
121
|
+
fields=["job", "binding"],
|
|
122
|
+
name="unique_job_capability_binding",
|
|
123
|
+
)
|
|
124
|
+
]
|
|
125
|
+
ordering = ["job", "binding"]
|
|
126
|
+
|
|
127
|
+
def __str__(self) -> str:
|
|
128
|
+
return f"{self.job}: {self.binding}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class JobGrant(models.Model):
|
|
132
|
+
class Status(models.TextChoices):
|
|
133
|
+
PENDING = "pending", "Pending"
|
|
134
|
+
APPROVED = "approved", "Approved"
|
|
135
|
+
DENIED = "denied", "Denied"
|
|
136
|
+
|
|
137
|
+
capability_request = models.OneToOneField(
|
|
138
|
+
CapabilityRequest,
|
|
139
|
+
on_delete=models.CASCADE,
|
|
140
|
+
related_name="grant",
|
|
141
|
+
)
|
|
142
|
+
connection = models.ForeignKey(
|
|
143
|
+
Connection,
|
|
144
|
+
on_delete=models.PROTECT,
|
|
145
|
+
related_name="job_grants",
|
|
146
|
+
null=True,
|
|
147
|
+
blank=True,
|
|
148
|
+
)
|
|
149
|
+
status = models.CharField(
|
|
150
|
+
max_length=16,
|
|
151
|
+
choices=Status.choices,
|
|
152
|
+
default=Status.PENDING,
|
|
153
|
+
)
|
|
154
|
+
allowed_tools = models.JSONField(default=list, blank=True)
|
|
155
|
+
# The owner's argument rules per approved tool. See jobs.tool_rules.
|
|
156
|
+
tool_rules = models.JSONField(default=dict, blank=True)
|
|
157
|
+
reviewed_by = models.ForeignKey(
|
|
158
|
+
"auth.User",
|
|
159
|
+
on_delete=models.PROTECT,
|
|
160
|
+
related_name="reviewed_job_grants",
|
|
161
|
+
null=True,
|
|
162
|
+
blank=True,
|
|
163
|
+
)
|
|
164
|
+
reviewed_at = models.DateTimeField(null=True, blank=True)
|
|
165
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
166
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
167
|
+
|
|
168
|
+
class Meta:
|
|
169
|
+
ordering = ["capability_request__job", "capability_request__binding"]
|
|
170
|
+
|
|
171
|
+
def __str__(self) -> str:
|
|
172
|
+
return f"{self.capability_request}: {self.get_status_display()}"
|
|
173
|
+
|
|
174
|
+
def clean(self) -> None:
|
|
175
|
+
request = self.capability_request
|
|
176
|
+
connection = self.connection
|
|
177
|
+
if self.status == self.Status.APPROVED and connection is None:
|
|
178
|
+
raise ValidationError("Approved grants require a connection.")
|
|
179
|
+
if connection is None:
|
|
180
|
+
return
|
|
181
|
+
if request.job.workspace_id != connection.workspace_id:
|
|
182
|
+
raise ValidationError("Grant and connection must belong to one workspace.")
|
|
183
|
+
if request.kind != connection.kind:
|
|
184
|
+
raise ValidationError(
|
|
185
|
+
"Connection kind must match the requested capability."
|
|
186
|
+
)
|
|
187
|
+
if request.binding != connection.binding_key:
|
|
188
|
+
raise ValidationError(
|
|
189
|
+
"Connection binding must match the requested capability."
|
|
190
|
+
)
|
|
191
|
+
unknown_tools = set(self.allowed_tools) - set(request.requested_tools)
|
|
192
|
+
if unknown_tools:
|
|
193
|
+
raise ValidationError(
|
|
194
|
+
{"allowed_tools": "Allowed tools must be requested by the job."}
|
|
195
|
+
)
|
|
196
|
+
if set(self.tool_rules) - set(self.allowed_tools):
|
|
197
|
+
raise ValidationError(
|
|
198
|
+
{"tool_rules": "Rules can only apply to approved tools."}
|
|
199
|
+
)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
from secondshift.connections.access import Access
|
|
5
|
+
from secondshift.connections.dao import connections_by_binding
|
|
6
|
+
from secondshift.connections.external import EXTERNAL_KINDS, external_access
|
|
7
|
+
from secondshift.connections.models import Connection
|
|
8
|
+
from secondshift.jobs.dao import workspace_requests
|
|
9
|
+
from secondshift.jobs.models import CapabilityRequest, JobGrant
|
|
10
|
+
from secondshift.repositories.github import repository_ref
|
|
11
|
+
from secondshift.repositories.models import Repository
|
|
12
|
+
from secondshift.workspaces.models import Workspace
|
|
13
|
+
|
|
14
|
+
NeedStatus = Literal["missing", "unavailable", "needs_approval", "ready"]
|
|
15
|
+
STATUS_ORDER: dict[str, int] = {
|
|
16
|
+
"missing": 0,
|
|
17
|
+
"unavailable": 1,
|
|
18
|
+
"needs_approval": 2,
|
|
19
|
+
"ready": 3,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class NeedJob:
|
|
25
|
+
job_id: int
|
|
26
|
+
job_name: str
|
|
27
|
+
repository: str
|
|
28
|
+
capability_id: int
|
|
29
|
+
required: bool
|
|
30
|
+
grant_status: str
|
|
31
|
+
access: Access | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class CapabilityNeed:
|
|
36
|
+
binding: str
|
|
37
|
+
kind: str
|
|
38
|
+
required: bool
|
|
39
|
+
requested_tools: list[str]
|
|
40
|
+
status: NeedStatus
|
|
41
|
+
detail: str
|
|
42
|
+
connection: Connection | None
|
|
43
|
+
jobs: list[NeedJob] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def connection_problem(
|
|
47
|
+
kind: str,
|
|
48
|
+
connection: Connection | None,
|
|
49
|
+
requested_tools: set[str],
|
|
50
|
+
repositories: list[Repository],
|
|
51
|
+
) -> tuple[NeedStatus, str] | None:
|
|
52
|
+
"""Why a binding cannot be used yet, before anyone approves it."""
|
|
53
|
+
if connection is None:
|
|
54
|
+
return "missing", "No workspace connection uses this binding yet."
|
|
55
|
+
if connection.kind != kind:
|
|
56
|
+
return (
|
|
57
|
+
"unavailable",
|
|
58
|
+
f"The {connection.binding_key} connection is a "
|
|
59
|
+
f"{connection.get_kind_display()} connection, not {kind}.",
|
|
60
|
+
)
|
|
61
|
+
if (status := connection.current_status) != Connection.Status.CONNECTED:
|
|
62
|
+
return (
|
|
63
|
+
"unavailable",
|
|
64
|
+
f"The connection is {Connection.Status(status).label.lower()}.",
|
|
65
|
+
)
|
|
66
|
+
if kind in EXTERNAL_KINDS:
|
|
67
|
+
for repository in repositories:
|
|
68
|
+
access = external_access(connection, repository_ref(repository))
|
|
69
|
+
if access and not access.ok:
|
|
70
|
+
return "unavailable", access.text
|
|
71
|
+
missing_tools = requested_tools - set(connection.available_tools)
|
|
72
|
+
if missing_tools:
|
|
73
|
+
return (
|
|
74
|
+
"unavailable",
|
|
75
|
+
"The connection does not provide: " + ", ".join(sorted(missing_tools)),
|
|
76
|
+
)
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def need_status(
|
|
81
|
+
kind: str,
|
|
82
|
+
connection: Connection | None,
|
|
83
|
+
requests: list[CapabilityRequest],
|
|
84
|
+
requested_tools: set[str],
|
|
85
|
+
) -> tuple[NeedStatus, str]:
|
|
86
|
+
problem = connection_problem(
|
|
87
|
+
kind,
|
|
88
|
+
connection,
|
|
89
|
+
requested_tools,
|
|
90
|
+
[request.job.repository for request in requests],
|
|
91
|
+
)
|
|
92
|
+
if problem:
|
|
93
|
+
return problem
|
|
94
|
+
unapproved = [
|
|
95
|
+
request
|
|
96
|
+
for request in requests
|
|
97
|
+
if getattr(request, "grant", None) is None
|
|
98
|
+
or request.grant.status == JobGrant.Status.PENDING
|
|
99
|
+
]
|
|
100
|
+
if unapproved:
|
|
101
|
+
return "needs_approval", "Connected. Approve it for the jobs that use it."
|
|
102
|
+
return "ready", "Connected and reviewed for every job."
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def capability_needs(workspace: Workspace) -> list[CapabilityNeed]:
|
|
106
|
+
"""Group every capability requested by active jobs by its logical binding."""
|
|
107
|
+
requests = workspace_requests(workspace)
|
|
108
|
+
connections = connections_by_binding(workspace.pk)
|
|
109
|
+
grouped: dict[tuple[str, str], list[CapabilityRequest]] = {}
|
|
110
|
+
for request in requests:
|
|
111
|
+
grouped.setdefault((request.binding, request.kind), []).append(request)
|
|
112
|
+
|
|
113
|
+
needs: list[CapabilityNeed] = []
|
|
114
|
+
for (binding, kind), items in grouped.items():
|
|
115
|
+
connection = connections.get(binding)
|
|
116
|
+
requested_tools = {tool for item in items for tool in item.requested_tools}
|
|
117
|
+
status, detail = need_status(kind, connection, items, requested_tools)
|
|
118
|
+
needs.append(
|
|
119
|
+
CapabilityNeed(
|
|
120
|
+
binding=binding,
|
|
121
|
+
kind=kind,
|
|
122
|
+
required=any(item.required for item in items),
|
|
123
|
+
requested_tools=sorted(requested_tools),
|
|
124
|
+
status=status,
|
|
125
|
+
detail=detail,
|
|
126
|
+
connection=connection,
|
|
127
|
+
jobs=[
|
|
128
|
+
NeedJob(
|
|
129
|
+
job_id=item.job_id,
|
|
130
|
+
job_name=item.job.name,
|
|
131
|
+
repository=item.job.repository.display_name,
|
|
132
|
+
access=(
|
|
133
|
+
external_access(
|
|
134
|
+
connection, repository_ref(item.job.repository)
|
|
135
|
+
)
|
|
136
|
+
if connection and connection.kind == kind
|
|
137
|
+
else None
|
|
138
|
+
),
|
|
139
|
+
capability_id=item.pk,
|
|
140
|
+
required=item.required,
|
|
141
|
+
grant_status=(
|
|
142
|
+
item.grant.status
|
|
143
|
+
if getattr(item, "grant", None)
|
|
144
|
+
else "unconfigured"
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
for item in items
|
|
148
|
+
],
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
needs.sort(
|
|
152
|
+
key=lambda need: (STATUS_ORDER[need.status], not need.required, need.binding)
|
|
153
|
+
)
|
|
154
|
+
return needs
|
secondshift/jobs/plan.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""The execution plan frozen into each run.
|
|
2
|
+
|
|
3
|
+
The planner builds it once from server-owned policy. Runs, brokers and pages
|
|
4
|
+
read it through these types. It never holds credentials.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from pathlib import PurePosixPath
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict
|
|
12
|
+
|
|
13
|
+
from secondshift.config.constants import SANDBOX_SKILL_DIR, AgentBackend
|
|
14
|
+
from secondshift.workspaces.limits import RunLimit
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Frozen(BaseModel):
|
|
18
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Missing(StrEnum):
|
|
22
|
+
"""Why a run does not have a binding, in words safe to show the model."""
|
|
23
|
+
|
|
24
|
+
NOT_CONNECTED = "not connected"
|
|
25
|
+
WAITING_FOR_APPROVAL = "waiting for approval"
|
|
26
|
+
DENIED = "denied"
|
|
27
|
+
UNAVAILABLE = "not available"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PlanReason(StrEnum):
|
|
31
|
+
"""Why a capability is not authorized. Connections add AccessReason codes."""
|
|
32
|
+
|
|
33
|
+
NO_GRANT = "no_grant"
|
|
34
|
+
GRANT_PENDING = "grant_pending"
|
|
35
|
+
GRANT_DENIED = "grant_denied"
|
|
36
|
+
CONNECTION_MISSING = "connection_missing"
|
|
37
|
+
CONNECTION_EXPIRED = "connection_expired"
|
|
38
|
+
CONNECTION_REVOKED = "connection_revoked"
|
|
39
|
+
CONNECTION_WORKSPACE_MISMATCH = "connection_workspace_mismatch"
|
|
40
|
+
CONNECTION_KIND_MISMATCH = "connection_kind_mismatch"
|
|
41
|
+
CONNECTION_BINDING_MISMATCH = "connection_binding_mismatch"
|
|
42
|
+
TOOLS_NOT_GRANTED = "tools_not_granted"
|
|
43
|
+
TOOLS_UNAVAILABLE = "tools_unavailable"
|
|
44
|
+
TOOL_RULES_BROKEN = "tool_rules_broken"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ExecutionChoice(Frozen):
|
|
48
|
+
"""The agent backend the workspace chose, and the login a CLI agent uses."""
|
|
49
|
+
|
|
50
|
+
backend: AgentBackend
|
|
51
|
+
label: str
|
|
52
|
+
ready: bool
|
|
53
|
+
problem: str
|
|
54
|
+
connection_id: int | None = None
|
|
55
|
+
credential_key: str | None = None
|
|
56
|
+
owner_id: int | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PlanLimits(Frozen):
|
|
60
|
+
max_turns: int
|
|
61
|
+
max_tool_calls: int
|
|
62
|
+
max_tool_result_chars: int
|
|
63
|
+
token_budget: int
|
|
64
|
+
timeout_seconds: int
|
|
65
|
+
|
|
66
|
+
def value(self, limit: RunLimit) -> int:
|
|
67
|
+
return int(getattr(self, limit))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AgentPlan(Frozen):
|
|
71
|
+
limits: PlanLimits
|
|
72
|
+
# The only tool names the agent may call.
|
|
73
|
+
tools: tuple[str, ...]
|
|
74
|
+
subagents: int = 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SkillSourcePlan(Frozen):
|
|
78
|
+
"""A skill from another repository, pinned to the commit the run gets."""
|
|
79
|
+
|
|
80
|
+
repository: str
|
|
81
|
+
commit_sha: str
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class JobPlan(Frozen):
|
|
85
|
+
key: str
|
|
86
|
+
# A path in the run's repository, or in skill_source's when it is set.
|
|
87
|
+
skill_path: str
|
|
88
|
+
config_hash: str
|
|
89
|
+
skill_source: SkillSourcePlan | None = None
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def sandbox_skill_path(self) -> str:
|
|
93
|
+
"""Where the agent reads the skill. Empty for written instructions."""
|
|
94
|
+
if self.skill_source is not None:
|
|
95
|
+
return f"{SANDBOX_SKILL_DIR}/{PurePosixPath(self.skill_path).name}"
|
|
96
|
+
return self.skill_path
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class RepositoryPlan(Frozen):
|
|
100
|
+
full_name: str
|
|
101
|
+
config_sha: str
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class RunnerPlan(Frozen):
|
|
105
|
+
image: str
|
|
106
|
+
network_policy: Literal["none"]
|
|
107
|
+
# Only runs that publish the agent's changes need them recorded.
|
|
108
|
+
collect_changes: bool
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class PullRequestPlan(Frozen):
|
|
112
|
+
"""The draft pull request's base branch and CI choice."""
|
|
113
|
+
|
|
114
|
+
base: str
|
|
115
|
+
run_ci: bool
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class IssuesPlan(Frozen):
|
|
119
|
+
"""How many issues the agent may propose, and the labels each gets."""
|
|
120
|
+
|
|
121
|
+
max: int
|
|
122
|
+
labels: tuple[str, ...]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ModelPlan(Frozen):
|
|
126
|
+
connection_id: int
|
|
127
|
+
connection_name: str
|
|
128
|
+
provider: str
|
|
129
|
+
family: str
|
|
130
|
+
default_model: str
|
|
131
|
+
allowed_models: tuple[str, ...]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class CapabilityPlan(Frozen):
|
|
135
|
+
binding: str
|
|
136
|
+
kind: str
|
|
137
|
+
required: bool
|
|
138
|
+
requested_tools: tuple[str, ...]
|
|
139
|
+
connection_id: int | None
|
|
140
|
+
connection_name: str | None
|
|
141
|
+
effective_tools: tuple[str, ...]
|
|
142
|
+
authorized: bool
|
|
143
|
+
# PlanReason and AccessReason codes. Older runs may hold retired ones.
|
|
144
|
+
reasons: tuple[str, ...]
|
|
145
|
+
missing: Missing | None = None
|
|
146
|
+
# The approved scope and tool definitions of an external tool.
|
|
147
|
+
external: dict[str, Any] | None = None
|
|
148
|
+
# Copied in so a rule changed mid-run does not affect the run.
|
|
149
|
+
tool_rules: dict[str, dict[str, Any]] = {}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class ExecutionPlan(Frozen):
|
|
153
|
+
execution: ExecutionChoice
|
|
154
|
+
ready: bool
|
|
155
|
+
agent: AgentPlan
|
|
156
|
+
job: JobPlan
|
|
157
|
+
repository: RepositoryPlan
|
|
158
|
+
runner: RunnerPlan
|
|
159
|
+
pull_request: PullRequestPlan | None
|
|
160
|
+
issues: IssuesPlan | None
|
|
161
|
+
model: ModelPlan | None
|
|
162
|
+
capabilities: tuple[CapabilityPlan, ...]
|
|
163
|
+
|
|
164
|
+
def limit(self, limit: RunLimit) -> int:
|
|
165
|
+
return self.agent.limits.value(limit)
|
|
166
|
+
|
|
167
|
+
def capability(self, kind: str, binding: str) -> CapabilityPlan:
|
|
168
|
+
return next(
|
|
169
|
+
item
|
|
170
|
+
for item in self.capabilities
|
|
171
|
+
if item.kind == kind and item.binding == binding
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def stored(self) -> dict[str, Any]:
|
|
175
|
+
"""The JSON saved on the run."""
|
|
176
|
+
return self.model_dump(mode="json")
|