From 8fad166062f405e2af65b8aa0d72b0bbb2cc4f6e Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Mon, 20 Jul 2026 19:57:16 -0400 Subject: [PATCH 1/2] feat(tasks): link a Channel to the desktop-fs folder that renders it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A channel exists as two unrelated rows today: the Channel model (feed, membership, Task.channel FK) and a desktop-surface FileSystem folder (the tree the app renders — canvases and task filings nest under its path). The only join between them is display-name matching, with the normalization function mirrored client- and server-side. Anything that needs to go from a task to the channel's folder (e.g. agent-side canvas placement) is stranded one hop short, or has to scrape filing-row conventions. Give Channel a folder FK (nullable, SET_NULL, db_constraint=False like the model's other FKs, one live channel per folder via a partial unique constraint). Clients that own folder creation pass folder_id on the resolve-or-create POST, or PATCH it onto an existing channel (personal included) to backfill lazily. Claim semantics: first claim wins, a claimed folder can't be stolen, re-linking is allowed once the old folder row is gone, and non-desktop / non-folder / foreign-team rows are ignored. The task_channels serializer now returns folder_id, so any consumer can resolve task -> channel -> folder purely by ids. Generated-By: PostHog Code Task-Id: 09832237-2f58-4ab7-adf6-231aeb82e1fe --- products/tasks/backend/facade/api.py | 46 ++++++++++++- products/tasks/backend/facade/contracts.py | 2 + ...lder_channel_task_channel_folder_unique.py | 36 ++++++++++ .../backend/migrations/max_migration.txt | 2 +- products/tasks/backend/models.py | 14 ++++ .../tasks/backend/presentation/serializers.py | 14 +++- .../presentation/views/channels_api.py | 51 +++++++++++---- .../tasks/backend/tests/test_channels_api.py | 65 +++++++++++++++++++ 8 files changed, 212 insertions(+), 18 deletions(-) create mode 100644 products/tasks/backend/migrations/0063_channel_folder_channel_task_channel_folder_unique.py diff --git a/products/tasks/backend/facade/api.py b/products/tasks/backend/facade/api.py index 518c54054c69..7bcff2ba10b2 100644 --- a/products/tasks/backend/facade/api.py +++ b/products/tasks/backend/facade/api.py @@ -33,6 +33,8 @@ from posthog.event_usage import groups from posthog.models import Team, User +from posthog.models.file_system.constants import DESKTOP_SURFACE, surface_q +from posthog.models.file_system.file_system import FileSystem from posthog.models.integration import Integration from products.tasks.backend.constants import ( @@ -4980,9 +4982,43 @@ def _channel_to_dto(channel: Channel) -> contracts.ChannelDTO: channel_type=channel.channel_type, created_at=channel.created_at, created_by=_user_basic_info(channel.created_by if channel.created_by_id else None), + folder_id=channel.folder_id, ) +def _link_channel_folder(channel: Channel, *, folder_id: UUID) -> None: + """Record the desktop-fs folder that renders this channel. First claim wins: + an already-linked channel keeps its folder unless that row is gone (repair), + and a folder already claimed by another live channel stays theirs (the partial + unique constraint rejects the steal; we swallow it and leave this one unlinked). + The folder must be a desktop-surface folder row on the same team.""" + if channel.folder_id == folder_id: + return + folder_exists = FileSystem.objects.filter( + surface_q(DESKTOP_SURFACE), id=folder_id, team_id=channel.team_id, type="folder" + ).exists() + if not folder_exists: + return + if channel.folder_id is not None and FileSystem.objects.filter(id=channel.folder_id).exists(): + return + channel.folder_id = folder_id + try: + with transaction.atomic(): + channel.save(update_fields=["folder", "updated_at"]) + except IntegrityError: + channel.folder_id = None + + +def link_channel_folder(channel_id: str | UUID, team_id: int, *, folder_id: UUID) -> contracts.ChannelDTO | str: + """Attach the desktop-fs folder that renders a channel (personal channels + included). Returns the DTO — unchanged if the claim was ignored — or ``not_found``.""" + channel = Channel.objects.select_related("created_by").filter(id=channel_id, team_id=team_id, deleted=False).first() + if channel is None: + return "not_found" + _link_channel_folder(channel, folder_id=folder_id) + return _channel_to_dto(channel) + + def _ensure_personal_channel(team_id: int, user_id: int) -> Channel: # select_related so _channel_to_dto doesn't lazy-load created_by per call. try: @@ -5038,9 +5074,13 @@ def _emit_channel_created(channel: Channel, user_id: int | None) -> None: 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: +def resolve_channel( + team_id: int, user_id: int | None, *, name: str, folder_id: UUID | None = None +) -> contracts.ChannelDTO | None: """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.""" + Emits a ``channel_created`` feed message the first time a channel is created. When the + caller passes the desktop-fs ``folder_id`` it renders the channel from, the folder is + linked (see ``_link_channel_folder``) so consumers can join the two by id, not name.""" normalized = normalize_channel_name(name) if not normalized: return None @@ -5059,6 +5099,8 @@ def resolve_channel(team_id: int, user_id: int | None, *, name: str) -> contract ) if created: _emit_channel_created(channel, user_id) + if folder_id is not None: + _link_channel_folder(channel, folder_id=folder_id) return _channel_to_dto(channel) diff --git a/products/tasks/backend/facade/contracts.py b/products/tasks/backend/facade/contracts.py index 1d7ae87431a3..af31cc0d7ff8 100644 --- a/products/tasks/backend/facade/contracts.py +++ b/products/tasks/backend/facade/contracts.py @@ -161,6 +161,8 @@ class ChannelDTO: channel_type: str created_at: datetime created_by: "TaskUserBasicInfo | None" = None + # Desktop-fs folder that renders this channel; None until a client links it. + folder_id: UUID | None = None @dataclass(frozen=True) diff --git a/products/tasks/backend/migrations/0063_channel_folder_channel_task_channel_folder_unique.py b/products/tasks/backend/migrations/0063_channel_folder_channel_task_channel_folder_unique.py new file mode 100644 index 000000000000..f7609953d22f --- /dev/null +++ b/products/tasks/backend/migrations/0063_channel_folder_channel_task_channel_folder_unique.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2.14 on 2026-07-20 23:50 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("posthog", "1261_alter_personalapikey_scoped_organizations_and_more"), + ("tasks", "0062_sandbox_custom_image_base_reference"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="channel", + name="folder", + field=models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="posthog.filesystem", + ), + ), + migrations.AddConstraint( + model_name="channel", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted", False), ("folder__isnull", False)), + fields=("folder",), + name="task_channel_folder_unique", + ), + ), + ] diff --git a/products/tasks/backend/migrations/max_migration.txt b/products/tasks/backend/migrations/max_migration.txt index 94f5d5edc055..74dc071b9d55 100644 --- a/products/tasks/backend/migrations/max_migration.txt +++ b/products/tasks/backend/migrations/max_migration.txt @@ -1 +1 @@ -0062_sandbox_custom_image_base_reference +0063_channel_folder_channel_task_channel_folder_unique diff --git a/products/tasks/backend/models.py b/products/tasks/backend/models.py index 1c0d795de813..0813701c8b44 100644 --- a/products/tasks/backend/models.py +++ b/products/tasks/backend/models.py @@ -80,6 +80,15 @@ class ChannelType(models.TextChoices): created_by = models.ForeignKey( "posthog.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="+", db_constraint=False ) + # The desktop-surface FileSystem folder that renders this channel (canvases and + # task filings nest under its path). This is the id join between the two — + # without it the only folder↔channel link is name matching. Written by the + # client that owns folder creation; SET_NULL keeps the channel alive if the + # folder row goes away. db_constraint=False for the same deploy-lock reason as + # the FKs above (posthog_filesystem is rewritten constantly). + folder = models.ForeignKey( + "posthog.FileSystem", on_delete=models.SET_NULL, null=True, blank=True, related_name="+", db_constraint=False + ) deleted = models.BooleanField(default=False) created_at = models.DateTimeField(default=django_timezone.now) updated_at = models.DateTimeField(auto_now=True) @@ -97,6 +106,11 @@ class Meta: condition=models.Q(channel_type="personal", deleted=False), name="task_channel_team_user_personal_unique", ), + models.UniqueConstraint( + fields=["folder"], + condition=models.Q(deleted=False, folder__isnull=False), + name="task_channel_folder_unique", + ), ] def __str__(self): diff --git a/products/tasks/backend/presentation/serializers.py b/products/tasks/backend/presentation/serializers.py index 48583b09d89f..0e38ba8712fb 100644 --- a/products/tasks/backend/presentation/serializers.py +++ b/products/tasks/backend/presentation/serializers.py @@ -1339,14 +1339,22 @@ class ChannelSerializer(DataclassSerializer): class Meta: dataclass = ChannelDTO - fields = ["id", "name", "channel_type", "created_at", "created_by"] + fields = ["id", "name", "channel_type", "created_at", "created_by", "folder_id"] class ChannelWriteSerializer(serializers.Serializer): - """Request body for creating (resolve-or-create) or renaming a public channel.""" + """Request body for creating (resolve-or-create), renaming, or folder-linking a + channel. ``name`` is required on create; updates take either or both fields.""" name = serializers.CharField( - max_length=128, help_text="Channel name, rendered as #. Normalized to lowercase-dashed." + max_length=128, + required=False, + help_text="Channel name, rendered as #. Normalized to lowercase-dashed.", + ) + folder_id = serializers.UUIDField( + required=False, + allow_null=True, + help_text="Desktop file-system folder that renders this channel; links the two by id.", ) diff --git a/products/tasks/backend/presentation/views/channels_api.py b/products/tasks/backend/presentation/views/channels_api.py index 1891ecc2238a..f48fd9a120c5 100644 --- a/products/tasks/backend/presentation/views/channels_api.py +++ b/products/tasks/backend/presentation/views/channels_api.py @@ -58,12 +58,23 @@ def list(self, request, *args, **kwargs): request=ChannelWriteSerializer, responses={200: ChannelSerializer}, summary="Resolve or create a public channel", - description="Returns the existing public channel with the (normalized) name, creating it if needed.", + description=( + "Returns the existing public channel with the (normalized) name, creating it if needed. " + "Pass folder_id to link the desktop file-system folder that renders the channel." + ), ) def create(self, request, **kwargs): serializer = ChannelWriteSerializer(data=request.data) serializer.is_valid(raise_exception=True) - channel = tasks_facade.resolve_channel(self.team_id, self._user_id(), name=serializer.validated_data["name"]) + name = serializer.validated_data.get("name") + if not name: + return Response({"detail": "Channel name is required"}, status=status.HTTP_400_BAD_REQUEST) + channel = tasks_facade.resolve_channel( + self.team_id, + self._user_id(), + name=name, + folder_id=serializer.validated_data.get("folder_id"), + ) if channel is None: return Response({"detail": "Invalid channel name"}, status=status.HTTP_400_BAD_REQUEST) return Response(ChannelSerializer(channel).data) @@ -71,21 +82,37 @@ def create(self, request, **kwargs): @extend_schema( request=ChannelWriteSerializer, responses={200: ChannelSerializer}, - summary="Rename a public channel", + summary="Rename or folder-link a channel", + description=( + "Pass name to rename (public channels only), folder_id to link the desktop " + "file-system folder that renders the channel (personal channels included), or both." + ), ) def partial_update(self, request, pk=None, **kwargs): serializer = ChannelWriteSerializer(data=request.data) serializer.is_valid(raise_exception=True) - result = tasks_facade.rename_channel(pk, self.team_id, name=serializer.validated_data["name"]) - if result == "not_found": + name = serializer.validated_data.get("name") + folder_id = serializer.validated_data.get("folder_id") + if name is None and folder_id is None: + return Response({"detail": "Nothing to update"}, status=status.HTTP_400_BAD_REQUEST) + if name is not None: + result = tasks_facade.rename_channel(pk, self.team_id, name=name) + if result == "not_found": + raise NotFound() + if result == "personal": + raise PermissionDenied("Personal channels cannot be renamed") + if result == "invalid_name": + return Response({"detail": "Invalid channel name"}, status=status.HTTP_400_BAD_REQUEST) + if result == "name_taken": + return Response( + {"detail": "A channel with this name already exists"}, status=status.HTTP_400_BAD_REQUEST + ) + if folder_id is None: + return Response(ChannelSerializer(result).data) + linked = tasks_facade.link_channel_folder(pk, self.team_id, folder_id=folder_id) + if linked == "not_found": raise NotFound() - if result == "personal": - raise PermissionDenied("Personal channels cannot be renamed") - if result == "invalid_name": - return Response({"detail": "Invalid channel name"}, status=status.HTTP_400_BAD_REQUEST) - if result == "name_taken": - return Response({"detail": "A channel with this name already exists"}, status=status.HTTP_400_BAD_REQUEST) - return Response(ChannelSerializer(result).data) + return Response(ChannelSerializer(linked).data) @extend_schema(responses={204: None}, summary="Delete a public channel") def destroy(self, request, pk=None, **kwargs): diff --git a/products/tasks/backend/tests/test_channels_api.py b/products/tasks/backend/tests/test_channels_api.py index 7df6b6c01316..5c2ab14d07da 100644 --- a/products/tasks/backend/tests/test_channels_api.py +++ b/products/tasks/backend/tests/test_channels_api.py @@ -9,6 +9,8 @@ from rest_framework.test import APIClient from posthog.models import Organization, OrganizationMembership, Team, User +from posthog.models.file_system.constants import DESKTOP_SURFACE +from posthog.models.file_system.file_system import FileSystem from products.tasks.backend.models import Channel, ChannelFeedMessage, Task, TaskRun, TaskThreadMessage @@ -67,6 +69,69 @@ def test_resolve_or_create_public_channel(self): second = self.client.post(self._channels_url(), {"name": "growth ideas"}) self.assertEqual(second.json()["id"], first.json()["id"]) + def _desktop_folder(self, path: str, team: Team | None = None) -> FileSystem: + return FileSystem.objects.create( + team=team or self.team, path=path, depth=1, type="folder", surface=DESKTOP_SURFACE + ) + + def test_resolve_links_desktop_folder_first_claim_wins(self): + folder = self._desktop_folder("growth") + first = self.client.post(self._channels_url(), {"name": "growth", "folder_id": str(folder.id)}) + self.assertEqual(first.status_code, status.HTTP_200_OK) + self.assertEqual(first.json()["folder_id"], str(folder.id)) + + other = self._desktop_folder("growth-2") + second = self.client.post(self._channels_url(), {"name": "growth", "folder_id": str(other.id)}) + self.assertEqual(second.json()["folder_id"], str(folder.id)) + + def test_folder_claim_is_exclusive_across_channels(self): + folder = self._desktop_folder("shared") + claimed = self.client.post(self._channels_url(), {"name": "one", "folder_id": str(folder.id)}) + self.assertEqual(claimed.json()["folder_id"], str(folder.id)) + thief = self.client.post(self._channels_url(), {"name": "two", "folder_id": str(folder.id)}) + self.assertEqual(thief.status_code, status.HTTP_200_OK) + self.assertIsNone(thief.json()["folder_id"]) + + def test_patch_links_folder_on_personal_channel(self): + self.client.get(self._channels_url()) + personal = Channel.objects.unscoped().get(team=self.team, channel_type=Channel.ChannelType.PERSONAL) + folder = self._desktop_folder("me") + response = self.client.patch(f"{self._channels_url()}{personal.id}/", {"folder_id": str(folder.id)}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json()["folder_id"], str(folder.id)) + + def test_link_ignores_foreign_and_non_desktop_folders(self): + other_team = Team.objects.create(organization=self.organization, name="Other Team") + foreign = self._desktop_folder("foreign", team=other_team) + web_row = FileSystem.objects.create(team=self.team, path="webby", depth=1, type="folder") + non_folder = FileSystem.objects.create( + team=self.team, path="board/canvas", depth=2, type="dashboard", surface=DESKTOP_SURFACE + ) + for bad in (foreign, web_row, non_folder): + response = self.client.post(self._channels_url(), {"name": "strict", "folder_id": str(bad.id)}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIsNone(response.json()["folder_id"]) + + def test_relink_allowed_when_claimed_folder_row_is_gone(self): + folder = self._desktop_folder("ephemeral") + created = self.client.post(self._channels_url(), {"name": "moves", "folder_id": str(folder.id)}).json() + folder.delete() + replacement = self._desktop_folder("ephemeral-new") + response = self.client.patch(f"{self._channels_url()}{created['id']}/", {"folder_id": str(replacement.id)}) + self.assertEqual(response.json()["folder_id"], str(replacement.id)) + + def test_rename_keeps_folder_link(self): + folder = self._desktop_folder("old-name") + created = self.client.post(self._channels_url(), {"name": "old name", "folder_id": str(folder.id)}).json() + renamed = self.client.patch(f"{self._channels_url()}{created['id']}/", {"name": "new name"}) + self.assertEqual(renamed.status_code, status.HTTP_200_OK) + self.assertEqual(renamed.json()["folder_id"], str(folder.id)) + + def test_patch_with_no_fields_is_a_400(self): + created = self.client.post(self._channels_url(), {"name": "plain"}).json() + response = self.client.patch(f"{self._channels_url()}{created['id']}/", {}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + def test_personal_channel_cannot_be_renamed_or_deleted(self): self.client.get(self._channels_url()) # Direct ORM reads in tests bypass the DRF-set team context, so opt out From 2be819869b276b5150bf47269178834ad6d37da9 Mon Sep 17 00:00:00 2001 From: "tests-posthog[bot]" <250237707+tests-posthog[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 00:01:53 +0000 Subject: [PATCH 2/2] chore: update OpenAPI generated types --- .../tasks/frontend/generated/api.schemas.ts | 20 ++++++++++++--- products/tasks/frontend/generated/api.ts | 10 +++----- products/tasks/frontend/generated/api.zod.ts | 25 +++++++++++++------ services/mcp/src/api/generated.ts | 20 ++++++++++++--- 4 files changed, 56 insertions(+), 19 deletions(-) diff --git a/products/tasks/frontend/generated/api.schemas.ts b/products/tasks/frontend/generated/api.schemas.ts index 1e08afcefd1b..649c0edf1658 100644 --- a/products/tasks/frontend/generated/api.schemas.ts +++ b/products/tasks/frontend/generated/api.schemas.ts @@ -392,6 +392,8 @@ export interface ChannelDTOApi { channel_type: string created_at: string created_by?: TaskUserBasicInfoApi | null + /** @nullable */ + folder_id?: string | null } export interface PaginatedChannelDTOListApi { @@ -404,14 +406,20 @@ export interface PaginatedChannelDTOListApi { } /** - * Request body for creating (resolve-or-create) or renaming a public channel. + * Request body for creating (resolve-or-create), renaming, or folder-linking a + * channel. ``name`` is required on create; updates take either or both fields. */ export interface ChannelWriteApi { /** * Channel name, rendered as #. Normalized to lowercase-dashed. * @maxLength 128 */ - name: string + name?: string + /** + * Desktop file-system folder that renders this channel; links the two by id. + * @nullable + */ + folder_id?: string | null } export type ChannelFeedMessageDTOApiPayload = { [key: string]: unknown } @@ -466,7 +474,8 @@ export interface ChannelFeedMessageWriteApi { } /** - * Request body for creating (resolve-or-create) or renaming a public channel. + * Request body for creating (resolve-or-create), renaming, or folder-linking a + * channel. ``name`` is required on create; updates take either or both fields. */ export interface PatchedChannelWriteApi { /** @@ -474,6 +483,11 @@ export interface PatchedChannelWriteApi { * @maxLength 128 */ name?: string + /** + * Desktop file-system folder that renders this channel; links the two by id. + * @nullable + */ + folder_id?: string | null } /** diff --git a/products/tasks/frontend/generated/api.ts b/products/tasks/frontend/generated/api.ts index aff5cd8424c6..bc20e2ce28a0 100644 --- a/products/tasks/frontend/generated/api.ts +++ b/products/tasks/frontend/generated/api.ts @@ -524,12 +524,12 @@ export const getTaskChannelsCreateUrl = (projectId: string) => { } /** - * Returns the existing public channel with the (normalized) name, creating it if needed. + * Returns the existing public channel with the (normalized) name, creating it if needed. Pass folder_id to link the desktop file-system folder that renders the channel. * @summary Resolve or create a public channel */ export const taskChannelsCreate = async ( projectId: string, - channelWriteApi: ChannelWriteApi, + channelWriteApi?: ChannelWriteApi, options?: RequestInit ): Promise => { return apiMutator(getTaskChannelsCreateUrl(projectId), { @@ -605,10 +605,8 @@ export const getTaskChannelsPartialUpdateUrl = (projectId: string, id: string) = } /** - * 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 - * by normalized name so clients can map channel-like surfaces onto backend channels. - * @summary Rename a public channel + * Pass name to rename (public channels only), folder_id to link the desktop file-system folder that renders the channel (personal channels included), or both. + * @summary Rename or folder-link a channel */ export const taskChannelsPartialUpdate = async ( projectId: string, diff --git a/products/tasks/frontend/generated/api.zod.ts b/products/tasks/frontend/generated/api.zod.ts index 0502839cb966..b794f1db2df6 100644 --- a/products/tasks/frontend/generated/api.zod.ts +++ b/products/tasks/frontend/generated/api.zod.ts @@ -280,7 +280,7 @@ export const TaskAutomationsPartialUpdateBody = /* @__PURE__ */ zod .describe('Request body for creating or updating a task automation.') /** - * Returns the existing public channel with the (normalized) name, creating it if needed. + * Returns the existing public channel with the (normalized) name, creating it if needed. Pass folder_id to link the desktop file-system folder that renders the channel. * @summary Resolve or create a public channel */ export const taskChannelsCreateBodyNameMax = 128 @@ -290,9 +290,16 @@ export const TaskChannelsCreateBody = /* @__PURE__ */ zod name: zod .string() .max(taskChannelsCreateBodyNameMax) + .optional() .describe('Channel name, rendered as #. Normalized to lowercase-dashed.'), + folder_id: zod + .uuid() + .nullish() + .describe('Desktop file-system folder that renders this channel; links the two by id.'), }) - .describe('Request body for creating (resolve-or-create) or renaming a public channel.') + .describe( + 'Request body for creating (resolve-or-create), renaming, or folder-linking a\nchannel. ``name`` is required on create; updates take either or both fields.' + ) /** * API for a channel's system-announcement feed — durable "PostHog agent" rows @@ -322,10 +329,8 @@ export const TaskChannelsFeedCreateBody = /* @__PURE__ */ zod .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 - * by normalized name so clients can map channel-like surfaces onto backend channels. - * @summary Rename a public channel + * Pass name to rename (public channels only), folder_id to link the desktop file-system folder that renders the channel (personal channels included), or both. + * @summary Rename or folder-link a channel */ export const taskChannelsPartialUpdateBodyNameMax = 128 @@ -336,8 +341,14 @@ export const TaskChannelsPartialUpdateBody = /* @__PURE__ */ zod .max(taskChannelsPartialUpdateBodyNameMax) .optional() .describe('Channel name, rendered as #. Normalized to lowercase-dashed.'), + folder_id: zod + .uuid() + .nullish() + .describe('Desktop file-system folder that renders this channel; links the two by id.'), }) - .describe('Request body for creating (resolve-or-create) or renaming a public channel.') + .describe( + 'Request body for creating (resolve-or-create), renaming, or folder-linking a\nchannel. ``name`` is required on create; updates take either or both fields.' + ) /** * API for managing tasks within a project. Tasks represent units of work to be performed by an agent. diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 0a4eb5877001..25feb0d04c71 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -13854,6 +13854,8 @@ export namespace Schemas { channel_type: string; created_at: string; created_by?: TaskUserBasicInfo | null; + /** @nullable */ + folder_id?: string | null; } /** @@ -13942,14 +13944,20 @@ export namespace Schemas { } as const; /** - * Request body for creating (resolve-or-create) or renaming a public channel. + * Request body for creating (resolve-or-create), renaming, or folder-linking a + * channel. ``name`` is required on create; updates take either or both fields. */ export interface ChannelWrite { /** * Channel name, rendered as #. Normalized to lowercase-dashed. * @maxLength 128 */ - name: string; + name?: string; + /** + * Desktop file-system folder that renders this channel; links the two by id. + * @nullable + */ + folder_id?: string | null; } export interface CheckDatabaseNameResponse { @@ -43203,7 +43211,8 @@ export namespace Schemas { } /** - * Request body for creating (resolve-or-create) or renaming a public channel. + * Request body for creating (resolve-or-create), renaming, or folder-linking a + * channel. ``name`` is required on create; updates take either or both fields. */ export interface PatchedChannelWrite { /** @@ -43211,6 +43220,11 @@ export namespace Schemas { * @maxLength 128 */ name?: string; + /** + * Desktop file-system folder that renders this channel; links the two by id. + * @nullable + */ + folder_id?: string | null; } export interface PatchedClusteringJob {