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,162 @@
|
|
|
1
|
+
"""Publish a finished run's changes as a draft pull request.
|
|
2
|
+
|
|
3
|
+
Publishing reads only what the run stored: the pinned commit, the collected
|
|
4
|
+
patch and the report. A retry repeats this without the agent or a sandbox.
|
|
5
|
+
|
|
6
|
+
When the GitHub connection shows attribution, the branch, the description and
|
|
7
|
+
the commit's trailers name SecondShift. When it does not, nothing does: the
|
|
8
|
+
branch is named after the title and the description is the report alone.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import tempfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from django.utils.text import slugify
|
|
16
|
+
|
|
17
|
+
from secondshift.config.constants import MAX_CHANGES_BYTES
|
|
18
|
+
from secondshift.connections.github import shows_attribution
|
|
19
|
+
from secondshift.github.pulls import MAX_BODY_CHARS
|
|
20
|
+
from secondshift.repositories.models import Repository
|
|
21
|
+
from secondshift.repositories.pull_requests import (
|
|
22
|
+
DraftPullRequest,
|
|
23
|
+
PublishError,
|
|
24
|
+
publish_pull_request,
|
|
25
|
+
)
|
|
26
|
+
from secondshift.runs.artifacts import artifact_storage, read_text
|
|
27
|
+
from secondshift.runs.dao import (
|
|
28
|
+
create_delivery,
|
|
29
|
+
finish_delivery,
|
|
30
|
+
get_run,
|
|
31
|
+
run_delivery,
|
|
32
|
+
)
|
|
33
|
+
from secondshift.runs.models import Run, RunDelivery
|
|
34
|
+
from secondshift.runs.reporting.collection import plural
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
SKIP_CI = "[skip ci]"
|
|
39
|
+
BRANCH_PREFIX = "secondshift/run-"
|
|
40
|
+
# Long enough to read, short enough for a branch list.
|
|
41
|
+
BRANCH_SLUG_CHARS = 40
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def record_pending_pull_request(run: Run) -> None:
|
|
45
|
+
"""Shown as pending from the start, and kept if publishing fails."""
|
|
46
|
+
plan = run.plan.pull_request
|
|
47
|
+
if plan:
|
|
48
|
+
create_delivery(
|
|
49
|
+
run,
|
|
50
|
+
RunDelivery.Kind.PULL_REQUEST,
|
|
51
|
+
f"{run.repository.full_name} into {plan.base}",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def pull_request_delivery(run: Run) -> RunDelivery | None:
|
|
56
|
+
return run_delivery(run, RunDelivery.Kind.PULL_REQUEST)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def skip_reason(run: Run, repository: Repository) -> str:
|
|
60
|
+
"""Why this run opens no pull request, or an empty string if it should."""
|
|
61
|
+
if repository.source_type != Repository.SourceType.GITHUB:
|
|
62
|
+
return "Local repositories cannot open pull requests."
|
|
63
|
+
if not repository.allow_pull_requests:
|
|
64
|
+
return "An owner has not allowed pull requests for this repository."
|
|
65
|
+
if run.status != Run.Status.SUCCEEDED:
|
|
66
|
+
return "The run did not succeed."
|
|
67
|
+
if run.collected is None:
|
|
68
|
+
return "The run's changes could not be collected."
|
|
69
|
+
changes = run.collected.changes
|
|
70
|
+
if changes.too_large:
|
|
71
|
+
return (
|
|
72
|
+
f"The changes were larger than {MAX_CHANGES_BYTES // 2**20} MiB, "
|
|
73
|
+
"so they were not kept."
|
|
74
|
+
)
|
|
75
|
+
if not changes.path:
|
|
76
|
+
return "The agent made no changes."
|
|
77
|
+
return ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def draft_for(run: Run) -> DraftPullRequest:
|
|
81
|
+
if run.finished_at is None:
|
|
82
|
+
raise PublishError("The run has not finished.")
|
|
83
|
+
plan = run.plan.pull_request
|
|
84
|
+
if plan is None:
|
|
85
|
+
raise PublishError("The run did not ask for a pull request.")
|
|
86
|
+
title = run.display_name[:200]
|
|
87
|
+
report = ""
|
|
88
|
+
if run.output_path:
|
|
89
|
+
report, _ = read_text(run.output_path, MAX_BODY_CHARS)
|
|
90
|
+
attribution = shows_attribution(run.repository.github_connection)
|
|
91
|
+
body = report.strip() or run.summary
|
|
92
|
+
paragraphs = [title]
|
|
93
|
+
if not plan.run_ci:
|
|
94
|
+
# CI waits for a person, because the patch has not been reviewed yet.
|
|
95
|
+
paragraphs.append(SKIP_CI)
|
|
96
|
+
if attribution:
|
|
97
|
+
body = f"{body}\n\n---\n\n{footer(run)}"
|
|
98
|
+
paragraphs.append(trailers(run))
|
|
99
|
+
return DraftPullRequest(
|
|
100
|
+
branch=branch_name(run, attribution),
|
|
101
|
+
base=plan.base,
|
|
102
|
+
title=title,
|
|
103
|
+
body=body,
|
|
104
|
+
message="\n\n".join(paragraphs),
|
|
105
|
+
# The same date on every attempt gives the same commit.
|
|
106
|
+
committed_at=run.finished_at,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def footer(run: Run) -> str:
|
|
111
|
+
return f"Opened by SecondShift from run #{run.pk} at {run.commit_sha[:12]}."
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def trailers(run: Run) -> str:
|
|
115
|
+
"""Git trailers, so tools can find the run behind a commit."""
|
|
116
|
+
lines = [f"SecondShift-Run: {run.pk}"]
|
|
117
|
+
if run.job is not None:
|
|
118
|
+
lines.append(f"SecondShift-Job: {run.job.name}")
|
|
119
|
+
return "\n".join(lines)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def branch_name(run: Run, attribution: bool) -> str:
|
|
123
|
+
"""The same name on every attempt, so a retry pushes the same branch."""
|
|
124
|
+
if attribution:
|
|
125
|
+
return f"{BRANCH_PREFIX}{run.pk}"
|
|
126
|
+
slug = slugify(run.display_name)[:BRANCH_SLUG_CHARS].strip("-") or "changes"
|
|
127
|
+
return f"{slug}-{run.pk}"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def publish_run(run_id: int) -> None:
|
|
131
|
+
run = get_run(run_id, "job", "task__repository__github_connection")
|
|
132
|
+
delivery = pull_request_delivery(run)
|
|
133
|
+
if delivery is None or delivery.status == RunDelivery.Status.DELIVERED:
|
|
134
|
+
return
|
|
135
|
+
if reason := skip_reason(run, run.repository):
|
|
136
|
+
finish_delivery(delivery, RunDelivery.Status.SKIPPED, reason)
|
|
137
|
+
return
|
|
138
|
+
delivery.attempts += 1
|
|
139
|
+
try:
|
|
140
|
+
with tempfile.TemporaryDirectory(prefix="secondshift-patch-") as folder:
|
|
141
|
+
patch = Path(folder) / "changes.patch"
|
|
142
|
+
with artifact_storage().open(run.collected.changes.path, "rb") as src:
|
|
143
|
+
patch.write_bytes(src.read())
|
|
144
|
+
opened = publish_pull_request(
|
|
145
|
+
run.repository, run.commit_sha, patch, draft_for(run)
|
|
146
|
+
)
|
|
147
|
+
except PublishError as error:
|
|
148
|
+
finish_delivery(delivery, RunDelivery.Status.FAILED, str(error))
|
|
149
|
+
return
|
|
150
|
+
except Exception:
|
|
151
|
+
logger.exception("publishing run %s failed", run_id)
|
|
152
|
+
finish_delivery(
|
|
153
|
+
delivery, RunDelivery.Status.FAILED, "Publishing failed unexpectedly."
|
|
154
|
+
)
|
|
155
|
+
return
|
|
156
|
+
delivery.external_url = opened.url
|
|
157
|
+
count = run.collected.changes.file_count
|
|
158
|
+
finish_delivery(
|
|
159
|
+
delivery,
|
|
160
|
+
RunDelivery.Status.DELIVERED,
|
|
161
|
+
f"Opened draft #{opened.number} with changes to {plural(count, 'file')}.",
|
|
162
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Changes a person may make to a task that has not started."""
|
|
2
|
+
|
|
3
|
+
from django.db import transaction
|
|
4
|
+
|
|
5
|
+
from secondshift.repositories.dao import lock_repository
|
|
6
|
+
from secondshift.runs.dao import add_event, lock_run
|
|
7
|
+
from secondshift.runs.models import Run
|
|
8
|
+
from secondshift.runs.services import RunRefused
|
|
9
|
+
from secondshift.runs.transitions import finish_run
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@transaction.atomic
|
|
13
|
+
def cancel_queued_task(run: Run) -> Run:
|
|
14
|
+
if run.job_id is not None:
|
|
15
|
+
raise RunRefused("Only queued ad hoc tasks can be canceled here.")
|
|
16
|
+
lock_repository(run.task.repository_id)
|
|
17
|
+
run = lock_run(run.pk, "task__repository")
|
|
18
|
+
if run.status == Run.Status.CANCELED:
|
|
19
|
+
return run
|
|
20
|
+
if run.status != Run.Status.QUEUED:
|
|
21
|
+
raise RunRefused("This task has already started or finished.")
|
|
22
|
+
summary = "Canceled before execution."
|
|
23
|
+
finish_run(run.pk, Run.Status.CANCELED, summary, only_from={Run.Status.QUEUED})
|
|
24
|
+
run.refresh_from_db(fields=["status", "summary", "finished_at"])
|
|
25
|
+
add_event(run.pk, summary)
|
|
26
|
+
return run
|
|
File without changes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from secondshift.config.constants import MAX_CHANGES_BYTES
|
|
2
|
+
from secondshift.runs.outputs import RunOutputs
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def plural(count: int, noun: str) -> str:
|
|
6
|
+
return f"{count} {noun}" if count == 1 else f"{count} {noun}s"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def collection_summary(outputs: RunOutputs) -> str:
|
|
10
|
+
"""One timeline line about what the sandbox left behind, or none."""
|
|
11
|
+
changes = outputs.changes
|
|
12
|
+
parts: list[str] = []
|
|
13
|
+
if changes.path:
|
|
14
|
+
parts.append(f"changes to {plural(changes.file_count, 'file')}")
|
|
15
|
+
if outputs.files:
|
|
16
|
+
parts.append(plural(len(outputs.files), "saved file"))
|
|
17
|
+
lines = [f"Collected {' and '.join(parts)}."] if parts else []
|
|
18
|
+
if changes.too_large:
|
|
19
|
+
limit = MAX_CHANGES_BYTES // (1024 * 1024)
|
|
20
|
+
lines.append(
|
|
21
|
+
f"Changes to {plural(changes.file_count, 'file')} were larger than "
|
|
22
|
+
f"{limit} MiB, so they were not kept."
|
|
23
|
+
)
|
|
24
|
+
if outputs.skipped:
|
|
25
|
+
lines.append(f"Skipped {plural(len(outputs.skipped), 'saved file')}.")
|
|
26
|
+
return " ".join(lines)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""How each connection is used: by which jobs, and for model providers, at what cost."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
|
|
7
|
+
from django.utils import timezone
|
|
8
|
+
|
|
9
|
+
from secondshift.gateway.dao import usage_by_connection
|
|
10
|
+
from secondshift.jobs.dao import approved_jobs_per_connection
|
|
11
|
+
from secondshift.workspaces.models import Workspace
|
|
12
|
+
|
|
13
|
+
WINDOW = timedelta(days=7)
|
|
14
|
+
# The window an agent login's run cap counts over.
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class ConnectionUsage:
|
|
19
|
+
# Jobs with approved access through this connection.
|
|
20
|
+
jobs: int = 0
|
|
21
|
+
runs_7d: int = 0
|
|
22
|
+
spend_7d_usd: Decimal = Decimal(0)
|
|
23
|
+
# Some model calls had no known price, so spend is a lower bound.
|
|
24
|
+
spend_partial: bool = False
|
|
25
|
+
# Last model call. Services don't record individual calls.
|
|
26
|
+
last_used_at: datetime | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def connection_usage(
|
|
30
|
+
workspace: Workspace, now: datetime | None = None
|
|
31
|
+
) -> dict[int, ConnectionUsage]:
|
|
32
|
+
start = (now or timezone.now()) - WINDOW
|
|
33
|
+
jobs = approved_jobs_per_connection(workspace.pk)
|
|
34
|
+
models = usage_by_connection(workspace.pk, start)
|
|
35
|
+
usage: dict[int, ConnectionUsage] = {}
|
|
36
|
+
for connection_id in jobs.keys() | models.keys():
|
|
37
|
+
row = models.get(connection_id, {})
|
|
38
|
+
usage[connection_id] = ConnectionUsage(
|
|
39
|
+
jobs=jobs.get(connection_id, 0),
|
|
40
|
+
runs_7d=row.get("runs", 0),
|
|
41
|
+
spend_7d_usd=row.get("spend") or Decimal(0),
|
|
42
|
+
spend_partial=row.get("unpriced", 0) > 0,
|
|
43
|
+
last_used_at=row.get("last"),
|
|
44
|
+
)
|
|
45
|
+
return usage
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Turn workflow exceptions into run summaries a person can act on."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from pydantic_ai.exceptions import ModelHTTPError
|
|
6
|
+
|
|
7
|
+
from secondshift.connections.errors import ExternalRequestError, ExternalToolError
|
|
8
|
+
from secondshift.github.errors import GitHubError
|
|
9
|
+
from secondshift.jobs.tool_rules import ToolRuleViolation
|
|
10
|
+
from secondshift.runner.protocol import RunnerProtocolError
|
|
11
|
+
from secondshift.runs.models import Run, RunFailure
|
|
12
|
+
|
|
13
|
+
STAGE_FAILURES: dict[str, str] = {
|
|
14
|
+
RunFailure.STARTING: "The run could not start",
|
|
15
|
+
RunFailure.CHECKOUT: "Couldn't check out the repository",
|
|
16
|
+
RunFailure.SANDBOX: "Couldn't start the sandbox",
|
|
17
|
+
RunFailure.SKILL: "The skill run failed",
|
|
18
|
+
RunFailure.FINISHING: "The run could not be recorded",
|
|
19
|
+
}
|
|
20
|
+
MAX_DETAIL_CHARS = 300
|
|
21
|
+
TOOL_RULE_NEXT_STEP = (
|
|
22
|
+
"The model used a tool in a way the job's access review does not allow. "
|
|
23
|
+
"Change the restriction or the skill, then run again."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Messages of these types come from SecondShift or Pydantic AI (whose run errors
|
|
27
|
+
# are RuntimeErrors) and never carry credentials, so they are shown. Anything
|
|
28
|
+
# else could echo arbitrary data, so only its type is shown.
|
|
29
|
+
SHOWN_ERROR_TYPES: tuple[type[BaseException], ...] = (
|
|
30
|
+
GitHubError,
|
|
31
|
+
RunnerProtocolError,
|
|
32
|
+
RuntimeError,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def root_error(error: BaseException) -> BaseException:
|
|
37
|
+
"""The first underlying error, unwrapping exhausted step retries.
|
|
38
|
+
|
|
39
|
+
Later retry attempts often fail for a different, misleading reason (for
|
|
40
|
+
example, a conflict because the first attempt already ran), so the first
|
|
41
|
+
attempt is the one that explains the failure.
|
|
42
|
+
"""
|
|
43
|
+
attempts = getattr(error, "errors", None)
|
|
44
|
+
if isinstance(attempts, list) and attempts:
|
|
45
|
+
first = attempts[0]
|
|
46
|
+
if isinstance(first, BaseException):
|
|
47
|
+
return first
|
|
48
|
+
return error
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def provider_message(body: object) -> str:
|
|
52
|
+
"""The human message from an OpenAI- or Anthropic-shaped error body."""
|
|
53
|
+
if not isinstance(body, dict):
|
|
54
|
+
return ""
|
|
55
|
+
nested = body.get("error")
|
|
56
|
+
if isinstance(nested, dict) and isinstance(nested.get("message"), str):
|
|
57
|
+
return nested["message"]
|
|
58
|
+
message = body.get("message")
|
|
59
|
+
return message if isinstance(message, str) else ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def one_line(text: str) -> str:
|
|
63
|
+
line = " ".join(text.split())
|
|
64
|
+
if len(line) > MAX_DETAIL_CHARS:
|
|
65
|
+
line = line[: MAX_DETAIL_CHARS - 1] + "…"
|
|
66
|
+
return line
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def provider_reply(error: ModelHTTPError, separator: str) -> str:
|
|
70
|
+
"""`HTTP 401: User not found.`, or `HTTP 401.` when the body says nothing."""
|
|
71
|
+
message = provider_message(error.body)
|
|
72
|
+
reply = f"HTTP {error.status_code}"
|
|
73
|
+
return one_line(f"{reply}{separator}{message}" if message else f"{reply}.")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def failure_detail(error: BaseException) -> str:
|
|
77
|
+
if isinstance(error, ModelHTTPError):
|
|
78
|
+
return one_line(f"The model provider answered {provider_reply(error, ': ')}")
|
|
79
|
+
if isinstance(error, ToolRuleViolation):
|
|
80
|
+
# Names the argument and the rule, never the value the model sent.
|
|
81
|
+
return one_line(f"Blocked by a tool restriction: {error}")
|
|
82
|
+
if isinstance(error, SHOWN_ERROR_TYPES) and str(error).strip():
|
|
83
|
+
return one_line(str(error))
|
|
84
|
+
# SecondShift writes these for people. Request errors may quote the
|
|
85
|
+
# remote service, so they stay hidden.
|
|
86
|
+
shown_tool_error = isinstance(error, ExternalToolError) and not isinstance(
|
|
87
|
+
error, ExternalRequestError
|
|
88
|
+
)
|
|
89
|
+
if shown_tool_error and str(error).strip():
|
|
90
|
+
return one_line(str(error))
|
|
91
|
+
return f"Unexpected {type(error).__name__}."
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class StageFailure:
|
|
96
|
+
kind: RunFailure
|
|
97
|
+
reason: str
|
|
98
|
+
# One line naming the failed stage and the original cause.
|
|
99
|
+
summary: str
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def stage_failure(stage: RunFailure, error: BaseException) -> StageFailure:
|
|
103
|
+
"""What a workflow error at this stage means for the run."""
|
|
104
|
+
root = root_error(error)
|
|
105
|
+
detail = failure_detail(root)
|
|
106
|
+
summary = f"{STAGE_FAILURES[stage]}: {detail}"
|
|
107
|
+
if isinstance(root, ModelHTTPError):
|
|
108
|
+
# model_provider_next_step reads the status from the start of this.
|
|
109
|
+
reason = provider_reply(root, " · ")
|
|
110
|
+
return StageFailure(RunFailure.MODEL_PROVIDER, reason, summary)
|
|
111
|
+
if isinstance(root, ToolRuleViolation):
|
|
112
|
+
return StageFailure(RunFailure.TOOL_RULE, one_line(str(root)), summary)
|
|
113
|
+
return StageFailure(stage, detail, summary)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def failure_metadata(stage: str, error: BaseException) -> dict[str, object]:
|
|
117
|
+
attempts = getattr(error, "errors", None)
|
|
118
|
+
return {
|
|
119
|
+
"stage": stage,
|
|
120
|
+
"error_type": type(root_error(error)).__name__,
|
|
121
|
+
"attempts": len(attempts) if isinstance(attempts, list) else 1,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class FailureView:
|
|
127
|
+
"""A failure split for compact display: where it happened, and why."""
|
|
128
|
+
|
|
129
|
+
label: str
|
|
130
|
+
reason: str
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def describe_failure(run: Run) -> FailureView:
|
|
134
|
+
kind = RunFailure(run.failure or RunFailure.OTHER)
|
|
135
|
+
reason = " ".join(run.failure_reason.split())
|
|
136
|
+
return FailureView(kind.label, reason or "No reason was recorded.")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def model_provider_next_step(reason: str, connection_name: str) -> str:
|
|
140
|
+
"""What to do about a model provider failure, in plain words."""
|
|
141
|
+
if reason.startswith(("HTTP 401", "HTTP 403")):
|
|
142
|
+
return (
|
|
143
|
+
f"The API key for {connection_name} was refused. "
|
|
144
|
+
"Update the key, then run again."
|
|
145
|
+
)
|
|
146
|
+
if reason.startswith("HTTP 429"):
|
|
147
|
+
return "The provider is limiting requests. Wait, then run again."
|
|
148
|
+
if reason.startswith("HTTP 5"):
|
|
149
|
+
return "The provider had a problem. Run again later."
|
|
150
|
+
return f"Check {connection_name}, then run again."
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Workspace health for the overview: what is failing now, and what it costs."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import date, datetime, timedelta
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
|
|
7
|
+
from django.db.models import Q
|
|
8
|
+
from django.utils import timezone
|
|
9
|
+
|
|
10
|
+
from secondshift.gateway.dao import spend_per_job, spend_totals
|
|
11
|
+
from secondshift.jobs.dao import jobs_by_id
|
|
12
|
+
from secondshift.runs.dao import (
|
|
13
|
+
finished_runs_per_day,
|
|
14
|
+
jobs_whose_last_run_failed,
|
|
15
|
+
run_counts,
|
|
16
|
+
runs_by_id,
|
|
17
|
+
)
|
|
18
|
+
from secondshift.runs.models import Run
|
|
19
|
+
from secondshift.runs.reporting.failures import describe_failure
|
|
20
|
+
from secondshift.workspaces.models import Workspace
|
|
21
|
+
|
|
22
|
+
WINDOW = timedelta(days=7)
|
|
23
|
+
# Two weeks, so the overview chart shows this week against the one before.
|
|
24
|
+
CHART_DAYS = 14
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class FailingJob:
|
|
29
|
+
job_id: int
|
|
30
|
+
job: str
|
|
31
|
+
repository: str
|
|
32
|
+
run_id: int
|
|
33
|
+
failed_at: datetime | None
|
|
34
|
+
label: str
|
|
35
|
+
reason: str
|
|
36
|
+
# Scheduled but switched off, so nothing will run it again on its own.
|
|
37
|
+
paused: bool
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class WeekSummary:
|
|
42
|
+
runs: int
|
|
43
|
+
previous_runs: int
|
|
44
|
+
finished: int
|
|
45
|
+
succeeded: int
|
|
46
|
+
spend_usd: Decimal
|
|
47
|
+
previous_spend_usd: Decimal
|
|
48
|
+
# Some model calls had no known price, so spend is a lower bound.
|
|
49
|
+
spend_partial: bool
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class RunDay:
|
|
54
|
+
"""Finished runs on one local day, by outcome."""
|
|
55
|
+
|
|
56
|
+
day: date
|
|
57
|
+
succeeded: int
|
|
58
|
+
failed: int
|
|
59
|
+
canceled: int
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class JobSpend:
|
|
64
|
+
job_id: int
|
|
65
|
+
job: str
|
|
66
|
+
repository: str
|
|
67
|
+
runs: int
|
|
68
|
+
spend_usd: Decimal
|
|
69
|
+
# Some of this job's model calls had no known price.
|
|
70
|
+
partial: bool
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def failing_jobs(workspace: Workspace) -> list[FailingJob]:
|
|
74
|
+
"""Jobs whose most recent finished run failed.
|
|
75
|
+
|
|
76
|
+
A job that failed last week but has succeeded since is healthy, so this
|
|
77
|
+
looks only at each job's latest outcome, not at failure history. Paused
|
|
78
|
+
jobs still count, marked as paused, since their last run is still broken.
|
|
79
|
+
"""
|
|
80
|
+
jobs = jobs_whose_last_run_failed(workspace.pk)
|
|
81
|
+
runs = runs_by_id(job.latest_run_id for job in jobs)
|
|
82
|
+
failing: list[FailingJob] = []
|
|
83
|
+
for job in jobs:
|
|
84
|
+
run = runs[job.latest_run_id]
|
|
85
|
+
view = describe_failure(run)
|
|
86
|
+
failing.append(
|
|
87
|
+
FailingJob(
|
|
88
|
+
job_id=job.pk,
|
|
89
|
+
job=job.name,
|
|
90
|
+
repository=job.repository.display_name,
|
|
91
|
+
run_id=run.pk,
|
|
92
|
+
failed_at=run.finished_at,
|
|
93
|
+
label=view.label,
|
|
94
|
+
reason=view.reason,
|
|
95
|
+
paused=bool(job.cron) and not job.enabled,
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
return failing
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def week_summary(workspace: Workspace, now: datetime | None = None) -> WeekSummary:
|
|
102
|
+
"""Runs and model spend in the last seven days, with the week before for trend."""
|
|
103
|
+
now = now or timezone.now()
|
|
104
|
+
start, previous_start = now - WINDOW, now - 2 * WINDOW
|
|
105
|
+
runs = run_counts(workspace.pk, start, previous_start)
|
|
106
|
+
totals = spend_totals(
|
|
107
|
+
Q(run__task__repository__workspace_id=workspace.pk), start, previous_start
|
|
108
|
+
)
|
|
109
|
+
return WeekSummary(
|
|
110
|
+
runs=runs["runs"],
|
|
111
|
+
previous_runs=runs["previous"],
|
|
112
|
+
finished=runs["finished"],
|
|
113
|
+
succeeded=runs["succeeded"],
|
|
114
|
+
spend_usd=totals["spend"] or Decimal(0),
|
|
115
|
+
previous_spend_usd=totals["previous"] or Decimal(0),
|
|
116
|
+
spend_partial=totals["unpriced"] > 0,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def runs_by_day(
|
|
121
|
+
workspace: Workspace, now: datetime | None = None, days: int = CHART_DAYS
|
|
122
|
+
) -> list[RunDay]:
|
|
123
|
+
"""Finished runs per local day, oldest first. Days without runs count as zero."""
|
|
124
|
+
today = timezone.localdate(now or timezone.now())
|
|
125
|
+
first = today - timedelta(days=days - 1)
|
|
126
|
+
rows = finished_runs_per_day(workspace.pk, first, today)
|
|
127
|
+
counts = {(row["day"], row["status"]): row["count"] for row in rows}
|
|
128
|
+
return [
|
|
129
|
+
RunDay(
|
|
130
|
+
day=day,
|
|
131
|
+
succeeded=counts.get((day, Run.Status.SUCCEEDED), 0),
|
|
132
|
+
failed=counts.get((day, Run.Status.FAILED), 0),
|
|
133
|
+
canceled=counts.get((day, Run.Status.CANCELED), 0),
|
|
134
|
+
)
|
|
135
|
+
for day in (first + timedelta(days=offset) for offset in range(days))
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def spend_by_job(
|
|
140
|
+
workspace: Workspace, now: datetime | None = None, limit: int = 5
|
|
141
|
+
) -> list[JobSpend]:
|
|
142
|
+
"""The jobs that spent the most on models in the last seven days."""
|
|
143
|
+
start = (now or timezone.now()) - WINDOW
|
|
144
|
+
rows = spend_per_job(workspace.pk, start)
|
|
145
|
+
ranked = sorted(rows, key=lambda row: row["spend"] or Decimal(0), reverse=True)
|
|
146
|
+
ranked = ranked[:limit]
|
|
147
|
+
jobs = jobs_by_id(row["run__job_id"] for row in ranked)
|
|
148
|
+
return [
|
|
149
|
+
JobSpend(
|
|
150
|
+
job_id=row["run__job_id"],
|
|
151
|
+
job=jobs[row["run__job_id"]].name,
|
|
152
|
+
repository=jobs[row["run__job_id"]].repository.display_name,
|
|
153
|
+
runs=row["runs"],
|
|
154
|
+
spend_usd=row["spend"] or Decimal(0),
|
|
155
|
+
partial=row["unpriced"] > 0,
|
|
156
|
+
)
|
|
157
|
+
for row in ranked
|
|
158
|
+
]
|