diff --git a/posthog/temporal/oauth.py b/posthog/temporal/oauth.py index c92cd4650793..faaea048a67c 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_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_DESKTOP_OAUTH_CLIENT_IDS = frozenset( + { + ARRAY_APP_CLIENT_ID_DEV, + ARRAY_APP_CLIENT_ID_EU, + ARRAY_APP_CLIENT_ID_US, + POSTHOG_DESKTOP_MOBILE_CLIENT_ID_EU, + POSTHOG_DESKTOP_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..b222fe435cec 100644 --- a/products/tasks/backend/facade/api.py +++ b/products/tasks/backend/facade/api.py @@ -58,6 +58,7 @@ ChannelFeedMessage, CodeInvite, CodeInviteRedemption, + ComputeSource as ComputeSourceModel, SandboxCustomImage, SandboxEnvironment, SandboxSession, @@ -83,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 @@ -2932,6 +2934,7 @@ def signal_task_run_user_message( message_id: str | None = None, actor_slack_user_id: str | None = None, steer: bool = False, + compute_source: ComputeSource | None = None, ) -> bool | None: """Queue a user_message follow-up signal on the run's workflow. @@ -2965,11 +2968,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) + 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) -> 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 @@ -2980,7 +2985,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, compute_source=compute_source) def get_task_run_sandbox_connection( @@ -3235,7 +3240,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, + compute_source: ComputeSource | None = None, ) -> contracts.TaskRunCreateResult | None: """Create a task run (without starting execution) from validated bootstrap data. @@ -3369,7 +3379,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, + compute_source=compute_source, + ) if imported_mcp_servers or relayed_mcp_servers: update_fields = ["updated_at"] @@ -3463,7 +3479,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, + compute_source: ComputeSource | None = None, ) -> tuple[str, UUID | None]: """Apply run-scoped attachments and trigger the cloud workflow for a startable run. @@ -3480,6 +3502,8 @@ def start_task_run( if run is None: return "not_found", None task = run.task + 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 [] @@ -3525,7 +3549,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, + *, + compute_source: ComputeSource | None = None, ) -> tuple[str, contracts.TaskRunDetailDTO | None, str | None]: """Resume a run in a cloud sandbox, terminating any prior workflow. @@ -3595,6 +3624,8 @@ def resume_task_run_in_cloud( prior_environment = run.environment prior_completed_at = run.completed_at prior_state = dict(run.state or {}) + 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)}) @@ -3614,8 +3645,19 @@ def resume_task_run_in_cloud( run.environment = prior_environment run.completed_at = prior_completed_at run.state = prior_state + run.compute_source = prior_compute_source 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", + "compute_source", + "error_message", + "updated_at", + ] + ) run.publish_stream_state_event() return "workflow_failed", None, None @@ -4097,6 +4139,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..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__) @@ -58,16 +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", "task__origin_product") + .select_related("task", "task__loop") + .only( + "id", + "team_id", + "state", + "compute_source", + "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, + "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, "cpu_cores": config.cpu_cores, @@ -110,19 +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) -> 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) open_sessions = SandboxSession.objects.for_team(team_id).filter(task_run_id=run_uuid, ended_at__isnull=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 814630ebe9a7..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 SandboxSession, Task, TaskRun +from products.tasks.backend.models import ComputeSource, Loop, SandboxSession, Task, TaskRun def _config(**overrides) -> SandboxConfig: @@ -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, compute_source: ComputeSource | 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 {}, + compute_source=compute_source, ) - 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(compute_source=ComputeSource.POSTHOG_DESKTOP) 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.compute_source == ComputeSource.POSTHOG_DESKTOP assert session.user_attributed_at is not None assert session.prewarmed is False assert session.vm_runtime is False @@ -90,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()) @@ -174,6 +204,29 @@ 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_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, compute_source=ComputeSource.POSTHOG_DESKTOP) + + run.refresh_from_db() + session = SandboxSession.objects.unscoped().get(sandbox_id="sb-code-claim") + 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): BEGIN = datetime(2026, 1, 2, tzinfo=UTC) diff --git a/products/tasks/backend/migrations/0076_add_compute_source.py b/products/tasks/backend/migrations/0076_add_compute_source.py new file mode 100644 index 000000000000..2084e7f1d78b --- /dev/null +++ b/products/tasks/backend/migrations/0076_add_compute_source.py @@ -0,0 +1,41 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0075_task_pin"), + ] + + operations = [ + migrations.AddField( + model_name="taskrun", + name="compute_source", + field=models.CharField( + choices=[("posthog_desktop", "PostHog Desktop")], + editable=False, + help_text="Trusted surface that initiated the current cloud execution", + max_length=32, + null=True, + ), + ), + migrations.AddField( + model_name="sandboxsession", + name="compute_source", + field=models.CharField( + choices=[("posthog_desktop", "PostHog Desktop")], + editable=False, + help_text="Trusted compute source at provision or claim time", + max_length=32, + 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/migrations/max_migration.txt b/products/tasks/backend/migrations/max_migration.txt index 718239371425..e5818894a699 100644 --- a/products/tasks/backend/migrations/max_migration.txt +++ b/products/tasks/backend/migrations/max_migration.txt @@ -1 +1 @@ -0075_task_pin +0076_add_compute_source diff --git a/products/tasks/backend/models.py b/products/tasks/backend/models.py index 0d3aa5d3737e..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 @@ -157,7 +161,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 +389,7 @@ def create_run( mode: str = "background", extra_state: dict | None = None, branch: str | None = None, + compute_source: ComputeSource | None = None, ) -> "TaskRun": state: dict = {} if self.runtime == Task.Runtime.PI else {"mode": mode} if extra_state: @@ -408,6 +412,7 @@ def create_run( **({"environment": environment} if environment else {}), state=state, branch=branch, + compute_source=compute_source, ) task_run.publish_stream_state_event() observe_task_run_created(task_run) @@ -1533,6 +1538,13 @@ class Environment(models.TextChoices): blank=True, related_name="active_runs", ) + compute_source = models.CharField( + max_length=32, + choices=ComputeSource, + null=True, + editable=False, + 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") @@ -1697,6 +1709,7 @@ def prepare_for_cloud_handoff(self) -> None: "completed_at", "error_message", "state", + "compute_source", "updated_at", ] ) @@ -2281,6 +2294,18 @@ class EndedReason(models.TextChoices): blank=True, help_text="Task origin at provision time, denormalized for per-origin aggregation", ) + compute_source = models.CharField( + max_length=32, + choices=ComputeSource, + null=True, + editable=False, + help_text="Trusted compute source at provision or claim 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)" 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..b22c1423f8b9 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_DESKTOP_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,18 @@ def _pi_cloud_runtime_disabled_response() -> Response: ) +def _compute_source(request) -> tasks_facade.ComputeSource | None: + authenticator = request.successful_authenticator + if not isinstance(authenticator, OAuthAccessTokenAuthentication): + return None + if "internal_run:read" in (authenticator.access_token.scope or "").split(): + return None + application = authenticator.access_token.application + if application is not None and application.client_id in POSTHOG_DESKTOP_OAUTH_CLIENT_IDS: + return tasks_facade.ComputeSource.POSTHOG_DESKTOP + return None + + TASKS_PREWARM_SANDBOX_FLAG = "tasks-prewarm-sandbox" TASK_RUN_STREAM_KEEPALIVE_INTERVAL_SECONDS = 20.0 @@ -272,7 +286,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 +332,34 @@ 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) + compute_source = _compute_source(request) + if ( + compute_source + and validated_data.get("origin_product", tasks_facade.TaskOriginProduct.USER_CREATED) + != tasks_facade.TaskOriginProduct.USER_CREATED + ): + raise ValidationError( + {"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) + @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 _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) + 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), + compute_source=_compute_source(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), + compute_source=_compute_source(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), + compute_source=_compute_source(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(), + compute_source=_compute_source(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..c318cfa84b2d 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_DESKTOP_OAUTH_CLIENT_IDS from products.slack_app.backend.models import SlackThreadTaskMapping from products.tasks.backend.facade import api as tasks_facade @@ -59,6 +69,7 @@ Channel, CodeInvite, CodeInviteRedemption, + ComputeSource, SandboxCustomImage, SandboxEnvironment, SandboxSession, @@ -936,6 +947,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 _desktop_oauth_client(self) -> APIClient: + application = OAuthApplication.objects.create( + name="PostHog Code", + 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", + 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 +1282,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_desktop_compute_source(self): + task = self.create_task() + 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.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 # origin. Ensure the value round-trips through the API — the serializer @@ -1354,17 +1402,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 +1431,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 +1512,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 +1526,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 +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.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. @@ -2275,6 +2328,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_desktop_compute_source(self, _mock_workflow): + task = self.create_task() + task_run = task.create_run(environment=TaskRun.Environment.CLOUD) + + response = self._desktop_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.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): 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 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. *