diff --git a/api/oss/src/core/environments/service.py b/api/oss/src/core/environments/service.py index 96a904bbec..2d94b71a81 100644 --- a/api/oss/src/core/environments/service.py +++ b/api/oss/src/core/environments/service.py @@ -1,8 +1,9 @@ from datetime import datetime, timezone -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional from uuid import UUID, uuid4 import uuid_utils.compat as uuid_compat +from pydantic import BaseModel from oss.src.core.environments.dtos import ( Environment, @@ -60,6 +61,72 @@ log = get_module_logger(__name__) +def _to_jsonable(value: Any) -> Any: + if isinstance(value, BaseModel): + return value.model_dump(mode="json", exclude_none=True) + + if isinstance(value, dict): + return { + str(key): _to_jsonable(item) + for key, item in value.items() + if item is not None + } + + if isinstance(value, list): + return [_to_jsonable(item) for item in value] + + return value + + +def _normalize_environment_references( + references: Optional[Dict[str, Dict[str, Reference]]], +) -> Dict[str, Dict[str, Any]]: + if not references: + return {} + + normalized = _to_jsonable(references) + return normalized if isinstance(normalized, dict) else {} + + +def _normalize_environment_revision_data( + data: Optional[EnvironmentRevisionData], +) -> Dict[str, Any]: + if not data: + return {} + + normalized = _to_jsonable(data) + return normalized if isinstance(normalized, dict) else {} + + +def _build_environment_references_diff( + *, + old: Dict[str, Dict[str, Any]], + new: Dict[str, Dict[str, Any]], +) -> Dict[str, Dict[str, Dict[str, Any]]]: + created: Dict[str, Dict[str, Any]] = {} + updated: Dict[str, Dict[str, Any]] = {} + deleted: Dict[str, Dict[str, Any]] = {} + + for key, new_value in new.items(): + if key not in old: + created[key] = {"new": new_value} + elif old[key] != new_value: + updated[key] = { + "old": old[key], + "new": new_value, + } + + for key, old_value in old.items(): + if key not in new: + deleted[key] = {"old": old_value} + + return { + "created": created, + "updated": updated, + "deleted": deleted, + } + + class EnvironmentsService: def __init__( self, @@ -69,6 +136,32 @@ def __init__( self.embeds_service = None # Will be set later self.environments_dao = environments_dao + async def _get_previous_environment_references( + self, + *, + project_id: UUID, + environment_variant_id: Optional[UUID], + ) -> Dict[str, Dict[str, Any]]: + if environment_variant_id is None: + return {} + + previous_revisions = await self.query_environment_revisions( + project_id=project_id, + environment_variant_refs=[Reference(id=environment_variant_id)], + windowing=Windowing(limit=1), + ) + + if not previous_revisions: + return {} + + previous_revision = previous_revisions[0] + previous_references = ( + previous_revision.data.references + if previous_revision.data and previous_revision.data.references + else None + ) + return _normalize_environment_references(previous_references) + # environments --------------------------------------------------------- async def create_environment( @@ -723,6 +816,15 @@ async def commit_environment_revision( environment_revision_commit=environment_revision_commit, ) + environment_variant_id = ( + environment_revision_commit.environment_variant_id + or environment_revision_commit.variant_id + ) + previous_references = await self._get_previous_environment_references( + project_id=project_id, + environment_variant_id=environment_variant_id, + ) + dumped = environment_revision_commit.model_dump( mode="json", exclude_none=True, @@ -745,6 +847,16 @@ async def commit_environment_revision( mode="json", ), ) + current_state = _normalize_environment_revision_data(environment_revision.data) + current_references = _normalize_environment_references( + environment_revision.data.references + if environment_revision.data and environment_revision.data.references + else None + ) + references_diff = _build_environment_references_diff( + old=previous_references, + new=current_references, + ) # --- THIS WILL BE IMPROVED LATER ------------------------------------ # request_id = uuid_compat.uuid7() @@ -770,6 +882,8 @@ async def commit_environment_revision( version=environment_revision.version, ), ), + state=current_state, + diff=references_diff, ) # --- THIS WILL BE IMPROVED LATER ------------------------------------ # diff --git a/api/oss/tests/pytest/unit/test_environments_service.py b/api/oss/tests/pytest/unit/test_environments_service.py new file mode 100644 index 0000000000..22d36d7be7 --- /dev/null +++ b/api/oss/tests/pytest/unit/test_environments_service.py @@ -0,0 +1,256 @@ +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +import pytest + +from oss.src.core.environments.dtos import ( + EnvironmentRevisionCommit, + EnvironmentRevisionData, +) +from oss.src.core.environments.service import EnvironmentsService +from oss.src.core.git.dtos import Revision +from oss.src.core.shared.dtos import Reference + + +@pytest.mark.asyncio +async def test_commit_environment_revision_publishes_state_and_grouped_diff(): + project_id = uuid4() + user_id = uuid4() + environment_id = uuid4() + variant_id = uuid4() + previous_revision_id = uuid4() + current_revision_id = uuid4() + + unchanged_refs = { + "application": Reference(id=uuid4(), slug="keep-app"), + "application_variant": Reference(id=uuid4(), slug="keep-variant"), + "application_revision": Reference( + id=uuid4(), + slug="keep-revision", + version="v1", + ), + } + old_updated_refs = { + "application": Reference(id=uuid4(), slug="update-app"), + "application_variant": Reference(id=uuid4(), slug="update-variant"), + "application_revision": Reference( + id=uuid4(), + slug="update-revision", + version="v1", + ), + } + new_updated_refs = { + "application": old_updated_refs["application"], + "application_variant": old_updated_refs["application_variant"], + "application_revision": Reference( + id=uuid4(), + slug="update-revision", + version="v2", + ), + } + deleted_refs = { + "application": Reference(id=uuid4(), slug="delete-app"), + "application_variant": Reference(id=uuid4(), slug="delete-variant"), + "application_revision": Reference( + id=uuid4(), + slug="delete-revision", + version="v1", + ), + } + created_refs = { + "application": Reference(id=uuid4(), slug="create-app"), + "application_variant": Reference(id=uuid4(), slug="create-variant"), + "application_revision": Reference( + id=uuid4(), + slug="create-revision", + version="v1", + ), + } + + previous_revision = Revision( + id=previous_revision_id, + slug="prev", + version="v1", + artifact_id=environment_id, + variant_id=variant_id, + data=EnvironmentRevisionData( + references={ + "keep.revision": unchanged_refs, + "update.revision": old_updated_refs, + "delete.revision": deleted_refs, + } + ).model_dump(mode="json", exclude_none=True), + ) + committed_revision = Revision( + id=current_revision_id, + slug="curr", + version="v2", + artifact_id=environment_id, + variant_id=variant_id, + data=EnvironmentRevisionData( + references={ + "keep.revision": unchanged_refs, + "update.revision": new_updated_refs, + "create.revision": created_refs, + } + ).model_dump(mode="json", exclude_none=True), + ) + + environments_dao = MagicMock() + environments_dao.query_revisions = AsyncMock(return_value=[previous_revision]) + environments_dao.commit_revision = AsyncMock(return_value=committed_revision) + + service = EnvironmentsService(environments_dao=environments_dao) + commit = EnvironmentRevisionCommit( + slug="curr", + environment_id=environment_id, + environment_variant_id=variant_id, + data=EnvironmentRevisionData( + references={ + "keep.revision": unchanged_refs, + "update.revision": new_updated_refs, + "create.revision": created_refs, + } + ), + ) + + with patch( + "oss.src.core.environments.service.publish_event", + new=AsyncMock(), + ) as publish_event: + await service.commit_environment_revision( + project_id=project_id, + user_id=user_id, + environment_revision_commit=commit, + ) + + publish_event.assert_awaited_once() + event = publish_event.await_args.kwargs["event"] + attributes = event.attributes + + assert attributes["state"] == { + "references": { + "keep.revision": { + "application": { + "id": str(unchanged_refs["application"].id), + "slug": "keep-app", + }, + "application_variant": { + "id": str(unchanged_refs["application_variant"].id), + "slug": "keep-variant", + }, + "application_revision": { + "id": str(unchanged_refs["application_revision"].id), + "slug": "keep-revision", + "version": "v1", + }, + }, + "update.revision": { + "application": { + "id": str(old_updated_refs["application"].id), + "slug": "update-app", + }, + "application_variant": { + "id": str(old_updated_refs["application_variant"].id), + "slug": "update-variant", + }, + "application_revision": { + "id": str(new_updated_refs["application_revision"].id), + "slug": "update-revision", + "version": "v2", + }, + }, + "create.revision": { + "application": { + "id": str(created_refs["application"].id), + "slug": "create-app", + }, + "application_variant": { + "id": str(created_refs["application_variant"].id), + "slug": "create-variant", + }, + "application_revision": { + "id": str(created_refs["application_revision"].id), + "slug": "create-revision", + "version": "v1", + }, + }, + } + } + assert attributes["diff"] == { + "created": { + "create.revision": { + "new": { + "application": { + "id": str(created_refs["application"].id), + "slug": "create-app", + }, + "application_variant": { + "id": str(created_refs["application_variant"].id), + "slug": "create-variant", + }, + "application_revision": { + "id": str(created_refs["application_revision"].id), + "slug": "create-revision", + "version": "v1", + }, + } + } + }, + "updated": { + "update.revision": { + "old": { + "application": { + "id": str(old_updated_refs["application"].id), + "slug": "update-app", + }, + "application_variant": { + "id": str(old_updated_refs["application_variant"].id), + "slug": "update-variant", + }, + "application_revision": { + "id": str(old_updated_refs["application_revision"].id), + "slug": "update-revision", + "version": "v1", + }, + }, + "new": { + "application": { + "id": str(old_updated_refs["application"].id), + "slug": "update-app", + }, + "application_variant": { + "id": str(old_updated_refs["application_variant"].id), + "slug": "update-variant", + }, + "application_revision": { + "id": str(new_updated_refs["application_revision"].id), + "slug": "update-revision", + "version": "v2", + }, + }, + } + }, + "deleted": { + "delete.revision": { + "old": { + "application": { + "id": str(deleted_refs["application"].id), + "slug": "delete-app", + }, + "application_variant": { + "id": str(deleted_refs["application_variant"].id), + "slug": "delete-variant", + }, + "application_revision": { + "id": str(deleted_refs["application_revision"].id), + "slug": "delete-revision", + "version": "v1", + }, + } + } + }, + } + assert "keep.revision" not in attributes["diff"]["updated"] + assert "keep.revision" not in attributes["diff"]["created"] + assert "keep.revision" not in attributes["diff"]["deleted"] diff --git a/docs/docs/prompt-engineering/integrating-prompts/04-webhooks.mdx b/docs/docs/prompt-engineering/integrating-prompts/04-webhooks.mdx index c1ff1c812a..5d9be5ec99 100644 --- a/docs/docs/prompt-engineering/integrating-prompts/04-webhooks.mdx +++ b/docs/docs/prompt-engineering/integrating-prompts/04-webhooks.mdx @@ -49,24 +49,182 @@ After you create the automation, Agenta sends an HTTP `POST` to your endpoint ev ## What Agenta sends -For `environments.revisions.committed`, the current webhook body is the event attributes object. +Agenta sends a JSON body with three top-level objects: + +- `event`, which contains the event metadata and payload +- `subscription`, which identifies the automation that fired +- `scope`, which identifies the project + +For `environments.revisions.committed`, the deployment details live in `event.attributes`. + +`references` identifies the environment revision that was just committed. + +`state` is the current value of `data.references` in that environment revision. + +`diff` is the diff for that commit. + +The overall request body looks like this: ```json { + "event": { + "event_id": "01961234-5678-7abc-9def-123456789abc", + "event_type": "environments.revisions.committed", + "timestamp": "2026-04-07T11:24:18.000Z", + "created_at": "2026-04-07T11:24:18.000Z", + "attributes": { + "...": "deployment payload" + } + }, + "subscription": { + "id": "01961234-aaaa-7abc-9def-123456789abc" + }, + "scope": { + "project_id": "01961234-bbbb-7abc-9def-123456789abc" + } +} +``` + +Here are two concrete `event.attributes` examples. + +### Example 1: first deployment of a new app + +```json +{ + "user_id": "019315dc-a332-7ba5-a426-d079c43ab776", "references": { "environment": { "id": "019c2b74-d84f-7cf2-aff0-e45e116e26cb" }, "environment_revision": { "id": "019cd9b8-e21c-7c73-82a2-099cb1352f19", - "slug": "5baf5e00de25", - "version": "13" + "slug": "prod-0001", + "version": "1" }, "environment_variant": { "id": "019c2b74-d85c-7803-8a55-f12f2fc8f461" } }, - "user_id": "019315dc-a332-7ba5-a426-d079c43ab776" + "state": { + "references": { + "customer-support-bot.revision": { + "application": { + "id": "019c2b74-d8b7-74e7-9f16-6a6a2c9cd111", + "slug": "customer-support-bot" + }, + "application_variant": { + "id": "019c2b74-d8df-7f57-bb13-5e8c0c3f5222", + "slug": "production" + }, + "application_revision": { + "id": "019cd9b8-e21c-7c73-82a2-099cb1352f19", + "slug": "prompt-v1", + "version": "1" + } + } + } + }, + "diff": { + "created": { + "customer-support-bot.revision": { + "new": { + "application": { + "id": "019c2b74-d8b7-74e7-9f16-6a6a2c9cd111", + "slug": "customer-support-bot" + }, + "application_variant": { + "id": "019c2b74-d8df-7f57-bb13-5e8c0c3f5222", + "slug": "production" + }, + "application_revision": { + "id": "019cd9b8-e21c-7c73-82a2-099cb1352f19", + "slug": "prompt-v1", + "version": "1" + } + } + } + }, + "updated": {}, + "deleted": {} + } +} +``` + +### Example 2: deploying a new revision for an app that is already in the environment + +```json +{ + "user_id": "019315dc-a332-7ba5-a426-d079c43ab776", + "references": { + "environment": { + "id": "019c2b74-d84f-7cf2-aff0-e45e116e26cb" + }, + "environment_revision": { + "id": "019cd9c9-a742-78e0-9c0d-a08da7df88a1", + "slug": "prod-0008", + "version": "8" + }, + "environment_variant": { + "id": "019c2b74-d85c-7803-8a55-f12f2fc8f461" + } + }, + "state": { + "references": { + "customer-support-bot.revision": { + "application": { + "id": "019c2b74-d8b7-74e7-9f16-6a6a2c9cd111", + "slug": "customer-support-bot" + }, + "application_variant": { + "id": "019c2b74-d8df-7f57-bb13-5e8c0c3f5222", + "slug": "production" + }, + "application_revision": { + "id": "019cd9c9-a742-78e0-9c0d-a08da7df88a1", + "slug": "prompt-v8", + "version": "8" + } + } + } + }, + "diff": { + "created": {}, + "updated": { + "customer-support-bot.revision": { + "old": { + "application": { + "id": "019c2b74-d8b7-74e7-9f16-6a6a2c9cd111", + "slug": "customer-support-bot" + }, + "application_variant": { + "id": "019c2b74-d8df-7f57-bb13-5e8c0c3f5222", + "slug": "production" + }, + "application_revision": { + "id": "019cd9a1-3fd6-7144-9c0d-fcbf0a6fd777", + "slug": "prompt-v7", + "version": "7" + } + }, + "new": { + "application": { + "id": "019c2b74-d8b7-74e7-9f16-6a6a2c9cd111", + "slug": "customer-support-bot" + }, + "application_variant": { + "id": "019c2b74-d8df-7f57-bb13-5e8c0c3f5222", + "slug": "production" + }, + "application_revision": { + "id": "019cd9c9-a742-78e0-9c0d-a08da7df88a1", + "slug": "prompt-v8", + "version": "8" + } + } + } + }, + "deleted": {} + } } ``` diff --git a/docs/docs/prompt-engineering/integrating-prompts/05-github.mdx b/docs/docs/prompt-engineering/integrating-prompts/05-github.mdx index 67fb940878..97ddfcc901 100644 --- a/docs/docs/prompt-engineering/integrating-prompts/05-github.mdx +++ b/docs/docs/prompt-engineering/integrating-prompts/05-github.mdx @@ -40,10 +40,10 @@ POST https://api.github.com/repos///dispatches { "event_type": "environments.revisions.committed", "client_payload": { - "event_id": "01961234-5678-7abc-...", + "event_id": "01961234-5678-7abc-9def-123456789abc", "event_type": "environments.revisions.committed", - "timestamp": "2026-03-10T20:44:12.264Z", - "created_at": "2026-03-10T20:44:12.264Z", + "timestamp": "2026-04-07T11:24:18.000Z", + "created_at": "2026-04-07T11:24:18.000Z", "attributes": { "user_id": "", "references": { @@ -51,9 +51,66 @@ POST https://api.github.com/repos///dispatches "environment_variant": {"id": ""}, "environment_revision": { "id": "", - "slug": "", - "version": "" + "slug": "prod-0008", + "version": "8" + } + }, + "state": { + "references": { + "customer-support-bot.revision": { + "application": { + "id": "", + "slug": "customer-support-bot" + }, + "application_variant": { + "id": "", + "slug": "production" + }, + "application_revision": { + "id": "", + "slug": "prompt-v8", + "version": "8" + } + } } + }, + "diff": { + "created": {}, + "updated": { + "customer-support-bot.revision": { + "old": { + "application": { + "id": "", + "slug": "customer-support-bot" + }, + "application_variant": { + "id": "", + "slug": "production" + }, + "application_revision": { + "id": "", + "slug": "prompt-v7", + "version": "7" + } + }, + "new": { + "application": { + "id": "", + "slug": "customer-support-bot" + }, + "application_variant": { + "id": "", + "slug": "production" + }, + "application_revision": { + "id": "", + "slug": "prompt-v8", + "version": "8" + } + } + } + }, + "deleted": {} } } } @@ -62,6 +119,8 @@ POST https://api.github.com/repos///dispatches This mode is useful when your workflow needs the full event payload. +In that payload, `references` identifies the environment revision that was committed, `state` is the current value of that revision's `data.references`, and `diff` is the diff for the commit. + ### Workflow dispatch Use workflow dispatch when you want Agenta to trigger one known workflow file on a specific branch. diff --git a/web/oss/src/components/Automations/utils/buildPreviewRequest.ts b/web/oss/src/components/Automations/utils/buildPreviewRequest.ts index 53989ea2a8..ce9428c2a9 100644 --- a/web/oss/src/components/Automations/utils/buildPreviewRequest.ts +++ b/web/oss/src/components/Automations/utils/buildPreviewRequest.ts @@ -16,43 +16,122 @@ export interface PreviewContext { } /** - * Builds a dummy event context that mirrors the resolver input on the backend. - * - * GitHub payload templates resolve against this full context ($.event.*, - * $.subscription.*, $.scope.*). The plain webhook provider currently sends - * event.attributes as the body. + * Builds example event attributes for an environment deployment. */ -const buildEventContext = (eventType: string, ctx?: PreviewContext) => ({ - event: { - event_id: "01961234-5678-7abc-...", - event_type: eventType, - timestamp: new Date().toISOString(), - created_at: new Date().toISOString(), - attributes: { - user_id: ctx?.userId || "", - references: { - environment: { - id: "", +const buildCommittedEnvironmentAttributes = (ctx?: PreviewContext) => ({ + user_id: ctx?.userId || "", + references: { + environment: { + id: "019c2b74-d84f-7cf2-aff0-e45e116e26cb", + }, + environment_variant: { + id: "019c2b74-d85c-7803-8a55-f12f2fc8f461", + }, + environment_revision: { + id: "019cd9b8-e21c-7c73-82a2-099cb1352f19", + slug: "prod-0008", + version: "8", + }, + }, + state: { + references: { + "customer-support-bot.revision": { + application: { + id: "019c2b74-d8b7-74e7-9f16-6a6a2c9cd111", + slug: "customer-support-bot", }, - environment_variant: { - id: "", + application_variant: { + id: "019c2b74-d8df-7f57-bb13-5e8c0c3f5222", + slug: "production", }, - environment_revision: { - id: "", - slug: "", - version: "", + application_revision: { + id: "019cd9b8-e21c-7c73-82a2-099cb1352f19", + slug: "prompt-v8", + version: "8", }, }, }, }, - subscription: { - id: ctx?.subscriptionId || "", - }, - scope: { - project_id: ctx?.projectId || "", + diff: { + created: {}, + updated: { + "customer-support-bot.revision": { + old: { + application: { + id: "019c2b74-d8b7-74e7-9f16-6a6a2c9cd111", + slug: "customer-support-bot", + }, + application_variant: { + id: "019c2b74-d8df-7f57-bb13-5e8c0c3f5222", + slug: "production", + }, + application_revision: { + id: "019cd9a1-3fd6-7144-9c0d-fcbf0a6fd777", + slug: "prompt-v7", + version: "7", + }, + }, + new: { + application: { + id: "019c2b74-d8b7-74e7-9f16-6a6a2c9cd111", + slug: "customer-support-bot", + }, + application_variant: { + id: "019c2b74-d8df-7f57-bb13-5e8c0c3f5222", + slug: "production", + }, + application_revision: { + id: "019cd9b8-e21c-7c73-82a2-099cb1352f19", + slug: "prompt-v8", + version: "8", + }, + }, + }, + }, + deleted: {}, }, }) +const buildEventContext = (eventType: string, ctx?: PreviewContext) => { + const timestamp = new Date().toISOString() + + if (eventType === "webhooks.subscriptions.tested") { + return { + event: { + event_id: "01961234-5678-7abc-9def-123456789abc", + event_type: eventType, + timestamp, + created_at: timestamp, + attributes: { + subscription_id: ctx?.subscriptionId || "draft", + }, + }, + subscription: { + id: ctx?.subscriptionId || "draft", + }, + scope: { + project_id: ctx?.projectId || "", + }, + } + } + + return { + event: { + event_id: "01961234-5678-7abc-9def-123456789abc", + event_type: eventType, + timestamp, + created_at: timestamp, + attributes: buildCommittedEnvironmentAttributes(ctx), + }, + subscription: { + id: ctx?.subscriptionId || "", + }, + scope: { + project_id: ctx?.projectId || "", + }, + } +} + /** * Recursively resolves template strings in a payload object. */ @@ -88,7 +167,7 @@ const resolvePayloadMocks = (payload: any, eventContext: Record): a /** * Creates a read-only HTTP request preview for the UI. - * Masks tokens and resolves basic payload templates so the user sees approximately what will be sent. + * Masks tokens and resolves payload templates so the user sees what Agenta sends. */ export const buildPreviewRequest = ( formValues: AutomationFormValues, @@ -152,7 +231,7 @@ export const buildPreviewRequest = ( method: "POST", url: url || "https://...", headers: finalHeaders, - body: eventContext.event.attributes, + body: eventContext, } } else if (provider === "github") { const subType = github_sub_type || "repository_dispatch"