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,150 @@
|
|
|
1
|
+
"""One-time links that let someone join a workspace with a role.
|
|
2
|
+
|
|
3
|
+
An owner creates an invite and shares its link. The token appears only in
|
|
4
|
+
that link. The database keeps a hash of it. A link works once, until it
|
|
5
|
+
expires or an owner revokes it. Joining always needs the person to confirm
|
|
6
|
+
while signed in, so opening a link alone never adds anyone to a workspace.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import secrets
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import timedelta
|
|
13
|
+
|
|
14
|
+
from django.contrib.auth.models import AbstractBaseUser
|
|
15
|
+
from django.db import transaction
|
|
16
|
+
from django.http import HttpRequest
|
|
17
|
+
from django.urls import reverse
|
|
18
|
+
from django.utils import timezone
|
|
19
|
+
|
|
20
|
+
from secondshift.workspaces.dao import (
|
|
21
|
+
create_invite_record,
|
|
22
|
+
create_membership,
|
|
23
|
+
find_membership,
|
|
24
|
+
find_usable_invite,
|
|
25
|
+
get_or_create_workspace,
|
|
26
|
+
lock_usable_invite,
|
|
27
|
+
workspace_has_owner,
|
|
28
|
+
)
|
|
29
|
+
from secondshift.workspaces.models import (
|
|
30
|
+
Workspace,
|
|
31
|
+
WorkspaceInvite,
|
|
32
|
+
WorkspaceMembership,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
INVITE_LIFETIME = timedelta(days=7)
|
|
36
|
+
# An opened invite link waits in the session under this key until the person
|
|
37
|
+
# confirms or dismisses it.
|
|
38
|
+
PENDING_INVITE_SESSION_KEY = "secondshift.invite"
|
|
39
|
+
INVITE_UNUSABLE_MESSAGE = (
|
|
40
|
+
"This invitation has expired or was already used. "
|
|
41
|
+
"Ask a workspace owner for a new one."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class InviteError(Exception):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class WorkspaceTaken(Exception):
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class NewInvite:
|
|
55
|
+
invite: WorkspaceInvite
|
|
56
|
+
# Only here, right after creating it. It is never stored.
|
|
57
|
+
token: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def token_hash(token: str) -> str:
|
|
61
|
+
return hashlib.sha256(token.encode()).hexdigest()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def invite_path(token: str) -> str:
|
|
65
|
+
return reverse("invite", args=[token])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def create_invite(
|
|
69
|
+
workspace: Workspace,
|
|
70
|
+
role: str,
|
|
71
|
+
created_by: AbstractBaseUser | None,
|
|
72
|
+
note: str = "",
|
|
73
|
+
) -> NewInvite:
|
|
74
|
+
token = secrets.token_urlsafe(32)
|
|
75
|
+
invite = create_invite_record(
|
|
76
|
+
workspace,
|
|
77
|
+
WorkspaceMembership.Role(role),
|
|
78
|
+
note,
|
|
79
|
+
token_hash(token),
|
|
80
|
+
created_by,
|
|
81
|
+
timezone.now() + INVITE_LIFETIME,
|
|
82
|
+
)
|
|
83
|
+
return NewInvite(invite, token)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def find_invite(token: str) -> WorkspaceInvite | None:
|
|
87
|
+
"""The invite for a token while it can still be accepted."""
|
|
88
|
+
return find_usable_invite(token_hash(token))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@transaction.atomic
|
|
92
|
+
def accept_invite(token: str, user: AbstractBaseUser) -> WorkspaceMembership:
|
|
93
|
+
"""Add the person to the invite's workspace and use the invite up.
|
|
94
|
+
|
|
95
|
+
Someone who already belongs keeps their role, and the link stays unused.
|
|
96
|
+
"""
|
|
97
|
+
invite = lock_usable_invite(token_hash(token))
|
|
98
|
+
if invite is None:
|
|
99
|
+
raise InviteError(INVITE_UNUSABLE_MESSAGE)
|
|
100
|
+
existing = find_membership(invite.workspace_id, user.pk)
|
|
101
|
+
if existing:
|
|
102
|
+
return existing
|
|
103
|
+
membership = create_membership(invite.workspace_id, user, invite.role)
|
|
104
|
+
invite.accepted_by = user
|
|
105
|
+
invite.accepted_at = timezone.now()
|
|
106
|
+
invite.save(update_fields=["accepted_by", "accepted_at"])
|
|
107
|
+
return membership
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def revoke_invite(invite: WorkspaceInvite) -> None:
|
|
111
|
+
invite.revoked_at = timezone.now()
|
|
112
|
+
invite.save(update_fields=["revoked_at"])
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def remember_invite(request: HttpRequest, token: str) -> None:
|
|
116
|
+
request.session[PENDING_INVITE_SESSION_KEY] = token
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def pending_invite(request: HttpRequest) -> WorkspaceInvite | None:
|
|
120
|
+
"""The invite this browser opened, while it can still be accepted."""
|
|
121
|
+
token = request.session.get(PENDING_INVITE_SESSION_KEY)
|
|
122
|
+
return find_invite(token) if token else None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def forget_invite(request: HttpRequest) -> None:
|
|
126
|
+
request.session.pop(PENDING_INVITE_SESSION_KEY, None)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def join_pending_invite(request: HttpRequest) -> WorkspaceMembership:
|
|
130
|
+
"""Accept the invite waiting in the session, for the signed-in person."""
|
|
131
|
+
token = request.session.get(PENDING_INVITE_SESSION_KEY)
|
|
132
|
+
forget_invite(request)
|
|
133
|
+
if not token:
|
|
134
|
+
raise InviteError(INVITE_UNUSABLE_MESSAGE)
|
|
135
|
+
return accept_invite(token, request.user)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@transaction.atomic
|
|
139
|
+
def invite_first_owner(name: str, slug: str) -> NewInvite:
|
|
140
|
+
"""An owner invite for a new workspace, or one that has no owner yet.
|
|
141
|
+
|
|
142
|
+
This is how a server gets its first owner without any account existing.
|
|
143
|
+
"""
|
|
144
|
+
workspace = get_or_create_workspace(slug, name)
|
|
145
|
+
if workspace_has_owner(workspace.pk):
|
|
146
|
+
raise WorkspaceTaken(
|
|
147
|
+
f"{workspace.name} already has an owner. Owners invite people "
|
|
148
|
+
"from the Settings page."
|
|
149
|
+
)
|
|
150
|
+
return create_invite(workspace, WorkspaceMembership.Role.OWNER, None)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Run limits: how far one run may go.
|
|
2
|
+
|
|
3
|
+
Each limit has a server default and a server maximum, both set through
|
|
4
|
+
environment variables. A workspace owner can choose a value for the workspace
|
|
5
|
+
and for each job, up to the maximum. A job's value wins over the workspace's,
|
|
6
|
+
and the workspace's wins over the default. Repository configuration cannot set
|
|
7
|
+
limits.
|
|
8
|
+
|
|
9
|
+
The values that apply are frozen into the run's execution plan when the run is
|
|
10
|
+
created. Changing a limit never affects a run that is already queued or running.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable, Mapping
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from enum import StrEnum
|
|
16
|
+
|
|
17
|
+
from django.conf import settings
|
|
18
|
+
|
|
19
|
+
from secondshift.workspaces.models import RunLimitOverrides, Workspace
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RunLimit(StrEnum):
|
|
23
|
+
MAX_TURNS = "max_turns"
|
|
24
|
+
MAX_TOOL_CALLS = "max_tool_calls"
|
|
25
|
+
TOKEN_BUDGET = "token_budget"
|
|
26
|
+
TIMEOUT_SECONDS = "timeout_seconds"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LimitUnit(StrEnum):
|
|
30
|
+
COUNT = "count"
|
|
31
|
+
TOKENS = "tokens"
|
|
32
|
+
SECONDS = "seconds"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class LimitSpec:
|
|
37
|
+
label: str
|
|
38
|
+
help: str
|
|
39
|
+
unit: LimitUnit
|
|
40
|
+
# The Django setting for the default. Its maximum adds "_CEILING".
|
|
41
|
+
setting: str
|
|
42
|
+
minimum: int
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def env(self) -> str:
|
|
46
|
+
return f"SECONDSHIFT_{self.setting}"
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def ceiling_env(self) -> str:
|
|
50
|
+
return f"{self.env}_CEILING"
|
|
51
|
+
|
|
52
|
+
def default(self) -> int:
|
|
53
|
+
return int(getattr(settings, self.setting))
|
|
54
|
+
|
|
55
|
+
def ceiling(self) -> int:
|
|
56
|
+
return int(getattr(settings, f"{self.setting}_CEILING"))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
LIMIT_SPECS = {
|
|
60
|
+
RunLimit.MAX_TURNS: LimitSpec(
|
|
61
|
+
"Model calls",
|
|
62
|
+
"Shown on runs as turns, e.g. 5/24. The last one writes the report.",
|
|
63
|
+
LimitUnit.COUNT,
|
|
64
|
+
"AGENT_MAX_TURNS",
|
|
65
|
+
minimum=1,
|
|
66
|
+
),
|
|
67
|
+
RunLimit.MAX_TOOL_CALLS: LimitSpec(
|
|
68
|
+
"Tool calls",
|
|
69
|
+
"Reading files, searching, running commands and calling connected services.",
|
|
70
|
+
LimitUnit.COUNT,
|
|
71
|
+
"AGENT_MAX_TOOL_CALLS",
|
|
72
|
+
minimum=0,
|
|
73
|
+
),
|
|
74
|
+
RunLimit.TOKEN_BUDGET: LimitSpec(
|
|
75
|
+
"Tokens",
|
|
76
|
+
"Input and output tokens. Cached input counts as a tenth. Model calls past this are refused.",
|
|
77
|
+
LimitUnit.TOKENS,
|
|
78
|
+
"MODEL_RUN_TOKEN_BUDGET",
|
|
79
|
+
minimum=1_000,
|
|
80
|
+
),
|
|
81
|
+
RunLimit.TIMEOUT_SECONDS: LimitSpec(
|
|
82
|
+
"Time",
|
|
83
|
+
"Counted from when the run starts. Waiting to start does not count.",
|
|
84
|
+
LimitUnit.SECONDS,
|
|
85
|
+
"RUN_TIMEOUT_SECONDS",
|
|
86
|
+
minimum=60,
|
|
87
|
+
),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class LimitError(ValueError):
|
|
92
|
+
def __init__(self, message: str, ceiling_env: str = "") -> None:
|
|
93
|
+
super().__init__(message)
|
|
94
|
+
# The variable that raises the maximum, for people who run the server.
|
|
95
|
+
self.ceiling_env = ceiling_env
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class LimitView:
|
|
100
|
+
"""One limit as an owner sees it at one level (workspace or job)."""
|
|
101
|
+
|
|
102
|
+
key: str
|
|
103
|
+
label: str
|
|
104
|
+
help: str
|
|
105
|
+
unit: str
|
|
106
|
+
# What a run would get now.
|
|
107
|
+
value: int
|
|
108
|
+
# What this level chose, or None to use `inherited`.
|
|
109
|
+
override: int | None
|
|
110
|
+
# What applies when this level chooses nothing.
|
|
111
|
+
inherited: int
|
|
112
|
+
minimum: int
|
|
113
|
+
maximum: int
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def server_default(limit: RunLimit) -> int:
|
|
117
|
+
# Settings refuse a maximum below its default, so no cap is needed.
|
|
118
|
+
return LIMIT_SPECS[limit].default()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def capped(limit: RunLimit, value: int) -> int:
|
|
122
|
+
# A maximum lowered after an owner chose a value still applies.
|
|
123
|
+
return min(value, LIMIT_SPECS[limit].ceiling())
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def workspace_value(workspace: Workspace, limit: RunLimit) -> int:
|
|
127
|
+
chosen = getattr(workspace, limit)
|
|
128
|
+
return capped(limit, chosen) if chosen is not None else server_default(limit)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def workspace_limits(workspace: Workspace) -> dict[str, int]:
|
|
132
|
+
"""The limits a run gets from the workspace alone, for its execution plan."""
|
|
133
|
+
return {str(limit): workspace_value(workspace, limit) for limit in RunLimit}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def limit_views(
|
|
137
|
+
overrides: RunLimitOverrides, inherited: Callable[[RunLimit], int]
|
|
138
|
+
) -> list[LimitView]:
|
|
139
|
+
"""Each limit at one level: what it chose, and what applies without a choice."""
|
|
140
|
+
views: list[LimitView] = []
|
|
141
|
+
for limit, spec in LIMIT_SPECS.items():
|
|
142
|
+
override = getattr(overrides, limit)
|
|
143
|
+
fallback = inherited(limit)
|
|
144
|
+
views.append(
|
|
145
|
+
LimitView(
|
|
146
|
+
key=str(limit),
|
|
147
|
+
label=spec.label,
|
|
148
|
+
help=spec.help,
|
|
149
|
+
unit=str(spec.unit),
|
|
150
|
+
value=capped(limit, override) if override is not None else fallback,
|
|
151
|
+
override=override,
|
|
152
|
+
inherited=fallback,
|
|
153
|
+
minimum=spec.minimum,
|
|
154
|
+
maximum=spec.ceiling(),
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
return views
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def workspace_limit_views(workspace: Workspace) -> list[LimitView]:
|
|
161
|
+
return limit_views(workspace, server_default)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def validate_overrides(values: Mapping[str, int | None]) -> dict[str, int | None]:
|
|
165
|
+
"""Check owner input against the server bounds. Unknown keys are refused."""
|
|
166
|
+
unknown = set(values) - {str(limit) for limit in RunLimit}
|
|
167
|
+
if unknown:
|
|
168
|
+
raise LimitError(f"Unknown limit: {', '.join(sorted(unknown))}.")
|
|
169
|
+
checked: dict[str, int | None] = {}
|
|
170
|
+
for key, value in values.items():
|
|
171
|
+
spec = LIMIT_SPECS[RunLimit(key)]
|
|
172
|
+
if value is not None and not spec.minimum <= value <= spec.ceiling():
|
|
173
|
+
raise LimitError(
|
|
174
|
+
f"{spec.label} must be between {spec.minimum:,} and "
|
|
175
|
+
f"{spec.ceiling():,}.",
|
|
176
|
+
ceiling_env=spec.ceiling_env,
|
|
177
|
+
)
|
|
178
|
+
checked[key] = value
|
|
179
|
+
return checked
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def save_overrides(target: RunLimitOverrides, values: Mapping[str, int | None]) -> None:
|
|
183
|
+
checked = validate_overrides(values)
|
|
184
|
+
for key, value in checked.items():
|
|
185
|
+
setattr(target, key, value)
|
|
186
|
+
target.save(update_fields=[*checked, "updated_at"])
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def raise_hint(limit: RunLimit, value: int) -> str:
|
|
190
|
+
"""How to allow more than `value`, for messages about a stopped run.
|
|
191
|
+
|
|
192
|
+
The message is stored with the run and shown to every member, so it does
|
|
193
|
+
not name the server variable. Only staff can set it.
|
|
194
|
+
"""
|
|
195
|
+
if value < LIMIT_SPECS[limit].ceiling():
|
|
196
|
+
return "Raise it in the job's limits."
|
|
197
|
+
return "That is the server maximum. Whoever runs this server can raise it."
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import djclick as click
|
|
2
|
+
from django.conf import settings
|
|
3
|
+
from django.utils.text import slugify
|
|
4
|
+
|
|
5
|
+
from secondshift.workspaces.invites import (
|
|
6
|
+
INVITE_LIFETIME,
|
|
7
|
+
WorkspaceTaken,
|
|
8
|
+
invite_first_owner,
|
|
9
|
+
invite_path,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.command()
|
|
14
|
+
@click.argument("name")
|
|
15
|
+
@click.option("--slug", help="Short name used in the database. From NAME by default.")
|
|
16
|
+
@click.option(
|
|
17
|
+
"--url",
|
|
18
|
+
default=settings.FRONTEND_URL,
|
|
19
|
+
show_default=True,
|
|
20
|
+
help="Where people open SecondShift.",
|
|
21
|
+
)
|
|
22
|
+
def command(name: str, slug: str | None, url: str) -> None:
|
|
23
|
+
"""Create a workspace and print a link that makes its first owner.
|
|
24
|
+
|
|
25
|
+
Run it again for a workspace whose link expired before anyone used it.
|
|
26
|
+
"""
|
|
27
|
+
slug = slug or slugify(name)
|
|
28
|
+
if not slug:
|
|
29
|
+
raise click.ClickException("Choose a --slug with letters or numbers.")
|
|
30
|
+
try:
|
|
31
|
+
new = invite_first_owner(name, slug)
|
|
32
|
+
except WorkspaceTaken as error:
|
|
33
|
+
raise click.ClickException(str(error)) from error
|
|
34
|
+
click.echo(f"Workspace {new.invite.workspace.name} ({slug}) is ready.")
|
|
35
|
+
click.echo(
|
|
36
|
+
f"Open this link to sign in or sign up as its owner. "
|
|
37
|
+
f"It works once, within {INVITE_LIFETIME.days} days:"
|
|
38
|
+
)
|
|
39
|
+
click.echo(f" {url.rstrip('/')}{invite_path(new.token)}")
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Generated by Django 6.0.9 on 2026-10-26 14:22
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
from django.db import migrations, models
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Migration(migrations.Migration):
|
|
9
|
+
|
|
10
|
+
initial = True
|
|
11
|
+
|
|
12
|
+
dependencies = [
|
|
13
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
operations = [
|
|
17
|
+
migrations.CreateModel(
|
|
18
|
+
name='Workspace',
|
|
19
|
+
fields=[
|
|
20
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
21
|
+
('max_turns', models.PositiveIntegerField(blank=True, null=True)),
|
|
22
|
+
('max_tool_calls', models.PositiveIntegerField(blank=True, null=True)),
|
|
23
|
+
('token_budget', models.PositiveBigIntegerField(blank=True, null=True)),
|
|
24
|
+
('timeout_seconds', models.PositiveIntegerField(blank=True, null=True)),
|
|
25
|
+
('name', models.CharField(max_length=160)),
|
|
26
|
+
('slug', models.SlugField(unique=True)),
|
|
27
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
28
|
+
('updated_at', models.DateTimeField(auto_now=True)),
|
|
29
|
+
],
|
|
30
|
+
options={
|
|
31
|
+
'ordering': ['name'],
|
|
32
|
+
'abstract': False,
|
|
33
|
+
},
|
|
34
|
+
),
|
|
35
|
+
migrations.CreateModel(
|
|
36
|
+
name='WorkspaceInvite',
|
|
37
|
+
fields=[
|
|
38
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
39
|
+
('role', models.CharField(choices=[('owner', 'Owner'), ('admin', 'Admin'), ('member', 'Member')], default='member', max_length=16)),
|
|
40
|
+
('note', models.CharField(blank=True, max_length=160)),
|
|
41
|
+
('token_hash', models.CharField(max_length=64, unique=True)),
|
|
42
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
43
|
+
('expires_at', models.DateTimeField()),
|
|
44
|
+
('accepted_at', models.DateTimeField(blank=True, null=True)),
|
|
45
|
+
('revoked_at', models.DateTimeField(blank=True, null=True)),
|
|
46
|
+
('accepted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
|
47
|
+
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
|
48
|
+
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites', to='workspaces.workspace')),
|
|
49
|
+
],
|
|
50
|
+
options={
|
|
51
|
+
'ordering': ['-created_at', '-pk'],
|
|
52
|
+
},
|
|
53
|
+
),
|
|
54
|
+
migrations.CreateModel(
|
|
55
|
+
name='WorkspaceMembership',
|
|
56
|
+
fields=[
|
|
57
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
58
|
+
('role', models.CharField(choices=[('owner', 'Owner'), ('admin', 'Admin'), ('member', 'Member')], default='member', max_length=16)),
|
|
59
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
60
|
+
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_memberships', to=settings.AUTH_USER_MODEL)),
|
|
61
|
+
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='workspaces.workspace')),
|
|
62
|
+
],
|
|
63
|
+
options={
|
|
64
|
+
'constraints': [models.UniqueConstraint(fields=('workspace', 'user'), name='unique_workspace_membership')],
|
|
65
|
+
},
|
|
66
|
+
),
|
|
67
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Generated by Django 6.0.9 on 2026-10-29 07:44
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
from django.db import migrations, models
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Migration(migrations.Migration):
|
|
9
|
+
|
|
10
|
+
dependencies = [
|
|
11
|
+
('workspaces', '0001_initial'),
|
|
12
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
operations = [
|
|
16
|
+
migrations.CreateModel(
|
|
17
|
+
name='ApiToken',
|
|
18
|
+
fields=[
|
|
19
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
20
|
+
('name', models.CharField(max_length=160)),
|
|
21
|
+
('scope', models.CharField(choices=[('read', 'Read'), ('run', 'Read and run')], default='read', max_length=16)),
|
|
22
|
+
('token_hash', models.CharField(max_length=64, unique=True)),
|
|
23
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
24
|
+
('expires_at', models.DateTimeField()),
|
|
25
|
+
('last_used_at', models.DateTimeField(blank=True, null=True)),
|
|
26
|
+
('revoked_at', models.DateTimeField(blank=True, null=True)),
|
|
27
|
+
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to=settings.AUTH_USER_MODEL)),
|
|
28
|
+
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to='workspaces.workspace')),
|
|
29
|
+
],
|
|
30
|
+
options={
|
|
31
|
+
'ordering': ['-created_at', '-pk'],
|
|
32
|
+
},
|
|
33
|
+
),
|
|
34
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Generated by Django 6.0.9 on 2026-11-03 14:14
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
|
|
9
|
+
dependencies = [
|
|
10
|
+
('connections', '0005_agentconnection'),
|
|
11
|
+
('workspaces', '0002_api_token'),
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
operations = [
|
|
15
|
+
migrations.AddField(
|
|
16
|
+
model_name='workspace',
|
|
17
|
+
name='default_agent_connection',
|
|
18
|
+
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='default_workspaces', to='connections.agentconnection'),
|
|
19
|
+
),
|
|
20
|
+
migrations.AddField(
|
|
21
|
+
model_name='workspace',
|
|
22
|
+
name='default_model_connection',
|
|
23
|
+
field=models.ForeignKey(blank=True, limit_choices_to={'kind': 'model'}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_workspaces', to='connections.connection'),
|
|
24
|
+
),
|
|
25
|
+
migrations.AddConstraint(
|
|
26
|
+
model_name='workspace',
|
|
27
|
+
constraint=models.CheckConstraint(condition=models.Q(('default_model_connection__isnull', True), ('default_agent_connection__isnull', True), _connector='OR'), name='workspace_one_execution_default'),
|
|
28
|
+
),
|
|
29
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Generated by Django 6.0.9 on 2026-11-03 14:14
|
|
2
|
+
|
|
3
|
+
from django.apps.registry import Apps
|
|
4
|
+
from django.db import migrations
|
|
5
|
+
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def copy_default_model_key(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
|
|
9
|
+
"""Released installs marked their default model key on the key itself."""
|
|
10
|
+
Connection = apps.get_model("connections", "Connection")
|
|
11
|
+
Workspace = apps.get_model("workspaces", "Workspace")
|
|
12
|
+
for connection in Connection.objects.filter(is_default=True):
|
|
13
|
+
Workspace.objects.filter(pk=connection.workspace_id).update(
|
|
14
|
+
default_model_connection=connection
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Migration(migrations.Migration):
|
|
19
|
+
|
|
20
|
+
dependencies = [
|
|
21
|
+
('workspaces', '0003_execution_default'),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
operations = [
|
|
25
|
+
migrations.RunPython(copy_default_model_key, migrations.RunPython.noop),
|
|
26
|
+
]
|
|
File without changes
|