From 7c0da49450bd0750338497ad96952ab6e65c63a5 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Sun, 26 Jul 2026 11:04:57 +0100 Subject: [PATCH] feat(canvas): diff-aware guarded source edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of the canvas application build pipeline plan: a per-file edit endpoint (POST canvas/edit) and MCP tool so agents publish small changes without resending the whole project. Each operation sets a file's complete content or deletes it; operations apply to the head the caller read, the edited project is validated before anything commits, and expected_current_version_id is mandatory — a relative edit against an unverified base could silently merge into someone else's newer work, so unguarded diff publishes are refused outright. Shares the publish path (upload-then-commit lifecycle recording, build queuing) with the whole-project tool. Generated-By: PostHog Code Task-Id: 9e9a7b3c-f90d-4867-aa0d-b9acc83e26e1 --- frontend/src/generated/core/api.schemas.ts | 94 +++++++---- frontend/src/generated/core/api.ts | 28 ++++ frontend/src/generated/core/api.zod.ts | 45 ++++++ posthog/api/file_system/file_system.py | 147 +++++++++++++++++- .../api/file_system/test/test_canvas_edit.py | 117 ++++++++++++++ .../SKILL.md | 9 +- services/mcp/definitions/core.yaml | 19 +++ .../schema/generated-tool-definitions.json | 14 ++ services/mcp/schema/tool-definitions-all.json | 14 ++ services/mcp/src/api/generated.ts | 30 ++++ services/mcp/src/generated/core/api.ts | 56 ++++++- services/mcp/src/tools/generated/core.ts | 39 +++++ ...esktop-file-system-canvas-edit-create.json | 56 +++++++ 13 files changed, 628 insertions(+), 40 deletions(-) create mode 100644 posthog/api/file_system/test/test_canvas_edit.py create mode 100644 services/mcp/tests/unit/__snapshots__/tool-schemas/desktop-file-system-canvas-edit-create.json diff --git a/frontend/src/generated/core/api.schemas.ts b/frontend/src/generated/core/api.schemas.ts index d9252a8a513a..e458246d29aa 100644 --- a/frontend/src/generated/core/api.schemas.ts +++ b/frontend/src/generated/core/api.schemas.ts @@ -2963,50 +2963,33 @@ export interface CanvasBuildsResponseApi { } /** - * Project files keyed by relative path (forward slashes, no '..'). Until the canvas build service ships, only "index.html" and "src/canvas.tsx" (the single React component the canvas mounts) are supported. - */ -export type CanvasSourceProjectApiFiles = { [key: string]: string } - -/** - * Exact-version dependencies, restricted to the platform-supported set (react, react-dom, @posthog/quill, recharts, lucide-react, dayjs) at their pinned versions. + * One per-file edit: set a file's content, or delete it. */ -export type CanvasSourceProjectApiDependencies = { [key: string]: string } - -/** - * A canvas's multi-file source project — the canonical write format for canvas source. - * - * Until the canvas build service ships, projects are constrained to the - * legacy-compatible shape: `index.html` (a fixed synthetic shell) plus - * `src/canvas.tsx` (the single React component the runtime mounts). - */ -export interface CanvasSourceProjectApi { - /** Source-project schema version. Currently always 1. */ - schemaVersion: number - /** Project files keyed by relative path (forward slashes, no '..'). Until the canvas build service ships, only "index.html" and "src/canvas.tsx" (the single React component the canvas mounts) are supported. */ - files: CanvasSourceProjectApiFiles - /** The project's entry HTML file. Currently always "index.html". */ - entryHtml: string - /** Exact-version dependencies, restricted to the platform-supported set (react, react-dom, @posthog/quill, recharts, lucide-react, dayjs) at their pinned versions. */ - dependencies?: CanvasSourceProjectApiDependencies - /** Version of the host-injected `ph` canvas SDK the project targets. */ - canvasSdkVersion?: string +export interface CanvasSourceEditOperationApi { + /** Project-relative path of the file to write or delete (e.g. "src/canvas.tsx"). */ + path: string + /** + * The file's complete new content. Null (or omitted) deletes the file. + * @nullable + */ + content?: string | null } /** - * Payload for publishing a complete canvas source project. + * Payload for publishing per-file edits against the canvas's current source. */ -export interface CanvasSourcePublishApi { - /** The complete source project to publish. */ - project: CanvasSourceProjectApi +export interface CanvasSourceEditApi { + /** Edits applied in order to the canvas's current source project. */ + operations: CanvasSourceEditOperationApi[] /** Short description of the change, stored on the appended version history entry. */ prompt?: string /** Optional new display name for the canvas (rewrites the leaf segment of its path). */ name?: string /** - * Optimistic-concurrency guard: the current_version_id the publisher based its edits on (null when it read a canvas with no versions yet). When the canvas has since moved past it the publish is rejected with a 409 version_conflict instead of overwriting the newer head. Omit to publish unguarded. + * Required optimistic-concurrency guard: the current_version_id the edits are based on (null when the canvas has never been published). Diff edits against a moved head are rejected with 409 version_conflict — they cannot be published unguarded. * @nullable */ - expected_current_version_id?: string | null + expected_current_version_id: string | null } /** @@ -3067,6 +3050,53 @@ export interface CanvasSourceInvalidApi { diagnostics: CanvasDiagnosticApi[] } +/** + * Project files keyed by relative path (forward slashes, no '..'). Until the canvas build service ships, only "index.html" and "src/canvas.tsx" (the single React component the canvas mounts) are supported. + */ +export type CanvasSourceProjectApiFiles = { [key: string]: string } + +/** + * Exact-version dependencies, restricted to the platform-supported set (react, react-dom, @posthog/quill, recharts, lucide-react, dayjs) at their pinned versions. + */ +export type CanvasSourceProjectApiDependencies = { [key: string]: string } + +/** + * A canvas's multi-file source project — the canonical write format for canvas source. + * + * Until the canvas build service ships, projects are constrained to the + * legacy-compatible shape: `index.html` (a fixed synthetic shell) plus + * `src/canvas.tsx` (the single React component the runtime mounts). + */ +export interface CanvasSourceProjectApi { + /** Source-project schema version. Currently always 1. */ + schemaVersion: number + /** Project files keyed by relative path (forward slashes, no '..'). Until the canvas build service ships, only "index.html" and "src/canvas.tsx" (the single React component the canvas mounts) are supported. */ + files: CanvasSourceProjectApiFiles + /** The project's entry HTML file. Currently always "index.html". */ + entryHtml: string + /** Exact-version dependencies, restricted to the platform-supported set (react, react-dom, @posthog/quill, recharts, lucide-react, dayjs) at their pinned versions. */ + dependencies?: CanvasSourceProjectApiDependencies + /** Version of the host-injected `ph` canvas SDK the project targets. */ + canvasSdkVersion?: string +} + +/** + * Payload for publishing a complete canvas source project. + */ +export interface CanvasSourcePublishApi { + /** The complete source project to publish. */ + project: CanvasSourceProjectApi + /** Short description of the change, stored on the appended version history entry. */ + prompt?: string + /** Optional new display name for the canvas (rewrites the leaf segment of its path). */ + name?: string + /** + * Optimistic-concurrency guard: the current_version_id the publisher based its edits on (null when it read a canvas with no versions yet). When the canvas has since moved past it the publish is rejected with a 409 version_conflict instead of overwriting the newer head. Omit to publish unguarded. + * @nullable + */ + expected_current_version_id?: string | null +} + /** * A canvas's source project plus the version pointer edits must be based on. */ diff --git a/frontend/src/generated/core/api.ts b/frontend/src/generated/core/api.ts index 81339f22a204..cce00e81f86c 100644 --- a/frontend/src/generated/core/api.ts +++ b/frontend/src/generated/core/api.ts @@ -15,6 +15,7 @@ import type { CIMDVerificationTokenWithValueApi, CanvasBuildsResponseApi, CanvasCreateApi, + CanvasSourceEditApi, CanvasSourcePublishApi, CanvasSourcePublishResponseApi, CanvasSourceResponseApi, @@ -1488,6 +1489,33 @@ export const desktopFileSystemCanvasBuildsRetrieve = async ( }) } +export const getDesktopFileSystemCanvasEditCreateUrl = (projectId: string, id: string) => { + return `/api/projects/${projectId}/desktop_file_system/${id}/canvas/edit/` +} + +/** + * Publish per-file edits against the canvas's current source project. + * + * Diff-aware alternative to sending the complete project: each operation + * sets a file's content or (content null) deletes it, applied to the head + * the caller read. `expected_current_version_id` is mandatory here — + * relative edits against an unverified base could silently merge into + * someone else's newer work, so unguarded diff publishes are refused. + */ +export const desktopFileSystemCanvasEditCreate = async ( + projectId: string, + id: string, + canvasSourceEditApi: CanvasSourceEditApi, + options?: RequestInit +): Promise => { + return apiMutator(getDesktopFileSystemCanvasEditCreateUrl(projectId, id), { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(canvasSourceEditApi), + }) +} + export const getDesktopFileSystemCanvasPublishCreateUrl = (projectId: string, id: string) => { return `/api/projects/${projectId}/desktop_file_system/${id}/canvas/publish/` } diff --git a/frontend/src/generated/core/api.zod.ts b/frontend/src/generated/core/api.zod.ts index 12aa17c04334..eb97d307b506 100644 --- a/frontend/src/generated/core/api.zod.ts +++ b/frontend/src/generated/core/api.zod.ts @@ -9003,6 +9003,51 @@ export const DesktopFileSystemCanvasPartialUpdateBody = /* @__PURE__ */ zod }) .describe("Payload for publishing a freeform canvas's React source via the agent.") +/** + * Publish per-file edits against the canvas's current source project. + * + * Diff-aware alternative to sending the complete project: each operation + * sets a file's content or (content null) deletes it, applied to the head + * the caller read. `expected_current_version_id` is mandatory here — + * relative edits against an unverified base could silently merge into + * someone else's newer work, so unguarded diff publishes are refused. + */ +export const DesktopFileSystemCanvasEditCreateBody = /* @__PURE__ */ zod + .object({ + operations: zod + .array( + zod + .object({ + path: zod + .string() + .describe( + 'Project-relative path of the file to write or delete (e.g. \"src\/canvas.tsx\").' + ), + content: zod + .string() + .nullish() + .describe("The file's complete new content. Null (or omitted) deletes the file."), + }) + .describe("One per-file edit: set a file's content, or delete it.") + ) + .describe("Edits applied in order to the canvas's current source project."), + prompt: zod + .string() + .optional() + .describe('Short description of the change, stored on the appended version history entry.'), + name: zod + .string() + .optional() + .describe('Optional new display name for the canvas (rewrites the leaf segment of its path).'), + expected_current_version_id: zod + .string() + .nullable() + .describe( + 'Required optimistic-concurrency guard: the current_version_id the edits are based on (null when the canvas has never been published). Diff edits against a moved head are rejected with 409 version_conflict — they cannot be published unguarded.' + ), + }) + .describe("Payload for publishing per-file edits against the canvas's current source.") + /** * Publish a complete canvas source project as the canvas's new head version. * diff --git a/posthog/api/file_system/file_system.py b/posthog/api/file_system/file_system.py index 3eb925d11e80..4d20e9cb104d 100644 --- a/posthog/api/file_system/file_system.py +++ b/posthog/api/file_system/file_system.py @@ -254,6 +254,7 @@ class FileSystemViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet): "publish_canvas", "create_canvas", "publish_canvas_source", + "edit_canvas_source", ] def _basename_regex(self, value: str) -> str: @@ -1252,6 +1253,51 @@ class CanvasSourcePublishSerializer(serializers.Serializer): ) +class CanvasSourceEditOperationSerializer(serializers.Serializer): + """One per-file edit: set a file's content, or delete it.""" + + path = serializers.CharField( + help_text='Project-relative path of the file to write or delete (e.g. "src/canvas.tsx").' + ) + content = serializers.CharField( + required=False, + allow_null=True, + allow_blank=True, + trim_whitespace=False, + help_text="The file's complete new content. Null (or omitted) deletes the file.", + ) + + +class CanvasSourceEditSerializer(serializers.Serializer): + """Payload for publishing per-file edits against the canvas's current source.""" + + operations = CanvasSourceEditOperationSerializer( + many=True, + allow_empty=False, + help_text="Edits applied in order to the canvas's current source project.", + ) + prompt = serializers.CharField( + required=False, + allow_blank=True, + trim_whitespace=False, + help_text="Short description of the change, stored on the appended version history entry.", + ) + name = serializers.CharField( + required=False, + allow_blank=False, + trim_whitespace=True, + help_text="Optional new display name for the canvas (rewrites the leaf segment of its path).", + ) + expected_current_version_id = serializers.CharField( + allow_null=True, + help_text=( + "Required optimistic-concurrency guard: the current_version_id the edits are based on (null when the " + "canvas has never been published). Diff edits against a moved head are rejected with 409 " + "version_conflict — they cannot be published unguarded." + ), + ) + + class CanvasSourcePublishResponseSerializer(serializers.Serializer): """Result of a successful source-project publish.""" @@ -1707,8 +1753,29 @@ def publish_canvas_source(self, request: Request, *args: Any, **kwargs: Any) -> payload = CanvasSourcePublishSerializer(data=request.data) payload.is_valid(raise_exception=True) - project = payload.validated_data["project"] + return self._publish_source_project( + request, + dashboard, + project=payload.validated_data["project"], + prompt=payload.validated_data.get("prompt"), + name=payload.validated_data.get("name"), + has_expected_version="expected_current_version_id" in payload.validated_data, + expected_version_id=payload.validated_data.get("expected_current_version_id"), + ) + + def _publish_source_project( + self, + request: Request, + dashboard: FileSystem, + *, + project: dict[str, Any], + prompt: str | None, + name: str | None, + has_expected_version: bool, + expected_version_id: str | None, + ) -> Response: + """Validate + publish a complete source project (shared by publish and edit).""" diagnostics = validate_source_project(project) if has_errors(diagnostics): body = { @@ -1731,7 +1798,6 @@ def publish_canvas_source(self, request: Request, *args: Any, **kwargs: Any) -> record_lifecycle: Callable[[FileSystem, dict[str, Any], dict[str, Any]], None] | None = None if source_object is not None: uploaded = source_object - prompt = payload.validated_data.get("prompt") task_id = self._request_task_id(request) user = request.user if isinstance(request.user, User) else None @@ -1752,10 +1818,10 @@ def _record(locked: FileSystem, meta: dict[str, Any], version: dict[str, Any]) - dashboard, conflict, first_publish = self._apply_canvas_publish( dashboard, code=extract_legacy_code(project), - prompt=payload.validated_data.get("prompt"), - name=payload.validated_data.get("name"), - has_expected_version="expected_current_version_id" in payload.validated_data, - expected_version_id=payload.validated_data.get("expected_current_version_id"), + prompt=prompt, + name=name, + has_expected_version=has_expected_version, + expected_version_id=expected_version_id, record_lifecycle=record_lifecycle, ) if conflict is not None: @@ -1772,6 +1838,75 @@ def _record(locked: FileSystem, meta: dict[str, Any], version: dict[str, Any]) - } return Response(CanvasSourcePublishResponseSerializer(response).data) + @extend_schema( + operation_id="desktop_file_system_canvas_edit_create", + request=CanvasSourceEditSerializer, + responses={ + 200: CanvasSourcePublishResponseSerializer, + 400: OpenApiResponse( + response=CanvasSourceInvalidSerializer, + description="An edit targeted a missing file, or the edited project failed validation.", + ), + 409: OpenApiResponse( + response=CanvasPublishConflictSerializer, + description="The canvas moved past expected_current_version_id (a concurrent publish or an undo).", + ), + }, + ) + @action(methods=["POST"], detail=True, url_path="canvas/edit", request=CanvasSourceEditSerializer) + def edit_canvas_source(self, request: Request, *args: Any, **kwargs: Any) -> Response: + """Publish per-file edits against the canvas's current source project. + + Diff-aware alternative to sending the complete project: each operation + sets a file's content or (content null) deletes it, applied to the head + the caller read. `expected_current_version_id` is mandatory here — + relative edits against an unverified base could silently merge into + someone else's newer work, so unguarded diff publishes are refused. + """ + dashboard = self._get_dashboard_or_400() + if isinstance(dashboard, Response): + return dashboard + + payload = CanvasSourceEditSerializer(data=request.data) + payload.is_valid(raise_exception=True) + + project = synthetic_source_project(dashboard.meta or {}) + diagnostics: list[dict[str, Any]] = [] + for operation in payload.validated_data["operations"]: + path = operation["path"] + content = operation.get("content") + if content is None: + if path not in project["files"]: + diagnostics.append( + { + "severity": "error", + "code": "edit_target_missing", + "message": f"cannot delete {path} — the project has no file at that path", + "path": path, + } + ) + continue + del project["files"][path] + else: + project["files"][path] = content + if diagnostics: + body = { + "detail": "The edit could not be applied to the canvas's current source.", + "code": "invalid_source_project", + "diagnostics": diagnostics, + } + return Response(CanvasSourceInvalidSerializer(body).data, status=status.HTTP_400_BAD_REQUEST) + + return self._publish_source_project( + request, + dashboard, + project=project, + prompt=payload.validated_data.get("prompt"), + name=payload.validated_data.get("name"), + has_expected_version=True, + expected_version_id=payload.validated_data["expected_current_version_id"], + ) + @staticmethod def _request_task_id(request: Request) -> UUID | None: """The publishing task's id, when the sandbox stamped one on the call.""" diff --git a/posthog/api/file_system/test/test_canvas_edit.py b/posthog/api/file_system/test/test_canvas_edit.py new file mode 100644 index 000000000000..ea3ce5b0fa45 --- /dev/null +++ b/posthog/api/file_system/test/test_canvas_edit.py @@ -0,0 +1,117 @@ +from typing import Any, cast + +from posthog.test.base import APIBaseTest + +from rest_framework import status + +from posthog.api.file_system.canvas_source import CANVAS_COMPONENT_PATH +from posthog.models.file_system.file_system import FileSystem + +CODE_V1 = 'import React from "react";\nexport default () =>
v1
;\n' +CODE_V2 = 'import React from "react";\nexport default () =>
v2
;\n' + + +class TestDesktopCanvasEditAPI(APIBaseTest): + def setUp(self): + super().setUp() + self.user.is_staff = True + self.user.save() + + def _base_url(self) -> str: + return f"/api/projects/{self.team.id}/desktop_file_system/" + + def _create_published_canvas(self) -> tuple[str, str]: + channel = self.client.post(self._base_url(), {"path": "MyChannel", "type": "folder"}).json() + canvas = self.client.post( + f"{self._base_url()}canvases/", {"name": "MyCanvas", "channel_id": channel["id"]} + ).json() + self.client.patch(f"{self._base_url()}{canvas['id']}/canvas/", {"code": CODE_V1}) + head = cast(dict, FileSystem.objects.get(id=canvas["id"]).meta)["currentVersionId"] + return canvas["id"], head + + def _edit(self, canvas_id: str, body: dict[str, Any]) -> Any: + return self.client.post(f"{self._base_url()}{canvas_id}/canvas/edit/", body, format="json") + + def test_edit_publishes_a_new_guarded_version_without_resending_the_project(self): + canvas_id, head = self._create_published_canvas() + + response = self._edit( + canvas_id, + { + "operations": [{"path": CANVAS_COMPONENT_PATH, "content": CODE_V2}], + "prompt": "swap v1 for v2", + "expected_current_version_id": head, + }, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + meta = cast(dict, FileSystem.objects.get(id=canvas_id).meta) + self.assertEqual(meta["code"], CODE_V2) + self.assertEqual([v["code"] for v in meta["versions"]], [CODE_V1, CODE_V2]) + self.assertEqual(response.json()["current_version_id"], meta["currentVersionId"]) + + def test_edit_refuses_to_run_unguarded(self): + # A diff edit's meaning depends on its base; without the guard it could + # silently merge into someone else's newer head. The serializer must + # reject the request outright. + canvas_id, _head = self._create_published_canvas() + + response = self._edit( + canvas_id, + {"operations": [{"path": CANVAS_COMPONENT_PATH, "content": CODE_V2}]}, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.json()["attr"], "expected_current_version_id") + self.assertEqual(cast(dict, FileSystem.objects.get(id=canvas_id).meta)["code"], CODE_V1) + + def test_stale_edit_conflicts_and_leaves_the_canvas_untouched(self): + canvas_id, _head = self._create_published_canvas() + + response = self._edit( + canvas_id, + { + "operations": [{"path": CANVAS_COMPONENT_PATH, "content": CODE_V2}], + "expected_current_version_id": "not-the-head", + }, + ) + + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT, response.json()) + self.assertEqual(response.json()["code"], "version_conflict") + meta = cast(dict, FileSystem.objects.get(id=canvas_id).meta) + self.assertEqual(meta["code"], CODE_V1) + self.assertEqual(len(meta["versions"]), 1) + + def test_deleting_a_missing_file_rejects_the_whole_edit(self): + canvas_id, head = self._create_published_canvas() + + response = self._edit( + canvas_id, + { + "operations": [ + {"path": CANVAS_COMPONENT_PATH, "content": CODE_V2}, + {"path": "src/nonexistent.ts", "content": None}, + ], + "expected_current_version_id": head, + }, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.json()) + self.assertEqual(response.json()["diagnostics"][0]["code"], "edit_target_missing") + # Atomic: the valid operation in the same request published nothing. + self.assertEqual(cast(dict, FileSystem.objects.get(id=canvas_id).meta)["code"], CODE_V1) + + def test_edit_producing_an_invalid_project_is_rejected_with_diagnostics(self): + canvas_id, head = self._create_published_canvas() + + response = self._edit( + canvas_id, + { + "operations": [{"path": "src/extra.css", "content": "body {}"}], + "expected_current_version_id": head, + }, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.json()) + self.assertIn("unsupported_file", [d["code"] for d in response.json()["diagnostics"]]) + self.assertEqual(cast(dict, FileSystem.objects.get(id=canvas_id).meta)["code"], CODE_V1) diff --git a/products/tasks/skills/validating-and-publishing-canvases/SKILL.md b/products/tasks/skills/validating-and-publishing-canvases/SKILL.md index b5e0a0f8c9ca..6b6f3c42969f 100644 --- a/products/tasks/skills/validating-and-publishing-canvases/SKILL.md +++ b/products/tasks/skills/validating-and-publishing-canvases/SKILL.md @@ -42,7 +42,14 @@ Diagnostics carry `severity`, a stable `code`, a `message`, and (for file-specif ## Publish guarded -Publish the **complete** project with `desktop-file-system-canvas-publish-create`: +Two ways to save, both guarded: + +- **Whole project** — `desktop-file-system-canvas-publish-create` with the complete `project`. +- **Per-file edits** — `desktop-file-system-canvas-edit-create` with `operations` (each sets a + file's complete content, or deletes it with `content: null`). Prefer this for small changes to a + large project; the guard is mandatory here because a diff's meaning depends on its base. + +For a whole-project publish with `desktop-file-system-canvas-publish-create`: - Always pass `expected_current_version_id` — the `current_version_id` you read (or explicit `null` on a first publish). Unguarded publishes can silently clobber concurrent edits. diff --git a/services/mcp/definitions/core.yaml b/services/mcp/definitions/core.yaml index 5e25d978446a..690b6c2058a7 100644 --- a/services/mcp/definitions/core.yaml +++ b/services/mcp/definitions/core.yaml @@ -82,6 +82,25 @@ tools: param_overrides: id: description: ID of the canvas whose builds to read. + desktop-file-system-canvas-edit-create: + operation: desktop_file_system_canvas_edit_create + enabled: true + scopes: + - file_system:write + annotations: + readOnly: false + destructive: false + idempotent: false + title: Edit canvas source files + description: > + Publish per-file edits against a canvas's current source, without resending the whole project. Each + operation sets a file's complete content, or deletes it when `content` is null. + `expected_current_version_id` is REQUIRED (from desktop-file-system-canvas-source-retrieve; null only for a + never-published canvas) — a stale base is rejected with 409 version_conflict; re-read the source, re-apply, + and edit again. The edited project is validated first; error diagnostics reject the whole edit atomically. + param_overrides: + id: + description: ID of the canvas whose source to edit. desktop-file-system-canvas-partial-update: operation: desktop_file_system_canvas_partial_update enabled: true diff --git a/services/mcp/schema/generated-tool-definitions.json b/services/mcp/schema/generated-tool-definitions.json index 491a75924948..fe3ad2e6c861 100644 --- a/services/mcp/schema/generated-tool-definitions.json +++ b/services/mcp/schema/generated-tool-definitions.json @@ -2764,6 +2764,20 @@ "readOnlyHint": true } }, + "desktop-file-system-canvas-edit-create": { + "description": "Publish per-file edits against a canvas's current source, without resending the whole project. Each operation sets a file's complete content, or deletes it when `content` is null. `expected_current_version_id` is REQUIRED (from desktop-file-system-canvas-source-retrieve; null only for a never-published canvas) — a stale base is rejected with 409 version_conflict; re-read the source, re-apply, and edit again. The edited project is validated first; error diagnostics reject the whole edit atomically.", + "category": "Core", + "feature": "core", + "summary": "Edit canvas source files", + "title": "Edit canvas source files", + "required_scopes": ["file_system:write"], + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false + } + }, "desktop-file-system-canvas-partial-update": { "description": "Publish the React source for a freeform canvas (a \"dashboard\" item on the desktop surface). The `id` is the canvas/dashboard id. Pass the COMPLETE single-file React source in `code` — each call replaces the live code and appends a new version to the canvas's history. This is how a canvas-generation task saves its result; do not write a local file.", "category": "Core", diff --git a/services/mcp/schema/tool-definitions-all.json b/services/mcp/schema/tool-definitions-all.json index c568b48f484d..bff6743e8000 100644 --- a/services/mcp/schema/tool-definitions-all.json +++ b/services/mcp/schema/tool-definitions-all.json @@ -2807,6 +2807,20 @@ "readOnlyHint": true } }, + "desktop-file-system-canvas-edit-create": { + "description": "Publish per-file edits against a canvas's current source, without resending the whole project. Each operation sets a file's complete content, or deletes it when `content` is null. `expected_current_version_id` is REQUIRED (from desktop-file-system-canvas-source-retrieve; null only for a never-published canvas) — a stale base is rejected with 409 version_conflict; re-read the source, re-apply, and edit again. The edited project is validated first; error diagnostics reject the whole edit atomically.", + "category": "Core", + "feature": "core", + "summary": "Edit canvas source files", + "title": "Edit canvas source files", + "required_scopes": ["file_system:write"], + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false + } + }, "desktop-file-system-canvas-partial-update": { "description": "Publish the React source for a freeform canvas (a \"dashboard\" item on the desktop surface). The `id` is the canvas/dashboard id. Pass the COMPLETE single-file React source in `code` — each call replaces the live code and appends a new version to the canvas's history. This is how a canvas-generation task saves its result; do not write a local file.", "category": "Core", diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 53ea1d4758e2..fd1ffae98231 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -13735,6 +13735,36 @@ export namespace Schemas { current_version_id: string | null; } + /** + * One per-file edit: set a file's content, or delete it. + */ + export interface CanvasSourceEditOperation { + /** Project-relative path of the file to write or delete (e.g. "src/canvas.tsx"). */ + path: string; + /** + * The file's complete new content. Null (or omitted) deletes the file. + * @nullable + */ + content?: string | null; + } + + /** + * Payload for publishing per-file edits against the canvas's current source. + */ + export interface CanvasSourceEdit { + /** Edits applied in order to the canvas's current source project. */ + operations: CanvasSourceEditOperation[]; + /** Short description of the change, stored on the appended version history entry. */ + prompt?: string; + /** Optional new display name for the canvas (rewrites the leaf segment of its path). */ + name?: string; + /** + * Required optimistic-concurrency guard: the current_version_id the edits are based on (null when the canvas has never been published). Diff edits against a moved head are rejected with 409 version_conflict — they cannot be published unguarded. + * @nullable + */ + expected_current_version_id: string | null; + } + /** * 400 body for a publish whose source project failed validation. */ diff --git a/services/mcp/src/generated/core/api.ts b/services/mcp/src/generated/core/api.ts index 54b93562e805..1b9c56e526d6 100644 --- a/services/mcp/src/generated/core/api.ts +++ b/services/mcp/src/generated/core/api.ts @@ -3,7 +3,7 @@ * MCP service uses these Zod schemas for generated tool handlers. * To regenerate: hogli build:openapi * - * PostHog API - MCP 16 enabled ops + * PostHog API - MCP 17 enabled ops * OpenAPI spec version: 1.0.0 */ import * as zod from 'zod' @@ -762,6 +762,60 @@ export const DesktopFileSystemCanvasBuildsRetrieveParams = /* @__PURE__ */ zod.o ), }) +/** + * Publish per-file edits against the canvas's current source project. + * + * Diff-aware alternative to sending the complete project: each operation + * sets a file's content or (content null) deletes it, applied to the head + * the caller read. `expected_current_version_id` is mandatory here — + * relative edits against an unverified base could silently merge into + * someone else's newer work, so unguarded diff publishes are refused. + */ +export const DesktopFileSystemCanvasEditCreateParams = /* @__PURE__ */ zod.object({ + id: zod.string().describe('A UUID string identifying this file system.'), + project_id: zod + .string() + .describe( + "Project ID of the project you're trying to access. To find the ID of the project, make a call to \/api\/projects\/." + ), +}) + +export const DesktopFileSystemCanvasEditCreateBody = /* @__PURE__ */ zod + .object({ + operations: zod + .array( + zod + .object({ + path: zod + .string() + .describe( + 'Project-relative path of the file to write or delete (e.g. \"src\/canvas.tsx\").' + ), + content: zod + .string() + .nullish() + .describe("The file's complete new content. Null (or omitted) deletes the file."), + }) + .describe("One per-file edit: set a file's content, or delete it.") + ) + .describe("Edits applied in order to the canvas's current source project."), + prompt: zod + .string() + .optional() + .describe('Short description of the change, stored on the appended version history entry.'), + name: zod + .string() + .optional() + .describe('Optional new display name for the canvas (rewrites the leaf segment of its path).'), + expected_current_version_id: zod + .string() + .nullable() + .describe( + 'Required optimistic-concurrency guard: the current_version_id the edits are based on (null when the canvas has never been published). Diff edits against a moved head are rejected with 409 version_conflict — they cannot be published unguarded.' + ), + }) + .describe("Payload for publishing per-file edits against the canvas's current source.") + /** * Publish a complete canvas source project as the canvas's new head version. * diff --git a/services/mcp/src/tools/generated/core.ts b/services/mcp/src/tools/generated/core.ts index d333f07d8306..7f114973020d 100644 --- a/services/mcp/src/tools/generated/core.ts +++ b/services/mcp/src/tools/generated/core.ts @@ -4,6 +4,8 @@ import { z } from 'zod' import type { Schemas } from '@/api/generated' import { DesktopFileSystemCanvasBuildsRetrieveParams, + DesktopFileSystemCanvasEditCreateBody, + DesktopFileSystemCanvasEditCreateParams, DesktopFileSystemCanvasPartialUpdateBody, DesktopFileSystemCanvasPartialUpdateParams, DesktopFileSystemCanvasPublishCreateBody, @@ -52,6 +54,42 @@ const desktopFileSystemCanvasBuildsRetrieve = (): ToolBase< }, }) +const DesktopFileSystemCanvasEditCreateSchema = DesktopFileSystemCanvasEditCreateParams.omit({ project_id: true }) + .extend(DesktopFileSystemCanvasEditCreateBody.shape) + .extend({ + id: DesktopFileSystemCanvasEditCreateParams.shape['id'].describe('ID of the canvas whose source to edit.'), + }) + +const desktopFileSystemCanvasEditCreate = (): ToolBase< + typeof DesktopFileSystemCanvasEditCreateSchema, + Schemas.CanvasSourcePublishResponse +> => ({ + name: 'desktop-file-system-canvas-edit-create', + schema: DesktopFileSystemCanvasEditCreateSchema, + handler: async (context: Context, params: z.infer) => { + const projectId = await context.stateManager.getProjectId() + const body: Record = {} + if (params.operations !== undefined) { + body['operations'] = params.operations + } + if (params.prompt !== undefined) { + body['prompt'] = params.prompt + } + if (params.name !== undefined) { + body['name'] = params.name + } + if (params.expected_current_version_id !== undefined) { + body['expected_current_version_id'] = params.expected_current_version_id + } + const result = await context.api.request({ + method: 'POST', + path: `/api/projects/${encodeURIComponent(String(projectId))}/desktop_file_system/${encodeURIComponent(String(params.id))}/canvas/edit/`, + body, + }) + return result + }, +}) + const DesktopFileSystemCanvasPartialUpdateSchema = DesktopFileSystemCanvasPartialUpdateParams.omit({ project_id: true }) .extend(DesktopFileSystemCanvasPartialUpdateBody.shape) .extend({ @@ -768,6 +806,7 @@ const userSettingsUpdate = (): ToolBase ToolBase> = { 'desktop-file-system-canvas-builds-retrieve': desktopFileSystemCanvasBuildsRetrieve, + 'desktop-file-system-canvas-edit-create': desktopFileSystemCanvasEditCreate, 'desktop-file-system-canvas-partial-update': desktopFileSystemCanvasPartialUpdate, 'desktop-file-system-canvas-publish-create': desktopFileSystemCanvasPublishCreate, 'desktop-file-system-canvas-source-retrieve': desktopFileSystemCanvasSourceRetrieve, diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/desktop-file-system-canvas-edit-create.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/desktop-file-system-canvas-edit-create.json new file mode 100644 index 000000000000..47ece1680da7 --- /dev/null +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/desktop-file-system-canvas-edit-create.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "expected_current_version_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Required optimistic-concurrency guard: the current_version_id the edits are based on (null when the canvas has never been published). Diff edits against a moved head are rejected with 409 version_conflict — they cannot be published unguarded." + }, + "id": { + "description": "ID of the canvas whose source to edit.", + "type": "string" + }, + "name": { + "description": "Optional new display name for the canvas (rewrites the leaf segment of its path).", + "type": "string" + }, + "operations": { + "description": "Edits applied in order to the canvas's current source project.", + "items": { + "description": "One per-file edit: set a file's content, or delete it.", + "properties": { + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The file's complete new content. Null (or omitted) deletes the file." + }, + "path": { + "description": "Project-relative path of the file to write or delete (e.g. \"src/canvas.tsx\").", + "type": "string" + } + }, + "required": ["path"], + "type": "object" + }, + "type": "array" + }, + "prompt": { + "description": "Short description of the change, stored on the appended version history entry.", + "type": "string" + } + }, + "required": ["id", "operations", "expected_current_version_id"], + "type": "object" +}