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