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,130 @@
|
|
|
1
|
+
"""Source snapshots the control plane uploads for a run to unpack.
|
|
2
|
+
|
|
3
|
+
The runner keeps no repositories. Each snapshot is named by its SHA-256, kept
|
|
4
|
+
only until its session starts, and swept after an hour if no session uses it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import tarfile
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import IO
|
|
15
|
+
|
|
16
|
+
from django.conf import settings
|
|
17
|
+
|
|
18
|
+
from secondshift.config.constants import (
|
|
19
|
+
MAX_SOURCE_FILES,
|
|
20
|
+
MAX_SOURCE_SNAPSHOT_BYTES,
|
|
21
|
+
MAX_SOURCE_UNPACKED_BYTES,
|
|
22
|
+
)
|
|
23
|
+
from secondshift.runner.protocol import RunnerProtocolError
|
|
24
|
+
|
|
25
|
+
SOURCE_SHA256 = re.compile(r"[0-9a-f]{64}")
|
|
26
|
+
SOURCE_MAX_AGE_SECONDS = 60 * 60
|
|
27
|
+
CHUNK_BYTES = 1024 * 1024
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def sources_root() -> Path:
|
|
31
|
+
return (settings.RUNNER_WORK_ROOT / "sources").resolve()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def source_path(sha256: str) -> Path:
|
|
35
|
+
if not SOURCE_SHA256.fullmatch(sha256):
|
|
36
|
+
raise RunnerProtocolError("A source is named by its SHA-256.")
|
|
37
|
+
return sources_root() / f"{sha256}.tar.gz"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def has_source(sha256: str) -> bool:
|
|
41
|
+
"""Whether the snapshot is here. Asking keeps it for another hour."""
|
|
42
|
+
path = source_path(sha256)
|
|
43
|
+
try:
|
|
44
|
+
os.utime(path)
|
|
45
|
+
except FileNotFoundError:
|
|
46
|
+
return False
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def receive_source(sha256: str, stream: IO[bytes], length: int | None) -> None:
|
|
51
|
+
"""Store an uploaded snapshot if its bytes match its name."""
|
|
52
|
+
target = source_path(sha256)
|
|
53
|
+
if length is None:
|
|
54
|
+
raise RunnerProtocolError("The source upload needs a Content-Length.")
|
|
55
|
+
if length > MAX_SOURCE_SNAPSHOT_BYTES:
|
|
56
|
+
raise RunnerProtocolError("The source is too large.")
|
|
57
|
+
root = sources_root()
|
|
58
|
+
root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
59
|
+
sweep_sources()
|
|
60
|
+
descriptor, name = tempfile.mkstemp(dir=root, prefix="upload-")
|
|
61
|
+
temporary = Path(name)
|
|
62
|
+
try:
|
|
63
|
+
digest = hashlib.sha256()
|
|
64
|
+
remaining = length
|
|
65
|
+
with os.fdopen(descriptor, "wb") as output:
|
|
66
|
+
while remaining:
|
|
67
|
+
chunk = stream.read(min(CHUNK_BYTES, remaining))
|
|
68
|
+
if not chunk:
|
|
69
|
+
raise RunnerProtocolError("The source upload ended early.")
|
|
70
|
+
remaining -= len(chunk)
|
|
71
|
+
digest.update(chunk)
|
|
72
|
+
output.write(chunk)
|
|
73
|
+
if digest.hexdigest() != sha256:
|
|
74
|
+
raise RunnerProtocolError("The source does not match its SHA-256.")
|
|
75
|
+
os.replace(temporary, target)
|
|
76
|
+
finally:
|
|
77
|
+
temporary.unlink(missing_ok=True)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def sweep_sources() -> None:
|
|
81
|
+
"""Remove snapshots and partial uploads no session started with."""
|
|
82
|
+
root = sources_root()
|
|
83
|
+
if not root.is_dir():
|
|
84
|
+
return
|
|
85
|
+
cutoff = time.time() - SOURCE_MAX_AGE_SECONDS
|
|
86
|
+
for path in root.iterdir():
|
|
87
|
+
try:
|
|
88
|
+
if path.is_file() and path.stat().st_mtime < cutoff:
|
|
89
|
+
path.unlink()
|
|
90
|
+
except FileNotFoundError:
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def unpack_source(sha256: str, workspace: Path) -> None:
|
|
95
|
+
"""Unpack an uploaded snapshot into a session's empty workspace."""
|
|
96
|
+
path = source_path(sha256)
|
|
97
|
+
if not path.is_file():
|
|
98
|
+
raise RunnerProtocolError("The run's source files were not uploaded.")
|
|
99
|
+
if file_sha256(path) != sha256:
|
|
100
|
+
raise RunnerProtocolError("The run's source files do not match their SHA-256.")
|
|
101
|
+
try:
|
|
102
|
+
with tarfile.open(path, mode="r:gz") as archive:
|
|
103
|
+
check_members(archive)
|
|
104
|
+
archive.extractall(workspace, filter="data")
|
|
105
|
+
except (tarfile.TarError, OSError, EOFError) as error:
|
|
106
|
+
raise RunnerProtocolError(
|
|
107
|
+
"The runner could not unpack the run's source files."
|
|
108
|
+
) from error
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def check_members(archive: tarfile.TarFile) -> None:
|
|
112
|
+
# Stop at the first entry past a limit, so a compression bomb is never
|
|
113
|
+
# read to the end.
|
|
114
|
+
size = 0
|
|
115
|
+
for files, member in enumerate(archive, start=1):
|
|
116
|
+
size += member.size
|
|
117
|
+
if files > MAX_SOURCE_FILES or size > MAX_SOURCE_UNPACKED_BYTES:
|
|
118
|
+
raise RunnerProtocolError("The run's source files are too large.")
|
|
119
|
+
if not (member.isreg() or member.isdir() or member.issym()):
|
|
120
|
+
raise RunnerProtocolError(
|
|
121
|
+
"The run's source files may only hold files, folders and links."
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def file_sha256(path: Path) -> str:
|
|
126
|
+
digest = hashlib.sha256()
|
|
127
|
+
with path.open("rb") as stream:
|
|
128
|
+
while chunk := stream.read(CHUNK_BYTES):
|
|
129
|
+
digest.update(chunk)
|
|
130
|
+
return digest.hexdigest()
|
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from django.http import Http404, HttpRequest
|
|
2
|
+
|
|
3
|
+
from secondshift.runs.dao import find_workspace_run
|
|
4
|
+
from secondshift.runs.models import Run
|
|
5
|
+
from secondshift.workspaces.access import current_workspace
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def run_for_request(request: HttpRequest, run_id: int) -> Run:
|
|
9
|
+
workspace = current_workspace(request)
|
|
10
|
+
run = find_workspace_run(workspace.pk if workspace else None, run_id)
|
|
11
|
+
if run is None:
|
|
12
|
+
raise Http404
|
|
13
|
+
return run
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from django.contrib import admin
|
|
2
|
+
from django.http import FileResponse, Http404, HttpRequest
|
|
3
|
+
from django.urls import URLPattern, URLResolver, path, reverse
|
|
4
|
+
from django.utils.html import format_html
|
|
5
|
+
|
|
6
|
+
from secondshift.runs.artifacts import artifact_storage
|
|
7
|
+
from secondshift.runs.dao import get_run
|
|
8
|
+
from secondshift.runs.models import Run, RunEvent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RunEventInline(admin.TabularInline):
|
|
12
|
+
model = RunEvent
|
|
13
|
+
extra = 0
|
|
14
|
+
can_delete = False
|
|
15
|
+
readonly_fields = ("level", "message", "metadata", "created_at")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@admin.register(Run)
|
|
19
|
+
class RunAdmin(admin.ModelAdmin):
|
|
20
|
+
list_display = (
|
|
21
|
+
"display_name",
|
|
22
|
+
"status",
|
|
23
|
+
"scheduled_for",
|
|
24
|
+
"sandbox_runtime",
|
|
25
|
+
"finished_at",
|
|
26
|
+
)
|
|
27
|
+
list_filter = ("status", "sandbox_runtime")
|
|
28
|
+
search_fields = ("job__name", "job__repository__full_name", "commit_sha")
|
|
29
|
+
readonly_fields = (
|
|
30
|
+
"job",
|
|
31
|
+
"task",
|
|
32
|
+
"status",
|
|
33
|
+
"commit_sha",
|
|
34
|
+
"scheduled_for",
|
|
35
|
+
"started_at",
|
|
36
|
+
"finished_at",
|
|
37
|
+
"sandbox_runtime",
|
|
38
|
+
"exit_code",
|
|
39
|
+
"dbos_workflow_id",
|
|
40
|
+
"summary",
|
|
41
|
+
"input_snapshot",
|
|
42
|
+
"execution_plan",
|
|
43
|
+
"output_excerpt",
|
|
44
|
+
"full_output",
|
|
45
|
+
"output_size_bytes",
|
|
46
|
+
"output_truncated",
|
|
47
|
+
"output_sha256",
|
|
48
|
+
"created_at",
|
|
49
|
+
)
|
|
50
|
+
exclude = ("output_path",)
|
|
51
|
+
inlines = [RunEventInline]
|
|
52
|
+
|
|
53
|
+
def get_urls(self) -> list[URLPattern | URLResolver]:
|
|
54
|
+
return [
|
|
55
|
+
path(
|
|
56
|
+
"<int:run_id>/output/",
|
|
57
|
+
self.admin_site.admin_view(self.output_view),
|
|
58
|
+
name="runs_run_output",
|
|
59
|
+
),
|
|
60
|
+
*super().get_urls(),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
@admin.display(description="Full output")
|
|
64
|
+
def full_output(self, run: Run) -> str:
|
|
65
|
+
if not run.output_path:
|
|
66
|
+
return "-"
|
|
67
|
+
url = reverse("admin:runs_run_output", args=[run.pk])
|
|
68
|
+
return format_html('<a href="{}">Download output.log</a>', url)
|
|
69
|
+
|
|
70
|
+
def output_view(self, request: HttpRequest, run_id: int) -> FileResponse:
|
|
71
|
+
try:
|
|
72
|
+
run = get_run(run_id)
|
|
73
|
+
except Run.DoesNotExist:
|
|
74
|
+
raise Http404 from None
|
|
75
|
+
if not self.has_view_permission(request, run) or not run.output_path:
|
|
76
|
+
raise Http404
|
|
77
|
+
return FileResponse(
|
|
78
|
+
artifact_storage().open(run.output_path, "rb"),
|
|
79
|
+
as_attachment=True,
|
|
80
|
+
filename=f"run-{run.pk}-output.log",
|
|
81
|
+
content_type="text/plain; charset=utf-8",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@admin.register(RunEvent)
|
|
86
|
+
class RunEventAdmin(admin.ModelAdmin):
|
|
87
|
+
list_display = ("run", "level", "message", "created_at")
|
|
88
|
+
list_filter = ("level",)
|
|
89
|
+
readonly_fields = ("run", "level", "message", "metadata", "created_at")
|
secondshift/runs/api.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
from pathlib import PurePosixPath
|
|
2
|
+
|
|
3
|
+
from django.http import FileResponse, Http404, HttpRequest
|
|
4
|
+
from ninja import Query, Router, Status
|
|
5
|
+
from ninja.errors import HttpError
|
|
6
|
+
from ninja.security import django_auth
|
|
7
|
+
|
|
8
|
+
from secondshift.config.constants import RunStart
|
|
9
|
+
from secondshift.jobs.access import job_for_request
|
|
10
|
+
from secondshift.repositories.access import repository_for_request
|
|
11
|
+
from secondshift.runs.access import run_for_request
|
|
12
|
+
from secondshift.runs.artifacts import artifact_storage
|
|
13
|
+
from secondshift.runs.deliveries import DeliveryQueueUnavailable, retry_delivery
|
|
14
|
+
from secondshift.runs.models import RunDelivery
|
|
15
|
+
from secondshift.runs.queue import cancel_queued_task
|
|
16
|
+
from secondshift.runs.reporting.queries import RunFilters, run_page
|
|
17
|
+
from secondshift.runs.schemas import (
|
|
18
|
+
RunAgainSchema,
|
|
19
|
+
RunDeliverySchema,
|
|
20
|
+
RunDetailSchema,
|
|
21
|
+
RunFileName,
|
|
22
|
+
RunInProgressSchema,
|
|
23
|
+
RunListQuery,
|
|
24
|
+
RunPageSchema,
|
|
25
|
+
RunSchema,
|
|
26
|
+
TaskSubmissionSchema,
|
|
27
|
+
)
|
|
28
|
+
from secondshift.runs.serialization import (
|
|
29
|
+
run_file_path,
|
|
30
|
+
saved_file_path,
|
|
31
|
+
serialize_delivery,
|
|
32
|
+
serialize_run,
|
|
33
|
+
serialize_run_detail,
|
|
34
|
+
serialize_submission,
|
|
35
|
+
)
|
|
36
|
+
from secondshift.runs.services import RunInProgress, RunRefused, start_manual_run
|
|
37
|
+
from secondshift.runs.submissions import SkillSubmission, resubmission, submit_skill
|
|
38
|
+
from secondshift.workspaces.access import (
|
|
39
|
+
Action,
|
|
40
|
+
current_workspace,
|
|
41
|
+
require,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
router = Router(auth=django_auth, tags=["runs"])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@router.post("/runs/{run_id}/cancel", response=RunSchema)
|
|
48
|
+
def cancel_task(request: HttpRequest, run_id: int) -> RunSchema:
|
|
49
|
+
require(request, Action.RUN_JOBS)
|
|
50
|
+
run = run_for_request(request, run_id)
|
|
51
|
+
try:
|
|
52
|
+
return serialize_run(cancel_queued_task(run))
|
|
53
|
+
except RunRefused as error:
|
|
54
|
+
raise HttpError(409, str(error)) from error
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@router.post("/runs/{run_id}/again", response=TaskSubmissionSchema)
|
|
58
|
+
def run_task_again(
|
|
59
|
+
request: HttpRequest, run_id: int, payload: RunAgainSchema
|
|
60
|
+
) -> TaskSubmissionSchema:
|
|
61
|
+
"""Submit a task run's task again, as it was first submitted."""
|
|
62
|
+
require(request, Action.RUN_JOBS)
|
|
63
|
+
run = run_for_request(request, run_id)
|
|
64
|
+
try:
|
|
65
|
+
submission = resubmission(run, payload.idempotency_key)
|
|
66
|
+
result = submit_skill(run.repository, submission, request.user, RunStart.MANUAL)
|
|
67
|
+
except RunRefused as error:
|
|
68
|
+
raise HttpError(409, str(error)) from error
|
|
69
|
+
return serialize_submission(result.run, result.started)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@router.post(
|
|
73
|
+
"/runs/{run_id}/deliveries/{delivery_id}/retry", response=RunDeliverySchema
|
|
74
|
+
)
|
|
75
|
+
def retry_run_delivery(
|
|
76
|
+
request: HttpRequest, run_id: int, delivery_id: int
|
|
77
|
+
) -> RunDeliverySchema:
|
|
78
|
+
require(request, Action.RUN_JOBS)
|
|
79
|
+
run = run_for_request(request, run_id)
|
|
80
|
+
try:
|
|
81
|
+
return serialize_delivery(retry_delivery(run, delivery_id))
|
|
82
|
+
except RunDelivery.DoesNotExist as error:
|
|
83
|
+
raise Http404 from error
|
|
84
|
+
except RunRefused as error:
|
|
85
|
+
raise HttpError(409, str(error)) from error
|
|
86
|
+
except DeliveryQueueUnavailable as error:
|
|
87
|
+
raise HttpError(
|
|
88
|
+
503, "The execution queue is unavailable. Try again shortly."
|
|
89
|
+
) from error
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@router.get("/runs", response=RunPageSchema)
|
|
93
|
+
def runs(request: HttpRequest, query: Query[RunListQuery]) -> RunPageSchema:
|
|
94
|
+
workspace = current_workspace(request)
|
|
95
|
+
if not workspace:
|
|
96
|
+
return RunPageSchema(
|
|
97
|
+
items=[], count=0, page=1, page_size=query.page_size, status_counts={}
|
|
98
|
+
)
|
|
99
|
+
result = run_page(
|
|
100
|
+
workspace,
|
|
101
|
+
RunFilters(
|
|
102
|
+
q=query.q,
|
|
103
|
+
statuses=[str(status) for status in query.status],
|
|
104
|
+
job_id=query.job_id,
|
|
105
|
+
repository_id=query.repository_id,
|
|
106
|
+
period=query.period,
|
|
107
|
+
sort=query.sort,
|
|
108
|
+
),
|
|
109
|
+
query.page,
|
|
110
|
+
query.page_size,
|
|
111
|
+
)
|
|
112
|
+
return RunPageSchema(
|
|
113
|
+
items=[serialize_run(run) for run in result.runs],
|
|
114
|
+
count=result.count,
|
|
115
|
+
page=result.page,
|
|
116
|
+
page_size=result.page_size,
|
|
117
|
+
status_counts=result.status_counts,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@router.get("/runs/{run_id}", response=RunDetailSchema)
|
|
122
|
+
def run_detail(request: HttpRequest, run_id: int) -> RunDetailSchema:
|
|
123
|
+
return serialize_run_detail(run_for_request(request, run_id))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def stored_file_response(
|
|
127
|
+
path: str, filename: str, download: bool, content_type: str
|
|
128
|
+
) -> FileResponse:
|
|
129
|
+
response = FileResponse(
|
|
130
|
+
artifact_storage().open(path, "rb"),
|
|
131
|
+
as_attachment=download,
|
|
132
|
+
filename=filename,
|
|
133
|
+
content_type=content_type,
|
|
134
|
+
)
|
|
135
|
+
response["X-Content-Type-Options"] = "nosniff"
|
|
136
|
+
response["Cache-Control"] = "private, no-store"
|
|
137
|
+
return response
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@router.get("/runs/{run_id}/files/{name}")
|
|
141
|
+
def run_file(
|
|
142
|
+
request: HttpRequest, run_id: int, name: RunFileName, download: bool = False
|
|
143
|
+
) -> FileResponse:
|
|
144
|
+
"""Stream a stored run file through the configured storage backend.
|
|
145
|
+
|
|
146
|
+
Always served as plain text, so a browser never renders repository or model
|
|
147
|
+
content as HTML; the dashboard renders Markdown itself, sanitized.
|
|
148
|
+
"""
|
|
149
|
+
run = run_for_request(request, run_id)
|
|
150
|
+
path = run_file_path(run, name)
|
|
151
|
+
if not path:
|
|
152
|
+
raise Http404
|
|
153
|
+
return stored_file_response(
|
|
154
|
+
path, f"run-{run.pk}-{name}.md", download, "text/plain; charset=utf-8"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@router.get("/runs/{run_id}/changes")
|
|
159
|
+
def run_changes_patch(
|
|
160
|
+
request: HttpRequest, run_id: int, download: bool = False
|
|
161
|
+
) -> FileResponse:
|
|
162
|
+
"""The agent's file changes as a patch, always served as plain text."""
|
|
163
|
+
run = run_for_request(request, run_id)
|
|
164
|
+
path = run.collected.changes.path if run.collected else ""
|
|
165
|
+
if not path:
|
|
166
|
+
raise Http404
|
|
167
|
+
return stored_file_response(
|
|
168
|
+
path, f"run-{run.pk}-changes.patch", download, "text/plain; charset=utf-8"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@router.get("/runs/{run_id}/saved-files/{path:name}")
|
|
173
|
+
def run_saved_file(request: HttpRequest, run_id: int, name: str) -> FileResponse:
|
|
174
|
+
"""One file the agent saved. Always a download, never rendered.
|
|
175
|
+
|
|
176
|
+
The agent chose its name and content, so the browser must not open it as
|
|
177
|
+
a page on this site.
|
|
178
|
+
"""
|
|
179
|
+
run = run_for_request(request, run_id)
|
|
180
|
+
path = saved_file_path(run, name)
|
|
181
|
+
if not path:
|
|
182
|
+
raise Http404
|
|
183
|
+
return stored_file_response(
|
|
184
|
+
path, PurePosixPath(name).name, True, "application/octet-stream"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@router.post("/jobs/{job_id}/run", response={200: RunSchema, 409: RunInProgressSchema})
|
|
189
|
+
def run_job(
|
|
190
|
+
request: HttpRequest, job_id: int
|
|
191
|
+
) -> RunSchema | Status[RunInProgressSchema]:
|
|
192
|
+
job = job_for_request(request, job_id)
|
|
193
|
+
require(request, Action.RUN_JOBS)
|
|
194
|
+
try:
|
|
195
|
+
run = start_manual_run(job, submitted_by=request.user)
|
|
196
|
+
except RunInProgress as error:
|
|
197
|
+
return Status(409, RunInProgressSchema(detail=str(error), run_id=error.run.pk))
|
|
198
|
+
except RunRefused as error:
|
|
199
|
+
raise HttpError(409, str(error)) from error
|
|
200
|
+
return serialize_run(run)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@router.post("/tasks", response=TaskSubmissionSchema)
|
|
204
|
+
def submit_task(request: HttpRequest, payload: SkillSubmission) -> TaskSubmissionSchema:
|
|
205
|
+
require(request, Action.RUN_JOBS)
|
|
206
|
+
repository = repository_for_request(request, payload.repository_id)
|
|
207
|
+
try:
|
|
208
|
+
result = submit_skill(repository, payload, request.user, RunStart.MANUAL)
|
|
209
|
+
except RunRefused as error:
|
|
210
|
+
raise HttpError(409, str(error)) from error
|
|
211
|
+
return serialize_submission(result.run, result.started)
|
secondshift/runs/apps.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from importlib import import_module
|
|
2
|
+
|
|
3
|
+
from django.apps import AppConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RunsConfig(AppConfig):
|
|
7
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
8
|
+
name = "secondshift.runs"
|
|
9
|
+
|
|
10
|
+
def ready(self) -> None:
|
|
11
|
+
import_module("secondshift.runs.signals")
|