From a663099c31f1287fc3c26a23751550da72c4d2a1 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Wed, 29 Jul 2026 12:21:05 -0400 Subject: [PATCH 1/6] fix(tasks): verify sandbox billing provenance Derive PostHog Code provenance from authenticated run actions and route signal report implementations through an idempotent server-owned creation path. Generated-By: PostHog Code Task-Id: e55f8ea4-cc41-4e76-ad48-896b396e95ce --- posthog/temporal/oauth.py | 12 ++ products/tasks/backend/facade/api.py | 73 ++++++++- .../backend/logic/services/sandbox_usage.py | 9 +- .../services/tests/test_sandbox_usage.py | 28 +++- .../0076_add_code_task_provenance.py | 28 ++++ .../backend/migrations/max_migration.txt | 2 +- products/tasks/backend/models.py | 14 +- .../tasks/backend/presentation/serializers.py | 31 ++++ .../tasks/backend/presentation/views/api.py | 65 +++++++- products/tasks/backend/tests/test_api.py | 141 +++++++++++++----- .../tasks/frontend/generated/api.schemas.ts | 125 ++++++++++++++++ products/tasks/frontend/generated/api.ts | 21 +++ products/tasks/frontend/generated/api.zod.ts | 124 +++++++++++++++ 13 files changed, 615 insertions(+), 58 deletions(-) create mode 100644 products/tasks/backend/migrations/0076_add_code_task_provenance.py diff --git a/posthog/temporal/oauth.py b/posthog/temporal/oauth.py index c92cd4650793..43076f087894 100644 --- a/posthog/temporal/oauth.py +++ b/posthog/temporal/oauth.py @@ -12,10 +12,22 @@ ARRAY_APP_CLIENT_ID_US = "HCWoE0aRFMYxIxFNTTwkOORn5LBjOt2GVDzwSw5W" ARRAY_APP_CLIENT_ID_EU = "AIvijgMS0dxKEmr5z6odvRd8Pkh5vts3nPTzgzU9" ARRAY_APP_CLIENT_ID_DEV = "DC5uRLVbGI02YQ82grxgnK6Qn12SXWpCqdPb60oZ" +POSTHOG_CODE_MOBILE_CLIENT_ID_US = "a5TY7w9IjFYfes6dkPgZe6envclWw3bm2UD8ZTlm" +POSTHOG_CODE_MOBILE_CLIENT_ID_EU = "1A7vO138Fh5sYmJislicN4F5HnttI6urmFttxPDU" POSTHOG_AI_APP_CLIENT_ID_US = "N6UgOECSl98ag1xajxPphGApQXYEVvJIwzCXotKu" POSTHOG_AI_APP_CLIENT_ID_EU = "0Lizwa3mFSlBuEEQ8V8FMJlskUXpDuSmoEdhzxyi" POSTHOG_AI_APP_CLIENT_ID_DEV = "DD2ZLG6a2YEUtpPANSzSiIBPuUryYmbndLnKKUy1" +POSTHOG_CODE_OAUTH_CLIENT_IDS = frozenset( + { + ARRAY_APP_CLIENT_ID_DEV, + ARRAY_APP_CLIENT_ID_EU, + ARRAY_APP_CLIENT_ID_US, + POSTHOG_CODE_MOBILE_CLIENT_ID_EU, + POSTHOG_CODE_MOBILE_CLIENT_ID_US, + } +) + # Every OAuth application sandbox agent tokens are minted under. Tokens for these apps # are only ever created server-side (never via the consent flow or personal API keys), # so a request bearing one provably originates from a sandbox run. diff --git a/products/tasks/backend/facade/api.py b/products/tasks/backend/facade/api.py index 7d20056f36cb..f1d27bce848b 100644 --- a/products/tasks/backend/facade/api.py +++ b/products/tasks/backend/facade/api.py @@ -2932,6 +2932,7 @@ def signal_task_run_user_message( message_id: str | None = None, actor_slack_user_id: str | None = None, steer: bool = False, + created_via_code: bool = False, ) -> bool | None: """Queue a user_message follow-up signal on the run's workflow. @@ -2965,11 +2966,11 @@ def signal_task_run_user_message( logger.warning("Follow-up signal target workflow gone for task run %s", run.id) return False raise - record_task_run_user_activity(run.id, team_id) + record_task_run_user_activity(run.id, team_id, created_via_code=created_via_code) return True -def record_task_run_user_activity(run_id: str | UUID, team_id: int) -> None: +def record_task_run_user_activity(run_id: str | UUID, team_id: int, *, created_via_code: bool = False) -> None: """Stamp a user message against the run's open sandbox usage sessions. Best-effort (the ledger swallows its own failures): records last-activity on @@ -2980,7 +2981,7 @@ def record_task_run_user_activity(run_id: str | UUID, team_id: int) -> None: record_task_run_user_activity as _record_user_activity, ) - _record_user_activity(run_id, team_id) + _record_user_activity(run_id, team_id, created_via_code=created_via_code) def get_task_run_sandbox_connection( @@ -3235,7 +3236,12 @@ def _github_credential_source_extra_state(pr_authorship_mode, github_user_token: def bootstrap_task_run( - task_id: str | UUID, team_id: int, user_id: int | None, *, validated_data: dict + task_id: str | UUID, + team_id: int, + user_id: int | None, + *, + validated_data: dict, + created_via_code: bool = False, ) -> contracts.TaskRunCreateResult | None: """Create a task run (without starting execution) from validated bootstrap data. @@ -3369,7 +3375,13 @@ def bootstrap_task_run( logger.info( "Creating task run for task %s with mode=%s, branch=%s, environment=%s", task.id, mode, branch, environment ) - run = task.create_run(environment=environment, mode=mode, branch=branch, extra_state=extra_state) + run = task.create_run( + environment=environment, + mode=mode, + branch=branch, + extra_state=extra_state, + created_via_code=created_via_code, + ) if imported_mcp_servers or relayed_mcp_servers: update_fields = ["updated_at"] @@ -3463,7 +3475,13 @@ def check_task_run_startable(run_id: str | UUID, task_id: str | UUID, team_id: i def start_task_run( - run_id: str | UUID, task_id: str | UUID, team_id: int, user_id: int | None, *, validated_data: dict + run_id: str | UUID, + task_id: str | UUID, + team_id: int, + user_id: int | None, + *, + validated_data: dict, + created_via_code: bool = False, ) -> tuple[str, UUID | None]: """Apply run-scoped attachments and trigger the cloud workflow for a startable run. @@ -3480,6 +3498,8 @@ def start_task_run( if run is None: return "not_found", None task = run.task + run.created_via_code = created_via_code + run.save(update_fields=["created_via_code", "updated_at"]) pending_user_message = validated_data.get("pending_user_message") pending_user_artifact_ids = validated_data.get("pending_user_artifact_ids") or [] @@ -3525,7 +3545,12 @@ def start_task_run( def resume_task_run_in_cloud( - run_id: str | UUID, task_id: str | UUID, team_id: int, user_id: int | None + run_id: str | UUID, + task_id: str | UUID, + team_id: int, + user_id: int | None, + *, + created_via_code: bool = False, ) -> tuple[str, contracts.TaskRunDetailDTO | None, str | None]: """Resume a run in a cloud sandbox, terminating any prior workflow. @@ -3595,6 +3620,8 @@ def resume_task_run_in_cloud( prior_environment = run.environment prior_completed_at = run.completed_at prior_state = dict(run.state or {}) + prior_created_via_code = run.created_via_code + run.created_via_code = created_via_code run.prepare_for_cloud_handoff() logger.info("Resuming task run in cloud", extra={"task_run_id": str(run.id), "task_id": str(run.task_id)}) @@ -3614,8 +3641,19 @@ def resume_task_run_in_cloud( run.environment = prior_environment run.completed_at = prior_completed_at run.state = prior_state + run.created_via_code = prior_created_via_code run.error_message = "Failed to start cloud workflow" - run.save(update_fields=["status", "environment", "completed_at", "state", "error_message", "updated_at"]) + run.save( + update_fields=[ + "status", + "environment", + "completed_at", + "state", + "created_via_code", + "error_message", + "updated_at", + ] + ) run.publish_stream_state_event() return "workflow_failed", None, None @@ -4097,6 +4135,25 @@ def create_task(team_id: int, user_id: int | None, *, validated_data: dict) -> c return _task_detail_to_dto(_task_detail_queryset().get(pk=task.pk)) +def create_signal_report_task( + team_id: int, user_id: int | None, *, validated_data: dict +) -> tuple[contracts.TaskDetailDTO, bool]: + from products.signals.backend.models import SignalReport, SignalReportTask # noqa: PLC0415 + from products.signals.backend.task_run_artefacts import TASK_RUN_TYPE_IMPLEMENTATION # noqa: PLC0415 + + report = validated_data["signal_report"] + with transaction.atomic(): + SignalReport.objects.select_for_update().get(id=report.id, team_id=team_id) + existing = SignalReportTask.objects.filter( + team_id=team_id, + report_id=report.id, + relationship=TASK_RUN_TYPE_IMPLEMENTATION, + ).first() + if existing is not None: + return _task_detail_to_dto(_task_detail_queryset().get(pk=existing.task_id)), False + return create_task(team_id, user_id, validated_data=validated_data), True + + def set_task_title(task_id: str | UUID, team_id: int, title: str) -> bool: """Set a task's title, team-scoped. For automated relabels — e.g. backfilling a Signals research task with ``"Research: "`` once research produces the title. Leaves diff --git a/products/tasks/backend/logic/services/sandbox_usage.py b/products/tasks/backend/logic/services/sandbox_usage.py index ffb8516e6aa0..b9d848ed4d29 100644 --- a/products/tasks/backend/logic/services/sandbox_usage.py +++ b/products/tasks/backend/logic/services/sandbox_usage.py @@ -59,7 +59,7 @@ def open_sandbox_session( run = ( TaskRun.objects.select_for_update(of=("self",)) .select_related("task") - .only("id", "team_id", "state", "task__origin_product") + .only("id", "team_id", "state", "created_via_code", "task__origin_product") .get(id=run_id) ) state = run.state or {} @@ -68,6 +68,7 @@ def open_sandbox_session( "team_id": run.team_id, "task_run_id": run.id, "origin_product": run.task.origin_product, + "created_via_code": run.created_via_code, "prewarmed": bool(state.get("prewarmed")), "vm_runtime": config.is_vm, "cpu_cores": config.cpu_cores, @@ -110,7 +111,7 @@ def close_sandbox_session(sandbox_id: str, *, reason: str) -> None: @_best_effort -def record_task_run_user_activity(run_id: str | UUID, team_id: int) -> None: +def record_task_run_user_activity(run_id: str | UUID, team_id: int, *, created_via_code: bool = False) -> None: """Stamp a user message against the run's open sandbox sessions. Sets ``last_user_activity_at`` on every message and ``user_attributed_at`` @@ -120,7 +121,11 @@ def record_task_run_user_activity(run_id: str | UUID, team_id: int) -> None: """ now = timezone.now() run_uuid = run_id if isinstance(run_id, UUID) else UUID(run_id) + if created_via_code: + TaskRun.objects.filter(id=run_uuid, team_id=team_id).update(created_via_code=True) open_sessions = SandboxSession.objects.for_team(team_id).filter(task_run_id=run_uuid, ended_at__isnull=True) + if created_via_code: + open_sessions.update(created_via_code=True) open_sessions.update(last_user_activity_at=now) open_sessions.filter(user_attributed_at__isnull=True).update(user_attributed_at=now) diff --git a/products/tasks/backend/logic/services/tests/test_sandbox_usage.py b/products/tasks/backend/logic/services/tests/test_sandbox_usage.py index 814630ebe9a7..7ac684d4381a 100644 --- a/products/tasks/backend/logic/services/tests/test_sandbox_usage.py +++ b/products/tasks/backend/logic/services/tests/test_sandbox_usage.py @@ -24,16 +24,24 @@ def _config(**overrides) -> SandboxConfig: class SandboxUsageBase(APIBaseTest): - def _run(self, *, state: dict | None = None) -> TaskRun: + def _run(self, *, state: dict | None = None, created_via_code: bool | None = None) -> TaskRun: task = Task.objects.create( - team=self.team, title="t", description="", origin_product=Task.OriginProduct.USER_CREATED + team=self.team, + title="t", + description="", + origin_product=Task.OriginProduct.USER_CREATED, + ) + return TaskRun.objects.create( + task=task, + team=self.team, + state=state or {}, + created_via_code=created_via_code, ) - return TaskRun.objects.create(task=task, team=self.team, state=state or {}) class TestSandboxSessionWrites(SandboxUsageBase): def test_open_attributes_cold_runs_immediately(self): - run = self._run() + run = self._run(created_via_code=True) open_sandbox_session(run_id=run.id, sandbox_id="sb-cold", config=_config()) @@ -41,6 +49,7 @@ def test_open_attributes_cold_runs_immediately(self): assert session.team_id == self.team.id assert session.task_run_id == run.id assert session.origin_product == Task.OriginProduct.USER_CREATED + assert session.created_via_code is True assert session.user_attributed_at is not None assert session.prewarmed is False assert session.vm_runtime is False @@ -174,6 +183,17 @@ def test_facade_signal_attributes_claimed_warm_run(self): assert SandboxSession.objects.unscoped().get(sandbox_id="sb-claim").user_attributed_at is not None + def test_code_claim_updates_run_and_open_session_provenance(self): + run = self._run(state={"prewarmed": True, "await_user_message": True}) + open_sandbox_session(run_id=run.id, sandbox_id="sb-code-claim", config=_config()) + + record_task_run_user_activity(run.id, self.team.id, created_via_code=True) + + run.refresh_from_db() + session = SandboxSession.objects.unscoped().get(sandbox_id="sb-code-claim") + assert run.created_via_code is True + assert session.created_via_code is True + class TestSandboxUsageAggregation(SandboxUsageBase): BEGIN = datetime(2026, 1, 2, tzinfo=UTC) diff --git a/products/tasks/backend/migrations/0076_add_code_task_provenance.py b/products/tasks/backend/migrations/0076_add_code_task_provenance.py new file mode 100644 index 000000000000..b0cc9a501e4b --- /dev/null +++ b/products/tasks/backend/migrations/0076_add_code_task_provenance.py @@ -0,0 +1,28 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0075_task_pin"), + ] + + operations = [ + migrations.AddField( + model_name="taskrun", + name="created_via_code", + field=models.BooleanField( + editable=False, + help_text="Whether the current cloud execution was initiated by a PostHog Code OAuth application", + null=True, + ), + ), + migrations.AddField( + model_name="sandboxsession", + name="created_via_code", + field=models.BooleanField( + editable=False, + help_text="PostHog Code OAuth provenance at provision time", + null=True, + ), + ), + ] diff --git a/products/tasks/backend/migrations/max_migration.txt b/products/tasks/backend/migrations/max_migration.txt index 718239371425..979df7521963 100644 --- a/products/tasks/backend/migrations/max_migration.txt +++ b/products/tasks/backend/migrations/max_migration.txt @@ -1 +1 @@ -0075_task_pin +0076_add_code_task_provenance diff --git a/products/tasks/backend/models.py b/products/tasks/backend/models.py index 0d3aa5d3737e..a75e11fafc2a 100644 --- a/products/tasks/backend/models.py +++ b/products/tasks/backend/models.py @@ -157,7 +157,6 @@ class OriginProduct(models.TextChoices): title_manually_set = models.BooleanField(default=False) description = models.TextField() origin_product = models.CharField(max_length=20, choices=OriginProduct) - # Repository configuration github_integration = models.ForeignKey( "posthog.Integration", @@ -386,6 +385,7 @@ def create_run( mode: str = "background", extra_state: dict | None = None, branch: str | None = None, + created_via_code: bool | None = None, ) -> "TaskRun": state: dict = {} if self.runtime == Task.Runtime.PI else {"mode": mode} if extra_state: @@ -408,6 +408,7 @@ def create_run( **({"environment": environment} if environment else {}), state=state, branch=branch, + created_via_code=created_via_code, ) task_run.publish_stream_state_event() observe_task_run_created(task_run) @@ -1533,6 +1534,11 @@ class Environment(models.TextChoices): blank=True, related_name="active_runs", ) + created_via_code = models.BooleanField( + null=True, + editable=False, + help_text="Whether the current cloud execution was initiated by a PostHog Code OAuth application", + ) branch = models.CharField(max_length=255, blank=True, null=True, help_text="Branch name for the run") @@ -1697,6 +1703,7 @@ def prepare_for_cloud_handoff(self) -> None: "completed_at", "error_message", "state", + "created_via_code", "updated_at", ] ) @@ -2281,6 +2288,11 @@ class EndedReason(models.TextChoices): blank=True, help_text="Task origin at provision time, denormalized for per-origin aggregation", ) + created_via_code = models.BooleanField( + null=True, + editable=False, + help_text="PostHog Code OAuth provenance at provision time", + ) prewarmed = models.BooleanField(default=False, help_text="Sandbox was provisioned ahead of any user demand") vm_runtime = models.BooleanField( default=False, help_text="Modal VM runtime rather than gVisor (billed differently)" diff --git a/products/tasks/backend/presentation/serializers.py b/products/tasks/backend/presentation/serializers.py index 351efefa128e..e941a6c17993 100644 --- a/products/tasks/backend/presentation/serializers.py +++ b/products/tasks/backend/presentation/serializers.py @@ -596,6 +596,10 @@ def validate_github_user_integration(self, value): def validate_origin_product(self, value): """Reject internal-only origins that are set by server-side flows, never by API callers.""" + if value == tasks_facade.TaskOriginProduct.SIGNAL_REPORT: + raise serializers.ValidationError( + "Update PostHog Code to start tasks from Inbox. Signal report tasks can no longer be created through the generic tasks API." + ) if value == tasks_facade.TaskOriginProduct.IMAGE_BUILDER: raise serializers.ValidationError("origin_product 'image_builder' is reserved for image-builder sessions") if value == tasks_facade.TaskOriginProduct.EXPERIMENTS: @@ -641,6 +645,12 @@ def validate_signal_report_task_relationship(self, value: str) -> str: return normalized def validate(self, attrs: dict) -> dict: + if self.context.get("is_update"): + immutable_fields = {"origin_product", "signal_report"}.intersection(self.initial_data) + if immutable_fields: + raise serializers.ValidationError( + dict.fromkeys(immutable_fields, "This field cannot be changed after task creation.") + ) if "runtime" in self.initial_data and "runtime" not in self.fields: raise serializers.ValidationError({"runtime": "Runtime cannot be changed after task creation."}) @@ -686,6 +696,27 @@ class TaskCreateSerializer(TaskWriteSerializer): ) +class SignalReportTaskCreateSerializer(TaskCreateSerializer): + origin_product = serializers.HiddenField( # type: ignore[assignment] + default=tasks_facade.TaskOriginProduct.SIGNAL_REPORT + ) + signal_report_task_relationship = serializers.ChoiceField( # type: ignore[assignment] + choices=["implementation"], + default="implementation", + write_only=True, + help_text="Signal report relationship created by this endpoint.", + ) + + def validate_origin_product(self, value): + return value + + def validate(self, attrs: dict) -> dict: + attrs = super().validate(attrs) + if not attrs.get("signal_report"): + raise serializers.ValidationError({"signal_report": "This field is required."}) + return attrs + + class TaskRunSetOutputRequestSerializer(serializers.Serializer): output = serializers.JSONField( help_text="Output data from the run. Validated against the task's json_schema if one is set." diff --git a/products/tasks/backend/presentation/views/api.py b/products/tasks/backend/presentation/views/api.py index a2c7be89ab49..e4c959e17f58 100644 --- a/products/tasks/backend/presentation/views/api.py +++ b/products/tasks/backend/presentation/views/api.py @@ -33,6 +33,7 @@ from posthog.permissions import APIScopePermission from posthog.rate_limit import CodeInviteThrottle from posthog.renderers import ServerSentEventRenderer +from posthog.temporal.oauth import POSTHOG_CODE_OAUTH_CLIENT_IDS from products.tasks.backend.facade import ( access as tasks_access, @@ -70,6 +71,7 @@ SandboxEnvironmentListSerializer, SandboxEnvironmentSerializer, SandboxEnvironmentWriteSerializer, + SignalReportTaskCreateSerializer, SlackThreadContextQuerySerializer, SlackThreadContextResponseSerializer, StreamReadTokenResponseSerializer, @@ -156,6 +158,16 @@ def _pi_cloud_runtime_disabled_response() -> Response: ) +def _is_posthog_code_request(request) -> bool: + authenticator = request.successful_authenticator + if not isinstance(authenticator, OAuthAccessTokenAuthentication): + return False + if "internal_run:read" in (authenticator.access_token.scope or "").split(): + return False + application = authenticator.access_token.application + return application is not None and application.client_id in POSTHOG_CODE_OAUTH_CLIENT_IDS + + TASKS_PREWARM_SANDBOX_FLAG = "tasks-prewarm-sandbox" TASK_RUN_STREAM_KEEPALIVE_INTERVAL_SECONDS = 20.0 @@ -272,7 +284,7 @@ def _write_serializer( serializer = serializer_class( data=data, partial=partial, - context={"team": self.team, "team_id": self.team.id, "request": self.request}, + context={"team": self.team, "team_id": self.team.id, "request": self.request, "is_update": partial}, ) serializer.is_valid(raise_exception=True) return serializer @@ -318,9 +330,36 @@ def retrieve(self, request, pk=None, **kwargs): @extend_schema(request=TaskCreateSerializer, responses={201: TaskSerializer}) def create(self, request, **kwargs): serializer = self._write_serializer(request.data, serializer_class=TaskCreateSerializer) - task = tasks_facade.create_task(self.team_id, self._user_id(), validated_data=dict(serializer.validated_data)) + validated_data = dict(serializer.validated_data) + created_via_code = _is_posthog_code_request(request) + if ( + created_via_code + and validated_data.get("origin_product", tasks_facade.TaskOriginProduct.USER_CREATED) + != tasks_facade.TaskOriginProduct.USER_CREATED + ): + raise ValidationError( + {"origin_product": "PostHog Code can only use the generic tasks API for user-created tasks."} + ) + task = tasks_facade.create_task(self.team_id, self._user_id(), validated_data=validated_data) return Response(TaskSerializer(task).data, status=status.HTTP_201_CREATED) + @extend_schema(request=SignalReportTaskCreateSerializer, responses={200: TaskSerializer, 201: TaskSerializer}) + @action(detail=False, methods=["post"], url_path="from_signal_report", required_scopes=["task:write"]) + def from_signal_report(self, request, **kwargs): + if not isinstance(request.successful_authenticator, SessionAuthentication) and not _is_posthog_code_request( + request + ): + raise PermissionDenied("Signal report tasks must be started from PostHog Inbox.") + serializer = self._write_serializer(request.data, serializer_class=SignalReportTaskCreateSerializer) + validated_data = dict(serializer.validated_data) + task, created = tasks_facade.create_signal_report_task( + self.team_id, self._user_id(), validated_data=validated_data + ) + return Response( + TaskSerializer(task).data, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + ) + @extend_schema(request=TaskWriteSerializer, responses={200: TaskSerializer}) def update(self, request, pk=None, **kwargs): return self.partial_update(request, pk=pk, **kwargs) @@ -985,7 +1024,11 @@ def create(self, request, *args, **kwargs): return limit_response result = tasks_facade.bootstrap_task_run( - task_id, self.team_id, self._user_id(), validated_data=dict(request.validated_data) + task_id, + self.team_id, + self._user_id(), + validated_data=dict(request.validated_data), + created_via_code=_is_posthog_code_request(request), ) if result is None: raise NotFound("Task not found") @@ -1040,7 +1083,12 @@ def start(self, request, pk=None, **kwargs): return limit_response outcome, started_task_id = tasks_facade.start_task_run( - pk, task_id, self.team_id, self._user_id(), validated_data=dict(request.validated_data) + pk, + task_id, + self.team_id, + self._user_id(), + validated_data=dict(request.validated_data), + created_via_code=_is_posthog_code_request(request), ) if outcome == "not_found": raise NotFound() @@ -1722,6 +1770,7 @@ def command(self, request, pk=None, **kwargs): actor_user_id=request.user.id, message_id=str(request_id) if request_id is not None else None, steer=command_params.get("steer", False), + created_via_code=_is_posthog_code_request(request), ) except Exception: # A synchronous web request can't retry the way the Temporal @@ -2041,7 +2090,13 @@ def resume_in_cloud(self, request, pk=None, **kwargs): if (limit_response := cloud_usage_limit_response(request.user, self.team_id)) is not None: return limit_response - outcome, run, _ = tasks_facade.resume_task_run_in_cloud(pk, task_id, self.team_id, self._user_id()) + outcome, run, _ = tasks_facade.resume_task_run_in_cloud( + pk, + task_id, + self.team_id, + self._user_id(), + created_via_code=_is_posthog_code_request(request), + ) if outcome == "not_found": raise NotFound() if outcome == "already_active": diff --git a/products/tasks/backend/tests/test_api.py b/products/tasks/backend/tests/test_api.py index 31c004b616dd..1c839a74bde6 100644 --- a/products/tasks/backend/tests/test_api.py +++ b/products/tasks/backend/tests/test_api.py @@ -24,11 +24,21 @@ from rest_framework import status from rest_framework.test import APIClient -from posthog.models import Integration, Organization, OrganizationMembership, PersonalAPIKey, Team, User +from posthog.models import ( + Integration, + OAuthAccessToken, + OAuthApplication, + Organization, + OrganizationMembership, + PersonalAPIKey, + Team, + User, +) from posthog.models.personal_api_key import hash_key_value from posthog.models.user_integration import UserIntegration from posthog.models.utils import generate_random_token_personal from posthog.storage import object_storage +from posthog.temporal.oauth import POSTHOG_CODE_OAUTH_CLIENT_IDS from products.slack_app.backend.models import SlackThreadTaskMapping from products.tasks.backend.facade import api as tasks_facade @@ -936,6 +946,32 @@ def test_slack_thread_context_403_off_us(self, _name: str, deployment: str | Non class TestTaskAPI(BaseTaskAPITest): + def _authenticate_with_session(self) -> None: + self.client.force_authenticate(user=None) + self.client.force_login(self.user) + + def _code_oauth_client(self) -> APIClient: + application = OAuthApplication.objects.create( + name="PostHog Code", + client_id=next(iter(POSTHOG_CODE_OAUTH_CLIENT_IDS)), + client_type=OAuthApplication.CLIENT_PUBLIC, + authorization_grant_type=OAuthApplication.GRANT_AUTHORIZATION_CODE, + redirect_uris="posthog-code://oauth/callback", + algorithm="RS256", + organization=self.organization, + user=self.user, + ) + token = OAuthAccessToken.objects.create( + user=self.user, + application=application, + token="pha_code_task_create", + expires=django_timezone.now() + timedelta(hours=1), + scope="task:read task:write", + ) + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token.token}") + return client + def test_list_tasks(self): self.create_task("Task 1") self.create_task("Task 2") @@ -1245,6 +1281,17 @@ def test_create_task_defaults_origin_product(self): task = Task.objects.get(id=data["id"]) self.assertEqual(task.origin_product, Task.OriginProduct.USER_CREATED) + def test_create_cloud_run_records_code_oauth_provenance(self): + task = self.create_task() + response = self._code_oauth_client().post( + f"/api/projects/{self.team.id}/tasks/{task.id}/runs/", + {"environment": "cloud", "mode": "interactive"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertTrue(TaskRun.objects.get(id=response.json()["id"]).created_via_code) + def test_create_task_with_hogdesk_origin_product(self): # HogDesk creates Code tasks from a support ticket's Code chat with this # origin. Ensure the value round-trips through the API — the serializer @@ -1354,17 +1401,17 @@ def test_create_task_rejects_github_user_integration_for_other_user(self): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.json()["attr"], "github_user_integration") - def test_create_task_with_signal_report_same_team(self): + def test_create_signal_report_task_from_dedicated_action(self): from products.signals.backend.models import SignalReport, SignalReportTask from products.signals.backend.task_run_artefacts import TASK_RUN_TYPE_IMPLEMENTATION, signals_task_ids report = SignalReport.objects.create(team=self.team) + self._authenticate_with_session() response = self.client.post( - "/api/projects/@current/tasks/", + "/api/projects/@current/tasks/from_signal_report/", { "title": "Signal Task", "description": "From a signal report", - "origin_product": "signal_report", "signal_report": str(report.id), "signal_report_task_relationship": "implementation", }, @@ -1383,69 +1430,73 @@ def test_create_task_with_signal_report_same_team(self): ).exists() ) - def test_create_task_with_signal_report_discussion_records_artefact_without_gate_row(self): - from products.signals.backend.models import SignalReport, SignalReportTask - from products.signals.backend.task_run_artefacts import ( - TASK_RUN_TYPE_DISCUSSION, - TASK_RUN_TYPE_IMPLEMENTATION, - signals_task_ids, + repeated_response = self.client.post( + "/api/projects/@current/tasks/from_signal_report/", + { + "title": "Another task", + "description": "Try to implement the same report again", + "signal_report": str(report.id), + }, + format="json", + ) + + self.assertEqual(repeated_response.status_code, status.HTTP_200_OK) + self.assertEqual(repeated_response.json()["id"], data["id"]) + self.assertEqual( + SignalReportTask.objects.filter( + report=report, + relationship=TASK_RUN_TYPE_IMPLEMENTATION, + ).count(), + 1, ) + def test_generic_create_rejects_signal_report_with_upgrade_message(self): + from products.signals.backend.models import SignalReport + report = SignalReport.objects.create(team=self.team) + self._authenticate_with_session() response = self.client.post( "/api/projects/@current/tasks/", { - "title": "Discuss report", - "description": "Let's discuss this report", + "description": "From a signal report", "origin_product": "signal_report", "signal_report": str(report.id), - "signal_report_task_relationship": "discussion", }, format="json", ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - data = response.json() - # A discussion link is recorded as a discussion task_run artefact only — it must NOT open the - # implementation spend gate (no SignalReportTask row, no implementation artefact), otherwise a - # discuss-the-report task would block the auto-start pipeline. - self.assertEqual(signals_task_ids(report_id=str(report.id), type=TASK_RUN_TYPE_DISCUSSION), [data["id"]]) - self.assertEqual(signals_task_ids(report_id=str(report.id), type=TASK_RUN_TYPE_IMPLEMENTATION), []) - self.assertFalse(SignalReportTask.objects.filter(report=report, task_id=data["id"]).exists()) - - def test_create_task_with_signal_report_accepts_free_form_relationship(self): - from products.signals.backend.models import SignalReport, SignalReportTask - from products.signals.backend.task_run_artefacts import signals_task_ids - # The relationship is a free-form task_run label — no value is reserved. A non-implementation - # relationship records only the work-log artefact (no SignalReportTask gate row). + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("Update PostHog Code", response.json()["detail"]) + + def test_signal_report_action_rejects_non_implementation_relationship(self): + from products.signals.backend.models import SignalReport + report = SignalReport.objects.create(team=self.team) + self._authenticate_with_session() response = self.client.post( - "/api/projects/@current/tasks/", + "/api/projects/@current/tasks/from_signal_report/", { "title": "Research", "description": "From a signal report", - "origin_product": "signal_report", "signal_report": str(report.id), "signal_report_task_relationship": "research", }, format="json", ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - data = response.json() - self.assertEqual(signals_task_ids(report_id=str(report.id), type="research"), [data["id"]]) - self.assertFalse(SignalReportTask.objects.filter(report=report, task_id=data["id"]).exists()) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) def test_create_task_with_signal_report_different_team_rejected(self): from products.signals.backend.models import SignalReport other_team = Team.objects.create(organization=self.organization, name="Other Team") report = SignalReport.objects.create(team=other_team) + self._authenticate_with_session() response = self.client.post( - "/api/projects/@current/tasks/", + "/api/projects/@current/tasks/from_signal_report/", { "title": "Cross-team Task", "description": "Should be rejected", - "origin_product": "signal_report", "signal_report": str(report.id), }, format="json", @@ -1460,7 +1511,7 @@ def test_patch_cannot_change_origin_product(self): {"origin_product": "signal_report"}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) task.refresh_from_db() self.assertEqual(task.origin_product, Task.OriginProduct.USER_CREATED) @@ -1474,7 +1525,7 @@ def test_patch_cannot_change_signal_report(self): {"signal_report": str(report.id)}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) task.refresh_from_db() self.assertIsNone(task.signal_report_id) @@ -1926,6 +1977,7 @@ def test_create_run_endpoint_creates_cloud_run_without_triggering_workflow(self, self.assertEqual(task_run.state["initial_permission_mode"], "auto") self.assertEqual(task_run.state["run_source"], "manual") self.assertEqual(task_run.state["auto_publish"], True) + self.assertFalse(task_run.created_via_code) mock_workflow.assert_not_called() # is_url_allowed resolves DNS for real in CI, and example.com subdomains don't resolve. @@ -2275,6 +2327,21 @@ def test_start_run_endpoint_triggers_workflow_for_existing_cloud_run(self, mock_ posthog_mcp_scopes="full", ) + @patch("products.tasks.backend.temporal.client.execute_task_processing_workflow") + def test_start_existing_cloud_run_records_code_oauth_provenance(self, _mock_workflow): + task = self.create_task() + task_run = task.create_run(environment=TaskRun.Environment.CLOUD) + + response = self._code_oauth_client().post( + f"/api/projects/{self.team.id}/tasks/{task.id}/runs/{task_run.id}/start/", + {}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + task_run.refresh_from_db() + self.assertTrue(task_run.created_via_code) + @patch("products.tasks.backend.temporal.client.execute_task_processing_workflow") def test_start_run_endpoint_starts_pi_task(self, mock_workflow): task = self.create_task(runtime=Task.Runtime.PI) diff --git a/products/tasks/frontend/generated/api.schemas.ts b/products/tasks/frontend/generated/api.schemas.ts index 2ef2e7534103..70ef08d88f3a 100644 --- a/products/tasks/frontend/generated/api.schemas.ts +++ b/products/tasks/frontend/generated/api.schemas.ts @@ -3290,6 +3290,131 @@ export interface WizardCloudRunDTOApi { started_at?: string | null } +/** + * * `implementation` - implementation + */ +export type SignalReportTaskRelationshipEnumApi = + (typeof SignalReportTaskRelationshipEnumApi)[keyof typeof SignalReportTaskRelationshipEnumApi] + +export const SignalReportTaskRelationshipEnumApi = { + Implementation: 'implementation', +} as const + +/** + * Request body for creating or updating a task. + * + * Field required/default semantics match the ``Task`` model. The view passes + * ``validated_data`` (integration/report PK fields already resolved to instances) to the + * facade ``create_task`` / ``update_task`` functions. + */ +export interface SignalReportTaskCreateApi { + /** + * Short human-readable title. Auto-generated from `description` when omitted. + * @maxLength 255 + */ + title?: string + /** Whether the title was set by a human (vs auto-generated from the description). */ + title_manually_set?: boolean + /** Free-form description of the work to be done. Used as the prompt passed to the agent. */ + description?: string + /** + * Target GitHub repository in `organization/repo` format (e.g. `posthog/posthog-js`). + * @maxLength 255 + * @nullable + */ + repository?: string | null + /** + * GitHub integration for this task. + * @nullable + */ + github_integration?: number | null + /** + * User-scoped GitHub integration to use for user-authored cloud runs. + * @nullable + */ + github_user_integration?: string | null + /** + * Signal report this task implements, when created from a report. + * @nullable + */ + signal_report?: string | null + /** Signal report relationship created by this endpoint. + * + * * `implementation` - implementation */ + signal_report_task_relationship?: SignalReportTaskRelationshipEnumApi + /** JSON schema used to validate the output of the task. */ + json_schema?: unknown + /** If true, this task is for internal use and should not be exposed to end users. */ + internal?: boolean + /** If true, the task is hidden from default list responses. */ + archived?: boolean + /** + * Custom prompt for CI fixes. If blank, a default prompt will be used. + * @nullable + */ + ci_prompt?: string | null + /** + * Branch the user has selected for this cloud task. Write-only and not persisted on the task itself: used only to reuse a matching pre-warmed sandbox Run on creation (the branch is otherwise carried on the run). Omit to match a warm Run on the default branch. + * @maxLength 255 + * @nullable + */ + branch?: string | null + /** Selected runtime adapter ('claude' or 'codex'). Write-only and not persisted on the task: used only to reuse a pre-warmed Run started on the same runtime. A value differing from the warm Run's runtime skips reuse so the task isn't silently run on the wrong runtime. + * + * * `claude` - claude + * * `codex` - codex */ + runtime_adapter?: RuntimeAdapterEnumApi | null + /** + * Selected LLM model identifier. Write-only; used only to reuse a warm Run started on the same model. + * @nullable + */ + model?: string | null + /** Selected reasoning effort. Write-only; used only to reuse a warm Run started on the same effort. + * + * * `low` - low + * * `medium` - medium + * * `high` - high + * * `xhigh` - xhigh + * * `max` - max + * * `ultracode` - ultracode */ + reasoning_effort?: ReasoningEffortEnumApi | null + /** + * First user message to forward when creation reuses a pre-warmed Run. Write-only and not persisted on the task: lets clients deliver a message that differs from `description` (e.g. a resolved skill invocation with channel context folded in). Ignored when no warm Run is reused — cold creation takes the first message via the run start endpoint instead. + * @nullable + */ + pending_user_message?: string | null + /** + * Run artifact ids (already uploaded to the pre-warmed Run) to attach to the forwarded first message when creation reuses that warm Run, e.g. skill bundles or file attachments. If any id is missing from the warm Run's manifest, warm reuse is skipped and the task is created cold. Ignored when no warm Run is matched. + * @items.maxLength 128 + */ + pending_user_artifact_ids?: string[] + /** + * When true, the cloud run agent pushes its work and opens a draft pull request on completion without waiting for an explicit ask. Write-only and not persisted on the task: persisted into the reused warm Run's state when creation activates one, so resumes of that Run honor it. Ignored when no warm Run is reused — cold creation takes it via the run start endpoint instead. + * @nullable + */ + auto_publish?: boolean | null + /** + * Channel this task is owned by (the channel it was kicked off in). + * @nullable + */ + channel?: string | null + /** + * Sandbox environment selected for matching a pre-warmed cloud run. Not persisted on the task. + * @nullable + */ + sandbox_environment_id?: string | null + /** + * Custom image selected for matching a pre-warmed cloud run. Not persisted on the task. + * @nullable + */ + custom_image_id?: string | null + /** Agent protocol and harness used for this task's runs. Defaults to ACP when omitted. + * + * * `acp` - ACP + * * `pi` - Pi */ + runtime?: RuntimeEnumApi +} + export interface TaskRepositoriesResponseApi { /** Distinct repositories in use by non-deleted, non-internal tasks for the current team. */ repositories: string[] diff --git a/products/tasks/frontend/generated/api.ts b/products/tasks/frontend/generated/api.ts index 73d2cc0ee28d..f748567f9a57 100644 --- a/products/tasks/frontend/generated/api.ts +++ b/products/tasks/frontend/generated/api.ts @@ -54,6 +54,7 @@ import type { SandboxEnvironmentDTOApi, SandboxEnvironmentWriteApi, SandboxListParams, + SignalReportTaskCreateApi, SlackThreadContextResponseApi, StreamReadTokenResponseApi, TaskActivityListParams, @@ -1984,6 +1985,26 @@ export const tasksActiveWizardRunRetrieve = async ( }) } +export const getTasksFromSignalReportCreateUrl = (projectId: string) => { + return `/api/projects/${projectId}/tasks/from_signal_report/` +} + +/** + * API for managing tasks within a project. Tasks represent units of work to be performed by an agent. + */ +export const tasksFromSignalReportCreate = async ( + projectId: string, + signalReportTaskCreateApi?: SignalReportTaskCreateApi, + options?: RequestInit +): Promise => { + return apiMutator(getTasksFromSignalReportCreateUrl(projectId), { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(signalReportTaskCreateApi), + }) +} + export const getTasksRepositoriesRetrieveUrl = (projectId: string) => { return `/api/projects/${projectId}/tasks/repositories/` } diff --git a/products/tasks/frontend/generated/api.zod.ts b/products/tasks/frontend/generated/api.zod.ts index 21d2d5d863cc..aed5b183f5f5 100644 --- a/products/tasks/frontend/generated/api.zod.ts +++ b/products/tasks/frontend/generated/api.zod.ts @@ -2874,6 +2874,130 @@ export const TasksThreadMessagesSendToAgentCreateBody = /* @__PURE__ */ zod }) .describe("Response shape for one message in a task's thread.") +/** + * API for managing tasks within a project. Tasks represent units of work to be performed by an agent. + */ +export const tasksFromSignalReportCreateBodyTitleMax = 255 + +export const tasksFromSignalReportCreateBodyRepositoryMax = 255 + +export const tasksFromSignalReportCreateBodySignalReportTaskRelationshipDefault = `implementation` +export const tasksFromSignalReportCreateBodyBranchMax = 255 + +export const tasksFromSignalReportCreateBodyPendingUserArtifactIdsItemMax = 128 + +export const TasksFromSignalReportCreateBody = /* @__PURE__ */ zod + .object({ + title: zod + .string() + .max(tasksFromSignalReportCreateBodyTitleMax) + .optional() + .describe('Short human-readable title. Auto-generated from `description` when omitted.'), + title_manually_set: zod + .boolean() + .optional() + .describe('Whether the title was set by a human (vs auto-generated from the description).'), + description: zod + .string() + .optional() + .describe('Free-form description of the work to be done. Used as the prompt passed to the agent.'), + repository: zod + .string() + .max(tasksFromSignalReportCreateBodyRepositoryMax) + .nullish() + .describe('Target GitHub repository in `organization\/repo` format (e.g. `posthog\/posthog-js`).'), + github_integration: zod.number().nullish().describe('GitHub integration for this task.'), + github_user_integration: zod + .uuid() + .nullish() + .describe('User-scoped GitHub integration to use for user-authored cloud runs.'), + signal_report: zod.uuid().nullish().describe('Signal report this task implements, when created from a report.'), + signal_report_task_relationship: zod + .enum(['implementation']) + .describe('\* `implementation` - implementation') + .default(tasksFromSignalReportCreateBodySignalReportTaskRelationshipDefault) + .describe('Signal report relationship created by this endpoint.\n\n\* `implementation` - implementation'), + json_schema: zod.unknown().optional().describe('JSON schema used to validate the output of the task.'), + internal: zod + .boolean() + .optional() + .describe('If true, this task is for internal use and should not be exposed to end users.'), + archived: zod.boolean().optional().describe('If true, the task is hidden from default list responses.'), + ci_prompt: zod + .string() + .nullish() + .describe('Custom prompt for CI fixes. If blank, a default prompt will be used.'), + branch: zod + .string() + .max(tasksFromSignalReportCreateBodyBranchMax) + .nullish() + .describe( + 'Branch the user has selected for this cloud task. Write-only and not persisted on the task itself: used only to reuse a matching pre-warmed sandbox Run on creation (the branch is otherwise carried on the run). Omit to match a warm Run on the default branch.' + ), + runtime_adapter: zod + .union([zod.enum(['claude', 'codex']).describe('\* `claude` - claude\n\* `codex` - codex'), zod.null()]) + .optional() + .describe( + "Selected runtime adapter ('claude' or 'codex'). Write-only and not persisted on the task: used only to reuse a pre-warmed Run started on the same runtime. A value differing from the warm Run's runtime skips reuse so the task isn't silently run on the wrong runtime.\n\n\* `claude` - claude\n\* `codex` - codex" + ), + model: zod + .string() + .nullish() + .describe( + 'Selected LLM model identifier. Write-only; used only to reuse a warm Run started on the same model.' + ), + reasoning_effort: zod + .union([ + zod + .enum(['low', 'medium', 'high', 'xhigh', 'max', 'ultracode']) + .describe( + '\* `low` - low\n\* `medium` - medium\n\* `high` - high\n\* `xhigh` - xhigh\n\* `max` - max\n\* `ultracode` - ultracode' + ), + zod.null(), + ]) + .optional() + .describe( + 'Selected reasoning effort. Write-only; used only to reuse a warm Run started on the same effort.\n\n\* `low` - low\n\* `medium` - medium\n\* `high` - high\n\* `xhigh` - xhigh\n\* `max` - max\n\* `ultracode` - ultracode' + ), + pending_user_message: zod + .string() + .nullish() + .describe( + 'First user message to forward when creation reuses a pre-warmed Run. Write-only and not persisted on the task: lets clients deliver a message that differs from `description` (e.g. a resolved skill invocation with channel context folded in). Ignored when no warm Run is reused — cold creation takes the first message via the run start endpoint instead.' + ), + pending_user_artifact_ids: zod + .array(zod.string().max(tasksFromSignalReportCreateBodyPendingUserArtifactIdsItemMax)) + .optional() + .describe( + "Run artifact ids (already uploaded to the pre-warmed Run) to attach to the forwarded first message when creation reuses that warm Run, e.g. skill bundles or file attachments. If any id is missing from the warm Run's manifest, warm reuse is skipped and the task is created cold. Ignored when no warm Run is matched." + ), + auto_publish: zod + .boolean() + .nullish() + .describe( + "When true, the cloud run agent pushes its work and opens a draft pull request on completion without waiting for an explicit ask. Write-only and not persisted on the task: persisted into the reused warm Run's state when creation activates one, so resumes of that Run honor it. Ignored when no warm Run is reused — cold creation takes it via the run start endpoint instead." + ), + channel: zod.uuid().nullish().describe('Channel this task is owned by (the channel it was kicked off in).'), + sandbox_environment_id: zod + .uuid() + .nullish() + .describe('Sandbox environment selected for matching a pre-warmed cloud run. Not persisted on the task.'), + custom_image_id: zod + .uuid() + .nullish() + .describe('Custom image selected for matching a pre-warmed cloud run. Not persisted on the task.'), + runtime: zod + .enum(['acp', 'pi']) + .describe('\* `acp` - ACP\n\* `pi` - Pi') + .optional() + .describe( + "Agent protocol and harness used for this task's runs. Defaults to ACP when omitted.\n\n\* `acp` - ACP\n\* `pi` - Pi" + ), + }) + .describe( + 'Request body for creating or updating a task.\n\nField required\/default semantics match the ``Task`` model. The view passes\n``validated_data`` (integration\/report PK fields already resolved to instances) to the\nfacade ``create_task`` \/ ``update_task`` functions.' + ) + /** * Returns summary for the requested tasks: `id`, `title`, `repository`, `created_at`, `updated_at`, and the latest run's `status` and `environment`. * @summary Fetch task summaries by ID From 1b50ea6146e70e9cf313a792738fefb85a63a699 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Wed, 29 Jul 2026 12:21:08 -0400 Subject: [PATCH 2/6] chore: update OpenAPI generated types --- products/tasks/mcp/tools.yaml | 3 + services/mcp/src/api/generated.ts | 125 ++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/products/tasks/mcp/tools.yaml b/products/tasks/mcp/tools.yaml index d9d67d7c76cd..e2056c47008d 100644 --- a/products/tasks/mcp/tools.yaml +++ b/products/tasks/mcp/tools.yaml @@ -282,6 +282,9 @@ tools: tasks-destroy: operation: tasks_destroy enabled: false + tasks-from-signal-report-create: + operation: tasks_from_signal_report_create + enabled: false tasks-list: operation: tasks_list enabled: true diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 4512d7390993..bfe3024bbbd6 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -62183,6 +62183,131 @@ export namespace Schemas { snooze_for?: number; } + /** + * * `implementation` - implementation + */ + export type SignalReportTaskRelationshipEnum = typeof SignalReportTaskRelationshipEnum[keyof typeof SignalReportTaskRelationshipEnum]; + + + export const SignalReportTaskRelationshipEnum = { + Implementation: 'implementation', + } as const; + + /** + * Request body for creating or updating a task. + * + * Field required/default semantics match the ``Task`` model. The view passes + * ``validated_data`` (integration/report PK fields already resolved to instances) to the + * facade ``create_task`` / ``update_task`` functions. + */ + export interface SignalReportTaskCreate { + /** + * Short human-readable title. Auto-generated from `description` when omitted. + * @maxLength 255 + */ + title?: string; + /** Whether the title was set by a human (vs auto-generated from the description). */ + title_manually_set?: boolean; + /** Free-form description of the work to be done. Used as the prompt passed to the agent. */ + description?: string; + /** + * Target GitHub repository in `organization/repo` format (e.g. `posthog/posthog-js`). + * @maxLength 255 + * @nullable + */ + repository?: string | null; + /** + * GitHub integration for this task. + * @nullable + */ + github_integration?: number | null; + /** + * User-scoped GitHub integration to use for user-authored cloud runs. + * @nullable + */ + github_user_integration?: string | null; + /** + * Signal report this task implements, when created from a report. + * @nullable + */ + signal_report?: string | null; + /** Signal report relationship created by this endpoint. + * + * * `implementation` - implementation */ + signal_report_task_relationship?: SignalReportTaskRelationshipEnum; + /** JSON schema used to validate the output of the task. */ + json_schema?: unknown; + /** If true, this task is for internal use and should not be exposed to end users. */ + internal?: boolean; + /** If true, the task is hidden from default list responses. */ + archived?: boolean; + /** + * Custom prompt for CI fixes. If blank, a default prompt will be used. + * @nullable + */ + ci_prompt?: string | null; + /** + * Branch the user has selected for this cloud task. Write-only and not persisted on the task itself: used only to reuse a matching pre-warmed sandbox Run on creation (the branch is otherwise carried on the run). Omit to match a warm Run on the default branch. + * @maxLength 255 + * @nullable + */ + branch?: string | null; + /** Selected runtime adapter ('claude' or 'codex'). Write-only and not persisted on the task: used only to reuse a pre-warmed Run started on the same runtime. A value differing from the warm Run's runtime skips reuse so the task isn't silently run on the wrong runtime. + * + * * `claude` - claude + * * `codex` - codex */ + runtime_adapter?: RuntimeAdapterEnum | null; + /** + * Selected LLM model identifier. Write-only; used only to reuse a warm Run started on the same model. + * @nullable + */ + model?: string | null; + /** Selected reasoning effort. Write-only; used only to reuse a warm Run started on the same effort. + * + * * `low` - low + * * `medium` - medium + * * `high` - high + * * `xhigh` - xhigh + * * `max` - max + * * `ultracode` - ultracode */ + reasoning_effort?: ReasoningEffortEnum | null; + /** + * First user message to forward when creation reuses a pre-warmed Run. Write-only and not persisted on the task: lets clients deliver a message that differs from `description` (e.g. a resolved skill invocation with channel context folded in). Ignored when no warm Run is reused — cold creation takes the first message via the run start endpoint instead. + * @nullable + */ + pending_user_message?: string | null; + /** + * Run artifact ids (already uploaded to the pre-warmed Run) to attach to the forwarded first message when creation reuses that warm Run, e.g. skill bundles or file attachments. If any id is missing from the warm Run's manifest, warm reuse is skipped and the task is created cold. Ignored when no warm Run is matched. + * @items.maxLength 128 + */ + pending_user_artifact_ids?: string[]; + /** + * When true, the cloud run agent pushes its work and opens a draft pull request on completion without waiting for an explicit ask. Write-only and not persisted on the task: persisted into the reused warm Run's state when creation activates one, so resumes of that Run honor it. Ignored when no warm Run is reused — cold creation takes it via the run start endpoint instead. + * @nullable + */ + auto_publish?: boolean | null; + /** + * Channel this task is owned by (the channel it was kicked off in). + * @nullable + */ + channel?: string | null; + /** + * Sandbox environment selected for matching a pre-warmed cloud run. Not persisted on the task. + * @nullable + */ + sandbox_environment_id?: string | null; + /** + * Custom image selected for matching a pre-warmed cloud run. Not persisted on the task. + * @nullable + */ + custom_image_id?: string | null; + /** Agent protocol and harness used for this task's runs. Defaults to ACP when omitted. + * + * * `acp` - ACP + * * `pi` - Pi */ + runtime?: RuntimeEnum; + } + /** * Read shape for a per-(team, skill) scout config. * From a121cf763180789837afa0ee7cb4ab4510fdefc7 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Wed, 29 Jul 2026 12:21:11 -0400 Subject: [PATCH 3/6] fix(tasks): snapshot loop billing classification Preserve a loop's internal classification on each sandbox session so historical compute billing cannot change when the loop changes. Generated-By: PostHog Code Task-Id: e55f8ea4-cc41-4e76-ad48-896b396e95ce --- .../backend/logic/services/sandbox_usage.py | 13 +++++++++-- .../services/tests/test_sandbox_usage.py | 23 ++++++++++++++++++- .../0076_add_code_task_provenance.py | 9 ++++++++ products/tasks/backend/models.py | 5 ++++ 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/products/tasks/backend/logic/services/sandbox_usage.py b/products/tasks/backend/logic/services/sandbox_usage.py index b9d848ed4d29..0523ac0105d9 100644 --- a/products/tasks/backend/logic/services/sandbox_usage.py +++ b/products/tasks/backend/logic/services/sandbox_usage.py @@ -58,17 +58,26 @@ def open_sandbox_session( with transaction.atomic(): run = ( TaskRun.objects.select_for_update(of=("self",)) - .select_related("task") - .only("id", "team_id", "state", "created_via_code", "task__origin_product") + .select_related("task", "task__loop") + .only( + "id", + "team_id", + "state", + "created_via_code", + "task__origin_product", + "task__loop__internal", + ) .get(id=run_id) ) state = run.state or {} + loop = run.task.loop created_at = sandbox_created_at or timezone.now() shape = { "team_id": run.team_id, "task_run_id": run.id, "origin_product": run.task.origin_product, "created_via_code": run.created_via_code, + "loop_internal": loop.internal if loop is not None else None, "prewarmed": bool(state.get("prewarmed")), "vm_runtime": config.is_vm, "cpu_cores": config.cpu_cores, diff --git a/products/tasks/backend/logic/services/tests/test_sandbox_usage.py b/products/tasks/backend/logic/services/tests/test_sandbox_usage.py index 7ac684d4381a..7de596f033d8 100644 --- a/products/tasks/backend/logic/services/tests/test_sandbox_usage.py +++ b/products/tasks/backend/logic/services/tests/test_sandbox_usage.py @@ -14,7 +14,7 @@ open_sandbox_session, record_task_run_user_activity, ) -from products.tasks.backend.models import SandboxSession, Task, TaskRun +from products.tasks.backend.models import Loop, SandboxSession, Task, TaskRun def _config(**overrides) -> SandboxConfig: @@ -99,6 +99,27 @@ def test_open_records_vm_runtime(self): assert SandboxSession.objects.unscoped().get(sandbox_id="sb-vm").vm_runtime is True + def test_open_snapshots_loop_internal_classification(self): + loop = Loop.objects.unscoped().create( + team=self.team, + name="Internal loop", + instructions="Run", + runtime_adapter="claude", + internal=True, + ) + task = Task.objects.create( + team=self.team, + title="Loop task", + description="", + origin_product=Task.OriginProduct.LOOP, + loop=loop, + ) + run = TaskRun.objects.create(task=task, team=self.team) + + open_sandbox_session(run_id=run.id, sandbox_id="sb-loop", config=_config()) + + assert SandboxSession.objects.unscoped().get(sandbox_id="sb-loop").loop_internal is True + def test_open_retry_never_regresses_attribution(self): run = self._run(state={"await_user_message": True}) open_sandbox_session(run_id=run.id, sandbox_id="sb-retry", config=_config()) diff --git a/products/tasks/backend/migrations/0076_add_code_task_provenance.py b/products/tasks/backend/migrations/0076_add_code_task_provenance.py index b0cc9a501e4b..fc9f13c4f67c 100644 --- a/products/tasks/backend/migrations/0076_add_code_task_provenance.py +++ b/products/tasks/backend/migrations/0076_add_code_task_provenance.py @@ -25,4 +25,13 @@ class Migration(migrations.Migration): null=True, ), ), + migrations.AddField( + model_name="sandboxsession", + name="loop_internal", + field=models.BooleanField( + editable=False, + help_text="Loop internal classification at provision time", + null=True, + ), + ), ] diff --git a/products/tasks/backend/models.py b/products/tasks/backend/models.py index a75e11fafc2a..c10f1982f980 100644 --- a/products/tasks/backend/models.py +++ b/products/tasks/backend/models.py @@ -2293,6 +2293,11 @@ class EndedReason(models.TextChoices): editable=False, help_text="PostHog Code OAuth provenance at provision time", ) + loop_internal = models.BooleanField( + null=True, + editable=False, + help_text="Loop internal classification at provision time", + ) prewarmed = models.BooleanField(default=False, help_text="Sandbox was provisioned ahead of any user demand") vm_runtime = models.BooleanField( default=False, help_text="Modal VM runtime rather than gVisor (billed differently)" From 748457891c7617cc991377302b55dc02ef08bc74 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Wed, 29 Jul 2026 12:21:13 -0400 Subject: [PATCH 4/6] refactor(tasks): model trusted compute source explicitly Generated-By: PostHog Code Task-Id: e55f8ea4-cc41-4e76-ad48-896b396e95ce --- posthog/temporal/oauth.py | 10 +++--- products/tasks/backend/facade/api.py | 31 ++++++++++--------- .../backend/logic/services/sandbox_usage.py | 30 ++++++++++-------- .../services/tests/test_sandbox_usage.py | 30 ++++++++++++------ ...ovenance.py => 0076_add_compute_source.py} | 17 ++++++---- .../backend/migrations/max_migration.txt | 2 +- products/tasks/backend/models.py | 22 ++++++++----- .../tasks/backend/presentation/views/api.py | 31 ++++++++++--------- products/tasks/backend/tests/test_api.py | 21 +++++++------ 9 files changed, 114 insertions(+), 80 deletions(-) rename products/tasks/backend/migrations/{0076_add_code_task_provenance.py => 0076_add_compute_source.py} (60%) diff --git a/posthog/temporal/oauth.py b/posthog/temporal/oauth.py index 43076f087894..faaea048a67c 100644 --- a/posthog/temporal/oauth.py +++ b/posthog/temporal/oauth.py @@ -12,19 +12,19 @@ ARRAY_APP_CLIENT_ID_US = "HCWoE0aRFMYxIxFNTTwkOORn5LBjOt2GVDzwSw5W" ARRAY_APP_CLIENT_ID_EU = "AIvijgMS0dxKEmr5z6odvRd8Pkh5vts3nPTzgzU9" ARRAY_APP_CLIENT_ID_DEV = "DC5uRLVbGI02YQ82grxgnK6Qn12SXWpCqdPb60oZ" -POSTHOG_CODE_MOBILE_CLIENT_ID_US = "a5TY7w9IjFYfes6dkPgZe6envclWw3bm2UD8ZTlm" -POSTHOG_CODE_MOBILE_CLIENT_ID_EU = "1A7vO138Fh5sYmJislicN4F5HnttI6urmFttxPDU" +POSTHOG_DESKTOP_MOBILE_CLIENT_ID_US = "a5TY7w9IjFYfes6dkPgZe6envclWw3bm2UD8ZTlm" +POSTHOG_DESKTOP_MOBILE_CLIENT_ID_EU = "1A7vO138Fh5sYmJislicN4F5HnttI6urmFttxPDU" POSTHOG_AI_APP_CLIENT_ID_US = "N6UgOECSl98ag1xajxPphGApQXYEVvJIwzCXotKu" POSTHOG_AI_APP_CLIENT_ID_EU = "0Lizwa3mFSlBuEEQ8V8FMJlskUXpDuSmoEdhzxyi" POSTHOG_AI_APP_CLIENT_ID_DEV = "DD2ZLG6a2YEUtpPANSzSiIBPuUryYmbndLnKKUy1" -POSTHOG_CODE_OAUTH_CLIENT_IDS = frozenset( +POSTHOG_DESKTOP_OAUTH_CLIENT_IDS = frozenset( { ARRAY_APP_CLIENT_ID_DEV, ARRAY_APP_CLIENT_ID_EU, ARRAY_APP_CLIENT_ID_US, - POSTHOG_CODE_MOBILE_CLIENT_ID_EU, - POSTHOG_CODE_MOBILE_CLIENT_ID_US, + POSTHOG_DESKTOP_MOBILE_CLIENT_ID_EU, + POSTHOG_DESKTOP_MOBILE_CLIENT_ID_US, } ) diff --git a/products/tasks/backend/facade/api.py b/products/tasks/backend/facade/api.py index f1d27bce848b..c7015eb757c3 100644 --- a/products/tasks/backend/facade/api.py +++ b/products/tasks/backend/facade/api.py @@ -58,6 +58,7 @@ ChannelFeedMessage, CodeInvite, CodeInviteRedemption, + ComputeSource, SandboxCustomImage, SandboxEnvironment, SandboxSession, @@ -2932,7 +2933,7 @@ def signal_task_run_user_message( message_id: str | None = None, actor_slack_user_id: str | None = None, steer: bool = False, - created_via_code: bool = False, + compute_source: ComputeSource | None = None, ) -> bool | None: """Queue a user_message follow-up signal on the run's workflow. @@ -2966,11 +2967,13 @@ def signal_task_run_user_message( logger.warning("Follow-up signal target workflow gone for task run %s", run.id) return False raise - record_task_run_user_activity(run.id, team_id, created_via_code=created_via_code) + record_task_run_user_activity(run.id, team_id, compute_source=compute_source) return True -def record_task_run_user_activity(run_id: str | UUID, team_id: int, *, created_via_code: bool = False) -> None: +def record_task_run_user_activity( + run_id: str | UUID, team_id: int, *, compute_source: ComputeSource | None = None +) -> None: """Stamp a user message against the run's open sandbox usage sessions. Best-effort (the ledger swallows its own failures): records last-activity on @@ -2981,7 +2984,7 @@ def record_task_run_user_activity(run_id: str | UUID, team_id: int, *, created_v record_task_run_user_activity as _record_user_activity, ) - _record_user_activity(run_id, team_id, created_via_code=created_via_code) + _record_user_activity(run_id, team_id, compute_source=compute_source) def get_task_run_sandbox_connection( @@ -3241,7 +3244,7 @@ def bootstrap_task_run( user_id: int | None, *, validated_data: dict, - created_via_code: bool = False, + compute_source: ComputeSource | None = None, ) -> contracts.TaskRunCreateResult | None: """Create a task run (without starting execution) from validated bootstrap data. @@ -3380,7 +3383,7 @@ def bootstrap_task_run( mode=mode, branch=branch, extra_state=extra_state, - created_via_code=created_via_code, + compute_source=compute_source, ) if imported_mcp_servers or relayed_mcp_servers: @@ -3481,7 +3484,7 @@ def start_task_run( user_id: int | None, *, validated_data: dict, - created_via_code: bool = False, + compute_source: ComputeSource | None = None, ) -> tuple[str, UUID | None]: """Apply run-scoped attachments and trigger the cloud workflow for a startable run. @@ -3498,8 +3501,8 @@ def start_task_run( if run is None: return "not_found", None task = run.task - run.created_via_code = created_via_code - run.save(update_fields=["created_via_code", "updated_at"]) + run.compute_source = compute_source + run.save(update_fields=["compute_source", "updated_at"]) pending_user_message = validated_data.get("pending_user_message") pending_user_artifact_ids = validated_data.get("pending_user_artifact_ids") or [] @@ -3550,7 +3553,7 @@ def resume_task_run_in_cloud( team_id: int, user_id: int | None, *, - created_via_code: bool = False, + compute_source: ComputeSource | None = None, ) -> tuple[str, contracts.TaskRunDetailDTO | None, str | None]: """Resume a run in a cloud sandbox, terminating any prior workflow. @@ -3620,8 +3623,8 @@ def resume_task_run_in_cloud( prior_environment = run.environment prior_completed_at = run.completed_at prior_state = dict(run.state or {}) - prior_created_via_code = run.created_via_code - run.created_via_code = created_via_code + prior_compute_source = run.compute_source + run.compute_source = compute_source run.prepare_for_cloud_handoff() logger.info("Resuming task run in cloud", extra={"task_run_id": str(run.id), "task_id": str(run.task_id)}) @@ -3641,7 +3644,7 @@ def resume_task_run_in_cloud( run.environment = prior_environment run.completed_at = prior_completed_at run.state = prior_state - run.created_via_code = prior_created_via_code + run.compute_source = prior_compute_source run.error_message = "Failed to start cloud workflow" run.save( update_fields=[ @@ -3649,7 +3652,7 @@ def resume_task_run_in_cloud( "environment", "completed_at", "state", - "created_via_code", + "compute_source", "error_message", "updated_at", ] diff --git a/products/tasks/backend/logic/services/sandbox_usage.py b/products/tasks/backend/logic/services/sandbox_usage.py index 0523ac0105d9..943f670a6689 100644 --- a/products/tasks/backend/logic/services/sandbox_usage.py +++ b/products/tasks/backend/logic/services/sandbox_usage.py @@ -25,7 +25,7 @@ import structlog from products.tasks.backend.logic.services.sandbox import SandboxConfig -from products.tasks.backend.models import SandboxSession, TaskRun +from products.tasks.backend.models import ComputeSource, SandboxSession, TaskRun logger = structlog.get_logger(__name__) @@ -63,7 +63,7 @@ def open_sandbox_session( "id", "team_id", "state", - "created_via_code", + "compute_source", "task__origin_product", "task__loop__internal", ) @@ -76,7 +76,7 @@ def open_sandbox_session( "team_id": run.team_id, "task_run_id": run.id, "origin_product": run.task.origin_product, - "created_via_code": run.created_via_code, + "compute_source": run.compute_source, "loop_internal": loop.internal if loop is not None else None, "prewarmed": bool(state.get("prewarmed")), "vm_runtime": config.is_vm, @@ -120,23 +120,27 @@ def close_sandbox_session(sandbox_id: str, *, reason: str) -> None: @_best_effort -def record_task_run_user_activity(run_id: str | UUID, team_id: int, *, created_via_code: bool = False) -> None: +def record_task_run_user_activity( + run_id: str | UUID, team_id: int, *, compute_source: ComputeSource | None = None +) -> None: """Stamp a user message against the run's open sandbox sessions. - Sets ``last_user_activity_at`` on every message and ``user_attributed_at`` - set-if-NULL, so the first message both claims a warm sandbox and self-heals the - race where a claim lands mid-provision (before ``open_sandbox_session`` read the - run state). + Sets ``last_user_activity_at`` on every message. The first message atomically + claims a warm sandbox and fixes its compute source, including when the claim + lands mid-provision. """ now = timezone.now() run_uuid = run_id if isinstance(run_id, UUID) else UUID(run_id) - if created_via_code: - TaskRun.objects.filter(id=run_uuid, team_id=team_id).update(created_via_code=True) open_sessions = SandboxSession.objects.for_team(team_id).filter(task_run_id=run_uuid, ended_at__isnull=True) - if created_via_code: - open_sessions.update(created_via_code=True) + claim_updates: dict[str, object] = {"user_attributed_at": now} + if compute_source is not None: + claim_updates["compute_source"] = compute_source + claimed = open_sessions.filter(user_attributed_at__isnull=True).update(**claim_updates) + if claimed and compute_source is not None: + TaskRun.objects.filter(id=run_uuid, team_id=team_id, compute_source__isnull=True).update( + compute_source=compute_source + ) open_sessions.update(last_user_activity_at=now) - open_sessions.filter(user_attributed_at__isnull=True).update(user_attributed_at=now) @dataclass(frozen=True) diff --git a/products/tasks/backend/logic/services/tests/test_sandbox_usage.py b/products/tasks/backend/logic/services/tests/test_sandbox_usage.py index 7de596f033d8..87f5ef15d3f6 100644 --- a/products/tasks/backend/logic/services/tests/test_sandbox_usage.py +++ b/products/tasks/backend/logic/services/tests/test_sandbox_usage.py @@ -14,7 +14,7 @@ open_sandbox_session, record_task_run_user_activity, ) -from products.tasks.backend.models import Loop, SandboxSession, Task, TaskRun +from products.tasks.backend.models import ComputeSource, Loop, SandboxSession, Task, TaskRun def _config(**overrides) -> SandboxConfig: @@ -24,7 +24,7 @@ def _config(**overrides) -> SandboxConfig: class SandboxUsageBase(APIBaseTest): - def _run(self, *, state: dict | None = None, created_via_code: bool | None = None) -> TaskRun: + def _run(self, *, state: dict | None = None, compute_source: ComputeSource | None = None) -> TaskRun: task = Task.objects.create( team=self.team, title="t", @@ -35,13 +35,13 @@ def _run(self, *, state: dict | None = None, created_via_code: bool | None = Non task=task, team=self.team, state=state or {}, - created_via_code=created_via_code, + compute_source=compute_source, ) class TestSandboxSessionWrites(SandboxUsageBase): def test_open_attributes_cold_runs_immediately(self): - run = self._run(created_via_code=True) + run = self._run(compute_source=ComputeSource.POSTHOG_DESKTOP) open_sandbox_session(run_id=run.id, sandbox_id="sb-cold", config=_config()) @@ -49,7 +49,7 @@ def test_open_attributes_cold_runs_immediately(self): assert session.team_id == self.team.id assert session.task_run_id == run.id assert session.origin_product == Task.OriginProduct.USER_CREATED - assert session.created_via_code is True + assert session.compute_source == ComputeSource.POSTHOG_DESKTOP assert session.user_attributed_at is not None assert session.prewarmed is False assert session.vm_runtime is False @@ -204,16 +204,28 @@ def test_facade_signal_attributes_claimed_warm_run(self): assert SandboxSession.objects.unscoped().get(sandbox_id="sb-claim").user_attributed_at is not None - def test_code_claim_updates_run_and_open_session_provenance(self): + def test_desktop_claim_updates_run_and_open_session_compute_source(self): run = self._run(state={"prewarmed": True, "await_user_message": True}) open_sandbox_session(run_id=run.id, sandbox_id="sb-code-claim", config=_config()) - record_task_run_user_activity(run.id, self.team.id, created_via_code=True) + record_task_run_user_activity(run.id, self.team.id, compute_source=ComputeSource.POSTHOG_DESKTOP) run.refresh_from_db() session = SandboxSession.objects.unscoped().get(sandbox_id="sb-code-claim") - assert run.created_via_code is True - assert session.created_via_code is True + assert run.compute_source == ComputeSource.POSTHOG_DESKTOP + assert session.compute_source == ComputeSource.POSTHOG_DESKTOP + + def test_later_desktop_activity_does_not_relabel_an_attributed_session(self): + run = self._run(state={"prewarmed": True, "await_user_message": True}) + open_sandbox_session(run_id=run.id, sandbox_id="sb-other-claim", config=_config()) + record_task_run_user_activity(run.id, self.team.id) + + record_task_run_user_activity(run.id, self.team.id, compute_source=ComputeSource.POSTHOG_DESKTOP) + + run.refresh_from_db() + session = SandboxSession.objects.unscoped().get(sandbox_id="sb-other-claim") + assert run.compute_source is None + assert session.compute_source is None class TestSandboxUsageAggregation(SandboxUsageBase): diff --git a/products/tasks/backend/migrations/0076_add_code_task_provenance.py b/products/tasks/backend/migrations/0076_add_compute_source.py similarity index 60% rename from products/tasks/backend/migrations/0076_add_code_task_provenance.py rename to products/tasks/backend/migrations/0076_add_compute_source.py index fc9f13c4f67c..b7769612a3b4 100644 --- a/products/tasks/backend/migrations/0076_add_code_task_provenance.py +++ b/products/tasks/backend/migrations/0076_add_compute_source.py @@ -9,19 +9,23 @@ class Migration(migrations.Migration): operations = [ migrations.AddField( model_name="taskrun", - name="created_via_code", - field=models.BooleanField( + name="compute_source", + field=models.CharField( + choices=[("posthog_desktop", "PostHog Desktop")], editable=False, - help_text="Whether the current cloud execution was initiated by a PostHog Code OAuth application", + help_text="Trusted surface that initiated the current cloud execution", + max_length=32, null=True, ), ), migrations.AddField( model_name="sandboxsession", - name="created_via_code", - field=models.BooleanField( + name="compute_source", + field=models.CharField( + choices=[("posthog_desktop", "PostHog Desktop")], editable=False, - help_text="PostHog Code OAuth provenance at provision time", + help_text="Trusted compute source at provision or claim time", + max_length=32, null=True, ), ), @@ -35,3 +39,4 @@ class Migration(migrations.Migration): ), ), ] + diff --git a/products/tasks/backend/migrations/max_migration.txt b/products/tasks/backend/migrations/max_migration.txt index 979df7521963..e5818894a699 100644 --- a/products/tasks/backend/migrations/max_migration.txt +++ b/products/tasks/backend/migrations/max_migration.txt @@ -1 +1 @@ -0076_add_code_task_provenance +0076_add_compute_source diff --git a/products/tasks/backend/models.py b/products/tasks/backend/models.py index c10f1982f980..755876dc80f9 100644 --- a/products/tasks/backend/models.py +++ b/products/tasks/backend/models.py @@ -54,6 +54,10 @@ LogLevel = Literal["debug", "info", "warn", "error"] +class ComputeSource(models.TextChoices): + POSTHOG_DESKTOP = "posthog_desktop", "PostHog Desktop" + + def resolve_schema(schema: type[BaseModel] | dict) -> dict: if isinstance(schema, dict): return schema @@ -385,7 +389,7 @@ def create_run( mode: str = "background", extra_state: dict | None = None, branch: str | None = None, - created_via_code: bool | None = None, + compute_source: ComputeSource | None = None, ) -> "TaskRun": state: dict = {} if self.runtime == Task.Runtime.PI else {"mode": mode} if extra_state: @@ -408,7 +412,7 @@ def create_run( **({"environment": environment} if environment else {}), state=state, branch=branch, - created_via_code=created_via_code, + compute_source=compute_source, ) task_run.publish_stream_state_event() observe_task_run_created(task_run) @@ -1534,10 +1538,12 @@ class Environment(models.TextChoices): blank=True, related_name="active_runs", ) - created_via_code = models.BooleanField( + compute_source = models.CharField( + max_length=32, + choices=ComputeSource, null=True, editable=False, - help_text="Whether the current cloud execution was initiated by a PostHog Code OAuth application", + help_text="Trusted surface that initiated the current cloud execution", ) branch = models.CharField(max_length=255, blank=True, null=True, help_text="Branch name for the run") @@ -1703,7 +1709,7 @@ def prepare_for_cloud_handoff(self) -> None: "completed_at", "error_message", "state", - "created_via_code", + "compute_source", "updated_at", ] ) @@ -2288,10 +2294,12 @@ class EndedReason(models.TextChoices): blank=True, help_text="Task origin at provision time, denormalized for per-origin aggregation", ) - created_via_code = models.BooleanField( + compute_source = models.CharField( + max_length=32, + choices=ComputeSource, null=True, editable=False, - help_text="PostHog Code OAuth provenance at provision time", + help_text="Trusted compute source at provision or claim time", ) loop_internal = models.BooleanField( null=True, diff --git a/products/tasks/backend/presentation/views/api.py b/products/tasks/backend/presentation/views/api.py index e4c959e17f58..fd82d409b903 100644 --- a/products/tasks/backend/presentation/views/api.py +++ b/products/tasks/backend/presentation/views/api.py @@ -33,7 +33,7 @@ from posthog.permissions import APIScopePermission from posthog.rate_limit import CodeInviteThrottle from posthog.renderers import ServerSentEventRenderer -from posthog.temporal.oauth import POSTHOG_CODE_OAUTH_CLIENT_IDS +from posthog.temporal.oauth import POSTHOG_DESKTOP_OAUTH_CLIENT_IDS from products.tasks.backend.facade import ( access as tasks_access, @@ -59,6 +59,7 @@ get_task_run_stream_key, run_uses_dedicated_stream, ) +from products.tasks.backend.models import ComputeSource from products.tasks.backend.presentation.serializers import ( CodeInviteRedeemRequestSerializer, ConnectionTokenResponseSerializer, @@ -158,14 +159,16 @@ def _pi_cloud_runtime_disabled_response() -> Response: ) -def _is_posthog_code_request(request) -> bool: +def _compute_source(request) -> ComputeSource | None: authenticator = request.successful_authenticator if not isinstance(authenticator, OAuthAccessTokenAuthentication): - return False + return None if "internal_run:read" in (authenticator.access_token.scope or "").split(): - return False + return None application = authenticator.access_token.application - return application is not None and application.client_id in POSTHOG_CODE_OAUTH_CLIENT_IDS + if application is not None and application.client_id in POSTHOG_DESKTOP_OAUTH_CLIENT_IDS: + return ComputeSource.POSTHOG_DESKTOP + return None TASKS_PREWARM_SANDBOX_FLAG = "tasks-prewarm-sandbox" @@ -331,14 +334,14 @@ def retrieve(self, request, pk=None, **kwargs): def create(self, request, **kwargs): serializer = self._write_serializer(request.data, serializer_class=TaskCreateSerializer) validated_data = dict(serializer.validated_data) - created_via_code = _is_posthog_code_request(request) + compute_source = _compute_source(request) if ( - created_via_code + compute_source and validated_data.get("origin_product", tasks_facade.TaskOriginProduct.USER_CREATED) != tasks_facade.TaskOriginProduct.USER_CREATED ): raise ValidationError( - {"origin_product": "PostHog Code can only use the generic tasks API for user-created tasks."} + {"origin_product": "PostHog Desktop can only use the generic tasks API for user-created tasks."} ) task = tasks_facade.create_task(self.team_id, self._user_id(), validated_data=validated_data) return Response(TaskSerializer(task).data, status=status.HTTP_201_CREATED) @@ -346,9 +349,7 @@ def create(self, request, **kwargs): @extend_schema(request=SignalReportTaskCreateSerializer, responses={200: TaskSerializer, 201: TaskSerializer}) @action(detail=False, methods=["post"], url_path="from_signal_report", required_scopes=["task:write"]) def from_signal_report(self, request, **kwargs): - if not isinstance(request.successful_authenticator, SessionAuthentication) and not _is_posthog_code_request( - request - ): + if not isinstance(request.successful_authenticator, SessionAuthentication) and not _compute_source(request): raise PermissionDenied("Signal report tasks must be started from PostHog Inbox.") serializer = self._write_serializer(request.data, serializer_class=SignalReportTaskCreateSerializer) validated_data = dict(serializer.validated_data) @@ -1028,7 +1029,7 @@ def create(self, request, *args, **kwargs): self.team_id, self._user_id(), validated_data=dict(request.validated_data), - created_via_code=_is_posthog_code_request(request), + compute_source=_compute_source(request), ) if result is None: raise NotFound("Task not found") @@ -1088,7 +1089,7 @@ def start(self, request, pk=None, **kwargs): self.team_id, self._user_id(), validated_data=dict(request.validated_data), - created_via_code=_is_posthog_code_request(request), + compute_source=_compute_source(request), ) if outcome == "not_found": raise NotFound() @@ -1770,7 +1771,7 @@ def command(self, request, pk=None, **kwargs): actor_user_id=request.user.id, message_id=str(request_id) if request_id is not None else None, steer=command_params.get("steer", False), - created_via_code=_is_posthog_code_request(request), + compute_source=_compute_source(request), ) except Exception: # A synchronous web request can't retry the way the Temporal @@ -2095,7 +2096,7 @@ def resume_in_cloud(self, request, pk=None, **kwargs): task_id, self.team_id, self._user_id(), - created_via_code=_is_posthog_code_request(request), + compute_source=_compute_source(request), ) if outcome == "not_found": raise NotFound() diff --git a/products/tasks/backend/tests/test_api.py b/products/tasks/backend/tests/test_api.py index 1c839a74bde6..c318cfa84b2d 100644 --- a/products/tasks/backend/tests/test_api.py +++ b/products/tasks/backend/tests/test_api.py @@ -38,7 +38,7 @@ from posthog.models.user_integration import UserIntegration from posthog.models.utils import generate_random_token_personal from posthog.storage import object_storage -from posthog.temporal.oauth import POSTHOG_CODE_OAUTH_CLIENT_IDS +from posthog.temporal.oauth import POSTHOG_DESKTOP_OAUTH_CLIENT_IDS from products.slack_app.backend.models import SlackThreadTaskMapping from products.tasks.backend.facade import api as tasks_facade @@ -69,6 +69,7 @@ Channel, CodeInvite, CodeInviteRedemption, + ComputeSource, SandboxCustomImage, SandboxEnvironment, SandboxSession, @@ -950,10 +951,10 @@ def _authenticate_with_session(self) -> None: self.client.force_authenticate(user=None) self.client.force_login(self.user) - def _code_oauth_client(self) -> APIClient: + def _desktop_oauth_client(self) -> APIClient: application = OAuthApplication.objects.create( name="PostHog Code", - client_id=next(iter(POSTHOG_CODE_OAUTH_CLIENT_IDS)), + client_id=next(iter(POSTHOG_DESKTOP_OAUTH_CLIENT_IDS)), client_type=OAuthApplication.CLIENT_PUBLIC, authorization_grant_type=OAuthApplication.GRANT_AUTHORIZATION_CODE, redirect_uris="posthog-code://oauth/callback", @@ -1281,16 +1282,16 @@ def test_create_task_defaults_origin_product(self): task = Task.objects.get(id=data["id"]) self.assertEqual(task.origin_product, Task.OriginProduct.USER_CREATED) - def test_create_cloud_run_records_code_oauth_provenance(self): + def test_create_cloud_run_records_desktop_compute_source(self): task = self.create_task() - response = self._code_oauth_client().post( + response = self._desktop_oauth_client().post( f"/api/projects/{self.team.id}/tasks/{task.id}/runs/", {"environment": "cloud", "mode": "interactive"}, format="json", ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertTrue(TaskRun.objects.get(id=response.json()["id"]).created_via_code) + self.assertEqual(TaskRun.objects.get(id=response.json()["id"]).compute_source, ComputeSource.POSTHOG_DESKTOP) def test_create_task_with_hogdesk_origin_product(self): # HogDesk creates Code tasks from a support ticket's Code chat with this @@ -1977,7 +1978,7 @@ def test_create_run_endpoint_creates_cloud_run_without_triggering_workflow(self, self.assertEqual(task_run.state["initial_permission_mode"], "auto") self.assertEqual(task_run.state["run_source"], "manual") self.assertEqual(task_run.state["auto_publish"], True) - self.assertFalse(task_run.created_via_code) + self.assertIsNone(task_run.compute_source) mock_workflow.assert_not_called() # is_url_allowed resolves DNS for real in CI, and example.com subdomains don't resolve. @@ -2328,11 +2329,11 @@ def test_start_run_endpoint_triggers_workflow_for_existing_cloud_run(self, mock_ ) @patch("products.tasks.backend.temporal.client.execute_task_processing_workflow") - def test_start_existing_cloud_run_records_code_oauth_provenance(self, _mock_workflow): + def test_start_existing_cloud_run_records_desktop_compute_source(self, _mock_workflow): task = self.create_task() task_run = task.create_run(environment=TaskRun.Environment.CLOUD) - response = self._code_oauth_client().post( + response = self._desktop_oauth_client().post( f"/api/projects/{self.team.id}/tasks/{task.id}/runs/{task_run.id}/start/", {}, format="json", @@ -2340,7 +2341,7 @@ def test_start_existing_cloud_run_records_code_oauth_provenance(self, _mock_work self.assertEqual(response.status_code, status.HTTP_200_OK) task_run.refresh_from_db() - self.assertTrue(task_run.created_via_code) + self.assertEqual(task_run.compute_source, ComputeSource.POSTHOG_DESKTOP) @patch("products.tasks.backend.temporal.client.execute_task_processing_workflow") def test_start_run_endpoint_starts_pi_task(self, mock_workflow): From 2f3bfb239bc22244c77a260fb8b88c5a47436505 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Wed, 29 Jul 2026 12:21:30 -0400 Subject: [PATCH 5/6] style(tasks): format compute source migration Generated-By: PostHog Code Task-Id: e55f8ea4-cc41-4e76-ad48-896b396e95ce --- products/tasks/backend/migrations/0076_add_compute_source.py | 1 - 1 file changed, 1 deletion(-) diff --git a/products/tasks/backend/migrations/0076_add_compute_source.py b/products/tasks/backend/migrations/0076_add_compute_source.py index b7769612a3b4..2084e7f1d78b 100644 --- a/products/tasks/backend/migrations/0076_add_compute_source.py +++ b/products/tasks/backend/migrations/0076_add_compute_source.py @@ -39,4 +39,3 @@ class Migration(migrations.Migration): ), ), ] - From 5a5f7191ca7e23c866b29b22e65e8f2bd4fd82b3 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Wed, 29 Jul 2026 12:30:01 -0400 Subject: [PATCH 6/6] fix(tasks): expose compute source through facade Generated-By: PostHog Code Task-Id: e55f8ea4-cc41-4e76-ad48-896b396e95ce --- products/tasks/backend/facade/api.py | 3 ++- products/tasks/backend/presentation/views/api.py | 5 ++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/products/tasks/backend/facade/api.py b/products/tasks/backend/facade/api.py index c7015eb757c3..b222fe435cec 100644 --- a/products/tasks/backend/facade/api.py +++ b/products/tasks/backend/facade/api.py @@ -58,7 +58,7 @@ ChannelFeedMessage, CodeInvite, CodeInviteRedemption, - ComputeSource, + ComputeSource as ComputeSourceModel, SandboxCustomImage, SandboxEnvironment, SandboxSession, @@ -84,6 +84,7 @@ # Value types (not ORM models), safe to expose. External callers compare against the # string-valued ``.status`` / ``.environment`` / ``.origin_product`` fields on the DTOs. TaskRunStatus = TaskRun.Status +ComputeSource = ComputeSourceModel TaskRunEnvironment = TaskRun.Environment TaskOriginProduct = Task.OriginProduct TaskRuntime = Task.Runtime diff --git a/products/tasks/backend/presentation/views/api.py b/products/tasks/backend/presentation/views/api.py index fd82d409b903..b22c1423f8b9 100644 --- a/products/tasks/backend/presentation/views/api.py +++ b/products/tasks/backend/presentation/views/api.py @@ -59,7 +59,6 @@ get_task_run_stream_key, run_uses_dedicated_stream, ) -from products.tasks.backend.models import ComputeSource from products.tasks.backend.presentation.serializers import ( CodeInviteRedeemRequestSerializer, ConnectionTokenResponseSerializer, @@ -159,7 +158,7 @@ def _pi_cloud_runtime_disabled_response() -> Response: ) -def _compute_source(request) -> ComputeSource | None: +def _compute_source(request) -> tasks_facade.ComputeSource | None: authenticator = request.successful_authenticator if not isinstance(authenticator, OAuthAccessTokenAuthentication): return None @@ -167,7 +166,7 @@ def _compute_source(request) -> ComputeSource | None: return None application = authenticator.access_token.application if application is not None and application.client_id in POSTHOG_DESKTOP_OAUTH_CLIENT_IDS: - return ComputeSource.POSTHOG_DESKTOP + return tasks_facade.ComputeSource.POSTHOG_DESKTOP return None