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,276 @@
|
|
|
1
|
+
"""What a session leaves behind: its file changes and the files it saved.
|
|
2
|
+
|
|
3
|
+
When a session that collects changes starts, the runner records the unpacked
|
|
4
|
+
commit in a git folder beside the workspace, outside the sandbox's mounts, so
|
|
5
|
+
the agent cannot change it. Recording costs one write per file, so only runs
|
|
6
|
+
that publish changes ask for it. After the agent finishes, the runner stops the sandbox and compares the
|
|
7
|
+
workspace with that record. The result is one archive:
|
|
8
|
+
|
|
9
|
+
- ``manifest.json`` describes the changes and the saved files
|
|
10
|
+
- ``changes.patch`` is a binary-safe git diff, if anything changed
|
|
11
|
+
- ``outputs/<name>`` holds each regular file the agent saved in /outputs
|
|
12
|
+
|
|
13
|
+
Git runs here with no system, global or repository settings that the
|
|
14
|
+
workspace could supply, and never runs hooks, filters or external diff tools.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import io
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import stat
|
|
22
|
+
import subprocess
|
|
23
|
+
import tarfile
|
|
24
|
+
from pathlib import Path, PurePosixPath
|
|
25
|
+
from typing import IO, Any
|
|
26
|
+
|
|
27
|
+
from secondshift.config.constants import (
|
|
28
|
+
MAX_CHANGES_BYTES,
|
|
29
|
+
MAX_OUTPUT_BYTES,
|
|
30
|
+
MAX_OUTPUT_FILES,
|
|
31
|
+
)
|
|
32
|
+
from secondshift.runner.protocol import RunnerProtocolError
|
|
33
|
+
|
|
34
|
+
BASELINE_DIR = "baseline.git"
|
|
35
|
+
OUTPUTS_DIR = "outputs"
|
|
36
|
+
COLLECTION_FILE = "collection.tar.gz"
|
|
37
|
+
MANIFEST_NAME = "manifest.json"
|
|
38
|
+
CHANGES_NAME = "changes.patch"
|
|
39
|
+
# A long list of names helps nobody. The patch itself holds every path.
|
|
40
|
+
MAX_LISTED_CHANGES = 1000
|
|
41
|
+
GIT_TIMEOUT_SECONDS = 300
|
|
42
|
+
NO_CHANGES = {
|
|
43
|
+
"file_count": 0,
|
|
44
|
+
"files": [],
|
|
45
|
+
"size_bytes": 0,
|
|
46
|
+
"captured": False,
|
|
47
|
+
"too_large": False,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def git(
|
|
52
|
+
session_root: Path,
|
|
53
|
+
workspace: Path,
|
|
54
|
+
*args: str,
|
|
55
|
+
stdout: IO[bytes] | None = None,
|
|
56
|
+
work_tree: bool = True,
|
|
57
|
+
) -> subprocess.CompletedProcess[bytes]:
|
|
58
|
+
git_dir = session_root / BASELINE_DIR
|
|
59
|
+
command = [
|
|
60
|
+
"git",
|
|
61
|
+
# The workspace belongs to the sandbox user, not to the runner.
|
|
62
|
+
"-c",
|
|
63
|
+
"safe.directory=*",
|
|
64
|
+
"-c",
|
|
65
|
+
"core.hooksPath=/dev/null",
|
|
66
|
+
"-c",
|
|
67
|
+
"core.fsmonitor=false",
|
|
68
|
+
"-c",
|
|
69
|
+
"core.autocrlf=false",
|
|
70
|
+
"-c",
|
|
71
|
+
"core.symlinks=true",
|
|
72
|
+
f"--git-dir={git_dir}",
|
|
73
|
+
# git init refuses a work tree. Every other command compares one.
|
|
74
|
+
*([f"--work-tree={workspace}"] if work_tree else []),
|
|
75
|
+
*args,
|
|
76
|
+
]
|
|
77
|
+
environment = {
|
|
78
|
+
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
|
79
|
+
"HOME": str(git_dir),
|
|
80
|
+
"LC_ALL": "C",
|
|
81
|
+
"GIT_CONFIG_NOSYSTEM": "1",
|
|
82
|
+
"GIT_CONFIG_GLOBAL": os.devnull,
|
|
83
|
+
"GIT_AUTHOR_NAME": "SecondShift",
|
|
84
|
+
"GIT_AUTHOR_EMAIL": "runner@secondshift.invalid",
|
|
85
|
+
"GIT_COMMITTER_NAME": "SecondShift",
|
|
86
|
+
"GIT_COMMITTER_EMAIL": "runner@secondshift.invalid",
|
|
87
|
+
}
|
|
88
|
+
try:
|
|
89
|
+
return subprocess.run(
|
|
90
|
+
command,
|
|
91
|
+
cwd=workspace,
|
|
92
|
+
env=environment,
|
|
93
|
+
stdout=stdout if stdout is not None else subprocess.PIPE,
|
|
94
|
+
stderr=subprocess.PIPE,
|
|
95
|
+
timeout=GIT_TIMEOUT_SECONDS,
|
|
96
|
+
check=False,
|
|
97
|
+
)
|
|
98
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
99
|
+
raise RunnerProtocolError("The runner could not compare the files.") from error
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def require_git(result: subprocess.CompletedProcess[bytes]) -> None:
|
|
103
|
+
if result.returncode:
|
|
104
|
+
raise RunnerProtocolError("The runner could not compare the files.")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def record_baseline(session_root: Path, workspace: Path) -> None:
|
|
108
|
+
"""Commit the unpacked files, before the sandbox can change them."""
|
|
109
|
+
git_dir = session_root / BASELINE_DIR
|
|
110
|
+
git_dir.mkdir(mode=0o700)
|
|
111
|
+
require_git(
|
|
112
|
+
git(session_root, workspace, "init", "--quiet", "--bare", work_tree=False)
|
|
113
|
+
)
|
|
114
|
+
# Forced, so a committed file that matches .gitignore is compared too.
|
|
115
|
+
require_git(git(session_root, workspace, "add", "--all", "--force", "."))
|
|
116
|
+
require_git(
|
|
117
|
+
git(
|
|
118
|
+
session_root,
|
|
119
|
+
workspace,
|
|
120
|
+
"commit",
|
|
121
|
+
"--quiet",
|
|
122
|
+
"--no-verify",
|
|
123
|
+
"--allow-empty",
|
|
124
|
+
"--message=Run baseline",
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def collect(session_root: Path, workspace: Path) -> Path:
|
|
130
|
+
"""Build the session's archive once. Later calls return the same one.
|
|
131
|
+
|
|
132
|
+
The sandbox must already be stopped, so nothing changes the files while
|
|
133
|
+
they are read.
|
|
134
|
+
"""
|
|
135
|
+
archive_path = session_root / COLLECTION_FILE
|
|
136
|
+
if archive_path.is_file():
|
|
137
|
+
return archive_path
|
|
138
|
+
partial = session_root / f"{COLLECTION_FILE}.partial"
|
|
139
|
+
patch_path = session_root / CHANGES_NAME
|
|
140
|
+
# Sessions that did not ask for changes have no baseline to compare.
|
|
141
|
+
manifest: dict[str, Any] = {
|
|
142
|
+
"changes": compare(session_root, workspace, patch_path)
|
|
143
|
+
if (session_root / BASELINE_DIR).is_dir()
|
|
144
|
+
else NO_CHANGES
|
|
145
|
+
}
|
|
146
|
+
with tarfile.open(partial, mode="w:gz") as archive:
|
|
147
|
+
if manifest["changes"]["captured"]:
|
|
148
|
+
add_file(archive, patch_path, CHANGES_NAME)
|
|
149
|
+
manifest["outputs"], manifest["skipped"] = add_outputs(
|
|
150
|
+
archive, session_root / OUTPUTS_DIR
|
|
151
|
+
)
|
|
152
|
+
add_bytes(archive, MANIFEST_NAME, json.dumps(manifest).encode())
|
|
153
|
+
patch_path.unlink(missing_ok=True)
|
|
154
|
+
os.replace(partial, archive_path)
|
|
155
|
+
return archive_path
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def compare(session_root: Path, workspace: Path, patch_path: Path) -> dict[str, Any]:
|
|
159
|
+
"""Write the changes since the baseline, if they fit, and describe them."""
|
|
160
|
+
# Not forced: new files that match .gitignore, such as build output, stay out.
|
|
161
|
+
require_git(git(session_root, workspace, "add", "--all", "."))
|
|
162
|
+
names = git(session_root, workspace, "diff", "--cached", "--name-only", "-z")
|
|
163
|
+
require_git(names)
|
|
164
|
+
paths = [
|
|
165
|
+
item.decode(errors="replace") for item in names.stdout.split(b"\0") if item
|
|
166
|
+
]
|
|
167
|
+
with patch_path.open("wb") as stream:
|
|
168
|
+
require_git(
|
|
169
|
+
git(
|
|
170
|
+
session_root,
|
|
171
|
+
workspace,
|
|
172
|
+
"diff",
|
|
173
|
+
"--cached",
|
|
174
|
+
"--binary",
|
|
175
|
+
"--no-color",
|
|
176
|
+
"--no-ext-diff",
|
|
177
|
+
"--no-textconv",
|
|
178
|
+
"--full-index",
|
|
179
|
+
stdout=stream,
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
size = patch_path.stat().st_size
|
|
183
|
+
return {
|
|
184
|
+
"file_count": len(paths),
|
|
185
|
+
"files": paths[:MAX_LISTED_CHANGES],
|
|
186
|
+
"size_bytes": size,
|
|
187
|
+
"captured": bool(paths) and size <= MAX_CHANGES_BYTES,
|
|
188
|
+
"too_large": size > MAX_CHANGES_BYTES,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def add_outputs(
|
|
193
|
+
archive: tarfile.TarFile, outputs: Path
|
|
194
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
|
|
195
|
+
saved: list[dict[str, Any]] = []
|
|
196
|
+
skipped: list[dict[str, str]] = []
|
|
197
|
+
if not outputs.is_dir():
|
|
198
|
+
return saved, skipped
|
|
199
|
+
total = 0
|
|
200
|
+
for root, directories, files in os.walk(outputs, followlinks=False):
|
|
201
|
+
# Linked folders are never entered, and are listed as skipped.
|
|
202
|
+
for name in [item for item in directories if (Path(root) / item).is_symlink()]:
|
|
203
|
+
directories.remove(name)
|
|
204
|
+
relative = (Path(root) / name).relative_to(outputs).as_posix()
|
|
205
|
+
skipped.append({"name": relative, "reason": "Not a regular file."})
|
|
206
|
+
directories.sort()
|
|
207
|
+
for name in sorted(files):
|
|
208
|
+
path = Path(root) / name
|
|
209
|
+
relative = path.relative_to(outputs).as_posix()
|
|
210
|
+
reason = output_problem(path, relative, len(saved), total)
|
|
211
|
+
if reason:
|
|
212
|
+
skipped.append({"name": relative, "reason": reason})
|
|
213
|
+
continue
|
|
214
|
+
size, digest = add_file(archive, path, f"{OUTPUTS_DIR}/{relative}")
|
|
215
|
+
total += size
|
|
216
|
+
saved.append({"name": relative, "size_bytes": size, "sha256": digest})
|
|
217
|
+
return saved, skipped
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def output_problem(path: Path, relative: str, count: int, total: int) -> str:
|
|
221
|
+
info = path.lstat()
|
|
222
|
+
if not stat.S_ISREG(info.st_mode):
|
|
223
|
+
return "Not a regular file."
|
|
224
|
+
if not safe_output_name(relative):
|
|
225
|
+
return "The file name is not allowed."
|
|
226
|
+
if count >= MAX_OUTPUT_FILES:
|
|
227
|
+
return f"Only the first {MAX_OUTPUT_FILES} files are kept."
|
|
228
|
+
if total + info.st_size > MAX_OUTPUT_BYTES:
|
|
229
|
+
return "The files are larger than the limit."
|
|
230
|
+
return ""
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def safe_output_name(name: str) -> bool:
|
|
234
|
+
"""A relative path of plain parts, safe to use as a storage path."""
|
|
235
|
+
path = PurePosixPath(name)
|
|
236
|
+
return (
|
|
237
|
+
0 < len(name) <= 255
|
|
238
|
+
and not path.is_absolute()
|
|
239
|
+
and all(part not in {"", ".", ".."} for part in path.parts)
|
|
240
|
+
and all(char.isprintable() and char != "\\" for char in name)
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def add_file(archive: tarfile.TarFile, path: Path, name: str) -> tuple[int, str]:
|
|
245
|
+
"""Add one regular file under a fixed owner and mode. Links are refused."""
|
|
246
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
|
247
|
+
with os.fdopen(descriptor, "rb") as stream:
|
|
248
|
+
info = os.fstat(stream.fileno())
|
|
249
|
+
if not stat.S_ISREG(info.st_mode):
|
|
250
|
+
raise RunnerProtocolError("A collected file is not a regular file.")
|
|
251
|
+
member = tarfile.TarInfo(name)
|
|
252
|
+
member.size = info.st_size
|
|
253
|
+
member.mode = 0o600
|
|
254
|
+
reader = HashingReader(stream)
|
|
255
|
+
archive.addfile(member, reader)
|
|
256
|
+
return info.st_size, reader.digest.hexdigest()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class HashingReader:
|
|
260
|
+
"""Passes a stream to tarfile and keeps the SHA-256 of what it read."""
|
|
261
|
+
|
|
262
|
+
def __init__(self, stream: IO[bytes]) -> None:
|
|
263
|
+
self.stream = stream
|
|
264
|
+
self.digest = hashlib.sha256()
|
|
265
|
+
|
|
266
|
+
def read(self, size: int = -1) -> bytes:
|
|
267
|
+
chunk = self.stream.read(size)
|
|
268
|
+
self.digest.update(chunk)
|
|
269
|
+
return chunk
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def add_bytes(archive: tarfile.TarFile, name: str, content: bytes) -> None:
|
|
273
|
+
member = tarfile.TarInfo(name)
|
|
274
|
+
member.size = len(content)
|
|
275
|
+
member.mode = 0o600
|
|
276
|
+
archive.addfile(member, io.BytesIO(content))
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Queries and writes on runner sessions and the commands run in them."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from secondshift.runner.models import RunnerCommand, RunnerSession
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def has_sessions() -> bool:
|
|
9
|
+
"""Whether any session exists. The health check uses it to reach the database."""
|
|
10
|
+
return RunnerSession.objects.exists()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_session(session_id: str) -> RunnerSession:
|
|
14
|
+
"""The session. Raises RunnerSession.DoesNotExist when there is none."""
|
|
15
|
+
return RunnerSession.objects.get(session_id=session_id)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def lock_session(session_id: str, active_only: bool = False) -> RunnerSession | None:
|
|
19
|
+
"""Lock the session for this transaction. None when it does not exist."""
|
|
20
|
+
sessions = RunnerSession.objects.select_for_update().filter(session_id=session_id)
|
|
21
|
+
if active_only:
|
|
22
|
+
sessions = sessions.filter(status=RunnerSession.Status.ACTIVE)
|
|
23
|
+
return sessions.first()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_session_active(session_id: str) -> bool:
|
|
27
|
+
return RunnerSession.objects.filter(
|
|
28
|
+
session_id=session_id, status=RunnerSession.Status.ACTIVE
|
|
29
|
+
).exists()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def create_session(**fields: Any) -> RunnerSession:
|
|
33
|
+
"""Record a new session. Raises IntegrityError when the run already has one."""
|
|
34
|
+
return RunnerSession.objects.create(**fields)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def set_session_status(session_id: str, status: RunnerSession.Status) -> None:
|
|
38
|
+
RunnerSession.objects.filter(session_id=session_id).update(status=status)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def session_command(session: RunnerSession, call_id: str) -> RunnerCommand | None:
|
|
42
|
+
"""The command already reserved for this call, if any."""
|
|
43
|
+
return RunnerCommand.objects.filter(session=session, call_id=call_id).first()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def create_command(
|
|
47
|
+
session: RunnerSession, call_id: str, fingerprint: str
|
|
48
|
+
) -> RunnerCommand:
|
|
49
|
+
"""Reserve a call before running it."""
|
|
50
|
+
return RunnerCommand.objects.create(
|
|
51
|
+
session=session, call_id=call_id, fingerprint=fingerprint
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def complete_command(
|
|
56
|
+
session: RunnerSession, call_id: str, exit_code: int, output: str, timed_out: bool
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Store the outcome of a reserved call."""
|
|
59
|
+
RunnerCommand.objects.filter(session=session, call_id=call_id).update(
|
|
60
|
+
status=RunnerCommand.Status.COMPLETED,
|
|
61
|
+
exit_code=exit_code,
|
|
62
|
+
output=output,
|
|
63
|
+
timed_out=timed_out,
|
|
64
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Start the development runner with the exact locally built agent image."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from secondshift.config.constants import AppEnv
|
|
7
|
+
from secondshift.runner.images import resolve_image_id
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> None:
|
|
11
|
+
image = os.environ.get(AppEnv.AGENT_IMAGE, "")
|
|
12
|
+
if not image:
|
|
13
|
+
raise ValueError("The development agent image must be configured.")
|
|
14
|
+
os.environ[AppEnv.AGENT_IMAGE] = resolve_image_id(image)
|
|
15
|
+
os.execv(sys.executable, [sys.executable, "-m", "gunicorn", "--reload"])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
if __name__ == "__main__":
|
|
19
|
+
main()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Build the agent image from the files shipped in the package."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from secondshift.config.constants import LOCAL_AGENT_TAG
|
|
10
|
+
|
|
11
|
+
AGENT_IMAGE_DIR = Path(__file__).parent / "agent_image"
|
|
12
|
+
BUILD_SECONDS = 1800
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def resolve_image_id(image: str) -> str:
|
|
16
|
+
"""The immutable ID behind a local image name."""
|
|
17
|
+
result = subprocess.run(
|
|
18
|
+
["docker", "image", "inspect", "--format", "{{.Id}}", image],
|
|
19
|
+
capture_output=True,
|
|
20
|
+
text=True,
|
|
21
|
+
check=True,
|
|
22
|
+
timeout=30,
|
|
23
|
+
)
|
|
24
|
+
image_id = result.stdout.strip()
|
|
25
|
+
if not re.fullmatch(r"sha256:[0-9a-f]{64}", image_id):
|
|
26
|
+
raise ValueError("Docker did not return an immutable agent image ID.")
|
|
27
|
+
return image_id
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_agent_image(tag: str = LOCAL_AGENT_TAG) -> str:
|
|
31
|
+
"""Build the pinned agent image and return its immutable ID.
|
|
32
|
+
|
|
33
|
+
The build context holds only the agent files, laid out as in the checkout,
|
|
34
|
+
so Docker never uploads the rest of the installed packages.
|
|
35
|
+
"""
|
|
36
|
+
with tempfile.TemporaryDirectory(prefix="secondshift-agent-") as context:
|
|
37
|
+
target = Path(context) / "secondshift" / "runner" / "agent_image"
|
|
38
|
+
shutil.copytree(
|
|
39
|
+
AGENT_IMAGE_DIR, target, ignore=shutil.ignore_patterns("__pycache__")
|
|
40
|
+
)
|
|
41
|
+
subprocess.run(
|
|
42
|
+
[
|
|
43
|
+
"docker",
|
|
44
|
+
"build",
|
|
45
|
+
"--quiet",
|
|
46
|
+
"-f",
|
|
47
|
+
str(target / "Dockerfile"),
|
|
48
|
+
"-t",
|
|
49
|
+
tag,
|
|
50
|
+
context,
|
|
51
|
+
],
|
|
52
|
+
capture_output=True,
|
|
53
|
+
text=True,
|
|
54
|
+
check=True,
|
|
55
|
+
timeout=BUILD_SECONDS,
|
|
56
|
+
)
|
|
57
|
+
return resolve_image_id(tag)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Generated by Django 6.0.9 on 2026-10-26 14:22
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
|
|
9
|
+
initial = True
|
|
10
|
+
|
|
11
|
+
dependencies = [
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
operations = [
|
|
15
|
+
migrations.CreateModel(
|
|
16
|
+
name='RunnerSession',
|
|
17
|
+
fields=[
|
|
18
|
+
('session_id', models.CharField(max_length=80, primary_key=True, serialize=False)),
|
|
19
|
+
('run_id', models.PositiveBigIntegerField(db_index=True)),
|
|
20
|
+
('repository', models.CharField(max_length=300)),
|
|
21
|
+
('commit_sha', models.CharField(max_length=64)),
|
|
22
|
+
('image', models.CharField(max_length=300)),
|
|
23
|
+
('container_name', models.CharField(max_length=120, unique=True)),
|
|
24
|
+
('workspace_path', models.TextField()),
|
|
25
|
+
('status', models.CharField(choices=[('starting', 'Starting'), ('active', 'Active'), ('stopped', 'Stopped'), ('error', 'Error')], default='starting', max_length=16)),
|
|
26
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
27
|
+
('updated_at', models.DateTimeField(auto_now=True)),
|
|
28
|
+
],
|
|
29
|
+
),
|
|
30
|
+
migrations.CreateModel(
|
|
31
|
+
name='RunnerCommand',
|
|
32
|
+
fields=[
|
|
33
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
34
|
+
('call_id', models.CharField(max_length=200)),
|
|
35
|
+
('fingerprint', models.CharField(max_length=64)),
|
|
36
|
+
('status', models.CharField(choices=[('running', 'Running'), ('completed', 'Completed')], default='running', max_length=16)),
|
|
37
|
+
('exit_code', models.IntegerField(blank=True, null=True)),
|
|
38
|
+
('output', models.TextField(default='')),
|
|
39
|
+
('timed_out', models.BooleanField(default=False)),
|
|
40
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
41
|
+
('updated_at', models.DateTimeField(auto_now=True)),
|
|
42
|
+
('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='commands', to='runner.runnersession')),
|
|
43
|
+
],
|
|
44
|
+
options={
|
|
45
|
+
'constraints': [models.UniqueConstraint(fields=('session', 'call_id'), name='runner_unique_session_call')],
|
|
46
|
+
},
|
|
47
|
+
),
|
|
48
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Generated by Django 6.0.9 on 2026-10-29 22:44
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
('runner', '0001_initial'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AddField(
|
|
14
|
+
model_name='runnersession',
|
|
15
|
+
name='source_sha256',
|
|
16
|
+
field=models.CharField(blank=True, max_length=64),
|
|
17
|
+
),
|
|
18
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Generated by Django 6.0.9 on 2026-11-03 20:24
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
('runner', '0002_session_source_sha256'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AddField(
|
|
14
|
+
model_name='runnersession',
|
|
15
|
+
name='collect_changes',
|
|
16
|
+
field=models.BooleanField(default=False),
|
|
17
|
+
),
|
|
18
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Generated by Django 6.0.9 on 2026-11-07 15:03
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
('runner', '0003_session_collect_changes'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AddField(
|
|
14
|
+
model_name='runnersession',
|
|
15
|
+
name='history_sha256',
|
|
16
|
+
field=models.CharField(blank=True, max_length=64),
|
|
17
|
+
),
|
|
18
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Generated by Django 6.0.9 on 2026-11-08 23:43
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
('runner', '0004_session_history_sha256'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AlterField(
|
|
14
|
+
model_name='runnersession',
|
|
15
|
+
name='source_sha256',
|
|
16
|
+
field=models.CharField(max_length=64),
|
|
17
|
+
),
|
|
18
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Generated by Django 6.0.9 on 2026-11-11 19:21
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
('runner', '0005_require_source_sha256'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AddField(
|
|
14
|
+
model_name='runnersession',
|
|
15
|
+
name='skill_sha256',
|
|
16
|
+
field=models.CharField(blank=True, max_length=64),
|
|
17
|
+
),
|
|
18
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class RunnerSession(models.Model):
|
|
5
|
+
class Status(models.TextChoices):
|
|
6
|
+
STARTING = "starting", "Starting"
|
|
7
|
+
ACTIVE = "active", "Active"
|
|
8
|
+
STOPPED = "stopped", "Stopped"
|
|
9
|
+
ERROR = "error", "Error"
|
|
10
|
+
|
|
11
|
+
session_id = models.CharField(primary_key=True, max_length=80)
|
|
12
|
+
run_id = models.PositiveBigIntegerField(db_index=True)
|
|
13
|
+
repository = models.CharField(max_length=300)
|
|
14
|
+
commit_sha = models.CharField(max_length=64)
|
|
15
|
+
image = models.CharField(max_length=300)
|
|
16
|
+
source_sha256 = models.CharField(max_length=64)
|
|
17
|
+
collect_changes = models.BooleanField(default=False)
|
|
18
|
+
history_sha256 = models.CharField(max_length=64, blank=True)
|
|
19
|
+
skill_sha256 = models.CharField(max_length=64, blank=True)
|
|
20
|
+
container_name = models.CharField(max_length=120, unique=True)
|
|
21
|
+
workspace_path = models.TextField()
|
|
22
|
+
status = models.CharField(
|
|
23
|
+
max_length=16, choices=Status.choices, default=Status.STARTING
|
|
24
|
+
)
|
|
25
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
26
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RunnerCommand(models.Model):
|
|
30
|
+
class Status(models.TextChoices):
|
|
31
|
+
RUNNING = "running", "Running"
|
|
32
|
+
COMPLETED = "completed", "Completed"
|
|
33
|
+
|
|
34
|
+
session = models.ForeignKey(
|
|
35
|
+
RunnerSession, on_delete=models.CASCADE, related_name="commands"
|
|
36
|
+
)
|
|
37
|
+
call_id = models.CharField(max_length=200)
|
|
38
|
+
fingerprint = models.CharField(max_length=64)
|
|
39
|
+
status = models.CharField(
|
|
40
|
+
max_length=16, choices=Status.choices, default=Status.RUNNING
|
|
41
|
+
)
|
|
42
|
+
exit_code = models.IntegerField(null=True, blank=True)
|
|
43
|
+
output = models.TextField(default="")
|
|
44
|
+
timed_out = models.BooleanField(default=False)
|
|
45
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
46
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
47
|
+
|
|
48
|
+
class Meta:
|
|
49
|
+
constraints = [
|
|
50
|
+
models.UniqueConstraint(
|
|
51
|
+
fields=("session", "call_id"), name="runner_unique_session_call"
|
|
52
|
+
)
|
|
53
|
+
]
|