From 8fdfa5baf8f3922cf90c66272968cbb4533e1fbb Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 7 Jul 2026 15:08:00 +0100 Subject: [PATCH 01/55] feat(sessions): add Session and Run models unifying Activity/ChatThread schema --- daiv/daiv/settings/components/common.py | 1 + daiv/sessions/__init__.py | 0 daiv/sessions/apps.py | 12 + daiv/sessions/managers.py | 58 ++++ daiv/sessions/migrations/0001_initial.py | 308 +++++++++++++++++++++ daiv/sessions/migrations/__init__.py | 0 daiv/sessions/models.py | 335 +++++++++++++++++++++++ daiv/sessions/signals.py | 1 + tests/unit_tests/sessions/__init__.py | 0 tests/unit_tests/sessions/test_models.py | 115 ++++++++ 10 files changed, 830 insertions(+) create mode 100644 daiv/sessions/__init__.py create mode 100644 daiv/sessions/apps.py create mode 100644 daiv/sessions/managers.py create mode 100644 daiv/sessions/migrations/0001_initial.py create mode 100644 daiv/sessions/migrations/__init__.py create mode 100644 daiv/sessions/models.py create mode 100644 daiv/sessions/signals.py create mode 100644 tests/unit_tests/sessions/__init__.py create mode 100644 tests/unit_tests/sessions/test_models.py diff --git a/daiv/daiv/settings/components/common.py b/daiv/daiv/settings/components/common.py index 68db16b27..51e6f6dca 100644 --- a/daiv/daiv/settings/components/common.py +++ b/daiv/daiv/settings/components/common.py @@ -24,6 +24,7 @@ "notifications", "sandbox_envs", "schedules", + "sessions", "skills", "slash_commands", ] diff --git a/daiv/sessions/__init__.py b/daiv/sessions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/daiv/sessions/apps.py b/daiv/sessions/apps.py new file mode 100644 index 000000000..2e59d84eb --- /dev/null +++ b/daiv/sessions/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + + +class SessionsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "sessions" + # "sessions" is taken by django.contrib.sessions; the label must differ. + label = "agent_sessions" + verbose_name = "Agent Sessions" + + def ready(self): + import sessions.signals # noqa: F401, PLC0415 diff --git a/daiv/sessions/managers.py b/daiv/sessions/managers.py new file mode 100644 index 000000000..f28825ccf --- /dev/null +++ b/daiv/sessions/managers.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.db import models + +if TYPE_CHECKING: + from accounts.models import User + from sessions.models import Run, Session + + +class SessionManager(models.Manager["Session"]): + def by_owner(self, user: User) -> models.QuerySet[Session]: + """Return sessions visible to the given user. + + Admins see all. Regular users see sessions where they own the session row, + match its ``external_username``, subscribe to its schedule, or acted in any + of its runs (user FK or external_username on the Run). The run-level match + preserves per-actor visibility on shared webhook threads. + """ + from sessions.models import Run + + if user.is_admin: + return self.all() + run_match = Run.objects.filter(session=models.OuterRef("pk")).filter( + models.Q(user=user) | models.Q(external_username=user.username) + ) + return self.filter( + models.Q(user=user) + | models.Q(external_username=user.username) + | models.Q(scheduled_job__subscribers=user) + | models.Exists(run_match) + ).distinct() + + def with_latest_status(self) -> models.QuerySet[Session]: + """Annotate each session with ``latest_run_status`` (status of the newest run). + + NULL for chat-only sessions that predate run tracking. + """ + from sessions.models import Run + + latest = Run.objects.filter(session=models.OuterRef("pk")).order_by("-created_at", "-id") + return self.annotate(latest_run_status=models.Subquery(latest.values("status")[:1])) + + +class RunManager(models.Manager["Run"]): + def by_owner(self, user: User) -> models.QuerySet[Run]: + """Mirror of the old ActivityManager.by_owner semantics, run-level.""" + if user.is_admin: + return self.all() + return self.filter( + models.Q(user=user) + | models.Q(external_username=user.username) + | models.Q(session__scheduled_job__subscribers=user) + ).distinct() + + def by_batch(self, batch_id) -> models.QuerySet[Run]: + return self.filter(batch_id=batch_id) diff --git a/daiv/sessions/migrations/0001_initial.py b/daiv/sessions/migrations/0001_initial.py new file mode 100644 index 000000000..ec10a9470 --- /dev/null +++ b/daiv/sessions/migrations/0001_initial.py @@ -0,0 +1,308 @@ +# Generated by Django 6.0.6 on 2026-07-07 14:06 + +import uuid + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("django_tasks_database", "0019_rename_django_task_new_ordering_idx_tasks_db_new_ordering_idx_and_more"), + ("sandbox_envs", "0006_drop_network_enabled"), + ("schedules", "0015_schedules_agent_override_fields"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Session", + fields=[ + ("thread_id", models.CharField(max_length=64, primary_key=True, serialize=False)), + ( + "origin", + models.CharField( + choices=[ + ("chat", "Chat"), + ("api_job", "API Run"), + ("mcp_job", "MCP Run"), + ("schedule", "Scheduled Run"), + ("ui_job", "UI Run"), + ("issue_webhook", "Issue Webhook"), + ("mr_webhook", "MR/PR Webhook"), + ], + max_length=20, + verbose_name="origin", + ), + ), + ( + "external_username", + models.CharField(blank=True, default="", max_length=255, verbose_name="external username"), + ), + ("repo_id", models.CharField(max_length=255, verbose_name="repository")), + ("ref", models.CharField(blank=True, default="", max_length=255, verbose_name="branch / ref")), + ("title", models.CharField(blank=True, default="", max_length=120, verbose_name="title")), + ("agent_model", models.CharField(blank=True, default="", max_length=255, verbose_name="agent model")), + ( + "agent_thinking_level", + models.CharField( + blank=True, + choices=[ + ("minimal", "Minimal"), + ("low", "Low"), + ("medium", "Medium"), + ("high", "High"), + ("xhigh", "Extra high"), + ], + default="", + max_length=20, + verbose_name="agent thinking level", + ), + ), + ("issue_iid", models.PositiveIntegerField(blank=True, null=True, verbose_name="issue IID")), + ( + "merge_request_iid", + models.PositiveIntegerField(blank=True, null=True, verbose_name="merge request IID"), + ), + ("active_run_id", models.CharField(blank=True, default=None, max_length=64, null=True)), + ( + "created_at", + models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name="created at"), + ), + ( + "last_active_at", + models.DateTimeField(default=django.utils.timezone.now, verbose_name="last active at"), + ), + ( + "sandbox_environment", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="agent_sessions", + to="sandbox_envs.sandboxenvironment", + verbose_name="sandbox environment", + ), + ), + ( + "scheduled_job", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="agent_sessions", + to="schedules.scheduledjob", + verbose_name="scheduled job", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="agent_sessions", + to=settings.AUTH_USER_MODEL, + verbose_name="user", + ), + ), + ], + options={"verbose_name": "Session", "verbose_name_plural": "Sessions", "ordering": ["-last_active_at"]}, + ), + migrations.CreateModel( + name="Run", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ( + "trigger_type", + models.CharField( + choices=[ + ("chat", "Chat"), + ("api_job", "API Run"), + ("mcp_job", "MCP Run"), + ("schedule", "Scheduled Run"), + ("ui_job", "UI Run"), + ("issue_webhook", "Issue Webhook"), + ("mr_webhook", "MR/PR Webhook"), + ], + max_length=20, + verbose_name="trigger type", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("QUEUED", "Queued"), + ("READY", "Pending"), + ("RUNNING", "Running"), + ("SUCCESSFUL", "Successful"), + ("FAILED", "Failed"), + ], + default="READY", + max_length=10, + verbose_name="status", + ), + ), + ( + "external_username", + models.CharField(blank=True, default="", max_length=255, verbose_name="external username"), + ), + ("title", models.CharField(blank=True, default="", max_length=120, verbose_name="title")), + ("batch_id", models.UUIDField(blank=True, db_index=True, null=True, verbose_name="batch ID")), + ("repo_id", models.CharField(max_length=255, verbose_name="repository")), + ("ref", models.CharField(blank=True, default="", max_length=255, verbose_name="branch / ref")), + ("prompt", models.TextField(blank=True, default="", verbose_name="prompt")), + ("agent_model", models.CharField(blank=True, default="", max_length=255, verbose_name="agent model")), + ( + "agent_thinking_level", + models.CharField( + blank=True, + choices=[ + ("minimal", "Minimal"), + ("low", "Low"), + ("medium", "Medium"), + ("high", "High"), + ("xhigh", "Extra high"), + ], + default="", + max_length=20, + verbose_name="agent thinking level", + ), + ), + ( + "notify_on", + models.CharField( + blank=True, + choices=[ + ("never", "Never"), + ("always", "Always"), + ("on_success", "On success only"), + ("on_failure", "On failure only"), + ], + max_length=16, + null=True, + verbose_name="notify on", + ), + ), + ( + "mention_comment_id", + models.CharField(blank=True, default="", max_length=255, verbose_name="mention comment ID"), + ), + ( + "merge_request_iid", + models.PositiveIntegerField(blank=True, null=True, verbose_name="merge request IID"), + ), + ( + "merge_request_web_url", + models.URLField(blank=True, default="", max_length=500, verbose_name="merge request URL"), + ), + ("result_summary", models.TextField(blank=True, default="", verbose_name="result summary")), + ("error_message", models.TextField(blank=True, default="", verbose_name="error message")), + ("code_changes", models.BooleanField(default=False, verbose_name="code changes")), + ("input_tokens", models.PositiveIntegerField(blank=True, null=True, verbose_name="input tokens")), + ("output_tokens", models.PositiveIntegerField(blank=True, null=True, verbose_name="output tokens")), + ("total_tokens", models.PositiveIntegerField(blank=True, null=True, verbose_name="total tokens")), + ( + "cost_usd", + models.DecimalField( + blank=True, decimal_places=6, max_digits=10, null=True, verbose_name="cost (USD)" + ), + ), + ("usage_by_model", models.JSONField(blank=True, null=True, verbose_name="usage by model")), + ( + "created_at", + models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name="created at"), + ), + ("started_at", models.DateTimeField(blank=True, null=True, verbose_name="started at")), + ("finished_at", models.DateTimeField(blank=True, null=True, verbose_name="finished at")), + ( + "sandbox_environment", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="agent_runs", + to="sandbox_envs.sandboxenvironment", + verbose_name="sandbox environment", + ), + ), + ( + "task_result", + models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="run", + to="django_tasks_database.dbtaskresult", + verbose_name="task result", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="agent_runs", + to=settings.AUTH_USER_MODEL, + verbose_name="user", + ), + ), + ( + "session", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="runs", + to="agent_sessions.session", + verbose_name="session", + ), + ), + ], + options={"verbose_name": "Run", "verbose_name_plural": "Runs", "ordering": ["-created_at"]}, + ), + migrations.AddIndex( + model_name="session", index=models.Index(fields=["user", "-last_active_at"], name="session_user_active_idx") + ), + migrations.AddIndex( + model_name="session", + index=models.Index(fields=["origin", "-last_active_at"], name="session_origin_active_idx"), + ), + migrations.AddIndex( + model_name="session", + index=models.Index(fields=["repo_id", "-last_active_at"], name="session_repo_active_idx"), + ), + migrations.AddConstraint( + model_name="session", + constraint=models.CheckConstraint( + condition=models.Q( + ("active_run_id__isnull", True), models.Q(("active_run_id", ""), _negated=True), _connector="OR" + ), + name="session_active_run_id_nonempty", + ), + ), + migrations.AddIndex( + model_name="run", index=models.Index(fields=["session", "-created_at"], name="run_session_created_idx") + ), + migrations.AddIndex( + model_name="run", index=models.Index(fields=["trigger_type", "-created_at"], name="run_trigger_created_idx") + ), + migrations.AddIndex( + model_name="run", index=models.Index(fields=["status", "-created_at"], name="run_status_created_idx") + ), + migrations.AddIndex( + model_name="run", index=models.Index(fields=["user", "-created_at"], name="run_user_created_idx") + ), + migrations.AddConstraint( + model_name="run", + constraint=models.UniqueConstraint( + condition=models.Q(("status__in", ["READY", "RUNNING"]), ("trigger_type__in", ["api_job", "mcp_job"])), + fields=("session",), + name="run_one_active_per_session", + ), + ), + ] diff --git a/daiv/sessions/migrations/__init__.py b/daiv/sessions/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/daiv/sessions/models.py b/daiv/sessions/models.py new file mode 100644 index 000000000..5a920c120 --- /dev/null +++ b/daiv/sessions/models.py @@ -0,0 +1,335 @@ +from __future__ import annotations + +import logging +import uuid +from decimal import Decimal + +from django.conf import settings +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from notifications.choices import NotifyOn + +from automation.agent.results import parse_agent_result +from core.models import ThinkingLevelChoices +from sessions.managers import RunManager, SessionManager + +logger = logging.getLogger("daiv.sessions") + + +class RunStatus(models.TextChoices): + QUEUED = "QUEUED", _("Queued") + READY = "READY", _("Pending") + RUNNING = "RUNNING", _("Running") + SUCCESSFUL = "SUCCESSFUL", _("Successful") + FAILED = "FAILED", _("Failed") + + @classmethod + def terminal(cls) -> frozenset[str]: + return frozenset({cls.SUCCESSFUL, cls.FAILED}) + + +class SessionOrigin(models.TextChoices): + """How a session (or an individual run) was triggered. + + Shared by ``Session.origin`` (first trigger) and ``Run.trigger_type`` (per run): + a webhook-origin session can later contain chat runs. + Values for the non-chat members must stay identical to the old + ``activity.TriggerType`` strings — dashboards deep-link ``?trigger=`` + and the data migration copies them verbatim. + """ + + CHAT = "chat", _("Chat") + API_JOB = "api_job", _("API Run") + MCP_JOB = "mcp_job", _("MCP Run") + SCHEDULE = "schedule", _("Scheduled Run") + UI_JOB = "ui_job", _("UI Run") + ISSUE_WEBHOOK = "issue_webhook", _("Issue Webhook") + MR_WEBHOOK = "mr_webhook", _("MR/PR Webhook") + + +class Session(models.Model): + """One agent thread. PK == LangGraph checkpoint key (``thread_id``).""" + + thread_id = models.CharField(max_length=64, primary_key=True) + origin = models.CharField(_("origin"), max_length=20, choices=SessionOrigin.choices) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="agent_sessions", + verbose_name=_("user"), + ) + external_username = models.CharField(_("external username"), max_length=255, blank=True, default="") + repo_id = models.CharField(_("repository"), max_length=255) + ref = models.CharField(_("branch / ref"), max_length=255, blank=True, default="") + title = models.CharField(_("title"), max_length=120, blank=True, default="") + agent_model = models.CharField(_("agent model"), max_length=255, blank=True, default="") + agent_thinking_level = models.CharField( + _("agent thinking level"), max_length=20, blank=True, default="", choices=ThinkingLevelChoices.choices + ) + sandbox_environment = models.ForeignKey( + "sandbox_envs.SandboxEnvironment", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="agent_sessions", + verbose_name=_("sandbox environment"), + ) + scheduled_job = models.ForeignKey( + "schedules.ScheduledJob", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="agent_sessions", + verbose_name=_("scheduled job"), + ) + issue_iid = models.PositiveIntegerField(_("issue IID"), null=True, blank=True) + merge_request_iid = models.PositiveIntegerField(_("merge request IID"), null=True, blank=True) + + # Unified execution lock. NULL means "free slot"; any non-NULL value is the + # holder id (AG-UI run_id for chat turns, str(Run.pk) for background runs). + # Same semantics as the old ChatThread.active_run_id. + active_run_id = models.CharField(max_length=64, null=True, blank=True, default=None) # noqa: DJ001 + + # default (not auto_now_add) so the data migration can backfill historical values. + created_at = models.DateTimeField(_("created at"), default=timezone.now, editable=False) + # Ordering + lock staleness. Bumped explicitly by the lock service and run creation + # (not auto_now: queryset .aupdate() paths must control it, as ChatThread did). + last_active_at = models.DateTimeField(_("last active at"), default=timezone.now) + + objects = SessionManager() + + class Meta: + verbose_name = _("Session") + verbose_name_plural = _("Sessions") + ordering = ["-last_active_at"] + indexes = [ + models.Index(fields=["user", "-last_active_at"], name="session_user_active_idx"), + models.Index(fields=["origin", "-last_active_at"], name="session_origin_active_idx"), + models.Index(fields=["repo_id", "-last_active_at"], name="session_repo_active_idx"), + ] + constraints = [ + models.CheckConstraint( + condition=models.Q(active_run_id__isnull=True) | ~models.Q(active_run_id=""), + name="session_active_run_id_nonempty", + ) + ] + + def __str__(self) -> str: + return str(self.title or self.thread_id) + + async def atouch(self) -> None: + """Bump ``last_active_at`` (queryset update; safe from async contexts).""" + await type(self).objects.filter(pk=self.pk).aupdate(last_active_at=timezone.now()) + + +class Run(models.Model): + """One agent execution within a session. Successor of ``activity.Activity``; + UUIDs are preserved by the data migration so external job IDs keep resolving. + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + session = models.ForeignKey(Session, on_delete=models.CASCADE, related_name="runs", verbose_name=_("session")) + trigger_type = models.CharField(_("trigger type"), max_length=20, choices=SessionOrigin.choices) + status = models.CharField(_("status"), max_length=10, choices=RunStatus.choices, default=RunStatus.READY) + task_result = models.OneToOneField( + "django_tasks_database.DBTaskResult", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="run", + verbose_name=_("task result"), + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="agent_runs", + verbose_name=_("user"), + ) + external_username = models.CharField(_("external username"), max_length=255, blank=True, default="") + title = models.CharField(_("title"), max_length=120, blank=True, default="") + batch_id = models.UUIDField(_("batch ID"), null=True, blank=True, db_index=True) + repo_id = models.CharField(_("repository"), max_length=255) + ref = models.CharField(_("branch / ref"), max_length=255, blank=True, default="") + prompt = models.TextField(_("prompt"), blank=True, default="") + agent_model = models.CharField(_("agent model"), max_length=255, blank=True, default="") + agent_thinking_level = models.CharField( + _("agent thinking level"), max_length=20, blank=True, default="", choices=ThinkingLevelChoices.choices + ) + notify_on = models.CharField( # noqa: DJ001 — null distinguishes "no override" from explicit "never". + _("notify on"), max_length=16, choices=NotifyOn.choices, null=True, blank=True + ) + mention_comment_id = models.CharField(_("mention comment ID"), max_length=255, blank=True, default="") + merge_request_iid = models.PositiveIntegerField(_("merge request IID"), null=True, blank=True) + merge_request_web_url = models.URLField(_("merge request URL"), max_length=500, blank=True, default="") + sandbox_environment = models.ForeignKey( + "sandbox_envs.SandboxEnvironment", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="agent_runs", + verbose_name=_("sandbox environment"), + ) + + # Denormalized result / error / usage — copied verbatim from Activity. + result_summary = models.TextField(_("result summary"), blank=True, default="") + error_message = models.TextField(_("error message"), blank=True, default="") + code_changes = models.BooleanField(_("code changes"), default=False) + input_tokens = models.PositiveIntegerField(_("input tokens"), null=True, blank=True) + output_tokens = models.PositiveIntegerField(_("output tokens"), null=True, blank=True) + total_tokens = models.PositiveIntegerField(_("total tokens"), null=True, blank=True) + cost_usd = models.DecimalField(_("cost (USD)"), max_digits=10, decimal_places=6, null=True, blank=True) + usage_by_model = models.JSONField(_("usage by model"), null=True, blank=True) + + created_at = models.DateTimeField(_("created at"), default=timezone.now, editable=False) + started_at = models.DateTimeField(_("started at"), null=True, blank=True) + finished_at = models.DateTimeField(_("finished at"), null=True, blank=True) + + objects = RunManager() + + class Meta: + verbose_name = _("Run") + verbose_name_plural = _("Runs") + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["session", "-created_at"], name="run_session_created_idx"), + models.Index(fields=["trigger_type", "-created_at"], name="run_trigger_created_idx"), + models.Index(fields=["status", "-created_at"], name="run_status_created_idx"), + models.Index(fields=["user", "-created_at"], name="run_user_created_idx"), + ] + constraints = [ + # At most one active (READY or RUNNING) API/MCP run per session. QUEUED is + # intentionally outside the constraint so FIFO siblings can stack; webhook + # triggers share deterministic sessions and are intentionally excluded. + # Exact port of activity_one_active_per_thread. + models.UniqueConstraint( + fields=["session"], + condition=models.Q(status__in=["READY", "RUNNING"], trigger_type__in=["api_job", "mcp_job"]), + name="run_one_active_per_session", + ) + ] + + def __str__(self) -> str: + return f"{self.get_trigger_type_display()} on {self.repo_id} ({self.status})" + + # --- Ported verbatim from Activity (adjust names only) --- + # effective_notify_on: identical body; ``scheduled_job`` is now reached via + # ``self.session.scheduled_job`` — change the property to: + @property + def effective_notify_on(self) -> NotifyOn: + if self.notify_on: + return NotifyOn(self.notify_on) + schedule = self.session.scheduled_job if self.session_id else None + if schedule is not None: + return NotifyOn(schedule.notify_on) + if self.user_id is not None and self.user is not None: + return NotifyOn(self.user.notify_on_jobs) + return NotifyOn.NEVER + + @property + def is_retryable(self) -> bool: + return self.status in RunStatus.terminal() and self.trigger_type not in { + SessionOrigin.ISSUE_WEBHOOK, + SessionOrigin.MR_WEBHOOK, + SessionOrigin.CHAT, + } + + @property + def duration(self) -> float | None: + """Return the execution duration in seconds, or None if not finished.""" + if self.started_at and self.finished_at: + return (self.finished_at - self.started_at).total_seconds() + return None + + @property + def response_text(self) -> str: + """Return the response text from the task result, or the truncated denormalized summary if unavailable.""" + if self.task_result and self.task_result.return_value: + parsed = parse_agent_result(self.task_result.return_value) + if parsed["response"]: + return parsed["response"] + return self.result_summary + + def sync_and_save(self) -> bool: + """Sync from the linked DBTaskResult and persist changed fields. + + Returns True if any field was updated (and a save was issued), else False. + Emits ``run_finished`` when the status transitions to a terminal state. + + Raises whatever ``sync_from_task_result`` or ``self.save`` raise — callers running + in long-lived loops (signal handlers, management commands) must catch. + """ + previous_status = self.status # noqa: F841 — used by Task-3 emit + changed = self.sync_from_task_result() + if not changed: + return False + self.save(update_fields=changed) + # TODO(task-3): emit_run_finished_if_terminal(self, previous_status=previous_status) + return True + + def sync_from_task_result(self) -> list[str]: + """Pull latest status/timing/result from the linked DBTaskResult. + + Returns: + List of field names that were updated (empty if nothing changed). + """ + if self.task_result is None: + return [] + + tr = self.task_result + changed: list[str] = [] + + for field, value in [("status", tr.status), ("started_at", tr.started_at), ("finished_at", tr.finished_at)]: + if getattr(self, field) != value: + setattr(self, field, value) + changed.append(field) + + if tr.status == RunStatus.SUCCESSFUL and tr.return_value: + parsed = parse_agent_result(tr.return_value) + + if parsed["response"] and not self.result_summary: + self.result_summary = parsed["response"][:2000] + changed.append("result_summary") + if parsed["code_changes"] and not self.code_changes: + self.code_changes = True + changed.append("code_changes") + if parsed["merge_request_id"] and not self.merge_request_iid: + self.merge_request_iid = parsed["merge_request_id"] + changed.append("merge_request_iid") + if parsed["merge_request_web_url"] and not self.merge_request_web_url: + self.merge_request_web_url = parsed["merge_request_web_url"] + changed.append("merge_request_web_url") + + if (usage := parsed["usage"]) and self.input_tokens is None: + if usage.get("input_tokens") is not None: + self.input_tokens = usage["input_tokens"] + changed.append("input_tokens") + if usage.get("output_tokens") is not None: + self.output_tokens = usage["output_tokens"] + changed.append("output_tokens") + if usage.get("total_tokens") is not None: + self.total_tokens = usage["total_tokens"] + changed.append("total_tokens") + if usage.get("cost_usd") is not None: + try: + self.cost_usd = Decimal(usage["cost_usd"]) + except Exception: + logger.warning("Invalid cost_usd value %r for run %s", usage["cost_usd"], self.pk) + else: + changed.append("cost_usd") + if usage.get("by_model") is not None: + self.usage_by_model = usage["by_model"] + changed.append("usage_by_model") + + if tr.status == RunStatus.FAILED and tr.exception_class_path and not self.error_message: + self.error_message = tr.exception_class_path + if tr.traceback: + self.error_message += f"\n{tr.traceback}" + changed.append("error_message") + + return changed diff --git a/daiv/sessions/signals.py b/daiv/sessions/signals.py new file mode 100644 index 000000000..e88bf1fa5 --- /dev/null +++ b/daiv/sessions/signals.py @@ -0,0 +1 @@ +"""Signal receivers for the sessions app (populated in later tasks).""" diff --git a/tests/unit_tests/sessions/__init__.py b/tests/unit_tests/sessions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit_tests/sessions/test_models.py b/tests/unit_tests/sessions/test_models.py new file mode 100644 index 000000000..f0a91fb32 --- /dev/null +++ b/tests/unit_tests/sessions/test_models.py @@ -0,0 +1,115 @@ +import uuid + +from django.db import IntegrityError + +import pytest +from sessions.models import Run, RunStatus, Session, SessionOrigin + +pytestmark = pytest.mark.django_db + + +def _mk_session(**kwargs) -> Session: + defaults = {"thread_id": str(uuid.uuid4()), "origin": SessionOrigin.API_JOB, "repo_id": "group/repo"} + defaults.update(kwargs) + return Session.objects.create(**defaults) + + +def _mk_run(session: Session, **kwargs) -> Run: + defaults = {"trigger_type": SessionOrigin.API_JOB, "repo_id": session.repo_id, "status": RunStatus.READY} + defaults.update(kwargs) + return Run.objects.create(session=session, **defaults) + + +def test_run_status_terminal_set(): + assert RunStatus.terminal() == frozenset({RunStatus.SUCCESSFUL, RunStatus.FAILED}) + + +def test_session_origin_includes_chat(): + assert SessionOrigin.CHAT == "chat" + # All Activity trigger values survive with identical strings. + assert {c[0] for c in SessionOrigin.choices} == { + "chat", + "api_job", + "mcp_job", + "schedule", + "ui_job", + "issue_webhook", + "mr_webhook", + } + + +def test_one_active_api_run_per_session_constraint(): + session = _mk_session() + _mk_run(session, status=RunStatus.READY, trigger_type=SessionOrigin.API_JOB) + with pytest.raises(IntegrityError): + _mk_run(session, status=RunStatus.RUNNING, trigger_type=SessionOrigin.API_JOB) + + +def test_webhook_runs_exempt_from_active_constraint(): + session = _mk_session(origin=SessionOrigin.ISSUE_WEBHOOK) + _mk_run(session, status=RunStatus.READY, trigger_type=SessionOrigin.ISSUE_WEBHOOK) + # Second active webhook run on the same session is allowed (FIFO handled by QUEUED). + _mk_run(session, status=RunStatus.RUNNING, trigger_type=SessionOrigin.ISSUE_WEBHOOK) + + +def test_queued_runs_stack_freely(): + session = _mk_session() + _mk_run(session, status=RunStatus.READY) + _mk_run(session, status=RunStatus.QUEUED) + _mk_run(session, status=RunStatus.QUEUED) + + +def test_active_run_id_nonempty_constraint(): + with pytest.raises(IntegrityError): + _mk_session(active_run_id="") + + +def test_by_owner_admin_sees_all(admin_user, django_user_model): + other = django_user_model.objects.create_user(username="other", email="o@x.io", password="x") # noqa: S106 + _mk_session(user=other) + assert Session.objects.by_owner(admin_user).count() == 1 + + +def test_by_owner_matches_session_user(django_user_model): + user = django_user_model.objects.create_user(username="u1", email="u1@x.io", password="x") # noqa: S106 + other = django_user_model.objects.create_user(username="u2", email="u2@x.io", password="x") # noqa: S106 + mine = _mk_session(user=user) + _mk_session(user=other) + assert list(Session.objects.by_owner(user)) == [mine] + + +def test_by_owner_matches_run_actor(django_user_model): + """A session owned by nobody is visible to a user who acted in one of its runs.""" + user = django_user_model.objects.create_user(username="gituser", email="g@x.io", password="x") # noqa: S106 + session = _mk_session(user=None, origin=SessionOrigin.ISSUE_WEBHOOK) + _mk_run(session, trigger_type=SessionOrigin.ISSUE_WEBHOOK, external_username="gituser") + assert list(Session.objects.by_owner(user)) == [session] + + +def test_with_latest_status_annotation(): + session = _mk_session() + _mk_run(session, status=RunStatus.SUCCESSFUL) + _mk_run(session, status=RunStatus.RUNNING) + annotated = Session.objects.with_latest_status().get(pk=session.pk) + assert annotated.latest_run_status == RunStatus.RUNNING + + +def test_run_is_retryable_mirrors_activity_semantics(): + session = _mk_session() + done = _mk_run(session, status=RunStatus.SUCCESSFUL) + webhook = _mk_run(session, status=RunStatus.FAILED, trigger_type=SessionOrigin.MR_WEBHOOK) + running = _mk_run(session, status=RunStatus.RUNNING, trigger_type=SessionOrigin.UI_JOB) + assert done.is_retryable is True + assert webhook.is_retryable is False + assert running.is_retryable is False + + +def test_run_duration(): + from datetime import timedelta + + from django.utils import timezone + + session = _mk_session() + now = timezone.now() + run = _mk_run(session, started_at=now, finished_at=now + timedelta(seconds=90)) + assert run.duration == 90.0 From 7c49ae8c61f8709ac6290e15dc69b14ce52b2389 Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 7 Jul 2026 15:24:32 +0100 Subject: [PATCH 02/55] feat(sessions): unified execution lock (claim/heartbeat/release with stale takeover) --- daiv/sessions/locks.py | 48 +++++++++++++++++++++ tests/unit_tests/sessions/test_locks.py | 57 +++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 daiv/sessions/locks.py create mode 100644 tests/unit_tests/sessions/test_locks.py diff --git a/daiv/sessions/locks.py b/daiv/sessions/locks.py new file mode 100644 index 000000000..3a7026d62 --- /dev/null +++ b/daiv/sessions/locks.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from datetime import timedelta + +from django.db.models import Q +from django.utils import timezone + +from sessions.models import Session + +# A claim that hasn't bumped last_active_at within this window is considered +# orphaned (worker crashed / OOM-killed before the holder's finally ran) and +# can be taken over by a fresh claim. Live holders heartbeat well within this +# window. Port of chat.api.threads.STALE_RUN_MINUTES semantics. +STALE_RUN_MINUTES = 30 + + +class SessionLock: + """Unified execution slot for a session. + + Holders are chat turns (holder_id = the AG-UI run_id) and background jobs + (holder_id = str(Run.pk)). Exactly one holder executes against a thread's + checkpoint at a time — this closes the historical race where a chat + continuation and a webhook/API run on the same thread ran concurrently. + """ + + @staticmethod + async def try_claim(thread_id: str, holder_id: str) -> bool: + """Atomic claim: succeeds if the slot is free OR its heartbeat is stale.""" + stale_cutoff = timezone.now() - timedelta(minutes=STALE_RUN_MINUTES) + free_or_stale = Q(active_run_id__isnull=True) | Q(last_active_at__lt=stale_cutoff) + claimed = await Session.objects.filter(Q(thread_id=thread_id) & free_or_stale).aupdate( + active_run_id=holder_id, last_active_at=timezone.now() + ) + return bool(claimed) + + @staticmethod + async def heartbeat(thread_id: str, holder_id: str) -> None: + """Bump ``last_active_at`` while the slot is still ours.""" + await Session.objects.filter(thread_id=thread_id, active_run_id=holder_id).aupdate( + last_active_at=timezone.now() + ) + + @staticmethod + async def release(thread_id: str, holder_id: str) -> None: + """Clear the slot only if we still hold it.""" + await Session.objects.filter(thread_id=thread_id, active_run_id=holder_id).aupdate( + active_run_id=None, last_active_at=timezone.now() + ) diff --git a/tests/unit_tests/sessions/test_locks.py b/tests/unit_tests/sessions/test_locks.py new file mode 100644 index 000000000..68867903d --- /dev/null +++ b/tests/unit_tests/sessions/test_locks.py @@ -0,0 +1,57 @@ +import uuid +from datetime import timedelta + +from django.utils import timezone + +import pytest +from sessions.locks import SessionLock +from sessions.models import Session, SessionOrigin + +pytestmark = pytest.mark.django_db + + +async def _mk_session(**kwargs) -> Session: + defaults = {"thread_id": str(uuid.uuid4()), "origin": SessionOrigin.CHAT, "repo_id": "g/r"} + defaults.update(kwargs) + return await Session.objects.acreate(**defaults) + + +async def test_claim_free_slot(): + session = await _mk_session() + assert await SessionLock.try_claim(session.thread_id, "run-1") is True + await session.arefresh_from_db() + assert session.active_run_id == "run-1" + + +async def test_claim_busy_slot_fails(): + session = await _mk_session(active_run_id="run-1") + assert await SessionLock.try_claim(session.thread_id, "run-2") is False + + +async def test_claim_stale_slot_takes_over(): + stale = timezone.now() - timedelta(minutes=31) + session = await _mk_session(active_run_id="run-1", last_active_at=stale) + assert await SessionLock.try_claim(session.thread_id, "run-2") is True + await session.arefresh_from_db() + assert session.active_run_id == "run-2" + + +async def test_release_only_by_holder(): + session = await _mk_session(active_run_id="run-1") + await SessionLock.release(session.thread_id, "run-2") # not the holder: no-op + await session.arefresh_from_db() + assert session.active_run_id == "run-1" + await SessionLock.release(session.thread_id, "run-1") + await session.arefresh_from_db() + assert session.active_run_id is None + + +async def test_heartbeat_only_by_holder(): + old = timezone.now() - timedelta(minutes=10) + session = await _mk_session(active_run_id="run-1", last_active_at=old) + await SessionLock.heartbeat(session.thread_id, "run-2") # not the holder: no-op + await session.arefresh_from_db() + assert session.last_active_at == old + await SessionLock.heartbeat(session.thread_id, "run-1") + await session.arefresh_from_db() + assert session.last_active_at > old From 2839d394e35753def374d0d4506b1c29a92029be Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 7 Jul 2026 15:44:53 +0100 Subject: [PATCH 03/55] feat(sessions): submit services, FIFO dispatcher, task sync and backfill signals --- daiv/sessions/management/__init__.py | 0 daiv/sessions/management/commands/__init__.py | 0 .../release_orphan_queued_sessions.py | 69 ++ .../management/commands/sync_stuck_runs.py | 37 ++ daiv/sessions/models.py | 6 +- daiv/sessions/services.py | 400 +++++++++++ daiv/sessions/signals.py | 234 ++++++- tests/unit_tests/conftest.py | 5 +- tests/unit_tests/sessions/conftest.py | 74 +++ tests/unit_tests/sessions/test_management.py | 172 +++++ tests/unit_tests/sessions/test_services.py | 626 ++++++++++++++++++ tests/unit_tests/sessions/test_signals.py | 429 ++++++++++++ 12 files changed, 2048 insertions(+), 4 deletions(-) create mode 100644 daiv/sessions/management/__init__.py create mode 100644 daiv/sessions/management/commands/__init__.py create mode 100644 daiv/sessions/management/commands/release_orphan_queued_sessions.py create mode 100644 daiv/sessions/management/commands/sync_stuck_runs.py create mode 100644 daiv/sessions/services.py create mode 100644 tests/unit_tests/sessions/conftest.py create mode 100644 tests/unit_tests/sessions/test_management.py create mode 100644 tests/unit_tests/sessions/test_services.py create mode 100644 tests/unit_tests/sessions/test_signals.py diff --git a/daiv/sessions/management/__init__.py b/daiv/sessions/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/daiv/sessions/management/commands/__init__.py b/daiv/sessions/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/daiv/sessions/management/commands/release_orphan_queued_sessions.py b/daiv/sessions/management/commands/release_orphan_queued_sessions.py new file mode 100644 index 000000000..8b271119e --- /dev/null +++ b/daiv/sessions/management/commands/release_orphan_queued_sessions.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import logging + +from django.core.management.base import BaseCommand +from django.db import IntegrityError +from django.db.models import Q + +from sessions.models import Run, RunStatus +from sessions.signals import _enqueue_queued_run + +logger = logging.getLogger("daiv.sessions") + + +class Command(BaseCommand): + help = ( + "Release QUEUED Runs whose session has no active (READY/RUNNING) sibling. " + "Mitigates a rare TOCTOU loss where the dispatcher missed a terminal transition " + "or the row was created QUEUED but never picked up." + ) + + def handle(self, *args, **options): + active_sessions = set( + Run.objects.filter(status__in=[RunStatus.READY, RunStatus.RUNNING], session_id__isnull=False).values_list( + "session_id", flat=True + ) + ) + + orphans = ( + Run.objects + .filter(status=RunStatus.QUEUED) + .filter(~Q(session_id__in=active_sessions)) + .order_by("session_id", "created_at") + ) + + seen_sessions: set[str] = set() + released = skipped = errored = 0 + for run in orphans.iterator(): + if run.session_id in seen_sessions: + skipped += 1 + continue + seen_sessions.add(run.session_id) + try: + claimed = Run.objects.filter(pk=run.pk, status=RunStatus.QUEUED).update(status=RunStatus.READY) + except IntegrityError: + # A concurrent submission claimed the session between our snapshot of + # active_sessions and this CAS; leave the row QUEUED for a future pass. + skipped += 1 + continue + if claimed != 1: + skipped += 1 + continue + run.refresh_from_db() + try: + ok = _enqueue_queued_run(run) + except Exception: + errored += 1 + logger.exception("Failed to release orphan QUEUED run %s", run.pk) + continue + if ok: + released += 1 + else: + errored += 1 + + summary = f"Released: {released}, skipped: {skipped}, errored: {errored}" + if errored: + self.stdout.write(self.style.WARNING(f"{summary} — see logs; broker may be unavailable.")) + else: + self.stdout.write(self.style.SUCCESS(summary)) diff --git a/daiv/sessions/management/commands/sync_stuck_runs.py b/daiv/sessions/management/commands/sync_stuck_runs.py new file mode 100644 index 000000000..4c11a24b1 --- /dev/null +++ b/daiv/sessions/management/commands/sync_stuck_runs.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import logging + +from django.core.management.base import BaseCommand, CommandError + +from sessions.models import Run, RunStatus + +logger = logging.getLogger("daiv.sessions") + + +class Command(BaseCommand): + help = "Re-sync non-terminal Run rows from their linked DBTaskResult." + + def handle(self, *args, **options): + qs = ( + Run.objects + .filter(task_result__isnull=False) + .exclude(status__in=list(RunStatus.terminal())) + .select_related("task_result", "session", "session__scheduled_job") + ) + + synced = skipped = errored = 0 + for run in qs.iterator(): + try: + if run.sync_and_save(): + synced += 1 + else: + skipped += 1 + except Exception: + errored += 1 + logger.exception("Failed to sync run %s", run.id) + + summary = f"Synced: {synced}, already up to date: {skipped}, errored: {errored}" + if errored: + raise CommandError(summary) + self.stdout.write(self.style.SUCCESS(summary)) diff --git a/daiv/sessions/models.py b/daiv/sessions/models.py index 5a920c120..62ef76e38 100644 --- a/daiv/sessions/models.py +++ b/daiv/sessions/models.py @@ -264,12 +264,14 @@ def sync_and_save(self) -> bool: Raises whatever ``sync_from_task_result`` or ``self.save`` raise — callers running in long-lived loops (signal handlers, management commands) must catch. """ - previous_status = self.status # noqa: F841 — used by Task-3 emit + previous_status = self.status changed = self.sync_from_task_result() if not changed: return False self.save(update_fields=changed) - # TODO(task-3): emit_run_finished_if_terminal(self, previous_status=previous_status) + from sessions.signals import emit_run_finished_if_terminal # local import to avoid circular deps + + emit_run_finished_if_terminal(self, previous_status=previous_status) return True def sync_from_task_result(self) -> list[str]: diff --git a/daiv/sessions/services.py b/daiv/sessions/services.py new file mode 100644 index 000000000..5e78a2acb --- /dev/null +++ b/daiv/sessions/services.py @@ -0,0 +1,400 @@ +from __future__ import annotations + +import asyncio +import logging +import uuid +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from django.db import IntegrityError +from django.db.models import Q +from django.utils import timezone + +from asgiref.sync import async_to_sync +from jobs.tasks import run_job_task + +from automation.titling.tasks import generate_batch_title_task +from sessions.models import Run, RunStatus, Session, SessionOrigin +from sessions.signals import emit_run_finished_if_terminal + +_PROMPT_DRIVEN = {SessionOrigin.API_JOB, SessionOrigin.MCP_JOB, SessionOrigin.UI_JOB} + +if TYPE_CHECKING: + from datetime import datetime + + from notifications.choices import NotifyOn + + from accounts.models import User + from schedules.models import ScheduledJob + +logger = logging.getLogger("daiv.sessions") + +MAX_REPOS_PER_BATCH = 20 + + +@dataclass(frozen=True) +class RepoTarget: + repo_id: str + ref: str = "" + sandbox_environment_id: str | None = None + + +@dataclass(frozen=True) +class BatchSubmitFailure: + repo_id: str + ref: str + error: str + + +@dataclass(frozen=True) +class BatchSubmitResult: + batch_id: uuid.UUID + runs: list[Run] = field(default_factory=list) + failed: list[BatchSubmitFailure] = field(default_factory=list) + + +def validate_repo_list(raw) -> list[dict]: + """Validate and normalize a list of ``{repo_id, ref}`` entries. + + Raises ``ValueError`` on any violation. Returns a fresh list of normalized dicts + (guaranteed string keys/values, no duplicates, 1-20 entries). + """ + if not isinstance(raw, list) or not raw: + raise ValueError("At least one repository is required.") + if len(raw) > MAX_REPOS_PER_BATCH: + raise ValueError(f"At most {MAX_REPOS_PER_BATCH} repositories allowed per submission.") + + seen: set[tuple[str, str]] = set() + out: list[dict] = [] + for entry in raw: + if not isinstance(entry, dict) or set(entry.keys()) != {"repo_id", "ref"}: + raise ValueError("Each entry must be an object with keys 'repo_id' and 'ref'.") + repo_id = entry["repo_id"] + ref = entry["ref"] or "" + if not isinstance(repo_id, str) or not repo_id.strip(): + raise ValueError("repo_id must be a non-empty string.") + if not isinstance(ref, str): + raise ValueError("ref must be a string (empty for default branch).") + key = (repo_id, ref) + if key in seen: + label = f"{repo_id} on {ref}" if ref else repo_id + raise ValueError(f"Repository already in the list: {label}.") + seen.add(key) + out.append({"repo_id": repo_id, "ref": ref}) + return out + + +def _validate(repos: list[RepoTarget]) -> None: + if not repos: + raise ValueError("repos must contain at least one entry") + if len(repos) > MAX_REPOS_PER_BATCH: + raise ValueError(f"repos exceeds the maximum of {MAX_REPOS_PER_BATCH}") + + +async def aget_or_create_session( + *, + thread_id: str, + origin: str, + repo_id: str, + ref: str = "", + user=None, + external_username: str = "", + title: str = "", + agent_model: str = "", + agent_thinking_level: str = "", + sandbox_environment_id: str | None = None, + scheduled_job=None, + issue_iid: int | None = None, + merge_request_iid: int | None = None, +) -> Session: + """Idempotent session bootstrap keyed on thread_id. First caller sets origin + and context; later callers just bump last_active_at (a webhook session later + continued via API keeps origin=issue_webhook). + """ + session, created = await Session.objects.aget_or_create( + thread_id=thread_id, + defaults={ + "origin": origin, + "repo_id": repo_id, + "ref": ref, + "user": user, + "external_username": external_username, + "title": title[: Session._meta.get_field("title").max_length], + "agent_model": agent_model, + "agent_thinking_level": agent_thinking_level, + "sandbox_environment_id": sandbox_environment_id, + "scheduled_job": scheduled_job, + "issue_iid": issue_iid, + "merge_request_iid": merge_request_iid, + }, + ) + if not created: + await session.atouch() + return session + + +async def acreate_run( + *, + trigger_type: str, + task_result_id: uuid.UUID | None, + repo_id: str, + ref: str = "", + prompt: str = "", + agent_model: str = "", + agent_thinking_level: str = "", + issue_iid: int | None = None, + merge_request_iid: int | None = None, + mention_comment_id: str = "", + scheduled_job: ScheduledJob | None = None, + user: User | None = None, + external_username: str = "", + notify_on: NotifyOn | None = None, + batch_id: uuid.UUID | None = None, + thread_id: str | None = None, + title: str = "", + sandbox_environment_id: str | None = None, + status: str = RunStatus.READY, +) -> Run: + """Async: create a Session (idempotent) then a Run linked to it.""" + effective_thread_id = thread_id or str(uuid.uuid4()) + session = await aget_or_create_session( + thread_id=effective_thread_id, + origin=trigger_type, + repo_id=repo_id, + ref=ref, + user=user, + external_username=external_username, + title=title, + agent_model=agent_model, + agent_thinking_level=agent_thinking_level, + sandbox_environment_id=sandbox_environment_id, + scheduled_job=scheduled_job, + issue_iid=issue_iid, + merge_request_iid=merge_request_iid, + ) + return await Run.objects.acreate( + session=session, + trigger_type=trigger_type, + status=status, + task_result_id=task_result_id, + user=user, + external_username=external_username, + repo_id=repo_id, + ref=ref, + prompt=prompt, + agent_model=agent_model, + agent_thinking_level=agent_thinking_level, + notify_on=notify_on, + batch_id=batch_id, + title=title[: Run._meta.get_field("title").max_length], + sandbox_environment_id=sandbox_environment_id, + merge_request_iid=merge_request_iid, + mention_comment_id=mention_comment_id, + ) + + +async def _mark_failed_and_release(run: Run, *, prefix: str, err: Exception, previous_status: str) -> None: + """Transition a row to FAILED with finished_at and emit ``run_finished``. + + Used by the services-layer post-create error paths (enqueue or task-result-id-link + failure). The emit is best-effort — if it raises, we log loudly and recommend the + operator run ``release_orphan_queued_sessions`` to recover stranded siblings. + """ + now = timezone.now() + run.status = RunStatus.FAILED + run.error_message = f"{prefix}: {type(err).__name__}: {err}" + run.finished_at = now + if run.started_at is None: + run.started_at = now + try: + await run.asave(update_fields=["status", "error_message", "finished_at", "started_at"]) + except Exception: + logger.exception("submit_batch_runs: terminal save failed for run=%s", run.pk) + try: + await asyncio.to_thread(emit_run_finished_if_terminal, run, previous_status=previous_status) + except Exception: + logger.exception( + "submit_batch_runs: emit_run_finished_if_terminal failed for run=%s; " + "queued siblings on this session may be stranded — run release_orphan_queued_sessions", + run.pk, + ) + + +async def asubmit_batch_runs( + *, + user: User | None, + prompt: str, + repos: list[RepoTarget], + agent_model: str = "", + agent_thinking_level: str = "", + notify_on: NotifyOn | None = None, + trigger_type: str, + scheduled_job: ScheduledJob | None = None, + external_username: str = "", + thread_id: str | None = None, +) -> BatchSubmitResult: + """Enqueue N ``run_job_task`` instances sharing a ``batch_id``; record N ``Run`` rows. + + Each ``RepoTarget`` carries its own ``sandbox_environment_id`` (resolved upstream by + :func:`sandbox_envs.services.resolve_repo_envs`), so the batch can mix per-repo envs. + + Best-effort: any per-repo exception (enqueue failure or post-enqueue run-creation + failure) lands in ``result.failed`` while siblings continue. + """ + _validate(repos) + if thread_id is not None: + if not thread_id: + raise ValueError("thread_id must be a non-empty UUID string") + try: + uuid.UUID(thread_id) + except (ValueError, TypeError) as err: + raise ValueError("thread_id must be a UUID string") from err + if len(repos) != 1: + raise ValueError("thread_id continuation requires exactly one repo") + batch_id = uuid.uuid4() + + schedule_run_base = 0 + if trigger_type == SessionOrigin.SCHEDULE and scheduled_job is not None: + schedule_run_base = await Run.objects.filter(session__scheduled_job=scheduled_job).acount() + + async def _submit_one(idx: int, target: RepoTarget) -> Run | BatchSubmitFailure: + effective_thread_id = thread_id or str(uuid.uuid4()) + + run_title = "" + if trigger_type == SessionOrigin.SCHEDULE and scheduled_job is not None: + run_title = f"{scheduled_job.name} · run #{schedule_run_base + idx + 1}" + + common_kwargs: dict = { + "trigger_type": trigger_type, + "repo_id": target.repo_id, + "ref": target.ref, + "prompt": prompt, + "agent_model": agent_model, + "agent_thinking_level": agent_thinking_level, + "scheduled_job": scheduled_job, + "user": user, + "external_username": external_username, + "notify_on": notify_on, + "batch_id": batch_id, + "thread_id": effective_thread_id, + "title": run_title, + "sandbox_environment_id": target.sandbox_environment_id, + } + + # Claim the session atomically by trying to create a READY row. The partial + # unique constraint ``run_one_active_per_session`` raises IntegrityError + # when a sibling (READY/RUNNING) is already active on this session — in that + # case we fall back to QUEUED, no task enqueue. + try: + run = await acreate_run(**common_kwargs, task_result_id=None, status=RunStatus.READY) + except IntegrityError: + try: + return await acreate_run(**common_kwargs, task_result_id=None, status=RunStatus.QUEUED) + except Exception as inner_err: + logger.exception("submit_batch_runs: queued run creation failed for repo_id=%s", target.repo_id) + return BatchSubmitFailure( + repo_id=target.repo_id, + ref=target.ref, + error=f"RunCreationFailed: {type(inner_err).__name__}: {inner_err}", + ) + except Exception as err: + logger.exception("submit_batch_runs: run creation failed for repo_id=%s", target.repo_id) + return BatchSubmitFailure( + repo_id=target.repo_id, ref=target.ref, error=f"RunCreationFailed: {type(err).__name__}: {err}" + ) + + try: + task = await run_job_task.aenqueue( + repo_id=target.repo_id, + prompt=prompt, + ref=target.ref or None, + agent_model=agent_model or None, + agent_thinking_level=agent_thinking_level or None, + thread_id=effective_thread_id, + sandbox_environment_id=target.sandbox_environment_id, + ) + except Exception as err: # noqa: BLE001 + logger.exception("submit_batch_runs: enqueue failed for repo_id=%s batch_id=%s", target.repo_id, batch_id) + await _mark_failed_and_release(run, prefix="enqueue_failed", err=err, previous_status=RunStatus.READY) + return BatchSubmitFailure(repo_id=target.repo_id, ref=target.ref, error=f"{type(err).__name__}: {err}") + + try: + run.task_result_id = task.id + await run.asave(update_fields=["task_result_id"]) + except Exception as save_err: + # The broker now holds a task this Run row doesn't link to. + # Mark the row FAILED so callers see the failure and queued siblings advance; + # the orphan task itself will execute and ``_sync_run_for_task`` will no-op + # (no Run with that task_result_id). + logger.exception( + "submit_batch_runs: failed to link task_result_id=%s to run=%s (orphan task will run)", task.id, run.pk + ) + await _mark_failed_and_release(run, prefix="link_failed", err=save_err, previous_status=RunStatus.READY) + return BatchSubmitFailure( + repo_id=target.repo_id, ref=target.ref, error=f"LinkFailed: {type(save_err).__name__}: {save_err}" + ) + return run + + # return_exceptions=True guards against BaseException (CancelledError, etc.) aborting the + # whole batch; _submit_one already catches Exception itself. + outcomes = await asyncio.gather(*[_submit_one(i, t) for i, t in enumerate(repos)], return_exceptions=True) + + runs: list[Run] = [] + failed: list[BatchSubmitFailure] = [] + for target, outcome in zip(repos, outcomes, strict=True): + if isinstance(outcome, BaseException): + logger.error("submit_batch_runs: unexpected exception for repo_id=%s", target.repo_id, exc_info=outcome) + failed.append( + BatchSubmitFailure(repo_id=target.repo_id, ref=target.ref, error=f"{type(outcome).__name__}: {outcome}") + ) + elif isinstance(outcome, BatchSubmitFailure): + failed.append(outcome) + else: + runs.append(outcome) + + if runs and trigger_type in _PROMPT_DRIVEN and prompt: + try: + await generate_batch_title_task.aenqueue(batch_id=str(batch_id), prompt=prompt) + except Exception: # noqa: BLE001 + logger.exception( + "Failed to enqueue batch title task for batch_id=%s user=%s trigger=%s runs=%d", + batch_id, + user.pk if user is not None else None, + trigger_type, + len(runs), + ) + + return BatchSubmitResult(batch_id=batch_id, runs=runs, failed=failed) + + +def submit_batch_runs(**kwargs) -> BatchSubmitResult: + """Sync wrapper around :func:`asubmit_batch_runs` for cron and sync views.""" + return async_to_sync(asubmit_batch_runs)(**kwargs) + + +async def alist_user_runs( + user, + *, + repo_id: str | None = None, + status: str | None = None, + limit: int = 20, + before: tuple[datetime, uuid.UUID] | None = None, +) -> list[Run]: + """Return ``user``'s runs, newest first, optionally filtered by repo/status. + + Capped at ``limit`` rows. Callers needing truncation/pagination should pass + ``limit + 1`` and trim. ``before`` is a keyset cursor ``(created_at, id)`` of the + last row already seen; only rows strictly older (in ``-created_at, -id`` order) are + returned, so pagination is stable even as new rows arrive at the head. The ``id`` + tie-break is required because a batch submit stamps several rows with the same + ``created_at``. Backed by ``run_user_created_idx`` (user, -created_at). + """ + qs = Run.objects.filter(user=user) + if repo_id: + qs = qs.filter(repo_id=repo_id) + if status: + qs = qs.filter(status=status) + if before is not None: + created_at, last_id = before + qs = qs.filter(Q(created_at__lt=created_at) | Q(created_at=created_at, id__lt=last_id)) + return [run async for run in qs.order_by("-created_at", "-id")[:limit]] diff --git a/daiv/sessions/signals.py b/daiv/sessions/signals.py index e88bf1fa5..6c9a23726 100644 --- a/daiv/sessions/signals.py +++ b/daiv/sessions/signals.py @@ -1 +1,233 @@ -"""Signal receivers for the sessions app (populated in later tasks).""" +from __future__ import annotations + +import logging +from typing import Any + +from django.conf import settings +from django.db import IntegrityError +from django.db.models.signals import post_save +from django.dispatch import Signal, receiver +from django.utils import timezone + +from asgiref.sync import async_to_sync +from django_tasks.signals import task_finished, task_started +from jobs.tasks import run_job_task + +logger = logging.getLogger("daiv.sessions") + +# Emitted when a Run transitions to a terminal status (SUCCESSFUL or FAILED). +# Arguments: run (Run instance). +run_finished = Signal() + + +@receiver(post_save, sender=settings.AUTH_USER_MODEL) +def backfill_session_user(sender: type, instance: Any, created: bool, **kwargs: Any) -> None: + """Link orphaned runs and sessions to a newly created user by matching external_username. + + Only runs on user creation, not updates — renaming a user will not re-trigger backfill. + Errors are caught so that a problem in session backfill never breaks user creation. + """ + if not created: + return + + from sessions.models import Run, Session + + try: + updated_runs = Run.objects.filter(user__isnull=True, external_username=instance.username).update(user=instance) + except Exception: + logger.exception("Failed to backfill runs for new user %s (pk=%s)", instance.username, instance.pk) + updated_runs = 0 + + if updated_runs: + logger.info("Backfilled %d runs for new user %s (pk=%s)", updated_runs, instance.username, instance.pk) + + try: + updated_sessions = Session.objects.filter(user__isnull=True, external_username=instance.username).update( + user=instance + ) + except Exception: + logger.exception("Failed to backfill sessions for new user %s (pk=%s)", instance.username, instance.pk) + return + + if updated_sessions: + logger.info("Backfilled %d sessions for new user %s (pk=%s)", updated_sessions, instance.username, instance.pk) + + +def emit_run_finished_if_terminal(run: Any, previous_status: str | None, *, skip_dispatch: bool = False) -> None: + """Emit run_finished if the run just transitioned to a terminal status. + + ``skip_dispatch`` is forwarded to receivers; the in-session dispatcher uses it to + suppress recursive re-entry while still letting notification receivers fire. + """ + from sessions.models import RunStatus + + if run.status not in RunStatus.terminal(): + return + if previous_status in RunStatus.terminal(): + return # Already emitted on a prior save + results = run_finished.send_robust(sender=type(run), run=run, skip_dispatch=skip_dispatch) + for recv, response in results: + if isinstance(response, Exception): + logger.error( + "Receiver %s failed for run_finished (run=%s)", + getattr(recv, "__name__", recv), + run.pk, + exc_info=response, + ) + + +def _sync_run_for_task(task_result_id: Any) -> None: + """Pull latest status/timing/result from the linked DBTaskResult into the Run row. + + Silently no-ops if no Run is linked to the given task_result_id (e.g. tasks that + don't create a Run, or the brief cross-process race where ``task_started`` fires + before the Run row is committed on the web side — ``task_finished`` will catch up). + Errors are swallowed and logged so the worker loop is never crashed by sync failures. + """ + from sessions.models import Run + + try: + run = ( + Run.objects + .select_related("task_result", "session", "session__scheduled_job", "session__scheduled_job__user", "user") + .filter(task_result_id=task_result_id) + .first() + ) + if run is None: + return + run.sync_and_save() + except Exception: + logger.exception("Failed to sync run for task_result_id=%s", task_result_id) + + +@receiver([task_started, task_finished]) +def sync_run_on_task_signal(sender: type, task_result: Any, **kwargs: Any) -> None: + """Sync the linked Run on task state transitions (RUNNING / terminal). + + The django-tasks worker commits DBTaskResult state (via ``claim``/``set_successful``/ + ``set_failed``) before dispatching these signals, so reading back the row here is safe + without a transaction guard. + """ + _sync_run_for_task(task_result.id) + + +#: Cap on consecutive enqueue failures before the dispatcher bails. A persistent +#: broker outage would otherwise mass-fail every QUEUED row on the session within +#: a single signal-handler call; bailing leaves the rest QUEUED for +#: ``release_orphan_queued_sessions`` to recover when the broker is back. +MAX_CONSECUTIVE_DISPATCH_FAILURES = 3 + + +@receiver(run_finished) +def dispatch_next_in_session(sender: type, run: Any, **kwargs: Any) -> None: + """Release queued continuations on this session, one at a time, until one succeeds. + + Atomic compare-and-swap (``filter(pk=, status=QUEUED).update(status=READY)``) wins + the race against concurrent dispatchers reading the same row. A losing race or a + unique-constraint violation against a peer claim is silently retried on the next + QUEUED row. Enqueue failures mark the row FAILED (with ``finished_at`` set) and + loop to the next sibling so a single bad row does not block the session — bounded + by ``MAX_CONSECUTIVE_DISPATCH_FAILURES`` so a broker outage doesn't mass-fail the + whole backlog. + + ``skip_dispatch=True`` (passed by re-emits from dispatch-failure paths) suppresses + re-entry while still letting notification receivers fire. + """ + from sessions.models import Run, RunStatus + + if kwargs.get("skip_dispatch"): + return + + session_id = getattr(run, "session_id", None) + if not session_id: + return + + consecutive_failures = 0 + while True: + next_q = Run.objects.filter(session_id=session_id, status=RunStatus.QUEUED).order_by("created_at").first() + if next_q is None: + return + + try: + claimed = Run.objects.filter(pk=next_q.pk, status=RunStatus.QUEUED).update(status=RunStatus.READY) + except IntegrityError: + # A concurrent insert (e.g. a fresh _submit_one) already created a + # READY row on this session; the partial unique constraint blocks us. + logger.debug("dispatch_next_in_session: peer claim on session=%s, backing off", session_id) + return + + if claimed != 1: + # Another dispatcher took this exact row; try the next one. + continue + + next_q.refresh_from_db() + if _enqueue_queued_run(next_q): + return + + consecutive_failures += 1 + if consecutive_failures >= MAX_CONSECUTIVE_DISPATCH_FAILURES: + logger.warning( + "dispatch_next_in_session: bailing on session=%s after %d consecutive dispatch failures; " + "remaining QUEUED siblings left for release_orphan_queued_sessions", + session_id, + consecutive_failures, + ) + return + # Enqueue failed; loop to the next QUEUED row. + + +def _enqueue_queued_run(run: Any) -> bool: + """Enqueue ``run_job_task`` for an already-claimed (READY) Run. + + Returns ``True`` on success. On failure, marks the row FAILED with + ``finished_at`` set and re-emits ``run_finished`` with ``skip_dispatch=True`` + so notification receivers fire without recursively re-entering the dispatcher. + """ + from sessions.models import RunStatus + + agent_model = run.agent_model or None + agent_thinking_level = run.agent_thinking_level or None + + try: + task = async_to_sync(run_job_task.aenqueue)( + repo_id=run.repo_id, + prompt=run.prompt, + thread_id=str(run.session_id), + ref=run.ref or None, + agent_model=agent_model, + agent_thinking_level=agent_thinking_level, + sandbox_environment_id=str(run.sandbox_environment_id) if run.sandbox_environment_id else None, + ) + except Exception as err: # noqa: BLE001 + logger.exception("dispatch_next_in_session: enqueue failed for run=%s", run.pk) + now = timezone.now() + run.status = RunStatus.FAILED + run.error_message = f"dispatch_failed: {type(err).__name__}: {err}" + run.finished_at = now + if run.started_at is None: + run.started_at = now + run.save(update_fields=["status", "error_message", "finished_at", "started_at"]) + emit_run_finished_if_terminal(run, previous_status=RunStatus.READY, skip_dispatch=True) + return False + + try: + run.task_result_id = task.id + run.save(update_fields=["task_result_id"]) + except Exception as save_err: + # Broker holds an orphan task that won't be linked back via task_result_id. + # Mark FAILED so siblings advance; the orphan runs but ``_sync_run_for_task`` + # no-ops because no Run row matches the task_result_id. + logger.exception("dispatch_next_in_session: failed to link task_result_id=%s to run=%s", task.id, run.pk) + now = timezone.now() + run.status = RunStatus.FAILED + run.error_message = f"link_failed: {type(save_err).__name__}: {save_err}" + run.finished_at = now + if run.started_at is None: + run.started_at = now + try: + run.save(update_fields=["status", "error_message", "finished_at", "started_at"]) + except Exception: + logger.exception("dispatch_next_in_session: terminal save also failed for run=%s", run.pk) + emit_run_finished_if_terminal(run, previous_status=RunStatus.READY, skip_dispatch=True) + return False + return True diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 35d25e7cd..63c4be76b 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -102,7 +102,10 @@ def mock_generate_title_task(): ): for m in (m1, m2, m3): m.aenqueue = AsyncMock(return_value=None) - yield m1 + # Patch sessions.services with the *same* mock object so tests that + # override mock_generate_title_task.aenqueue mid-test affect both sites. + with patch("sessions.services.generate_batch_title_task", m1): + yield m1 @pytest.fixture(autouse=True) diff --git a/tests/unit_tests/sessions/conftest.py b/tests/unit_tests/sessions/conftest.py new file mode 100644 index 000000000..a333027a5 --- /dev/null +++ b/tests/unit_tests/sessions/conftest.py @@ -0,0 +1,74 @@ +import uuid + +import pytest +from django_tasks_db.models import DBTaskResult, get_date_max + + +@pytest.fixture +def create_db_task_result(): + """Build a DBTaskResult row for signal / view / command tests.""" + + def _create( + *, + status="SUCCESSFUL", + return_value=None, + started_at=None, + finished_at=None, + exception_class_path="", + traceback="", + ): + return DBTaskResult.objects.create( + id=uuid.uuid4(), + status=status, + task_path="jobs.tasks.run_job_task", + args_kwargs={"args": [], "kwargs": {}}, + queue_name="default", + backend_name="default", + run_after=get_date_max(), + return_value=return_value or {}, + started_at=started_at, + finished_at=finished_at, + exception_class_path=exception_class_path, + traceback=traceback, + ) + + return _create + + +@pytest.fixture(autouse=True) +def _cleanup_sessions_rows(django_db_blocker): + """Delete committed Session/Run rows after each test. + + Some sessions tests use ``@pytest.mark.django_db(transaction=True)`` or + async DB access (which commits via a separate connection) and those rows + are not rolled back by pytest-django's savepoint/transaction mechanism. + Without this cleanup they leak into later tests (e.g. global-count + assertions). Runs for every test — for purely transaction-wrapped tests it + is a harmless no-op. + """ + yield + with django_db_blocker.unblock(): + import threading + + from django.db import close_old_connections + + def _delete() -> None: + import logging + + from sessions.models import Run, Session + + # Runs on its own thread so it gets a fresh autocommit DB connection: + # the deletes then commit unconditionally instead of being rolled back + # with an enclosing atomic block on the main (test) thread. + close_old_connections() + try: + Run.objects.all().delete() + Session.objects.all().delete() + except Exception: + logging.getLogger("tests").warning("session-row cleanup failed", exc_info=True) + finally: + close_old_connections() + + t = threading.Thread(target=_delete) + t.start() + t.join() diff --git a/tests/unit_tests/sessions/test_management.py b/tests/unit_tests/sessions/test_management.py new file mode 100644 index 000000000..2cef30cc7 --- /dev/null +++ b/tests/unit_tests/sessions/test_management.py @@ -0,0 +1,172 @@ +import uuid +from datetime import UTC, datetime +from io import StringIO +from unittest.mock import AsyncMock, MagicMock, patch + +from django.core.management import call_command +from django.core.management.base import CommandError + +import pytest +from sessions.models import Run, RunStatus, Session, SessionOrigin + + +def _make_session(*, thread_id: str | None = None) -> Session: + return Session.objects.create( + thread_id=thread_id or str(uuid.uuid4()), origin=SessionOrigin.API_JOB, repo_id="group/project" + ) + + +@pytest.mark.django_db(transaction=True) +class TestSyncStuckRunsCommand: + def test_syncs_stuck_running_run(self, create_db_task_result): + finished = datetime(2026, 4, 13, 12, 0, 0, tzinfo=UTC) + tr = create_db_task_result( + status="SUCCESSFUL", + return_value={"response": "Done.", "code_changes": False}, + started_at=datetime(2026, 4, 13, 11, 0, 0, tzinfo=UTC), + finished_at=finished, + ) + session = _make_session() + run = Run.objects.create( + session=session, + trigger_type=SessionOrigin.API_JOB, + repo_id="group/project", + status=RunStatus.RUNNING, + task_result=tr, + ) + + out = StringIO() + call_command("sync_stuck_runs", stdout=out) + + run.refresh_from_db() + assert run.status == RunStatus.SUCCESSFUL + assert run.finished_at == finished + assert run.result_summary == "Done." + assert "Synced: 1" in out.getvalue() + + def test_skips_terminal_runs(self, create_db_task_result): + tr = create_db_task_result(status="SUCCESSFUL", return_value={"response": "Already done."}) + session = _make_session() + Run.objects.create( + session=session, + trigger_type=SessionOrigin.API_JOB, + repo_id="group/project", + status=RunStatus.SUCCESSFUL, + task_result=tr, + result_summary="Already done.", + ) + + out = StringIO() + call_command("sync_stuck_runs", stdout=out) + + assert "Synced: 0" in out.getvalue() + + def test_counts_already_synced_run_as_skipped(self, create_db_task_result): + """A non-terminal Run already in sync with its DBTaskResult counts toward `skipped`.""" + tr = create_db_task_result(status="READY") + session = _make_session() + Run.objects.create( + session=session, + trigger_type=SessionOrigin.API_JOB, + repo_id="group/project", + status=RunStatus.READY, + task_result=tr, + ) + + out = StringIO() + call_command("sync_stuck_runs", stdout=out) + + assert "Synced: 0, already up to date: 1" in out.getvalue() + + def test_skips_runs_without_task_result(self): + session = _make_session() + Run.objects.create( + session=session, trigger_type=SessionOrigin.ISSUE_WEBHOOK, repo_id="group/project", status=RunStatus.RUNNING + ) + + out = StringIO() + call_command("sync_stuck_runs", stdout=out) + + assert "Synced: 0" in out.getvalue() + + def test_continues_after_per_row_error(self, create_db_task_result): + ok_tr = create_db_task_result( + status="SUCCESSFUL", + return_value={"response": "Done."}, + finished_at=datetime(2026, 4, 13, 12, 0, 0, tzinfo=UTC), + ) + bad_tr = create_db_task_result(status="SUCCESSFUL", return_value={"response": "boom."}) + + session = _make_session() + ok_run = Run.objects.create( + session=session, + trigger_type=SessionOrigin.API_JOB, + repo_id="group/project", + status=RunStatus.RUNNING, + task_result=ok_tr, + ) + bad_session = _make_session() + bad_run = Run.objects.create( + session=bad_session, + trigger_type=SessionOrigin.API_JOB, + repo_id="group/project", + status=RunStatus.RUNNING, + task_result=bad_tr, + ) + + original = Run.sync_and_save + + def selectively_raise(self): + if self.pk == bad_run.pk: + raise RuntimeError("simulated sync failure") + return original(self) + + out = StringIO() + with patch.object(Run, "sync_and_save", selectively_raise), pytest.raises(CommandError) as exc_info: + call_command("sync_stuck_runs", stdout=out) + + ok_run.refresh_from_db() + bad_run.refresh_from_db() + assert ok_run.status == RunStatus.SUCCESSFUL + assert bad_run.status == RunStatus.RUNNING + assert "Synced: 1" in str(exc_info.value) + assert "errored: 1" in str(exc_info.value) + + +@pytest.mark.django_db(transaction=True) +class TestReleaseOrphanQueuedSessionsCommand: + def test_releases_queued_when_no_active_sibling(self, create_db_task_result): + """A QUEUED run with no READY/RUNNING sibling on the session is dispatched.""" + session = _make_session() + orphan = Run.objects.create( + session=session, trigger_type=SessionOrigin.API_JOB, repo_id="a/b", status=RunStatus.QUEUED, prompt="p" + ) + fake_task = MagicMock(id=create_db_task_result().id) + out = StringIO() + with patch("sessions.signals.run_job_task") as mock_task: + mock_task.aenqueue = AsyncMock(return_value=fake_task) + call_command("release_orphan_queued_sessions", stdout=out) + + orphan.refresh_from_db() + assert orphan.status == RunStatus.READY + assert orphan.task_result_id == fake_task.id + assert "Released: 1" in out.getvalue() + + def test_skips_queued_when_active_sibling_exists(self): + """A QUEUED run whose session already has a READY/RUNNING sibling is left alone.""" + session = _make_session() + Run.objects.create( + session=session, trigger_type=SessionOrigin.API_JOB, repo_id="a/b", status=RunStatus.RUNNING, prompt="p" + ) + queued = Run.objects.create( + session=session, trigger_type=SessionOrigin.API_JOB, repo_id="a/b", status=RunStatus.QUEUED, prompt="p" + ) + out = StringIO() + with patch("sessions.signals.run_job_task") as mock_task: + mock_task.aenqueue = AsyncMock() + call_command("release_orphan_queued_sessions", stdout=out) + mock_task.aenqueue.assert_not_called() + + queued.refresh_from_db() + assert queued.status == RunStatus.QUEUED + assert "Released: 0" in out.getvalue() diff --git a/tests/unit_tests/sessions/test_services.py b/tests/unit_tests/sessions/test_services.py new file mode 100644 index 000000000..459815a1e --- /dev/null +++ b/tests/unit_tests/sessions/test_services.py @@ -0,0 +1,626 @@ +"""Tests for sessions.services — ported from activity/test_batch_submit.py and test_services.py.""" + +from __future__ import annotations + +import uuid +from unittest import mock +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from django_tasks_db.models import DBTaskResult, get_date_max +from notifications.choices import NotifyOn +from sessions.models import Run, RunStatus, Session, SessionOrigin +from sessions.services import ( + BatchSubmitFailure, + RepoTarget, + acreate_run, + aget_or_create_session, + asubmit_batch_runs, + submit_batch_runs, + validate_repo_list, +) + +pytestmark = pytest.mark.django_db + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _task_result_row(task_id: uuid.UUID) -> mock.Mock: + DBTaskResult.objects.create( + id=task_id, + status="READY", + task_path="jobs.tasks.run_job_task", + args_kwargs={"args": [], "kwargs": {}}, + queue_name="default", + backend_name="default", + run_after=get_date_max(), + return_value={}, + ) + return mock.Mock(id=task_id) + + +async def _atask_result_row(task_id: uuid.UUID) -> mock.Mock: + await DBTaskResult.objects.acreate( + id=task_id, + status="READY", + task_path="jobs.tasks.run_job_task", + args_kwargs={"args": [], "kwargs": {}}, + queue_name="default", + backend_name="default", + run_after=get_date_max(), + return_value={}, + ) + return mock.Mock(id=task_id) + + +async def _make_db_task_result() -> MagicMock: + task_id = uuid.uuid4() + await DBTaskResult.objects.acreate( + id=task_id, + status="READY", + task_path="jobs.tasks.run_job_task", + args_kwargs={"args": [], "kwargs": {}}, + queue_name="default", + backend_name="default", + run_after=get_date_max(), + return_value={}, + ) + return MagicMock(id=task_id) + + +def _patch_run_job_task(side_effect=None): + mock_task = MagicMock() + mock_task.aenqueue = AsyncMock(side_effect=side_effect) + return patch("sessions.services.run_job_task", mock_task), mock_task + + +# --------------------------------------------------------------------------- +# New-behavior tests (from the brief's Step 1) +# --------------------------------------------------------------------------- + + +async def test_aget_or_create_session_creates_with_origin(): + tid = str(uuid.uuid4()) + session = await aget_or_create_session(thread_id=tid, origin=SessionOrigin.API_JOB, repo_id="g/r", ref="main") + assert session.thread_id == tid + assert session.origin == SessionOrigin.API_JOB + + +async def test_aget_or_create_session_existing_keeps_origin_and_touches(): + tid = str(uuid.uuid4()) + first = await aget_or_create_session(thread_id=tid, origin=SessionOrigin.ISSUE_WEBHOOK, repo_id="g/r") + before = first.last_active_at + again = await aget_or_create_session(thread_id=tid, origin=SessionOrigin.API_JOB, repo_id="g/r") + assert again.pk == first.pk + assert again.origin == SessionOrigin.ISSUE_WEBHOOK # first trigger wins + await again.arefresh_from_db() + assert again.last_active_at > before + + +async def test_acreate_run_creates_session_and_run(): + tid = str(uuid.uuid4()) + run = await acreate_run( + trigger_type=SessionOrigin.ISSUE_WEBHOOK, + task_result_id=None, + repo_id="g/r", + thread_id=tid, + external_username="gituser", + prompt="fix the bug", + ) + assert run.session_id == tid + session = await Session.objects.aget(pk=tid) + assert session.origin == SessionOrigin.ISSUE_WEBHOOK + assert session.external_username == "gituser" + + +@pytest.mark.django_db(transaction=True) +async def test_submit_batch_creates_session_and_ready_run(): + task_id = uuid.uuid4() + fake = await _atask_result_row(task_id) + with patch("sessions.services.run_job_task") as mock_task: + mock_task.aenqueue = AsyncMock(return_value=fake) + result = await asubmit_batch_runs( + user=None, prompt="do it", repos=[RepoTarget(repo_id="g/r")], trigger_type=SessionOrigin.API_JOB + ) + assert len(result.runs) == 1 + run = result.runs[0] + assert run.status == RunStatus.READY + assert await Session.objects.filter(pk=run.session_id).aexists() + + +@pytest.mark.django_db(transaction=True) +async def test_submit_continuation_queues_when_thread_busy(): + tid = str(uuid.uuid4()) + task_id = uuid.uuid4() + fake = await _atask_result_row(task_id) + with patch("sessions.services.run_job_task") as mock_task: + mock_task.aenqueue = AsyncMock(return_value=fake) + first = await asubmit_batch_runs( + user=None, prompt="p1", repos=[RepoTarget(repo_id="g/r")], trigger_type=SessionOrigin.API_JOB, thread_id=tid + ) + assert first.runs[0].status == RunStatus.READY + second = await asubmit_batch_runs( + user=None, prompt="p2", repos=[RepoTarget(repo_id="g/r")], trigger_type=SessionOrigin.API_JOB, thread_id=tid + ) + assert second.runs[0].status == RunStatus.QUEUED # constraint fallback preserved + assert await Run.objects.filter(session_id=tid).acount() == 2 + + +# --------------------------------------------------------------------------- +# validate_repo_list tests (ported from activity) +# --------------------------------------------------------------------------- + + +class TestValidateRepoListDuplicates: + def test_duplicate_with_ref_mentions_both_repo_and_ref(self): + raw = [{"repo_id": "acme/api", "ref": "main"}, {"repo_id": "acme/api", "ref": "main"}] + with pytest.raises(ValueError) as exc: + validate_repo_list(raw) + msg = str(exc.value) + assert "acme/api" in msg + assert "main" in msg + + def test_duplicate_without_ref_omits_on_clause(self): + raw = [{"repo_id": "acme/api", "ref": ""}, {"repo_id": "acme/api", "ref": ""}] + with pytest.raises(ValueError) as exc: + validate_repo_list(raw) + msg = str(exc.value) + assert "acme/api" in msg + assert " on " not in msg + + +# --------------------------------------------------------------------------- +# Sync batch submit tests (ported from TestSubmitBatchRunsSync) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db(transaction=True) +class TestSubmitBatchRunsSync: + def test_single_repo_creates_one_run_with_batch_id(self, member_user): + task_id = uuid.uuid4() + fake = _task_result_row(task_id) + with mock.patch("sessions.services.run_job_task") as m_task: + m_task.aenqueue = mock.AsyncMock(return_value=fake) + result = submit_batch_runs( + user=member_user, + prompt="do it", + repos=[RepoTarget(repo_id="a/b", ref="")], + notify_on=None, + trigger_type=SessionOrigin.UI_JOB, + ) + + assert len(result.runs) == 1 + assert result.failed == [] + assert result.runs[0].batch_id == result.batch_id + assert result.runs[0].repo_id == "a/b" + assert result.runs[0].trigger_type == SessionOrigin.UI_JOB + m_task.aenqueue.assert_awaited_once() + enqueue_kwargs = m_task.aenqueue.await_args.kwargs + assert enqueue_kwargs["repo_id"] == "a/b" + assert enqueue_kwargs["prompt"] == "do it" + assert enqueue_kwargs["ref"] is None + assert enqueue_kwargs["agent_model"] is None + assert enqueue_kwargs["agent_thinking_level"] is None + assert "use_max" not in enqueue_kwargs + assert enqueue_kwargs["thread_id"] == str(result.runs[0].session_id) + + def test_five_repos_creates_five_runs_sharing_batch_id(self, member_user): + tasks_seen = [] + + async def _aenqueue(**kwargs): + tasks_seen.append(kwargs) + return await _atask_result_row(uuid.uuid4()) + + with mock.patch("sessions.services.run_job_task") as m_task: + m_task.aenqueue = _aenqueue + repos = [RepoTarget(repo_id=f"o/r{i}", ref="dev" if i % 2 else "") for i in range(5)] + result = submit_batch_runs( + user=member_user, prompt="p", repos=repos, notify_on=None, trigger_type=SessionOrigin.UI_JOB + ) + + assert len(result.runs) == 5 + assert {r.batch_id for r in result.runs} == {result.batch_id} + assert [t["repo_id"] for t in tasks_seen] == [f"o/r{i}" for i in range(5)] + assert tasks_seen[0]["ref"] is None # empty ref threads as None + assert tasks_seen[1]["ref"] == "dev" + # Each run gets a distinct session_id that matches the one passed to the task. + run_session_ids = [str(r.session_id) for r in result.runs] + assert all(run_session_ids) + assert len(set(run_session_ids)) == 5 + task_thread_ids = [t["thread_id"] for t in tasks_seen] + assert set(task_thread_ids) == set(run_session_ids) + + def test_oversized_repos_raises_value_error(self, member_user): + repos = [RepoTarget(repo_id=f"o/r{i}", ref="") for i in range(21)] + with pytest.raises(ValueError): + submit_batch_runs( + user=member_user, prompt="p", repos=repos, notify_on=None, trigger_type=SessionOrigin.UI_JOB + ) + + def test_partial_enqueue_failure_is_best_effort(self, member_user): + call_count = {"n": 0} + + async def _flaky(**kwargs): + call_count["n"] += 1 + if call_count["n"] == 2: + raise RuntimeError("DB hiccup") + return await _atask_result_row(uuid.uuid4()) + + with mock.patch("sessions.services.run_job_task") as m_task: + m_task.aenqueue = _flaky + repos = [ + RepoTarget(repo_id="o/a", ref=""), + RepoTarget(repo_id="o/b", ref=""), + RepoTarget(repo_id="o/c", ref=""), + ] + result = submit_batch_runs( + user=member_user, prompt="p", repos=repos, notify_on=None, trigger_type=SessionOrigin.UI_JOB + ) + + assert len(result.runs) == 2 + assert len(result.failed) == 1 + failure = result.failed[0] + assert isinstance(failure, BatchSubmitFailure) + assert failure.repo_id == "o/b" + assert "DB hiccup" in failure.error + + def test_run_creation_failure_uses_run_creation_failed_prefix(self, member_user): + """RunCreationFailed: prefix is used when acreate_run itself raises.""" + call_count = {"n": 0} + + async def _flaky_create(**kwargs): + call_count["n"] += 1 + if call_count["n"] == 2: + raise RuntimeError("constraint violation") + # Delegate to real acreate_run for other calls. + return await acreate_run(**kwargs) + + async def _aenqueue(**kwargs): + return await _atask_result_row(uuid.uuid4()) + + patch_create = mock.patch("sessions.services.acreate_run", side_effect=_flaky_create) + patch_task, m_task = _patch_run_job_task() + m_task.aenqueue = _aenqueue + with patch_create, patch_task: + repos = [RepoTarget(repo_id="o/a", ref=""), RepoTarget(repo_id="o/b", ref="")] + result = submit_batch_runs( + user=member_user, prompt="p", repos=repos, notify_on=None, trigger_type=SessionOrigin.UI_JOB + ) + + assert len(result.runs) == 1 + assert len(result.failed) == 1 + failure = result.failed[0] + assert failure.repo_id == "o/b" + assert failure.error.startswith("RunCreationFailed:") + assert "constraint violation" in failure.error + + def test_run_persists_scheduled_job_link(self, member_user): + from schedules.models import Frequency, ScheduledJob + + schedule = ScheduledJob.objects.create( + user=member_user, + name="s", + prompt="p", + repos=[{"repo_id": "x/y", "ref": ""}], + frequency=Frequency.DAILY, + time="12:00", + ) + fake = _task_result_row(uuid.uuid4()) + with mock.patch("sessions.services.run_job_task") as m_task: + m_task.aenqueue = mock.AsyncMock(return_value=fake) + result = submit_batch_runs( + user=member_user, + prompt="p", + repos=[RepoTarget(repo_id="x/y", ref="")], + notify_on=None, + trigger_type=SessionOrigin.SCHEDULE, + scheduled_job=schedule, + ) + # The run's session should have the scheduled_job linked + session = Session.objects.get(pk=result.runs[0].session_id) + assert session.scheduled_job_id == schedule.pk + + +# --------------------------------------------------------------------------- +# Async batch submit tests (ported from TestAsubmitBatchRuns) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db(transaction=True) +class TestAsubmitBatchRuns: + async def test_async_variant_returns_same_shape(self, member_user): + task_id = uuid.uuid4() + + async def _aenqueue(**kwargs): + return await _atask_result_row(task_id) + + with mock.patch("sessions.services.run_job_task") as m_task: + m_task.aenqueue = _aenqueue + result = await asubmit_batch_runs( + user=member_user, + prompt="p", + repos=[RepoTarget(repo_id="a/b", ref="")], + notify_on=None, + trigger_type=SessionOrigin.API_JOB, + ) + + assert len(result.runs) == 1 + assert result.runs[0].batch_id == result.batch_id + + +# --------------------------------------------------------------------------- +# Batch title task tests (ported from TestBatchTitleEnqueue) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db(transaction=True) +class TestBatchTitleEnqueue: + """One title task per batch — not one per run.""" + + def test_single_batch_title_task_enqueued_for_n_repos(self, member_user, mock_generate_title_task): + async def _aenqueue(**kwargs): + return await _atask_result_row(uuid.uuid4()) + + with mock.patch("sessions.services.run_job_task") as m_task: + m_task.aenqueue = _aenqueue + repos = [RepoTarget(repo_id=f"o/r{i}", ref="") for i in range(4)] + result = submit_batch_runs( + user=member_user, prompt="add login", repos=repos, notify_on=None, trigger_type=SessionOrigin.UI_JOB + ) + + assert len(result.runs) == 4 + mock_generate_title_task.aenqueue.assert_awaited_once() + call_kwargs = mock_generate_title_task.aenqueue.await_args.kwargs + assert call_kwargs["batch_id"] == str(result.batch_id) + assert call_kwargs["prompt"] == "add login" + + def test_no_title_task_for_schedule_trigger(self, member_user, mock_generate_title_task): + from schedules.models import Frequency, ScheduledJob + + schedule = ScheduledJob.objects.create( + user=member_user, + name="s", + prompt="p", + repos=[{"repo_id": "x/y", "ref": ""}], + frequency=Frequency.DAILY, + time="12:00", + ) + fake = _task_result_row(uuid.uuid4()) + with mock.patch("sessions.services.run_job_task") as m_task: + m_task.aenqueue = mock.AsyncMock(return_value=fake) + submit_batch_runs( + user=member_user, + prompt="p", + repos=[RepoTarget(repo_id="x/y", ref="")], + notify_on=None, + trigger_type=SessionOrigin.SCHEDULE, + scheduled_job=schedule, + ) + mock_generate_title_task.aenqueue.assert_not_called() + + def test_no_title_task_when_no_runs_created(self, member_user, mock_generate_title_task): + async def _aenqueue_fails(**kwargs): + raise RuntimeError("queue down") + + with mock.patch("sessions.services.run_job_task") as m_task: + m_task.aenqueue = _aenqueue_fails + result = submit_batch_runs( + user=member_user, + prompt="p", + repos=[RepoTarget(repo_id="o/r", ref="")], + notify_on=None, + trigger_type=SessionOrigin.UI_JOB, + ) + + assert result.runs == [] + mock_generate_title_task.aenqueue.assert_not_called() + + def test_title_enqueue_failure_does_not_abort_batch(self, member_user, mock_generate_title_task): + """Enqueue failures for the (best-effort) title task must not raise to the caller.""" + mock_generate_title_task.aenqueue = mock.AsyncMock(side_effect=RuntimeError("title queue down")) + + async def _aenqueue(**kwargs): + return await _atask_result_row(uuid.uuid4()) + + with mock.patch("sessions.services.run_job_task") as m_task: + m_task.aenqueue = _aenqueue + result = submit_batch_runs( + user=member_user, + prompt="add login", + repos=[RepoTarget(repo_id=f"o/r{i}", ref="") for i in range(3)], + notify_on=None, + trigger_type=SessionOrigin.UI_JOB, + ) + + assert len(result.runs) == 3 + assert result.failed == [] + mock_generate_title_task.aenqueue.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# Thread/session continuation tests (ported from TestThreadContinuation) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db(transaction=True) +class TestSessionContinuation: + async def test_reuses_supplied_thread_id(self, member_user): + thread = str(uuid.uuid4()) + fake_task = await _make_db_task_result() + patcher, mock_task = _patch_run_job_task() + mock_task.aenqueue.return_value = fake_task + with patcher: + result = await asubmit_batch_runs( + user=member_user, + prompt="follow-up", + repos=[RepoTarget(repo_id="acme/api", ref="")], + trigger_type=SessionOrigin.API_JOB, + thread_id=thread, + ) + run = result.runs[0] + assert str(run.session_id) == thread + + async def test_multi_repo_with_thread_id_raises(self, member_user): + thread = str(uuid.uuid4()) + with pytest.raises(ValueError, match="exactly one repo"): + await asubmit_batch_runs( + user=member_user, + prompt="p", + repos=[RepoTarget(repo_id="a/b", ref=""), RepoTarget(repo_id="c/d", ref="")], + trigger_type=SessionOrigin.API_JOB, + thread_id=thread, + ) + + async def test_prior_terminal_creates_ready_and_enqueues(self, member_user): + thread = str(uuid.uuid4()) + # Create the session and a prior terminal Run + session = await Session.objects.acreate(thread_id=thread, origin=SessionOrigin.API_JOB, repo_id="acme/api") + await Run.objects.acreate( + session=session, + trigger_type=SessionOrigin.API_JOB, + repo_id="acme/api", + status=RunStatus.SUCCESSFUL, + user=member_user, + ) + fake_task = await _make_db_task_result() + patcher, mock_task = _patch_run_job_task() + mock_task.aenqueue.return_value = fake_task + with patcher: + result = await asubmit_batch_runs( + user=member_user, + prompt="follow-up", + repos=[RepoTarget(repo_id="acme/api", ref="")], + trigger_type=SessionOrigin.API_JOB, + thread_id=thread, + ) + run = result.runs[0] + assert run.status == RunStatus.READY + mock_task.aenqueue.assert_called_once() + + async def test_prior_non_terminal_creates_queued_and_skips_enqueue(self, member_user): + thread = str(uuid.uuid4()) + session = await Session.objects.acreate(thread_id=thread, origin=SessionOrigin.API_JOB, repo_id="acme/api") + await Run.objects.acreate( + session=session, + trigger_type=SessionOrigin.API_JOB, + repo_id="acme/api", + status=RunStatus.RUNNING, + user=member_user, + ) + patcher, mock_task = _patch_run_job_task() + with patcher: + result = await asubmit_batch_runs( + user=member_user, + prompt="follow-up", + repos=[RepoTarget(repo_id="acme/api", ref="")], + trigger_type=SessionOrigin.API_JOB, + thread_id=thread, + ) + run = result.runs[0] + assert run.status == RunStatus.QUEUED + assert run.task_result_id is None + mock_task.aenqueue.assert_not_called() + + async def test_asubmit_batch_runs_stores_and_forwards_overrides(self, member_user): + fake_task = await _make_db_task_result() + patcher, mock_task = _patch_run_job_task() + mock_task.aenqueue.return_value = fake_task + with patcher: + result = await asubmit_batch_runs( + user=member_user, + prompt="do thing", + repos=[RepoTarget(repo_id="acme/x", ref="main")], + agent_model="openrouter:anthropic/claude-haiku-4.5", + agent_thinking_level="low", + trigger_type=SessionOrigin.UI_JOB, + ) + + assert result.runs[0].agent_model == "openrouter:anthropic/claude-haiku-4.5" + assert result.runs[0].agent_thinking_level == "low" + enqueue_kwargs = mock_task.aenqueue.call_args.kwargs + assert enqueue_kwargs["agent_model"] == "openrouter:anthropic/claude-haiku-4.5" + assert enqueue_kwargs["agent_thinking_level"] == "low" + assert "use_max" not in enqueue_kwargs + + async def test_asubmit_batch_runs_empty_overrides_pass_none_to_aenqueue(self, member_user): + fake_task = await _make_db_task_result() + patcher, mock_task = _patch_run_job_task() + mock_task.aenqueue.return_value = fake_task + with patcher: + await asubmit_batch_runs( + user=member_user, + prompt="do thing", + repos=[RepoTarget(repo_id="acme/x", ref="")], + trigger_type=SessionOrigin.UI_JOB, + ) + + enqueue_kwargs = mock_task.aenqueue.call_args.kwargs + assert enqueue_kwargs["agent_model"] is None + assert enqueue_kwargs["agent_thinking_level"] is None + assert "use_max" not in enqueue_kwargs + + async def test_enqueue_failure_marks_failed_with_audit_and_releases_queued_sibling(self, member_user): + """When enqueue raises, run transitions to FAILED and queued sibling is released.""" + thread = str(uuid.uuid4()) + # A prior QUEUED sibling waiting for the active slot to open up. + session = await Session.objects.acreate(thread_id=thread, origin=SessionOrigin.API_JOB, repo_id="acme/api") + queued = await Run.objects.acreate( + session=session, + trigger_type=SessionOrigin.API_JOB, + repo_id="acme/api", + status=RunStatus.QUEUED, + user=member_user, + prompt="p", + ) + good_task = await _make_db_task_result() + services_patch, services_mock = _patch_run_job_task(side_effect=RuntimeError("broker down")) + signals_mock = MagicMock() + signals_mock.aenqueue = AsyncMock(return_value=good_task) + with services_patch, patch("sessions.signals.run_job_task", signals_mock): + result = await asubmit_batch_runs( + user=member_user, + prompt="follow-up", + repos=[RepoTarget(repo_id="acme/api", ref="")], + trigger_type=SessionOrigin.API_JOB, + thread_id=thread, + ) + + assert result.runs == [] and len(result.failed) == 1 + assert "RuntimeError" in result.failed[0].error + services_mock.aenqueue.assert_awaited_once() + + failed_row = await Run.objects.aget(session_id=thread, status=RunStatus.FAILED) + assert failed_row.error_message.startswith("enqueue_failed:") + assert failed_row.finished_at is not None + + await queued.arefresh_from_db() + assert queued.status == RunStatus.READY + assert queued.task_result_id == good_task.id + + +# --------------------------------------------------------------------------- +# notify_on tests (ported from TestCreateActivityNotifyOn / TestEffectiveNotifyOn) +# --------------------------------------------------------------------------- + + +class TestCreateRunNotifyOn: + def test_explicit_notify_on_is_persisted(self, member_user): + + # Need a session first + session = Session.objects.create(thread_id=str(uuid.uuid4()), origin=SessionOrigin.UI_JOB, repo_id="x/y") + run = Run.objects.create( + session=session, + trigger_type=SessionOrigin.UI_JOB, + repo_id="x/y", + user=member_user, + notify_on=NotifyOn.NEVER, + ) + assert run.notify_on == NotifyOn.NEVER + + def test_no_notify_on_leaves_null(self, member_user): + session = Session.objects.create(thread_id=str(uuid.uuid4()), origin=SessionOrigin.UI_JOB, repo_id="x/y") + run = Run.objects.create(session=session, trigger_type=SessionOrigin.UI_JOB, repo_id="x/y", user=member_user) + assert run.notify_on is None diff --git a/tests/unit_tests/sessions/test_signals.py b/tests/unit_tests/sessions/test_signals.py new file mode 100644 index 000000000..9da4e6f8f --- /dev/null +++ b/tests/unit_tests/sessions/test_signals.py @@ -0,0 +1,429 @@ +import uuid +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from django_tasks.signals import task_finished, task_started +from sessions.models import Run, RunStatus, Session, SessionOrigin +from sessions.signals import run_finished + +from accounts.models import User + + +def _make_session(*, thread_id: str | None = None) -> Session: + return Session.objects.create( + thread_id=thread_id or str(uuid.uuid4()), origin=SessionOrigin.API_JOB, repo_id="acme/api" + ) + + +def _create_run(*, session: Session, status: str = RunStatus.READY, **kwargs) -> Run: + defaults = {"trigger_type": SessionOrigin.API_JOB, "repo_id": "group/project", "status": status} + defaults.update(kwargs) + return Run.objects.create(session=session, **defaults) + + +def _make_run(*, session_id: str, status: str, **kwargs) -> Run: + session = Session.objects.get_or_create( + thread_id=session_id, defaults={"origin": SessionOrigin.API_JOB, "repo_id": "acme/api"} + )[0] + return Run.objects.create( + session=session, trigger_type=SessionOrigin.API_JOB, repo_id="acme/api", status=status, prompt="p", **kwargs + ) + + +@pytest.mark.django_db +class TestBackfillSessionUser: + def test_backfills_orphaned_runs_on_user_create(self): + session = _make_session() + orphan = _create_run( + session=session, + trigger_type=SessionOrigin.ISSUE_WEBHOOK, + repo_id="group/repo", + external_username="newdev", + status=RunStatus.READY, + ) + assert orphan.user is None + + user = User.objects.create_user( + username="newdev", + email="newdev@test.com", + password="testpass", # noqa: S106 + ) + + orphan.refresh_from_db() + assert orphan.user == user + + def test_backfills_orphaned_sessions_on_user_create(self): + session = Session.objects.create( + thread_id=str(uuid.uuid4()), + origin=SessionOrigin.ISSUE_WEBHOOK, + repo_id="group/repo", + external_username="newdev2", + ) + assert session.user is None + + user = User.objects.create_user( + username="newdev2", + email="newdev2@test.com", + password="testpass", # noqa: S106 + ) + + session.refresh_from_db() + assert session.user == user + + def test_does_not_backfill_already_linked_runs(self): + existing_user = User.objects.create_user( + username="existing", + email="existing@test.com", + password="testpass", # noqa: S106 + ) + session = _make_session() + linked = _create_run( + session=session, + trigger_type=SessionOrigin.ISSUE_WEBHOOK, + repo_id="group/repo", + user=existing_user, + external_username="newdev", + status=RunStatus.READY, + ) + + new_user = User.objects.create_user( + username="newdev", + email="newdev@test.com", + password="testpass", # noqa: S106 + ) + + linked.refresh_from_db() + assert linked.user == existing_user, "Should not overwrite existing user FK" + assert linked.user != new_user + + def test_does_not_backfill_on_user_update(self): + session = _make_session() + orphan = _create_run( + session=session, + trigger_type=SessionOrigin.ISSUE_WEBHOOK, + repo_id="group/repo", + external_username="devuser", + status=RunStatus.READY, + ) + + user = User.objects.create_user( + username="devuser", + email="dev@test.com", + password="testpass", # noqa: S106 + ) + + orphan.refresh_from_db() + assert orphan.user == user + + # Now unlink manually and update user — should NOT re-backfill + Run.objects.filter(pk=orphan.pk).update(user=None) + user.name = "Updated Name" + user.save() + + orphan.refresh_from_db() + assert orphan.user is None, "Should not backfill on user update, only on create" + + def test_no_match_when_external_username_differs(self): + session = _make_session() + orphan = _create_run( + session=session, + trigger_type=SessionOrigin.ISSUE_WEBHOOK, + repo_id="group/repo", + external_username="other_user", + status=RunStatus.READY, + ) + + User.objects.create_user( + username="newdev", + email="newdev@test.com", + password="testpass", # noqa: S106 + ) + + orphan.refresh_from_db() + assert orphan.user is None + + +@pytest.mark.django_db +class TestSyncRunOnTaskSignals: + def test_task_finished_syncs_successful_run(self, create_db_task_result): + finished = datetime(2026, 4, 13, 12, 0, 0, tzinfo=UTC) + tr = create_db_task_result( + status="SUCCESSFUL", + return_value={"response": "Job done.", "code_changes": True, "merge_request_id": 42}, + started_at=datetime(2026, 4, 13, 11, 0, 0, tzinfo=UTC), + finished_at=finished, + ) + session = _make_session() + run = _create_run(session=session, task_result=tr, status=RunStatus.READY) + + task_finished.send(sender=type(None), task_result=tr.task_result) + + run.refresh_from_db() + assert run.status == RunStatus.SUCCESSFUL + assert run.finished_at == finished + assert run.result_summary == "Job done." + assert run.code_changes is True + assert run.merge_request_iid == 42 + + def test_task_finished_syncs_failed_run(self, create_db_task_result): + tr = create_db_task_result( + status="FAILED", + exception_class_path="builtins.ValueError", + traceback="Traceback (most recent call last): ...", + started_at=datetime(2026, 4, 13, 11, 0, 0, tzinfo=UTC), + finished_at=datetime(2026, 4, 13, 11, 5, 0, tzinfo=UTC), + ) + session = _make_session() + run = _create_run(session=session, task_result=tr, status=RunStatus.RUNNING) + + task_finished.send(sender=type(None), task_result=tr.task_result) + + run.refresh_from_db() + assert run.status == RunStatus.FAILED + assert "ValueError" in run.error_message + assert "Traceback" in run.error_message + + def test_task_started_syncs_running_status(self, create_db_task_result): + started = datetime(2026, 4, 13, 11, 0, 0, tzinfo=UTC) + tr = create_db_task_result(status="RUNNING", started_at=started) + session = _make_session() + run = _create_run(session=session, task_result=tr, status=RunStatus.READY) + + task_started.send(sender=type(None), task_result=tr.task_result) + + run.refresh_from_db() + assert run.status == RunStatus.RUNNING + assert run.started_at == started + + def test_task_finished_no_run_does_not_raise(self, create_db_task_result): + tr = create_db_task_result(status="SUCCESSFUL", return_value={"response": "done"}) + task_finished.send(sender=type(None), task_result=tr.task_result) + + def test_task_started_no_run_does_not_raise(self, create_db_task_result): + tr = create_db_task_result(status="RUNNING") + task_started.send(sender=type(None), task_result=tr.task_result) + + def test_signal_handler_swallows_sync_errors(self, create_db_task_result): + tr = create_db_task_result(status="SUCCESSFUL", return_value={"response": "done"}) + session = _make_session() + _create_run(session=session, task_result=tr, status=RunStatus.READY) + + with patch.object(Run, "sync_from_task_result", side_effect=RuntimeError("boom")): + task_finished.send(sender=type(None), task_result=tr.task_result) + + +@pytest.mark.django_db +class TestRunFinishedSignal: + def test_emitted_on_transition_to_successful(self, member_user): + from unittest.mock import MagicMock + + from sessions.signals import emit_run_finished_if_terminal, run_finished + + session = _make_session() + run = _create_run(session=session, status=RunStatus.RUNNING, user=member_user) + received = MagicMock() + run_finished.connect(received, dispatch_uid="test-succ") + try: + run.status = RunStatus.SUCCESSFUL + run.save() + emit_run_finished_if_terminal(run, previous_status=RunStatus.RUNNING) + + assert received.called + _, kwargs = received.call_args + assert kwargs["run"] is run + finally: + run_finished.disconnect(dispatch_uid="test-succ") + + def test_not_emitted_when_still_running(self, member_user): + from unittest.mock import MagicMock + + from sessions.signals import emit_run_finished_if_terminal, run_finished + + session = _make_session() + run = _create_run(session=session, status=RunStatus.RUNNING, user=member_user) + received = MagicMock() + run_finished.connect(received, dispatch_uid="test-run") + try: + emit_run_finished_if_terminal(run, previous_status=RunStatus.READY) + assert not received.called + finally: + run_finished.disconnect(dispatch_uid="test-run") + + def test_not_emitted_when_already_terminal(self, member_user): + from unittest.mock import MagicMock + + from sessions.signals import emit_run_finished_if_terminal, run_finished + + session = _make_session() + run = _create_run(session=session, status=RunStatus.SUCCESSFUL, user=member_user) + received = MagicMock() + run_finished.connect(received, dispatch_uid="test-term") + try: + emit_run_finished_if_terminal(run, previous_status=RunStatus.SUCCESSFUL) + assert not received.called + finally: + run_finished.disconnect(dispatch_uid="test-term") + + +@pytest.mark.django_db(transaction=True) +class TestDispatchNextInSession: + def test_releases_oldest_queued_sibling(self, create_db_task_result): + session_id = str(uuid.uuid4()) + finished = _make_run(session_id=session_id, status=RunStatus.SUCCESSFUL) + oldest = _make_run(session_id=session_id, status=RunStatus.QUEUED) + _make_run(session_id=session_id, status=RunStatus.QUEUED) # newer sibling + + db_task = create_db_task_result() + fake_task = MagicMock(id=db_task.id) + with patch("sessions.signals.run_job_task") as mock_task: + mock_task.aenqueue = AsyncMock(return_value=fake_task) + run_finished.send(sender=Run, run=finished) + + oldest.refresh_from_db() + assert oldest.status == RunStatus.READY + assert oldest.task_result_id == fake_task.id + + def test_no_op_when_no_session_id(self): + # Create a run without a session (shouldn't happen in practice but guard the code path) + session = _make_session() + _create_run(session=session, status=RunStatus.SUCCESSFUL) + # Manually blank session_id via a mock + finished_mock = MagicMock() + finished_mock.session_id = None + with patch("sessions.signals.run_job_task") as mock_task: + mock_task.aenqueue = AsyncMock() + run_finished.send(sender=Run, run=finished_mock) + mock_task.aenqueue.assert_not_called() + + def test_no_op_when_no_queued_sibling(self): + session_id = str(uuid.uuid4()) + finished = _make_run(session_id=session_id, status=RunStatus.SUCCESSFUL) + with patch("sessions.signals.run_job_task") as mock_task: + mock_task.aenqueue = AsyncMock() + run_finished.send(sender=Run, run=finished) + mock_task.aenqueue.assert_not_called() + + def test_dispatch_failure_marks_queued_failed_and_unblocks_chain(self, create_db_task_result): + session_id = str(uuid.uuid4()) + finished = _make_run(session_id=session_id, status=RunStatus.SUCCESSFUL) + bad = _make_run(session_id=session_id, status=RunStatus.QUEUED) + next_q = _make_run(session_id=session_id, status=RunStatus.QUEUED) + + db_task = create_db_task_result() + fake_task = MagicMock(id=db_task.id) + with patch("sessions.signals.run_job_task") as mock_task: + mock_task.aenqueue = AsyncMock(side_effect=[RuntimeError("queue down"), fake_task]) + run_finished.send(sender=Run, run=finished) + + bad.refresh_from_db() + next_q.refresh_from_db() + assert bad.status == RunStatus.FAILED + assert "dispatch_failed" in (bad.error_message or "") + assert next_q.status == RunStatus.READY + assert next_q.task_result_id == fake_task.id + + def test_skip_dispatch_kwarg_suppresses_dispatcher(self): + """run_finished with skip_dispatch=True must not re-enter dispatch_next_in_session.""" + session_id = str(uuid.uuid4()) + finished = _make_run(session_id=session_id, status=RunStatus.SUCCESSFUL) + queued = _make_run(session_id=session_id, status=RunStatus.QUEUED) + with patch("sessions.signals.run_job_task") as mock_task: + mock_task.aenqueue = AsyncMock() + run_finished.send(sender=Run, run=finished, skip_dispatch=True) + mock_task.aenqueue.assert_not_called() + queued.refresh_from_db() + assert queued.status == RunStatus.QUEUED + + def test_enqueue_failure_reemit_uses_skip_dispatch(self): + """The dispatch-failure path must re-emit ``run_finished`` with + ``skip_dispatch=True`` so the dispatcher does not recurse — notifications still fire.""" + from sessions.signals import _enqueue_queued_run + + session_id = str(uuid.uuid4()) + bad = _make_run(session_id=session_id, status=RunStatus.READY) + captured: list = [] + + def _spy(sender, run, **kwargs): + captured.append(kwargs.get("skip_dispatch")) + + run_finished.connect(_spy, dispatch_uid="t-skip-test") + try: + with patch("sessions.signals.run_job_task") as mock_task: + mock_task.aenqueue = AsyncMock(side_effect=RuntimeError("queue down")) + ok = _enqueue_queued_run(bad) + finally: + run_finished.disconnect(dispatch_uid="t-skip-test") + + assert ok is False + assert captured == [True], f"expected one emit with skip_dispatch=True, got {captured}" + + def test_bails_after_max_consecutive_failures(self, create_db_task_result): + """A persistent broker outage must not mass-fail every QUEUED row on the session.""" + from sessions.signals import MAX_CONSECUTIVE_DISPATCH_FAILURES + + session_id = str(uuid.uuid4()) + finished = _make_run(session_id=session_id, status=RunStatus.SUCCESSFUL) + queued_rows = [ + _make_run(session_id=session_id, status=RunStatus.QUEUED) + for _ in range(MAX_CONSECUTIVE_DISPATCH_FAILURES + 2) + ] + with patch("sessions.signals.run_job_task") as mock_task: + mock_task.aenqueue = AsyncMock(side_effect=RuntimeError("queue down")) + run_finished.send(sender=Run, run=finished) + assert mock_task.aenqueue.call_count == MAX_CONSECUTIVE_DISPATCH_FAILURES + + statuses = {RunStatus.FAILED: 0, RunStatus.QUEUED: 0} + for row in queued_rows: + row.refresh_from_db() + statuses[row.status] = statuses.get(row.status, 0) + 1 + assert statuses[RunStatus.FAILED] == MAX_CONSECUTIVE_DISPATCH_FAILURES + assert statuses[RunStatus.QUEUED] == 2 + + def test_re_enqueue_propagates_agent_override(self, create_db_task_result): + """Releasing a QUEUED sibling must forward the per-row agent override pair.""" + session_id = str(uuid.uuid4()) + finished = _make_run(session_id=session_id, status=RunStatus.SUCCESSFUL) + _make_run( + session_id=session_id, + status=RunStatus.QUEUED, + agent_model="openrouter:anthropic/claude-opus-4.6", + agent_thinking_level="high", + ) + + db_task = create_db_task_result() + fake_task = MagicMock(id=db_task.id) + with patch("sessions.signals.run_job_task") as mock_task: + mock_task.aenqueue = AsyncMock(return_value=fake_task) + run_finished.send(sender=Run, run=finished) + + kwargs = mock_task.aenqueue.call_args.kwargs + assert kwargs["agent_model"] == "openrouter:anthropic/claude-opus-4.6" + assert kwargs["agent_thinking_level"] == "high" + assert "use_max" not in kwargs + + +@pytest.mark.django_db(transaction=True) +class TestSyncReleasesQueuedSibling: + def test_terminal_dbtaskresult_releases_queued_sibling(self, create_db_task_result): + """When sync_stuck_runs reconciles a stuck RUNNING Run whose + DBTaskResult is already terminal, the resulting run_finished signal + must release the oldest QUEUED sibling on the same session_id. + """ + from django.core.management import call_command + + session_id = str(uuid.uuid4()) + tr = create_db_task_result(status="SUCCESSFUL", return_value={"response": "done"}) + stuck = _make_run(session_id=session_id, status=RunStatus.RUNNING, task_result=tr) + queued = _make_run(session_id=session_id, status=RunStatus.QUEUED) + + fake_task = MagicMock(id=create_db_task_result().id) + with patch("sessions.signals.run_job_task") as mock_task: + mock_task.aenqueue = AsyncMock(return_value=fake_task) + call_command("sync_stuck_runs") + + stuck.refresh_from_db() + queued.refresh_from_db() + assert stuck.status == RunStatus.SUCCESSFUL + assert queued.status == RunStatus.READY + assert queued.task_result_id == fake_task.id From 7adb3ce018c27fcfcff859547ced3e958795b7ab Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 7 Jul 2026 16:30:54 +0100 Subject: [PATCH 04/55] feat(sessions): backfill migration from Activity and ChatThread --- daiv/sessions/backfill.py | 140 ++++++++++++++++++ .../0002_backfill_from_activity_and_chat.py | 15 ++ .../sessions/test_data_migration.py | 83 +++++++++++ 3 files changed, 238 insertions(+) create mode 100644 daiv/sessions/backfill.py create mode 100644 daiv/sessions/migrations/0002_backfill_from_activity_and_chat.py create mode 100644 tests/unit_tests/sessions/test_data_migration.py diff --git a/daiv/sessions/backfill.py b/daiv/sessions/backfill.py new file mode 100644 index 000000000..d63570f02 --- /dev/null +++ b/daiv/sessions/backfill.py @@ -0,0 +1,140 @@ +"""Backfill Session/Run rows from historical Activity and ChatThread tables. + +Called by migration 0002. Kept as a real module so the logic is unit-testable +with live models. Uses ``apps.get_model`` so it works with both historical +(migration) and current (test) app registries. Idempotent: rows that already +exist are skipped, so re-running after a partial failure is safe. +""" + +from __future__ import annotations + +import uuid + +RUN_COPY_FIELDS = [ + # Activity field -> Run field, 1:1 names + "trigger_type", + "status", + "task_result_id", + "user_id", + "external_username", + "title", + "batch_id", + "repo_id", + "ref", + "prompt", + "agent_model", + "agent_thinking_level", + "notify_on", + "mention_comment_id", + "merge_request_iid", + "merge_request_web_url", + "sandbox_environment_id", + "result_summary", + "error_message", + "code_changes", + "input_tokens", + "output_tokens", + "total_tokens", + "cost_usd", + "usage_by_model", + "created_at", + "started_at", + "finished_at", +] + + +def run_backfill(apps, schema_editor=None) -> None: + Activity = apps.get_model("activity", "Activity") + ChatThread = apps.get_model("chat", "ChatThread") + Session = apps.get_model("agent_sessions", "Session") + Run = apps.get_model("agent_sessions", "Run") + + existing_runs = set(Run.objects.values_list("id", flat=True)) + existing_sessions = set(Session.objects.values_list("thread_id", flat=True)) + + # Pass 1: activities, grouped by thread, oldest first so "first wins" fields + # come from the earliest row and "latest wins" fields overwrite as we walk. + sessions_to_create: dict[str, Session] = {} + runs_to_create: list[Run] = [] + for activity in Activity.objects.order_by("created_at", "id").iterator(): + if activity.id in existing_runs: + continue # already backfilled — also prevents re-minting sessions for null-thread rows + thread_id = activity.thread_id or str(uuid.uuid4()) # mint for legacy null-thread rows + if thread_id not in sessions_to_create and thread_id not in existing_sessions: + sessions_to_create[thread_id] = Session( + thread_id=thread_id, + origin=activity.trigger_type, # earliest activity wins + user_id=activity.user_id, + external_username=activity.external_username, + repo_id=activity.repo_id, + ref=activity.ref, + title=activity.title, + agent_model=activity.agent_model, + agent_thinking_level=activity.agent_thinking_level, + sandbox_environment_id=activity.sandbox_environment_id, + scheduled_job_id=activity.scheduled_job_id, + issue_iid=activity.issue_iid, + merge_request_iid=activity.merge_request_iid, + created_at=activity.created_at, + last_active_at=activity.finished_at or activity.created_at, + ) + elif thread_id in sessions_to_create: + session = sessions_to_create[thread_id] + # Latest-wins fields. + if activity.title: + session.title = activity.title + if activity.issue_iid: + session.issue_iid = activity.issue_iid + if activity.merge_request_iid: + session.merge_request_iid = activity.merge_request_iid + if activity.sandbox_environment_id: + session.sandbox_environment_id = activity.sandbox_environment_id + if activity.scheduled_job_id: + session.scheduled_job_id = activity.scheduled_job_id + # First-wins fields backfilled only if still empty. + if session.user_id is None and activity.user_id is not None: + session.user_id = activity.user_id + if not session.external_username and activity.external_username: + session.external_username = activity.external_username + session.last_active_at = max(session.last_active_at, activity.finished_at or activity.created_at) + + if activity.id not in existing_runs: + run = Run(id=activity.id, session_id=thread_id) + for field in RUN_COPY_FIELDS: + setattr(run, field, getattr(activity, field)) + runs_to_create.append(run) + + Session.objects.bulk_create(sessions_to_create.values(), batch_size=500) + Run.objects.bulk_create(runs_to_create, batch_size=500) + + # Pass 2: chat threads. Merge into existing sessions (chat metadata wins for + # title/last_active/user/model pins) or create chat-origin sessions. + for thread in ChatThread.objects.iterator(): + session, created = Session.objects.get_or_create( + thread_id=thread.thread_id, + defaults={ + "origin": "chat", + "user_id": thread.user_id, + "repo_id": thread.repo_id, + "ref": thread.ref, + "title": thread.title, + "agent_model": thread.agent_model, + "agent_thinking_level": thread.agent_thinking_level, + "sandbox_environment_id": thread.sandbox_environment_id, + "created_at": thread.created_at, + "last_active_at": thread.last_active_at, + }, + ) + if not created: + update_fields = [] + if thread.title: + session.title = thread.title + update_fields.append("title") + if session.user_id is None and thread.user_id is not None: + session.user_id = thread.user_id + update_fields.append("user_id") + if thread.last_active_at > session.last_active_at: + session.last_active_at = thread.last_active_at + update_fields.append("last_active_at") + if update_fields: + session.save(update_fields=update_fields) diff --git a/daiv/sessions/migrations/0002_backfill_from_activity_and_chat.py b/daiv/sessions/migrations/0002_backfill_from_activity_and_chat.py new file mode 100644 index 000000000..3ad4d5ae1 --- /dev/null +++ b/daiv/sessions/migrations/0002_backfill_from_activity_and_chat.py @@ -0,0 +1,15 @@ +from django.db import migrations + +from sessions.backfill import run_backfill + + +class Migration(migrations.Migration): + dependencies = [ + ("agent_sessions", "0001_initial"), + # Latest migration of each source app as of plan-writing; if new migrations + # landed since, re-check with: ls daiv/activity/migrations/ | tail -1 + ("activity", "0015_activity_agent_override_fields"), + ("chat", "0003_chatthread_agent_override_fields"), + ] + + operations = [migrations.RunPython(run_backfill, migrations.RunPython.noop)] diff --git a/tests/unit_tests/sessions/test_data_migration.py b/tests/unit_tests/sessions/test_data_migration.py new file mode 100644 index 000000000..3c206e6e5 --- /dev/null +++ b/tests/unit_tests/sessions/test_data_migration.py @@ -0,0 +1,83 @@ +import uuid +from datetime import timedelta + +from django.apps import apps as global_apps +from django.utils import timezone + +import pytest +from activity.models import Activity, ActivityStatus, TriggerType +from sessions.backfill import run_backfill +from sessions.models import Run, Session, SessionOrigin + +from chat.models import ChatThread + +pytestmark = pytest.mark.django_db + + +def test_activities_collapse_into_one_session_per_thread(): + tid = str(uuid.uuid4()) + a1 = Activity.objects.create( + trigger_type=TriggerType.ISSUE_WEBHOOK, + repo_id="g/r", + thread_id=tid, + status=ActivityStatus.SUCCESSFUL, + external_username="alice", + ) + a2 = Activity.objects.create( + trigger_type=TriggerType.API_JOB, repo_id="g/r", thread_id=tid, status=ActivityStatus.FAILED, title="second run" + ) + run_backfill(global_apps) + session = Session.objects.get(pk=tid) + assert session.origin == SessionOrigin.ISSUE_WEBHOOK # earliest activity wins + assert session.title == "second run" # latest non-empty title wins + assert set(Run.objects.filter(session=session).values_list("id", flat=True)) == {a1.id, a2.id} + + +def test_null_thread_activity_gets_minted_session(): + a = Activity.objects.create(trigger_type=TriggerType.UI_JOB, repo_id="g/r", thread_id=None) + run_backfill(global_apps) + run = Run.objects.get(pk=a.id) + assert run.session_id # minted + assert Session.objects.filter(pk=run.session_id).exists() + + +def test_chat_thread_merges_into_existing_session(django_user_model): + user = django_user_model.objects.create_user(username="u", email="u@x.io", password="x") # noqa: S106 + tid = str(uuid.uuid4()) + Activity.objects.create(trigger_type=TriggerType.API_JOB, repo_id="g/r", thread_id=tid, title="activity title") + ChatThread.objects.create(thread_id=tid, user=user, repo_id="g/r", title="chat title") + run_backfill(global_apps) + session = Session.objects.get(pk=tid) + assert session.title == "chat title" # chat metadata wins on merge + assert session.user == user + assert session.origin == SessionOrigin.API_JOB # origin stays from the activity + + +def test_chat_only_thread_becomes_chat_session_with_zero_runs(django_user_model): + user = django_user_model.objects.create_user(username="u", email="u@x.io", password="x") # noqa: S106 + tid = str(uuid.uuid4()) + ChatThread.objects.create(thread_id=tid, user=user, repo_id="g/r", ref="main", title="hello") + run_backfill(global_apps) + session = Session.objects.get(pk=tid) + assert session.origin == SessionOrigin.CHAT + assert session.user == user + assert session.runs.count() == 0 + + +def test_created_at_and_timestamps_preserved(): + tid = str(uuid.uuid4()) + old = timezone.now() - timedelta(days=30) + a = Activity.objects.create(trigger_type=TriggerType.API_JOB, repo_id="g/r", thread_id=tid) + Activity.objects.filter(pk=a.pk).update(created_at=old) # bypass auto_now_add + run_backfill(global_apps) + assert Run.objects.get(pk=a.pk).created_at == old + assert Session.objects.get(pk=tid).created_at == old + + +def test_backfill_is_idempotent(): + tid = str(uuid.uuid4()) + Activity.objects.create(trigger_type=TriggerType.API_JOB, repo_id="g/r", thread_id=tid) + run_backfill(global_apps) + run_backfill(global_apps) + assert Session.objects.filter(pk=tid).count() == 1 + assert Run.objects.count() == 1 From 80d7221c5b5cdffdca30a94a6aa1efc32960de20 Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 7 Jul 2026 16:54:27 +0100 Subject: [PATCH 05/55] test(sessions): fix full-suite isolation for the agent_sessions app - sandbox_envs migration test now restores migrations to head: reversing sandbox_envs.0006 cascade-reverses agent_sessions.0001 (drops its tables), which left later Session/Run tests failing with 'no such table'. - test_by_owner_admin_sees_all uses a leak-immune subset assertion: async ORM writes commit to the shared in-memory SQLite DB and escape rollback. - Drop the earlier threaded cleanup fixture (wrong approach: a worker thread hit a separate empty in-memory DB and corrupted connection state). --- .../sandbox_envs/test_migrations.py | 17 ++++++++ tests/unit_tests/sessions/conftest.py | 39 ------------------- tests/unit_tests/sessions/test_models.py | 10 ++++- 3 files changed, 25 insertions(+), 41 deletions(-) diff --git a/tests/unit_tests/sandbox_envs/test_migrations.py b/tests/unit_tests/sandbox_envs/test_migrations.py index d8aa9b981..b847bb61a 100644 --- a/tests/unit_tests/sandbox_envs/test_migrations.py +++ b/tests/unit_tests/sandbox_envs/test_migrations.py @@ -16,6 +16,23 @@ import pytest +@pytest.fixture(autouse=True) +def _restore_migrations_to_head(): + """Re-apply all migrations to head after this test. + + Migrating ``sandbox_envs`` down to 0005 reverses every migration that + depends on 0006 — including ``agent_sessions.0001`` — which DROPS the + agent_sessions tables. This test only migrates ``sandbox_envs`` back up, so + without this teardown the dropped tables stay gone and every later test that + touches Session/Run fails with "no such table" (the test DB is a single + in-memory SQLite shared across the whole session). + """ + yield + executor = MigrationExecutor(connection) + executor.migrate(executor.loader.graph.leaf_nodes()) + executor.loader.build_graph() + + @pytest.mark.django_db(transaction=True) def test_off_env_with_policy_loses_policy(): """null_egress_for_off_envs: network-off env's policy is nulled; network-on env's policy is kept.""" diff --git a/tests/unit_tests/sessions/conftest.py b/tests/unit_tests/sessions/conftest.py index a333027a5..94f813516 100644 --- a/tests/unit_tests/sessions/conftest.py +++ b/tests/unit_tests/sessions/conftest.py @@ -33,42 +33,3 @@ def _create( ) return _create - - -@pytest.fixture(autouse=True) -def _cleanup_sessions_rows(django_db_blocker): - """Delete committed Session/Run rows after each test. - - Some sessions tests use ``@pytest.mark.django_db(transaction=True)`` or - async DB access (which commits via a separate connection) and those rows - are not rolled back by pytest-django's savepoint/transaction mechanism. - Without this cleanup they leak into later tests (e.g. global-count - assertions). Runs for every test — for purely transaction-wrapped tests it - is a harmless no-op. - """ - yield - with django_db_blocker.unblock(): - import threading - - from django.db import close_old_connections - - def _delete() -> None: - import logging - - from sessions.models import Run, Session - - # Runs on its own thread so it gets a fresh autocommit DB connection: - # the deletes then commit unconditionally instead of being rolled back - # with an enclosing atomic block on the main (test) thread. - close_old_connections() - try: - Run.objects.all().delete() - Session.objects.all().delete() - except Exception: - logging.getLogger("tests").warning("session-row cleanup failed", exc_info=True) - finally: - close_old_connections() - - t = threading.Thread(target=_delete) - t.start() - t.join() diff --git a/tests/unit_tests/sessions/test_models.py b/tests/unit_tests/sessions/test_models.py index f0a91fb32..e656d8d67 100644 --- a/tests/unit_tests/sessions/test_models.py +++ b/tests/unit_tests/sessions/test_models.py @@ -66,8 +66,14 @@ def test_active_run_id_nonempty_constraint(): def test_by_owner_admin_sees_all(admin_user, django_user_model): other = django_user_model.objects.create_user(username="other", email="o@x.io", password="x") # noqa: S106 - _mk_session(user=other) - assert Session.objects.by_owner(admin_user).count() == 1 + mine = _mk_session(user=admin_user) + theirs = _mk_session(user=other) + orphan = _mk_session(user=None, external_username="ext") + # Admin sees all sessions, including ones it does not own. Subset (not equality) + # keeps the assertion correct even if other tests committed rows into the shared + # in-memory DB (async writes can escape the transaction rollback). + visible = set(Session.objects.by_owner(admin_user).values_list("pk", flat=True)) + assert {mine.pk, theirs.pk, orphan.pk} <= visible def test_by_owner_matches_session_user(django_user_model): From 10f72e3df9d135c14d909cb60bfb89b810316213 Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 7 Jul 2026 17:00:29 +0100 Subject: [PATCH 06/55] fix(sessions): chat model pins win when backfill merges into an existing session --- daiv/sessions/backfill.py | 6 +++++ .../sessions/test_data_migration.py | 24 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/daiv/sessions/backfill.py b/daiv/sessions/backfill.py index d63570f02..866a02721 100644 --- a/daiv/sessions/backfill.py +++ b/daiv/sessions/backfill.py @@ -136,5 +136,11 @@ def run_backfill(apps, schema_editor=None) -> None: if thread.last_active_at > session.last_active_at: session.last_active_at = thread.last_active_at update_fields.append("last_active_at") + if thread.agent_model and thread.agent_model != session.agent_model: + session.agent_model = thread.agent_model + update_fields.append("agent_model") + if thread.agent_thinking_level and thread.agent_thinking_level != session.agent_thinking_level: + session.agent_thinking_level = thread.agent_thinking_level + update_fields.append("agent_thinking_level") if update_fields: session.save(update_fields=update_fields) diff --git a/tests/unit_tests/sessions/test_data_migration.py b/tests/unit_tests/sessions/test_data_migration.py index 3c206e6e5..7b737495d 100644 --- a/tests/unit_tests/sessions/test_data_migration.py +++ b/tests/unit_tests/sessions/test_data_migration.py @@ -10,6 +10,7 @@ from sessions.models import Run, Session, SessionOrigin from chat.models import ChatThread +from core.models import ThinkingLevelChoices pytestmark = pytest.mark.django_db @@ -74,6 +75,29 @@ def test_created_at_and_timestamps_preserved(): assert Session.objects.get(pk=tid).created_at == old +def test_chat_merge_model_pins_win(django_user_model): + user = django_user_model.objects.create_user(username="u", email="u@x.io", password="x") # noqa: S106 + tid = str(uuid.uuid4()) + Activity.objects.create( + trigger_type=TriggerType.API_JOB, + repo_id="g/r", + thread_id=tid, + agent_model="activity-model", + agent_thinking_level=ThinkingLevelChoices.LOW, + ) + ChatThread.objects.create( + thread_id=tid, + user=user, + repo_id="g/r", + agent_model="chat-model", + agent_thinking_level=ThinkingLevelChoices.HIGH, + ) + run_backfill(global_apps) + session = Session.objects.get(pk=tid) + assert session.agent_model == "chat-model" # chat metadata wins on merge + assert session.agent_thinking_level == ThinkingLevelChoices.HIGH + + def test_backfill_is_idempotent(): tid = str(uuid.uuid4()) Activity.objects.create(trigger_type=TriggerType.API_JOB, repo_id="g/r", thread_id=tid) From 9687a4c52bd95beacae3fb4111e78313f0c54652 Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 7 Jul 2026 17:05:32 +0100 Subject: [PATCH 07/55] feat(jobs): run_job_task claims the unified session lock with wait and heartbeat --- daiv/jobs/tasks.py | 101 +++++++++++++++++------ daiv/sessions/services.py | 1 + daiv/sessions/signals.py | 1 + tests/unit_tests/jobs/test_tasks.py | 1 + tests/unit_tests/jobs/test_tasks_lock.py | 35 ++++++++ 5 files changed, 113 insertions(+), 26 deletions(-) create mode 100644 tests/unit_tests/jobs/test_tasks_lock.py diff --git a/daiv/jobs/tasks.py b/daiv/jobs/tasks.py index b26a48c87..5b260152c 100644 --- a/daiv/jobs/tasks.py +++ b/daiv/jobs/tasks.py @@ -1,7 +1,11 @@ +import asyncio import logging +import time from django_tasks import task from langchain_core.messages import HumanMessage +from sessions.locks import SessionLock +from sessions.models import Session from automation.agent.graph import create_daiv_agent from automation.agent.results import AgentResult, build_agent_result @@ -13,6 +17,38 @@ logger = logging.getLogger("daiv.jobs") +LOCK_WAIT_TIMEOUT_S = 1800.0 # give a long-running chat turn time to finish +LOCK_POLL_INTERVAL_S = 5.0 +LOCK_HEARTBEAT_INTERVAL_S = 60.0 + + +async def _acquire_session_lock(thread_id: str, holder_id: str) -> bool | None: + """Claim the session's execution slot, waiting for the current holder. + + Returns True when claimed, None when the session row doesn't exist (legacy + rows — run unlocked rather than fail), and raises TimeoutError if the slot + never frees within LOCK_WAIT_TIMEOUT_S (stale takeover in SessionLock + guarantees eventual success against crashed holders well before that). + """ + if not await Session.objects.filter(pk=thread_id).aexists(): + logger.warning("run_job_task: no session row for thread_id=%s; running without lock", thread_id) + return None + deadline = time.monotonic() + LOCK_WAIT_TIMEOUT_S + while time.monotonic() < deadline: + if await SessionLock.try_claim(thread_id, holder_id): + return True + await asyncio.sleep(LOCK_POLL_INTERVAL_S) + raise TimeoutError(f"session lock for thread_id={thread_id} not released within {LOCK_WAIT_TIMEOUT_S}s") + + +async def _heartbeat_loop(thread_id: str, holder_id: str) -> None: + while True: + await asyncio.sleep(LOCK_HEARTBEAT_INTERVAL_S) + try: + await SessionLock.heartbeat(thread_id, holder_id) + except Exception: + logger.exception("run_job_task: heartbeat failed for thread_id=%s", thread_id) + @task() async def run_job_task( @@ -23,6 +59,7 @@ async def run_job_task( agent_model: str | None = None, agent_thinking_level: str | None = None, sandbox_environment_id: str | None = None, + run_id: str | None = None, ) -> AgentResult: """Run the DAIV agent for a submitted job and return a standardized result. @@ -50,33 +87,45 @@ async def run_job_task( input_data = {"messages": [HumanMessage(content=prompt)]} + holder_id = run_id or f"job-{thread_id[:8]}" + locked = await _acquire_session_lock(thread_id, holder_id) + heartbeat_task = asyncio.create_task(_heartbeat_loop(thread_id, holder_id)) if locked else None try: - async with ( - set_runtime_ctx( - repo_id=repo_id, scope=Scope.GLOBAL, ref=ref, sandbox_env_id=sandbox_environment_id - ) as runtime_ctx, - open_checkpointer() as checkpointer, - ): - agent_kwargs = get_daiv_agent_kwargs( - model_config=runtime_ctx.config.models.agent, - agent_model=agent_model, - agent_thinking_level=agent_thinking_level, - ) - daiv_agent = await create_daiv_agent(ctx=runtime_ctx, checkpointer=checkpointer, **agent_kwargs) - config = build_langsmith_config( - runtime_ctx, - trigger="job", - model=agent_kwargs["model_names"][0], - thinking_level=agent_kwargs["thinking_level"], - agent_name=daiv_agent.get_name(), - extra_metadata={"ref": ref, "override_source": "explicit" if agent_model else None}, - configurable={"thread_id": thread_id}, - ) - with track_usage_metadata() as usage_handler: - result = await daiv_agent.ainvoke(input_data, config=config, context=runtime_ctx) - except Exception: - logger.exception("Job failed for repo_id=%s, ref=%s, agent_model=%s", repo_id, ref, agent_model or "") - raise + try: + async with ( + set_runtime_ctx( + repo_id=repo_id, scope=Scope.GLOBAL, ref=ref, sandbox_env_id=sandbox_environment_id + ) as runtime_ctx, + open_checkpointer() as checkpointer, + ): + agent_kwargs = get_daiv_agent_kwargs( + model_config=runtime_ctx.config.models.agent, + agent_model=agent_model, + agent_thinking_level=agent_thinking_level, + ) + daiv_agent = await create_daiv_agent(ctx=runtime_ctx, checkpointer=checkpointer, **agent_kwargs) + config = build_langsmith_config( + runtime_ctx, + trigger="job", + model=agent_kwargs["model_names"][0], + thinking_level=agent_kwargs["thinking_level"], + agent_name=daiv_agent.get_name(), + extra_metadata={"ref": ref, "override_source": "explicit" if agent_model else None}, + configurable={"thread_id": thread_id}, + ) + with track_usage_metadata() as usage_handler: + result = await daiv_agent.ainvoke(input_data, config=config, context=runtime_ctx) + except Exception: + logger.exception("Job failed for repo_id=%s, ref=%s, agent_model=%s", repo_id, ref, agent_model or "") + raise + finally: + if heartbeat_task is not None: + heartbeat_task.cancel() + if locked: + try: + await SessionLock.release(thread_id, holder_id) + except Exception: + logger.exception("run_job_task: failed to release session lock for thread_id=%s", thread_id) messages = result.get("messages") if not messages: diff --git a/daiv/sessions/services.py b/daiv/sessions/services.py index 5e78a2acb..b0977a8fc 100644 --- a/daiv/sessions/services.py +++ b/daiv/sessions/services.py @@ -312,6 +312,7 @@ async def _submit_one(idx: int, target: RepoTarget) -> Run | BatchSubmitFailure: agent_thinking_level=agent_thinking_level or None, thread_id=effective_thread_id, sandbox_environment_id=target.sandbox_environment_id, + run_id=str(run.pk), ) except Exception as err: # noqa: BLE001 logger.exception("submit_batch_runs: enqueue failed for repo_id=%s batch_id=%s", target.repo_id, batch_id) diff --git a/daiv/sessions/signals.py b/daiv/sessions/signals.py index 6c9a23726..2e34213dd 100644 --- a/daiv/sessions/signals.py +++ b/daiv/sessions/signals.py @@ -197,6 +197,7 @@ def _enqueue_queued_run(run: Any) -> bool: agent_model=agent_model, agent_thinking_level=agent_thinking_level, sandbox_environment_id=str(run.sandbox_environment_id) if run.sandbox_environment_id else None, + run_id=str(run.pk), ) except Exception as err: # noqa: BLE001 logger.exception("dispatch_next_in_session: enqueue failed for run=%s", run.pk) diff --git a/tests/unit_tests/jobs/test_tasks.py b/tests/unit_tests/jobs/test_tasks.py index bee7c48d3..e9bd80449 100644 --- a/tests/unit_tests/jobs/test_tasks.py +++ b/tests/unit_tests/jobs/test_tasks.py @@ -67,6 +67,7 @@ async def _fake_set_runtime_ctx(*args, **kwargs): # We're not setting up enough scaffolding to complete the agent invoke; # the assertion below is what matters. with ( + patch("jobs.tasks._acquire_session_lock", new=AsyncMock(return_value=None)), patch("jobs.tasks.set_runtime_ctx", _fake_set_runtime_ctx), patch("jobs.tasks.open_checkpointer"), patch("jobs.tasks.create_daiv_agent", AsyncMock()), diff --git a/tests/unit_tests/jobs/test_tasks_lock.py b/tests/unit_tests/jobs/test_tasks_lock.py new file mode 100644 index 000000000..9b802672a --- /dev/null +++ b/tests/unit_tests/jobs/test_tasks_lock.py @@ -0,0 +1,35 @@ +import uuid +from unittest.mock import patch + +import pytest +from jobs.tasks import _acquire_session_lock +from sessions.locks import SessionLock +from sessions.models import Session, SessionOrigin + +pytestmark = pytest.mark.django_db + + +async def _mk_session(**kwargs): + defaults = {"thread_id": str(uuid.uuid4()), "origin": SessionOrigin.API_JOB, "repo_id": "g/r"} + defaults.update(kwargs) + return await Session.objects.acreate(**defaults) + + +async def test_acquire_free_lock_immediately(): + session = await _mk_session() + assert await _acquire_session_lock(session.thread_id, "run-1") is True + + +async def test_acquire_waits_then_succeeds_when_released(): + session = await _mk_session(active_run_id="chat-run") + + async def _release_soon(*args, **kwargs): + await SessionLock.release(session.thread_id, "chat-run") + + with patch("jobs.tasks.LOCK_POLL_INTERVAL_S", 0.01), patch("jobs.tasks.asyncio.sleep", side_effect=_release_soon): + assert await _acquire_session_lock(session.thread_id, "run-1") is True + + +async def test_acquire_skips_missing_session(): + # No Session row (legacy null-thread activity): don't block, don't crash. + assert await _acquire_session_lock(str(uuid.uuid4()), "run-1") is None From 7c2546a49f254d1bf5b3b9a71bc44843a0f3ab69 Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 7 Jul 2026 17:15:53 +0100 Subject: [PATCH 08/55] refactor(jobs): back /api/jobs with Session/Run, contract unchanged --- daiv/jobs/api/views.py | 52 +++---- tests/unit_tests/jobs/api/test_views.py | 147 ++++++++++-------- tests/unit_tests/jobs/test_api_environment.py | 4 +- 3 files changed, 113 insertions(+), 90 deletions(-) diff --git a/daiv/jobs/api/views.py b/daiv/jobs/api/views.py index d55c87c1b..2644ea611 100644 --- a/daiv/jobs/api/views.py +++ b/daiv/jobs/api/views.py @@ -5,10 +5,10 @@ from django.http import HttpRequest # noqa: TC002 - required at runtime by Django -from activity.models import Activity, ActivityStatus, TriggerType -from activity.services import RepoTarget, asubmit_batch_runs from ninja import Router from sandbox_envs.services import aresolve_repo_envs, resolve_env_for_user +from sessions.models import Run, RunStatus, Session, SessionOrigin +from sessions.services import RepoTarget, asubmit_batch_runs from automation.agent.validators import AgentOverrideError, validate_agent_override from chat.api.security import AuthBearer @@ -32,8 +32,8 @@ async def _validate_thread_id(thread_id: UUID, user: User) -> tuple[bool, str | Schema-layer validation already constrains ``thread_id`` to a well-formed UUID, so no UUID parsing is needed here — a non-existent ID is indistinguishable from a non-owned one. """ - latest = await Activity.objects.filter(thread_id=str(thread_id)).order_by("-created_at").afirst() - if latest is None or latest.user_id != user.pk: + owned = await Session.objects.by_owner(user).filter(thread_id=str(thread_id)).aexists() + if not owned: return False, _THREAD_NOT_FOUND return True, None @@ -43,9 +43,9 @@ async def submit_job(request: HttpRequest, payload: JobSubmitRequest): """Submit a batch of 1-20 agent jobs. Each repository runs as an independent job. If ``thread_id`` is supplied, the new job continues an existing thread: exactly one - repo must be provided and the most recent Activity on that thread must belong to the - caller. If a prior run on the thread is still in flight, the new Activity is created - in ``QUEUED`` state and will be released FIFO when the prior run terminates. + repo must be provided and the session owning that thread must belong to the caller. + If a prior run on the thread is still in flight, the new Run is created in ``QUEUED`` + state and will be released FIFO when the prior run terminates. """ if payload.thread_id is not None: if len(payload.repos) != 1: @@ -76,24 +76,24 @@ async def submit_job(request: HttpRequest, payload: JobSubmitRequest): agent_model=agent_model, agent_thinking_level=agent_thinking_level, notify_on=payload.notify_on, - trigger_type=TriggerType.API_JOB, + trigger_type=SessionOrigin.API_JOB, thread_id=str(payload.thread_id) if payload.thread_id is not None else None, ) failed_keys = {(f.repo_id, f.ref) for f in result.failed} - activities_iter = iter(result.activities) + runs_iter = iter(result.runs) jobs: list[JobSubmitJobItem] = [] for spec in payload.repos: if (spec.repo_id, spec.ref or "") in failed_keys: continue - activity = next(activities_iter) + run = next(runs_iter) jobs.append( JobSubmitJobItem( - job_id=str(activity.id), + job_id=str(run.id), repo_id=spec.repo_id, ref=spec.ref, - thread_id=str(activity.thread_id), - status=cast("Literal['QUEUED', 'READY']", activity.status), + thread_id=str(run.session_id), + status=cast("Literal['QUEUED', 'READY']", run.status), ) ) @@ -103,26 +103,26 @@ async def submit_job(request: HttpRequest, payload: JobSubmitRequest): @jobs_router.get("/{job_id}", response={200: JobStatusResponse, 404: dict}) async def get_job_status(request: HttpRequest, job_id: str): - """Get the status and result of a submitted job. Looks up by Activity.id.""" + """Get the status and result of a submitted job. Looks up by Run.id.""" try: - activity_uuid = uuid_mod.UUID(job_id) + run_uuid = uuid_mod.UUID(job_id) except ValueError: return 404, {"detail": "Job not found"} try: - activity = await Activity.objects.aget(id=activity_uuid, user=request.auth) - except Activity.DoesNotExist: + run = await Run.objects.aget(id=run_uuid, user=request.auth) + except Run.DoesNotExist: return 404, {"detail": "Job not found"} - error = "Job execution failed" if activity.status == ActivityStatus.FAILED else None + error = "Job execution failed" if run.status == RunStatus.FAILED else None return 200, JobStatusResponse( - job_id=str(activity.id), - status=cast("Literal['QUEUED', 'READY', 'RUNNING', 'SUCCESSFUL', 'FAILED']", activity.status), - thread_id=str(activity.thread_id) if activity.thread_id else None, - result=activity.result_summary or None, - merge_request_url=activity.merge_request_web_url or None, + job_id=str(run.id), + status=cast("Literal['QUEUED', 'READY', 'RUNNING', 'SUCCESSFUL', 'FAILED']", run.status), + thread_id=str(run.session_id) if run.session_id else None, + result=run.result_summary or None, + merge_request_url=run.merge_request_web_url or None, error=error, - created_at=activity.created_at, - started_at=activity.started_at, - finished_at=activity.finished_at, + created_at=run.created_at, + started_at=run.started_at, + finished_at=run.finished_at, ) diff --git a/tests/unit_tests/jobs/api/test_views.py b/tests/unit_tests/jobs/api/test_views.py index 7490d818a..b29fe639c 100644 --- a/tests/unit_tests/jobs/api/test_views.py +++ b/tests/unit_tests/jobs/api/test_views.py @@ -2,11 +2,11 @@ from unittest.mock import AsyncMock, patch import pytest -from activity.models import Activity, ActivityStatus, TriggerType from asgiref.sync import async_to_sync from django_tasks_db.models import DBTaskResult from jobs.tasks import run_job_task from ninja.testing import TestAsyncClient +from sessions.models import Run, RunStatus, Session, SessionOrigin from accounts.models import APIKey, User from core.models import Provider, ProviderType @@ -45,7 +45,7 @@ def _single_repo_body(**overrides): async def _make_task_row(task_id=None) -> AsyncMock: - """Create a real DBTaskResult row so the Activity FK is satisfied, and return a mock that + """Create a real DBTaskResult row so the Run FK is satisfied, and return a mock that exposes the row's id.""" tid = task_id or uuid.uuid4() await DBTaskResult.objects.acreate( @@ -63,25 +63,28 @@ async def _make_task_row(task_id=None) -> AsyncMock: return m -class _FakeActivity: - """Stand-in for Activity returned from a mocked ``acreate_activity`` in tests that patch it.""" +class _FakeRun: + """Stand-in for Run returned from a mocked ``acreate_run`` in tests that patch it.""" def __init__(self, task_result_id): self.id = uuid.uuid4() + self.pk = self.id self.task_result_id = task_result_id - self.thread_id = str(uuid.uuid4()) - self.status = ActivityStatus.READY - # Tests bypass the real ORM via ``_patch_acreate``; provide an async no-op - # for the post-acreate ``activity.asave(update_fields=...)`` call. + self.session_id = str(uuid.uuid4()) + self.status = RunStatus.READY + self.started_at = None + self.finished_at = None + # Tests bypass the real ORM via ``_patch_acreate_run``; provide an async no-op + # for the post-acreate ``run.asave(update_fields=...)`` call. self.asave = AsyncMock(return_value=None) -async def _fake_acreate_activity(**kwargs): - return _FakeActivity(task_result_id=kwargs["task_result_id"]) +async def _fake_acreate_run(**kwargs): + return _FakeRun(task_result_id=kwargs.get("task_result_id")) -def _patch_acreate(): - return patch("activity.services.acreate_activity", new_callable=AsyncMock, side_effect=_fake_acreate_activity) +def _patch_acreate_run(): + return patch("sessions.services.acreate_run", new_callable=AsyncMock, side_effect=_fake_acreate_run) # --- Authentication tests --- @@ -148,7 +151,7 @@ async def test_submit_job_success(authenticated_client: TestAsyncClient): async def _aenq(**kwargs): return await _make_task_row(task_id) - with patch("activity.services.run_job_task") as mock_task, _patch_acreate(): + with patch("sessions.services.run_job_task") as mock_task, _patch_acreate_run(): mock_task.aenqueue.side_effect = _aenq mock_task.module_path = run_job_task.module_path response = await authenticated_client.post("/jobs", json=_single_repo_body(prompt="List all files")) @@ -178,7 +181,7 @@ async def test_submit_job_multi_repo(authenticated_client: TestAsyncClient): async def _aenq(**kwargs): return await _make_task_row() - with patch("activity.services.run_job_task") as mock_task: + with patch("sessions.services.run_job_task") as mock_task: mock_task.aenqueue.side_effect = _aenq mock_task.module_path = run_job_task.module_path response = await authenticated_client.post( @@ -224,7 +227,7 @@ async def test_submit_job_forwards_agent_override(authenticated_client: TestAsyn async def _aenq(**kwargs): return await _make_task_row() - with patch("activity.services.run_job_task") as mock_task, _patch_acreate() as mock_create: + with patch("sessions.services.run_job_task") as mock_task, _patch_acreate_run() as mock_create: mock_task.aenqueue.side_effect = _aenq mock_task.module_path = run_job_task.module_path response = await authenticated_client.post( @@ -246,13 +249,13 @@ async def _aenq(**kwargs): @pytest.mark.django_db(transaction=True) -async def test_submit_job_forwards_notify_on_to_activity(authenticated_client: TestAsyncClient): - """POST /jobs threads ``notify_on`` into ``acreate_activity``.""" +async def test_submit_job_forwards_notify_on_to_run(authenticated_client: TestAsyncClient): + """POST /jobs threads ``notify_on`` into ``acreate_run``.""" async def _aenq(**kwargs): return await _make_task_row() - with patch("activity.services.run_job_task") as mock_task, _patch_acreate() as mock_create: + with patch("sessions.services.run_job_task") as mock_task, _patch_acreate_run() as mock_create: mock_task.aenqueue.side_effect = _aenq mock_task.module_path = run_job_task.module_path response = await authenticated_client.post("/jobs", json=_single_repo_body(notify_on="always")) @@ -268,7 +271,7 @@ async def test_submit_job_notify_on_optional(authenticated_client: TestAsyncClie async def _aenq(**kwargs): return await _make_task_row() - with patch("activity.services.run_job_task") as mock_task, _patch_acreate() as mock_create: + with patch("sessions.services.run_job_task") as mock_task, _patch_acreate_run() as mock_create: mock_task.aenqueue.side_effect = _aenq mock_task.module_path = run_job_task.module_path response = await authenticated_client.post("/jobs", json=_single_repo_body()) @@ -285,7 +288,7 @@ async def test_submit_job_invalid_notify_on_returns_422(authenticated_client: Te @pytest.mark.django_db(transaction=True) async def test_submit_job_all_enqueue_failures_reported(authenticated_client: TestAsyncClient): - with patch("activity.services.run_job_task") as mock_task: + with patch("sessions.services.run_job_task") as mock_task: mock_task.aenqueue = AsyncMock(side_effect=Exception("DB down")) response = await authenticated_client.post("/jobs", json=_single_repo_body(prompt="List all files")) @@ -296,19 +299,21 @@ async def test_submit_job_all_enqueue_failures_reported(authenticated_client: Te assert data["failed"][0]["repo_id"] == "group/project" -# --- Get job status tests (Activity-based) --- +# --- Get job status tests (Run-based) --- -async def _create_activity_row( - user, status="SUCCESSFUL", result_summary="", merge_request_web_url="", error_message="" -): - """Create a real Activity row for use in get_job_status tests.""" - return await Activity.objects.acreate( - trigger_type=TriggerType.API_JOB, +async def _create_run_row(user, status="SUCCESSFUL", result_summary="", merge_request_web_url="", error_message=""): + """Create a real Session+Run row for use in get_job_status tests.""" + thread_id = str(uuid.uuid4()) + session = await Session.objects.acreate( + thread_id=thread_id, origin=SessionOrigin.API_JOB, repo_id="group/project", user=user + ) + return await Run.objects.acreate( + session=session, + trigger_type=SessionOrigin.API_JOB, repo_id="group/project", user=user, status=status, - thread_id=str(uuid.uuid4()), result_summary=result_summary, merge_request_web_url=merge_request_web_url, error_message=error_message, @@ -318,12 +323,12 @@ async def _create_activity_row( @pytest.mark.django_db(transaction=True) async def test_get_job_status_successful(authenticated_client: TestAsyncClient): user = await User.objects.aget(username="testuser") - activity = await _create_activity_row(user, status="SUCCESSFUL", result_summary="Here are the files...") - response = await authenticated_client.get(f"/jobs/{activity.id}") + run = await _create_run_row(user, status="SUCCESSFUL", result_summary="Here are the files...") + response = await authenticated_client.get(f"/jobs/{run.id}") assert response.status_code == 200 data = response.json() - assert data["job_id"] == str(activity.id) + assert data["job_id"] == str(run.id) assert data["status"] == "SUCCESSFUL" assert data["result"] == "Here are the files..." assert data["error"] is None @@ -332,8 +337,8 @@ async def test_get_job_status_successful(authenticated_client: TestAsyncClient): @pytest.mark.django_db(transaction=True) async def test_get_job_status_failed(authenticated_client: TestAsyncClient): user = await User.objects.aget(username="testuser") - activity = await _create_activity_row(user, status="FAILED") - response = await authenticated_client.get(f"/jobs/{activity.id}") + run = await _create_run_row(user, status="FAILED") + response = await authenticated_client.get(f"/jobs/{run.id}") assert response.status_code == 200 data = response.json() @@ -357,11 +362,11 @@ async def test_get_job_status_invalid_uuid(authenticated_client: TestAsyncClient @pytest.mark.django_db(transaction=True) -async def test_get_job_status_other_user_activity_returns_404(authenticated_client: TestAsyncClient): - """Activities belonging to other users must not be accessible.""" +async def test_get_job_status_other_user_run_returns_404(authenticated_client: TestAsyncClient): + """Runs belonging to other users must not be accessible.""" other = await User.objects.acreate_user(username="other2", email="other2@test.com", password="x") # noqa: S106 - activity = await _create_activity_row(other, status="SUCCESSFUL", result_summary="secret") - response = await authenticated_client.get(f"/jobs/{activity.id}") + run = await _create_run_row(other, status="SUCCESSFUL", result_summary="secret") + response = await authenticated_client.get(f"/jobs/{run.id}") assert response.status_code == 404 @@ -371,8 +376,8 @@ async def test_get_job_status_other_user_activity_returns_404(authenticated_clie @pytest.mark.django_db(transaction=True) class TestThreadContinuationAPI: async def test_response_includes_thread_id_and_status(self, authenticated_client): - # New thread, no prior Activity — should be READY - with patch("activity.services.run_job_task") as mock_task, _patch_acreate(): + # New thread, no prior Run — should be READY + with patch("sessions.services.run_job_task") as mock_task, _patch_acreate_run(): mock_task.aenqueue = AsyncMock(return_value=await _make_task_row()) mock_task.module_path = run_job_task.module_path response = await authenticated_client.post("/jobs", json=_single_repo_body(prompt="x")) @@ -390,7 +395,10 @@ async def test_continuation_with_unknown_thread_id_rejects(self, authenticated_c async def test_continuation_with_other_user_thread_id_rejects(self, authenticated_client, db): other = await User.objects.acreate_user(username="other", email="o@t.com", password="x") # noqa: S106 thread = str(uuid.uuid4()) - await Activity.objects.acreate(trigger_type=TriggerType.API_JOB, repo_id="a/b", thread_id=thread, user=other) + session = await Session.objects.acreate( + thread_id=thread, origin=SessionOrigin.API_JOB, repo_id="a/b", user=other + ) + await Run.objects.acreate(session=session, trigger_type=SessionOrigin.API_JOB, repo_id="a/b", user=other) body = _single_repo_body(prompt="x", thread_id=thread) response = await authenticated_client.post("/jobs", json=body) assert response.status_code == 400 @@ -406,27 +414,27 @@ async def test_continuation_with_multi_repo_rejects(self, authenticated_client): assert response.status_code == 400 assert "exactly one repo" in response.json()["detail"] - async def test_job_id_is_activity_id(self, authenticated_client): - created_activities: list = [] + async def test_job_id_is_run_id(self, authenticated_client): + created_runs: list = [] - async def capture_acreate(**kwargs): - activity = _FakeActivity(task_result_id=kwargs["task_result_id"]) - created_activities.append(activity) - return activity + async def capture_acreate_run(**kwargs): + run = _FakeRun(task_result_id=kwargs.get("task_result_id")) + created_runs.append(run) + return run with ( - patch("activity.services.run_job_task") as mock_task, - patch("activity.services.acreate_activity", new_callable=AsyncMock, side_effect=capture_acreate), - patch("activity.services.generate_batch_title_task"), + patch("sessions.services.run_job_task") as mock_task, + patch("sessions.services.acreate_run", new_callable=AsyncMock, side_effect=capture_acreate_run), + patch("sessions.services.generate_batch_title_task"), ): mock_task.aenqueue = AsyncMock(return_value=await _make_task_row()) mock_task.module_path = run_job_task.module_path response = await authenticated_client.post("/jobs", json=_single_repo_body(prompt="x")) body = response.json() - assert len(created_activities) == 1 - assert body["jobs"][0]["job_id"] == str(created_activities[0].id) - assert body["jobs"][0]["job_id"] != str(created_activities[0].task_result_id) + assert len(created_runs) == 1 + assert body["jobs"][0]["job_id"] == str(created_runs[0].id) + assert body["jobs"][0]["job_id"] != str(created_runs[0].task_result_id) async def test_empty_thread_id_rejected_at_schema(self, authenticated_client): """An empty-string thread_id is malformed at the protocol layer (422, not 400).""" @@ -441,18 +449,20 @@ async def test_malformed_thread_id_rejected_at_schema(self, authenticated_client assert response.status_code == 422 async def test_continuation_creates_queued_when_sibling_running(self, authenticated_client): - """An IntegrityError fallback path: when an active sibling exists on the thread, - the second submission lands in QUEUED with no enqueue.""" + """When an active sibling exists on the session, the second submission lands in QUEUED.""" user = await User.objects.aget(username="testuser") thread = str(uuid.uuid4()) - await Activity.objects.acreate( - trigger_type=TriggerType.API_JOB, + session = await Session.objects.acreate( + thread_id=thread, origin=SessionOrigin.API_JOB, repo_id="group/project", user=user + ) + await Run.objects.acreate( + session=session, + trigger_type=SessionOrigin.API_JOB, repo_id="group/project", - thread_id=thread, - status=ActivityStatus.RUNNING, + status=RunStatus.RUNNING, user=user, ) - with patch("activity.services.run_job_task") as mock_task: + with patch("sessions.services.run_job_task") as mock_task: mock_task.aenqueue = AsyncMock(return_value=await _make_task_row()) mock_task.module_path = run_job_task.module_path response = await authenticated_client.post("/jobs", json=_single_repo_body(prompt="x", thread_id=thread)) @@ -464,9 +474,22 @@ async def test_continuation_creates_queued_when_sibling_running(self, authentica @pytest.mark.django_db(transaction=True) async def test_get_job_status_queued_passes_through(authenticated_client: TestAsyncClient): - """A QUEUED Activity surfaces as ``status='QUEUED'`` in get_job_status (not 'PENDING').""" + """A QUEUED Run surfaces as ``status='QUEUED'`` in get_job_status (not 'PENDING').""" user = await User.objects.aget(username="testuser") - activity = await _create_activity_row(user, status=ActivityStatus.QUEUED) - response = await authenticated_client.get(f"/jobs/{activity.id}") + run = await _create_run_row(user, status=RunStatus.QUEUED) + response = await authenticated_client.get(f"/jobs/{run.id}") assert response.status_code == 200 assert response.json()["status"] == "QUEUED" + + +@pytest.mark.django_db(transaction=True) +async def test_thread_validation_uses_session_ownership(authenticated_client: TestAsyncClient): + """Continuation of a thread whose session belongs to someone else -> 400 opaque error.""" + other = await User.objects.acreate_user(username="outsider", email="outsider@test.com", password="x") # noqa: S106 + thread = str(uuid.uuid4()) + session = await Session.objects.acreate(thread_id=thread, origin=SessionOrigin.API_JOB, repo_id="a/b", user=other) + await Run.objects.acreate(session=session, trigger_type=SessionOrigin.API_JOB, repo_id="a/b", user=other) + body = _single_repo_body(prompt="x", thread_id=thread) + response = await authenticated_client.post("/jobs", json=body) + assert response.status_code == 400 + assert "thread_id not found" in response.json()["detail"] diff --git a/tests/unit_tests/jobs/test_api_environment.py b/tests/unit_tests/jobs/test_api_environment.py index dac89dfa9..5f28c1256 100644 --- a/tests/unit_tests/jobs/test_api_environment.py +++ b/tests/unit_tests/jobs/test_api_environment.py @@ -23,9 +23,9 @@ def auth_pair(db): def test_jobs_endpoint_accepts_environment_name(auth_pair): user, key = auth_pair env = SandboxEnvironment.objects.create(scope=Scope.USER, user=user, name="dev", base_image="alpine:latest") - fake_activity = type("A", (), {"id": uuid.uuid4(), "thread_id": uuid.uuid4(), "status": "READY"})() + fake_run = type("R", (), {"id": uuid.uuid4(), "session_id": str(uuid.uuid4()), "status": "READY"})() with patch("jobs.api.views.asubmit_batch_runs", AsyncMock()) as submit: - submit.return_value = type("R", (), {"batch_id": uuid.uuid4(), "activities": [fake_activity], "failed": []})() + submit.return_value = type("BR", (), {"batch_id": uuid.uuid4(), "runs": [fake_run], "failed": []})() c = Client() resp = c.post( "/api/jobs", From 1e731dcc8993cff5d87c764516c95af078d633a3 Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 7 Jul 2026 17:31:27 +0100 Subject: [PATCH 09/55] refactor(mcp_server): back job tools with Session/Run, contract unchanged --- daiv/mcp_server/server.py | 116 +++++++------- tests/unit_tests/mcp_server/test_server.py | 150 +++++++++--------- .../unit_tests/mcp_server/test_server_envs.py | 6 +- .../unit_tests/mcp_server/test_server_jobs.py | 103 ++++++------ 4 files changed, 179 insertions(+), 196 deletions(-) diff --git a/daiv/mcp_server/server.py b/daiv/mcp_server/server.py index 81cd93365..c606c18b8 100644 --- a/daiv/mcp_server/server.py +++ b/daiv/mcp_server/server.py @@ -13,14 +13,14 @@ from django.utils import timezone from django.utils.dateparse import parse_datetime -from activity.models import Activity, ActivityStatus, TriggerType -from activity.services import MAX_REPOS_PER_BATCH, RepoTarget, alist_user_activities, asubmit_batch_runs from mcp.server.auth.settings import AuthSettings from mcp.server.fastmcp import FastMCP from mcp.server.transport_security import TransportSecuritySettings from notifications.choices import NotifyOn # noqa: TC002 - required at runtime for MCP tool schema from pydantic import BaseModel, Field from sandbox_envs.services import alist_visible_environments, aresolve_repo_envs, resolve_env_for_user +from sessions.models import Run, RunStatus, Session, SessionOrigin +from sessions.services import MAX_REPOS_PER_BATCH, RepoTarget, alist_user_runs, asubmit_batch_runs from automation.agent.validators import AgentOverrideError, ensure_agent_model_available, validate_agent_override from codebase.clients import RepoClient @@ -75,7 +75,7 @@ _THREAD_NOT_FOUND = "thread_id not found" -TERMINAL_STATUSES = {ActivityStatus.SUCCESSFUL, ActivityStatus.FAILED} +TERMINAL_STATUSES = {RunStatus.SUCCESSFUL, RunStatus.FAILED} POLL_INTERVAL = 2.0 MAX_POLL_DURATION = 600.0 # 10 minutes @@ -156,7 +156,7 @@ async def submit_job( description=( "Optional. Continue an existing thread by passing its UUID (from a prior" " submit_job or get_job_status response). When set, ``repos`` must contain" - " exactly one entry whose latest Activity belongs to the calling user." + " exactly one entry whose session belongs to the calling user." " If a prior run on this thread is still in flight, the new job is queued" " and runs FIFO after it terminates." ) @@ -212,8 +212,8 @@ async def submit_job( except ValueError, TypeError: logger.info("submit_job: rejecting malformed thread_id", extra={"user_id": mcp_user.pk}) return json.dumps({"error": _THREAD_NOT_FOUND}) - latest = await Activity.objects.filter(thread_id=thread_id_str).order_by("-created_at").afirst() - if latest is None or latest.user_id != mcp_user.pk: + owned = await Session.objects.by_owner(mcp_user).filter(thread_id=thread_id_str).aexists() + if not owned: return json.dumps({"error": _THREAD_NOT_FOUND}) explicit_env_id: str | None = None @@ -234,28 +234,28 @@ async def submit_job( agent_model=agent_model, agent_thinking_level=agent_thinking_level, notify_on=notify_on, - trigger_type=TriggerType.MCP_JOB, + trigger_type=SessionOrigin.MCP_JOB, thread_id=thread_id_str, ) # Preserve the client-sent ref value (None vs "") by walking the specs and pairing each - # non-failed one with the next activity in result.activities (same order as input). + # non-failed one with the next run in result.runs (same order as input). failed_keys = {(f.repo_id, f.ref) for f in result.failed} - activities_iter = iter(result.activities) + runs_iter = iter(result.runs) jobs: list[dict] = [] job_ids: list[str] = [] for spec in specs: if (spec.repo_id, spec.ref or "") in failed_keys: continue - activity = next(activities_iter) + run = next(runs_iter) jobs.append({ - "job_id": str(activity.id), + "job_id": str(run.id), "repo_id": spec.repo_id, "ref": spec.ref, - "thread_id": str(activity.thread_id) if activity.thread_id else None, - "status": str(activity.status), + "thread_id": str(run.session_id) if run.session_id else None, + "status": str(run.status), }) - job_ids.append(str(activity.id)) + job_ids.append(str(run.id)) failed_out = [{"repo_id": f.repo_id, "ref": f.ref, "error": f.error} for f in result.failed] @@ -268,28 +268,28 @@ async def submit_job( return await _poll_batch_until_complete(str(result.batch_id), job_ids, response, mcp_user) -def _build_job_response_dict(activity: Activity) -> dict: - """Build a dict response from an Activity (shared by single + batch paths).""" - error = "Job execution failed." if activity.status == ActivityStatus.FAILED else None +def _build_job_response_dict(run: Run) -> dict: + """Build a dict response from a Run (shared by single + batch paths).""" + error = "Job execution failed." if run.status == RunStatus.FAILED else None return { - "job_id": str(activity.id), - "status": str(activity.status), - "thread_id": str(activity.thread_id) if activity.thread_id else None, - "result": activity.result_summary or None, - "merge_request_url": activity.merge_request_web_url or None, + "job_id": str(run.id), + "status": str(run.status), + "thread_id": str(run.session_id) if run.session_id else None, + "result": run.result_summary or None, + "merge_request_url": run.merge_request_web_url or None, "error": error, - "created_at": activity.created_at.isoformat() if activity.created_at else None, - "started_at": activity.started_at.isoformat() if activity.started_at else None, - "finished_at": activity.finished_at.isoformat() if activity.finished_at else None, + "created_at": run.created_at.isoformat() if run.created_at else None, + "started_at": run.started_at.isoformat() if run.started_at else None, + "finished_at": run.finished_at.isoformat() if run.finished_at else None, } -def _build_job_response(activity: Activity) -> str: - """Build a JSON response string from an Activity.""" - return json.dumps(_build_job_response_dict(activity)) +def _build_job_response(run: Run) -> str: + """Build a JSON response string from a Run.""" + return json.dumps(_build_job_response_dict(run)) -def _batch_response(batch_id: str, enqueue_response: dict, results_by_id: dict[str, Activity]) -> str: +def _batch_response(batch_id: str, enqueue_response: dict, results_by_id: dict[str, Run]) -> str: return json.dumps({ "batch_id": batch_id, "jobs": enqueue_response["jobs"], @@ -297,7 +297,7 @@ def _batch_response(batch_id: str, enqueue_response: dict, results_by_id: dict[s "statuses": [ _build_job_response_dict(results_by_id[jid]) if jid in results_by_id - else {"job_id": jid, "status": str(ActivityStatus.RUNNING)} + else {"job_id": jid, "status": str(RunStatus.RUNNING)} for jid in [j["job_id"] for j in enqueue_response["jobs"]] ], }) @@ -312,7 +312,7 @@ async def _poll_batch_until_complete( terminal ones), so on timeout the response reports each job's real status (QUEUED/READY/RUNNING) rather than a placeholder. """ - results_by_id: dict[str, Activity] = {} + results_by_id: dict[str, Run] = {} if not job_ids: return _batch_response(batch_id, enqueue_response, results_by_id) @@ -324,7 +324,7 @@ async def _poll_batch_until_complete( elapsed += POLL_INTERVAL try: - async for row in Activity.objects.filter(id__in=list(outstanding), user=mcp_user): + async for row in Run.objects.filter(id__in=list(outstanding), user=mcp_user): results_by_id[str(row.id)] = row if row.status in TERMINAL_STATUSES: outstanding.discard(row.id) @@ -339,15 +339,15 @@ async def _poll_job_until_complete(job_id: str, mcp_user: object) -> str: """Poll a job until it reaches a terminal status or the timeout is exceeded.""" job_uuid = uuid_mod.UUID(job_id) elapsed = 0.0 - last: Activity | None = None + last: Run | None = None while elapsed < MAX_POLL_DURATION: await asyncio.sleep(POLL_INTERVAL) elapsed += POLL_INTERVAL try: - last = await Activity.objects.aget(id=job_uuid, user=mcp_user) - except Activity.DoesNotExist: + last = await Run.objects.aget(id=job_uuid, user=mcp_user) + except Run.DoesNotExist: logger.debug("Job %s not yet available, retrying (%.0fs elapsed)", job_id, elapsed) continue except Exception: @@ -395,13 +395,13 @@ async def get_job_status( return json.dumps({"error": "Authentication failed: unable to resolve the current user."}) try: - activity_uuid = uuid_mod.UUID(job_id) + run_uuid = uuid_mod.UUID(job_id) except ValueError: return json.dumps({"error": "Invalid job_id format."}) try: - activity = await Activity.objects.aget(id=activity_uuid, user=mcp_user) - except Activity.DoesNotExist: + run = await Run.objects.aget(id=run_uuid, user=mcp_user) + except Run.DoesNotExist: if wait: return await _poll_job_until_complete(job_id, mcp_user) return json.dumps({"error": "Job not found."}) @@ -409,10 +409,10 @@ async def get_job_status( logger.exception("Failed to retrieve job status for job_id=%s", job_id) return json.dumps({"error": "Failed to retrieve job status. Please try again later."}) - if wait and activity.status not in TERMINAL_STATUSES: + if wait and run.status not in TERMINAL_STATUSES: return await _poll_job_until_complete(job_id, mcp_user) - return _build_job_response(activity) + return _build_job_response(run) async def _resolve_mcp_user() -> tuple[object | None, dict | None]: @@ -479,7 +479,7 @@ def _encode_created_id_cursor(created: datetime, obj_id: object) -> str: def _decode_created_id_cursor(raw: str, id_type: type) -> tuple[datetime, Any]: """Decode a ``(created, id)`` cursor → ``(datetime, id)``; raises on malformed input. - ``id_type`` coerces the id string to the PK's Python type (``uuid.UUID`` for Activity, + ``id_type`` coerces the id string to the PK's Python type (``uuid.UUID`` for Run, ``int`` for ScheduledJob) *inside* this guarded decode. Coercing here — rather than deferring to the ORM's ``id__lt`` lookup — means a wrong-type or wrong-tool cursor (the two listings share this format) fails as a caught ``ValueError``/``TypeError`` reported @@ -495,23 +495,23 @@ def _decode_created_id_cursor(raw: str, id_type: type) -> tuple[datetime, Any]: return parsed, id_type(payload["id"]) -def _serialize_job_summary(activity: Activity) -> dict: +def _serialize_job_summary(run: Run) -> dict: """Lean per-row summary for list_jobs — excludes result_summary (use get_job_status for that).""" return { - "job_id": str(activity.id), - "repo_id": activity.repo_id, - "ref": activity.ref or None, - "status": str(activity.status), - "title": activity.title or None, - "trigger_type": str(activity.trigger_type), - "thread_id": str(activity.thread_id) if activity.thread_id else None, - "batch_id": str(activity.batch_id) if activity.batch_id else None, - "merge_request_url": activity.merge_request_web_url or None, - "code_changes": activity.code_changes, - "created_at": activity.created_at.isoformat() if activity.created_at else None, - "finished_at": activity.finished_at.isoformat() if activity.finished_at else None, - "cost_usd": str(activity.cost_usd) if activity.cost_usd is not None else None, - "total_tokens": activity.total_tokens, + "job_id": str(run.id), + "repo_id": run.repo_id, + "ref": run.ref or None, + "status": str(run.status), + "title": run.title or None, + "trigger_type": str(run.trigger_type), + "thread_id": str(run.session_id) if run.session_id else None, + "batch_id": str(run.batch_id) if run.batch_id else None, + "merge_request_url": run.merge_request_web_url or None, + "code_changes": run.code_changes, + "created_at": run.created_at.isoformat() if run.created_at else None, + "finished_at": run.finished_at.isoformat() if run.finished_at else None, + "cost_usd": str(run.cost_usd) if run.cost_usd is not None else None, + "total_tokens": run.total_tokens, } @@ -519,7 +519,7 @@ def _serialize_job_summary(activity: Activity) -> dict: async def list_jobs( repo_id: Annotated[str | None, Field(description="Filter to one repository (repo_id).")] = None, status: Annotated[ - ActivityStatus | None, Field(description="Filter by status: QUEUED, READY, RUNNING, SUCCESSFUL, or FAILED.") + RunStatus | None, Field(description="Filter by status: QUEUED, READY, RUNNING, SUCCESSFUL, or FAILED.") ] = None, limit: LimitParam = DEFAULT_LIST_LIMIT, cursor: Annotated[ @@ -550,7 +550,7 @@ async def list_jobs( except _CURSOR_ERRORS: return {"error": "Invalid cursor."} try: - rows = await alist_user_activities( + rows = await alist_user_runs( mcp_user, repo_id=repo_id, status=str(status) if status else None, limit=capped + 1, before=before ) except Exception: diff --git a/tests/unit_tests/mcp_server/test_server.py b/tests/unit_tests/mcp_server/test_server.py index 071819d19..9e2e79ea0 100644 --- a/tests/unit_tests/mcp_server/test_server.py +++ b/tests/unit_tests/mcp_server/test_server.py @@ -5,6 +5,7 @@ import pytest from mcp_server.server import get_job_status, list_repositories, submit_job +from sessions.models import Run, RunStatus, Session, SessionOrigin from codebase.base import GitPlatform, Repository @@ -15,33 +16,31 @@ def _mock_task(): return m -class _FakeActivity: +class _FakeRun: _next_pk = 0 def __init__(self, task_result_id): self.id = uuid.uuid4() self.task_result_id = task_result_id - self.thread_id = str(uuid.uuid4()) + self.session_id = str(uuid.uuid4()) self.status = "READY" type(self)._next_pk += 1 self.pk = type(self)._next_pk # Tests bypass the real ORM via ``_patch_acreate``; provide an async no-op - # so the post-acreate ``activity.asave(update_fields=...)`` call in + # so the post-acreate ``run.asave(update_fields=...)`` call in # ``asubmit_batch_runs`` doesn't AttributeError on this stub. self.asave = AsyncMock(return_value=None) -async def _fake_acreate_activity(**kwargs): - return _FakeActivity(task_result_id=kwargs["task_result_id"]) +async def _fake_acreate_run(**kwargs): + return _FakeRun(task_result_id=kwargs["task_result_id"]) def _patch_acreate(): - # Patch acreate_activity and silence the post-create title task enqueue so + # Patch acreate_run and silence the post-create title task enqueue so # tests don't depend on the queue backend. - acreate_patch = patch( - "activity.services.acreate_activity", new_callable=AsyncMock, side_effect=_fake_acreate_activity - ) - title_patch = patch("activity.services.generate_batch_title_task") + acreate_patch = patch("sessions.services.acreate_run", new_callable=AsyncMock, side_effect=_fake_acreate_run) + title_patch = patch("sessions.services.generate_batch_title_task") class _Combined: def __enter__(self): @@ -75,7 +74,7 @@ def _default_mcp_user(db): @pytest.mark.django_db(transaction=True) async def test_submit_job_single_repo_returns_batch_response(): - with patch("activity.services.run_job_task") as mock_task, _patch_acreate(): + with patch("sessions.services.run_job_task") as mock_task, _patch_acreate(): mock_task.aenqueue = AsyncMock(return_value=_mock_task()) result = await submit_job(repos=[{"repo_id": "group/project", "ref": None}], prompt="Fix the bug") @@ -96,7 +95,7 @@ async def _aenqueue(**kwargs): call_log.append(kwargs) return tasks[len(call_log) - 1] - with patch("activity.services.run_job_task") as mock_task, _patch_acreate(): + with patch("sessions.services.run_job_task") as mock_task, _patch_acreate(): mock_task.aenqueue = _aenqueue result = await submit_job( repos=[{"repo_id": "o/a", "ref": None}, {"repo_id": "o/b", "ref": "dev"}, {"repo_id": "o/c", "ref": ""}], @@ -117,7 +116,7 @@ async def _flaky(**kwargs): raise RuntimeError("boom") return _mock_task() - with patch("activity.services.run_job_task") as mock_task, _patch_acreate(): + with patch("sessions.services.run_job_task") as mock_task, _patch_acreate(): mock_task.aenqueue = _flaky result = await submit_job(repos=[{"repo_id": "o/a", "ref": None}, {"repo_id": "o/b", "ref": None}], prompt="p") @@ -129,7 +128,7 @@ async def _flaky(**kwargs): @pytest.mark.django_db(transaction=True) async def test_submit_job_passes_ref(): - with patch("activity.services.run_job_task") as mock_task, _patch_acreate(): + with patch("sessions.services.run_job_task") as mock_task, _patch_acreate(): mock_task.aenqueue = AsyncMock(return_value=_mock_task()) await submit_job(repos=[{"repo_id": "group/project", "ref": "feature-branch"}], prompt="Fix the bug") mock_task.aenqueue.assert_called_once() @@ -195,7 +194,7 @@ async def test_submit_job_rejects_invalid_thinking_level(openrouter_provider): @pytest.mark.django_db(transaction=True) async def test_submit_job_forwards_agent_override(openrouter_provider): - with patch("activity.services.run_job_task") as mock_task, _patch_acreate() as mock_create: + with patch("sessions.services.run_job_task") as mock_task, _patch_acreate() as mock_create: mock_task.aenqueue = AsyncMock(return_value=_mock_task()) await submit_job( repos=[{"repo_id": "group/project", "ref": None}], @@ -213,10 +212,10 @@ async def test_submit_job_forwards_agent_override(openrouter_provider): @pytest.mark.django_db(transaction=True) async def test_submit_job_forwards_notify_on_to_activity(): - """MCP submit tool threads ``notify_on`` into ``acreate_activity``.""" + """MCP submit tool threads ``notify_on`` into ``acreate_run``.""" from notifications.choices import NotifyOn - with patch("activity.services.run_job_task") as mock_task, _patch_acreate() as mock_create: + with patch("sessions.services.run_job_task") as mock_task, _patch_acreate() as mock_create: mock_task.aenqueue = AsyncMock(return_value=_mock_task()) await submit_job(repos=[{"repo_id": "group/project", "ref": None}], prompt="p", notify_on=NotifyOn.ALWAYS) @@ -225,8 +224,8 @@ async def test_submit_job_forwards_notify_on_to_activity(): @pytest.mark.django_db(transaction=True) async def test_submit_job_notify_on_defaults_to_none(): - """Omitting ``notify_on`` forwards ``None`` to the activity.""" - with patch("activity.services.run_job_task") as mock_task, _patch_acreate() as mock_create: + """Omitting ``notify_on`` forwards ``None`` to the run.""" + with patch("sessions.services.run_job_task") as mock_task, _patch_acreate() as mock_create: mock_task.aenqueue = AsyncMock(return_value=_mock_task()) await submit_job(repos=[{"repo_id": "group/project", "ref": None}], prompt="p") @@ -236,7 +235,7 @@ async def test_submit_job_notify_on_defaults_to_none(): @pytest.mark.django_db(transaction=True) async def test_submit_job_all_fail(): """When every enqueue fails, no jobs in response, all entries in failed.""" - with patch("activity.services.run_job_task") as mock_task: + with patch("sessions.services.run_job_task") as mock_task: mock_task.aenqueue = AsyncMock(side_effect=Exception("DB down")) result = await submit_job(repos=[{"repo_id": "group/project", "ref": None}], prompt="Fix the bug") @@ -268,12 +267,12 @@ async def test_submit_job_wait_success(): mock_result.id = str(uuid.uuid4()) now = datetime.now(UTC) - created_activities: list[_FakeActivity] = [] + created_runs: list[_FakeRun] = [] async def _capture_acreate(**kwargs): - act = _FakeActivity(task_result_id=kwargs["task_result_id"]) - created_activities.append(act) - return act + run = _FakeRun(task_result_id=kwargs["task_result_id"]) + created_runs.append(run) + return run class _AsyncRows: def __init__(self, rows): @@ -286,30 +285,28 @@ async def _aiter(self): for r in self._rows: yield r - from activity.models import ActivityStatus - with ( - patch("activity.services.run_job_task") as mock_task, - patch("activity.services.acreate_activity", new_callable=AsyncMock, side_effect=_capture_acreate), - patch("activity.services.generate_batch_title_task") as mock_title, - patch("mcp_server.server.Activity") as mock_model, + patch("sessions.services.run_job_task") as mock_task, + patch("sessions.services.acreate_run", new_callable=AsyncMock, side_effect=_capture_acreate), + patch("sessions.services.generate_batch_title_task") as mock_title, + patch("mcp_server.server.Run") as mock_model, patch("mcp_server.server.asyncio.sleep", new_callable=AsyncMock), ): mock_task.aenqueue = AsyncMock(return_value=mock_result) mock_task.module_path = "jobs.tasks.run_job_task" mock_title.aenqueue = AsyncMock(return_value=None) - # We need to capture the activity ID after submit to build the finished mock. - # Use a filter side effect that builds the row from the captured activity. + # We need to capture the run ID after submit to build the finished mock. + # Use a filter side effect that builds the row from the captured run. def _make_filter(**kwargs): - if not created_activities: + if not created_runs: return _AsyncRows([]) finished = MagicMock() - finished.id = created_activities[0].id - finished.status = ActivityStatus.SUCCESSFUL + finished.id = created_runs[0].id + finished.status = RunStatus.SUCCESSFUL finished.result_summary = "All done" finished.merge_request_web_url = "" - finished.thread_id = None + finished.session_id = None finished.created_at = now finished.started_at = now finished.finished_at = now @@ -330,7 +327,7 @@ def _make_filter(**kwargs): async def test_submit_job_wait_running_when_never_terminal(): """When the batch poll times out without terminal results, statuses surface as RUNNING. - RUNNING is the closest valid Activity status; PENDING is not in the documented enum. + RUNNING is the closest valid Run status; PENDING is not in the documented enum. """ mock_result = MagicMock() mock_result.id = str(uuid.uuid4()) @@ -344,8 +341,8 @@ async def _aiter(self): yield None # never yields with ( - patch("activity.services.run_job_task") as mock_task, - patch("mcp_server.server.Activity") as mock_model, + patch("sessions.services.run_job_task") as mock_task, + patch("mcp_server.server.Run") as mock_model, _patch_acreate(), patch("mcp_server.server.asyncio.sleep", new_callable=AsyncMock), patch("mcp_server.server.MAX_POLL_DURATION", 4.0), @@ -363,7 +360,7 @@ async def _aiter(self): @pytest.mark.django_db(transaction=True) async def test_submit_job_batch_poll_filters_by_authenticated_user(_default_mcp_user): - """The batch poll must scope its Activity lookup by ``user=mcp_user`` to prevent + """The batch poll must scope its Run lookup by ``user=mcp_user`` to prevent cross-user reads. Asserts the call construction (not just behavior) so a refactor that drops the kwarg fails immediately.""" from mcp_server.server import _poll_batch_until_complete @@ -384,7 +381,7 @@ def _capture_filter(**kwargs): job_id = str(uuid.uuid4()) with ( - patch("mcp_server.server.Activity") as mock_model, + patch("mcp_server.server.Run") as mock_model, patch("mcp_server.server.asyncio.sleep", new_callable=AsyncMock), patch("mcp_server.server.MAX_POLL_DURATION", 2.0), patch("mcp_server.server.POLL_INTERVAL", 2.0), @@ -409,7 +406,7 @@ class _DoesNotExistError(Exception): with ( patch("mcp_server.server.get_current_user", new=AsyncMock(return_value=caller)), - patch("mcp_server.server.Activity") as mock_model, + patch("mcp_server.server.Run") as mock_model, ): mock_model.DoesNotExist = _DoesNotExistError mock_model.objects.aget = AsyncMock(side_effect=_DoesNotExistError) @@ -429,26 +426,24 @@ async def test_get_job_status_invalid_uuid(): @pytest.mark.django_db(transaction=True) async def test_get_job_status_wait_already_complete(): """When wait=True but the job is already complete, return immediately.""" - from activity.models import ActivityStatus - job_id = str(uuid.uuid4()) now = datetime.now(UTC) - mock_activity = MagicMock() - mock_activity.id = uuid.UUID(job_id) - mock_activity.status = ActivityStatus.SUCCESSFUL - mock_activity.result_summary = "Done" - mock_activity.merge_request_web_url = "" - mock_activity.thread_id = None - mock_activity.created_at = now - mock_activity.started_at = now - mock_activity.finished_at = now + mock_run = MagicMock() + mock_run.id = uuid.UUID(job_id) + mock_run.status = RunStatus.SUCCESSFUL + mock_run.result_summary = "Done" + mock_run.merge_request_web_url = "" + mock_run.session_id = None + mock_run.created_at = now + mock_run.started_at = now + mock_run.finished_at = now caller = MagicMock(pk=1) with ( - patch("mcp_server.server.Activity") as mock_model, + patch("mcp_server.server.Run") as mock_model, patch("mcp_server.server.get_current_user", new=AsyncMock(return_value=caller)), ): - mock_model.objects.aget = AsyncMock(return_value=mock_activity) + mock_model.objects.aget = AsyncMock(return_value=mock_run) mock_model.DoesNotExist = Exception result = await get_job_status(job_id=job_id, wait=True) @@ -463,28 +458,26 @@ async def test_get_job_status_wait_already_complete(): @pytest.mark.django_db(transaction=True) async def test_get_job_status_wait_polls_until_complete(): """When wait=True and the job is still running, poll until complete.""" - from activity.models import ActivityStatus - job_id = str(uuid.uuid4()) now = datetime.now(UTC) running_result = MagicMock() running_result.id = uuid.UUID(job_id) - running_result.status = ActivityStatus.RUNNING + running_result.status = RunStatus.RUNNING finished_result = MagicMock() finished_result.id = uuid.UUID(job_id) - finished_result.status = ActivityStatus.SUCCESSFUL + finished_result.status = RunStatus.SUCCESSFUL finished_result.result_summary = "Done" finished_result.merge_request_web_url = "" - finished_result.thread_id = None + finished_result.session_id = None finished_result.created_at = now finished_result.started_at = now finished_result.finished_at = now caller = MagicMock(pk=1) with ( - patch("mcp_server.server.Activity") as mock_model, + patch("mcp_server.server.Run") as mock_model, patch("mcp_server.server.asyncio.sleep", new_callable=AsyncMock), patch("mcp_server.server.get_current_user", new=AsyncMock(return_value=caller)), ): @@ -503,17 +496,15 @@ async def test_get_job_status_wait_polls_until_complete(): @pytest.mark.django_db(transaction=True) async def test_get_job_status_wait_not_found_then_appears(): """When wait=True and the job doesn't exist yet, poll until it appears.""" - from activity.models import ActivityStatus - job_id = str(uuid.uuid4()) now = datetime.now(UTC) finished_result = MagicMock() finished_result.id = uuid.UUID(job_id) - finished_result.status = ActivityStatus.SUCCESSFUL + finished_result.status = RunStatus.SUCCESSFUL finished_result.result_summary = "Done" finished_result.merge_request_web_url = "" - finished_result.thread_id = None + finished_result.session_id = None finished_result.created_at = now finished_result.started_at = now finished_result.finished_at = now @@ -523,7 +514,7 @@ class _DoesNotExistError(Exception): caller = MagicMock(pk=1) with ( - patch("mcp_server.server.Activity") as mock_model, + patch("mcp_server.server.Run") as mock_model, patch("mcp_server.server.asyncio.sleep", new_callable=AsyncMock), patch("mcp_server.server.get_current_user", new=AsyncMock(return_value=caller)), ): @@ -544,8 +535,8 @@ async def test_submit_job_batch_poll_db_exception_breaks_loop(): mock_result.id = str(uuid.uuid4()) with ( - patch("activity.services.run_job_task") as mock_task, - patch("mcp_server.server.Activity") as mock_model, + patch("sessions.services.run_job_task") as mock_task, + patch("mcp_server.server.Run") as mock_model, _patch_acreate(), patch("mcp_server.server.asyncio.sleep", new_callable=AsyncMock), patch("mcp_server.server.MAX_POLL_DURATION", 4.0), @@ -571,7 +562,7 @@ class _DoesNotExistError(Exception): caller = MagicMock(pk=1) with ( - patch("mcp_server.server.Activity") as mock_model, + patch("mcp_server.server.Run") as mock_model, patch("mcp_server.server.get_current_user", new=AsyncMock(return_value=caller)), ): mock_model.DoesNotExist = _DoesNotExistError @@ -690,7 +681,7 @@ async def test_list_repositories_error_handling(): class TestMCPThreadContinuation: async def test_response_includes_thread_id_and_status(self): with ( - patch("activity.services.run_job_task") as mock_task, + patch("sessions.services.run_job_task") as mock_task, _patch_acreate(), patch("mcp_server.server.get_current_user", new=AsyncMock(return_value=MagicMock(pk=1))), patch("mcp_server.server.aresolve_repo_envs", new=AsyncMock(side_effect=lambda **kw: kw["repos"])), @@ -744,29 +735,30 @@ async def test_unauthenticated_user_rejected(self): @pytest.mark.django_db(transaction=True) -async def test_get_job_status_other_user_activity_returns_not_found(): - """An MCP caller cannot read another user's Activity by id.""" - from activity.models import Activity, ActivityStatus, TriggerType - +async def test_get_job_status_other_user_run_returns_not_found(): + """An MCP caller cannot read another user's Run by id.""" from accounts.models import User - # One user owns the Activity + # One user owns the Run owner = await User.objects.acreate_user( username="owner_mcp", email="owner_mcp@example.com", password="x", # noqa: S106 ) - activity = await Activity.objects.acreate( - trigger_type=TriggerType.MCP_JOB, repo_id="a/b", status=ActivityStatus.SUCCESSFUL, user=owner + session = await Session.objects.acreate( + thread_id=str(uuid.uuid4()), origin=SessionOrigin.MCP_JOB, user=owner, repo_id="a/b" + ) + run = await Run.objects.acreate( + session=session, trigger_type=SessionOrigin.MCP_JOB, repo_id="a/b", status=RunStatus.SUCCESSFUL, user=owner ) - # A different caller tries to read the same Activity id + # A different caller tries to read the same Run id caller = await User.objects.acreate_user( username="caller_mcp", email="caller_mcp@example.com", password="x", # noqa: S106 ) with patch("mcp_server.server.get_current_user", new=AsyncMock(return_value=caller)): - result = await get_job_status(job_id=str(activity.id)) + result = await get_job_status(job_id=str(run.id)) data = json.loads(result) assert "Job not found" in data["error"] diff --git a/tests/unit_tests/mcp_server/test_server_envs.py b/tests/unit_tests/mcp_server/test_server_envs.py index 13c1f1e7e..02426bdad 100644 --- a/tests/unit_tests/mcp_server/test_server_envs.py +++ b/tests/unit_tests/mcp_server/test_server_envs.py @@ -111,10 +111,8 @@ async def test_submit_job_resolves_environment_name(): env = await SandboxEnvironment.objects.acreate(scope=Scope.USER, user=user, name="dev", base_image="alpine:latest") from mcp_server.server import submit_job - fake_activity = type( - "A", (), {"id": "00000000-0000-0000-0000-000000000001", "thread_id": None, "status": "READY"} - )() # noqa: E501 - fake_result = type("R", (), {"batch_id": "b", "activities": [fake_activity], "failed": []})() + fake_run = type("R", (), {"id": "00000000-0000-0000-0000-000000000001", "session_id": None, "status": "READY"})() + fake_result = type("R", (), {"batch_id": "b", "runs": [fake_run], "failed": []})() with ( patch("mcp_server.server.get_current_user", new=AsyncMock(return_value=user)), patch("mcp_server.server.asubmit_batch_runs", new=AsyncMock(return_value=fake_result)) as submit, diff --git a/tests/unit_tests/mcp_server/test_server_jobs.py b/tests/unit_tests/mcp_server/test_server_jobs.py index 5428ab95e..24a7027be 100644 --- a/tests/unit_tests/mcp_server/test_server_jobs.py +++ b/tests/unit_tests/mcp_server/test_server_jobs.py @@ -1,3 +1,4 @@ +import uuid from datetime import timedelta from decimal import Decimal from unittest.mock import AsyncMock, patch @@ -5,7 +6,7 @@ from django.utils import timezone import pytest -from activity.models import Activity, ActivityStatus, TriggerType +from sessions.models import Run, RunStatus, Session, SessionOrigin from accounts.models import User @@ -14,18 +15,30 @@ async def _user(username): return await User.objects.acreate_user(username=username, email=f"{username}@e.com", password="x") # noqa: S106 +async def _session(user, *, repo_id="a/b", thread_id=None): + return await Session.objects.acreate( + thread_id=thread_id or str(uuid.uuid4()), origin=SessionOrigin.MCP_JOB, user=user, repo_id=repo_id + ) + + +async def _run(session, *, status=RunStatus.QUEUED, **kwargs): + return await Run.objects.acreate( + session=session, + user=session.user, + repo_id=session.repo_id, + trigger_type=SessionOrigin.MCP_JOB, + status=status, + **kwargs, + ) + + @pytest.mark.django_db(transaction=True) async def test_list_jobs_returns_user_jobs_and_excludes_result_summary(): from mcp_server.server import list_jobs user = await _user("lj1") - await Activity.objects.acreate( - user=user, - repo_id="a/b", - status=ActivityStatus.SUCCESSFUL, - trigger_type=TriggerType.MCP_JOB, - result_summary="secret detail", - ) + sess = await _session(user) + await _run(sess, status=RunStatus.SUCCESSFUL, result_summary="secret detail") with patch("mcp_server.server.get_current_user", new=AsyncMock(return_value=user)): data = await list_jobs() assert data["next_cursor"] is None @@ -41,10 +54,9 @@ async def test_list_jobs_truncates_and_caps(): from mcp_server.server import list_jobs user = await _user("lj2") + sess = await _session(user) for _ in range(3): - await Activity.objects.acreate( - user=user, repo_id="a/b", status=ActivityStatus.READY, trigger_type=TriggerType.MCP_JOB - ) + await _run(sess) with patch("mcp_server.server.get_current_user", new=AsyncMock(return_value=user)): data = await list_jobs(limit=2) assert len(data["jobs"]) == 2 @@ -58,18 +70,15 @@ async def test_list_jobs_cursor_paginates_without_overlap(): user = await _user("lj_pg") now = timezone.now() + sess = await _session(user) created = [] for _ in range(5): - created.append( - await Activity.objects.acreate( - user=user, repo_id="a/b", status=ActivityStatus.READY, trigger_type=TriggerType.MCP_JOB - ) - ) + created.append(await _run(sess)) # Give each a distinct created_at so ordering is deterministic. created[0] gets the # latest timestamp (now - 0min), so created is already in newest-first order. - for offset, act in enumerate(created): - await Activity.objects.filter(pk=act.pk).aupdate(created_at=now - timedelta(minutes=offset)) - expected = [str(a.id) for a in created] # newest first + for offset, run in enumerate(created): + await Run.objects.filter(pk=run.pk).aupdate(created_at=now - timedelta(minutes=offset)) + expected = [str(r.id) for r in created] # newest first seen: list[str] = [] cursor = None @@ -92,15 +101,12 @@ async def test_list_jobs_cursor_tie_break_on_same_created_at(): user = await _user("lj_tie") same = timezone.now() + sess = await _session(user) created = [] for _ in range(4): - created.append( - await Activity.objects.acreate( - user=user, repo_id="a/b", status=ActivityStatus.READY, trigger_type=TriggerType.MCP_JOB - ) - ) - for act in created: - await Activity.objects.filter(pk=act.pk).aupdate(created_at=same) + created.append(await _run(sess)) + for run in created: + await Run.objects.filter(pk=run.pk).aupdate(created_at=same) seen: list[str] = [] cursor = None @@ -112,7 +118,7 @@ async def test_list_jobs_cursor_tie_break_on_same_created_at(): if cursor is None: break - assert sorted(seen) == sorted(str(a.id) for a in created) + assert sorted(seen) == sorted(str(r.id) for r in created) assert len(seen) == len(set(seen)) # each row exactly once @@ -129,7 +135,7 @@ async def test_list_jobs_invalid_cursor_returns_error(): @pytest.mark.django_db(transaction=True) async def test_list_jobs_wrong_tool_or_bad_id_cursor_returns_invalid(): - """A decodable cursor whose id can't coerce to Activity's UUID PK (e.g. a schedules + """A decodable cursor whose id can't coerce to Run's UUID PK (e.g. a schedules cursor with an integer id, or plain junk) must be reported as "Invalid cursor." at decode time — not deferred to the ORM where the generic handler mislabels it as transient.""" from mcp_server.server import _encode_cursor, list_jobs @@ -155,16 +161,13 @@ async def test_list_jobs_orders_newest_first(): from mcp_server.server import list_jobs user = await _user("lj3") - older = await Activity.objects.acreate( - user=user, repo_id="a/b", status=ActivityStatus.READY, trigger_type=TriggerType.MCP_JOB - ) - newer = await Activity.objects.acreate( - user=user, repo_id="a/b", status=ActivityStatus.READY, trigger_type=TriggerType.MCP_JOB - ) + sess = await _session(user) + older = await _run(sess) + newer = await _run(sess) # created_at is auto_now_add, so nudge it explicitly to make ordering observable. now = timezone.now() - await Activity.objects.filter(pk=older.pk).aupdate(created_at=now - timedelta(hours=1)) - await Activity.objects.filter(pk=newer.pk).aupdate(created_at=now) + await Run.objects.filter(pk=older.pk).aupdate(created_at=now - timedelta(hours=1)) + await Run.objects.filter(pk=newer.pk).aupdate(created_at=now) with patch("mcp_server.server.get_current_user", new=AsyncMock(return_value=user)): data = await list_jobs() assert [j["job_id"] for j in data["jobs"]] == [str(newer.id), str(older.id)] @@ -175,10 +178,9 @@ async def test_list_jobs_not_truncated_at_exact_limit(): from mcp_server.server import list_jobs user = await _user("lj4") + sess = await _session(user) for _ in range(2): - await Activity.objects.acreate( - user=user, repo_id="a/b", status=ActivityStatus.READY, trigger_type=TriggerType.MCP_JOB - ) + await _run(sess) with patch("mcp_server.server.get_current_user", new=AsyncMock(return_value=user)): data = await list_jobs(limit=2) assert len(data["jobs"]) == 2 @@ -190,14 +192,8 @@ async def test_list_jobs_serializes_cost_and_tokens(): from mcp_server.server import list_jobs user = await _user("lj5") - await Activity.objects.acreate( - user=user, - repo_id="a/b", - status=ActivityStatus.SUCCESSFUL, - trigger_type=TriggerType.MCP_JOB, - cost_usd=Decimal("1.234567"), - total_tokens=4242, - ) + sess = await _session(user) + await _run(sess, status=RunStatus.SUCCESSFUL, cost_usd=Decimal("1.234567"), total_tokens=4242) with patch("mcp_server.server.get_current_user", new=AsyncMock(return_value=user)): data = await list_jobs() job = data["jobs"][0] @@ -211,14 +207,11 @@ async def test_list_jobs_status_filter(): from mcp_server.server import list_jobs user = await _user("lj6") - await Activity.objects.acreate( - user=user, repo_id="a/b", status=ActivityStatus.RUNNING, trigger_type=TriggerType.MCP_JOB - ) - await Activity.objects.acreate( - user=user, repo_id="a/b", status=ActivityStatus.SUCCESSFUL, trigger_type=TriggerType.MCP_JOB - ) + sess = await _session(user) + await _run(sess, status=RunStatus.RUNNING) + await _run(sess, status=RunStatus.SUCCESSFUL) with patch("mcp_server.server.get_current_user", new=AsyncMock(return_value=user)): - data = await list_jobs(status=ActivityStatus.RUNNING) + data = await list_jobs(status=RunStatus.RUNNING) assert {j["status"] for j in data["jobs"]} == {"RUNNING"} @@ -229,7 +222,7 @@ async def test_list_jobs_db_error_returns_friendly_error(): user = await _user("lj7") with ( patch("mcp_server.server.get_current_user", new=AsyncMock(return_value=user)), - patch("mcp_server.server.alist_user_activities", new=AsyncMock(side_effect=RuntimeError("db down"))), + patch("mcp_server.server.alist_user_runs", new=AsyncMock(side_effect=RuntimeError("db down"))), ): data = await list_jobs() assert "error" in data From 744e27ab2cbd85a309057aee22df88cc6dcf9310 Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 7 Jul 2026 17:48:37 +0100 Subject: [PATCH 10/55] refactor(sessions): switch webhooks, schedules, dashboard and sandbox_envs to Session/Run --- daiv/accounts/views.py | 30 +++---- daiv/codebase/clients/github/api/callbacks.py | 34 ++++--- daiv/codebase/clients/gitlab/api/callbacks.py | 34 ++++--- daiv/sandbox_envs/services.py | 4 +- daiv/schedules/models.py | 2 +- daiv/schedules/tasks.py | 6 +- daiv/schedules/views.py | 12 +-- .../clients/github/api/test_callbacks.py | 12 +-- .../clients/github/test_webhook_use_max.py | 75 +++++++++++++--- .../clients/gitlab/api/test_callbacks.py | 14 +-- .../clients/gitlab/test_webhook_use_max.py | 88 ++++++++++++++++--- .../unit_tests/sandbox_envs/test_services.py | 14 +-- .../schedules/test_dispatch_sandbox_env.py | 16 ++-- tests/unit_tests/schedules/test_tasks.py | 36 ++++---- tests/unit_tests/schedules/test_views.py | 50 +++++------ 15 files changed, 279 insertions(+), 148 deletions(-) diff --git a/daiv/accounts/views.py b/daiv/accounts/views.py index 03e8467a8..1e50828ff 100644 --- a/daiv/accounts/views.py +++ b/daiv/accounts/views.py @@ -12,8 +12,8 @@ from django.views import View from django.views.generic import CreateView, DeleteView, ListView, TemplateView, UpdateView -from activity.models import Activity, ActivityStatus, TriggerType from django_filters.views import FilterView +from sessions.models import Run, RunStatus, SessionOrigin from accounts.context_processors import running_jobs_count from accounts.emails import send_welcome_email @@ -134,16 +134,16 @@ def get_context_data(self, **kwargs): return context def _get_activity_data(self, cutoff_date: date | None, user: User) -> dict: - owned = Activity.objects.by_owner(user) + owned = Run.objects.by_owner(user) activities = owned.filter(created_at__date__gte=cutoff_date) if cutoff_date is not None else owned - successful = Q(status=ActivityStatus.SUCCESSFUL) - failed = Q(status=ActivityStatus.FAILED) - issue_trigger = Q(trigger_type=TriggerType.ISSUE_WEBHOOK) - mr_trigger = Q(trigger_type=TriggerType.MR_WEBHOOK) - mcp_trigger = Q(trigger_type=TriggerType.MCP_JOB) - schedule_trigger = Q(trigger_type=TriggerType.SCHEDULE) - api_trigger = Q(trigger_type=TriggerType.API_JOB) + successful = Q(status=RunStatus.SUCCESSFUL) + failed = Q(status=RunStatus.FAILED) + issue_trigger = Q(trigger_type=SessionOrigin.ISSUE_WEBHOOK) + mr_trigger = Q(trigger_type=SessionOrigin.MR_WEBHOOK) + mcp_trigger = Q(trigger_type=SessionOrigin.MCP_JOB) + schedule_trigger = Q(trigger_type=SessionOrigin.SCHEDULE) + api_trigger = Q(trigger_type=SessionOrigin.API_JOB) duration_expr = ExpressionWrapper(F("finished_at") - F("started_at"), output_field=DurationField()) stats = activities.aggregate( @@ -181,13 +181,13 @@ def _get_activity_data(self, cutoff_date: date | None, user: User) -> dict: 0, total - issues_count - mrs_count - mcp_jobs_count - scheduled_count - api_jobs_count - failed_count ) raw_segments = [ - ("Issues", issues_count, "bg-amber-500/50", f"{activity_url}?trigger={TriggerType.ISSUE_WEBHOOK}"), - ("MR/PR", mrs_count, "bg-cyan-500/50", f"{activity_url}?trigger={TriggerType.MR_WEBHOOK}"), - ("MCP Job", mcp_jobs_count, "bg-indigo-500/50", f"{activity_url}?trigger={TriggerType.MCP_JOB}"), - ("Scheduled", scheduled_count, "bg-violet-500/40", f"{activity_url}?trigger={TriggerType.SCHEDULE}"), - ("API", api_jobs_count, "bg-emerald-500/50", f"{activity_url}?trigger={TriggerType.API_JOB}"), + ("Issues", issues_count, "bg-amber-500/50", f"{activity_url}?trigger={SessionOrigin.ISSUE_WEBHOOK}"), + ("MR/PR", mrs_count, "bg-cyan-500/50", f"{activity_url}?trigger={SessionOrigin.MR_WEBHOOK}"), + ("MCP Job", mcp_jobs_count, "bg-indigo-500/50", f"{activity_url}?trigger={SessionOrigin.MCP_JOB}"), + ("Scheduled", scheduled_count, "bg-violet-500/40", f"{activity_url}?trigger={SessionOrigin.SCHEDULE}"), + ("API", api_jobs_count, "bg-emerald-500/50", f"{activity_url}?trigger={SessionOrigin.API_JOB}"), ("Other", other_count, "bg-gray-500/30", None), - ("Failed", failed_count, "bg-red-500/40", f"{activity_url}?status={ActivityStatus.FAILED}"), + ("Failed", failed_count, "bg-red-500/40", f"{activity_url}?status={RunStatus.FAILED}"), ] segments = [] for label, value, css, url in raw_segments: diff --git a/daiv/codebase/clients/github/api/callbacks.py b/daiv/codebase/clients/github/api/callbacks.py index b6fd3e4fd..220116083 100644 --- a/daiv/codebase/clients/github/api/callbacks.py +++ b/daiv/codebase/clients/github/api/callbacks.py @@ -2,11 +2,11 @@ from functools import cached_property from typing import Any, Literal -from activity.models import TriggerType -from activity.services import acreate_activity from asgiref.sync import sync_to_async from github.GithubException import GithubException from sandbox_envs.services import resolve_env_for_run +from sessions.models import SessionOrigin +from sessions.services import acreate_run from accounts.utils import resolve_user from codebase.api.callbacks import BaseCallback @@ -113,12 +113,16 @@ async def process_callback(self): ) daiv_user = await resolve_user("github", self.sender.id, username=self.sender.username) try: - await acreate_activity( - trigger_type=TriggerType.ISSUE_WEBHOOK, + from core.site_settings import site_settings + + has_max = self.issue.has_max_label() + await acreate_run( + trigger_type=SessionOrigin.ISSUE_WEBHOOK, task_result_id=result.id, repo_id=self.repository.full_name, issue_iid=self.issue.number, - use_max=self.issue.has_max_label(), + agent_model=site_settings.agent_max_model_name if has_max else "", + agent_thinking_level=site_settings.agent_max_thinking_level if has_max else "", user=daiv_user, external_username=self.sender.username, title=self.issue.title, @@ -191,13 +195,17 @@ async def process_callback(self): sandbox_environment_id=sandbox_environment_id, ) try: - await acreate_activity( - trigger_type=TriggerType.ISSUE_WEBHOOK, + from core.site_settings import site_settings + + has_max = self.issue.has_max_label() + await acreate_run( + trigger_type=SessionOrigin.ISSUE_WEBHOOK, task_result_id=result.id, repo_id=self.repository.full_name, issue_iid=self.issue.number, mention_comment_id=str(self.comment.id), - use_max=self.issue.has_max_label(), + agent_model=site_settings.agent_max_model_name if has_max else "", + agent_thinking_level=site_settings.agent_max_thinking_level if has_max else "", user=daiv_user, external_username=self.comment.user.username, title=self.issue.title, @@ -239,14 +247,18 @@ async def process_callback(self): "Failed to resolve source branch for PR comment %s#%s", self.repository.full_name, self.issue.number ) try: - await acreate_activity( - trigger_type=TriggerType.MR_WEBHOOK, + from core.site_settings import site_settings + + has_max = self.issue.has_max_label() + await acreate_run( + trigger_type=SessionOrigin.MR_WEBHOOK, task_result_id=result.id, repo_id=self.repository.full_name, ref=source_branch, merge_request_iid=self.issue.number, mention_comment_id=str(self.comment.id), - use_max=self.issue.has_max_label(), + agent_model=site_settings.agent_max_model_name if has_max else "", + agent_thinking_level=site_settings.agent_max_thinking_level if has_max else "", user=daiv_user, external_username=self.comment.user.username, title=self.issue.title, diff --git a/daiv/codebase/clients/gitlab/api/callbacks.py b/daiv/codebase/clients/gitlab/api/callbacks.py index cd141ba08..9da9677a0 100644 --- a/daiv/codebase/clients/gitlab/api/callbacks.py +++ b/daiv/codebase/clients/gitlab/api/callbacks.py @@ -2,10 +2,10 @@ from functools import cached_property from typing import Any, Literal -from activity.models import TriggerType -from activity.services import acreate_activity from gitlab.exceptions import GitlabError from sandbox_envs.services import resolve_env_for_run +from sessions.models import SessionOrigin +from sessions.services import acreate_run from accounts.utils import resolve_user from codebase.api.callbacks import BaseCallback @@ -128,12 +128,16 @@ async def process_callback(self): ) daiv_user = await resolve_user("gitlab", self.user.id, username=self.user.username, email=self.user.email) try: - await acreate_activity( - trigger_type=TriggerType.ISSUE_WEBHOOK, + from core.site_settings import site_settings + + has_max = self.object_attributes.has_max_label() + await acreate_run( + trigger_type=SessionOrigin.ISSUE_WEBHOOK, task_result_id=result.id, repo_id=self.project.path_with_namespace, issue_iid=self.object_attributes.iid, - use_max=self.object_attributes.has_max_label(), + agent_model=site_settings.agent_max_model_name if has_max else "", + agent_thinking_level=site_settings.agent_max_thinking_level if has_max else "", user=daiv_user, external_username=self.user.username, title=self.object_attributes.title, @@ -214,13 +218,17 @@ async def process_callback(self): sandbox_environment_id=sandbox_environment_id, ) try: - await acreate_activity( - trigger_type=TriggerType.ISSUE_WEBHOOK, + from core.site_settings import site_settings + + has_max = self.issue.has_max_label() + await acreate_run( + trigger_type=SessionOrigin.ISSUE_WEBHOOK, task_result_id=result.id, repo_id=self.project.path_with_namespace, issue_iid=self.issue.iid, mention_comment_id=self.object_attributes.discussion_id, - use_max=self.issue.has_max_label(), + agent_model=site_settings.agent_max_model_name if has_max else "", + agent_thinking_level=site_settings.agent_max_thinking_level if has_max else "", user=daiv_user, external_username=self.user.username, title=self.issue.title, @@ -254,14 +262,18 @@ async def process_callback(self): sandbox_environment_id=sandbox_environment_id, ) try: - await acreate_activity( - trigger_type=TriggerType.MR_WEBHOOK, + from core.site_settings import site_settings + + has_max = self.merge_request.has_max_label() + await acreate_run( + trigger_type=SessionOrigin.MR_WEBHOOK, task_result_id=result.id, repo_id=self.project.path_with_namespace, ref=self.merge_request.source_branch, merge_request_iid=self.merge_request.iid, mention_comment_id=self.object_attributes.discussion_id, - use_max=self.merge_request.has_max_label(), + agent_model=site_settings.agent_max_model_name if has_max else "", + agent_thinking_level=site_settings.agent_max_thinking_level if has_max else "", user=daiv_user, external_username=self.user.username, title=self.merge_request.title, diff --git a/daiv/sandbox_envs/services.py b/daiv/sandbox_envs/services.py index 4c3a21203..144eb1360 100644 --- a/daiv/sandbox_envs/services.py +++ b/daiv/sandbox_envs/services.py @@ -13,7 +13,7 @@ from sandbox_envs.models import SandboxEnvironment, Scope, _fmt_cpus, _fmt_memory if TYPE_CHECKING: - from activity.services import RepoTarget + from sessions.services import RepoTarget from codebase.base import Repository from codebase.clients import RepoClient @@ -192,7 +192,7 @@ def env_picker_context(form) -> dict: async def aresolve_repo_envs(*, user, repos: list[RepoTarget], explicit_env_id: str | None) -> list[RepoTarget]: - """Stamp ``sandbox_environment_id`` on each :class:`activity.services.RepoTarget`. + """Stamp ``sandbox_environment_id`` on each :class:`sessions.services.RepoTarget`. When ``explicit_env_id`` is set every target gets that id; otherwise each repo is matched against a per-call snapshot of USER envs (owned by ``user``), GLOBAL non-default diff --git a/daiv/schedules/models.py b/daiv/schedules/models.py index a37175cc7..800580263 100644 --- a/daiv/schedules/models.py +++ b/daiv/schedules/models.py @@ -10,10 +10,10 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from activity.services import validate_repo_list from croniter import croniter from django_extensions.db.models import TimeStampedModel from notifications.choices import NotifyOn +from sessions.services import validate_repo_list from automation.agent.display import MODEL_NAME_MAX_LEN, display_model_name, display_thinking_level from core.models import ThinkingLevelChoices diff --git a/daiv/schedules/tasks.py b/daiv/schedules/tasks.py index b2f1a9653..cba21b8e7 100644 --- a/daiv/schedules/tasks.py +++ b/daiv/schedules/tasks.py @@ -21,9 +21,9 @@ def dispatch_scheduled_jobs_cron_task(): Each schedule is processed in its own savepoint so that one failure does not roll back updates for other schedules. """ - from activity.models import TriggerType - from activity.services import RepoTarget, submit_batch_runs from sandbox_envs.services import resolve_repo_envs + from sessions.models import SessionOrigin + from sessions.services import RepoTarget, submit_batch_runs now = datetime.now(tz=UTC) dispatched = 0 @@ -52,7 +52,7 @@ def dispatch_scheduled_jobs_cron_task(): agent_model=schedule.agent_model, agent_thinking_level=schedule.agent_thinking_level, notify_on=None, - trigger_type=TriggerType.SCHEDULE, + trigger_type=SessionOrigin.SCHEDULE, scheduled_job=schedule, ) schedule.last_run_at = now diff --git a/daiv/schedules/views.py b/daiv/schedules/views.py index b431243ac..4f526a61b 100644 --- a/daiv/schedules/views.py +++ b/daiv/schedules/views.py @@ -14,9 +14,9 @@ from django.views import View from django.views.generic import CreateView, DeleteView, ListView, UpdateView -from activity.models import TriggerType -from activity.services import RepoTarget, submit_batch_runs from sandbox_envs.services import env_picker_context, resolve_repo_envs +from sessions.models import SessionOrigin +from sessions.services import RepoTarget, submit_batch_runs from accounts.mixins import AdminRequiredMixin, BreadcrumbMixin from accounts.templatetags.avatar_tags import user_color_index, user_initials @@ -236,7 +236,7 @@ def post(self, request, pk): agent_model=schedule.agent_model, agent_thinking_level=schedule.agent_thinking_level, notify_on=None, - trigger_type=TriggerType.SCHEDULE, + trigger_type=SessionOrigin.SCHEDULE, scheduled_job=schedule, ) except Exception: @@ -250,9 +250,9 @@ def post(self, request, pk): else: messages.success(request, f"Schedule '{schedule.name}' triggered successfully.") - if len(result.activities) == 1 and not result.failed: - return redirect("activity_detail", pk=result.activities[0].pk) - if result.activities: + if len(result.runs) == 1 and not result.failed: + return redirect("activity_detail", pk=result.runs[0].pk) + if result.runs: return redirect(reverse("activity_list") + f"?batch={result.batch_id}") return redirect("schedule_list") diff --git a/tests/unit_tests/codebase/clients/github/api/test_callbacks.py b/tests/unit_tests/codebase/clients/github/api/test_callbacks.py index 1b851fc70..222d00aec 100644 --- a/tests/unit_tests/codebase/clients/github/api/test_callbacks.py +++ b/tests/unit_tests/codebase/clients/github/api/test_callbacks.py @@ -316,7 +316,7 @@ async def test_issue_callback_passes_thread_id(self, monkeypatch_dependencies, m with ( patch("codebase.clients.github.api.callbacks.address_issue_task") as mock_task, - patch("codebase.clients.github.api.callbacks.acreate_activity") as mock_activity, + patch("codebase.clients.github.api.callbacks.acreate_run") as mock_activity, patch("codebase.clients.github.api.callbacks.resolve_user", new=AsyncMock(return_value=None)), patch("codebase.clients.github.api.callbacks.resolve_env_for_run", new=AsyncMock(return_value=None)), ): @@ -344,7 +344,7 @@ async def test_issue_comment_callback_passes_thread_id(self, monkeypatch_depende with ( patch("codebase.clients.github.api.callbacks.address_issue_task") as mock_task, - patch("codebase.clients.github.api.callbacks.acreate_activity") as mock_activity, + patch("codebase.clients.github.api.callbacks.acreate_run") as mock_activity, patch("codebase.clients.github.api.callbacks.note_mentions_daiv", return_value=True), patch("codebase.clients.github.api.callbacks.resolve_user", new=AsyncMock(return_value=None)), patch("codebase.clients.github.api.callbacks.resolve_env_for_run", new=AsyncMock(return_value=None)), @@ -377,7 +377,7 @@ async def test_pr_review_comment_callback_passes_thread_id(self, monkeypatch_dep with ( patch("codebase.clients.github.api.callbacks.address_mr_comments_task") as mock_task, - patch("codebase.clients.github.api.callbacks.acreate_activity") as mock_activity, + patch("codebase.clients.github.api.callbacks.acreate_run") as mock_activity, patch("codebase.clients.github.api.callbacks.note_mentions_daiv", return_value=True), patch("codebase.clients.github.api.callbacks.resolve_user", new=AsyncMock(return_value=None)), patch("codebase.clients.github.api.callbacks.resolve_env_for_run", new=AsyncMock(return_value=None)), @@ -404,7 +404,7 @@ async def test_issue_callback_propagates_env_id(self, monkeypatch_dependencies, with ( patch("codebase.clients.github.api.callbacks.address_issue_task") as mock_task, - patch("codebase.clients.github.api.callbacks.acreate_activity") as mock_activity, + patch("codebase.clients.github.api.callbacks.acreate_run") as mock_activity, patch("codebase.clients.github.api.callbacks.resolve_user", new=AsyncMock(return_value=None)), patch("codebase.clients.github.api.callbacks.resolve_env_for_run", new=AsyncMock(return_value=env_row)), ): @@ -432,7 +432,7 @@ async def test_pr_review_comment_callback_propagates_env_id(self, monkeypatch_de with ( patch("codebase.clients.github.api.callbacks.address_mr_comments_task") as mock_task, - patch("codebase.clients.github.api.callbacks.acreate_activity") as mock_activity, + patch("codebase.clients.github.api.callbacks.acreate_run") as mock_activity, patch("codebase.clients.github.api.callbacks.note_mentions_daiv", return_value=True), patch("codebase.clients.github.api.callbacks.resolve_user", new=AsyncMock(return_value=None)), patch("codebase.clients.github.api.callbacks.resolve_env_for_run", new=AsyncMock(return_value=env_row)), @@ -459,7 +459,7 @@ async def test_issue_comment_callback_propagates_env_id(self, monkeypatch_depend with ( patch("codebase.clients.github.api.callbacks.address_issue_task") as mock_task, - patch("codebase.clients.github.api.callbacks.acreate_activity") as mock_activity, + patch("codebase.clients.github.api.callbacks.acreate_run") as mock_activity, patch("codebase.clients.github.api.callbacks.note_mentions_daiv", return_value=True), patch("codebase.clients.github.api.callbacks.resolve_user", new=AsyncMock(return_value=None)), patch("codebase.clients.github.api.callbacks.resolve_env_for_run", new=AsyncMock(return_value=env_row)), diff --git a/tests/unit_tests/codebase/clients/github/test_webhook_use_max.py b/tests/unit_tests/codebase/clients/github/test_webhook_use_max.py index 76d107a7d..74f21fcdf 100644 --- a/tests/unit_tests/codebase/clients/github/test_webhook_use_max.py +++ b/tests/unit_tests/codebase/clients/github/test_webhook_use_max.py @@ -2,8 +2,8 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from activity.models import Activity from django_tasks_db.models import DBTaskResult, get_date_max +from sessions.models import Run, Session from codebase.clients.github.api.callbacks import IssueCallback, IssueCommentCallback from codebase.clients.github.api.models import Comment, Issue, Label, PullRequest, Ref, Repository, User @@ -55,7 +55,7 @@ def test_pull_request_has_max_label_false_when_absent(): # --------------------------------------------------------------------------- -# End-to-end: ``process_callback`` persists ``use_max`` on the Activity row. +# End-to-end: ``process_callback`` persists model-pair on the Run row. # --------------------------------------------------------------------------- @@ -99,9 +99,11 @@ def _stub_github(monkeypatch): @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize( - "labels, expected", [([Label(id=1, name="daiv-max")], True), ([Label(id=1, name="daiv")], False)] + "labels, expect_max_model", [([Label(id=1, name="daiv-max")], True), ([Label(id=1, name="daiv")], False)] ) -async def test_issue_callback_persists_use_max(_stub_github, labels, expected): +async def test_issue_callback_persists_agent_model(_stub_github, labels, expect_max_model): + from core.site_settings import site_settings + task_id = await _make_db_task_result() callback = IssueCallback( action="opened", @@ -113,16 +115,23 @@ async def test_issue_callback_persists_use_max(_stub_github, labels, expected): mock_task.aenqueue = AsyncMock(return_value=MagicMock(id=task_id)) await callback.process_callback() - activity = await Activity.objects.aget(task_result_id=task_id) - assert activity.use_max is expected + run = await Run.objects.aget(task_result_id=task_id) + if expect_max_model: + assert run.agent_model == site_settings.agent_max_model_name + assert run.agent_thinking_level == site_settings.agent_max_thinking_level + else: + assert run.agent_model == "" + assert run.agent_thinking_level == "" @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize( - "labels, expected", [([Label(id=1, name="daiv-max")], True), ([Label(id=1, name="daiv")], False)] + "labels, expect_max_model", [([Label(id=1, name="daiv-max")], True), ([Label(id=1, name="daiv")], False)] ) -async def test_issue_comment_callback_persists_use_max(_stub_github, labels, expected): +async def test_issue_comment_callback_persists_agent_model(_stub_github, labels, expect_max_model): + from core.site_settings import site_settings + task_id = await _make_db_task_result() callback = IssueCommentCallback( action="created", @@ -137,16 +146,21 @@ async def test_issue_comment_callback_persists_use_max(_stub_github, labels, exp mock_task.aenqueue = AsyncMock(return_value=MagicMock(id=task_id)) await callback.process_callback() - activity = await Activity.objects.aget(task_result_id=task_id) - assert activity.use_max is expected + run = await Run.objects.aget(task_result_id=task_id) + if expect_max_model: + assert run.agent_model == site_settings.agent_max_model_name + else: + assert run.agent_model == "" @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize( - "labels, expected", [([Label(id=1, name="daiv-max")], True), ([Label(id=1, name="daiv")], False)] + "labels, expect_max_model", [([Label(id=1, name="daiv-max")], True), ([Label(id=1, name="daiv")], False)] ) -async def test_pr_comment_callback_persists_use_max(_stub_github, labels, expected): +async def test_pr_comment_callback_persists_agent_model(_stub_github, labels, expect_max_model): + from core.site_settings import site_settings + task_id = await _make_db_task_result() # GitHub webhooks treat PR comments as Issue comments, so ``issue`` here carries the PR-stub dict. pr_stub_issue = _issue(labels, pull_request={"url": "https://example/pr/1"}) @@ -163,5 +177,38 @@ async def test_pr_comment_callback_persists_use_max(_stub_github, labels, expect mock_task.aenqueue = AsyncMock(return_value=MagicMock(id=task_id)) await callback.process_callback() - activity = await Activity.objects.aget(task_result_id=task_id) - assert activity.use_max is expected + run = await Run.objects.aget(task_result_id=task_id) + if expect_max_model: + assert run.agent_model == site_settings.agent_max_model_name + else: + assert run.agent_model == "" + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +async def test_two_issue_events_share_one_session(_stub_github): + """Two callbacks for the same issue produce ONE Session and TWO Runs.""" + from codebase.base import Scope + from codebase.utils import compute_thread_id + + issue_number = 77 + repo = "acme/two-event-repo" + thread_id = compute_thread_id(repo_slug=repo, scope=Scope.ISSUE, entity_iid=issue_number) + + for _n in range(2): + task_id = await _make_db_task_result() + callback = IssueCallback( + action="opened", + repository=Repository(id=1, full_name=repo, default_branch="main"), + issue=_issue([Label(id=1, name="daiv")]), + sender=User(id=2, login="reviewer"), + ) + callback.issue = _issue([Label(id=1, name="daiv")]) + with patch("codebase.clients.github.api.callbacks.address_issue_task") as mock_task: + mock_task.aenqueue = AsyncMock(return_value=MagicMock(id=task_id)) + # Patch compute_thread_id to use our specific repo/issue + with patch("codebase.clients.github.api.callbacks.compute_thread_id", return_value=thread_id): + await callback.process_callback() + + assert await Session.objects.filter(thread_id=thread_id).acount() == 1 + assert await Run.objects.filter(session_id=thread_id).acount() == 2 diff --git a/tests/unit_tests/codebase/clients/gitlab/api/test_callbacks.py b/tests/unit_tests/codebase/clients/gitlab/api/test_callbacks.py index 697b07bc9..a8ffdc3f6 100644 --- a/tests/unit_tests/codebase/clients/gitlab/api/test_callbacks.py +++ b/tests/unit_tests/codebase/clients/gitlab/api/test_callbacks.py @@ -537,7 +537,7 @@ async def test_issue_callback_passes_thread_id(self, monkeypatch_dependencies): with ( patch("codebase.clients.gitlab.api.callbacks.address_issue_task") as mock_task, - patch("codebase.clients.gitlab.api.callbacks.acreate_activity") as mock_activity, + patch("codebase.clients.gitlab.api.callbacks.acreate_run") as mock_activity, patch("codebase.clients.gitlab.api.callbacks.resolve_user", new=AsyncMock(return_value=None)), patch("codebase.clients.gitlab.api.callbacks.resolve_env_for_run", new=AsyncMock(return_value=None)), ): @@ -560,7 +560,7 @@ async def test_note_callback_on_mr_passes_thread_id(self, monkeypatch_dependenci with ( patch("codebase.clients.gitlab.api.callbacks.address_mr_comments_task") as mock_task, - patch("codebase.clients.gitlab.api.callbacks.acreate_activity") as mock_activity, + patch("codebase.clients.gitlab.api.callbacks.acreate_run") as mock_activity, patch("codebase.clients.gitlab.api.callbacks.resolve_user", new=AsyncMock(return_value=None)), patch("codebase.clients.gitlab.api.callbacks.resolve_env_for_run", new=AsyncMock(return_value=None)), ): @@ -607,7 +607,7 @@ async def test_note_callback_on_issue_passes_thread_id(self, monkeypatch_depende with ( patch("codebase.clients.gitlab.api.callbacks.address_issue_task") as mock_task, - patch("codebase.clients.gitlab.api.callbacks.acreate_activity") as mock_activity, + patch("codebase.clients.gitlab.api.callbacks.acreate_run") as mock_activity, patch("codebase.clients.gitlab.api.callbacks.resolve_user", new=AsyncMock(return_value=None)), patch("codebase.clients.gitlab.api.callbacks.resolve_env_for_run", new=AsyncMock(return_value=None)), ): @@ -630,7 +630,7 @@ async def test_issue_callback_propagates_env_id(self, monkeypatch_dependencies): with ( patch("codebase.clients.gitlab.api.callbacks.address_issue_task") as mock_task, - patch("codebase.clients.gitlab.api.callbacks.acreate_activity") as mock_activity, + patch("codebase.clients.gitlab.api.callbacks.acreate_run") as mock_activity, patch("codebase.clients.gitlab.api.callbacks.resolve_user", new=AsyncMock(return_value=None)), patch("codebase.clients.gitlab.api.callbacks.resolve_env_for_run", new=AsyncMock(return_value=env_row)), ): @@ -649,7 +649,7 @@ async def test_note_callback_on_mr_propagates_env_id(self, monkeypatch_dependenc with ( patch("codebase.clients.gitlab.api.callbacks.address_mr_comments_task") as mock_task, - patch("codebase.clients.gitlab.api.callbacks.acreate_activity") as mock_activity, + patch("codebase.clients.gitlab.api.callbacks.acreate_run") as mock_activity, patch("codebase.clients.gitlab.api.callbacks.resolve_user", new=AsyncMock(return_value=None)), patch("codebase.clients.gitlab.api.callbacks.resolve_env_for_run", new=AsyncMock(return_value=env_row)), ): @@ -667,7 +667,7 @@ async def test_issue_callback_no_env_resolves_to_none(self, monkeypatch_dependen with ( patch("codebase.clients.gitlab.api.callbacks.address_issue_task") as mock_task, - patch("codebase.clients.gitlab.api.callbacks.acreate_activity") as mock_activity, + patch("codebase.clients.gitlab.api.callbacks.acreate_run") as mock_activity, patch("codebase.clients.gitlab.api.callbacks.resolve_user", new=AsyncMock(return_value=None)), patch("codebase.clients.gitlab.api.callbacks.resolve_env_for_run", new=AsyncMock(return_value=None)), ): @@ -710,7 +710,7 @@ async def test_note_callback_on_issue_propagates_env_id(self, monkeypatch_depend with ( patch("codebase.clients.gitlab.api.callbacks.address_issue_task") as mock_task, - patch("codebase.clients.gitlab.api.callbacks.acreate_activity") as mock_activity, + patch("codebase.clients.gitlab.api.callbacks.acreate_run") as mock_activity, patch("codebase.clients.gitlab.api.callbacks.resolve_user", new=AsyncMock(return_value=None)), patch("codebase.clients.gitlab.api.callbacks.resolve_env_for_run", new=AsyncMock(return_value=env_row)), ): diff --git a/tests/unit_tests/codebase/clients/gitlab/test_webhook_use_max.py b/tests/unit_tests/codebase/clients/gitlab/test_webhook_use_max.py index de97389b3..acfaa06ed 100644 --- a/tests/unit_tests/codebase/clients/gitlab/test_webhook_use_max.py +++ b/tests/unit_tests/codebase/clients/gitlab/test_webhook_use_max.py @@ -2,8 +2,8 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from activity.models import Activity from django_tasks_db.models import DBTaskResult, get_date_max +from sessions.models import Run, Session from codebase.clients.gitlab.api.callbacks import IssueCallback, NoteCallback from codebase.clients.gitlab.api.models import ( @@ -59,7 +59,7 @@ def test_merge_request_has_max_label_false_when_absent(): # --------------------------------------------------------------------------- -# End-to-end: ``process_callback`` persists ``use_max`` onto the Activity row. +# End-to-end: ``process_callback`` persists model-pair onto the Run row. # Guards against silent drift where a callback forgets to forward has_max_label(). # --------------------------------------------------------------------------- @@ -103,8 +103,12 @@ def _stub_gitlab(monkeypatch): @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) -@pytest.mark.parametrize("labels, expected", [([Label(title="daiv-max")], True), ([Label(title="daiv")], False)]) -async def test_issue_callback_persists_use_max(_stub_gitlab, labels, expected): +@pytest.mark.parametrize( + "labels, expect_max_model", [([Label(title="daiv-max")], True), ([Label(title="daiv")], False)] +) +async def test_issue_callback_persists_agent_model(_stub_gitlab, labels, expect_max_model): + from core.site_settings import site_settings + task_id = await _make_db_task_result() callback = IssueCallback( object_kind="issue", @@ -126,14 +130,23 @@ async def test_issue_callback_persists_use_max(_stub_gitlab, labels, expected): mock_task.aenqueue = AsyncMock(return_value=MagicMock(id=task_id)) await callback.process_callback() - activity = await Activity.objects.aget(task_result_id=task_id) - assert activity.use_max is expected + run = await Run.objects.aget(task_result_id=task_id) + if expect_max_model: + assert run.agent_model == site_settings.agent_max_model_name + assert run.agent_thinking_level == site_settings.agent_max_thinking_level + else: + assert run.agent_model == "" + assert run.agent_thinking_level == "" @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) -@pytest.mark.parametrize("labels, expected", [([Label(title="daiv-max")], True), ([Label(title="daiv")], False)]) -async def test_note_callback_on_mr_persists_use_max(_stub_gitlab, labels, expected): +@pytest.mark.parametrize( + "labels, expect_max_model", [([Label(title="daiv-max")], True), ([Label(title="daiv")], False)] +) +async def test_note_callback_on_mr_persists_agent_model(_stub_gitlab, labels, expect_max_model): + from core.site_settings import site_settings + task_id = await _make_db_task_result() callback = NoteCallback( object_kind="note", @@ -159,14 +172,21 @@ async def test_note_callback_on_mr_persists_use_max(_stub_gitlab, labels, expect mock_task.aenqueue = AsyncMock(return_value=MagicMock(id=task_id)) await callback.process_callback() - activity = await Activity.objects.aget(task_result_id=task_id) - assert activity.use_max is expected + run = await Run.objects.aget(task_result_id=task_id) + if expect_max_model: + assert run.agent_model == site_settings.agent_max_model_name + else: + assert run.agent_model == "" @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) -@pytest.mark.parametrize("labels, expected", [([Label(title="daiv-max")], True), ([Label(title="daiv")], False)]) -async def test_note_callback_on_issue_persists_use_max(_stub_gitlab, labels, expected): +@pytest.mark.parametrize( + "labels, expect_max_model", [([Label(title="daiv-max")], True), ([Label(title="daiv")], False)] +) +async def test_note_callback_on_issue_persists_agent_model(_stub_gitlab, labels, expect_max_model): + from core.site_settings import site_settings + task_id = await _make_db_task_result() callback = NoteCallback( object_kind="note", @@ -192,5 +212,45 @@ async def test_note_callback_on_issue_persists_use_max(_stub_gitlab, labels, exp mock_task.aenqueue = AsyncMock(return_value=MagicMock(id=task_id)) await callback.process_callback() - activity = await Activity.objects.aget(task_result_id=task_id) - assert activity.use_max is expected + run = await Run.objects.aget(task_result_id=task_id) + if expect_max_model: + assert run.agent_model == site_settings.agent_max_model_name + else: + assert run.agent_model == "" + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +async def test_two_issue_events_share_one_session(_stub_gitlab): + """Two callbacks for the same issue produce ONE Session and TWO Runs.""" + from codebase.base import Scope + from codebase.utils import compute_thread_id + + issue_iid = 99 + repo = "group/two-event-repo" + thread_id = compute_thread_id(repo_slug=repo, scope=Scope.ISSUE, entity_iid=issue_iid) + + for _n in range(2): + task_id = await _make_db_task_result() + callback = IssueCallback( + object_kind="issue", + project=Project(id=1, path_with_namespace=repo, default_branch="main"), + user=User(id=2, username="reviewer", name="Reviewer", email="reviewer@example.com"), + object_attributes=Issue( + id=100, + iid=issue_iid, + title="T", + description="", + state="opened", + assignee_id=None, + action=IssueAction.OPEN, + labels=[Label(title="daiv")], + type="Issue", + ), + ) + with patch("codebase.clients.gitlab.api.callbacks.address_issue_task") as mock_task: + mock_task.aenqueue = AsyncMock(return_value=MagicMock(id=task_id)) + await callback.process_callback() + + assert await Session.objects.filter(thread_id=thread_id).acount() == 1 + assert await Run.objects.filter(session_id=thread_id).acount() == 2 diff --git a/tests/unit_tests/sandbox_envs/test_services.py b/tests/unit_tests/sandbox_envs/test_services.py index a61f912c8..25154bf48 100644 --- a/tests/unit_tests/sandbox_envs/test_services.py +++ b/tests/unit_tests/sandbox_envs/test_services.py @@ -537,8 +537,8 @@ def _clear_global(self): @pytest.mark.asyncio async def test_explicit_env_id_stamps_all_targets(self): - from activity.services import RepoTarget from sandbox_envs.services import aresolve_repo_envs + from sessions.services import RepoTarget resolved = await aresolve_repo_envs( user=None, @@ -559,8 +559,8 @@ async def test_empty_repos_returns_empty_list(self): @pytest.mark.asyncio async def test_user_env_wins_over_global_when_both_match_repo(self): - from activity.services import RepoTarget from sandbox_envs.services import aresolve_repo_envs + from sessions.services import RepoTarget user = await User.objects.acreate(username="u", email="u@x.test") user_env = await SandboxEnvironment.objects.acreate( @@ -572,8 +572,8 @@ async def test_user_env_wins_over_global_when_both_match_repo(self): @pytest.mark.asyncio async def test_global_repo_match_wins_over_default(self): - from activity.services import RepoTarget from sandbox_envs.services import aresolve_repo_envs + from sessions.services import RepoTarget await SandboxEnvironment.objects.acreate(scope=Scope.GLOBAL, name="Default", base_image="x", is_default=True) repo_env = await SandboxEnvironment.objects.acreate( @@ -584,8 +584,8 @@ async def test_global_repo_match_wins_over_default(self): @pytest.mark.asyncio async def test_no_envs_at_all_yields_none(self): - from activity.services import RepoTarget from sandbox_envs.services import aresolve_repo_envs + from sessions.services import RepoTarget resolved = await aresolve_repo_envs(user=None, repos=[RepoTarget(repo_id="a/b")], explicit_env_id=None) assert resolved[0].sandbox_environment_id is None @@ -594,8 +594,8 @@ async def test_no_envs_at_all_yields_none(self): async def test_envs_with_empty_repo_ids_do_not_match(self): """An env with an empty ``repo_ids`` list must not match any repo and must fall through to the GLOBAL default.""" - from activity.services import RepoTarget from sandbox_envs.services import aresolve_repo_envs + from sessions.services import RepoTarget default = await SandboxEnvironment.objects.acreate( scope=Scope.GLOBAL, name="Default", base_image="x", is_default=True, repo_ids=[] @@ -608,8 +608,8 @@ async def test_envs_with_empty_repo_ids_do_not_match(self): @pytest.mark.asyncio async def test_user_scope_skipped_for_anonymous_or_none(self): - from activity.services import RepoTarget from sandbox_envs.services import aresolve_repo_envs + from sessions.services import RepoTarget other = await User.objects.acreate(username="o", email="o@x.test") await SandboxEnvironment.objects.acreate( @@ -624,8 +624,8 @@ async def test_user_scope_skipped_for_anonymous_or_none(self): @pytest.mark.asyncio async def test_input_targets_not_mutated(self): - from activity.services import RepoTarget from sandbox_envs.services import aresolve_repo_envs + from sessions.services import RepoTarget await SandboxEnvironment.objects.acreate(scope=Scope.GLOBAL, name="Default", base_image="x", is_default=True) original = [RepoTarget(repo_id="a/b"), RepoTarget(repo_id="c/d", ref="dev")] diff --git a/tests/unit_tests/schedules/test_dispatch_sandbox_env.py b/tests/unit_tests/schedules/test_dispatch_sandbox_env.py index 2554bf23f..d9ea0a671 100644 --- a/tests/unit_tests/schedules/test_dispatch_sandbox_env.py +++ b/tests/unit_tests/schedules/test_dispatch_sandbox_env.py @@ -27,10 +27,10 @@ def test_dispatch_stamps_explicit_env_on_targets(member_user): fake_result = MagicMock() fake_result.batch_id = uuid.uuid4() - fake_result.activities = [] + fake_result.runs = [] fake_result.failed = [] - with patch("activity.services.submit_batch_runs", return_value=fake_result) as submit: + with patch("sessions.services.submit_batch_runs", return_value=fake_result) as submit: dispatch_scheduled_jobs_cron_task.func() assert submit.call_count == 1 @@ -60,10 +60,10 @@ def test_dispatch_auto_resolves_to_global_default(member_user): fake_result = MagicMock() fake_result.batch_id = uuid.uuid4() - fake_result.activities = [] + fake_result.runs = [] fake_result.failed = [] - with patch("activity.services.submit_batch_runs", return_value=fake_result) as submit: + with patch("sessions.services.submit_batch_runs", return_value=fake_result) as submit: dispatch_scheduled_jobs_cron_task.func() targets = submit.call_args.kwargs["repos"] @@ -93,10 +93,10 @@ def test_dispatch_auto_resolves_user_env_for_schedule_owner(member_user): fake_result = MagicMock() fake_result.batch_id = uuid.uuid4() - fake_result.activities = [] + fake_result.runs = [] fake_result.failed = [] - with patch("activity.services.submit_batch_runs", return_value=fake_result) as submit: + with patch("sessions.services.submit_batch_runs", return_value=fake_result) as submit: dispatch_scheduled_jobs_cron_task.func() targets = submit.call_args.kwargs["repos"] @@ -120,10 +120,10 @@ def test_dispatch_auto_with_no_envs_stays_none(member_user): fake_result = MagicMock() fake_result.batch_id = uuid.uuid4() - fake_result.activities = [] + fake_result.runs = [] fake_result.failed = [] - with patch("activity.services.submit_batch_runs", return_value=fake_result) as submit: + with patch("sessions.services.submit_batch_runs", return_value=fake_result) as submit: dispatch_scheduled_jobs_cron_task.func() targets = submit.call_args.kwargs["repos"] diff --git a/tests/unit_tests/schedules/test_tasks.py b/tests/unit_tests/schedules/test_tasks.py index 151a114d0..cfc5ee19f 100644 --- a/tests/unit_tests/schedules/test_tasks.py +++ b/tests/unit_tests/schedules/test_tasks.py @@ -3,8 +3,8 @@ from unittest.mock import MagicMock, patch import pytest -from activity.models import Activity, TriggerType from django_tasks_db.models import DBTaskResult, get_date_max +from sessions.models import Run, SessionOrigin from schedules.models import Frequency, ScheduledJob from schedules.tasks import dispatch_scheduled_jobs_cron_task @@ -57,7 +57,7 @@ def test_dispatch_single_repo_propagates_agent_override(member_user): next_run_at=past, ) - with patch("activity.services.run_job_task") as mock_task: + with patch("sessions.services.run_job_task") as mock_task: async def _aenqueue(**kwargs): return await _amake_task_result() @@ -65,11 +65,11 @@ async def _aenqueue(**kwargs): mock_task.aenqueue.side_effect = _aenqueue dispatch_scheduled_jobs_cron_task.func() - activity = Activity.objects.get(scheduled_job=schedule) - assert activity.trigger_type == TriggerType.SCHEDULE - assert activity.agent_model == "openrouter:anthropic/claude-opus-4.6" - assert activity.agent_thinking_level == "high" - assert activity.batch_id is not None + run = Run.objects.get(session__scheduled_job=schedule) + assert run.trigger_type == SessionOrigin.SCHEDULE + assert run.agent_model == "openrouter:anthropic/claude-opus-4.6" + assert run.agent_thinking_level == "high" + assert run.batch_id is not None @pytest.mark.django_db(transaction=True) @@ -86,7 +86,7 @@ def test_dispatch_single_repo_auto_override(member_user): next_run_at=past, ) - with patch("activity.services.run_job_task") as mock_task: + with patch("sessions.services.run_job_task") as mock_task: async def _aenqueue(**kwargs): return await _amake_task_result() @@ -94,9 +94,9 @@ async def _aenqueue(**kwargs): mock_task.aenqueue.side_effect = _aenqueue dispatch_scheduled_jobs_cron_task.func() - activity = Activity.objects.get(scheduled_job=schedule) - assert activity.agent_model == "" - assert activity.agent_thinking_level == "" + run = Run.objects.get(session__scheduled_job=schedule) + assert run.agent_model == "" + assert run.agent_thinking_level == "" @pytest.mark.django_db(transaction=True) @@ -113,7 +113,7 @@ def test_dispatch_three_repos_creates_three_activities_sharing_batch(member_user next_run_at=past, ) - with patch("activity.services.run_job_task") as mock_task: + with patch("sessions.services.run_job_task") as mock_task: async def _aenqueue(**kwargs): return await _amake_task_result() @@ -121,9 +121,9 @@ async def _aenqueue(**kwargs): mock_task.aenqueue.side_effect = _aenqueue dispatch_scheduled_jobs_cron_task.func() - activities = list(Activity.objects.filter(scheduled_job=schedule)) - assert len(activities) == 3 - batches = {a.batch_id for a in activities} + runs = list(Run.objects.filter(session__scheduled_job=schedule)) + assert len(runs) == 3 + batches = {r.batch_id for r in runs} assert len(batches) == 1 schedule.refresh_from_db() assert schedule.last_run_batch_id == next(iter(batches)) @@ -143,7 +143,7 @@ def test_dispatch_advances_next_run_on_success(member_user): next_run_at=past, ) - with patch("activity.services.run_job_task") as mock_task: + with patch("sessions.services.run_job_task") as mock_task: async def _aenqueue(**kwargs): return await _amake_task_result() @@ -170,7 +170,7 @@ def test_dispatch_once_schedule_auto_disables_on_success(member_user): next_run_at=past, ) - with patch("activity.services.run_job_task") as mock_task: + with patch("sessions.services.run_job_task") as mock_task: async def _aenqueue(**kwargs): return await _amake_task_result() @@ -199,7 +199,7 @@ def test_dispatch_once_schedule_auto_disables_on_failure(member_user): next_run_at=past, ) - with patch("activity.services.submit_batch_runs", side_effect=RuntimeError("boom")): + with patch("sessions.services.submit_batch_runs", side_effect=RuntimeError("boom")): dispatch_scheduled_jobs_cron_task.func() schedule.refresh_from_db() diff --git a/tests/unit_tests/schedules/test_views.py b/tests/unit_tests/schedules/test_views.py index daae37746..976a1958b 100644 --- a/tests/unit_tests/schedules/test_views.py +++ b/tests/unit_tests/schedules/test_views.py @@ -6,9 +6,9 @@ from django.urls import reverse import pytest -from activity.models import Activity, ActivityStatus, TriggerType from django_tasks_db.models import DBTaskResult, get_date_max from notifications.choices import NotifyOn +from sessions.models import Run, RunStatus, SessionOrigin from accounts.models import User from schedules.models import Frequency, ScheduledJob, ScheduleTemplate @@ -229,15 +229,15 @@ async def _amake_task_row(): return m def test_enqueues_single_repo_and_redirects_to_activity_detail(self, member_client, member_user, schedule): - with mock.patch("activity.services.run_job_task") as m_task: + with mock.patch("sessions.services.run_job_task") as m_task: m_task.aenqueue = mock.AsyncMock(return_value=self._make_task_row()) response = member_client.post(reverse("schedule_run_now", args=[schedule.pk])) assert response.status_code == 302 - activity = Activity.objects.get(scheduled_job=schedule) - assert activity.trigger_type == TriggerType.SCHEDULE - assert activity.batch_id is not None - assert response.url == reverse("activity_detail", args=[activity.pk]) + run = Run.objects.get(session__scheduled_job=schedule) + assert run.trigger_type == SessionOrigin.SCHEDULE + assert run.batch_id is not None + assert response.url == reverse("activity_detail", args=[run.pk]) def test_multi_repo_redirects_to_batch_filtered_activity_list(self, member_client, member_user, schedule): schedule.repos = [{"repo_id": "a/b", "ref": ""}, {"repo_id": "c/d", "ref": ""}] @@ -246,58 +246,58 @@ def test_multi_repo_redirects_to_batch_filtered_activity_list(self, member_clien async def _aenq(**kwargs): return await self._amake_task_row() - with mock.patch("activity.services.run_job_task") as m_task: + with mock.patch("sessions.services.run_job_task") as m_task: m_task.aenqueue.side_effect = _aenq response = member_client.post(reverse("schedule_run_now", args=[schedule.pk])) assert response.status_code == 302 assert "batch=" in response.url - activities = list(Activity.objects.filter(scheduled_job=schedule)) - assert len(activities) == 2 - assert len({a.batch_id for a in activities}) == 1 + runs = list(Run.objects.filter(session__scheduled_job=schedule)) + assert len(runs) == 2 + assert len({r.batch_id for r in runs}) == 1 def test_works_on_disabled_schedule(self, member_client, schedule): schedule.is_enabled = False schedule.next_run_at = None schedule.save(update_fields=["is_enabled", "next_run_at"]) - with mock.patch("activity.services.run_job_task") as m_task: + with mock.patch("sessions.services.run_job_task") as m_task: m_task.aenqueue = mock.AsyncMock(return_value=self._make_task_row()) response = member_client.post(reverse("schedule_run_now", args=[schedule.pk])) assert response.status_code == 302 - activity = Activity.objects.get(scheduled_job=schedule) - assert response.url == reverse("activity_detail", args=[activity.pk]) + run = Run.objects.get(session__scheduled_job=schedule) + assert response.url == reverse("activity_detail", args=[run.pk]) def test_enqueue_failure_returns_error_message(self, member_client, schedule): - with mock.patch("activity.services.run_job_task") as m_task: + with mock.patch("sessions.services.run_job_task") as m_task: m_task.aenqueue = mock.AsyncMock(side_effect=RuntimeError("backend down")) response = member_client.post(reverse("schedule_run_now", args=[schedule.pk]), follow=True) - # All repos failed → the schedule_list view shows a warning. The Activity row + # All repos failed → the schedule_list view shows a warning. The Run row # exists (created before the enqueue attempt) and is marked FAILED so the # audit trail remains visible. assert response.status_code == 200 - activities = list(Activity.objects.filter(scheduled_job=schedule)) - assert activities and all(a.status == ActivityStatus.FAILED for a in activities) + runs = list(Run.objects.filter(session__scheduled_job=schedule)) + assert runs and all(r.status == RunStatus.FAILED for r in runs) content = response.content.decode() assert "triggered with failures" in content or "Failed to trigger" in content - def test_run_now_persists_explicit_env_on_activity(self, member_client, member_user, schedule): - """Schedule with an explicit env → run-now stamps that env on the generated Activity.""" + def test_run_now_persists_explicit_env_on_run(self, member_client, member_user, schedule): + """Schedule with an explicit env → run-now stamps that env on the generated Run.""" from sandbox_envs.models import SandboxEnvironment, Scope env = SandboxEnvironment.objects.create(scope=Scope.USER, user=member_user, name="prod", base_image="x") schedule.sandbox_environment = env schedule.save(update_fields=["sandbox_environment"]) - with mock.patch("activity.services.run_job_task") as m_task: + with mock.patch("sessions.services.run_job_task") as m_task: m_task.aenqueue = mock.AsyncMock(return_value=self._make_task_row()) response = member_client.post(reverse("schedule_run_now", args=[schedule.pk])) assert response.status_code == 302 - activity = Activity.objects.get(scheduled_job=schedule) - assert activity.sandbox_environment_id == env.id + run = Run.objects.get(session__scheduled_job=schedule) + assert run.sandbox_environment_id == env.id def test_run_now_auto_resolves_against_schedule_owner_not_request_user( self, member_client, member_user, schedule, admin_user @@ -318,13 +318,13 @@ def test_run_now_auto_resolves_against_schedule_owner_not_request_user( admin_client = Client() admin_client.force_login(admin_user) - with mock.patch("activity.services.run_job_task") as m_task: + with mock.patch("sessions.services.run_job_task") as m_task: m_task.aenqueue = mock.AsyncMock(return_value=self._make_task_row()) response = admin_client.post(reverse("schedule_run_now", args=[schedule.pk])) assert response.status_code == 302 - activity = Activity.objects.get(scheduled_job=schedule) - assert activity.sandbox_environment_id == owner_env.id + run = Run.objects.get(session__scheduled_job=schedule) + assert run.sandbox_environment_id == owner_env.id @pytest.mark.django_db From be3fbd618ed01fca5f82785c7a17914a94493f75 Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 7 Jul 2026 18:09:24 +0100 Subject: [PATCH 11/55] feat(chat): chat turns create Runs with usage tracking on the unified session --- daiv/automation/titling/tasks.py | 37 +++-- daiv/chat/api/streaming.py | 133 ++++++++++++--- daiv/chat/api/threads.py | 75 ++------- daiv/chat/api/views.py | 66 ++++---- .../automation/titling/test_tasks.py | 82 ++++++--- tests/unit_tests/chat/api/test_streaming.py | 54 ++++-- tests/unit_tests/chat/api/test_threads.py | 155 ++++-------------- tests/unit_tests/chat/api/test_views.py | 64 +++++--- tests/unit_tests/sessions/test_chat_runs.py | 65 ++++++++ 9 files changed, 422 insertions(+), 309 deletions(-) create mode 100644 tests/unit_tests/sessions/test_chat_runs.py diff --git a/daiv/automation/titling/tasks.py b/daiv/automation/titling/tasks.py index 7c2066eb0..8fde782c6 100644 --- a/daiv/automation/titling/tasks.py +++ b/daiv/automation/titling/tasks.py @@ -85,15 +85,24 @@ def _invoke_titler(structured_llm, *, prompt: str, repo_id: str = "", ref: str = @task() def generate_title_task( - entity_type: Literal["chat_thread", "activity"], pk: str, prompt: str, repo_id: str, ref: str = "" + entity_type: Literal["session", "run", "chat_thread", "activity"], pk: str, prompt: str, repo_id: str, ref: str = "" ) -> None: - """Overwrite a ChatThread/Activity title with an LLM-generated one. + """Overwrite a Session/Run (or legacy ChatThread/Activity) title with an LLM-generated one. Failures propagate to django-tasks (which logs + marks the task failed); the - title set synchronously remains (heuristic for chat threads, possibly empty - for prompt-driven activities). + title set synchronously remains (heuristic for chat sessions, possibly empty + for prompt-driven runs). The legacy ``chat_thread``/``activity`` literals stay + supported during the sessions-unification dual period (removed in Task 15). """ - if entity_type == "chat_thread": + if entity_type == "session": + from sessions.models import Session + + model_cls = Session + elif entity_type == "run": + from sessions.models import Run + + model_cls = Run + elif entity_type == "chat_thread": from chat.models import ChatThread model_cls = ChatThread @@ -132,13 +141,14 @@ def generate_title_task( @task() def generate_batch_title_task(batch_id: str, prompt: str) -> None: - """Generate a single LLM title for an Activity batch and apply it to every untitled member. + """Generate a single LLM title for a Run batch and apply it to every untitled member. - One LLM call per batch instead of one per activity. Repo/ref context is omitted because batch + One LLM call per batch instead of one per run. Repo/ref context is omitted because batch members typically span repos. Only rows with an empty ``title`` are updated, so synchronous - titles (e.g. scheduled-run templates) are preserved. + titles (e.g. scheduled-run templates) are preserved. The same title is stamped on each + affected run's Session when the session title is still empty. """ - from activity.models import Activity + from sessions.models import Run, Session try: structured_llm = _build_structured_llm() @@ -150,13 +160,16 @@ def generate_batch_title_task(batch_id: str, prompt: str) -> None: return title = _invoke_titler( - structured_llm, prompt=prompt, run_metadata={"entity_type": "activity_batch", "batch_id": batch_id} + structured_llm, prompt=prompt, run_metadata={"entity_type": "run_batch", "batch_id": batch_id} ) - updated = Activity.objects.by_batch(batch_id).filter(title="").update(title=title) + updated = Run.objects.filter(batch_id=batch_id, title="").update(title=title) + # Backfill the parent session title too — a fresh batch creates one session per run, + # each with an empty title until this titler lands. + Session.objects.filter(runs__batch_id=batch_id, title="").update(title=title) if updated == 0: logger.warning( "generate_batch_title_task: no rows updated for batch_id=%s (stale batch or all already titled)", batch_id ) else: - logger.info("generate_batch_title_task: updated %d activities for batch_id=%s", updated, batch_id) + logger.info("generate_batch_title_task: updated %d runs for batch_id=%s", updated, batch_id) diff --git a/daiv/chat/api/streaming.py b/daiv/chat/api/streaming.py index 11eb9b317..4aa0512a7 100644 --- a/daiv/chat/api/streaming.py +++ b/daiv/chat/api/streaming.py @@ -3,20 +3,26 @@ import logging import time from dataclasses import dataclass, fields, is_dataclass +from decimal import Decimal from typing import TYPE_CHECKING, Any +from django.utils import timezone + from ag_ui.core.events import CustomEvent, EventType, RunErrorEvent from copilotkit import LangGraphAGUIAgent from langgraph.store.memory import InMemoryStore +from sessions.locks import SessionLock +from sessions.models import Run, RunStatus, SessionOrigin from automation.agent.graph import create_daiv_agent +from automation.agent.usage_tracking import build_usage_summary, track_usage_metadata from automation.agent.utils import build_langsmith_config, get_daiv_agent_kwargs from codebase.base import Scope from codebase.context import set_runtime_ctx from core.checkpointer import open_checkpointer from .event_filter import SubagentEventFilter -from .threads import ChatThreadService +from .threads import ChatSessionService if TYPE_CHECKING: from collections.abc import AsyncIterator @@ -29,6 +35,44 @@ logger = logging.getLogger("daiv.chat") + +async def start_chat_run(*, session_id: str, user_id, prompt: str, repo_id: str, ref: str) -> Run: + """Record the chat turn as a RUNNING Run. Chat runs execute inline: no + task_result, no QUEUED/READY phase. + """ + return await Run.objects.acreate( + session_id=session_id, + trigger_type=SessionOrigin.CHAT, + status=RunStatus.RUNNING, + user_id=user_id, + prompt=prompt[:2000], + repo_id=repo_id, + ref=ref, + started_at=timezone.now(), + ) + + +async def finalize_chat_run(run_pk, *, success: bool, usage: dict | None, response_text: str) -> None: + """Terminal transition for a chat Run. Mirrors the denormalization + ``sync_from_task_result`` performs for background runs. + """ + update = {"status": RunStatus.SUCCESSFUL if success else RunStatus.FAILED, "finished_at": timezone.now()} + if response_text: + update["result_summary"] = response_text[:2000] + if usage: + for key in ("input_tokens", "output_tokens", "total_tokens"): + if usage.get(key) is not None: + update[key] = usage[key] + if usage.get("cost_usd") is not None: + try: + update["cost_usd"] = Decimal(usage["cost_usd"]) + except Exception: # noqa: BLE001 + logger.warning("Invalid cost_usd %r for chat run %s", usage["cost_usd"], run_pk) + if usage.get("by_model") is not None: + update["usage_by_model"] = usage["by_model"] + await Run.objects.filter(pk=run_pk).aupdate(**update) + + # GitState fields that survive the ag-ui output-schema filter and reach the # chat client through STATE_SNAPSHOT events. STREAMED_STATE_KEYS = ("merge_request",) @@ -78,8 +122,8 @@ def get_schema_keys(self, config: Any) -> dict[str, list[str]]: @dataclass(frozen=True, kw_only=True) class ChatRunStreamer: """SSE generator: configures the agent, runs it through the subagent filter, - captures the latest MR from STATE_SNAPSHOTs, and persists the ref before - releasing the per-thread run slot. + captures the latest MR from STATE_SNAPSHOTs, records the turn as a ``Run`` with + token/cost usage, and persists the ref before releasing the per-session run slot. """ repo_id: str @@ -88,6 +132,8 @@ class ChatRunStreamer: run_id: str input_data: RunAgentInput encoder: EventEncoder + user_id: int | None = None + prompt: str = "" sandbox_environment_id: str | None = None agent_model: str | None = None agent_thinking_level: str | None = None @@ -109,10 +155,15 @@ async def events(self) -> AsyncIterator[str]: last_mr: MergeRequest | None = None clean_run = False last_heartbeat = time.monotonic() + # The Run row (a separate object from the AG-UI run_id that holds the lock). + # Created after the stream context opens; finalized in ``finally``. + chat_run: Run | None = None + usage_handler = None + response_buffer = "" try: # Surface the auto-resolved env before any agent output so the locked composer # pill swaps "Auto" → real env name as early as possible. Kept inside the - # ``try`` so an encode failure still routes through RUN_ERROR + ``release_run`` + # ``try`` so an encode failure still routes through RUN_ERROR + lock release # in ``finally``; the emit precedes ``set_runtime_ctx`` so the user still sees # what would have run even if agent setup fails. if self.auto_resolved_env is not None: @@ -125,6 +176,14 @@ async def events(self) -> AsyncIterator[str]: repo_id=self.repo_id, scope=Scope.GLOBAL, ref=self.ref, sandbox_env_id=self.sandbox_environment_id ) as runtime_ctx, ): + # Record the turn as a RUNNING Run once we're committed to executing. + chat_run = await start_chat_run( + session_id=self.thread_id, + user_id=self.user_id, + prompt=self.prompt, + repo_id=self.repo_id, + ref=self.ref, + ) agent_kwargs = get_daiv_agent_kwargs( model_config=runtime_ctx.config.models.agent, agent_model=self.agent_model, @@ -148,20 +207,31 @@ async def events(self) -> AsyncIterator[str]: config={"recursion_limit": 500, **langsmith_config}, runtime_context=runtime_ctx, ) - async for event in SubagentEventFilter().apply(langgraph_agent.run(self.input_data)): - if event.type == EventType.STATE_SNAPSHOT: - snap = getattr(event, "snapshot", None) or {} - if isinstance(snap, dict) and "merge_request" in snap: - last_mr = snap["merge_request"] - yield self.encoder.encode(event) - - now = time.monotonic() - if now - last_heartbeat >= HEARTBEAT_INTERVAL_S: - last_heartbeat = now - try: - await ChatThreadService.heartbeat(self.thread_id, self.run_id) - except Exception: - logger.exception("chat: heartbeat failed for thread_id=%s", self.thread_id) + # ``track_usage_metadata`` sets a ContextVar whose hook propagates the + # cost-aware callback to every nested runnable (subagents included) — the + # same mechanism ``run_job_task`` relies on. The whole generator body runs + # in one task, so the ContextVar scope holds across ``yield``. + with track_usage_metadata() as usage_handler: + async for event in SubagentEventFilter().apply(langgraph_agent.run(self.input_data)): + if event.type == EventType.STATE_SNAPSHOT: + snap = getattr(event, "snapshot", None) or {} + if isinstance(snap, dict) and "merge_request" in snap: + last_mr = snap["merge_request"] + elif event.type in (EventType.TEXT_MESSAGE_CONTENT, EventType.TEXT_MESSAGE_CHUNK): + # Buffer the assistant text deltas for ``result_summary``. Capped at + # 2000 chars — the same bound ``finalize_chat_run`` re-applies. + delta = getattr(event, "delta", None) + if delta and len(response_buffer) < 2000: + response_buffer = (response_buffer + delta)[:2000] + yield self.encoder.encode(event) + + now = time.monotonic() + if now - last_heartbeat >= HEARTBEAT_INTERVAL_S: + last_heartbeat = now + try: + await SessionLock.heartbeat(self.thread_id, self.run_id) + except Exception: + logger.exception("chat: heartbeat failed for thread_id=%s", self.thread_id) clean_run = True except Exception: logger.exception("Chat run failed for thread_id=%s run_id=%s", self.thread_id, self.run_id) @@ -171,18 +241,27 @@ async def events(self) -> AsyncIterator[str]: ) ) finally: - # Both cleanup steps are wrapped: a post-stream DB hiccup must not - # retroactively paint a clean run as RUN_ERROR, and a release_run - # failure must not leave the per-thread slot permanently claimed. - # ref is only persisted on a clean finish — a partial run could have - # checked out a branch without committing, and pinning it would - # silently retarget reloads at half-built state. + # Each cleanup step is wrapped independently: a post-stream DB hiccup must not + # retroactively paint a clean run as RUN_ERROR, and a lock-release failure must + # not leave the per-session slot permanently claimed. ref is only persisted on a + # clean finish — a partial run could have checked out a branch without committing, + # and pinning it would silently retarget reloads at half-built state. if clean_run: try: - await ChatThreadService.persist_ref(self.thread_id, self.ref, last_mr) + await ChatSessionService.persist_ref(self.thread_id, self.ref, last_mr) + except Exception: + logger.exception("chat: failed to persist session ref for thread_id=%s", self.thread_id) + if chat_run is not None: + try: + await finalize_chat_run( + chat_run.pk, + success=clean_run, + usage=build_usage_summary(usage_handler).to_dict() if usage_handler else None, + response_text=response_buffer, + ) except Exception: - logger.exception("chat: failed to persist thread ref for thread_id=%s", self.thread_id) + logger.exception("chat: failed to finalize chat run for thread_id=%s", self.thread_id) try: - await ChatThreadService.release_run(self.thread_id, self.run_id) + await SessionLock.release(self.thread_id, self.run_id) except Exception: logger.exception("chat: failed to release run slot for thread_id=%s", self.thread_id) diff --git a/daiv/chat/api/threads.py b/daiv/chat/api/threads.py index ed7671798..77f327c61 100644 --- a/daiv/chat/api/threads.py +++ b/daiv/chat/api/threads.py @@ -1,15 +1,12 @@ from __future__ import annotations import logging -from datetime import timedelta from typing import TYPE_CHECKING -from django.db.models import Q -from django.utils import timezone +from sessions.models import Session, SessionOrigin from automation.titling.services import TitlerService from automation.titling.tasks import generate_title_task -from chat.models import ChatThread if TYPE_CHECKING: from ag_ui.core import RunAgentInput @@ -21,13 +18,6 @@ logger = logging.getLogger("daiv.chat") -# A claim that hasn't bumped last_active_at within this window is considered -# orphaned (worker crashed / OOM-killed before the streamer's finally ran) and -# can be taken over by a fresh claim. Live runs heartbeat well within this -# window via ``ChatThreadService.heartbeat``. -STALE_RUN_MINUTES = 30 - - def _extract_first_user_message(input_data: RunAgentInput) -> str: """Return the first non-empty content from a human/user role message.""" for m in input_data.messages: @@ -40,7 +30,7 @@ def _extract_first_user_message(input_data: RunAgentInput) -> str: return "" -class ChatThreadService: +class ChatSessionService: @staticmethod async def get_or_create_for_user( *, @@ -52,19 +42,21 @@ async def get_or_create_for_user( sandbox_environment: SandboxEnvironment | None = None, agent_model: str = "", agent_thinking_level: str = "", - ) -> tuple[ChatThread, bool]: - """First sight of ``thread_id`` creates the row under ``user``; later calls - return the existing row regardless of owner. Caller must enforce ownership. + ) -> tuple[Session, bool]: + """First sight of ``thread_id`` creates a chat-origin ``Session`` under ``user``; + later calls return the existing row regardless of owner. Caller must enforce + ownership. - ``agent_model`` and ``agent_thinking_level`` are pinned at thread creation: + ``agent_model`` and ``agent_thinking_level`` are pinned at session creation: they're written to ``defaults`` so the first turn fixes the override and subsequent turns ignore client-supplied values (same lock semantics as ``sandbox_environment``). The boolean return flag lets callers detect the - existing-thread case and reject a client that tries to change the override + existing-session case and reject a client that tries to change the override after the first turn — see ``chat.api.views.create_chat_completion``. """ first_message = _extract_first_user_message(input_data) defaults = { + "origin": SessionOrigin.CHAT, "user": user, "repo_id": repo_id, "ref": ref, @@ -74,56 +66,19 @@ async def get_or_create_for_user( } if sandbox_environment is not None: defaults["sandbox_environment"] = sandbox_environment - thread, created = await ChatThread.objects.aget_or_create(thread_id=thread_id, defaults=defaults) + session, created = await Session.objects.aget_or_create(thread_id=thread_id, defaults=defaults) if created and first_message: try: await generate_title_task.aenqueue( - entity_type="chat_thread", pk=thread.thread_id, prompt=first_message, repo_id=repo_id, ref=ref + entity_type="session", pk=session.thread_id, prompt=first_message, repo_id=repo_id, ref=ref ) except Exception: # noqa: BLE001 - logger.exception("Failed to enqueue title task for chat thread %s", thread.thread_id) - return thread, created - - @staticmethod - async def try_claim_run(thread_id: str, run_id: str) -> bool: - """Atomic claim: succeeds if the slot is free OR its heartbeat is stale. - - Why: a worker crash (OOM, SIGKILL, ASGI transport error before the streaming - body iterates) skips the streamer's ``finally`` so ``release_run`` never fires. - Without the stale-takeover branch the thread would be unrecoverable forever. - """ - stale_cutoff = timezone.now() - timedelta(minutes=STALE_RUN_MINUTES) - free_or_stale = Q(active_run_id__isnull=True) | Q(last_active_at__lt=stale_cutoff) - claimed = await ChatThread.objects.filter(Q(thread_id=thread_id) & free_or_stale).aupdate( - active_run_id=run_id, last_active_at=timezone.now() - ) - return bool(claimed) - - @staticmethod - async def heartbeat(thread_id: str, run_id: str) -> None: - """Bump ``last_active_at`` while the slot is still ours. - - Filtered on ``active_run_id=run_id`` so a delayed heartbeat from a previous - run cannot keep a stale slot alive after another run took it over. - """ - await ChatThread.objects.filter(thread_id=thread_id, active_run_id=run_id).aupdate( - last_active_at=timezone.now() - ) - - @staticmethod - async def release_run(thread_id: str, run_id: str) -> None: - """Clear the slot only if we still hold it. - - The ``active_run_id=run_id`` guard prevents a delayed cleanup from stomping - a freshly-claimed slot taken over via the stale path. - """ - await ChatThread.objects.filter(thread_id=thread_id, active_run_id=run_id).aupdate( - active_run_id=None, last_active_at=timezone.now() - ) + logger.exception("Failed to enqueue title task for session %s", session.thread_id) + return session, created @staticmethod async def persist_ref(thread_id: str, original_ref: str, mr: MergeRequest | dict | None) -> None: - """Sync ``ChatThread.ref`` with the agent's final ``merge_request``. + """Sync ``Session.ref`` with the agent's final ``merge_request``. Accepts both a live ``MergeRequest`` instance and a dict (the snapshot gets rehydrated through the checkpointer as a plain dict, so resumed @@ -133,4 +88,4 @@ async def persist_ref(thread_id: str, original_ref: str, mr: MergeRequest | dict return new_ref = mr.get("source_branch") if isinstance(mr, dict) else getattr(mr, "source_branch", None) if new_ref and new_ref != original_ref: - await ChatThread.objects.filter(thread_id=thread_id).aupdate(ref=new_ref) + await Session.objects.filter(thread_id=thread_id).aupdate(ref=new_ref) diff --git a/daiv/chat/api/views.py b/daiv/chat/api/views.py index 7ca2d4e8f..bd6186718 100644 --- a/daiv/chat/api/views.py +++ b/daiv/chat/api/views.py @@ -8,14 +8,15 @@ from ninja.errors import HttpError from ninja.security import django_auth from sandbox_envs.services import resolve_env_for_run, resolve_env_for_user +from sessions.locks import SessionLock +from sessions.models import Session from automation.agent.validators import AgentOverrideError, ensure_agent_model_available, validate_agent_override -from chat.models import ChatThread from core.api.throttling import JobsRateThrottle from .security import AuthBearer from .streaming import ChatRunStreamer -from .threads import ChatThreadService +from .threads import ChatSessionService, _extract_first_user_message logger = logging.getLogger("daiv.chat") @@ -32,10 +33,10 @@ async def thread_status(request: HttpRequest, thread_id: str): the per-thread slot and trigger a rehydration from the checkpointer. """ user = request.auth # ty: ignore[unresolved-attribute] - thread = await ChatThread.objects.filter(thread_id=thread_id, user=user).afirst() - if thread is None: + session = await Session.objects.by_owner(user).filter(thread_id=thread_id).afirst() + if session is None: raise HttpError(404, "Thread not found") - return {"active": bool(thread.active_run_id)} + return {"active": bool(session.active_run_id)} @chat_router.post( @@ -52,10 +53,11 @@ async def thread_status(request: HttpRequest, thread_id: str): }, ) async def create_chat_completion(request: HttpRequest, input_data: RunAgentInput): - """AG-UI streaming endpoint. First sight of a ``thread_id`` creates its ``ChatThread`` - under the authenticated caller; subsequent requests must own it. The conditional - ``UPDATE`` on ``active_run_id`` atomically claims the per-thread run slot — - parallel tabs resolve to a single winner, the loser gets 409. + """AG-UI streaming endpoint. First sight of a ``thread_id`` creates its ``Session`` + under the authenticated caller; subsequent requests must be able to see it (a + webhook-origin session with ``user=None`` is continuable by anyone with visibility). + ``SessionLock.try_claim`` atomically claims the per-session run slot — parallel tabs + resolve to a single winner, the loser gets 409. """ repo_id = request.headers.get(HEADER_REPO_ID) ref = request.headers.get(HEADER_REF) @@ -79,9 +81,9 @@ async def create_chat_completion(request: HttpRequest, input_data: RunAgentInput env_obj = await resolve_env_for_user(user, env_header) except LookupError as err: raise HttpError(400, str(err)) from err - # Auto: snapshot the resolved env at thread creation so the stored env matches what ran. - # Existing threads keep their original env (get_or_create_for_user only applies on create); - # this resolution still runs on every request but is discarded for existing threads. + # Auto: snapshot the resolved env at session creation so the stored env matches what ran. + # Existing sessions keep their original env (get_or_create_for_user only applies on create); + # this resolution still runs on every request but is discarded for existing sessions. auto_resolved = env_obj is None if auto_resolved: env_obj = await resolve_env_for_run(user=user, repo_id=repo_id) @@ -89,7 +91,7 @@ async def create_chat_completion(request: HttpRequest, input_data: RunAgentInput "chat: auto-resolved env=%s for repo=%s user=%s", env_obj.id if env_obj else None, repo_id, user.pk ) - thread, created = await ChatThreadService.get_or_create_for_user( + session, created = await ChatSessionService.get_or_create_for_user( user=user, thread_id=thread_id, repo_id=repo_id, @@ -99,40 +101,44 @@ async def create_chat_completion(request: HttpRequest, input_data: RunAgentInput agent_model=agent_model, agent_thinking_level=agent_thinking_level, ) - if thread.user_id != user.id: + # Ownership: an existing session must be visible to the caller. A webhook-origin + # session (user=None) is visible to anyone who can see it, so "continue as chat" + # is just typing into any visible session — this replaces the old + # ``thread.user_id != user.id`` equality check and the ChatThreadFromActivity bridge. + if not created and not await Session.objects.by_owner(user).filter(pk=session.pk).aexists(): raise HttpError(403, "Thread not found") # Re-validate the pinned override before any other gate: a Provider row may - # have been disabled or renamed since the thread was created, OR a thinking + # have been disabled or renamed since the session was created, OR a thinking # level enum value may have been dropped. Surface a typed 400 first so the # user gets the actionable "start a new thread" hint — even when they also # tried to send a divergent override, the pinned model is the blocker. # ``validate_agent_override`` is a no-op when both fields are empty, so the # call is unconditional. try: - validate_agent_override(thread.agent_model, thread.agent_thinking_level) + validate_agent_override(session.agent_model, session.agent_thinking_level) except AgentOverrideError as err: raise HttpError( 400, f"The model pinned to this thread is no longer available: {err}. Start a new thread to pick another." ) from err # Submit-time gate: refuse the call when no model can be resolved at runtime. - # On a freshly created thread this catches "client omitted the override + admin - # never set a system default"; on resume it catches threads pinned to "" back + # On a freshly created session this catches "client omitted the override + admin + # never set a system default"; on resume it catches sessions pinned to "" back # when the now-removed Auto fallback supplied the model. Either way, surface # the configuration gap here instead of letting it explode mid-stream. try: - ensure_agent_model_available(thread.agent_model) + ensure_agent_model_available(session.agent_model) except AgentOverrideError as err: raise HttpError(400, str(err)) from err - # First-turn pin: an existing thread keeps the override that was set on creation. + # First-turn pin: an existing session keeps the override that was set on creation. # If the client supplies a divergent override (e.g. a bot bypassing the locked # composer pill), reject with 409 rather than silently running the pinned value. # Empty client values mean "no override supplied" and never count as a divergence. if not created and ( - (agent_model and agent_model != thread.agent_model) - or (agent_thinking_level and agent_thinking_level != thread.agent_thinking_level) + (agent_model and agent_model != session.agent_model) + or (agent_thinking_level and agent_thinking_level != session.agent_thinking_level) ): raise HttpError( 409, @@ -140,14 +146,14 @@ async def create_chat_completion(request: HttpRequest, input_data: RunAgentInput " from forwarded_props or start a new thread to change it.", ) - if not await ChatThreadService.try_claim_run(thread_id, run_id): + if not await SessionLock.try_claim(thread_id, run_id): raise HttpError(409, "A run is already in progress for this thread") # Only emit the resolved-env hint when: # - The client sent Auto (empty/missing header) AND we resolved something for them, AND - # - This is a newly-created thread (so the resolved env *is* what the run is using — - # on an existing-thread Auto submit, the resolved env_obj is discarded in favour of - # the thread's stored env, and lying about it would mis-stamp the locked pill). + # - This is a newly-created session (so the resolved env *is* what the run is using — + # on an existing-session Auto submit, the resolved env_obj is discarded in favour of + # the session's stored env, and lying about it would mis-stamp the locked pill). auto_resolved_env: dict[str, str] | None = None if auto_resolved and created and env_obj is not None: auto_resolved_env = {"id": str(env_obj.id), "name": str(env_obj.name), "scope": str(env_obj.scope)} @@ -160,9 +166,11 @@ async def create_chat_completion(request: HttpRequest, input_data: RunAgentInput run_id=run_id, input_data=input_data, encoder=encoder, - sandbox_environment_id=(str(thread.sandbox_environment_id) if thread.sandbox_environment_id else None), - agent_model=thread.agent_model or None, - agent_thinking_level=thread.agent_thinking_level or None, + user_id=user.pk, + prompt=_extract_first_user_message(input_data), + sandbox_environment_id=(str(session.sandbox_environment_id) if session.sandbox_environment_id else None), + agent_model=session.agent_model or None, + agent_thinking_level=session.agent_thinking_level or None, auto_resolved_env=auto_resolved_env, ) return StreamingHttpResponse(streamer.events(), content_type=encoder.get_content_type()) diff --git a/tests/unit_tests/automation/titling/test_tasks.py b/tests/unit_tests/automation/titling/test_tasks.py index adea87742..243ca23ec 100644 --- a/tests/unit_tests/automation/titling/test_tasks.py +++ b/tests/unit_tests/automation/titling/test_tasks.py @@ -5,6 +5,7 @@ import pytest from activity.models import Activity, TriggerType +from sessions.models import Run, Session, SessionOrigin from automation.titling import tasks as titling_tasks from automation.titling.tasks import GeneratedTitle, _ref_is_informative, generate_batch_title_task, generate_title_task @@ -89,6 +90,25 @@ def test_writes_generated_title(self): activity.refresh_from_db() assert activity.title == "Add login feature" + def test_writes_generated_title_for_session_entity(self): + session = Session.objects.create(thread_id=str(uuid.uuid4()), origin=SessionOrigin.CHAT, repo_id="group/repo") + with patch.object(titling_tasks.BaseAgent, "get_model", return_value=_fake_chain(title="Session title")): + generate_title_task.func( + entity_type="session", pk=session.thread_id, prompt="do a thing", repo_id="group/repo" + ) + session.refresh_from_db() + assert session.title == "Session title" + + def test_writes_generated_title_for_run_entity(self): + session = Session.objects.create( + thread_id=str(uuid.uuid4()), origin=SessionOrigin.API_JOB, repo_id="group/repo" + ) + run = Run.objects.create(session=session, trigger_type=SessionOrigin.API_JOB, repo_id="group/repo") + with patch.object(titling_tasks.BaseAgent, "get_model", return_value=_fake_chain(title="Run title")): + generate_title_task.func(entity_type="run", pk=str(run.pk), prompt="do a thing", repo_id="group/repo") + run.refresh_from_db() + assert run.title == "Run title" + def test_user_text_includes_branch_when_informative(self): activity = self._make_activity() capture: dict = {} @@ -130,26 +150,50 @@ def test_prompt_truncated_to_500_chars(self): @pytest.mark.django_db class TestGenerateBatchTitleTask: - def _make_activity(self, batch_id, *, title: str = "", repo_id: str = "group/repo") -> Activity: - return Activity.objects.create( - trigger_type=TriggerType.API_JOB, repo_id=repo_id, batch_id=batch_id, title=title + def _make_run(self, batch_id, *, title: str = "", session_title: str = "", repo_id: str = "group/repo") -> Run: + session = Session.objects.create( + thread_id=str(uuid.uuid4()), origin=SessionOrigin.API_JOB, repo_id=repo_id, title=session_title + ) + return Run.objects.create( + session=session, trigger_type=SessionOrigin.API_JOB, repo_id=repo_id, batch_id=batch_id, title=title ) def test_applies_single_title_to_all_batch_members(self): batch_id = uuid.uuid4() - members = [self._make_activity(batch_id, repo_id=f"o/r{i}") for i in range(3)] + members = [self._make_run(batch_id, repo_id=f"o/r{i}") for i in range(3)] with patch.object(titling_tasks.BaseAgent, "get_model", return_value=_fake_chain(title="Add login feature")): generate_batch_title_task.func(batch_id=str(batch_id), prompt="add login") - for activity in members: - activity.refresh_from_db() - assert activity.title == "Add login feature" + for run in members: + run.refresh_from_db() + assert run.title == "Add login feature" + + def test_stamps_session_title_when_empty(self): + """The batch title also backfills each run's parent Session when its title is empty.""" + batch_id = uuid.uuid4() + run = self._make_run(batch_id, repo_id="o/r") + + with patch.object(titling_tasks.BaseAgent, "get_model", return_value=_fake_chain(title="Batch title")): + generate_batch_title_task.func(batch_id=str(batch_id), prompt="task") + + session = Session.objects.get(pk=run.session_id) + assert session.title == "Batch title" + + def test_does_not_overwrite_already_titled_session(self): + batch_id = uuid.uuid4() + run = self._make_run(batch_id, repo_id="o/r", session_title="Session pinned") + + with patch.object(titling_tasks.BaseAgent, "get_model", return_value=_fake_chain(title="LLM choice")): + generate_batch_title_task.func(batch_id=str(batch_id), prompt="task") + + session = Session.objects.get(pk=run.session_id) + assert session.title == "Session pinned" def test_invokes_llm_exactly_once_for_n_repos(self): batch_id = uuid.uuid4() for i in range(5): - self._make_activity(batch_id, repo_id=f"o/r{i}") + self._make_run(batch_id, repo_id=f"o/r{i}") chain = _fake_chain(title="One shared title") with patch.object(titling_tasks.BaseAgent, "get_model", return_value=chain): @@ -159,21 +203,21 @@ def test_invokes_llm_exactly_once_for_n_repos(self): # so we assert on ``invoke`` directly. assert chain.invoke.call_count == 1 - def test_does_not_overwrite_already_titled_activities(self): + def test_does_not_overwrite_already_titled_runs(self): batch_id = uuid.uuid4() - activity = self._make_activity(batch_id, title="Already set") + run = self._make_run(batch_id, title="Already set") with patch.object(titling_tasks.BaseAgent, "get_model", return_value=_fake_chain(title="LLM choice")): generate_batch_title_task.func(batch_id=str(batch_id), prompt="task") - activity.refresh_from_db() - assert activity.title == "Already set" + run.refresh_from_db() + assert run.title == "Already set" def test_preserves_pre_existing_titles_in_mixed_batch(self): """Schedule runs set a synchronous title; LLM titles must not overwrite them.""" batch_id = uuid.uuid4() - prefilled = self._make_activity(batch_id, title="job · run #1", repo_id="o/sched") - empty = self._make_activity(batch_id, repo_id="o/r") + prefilled = self._make_run(batch_id, title="job · run #1", repo_id="o/sched") + empty = self._make_run(batch_id, repo_id="o/r") with patch.object(titling_tasks.BaseAgent, "get_model", return_value=_fake_chain(title="LLM choice")): generate_batch_title_task.func(batch_id=str(batch_id), prompt="task") @@ -186,8 +230,8 @@ def test_preserves_pre_existing_titles_in_mixed_batch(self): def test_user_text_omits_repo_and_branch_context(self): """Batch titling spans multiple repos, so per-repo context is intentionally dropped.""" batch_id = uuid.uuid4() - self._make_activity(batch_id, repo_id="o/r1") - self._make_activity(batch_id, repo_id="o/r2") + self._make_run(batch_id, repo_id="o/r1") + self._make_run(batch_id, repo_id="o/r2") capture: dict = {} with patch.object(titling_tasks.BaseAgent, "get_model", return_value=_fake_chain(capture=capture)): @@ -200,8 +244,8 @@ def test_user_text_omits_repo_and_branch_context(self): def test_returns_when_model_not_configured(self): batch_id = uuid.uuid4() - activity = self._make_activity(batch_id) + run = self._make_run(batch_id) with patch.object(titling_tasks.BaseAgent, "get_model", side_effect=RuntimeError("no key")): generate_batch_title_task.func(batch_id=str(batch_id), prompt="task") - activity.refresh_from_db() - assert activity.title == "" + run.refresh_from_db() + assert run.title == "" diff --git a/tests/unit_tests/chat/api/test_streaming.py b/tests/unit_tests/chat/api/test_streaming.py index 51cdbb4f4..11268a5cf 100644 --- a/tests/unit_tests/chat/api/test_streaming.py +++ b/tests/unit_tests/chat/api/test_streaming.py @@ -4,6 +4,10 @@ don't reach — most importantly the STATE_SNAPSHOT-driven ``last_mr`` capture that keeps the composer MR pill alive across reloads, and the run-slot lifecycle invariants. + +The Run-row lifecycle (``start_chat_run`` / ``finalize_chat_run``) is patched out +here so these tests stay focused on the MR-capture + lock-release invariants; the +Run helpers are covered directly in ``tests/unit_tests/sessions/test_chat_runs.py``. """ from types import SimpleNamespace @@ -16,6 +20,26 @@ from chat.api.streaming import ChatRunStreamer, RuntimeContextLangGraphAGUIAgent +@pytest.fixture(autouse=True) +def _patch_run_lifecycle(): + """Stub the Run-row helpers so streaming tests don't need a Session row in the DB.""" + + async def _fake_start(**_kwargs): + return SimpleNamespace(pk="run-pk") + + async def _fake_finalize(*_args, **_kwargs): + return None + + with ( + patch("chat.api.streaming.start_chat_run", side_effect=_fake_start), + patch("chat.api.streaming.finalize_chat_run", side_effect=_fake_finalize), + # ``track_usage_metadata`` is a real contextmanager; keep it but with a no-op handler + # so ``build_usage_summary`` isn't exercised against a live callback here. + patch("chat.api.streaming.build_usage_summary", return_value=MagicMock(to_dict=lambda: None)), + ): + yield + + def _mock_ctx(*_args, **_kwargs): """Async context manager yielding a MagicMock — stands in for ``open_checkpointer`` / ``set_runtime_ctx`` so we don't touch Redis or clone a repo. @@ -78,9 +102,9 @@ async def _capture_release(thread_id, run_id): patch("chat.api.streaming.set_runtime_ctx", _mock_ctx), patch("chat.api.streaming.create_daiv_agent", new=AsyncMock()), patch("chat.api.streaming.RuntimeContextLangGraphAGUIAgent", return_value=_mock_agent([snapshot])), - patch("chat.api.streaming.ChatThreadService.persist_ref", side_effect=_capture_persist), - patch("chat.api.streaming.ChatThreadService.release_run", side_effect=_capture_release), - patch("chat.api.streaming.ChatThreadService.heartbeat", new=AsyncMock()), + patch("chat.api.streaming.ChatSessionService.persist_ref", side_effect=_capture_persist), + patch("chat.api.streaming.SessionLock.release", side_effect=_capture_release), + patch("chat.api.streaming.SessionLock.heartbeat", new=AsyncMock()), ): streamer = _streamer() async for _ in streamer.events(): @@ -118,9 +142,9 @@ async def _capture_persist(thread_id, original_ref, captured_mr): patch("chat.api.streaming.set_runtime_ctx", _mock_ctx), patch("chat.api.streaming.create_daiv_agent", new=AsyncMock()), patch("chat.api.streaming.RuntimeContextLangGraphAGUIAgent", return_value=_mock_agent([snap_first, snap_last])), - patch("chat.api.streaming.ChatThreadService.persist_ref", side_effect=_capture_persist), - patch("chat.api.streaming.ChatThreadService.release_run", new=AsyncMock()), - patch("chat.api.streaming.ChatThreadService.heartbeat", new=AsyncMock()), + patch("chat.api.streaming.ChatSessionService.persist_ref", side_effect=_capture_persist), + patch("chat.api.streaming.SessionLock.release", new=AsyncMock()), + patch("chat.api.streaming.SessionLock.heartbeat", new=AsyncMock()), ): async for _ in _streamer().events(): pass @@ -148,9 +172,9 @@ async def _capture_persist(thread_id, original_ref, captured_mr): patch("chat.api.streaming.set_runtime_ctx", _mock_ctx), patch("chat.api.streaming.create_daiv_agent", new=AsyncMock()), patch("chat.api.streaming.RuntimeContextLangGraphAGUIAgent", return_value=_mock_agent([snapshot_no_mr])), - patch("chat.api.streaming.ChatThreadService.persist_ref", side_effect=_capture_persist), - patch("chat.api.streaming.ChatThreadService.release_run", new=AsyncMock()), - patch("chat.api.streaming.ChatThreadService.heartbeat", new=AsyncMock()), + patch("chat.api.streaming.ChatSessionService.persist_ref", side_effect=_capture_persist), + patch("chat.api.streaming.SessionLock.release", new=AsyncMock()), + patch("chat.api.streaming.SessionLock.heartbeat", new=AsyncMock()), ): async for _ in _streamer().events(): pass @@ -191,9 +215,9 @@ async def _capture_release(*args): patch("chat.api.streaming.set_runtime_ctx", _mock_ctx), patch("chat.api.streaming.create_daiv_agent", new=AsyncMock()), patch("chat.api.streaming.RuntimeContextLangGraphAGUIAgent", return_value=runner), - patch("chat.api.streaming.ChatThreadService.persist_ref", side_effect=_capture_persist), - patch("chat.api.streaming.ChatThreadService.release_run", side_effect=_capture_release), - patch("chat.api.streaming.ChatThreadService.heartbeat", new=AsyncMock()), + patch("chat.api.streaming.ChatSessionService.persist_ref", side_effect=_capture_persist), + patch("chat.api.streaming.SessionLock.release", side_effect=_capture_release), + patch("chat.api.streaming.SessionLock.heartbeat", new=AsyncMock()), ): async for _ in _streamer().events(): pass @@ -220,9 +244,9 @@ async def _capture_release(thread_id, run_id): patch("chat.api.streaming.set_runtime_ctx", _mock_ctx), patch("chat.api.streaming.create_daiv_agent", new=AsyncMock()), patch("chat.api.streaming.RuntimeContextLangGraphAGUIAgent", return_value=_mock_agent([])), - patch("chat.api.streaming.ChatThreadService.persist_ref", side_effect=_persist_boom), - patch("chat.api.streaming.ChatThreadService.release_run", side_effect=_capture_release), - patch("chat.api.streaming.ChatThreadService.heartbeat", new=AsyncMock()), + patch("chat.api.streaming.ChatSessionService.persist_ref", side_effect=_persist_boom), + patch("chat.api.streaming.SessionLock.release", side_effect=_capture_release), + patch("chat.api.streaming.SessionLock.heartbeat", new=AsyncMock()), ): async for _ in _streamer().events(): pass diff --git a/tests/unit_tests/chat/api/test_threads.py b/tests/unit_tests/chat/api/test_threads.py index 9fa191735..07779c1b5 100644 --- a/tests/unit_tests/chat/api/test_threads.py +++ b/tests/unit_tests/chat/api/test_threads.py @@ -1,15 +1,11 @@ -import asyncio -from datetime import timedelta from types import SimpleNamespace from unittest.mock import patch -from django.utils import timezone - import pytest +from sessions.models import Session, SessionOrigin from accounts.models import User -from chat.api.threads import STALE_RUN_MINUTES, ChatThreadService, _extract_first_user_message -from chat.models import ChatThread +from chat.api.threads import ChatSessionService, _extract_first_user_message from core.models import Provider, ProviderType @@ -59,147 +55,56 @@ def test_extract_first_user_message_skips_non_user_roles(): @pytest.mark.django_db(transaction=True) async def test_persist_ref_updates_when_branch_changed(): user = await User.objects.acreate_user(username="u-ref-1", email="ref1@x.com", password="x") # noqa: S106 - await ChatThread.objects.acreate(thread_id="t-ref-1", user=user, repo_id="a/b", ref="feature-x") + await Session.objects.acreate( + thread_id="t-ref-1", origin=SessionOrigin.CHAT, user=user, repo_id="a/b", ref="feature-x" + ) - await ChatThreadService.persist_ref("t-ref-1", "feature-x", SimpleNamespace(source_branch="feature-y")) + await ChatSessionService.persist_ref("t-ref-1", "feature-x", SimpleNamespace(source_branch="feature-y")) - refreshed = await ChatThread.objects.aget(thread_id="t-ref-1") + refreshed = await Session.objects.aget(thread_id="t-ref-1") assert refreshed.ref == "feature-y" await user.adelete() @pytest.mark.django_db(transaction=True) async def test_persist_ref_noop_when_branch_unchanged(): - with patch("chat.api.threads.ChatThread.objects.filter") as filter_mock: - await ChatThreadService.persist_ref("t-ref-2", "feature-x", SimpleNamespace(source_branch="feature-x")) + with patch("chat.api.threads.Session.objects.filter") as filter_mock: + await ChatSessionService.persist_ref("t-ref-2", "feature-x", SimpleNamespace(source_branch="feature-x")) filter_mock.assert_not_called() @pytest.mark.django_db(transaction=True) async def test_persist_ref_noop_when_no_mr_captured(): - with patch("chat.api.threads.ChatThread.objects.filter") as filter_mock: - await ChatThreadService.persist_ref("t-ref-3", "feature-x", None) + with patch("chat.api.threads.Session.objects.filter") as filter_mock: + await ChatSessionService.persist_ref("t-ref-3", "feature-x", None) filter_mock.assert_not_called() @pytest.mark.django_db(transaction=True) -async def test_try_claim_run_succeeds_on_free_slot(): - user = await User.objects.acreate_user(username="u-claim-1", email="c1@x.com", password="x") # noqa: S106 - await ChatThread.objects.acreate(thread_id="t-claim-1", user=user, repo_id="a/b", ref="main") - - assert await ChatThreadService.try_claim_run("t-claim-1", "r-1") is True - - refreshed = await ChatThread.objects.aget(thread_id="t-claim-1") - assert refreshed.active_run_id == "r-1" - await user.adelete() - - -@pytest.mark.django_db(transaction=True) -async def test_try_claim_run_fails_on_held_slot(): - user = await User.objects.acreate_user(username="u-claim-2", email="c2@x.com", password="x") # noqa: S106 - await ChatThread.objects.acreate( - thread_id="t-claim-2", user=user, repo_id="a/b", ref="main", active_run_id="r-existing" - ) - - assert await ChatThreadService.try_claim_run("t-claim-2", "r-new") is False - - refreshed = await ChatThread.objects.aget(thread_id="t-claim-2") - # Loser does not overwrite the winner's run_id. - assert refreshed.active_run_id == "r-existing" - await user.adelete() - +async def test_get_or_create_creates_chat_origin_session(): + user = await User.objects.acreate_user(username="u-create-1", email="create1@x.com", password="x") # noqa: S106 + input_data = _fake_input(["hello"]) -@pytest.mark.django_db(transaction=True) -async def test_try_claim_run_concurrent_calls_yield_exactly_one_winner(): - # Direct regression test for the TOCTOU fix in commit dde32f93. The whole - # point of the conditional UPDATE is that two simultaneous claims can't - # both succeed; this asserts the protocol holds when the calls overlap. - user = await User.objects.acreate_user(username="u-claim-3", email="c3@x.com", password="x") # noqa: S106 - await ChatThread.objects.acreate(thread_id="t-claim-3", user=user, repo_id="a/b", ref="main") - - results = await asyncio.gather( - ChatThreadService.try_claim_run("t-claim-3", "r-A"), ChatThreadService.try_claim_run("t-claim-3", "r-B") + session, created = await ChatSessionService.get_or_create_for_user( + user=user, thread_id="t-create-1", repo_id="acme/x", ref="main", input_data=input_data ) - assert sorted(results) == [False, True] - - refreshed = await ChatThread.objects.aget(thread_id="t-claim-3") - assert refreshed.active_run_id in ("r-A", "r-B") - await user.adelete() - -@pytest.mark.django_db(transaction=True) -async def test_release_run_clears_slot_and_reopens_for_claim(): - user = await User.objects.acreate_user(username="u-rel", email="rel@x.com", password="x") # noqa: S106 - await ChatThread.objects.acreate(thread_id="t-rel", user=user, repo_id="a/b", ref="main", active_run_id="r-old") - - await ChatThreadService.release_run("t-rel", "r-old") - refreshed = await ChatThread.objects.aget(thread_id="t-rel") - assert refreshed.active_run_id is None - - # Next claim succeeds — the slot is genuinely free, not just blanked. - assert await ChatThreadService.try_claim_run("t-rel", "r-next") is True - await user.adelete() - - -@pytest.mark.django_db(transaction=True) -async def test_release_run_does_not_clear_other_holders_slot(): - """Stale `finally` from a cancelled run must not stomp a freshly-claimed slot.""" - user = await User.objects.acreate_user(username="u-rel-mismatch", email="chat@example.com", password="x") # noqa: S106 - await ChatThread.objects.acreate(thread_id="t-rel-x", user=user, repo_id="a/b", ref="main", active_run_id="r-fresh") - - # Stale streamer's finally tries to release with the OLD run_id. - await ChatThreadService.release_run("t-rel-x", "r-stale") - - refreshed = await ChatThread.objects.aget(thread_id="t-rel-x") - assert refreshed.active_run_id == "r-fresh" # untouched - await user.adelete() - - -@pytest.mark.django_db(transaction=True) -async def test_try_claim_run_takes_over_stale_slot(): - """Worker crash leaves active_run_id set; after the heartbeat window expires - a fresh claim succeeds. Without this the thread would be permanently locked. - """ - user = await User.objects.acreate_user(username="u-stale", email="owner@example.com", password="x") # noqa: S106 - stale_at = timezone.now() - timedelta(minutes=STALE_RUN_MINUTES + 1) - await ChatThread.objects.acreate(thread_id="t-stale", user=user, repo_id="a/b", ref="main", active_run_id="r-dead") - # auto_now would clobber the stale timestamp — force it via aupdate. - await ChatThread.objects.filter(thread_id="t-stale").aupdate(last_active_at=stale_at) - - assert await ChatThreadService.try_claim_run("t-stale", "r-new") is True - refreshed = await ChatThread.objects.aget(thread_id="t-stale") - assert refreshed.active_run_id == "r-new" - await user.adelete() - - -@pytest.mark.django_db(transaction=True) -async def test_heartbeat_only_bumps_when_caller_holds_slot(): - """Delayed heartbeat from a previous run must not keep a stolen slot alive.""" - user = await User.objects.acreate_user(username="u-hb", email="i@example.com", password="x") # noqa: S106 - await ChatThread.objects.acreate(thread_id="t-hb", user=user, repo_id="a/b", ref="main", active_run_id="r-current") - old_timestamp = timezone.now() - timedelta(minutes=STALE_RUN_MINUTES + 5) - await ChatThread.objects.filter(thread_id="t-hb").aupdate(last_active_at=old_timestamp) - - # Stale run heartbeats — should be a no-op because it doesn't hold the slot. - await ChatThreadService.heartbeat("t-hb", "r-stale") - refreshed = await ChatThread.objects.aget(thread_id="t-hb") - assert (timezone.now() - refreshed.last_active_at).total_seconds() > STALE_RUN_MINUTES * 60 - - # Real holder bumps successfully. - await ChatThreadService.heartbeat("t-hb", "r-current") - refreshed = await ChatThread.objects.aget(thread_id="t-hb") - assert (timezone.now() - refreshed.last_active_at).total_seconds() < 5 + assert created is True + assert session.origin == SessionOrigin.CHAT + assert session.user_id == user.id + assert session.repo_id == "acme/x" + assert session.ref == "main" await user.adelete() @pytest.mark.django_db(transaction=True) -async def test_override_pinned_on_thread_creation(openrouter_provider): +async def test_override_pinned_on_session_creation(openrouter_provider): user = await User.objects.acreate_user(username="u-ov-1", email="ov1@x.com", password="x") # noqa: S106 input_data = _fake_input(["hello"]) - thread, created = await ChatThreadService.get_or_create_for_user( + session, created = await ChatSessionService.get_or_create_for_user( user=user, thread_id="t-ov-1", repo_id="acme/x", @@ -210,19 +115,19 @@ async def test_override_pinned_on_thread_creation(openrouter_provider): ) assert created is True - assert thread.agent_model == "openrouter:anthropic/claude-haiku-4.5" - assert thread.agent_thinking_level == "low" + assert session.agent_model == "openrouter:anthropic/claude-haiku-4.5" + assert session.agent_thinking_level == "low" await user.adelete() @pytest.mark.django_db(transaction=True) -async def test_override_ignored_on_existing_thread(openrouter_provider): +async def test_override_ignored_on_existing_session(openrouter_provider): # First turn pins the override; the second turn supplies different values # but ``aget_or_create`` ignores defaults on hit, so the pinned values stand. user = await User.objects.acreate_user(username="u-ov-2", email="ov2@x.com", password="x") # noqa: S106 input_data = _fake_input(["hello"]) - _, first_created = await ChatThreadService.get_or_create_for_user( + _, first_created = await ChatSessionService.get_or_create_for_user( user=user, thread_id="t-ov-2", repo_id="acme/x", @@ -231,7 +136,7 @@ async def test_override_ignored_on_existing_thread(openrouter_provider): agent_model="openrouter:anthropic/claude-haiku-4.5", agent_thinking_level="low", ) - thread, created = await ChatThreadService.get_or_create_for_user( + session, created = await ChatSessionService.get_or_create_for_user( user=user, thread_id="t-ov-2", repo_id="acme/x", @@ -243,6 +148,6 @@ async def test_override_ignored_on_existing_thread(openrouter_provider): assert first_created is True assert created is False - assert thread.agent_model == "openrouter:anthropic/claude-haiku-4.5" - assert thread.agent_thinking_level == "low" + assert session.agent_model == "openrouter:anthropic/claude-haiku-4.5" + assert session.agent_thinking_level == "low" await user.adelete() diff --git a/tests/unit_tests/chat/api/test_views.py b/tests/unit_tests/chat/api/test_views.py index 7a2a5dccd..4e160c04d 100644 --- a/tests/unit_tests/chat/api/test_views.py +++ b/tests/unit_tests/chat/api/test_views.py @@ -2,9 +2,9 @@ import pytest from ninja.testing import TestAsyncClient +from sessions.models import Session, SessionOrigin from accounts.models import APIKey, User -from chat.models import ChatThread from daiv.api import api @@ -108,7 +108,7 @@ async def test_cross_user_thread_id_is_rejected(client: TestAsyncClient, authed) email="owner@example.com", password="x", # noqa: S106 ) - await ChatThread.objects.acreate(thread_id="t-owned", user=other, repo_id="a/b", ref="main") + await Session.objects.acreate(origin=SessionOrigin.CHAT, thread_id="t-owned", user=other, repo_id="a/b", ref="main") response = await client.post( "/chat/completions", @@ -145,7 +145,7 @@ async def _empty_stream(_input): ) assert response.status_code == 200 - created = await ChatThread.objects.filter(thread_id="t-new").afirst() + created = await Session.objects.filter(thread_id="t-new").afirst() assert created is not None assert created.user_id == user.id assert created.repo_id == "a/b" @@ -191,7 +191,7 @@ async def _empty_stream(_input): headers=_auth_headers(raw, **{"X-Repo-ID": "a/b", "X-Ref": "main"}), ) assert response.status_code == 200 - created = await ChatThread.objects.aget(thread_id="t-auto") + created = await Session.objects.aget(thread_id="t-auto") assert created.sandbox_environment_id == user_env.id await user.adelete() @@ -210,8 +210,13 @@ async def test_existing_thread_keeps_original_env_even_when_resolution_would_pic scope=Scope.GLOBAL, name="Original", base_image="python:3.14", is_default=True ) # Pre-create the thread with the original env, simulating a prior first-message run. - await ChatThread.objects.acreate( - thread_id="t-keep", user=user, repo_id="a/b", ref="main", sandbox_environment=original + await Session.objects.acreate( + origin=SessionOrigin.CHAT, + thread_id="t-keep", + user=user, + repo_id="a/b", + ref="main", + sandbox_environment=original, ) # Now add a USER env that would win at Auto resolution; the existing thread must # ignore it because get_or_create_for_user only applies on create. @@ -240,7 +245,7 @@ async def _empty_stream(_input): headers=_auth_headers(raw, **{"X-Repo-ID": "a/b", "X-Ref": "main"}), ) assert response.status_code == 200 - thread = await ChatThread.objects.aget(thread_id="t-keep") + thread = await Session.objects.aget(thread_id="t-keep") assert thread.sandbox_environment_id == original.id await user.adelete() @@ -285,8 +290,13 @@ async def test_existing_thread_auto_submit_does_not_emit_resolved_env( original = await SandboxEnvironment.objects.acreate( scope=Scope.GLOBAL, name="Original", base_image="python:3.14", is_default=True ) - await ChatThread.objects.acreate( - thread_id="t-existing-auto", user=user, repo_id="a/b", ref="main", sandbox_environment=original + await Session.objects.acreate( + origin=SessionOrigin.CHAT, + thread_id="t-existing-auto", + user=user, + repo_id="a/b", + ref="main", + sandbox_environment=original, ) response = await client.post( @@ -324,7 +334,7 @@ async def test_explicit_env_header_does_not_emit_resolved_env(client: TestAsyncC @pytest.mark.django_db(transaction=True) async def test_exception_in_stream_clears_active_run_id_and_emits_run_error(client: TestAsyncClient, authed): _, raw, user = authed - await ChatThread.objects.acreate(thread_id="t-boom", user=user, repo_id="a/b", ref="main") + await Session.objects.acreate(origin=SessionOrigin.CHAT, thread_id="t-boom", user=user, repo_id="a/b", ref="main") with ( patch("chat.api.streaming.open_checkpointer", _mock_stream), @@ -357,7 +367,7 @@ async def _boom(_input): # in a stack trace. assert "kaboom" not in body assert "RuntimeError" not in body - refreshed = await ChatThread.objects.aget(thread_id="t-boom") + refreshed = await Session.objects.aget(thread_id="t-boom") assert refreshed.active_run_id is None await user.adelete() @@ -365,8 +375,12 @@ async def _boom(_input): @pytest.mark.django_db(transaction=True) async def test_thread_status_reports_active_run(client: TestAsyncClient, authed): _, raw, user = authed - await ChatThread.objects.acreate(thread_id="t-live", user=user, repo_id="a/b", ref="main", active_run_id="r-1") - await ChatThread.objects.acreate(thread_id="t-idle", user=user, repo_id="a/b", ref="main", active_run_id=None) + await Session.objects.acreate( + origin=SessionOrigin.CHAT, thread_id="t-live", user=user, repo_id="a/b", ref="main", active_run_id="r-1" + ) + await Session.objects.acreate( + origin=SessionOrigin.CHAT, thread_id="t-idle", user=user, repo_id="a/b", ref="main", active_run_id=None + ) live = await client.get("/chat/threads/t-live/status", headers=_auth_headers(raw)) idle = await client.get("/chat/threads/t-idle/status", headers=_auth_headers(raw)) @@ -386,7 +400,9 @@ async def test_thread_status_rejects_cross_user_access(client: TestAsyncClient, email="i@example.com", password="x", # noqa: S106 ) - await ChatThread.objects.acreate(thread_id="t-foreign", user=other, repo_id="a/b", ref="main", active_run_id="r-9") + await Session.objects.acreate( + origin=SessionOrigin.CHAT, thread_id="t-foreign", user=other, repo_id="a/b", ref="main", active_run_id="r-9" + ) response = await client.get("/chat/threads/t-foreign/status", headers=_auth_headers(raw)) assert response.status_code == 404 @@ -397,8 +413,8 @@ async def test_thread_status_rejects_cross_user_access(client: TestAsyncClient, @pytest.mark.django_db(transaction=True) async def test_concurrent_run_returns_409(client: TestAsyncClient, authed): _, raw, user = authed - await ChatThread.objects.acreate( - thread_id="t-busy", user=user, repo_id="a/b", ref="main", active_run_id="r-existing" + await Session.objects.acreate( + origin=SessionOrigin.CHAT, thread_id="t-busy", user=user, repo_id="a/b", ref="main", active_run_id="r-existing" ) response = await client.post( "/chat/completions", @@ -423,7 +439,7 @@ def openrouter_provider(db): async def test_first_turn_rejects_invalid_agent_override_and_does_not_persist( client: TestAsyncClient, authed, openrouter_provider ): - """A malformed forwarded override must return 400 before any ChatThread row is created. + """A malformed forwarded override must return 400 before any Session row is created. Without this guard the picker validator could be bypassed and we'd persist an invalid spec that later fails opaquely during the stream.""" _, raw, user = authed @@ -435,7 +451,7 @@ async def test_first_turn_rejects_invalid_agent_override_and_does_not_persist( headers=_auth_headers(raw, **{"X-Repo-ID": "a/b", "X-Ref": "main"}), ) assert response.status_code == 400 - assert await ChatThread.objects.filter(thread_id="t-bad-override").aexists() is False + assert await Session.objects.filter(thread_id="t-bad-override").aexists() is False await user.adelete() @@ -445,7 +461,8 @@ async def test_divergent_override_on_existing_thread_returns_409(client: TestAsy rather than silently running the persisted value — surfaces a bot bypassing the locked composer pill instead of letting the user think they switched models.""" _, raw, user = authed - await ChatThread.objects.acreate( + await Session.objects.acreate( + origin=SessionOrigin.CHAT, thread_id="t-pinned", user=user, repo_id="a/b", @@ -485,7 +502,8 @@ async def test_existing_thread_with_stale_persisted_override_returns_400( surface a typed 400 before the stream starts, rather than blowing up deep in the agent with an opaque ``ValueError``.""" _, raw, user = authed - await ChatThread.objects.acreate( + await Session.objects.acreate( + origin=SessionOrigin.CHAT, thread_id="t-stale-pin", user=user, repo_id="a/b", @@ -526,7 +544,8 @@ async def test_existing_thread_with_disabled_provider_returns_400( """``is_enabled=False`` rows must also fail at the 400 boundary, not deep in ``BaseAgent.get_model_kwargs`` mid-run.""" _, raw, user = authed - await ChatThread.objects.acreate( + await Session.objects.acreate( + origin=SessionOrigin.CHAT, thread_id="t-disabled-pin", user=user, repo_id="a/b", @@ -557,7 +576,8 @@ async def test_stale_pinned_model_wins_over_divergent_client_override( from the level mismatch with the persisted ``low``. """ _, raw, user = authed - await ChatThread.objects.acreate( + await Session.objects.acreate( + origin=SessionOrigin.CHAT, thread_id="t-stale-and-divergent", user=user, repo_id="a/b", diff --git a/tests/unit_tests/sessions/test_chat_runs.py b/tests/unit_tests/sessions/test_chat_runs.py new file mode 100644 index 000000000..e5c64ce12 --- /dev/null +++ b/tests/unit_tests/sessions/test_chat_runs.py @@ -0,0 +1,65 @@ +import uuid +from decimal import Decimal + +import pytest +from sessions.models import RunStatus, Session, SessionOrigin + +from chat.api.streaming import finalize_chat_run, start_chat_run + +# ``transaction=True``: these are async DB tests. Async writes commit and escape the +# plain-``django_db`` savepoint rollback (a known footgun in this project's in-memory +# SQLite), so the created Run/Session rows would leak into later tests that make global +# ``Run.objects.count()`` assertions (e.g. ``test_data_migration``). The transactional +# flush after each test cleans them up. +pytestmark = pytest.mark.django_db(transaction=True) + + +async def _mk_user(django_user_model): + """Async DB rows escape plain-``django_db`` rollback in this project's in-memory + SQLite, so give each user a unique username/email to avoid cross-test collisions. + """ + tag = uuid.uuid4().hex[:8] + return await django_user_model.objects.acreate_user( + username=f"u-{tag}", + email=f"u-{tag}@x.io", + password="x", # noqa: S106 + ) + + +async def _mk_chat_session(user) -> Session: + return await Session.objects.acreate( + thread_id=str(uuid.uuid4()), origin=SessionOrigin.CHAT, repo_id="g/r", user=user + ) + + +async def test_start_chat_run_creates_running_run(django_user_model): + user = await _mk_user(django_user_model) + session = await _mk_chat_session(user) + run = await start_chat_run(session_id=session.thread_id, user_id=user.pk, prompt="hello", repo_id="g/r", ref="main") + assert run.trigger_type == SessionOrigin.CHAT + assert run.status == RunStatus.RUNNING + assert run.started_at is not None + assert run.task_result_id is None + + +async def test_finalize_chat_run_success_records_usage(django_user_model): + user = await _mk_user(django_user_model) + session = await _mk_chat_session(user) + run = await start_chat_run(session_id=session.thread_id, user_id=user.pk, prompt="hi", repo_id="g/r", ref="main") + usage = {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15, "cost_usd": "0.01", "by_model": {}} + await finalize_chat_run(run.pk, success=True, usage=usage, response_text="done") + await run.arefresh_from_db() + assert run.status == RunStatus.SUCCESSFUL + assert run.finished_at is not None + assert run.total_tokens == 15 + assert run.cost_usd == Decimal("0.01") + assert run.result_summary == "done" + + +async def test_finalize_chat_run_failure(django_user_model): + user = await _mk_user(django_user_model) + session = await _mk_chat_session(user) + run = await start_chat_run(session_id=session.thread_id, user_id=user.pk, prompt="hi", repo_id="g/r", ref="main") + await finalize_chat_run(run.pk, success=False, usage=None, response_text="") + await run.arefresh_from_db() + assert run.status == RunStatus.FAILED From e44c2cfb588da202a3977e4917758cdb758af4e7 Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 7 Jul 2026 18:37:23 +0100 Subject: [PATCH 12/55] refactor(memory,notifications): hang observations and notifications off Run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace MemoryObservation.activity FK (→ activity.Activity) with MemoryObservation.run FK (→ agent_sessions.Run); migration 0002 adds the new column, copies activity_id values whose PKs exist in Run, then drops the old column. - Switch memory.signals receiver from activity_finished to run_finished; add CHAT-trigger guard so chat turns are never mined for observations. - Update extract_observations_task to accept run_id, look up Run, and use run.session_id as the LangGraph thread_id. - notifications.signals: on_run_finished receiver (already scaffolded) now fully wired with CHAT- and webhook-trigger guards, schedule resolution via run.session.scheduled_job, and a top-level try/except so errors never crash the run lifecycle. - Update memory/detail.html template to reference obs.run instead of obs.activity. --- .../0002_swap_activity_fk_to_run.py | 32 ++++++++ .../notifications/test_run_signals.py | 73 +++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 daiv/memory/migrations/0002_swap_activity_fk_to_run.py create mode 100644 tests/unit_tests/notifications/test_run_signals.py diff --git a/daiv/memory/migrations/0002_swap_activity_fk_to_run.py b/daiv/memory/migrations/0002_swap_activity_fk_to_run.py new file mode 100644 index 000000000..261b6f55b --- /dev/null +++ b/daiv/memory/migrations/0002_swap_activity_fk_to_run.py @@ -0,0 +1,32 @@ +import django.db.models.deletion +from django.db import migrations, models + + +def copy_activity_fk(apps, schema_editor): + MemoryObservation = apps.get_model("memory", "MemoryObservation") + Run = apps.get_model("agent_sessions", "Run") + run_ids = set(Run.objects.values_list("id", flat=True)) + for obs in MemoryObservation.objects.exclude(activity__isnull=True).iterator(): + if obs.activity_id in run_ids: + obs.run_id = obs.activity_id + obs.save(update_fields=["run_id"]) + + +class Migration(migrations.Migration): + dependencies = [("memory", "0001_initial"), ("agent_sessions", "0002_backfill_from_activity_and_chat")] + operations = [ + migrations.AddField( + model_name="memoryobservation", + name="run", + field=models.ForeignKey( + to="agent_sessions.run", + on_delete=django.db.models.deletion.SET_NULL, + null=True, + blank=True, + related_name="memory_observations", + verbose_name="run", + ), + ), + migrations.RunPython(copy_activity_fk, migrations.RunPython.noop), + migrations.RemoveField(model_name="memoryobservation", name="activity"), + ] diff --git a/tests/unit_tests/notifications/test_run_signals.py b/tests/unit_tests/notifications/test_run_signals.py new file mode 100644 index 000000000..b66585507 --- /dev/null +++ b/tests/unit_tests/notifications/test_run_signals.py @@ -0,0 +1,73 @@ +"""Tests for notifications receivers wired to sessions.signals.run_finished.""" + +from unittest.mock import patch + +import pytest +from notifications.choices import NotifyOn +from notifications.models import Notification +from sessions.models import Run, RunStatus, Session, SessionOrigin +from sessions.signals import run_finished + +from schedules.models import Frequency, ScheduledJob + + +def _session(*, origin=SessionOrigin.API_JOB, thread_id="thread-run-1", repo_id="x/y", **kwargs): + return Session.objects.create(thread_id=thread_id, origin=origin, repo_id=repo_id, **kwargs) + + +def _run(session, *, trigger_type=SessionOrigin.API_JOB, status=RunStatus.SUCCESSFUL, repo_id="x/y", **kwargs): + return Run.objects.create(session=session, trigger_type=trigger_type, status=status, repo_id=repo_id, **kwargs) + + +@pytest.mark.django_db +class TestMemorySkipChatRuns: + """capture_run_observations must ignore CHAT-triggered runs.""" + + def test_memory_skips_chat_runs(self): + session = _session(origin=SessionOrigin.CHAT, thread_id="chat-thread") + run = _run(session, trigger_type=SessionOrigin.CHAT) + with patch("memory.signals.extract_observations_task") as task_mock: + run_finished.send(sender=Run, run=run) + task_mock.enqueue.assert_not_called() + + def test_memory_processes_api_job_runs(self): + session = _session() + run = _run(session) + with patch("memory.signals.extract_observations_task") as task_mock: + run_finished.send(sender=Run, run=run) + task_mock.enqueue.assert_called_once_with(str(run.pk)) + + +@pytest.mark.django_db +class TestNotificationsSkipChatRuns: + """on_run_finished must ignore CHAT-triggered runs.""" + + def test_notifications_skip_chat_runs(self, member_user): + session = _session(origin=SessionOrigin.CHAT, thread_id="chat-notif", user=member_user) + run = _run(session, trigger_type=SessionOrigin.CHAT, user=member_user) + run_finished.send(sender=Run, run=run) + assert Notification.objects.filter(recipient=member_user).count() == 0 + + def test_notifications_process_api_job_runs(self, member_user): + member_user.notify_on_jobs = NotifyOn.ALWAYS + member_user.save(update_fields=["notify_on_jobs"]) + + session = _session(user=member_user) + run = _run(session, user=member_user) + run_finished.send(sender=Run, run=run) + assert Notification.objects.filter(recipient=member_user).count() == 1 + + def test_notifications_process_schedule_runs(self, member_user): + schedule = ScheduledJob.objects.create( + user=member_user, + name="nightly", + prompt="p", + repos=[{"repo_id": "x/y", "ref": ""}], + frequency=Frequency.DAILY, + time="12:00", + notify_on=NotifyOn.ALWAYS, + ) + session = _session(origin=SessionOrigin.SCHEDULE, thread_id="sched-thread", scheduled_job=schedule) + run = _run(session, trigger_type=SessionOrigin.SCHEDULE, user=member_user) + run_finished.send(sender=Run, run=run) + assert Notification.objects.filter(recipient=member_user, event_type="schedule.finished").count() == 1 From 8ebb78b29435cc59ae16872c2234251d2781d38a Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 7 Jul 2026 18:37:45 +0100 Subject: [PATCH 13/55] refactor(memory,notifications): update models, signals, tasks and tests Complete the FK swap: memory models, signals, tasks, views and templates now reference sessions.Run instead of activity.Activity. Tests updated to use Run/Session fixtures. Notifications on_run_finished receiver completed with chat-skip guard and run-based helpers. --- daiv/memory/models.py | 6 +- daiv/memory/signals.py | 22 +- daiv/memory/tasks.py | 59 ++-- daiv/memory/templates/memory/detail.html | 4 +- daiv/memory/views.py | 2 +- daiv/notifications/signals.py | 285 ++++++++++++++++++ .../unit_tests/memory/test_extraction_task.py | 80 ++--- tests/unit_tests/memory/test_models.py | 15 +- tests/unit_tests/memory/test_signals.py | 81 +++-- .../unit_tests/notifications/test_signals.py | 127 ++++++++ 10 files changed, 561 insertions(+), 120 deletions(-) diff --git a/daiv/memory/models.py b/daiv/memory/models.py index 9b23405de..5c5d631bf 100644 --- a/daiv/memory/models.py +++ b/daiv/memory/models.py @@ -25,13 +25,13 @@ class MemoryObservation(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) repo_id = models.CharField(_("repository"), max_length=255, db_index=True) - activity = models.ForeignKey( - "activity.Activity", + run = models.ForeignKey( + "agent_sessions.Run", on_delete=models.SET_NULL, null=True, blank=True, related_name="memory_observations", - verbose_name=_("activity"), + verbose_name=_("run"), ) category = models.CharField(_("category"), max_length=32, choices=ObservationCategory.choices) content = models.TextField(_("content")) diff --git a/daiv/memory/signals.py b/daiv/memory/signals.py index c0c16258c..4e7affc9f 100644 --- a/daiv/memory/signals.py +++ b/daiv/memory/signals.py @@ -5,32 +5,36 @@ from django.dispatch import receiver -from activity.signals import activity_finished +from sessions.signals import run_finished from memory.tasks import extract_observations_task logger = logging.getLogger("daiv.memory") -@receiver(activity_finished) -def capture_run_observations(sender: type, activity: Any, **kwargs: Any) -> None: +@receiver(run_finished) +def capture_run_observations(sender: type, run: Any, **kwargs: Any) -> None: """Enqueue transcript extraction when a run reaches a terminal status. FAILED runs are included — failures are valuable learning signal. ``skip_dispatch=True`` marks re-emits from dispatch-failure paths: those - activities never executed, so there is no new transcript to mine. + runs never executed, so there is no new transcript to mine. + CHAT-triggered runs are skipped — interactive chat turns are not agent + sessions worth mining for repository-scoped memory. Exception-safe: memory capture must never affect the run lifecycle (the signal is robust-sent, but we don't rely on that). """ - from activity.models import ActivityStatus + from sessions.models import RunStatus, SessionOrigin try: if kwargs.get("skip_dispatch"): return - if activity.status not in ActivityStatus.terminal(): + if run.trigger_type == SessionOrigin.CHAT: return - if not activity.thread_id: + if run.status not in RunStatus.terminal(): return - extract_observations_task.enqueue(str(activity.pk)) + if not run.session_id: + return + extract_observations_task.enqueue(str(run.pk)) except Exception: - logger.exception("capture_run_observations: failed to enqueue extraction for activity=%s", activity.pk) + logger.exception("capture_run_observations: failed to enqueue extraction for run=%s", run.pk) diff --git a/daiv/memory/tasks.py b/daiv/memory/tasks.py index 882fa6ef4..1034818ef 100644 --- a/daiv/memory/tasks.py +++ b/daiv/memory/tasks.py @@ -166,18 +166,18 @@ def _persist() -> None: @task(dedup=True) -async def extract_observations_task(activity_id: str) -> None: +async def extract_observations_task(run_id: str) -> None: """Extract candidate memory observations from a finished run's transcript. Transcripts live in the Redis checkpointer behind a TTL, so this must run promptly after the run finishes; an expired checkpoint is a silent skip. - ``dedup=True`` is keyed on the unique ``activity_id``: a duplicate - ``activity_finished`` delivery for the same run is suppressed (no double + ``dedup=True`` is keyed on the unique ``run_id``: a duplicate + ``run_finished`` delivery for the same run is suppressed (no double observations), while a different run always re-runs. (Consolidation, keyed on the reusable ``repo_id``, must NOT dedup — see ``consolidate_memory_task``.) - Precondition failures (missing activity, disabled flag, expired checkpoint, + Precondition failures (missing run, disabled flag, expired checkpoint, unconfigured model) are log + return — never an error confused with a run failure. The LLM ``ainvoke`` itself is deliberately NOT guarded: a schema mismatch must surface loudly, and a transient failure marks this task FAILED @@ -185,48 +185,47 @@ async def extract_observations_task(activity_id: str) -> None: lost. Losing a single run's learnings is an accepted trade-off; agent runs are unaffected because this runs out-of-band. """ - from activity.models import Activity + from sessions.models import Run if not site_settings.memory_enabled: - logger.info("extract_observations_task: memory disabled site-wide, skipping activity %s", activity_id) + logger.info("extract_observations_task: memory disabled site-wide, skipping run %s", run_id) return - activity = await Activity.objects.filter(pk=activity_id).afirst() - if activity is None: - logger.warning("extract_observations_task: activity %s not found, skipping", activity_id) + run = await Run.objects.filter(pk=run_id).afirst() + if run is None: + logger.warning("extract_observations_task: run %s not found, skipping", run_id) return - if not activity.thread_id: + if not run.session_id: logger.warning( - "extract_observations_task: activity %s has no thread_id (violates thread_id contract), skipping", - activity_id, + "extract_observations_task: run %s has no session_id (violates thread_id contract), skipping", run_id ) return - config = await asyncio.to_thread(RepositoryConfig.get_config, activity.repo_id) + config = await asyncio.to_thread(RepositoryConfig.get_config, run.repo_id) if not config.memory.enabled: - logger.info("extract_observations_task: memory disabled for repo %s, skipping", activity.repo_id) + logger.info("extract_observations_task: memory disabled for repo %s, skipping", run.repo_id) return async with open_checkpointer() as checkpointer: - checkpoint_tuple = await checkpointer.aget_tuple({"configurable": {"thread_id": activity.thread_id}}) + checkpoint_tuple = await checkpointer.aget_tuple({"configurable": {"thread_id": str(run.session_id)}}) channel_values = (checkpoint_tuple.checkpoint or {}).get("channel_values", {}) if checkpoint_tuple else {} if not (messages := channel_values.get("messages", [])): if checkpoint_tuple is None: # Benign: the checkpoint expired from Redis before this task ran. logger.info( - "extract_observations_task: checkpoint missing/expired for thread %s (activity=%s), skipping", - activity.thread_id, - activity_id, + "extract_observations_task: checkpoint missing/expired for thread %s (run=%s), skipping", + run.session_id, + run_id, ) else: # A present checkpoint with no messages signals a real defect (serialization # or channel-name drift), not normal TTL expiry — surface it louder. logger.warning( - "extract_observations_task: checkpoint present but has no messages for thread %s (activity=%s); " + "extract_observations_task: checkpoint present but has no messages for thread %s (run=%s); " "available channels: %s — skipping (serialization or channel-name drift?)", - activity.thread_id, - activity_id, + run.session_id, + run_id, sorted(channel_values), ) return @@ -245,8 +244,8 @@ async def extract_observations_task(activity_id: str) -> None: # raise IndexError on model_names[0], which would crash the task with no breadcrumb. logger.error( "extract_observations_task: no extraction model configured " - "(check DAIV_MEMORY_EXTRACTION_MODEL_NAME / _FALLBACK_MODEL_NAME), skipping activity %s", - activity_id, + "(check DAIV_MEMORY_EXTRACTION_MODEL_NAME / _FALLBACK_MODEL_NAME), skipping run %s", + run_id, ) return try: @@ -262,15 +261,13 @@ async def extract_observations_task(activity_id: str) -> None: await structured_llm.with_config( run_name="MemoryExtraction", tags=["MemoryExtraction"], - metadata={"repo_id": activity.repo_id, "activity_id": str(activity.pk)}, + metadata={"repo_id": run.repo_id, "run_id": str(run.pk)}, ).ainvoke([ SystemMessage(content=cast("str", extraction_system.format().content)), HumanMessage( content=cast( "str", - extraction_human.format( - repo_id=activity.repo_id, status=activity.status, transcript=transcript - ).content, + extraction_human.format(repo_id=run.repo_id, status=run.status, transcript=transcript).content, ) ), ]), @@ -278,14 +275,14 @@ async def extract_observations_task(activity_id: str) -> None: if result and result.observations: await MemoryObservation.objects.abulk_create([ - MemoryObservation(repo_id=activity.repo_id, activity=activity, category=obs.category, content=obs.content) + MemoryObservation(repo_id=run.repo_id, run=run, category=obs.category, content=obs.content) for obs in result.observations ]) logger.info( - "extract_observations_task: stored %d observations for repo %s (activity=%s)", + "extract_observations_task: stored %d observations for repo %s (run=%s)", len(result.observations), - activity.repo_id, - activity_id, + run.repo_id, + run_id, ) diff --git a/daiv/memory/templates/memory/detail.html b/daiv/memory/templates/memory/detail.html index 759f03dff..a3e1a17e1 100644 --- a/daiv/memory/templates/memory/detail.html +++ b/daiv/memory/templates/memory/detail.html @@ -73,8 +73,8 @@

{% translate "Observations" %}

{{ obs.created_at|naturaltime }}

{{ obs.content }}

- {% if obs.activity %} - {% translate "View source run" %} {% endif %} diff --git a/daiv/memory/views.py b/daiv/memory/views.py index 1468509e8..9a6923348 100644 --- a/daiv/memory/views.py +++ b/daiv/memory/views.py @@ -65,7 +65,7 @@ def get_queryset(self): return ( MemoryObservation.objects .filter(repo_id=self.kwargs["repo_id"]) - .select_related("activity") + .select_related("run") .order_by("-created_at") ) diff --git a/daiv/notifications/signals.py b/daiv/notifications/signals.py index 5548227c5..ff6c13332 100644 --- a/daiv/notifications/signals.py +++ b/daiv/notifications/signals.py @@ -14,6 +14,7 @@ from activity.models import Activity, ActivityStatus, TriggerType from activity.signals import activity_finished +from sessions.signals import run_finished from notifications.channels.registry import enabled_channels from notifications.choices import ChannelType, EventType, NotifyOn @@ -23,6 +24,7 @@ logger = logging.getLogger("daiv.notifications") EXCLUDED_TRIGGERS = {TriggerType.ISSUE_WEBHOOK, TriggerType.MR_WEBHOOK} +EXCLUDED_RUN_TRIGGERS = {"issue_webhook", "mr_webhook"} def _is_schedule(activity: Activity) -> bool: @@ -327,6 +329,289 @@ def _batch_duration(rows: list[tuple]) -> float | None: return (latest - earliest).total_seconds() +def _is_schedule_run(run) -> bool: + """True when ``run`` belongs to a session with a still-loadable ScheduledJob.""" + session = run.session if run.session_id else None + return session is not None and session.scheduled_job_id is not None and session.scheduled_job is not None + + +def _status_matches_run(notify_on: NotifyOn, status: str) -> bool: + from sessions.models import RunStatus + + if notify_on == NotifyOn.NEVER: + return False + if notify_on == NotifyOn.ALWAYS: + return status in RunStatus.terminal() + if notify_on == NotifyOn.ON_SUCCESS: + return status == RunStatus.SUCCESSFUL + if notify_on == NotifyOn.ON_FAILURE: + return status == RunStatus.FAILED + logger.warning("Unknown notify_on value %r; treating as NEVER", notify_on) + return False + + +def _resolve_recipients_run(run) -> dict[int, object]: + if _is_schedule_run(run): + schedule = run.session.scheduled_job + recipients: dict[int, object] = {schedule.user_id: schedule.user} + for sub in schedule.subscribers.all(): + recipients.setdefault(sub.pk, sub) + return recipients + if run.user is not None: + return {run.user.pk: run.user} + return {} + + +def _render_payload_run(run) -> tuple[str, str, dict]: + from sessions.models import RunStatus + + is_schedule = _is_schedule_run(run) + ok = run.status == RunStatus.SUCCESSFUL + repo = run.repo_id + name = run.session.scheduled_job.name if is_schedule else "" + owner = str(run.session.scheduled_job.user) if is_schedule else "" + + if is_schedule: + params = {"name": name, "owner": owner, "repo": repo} + if ok: + subject = _("'%(name)s' succeeded on %(repo)s — %(owner)s") % params + body = _("Scheduled run '%(name)s' by %(owner)s finished on %(repo)s.") % params + else: + subject = _("'%(name)s' failed on %(repo)s — %(owner)s") % params + body = _("Scheduled run '%(name)s' by %(owner)s failed on %(repo)s.") % params + else: + if ok: + subject = _("Agent run on %(repo)s succeeded") % {"repo": repo} + body = _("Agent run on %(repo)s finished successfully.") % {"repo": repo} + else: + subject = _("Agent run on %(repo)s failed") % {"repo": repo} + body = _("Agent run on %(repo)s failed.") % {"repo": repo} + + context = { + "status": run.status, + "status_label": run.get_status_display(), + "is_successful": ok, + "trigger_label": run.get_trigger_type_display(), + "trigger_name": name, + "trigger_owner": owner, + "repo_id": repo, + "duration_seconds": run.duration, + "input_tokens": run.input_tokens, + "output_tokens": run.output_tokens, + "total_tokens": run.total_tokens, + "cost_usd": float(run.cost_usd) if run.cost_usd is not None else None, + } + return subject, body, context + + +def _rollup_exists_run(recipient, batch_id) -> bool: + from notifications.models import Notification + + return Notification.objects.filter( + recipient=recipient, + source_type="sessions.Batch", + source_id=str(batch_id), + event_type=EventType.JOB_BATCH_FINISHED, + ).exists() + + +def _handle_batch_completion_run(run, siblings, total: int) -> None: + """Emit a single rollup notification when every sibling in a Run batch is terminal.""" + from sessions.models import RunStatus + + agg = siblings.aggregate( + terminal=Count("id", filter=Q(status__in=RunStatus.terminal())), + successful=Count("id", filter=Q(status=RunStatus.SUCCESSFUL)), + total_input_tokens=Sum("input_tokens"), + total_output_tokens=Sum("output_tokens"), + total_total_tokens=Sum("total_tokens"), + total_cost_usd=Sum("cost_usd"), + ) + if agg["terminal"] < total: + return + + recipients = _resolve_recipients_run(run) + if not recipients: + logger.warning( + "Run batch %s completed with no resolvable recipients (run_pk=%s, total=%d)", run.batch_id, run.pk, total + ) + return + + successful = agg["successful"] + failed = total - successful + agg_status = RunStatus.SUCCESSFUL if failed == 0 else RunStatus.FAILED + + rows = list(siblings.values_list("repo_id", "started_at", "finished_at", "status")) + + effective = run.effective_notify_on + channels = [cls.channel_type for cls in enabled_channels()] if _status_matches_run(effective, agg_status) else [] + + usage = { + "input_tokens": agg["total_input_tokens"], + "output_tokens": agg["total_output_tokens"], + "total_tokens": agg["total_total_tokens"], + "cost_usd": float(agg["total_cost_usd"]) if agg["total_cost_usd"] is not None else None, + } + subject, body, context = _render_batch_payload_run(run, rows, total, successful, failed, agg_status, usage) + # Task 14 will add sessions list/detail URLs; fall back to activity_list for now. + link_url = f"{reverse('activity_list')}?batch={run.batch_id}" + + for recipient in recipients.values(): + try: + notify( + recipient=recipient, + event_type=EventType.JOB_BATCH_FINISHED, + source_type="sessions.Batch", + source_id=str(run.batch_id), + subject=subject, + body=body, + link_url=link_url, + channels=channels, + context=context, + ) + except IntegrityError: + if _rollup_exists_run(recipient, run.batch_id): + logger.debug( + "Run batch rollup already exists for batch_id=%s recipient_pk=%s", + run.batch_id, + getattr(recipient, "pk", None), + ) + else: + logger.exception( + "Unexpected IntegrityError creating run batch notification for batch_id=%s recipient pk=%s", + run.batch_id, + getattr(recipient, "pk", None), + ) + except Exception: + logger.exception( + "Failed to create run batch notification for batch_id=%s recipient pk=%s", + run.batch_id, + getattr(recipient, "pk", None), + ) + + +def _render_batch_payload_run( + run, rows: list[tuple], total: int, successful: int, failed: int, agg_status: str, usage: dict +) -> tuple[str, str, dict]: + from sessions.models import RunStatus + + is_schedule = _is_schedule_run(run) + ok = failed == 0 + repo_ids = sorted({repo for repo, _start, _end, _status in rows if repo}) + repo_results = [{"repo": repo, "ok": status == RunStatus.SUCCESSFUL} for repo, _start, _end, status in rows if repo] + name = run.session.scheduled_job.name if is_schedule else "" + owner = str(run.session.scheduled_job.user) if is_schedule else "" + + if is_schedule: + params = {"name": name, "owner": owner, "total": total, "ok": successful, "failed": failed} + if ok: + subject = _("'%(name)s' batch succeeded (%(total)d runs) — %(owner)s") % params + body = _("All %(total)d runs of '%(name)s' by %(owner)s finished successfully.") % params + elif successful == 0: + subject = _("'%(name)s' batch failed (%(total)d runs) — %(owner)s") % params + body = _("All %(total)d runs of '%(name)s' by %(owner)s failed.") % params + else: + subject = _("'%(name)s' batch: %(ok)d/%(total)d succeeded — %(owner)s") % params + body = _("%(ok)d of %(total)d runs of '%(name)s' by %(owner)s succeeded; %(failed)d failed.") % params + else: + repo_summary = _summarize_repos(repo_ids) + if ok: + subject = _("Agent run batch succeeded (%(total)d runs)") % {"total": total} + body = _("All %(total)d runs on %(repos)s finished successfully.") % {"total": total, "repos": repo_summary} + elif successful == 0: + subject = _("Agent run batch failed (%(total)d runs)") % {"total": total} + body = _("All %(total)d runs on %(repos)s failed.") % {"total": total, "repos": repo_summary} + else: + subject = _("Agent run batch finished: %(ok)d/%(total)d succeeded") % {"ok": successful, "total": total} + body = _("%(ok)d of %(total)d runs on %(repos)s succeeded; %(failed)d failed.") % { + "ok": successful, + "total": total, + "repos": repo_summary, + "failed": failed, + } + + context = { + "status": str(agg_status), + "status_label": str(agg_status), + "is_successful": ok, + "trigger_label": run.get_trigger_type_display(), + "trigger_name": name, + "trigger_owner": owner, + "repo_id": repo_ids[0] if len(repo_ids) == 1 else "", + "repo_ids": repo_ids, + "repo_results": repo_results, + "total": total, + "successful_count": successful, + "failed_count": failed, + "duration_seconds": _batch_duration(rows), + "batch_id": str(run.batch_id), + "input_tokens": usage["input_tokens"], + "output_tokens": usage["output_tokens"], + "total_tokens": usage["total_tokens"], + "cost_usd": usage["cost_usd"], + } + return subject, body, context + + +@receiver(run_finished, dispatch_uid="notifications.on_run_finished") +def on_run_finished(sender, run, **kwargs) -> None: + """Notify recipients when a Run transitions to a terminal status. + + Chat-triggered runs are excluded: those are interactive sessions and should + not generate bell/email notifications (preserves today's behaviour for chat). + Webhook-triggered runs are excluded to avoid noise on automated operations. + """ + from sessions.models import Run, SessionOrigin + + try: + if run.trigger_type == SessionOrigin.CHAT: + return + if run.trigger_type in EXCLUDED_RUN_TRIGGERS: + return + + if run.batch_id is not None: + siblings = Run.objects.by_batch(run.batch_id) + total = siblings.count() + if total > 1: + _handle_batch_completion_run(run, siblings, total) + return + + recipients = _resolve_recipients_run(run) + if not recipients: + return + + effective = run.effective_notify_on + channels = ( + [cls.channel_type for cls in enabled_channels()] if _status_matches_run(effective, run.status) else [] + ) + + subject, body, context = _render_payload_run(run) + # Task 14 will add the sessions:detail URL; for now fall back to activity_detail + # (Run.pk == Activity.pk so the link resolves to the same run row). + link_url = reverse("activity_detail", args=[run.pk]) + event_type = EventType.SCHEDULE_FINISHED if _is_schedule_run(run) else EventType.JOB_FINISHED + + for recipient in recipients.values(): + try: + notify( + recipient=recipient, + event_type=event_type, + source_type="sessions.Run", + source_id=str(run.pk), + subject=subject, + body=body, + link_url=link_url, + channels=channels, + context=context, + ) + except Exception: + logger.exception( + "Failed to create notification for run %s, recipient pk=%s", run.pk, getattr(recipient, "pk", None) + ) + except Exception: + logger.exception("on_run_finished: unexpected error for run=%s", getattr(run, "pk", run)) + + @receiver(post_save, sender=settings.AUTH_USER_MODEL, dispatch_uid="notifications.sync_email_binding") def sync_email_binding(sender, instance, created, **kwargs) -> None: """Ensure the user always has a verified email channel binding. diff --git a/tests/unit_tests/memory/test_extraction_task.py b/tests/unit_tests/memory/test_extraction_task.py index 4b486cc0b..1fad71fb2 100644 --- a/tests/unit_tests/memory/test_extraction_task.py +++ b/tests/unit_tests/memory/test_extraction_task.py @@ -2,11 +2,11 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from activity.models import Activity, ActivityStatus, TriggerType from langchain_core.messages import AIMessage, HumanMessage from memory.models import MemoryObservation, ObservationStatus from memory.schemas import ExtractedObservation, ExtractedObservations from memory.tasks import extract_observations_task +from sessions.models import Run, RunStatus, Session, SessionOrigin def _enabled_config(enabled=True): @@ -53,15 +53,15 @@ def _structured_llm_returning(observations=None, *, error=None): return llm -async def _create_activity(**kwargs): - defaults = { - "trigger_type": TriggerType.API_JOB, - "repo_id": "group/project", - "status": ActivityStatus.SUCCESSFUL, - "thread_id": "thread-1", - } +async def _create_run(**kwargs): + session = await Session.objects.acreate( + thread_id=kwargs.pop("thread_id", "thread-1"), + origin=SessionOrigin.API_JOB, + repo_id=kwargs.get("repo_id", "group/project"), + ) + defaults = {"trigger_type": SessionOrigin.API_JOB, "repo_id": "group/project", "status": RunStatus.SUCCESSFUL} defaults.update(kwargs) - return await Activity.objects.acreate(**defaults) + return await Run.objects.acreate(session=session, **defaults) TRANSCRIPT = [HumanMessage(content="fix the bug"), AIMessage(content="done, ran make test")] @@ -69,7 +69,7 @@ async def _create_activity(**kwargs): @pytest.mark.django_db(transaction=True) async def test_extraction_creates_observation_rows(): - activity = await _create_activity() + run = await _create_run() extracted = [ ExtractedObservation(category="build_test", content="`make test` needs LANGCHAIN_TRACING_V2=false set"), ExtractedObservation(category="pitfall", content="editing pyproject.toml directly breaks uv lock sync"), @@ -81,18 +81,18 @@ async def test_extraction_creates_observation_rows(): patch("memory.tasks._build_structured_llm", return_value=_structured_llm_returning(extracted)), ): cfg.get_config.return_value = _enabled_config() - await extract_observations_task.func(str(activity.pk)) + await extract_observations_task.func(str(run.pk)) rows = [obs async for obs in MemoryObservation.objects.filter(repo_id="group/project")] assert len(rows) == 2 assert all(row.status == ObservationStatus.PENDING for row in rows) - assert all(row.activity_id == activity.pk for row in rows) + assert all(row.run_id == run.pk for row in rows) assert {row.category for row in rows} == {"build_test", "pitfall"} @pytest.mark.django_db(transaction=True) async def test_extraction_skips_when_checkpoint_expired(): - activity = await _create_activity() + run = await _create_run() with ( patch("memory.tasks.RepositoryConfig") as cfg, @@ -100,7 +100,7 @@ async def test_extraction_skips_when_checkpoint_expired(): patch("memory.tasks._build_structured_llm") as build, ): cfg.get_config.return_value = _enabled_config() - await extract_observations_task.func(str(activity.pk)) # must not raise + await extract_observations_task.func(str(run.pk)) # must not raise build.assert_not_called() assert await MemoryObservation.objects.acount() == 0 @@ -110,7 +110,7 @@ async def test_extraction_skips_when_checkpoint_expired(): async def test_extraction_warns_when_checkpoint_has_no_messages(caplog): # A present checkpoint with an empty message list is a defect signature, distinct from # a missing/expired checkpoint: it skips like the expired case but logs at WARNING. - activity = await _create_activity() + run = await _create_run() with ( patch("memory.tasks.RepositoryConfig") as cfg, @@ -119,7 +119,7 @@ async def test_extraction_warns_when_checkpoint_has_no_messages(caplog): caplog.at_level("WARNING", logger="daiv.memory"), ): cfg.get_config.return_value = _enabled_config() - await extract_observations_task.func(str(activity.pk)) # must not raise + await extract_observations_task.func(str(run.pk)) # must not raise build.assert_not_called() assert await MemoryObservation.objects.acount() == 0 @@ -128,7 +128,7 @@ async def test_extraction_warns_when_checkpoint_has_no_messages(caplog): @pytest.mark.django_db(transaction=True) async def test_extraction_respects_daiv_yml_flag(): - activity = await _create_activity() + run = await _create_run() with ( patch("memory.tasks.RepositoryConfig") as cfg, @@ -136,7 +136,7 @@ async def test_extraction_respects_daiv_yml_flag(): patch("memory.tasks._build_structured_llm") as build, ): cfg.get_config.return_value = _enabled_config(enabled=False) - await extract_observations_task.func(str(activity.pk)) + await extract_observations_task.func(str(run.pk)) build.assert_not_called() assert await MemoryObservation.objects.acount() == 0 @@ -154,7 +154,7 @@ async def test_extraction_respects_daiv_yml_flag(): ids=["with_fallback", "drops_empty_fallback"], ) async def test_extraction_uses_configured_models(fallback_model, expected_models): - activity = await _create_activity() + run = await _create_run() extracted = [ExtractedObservation(category="build_test", content="`make test` needs the DB up first")] with ( @@ -169,7 +169,7 @@ async def test_extraction_uses_configured_models(fallback_model, expected_models ), ): cfg.get_config.return_value = _enabled_config() - await extract_observations_task.func(str(activity.pk)) + await extract_observations_task.func(str(run.pk)) _schema, models = build.call_args.args assert tuple(models) == expected_models @@ -179,7 +179,7 @@ async def test_extraction_uses_configured_models(fallback_model, expected_models async def test_extraction_noop_when_no_model_configured(): # Both model and fallback empty (only reachable via an empty-string env override) → clean skip, # not an IndexError crash in _build_structured_llm. - activity = await _create_activity() + run = await _create_run() with ( patch("memory.tasks.RepositoryConfig") as cfg, @@ -191,7 +191,7 @@ async def test_extraction_noop_when_no_model_configured(): ), ): cfg.get_config.return_value = _enabled_config() - await extract_observations_task.func(str(activity.pk)) # must not raise + await extract_observations_task.func(str(run.pk)) # must not raise build.assert_not_called() assert await MemoryObservation.objects.acount() == 0 @@ -200,7 +200,7 @@ async def test_extraction_noop_when_no_model_configured(): @pytest.mark.django_db(transaction=True) async def test_extraction_noop_when_site_disabled(): # Repo flag is on, but the instance-wide master switch is off → must not run. - activity = await _create_activity() + run = await _create_run() with ( patch("memory.tasks.RepositoryConfig") as cfg, @@ -209,29 +209,37 @@ async def test_extraction_noop_when_site_disabled(): patch("memory.tasks.site_settings", _site_settings(memory_enabled=False)), ): cfg.get_config.return_value = _enabled_config(enabled=True) - await extract_observations_task.func(str(activity.pk)) + await extract_observations_task.func(str(run.pk)) build.assert_not_called() assert await MemoryObservation.objects.acount() == 0 @pytest.mark.django_db(transaction=True) -async def test_extraction_handles_missing_activity(): +async def test_extraction_handles_missing_run(): with patch("memory.tasks.RepositoryConfig") as cfg: await extract_observations_task.func("00000000-0000-0000-0000-000000000000") # must not raise cfg.get_config.assert_not_called() @pytest.mark.django_db(transaction=True) -async def test_extraction_skips_activity_without_thread_id(): - activity = await _create_activity(thread_id=None) +async def test_extraction_skips_run_without_session_id(): + run = await _create_run() + # Patch the queryset to return a run with no session_id. + # Run is imported locally inside extract_observations_task, so patch via sessions.models. + run.session_id = None - with patch("memory.tasks.RepositoryConfig") as cfg, patch("memory.tasks._build_structured_llm") as build: - await extract_observations_task.func(str(activity.pk)) # must not raise + with patch("sessions.models.Run") as mock_run: + mock_qs = MagicMock() + mock_qs.afirst = AsyncMock(return_value=run) + mock_run.objects.filter.return_value = mock_qs - cfg.get_config.assert_not_called() # bails before loading config - build.assert_not_called() - assert await MemoryObservation.objects.acount() == 0 + with patch("memory.tasks.RepositoryConfig") as cfg, patch("memory.tasks._build_structured_llm") as build: + await extract_observations_task.func(str(run.pk)) # must not raise + + cfg.get_config.assert_not_called() # bails before loading config + build.assert_not_called() + assert await MemoryObservation.objects.acount() == 0 @pytest.mark.django_db(transaction=True) @@ -239,7 +247,7 @@ async def test_extraction_noop_when_model_spec_invalid(): # A bad/unparseable extraction model spec raises ValueError; it must be swallowed (clean skip), # not crash the task. The hardcoded extraction models raise this in a deployment without the # OpenAI/Anthropic provider rows configured (regression guard for C1). - activity = await _create_activity() + run = await _create_run() with ( patch("memory.tasks.RepositoryConfig") as cfg, @@ -247,7 +255,7 @@ async def test_extraction_noop_when_model_spec_invalid(): patch("memory.tasks._build_structured_llm", side_effect=ValueError("Unknown/Unsupported provider for model")), ): cfg.get_config.return_value = _enabled_config() - await extract_observations_task.func(str(activity.pk)) # must not raise + await extract_observations_task.func(str(run.pk)) # must not raise assert await MemoryObservation.objects.acount() == 0 @@ -257,7 +265,7 @@ async def test_extraction_propagates_llm_failure_without_partial_writes(): # The extraction ainvoke is deliberately unguarded: a transient/validation failure must propagate # (task FAILED, no retry — that run's signal is lost) and write nothing partial. Distinct from the # model-misconfig precondition, which IS skipped silently. - activity = await _create_activity() + run = await _create_run() failing_llm = _structured_llm_returning(error=RuntimeError("upstream 500")) with ( @@ -267,6 +275,6 @@ async def test_extraction_propagates_llm_failure_without_partial_writes(): pytest.raises(RuntimeError), ): cfg.get_config.return_value = _enabled_config() - await extract_observations_task.func(str(activity.pk)) + await extract_observations_task.func(str(run.pk)) assert await MemoryObservation.objects.acount() == 0 diff --git a/tests/unit_tests/memory/test_models.py b/tests/unit_tests/memory/test_models.py index 5d81b4e51..e030e6c80 100644 --- a/tests/unit_tests/memory/test_models.py +++ b/tests/unit_tests/memory/test_models.py @@ -1,24 +1,25 @@ import pytest -from activity.models import Activity, ActivityStatus, TriggerType from memory.models import MemoryObservation, ObservationCategory, ObservationStatus, RepositoryMemory +from sessions.models import Run, RunStatus, Session, SessionOrigin @pytest.mark.django_db -def test_observation_defaults_to_pending_and_survives_activity_deletion(): - activity = Activity.objects.create( - trigger_type=TriggerType.API_JOB, repo_id="group/project", status=ActivityStatus.SUCCESSFUL +def test_observation_defaults_to_pending_and_survives_run_deletion(): + session = Session.objects.create(thread_id="t1", origin=SessionOrigin.API_JOB, repo_id="group/project") + run = Run.objects.create( + session=session, trigger_type=SessionOrigin.API_JOB, repo_id="group/project", status=RunStatus.SUCCESSFUL ) obs = MemoryObservation.objects.create( repo_id="group/project", - activity=activity, + run=run, category=ObservationCategory.BUILD_TEST, content="`make test` requires LANGCHAIN_TRACING_V2=false", ) assert obs.status == ObservationStatus.PENDING - activity.delete() + run.delete() obs.refresh_from_db() - assert obs.activity is None, "FK must be SET_NULL so observations outlive activity retention" + assert obs.run is None, "FK must be SET_NULL so observations outlive run retention" @pytest.mark.django_db diff --git a/tests/unit_tests/memory/test_signals.py b/tests/unit_tests/memory/test_signals.py index 5774cc994..1fdadc07e 100644 --- a/tests/unit_tests/memory/test_signals.py +++ b/tests/unit_tests/memory/test_signals.py @@ -1,65 +1,84 @@ from unittest.mock import patch import pytest -from activity.models import Activity, ActivityStatus, TriggerType -from activity.signals import activity_finished from memory.signals import capture_run_observations +from sessions.models import Run, RunStatus, Session, SessionOrigin +from sessions.signals import run_finished -def _activity(**kwargs): - defaults = { - "trigger_type": TriggerType.API_JOB, - "repo_id": "group/project", - "status": ActivityStatus.SUCCESSFUL, - "thread_id": "thread-1", - } +def _session(**kwargs): + defaults = {"thread_id": "thread-1", "origin": SessionOrigin.API_JOB, "repo_id": "group/project"} defaults.update(kwargs) - return Activity.objects.create(**defaults) + return Session.objects.create(**defaults) + + +def _run(session, **kwargs): + defaults = {"trigger_type": SessionOrigin.API_JOB, "repo_id": "group/project", "status": RunStatus.SUCCESSFUL} + defaults.update(kwargs) + return Run.objects.create(session=session, **defaults) @pytest.mark.django_db class TestCaptureRunObservations: def test_enqueues_for_successful_with_thread_id(self): - activity = _activity() + session = _session() + run = _run(session) with patch("memory.signals.extract_observations_task") as task_mock: - capture_run_observations(sender=Activity, activity=activity) - task_mock.enqueue.assert_called_once_with(str(activity.pk)) + capture_run_observations(sender=Run, run=run) + task_mock.enqueue.assert_called_once_with(str(run.pk)) def test_enqueues_for_failed_runs_too(self): """Failures are valuable learning signal.""" - activity = _activity(status=ActivityStatus.FAILED) + session = _session() + run = _run(session, status=RunStatus.FAILED) with patch("memory.signals.extract_observations_task") as task_mock: - capture_run_observations(sender=Activity, activity=activity) - task_mock.enqueue.assert_called_once_with(str(activity.pk)) + capture_run_observations(sender=Run, run=run) + task_mock.enqueue.assert_called_once_with(str(run.pk)) def test_skips_non_terminal_status(self): - activity = _activity(status=ActivityStatus.RUNNING) + session = _session() + run = _run(session, status=RunStatus.RUNNING) with patch("memory.signals.extract_observations_task") as task_mock: - capture_run_observations(sender=Activity, activity=activity) + capture_run_observations(sender=Run, run=run) task_mock.enqueue.assert_not_called() - def test_skips_missing_thread_id(self): - activity = _activity(thread_id=None) + def test_skips_missing_session_id(self): + """A run with no session_id (guard against stale/bad data).""" + session = _session(thread_id="thread-no-session") + run = _run(session) + # Simulate missing session_id by detaching + run.session_id = None with patch("memory.signals.extract_observations_task") as task_mock: - capture_run_observations(sender=Activity, activity=activity) + capture_run_observations(sender=Run, run=run) task_mock.enqueue.assert_not_called() def test_skips_dispatch_failure_reemits(self): - """skip_dispatch=True marks re-emits for activities that never actually ran.""" - activity = _activity(status=ActivityStatus.FAILED) + """skip_dispatch=True marks re-emits for runs that never actually executed.""" + session = _session() + run = _run(session, status=RunStatus.FAILED) + with patch("memory.signals.extract_observations_task") as task_mock: + capture_run_observations(sender=Run, run=run, skip_dispatch=True) + task_mock.enqueue.assert_not_called() + + def test_skips_chat_trigger_runs(self): + """Chat-triggered runs produce no memory observations.""" + session = _session(origin=SessionOrigin.CHAT, thread_id="thread-chat") + run = _run(session, trigger_type=SessionOrigin.CHAT) with patch("memory.signals.extract_observations_task") as task_mock: - capture_run_observations(sender=Activity, activity=activity, skip_dispatch=True) + capture_run_observations(sender=Run, run=run) task_mock.enqueue.assert_not_called() def test_never_raises_on_enqueue_failure(self): - activity = _activity() + session = _session() + run = _run(session) with patch("memory.signals.extract_observations_task") as task_mock: task_mock.enqueue.side_effect = RuntimeError("broker down") - capture_run_observations(sender=Activity, activity=activity) # must not raise + capture_run_observations(sender=Run, run=run) # must not raise - def test_wired_to_activity_finished_signal(self): - """apps.ready() must register the receiver on the real signal.""" - activity = _activity() + def test_wired_to_run_finished_signal(self): + """apps.ready() must register the receiver on the run_finished signal.""" + session = _session() + run = _run(session) with patch("memory.signals.extract_observations_task") as task_mock: - activity_finished.send(sender=Activity, activity=activity) - task_mock.enqueue.assert_called_once_with(str(activity.pk)) + run_finished.send(sender=Run, run=run) + task_mock.enqueue.assert_called_once_with(str(run.pk)) diff --git a/tests/unit_tests/notifications/test_signals.py b/tests/unit_tests/notifications/test_signals.py index 28e23086c..c5e96e454 100644 --- a/tests/unit_tests/notifications/test_signals.py +++ b/tests/unit_tests/notifications/test_signals.py @@ -884,3 +884,130 @@ def test_empty_recipients_on_multi_job_batch_logs_warning(self, caplog): assert Notification.objects.count() == 0 assert any("completed with no resolvable recipients" in rec.message for rec in caplog.records) + + +# --------------------------------------------------------------------------- +# Run-based notification tests (run_finished signal → on_run_finished receiver) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def run_schedule(member_user, email_binding): + return ScheduledJob.objects.create( + user=member_user, + name="run-schedule", + prompt="p", + repos=[{"repo_id": "x/y", "ref": ""}], + frequency=Frequency.DAILY, + time="12:00", + notify_on=NotifyOn.ALWAYS, + ) + + +def _make_run_with_session( + user=None, + trigger_type="api_job", + status="SUCCESSFUL", + repo_id="x/y", + scheduled_job=None, + notify_on=None, + batch_id=None, + thread_id=None, + input_tokens=None, + output_tokens=None, + total_tokens=None, + cost_usd=None, +): + from sessions.models import Run, Session, SessionOrigin + + origin = trigger_type if trigger_type != SessionOrigin.CHAT else SessionOrigin.CHAT + session = Session.objects.create( + thread_id=thread_id or str(uuid.uuid4()), origin=origin, repo_id=repo_id, user=user, scheduled_job=scheduled_job + ) + run = Run.objects.create( + session=session, + trigger_type=trigger_type, + repo_id=repo_id, + status=status, + user=user, + notify_on=notify_on, + batch_id=batch_id, + input_tokens=input_tokens, + output_tokens=output_tokens, + total_tokens=total_tokens, + cost_usd=cost_usd, + ) + return run + + +@pytest.mark.django_db +class TestOnRunFinished: + def test_notifications_skip_chat_runs(self, member_user): + """Chat-triggered runs must never produce notifications.""" + from sessions.models import RunStatus, SessionOrigin + from sessions.signals import emit_run_finished_if_terminal + + run = _make_run_with_session(user=member_user, trigger_type=SessionOrigin.CHAT, status=RunStatus.SUCCESSFUL) + with patch("notifications.signals.notify") as mock_notify: + emit_run_finished_if_terminal(run, previous_status=RunStatus.RUNNING) + + mock_notify.assert_not_called() + assert Notification.objects.filter(recipient=member_user).count() == 0 + + def test_notifications_skip_webhook_runs(self, member_user): + from sessions.models import RunStatus, SessionOrigin + from sessions.signals import emit_run_finished_if_terminal + + for trigger in (SessionOrigin.ISSUE_WEBHOOK, SessionOrigin.MR_WEBHOOK): + run = _make_run_with_session(user=member_user, trigger_type=trigger, status=RunStatus.SUCCESSFUL) + emit_run_finished_if_terminal(run, previous_status=RunStatus.RUNNING) + + assert Notification.objects.count() == 0 + + def test_api_job_run_successful_creates_bell(self, member_user): + from sessions.models import RunStatus, SessionOrigin + from sessions.signals import emit_run_finished_if_terminal + + run = _make_run_with_session(user=member_user, trigger_type=SessionOrigin.API_JOB, status=RunStatus.SUCCESSFUL) + emit_run_finished_if_terminal(run, previous_status=RunStatus.RUNNING) + + assert Notification.objects.filter(recipient=member_user, event_type="job.finished").count() == 1 + + def test_schedule_run_notifies_owner(self, member_user, run_schedule): + from sessions.models import RunStatus, SessionOrigin + from sessions.signals import emit_run_finished_if_terminal + + run = _make_run_with_session( + user=member_user, + trigger_type=SessionOrigin.SCHEDULE, + status=RunStatus.SUCCESSFUL, + scheduled_job=run_schedule, + ) + emit_run_finished_if_terminal(run, previous_status=RunStatus.RUNNING) + + assert Notification.objects.filter(recipient=member_user, event_type="schedule.finished").count() == 1 + + def test_run_context_carries_metadata(self, member_user, run_schedule): + from decimal import Decimal + + from sessions.models import RunStatus, SessionOrigin + from sessions.signals import emit_run_finished_if_terminal + + run = _make_run_with_session( + user=member_user, + trigger_type=SessionOrigin.SCHEDULE, + status=RunStatus.SUCCESSFUL, + scheduled_job=run_schedule, + repo_id="acme/app", + input_tokens=100, + output_tokens=200, + total_tokens=300, + cost_usd=Decimal("0.05"), + ) + emit_run_finished_if_terminal(run, previous_status=RunStatus.RUNNING) + + n = Notification.objects.get(recipient=member_user, event_type="schedule.finished") + assert n.context["repo_id"] == "acme/app" + assert n.context["status"] == RunStatus.SUCCESSFUL + assert n.context["input_tokens"] == 100 + assert n.context["cost_usd"] == pytest.approx(0.05) From a6a4365a9bcea33b2b75b704d45438fe0e4ff559 Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 7 Jul 2026 23:02:44 +0100 Subject: [PATCH 14/55] feat(sessions): unified sessions list with origin/status filtering --- daiv/daiv/urls.py | 1 + daiv/sessions/filters.py | 28 +++ .../templates/sessions/_origin_badge.html | 16 ++ .../templates/sessions/_status_pill.html | 10 + .../templates/sessions/session_list.html | 166 +++++++++++++ daiv/sessions/templatetags/__init__.py | 0 daiv/sessions/templatetags/session_tags.py | 67 ++++++ daiv/sessions/urls.py | 5 + daiv/sessions/views.py | 79 +++++++ tests/unit_tests/sessions/test_filters.py | 220 ++++++++++++++++++ tests/unit_tests/sessions/test_views_list.py | 177 ++++++++++++++ 11 files changed, 769 insertions(+) create mode 100644 daiv/sessions/filters.py create mode 100644 daiv/sessions/templates/sessions/_origin_badge.html create mode 100644 daiv/sessions/templates/sessions/_status_pill.html create mode 100644 daiv/sessions/templates/sessions/session_list.html create mode 100644 daiv/sessions/templatetags/__init__.py create mode 100644 daiv/sessions/templatetags/session_tags.py create mode 100644 daiv/sessions/urls.py create mode 100644 daiv/sessions/views.py create mode 100644 tests/unit_tests/sessions/test_filters.py create mode 100644 tests/unit_tests/sessions/test_views_list.py diff --git a/daiv/daiv/urls.py b/daiv/daiv/urls.py index 4c6298caa..cdee82264 100644 --- a/daiv/daiv/urls.py +++ b/daiv/daiv/urls.py @@ -28,6 +28,7 @@ def location(self, item): path("dashboard/", include("accounts.urls.dashboard")), path("dashboard/configuration/", include("core.urls.configuration")), path("dashboard/activity/", include("activity.urls")), + path("dashboard/sessions/", include("sessions.urls")), path("dashboard/chat/", include("chat.urls")), path("dashboard/runs/", include("activity.urls_runs", namespace="runs")), path("dashboard/notifications/", include("notifications.urls")), diff --git a/daiv/sessions/filters.py b/daiv/sessions/filters.py new file mode 100644 index 000000000..fb0aee036 --- /dev/null +++ b/daiv/sessions/filters.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import django_filters + +from sessions.models import RunStatus, Session, SessionOrigin + + +class SessionFilter(django_filters.FilterSet): + # Param names match the old activity deep links (?trigger=, ?status=, ...). + status = django_filters.ChoiceFilter(choices=RunStatus.choices, method="filter_status") + trigger = django_filters.ChoiceFilter(field_name="origin", choices=SessionOrigin.choices) + repo = django_filters.CharFilter(field_name="repo_id") + schedule = django_filters.NumberFilter(field_name="scheduled_job_id") + batch = django_filters.UUIDFilter(method="filter_batch") + date_from = django_filters.DateFilter(field_name="created_at", lookup_expr="date__gte") + date_to = django_filters.DateFilter(field_name="created_at", lookup_expr="date__lte") + + class Meta: + model = Session + # All filters are declared above; disable auto-generation from model fields. + fields: list[str] = [] + + def filter_status(self, queryset, name, value): + # Requires the with_latest_status() annotation on the base queryset. + return queryset.filter(latest_run_status=value) + + def filter_batch(self, queryset, name, value): + return queryset.filter(runs__batch_id=value).distinct() diff --git a/daiv/sessions/templates/sessions/_origin_badge.html b/daiv/sessions/templates/sessions/_origin_badge.html new file mode 100644 index 000000000..efce58a2a --- /dev/null +++ b/daiv/sessions/templates/sessions/_origin_badge.html @@ -0,0 +1,16 @@ +{% load icon_tags %} +{% if origin == "chat" %} + + {% icon "chat-bubble" "h-3 w-3" %}Chat + +{% elif origin == "api_job" or origin == "mcp_job" %} +{{ origin_display }} +{% elif origin == "schedule" %} +Schedule +{% elif origin == "issue_webhook" %} +Issue +{% elif origin == "mr_webhook" %} +MR/PR +{% elif origin == "ui_job" %} +UI Run +{% endif %} diff --git a/daiv/sessions/templates/sessions/_status_pill.html b/daiv/sessions/templates/sessions/_status_pill.html new file mode 100644 index 000000000..b88c9f21a --- /dev/null +++ b/daiv/sessions/templates/sessions/_status_pill.html @@ -0,0 +1,10 @@ +{% comment %} +Status pill for a session run. Required: variant, label. +Pass pk+status (list rows) to wire up in-place Alpine updates. Otherwise static. +{% endcomment %} + + + {{ label }} + diff --git a/daiv/sessions/templates/sessions/session_list.html b/daiv/sessions/templates/sessions/session_list.html new file mode 100644 index 000000000..d599b15a6 --- /dev/null +++ b/daiv/sessions/templates/sessions/session_list.html @@ -0,0 +1,166 @@ +{% extends "base_app.html" %} +{% load dashboard_tags humanize i18n icon_tags l10n session_tags static %} + +{% block title %}Agent Sessions — DAIV{% endblock %} + +{% block container_width %}max-w-6xl{% endblock %} + +{% block alpine_plugins %} + + +{% endblock alpine_plugins %} + +{% block app_content %} +
+
+
+

Agent Sessions

+

+ {% if schedule_name %}{{ schedule_name }} · {% endif %}All agent sessions across jobs, schedules, webhooks, and chat. +

+
+
+ + +
+ +
+
+ + All + + {% for value, label in statuses %} + + {{ label }} + + {% endfor %} +
+ + +
+ + All types + + {% for value, label in origins %} + + {{ label }} + + {% endfor %} +
+
+ + +
+ +
+ {% include "codebase/_repo_combobox.html" %} +
+ +
+ {% if current_status %}{% endif %} + {% if current_trigger %}{% endif %} + {% if current_repo %}{% endif %} + {% if current_schedule %}{% endif %} + {% if current_batch %}{% endif %} + + to + +
+ + {% if current_batch %} + + {% blocktranslate with id=current_batch_short %}Batch {{ id }}{% endblocktranslate %} + × + + {% endif %} + + {% if has_active_filters %} + + Clear filters + + {% endif %} +
+
+ + +
+ {% if sessions %} +
+ {% for session in sessions %} +
+
+
+ {% if session.latest_run_status %} + {% include "sessions/_status_pill.html" with variant=session.latest_run_status|status_variant label=session.latest_run_status pk=session.pk status=session.latest_run_status %} + {% endif %} +

+ {% if session.title %} + {{ session.title }} + {% else %} + {% translate "generating title…" %} + {% endif %} +

+ {% include "sessions/_origin_badge.html" with origin=session.origin origin_display=session.get_origin_display %} +
+
+ {% if session.user %} + {% include "accounts/_avatar.html" with user=session.user label="Owner" %} + · + {% elif session.external_username %} + {{ session.external_username }} + · + {% endif %} + {{ session.repo_id }} + · + {{ session.last_active_at|naturaltime }} +
+
+ {% icon "chevron-right" "ml-4 h-3.5 w-3.5 shrink-0 text-gray-700 transition-colors group-hover:text-gray-400" %} +
+ {% endfor %} +
+ {% include "accounts/_pagination.html" %} + {% else %} +
+

+ {% if has_active_filters %} + No sessions match your filters. + {% else %} + No agent sessions recorded yet. + {% endif %} +

+
+ {% endif %} +
+
+{% endblock app_content %} diff --git a/daiv/sessions/templatetags/__init__.py b/daiv/sessions/templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/daiv/sessions/templatetags/session_tags.py b/daiv/sessions/templatetags/session_tags.py new file mode 100644 index 000000000..c254fb616 --- /dev/null +++ b/daiv/sessions/templatetags/session_tags.py @@ -0,0 +1,67 @@ +from decimal import Decimal + +from django import template + +register = template.Library() + +_CENT = Decimal("0.01") +_TITLE_MAX_LEN = 100 + + +@register.simple_tag +def session_title(session) -> str: + """Derive a human-meaningful title for a Session.""" + if stored := (session.title or "").strip(): + return stored + + # Fall back to the thread_id prefix so there is always something to show. + return session.thread_id[:8] + + +@register.filter +def duration(value): + """Format a duration in seconds as a compact human-readable string.""" + if value is None: + return "" + total_seconds = int(value) + if total_seconds < 0: + return "" + if total_seconds < 60: + return f"{total_seconds}s" + minutes, seconds = divmod(total_seconds, 60) + if minutes < 60: + return f"{minutes}m {seconds}s" + hours, minutes = divmod(minutes, 60) + return f"{hours}h {minutes}m" + + +@register.filter +def format_cost(value): + """Format a Decimal cost as a compact USD string.""" + if value is None: + return "" + d = value if isinstance(value, Decimal) else Decimal(str(value)) + if d < _CENT: + return f"${d:.4f}" + return f"${d:.2f}" + + +@register.filter +def format_tokens(value): + """Format token count with compact suffixes (1.2k, 45.3k, 1.2M).""" + if value is None: + return "" + if value >= 1_000_000: + return f"{value / 1_000_000:.1f}M" + if value >= 1_000: + return f"{value / 1_000:.1f}k" + return str(value) + + +_STATUS_VARIANTS = {"SUCCESSFUL": "success", "FAILED": "failed", "RUNNING": "running", "QUEUED": "queued"} + + +@register.filter +def status_variant(status) -> str: + """Map RunStatus to the CSS/Alpine variant suffix used by status-badge / status-dot.""" + return _STATUS_VARIANTS.get(status, "pending") diff --git a/daiv/sessions/urls.py b/daiv/sessions/urls.py new file mode 100644 index 000000000..c748d087c --- /dev/null +++ b/daiv/sessions/urls.py @@ -0,0 +1,5 @@ +from django.urls import path + +from sessions.views import SessionListView + +urlpatterns = [path("", SessionListView.as_view(), name="session_list")] diff --git a/daiv/sessions/views.py b/daiv/sessions/views.py new file mode 100644 index 000000000..4019aba91 --- /dev/null +++ b/daiv/sessions/views.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.contrib.auth.mixins import LoginRequiredMixin + +from django_filters.views import FilterView + +from schedules.models import ScheduledJob +from sessions.filters import SessionFilter +from sessions.models import Run, RunStatus, Session, SessionOrigin + +if TYPE_CHECKING: + from django.db.models import QuerySet + + +class SessionListView(LoginRequiredMixin, FilterView): + model = Session + filterset_class = SessionFilter + template_name = "sessions/session_list.html" + context_object_name = "sessions" + paginate_by = 25 + # Preserve UX: an invalid URL param (e.g. ?status=bogus) should + # silently drop that filter, not blank the whole list. + strict = False + + def get_queryset(self) -> QuerySet[Session]: + from django.db import models as db_models + + from sessions.models import Run + + user = self.request.user + # Apply owner scoping first (returns a plain QuerySet), then annotate. + base_qs = Session.objects.by_owner(user) + latest = Run.objects.filter(session=db_models.OuterRef("pk")).order_by("-created_at", "-id") + return ( + base_qs + .annotate(latest_run_status=db_models.Subquery(latest.values("status")[:1])) + .select_related("user", "scheduled_job") + .prefetch_related("runs") + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + form = context["filter"].form + cleaned = form.cleaned_data if form.is_valid() else {} + context["current_status"] = cleaned.get("status") or "" + context["current_trigger"] = cleaned.get("trigger") or "" + context["current_repo"] = cleaned.get("repo") or "" + context["current_schedule"] = cleaned.get("schedule") or "" + context["current_batch"] = cleaned.get("batch") or "" + context["current_batch_short"] = str(context["current_batch"])[:8] if context["current_batch"] else "" + # Date fields are read raw: cleaned_data yields `date` objects, but the + # HTML `` needs the original ISO string to round-trip. + context["current_from"] = self.request.GET.get("date_from", "") + context["current_to"] = self.request.GET.get("date_to", "") + context["has_active_filters"] = any([ + context["current_status"], + context["current_trigger"], + context["current_repo"], + context["current_schedule"], + context["current_batch"], + context["current_from"], + context["current_to"], + ]) + context["origins"] = SessionOrigin.choices + context["statuses"] = RunStatus.choices + + # Resolve schedule name for display. + if schedule_id := context["current_schedule"]: + schedule = ScheduledJob.objects.filter(pk=schedule_id).values_list("name", flat=True).first() + context["schedule_name"] = schedule or "" + + # In-flight RUN ids across the page's sessions, for the SSE status stream (Task 13). + page_ids = [s.pk for s in context["sessions"]] + in_flight = Run.objects.filter(session_id__in=page_ids).exclude(status__in=RunStatus.terminal()) + context["in_flight_ids"] = ",".join(str(rid) for rid in in_flight.values_list("id", flat=True)) + + return context diff --git a/tests/unit_tests/sessions/test_filters.py b/tests/unit_tests/sessions/test_filters.py new file mode 100644 index 000000000..56a6921c9 --- /dev/null +++ b/tests/unit_tests/sessions/test_filters.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +import uuid +from datetime import UTC, datetime, time + +import pytest +from sessions.filters import SessionFilter +from sessions.models import Run, RunStatus, Session, SessionOrigin + +from accounts.models import User +from schedules.models import Frequency, ScheduledJob + + +@pytest.fixture +def user(db): + return User.objects.create_user( + username="alice", + email="alice@test.com", + password="testpass123", # noqa: S106 + ) + + +def _create_session(**kwargs) -> Session: + defaults = { + "thread_id": str(uuid.uuid4()), + "origin": SessionOrigin.SCHEDULE, + "repo_id": "group/project", + "ref": "main", + } + defaults.update(kwargs) + return Session.objects.create(**defaults) + + +def _create_run(session: Session, **kwargs) -> Run: + defaults = { + "session": session, + "trigger_type": SessionOrigin.SCHEDULE, + "repo_id": session.repo_id, + "status": RunStatus.SUCCESSFUL, + } + defaults.update(kwargs) + return Run.objects.create(**defaults) + + +def _qs(): + """Base queryset with the annotation required by filter_status.""" + return Session.objects.with_latest_status() + + +@pytest.mark.django_db +class TestSessionFilter: + def test_no_params_returns_all(self, user): + a = _create_session() + b = _create_session() + qs = SessionFilter({}, queryset=_qs()).qs + pks = list(qs.values_list("pk", flat=True)) + assert a.pk in pks + assert b.pk in pks + + def test_trigger_filter(self, user): + sched = _create_session(origin=SessionOrigin.SCHEDULE) + webhook = _create_session(origin=SessionOrigin.ISSUE_WEBHOOK) + qs = SessionFilter({"trigger": SessionOrigin.SCHEDULE}, queryset=_qs()).qs + pks = list(qs.values_list("pk", flat=True)) + assert sched.pk in pks + assert webhook.pk not in pks + + def test_repo_filter(self, user): + a = _create_session(repo_id="group/project") + b = _create_session(repo_id="group/other") + qs = SessionFilter({"repo": "group/project"}, queryset=_qs()).qs + pks = list(qs.values_list("pk", flat=True)) + assert a.pk in pks + assert b.pk not in pks + + def test_schedule_filter_invalid_is_ignored(self, user): + a = _create_session() + f = SessionFilter({"schedule": "not-a-number"}, queryset=_qs()) + assert not f.form.is_valid() + assert a.pk in list(f.qs.values_list("pk", flat=True)) + + def test_schedule_filter_matches_fk(self, user): + job = ScheduledJob.objects.create( + user=user, + name="nightly", + prompt="x", + repos=[{"repo_id": "group/project", "ref": ""}], + frequency=Frequency.DAILY, + time=time(3, 0), + ) + match = _create_session(scheduled_job=job) + other = _create_session() + qs = SessionFilter({"schedule": str(job.pk)}, queryset=_qs()).qs + pks = list(qs.values_list("pk", flat=True)) + assert match.pk in pks + assert other.pk not in pks + + def test_date_from_filter(self, user): + old = _create_session() + Session.objects.filter(pk=old.pk).update(created_at=datetime(2020, 1, 1, tzinfo=UTC)) + recent = _create_session() + Session.objects.filter(pk=recent.pk).update(created_at=datetime(2026, 1, 1, tzinfo=UTC)) + qs = SessionFilter({"date_from": "2025-06-01"}, queryset=_qs()).qs + pks = list(qs.values_list("pk", flat=True)) + assert recent.pk in pks + assert old.pk not in pks + + def test_date_to_filter(self, user): + old = _create_session() + Session.objects.filter(pk=old.pk).update(created_at=datetime(2020, 1, 1, tzinfo=UTC)) + recent = _create_session() + Session.objects.filter(pk=recent.pk).update(created_at=datetime(2026, 1, 1, tzinfo=UTC)) + qs = SessionFilter({"date_to": "2025-06-01"}, queryset=_qs()).qs + pks = list(qs.values_list("pk", flat=True)) + assert old.pk in pks + assert recent.pk not in pks + + def test_date_range_combined(self, user): + before = _create_session() + Session.objects.filter(pk=before.pk).update(created_at=datetime(2020, 1, 1, tzinfo=UTC)) + inside = _create_session() + Session.objects.filter(pk=inside.pk).update(created_at=datetime(2025, 6, 15, tzinfo=UTC)) + after = _create_session() + Session.objects.filter(pk=after.pk).update(created_at=datetime(2026, 1, 1, tzinfo=UTC)) + qs = SessionFilter({"date_from": "2025-01-01", "date_to": "2025-12-31"}, queryset=_qs()).qs + pks = list(qs.values_list("pk", flat=True)) + assert inside.pk in pks + assert before.pk not in pks + assert after.pk not in pks + + def test_invalid_date_is_ignored(self, user): + a = _create_session() + f = SessionFilter({"date_from": "not-a-date"}, queryset=_qs()) + assert not f.form.is_valid() + assert a.pk in list(f.qs.values_list("pk", flat=True)) + + def test_combined_filters(self, user): + match = _create_session(origin=SessionOrigin.SCHEDULE, repo_id="group/project") + _create_run(match, status=RunStatus.SUCCESSFUL) + wrong_origin = _create_session(origin=SessionOrigin.ISSUE_WEBHOOK, repo_id="group/project") + _create_run(wrong_origin, status=RunStatus.SUCCESSFUL) + wrong_repo = _create_session(origin=SessionOrigin.SCHEDULE, repo_id="group/other") + _create_run(wrong_repo, status=RunStatus.SUCCESSFUL) + + qs = SessionFilter({"trigger": SessionOrigin.SCHEDULE, "repo": "group/project"}, queryset=_qs()).qs + pks = list(qs.values_list("pk", flat=True)) + assert match.pk in pks + assert wrong_origin.pk not in pks + assert wrong_repo.pk not in pks + + # --- Brief-specified new tests --- + + def test_status_filters_on_latest_run(self, user): + """?status=RUNNING matches a session whose LATEST run is RUNNING, and does not + match a session whose latest run is SUCCESSFUL even if an older one was RUNNING.""" + # Session A: older RUNNING, newer SUCCESSFUL → should NOT match ?status=RUNNING + session_a = _create_session() + _create_run(session_a, status=RunStatus.RUNNING) + _create_run(session_a, status=RunStatus.SUCCESSFUL) + + # Session B: only run is RUNNING → SHOULD match + session_b = _create_session() + _create_run(session_b, status=RunStatus.RUNNING) + + qs = SessionFilter({"status": RunStatus.RUNNING}, queryset=_qs()).qs + pks = list(qs.values_list("pk", flat=True)) + assert session_b.pk in pks + assert session_a.pk not in pks + + def test_trigger_filters_on_origin(self, user): + """?trigger=issue_webhook returns webhook-origin sessions; ?trigger=chat returns chat sessions.""" + webhook_session = _create_session(origin=SessionOrigin.ISSUE_WEBHOOK) + chat_session = _create_session(origin=SessionOrigin.CHAT) + other_session = _create_session(origin=SessionOrigin.SCHEDULE) + + webhook_qs = SessionFilter({"trigger": SessionOrigin.ISSUE_WEBHOOK}, queryset=_qs()).qs + webhook_pks = list(webhook_qs.values_list("pk", flat=True)) + assert webhook_session.pk in webhook_pks + assert chat_session.pk not in webhook_pks + assert other_session.pk not in webhook_pks + + chat_qs = SessionFilter({"trigger": SessionOrigin.CHAT}, queryset=_qs()).qs + chat_pks = list(chat_qs.values_list("pk", flat=True)) + assert chat_session.pk in chat_pks + assert webhook_session.pk not in chat_pks + + def test_batch_filters_via_runs(self, user): + """?batch= returns sessions containing a run with that batch_id.""" + batch_id = uuid.uuid4() + + session_with_batch = _create_session() + _create_run(session_with_batch, batch_id=batch_id) + + session_other_batch = _create_session() + _create_run(session_other_batch, batch_id=uuid.uuid4()) + + session_no_batch = _create_session() + + qs = SessionFilter({"batch": str(batch_id)}, queryset=_qs()).qs + pks = list(qs.values_list("pk", flat=True)) + assert session_with_batch.pk in pks + assert session_other_batch.pk not in pks + assert session_no_batch.pk not in pks + + def test_invalid_status_drops_filter(self, user): + """?status=bogus returns the unfiltered list (strict=False semantics).""" + a = _create_session() + b = _create_session() + f = SessionFilter({"status": "bogus"}, queryset=_qs()) + assert not f.form.is_valid() + # Invalid choice is dropped → no filter applied; all sessions returned. + pks = list(f.qs.values_list("pk", flat=True)) + assert a.pk in pks + assert b.pk in pks + + def test_batch_filter_invalid_uuid_is_ignored(self, user): + a = _create_session() + f = SessionFilter({"batch": "not-a-uuid"}, queryset=_qs()) + assert not f.form.is_valid() + assert a.pk in list(f.qs.values_list("pk", flat=True)) diff --git a/tests/unit_tests/sessions/test_views_list.py b/tests/unit_tests/sessions/test_views_list.py new file mode 100644 index 000000000..ad16d01f8 --- /dev/null +++ b/tests/unit_tests/sessions/test_views_list.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import uuid + +from django.test import Client +from django.urls import reverse + +import pytest +from sessions.models import Run, RunStatus, Session, SessionOrigin + +from accounts.models import User + + +@pytest.fixture +def user(db): + return User.objects.create_user( + username="alice", + email="alice@test.com", + password="testpass123", # noqa: S106 + ) + + +@pytest.fixture +def logged_in_client(user): + client = Client() + client.force_login(user) + return client + + +def _create_session(**kwargs) -> Session: + defaults = { + "thread_id": str(uuid.uuid4()), + "origin": SessionOrigin.SCHEDULE, + "repo_id": "group/project", + "ref": "main", + } + defaults.update(kwargs) + return Session.objects.create(**defaults) + + +def _create_run(session: Session, **kwargs) -> Run: + defaults = { + "session": session, + "trigger_type": SessionOrigin.SCHEDULE, + "repo_id": session.repo_id, + "status": RunStatus.SUCCESSFUL, + } + defaults.update(kwargs) + return Run.objects.create(**defaults) + + +@pytest.mark.django_db +class TestSessionListView: + def test_unauthenticated_redirects_to_login(self): + response = Client().get(reverse("session_list")) + assert response.status_code == 302 + assert "/accounts/login/" in response.url + + def test_authenticated_user_can_access(self, logged_in_client, user): + response = logged_in_client.get(reverse("session_list")) + assert response.status_code == 200 + + def test_owner_scoping_excludes_other_users_sessions(self, logged_in_client, user): + """Owner scoping is applied before filters — another user's sessions are invisible.""" + mine = _create_session(user=user, repo_id="mine/repo") + other_user = User.objects.create_user( + username="bob", + email="bob@test.com", + password="testpass123", # noqa: S106 + ) + theirs = _create_session(user=other_user, repo_id="mine/repo") + + response = logged_in_client.get(reverse("session_list"), {"repo": "mine/repo"}) + + assert response.status_code == 200 + sessions = list(response.context["sessions"]) + session_pks = [s.pk for s in sessions] + assert mine.pk in session_pks + assert theirs.pk not in session_pks + + def test_filter_by_status_on_latest_run(self, logged_in_client, user): + """?status=SUCCESSFUL only returns sessions whose latest run is SUCCESSFUL.""" + success_session = _create_session(user=user) + _create_run(success_session, status=RunStatus.SUCCESSFUL) + + failed_session = _create_session(user=user) + _create_run(failed_session, status=RunStatus.FAILED) + + response = logged_in_client.get(reverse("session_list"), {"status": RunStatus.SUCCESSFUL}) + + assert response.status_code == 200 + sessions = list(response.context["sessions"]) + session_pks = [s.pk for s in sessions] + assert success_session.pk in session_pks + assert failed_session.pk not in session_pks + + def test_invalid_filter_drops_silently(self, logged_in_client, user): + """?status=bogus shows full list (strict=False) and current_status is empty.""" + session = _create_session(user=user) + + response = logged_in_client.get(reverse("session_list"), {"status": "bogus"}) + + assert response.status_code == 200 + session_pks = [s.pk for s in response.context["sessions"]] + assert session.pk in session_pks + assert response.context["current_status"] == "" + + def test_context_includes_origins_and_statuses(self, logged_in_client, user): + """Context must include origins and statuses choice lists.""" + response = logged_in_client.get(reverse("session_list")) + assert response.status_code == 200 + assert "origins" in response.context + assert "statuses" in response.context + assert len(response.context["origins"]) > 0 + assert len(response.context["statuses"]) > 0 + + def test_date_param_names_are_date_from_and_date_to(self, logged_in_client, user): + """Lock in the URL param names; values round-trip to template context.""" + _create_session(user=user) + response = logged_in_client.get(reverse("session_list"), {"date_from": "2020-01-01", "date_to": "2100-01-01"}) + assert response.status_code == 200 + assert response.context["current_from"] == "2020-01-01" + assert response.context["current_to"] == "2100-01-01" + + def test_has_active_filters_false_with_no_params(self, logged_in_client, user): + _create_session(user=user) + response = logged_in_client.get(reverse("session_list")) + assert response.context["has_active_filters"] is False + assert response.context["current_batch_short"] == "" + + def test_has_active_filters_true_when_batch_is_set(self, logged_in_client, user): + batch_id = uuid.uuid4() + _create_session(user=user) + response = logged_in_client.get(reverse("session_list"), {"batch": str(batch_id)}) + assert response.context["has_active_filters"] is True + assert response.context["current_batch_short"] == str(batch_id)[:8] + + def test_in_flight_ids_contains_non_terminal_run_ids(self, logged_in_client, user): + """in_flight_ids is a comma-joined string of non-terminal run PKs from page sessions.""" + session = _create_session(user=user) + running_run = _create_run(session, status=RunStatus.RUNNING) + _create_run(session, status=RunStatus.SUCCESSFUL) # terminal — excluded + + response = logged_in_client.get(reverse("session_list")) + assert response.status_code == 200 + + in_flight_ids = response.context["in_flight_ids"] + # The running run should be in the comma-joined string. + assert str(running_run.pk) in in_flight_ids + + def test_in_flight_ids_excludes_terminal_runs(self, logged_in_client, user): + """Terminal runs (SUCCESSFUL, FAILED) must not appear in in_flight_ids.""" + session = _create_session(user=user) + successful_run = _create_run(session, status=RunStatus.SUCCESSFUL) + failed_run = _create_run(session, status=RunStatus.FAILED) + + response = logged_in_client.get(reverse("session_list")) + in_flight_ids = response.context["in_flight_ids"] + + assert str(successful_run.pk) not in in_flight_ids + assert str(failed_run.pk) not in in_flight_ids + + def test_pagination_uses_paginate_by(self, logged_in_client, user): + """Check that paginated results are returned when there are more than paginate_by sessions.""" + # Create 30 sessions to exceed typical paginate_by=25. + for _ in range(30): + _create_session(user=user) + + response = logged_in_client.get(reverse("session_list")) + assert response.status_code == 200 + # Page 1 should have at most 25 sessions (default paginate_by). + assert len(response.context["sessions"]) <= 25 + + # Page 2 should exist. + response_p2 = logged_in_client.get(reverse("session_list"), {"page": "2"}) + assert response_p2.status_code == 200 + assert len(response_p2.context["sessions"]) > 0 From 28e66d5526f793e6fcdeaa0519d707de6c14a358 Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 7 Jul 2026 23:20:26 +0100 Subject: [PATCH 15/55] feat(sessions): transcript-centric session detail with run timeline --- daiv/chat/views.py | 19 +- daiv/sessions/hydration.py | 17 ++ .../templates/sessions/_run_timeline.html | 32 ++ .../templates/sessions/session_detail.html | 286 ++++++++++++++++++ daiv/sessions/urls.py | 14 +- daiv/sessions/views.py | 140 ++++++++- .../chat/test_composer_agent_picker.py | 6 +- .../chat/test_composer_env_select.py | 2 +- tests/unit_tests/chat/test_views.py | 20 +- .../unit_tests/sessions/test_views_detail.py | 255 ++++++++++++++++ 10 files changed, 759 insertions(+), 32 deletions(-) create mode 100644 daiv/sessions/hydration.py create mode 100644 daiv/sessions/templates/sessions/_run_timeline.html create mode 100644 daiv/sessions/templates/sessions/session_detail.html create mode 100644 tests/unit_tests/sessions/test_views_detail.py diff --git a/daiv/chat/views.py b/daiv/chat/views.py index 07f280f2e..e159da3e7 100644 --- a/daiv/chat/views.py +++ b/daiv/chat/views.py @@ -11,24 +11,13 @@ from activity.models import Activity from asgiref.sync import async_to_sync from sandbox_envs.models import SandboxEnvironment +from sessions.hydration import ahydrate_thread from accounts.mixins import BreadcrumbMixin from automation.agent.picker_context import agent_picker_context from chat.models import ChatThread -from chat.repo_state import aget_existing_mr_payload, mr_to_payload +from chat.repo_state import aget_existing_mr_payload from chat.turns import build_turns -from core.checkpointer import open_checkpointer - - -async def _ahydrate(thread_id: str) -> tuple[list[Any], bool, dict | None]: - """Return (messages, expired, merge_request_payload) for a thread.""" - async with open_checkpointer() as cp: - tup = await cp.aget_tuple({"configurable": {"thread_id": thread_id}}) - if tup is None: - return [], True, None - channel_values = (tup.checkpoint or {}).get("channel_values", {}) - messages = channel_values.get("messages", []) - return messages, False, mr_to_payload(channel_values.get("merge_request")) class ChatThreadListView(LoginRequiredMixin, BreadcrumbMixin, ListView): @@ -87,7 +76,7 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]: if thread is None: ctx.update({"turns": [], "expired": False, "active_run_id": "", "merge_request": None}) return ctx - messages_history, expired, merge_request = async_to_sync(_ahydrate)(thread.thread_id) + messages_history, expired, merge_request = async_to_sync(ahydrate_thread)(thread.thread_id) if merge_request is None and thread.repo_id and thread.ref: merge_request = async_to_sync(aget_existing_mr_payload)(thread.repo_id, thread.ref) ctx["turns"] = build_turns(messages_history) @@ -115,7 +104,7 @@ def post(self, request, *, activity_id): if not activity.thread_id: raise Http404 - messages, expired, _mr = async_to_sync(_ahydrate)(activity.thread_id) + messages, expired, _mr = async_to_sync(ahydrate_thread)(activity.thread_id) if expired: return HttpResponseGone("This run's state has expired. Start a fresh chat from its prompt.") diff --git a/daiv/sessions/hydration.py b/daiv/sessions/hydration.py new file mode 100644 index 000000000..018835694 --- /dev/null +++ b/daiv/sessions/hydration.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from typing import Any + +from chat.repo_state import mr_to_payload +from core.checkpointer import open_checkpointer + + +async def ahydrate_thread(thread_id: str) -> tuple[list[Any], bool, dict | None]: + """Return (messages, expired, merge_request_payload) for a thread.""" + async with open_checkpointer() as cp: + tup = await cp.aget_tuple({"configurable": {"thread_id": thread_id}}) + if tup is None: + return [], True, None + channel_values = (tup.checkpoint or {}).get("channel_values", {}) + messages = channel_values.get("messages", []) + return messages, False, mr_to_payload(channel_values.get("merge_request")) diff --git a/daiv/sessions/templates/sessions/_run_timeline.html b/daiv/sessions/templates/sessions/_run_timeline.html new file mode 100644 index 000000000..75ef643ff --- /dev/null +++ b/daiv/sessions/templates/sessions/_run_timeline.html @@ -0,0 +1,32 @@ +{% load i18n icon_tags session_tags %} +
+

{% translate "Runs" %}

+ {% for run in runs %} +
+
+ {% include "sessions/_status_pill.html" with status=run.status %} + {% include "sessions/_origin_badge.html" with origin=run.trigger_type %} + {{ run.created_at|date:"M j, H:i" }} +
+
+ {% if run.duration %}{{ run|duration }}{% endif %} + {% if run.total_tokens %}{{ run.total_tokens|format_tokens }}{% endif %} + {% if run.cost_usd %}{{ run.cost_usd|format_cost }}{% endif %} + {% if run.merge_request_web_url %} + MR + {% endif %} + {% if run.status == "SUCCESSFUL" and run.result_summary %} + + {% icon "arrow-down-tray" "h-3.5 w-3.5 inline" %} {% translate "Markdown" %} + + {% endif %} +
+ {% if run.error_message %} +

{{ run.error_message|truncatechars:120 }}

+ {% endif %} +
+ {% empty %} +

{% translate "No recorded runs — this conversation predates run tracking." %}

+ {% endfor %} +
diff --git a/daiv/sessions/templates/sessions/session_detail.html b/daiv/sessions/templates/sessions/session_detail.html new file mode 100644 index 000000000..bd256d2eb --- /dev/null +++ b/daiv/sessions/templates/sessions/session_detail.html @@ -0,0 +1,286 @@ +{% extends "base_app.html" %} +{% load i18n icon_tags static %} + +{% block title %}{% if session.title %}{{ session.title }} — {% endif %}DAIV Sessions{% endblock %} + +{% block container_width %}max-w-6xl flex flex-col min-h-full{% endblock %} + +{% block head_extra %} + + + + +{% endblock head_extra %} + +{% block alpine_plugins %} + + + + + + + + + + {% include "sandbox_envs/_scripts.html" %} +{% endblock alpine_plugins %} + +{% block breadcrumb %}{% include "accounts/_breadcrumb.html" %}{% endblock %} + +{% block app_content %} + {{ turns|json_script:"chat-initial-turns" }} + {{ merge_request|json_script:"chat-initial-merge-request" }} + + {# Translation lookups for the locked-pill fallbacks in chat({…}) below. #} + {# Tag→variable lets us pipe them through ``|escapejs`` for safe quoting in JS. #} + {% translate "Pick a model" as locked_agent_fallback %} + {% translate "Auto" as locked_env_fallback %} + +
+ +
+ {% if expired %} +
+ {% translate "This session's state has expired. Start a new session to continue." %} + {% translate "New session" %} +
+ {% endif %} + + {# Responsive summary strip shown below 1100px #} +
+ · + · + + + +
+ +
+ {# Empty state — pick a repo first, then the composer fades in. #} + + + {# Queued / running state: background run started but no checkpoint yet. #} + {% if not turns and is_in_flight %} + {% with latest_run=runs|last %} + {% if latest_run.status == "QUEUED" %} +
+
+
+ {% translate "Job is queued" %} +

{% translate "Waiting in queue" %}

+

{% translate "This job will start shortly." %}

+
+
+ {% else %} +
+
+
+ {% translate "Agent is still running" %} +

{% translate "Agent is working" %}

+

{% translate "This page refreshes automatically when the run finishes." %}

+ {% if latest_run.started_at %} +

this.elapsed++, 1000); + }, + destroy() { + if (this.tickId) clearInterval(this.tickId); + } + }" + x-text="'⏱ ' + fmt(elapsed) + ' elapsed'">⏱ elapsed

+ {% endif %} +
+
+ {% endif %} + {% endwith %} + {% endif %} + + {# Turns #} + + +
+ + +
+
+ + {% if not expired %} + {% include "chat/_composer.html" %} + {% endif %} +
+ + {# Session rail: todos + files from chat, plus the run timeline below. #} + +
+ + {% include "sandbox_envs/_env_drawer.html" %} +{% endblock app_content %} diff --git a/daiv/sessions/urls.py b/daiv/sessions/urls.py index c748d087c..59962ffcb 100644 --- a/daiv/sessions/urls.py +++ b/daiv/sessions/urls.py @@ -1,5 +1,15 @@ from django.urls import path -from sessions.views import SessionListView +from sessions.views import RunDownloadMarkdownView, SessionDetailView, SessionListView -urlpatterns = [path("", SessionListView.as_view(), name="session_list")] +urlpatterns = [ + path("", SessionListView.as_view(), name="session_list"), + # "new/" must be declared before the slug catch-all so it is matched first. + path("new/", SessionDetailView.as_view(), name="session_new"), + path("/", SessionDetailView.as_view(), name="session_detail"), + path( + "/runs//download/md/", + RunDownloadMarkdownView.as_view(), + name="session_run_download_md", + ), +] diff --git a/daiv/sessions/views.py b/daiv/sessions/views.py index 4019aba91..10101b889 100644 --- a/daiv/sessions/views.py +++ b/daiv/sessions/views.py @@ -1,13 +1,24 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import Http404, HttpResponse +from django.urls import reverse +from django.utils.text import slugify +from django.views.generic import DetailView +from asgiref.sync import async_to_sync from django_filters.views import FilterView +from sandbox_envs.models import SandboxEnvironment +from accounts.mixins import BreadcrumbMixin +from automation.agent.picker_context import agent_picker_context +from chat.repo_state import aget_existing_mr_payload +from chat.turns import build_turns from schedules.models import ScheduledJob from sessions.filters import SessionFilter +from sessions.hydration import ahydrate_thread from sessions.models import Run, RunStatus, Session, SessionOrigin if TYPE_CHECKING: @@ -77,3 +88,130 @@ def get_context_data(self, **kwargs): context["in_flight_ids"] = ",".join(str(rid) for rid in in_flight.values_list("id", flat=True)) return context + + +class SessionDetailView(LoginRequiredMixin, BreadcrumbMixin, DetailView): + """Renders the session transcript page, or the empty state for the ``session_new`` route.""" + + model = Session + template_name = "sessions/session_detail.html" + context_object_name = "session" + pk_url_kwarg = "thread_id" + + def get_queryset(self) -> QuerySet[Session]: + return Session.objects.by_owner(self.request.user).select_related( + "user", "sandbox_environment", "scheduled_job" + ) + + def get_object(self, queryset=None): + if "thread_id" not in self.kwargs: + return None # empty state (session_new route) + return super().get_object(queryset) + + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + ctx = super().get_context_data(**kwargs) + session = ctx.setdefault("session", None) + + # Populate sandbox envs both for the empty hero state and a live session. + ctx["sandbox_envs"] = list(SandboxEnvironment.objects.visible_to(self.request.user)) + ctx["selected_sandbox_env_id"] = ( + str(session.sandbox_environment_id) if session is not None and session.sandbox_environment_id else "" + ) + ctx["selected_sandbox_env"] = next( + (e for e in ctx["sandbox_envs"] if str(e.id) == ctx["selected_sandbox_env_id"]), None + ) + + ctx.update( + agent_picker_context( + initial_model=session.agent_model if session is not None else "", + initial_thinking_level=session.agent_thinking_level if session is not None else "", + ) + ) + + if session is None: + ctx.update({ + "turns": [], + "expired": False, + "active_run_id": "", + "merge_request": None, + "runs": [], + "is_in_flight": False, + "in_flight_ids": "", + }) + return ctx + + messages_history, expired, merge_request = async_to_sync(ahydrate_thread)(session.thread_id) + if merge_request is None and session.repo_id and session.ref: + merge_request = async_to_sync(aget_existing_mr_payload)(session.repo_id, session.ref) + + ctx["turns"] = build_turns(messages_history) + ctx["expired"] = expired + ctx["active_run_id"] = session.active_run_id or "" + ctx["merge_request"] = merge_request + + runs = list(session.runs.order_by("created_at")) + ctx["runs"] = runs + ctx["is_in_flight"] = any(r.status not in RunStatus.terminal() for r in runs) + ctx["in_flight_ids"] = ",".join(str(r.id) for r in runs if r.status not in RunStatus.terminal()) + + return ctx + + def get_breadcrumbs(self): + sessions_url = reverse("session_list") + session = getattr(self, "object", None) + if session is None: + return [{"label": "Sessions", "url": sessions_url}, {"label": "New", "url": None}] + return [ + {"label": "Sessions", "url": sessions_url}, + {"label": session.title or session.thread_id[:8], "url": None}, + ] + + +class RunDownloadMarkdownView(LoginRequiredMixin, DetailView): + """Serve a run's result as a downloadable Markdown file.""" + + model = Run + + def get_queryset(self) -> QuerySet[Run]: + return ( + Run.objects + .by_owner(self.request.user) + .filter(status=RunStatus.SUCCESSFUL, session_id=self.kwargs["thread_id"]) + .select_related("session") + ) + + def get(self, request, *args, **kwargs): + run = self.get_object() + content = self._build_markdown(run) + if not content: + raise Http404 + filename = self._build_filename(run) + response = HttpResponse(content, content_type="text/markdown; charset=utf-8") + response["Content-Disposition"] = f'attachment; filename="{filename}"' + return response + + def _build_markdown(self, run: Run) -> str: + response_text = run.response_text + if not response_text: + return "" + + meta_lines = ["---", f"repository: {run.repo_id}", f"trigger: {run.get_trigger_type_display()}"] + if run.ref: + meta_lines.append(f"ref: {run.ref}") + meta_lines.append(f"created: {run.created_at.strftime('%Y-%m-%d %H:%M:%S %Z')}") + if run.finished_at: + meta_lines.append(f"finished: {run.finished_at.strftime('%Y-%m-%d %H:%M:%S %Z')}") + if run.merge_request_iid: + meta_lines.append(f"merge_request: '!{run.merge_request_iid}'") + if run.total_tokens: + meta_lines.append(f"total_tokens: {run.total_tokens}") + if run.cost_usd is not None: + meta_lines.append(f"cost_usd: '{run.cost_usd}'") + meta_lines.append("---") + + return "\n".join(meta_lines) + "\n\n" + response_text + + def _build_filename(self, run: Run) -> str: + repo_slug = slugify(run.repo_id.replace("/", "-")) or "unknown" + date_str = run.created_at.strftime("%Y-%m-%d") + return f"daiv-{repo_slug}-{date_str}.md" diff --git a/tests/unit_tests/chat/test_composer_agent_picker.py b/tests/unit_tests/chat/test_composer_agent_picker.py index 367f454ce..04d4a392a 100644 --- a/tests/unit_tests/chat/test_composer_agent_picker.py +++ b/tests/unit_tests/chat/test_composer_agent_picker.py @@ -67,7 +67,7 @@ def test_existing_thread_renders_locked_agent_pill(member_client, member_user, e ) tup = MagicMock(checkpoint={"channel_values": {"messages": []}}) with ( - patch("chat.views.open_checkpointer") as cp_ctx, + patch("sessions.hydration.open_checkpointer") as cp_ctx, patch("chat.views.aget_existing_mr_payload", AsyncMock(return_value=None)), ): saver = MagicMock() @@ -202,7 +202,7 @@ def test_composer_locked_pill_seeds_from_pinned_thread(member_client, member_use ) tup = MagicMock(checkpoint={"channel_values": {"messages": []}}) with ( - patch("chat.views.open_checkpointer") as cp_ctx, + patch("sessions.hydration.open_checkpointer") as cp_ctx, patch("chat.views.aget_existing_mr_payload", AsyncMock(return_value=None)), ): saver = MagicMock() @@ -229,7 +229,7 @@ def test_thread_without_pinned_override_renders_auto_label(member_client, member thread = ChatThread.objects.create(thread_id="t-auto", user=member_user, repo_id="a/b", ref="main") tup = MagicMock(checkpoint={"channel_values": {"messages": []}}) with ( - patch("chat.views.open_checkpointer") as cp_ctx, + patch("sessions.hydration.open_checkpointer") as cp_ctx, patch("chat.views.aget_existing_mr_payload", AsyncMock(return_value=None)), ): saver = MagicMock() diff --git a/tests/unit_tests/chat/test_composer_env_select.py b/tests/unit_tests/chat/test_composer_env_select.py index 38cc08c00..6be41d03b 100644 --- a/tests/unit_tests/chat/test_composer_env_select.py +++ b/tests/unit_tests/chat/test_composer_env_select.py @@ -41,7 +41,7 @@ def test_composer_renders_env_picker_with_envs(member_client, member_user): thread = ChatThread.objects.create(thread_id="t-env", user=member_user, repo_id="a/b", ref="main") tup = MagicMock(checkpoint={"channel_values": {"messages": []}}) with ( - patch("chat.views.open_checkpointer") as cp_ctx, + patch("sessions.hydration.open_checkpointer") as cp_ctx, patch("chat.views.aget_existing_mr_payload", AsyncMock(return_value=None)), ): saver = MagicMock() diff --git a/tests/unit_tests/chat/test_views.py b/tests/unit_tests/chat/test_views.py index 7ab444bc5..6c3a36933 100644 --- a/tests/unit_tests/chat/test_views.py +++ b/tests/unit_tests/chat/test_views.py @@ -46,7 +46,7 @@ def test_detail_view_with_live_checkpoint_renders_transcript(member_client, memb msg = AIMessage(content="hello from agent", id="m-1") tup = MagicMock(checkpoint={"channel_values": {"messages": [msg]}}) with ( - patch("chat.views.open_checkpointer") as cp_ctx, + patch("sessions.hydration.open_checkpointer") as cp_ctx, patch("chat.views.aget_existing_mr_payload", AsyncMock(return_value=None)), ): saver = MagicMock() @@ -67,7 +67,7 @@ def test_detail_view_with_live_checkpoint_renders_transcript(member_client, memb def test_detail_view_with_missing_checkpoint_flags_expired(member_client, member_user): thread = ChatThread.objects.create(thread_id="t-gone", user=member_user, repo_id="a/b", ref="main") with ( - patch("chat.views.open_checkpointer") as cp_ctx, + patch("sessions.hydration.open_checkpointer") as cp_ctx, patch("chat.views.aget_existing_mr_payload", AsyncMock(return_value=None)), ): saver = MagicMock() @@ -112,7 +112,7 @@ def test_detail_view_surfaces_existing_mr_when_checkpoint_has_none(member_client repo_client = MagicMock() repo_client.get_merge_request_by_branches.return_value = existing_mr with ( - patch("chat.views.open_checkpointer") as cp_ctx, + patch("sessions.hydration.open_checkpointer") as cp_ctx, patch("chat.repo_state.RepoClient.create_instance", return_value=repo_client), patch("chat.repo_state.RepositoryConfig.get_config", return_value=MagicMock(default_branch="main")), ): @@ -146,7 +146,7 @@ def test_detail_view_skips_mr_lookup_when_checkpoint_already_has_one(member_clie tup = MagicMock(checkpoint={"channel_values": {"messages": [], "merge_request": stored_mr}}) repo_client = MagicMock() with ( - patch("chat.views.open_checkpointer") as cp_ctx, + patch("sessions.hydration.open_checkpointer") as cp_ctx, patch("chat.repo_state.RepoClient.create_instance", return_value=repo_client) as factory, ): saver = MagicMock() @@ -170,7 +170,7 @@ def test_detail_view_swallows_platform_errors_in_mr_lookup(member_client, member thread = ChatThread.objects.create(thread_id="t-err", user=member_user, repo_id="a/b", ref="feature-z") tup = MagicMock(checkpoint={"channel_values": {"messages": []}}) with ( - patch("chat.views.open_checkpointer") as cp_ctx, + patch("sessions.hydration.open_checkpointer") as cp_ctx, patch("chat.repo_state.RepositoryConfig.get_config", side_effect=httpx.ConnectError("platform unreachable")), ): saver = MagicMock() @@ -192,7 +192,7 @@ def test_detail_view_propagates_unexpected_errors_in_mr_lookup(member_client, me thread = ChatThread.objects.create(thread_id="t-bug", user=member_user, repo_id="a/b", ref="feature-z") tup = MagicMock(checkpoint={"channel_values": {"messages": []}}) with ( - patch("chat.views.open_checkpointer") as cp_ctx, + patch("sessions.hydration.open_checkpointer") as cp_ctx, patch("chat.repo_state.RepositoryConfig.get_config", side_effect=KeyError("config missing")), ): saver = MagicMock() @@ -210,7 +210,7 @@ def test_detail_view_skips_mr_lookup_when_branch_is_default(member_client, membe tup = MagicMock(checkpoint={"channel_values": {"messages": []}}) repo_client = MagicMock() with ( - patch("chat.views.open_checkpointer") as cp_ctx, + patch("sessions.hydration.open_checkpointer") as cp_ctx, patch("chat.repo_state.RepoClient.create_instance", return_value=repo_client) as factory, patch("chat.repo_state.RepositoryConfig.get_config", return_value=MagicMock(default_branch="main")), ): @@ -249,7 +249,7 @@ def test_from_activity_succeeds_for_webhook_activity_matched_by_external_usernam external_username=member_user.username, ) tup = MagicMock(checkpoint={"channel_values": {"messages": []}}) - with patch("chat.views.open_checkpointer") as cp_ctx: + with patch("sessions.hydration.open_checkpointer") as cp_ctx: saver = MagicMock() saver.aget_tuple = AsyncMock(return_value=tup) cp_ctx.return_value.__aenter__ = AsyncMock(return_value=saver) @@ -274,7 +274,7 @@ def test_from_activity_410_when_checkpoint_missing(member_client, member_user): activity = Activity.objects.create( trigger_type=TriggerType.UI_JOB, repo_id="a/b", ref="main", prompt="x", thread_id="t-gone", user=member_user ) - with patch("chat.views.open_checkpointer") as cp_ctx: + with patch("sessions.hydration.open_checkpointer") as cp_ctx: saver = MagicMock() saver.aget_tuple = AsyncMock(return_value=None) cp_ctx.return_value.__aenter__ = AsyncMock(return_value=saver) @@ -294,7 +294,7 @@ def test_from_activity_creates_thread_and_redirects(member_client, member_user): user=member_user, ) tup = MagicMock(checkpoint={"channel_values": {"messages": []}}) - with patch("chat.views.open_checkpointer") as cp_ctx: + with patch("sessions.hydration.open_checkpointer") as cp_ctx: saver = MagicMock() saver.aget_tuple = AsyncMock(return_value=tup) cp_ctx.return_value.__aenter__ = AsyncMock(return_value=saver) diff --git a/tests/unit_tests/sessions/test_views_detail.py b/tests/unit_tests/sessions/test_views_detail.py new file mode 100644 index 000000000..a69f68ba9 --- /dev/null +++ b/tests/unit_tests/sessions/test_views_detail.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +import uuid +from unittest.mock import AsyncMock, MagicMock, patch + +from django.urls import reverse + +import pytest +from sessions.models import Run, RunStatus, Session, SessionOrigin + +from accounts.models import Role, User + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def other_user(db): + return User.objects.create_user(username="other", email="other@test.com", password="x", role=Role.MEMBER) # noqa: S106 + + +def _create_session(**kwargs) -> Session: + defaults = {"thread_id": str(uuid.uuid4()), "origin": SessionOrigin.CHAT, "repo_id": "group/project", "ref": "main"} + defaults.update(kwargs) + return Session.objects.create(**defaults) + + +def _create_run(session: Session, **kwargs) -> Run: + defaults = { + "session": session, + "trigger_type": SessionOrigin.CHAT, + "repo_id": session.repo_id, + "status": RunStatus.SUCCESSFUL, + } + defaults.update(kwargs) + return Run.objects.create(**defaults) + + +def _null_hydration(): + """Patch target that returns an empty, non-expired hydration.""" + return AsyncMock(return_value=([], False, None)) + + +# --------------------------------------------------------------------------- +# session_new (empty state) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_session_new_requires_login(client): + resp = client.get(reverse("session_new")) + assert resp.status_code == 302 + assert "login" in resp["Location"].lower() + + +@pytest.mark.django_db +def test_session_new_renders_empty_state(member_client): + with patch("sessions.views.ahydrate_thread", _null_hydration()): + resp = member_client.get(reverse("session_new")) + assert resp.status_code == 200 + assert resp.context["session"] is None + assert resp.context["expired"] is False + assert resp.context["turns"] == [] + + +# --------------------------------------------------------------------------- +# session_detail +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_detail_requires_login(client, member_user): + session = _create_session(user=member_user) + resp = client.get(reverse("session_detail", kwargs={"thread_id": session.thread_id})) + assert resp.status_code == 302 + assert "login" in resp["Location"].lower() + + +@pytest.mark.django_db +def test_detail_404_for_other_users_session(member_client, other_user): + session = _create_session(user=other_user) + resp = member_client.get(reverse("session_detail", kwargs={"thread_id": session.thread_id})) + assert resp.status_code == 404 + + +@pytest.mark.django_db +def test_detail_renders_for_own_session(member_client, member_user): + session = _create_session(user=member_user) + with patch("sessions.views.ahydrate_thread", _null_hydration()): + resp = member_client.get(reverse("session_detail", kwargs={"thread_id": session.thread_id})) + assert resp.status_code == 200 + assert resp.context["session"] == session + assert resp.context["expired"] is False + + +@pytest.mark.django_db +def test_detail_with_live_checkpoint_renders_transcript(member_client, member_user): + from langchain_core.messages import AIMessage + + session = _create_session(user=member_user) + msg = AIMessage(content="hello from agent", id="m-1") + tup = MagicMock(checkpoint={"channel_values": {"messages": [msg]}}) + + with ( + patch("sessions.hydration.open_checkpointer") as cp_ctx, + patch("sessions.views.aget_existing_mr_payload", AsyncMock(return_value=None)), + ): + saver = MagicMock() + saver.aget_tuple = AsyncMock(return_value=tup) + cp_ctx.return_value.__aenter__ = AsyncMock(return_value=saver) + cp_ctx.return_value.__aexit__ = AsyncMock(return_value=None) + resp = member_client.get(reverse("session_detail", kwargs={"thread_id": session.thread_id})) + + assert resp.status_code == 200 + assert resp.context["expired"] is False + turns = resp.context["turns"] + assert len(turns) == 1 + assert turns[0]["role"] == "assistant" + assert turns[0]["segments"] == [{"type": "text", "content": "hello from agent"}] + + +@pytest.mark.django_db +def test_detail_with_missing_checkpoint_flags_expired(member_client, member_user): + session = _create_session(user=member_user) + + with patch("sessions.hydration.open_checkpointer") as cp_ctx: + saver = MagicMock() + saver.aget_tuple = AsyncMock(return_value=None) + cp_ctx.return_value.__aenter__ = AsyncMock(return_value=saver) + cp_ctx.return_value.__aexit__ = AsyncMock(return_value=None) + resp = member_client.get(reverse("session_detail", kwargs={"thread_id": session.thread_id})) + + assert resp.status_code == 200 + assert resp.context["expired"] is True + + +@pytest.mark.django_db +def test_detail_includes_run_timeline(member_client, member_user): + """A session with two runs renders both in the timeline rail with status pills.""" + session = _create_session(user=member_user) + run1 = _create_run(session, status=RunStatus.SUCCESSFUL) + run2 = _create_run(session, status=RunStatus.FAILED) + + with patch("sessions.views.ahydrate_thread", _null_hydration()): + resp = member_client.get(reverse("session_detail", kwargs={"thread_id": session.thread_id})) + + assert resp.status_code == 200 + runs = resp.context["runs"] + assert len(runs) == 2 + run_ids = {r.id for r in runs} + assert run1.id in run_ids + assert run2.id in run_ids + # Both status pills should be visible in the rendered HTML + content = resp.content.decode() + assert f"run-{run1.id}" in content + assert f"run-{run2.id}" in content + + +@pytest.mark.django_db +def test_detail_expired_checkpoint_disables_composer(member_client, member_user): + """_ahydrate returning (.., expired=True, ..) => context['expired'] is True + and the template renders the expired notice.""" + session = _create_session(user=member_user) + + with patch("sessions.hydration.open_checkpointer") as cp_ctx: + saver = MagicMock() + saver.aget_tuple = AsyncMock(return_value=None) # tup=None => expired + cp_ctx.return_value.__aenter__ = AsyncMock(return_value=saver) + cp_ctx.return_value.__aexit__ = AsyncMock(return_value=None) + resp = member_client.get(reverse("session_detail", kwargs={"thread_id": session.thread_id})) + + assert resp.status_code == 200 + assert resp.context["expired"] is True + # Template should contain the expired notice text + content = resp.content.decode() + assert "expired" in content.lower() or "state has expired" in content.lower() + + +@pytest.mark.django_db +def test_detail_visible_to_run_actor(member_client, member_user): + """A webhook session (user=None) is reachable by the external actor via by_owner.""" + session = _create_session(user=None, external_username=member_user.username, origin=SessionOrigin.ISSUE_WEBHOOK) + + with patch("sessions.views.ahydrate_thread", _null_hydration()): + resp = member_client.get(reverse("session_detail", kwargs={"thread_id": session.thread_id})) + + assert resp.status_code == 200 + assert resp.context["session"] == session + + +@pytest.mark.django_db +def test_detail_in_flight_context(member_client, member_user): + """is_in_flight and in_flight_ids are populated from non-terminal runs.""" + session = _create_session(user=member_user) + run_done = _create_run(session, status=RunStatus.SUCCESSFUL) + run_live = _create_run(session, status=RunStatus.RUNNING) + + with patch("sessions.views.ahydrate_thread", _null_hydration()): + resp = member_client.get(reverse("session_detail", kwargs={"thread_id": session.thread_id})) + + assert resp.context["is_in_flight"] is True + assert str(run_live.id) in resp.context["in_flight_ids"] + assert str(run_done.id) not in resp.context["in_flight_ids"] + + +# --------------------------------------------------------------------------- +# session_run_download_md +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_download_md_serves_run_result(member_client, member_user): + """GET session_run_download_md for a SUCCESSFUL run returns markdown attachment.""" + session = _create_session(user=member_user) + run = _create_run(session, status=RunStatus.SUCCESSFUL, result_summary="# Hello\n\nWorld", user=member_user) + + resp = member_client.get(reverse("session_run_download_md", kwargs={"thread_id": session.thread_id, "pk": run.id})) + + assert resp.status_code == 200 + assert resp["Content-Type"].startswith("text/markdown") + assert "attachment" in resp["Content-Disposition"] + content = b"".join(resp.streaming_content) if hasattr(resp, "streaming_content") else resp.content + text = content.decode() + assert "Hello" in text + + +@pytest.mark.django_db +def test_download_md_404_for_non_successful_run(member_client, member_user): + """Failed runs cannot be downloaded.""" + session = _create_session(user=member_user) + run = _create_run(session, status=RunStatus.FAILED, result_summary="some error", user=member_user) + + resp = member_client.get(reverse("session_run_download_md", kwargs={"thread_id": session.thread_id, "pk": run.id})) + assert resp.status_code == 404 + + +@pytest.mark.django_db +def test_download_md_404_for_other_users_run(member_client, other_user): + """Other user's run cannot be downloaded.""" + session = _create_session(user=other_user) + run = _create_run(session, status=RunStatus.SUCCESSFUL, result_summary="some result") + + resp = member_client.get(reverse("session_run_download_md", kwargs={"thread_id": session.thread_id, "pk": run.id})) + assert resp.status_code == 404 + + +@pytest.mark.django_db +def test_download_md_404_when_no_result_summary(member_client, member_user): + """Runs with empty result_summary return 404 — nothing to serve.""" + session = _create_session(user=member_user) + run = _create_run(session, status=RunStatus.SUCCESSFUL, result_summary="", user=member_user) + + resp = member_client.get(reverse("session_run_download_md", kwargs={"thread_id": session.thread_id, "pk": run.id})) + assert resp.status_code == 404 From 66e4e19542905247e28ea9917fffb4f8ee7af96a Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 7 Jul 2026 23:33:09 +0100 Subject: [PATCH 16/55] feat(sessions): SSE status stream and live transcript polling for background runs --- daiv/daiv/api.py | 2 + daiv/sessions/api/__init__.py | 0 daiv/sessions/api/views.py | 47 +++++ .../static/sessions/js/session-stream.js | 64 +++++++ .../static/sessions/js/session-sync.js | 28 +++ .../templates/sessions/session_detail.html | 12 +- .../templates/sessions/session_list.html | 3 +- daiv/sessions/urls.py | 5 +- daiv/sessions/views.py | 82 ++++++++- tests/unit_tests/sessions/test_api.py | 164 ++++++++++++++++++ tests/unit_tests/sessions/test_stream.py | 91 ++++++++++ .../unit_tests/sessions/test_views_detail.py | 41 +++++ 12 files changed, 533 insertions(+), 6 deletions(-) create mode 100644 daiv/sessions/api/__init__.py create mode 100644 daiv/sessions/api/views.py create mode 100644 daiv/sessions/static/sessions/js/session-stream.js create mode 100644 daiv/sessions/static/sessions/js/session-sync.js create mode 100644 tests/unit_tests/sessions/test_api.py create mode 100644 tests/unit_tests/sessions/test_stream.py diff --git a/daiv/daiv/api.py b/daiv/daiv/api.py index 69684be5a..56c2de471 100644 --- a/daiv/daiv/api.py +++ b/daiv/daiv/api.py @@ -1,6 +1,7 @@ from jobs.api.views import jobs_router from mcp_server.api.views import oauth_router from ninja import NinjaAPI +from sessions.api.views import sessions_router from automation.api.views import router as automation_router from chat.api.views import chat_router @@ -14,3 +15,4 @@ api.add_router("/chat", chat_router) api.add_router("/jobs", jobs_router) api.add_router("/oauth", oauth_router) +api.add_router("/sessions", sessions_router) diff --git a/daiv/sessions/api/__init__.py b/daiv/sessions/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/daiv/sessions/api/views.py b/daiv/sessions/api/views.py new file mode 100644 index 000000000..a26d034ed --- /dev/null +++ b/daiv/sessions/api/views.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from ninja import Router +from ninja.errors import HttpError +from ninja.security import django_auth + +from chat.api.security import AuthBearer +from chat.turns import build_turns +from sessions.hydration import ahydrate_thread +from sessions.models import Session + +if TYPE_CHECKING: + from django.http import HttpRequest + +logger = logging.getLogger("daiv.sessions") + +sessions_router = Router(tags=["sessions"], auth=[AuthBearer(), django_auth]) + + +async def _get_visible_session(user, thread_id: str) -> Session: + session = await Session.objects.by_owner(user).filter(thread_id=thread_id).afirst() + if session is None: + raise HttpError(404, "Session not found") + return session + + +@sessions_router.get("/{thread_id}/status", response=dict, url_name="session_status") +async def session_status(request: HttpRequest, thread_id: str): + """Cheap probe so a reloaded page can detect when the in-flight run released the slot.""" + session = await _get_visible_session(request.auth, thread_id) # ty: ignore[unresolved-attribute] + return {"active": bool(session.active_run_id)} + + +@sessions_router.get("/{thread_id}/turns", response=dict, url_name="session_turns") +async def session_turns(request: HttpRequest, thread_id: str): + """Re-hydrated transcript for live background runs (the detail page polls this + while a non-chat run holds the session slot).""" + session = await _get_visible_session(request.auth, thread_id) # ty: ignore[unresolved-attribute] + messages, expired, _mr = await ahydrate_thread(thread_id) + return { + "turns": [] if expired else build_turns(messages), + "active": bool(session.active_run_id), + "expired": expired, + } diff --git a/daiv/sessions/static/sessions/js/session-stream.js b/daiv/sessions/static/sessions/js/session-stream.js new file mode 100644 index 000000000..bd529e73e --- /dev/null +++ b/daiv/sessions/static/sessions/js/session-stream.js @@ -0,0 +1,64 @@ +/** + * Alpine.js component for real-time session/run status updates via SSE. + * + * sessionStream (list page) — tracks multiple runs in place: + * dotClass(id, fallback) → object toggling status-dot-{variant} classes + * statusClass(id, fallback) → object toggling status-badge-{variant} classes + * statusLabel(id, fallback) → human-readable label + * + * Object class maps (rather than a single string) are required so Alpine + * removes the previously rendered variant class when the status transitions — + * otherwise the static server-rendered class lingers alongside the new one + * and the later CSS rule wins. + */ +document.addEventListener("alpine:init", () => { + const VARIANTS = ["success", "failed", "running", "queued", "pending"]; + + function statusVariantFor(status) { + if (status === "SUCCESSFUL") return "success"; + if (status === "FAILED") return "failed"; + if (status === "RUNNING") return "running"; + if (status === "QUEUED") return "queued"; + return "pending"; + } + + function statusLabelFor(status) { + if (status === "SUCCESSFUL") return "Successful"; + if (status === "FAILED") return "Failed"; + if (status === "RUNNING") return "Running"; + if (status === "QUEUED") return "Queued"; + return "Pending"; + } + + function variantClassMap(prefix, active) { + return Object.fromEntries(VARIANTS.map((v) => [prefix + v, v === active])); + } + + Alpine.data("sessionStream", (streamUrl, inFlightIds) => ({ + updates: {}, + init() { + if (!inFlightIds) return; + const url = streamUrl + "?ids=" + inFlightIds; + const source = new EventSource(url); + source.onmessage = (event) => { + const data = JSON.parse(event.data); + if (data.done) { + source.close(); + return; + } + this.updates[data.id] = data; + }; + source.onerror = () => source.close(); + }, + statusClass(id, fallback) { + return variantClassMap("status-badge-", statusVariantFor(this.updates[id]?.status || fallback)); + }, + dotClass(id, fallback) { + return variantClassMap("status-dot-", statusVariantFor(this.updates[id]?.status || fallback)); + }, + statusLabel(id, fallback) { + const update = this.updates[id]; + return update ? statusLabelFor(update.status) : fallback; + }, + })); +}); diff --git a/daiv/sessions/static/sessions/js/session-sync.js b/daiv/sessions/static/sessions/js/session-sync.js new file mode 100644 index 000000000..c40498a4f --- /dev/null +++ b/daiv/sessions/static/sessions/js/session-sync.js @@ -0,0 +1,28 @@ +// Polls the session turns endpoint while a background (non-chat) run holds the +// session slot, so the transcript grows as the run progresses. +document.addEventListener("alpine:init", () => { + Alpine.data("sessionSync", ({ turnsUrl, active }) => ({ + active, + _timer: null, + init() { + if (this.active) this._timer = setInterval(() => this.poll(), 5000); + }, + destroy() { + if (this._timer) clearInterval(this._timer); + }, + async poll() { + try { + const res = await fetch(turnsUrl, { headers: { Accept: "application/json" } }); + if (!res.ok) return; + const data = await res.json(); + window.dispatchEvent(new CustomEvent("daiv:session-turns", { detail: data })); + if (!data.active) { + clearInterval(this._timer); + location.reload(); + } + } catch (e) { + /* transient network errors: keep polling */ + } + }, + })); +}); diff --git a/daiv/sessions/templates/sessions/session_detail.html b/daiv/sessions/templates/sessions/session_detail.html index bd256d2eb..1ef553548 100644 --- a/daiv/sessions/templates/sessions/session_detail.html +++ b/daiv/sessions/templates/sessions/session_detail.html @@ -22,6 +22,9 @@ + {% if poll_transcript %} + + {% endif %} {% include "sandbox_envs/_scripts.html" %} {% endblock alpine_plugins %} @@ -31,6 +34,11 @@ {{ turns|json_script:"chat-initial-turns" }} {{ merge_request|json_script:"chat-initial-merge-request" }} + {% if poll_transcript %} + + {% endif %} + {# Translation lookups for the locked-pill fallbacks in chat({…}) below. #} {# Tag→variable lets us pipe them through ``|escapejs`` for safe quoting in JS. #} {% translate "Pick a model" as locked_agent_fallback %} @@ -38,8 +46,7 @@
diff --git a/daiv/sessions/templates/sessions/session_list.html b/daiv/sessions/templates/sessions/session_list.html index d599b15a6..50f54fe50 100644 --- a/daiv/sessions/templates/sessions/session_list.html +++ b/daiv/sessions/templates/sessions/session_list.html @@ -10,10 +10,11 @@ integrity="sha384-USgPxo+ohBkt/xxOPsfCDC5BYAwgFHCatL+RFkcPCWWkvKSp5KzH52tUZZ7taB/c" crossorigin="anonymous"> + {% endblock alpine_plugins %} {% block app_content %} -
+

Agent Sessions

diff --git a/daiv/sessions/urls.py b/daiv/sessions/urls.py index 59962ffcb..d34edcd2c 100644 --- a/daiv/sessions/urls.py +++ b/daiv/sessions/urls.py @@ -1,11 +1,12 @@ from django.urls import path -from sessions.views import RunDownloadMarkdownView, SessionDetailView, SessionListView +from sessions.views import RunDownloadMarkdownView, SessionDetailView, SessionListView, SessionStreamView urlpatterns = [ path("", SessionListView.as_view(), name="session_list"), - # "new/" must be declared before the slug catch-all so it is matched first. + # "new/" and "stream/" must be declared before the slug catch-all so they match first. path("new/", SessionDetailView.as_view(), name="session_new"), + path("stream/", SessionStreamView.as_view(), name="session_stream"), path("/", SessionDetailView.as_view(), name="session_detail"), path( "/runs//download/md/", diff --git a/daiv/sessions/views.py b/daiv/sessions/views.py index 10101b889..b34c585c2 100644 --- a/daiv/sessions/views.py +++ b/daiv/sessions/views.py @@ -1,11 +1,16 @@ from __future__ import annotations +import asyncio +import json +import time +import uuid from typing import TYPE_CHECKING, Any from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import Http404, HttpResponse +from django.http import Http404, HttpResponse, HttpResponseBase, StreamingHttpResponse from django.urls import reverse from django.utils.text import slugify +from django.views import View from django.views.generic import DetailView from asgiref.sync import async_to_sync @@ -25,6 +30,72 @@ from django.db.models import QuerySet +POLL_INTERVAL = 2.0 +MAX_DURATION = 300.0 + + +class SessionStreamView(View): + """SSE endpoint that streams Run status updates for in-flight sessions.""" + + async def get(self, request: HttpResponseBase) -> HttpResponseBase: + user = await request.auser() + if not user.is_authenticated: + return HttpResponse(status=403) + + ids_param = request.GET.get("ids", "") + uuids: list[uuid.UUID] = [] + for part in ids_param.split(","): + try: + uuids.append(uuid.UUID(part.strip())) + except ValueError: + continue + + if not uuids: + return HttpResponse(status=400) + + return StreamingHttpResponse( + self._stream(uuids, user), + content_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + async def _stream(self, run_ids: list[uuid.UUID], user): + """Stream current Run state to the browser. + + Sync from DBTaskResult happens in the worker via django-tasks signals; this view + only reads already-synced rows and emits SSE events for state changes. + """ + tracking = set(run_ids) + terminal = RunStatus.terminal() + start = time.monotonic() + last_emitted: dict[uuid.UUID, tuple[str, str | None, str | None]] = {} + + while tracking and (time.monotonic() - start) < MAX_DURATION: + await asyncio.sleep(POLL_INTERVAL) + + runs = Run.objects.by_owner(user).filter(id__in=tracking).only("id", "status", "started_at", "finished_at") + + async for run in runs: + started_iso = run.started_at.isoformat() if run.started_at else None + finished_iso = run.finished_at.isoformat() if run.finished_at else None + current_state = (run.status, started_iso, finished_iso) + + if last_emitted.get(run.id) != current_state: + last_emitted[run.id] = current_state + data = json.dumps({ + "id": str(run.id), + "status": run.status, + "started_at": started_iso, + "finished_at": finished_iso, + }) + yield f"data: {data}\n\n" + + if run.status in terminal: + tracking.discard(run.id) + + yield 'data: {"done": true}\n\n' + + class SessionListView(LoginRequiredMixin, FilterView): model = Session filterset_class = SessionFilter @@ -154,6 +225,15 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]: ctx["is_in_flight"] = any(r.status not in RunStatus.terminal() for r in runs) ctx["in_flight_ids"] = ",".join(str(r.id) for r in runs if r.status not in RunStatus.terminal()) + # Engage transcript polling when a background run holds the slot and there is + # no live chat stream from this tab (chat stream manages its own turns in JS; + # the poller only kicks in for non-chat background runs). + ctx["poll_transcript"] = bool( + self.object + and self.object.active_run_id + and any(r.trigger_type != SessionOrigin.CHAT and r.status not in RunStatus.terminal() for r in ctx["runs"]) + ) + return ctx def get_breadcrumbs(self): diff --git a/tests/unit_tests/sessions/test_api.py b/tests/unit_tests/sessions/test_api.py new file mode 100644 index 000000000..3c46c2636 --- /dev/null +++ b/tests/unit_tests/sessions/test_api.py @@ -0,0 +1,164 @@ +"""Tests for sessions.api.views — session_status and session_turns endpoints.""" + +from __future__ import annotations + +import uuid +from unittest.mock import AsyncMock, patch + +import pytest +from ninja.testing import TestAsyncClient +from sessions.models import Run, RunStatus, Session, SessionOrigin + +from accounts.models import APIKey, User +from daiv.api import api + + +@pytest.fixture +def client(): + return TestAsyncClient(api) + + +@pytest.fixture +async def authed(db): + """Return (APIKey, raw_key, user) for authenticated tests.""" + user = await User.objects.acreate_user( + username="sessuser", + email="sessuser@example.com", + password="testpass123", # noqa: S106 + ) + key_obj, raw = await APIKey.objects.create_key(user=user, name="Test") + return key_obj, raw, user + + +def _auth_headers(raw_key: str) -> dict: + return {"Authorization": f"Bearer {raw_key}"} + + +def _create_session(user=None, **kwargs) -> Session: + defaults = {"thread_id": str(uuid.uuid4()), "origin": SessionOrigin.CHAT, "repo_id": "group/project", "ref": "main"} + if user is not None: + defaults["user"] = user + defaults.update(kwargs) + return Session.objects.create(**defaults) + + +def _create_run(session: Session, **kwargs) -> Run: + defaults = { + "session": session, + "trigger_type": SessionOrigin.CHAT, + "repo_id": session.repo_id, + "status": RunStatus.SUCCESSFUL, + } + defaults.update(kwargs) + return Run.objects.create(**defaults) + + +# --------------------------------------------------------------------------- +# session_status +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db(transaction=True) +async def test_session_status_active_flag(client, authed): + """Session with active_run_id -> {"active": true}; without -> false; other user's -> 404.""" + _key_obj, raw, user = authed + + # Session with active_run_id set -> active: true + session_active = await Session.objects.acreate( + thread_id=str(uuid.uuid4()), + origin=SessionOrigin.CHAT, + repo_id="group/project", + ref="main", + user=user, + active_run_id="run-abc-123", + ) + + resp = await client.get(f"/sessions/{session_active.thread_id}/status", headers=_auth_headers(raw)) + assert resp.status_code == 200 + assert resp.json() == {"active": True} + + # Session without active_run_id -> active: false + session_idle = await Session.objects.acreate( + thread_id=str(uuid.uuid4()), + origin=SessionOrigin.CHAT, + repo_id="group/project", + ref="main", + user=user, + active_run_id=None, + ) + resp = await client.get(f"/sessions/{session_idle.thread_id}/status", headers=_auth_headers(raw)) + assert resp.status_code == 200 + assert resp.json() == {"active": False} + + # Other user's session -> 404 + other = await User.objects.acreate_user( + username="other-sess", + email="other-sess@example.com", + password="x", # noqa: S106 + ) + session_other = await Session.objects.acreate( + thread_id=str(uuid.uuid4()), origin=SessionOrigin.CHAT, repo_id="group/project", ref="main", user=other + ) + resp = await client.get(f"/sessions/{session_other.thread_id}/status", headers=_auth_headers(raw)) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# session_turns +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db(transaction=True) +async def test_session_turns_returns_built_turns(client, authed): + """Patch ahydrate_thread to return two fake messages; response contains build_turns + output and expired=False.""" + from langchain_core.messages import AIMessage, HumanMessage + + _key_obj, raw, user = authed + + session = await Session.objects.acreate( + thread_id=str(uuid.uuid4()), + origin=SessionOrigin.CHAT, + repo_id="group/project", + ref="main", + user=user, + active_run_id=None, + ) + + fake_messages = [HumanMessage(content="hello", id="m-1"), AIMessage(content="world", id="m-2")] + + with patch("sessions.api.views.ahydrate_thread", AsyncMock(return_value=(fake_messages, False, None))): + resp = await client.get(f"/sessions/{session.thread_id}/turns", headers=_auth_headers(raw)) + + assert resp.status_code == 200 + data = resp.json() + assert data["expired"] is False + assert data["active"] is False + # build_turns should produce one user turn and one assistant turn + roles = [t["role"] for t in data["turns"]] + assert "user" in roles + assert "assistant" in roles + + +@pytest.mark.django_db(transaction=True) +async def test_session_turns_expired(client, authed): + """ahydrate_thread returns (.., True, ..) -> {"turns": [], "expired": true, "active": false}.""" + _key_obj, raw, user = authed + + session = await Session.objects.acreate( + thread_id=str(uuid.uuid4()), + origin=SessionOrigin.CHAT, + repo_id="group/project", + ref="main", + user=user, + active_run_id=None, + ) + + with patch("sessions.api.views.ahydrate_thread", AsyncMock(return_value=([], True, None))): + resp = await client.get(f"/sessions/{session.thread_id}/turns", headers=_auth_headers(raw)) + + assert resp.status_code == 200 + data = resp.json() + assert data["expired"] is True + assert data["turns"] == [] + assert data["active"] is False diff --git a/tests/unit_tests/sessions/test_stream.py b/tests/unit_tests/sessions/test_stream.py new file mode 100644 index 000000000..fd90e0c60 --- /dev/null +++ b/tests/unit_tests/sessions/test_stream.py @@ -0,0 +1,91 @@ +"""Tests for SessionStreamView (SSE endpoint for Run status updates).""" + +from __future__ import annotations + +import uuid + +from django.urls import reverse + +import pytest +from sessions.models import Run, RunStatus, Session, SessionOrigin + +from accounts.models import User + + +@pytest.fixture +def user(db): + return User.objects.create_user( + username="streamuser", + email="streamuser@test.com", + password="testpass123", # noqa: S106 + ) + + +def _create_session(user=None, **kwargs) -> Session: + defaults = { + "thread_id": str(uuid.uuid4()), + "origin": SessionOrigin.SCHEDULE, + "repo_id": "group/project", + "ref": "main", + } + if user is not None: + defaults["user"] = user + defaults.update(kwargs) + return Session.objects.create(**defaults) + + +def _create_run(session: Session, **kwargs) -> Run: + defaults = { + "session": session, + "trigger_type": SessionOrigin.SCHEDULE, + "repo_id": session.repo_id, + "status": RunStatus.RUNNING, + } + defaults.update(kwargs) + return Run.objects.create(**defaults) + + +@pytest.mark.django_db +class TestSessionStreamView: + def test_unauthenticated_returns_403(self, client): + """Unauthenticated requests get 403.""" + from django.test import Client + + anon = Client() + resp = anon.get(reverse("session_stream"), {"ids": str(uuid.uuid4())}) + assert resp.status_code == 403 + + def test_missing_ids_returns_400(self, logged_in_client): + resp = logged_in_client.get(reverse("session_stream")) + assert resp.status_code == 400 + + def test_invalid_uuids_only_returns_400(self, logged_in_client): + """If all id values are invalid UUIDs, return 400.""" + resp = logged_in_client.get(reverse("session_stream"), {"ids": "not-a-uuid"}) + assert resp.status_code == 400 + + def test_valid_ids_returns_sse_stream(self, logged_in_client, user, db): + """Valid UUIDs produce a streaming SSE response (200, text/event-stream).""" + session = _create_session(user=user) + run = _create_run(session, user=user, status=RunStatus.SUCCESSFUL) + resp = logged_in_client.get(reverse("session_stream"), {"ids": str(run.id)}) + assert resp.status_code == 200 + assert "text/event-stream" in resp.get("Content-Type", "") + + def test_stream_route_precedes_slug_catchall(self): + """session_stream URL resolves to SessionStreamView, not SessionDetailView.""" + from django.urls import resolve + + from sessions.views import SessionStreamView + + match = resolve(reverse("session_stream")) + assert match.func.view_class is SessionStreamView + + +@pytest.fixture +def logged_in_client(user): + from django.test import Client + + c = Client() + c.force_login(user) + return c diff --git a/tests/unit_tests/sessions/test_views_detail.py b/tests/unit_tests/sessions/test_views_detail.py index a69f68ba9..f9e7e55aa 100644 --- a/tests/unit_tests/sessions/test_views_detail.py +++ b/tests/unit_tests/sessions/test_views_detail.py @@ -204,6 +204,47 @@ def test_detail_in_flight_context(member_client, member_user): assert str(run_done.id) not in resp.context["in_flight_ids"] +@pytest.mark.django_db +def test_poll_transcript_only_for_background_runs(member_client, member_user): + """poll_transcript is True only for non-chat in-flight runs; chat runs manage themselves via AG-UI stream.""" + # Case 1: in-flight CHAT run — poller must NOT engage (chat uses AG-UI stream). + session_chat = _create_session(user=member_user) + chat_run = _create_run(session_chat, trigger_type=SessionOrigin.CHAT, status=RunStatus.RUNNING) + session_chat.active_run_id = chat_run.id + session_chat.save(update_fields=["active_run_id"]) + + with patch("sessions.views.ahydrate_thread", _null_hydration()): + resp = member_client.get(reverse("session_detail", kwargs={"thread_id": session_chat.thread_id})) + + assert resp.context["poll_transcript"] is False, ( + "A live CHAT run should not activate the transcript poller (it streams via AG-UI)" + ) + + # Case 2: in-flight background (API_JOB) run — poller MUST engage. + session_bg = _create_session(user=member_user) + bg_run = _create_run(session_bg, trigger_type=SessionOrigin.API_JOB, status=RunStatus.RUNNING) + session_bg.active_run_id = bg_run.id + session_bg.save(update_fields=["active_run_id"]) + + with patch("sessions.views.ahydrate_thread", _null_hydration()): + resp = member_client.get(reverse("session_detail", kwargs={"thread_id": session_bg.thread_id})) + + assert resp.context["poll_transcript"] is True, ( + "A live background (API_JOB) run should activate the transcript poller" + ) + + # Case 3: all runs are terminal — poller must NOT engage. + session_done = _create_session(user=member_user) + done_run = _create_run(session_done, trigger_type=SessionOrigin.API_JOB, status=RunStatus.SUCCESSFUL) + session_done.active_run_id = done_run.id + session_done.save(update_fields=["active_run_id"]) + + with patch("sessions.views.ahydrate_thread", _null_hydration()): + resp = member_client.get(reverse("session_detail", kwargs={"thread_id": session_done.thread_id})) + + assert resp.context["poll_transcript"] is False, "All-terminal runs should not activate the transcript poller" + + # --------------------------------------------------------------------------- # session_run_download_md # --------------------------------------------------------------------------- From 71beb7f445a18fb5a65546fd324940f86cad00cc Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 7 Jul 2026 23:53:27 +0100 Subject: [PATCH 17/55] feat(sessions): unified nav, dashboard tiles, legacy URL redirects, run form move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace activity/chat URL mounts with permanent redirect patterns (301) via sessions.urls_legacy; LegacyActivityDetailRedirectView resolves Run by pk → session_detail#run- - Move AgentRunCreateView, AgentRunCreateForm, RepoListField, AgentRunFieldsMixin from activity app into sessions app; run-form templates copied to sessions/templates/sessions/ - Switch runs include from activity.urls_runs to sessions.urls_runs (app_name="runs", route name "agent_run_new" preserved) - Sidebar: two items (Activity + Chat) → one "Sessions" item with running-jobs badge; "New chat" CTA → session_new - context_processors: replace "activity"/"chat" SECTION_URL_NAMES with "sessions"; running_jobs_count now queries Run instead of Activity - accounts/views.py dashboard tiles: all segment URLs → session_list - schedules/forms.py: import AgentRunFieldsMixin/RepoListField from sessions.forms (not activity.forms) - Fix all live stragglers: schedules/views.py, schedules/_schedule_row.html, memory/detail.html, notifications/signals.py, dashboard.html - Tests: new test_redirects.py (8 tests); update accounts/sidebar, breadcrumbs, context_processors and schedules tests to use session routes --- daiv/accounts/context_processors.py | 14 ++- .../accounts/templates/accounts/_sidebar.html | 19 +--- .../templates/accounts/dashboard.html | 2 +- daiv/accounts/views.py | 30 +++-- daiv/daiv/urls.py | 7 +- daiv/memory/templates/memory/detail.html | 2 +- daiv/notifications/signals.py | 11 +- daiv/schedules/forms.py | 2 +- .../templates/schedules/_schedule_row.html | 4 +- daiv/schedules/views.py | 6 +- daiv/sessions/forms.py | 97 +++++++++++++++++ daiv/sessions/redirect_views.py | 18 +++ .../templates/sessions/_agent_run_fields.html | 45 ++++++++ .../sessions/_prompt_disclosure.html | 21 ++++ .../templates/sessions/agent_run_form.html | 63 +++++++++++ daiv/sessions/urls_legacy.py | 15 +++ daiv/sessions/urls_runs.py | 7 ++ daiv/sessions/views.py | 103 +++++++++++++++++- tests/unit_tests/accounts/test_breadcrumbs.py | 4 +- .../accounts/test_context_processors.py | 34 ++++-- tests/unit_tests/accounts/test_sidebar.py | 30 +++-- tests/unit_tests/accounts/test_views.py | 54 +++++++++ tests/unit_tests/schedules/test_views.py | 10 +- tests/unit_tests/sessions/conftest.py | 30 +++++ tests/unit_tests/sessions/test_redirects.py | 74 +++++++++++++ 25 files changed, 634 insertions(+), 68 deletions(-) create mode 100644 daiv/sessions/forms.py create mode 100644 daiv/sessions/redirect_views.py create mode 100644 daiv/sessions/templates/sessions/_agent_run_fields.html create mode 100644 daiv/sessions/templates/sessions/_prompt_disclosure.html create mode 100644 daiv/sessions/templates/sessions/agent_run_form.html create mode 100644 daiv/sessions/urls_legacy.py create mode 100644 daiv/sessions/urls_runs.py create mode 100644 tests/unit_tests/sessions/test_redirects.py diff --git a/daiv/accounts/context_processors.py b/daiv/accounts/context_processors.py index db604a5dd..025c34207 100644 --- a/daiv/accounts/context_processors.py +++ b/daiv/accounts/context_processors.py @@ -10,8 +10,14 @@ SECTION_URL_NAMES: dict[str, set[str]] = { "dashboard": {"dashboard"}, - "activity": {"activity_list", "activity_detail", "activity_stream", "activity_download_md", "agent_run_new"}, - "chat": {"chat_list", "chat_new", "chat_detail"}, + "sessions": { + "session_list", + "session_new", + "session_detail", + "session_stream", + "session_run_download_md", + "agent_run_new", + }, "schedules": { "schedule_list", "schedule_create", @@ -68,10 +74,10 @@ def running_jobs_count(request, user) -> int: if cached is not None: return cached - from activity.models import Activity, ActivityStatus # local import to avoid circulars + from sessions.models import Run, RunStatus # local import to avoid circulars try: - running = Activity.objects.by_owner(user).filter(status=ActivityStatus.RUNNING).count() + running = Run.objects.by_owner(user).filter(status=RunStatus.RUNNING).count() except DatabaseError: logger.exception("Failed to compute nav_running_jobs for user %s", user.pk) running = 0 diff --git a/daiv/accounts/templates/accounts/_sidebar.html b/daiv/accounts/templates/accounts/_sidebar.html index 4ed07b9f3..c324175a4 100644 --- a/daiv/accounts/templates/accounts/_sidebar.html +++ b/daiv/accounts/templates/accounts/_sidebar.html @@ -9,7 +9,7 @@
{# Primary action — promoted out of the nav list #} - @@ -29,11 +29,11 @@ {% translate "Dashboard" %} - - - {% icon "bolt" "h-4 w-4" %} - {% translate "Activity" %} + + + {% icon "chat-bubble" "h-4 w-4" %} + {% translate "Sessions" %} {% if nav_running_jobs %} @@ -42,13 +42,6 @@ {% endif %} - - - {% icon "chat-bubble" "h-4 w-4" %} - {% translate "Chat" %} - - diff --git a/daiv/accounts/templates/accounts/dashboard.html b/daiv/accounts/templates/accounts/dashboard.html index 0a83cc979..ad9538072 100644 --- a/daiv/accounts/templates/accounts/dashboard.html +++ b/daiv/accounts/templates/accounts/dashboard.html @@ -32,7 +32,7 @@

Dashboard

Agent Activity

- View all → + View all →
diff --git a/daiv/accounts/views.py b/daiv/accounts/views.py index 1e50828ff..3097cf986 100644 --- a/daiv/accounts/views.py +++ b/daiv/accounts/views.py @@ -144,6 +144,7 @@ def _get_activity_data(self, cutoff_date: date | None, user: User) -> dict: mcp_trigger = Q(trigger_type=SessionOrigin.MCP_JOB) schedule_trigger = Q(trigger_type=SessionOrigin.SCHEDULE) api_trigger = Q(trigger_type=SessionOrigin.API_JOB) + chat_trigger = Q(trigger_type=SessionOrigin.CHAT) duration_expr = ExpressionWrapper(F("finished_at") - F("started_at"), output_field=DurationField()) stats = activities.aggregate( @@ -155,6 +156,7 @@ def _get_activity_data(self, cutoff_date: date | None, user: User) -> dict: mcp_jobs=Count("id", filter=mcp_trigger & ~failed), scheduled=Count("id", filter=schedule_trigger & ~failed), api_jobs=Count("id", filter=api_trigger & ~failed), + chat_jobs=Count("id", filter=chat_trigger & ~failed), code_changes=Count("id", filter=successful & Q(code_changes=True)), avg_duration=Avg(duration_expr, filter=successful), ) @@ -172,22 +174,32 @@ def _get_activity_data(self, cutoff_date: date | None, user: User) -> dict: mcp_jobs_count = stats["mcp_jobs"] scheduled_count = stats["scheduled"] api_jobs_count = stats["api_jobs"] - activity_url = reverse("activity_list") + chat_jobs_count = stats["chat_jobs"] + sessions_url = reverse("session_list") # Non-overlapping segments for the breakdown bar. # Trigger types are mutually exclusive, so the trigger-keyed segments never overlap. # Each segment excludes failed; "Other" absorbs the remainder (e.g. UI jobs). other_count = max( - 0, total - issues_count - mrs_count - mcp_jobs_count - scheduled_count - api_jobs_count - failed_count + 0, + total + - issues_count + - mrs_count + - mcp_jobs_count + - scheduled_count + - api_jobs_count + - chat_jobs_count + - failed_count, ) raw_segments = [ - ("Issues", issues_count, "bg-amber-500/50", f"{activity_url}?trigger={SessionOrigin.ISSUE_WEBHOOK}"), - ("MR/PR", mrs_count, "bg-cyan-500/50", f"{activity_url}?trigger={SessionOrigin.MR_WEBHOOK}"), - ("MCP Job", mcp_jobs_count, "bg-indigo-500/50", f"{activity_url}?trigger={SessionOrigin.MCP_JOB}"), - ("Scheduled", scheduled_count, "bg-violet-500/40", f"{activity_url}?trigger={SessionOrigin.SCHEDULE}"), - ("API", api_jobs_count, "bg-emerald-500/50", f"{activity_url}?trigger={SessionOrigin.API_JOB}"), + ("Issues", issues_count, "bg-amber-500/50", f"{sessions_url}?trigger={SessionOrigin.ISSUE_WEBHOOK}"), + ("MR/PR", mrs_count, "bg-cyan-500/50", f"{sessions_url}?trigger={SessionOrigin.MR_WEBHOOK}"), + ("MCP Job", mcp_jobs_count, "bg-indigo-500/50", f"{sessions_url}?trigger={SessionOrigin.MCP_JOB}"), + ("Scheduled", scheduled_count, "bg-violet-500/40", f"{sessions_url}?trigger={SessionOrigin.SCHEDULE}"), + ("API", api_jobs_count, "bg-emerald-500/50", f"{sessions_url}?trigger={SessionOrigin.API_JOB}"), + ("Chat", chat_jobs_count, "bg-sky-500/40", f"{sessions_url}?trigger={SessionOrigin.CHAT}"), ("Other", other_count, "bg-gray-500/30", None), - ("Failed", failed_count, "bg-red-500/40", f"{activity_url}?status={RunStatus.FAILED}"), + ("Failed", failed_count, "bg-red-500/40", f"{sessions_url}?status={RunStatus.FAILED}"), ] segments = [] for label, value, css, url in raw_segments: @@ -212,7 +224,7 @@ def _get_activity_data(self, cutoff_date: date | None, user: User) -> dict: "code_changes": code_changes_count, "code_changes_pct": _format_pct(code_changes_count, successful_count), "avg_duration": _format_duration(stats["avg_duration"]), - "activity_url": activity_url, + "activity_url": sessions_url, "segments": segments, } diff --git a/daiv/daiv/urls.py b/daiv/daiv/urls.py index cdee82264..27a6912b3 100644 --- a/daiv/daiv/urls.py +++ b/daiv/daiv/urls.py @@ -3,6 +3,7 @@ from django.urls import include, path, reverse from mcp_server.oauth import oauth_metadata +from sessions.urls_legacy import legacy_activity_urlpatterns, legacy_chat_urlpatterns from accounts.views import homepage from core.views import HealthCheckView @@ -27,10 +28,10 @@ def location(self, item): path("accounts/users/", include("accounts.urls.users")), path("dashboard/", include("accounts.urls.dashboard")), path("dashboard/configuration/", include("core.urls.configuration")), - path("dashboard/activity/", include("activity.urls")), + path("dashboard/activity/", include(legacy_activity_urlpatterns)), path("dashboard/sessions/", include("sessions.urls")), - path("dashboard/chat/", include("chat.urls")), - path("dashboard/runs/", include("activity.urls_runs", namespace="runs")), + path("dashboard/chat/", include(legacy_chat_urlpatterns)), + path("dashboard/runs/", include("sessions.urls_runs", namespace="runs")), path("dashboard/notifications/", include("notifications.urls")), path("dashboard/sandbox-envs/", include("sandbox_envs.urls", namespace="sandbox_envs")), path("dashboard/schedules/", include("schedules.urls")), diff --git a/daiv/memory/templates/memory/detail.html b/daiv/memory/templates/memory/detail.html index a3e1a17e1..18ae7fb78 100644 --- a/daiv/memory/templates/memory/detail.html +++ b/daiv/memory/templates/memory/detail.html @@ -74,7 +74,7 @@

{% translate "Observations" %}

{{ obs.content }}

{% if obs.run %} - {% translate "View source run" %} {% endif %} diff --git a/daiv/notifications/signals.py b/daiv/notifications/signals.py index ff6c13332..5ce5aa2fd 100644 --- a/daiv/notifications/signals.py +++ b/daiv/notifications/signals.py @@ -126,7 +126,7 @@ def on_activity_finished(sender, activity: Activity, **kwargs) -> None: channels = [cls.channel_type for cls in enabled_channels()] if _status_matches(effective, activity.status) else [] subject, body, context = _render_payload(activity) - link_url = reverse("activity_detail", args=[activity.pk]) + link_url = reverse("session_list") event_type = EventType.SCHEDULE_FINISHED if _is_schedule(activity) else EventType.JOB_FINISHED for recipient in recipients.values(): @@ -197,7 +197,7 @@ def _handle_batch_completion(activity: Activity, siblings, total: int) -> None: "cost_usd": float(agg["total_cost_usd"]) if agg["total_cost_usd"] is not None else None, } subject, body, context = _render_batch_payload(activity, rows, total, successful, failed, agg_status, usage) - link_url = f"{reverse('activity_list')}?batch={activity.batch_id}" + link_url = f"{reverse('session_list')}?batch={activity.batch_id}" for recipient in recipients.values(): try: @@ -453,8 +453,7 @@ def _handle_batch_completion_run(run, siblings, total: int) -> None: "cost_usd": float(agg["total_cost_usd"]) if agg["total_cost_usd"] is not None else None, } subject, body, context = _render_batch_payload_run(run, rows, total, successful, failed, agg_status, usage) - # Task 14 will add sessions list/detail URLs; fall back to activity_list for now. - link_url = f"{reverse('activity_list')}?batch={run.batch_id}" + link_url = f"{reverse('session_list')}?batch={run.batch_id}" for recipient in recipients.values(): try: @@ -586,9 +585,7 @@ def on_run_finished(sender, run, **kwargs) -> None: ) subject, body, context = _render_payload_run(run) - # Task 14 will add the sessions:detail URL; for now fall back to activity_detail - # (Run.pk == Activity.pk so the link resolves to the same run row). - link_url = reverse("activity_detail", args=[run.pk]) + link_url = reverse("session_detail", kwargs={"thread_id": run.session_id}) event_type = EventType.SCHEDULE_FINISHED if _is_schedule_run(run) else EventType.JOB_FINISHED for recipient in recipients.values(): diff --git a/daiv/schedules/forms.py b/daiv/schedules/forms.py index 1481e081a..790f81892 100644 --- a/daiv/schedules/forms.py +++ b/daiv/schedules/forms.py @@ -1,6 +1,6 @@ from django import forms -from activity.forms import AgentRunFieldsMixin, RepoListField +from sessions.forms import AgentRunFieldsMixin, RepoListField from accounts.models import User from schedules.models import ScheduledJob, ScheduleTemplate diff --git a/daiv/schedules/templates/schedules/_schedule_row.html b/daiv/schedules/templates/schedules/_schedule_row.html index 070596ac0..b76c20164 100644 --- a/daiv/schedules/templates/schedules/_schedule_row.html +++ b/daiv/schedules/templates/schedules/_schedule_row.html @@ -7,7 +7,7 @@ class="absolute inset-0 rounded-2xl focus:outline-none focus-visible:ring-2 focus-visible:ring-white/20" aria-label="Edit {{ schedule.name }}"> {% else %} - {% endif %} @@ -98,7 +98,7 @@ {% if schedule.last_run_at %}Last run {{ schedule.last_run_at|timesince }} ago{% endif %} {% if schedule.last_run_at and schedule.run_count %}·{% endif %} {% if schedule.run_count %} - {{ schedule.run_count }} run{{ schedule.run_count|pluralize }} diff --git a/daiv/schedules/views.py b/daiv/schedules/views.py index 4f526a61b..acb56afb9 100644 --- a/daiv/schedules/views.py +++ b/daiv/schedules/views.py @@ -251,9 +251,9 @@ def post(self, request, pk): messages.success(request, f"Schedule '{schedule.name}' triggered successfully.") if len(result.runs) == 1 and not result.failed: - return redirect("activity_detail", pk=result.runs[0].pk) + return redirect("session_detail", thread_id=result.runs[0].session_id) if result.runs: - return redirect(reverse("activity_list") + f"?batch={result.batch_id}") + return redirect(reverse("session_list") + f"?batch={result.batch_id}") return redirect("schedule_list") @@ -271,7 +271,7 @@ def post(self, request, pk): next_url = request.POST.get("next", "") if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}): return redirect(next_url) - return redirect("activity_list") + return redirect("session_list") class ScheduleDeleteView(BreadcrumbMixin, _ScheduleOwnerMixin, SuccessMessageMixin, LoginRequiredMixin, DeleteView): diff --git a/daiv/sessions/forms.py b/daiv/sessions/forms.py new file mode 100644 index 000000000..04e5f1d62 --- /dev/null +++ b/daiv/sessions/forms.py @@ -0,0 +1,97 @@ +"""Shared form fields for any surface that submits an agent run. + +The prompt-box UI emits a single ``repos`` hidden input containing a JSON +list of ``{repo_id, ref}`` entries. :class:`RepoListField` parses and validates +it so ``cleaned_data["repos"]`` is a list of dicts; the caller converts to +``RepoTarget`` and hands off to :func:`sessions.services.submit_batch_runs`. +""" + +from __future__ import annotations + +from django import forms +from django.utils.translation import gettext_lazy as _ + +from notifications.choices import NotifyOn +from sandbox_envs.models import SandboxEnvironment + +from automation.agent.validators import AgentOverrideError, ensure_agent_model_available, validate_agent_override +from core.models import ThinkingLevelChoices +from sessions.services import validate_repo_list + + +class RepoListField(forms.JSONField): + """Form field for a JSON-encoded ``[{"repo_id", "ref"}, ...]`` hidden input. + + With ``required=False`` an *exactly-empty* list bypasses ``validate_repo_list`` + (which enforces a 1-entry minimum) — used by schedule templates where empty + means "let users choose". Other falsy or malformed shapes still fall through + to validation so the user sees an explicit error rather than a silent reset. + """ + + widget = forms.HiddenInput + default_error_messages = {"invalid": _("Malformed repository list.")} + + def to_python(self, value): + parsed = super().to_python(value) + if parsed is None: + return None + if parsed == [] and not self.required: + return [] + try: + return validate_repo_list(parsed) + except ValueError as err: + raise forms.ValidationError(str(err)) from err + + def prepare_value(self, value): + # Widget value is embedded verbatim into Alpine's initialRepos; empty must serialize as "[]", not "null". + if value in (None, []): + return "[]" + return super().prepare_value(value) + + +class AgentRunFieldsMixin(forms.Form): + prompt = forms.CharField(label=_("Prompt"), required=True) + repos = RepoListField(required=True) + agent_model = forms.CharField( + label=_("Agent model"), + required=False, + empty_value="", + help_text=_("Override the configured model for this run."), + ) + agent_thinking_level = forms.ChoiceField( + label=_("Thinking effort"), choices=[("", "")] + list(ThinkingLevelChoices.choices), required=False + ) + notify_on = forms.ChoiceField(label=_("Notify me"), choices=NotifyOn.choices, required=True) + sandbox_environment = forms.ModelChoiceField( + # Queryset is scoped to the caller in ``__init__``; an empty default avoids + # leaking other users' USER-scoped envs if a subclass forgets to pass ``user``. + queryset=SandboxEnvironment.objects.none(), + required=False, + empty_label=_("(global default)"), + label=_("Sandbox environment"), + ) + + def __init__(self, *args, user=None, **kwargs): + super().__init__(*args, **kwargs) + if "sandbox_environment" in self.fields and user is not None: + self.fields["sandbox_environment"].queryset = SandboxEnvironment.objects.visible_to(user) + + def clean(self): + cleaned = super().clean() or {} + try: + cleaned["agent_model"], cleaned["agent_thinking_level"] = validate_agent_override( + cleaned.get("agent_model"), cleaned.get("agent_thinking_level") + ) + # Server-side backstop for the picker's HTML5 ``required`` — if the + # client-side gate is bypassed (curl, scripted submit, a stale page + # cached when a system default still existed), surface the same error + # as a form error instead of letting the run enqueue and explode at + # ``get_daiv_agent_kwargs`` time. + ensure_agent_model_available(cleaned["agent_model"]) + except AgentOverrideError as err: + self.add_error("agent_model", str(err)) + return cleaned + + +class AgentRunCreateForm(AgentRunFieldsMixin, forms.Form): + """Validate 'Start a run' submissions. Orchestration lives in ``sessions.services``.""" diff --git a/daiv/sessions/redirect_views.py b/daiv/sessions/redirect_views.py new file mode 100644 index 000000000..5b50854a3 --- /dev/null +++ b/daiv/sessions/redirect_views.py @@ -0,0 +1,18 @@ +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import Http404, HttpResponsePermanentRedirect +from django.urls import reverse +from django.views import View + +from sessions.models import Run + + +class LegacyActivityDetailRedirectView(LoginRequiredMixin, View): + """Old /dashboard/activity// links resolve the Run (same UUID as the old + Activity) and land on its session, anchored to the run card.""" + + def get(self, request, pk): + run = Run.objects.by_owner(request.user).filter(pk=pk).first() + if run is None: + raise Http404 + url = reverse("session_detail", kwargs={"thread_id": run.session_id}) + f"#run-{run.pk}" + return HttpResponsePermanentRedirect(url) diff --git a/daiv/sessions/templates/sessions/_agent_run_fields.html b/daiv/sessions/templates/sessions/_agent_run_fields.html new file mode 100644 index 000000000..a5c39ab8c --- /dev/null +++ b/daiv/sessions/templates/sessions/_agent_run_fields.html @@ -0,0 +1,45 @@ +{% load i18n %} +{% translate "Repository already in the list: __LABEL__." as conflict_message_template %} + +
+ + + + + +
+ +
+ +
+ {% if with_env_picker %} + {% include "sandbox_envs/_env_picker.html" with envs=env_picker_envs selected_id=env_picker_selected_id field_name="sandbox_environment" %} + {% endif %} + {% include "automation/_agent_picker.html" with providers=agent_picker_providers initial_agent_model=agent_picker_initial_model initial_model_display=agent_picker_initial_model_display initial_thinking_level=agent_picker_initial_thinking stale=agent_picker_stale_model default_model=agent_picker_default_model default_thinking=agent_picker_default_thinking %} + {% include "codebase/_repo_picker.html" with with_x_data=False initial_repos=form.repos.value|default:"[]" max_repos=20 show_conflict_message=False required=repos_required %} +
+
+ +
    + {% for err in form.prompt.errors %}
  • {{ err }}
  • {% endfor %} + {% for err in form.repos.errors %}
  • {{ err }}
  • {% endfor %} + {% for err in form.agent_model.errors %}
  • {{ err }}
  • {% endfor %} + {% for err in form.agent_thinking_level.errors %}
  • {{ err }}
  • {% endfor %} +
  • +
+
diff --git a/daiv/sessions/templates/sessions/_prompt_disclosure.html b/daiv/sessions/templates/sessions/_prompt_disclosure.html new file mode 100644 index 000000000..7b3171bad --- /dev/null +++ b/daiv/sessions/templates/sessions/_prompt_disclosure.html @@ -0,0 +1,21 @@ +{% load activity_tags markdown_tags %} +{% if activity.prompt %} +{{ activity.prompt|json_script:"activity-prompt-raw" }} +
+ +
+ + Prompt + {% with tokens=activity.prompt|approx_prompt_tokens %} + {% if tokens %} + — ≈ {{ tokens|format_tokens }} tokens + {% endif %} + {% endwith %} +
+ {% include "activity/_copy_markdown_button.html" with source_id="activity-prompt-raw" label="Copy prompt" %} +
+
+ {{ activity.prompt|render_markdown }} +
+
+{% endif %} diff --git a/daiv/sessions/templates/sessions/agent_run_form.html b/daiv/sessions/templates/sessions/agent_run_form.html new file mode 100644 index 000000000..033c2a8e8 --- /dev/null +++ b/daiv/sessions/templates/sessions/agent_run_form.html @@ -0,0 +1,63 @@ +{% extends "base_app.html" %} +{% load i18n static %} + +{% block title %}{% if source_run %}Retry run{% else %}Start a run{% endif %} — DAIV{% endblock %} + +{% block container_width %}max-w-3xl{% endblock %} + +{% block alpine_plugins %} + + +{% include "sandbox_envs/_scripts.html" %} +{% endblock alpine_plugins %} + +{% block breadcrumb %} +{% include "accounts/_breadcrumb.html" with crumbs=breadcrumbs %} +{% endblock breadcrumb %} + +{% block app_content %} +
+

+ {% if source_run %}Retry run{% else %}Start a run{% endif %} +

+

+ {% if source_run %} + Retried from + + {{ source_run.repo_id }} · {{ source_run.created_at|date:"Y-m-d H:i" }} + + {% else %} + Launch a new agent run on a repository. + {% endif %} +

+
+ +{% if form.non_field_errors %} +
+ {% for error in form.non_field_errors %} +

{{ error }}

+ {% endfor %} +
+{% endif %} + +
+ {% csrf_token %} + +
+ {% include "sessions/_agent_run_fields.html" with form=form with_env_picker=True env_picker_envs=sandbox_envs env_picker_selected_id=selected_sandbox_env_id %} +
+ + {% include "notifications/_notify_on_radio.html" with form=form %} + +
+ + + Cancel + +
+
+{% include "sandbox_envs/_env_drawer.html" %} +{% endblock app_content %} diff --git a/daiv/sessions/urls_legacy.py b/daiv/sessions/urls_legacy.py new file mode 100644 index 000000000..9b7d71d3d --- /dev/null +++ b/daiv/sessions/urls_legacy.py @@ -0,0 +1,15 @@ +from django.urls import path +from django.views.generic import RedirectView + +from sessions.redirect_views import LegacyActivityDetailRedirectView + +legacy_activity_urlpatterns = [ + path("", RedirectView.as_view(pattern_name="session_list", permanent=True)), + path("/", LegacyActivityDetailRedirectView.as_view()), +] + +legacy_chat_urlpatterns = [ + path("", RedirectView.as_view(pattern_name="session_list", permanent=True)), + path("new/", RedirectView.as_view(pattern_name="session_new", permanent=True)), + path("/", RedirectView.as_view(pattern_name="session_detail", permanent=True)), +] diff --git a/daiv/sessions/urls_runs.py b/daiv/sessions/urls_runs.py new file mode 100644 index 000000000..5ea7a1876 --- /dev/null +++ b/daiv/sessions/urls_runs.py @@ -0,0 +1,7 @@ +from django.urls import path + +from sessions.views import AgentRunCreateView + +app_name = "runs" + +urlpatterns = [path("new/", AgentRunCreateView.as_view(), name="agent_run_new")] diff --git a/daiv/sessions/views.py b/daiv/sessions/views.py index b34c585c2..4585e906c 100644 --- a/daiv/sessions/views.py +++ b/daiv/sessions/views.py @@ -2,20 +2,26 @@ import asyncio import json +import logging import time import uuid from typing import TYPE_CHECKING, Any +from django.contrib import messages as messages_module from django.contrib.auth.mixins import LoginRequiredMixin +from django.core.exceptions import PermissionDenied, SuspiciousOperation, ValidationError from django.http import Http404, HttpResponse, HttpResponseBase, StreamingHttpResponse +from django.shortcuts import redirect from django.urls import reverse from django.utils.text import slugify +from django.utils.translation import gettext_lazy as _ from django.views import View -from django.views.generic import DetailView +from django.views.generic import DetailView, FormView from asgiref.sync import async_to_sync from django_filters.views import FilterView from sandbox_envs.models import SandboxEnvironment +from sandbox_envs.services import env_picker_context, resolve_repo_envs from accounts.mixins import BreadcrumbMixin from automation.agent.picker_context import agent_picker_context @@ -23,8 +29,12 @@ from chat.turns import build_turns from schedules.models import ScheduledJob from sessions.filters import SessionFilter +from sessions.forms import AgentRunCreateForm from sessions.hydration import ahydrate_thread from sessions.models import Run, RunStatus, Session, SessionOrigin +from sessions.services import RepoTarget, submit_batch_runs + +logger = logging.getLogger("daiv.sessions") if TYPE_CHECKING: from django.db.models import QuerySet @@ -295,3 +305,94 @@ def _build_filename(self, run: Run) -> str: repo_slug = slugify(run.repo_id.replace("/", "-")) or "unknown" date_str = run.created_at.strftime("%Y-%m-%d") return f"daiv-{repo_slug}-{date_str}.md" + + +class AgentRunCreateView(LoginRequiredMixin, BreadcrumbMixin, FormView): + """Serve the "Start a run" page and submit new UI-initiated agent runs. + + ``GET /runs/new/`` renders a blank form. ``GET /runs/new/?from=`` + pre-fills the form from a retryable source Run. ``POST`` enqueues + ``run_job_task`` and creates a Run, redirecting to the session detail page. + """ + + template_name = "sessions/agent_run_form.html" + form_class = AgentRunCreateForm + + _SOURCE_UNSET = object() + + def _get_source_run(self) -> Run | None: + # Memoize per-request: ``get_initial`` and ``get_context_data`` both call this on retry GETs. + cached = getattr(self, "_source_cached", self._SOURCE_UNSET) + if cached is not self._SOURCE_UNSET: + return cached + source_id = self.request.GET.get("from") + if not source_id: + self._source_cached = None + return None + try: + source = Run.objects.by_owner(self.request.user).filter(pk=source_id).first() + except (ValueError, ValidationError) as err: + raise Http404("Invalid run id.") from err + self._source_cached = source + return source + + def get_initial(self) -> dict: + initial: dict = {"notify_on": self.request.user.notify_on_jobs} + source = self._get_source_run() + if source is not None: + initial.update({ + "prompt": source.prompt, + "repos": [{"repo_id": source.repo_id, "ref": source.ref}], + "agent_model": source.agent_model, + "agent_thinking_level": source.agent_thinking_level, + }) + return initial + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx["source_run"] = self._get_source_run() + ctx.update(env_picker_context(ctx["form"])) + ctx.update(agent_picker_context(ctx["form"])) + return ctx + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["user"] = self.request.user + return kwargs + + def form_valid(self, form): + repos = [RepoTarget(repo_id=r["repo_id"], ref=r["ref"]) for r in form.cleaned_data["repos"]] + env = form.cleaned_data.get("sandbox_environment") + repos = resolve_repo_envs(user=self.request.user, repos=repos, explicit_env_id=str(env.id) if env else None) + try: + result = submit_batch_runs( + user=self.request.user, + prompt=form.cleaned_data["prompt"], + repos=repos, + agent_model=form.cleaned_data["agent_model"], + agent_thinking_level=form.cleaned_data["agent_thinking_level"], + notify_on=form.cleaned_data["notify_on"], + trigger_type=SessionOrigin.UI_JOB, + ) + except Http404, PermissionDenied, SuspiciousOperation: + raise + except Exception: + logger.exception( + "Failed to submit UI run", + extra={"user_pk": self.request.user.pk, "repos": form.cleaned_data.get("repos")}, + ) + form.add_error(None, _("Failed to submit the run. Please try again in a moment.")) + return self.form_invalid(form) + + if result.failed: + failed_ids = ", ".join(f.repo_id for f in result.failed) + messages_module.warning( + self.request, _("Some repositories failed to submit: %(ids)s") % {"ids": failed_ids} + ) + + if len(result.runs) == 1 and not result.failed: + return redirect("session_detail", thread_id=result.runs[0].session_id) + return redirect(reverse("session_list") + f"?batch={result.batch_id}") + + def get_breadcrumbs(self): + return [{"label": "Sessions", "url": reverse("session_list")}, {"label": "Start a run", "url": None}] diff --git a/tests/unit_tests/accounts/test_breadcrumbs.py b/tests/unit_tests/accounts/test_breadcrumbs.py index ee8850daf..039fda4ce 100644 --- a/tests/unit_tests/accounts/test_breadcrumbs.py +++ b/tests/unit_tests/accounts/test_breadcrumbs.py @@ -24,8 +24,8 @@ def _client(user): @pytest.mark.django_db class TestBreadcrumbs: - def test_activity_list_has_no_breadcrumb(self, admin): - response = _client(admin).get(reverse("activity_list")) + def test_session_list_has_no_breadcrumb(self, admin): + response = _client(admin).get(reverse("session_list")) assert b'data-testid="app-breadcrumb"' not in response.content def test_schedule_create_breadcrumb(self, admin): diff --git a/tests/unit_tests/accounts/test_context_processors.py b/tests/unit_tests/accounts/test_context_processors.py index 733ed8dcf..7557a0448 100644 --- a/tests/unit_tests/accounts/test_context_processors.py +++ b/tests/unit_tests/accounts/test_context_processors.py @@ -4,7 +4,7 @@ from django.urls import reverse import pytest -from activity.models import Activity, ActivityStatus, TriggerType +from sessions.models import Run, RunStatus, SessionOrigin from accounts.context_processors import _resolve_active_section, nav, running_jobs_count from accounts.models import User @@ -31,15 +31,31 @@ def test_returns_zero_running_jobs_when_none(self, user): assert out["nav_active_section"] == "" def test_counts_only_running_jobs_owned_by_user(self, user, db): - Activity.objects.create( - status=ActivityStatus.RUNNING, trigger_type=TriggerType.MCP_JOB, user=user, repo_id="daiv/api" + from sessions.models import Session + + session = Session.objects.create( + thread_id="test-thread-1", origin=SessionOrigin.MCP_JOB, repo_id="daiv/api", user=user + ) + Run.objects.create( + session=session, status=RunStatus.RUNNING, trigger_type=SessionOrigin.MCP_JOB, repo_id="daiv/api", user=user ) - Activity.objects.create( - status=ActivityStatus.SUCCESSFUL, trigger_type=TriggerType.MCP_JOB, user=user, repo_id="daiv/api" + Run.objects.create( + session=session, + status=RunStatus.SUCCESSFUL, + trigger_type=SessionOrigin.MCP_JOB, + repo_id="daiv/api", + user=user, ) other = User.objects.create_user(username="bob", email="bob@test.com", password="x123456789") # noqa: S106 - Activity.objects.create( - status=ActivityStatus.RUNNING, trigger_type=TriggerType.MCP_JOB, user=other, repo_id="daiv/api" + session2 = Session.objects.create( + thread_id="test-thread-2", origin=SessionOrigin.MCP_JOB, repo_id="daiv/api", user=other + ) + Run.objects.create( + session=session2, + status=RunStatus.RUNNING, + trigger_type=SessionOrigin.MCP_JOB, + repo_id="daiv/api", + user=other, ) request = RequestFactory().get("/dashboard/") @@ -59,7 +75,7 @@ def test_running_jobs_falls_back_to_zero_on_database_error(self, user, mocker): # A transient DB failure should log-and-degrade the badge rather than crash rendering. failing_qs = mocker.MagicMock() failing_qs.filter.return_value.count.side_effect = DatabaseError("connection lost") - mocker.patch("activity.models.ActivityManager.by_owner", return_value=failing_qs) + mocker.patch("sessions.models.RunManager.by_owner", return_value=failing_qs) request = RequestFactory().get("/dashboard/") request.user = user assert running_jobs_count(request, user) == 0 @@ -70,7 +86,7 @@ def test_running_jobs_memoizes_on_request(self, user, db, mocker): request.user = user assert running_jobs_count(request, user) == 0 - spy = mocker.patch("activity.models.ActivityManager.by_owner") + spy = mocker.patch("sessions.models.RunManager.by_owner") assert running_jobs_count(request, user) == 0 spy.assert_not_called() diff --git a/tests/unit_tests/accounts/test_sidebar.py b/tests/unit_tests/accounts/test_sidebar.py index 2a4165501..305dc7659 100644 --- a/tests/unit_tests/accounts/test_sidebar.py +++ b/tests/unit_tests/accounts/test_sidebar.py @@ -1,8 +1,10 @@ +import uuid + from django.test import Client from django.urls import reverse import pytest -from activity.models import Activity, ActivityStatus, TriggerType +from sessions.models import Run, RunStatus, Session, SessionOrigin from accounts.models import Role, User @@ -34,7 +36,7 @@ class TestSidebarSmoke: "url_name,kwargs_fn", [ ("dashboard", lambda u: {}), - ("activity_list", lambda u: {}), + ("session_list", lambda u: {}), ("schedule_list", lambda u: {}), ("sandbox_envs:list", lambda u: {}), ("user_channels", lambda u: {}), @@ -68,11 +70,25 @@ def test_no_badge_when_zero_running(self, member): assert b'data-testid="nav-running-badge"' not in response.content def test_badge_shows_count_when_running(self, member): - Activity.objects.create( - status=ActivityStatus.RUNNING, trigger_type=TriggerType.MCP_JOB, user=member, repo_id="daiv/api" + session1 = Session.objects.create( + thread_id=str(uuid.uuid4()), origin=SessionOrigin.UI_JOB, repo_id="daiv/api", user=member + ) + session2 = Session.objects.create( + thread_id=str(uuid.uuid4()), origin=SessionOrigin.UI_JOB, repo_id="daiv/api2", user=member + ) + Run.objects.create( + session=session1, + status=RunStatus.RUNNING, + trigger_type=SessionOrigin.UI_JOB, + repo_id="daiv/api", + user=member, ) - Activity.objects.create( - status=ActivityStatus.RUNNING, trigger_type=TriggerType.MCP_JOB, user=member, repo_id="daiv/api" + Run.objects.create( + session=session2, + status=RunStatus.RUNNING, + trigger_type=SessionOrigin.UI_JOB, + repo_id="daiv/api2", + user=member, ) response = _client(member).get(reverse("dashboard")) assert b'data-testid="nav-running-badge"' in response.content @@ -88,7 +104,7 @@ class TestNavActiveState: "url_name,expected_section", [ ("dashboard", "dashboard"), - ("activity_list", "activity"), + ("session_list", "sessions"), ("schedule_list", "schedules"), ("sandbox_envs:list", "sandbox_envs"), ("user_channels", "channels"), diff --git a/tests/unit_tests/accounts/test_views.py b/tests/unit_tests/accounts/test_views.py index f9739d2c2..10e2f88ed 100644 --- a/tests/unit_tests/accounts/test_views.py +++ b/tests/unit_tests/accounts/test_views.py @@ -1,3 +1,4 @@ +import uuid from unittest.mock import patch from django.contrib.messages import get_messages @@ -6,6 +7,7 @@ from django.urls import reverse import pytest +from sessions.models import Run, RunStatus, Session, SessionOrigin from accounts.models import APIKey, Role, User @@ -330,3 +332,55 @@ def test_cannot_delete_last_admin(self, admin_user): # an admin tries to delete themselves (covered by test_cannot_delete_self). response = client.post(reverse("user_delete", kwargs={"pk": admin_user.pk})) assert response.status_code == 403 + + +@pytest.mark.django_db +class TestDashboardChatSegment: + """Dashboard breakdown bar must include a Chat tile (not silently absorb it into Other).""" + + def _make_run(self, user, trigger_type, status=RunStatus.SUCCESSFUL): + session = Session.objects.create( + thread_id=str(uuid.uuid4()), origin=trigger_type, repo_id="daiv/test", user=user + ) + return Run.objects.create( + session=session, status=status, trigger_type=trigger_type, repo_id="daiv/test", user=user + ) + + def test_chat_segment_appears_with_correct_count_and_url(self, user): + self._make_run(user, SessionOrigin.CHAT) + self._make_run(user, SessionOrigin.CHAT) + self._make_run(user, SessionOrigin.API_JOB) + + client = Client() + client.force_login(user) + response = client.get(reverse("dashboard")) + assert response.status_code == 200 + + segments = response.context["activity"]["segments"] + labels = [s["label"] for s in segments] + assert "Chat" in labels + + chat_seg = next(s for s in segments if s["label"] == "Chat") + assert chat_seg["value"] == 2 + sessions_url = reverse("session_list") + assert chat_seg["url"] == f"{sessions_url}?trigger={SessionOrigin.CHAT}" + + def test_chat_runs_not_double_counted_in_other(self, user): + # 1 UI_JOB run → goes to Other (not a named trigger segment) + # 2 CHAT runs → go to Chat segment, NOT Other + self._make_run(user, SessionOrigin.UI_JOB) + self._make_run(user, SessionOrigin.CHAT) + self._make_run(user, SessionOrigin.CHAT) + + client = Client() + client.force_login(user) + response = client.get(reverse("dashboard")) + assert response.status_code == 200 + + segments = response.context["activity"]["segments"] + seg_by_label = {s["label"]: s["value"] for s in segments} + + # Chat counts correctly + assert seg_by_label.get("Chat", 0) == 2 + # Other gets only the UI_JOB run (1), not the chat runs + assert seg_by_label.get("Other", 0) == 1 diff --git a/tests/unit_tests/schedules/test_views.py b/tests/unit_tests/schedules/test_views.py index 976a1958b..82cb902ca 100644 --- a/tests/unit_tests/schedules/test_views.py +++ b/tests/unit_tests/schedules/test_views.py @@ -228,7 +228,7 @@ async def _amake_task_row(): m.id = tid return m - def test_enqueues_single_repo_and_redirects_to_activity_detail(self, member_client, member_user, schedule): + def test_enqueues_single_repo_and_redirects_to_session_detail(self, member_client, member_user, schedule): with mock.patch("sessions.services.run_job_task") as m_task: m_task.aenqueue = mock.AsyncMock(return_value=self._make_task_row()) response = member_client.post(reverse("schedule_run_now", args=[schedule.pk])) @@ -237,7 +237,7 @@ def test_enqueues_single_repo_and_redirects_to_activity_detail(self, member_clie run = Run.objects.get(session__scheduled_job=schedule) assert run.trigger_type == SessionOrigin.SCHEDULE assert run.batch_id is not None - assert response.url == reverse("activity_detail", args=[run.pk]) + assert response.url == reverse("session_detail", kwargs={"thread_id": run.session_id}) def test_multi_repo_redirects_to_batch_filtered_activity_list(self, member_client, member_user, schedule): schedule.repos = [{"repo_id": "a/b", "ref": ""}, {"repo_id": "c/d", "ref": ""}] @@ -267,7 +267,7 @@ def test_works_on_disabled_schedule(self, member_client, schedule): assert response.status_code == 302 run = Run.objects.get(session__scheduled_job=schedule) - assert response.url == reverse("activity_detail", args=[run.pk]) + assert response.url == reverse("session_detail", kwargs={"thread_id": run.session_id}) def test_enqueue_failure_returns_error_message(self, member_client, schedule): with mock.patch("sessions.services.run_job_task") as m_task: @@ -474,7 +474,7 @@ def test_next_redirect_honored_when_safe(self, schedule): assert response.status_code == 302 assert response.url == "/dashboard/activity/" - def test_unsafe_next_falls_back_to_activity_list(self, schedule): + def test_unsafe_next_falls_back_to_session_list(self, schedule): sub = self._subscriber() schedule.subscribers.add(sub) client = Client() @@ -483,7 +483,7 @@ def test_unsafe_next_falls_back_to_activity_list(self, schedule): reverse("schedule_unsubscribe", args=[schedule.pk]), data={"next": "https://evil.example.com/phish"} ) assert response.status_code == 302 - assert response.url == reverse("activity_list") + assert response.url == reverse("session_list") def test_unauthenticated_redirects_to_login(self, schedule): client = Client() diff --git a/tests/unit_tests/sessions/conftest.py b/tests/unit_tests/sessions/conftest.py index 94f813516..aeef743e7 100644 --- a/tests/unit_tests/sessions/conftest.py +++ b/tests/unit_tests/sessions/conftest.py @@ -2,6 +2,9 @@ import pytest from django_tasks_db.models import DBTaskResult, get_date_max +from sessions.models import Run, RunStatus, Session, SessionOrigin + +from accounts.models import User @pytest.fixture @@ -33,3 +36,30 @@ def _create( ) return _create + + +@pytest.fixture +def admin_user(db): + return User.objects.create_user( + username="admin", + email="admin@test.com", + password="testpass123", # noqa: S106 + role="admin", + ) + + +@pytest.fixture +def session_fixture(admin_user): + return Session.objects.create( + thread_id=str(uuid.uuid4()), origin=SessionOrigin.CHAT, repo_id="group/project", ref="main", user=admin_user + ) + + +@pytest.fixture +def run_fixture(session_fixture): + return Run.objects.create( + session=session_fixture, + trigger_type=SessionOrigin.UI_JOB, + repo_id=session_fixture.repo_id, + status=RunStatus.SUCCESSFUL, + ) diff --git a/tests/unit_tests/sessions/test_redirects.py b/tests/unit_tests/sessions/test_redirects.py new file mode 100644 index 000000000..6ac7e6ef0 --- /dev/null +++ b/tests/unit_tests/sessions/test_redirects.py @@ -0,0 +1,74 @@ +"""Tests for legacy activity/chat URL redirects (Task 14). + +All old /dashboard/activity/ and /dashboard/chat/ URLs must return 301 +permanent redirects pointing at the equivalent sessions routes. +""" + +from django.test import Client +from django.urls import reverse + +import pytest + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def client(admin_user): + c = Client() + c.force_login(admin_user) + return c + + +def test_activity_list_redirects(client): + resp = client.get("/dashboard/activity/") + assert resp.status_code == 301 + assert resp["Location"] == reverse("session_list") + + +def test_activity_detail_redirects_to_run_anchor(client, run_fixture): + resp = client.get(f"/dashboard/activity/{run_fixture.id}/") + assert resp.status_code == 301 + expected = reverse("session_detail", kwargs={"thread_id": run_fixture.session_id}) + f"#run-{run_fixture.id}" + assert resp["Location"] == expected + + +def test_activity_detail_unknown_run_returns_404(client): + import uuid + + fake_pk = uuid.uuid4() + resp = client.get(f"/dashboard/activity/{fake_pk}/") + assert resp.status_code == 404 + + +def test_chat_list_redirects(client): + resp = client.get("/dashboard/chat/") + assert resp.status_code == 301 + assert resp["Location"] == reverse("session_list") + + +def test_chat_new_redirects(client): + resp = client.get("/dashboard/chat/new/") + assert resp.status_code == 301 + assert resp["Location"] == reverse("session_new") + + +def test_chat_detail_redirects(client, session_fixture): + resp = client.get(f"/dashboard/chat/{session_fixture.thread_id}/") + assert resp.status_code == 301 + assert resp["Location"] == reverse("session_detail", kwargs={"thread_id": session_fixture.thread_id}) + + +def test_activity_list_requires_login(admin_user): + c = Client() + resp = c.get("/dashboard/activity/") + # RedirectView without login_required still redirects (301 to session_list) + # because RedirectView itself has no auth gate — only the detail view does. + assert resp.status_code == 301 + + +def test_activity_detail_requires_login(run_fixture): + c = Client() + resp = c.get(f"/dashboard/activity/{run_fixture.id}/") + # LegacyActivityDetailRedirectView has LoginRequiredMixin → 302 to login + assert resp.status_code == 302 + assert "login" in resp["Location"].lower() From 7798d363bbe30f1bb2bd0ae8db68f310cbfde23a Mon Sep 17 00:00:00 2001 From: Sandro Date: Wed, 8 Jul 2026 00:23:44 +0100 Subject: [PATCH 18/55] refactor(sessions): drop Activity and ChatThread models; activity/chat become stubs - Empty activity/models.py and chat/models.py to module docstrings; both apps stay in INSTALLED_APPS with migration history intact (other apps' historical migrations reference them) - Delete dead files from activity app: views, urls, urls_runs, filters, forms, services, signals, management commands, static assets, and most templates; keep _agent_run_fields.html and _copy_markdown_button.html (still included by schedules and sessions templates respectively), and the activity_tags templatetag library (still loaded by notification email templates and sessions/_prompt_disclosure.html) with Activity-model-dependent tags removed - Delete dead files from chat app: views, urls, managers, chat_list.html, chat_detail.html; keep api/, turns.py, repo_state.py, static/, templatetags/, and _composer.html (included by sessions/session_detail.html) - Generate DeleteModel migrations for Activity (activity/0016) and ChatThread (chat/0004) with explicit dependencies on agent_sessions.0002 and memory.0002 so tables are never dropped before data is copied - Remove on_activity_finished receiver and all Activity-based helpers from notifications/signals.py; keep on_run_finished and all _run helpers - Drop legacy chat_thread/activity branches from generate_title_task in automation/titling/tasks.py; narrow Literal to ["session", "run"] - Delete all tests/unit_tests/activity/ and dead chat test files (test_models, test_views, test_composer_agent_picker, test_composer_env_select, test_sandbox_env_link); delete sessions/test_data_migration.py which depended on live Activity/ChatThread model access - Update notifications/test_signals.py to remove Activity-based test classes; keep TestUserBindingSeeder and TestOnRunFinished - Update automation/titling/test_tasks.py to use Session/Run entities instead of Activity; preserve all behavioural coverage - Fix global conftest mock_generate_title_task to remove the now-dead patches on activity.services and chat.models --- daiv/activity/apps.py | 3 - daiv/activity/filters.py | 20 - daiv/activity/forms.py | 97 -- daiv/activity/management/__init__.py | 0 daiv/activity/management/commands/__init__.py | 0 .../commands/release_orphan_queued_threads.py | 71 -- .../commands/sync_stuck_activities.py | 37 - ...y_activity_trigger_created_idx_and_more.py | 27 + daiv/activity/models.py | 353 +------- daiv/activity/services.py | 415 --------- daiv/activity/signals.py | 245 ----- .../static/activity/js/activity-stream.js | 87 -- .../activity/static/activity/js/prompt-box.js | 175 ---- daiv/activity/templates/activity/_header.html | 15 - .../templates/activity/_hero_failed.html | 17 - .../templates/activity/_hero_pruned.html | 18 - .../templates/activity/_hero_queued.html | 17 - .../templates/activity/_hero_running.html | 34 - .../templates/activity/_hero_success.html | 43 - .../activity/_prompt_disclosure.html | 21 - .../templates/activity/_rail_context.html | 112 --- .../templates/activity/_rail_timing.html | 45 - .../templates/activity/_rail_usage.html | 51 -- .../templates/activity/_status_pill.html | 10 - .../templates/activity/_status_strip.html | 38 - .../templates/activity/_trigger_badge.html | 9 - .../templates/activity/activity_detail.html | 56 -- .../templates/activity/activity_list.html | 182 ---- .../templates/activity/agent_run_form.html | 63 -- daiv/activity/templatetags/activity_tags.py | 32 +- daiv/activity/urls.py | 10 - daiv/activity/urls_runs.py | 7 - daiv/activity/views.py | 337 ------- daiv/automation/titling/tasks.py | 17 +- daiv/chat/managers.py | 6 - ...chat_chatth_user_id_abfd75_idx_and_more.py | 18 + daiv/chat/models.py | 92 +- daiv/chat/templates/chat/chat_detail.html | 194 ---- daiv/chat/templates/chat/chat_list.html | 48 - daiv/chat/urls.py | 10 - daiv/chat/views.py | 112 --- daiv/notifications/signals.py | 286 ------ tests/unit_tests/activity/__init__.py | 0 tests/unit_tests/activity/conftest.py | 35 - .../test_agent_run_fields_template.py | 95 -- .../unit_tests/activity/test_batch_submit.py | 299 ------- tests/unit_tests/activity/test_filters.py | 153 ---- tests/unit_tests/activity/test_forms.py | 166 ---- .../activity/test_list_activities.py | 95 -- tests/unit_tests/activity/test_management.py | 177 ---- tests/unit_tests/activity/test_models.py | 348 -------- .../unit_tests/activity/test_models_retry.py | 24 - .../activity/test_run_form_env_field.py | 177 ---- .../activity/test_sandbox_env_link.py | 43 - tests/unit_tests/activity/test_services.py | 334 ------- .../activity/test_services_agent_override.py | 63 -- tests/unit_tests/activity/test_signals.py | 384 -------- .../activity/test_submit_batch_env.py | 89 -- .../unit_tests/activity/test_templatetags.py | 113 --- tests/unit_tests/activity/test_views.py | 586 ------------ tests/unit_tests/activity/test_views_runs.py | 212 ----- .../automation/titling/test_tasks.py | 61 +- .../chat/test_composer_agent_picker.py | 245 ----- .../chat/test_composer_env_select.py | 66 -- tests/unit_tests/chat/test_models.py | 51 -- .../unit_tests/chat/test_sandbox_env_link.py | 18 - tests/unit_tests/chat/test_views.py | 305 ------- tests/unit_tests/conftest.py | 14 +- .../unit_tests/notifications/test_signals.py | 836 +----------------- .../sessions/test_data_migration.py | 107 --- 70 files changed, 95 insertions(+), 8431 deletions(-) delete mode 100644 daiv/activity/filters.py delete mode 100644 daiv/activity/forms.py delete mode 100644 daiv/activity/management/__init__.py delete mode 100644 daiv/activity/management/commands/__init__.py delete mode 100644 daiv/activity/management/commands/release_orphan_queued_threads.py delete mode 100644 daiv/activity/management/commands/sync_stuck_activities.py create mode 100644 daiv/activity/migrations/0016_remove_activity_activity_trigger_created_idx_and_more.py delete mode 100644 daiv/activity/services.py delete mode 100644 daiv/activity/signals.py delete mode 100644 daiv/activity/static/activity/js/activity-stream.js delete mode 100644 daiv/activity/static/activity/js/prompt-box.js delete mode 100644 daiv/activity/templates/activity/_header.html delete mode 100644 daiv/activity/templates/activity/_hero_failed.html delete mode 100644 daiv/activity/templates/activity/_hero_pruned.html delete mode 100644 daiv/activity/templates/activity/_hero_queued.html delete mode 100644 daiv/activity/templates/activity/_hero_running.html delete mode 100644 daiv/activity/templates/activity/_hero_success.html delete mode 100644 daiv/activity/templates/activity/_prompt_disclosure.html delete mode 100644 daiv/activity/templates/activity/_rail_context.html delete mode 100644 daiv/activity/templates/activity/_rail_timing.html delete mode 100644 daiv/activity/templates/activity/_rail_usage.html delete mode 100644 daiv/activity/templates/activity/_status_pill.html delete mode 100644 daiv/activity/templates/activity/_status_strip.html delete mode 100644 daiv/activity/templates/activity/_trigger_badge.html delete mode 100644 daiv/activity/templates/activity/activity_detail.html delete mode 100644 daiv/activity/templates/activity/activity_list.html delete mode 100644 daiv/activity/templates/activity/agent_run_form.html delete mode 100644 daiv/activity/urls.py delete mode 100644 daiv/activity/urls_runs.py delete mode 100644 daiv/activity/views.py delete mode 100644 daiv/chat/managers.py create mode 100644 daiv/chat/migrations/0004_remove_chatthread_chat_chatth_user_id_abfd75_idx_and_more.py delete mode 100644 daiv/chat/templates/chat/chat_detail.html delete mode 100644 daiv/chat/templates/chat/chat_list.html delete mode 100644 daiv/chat/urls.py delete mode 100644 daiv/chat/views.py delete mode 100644 tests/unit_tests/activity/__init__.py delete mode 100644 tests/unit_tests/activity/conftest.py delete mode 100644 tests/unit_tests/activity/test_agent_run_fields_template.py delete mode 100644 tests/unit_tests/activity/test_batch_submit.py delete mode 100644 tests/unit_tests/activity/test_filters.py delete mode 100644 tests/unit_tests/activity/test_forms.py delete mode 100644 tests/unit_tests/activity/test_list_activities.py delete mode 100644 tests/unit_tests/activity/test_management.py delete mode 100644 tests/unit_tests/activity/test_models.py delete mode 100644 tests/unit_tests/activity/test_models_retry.py delete mode 100644 tests/unit_tests/activity/test_run_form_env_field.py delete mode 100644 tests/unit_tests/activity/test_sandbox_env_link.py delete mode 100644 tests/unit_tests/activity/test_services.py delete mode 100644 tests/unit_tests/activity/test_services_agent_override.py delete mode 100644 tests/unit_tests/activity/test_signals.py delete mode 100644 tests/unit_tests/activity/test_submit_batch_env.py delete mode 100644 tests/unit_tests/activity/test_templatetags.py delete mode 100644 tests/unit_tests/activity/test_views.py delete mode 100644 tests/unit_tests/activity/test_views_runs.py delete mode 100644 tests/unit_tests/chat/test_composer_agent_picker.py delete mode 100644 tests/unit_tests/chat/test_composer_env_select.py delete mode 100644 tests/unit_tests/chat/test_models.py delete mode 100644 tests/unit_tests/chat/test_sandbox_env_link.py delete mode 100644 tests/unit_tests/chat/test_views.py delete mode 100644 tests/unit_tests/sessions/test_data_migration.py diff --git a/daiv/activity/apps.py b/daiv/activity/apps.py index 5426f7767..006cbcafb 100644 --- a/daiv/activity/apps.py +++ b/daiv/activity/apps.py @@ -3,6 +3,3 @@ class ActivityConfig(AppConfig): name = "activity" - - def ready(self): - import activity.signals # noqa: F401 diff --git a/daiv/activity/filters.py b/daiv/activity/filters.py deleted file mode 100644 index 01ad22ca5..000000000 --- a/daiv/activity/filters.py +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import annotations - -import django_filters - -from activity.models import Activity, ActivityStatus, TriggerType - - -class ActivityFilter(django_filters.FilterSet): - status = django_filters.ChoiceFilter(choices=ActivityStatus.choices) - trigger = django_filters.ChoiceFilter(field_name="trigger_type", choices=TriggerType.choices) - repo = django_filters.CharFilter(field_name="repo_id") - schedule = django_filters.NumberFilter(field_name="scheduled_job_id") - batch = django_filters.UUIDFilter(field_name="batch_id") - date_from = django_filters.DateFilter(field_name="created_at", lookup_expr="date__gte") - date_to = django_filters.DateFilter(field_name="created_at", lookup_expr="date__lte") - - class Meta: - model = Activity - # All filters are declared above; disable auto-generation from model fields. - fields: list[str] = [] diff --git a/daiv/activity/forms.py b/daiv/activity/forms.py deleted file mode 100644 index fadfecaf4..000000000 --- a/daiv/activity/forms.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Shared form fields for any surface that submits an agent run. - -The prompt-box UI emits a single ``repos`` hidden input containing a JSON -list of ``{repo_id, ref}`` entries. :class:`RepoListField` parses and validates -it so ``cleaned_data["repos"]`` is a list of dicts; the caller converts to -``RepoTarget`` and hands off to :func:`activity.services.submit_batch_runs`. -""" - -from __future__ import annotations - -from django import forms -from django.utils.translation import gettext_lazy as _ - -from notifications.choices import NotifyOn -from sandbox_envs.models import SandboxEnvironment - -from activity.services import validate_repo_list -from automation.agent.validators import AgentOverrideError, ensure_agent_model_available, validate_agent_override -from core.models import ThinkingLevelChoices - - -class RepoListField(forms.JSONField): - """Form field for a JSON-encoded ``[{"repo_id", "ref"}, ...]`` hidden input. - - With ``required=False`` an *exactly-empty* list bypasses ``validate_repo_list`` - (which enforces a 1-entry minimum) — used by schedule templates where empty - means "let users choose". Other falsy or malformed shapes still fall through - to validation so the user sees an explicit error rather than a silent reset. - """ - - widget = forms.HiddenInput - default_error_messages = {"invalid": _("Malformed repository list.")} - - def to_python(self, value): - parsed = super().to_python(value) - if parsed is None: - return None - if parsed == [] and not self.required: - return [] - try: - return validate_repo_list(parsed) - except ValueError as err: - raise forms.ValidationError(str(err)) from err - - def prepare_value(self, value): - # Widget value is embedded verbatim into Alpine's initialRepos; empty must serialize as "[]", not "null". - if value in (None, []): - return "[]" - return super().prepare_value(value) - - -class AgentRunFieldsMixin(forms.Form): - prompt = forms.CharField(label=_("Prompt"), required=True) - repos = RepoListField(required=True) - agent_model = forms.CharField( - label=_("Agent model"), - required=False, - empty_value="", - help_text=_("Override the configured model for this run."), - ) - agent_thinking_level = forms.ChoiceField( - label=_("Thinking effort"), choices=[("", "")] + list(ThinkingLevelChoices.choices), required=False - ) - notify_on = forms.ChoiceField(label=_("Notify me"), choices=NotifyOn.choices, required=True) - sandbox_environment = forms.ModelChoiceField( - # Queryset is scoped to the caller in ``__init__``; an empty default avoids - # leaking other users' USER-scoped envs if a subclass forgets to pass ``user``. - queryset=SandboxEnvironment.objects.none(), - required=False, - empty_label=_("(global default)"), - label=_("Sandbox environment"), - ) - - def __init__(self, *args, user=None, **kwargs): - super().__init__(*args, **kwargs) - if "sandbox_environment" in self.fields and user is not None: - self.fields["sandbox_environment"].queryset = SandboxEnvironment.objects.visible_to(user) - - def clean(self): - cleaned = super().clean() or {} - try: - cleaned["agent_model"], cleaned["agent_thinking_level"] = validate_agent_override( - cleaned.get("agent_model"), cleaned.get("agent_thinking_level") - ) - # Server-side backstop for the picker's HTML5 ``required`` — if the - # client-side gate is bypassed (curl, scripted submit, a stale page - # cached when a system default still existed), surface the same error - # as a form error instead of letting the run enqueue and explode at - # ``get_daiv_agent_kwargs`` time. - ensure_agent_model_available(cleaned["agent_model"]) - except AgentOverrideError as err: - self.add_error("agent_model", str(err)) - return cleaned - - -class AgentRunCreateForm(AgentRunFieldsMixin, forms.Form): - """Validate 'Start a run' submissions. Orchestration lives in ``activity.services``.""" diff --git a/daiv/activity/management/__init__.py b/daiv/activity/management/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/daiv/activity/management/commands/__init__.py b/daiv/activity/management/commands/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/daiv/activity/management/commands/release_orphan_queued_threads.py b/daiv/activity/management/commands/release_orphan_queued_threads.py deleted file mode 100644 index 7172b6fc5..000000000 --- a/daiv/activity/management/commands/release_orphan_queued_threads.py +++ /dev/null @@ -1,71 +0,0 @@ -from __future__ import annotations - -import logging - -from django.core.management.base import BaseCommand -from django.db import IntegrityError -from django.db.models import Q - -from activity.models import Activity, ActivityStatus -from activity.signals import _enqueue_queued_activity - -logger = logging.getLogger("daiv.activity") - - -class Command(BaseCommand): - help = ( - "Release QUEUED Activities whose thread has no active (READY/RUNNING) sibling. " - "Mitigates a rare TOCTOU loss where the dispatcher missed a terminal transition " - "or the row was created QUEUED but never picked up." - ) - - def handle(self, *args, **options): - active_threads = set( - Activity.objects.filter( - status__in=[ActivityStatus.READY, ActivityStatus.RUNNING], thread_id__isnull=False - ).values_list("thread_id", flat=True) - ) - - orphans = ( - Activity.objects - .filter(status=ActivityStatus.QUEUED) - .filter(~Q(thread_id__in=active_threads)) - .order_by("thread_id", "created_at") - ) - - seen_threads: set[str] = set() - released = skipped = errored = 0 - for activity in orphans.iterator(): - if activity.thread_id in seen_threads: - skipped += 1 - continue - seen_threads.add(activity.thread_id) - try: - claimed = Activity.objects.filter(pk=activity.pk, status=ActivityStatus.QUEUED).update( - status=ActivityStatus.READY - ) - except IntegrityError: - # A concurrent submission claimed the thread between our snapshot of - # active_threads and this CAS; leave the row QUEUED for a future pass. - skipped += 1 - continue - if claimed != 1: - skipped += 1 - continue - activity.refresh_from_db() - try: - ok = _enqueue_queued_activity(activity) - except Exception: - errored += 1 - logger.exception("Failed to release orphan QUEUED activity %s", activity.pk) - continue - if ok: - released += 1 - else: - errored += 1 - - summary = f"Released: {released}, skipped: {skipped}, errored: {errored}" - if errored: - self.stdout.write(self.style.WARNING(f"{summary} — see logs; broker may be unavailable.")) - else: - self.stdout.write(self.style.SUCCESS(summary)) diff --git a/daiv/activity/management/commands/sync_stuck_activities.py b/daiv/activity/management/commands/sync_stuck_activities.py deleted file mode 100644 index db296c32e..000000000 --- a/daiv/activity/management/commands/sync_stuck_activities.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import annotations - -import logging - -from django.core.management.base import BaseCommand, CommandError - -from activity.models import Activity, ActivityStatus - -logger = logging.getLogger("daiv.activity") - - -class Command(BaseCommand): - help = "Re-sync non-terminal Activity rows from their linked DBTaskResult." - - def handle(self, *args, **options): - qs = ( - Activity.objects - .filter(task_result__isnull=False) - .exclude(status__in=list(ActivityStatus.terminal())) - .select_related("task_result", "scheduled_job") - ) - - synced = skipped = errored = 0 - for activity in qs.iterator(): - try: - if activity.sync_and_save(): - synced += 1 - else: - skipped += 1 - except Exception: - errored += 1 - logger.exception("Failed to sync activity %s", activity.id) - - summary = f"Synced: {synced}, already up to date: {skipped}, errored: {errored}" - if errored: - raise CommandError(summary) - self.stdout.write(self.style.SUCCESS(summary)) diff --git a/daiv/activity/migrations/0016_remove_activity_activity_trigger_created_idx_and_more.py b/daiv/activity/migrations/0016_remove_activity_activity_trigger_created_idx_and_more.py new file mode 100644 index 000000000..1bbe92e41 --- /dev/null +++ b/daiv/activity/migrations/0016_remove_activity_activity_trigger_created_idx_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 6.0.6 on 2026-07-07 23:12 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("activity", "0015_activity_agent_override_fields"), + # Data was already copied to sessions.Run before we drop the source table. + # Note: sessions app is registered under the label "agent_sessions". + ("agent_sessions", "0002_backfill_from_activity_and_chat"), + # memory FK was moved off activity.Activity to sessions.Run. + ("memory", "0002_swap_activity_fk_to_run"), + ] + + operations = [ + migrations.RemoveIndex(model_name="activity", name="activity_trigger_created_idx"), + migrations.RemoveIndex(model_name="activity", name="activity_repo_created_idx"), + migrations.RemoveIndex(model_name="activity", name="activity_status_created_idx"), + migrations.RemoveIndex(model_name="activity", name="activity_schedule_created_idx"), + migrations.RemoveIndex(model_name="activity", name="activity_user_created_idx"), + migrations.RemoveIndex(model_name="activity", name="activity_ext_user_created_idx"), + migrations.RemoveIndex(model_name="activity", name="activity_thread_status_idx"), + migrations.RemoveConstraint(model_name="activity", name="activity_thread_id_nonempty"), + migrations.RemoveConstraint(model_name="activity", name="activity_one_active_per_thread"), + migrations.DeleteModel(name="Activity"), + ] diff --git a/daiv/activity/models.py b/daiv/activity/models.py index eefb0c0ca..e2e8ccfdc 100644 --- a/daiv/activity/models.py +++ b/daiv/activity/models.py @@ -1,352 +1 @@ -from __future__ import annotations - -import logging -import uuid -from decimal import Decimal -from typing import TYPE_CHECKING - -from django.conf import settings -from django.db import models -from django.utils.translation import gettext_lazy as _ - -from notifications.choices import NotifyOn - -from automation.agent.results import parse_agent_result -from core.models import ThinkingLevelChoices - -logger = logging.getLogger("daiv.activity") - -if TYPE_CHECKING: - from accounts.models import User - - -class ActivityStatus(models.TextChoices): - QUEUED = "QUEUED", _("Queued") - READY = "READY", _("Pending") - RUNNING = "RUNNING", _("Running") - SUCCESSFUL = "SUCCESSFUL", _("Successful") - FAILED = "FAILED", _("Failed") - - @classmethod - def terminal(cls) -> frozenset[str]: - return frozenset({cls.SUCCESSFUL, cls.FAILED}) - - -class TriggerType(models.TextChoices): - API_JOB = "api_job", _("API Run") - MCP_JOB = "mcp_job", _("MCP Run") - SCHEDULE = "schedule", _("Scheduled Run") - UI_JOB = "ui_job", _("UI Run") - ISSUE_WEBHOOK = "issue_webhook", _("Issue Webhook") - MR_WEBHOOK = "mr_webhook", _("MR/PR Webhook") - - -class ActivityManager(models.Manager["Activity"]): - def by_owner(self, user: User) -> models.QuerySet[Activity]: - """Return activities visible to the given user. - - Admins see all. Regular users see activities where they are: - - the owner (``user`` FK), or - - matched by ``external_username``, or - - a subscriber of the linked ``scheduled_job``. - """ - if user.is_admin: - return self.all() - return self.filter( - models.Q(user=user) | models.Q(external_username=user.username) | models.Q(scheduled_job__subscribers=user) - ).distinct() - - def by_batch(self, batch_id) -> models.QuerySet[Activity]: - """Return activities that share a ``batch_id`` (multi-repo submission group).""" - return self.filter(batch_id=batch_id) - - -class Activity(models.Model): - """Unified record of every agent execution, regardless of trigger source. - - Denormalized fields (status, started_at, finished_at, result_summary, - error_message, code_changes) ensure the record remains useful after - the linked DBTaskResult row is pruned by the retention policy. - """ - - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - trigger_type = models.CharField(_("trigger type"), max_length=20, choices=TriggerType.choices) - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="activities", - verbose_name=_("user"), - ) - task_result = models.OneToOneField( - "django_tasks_database.DBTaskResult", - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="activity", - verbose_name=_("task result"), - ) - - status = models.CharField(_("status"), max_length=10, choices=ActivityStatus.choices, default=ActivityStatus.READY) - - title = models.CharField(_("title"), max_length=120, blank=True, default="") - - batch_id = models.UUIDField( - _("batch ID"), - null=True, - blank=True, - db_index=True, - help_text=_("Shared identifier for activities from the same submission."), - ) - - thread_id = models.CharField( # noqa: DJ001 — nullable for legacy rows. - _("thread ID"), - max_length=64, - null=True, - blank=True, - db_index=True, - help_text=_("LangGraph checkpoint key. Lets chat resume this run."), - ) - - external_username = models.CharField( - _("external username"), - max_length=255, - blank=True, - default="", - help_text=_( - "Git platform username from webhook payload." - " Used for activity visibility matching and to backfill the user FK when they later join DAIV." - ), - ) - - # Context fields - repo_id = models.CharField(_("repository"), max_length=255) - ref = models.CharField(_("branch / ref"), max_length=255, blank=True, default="") - prompt = models.TextField(_("prompt"), blank=True, default="") - use_max = models.BooleanField(_("use max model"), default=False) # deprecated; remove in follow-up release - agent_model = models.CharField( - _("agent model"), - max_length=255, - blank=True, - default="", - help_text=_("Per-run model override (slug:model_name); empty = auto."), - ) - agent_thinking_level = models.CharField( - _("agent thinking level"), - max_length=20, - blank=True, - default="", - choices=ThinkingLevelChoices.choices, - help_text=_("Per-run thinking effort; empty = inherit from repo config."), - ) - notify_on = models.CharField( # noqa: DJ001 — null distinguishes "no override" from explicit "never". - _("notify on"), - max_length=16, - choices=NotifyOn.choices, - null=True, - blank=True, - help_text=_( - "Per-run override. When null, the notifier falls back to ScheduledJob.notify_on" - " (for schedule runs) or to the initiating user's notify_on_jobs preference." - ), - ) - - # Issue / MR context - issue_iid = models.PositiveIntegerField(_("issue IID"), null=True, blank=True) - merge_request_iid = models.PositiveIntegerField(_("merge request IID"), null=True, blank=True) - merge_request_web_url = models.URLField(_("merge request URL"), max_length=500, blank=True, default="") - mention_comment_id = models.CharField(_("mention comment ID"), max_length=255, blank=True, default="") - - # Schedule linkage - scheduled_job = models.ForeignKey( - "schedules.ScheduledJob", - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="activities", - verbose_name=_("scheduled job"), - ) - - sandbox_environment = models.ForeignKey( - "sandbox_envs.SandboxEnvironment", - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="activities", - verbose_name=_("sandbox environment"), - ) - - # Denormalized result / error (survives DBTaskResult pruning) - result_summary = models.TextField(_("result summary"), blank=True, default="") - error_message = models.TextField(_("error message"), blank=True, default="") - code_changes = models.BooleanField(_("code changes"), default=False) - - # Denormalized usage / cost (survives DBTaskResult pruning) - input_tokens = models.PositiveIntegerField(_("input tokens"), null=True, blank=True) - output_tokens = models.PositiveIntegerField(_("output tokens"), null=True, blank=True) - total_tokens = models.PositiveIntegerField(_("total tokens"), null=True, blank=True) - cost_usd = models.DecimalField(_("cost (USD)"), max_digits=10, decimal_places=6, null=True, blank=True) - usage_by_model = models.JSONField(_("usage by model"), null=True, blank=True) - - # Denormalized timing - created_at = models.DateTimeField(_("created at"), auto_now_add=True) - started_at = models.DateTimeField(_("started at"), null=True, blank=True) - finished_at = models.DateTimeField(_("finished at"), null=True, blank=True) - - objects = ActivityManager() - - class Meta: - verbose_name = _("Activity") - verbose_name_plural = _("Activities") - ordering = ["-created_at"] - indexes = [ - models.Index(fields=["trigger_type", "-created_at"], name="activity_trigger_created_idx"), - models.Index(fields=["repo_id", "-created_at"], name="activity_repo_created_idx"), - models.Index(fields=["status", "-created_at"], name="activity_status_created_idx"), - models.Index(fields=["scheduled_job", "-created_at"], name="activity_schedule_created_idx"), - models.Index(fields=["user", "-created_at"], name="activity_user_created_idx"), - models.Index( - fields=["external_username", "-created_at"], - name="activity_ext_user_created_idx", - condition=models.Q(external_username__gt=""), - ), - models.Index(fields=["thread_id", "status"], name="activity_thread_status_idx"), - ] - constraints = [ - # NULL is the unambiguous "no thread" marker; reject "" so legacy queries - # filtering ``thread_id__isnull`` don't miss empty-string sentinels. - models.CheckConstraint( - condition=models.Q(thread_id__isnull=True) | ~models.Q(thread_id=""), name="activity_thread_id_nonempty" - ), - # At most one active (READY or RUNNING) API/MCP Activity per thread. The - # FIFO queue uses QUEUED, which is intentionally outside the constraint - # so siblings can stack up while one runs. Webhook-driven triggers share - # deterministic thread_ids across events and are intentionally excluded. - models.UniqueConstraint( - fields=["thread_id"], - condition=models.Q(status__in=["READY", "RUNNING"], trigger_type__in=["api_job", "mcp_job"]), - name="activity_one_active_per_thread", - ), - ] - - def __str__(self) -> str: - return f"{self.get_trigger_type_display()} on {self.repo_id} ({self.status})" - - @property - def effective_notify_on(self) -> NotifyOn: - """Resolve the notification preference that applies to this run. - - Precedence: per-run override > schedule preference > user default > NEVER. - """ - if self.notify_on: - return NotifyOn(self.notify_on) - if self.scheduled_job_id is not None and self.scheduled_job is not None: - return NotifyOn(self.scheduled_job.notify_on) - if self.user_id is not None and self.user is not None: - return NotifyOn(self.user.notify_on_jobs) - return NotifyOn.NEVER - - @property - def is_retryable(self) -> bool: - return self.status in ActivityStatus.terminal() and self.trigger_type not in { - TriggerType.ISSUE_WEBHOOK, - TriggerType.MR_WEBHOOK, - } - - @property - def duration(self) -> float | None: - """Return the execution duration in seconds, or None if not finished.""" - if self.started_at and self.finished_at: - return (self.finished_at - self.started_at).total_seconds() - return None - - @property - def response_text(self) -> str: - """Return the response text from the task result, or the truncated denormalized summary if unavailable.""" - if self.task_result and self.task_result.return_value: - parsed = parse_agent_result(self.task_result.return_value) - if parsed["response"]: - return parsed["response"] - return self.result_summary - - def sync_and_save(self) -> bool: - """Sync from the linked DBTaskResult and persist changed fields. - - Returns True if any field was updated (and a save was issued), else False. - Emits ``activity_finished`` when the status transitions to a terminal state. - - Raises whatever ``sync_from_task_result`` or ``self.save`` raise — callers running - in long-lived loops (signal handlers, management commands) must catch. - """ - from activity.signals import emit_activity_finished_if_terminal - - previous_status = self.status - changed = self.sync_from_task_result() - if not changed: - return False - self.save(update_fields=changed) - emit_activity_finished_if_terminal(self, previous_status=previous_status) - return True - - def sync_from_task_result(self) -> list[str]: - """Pull latest status/timing/result from the linked DBTaskResult. - - Returns: - List of field names that were updated (empty if nothing changed). - """ - if self.task_result is None: - return [] - - tr = self.task_result - changed: list[str] = [] - - for field, value in [("status", tr.status), ("started_at", tr.started_at), ("finished_at", tr.finished_at)]: - if getattr(self, field) != value: - setattr(self, field, value) - changed.append(field) - - if tr.status == ActivityStatus.SUCCESSFUL and tr.return_value: - parsed = parse_agent_result(tr.return_value) - - if parsed["response"] and not self.result_summary: - self.result_summary = parsed["response"][:2000] - changed.append("result_summary") - if parsed["code_changes"] and not self.code_changes: - self.code_changes = True - changed.append("code_changes") - if parsed["merge_request_id"] and not self.merge_request_iid: - self.merge_request_iid = parsed["merge_request_id"] - changed.append("merge_request_iid") - if parsed["merge_request_web_url"] and not self.merge_request_web_url: - self.merge_request_web_url = parsed["merge_request_web_url"] - changed.append("merge_request_web_url") - - if (usage := parsed["usage"]) and self.input_tokens is None: - if usage.get("input_tokens") is not None: - self.input_tokens = usage["input_tokens"] - changed.append("input_tokens") - if usage.get("output_tokens") is not None: - self.output_tokens = usage["output_tokens"] - changed.append("output_tokens") - if usage.get("total_tokens") is not None: - self.total_tokens = usage["total_tokens"] - changed.append("total_tokens") - if usage.get("cost_usd") is not None: - try: - self.cost_usd = Decimal(usage["cost_usd"]) - except Exception: - logger.warning("Invalid cost_usd value %r for activity %s", usage["cost_usd"], self.pk) - else: - changed.append("cost_usd") - if usage.get("by_model") is not None: - self.usage_by_model = usage["by_model"] - changed.append("usage_by_model") - - if tr.status == ActivityStatus.FAILED and tr.exception_class_path and not self.error_message: - self.error_message = tr.exception_class_path - if tr.traceback: - self.error_message += f"\n{tr.traceback}" - changed.append("error_message") - - return changed +"""Historical app — models replaced by the sessions app; kept for migration history.""" diff --git a/daiv/activity/services.py b/daiv/activity/services.py deleted file mode 100644 index 978bf8f74..000000000 --- a/daiv/activity/services.py +++ /dev/null @@ -1,415 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -import uuid -from dataclasses import dataclass, field -from typing import TYPE_CHECKING - -from django.db import IntegrityError -from django.db.models import Q -from django.utils import timezone - -from asgiref.sync import async_to_sync -from jobs.tasks import run_job_task - -from activity.models import Activity, ActivityStatus, TriggerType -from activity.signals import emit_activity_finished_if_terminal -from automation.titling.tasks import generate_batch_title_task - -_PROMPT_DRIVEN = {TriggerType.API_JOB, TriggerType.MCP_JOB, TriggerType.UI_JOB} - -if TYPE_CHECKING: - from datetime import datetime - - from notifications.choices import NotifyOn - from sandbox_envs.models import SandboxEnvironment - - from accounts.models import User - from schedules.models import ScheduledJob - -logger = logging.getLogger("daiv.activity") - -MAX_REPOS_PER_BATCH = 20 - - -@dataclass(frozen=True) -class RepoTarget: - repo_id: str - ref: str = "" - sandbox_environment_id: str | None = None - - -@dataclass(frozen=True) -class BatchSubmitFailure: - repo_id: str - ref: str - error: str - - -@dataclass(frozen=True) -class BatchSubmitResult: - batch_id: uuid.UUID - activities: list[Activity] = field(default_factory=list) - failed: list[BatchSubmitFailure] = field(default_factory=list) - - -def validate_repo_list(raw) -> list[dict]: - """Validate and normalize a list of ``{repo_id, ref}`` entries. - - Raises ``ValueError`` on any violation. Returns a fresh list of normalized dicts - (guaranteed string keys/values, no duplicates, 1-20 entries). - """ - if not isinstance(raw, list) or not raw: - raise ValueError("At least one repository is required.") - if len(raw) > MAX_REPOS_PER_BATCH: - raise ValueError(f"At most {MAX_REPOS_PER_BATCH} repositories allowed per submission.") - - seen: set[tuple[str, str]] = set() - out: list[dict] = [] - for entry in raw: - if not isinstance(entry, dict) or set(entry.keys()) != {"repo_id", "ref"}: - raise ValueError("Each entry must be an object with keys 'repo_id' and 'ref'.") - repo_id = entry["repo_id"] - ref = entry["ref"] or "" - if not isinstance(repo_id, str) or not repo_id.strip(): - raise ValueError("repo_id must be a non-empty string.") - if not isinstance(ref, str): - raise ValueError("ref must be a string (empty for default branch).") - key = (repo_id, ref) - if key in seen: - label = f"{repo_id} on {ref}" if ref else repo_id - raise ValueError(f"Repository already in the list: {label}.") - seen.add(key) - out.append({"repo_id": repo_id, "ref": ref}) - return out - - -def _validate(repos: list[RepoTarget]) -> None: - if not repos: - raise ValueError("repos must contain at least one entry") - if len(repos) > MAX_REPOS_PER_BATCH: - raise ValueError(f"repos exceeds the maximum of {MAX_REPOS_PER_BATCH}") - - -def create_activity( - *, - trigger_type: str, - task_result_id: uuid.UUID | None, - repo_id: str, - ref: str = "", - prompt: str = "", - agent_model: str = "", - agent_thinking_level: str = "", - use_max: bool = False, - issue_iid: int | None = None, - merge_request_iid: int | None = None, - mention_comment_id: str = "", - scheduled_job: ScheduledJob | None = None, - user: User | None = None, - external_username: str = "", - notify_on: NotifyOn | None = None, - batch_id: uuid.UUID | None = None, - thread_id: str | None = None, - title: str = "", - sandbox_environment: SandboxEnvironment | None = None, - status: str = ActivityStatus.READY, -) -> Activity: - """Create an Activity record linked to a DBTaskResult. - - ``notify_on=None`` defers to ``Activity.effective_notify_on`` at send time. - The ``agent_model`` / ``agent_thinking_level`` pair is the per-run override (empty - string = auto). ``use_max`` is the legacy column kept for webhook callers - (``daiv-max`` label) so the UI can still display the badge; non-webhook surfaces - pass the override pair instead. - """ - return Activity.objects.create( - trigger_type=trigger_type, - task_result_id=task_result_id, - repo_id=repo_id, - ref=ref, - prompt=prompt, - agent_model=agent_model, - agent_thinking_level=agent_thinking_level, - use_max=use_max, - issue_iid=issue_iid, - merge_request_iid=merge_request_iid, - mention_comment_id=mention_comment_id, - scheduled_job=scheduled_job, - user=user, - external_username=external_username, - notify_on=notify_on, - batch_id=batch_id, - thread_id=thread_id, - title=title[: Activity._meta.get_field("title").max_length], - sandbox_environment=sandbox_environment, - status=status, - ) - - -async def acreate_activity( - *, - trigger_type: str, - task_result_id: uuid.UUID | None, - repo_id: str, - ref: str = "", - prompt: str = "", - agent_model: str = "", - agent_thinking_level: str = "", - use_max: bool = False, - issue_iid: int | None = None, - merge_request_iid: int | None = None, - mention_comment_id: str = "", - scheduled_job: ScheduledJob | None = None, - user: User | None = None, - external_username: str = "", - notify_on: NotifyOn | None = None, - batch_id: uuid.UUID | None = None, - thread_id: str | None = None, - title: str = "", - sandbox_environment: SandboxEnvironment | None = None, - sandbox_environment_id: str | None = None, - status: str = ActivityStatus.READY, -) -> Activity: - """Async variant of create_activity.""" - extra: dict = {} - if sandbox_environment_id is not None: - extra["sandbox_environment_id"] = sandbox_environment_id - else: - extra["sandbox_environment"] = sandbox_environment - return await Activity.objects.acreate( - trigger_type=trigger_type, - task_result_id=task_result_id, - repo_id=repo_id, - ref=ref, - prompt=prompt, - agent_model=agent_model, - agent_thinking_level=agent_thinking_level, - use_max=use_max, - issue_iid=issue_iid, - merge_request_iid=merge_request_iid, - mention_comment_id=mention_comment_id, - scheduled_job=scheduled_job, - user=user, - external_username=external_username, - notify_on=notify_on, - batch_id=batch_id, - thread_id=thread_id, - title=title[: Activity._meta.get_field("title").max_length], - status=status, - **extra, - ) - - -async def _mark_failed_and_release(activity: Activity, *, prefix: str, err: Exception, previous_status: str) -> None: - """Transition a row to FAILED with finished_at and emit ``activity_finished``. - - Used by the services-layer post-create error paths (enqueue or task-result-id-link - failure). The emit is best-effort — if it raises, we log loudly and recommend the - operator run ``release_orphan_queued_threads`` to recover stranded siblings. - """ - now = timezone.now() - activity.status = ActivityStatus.FAILED - activity.error_message = f"{prefix}: {type(err).__name__}: {err}" - activity.finished_at = now - if activity.started_at is None: - activity.started_at = now - try: - await activity.asave(update_fields=["status", "error_message", "finished_at", "started_at"]) - except Exception: - logger.exception("submit_batch_runs: terminal save failed for activity=%s", activity.pk) - try: - await asyncio.to_thread(emit_activity_finished_if_terminal, activity, previous_status=previous_status) - except Exception: - logger.exception( - "submit_batch_runs: emit_activity_finished_if_terminal failed for activity=%s; " - "queued siblings on this thread may be stranded — run release_orphan_queued_threads", - activity.pk, - ) - - -async def asubmit_batch_runs( - *, - user: User | None, - prompt: str, - repos: list[RepoTarget], - agent_model: str = "", - agent_thinking_level: str = "", - notify_on: NotifyOn | None = None, - trigger_type: str, - scheduled_job: ScheduledJob | None = None, - external_username: str = "", - thread_id: str | None = None, -) -> BatchSubmitResult: - """Enqueue N ``run_job_task`` instances sharing a ``batch_id``; record N ``Activity`` rows. - - Each ``RepoTarget`` carries its own ``sandbox_environment_id`` (resolved upstream by - :func:`sandbox_envs.services.resolve_repo_envs`), so the batch can mix per-repo envs. - - Best-effort: any per-repo exception (enqueue failure or post-enqueue activity-creation - failure) lands in ``result.failed`` while siblings continue. Callers can use this to - distinguish "submitted" from "orphaned" and recover accordingly. - """ - _validate(repos) - if thread_id is not None: - if not thread_id: - raise ValueError("thread_id must be a non-empty UUID string") - try: - uuid.UUID(thread_id) - except (ValueError, TypeError) as err: - raise ValueError("thread_id must be a UUID string") from err - if len(repos) != 1: - raise ValueError("thread_id continuation requires exactly one repo") - batch_id = uuid.uuid4() - - schedule_run_base = 0 - if trigger_type == TriggerType.SCHEDULE and scheduled_job is not None: - schedule_run_base = await Activity.objects.filter(scheduled_job=scheduled_job).acount() - - async def _submit_one(idx: int, target: RepoTarget) -> Activity | BatchSubmitFailure: - effective_thread_id = thread_id or str(uuid.uuid4()) - - activity_title = "" - if trigger_type == TriggerType.SCHEDULE and scheduled_job is not None: - activity_title = f"{scheduled_job.name} · run #{schedule_run_base + idx + 1}" - - common_kwargs: dict = { - "trigger_type": trigger_type, - "repo_id": target.repo_id, - "ref": target.ref, - "prompt": prompt, - "agent_model": agent_model, - "agent_thinking_level": agent_thinking_level, - "scheduled_job": scheduled_job, - "user": user, - "external_username": external_username, - "notify_on": notify_on, - "batch_id": batch_id, - "thread_id": effective_thread_id, - "title": activity_title, - "sandbox_environment_id": target.sandbox_environment_id, - } - - # Claim the thread atomically by trying to create a READY row. The partial - # unique constraint ``activity_one_active_per_thread`` raises IntegrityError - # when a sibling (READY/RUNNING) is already active on this thread — in that - # case we fall back to QUEUED, no task enqueue. - try: - activity = await acreate_activity(**common_kwargs, task_result_id=None, status=ActivityStatus.READY) - except IntegrityError: - try: - return await acreate_activity(**common_kwargs, task_result_id=None, status=ActivityStatus.QUEUED) - except Exception as inner_err: - logger.exception("submit_batch_runs: queued activity creation failed for repo_id=%s", target.repo_id) - return BatchSubmitFailure( - repo_id=target.repo_id, - ref=target.ref, - error=f"ActivityCreationFailed: {type(inner_err).__name__}: {inner_err}", - ) - except Exception as err: - logger.exception("submit_batch_runs: activity creation failed for repo_id=%s", target.repo_id) - return BatchSubmitFailure( - repo_id=target.repo_id, ref=target.ref, error=f"ActivityCreationFailed: {type(err).__name__}: {err}" - ) - - try: - task = await run_job_task.aenqueue( - repo_id=target.repo_id, - prompt=prompt, - ref=target.ref or None, - agent_model=agent_model or None, - agent_thinking_level=agent_thinking_level or None, - thread_id=effective_thread_id, - sandbox_environment_id=target.sandbox_environment_id, - ) - except Exception as err: # noqa: BLE001 - logger.exception("submit_batch_runs: enqueue failed for repo_id=%s batch_id=%s", target.repo_id, batch_id) - await _mark_failed_and_release( - activity, prefix="enqueue_failed", err=err, previous_status=ActivityStatus.READY - ) - return BatchSubmitFailure(repo_id=target.repo_id, ref=target.ref, error=f"{type(err).__name__}: {err}") - - try: - activity.task_result_id = task.id - await activity.asave(update_fields=["task_result_id"]) - except Exception as save_err: - # The broker now holds a task this Activity row doesn't link to. - # Mark the row FAILED so callers see the failure and queued siblings advance; - # the orphan task itself will execute and ``_sync_activity_for_task`` will no-op - # (no Activity with that task_result_id). - logger.exception( - "submit_batch_runs: failed to link task_result_id=%s to activity=%s (orphan task will run)", - task.id, - activity.pk, - ) - await _mark_failed_and_release( - activity, prefix="link_failed", err=save_err, previous_status=ActivityStatus.READY - ) - return BatchSubmitFailure( - repo_id=target.repo_id, ref=target.ref, error=f"LinkFailed: {type(save_err).__name__}: {save_err}" - ) - return activity - - # return_exceptions=True guards against BaseException (CancelledError, etc.) aborting the - # whole batch; _submit_one already catches Exception itself. - outcomes = await asyncio.gather(*[_submit_one(i, t) for i, t in enumerate(repos)], return_exceptions=True) - - activities: list[Activity] = [] - failed: list[BatchSubmitFailure] = [] - for target, outcome in zip(repos, outcomes, strict=True): - if isinstance(outcome, BaseException): - logger.error("submit_batch_runs: unexpected exception for repo_id=%s", target.repo_id, exc_info=outcome) - failed.append( - BatchSubmitFailure(repo_id=target.repo_id, ref=target.ref, error=f"{type(outcome).__name__}: {outcome}") - ) - elif isinstance(outcome, BatchSubmitFailure): - failed.append(outcome) - else: - activities.append(outcome) - - if activities and trigger_type in _PROMPT_DRIVEN and prompt: - try: - await generate_batch_title_task.aenqueue(batch_id=str(batch_id), prompt=prompt) - except Exception: # noqa: BLE001 - logger.exception( - "Failed to enqueue batch title task for batch_id=%s user=%s trigger=%s activities=%d", - batch_id, - user.pk if user is not None else None, - trigger_type, - len(activities), - ) - - return BatchSubmitResult(batch_id=batch_id, activities=activities, failed=failed) - - -def submit_batch_runs(**kwargs) -> BatchSubmitResult: - """Sync wrapper around :func:`asubmit_batch_runs` for cron and sync views.""" - return async_to_sync(asubmit_batch_runs)(**kwargs) - - -async def alist_user_activities( - user, - *, - repo_id: str | None = None, - status: str | None = None, - limit: int = 20, - before: tuple[datetime, uuid.UUID] | None = None, -) -> list[Activity]: - """Return ``user``'s activities, newest first, optionally filtered by repo/status. - - Capped at ``limit`` rows. Callers needing truncation/pagination should pass - ``limit + 1`` and trim. ``before`` is a keyset cursor ``(created_at, id)`` of the - last row already seen; only rows strictly older (in ``-created_at, -id`` order) are - returned, so pagination is stable even as new rows arrive at the head. The ``id`` - tie-break is required because a batch submit stamps several rows with the same - ``created_at``. Backed by ``activity_user_created_idx`` (user, -created_at). - """ - qs = Activity.objects.filter(user=user) - if repo_id: - qs = qs.filter(repo_id=repo_id) - if status: - qs = qs.filter(status=status) - if before is not None: - created_at, last_id = before - qs = qs.filter(Q(created_at__lt=created_at) | Q(created_at=created_at, id__lt=last_id)) - return [activity async for activity in qs.order_by("-created_at", "-id")[:limit]] diff --git a/daiv/activity/signals.py b/daiv/activity/signals.py deleted file mode 100644 index a0cf78092..000000000 --- a/daiv/activity/signals.py +++ /dev/null @@ -1,245 +0,0 @@ -from __future__ import annotations - -import logging -from typing import Any - -from django.conf import settings -from django.db import IntegrityError -from django.db.models.signals import post_save -from django.dispatch import Signal, receiver -from django.utils import timezone - -from asgiref.sync import async_to_sync -from django_tasks.signals import task_finished, task_started -from jobs.tasks import run_job_task - -logger = logging.getLogger("daiv.activity") - -# Emitted when an Activity transitions to a terminal status (SUCCESSFUL or FAILED). -# Arguments: activity (Activity instance). -activity_finished = Signal() - - -@receiver(post_save, sender=settings.AUTH_USER_MODEL) -def backfill_activity_user(sender: type, instance: Any, created: bool, **kwargs: Any) -> None: - """Link orphaned activities to a newly created user by matching external_username. - - Only runs on user creation, not updates — renaming a user will not re-trigger backfill. - Errors are caught so that a problem in activity backfill never breaks user creation. - """ - if not created: - return - - from activity.models import Activity - - try: - updated = Activity.objects.filter(user__isnull=True, external_username=instance.username).update(user=instance) - except Exception: - logger.exception("Failed to backfill activities for new user %s (pk=%s)", instance.username, instance.pk) - return - - if updated: - logger.info("Backfilled %d activities for new user %s (pk=%s)", updated, instance.username, instance.pk) - - -def emit_activity_finished_if_terminal( - activity: Any, previous_status: str | None, *, skip_dispatch: bool = False -) -> None: - """Emit activity_finished if the activity just transitioned to a terminal status. - - ``skip_dispatch`` is forwarded to receivers; the in-thread dispatcher uses it to - suppress recursive re-entry while still letting notification receivers fire. - """ - from activity.models import ActivityStatus - - if activity.status not in ActivityStatus.terminal(): - return - if previous_status in ActivityStatus.terminal(): - return # Already emitted on a prior save - results = activity_finished.send_robust(sender=type(activity), activity=activity, skip_dispatch=skip_dispatch) - for recv, response in results: - if isinstance(response, Exception): - logger.error( - "Receiver %s failed for activity_finished (activity=%s)", - getattr(recv, "__name__", recv), - activity.pk, - exc_info=response, - ) - - -def _sync_activity_for_task(task_result_id: Any) -> None: - """Pull latest status/timing/result from the linked DBTaskResult into the Activity row. - - Silently no-ops if no Activity is linked to the given task_result_id (e.g. tasks that - don't create an Activity, or the brief cross-process race where ``task_started`` fires - before the Activity row is committed on the web side — ``task_finished`` will catch up). - Errors are swallowed and logged so the worker loop is never crashed by sync failures. - """ - from activity.models import Activity - - try: - activity = ( - Activity.objects - .select_related("task_result", "scheduled_job", "scheduled_job__user", "user") - .filter(task_result_id=task_result_id) - .first() - ) - if activity is None: - return - activity.sync_and_save() - except Exception: - logger.exception("Failed to sync activity for task_result_id=%s", task_result_id) - - -@receiver([task_started, task_finished]) -def sync_activity_on_task_signal(sender: type, task_result: Any, **kwargs: Any) -> None: - """Sync the linked Activity on task state transitions (RUNNING / terminal). - - The django-tasks worker commits DBTaskResult state (via ``claim``/``set_successful``/ - ``set_failed``) before dispatching these signals, so reading back the row here is safe - without a transaction guard. - """ - _sync_activity_for_task(task_result.id) - - -#: Cap on consecutive enqueue failures before the dispatcher bails. A persistent -#: broker outage would otherwise mass-fail every QUEUED row on the thread within -#: a single signal-handler call; bailing leaves the rest QUEUED for -#: ``release_orphan_queued_threads`` to recover when the broker is back. -MAX_CONSECUTIVE_DISPATCH_FAILURES = 3 - - -@receiver(activity_finished) -def dispatch_next_in_thread(sender: type, activity: Any, **kwargs: Any) -> None: - """Release queued continuations on this thread, one at a time, until one succeeds. - - Atomic compare-and-swap (``filter(pk=, status=QUEUED).update(status=READY)``) wins - the race against concurrent dispatchers reading the same row. A losing race or a - unique-constraint violation against a peer claim is silently retried on the next - QUEUED row. Enqueue failures mark the row FAILED (with ``finished_at`` set) and - loop to the next sibling so a single bad row does not block the thread — bounded - by ``MAX_CONSECUTIVE_DISPATCH_FAILURES`` so a broker outage doesn't mass-fail the - whole backlog. - - ``skip_dispatch=True`` (passed by re-emits from dispatch-failure paths) suppresses - re-entry while still letting notification receivers fire. - """ - from activity.models import Activity, ActivityStatus - - if kwargs.get("skip_dispatch"): - return - - thread_id = getattr(activity, "thread_id", None) - if not thread_id: - return - - consecutive_failures = 0 - while True: - next_q = ( - Activity.objects.filter(thread_id=thread_id, status=ActivityStatus.QUEUED).order_by("created_at").first() - ) - if next_q is None: - return - - try: - claimed = Activity.objects.filter(pk=next_q.pk, status=ActivityStatus.QUEUED).update( - status=ActivityStatus.READY - ) - except IntegrityError: - # A concurrent insert (e.g. a fresh _submit_one) already created a - # READY row on this thread; the partial unique constraint blocks us. - logger.debug("dispatch_next_in_thread: peer claim on thread=%s, backing off", thread_id) - return - - if claimed != 1: - # Another dispatcher took this exact row; try the next one. - continue - - next_q.refresh_from_db() - if _enqueue_queued_activity(next_q): - return - - consecutive_failures += 1 - if consecutive_failures >= MAX_CONSECUTIVE_DISPATCH_FAILURES: - logger.warning( - "dispatch_next_in_thread: bailing on thread=%s after %d consecutive dispatch failures; " - "remaining QUEUED siblings left for release_orphan_queued_threads", - thread_id, - consecutive_failures, - ) - return - # Enqueue failed; loop to the next QUEUED row. - - -def _enqueue_queued_activity(activity: Any) -> bool: - """Enqueue ``run_job_task`` for an already-claimed (READY) Activity. - - Returns ``True`` on success. On failure, marks the row FAILED with - ``finished_at`` set and re-emits ``activity_finished`` with ``skip_dispatch=True`` - so notification receivers fire without recursively re-entering the dispatcher. - """ - from activity.models import ActivityStatus - - agent_model = activity.agent_model or None - agent_thinking_level = activity.agent_thinking_level or None - # Legacy fallback: rows persisted before the data migration may carry - # ``use_max=True`` without the new override pair. ``run_job_task`` no longer - # accepts ``use_max``, so resolve it to the site-configured max preset here - # rather than silently downgrading the re-enqueued run to the default model. - if not agent_model and getattr(activity, "use_max", False): - from core.site_settings import site_settings - - agent_model = site_settings.agent_max_model_name - agent_thinking_level = agent_thinking_level or site_settings.agent_max_thinking_level - logger.warning( - "dispatch_next_in_thread: legacy use_max=True activity=%s; resolving to %s/%s", - activity.pk, - agent_model, - agent_thinking_level, - ) - - try: - task = async_to_sync(run_job_task.aenqueue)( - repo_id=activity.repo_id, - prompt=activity.prompt, - thread_id=str(activity.thread_id), - ref=activity.ref or None, - agent_model=agent_model, - agent_thinking_level=agent_thinking_level, - sandbox_environment_id=str(activity.sandbox_environment_id) if activity.sandbox_environment_id else None, - ) - except Exception as err: # noqa: BLE001 - logger.exception("dispatch_next_in_thread: enqueue failed for activity=%s", activity.pk) - now = timezone.now() - activity.status = ActivityStatus.FAILED - activity.error_message = f"dispatch_failed: {type(err).__name__}: {err}" - activity.finished_at = now - if activity.started_at is None: - activity.started_at = now - activity.save(update_fields=["status", "error_message", "finished_at", "started_at"]) - emit_activity_finished_if_terminal(activity, previous_status=ActivityStatus.READY, skip_dispatch=True) - return False - - try: - activity.task_result_id = task.id - activity.save(update_fields=["task_result_id"]) - except Exception as save_err: - # Broker holds an orphan task that won't be linked back via task_result_id. - # Mark FAILED so siblings advance; the orphan runs but ``_sync_activity_for_task`` - # no-ops because no Activity row matches the task_result_id. - logger.exception( - "dispatch_next_in_thread: failed to link task_result_id=%s to activity=%s", task.id, activity.pk - ) - now = timezone.now() - activity.status = ActivityStatus.FAILED - activity.error_message = f"link_failed: {type(save_err).__name__}: {save_err}" - activity.finished_at = now - if activity.started_at is None: - activity.started_at = now - try: - activity.save(update_fields=["status", "error_message", "finished_at", "started_at"]) - except Exception: - logger.exception("dispatch_next_in_thread: terminal save also failed for activity=%s", activity.pk) - emit_activity_finished_if_terminal(activity, previous_status=ActivityStatus.READY, skip_dispatch=True) - return False - return True diff --git a/daiv/activity/static/activity/js/activity-stream.js b/daiv/activity/static/activity/js/activity-stream.js deleted file mode 100644 index b6764adcd..000000000 --- a/daiv/activity/static/activity/js/activity-stream.js +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Alpine.js components for real-time activity status updates via SSE. - * - * activityStream (list page) — tracks multiple activities in place: - * dotClass(id, fallback) → object toggling status-dot-{variant} classes - * statusClass(id, fallback) → object toggling status-badge-{variant} classes - * statusLabel(id, fallback) → human-readable label - * - * Object class maps (rather than a single string) are required so Alpine - * removes the previously rendered variant class when the status transitions — - * otherwise the static server-rendered class lingers alongside the new one - * and the later CSS rule wins. - * - * activityDetail (detail page) — subscribes to one activity and reloads the - * page on any state change so server-rendered fields (started_at, finished_at, - * elapsed counter, duration, timeline dots) reflect the new state. - */ -document.addEventListener("alpine:init", () => { - const VARIANTS = ["success", "failed", "running", "queued", "pending"]; - - function statusVariantFor(status) { - if (status === "SUCCESSFUL") return "success"; - if (status === "FAILED") return "failed"; - if (status === "RUNNING") return "running"; - if (status === "QUEUED") return "queued"; - return "pending"; - } - - function statusLabelFor(status) { - if (status === "SUCCESSFUL") return "Successful"; - if (status === "FAILED") return "Failed"; - if (status === "RUNNING") return "Running"; - if (status === "QUEUED") return "Queued"; - return "Pending"; - } - - function variantClassMap(prefix, active) { - return Object.fromEntries(VARIANTS.map((v) => [prefix + v, v === active])); - } - - Alpine.data("activityStream", (streamUrl, inFlightIds) => ({ - updates: {}, - init() { - if (!inFlightIds) return; - const url = streamUrl + "?ids=" + inFlightIds; - const source = new EventSource(url); - source.onmessage = (event) => { - const data = JSON.parse(event.data); - if (data.done) { - source.close(); - return; - } - this.updates[data.id] = data; - }; - source.onerror = () => source.close(); - }, - statusClass(id, fallback) { - return variantClassMap("status-badge-", statusVariantFor(this.updates[id]?.status || fallback)); - }, - dotClass(id, fallback) { - return variantClassMap("status-dot-", statusVariantFor(this.updates[id]?.status || fallback)); - }, - statusLabel(id, fallback) { - const update = this.updates[id]; - return update ? statusLabelFor(update.status) : fallback; - }, - })); - - // The SSE endpoint always emits the current state on first poll (it doesn't - // know what the page rendered with), so reload only when the status has - // actually drifted from what the template saw — otherwise a RUNNING page - // would reload every poll interval. - Alpine.data("activityDetail", (streamUrl, activityId, initialStatus) => ({ - init() { - const url = streamUrl + "?ids=" + activityId; - const source = new EventSource(url); - source.onmessage = (event) => { - const data = JSON.parse(event.data); - if (data.done || (data.status && data.status !== initialStatus)) { - source.close(); - window.location.reload(); - } - }; - source.onerror = () => source.close(); - }, - })); -}); diff --git a/daiv/activity/static/activity/js/prompt-box.js b/daiv/activity/static/activity/js/prompt-box.js deleted file mode 100644 index e134b2b77..000000000 --- a/daiv/activity/static/activity/js/prompt-box.js +++ /dev/null @@ -1,175 +0,0 @@ -/** - * Thin Alpine state shell for the prompt box. - * - * Owns the chip list (`repos`), the use-max toggle, and the open/close state - * of the HTMX-driven repo/branch pickers. The list contents themselves are - * server-rendered into `#repo-picker-list` / `#branch-picker-list` and attach - * back into this component's state via `@click="setRepo(...)"` / `setBranch(...)` - * — Alpine's MutationObserver picks those directives up when HTMX swaps them in. - */ -document.addEventListener("alpine:init", () => { - Alpine.data("promptBox", ({ - initialRepos = [], - initialUseMax = false, - maxRepos = 1, - repoPickerUrl = "", - branchPickerTemplate = "", - conflictMessageTemplate = "Repository already in the list: __LABEL__.", - onChangeEvent = "", - }) => ({ - repos: (initialRepos || []).map(r => ({ slug: r.repo_id, ref: r.ref || "" })), - useMax: initialUseMax, - maxRepos, - repoPickerUrl, - branchPickerTemplate, - conflictMessageTemplate, - onChangeEvent, - - popover: null, - editingIndex: null, - repoLoading: false, - branchLoading: false, - conflictIndex: null, - _conflictTimer: null, - - init() { - this.$el.addEventListener("htmx:beforeRequest", (e) => { - if (e.target === this.$refs.repoSearch) this.repoLoading = true; - if (e.target === this.$refs.branchSearch) this.branchLoading = true; - }); - this.$el.addEventListener("htmx:afterSwap", (e) => { - if (e.target === this.$refs.repoPickerList) this.repoLoading = false; - if (e.target === this.$refs.branchPickerList) this.branchLoading = false; - }); - this.$el.addEventListener("htmx:sendError", (e) => { - if (e.target === this.$refs.repoSearch) this.repoLoading = false; - if (e.target === this.$refs.branchSearch) this.branchLoading = false; - }); - }, - - _emitChange() { - if (!this.onChangeEvent) return; - window.dispatchEvent( - new CustomEvent(this.onChangeEvent, { - detail: { repos: this.repos.map((r) => ({ repo_id: r.slug, ref: r.ref || "" })) }, - }), - ); - }, - - destroy() { - if (this._conflictTimer) clearTimeout(this._conflictTimer); - }, - - get conflictMessage() { - const repo = this.conflictIndex === null ? null : this.repos[this.conflictIndex]; - if (!repo) return ""; - const label = repo.ref ? `${repo.slug} on ${repo.ref}` : repo.slug; - return this.conflictMessageTemplate.replace("__LABEL__", label); - }, - - openRepoPicker(index = null) { - this.editingIndex = index; - this.repoLoading = true; - this.popover = "repo"; - this.$nextTick(() => this._refresh(this.$refs.repoSearch)); - }, - - openBranchPicker(index) { - const repo = this.repos[index]; - if (!repo) return; - this.editingIndex = index; - this.branchLoading = true; - this.popover = "branch"; - // Slug goes into a Django converter that accepts '/', so we leave the - // separator unencoded — nginx's default `allow_encoded_slashes off` would 404 on %2F. - const url = - this.branchPickerTemplate.replace("__SLUG__", repo.slug) + - "?selected=" + - encodeURIComponent(repo.ref || ""); - this.$nextTick(() => { - const input = this.$refs.branchSearch; - if (!input) return; - input.setAttribute("hx-get", url); - // HTMX caches parsed hx-* attributes at processing time — re-process so the - // new URL is picked up instead of the `__SLUG__` placeholder. - window.htmx.process(input); - this._refresh(input); - }); - }, - - _refresh(input) { - if (!input) return; - input.value = ""; - window.htmx.trigger(input, "refresh"); - }, - - closePopover() { - this.popover = null; - this.editingIndex = null; - }, - - setRepo(slug, defaultBranch) { - const ref = defaultBranch || ""; - const conflict = this._findConflict(slug, ref, this.editingIndex); - if (conflict !== -1) { - this._flagConflict(conflict); - this.closePopover(); - return; - } - const entry = { slug, ref }; - if (this.editingIndex === null) this.repos.push(entry); - else this.repos.splice(this.editingIndex, 1, entry); - this.closePopover(); - this._emitChange(); - }, - - setBranch(ref) { - if (this.editingIndex == null) return; - const repo = this.repos[this.editingIndex]; - const conflict = this._findConflict(repo.slug, ref, this.editingIndex); - if (conflict !== -1) { - this._flagConflict(conflict); - this.closePopover(); - return; - } - this.repos[this.editingIndex].ref = ref; - this.closePopover(); - this._emitChange(); - }, - - remove(index) { - this.repos.splice(index, 1); - if (this.conflictIndex !== null) { - if (this.conflictIndex === index) this._clearConflict(); - else if (index < this.conflictIndex) this.conflictIndex -= 1; - } - if (this.editingIndex !== null) { - if (this.editingIndex === index) this.closePopover(); - else if (index < this.editingIndex) this.editingIndex -= 1; - } - this._emitChange(); - }, - - _findConflict(slug, ref, skipIndex) { - return this.repos.findIndex( - (r, i) => i !== skipIndex && r.slug === slug && (r.ref || "") === (ref || ""), - ); - }, - - _flagConflict(index) { - this.conflictIndex = index; - if (this._conflictTimer) clearTimeout(this._conflictTimer); - this._conflictTimer = setTimeout(() => this._clearConflict(), 3000); - }, - - _clearConflict() { - this.conflictIndex = null; - this._conflictTimer = null; - }, - - autosize(el) { - el.style.height = "auto"; - el.style.height = el.scrollHeight + "px"; - }, - })); -}); diff --git a/daiv/activity/templates/activity/_header.html b/daiv/activity/templates/activity/_header.html deleted file mode 100644 index 26cc51742..000000000 --- a/daiv/activity/templates/activity/_header.html +++ /dev/null @@ -1,15 +0,0 @@ -{% load activity_tags humanize %} -
-

{% activity_title activity %}

-
- {{ activity.get_trigger_type_display }} - {% if activity.user %} - · - {% include "accounts/_avatar.html" with user=activity.user label="Started by" %} - {% elif activity.external_username %} - · - {{ activity.external_username }} - {% endif %} - · {{ activity.created_at|naturaltime }} -
-
diff --git a/daiv/activity/templates/activity/_hero_failed.html b/daiv/activity/templates/activity/_hero_failed.html deleted file mode 100644 index 161623296..000000000 --- a/daiv/activity/templates/activity/_hero_failed.html +++ /dev/null @@ -1,17 +0,0 @@ -
-

Error

- {% if activity.task_result and activity.task_result.exception_class_path %} -

{{ activity.task_result.exception_class_path }}

- {% if activity.task_result.traceback %} -
-
{{ activity.task_result.traceback }}
-
- {% endif %} - {% elif activity.error_message %} -
-
{{ activity.error_message }}
-
- {% else %} -

No error details available.

- {% endif %} -
diff --git a/daiv/activity/templates/activity/_hero_pruned.html b/daiv/activity/templates/activity/_hero_pruned.html deleted file mode 100644 index f8f1abdfc..000000000 --- a/daiv/activity/templates/activity/_hero_pruned.html +++ /dev/null @@ -1,18 +0,0 @@ -{% load markdown_tags %} -
-
-

Result

-
- Copy - Markdown -
-
-

- Full result pruned after the retention period. -

- {% if activity.result_summary %} -
- {{ activity.result_summary|render_markdown }} -
- {% endif %} -
diff --git a/daiv/activity/templates/activity/_hero_queued.html b/daiv/activity/templates/activity/_hero_queued.html deleted file mode 100644 index 24d97841d..000000000 --- a/daiv/activity/templates/activity/_hero_queued.html +++ /dev/null @@ -1,17 +0,0 @@ -
-
-

Result

-
- Copy - Markdown -
-
-
-
- Job is queued behind a prior run on this thread -

Waiting in queue

-

A prior run on this thread is still in flight. This job will start when that one finishes.

-
-
diff --git a/daiv/activity/templates/activity/_hero_running.html b/daiv/activity/templates/activity/_hero_running.html deleted file mode 100644 index 8527f498a..000000000 --- a/daiv/activity/templates/activity/_hero_running.html +++ /dev/null @@ -1,34 +0,0 @@ -
-
-

Result

-
- Copy - Markdown -
-
-
-
- Agent is still running -

Agent is working

-

This page refreshes automatically when the run finishes.

- {% if activity.started_at %} -

this.elapsed++, 1000); - }, - destroy() { - if (this.tickId) clearInterval(this.tickId); - } - }" - x-text="'⏱ ' + fmt(elapsed) + ' elapsed'">⏱ elapsed

- {% endif %} -
-
diff --git a/daiv/activity/templates/activity/_hero_success.html b/daiv/activity/templates/activity/_hero_success.html deleted file mode 100644 index bb1bd0ee0..000000000 --- a/daiv/activity/templates/activity/_hero_success.html +++ /dev/null @@ -1,43 +0,0 @@ -{% load icon_tags l10n markdown_tags %} -{% with response_text=activity.response_text %} -
- {% if response_text %}{{ response_text|json_script:"activity-result-raw" }}{% endif %} -
-

Result

-
- {% if activity.merge_request_web_url %} - - {% icon "merge-request" "h-4 w-4" %} - Open !{{ activity.merge_request_iid|unlocalize }} - - {% endif %} - {% if activity.thread_id %} -
- {% csrf_token %} - -
- {% endif %} - {% if response_text %} - {% include "activity/_copy_markdown_button.html" with source_id="activity-result-raw" label="Copy" %} - - {% icon "arrow-down-tray" "h-4 w-4" %} - Markdown - - {% endif %} -
-
- {% if response_text %} -
- {{ response_text|render_markdown }} -
- {% else %} -

No result data available.

- {% endif %} -
-{% endwith %} diff --git a/daiv/activity/templates/activity/_prompt_disclosure.html b/daiv/activity/templates/activity/_prompt_disclosure.html deleted file mode 100644 index 7b3171bad..000000000 --- a/daiv/activity/templates/activity/_prompt_disclosure.html +++ /dev/null @@ -1,21 +0,0 @@ -{% load activity_tags markdown_tags %} -{% if activity.prompt %} -{{ activity.prompt|json_script:"activity-prompt-raw" }} -
- -
- - Prompt - {% with tokens=activity.prompt|approx_prompt_tokens %} - {% if tokens %} - — ≈ {{ tokens|format_tokens }} tokens - {% endif %} - {% endwith %} -
- {% include "activity/_copy_markdown_button.html" with source_id="activity-prompt-raw" label="Copy prompt" %} -
-
- {{ activity.prompt|render_markdown }} -
-
-{% endif %} diff --git a/daiv/activity/templates/activity/_rail_context.html b/daiv/activity/templates/activity/_rail_context.html deleted file mode 100644 index 842971705..000000000 --- a/daiv/activity/templates/activity/_rail_context.html +++ /dev/null @@ -1,112 +0,0 @@ -{% load agent_tags i18n l10n %} -
-
Context
-
-
-
Repository
-
{{ activity.repo_id }}
-
- - {% if activity.ref %} -
-
Branch
-
{{ activity.ref }}
-
- {% endif %} - - {% if activity.merge_request_iid %} -
-
MR
-
- {% if activity.merge_request_web_url %} - - !{{ activity.merge_request_iid|unlocalize }} - - {% else %} - !{{ activity.merge_request_iid|unlocalize }} - {% endif %} -
-
- {% endif %} - - {% if activity.issue_iid %} -
-
Issue
-
#{{ activity.issue_iid|unlocalize }}
-
- {% endif %} - - {% if activity.scheduled_job %} -
-
Schedule
-
- {% if is_schedule_owner_or_admin %} - - {{ activity.scheduled_job.name }} - - {% else %} - {{ activity.scheduled_job.name }} - {% endif %} -
-
- {% if is_subscriber %} -
-
- {% csrf_token %} - - -
-
- {% endif %} - {% endif %} - - {% if user.is_admin and activity.user and activity.user != user %} -
-
Owner
-
- {% include "accounts/_avatar.html" with user=activity.user label="Owner" %} - {{ activity.user }} -
-
- {% endif %} - - {% if activity.agent_model %} -
-
Model
-
- {% agent_model_pill activity.agent_model activity.agent_thinking_level %} -
-
- {% elif activity.use_max %} -
-
Model
-
Max
-
- {% endif %} - -
-
Environment
-
- {% if activity.sandbox_environment %} - {% if can_edit_sandbox_environment %} - - {{ activity.sandbox_environment.name }} - - {% else %} - {{ activity.sandbox_environment.name }} - {% endif %} - {% if activity.sandbox_environment.short_summary %} -
{{ activity.sandbox_environment.short_summary }}
- {% endif %} - {% else %} - {% translate "(deleted)" %} - {% endif %} -
-
-
-
diff --git a/daiv/activity/templates/activity/_rail_timing.html b/daiv/activity/templates/activity/_rail_timing.html deleted file mode 100644 index ec06b9362..000000000 --- a/daiv/activity/templates/activity/_rail_timing.html +++ /dev/null @@ -1,45 +0,0 @@ -{% load activity_tags %} -
-
Timing
-
    -
  1. - - Created - {{ activity.created_at|date:"H:i:s" }} -
  2. -
  3. - {% if activity.started_at %} - - Started - {{ activity.started_at|date:"H:i:s" }} - {% else %} - - Started - - {% endif %} -
  4. -
  5. - {% if activity.status == "SUCCESSFUL" %} - - Finished - {{ activity.finished_at|date:"H:i:s" }} - {% elif activity.status == "FAILED" %} - - Failed - {{ activity.finished_at|date:"H:i:s" }} - {% else %} - - Finished - - {% endif %} -
  6. -
-
- - {% if activity.status == "RUNNING" or activity.status == "READY" %}Elapsed{% else %}Duration{% endif %} - - - {% if activity.duration %}{{ activity.duration|duration }}{% else %}—{% endif %} - -
-
diff --git a/daiv/activity/templates/activity/_rail_usage.html b/daiv/activity/templates/activity/_rail_usage.html deleted file mode 100644 index 681a74ced..000000000 --- a/daiv/activity/templates/activity/_rail_usage.html +++ /dev/null @@ -1,51 +0,0 @@ -{% load activity_tags %} -
-
Usage
-
-
-
- {% if activity.total_tokens %}{{ activity.total_tokens|format_tokens }}{% else %}—{% endif %} -
-
Tokens
-
-
-
- {% if activity.cost_usd %}{{ activity.cost_usd|format_cost }}{% else %}—{% endif %} -
-
Est. cost
-
-
- - {% if activity.input_tokens or activity.output_tokens %} -
-
-
Input
-
{{ activity.input_tokens|format_tokens|default:"—" }}
-
-
-
Output
-
{{ activity.output_tokens|format_tokens|default:"—" }}
-
-
- {% elif not activity.total_tokens %} -

- {% if activity.status == "RUNNING" or activity.status == "READY" %}Reported after the run finishes{% else %}Not available{% endif %} -

- {% endif %} - - {% if activity.usage_by_model and activity.usage_by_model.keys|length > 1 %} -
- Per-model breakdown -
- {% for model_name, model_usage in activity.usage_by_model.items %} -
- {{ model_name }} - {{ model_usage.input_tokens|format_tokens }} in - {{ model_usage.output_tokens|format_tokens }} out - {% if model_usage.cost_usd %}{{ model_usage.cost_usd|format_cost }}{% endif %} -
- {% endfor %} -
-
- {% endif %} -
diff --git a/daiv/activity/templates/activity/_status_pill.html b/daiv/activity/templates/activity/_status_pill.html deleted file mode 100644 index 56c6bcf23..000000000 --- a/daiv/activity/templates/activity/_status_pill.html +++ /dev/null @@ -1,10 +0,0 @@ -{% comment %} -Status pill. Required: variant, label. -Pass pk+status (list rows) to wire up in-place Alpine updates. Otherwise static. -{% endcomment %} - - - {{ label }} - diff --git a/daiv/activity/templates/activity/_status_strip.html b/daiv/activity/templates/activity/_status_strip.html deleted file mode 100644 index c5f87e34c..000000000 --- a/daiv/activity/templates/activity/_status_strip.html +++ /dev/null @@ -1,38 +0,0 @@ -{% load activity_tags icon_tags %} -
- - {% include "activity/_status_pill.html" with variant=activity.status|status_variant label=activity.get_status_display %} - - {% include "activity/_trigger_badge.html" with trigger_type=activity.trigger_type trigger_type_display=activity.get_trigger_type_display %} - - {% if activity.duration %} - · - {{ activity.duration|duration }} - {% endif %} - - {% if activity.cost_usd %} - · - {{ activity.cost_usd|format_cost }} - {% endif %} - - {% if activity.total_tokens %} - · - {{ activity.total_tokens|format_tokens }} tokens - {% endif %} - - {% if activity.status == "SUCCESSFUL" or activity.status == "FAILED" %} - {% if not activity.task_result %} - · - pruned - {% endif %} - {% endif %} - - {% if activity.is_retryable %} - - {% icon "arrow-path" "h-4 w-4" %} - Retry - - {% endif %} -
diff --git a/daiv/activity/templates/activity/_trigger_badge.html b/daiv/activity/templates/activity/_trigger_badge.html deleted file mode 100644 index c8bd14478..000000000 --- a/daiv/activity/templates/activity/_trigger_badge.html +++ /dev/null @@ -1,9 +0,0 @@ -{% if trigger_type == "api_job" or trigger_type == "mcp_job" %} -{{ trigger_type_display }} -{% elif trigger_type == "schedule" %} -Schedule -{% elif trigger_type == "issue_webhook" %} -Issue -{% elif trigger_type == "mr_webhook" %} -MR/PR -{% endif %} diff --git a/daiv/activity/templates/activity/activity_detail.html b/daiv/activity/templates/activity/activity_detail.html deleted file mode 100644 index 6aaa7adc9..000000000 --- a/daiv/activity/templates/activity/activity_detail.html +++ /dev/null @@ -1,56 +0,0 @@ -{% extends "base_app.html" %} -{% load activity_tags static %} - -{% block title %}{% activity_title activity %} — DAIV{% endblock %} - -{% block container_width %}max-w-screen-2xl{% endblock %} - -{% block alpine_plugins %} -{% if is_in_flight %} - -{% endif %} -{% endblock alpine_plugins %} - -{% block breadcrumb %} -{% include "accounts/_breadcrumb.html" with crumbs=breadcrumbs %} -{% endblock breadcrumb %} - -{% block app_content %} -
- - {% include "activity/_header.html" %} - {% include "activity/_status_strip.html" %} - -
-
- {% if activity.status == "SUCCESSFUL" %} - {% if activity.task_result %} - {% include "activity/_hero_success.html" %} - {% else %} - {% include "activity/_hero_pruned.html" %} - {% endif %} - {% elif activity.status == "FAILED" %} - {% include "activity/_hero_failed.html" %} - {% elif activity.status == "QUEUED" %} - {% include "activity/_hero_queued.html" %} - {% else %} - {% include "activity/_hero_running.html" %} - {% endif %} - - {% if activity.status == "RUNNING" or activity.status == "READY" or activity.status == "QUEUED" %} - {% include "activity/_prompt_disclosure.html" with prompt_open_by_default=True %} - {% elif activity.status == "SUCCESSFUL" and not activity.task_result and not activity.result_summary %} - {% include "activity/_prompt_disclosure.html" with prompt_open_by_default=True %} - {% else %} - {% include "activity/_prompt_disclosure.html" %} - {% endif %} -
- - -
-
-{% endblock app_content %} diff --git a/daiv/activity/templates/activity/activity_list.html b/daiv/activity/templates/activity/activity_list.html deleted file mode 100644 index beb5ec443..000000000 --- a/daiv/activity/templates/activity/activity_list.html +++ /dev/null @@ -1,182 +0,0 @@ -{% extends "base_app.html" %} -{% load activity_tags dashboard_tags humanize i18n icon_tags l10n static %} - -{% block title %}Agent Activity — DAIV{% endblock %} - -{% block container_width %}max-w-6xl{% endblock %} - -{% block alpine_plugins %} - - - -{% endblock alpine_plugins %} - -{% block app_content %} -
-
-
-

Agent Activity

-

- {% if schedule_name %}{{ schedule_name }} · {% endif %}All agent executions across jobs, schedules, and webhooks. -

-
- {% translate "Start a run" %} -
- - -
- -
-
- - All - - {% for value, label in statuses %} - - {{ label }} - - {% endfor %} -
- - -
- - All types - - {% for value, label in trigger_types %} - - {{ label }} - - {% endfor %} -
-
- - -
- -
- {% include "codebase/_repo_combobox.html" %} -
- -
- {% if current_status %}{% endif %} - {% if current_trigger %}{% endif %} - {% if current_repo %}{% endif %} - {% if current_schedule %}{% endif %} - {% if current_batch %}{% endif %} - - to - -
- - {% if current_batch %} - - {% blocktranslate with id=current_batch_short %}Batch {{ id }}{% endblocktranslate %} - × - - {% endif %} - - {% if has_active_filters %} - - Clear filters - - {% endif %} -
-
- - -
- {% if activities %} -
- {% for a in activities %} -
- -
-
- {% include "activity/_status_pill.html" with variant=a.status|status_variant label=a.get_status_display pk=a.pk status=a.status %} -

- {% activity_title a %} -

- {% if a.merge_request_iid and a.merge_request_web_url %} - - {% icon "merge-request" "h-3 w-3" %} - !{{ a.merge_request_iid|unlocalize }} - - {% endif %} -
-
- {{ a.get_trigger_type_display }} - {% if a.user %} - · - {% include "accounts/_avatar.html" with user=a.user label="Owner" %} - {% elif a.external_username %} - · - {{ a.external_username }} - {% endif %} - · - {{ a.repo_id }} - · - {{ a.created_at|naturaltime }} - {% if a.duration %} - · - {{ a.duration|duration }} - {% endif %} - {% if a.total_tokens %} - · - {{ a.total_tokens|format_tokens }} tokens - {% endif %} -
-
- {% icon "chevron-right" "ml-4 h-3.5 w-3.5 shrink-0 text-gray-700 transition-colors group-hover:text-gray-400" %} -
- {% endfor %} -
- {% include "accounts/_pagination.html" %} - {% else %} -
-

- {% if has_active_filters %} - No activities match your filters. - {% else %} - No agent activity recorded yet. - {% endif %} -

-
- {% endif %} -
-
-{% endblock app_content %} diff --git a/daiv/activity/templates/activity/agent_run_form.html b/daiv/activity/templates/activity/agent_run_form.html deleted file mode 100644 index 0886952da..000000000 --- a/daiv/activity/templates/activity/agent_run_form.html +++ /dev/null @@ -1,63 +0,0 @@ -{% extends "base_app.html" %} -{% load i18n static %} - -{% block title %}{% if source_activity %}Retry run{% else %}Start a run{% endif %} — DAIV{% endblock %} - -{% block container_width %}max-w-3xl{% endblock %} - -{% block alpine_plugins %} - - -{% include "sandbox_envs/_scripts.html" %} -{% endblock alpine_plugins %} - -{% block breadcrumb %} -{% include "accounts/_breadcrumb.html" with crumbs=breadcrumbs %} -{% endblock breadcrumb %} - -{% block app_content %} -
-

- {% if source_activity %}Retry run{% else %}Start a run{% endif %} -

-

- {% if source_activity %} - Retried from - - {{ source_activity.repo_id }} · {{ source_activity.created_at|date:"Y-m-d H:i" }} - - {% else %} - Launch a new agent run on a repository. - {% endif %} -

-
- -{% if form.non_field_errors %} -
- {% for error in form.non_field_errors %} -

{{ error }}

- {% endfor %} -
-{% endif %} - -
- {% csrf_token %} - -
- {% include "activity/_agent_run_fields.html" with form=form with_env_picker=True env_picker_envs=sandbox_envs env_picker_selected_id=selected_sandbox_env_id %} -
- - {% include "notifications/_notify_on_radio.html" with form=form %} - -
- - - Cancel - -
-
-{% include "sandbox_envs/_env_drawer.html" %} -{% endblock app_content %} diff --git a/daiv/activity/templatetags/activity_tags.py b/daiv/activity/templatetags/activity_tags.py index 8b174c7bf..3abfde966 100644 --- a/daiv/activity/templatetags/activity_tags.py +++ b/daiv/activity/templatetags/activity_tags.py @@ -1,33 +1,17 @@ +"""Historical templatetag library — kept for templates that still load activity_tags. + +The Activity model has been replaced by sessions.Run. The tags and filters below +are model-agnostic utilities; the Activity-specific ``activity_title`` tag has been +removed. Templates that used ``activity_title`` should switch to ``session_tags``. +""" + from decimal import Decimal from django import template -from activity.models import TriggerType - register = template.Library() _CENT = Decimal("0.01") -_TITLE_MAX_LEN = 100 - - -@register.simple_tag -def activity_title(activity) -> str: - """Derive a human-meaningful title for an Activity.""" - if stored := (activity.title or "").strip(): - return stored - - prompt = (activity.prompt or "").strip() - if prompt: - first_line = next((line for line in prompt.splitlines() if line.strip()), "").strip() - if len(first_line) > _TITLE_MAX_LEN: - return first_line[:_TITLE_MAX_LEN] + "…" - return first_line - - if activity.trigger_type == TriggerType.ISSUE_WEBHOOK: - return f"Issue #{activity.issue_iid}" if activity.issue_iid else "Issue" - if activity.trigger_type == TriggerType.MR_WEBHOOK: - return f"MR/PR !{activity.merge_request_iid}" if activity.merge_request_iid else "MR/PR" - return f"{activity.get_trigger_type_display()} on {activity.repo_id}" @register.filter @@ -83,5 +67,5 @@ def approx_prompt_tokens(prompt) -> int: @register.filter def status_variant(status) -> str: - """Map ActivityStatus to the CSS/Alpine variant suffix used by status-badge / status-dot.""" + """Map a status string to the CSS/Alpine variant suffix used by status-badge / status-dot.""" return _STATUS_VARIANTS.get(status, "pending") diff --git a/daiv/activity/urls.py b/daiv/activity/urls.py deleted file mode 100644 index 0e31adb39..000000000 --- a/daiv/activity/urls.py +++ /dev/null @@ -1,10 +0,0 @@ -from django.urls import path - -from activity.views import ActivityDetailView, ActivityDownloadMarkdownView, ActivityListView, ActivityStreamView - -urlpatterns = [ - path("", ActivityListView.as_view(), name="activity_list"), - path("stream/", ActivityStreamView.as_view(), name="activity_stream"), - path("/", ActivityDetailView.as_view(), name="activity_detail"), - path("/download/md/", ActivityDownloadMarkdownView.as_view(), name="activity_download_md"), -] diff --git a/daiv/activity/urls_runs.py b/daiv/activity/urls_runs.py deleted file mode 100644 index babed0264..000000000 --- a/daiv/activity/urls_runs.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.urls import path - -from activity.views import AgentRunCreateView - -app_name = "runs" - -urlpatterns = [path("new/", AgentRunCreateView.as_view(), name="agent_run_new")] diff --git a/daiv/activity/views.py b/daiv/activity/views.py deleted file mode 100644 index 83100e1d4..000000000 --- a/daiv/activity/views.py +++ /dev/null @@ -1,337 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import logging -import time -import uuid -from typing import TYPE_CHECKING - -from django.contrib import messages as messages_module -from django.contrib.auth.mixins import LoginRequiredMixin -from django.core.exceptions import PermissionDenied, SuspiciousOperation, ValidationError -from django.http import Http404, HttpResponse, HttpResponseBase, StreamingHttpResponse -from django.shortcuts import redirect -from django.urls import reverse -from django.utils.text import slugify -from django.utils.translation import gettext_lazy as _ -from django.views import View -from django.views.generic import DetailView, FormView - -from django_filters.views import FilterView -from sandbox_envs.models import Scope -from sandbox_envs.services import env_picker_context, resolve_repo_envs - -from accounts.mixins import BreadcrumbMixin -from activity.filters import ActivityFilter -from activity.forms import AgentRunCreateForm -from activity.models import Activity, ActivityStatus, TriggerType -from activity.services import RepoTarget, submit_batch_runs -from automation.agent.picker_context import agent_picker_context -from schedules.models import ScheduledJob - -logger = logging.getLogger("daiv.activity") - -if TYPE_CHECKING: - from django.db.models import QuerySet - from django.http import HttpRequest - - from accounts.models import User - - -class ActivityListView(LoginRequiredMixin, FilterView): - model = Activity - filterset_class = ActivityFilter - template_name = "activity/activity_list.html" - context_object_name = "activities" - paginate_by = 25 - # Preserve pre-django-filter UX: an invalid URL param (e.g. ?status=bogus) should - # silently drop that filter, not blank the whole list. - strict = False - - def get_queryset(self) -> QuerySet[Activity]: - return Activity.objects.by_owner(self.request.user).select_related("task_result", "scheduled_job", "user") - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - form = context["filter"].form - cleaned = form.cleaned_data if form.is_valid() else {} - context["current_status"] = cleaned.get("status") or "" - context["current_trigger"] = cleaned.get("trigger") or "" - context["current_repo"] = cleaned.get("repo") or "" - context["current_schedule"] = cleaned.get("schedule") or "" - context["current_batch"] = cleaned.get("batch") or "" - context["current_batch_short"] = str(context["current_batch"])[:8] if context["current_batch"] else "" - # Date fields are read raw: cleaned_data yields `date` objects, but the - # HTML `` needs the original ISO string to round-trip. - context["current_from"] = self.request.GET.get("date_from", "") - context["current_to"] = self.request.GET.get("date_to", "") - context["has_active_filters"] = any([ - context["current_status"], - context["current_trigger"], - context["current_repo"], - context["current_schedule"], - context["current_batch"], - context["current_from"], - context["current_to"], - ]) - context["trigger_types"] = TriggerType.choices - context["statuses"] = ActivityStatus.choices - # Resolve schedule name for display - if schedule_id := context["current_schedule"]: - schedule = ScheduledJob.objects.filter(pk=schedule_id).values_list("name", flat=True).first() - context["schedule_name"] = schedule or "" - - # Collect IDs of in-flight activities for SSE - in_flight = [str(a.id) for a in context["activities"] if a.status not in ActivityStatus.terminal()] - context["in_flight_ids"] = ",".join(in_flight) - - return context - - -class ActivityDetailView(BreadcrumbMixin, LoginRequiredMixin, DetailView): - model = Activity - template_name = "activity/activity_detail.html" - context_object_name = "activity" - - def get_queryset(self) -> QuerySet[Activity]: - return Activity.objects.by_owner(self.request.user).select_related( - "task_result", "scheduled_job", "user", "sandbox_environment" - ) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - activity: Activity = context["activity"] - context["is_in_flight"] = activity.status not in ActivityStatus.terminal() - - user = self.request.user - schedule = activity.scheduled_job - context["is_schedule_owner_or_admin"] = user.is_admin or (schedule is not None and schedule.user_id == user.pk) - context["is_subscriber"] = bool( - schedule is not None and schedule.user_id != user.pk and schedule.subscribers.filter(pk=user.pk).exists() - ) - env = activity.sandbox_environment - context["can_edit_sandbox_environment"] = bool( - env - and ((env.scope == Scope.GLOBAL and user.is_admin) or (env.scope == Scope.USER and env.user_id == user.pk)) - ) - return context - - def get_breadcrumbs(self): - return [ - {"label": "Activity", "url": reverse("activity_list")}, - {"label": f"Run {str(self.object.pk)[:8]} — {self.object.repo_id}", "url": None}, - ] - - -class ActivityDownloadMarkdownView(LoginRequiredMixin, DetailView): - """Serve the activity result as a downloadable Markdown file.""" - - model = Activity - - def get_queryset(self) -> QuerySet[Activity]: - return super().get_queryset().filter(status=ActivityStatus.SUCCESSFUL).select_related("task_result") - - def get(self, request, *args, **kwargs): - activity = self.get_object() - content = self._build_markdown(activity) - if not content: - raise Http404 - filename = self._build_filename(activity) - response = HttpResponse(content, content_type="text/markdown; charset=utf-8") - response["Content-Disposition"] = f'attachment; filename="{filename}"' - return response - - def _build_markdown(self, activity: Activity) -> str: - response_text = activity.response_text - if not response_text: - return "" - - meta_lines = ["---", f"repository: {activity.repo_id}", f"trigger: {activity.get_trigger_type_display()}"] - if activity.ref: - meta_lines.append(f"ref: {activity.ref}") - meta_lines.append(f"created: {activity.created_at.strftime('%Y-%m-%d %H:%M:%S %Z')}") - if activity.finished_at: - meta_lines.append(f"finished: {activity.finished_at.strftime('%Y-%m-%d %H:%M:%S %Z')}") - if activity.issue_iid: - meta_lines.append(f"issue: '#{activity.issue_iid}'") - if activity.merge_request_iid: - meta_lines.append(f"merge_request: '!{activity.merge_request_iid}'") - if activity.total_tokens: - meta_lines.append(f"total_tokens: {activity.total_tokens}") - if activity.cost_usd is not None: - meta_lines.append(f"cost_usd: '{activity.cost_usd}'") - meta_lines.append("---") - - return "\n".join(meta_lines) + "\n\n" + response_text - - def _build_filename(self, activity: Activity) -> str: - repo_slug = slugify(activity.repo_id.replace("/", "-")) or "unknown" - date_str = activity.created_at.strftime("%Y-%m-%d") - return f"daiv-{repo_slug}-{date_str}.md" - - -POLL_INTERVAL = 2.0 -MAX_DURATION = 300.0 - - -class ActivityStreamView(View): - """SSE endpoint that streams status updates for in-flight activities.""" - - async def get(self, request: HttpRequest) -> HttpResponseBase: - user = await request.auser() - if not user.is_authenticated: - return HttpResponse(status=403) - - ids_param = request.GET.get("ids", "") - uuids: list[uuid.UUID] = [] - for part in ids_param.split(","): - try: - uuids.append(uuid.UUID(part.strip())) - except ValueError: - continue - - if not uuids: - return HttpResponse(status=400) - - return StreamingHttpResponse( - self._stream(uuids, user), - content_type="text/event-stream", - headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, - ) - - async def _stream(self, activity_ids: list[uuid.UUID], user: User): - """Stream current Activity state to the browser. - - Sync from DBTaskResult happens in the worker via django-tasks signals; this view - only reads already-synced rows and emits SSE events for state changes. - """ - tracking = set(activity_ids) - terminal = ActivityStatus.terminal() - start = time.monotonic() - last_emitted: dict[uuid.UUID, tuple[str, str | None, str | None]] = {} - - while tracking and (time.monotonic() - start) < MAX_DURATION: - await asyncio.sleep(POLL_INTERVAL) - - activities = ( - Activity.objects - .by_owner(user) - .filter(id__in=tracking) - .only("id", "status", "started_at", "finished_at") - ) - - async for activity in activities: - started_iso = activity.started_at.isoformat() if activity.started_at else None - finished_iso = activity.finished_at.isoformat() if activity.finished_at else None - current_state = (activity.status, started_iso, finished_iso) - - if last_emitted.get(activity.id) != current_state: - last_emitted[activity.id] = current_state - data = json.dumps({ - "id": str(activity.id), - "status": activity.status, - "started_at": started_iso, - "finished_at": finished_iso, - }) - yield f"data: {data}\n\n" - - if activity.status in terminal: - tracking.discard(activity.id) - - yield 'data: {"done": true}\n\n' - - -class AgentRunCreateView(LoginRequiredMixin, BreadcrumbMixin, FormView): - """Serve the "Start a run" page and submit new UI-initiated agent runs. - - ``GET /runs/new/`` renders a blank form. ``GET /runs/new/?from=`` - pre-fills the form from a retryable source Activity. ``POST`` enqueues - ``run_job_task`` and creates a UI_JOB Activity, redirecting to the detail page. - """ - - template_name = "activity/agent_run_form.html" - form_class = AgentRunCreateForm - - _SOURCE_UNSET = object() - - def _get_source_activity(self) -> Activity | None: - # Memoize per-request: ``get_initial`` and ``get_context_data`` both call this on retry GETs. - cached = getattr(self, "_source_cached", self._SOURCE_UNSET) - if cached is not self._SOURCE_UNSET: - return cached - source_id = self.request.GET.get("from") - if not source_id: - self._source_cached = None - return None - try: - source = Activity.objects.by_owner(self.request.user).filter(pk=source_id).first() - except (ValueError, ValidationError) as err: - # Malformed UUID on ``?from=`` is user error, not server error. - raise Http404("Invalid activity id.") from err - if source is None or not source.is_retryable: - raise Http404("Activity is not retryable.") - self._source_cached = source - return source - - def get_initial(self) -> dict: - initial: dict = {"notify_on": self.request.user.notify_on_jobs} - source = self._get_source_activity() - if source is not None: - initial.update({ - "prompt": source.prompt, - "repos": [{"repo_id": source.repo_id, "ref": source.ref}], - "agent_model": source.agent_model, - "agent_thinking_level": source.agent_thinking_level, - }) - return initial - - def get_context_data(self, **kwargs): - ctx = super().get_context_data(**kwargs) - ctx["source_activity"] = self._get_source_activity() - ctx.update(env_picker_context(ctx["form"])) - ctx.update(agent_picker_context(ctx["form"])) - return ctx - - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs["user"] = self.request.user - return kwargs - - def form_valid(self, form): - repos = [RepoTarget(repo_id=r["repo_id"], ref=r["ref"]) for r in form.cleaned_data["repos"]] - env = form.cleaned_data.get("sandbox_environment") - repos = resolve_repo_envs(user=self.request.user, repos=repos, explicit_env_id=str(env.id) if env else None) - try: - result = submit_batch_runs( - user=self.request.user, - prompt=form.cleaned_data["prompt"], - repos=repos, - agent_model=form.cleaned_data["agent_model"], - agent_thinking_level=form.cleaned_data["agent_thinking_level"], - notify_on=form.cleaned_data["notify_on"], - trigger_type=TriggerType.UI_JOB, - ) - except Http404, PermissionDenied, SuspiciousOperation: - # Let Django middleware render these as 4xx instead of swallowing as "submit failed" 200. - raise - except Exception: - logger.exception( - "Failed to submit UI run", - extra={"user_pk": self.request.user.pk, "repos": form.cleaned_data.get("repos")}, - ) - form.add_error(None, _("Failed to submit the run. Please try again in a moment.")) - return self.form_invalid(form) - - if result.failed: - failed_ids = ", ".join(f.repo_id for f in result.failed) - messages_module.warning( - self.request, _("Some repositories failed to submit: %(ids)s") % {"ids": failed_ids} - ) - - if len(result.activities) == 1 and not result.failed: - return redirect("activity_detail", pk=result.activities[0].pk) - return redirect(reverse("activity_list") + f"?batch={result.batch_id}") - - def get_breadcrumbs(self): - return [{"label": "Activity", "url": reverse("activity_list")}, {"label": "Start a run", "url": None}] diff --git a/daiv/automation/titling/tasks.py b/daiv/automation/titling/tasks.py index 8fde782c6..749315553 100644 --- a/daiv/automation/titling/tasks.py +++ b/daiv/automation/titling/tasks.py @@ -85,31 +85,22 @@ def _invoke_titler(structured_llm, *, prompt: str, repo_id: str = "", ref: str = @task() def generate_title_task( - entity_type: Literal["session", "run", "chat_thread", "activity"], pk: str, prompt: str, repo_id: str, ref: str = "" + entity_type: Literal["session", "run"], pk: str, prompt: str, repo_id: str, ref: str = "" ) -> None: - """Overwrite a Session/Run (or legacy ChatThread/Activity) title with an LLM-generated one. + """Overwrite a Session/Run title with an LLM-generated one. Failures propagate to django-tasks (which logs + marks the task failed); the title set synchronously remains (heuristic for chat sessions, possibly empty - for prompt-driven runs). The legacy ``chat_thread``/``activity`` literals stay - supported during the sessions-unification dual period (removed in Task 15). + for prompt-driven runs). """ if entity_type == "session": from sessions.models import Session model_cls = Session - elif entity_type == "run": + else: from sessions.models import Run model_cls = Run - elif entity_type == "chat_thread": - from chat.models import ChatThread - - model_cls = ChatThread - else: - from activity.models import Activity - - model_cls = Activity try: entity = model_cls.objects.get(pk=pk) diff --git a/daiv/chat/managers.py b/daiv/chat/managers.py deleted file mode 100644 index 7597f2adb..000000000 --- a/daiv/chat/managers.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.db import models - - -class ChatThreadManager(models.Manager): - def for_user(self, user): - return self.filter(user=user) diff --git a/daiv/chat/migrations/0004_remove_chatthread_chat_chatth_user_id_abfd75_idx_and_more.py b/daiv/chat/migrations/0004_remove_chatthread_chat_chatth_user_id_abfd75_idx_and_more.py new file mode 100644 index 000000000..19602b185 --- /dev/null +++ b/daiv/chat/migrations/0004_remove_chatthread_chat_chatth_user_id_abfd75_idx_and_more.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.6 on 2026-07-07 23:12 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("chat", "0003_chatthread_agent_override_fields"), + # Data was already copied to sessions.Session before we drop the source table. + # Note: sessions app is registered under the label "agent_sessions". + ("agent_sessions", "0002_backfill_from_activity_and_chat"), + ] + + operations = [ + migrations.RemoveIndex(model_name="chatthread", name="chat_chatth_user_id_abfd75_idx"), + migrations.RemoveConstraint(model_name="chatthread", name="chat_active_run_id_nonempty"), + migrations.DeleteModel(name="ChatThread"), + ] diff --git a/daiv/chat/models.py b/daiv/chat/models.py index 01acb8b90..e2e8ccfdc 100644 --- a/daiv/chat/models.py +++ b/daiv/chat/models.py @@ -1,91 +1 @@ -from __future__ import annotations - -import logging - -from django.conf import settings -from django.db import models -from django.utils.translation import gettext_lazy as _ - -from automation.titling.services import TitlerService -from automation.titling.tasks import generate_title_task -from chat.managers import ChatThreadManager -from core.models import ThinkingLevelChoices - -logger = logging.getLogger("daiv.chat") - - -class ChatThread(models.Model): - """Metadata row for a chat conversation. The ``thread_id`` is the LangGraph - checkpoint key — shared with any ``activity.Activity`` that produced the run we're - continuing. - """ - - thread_id = models.CharField(max_length=64, primary_key=True) - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="chat_threads") - repo_id = models.CharField(_("repository"), max_length=255) - ref = models.CharField(_("ref"), max_length=255, blank=True, default="") - title = models.CharField(max_length=120, blank=True, default="") - # NULL means "free slot"; any non-NULL value is the run_id currently holding - # the thread. Empty string is forbidden by ``chat_active_run_id_nonempty`` - # so the sentinel is unambiguous. - active_run_id = models.CharField(max_length=64, null=True, blank=True, default=None) # noqa: DJ001 - created_at = models.DateTimeField(auto_now_add=True) - last_active_at = models.DateTimeField(auto_now=True) - sandbox_environment = models.ForeignKey( - "sandbox_envs.SandboxEnvironment", - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="chat_threads", - verbose_name=_("sandbox environment"), - ) - agent_model = models.CharField(_("agent model"), max_length=255, blank=True, default="") - agent_thinking_level = models.CharField( - _("agent thinking level"), max_length=20, blank=True, default="", choices=ThinkingLevelChoices.choices - ) - - objects = ChatThreadManager() - - class Meta: - ordering = ["-last_active_at"] - indexes = [models.Index(fields=["user", "-last_active_at"])] - constraints = [ - models.CheckConstraint( - condition=models.Q(active_run_id__isnull=True) | ~models.Q(active_run_id=""), - name="chat_active_run_id_nonempty", - ) - ] - - def __str__(self) -> str: - return str(self.title or self.thread_id) - - @classmethod - async def aget_or_create_from_activity(cls, user, activity) -> tuple[ChatThread, bool]: - """Look up or create a thread that continues an activity run. Idempotent.""" - # Reuse the activity's already-generated title when present — both rows describe - # the same underlying run, so re-titling would just spend tokens to land on the - # same answer. - existing_title = (activity.title or "").strip() - thread, created = await cls.objects.aget_or_create( - thread_id=activity.thread_id, - defaults={ - "user": user, - "repo_id": activity.repo_id, - "ref": activity.ref or "", - "title": existing_title or TitlerService.heuristic(activity.prompt or ""), - "agent_model": activity.agent_model or "", - "agent_thinking_level": activity.agent_thinking_level or "", - }, - ) - if created and not existing_title and activity.prompt: - try: - await generate_title_task.aenqueue( - entity_type="chat_thread", - pk=thread.thread_id, - prompt=activity.prompt, - repo_id=activity.repo_id, - ref=activity.ref or "", - ) - except Exception: # noqa: BLE001 - logger.exception("Failed to enqueue title task for chat thread %s", thread.thread_id) - return thread, created +"""Historical app — models replaced by the sessions app; kept for migration history.""" diff --git a/daiv/chat/templates/chat/chat_detail.html b/daiv/chat/templates/chat/chat_detail.html deleted file mode 100644 index c01928aa2..000000000 --- a/daiv/chat/templates/chat/chat_detail.html +++ /dev/null @@ -1,194 +0,0 @@ -{% extends "base_app.html" %} -{% load i18n icon_tags static %} - -{% block title %}{% if thread.title %}{{ thread.title }} — {% endif %}DAIV Chat{% endblock %} - -{% block container_width %}max-w-6xl flex flex-col min-h-full{% endblock %} - -{% block head_extra %} - - - - -{% endblock head_extra %} - -{% block alpine_plugins %} - - - - - - - - - - {% include "sandbox_envs/_scripts.html" %} -{% endblock alpine_plugins %} - -{% block breadcrumb %}{% include "accounts/_breadcrumb.html" %}{% endblock %} - -{% block app_content %} - {{ turns|json_script:"chat-initial-turns" }} - {{ merge_request|json_script:"chat-initial-merge-request" }} - - {# Translation lookups for the locked-pill fallbacks in chat({…}) below. #} - {# Tag→variable lets us pipe them through ``|escapejs`` for safe quoting in JS. #} - {% translate "Pick a model" as locked_agent_fallback %} - {% translate "Auto" as locked_env_fallback %} - -
- -
- {% if expired %} -
- {% translate "This conversation's state has expired. Start a new chat to continue." %} - {% translate "New chat" %} -
- {% endif %} - - {# Responsive summary strip shown below 1100px #} -
- · - · - - - -
- -
- {# Empty state — pick a repo first, then the composer fades in. #} - - - {# Turns #} - - -
- - -
-
- - {% if not expired %} - {% include "chat/_composer.html" %} - {% endif %} -
- - {% include "chat/_rail.html" %} -
- - {% include "sandbox_envs/_env_drawer.html" %} -{% endblock app_content %} diff --git a/daiv/chat/templates/chat/chat_list.html b/daiv/chat/templates/chat/chat_list.html deleted file mode 100644 index feaaa71b4..000000000 --- a/daiv/chat/templates/chat/chat_list.html +++ /dev/null @@ -1,48 +0,0 @@ -{% extends "base_app.html" %} -{% load i18n %} - -{% block breadcrumb %}{% include "accounts/_breadcrumb.html" %}{% endblock %} - -{% block app_content %} -
-

{% translate "Chat" %}

- {% translate "New chat" %} -
- -{% if threads %} - - {% include "accounts/_pagination.html" %} -{% else %} -
-

{% translate "No conversations yet." %}

- {% translate "Start a chat" %} -
-{% endif %} -{% endblock app_content %} diff --git a/daiv/chat/urls.py b/daiv/chat/urls.py deleted file mode 100644 index 43a140273..000000000 --- a/daiv/chat/urls.py +++ /dev/null @@ -1,10 +0,0 @@ -from django.urls import path - -from chat.views import ChatThreadDetailView, ChatThreadFromActivityView, ChatThreadListView - -urlpatterns = [ - path("", ChatThreadListView.as_view(), name="chat_list"), - path("new/", ChatThreadDetailView.as_view(), name="chat_new"), - path("/", ChatThreadDetailView.as_view(), name="chat_detail"), - path("from-activity//", ChatThreadFromActivityView.as_view(), name="chat_from_activity"), -] diff --git a/daiv/chat/views.py b/daiv/chat/views.py deleted file mode 100644 index e159da3e7..000000000 --- a/daiv/chat/views.py +++ /dev/null @@ -1,112 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import Http404, HttpResponseGone -from django.shortcuts import get_object_or_404, redirect -from django.urls import reverse -from django.views.generic import DetailView, ListView, View - -from activity.models import Activity -from asgiref.sync import async_to_sync -from sandbox_envs.models import SandboxEnvironment -from sessions.hydration import ahydrate_thread - -from accounts.mixins import BreadcrumbMixin -from automation.agent.picker_context import agent_picker_context -from chat.models import ChatThread -from chat.repo_state import aget_existing_mr_payload -from chat.turns import build_turns - - -class ChatThreadListView(LoginRequiredMixin, BreadcrumbMixin, ListView): - model = ChatThread - template_name = "chat/chat_list.html" - context_object_name = "threads" - paginate_by = 25 - - def get_queryset(self): - return ChatThread.objects.for_user(self.request.user) - - def get_breadcrumbs(self): - return [{"label": "Chat", "url": None}] - - -class ChatThreadDetailView(LoginRequiredMixin, BreadcrumbMixin, DetailView): - """Renders the chat page for a specific thread, or the empty state when no - ``thread_id`` URL kwarg is present (the ``chat_new`` route). - """ - - model = ChatThread - template_name = "chat/chat_detail.html" - context_object_name = "thread" - pk_url_kwarg = "thread_id" - - def get_queryset(self): - return ChatThread.objects.for_user(self.request.user) - - def get_object(self, queryset=None): - if "thread_id" not in self.kwargs: - return None - return super().get_object(queryset) - - def get_context_data(self, **kwargs: Any) -> dict[str, Any]: - ctx = super().get_context_data(**kwargs) - thread = ctx.setdefault("thread", None) - # Populate sandbox envs both for the empty hero state and a live thread; JS - # forwards the selection on each request via the ``X-Sandbox-Env`` header. - ctx["sandbox_envs"] = list(SandboxEnvironment.objects.visible_to(self.request.user)) - ctx["selected_sandbox_env_id"] = ( - str(thread.sandbox_environment_id) if thread is not None and thread.sandbox_environment_id else "" - ) - ctx["selected_sandbox_env"] = next( - (e for e in ctx["sandbox_envs"] if str(e.id) == ctx["selected_sandbox_env_id"]), None - ) - # The chat composer has no Django form — the agent picker reads its initial - # state straight from the thread row (empty strings mean Auto). On an - # existing thread we render the picker locked to mirror the env-pill, since - # the backend ignores agent overrides after the first turn. - ctx.update( - agent_picker_context( - initial_model=thread.agent_model if thread is not None else "", - initial_thinking_level=thread.agent_thinking_level if thread is not None else "", - ) - ) - if thread is None: - ctx.update({"turns": [], "expired": False, "active_run_id": "", "merge_request": None}) - return ctx - messages_history, expired, merge_request = async_to_sync(ahydrate_thread)(thread.thread_id) - if merge_request is None and thread.repo_id and thread.ref: - merge_request = async_to_sync(aget_existing_mr_payload)(thread.repo_id, thread.ref) - ctx["turns"] = build_turns(messages_history) - ctx["expired"] = expired - ctx["active_run_id"] = thread.active_run_id - ctx["merge_request"] = merge_request - return ctx - - def get_breadcrumbs(self): - chat_url = reverse("chat_list") - thread = getattr(self, "object", None) - if thread is None: - return [{"label": "Chat", "url": chat_url}, {"label": "New", "url": None}] - return [{"label": "Chat", "url": chat_url}, {"label": thread.title or thread.thread_id[:8], "url": None}] - - -class ChatThreadFromActivityView(LoginRequiredMixin, View): - """Bridge: create (or reuse) a ChatThread for an activity and redirect to it.""" - - def post(self, request, *, activity_id): - # Mirror ActivityDetailView's visibility (Activity.objects.by_owner) so the - # button rendered there always works — webhook activities have user=None and - # are reached via external_username; without this the bridge 404s. - activity = get_object_or_404(Activity.objects.by_owner(request.user), pk=activity_id) - if not activity.thread_id: - raise Http404 - - messages, expired, _mr = async_to_sync(ahydrate_thread)(activity.thread_id) - if expired: - return HttpResponseGone("This run's state has expired. Start a fresh chat from its prompt.") - - thread, _ = async_to_sync(ChatThread.aget_or_create_from_activity)(request.user, activity) - return redirect("chat_detail", thread_id=thread.thread_id) diff --git a/daiv/notifications/signals.py b/daiv/notifications/signals.py index 5ce5aa2fd..0984c3054 100644 --- a/daiv/notifications/signals.py +++ b/daiv/notifications/signals.py @@ -12,8 +12,6 @@ from django.utils import timezone from django.utils.translation import gettext as _ -from activity.models import Activity, ActivityStatus, TriggerType -from activity.signals import activity_finished from sessions.signals import run_finished from notifications.channels.registry import enabled_channels @@ -23,293 +21,9 @@ logger = logging.getLogger("daiv.notifications") -EXCLUDED_TRIGGERS = {TriggerType.ISSUE_WEBHOOK, TriggerType.MR_WEBHOOK} EXCLUDED_RUN_TRIGGERS = {"issue_webhook", "mr_webhook"} -def _is_schedule(activity: Activity) -> bool: - """True when ``activity`` is linked to a still-loadable ScheduledJob. - - Both checks are required: the FK uses ``on_delete=SET_NULL``, so an instance can - have a non-null ``scheduled_job_id`` but a ``None`` related object after the - ScheduledJob is deleted out from under it. - """ - return activity.scheduled_job_id is not None and activity.scheduled_job is not None - - -def _status_matches(notify_on: NotifyOn, status: str) -> bool: - if notify_on == NotifyOn.NEVER: - return False - if notify_on == NotifyOn.ALWAYS: - return status in ActivityStatus.terminal() - if notify_on == NotifyOn.ON_SUCCESS: - return status == ActivityStatus.SUCCESSFUL - if notify_on == NotifyOn.ON_FAILURE: - return status == ActivityStatus.FAILED - logger.warning("Unknown notify_on value %r; treating as NEVER", notify_on) - return False - - -def _resolve_recipients(activity: Activity) -> dict[int, object]: - if _is_schedule(activity): - schedule = activity.scheduled_job - recipients: dict[int, object] = {schedule.user_id: schedule.user} - for sub in schedule.subscribers.all(): - recipients.setdefault(sub.pk, sub) - return recipients - if activity.user is not None: - return {activity.user.pk: activity.user} - return {} - - -def _render_payload(activity: Activity) -> tuple[str, str, dict]: - is_schedule = _is_schedule(activity) - ok = activity.status == ActivityStatus.SUCCESSFUL - repo = activity.repo_id - name = activity.scheduled_job.name if is_schedule else "" - # Owner disambiguates schedules that share a name across users. - owner = str(activity.scheduled_job.user) if is_schedule else "" - - if is_schedule: - params = {"name": name, "owner": owner, "repo": repo} - if ok: - subject = _("'%(name)s' succeeded on %(repo)s — %(owner)s") % params - body = _("Scheduled run '%(name)s' by %(owner)s finished on %(repo)s.") % params - else: - subject = _("'%(name)s' failed on %(repo)s — %(owner)s") % params - body = _("Scheduled run '%(name)s' by %(owner)s failed on %(repo)s.") % params - else: - if ok: - subject = _("Agent run on %(repo)s succeeded") % {"repo": repo} - body = _("Agent run on %(repo)s finished successfully.") % {"repo": repo} - else: - subject = _("Agent run on %(repo)s failed") % {"repo": repo} - body = _("Agent run on %(repo)s failed.") % {"repo": repo} - - context = { - "status": activity.status, - "status_label": activity.get_status_display(), - "is_successful": ok, - "trigger_label": activity.get_trigger_type_display(), - "trigger_name": name, - "trigger_owner": owner, - "repo_id": repo, - "duration_seconds": activity.duration, - "input_tokens": activity.input_tokens, - "output_tokens": activity.output_tokens, - "total_tokens": activity.total_tokens, - "cost_usd": float(activity.cost_usd) if activity.cost_usd is not None else None, - } - return subject, body, context - - -@receiver(activity_finished, dispatch_uid="notifications.on_activity_finished") -def on_activity_finished(sender, activity: Activity, **kwargs) -> None: - if activity.trigger_type in EXCLUDED_TRIGGERS: - return - - if activity.batch_id is not None: - siblings = Activity.objects.by_batch(activity.batch_id) - total = siblings.count() - if total > 1: - _handle_batch_completion(activity, siblings, total) - return - - recipients = _resolve_recipients(activity) - if not recipients: - return - - effective = activity.effective_notify_on - # The Notification row doubles as the in-app bell entry and is always written for - # terminal activities with a recipient. ``notify_on`` only gates external delivery - # channels (email, etc.) — empty channels list means bell-only, no external dispatch. - channels = [cls.channel_type for cls in enabled_channels()] if _status_matches(effective, activity.status) else [] - - subject, body, context = _render_payload(activity) - link_url = reverse("session_list") - event_type = EventType.SCHEDULE_FINISHED if _is_schedule(activity) else EventType.JOB_FINISHED - - for recipient in recipients.values(): - try: - notify( - recipient=recipient, - event_type=event_type, - source_type="activity.Activity", - source_id=str(activity.pk), - subject=subject, - body=body, - link_url=link_url, - channels=channels, - context=context, - ) - except Exception: - logger.exception( - "Failed to create notification for activity %s, recipient pk=%s", - activity.pk, - getattr(recipient, "pk", None), - ) - - -def _handle_batch_completion(activity: Activity, siblings, total: int) -> None: - """Emit a single rollup notification when every sibling in the batch is terminal. - - Sibling-level notifications are suppressed entirely for multi-job batches; only - this rollup is written. Two near-simultaneous "last" workers can both see the - batch as complete — the partial unique constraint on ``Notification`` lets the DB - elect a single winner and the loser swallows ``IntegrityError`` below. - """ - agg = siblings.aggregate( - terminal=Count("id", filter=Q(status__in=ActivityStatus.terminal())), - successful=Count("id", filter=Q(status=ActivityStatus.SUCCESSFUL)), - total_input_tokens=Sum("input_tokens"), - total_output_tokens=Sum("output_tokens"), - total_total_tokens=Sum("total_tokens"), - total_cost_usd=Sum("cost_usd"), - ) - if agg["terminal"] < total: - return - - recipients = _resolve_recipients(activity) - if not recipients: - # A multi-job batch finalizing with zero recipients usually means a misconfigured - # schedule or a deleted user — worth surfacing so an operator can investigate. - logger.warning( - "Batch %s completed with no resolvable recipients (activity_pk=%s, total=%d)", - activity.batch_id, - activity.pk, - total, - ) - return - - successful = agg["successful"] - failed = total - successful - agg_status = ActivityStatus.SUCCESSFUL if failed == 0 else ActivityStatus.FAILED - - rows = list(siblings.values_list("repo_id", "started_at", "finished_at", "status")) - - effective = activity.effective_notify_on - channels = [cls.channel_type for cls in enabled_channels()] if _status_matches(effective, agg_status) else [] - - usage = { - "input_tokens": agg["total_input_tokens"], - "output_tokens": agg["total_output_tokens"], - "total_tokens": agg["total_total_tokens"], - "cost_usd": float(agg["total_cost_usd"]) if agg["total_cost_usd"] is not None else None, - } - subject, body, context = _render_batch_payload(activity, rows, total, successful, failed, agg_status, usage) - link_url = f"{reverse('session_list')}?batch={activity.batch_id}" - - for recipient in recipients.values(): - try: - notify( - recipient=recipient, - event_type=EventType.JOB_BATCH_FINISHED, - source_type="activity.Batch", - source_id=str(activity.batch_id), - subject=subject, - body=body, - link_url=link_url, - channels=channels, - context=context, - ) - except IntegrityError: - # Distinguish the expected race (sibling worker already inserted the rollup) - # from any other integrity violation (FK to a deleted recipient, NOT NULL, etc.). - # The expected race leaves a matching row behind; anything else does not. - if _rollup_exists(recipient, activity.batch_id): - logger.debug( - "Batch rollup already exists for batch_id=%s recipient_pk=%s", - activity.batch_id, - getattr(recipient, "pk", None), - ) - else: - logger.exception( - "Unexpected IntegrityError creating batch notification for batch_id=%s recipient pk=%s", - activity.batch_id, - getattr(recipient, "pk", None), - ) - except Exception: - logger.exception( - "Failed to create batch notification for batch_id=%s recipient pk=%s", - activity.batch_id, - getattr(recipient, "pk", None), - ) - - -def _rollup_exists(recipient, batch_id) -> bool: - from notifications.models import Notification - - return Notification.objects.filter( - recipient=recipient, - source_type="activity.Batch", - source_id=str(batch_id), - event_type=EventType.JOB_BATCH_FINISHED, - ).exists() - - -def _render_batch_payload( - activity: Activity, rows: list[tuple], total: int, successful: int, failed: int, agg_status: str, usage: dict -) -> tuple[str, str, dict]: - is_schedule = _is_schedule(activity) - ok = failed == 0 - repo_ids = sorted({repo for repo, _start, _end, _status in rows if repo}) - repo_results = [ - {"repo": repo, "ok": status == ActivityStatus.SUCCESSFUL} for repo, _start, _end, status in rows if repo - ] - name = activity.scheduled_job.name if is_schedule else "" - owner = str(activity.scheduled_job.user) if is_schedule else "" - - if is_schedule: - params = {"name": name, "owner": owner, "total": total, "ok": successful, "failed": failed} - if ok: - subject = _("'%(name)s' batch succeeded (%(total)d runs) — %(owner)s") % params - body = _("All %(total)d runs of '%(name)s' by %(owner)s finished successfully.") % params - elif successful == 0: - subject = _("'%(name)s' batch failed (%(total)d runs) — %(owner)s") % params - body = _("All %(total)d runs of '%(name)s' by %(owner)s failed.") % params - else: - subject = _("'%(name)s' batch: %(ok)d/%(total)d succeeded — %(owner)s") % params - body = _("%(ok)d of %(total)d runs of '%(name)s' by %(owner)s succeeded; %(failed)d failed.") % params - else: - repo_summary = _summarize_repos(repo_ids) - if ok: - subject = _("Agent run batch succeeded (%(total)d runs)") % {"total": total} - body = _("All %(total)d runs on %(repos)s finished successfully.") % {"total": total, "repos": repo_summary} - elif successful == 0: - subject = _("Agent run batch failed (%(total)d runs)") % {"total": total} - body = _("All %(total)d runs on %(repos)s failed.") % {"total": total, "repos": repo_summary} - else: - subject = _("Agent run batch finished: %(ok)d/%(total)d succeeded") % {"ok": successful, "total": total} - body = _("%(ok)d of %(total)d runs on %(repos)s succeeded; %(failed)d failed.") % { - "ok": successful, - "total": total, - "repos": repo_summary, - "failed": failed, - } - - context = { - "status": str(agg_status), - "status_label": str(ActivityStatus(agg_status).label), - "is_successful": ok, - "trigger_label": str(activity.get_trigger_type_display()), - "trigger_name": name, - "trigger_owner": owner, - "repo_id": repo_ids[0] if len(repo_ids) == 1 else "", - "repo_ids": repo_ids, - "repo_results": repo_results, - "total": total, - "successful_count": successful, - "failed_count": failed, - "duration_seconds": _batch_duration(rows), - "batch_id": str(activity.batch_id), - "input_tokens": usage["input_tokens"], - "output_tokens": usage["output_tokens"], - "total_tokens": usage["total_tokens"], - "cost_usd": usage["cost_usd"], - } - return subject, body, context - - def _summarize_repos(repo_ids: list[str], limit: int = 3) -> str: if not repo_ids: return "" diff --git a/tests/unit_tests/activity/__init__.py b/tests/unit_tests/activity/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/unit_tests/activity/conftest.py b/tests/unit_tests/activity/conftest.py deleted file mode 100644 index 94f813516..000000000 --- a/tests/unit_tests/activity/conftest.py +++ /dev/null @@ -1,35 +0,0 @@ -import uuid - -import pytest -from django_tasks_db.models import DBTaskResult, get_date_max - - -@pytest.fixture -def create_db_task_result(): - """Build a DBTaskResult row for signal / view / command tests.""" - - def _create( - *, - status="SUCCESSFUL", - return_value=None, - started_at=None, - finished_at=None, - exception_class_path="", - traceback="", - ): - return DBTaskResult.objects.create( - id=uuid.uuid4(), - status=status, - task_path="jobs.tasks.run_job_task", - args_kwargs={"args": [], "kwargs": {}}, - queue_name="default", - backend_name="default", - run_after=get_date_max(), - return_value=return_value or {}, - started_at=started_at, - finished_at=finished_at, - exception_class_path=exception_class_path, - traceback=traceback, - ) - - return _create diff --git a/tests/unit_tests/activity/test_agent_run_fields_template.py b/tests/unit_tests/activity/test_agent_run_fields_template.py deleted file mode 100644 index 6a1372ae8..000000000 --- a/tests/unit_tests/activity/test_agent_run_fields_template.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Render tests for ``activity/_agent_run_fields.html``. - -Alpine/HTMX behavior (popover opening, picker fetches, chip interactions) is -verified manually in the browser, not here. -""" - -from __future__ import annotations - -import re - -from django.template.loader import render_to_string -from django.urls import reverse - -from activity.forms import AgentRunCreateForm - - -def _render(form): - return render_to_string("activity/_agent_run_fields.html", {"form": form}) - - -def _input_tag(html, name): - match = re.search(rf']*\bname="{re.escape(name)}"[^>]*>', html) - assert match, f"no in rendered HTML" - return match.group(0) - - -def test_renders_single_hidden_repos_input(): - form = AgentRunCreateForm(initial={"prompt": "p", "repos": [{"repo_id": "acme/api", "ref": "main"}]}) - html = _render(form) - tag = _input_tag(html, "repos") - assert "acme/api" in tag - assert re.search(r']*\bname="repo_id"', html) is None - assert re.search(r']*\bname="ref"', html) is None - - -def test_max_repos_is_twenty(): - form = AgentRunCreateForm(initial={"prompt": "p"}) - html = _render(form) - assert "maxRepos: 20" in html - - -def test_renders_textarea_with_prompt_value(): - form = AgentRunCreateForm(initial={"prompt": "hello world"}) - html = _render(form) - assert 'name="prompt"' in html - assert ">hello world" in html - - -def test_required_guard_has_value_when_repos_present(): - form = AgentRunCreateForm(initial={"prompt": "p", "repos": [{"repo_id": "x/y", "ref": ""}]}) - html = _render(form) - assert 'value="ok"' in _input_tag(html, "__repo_required_guard") - - -def test_required_guard_empty_when_no_repos(): - form = AgentRunCreateForm(initial={"prompt": "p"}) - html = _render(form) - assert 'value="ok"' not in _input_tag(html, "__repo_required_guard") - - -def test_empty_repos_renders_as_json_array_not_null(): - # Alpine parses the hidden input value as JSON to seed initialRepos; "null" would break boot. - form = AgentRunCreateForm(initial={"prompt": "p"}) - html = _render(form) - assert 'value="[]"' in _input_tag(html, "repos") - - -def test_renders_combined_error_list_below_box(): - form = AgentRunCreateForm(data={"prompt": "", "repos": ""}) - form.is_valid() - html = _render(form) - assert re.search(r"]*text-red-400", html) - assert "required" in html.lower() - - -def test_empty_state_shows_choose_repository_button(): - """With no repo bound, the chip row renders the 'Choose repository' empty-state button.""" - form = AgentRunCreateForm(initial={"prompt": "p"}) - html = _render(form) - assert "Choose repository" in html - - -def test_repo_picker_popover_uses_picker_url(): - form = AgentRunCreateForm(initial={"prompt": "p"}) - html = _render(form) - assert reverse("codebase:picker-repositories") in html - assert "x-combobox" not in html - - -def test_branch_picker_template_references_branches_url_prefix(): - """The branch popover builds its hx-get URL in Alpine; the literal URL prefix must appear in the template.""" - form = AgentRunCreateForm(initial={"prompt": "p", "repos": [{"repo_id": "acme/api", "ref": "main"}]}) - html = _render(form) - assert "/codebase/pickers/repositories/" in html - assert "/branches/" in html diff --git a/tests/unit_tests/activity/test_batch_submit.py b/tests/unit_tests/activity/test_batch_submit.py deleted file mode 100644 index 91e7d1254..000000000 --- a/tests/unit_tests/activity/test_batch_submit.py +++ /dev/null @@ -1,299 +0,0 @@ -"""Tests for the multi-repo batch submission service.""" - -from __future__ import annotations - -import uuid -from unittest import mock - -import pytest -from activity.models import TriggerType -from activity.services import BatchSubmitFailure, RepoTarget, asubmit_batch_runs, submit_batch_runs -from django_tasks_db.models import DBTaskResult, get_date_max - - -def _task_result_row(task_id: uuid.UUID) -> mock.Mock: - DBTaskResult.objects.create( - id=task_id, - status="READY", - task_path="jobs.tasks.run_job_task", - args_kwargs={"args": [], "kwargs": {}}, - queue_name="default", - backend_name="default", - run_after=get_date_max(), - return_value={}, - ) - return mock.Mock(id=task_id) - - -async def _atask_result_row(task_id: uuid.UUID) -> mock.Mock: - await DBTaskResult.objects.acreate( - id=task_id, - status="READY", - task_path="jobs.tasks.run_job_task", - args_kwargs={"args": [], "kwargs": {}}, - queue_name="default", - backend_name="default", - run_after=get_date_max(), - return_value={}, - ) - return mock.Mock(id=task_id) - - -@pytest.mark.django_db(transaction=True) -class TestSubmitBatchRunsSync: - def test_single_repo_creates_one_activity_with_batch_id(self, member_user): - task_id = uuid.uuid4() - fake = _task_result_row(task_id) - with mock.patch("activity.services.run_job_task") as m_task: - m_task.aenqueue = mock.AsyncMock(return_value=fake) - result = submit_batch_runs( - user=member_user, - prompt="do it", - repos=[RepoTarget(repo_id="a/b", ref="")], - notify_on=None, - trigger_type=TriggerType.UI_JOB, - ) - - assert len(result.activities) == 1 - assert result.failed == [] - assert result.activities[0].batch_id == result.batch_id - assert result.activities[0].repo_id == "a/b" - assert result.activities[0].trigger_type == TriggerType.UI_JOB - m_task.aenqueue.assert_awaited_once() - enqueue_kwargs = m_task.aenqueue.await_args.kwargs - assert enqueue_kwargs["repo_id"] == "a/b" - assert enqueue_kwargs["prompt"] == "do it" - assert enqueue_kwargs["ref"] is None - assert enqueue_kwargs["agent_model"] is None - assert enqueue_kwargs["agent_thinking_level"] is None - assert "use_max" not in enqueue_kwargs - assert enqueue_kwargs["thread_id"] == result.activities[0].thread_id - - def test_five_repos_creates_five_activities_sharing_batch_id(self, member_user): - tasks_seen = [] - - async def _aenqueue(**kwargs): - tasks_seen.append(kwargs) - return await _atask_result_row(uuid.uuid4()) - - with mock.patch("activity.services.run_job_task") as m_task: - m_task.aenqueue = _aenqueue - repos = [RepoTarget(repo_id=f"o/r{i}", ref="dev" if i % 2 else "") for i in range(5)] - result = submit_batch_runs( - user=member_user, prompt="p", repos=repos, notify_on=None, trigger_type=TriggerType.UI_JOB - ) - - assert len(result.activities) == 5 - assert {a.batch_id for a in result.activities} == {result.batch_id} - assert [t["repo_id"] for t in tasks_seen] == [f"o/r{i}" for i in range(5)] - assert tasks_seen[0]["ref"] is None # empty ref threads as None - assert tasks_seen[1]["ref"] == "dev" - # Each activity gets a distinct thread_id that matches the one passed to the task. - activity_thread_ids = [a.thread_id for a in result.activities] - assert all(activity_thread_ids) - assert len(set(activity_thread_ids)) == 5 - task_thread_ids = [t["thread_id"] for t in tasks_seen] - assert set(task_thread_ids) == set(activity_thread_ids) - - def test_oversized_repos_raises_value_error(self, member_user): - repos = [RepoTarget(repo_id=f"o/r{i}", ref="") for i in range(21)] - with pytest.raises(ValueError): - submit_batch_runs( - user=member_user, prompt="p", repos=repos, notify_on=None, trigger_type=TriggerType.UI_JOB - ) - - def test_partial_enqueue_failure_is_best_effort(self, member_user): - call_count = {"n": 0} - - async def _flaky(**kwargs): - call_count["n"] += 1 - if call_count["n"] == 2: - raise RuntimeError("DB hiccup") - return await _atask_result_row(uuid.uuid4()) - - with mock.patch("activity.services.run_job_task") as m_task: - m_task.aenqueue = _flaky - repos = [ - RepoTarget(repo_id="o/a", ref=""), - RepoTarget(repo_id="o/b", ref=""), - RepoTarget(repo_id="o/c", ref=""), - ] - result = submit_batch_runs( - user=member_user, prompt="p", repos=repos, notify_on=None, trigger_type=TriggerType.UI_JOB - ) - - assert len(result.activities) == 2 - assert len(result.failed) == 1 - failure = result.failed[0] - assert isinstance(failure, BatchSubmitFailure) - assert failure.repo_id == "o/b" - assert "DB hiccup" in failure.error - - def test_orphan_activity_creation_failure_surfaces_in_failed(self, member_user): - """When enqueue succeeds but acreate_activity raises, the failure is surfaced to the - caller (not silently dropped) so batch response pairing stays aligned. - """ - - async def _aenqueue(**kwargs): - return await _atask_result_row(uuid.uuid4()) - - class _Stub: - def __init__(self, task_result_id): - self.task_result_id = task_result_id - self.pk = uuid.uuid4() - # Stand-in for the post-acreate ``activity.asave(update_fields=...)`` call. - self.asave = mock.AsyncMock(return_value=None) - - async def _flaky_create(**kwargs): - if kwargs["repo_id"] == "o/b": - raise RuntimeError("activity INSERT failed") - return _Stub(task_result_id=kwargs["task_result_id"]) - - with ( - mock.patch("activity.services.run_job_task") as m_task, - mock.patch("activity.services.acreate_activity", side_effect=_flaky_create), - ): - m_task.aenqueue = _aenqueue - repos = [ - RepoTarget(repo_id="o/a", ref=""), - RepoTarget(repo_id="o/b", ref=""), - RepoTarget(repo_id="o/c", ref=""), - ] - result = submit_batch_runs( - user=member_user, prompt="p", repos=repos, notify_on=None, trigger_type=TriggerType.UI_JOB - ) - - assert len(result.activities) == 2 - assert len(result.failed) == 1 - assert result.failed[0].repo_id == "o/b" - assert "ActivityCreationFailed" in result.failed[0].error - - def test_activity_persists_scheduled_job_link(self, member_user): - from schedules.models import Frequency, ScheduledJob - - schedule = ScheduledJob.objects.create( - user=member_user, - name="s", - prompt="p", - repos=[{"repo_id": "x/y", "ref": ""}], - frequency=Frequency.DAILY, - time="12:00", - ) - fake = _task_result_row(uuid.uuid4()) - with mock.patch("activity.services.run_job_task") as m_task: - m_task.aenqueue = mock.AsyncMock(return_value=fake) - result = submit_batch_runs( - user=member_user, - prompt="p", - repos=[RepoTarget(repo_id="x/y", ref="")], - notify_on=None, - trigger_type=TriggerType.SCHEDULE, - scheduled_job=schedule, - ) - assert result.activities[0].scheduled_job_id == schedule.pk - - -@pytest.mark.django_db(transaction=True) -class TestAsubmitBatchRuns: - async def test_async_variant_returns_same_shape(self, member_user): - task_id = uuid.uuid4() - - async def _aenqueue(**kwargs): - return await _atask_result_row(task_id) - - with mock.patch("activity.services.run_job_task") as m_task: - m_task.aenqueue = _aenqueue - result = await asubmit_batch_runs( - user=member_user, - prompt="p", - repos=[RepoTarget(repo_id="a/b", ref="")], - notify_on=None, - trigger_type=TriggerType.API_JOB, - ) - - assert len(result.activities) == 1 - assert result.activities[0].batch_id == result.batch_id - - -@pytest.mark.django_db(transaction=True) -class TestBatchTitleEnqueue: - """One title task per batch — not one per activity.""" - - def test_single_batch_title_task_enqueued_for_n_repos(self, member_user, mock_generate_title_task): - async def _aenqueue(**kwargs): - return await _atask_result_row(uuid.uuid4()) - - with mock.patch("activity.services.run_job_task") as m_task: - m_task.aenqueue = _aenqueue - repos = [RepoTarget(repo_id=f"o/r{i}", ref="") for i in range(4)] - result = submit_batch_runs( - user=member_user, prompt="add login", repos=repos, notify_on=None, trigger_type=TriggerType.UI_JOB - ) - - assert len(result.activities) == 4 - mock_generate_title_task.aenqueue.assert_awaited_once() - call_kwargs = mock_generate_title_task.aenqueue.await_args.kwargs - assert call_kwargs["batch_id"] == str(result.batch_id) - assert call_kwargs["prompt"] == "add login" - - def test_no_title_task_for_schedule_trigger(self, member_user, mock_generate_title_task): - from schedules.models import Frequency, ScheduledJob - - schedule = ScheduledJob.objects.create( - user=member_user, - name="s", - prompt="p", - repos=[{"repo_id": "x/y", "ref": ""}], - frequency=Frequency.DAILY, - time="12:00", - ) - fake = _task_result_row(uuid.uuid4()) - with mock.patch("activity.services.run_job_task") as m_task: - m_task.aenqueue = mock.AsyncMock(return_value=fake) - submit_batch_runs( - user=member_user, - prompt="p", - repos=[RepoTarget(repo_id="x/y", ref="")], - notify_on=None, - trigger_type=TriggerType.SCHEDULE, - scheduled_job=schedule, - ) - mock_generate_title_task.aenqueue.assert_not_called() - - def test_no_title_task_when_no_activities_created(self, member_user, mock_generate_title_task): - async def _aenqueue_fails(**kwargs): - raise RuntimeError("queue down") - - with mock.patch("activity.services.run_job_task") as m_task: - m_task.aenqueue = _aenqueue_fails - result = submit_batch_runs( - user=member_user, - prompt="p", - repos=[RepoTarget(repo_id="o/r", ref="")], - notify_on=None, - trigger_type=TriggerType.UI_JOB, - ) - - assert result.activities == [] - mock_generate_title_task.aenqueue.assert_not_called() - - def test_title_enqueue_failure_does_not_abort_batch(self, member_user, mock_generate_title_task): - """Enqueue failures for the (best-effort) title task must not raise to the caller — submission stays green.""" - mock_generate_title_task.aenqueue = mock.AsyncMock(side_effect=RuntimeError("title queue down")) - - async def _aenqueue(**kwargs): - return await _atask_result_row(uuid.uuid4()) - - with mock.patch("activity.services.run_job_task") as m_task: - m_task.aenqueue = _aenqueue - result = submit_batch_runs( - user=member_user, - prompt="add login", - repos=[RepoTarget(repo_id=f"o/r{i}", ref="") for i in range(3)], - notify_on=None, - trigger_type=TriggerType.UI_JOB, - ) - - assert len(result.activities) == 3 - assert result.failed == [] - mock_generate_title_task.aenqueue.assert_awaited_once() diff --git a/tests/unit_tests/activity/test_filters.py b/tests/unit_tests/activity/test_filters.py deleted file mode 100644 index 86aed2363..000000000 --- a/tests/unit_tests/activity/test_filters.py +++ /dev/null @@ -1,153 +0,0 @@ -from datetime import UTC, datetime, time - -import pytest -from activity.filters import ActivityFilter -from activity.models import Activity, ActivityStatus, TriggerType - -from accounts.models import User -from schedules.models import Frequency, ScheduledJob - - -@pytest.fixture -def user(db): - return User.objects.create_user( - username="alice", - email="alice@test.com", - password="testpass123", # noqa: S106 - ) - - -def _create(**kwargs): - defaults = { - "trigger_type": TriggerType.SCHEDULE, - "repo_id": "group/project", - "ref": "main", - "status": ActivityStatus.SUCCESSFUL, - } - defaults.update(kwargs) - return Activity.objects.create(**defaults) - - -@pytest.mark.django_db -class TestActivityFilter: - def test_no_params_returns_all(self, user): - a = _create() - b = _create(status=ActivityStatus.FAILED) - qs = ActivityFilter({}, queryset=Activity.objects.all()).qs - assert a in qs - assert b in qs - - def test_status_filter(self, user): - successful = _create(status=ActivityStatus.SUCCESSFUL) - failed = _create(status=ActivityStatus.FAILED) - qs = ActivityFilter({"status": ActivityStatus.SUCCESSFUL}, queryset=Activity.objects.all()).qs - assert successful in qs - assert failed not in qs - - def test_invalid_status_is_ignored(self, user): - a = _create() - f = ActivityFilter({"status": "bogus"}, queryset=Activity.objects.all()) - assert not f.form.is_valid() - # Invalid choice is dropped from cleaned_data → no filter applied for that field. - assert a in f.qs - - def test_trigger_filter(self, user): - sched = _create(trigger_type=TriggerType.SCHEDULE) - webhook = _create(trigger_type=TriggerType.ISSUE_WEBHOOK) - qs = ActivityFilter({"trigger": TriggerType.SCHEDULE}, queryset=Activity.objects.all()).qs - assert sched in qs - assert webhook not in qs - - def test_repo_filter(self, user): - a = _create(repo_id="group/project") - b = _create(repo_id="group/other") - qs = ActivityFilter({"repo": "group/project"}, queryset=Activity.objects.all()).qs - assert a in qs - assert b not in qs - - def test_schedule_filter_accepts_int_string(self, user): - a = _create() - f = ActivityFilter({"schedule": "not-a-number"}, queryset=Activity.objects.all()) - assert not f.form.is_valid() - # Invalid int is dropped from cleaned_data. - assert a in f.qs - - def test_schedule_filter_matches_fk(self, user): - job = ScheduledJob.objects.create( - user=user, - name="nightly", - prompt="x", - repos=[{"repo_id": "group/project", "ref": ""}], - frequency=Frequency.DAILY, - time=time(3, 0), - ) - match = _create(scheduled_job=job) - other = _create() - qs = ActivityFilter({"schedule": str(job.pk)}, queryset=Activity.objects.all()).qs - assert match in qs - assert other not in qs - - def test_date_from_filter(self, user): - old = _create() - Activity.objects.filter(pk=old.pk).update(created_at=datetime(2020, 1, 1, tzinfo=UTC)) - recent = _create() - Activity.objects.filter(pk=recent.pk).update(created_at=datetime(2026, 1, 1, tzinfo=UTC)) - qs = ActivityFilter({"date_from": "2025-06-01"}, queryset=Activity.objects.all()).qs - assert recent in qs - assert old not in qs - - def test_date_to_filter(self, user): - old = _create() - Activity.objects.filter(pk=old.pk).update(created_at=datetime(2020, 1, 1, tzinfo=UTC)) - recent = _create() - Activity.objects.filter(pk=recent.pk).update(created_at=datetime(2026, 1, 1, tzinfo=UTC)) - qs = ActivityFilter({"date_to": "2025-06-01"}, queryset=Activity.objects.all()).qs - assert old in qs - assert recent not in qs - - def test_date_range_combined(self, user): - before = _create() - Activity.objects.filter(pk=before.pk).update(created_at=datetime(2020, 1, 1, tzinfo=UTC)) - inside = _create() - Activity.objects.filter(pk=inside.pk).update(created_at=datetime(2025, 6, 15, tzinfo=UTC)) - after = _create() - Activity.objects.filter(pk=after.pk).update(created_at=datetime(2026, 1, 1, tzinfo=UTC)) - qs = ActivityFilter({"date_from": "2025-01-01", "date_to": "2025-12-31"}, queryset=Activity.objects.all()).qs - assert inside in qs - assert before not in qs - assert after not in qs - - def test_invalid_date_is_ignored(self, user): - a = _create() - f = ActivityFilter({"date_from": "not-a-date"}, queryset=Activity.objects.all()) - assert not f.form.is_valid() - # Invalid date is dropped from cleaned_data → no filter applied. - assert a in f.qs - - def test_combined_filters(self, user): - match = _create(status=ActivityStatus.SUCCESSFUL, repo_id="group/project") - wrong_status = _create(status=ActivityStatus.FAILED, repo_id="group/project") - wrong_repo = _create(status=ActivityStatus.SUCCESSFUL, repo_id="group/other") - qs = ActivityFilter( - {"status": ActivityStatus.SUCCESSFUL, "repo": "group/project"}, queryset=Activity.objects.all() - ).qs - assert match in qs - assert wrong_status not in qs - assert wrong_repo not in qs - - def test_batch_filter_matches_by_batch_id(self, user): - import uuid as _uuid - - b = _uuid.uuid4() - match = _create(batch_id=b) - other = _create(batch_id=_uuid.uuid4()) - qs = ActivityFilter({"batch": str(b)}, queryset=Activity.objects.all()).qs - assert match in qs - assert other not in qs - - def test_batch_filter_invalid_uuid_is_ignored(self, user): - a = _create() - f = ActivityFilter({"batch": "not-a-uuid"}, queryset=Activity.objects.all()) - assert not f.form.is_valid() - # Invalid value is dropped → no filter applied. - assert a in f.qs diff --git a/tests/unit_tests/activity/test_forms.py b/tests/unit_tests/activity/test_forms.py deleted file mode 100644 index dfabd0b5b..000000000 --- a/tests/unit_tests/activity/test_forms.py +++ /dev/null @@ -1,166 +0,0 @@ -"""Tests for the UI agent-run form.""" - -import json - -from django import forms - -import pytest -from activity.forms import AgentRunCreateForm, RepoListField -from notifications.choices import NotifyOn - -from core.models import Provider, ProviderType - - -def _valid(**overrides): - data = { - "prompt": "do the thing", - "repos": json.dumps([{"repo_id": "acme/repo", "ref": "main"}]), - "notify_on": NotifyOn.NEVER, - } - data.update(overrides) - return data - - -@pytest.fixture -def openrouter_provider(db): - Provider.objects.filter(slug="openrouter").delete() - return Provider.objects.create( - slug="openrouter", provider_type=ProviderType.OPENROUTER, api_key="sk-test", is_enabled=True - ) - - -class TestAgentRunCreateForm: - def test_valid_single_repo(self): - form = AgentRunCreateForm(data=_valid()) - assert form.is_valid(), form.errors - assert form.cleaned_data["repos"] == [{"repo_id": "acme/repo", "ref": "main"}] - - def test_valid_multiple_repos(self): - form = AgentRunCreateForm( - data=_valid(repos=json.dumps([{"repo_id": "a/b", "ref": ""}, {"repo_id": "c/d", "ref": "dev"}])) - ) - assert form.is_valid(), form.errors - assert len(form.cleaned_data["repos"]) == 2 - - def test_rejects_empty_repos(self): - form = AgentRunCreateForm(data=_valid(repos="[]")) - assert not form.is_valid() - assert "repos" in form.errors - - def test_rejects_oversized_repos(self): - big = [{"repo_id": f"o/r{i}", "ref": ""} for i in range(21)] - form = AgentRunCreateForm(data=_valid(repos=json.dumps(big))) - assert not form.is_valid() - assert "repos" in form.errors - - def test_rejects_malformed_json(self): - form = AgentRunCreateForm(data=_valid(repos="not-json")) - assert not form.is_valid() - assert "repos" in form.errors - - def test_rejects_malformed_entry(self): - form = AgentRunCreateForm(data=_valid(repos=json.dumps([{"repo_id": ""}]))) - assert not form.is_valid() - assert "repos" in form.errors - - def test_rejects_duplicate_entries(self): - form = AgentRunCreateForm( - data=_valid(repos=json.dumps([{"repo_id": "a/b", "ref": "main"}, {"repo_id": "a/b", "ref": "main"}])) - ) - assert not form.is_valid() - assert "repos" in form.errors - - def test_requires_notify_on(self): - data = _valid() - data.pop("notify_on") - form = AgentRunCreateForm(data=data) - assert not form.is_valid() - assert "notify_on" in form.errors - - def test_requires_prompt(self): - form = AgentRunCreateForm(data=_valid(prompt="")) - assert not form.is_valid() - assert "prompt" in form.errors - - -@pytest.mark.parametrize("notify_on", [NotifyOn.NEVER, NotifyOn.ALWAYS, NotifyOn.ON_FAILURE]) -def test_notify_on_round_trips(notify_on): - form = AgentRunCreateForm(data=_valid(notify_on=notify_on)) - assert form.is_valid(), form.errors - assert form.cleaned_data["notify_on"] == notify_on - - -class TestAgentOverrideField: - """``agent_model`` / ``agent_thinking_level`` validate via ``validate_agent_override``.""" - - @pytest.mark.django_db - def test_form_validates_agent_model(self, openrouter_provider): - form = AgentRunCreateForm(data=_valid(agent_model="bogus:nope", agent_thinking_level="")) - assert not form.is_valid() - assert "agent_model" in form.errors - - @pytest.mark.django_db - def test_form_accepts_valid_pair(self, openrouter_provider): - form = AgentRunCreateForm( - data=_valid(agent_model="openrouter:anthropic/claude-haiku-4.5", agent_thinking_level="low") - ) - assert form.is_valid(), form.errors - assert form.cleaned_data["agent_model"] == "openrouter:anthropic/claude-haiku-4.5" - assert form.cleaned_data["agent_thinking_level"] == "low" - - @pytest.mark.django_db - def test_form_accepts_empty_override(self): - # Both fields empty: validator returns ("", "") without touching Provider. - form = AgentRunCreateForm(data=_valid()) - assert form.is_valid(), form.errors - assert form.cleaned_data["agent_model"] == "" - assert form.cleaned_data["agent_thinking_level"] == "" - - @pytest.mark.django_db - def test_form_rejects_invalid_thinking_level(self): - # ChoiceField rejects unknown values before our clean() runs — surfaced on the field. - form = AgentRunCreateForm(data=_valid(agent_thinking_level="extreme")) - assert not form.is_valid() - assert "agent_thinking_level" in form.errors - - @pytest.mark.django_db - def test_form_rejects_empty_override_when_no_system_default(self, monkeypatch): - """Server-side backstop for the picker's HTML5 ``required``: when no system - default is configured AND the client posts an empty ``agent_model``, the - form must refuse rather than letting the run reach the agent kickoff and - explode at ``get_daiv_agent_kwargs``.""" - from core.site_settings import site_settings - - monkeypatch.setattr(site_settings, "agent_model_name", "") - form = AgentRunCreateForm(data=_valid()) - assert not form.is_valid() - assert "agent_model" in form.errors - - -class _OptionalRepoForm(forms.Form): - """Tiny harness — exercises RepoListField(required=False) in isolation.""" - - repos = RepoListField(required=False) - - -class TestRepoListFieldOptional: - """``required=False`` must accept an empty list but still reject malformed shapes.""" - - @pytest.mark.parametrize("payload", ["[]", ""]) - def test_accepts_empty_payloads(self, payload): - form = _OptionalRepoForm(data={"repos": payload}) - assert form.is_valid(), form.errors - assert form.cleaned_data["repos"] in ([], None) - - def test_accepts_populated_list(self): - form = _OptionalRepoForm(data={"repos": json.dumps([{"repo_id": "a/b", "ref": ""}])}) - assert form.is_valid(), form.errors - assert form.cleaned_data["repos"] == [{"repo_id": "a/b", "ref": ""}] - - @pytest.mark.parametrize("payload", ["{}", '{"repo_id": "a/b"}', '"oops"']) - def test_rejects_malformed_non_list(self, payload): - # The required=False short-circuit must only fire for exactly [] — other - # falsy/malformed shapes must still surface as an explicit error. - form = _OptionalRepoForm(data={"repos": payload}) - assert not form.is_valid() - assert "repos" in form.errors diff --git a/tests/unit_tests/activity/test_list_activities.py b/tests/unit_tests/activity/test_list_activities.py deleted file mode 100644 index 3dde74a39..000000000 --- a/tests/unit_tests/activity/test_list_activities.py +++ /dev/null @@ -1,95 +0,0 @@ -import pytest -from activity.models import Activity, ActivityStatus, TriggerType -from activity.services import alist_user_activities - -from accounts.models import User - - -async def _user(username): - return await User.objects.acreate_user(username=username, email=f"{username}@e.com", password="x") # noqa: S106 - - -async def _activity(user, repo_id="a/b", status=ActivityStatus.SUCCESSFUL): - return await Activity.objects.acreate(user=user, repo_id=repo_id, status=status, trigger_type=TriggerType.MCP_JOB) - - -@pytest.mark.django_db(transaction=True) -async def test_alist_user_activities_scopes_to_user(): - user = await _user("u1") - other = await _user("o1") - await _activity(user) - await _activity(other) - rows = await alist_user_activities(user) - assert len(rows) == 1 - assert rows[0].user_id == user.pk - - -@pytest.mark.django_db(transaction=True) -async def test_alist_user_activities_filters_repo_and_status(): - user = await _user("u2") - await _activity(user, repo_id="a/b", status=ActivityStatus.SUCCESSFUL) - await _activity(user, repo_id="c/d", status=ActivityStatus.RUNNING) - by_repo = await alist_user_activities(user, repo_id="a/b") - assert {r.repo_id for r in by_repo} == {"a/b"} - by_status = await alist_user_activities(user, status=ActivityStatus.RUNNING) - assert {r.status for r in by_status} == {ActivityStatus.RUNNING} - - -@pytest.mark.django_db(transaction=True) -async def test_alist_user_activities_respects_limit(): - user = await _user("u3") - for _ in range(3): - await _activity(user) - rows = await alist_user_activities(user, limit=2) - assert len(rows) == 2 - - -@pytest.mark.django_db(transaction=True) -async def test_alist_user_activities_before_returns_only_older_rows(): - """The keyset ``before`` predicate returns rows strictly older than the cursor row, - in ``-created_at, -id`` order.""" - from datetime import timedelta - - from django.utils import timezone - - user = await _user("u4") - now = timezone.now() - rows_in = [] - for _ in range(4): - rows_in.append(await _activity(user)) - for offset, act in enumerate(rows_in): - await Activity.objects.filter(pk=act.pk).aupdate(created_at=now - timedelta(minutes=offset)) - # rows_in[0] is newest. First page (limit 2) → [rows_in[0], rows_in[1]]. - page1 = await alist_user_activities(user, limit=2) - assert [r.id for r in page1] == [rows_in[0].id, rows_in[1].id] - # Resume after page1's last row → the two older rows. - page2 = await alist_user_activities(user, limit=2, before=(page1[-1].created_at, page1[-1].id)) - assert [r.id for r in page2] == [rows_in[2].id, rows_in[3].id] - - -@pytest.mark.django_db(transaction=True) -async def test_alist_user_activities_before_tie_break_on_equal_created_at(): - """When several rows share an identical created_at, the id tie-break keeps the cursor - unambiguous — no row is skipped or repeated across pages.""" - from django.utils import timezone - - user = await _user("u5") - same = timezone.now() - created = [] - for _ in range(4): - created.append(await _activity(user)) - for act in created: - await Activity.objects.filter(pk=act.pk).aupdate(created_at=same) - - collected = [] - cursor = None - for _ in range(10): - page = await alist_user_activities(user, limit=2, before=cursor) - if not page: - break - collected.extend(page) - cursor = (page[-1].created_at, page[-1].id) - - ids = [r.id for r in collected] - assert sorted(str(i) for i in ids) == sorted(str(a.id) for a in created) - assert len(ids) == len(set(ids)) diff --git a/tests/unit_tests/activity/test_management.py b/tests/unit_tests/activity/test_management.py deleted file mode 100644 index b88d08d36..000000000 --- a/tests/unit_tests/activity/test_management.py +++ /dev/null @@ -1,177 +0,0 @@ -import uuid -from datetime import UTC, datetime -from io import StringIO -from unittest.mock import AsyncMock, MagicMock, patch - -from django.core.management import call_command -from django.core.management.base import CommandError - -import pytest -from activity.models import Activity, ActivityStatus, TriggerType - - -@pytest.mark.django_db -class TestSyncStuckActivitiesCommand: - def test_syncs_stuck_running_activity(self, create_db_task_result): - finished = datetime(2026, 4, 13, 12, 0, 0, tzinfo=UTC) - tr = create_db_task_result( - status="SUCCESSFUL", - return_value={"response": "Done.", "code_changes": False}, - started_at=datetime(2026, 4, 13, 11, 0, 0, tzinfo=UTC), - finished_at=finished, - ) - activity = Activity.objects.create( - trigger_type=TriggerType.API_JOB, repo_id="group/project", status=ActivityStatus.RUNNING, task_result=tr - ) - - out = StringIO() - call_command("sync_stuck_activities", stdout=out) - - activity.refresh_from_db() - assert activity.status == ActivityStatus.SUCCESSFUL - assert activity.finished_at == finished - assert activity.result_summary == "Done." - assert "Synced: 1" in out.getvalue() - - def test_skips_terminal_activities(self, create_db_task_result): - tr = create_db_task_result(status="SUCCESSFUL", return_value={"response": "Already done."}) - Activity.objects.create( - trigger_type=TriggerType.API_JOB, - repo_id="group/project", - status=ActivityStatus.SUCCESSFUL, - task_result=tr, - result_summary="Already done.", - ) - - out = StringIO() - call_command("sync_stuck_activities", stdout=out) - - assert "Synced: 0" in out.getvalue() - - def test_counts_already_synced_activity_as_skipped(self, create_db_task_result): - """A non-terminal Activity already in sync with its DBTaskResult counts toward `skipped`.""" - tr = create_db_task_result(status="READY") - Activity.objects.create( - trigger_type=TriggerType.API_JOB, repo_id="group/project", status=ActivityStatus.READY, task_result=tr - ) - - out = StringIO() - call_command("sync_stuck_activities", stdout=out) - - assert "Synced: 0, already up to date: 1" in out.getvalue() - - def test_skips_activities_without_task_result(self): - Activity.objects.create( - trigger_type=TriggerType.ISSUE_WEBHOOK, repo_id="group/project", status=ActivityStatus.RUNNING - ) - - out = StringIO() - call_command("sync_stuck_activities", stdout=out) - - assert "Synced: 0" in out.getvalue() - - def test_continues_after_per_row_error(self, create_db_task_result): - ok_tr = create_db_task_result( - status="SUCCESSFUL", - return_value={"response": "Done."}, - finished_at=datetime(2026, 4, 13, 12, 0, 0, tzinfo=UTC), - ) - bad_tr = create_db_task_result(status="SUCCESSFUL", return_value={"response": "boom."}) - - ok_activity = Activity.objects.create( - trigger_type=TriggerType.API_JOB, repo_id="group/project", status=ActivityStatus.RUNNING, task_result=ok_tr - ) - bad_activity = Activity.objects.create( - trigger_type=TriggerType.API_JOB, repo_id="group/project", status=ActivityStatus.RUNNING, task_result=bad_tr - ) - - original = Activity.sync_and_save - - def selectively_raise(self): - if self.pk == bad_activity.pk: - raise RuntimeError("simulated sync failure") - return original(self) - - out = StringIO() - with patch.object(Activity, "sync_and_save", selectively_raise), pytest.raises(CommandError) as exc_info: - call_command("sync_stuck_activities", stdout=out) - - ok_activity.refresh_from_db() - bad_activity.refresh_from_db() - assert ok_activity.status == ActivityStatus.SUCCESSFUL - assert bad_activity.status == ActivityStatus.RUNNING - assert "Synced: 1" in str(exc_info.value) - assert "errored: 1" in str(exc_info.value) - - -@pytest.mark.django_db(transaction=True) -class TestReleaseOrphanQueuedThreadsCommand: - def test_releases_queued_when_no_active_sibling(self, create_db_task_result): - """A QUEUED row with no READY/RUNNING sibling on the thread is dispatched.""" - thread = str(uuid.uuid4()) - orphan = Activity.objects.create( - trigger_type=TriggerType.API_JOB, repo_id="a/b", thread_id=thread, status=ActivityStatus.QUEUED, prompt="p" - ) - fake_task = MagicMock(id=create_db_task_result().id) - out = StringIO() - with patch("activity.signals.run_job_task") as mock_task: - mock_task.aenqueue = AsyncMock(return_value=fake_task) - call_command("release_orphan_queued_threads", stdout=out) - - orphan.refresh_from_db() - assert orphan.status == ActivityStatus.READY - assert orphan.task_result_id == fake_task.id - assert "Released: 1" in out.getvalue() - - def test_skips_queued_when_active_sibling_exists(self): - """A QUEUED row whose thread already has a READY/RUNNING sibling is left alone - (dispatch will fire naturally when the active sibling terminates).""" - thread = str(uuid.uuid4()) - Activity.objects.create( - trigger_type=TriggerType.API_JOB, repo_id="a/b", thread_id=thread, status=ActivityStatus.RUNNING, prompt="p" - ) - queued = Activity.objects.create( - trigger_type=TriggerType.API_JOB, repo_id="a/b", thread_id=thread, status=ActivityStatus.QUEUED, prompt="p" - ) - out = StringIO() - with patch("activity.signals.run_job_task") as mock_task: - mock_task.aenqueue = AsyncMock() - call_command("release_orphan_queued_threads", stdout=out) - mock_task.aenqueue.assert_not_called() - - queued.refresh_from_db() - assert queued.status == ActivityStatus.QUEUED - assert "Released: 0" in out.getvalue() - - -@pytest.mark.django_db(transaction=True) -class TestSyncReleasesQueuedSibling: - def test_terminal_dbtaskresult_releases_queued_sibling(self, create_db_task_result): - """When sync_stuck_activities reconciles a stuck RUNNING Activity whose - DBTaskResult is already terminal, the resulting activity_finished signal - must release the oldest QUEUED sibling on the same thread_id. - """ - thread = str(uuid.uuid4()) - tr = create_db_task_result(status="SUCCESSFUL", return_value={"response": "done"}) - stuck = Activity.objects.create( - trigger_type=TriggerType.API_JOB, - repo_id="a/b", - thread_id=thread, - status=ActivityStatus.RUNNING, - task_result=tr, - prompt="p", - ) - queued = Activity.objects.create( - trigger_type=TriggerType.API_JOB, repo_id="a/b", thread_id=thread, status=ActivityStatus.QUEUED, prompt="p" - ) - - fake_task = MagicMock(id=create_db_task_result().id) - with patch("activity.signals.run_job_task") as mock_task: - mock_task.aenqueue = AsyncMock(return_value=fake_task) - call_command("sync_stuck_activities") - - stuck.refresh_from_db() - queued.refresh_from_db() - assert stuck.status == ActivityStatus.SUCCESSFUL - assert queued.status == ActivityStatus.READY - assert queued.task_result_id == fake_task.id diff --git a/tests/unit_tests/activity/test_models.py b/tests/unit_tests/activity/test_models.py deleted file mode 100644 index db9607c9c..000000000 --- a/tests/unit_tests/activity/test_models.py +++ /dev/null @@ -1,348 +0,0 @@ -import uuid -from datetime import UTC, datetime -from decimal import Decimal -from unittest.mock import patch - -import pytest -from activity.models import Activity, ActivityStatus, TriggerType - -from accounts.models import User - - -@pytest.fixture -def admin_user(db): - return User.objects.create_user( - username="admin", - email="admin@test.com", - password="testpass", # noqa: S106 - role="admin", - ) - - -@pytest.fixture -def member_user(db): - return User.objects.create_user( - username="member", - email="member@test.com", - password="testpass", # noqa: S106 - role="member", - ) - - -def _create_activity(user=None, external_username=""): - return Activity.objects.create( - trigger_type=TriggerType.ISSUE_WEBHOOK, repo_id="group/repo", user=user, external_username=external_username - ) - - -class TestByOwner: - def test_admin_sees_all_activities(self, admin_user, member_user): - a1 = _create_activity(user=admin_user) - a2 = _create_activity(user=member_user) - a3 = _create_activity(external_username="someone_else") - - qs = Activity.objects.by_owner(admin_user) - assert set(qs.values_list("pk", flat=True)) == {a1.pk, a2.pk, a3.pk} - - def test_member_sees_own_activities(self, member_user): - own = _create_activity(user=member_user) - _create_activity(external_username="other") - - qs = Activity.objects.by_owner(member_user) - assert list(qs.values_list("pk", flat=True)) == [own.pk] - - def test_member_sees_activities_by_external_username(self, member_user): - by_fk = _create_activity(user=member_user) - by_ext = _create_activity(external_username="member") - _create_activity(external_username="someone_else") - - qs = Activity.objects.by_owner(member_user) - assert set(qs.values_list("pk", flat=True)) == {by_fk.pk, by_ext.pk} - - def test_member_sees_orphaned_activities_before_backfill(self, db): - """Activities with external_username but no user FK should be visible after login.""" - orphan = _create_activity(external_username="newdev") - _create_activity(external_username="other") - - user = User.objects.create_user( - username="newdev", - email="newdev@test.com", - password="testpass", # noqa: S106 - ) - - qs = Activity.objects.by_owner(user) - assert orphan.pk in set(qs.values_list("pk", flat=True)) - - -@pytest.mark.django_db -class TestSyncAndSave: - def test_returns_true_and_persists_when_changed(self, create_db_task_result): - finished = datetime(2026, 4, 13, 12, 0, 0, tzinfo=UTC) - tr = create_db_task_result(status="SUCCESSFUL", return_value={"response": "Job done."}, finished_at=finished) - activity = Activity.objects.create( - trigger_type=TriggerType.API_JOB, repo_id="group/project", status=ActivityStatus.READY, task_result=tr - ) - - assert activity.sync_and_save() is True - - activity.refresh_from_db() - assert activity.status == ActivityStatus.SUCCESSFUL - assert activity.finished_at == finished - assert activity.result_summary == "Job done." - - def test_returns_false_and_skips_save_when_no_changes(self, create_db_task_result): - finished = datetime(2026, 4, 13, 12, 0, 0, tzinfo=UTC) - tr = create_db_task_result( - status="SUCCESSFUL", return_value={"response": "Already synced."}, finished_at=finished - ) - activity = Activity.objects.create( - trigger_type=TriggerType.API_JOB, - repo_id="group/project", - status=ActivityStatus.SUCCESSFUL, - task_result=tr, - finished_at=finished, - result_summary="Already synced.", - ) - - with patch.object(Activity, "save") as mock_save: - assert activity.sync_and_save() is False - - mock_save.assert_not_called() - - -@pytest.mark.django_db -class TestSyncFromTaskResultUsage: - def test_syncs_usage_fields_from_successful_result(self, create_db_task_result): - tr = create_db_task_result( - status="SUCCESSFUL", - return_value={ - "response": "Done", - "code_changes": False, - "usage": { - "input_tokens": 5000, - "output_tokens": 2000, - "total_tokens": 7000, - "cost_usd": "0.033", - "by_model": {"claude-sonnet-4-6": {"input_tokens": 5000, "output_tokens": 2000}}, - }, - }, - ) - activity = Activity.objects.create( - trigger_type=TriggerType.API_JOB, repo_id="group/project", status=ActivityStatus.READY, task_result=tr - ) - - changed = activity.sync_from_task_result() - assert "input_tokens" in changed - assert "output_tokens" in changed - assert "total_tokens" in changed - assert "cost_usd" in changed - assert "usage_by_model" in changed - - assert activity.input_tokens == 5000 - assert activity.output_tokens == 2000 - assert activity.total_tokens == 7000 - assert activity.cost_usd == Decimal("0.033") - assert activity.usage_by_model == {"claude-sonnet-4-6": {"input_tokens": 5000, "output_tokens": 2000}} - - def test_no_usage_leaves_fields_null(self, create_db_task_result): - """Old results without usage field leave Activity usage fields as null.""" - tr = create_db_task_result(status="SUCCESSFUL", return_value={"response": "Done"}) - activity = Activity.objects.create( - trigger_type=TriggerType.API_JOB, repo_id="group/project", status=ActivityStatus.READY, task_result=tr - ) - - changed = activity.sync_from_task_result() - assert "input_tokens" not in changed - assert activity.input_tokens is None - assert activity.cost_usd is None - - def test_usage_not_overwritten_on_re_sync(self, create_db_task_result): - """Once usage is synced, re-syncing doesn't overwrite.""" - tr = create_db_task_result( - status="SUCCESSFUL", - return_value={ - "response": "Done", - "usage": { - "input_tokens": 5000, - "output_tokens": 2000, - "total_tokens": 7000, - "cost_usd": "0.033", - "by_model": {}, - }, - }, - ) - activity = Activity.objects.create( - trigger_type=TriggerType.API_JOB, - repo_id="group/project", - status=ActivityStatus.SUCCESSFUL, - task_result=tr, - result_summary="Done", - input_tokens=5000, - output_tokens=2000, - total_tokens=7000, - cost_usd=Decimal("0.033"), - usage_by_model={}, - ) - - changed = activity.sync_from_task_result() - assert "input_tokens" not in changed - - def test_syncs_tokens_when_cost_is_null(self, create_db_task_result): - """When cost_usd is None (unknown model), tokens are still synced.""" - tr = create_db_task_result( - status="SUCCESSFUL", - return_value={ - "response": "Done", - "usage": { - "input_tokens": 3000, - "output_tokens": 1000, - "total_tokens": 4000, - "cost_usd": None, - "by_model": {}, - }, - }, - ) - activity = Activity.objects.create( - trigger_type=TriggerType.API_JOB, repo_id="group/project", status=ActivityStatus.READY, task_result=tr - ) - - changed = activity.sync_from_task_result() - assert "input_tokens" in changed - assert activity.input_tokens == 3000 - assert activity.cost_usd is None - assert "cost_usd" not in changed - - -@pytest.mark.django_db -class TestActivityBatchId: - def test_batch_id_persisted(self, member_user): - batch = uuid.uuid4() - activity = Activity.objects.create( - trigger_type=TriggerType.UI_JOB, repo_id="x/y", user=member_user, batch_id=batch - ) - activity.refresh_from_db() - assert activity.batch_id == batch - - def test_batch_id_defaults_to_null(self, member_user): - activity = Activity.objects.create(trigger_type=TriggerType.UI_JOB, repo_id="x/y", user=member_user) - assert activity.batch_id is None - - def test_by_batch_returns_only_matching(self, member_user): - b1, b2 = uuid.uuid4(), uuid.uuid4() - a = Activity.objects.create(trigger_type=TriggerType.UI_JOB, repo_id="x/y", user=member_user, batch_id=b1) - other = Activity.objects.create(trigger_type=TriggerType.UI_JOB, repo_id="x/y", user=member_user, batch_id=b2) - qs = Activity.objects.by_batch(b1) - assert a in qs - assert other not in qs - - -class TestActivityThreadId: - def test_duplicate_thread_id_allowed(self, member_user): - """The same deterministic thread_id is reused across webhook events on a single MR/issue, - so multiple Activity rows must be allowed to share it.""" - shared = "deadbeef" * 4 - first = Activity.objects.create( - trigger_type=TriggerType.MR_WEBHOOK, - repo_id="group/repo", - user=member_user, - thread_id=shared, - mention_comment_id="100", - ) - second = Activity.objects.create( - trigger_type=TriggerType.MR_WEBHOOK, - repo_id="group/repo", - user=member_user, - thread_id=shared, - mention_comment_id="200", - ) - assert first.pk != second.pk - assert first.thread_id == second.thread_id == shared - - def test_empty_string_thread_id_rejected(self, member_user): - """The non-empty CheckConstraint still applies after dropping uniqueness.""" - from django.db.utils import IntegrityError - - with pytest.raises(IntegrityError): - Activity.objects.create(trigger_type=TriggerType.UI_JOB, repo_id="x/y", user=member_user, thread_id="") - - -class TestQueuedStatus: - def test_queued_in_choices(self): - assert ActivityStatus.QUEUED == "QUEUED" - assert "QUEUED" in {s.value for s in ActivityStatus} - - def test_queued_is_not_terminal(self): - assert "QUEUED" not in ActivityStatus.terminal() - - -@pytest.mark.django_db(transaction=True) -class TestActiveThreadConstraint: - """Sentinel tests pinning the partial unique constraint ``activity_one_active_per_thread``. - - A future migration that drops the constraint, removes the partial filter, or widens its - trigger_type scope would silently re-open the TOCTOU race that the constraint exists to - close — these tests fail loudly in that case. - """ - - def test_two_active_api_rows_on_same_thread_violate(self, member_user): - from django.db.utils import IntegrityError - - thread = str(uuid.uuid4()) - Activity.objects.create( - trigger_type=TriggerType.API_JOB, - repo_id="a/b", - user=member_user, - thread_id=thread, - status=ActivityStatus.READY, - ) - with pytest.raises(IntegrityError): - Activity.objects.create( - trigger_type=TriggerType.API_JOB, - repo_id="a/b", - user=member_user, - thread_id=thread, - status=ActivityStatus.RUNNING, - ) - - def test_two_active_schedule_rows_on_same_thread_allowed(self, member_user): - """SCHEDULE rows are intentionally excluded from the constraint.""" - thread = str(uuid.uuid4()) - Activity.objects.create( - trigger_type=TriggerType.SCHEDULE, - repo_id="a/b", - user=member_user, - thread_id=thread, - status=ActivityStatus.RUNNING, - ) - Activity.objects.create( - trigger_type=TriggerType.SCHEDULE, - repo_id="a/b", - user=member_user, - thread_id=thread, - status=ActivityStatus.READY, - ) - - def test_multiple_queued_siblings_allowed(self, member_user): - """QUEUED is intentionally outside the constraint so siblings can stack FIFO.""" - thread = str(uuid.uuid4()) - Activity.objects.create( - trigger_type=TriggerType.API_JOB, - repo_id="a/b", - user=member_user, - thread_id=thread, - status=ActivityStatus.RUNNING, - ) - Activity.objects.create( - trigger_type=TriggerType.API_JOB, - repo_id="a/b", - user=member_user, - thread_id=thread, - status=ActivityStatus.QUEUED, - ) - Activity.objects.create( - trigger_type=TriggerType.API_JOB, - repo_id="a/b", - user=member_user, - thread_id=thread, - status=ActivityStatus.QUEUED, - ) diff --git a/tests/unit_tests/activity/test_models_retry.py b/tests/unit_tests/activity/test_models_retry.py deleted file mode 100644 index 0632cbabb..000000000 --- a/tests/unit_tests/activity/test_models_retry.py +++ /dev/null @@ -1,24 +0,0 @@ -import pytest -from activity.models import Activity, ActivityStatus, TriggerType - - -@pytest.mark.django_db -class TestIsRetryable: - def _make(self, status: str, trigger: str) -> Activity: - return Activity(status=status, trigger_type=trigger, repo_id="acme/repo") - - @pytest.mark.parametrize( - "trigger", [TriggerType.API_JOB, TriggerType.MCP_JOB, TriggerType.SCHEDULE, TriggerType.UI_JOB] - ) - @pytest.mark.parametrize("status", [ActivityStatus.SUCCESSFUL, ActivityStatus.FAILED]) - def test_terminal_non_webhook_is_retryable(self, status, trigger): - assert self._make(status, trigger).is_retryable is True - - @pytest.mark.parametrize("status", [ActivityStatus.READY, ActivityStatus.RUNNING]) - def test_non_terminal_not_retryable(self, status): - assert self._make(status, TriggerType.API_JOB).is_retryable is False - - @pytest.mark.parametrize("trigger", [TriggerType.ISSUE_WEBHOOK, TriggerType.MR_WEBHOOK]) - @pytest.mark.parametrize("status", [ActivityStatus.SUCCESSFUL, ActivityStatus.FAILED]) - def test_webhook_not_retryable_even_when_terminal(self, status, trigger): - assert self._make(status, trigger).is_retryable is False diff --git a/tests/unit_tests/activity/test_run_form_env_field.py b/tests/unit_tests/activity/test_run_form_env_field.py deleted file mode 100644 index 880e7047b..000000000 --- a/tests/unit_tests/activity/test_run_form_env_field.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Tests for the sandbox_environment field on the agent-run form.""" - -from __future__ import annotations - -import json -import uuid -from unittest import mock - -from django.urls import reverse - -import pytest -from activity.forms import AgentRunCreateForm -from activity.models import Activity -from django_tasks_db.models import DBTaskResult, get_date_max -from sandbox_envs.models import SandboxEnvironment, Scope - - -def _make_task_result(task_id: uuid.UUID) -> mock.Mock: - DBTaskResult.objects.create( - id=task_id, - status="READY", - task_path="jobs.tasks.run_job_task", - args_kwargs={"args": [], "kwargs": {}}, - queue_name="default", - backend_name="default", - run_after=get_date_max(), - return_value={}, - ) - return mock.Mock(id=task_id) - - -@pytest.mark.django_db -def test_form_offers_user_and_global_envs(member_client, member_user): - """GET request provides caller's USER envs + all GLOBAL envs via sandbox_envs context.""" - # A GLOBAL default is seeded by migration; add an additional non-default global to verify visibility. - global_extra = SandboxEnvironment.objects.create(scope=Scope.GLOBAL, name="GlobalExtra", base_image="g") - user_env = SandboxEnvironment.objects.create(scope=Scope.USER, user=member_user, name="my-dev-env", base_image="x") - # Another user's env should NOT appear. - from accounts.models import User - - other = User.objects.create_user(username="other", email="other@e.com", password="x") # noqa: S106 - other_env = SandboxEnvironment.objects.create(scope=Scope.USER, user=other, name="other-env", base_image="y") - - resp = member_client.get(reverse("runs:agent_run_new")) - assert resp.status_code == 200 - # The env-picker renders names via escapejs (hyphens become -); check context instead of raw HTML. - envs = list(resp.context["sandbox_envs"]) - env_ids = {e.id for e in envs} - assert global_extra.id in env_ids - assert user_env.id in env_ids - assert other_env.id not in env_ids - # The hidden input for form submission must still appear. - assert "sandbox_environment" in resp.content.decode() - - -@pytest.mark.django_db -def test_form_init_filters_queryset_by_user(member_user): - """The form __init__ should restrict the sandbox_environment queryset to user's envs + globals.""" - global_env = SandboxEnvironment.objects.create(scope=Scope.GLOBAL, name="G", base_image="g") - user_env = SandboxEnvironment.objects.create(scope=Scope.USER, user=member_user, name="U", base_image="x") - from accounts.models import User - - other = User.objects.create_user(username="other2", email="other2@e.com", password="x") # noqa: S106 - other_env = SandboxEnvironment.objects.create(scope=Scope.USER, user=other, name="O", base_image="y") - - form = AgentRunCreateForm(user=member_user) - qs = form.fields["sandbox_environment"].queryset - ids = {e.id for e in qs} - assert global_env.id in ids - assert user_env.id in ids - assert other_env.id not in ids - - -@pytest.mark.django_db(transaction=True) -def test_post_persists_explicitly_selected_env(member_client, member_user): - """Explicit env on the form → every batch target carries it → Activity records it.""" - env = SandboxEnvironment.objects.create(scope=Scope.USER, user=member_user, name="prod", base_image="x") - - from activity import services as _services - - task_id = uuid.uuid4() - fake_task = _make_task_result(task_id) - with ( - mock.patch("activity.services.run_job_task") as m_task, - mock.patch("activity.views.submit_batch_runs", wraps=_services.submit_batch_runs) as m_submit, - ): - m_task.aenqueue = mock.AsyncMock(return_value=fake_task) - resp = member_client.post( - reverse("runs:agent_run_new"), - data={ - "prompt": "go", - "repos": json.dumps([{"repo_id": "a/b", "ref": ""}]), - "notify_on": "never", - "sandbox_environment": str(env.id), - }, - ) - - assert resp.status_code == 302 - targets = m_submit.call_args.kwargs["repos"] - assert [t.sandbox_environment_id for t in targets] == [str(env.id)] - activity = Activity.objects.get(task_result_id=task_id) - assert activity.sandbox_environment_id == env.id - - -@pytest.mark.django_db(transaction=True) -def test_post_auto_resolves_to_global_default(member_client): - """No explicit selection → Auto resolves to GLOBAL default at submit time and persists it.""" - SandboxEnvironment.objects.filter(scope=Scope.GLOBAL).delete() - default = SandboxEnvironment.objects.create( - scope=Scope.GLOBAL, name="Default", base_image="python:3.14", is_default=True - ) - from activity import services as _services - - task_id = uuid.uuid4() - fake_task = _make_task_result(task_id) - with ( - mock.patch("activity.services.run_job_task") as m_task, - mock.patch("activity.views.submit_batch_runs", wraps=_services.submit_batch_runs) as m_submit, - ): - m_task.aenqueue = mock.AsyncMock(return_value=fake_task) - resp = member_client.post( - reverse("runs:agent_run_new"), - data={"prompt": "go", "repos": json.dumps([{"repo_id": "a/b", "ref": ""}]), "notify_on": "never"}, - ) - assert resp.status_code == 302 - targets = m_submit.call_args.kwargs["repos"] - assert [t.sandbox_environment_id for t in targets] == [str(default.id)] - - -@pytest.mark.django_db(transaction=True) -def test_post_auto_resolves_user_env_matching_repo(member_client, member_user): - """Auto + USER env claiming the repo_id → submit-time resolution picks the USER env.""" - SandboxEnvironment.objects.filter(scope=Scope.GLOBAL).delete() - SandboxEnvironment.objects.create(scope=Scope.GLOBAL, name="Default", base_image="python:3.14", is_default=True) - user_env = SandboxEnvironment.objects.create( - scope=Scope.USER, user=member_user, name="mine", base_image="python:3.14", repo_ids=["acme/foo"] - ) - from activity import services as _services - - task_id = uuid.uuid4() - fake_task = _make_task_result(task_id) - with ( - mock.patch("activity.services.run_job_task") as m_task, - mock.patch("activity.views.submit_batch_runs", wraps=_services.submit_batch_runs) as m_submit, - ): - m_task.aenqueue = mock.AsyncMock(return_value=fake_task) - resp = member_client.post( - reverse("runs:agent_run_new"), - data={"prompt": "go", "repos": json.dumps([{"repo_id": "acme/foo", "ref": ""}]), "notify_on": "never"}, - ) - assert resp.status_code == 302 - targets = m_submit.call_args.kwargs["repos"] - assert [t.sandbox_environment_id for t in targets] == [str(user_env.id)] - activity = Activity.objects.get(task_result_id=task_id) - assert activity.sandbox_environment_id == user_env.id - - -@pytest.mark.django_db(transaction=True) -def test_post_auto_with_no_envs_stays_none(member_client): - """Auto with nothing to resolve to → target carries None and Activity records None.""" - SandboxEnvironment.objects.filter(scope=Scope.GLOBAL).delete() - from activity import services as _services - - task_id = uuid.uuid4() - fake_task = _make_task_result(task_id) - with ( - mock.patch("activity.services.run_job_task") as m_task, - mock.patch("activity.views.submit_batch_runs", wraps=_services.submit_batch_runs) as m_submit, - ): - m_task.aenqueue = mock.AsyncMock(return_value=fake_task) - resp = member_client.post( - reverse("runs:agent_run_new"), - data={"prompt": "go", "repos": json.dumps([{"repo_id": "a/b", "ref": ""}]), "notify_on": "never"}, - ) - assert resp.status_code == 302 - targets = m_submit.call_args.kwargs["repos"] - assert [t.sandbox_environment_id for t in targets] == [None] diff --git a/tests/unit_tests/activity/test_sandbox_env_link.py b/tests/unit_tests/activity/test_sandbox_env_link.py deleted file mode 100644 index 4bfa4f13e..000000000 --- a/tests/unit_tests/activity/test_sandbox_env_link.py +++ /dev/null @@ -1,43 +0,0 @@ -import pytest -from activity.models import Activity, TriggerType -from activity.services import acreate_activity -from sandbox_envs.models import SandboxEnvironment, Scope - - -@pytest.fixture -def user_factory(db): - from accounts.models import User - - counter = {"n": 0} - - async def _make(): - counter["n"] += 1 - n = counter["n"] - return await User.objects.acreate_user(username=f"u{n}", email=f"u{n}@e.com", password="x") # noqa: S106 - - return _make - - -@pytest.mark.django_db(transaction=True) -@pytest.mark.asyncio -async def test_activity_can_be_linked_to_sandbox_env(user_factory): - user = await user_factory() - env = await SandboxEnvironment.objects.acreate(scope=Scope.USER, user=user, name="dev", base_image="alpine:latest") - activity = await acreate_activity( - trigger_type=TriggerType.UI_JOB, task_result_id=None, repo_id="r/p", user=user, sandbox_environment=env - ) - await activity.arefresh_from_db() - assert activity.sandbox_environment_id == env.id - - -@pytest.mark.django_db(transaction=True) -@pytest.mark.asyncio -async def test_activity_sandbox_env_set_null_on_env_delete(user_factory): - user = await user_factory() - env = await SandboxEnvironment.objects.acreate(scope=Scope.USER, user=user, name="dev", base_image="alpine:latest") - activity = await acreate_activity( - trigger_type=TriggerType.UI_JOB, task_result_id=None, repo_id="r/p", user=user, sandbox_environment=env - ) - await env.adelete() - activity = await Activity.objects.aget(pk=activity.pk) - assert activity.sandbox_environment_id is None diff --git a/tests/unit_tests/activity/test_services.py b/tests/unit_tests/activity/test_services.py deleted file mode 100644 index 664ba0bde..000000000 --- a/tests/unit_tests/activity/test_services.py +++ /dev/null @@ -1,334 +0,0 @@ -import uuid -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from activity.models import Activity, ActivityStatus, TriggerType -from activity.services import RepoTarget, acreate_activity, asubmit_batch_runs, create_activity, validate_repo_list -from django_tasks_db.models import DBTaskResult, get_date_max -from notifications.choices import NotifyOn - -from schedules.models import Frequency, ScheduledJob - - -class TestValidateRepoListDuplicates: - def test_duplicate_with_ref_mentions_both_repo_and_ref(self): - raw = [{"repo_id": "acme/api", "ref": "main"}, {"repo_id": "acme/api", "ref": "main"}] - with pytest.raises(ValueError) as exc: - validate_repo_list(raw) - msg = str(exc.value) - assert "acme/api" in msg - assert "main" in msg - - def test_duplicate_without_ref_omits_on_clause(self): - raw = [{"repo_id": "acme/api", "ref": ""}, {"repo_id": "acme/api", "ref": ""}] - with pytest.raises(ValueError) as exc: - validate_repo_list(raw) - msg = str(exc.value) - assert "acme/api" in msg - assert " on " not in msg - - -@pytest.mark.django_db -class TestCreateActivityNotifyOn: - def test_explicit_notify_on_is_persisted(self, member_user): - activity = create_activity( - trigger_type=TriggerType.UI_JOB, - task_result_id=None, - repo_id="x/y", - user=member_user, - notify_on=NotifyOn.NEVER, - ) - assert activity.notify_on == NotifyOn.NEVER - - def test_no_notify_on_leaves_null(self, member_user): - activity = create_activity( - trigger_type=TriggerType.UI_JOB, task_result_id=None, repo_id="x/y", user=member_user - ) - assert activity.notify_on is None - - def test_schedule_run_defers_to_schedule_when_no_override(self, member_user): - """Without an explicit override, activity.notify_on stays null and the effective - value falls through to ScheduledJob.notify_on via Activity.effective_notify_on.""" - schedule = ScheduledJob.objects.create( - user=member_user, - name="s", - prompt="p", - repos=[{"repo_id": "x/y", "ref": ""}], - frequency=Frequency.DAILY, - time="12:00", - notify_on=NotifyOn.ALWAYS, - ) - activity = create_activity( - trigger_type=TriggerType.SCHEDULE, - task_result_id=None, - repo_id="x/y", - scheduled_job=schedule, - user=member_user, - ) - assert activity.notify_on is None - assert activity.effective_notify_on == NotifyOn.ALWAYS - - def test_explicit_notify_on_beats_schedule_default(self, member_user): - schedule = ScheduledJob.objects.create( - user=member_user, - name="s", - prompt="p", - repos=[{"repo_id": "x/y", "ref": ""}], - frequency=Frequency.DAILY, - time="12:00", - notify_on=NotifyOn.ALWAYS, - ) - activity = create_activity( - trigger_type=TriggerType.SCHEDULE, - task_result_id=None, - repo_id="x/y", - scheduled_job=schedule, - user=member_user, - notify_on=NotifyOn.NEVER, - ) - assert activity.notify_on == NotifyOn.NEVER - assert activity.effective_notify_on == NotifyOn.NEVER - - -@pytest.mark.django_db(transaction=True) -class TestAcreateActivityNotifyOn: - async def test_async_variant_threads_notify_on(self, member_user): - activity = await acreate_activity( - trigger_type=TriggerType.API_JOB, - task_result_id=None, - repo_id="x/y", - user=member_user, - notify_on=NotifyOn.ON_FAILURE, - ) - assert activity.notify_on == NotifyOn.ON_FAILURE - - -@pytest.mark.django_db -class TestEffectiveNotifyOn: - def test_run_override_wins_over_user_default(self, member_user): - from activity.models import Activity - - member_user.notify_on_jobs = NotifyOn.NEVER - member_user.save(update_fields=["notify_on_jobs"]) - - activity = Activity.objects.create( - trigger_type=TriggerType.UI_JOB, user=member_user, repo_id="x/y", notify_on=NotifyOn.ALWAYS - ) - assert activity.effective_notify_on == NotifyOn.ALWAYS - - def test_falls_back_to_user_default_when_no_override(self, member_user): - from activity.models import Activity - - member_user.notify_on_jobs = NotifyOn.ON_FAILURE - member_user.save(update_fields=["notify_on_jobs"]) - - activity = Activity.objects.create(trigger_type=TriggerType.UI_JOB, user=member_user, repo_id="x/y") - assert activity.effective_notify_on == NotifyOn.ON_FAILURE - - def test_schedule_default_used_when_no_override(self, member_user): - from activity.models import Activity - - schedule = ScheduledJob.objects.create( - user=member_user, - name="s", - prompt="p", - repos=[{"repo_id": "x/y", "ref": ""}], - frequency=Frequency.DAILY, - time="12:00", - notify_on=NotifyOn.ON_SUCCESS, - ) - activity = Activity.objects.create( - trigger_type=TriggerType.SCHEDULE, user=member_user, repo_id="x/y", scheduled_job=schedule - ) - assert activity.effective_notify_on == NotifyOn.ON_SUCCESS - - def test_returns_never_when_no_user_no_schedule_no_override(self): - from activity.models import Activity - - activity = Activity.objects.create(trigger_type=TriggerType.API_JOB, repo_id="x/y") - assert activity.effective_notify_on == NotifyOn.NEVER - - -async def _make_db_task_result() -> MagicMock: - task_id = uuid.uuid4() - await DBTaskResult.objects.acreate( - id=task_id, - status="READY", - task_path="jobs.tasks.run_job_task", - args_kwargs={"args": [], "kwargs": {}}, - queue_name="default", - backend_name="default", - run_after=get_date_max(), - return_value={}, - ) - return MagicMock(id=task_id) - - -def _patch_run_job_task(side_effect=None): - mock_task = MagicMock() - mock_task.aenqueue = AsyncMock(side_effect=side_effect) - return patch("activity.services.run_job_task", mock_task), mock_task - - -@pytest.mark.django_db(transaction=True) -class TestThreadContinuation: - async def test_reuses_supplied_thread_id(self, member_user): - thread = str(uuid.uuid4()) - fake_task = await _make_db_task_result() - patcher, mock_task = _patch_run_job_task() - mock_task.aenqueue.return_value = fake_task - with patcher: - result = await asubmit_batch_runs( - user=member_user, - prompt="follow-up", - repos=[RepoTarget(repo_id="acme/api", ref="")], - trigger_type=TriggerType.API_JOB, - thread_id=thread, - ) - activity = result.activities[0] - assert activity.thread_id == thread - - async def test_multi_repo_with_thread_id_raises(self, member_user): - thread = str(uuid.uuid4()) - with pytest.raises(ValueError, match="exactly one repo"): - await asubmit_batch_runs( - user=member_user, - prompt="p", - repos=[RepoTarget(repo_id="a/b", ref=""), RepoTarget(repo_id="c/d", ref="")], - trigger_type=TriggerType.API_JOB, - thread_id=thread, - ) - - async def test_prior_terminal_creates_ready_and_enqueues(self, member_user): - thread = str(uuid.uuid4()) - # Prior terminal Activity on this thread - await Activity.objects.acreate( - trigger_type=TriggerType.API_JOB, - repo_id="acme/api", - thread_id=thread, - status=ActivityStatus.SUCCESSFUL, - user=member_user, - ) - fake_task = await _make_db_task_result() - patcher, mock_task = _patch_run_job_task() - mock_task.aenqueue.return_value = fake_task - with patcher: - result = await asubmit_batch_runs( - user=member_user, - prompt="follow-up", - repos=[RepoTarget(repo_id="acme/api", ref="")], - trigger_type=TriggerType.API_JOB, - thread_id=thread, - ) - activity = result.activities[0] - assert activity.status == ActivityStatus.READY - mock_task.aenqueue.assert_called_once() - - async def test_prior_non_terminal_creates_queued_and_skips_enqueue(self, member_user): - thread = str(uuid.uuid4()) - await Activity.objects.acreate( - trigger_type=TriggerType.API_JOB, - repo_id="acme/api", - thread_id=thread, - status=ActivityStatus.RUNNING, - user=member_user, - ) - patcher, mock_task = _patch_run_job_task() - with patcher: - result = await asubmit_batch_runs( - user=member_user, - prompt="follow-up", - repos=[RepoTarget(repo_id="acme/api", ref="")], - trigger_type=TriggerType.API_JOB, - thread_id=thread, - ) - activity = result.activities[0] - assert activity.status == ActivityStatus.QUEUED - assert activity.task_result_id is None - mock_task.aenqueue.assert_not_called() - - async def test_asubmit_batch_runs_stores_and_forwards_overrides(self, member_user): - """The override pair must round-trip onto Activity and onto run_job_task.aenqueue. - - Empty-string defaults are converted to ``None`` at the ``run_job_task.aenqueue`` - boundary (matching its ``str | None`` signature); explicit values flow through - unchanged and ``use_max`` must NOT be forwarded. - """ - fake_task = await _make_db_task_result() - patcher, mock_task = _patch_run_job_task() - mock_task.aenqueue.return_value = fake_task - with patcher: - result = await asubmit_batch_runs( - user=member_user, - prompt="do thing", - repos=[RepoTarget(repo_id="acme/x", ref="main")], - agent_model="openrouter:anthropic/claude-haiku-4.5", - agent_thinking_level="low", - trigger_type=TriggerType.UI_JOB, - ) - - assert result.activities[0].agent_model == "openrouter:anthropic/claude-haiku-4.5" - assert result.activities[0].agent_thinking_level == "low" - enqueue_kwargs = mock_task.aenqueue.call_args.kwargs - assert enqueue_kwargs["agent_model"] == "openrouter:anthropic/claude-haiku-4.5" - assert enqueue_kwargs["agent_thinking_level"] == "low" - assert "use_max" not in enqueue_kwargs - - async def test_asubmit_batch_runs_empty_overrides_pass_none_to_aenqueue(self, member_user): - """Default empty-string overrides must surface as ``None`` at the task boundary.""" - fake_task = await _make_db_task_result() - patcher, mock_task = _patch_run_job_task() - mock_task.aenqueue.return_value = fake_task - with patcher: - await asubmit_batch_runs( - user=member_user, - prompt="do thing", - repos=[RepoTarget(repo_id="acme/x", ref="")], - trigger_type=TriggerType.UI_JOB, - ) - - enqueue_kwargs = mock_task.aenqueue.call_args.kwargs - assert enqueue_kwargs["agent_model"] is None - assert enqueue_kwargs["agent_thinking_level"] is None - assert "use_max" not in enqueue_kwargs - - async def test_enqueue_failure_marks_failed_with_audit_and_releases_queued_sibling(self, member_user): - """When the new READY row's enqueue raises, the row must transition to FAILED with - ``finished_at`` and a ``enqueue_failed:`` error_message, and an existing QUEUED sibling - on the same thread must be released (via emit_activity_finished_if_terminal).""" - thread = str(uuid.uuid4()) - # A prior QUEUED sibling waiting for the active slot to open up. - queued = await Activity.objects.acreate( - trigger_type=TriggerType.API_JOB, - repo_id="acme/api", - thread_id=thread, - status=ActivityStatus.QUEUED, - user=member_user, - prompt="p", - ) - good_task = await _make_db_task_result() - # services-layer aenqueue fails (the new submission); the dispatcher-layer - # aenqueue (used by signals.py to release the QUEUED sibling) succeeds. - services_patch, services_mock = _patch_run_job_task(side_effect=RuntimeError("broker down")) - signals_mock = MagicMock() - signals_mock.aenqueue = AsyncMock(return_value=good_task) - with services_patch, patch("activity.signals.run_job_task", signals_mock): - result = await asubmit_batch_runs( - user=member_user, - prompt="follow-up", - repos=[RepoTarget(repo_id="acme/api", ref="")], - trigger_type=TriggerType.API_JOB, - thread_id=thread, - ) - - assert result.activities == [] and len(result.failed) == 1 - assert "RuntimeError" in result.failed[0].error - services_mock.aenqueue.assert_awaited_once() - - failed_row = await Activity.objects.aget(thread_id=thread, status=ActivityStatus.FAILED) - assert failed_row.error_message.startswith("enqueue_failed:") - assert failed_row.finished_at is not None - - await queued.arefresh_from_db() - assert queued.status == ActivityStatus.READY - assert queued.task_result_id == good_task.id diff --git a/tests/unit_tests/activity/test_services_agent_override.py b/tests/unit_tests/activity/test_services_agent_override.py deleted file mode 100644 index 78095c26f..000000000 --- a/tests/unit_tests/activity/test_services_agent_override.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Activity persists the per-run agent override pair (agent_model + agent_thinking_level). - -This file replaces the old ``use_max`` assertions: ``use_max`` is no longer written by -non-webhook surfaces (the column stays on the model for one release but is left at its -default ``False``). -""" - -import uuid - -import pytest -from activity.models import Activity, TriggerType -from activity.services import acreate_activity, create_activity -from django_tasks_db.models import DBTaskResult, get_date_max - - -@pytest.fixture -def task_result_id(db): - result = DBTaskResult.objects.create( - id=uuid.uuid4(), - status="READY", - task_path="jobs.tasks.run_job_task", - args_kwargs={"args": [], "kwargs": {}}, - queue_name="default", - backend_name="default", - run_after=get_date_max(), - return_value={}, - ) - return result.id - - -@pytest.mark.django_db -def test_create_activity_persists_override_pair(task_result_id): - activity = create_activity( - trigger_type=TriggerType.UI_JOB, - task_result_id=task_result_id, - repo_id="acme/repo", - agent_model="openrouter:anthropic/claude-opus-4.6", - agent_thinking_level="high", - ) - stored = Activity.objects.get(pk=activity.pk) - assert stored.agent_model == "openrouter:anthropic/claude-opus-4.6" - assert stored.agent_thinking_level == "high" - - -@pytest.mark.django_db -def test_create_activity_defaults_override_pair_to_empty(task_result_id): - activity = create_activity(trigger_type=TriggerType.UI_JOB, task_result_id=task_result_id, repo_id="acme/repo") - assert activity.agent_model == "" - assert activity.agent_thinking_level == "" - - -@pytest.mark.asyncio -@pytest.mark.django_db(transaction=True) -async def test_acreate_activity_persists_override_pair(task_result_id): - activity = await acreate_activity( - trigger_type=TriggerType.UI_JOB, - task_result_id=task_result_id, - repo_id="acme/repo", - agent_model="openrouter:anthropic/claude-opus-4.6", - agent_thinking_level="high", - ) - assert activity.agent_model == "openrouter:anthropic/claude-opus-4.6" - assert activity.agent_thinking_level == "high" diff --git a/tests/unit_tests/activity/test_signals.py b/tests/unit_tests/activity/test_signals.py deleted file mode 100644 index c09e0f54d..000000000 --- a/tests/unit_tests/activity/test_signals.py +++ /dev/null @@ -1,384 +0,0 @@ -import uuid -from datetime import UTC, datetime -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from activity.models import Activity, ActivityStatus, TriggerType -from activity.signals import activity_finished -from django_tasks.signals import task_finished, task_started - -from accounts.models import User - - -def _create_activity(*, task_result=None, status=ActivityStatus.READY, **kwargs): - defaults = { - "trigger_type": TriggerType.API_JOB, - "repo_id": "group/project", - "status": status, - "task_result": task_result, - } - defaults.update(kwargs) - return Activity.objects.create(**defaults) - - -@pytest.mark.django_db -class TestBackfillActivityUser: - def test_backfills_orphaned_activities_on_user_create(self): - orphan = Activity.objects.create( - trigger_type=TriggerType.ISSUE_WEBHOOK, repo_id="group/repo", external_username="newdev" - ) - assert orphan.user is None - - user = User.objects.create_user( - username="newdev", - email="newdev@test.com", - password="testpass", # noqa: S106 - ) - - orphan.refresh_from_db() - assert orphan.user == user - - def test_does_not_backfill_already_linked_activities(self): - existing_user = User.objects.create_user( - username="existing", - email="existing@test.com", - password="testpass", # noqa: S106 - ) - linked = Activity.objects.create( - trigger_type=TriggerType.ISSUE_WEBHOOK, repo_id="group/repo", user=existing_user, external_username="newdev" - ) - - new_user = User.objects.create_user( - username="newdev", - email="newdev@test.com", - password="testpass", # noqa: S106 - ) - - linked.refresh_from_db() - assert linked.user == existing_user, "Should not overwrite existing user FK" - assert linked.user != new_user - - def test_does_not_backfill_on_user_update(self): - orphan = Activity.objects.create( - trigger_type=TriggerType.ISSUE_WEBHOOK, repo_id="group/repo", external_username="devuser" - ) - - user = User.objects.create_user( - username="devuser", - email="dev@test.com", - password="testpass", # noqa: S106 - ) - - orphan.refresh_from_db() - assert orphan.user == user - - # Now unlink manually and update user — should NOT re-backfill - Activity.objects.filter(pk=orphan.pk).update(user=None) - user.name = "Updated Name" - user.save() - - orphan.refresh_from_db() - assert orphan.user is None, "Should not backfill on user update, only on create" - - def test_no_match_when_external_username_differs(self): - orphan = Activity.objects.create( - trigger_type=TriggerType.ISSUE_WEBHOOK, repo_id="group/repo", external_username="other_user" - ) - - User.objects.create_user( - username="newdev", - email="newdev@test.com", - password="testpass", # noqa: S106 - ) - - orphan.refresh_from_db() - assert orphan.user is None - - -@pytest.mark.django_db -class TestSyncActivityOnTaskSignals: - def test_task_finished_syncs_successful_activity(self, create_db_task_result): - finished = datetime(2026, 4, 13, 12, 0, 0, tzinfo=UTC) - tr = create_db_task_result( - status="SUCCESSFUL", - return_value={"response": "Job done.", "code_changes": True, "merge_request_id": 42}, - started_at=datetime(2026, 4, 13, 11, 0, 0, tzinfo=UTC), - finished_at=finished, - ) - activity = _create_activity(task_result=tr, status=ActivityStatus.READY) - - task_finished.send(sender=type(None), task_result=tr.task_result) - - activity.refresh_from_db() - assert activity.status == ActivityStatus.SUCCESSFUL - assert activity.finished_at == finished - assert activity.result_summary == "Job done." - assert activity.code_changes is True - assert activity.merge_request_iid == 42 - - def test_task_finished_syncs_failed_activity(self, create_db_task_result): - tr = create_db_task_result( - status="FAILED", - exception_class_path="builtins.ValueError", - traceback="Traceback (most recent call last): ...", - started_at=datetime(2026, 4, 13, 11, 0, 0, tzinfo=UTC), - finished_at=datetime(2026, 4, 13, 11, 5, 0, tzinfo=UTC), - ) - activity = _create_activity(task_result=tr, status=ActivityStatus.RUNNING) - - task_finished.send(sender=type(None), task_result=tr.task_result) - - activity.refresh_from_db() - assert activity.status == ActivityStatus.FAILED - assert "ValueError" in activity.error_message - assert "Traceback" in activity.error_message - - def test_task_started_syncs_running_status(self, create_db_task_result): - started = datetime(2026, 4, 13, 11, 0, 0, tzinfo=UTC) - tr = create_db_task_result(status="RUNNING", started_at=started) - activity = _create_activity(task_result=tr, status=ActivityStatus.READY) - - task_started.send(sender=type(None), task_result=tr.task_result) - - activity.refresh_from_db() - assert activity.status == ActivityStatus.RUNNING - assert activity.started_at == started - - def test_task_finished_no_activity_does_not_raise(self, create_db_task_result): - tr = create_db_task_result(status="SUCCESSFUL", return_value={"response": "done"}) - - task_finished.send(sender=type(None), task_result=tr.task_result) - - def test_task_started_no_activity_does_not_raise(self, create_db_task_result): - tr = create_db_task_result(status="RUNNING") - - task_started.send(sender=type(None), task_result=tr.task_result) - - def test_signal_handler_swallows_sync_errors(self, create_db_task_result): - tr = create_db_task_result(status="SUCCESSFUL", return_value={"response": "done"}) - _create_activity(task_result=tr, status=ActivityStatus.READY) - - with patch.object(Activity, "sync_from_task_result", side_effect=RuntimeError("boom")): - task_finished.send(sender=type(None), task_result=tr.task_result) - - -@pytest.mark.django_db -class TestActivityFinishedSignal: - def test_emitted_on_transition_to_successful(self, member_user): - from unittest.mock import MagicMock - - from activity.signals import activity_finished, emit_activity_finished_if_terminal - - activity = Activity.objects.create( - trigger_type=TriggerType.SCHEDULE, user=member_user, repo_id="r/x", status=ActivityStatus.RUNNING - ) - received = MagicMock() - activity_finished.connect(received, dispatch_uid="test-succ") - try: - activity.status = ActivityStatus.SUCCESSFUL - activity.save() - emit_activity_finished_if_terminal(activity, previous_status=ActivityStatus.RUNNING) - - assert received.called - _, kwargs = received.call_args - assert kwargs["activity"] is activity - finally: - activity_finished.disconnect(dispatch_uid="test-succ") - - def test_not_emitted_when_still_running(self, member_user): - from unittest.mock import MagicMock - - from activity.signals import activity_finished, emit_activity_finished_if_terminal - - activity = Activity.objects.create( - trigger_type=TriggerType.SCHEDULE, user=member_user, repo_id="r/x", status=ActivityStatus.RUNNING - ) - received = MagicMock() - activity_finished.connect(received, dispatch_uid="test-run") - try: - emit_activity_finished_if_terminal(activity, previous_status=ActivityStatus.READY) - assert not received.called - finally: - activity_finished.disconnect(dispatch_uid="test-run") - - def test_not_emitted_when_already_terminal(self, member_user): - from unittest.mock import MagicMock - - from activity.signals import activity_finished, emit_activity_finished_if_terminal - - activity = Activity.objects.create( - trigger_type=TriggerType.SCHEDULE, user=member_user, repo_id="r/x", status=ActivityStatus.SUCCESSFUL - ) - received = MagicMock() - activity_finished.connect(received, dispatch_uid="test-term") - try: - emit_activity_finished_if_terminal(activity, previous_status=ActivityStatus.SUCCESSFUL) - assert not received.called - finally: - activity_finished.disconnect(dispatch_uid="test-term") - - -def _make_activity(*, thread_id: str, status: str, **kwargs) -> Activity: - return Activity.objects.create( - trigger_type=TriggerType.API_JOB, repo_id="acme/api", thread_id=thread_id, status=status, prompt="p", **kwargs - ) - - -@pytest.mark.django_db(transaction=True) -class TestDispatchNextInThread: - def test_releases_oldest_queued_sibling(self, create_db_task_result): - thread = str(uuid.uuid4()) - finished = _make_activity(thread_id=thread, status=ActivityStatus.SUCCESSFUL) - oldest = _make_activity(thread_id=thread, status=ActivityStatus.QUEUED) - _make_activity(thread_id=thread, status=ActivityStatus.QUEUED) # newer sibling - - db_task = create_db_task_result() - fake_task = MagicMock(id=db_task.id) - with patch("activity.signals.run_job_task") as mock_task: - mock_task.aenqueue = AsyncMock(return_value=fake_task) - activity_finished.send(sender=Activity, activity=finished) - - oldest.refresh_from_db() - assert oldest.status == ActivityStatus.READY - assert oldest.task_result_id == fake_task.id - - def test_no_op_when_no_thread_id(self): - finished = _make_activity(thread_id=None, status=ActivityStatus.SUCCESSFUL) - with patch("activity.signals.run_job_task") as mock_task: - mock_task.aenqueue = AsyncMock() - activity_finished.send(sender=Activity, activity=finished) - mock_task.aenqueue.assert_not_called() - - def test_no_op_when_no_queued_sibling(self): - thread = str(uuid.uuid4()) - finished = _make_activity(thread_id=thread, status=ActivityStatus.SUCCESSFUL) - with patch("activity.signals.run_job_task") as mock_task: - mock_task.aenqueue = AsyncMock() - activity_finished.send(sender=Activity, activity=finished) - mock_task.aenqueue.assert_not_called() - - def test_dispatch_failure_marks_queued_failed_and_unblocks_chain(self, create_db_task_result): - thread = str(uuid.uuid4()) - finished = _make_activity(thread_id=thread, status=ActivityStatus.SUCCESSFUL) - bad = _make_activity(thread_id=thread, status=ActivityStatus.QUEUED) - next_q = _make_activity(thread_id=thread, status=ActivityStatus.QUEUED) - - db_task = create_db_task_result() - fake_task = MagicMock(id=db_task.id) - with patch("activity.signals.run_job_task") as mock_task: - mock_task.aenqueue = AsyncMock(side_effect=[RuntimeError("queue down"), fake_task]) - # First iteration claims `bad`, enqueue raises, `bad` is marked FAILED. The - # dispatcher's in-call loop then iterates to `next_q` and successfully enqueues. - # The FAILED re-emit fires with skip_dispatch=True so it does not recurse. - activity_finished.send(sender=Activity, activity=finished) - - bad.refresh_from_db() - next_q.refresh_from_db() - assert bad.status == ActivityStatus.FAILED - assert "dispatch_failed" in (bad.error_message or "") - assert next_q.status == ActivityStatus.READY - assert next_q.task_result_id == fake_task.id - - def test_skip_dispatch_kwarg_suppresses_dispatcher(self): - """activity_finished with skip_dispatch=True must not re-enter dispatch_next_in_thread.""" - thread = str(uuid.uuid4()) - finished = _make_activity(thread_id=thread, status=ActivityStatus.SUCCESSFUL) - queued = _make_activity(thread_id=thread, status=ActivityStatus.QUEUED) - with patch("activity.signals.run_job_task") as mock_task: - mock_task.aenqueue = AsyncMock() - activity_finished.send(sender=Activity, activity=finished, skip_dispatch=True) - mock_task.aenqueue.assert_not_called() - queued.refresh_from_db() - assert queued.status == ActivityStatus.QUEUED - - def test_enqueue_failure_reemit_uses_skip_dispatch(self): - """The dispatch-failure path must re-emit ``activity_finished`` with - ``skip_dispatch=True`` so the dispatcher does not recurse — notifications still fire.""" - from activity.signals import _enqueue_queued_activity - - thread = str(uuid.uuid4()) - bad = _make_activity(thread_id=thread, status=ActivityStatus.READY) - captured: list = [] - - def _spy(sender, activity, **kwargs): - captured.append(kwargs.get("skip_dispatch")) - - activity_finished.connect(_spy, dispatch_uid="t-skip-test") - try: - with patch("activity.signals.run_job_task") as mock_task: - mock_task.aenqueue = AsyncMock(side_effect=RuntimeError("queue down")) - ok = _enqueue_queued_activity(bad) - finally: - activity_finished.disconnect(dispatch_uid="t-skip-test") - - assert ok is False - assert captured == [True], f"expected one emit with skip_dispatch=True, got {captured}" - - def test_bails_after_max_consecutive_failures(self, create_db_task_result): - """A persistent broker outage must not mass-fail every QUEUED row on the thread. - The loop bails after ``MAX_CONSECUTIVE_DISPATCH_FAILURES`` failures, leaving the - rest QUEUED for ``release_orphan_queued_threads`` to recover.""" - from activity.signals import MAX_CONSECUTIVE_DISPATCH_FAILURES - - thread = str(uuid.uuid4()) - finished = _make_activity(thread_id=thread, status=ActivityStatus.SUCCESSFUL) - queued_rows = [ - _make_activity(thread_id=thread, status=ActivityStatus.QUEUED) - for _ in range(MAX_CONSECUTIVE_DISPATCH_FAILURES + 2) - ] - with patch("activity.signals.run_job_task") as mock_task: - mock_task.aenqueue = AsyncMock(side_effect=RuntimeError("queue down")) - activity_finished.send(sender=Activity, activity=finished) - assert mock_task.aenqueue.call_count == MAX_CONSECUTIVE_DISPATCH_FAILURES - - statuses = {ActivityStatus.FAILED: 0, ActivityStatus.QUEUED: 0} - for row in queued_rows: - row.refresh_from_db() - statuses[row.status] = statuses.get(row.status, 0) + 1 - assert statuses[ActivityStatus.FAILED] == MAX_CONSECUTIVE_DISPATCH_FAILURES - assert statuses[ActivityStatus.QUEUED] == 2 - - def test_re_enqueue_propagates_agent_override(self, create_db_task_result): - """Releasing a QUEUED sibling must forward the per-row agent override pair - to ``run_job_task`` — a refactor that drops the kwargs would silently - downgrade the second-in-line run to the auto model.""" - thread = str(uuid.uuid4()) - finished = _make_activity(thread_id=thread, status=ActivityStatus.SUCCESSFUL) - _make_activity( - thread_id=thread, - status=ActivityStatus.QUEUED, - agent_model="openrouter:anthropic/claude-opus-4.6", - agent_thinking_level="high", - ) - - db_task = create_db_task_result() - fake_task = MagicMock(id=db_task.id) - with patch("activity.signals.run_job_task") as mock_task: - mock_task.aenqueue = AsyncMock(return_value=fake_task) - activity_finished.send(sender=Activity, activity=finished) - - kwargs = mock_task.aenqueue.call_args.kwargs - assert kwargs["agent_model"] == "openrouter:anthropic/claude-opus-4.6" - assert kwargs["agent_thinking_level"] == "high" - assert "use_max" not in kwargs - - def test_re_enqueue_resolves_legacy_use_max_to_max_preset(self, create_db_task_result): - """A pre-migration row that still carries ``use_max=True`` with an empty - override pair must resolve to the site-configured max preset rather than - silently downgrade to the default model when the dispatcher releases it.""" - from core.site_settings import site_settings - - thread = str(uuid.uuid4()) - finished = _make_activity(thread_id=thread, status=ActivityStatus.SUCCESSFUL) - _make_activity(thread_id=thread, status=ActivityStatus.QUEUED, use_max=True) - - db_task = create_db_task_result() - fake_task = MagicMock(id=db_task.id) - with patch("activity.signals.run_job_task") as mock_task: - mock_task.aenqueue = AsyncMock(return_value=fake_task) - activity_finished.send(sender=Activity, activity=finished) - - kwargs = mock_task.aenqueue.call_args.kwargs - assert kwargs["agent_model"] == site_settings.agent_max_model_name - assert kwargs["agent_thinking_level"] == site_settings.agent_max_thinking_level - assert "use_max" not in kwargs diff --git a/tests/unit_tests/activity/test_submit_batch_env.py b/tests/unit_tests/activity/test_submit_batch_env.py deleted file mode 100644 index 73beb0360..000000000 --- a/tests/unit_tests/activity/test_submit_batch_env.py +++ /dev/null @@ -1,89 +0,0 @@ -import uuid -from unittest import mock - -import pytest -from activity.models import TriggerType -from activity.services import RepoTarget, asubmit_batch_runs -from django_tasks_db.models import DBTaskResult, get_date_max -from sandbox_envs.models import SandboxEnvironment, Scope - - -async def _atask_result_row(task_id: uuid.UUID) -> mock.Mock: - await DBTaskResult.objects.acreate( - id=task_id, - status="READY", - task_path="jobs.tasks.run_job_task", - args_kwargs={"args": [], "kwargs": {}}, - queue_name="default", - backend_name="default", - run_after=get_date_max(), - return_value={}, - ) - return mock.Mock(id=task_id) - - -@pytest.mark.django_db(transaction=True) -@pytest.mark.asyncio -async def test_submit_batch_passes_sandbox_env_to_task_and_activity(): - from accounts.models import User - - user = await User.objects.acreate_user(username="u", email="u@e.com", password="x") # noqa: S106 - env = await SandboxEnvironment.objects.acreate(scope=Scope.USER, user=user, name="dev", base_image="alpine:latest") - - fake_task = await _atask_result_row(uuid.uuid4()) - - with ( - mock.patch("activity.services.run_job_task") as m_task, - mock.patch("activity.services.generate_batch_title_task") as m_title, - ): - m_task.aenqueue = mock.AsyncMock(return_value=fake_task) - m_title.aenqueue = mock.AsyncMock() - result = await asubmit_batch_runs( - user=user, - prompt="p", - repos=[RepoTarget(repo_id="r/p", sandbox_environment_id=str(env.id))], - trigger_type=TriggerType.UI_JOB, - ) - - assert m_task.aenqueue.await_args.kwargs["sandbox_environment_id"] == str(env.id) - activity = result.activities[0] - await activity.arefresh_from_db() - assert activity.sandbox_environment_id == env.id - - -@pytest.mark.django_db(transaction=True) -@pytest.mark.asyncio -async def test_submit_batch_honors_per_target_env_ids(): - """Each RepoTarget carries its own env id; the batch must not collapse them - to a single shared id when enqueueing or stamping Activities.""" - from accounts.models import User - - user = await User.objects.acreate_user(username="u", email="u@e.com", password="x") # noqa: S106 - env_a = await SandboxEnvironment.objects.acreate(scope=Scope.USER, user=user, name="a", base_image="x") - env_b = await SandboxEnvironment.objects.acreate(scope=Scope.USER, user=user, name="b", base_image="x") - - task_ids = [uuid.uuid4(), uuid.uuid4()] - fake_tasks = [await _atask_result_row(tid) for tid in task_ids] - with ( - mock.patch("activity.services.run_job_task") as m_task, - mock.patch("activity.services.generate_batch_title_task") as m_title, - ): - m_task.aenqueue = mock.AsyncMock(side_effect=fake_tasks) - m_title.aenqueue = mock.AsyncMock() - result = await asubmit_batch_runs( - user=user, - prompt="p", - repos=[ - RepoTarget(repo_id="r/a", sandbox_environment_id=str(env_a.id)), - RepoTarget(repo_id="r/b", sandbox_environment_id=str(env_b.id)), - ], - trigger_type=TriggerType.UI_JOB, - ) - - enqueued = [c.kwargs["sandbox_environment_id"] for c in m_task.aenqueue.await_args_list] - assert sorted(enqueued) == sorted([str(env_a.id), str(env_b.id)]) - by_repo = {a.repo_id: a for a in result.activities} - await by_repo["r/a"].arefresh_from_db() - await by_repo["r/b"].arefresh_from_db() - assert by_repo["r/a"].sandbox_environment_id == env_a.id - assert by_repo["r/b"].sandbox_environment_id == env_b.id diff --git a/tests/unit_tests/activity/test_templatetags.py b/tests/unit_tests/activity/test_templatetags.py deleted file mode 100644 index e33c7b54a..000000000 --- a/tests/unit_tests/activity/test_templatetags.py +++ /dev/null @@ -1,113 +0,0 @@ -from decimal import Decimal - -import pytest -from activity.models import Activity, TriggerType -from activity.templatetags.activity_tags import activity_title, approx_prompt_tokens, format_cost, format_tokens - - -class TestActivityTitle: - def _activity(self, **kwargs): - # In-memory Activity (unsaved) is fine for pure formatting logic. - defaults = {"trigger_type": TriggerType.UI_JOB, "repo_id": "acme/web", "prompt": ""} - defaults.update(kwargs) - return Activity(**defaults) - - def test_uses_prompt_first_line_when_present(self): - activity = self._activity(prompt="Refactor checkout\nDetails: remove the redirect") - assert activity_title(activity) == "Refactor checkout" - - def test_strips_leading_whitespace_and_blank_lines(self): - activity = self._activity(prompt="\n \n Do the thing \n") - assert activity_title(activity) == "Do the thing" - - def test_truncates_long_single_line_to_100_chars_with_ellipsis(self): - long = "x" * 250 - activity = self._activity(prompt=long) - title = activity_title(activity) - assert len(title) == 101 # 100 chars + ellipsis - assert title.endswith("…") - assert title.startswith("x" * 100) - - def test_issue_webhook_with_no_prompt_uses_issue_iid(self): - activity = self._activity(prompt="", trigger_type=TriggerType.ISSUE_WEBHOOK, issue_iid=412) - assert activity_title(activity) == "Issue #412" - - def test_issue_webhook_without_iid_falls_back_to_trigger_label(self): - activity = self._activity(prompt="", trigger_type=TriggerType.ISSUE_WEBHOOK, issue_iid=None) - assert activity_title(activity) == "Issue" - - def test_mr_webhook_with_no_prompt_uses_mr_iid(self): - activity = self._activity(prompt="", trigger_type=TriggerType.MR_WEBHOOK, merge_request_iid=1289) - assert activity_title(activity) == "MR/PR !1289" - - def test_mr_webhook_without_iid_falls_back_to_trigger_label(self): - activity = self._activity(prompt="", trigger_type=TriggerType.MR_WEBHOOK, merge_request_iid=None) - assert activity_title(activity) == "MR/PR" - - def test_job_with_empty_prompt_falls_back_to_trigger_and_repo(self): - activity = self._activity(prompt=" ", trigger_type=TriggerType.SCHEDULE, repo_id="acme/api") - assert activity_title(activity) == "Scheduled Run on acme/api" - - def test_whitespace_only_prompt_with_webhook_falls_back_to_iid_label(self): - activity = self._activity(prompt=" \n\n", trigger_type=TriggerType.ISSUE_WEBHOOK, issue_iid=None) - assert activity_title(activity) == "Issue" - - def test_prompt_wins_over_issue_iid(self): - activity = self._activity(prompt="My specific request", trigger_type=TriggerType.ISSUE_WEBHOOK, issue_iid=99) - assert activity_title(activity) == "My specific request" - - -class TestFormatCost: - def test_none_returns_empty(self): - assert format_cost(None) == "" - - def test_sub_cent_shows_four_decimals(self): - assert format_cost(Decimal("0.003")) == "$0.0030" - - def test_above_cent_shows_two_decimals(self): - assert format_cost(Decimal("1.50")) == "$1.50" - - def test_exact_cent_boundary(self): - assert format_cost(Decimal("0.01")) == "$0.01" - - def test_string_input(self): - assert format_cost("0.005") == "$0.0050" - - -class TestFormatTokens: - def test_none_returns_empty(self): - assert format_tokens(None) == "" - - def test_small_number(self): - assert format_tokens(500) == "500" - - def test_thousands(self): - assert format_tokens(1500) == "1.5k" - - def test_millions(self): - assert format_tokens(2_500_000) == "2.5M" - - @pytest.mark.parametrize("value", [999, 0, 1]) - def test_below_thousand(self, value): - assert format_tokens(value) == str(value) - - -class TestApproxPromptTokens: - def test_empty_string_returns_zero(self): - assert approx_prompt_tokens("") == 0 - - def test_none_returns_zero(self): - assert approx_prompt_tokens(None) == 0 - - def test_four_chars_per_token_heuristic(self): - # Eight characters → two "tokens" under the len//4 heuristic. - assert approx_prompt_tokens("abcdefgh") == 2 - - def test_short_string_floors_to_zero(self): - # Three characters → 0 tokens; the caller is expected to hide the hint - # when the value is falsy, so 0 is the right floor. - assert approx_prompt_tokens("abc") == 0 - - def test_longer_prompt(self): - prompt = "x" * 1000 - assert approx_prompt_tokens(prompt) == 250 diff --git a/tests/unit_tests/activity/test_views.py b/tests/unit_tests/activity/test_views.py deleted file mode 100644 index a7673c0a7..000000000 --- a/tests/unit_tests/activity/test_views.py +++ /dev/null @@ -1,586 +0,0 @@ -import uuid - -from django.test import Client -from django.urls import reverse - -import pytest -from activity.models import Activity, ActivityStatus, TriggerType -from django_tasks_db.models import DBTaskResult - -from accounts.models import User -from schedules.models import Frequency, ScheduledJob - - -@pytest.fixture -def user(db): - return User.objects.create_user(username="alice", email="alice@test.com", password="testpass123") # noqa: S106 - - -@pytest.fixture -def logged_in_client(user): - client = Client() - client.force_login(user) - return client - - -def _create_task_result(*, status="SUCCESSFUL", return_value=None): - return DBTaskResult.objects.create( - id=uuid.uuid4(), - status=status, - task_path="jobs.tasks.run_job_task", - args_kwargs={"args": [], "kwargs": {}}, - queue_name="default", - backend_name="default", - run_after="9999-01-01T00:00:00Z", - return_value=return_value or {}, - exception_class_path="", - traceback="", - ) - - -def _create_activity(*, status=ActivityStatus.SUCCESSFUL, task_result=None, **kwargs): - defaults = { - "trigger_type": TriggerType.SCHEDULE, - "repo_id": "group/project", - "ref": "main", - "prompt": "Run a security audit", - "status": status, - "task_result": task_result, - } - defaults.update(kwargs) - return Activity.objects.create(**defaults) - - -@pytest.mark.django_db -class TestActivityDownloadMarkdownView: - def test_download_with_task_result(self, logged_in_client): - tr = _create_task_result(return_value={"response": "# Security Report\n\nAll good.", "code_changes": False}) - activity = _create_activity(task_result=tr) - - response = logged_in_client.get(reverse("activity_download_md", kwargs={"pk": activity.pk})) - - assert response.status_code == 200 - assert response["Content-Type"] == "text/markdown; charset=utf-8" - assert "attachment" in response["Content-Disposition"] - assert ".md" in response["Content-Disposition"] - - body = response.content.decode() - assert "---" in body - assert "repository: group/project" in body - assert "# Security Report" in body - assert "All good." in body - - def test_download_with_result_summary_fallback(self, logged_in_client): - activity = _create_activity(result_summary="Summary of the result") - - response = logged_in_client.get(reverse("activity_download_md", kwargs={"pk": activity.pk})) - - assert response.status_code == 200 - body = response.content.decode() - assert "Summary of the result" in body - - def test_download_includes_metadata(self, logged_in_client): - activity = _create_activity( - result_summary="Result content", ref="feature-branch", issue_iid=42, merge_request_iid=10 - ) - - response = logged_in_client.get(reverse("activity_download_md", kwargs={"pk": activity.pk})) - - body = response.content.decode() - assert "ref: feature-branch" in body - assert "issue: '#42'" in body - assert "merge_request: '!10'" in body - assert "trigger: Scheduled Run" in body - - def test_download_filename_format(self, logged_in_client): - activity = _create_activity(result_summary="Content") - - response = logged_in_client.get(reverse("activity_download_md", kwargs={"pk": activity.pk})) - - disposition = response["Content-Disposition"] - assert disposition.startswith('attachment; filename="daiv-group-project-') - assert disposition.endswith('.md"') - - def test_task_result_response_takes_priority_over_result_summary(self, logged_in_client): - tr = _create_task_result(return_value={"response": "Full response text", "code_changes": False}) - activity = _create_activity(task_result=tr, result_summary="Truncated summary") - - response = logged_in_client.get(reverse("activity_download_md", kwargs={"pk": activity.pk})) - - body = response.content.decode() - assert "Full response text" in body - assert "Truncated summary" not in body - - def test_legacy_return_value_without_response_falls_back_to_summary(self, logged_in_client): - tr = _create_task_result(return_value={"code_changes": True}) - activity = _create_activity(task_result=tr, result_summary="Fallback summary") - - response = logged_in_client.get(reverse("activity_download_md", kwargs={"pk": activity.pk})) - - body = response.content.decode() - assert "Fallback summary" in body - - def test_non_successful_activity_returns_404(self, logged_in_client): - activity = _create_activity(status=ActivityStatus.FAILED, result_summary="error") - - response = logged_in_client.get(reverse("activity_download_md", kwargs={"pk": activity.pk})) - - assert response.status_code == 404 - - def test_successful_activity_without_result_returns_404(self, logged_in_client): - activity = _create_activity(result_summary="") - - response = logged_in_client.get(reverse("activity_download_md", kwargs={"pk": activity.pk})) - - assert response.status_code == 404 - - def test_unauthenticated_redirects_to_login(self): - activity = _create_activity(result_summary="Content") - client = Client() - - response = client.get(reverse("activity_download_md", kwargs={"pk": activity.pk})) - - assert response.status_code == 302 - assert "/accounts/login/" in response.url - - -@pytest.mark.django_db -class TestActivityListView: - def test_unauthenticated_redirects_to_login(self): - response = Client().get(reverse("activity_list")) - assert response.status_code == 302 - assert "/accounts/login/" in response.url - - def test_owner_scoping_applied_before_filters(self, logged_in_client, user): - mine = _create_activity(user=user, repo_id="mine/repo") - other = User.objects.create_user( - username="bob", - email="bob@test.com", - password="testpass123", # noqa: S106 - ) - theirs = _create_activity(user=other, repo_id="mine/repo") - - response = logged_in_client.get(reverse("activity_list"), {"repo": "mine/repo"}) - - assert response.status_code == 200 - activities = list(response.context["activities"]) - assert mine in activities - # by_owner must run before the filterset — without it, the repo filter would leak `theirs`. - assert theirs not in activities - - def test_filter_by_status(self, logged_in_client, user): - success = _create_activity(user=user, status=ActivityStatus.SUCCESSFUL) - failed = _create_activity(user=user, status=ActivityStatus.FAILED) - response = logged_in_client.get(reverse("activity_list"), {"status": ActivityStatus.SUCCESSFUL}) - activities = list(response.context["activities"]) - assert success in activities - assert failed not in activities - - def test_date_param_names_are_date_from_and_date_to(self, logged_in_client, user): - """Lock in the URL contract after the `from`/`to` → `date_from`/`date_to` rename.""" - _create_activity(user=user) - response = logged_in_client.get(reverse("activity_list"), {"date_from": "2020-01-01", "date_to": "2100-01-01"}) - assert response.status_code == 200 - # Values round-trip to the template context so the date inputs stay populated. - assert response.context["current_from"] == "2020-01-01" - assert response.context["current_to"] == "2100-01-01" - - def test_invalid_filter_drops_silently(self, logged_in_client, user): - activity = _create_activity(user=user) - response = logged_in_client.get(reverse("activity_list"), {"status": "bogus"}) - assert response.status_code == 200 - # Invalid choice is dropped; full (owner-scoped) list is shown and context key is empty. - assert activity in response.context["activities"] - assert response.context["current_status"] == "" - - def test_has_active_filters_false_with_no_params(self, logged_in_client, user): - _create_activity(user=user) - response = logged_in_client.get(reverse("activity_list")) - assert response.context["has_active_filters"] is False - assert response.context["current_batch_short"] == "" - - def test_has_active_filters_true_when_only_batch_is_set(self, logged_in_client, user): - batch_id = uuid.uuid4() - _create_activity(user=user) - response = logged_in_client.get(reverse("activity_list"), {"batch": str(batch_id)}) - assert response.context["has_active_filters"] is True - assert response.context["current_batch_short"] == str(batch_id)[:8] - - -@pytest.mark.django_db -class TestActivityDetailView: - def _get(self, logged_in_client, activity): - response = logged_in_client.get(reverse("activity_detail", kwargs={"pk": activity.pk})) - assert response.status_code == 200 - return response - - def test_h1_uses_first_line_of_prompt(self, logged_in_client, user): - activity = _create_activity(user=user, prompt="Refactor checkout\nMore context") - body = self._get(logged_in_client, activity).content.decode() - assert "Finished<" not in body - - def test_rail_context_renders_mr_link_when_url_present(self, logged_in_client, user): - activity = _create_activity( - user=user, - merge_request_iid=1289, - merge_request_web_url="https://gitlab.example.com/acme/web/-/merge_requests/1289", - ) - body = self._get(logged_in_client, activity).content.decode() - assert "!1289" in body - assert "https://gitlab.example.com/acme/web/-/merge_requests/1289" in body - - def test_rail_context_shows_model_row_only_when_use_max(self, logged_in_client, user): - default_model = _create_activity(user=user, use_max=False) - max_model = _create_activity(user=user, use_max=True) - - body_default = self._get(logged_in_client, default_model).content.decode() - body_max = self._get(logged_in_client, max_model).content.decode() - assert ">Model<" not in body_default - assert ">Max<" not in body_default - assert ">Model<" in body_max - assert ">Max<" in body_max - - def test_rail_context_shows_agent_model_pill_when_override_set(self, logged_in_client, user): - """When ``agent_model`` is set, the badge takes precedence over the legacy - ``use_max`` flag and surfaces the chosen model (provider prefix stripped) - plus the human label of ``agent_thinking_level``.""" - activity = _create_activity( - user=user, use_max=False, agent_model="anthropic:claude-opus-4-5", agent_thinking_level="high" - ) - body = self._get(logged_in_client, activity).content.decode() - assert ">Model<" in body - # Provider prefix is stripped by the ``|cut:":"`` filter. - assert "claude-opus-4-5" in body - # Effort uses the choice label ("High"), not the raw value. - assert "High" in body - # Legacy ``Max`` text must NOT appear when ``agent_model`` wins. - assert ">Max<" not in body - - def test_rail_context_hides_owner_for_non_admin(self, logged_in_client, user): - activity = _create_activity(user=user) - body = self._get(logged_in_client, activity).content.decode() - assert ">Owner<" not in body - - def test_rail_usage_renders_token_and_cost_stats(self, logged_in_client, user): - from decimal import Decimal - - activity = _create_activity( - user=user, input_tokens=38200, output_tokens=3900, total_tokens=42100, cost_usd=Decimal("0.18") - ) - body = self._get(logged_in_client, activity).content.decode() - assert "42.1k" in body - assert "$0.18" in body - - def test_rail_usage_shows_placeholders_when_no_data(self, logged_in_client, user): - activity = _create_activity(user=user, status=ActivityStatus.RUNNING) - body = self._get(logged_in_client, activity).content.decode() - assert "—" in body - - def test_failed_hero_shows_no_details_when_nothing_available(self, logged_in_client, user): - activity = _create_activity(user=user, status=ActivityStatus.FAILED, task_result=None, error_message="") - body = self._get(logged_in_client, activity).content.decode() - assert "No error details available." in body - - def test_rail_usage_per_model_breakdown_shown_when_multiple_models(self, logged_in_client, user): - activity = _create_activity( - user=user, - total_tokens=100, - usage_by_model={ - "claude-sonnet": {"input_tokens": 60, "output_tokens": 30, "cost_usd": "0.10"}, - "claude-haiku": {"input_tokens": 6, "output_tokens": 4, "cost_usd": "0.01"}, - }, - ) - body = self._get(logged_in_client, activity).content.decode() - assert "Per-model breakdown" in body - assert "claude-sonnet" in body - assert "claude-haiku" in body - - def test_rail_usage_per_model_breakdown_hidden_for_single_model(self, logged_in_client, user): - activity = _create_activity( - user=user, - total_tokens=100, - usage_by_model={"claude-sonnet": {"input_tokens": 60, "output_tokens": 30, "cost_usd": "0.10"}}, - ) - body = self._get(logged_in_client, activity).content.decode() - assert "Per-model breakdown" not in body - - -@pytest.mark.django_db -class TestActivityVisibilityForSubscribers: - def _schedule(self, owner, **overrides): - data = { - "user": owner, - "name": "s", - "prompt": "p", - "repos": [{"repo_id": "x/y", "ref": ""}], - "frequency": Frequency.DAILY, - "time": "12:00", - } - data.update(overrides) - return ScheduledJob.objects.create(**data) - - def _activity(self, schedule, **overrides): - data = { - "trigger_type": TriggerType.SCHEDULE, - "repo_id": schedule.repos[0]["repo_id"], - "status": ActivityStatus.SUCCESSFUL, - "scheduled_job": schedule, - "user": schedule.user, - } - data.update(overrides) - return Activity.objects.create(**data) - - def test_subscriber_can_view_linked_activity_detail(self, member_user): - owner = User.objects.create_user(username="owner", email="owner@t.com", password="x") # noqa: S106 - schedule = self._schedule(owner) - schedule.subscribers.add(member_user) - activity = self._activity(schedule) - - client = Client() - client.force_login(member_user) - response = client.get(reverse("activity_detail", args=[activity.pk])) - assert response.status_code == 200 - - def test_non_subscriber_cannot_view_linked_activity_detail(self, member_user): - owner = User.objects.create_user(username="owner", email="owner@t.com", password="x") # noqa: S106 - schedule = self._schedule(owner) - activity = self._activity(schedule) - - client = Client() - client.force_login(member_user) - response = client.get(reverse("activity_detail", args=[activity.pk])) - assert response.status_code == 404 - - def test_subscriber_sees_activity_in_list(self, member_user): - owner = User.objects.create_user(username="owner", email="owner@t.com", password="x") # noqa: S106 - schedule = self._schedule(owner) - schedule.subscribers.add(member_user) - activity = self._activity(schedule) - - client = Client() - client.force_login(member_user) - response = client.get(reverse("activity_list")) - assert response.status_code == 200 - assert str(activity.pk) in response.content.decode() - - def test_list_does_not_duplicate_rows_for_admins_matching_twice(self, admin_user): - owner = User.objects.create_user(username="owner", email="owner@t.com", password="x") # noqa: S106 - schedule = self._schedule(owner) - schedule.subscribers.add(admin_user) - activity = self._activity(schedule) - - client = Client() - client.force_login(admin_user) - response = client.get(reverse("activity_list")) - # Count rows by the detail-url anchor (one per distinct row). - detail_url = reverse("activity_detail", args=[activity.pk]) - assert response.content.decode().count(detail_url) == 1 - - -@pytest.mark.django_db -class TestActivityDetailSubscriberContext: - def _fixture(self): - owner = User.objects.create_user(username="own", email="own@t.com", password="x") # noqa: S106 - sub = User.objects.create_user(username="sub", email="sub@t.com", password="x") # noqa: S106 - schedule = ScheduledJob.objects.create( - user=owner, - name="s", - prompt="p", - repos=[{"repo_id": "x/y", "ref": ""}], - frequency=Frequency.DAILY, - time="12:00", - ) - schedule.subscribers.add(sub) - activity = Activity.objects.create( - trigger_type=TriggerType.SCHEDULE, - repo_id="x/y", - status=ActivityStatus.SUCCESSFUL, - scheduled_job=schedule, - user=owner, - ) - return owner, sub, schedule, activity - - def test_is_subscriber_true_for_subscriber(self): - _, sub, _, activity = self._fixture() - client = Client() - client.force_login(sub) - response = client.get(reverse("activity_detail", args=[activity.pk])) - assert response.context["is_subscriber"] is True - - def test_is_subscriber_false_for_owner(self): - owner, _, _, activity = self._fixture() - client = Client() - client.force_login(owner) - response = client.get(reverse("activity_detail", args=[activity.pk])) - assert response.context["is_subscriber"] is False - - def test_unsubscribe_button_visible_to_subscriber(self): - _, sub, schedule, activity = self._fixture() - client = Client() - client.force_login(sub) - response = client.get(reverse("activity_detail", args=[activity.pk])) - html = response.content.decode() - assert reverse("schedule_unsubscribe", args=[schedule.pk]) in html - assert "Unsubscribe" in html - - def test_unsubscribe_button_hidden_for_owner(self): - owner, _, _, activity = self._fixture() - client = Client() - client.force_login(owner) - response = client.get(reverse("activity_detail", args=[activity.pk])) - html = response.content.decode() - assert "schedule_unsubscribe" not in html - assert "Unsubscribe" not in html - - def test_schedule_name_is_plain_text_for_subscriber(self): - _, sub, schedule, activity = self._fixture() - client = Client() - client.force_login(sub) - response = client.get(reverse("activity_detail", args=[activity.pk])) - html = response.content.decode() - assert reverse("schedule_update", args=[schedule.pk]) not in html - assert schedule.name in html - - def test_schedule_name_is_link_for_owner(self): - owner, _, schedule, activity = self._fixture() - client = Client() - client.force_login(owner) - response = client.get(reverse("activity_detail", args=[activity.pk])) - html = response.content.decode() - assert reverse("schedule_update", args=[schedule.pk]) in html diff --git a/tests/unit_tests/activity/test_views_runs.py b/tests/unit_tests/activity/test_views_runs.py deleted file mode 100644 index f411ba118..000000000 --- a/tests/unit_tests/activity/test_views_runs.py +++ /dev/null @@ -1,212 +0,0 @@ -import json -import uuid -from unittest import mock - -from django.core.exceptions import PermissionDenied, SuspiciousOperation -from django.http import Http404 -from django.urls import reverse - -import pytest -from activity.models import Activity, ActivityStatus, TriggerType -from django_tasks_db.models import DBTaskResult, get_date_max - -from accounts.models import Role -from accounts.models import User as AccountUser - - -def _make_user(username: str) -> AccountUser: - return AccountUser.objects.create_user( - username=username, - email=f"{username}@test.com", - password="testpass123", # noqa: S106 - role=Role.MEMBER, - ) - - -def _make_task_result(task_id: uuid.UUID) -> mock.Mock: - DBTaskResult.objects.create( - id=task_id, - status="READY", - task_path="jobs.tasks.run_job_task", - args_kwargs={"args": [], "kwargs": {}}, - queue_name="default", - backend_name="default", - run_after=get_date_max(), - return_value={}, - ) - return mock.Mock(id=task_id) - - -async def _amake_task_result(task_id: uuid.UUID) -> mock.Mock: - await DBTaskResult.objects.acreate( - id=task_id, - status="READY", - task_path="jobs.tasks.run_job_task", - args_kwargs={"args": [], "kwargs": {}}, - queue_name="default", - backend_name="default", - run_after=get_date_max(), - return_value={}, - ) - return mock.Mock(id=task_id) - - -def _single_repo_post_data(repo_id="acme/repo", ref=""): - return {"prompt": "go", "repos": json.dumps([{"repo_id": repo_id, "ref": ref}]), "notify_on": "never"} - - -@pytest.mark.django_db -def test_get_provides_sandbox_envs_in_context(member_client): - resp = member_client.get(reverse("runs:agent_run_new")) - assert resp.status_code == 200 - assert "sandbox_envs" in resp.context - envs = list(resp.context["sandbox_envs"]) - # GLOBAL Default is seeded by migration — always present. - assert any(e.scope == "global" and e.is_default for e in envs) - assert resp.context["selected_sandbox_env_id"] == "" - - -@pytest.mark.django_db -def test_get_blank_renders_empty_form(member_client): - resp = member_client.get(reverse("runs:agent_run_new")) - assert resp.status_code == 200 - assert resp.context["source_activity"] is None - - -@pytest.mark.django_db -def test_get_retry_prefills_fields(member_client, member_user): - source = Activity.objects.create( - user=member_user, - status=ActivityStatus.SUCCESSFUL, - trigger_type=TriggerType.API_JOB, - repo_id="a/b", - ref="develop", - prompt="P", - agent_model="openrouter:anthropic/claude-opus-4.6", - agent_thinking_level="high", - ) - resp = member_client.get(reverse("runs:agent_run_new") + f"?from={source.pk}") - assert resp.status_code == 200 - assert resp.context["form"].initial == { - "notify_on": member_user.notify_on_jobs, - "prompt": "P", - "repos": [{"repo_id": "a/b", "ref": "develop"}], - "agent_model": "openrouter:anthropic/claude-opus-4.6", - "agent_thinking_level": "high", - } - assert resp.context["source_activity"].pk == source.pk - - -@pytest.mark.django_db -@pytest.mark.parametrize("status", [ActivityStatus.READY, ActivityStatus.RUNNING]) -def test_get_retry_non_terminal_returns_404(member_client, member_user, status): - source = Activity.objects.create(user=member_user, status=status, trigger_type=TriggerType.API_JOB, repo_id="a/b") - resp = member_client.get(reverse("runs:agent_run_new") + f"?from={source.pk}") - assert resp.status_code == 404 - - -@pytest.mark.django_db -@pytest.mark.parametrize("trigger", [TriggerType.ISSUE_WEBHOOK, TriggerType.MR_WEBHOOK]) -def test_get_retry_webhook_returns_404(member_client, member_user, trigger): - source = Activity.objects.create( - user=member_user, status=ActivityStatus.SUCCESSFUL, trigger_type=trigger, repo_id="a/b" - ) - resp = member_client.get(reverse("runs:agent_run_new") + f"?from={source.pk}") - assert resp.status_code == 404 - - -@pytest.mark.django_db -def test_get_retry_other_users_activity_returns_404(member_client): - owner = _make_user("owner2") - source = Activity.objects.create( - user=owner, status=ActivityStatus.SUCCESSFUL, trigger_type=TriggerType.API_JOB, repo_id="a/b" - ) - resp = member_client.get(reverse("runs:agent_run_new") + f"?from={source.pk}") - assert resp.status_code == 404 - - -@pytest.mark.django_db(transaction=True) -def test_post_single_repo_redirects_to_activity_detail(member_client): - task_id = uuid.uuid4() - fake_task = _make_task_result(task_id) - with mock.patch("activity.services.run_job_task") as m_task: - m_task.aenqueue = mock.AsyncMock(return_value=fake_task) - resp = member_client.post(reverse("runs:agent_run_new"), data=_single_repo_post_data()) - assert resp.status_code == 302 - created = Activity.objects.get(task_result_id=task_id) - assert resp["Location"] == reverse("activity_detail", args=[created.pk]) - assert created.trigger_type == TriggerType.UI_JOB - assert created.use_max is False - assert created.agent_model == "" - assert created.agent_thinking_level == "" - assert created.batch_id is not None - - -@pytest.mark.django_db(transaction=True) -def test_post_multi_repo_redirects_to_filtered_activity_list(member_client): - async def _aenqueue(**kwargs): - return await _amake_task_result(uuid.uuid4()) - - with mock.patch("activity.services.run_job_task") as m_task: - m_task.aenqueue = _aenqueue - resp = member_client.post( - reverse("runs:agent_run_new"), - data={ - "prompt": "go", - "repos": json.dumps([{"repo_id": "a/b", "ref": ""}, {"repo_id": "c/d", "ref": "main"}]), - "notify_on": "never", - }, - ) - assert resp.status_code == 302 - assert "batch=" in resp["Location"] - activities = list(Activity.objects.all()) - assert len(activities) == 2 - assert len({a.batch_id for a in activities}) == 1 - - -@pytest.mark.django_db -def test_get_retry_invalid_uuid_returns_404(member_client): - resp = member_client.get(reverse("runs:agent_run_new") + "?from=not-a-uuid") - assert resp.status_code == 404 - - -@pytest.mark.django_db -def test_post_submit_failure_rerenders_with_error(member_client, monkeypatch, caplog): - def _boom(**kwargs): - raise RuntimeError("broker is down") - - monkeypatch.setattr("activity.views.submit_batch_runs", _boom) - with caplog.at_level("ERROR", logger="daiv.activity"): - resp = member_client.post(reverse("runs:agent_run_new"), data=_single_repo_post_data()) - assert resp.status_code == 200 - assert "Failed to submit" in resp.content.decode() - - # Operators need the traceback AND enough context (repos list) to triage the - # failure without the user's prompt text; assert both are preserved on the log record. - [record] = [r for r in caplog.records if r.name == "daiv.activity" and r.levelname == "ERROR"] - assert record.exc_info is not None - assert record.repos == [{"repo_id": "acme/repo", "ref": ""}] - - -@pytest.mark.django_db -@pytest.mark.parametrize("exc", [Http404, PermissionDenied, SuspiciousOperation]) -def test_post_django_control_flow_exceptions_propagate(member_client, monkeypatch, exc): - def _boom(**kwargs): - raise exc("boom") - - monkeypatch.setattr("activity.views.submit_batch_runs", _boom) - resp = member_client.post(reverse("runs:agent_run_new"), data=_single_repo_post_data()) - # Django middleware renders these as 404/403/400 — not swallowed as "submit failed". - assert resp.status_code in {400, 403, 404} - - -@pytest.mark.django_db -def test_post_invalid_agent_model_renders_field_error(member_client): - """A malformed ``agent_model`` posted via raw form data must surface as a visible - error on the page, not silently re-render with no message. - """ - data = {**_single_repo_post_data(), "agent_model": "bogus:nope"} - resp = member_client.post(reverse("runs:agent_run_new"), data=data) - assert resp.status_code == 200 - body = resp.content.decode() - assert "Unknown provider prefix" in body diff --git a/tests/unit_tests/automation/titling/test_tasks.py b/tests/unit_tests/automation/titling/test_tasks.py index 243ca23ec..90bb6cc14 100644 --- a/tests/unit_tests/automation/titling/test_tasks.py +++ b/tests/unit_tests/automation/titling/test_tasks.py @@ -4,7 +4,6 @@ from unittest.mock import MagicMock, patch import pytest -from activity.models import Activity, TriggerType from sessions.models import Run, Session, SessionOrigin from automation.titling import tasks as titling_tasks @@ -53,13 +52,23 @@ def _invoke(messages): @pytest.mark.django_db class TestGenerateTitleTask: - def _make_activity(self, *, title: str = "") -> Activity: - return Activity.objects.create(trigger_type=TriggerType.API_JOB, repo_id="group/repo", title=title) + def _make_session(self, *, title: str = "") -> Session: + return Session.objects.create( + thread_id=str(uuid.uuid4()), origin=SessionOrigin.API_JOB, repo_id="group/repo", title=title + ) + + def _make_run(self, *, title: str = "") -> Run: + session = Session.objects.create( + thread_id=str(uuid.uuid4()), origin=SessionOrigin.API_JOB, repo_id="group/repo" + ) + return Run.objects.create( + session=session, trigger_type=SessionOrigin.API_JOB, repo_id="group/repo", title=title + ) def test_returns_silently_when_entity_missing(self): with patch.object(titling_tasks.BaseAgent, "get_model") as get_model: generate_title_task.func( - entity_type="activity", pk="00000000-0000-0000-0000-000000000000", prompt="any", repo_id="x/y" + entity_type="run", pk="00000000-0000-0000-0000-000000000000", prompt="any", repo_id="x/y" ) get_model.assert_not_called() @@ -68,27 +77,25 @@ def test_overwrites_existing_heuristic_title(self): title must overwrite them. (No user-facing edit endpoint exists, so no need to protect manual edits.) """ - activity = self._make_activity(title="Heuristic placeholder") + run = self._make_run(title="Heuristic placeholder") with patch.object(titling_tasks.BaseAgent, "get_model", return_value=_fake_chain(title="LLM generated")): - generate_title_task.func(entity_type="activity", pk=str(activity.pk), prompt="any", repo_id="group/repo") - activity.refresh_from_db() - assert activity.title == "LLM generated" + generate_title_task.func(entity_type="run", pk=str(run.pk), prompt="any", repo_id="group/repo") + run.refresh_from_db() + assert run.title == "LLM generated" def test_returns_when_model_not_configured(self): - activity = self._make_activity() + run = self._make_run() with patch.object(titling_tasks.BaseAgent, "get_model", side_effect=RuntimeError("no key")): - generate_title_task.func(entity_type="activity", pk=str(activity.pk), prompt="any", repo_id="group/repo") - activity.refresh_from_db() - assert activity.title == "" + generate_title_task.func(entity_type="run", pk=str(run.pk), prompt="any", repo_id="group/repo") + run.refresh_from_db() + assert run.title == "" def test_writes_generated_title(self): - activity = self._make_activity() + run = self._make_run() with patch.object(titling_tasks.BaseAgent, "get_model", return_value=_fake_chain(title="Add login feature")): - generate_title_task.func( - entity_type="activity", pk=str(activity.pk), prompt="add login", repo_id="group/repo" - ) - activity.refresh_from_db() - assert activity.title == "Add login feature" + generate_title_task.func(entity_type="run", pk=str(run.pk), prompt="add login", repo_id="group/repo") + run.refresh_from_db() + assert run.title == "Add login feature" def test_writes_generated_title_for_session_entity(self): session = Session.objects.create(thread_id=str(uuid.uuid4()), origin=SessionOrigin.CHAT, repo_id="group/repo") @@ -110,15 +117,11 @@ def test_writes_generated_title_for_run_entity(self): assert run.title == "Run title" def test_user_text_includes_branch_when_informative(self): - activity = self._make_activity() + run = self._make_run() capture: dict = {} with patch.object(titling_tasks.BaseAgent, "get_model", return_value=_fake_chain(capture=capture)): generate_title_task.func( - entity_type="activity", - pk=str(activity.pk), - prompt="add login", - repo_id="group/repo", - ref="feat/copilotkit-chat", + entity_type="run", pk=str(run.pk), prompt="add login", repo_id="group/repo", ref="feat/copilotkit-chat" ) human_text = capture["messages"][-1].content assert "Repository: group/repo" in human_text @@ -126,23 +129,21 @@ def test_user_text_includes_branch_when_informative(self): assert "Task: add login" in human_text def test_user_text_omits_branch_for_generic_ref(self): - activity = self._make_activity() + run = self._make_run() capture: dict = {} with patch.object(titling_tasks.BaseAgent, "get_model", return_value=_fake_chain(capture=capture)): generate_title_task.func( - entity_type="activity", pk=str(activity.pk), prompt="add login", repo_id="group/repo", ref="main" + entity_type="run", pk=str(run.pk), prompt="add login", repo_id="group/repo", ref="main" ) human_text = capture["messages"][-1].content assert "Branch:" not in human_text def test_prompt_truncated_to_500_chars(self): - activity = self._make_activity() + run = self._make_run() capture: dict = {} long_prompt = "x" * 1000 with patch.object(titling_tasks.BaseAgent, "get_model", return_value=_fake_chain(capture=capture)): - generate_title_task.func( - entity_type="activity", pk=str(activity.pk), prompt=long_prompt, repo_id="group/repo" - ) + generate_title_task.func(entity_type="run", pk=str(run.pk), prompt=long_prompt, repo_id="group/repo") human_text = capture["messages"][-1].content assert human_text.endswith("x" * 500) assert "x" * 501 not in human_text diff --git a/tests/unit_tests/chat/test_composer_agent_picker.py b/tests/unit_tests/chat/test_composer_agent_picker.py deleted file mode 100644 index 04d4a392a..000000000 --- a/tests/unit_tests/chat/test_composer_agent_picker.py +++ /dev/null @@ -1,245 +0,0 @@ -"""Tests for the agent model + thinking-effort picker on the chat composer. - -The picker mirrors the env picker's two-mode shape: - -- on the empty-state hero (no thread yet) it renders as an interactive Alpine - component with hidden inputs the JS reads at submit time; -- on an existing thread (any thread row, since ``ChatThread`` rows are created - on the first turn) it renders locked because the backend pins ``agent_model`` - / ``agent_thinking_level`` on first sight and ignores client values afterwards. -""" - -from __future__ import annotations - -import json -from unittest.mock import AsyncMock, MagicMock, patch - -from django.test import override_settings -from django.urls import reverse - -import pytest - -from chat.models import ChatThread -from core.models import Provider, ProviderType - - -@pytest.fixture -def enabled_provider(db): - """Ensure at least one enabled provider so the picker renders its dropdown.""" - Provider.objects.filter(slug="openrouter").delete() - Provider.objects.create( - slug="openrouter", - display_name="OpenRouter", - provider_type=ProviderType.OPENROUTER, - api_key="sk-test", - is_enabled=True, - ) - - -@pytest.mark.django_db -def test_hero_renders_interactive_agent_picker(member_client, enabled_provider): - """The empty ``chat_new`` page exposes the picker as an editable Alpine root.""" - resp = member_client.get(reverse("chat_new")) - assert resp.status_code == 200 - body = resp.content.decode() - - # Alpine root marker — same shape as env picker's ``envPicker(`` smoke check. - assert "agentPicker(" in body - # Picker context vars made it to the template. - assert "agent_picker_providers" not in body # consumed, not literal - providers = json.loads(resp.context["agent_picker_providers"]) - assert any(p["slug"] == "openrouter" for p in providers) - # The hero's editable picker mounts its hidden inputs. - assert 'name="agent_model"' in body - assert 'name="agent_thinking_level"' in body - - -@pytest.mark.django_db -def test_existing_thread_renders_locked_agent_pill(member_client, member_user, enabled_provider): - """Any persisted ``ChatThread`` makes the picker render locked, mirroring env.""" - thread = ChatThread.objects.create( - thread_id="t-agent", - user=member_user, - repo_id="a/b", - ref="main", - agent_model="openrouter:anthropic/claude-haiku-4.5", - agent_thinking_level="medium", - ) - tup = MagicMock(checkpoint={"channel_values": {"messages": []}}) - with ( - patch("sessions.hydration.open_checkpointer") as cp_ctx, - patch("chat.views.aget_existing_mr_payload", AsyncMock(return_value=None)), - ): - saver = MagicMock() - saver.aget_tuple = AsyncMock(return_value=tup) - cp_ctx.return_value.__aenter__ = AsyncMock(return_value=saver) - cp_ctx.return_value.__aexit__ = AsyncMock(return_value=None) - resp = member_client.get(reverse("chat_detail", kwargs={"thread_id": thread.thread_id})) - - assert resp.status_code == 200 - body = resp.content.decode() - # The locked pill renders the stripped display form (provider prefix and ``org/`` - # stripped) so the chip stays compact — same normalisation the editable picker - # applies to pinned models via ``pillLabel``. - assert "claude-haiku-4.5" in body - # The locked-mode partial emits an ``aria-disabled`` pill (no Alpine root). The hero - # picker still renders unconditionally inside its ``