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/sync.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Reconcile a repository's jobs, capability requests and grants with its manifest.
|
|
2
|
+
|
|
3
|
+
Sync reads the existing rows once, creates new rows in bulk and saves only the
|
|
4
|
+
rows that changed. Its query count stays the same however many jobs there are.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from django.db import models, transaction
|
|
12
|
+
from django.utils import timezone
|
|
13
|
+
|
|
14
|
+
from secondshift.config.constants import MAX_SKILL_PREVIEW_CHARS
|
|
15
|
+
from secondshift.connections.dao import bound_connections
|
|
16
|
+
from secondshift.jobs.automatic_grants import automatic_approval
|
|
17
|
+
from secondshift.jobs.dao import (
|
|
18
|
+
create_capability_requests,
|
|
19
|
+
create_grants,
|
|
20
|
+
create_jobs,
|
|
21
|
+
delete_capability_requests,
|
|
22
|
+
repository_requests,
|
|
23
|
+
retire_missing_jobs,
|
|
24
|
+
)
|
|
25
|
+
from secondshift.jobs.manifest import (
|
|
26
|
+
CapabilityManifest,
|
|
27
|
+
JobManifest,
|
|
28
|
+
NotificationManifest,
|
|
29
|
+
RepositoryManifest,
|
|
30
|
+
manifest_hash,
|
|
31
|
+
skill_key,
|
|
32
|
+
)
|
|
33
|
+
from secondshift.jobs.models import CapabilityRequest, Job, JobGrant
|
|
34
|
+
from secondshift.jobs.scheduling import next_occurrence
|
|
35
|
+
from secondshift.jobs.tool_rules import kept_rules
|
|
36
|
+
from secondshift.repositories.models import Repository
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class SyncedRequest:
|
|
41
|
+
request: CapabilityRequest
|
|
42
|
+
tools: list[str]
|
|
43
|
+
# False when this sync created the request.
|
|
44
|
+
existed: bool
|
|
45
|
+
# The manifest asks for more tools than before, or for another kind.
|
|
46
|
+
expanded: bool
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@transaction.atomic
|
|
50
|
+
def sync_jobs(
|
|
51
|
+
repository: Repository,
|
|
52
|
+
manifest: RepositoryManifest,
|
|
53
|
+
skills: Mapping[str, str] | None = None,
|
|
54
|
+
) -> list[Job]:
|
|
55
|
+
"""`skills` maps each skill path to its text at the synced commit."""
|
|
56
|
+
jobs = sync_job_rows(repository, manifest, skills or {})
|
|
57
|
+
requests = sync_capability_requests(repository, jobs, manifest)
|
|
58
|
+
sync_grants(repository, requests)
|
|
59
|
+
retire_missing_jobs(repository, manifest.jobs.keys(), timezone.now())
|
|
60
|
+
repository.last_synced_at = timezone.now()
|
|
61
|
+
repository.save(update_fields=["last_synced_at", "updated_at"])
|
|
62
|
+
return jobs
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def assign(instance: models.Model, fields: Mapping[str, object]) -> list[str]:
|
|
66
|
+
"""Set the fields that differ and return their names."""
|
|
67
|
+
changed = [
|
|
68
|
+
name for name, value in fields.items() if getattr(instance, name) != value
|
|
69
|
+
]
|
|
70
|
+
for name in changed:
|
|
71
|
+
setattr(instance, name, fields[name])
|
|
72
|
+
return changed
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def save_changes(instance: models.Model, changed: list[str]) -> None:
|
|
76
|
+
if changed:
|
|
77
|
+
instance.save(update_fields=[*changed, "updated_at"])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def job_fields(
|
|
81
|
+
repository: Repository, item: JobManifest, skill: str
|
|
82
|
+
) -> dict[str, object]:
|
|
83
|
+
return {
|
|
84
|
+
"workspace_id": repository.workspace_id,
|
|
85
|
+
"name": item.name,
|
|
86
|
+
"skill_path": item.skill,
|
|
87
|
+
"skill_source": item.skill_source.model_dump() if item.skill_source else None,
|
|
88
|
+
"skill_text": skill[:MAX_SKILL_PREVIEW_CHARS],
|
|
89
|
+
"skill_truncated": len(skill) > MAX_SKILL_PREVIEW_CHARS,
|
|
90
|
+
"cron": item.schedule or "",
|
|
91
|
+
"timezone": item.timezone,
|
|
92
|
+
"history": item.history,
|
|
93
|
+
"subagents": item.subagents,
|
|
94
|
+
"triggers": [str(kind) for kind in item.triggers],
|
|
95
|
+
"webhook": item.webhook.model_dump(mode="json") if item.webhook else {},
|
|
96
|
+
"open_pull_request": item.open_pull_request,
|
|
97
|
+
"open_issues": item.open_issues.model_dump() if item.open_issues else None,
|
|
98
|
+
"config_hash": manifest_hash(item),
|
|
99
|
+
# A job named again comes back. Its schedule stays off until resumed.
|
|
100
|
+
"removed_at": None,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def schedule(job: Job, changed: list[str]) -> list[str]:
|
|
105
|
+
"""`changed` names the fields this sync changed on the job."""
|
|
106
|
+
if not job.cron:
|
|
107
|
+
# A job without a schedule is never on the scheduler.
|
|
108
|
+
return assign(job, {"next_run_at": None, "enabled": False})
|
|
109
|
+
if job.next_run_at is None or {"cron", "timezone"} & set(changed):
|
|
110
|
+
job.next_run_at = next_occurrence(job)
|
|
111
|
+
return ["next_run_at"]
|
|
112
|
+
return []
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def sync_job_rows(
|
|
116
|
+
repository: Repository, manifest: RepositoryManifest, skills: Mapping[str, str]
|
|
117
|
+
) -> list[Job]:
|
|
118
|
+
existing = {job.key: job for job in repository.jobs.all()}
|
|
119
|
+
jobs: list[Job] = []
|
|
120
|
+
created: list[Job] = []
|
|
121
|
+
for key, item in manifest.jobs.items():
|
|
122
|
+
job = existing.get(key) or Job(key=key)
|
|
123
|
+
job.repository = repository
|
|
124
|
+
changed = assign(
|
|
125
|
+
job, job_fields(repository, item, skills.get(skill_key(item), ""))
|
|
126
|
+
)
|
|
127
|
+
changed += schedule(job, changed)
|
|
128
|
+
if job.pk is None:
|
|
129
|
+
created.append(job)
|
|
130
|
+
else:
|
|
131
|
+
save_changes(job, changed)
|
|
132
|
+
jobs.append(job)
|
|
133
|
+
create_jobs(created)
|
|
134
|
+
return jobs
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def requested_capabilities(
|
|
138
|
+
item: JobManifest,
|
|
139
|
+
) -> list[tuple[str, CapabilityManifest | NotificationManifest]]:
|
|
140
|
+
return [
|
|
141
|
+
*(
|
|
142
|
+
(CapabilityRequest.Kind.MCP, capability)
|
|
143
|
+
for capability in item.requires.mcps
|
|
144
|
+
),
|
|
145
|
+
*(
|
|
146
|
+
(CapabilityRequest.Kind.API, capability)
|
|
147
|
+
for capability in item.requires.apis
|
|
148
|
+
),
|
|
149
|
+
*(
|
|
150
|
+
(CapabilityRequest.Kind.NOTIFICATION, notification)
|
|
151
|
+
for notification in item.notify
|
|
152
|
+
),
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def sync_capability_requests(
|
|
157
|
+
repository: Repository, jobs: list[Job], manifest: RepositoryManifest
|
|
158
|
+
) -> list[SyncedRequest]:
|
|
159
|
+
existing: dict[int, dict[str, CapabilityRequest]] = defaultdict(dict)
|
|
160
|
+
for request in repository_requests(repository):
|
|
161
|
+
existing[request.job_id][request.binding] = request
|
|
162
|
+
|
|
163
|
+
synced: list[SyncedRequest] = []
|
|
164
|
+
created: list[CapabilityRequest] = []
|
|
165
|
+
stale: list[int] = []
|
|
166
|
+
for job, item in zip(jobs, manifest.jobs.values(), strict=True):
|
|
167
|
+
current = existing.pop(job.pk, {})
|
|
168
|
+
for kind, capability in requested_capabilities(item):
|
|
169
|
+
tools = list(getattr(capability, "tools", []))
|
|
170
|
+
fields = {
|
|
171
|
+
"kind": kind,
|
|
172
|
+
"requested_tools": tools,
|
|
173
|
+
"required": capability.required,
|
|
174
|
+
}
|
|
175
|
+
request = current.pop(capability.binding, None)
|
|
176
|
+
if request is None:
|
|
177
|
+
request = CapabilityRequest(
|
|
178
|
+
job=job, binding=capability.binding, **fields
|
|
179
|
+
)
|
|
180
|
+
created.append(request)
|
|
181
|
+
synced.append(SyncedRequest(request, tools, False, False))
|
|
182
|
+
continue
|
|
183
|
+
expanded = (
|
|
184
|
+
not set(tools).issubset(request.requested_tools) or request.kind != kind
|
|
185
|
+
)
|
|
186
|
+
save_changes(request, assign(request, fields))
|
|
187
|
+
synced.append(SyncedRequest(request, tools, True, expanded))
|
|
188
|
+
# Bindings the manifest no longer asks for.
|
|
189
|
+
stale += [request.pk for request in current.values()]
|
|
190
|
+
|
|
191
|
+
create_capability_requests(created)
|
|
192
|
+
if stale:
|
|
193
|
+
delete_capability_requests(stale)
|
|
194
|
+
return synced
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def sync_grants(repository: Repository, requests: list[SyncedRequest]) -> None:
|
|
198
|
+
"""Grant matching connections, and send changed requests back for review.
|
|
199
|
+
|
|
200
|
+
A waiting request is approved here when its connection does not require
|
|
201
|
+
approval and allows every tool it asks for.
|
|
202
|
+
"""
|
|
203
|
+
connections = {
|
|
204
|
+
(connection.binding_key, connection.kind): connection
|
|
205
|
+
for connection in bound_connections(repository.workspace_id)
|
|
206
|
+
}
|
|
207
|
+
created: list[JobGrant] = []
|
|
208
|
+
for synced in requests:
|
|
209
|
+
request = synced.request
|
|
210
|
+
grant = getattr(request, "grant", None) if synced.existed else None
|
|
211
|
+
connection = connections.get((request.binding, request.kind))
|
|
212
|
+
new = grant is None
|
|
213
|
+
if grant is None:
|
|
214
|
+
if connection is None:
|
|
215
|
+
continue
|
|
216
|
+
grant = JobGrant(
|
|
217
|
+
capability_request=request,
|
|
218
|
+
connection=connection,
|
|
219
|
+
allowed_tools=sorted(synced.tools),
|
|
220
|
+
)
|
|
221
|
+
changed = assign(grant, review_fields(grant, synced)) if synced.existed else []
|
|
222
|
+
if (
|
|
223
|
+
grant.status == JobGrant.Status.PENDING
|
|
224
|
+
and connection is not None
|
|
225
|
+
and grant.connection_id in (None, connection.pk)
|
|
226
|
+
and (fields := automatic_approval(request, connection, repository))
|
|
227
|
+
):
|
|
228
|
+
changed += assign(grant, fields)
|
|
229
|
+
if new:
|
|
230
|
+
created.append(grant)
|
|
231
|
+
else:
|
|
232
|
+
save_changes(grant, changed)
|
|
233
|
+
create_grants(created)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def review_fields(grant: JobGrant, synced: SyncedRequest) -> dict[str, object]:
|
|
237
|
+
if synced.expanded:
|
|
238
|
+
# New tools need a fresh approval.
|
|
239
|
+
return {
|
|
240
|
+
"status": JobGrant.Status.PENDING,
|
|
241
|
+
"allowed_tools": sorted(synced.tools),
|
|
242
|
+
# Kept so the owner reviews them again with the new tools.
|
|
243
|
+
"tool_rules": kept_rules(grant.tool_rules, synced.tools),
|
|
244
|
+
"reviewed_by_id": None,
|
|
245
|
+
"reviewed_at": None,
|
|
246
|
+
}
|
|
247
|
+
# Tools the job stopped asking for are no longer allowed.
|
|
248
|
+
allowed = sorted(set(grant.allowed_tools) & set(synced.tools))
|
|
249
|
+
return {
|
|
250
|
+
"allowed_tools": allowed,
|
|
251
|
+
"tool_rules": kept_rules(grant.tool_rules, allowed),
|
|
252
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
from secondshift.jobs.plan import CapabilityPlan, ExecutionPlan
|
|
5
|
+
|
|
6
|
+
LOCAL_TOOLS = (
|
|
7
|
+
"workspace.list_files",
|
|
8
|
+
"workspace.read_file",
|
|
9
|
+
"workspace.search_text",
|
|
10
|
+
"workspace.run_command",
|
|
11
|
+
)
|
|
12
|
+
EXTERNAL_KINDS = {"api", "mcp"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ToolPolicyError(ValueError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class AuthorizedTool:
|
|
21
|
+
name: str
|
|
22
|
+
kind: str
|
|
23
|
+
binding: str | None
|
|
24
|
+
provider_tool: str | None
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def is_local(self) -> bool:
|
|
28
|
+
return self.kind == "workspace"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def external_tool_name(kind: str, binding: str, provider_tool: str) -> str:
|
|
32
|
+
"""Namespace provider tools so bindings cannot collide in an agent turn."""
|
|
33
|
+
if kind not in EXTERNAL_KINDS:
|
|
34
|
+
raise ToolPolicyError(f"Unsupported external tool kind: {kind}")
|
|
35
|
+
if not binding or not provider_tool:
|
|
36
|
+
raise ToolPolicyError("External tools require a binding and tool name.")
|
|
37
|
+
return f"{kind}.{binding}.{provider_tool}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def plan_tools(
|
|
41
|
+
ready: bool, capabilities: Sequence[CapabilityPlan]
|
|
42
|
+
) -> tuple[AuthorizedTool, ...]:
|
|
43
|
+
"""Compile the only tool names that an agent may request for this run."""
|
|
44
|
+
tools = [
|
|
45
|
+
AuthorizedTool(name=name, kind="workspace", binding=None, provider_tool=None)
|
|
46
|
+
for name in LOCAL_TOOLS
|
|
47
|
+
]
|
|
48
|
+
if not ready:
|
|
49
|
+
return tuple(tools)
|
|
50
|
+
|
|
51
|
+
for capability in capabilities:
|
|
52
|
+
if not capability.authorized or capability.kind not in EXTERNAL_KINDS:
|
|
53
|
+
continue
|
|
54
|
+
for provider_tool in capability.effective_tools:
|
|
55
|
+
tools.append(
|
|
56
|
+
AuthorizedTool(
|
|
57
|
+
name=external_tool_name(
|
|
58
|
+
capability.kind, capability.binding, provider_tool
|
|
59
|
+
),
|
|
60
|
+
kind=capability.kind,
|
|
61
|
+
binding=capability.binding,
|
|
62
|
+
provider_tool=provider_tool,
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
by_name = {tool.name: tool for tool in tools}
|
|
67
|
+
if len(by_name) != len(tools):
|
|
68
|
+
raise ToolPolicyError("The execution plan produced duplicate tool names.")
|
|
69
|
+
return tuple(by_name[name] for name in sorted(by_name))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def authorized_tools(plan: ExecutionPlan) -> tuple[AuthorizedTool, ...]:
|
|
73
|
+
return plan_tools(plan.ready, plan.capabilities)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def resolve_tool(plan: ExecutionPlan, name: str) -> AuthorizedTool:
|
|
77
|
+
"""Resolve a model-supplied name only through the frozen run policy."""
|
|
78
|
+
matches = {tool.name: tool for tool in authorized_tools(plan)}
|
|
79
|
+
try:
|
|
80
|
+
return matches[name]
|
|
81
|
+
except KeyError as error:
|
|
82
|
+
raise ToolPolicyError(f"Tool is not authorized for this run: {name}") from error
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Owner rules that narrow the arguments of an approved tool.
|
|
2
|
+
|
|
3
|
+
Rules are stored on a grant as `{tool: {property: rule}}`. Each rule applies to
|
|
4
|
+
one top-level property of the tool's `inputSchema`:
|
|
5
|
+
|
|
6
|
+
- fixed: the broker always sends this value. The model never sees the property.
|
|
7
|
+
- allowed: the value must be one of these. The model must send it.
|
|
8
|
+
- maximum: the value must not be larger. The model must send it.
|
|
9
|
+
- forbidden: the model may not send the property at all.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import copy
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from enum import StrEnum
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from jsonschema import ValidationError, validate
|
|
18
|
+
|
|
19
|
+
from secondshift.connections.errors import ExternalToolError
|
|
20
|
+
|
|
21
|
+
ToolRules = dict[str, dict[str, dict[str, Any]]]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RuleType(StrEnum):
|
|
25
|
+
FIXED = "fixed"
|
|
26
|
+
ALLOWED = "allowed"
|
|
27
|
+
MAXIMUM = "maximum"
|
|
28
|
+
FORBIDDEN = "forbidden"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
NUMERIC_TYPES = {"integer", "number"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ToolRuleError(ValueError):
|
|
35
|
+
"""A rule the owner tried to save does not fit the tool."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ToolRuleViolation(ExternalToolError):
|
|
39
|
+
"""A call broke an owner rule. It never reaches the provider."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def property_types(schema: Mapping[str, Any]) -> set[str]:
|
|
43
|
+
declared = schema.get("type")
|
|
44
|
+
if isinstance(declared, str):
|
|
45
|
+
return {declared}
|
|
46
|
+
if isinstance(declared, list):
|
|
47
|
+
return {str(item) for item in declared}
|
|
48
|
+
return set()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def fits(value: object, schema: Mapping[str, Any]) -> bool:
|
|
52
|
+
try:
|
|
53
|
+
validate(value, dict(schema))
|
|
54
|
+
except ValidationError:
|
|
55
|
+
return False
|
|
56
|
+
return True
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def value_problem(
|
|
60
|
+
rule: Mapping[str, Any], name: str, prop: Mapping[str, Any]
|
|
61
|
+
) -> str | None:
|
|
62
|
+
kind = rule.get("type")
|
|
63
|
+
if kind == RuleType.FIXED:
|
|
64
|
+
if "value" not in rule or not fits(rule["value"], prop):
|
|
65
|
+
return f"The fixed value for `{name}` does not fit its type."
|
|
66
|
+
return None
|
|
67
|
+
values = rule.get("values")
|
|
68
|
+
if not isinstance(values, list) or not values:
|
|
69
|
+
return f"List at least one allowed value for `{name}`."
|
|
70
|
+
if not all(fits(value, prop) for value in values):
|
|
71
|
+
return f"An allowed value for `{name}` does not fit its type."
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def maximum_problem(
|
|
76
|
+
rule: Mapping[str, Any], name: str, prop: Mapping[str, Any]
|
|
77
|
+
) -> str | None:
|
|
78
|
+
value = rule.get("value")
|
|
79
|
+
if not property_types(prop) & NUMERIC_TYPES:
|
|
80
|
+
return f"`{name}` is not a number, so it cannot have a maximum."
|
|
81
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
82
|
+
return f"The maximum for `{name}` must be a number."
|
|
83
|
+
if not fits(value, prop):
|
|
84
|
+
return f"The maximum for `{name}` is outside what the tool accepts."
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def rule_problem(
|
|
89
|
+
rule: Mapping[str, Any], name: str, schema: Mapping[str, Any]
|
|
90
|
+
) -> str | None:
|
|
91
|
+
"""Why a rule does not fit the tool's input schema. None when it fits."""
|
|
92
|
+
properties = schema.get("properties") or {}
|
|
93
|
+
if name not in properties:
|
|
94
|
+
return f"`{name}` is not an argument of this tool."
|
|
95
|
+
kind = rule.get("type")
|
|
96
|
+
if kind in (RuleType.FIXED, RuleType.ALLOWED):
|
|
97
|
+
return value_problem(rule, name, properties[name])
|
|
98
|
+
if kind == RuleType.MAXIMUM:
|
|
99
|
+
return maximum_problem(rule, name, properties[name])
|
|
100
|
+
if kind == RuleType.FORBIDDEN:
|
|
101
|
+
if name in (schema.get("required") or []):
|
|
102
|
+
return f"`{name}` is required by the tool, so it cannot be forbidden."
|
|
103
|
+
return None
|
|
104
|
+
return f"Unknown rule for `{name}`."
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def validate_rules(
|
|
108
|
+
rules: ToolRules, definitions: Mapping[str, Any], allowed_tools: list[str]
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Raise ToolRuleError when any rule cannot apply to the approved tools."""
|
|
111
|
+
for tool, properties in rules.items():
|
|
112
|
+
if tool not in allowed_tools:
|
|
113
|
+
raise ToolRuleError(f"`{tool}` is not an approved tool.")
|
|
114
|
+
definition = definitions.get(tool) or {}
|
|
115
|
+
schema = definition.get("inputSchema") or {}
|
|
116
|
+
for name, rule in properties.items():
|
|
117
|
+
if problem := rule_problem(rule, name, schema):
|
|
118
|
+
raise ToolRuleError(f"{tool}: {problem}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def broken_rules(rules: ToolRules, definitions: Mapping[str, Any]) -> list[str]:
|
|
122
|
+
"""Tools whose rules no longer fit, after the connection's schemas changed."""
|
|
123
|
+
broken = []
|
|
124
|
+
for tool, properties in sorted(rules.items()):
|
|
125
|
+
schema = (definitions.get(tool) or {}).get("inputSchema") or {}
|
|
126
|
+
if any(rule_problem(rule, name, schema) for name, rule in properties.items()):
|
|
127
|
+
broken.append(tool)
|
|
128
|
+
return broken
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def kept_rules(rules: ToolRules, tools: set[str] | list[str]) -> ToolRules:
|
|
132
|
+
"""Only the rules for tools that are still approved or requested."""
|
|
133
|
+
return {
|
|
134
|
+
tool: rules[tool]
|
|
135
|
+
for tool in sorted(rules)
|
|
136
|
+
if tool in set(tools) and rules[tool]
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def apply_rules(
|
|
141
|
+
tool: str, rules: Mapping[str, Mapping[str, Any]], arguments: Mapping[str, Any]
|
|
142
|
+
) -> dict[str, Any]:
|
|
143
|
+
"""Set fixed values, then reject anything that breaks a rule.
|
|
144
|
+
|
|
145
|
+
The error names the property and the rule, never the value the model sent.
|
|
146
|
+
"""
|
|
147
|
+
result = dict(arguments)
|
|
148
|
+
for name, rule in rules.items():
|
|
149
|
+
kind = rule["type"]
|
|
150
|
+
if kind == RuleType.FIXED:
|
|
151
|
+
result[name] = copy.deepcopy(rule["value"])
|
|
152
|
+
elif kind == RuleType.FORBIDDEN:
|
|
153
|
+
if name in result:
|
|
154
|
+
raise ToolRuleViolation(f"{tool}: `{name}` is not allowed.")
|
|
155
|
+
elif kind == RuleType.ALLOWED:
|
|
156
|
+
if name not in result or result[name] not in rule["values"]:
|
|
157
|
+
raise ToolRuleViolation(
|
|
158
|
+
f"{tool}: `{name}` must be one of the approved values."
|
|
159
|
+
)
|
|
160
|
+
elif kind == RuleType.MAXIMUM:
|
|
161
|
+
value = result.get(name)
|
|
162
|
+
if (
|
|
163
|
+
isinstance(value, bool)
|
|
164
|
+
or not isinstance(value, int | float)
|
|
165
|
+
or value > rule["value"]
|
|
166
|
+
):
|
|
167
|
+
raise ToolRuleViolation(
|
|
168
|
+
f"{tool}: `{name}` must be at most {rule['value']}."
|
|
169
|
+
)
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def narrow_schema(
|
|
174
|
+
schema: Mapping[str, Any], rules: Mapping[str, Mapping[str, Any]]
|
|
175
|
+
) -> dict[str, Any]:
|
|
176
|
+
"""The input schema the model sees, with the owner's rules applied."""
|
|
177
|
+
if not rules:
|
|
178
|
+
return dict(schema)
|
|
179
|
+
narrowed = copy.deepcopy(dict(schema))
|
|
180
|
+
properties = narrowed.setdefault("properties", {})
|
|
181
|
+
required = list(narrowed.get("required") or [])
|
|
182
|
+
for name, rule in rules.items():
|
|
183
|
+
kind = rule["type"]
|
|
184
|
+
if kind in (RuleType.FIXED, RuleType.FORBIDDEN):
|
|
185
|
+
properties.pop(name, None)
|
|
186
|
+
required = [item for item in required if item != name]
|
|
187
|
+
continue
|
|
188
|
+
prop = properties.get(name, {})
|
|
189
|
+
if kind == RuleType.ALLOWED:
|
|
190
|
+
properties[name] = {**prop, "enum": list(rule["values"])}
|
|
191
|
+
elif kind == RuleType.MAXIMUM:
|
|
192
|
+
ceiling = min(rule["value"], prop.get("maximum", rule["value"]))
|
|
193
|
+
properties[name] = {**prop, "maximum": ceiling}
|
|
194
|
+
if name not in required:
|
|
195
|
+
required.append(name)
|
|
196
|
+
if required:
|
|
197
|
+
narrowed["required"] = required
|
|
198
|
+
else:
|
|
199
|
+
narrowed.pop("required", None)
|
|
200
|
+
return narrowed
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""The /mcp endpoint: token auth, a rate limit, protocol dispatch and audit."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from django.conf import settings
|
|
6
|
+
from django.http import HttpRequest, HttpResponse
|
|
7
|
+
from ninja.throttling import UserRateThrottle
|
|
8
|
+
from thin_mcp import (
|
|
9
|
+
JsonRpcErrorCode,
|
|
10
|
+
McpHttpResult,
|
|
11
|
+
McpMethodName,
|
|
12
|
+
McpRequest,
|
|
13
|
+
McpRequestHeaders,
|
|
14
|
+
)
|
|
15
|
+
from thin_mcp.django_ninja import McpAPI, render_mcp_result
|
|
16
|
+
|
|
17
|
+
from secondshift.mcp_server.audit import record_call
|
|
18
|
+
from secondshift.mcp_server.core import server
|
|
19
|
+
from secondshift.mcp_server.models import ToolCall
|
|
20
|
+
from secondshift.mcp_server.tools_registry import McpCall
|
|
21
|
+
from secondshift.workspaces.models import ApiToken
|
|
22
|
+
from secondshift.workspaces.tokens import ApiTokenAuth
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
# Counts requests per person, across all their tokens. It runs after token
|
|
27
|
+
# auth, which signs the request in as the token's creator, and keeps its
|
|
28
|
+
# counts in the shared cache.
|
|
29
|
+
throttle = UserRateThrottle(f"{settings.MCP_RATE_LIMIT}/m")
|
|
30
|
+
|
|
31
|
+
api = McpAPI(
|
|
32
|
+
title="SecondShift MCP",
|
|
33
|
+
urls_namespace="mcp",
|
|
34
|
+
auth=ApiTokenAuth(),
|
|
35
|
+
throttle=throttle,
|
|
36
|
+
docs_url=None,
|
|
37
|
+
openapi_url=None,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def tool_name(payload: McpRequest) -> str:
|
|
42
|
+
name = payload.params.get("name")
|
|
43
|
+
return name if isinstance(name, str) else ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def dispatch(request: HttpRequest, payload: McpRequest) -> McpHttpResult:
|
|
47
|
+
try:
|
|
48
|
+
return server.dispatch_request(
|
|
49
|
+
user=request.user, request=payload, resources=(McpCall(request),)
|
|
50
|
+
)
|
|
51
|
+
except Exception:
|
|
52
|
+
logger.exception("Unhandled MCP request error")
|
|
53
|
+
if payload.is_notification:
|
|
54
|
+
return McpHttpResult.accepted()
|
|
55
|
+
return McpHttpResult.error(
|
|
56
|
+
payload.id, JsonRpcErrorCode.INTERNAL_ERROR, "Internal error.", status=500
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def outcome(result: McpHttpResult) -> ToolCall.Outcome:
|
|
61
|
+
body = result.body.model_dump(by_alias=True) if result.body else {}
|
|
62
|
+
tool_result = body.get("result") or {}
|
|
63
|
+
if "error" in body or tool_result.get("isError"):
|
|
64
|
+
return ToolCall.Outcome.ERROR
|
|
65
|
+
return ToolCall.Outcome.OK
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@api.post("")
|
|
69
|
+
def mcp_endpoint(request: HttpRequest, payload: McpRequest) -> HttpResponse:
|
|
70
|
+
token: ApiToken = request.auth
|
|
71
|
+
headers = McpRequestHeaders.from_mapping(request.headers)
|
|
72
|
+
invalid = server.validate_http_request(payload, headers)
|
|
73
|
+
if invalid is not None:
|
|
74
|
+
return render_mcp_result(invalid)
|
|
75
|
+
if payload.method != McpMethodName.TOOLS_CALL:
|
|
76
|
+
return render_mcp_result(dispatch(request, payload))
|
|
77
|
+
result = dispatch(request, payload)
|
|
78
|
+
record_call(token, tool_name(payload), outcome(result))
|
|
79
|
+
return render_mcp_result(result)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Audit records of MCP tool calls, kept as long as runs."""
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
|
|
5
|
+
from django.conf import settings
|
|
6
|
+
from django.utils import timezone
|
|
7
|
+
|
|
8
|
+
from secondshift.mcp_server.dao import create_tool_call, delete_tool_calls_before
|
|
9
|
+
from secondshift.mcp_server.models import TOOL_NAME_MAX, ToolCall
|
|
10
|
+
from secondshift.workspaces.models import ApiToken
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def record_call(token: ApiToken, tool: str, outcome: ToolCall.Outcome) -> None:
|
|
14
|
+
create_tool_call(token, tool[:TOOL_NAME_MAX], outcome)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def prune_tool_calls(days: int | None = None) -> int:
|
|
18
|
+
"""Delete tool calls older than the run retention window."""
|
|
19
|
+
days = settings.RUN_RETENTION_DAYS if days is None else days
|
|
20
|
+
if days <= 0:
|
|
21
|
+
return 0
|
|
22
|
+
return delete_tool_calls_before(timezone.now() - timedelta(days=days))
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from importlib.metadata import version
|
|
2
|
+
|
|
3
|
+
from thin_mcp import McpServer
|
|
4
|
+
|
|
5
|
+
from secondshift.mcp_server.tools import registry
|
|
6
|
+
|
|
7
|
+
server = McpServer(
|
|
8
|
+
name="secondshift",
|
|
9
|
+
version=version("secondshift"),
|
|
10
|
+
protocol_versions=("2025-03-26", "2025-06-18", "2025-11-25", "2026-07-28"),
|
|
11
|
+
tools=registry,
|
|
12
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Queries and writes on MCP tool call records."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from secondshift.mcp_server.models import ToolCall
|
|
6
|
+
from secondshift.workspaces.models import ApiToken
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_tool_call(token: ApiToken, tool: str, outcome: ToolCall.Outcome) -> ToolCall:
|
|
10
|
+
"""Record one tool call made with this token."""
|
|
11
|
+
return ToolCall.objects.create(
|
|
12
|
+
workspace_id=token.workspace_id,
|
|
13
|
+
token=token,
|
|
14
|
+
user_id=token.created_by_id,
|
|
15
|
+
tool=tool,
|
|
16
|
+
outcome=outcome,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def delete_tool_calls_before(cutoff: datetime) -> int:
|
|
21
|
+
"""Delete tool calls made before the cutoff. Returns how many."""
|
|
22
|
+
deleted, _ = ToolCall.objects.filter(created_at__lt=cutoff).delete()
|
|
23
|
+
return deleted
|