secondshift 0.1.0b12__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- secondshift/__init__.py +1 -0
- secondshift/__main__.py +3 -0
- secondshift/cli.py +17 -0
- secondshift/config/__init__.py +0 -0
- secondshift/config/api.py +23 -0
- secondshift/config/app.py +62 -0
- secondshift/config/constants.py +279 -0
- secondshift/config/container_env.py +118 -0
- secondshift/config/first_run.py +40 -0
- secondshift/config/formatting.py +5 -0
- secondshift/config/gunicorn.py +31 -0
- secondshift/config/logging.py +40 -0
- secondshift/config/settings/__init__.py +0 -0
- secondshift/config/settings/base.py +524 -0
- secondshift/config/settings/dev.py +14 -0
- secondshift/config/settings/local.py +23 -0
- secondshift/config/settings/production.py +113 -0
- secondshift/config/settings/runner.py +140 -0
- secondshift/config/settings/test.py +47 -0
- secondshift/config/static_storage.py +45 -0
- secondshift/config/urls.py +42 -0
- secondshift/connections/__init__.py +0 -0
- secondshift/connections/access.py +27 -0
- secondshift/connections/admin.py +25 -0
- secondshift/connections/agents.py +224 -0
- secondshift/connections/api.py +747 -0
- secondshift/connections/apps.py +6 -0
- secondshift/connections/catalog.py +96 -0
- secondshift/connections/checks.py +22 -0
- secondshift/connections/dao.py +305 -0
- secondshift/connections/errors.py +13 -0
- secondshift/connections/execution.py +262 -0
- secondshift/connections/external.py +258 -0
- secondshift/connections/github.py +123 -0
- secondshift/connections/http_api/__init__.py +1 -0
- secondshift/connections/http_api/client.py +161 -0
- secondshift/connections/http_api/connections.py +187 -0
- secondshift/connections/http_api/spec.py +382 -0
- secondshift/connections/mcp/__init__.py +1 -0
- secondshift/connections/mcp/base.py +139 -0
- secondshift/connections/mcp/client.py +353 -0
- secondshift/connections/mcp/custom.py +86 -0
- secondshift/connections/mcp/github.py +256 -0
- secondshift/connections/mcp/oauth.py +352 -0
- secondshift/connections/mcp/providers.py +15 -0
- secondshift/connections/mcp/schemas.py +77 -0
- secondshift/connections/mcp/sentry.py +135 -0
- secondshift/connections/mcp/signin.py +206 -0
- secondshift/connections/migration_helpers.py +28 -0
- secondshift/connections/migrations/0001_initial.py +101 -0
- secondshift/connections/migrations/0002_alter_connection_kind.py +18 -0
- secondshift/connections/migrations/0003_require_approval.py +23 -0
- secondshift/connections/migrations/0004_new_connections_skip_approval.py +18 -0
- secondshift/connections/migrations/0005_agentconnection.py +36 -0
- secondshift/connections/migrations/0006_remove_is_default.py +23 -0
- secondshift/connections/migrations/0007_agent_last_check.py +18 -0
- secondshift/connections/migrations/0008_agentcredentialcleanup.py +25 -0
- secondshift/connections/migrations/0009_release_0_1_0b11.py +52 -0
- secondshift/connections/migrations/0010_connection_attribution.py +18 -0
- secondshift/connections/migrations/__init__.py +0 -0
- secondshift/connections/model_providers.py +441 -0
- secondshift/connections/models.py +246 -0
- secondshift/connections/outbound.py +317 -0
- secondshift/connections/repository_ref.py +19 -0
- secondshift/connections/schemas.py +313 -0
- secondshift/connections/serialization.py +100 -0
- secondshift/connections/services.py +94 -0
- secondshift/connections/tools.py +38 -0
- secondshift/connections/webhooks.py +107 -0
- secondshift/core/__init__.py +0 -0
- secondshift/core/adapters.py +38 -0
- secondshift/core/admin.py +5 -0
- secondshift/core/api.py +104 -0
- secondshift/core/apps.py +20 -0
- secondshift/core/launcher.py +311 -0
- secondshift/core/local_agents.py +90 -0
- secondshift/core/local_output.py +120 -0
- secondshift/core/management/__init__.py +0 -0
- secondshift/core/management/commands/__init__.py +0 -0
- secondshift/core/management/commands/bootstrap_dev.py +33 -0
- secondshift/core/management/commands/dev_reset_db.py +204 -0
- secondshift/core/management/commands/login.py +23 -0
- secondshift/core/management/commands/logs.py +58 -0
- secondshift/core/management/commands/status.py +46 -0
- secondshift/core/management/commands/up.py +244 -0
- secondshift/core/middleware.py +21 -0
- secondshift/core/migrations/__init__.py +0 -0
- secondshift/core/schemas.py +71 -0
- secondshift/core/signin.py +58 -0
- secondshift/core/static/core/account.css +149 -0
- secondshift/core/static/core/admin.css +135 -0
- secondshift/core/templatetags/__init__.py +0 -0
- secondshift/core/templatetags/accounts.py +20 -0
- secondshift/core/terminal.py +27 -0
- secondshift/core/views.py +10 -0
- secondshift/gateway/__init__.py +0 -0
- secondshift/gateway/apps.py +6 -0
- secondshift/gateway/dao.py +137 -0
- secondshift/gateway/metering.py +171 -0
- secondshift/gateway/migrations/0001_initial.py +54 -0
- secondshift/gateway/migrations/0002_modelusage_gateway_usage_created_at_idx.py +19 -0
- secondshift/gateway/migrations/__init__.py +0 -0
- secondshift/gateway/models.py +61 -0
- secondshift/gateway/pricing.py +39 -0
- secondshift/gateway/proxy.py +369 -0
- secondshift/gateway/tokens.py +68 -0
- secondshift/gateway/urls.py +17 -0
- secondshift/gateway/views.py +11 -0
- secondshift/github/__init__.py +6 -0
- secondshift/github/app.py +109 -0
- secondshift/github/authors.py +56 -0
- secondshift/github/credentials.py +59 -0
- secondshift/github/errors.py +2 -0
- secondshift/github/git.py +56 -0
- secondshift/github/identity.py +35 -0
- secondshift/github/installations.py +74 -0
- secondshift/github/issues.py +75 -0
- secondshift/github/permissions.py +49 -0
- secondshift/github/pulls.py +49 -0
- secondshift/github/repos.py +52 -0
- secondshift/github/rest.py +93 -0
- secondshift/github/ruff.toml +16 -0
- secondshift/github/tokens.py +52 -0
- secondshift/jobs/__init__.py +0 -0
- secondshift/jobs/access.py +12 -0
- secondshift/jobs/admin.py +83 -0
- secondshift/jobs/api.py +184 -0
- secondshift/jobs/apps.py +6 -0
- secondshift/jobs/automatic_grants.py +115 -0
- secondshift/jobs/autosync.py +78 -0
- secondshift/jobs/capabilities.py +43 -0
- secondshift/jobs/dao.py +258 -0
- secondshift/jobs/drafts.py +112 -0
- secondshift/jobs/grants.py +116 -0
- secondshift/jobs/limits.py +26 -0
- secondshift/jobs/management/__init__.py +0 -0
- secondshift/jobs/management/commands/__init__.py +0 -0
- secondshift/jobs/manifest.py +364 -0
- secondshift/jobs/migrations/0001_initial.py +87 -0
- secondshift/jobs/migrations/0002_job_history.py +18 -0
- secondshift/jobs/migrations/0003_job_skill_text.py +23 -0
- secondshift/jobs/migrations/0004_jobgrant_tool_rules.py +18 -0
- secondshift/jobs/migrations/0005_job_triggers.py +18 -0
- secondshift/jobs/migrations/0006_job_webhook.py +18 -0
- secondshift/jobs/migrations/0007_job_deliveries.py +23 -0
- secondshift/jobs/migrations/0008_job_removed_at.py +18 -0
- secondshift/jobs/migrations/0009_job_subagents.py +18 -0
- secondshift/jobs/migrations/0010_job_skill_source.py +18 -0
- secondshift/jobs/migrations/__init__.py +0 -0
- secondshift/jobs/models.py +199 -0
- secondshift/jobs/needs.py +154 -0
- secondshift/jobs/plan.py +176 -0
- secondshift/jobs/planning.py +386 -0
- secondshift/jobs/readiness.py +46 -0
- secondshift/jobs/repository_sync.py +199 -0
- secondshift/jobs/scheduling.py +26 -0
- secondshift/jobs/schemas.py +207 -0
- secondshift/jobs/serialization.py +218 -0
- secondshift/jobs/skill_needs.py +189 -0
- secondshift/jobs/sync.py +252 -0
- secondshift/jobs/tool_policy.py +82 -0
- secondshift/jobs/tool_rules.py +200 -0
- secondshift/mcp_server/__init__.py +6 -0
- secondshift/mcp_server/api.py +79 -0
- secondshift/mcp_server/apps.py +7 -0
- secondshift/mcp_server/audit.py +22 -0
- secondshift/mcp_server/core.py +12 -0
- secondshift/mcp_server/dao.py +23 -0
- secondshift/mcp_server/migrations/0001_initial.py +33 -0
- secondshift/mcp_server/migrations/__init__.py +0 -0
- secondshift/mcp_server/models.py +40 -0
- secondshift/mcp_server/schemas.py +204 -0
- secondshift/mcp_server/tools/__init__.py +9 -0
- secondshift/mcp_server/tools/jobs.py +106 -0
- secondshift/mcp_server/tools/manifests.py +127 -0
- secondshift/mcp_server/tools/repositories.py +42 -0
- secondshift/mcp_server/tools/runs.py +86 -0
- secondshift/mcp_server/tools/tasks.py +103 -0
- secondshift/mcp_server/tools_registry.py +81 -0
- secondshift/repositories/__init__.py +0 -0
- secondshift/repositories/access.py +13 -0
- secondshift/repositories/admin.py +19 -0
- secondshift/repositories/api.py +296 -0
- secondshift/repositories/apps.py +6 -0
- secondshift/repositories/dao.py +114 -0
- secondshift/repositories/external_skills.py +224 -0
- secondshift/repositories/github.py +27 -0
- secondshift/repositories/github_access.py +57 -0
- secondshift/repositories/github_check.py +279 -0
- secondshift/repositories/github_sync.py +181 -0
- secondshift/repositories/github_views.py +90 -0
- secondshift/repositories/local.py +198 -0
- secondshift/repositories/management/__init__.py +0 -0
- secondshift/repositories/management/commands/__init__.py +0 -0
- secondshift/repositories/management/commands/prune_source_cache.py +15 -0
- secondshift/repositories/migrations/0001_initial.py +45 -0
- secondshift/repositories/migrations/0002_repository_last_sync_error.py +18 -0
- secondshift/repositories/migrations/0003_repository_checked_sha.py +18 -0
- secondshift/repositories/migrations/0004_pull_request_settings.py +23 -0
- secondshift/repositories/migrations/0005_remove_repository_installation_id.py +17 -0
- secondshift/repositories/migrations/0006_allow_issues.py +18 -0
- secondshift/repositories/migrations/__init__.py +0 -0
- secondshift/repositories/mirrors.py +50 -0
- secondshift/repositories/models.py +104 -0
- secondshift/repositories/pull_requests.py +199 -0
- secondshift/repositories/schemas.py +102 -0
- secondshift/repositories/serialization.py +133 -0
- secondshift/repositories/skill_source.py +64 -0
- secondshift/repositories/snapshots.py +238 -0
- secondshift/repositories/sources.py +256 -0
- secondshift/repositories/status.py +67 -0
- secondshift/runner/README.md +65 -0
- secondshift/runner/__init__.py +1 -0
- secondshift/runner/agent_credentials.py +89 -0
- secondshift/runner/agent_image/Dockerfile +16 -0
- secondshift/runner/agent_image/agent.py +132 -0
- secondshift/runner/agent_image/claude.py +254 -0
- secondshift/runner/agent_image/codex.py +84 -0
- secondshift/runner/agent_image/outcomes.py +120 -0
- secondshift/runner/agent_image/subagent_hook.py +71 -0
- secondshift/runner/agent_protocol.py +41 -0
- secondshift/runner/agents.py +254 -0
- secondshift/runner/api.py +123 -0
- secondshift/runner/apps.py +6 -0
- secondshift/runner/client.py +168 -0
- secondshift/runner/collection.py +276 -0
- secondshift/runner/dao.py +64 -0
- secondshift/runner/dev.py +19 -0
- secondshift/runner/images.py +57 -0
- secondshift/runner/migrations/0001_initial.py +48 -0
- secondshift/runner/migrations/0002_session_source_sha256.py +18 -0
- secondshift/runner/migrations/0003_session_collect_changes.py +18 -0
- secondshift/runner/migrations/0004_session_history_sha256.py +18 -0
- secondshift/runner/migrations/0005_require_source_sha256.py +18 -0
- secondshift/runner/migrations/0006_session_skill_sha256.py +18 -0
- secondshift/runner/migrations/__init__.py +0 -0
- secondshift/runner/models.py +53 -0
- secondshift/runner/protocol.py +97 -0
- secondshift/runner/schemas.py +36 -0
- secondshift/runner/services.py +465 -0
- secondshift/runner/sources.py +130 -0
- secondshift/runner/urls.py +7 -0
- secondshift/runs/__init__.py +0 -0
- secondshift/runs/access.py +13 -0
- secondshift/runs/admin.py +89 -0
- secondshift/runs/api.py +211 -0
- secondshift/runs/apps.py +11 -0
- secondshift/runs/artifacts.py +261 -0
- secondshift/runs/dao.py +640 -0
- secondshift/runs/deliveries.py +80 -0
- secondshift/runs/execution/__init__.py +0 -0
- secondshift/runs/execution/access_needs.py +89 -0
- secondshift/runs/execution/agent.py +91 -0
- secondshift/runs/execution/agent_broker.py +198 -0
- secondshift/runs/execution/budgets.py +177 -0
- secondshift/runs/execution/cli_agent.py +167 -0
- secondshift/runs/execution/consent.py +58 -0
- secondshift/runs/execution/deadlines.py +47 -0
- secondshift/runs/execution/external_tools.py +84 -0
- secondshift/runs/execution/issues.py +99 -0
- secondshift/runs/execution/model_gateway.py +90 -0
- secondshift/runs/execution/payload.py +63 -0
- secondshift/runs/execution/result.py +59 -0
- secondshift/runs/execution/run.py +345 -0
- secondshift/runs/execution/subagents.py +246 -0
- secondshift/runs/execution/tool_broker.py +123 -0
- secondshift/runs/execution/workspace_tools.py +248 -0
- secondshift/runs/history.py +182 -0
- secondshift/runs/inputs.py +49 -0
- secondshift/runs/issue_delivery.py +144 -0
- secondshift/runs/management/__init__.py +0 -0
- secondshift/runs/management/commands/__init__.py +0 -0
- secondshift/runs/management/commands/prune_runs.py +12 -0
- secondshift/runs/management/commands/run_dbos_worker.py +129 -0
- secondshift/runs/migration_helpers.py +313 -0
- secondshift/runs/migrations/0001_initial.py +69 -0
- secondshift/runs/migrations/0002_run_started_by.py +18 -0
- secondshift/runs/migrations/0003_agentlease.py +25 -0
- secondshift/runs/migrations/0004_task_assignment.py +39 -0
- secondshift/runs/migrations/0005_backfill_tasks.py +19 -0
- secondshift/runs/migrations/0006_standalone_tasks.py +50 -0
- secondshift/runs/migrations/0007_run_delivery.py +35 -0
- secondshift/runs/migrations/0008_run_outputs.py +18 -0
- secondshift/runs/migrations/0009_pull_request_delivery.py +28 -0
- secondshift/runs/migrations/0010_run_issue.py +32 -0
- secondshift/runs/migrations/0011_issues_delivery.py +23 -0
- secondshift/runs/migrations/0012_alter_rundelivery_status.py +18 -0
- secondshift/runs/migrations/0013_release_0_1_0b11.py +113 -0
- secondshift/runs/migrations/0014_run_access_need.py +30 -0
- secondshift/runs/migrations/__init__.py +0 -0
- secondshift/runs/models.py +340 -0
- secondshift/runs/orchestration/__init__.py +0 -0
- secondshift/runs/orchestration/admission.py +15 -0
- secondshift/runs/orchestration/dbos_runtime.py +43 -0
- secondshift/runs/orchestration/dispatch.py +130 -0
- secondshift/runs/orchestration/periodic.py +39 -0
- secondshift/runs/orchestration/reconciliation.py +98 -0
- secondshift/runs/orchestration/schedules.py +51 -0
- secondshift/runs/orchestration/workflows.py +480 -0
- secondshift/runs/outputs.py +52 -0
- secondshift/runs/publishing.py +162 -0
- secondshift/runs/queue.py +26 -0
- secondshift/runs/reporting/__init__.py +0 -0
- secondshift/runs/reporting/collection.py +26 -0
- secondshift/runs/reporting/connection_usage.py +45 -0
- secondshift/runs/reporting/failures.py +150 -0
- secondshift/runs/reporting/health.py +158 -0
- secondshift/runs/reporting/notifications.py +258 -0
- secondshift/runs/reporting/queries.py +60 -0
- secondshift/runs/retention.py +35 -0
- secondshift/runs/schemas.py +192 -0
- secondshift/runs/serialization.py +245 -0
- secondshift/runs/services.py +176 -0
- secondshift/runs/signals.py +25 -0
- secondshift/runs/submissions.py +236 -0
- secondshift/runs/transitions.py +84 -0
- secondshift/templates/account/password_reset_help_text.html +1 -0
- secondshift/templates/admin/base_site.html +24 -0
- secondshift/templates/allauth/elements/provider.html +9 -0
- secondshift/templates/allauth/layouts/base.html +67 -0
- secondshift/templates/socialaccount/snippets/login.html +9 -0
- secondshift/templates/workspaces/invite_unusable.html +10 -0
- secondshift/triggers/__init__.py +0 -0
- secondshift/triggers/admin.py +19 -0
- secondshift/triggers/api.py +116 -0
- secondshift/triggers/apps.py +6 -0
- secondshift/triggers/dao.py +66 -0
- secondshift/triggers/filters.py +48 -0
- secondshift/triggers/migrations/0001_initial.py +55 -0
- secondshift/triggers/migrations/0002_ignored_events_and_hourly_limit.py +23 -0
- secondshift/triggers/migrations/__init__.py +0 -0
- secondshift/triggers/models.py +83 -0
- secondshift/triggers/schemas.py +58 -0
- secondshift/triggers/serialization.py +91 -0
- secondshift/triggers/services.py +246 -0
- secondshift/triggers/verification.py +84 -0
- secondshift/web/assets/index-Bwy0hvk_.js +2419 -0
- secondshift/web/assets/index-D27gB9_L.css +1 -0
- secondshift/web/favicon.svg +19 -0
- secondshift/web/index.html +16 -0
- secondshift/web/secondshift-mark.svg +16 -0
- secondshift/workspaces/__init__.py +0 -0
- secondshift/workspaces/access.py +181 -0
- secondshift/workspaces/admin.py +37 -0
- secondshift/workspaces/api.py +264 -0
- secondshift/workspaces/apps.py +6 -0
- secondshift/workspaces/context_processors.py +11 -0
- secondshift/workspaces/dao.py +281 -0
- secondshift/workspaces/invites.py +150 -0
- secondshift/workspaces/limits.py +197 -0
- secondshift/workspaces/management/__init__.py +0 -0
- secondshift/workspaces/management/commands/__init__.py +0 -0
- secondshift/workspaces/management/commands/create_workspace.py +39 -0
- secondshift/workspaces/migrations/0001_initial.py +67 -0
- secondshift/workspaces/migrations/0002_api_token.py +34 -0
- secondshift/workspaces/migrations/0003_execution_default.py +29 -0
- secondshift/workspaces/migrations/0004_copy_default_model_key.py +26 -0
- secondshift/workspaces/migrations/__init__.py +0 -0
- secondshift/workspaces/models.py +169 -0
- secondshift/workspaces/owners.py +58 -0
- secondshift/workspaces/schemas.py +149 -0
- secondshift/workspaces/serialization.py +138 -0
- secondshift/workspaces/server_settings.py +140 -0
- secondshift/workspaces/services.py +94 -0
- secondshift/workspaces/tokens.py +108 -0
- secondshift/workspaces/views.py +32 -0
- secondshift-0.1.0b12.dist-info/METADATA +270 -0
- secondshift-0.1.0b12.dist-info/RECORD +371 -0
- secondshift-0.1.0b12.dist-info/WHEEL +4 -0
- secondshift-0.1.0b12.dist-info/entry_points.txt +2 -0
- secondshift-0.1.0b12.dist-info/licenses/LICENSE +202 -0
secondshift/runs/dao.py
ADDED
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
"""Queries and writes on runs, tasks, run events, deliveries, issues and leases."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Collection, Iterable
|
|
4
|
+
from datetime import date, datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from django.db import transaction
|
|
8
|
+
from django.db.models import Count, Exists, F, OuterRef, Q, QuerySet, Subquery, Sum
|
|
9
|
+
from django.db.models.functions import Coalesce, TruncDate
|
|
10
|
+
|
|
11
|
+
from secondshift.config.constants import AgentBackend
|
|
12
|
+
from secondshift.connections.models import Connection
|
|
13
|
+
from secondshift.jobs.dao import active_jobs
|
|
14
|
+
from secondshift.jobs.models import Job
|
|
15
|
+
from secondshift.jobs.plan import ExecutionPlan
|
|
16
|
+
from secondshift.runner.agent_image.outcomes import CACHE_READ_DIVISOR
|
|
17
|
+
from secondshift.runs.models import (
|
|
18
|
+
AgentLease,
|
|
19
|
+
Run,
|
|
20
|
+
RunAccessNeed,
|
|
21
|
+
RunDelivery,
|
|
22
|
+
RunEvent,
|
|
23
|
+
RunFailure,
|
|
24
|
+
RunIssue,
|
|
25
|
+
Task,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Runs the queue could not hand off. They never started, so they do not count
|
|
29
|
+
# as earlier or recent runs.
|
|
30
|
+
ENQUEUE_FAILED = Q(failure=RunFailure.NOT_QUEUED)
|
|
31
|
+
NEWEST_FIRST = ("-created_at", "-pk")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Runs
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_run(run_id: int, *related: str) -> Run:
|
|
38
|
+
"""The run with these relations loaded. Raises Run.DoesNotExist when missing."""
|
|
39
|
+
return Run.objects.select_related(*related).get(pk=run_id)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def lock_run(run_id: int, *related: str) -> Run:
|
|
43
|
+
"""Lock the run for this transaction, with these relations loaded."""
|
|
44
|
+
return Run.objects.select_for_update().select_related(*related).get(pk=run_id)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def lock_run_in(run_id: int, statuses: Collection[str]) -> Run | None:
|
|
48
|
+
"""Lock the run while it is in one of these statuses. None otherwise."""
|
|
49
|
+
return (
|
|
50
|
+
Run.objects.select_for_update().filter(pk=run_id, status__in=statuses).first()
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def create_run_record(**fields: Any) -> Run:
|
|
55
|
+
"""A run counts against its agent login only when its plan was ready."""
|
|
56
|
+
plan = fields.get("execution_plan")
|
|
57
|
+
if plan:
|
|
58
|
+
typed = ExecutionPlan.model_validate(plan)
|
|
59
|
+
if typed.ready:
|
|
60
|
+
fields["agent_connection_id"] = typed.execution.connection_id
|
|
61
|
+
return Run.objects.create(**fields)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def update_run(run_id: int, **fields: Any) -> None:
|
|
65
|
+
"""Save these fields on the run. Its status changes only in runs.transitions."""
|
|
66
|
+
if "status" in fields:
|
|
67
|
+
raise ValueError("Change a run's status through runs.transitions.")
|
|
68
|
+
Run.objects.filter(pk=run_id).update(**fields)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def with_model_usage(runs: QuerySet[Run]) -> QuerySet[Run]:
|
|
72
|
+
"""Annotate each run with its model usage totals and agent login usage."""
|
|
73
|
+
agent_usage = (
|
|
74
|
+
RunEvent.objects.filter(
|
|
75
|
+
run_id=OuterRef("pk"),
|
|
76
|
+
metadata__backend__in=[
|
|
77
|
+
backend for backend in AgentBackend if not backend.uses_model_api
|
|
78
|
+
],
|
|
79
|
+
metadata__has_key="usage",
|
|
80
|
+
)
|
|
81
|
+
.order_by("-pk")
|
|
82
|
+
.values("metadata__usage")[:1]
|
|
83
|
+
)
|
|
84
|
+
return runs.annotate(
|
|
85
|
+
agent_usage=Subquery(agent_usage),
|
|
86
|
+
model_tokens=Sum(
|
|
87
|
+
F("model_usage__input_tokens")
|
|
88
|
+
+ F("model_usage__output_tokens")
|
|
89
|
+
+ F("model_usage__cache_read_tokens")
|
|
90
|
+
+ F("model_usage__cache_write_tokens")
|
|
91
|
+
),
|
|
92
|
+
# Cached prompt tokens are still input the model read.
|
|
93
|
+
model_input_tokens=Sum(
|
|
94
|
+
F("model_usage__input_tokens")
|
|
95
|
+
+ F("model_usage__cache_read_tokens")
|
|
96
|
+
+ F("model_usage__cache_write_tokens")
|
|
97
|
+
),
|
|
98
|
+
model_output_tokens=Sum("model_usage__output_tokens"),
|
|
99
|
+
model_cache_read_tokens=Sum("model_usage__cache_read_tokens"),
|
|
100
|
+
# The gateway's count, request by request: cache reads as a tenth,
|
|
101
|
+
# rounded up. Integer division matches it exactly.
|
|
102
|
+
model_budget_tokens=Sum(
|
|
103
|
+
F("model_usage__input_tokens")
|
|
104
|
+
+ F("model_usage__output_tokens")
|
|
105
|
+
+ F("model_usage__cache_write_tokens")
|
|
106
|
+
+ (F("model_usage__cache_read_tokens") + CACHE_READ_DIVISOR - 1)
|
|
107
|
+
/ CACHE_READ_DIVISOR
|
|
108
|
+
),
|
|
109
|
+
model_requests=Count("model_usage"),
|
|
110
|
+
model_cost_usd=Sum("model_usage__cost_usd"),
|
|
111
|
+
unpriced_requests=Count(
|
|
112
|
+
"model_usage", filter=Q(model_usage__cost_usd__isnull=True)
|
|
113
|
+
),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def find_workspace_run(workspace_id: int | None, run_id: int) -> Run | None:
|
|
118
|
+
"""The workspace's run with its job, task, repository and usage."""
|
|
119
|
+
return (
|
|
120
|
+
with_model_usage(
|
|
121
|
+
Run.objects.select_related("job__repository", "task__repository")
|
|
122
|
+
)
|
|
123
|
+
.filter(task__repository__workspace_id=workspace_id, pk=run_id)
|
|
124
|
+
.first()
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def workspace_runs(workspace_id: int | None) -> QuerySet[Run]:
|
|
129
|
+
"""The workspace's job runs and ad hoc tasks."""
|
|
130
|
+
return Run.objects.filter(task__repository__workspace_id=workspace_id)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def runs_by_id(run_ids: Iterable[int]) -> dict[int, Run]:
|
|
134
|
+
return Run.objects.in_bulk(list(run_ids))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def job_runs_by_id(job_id: int, run_ids: Iterable[int]) -> dict[int, Run]:
|
|
138
|
+
"""These runs, only where they belong to the job."""
|
|
139
|
+
return Run.objects.filter(job_id=job_id).in_bulk(list(run_ids))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def newest_job_runs(job: Job) -> QuerySet[Run]:
|
|
143
|
+
"""The job's runs with their repository, newest first."""
|
|
144
|
+
return job.runs.select_related("job__repository").order_by(*NEWEST_FIRST)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def succeeded_run_ids(job: Job, count: int) -> list[int]:
|
|
148
|
+
"""The job's newest successful runs, newest first."""
|
|
149
|
+
runs = Run.objects.filter(job=job, status=Run.Status.SUCCEEDED)
|
|
150
|
+
return list(runs.order_by("-created_at").values_list("pk", flat=True)[:count])
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def job_run_for_slot(job: Job, scheduled_for: datetime) -> Run | None:
|
|
154
|
+
"""The run already created for this scheduled time, if any."""
|
|
155
|
+
return job.runs.filter(scheduled_for=scheduled_for).first()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def active_runs() -> QuerySet[Run]:
|
|
159
|
+
"""Runs that have not finished yet."""
|
|
160
|
+
return Run.objects.filter(status__in=Run.ACTIVE_STATUSES)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def latest_active_run(job: Job) -> Run | None:
|
|
164
|
+
"""The job's newest unfinished run, if it has one."""
|
|
165
|
+
return active_runs().filter(job=job).order_by("-pk").first()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def active_tasks(repository_id: int) -> QuerySet[Run]:
|
|
169
|
+
"""Unfinished ad hoc task runs in the repository."""
|
|
170
|
+
return active_runs().filter(job__isnull=True, task__repository_id=repository_id)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def active_task_count(repository_id: int) -> int:
|
|
174
|
+
return active_tasks(repository_id).count()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def has_dispatched_task(repository_id: int) -> bool:
|
|
178
|
+
"""Whether an unfinished task in the repository is already with the queue."""
|
|
179
|
+
return active_tasks(repository_id).exclude(dbos_workflow_id="").exists()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def repository_task_run(repository_id: int, run_id: int) -> Run:
|
|
183
|
+
"""The repository's ad hoc task run, with its task."""
|
|
184
|
+
return (
|
|
185
|
+
Run.objects.select_related("task")
|
|
186
|
+
.filter(job__isnull=True, task__repository_id=repository_id)
|
|
187
|
+
.get(pk=run_id)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def first_due_task(repository_id: int, now: datetime) -> Run | None:
|
|
192
|
+
"""The repository's oldest queued task that is due and not yet dispatched."""
|
|
193
|
+
return (
|
|
194
|
+
Run.objects.filter(
|
|
195
|
+
job__isnull=True,
|
|
196
|
+
task__repository_id=repository_id,
|
|
197
|
+
status=Run.Status.QUEUED,
|
|
198
|
+
scheduled_for__lte=now,
|
|
199
|
+
dbos_workflow_id="",
|
|
200
|
+
)
|
|
201
|
+
.order_by("scheduled_for", "created_at", "pk")
|
|
202
|
+
.first()
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def waiting_tasks(now: datetime, limit: int) -> list[Run]:
|
|
207
|
+
"""Due tasks not yet dispatched, in repositories with nothing dispatched."""
|
|
208
|
+
blocked = (
|
|
209
|
+
active_runs()
|
|
210
|
+
.filter(job__isnull=True, task__repository_id=OuterRef("task__repository_id"))
|
|
211
|
+
.exclude(dbos_workflow_id="")
|
|
212
|
+
)
|
|
213
|
+
return list(
|
|
214
|
+
Run.objects.filter(
|
|
215
|
+
job__isnull=True,
|
|
216
|
+
status=Run.Status.QUEUED,
|
|
217
|
+
dbos_workflow_id="",
|
|
218
|
+
scheduled_for__lte=now,
|
|
219
|
+
)
|
|
220
|
+
.filter(~Exists(blocked))
|
|
221
|
+
.select_related("task")
|
|
222
|
+
.order_by("scheduled_for", "created_at", "pk")[:limit]
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def active_workflows(limit: int) -> list[tuple[int, str]]:
|
|
227
|
+
"""Unfinished runs that the queue accepted, as (run id, workflow id)."""
|
|
228
|
+
return list(
|
|
229
|
+
active_runs()
|
|
230
|
+
.exclude(dbos_workflow_id="")
|
|
231
|
+
.values_list("pk", "dbos_workflow_id")[:limit]
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def run_for_repeat_key(job: Job, key: str, finished_since: datetime) -> Run | None:
|
|
236
|
+
"""The job's newest run for this event that is active or finished since then."""
|
|
237
|
+
return (
|
|
238
|
+
Run.objects.filter(job=job, input_snapshot__repeat_key=key)
|
|
239
|
+
.filter(Q(status__in=Run.ACTIVE_STATUSES) | Q(finished_at__gte=finished_since))
|
|
240
|
+
.exclude(ENQUEUE_FAILED)
|
|
241
|
+
.order_by("-pk")
|
|
242
|
+
.first()
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def started_run_count(job: Job, started_by: str, since: datetime) -> int:
|
|
247
|
+
"""How many runs this source started for the job since then."""
|
|
248
|
+
return (
|
|
249
|
+
Run.objects.filter(job=job, started_by=started_by, created_at__gte=since)
|
|
250
|
+
.exclude(ENQUEUE_FAILED)
|
|
251
|
+
.count()
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def finished_runs_before(cutoff: datetime) -> list[Run]:
|
|
256
|
+
"""Finished runs that ended before the cutoff, with their jobs."""
|
|
257
|
+
return list(
|
|
258
|
+
Run.objects.filter(
|
|
259
|
+
status__in=Run.FINISHED_STATUSES, finished_at__lt=cutoff
|
|
260
|
+
).select_related("job")
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@transaction.atomic
|
|
265
|
+
def delete_runs(runs: list[Run]) -> None:
|
|
266
|
+
"""Delete the runs, and their tasks once no other attempt is left."""
|
|
267
|
+
task_ids = {run.task_id for run in runs if run.task_id is not None}
|
|
268
|
+
Run.objects.filter(pk__in=[run.pk for run in runs]).delete()
|
|
269
|
+
# Never sweep unrelated assignments here.
|
|
270
|
+
Task.objects.filter(pk__in=task_ids, runs__isnull=True).delete()
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# Reporting
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def run_counts(workspace_id: int, start: datetime, previous_start: datetime) -> dict:
|
|
277
|
+
"""Runs since `start`, finished and succeeded among them, and runs before."""
|
|
278
|
+
recent = Q(created_at__gte=start)
|
|
279
|
+
return workspace_runs(workspace_id).aggregate(
|
|
280
|
+
runs=Count("pk", filter=recent),
|
|
281
|
+
previous=Count(
|
|
282
|
+
"pk", filter=Q(created_at__gte=previous_start, created_at__lt=start)
|
|
283
|
+
),
|
|
284
|
+
finished=Count("pk", filter=recent & Q(status__in=Run.FINISHED_STATUSES)),
|
|
285
|
+
succeeded=Count("pk", filter=recent & Q(status=Run.Status.SUCCEEDED)),
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def run_counts_today(workspace_id: int, today: date) -> dict:
|
|
290
|
+
"""Runs created today, and failed runs at any time."""
|
|
291
|
+
return workspace_runs(workspace_id).aggregate(
|
|
292
|
+
today=Count("pk", filter=Q(created_at__date=today)),
|
|
293
|
+
failed=Count("pk", filter=Q(status=Run.Status.FAILED)),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def finished_runs_per_day(
|
|
298
|
+
workspace_id: int, first: date, last: date
|
|
299
|
+
) -> list[dict[str, Any]]:
|
|
300
|
+
"""Finished runs per local day and status, as rows of day, status and count."""
|
|
301
|
+
return list(
|
|
302
|
+
workspace_runs(workspace_id)
|
|
303
|
+
.filter(
|
|
304
|
+
status__in=Run.FINISHED_STATUSES,
|
|
305
|
+
created_at__date__gte=first,
|
|
306
|
+
created_at__date__lte=last,
|
|
307
|
+
)
|
|
308
|
+
.annotate(day=TruncDate("created_at"))
|
|
309
|
+
.values("day", "status")
|
|
310
|
+
.annotate(count=Count("pk"))
|
|
311
|
+
.order_by()
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def search_runs(runs: QuerySet[Run], q: str) -> QuerySet[Run]:
|
|
316
|
+
"""Runs whose job, repository, task name or summary contains the text."""
|
|
317
|
+
q = q.strip()
|
|
318
|
+
if not q:
|
|
319
|
+
return runs
|
|
320
|
+
match = (
|
|
321
|
+
Q(job__name__icontains=q)
|
|
322
|
+
| Q(job__key__icontains=q)
|
|
323
|
+
| Q(task__repository__full_name__icontains=q)
|
|
324
|
+
| Q(input_snapshot__name__icontains=q)
|
|
325
|
+
| Q(summary__icontains=q)
|
|
326
|
+
)
|
|
327
|
+
# "#42" or "42" also finds run 42.
|
|
328
|
+
if (number := q.removeprefix("#")).isdigit():
|
|
329
|
+
match |= Q(pk=int(number))
|
|
330
|
+
return runs.filter(match)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def run_listing(
|
|
334
|
+
workspace_id: int,
|
|
335
|
+
*,
|
|
336
|
+
q: str,
|
|
337
|
+
statuses: Collection[str],
|
|
338
|
+
job_id: int | None,
|
|
339
|
+
repository_id: int | None,
|
|
340
|
+
since: datetime | None,
|
|
341
|
+
by_cost: bool,
|
|
342
|
+
page: int,
|
|
343
|
+
page_size: int,
|
|
344
|
+
) -> tuple[list[Run], int, int, dict[str, int]]:
|
|
345
|
+
"""One page of matching runs, with usage.
|
|
346
|
+
|
|
347
|
+
Returns the runs, how many match, the page actually shown (the last one
|
|
348
|
+
when `page` is past it), and counts per status before the status filter.
|
|
349
|
+
"""
|
|
350
|
+
runs = workspace_runs(workspace_id)
|
|
351
|
+
if job_id is not None:
|
|
352
|
+
runs = runs.filter(job_id=job_id)
|
|
353
|
+
if repository_id is not None:
|
|
354
|
+
runs = runs.filter(task__repository_id=repository_id)
|
|
355
|
+
if since is not None:
|
|
356
|
+
runs = runs.filter(created_at__gte=since)
|
|
357
|
+
runs = search_runs(runs, q)
|
|
358
|
+
# Counts ignore the status filter, so each status chip shows what it would add.
|
|
359
|
+
status_counts = {
|
|
360
|
+
row["status"]: row["total"]
|
|
361
|
+
for row in runs.order_by().values("status").annotate(total=Count("pk"))
|
|
362
|
+
}
|
|
363
|
+
if statuses:
|
|
364
|
+
runs = runs.filter(status__in=statuses)
|
|
365
|
+
count = runs.count()
|
|
366
|
+
last_page = max((count + page_size - 1) // page_size, 1)
|
|
367
|
+
page = min(page, last_page)
|
|
368
|
+
start = (page - 1) * page_size
|
|
369
|
+
page_slice = slice(start, start + page_size)
|
|
370
|
+
related = ("job", "task__repository")
|
|
371
|
+
if by_cost:
|
|
372
|
+
# Cost comes from usage, so aggregate every run before taking the page.
|
|
373
|
+
ordered = with_model_usage(runs.select_related(*related)).order_by(
|
|
374
|
+
F("model_cost_usd").desc(nulls_last=True), *NEWEST_FIRST
|
|
375
|
+
)[page_slice]
|
|
376
|
+
else:
|
|
377
|
+
# Take the page's run IDs first, then aggregate usage for those runs.
|
|
378
|
+
page_ids = runs.order_by(*NEWEST_FIRST).values("pk")[page_slice]
|
|
379
|
+
page_runs = Run.objects.filter(pk__in=Subquery(page_ids))
|
|
380
|
+
ordered = with_model_usage(page_runs.select_related(*related)).order_by(
|
|
381
|
+
*NEWEST_FIRST
|
|
382
|
+
)
|
|
383
|
+
return list(ordered), count, page, status_counts
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# Tasks
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def create_task(**fields: Any) -> Task:
|
|
390
|
+
return Task.objects.create(**fields)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def find_submitted_task(repository_id: int, submission_key: str) -> Task | None:
|
|
394
|
+
"""The repository's task submitted with this idempotency key, if any."""
|
|
395
|
+
return Task.objects.filter(
|
|
396
|
+
repository_id=repository_id, submission_key=submission_key
|
|
397
|
+
).first()
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def first_attempt(task: Task) -> Run | None:
|
|
401
|
+
return task.runs.order_by("created_at", "pk").first()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# Run events
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def add_event(
|
|
408
|
+
run_id: int,
|
|
409
|
+
message: str,
|
|
410
|
+
*,
|
|
411
|
+
level: str = "info",
|
|
412
|
+
metadata: dict[str, Any] | None = None,
|
|
413
|
+
) -> RunEvent:
|
|
414
|
+
"""Add a line to the run's timeline."""
|
|
415
|
+
return RunEvent.objects.create(
|
|
416
|
+
run_id=run_id, message=message, level=level, metadata=metadata or {}
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def add_event_once(run_id: int, message: str, level: str) -> None:
|
|
421
|
+
"""Add a line to the run's timeline unless it is already there."""
|
|
422
|
+
RunEvent.objects.get_or_create(
|
|
423
|
+
run_id=run_id, message=message, defaults={"level": level}
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def run_events(run: Run) -> QuerySet[RunEvent]:
|
|
428
|
+
"""The run's timeline, oldest first."""
|
|
429
|
+
return run.events.order_by("created_at", "pk")
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def subagent_start(run_id: int, marker: str, call_id: str) -> RunEvent | None:
|
|
433
|
+
"""The start already recorded for this sub-agent call, if any."""
|
|
434
|
+
return RunEvent.objects.filter(
|
|
435
|
+
run_id=run_id, metadata__subagent=marker, metadata__call_id=call_id
|
|
436
|
+
).first()
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def subagent_event_count(run_id: int, marker: str) -> int:
|
|
440
|
+
return RunEvent.objects.filter(run_id=run_id, metadata__subagent=marker).count()
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def has_subagent_event(run_id: int, marker: str, call_id: str) -> bool:
|
|
444
|
+
return RunEvent.objects.filter(
|
|
445
|
+
run_id=run_id, metadata__subagent=marker, metadata__call_id=call_id
|
|
446
|
+
).exists()
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# Deliveries
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def create_delivery(run: Run, kind: str, target: str) -> RunDelivery:
|
|
453
|
+
return RunDelivery.objects.create(run=run, kind=kind, target=target)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def run_delivery(run: Run, kind: str, delivered: bool = False) -> RunDelivery | None:
|
|
457
|
+
"""The run's delivery of this kind. With `delivered`, only a delivered one."""
|
|
458
|
+
deliveries = run.deliveries.filter(kind=kind)
|
|
459
|
+
if delivered:
|
|
460
|
+
deliveries = deliveries.filter(status=RunDelivery.Status.DELIVERED)
|
|
461
|
+
return deliveries.first()
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def lock_delivery(run: Run, delivery_id: int) -> RunDelivery:
|
|
465
|
+
"""Lock the run's delivery, with its connection. Raises when missing."""
|
|
466
|
+
return (
|
|
467
|
+
RunDelivery.objects.select_for_update()
|
|
468
|
+
.select_related("connection")
|
|
469
|
+
.get(pk=delivery_id, run=run)
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def notification_delivery(run: Run, connection: Connection) -> RunDelivery:
|
|
474
|
+
"""The run's notification delivery to this connection, created when missing."""
|
|
475
|
+
delivery, _ = RunDelivery.objects.get_or_create(
|
|
476
|
+
run=run,
|
|
477
|
+
kind=RunDelivery.Kind.NOTIFICATION,
|
|
478
|
+
connection=connection,
|
|
479
|
+
defaults={"target": connection.binding_key or connection.display_name},
|
|
480
|
+
)
|
|
481
|
+
return delivery
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def finish_delivery(
|
|
485
|
+
delivery: RunDelivery, status: str, message: str, *, announce: bool = True
|
|
486
|
+
) -> None:
|
|
487
|
+
"""Save the delivery's outcome, and say it in the run's events."""
|
|
488
|
+
delivery.status = status
|
|
489
|
+
delivery.message = message
|
|
490
|
+
delivery.save(
|
|
491
|
+
update_fields=[
|
|
492
|
+
"status",
|
|
493
|
+
"message",
|
|
494
|
+
"attempts",
|
|
495
|
+
"status_code",
|
|
496
|
+
"external_url",
|
|
497
|
+
"updated_at",
|
|
498
|
+
]
|
|
499
|
+
)
|
|
500
|
+
if announce:
|
|
501
|
+
add_event(
|
|
502
|
+
delivery.run_id,
|
|
503
|
+
f"{delivery.event_name}: {message}",
|
|
504
|
+
level="warning" if status == RunDelivery.Status.FAILED else "info",
|
|
505
|
+
metadata={
|
|
506
|
+
"delivery_id": delivery.pk,
|
|
507
|
+
"connection_id": delivery.connection_id,
|
|
508
|
+
"status_code": delivery.status_code,
|
|
509
|
+
"url": delivery.external_url,
|
|
510
|
+
},
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def fail_notification_delivery(run_id: int, connection_id: int, now: datetime) -> None:
|
|
515
|
+
"""Mark the notification failed, unless it was delivered."""
|
|
516
|
+
RunDelivery.objects.filter(
|
|
517
|
+
run_id=run_id,
|
|
518
|
+
kind=RunDelivery.Kind.NOTIFICATION,
|
|
519
|
+
connection_id=connection_id,
|
|
520
|
+
).exclude(status=RunDelivery.Status.DELIVERED).update(
|
|
521
|
+
status=RunDelivery.Status.FAILED, updated_at=now
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
# Issues
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def has_proposed_issues(run: Run) -> bool:
|
|
529
|
+
return run.issues.exists()
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def unopened_issues(run: Run) -> QuerySet[RunIssue]:
|
|
533
|
+
return run.issues.filter(number__isnull=True)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def opened_issues(run: Run) -> list[RunIssue]:
|
|
537
|
+
return list(run.issues.exclude(number__isnull=True))
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def proposed_issue(run_id: int, call_id: str) -> bool:
|
|
541
|
+
"""Whether this tool call already proposed an issue."""
|
|
542
|
+
return RunIssue.objects.filter(run_id=run_id, call_id=call_id).exists()
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def proposed_issue_titles(run_id: int) -> list[str]:
|
|
546
|
+
return list(RunIssue.objects.filter(run_id=run_id).values_list("title", flat=True))
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def proposed_issue_count(run_id: int) -> int:
|
|
550
|
+
return RunIssue.objects.filter(run_id=run_id).count()
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def create_issue_proposal(run_id: int, call_id: str, title: str, body: str) -> RunIssue:
|
|
554
|
+
return RunIssue.objects.create(
|
|
555
|
+
run_id=run_id, call_id=call_id, title=title, body=body
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
# Access the agent needed
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def access_need_for_call(run_id: int, call_id: str) -> RunAccessNeed | None:
|
|
563
|
+
return RunAccessNeed.objects.filter(run_id=run_id, call_id=call_id).first()
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def run_access_needs(run_id: int) -> list[RunAccessNeed]:
|
|
567
|
+
return list(RunAccessNeed.objects.filter(run_id=run_id))
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def create_access_need(
|
|
571
|
+
run_id: int, call_id: str, service: str, tools: list[str], reason: str
|
|
572
|
+
) -> RunAccessNeed:
|
|
573
|
+
return RunAccessNeed.objects.create(
|
|
574
|
+
run_id=run_id, call_id=call_id, service=service, tools=tools, reason=reason
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def update_access_need_tools(need: RunAccessNeed, tools: list[str]) -> None:
|
|
579
|
+
need.tools = tools
|
|
580
|
+
need.save(update_fields=["tools"])
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
# Agent leases
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def run_has_lease(run: Run) -> bool:
|
|
587
|
+
return AgentLease.objects.filter(run=run).exists()
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def create_lease(run: Run, token_hash: str, session_id: str) -> AgentLease:
|
|
591
|
+
return AgentLease.objects.create(
|
|
592
|
+
run=run, token_hash=token_hash, session_id=session_id
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def end_lease(lease_id: int) -> None:
|
|
597
|
+
AgentLease.objects.filter(pk=lease_id).update(active=False)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def lock_lease(lease_id: int) -> AgentLease:
|
|
601
|
+
return AgentLease.objects.select_for_update().get(pk=lease_id)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def active_lease(token_hash: str) -> AgentLease | None:
|
|
605
|
+
return AgentLease.objects.filter(token_hash=token_hash, active=True).first()
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def with_last_run(jobs: QuerySet[Job]) -> QuerySet[Job]:
|
|
609
|
+
"""Annotate each job with its newest run, so a list needs no query per job."""
|
|
610
|
+
newest = Run.objects.filter(job=OuterRef("pk")).order_by("-created_at", "-pk")
|
|
611
|
+
return jobs.annotate(
|
|
612
|
+
last_run_id=Subquery(newest.values("pk")[:1]),
|
|
613
|
+
last_run_status=Subquery(newest.values("status")[:1]),
|
|
614
|
+
last_run_at=Subquery(
|
|
615
|
+
newest.annotate(at=Coalesce("finished_at", "created_at")).values("at")[:1]
|
|
616
|
+
),
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def job_with_last_run(job_id: int) -> Job:
|
|
621
|
+
return with_last_run(Job.objects.filter(pk=job_id)).get()
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def jobs_whose_last_run_failed(workspace_id: int) -> list[Job]:
|
|
625
|
+
"""Active jobs whose newest finished run failed, by name.
|
|
626
|
+
|
|
627
|
+
Each job gets `latest_run_id`.
|
|
628
|
+
"""
|
|
629
|
+
latest = Run.objects.filter(
|
|
630
|
+
job=OuterRef("pk"), status__in=Run.FINISHED_STATUSES
|
|
631
|
+
).order_by("-created_at", "-pk")
|
|
632
|
+
return list(
|
|
633
|
+
active_jobs(workspace_id)
|
|
634
|
+
.annotate(
|
|
635
|
+
latest_run_id=Subquery(latest.values("pk")[:1]),
|
|
636
|
+
latest_status=Subquery(latest.values("status")[:1]),
|
|
637
|
+
)
|
|
638
|
+
.filter(latest_status=Run.Status.FAILED)
|
|
639
|
+
.order_by("name")
|
|
640
|
+
)
|