From 041d78a805d67df3cf54be539bb0cee5b522f763 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Sun, 12 Jul 2026 23:06:34 +0100 Subject: [PATCH 1/6] =?UTF-8?q?feat(tasks):=20channel=20feed=20messages=20?= =?UTF-8?q?=E2=80=94=20durable,=20multiplayer=20system=20announcements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces ChannelFeedMessage: the first durable, team-visible "feed message" model for task channels. Until now a channel's feed was purely a task list (GET /tasks/?channel=) with no way to record anything that isn't a task — no lifecycle events, no agent/system rows, nothing. ChannelFeedMessage adds that surface, deliberately generic: - author (FK user) + author_kind (human | system | agent) — the first system/agent-authored rows in a channel feed, not just human tasks - event (stable key) + payload (JSON) — structured + rename/i18n-safe - content — freeform escape hatch - optional client-supplied created_at so a burst of announcements orders deterministically instead of racing on insert time Endpoint: GET/POST /api/projects/{id}/task_channels/{channel_id}/feed/ (nested under the channel, personal channels owner-only, public team-visible). First event wired end-to-end: channel_created — emitted server-side in resolve_channel the moment a public channel is created, so "Ann created this context" appears in the feed no matter which client or integration created it. The desktop app (posthog/code) posts a companion context_md_building row when it launches the CONTEXT.md planning session. Layered per the tasks app conventions: model -> facade (frozen DTOs) -> DRF serializers -> viewset -> route. Full API test coverage for post/list, team visibility, personal-channel isolation, event validation, and the server-emitted channel_created (including no-reemit on resolve of an existing channel). Co-Authored-By: Claude Opus 4.8 (1M context) --- products/tasks/backend/facade/api.py | 95 +++++++++++++++++- products/tasks/backend/facade/contracts.py | 14 +++ .../migrations/0056_channelfeedmessage.py | 74 ++++++++++++++ .../backend/migrations/max_migration.txt | 2 +- products/tasks/backend/models.py | 40 ++++++++ .../tasks/backend/presentation/serializers.py | 28 ++++++ .../presentation/views/channels_api.py | 68 +++++++++++++ products/tasks/backend/routes.py | 10 +- .../tasks/backend/tests/test_channels_api.py | 97 +++++++++++++++++++ 9 files changed, 424 insertions(+), 4 deletions(-) create mode 100644 products/tasks/backend/migrations/0056_channelfeedmessage.py diff --git a/products/tasks/backend/facade/api.py b/products/tasks/backend/facade/api.py index 24272b54a5da..8ad9a387018f 100644 --- a/products/tasks/backend/facade/api.py +++ b/products/tasks/backend/facade/api.py @@ -52,6 +52,7 @@ from products.tasks.backend.mentions import resolve_mentioned_user_ids from products.tasks.backend.models import ( Channel, + ChannelFeedMessage, CodeInvite, CodeInviteRedemption, CodeWorkflowConfig, @@ -4712,13 +4713,33 @@ def list_channels(team_id: int, user_id: int | None) -> list[contracts.ChannelDT return [_channel_to_dto(channel) for channel in channels] +def _emit_channel_created(channel: Channel, user_id: int | None) -> None: + """Announce a newly-created public channel in its own feed as a system row + ("Ann created this context"). Server-emitted so the announcement appears no + matter which client (or integration) created the channel. Best-effort — a + feed-write failure must never break channel creation.""" + try: + ChannelFeedMessage.objects.create( + team_id=channel.team_id, + channel_id=channel.id, + author_id=user_id, + author_kind=ChannelFeedMessage.AuthorKind.SYSTEM, + event="channel_created", + payload={"channel_name": channel.name}, + ) + except Exception: + logger.exception("Failed to emit channel_created feed message", extra={"channel_id": str(channel.id)}) + + def resolve_channel(team_id: int, user_id: int | None, *, name: str) -> contracts.ChannelDTO | None: - """Resolve-or-create a public channel by (normalized) name. ``None`` for empty names.""" + """Resolve-or-create a public channel by (normalized) name. ``None`` for empty names. + Emits a ``channel_created`` feed message the first time a channel is created.""" normalized = normalize_channel_name(name) if not normalized: return None + created = False try: - channel, _ = Channel.objects.select_related("created_by").get_or_create( + channel, created = Channel.objects.select_related("created_by").get_or_create( team_id=team_id, name=normalized, channel_type=Channel.ChannelType.PUBLIC, @@ -4729,6 +4750,8 @@ def resolve_channel(team_id: int, user_id: int | None, *, name: str) -> contract channel = Channel.objects.select_related("created_by").get( team_id=team_id, name=normalized, channel_type=Channel.ChannelType.PUBLIC, deleted=False ) + if created: + _emit_channel_created(channel, user_id) return _channel_to_dto(channel) @@ -4763,6 +4786,74 @@ def delete_channel(channel_id: str | UUID, team_id: int) -> str: return "ok" +def _channel_feed_message_to_dto(message: ChannelFeedMessage) -> contracts.ChannelFeedMessageDTO: + return contracts.ChannelFeedMessageDTO( + id=message.id, + channel=message.channel_id, + author_kind=message.author_kind, + event=message.event, + payload=message.payload or {}, + content=message.content, + created_at=message.created_at, + author=_user_basic_info(message.author if message.author_id else None), + ) + + +def _visible_channel(channel_id: str | UUID, team_id: int, user_id: int | None) -> Channel | None: + """A channel the requester may read: any live public channel on the team, or their + own personal channel. ``None`` when it's missing or someone else's personal channel.""" + channel = Channel.objects.select_related("created_by").filter(id=channel_id, team_id=team_id, deleted=False).first() + if channel is None: + return None + if channel.channel_type == Channel.ChannelType.PERSONAL and channel.created_by_id != user_id: + return None + return channel + + +def list_channel_feed_messages( + channel_id: str | UUID, team_id: int, user_id: int | None +) -> list[contracts.ChannelFeedMessageDTO] | None: + """A channel's system-announcement feed, ascending. ``None`` when the channel isn't visible.""" + if _visible_channel(channel_id, team_id, user_id) is None: + return None + messages = ( + ChannelFeedMessage.objects.filter(channel_id=channel_id, team_id=team_id, deleted=False) + .select_related("author") + .order_by("created_at", "id") + ) + return [_channel_feed_message_to_dto(message) for message in messages] + + +def create_channel_feed_message( + channel_id: str | UUID, + team_id: int, + user_id: int | None, + *, + event: str, + payload: dict, + created_at: datetime | None = None, +) -> contracts.ChannelFeedMessageDTO | None: + """Post a system announcement into a channel's feed as the requester. ``None`` when + the channel isn't visible. The row is authored by the system; ``author`` records the + acting user so the client can render "Adam …". ``created_at`` lets a client order a + burst of announcements deterministically (else the server stamps ``now``).""" + if _visible_channel(channel_id, team_id, user_id) is None: + return None + fields: dict = { + "team_id": team_id, + "channel_id": channel_id, + "author_id": user_id, + "author_kind": ChannelFeedMessage.AuthorKind.SYSTEM, + "event": event, + "payload": payload or {}, + } + if created_at is not None: + fields["created_at"] = created_at + message = ChannelFeedMessage.objects.create(**fields) + # Fresh row: author lazy-loads once for the DTO. + return _channel_feed_message_to_dto(message) + + def _thread_message_to_dto(message: TaskThreadMessage) -> contracts.TaskThreadMessageDTO: return contracts.TaskThreadMessageDTO( id=message.id, diff --git a/products/tasks/backend/facade/contracts.py b/products/tasks/backend/facade/contracts.py index 1b243d5c9840..1cb022e31fe3 100644 --- a/products/tasks/backend/facade/contracts.py +++ b/products/tasks/backend/facade/contracts.py @@ -160,6 +160,20 @@ class TaskThreadMessageDTO: forwarded_by: "TaskUserBasicInfo | None" = None +@dataclass(frozen=True) +class ChannelFeedMessageDTO: + """The HTTP representation of one system announcement in a channel's feed.""" + + id: UUID + channel: UUID + author_kind: str + event: str + payload: dict + content: str + created_at: datetime + author: "TaskUserBasicInfo | None" = None + + @dataclass(frozen=True) class TaskMentionDTO: """One @-mention of the requesting user in a task's thread, for the mentions feed.""" diff --git a/products/tasks/backend/migrations/0056_channelfeedmessage.py b/products/tasks/backend/migrations/0056_channelfeedmessage.py new file mode 100644 index 000000000000..c9cb7c32899f --- /dev/null +++ b/products/tasks/backend/migrations/0056_channelfeedmessage.py @@ -0,0 +1,74 @@ +import uuid + +import django.utils.timezone +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("posthog", "1231_duckgresserverteam"), + ("tasks", "0055_task_artifact_registry"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ChannelFeedMessage", + fields=[ + ( + "id", + models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False), + ), + ( + "author_kind", + models.CharField( + choices=[("human", "Human"), ("system", "System"), ("agent", "Agent")], + default="system", + max_length=16, + ), + ), + ("event", models.CharField(max_length=64)), + ("payload", models.JSONField(blank=True, default=dict)), + ("content", models.TextField(blank=True, default="")), + ("deleted", models.BooleanField(default=False)), + ("created_at", models.DateTimeField(default=django.utils.timezone.now)), + ( + "author", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "channel", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="feed_messages", + to="tasks.channel", + ), + ), + ( + "team", + models.ForeignKey( + db_constraint=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="posthog.team", + ), + ), + ], + options={ + "db_table": "posthog_task_channel_feed_message", + }, + ), + migrations.AddIndex( + model_name="channelfeedmessage", + index=models.Index(fields=["channel", "created_at"], name="task_channel_feed_msg_created"), + ), + ] diff --git a/products/tasks/backend/migrations/max_migration.txt b/products/tasks/backend/migrations/max_migration.txt index 5aa11db1ff55..f14ffd9a113d 100644 --- a/products/tasks/backend/migrations/max_migration.txt +++ b/products/tasks/backend/migrations/max_migration.txt @@ -1 +1 @@ -0055_task_artifact_registry +0056_channelfeedmessage diff --git a/products/tasks/backend/models.py b/products/tasks/backend/models.py index dc5363a14587..648835496914 100644 --- a/products/tasks/backend/models.py +++ b/products/tasks/backend/models.py @@ -872,6 +872,46 @@ def __str__(self): return f"Mention of user {self.mentioned_user_id} in message {self.message_id}" +class ChannelFeedMessage(TeamScopedRootMixin): + """A durable, team-visible announcement in a channel's feed — rendered alongside + task cards as a "PostHog agent" system row (e.g. "Adam created this context"). + The channel feed is otherwise a task list, so these give channel lifecycle events + a home without shoe-horning them into tasks. ``author`` is the user whose action + produced the row (for "Adam …"); ``author_kind`` says who authored it.""" + + class AuthorKind(models.TextChoices): + HUMAN = "human", "Human" + SYSTEM = "system", "System" + AGENT = "agent", "Agent" + + # nosemgrep: prefer-uuid7-django-pk -- mirrors sibling task models in this app + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + # db_constraint=False on the team/user FKs: adding an FK constraint to those hot + # tables locks them and stalls deploys; Django still enforces the relation and + # on_delete at the app level (see safe-django-migrations.md). + team = models.ForeignKey("posthog.Team", on_delete=models.CASCADE, related_name="+", db_constraint=False) + channel = models.ForeignKey(Channel, on_delete=models.CASCADE, related_name="feed_messages") + author = models.ForeignKey( + "posthog.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="+", db_constraint=False + ) + author_kind = models.CharField(max_length=16, choices=AuthorKind, default=AuthorKind.SYSTEM) + # A stable event key the client maps to copy (e.g. "context_created"), plus a + # structured payload (e.g. {"context_name": "mobile"}) so rendering survives renames. + event = models.CharField(max_length=64) + payload = models.JSONField(default=dict, blank=True) + # Optional freeform fallback when there is no structured event. + content = models.TextField(blank=True, default="") + deleted = models.BooleanField(default=False) + created_at = models.DateTimeField(default=django_timezone.now) + + class Meta: + db_table = "posthog_task_channel_feed_message" + indexes = [models.Index(fields=["channel", "created_at"], name="task_channel_feed_msg_created")] + + def __str__(self): + return f"Feed message {self.id} on channel {self.channel_id}" + + class TaskAutomationManager(models.Manager): def get_queryset(self): return ( diff --git a/products/tasks/backend/presentation/serializers.py b/products/tasks/backend/presentation/serializers.py index 116d29ea9161..f8bf7609d637 100644 --- a/products/tasks/backend/presentation/serializers.py +++ b/products/tasks/backend/presentation/serializers.py @@ -18,6 +18,7 @@ from products.tasks.backend.facade import api as tasks_facade from products.tasks.backend.facade.contracts import ( ChannelDTO, + ChannelFeedMessageDTO, SandboxCustomImageDTO, SandboxEnvironmentDTO, TaskAutomationDTO, @@ -1279,6 +1280,33 @@ class TaskThreadMessageWriteSerializer(serializers.Serializer): content = serializers.CharField(help_text="Message text.") +# The lifecycle events a client may post into a channel's feed. Kept narrow so the +# feed stays a curated set of announcements, not an open write surface. +CHANNEL_FEED_EVENTS = ["context_created", "context_md_building"] + + +class ChannelFeedMessageSerializer(DataclassSerializer): + """Response shape for one system announcement in a channel's feed.""" + + author = TaskUserBasicInfoSerializer(allow_null=True, required=False) + + class Meta: + dataclass = ChannelFeedMessageDTO + fields = ["id", "channel", "author", "author_kind", "event", "payload", "content", "created_at"] + + +class ChannelFeedMessageWriteSerializer(serializers.Serializer): + """Request body for posting a system announcement into a channel's feed.""" + + event = serializers.ChoiceField(choices=CHANNEL_FEED_EVENTS, help_text="Lifecycle event key.") + payload = serializers.JSONField( + required=False, default=dict, help_text='Structured event data, e.g. {"context_name": "mobile"}.' + ) + created_at = serializers.DateTimeField( + required=False, help_text="Optional explicit timestamp, so a client can order a burst of announcements." + ) + + class TaskMentionQuerySerializer(serializers.Serializer): """Query parameters for listing mentions.""" diff --git a/products/tasks/backend/presentation/views/channels_api.py b/products/tasks/backend/presentation/views/channels_api.py index 3be9bd8f1bd2..03d8e267eced 100644 --- a/products/tasks/backend/presentation/views/channels_api.py +++ b/products/tasks/backend/presentation/views/channels_api.py @@ -15,6 +15,8 @@ from products.tasks.backend.facade import api as tasks_facade from products.tasks.backend.presentation.serializers import ( + ChannelFeedMessageSerializer, + ChannelFeedMessageWriteSerializer, ChannelSerializer, ChannelWriteSerializer, TaskMentionQuerySerializer, @@ -95,6 +97,72 @@ def destroy(self, request, pk=None, **kwargs): return Response(status=status.HTTP_204_NO_CONTENT) +class ChannelFeedMessageViewSet(TeamAndOrgViewSetMixin, viewsets.GenericViewSet): + """ + API for a channel's system-announcement feed — durable "PostHog agent" rows + (context created, CONTEXT.md being built) rendered alongside the channel's task + cards. Read by any team member for a public channel; personal channels are owner-only. + """ + + authentication_classes = [ + SessionAuthentication, + PersonalAPIKeyAuthentication, + OAuthAccessTokenAuthentication, + ] + permission_classes = [IsAuthenticated, APIScopePermission] + scope_object = "task" + http_method_names = ["get", "post", "head", "options"] + serializer_class = ChannelFeedMessageSerializer + + def _channel_id(self) -> str: + channel_id = self.kwargs.get("parent_lookup_channel_id") + if not channel_id: + raise NotFound("Channel ID is required") + try: + UUID(channel_id) + except (ValueError, TypeError): + raise NotFound("Channel not found") + return channel_id + + def _user_id(self) -> int | None: + return getattr(self.request.user, "id", None) + + @extend_schema( + responses={ + 200: OpenApiResponse( + response=ChannelFeedMessageSerializer(many=True), description="Feed messages, chronological" + ) + }, + summary="List channel feed messages", + description="A channel's system announcements in chronological order.", + ) + def list(self, request, *args, **kwargs): + messages = tasks_facade.list_channel_feed_messages(self._channel_id(), self.team_id, self._user_id()) + if messages is None: + raise NotFound("Channel not found") + return Response(ChannelFeedMessageSerializer(messages, many=True).data) + + @extend_schema( + request=ChannelFeedMessageWriteSerializer, + responses={201: ChannelFeedMessageSerializer}, + summary="Post a channel feed message", + ) + def create(self, request, **kwargs): + serializer = ChannelFeedMessageWriteSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + message = tasks_facade.create_channel_feed_message( + self._channel_id(), + self.team_id, + self._user_id(), + event=serializer.validated_data["event"], + payload=serializer.validated_data.get("payload") or {}, + created_at=serializer.validated_data.get("created_at"), + ) + if message is None: + raise NotFound("Channel not found") + return Response(ChannelFeedMessageSerializer(message).data, status=status.HTTP_201_CREATED) + + class TaskMentionViewSet(TeamAndOrgViewSetMixin, viewsets.GenericViewSet): """ API for the requester's mentions feed — thread messages across the team's tasks diff --git a/products/tasks/backend/routes.py b/products/tasks/backend/routes.py index d59500129c6d..293acf2ba607 100644 --- a/products/tasks/backend/routes.py +++ b/products/tasks/backend/routes.py @@ -20,7 +20,15 @@ def register_routes(routers: RouterRegistry) -> None: project_tasks_router.register( r"thread_messages", channels.TaskThreadMessageViewSet, "project_task_thread_messages", ["team_id", "task_id"] ) - routers.projects.register(r"task_channels", channels.ChannelViewSet, "project_task_channels", ["team_id"]) + project_task_channels_router = routers.projects.register( + r"task_channels", channels.ChannelViewSet, "project_task_channels", ["team_id"] + ) + project_task_channels_router.register( + r"feed", + channels.ChannelFeedMessageViewSet, + "project_task_channel_feed", + ["team_id", "channel_id"], + ) routers.projects.register(r"task_mentions", channels.TaskMentionViewSet, "project_task_mentions", ["team_id"]) routers.projects.register(r"task_automations", tasks.TaskAutomationViewSet, "project_task_automations", ["team_id"]) routers.projects.register( diff --git a/products/tasks/backend/tests/test_channels_api.py b/products/tasks/backend/tests/test_channels_api.py index 3f7b37b0aced..1125e5e9885e 100644 --- a/products/tasks/backend/tests/test_channels_api.py +++ b/products/tasks/backend/tests/test_channels_api.py @@ -329,3 +329,100 @@ def test_mentions_are_team_scoped(self): self.assertEqual(self.peer_client.get(self._mentions_url()).json(), []) other_team_mentions = self.peer_client.get(f"/api/projects/{other_team.id}/task_mentions/").json() self.assertEqual(len(other_team_mentions), 1) + + +class ChannelFeedMessageAPITestCase(TestCase): + def setUp(self) -> None: + self.organization = Organization.objects.create(name="Feed Org") + self.team = Team.objects.create(organization=self.organization, name="Feed Team") + self.user = User.objects.create_user(email="owner@example.com", first_name="Ann", password="password") + self.other_user = User.objects.create_user(email="peer@example.com", first_name="Bob", password="password") + for user in (self.user, self.other_user): + self.organization.members.add(user) + OrganizationMembership.objects.filter(user=user, organization=self.organization).update( + level=OrganizationMembership.Level.ADMIN + ) + + self.client = APIClient() + self.client.force_authenticate(self.user) + self.other_client = APIClient() + self.other_client.force_authenticate(self.other_user) + + def _channels_url(self) -> str: + return f"/api/projects/{self.team.id}/task_channels/" + + def _feed_url(self, channel_id) -> str: + return f"/api/projects/{self.team.id}/task_channels/{channel_id}/feed/" + + def _public_channel(self) -> str: + return self.client.post(self._channels_url(), {"name": "mobile"}).json()["id"] + + def test_post_and_list_feed_message(self): + channel_id = self._public_channel() + response = self.client.post( + self._feed_url(channel_id), + {"event": "context_created", "payload": {"context_name": "mobile"}}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.content) + body = response.json() + self.assertEqual(body["event"], "context_created") + self.assertEqual(body["author_kind"], "system") + self.assertEqual(body["author"]["id"], self.user.id) + self.assertEqual(body["payload"], {"context_name": "mobile"}) + + listing = self.client.get(self._feed_url(channel_id)).json() + # Creating the channel auto-emits a channel_created row, so the feed holds + # both that and the posted context_created. + self.assertEqual([m["event"] for m in listing], ["channel_created", "context_created"]) + self.assertIn(body["id"], [m["id"] for m in listing]) + + def test_feed_message_is_visible_to_the_team(self): + channel_id = self._public_channel() + self.client.post( + self._feed_url(channel_id), + {"event": "context_md_building", "payload": {"context_name": "mobile"}}, + format="json", + ) + peer_listing = self.other_client.get(self._feed_url(channel_id)).json() + events = [m["event"] for m in peer_listing] + # The peer sees the team-visible feed: the auto channel_created + the post. + self.assertEqual(events, ["channel_created", "context_md_building"]) + + def test_invalid_event_is_rejected(self): + channel_id = self._public_channel() + response = self.client.post(self._feed_url(channel_id), {"event": "nope"}, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_unknown_channel_is_404(self): + response = self.client.get(self._feed_url("00000000-0000-0000-0000-000000000000")) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_personal_channel_feed_is_owner_only(self): + # Listing provisions the requester's personal channel. + mine = self.client.get(self._channels_url()).json() + personal_id = next(c["id"] for c in mine if c["channel_type"] == "personal") + self.client.post( + self._feed_url(personal_id), + {"event": "context_created", "payload": {"context_name": "me"}}, + format="json", + ) + # A peer cannot read someone else's personal channel feed. + peer = self.other_client.get(self._feed_url(personal_id)) + self.assertEqual(peer.status_code, status.HTTP_404_NOT_FOUND) + + def test_channel_creation_emits_channel_created(self): + channel_id = self._public_channel() + feed = self.client.get(self._feed_url(channel_id)).json() + created = [m for m in feed if m["event"] == "channel_created"] + self.assertEqual(len(created), 1) + self.assertEqual(created[0]["author_kind"], "system") + self.assertEqual(created[0]["author"]["id"], self.user.id) + self.assertEqual(created[0]["payload"], {"channel_name": "mobile"}) + + def test_resolving_existing_channel_does_not_reemit(self): + channel_id = self._public_channel() + # Resolve the same name again — must not add a second channel_created. + self.client.post(self._channels_url(), {"name": "mobile"}) + feed = self.client.get(self._feed_url(channel_id)).json() + self.assertEqual(len([m for m in feed if m["event"] == "channel_created"]), 1) From 19f49c68c825076a77a63e7cc33eec7e74bfe322 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Sun, 12 Jul 2026 23:39:02 +0100 Subject: [PATCH 2/6] fix(tasks): bound client-supplied feed created_at + review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clamp created_at to ±10min of now (it exists for burst ordering, not backdating), test the ordering it was added for, test the cross-team boundary, and document the team_scope() requirement for future non-request emitters plus the unpaginated-list assumption. Co-Authored-By: Claude Fable 5 --- products/tasks/backend/facade/api.py | 9 ++++- .../tasks/backend/presentation/serializers.py | 16 +++++++- .../tasks/backend/tests/test_channels_api.py | 40 +++++++++++++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/products/tasks/backend/facade/api.py b/products/tasks/backend/facade/api.py index 8ad9a387018f..5a9327a6f418 100644 --- a/products/tasks/backend/facade/api.py +++ b/products/tasks/backend/facade/api.py @@ -4717,7 +4717,10 @@ def _emit_channel_created(channel: Channel, user_id: int | None) -> None: """Announce a newly-created public channel in its own feed as a system row ("Ann created this context"). Server-emitted so the announcement appears no matter which client (or integration) created the channel. Best-effort — a - feed-write failure must never break channel creation.""" + feed-write failure must never break channel creation. The fail-closed + ``TeamScopedManager`` raises without team context, so callers outside a + request (temporal, MCP) must wrap in ``team_scope()`` or the announcement + is swallowed here and only logged.""" try: ChannelFeedMessage.objects.create( team_id=channel.team_id, @@ -4813,7 +4816,9 @@ def _visible_channel(channel_id: str | UUID, team_id: int, user_id: int | None) def list_channel_feed_messages( channel_id: str | UUID, team_id: int, user_id: int | None ) -> list[contracts.ChannelFeedMessageDTO] | None: - """A channel's system-announcement feed, ascending. ``None`` when the channel isn't visible.""" + """A channel's system-announcement feed, ascending. ``None`` when the channel isn't visible. + Unpaginated: the feed holds rare lifecycle events. Add pagination before any + per-task or per-thread event lands here.""" if _visible_channel(channel_id, team_id, user_id) is None: return None messages = ( diff --git a/products/tasks/backend/presentation/serializers.py b/products/tasks/backend/presentation/serializers.py index f8bf7609d637..529fe7c12304 100644 --- a/products/tasks/backend/presentation/serializers.py +++ b/products/tasks/backend/presentation/serializers.py @@ -1,9 +1,12 @@ import base64 import logging import binascii +from datetime import datetime, timedelta from typing import Any, cast from zoneinfo import available_timezones +from django.utils import timezone as django_timezone + import posthoganalytics from croniter import croniter from drf_spectacular.utils import PolymorphicProxySerializer @@ -1295,6 +1298,11 @@ class Meta: fields = ["id", "channel", "author", "author_kind", "event", "payload", "content", "created_at"] +# A client-supplied created_at exists only to order a burst of announcements posted +# in quick succession; anything beyond this window is backdating, not ordering. +CHANNEL_FEED_CREATED_AT_WINDOW = timedelta(minutes=10) + + class ChannelFeedMessageWriteSerializer(serializers.Serializer): """Request body for posting a system announcement into a channel's feed.""" @@ -1303,9 +1311,15 @@ class ChannelFeedMessageWriteSerializer(serializers.Serializer): required=False, default=dict, help_text='Structured event data, e.g. {"context_name": "mobile"}.' ) created_at = serializers.DateTimeField( - required=False, help_text="Optional explicit timestamp, so a client can order a burst of announcements." + required=False, + help_text="Optional explicit timestamp (within 10 minutes of now), so a client can order a burst of announcements.", ) + def validate_created_at(self, value: datetime) -> datetime: + if abs(django_timezone.now() - value) > CHANNEL_FEED_CREATED_AT_WINDOW: + raise serializers.ValidationError("created_at must be within 10 minutes of the current time.") + return value + class TaskMentionQuerySerializer(serializers.Serializer): """Query parameters for listing mentions.""" diff --git a/products/tasks/backend/tests/test_channels_api.py b/products/tasks/backend/tests/test_channels_api.py index 1125e5e9885e..87bf08c6eb72 100644 --- a/products/tasks/backend/tests/test_channels_api.py +++ b/products/tasks/backend/tests/test_channels_api.py @@ -1,6 +1,9 @@ +from datetime import timedelta + from unittest.mock import patch from django.test import TestCase +from django.utils import timezone as django_timezone from rest_framework import status from rest_framework.test import APIClient @@ -426,3 +429,40 @@ def test_resolving_existing_channel_does_not_reemit(self): self.client.post(self._channels_url(), {"name": "mobile"}) feed = self.client.get(self._feed_url(channel_id)).json() self.assertEqual(len([m for m in feed if m["event"] == "channel_created"]), 1) + + def test_client_created_at_orders_a_burst(self): + channel_id = self._public_channel() + now = django_timezone.now() + # Post out of order with explicit timestamps; the feed must sort by created_at. + second = (now + timedelta(seconds=2)).isoformat() + first = (now + timedelta(seconds=1)).isoformat() + self.client.post( + self._feed_url(channel_id), + {"event": "context_md_building", "payload": {}, "created_at": second}, + format="json", + ) + self.client.post( + self._feed_url(channel_id), + {"event": "context_created", "payload": {}, "created_at": first}, + format="json", + ) + events = [m["event"] for m in self.client.get(self._feed_url(channel_id)).json()] + self.assertEqual(events, ["channel_created", "context_created", "context_md_building"]) + + def test_created_at_outside_window_is_rejected(self): + channel_id = self._public_channel() + for delta in (timedelta(hours=-1), timedelta(hours=1)): + stamp = (django_timezone.now() + delta).isoformat() + response = self.client.post( + self._feed_url(channel_id), + {"event": "context_created", "created_at": stamp}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, stamp) + + def test_feed_is_team_scoped(self): + channel_id = self._public_channel() + other_team = Team.objects.create(organization=self.organization, name="Other Team") + # Same org, wrong team in the URL — the channel must not resolve. + response = self.client.get(f"/api/projects/{other_team.id}/task_channels/{channel_id}/feed/") + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) From 25490dd2b1ccc6eec043fa880ba86cbaddff43c7 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Mon, 13 Jul 2026 13:47:00 +0100 Subject: [PATCH 3/6] feat(tasks): post agent thread updates for canvas creation and turn completion (#70371) Co-authored-by: tests-posthog[bot] <250237707+tests-posthog[bot]@users.noreply.github.com> --- .../security/idor-team-scoped-models.yaml | 2 + posthog/api/file_system/file_system.py | 57 +++++- .../file_system/test/test_canvas_publish.py | 84 +++++++- products/tasks/backend/facade/api.py | 128 +++++++++++- products/tasks/backend/facade/contracts.py | 3 + products/tasks/backend/mentions.py | 10 + .../0057_taskthreadmessage_agent_fields.py | 29 +++ .../backend/migrations/max_migration.txt | 2 +- products/tasks/backend/models.py | 20 +- .../tasks/backend/presentation/serializers.py | 13 +- .../activities/relay_sandbox_events.py | 41 ++++ .../backend/tests/test_thread_updates.py | 186 ++++++++++++++++++ .../tasks/frontend/generated/api.schemas.ts | 67 +++++++ products/tasks/frontend/generated/api.ts | 64 ++++++ products/tasks/frontend/generated/api.zod.ts | 27 +++ products/tasks/mcp/tools.yaml | 6 + services/mcp/src/api/generated.ts | 68 +++++++ 17 files changed, 798 insertions(+), 9 deletions(-) create mode 100644 products/tasks/backend/migrations/0057_taskthreadmessage_agent_fields.py create mode 100644 products/tasks/backend/tests/test_thread_updates.py diff --git a/.semgrep/rules/security/idor-team-scoped-models.yaml b/.semgrep/rules/security/idor-team-scoped-models.yaml index 8ab093dd0016..3e1ed02c74a7 100644 --- a/.semgrep/rules/security/idor-team-scoped-models.yaml +++ b/.semgrep/rules/security/idor-team-scoped-models.yaml @@ -259,6 +259,7 @@ rules: |TaskThreadMessage |TaskThreadMessageMention |Channel + |ChannelFeedMessage |EmailChannel |EvaluationReport |Text @@ -542,6 +543,7 @@ rules: |TaskThreadMessage |TaskThreadMessageMention |Channel + |ChannelFeedMessage |EmailChannel |EvaluationReport |Text diff --git a/posthog/api/file_system/file_system.py b/posthog/api/file_system/file_system.py index 84c81ddd4b61..e79f4d0e6e74 100644 --- a/posthog/api/file_system/file_system.py +++ b/posthog/api/file_system/file_system.py @@ -3,8 +3,9 @@ import shlex import builtins from typing import Any, cast -from uuid import uuid4 +from uuid import UUID, uuid4 +from django.conf import settings from django.db import transaction from django.db.models import Case, F, IntegerField, Q, QuerySet, Value, When from django.db.models.functions import Concat, Lower @@ -63,6 +64,8 @@ from posthog.models.user import User from posthog.utils import str_to_bool +from products.tasks.backend.facade import api as tasks_facade + DELETE_PREVIEW_ENTRY_LIMIT = 200 # Search-within-Recents scans this many of the user's most-recent views, then the text filter trims @@ -1132,6 +1135,7 @@ def publish_canvas(self, request: Request, *args: Any, **kwargs: Any) -> Respons if isinstance(existing_context, str): version["context"] = existing_context versions = list(meta.get("versions") or []) + first_publish = not versions and not meta.get("code") versions.append(version) meta.update( @@ -1156,8 +1160,59 @@ def publish_canvas(self, request: Request, *args: Any, **kwargs: Any) -> Respons dashboard.save(update_fields=update_fields) + if first_publish: + self._announce_canvas_created(request, dashboard) + return Response(self.get_serializer(dashboard).data) + def _announce_canvas_created(self, request: Request, dashboard: FileSystem) -> None: + """Announce a canvas's first publish in the generating task's thread. + + The task sandbox stamps every MCP call with an X-PostHog-Task-Id header, so + a publish is attributable to the task that made it. The sandbox authenticates + with the task creator's credentials, so the facade only accepts a task created + by the requesting user — the header can't point the announcement at someone + else's task thread. No header (a human or app save) means no announcement. + """ + raw_task_id = (request.headers.get("X-PostHog-Task-Id") or "").strip() + try: + task_id = UUID(raw_task_id) + except ValueError: + return + user = request.user if isinstance(request.user, User) else None + segments = split_path(dashboard.path) + tasks_facade.post_canvas_created_thread_update( + task_id, + self.team_id, + acting_user_id=user.id if user else None, + canvas_name=segments[-1] if segments else "Canvas", + canvas_url=self._canvas_share_url(dashboard), + ) + + def _canvas_share_url(self, dashboard: FileSystem) -> str | None: + """The web interstitial link that deep-links into the desktop app's canvas view: + `/code/canvas//`. The channel id is stamped on + the row's meta by the desktop app at create time; fall back to the parent folder + row for rows that predate the stamp. + """ + channel_id = (dashboard.meta or {}).get("channelId") + if not channel_id: + parent_path = join_path(split_path(dashboard.path)[:-1]) + folder = ( + FileSystem.objects.filter( + surface_q(self.file_system_surface), + team_id=dashboard.team_id, + type="folder", + path=parent_path, + ).first() + if parent_path + else None + ) + channel_id = str(folder.id) if folder else None + if not channel_id: + return None + return f"{settings.SITE_URL}/code/canvas/{channel_id}/{dashboard.id}" + @extend_schema(responses={200: FolderInstructionsSerializer}) @action(methods=["GET"], detail=True) def instructions(self, request: Request, *args: Any, **kwargs: Any) -> Response: diff --git a/posthog/api/file_system/test/test_canvas_publish.py b/posthog/api/file_system/test/test_canvas_publish.py index 791c929bdce0..d918e22b940d 100644 --- a/posthog/api/file_system/test/test_canvas_publish.py +++ b/posthog/api/file_system/test/test_canvas_publish.py @@ -1,10 +1,18 @@ -from typing import cast +from typing import TYPE_CHECKING, cast from posthog.test.base import APIBaseTest +from unittest.mock import patch + +from django.apps import apps +from django.conf import settings from rest_framework import status from posthog.models.file_system.file_system import FileSystem +from posthog.models.user import User + +if TYPE_CHECKING: + from products.tasks.backend.models import Task class TestDesktopCanvasPublishAPI(APIBaseTest): @@ -96,6 +104,80 @@ def test_publish_canvas_requires_code(self): self.assertEqual(bad.status_code, status.HTTP_400_BAD_REQUEST, bad.json()) self.assertIn("code", bad.json()) + # Task models load via the app registry: this test lives outside the isolated + # tasks product, so it can't import its internals (tach-enforced). + def _create_task(self) -> "Task": + Task = apps.get_model("tasks", "Task") + return Task.objects.create( + team=self.team, + title="Generate canvas", + description="", + origin_product=Task.OriginProduct.USER_CREATED, + created_by=self.user, + ) + + def _thread_messages(self, task: "Task"): + TaskThreadMessage = apps.get_model("tasks", "TaskThreadMessage") + return TaskThreadMessage.objects.for_team(self.team.id).filter(task=task) + + @patch("products.tasks.backend.facade.api.posthoganalytics.feature_enabled", return_value=True) + def test_first_publish_from_task_announces_in_thread_once(self, _flag): + task = self._create_task() + item_id = self._create_dashboard(meta={"channelId": "chan-1"}) + + self.client.patch(self._canvas_url(item_id), {"code": "v1"}, HTTP_X_POSTHOG_TASK_ID=str(task.id)) + + messages = self._thread_messages(task) + self.assertEqual(messages.count(), 1) + message = messages.get() + self.assertIsNone(message.author_id) + self.assertEqual( + message.content, + f"[MyCanvas]({settings.SITE_URL}/code/canvas/chan-1/{item_id}) has been created", + ) + + # A second publish updates the canvas, it doesn't create it again. + self.client.patch(self._canvas_url(item_id), {"code": "v2"}, HTTP_X_POSTHOG_TASK_ID=str(task.id)) + self.assertEqual(messages.count(), 1) + + @patch("products.tasks.backend.facade.api.posthoganalytics.feature_enabled", return_value=True) + def test_announcement_links_via_parent_folder_when_meta_has_no_channel(self, _flag): + task = self._create_task() + item_id = self._create_dashboard() # no channelId stamp — rows created before the app stamped it + + self.client.patch(self._canvas_url(item_id), {"code": "v1"}, HTTP_X_POSTHOG_TASK_ID=str(task.id)) + + folder = FileSystem.objects.get(team=self.team, path="MyChannel", type="folder") + message = self._thread_messages(task).get() + self.assertTrue(message.content.startswith(f"[MyCanvas]({settings.SITE_URL}/code/canvas/{folder.id}/")) + + @patch("products.tasks.backend.facade.api.posthoganalytics.feature_enabled", return_value=True) + def test_header_naming_someone_elses_task_stays_silent(self, _flag): + # The header selects the announcement's thread; it must not let a publisher + # plant agent messages in a task they didn't create. + other = User.objects.create_and_join(self.organization, "other@posthog.com", None) + Task = apps.get_model("tasks", "Task") + task = Task.objects.create( + team=self.team, + title="Someone else's task", + description="", + origin_product=Task.OriginProduct.USER_CREATED, + created_by=other, + ) + item_id = self._create_dashboard() + + self.client.patch(self._canvas_url(item_id), {"code": "v1"}, HTTP_X_POSTHOG_TASK_ID=str(task.id)) + + self.assertFalse(self._thread_messages(task).exists()) + + def test_publish_without_task_attribution_stays_silent(self): + item_id = self._create_dashboard() + + self.client.patch(self._canvas_url(item_id), {"code": "v1"}) + + TaskThreadMessage = apps.get_model("tasks", "TaskThreadMessage") + self.assertFalse(TaskThreadMessage.objects.for_team(self.team.id).exists()) + def test_delete_canvas_removes_ref_less_dashboard_row(self): # Desktop canvases are `dashboard`-typed rows with no ref; deleting one must not # trip the "without a reference" guard meant for real object-backed rows. diff --git a/products/tasks/backend/facade/api.py b/products/tasks/backend/facade/api.py index 5a9327a6f418..696dfd28ba6d 100644 --- a/products/tasks/backend/facade/api.py +++ b/products/tasks/backend/facade/api.py @@ -24,7 +24,7 @@ from uuid import UUID, uuid4 from django.conf import settings -from django.db import IntegrityError, transaction +from django.db import IntegrityError, close_old_connections, transaction from django.db.models import CharField, Count, Exists, F, Min, OuterRef, Q, QuerySet, Subquery from django.db.models.fields.json import KeyTextTransform from django.utils import timezone as django_timezone @@ -49,7 +49,7 @@ is_custom_images_enabled, read_spec_from_builder_sandbox, ) -from products.tasks.backend.mentions import resolve_mentioned_user_ids +from products.tasks.backend.mentions import format_mention_token, resolve_mentioned_user_ids from products.tasks.backend.models import ( Channel, ChannelFeedMessage, @@ -4863,6 +4863,9 @@ def _thread_message_to_dto(message: TaskThreadMessage) -> contracts.TaskThreadMe return contracts.TaskThreadMessageDTO( id=message.id, task=message.task_id, + author_kind=message.author_kind, + event=message.event, + payload=message.payload or {}, content=message.content, created_at=message.created_at, author=_user_basic_info(message.author if message.author_id else None), @@ -5018,6 +5021,127 @@ def forward_thread_message( return "ok", _thread_message_to_dto(message) +# Threads are a Channels (project-bluebird) surface, so agent-authored thread +# updates are gated on the same flag — evaluated for the task creator. +AGENT_THREAD_UPDATES_FLAG = "project-bluebird" + +# One turn-complete post per run within the window, so an SSE relay reconnect +# replaying the tail of the stream can't double-post the same end-of-turn. +_TURN_COMPLETE_COOLDOWN_SECONDS = 30 + +# Cap the relayed final message so one agent essay can't dwarf the thread. +_TURN_MESSAGE_MAX_CHARS = 4000 + + +def _create_agent_thread_message(task: Task, content: str, *, event: str, payload: dict | None = None) -> None: + """Write an agent-authored thread message and index its mentions. + + ``content`` is the rendered text (older clients show it as-is); ``event`` + + ``payload`` are the structured record, mirroring ChannelFeedMessage, that + lets clients render agent rows natively and dedupe them against live views. + """ + message = TaskThreadMessage.objects.create( + team_id=task.team_id, + task_id=task.id, + author_id=None, + author_kind=TaskThreadMessage.AuthorKind.AGENT, + event=event, + payload=payload or {}, + content=content, + ) + try: + _index_thread_message_mentions(message) + except Exception: + logger.exception("Failed to index thread message mentions", extra={"message_id": str(message.id)}) + + +def _agent_thread_updates_enabled(creator: User | None) -> bool: + """Fail closed: no creator to key the flag on, or a flag-service error, means no post.""" + if creator is None: + return False + distinct_id = creator.distinct_id or f"user_{creator.id}" + try: + return bool( + posthoganalytics.feature_enabled(AGENT_THREAD_UPDATES_FLAG, distinct_id, send_feature_flag_events=False) + ) + except Exception: + logger.warning("Agent thread update flag check failed", extra={"user_id": creator.id}, exc_info=True) + return False + + +def post_canvas_created_thread_update( + task_id: str | UUID, team_id: int, *, acting_user_id: int | None, canvas_name: str, canvas_url: str | None +) -> None: + """Announce a freshly created canvas in the generating task's thread. + + Posts "[name](url) has been created" as an agent message. Called on a canvas's + first publish only — the caller owns that once-guard. ``acting_user_id`` must be + the task's creator: the sandbox publishes with the creator's credentials, so this + binds the attributed task to the caller's identity — a same-team caller can't + plant agent messages in someone else's task thread by naming its id. Best-effort + and never raises: the publish must not fail because its announcement couldn't + be written. + """ + try: + task = Task.objects.select_related("created_by").filter(id=task_id, team_id=team_id).first() + if task is None or task.created_by_id is None or task.created_by_id != acting_user_id: + return + if not _agent_thread_updates_enabled(task.created_by): + return + # Brackets and newlines in the name would break the [label](url) token. + name = re.sub(r"[\[\]\n]", " ", canvas_name).strip() or "Canvas" + content = f"[{name}]({canvas_url}) has been created" if canvas_url else f"{name} has been created" + _create_agent_thread_message( + task, + content, + event="canvas_created", + payload={"canvas_name": name, "canvas_url": canvas_url}, + ) + except Exception: + logger.exception("Failed to post canvas-created thread update", extra={"task_id": str(task_id)}) + + +def post_turn_complete_thread_update( + run_id: str | UUID, task_id: str | UUID, team_id: int, *, message: str | None = None +) -> None: + """Post the agent's final turn message into the task's thread, @-mentioning the task creator. + + Fires from the sandbox event relay on every end-of-turn of a channel task's + background run, so the update lands even with no client open. ``message`` is + the agent's closing prose for the turn; when the relay captured none, a plain + "Turn complete." stands in. Best-effort and never raises — a failed post must + not disturb the relay. + """ + try: + if not settings.TEST: + close_old_connections() + task = Task.objects.select_related("created_by").filter(id=task_id, team_id=team_id).first() + # Threads hang off a task's channel feed; a channel-less task has no audience. + if task is None or task.channel_id is None: + return + creator = task.created_by + if creator is None or not _agent_thread_updates_enabled(creator): + return + from products.tasks.backend.redis import get_tasks_cache # noqa: PLC0415 — keep redis off the api import path + + if not get_tasks_cache().add(f"thread_update:{run_id}:turn_complete", True, _TURN_COMPLETE_COOLDOWN_SECONDS): + return + body = (message or "").strip() or "Turn complete." + if len(body) > _TURN_MESSAGE_MAX_CHARS: + body = body[: _TURN_MESSAGE_MAX_CHARS - 1] + "…" + mention = format_mention_token(creator.get_full_name() or creator.email, creator.email) + # payload.run_id is the dedupe key: a client already rendering this run's + # live agent turns can suppress the durable row (or vice versa). + _create_agent_thread_message( + task, + f"{mention} {body}", + event="turn_complete", + payload={"run_id": str(run_id)}, + ) + except Exception: + logger.exception("Failed to post turn-complete thread update", extra={"task_id": str(task_id)}) + + def respond_to_permission_request( run_id: str | UUID, task_id: str | UUID, diff --git a/products/tasks/backend/facade/contracts.py b/products/tasks/backend/facade/contracts.py index 1cb022e31fe3..29b2007bf035 100644 --- a/products/tasks/backend/facade/contracts.py +++ b/products/tasks/backend/facade/contracts.py @@ -153,6 +153,9 @@ class TaskThreadMessageDTO: id: UUID task: UUID + author_kind: str + event: str + payload: dict content: str created_at: datetime author: "TaskUserBasicInfo | None" = None diff --git a/products/tasks/backend/mentions.py b/products/tasks/backend/mentions.py index a65daf954d2a..f0ee4cad5204 100644 --- a/products/tasks/backend/mentions.py +++ b/products/tasks/backend/mentions.py @@ -22,6 +22,16 @@ def extract_mention_emails(content: str) -> set[str]: return {match.group(1).lower() for match in MENTION_TOKEN_PATTERN.finditer(content)} +def format_mention_token(name: str, email: str) -> str: + """Serialize a user reference into the inline mention token. + + Brackets and newlines would break token parsing; the email is the identity, + so the name falls back to its local part when unusable. + """ + safe_name = re.sub(r"[\[\]\n]", " ", name).strip() or email.split("@")[0] or email + return f"@[{safe_name}]({email})" + + def resolve_mentioned_user_ids(user_model: Any, content: str, *, team_id: int, author_id: int | None) -> list[int]: """Ids of the team's org members mentioned in the content, excluding the author. diff --git a/products/tasks/backend/migrations/0057_taskthreadmessage_agent_fields.py b/products/tasks/backend/migrations/0057_taskthreadmessage_agent_fields.py new file mode 100644 index 000000000000..6ccad078fb3d --- /dev/null +++ b/products/tasks/backend/migrations/0057_taskthreadmessage_agent_fields.py @@ -0,0 +1,29 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0056_channelfeedmessage"), + ] + + operations = [ + migrations.AddField( + model_name="taskthreadmessage", + name="author_kind", + field=models.CharField( + choices=[("human", "Human"), ("system", "System"), ("agent", "Agent")], + default="human", + max_length=16, + ), + ), + migrations.AddField( + model_name="taskthreadmessage", + name="event", + field=models.CharField(blank=True, default="", max_length=64), + ), + migrations.AddField( + model_name="taskthreadmessage", + name="payload", + field=models.JSONField(blank=True, default=dict), + ), + ] diff --git a/products/tasks/backend/migrations/max_migration.txt b/products/tasks/backend/migrations/max_migration.txt index f14ffd9a113d..5c64038bac7f 100644 --- a/products/tasks/backend/migrations/max_migration.txt +++ b/products/tasks/backend/migrations/max_migration.txt @@ -1 +1 @@ -0056_channelfeedmessage +0057_taskthreadmessage_agent_fields diff --git a/products/tasks/backend/models.py b/products/tasks/backend/models.py index 648835496914..12abdc851f5a 100644 --- a/products/tasks/backend/models.py +++ b/products/tasks/backend/models.py @@ -812,9 +812,18 @@ def _dispatch() -> None: class TaskThreadMessage(TeamScopedRootMixin): - """One human message in a task's thread — the side conversation channel members - have around a task. Messages never reach the agent unless the task author - forwards one (send_to_agent), which stamps the forwarded_* fields.""" + """One message in a task's thread — the side conversation channel members have + around a task. Human messages never reach the agent unless the task author + forwards one (send_to_agent), which stamps the forwarded_* fields. Agent rows + (``author_kind=AGENT``, no ``author``) are server-emitted announcements carrying + a stable ``event`` key + ``payload`` — the same shape as ``ChannelFeedMessage`` — + so clients can render them structurally and dedupe them against live + session-derived views (e.g. ``turn_complete`` carries the run id).""" + + class AuthorKind(models.TextChoices): + HUMAN = "human", "Human" + SYSTEM = "system", "System" + AGENT = "agent", "Agent" # nosemgrep: prefer-uuid7-django-pk -- mirrors sibling task models in this app id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -826,6 +835,11 @@ class TaskThreadMessage(TeamScopedRootMixin): author = models.ForeignKey( "posthog.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="+", db_constraint=False ) + author_kind = models.CharField(max_length=16, choices=AuthorKind, default=AuthorKind.HUMAN) + # Stable event key + structured payload for non-human rows (empty for human + # messages); `content` stays the rendered text so older clients degrade cleanly. + event = models.CharField(max_length=64, blank=True, default="") + payload = models.JSONField(default=dict, blank=True) content = models.TextField() forwarded_to_agent_at = models.DateTimeField(null=True, blank=True) forwarded_by = models.ForeignKey( diff --git a/products/tasks/backend/presentation/serializers.py b/products/tasks/backend/presentation/serializers.py index 529fe7c12304..5bda37632614 100644 --- a/products/tasks/backend/presentation/serializers.py +++ b/products/tasks/backend/presentation/serializers.py @@ -1274,7 +1274,18 @@ class TaskThreadMessageSerializer(DataclassSerializer): class Meta: dataclass = TaskThreadMessageDTO - fields = ["id", "task", "content", "created_at", "author", "forwarded_to_agent_at", "forwarded_by"] + fields = [ + "id", + "task", + "author_kind", + "event", + "payload", + "content", + "created_at", + "author", + "forwarded_to_agent_at", + "forwarded_by", + ] class TaskThreadMessageWriteSerializer(serializers.Serializer): diff --git a/products/tasks/backend/temporal/process_task/activities/relay_sandbox_events.py b/products/tasks/backend/temporal/process_task/activities/relay_sandbox_events.py index 0808bfb9c66f..52259ca2ad05 100644 --- a/products/tasks/backend/temporal/process_task/activities/relay_sandbox_events.py +++ b/products/tasks/backend/temporal/process_task/activities/relay_sandbox_events.py @@ -19,6 +19,7 @@ from posthog.temporal.common.utils import close_db_connections +from products.tasks.backend.facade import api as tasks_facade from products.tasks.backend.logic.services.agent_command import validate_sandbox_url from products.tasks.backend.logic.services.connection_token import create_sandbox_connection_token from products.tasks.backend.logic.services.permission_broker import ( @@ -303,6 +304,10 @@ async def _relay_loop( last_audit_ts_ns: list[int] = [0] # track last agentsh audit timestamp # Brackets turn_started / turn_completed signals to the parent. slack_turn_active: list[bool] = [False] + # The agent's in-progress closing message for the current turn. Chunks + # accumulate; a new tool call or user message resets it, so at end-of-turn + # it holds the prose after the last tool call — what the thread update posts. + final_message_parts: list[str] = [] # ACP emits one tool_call + N tool_call_update per id; only render the start. emitted_tool_call_ids: set[str] = set() @@ -374,12 +379,31 @@ async def _relay_loop( # does sync Redis (cache.add) and a potential network call to # the feature-flag service. asyncio.create_task(asyncio.to_thread(_safe_dispatch_awaiting_input, task_run)) + if task_run is not None and task_run.mode != "interactive": + # Background run finished a turn — post its closing message + # into the task's thread so teammates following it see the + # outcome without a client open. Guards (flag, channel, + # cooldown) live in the facade; same thread hop as above + # for its sync I/O. + asyncio.create_task( + asyncio.to_thread( + tasks_facade.post_turn_complete_thread_update, + str(task_run.id), + str(task_run.task_id), + task_run.team_id, + message="".join(final_message_parts).strip() or None, + ) + ) + final_message_parts.clear() if is_agent_design_enabled and slack_turn_active[0] and workflow_handle is not None: slack_turn_active[0] = False asyncio.create_task(_signal_safely(workflow_handle, "turn_completed")) elif not agent_active[0] and _is_active_agent_update(event_data): agent_active[0] = True + if task_run is not None and task_run.mode != "interactive": + _track_final_message(event_data, final_message_parts) + # Agent-design signal fan-out: first session/update opens the # child relay; tool_call → step, agent_message_chunk → markdown. if is_agent_design_enabled and workflow_handle is not None: @@ -432,6 +456,7 @@ async def _relay_loop( reconnect_count += 1 # May have missed an end_of_turn on the dropped stream — assume idle until re-confirmed. agent_active[0] = False + final_message_parts.clear() logger.warning( "relay_sandbox_events_read_timeout", run_id=run_id, @@ -453,6 +478,7 @@ async def _relay_loop( # 5xx — transient server error, worth retrying reconnect_count += 1 agent_active[0] = False # missed-end_of_turn guard (see ReadTimeout above) + final_message_parts.clear() logger.warning( "relay_sandbox_events_http_error", run_id=run_id, @@ -465,6 +491,7 @@ async def _relay_loop( except (httpx.TransportError, httpx_sse.SSEError) as e: reconnect_count += 1 agent_active[0] = False # missed-end_of_turn guard (see ReadTimeout above) + final_message_parts.clear() logger.warning( "relay_sandbox_events_connection_error", run_id=run_id, @@ -594,6 +621,20 @@ def _tool_args_preview(raw_input: Any) -> str | None: return one_line +def _track_final_message(event_data: dict, parts: list[str]) -> None: + """Accumulate agent_message_chunk text; a new tool call or user message resets, + so `parts` ends the turn holding only the agent's closing prose.""" + text = _extract_agent_message_text(event_data) + if text: + parts.append(text) + return + if not _is_session_update(event_data): + return + update = (event_data.get("notification", {}).get("params") or {}).get("update") or {} + if update.get("sessionUpdate") in ("tool_call", "user_message", "user_message_chunk"): + parts.clear() + + def _extract_agent_message_text(event_data: dict) -> str | None: """Text delta from an ACP agent_message_chunk session/update, else None.""" notification = event_data.get("notification", {}) diff --git a/products/tasks/backend/tests/test_thread_updates.py b/products/tasks/backend/tests/test_thread_updates.py new file mode 100644 index 000000000000..4f197b309a34 --- /dev/null +++ b/products/tasks/backend/tests/test_thread_updates.py @@ -0,0 +1,186 @@ +from unittest.mock import patch + +from django.core.cache import cache +from django.test import SimpleTestCase, TestCase + +from parameterized import parameterized + +from posthog.models import Organization, OrganizationMembership, Team, User + +from products.tasks.backend.facade.api import post_canvas_created_thread_update, post_turn_complete_thread_update +from products.tasks.backend.models import Channel, Task, TaskRun, TaskThreadMessage, TaskThreadMessageMention +from products.tasks.backend.temporal.process_task.activities.relay_sandbox_events import _track_final_message + +_FLAG_TARGET = "products.tasks.backend.facade.api.posthoganalytics.feature_enabled" + + +class TestAgentThreadUpdates(TestCase): + def setUp(self) -> None: + cache.clear() + self.organization = Organization.objects.create(name="Test Org") + self.team = Team.objects.create(organization=self.organization, name="Test Team") + self.user = User.objects.create_user( + email="creator@example.com", first_name="Casey", last_name="Creator", password="password" + ) + OrganizationMembership.objects.create(user=self.user, organization=self.organization) + self.channel = Channel.objects.create(team=self.team, name="general") + self.task = Task.objects.create( + team=self.team, + title="Build canvas", + description="", + origin_product=Task.OriginProduct.USER_CREATED, + created_by=self.user, + channel=self.channel, + ) + self.task_run = TaskRun.objects.create(task=self.task, team=self.team) + + def _messages(self, task: Task) -> list[TaskThreadMessage]: + return list(TaskThreadMessage.objects.for_team(self.team.id).filter(task=task).order_by("created_at")) + + @parameterized.expand( + [ + ( + "relays_final_message", + "Shipped the canvas with three charts.", + "@[Casey Creator](creator@example.com) Shipped the canvas with three charts.", + ), + ("falls_back_without_message", None, "@[Casey Creator](creator@example.com) Turn complete."), + ] + ) + @patch(_FLAG_TARGET, return_value=True) + def test_turn_complete_posts_authorless_message_mentioning_creator(self, _name, message, expected, _flag) -> None: + post_turn_complete_thread_update(str(self.task_run.id), str(self.task.id), self.team.id, message=message) + + messages = self._messages(self.task) + self.assertEqual(len(messages), 1) + self.assertIsNone(messages[0].author_id) + self.assertEqual(messages[0].author_kind, TaskThreadMessage.AuthorKind.AGENT) + self.assertEqual(messages[0].event, "turn_complete") + # run_id is the client's key for deduping this durable row against + # live session-derived agent turns. + self.assertEqual(messages[0].payload, {"run_id": str(self.task_run.id)}) + self.assertEqual(messages[0].content, expected) + # The creator's mention is indexed so it lands in their mentions feed. + self.assertTrue( + TaskThreadMessageMention.objects.for_team(self.team.id) + .filter(message=messages[0], mentioned_user=self.user) + .exists() + ) + + @patch(_FLAG_TARGET, return_value=True) + def test_turn_complete_truncates_oversized_message(self, _flag) -> None: + post_turn_complete_thread_update(str(self.task_run.id), str(self.task.id), self.team.id, message="x" * 5000) + + content = self._messages(self.task)[0].content + self.assertTrue(content.endswith("…")) + self.assertLess(len(content), 4100) + + @patch(_FLAG_TARGET, return_value=True) + def test_turn_complete_cooldown_collapses_duplicate_end_of_turn_events(self, _flag) -> None: + post_turn_complete_thread_update(str(self.task_run.id), str(self.task.id), self.team.id) + post_turn_complete_thread_update(str(self.task_run.id), str(self.task.id), self.team.id) + + self.assertEqual(len(self._messages(self.task)), 1) + + @parameterized.expand( + [ + ("flag_off", False, True, True), + ("no_channel", True, False, True), + ("no_creator", True, True, False), + ] + ) + @patch(_FLAG_TARGET) + def test_turn_complete_skips(self, _name, flag_on, has_channel, has_creator, flag_mock) -> None: + flag_mock.return_value = flag_on + task = Task.objects.create( + team=self.team, + title="Other task", + description="", + origin_product=Task.OriginProduct.USER_CREATED, + created_by=self.user if has_creator else None, + channel=self.channel if has_channel else None, + ) + run = TaskRun.objects.create(task=task, team=self.team) + + post_turn_complete_thread_update(str(run.id), str(task.id), self.team.id) + + self.assertEqual(self._messages(task), []) + + @parameterized.expand( + [ + ( + "with_link", + "Signups overview", + "https://us.posthog.com/code/canvas/c/d", + "[Signups overview](https://us.posthog.com/code/canvas/c/d) has been created", + ), + ( + "name_sanitized_for_link_token", + "[Q3] KPIs", + "https://us.posthog.com/code/canvas/c/d", + "[Q3 KPIs](https://us.posthog.com/code/canvas/c/d) has been created", + ), + ("without_link", "Signups overview", None, "Signups overview has been created"), + ] + ) + @patch(_FLAG_TARGET, return_value=True) + def test_canvas_created_message_content(self, _name, canvas_name, canvas_url, expected, _flag) -> None: + post_canvas_created_thread_update( + self.task.id, self.team.id, acting_user_id=self.user.id, canvas_name=canvas_name, canvas_url=canvas_url + ) + + messages = self._messages(self.task) + self.assertEqual(len(messages), 1) + self.assertIsNone(messages[0].author_id) + self.assertEqual(messages[0].author_kind, TaskThreadMessage.AuthorKind.AGENT) + self.assertEqual(messages[0].event, "canvas_created") + self.assertEqual(messages[0].content, expected) + + @patch(_FLAG_TARGET, return_value=True) + def test_canvas_created_requires_creator_match(self, _flag) -> None: + other = User.objects.create_user(email="other@example.com", first_name="Other", password="password") + + post_canvas_created_thread_update( + self.task.id, self.team.id, acting_user_id=other.id, canvas_name="Canvas", canvas_url=None + ) + + self.assertEqual(self._messages(self.task), []) + + @patch(_FLAG_TARGET, return_value=False) + def test_canvas_created_skips_when_flag_off(self, _flag) -> None: + post_canvas_created_thread_update( + self.task.id, self.team.id, acting_user_id=self.user.id, canvas_name="Canvas", canvas_url=None + ) + + self.assertEqual(self._messages(self.task), []) + + +def _session_update(update: dict) -> dict: + return {"type": "notification", "notification": {"method": "session/update", "params": {"update": update}}} + + +def _chunk(text: str) -> dict: + return _session_update({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": text}}) + + +class TestTrackFinalMessage(SimpleTestCase): + def test_holds_only_prose_after_last_tool_call(self) -> None: + parts: list[str] = [] + for event in [ + _chunk("Let me look at the data first. "), + _session_update({"sessionUpdate": "tool_call", "toolCallId": "t1"}), + _session_update({"sessionUpdate": "tool_call_update", "toolCallId": "t1"}), + _chunk("Done. The canvas "), + _chunk("shows signups by week."), + ]: + _track_final_message(event, parts) + + self.assertEqual("".join(parts), "Done. The canvas shows signups by week.") + + @parameterized.expand([("user_message",), ("user_message_chunk",), ("tool_call",)]) + def test_resets_on(self, session_update: str) -> None: + parts = ["stale narration"] + + _track_final_message(_session_update({"sessionUpdate": session_update}), parts) + + self.assertEqual(parts, []) diff --git a/products/tasks/frontend/generated/api.schemas.ts b/products/tasks/frontend/generated/api.schemas.ts index aa994c41c87c..47f59325908b 100644 --- a/products/tasks/frontend/generated/api.schemas.ts +++ b/products/tasks/frontend/generated/api.schemas.ts @@ -414,6 +414,57 @@ export interface ChannelWriteApi { name: string } +export type ChannelFeedMessageDTOApiPayload = { [key: string]: unknown } + +/** + * Response shape for one system announcement in a channel's feed. + */ +export interface ChannelFeedMessageDTOApi { + id: string + channel: string + author?: TaskUserBasicInfoApi | null + author_kind: string + event: string + payload: ChannelFeedMessageDTOApiPayload + content: string + created_at: string +} + +export interface PaginatedChannelFeedMessageDTOListApi { + count: number + /** @nullable */ + next?: string | null + /** @nullable */ + previous?: string | null + results: ChannelFeedMessageDTOApi[] +} + +/** + * * `context_created` - context_created + * * `context_md_building` - context_md_building + */ +export type EventEnumApi = (typeof EventEnumApi)[keyof typeof EventEnumApi] + +export const EventEnumApi = { + ContextCreated: 'context_created', + ContextMdBuilding: 'context_md_building', +} as const + +/** + * Request body for posting a system announcement into a channel's feed. + */ +export interface ChannelFeedMessageWriteApi { + /** Lifecycle event key. + * + * * `context_created` - context_created + * * `context_md_building` - context_md_building */ + event: EventEnumApi + /** Structured event data, e.g. {"context_name": "mobile"}. */ + payload?: unknown + /** Optional explicit timestamp (within 10 minutes of now), so a client can order a burst of announcements. */ + created_at?: string +} + /** * Request body for creating (resolve-or-create) or renaming a public channel. */ @@ -2183,12 +2234,17 @@ export interface TaskRunLivingArtifactEditRequestApi { metadata?: TaskRunLivingArtifactEditRequestApiMetadata } +export type TaskThreadMessageDTOApiPayload = { [key: string]: unknown } + /** * Response shape for one message in a task's thread. */ export interface TaskThreadMessageDTOApi { id: string task: string + author_kind: string + event: string + payload: TaskThreadMessageDTOApiPayload content: string created_at: string author?: TaskUserBasicInfoApi | null @@ -2618,6 +2674,17 @@ export type TaskChannelsListParams = { offset?: number } +export type TaskChannelsFeedListParams = { + /** + * Number of results to return per page. + */ + limit?: number + /** + * The initial index from which to return the results. + */ + offset?: number +} + export type TaskMentionsListParams = { /** * Maximum number of mentions to return (newest first). diff --git a/products/tasks/frontend/generated/api.ts b/products/tasks/frontend/generated/api.ts index 6fdc10d93dda..ed261a55b7d3 100644 --- a/products/tasks/frontend/generated/api.ts +++ b/products/tasks/frontend/generated/api.ts @@ -10,10 +10,13 @@ import { apiMutator } from '../../../../frontend/src/lib/api-orval-mutator' */ import type { ChannelDTOApi, + ChannelFeedMessageDTOApi, + ChannelFeedMessageWriteApi, ChannelWriteApi, CodeInviteRedeemRequestApi, ConnectionTokenResponseApi, PaginatedChannelDTOListApi, + PaginatedChannelFeedMessageDTOListApi, PaginatedSandboxCustomImageDTOListApi, PaginatedSandboxEnvironmentDTOListApi, PaginatedTaskAutomationDTOListApi, @@ -41,6 +44,7 @@ import type { TaskAutomationDTOApi, TaskAutomationWriteApi, TaskAutomationsListParams, + TaskChannelsFeedListParams, TaskChannelsListParams, TaskDetailDTOApi, TaskMentionsListParams, @@ -533,6 +537,66 @@ export const taskChannelsCreate = async ( }) } +export const getTaskChannelsFeedListUrl = ( + projectId: string, + channelId: string, + params?: TaskChannelsFeedListParams +) => { + const normalizedParams = new URLSearchParams() + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? 'null' : String(value)) + } + }) + + const stringifiedParams = normalizedParams.toString() + + return stringifiedParams.length > 0 + ? `/api/projects/${projectId}/task_channels/${channelId}/feed/?${stringifiedParams}` + : `/api/projects/${projectId}/task_channels/${channelId}/feed/` +} + +/** + * A channel's system announcements in chronological order. + * @summary List channel feed messages + */ +export const taskChannelsFeedList = async ( + projectId: string, + channelId: string, + params?: TaskChannelsFeedListParams, + options?: RequestInit +): Promise => { + return apiMutator(getTaskChannelsFeedListUrl(projectId, channelId, params), { + ...options, + method: 'GET', + }) +} + +export const getTaskChannelsFeedCreateUrl = (projectId: string, channelId: string) => { + return `/api/projects/${projectId}/task_channels/${channelId}/feed/` +} + +/** + * API for a channel's system-announcement feed — durable "PostHog agent" rows + * (context created, CONTEXT.md being built) rendered alongside the channel's task + * cards. Read by any team member for a public channel; personal channels are owner-only. + * @summary Post a channel feed message + */ +export const taskChannelsFeedCreate = async ( + projectId: string, + channelId: string, + channelFeedMessageWriteApi: ChannelFeedMessageWriteApi, + options?: RequestInit +): Promise => { + return apiMutator(getTaskChannelsFeedCreateUrl(projectId, channelId), { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(channelFeedMessageWriteApi), + }) +} + export const getTaskChannelsPartialUpdateUrl = (projectId: string, id: string) => { return `/api/projects/${projectId}/task_channels/${id}/` } diff --git a/products/tasks/frontend/generated/api.zod.ts b/products/tasks/frontend/generated/api.zod.ts index 4039b5334460..1620c2a7f8ce 100644 --- a/products/tasks/frontend/generated/api.zod.ts +++ b/products/tasks/frontend/generated/api.zod.ts @@ -294,6 +294,30 @@ export const TaskChannelsCreateBody = /* @__PURE__ */ zod }) .describe('Request body for creating (resolve-or-create) or renaming a public channel.') +/** + * API for a channel's system-announcement feed — durable "PostHog agent" rows + * (context created, CONTEXT.md being built) rendered alongside the channel's task + * cards. Read by any team member for a public channel; personal channels are owner-only. + * @summary Post a channel feed message + */ +export const TaskChannelsFeedCreateBody = /* @__PURE__ */ zod + .object({ + event: zod + .enum(['context_created', 'context_md_building']) + .describe('\* `context_created` - context_created\n\* `context_md_building` - context_md_building') + .describe( + 'Lifecycle event key.\n\n\* `context_created` - context_created\n\* `context_md_building` - context_md_building' + ), + payload: zod.unknown().optional().describe('Structured event data, e.g. {\"context_name\": \"mobile\"}.'), + created_at: zod.iso + .datetime({ offset: true }) + .optional() + .describe( + 'Optional explicit timestamp (within 10 minutes of now), so a client can order a burst of announcements.' + ), + }) + .describe("Request body for posting a system announcement into a channel's feed.") + /** * API for task channels — the shared feeds tasks are kicked off in. Listing lazily * provisions the requester's personal "#me" channel; creation is resolve-or-create @@ -1841,6 +1865,9 @@ export const TasksThreadMessagesSendToAgentCreateBody = /* @__PURE__ */ zod .object({ id: zod.uuid(), task: zod.uuid(), + author_kind: zod.string(), + event: zod.string(), + payload: zod.record(zod.string(), zod.unknown()), content: zod.string(), created_at: zod.iso.datetime({ offset: true }), author: zod diff --git a/products/tasks/mcp/tools.yaml b/products/tasks/mcp/tools.yaml index f51e0102ed8e..40d8d18c226d 100644 --- a/products/tasks/mcp/tools.yaml +++ b/products/tasks/mcp/tools.yaml @@ -72,6 +72,12 @@ tools: task-channels-destroy: operation: task_channels_destroy enabled: false + task-channels-feed-create: + operation: task_channels_feed_create + enabled: false + task-channels-feed-list: + operation: task_channels_feed_list + enabled: false task-channels-list: operation: task_channels_list enabled: false diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index ba4aa022c4d4..e0a6acaee5b0 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -12939,6 +12939,49 @@ export namespace Schemas { GithubIssue: 'github_issue', } as const; + export type ChannelFeedMessageDTOPayload = { [key: string]: unknown }; + + /** + * Response shape for one system announcement in a channel's feed. + */ + export interface ChannelFeedMessageDTO { + id: string; + channel: string; + author?: TaskUserBasicInfo | null; + author_kind: string; + event: string; + payload: ChannelFeedMessageDTOPayload; + content: string; + created_at: string; + } + + /** + * * `context_created` - context_created + * * `context_md_building` - context_md_building + */ + export type EventEnum = typeof EventEnum[keyof typeof EventEnum]; + + + export const EventEnum = { + ContextCreated: 'context_created', + ContextMdBuilding: 'context_md_building', + } as const; + + /** + * Request body for posting a system announcement into a channel's feed. + */ + export interface ChannelFeedMessageWrite { + /** Lifecycle event key. + * + * * `context_created` - context_created + * * `context_md_building` - context_md_building */ + event: EventEnum; + /** Structured event data, e.g. {"context_name": "mobile"}. */ + payload?: unknown; + /** Optional explicit timestamp (within 10 minutes of now), so a client can order a burst of announcements. */ + created_at?: string; + } + /** * * `widget` - Widget * * `email` - Email @@ -33783,6 +33826,15 @@ export namespace Schemas { results: ChannelDTO[]; } + export interface PaginatedChannelFeedMessageDTOList { + count: number; + /** @nullable */ + next?: string | null; + /** @nullable */ + previous?: string | null; + results: ChannelFeedMessageDTO[]; + } + export interface PaginatedClickhouseEventList { /** @nullable */ next?: string | null; @@ -37261,12 +37313,17 @@ export namespace Schemas { results: TaskSummaryDTO[]; } + export type TaskThreadMessageDTOPayload = { [key: string]: unknown }; + /** * Response shape for one message in a task's thread. */ export interface TaskThreadMessageDTO { id: string; task: string; + author_kind: string; + event: string; + payload: TaskThreadMessageDTOPayload; content: string; created_at: string; author?: TaskUserBasicInfo | null; @@ -72221,6 +72278,17 @@ export namespace Schemas { offset?: number; }; + export type TaskChannelsFeedListParams = { + /** + * Number of results to return per page. + */ + limit?: number; + /** + * The initial index from which to return the results. + */ + offset?: number; + }; + export type TaskMentionsListParams = { /** * Maximum number of mentions to return (newest first). From 36deaad78d88407820a14ad67149b6d043c37517 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Tue, 14 Jul 2026 10:52:06 +0100 Subject: [PATCH 4/6] fix(tasks): harden channel feed and canvas announcements - write agent thread messages via for_team so temporal-relay callers pass the fail-closed manager (CI TeamScopeError) - client feed posts are marked human-authored; system/agent kinds reserved for server-side writers - cap feed payloads at 8 KB and channels at 500 feed rows; bound the list path to the newest rows - only announce canvas creation for requests bearing a sandbox-app OAuth token, so the X-PostHog-Task-Id header alone can't forge agent messages Co-Authored-By: Claude Fable 5 --- posthog/api/file_system/file_system.py | 24 +++++++++-- .../file_system/test/test_canvas_publish.py | 43 +++++++++++++++++++ posthog/temporal/oauth.py | 14 ++++++ products/tasks/backend/facade/api.py | 40 +++++++++++------ .../tasks/backend/presentation/serializers.py | 16 ++++++- .../presentation/views/channels_api.py | 4 +- .../tests/test_relay_sandbox_events.py | 4 +- .../tasks/backend/tests/test_channels_api.py | 36 +++++++++++++++- .../backend/tests/test_thread_updates.py | 5 ++- 9 files changed, 164 insertions(+), 22 deletions(-) diff --git a/posthog/api/file_system/file_system.py b/posthog/api/file_system/file_system.py index e79f4d0e6e74..17cbeea7748e 100644 --- a/posthog/api/file_system/file_system.py +++ b/posthog/api/file_system/file_system.py @@ -48,6 +48,7 @@ from posthog.api.routing import TeamAndOrgViewSetMixin from posthog.api.shared import UserBasicSerializer from posthog.api.utils import action +from posthog.auth import OAuthAccessTokenAuthentication from posthog.decorators import disallow_if_impersonated from posthog.models.file_system.file_system import ( DEFAULT_SURFACE, @@ -62,6 +63,7 @@ from posthog.models.file_system.unfiled_file_saver import save_unfiled_files from posthog.models.team import Team from posthog.models.user import User +from posthog.temporal.oauth import SANDBOX_OAUTH_APP_CLIENT_IDS from posthog.utils import str_to_bool from products.tasks.backend.facade import api as tasks_facade @@ -1169,16 +1171,20 @@ def _announce_canvas_created(self, request: Request, dashboard: FileSystem) -> N """Announce a canvas's first publish in the generating task's thread. The task sandbox stamps every MCP call with an X-PostHog-Task-Id header, so - a publish is attributable to the task that made it. The sandbox authenticates - with the task creator's credentials, so the facade only accepts a task created - by the requesting user — the header can't point the announcement at someone - else's task thread. No header (a human or app save) means no announcement. + a publish is attributable to the task that made it. The header alone is + forgeable, so two checks bind the announcement to a real sandbox run: the + request must carry an OAuth token minted under a sandbox app (those tokens + are only created server-side), and the facade only accepts a task created + by the requesting user (the sandbox authenticates with the task creator's + credentials). No header (a human or app save) means no announcement. """ raw_task_id = (request.headers.get("X-PostHog-Task-Id") or "").strip() try: task_id = UUID(raw_task_id) except ValueError: return + if not self._is_sandbox_authenticated(request): + return user = request.user if isinstance(request.user, User) else None segments = split_path(dashboard.path) tasks_facade.post_canvas_created_thread_update( @@ -1189,6 +1195,16 @@ def _announce_canvas_created(self, request: Request, dashboard: FileSystem) -> N canvas_url=self._canvas_share_url(dashboard), ) + @staticmethod + def _is_sandbox_authenticated(request: Request) -> bool: + """True when the request bears an OAuth token minted under a sandbox app — + the credential a task sandbox (via the MCP server) calls this API with.""" + authenticator = request.successful_authenticator + if not isinstance(authenticator, OAuthAccessTokenAuthentication): + return False + application = authenticator.access_token.application + return application is not None and application.client_id in SANDBOX_OAUTH_APP_CLIENT_IDS + def _canvas_share_url(self, dashboard: FileSystem) -> str | None: """The web interstitial link that deep-links into the desktop app's canvas view: `/code/canvas//`. The channel id is stamped on diff --git a/posthog/api/file_system/test/test_canvas_publish.py b/posthog/api/file_system/test/test_canvas_publish.py index d918e22b940d..190867fd796a 100644 --- a/posthog/api/file_system/test/test_canvas_publish.py +++ b/posthog/api/file_system/test/test_canvas_publish.py @@ -9,7 +9,14 @@ from rest_framework import status from posthog.models.file_system.file_system import FileSystem +from posthog.models.oauth import OAuthApplication from posthog.models.user import User +from posthog.temporal.oauth import ( + ARRAY_APP_CLIENT_ID_DEV, + ARRAY_APP_CLIENT_ID_EU, + ARRAY_APP_CLIENT_ID_US, + create_oauth_access_token_for_user, +) if TYPE_CHECKING: from products.tasks.backend.models import Task @@ -120,10 +127,31 @@ def _thread_messages(self, task: "Task"): TaskThreadMessage = apps.get_model("tasks", "TaskThreadMessage") return TaskThreadMessage.objects.for_team(self.team.id).filter(task=task) + def _authenticate_as_sandbox(self) -> None: + """Swap session auth for a sandbox-app OAuth token — announcements only fire for + requests bearing one. The app is created for every region client id because + `create_oauth_access_token_for_user` resolves it by `get_instance_region()`.""" + for client_id in (ARRAY_APP_CLIENT_ID_DEV, ARRAY_APP_CLIENT_ID_US, ARRAY_APP_CLIENT_ID_EU): + OAuthApplication.objects.get_or_create( + client_id=client_id, + defaults={ + "name": "Array Test App", + "client_type": OAuthApplication.CLIENT_PUBLIC, + "authorization_grant_type": OAuthApplication.GRANT_AUTHORIZATION_CODE, + "redirect_uris": "https://app.posthog.com/callback", + # RS256 is enforced by the `enforce_rs256_algorithm` DB constraint. + "algorithm": "RS256", + }, + ) + token = create_oauth_access_token_for_user(self.user, self.team.id, scopes="full") + self.client.logout() + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + @patch("products.tasks.backend.facade.api.posthoganalytics.feature_enabled", return_value=True) def test_first_publish_from_task_announces_in_thread_once(self, _flag): task = self._create_task() item_id = self._create_dashboard(meta={"channelId": "chan-1"}) + self._authenticate_as_sandbox() self.client.patch(self._canvas_url(item_id), {"code": "v1"}, HTTP_X_POSTHOG_TASK_ID=str(task.id)) @@ -144,6 +172,7 @@ def test_first_publish_from_task_announces_in_thread_once(self, _flag): def test_announcement_links_via_parent_folder_when_meta_has_no_channel(self, _flag): task = self._create_task() item_id = self._create_dashboard() # no channelId stamp — rows created before the app stamped it + self._authenticate_as_sandbox() self.client.patch(self._canvas_url(item_id), {"code": "v1"}, HTTP_X_POSTHOG_TASK_ID=str(task.id)) @@ -165,11 +194,25 @@ def test_header_naming_someone_elses_task_stays_silent(self, _flag): created_by=other, ) item_id = self._create_dashboard() + self._authenticate_as_sandbox() self.client.patch(self._canvas_url(item_id), {"code": "v1"}, HTTP_X_POSTHOG_TASK_ID=str(task.id)) self.assertFalse(self._thread_messages(task).exists()) + @patch("products.tasks.backend.facade.api.posthoganalytics.feature_enabled", return_value=True) + def test_session_authenticated_publish_with_header_stays_silent(self, _flag): + # The header alone must not produce an agent announcement: a member setting it on + # an ordinary (session-authenticated) publish of their own task would otherwise + # forge a trusted-looking agent message. Only sandbox OAuth tokens qualify. + task = self._create_task() + item_id = self._create_dashboard() + + response = self.client.patch(self._canvas_url(item_id), {"code": "v1"}, HTTP_X_POSTHOG_TASK_ID=str(task.id)) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(self._thread_messages(task).exists()) + def test_publish_without_task_attribution_stays_silent(self): item_id = self._create_dashboard() diff --git a/posthog/temporal/oauth.py b/posthog/temporal/oauth.py index 0c75d2c57374..f202896f3eda 100644 --- a/posthog/temporal/oauth.py +++ b/posthog/temporal/oauth.py @@ -16,6 +16,20 @@ POSTHOG_AI_APP_CLIENT_ID_EU = "0Lizwa3mFSlBuEEQ8V8FMJlskUXpDuSmoEdhzxyi" POSTHOG_AI_APP_CLIENT_ID_DEV = "DD2ZLG6a2YEUtpPANSzSiIBPuUryYmbndLnKKUy1" +# 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. +SANDBOX_OAUTH_APP_CLIENT_IDS = frozenset( + { + ARRAY_APP_CLIENT_ID_US, + ARRAY_APP_CLIENT_ID_EU, + ARRAY_APP_CLIENT_ID_DEV, + POSTHOG_AI_APP_CLIENT_ID_US, + POSTHOG_AI_APP_CLIENT_ID_EU, + POSTHOG_AI_APP_CLIENT_ID_DEV, + } +) + McpScopePreset = Literal["read_only", "full", "signals_scout", "signals_scout_reports"] SandboxOAuthApplication = Literal["array", "posthog_ai"] diff --git a/products/tasks/backend/facade/api.py b/products/tasks/backend/facade/api.py index 696dfd28ba6d..bb97ba5bfebb 100644 --- a/products/tasks/backend/facade/api.py +++ b/products/tasks/backend/facade/api.py @@ -4789,6 +4789,11 @@ def delete_channel(channel_id: str | UUID, team_id: int) -> str: return "ok" +# Per-channel ceiling on feed rows — the feed holds rare lifecycle announcements, so the +# cap exists to stop one member making the feed unboundedly expensive to store and read. +CHANNEL_FEED_MAX_MESSAGES = 500 + + def _channel_feed_message_to_dto(message: ChannelFeedMessage) -> contracts.ChannelFeedMessageDTO: return contracts.ChannelFeedMessageDTO( id=message.id, @@ -4817,16 +4822,17 @@ def list_channel_feed_messages( channel_id: str | UUID, team_id: int, user_id: int | None ) -> list[contracts.ChannelFeedMessageDTO] | None: """A channel's system-announcement feed, ascending. ``None`` when the channel isn't visible. - Unpaginated: the feed holds rare lifecycle events. Add pagination before any - per-task or per-thread event lands here.""" + Bounded to the newest ``CHANNEL_FEED_MAX_MESSAGES``: the feed holds rare lifecycle + events, and the write path caps a channel at the same count. Add real pagination + before any per-task or per-thread event lands here.""" if _visible_channel(channel_id, team_id, user_id) is None: return None messages = ( ChannelFeedMessage.objects.filter(channel_id=channel_id, team_id=team_id, deleted=False) .select_related("author") - .order_by("created_at", "id") + .order_by("-created_at", "-id")[:CHANNEL_FEED_MAX_MESSAGES] ) - return [_channel_feed_message_to_dto(message) for message in messages] + return [_channel_feed_message_to_dto(message) for message in reversed(messages)] def create_channel_feed_message( @@ -4837,18 +4843,26 @@ def create_channel_feed_message( event: str, payload: dict, created_at: datetime | None = None, -) -> contracts.ChannelFeedMessageDTO | None: - """Post a system announcement into a channel's feed as the requester. ``None`` when - the channel isn't visible. The row is authored by the system; ``author`` records the - acting user so the client can render "Adam …". ``created_at`` lets a client order a - burst of announcements deterministically (else the server stamps ``now``).""" +) -> contracts.ChannelFeedMessageDTO | None | str: + """Post an announcement into a channel's feed as the requester. ``None`` when the + channel isn't visible; ``"full"`` when the channel's feed is at capacity. The row is + marked human-authored — ``system``/``agent`` kinds are reserved for server-side + writers, so a client can't forge rows other clients render as trusted. ``author`` + records the acting user so the client can render "Adam …". ``created_at`` lets a + client order a burst of announcements deterministically (else the server stamps + ``now``).""" if _visible_channel(channel_id, team_id, user_id) is None: return None + if ( + ChannelFeedMessage.objects.filter(channel_id=channel_id, team_id=team_id, deleted=False).count() + >= CHANNEL_FEED_MAX_MESSAGES + ): + return "full" fields: dict = { "team_id": team_id, "channel_id": channel_id, "author_id": user_id, - "author_kind": ChannelFeedMessage.AuthorKind.SYSTEM, + "author_kind": ChannelFeedMessage.AuthorKind.HUMAN, "event": event, "payload": payload or {}, } @@ -4917,7 +4931,7 @@ def _index_thread_message_mentions(message: TaskThreadMessage) -> None: mentioned_user_ids = resolve_mentioned_user_ids( User, message.content, team_id=message.team_id, author_id=message.author_id ) - TaskThreadMessageMention.objects.bulk_create( + TaskThreadMessageMention.objects.for_team(message.team_id).bulk_create( [ TaskThreadMessageMention( team_id=message.team_id, @@ -5040,7 +5054,9 @@ def _create_agent_thread_message(task: Task, content: str, *, event: str, payloa ``payload`` are the structured record, mirroring ChannelFeedMessage, that lets clients render agent rows natively and dedupe them against live views. """ - message = TaskThreadMessage.objects.create( + # for_team: callers include non-request contexts (temporal relay) where the + # fail-closed manager has no team scope. + message = TaskThreadMessage.objects.for_team(task.team_id).create( team_id=task.team_id, task_id=task.id, author_id=None, diff --git a/products/tasks/backend/presentation/serializers.py b/products/tasks/backend/presentation/serializers.py index 5bda37632614..797669e5f4a7 100644 --- a/products/tasks/backend/presentation/serializers.py +++ b/products/tasks/backend/presentation/serializers.py @@ -1,3 +1,4 @@ +import json import base64 import logging import binascii @@ -1313,13 +1314,19 @@ class Meta: # in quick succession; anything beyond this window is backdating, not ordering. CHANNEL_FEED_CREATED_AT_WINDOW = timedelta(minutes=10) +# Feed payloads carry a couple of short strings (e.g. a context name); the cap stops one +# member storing megabytes of JSON every reader must then load and serialize. +CHANNEL_FEED_PAYLOAD_MAX_BYTES = 8 * 1024 + class ChannelFeedMessageWriteSerializer(serializers.Serializer): """Request body for posting a system announcement into a channel's feed.""" event = serializers.ChoiceField(choices=CHANNEL_FEED_EVENTS, help_text="Lifecycle event key.") payload = serializers.JSONField( - required=False, default=dict, help_text='Structured event data, e.g. {"context_name": "mobile"}.' + required=False, + default=dict, + help_text='Structured event data, e.g. {"context_name": "mobile"}. At most 8 KB of JSON.', ) created_at = serializers.DateTimeField( required=False, @@ -1331,6 +1338,13 @@ def validate_created_at(self, value: datetime) -> datetime: raise serializers.ValidationError("created_at must be within 10 minutes of the current time.") return value + def validate_payload(self, value: dict) -> dict: + if not isinstance(value, dict): + raise serializers.ValidationError("payload must be a JSON object.") + if len(json.dumps(value)) > CHANNEL_FEED_PAYLOAD_MAX_BYTES: + raise serializers.ValidationError("payload must be at most 8 KB of JSON.") + return value + class TaskMentionQuerySerializer(serializers.Serializer): """Query parameters for listing mentions.""" diff --git a/products/tasks/backend/presentation/views/channels_api.py b/products/tasks/backend/presentation/views/channels_api.py index 03d8e267eced..1891ecc2238a 100644 --- a/products/tasks/backend/presentation/views/channels_api.py +++ b/products/tasks/backend/presentation/views/channels_api.py @@ -4,7 +4,7 @@ from rest_framework import status, viewsets from rest_framework.authentication import SessionAuthentication from rest_framework.decorators import action -from rest_framework.exceptions import NotFound, PermissionDenied +from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -160,6 +160,8 @@ def create(self, request, **kwargs): ) if message is None: raise NotFound("Channel not found") + if message == "full": + raise ValidationError("This channel's feed is full.") return Response(ChannelFeedMessageSerializer(message).data, status=status.HTTP_201_CREATED) diff --git a/products/tasks/backend/temporal/process_task/activities/tests/test_relay_sandbox_events.py b/products/tasks/backend/temporal/process_task/activities/tests/test_relay_sandbox_events.py index 2b573a24c175..b01aa31f2d0f 100644 --- a/products/tasks/backend/temporal/process_task/activities/tests/test_relay_sandbox_events.py +++ b/products/tasks/backend/temporal/process_task/activities/tests/test_relay_sandbox_events.py @@ -555,7 +555,9 @@ async def test_permission_request_dispatches_to_broker(self, monkeypatch: pytest "type": "notification", "notification": {"method": "_posthog/task_complete"}, } - task_run = SimpleNamespace(id="run-id") + # mode="interactive" keeps the turn-complete thread-update path out of + # this test, which only cares about permission dispatch. + task_run = SimpleNamespace(id="run-id", mode="interactive") dispatch_mock = MagicMock() class SuccessfulEventSource: diff --git a/products/tasks/backend/tests/test_channels_api.py b/products/tasks/backend/tests/test_channels_api.py index 87bf08c6eb72..7df6b6c01316 100644 --- a/products/tasks/backend/tests/test_channels_api.py +++ b/products/tasks/backend/tests/test_channels_api.py @@ -10,7 +10,7 @@ from posthog.models import Organization, OrganizationMembership, Team, User -from products.tasks.backend.models import Channel, Task, TaskRun, TaskThreadMessage +from products.tasks.backend.models import Channel, ChannelFeedMessage, Task, TaskRun, TaskThreadMessage class ChannelsAPITestCase(TestCase): @@ -370,7 +370,9 @@ def test_post_and_list_feed_message(self): self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.content) body = response.json() self.assertEqual(body["event"], "context_created") - self.assertEqual(body["author_kind"], "system") + # Client posts are marked human-authored; system/agent kinds are reserved + # for server-side writers so a member can't forge trusted rows. + self.assertEqual(body["author_kind"], "human") self.assertEqual(body["author"]["id"], self.user.id) self.assertEqual(body["payload"], {"context_name": "mobile"}) @@ -460,6 +462,36 @@ def test_created_at_outside_window_is_rejected(self): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, stamp) + def test_oversized_payload_is_rejected(self): + channel_id = self._public_channel() + response = self.client.post( + self._feed_url(channel_id), + {"event": "context_created", "payload": {"context_name": "x" * 9000}}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_post_to_full_feed_is_rejected(self): + channel_id = self._public_channel() + # channel_created already occupies one slot; a cap of 2 leaves room for one post. + with patch("products.tasks.backend.facade.api.CHANNEL_FEED_MAX_MESSAGES", 2): + ok = self.client.post(self._feed_url(channel_id), {"event": "context_created"}, format="json") + self.assertEqual(ok.status_code, status.HTTP_201_CREATED) + full = self.client.post(self._feed_url(channel_id), {"event": "context_md_building"}, format="json") + self.assertEqual(full.status_code, status.HTTP_400_BAD_REQUEST) + + def test_list_returns_newest_rows_ascending_when_over_cap(self): + channel_id = self._public_channel() + now = django_timezone.now() + for i, event in enumerate(["context_created", "context_md_building"]): + ChannelFeedMessage( + team=self.team, channel_id=channel_id, event=event, created_at=now + timedelta(seconds=i + 1) + ).save() + with patch("products.tasks.backend.facade.api.CHANNEL_FEED_MAX_MESSAGES", 2): + events = [m["event"] for m in self.client.get(self._feed_url(channel_id)).json()] + # Three rows, cap 2: the oldest (channel_created) drops, newest two stay ascending. + self.assertEqual(events, ["context_created", "context_md_building"]) + def test_feed_is_team_scoped(self): channel_id = self._public_channel() other_team = Team.objects.create(organization=self.organization, name="Other Team") diff --git a/products/tasks/backend/tests/test_thread_updates.py b/products/tasks/backend/tests/test_thread_updates.py index 4f197b309a34..2e7f71b270ca 100644 --- a/products/tasks/backend/tests/test_thread_updates.py +++ b/products/tasks/backend/tests/test_thread_updates.py @@ -23,7 +23,10 @@ def setUp(self) -> None: email="creator@example.com", first_name="Casey", last_name="Creator", password="password" ) OrganizationMembership.objects.create(user=self.user, organization=self.organization) - self.channel = Channel.objects.create(team=self.team, name="general") + # Direct instantiation sidesteps the fail-closed TeamScopedManager so + # setUp doesn't need a team_scope wrapper (see test_channels_api.py). + self.channel = Channel(team=self.team, name="general") + self.channel.save() self.task = Task.objects.create( team=self.team, title="Build canvas", From 776c2b149dbe2927f90d28c9333521817d0709c8 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Tue, 14 Jul 2026 11:03:43 +0100 Subject: [PATCH 5/6] chore(tasks): regenerate openapi types Co-Authored-By: Claude Fable 5 --- .../tasks/frontend/generated/api.schemas.ts | 2 +- products/tasks/frontend/generated/api.zod.ts | 5 +++- services/mcp/src/api/generated.ts | 2 +- .../mcp/src/generated/agent_platform/api.ts | 4 +-- .../src/generated/customer_analytics/api.ts | 4 +-- .../mcp/src/generated/email_templates/api.ts | 2 +- services/mcp/src/generated/endpoints/api.ts | 2 +- .../mcp/src/generated/error_tracking/api.ts | 18 ++++++------- services/mcp/src/generated/experiments/api.ts | 10 +++---- services/mcp/src/generated/logs/api.ts | 26 +++++++++---------- services/mcp/src/generated/persons/api.ts | 2 +- .../src/generated/product_analytics/api.ts | 2 +- services/mcp/src/generated/tracing/api.ts | 14 +++++----- 13 files changed, 48 insertions(+), 45 deletions(-) diff --git a/products/tasks/frontend/generated/api.schemas.ts b/products/tasks/frontend/generated/api.schemas.ts index a9599999cafc..5a34db2382f4 100644 --- a/products/tasks/frontend/generated/api.schemas.ts +++ b/products/tasks/frontend/generated/api.schemas.ts @@ -459,7 +459,7 @@ export interface ChannelFeedMessageWriteApi { * * `context_created` - context_created * * `context_md_building` - context_md_building */ event: EventEnumApi - /** Structured event data, e.g. {"context_name": "mobile"}. */ + /** Structured event data, e.g. {"context_name": "mobile"}. At most 8 KB of JSON. */ payload?: unknown /** Optional explicit timestamp (within 10 minutes of now), so a client can order a burst of announcements. */ created_at?: string diff --git a/products/tasks/frontend/generated/api.zod.ts b/products/tasks/frontend/generated/api.zod.ts index 4c29eca17238..0c5282765d17 100644 --- a/products/tasks/frontend/generated/api.zod.ts +++ b/products/tasks/frontend/generated/api.zod.ts @@ -308,7 +308,10 @@ export const TaskChannelsFeedCreateBody = /* @__PURE__ */ zod .describe( 'Lifecycle event key.\n\n\* `context_created` - context_created\n\* `context_md_building` - context_md_building' ), - payload: zod.unknown().optional().describe('Structured event data, e.g. {\"context_name\": \"mobile\"}.'), + payload: zod + .unknown() + .optional() + .describe('Structured event data, e.g. {\"context_name\": \"mobile\"}. At most 8 KB of JSON.'), created_at: zod.iso .datetime({ offset: true }) .optional() diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index e6b3517af4f5..30c67fab2a62 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -13004,7 +13004,7 @@ export namespace Schemas { * * `context_created` - context_created * * `context_md_building` - context_md_building */ event: EventEnum; - /** Structured event data, e.g. {"context_name": "mobile"}. */ + /** Structured event data, e.g. {"context_name": "mobile"}. At most 8 KB of JSON. */ payload?: unknown; /** Optional explicit timestamp (within 10 minutes of now), so a client can order a burst of announcements. */ created_at?: string; diff --git a/services/mcp/src/generated/agent_platform/api.ts b/services/mcp/src/generated/agent_platform/api.ts index fe4f2b5348ac..3eed1fdf9eb0 100644 --- a/services/mcp/src/generated/agent_platform/api.ts +++ b/services/mcp/src/generated/agent_platform/api.ts @@ -164,7 +164,7 @@ export const AgentApplicationsRevisionsCreateBody = /* @__PURE__ */ zod.object({ .string() .default(agentApplicationsRevisionsCreateBodyBundleUriDefault) .describe( - 'Storage-prefix metadata for the bundle, e.g. `fs:/\/my-agent/`. Optional — leave blank and the server fills `fs:/\//`. Bundles are addressed by revision id regardless, so this is only a prefix hint.' + 'Storage-prefix metadata for the bundle, e.g. `fs://my-agent/`. Optional — leave blank and the server fills `fs:///`. Bundles are addressed by revision id regardless, so this is only a prefix hint.' ), spec: zod.unknown().optional(), }) @@ -249,7 +249,7 @@ export const AgentApplicationsRevisionsPartialUpdateBody = /* @__PURE__ */ zod.o .string() .optional() .describe( - 'Storage-prefix metadata for the bundle, e.g. `fs:/\/my-agent/`. Optional — leave blank and the server fills `fs:/\//`. Bundles are addressed by revision id regardless, so this is only a prefix hint.' + 'Storage-prefix metadata for the bundle, e.g. `fs://my-agent/`. Optional — leave blank and the server fills `fs:///`. Bundles are addressed by revision id regardless, so this is only a prefix hint.' ), spec: zod.unknown().optional(), }) diff --git a/services/mcp/src/generated/customer_analytics/api.ts b/services/mcp/src/generated/customer_analytics/api.ts index 34f417a4fc4e..55bf91fdb824 100644 --- a/services/mcp/src/generated/customer_analytics/api.ts +++ b/services/mcp/src/generated/customer_analytics/api.ts @@ -666,7 +666,7 @@ export const GroupsTypesMetricsCreateBody = /* @__PURE__ */ zod.object({ filters: zod .record(zod.string(), zod.unknown()) .describe( - 'Filter definition for the metric. Two shapes are accepted, discriminated by an optional `source` key.\n\n*\*Events*\* (default, when `source` is missing or `"events"`): HogFunction filter shape — `events: [...]`, optional `actions: [...]`, `properties: [...]`, `filter_test_accounts: bool`.\n\n*\*Data warehouse*\* (`source: "data_warehouse"`): `table_name` (synced DW table), `timestamp_field` (timestamp column or HogQL expression), `key_field` (column whose value matches the entity key). Currently DW metrics only render on group profiles — person profiles are not yet supported.' + 'Filter definition for the metric. Two shapes are accepted, discriminated by an optional `source` key.\n\n**Events** (default, when `source` is missing or `"events"`): HogFunction filter shape — `events: [...]`, optional `actions: [...]`, `properties: [...]`, `filter_test_accounts: bool`.\n\n**Data warehouse** (`source: "data_warehouse"`): `table_name` (synced DW table), `timestamp_field` (timestamp column or HogQL expression), `key_field` (column whose value matches the entity key). Currently DW metrics only render on group profiles — person profiles are not yet supported.' ), math: zod .enum(['count', 'sum']) @@ -748,7 +748,7 @@ export const GroupsTypesMetricsPartialUpdateBody = /* @__PURE__ */ zod.object({ .record(zod.string(), zod.unknown()) .optional() .describe( - 'Filter definition for the metric. Two shapes are accepted, discriminated by an optional `source` key.\n\n*\*Events*\* (default, when `source` is missing or `"events"`): HogFunction filter shape — `events: [...]`, optional `actions: [...]`, `properties: [...]`, `filter_test_accounts: bool`.\n\n*\*Data warehouse*\* (`source: "data_warehouse"`): `table_name` (synced DW table), `timestamp_field` (timestamp column or HogQL expression), `key_field` (column whose value matches the entity key). Currently DW metrics only render on group profiles — person profiles are not yet supported.' + 'Filter definition for the metric. Two shapes are accepted, discriminated by an optional `source` key.\n\n**Events** (default, when `source` is missing or `"events"`): HogFunction filter shape — `events: [...]`, optional `actions: [...]`, `properties: [...]`, `filter_test_accounts: bool`.\n\n**Data warehouse** (`source: "data_warehouse"`): `table_name` (synced DW table), `timestamp_field` (timestamp column or HogQL expression), `key_field` (column whose value matches the entity key). Currently DW metrics only render on group profiles — person profiles are not yet supported.' ), math: zod .enum(['count', 'sum']) diff --git a/services/mcp/src/generated/email_templates/api.ts b/services/mcp/src/generated/email_templates/api.ts index db2a6f801778..f791b8ea7ba9 100644 --- a/services/mcp/src/generated/email_templates/api.ts +++ b/services/mcp/src/generated/email_templates/api.ts @@ -288,7 +288,7 @@ export const MessagingTemplatesDesignPartialUpdateBody = /* @__PURE__ */ zod.obj index: zod .number() .optional() - .describe('add_*\/move_content only. 0-based insert position; omit to append to the end.'), + .describe('add_*/move_content only. 0-based insert position; omit to append to the end.'), }) ) .optional() diff --git a/services/mcp/src/generated/endpoints/api.ts b/services/mcp/src/generated/endpoints/api.ts index c4b5bb85a112..f3b17ce9aaca 100644 --- a/services/mcp/src/generated/endpoints/api.ts +++ b/services/mcp/src/generated/endpoints/api.ts @@ -1033,7 +1033,7 @@ export const EndpointsRunCreateBody = /* @__PURE__ */ zod.object({ .default( endpointsRunCreateBodyFiltersOverrideOnePropertiesOneItemOneoneTypeDefault ) - .describe('Event property with "$feature/\" prepended'), + .describe('Event property with "$feature/" prepended'), value: zod .union([ zod.array(zod.union([zod.string(), zod.number(), zod.boolean()])), diff --git a/services/mcp/src/generated/error_tracking/api.ts b/services/mcp/src/generated/error_tracking/api.ts index 7fabe14506ad..44c1abe822a9 100644 --- a/services/mcp/src/generated/error_tracking/api.ts +++ b/services/mcp/src/generated/error_tracking/api.ts @@ -634,7 +634,7 @@ export const ErrorTrackingAssignmentRulesCreateBody = /* @__PURE__ */ zod.object type: zod .literal('feature') .default(errorTrackingAssignmentRulesCreateBodyFiltersOneValuesItemOnetwoTypeDefault) - .describe('Event property with "$feature/\" prepended'), + .describe('Event property with "$feature/" prepended'), value: zod .union([ zod.array(zod.union([zod.string(), zod.number(), zod.boolean()])), @@ -1743,7 +1743,7 @@ export const ErrorTrackingBypassRulesCreateBody = /* @__PURE__ */ zod.object({ type: zod .literal('feature') .default(errorTrackingBypassRulesCreateBodyFiltersOneValuesItemOnetwoTypeDefault) - .describe('Event property with "$feature/\" prepended'), + .describe('Event property with "$feature/" prepended'), value: zod .union([ zod.array(zod.union([zod.string(), zod.number(), zod.boolean()])), @@ -2823,7 +2823,7 @@ export const ErrorTrackingBypassRulesUpdateBody = /* @__PURE__ */ zod.object({ type: zod .literal('feature') .default(errorTrackingBypassRulesUpdateBodyFiltersOneValuesItemOnetwoTypeDefault) - .describe('Event property with "$feature/\" prepended'), + .describe('Event property with "$feature/" prepended'), value: zod .union([ zod.array(zod.union([zod.string(), zod.number(), zod.boolean()])), @@ -3933,7 +3933,7 @@ export const ErrorTrackingGroupingRulesCreateBody = /* @__PURE__ */ zod.object({ type: zod .literal('feature') .default(errorTrackingGroupingRulesCreateBodyFiltersOneValuesItemOnetwoTypeDefault) - .describe('Event property with "$feature/\" prepended'), + .describe('Event property with "$feature/" prepended'), value: zod .union([ zod.array(zod.union([zod.string(), zod.number(), zod.boolean()])), @@ -5038,7 +5038,7 @@ export const ErrorTrackingGroupingRulesUpdateBody = /* @__PURE__ */ zod.object({ type: zod .literal('feature') .default(errorTrackingGroupingRulesUpdateBodyFiltersOneValuesItemOnetwoTypeDefault) - .describe('Event property with "$feature/\" prepended'), + .describe('Event property with "$feature/" prepended'), value: zod .union([ zod.array(zod.union([zod.string(), zod.number(), zod.boolean()])), @@ -5682,7 +5682,7 @@ export const ErrorTrackingQueryIssueEventsCreateBody = /* @__PURE__ */ zod.objec zod.array(zod.union([zod.string(), zod.number()])), ]) .describe( - 'Value of your filter. For example `test@example.com` or `https:/\/example.com/test/`. Can be an array for an OR query, like `["test@example.com","ok@example.com"]`' + 'Value of your filter. For example `test@example.com` or `https://example.com/test/`. Can be an array for an OR query, like `["test@example.com","ok@example.com"]`' ), operator: zod .union([ @@ -5888,7 +5888,7 @@ export const ErrorTrackingQueryIssuesListCreateBody = /* @__PURE__ */ zod.object zod.array(zod.union([zod.string(), zod.number()])), ]) .describe( - 'Value of your filter. For example `test@example.com` or `https:/\/example.com/test/`. Can be an array for an OR query, like `["test@example.com","ok@example.com"]`' + 'Value of your filter. For example `test@example.com` or `https://example.com/test/`. Can be an array for an OR query, like `["test@example.com","ok@example.com"]`' ), operator: zod .union([ @@ -6711,7 +6711,7 @@ export const ErrorTrackingSuppressionRulesCreateBody = /* @__PURE__ */ zod.objec type: zod .literal('feature') .default(errorTrackingSuppressionRulesCreateBodyFiltersOneValuesItemOnetwoTypeDefault) - .describe('Event property with "$feature/\" prepended'), + .describe('Event property with "$feature/" prepended'), value: zod .union([ zod.array(zod.union([zod.string(), zod.number(), zod.boolean()])), @@ -7802,7 +7802,7 @@ export const ErrorTrackingSuppressionRulesUpdateBody = /* @__PURE__ */ zod.objec type: zod .literal('feature') .default(errorTrackingSuppressionRulesUpdateBodyFiltersOneValuesItemOnetwoTypeDefault) - .describe('Event property with "$feature/\" prepended'), + .describe('Event property with "$feature/" prepended'), value: zod .union([ zod.array(zod.union([zod.string(), zod.number(), zod.boolean()])), diff --git a/services/mcp/src/generated/experiments/api.ts b/services/mcp/src/generated/experiments/api.ts index 6c093c7d9122..085044b6e13c 100644 --- a/services/mcp/src/generated/experiments/api.ts +++ b/services/mcp/src/generated/experiments/api.ts @@ -282,7 +282,7 @@ export const ExperimentHoldoutsCreateBody = /* @__PURE__ */ zod ) .optional() .describe( - "Non-empty list of release-condition groups defining the held-out population, using the same shape as feature-flag release conditions. Each element's `rollout_percentage` (0–100, may be fractional) is the *\*exclusion*\* percentage — the share of users held back from all experiments that reference this holdout. `properties` optionally narrows the group by person/group properties. Do not set `variant`: the server normalizes it to `holdout-{id}`. Note that only the first element's `rollout_percentage` is embedded into each linked experiment's feature flag, and this population is shared across every experiment using the holdout." + "Non-empty list of release-condition groups defining the held-out population, using the same shape as feature-flag release conditions. Each element's `rollout_percentage` (0–100, may be fractional) is the **exclusion** percentage — the share of users held back from all experiments that reference this holdout. `properties` optionally narrows the group by person/group properties. Do not set `variant`: the server normalizes it to `holdout-{id}`. Note that only the first element's `rollout_percentage` is embedded into each linked experiment's feature flag, and this population is shared across every experiment using the holdout." ), }) .describe('A holdout group — a stable slice of users excluded from experiment exposure.') @@ -559,7 +559,7 @@ export const ExperimentHoldoutsPartialUpdateBody = /* @__PURE__ */ zod ) .optional() .describe( - "Non-empty list of release-condition groups defining the held-out population, using the same shape as feature-flag release conditions. Each element's `rollout_percentage` (0–100, may be fractional) is the *\*exclusion*\* percentage — the share of users held back from all experiments that reference this holdout. `properties` optionally narrows the group by person/group properties. Do not set `variant`: the server normalizes it to `holdout-{id}`. Note that only the first element's `rollout_percentage` is embedded into each linked experiment's feature flag, and this population is shared across every experiment using the holdout." + "Non-empty list of release-condition groups defining the held-out population, using the same shape as feature-flag release conditions. Each element's `rollout_percentage` (0–100, may be fractional) is the **exclusion** percentage — the share of users held back from all experiments that reference this holdout. `properties` optionally narrows the group by person/group properties. Do not set `variant`: the server normalizes it to `holdout-{id}`. Note that only the first element's `rollout_percentage` is embedded into each linked experiment's feature flag, and this population is shared across every experiment using the holdout." ), }) .describe('A holdout group — a stable slice of users excluded from experiment exposure.') @@ -1716,7 +1716,7 @@ export const ExperimentsCreateBody = /* @__PURE__ */ zod .default( experimentsCreateBodyExposureCriteriaOneExposureConfigOnePropertiesItemOneoneTypeDefault ) - .describe('Event property with "$feature/\" prepended'), + .describe('Event property with "$feature/" prepended'), value: zod .union([ zod.array( @@ -5269,7 +5269,7 @@ export const ExperimentsPartialUpdateBody = /* @__PURE__ */ zod .default( experimentsPartialUpdateBodyExposureCriteriaOneExposureConfigOnePropertiesItemOneoneTypeDefault ) - .describe('Event property with "$feature/\" prepended'), + .describe('Event property with "$feature/" prepended'), value: zod .union([ zod.array( @@ -8773,7 +8773,7 @@ export const ExperimentsDuplicateCreateBody = /* @__PURE__ */ zod .default( experimentsDuplicateCreateBodyExposureCriteriaOneExposureConfigOnePropertiesItemOneoneTypeDefault ) - .describe('Event property with "$feature/\" prepended'), + .describe('Event property with "$feature/" prepended'), value: zod .union([ zod.array( diff --git a/services/mcp/src/generated/logs/api.ts b/services/mcp/src/generated/logs/api.ts index 37eb1e3e1a8c..3c8347ce3e54 100644 --- a/services/mcp/src/generated/logs/api.ts +++ b/services/mcp/src/generated/logs/api.ts @@ -699,7 +699,7 @@ export const LogsAlertsCreateBody = /* @__PURE__ */ zod.object({ .default( logsAlertsCreateBodyFiltersOneFilterGroupOneValuesItemValuesItemOnetwoTypeDefault ) - .describe('Event property with "$feature/\" prepended'), + .describe('Event property with "$feature/" prepended'), value: zod .union([ zod.array(zod.union([zod.string(), zod.number(), zod.boolean()])), @@ -1919,7 +1919,7 @@ export const LogsAlertsPartialUpdateBody = /* @__PURE__ */ zod.object({ .default( logsAlertsPartialUpdateBodyFiltersOneFilterGroupOneValuesItemValuesItemOnetwoTypeDefault ) - .describe('Event property with "$feature/\" prepended'), + .describe('Event property with "$feature/" prepended'), value: zod .union([ zod.array(zod.union([zod.string(), zod.number(), zod.boolean()])), @@ -3203,7 +3203,7 @@ export const LogsAlertsSimulateCreateBody = /* @__PURE__ */ zod.object({ .default( logsAlertsSimulateCreateBodyFiltersOneFilterGroupOneValuesItemValuesItemOnetwoTypeDefault ) - .describe('Event property with "$feature/\" prepended'), + .describe('Event property with "$feature/" prepended'), value: zod .union([ zod.array(zod.union([zod.string(), zod.number(), zod.boolean()])), @@ -3779,7 +3779,7 @@ export const LogsAttributesRetrieveQueryParams = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "log", use "message". For "log_attribute"\/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' + 'Attribute key. For type "log", use "message". For "log_attribute"/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' ), type: zod .enum(['log', 'log_attribute', 'log_resource_attribute']) @@ -3889,7 +3889,7 @@ export const LogsCountCreateBody = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "log", use "message". For "log_attribute"\/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' + 'Attribute key. For type "log", use "message". For "log_attribute"/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' ), type: zod .enum(['log', 'log_attribute', 'log_resource_attribute']) @@ -3998,7 +3998,7 @@ export const LogsCountRangesCreateBody = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "log", use "message". For "log_attribute"\/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' + 'Attribute key. For type "log", use "message". For "log_attribute"/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' ), type: zod .enum(['log', 'log_attribute', 'log_resource_attribute']) @@ -4114,7 +4114,7 @@ export const LogsFacetValuesCreateBody = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "log", use "message". For "log_attribute"\/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' + 'Attribute key. For type "log", use "message". For "log_attribute"/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' ), type: zod .enum(['log', 'log_attribute', 'log_resource_attribute']) @@ -4204,7 +4204,7 @@ export const LogsPatternsCreateBody = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "log", use "message". For "log_attribute"\/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' + 'Attribute key. For type "log", use "message". For "log_attribute"/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' ), type: zod .enum(['log', 'log_attribute', 'log_resource_attribute']) @@ -4294,7 +4294,7 @@ export const LogsPatternsDiffCreateBody = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "log", use "message". For "log_attribute"\/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' + 'Attribute key. For type "log", use "message". For "log_attribute"/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' ), type: zod .enum(['log', 'log_attribute', 'log_resource_attribute']) @@ -4418,7 +4418,7 @@ export const LogsQueryCreateBody = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "log", use "message". For "log_attribute"\/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' + 'Attribute key. For type "log", use "message". For "log_attribute"/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' ), type: zod .enum(['log', 'log_attribute', 'log_resource_attribute']) @@ -4525,7 +4525,7 @@ export const LogsServicesCreateBody = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "log", use "message". For "log_attribute"\/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' + 'Attribute key. For type "log", use "message". For "log_attribute"/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' ), type: zod .enum(['log', 'log_attribute', 'log_resource_attribute']) @@ -4622,7 +4622,7 @@ export const LogsSparklineCreateBody = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "log", use "message". For "log_attribute"\/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' + 'Attribute key. For type "log", use "message". For "log_attribute"/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' ), type: zod .enum(['log', 'log_attribute', 'log_resource_attribute']) @@ -4714,7 +4714,7 @@ export const LogsValuesRetrieveQueryParams = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "log", use "message". For "log_attribute"\/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' + 'Attribute key. For type "log", use "message". For "log_attribute"/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' ), type: zod .enum(['log', 'log_attribute', 'log_resource_attribute']) diff --git a/services/mcp/src/generated/persons/api.ts b/services/mcp/src/generated/persons/api.ts index 3b993208c672..13db9ae13242 100644 --- a/services/mcp/src/generated/persons/api.ts +++ b/services/mcp/src/generated/persons/api.ts @@ -51,7 +51,7 @@ export const PersonsListQueryParams = /* @__PURE__ */ zod.object({ zod.array(zod.union([zod.string(), zod.number()])), ]) .describe( - 'Value of your filter. For example `test@example.com` or `https:/\/example.com/test/`. Can be an array for an OR query, like `["test@example.com","ok@example.com"]`' + 'Value of your filter. For example `test@example.com` or `https://example.com/test/`. Can be an array for an OR query, like `["test@example.com","ok@example.com"]`' ), operator: zod .union([ diff --git a/services/mcp/src/generated/product_analytics/api.ts b/services/mcp/src/generated/product_analytics/api.ts index 67b2ffd7d06a..de4ba62f125a 100644 --- a/services/mcp/src/generated/product_analytics/api.ts +++ b/services/mcp/src/generated/product_analytics/api.ts @@ -51,7 +51,7 @@ export const ElementsStatsRetrieveQueryParams = /* @__PURE__ */ zod.object({ .string() .optional() .describe( - 'JSON-encoded list of property filters to apply to the underlying events, e.g. [{"key": "$current_url", "value": "https:/\/example.com/page"}] or [{"key": "email", "value": "@posthog.com", "operator": "icontains", "type": "person"}]. Supports event, person, cohort, element, and HogQL property filter types.' + 'JSON-encoded list of property filters to apply to the underlying events, e.g. [{"key": "$current_url", "value": "https://example.com/page"}] or [{"key": "email", "value": "@posthog.com", "operator": "icontains", "type": "person"}]. Supports event, person, cohort, element, and HogQL property filter types.' ), sampling_factor: zod.number().optional().describe('Sampling factor between 0 and 1'), }) diff --git a/services/mcp/src/generated/tracing/api.ts b/services/mcp/src/generated/tracing/api.ts index 4c6209504c6d..aec1e7a61234 100644 --- a/services/mcp/src/generated/tracing/api.ts +++ b/services/mcp/src/generated/tracing/api.ts @@ -63,7 +63,7 @@ export const TracingSpansAggregateCreateBody = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "span", use built-in fields (trace_id, span_id, duration, name, kind, status_code, is_root_span). For "span_attribute"\/"span_resource_attribute", use the attribute key (e.g. "http.method").' + 'Attribute key. For type "span", use built-in fields (trace_id, span_id, duration, name, kind, status_code, is_root_span). For "span_attribute"/"span_resource_attribute", use the attribute key (e.g. "http.method").' ), type: zod .enum(['span', 'span_attribute', 'span_resource_attribute']) @@ -188,7 +188,7 @@ export const TracingSpansAttributeBreakdownCreateBody = /* @__PURE__ */ zod.obje key: zod .string() .describe( - 'Attribute key. For type "span", use built-in fields (trace_id, span_id, duration, name, kind, status_code, is_root_span). For "span_attribute"\/"span_resource_attribute", use the attribute key (e.g. "http.method").' + 'Attribute key. For type "span", use built-in fields (trace_id, span_id, duration, name, kind, status_code, is_root_span). For "span_attribute"/"span_resource_attribute", use the attribute key (e.g. "http.method").' ), type: zod .enum(['span', 'span_attribute', 'span_resource_attribute']) @@ -313,7 +313,7 @@ export const TracingSpansCountCreateBody = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "span", use built-in fields (trace_id, span_id, duration, name, kind, status_code, is_root_span). For "span_attribute"\/"span_resource_attribute", use the attribute key (e.g. "http.method").' + 'Attribute key. For type "span", use built-in fields (trace_id, span_id, duration, name, kind, status_code, is_root_span). For "span_attribute"/"span_resource_attribute", use the attribute key (e.g. "http.method").' ), type: zod .enum(['span', 'span_attribute', 'span_resource_attribute']) @@ -398,7 +398,7 @@ export const TracingSpansDurationHistogramCreateBody = /* @__PURE__ */ zod.objec key: zod .string() .describe( - 'Attribute key. For type "span", use built-in fields (trace_id, span_id, duration, name, kind, status_code, is_root_span). For "span_attribute"\/"span_resource_attribute", use the attribute key (e.g. "http.method").' + 'Attribute key. For type "span", use built-in fields (trace_id, span_id, duration, name, kind, status_code, is_root_span). For "span_attribute"/"span_resource_attribute", use the attribute key (e.g. "http.method").' ), type: zod .enum(['span', 'span_attribute', 'span_resource_attribute']) @@ -508,7 +508,7 @@ export const TracingSpansQueryCreateBody = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "span", use built-in fields (trace_id, span_id, duration, name, kind, status_code, is_root_span). For "span_attribute"\/"span_resource_attribute", use the attribute key (e.g. "http.method").' + 'Attribute key. For type "span", use built-in fields (trace_id, span_id, duration, name, kind, status_code, is_root_span). For "span_attribute"/"span_resource_attribute", use the attribute key (e.g. "http.method").' ), type: zod .enum(['span', 'span_attribute', 'span_resource_attribute']) @@ -637,7 +637,7 @@ export const TracingSpansSparklineCreateBody = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "span", use built-in fields (trace_id, span_id, duration, name, kind, status_code, is_root_span). For "span_attribute"\/"span_resource_attribute", use the attribute key (e.g. "http.method").' + 'Attribute key. For type "span", use built-in fields (trace_id, span_id, duration, name, kind, status_code, is_root_span). For "span_attribute"/"span_resource_attribute", use the attribute key (e.g. "http.method").' ), type: zod .enum(['span', 'span_attribute', 'span_resource_attribute']) @@ -796,7 +796,7 @@ export const TracingSpansTreeCreateBody = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "span", use built-in fields (trace_id, span_id, duration, name, kind, status_code, is_root_span). For "span_attribute"\/"span_resource_attribute", use the attribute key (e.g. "http.method").' + 'Attribute key. For type "span", use built-in fields (trace_id, span_id, duration, name, kind, status_code, is_root_span). For "span_attribute"/"span_resource_attribute", use the attribute key (e.g. "http.method").' ), type: zod .enum(['span', 'span_attribute', 'span_resource_attribute']) From 63637edaf809223248daaf8deab45324cc5b1c04 Mon Sep 17 00:00:00 2001 From: "tests-posthog[bot]" <250237707+tests-posthog[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:31:13 +0000 Subject: [PATCH 6/6] chore: update OpenAPI generated types --- .../mcp/src/generated/agent_platform/api.ts | 4 +-- .../src/generated/customer_analytics/api.ts | 4 +-- .../mcp/src/generated/email_templates/api.ts | 2 +- services/mcp/src/generated/endpoints/api.ts | 2 +- .../mcp/src/generated/error_tracking/api.ts | 18 ++++++------- services/mcp/src/generated/experiments/api.ts | 10 +++---- services/mcp/src/generated/logs/api.ts | 26 +++++++++---------- services/mcp/src/generated/persons/api.ts | 2 +- .../src/generated/product_analytics/api.ts | 2 +- services/mcp/src/generated/tracing/api.ts | 14 +++++----- 10 files changed, 42 insertions(+), 42 deletions(-) diff --git a/services/mcp/src/generated/agent_platform/api.ts b/services/mcp/src/generated/agent_platform/api.ts index 3eed1fdf9eb0..fe4f2b5348ac 100644 --- a/services/mcp/src/generated/agent_platform/api.ts +++ b/services/mcp/src/generated/agent_platform/api.ts @@ -164,7 +164,7 @@ export const AgentApplicationsRevisionsCreateBody = /* @__PURE__ */ zod.object({ .string() .default(agentApplicationsRevisionsCreateBodyBundleUriDefault) .describe( - 'Storage-prefix metadata for the bundle, e.g. `fs://my-agent/`. Optional — leave blank and the server fills `fs:///`. Bundles are addressed by revision id regardless, so this is only a prefix hint.' + 'Storage-prefix metadata for the bundle, e.g. `fs:/\/my-agent/`. Optional — leave blank and the server fills `fs:/\//`. Bundles are addressed by revision id regardless, so this is only a prefix hint.' ), spec: zod.unknown().optional(), }) @@ -249,7 +249,7 @@ export const AgentApplicationsRevisionsPartialUpdateBody = /* @__PURE__ */ zod.o .string() .optional() .describe( - 'Storage-prefix metadata for the bundle, e.g. `fs://my-agent/`. Optional — leave blank and the server fills `fs:///`. Bundles are addressed by revision id regardless, so this is only a prefix hint.' + 'Storage-prefix metadata for the bundle, e.g. `fs:/\/my-agent/`. Optional — leave blank and the server fills `fs:/\//`. Bundles are addressed by revision id regardless, so this is only a prefix hint.' ), spec: zod.unknown().optional(), }) diff --git a/services/mcp/src/generated/customer_analytics/api.ts b/services/mcp/src/generated/customer_analytics/api.ts index 55bf91fdb824..34f417a4fc4e 100644 --- a/services/mcp/src/generated/customer_analytics/api.ts +++ b/services/mcp/src/generated/customer_analytics/api.ts @@ -666,7 +666,7 @@ export const GroupsTypesMetricsCreateBody = /* @__PURE__ */ zod.object({ filters: zod .record(zod.string(), zod.unknown()) .describe( - 'Filter definition for the metric. Two shapes are accepted, discriminated by an optional `source` key.\n\n**Events** (default, when `source` is missing or `"events"`): HogFunction filter shape — `events: [...]`, optional `actions: [...]`, `properties: [...]`, `filter_test_accounts: bool`.\n\n**Data warehouse** (`source: "data_warehouse"`): `table_name` (synced DW table), `timestamp_field` (timestamp column or HogQL expression), `key_field` (column whose value matches the entity key). Currently DW metrics only render on group profiles — person profiles are not yet supported.' + 'Filter definition for the metric. Two shapes are accepted, discriminated by an optional `source` key.\n\n*\*Events*\* (default, when `source` is missing or `"events"`): HogFunction filter shape — `events: [...]`, optional `actions: [...]`, `properties: [...]`, `filter_test_accounts: bool`.\n\n*\*Data warehouse*\* (`source: "data_warehouse"`): `table_name` (synced DW table), `timestamp_field` (timestamp column or HogQL expression), `key_field` (column whose value matches the entity key). Currently DW metrics only render on group profiles — person profiles are not yet supported.' ), math: zod .enum(['count', 'sum']) @@ -748,7 +748,7 @@ export const GroupsTypesMetricsPartialUpdateBody = /* @__PURE__ */ zod.object({ .record(zod.string(), zod.unknown()) .optional() .describe( - 'Filter definition for the metric. Two shapes are accepted, discriminated by an optional `source` key.\n\n**Events** (default, when `source` is missing or `"events"`): HogFunction filter shape — `events: [...]`, optional `actions: [...]`, `properties: [...]`, `filter_test_accounts: bool`.\n\n**Data warehouse** (`source: "data_warehouse"`): `table_name` (synced DW table), `timestamp_field` (timestamp column or HogQL expression), `key_field` (column whose value matches the entity key). Currently DW metrics only render on group profiles — person profiles are not yet supported.' + 'Filter definition for the metric. Two shapes are accepted, discriminated by an optional `source` key.\n\n*\*Events*\* (default, when `source` is missing or `"events"`): HogFunction filter shape — `events: [...]`, optional `actions: [...]`, `properties: [...]`, `filter_test_accounts: bool`.\n\n*\*Data warehouse*\* (`source: "data_warehouse"`): `table_name` (synced DW table), `timestamp_field` (timestamp column or HogQL expression), `key_field` (column whose value matches the entity key). Currently DW metrics only render on group profiles — person profiles are not yet supported.' ), math: zod .enum(['count', 'sum']) diff --git a/services/mcp/src/generated/email_templates/api.ts b/services/mcp/src/generated/email_templates/api.ts index f791b8ea7ba9..db2a6f801778 100644 --- a/services/mcp/src/generated/email_templates/api.ts +++ b/services/mcp/src/generated/email_templates/api.ts @@ -288,7 +288,7 @@ export const MessagingTemplatesDesignPartialUpdateBody = /* @__PURE__ */ zod.obj index: zod .number() .optional() - .describe('add_*/move_content only. 0-based insert position; omit to append to the end.'), + .describe('add_*\/move_content only. 0-based insert position; omit to append to the end.'), }) ) .optional() diff --git a/services/mcp/src/generated/endpoints/api.ts b/services/mcp/src/generated/endpoints/api.ts index f3b17ce9aaca..c4b5bb85a112 100644 --- a/services/mcp/src/generated/endpoints/api.ts +++ b/services/mcp/src/generated/endpoints/api.ts @@ -1033,7 +1033,7 @@ export const EndpointsRunCreateBody = /* @__PURE__ */ zod.object({ .default( endpointsRunCreateBodyFiltersOverrideOnePropertiesOneItemOneoneTypeDefault ) - .describe('Event property with "$feature/" prepended'), + .describe('Event property with "$feature/\" prepended'), value: zod .union([ zod.array(zod.union([zod.string(), zod.number(), zod.boolean()])), diff --git a/services/mcp/src/generated/error_tracking/api.ts b/services/mcp/src/generated/error_tracking/api.ts index 44c1abe822a9..7fabe14506ad 100644 --- a/services/mcp/src/generated/error_tracking/api.ts +++ b/services/mcp/src/generated/error_tracking/api.ts @@ -634,7 +634,7 @@ export const ErrorTrackingAssignmentRulesCreateBody = /* @__PURE__ */ zod.object type: zod .literal('feature') .default(errorTrackingAssignmentRulesCreateBodyFiltersOneValuesItemOnetwoTypeDefault) - .describe('Event property with "$feature/" prepended'), + .describe('Event property with "$feature/\" prepended'), value: zod .union([ zod.array(zod.union([zod.string(), zod.number(), zod.boolean()])), @@ -1743,7 +1743,7 @@ export const ErrorTrackingBypassRulesCreateBody = /* @__PURE__ */ zod.object({ type: zod .literal('feature') .default(errorTrackingBypassRulesCreateBodyFiltersOneValuesItemOnetwoTypeDefault) - .describe('Event property with "$feature/" prepended'), + .describe('Event property with "$feature/\" prepended'), value: zod .union([ zod.array(zod.union([zod.string(), zod.number(), zod.boolean()])), @@ -2823,7 +2823,7 @@ export const ErrorTrackingBypassRulesUpdateBody = /* @__PURE__ */ zod.object({ type: zod .literal('feature') .default(errorTrackingBypassRulesUpdateBodyFiltersOneValuesItemOnetwoTypeDefault) - .describe('Event property with "$feature/" prepended'), + .describe('Event property with "$feature/\" prepended'), value: zod .union([ zod.array(zod.union([zod.string(), zod.number(), zod.boolean()])), @@ -3933,7 +3933,7 @@ export const ErrorTrackingGroupingRulesCreateBody = /* @__PURE__ */ zod.object({ type: zod .literal('feature') .default(errorTrackingGroupingRulesCreateBodyFiltersOneValuesItemOnetwoTypeDefault) - .describe('Event property with "$feature/" prepended'), + .describe('Event property with "$feature/\" prepended'), value: zod .union([ zod.array(zod.union([zod.string(), zod.number(), zod.boolean()])), @@ -5038,7 +5038,7 @@ export const ErrorTrackingGroupingRulesUpdateBody = /* @__PURE__ */ zod.object({ type: zod .literal('feature') .default(errorTrackingGroupingRulesUpdateBodyFiltersOneValuesItemOnetwoTypeDefault) - .describe('Event property with "$feature/" prepended'), + .describe('Event property with "$feature/\" prepended'), value: zod .union([ zod.array(zod.union([zod.string(), zod.number(), zod.boolean()])), @@ -5682,7 +5682,7 @@ export const ErrorTrackingQueryIssueEventsCreateBody = /* @__PURE__ */ zod.objec zod.array(zod.union([zod.string(), zod.number()])), ]) .describe( - 'Value of your filter. For example `test@example.com` or `https://example.com/test/`. Can be an array for an OR query, like `["test@example.com","ok@example.com"]`' + 'Value of your filter. For example `test@example.com` or `https:/\/example.com/test/`. Can be an array for an OR query, like `["test@example.com","ok@example.com"]`' ), operator: zod .union([ @@ -5888,7 +5888,7 @@ export const ErrorTrackingQueryIssuesListCreateBody = /* @__PURE__ */ zod.object zod.array(zod.union([zod.string(), zod.number()])), ]) .describe( - 'Value of your filter. For example `test@example.com` or `https://example.com/test/`. Can be an array for an OR query, like `["test@example.com","ok@example.com"]`' + 'Value of your filter. For example `test@example.com` or `https:/\/example.com/test/`. Can be an array for an OR query, like `["test@example.com","ok@example.com"]`' ), operator: zod .union([ @@ -6711,7 +6711,7 @@ export const ErrorTrackingSuppressionRulesCreateBody = /* @__PURE__ */ zod.objec type: zod .literal('feature') .default(errorTrackingSuppressionRulesCreateBodyFiltersOneValuesItemOnetwoTypeDefault) - .describe('Event property with "$feature/" prepended'), + .describe('Event property with "$feature/\" prepended'), value: zod .union([ zod.array(zod.union([zod.string(), zod.number(), zod.boolean()])), @@ -7802,7 +7802,7 @@ export const ErrorTrackingSuppressionRulesUpdateBody = /* @__PURE__ */ zod.objec type: zod .literal('feature') .default(errorTrackingSuppressionRulesUpdateBodyFiltersOneValuesItemOnetwoTypeDefault) - .describe('Event property with "$feature/" prepended'), + .describe('Event property with "$feature/\" prepended'), value: zod .union([ zod.array(zod.union([zod.string(), zod.number(), zod.boolean()])), diff --git a/services/mcp/src/generated/experiments/api.ts b/services/mcp/src/generated/experiments/api.ts index 085044b6e13c..6c093c7d9122 100644 --- a/services/mcp/src/generated/experiments/api.ts +++ b/services/mcp/src/generated/experiments/api.ts @@ -282,7 +282,7 @@ export const ExperimentHoldoutsCreateBody = /* @__PURE__ */ zod ) .optional() .describe( - "Non-empty list of release-condition groups defining the held-out population, using the same shape as feature-flag release conditions. Each element's `rollout_percentage` (0–100, may be fractional) is the **exclusion** percentage — the share of users held back from all experiments that reference this holdout. `properties` optionally narrows the group by person/group properties. Do not set `variant`: the server normalizes it to `holdout-{id}`. Note that only the first element's `rollout_percentage` is embedded into each linked experiment's feature flag, and this population is shared across every experiment using the holdout." + "Non-empty list of release-condition groups defining the held-out population, using the same shape as feature-flag release conditions. Each element's `rollout_percentage` (0–100, may be fractional) is the *\*exclusion*\* percentage — the share of users held back from all experiments that reference this holdout. `properties` optionally narrows the group by person/group properties. Do not set `variant`: the server normalizes it to `holdout-{id}`. Note that only the first element's `rollout_percentage` is embedded into each linked experiment's feature flag, and this population is shared across every experiment using the holdout." ), }) .describe('A holdout group — a stable slice of users excluded from experiment exposure.') @@ -559,7 +559,7 @@ export const ExperimentHoldoutsPartialUpdateBody = /* @__PURE__ */ zod ) .optional() .describe( - "Non-empty list of release-condition groups defining the held-out population, using the same shape as feature-flag release conditions. Each element's `rollout_percentage` (0–100, may be fractional) is the **exclusion** percentage — the share of users held back from all experiments that reference this holdout. `properties` optionally narrows the group by person/group properties. Do not set `variant`: the server normalizes it to `holdout-{id}`. Note that only the first element's `rollout_percentage` is embedded into each linked experiment's feature flag, and this population is shared across every experiment using the holdout." + "Non-empty list of release-condition groups defining the held-out population, using the same shape as feature-flag release conditions. Each element's `rollout_percentage` (0–100, may be fractional) is the *\*exclusion*\* percentage — the share of users held back from all experiments that reference this holdout. `properties` optionally narrows the group by person/group properties. Do not set `variant`: the server normalizes it to `holdout-{id}`. Note that only the first element's `rollout_percentage` is embedded into each linked experiment's feature flag, and this population is shared across every experiment using the holdout." ), }) .describe('A holdout group — a stable slice of users excluded from experiment exposure.') @@ -1716,7 +1716,7 @@ export const ExperimentsCreateBody = /* @__PURE__ */ zod .default( experimentsCreateBodyExposureCriteriaOneExposureConfigOnePropertiesItemOneoneTypeDefault ) - .describe('Event property with "$feature/" prepended'), + .describe('Event property with "$feature/\" prepended'), value: zod .union([ zod.array( @@ -5269,7 +5269,7 @@ export const ExperimentsPartialUpdateBody = /* @__PURE__ */ zod .default( experimentsPartialUpdateBodyExposureCriteriaOneExposureConfigOnePropertiesItemOneoneTypeDefault ) - .describe('Event property with "$feature/" prepended'), + .describe('Event property with "$feature/\" prepended'), value: zod .union([ zod.array( @@ -8773,7 +8773,7 @@ export const ExperimentsDuplicateCreateBody = /* @__PURE__ */ zod .default( experimentsDuplicateCreateBodyExposureCriteriaOneExposureConfigOnePropertiesItemOneoneTypeDefault ) - .describe('Event property with "$feature/" prepended'), + .describe('Event property with "$feature/\" prepended'), value: zod .union([ zod.array( diff --git a/services/mcp/src/generated/logs/api.ts b/services/mcp/src/generated/logs/api.ts index 3c8347ce3e54..37eb1e3e1a8c 100644 --- a/services/mcp/src/generated/logs/api.ts +++ b/services/mcp/src/generated/logs/api.ts @@ -699,7 +699,7 @@ export const LogsAlertsCreateBody = /* @__PURE__ */ zod.object({ .default( logsAlertsCreateBodyFiltersOneFilterGroupOneValuesItemValuesItemOnetwoTypeDefault ) - .describe('Event property with "$feature/" prepended'), + .describe('Event property with "$feature/\" prepended'), value: zod .union([ zod.array(zod.union([zod.string(), zod.number(), zod.boolean()])), @@ -1919,7 +1919,7 @@ export const LogsAlertsPartialUpdateBody = /* @__PURE__ */ zod.object({ .default( logsAlertsPartialUpdateBodyFiltersOneFilterGroupOneValuesItemValuesItemOnetwoTypeDefault ) - .describe('Event property with "$feature/" prepended'), + .describe('Event property with "$feature/\" prepended'), value: zod .union([ zod.array(zod.union([zod.string(), zod.number(), zod.boolean()])), @@ -3203,7 +3203,7 @@ export const LogsAlertsSimulateCreateBody = /* @__PURE__ */ zod.object({ .default( logsAlertsSimulateCreateBodyFiltersOneFilterGroupOneValuesItemValuesItemOnetwoTypeDefault ) - .describe('Event property with "$feature/" prepended'), + .describe('Event property with "$feature/\" prepended'), value: zod .union([ zod.array(zod.union([zod.string(), zod.number(), zod.boolean()])), @@ -3779,7 +3779,7 @@ export const LogsAttributesRetrieveQueryParams = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "log", use "message". For "log_attribute"/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' + 'Attribute key. For type "log", use "message". For "log_attribute"\/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' ), type: zod .enum(['log', 'log_attribute', 'log_resource_attribute']) @@ -3889,7 +3889,7 @@ export const LogsCountCreateBody = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "log", use "message". For "log_attribute"/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' + 'Attribute key. For type "log", use "message". For "log_attribute"\/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' ), type: zod .enum(['log', 'log_attribute', 'log_resource_attribute']) @@ -3998,7 +3998,7 @@ export const LogsCountRangesCreateBody = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "log", use "message". For "log_attribute"/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' + 'Attribute key. For type "log", use "message". For "log_attribute"\/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' ), type: zod .enum(['log', 'log_attribute', 'log_resource_attribute']) @@ -4114,7 +4114,7 @@ export const LogsFacetValuesCreateBody = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "log", use "message". For "log_attribute"/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' + 'Attribute key. For type "log", use "message". For "log_attribute"\/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' ), type: zod .enum(['log', 'log_attribute', 'log_resource_attribute']) @@ -4204,7 +4204,7 @@ export const LogsPatternsCreateBody = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "log", use "message". For "log_attribute"/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' + 'Attribute key. For type "log", use "message". For "log_attribute"\/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' ), type: zod .enum(['log', 'log_attribute', 'log_resource_attribute']) @@ -4294,7 +4294,7 @@ export const LogsPatternsDiffCreateBody = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "log", use "message". For "log_attribute"/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' + 'Attribute key. For type "log", use "message". For "log_attribute"\/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' ), type: zod .enum(['log', 'log_attribute', 'log_resource_attribute']) @@ -4418,7 +4418,7 @@ export const LogsQueryCreateBody = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "log", use "message". For "log_attribute"/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' + 'Attribute key. For type "log", use "message". For "log_attribute"\/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' ), type: zod .enum(['log', 'log_attribute', 'log_resource_attribute']) @@ -4525,7 +4525,7 @@ export const LogsServicesCreateBody = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "log", use "message". For "log_attribute"/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' + 'Attribute key. For type "log", use "message". For "log_attribute"\/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' ), type: zod .enum(['log', 'log_attribute', 'log_resource_attribute']) @@ -4622,7 +4622,7 @@ export const LogsSparklineCreateBody = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "log", use "message". For "log_attribute"/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' + 'Attribute key. For type "log", use "message". For "log_attribute"\/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' ), type: zod .enum(['log', 'log_attribute', 'log_resource_attribute']) @@ -4714,7 +4714,7 @@ export const LogsValuesRetrieveQueryParams = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "log", use "message". For "log_attribute"/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' + 'Attribute key. For type "log", use "message". For "log_attribute"\/"log_resource_attribute", use the attribute key (e.g. "k8s.container.name").' ), type: zod .enum(['log', 'log_attribute', 'log_resource_attribute']) diff --git a/services/mcp/src/generated/persons/api.ts b/services/mcp/src/generated/persons/api.ts index 13db9ae13242..3b993208c672 100644 --- a/services/mcp/src/generated/persons/api.ts +++ b/services/mcp/src/generated/persons/api.ts @@ -51,7 +51,7 @@ export const PersonsListQueryParams = /* @__PURE__ */ zod.object({ zod.array(zod.union([zod.string(), zod.number()])), ]) .describe( - 'Value of your filter. For example `test@example.com` or `https://example.com/test/`. Can be an array for an OR query, like `["test@example.com","ok@example.com"]`' + 'Value of your filter. For example `test@example.com` or `https:/\/example.com/test/`. Can be an array for an OR query, like `["test@example.com","ok@example.com"]`' ), operator: zod .union([ diff --git a/services/mcp/src/generated/product_analytics/api.ts b/services/mcp/src/generated/product_analytics/api.ts index de4ba62f125a..67b2ffd7d06a 100644 --- a/services/mcp/src/generated/product_analytics/api.ts +++ b/services/mcp/src/generated/product_analytics/api.ts @@ -51,7 +51,7 @@ export const ElementsStatsRetrieveQueryParams = /* @__PURE__ */ zod.object({ .string() .optional() .describe( - 'JSON-encoded list of property filters to apply to the underlying events, e.g. [{"key": "$current_url", "value": "https://example.com/page"}] or [{"key": "email", "value": "@posthog.com", "operator": "icontains", "type": "person"}]. Supports event, person, cohort, element, and HogQL property filter types.' + 'JSON-encoded list of property filters to apply to the underlying events, e.g. [{"key": "$current_url", "value": "https:/\/example.com/page"}] or [{"key": "email", "value": "@posthog.com", "operator": "icontains", "type": "person"}]. Supports event, person, cohort, element, and HogQL property filter types.' ), sampling_factor: zod.number().optional().describe('Sampling factor between 0 and 1'), }) diff --git a/services/mcp/src/generated/tracing/api.ts b/services/mcp/src/generated/tracing/api.ts index aec1e7a61234..4c6209504c6d 100644 --- a/services/mcp/src/generated/tracing/api.ts +++ b/services/mcp/src/generated/tracing/api.ts @@ -63,7 +63,7 @@ export const TracingSpansAggregateCreateBody = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "span", use built-in fields (trace_id, span_id, duration, name, kind, status_code, is_root_span). For "span_attribute"/"span_resource_attribute", use the attribute key (e.g. "http.method").' + 'Attribute key. For type "span", use built-in fields (trace_id, span_id, duration, name, kind, status_code, is_root_span). For "span_attribute"\/"span_resource_attribute", use the attribute key (e.g. "http.method").' ), type: zod .enum(['span', 'span_attribute', 'span_resource_attribute']) @@ -188,7 +188,7 @@ export const TracingSpansAttributeBreakdownCreateBody = /* @__PURE__ */ zod.obje key: zod .string() .describe( - 'Attribute key. For type "span", use built-in fields (trace_id, span_id, duration, name, kind, status_code, is_root_span). For "span_attribute"/"span_resource_attribute", use the attribute key (e.g. "http.method").' + 'Attribute key. For type "span", use built-in fields (trace_id, span_id, duration, name, kind, status_code, is_root_span). For "span_attribute"\/"span_resource_attribute", use the attribute key (e.g. "http.method").' ), type: zod .enum(['span', 'span_attribute', 'span_resource_attribute']) @@ -313,7 +313,7 @@ export const TracingSpansCountCreateBody = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "span", use built-in fields (trace_id, span_id, duration, name, kind, status_code, is_root_span). For "span_attribute"/"span_resource_attribute", use the attribute key (e.g. "http.method").' + 'Attribute key. For type "span", use built-in fields (trace_id, span_id, duration, name, kind, status_code, is_root_span). For "span_attribute"\/"span_resource_attribute", use the attribute key (e.g. "http.method").' ), type: zod .enum(['span', 'span_attribute', 'span_resource_attribute']) @@ -398,7 +398,7 @@ export const TracingSpansDurationHistogramCreateBody = /* @__PURE__ */ zod.objec key: zod .string() .describe( - 'Attribute key. For type "span", use built-in fields (trace_id, span_id, duration, name, kind, status_code, is_root_span). For "span_attribute"/"span_resource_attribute", use the attribute key (e.g. "http.method").' + 'Attribute key. For type "span", use built-in fields (trace_id, span_id, duration, name, kind, status_code, is_root_span). For "span_attribute"\/"span_resource_attribute", use the attribute key (e.g. "http.method").' ), type: zod .enum(['span', 'span_attribute', 'span_resource_attribute']) @@ -508,7 +508,7 @@ export const TracingSpansQueryCreateBody = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "span", use built-in fields (trace_id, span_id, duration, name, kind, status_code, is_root_span). For "span_attribute"/"span_resource_attribute", use the attribute key (e.g. "http.method").' + 'Attribute key. For type "span", use built-in fields (trace_id, span_id, duration, name, kind, status_code, is_root_span). For "span_attribute"\/"span_resource_attribute", use the attribute key (e.g. "http.method").' ), type: zod .enum(['span', 'span_attribute', 'span_resource_attribute']) @@ -637,7 +637,7 @@ export const TracingSpansSparklineCreateBody = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "span", use built-in fields (trace_id, span_id, duration, name, kind, status_code, is_root_span). For "span_attribute"/"span_resource_attribute", use the attribute key (e.g. "http.method").' + 'Attribute key. For type "span", use built-in fields (trace_id, span_id, duration, name, kind, status_code, is_root_span). For "span_attribute"\/"span_resource_attribute", use the attribute key (e.g. "http.method").' ), type: zod .enum(['span', 'span_attribute', 'span_resource_attribute']) @@ -796,7 +796,7 @@ export const TracingSpansTreeCreateBody = /* @__PURE__ */ zod.object({ key: zod .string() .describe( - 'Attribute key. For type "span", use built-in fields (trace_id, span_id, duration, name, kind, status_code, is_root_span). For "span_attribute"/"span_resource_attribute", use the attribute key (e.g. "http.method").' + 'Attribute key. For type "span", use built-in fields (trace_id, span_id, duration, name, kind, status_code, is_root_span). For "span_attribute"\/"span_resource_attribute", use the attribute key (e.g. "http.method").' ), type: zod .enum(['span', 'span_attribute', 'span_resource_attribute'])