From f606d7a10e27d50ee5387144f43595105f0f9375 Mon Sep 17 00:00:00 2001 From: Juan Pablo Vega Date: Fri, 19 Jun 2026 11:43:38 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20gateway=20triggers=20=E2=80=94=20Co?= =?UTF-8?q?mposio=20event=20ingress,=20subscriptions,=20and=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inbound dual of webhooks: turn external provider events into Agenta workflow runs. Adds a shared routerless connections domain (core/gateway/connections), a triggers domain (event catalog, subscriptions, deliveries), a global Composio ingress endpoint with HMAC verification + async dispatch worker, and the web UI for catalog browse and subscription/delivery management. Includes design docs and unit/acceptance tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 110 ++- api/ee/src/core/access/permissions/types.py | 8 + .../pytest/acceptance}/tools/__init__.py | 0 .../tools/test_tools_connections.py | 155 ++++ .../pytest/acceptance/triggers/__init__.py | 0 .../triggers/test_triggers_catalog.py | 159 ++++ .../triggers/test_triggers_subscriptions.py | 226 +++++ api/entrypoints/routers.py | 109 ++- api/entrypoints/worker_triggers.py | 142 +++ ...tool_connections_to_gateway_connections.py | 49 ++ ...dd_trigger_subscriptions_and_deliveries.py | 179 ++++ api/oss/src/apis/fastapi/tools/models.py | 13 +- api/oss/src/apis/fastapi/tools/router.py | 10 +- api/oss/src/apis/fastapi/triggers/__init__.py | 0 api/oss/src/apis/fastapi/triggers/models.py | 116 +++ api/oss/src/apis/fastapi/triggers/router.py | 809 ++++++++++++++++++ api/oss/src/core/gateway/__init__.py | 0 .../src/core/gateway/connections/__init__.py | 0 api/oss/src/core/gateway/connections/dtos.py | 130 +++ .../core/gateway/connections/exceptions.py | 65 ++ .../core/gateway/connections/interfaces.py | 127 +++ .../gateway/connections/providers/__init__.py | 0 .../providers/composio/__init__.py | 20 + .../connections/providers/composio/adapter.py | 302 +++++++ .../src/core/gateway/connections/registry.py | 27 + .../src/core/gateway/connections/service.py | 327 +++++++ .../{tools => gateway/connections}/utils.py | 2 +- api/oss/src/core/tools/dtos.py | 95 -- api/oss/src/core/tools/interfaces.py | 253 ++---- .../core/tools/providers/composio/adapter.py | 225 +---- api/oss/src/core/tools/service.py | 675 ++++++--------- api/oss/src/core/triggers/__init__.py | 0 api/oss/src/core/triggers/dtos.py | 190 ++++ api/oss/src/core/triggers/exceptions.py | 52 ++ api/oss/src/core/triggers/interfaces.py | 203 +++++ .../src/core/triggers/providers/__init__.py | 0 .../triggers/providers/composio/__init__.py | 18 + .../triggers/providers/composio/adapter.py | 187 ++++ .../triggers/providers/composio/catalog.py | 188 ++++ api/oss/src/core/triggers/registry.py | 27 + api/oss/src/core/triggers/service.py | 390 +++++++++ api/oss/src/core/webhooks/delivery.py | 29 +- api/oss/src/dbs/postgres/gateway/__init__.py | 0 .../postgres/gateway/connections/__init__.py | 0 .../{tools => gateway/connections}/dao.py | 564 ++++++------ .../{tools => gateway/connections}/dbes.py | 138 +-- .../connections}/mappings.py | 24 +- api/oss/src/dbs/postgres/triggers/__init__.py | 0 api/oss/src/dbs/postgres/triggers/dao.py | 404 +++++++++ api/oss/src/dbs/postgres/triggers/dbas.py | 53 ++ api/oss/src/dbs/postgres/triggers/dbes.py | 75 ++ api/oss/src/dbs/postgres/triggers/mappings.py | 179 ++++ api/oss/src/middlewares/auth.py | 5 + .../src/tasks/asyncio/triggers/__init__.py | 0 .../src/tasks/asyncio/triggers/dispatcher.py | 244 ++++++ api/oss/src/tasks/taskiq/triggers/__init__.py | 0 api/oss/src/tasks/taskiq/triggers/worker.py | 63 ++ api/oss/src/utils/env.py | 1 + .../tools/test_tools_connections.py | 72 ++ .../pytest/acceptance/triggers/__init__.py | 0 .../triggers/test_triggers_catalog.py | 77 ++ .../triggers/test_triggers_ingress.py | 163 ++++ .../triggers/test_triggers_subscriptions.py | 155 ++++ .../unit/models/test_lifecycle_conventions.py | 2 +- .../tests/pytest/unit/triggers/__init__.py | 0 .../unit/triggers/test_triggers_dispatcher.py | 149 ++++ .../unit/triggers/test_triggers_signature.py | 106 +++ .../unit/webhooks/test_webhooks_tasks.py | 31 +- docs/designs/gateway-triggers/gap.md | 140 +++ docs/designs/gateway-triggers/mapping.md | 330 +++++++ docs/designs/gateway-triggers/mimics.md | 307 +++++++ docs/designs/gateway-triggers/plan.md | 409 +++++++++ docs/designs/gateway-triggers/proposal.md | 236 +++++ docs/designs/gateway-triggers/research.md | 403 +++++++++ .../designs/gateway-triggers/wp/WL-runbook.md | 156 ++++ docs/designs/gateway-triggers/wp/WP0-specs.md | 104 +++ .../designs/gateway-triggers/wp/WP0-status.md | 65 ++ docs/designs/gateway-triggers/wp/WP1-specs.md | 66 ++ .../designs/gateway-triggers/wp/WP1-status.md | 43 + docs/designs/gateway-triggers/wp/WP2-specs.md | 51 ++ .../designs/gateway-triggers/wp/WP2-status.md | 43 + docs/designs/gateway-triggers/wp/WP3-specs.md | 64 ++ .../designs/gateway-triggers/wp/WP3-status.md | 60 ++ docs/designs/gateway-triggers/wp/WP4-specs.md | 58 ++ .../designs/gateway-triggers/wp/WP4-status.md | 36 + docs/designs/gateway-triggers/wp/WP5-specs.md | 43 + .../designs/gateway-triggers/wp/WP5-status.md | 76 ++ docs/designs/gateway-triggers/wp/WP6-specs.md | 38 + .../designs/gateway-triggers/wp/WP6-status.md | 24 + .../docker-compose/ee/docker-compose.dev.yml | 45 + .../ee/docker-compose.gh.local.yml | 41 + .../docker-compose/ee/docker-compose.gh.yml | 39 + .../docker-compose/oss/docker-compose.dev.yml | 44 + .../oss/docker-compose.gh.local.yml | 41 + .../oss/docker-compose.gh.ssl.yml | 41 + .../docker-compose/oss/docker-compose.gh.yml | 44 + sdks/python/agenta/sdk/utils/resolvers.py | 32 + .../oss/tests/pytest/unit/test_resolvers.py | 125 +++ .../components/Sidebar/SettingsSidebar.tsx | 14 + .../pages/settings/Triggers/Triggers.tsx | 11 + .../GatewaySubscriptionsSection.tsx | 264 ++++++ .../components/GatewayTriggersSection.tsx | 134 +++ .../p/[project_id]/settings/index.tsx | 10 + web/packages/agenta-entities/package.json | 1 + .../src/gatewayTrigger/api/api.ts | 274 ++++++ .../src/gatewayTrigger/api/client.ts | 30 + .../src/gatewayTrigger/api/index.ts | 17 + .../src/gatewayTrigger/core/index.ts | 1 + .../src/gatewayTrigger/core/types.ts | 301 +++++++ .../src/gatewayTrigger/hooks/index.ts | 16 + .../gatewayTrigger/hooks/useCatalogEvents.ts | 84 ++ .../hooks/useTriggerConnections.ts | 66 ++ .../hooks/useTriggerDeliveries.ts | 36 + .../gatewayTrigger/hooks/useTriggerEvent.ts | 37 + .../hooks/useTriggerSubscription.ts | 94 ++ .../hooks/useTriggerSubscriptions.ts | 60 ++ .../src/gatewayTrigger/index.ts | 102 +++ .../src/gatewayTrigger/state/atoms.ts | 38 + .../src/gatewayTrigger/state/index.ts | 8 + .../tests/unit/gatewayTriggerApi.test.ts | 282 ++++++ web/packages/agenta-entity-ui/package.json | 1 + .../drawers/TriggerDeliveriesDrawer.tsx | 162 ++++ .../drawers/TriggerEventsDrawer.tsx | 250 ++++++ .../drawers/TriggerSubscriptionDrawer.tsx | 340 ++++++++ .../src/gatewayTrigger/index.ts | 12 + 125 files changed, 12642 insertions(+), 1329 deletions(-) rename api/{oss/src/dbs/postgres => ee/tests/pytest/acceptance}/tools/__init__.py (100%) create mode 100644 api/ee/tests/pytest/acceptance/tools/test_tools_connections.py create mode 100644 api/ee/tests/pytest/acceptance/triggers/__init__.py create mode 100644 api/ee/tests/pytest/acceptance/triggers/test_triggers_catalog.py create mode 100644 api/ee/tests/pytest/acceptance/triggers/test_triggers_subscriptions.py create mode 100644 api/entrypoints/worker_triggers.py create mode 100644 api/oss/databases/postgres/migrations/core_oss/versions/oss000000002_rename_tool_connections_to_gateway_connections.py create mode 100644 api/oss/databases/postgres/migrations/core_oss/versions/oss000000003_add_trigger_subscriptions_and_deliveries.py create mode 100644 api/oss/src/apis/fastapi/triggers/__init__.py create mode 100644 api/oss/src/apis/fastapi/triggers/models.py create mode 100644 api/oss/src/apis/fastapi/triggers/router.py create mode 100644 api/oss/src/core/gateway/__init__.py create mode 100644 api/oss/src/core/gateway/connections/__init__.py create mode 100644 api/oss/src/core/gateway/connections/dtos.py create mode 100644 api/oss/src/core/gateway/connections/exceptions.py create mode 100644 api/oss/src/core/gateway/connections/interfaces.py create mode 100644 api/oss/src/core/gateway/connections/providers/__init__.py create mode 100644 api/oss/src/core/gateway/connections/providers/composio/__init__.py create mode 100644 api/oss/src/core/gateway/connections/providers/composio/adapter.py create mode 100644 api/oss/src/core/gateway/connections/registry.py create mode 100644 api/oss/src/core/gateway/connections/service.py rename api/oss/src/core/{tools => gateway/connections}/utils.py (96%) create mode 100644 api/oss/src/core/triggers/__init__.py create mode 100644 api/oss/src/core/triggers/dtos.py create mode 100644 api/oss/src/core/triggers/exceptions.py create mode 100644 api/oss/src/core/triggers/interfaces.py create mode 100644 api/oss/src/core/triggers/providers/__init__.py create mode 100644 api/oss/src/core/triggers/providers/composio/__init__.py create mode 100644 api/oss/src/core/triggers/providers/composio/adapter.py create mode 100644 api/oss/src/core/triggers/providers/composio/catalog.py create mode 100644 api/oss/src/core/triggers/registry.py create mode 100644 api/oss/src/core/triggers/service.py create mode 100644 api/oss/src/dbs/postgres/gateway/__init__.py create mode 100644 api/oss/src/dbs/postgres/gateway/connections/__init__.py rename api/oss/src/dbs/postgres/{tools => gateway/connections}/dao.py (74%) rename api/oss/src/dbs/postgres/{tools => gateway/connections}/dbes.py (81%) rename api/oss/src/dbs/postgres/{tools => gateway/connections}/mappings.py (80%) create mode 100644 api/oss/src/dbs/postgres/triggers/__init__.py create mode 100644 api/oss/src/dbs/postgres/triggers/dao.py create mode 100644 api/oss/src/dbs/postgres/triggers/dbas.py create mode 100644 api/oss/src/dbs/postgres/triggers/dbes.py create mode 100644 api/oss/src/dbs/postgres/triggers/mappings.py create mode 100644 api/oss/src/tasks/asyncio/triggers/__init__.py create mode 100644 api/oss/src/tasks/asyncio/triggers/dispatcher.py create mode 100644 api/oss/src/tasks/taskiq/triggers/__init__.py create mode 100644 api/oss/src/tasks/taskiq/triggers/worker.py create mode 100644 api/oss/tests/pytest/acceptance/tools/test_tools_connections.py create mode 100644 api/oss/tests/pytest/acceptance/triggers/__init__.py create mode 100644 api/oss/tests/pytest/acceptance/triggers/test_triggers_catalog.py create mode 100644 api/oss/tests/pytest/acceptance/triggers/test_triggers_ingress.py create mode 100644 api/oss/tests/pytest/acceptance/triggers/test_triggers_subscriptions.py create mode 100644 api/oss/tests/pytest/unit/triggers/__init__.py create mode 100644 api/oss/tests/pytest/unit/triggers/test_triggers_dispatcher.py create mode 100644 api/oss/tests/pytest/unit/triggers/test_triggers_signature.py create mode 100644 docs/designs/gateway-triggers/gap.md create mode 100644 docs/designs/gateway-triggers/mapping.md create mode 100644 docs/designs/gateway-triggers/mimics.md create mode 100644 docs/designs/gateway-triggers/plan.md create mode 100644 docs/designs/gateway-triggers/proposal.md create mode 100644 docs/designs/gateway-triggers/research.md create mode 100644 docs/designs/gateway-triggers/wp/WL-runbook.md create mode 100644 docs/designs/gateway-triggers/wp/WP0-specs.md create mode 100644 docs/designs/gateway-triggers/wp/WP0-status.md create mode 100644 docs/designs/gateway-triggers/wp/WP1-specs.md create mode 100644 docs/designs/gateway-triggers/wp/WP1-status.md create mode 100644 docs/designs/gateway-triggers/wp/WP2-specs.md create mode 100644 docs/designs/gateway-triggers/wp/WP2-status.md create mode 100644 docs/designs/gateway-triggers/wp/WP3-specs.md create mode 100644 docs/designs/gateway-triggers/wp/WP3-status.md create mode 100644 docs/designs/gateway-triggers/wp/WP4-specs.md create mode 100644 docs/designs/gateway-triggers/wp/WP4-status.md create mode 100644 docs/designs/gateway-triggers/wp/WP5-specs.md create mode 100644 docs/designs/gateway-triggers/wp/WP5-status.md create mode 100644 docs/designs/gateway-triggers/wp/WP6-specs.md create mode 100644 docs/designs/gateway-triggers/wp/WP6-status.md create mode 100644 sdks/python/oss/tests/pytest/unit/test_resolvers.py create mode 100644 web/oss/src/components/pages/settings/Triggers/Triggers.tsx create mode 100644 web/oss/src/components/pages/settings/Triggers/components/GatewaySubscriptionsSection.tsx create mode 100644 web/oss/src/components/pages/settings/Triggers/components/GatewayTriggersSection.tsx create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/api/api.ts create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/api/client.ts create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/api/index.ts create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/core/index.ts create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/core/types.ts create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/hooks/index.ts create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/hooks/useCatalogEvents.ts create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerConnections.ts create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerDeliveries.ts create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerEvent.ts create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSubscription.ts create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSubscriptions.ts create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/index.ts create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/state/atoms.ts create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/state/index.ts create mode 100644 web/packages/agenta-entities/tests/unit/gatewayTriggerApi.test.ts create mode 100644 web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerDeliveriesDrawer.tsx create mode 100644 web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerEventsDrawer.tsx create mode 100644 web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerSubscriptionDrawer.tsx create mode 100644 web/packages/agenta-entity-ui/src/gatewayTrigger/index.ts diff --git a/AGENTS.md b/AGENTS.md index 14a73c0f3e..a287b4b00c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,11 +40,115 @@ If so, use the `but` CLI instead of raw `git branch`/`git commit`: - `but pr new` needs interactive forge auth; use `but push ` then `gh pr create --head --base ` instead. For stacked PRs, set `--base` to the parent branch so each PR shows only its own diff. +- **`but push` prints NOTHING on success.** It is not a confirmation — always verify + the push landed by comparing SHAs: + `git ls-remote --heads origin ` vs `git rev-parse `. They must match. - To update an already-committed file, `but absorb ` amends it into the right commit; force-push with `but push -f`. -- To commit to a specific branch in a stack, stage the files to it first - (`but rub `), then `but commit --only`. `but commit` - alone sweeps ALL uncommitted changes into that branch. + +### Committing to specific lanes in a stack (the part that bites) + +Changes are assigned to the **stack**, not to an individual branch. `but rub +` and `but commit --only` both operate on the stack's *assigned-changes* +set — `--only` commits **whatever is currently assigned** to the named branch, regardless +of which branch name you used when staging. So: + +- **Never pre-stage multiple lanes' files and then commit them one lane at a time.** The + first `but commit --only` sweeps the entire assigned set into that one branch (the others + end up empty or scrambled). Instead, work **one lane at a time**: assign exactly that + lane's files → `but commit --only` → **verify** → then assign the next lane's + files. Keep the assigned set equal to exactly one lane's files at each commit. +- **Verify every commit immediately:** `git show --stat --name-only `. If a file + from another lane leaked in, stop and fix before continuing. +- **`but rub` by path goes stale after any mutation.** Every `but` mutation kicks a + background sync that invalidates the path index, so the *next* path-based + `but rub ...` often fails with "Source '' not found". Use the stable + **cliId** instead (the 2-4 char code in `but status` / `but status --json`): + `but rub `. cliIds survive across the sync; paths don't. +- **Splitting one file across two stacked lanes** (e.g. `routers.py` where the lower lane + owns half the edit and the upper lane the other half): you cannot split mixed hunks + reliably. Instead use sequential working-tree states — make the file the lower lane's + version, commit it to the lower lane; then edit the file to add the upper lane's delta + and `but rub ` to amend that delta into the upper commit. +- The **branch ref can diverge from the workspace-applied commit** mid-session (after + absorb/amend/rebase). The **working tree is the source of truth**; `but push` pushes the + applied state. Don't panic if `git diff -- ` shows a delta while + `git status` is clean — verify against `git show ":"` and re-push. + +### Spreading a pile of edits back across an existing stack (the reliable way) + +When you have a working tree full of changes that belong to *many* lanes of an +already-pushed stack (e.g. a review-pass that fixes files across wp0…wp4), do NOT try to +assign-and-commit lane by lane against the live working tree — `but rub`/`but commit +--only`/`but absorb` all route by **hunk dependency across the whole stack**, and they +mis-route in three predictable ways that scramble the stack and waste hours: + +- **New (untracked) files ignore the target branch.** `but rub ` + dumps every untracked file into the **topmost** lane's staging group, not the one you + named. New files cannot be assigned to a lower lane at all. +- **`but absorb` sends anything it can't attribute to the docs/top lane.** Renamed files, + new files, and hunks in line-regions the target lane's original commit never touched all + fall to the "last commit in the primary lane" fallback — silently the wrong lane. +- **A multi-hunk file whose hunks belong to different commits won't commit whole.** `but + commit ` / `-p ` commits the attributable hunks and **drops the rest** + ("Warning: Some selected changes could not be committed"), often leaving an empty + no-change commit. Splitting one file across lower+upper lanes is the §"Splitting one file + across two stacked lanes" case above. + +The technique that actually works — **git-stash isolation, one lane at a time:** + +1. `but oplog snapshot -m "pristine"` then `git stash push -u` everything. Working tree + clean, every lane back at its remote tip. This snapshot is your only safe recovery + point — `but oplog restore` it whenever a step scrambles the stack (it does, often). +2. For each lane, restore **only that lane's files** into the clean tree: + tracked-modified from `git checkout 'stash@{0}' -- `; **untracked/new** files + from the stash's untracked parent `git checkout 'stash@{0}^3' -- `; reproduce + deletes/renames with `git rm`. Verify with `git status` that ONLY that lane's files are + present — nothing else. +3. Land them: if every hunk dependency-attributes cleanly to existing commits in that lane + (and the lane below), a blanket `but absorb` (no source — the tree holds only this + lane's files, so there's nothing to mis-route) puts each hunk in the right commit. If + the lane needs **new** files, use `but commit ` instead (the new files have only + this lane to land in because the tree is isolated). +4. **Verify the lane's tip TREE, not the diff** (commit history within a lane doesn't + matter; the resulting tree does): `git show :` for each touched file, plus + `git ls-tree -r ` for moves/deletes. Then check the lanes *above* it for + resurrected deletes / phantom files (the rebase re-materializes deleted dirs as + untracked — `rm -rf` that residue; it's noise, the tip tree is authoritative). +5. Next lane. Push at the very end with `but push -f` and confirm every lane's + `git rev-parse ` == `git ls-remote origin `. + +Unrelated fixes that depend on nothing in the stack (e.g. a stale test for code already on +main) go on their **own parallel lane**: isolate just that file, `but commit -c `. + +### Stacks are linear; a fan-out is expressed through PR bases, not graph shape + +A GitButler **stack** is a linear series. `but branch new --anchor ` does NOT +create a sibling of `` — it **inserts the new branch into the line** on top of it. So +anchoring two branches on the same parent produces `parent → first → second`, not two children +of `parent`. `but branch new ` with **no** anchor makes a separate parallel stack, but a +parallel stack branches off the workspace base (main), so a branch that genuinely depends on an +ancestor's commits can't live there with a clean diff. + +This matters when a design's dependency tree fans out (e.g. a web lane and an SDK lane that both +depend on an API lane but not on each other). You cannot draw that fan-out in the git graph here. +You don't need to. The clean per-PR diff is a **PR-base** property, not a graph-shape property: +a stacked branch contains every commit below it, and GitHub shows only the delta against the base +you set. So put everything in **one linear stack in dependency order** and set each PR's base to +the branch directly below it. Order independent lanes however you like (sort by fewest conflicts); +lanes that touch disjoint files (e.g. `web/**` vs `api/**`) can sit anywhere in the line. + +- Build the line with `but move ` (stacks `` on top of ``) + and `but move zz` (tears `` off into its own parallel stack). Use these to + reorder after the fact; take a `but oplog snapshot` first. +- **Verify the line by diffing, not by eyeballing the tree.** For each branch, run + `git diff --name-only ..` where `` is the branch below it. The file list + must be exactly that lane's files. If a lower lane's files appear, the order is wrong (a lane got + inserted into another's ancestry) — `but move` it out of the way and re-diff. +- A branch torn off to its own parallel stack (base = main) gives a **wrong** diff against an + ancestor branch: `git diff ..` reverses the ancestor's own changes (their + merge base is main). That's the tell that the branch needs to be stacked, not parallel. +- Set PR bases to match: bottom lane `--base main`, every other lane `--base `. ### Hard-won gotchas (don't relearn these) diff --git a/api/ee/src/core/access/permissions/types.py b/api/ee/src/core/access/permissions/types.py index c3ab36b719..6cf7ee1647 100644 --- a/api/ee/src/core/access/permissions/types.py +++ b/api/ee/src/core/access/permissions/types.py @@ -190,6 +190,11 @@ class Permission(str, Enum): EDIT_TOOLS = "edit_tools" RUN_TOOLS = "run_tools" + # Triggers + VIEW_TRIGGERS = "view_triggers" + EDIT_TRIGGERS = "edit_triggers" + RUN_TRIGGERS = "run_triggers" + @classmethod def default_permissions(cls, role): VIEWER_PERMISSIONS = [ @@ -217,6 +222,7 @@ def default_permissions(cls, role): cls.VIEW_EVALUATION_METRICS, cls.VIEW_EVALUATION_QUEUES, cls.VIEW_TOOLS, + cls.VIEW_TRIGGERS, ] ANNOTATOR_PERMISSIONS = VIEWER_PERMISSIONS + [ cls.CREATE_EVALUATION, @@ -230,6 +236,7 @@ def default_permissions(cls, role): cls.EDIT_EVALUATION_QUEUES, cls.EDIT_SPANS, cls.RUN_TOOLS, + cls.RUN_TRIGGERS, ] EDITOR_PERMISSIONS = ANNOTATOR_PERMISSIONS + [ cls.EDIT_APPLICATIONS, @@ -251,6 +258,7 @@ def default_permissions(cls, role): cls.EDIT_TESTSETS, cls.EDIT_INVOCATIONS, cls.EDIT_TOOLS, + cls.EDIT_TRIGGERS, ] DEVELOPER_PERMISSIONS = EDITOR_PERMISSIONS + [ cls.VIEW_API_KEYS, diff --git a/api/oss/src/dbs/postgres/tools/__init__.py b/api/ee/tests/pytest/acceptance/tools/__init__.py similarity index 100% rename from api/oss/src/dbs/postgres/tools/__init__.py rename to api/ee/tests/pytest/acceptance/tools/__init__.py diff --git a/api/ee/tests/pytest/acceptance/tools/test_tools_connections.py b/api/ee/tests/pytest/acceptance/tools/test_tools_connections.py new file mode 100644 index 0000000000..b465c342e9 --- /dev/null +++ b/api/ee/tests/pytest/acceptance/tools/test_tools_connections.py @@ -0,0 +1,155 @@ +"""EE acceptance tests for the /tools/connections contract (WP0). + +Mirrors the OSS suite (oss/tests/pytest/acceptance/tools/test_tools_connections.py) +but exercises /tools/connections as a business-plan, developer-role account. +Under EE the endpoints are gated on the tools permission surface (VIEW_TOOLS for +reads, EDIT_TOOLS for writes); a developer role carries both, so this verifies +the contract behaves once the gate is satisfied. + +The query endpoint is DB-only and needs no Composio credentials — it also proves +the gateway_connections rename landed in EE. Create / revoke make real provider +calls, so those are gated on COMPOSIO_API_KEY. + +Requires a running API. +""" + +import os +from uuid import uuid4 + +import pytest +import requests + +from utils.constants import BASE_TIMEOUT + + +_COMPOSIO_ENABLED = bool(os.getenv("COMPOSIO_API_KEY")) +_requires_composio = pytest.mark.skipif( + not _COMPOSIO_ENABLED, + reason="needs live Composio credentials (COMPOSIO_API_KEY)", +) + + +def _create_developer_business_account(admin_api): + uid = uuid4().hex[:12] + email = f"connections-dev-{uid}@test.agenta.ai" + resp = admin_api( + "POST", + "/admin/simple/accounts/", + json={ + "accounts": { + "u": { + "user": {"email": email}, + "options": { + "create_api_keys": True, + "return_api_keys": True, + "seed_defaults": False, + }, + "subscription": {"plan": "cloud_v0_business"}, + "organization_memberships": [ + { + "organization_ref": {"ref": "org"}, + "user_ref": {"ref": "user"}, + "role": "developer", + } + ], + "workspace_memberships": [ + { + "workspace_ref": {"ref": "wrk"}, + "user_ref": {"ref": "user"}, + "role": "developer", + } + ], + "project_memberships": [ + { + "project_ref": {"ref": "prj"}, + "user_ref": {"ref": "user"}, + "role": "developer", + } + ], + } + } + }, + ) + assert resp.status_code == 200, resp.text + account = resp.json()["accounts"]["u"] + return { + "email": email, + "credentials": f"ApiKey {account['api_keys']['key']}", + } + + +def _delete_account_by_email(admin_api, *, email): + resp = admin_api( + "DELETE", + "/admin/simple/accounts/", + json={"accounts": {"u": {"user": {"email": email}}}, "confirm": "delete"}, + ) + assert resp.status_code == 204, resp.text + + +@pytest.fixture(scope="class") +def connections_api(admin_api, ag_env): + account = _create_developer_business_account(admin_api) + + def _request(method: str, endpoint: str, **kwargs): + headers = kwargs.pop("headers", {}) + headers.setdefault("Authorization", account["credentials"]) + return requests.request( + method=method, + url=f"{ag_env['api_url']}{endpoint}", + headers=headers, + timeout=BASE_TIMEOUT, + **kwargs, + ) + + yield _request + + _delete_account_by_email(admin_api, email=account["email"]) + + +class TestToolsConnectionsQuery: + def test_query_connections_returns_200(self, connections_api): + response = connections_api("POST", "/tools/connections/query") + assert response.status_code == 200 + + def test_query_connections_response_shape(self, connections_api): + body = connections_api("POST", "/tools/connections/query").json() + assert "count" in body + assert "connections" in body + assert isinstance(body["connections"], list) + assert body["count"] == len(body["connections"]) + + +class TestToolsConnectionsGet: + def test_get_unknown_connection_returns_404(self, connections_api): + response = connections_api("GET", f"/tools/connections/{uuid4()}") + assert response.status_code == 404 + + +@_requires_composio +class TestToolsConnectionsLifecycle: + def test_create_revoke_roundtrip(self, connections_api): + slug = f"acc-{uuid4().hex[:8]}" + create = connections_api( + "POST", + "/tools/connections/", + json={ + "connection": { + "slug": slug, + "provider_key": "composio", + "integration_key": "github", + "data": {"auth_scheme": "oauth"}, + } + }, + ) + assert create.status_code == 200, create.text + connection_id = create.json()["connection"]["id"] + + # Local-only revoke (C7/B3): flips is_valid on the shared row, no + # provider call, no cascade. + revoke = connections_api("POST", f"/tools/connections/{connection_id}/revoke") + assert revoke.status_code == 200, revoke.text + assert revoke.json()["connection"]["flags"]["is_valid"] is False + + delete = connections_api("DELETE", f"/tools/connections/{connection_id}") + assert delete.status_code == 204, delete.text diff --git a/api/ee/tests/pytest/acceptance/triggers/__init__.py b/api/ee/tests/pytest/acceptance/triggers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/ee/tests/pytest/acceptance/triggers/test_triggers_catalog.py b/api/ee/tests/pytest/acceptance/triggers/test_triggers_catalog.py new file mode 100644 index 0000000000..7343878c7c --- /dev/null +++ b/api/ee/tests/pytest/acceptance/triggers/test_triggers_catalog.py @@ -0,0 +1,159 @@ +"""EE acceptance tests for the triggers events catalog. + +Mirrors the OSS suite (oss/tests/pytest/acceptance/triggers/test_triggers_catalog.py) +but exercises /triggers/catalog/* as a business-plan, developer-role account. +Under EE the catalog is gated on the VIEW_TRIGGERS permission; a developer role +carries VIEW_TRIGGERS, so this verifies the endpoint behaves once the gate is +satisfied. + +Provider-catalog reads need no Composio credentials (empty catalog is valid). +Event browse / config-schema fetch make real Composio calls and are gated on +COMPOSIO_API_KEY being present in the runner's environment. + +Requires a running API. +""" + +import os +from uuid import uuid4 + +import pytest +import requests + +from utils.constants import BASE_TIMEOUT + + +_COMPOSIO_ENABLED = bool(os.getenv("COMPOSIO_API_KEY")) +_requires_composio = pytest.mark.skipif( + not _COMPOSIO_ENABLED, + reason="needs live Composio credentials (COMPOSIO_API_KEY)", +) + + +def _create_developer_business_account(admin_api): + uid = uuid4().hex[:12] + email = f"triggers-dev-{uid}@test.agenta.ai" + resp = admin_api( + "POST", + "/admin/simple/accounts/", + json={ + "accounts": { + "u": { + "user": {"email": email}, + "options": { + "create_api_keys": True, + "return_api_keys": True, + "seed_defaults": False, + }, + "subscription": {"plan": "cloud_v0_business"}, + "organization_memberships": [ + { + "organization_ref": {"ref": "org"}, + "user_ref": {"ref": "user"}, + "role": "developer", + } + ], + "workspace_memberships": [ + { + "workspace_ref": {"ref": "wrk"}, + "user_ref": {"ref": "user"}, + "role": "developer", + } + ], + "project_memberships": [ + { + "project_ref": {"ref": "prj"}, + "user_ref": {"ref": "user"}, + "role": "developer", + } + ], + } + } + }, + ) + assert resp.status_code == 200, resp.text + account = resp.json()["accounts"]["u"] + return { + "email": email, + "credentials": f"ApiKey {account['api_keys']['key']}", + } + + +def _delete_account_by_email(admin_api, *, email): + resp = admin_api( + "DELETE", + "/admin/simple/accounts/", + json={"accounts": {"u": {"user": {"email": email}}}, "confirm": "delete"}, + ) + assert resp.status_code == 204, resp.text + + +@pytest.fixture(scope="class") +def triggers_api(admin_api, ag_env): + account = _create_developer_business_account(admin_api) + + def _request(method: str, endpoint: str, **kwargs): + headers = kwargs.pop("headers", {}) + headers.setdefault("Authorization", account["credentials"]) + return requests.request( + method=method, + url=f"{ag_env['api_url']}{endpoint}", + headers=headers, + timeout=BASE_TIMEOUT, + **kwargs, + ) + + yield _request + + _delete_account_by_email(admin_api, email=account["email"]) + + +class TestTriggersCatalogProviders: + def test_list_providers_returns_200(self, triggers_api): + response = triggers_api("GET", "/triggers/catalog/providers/") + assert response.status_code == 200 + + def test_list_providers_response_shape(self, triggers_api): + body = triggers_api("GET", "/triggers/catalog/providers/").json() + assert "count" in body + assert "providers" in body + assert isinstance(body["providers"], list) + assert body["count"] == len(body["providers"]) + + def test_list_providers_empty_when_composio_disabled(self, triggers_api): + """Gate on what the server reports, not a local env var — the test + runner's env need not match the API process's.""" + body = triggers_api("GET", "/triggers/catalog/providers/").json() + if body["count"] != 0: + pytest.skip("Composio is enabled on the API — catalog is non-empty") + assert body["providers"] == [] + + +@_requires_composio +class TestTriggersCatalogEvents: + def test_browse_events_returns_200(self, triggers_api): + response = triggers_api( + "GET", + "/triggers/catalog/providers/composio/integrations/github/events/", + ) + assert response.status_code == 200 + body = response.json() + assert "events" in body + assert isinstance(body["events"], list) + + def test_fetch_event_config_schema(self, triggers_api): + listing = triggers_api( + "GET", + "/triggers/catalog/providers/composio/integrations/github/events/", + ).json() + if not listing["events"]: + pytest.skip("no github events available from Composio") + + event_key = listing["events"][0]["key"] + response = triggers_api( + "GET", + f"/triggers/catalog/providers/composio/integrations/github/events/{event_key}", + ) + assert response.status_code == 200 + event = response.json()["event"] + assert event["key"] == event_key + assert "trigger_config" in event diff --git a/api/ee/tests/pytest/acceptance/triggers/test_triggers_subscriptions.py b/api/ee/tests/pytest/acceptance/triggers/test_triggers_subscriptions.py new file mode 100644 index 0000000000..d68b42acaa --- /dev/null +++ b/api/ee/tests/pytest/acceptance/triggers/test_triggers_subscriptions.py @@ -0,0 +1,226 @@ +"""EE acceptance tests for /triggers/subscriptions/* and /triggers/deliveries/*. + +Mirrors the OSS suite but exercises the routes as a business-plan, +developer-role account. Subscription CRUD is gated on EDIT_TRIGGERS and reads on +VIEW_TRIGGERS; a developer role carries both, so this verifies the routes behave +once the gate is satisfied. + +The read/query surfaces are DB-only (no Composio needed). The full create -> +list -> disable -> delete roundtrip, including the C7 invariant (deleting a +subscription leaves the shared connection intact), mints a provider-side trigger +instance and is gated on COMPOSIO_API_KEY. + +Requires a running API. +""" + +import os +from uuid import uuid4 + +import pytest +import requests + +from utils.constants import BASE_TIMEOUT + + +_COMPOSIO_ENABLED = bool(os.getenv("COMPOSIO_API_KEY")) +_requires_composio = pytest.mark.skipif( + not _COMPOSIO_ENABLED, + reason="needs live Composio credentials (COMPOSIO_API_KEY)", +) + + +def _create_developer_business_account(admin_api): + uid = uuid4().hex[:12] + email = f"triggers-sub-dev-{uid}@test.agenta.ai" + resp = admin_api( + "POST", + "/admin/simple/accounts/", + json={ + "accounts": { + "u": { + "user": {"email": email}, + "options": { + "create_api_keys": True, + "return_api_keys": True, + "seed_defaults": False, + }, + "subscription": {"plan": "cloud_v0_business"}, + "organization_memberships": [ + { + "organization_ref": {"ref": "org"}, + "user_ref": {"ref": "user"}, + "role": "developer", + } + ], + "workspace_memberships": [ + { + "workspace_ref": {"ref": "wrk"}, + "user_ref": {"ref": "user"}, + "role": "developer", + } + ], + "project_memberships": [ + { + "project_ref": {"ref": "prj"}, + "user_ref": {"ref": "user"}, + "role": "developer", + } + ], + } + } + }, + ) + assert resp.status_code == 200, resp.text + account = resp.json()["accounts"]["u"] + return { + "email": email, + "credentials": f"ApiKey {account['api_keys']['key']}", + } + + +def _delete_account_by_email(admin_api, *, email): + resp = admin_api( + "DELETE", + "/admin/simple/accounts/", + json={"accounts": {"u": {"user": {"email": email}}}, "confirm": "delete"}, + ) + assert resp.status_code == 204, resp.text + + +@pytest.fixture(scope="class") +def triggers_api(admin_api, ag_env): + account = _create_developer_business_account(admin_api) + + def _request(method: str, endpoint: str, **kwargs): + headers = kwargs.pop("headers", {}) + headers.setdefault("Authorization", account["credentials"]) + return requests.request( + method=method, + url=f"{ag_env['api_url']}{endpoint}", + headers=headers, + timeout=BASE_TIMEOUT, + **kwargs, + ) + + yield _request + + _delete_account_by_email(admin_api, email=account["email"]) + + +# --------------------------------------------------------------------------- +# DB-only: reads, queries, 404s +# --------------------------------------------------------------------------- + + +class TestTriggerSubscriptionsReads: + def test_list_subscriptions_returns_200_empty(self, triggers_api): + response = triggers_api("GET", "/triggers/subscriptions/") + assert response.status_code == 200 + body = response.json() + assert "count" in body + assert isinstance(body["subscriptions"], list) + assert body["count"] == len(body["subscriptions"]) + + def test_query_subscriptions_returns_200(self, triggers_api): + response = triggers_api("POST", "/triggers/subscriptions/query", json={}) + assert response.status_code == 200 + body = response.json() + assert body["count"] == len(body["subscriptions"]) + + def test_fetch_unknown_subscription_returns_404(self, triggers_api): + response = triggers_api("GET", f"/triggers/subscriptions/{uuid4()}") + assert response.status_code == 404 + + def test_delete_unknown_subscription_returns_404(self, triggers_api): + response = triggers_api("DELETE", f"/triggers/subscriptions/{uuid4()}") + assert response.status_code == 404 + + +class TestTriggerDeliveriesReads: + def test_list_deliveries_returns_200_empty(self, triggers_api): + response = triggers_api("GET", "/triggers/deliveries") + assert response.status_code == 200 + body = response.json() + assert isinstance(body["deliveries"], list) + assert body["count"] == len(body["deliveries"]) + + def test_query_deliveries_returns_200(self, triggers_api): + response = triggers_api("POST", "/triggers/deliveries/query", json={}) + assert response.status_code == 200 + body = response.json() + assert body["count"] == len(body["deliveries"]) + + def test_fetch_unknown_delivery_returns_404(self, triggers_api): + response = triggers_api("GET", f"/triggers/deliveries/{uuid4()}") + assert response.status_code == 404 + + +# --------------------------------------------------------------------------- +# Full lifecycle (needs Composio) — C7 invariant included +# --------------------------------------------------------------------------- + + +@_requires_composio +class TestTriggerSubscriptionsLifecycle: + def _create_connection(self, triggers_api): + slug = f"acc-{uuid4().hex[:8]}" + create = triggers_api( + "POST", + "/tools/connections/", + json={ + "connection": { + "slug": slug, + "provider_key": "composio", + "integration_key": "github", + "data": {"auth_scheme": "oauth"}, + } + }, + ) + assert create.status_code == 200, create.text + return create.json()["connection"]["id"] + + def test_create_list_disable_delete_keeps_connection(self, triggers_api): + connection_id = self._create_connection(triggers_api) + + create = triggers_api( + "POST", + "/triggers/subscriptions/", + json={ + "subscription": { + "name": f"sub-{uuid4().hex[:8]}", + "connection_id": connection_id, + "data": { + "event_key": "GITHUB_STAR_ADDED_EVENT", + "trigger_config": {}, + "inputs_fields": {"repo": "$.event.data.repository"}, + "references": {"workflow": {"slug": "triage"}}, + }, + } + }, + ) + assert create.status_code == 200, create.text + sub = create.json()["subscription"] + subscription_id = sub["id"] + assert sub["connection_id"] == connection_id + assert sub["data"]["ti_id"] is not None + + listing = triggers_api("GET", "/triggers/subscriptions/").json() + assert any(s["id"] == subscription_id for s in listing["subscriptions"]) + + revoke = triggers_api( + "POST", f"/triggers/subscriptions/{subscription_id}/revoke" + ) + assert revoke.status_code == 200, revoke.text + assert revoke.json()["subscription"]["enabled"] is False + + delete = triggers_api("DELETE", f"/triggers/subscriptions/{subscription_id}") + assert delete.status_code == 204 + + fetch = triggers_api("GET", f"/triggers/subscriptions/{subscription_id}") + assert fetch.status_code == 404 + + # C7: deleting the subscription must NOT delete/revoke the connection. + conn = triggers_api("GET", f"/tools/connections/{connection_id}") + assert conn.status_code == 200, conn.text + + triggers_api("DELETE", f"/tools/connections/{connection_id}") diff --git a/api/entrypoints/routers.py b/api/entrypoints/routers.py index d90b38c5f1..800abd074d 100644 --- a/api/entrypoints/routers.py +++ b/api/entrypoints/routers.py @@ -134,11 +134,24 @@ from oss.src.core.accounts.service import PlatformAdminAccountsService from oss.src.apis.fastapi.accounts.router import PlatformAdminAccountsRouter -from oss.src.dbs.postgres.tools.dao import ToolsDAO +from oss.src.dbs.postgres.gateway.connections.dao import ConnectionsDAO +from oss.src.core.gateway.connections.providers.composio import ( + ComposioConnectionsAdapter, +) +from oss.src.core.gateway.connections.registry import ConnectionsGatewayRegistry +from oss.src.core.gateway.connections.service import ConnectionsService from oss.src.core.tools.providers.composio import ComposioToolsAdapter from oss.src.core.tools.registry import ToolsGatewayRegistry from oss.src.core.tools.service import ToolsService from oss.src.apis.fastapi.tools.router import ToolsRouter +from oss.src.dbs.postgres.triggers.dao import TriggersDAO +from oss.src.core.triggers.providers.composio import ComposioTriggersAdapter +from oss.src.core.triggers.registry import TriggersGatewayRegistry +from oss.src.core.triggers.service import TriggersService +from oss.src.apis.fastapi.triggers.router import TriggersRouter +from oss.src.tasks.asyncio.triggers.dispatcher import TriggersDispatcher +from oss.src.tasks.taskiq.triggers.worker import TriggersWorker +from taskiq_redis import RedisStreamBroker from oss.src.apis.fastapi.shared.utils import SupportHeadersMiddleware @@ -204,11 +217,21 @@ async def lifespan(*args, **kwargs): warn_deprecated_env_vars() validate_required_env_vars() + await _triggers_broker.startup() + yield + await _triggers_broker.shutdown() + for adapter in _composio_adapters.values(): await adapter.close() + for adapter in _composio_connections_adapters.values(): + await adapter.close() + + for adapter in _composio_triggers_adapters.values(): + await adapter.close() + await _transactions_engine.close() await _analytics_engine.close() await _streams_engine.close() @@ -302,6 +325,11 @@ async def lifespan(*args, **kwargs): "description": "External tool connections and OAuth integrations available to applications.", }, # -- + { + "name": "Triggers", + "description": "Inbound provider event triggers and their watchable event catalog.", + }, + # -- { "name": "Folders", "description": "Organize applications and other resources into folder hierarchies.", @@ -439,7 +467,7 @@ async def lifespan(*args, **kwargs): evaluations_dao = EvaluationsDAO(engine=_transactions_engine) folders_dao = FoldersDAO(engine=_transactions_engine) -tools_dao = ToolsDAO(engine=_transactions_engine) +connections_dao = ConnectionsDAO(engine=_transactions_engine) # SERVICES --------------------------------------------------------------------- @@ -574,6 +602,23 @@ async def lifespan(*args, **kwargs): simple_evaluations_service=simple_evaluations_service, ) +# Connections adapter + service (owns gateway_connections; consumed by tools) +_composio_connections_adapters = {} +if env.composio.enabled: + _composio_connections_adapters["composio"] = ComposioConnectionsAdapter( + api_key=env.composio.api_key, # type: ignore[arg-type] # guarded by .enabled + api_url=env.composio.api_url, + ) + +connections_adapter_registry = ConnectionsGatewayRegistry( + adapters=_composio_connections_adapters, +) + +connections_service = ConnectionsService( + connections_dao=connections_dao, + adapter_registry=connections_adapter_registry, +) + # Tools adapter + service _composio_adapters = {} if env.composio.enabled: @@ -589,10 +634,50 @@ async def lifespan(*args, **kwargs): ) tools_service = ToolsService( - tools_dao=tools_dao, + connections_service=connections_service, adapter_registry=tools_adapter_registry, ) +# Triggers adapter + service +_composio_triggers_adapters = {} +if env.composio.enabled: + _composio_triggers_adapters["composio"] = ComposioTriggersAdapter( + api_key=env.composio.api_key, # type: ignore[arg-type] # guarded by .enabled + api_url=env.composio.api_url, + ) + +triggers_adapter_registry = TriggersGatewayRegistry( + adapters=_composio_triggers_adapters, +) + +triggers_dao = TriggersDAO(engine=_transactions_engine) + +triggers_service = TriggersService( + adapter_registry=triggers_adapter_registry, + triggers_dao=triggers_dao, + connections_service=connections_service, +) + +# Producer side of the inbound dispatch pipeline: the ingress route enqueues +# `triggers.dispatch` tasks here; entrypoints/worker_triggers.py consumes them. +_triggers_broker = RedisStreamBroker( + url=env.redis.uri_durable, + queue_name="queues:triggers", + consumer_group_name="api-triggers-producer", + maxlen=100_000, + approximate=True, +) + +_triggers_dispatcher = TriggersDispatcher( + triggers_dao=triggers_dao, + workflows_service=workflows_service, +) + +_triggers_worker = TriggersWorker( + broker=_triggers_broker, + dispatcher=_triggers_dispatcher, +) + _t_services_done = time.perf_counter() - _t_services print(f"[STARTUP] Service initialization completed (+{_t_services_done:.3f}s)") _t_routers = time.perf_counter() @@ -707,6 +792,11 @@ async def lifespan(*args, **kwargs): tools_service=tools_service, ) +triggers = TriggersRouter( + triggers_service=triggers_service, + dispatch_task=_triggers_worker.dispatch_trigger, +) + simple_traces = SimpleTracesRouter( simple_traces_service=simple_traces_service, ) @@ -1074,6 +1164,19 @@ async def lifespan(*args, **kwargs): include_in_schema=False, ) +app.include_router( + router=triggers.router, + prefix="/triggers", + tags=["Triggers"], +) + +app.include_router( + router=triggers.router, + prefix="/preview/triggers", + tags=["Triggers"], + include_in_schema=False, +) + app.include_router( router=evaluations.admin_router, prefix="/admin/evaluations", diff --git a/api/entrypoints/worker_triggers.py b/api/entrypoints/worker_triggers.py new file mode 100644 index 0000000000..1b25bef5a7 --- /dev/null +++ b/api/entrypoints/worker_triggers.py @@ -0,0 +1,142 @@ +import sys + +from taskiq.cli.worker.run import run_worker +from taskiq.cli.worker.args import WorkerArgs +from taskiq_redis import RedisStreamBroker + +from oss.src.utils.logging import get_module_logger +from oss.src.utils.helpers import warn_deprecated_env_vars, validate_required_env_vars +from oss.src.utils.env import env + +from oss.src.utils.common import is_ee +from oss.src.dbs.postgres.git.dao import GitDAO +from oss.src.dbs.postgres.triggers.dao import TriggersDAO +from oss.src.dbs.postgres.workflows.dbes import ( + WorkflowArtifactDBE, + WorkflowVariantDBE, + WorkflowRevisionDBE, +) +from oss.src.dbs.postgres.environments.dbes import ( + EnvironmentArtifactDBE, + EnvironmentVariantDBE, + EnvironmentRevisionDBE, +) +from oss.src.core.workflows.service import WorkflowsService +from oss.src.core.environments.service import EnvironmentsService +from oss.src.core.embeds.service import EmbedsService +from oss.src.tasks.asyncio.triggers.dispatcher import TriggersDispatcher +from oss.src.tasks.taskiq.triggers.worker import TriggersWorker + +# Guard EE imports — see worker_tracing.py for the rationale. +if is_ee(): + from ee.src.core.access.entitlements.service import bootstrap_entitlements_services + + +import agenta as ag + +log = get_module_logger(__name__) + +# Initialize Agenta SDK +ag.init( + api_url=env.agenta.api_url, +) + +# Bound the stream so acked entries are trimmed; without this it grows unbounded. +MAXLEN_QUEUES_TRIGGERS = 100_000 + +# BROKER ------------------------------------------------------------------- +broker = RedisStreamBroker( + url=env.redis.uri_durable, + queue_name="queues:triggers", + consumer_group_name="worker-triggers", + maxlen=MAXLEN_QUEUES_TRIGGERS, + approximate=True, +) + + +# WORKERS ------------------------------------------------------------------ +triggers_dao = TriggersDAO() + +workflows_dao = GitDAO( + ArtifactDBE=WorkflowArtifactDBE, + VariantDBE=WorkflowVariantDBE, + RevisionDBE=WorkflowRevisionDBE, +) + +environments_dao = GitDAO( + ArtifactDBE=EnvironmentArtifactDBE, + VariantDBE=EnvironmentVariantDBE, + RevisionDBE=EnvironmentRevisionDBE, +) + +workflows_service = WorkflowsService( + workflows_dao=workflows_dao, +) + +environments_service = EnvironmentsService( + environments_dao=environments_dao, +) + +embeds_service = EmbedsService( + workflows_service=workflows_service, + environments_service=environments_service, +) + +workflows_service.environments_service = environments_service +workflows_service.embeds_service = embeds_service +environments_service.embeds_service = embeds_service + +triggers_dispatcher = TriggersDispatcher( + triggers_dao=triggers_dao, + workflows_service=workflows_service, +) + +triggers_worker = TriggersWorker( + broker=broker, + dispatcher=triggers_dispatcher, +) + + +def main() -> int: + """ + Main entry point for the worker. + + Returns: + Exit code (0 for success, non-zero for failure) + """ + try: + log.info("[TRIGGERS] Initializing Taskiq worker") + + # Validate environment + warn_deprecated_env_vars() + validate_required_env_vars() + + # Wire EE entitlement services so `check_entitlements` works in + # this worker process. Gated on `is_ee()` to match the import above. + if is_ee(): + bootstrap_entitlements_services() + + log.info("[TRIGGERS] Starting Taskiq worker with Redis Streams") + + # Run Taskiq worker + args = WorkerArgs( + broker="entrypoints.worker_triggers:broker", # Reference broker from this module + modules=[], + fs_discover=False, + workers=1, + max_async_tasks=50, + ) + + result = run_worker(args) + return result if result is not None else 0 + + except KeyboardInterrupt: + log.info("[TRIGGERS] Shutdown requested") + return 0 + except Exception as e: + log.error("[TRIGGERS] Fatal error", error=str(e)) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/api/oss/databases/postgres/migrations/core_oss/versions/oss000000002_rename_tool_connections_to_gateway_connections.py b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000002_rename_tool_connections_to_gateway_connections.py new file mode 100644 index 0000000000..0eca1077c6 --- /dev/null +++ b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000002_rename_tool_connections_to_gateway_connections.py @@ -0,0 +1,49 @@ +"""rename tool_connections to gateway_connections + +Connection ownership moves out of /tools into the shared, routerless +connections domain (gateway-triggers WP0). Rename-only — no data transform. +Authored once in the shared core_oss chain so it runs in BOTH editions; the +legacy chain that created tool_connections is parked. + +Revision ID: oss000000002 +Revises: oss000000001 +Create Date: 2026-06-18 00:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "oss000000002" +down_revision: Union[str, None] = "oss000000001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.rename_table("tool_connections", "gateway_connections") + op.execute( + "ALTER TABLE gateway_connections " + "RENAME CONSTRAINT uq_tool_connections_project_provider_integration_slug " + "TO uq_gateway_connections_project_provider_integration_slug" + ) + op.execute( + "ALTER INDEX ix_tool_connections_project_provider_integration " + "RENAME TO ix_gateway_connections_project_provider_integration" + ) + + +def downgrade() -> None: + op.execute( + "ALTER INDEX ix_gateway_connections_project_provider_integration " + "RENAME TO ix_tool_connections_project_provider_integration" + ) + op.execute( + "ALTER TABLE gateway_connections " + "RENAME CONSTRAINT uq_gateway_connections_project_provider_integration_slug " + "TO uq_tool_connections_project_provider_integration_slug" + ) + op.rename_table("gateway_connections", "tool_connections") diff --git a/api/oss/databases/postgres/migrations/core_oss/versions/oss000000003_add_trigger_subscriptions_and_deliveries.py b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000003_add_trigger_subscriptions_and_deliveries.py new file mode 100644 index 0000000000..c755fbebcc --- /dev/null +++ b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000003_add_trigger_subscriptions_and_deliveries.py @@ -0,0 +1,179 @@ +"""add trigger_subscriptions and trigger_deliveries tables + +The two-table heart of the gateway-triggers domain (WP3), modeled on +webhook_subscriptions + webhook_deliveries. A subscription FKs the shared +gateway_connections row (many subscriptions per connection); a delivery dedups +on the provider event id (metadata.id) per subscription (I4). Authored once in +the shared core_oss chain so it runs in BOTH editions. + +Revision ID: oss000000003 +Revises: oss000000002 +Create Date: 2026-06-18 00:00:01.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = "oss000000003" +down_revision: Union[str, None] = "oss000000002" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # -- TRIGGER SUBSCRIPTIONS -------------------------------------------------- + op.create_table( + "trigger_subscriptions", + sa.Column("project_id", sa.UUID(), nullable=False), + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("connection_id", sa.UUID(), nullable=False), + sa.Column("name", sa.String(), nullable=True), + sa.Column("description", sa.String(), nullable=True), + sa.Column("data", postgresql.JSON(astext_type=sa.Text()), nullable=True), + sa.Column( + "flags", + postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), + nullable=True, + ), + sa.Column("meta", postgresql.JSON(astext_type=sa.Text()), nullable=True), + sa.Column( + "tags", + postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), + nullable=True, + ), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.TIMESTAMP(timezone=True), + server_onupdate=sa.text("CURRENT_TIMESTAMP"), + nullable=True, + ), + sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column("created_by_id", sa.UUID(), nullable=True), + sa.Column("updated_by_id", sa.UUID(), nullable=True), + sa.Column("deleted_by_id", sa.UUID(), nullable=True), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["project_id", "connection_id"], + ["gateway_connections.project_id", "gateway_connections.id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("project_id", "id"), + ) + + op.create_index( + "ix_trigger_subscriptions_project_id_created_at", + "trigger_subscriptions", + ["project_id", "created_at"], + unique=False, + ) + op.create_index( + "ix_trigger_subscriptions_project_id_deleted_at", + "trigger_subscriptions", + ["project_id", "deleted_at"], + unique=False, + ) + op.create_index( + "ix_trigger_subscriptions_connection_id", + "trigger_subscriptions", + ["project_id", "connection_id"], + unique=False, + ) + + # -- TRIGGER DELIVERIES ----------------------------------------------------- + op.create_table( + "trigger_deliveries", + sa.Column("project_id", sa.UUID(), nullable=False), + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("subscription_id", sa.UUID(), nullable=False), + sa.Column("event_id", sa.String(), nullable=False), + sa.Column( + "status", + postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), + nullable=True, + ), + sa.Column("data", postgresql.JSON(astext_type=sa.Text()), nullable=True), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.TIMESTAMP(timezone=True), + server_onupdate=sa.text("CURRENT_TIMESTAMP"), + nullable=True, + ), + sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column("created_by_id", sa.UUID(), nullable=True), + sa.Column("updated_by_id", sa.UUID(), nullable=True), + sa.Column("deleted_by_id", sa.UUID(), nullable=True), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["project_id", "subscription_id"], + ["trigger_subscriptions.project_id", "trigger_subscriptions.id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("project_id", "id"), + ) + + op.create_index( + "ix_trigger_deliveries_project_id_created_at", + "trigger_deliveries", + ["project_id", "created_at"], + unique=False, + ) + op.create_index( + "ix_trigger_deliveries_subscription_id_created_at", + "trigger_deliveries", + ["subscription_id", "created_at"], + unique=False, + ) + op.create_index( + "ix_trigger_deliveries_subscription_id_event_id", + "trigger_deliveries", + ["project_id", "subscription_id", "event_id"], + unique=True, + ) + + +def downgrade() -> None: + op.drop_index( + "ix_trigger_deliveries_subscription_id_event_id", + table_name="trigger_deliveries", + ) + op.drop_index( + "ix_trigger_deliveries_subscription_id_created_at", + table_name="trigger_deliveries", + ) + op.drop_index( + "ix_trigger_deliveries_project_id_created_at", + table_name="trigger_deliveries", + ) + op.drop_table("trigger_deliveries") + + op.drop_index( + "ix_trigger_subscriptions_connection_id", + table_name="trigger_subscriptions", + ) + op.drop_index( + "ix_trigger_subscriptions_project_id_deleted_at", + table_name="trigger_subscriptions", + ) + op.drop_index( + "ix_trigger_subscriptions_project_id_created_at", + table_name="trigger_subscriptions", + ) + op.drop_table("trigger_subscriptions") diff --git a/api/oss/src/apis/fastapi/tools/models.py b/api/oss/src/apis/fastapi/tools/models.py index 891b276c22..3dab664ab2 100644 --- a/api/oss/src/apis/fastapi/tools/models.py +++ b/api/oss/src/apis/fastapi/tools/models.py @@ -2,6 +2,10 @@ from pydantic import BaseModel +from oss.src.core.gateway.connections.dtos import ( + Connection, + ConnectionCreate, +) from oss.src.core.tools.dtos import ( # Tool Catalog ToolCatalogAction, @@ -10,9 +14,6 @@ ToolCatalogIntegrationDetails, ToolCatalogProvider, ToolCatalogProviderDetails, - # Tool Connections - ToolConnection, - ToolConnectionCreate, # Tool Calls ToolResult, ) @@ -67,17 +68,17 @@ class ToolCatalogActionsResponse(BaseModel): class ToolConnectionCreateRequest(BaseModel): - connection: ToolConnectionCreate + connection: ConnectionCreate class ToolConnectionResponse(BaseModel): count: int = 0 - connection: Optional[ToolConnection] = None + connection: Optional[Connection] = None class ToolConnectionsResponse(BaseModel): count: int = 0 - connections: List[ToolConnection] = [] + connections: List[Connection] = [] # --------------------------------------------------------------------------- diff --git a/api/oss/src/apis/fastapi/tools/router.py b/api/oss/src/apis/fastapi/tools/router.py index 043d114fa7..32b68d49eb 100644 --- a/api/oss/src/apis/fastapi/tools/router.py +++ b/api/oss/src/apis/fastapi/tools/router.py @@ -46,11 +46,12 @@ ConnectionInactiveError, ConnectionInvalidError, ConnectionNotFoundError, + ProviderNotFoundError, ) from oss.src.core.tools.service import ( ToolsService, ) -from oss.src.core.tools.utils import decode_oauth_state +from oss.src.core.gateway.connections.utils import decode_oauth_state from oss.src.utils.env import env _SLUG_SEGMENT_RE = re.compile(r"^[a-zA-Z0-9_-]+$") @@ -66,13 +67,18 @@ def handle_adapter_exceptions(): - """Convert only upstream 401 AdapterError failures to 424 Failed Dependency.""" + """Map unknown providers to 404 and upstream 401 failures to 424.""" def decorator(func): @wraps(func) async def wrapper(*args, **kwargs): try: return await func(*args, **kwargs) + except ProviderNotFoundError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) from e except AdapterError as e: cause = e.__cause__ if not ( diff --git a/api/oss/src/apis/fastapi/triggers/__init__.py b/api/oss/src/apis/fastapi/triggers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/src/apis/fastapi/triggers/models.py b/api/oss/src/apis/fastapi/triggers/models.py new file mode 100644 index 0000000000..9e13dd38f4 --- /dev/null +++ b/api/oss/src/apis/fastapi/triggers/models.py @@ -0,0 +1,116 @@ +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + +from oss.src.core.shared.dtos import Windowing +from oss.src.core.triggers.dtos import ( + TriggerCatalogEvent, + TriggerCatalogEventDetails, + TriggerCatalogProvider, + TriggerDelivery, + TriggerDeliveryQuery, + TriggerSubscription, + TriggerSubscriptionCreate, + TriggerSubscriptionEdit, + TriggerSubscriptionQuery, +) + + +# --------------------------------------------------------------------------- +# Trigger Catalog +# --------------------------------------------------------------------------- + + +class TriggerCatalogProviderResponse(BaseModel): + count: int = 0 + provider: Optional[TriggerCatalogProvider] = None + + +class TriggerCatalogProvidersResponse(BaseModel): + count: int = 0 + providers: List[TriggerCatalogProvider] = Field(default_factory=list) + + +class TriggerCatalogEventResponse(BaseModel): + count: int = 0 + event: Optional[TriggerCatalogEventDetails] = None + + +class TriggerCatalogEventsResponse(BaseModel): + count: int = 0 + total: int = 0 + cursor: Optional[str] = None + events: List[TriggerCatalogEvent] = Field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Trigger Subscriptions +# --------------------------------------------------------------------------- + + +class TriggerSubscriptionCreateRequest(BaseModel): + subscription: TriggerSubscriptionCreate + + +class TriggerSubscriptionEditRequest(BaseModel): + subscription: TriggerSubscriptionEdit + + +class TriggerSubscriptionQueryRequest(BaseModel): + subscription: Optional[TriggerSubscriptionQuery] = None + + windowing: Optional[Windowing] = None + + +class TriggerSubscriptionResponse(BaseModel): + count: int = 0 + subscription: Optional[TriggerSubscription] = None + + +class TriggerSubscriptionsResponse(BaseModel): + count: int = 0 + subscriptions: List[TriggerSubscription] = Field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Trigger Deliveries +# --------------------------------------------------------------------------- + + +class TriggerDeliveryQueryRequest(BaseModel): + delivery: Optional[TriggerDeliveryQuery] = None + + windowing: Optional[Windowing] = None + + +class TriggerDeliveryResponse(BaseModel): + count: int = 0 + delivery: Optional[TriggerDelivery] = None + + +class TriggerDeliveriesResponse(BaseModel): + count: int = 0 + deliveries: List[TriggerDelivery] = Field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Trigger Ingress (inbound provider events) +# --------------------------------------------------------------------------- + + +class TriggerEventAck(BaseModel): + status: str = "accepted" + detail: Optional[str] = None + + +class ComposioEventEnvelope(BaseModel): + """Loose view of a Composio trigger webhook envelope (`{data, type, ...}`). + + Demultiplexing keys live under ``metadata`` (``trigger_id``, ``id``); the rest + is passed through to the resolver as the inbound event. + """ + + type: Optional[str] = None + timestamp: Optional[str] = None + data: Optional[Dict[str, Any]] = None + metadata: Optional[Dict[str, Any]] = None diff --git a/api/oss/src/apis/fastapi/triggers/router.py b/api/oss/src/apis/fastapi/triggers/router.py new file mode 100644 index 0000000000..1bb1d66f80 --- /dev/null +++ b/api/oss/src/apis/fastapi/triggers/router.py @@ -0,0 +1,809 @@ +import hashlib +import hmac +from functools import wraps +from json import JSONDecodeError, loads +from typing import Any, Optional +from uuid import UUID + +import httpx +from fastapi import APIRouter, HTTPException, Query, Request, status +from fastapi.responses import JSONResponse + +from oss.src.utils.exceptions import intercept_exceptions +from oss.src.utils.logging import get_module_logger +from oss.src.utils.caching import get_cache, set_cache +from oss.src.utils.common import is_ee +from oss.src.utils.env import env + +from oss.src.apis.fastapi.triggers.models import ( + TriggerCatalogEventResponse, + TriggerCatalogEventsResponse, + TriggerCatalogProviderResponse, + TriggerCatalogProvidersResponse, + TriggerDeliveriesResponse, + TriggerDeliveryQueryRequest, + TriggerDeliveryResponse, + TriggerEventAck, + TriggerSubscriptionCreateRequest, + TriggerSubscriptionEditRequest, + TriggerSubscriptionQueryRequest, + TriggerSubscriptionResponse, + TriggerSubscriptionsResponse, +) +from oss.src.core.triggers.exceptions import ( + AdapterError, + ConnectionNotFoundError, + ProviderNotFoundError, + SubscriptionNotFoundError, +) +from oss.src.core.triggers.service import TriggersService + + +if is_ee(): + from ee.src.core.access.permissions.types import Permission + from ee.src.core.access.permissions.service import ( + check_action_access, + FORBIDDEN_EXCEPTION, + ) + +log = get_module_logger(__name__) + + +def handle_adapter_exceptions(): + """Map unknown providers to 404 and upstream 401 failures to 424.""" + + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + try: + return await func(*args, **kwargs) + except ProviderNotFoundError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) from e + except AdapterError as e: + cause = e.__cause__ + if not ( + isinstance(cause, httpx.HTTPStatusError) + and cause.response is not None + and cause.response.status_code == status.HTTP_401_UNAUTHORIZED + ): + raise + + raise HTTPException( + status_code=status.HTTP_424_FAILED_DEPENDENCY, + detail=e.message, + ) from e + + return wrapper + + return decorator + + +def _verify_composio_signature( + *, + body: bytes, + headers: Any, +) -> bool: + """HMAC-SHA256 verify over ``{id}.{ts}.{body}`` with ``COMPOSIO_WEBHOOK_SECRET``. + + Returns True when the secret is unset (no-op) or the signature matches. + """ + secret = env.composio.webhook_secret + if not secret: + return True + + signature = headers.get("webhook-signature") or headers.get("x-composio-signature") + webhook_id = headers.get("webhook-id") or "" + timestamp = headers.get("webhook-timestamp") or "" + if not signature: + return False + + signed = f"{webhook_id}.{timestamp}.{body.decode('utf-8', errors='replace')}" + expected = hmac.new( + secret.encode("utf-8"), + signed.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + provided = signature.split(",")[-1].strip() + return hmac.compare_digest(expected, provided) + + +class TriggersRouter: + def __init__( + self, + *, + triggers_service: TriggersService, + dispatch_task: Optional[Any] = None, + ): + self.triggers_service = triggers_service + self.dispatch_task = dispatch_task + + self.router = APIRouter() + + # --- Trigger Ingress (inbound provider events) --- + self.router.add_api_route( + "/composio/events", + self.ingest_composio_event, + methods=["POST"], + operation_id="ingest_composio_event", + response_model=TriggerEventAck, + status_code=status.HTTP_202_ACCEPTED, + ) + + # --- Trigger Catalog --- + self.router.add_api_route( + "/catalog/providers/", + self.list_providers, + methods=["GET"], + operation_id="list_trigger_providers", + response_model=TriggerCatalogProvidersResponse, + response_model_exclude_none=True, + ) + self.router.add_api_route( + "/catalog/providers/{provider_key}", + self.get_provider, + methods=["GET"], + operation_id="fetch_trigger_provider", + response_model=TriggerCatalogProviderResponse, + response_model_exclude_none=True, + ) + self.router.add_api_route( + "/catalog/providers/{provider_key}/integrations/{integration_key}/events/", + self.list_events, + methods=["GET"], + operation_id="list_trigger_events", + response_model=TriggerCatalogEventsResponse, + response_model_exclude_none=True, + ) + self.router.add_api_route( + "/catalog/providers/{provider_key}/integrations/{integration_key}/events/{event_key}", + self.get_event, + methods=["GET"], + operation_id="fetch_trigger_event", + response_model=TriggerCatalogEventResponse, + response_model_exclude_none=True, + ) + + # --- Trigger Subscriptions --- + self.router.add_api_route( + "/subscriptions/", + self.create_subscription, + methods=["POST"], + operation_id="create_trigger_subscription", + response_model=TriggerSubscriptionResponse, + response_model_exclude_none=True, + status_code=status.HTTP_200_OK, + ) + self.router.add_api_route( + "/subscriptions/", + self.list_subscriptions, + methods=["GET"], + operation_id="list_trigger_subscriptions", + response_model=TriggerSubscriptionsResponse, + response_model_exclude_none=True, + status_code=status.HTTP_200_OK, + ) + self.router.add_api_route( + "/subscriptions/query", + self.query_subscriptions, + methods=["POST"], + operation_id="query_trigger_subscriptions", + response_model=TriggerSubscriptionsResponse, + response_model_exclude_none=True, + status_code=status.HTTP_200_OK, + ) + self.router.add_api_route( + "/subscriptions/{subscription_id}/refresh", + self.refresh_subscription, + methods=["POST"], + operation_id="refresh_trigger_subscription", + response_model=TriggerSubscriptionResponse, + response_model_exclude_none=True, + status_code=status.HTTP_200_OK, + ) + self.router.add_api_route( + "/subscriptions/{subscription_id}/revoke", + self.revoke_subscription, + methods=["POST"], + operation_id="revoke_trigger_subscription", + response_model=TriggerSubscriptionResponse, + response_model_exclude_none=True, + status_code=status.HTTP_200_OK, + ) + self.router.add_api_route( + "/subscriptions/{subscription_id}", + self.fetch_subscription, + methods=["GET"], + operation_id="fetch_trigger_subscription", + response_model=TriggerSubscriptionResponse, + response_model_exclude_none=True, + status_code=status.HTTP_200_OK, + ) + self.router.add_api_route( + "/subscriptions/{subscription_id}", + self.edit_subscription, + methods=["PUT"], + operation_id="edit_trigger_subscription", + response_model=TriggerSubscriptionResponse, + response_model_exclude_none=True, + status_code=status.HTTP_200_OK, + ) + self.router.add_api_route( + "/subscriptions/{subscription_id}", + self.delete_subscription, + methods=["DELETE"], + operation_id="delete_trigger_subscription", + status_code=status.HTTP_204_NO_CONTENT, + ) + + # --- Trigger Deliveries --- + self.router.add_api_route( + "/deliveries", + self.list_deliveries, + methods=["GET"], + operation_id="list_trigger_deliveries", + response_model=TriggerDeliveriesResponse, + response_model_exclude_none=True, + status_code=status.HTTP_200_OK, + ) + self.router.add_api_route( + "/deliveries/query", + self.query_deliveries, + methods=["POST"], + operation_id="query_trigger_deliveries", + response_model=TriggerDeliveriesResponse, + response_model_exclude_none=True, + status_code=status.HTTP_200_OK, + ) + self.router.add_api_route( + "/deliveries/{delivery_id}", + self.fetch_delivery, + methods=["GET"], + operation_id="fetch_trigger_delivery", + response_model=TriggerDeliveryResponse, + response_model_exclude_none=True, + status_code=status.HTTP_200_OK, + ) + + # ----------------------------------------------------------------------- + # Trigger Catalog + # ----------------------------------------------------------------------- + + @intercept_exceptions() + @handle_adapter_exceptions() + async def list_providers( + self, + request: Request, + ) -> TriggerCatalogProvidersResponse: + if is_ee(): + has_permission = await check_action_access( + project_id=request.state.project_id, + user_uid=request.state.user_id, + permission=Permission.VIEW_TRIGGERS, + ) + if not has_permission: + raise FORBIDDEN_EXCEPTION + + cached = await get_cache( + project_id=None, # catalog is global; not per-project + namespace="triggers:catalog:providers", + key={}, + model=TriggerCatalogProvidersResponse, + ) + if cached: + return cached + + providers = await self.triggers_service.list_providers() + items = list(providers) + + response = TriggerCatalogProvidersResponse( + count=len(items), + providers=items, + ) + + await set_cache( + project_id=None, + namespace="triggers:catalog:providers", + key={}, + value=response, + ttl=5 * 60, + ) + + return response + + @intercept_exceptions() + @handle_adapter_exceptions() + async def get_provider( + self, + request: Request, + provider_key: str, + ) -> TriggerCatalogProviderResponse: + if is_ee(): + has_permission = await check_action_access( + user_uid=request.state.user_id, + project_id=request.state.project_id, + permission=Permission.VIEW_TRIGGERS, + ) + if not has_permission: + raise FORBIDDEN_EXCEPTION + + cache_key = {"provider_key": provider_key} + cached = await get_cache( + project_id=None, + namespace="triggers:catalog:provider", + key=cache_key, + model=TriggerCatalogProviderResponse, + ) + if cached: + return cached + + provider = await self.triggers_service.get_provider( + provider_key=provider_key, + ) + if not provider: + return JSONResponse( + status_code=404, + content={"detail": "Provider not found"}, + ) + + response = TriggerCatalogProviderResponse( + count=1, + provider=provider, + ) + + await set_cache( + project_id=None, + namespace="triggers:catalog:provider", + key=cache_key, + value=response, + ttl=5 * 60, + ) + + return response + + @intercept_exceptions() + @handle_adapter_exceptions() + async def list_events( + self, + request: Request, + provider_key: str, + integration_key: str, + *, + query: Optional[str] = Query(default=None), + limit: Optional[int] = Query(default=None), + cursor: Optional[str] = Query(default=None), + ) -> TriggerCatalogEventsResponse: + if is_ee(): + has_permission = await check_action_access( + user_uid=request.state.user_id, + project_id=request.state.project_id, + permission=Permission.VIEW_TRIGGERS, + ) + if not has_permission: + raise FORBIDDEN_EXCEPTION + + cache_key = { + "provider_key": provider_key, + "integration_key": integration_key, + "query": query, + "limit": limit, + "cursor": cursor, + } + cached = await get_cache( + project_id=None, + namespace="triggers:catalog:events", + key=cache_key, + model=TriggerCatalogEventsResponse, + ) + if cached: + return cached + + events, next_cursor, total = await self.triggers_service.list_events( + provider_key=provider_key, + integration_key=integration_key, + query=query, + limit=limit, + cursor=cursor, + ) + items = list(events) + + response = TriggerCatalogEventsResponse( + count=len(items), + total=total, + cursor=next_cursor, + events=items, + ) + + await set_cache( + project_id=None, + namespace="triggers:catalog:events", + key=cache_key, + value=response, + ttl=5 * 60, + ) + + return response + + @intercept_exceptions() + @handle_adapter_exceptions() + async def get_event( + self, + request: Request, + provider_key: str, + integration_key: str, + event_key: str, + ) -> TriggerCatalogEventResponse: + if is_ee(): + has_permission = await check_action_access( + user_uid=request.state.user_id, + project_id=request.state.project_id, + permission=Permission.VIEW_TRIGGERS, + ) + if not has_permission: + raise FORBIDDEN_EXCEPTION + + cache_key = { + "provider_key": provider_key, + "integration_key": integration_key, + "event_key": event_key, + } + cached = await get_cache( + project_id=None, + namespace="triggers:catalog:event", + key=cache_key, + model=TriggerCatalogEventResponse, + ) + if cached: + return cached + + event = await self.triggers_service.get_event( + provider_key=provider_key, + integration_key=integration_key, + event_key=event_key, + ) + if not event: + return JSONResponse( + status_code=404, + content={"detail": "Event not found"}, + ) + + response = TriggerCatalogEventResponse( + count=1, + event=event, + ) + + await set_cache( + project_id=None, + namespace="triggers:catalog:event", + key=cache_key, + value=response, + ttl=5 * 60, + ) + + return response + + # ----------------------------------------------------------------------- + # Trigger Subscriptions + # ----------------------------------------------------------------------- + + async def _check(self, request: Request, permission) -> None: + if is_ee(): + has_permission = await check_action_access( + user_uid=str(request.state.user_id), + project_id=str(request.state.project_id), + permission=permission, + ) + if not has_permission: + raise FORBIDDEN_EXCEPTION + + @intercept_exceptions() + @handle_adapter_exceptions() + async def create_subscription( + self, + request: Request, + *, + body: TriggerSubscriptionCreateRequest, + ) -> TriggerSubscriptionResponse: + await self._check(request, Permission.EDIT_TRIGGERS if is_ee() else None) + + try: + subscription = await self.triggers_service.create_subscription( + project_id=UUID(request.state.project_id), + user_id=UUID(str(request.state.user_id)), + # + subscription=body.subscription, + ) + except ConnectionNotFoundError as e: + raise HTTPException(status_code=404, detail=e.message) from e + + return TriggerSubscriptionResponse( + count=1 if subscription else 0, + subscription=subscription, + ) + + @intercept_exceptions() + async def list_subscriptions( + self, + request: Request, + ) -> TriggerSubscriptionsResponse: + await self._check(request, Permission.VIEW_TRIGGERS if is_ee() else None) + + subscriptions = await self.triggers_service.query_subscriptions( + project_id=UUID(request.state.project_id), + ) + + return TriggerSubscriptionsResponse( + count=len(subscriptions), + subscriptions=subscriptions, + ) + + @intercept_exceptions() + async def query_subscriptions( + self, + request: Request, + *, + body: TriggerSubscriptionQueryRequest, + ) -> TriggerSubscriptionsResponse: + await self._check(request, Permission.VIEW_TRIGGERS if is_ee() else None) + + subscriptions = await self.triggers_service.query_subscriptions( + project_id=UUID(request.state.project_id), + # + subscription=body.subscription, + # + windowing=body.windowing, + ) + + return TriggerSubscriptionsResponse( + count=len(subscriptions), + subscriptions=subscriptions, + ) + + @intercept_exceptions() + async def fetch_subscription( + self, + request: Request, + *, + subscription_id: UUID, + ) -> TriggerSubscriptionResponse: + await self._check(request, Permission.VIEW_TRIGGERS if is_ee() else None) + + subscription = await self.triggers_service.fetch_subscription( + project_id=UUID(request.state.project_id), + # + subscription_id=subscription_id, + ) + if not subscription: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Trigger subscription not found", + ) + + return TriggerSubscriptionResponse( + count=1, + subscription=subscription, + ) + + @intercept_exceptions() + @handle_adapter_exceptions() + async def edit_subscription( + self, + request: Request, + *, + subscription_id: UUID, + body: TriggerSubscriptionEditRequest, + ) -> TriggerSubscriptionResponse: + await self._check(request, Permission.EDIT_TRIGGERS if is_ee() else None) + + if str(subscription_id) != str(body.subscription.id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Path subscription_id does not match body id", + ) + + subscription = await self.triggers_service.edit_subscription( + project_id=UUID(request.state.project_id), + user_id=UUID(str(request.state.user_id)), + # + subscription=body.subscription, + ) + if not subscription: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Trigger subscription not found", + ) + + return TriggerSubscriptionResponse( + count=1, + subscription=subscription, + ) + + @intercept_exceptions() + @handle_adapter_exceptions() + async def delete_subscription( + self, + request: Request, + *, + subscription_id: UUID, + ) -> None: + await self._check(request, Permission.EDIT_TRIGGERS if is_ee() else None) + + deleted = await self.triggers_service.delete_subscription( + project_id=UUID(request.state.project_id), + # + subscription_id=subscription_id, + ) + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Trigger subscription not found", + ) + + @intercept_exceptions() + @handle_adapter_exceptions() + async def refresh_subscription( + self, + request: Request, + *, + subscription_id: UUID, + ) -> TriggerSubscriptionResponse: + await self._check(request, Permission.EDIT_TRIGGERS if is_ee() else None) + + try: + subscription = await self.triggers_service.refresh_subscription( + project_id=UUID(request.state.project_id), + user_id=UUID(str(request.state.user_id)), + # + subscription_id=subscription_id, + ) + except SubscriptionNotFoundError as e: + raise HTTPException(status_code=404, detail=e.message) from e + + return TriggerSubscriptionResponse( + count=1, + subscription=subscription, + ) + + @intercept_exceptions() + @handle_adapter_exceptions() + async def revoke_subscription( + self, + request: Request, + *, + subscription_id: UUID, + ) -> TriggerSubscriptionResponse: + await self._check(request, Permission.EDIT_TRIGGERS if is_ee() else None) + + try: + subscription = await self.triggers_service.revoke_subscription( + project_id=UUID(request.state.project_id), + user_id=UUID(str(request.state.user_id)), + # + subscription_id=subscription_id, + ) + except SubscriptionNotFoundError as e: + raise HTTPException(status_code=404, detail=e.message) from e + + return TriggerSubscriptionResponse( + count=1, + subscription=subscription, + ) + + # ----------------------------------------------------------------------- + # Trigger Deliveries + # ----------------------------------------------------------------------- + + @intercept_exceptions() + async def list_deliveries( + self, + request: Request, + ) -> TriggerDeliveriesResponse: + await self._check(request, Permission.VIEW_TRIGGERS if is_ee() else None) + + deliveries = await self.triggers_service.query_deliveries( + project_id=UUID(request.state.project_id), + ) + + return TriggerDeliveriesResponse( + count=len(deliveries), + deliveries=deliveries, + ) + + @intercept_exceptions() + async def query_deliveries( + self, + request: Request, + *, + body: TriggerDeliveryQueryRequest, + ) -> TriggerDeliveriesResponse: + await self._check(request, Permission.VIEW_TRIGGERS if is_ee() else None) + + deliveries = await self.triggers_service.query_deliveries( + project_id=UUID(request.state.project_id), + # + delivery=body.delivery, + # + windowing=body.windowing, + ) + + return TriggerDeliveriesResponse( + count=len(deliveries), + deliveries=deliveries, + ) + + @intercept_exceptions() + async def fetch_delivery( + self, + request: Request, + *, + delivery_id: UUID, + ) -> TriggerDeliveryResponse: + await self._check(request, Permission.VIEW_TRIGGERS if is_ee() else None) + + delivery = await self.triggers_service.fetch_delivery( + project_id=UUID(request.state.project_id), + # + delivery_id=delivery_id, + ) + if not delivery: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Trigger delivery not found", + ) + + return TriggerDeliveryResponse( + count=1, + delivery=delivery, + ) + + # ----------------------------------------------------------------------- + # Trigger Ingress (inbound provider events) + # ----------------------------------------------------------------------- + + @intercept_exceptions() + async def ingest_composio_event( + self, + request: Request, + ) -> Any: + """Receive a Composio provider event; verify, demux, ack-fast, enqueue. + + Public (no Agenta auth) — mirrors the Stripe events receiver. Scope and + attribution are recovered downstream from the resolved subscription row. + """ + body = await request.body() + + if not _verify_composio_signature(body=body, headers=request.headers): + return JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={"status": "error", "detail": "Signature verification failed"}, + ) + + try: + envelope = loads(body) if body else {} + except JSONDecodeError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid payload", + ) + + metadata = envelope.get("metadata") or {} + trigger_id = metadata.get("trigger_id") or metadata.get("nano_id") + event_id = metadata.get("id") + + if not trigger_id or not event_id: + # Nothing to route — accept (no-op) so the provider does not retry. + return TriggerEventAck( + status="accepted", detail="No trigger_id/id to route" + ) + + if self.dispatch_task is not None: + await self.dispatch_task.kiq( + trigger_id=str(trigger_id), + event_id=str(event_id), + event=envelope, + ) + + return TriggerEventAck(status="accepted") diff --git a/api/oss/src/core/gateway/__init__.py b/api/oss/src/core/gateway/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/src/core/gateway/connections/__init__.py b/api/oss/src/core/gateway/connections/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/src/core/gateway/connections/dtos.py b/api/oss/src/core/gateway/connections/dtos.py new file mode 100644 index 0000000000..e790953fe4 --- /dev/null +++ b/api/oss/src/core/gateway/connections/dtos.py @@ -0,0 +1,130 @@ +from enum import Enum +from typing import Any, Dict, Optional + +from pydantic import BaseModel, Field + +from oss.src.core.shared.dtos import ( + Header, + Identifier, + Lifecycle, + Metadata, + Slug, + Json, +) + +# --------------------------------------------------------------------------- +# Connection Enums +# --------------------------------------------------------------------------- + + +class ConnectionProviderKind(str, Enum): + COMPOSIO = "composio" + AGENTA = "agenta" + + +class ConnectionAuthScheme(str, Enum): + OAUTH = "oauth" + API_KEY = "api_key" + + +# --------------------------------------------------------------------------- +# Connections (domain DTOs) +# --------------------------------------------------------------------------- + + +class ConnectionStatus(BaseModel): + redirect_url: Optional[str] = None + + +class ConnectionCreateData(BaseModel): + callback_url: Optional[str] = None + # + auth_scheme: Optional[ConnectionAuthScheme] = None + + +class Connection( + Identifier, + Slug, + Header, + Lifecycle, + Metadata, +): + provider_key: ConnectionProviderKind + integration_key: str + # + data: Optional[Json] = None + # + status: Optional[ConnectionStatus] = None + + @property + def provider_connection_id(self) -> Optional[str]: + """Get provider-specific connection ID from data.""" + if self.data and isinstance(self.data, dict): + # For Composio, it's stored as "connected_account_id" + return self.data.get("connected_account_id") or self.data.get( + "provider_connection_id" + ) + return None + + @property + def is_active(self) -> bool: + """Check if connection is active (not deleted).""" + if self.flags and isinstance(self.flags, dict): + return self.flags.get("is_active", False) + return False + + @property + def is_valid(self) -> bool: + """Check if connection is valid (authenticated).""" + if self.flags and isinstance(self.flags, dict): + return self.flags.get("is_valid", False) + return False + + +class ConnectionCreate( + Slug, + Header, + Metadata, +): + provider_key: ConnectionProviderKind + integration_key: str + # + data: Optional[ConnectionCreateData] = None + + +class Usage(BaseModel): + """Cross-domain usage of a connection (C7). + + Reports how many consumers reference a given connection. ``tools`` is True + when the connection backs the tools domain; ``subscriptions`` counts trigger + subscriptions that read the same shared row. + """ + + tools: bool = False + subscriptions: int = 0 + + +# --------------------------------------------------------------------------- +# Connection (adapter-level DTOs) +# --------------------------------------------------------------------------- + + +class ConnectionRequest(BaseModel): + """Input DTO for initiating a provider connection via a gateway adapter.""" + + user_id: str + integration_key: str + auth_scheme: Optional[str] = None + callback_url: Optional[str] = None + + +class ConnectionResponse(BaseModel): + """Output DTO from ConnectionsGatewayInterface.initiate_connection. + + The adapter builds ``connection_data`` with provider-specific fields so the + service never needs to know which provider it is talking to. + """ + + provider_connection_id: str + redirect_url: Optional[str] = None + connection_data: Dict[str, Any] = Field(default_factory=dict) diff --git a/api/oss/src/core/gateway/connections/exceptions.py b/api/oss/src/core/gateway/connections/exceptions.py new file mode 100644 index 0000000000..5be6636a72 --- /dev/null +++ b/api/oss/src/core/gateway/connections/exceptions.py @@ -0,0 +1,65 @@ +from typing import Optional + + +class ConnectionsError(Exception): + """Base exception for the connections domain.""" + + def __init__(self, message: str = "Connections error"): + self.message = message + super().__init__(self.message) + + +class ProviderNotFoundError(ConnectionsError): + """Raised when the requested provider_key has no registered adapter.""" + + def __init__(self, provider_key: str): + self.provider_key = provider_key + super().__init__(f"Provider not found: {provider_key}") + + +class ConnectionNotFoundError(ConnectionsError): + """Raised when a connection cannot be found.""" + + def __init__( + self, + *, + connection_id: Optional[str] = None, + ): + self.connection_id = connection_id + super().__init__(f"Connection not found: {connection_id}") + + +class ConnectionInactiveError(ConnectionsError): + """Raised when trying to use an inactive or revoked connection.""" + + def __init__( + self, + *, + connection_id: str, + detail: Optional[str] = None, + ): + self.connection_id = connection_id + self.detail = detail + msg = f"Connection is inactive or revoked: {connection_id}" + if detail: + msg += f" - {detail}" + super().__init__(msg) + + +class AdapterError(ConnectionsError): + """Raised when an adapter operation fails.""" + + def __init__( + self, + *, + provider_key: str, + operation: str, + detail: Optional[str] = None, + ): + self.provider_key = provider_key + self.operation = operation + self.detail = detail + msg = f"Adapter error ({provider_key}.{operation})" + if detail: + msg += f": {detail}" + super().__init__(msg) diff --git a/api/oss/src/core/gateway/connections/interfaces.py b/api/oss/src/core/gateway/connections/interfaces.py new file mode 100644 index 0000000000..bc9eaa9b68 --- /dev/null +++ b/api/oss/src/core/gateway/connections/interfaces.py @@ -0,0 +1,127 @@ +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional +from uuid import UUID + +from oss.src.core.gateway.connections.dtos import ( + Connection, + ConnectionCreate, + ConnectionRequest, + ConnectionResponse, +) + + +class ConnectionsDAOInterface(ABC): + """Connection persistence contract — owns the gateway_connections table.""" + + @abstractmethod + async def create_connection( + self, + *, + project_id: UUID, + user_id: UUID, + # + connection_create: ConnectionCreate, + ) -> Optional[Connection]: ... + + @abstractmethod + async def get_connection( + self, + *, + project_id: UUID, + connection_id: UUID, + ) -> Optional[Connection]: ... + + @abstractmethod + async def update_connection( + self, + *, + project_id: UUID, + connection_id: UUID, + # + is_valid: Optional[bool] = None, + is_active: Optional[bool] = None, + provider_connection_id: Optional[str] = None, + data_update: Optional[Dict[str, Any]] = None, + ) -> Optional[Connection]: ... + + @abstractmethod + async def delete_connection( + self, + *, + project_id: UUID, + connection_id: UUID, + ) -> bool: ... + + @abstractmethod + async def query_connections( + self, + *, + project_id: UUID, + # + provider_key: Optional[str] = None, + integration_key: Optional[str] = None, + is_active: Optional[bool] = True, + ) -> List[Connection]: ... + + @abstractmethod + async def find_connection_by_provider_id( + self, + *, + provider_connection_id: str, + ) -> Optional[Connection]: ... + + @abstractmethod + async def activate_connection_by_provider_id( + self, + *, + provider_connection_id: str, + project_id: Optional[UUID] = None, + ) -> Optional[Connection]: ... + + +class ConnectionsGatewayInterface(ABC): + """Adapter port for external connection providers (Composio, Agenta, etc.). + + Provider-keyed on ``provider_connection_id`` and returns provider data. + Holds only the auth verbs; tool-specific verbs (execute, catalog) stay on + ``ToolsGatewayInterface``. + """ + + @abstractmethod + async def initiate_connection( + self, + *, + request: ConnectionRequest, + ) -> ConnectionResponse: + """Initiate a provider-side connection. Returns a typed response with + provider_connection_id, redirect_url, and connection_data — the dict + the service will persist in the local connection record. + """ + ... + + @abstractmethod + async def get_connection_status( + self, + *, + provider_connection_id: str, + ) -> Dict[str, Any]: + """Poll provider for updated connection status.""" + ... + + @abstractmethod + async def refresh_connection( + self, + *, + provider_connection_id: str, + force: bool = False, + callback_url: Optional[str] = None, + integration_key: Optional[str] = None, + user_id: Optional[str] = None, + ) -> Dict[str, Any]: ... + + @abstractmethod + async def revoke_connection( + self, + *, + provider_connection_id: str, + ) -> bool: ... diff --git a/api/oss/src/core/gateway/connections/providers/__init__.py b/api/oss/src/core/gateway/connections/providers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/src/core/gateway/connections/providers/composio/__init__.py b/api/oss/src/core/gateway/connections/providers/composio/__init__.py new file mode 100644 index 0000000000..a21be0d969 --- /dev/null +++ b/api/oss/src/core/gateway/connections/providers/composio/__init__.py @@ -0,0 +1,20 @@ +# Avoid importing adapter here to prevent SDK dependency issues in standalone scripts. +# Import directly when needed: +# from oss.src.core.gateway.connections.providers.composio.adapter import ( +# ComposioConnectionsAdapter, +# ) + +__all__ = [ + "ComposioConnectionsAdapter", +] + + +def __getattr__(name): + """Lazy import to avoid SDK dependency on module import.""" + if name == "ComposioConnectionsAdapter": + from oss.src.core.gateway.connections.providers.composio.adapter import ( + ComposioConnectionsAdapter, + ) + + return ComposioConnectionsAdapter + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/api/oss/src/core/gateway/connections/providers/composio/adapter.py b/api/oss/src/core/gateway/connections/providers/composio/adapter.py new file mode 100644 index 0000000000..6f9cbe67c0 --- /dev/null +++ b/api/oss/src/core/gateway/connections/providers/composio/adapter.py @@ -0,0 +1,302 @@ +from typing import Any, Dict, Optional + +import httpx + +from oss.src.utils.logging import get_module_logger + +from oss.src.core.gateway.connections.dtos import ( + ConnectionRequest, + ConnectionResponse, +) +from oss.src.core.gateway.connections.interfaces import ConnectionsGatewayInterface +from oss.src.core.gateway.connections.exceptions import AdapterError + + +log = get_module_logger(__name__) + +COMPOSIO_DEFAULT_API_URL = "https://backend.composio.dev/api/v3" + + +class ComposioConnectionsAdapter(ConnectionsGatewayInterface): + """Composio V3 connection auth adapter — uses httpx directly (no SDK). + + Holds the four auth verbs (initiate / status / refresh / revoke) behind + ``ConnectionsGatewayInterface``. Catalog and tool execution stay on the + tools adapter. + """ + + def __init__( + self, + *, + api_key: str, + api_url: str = COMPOSIO_DEFAULT_API_URL, + ): + self.api_key = api_key + self.api_url = api_url.rstrip("/") + # Shared client — one connection pool for the adapter's lifetime. + # Call close() on shutdown (wired in entrypoints/routers.py lifespan). + self._client = httpx.AsyncClient(timeout=30.0) + + async def close(self) -> None: + """Close the shared HTTP client and release connection pool resources.""" + await self._client.aclose() + + def _headers(self) -> Dict[str, str]: + return { + "x-api-key": self.api_key, + "Content-Type": "application/json", + } + + async def _get( + self, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + ) -> Any: + resp = await self._client.get( + f"{self.api_url}{path}", + headers=self._headers(), + params=params, + ) + resp.raise_for_status() + return resp.json() + + async def _post( + self, + path: str, + *, + json: Optional[Dict[str, Any]] = None, + ) -> Any: + resp = await self._client.post( + f"{self.api_url}{path}", + headers=self._headers(), + json=json or {}, + ) + if not resp.is_success: + log.error( + "Composio POST %s → %s: %s", + path, + resp.status_code, + resp.text, + ) + resp.raise_for_status() + return resp.json() + + async def _delete(self, path: str) -> bool: + resp = await self._client.delete( + f"{self.api_url}{path}", + headers=self._headers(), + ) + resp.raise_for_status() + return True + + # ----------------------------------------------------------------------- + # Connections + # ----------------------------------------------------------------------- + + async def initiate_connection( + self, + *, + request: ConnectionRequest, + ) -> ConnectionResponse: + user_id = request.user_id + integration_key = request.integration_key + auth_scheme = request.auth_scheme + callback_url = request.callback_url + + # Step 1: validate the toolkit exists and get its auth scheme info. + try: + toolkit = await self._get(f"/toolkits/{integration_key}") + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + raise AdapterError( + provider_key="composio", + operation="initiate_connection.validate_toolkit", + detail=f"Integration '{integration_key}' not found", + ) from e + raise AdapterError( + provider_key="composio", + operation="initiate_connection.validate_toolkit", + detail=str(e), + ) from e + except httpx.HTTPError as e: + raise AdapterError( + provider_key="composio", + operation="initiate_connection.validate_toolkit", + detail=str(e), + ) from e + + # Step 2: create an auth config for this integration. + # api_key → use_custom_auth; Composio's redirect UI collects the credentials. + # oauth / None → use_composio_managed_auth. + log.info( + "initiate_connection: integration_key=%s auth_scheme=%r", + integration_key, + auth_scheme, + ) + + if auth_scheme == "api_key": + # Derive Composio authScheme from toolkit's auth_config_details. + # Fall back to "API_KEY" as the common default. + composio_auth_scheme = "API_KEY" + for detail in toolkit.get("auth_config_details") or []: + mode = detail.get("mode", "") + if mode and "oauth" not in mode.lower(): + composio_auth_scheme = mode + break + + auth_config_body: Dict[str, Any] = { + "type": "use_custom_auth", + "authScheme": composio_auth_scheme, + } + else: + auth_config_body = {"type": "use_composio_managed_auth"} + + auth_configs_payload = { + "toolkit": {"slug": integration_key}, + "auth_config": auth_config_body, + } + log.info( + "initiate_connection: POST /auth_configs payload=%s", auth_configs_payload + ) + + try: + auth_config_result = await self._post( + "/auth_configs", + json=auth_configs_payload, + ) + except httpx.HTTPError as e: + raise AdapterError( + provider_key="composio", + operation="initiate_connection.create_auth_config", + detail=str(e), + ) from e + + auth_config_id = (auth_config_result.get("auth_config") or {}).get("id") + if not auth_config_id: + raise AdapterError( + provider_key="composio", + operation="initiate_connection.create_auth_config", + detail=f"No auth_config_id in response for integration '{integration_key}'", + ) + + log.info( + "initiate_connection: integration_key=%s auth_config_id=%s", + integration_key, + auth_config_id, + ) + + # Step 3: initiate connected account link. + payload: Dict[str, Any] = { + "user_id": user_id, + "auth_config_id": auth_config_id, + } + if callback_url: + payload["callback_url"] = callback_url + + try: + result = await self._post("/connected_accounts/link", json=payload) + except httpx.HTTPError as e: + raise AdapterError( + provider_key="composio", + operation="initiate_connection", + detail=str(e), + ) from e + + provider_connection_id = result.get("connected_account_id", "") + redirect_url = result.get("redirect_url") + + connection_data: Dict[str, Any] = { + "connected_account_id": provider_connection_id, + "auth_config_id": auth_config_id, + } + if redirect_url: + connection_data["redirect_url"] = redirect_url + + return ConnectionResponse( + provider_connection_id=provider_connection_id, + redirect_url=redirect_url, + connection_data=connection_data, + ) + + async def get_connection_status( + self, + *, + provider_connection_id: str, + ) -> Dict[str, Any]: + try: + result = await self._get(f"/connected_accounts/{provider_connection_id}") + except httpx.HTTPError as e: + raise AdapterError( + provider_key="composio", + operation="get_connection_status", + detail=str(e), + ) from e + + return { + "status": result.get("status"), + "is_valid": result.get("status") == "ACTIVE", + } + + async def refresh_connection( + self, + *, + provider_connection_id: str, + force: bool = False, + callback_url: Optional[str] = None, + integration_key: Optional[str] = None, + user_id: Optional[str] = None, + ) -> Dict[str, Any]: + # For Composio OAuth flows, "refresh" means re-initiating the auth link. + # The provider does not expose a token-refresh endpoint for OAuth connections, + # so we create a new connected_accounts/link which the user must re-authorize. + if integration_key and user_id: + result = await self.initiate_connection( + request=ConnectionRequest( + user_id=user_id, + integration_key=integration_key, + callback_url=callback_url, + ), + ) + return { + "id": result.provider_connection_id, + "redirect_url": result.redirect_url, + "auth_config_id": result.connection_data.get("auth_config_id"), + "is_valid": False, # Re-auth pending until callback fires + } + + payload: Dict[str, Any] = {} + if callback_url: + payload["callback_url"] = callback_url + + try: + result = await self._post( + f"/connected_accounts/{provider_connection_id}/refresh", + json=payload, + ) + except httpx.HTTPError as e: + raise AdapterError( + provider_key="composio", + operation="refresh_connection", + detail=str(e), + ) from e + + return { + "status": result.get("status"), + "is_valid": result.get("status") == "ACTIVE", + "redirect_url": result.get("redirect_url"), + } + + async def revoke_connection( + self, + *, + provider_connection_id: str, + ) -> bool: + try: + return await self._delete(f"/connected_accounts/{provider_connection_id}") + except httpx.HTTPError as e: + raise AdapterError( + provider_key="composio", + operation="revoke_connection", + detail=str(e), + ) from e diff --git a/api/oss/src/core/gateway/connections/registry.py b/api/oss/src/core/gateway/connections/registry.py new file mode 100644 index 0000000000..62bef9a74a --- /dev/null +++ b/api/oss/src/core/gateway/connections/registry.py @@ -0,0 +1,27 @@ +from typing import Dict, ItemsView + +from oss.src.core.gateway.connections.interfaces import ConnectionsGatewayInterface +from oss.src.core.gateway.connections.exceptions import ProviderNotFoundError + + +class ConnectionsGatewayRegistry: + """Dispatches to the correct connection adapter based on provider_key.""" + + def __init__( + self, + *, + adapters: Dict[str, ConnectionsGatewayInterface], + ): + self._adapters = adapters + + def get(self, provider_key: str) -> ConnectionsGatewayInterface: + adapter = self._adapters.get(provider_key) + if not adapter: + raise ProviderNotFoundError(provider_key) + return adapter + + def keys(self) -> list[str]: + return list(self._adapters.keys()) + + def items(self) -> ItemsView[str, ConnectionsGatewayInterface]: + return self._adapters.items() diff --git a/api/oss/src/core/gateway/connections/service.py b/api/oss/src/core/gateway/connections/service.py new file mode 100644 index 0000000000..988e27ccde --- /dev/null +++ b/api/oss/src/core/gateway/connections/service.py @@ -0,0 +1,327 @@ +from typing import Any, Dict, List, Optional +from uuid import UUID + +from oss.src.utils.logging import get_module_logger +from oss.src.utils.env import env + +from oss.src.core.gateway.connections.dtos import ( + Connection, + ConnectionCreate, + ConnectionRequest, + Usage, +) +from oss.src.core.gateway.connections.interfaces import ConnectionsDAOInterface +from oss.src.core.gateway.connections.registry import ConnectionsGatewayRegistry +from oss.src.core.gateway.connections.exceptions import ( + ConnectionInactiveError, + ConnectionNotFoundError, +) +from oss.src.core.gateway.connections.utils import make_oauth_state + + +log = get_module_logger(__name__) + +# The OAuth callback stays on the /tools router so the public contract is +# unchanged even though the connection now lives in its own domain. +_CALLBACK_PATH = "/tools/connections/callback" + + +class ConnectionsService: + """Project-scoped service that owns gateway_connections. + + Returns domain ``Connection`` DTOs. Downstream domains (tools, triggers) + consume this service; it never imports from them. + """ + + def __init__( + self, + *, + connections_dao: ConnectionsDAOInterface, + adapter_registry: ConnectionsGatewayRegistry, + ): + self.connections_dao = connections_dao + self.adapter_registry = adapter_registry + + # ----------------------------------------------------------------------- + # Reads + # ----------------------------------------------------------------------- + + async def query_connections( + self, + *, + project_id: UUID, + # + provider_key: Optional[str] = None, + integration_key: Optional[str] = None, + is_active: Optional[bool] = True, + ) -> List[Connection]: + """Query connections with optional filtering. Defaults to active-only.""" + return await self.connections_dao.query_connections( + project_id=project_id, + provider_key=provider_key, + integration_key=integration_key, + is_active=is_active, + ) + + async def list_connections( + self, + *, + project_id: UUID, + provider_key: str, + integration_key: str, + ) -> List[Connection]: + """List connections for a specific integration (catalog enrichment).""" + return await self.connections_dao.query_connections( + project_id=project_id, + provider_key=provider_key, + integration_key=integration_key, + ) + + async def get_connection( + self, + *, + project_id: UUID, + connection_id: UUID, + ) -> Optional[Connection]: + """Return a single connection by ID scoped to the project, or None.""" + # Read-only by design: do not mutate local state during GET. + return await self.connections_dao.get_connection( + project_id=project_id, + connection_id=connection_id, + ) + + async def find_connection_by_provider_connection_id( + self, + *, + provider_connection_id: str, + ) -> Optional[Connection]: + """Find any connection by its provider-side ID (for OAuth callbacks).""" + return await self.connections_dao.find_connection_by_provider_id( + provider_connection_id=provider_connection_id, + ) + + async def activate_connection_by_provider_connection_id( + self, + *, + provider_connection_id: str, + project_id: Optional[UUID] = None, + ) -> Optional[Connection]: + """Mark a connection valid+active after OAuth completes.""" + return await self.connections_dao.activate_connection_by_provider_id( + provider_connection_id=provider_connection_id, + project_id=project_id, + ) + + async def usage( + self, + *, + project_id: UUID, + connection_id: UUID, + ) -> Usage: + """Report cross-domain usage of a connection (C7). + + The seam for "used by tools / N subs". Tools and triggers read the same + shared row, so this is a read-only count of consumers. Subscriptions are + not yet a consumer in this WP, so the count is the seam (0). + """ + conn = await self.connections_dao.get_connection( + project_id=project_id, + connection_id=connection_id, + ) + if not conn: + raise ConnectionNotFoundError(connection_id=str(connection_id)) + + return Usage( + tools=True, + subscriptions=0, + ) + + # ----------------------------------------------------------------------- + # Writes + # ----------------------------------------------------------------------- + + async def initiate_connection( + self, + *, + project_id: UUID, + user_id: UUID, + # + connection_create: ConnectionCreate, + ) -> Connection: + """Initiate a provider connection and persist it locally in pending state.""" + provider_key = connection_create.provider_key.value + integration_key = connection_create.integration_key + + adapter = self.adapter_registry.get(provider_key) + + # Callback URL is server-owned. Do not trust/require client-provided values. + # Embed a signed state token so the callback can scope the activation. + state = make_oauth_state( + project_id=project_id, + user_id=user_id, + secret_key=env.agenta.crypt_key, + ) + callback_url = f"{env.agenta.api_url}{_CALLBACK_PATH}?state={state}" + + # Initiate with provider + connection_create_data = connection_create.data + provider_result = await adapter.initiate_connection( + request=ConnectionRequest( + user_id=str(project_id), + integration_key=integration_key, + auth_scheme=connection_create_data.auth_scheme.value + if connection_create_data and connection_create_data.auth_scheme + else None, + callback_url=callback_url, + ), + ) + + # Merge provider-returned connection_data with service-level project_id. + # The adapter owns provider-specific field names; the service adds project scope. + data: Dict[str, Any] = dict(provider_result.connection_data) + data["project_id"] = str(project_id) + connection_create.data = data # type: ignore[assignment] + + # Persist locally + return await self.connections_dao.create_connection( + project_id=project_id, + user_id=user_id, + # + connection_create=connection_create, + ) + + async def delete_connection( + self, + *, + project_id: UUID, + connection_id: UUID, + ) -> bool: + """Revoke provider-side connection and delete locally. Raises ConnectionNotFoundError if missing.""" + conn = await self.connections_dao.get_connection( + project_id=project_id, + connection_id=connection_id, + ) + + if not conn: + raise ConnectionNotFoundError( + connection_id=str(connection_id), + ) + + # Revoke provider-side + if conn.provider_connection_id: + adapter = self.adapter_registry.get(conn.provider_key.value) + try: + await adapter.revoke_connection( + provider_connection_id=conn.provider_connection_id, + ) + except Exception: + log.warning( + "Failed to revoke provider connection %s, proceeding with local delete", + conn.provider_connection_id, + ) + + # Delete locally + return await self.connections_dao.delete_connection( + project_id=project_id, + connection_id=connection_id, + ) + + async def revoke_connection( + self, + *, + project_id: UUID, + connection_id: UUID, + ) -> Connection: + """Mark a connection invalid locally without touching the provider. + + Local-only by design (C7/B3): flipping ``is_valid=False`` on the shared + gateway_connections row is the cross-domain effect — tools and triggers + read the same row, so everyone sees the revocation without a provider + call or cascade. + """ + conn = await self.connections_dao.get_connection( + project_id=project_id, + connection_id=connection_id, + ) + + if not conn: + raise ConnectionNotFoundError( + connection_id=str(connection_id), + ) + + updated = await self.connections_dao.update_connection( + project_id=project_id, + connection_id=connection_id, + is_valid=False, + ) + + return updated or conn + + async def refresh_connection( + self, + *, + project_id: UUID, + connection_id: UUID, + # + force: bool = False, + ) -> Connection: + conn = await self.connections_dao.get_connection( + project_id=project_id, + connection_id=connection_id, + ) + + if not conn: + raise ConnectionNotFoundError( + connection_id=str(connection_id), + ) + + if not conn.provider_connection_id: + raise ConnectionNotFoundError( + connection_id=str(connection_id), + ) + + if not conn.is_active: + raise ConnectionInactiveError( + connection_id=str(connection_id), + detail="Cannot refresh an inactive connection. Create a new connection to re-establish authorization.", + ) + + # Callback URL is server-owned with a signed state token. + state = make_oauth_state( + project_id=project_id, + user_id=project_id, # refresh has no user_id; use project_id as entity + secret_key=env.agenta.crypt_key, + ) + callback_url = f"{env.agenta.api_url}{_CALLBACK_PATH}?state={state}" + + adapter = self.adapter_registry.get(conn.provider_key.value) + + # Delegate provider-specific refresh logic to the adapter. + # For OAuth providers (e.g. Composio), the adapter re-initiates the link. + provider_connection_id = conn.provider_connection_id + result = await adapter.refresh_connection( + provider_connection_id=conn.provider_connection_id, + force=force, + callback_url=callback_url, + integration_key=conn.integration_key, + user_id=str(project_id), + ) + provider_connection_id = result.get("id") or provider_connection_id + auth_config_id = result.get("auth_config_id") + is_valid = result.get("is_valid", conn.is_valid) + + redirect_url = result.get("redirect_url") + # Always overwrite redirect_url so FE doesn't reuse stale links from prior flows. + data_update = {"redirect_url": redirect_url} + if auth_config_id: + data_update["auth_config_id"] = auth_config_id + + updated = await self.connections_dao.update_connection( + project_id=project_id, + connection_id=connection_id, + is_valid=is_valid, + provider_connection_id=provider_connection_id, + data_update=data_update, + ) + + return updated or conn diff --git a/api/oss/src/core/tools/utils.py b/api/oss/src/core/gateway/connections/utils.py similarity index 96% rename from api/oss/src/core/tools/utils.py rename to api/oss/src/core/gateway/connections/utils.py index 79334acd55..58a3dd18b5 100644 --- a/api/oss/src/core/tools/utils.py +++ b/api/oss/src/core/gateway/connections/utils.py @@ -1,4 +1,4 @@ -"""OAuth state signing utilities for tool connection callbacks.""" +"""OAuth state signing utilities for connection callbacks.""" import base64 import hashlib diff --git a/api/oss/src/core/tools/dtos.py b/api/oss/src/core/tools/dtos.py index a588965f61..2c1ac2bf82 100644 --- a/api/oss/src/core/tools/dtos.py +++ b/api/oss/src/core/tools/dtos.py @@ -5,11 +5,7 @@ from pydantic import BaseModel from oss.src.core.shared.dtos import ( - Header, Identifier, - Lifecycle, - Metadata, - Slug, Json, Status, ) @@ -85,71 +81,6 @@ class ToolCatalogProviderDetails(ToolCatalogProvider): integrations: Optional[List[ToolCatalogIntegration]] = None -# --------------------------------------------------------------------------- -# Tool Connections -# --------------------------------------------------------------------------- - - -class ToolConnectionStatus(BaseModel): - redirect_url: Optional[str] = None - - -class ToolConnectionCreateData(BaseModel): - callback_url: Optional[str] = None - # - auth_scheme: Optional[ToolAuthScheme] = None - - -class ToolConnection( - Identifier, - Slug, - Header, - Lifecycle, - Metadata, -): - provider_key: ToolProviderKind - integration_key: str - # - data: Optional[Json] = None - # - status: Optional[ToolConnectionStatus] = None - - @property - def provider_connection_id(self) -> Optional[str]: - """Get provider-specific connection ID from data.""" - if self.data and isinstance(self.data, dict): - # For Composio, it's stored as "connected_account_id" - return self.data.get("connected_account_id") or self.data.get( - "provider_connection_id" - ) - return None - - @property - def is_active(self) -> bool: - """Check if connection is active (not deleted).""" - if self.flags and isinstance(self.flags, dict): - return self.flags.get("is_active", False) - return False - - @property - def is_valid(self) -> bool: - """Check if connection is valid (authenticated).""" - if self.flags and isinstance(self.flags, dict): - return self.flags.get("is_valid", False) - return False - - -class ToolConnectionCreate( - Slug, - Header, - Metadata, -): - provider_key: ToolProviderKind - integration_key: str - # - data: Optional[ToolConnectionCreateData] = None - - # --------------------------------------------------------------------------- # Tool Calls # --------------------------------------------------------------------------- @@ -191,32 +122,6 @@ class ToolResult(Identifier): data: Optional[ToolResultData] = None -# --------------------------------------------------------------------------- -# Tool Connection (adapter-level DTOs) -# --------------------------------------------------------------------------- - - -class ToolConnectionRequest(BaseModel): - """Input DTO for initiating a provider connection via a gateway adapter.""" - - user_id: str - integration_key: str - auth_scheme: Optional[str] = None - callback_url: Optional[str] = None - - -class ToolConnectionResponse(BaseModel): - """Output DTO from ToolsGatewayInterface.initiate_connection. - - The adapter builds ``connection_data`` with provider-specific fields so the - service never needs to know which provider it is talking to. - """ - - provider_connection_id: str - redirect_url: Optional[str] = None - connection_data: Dict[str, Any] = {} - - # --------------------------------------------------------------------------- # Tool Execution (adapter-level DTOs) # --------------------------------------------------------------------------- diff --git a/api/oss/src/core/tools/interfaces.py b/api/oss/src/core/tools/interfaces.py index fdf0a820f7..0a61d59ee9 100644 --- a/api/oss/src/core/tools/interfaces.py +++ b/api/oss/src/core/tools/interfaces.py @@ -1,181 +1,72 @@ -from abc import ABC, abstractmethod -from typing import Any, Dict, List, Optional, Tuple -from uuid import UUID - -from oss.src.core.tools.dtos import ( - ToolCatalogAction, - ToolCatalogActionDetails, - ToolCatalogIntegration, - ToolCatalogProvider, - ToolConnection, - ToolConnectionCreate, - ToolConnectionRequest, - ToolConnectionResponse, - ToolExecutionRequest, - ToolExecutionResponse, -) - - -class ToolsDAOInterface(ABC): - """Connection persistence contract.""" - - @abstractmethod - async def create_connection( - self, - *, - project_id: UUID, - user_id: UUID, - # - connection_create: ToolConnectionCreate, - ) -> Optional[ToolConnection]: ... - - @abstractmethod - async def get_connection( - self, - *, - project_id: UUID, - connection_id: UUID, - ) -> Optional[ToolConnection]: ... - - @abstractmethod - async def update_connection( - self, - *, - project_id: UUID, - connection_id: UUID, - # - is_valid: Optional[bool] = None, - is_active: Optional[bool] = None, - provider_connection_id: Optional[str] = None, - data_update: Optional[Dict[str, Any]] = None, - ) -> Optional[ToolConnection]: ... - - @abstractmethod - async def delete_connection( - self, - *, - project_id: UUID, - connection_id: UUID, - ) -> bool: ... - - @abstractmethod - async def query_connections( - self, - *, - project_id: UUID, - # - provider_key: Optional[str] = None, - integration_key: Optional[str] = None, - is_active: Optional[bool] = True, - ) -> List[ToolConnection]: ... - - @abstractmethod - async def find_connection_by_provider_id( - self, - *, - provider_connection_id: str, - ) -> Optional[ToolConnection]: ... - - @abstractmethod - async def activate_connection_by_provider_id( - self, - *, - provider_connection_id: str, - project_id: Optional[UUID] = None, - ) -> Optional[ToolConnection]: ... - - -class ToolsGatewayInterface(ABC): - """Port for external tool providers (Composio, Agenta, etc.).""" - - @abstractmethod - async def list_providers(self) -> List[ToolCatalogProvider]: ... - - @abstractmethod - async def list_integrations( - self, - *, - search: Optional[str] = None, - sort_by: Optional[str] = None, - limit: Optional[int] = None, - cursor: Optional[str] = None, - ) -> Tuple[List[ToolCatalogIntegration], Optional[str], int]: - """Returns (items, next_cursor, total_items).""" - ... - - @abstractmethod - async def get_integration( - self, - *, - integration_key: str, - ) -> Optional[ToolCatalogIntegration]: ... - - @abstractmethod - async def list_actions( - self, - *, - integration_key: str, - query: Optional[str] = None, - categories: Optional[List[str]] = None, - important: Optional[bool] = None, - limit: Optional[int] = None, - cursor: Optional[str] = None, - ) -> Tuple[List[ToolCatalogAction], Optional[str], int]: - """Returns (items, next_cursor, total_items).""" - ... - - @abstractmethod - async def get_action( - self, - *, - integration_key: str, - action_key: str, - ) -> Optional[ToolCatalogActionDetails]: ... - - @abstractmethod - async def initiate_connection( - self, - *, - request: ToolConnectionRequest, - ) -> ToolConnectionResponse: - """Initiate a provider-side connection. Returns a typed response with - provider_connection_id, redirect_url, and connection_data — the dict - the service will persist in the local connection record. - """ - ... - - @abstractmethod - async def get_connection_status( - self, - *, - provider_connection_id: str, - ) -> Dict[str, Any]: - """Poll provider for updated connection status.""" - ... - - @abstractmethod - async def refresh_connection( - self, - *, - provider_connection_id: str, - force: bool = False, - callback_url: Optional[str] = None, - integration_key: Optional[str] = None, - user_id: Optional[str] = None, - ) -> Dict[str, Any]: ... - - @abstractmethod - async def revoke_connection( - self, - *, - provider_connection_id: str, - ) -> bool: ... - - @abstractmethod - async def execute( - self, - *, - request: ToolExecutionRequest, - ) -> ToolExecutionResponse: - """Execute a tool action.""" - ... +from abc import ABC, abstractmethod +from typing import List, Optional, Tuple + +from oss.src.core.tools.dtos import ( + ToolCatalogAction, + ToolCatalogActionDetails, + ToolCatalogIntegration, + ToolCatalogProvider, + ToolExecutionRequest, + ToolExecutionResponse, +) + + +class ToolsGatewayInterface(ABC): + """Port for external tool providers (Composio, Agenta, etc.). + + Tool-specific verbs only — catalog browse and execution. Connection auth + verbs live behind ``ConnectionsGatewayInterface`` in the connections domain. + """ + + @abstractmethod + async def list_providers(self) -> List[ToolCatalogProvider]: ... + + @abstractmethod + async def list_integrations( + self, + *, + search: Optional[str] = None, + sort_by: Optional[str] = None, + limit: Optional[int] = None, + cursor: Optional[str] = None, + ) -> Tuple[List[ToolCatalogIntegration], Optional[str], int]: + """Returns (items, next_cursor, total_items).""" + ... + + @abstractmethod + async def get_integration( + self, + *, + integration_key: str, + ) -> Optional[ToolCatalogIntegration]: ... + + @abstractmethod + async def list_actions( + self, + *, + integration_key: str, + query: Optional[str] = None, + categories: Optional[List[str]] = None, + important: Optional[bool] = None, + limit: Optional[int] = None, + cursor: Optional[str] = None, + ) -> Tuple[List[ToolCatalogAction], Optional[str], int]: + """Returns (items, next_cursor, total_items).""" + ... + + @abstractmethod + async def get_action( + self, + *, + integration_key: str, + action_key: str, + ) -> Optional[ToolCatalogActionDetails]: ... + + @abstractmethod + async def execute( + self, + *, + request: ToolExecutionRequest, + ) -> ToolExecutionResponse: + """Execute a tool action.""" + ... diff --git a/api/oss/src/core/tools/providers/composio/adapter.py b/api/oss/src/core/tools/providers/composio/adapter.py index f90ab9aa8e..82dfb56e83 100644 --- a/api/oss/src/core/tools/providers/composio/adapter.py +++ b/api/oss/src/core/tools/providers/composio/adapter.py @@ -9,8 +9,6 @@ from oss.src.core.tools.dtos import ( ToolCatalogActionDetails, ToolCatalogProvider, - ToolConnectionRequest, - ToolConnectionResponse, ToolExecutionRequest, ToolExecutionResponse, ) @@ -28,8 +26,8 @@ class ComposioToolsAdapter(ComposioCatalogClient, ToolsGatewayInterface): """Composio V3 API adapter — uses httpx directly (no SDK). Catalog operations (list/get integrations and actions) are provided by - ``ComposioCatalogClient``. Connection management and tool execution are - implemented here. + ``ComposioCatalogClient``. Tool execution is implemented here. Connection + auth lives in ``ComposioConnectionsAdapter``. """ def __init__( @@ -89,14 +87,6 @@ async def _post( resp.raise_for_status() return resp.json() - async def _delete(self, path: str) -> bool: - resp = await self._client.delete( - f"{self.api_url}{path}", - headers=self._headers(), - ) - resp.raise_for_status() - return True - # ----------------------------------------------------------------------- # Catalog — provider listing # ----------------------------------------------------------------------- @@ -163,217 +153,6 @@ async def get_action( scopes=item.get("scopes") or None, ) - # ----------------------------------------------------------------------- - # Connections - # ----------------------------------------------------------------------- - - async def initiate_connection( - self, - *, - request: ToolConnectionRequest, - ) -> ToolConnectionResponse: - user_id = request.user_id - integration_key = request.integration_key - auth_scheme = request.auth_scheme - callback_url = request.callback_url - - # Step 1: validate the toolkit exists and get its auth scheme info. - try: - toolkit = await self._get(f"/toolkits/{integration_key}") - except httpx.HTTPStatusError as e: - if e.response.status_code == 404: - raise AdapterError( - provider_key="composio", - operation="initiate_connection.validate_toolkit", - detail=f"Integration '{integration_key}' not found", - ) from e - raise AdapterError( - provider_key="composio", - operation="initiate_connection.validate_toolkit", - detail=str(e), - ) from e - except httpx.HTTPError as e: - raise AdapterError( - provider_key="composio", - operation="initiate_connection.validate_toolkit", - detail=str(e), - ) from e - - # Step 2: create an auth config for this integration. - # api_key → use_custom_auth; Composio's redirect UI collects the credentials. - # oauth / None → use_composio_managed_auth. - log.info( - "initiate_connection: integration_key=%s auth_scheme=%r", - integration_key, - auth_scheme, - ) - - if auth_scheme == "api_key": - # Derive Composio authScheme from toolkit's auth_config_details. - # Fall back to "API_KEY" as the common default. - composio_auth_scheme = "API_KEY" - for detail in toolkit.get("auth_config_details") or []: - mode = detail.get("mode", "") - if mode and "oauth" not in mode.lower(): - composio_auth_scheme = mode - break - - auth_config_body: Dict[str, Any] = { - "type": "use_custom_auth", - "authScheme": composio_auth_scheme, - } - else: - auth_config_body = {"type": "use_composio_managed_auth"} - - auth_configs_payload = { - "toolkit": {"slug": integration_key}, - "auth_config": auth_config_body, - } - log.info( - "initiate_connection: POST /auth_configs payload=%s", auth_configs_payload - ) - - try: - auth_config_result = await self._post( - "/auth_configs", - json=auth_configs_payload, - ) - except httpx.HTTPError as e: - raise AdapterError( - provider_key="composio", - operation="initiate_connection.create_auth_config", - detail=str(e), - ) from e - - auth_config_id = (auth_config_result.get("auth_config") or {}).get("id") - if not auth_config_id: - raise AdapterError( - provider_key="composio", - operation="initiate_connection.create_auth_config", - detail=f"No auth_config_id in response for integration '{integration_key}'", - ) - - log.info( - "initiate_connection: integration_key=%s auth_config_id=%s", - integration_key, - auth_config_id, - ) - - # Step 3: initiate connected account link. - payload: Dict[str, Any] = { - "user_id": user_id, - "auth_config_id": auth_config_id, - } - if callback_url: - payload["callback_url"] = callback_url - - try: - result = await self._post("/connected_accounts/link", json=payload) - except httpx.HTTPError as e: - raise AdapterError( - provider_key="composio", - operation="initiate_connection", - detail=str(e), - ) from e - - provider_connection_id = result.get("connected_account_id", "") - redirect_url = result.get("redirect_url") - - connection_data: Dict[str, Any] = { - "connected_account_id": provider_connection_id, - "auth_config_id": auth_config_id, - } - if redirect_url: - connection_data["redirect_url"] = redirect_url - - return ToolConnectionResponse( - provider_connection_id=provider_connection_id, - redirect_url=redirect_url, - connection_data=connection_data, - ) - - async def get_connection_status( - self, - *, - provider_connection_id: str, - ) -> Dict[str, Any]: - try: - result = await self._get(f"/connected_accounts/{provider_connection_id}") - except httpx.HTTPError as e: - raise AdapterError( - provider_key="composio", - operation="get_connection_status", - detail=str(e), - ) from e - - return { - "status": result.get("status"), - "is_valid": result.get("status") == "ACTIVE", - } - - async def refresh_connection( - self, - *, - provider_connection_id: str, - force: bool = False, - callback_url: Optional[str] = None, - integration_key: Optional[str] = None, - user_id: Optional[str] = None, - ) -> Dict[str, Any]: - # For Composio OAuth flows, "refresh" means re-initiating the auth link. - # The provider does not expose a token-refresh endpoint for OAuth connections, - # so we create a new connected_accounts/link which the user must re-authorize. - if integration_key and user_id: - result = await self.initiate_connection( - request=ToolConnectionRequest( - user_id=user_id, - integration_key=integration_key, - callback_url=callback_url, - ), - ) - return { - "id": result.provider_connection_id, - "redirect_url": result.redirect_url, - "auth_config_id": result.connection_data.get("auth_config_id"), - "is_valid": False, # Re-auth pending until callback fires - } - - payload: Dict[str, Any] = {} - if callback_url: - payload["callback_url"] = callback_url - - try: - result = await self._post( - f"/connected_accounts/{provider_connection_id}/refresh", - json=payload, - ) - except httpx.HTTPError as e: - raise AdapterError( - provider_key="composio", - operation="refresh_connection", - detail=str(e), - ) from e - - return { - "status": result.get("status"), - "is_valid": result.get("status") == "ACTIVE", - "redirect_url": result.get("redirect_url"), - } - - async def revoke_connection( - self, - *, - provider_connection_id: str, - ) -> bool: - try: - return await self._delete(f"/connected_accounts/{provider_connection_id}") - except httpx.HTTPError as e: - raise AdapterError( - provider_key="composio", - operation="revoke_connection", - detail=str(e), - ) from e - # ----------------------------------------------------------------------- # Execution # ----------------------------------------------------------------------- diff --git a/api/oss/src/core/tools/service.py b/api/oss/src/core/tools/service.py index f603bc4d42..df680b21b2 100644 --- a/api/oss/src/core/tools/service.py +++ b/api/oss/src/core/tools/service.py @@ -1,410 +1,265 @@ -from typing import Any, Dict, List, Optional, Tuple -from uuid import UUID - -from oss.src.utils.logging import get_module_logger -from oss.src.utils.env import env -from oss.src.core.tools.utils import make_oauth_state - -from oss.src.core.tools.dtos import ( - ToolCatalogAction, - ToolCatalogActionDetails, - ToolCatalogIntegration, - ToolCatalogProvider, - ToolConnection, - ToolConnectionCreate, - ToolConnectionRequest, - ToolExecutionRequest, - ToolExecutionResponse, -) -from oss.src.core.tools.interfaces import ( - ToolsDAOInterface, -) -from oss.src.core.tools.registry import ToolsGatewayRegistry -from oss.src.core.tools.exceptions import ( - ConnectionInactiveError, - ConnectionNotFoundError, -) - - -log = get_module_logger(__name__) - - -class ToolsService: - def __init__( - self, - *, - tools_dao: ToolsDAOInterface, - adapter_registry: ToolsGatewayRegistry, - ): - self.tools_dao = tools_dao - self.adapter_registry = adapter_registry - - # ----------------------------------------------------------------------- - # Catalog browse - # ----------------------------------------------------------------------- - - async def list_providers(self) -> List[ToolCatalogProvider]: - """Return all providers across registered adapters.""" - results: List[ToolCatalogProvider] = [] - for _key, adapter in self.adapter_registry.items(): - providers = await adapter.list_providers() - results.extend(providers) - return results - - async def get_provider( - self, - *, - provider_key: str, - ) -> Optional[ToolCatalogProvider]: - """Return a single provider by key, or None if not found.""" - adapter = self.adapter_registry.get(provider_key) - providers = await adapter.list_providers() - for p in providers: - if p.key == provider_key: - return p - return None - - async def list_integrations( - self, - *, - provider_key: str, - # - search: Optional[str] = None, - sort_by: Optional[str] = None, - limit: Optional[int] = None, - cursor: Optional[str] = None, - ) -> Tuple[List[ToolCatalogIntegration], Optional[str], int]: - """List integrations for a provider with optional filtering and pagination.""" - adapter = self.adapter_registry.get(provider_key) - integrations, next_cursor, total = await adapter.list_integrations( - search=search, - sort_by=sort_by, - limit=limit, - cursor=cursor, - ) - return integrations, next_cursor, total - - async def get_integration( - self, - *, - provider_key: str, - integration_key: str, - ) -> Optional[ToolCatalogIntegration]: - """Return a single integration by key, or None if not found.""" - adapter = self.adapter_registry.get(provider_key) - return await adapter.get_integration(integration_key=integration_key) - - async def list_actions( - self, - *, - provider_key: str, - integration_key: str, - # - query: Optional[str] = None, - categories: Optional[List[str]] = None, - important: Optional[bool] = None, - limit: Optional[int] = None, - cursor: Optional[str] = None, - ) -> Tuple[List[ToolCatalogAction], Optional[str], int]: - """List actions for an integration with optional search and pagination.""" - adapter = self.adapter_registry.get(provider_key) - return await adapter.list_actions( - integration_key=integration_key, - query=query, - categories=categories, - important=important, - limit=limit, - cursor=cursor, - ) - - async def get_action( - self, - *, - provider_key: str, - integration_key: str, - action_key: str, - ) -> Optional[ToolCatalogActionDetails]: - """Return full action detail including input/output schema, or None if not found.""" - adapter = self.adapter_registry.get(provider_key) - return await adapter.get_action( - integration_key=integration_key, - action_key=action_key, - ) - - # ----------------------------------------------------------------------- - # Connection management - # ----------------------------------------------------------------------- - - async def query_connections( - self, - *, - project_id: UUID, - # - provider_key: Optional[str] = None, - integration_key: Optional[str] = None, - is_active: Optional[bool] = True, - ) -> List[ToolConnection]: - """Query connections with optional filtering. Defaults to active-only.""" - return await self.tools_dao.query_connections( - project_id=project_id, - provider_key=provider_key, - integration_key=integration_key, - is_active=is_active, - ) - - async def find_connection_by_provider_connection_id( - self, - *, - provider_connection_id: str, - ) -> Optional[ToolConnection]: - """Find any connection by its provider-side ID (for OAuth callbacks).""" - return await self.tools_dao.find_connection_by_provider_id( - provider_connection_id=provider_connection_id, - ) - - async def activate_connection_by_provider_connection_id( - self, - *, - provider_connection_id: str, - project_id: Optional[UUID] = None, - ) -> Optional[ToolConnection]: - """Mark a connection valid+active after OAuth completes.""" - return await self.tools_dao.activate_connection_by_provider_id( - provider_connection_id=provider_connection_id, - project_id=project_id, - ) - - async def list_connections( - self, - *, - project_id: UUID, - provider_key: str, - integration_key: str, - ) -> List[ToolConnection]: - """List connections for a specific integration (catalog enrichment).""" - return await self.tools_dao.query_connections( - project_id=project_id, - provider_key=provider_key, - integration_key=integration_key, - ) - - async def get_connection( - self, - *, - project_id: UUID, - connection_id: UUID, - ) -> Optional[ToolConnection]: - """Return a single connection by ID scoped to the project, or None.""" - # Read-only by design: do not mutate local state during GET. - return await self.tools_dao.get_connection( - project_id=project_id, - connection_id=connection_id, - ) - - async def create_connection( - self, - *, - project_id: UUID, - user_id: UUID, - # - connection_create: ToolConnectionCreate, - ) -> ToolConnection: - """Initiate a provider connection and persist it locally in pending state.""" - provider_key = connection_create.provider_key.value - integration_key = connection_create.integration_key - - adapter = self.adapter_registry.get(provider_key) - - # Callback URL is server-owned. Do not trust/require client-provided values. - # Embed a signed state token so the callback can scope the activation. - state = make_oauth_state( - project_id=project_id, - user_id=user_id, - secret_key=env.agenta.crypt_key, - ) - callback_url = f"{env.agenta.api_url}/tools/connections/callback?state={state}" - - # Initiate with provider - connection_create_data = connection_create.data - provider_result = await adapter.initiate_connection( - request=ToolConnectionRequest( - user_id=str(project_id), - integration_key=integration_key, - auth_scheme=connection_create_data.auth_scheme.value - if connection_create_data and connection_create_data.auth_scheme - else None, - callback_url=callback_url, - ), - ) - - # Merge provider-returned connection_data with service-level project_id. - # The adapter owns provider-specific field names; the service adds project scope. - data: Dict[str, Any] = dict(provider_result.connection_data) - data["project_id"] = str(project_id) - connection_create.data = data # type: ignore[assignment] - - # Persist locally - return await self.tools_dao.create_connection( - project_id=project_id, - user_id=user_id, - # - connection_create=connection_create, - ) - - async def delete_connection( - self, - *, - project_id: UUID, - connection_id: UUID, - ) -> bool: - """Revoke provider-side connection and delete locally. Raises ConnectionNotFoundError if missing.""" - # Look up connection - conn = await self.tools_dao.get_connection( - project_id=project_id, - connection_id=connection_id, - ) - - if not conn: - raise ConnectionNotFoundError( - connection_id=str(connection_id), - ) - - # Revoke provider-side - if conn.provider_connection_id: - adapter = self.adapter_registry.get(conn.provider_key.value) - try: - await adapter.revoke_connection( - provider_connection_id=conn.provider_connection_id, - ) - except Exception: - log.warning( - "Failed to revoke provider connection %s, proceeding with local delete", - conn.provider_connection_id, - ) - - # Delete locally - return await self.tools_dao.delete_connection( - project_id=project_id, - connection_id=connection_id, - ) - - async def revoke_connection( - self, - *, - project_id: UUID, - connection_id: UUID, - ) -> ToolConnection: - """Mark a connection invalid locally without touching the provider.""" - conn = await self.tools_dao.get_connection( - project_id=project_id, - connection_id=connection_id, - ) - - if not conn: - raise ConnectionNotFoundError( - connection_id=str(connection_id), - ) - - updated = await self.tools_dao.update_connection( - project_id=project_id, - connection_id=connection_id, - is_valid=False, - ) - - return updated or conn - - async def refresh_connection( - self, - *, - project_id: UUID, - connection_id: UUID, - # - force: bool = False, - ) -> ToolConnection: - conn = await self.tools_dao.get_connection( - project_id=project_id, - connection_id=connection_id, - ) - - if not conn: - raise ConnectionNotFoundError( - connection_id=str(connection_id), - ) - - if not conn.provider_connection_id: - raise ConnectionNotFoundError( - connection_id=str(connection_id), - ) - - if not conn.is_active: - raise ConnectionInactiveError( - connection_id=str(connection_id), - detail="Cannot refresh an inactive connection. Create a new connection to re-establish authorization.", - ) - - # Callback URL is server-owned with a signed state token. - state = make_oauth_state( - project_id=project_id, - user_id=project_id, # refresh has no user_id; use project_id as entity - secret_key=env.agenta.crypt_key, - ) - callback_url = f"{env.agenta.api_url}/tools/connections/callback?state={state}" - - adapter = self.adapter_registry.get(conn.provider_key.value) - - # Delegate provider-specific refresh logic to the adapter. - # For OAuth providers (e.g. Composio), the adapter re-initiates the link. - provider_connection_id = conn.provider_connection_id - result = await adapter.refresh_connection( - provider_connection_id=conn.provider_connection_id, - force=force, - callback_url=callback_url, - integration_key=conn.integration_key, - user_id=str(project_id), - ) - provider_connection_id = result.get("id") or provider_connection_id - auth_config_id = result.get("auth_config_id") - is_valid = result.get("is_valid", conn.is_valid) - - redirect_url = result.get("redirect_url") - # Always overwrite redirect_url so FE doesn't reuse stale links from prior flows. - data_update = {"redirect_url": redirect_url} - if auth_config_id: - data_update["auth_config_id"] = auth_config_id - - updated = await self.tools_dao.update_connection( - project_id=project_id, - connection_id=connection_id, - is_valid=is_valid, - provider_connection_id=provider_connection_id, - data_update=data_update, - ) - - return updated or conn - - # ----------------------------------------------------------------------- - # Tool execution - # ----------------------------------------------------------------------- - - async def execute_tool( - self, - *, - provider_key: str, - integration_key: str, - action_key: str, - provider_connection_id: str, - user_id: Optional[str] = None, - arguments: Dict[str, Any], - ) -> ToolExecutionResponse: - """Execute a tool action using the provider adapter.""" - adapter = self.adapter_registry.get(provider_key) - - return await adapter.execute( - request=ToolExecutionRequest( - integration_key=integration_key, - action_key=action_key, - provider_connection_id=provider_connection_id, - user_id=user_id, - arguments=arguments, - ), - ) +from typing import Any, Dict, List, Optional, Tuple +from uuid import UUID + +from oss.src.utils.logging import get_module_logger + +from oss.src.core.gateway.connections.dtos import Connection, ConnectionCreate +from oss.src.core.gateway.connections.service import ConnectionsService + +from oss.src.core.tools.dtos import ( + ToolCatalogAction, + ToolCatalogActionDetails, + ToolCatalogIntegration, + ToolCatalogProvider, + ToolExecutionRequest, + ToolExecutionResponse, +) +from oss.src.core.tools.registry import ToolsGatewayRegistry + + +log = get_module_logger(__name__) + + +class ToolsService: + def __init__( + self, + *, + connections_service: ConnectionsService, + adapter_registry: ToolsGatewayRegistry, + ): + self.connections_service = connections_service + self.adapter_registry = adapter_registry + + # ----------------------------------------------------------------------- + # Catalog browse + # ----------------------------------------------------------------------- + + async def list_providers(self) -> List[ToolCatalogProvider]: + """Return all providers across registered adapters.""" + results: List[ToolCatalogProvider] = [] + for _key, adapter in self.adapter_registry.items(): + providers = await adapter.list_providers() + results.extend(providers) + return results + + async def get_provider( + self, + *, + provider_key: str, + ) -> Optional[ToolCatalogProvider]: + """Return a single provider by key, or None if not found.""" + adapter = self.adapter_registry.get(provider_key) + providers = await adapter.list_providers() + for p in providers: + if p.key == provider_key: + return p + return None + + async def list_integrations( + self, + *, + provider_key: str, + # + search: Optional[str] = None, + sort_by: Optional[str] = None, + limit: Optional[int] = None, + cursor: Optional[str] = None, + ) -> Tuple[List[ToolCatalogIntegration], Optional[str], int]: + """List integrations for a provider with optional filtering and pagination.""" + adapter = self.adapter_registry.get(provider_key) + integrations, next_cursor, total = await adapter.list_integrations( + search=search, + sort_by=sort_by, + limit=limit, + cursor=cursor, + ) + return integrations, next_cursor, total + + async def get_integration( + self, + *, + provider_key: str, + integration_key: str, + ) -> Optional[ToolCatalogIntegration]: + """Return a single integration by key, or None if not found.""" + adapter = self.adapter_registry.get(provider_key) + return await adapter.get_integration(integration_key=integration_key) + + async def list_actions( + self, + *, + provider_key: str, + integration_key: str, + # + query: Optional[str] = None, + categories: Optional[List[str]] = None, + important: Optional[bool] = None, + limit: Optional[int] = None, + cursor: Optional[str] = None, + ) -> Tuple[List[ToolCatalogAction], Optional[str], int]: + """List actions for an integration with optional search and pagination.""" + adapter = self.adapter_registry.get(provider_key) + return await adapter.list_actions( + integration_key=integration_key, + query=query, + categories=categories, + important=important, + limit=limit, + cursor=cursor, + ) + + async def get_action( + self, + *, + provider_key: str, + integration_key: str, + action_key: str, + ) -> Optional[ToolCatalogActionDetails]: + """Return full action detail including input/output schema, or None if not found.""" + adapter = self.adapter_registry.get(provider_key) + return await adapter.get_action( + integration_key=integration_key, + action_key=action_key, + ) + + # ----------------------------------------------------------------------- + # Connection management (delegated to ConnectionsService — one-way dep) + # ----------------------------------------------------------------------- + + async def query_connections( + self, + *, + project_id: UUID, + # + provider_key: Optional[str] = None, + integration_key: Optional[str] = None, + is_active: Optional[bool] = True, + ) -> List[Connection]: + return await self.connections_service.query_connections( + project_id=project_id, + provider_key=provider_key, + integration_key=integration_key, + is_active=is_active, + ) + + async def list_connections( + self, + *, + project_id: UUID, + provider_key: str, + integration_key: str, + ) -> List[Connection]: + return await self.connections_service.list_connections( + project_id=project_id, + provider_key=provider_key, + integration_key=integration_key, + ) + + async def get_connection( + self, + *, + project_id: UUID, + connection_id: UUID, + ) -> Optional[Connection]: + return await self.connections_service.get_connection( + project_id=project_id, + connection_id=connection_id, + ) + + async def find_connection_by_provider_connection_id( + self, + *, + provider_connection_id: str, + ) -> Optional[Connection]: + return await self.connections_service.find_connection_by_provider_connection_id( + provider_connection_id=provider_connection_id, + ) + + async def activate_connection_by_provider_connection_id( + self, + *, + provider_connection_id: str, + project_id: Optional[UUID] = None, + ) -> Optional[Connection]: + return await self.connections_service.activate_connection_by_provider_connection_id( + provider_connection_id=provider_connection_id, + project_id=project_id, + ) + + async def create_connection( + self, + *, + project_id: UUID, + user_id: UUID, + # + connection_create: ConnectionCreate, + ) -> Connection: + return await self.connections_service.initiate_connection( + project_id=project_id, + user_id=user_id, + # + connection_create=connection_create, + ) + + async def delete_connection( + self, + *, + project_id: UUID, + connection_id: UUID, + ) -> bool: + return await self.connections_service.delete_connection( + project_id=project_id, + connection_id=connection_id, + ) + + async def revoke_connection( + self, + *, + project_id: UUID, + connection_id: UUID, + ) -> Connection: + return await self.connections_service.revoke_connection( + project_id=project_id, + connection_id=connection_id, + ) + + async def refresh_connection( + self, + *, + project_id: UUID, + connection_id: UUID, + # + force: bool = False, + ) -> Connection: + return await self.connections_service.refresh_connection( + project_id=project_id, + connection_id=connection_id, + force=force, + ) + + # ----------------------------------------------------------------------- + # Tool execution + # ----------------------------------------------------------------------- + + async def execute_tool( + self, + *, + provider_key: str, + integration_key: str, + action_key: str, + provider_connection_id: str, + user_id: Optional[str] = None, + arguments: Dict[str, Any], + ) -> ToolExecutionResponse: + """Execute a tool action using the provider adapter.""" + adapter = self.adapter_registry.get(provider_key) + + return await adapter.execute( + request=ToolExecutionRequest( + integration_key=integration_key, + action_key=action_key, + provider_connection_id=provider_connection_id, + user_id=user_id, + arguments=arguments, + ), + ) diff --git a/api/oss/src/core/triggers/__init__.py b/api/oss/src/core/triggers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/src/core/triggers/dtos.py b/api/oss/src/core/triggers/dtos.py new file mode 100644 index 0000000000..2d7a1769f3 --- /dev/null +++ b/api/oss/src/core/triggers/dtos.py @@ -0,0 +1,190 @@ +from enum import Enum +from typing import Any, Dict, List, Optional +from uuid import UUID + +from pydantic import BaseModel, Field + +from oss.src.core.shared.dtos import ( + Header, + Identifier, + Lifecycle, + Metadata, + Reference, + Selector, + Status, +) + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +TRIGGER_MAX_RETRIES = 5 + + +# --------------------------------------------------------------------------- +# Trigger Enums +# --------------------------------------------------------------------------- + + +class TriggerProviderKind(str, Enum): + COMPOSIO = "composio" + + +# --------------------------------------------------------------------------- +# Trigger Catalog +# +# The catalog leaf is an **event** (Composio "trigger type"), the analogue of a +# tools **action**. An event carries a ``trigger_config`` JSON Schema, the +# analogue of an action's ``input_parameters``. +# --------------------------------------------------------------------------- + + +class TriggerCatalogEvent(BaseModel): + key: str + # + name: str + description: Optional[str] = None + # + provider: Optional[str] = None + integration: Optional[str] = None + # + categories: List[str] = Field(default_factory=list) + logo: Optional[str] = None + + +class TriggerCatalogEventDetails(TriggerCatalogEvent): + # FROZEN (WS-PRE): the Event DTO carries the event's trigger_config JSON Schema + # — the inbound analogue of an action's input_parameters. + trigger_config: Optional[Dict[str, Any]] = None + payload: Optional[Dict[str, Any]] = None + + +class TriggerCatalogProvider(BaseModel): + key: TriggerProviderKind + # + name: str + description: Optional[str] = None + + +# --------------------------------------------------------------------------- +# Context allowlists (mapping; see mapping.md §3) +# +# The inbound analogue of webhooks' EVENT_CONTEXT_FIELDS / SUBSCRIPTION_CONTEXT_FIELDS. +# A subscription's inputs_fields template may only reference these context keys; +# ca_*/secrets/connection internals are never exposed. +# --------------------------------------------------------------------------- + +TRIGGER_EVENT_FIELDS = { + "data", + "type", + "timestamp", + "metadata", +} + +SUBSCRIPTION_CONTEXT_FIELDS = { + "id", + "name", + "tags", + "meta", + "created_at", + "updated_at", +} + + +# --------------------------------------------------------------------------- +# Trigger Subscriptions +# +# A standing watch on one provider event. Mirrors a webhook subscription +# (subscribe-to-events lifecycle, CRUD) + FK to the shared gateway_connections +# row + a bound workflow reference. The provider-side trigger instance id +# (``ti_*``) lives on the row alongside its ``trigger_config``. +# --------------------------------------------------------------------------- + + +class TriggerSubscriptionData(BaseModel): + event_key: str + # + ti_id: Optional[str] = None + trigger_config: Optional[Dict[str, Any]] = None + # + # MAPPING — inputs-only template resolved into WorkflowServiceRequest.data.inputs. + inputs_fields: Optional[Dict[str, Any]] = None + # + # DESTINATION — the bound workflow, by reference (the /retrieve shape). + references: Optional[Dict[str, Reference]] = None + selector: Optional[Selector] = None + + +class TriggerSubscription(Identifier, Lifecycle, Header, Metadata): + connection_id: UUID + # + data: TriggerSubscriptionData + # + enabled: bool = True + valid: bool = True + + +class TriggerSubscriptionCreate(Header, Metadata): + connection_id: UUID + # + data: TriggerSubscriptionData + + +class TriggerSubscriptionEdit(Identifier, Header, Metadata): + connection_id: UUID + # + data: TriggerSubscriptionData + # + enabled: bool = True + valid: bool = True + + +class TriggerSubscriptionQuery(BaseModel): + name: Optional[str] = None + connection_id: Optional[UUID] = None + event_key: Optional[str] = None + + +# --------------------------------------------------------------------------- +# Trigger Deliveries +# +# One audit row per inbound event dispatched to its workflow — the inbound dual +# of webhook_deliveries. ``event_id`` is the I4 dedup key (provider metadata.id), +# unique per subscription. +# --------------------------------------------------------------------------- + + +class TriggerDeliveryData(BaseModel): + event_key: Optional[str] = None + # + references: Optional[Dict[str, Reference]] = None + inputs: Optional[Dict[str, Any]] = None + # + result: Optional[Dict[str, Any]] = None + error: Optional[str] = None + + +class TriggerDelivery(Identifier, Lifecycle): + status: Status + + data: Optional[TriggerDeliveryData] = None + + subscription_id: UUID + event_id: str + + +class TriggerDeliveryCreate(Identifier): + status: Status + + data: Optional[TriggerDeliveryData] = None + + subscription_id: UUID + event_id: str + + +class TriggerDeliveryQuery(BaseModel): + status: Optional[Status] = None + + subscription_id: Optional[UUID] = None + event_id: Optional[str] = None diff --git a/api/oss/src/core/triggers/exceptions.py b/api/oss/src/core/triggers/exceptions.py new file mode 100644 index 0000000000..092144ceff --- /dev/null +++ b/api/oss/src/core/triggers/exceptions.py @@ -0,0 +1,52 @@ +from typing import Optional + + +class TriggersError(Exception): + """Base exception for the triggers domain.""" + + def __init__(self, message: str = "Triggers error"): + self.message = message + super().__init__(self.message) + + +class ProviderNotFoundError(TriggersError): + """Raised when the requested provider_key has no registered adapter.""" + + def __init__(self, provider_key: str): + self.provider_key = provider_key + super().__init__(f"Provider not found: {provider_key}") + + +class SubscriptionNotFoundError(TriggersError): + """Raised when a subscription_id does not exist in the project.""" + + def __init__(self, *, subscription_id: str): + self.subscription_id = subscription_id + super().__init__(f"Trigger subscription not found: {subscription_id}") + + +class ConnectionNotFoundError(TriggersError): + """Raised when a subscription references a connection that does not exist.""" + + def __init__(self, *, connection_id: str): + self.connection_id = connection_id + super().__init__(f"Connection not found: {connection_id}") + + +class AdapterError(TriggersError): + """Raised when an adapter operation fails.""" + + def __init__( + self, + *, + provider_key: str, + operation: str, + detail: Optional[str] = None, + ): + self.provider_key = provider_key + self.operation = operation + self.detail = detail + msg = f"Adapter error ({provider_key}.{operation})" + if detail: + msg += f": {detail}" + super().__init__(msg) diff --git a/api/oss/src/core/triggers/interfaces.py b/api/oss/src/core/triggers/interfaces.py new file mode 100644 index 0000000000..5221b52349 --- /dev/null +++ b/api/oss/src/core/triggers/interfaces.py @@ -0,0 +1,203 @@ +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional, Tuple +from uuid import UUID + +from oss.src.core.shared.dtos import Windowing +from oss.src.core.triggers.dtos import ( + TriggerCatalogEvent, + TriggerCatalogEventDetails, + TriggerCatalogProvider, + TriggerDelivery, + TriggerDeliveryCreate, + TriggerDeliveryQuery, + TriggerSubscription, + TriggerSubscriptionCreate, + TriggerSubscriptionEdit, + TriggerSubscriptionQuery, +) + + +class TriggersGatewayInterface(ABC): + """Port for external trigger providers (Composio, ...). + + FROZEN (WS-PRE) — consumed by WS3 (subscriptions) and WS5 (web catalog). + The catalog reads (``list_events``/``get_event``) back the events catalog; + the subscription verbs build/manage the provider-side trigger instance + (``ti_*``) that WP3 stores on a local subscription row. + """ + + @abstractmethod + async def list_providers(self) -> List[TriggerCatalogProvider]: ... + + @abstractmethod + async def list_events( + self, + *, + integration_key: str, + query: Optional[str] = None, + limit: Optional[int] = None, + cursor: Optional[str] = None, + ) -> Tuple[List[TriggerCatalogEvent], Optional[str], int]: + """Returns (items, next_cursor, total_items).""" + ... + + @abstractmethod + async def get_event( + self, + *, + integration_key: str, + event_key: str, + ) -> Optional[TriggerCatalogEventDetails]: + """Return one event's detail, carrying its trigger_config JSON Schema.""" + ... + + @abstractmethod + async def create_subscription( + self, + *, + project_id: UUID, + event_key: str, + connected_account_id: str, + trigger_config: Dict[str, Any], + ) -> str: + """Create the provider-side trigger instance; returns its id (``ti_*``).""" + ... + + @abstractmethod + async def set_subscription_status( + self, + *, + trigger_id: str, + enabled: bool, + ) -> None: + """Enable or disable the provider-side trigger instance.""" + ... + + @abstractmethod + async def delete_subscription( + self, + *, + trigger_id: str, + ) -> None: + """Permanently delete the provider-side trigger instance.""" + ... + + +class TriggersDAOInterface(ABC): + """Persistence contract for the triggers domain (subscriptions + deliveries).""" + + # --- subscriptions ------------------------------------------------------ # + + @abstractmethod + async def create_subscription( + self, + *, + project_id: UUID, + user_id: UUID, + # + subscription: TriggerSubscriptionCreate, + # + ti_id: str, + ) -> TriggerSubscription: ... + + @abstractmethod + async def fetch_subscription( + self, + *, + project_id: UUID, + # + subscription_id: UUID, + ) -> Optional[TriggerSubscription]: ... + + @abstractmethod + async def edit_subscription( + self, + *, + project_id: UUID, + user_id: UUID, + # + subscription: TriggerSubscriptionEdit, + ) -> Optional[TriggerSubscription]: ... + + @abstractmethod + async def delete_subscription( + self, + *, + project_id: UUID, + # + subscription_id: UUID, + ) -> bool: ... + + @abstractmethod + async def query_subscriptions( + self, + *, + project_id: UUID, + # + subscription: Optional[TriggerSubscriptionQuery] = None, + # + windowing: Optional[Windowing] = None, + ) -> List[TriggerSubscription]: ... + + @abstractmethod + async def get_subscription_by_trigger_id( + self, + *, + trigger_id: str, + ) -> Optional[TriggerSubscription]: + """FROZEN (WP4): resolve an inbound event's ``ti_*`` to its local row.""" + ... + + @abstractmethod + async def get_project_and_subscription_by_trigger_id( + self, + *, + trigger_id: str, + ) -> Optional[Tuple[UUID, TriggerSubscription]]: + """Resolve a ``ti_*`` to its (project_id, subscription); the DTO omits project scope.""" + ... + + # --- deliveries --------------------------------------------------------- # + + @abstractmethod + async def write_delivery( + self, + *, + project_id: UUID, + user_id: Optional[UUID], + # + delivery: TriggerDeliveryCreate, + ) -> TriggerDelivery: + """FROZEN (WP4): upsert a delivery row (idempotent on event_id).""" + ... + + @abstractmethod + async def fetch_delivery( + self, + *, + project_id: UUID, + # + delivery_id: UUID, + ) -> Optional[TriggerDelivery]: ... + + @abstractmethod + async def query_deliveries( + self, + *, + project_id: UUID, + # + delivery: Optional[TriggerDeliveryQuery] = None, + # + windowing: Optional[Windowing] = None, + ) -> List[TriggerDelivery]: ... + + @abstractmethod + async def dedup_seen( + self, + *, + project_id: UUID, + subscription_id: UUID, + event_id: str, + ) -> bool: + """FROZEN (WP4): True if a delivery for this event_id already exists (I4).""" + ... diff --git a/api/oss/src/core/triggers/providers/__init__.py b/api/oss/src/core/triggers/providers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/src/core/triggers/providers/composio/__init__.py b/api/oss/src/core/triggers/providers/composio/__init__.py new file mode 100644 index 0000000000..9841fc07c1 --- /dev/null +++ b/api/oss/src/core/triggers/providers/composio/__init__.py @@ -0,0 +1,18 @@ +# Avoid importing adapter here to prevent SDK dependency issues in standalone scripts. +# Import directly when needed: +# from oss.src.core.triggers.providers.composio.adapter import ComposioTriggersAdapter + +__all__ = [ + "ComposioTriggersAdapter", +] + + +def __getattr__(name): + """Lazy import to avoid SDK dependency on module import.""" + if name == "ComposioTriggersAdapter": + from oss.src.core.triggers.providers.composio.adapter import ( + ComposioTriggersAdapter, + ) + + return ComposioTriggersAdapter + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/api/oss/src/core/triggers/providers/composio/adapter.py b/api/oss/src/core/triggers/providers/composio/adapter.py new file mode 100644 index 0000000000..20fd9dd212 --- /dev/null +++ b/api/oss/src/core/triggers/providers/composio/adapter.py @@ -0,0 +1,187 @@ +from typing import Any, Dict, List, Optional +from uuid import UUID + +import httpx + +from oss.src.utils.logging import get_module_logger + +from oss.src.core.triggers.dtos import ( + TriggerCatalogProvider, + TriggerProviderKind, +) +from oss.src.core.triggers.interfaces import TriggersGatewayInterface +from oss.src.core.triggers.exceptions import AdapterError +from oss.src.core.triggers.providers.composio.catalog import ( + ComposioTriggersCatalogClient, +) + + +log = get_module_logger(__name__) + +COMPOSIO_DEFAULT_API_URL = "https://backend.composio.dev/api/v3" + + +class ComposioTriggersAdapter(ComposioTriggersCatalogClient, TriggersGatewayInterface): + """Composio V3 triggers adapter — uses httpx directly (no SDK). + + Modeled on ``ComposioToolsAdapter``: own httpx client, ``_get/_post/_delete`` + helpers, slug passthrough. Catalog operations (list/get events) come from + ``ComposioTriggersCatalogClient``; subscription (trigger-instance) management + is implemented here and consumed by WP3. + + REST paths (E5 — verified vs the live Composio API reference): + list events GET /triggers_types?toolkit_slugs={i} + get event GET /triggers_types/{slug} + create/upsert POST /trigger_instances/{slug}/upsert + enable/disable PATCH /trigger_instances/manage/{trigger_id} + delete DELETE /trigger_instances/manage/{trigger_id} + """ + + def __init__( + self, + *, + api_key: str, + api_url: str = COMPOSIO_DEFAULT_API_URL, + ): + self.api_key = api_key + self.api_url = api_url.rstrip("/") + # Shared client — one connection pool for the adapter's lifetime. + # Call close() on shutdown (wired in entrypoints/routers.py lifespan). + self._client = httpx.AsyncClient(timeout=30.0) + + async def close(self) -> None: + """Close the shared HTTP client and release connection pool resources.""" + await self._client.aclose() + + def _headers(self) -> Dict[str, str]: + return { + "x-api-key": self.api_key, + "Content-Type": "application/json", + } + + async def _post( + self, + path: str, + *, + json: Optional[Dict[str, Any]] = None, + ) -> Any: + resp = await self._client.post( + f"{self.api_url}{path}", + headers=self._headers(), + json=json or {}, + ) + if not resp.is_success: + log.error("Composio POST %s → %s: %s", path, resp.status_code, resp.text) + resp.raise_for_status() + return resp.json() + + async def _patch( + self, + path: str, + *, + json: Optional[Dict[str, Any]] = None, + ) -> Any: + resp = await self._client.patch( + f"{self.api_url}{path}", + headers=self._headers(), + json=json or {}, + ) + if not resp.is_success: + log.error("Composio PATCH %s → %s: %s", path, resp.status_code, resp.text) + resp.raise_for_status() + return resp.json() + + async def _delete(self, path: str) -> bool: + resp = await self._client.delete( + f"{self.api_url}{path}", + headers=self._headers(), + ) + resp.raise_for_status() + return True + + # ----------------------------------------------------------------------- + # Catalog — provider listing + # ----------------------------------------------------------------------- + + async def list_providers(self) -> List[TriggerCatalogProvider]: + return [ + TriggerCatalogProvider( + key=TriggerProviderKind.COMPOSIO, + name="Composio", + description="Third-party event triggers via Composio", + ) + ] + + # list_events and get_event are inherited from ComposioTriggersCatalogClient + # and satisfy the TriggersGatewayInterface catalog contract. + + # ----------------------------------------------------------------------- + # Subscriptions (provider-side trigger instances — ti_*) — consumed by WP3 + # ----------------------------------------------------------------------- + + async def create_subscription( + self, + *, + project_id: UUID, + event_key: str, + connected_account_id: str, + trigger_config: Dict[str, Any], + ) -> str: + """Create/upsert the provider-side trigger instance; return its id (ti_*).""" + payload: Dict[str, Any] = { + "connected_account_id": connected_account_id, + "trigger_config": trigger_config or {}, + } + try: + result = await self._post( + f"/trigger_instances/{event_key}/upsert", + json=payload, + ) + except httpx.HTTPError as e: + raise AdapterError( + provider_key="composio", + operation="create_subscription", + detail=str(e), + ) from e + + trigger_id = result.get("trigger_id") or result.get("id") + if not trigger_id: + raise AdapterError( + provider_key="composio", + operation="create_subscription", + detail=f"No trigger_id in upsert response for event '{event_key}'", + ) + return trigger_id + + async def set_subscription_status( + self, + *, + trigger_id: str, + enabled: bool, + ) -> None: + status = "enable" if enabled else "disable" + try: + await self._patch( + f"/trigger_instances/manage/{trigger_id}", + json={"status": status}, + ) + except httpx.HTTPError as e: + raise AdapterError( + provider_key="composio", + operation="set_subscription_status", + detail=str(e), + ) from e + + async def delete_subscription( + self, + *, + trigger_id: str, + ) -> None: + try: + await self._delete(f"/trigger_instances/manage/{trigger_id}") + except httpx.HTTPError as e: + raise AdapterError( + provider_key="composio", + operation="delete_subscription", + detail=str(e), + ) from e diff --git a/api/oss/src/core/triggers/providers/composio/catalog.py b/api/oss/src/core/triggers/providers/composio/catalog.py new file mode 100644 index 0000000000..f773fab8ec --- /dev/null +++ b/api/oss/src/core/triggers/providers/composio/catalog.py @@ -0,0 +1,188 @@ +"""Composio triggers catalog operations — mixin for ComposioTriggersAdapter. + +Provides catalog HTTP calls (list events, get one event) backed by +``self._client``, ``self.api_key``, and ``self.api_url`` which must be supplied +by the concrete subclass (ComposioTriggersAdapter). + +Mirrors ``core/tools/providers/composio/catalog.py`` with ``action → event``: +the tools "action" leaf becomes the triggers "event" leaf (a Composio *trigger +type*), and an action's ``input_parameters`` schema becomes an event's +``trigger_config`` schema. The ``cursor`` value is Composio's native +``next_cursor`` string, passed through as-is. +""" + +from typing import Any, Dict, List, Optional, Tuple + +import httpx + +from oss.src.utils.logging import get_module_logger +from oss.src.core.triggers.dtos import ( + TriggerCatalogEvent, + TriggerCatalogEventDetails, +) +from oss.src.core.triggers.exceptions import AdapterError + + +log = get_module_logger(__name__) + +DEFAULT_PAGE_SIZE = 20 +MAX_PAGE_SIZE = 1000 + + +class ComposioTriggersCatalogClient: + """Catalog mixin for ComposioTriggersAdapter — cursor-based pagination. + + Subclass must set ``self.api_key``, ``self.api_url``, and ``self._client`` + (an ``httpx.AsyncClient``) before calling any method. + """ + + # Annotated for type-checkers; filled in by ComposioTriggersAdapter.__init__ + api_key: str + api_url: str + _client: httpx.AsyncClient + + async def list_events( + self, + *, + integration_key: str, + query: Optional[str] = None, + limit: Optional[int] = None, + cursor: Optional[str] = None, + ) -> Tuple[List[TriggerCatalogEvent], Optional[str], int]: + """Fetch one page of events (Composio trigger types) for an integration. + + E5 (verified vs live Composio API reference): GET /triggers_types, + filtered by ``toolkit_slugs``. + """ + page_limit = min(limit, MAX_PAGE_SIZE) if limit else DEFAULT_PAGE_SIZE + + params: Dict[str, Any] = { + "toolkit_slugs": integration_key, + "limit": page_limit, + } + if query: + params["query"] = query + if cursor: + params["cursor"] = cursor + + try: + resp = await self._client.get( + f"{self.api_url}/triggers_types", + headers={"x-api-key": self.api_key, "Content-Type": "application/json"}, + params=params, + timeout=30.0, + ) + resp.raise_for_status() + data = resp.json() + except httpx.HTTPError as e: + raise AdapterError( + provider_key="composio", + operation="list_events", + detail=str(e), + ) from e + + items_raw: List[Dict[str, Any]] = ( + data.get("items", []) if isinstance(data, dict) else data + ) + next_cursor: Optional[str] = ( + data.get("next_cursor") if isinstance(data, dict) else None + ) + total_items: int = ( + data.get("total_items", len(items_raw)) + if isinstance(data, dict) + else len(items_raw) + ) + + items = [_parse_event(item, integration_key) for item in items_raw] + + log.debug( + "[composio] list_events(%s) cursor=%s items=%d total=%d next=%s", + integration_key, + cursor, + len(items), + total_items, + next_cursor, + ) + + return items, next_cursor, total_items + + async def get_event( + self, + *, + integration_key: str, + event_key: str, + ) -> Optional[TriggerCatalogEventDetails]: + """Fetch one event (trigger type) by slug, with its trigger_config schema. + + E5 (verified vs live Composio API reference): GET /triggers_types/{slug}. + Returns None when the event does not exist (404). + """ + try: + resp = await self._client.get( + f"{self.api_url}/triggers_types/{event_key}", + headers={"x-api-key": self.api_key, "Content-Type": "application/json"}, + timeout=15.0, + ) + if resp.status_code == 404: + return None + resp.raise_for_status() + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + return None + raise AdapterError( + provider_key="composio", + operation="get_event", + detail=str(e), + ) from e + except httpx.HTTPError as e: + raise AdapterError( + provider_key="composio", + operation="get_event", + detail=str(e), + ) from e + + return _parse_event_detail(resp.json(), integration_key) + + +# --------------------------------------------------------------------------- +# Parsers (module-level — no instance state needed) +# --------------------------------------------------------------------------- + + +def _toolkit_slug(item: Dict[str, Any], fallback: str) -> str: + toolkit = item.get("toolkit") + if isinstance(toolkit, dict): + return toolkit.get("slug") or toolkit.get("name") or fallback + if isinstance(toolkit, str): + return toolkit + return fallback + + +def _parse_event(item: Dict[str, Any], integration_key: str) -> TriggerCatalogEvent: + return TriggerCatalogEvent( + key=item.get("slug", ""), + name=item.get("name", ""), + description=item.get("description"), + provider="composio", + integration=_toolkit_slug(item, integration_key), + ) + + +def _parse_event_detail( + item: Dict[str, Any], + integration_key: str, +) -> TriggerCatalogEventDetails: + # The event's required config is the JSON Schema under "config" — the inbound + # analogue of an action's "input_parameters". + trigger_config = item.get("config") or item.get("trigger_config") + payload = item.get("payload") + + return TriggerCatalogEventDetails( + key=item.get("slug", ""), + name=item.get("name", ""), + description=item.get("description"), + provider="composio", + integration=_toolkit_slug(item, integration_key), + trigger_config=trigger_config, + payload=payload, + ) diff --git a/api/oss/src/core/triggers/registry.py b/api/oss/src/core/triggers/registry.py new file mode 100644 index 0000000000..4e641f6202 --- /dev/null +++ b/api/oss/src/core/triggers/registry.py @@ -0,0 +1,27 @@ +from typing import Dict, ItemsView + +from oss.src.core.triggers.interfaces import TriggersGatewayInterface +from oss.src.core.triggers.exceptions import ProviderNotFoundError + + +class TriggersGatewayRegistry: + """Dispatches to the correct adapter based on provider_key.""" + + def __init__( + self, + *, + adapters: Dict[str, TriggersGatewayInterface], + ): + self._adapters = adapters + + def get(self, provider_key: str) -> TriggersGatewayInterface: + adapter = self._adapters.get(provider_key) + if not adapter: + raise ProviderNotFoundError(provider_key) + return adapter + + def keys(self) -> list[str]: + return list(self._adapters.keys()) + + def items(self) -> ItemsView[str, TriggersGatewayInterface]: + return self._adapters.items() diff --git a/api/oss/src/core/triggers/service.py b/api/oss/src/core/triggers/service.py new file mode 100644 index 0000000000..349f5fd889 --- /dev/null +++ b/api/oss/src/core/triggers/service.py @@ -0,0 +1,390 @@ +from typing import List, Optional, Tuple +from uuid import UUID + +from oss.src.utils.logging import get_module_logger + +from oss.src.core.gateway.connections.service import ConnectionsService +from oss.src.core.triggers.dtos import ( + TriggerCatalogEvent, + TriggerCatalogEventDetails, + TriggerCatalogProvider, + TriggerDelivery, + TriggerDeliveryCreate, + TriggerDeliveryQuery, + TriggerSubscription, + TriggerSubscriptionCreate, + TriggerSubscriptionEdit, + TriggerSubscriptionQuery, +) +from oss.src.core.triggers.exceptions import ( + ConnectionNotFoundError, + SubscriptionNotFoundError, +) +from oss.src.core.triggers.interfaces import TriggersDAOInterface +from oss.src.core.triggers.registry import TriggersGatewayRegistry +from oss.src.core.shared.dtos import Windowing + + +log = get_module_logger(__name__) + + +class TriggersService: + """Triggers domain orchestration. + + Covers the read-only events catalog (WP1) and subscription/delivery + CRUD (WP3). Subscriptions bind a provider event to a workflow on top of a + shared gateway connection; the provider-side trigger instance (``ti_*``) is + minted/managed through the adapter, never the catalog routes. + """ + + def __init__( + self, + *, + adapter_registry: TriggersGatewayRegistry, + triggers_dao: Optional[TriggersDAOInterface] = None, + connections_service: Optional[ConnectionsService] = None, + ): + self.adapter_registry = adapter_registry + self.dao = triggers_dao + self.connections_service = connections_service + + # ----------------------------------------------------------------------- + # Catalog browse + # ----------------------------------------------------------------------- + + async def list_providers(self) -> List[TriggerCatalogProvider]: + """Return all providers across registered adapters.""" + results: List[TriggerCatalogProvider] = [] + for _key, adapter in self.adapter_registry.items(): + providers = await adapter.list_providers() + results.extend(providers) + return results + + async def get_provider( + self, + *, + provider_key: str, + ) -> Optional[TriggerCatalogProvider]: + """Return a single provider by key. + + Raises ``ProviderNotFoundError`` for an unregistered key (mapped to 404 + at the router); returns None when the adapter has no matching provider. + """ + adapter = self.adapter_registry.get(provider_key) + providers = await adapter.list_providers() + for p in providers: + if p.key == provider_key: + return p + return None + + async def list_events( + self, + *, + provider_key: str, + integration_key: str, + # + query: Optional[str] = None, + limit: Optional[int] = None, + cursor: Optional[str] = None, + ) -> Tuple[List[TriggerCatalogEvent], Optional[str], int]: + """List events for an integration with optional search and pagination.""" + adapter = self.adapter_registry.get(provider_key) + return await adapter.list_events( + integration_key=integration_key, + query=query, + limit=limit, + cursor=cursor, + ) + + async def get_event( + self, + *, + provider_key: str, + integration_key: str, + event_key: str, + ) -> Optional[TriggerCatalogEventDetails]: + """Return full event detail including its trigger_config schema, or None.""" + adapter = self.adapter_registry.get(provider_key) + return await adapter.get_event( + integration_key=integration_key, + event_key=event_key, + ) + + # ----------------------------------------------------------------------- + # Subscriptions + # ----------------------------------------------------------------------- + + async def _require_connection( + self, + *, + project_id: UUID, + connection_id: UUID, + ): + connection = await self.connections_service.get_connection( + project_id=project_id, + connection_id=connection_id, + ) + if not connection: + raise ConnectionNotFoundError(connection_id=str(connection_id)) + return connection + + async def create_subscription( + self, + *, + project_id: UUID, + user_id: UUID, + # + subscription: TriggerSubscriptionCreate, + ) -> TriggerSubscription: + """Mint the provider-side ``ti_*`` on a shared connection, then persist.""" + connection = await self._require_connection( + project_id=project_id, + connection_id=subscription.connection_id, + ) + + adapter = self.adapter_registry.get(connection.provider_key.value) + + ti_id = await adapter.create_subscription( + project_id=project_id, + event_key=subscription.data.event_key, + connected_account_id=connection.provider_connection_id, + trigger_config=subscription.data.trigger_config or {}, + ) + + return await self.dao.create_subscription( + project_id=project_id, + user_id=user_id, + # + subscription=subscription, + # + ti_id=ti_id, + ) + + async def fetch_subscription( + self, + *, + project_id: UUID, + # + subscription_id: UUID, + ) -> Optional[TriggerSubscription]: + return await self.dao.fetch_subscription( + project_id=project_id, + subscription_id=subscription_id, + ) + + async def query_subscriptions( + self, + *, + project_id: UUID, + # + subscription: Optional[TriggerSubscriptionQuery] = None, + # + windowing: Optional[Windowing] = None, + ) -> List[TriggerSubscription]: + return await self.dao.query_subscriptions( + project_id=project_id, + subscription=subscription, + windowing=windowing, + ) + + async def edit_subscription( + self, + *, + project_id: UUID, + user_id: UUID, + # + subscription: TriggerSubscriptionEdit, + ) -> Optional[TriggerSubscription]: + """Full-PUT edit. Reflects the enabled flag onto the provider ``ti_*``.""" + existing = await self.dao.fetch_subscription( + project_id=project_id, + subscription_id=subscription.id, + ) + if existing is None: + return None + + ti_id = existing.data.ti_id + if ti_id is not None and subscription.enabled != existing.enabled: + connection = await self._require_connection( + project_id=project_id, + connection_id=existing.connection_id, + ) + adapter = self.adapter_registry.get(connection.provider_key.value) + await adapter.set_subscription_status( + trigger_id=ti_id, + enabled=subscription.enabled, + ) + + return await self.dao.edit_subscription( + project_id=project_id, + user_id=user_id, + subscription=subscription, + ) + + async def delete_subscription( + self, + *, + project_id: UUID, + # + subscription_id: UUID, + ) -> bool: + """Delete the local row and the provider ``ti_*``. + + Deleting a subscription must NOT revoke the shared connection (C7): the + adapter call below targets only the trigger instance, never the ``ca_*``. + """ + existing = await self.dao.fetch_subscription( + project_id=project_id, + subscription_id=subscription_id, + ) + if existing is None: + return False + + ti_id = existing.data.ti_id + if ti_id is not None: + connection = await self.connections_service.get_connection( + project_id=project_id, + connection_id=existing.connection_id, + ) + if connection is not None: + adapter = self.adapter_registry.get(connection.provider_key.value) + try: + await adapter.delete_subscription(trigger_id=ti_id) + except Exception: + log.warning( + "Failed to delete provider trigger %s; proceeding with local delete", + ti_id, + ) + + return await self.dao.delete_subscription( + project_id=project_id, + subscription_id=subscription_id, + ) + + async def refresh_subscription( + self, + *, + project_id: UUID, + user_id: UUID, + # + subscription_id: UUID, + ) -> TriggerSubscription: + """Re-enable the provider ``ti_*`` and mark the row enabled+valid.""" + return await self._set_enabled( + project_id=project_id, + user_id=user_id, + subscription_id=subscription_id, + enabled=True, + ) + + async def revoke_subscription( + self, + *, + project_id: UUID, + user_id: UUID, + # + subscription_id: UUID, + ) -> TriggerSubscription: + """Disable the provider ``ti_*`` and mark the row disabled. + + Local + provider trigger-instance only; the shared connection is never + touched (C7). + """ + return await self._set_enabled( + project_id=project_id, + user_id=user_id, + subscription_id=subscription_id, + enabled=False, + ) + + async def _set_enabled( + self, + *, + project_id: UUID, + user_id: UUID, + subscription_id: UUID, + enabled: bool, + ) -> TriggerSubscription: + existing = await self.dao.fetch_subscription( + project_id=project_id, + subscription_id=subscription_id, + ) + if existing is None: + raise SubscriptionNotFoundError(subscription_id=str(subscription_id)) + + ti_id = existing.data.ti_id + if ti_id is not None: + connection = await self._require_connection( + project_id=project_id, + connection_id=existing.connection_id, + ) + adapter = self.adapter_registry.get(connection.provider_key.value) + await adapter.set_subscription_status( + trigger_id=ti_id, + enabled=enabled, + ) + + edit = TriggerSubscriptionEdit( + id=existing.id, + connection_id=existing.connection_id, + name=existing.name, + description=existing.description, + tags=existing.tags, + meta=existing.meta, + data=existing.data, + enabled=enabled, + valid=existing.valid, + ) + + updated = await self.dao.edit_subscription( + project_id=project_id, + user_id=user_id, + subscription=edit, + ) + + return updated or existing + + # ----------------------------------------------------------------------- + # Deliveries + # ----------------------------------------------------------------------- + + async def fetch_delivery( + self, + *, + project_id: UUID, + # + delivery_id: UUID, + ) -> Optional[TriggerDelivery]: + return await self.dao.fetch_delivery( + project_id=project_id, + delivery_id=delivery_id, + ) + + async def query_deliveries( + self, + *, + project_id: UUID, + # + delivery: Optional[TriggerDeliveryQuery] = None, + # + windowing: Optional[Windowing] = None, + ) -> List[TriggerDelivery]: + return await self.dao.query_deliveries( + project_id=project_id, + delivery=delivery, + windowing=windowing, + ) + + async def write_delivery( + self, + *, + project_id: UUID, + user_id: Optional[UUID] = None, + # + delivery: TriggerDeliveryCreate, + ) -> TriggerDelivery: + return await self.dao.write_delivery( + project_id=project_id, + user_id=user_id, + delivery=delivery, + ) diff --git a/api/oss/src/core/webhooks/delivery.py b/api/oss/src/core/webhooks/delivery.py index 280c3e1a8b..9ca44f3e87 100644 --- a/api/oss/src/core/webhooks/delivery.py +++ b/api/oss/src/core/webhooks/delivery.py @@ -8,7 +8,7 @@ import httpx -from agenta.sdk.utils.resolvers import resolve_json_selector +from agenta.sdk.utils.resolvers import resolve_target_fields from oss.src.core.webhooks.types import ( EVENT_CONTEXT_FIELDS, @@ -23,8 +23,6 @@ log = get_module_logger(__name__) -MAX_RESOLVE_DEPTH = 10 - NON_OVERRIDABLE_HEADERS = { "content-type", "content-length", @@ -92,29 +90,6 @@ def _merge_headers( return merged -def resolve_payload_fields( - fields: Any, - context: Dict[str, Any], - *, - _depth: int = 0, -) -> Any: - if _depth > MAX_RESOLVE_DEPTH: - return None - if isinstance(fields, dict): - return { - k: resolve_payload_fields(v, context, _depth=_depth + 1) - for k, v in fields.items() - } - if isinstance(fields, list): - return [ - resolve_payload_fields(item, context, _depth=_depth + 1) for item in fields - ] - try: - return resolve_json_selector(fields, context) - except Exception: - return None - - def prepare_webhook_request( *, project_id: UUID, @@ -147,7 +122,7 @@ def prepare_webhook_request( } resolved_fields = payload_fields if payload_fields is not None else "$" - payload = resolve_payload_fields(resolved_fields, context) + payload = resolve_target_fields(resolved_fields, context) base_data = WebhookDeliveryData( event_type=typed_event_type, diff --git a/api/oss/src/dbs/postgres/gateway/__init__.py b/api/oss/src/dbs/postgres/gateway/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/src/dbs/postgres/gateway/connections/__init__.py b/api/oss/src/dbs/postgres/gateway/connections/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/src/dbs/postgres/tools/dao.py b/api/oss/src/dbs/postgres/gateway/connections/dao.py similarity index 74% rename from api/oss/src/dbs/postgres/tools/dao.py rename to api/oss/src/dbs/postgres/gateway/connections/dao.py index c3cefe279c..2441a20fdc 100644 --- a/api/oss/src/dbs/postgres/tools/dao.py +++ b/api/oss/src/dbs/postgres/gateway/connections/dao.py @@ -1,282 +1,282 @@ -from typing import List, Optional -from datetime import datetime, timezone -from uuid import UUID - -from sqlalchemy import select, delete -from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm.attributes import flag_modified - -from oss.src.utils.logging import get_module_logger -from oss.src.utils.exceptions import suppress_exceptions - -from oss.src.core.shared.exceptions import EntityCreationConflict -from oss.src.core.tools.interfaces import ToolsDAOInterface -from oss.src.core.tools.dtos import ( - ToolConnection, - ToolConnectionCreate, -) - -from oss.src.dbs.postgres.shared.engine import ( - TransactionsEngine, - get_transactions_engine, -) -from oss.src.dbs.postgres.tools.dbes import ToolConnectionDBE -from oss.src.dbs.postgres.tools.mappings import ( - map_connection_create_to_dbe, - map_connection_dbe_to_dto, -) - - -log = get_module_logger(__name__) - - -class ToolsDAO(ToolsDAOInterface): - def __init__( - self, - *, - ToolConnectionDBE: type = ToolConnectionDBE, - engine: TransactionsEngine = None, - ): - self.ToolConnectionDBE = ToolConnectionDBE - if engine is None: - engine = get_transactions_engine() - self.engine = engine - - @suppress_exceptions(exclude=[EntityCreationConflict]) - async def create_connection( - self, - *, - project_id: UUID, - user_id: UUID, - # - connection_create: ToolConnectionCreate, - ) -> Optional[ToolConnection]: - """Insert a new connection row. Raises EntityCreationConflict on slug collision.""" - dbe = map_connection_create_to_dbe( - project_id=project_id, - user_id=user_id, - # - dto=connection_create, - ) - - try: - async with self.engine.session() as session: - session.add(dbe) - await session.commit() - await session.refresh(dbe) - - return map_connection_dbe_to_dto(dbe=dbe) - - except IntegrityError as e: - error_str = str(e.orig) if e.orig else str(e) - if "uq_tool_connections_project_provider_integration_slug" in error_str: - raise EntityCreationConflict( - entity="ToolConnection", - message="ToolConnection with slug '{{slug}}' already exists for this integration.".replace( - "{{slug}}", connection_create.slug - ), - conflict={ - "provider_key": connection_create.provider_key, - "integration_key": connection_create.integration_key, - "slug": connection_create.slug, - }, - ) from e - raise - - @suppress_exceptions(default=None) - async def get_connection( - self, - *, - project_id: UUID, - connection_id: UUID, - ) -> Optional[ToolConnection]: - """Fetch a connection by ID scoped to project_id. Returns None if not found.""" - async with self.engine.session() as session: - stmt = ( - select(self.ToolConnectionDBE) - .filter(self.ToolConnectionDBE.project_id == project_id) - .filter(self.ToolConnectionDBE.id == connection_id) - .limit(1) - ) - - result = await session.execute(stmt) - dbe = result.scalars().first() - - if not dbe: - return None - - return map_connection_dbe_to_dto(dbe=dbe) - - @suppress_exceptions(default=None) - async def update_connection( - self, - *, - project_id: UUID, - connection_id: UUID, - # - is_valid: Optional[bool] = None, - is_active: Optional[bool] = None, - provider_connection_id: Optional[str] = None, - data_update: Optional[dict] = None, - ) -> Optional[ToolConnection]: - """Partially update flags and/or data for a connection. Returns updated DTO or None.""" - async with self.engine.session() as session: - stmt = ( - select(self.ToolConnectionDBE) - .filter(self.ToolConnectionDBE.project_id == project_id) - .filter(self.ToolConnectionDBE.id == connection_id) - .limit(1) - ) - - result = await session.execute(stmt) - dbe = result.scalars().first() - - if not dbe: - return None - - # Update flags - if is_valid is not None or is_active is not None: - flags = {**(dbe.flags or {})} - if is_valid is not None: - flags["is_valid"] = is_valid - if is_active is not None: - flags["is_active"] = is_active - dbe.flags = flags - flag_modified(dbe, "flags") - - # Update data fields - data_patch: dict = {} - if provider_connection_id is not None: - data_patch["connected_account_id"] = provider_connection_id - if data_update: - data_patch.update(data_update) - if data_patch: - dbe.data = {**(dbe.data or {}), **data_patch} - flag_modified(dbe, "data") - - dbe.updated_at = datetime.now(timezone.utc) - - await session.commit() - await session.refresh(dbe) - - return map_connection_dbe_to_dto(dbe=dbe) - - @suppress_exceptions(default=False) - async def delete_connection( - self, - *, - project_id: UUID, - connection_id: UUID, - ) -> bool: - """Hard-delete a connection row. Returns True if a row was deleted.""" - async with self.engine.session() as session: - stmt = ( - delete(self.ToolConnectionDBE) - .where(self.ToolConnectionDBE.project_id == project_id) - .where(self.ToolConnectionDBE.id == connection_id) - ) - - result = await session.execute(stmt) - await session.commit() - - return result.rowcount > 0 - - @suppress_exceptions(default=[]) - async def query_connections( - self, - *, - project_id: UUID, - # - provider_key: Optional[str] = None, - integration_key: Optional[str] = None, - is_active: Optional[bool] = True, - ) -> List[ToolConnection]: - """List connections with optional filters. Defaults to active-only (is_active=True).""" - async with self.engine.session() as session: - stmt = select(self.ToolConnectionDBE).filter( - self.ToolConnectionDBE.project_id == project_id, - ) - - if provider_key: - stmt = stmt.filter(self.ToolConnectionDBE.provider_key == provider_key) - - if integration_key: - stmt = stmt.filter( - self.ToolConnectionDBE.integration_key == integration_key - ) - - if is_active is not None: - expected = "true" if is_active else "false" - stmt = stmt.filter( - self.ToolConnectionDBE.flags["is_active"].astext == expected - ) - - stmt = stmt.order_by(self.ToolConnectionDBE.created_at.desc()) - - result = await session.execute(stmt) - dbes = result.scalars().all() - - return [map_connection_dbe_to_dto(dbe=dbe) for dbe in dbes] - - @suppress_exceptions(default=None) - async def activate_connection_by_provider_id( - self, - *, - provider_connection_id: str, - project_id: Optional[UUID] = None, - ) -> Optional[ToolConnection]: - """Set is_valid=True and is_active=True for the connection matching the provider ID.""" - async with self.engine.session() as session: - stmt = select(self.ToolConnectionDBE).filter( - self.ToolConnectionDBE.data["connected_account_id"].astext - == provider_connection_id - ) - - if project_id is not None: - stmt = stmt.filter(self.ToolConnectionDBE.project_id == project_id) - - stmt = stmt.limit(1) - - result = await session.execute(stmt) - dbe = result.scalars().first() - - if not dbe: - return None - - flags = {**(dbe.flags or {})} - flags["is_valid"] = True - flags["is_active"] = True - dbe.flags = flags - flag_modified(dbe, "flags") - - dbe.updated_at = datetime.now(timezone.utc) - - await session.commit() - await session.refresh(dbe) - - return map_connection_dbe_to_dto(dbe=dbe) - - @suppress_exceptions(default=None) - async def find_connection_by_provider_id( - self, - *, - provider_connection_id: str, - ) -> Optional[ToolConnection]: - """Lookup any connection by provider-side connected_account_id (no project scope).""" - async with self.engine.session() as session: - stmt = ( - select(self.ToolConnectionDBE) - .filter( - self.ToolConnectionDBE.data["connected_account_id"].astext - == provider_connection_id - ) - .limit(1) - ) - - result = await session.execute(stmt) - dbe = result.scalars().first() - - if not dbe: - return None - - return map_connection_dbe_to_dto(dbe=dbe) +from typing import List, Optional +from datetime import datetime, timezone +from uuid import UUID + +from sqlalchemy import select, delete +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm.attributes import flag_modified + +from oss.src.utils.logging import get_module_logger +from oss.src.utils.exceptions import suppress_exceptions + +from oss.src.core.shared.exceptions import EntityCreationConflict +from oss.src.core.gateway.connections.interfaces import ConnectionsDAOInterface +from oss.src.core.gateway.connections.dtos import ( + Connection, + ConnectionCreate, +) + +from oss.src.dbs.postgres.shared.engine import ( + TransactionsEngine, + get_transactions_engine, +) +from oss.src.dbs.postgres.gateway.connections.dbes import ConnectionDBE +from oss.src.dbs.postgres.gateway.connections.mappings import ( + map_connection_create_to_dbe, + map_connection_dbe_to_dto, +) + + +log = get_module_logger(__name__) + + +class ConnectionsDAO(ConnectionsDAOInterface): + def __init__( + self, + *, + ConnectionDBE: type = ConnectionDBE, + engine: TransactionsEngine = None, + ): + self.ConnectionDBE = ConnectionDBE + if engine is None: + engine = get_transactions_engine() + self.engine = engine + + @suppress_exceptions(exclude=[EntityCreationConflict]) + async def create_connection( + self, + *, + project_id: UUID, + user_id: UUID, + # + connection_create: ConnectionCreate, + ) -> Optional[Connection]: + """Insert a new connection row. Raises EntityCreationConflict on slug collision.""" + dbe = map_connection_create_to_dbe( + project_id=project_id, + user_id=user_id, + # + dto=connection_create, + ) + + try: + async with self.engine.session() as session: + session.add(dbe) + await session.commit() + await session.refresh(dbe) + + return map_connection_dbe_to_dto(dbe=dbe) + + except IntegrityError as e: + error_str = str(e.orig) if e.orig else str(e) + if "uq_gateway_connections_project_provider_integration_slug" in error_str: + raise EntityCreationConflict( + entity="Connection", + message="Connection with slug '{{slug}}' already exists for this integration.".replace( + "{{slug}}", connection_create.slug + ), + conflict={ + "provider_key": connection_create.provider_key, + "integration_key": connection_create.integration_key, + "slug": connection_create.slug, + }, + ) from e + raise + + @suppress_exceptions(default=None) + async def get_connection( + self, + *, + project_id: UUID, + connection_id: UUID, + ) -> Optional[Connection]: + """Fetch a connection by ID scoped to project_id. Returns None if not found.""" + async with self.engine.session() as session: + stmt = ( + select(self.ConnectionDBE) + .filter(self.ConnectionDBE.project_id == project_id) + .filter(self.ConnectionDBE.id == connection_id) + .limit(1) + ) + + result = await session.execute(stmt) + dbe = result.scalars().first() + + if not dbe: + return None + + return map_connection_dbe_to_dto(dbe=dbe) + + @suppress_exceptions(default=None) + async def update_connection( + self, + *, + project_id: UUID, + connection_id: UUID, + # + is_valid: Optional[bool] = None, + is_active: Optional[bool] = None, + provider_connection_id: Optional[str] = None, + data_update: Optional[dict] = None, + ) -> Optional[Connection]: + """Partially update flags and/or data for a connection. Returns updated DTO or None.""" + async with self.engine.session() as session: + stmt = ( + select(self.ConnectionDBE) + .filter(self.ConnectionDBE.project_id == project_id) + .filter(self.ConnectionDBE.id == connection_id) + .limit(1) + ) + + result = await session.execute(stmt) + dbe = result.scalars().first() + + if not dbe: + return None + + # Update flags + if is_valid is not None or is_active is not None: + flags = {**(dbe.flags or {})} + if is_valid is not None: + flags["is_valid"] = is_valid + if is_active is not None: + flags["is_active"] = is_active + dbe.flags = flags + flag_modified(dbe, "flags") + + # Update data fields + data_patch: dict = {} + if provider_connection_id is not None: + data_patch["connected_account_id"] = provider_connection_id + if data_update: + data_patch.update(data_update) + if data_patch: + dbe.data = {**(dbe.data or {}), **data_patch} + flag_modified(dbe, "data") + + dbe.updated_at = datetime.now(timezone.utc) + + await session.commit() + await session.refresh(dbe) + + return map_connection_dbe_to_dto(dbe=dbe) + + @suppress_exceptions(default=False) + async def delete_connection( + self, + *, + project_id: UUID, + connection_id: UUID, + ) -> bool: + """Hard-delete a connection row. Returns True if a row was deleted.""" + async with self.engine.session() as session: + stmt = ( + delete(self.ConnectionDBE) + .where(self.ConnectionDBE.project_id == project_id) + .where(self.ConnectionDBE.id == connection_id) + ) + + result = await session.execute(stmt) + await session.commit() + + return result.rowcount > 0 + + @suppress_exceptions(default=[]) + async def query_connections( + self, + *, + project_id: UUID, + # + provider_key: Optional[str] = None, + integration_key: Optional[str] = None, + is_active: Optional[bool] = True, + ) -> List[Connection]: + """List connections with optional filters. Defaults to active-only (is_active=True).""" + async with self.engine.session() as session: + stmt = select(self.ConnectionDBE).filter( + self.ConnectionDBE.project_id == project_id, + ) + + if provider_key: + stmt = stmt.filter(self.ConnectionDBE.provider_key == provider_key) + + if integration_key: + stmt = stmt.filter( + self.ConnectionDBE.integration_key == integration_key + ) + + if is_active is not None: + expected = "true" if is_active else "false" + stmt = stmt.filter( + self.ConnectionDBE.flags["is_active"].astext == expected + ) + + stmt = stmt.order_by(self.ConnectionDBE.created_at.desc()) + + result = await session.execute(stmt) + dbes = result.scalars().all() + + return [map_connection_dbe_to_dto(dbe=dbe) for dbe in dbes] + + @suppress_exceptions(default=None) + async def activate_connection_by_provider_id( + self, + *, + provider_connection_id: str, + project_id: Optional[UUID] = None, + ) -> Optional[Connection]: + """Set is_valid=True and is_active=True for the connection matching the provider ID.""" + async with self.engine.session() as session: + stmt = select(self.ConnectionDBE).filter( + self.ConnectionDBE.data["connected_account_id"].astext + == provider_connection_id + ) + + if project_id is not None: + stmt = stmt.filter(self.ConnectionDBE.project_id == project_id) + + stmt = stmt.limit(1) + + result = await session.execute(stmt) + dbe = result.scalars().first() + + if not dbe: + return None + + flags = {**(dbe.flags or {})} + flags["is_valid"] = True + flags["is_active"] = True + dbe.flags = flags + flag_modified(dbe, "flags") + + dbe.updated_at = datetime.now(timezone.utc) + + await session.commit() + await session.refresh(dbe) + + return map_connection_dbe_to_dto(dbe=dbe) + + @suppress_exceptions(default=None) + async def find_connection_by_provider_id( + self, + *, + provider_connection_id: str, + ) -> Optional[Connection]: + """Lookup any connection by provider-side connected_account_id (no project scope).""" + async with self.engine.session() as session: + stmt = ( + select(self.ConnectionDBE) + .filter( + self.ConnectionDBE.data["connected_account_id"].astext + == provider_connection_id + ) + .limit(1) + ) + + result = await session.execute(stmt) + dbe = result.scalars().first() + + if not dbe: + return None + + return map_connection_dbe_to_dto(dbe=dbe) diff --git a/api/oss/src/dbs/postgres/tools/dbes.py b/api/oss/src/dbs/postgres/gateway/connections/dbes.py similarity index 81% rename from api/oss/src/dbs/postgres/tools/dbes.py rename to api/oss/src/dbs/postgres/gateway/connections/dbes.py index f075e4b835..087f03e9b1 100644 --- a/api/oss/src/dbs/postgres/tools/dbes.py +++ b/api/oss/src/dbs/postgres/gateway/connections/dbes.py @@ -1,69 +1,69 @@ -from sqlalchemy import ( - Column, - ForeignKeyConstraint, - Index, - PrimaryKeyConstraint, - String, - UniqueConstraint, -) - -from oss.src.dbs.postgres.shared.base import Base -from oss.src.dbs.postgres.shared.dbas import ( - DataDBA, - FlagsDBA, - HeaderDBA, - IdentifierDBA, - LifecycleDBA, - MetaDBA, - ProjectScopeDBA, - SlugDBA, - StatusDBA, - TagsDBA, -) - - -class ToolConnectionDBE( - Base, - ProjectScopeDBA, - IdentifierDBA, - SlugDBA, - LifecycleDBA, - HeaderDBA, - TagsDBA, - FlagsDBA, - DataDBA, - StatusDBA, - MetaDBA, -): - __tablename__ = "tool_connections" - - __table_args__ = ( - PrimaryKeyConstraint("project_id", "id"), - UniqueConstraint( - "project_id", - "provider_key", - "integration_key", - "slug", - name="uq_tool_connections_project_provider_integration_slug", - ), - ForeignKeyConstraint( - ["project_id"], - ["projects.id"], - ondelete="CASCADE", - ), - Index( - "ix_tool_connections_project_provider_integration", - "project_id", - "provider_key", - "integration_key", - ), - ) - - provider_key = Column( - String, - nullable=False, - ) - integration_key = Column( - String, - nullable=False, - ) +from sqlalchemy import ( + Column, + ForeignKeyConstraint, + Index, + PrimaryKeyConstraint, + String, + UniqueConstraint, +) + +from oss.src.dbs.postgres.shared.base import Base +from oss.src.dbs.postgres.shared.dbas import ( + DataDBA, + FlagsDBA, + HeaderDBA, + IdentifierDBA, + LifecycleDBA, + MetaDBA, + ProjectScopeDBA, + SlugDBA, + StatusDBA, + TagsDBA, +) + + +class ConnectionDBE( + Base, + ProjectScopeDBA, + IdentifierDBA, + SlugDBA, + LifecycleDBA, + HeaderDBA, + TagsDBA, + FlagsDBA, + DataDBA, + StatusDBA, + MetaDBA, +): + __tablename__ = "gateway_connections" + + __table_args__ = ( + PrimaryKeyConstraint("project_id", "id"), + UniqueConstraint( + "project_id", + "provider_key", + "integration_key", + "slug", + name="uq_gateway_connections_project_provider_integration_slug", + ), + ForeignKeyConstraint( + ["project_id"], + ["projects.id"], + ondelete="CASCADE", + ), + Index( + "ix_gateway_connections_project_provider_integration", + "project_id", + "provider_key", + "integration_key", + ), + ) + + provider_key = Column( + String, + nullable=False, + ) + integration_key = Column( + String, + nullable=False, + ) diff --git a/api/oss/src/dbs/postgres/tools/mappings.py b/api/oss/src/dbs/postgres/gateway/connections/mappings.py similarity index 80% rename from api/oss/src/dbs/postgres/tools/mappings.py rename to api/oss/src/dbs/postgres/gateway/connections/mappings.py index 334fd600c0..e0e44598dd 100644 --- a/api/oss/src/dbs/postgres/tools/mappings.py +++ b/api/oss/src/dbs/postgres/gateway/connections/mappings.py @@ -2,12 +2,12 @@ from pydantic import BaseModel -from oss.src.core.tools.dtos import ( - ToolConnection, - ToolConnectionCreate, - ToolConnectionStatus, +from oss.src.core.gateway.connections.dtos import ( + Connection, + ConnectionCreate, + ConnectionStatus, ) -from oss.src.dbs.postgres.tools.dbes import ToolConnectionDBE +from oss.src.dbs.postgres.gateway.connections.dbes import ConnectionDBE def map_connection_create_to_dbe( @@ -15,8 +15,8 @@ def map_connection_create_to_dbe( project_id: UUID, user_id: UUID, # - dto: ToolConnectionCreate, -) -> ToolConnectionDBE: + dto: ConnectionCreate, +) -> ConnectionDBE: # Serialize provider-specific data to dict if present data = None if dto.data: @@ -30,7 +30,7 @@ def map_connection_create_to_dbe( flags.setdefault("is_active", True) flags.setdefault("is_valid", False) - return ToolConnectionDBE( + return ConnectionDBE( project_id=project_id, slug=dto.slug, name=dto.name, @@ -50,17 +50,17 @@ def map_connection_create_to_dbe( def map_connection_dbe_to_dto( *, - dbe: ToolConnectionDBE, -) -> ToolConnection: + dbe: ConnectionDBE, +) -> Connection: # Keep provider data generic in core DTOs. data = dbe.data or None # Parse status status = None if dbe.status: - status = ToolConnectionStatus(**dbe.status) + status = ConnectionStatus(**dbe.status) - return ToolConnection( + return Connection( id=dbe.id, slug=dbe.slug, name=dbe.name, diff --git a/api/oss/src/dbs/postgres/triggers/__init__.py b/api/oss/src/dbs/postgres/triggers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/src/dbs/postgres/triggers/dao.py b/api/oss/src/dbs/postgres/triggers/dao.py new file mode 100644 index 0000000000..c53bf2b9eb --- /dev/null +++ b/api/oss/src/dbs/postgres/triggers/dao.py @@ -0,0 +1,404 @@ +from datetime import datetime, timezone +from typing import List, Optional, Tuple +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert + +from oss.src.core.shared.dtos import Windowing +from oss.src.core.triggers.dtos import ( + TriggerDelivery, + TriggerDeliveryCreate, + TriggerDeliveryQuery, + TriggerSubscription, + TriggerSubscriptionCreate, + TriggerSubscriptionEdit, + TriggerSubscriptionQuery, +) +from oss.src.core.triggers.interfaces import TriggersDAOInterface + +from oss.src.dbs.postgres.shared.engine import ( + TransactionsEngine, + get_transactions_engine, +) +from oss.src.dbs.postgres.shared.utils import apply_windowing +from oss.src.dbs.postgres.triggers.dbes import ( + TriggerDeliveryDBE, + TriggerSubscriptionDBE, +) +from oss.src.dbs.postgres.triggers.mappings import ( + map_delivery_dbe_to_dto, + map_delivery_dto_to_dbe_create, + map_subscription_dbe_to_dto, + map_subscription_dto_to_dbe_create, + map_subscription_dto_to_dbe_edit, +) + + +class TriggersDAO(TriggersDAOInterface): + def __init__(self, engine: TransactionsEngine = None): + if engine is None: + engine = get_transactions_engine() + self.engine = engine + + # --- SUBSCRIPTIONS ------------------------------------------------------ # + + async def create_subscription( + self, + *, + project_id: UUID, + user_id: UUID, + # + subscription: TriggerSubscriptionCreate, + # + ti_id: str, + ) -> TriggerSubscription: + subscription_dbe = map_subscription_dto_to_dbe_create( + project_id=project_id, + user_id=user_id, + # + subscription=subscription, + # + ti_id=ti_id, + ) + + async with self.engine.session() as session: + session.add(subscription_dbe) + + await session.commit() + + await session.refresh(subscription_dbe) + + return map_subscription_dbe_to_dto( + subscription_dbe=subscription_dbe, + ) + + async def fetch_subscription( + self, + *, + project_id: UUID, + # + subscription_id: UUID, + ) -> Optional[TriggerSubscription]: + async with self.engine.session() as session: + stmt = select(TriggerSubscriptionDBE).where( + TriggerSubscriptionDBE.project_id == project_id, + TriggerSubscriptionDBE.id == subscription_id, + ) + + result = await session.execute(stmt) + + subscription_dbe = result.scalar_one_or_none() + + if not subscription_dbe: + return None + + return map_subscription_dbe_to_dto( + subscription_dbe=subscription_dbe, + ) + + async def edit_subscription( + self, + *, + project_id: UUID, + user_id: UUID, + # + subscription: TriggerSubscriptionEdit, + ) -> Optional[TriggerSubscription]: + async with self.engine.session() as session: + stmt = select(TriggerSubscriptionDBE).where( + TriggerSubscriptionDBE.id == subscription.id, + TriggerSubscriptionDBE.project_id == project_id, + ) + + result = await session.execute(stmt) + + subscription_dbe = result.scalar_one_or_none() + + if not subscription_dbe: + return None + + map_subscription_dto_to_dbe_edit( + subscription_dbe=subscription_dbe, + # + user_id=user_id, + # + subscription=subscription, + ) + + await session.commit() + + await session.refresh(subscription_dbe) + + return map_subscription_dbe_to_dto( + subscription_dbe=subscription_dbe, + ) + + async def delete_subscription( + self, + *, + project_id: UUID, + # + subscription_id: UUID, + ) -> bool: + async with self.engine.session() as session: + stmt = select(TriggerSubscriptionDBE).where( + TriggerSubscriptionDBE.project_id == project_id, + TriggerSubscriptionDBE.id == subscription_id, + ) + + result = await session.execute(stmt) + + subscription_dbe = result.scalar_one_or_none() + + if not subscription_dbe: + return False + + await session.delete(subscription_dbe) + + await session.commit() + + return True + + async def query_subscriptions( + self, + *, + project_id: UUID, + # + subscription: Optional[TriggerSubscriptionQuery] = None, + # + windowing: Optional[Windowing] = None, + ) -> List[TriggerSubscription]: + async with self.engine.session() as session: + stmt = select(TriggerSubscriptionDBE).filter( + TriggerSubscriptionDBE.project_id == project_id, + ) + + if subscription: + if subscription.name is not None: + stmt = stmt.filter( + TriggerSubscriptionDBE.name.ilike(f"%{subscription.name}%"), + ) + + if subscription.connection_id is not None: + stmt = stmt.filter( + TriggerSubscriptionDBE.connection_id + == subscription.connection_id, + ) + + if subscription.event_key is not None: + stmt = stmt.filter( + TriggerSubscriptionDBE.data["event_key"].astext + == subscription.event_key, + ) + + if windowing: + stmt = apply_windowing( + stmt=stmt, + DBE=TriggerSubscriptionDBE, + attribute="id", + order="descending", + windowing=windowing, + ) + + result = await session.execute(stmt) + + return [ + map_subscription_dbe_to_dto(subscription_dbe=dbe) + for dbe in result.scalars().all() + ] + + async def get_subscription_by_trigger_id( + self, + *, + trigger_id: str, + ) -> Optional[TriggerSubscription]: + async with self.engine.session() as session: + stmt = ( + select(TriggerSubscriptionDBE) + .filter( + TriggerSubscriptionDBE.data["ti_id"].astext == trigger_id, + ) + .limit(1) + ) + + result = await session.execute(stmt) + + subscription_dbe = result.scalars().first() + + if not subscription_dbe: + return None + + return map_subscription_dbe_to_dto( + subscription_dbe=subscription_dbe, + ) + + async def get_project_and_subscription_by_trigger_id( + self, + *, + trigger_id: str, + ) -> Optional[Tuple[UUID, TriggerSubscription]]: + async with self.engine.session() as session: + stmt = ( + select(TriggerSubscriptionDBE) + .filter( + TriggerSubscriptionDBE.data["ti_id"].astext == trigger_id, + ) + .limit(1) + ) + + result = await session.execute(stmt) + + subscription_dbe = result.scalars().first() + + if not subscription_dbe: + return None + + return ( + subscription_dbe.project_id, + map_subscription_dbe_to_dto(subscription_dbe=subscription_dbe), + ) + + # --- DELIVERIES --------------------------------------------------------- # + + async def write_delivery( + self, + *, + project_id: UUID, + user_id: Optional[UUID], + # + delivery: TriggerDeliveryCreate, + ) -> TriggerDelivery: + delivery_dbe = map_delivery_dto_to_dbe_create( + project_id=project_id, + user_id=user_id, + # + delivery=delivery, + ) + + async with self.engine.session() as session: + values = { + c.name: getattr(delivery_dbe, c.name) + for c in TriggerDeliveryDBE.__table__.columns + if not ( + c.name in ("id", "created_at", "updated_at", "deleted_at") + and getattr(delivery_dbe, c.name) is None + ) + } + + stmt = insert(TriggerDeliveryDBE).values(**values) + stmt = stmt.on_conflict_do_update( + index_elements=["project_id", "subscription_id", "event_id"], + set_={ + "status": stmt.excluded.status, + "data": stmt.excluded.data, + "updated_at": datetime.now(timezone.utc), + "updated_by_id": stmt.excluded.created_by_id, + }, + ) + await session.execute(stmt) + await session.commit() + + refreshed_stmt = select(TriggerDeliveryDBE).where( + TriggerDeliveryDBE.project_id == project_id, + TriggerDeliveryDBE.subscription_id == delivery.subscription_id, + TriggerDeliveryDBE.event_id == delivery.event_id, + ) + delivery_dbe = (await session.execute(refreshed_stmt)).scalar_one() + + return map_delivery_dbe_to_dto( + delivery_dbe=delivery_dbe, + ) + + async def fetch_delivery( + self, + *, + project_id: UUID, + # + delivery_id: UUID, + ) -> Optional[TriggerDelivery]: + async with self.engine.session() as session: + stmt = select(TriggerDeliveryDBE).where( + TriggerDeliveryDBE.project_id == project_id, + TriggerDeliveryDBE.id == delivery_id, + ) + + result = await session.execute(stmt) + + delivery_dbe = result.scalar_one_or_none() + + if not delivery_dbe: + return None + + return map_delivery_dbe_to_dto( + delivery_dbe=delivery_dbe, + ) + + async def query_deliveries( + self, + *, + project_id: UUID, + # + delivery: Optional[TriggerDeliveryQuery] = None, + # + windowing: Optional[Windowing] = None, + ) -> List[TriggerDelivery]: + async with self.engine.session() as session: + stmt = select(TriggerDeliveryDBE).filter( + TriggerDeliveryDBE.project_id == project_id, + ) + + if delivery: + if delivery.status is not None and delivery.status.code is not None: + stmt = stmt.filter( + TriggerDeliveryDBE.status["code"].astext + == str(delivery.status.code), + ) + + if delivery.subscription_id is not None: + stmt = stmt.filter( + TriggerDeliveryDBE.subscription_id == delivery.subscription_id, + ) + + if delivery.event_id is not None: + stmt = stmt.filter( + TriggerDeliveryDBE.event_id == delivery.event_id, + ) + + if windowing: + stmt = apply_windowing( + stmt=stmt, + DBE=TriggerDeliveryDBE, + attribute="created_at", + order="descending", + windowing=windowing, + ) + + result = await session.execute(stmt) + + return [ + map_delivery_dbe_to_dto(delivery_dbe=dbe) + for dbe in result.scalars().all() + ] + + async def dedup_seen( + self, + *, + project_id: UUID, + subscription_id: UUID, + event_id: str, + ) -> bool: + async with self.engine.session() as session: + stmt = ( + select(TriggerDeliveryDBE.id) + .where( + TriggerDeliveryDBE.project_id == project_id, + TriggerDeliveryDBE.subscription_id == subscription_id, + TriggerDeliveryDBE.event_id == event_id, + ) + .limit(1) + ) + + result = await session.execute(stmt) + + return result.scalar_one_or_none() is not None diff --git a/api/oss/src/dbs/postgres/triggers/dbas.py b/api/oss/src/dbs/postgres/triggers/dbas.py new file mode 100644 index 0000000000..2f2e7b199b --- /dev/null +++ b/api/oss/src/dbs/postgres/triggers/dbas.py @@ -0,0 +1,53 @@ +from sqlalchemy import Column, String +from sqlalchemy.dialects.postgresql import UUID + +from oss.src.dbs.postgres.shared.dbas import ( + DataDBA, + FlagsDBA, + HeaderDBA, + IdentifierDBA, + LifecycleDBA, + MetaDBA, + ProjectScopeDBA, + StatusDBA, + TagsDBA, +) + + +class TriggerSubscriptionDBA( + ProjectScopeDBA, + LifecycleDBA, + IdentifierDBA, + HeaderDBA, + DataDBA, + FlagsDBA, + TagsDBA, + MetaDBA, +): + __abstract__ = True + + connection_id = Column( + UUID(as_uuid=True), + nullable=False, + ) + + +class TriggerDeliveryDBA( + ProjectScopeDBA, + LifecycleDBA, + IdentifierDBA, + StatusDBA, + DataDBA, +): + __abstract__ = True + + subscription_id = Column( + UUID(as_uuid=True), + nullable=False, + ) + + # I4: provider metadata.id — an arbitrary provider string, unique per subscription. + event_id = Column( + String, + nullable=False, + ) diff --git a/api/oss/src/dbs/postgres/triggers/dbes.py b/api/oss/src/dbs/postgres/triggers/dbes.py new file mode 100644 index 0000000000..9caf012350 --- /dev/null +++ b/api/oss/src/dbs/postgres/triggers/dbes.py @@ -0,0 +1,75 @@ +from sqlalchemy import ForeignKeyConstraint, Index, PrimaryKeyConstraint + +from oss.src.dbs.postgres.shared.base import Base +from oss.src.dbs.postgres.triggers.dbas import ( + TriggerDeliveryDBA, + TriggerSubscriptionDBA, +) + + +class TriggerSubscriptionDBE(Base, TriggerSubscriptionDBA): + __tablename__ = "trigger_subscriptions" + + __table_args__ = ( + ForeignKeyConstraint( + ["project_id"], + ["projects.id"], + ondelete="CASCADE", + ), + ForeignKeyConstraint( + ["project_id", "connection_id"], + ["gateway_connections.project_id", "gateway_connections.id"], + ondelete="CASCADE", + ), + PrimaryKeyConstraint("project_id", "id"), + Index( + "ix_trigger_subscriptions_project_id_created_at", + "project_id", + "created_at", + ), + Index( + "ix_trigger_subscriptions_project_id_deleted_at", + "project_id", + "deleted_at", + ), + Index( + "ix_trigger_subscriptions_connection_id", + "project_id", + "connection_id", + ), + ) + + +class TriggerDeliveryDBE(Base, TriggerDeliveryDBA): + __tablename__ = "trigger_deliveries" + + __table_args__ = ( + ForeignKeyConstraint( + ["project_id"], + ["projects.id"], + ondelete="CASCADE", + ), + ForeignKeyConstraint( + ["project_id", "subscription_id"], + ["trigger_subscriptions.project_id", "trigger_subscriptions.id"], + ondelete="CASCADE", + ), + PrimaryKeyConstraint("project_id", "id"), + Index( + "ix_trigger_deliveries_project_id_created_at", + "project_id", + "created_at", + ), + Index( + "ix_trigger_deliveries_subscription_id_created_at", + "subscription_id", + "created_at", + ), + Index( + "ix_trigger_deliveries_subscription_id_event_id", + "project_id", + "subscription_id", + "event_id", + unique=True, + ), + ) diff --git a/api/oss/src/dbs/postgres/triggers/mappings.py b/api/oss/src/dbs/postgres/triggers/mappings.py new file mode 100644 index 0000000000..97b1eaed92 --- /dev/null +++ b/api/oss/src/dbs/postgres/triggers/mappings.py @@ -0,0 +1,179 @@ +from uuid import UUID + +from oss.src.core.shared.dtos import Status +from oss.src.core.triggers.dtos import ( + TriggerDelivery, + TriggerDeliveryCreate, + TriggerDeliveryData, + TriggerSubscription, + TriggerSubscriptionCreate, + TriggerSubscriptionData, + TriggerSubscriptionEdit, +) + +from oss.src.dbs.postgres.triggers.dbes import ( + TriggerDeliveryDBE, + TriggerSubscriptionDBE, +) + + +# --- Subscription ----------------------------------------------------------- # + +_SUBSCRIPTION_FLAGS = ("enabled", "valid") + + +def _flags_to_dbe(*, enabled: bool, valid: bool) -> dict: + return {"enabled": enabled, "valid": valid} + + +def map_subscription_dto_to_dbe_create( + *, + project_id: UUID, + user_id: UUID, + # + subscription: TriggerSubscriptionCreate, + # + ti_id: str, +) -> TriggerSubscriptionDBE: + data = subscription.data.model_copy(update={"ti_id": ti_id}) + + return TriggerSubscriptionDBE( + project_id=project_id, + # + created_by_id=user_id, + # + connection_id=subscription.connection_id, + # + name=subscription.name, + description=subscription.description, + tags=subscription.tags, + meta=subscription.meta, + # + flags=_flags_to_dbe(enabled=True, valid=True), + # + data=data.model_dump(mode="json", exclude_none=True), + ) + + +def map_subscription_dbe_to_dto( + *, + subscription_dbe: TriggerSubscriptionDBE, +) -> TriggerSubscription: + flags = subscription_dbe.flags or {} + + return TriggerSubscription( + id=subscription_dbe.id, + # + created_at=subscription_dbe.created_at, + updated_at=subscription_dbe.updated_at, + deleted_at=subscription_dbe.deleted_at, + created_by_id=subscription_dbe.created_by_id, + updated_by_id=subscription_dbe.updated_by_id, + deleted_by_id=subscription_dbe.deleted_by_id, + # + connection_id=subscription_dbe.connection_id, + # + name=subscription_dbe.name, + description=subscription_dbe.description, + # + tags=subscription_dbe.tags, + meta=subscription_dbe.meta, + # + data=TriggerSubscriptionData.model_validate(subscription_dbe.data), + # + enabled=bool(flags.get("enabled", True)), + valid=bool(flags.get("valid", True)), + ) + + +def map_subscription_dto_to_dbe_edit( + *, + subscription_dbe: TriggerSubscriptionDBE, + # + user_id: UUID, + # + subscription: TriggerSubscriptionEdit, +) -> None: + subscription_dbe.updated_by_id = user_id + + subscription_dbe.connection_id = subscription.connection_id + + subscription_dbe.name = subscription.name + subscription_dbe.description = subscription.description + + subscription_dbe.tags = subscription.tags + subscription_dbe.meta = subscription.meta + + # Preserve the provider ti_id even if the client omitted it on the full-PUT. + existing_ti_id = (subscription_dbe.data or {}).get("ti_id") + data = subscription.data + if data.ti_id is None and existing_ti_id is not None: + data = data.model_copy(update={"ti_id": existing_ti_id}) + + subscription_dbe.data = data.model_dump(mode="json", exclude_none=True) + + subscription_dbe.flags = _flags_to_dbe( + enabled=subscription.enabled, + valid=subscription.valid, + ) + + +# --- Delivery --------------------------------------------------------------- # + + +def map_delivery_dto_to_dbe_create( + *, + project_id: UUID, + user_id: UUID | None, + # + delivery: TriggerDeliveryCreate, +) -> TriggerDeliveryDBE: + dbe_kwargs = dict( + project_id=project_id, + # + created_by_id=user_id, + # + status=delivery.status.model_dump(mode="json", exclude_none=True) + if delivery.status + else None, + # + data=delivery.data.model_dump(mode="json", exclude_none=True) + if delivery.data + else None, + # + subscription_id=delivery.subscription_id, + # + event_id=delivery.event_id, + ) + if delivery.id is not None: + dbe_kwargs["id"] = delivery.id + + return TriggerDeliveryDBE(**dbe_kwargs) + + +def map_delivery_dbe_to_dto( + *, + delivery_dbe: TriggerDeliveryDBE, +) -> TriggerDelivery: + return TriggerDelivery( + id=delivery_dbe.id, + # + created_at=delivery_dbe.created_at, + updated_at=delivery_dbe.updated_at, + deleted_at=delivery_dbe.deleted_at, + created_by_id=delivery_dbe.created_by_id, + updated_by_id=delivery_dbe.updated_by_id, + deleted_by_id=delivery_dbe.deleted_by_id, + # + status=Status.model_validate(delivery_dbe.status) + if delivery_dbe.status + else Status(), + # + data=TriggerDeliveryData.model_validate(delivery_dbe.data) + if delivery_dbe.data + else None, + # + subscription_id=delivery_dbe.subscription_id, + # + event_id=delivery_dbe.event_id, + ) diff --git a/api/oss/src/middlewares/auth.py b/api/oss/src/middlewares/auth.py index 1cf4ab698b..bdbc1ee8c9 100644 --- a/api/oss/src/middlewares/auth.py +++ b/api/oss/src/middlewares/auth.py @@ -69,6 +69,11 @@ "/api/tools/connections/callback", "/preview/tools/connections/callback", "/api/preview/tools/connections/callback", + # TRIGGERS — inbound provider events arrive from Composio with no auth token + "/triggers/composio/events", + "/api/triggers/composio/events", + "/preview/triggers/composio/events", + "/api/preview/triggers/composio/events", ) _ADMIN_ENDPOINT_IDENTIFIER = "/admin/" diff --git a/api/oss/src/tasks/asyncio/triggers/__init__.py b/api/oss/src/tasks/asyncio/triggers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/src/tasks/asyncio/triggers/dispatcher.py b/api/oss/src/tasks/asyncio/triggers/dispatcher.py new file mode 100644 index 0000000000..3c2bcbdfe3 --- /dev/null +++ b/api/oss/src/tasks/asyncio/triggers/dispatcher.py @@ -0,0 +1,244 @@ +"""Trigger dispatcher — asyncio side of the inbound pipeline. + +The inbound dual of ``webhooks/dispatcher.py``. Given a verified Composio event +(``ti_*`` trigger id + ``metadata.id`` dedup key + raw payload), it resolves the +local subscription, dedups, maps ``inputs_fields`` into the workflow inputs, runs +the bound workflow, and records a single delivery row with the outcome. + +Self-contained so it can run inside its own TaskIQ worker process. +""" + +from typing import Any, Dict, Optional +from uuid import UUID + +import uuid_utils.compat as uuid_compat + +from oss.src.core.shared.dtos import Status +from oss.src.core.triggers.dtos import ( + TRIGGER_EVENT_FIELDS, + SUBSCRIPTION_CONTEXT_FIELDS, + TriggerDeliveryCreate, + TriggerDeliveryData, + TriggerSubscription, +) +from oss.src.core.triggers.interfaces import TriggersDAOInterface +from oss.src.core.workflows.service import WorkflowsService +from oss.src.utils.logging import get_module_logger + +from agenta.sdk.decorators.running import WorkflowServiceRequest +from agenta.sdk.models.workflows import WorkflowRequestData +from agenta.sdk.utils.resolvers import resolve_target_fields + +log = get_module_logger(__name__) + + +class TriggersDispatcher: + """Resolves and runs one inbound provider event against its bound workflow.""" + + def __init__( + self, + *, + triggers_dao: TriggersDAOInterface, + workflows_service: WorkflowsService, + ): + self.triggers_dao = triggers_dao + self.workflows_service = workflows_service + + def _build_context( + self, + *, + event: Dict[str, Any], + subscription: TriggerSubscription, + project_id: UUID, + ) -> Dict[str, Any]: + sub_dump = subscription.model_dump(mode="json", exclude_none=True) + return { + "event": {k: v for k, v in event.items() if k in TRIGGER_EVENT_FIELDS}, + "subscription": { + k: v for k, v in sub_dump.items() if k in SUBSCRIPTION_CONTEXT_FIELDS + }, + "scope": {"project_id": str(project_id)}, + } + + async def dispatch( + self, + *, + trigger_id: str, + event_id: str, + event: Dict[str, Any], + ) -> None: + """Run the bound workflow for one inbound event (idempotent on event_id).""" + resolved = await self.triggers_dao.get_project_and_subscription_by_trigger_id( + trigger_id=trigger_id, + ) + + if resolved is None: + log.info( + "[TRIGGERS DISPATCHER] Unknown trigger_id %s — skipping", trigger_id + ) + return + + project_id, subscription = resolved + + if not subscription.enabled: + log.info( + "[TRIGGERS DISPATCHER] Subscription %s disabled — skipping", + subscription.id, + ) + return + + already_seen = await self.triggers_dao.dedup_seen( + project_id=project_id, + subscription_id=subscription.id, + event_id=event_id, + ) + if already_seen: + log.info( + "[TRIGGERS DISPATCHER] Duplicate event %s for subscription %s — skipping", + event_id, + subscription.id, + ) + return + + context = self._build_context( + event=event, + subscription=subscription, + project_id=project_id, + ) + + # MAPPING — inputs-only template (default whole-context "$" like webhooks). + template = subscription.data.inputs_fields + inputs = resolve_target_fields( + template if template is not None else "$", context + ) + + references = ( + { + k: ref.model_dump(mode="json", exclude_none=True) + for k, ref in subscription.data.references.items() + } + if subscription.data.references + else None + ) + selector = ( + subscription.data.selector.model_dump(mode="json", exclude_none=True) + if subscription.data.selector + else None + ) + + delivery_id = uuid_compat.uuid7() + user_id = subscription.created_by_id # M6 — attribute to the creator, or None + + delivery_data = TriggerDeliveryData( + event_key=subscription.data.event_key, + references=subscription.data.references, + inputs=inputs if isinstance(inputs, dict) else {"value": inputs}, + ) + + if not references: + await self._write_delivery( + project_id=project_id, + user_id=user_id, + delivery_id=delivery_id, + subscription_id=subscription.id, + event_id=event_id, + status=Status(code="400", message="failed"), + data=delivery_data.model_copy( + update={"error": "Subscription has no bound workflow reference"} + ), + ) + return + + try: + request = WorkflowServiceRequest( + references=references, + selector=selector, + data=WorkflowRequestData( + inputs=inputs if isinstance(inputs, dict) else {"value": inputs}, + ), + ) + + response = await self.workflows_service.invoke_workflow( + project_id=project_id, + user_id=user_id, + request=request, + ) + except Exception as e: + await self._write_delivery( + project_id=project_id, + user_id=user_id, + delivery_id=delivery_id, + subscription_id=subscription.id, + event_id=event_id, + status=Status(code="500", message="failed"), + data=delivery_data.model_copy(update={"error": str(e)}), + ) + raise + + status_obj = getattr(response, "status", None) + status_code = getattr(status_obj, "code", None) + outputs = getattr(response, "outputs", None) or getattr( + getattr(response, "data", None), "outputs", None + ) + + if status_code not in (None, 200): + await self._write_delivery( + project_id=project_id, + user_id=user_id, + delivery_id=delivery_id, + subscription_id=subscription.id, + event_id=event_id, + status=Status(code=str(status_code), message="failed"), + data=delivery_data.model_copy( + update={ + "error": getattr(status_obj, "message", None) + or "Workflow failed", + "result": { + "trace_id": getattr(response, "trace_id", None), + "span_id": getattr(response, "span_id", None), + }, + } + ), + ) + return + + await self._write_delivery( + project_id=project_id, + user_id=user_id, + delivery_id=delivery_id, + subscription_id=subscription.id, + event_id=event_id, + status=Status(code="200", message="success"), + data=delivery_data.model_copy( + update={ + "result": { + "trace_id": getattr(response, "trace_id", None), + "span_id": getattr(response, "span_id", None), + "outputs": outputs, + } + } + ), + ) + + async def _write_delivery( + self, + *, + project_id: UUID, + user_id: Optional[UUID], + delivery_id: UUID, + subscription_id: UUID, + event_id: str, + status: Status, + data: TriggerDeliveryData, + ) -> None: + await self.triggers_dao.write_delivery( + project_id=project_id, + user_id=user_id, + delivery=TriggerDeliveryCreate( + id=delivery_id, + subscription_id=subscription_id, + event_id=event_id, + status=status, + data=data, + ), + ) diff --git a/api/oss/src/tasks/taskiq/triggers/__init__.py b/api/oss/src/tasks/taskiq/triggers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/src/tasks/taskiq/triggers/worker.py b/api/oss/src/tasks/taskiq/triggers/worker.py new file mode 100644 index 0000000000..8bcf26d553 --- /dev/null +++ b/api/oss/src/tasks/taskiq/triggers/worker.py @@ -0,0 +1,63 @@ +from typing import Any, Dict + +from taskiq import AsyncBroker, Context, TaskiqDepends + +from oss.src.core.triggers.dtos import TRIGGER_MAX_RETRIES +from oss.src.tasks.asyncio.triggers.dispatcher import TriggersDispatcher +from oss.src.utils.logging import get_module_logger + +log = get_module_logger(__name__) + + +class TriggersWorker: + """Registers and owns the TaskIQ trigger dispatch task. + + The dispatch task receives the verified Composio event inline and runs the + bound workflow, writing a single delivery row on the outcome. Idempotency + comes from the WP3 ``dedup_seen`` guard, so provider + TaskIQ retries are safe. + """ + + def __init__( + self, + *, + broker: AsyncBroker, + dispatcher: TriggersDispatcher, + ): + self.broker = broker + self.dispatcher = dispatcher + + self._register_tasks() + + def _register_tasks(self): + @self.broker.task( + task_name="triggers.dispatch", + retry_on_error=True, + max_retries=TRIGGER_MAX_RETRIES, + ) + async def dispatch_trigger( + *, + trigger_id: str, + event_id: str, + event: Dict[str, Any], + # + context: Context = TaskiqDepends(), + ) -> None: + retry_count_raw = context.message.labels.get("_taskiq_retry_count", 0) or 0 + try: + retry_count = int(retry_count_raw) + except (TypeError, ValueError): + retry_count = 0 + + log.info( + f"[TASK] triggers.dispatch " + f"trigger={trigger_id} event={event_id} " + f"attempt={retry_count}/{TRIGGER_MAX_RETRIES}" + ) + + await self.dispatcher.dispatch( + trigger_id=trigger_id, + event_id=event_id, + event=event, + ) + + self.dispatch_trigger = dispatch_trigger diff --git a/api/oss/src/utils/env.py b/api/oss/src/utils/env.py index 585386c33e..993ab83725 100644 --- a/api/oss/src/utils/env.py +++ b/api/oss/src/utils/env.py @@ -510,6 +510,7 @@ class ComposioConfig(BaseModel): api_key: str | None = os.getenv("COMPOSIO_API_KEY") api_url: str = os.getenv("COMPOSIO_API_URL", "https://backend.composio.dev/api/v3") + webhook_secret: str | None = os.getenv("COMPOSIO_WEBHOOK_SECRET") @property def enabled(self) -> bool: diff --git a/api/oss/tests/pytest/acceptance/tools/test_tools_connections.py b/api/oss/tests/pytest/acceptance/tools/test_tools_connections.py new file mode 100644 index 0000000000..2aff6a5f83 --- /dev/null +++ b/api/oss/tests/pytest/acceptance/tools/test_tools_connections.py @@ -0,0 +1,72 @@ +"""Acceptance tests for the /tools/connections contract (WP0). + +The connection now lives in the routerless ``connections`` domain backed by the +``gateway_connections`` table, but the public HTTP surface stays at +``/tools/connections`` byte-for-byte. These tests pin that contract. + +The query endpoint is DB-only — it needs no Composio credentials. A fresh +project returns an empty, well-shaped list, which also proves the table rename +landed (the query hits ``gateway_connections``). Create / refresh / revoke make +real provider calls, so those are gated on COMPOSIO_API_KEY. +""" + +import os +from uuid import uuid4 + +import pytest + + +_COMPOSIO_ENABLED = bool(os.getenv("COMPOSIO_API_KEY")) +_requires_composio = pytest.mark.skipif( + not _COMPOSIO_ENABLED, + reason="needs live Composio credentials (COMPOSIO_API_KEY)", +) + + +class TestToolsConnectionsQuery: + def test_query_connections_returns_200(self, authed_api): + response = authed_api("POST", "/tools/connections/query") + assert response.status_code == 200 + + def test_query_connections_response_shape(self, authed_api): + body = authed_api("POST", "/tools/connections/query").json() + assert "count" in body + assert "connections" in body + assert isinstance(body["connections"], list) + assert body["count"] == len(body["connections"]) + + +class TestToolsConnectionsGet: + def test_get_unknown_connection_returns_404(self, authed_api): + response = authed_api("GET", f"/tools/connections/{uuid4()}") + assert response.status_code == 404 + + +@_requires_composio +class TestToolsConnectionsLifecycle: + def test_create_revoke_roundtrip(self, authed_api): + slug = f"acc-{uuid4().hex[:8]}" + create = authed_api( + "POST", + "/tools/connections/", + json={ + "connection": { + "slug": slug, + "provider_key": "composio", + "integration_key": "github", + "data": {"auth_scheme": "oauth"}, + } + }, + ) + assert create.status_code == 200, create.text + connection = create.json()["connection"] + connection_id = connection["id"] + + # Local-only revoke (C7/B3): flips is_valid on the shared row, no + # provider call, no cascade. + revoke = authed_api("POST", f"/tools/connections/{connection_id}/revoke") + assert revoke.status_code == 200, revoke.text + assert revoke.json()["connection"]["flags"]["is_valid"] is False + + delete = authed_api("DELETE", f"/tools/connections/{connection_id}") + assert delete.status_code == 204, delete.text diff --git a/api/oss/tests/pytest/acceptance/triggers/__init__.py b/api/oss/tests/pytest/acceptance/triggers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/tests/pytest/acceptance/triggers/test_triggers_catalog.py b/api/oss/tests/pytest/acceptance/triggers/test_triggers_catalog.py new file mode 100644 index 0000000000..0e04a99902 --- /dev/null +++ b/api/oss/tests/pytest/acceptance/triggers/test_triggers_catalog.py @@ -0,0 +1,77 @@ +"""Acceptance tests for GET /triggers/catalog/* endpoints (events catalog). + +The provider-catalog endpoints are reachable without any external API key: an +empty catalog is a valid response (no Composio adapter is registered when +``env.composio`` is unset). The event-browse / config-schema fetch make real +Composio calls, so those tests are gated on COMPOSIO_API_KEY being present in +the runner's environment (the same env the API reads). +""" + +import os + +import pytest + + +_COMPOSIO_ENABLED = bool(os.getenv("COMPOSIO_API_KEY")) +_requires_composio = pytest.mark.skipif( + not _COMPOSIO_ENABLED, + reason="needs live Composio credentials (COMPOSIO_API_KEY)", +) + + +class TestTriggersCatalogProviders: + def test_list_providers_returns_200(self, authed_api): + response = authed_api("GET", "/triggers/catalog/providers/") + assert response.status_code == 200 + + def test_list_providers_response_shape(self, authed_api): + body = authed_api("GET", "/triggers/catalog/providers/").json() + assert "count" in body + assert "providers" in body + assert isinstance(body["providers"], list) + + def test_list_providers_count_matches_list(self, authed_api): + body = authed_api("GET", "/triggers/catalog/providers/").json() + assert body["count"] == len(body["providers"]) + + def test_list_providers_empty_when_composio_disabled(self, authed_api): + """With no adapter registered (``env.composio`` unset on the API), the + catalog is empty. Gate on what the *server* reports, not a local env + var — the test runner's env need not match the API process's.""" + body = authed_api("GET", "/triggers/catalog/providers/").json() + if body["count"] != 0: + pytest.skip("Composio is enabled on the API — catalog is non-empty") + assert body["providers"] == [] + + +@_requires_composio +class TestTriggersCatalogEvents: + def test_browse_events_returns_200(self, authed_api): + response = authed_api( + "GET", + "/triggers/catalog/providers/composio/integrations/github/events/", + ) + assert response.status_code == 200 + body = response.json() + assert "events" in body + assert isinstance(body["events"], list) + + def test_fetch_event_config_schema(self, authed_api): + """A single event carries its trigger_config JSON Schema.""" + listing = authed_api( + "GET", + "/triggers/catalog/providers/composio/integrations/github/events/", + ).json() + if not listing["events"]: + pytest.skip("no github events available from Composio") + + event_key = listing["events"][0]["key"] + response = authed_api( + "GET", + f"/triggers/catalog/providers/composio/integrations/github/events/{event_key}", + ) + assert response.status_code == 200 + event = response.json()["event"] + assert event["key"] == event_key + # trigger_config is the inbound analogue of an action's input_parameters + assert "trigger_config" in event diff --git a/api/oss/tests/pytest/acceptance/triggers/test_triggers_ingress.py b/api/oss/tests/pytest/acceptance/triggers/test_triggers_ingress.py new file mode 100644 index 0000000000..d76db95ed3 --- /dev/null +++ b/api/oss/tests/pytest/acceptance/triggers/test_triggers_ingress.py @@ -0,0 +1,163 @@ +"""Acceptance tests for POST /triggers/composio/events (inbound ingress). + +The ingress is the inbound dual of webhooks: a public (no Agenta auth) endpoint +that Composio POSTs provider events to. It ACKs fast (202) and enqueues dispatch +asynchronously; the actual workflow run + delivery write happen in a separate +worker, so the unconditional paths here are DB-free: + + - an event for an unknown trigger id is a clean 202 no-op (nothing to route); + - an event with no routable metadata is a clean 202 no-op. + +The signature-rejection path only bites when COMPOSIO_WEBHOOK_SECRET is set +(unset → 200/202 no-op, mirroring the Stripe receiver), so it is gated on that. +The full signed-event -> workflow-invoked -> single-delivery roundtrip needs the +live Composio adapter and a bound workflow, so it is gated on COMPOSIO_API_KEY. + +Requires a running API. +""" + +import os +from uuid import uuid4 + +import pytest + + +_COMPOSIO_ENABLED = bool(os.getenv("COMPOSIO_API_KEY")) +_WEBHOOK_SECRET = os.getenv("COMPOSIO_WEBHOOK_SECRET") + +_requires_composio = pytest.mark.skipif( + not _COMPOSIO_ENABLED, + reason="needs live Composio credentials (COMPOSIO_API_KEY)", +) +_requires_webhook_secret = pytest.mark.skipif( + not _WEBHOOK_SECRET, + reason="needs COMPOSIO_WEBHOOK_SECRET set to verify signature rejection", +) + + +# --------------------------------------------------------------------------- +# DB-only: unknown trigger / no metadata are clean 202 no-ops +# --------------------------------------------------------------------------- + + +class TestTriggerIngressNoOps: + def test_unknown_trigger_id_is_accepted_noop(self, unauthed_api): + response = unauthed_api( + "POST", + "/triggers/composio/events", + json={ + "type": "github_star_added_event", + "metadata": { + "trigger_id": f"ti_{uuid4().hex}", + "id": uuid4().hex, + }, + "data": {"repository": "acme/widgets"}, + }, + ) + assert response.status_code == 202, response.text + assert response.json()["status"] == "accepted" + + def test_no_routable_metadata_is_accepted_noop(self, unauthed_api): + response = unauthed_api( + "POST", + "/triggers/composio/events", + json={"type": "some_event", "data": {}}, + ) + assert response.status_code == 202, response.text + assert response.json()["status"] == "accepted" + + def test_empty_body_is_accepted_noop(self, unauthed_api): + response = unauthed_api("POST", "/triggers/composio/events", data=b"") + assert response.status_code == 202, response.text + + +@_requires_webhook_secret +class TestTriggerIngressSignature: + def test_forged_signature_is_rejected(self, unauthed_api): + response = unauthed_api( + "POST", + "/triggers/composio/events", + headers={ + "webhook-id": "msg_1", + "webhook-timestamp": "1700000000", + "webhook-signature": "v1,deadbeef", + }, + json={ + "metadata": {"trigger_id": f"ti_{uuid4().hex}", "id": uuid4().hex}, + }, + ) + assert response.status_code == 401, response.text + + +# --------------------------------------------------------------------------- +# Dedup (needs Composio) — a duplicate metadata.id does not double-write a +# delivery. Exercised end-to-end via a real subscription bound to a workflow. +# --------------------------------------------------------------------------- + + +@_requires_composio +class TestTriggerIngressDedup: + def test_duplicate_event_id_writes_single_delivery(self, authed_api, unauthed_api): + # Create a connection + subscription so an inbound ti_* resolves locally. + slug = f"acc-{uuid4().hex[:8]}" + conn = authed_api( + "POST", + "/tools/connections/", + json={ + "connection": { + "slug": slug, + "provider_key": "composio", + "integration_key": "github", + "data": {"auth_scheme": "oauth"}, + } + }, + ) + assert conn.status_code == 200, conn.text + connection_id = conn.json()["connection"]["id"] + + create = authed_api( + "POST", + "/triggers/subscriptions/", + json={ + "subscription": { + "name": f"sub-{uuid4().hex[:8]}", + "connection_id": connection_id, + "data": { + "event_key": "GITHUB_STAR_ADDED_EVENT", + "trigger_config": {}, + "inputs_fields": {"repo": "$.event.data.repository"}, + "references": {"workflow": {"slug": "triage"}}, + }, + } + }, + ) + assert create.status_code == 200, create.text + sub = create.json()["subscription"] + subscription_id = sub["id"] + ti_id = sub["data"]["ti_id"] + + event_id = uuid4().hex + envelope = { + "type": "github_star_added_event", + "metadata": {"trigger_id": ti_id, "id": event_id}, + "data": {"repository": "acme/widgets"}, + } + + # Post the same event twice (provider redelivery) — dedup must hold. + for _ in range(2): + ack = unauthed_api("POST", "/triggers/composio/events", json=envelope) + assert ack.status_code == 202, ack.text + + # The dispatch is async; the dedup guard means at most one delivery row + # exists for this (subscription, event_id). + deliveries = authed_api( + "POST", + "/triggers/deliveries/query", + json={ + "delivery": {"subscription_id": subscription_id, "event_id": event_id} + }, + ).json()["deliveries"] + assert len(deliveries) <= 1 + + authed_api("DELETE", f"/triggers/subscriptions/{subscription_id}") + authed_api("DELETE", f"/tools/connections/{connection_id}") diff --git a/api/oss/tests/pytest/acceptance/triggers/test_triggers_subscriptions.py b/api/oss/tests/pytest/acceptance/triggers/test_triggers_subscriptions.py new file mode 100644 index 0000000000..cd519cc3f2 --- /dev/null +++ b/api/oss/tests/pytest/acceptance/triggers/test_triggers_subscriptions.py @@ -0,0 +1,155 @@ +"""Acceptance tests for /triggers/subscriptions/* and /triggers/deliveries/*. + +The read/query surfaces are DB-only — a fresh project returns well-shaped empty +lists and 404s with no Composio credentials, which also proves the +trigger_subscriptions / trigger_deliveries tables landed (migration ran). + +Creating a subscription mints a provider-side trigger instance (ti_*) on a +shared gateway connection, so the full create -> list -> disable -> delete +roundtrip (and the C7 invariant — deleting a subscription leaves the connection +intact) is gated on COMPOSIO_API_KEY being present in the runner's environment. + +Requires a running API. +""" + +import os +from uuid import uuid4 + +import pytest + + +_COMPOSIO_ENABLED = bool(os.getenv("COMPOSIO_API_KEY")) +_requires_composio = pytest.mark.skipif( + not _COMPOSIO_ENABLED, + reason="needs live Composio credentials (COMPOSIO_API_KEY)", +) + + +# --------------------------------------------------------------------------- +# DB-only: reads, queries, 404s (no Composio needed) +# --------------------------------------------------------------------------- + + +class TestTriggerSubscriptionsReads: + def test_list_subscriptions_returns_200_empty(self, authed_api): + body = authed_api("GET", "/triggers/subscriptions/").json() + assert "count" in body + assert "subscriptions" in body + assert isinstance(body["subscriptions"], list) + assert body["count"] == len(body["subscriptions"]) + + def test_query_subscriptions_returns_200(self, authed_api): + response = authed_api("POST", "/triggers/subscriptions/query", json={}) + assert response.status_code == 200 + body = response.json() + assert body["count"] == len(body["subscriptions"]) + + def test_fetch_unknown_subscription_returns_404(self, authed_api): + response = authed_api("GET", f"/triggers/subscriptions/{uuid4()}") + assert response.status_code == 404 + + def test_delete_unknown_subscription_returns_404(self, authed_api): + response = authed_api("DELETE", f"/triggers/subscriptions/{uuid4()}") + assert response.status_code == 404 + + def test_refresh_unknown_subscription_returns_404(self, authed_api): + response = authed_api("POST", f"/triggers/subscriptions/{uuid4()}/refresh") + assert response.status_code == 404 + + def test_revoke_unknown_subscription_returns_404(self, authed_api): + response = authed_api("POST", f"/triggers/subscriptions/{uuid4()}/revoke") + assert response.status_code == 404 + + +class TestTriggerDeliveriesReads: + def test_list_deliveries_returns_200_empty(self, authed_api): + body = authed_api("GET", "/triggers/deliveries").json() + assert "count" in body + assert "deliveries" in body + assert isinstance(body["deliveries"], list) + assert body["count"] == len(body["deliveries"]) + + def test_query_deliveries_returns_200(self, authed_api): + response = authed_api("POST", "/triggers/deliveries/query", json={}) + assert response.status_code == 200 + body = response.json() + assert body["count"] == len(body["deliveries"]) + + def test_fetch_unknown_delivery_returns_404(self, authed_api): + response = authed_api("GET", f"/triggers/deliveries/{uuid4()}") + assert response.status_code == 404 + + +# --------------------------------------------------------------------------- +# Full lifecycle (needs Composio) — create on a shared connection bound to a +# workflow, list/disable/delete it, and prove the connection survives (C7). +# --------------------------------------------------------------------------- + + +@_requires_composio +class TestTriggerSubscriptionsLifecycle: + def _create_connection(self, authed_api): + slug = f"acc-{uuid4().hex[:8]}" + create = authed_api( + "POST", + "/tools/connections/", + json={ + "connection": { + "slug": slug, + "provider_key": "composio", + "integration_key": "github", + "data": {"auth_scheme": "oauth"}, + } + }, + ) + assert create.status_code == 200, create.text + return create.json()["connection"]["id"] + + def test_create_list_disable_delete_keeps_connection(self, authed_api): + connection_id = self._create_connection(authed_api) + + # CREATE — binds the event to a workflow reference on the shared connection + create = authed_api( + "POST", + "/triggers/subscriptions/", + json={ + "subscription": { + "name": f"sub-{uuid4().hex[:8]}", + "connection_id": connection_id, + "data": { + "event_key": "GITHUB_STAR_ADDED_EVENT", + "trigger_config": {}, + "inputs_fields": {"repo": "$.event.data.repository"}, + "references": {"workflow": {"slug": "triage"}}, + }, + } + }, + ) + assert create.status_code == 200, create.text + sub = create.json()["subscription"] + subscription_id = sub["id"] + assert sub["connection_id"] == connection_id + assert sub["data"]["ti_id"] is not None + assert sub["enabled"] is True + + # LIST + listing = authed_api("GET", "/triggers/subscriptions/").json() + assert any(s["id"] == subscription_id for s in listing["subscriptions"]) + + # DISABLE (revoke the subscription, not the connection) + revoke = authed_api("POST", f"/triggers/subscriptions/{subscription_id}/revoke") + assert revoke.status_code == 200, revoke.text + assert revoke.json()["subscription"]["enabled"] is False + + # DELETE + delete = authed_api("DELETE", f"/triggers/subscriptions/{subscription_id}") + assert delete.status_code == 204 + + fetch = authed_api("GET", f"/triggers/subscriptions/{subscription_id}") + assert fetch.status_code == 404 + + # C7: deleting the subscription must NOT delete/revoke the connection. + conn = authed_api("GET", f"/tools/connections/{connection_id}") + assert conn.status_code == 200, conn.text + + authed_api("DELETE", f"/tools/connections/{connection_id}") diff --git a/api/oss/tests/pytest/unit/models/test_lifecycle_conventions.py b/api/oss/tests/pytest/unit/models/test_lifecycle_conventions.py index 8e0399f3ec..34c3f89b79 100644 --- a/api/oss/tests/pytest/unit/models/test_lifecycle_conventions.py +++ b/api/oss/tests/pytest/unit/models/test_lifecycle_conventions.py @@ -16,7 +16,7 @@ "oss.src.dbs.postgres.users.dbes", "oss.src.dbs.postgres.folders.dbes", "oss.src.dbs.postgres.secrets.dbes", - "oss.src.dbs.postgres.tools.dbes", + "oss.src.dbs.postgres.gateway.connections.dbes", "oss.src.dbs.postgres.events.dbes", "oss.src.dbs.postgres.webhooks.dbes", "oss.src.dbs.postgres.tracing.dbes", diff --git a/api/oss/tests/pytest/unit/triggers/__init__.py b/api/oss/tests/pytest/unit/triggers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/tests/pytest/unit/triggers/test_triggers_dispatcher.py b/api/oss/tests/pytest/unit/triggers/test_triggers_dispatcher.py new file mode 100644 index 0000000000..e50fcf157b --- /dev/null +++ b/api/oss/tests/pytest/unit/triggers/test_triggers_dispatcher.py @@ -0,0 +1,149 @@ +"""Unit tests for the trigger dispatcher. + +The inbound dual of ``test_webhooks_dispatcher.py``. Stubs the DAO and workflows +service (no DB, no Composio) and pins the dispatch branches: unknown trigger, +disabled subscription, dedup, missing workflow reference, and the happy path. +""" + +from types import SimpleNamespace +from uuid import uuid4 + +from unittest.mock import AsyncMock, MagicMock + +from oss.src.core.shared.dtos import Reference +from oss.src.tasks.asyncio.triggers.dispatcher import TriggersDispatcher + + +def _make_subscription(*, enabled=True, references=None, inputs_fields=None): + data = SimpleNamespace( + event_key="github.issue.opened", + inputs_fields=inputs_fields, + references=references, + selector=None, + ) + return SimpleNamespace( + id=uuid4(), + enabled=enabled, + created_by_id=uuid4(), + data=data, + model_dump=lambda **_kwargs: {"id": "sub", "name": "watch"}, + ) + + +def _make_dao(*, resolved, seen=False): + dao = MagicMock() + dao.get_project_and_subscription_by_trigger_id = AsyncMock(return_value=resolved) + dao.dedup_seen = AsyncMock(return_value=seen) + dao.write_delivery = AsyncMock() + return dao + + +_EVENT = {"type": "github.issue.opened", "data": {"issue": {"number": 7}}} + + +async def test_unknown_trigger_id_is_skipped(): + dao = _make_dao(resolved=None) + workflows = MagicMock() + dispatcher = TriggersDispatcher(triggers_dao=dao, workflows_service=workflows) + + await dispatcher.dispatch(trigger_id="ti_unknown", event_id="e1", event=_EVENT) + + dao.dedup_seen.assert_not_awaited() + dao.write_delivery.assert_not_awaited() + + +async def test_disabled_subscription_is_skipped(): + project_id = uuid4() + subscription = _make_subscription(enabled=False) + dao = _make_dao(resolved=(project_id, subscription)) + dispatcher = TriggersDispatcher(triggers_dao=dao, workflows_service=MagicMock()) + + await dispatcher.dispatch(trigger_id="ti_1", event_id="e1", event=_EVENT) + + dao.dedup_seen.assert_not_awaited() + dao.write_delivery.assert_not_awaited() + + +async def test_duplicate_event_is_skipped(): + project_id = uuid4() + subscription = _make_subscription(references={"workflow": MagicMock()}) + dao = _make_dao(resolved=(project_id, subscription), seen=True) + dispatcher = TriggersDispatcher(triggers_dao=dao, workflows_service=MagicMock()) + + await dispatcher.dispatch(trigger_id="ti_1", event_id="e1", event=_EVENT) + + dao.dedup_seen.assert_awaited_once() + dao.write_delivery.assert_not_awaited() + + +async def test_missing_reference_writes_failed_delivery(): + project_id = uuid4() + subscription = _make_subscription(references=None) + dao = _make_dao(resolved=(project_id, subscription)) + workflows = MagicMock() + workflows.invoke_workflow = AsyncMock() + dispatcher = TriggersDispatcher(triggers_dao=dao, workflows_service=workflows) + + await dispatcher.dispatch(trigger_id="ti_1", event_id="e1", event=_EVENT) + + workflows.invoke_workflow.assert_not_awaited() + dao.write_delivery.assert_awaited_once() + delivery = dao.write_delivery.await_args.kwargs["delivery"] + assert delivery.status.code == "400" + assert "no bound workflow" in delivery.data.error.lower() + + +async def test_happy_path_invokes_workflow_and_writes_success(): + project_id = uuid4() + reference = Reference(slug="wf-1") + subscription = _make_subscription( + references={"workflow": reference}, + inputs_fields={"number": "$.event.data.issue.number"}, + ) + dao = _make_dao(resolved=(project_id, subscription)) + + response = SimpleNamespace( + status=SimpleNamespace(code=200, message="success"), + outputs={"ok": True}, + trace_id="tr-1", + span_id="sp-1", + ) + workflows = MagicMock() + workflows.invoke_workflow = AsyncMock(return_value=response) + dispatcher = TriggersDispatcher(triggers_dao=dao, workflows_service=workflows) + + await dispatcher.dispatch(trigger_id="ti_1", event_id="e1", event=_EVENT) + + workflows.invoke_workflow.assert_awaited_once() + invoke_kwargs = workflows.invoke_workflow.await_args.kwargs + assert invoke_kwargs["project_id"] == project_id + assert invoke_kwargs["user_id"] == subscription.created_by_id + + dao.write_delivery.assert_awaited_once() + delivery = dao.write_delivery.await_args.kwargs["delivery"] + assert delivery.status.code == "200" + assert delivery.event_id == "e1" + assert delivery.data.inputs == {"number": 7} + + +async def test_workflow_non_200_writes_failed_delivery(): + project_id = uuid4() + reference = Reference(slug="wf-1") + subscription = _make_subscription(references={"workflow": reference}) + dao = _make_dao(resolved=(project_id, subscription)) + + response = SimpleNamespace( + status=SimpleNamespace(code=500, message="boom"), + outputs=None, + trace_id="tr-1", + span_id="sp-1", + ) + workflows = MagicMock() + workflows.invoke_workflow = AsyncMock(return_value=response) + dispatcher = TriggersDispatcher(triggers_dao=dao, workflows_service=workflows) + + await dispatcher.dispatch(trigger_id="ti_1", event_id="e1", event=_EVENT) + + dao.write_delivery.assert_awaited_once() + delivery = dao.write_delivery.await_args.kwargs["delivery"] + assert delivery.status.code == "500" diff --git a/api/oss/tests/pytest/unit/triggers/test_triggers_signature.py b/api/oss/tests/pytest/unit/triggers/test_triggers_signature.py new file mode 100644 index 0000000000..d0d49ee0b7 --- /dev/null +++ b/api/oss/tests/pytest/unit/triggers/test_triggers_signature.py @@ -0,0 +1,106 @@ +"""Unit tests for Composio webhook signature verification. + +Pure HMAC logic, no network or database. The acceptance suite only exercises +this path when ``COMPOSIO_WEBHOOK_SECRET`` is present in the runner; these tests +pin the security contract (forged/missing signatures rejected) unconditionally. +""" + +import hashlib +import hmac + +from unittest.mock import patch + +from oss.src.apis.fastapi.triggers.router import _verify_composio_signature + +_SECRET = "whsec_test_secret" +_WEBHOOK_ID = "wh-1" +_TIMESTAMP = "1700000000" +_BODY = b'{"type":"github.issue.opened"}' + +_ENV_PATH = "oss.src.apis.fastapi.triggers.router.env" + + +def _sign(secret: str, webhook_id: str, timestamp: str, body: bytes) -> str: + signed = f"{webhook_id}.{timestamp}.{body.decode('utf-8')}" + return hmac.new( + secret.encode("utf-8"), + signed.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + +class _Env: + """Minimal stand-in for the shared env object's composio config.""" + + class composio: # noqa: N801 - mirrors env.composio attribute access + webhook_secret = None + + +def _env_with_secret(secret): + env = _Env() + env.composio.webhook_secret = secret + return env + + +class TestVerifyComposioSignature: + def test_unset_secret_is_noop_accept(self): + with patch(_ENV_PATH, _env_with_secret(None)): + assert _verify_composio_signature(body=_BODY, headers={}) is True + + def test_valid_signature_accepted(self): + sig = _sign(_SECRET, _WEBHOOK_ID, _TIMESTAMP, _BODY) + headers = { + "webhook-signature": sig, + "webhook-id": _WEBHOOK_ID, + "webhook-timestamp": _TIMESTAMP, + } + with patch(_ENV_PATH, _env_with_secret(_SECRET)): + assert _verify_composio_signature(body=_BODY, headers=headers) is True + + def test_valid_signature_with_versioned_prefix_accepted(self): + # Composio sends "v1,"; only the last comma-part is the digest. + sig = _sign(_SECRET, _WEBHOOK_ID, _TIMESTAMP, _BODY) + headers = { + "webhook-signature": f"v1,{sig}", + "webhook-id": _WEBHOOK_ID, + "webhook-timestamp": _TIMESTAMP, + } + with patch(_ENV_PATH, _env_with_secret(_SECRET)): + assert _verify_composio_signature(body=_BODY, headers=headers) is True + + def test_forged_signature_rejected(self): + headers = { + "webhook-signature": "deadbeef", + "webhook-id": _WEBHOOK_ID, + "webhook-timestamp": _TIMESTAMP, + } + with patch(_ENV_PATH, _env_with_secret(_SECRET)): + assert _verify_composio_signature(body=_BODY, headers=headers) is False + + def test_missing_signature_header_rejected(self): + headers = {"webhook-id": _WEBHOOK_ID, "webhook-timestamp": _TIMESTAMP} + with patch(_ENV_PATH, _env_with_secret(_SECRET)): + assert _verify_composio_signature(body=_BODY, headers=headers) is False + + def test_tampered_body_rejected(self): + sig = _sign(_SECRET, _WEBHOOK_ID, _TIMESTAMP, _BODY) + headers = { + "webhook-signature": sig, + "webhook-id": _WEBHOOK_ID, + "webhook-timestamp": _TIMESTAMP, + } + with patch(_ENV_PATH, _env_with_secret(_SECRET)): + assert ( + _verify_composio_signature(body=b'{"type":"tampered"}', headers=headers) + is False + ) + + def test_x_composio_signature_header_alias(self): + sig = _sign(_SECRET, _WEBHOOK_ID, _TIMESTAMP, _BODY) + headers = { + "x-composio-signature": sig, + "webhook-id": _WEBHOOK_ID, + "webhook-timestamp": _TIMESTAMP, + } + with patch(_ENV_PATH, _env_with_secret(_SECRET)): + assert _verify_composio_signature(body=_BODY, headers=headers) is True diff --git a/api/oss/tests/pytest/unit/webhooks/test_webhooks_tasks.py b/api/oss/tests/pytest/unit/webhooks/test_webhooks_tasks.py index 1ca605df49..c479f6afdb 100644 --- a/api/oss/tests/pytest/unit/webhooks/test_webhooks_tasks.py +++ b/api/oss/tests/pytest/unit/webhooks/test_webhooks_tasks.py @@ -5,11 +5,14 @@ from unittest.mock import patch -from oss.src.core.webhooks.delivery import ( +from agenta.sdk.utils.resolvers import ( MAX_RESOLVE_DEPTH, + resolve_target_fields, +) + +from oss.src.core.webhooks.delivery import ( NON_OVERRIDABLE_HEADERS, _merge_headers, - resolve_payload_fields, ) from oss.src.core.webhooks.types import ( EVENT_CONTEXT_FIELDS, @@ -35,18 +38,18 @@ "scope": {"project_id": "proj-1"}, } -_RESOLVE_PATH = "oss.src.core.webhooks.delivery.resolve_json_selector" +_RESOLVE_PATH = "agenta.sdk.utils.resolvers.resolve_json_selector" # --------------------------------------------------------------------------- -# resolve_payload_fields +# resolve_target_fields # --------------------------------------------------------------------------- -class TestResolvePayloadFields: +class TestResolveTargetFields: def test_dict_recurses_into_values(self): with patch(_RESOLVE_PATH, side_effect=lambda expr, ctx: f"resolved:{expr}"): - result = resolve_payload_fields( + result = resolve_target_fields( {"key": "$.event.event_id"}, _MOCK_CONTEXT, ) @@ -54,7 +57,7 @@ def test_dict_recurses_into_values(self): def test_list_recurses_into_items(self): with patch(_RESOLVE_PATH, side_effect=lambda expr, ctx: f"resolved:{expr}"): - result = resolve_payload_fields( + result = resolve_target_fields( ["$.event.event_id", "$.scope.project_id"], _MOCK_CONTEXT, ) @@ -65,12 +68,12 @@ def test_list_recurses_into_items(self): def test_primitive_delegates_to_resolve_json_selector(self): with patch(_RESOLVE_PATH, return_value="abc123") as mock_resolve: - result = resolve_payload_fields("$.event.event_id", _MOCK_CONTEXT) + result = resolve_target_fields("$.event.event_id", _MOCK_CONTEXT) assert result == "abc123" mock_resolve.assert_called_once_with("$.event.event_id", _MOCK_CONTEXT) def test_depth_exceeds_limit_returns_none(self): - result = resolve_payload_fields( + result = resolve_target_fields( "$.event.event_id", _MOCK_CONTEXT, _depth=MAX_RESOLVE_DEPTH + 1, @@ -79,7 +82,7 @@ def test_depth_exceeds_limit_returns_none(self): def test_depth_at_limit_still_resolves(self): with patch(_RESOLVE_PATH, return_value="ok"): - result = resolve_payload_fields( + result = resolve_target_fields( "$.event.event_id", _MOCK_CONTEXT, _depth=MAX_RESOLVE_DEPTH, @@ -88,7 +91,7 @@ def test_depth_at_limit_still_resolves(self): def test_resolve_error_returns_none(self): with patch(_RESOLVE_PATH, side_effect=ValueError("bad selector")): - result = resolve_payload_fields("$.bad[", _MOCK_CONTEXT) + result = resolve_target_fields("$.bad[", _MOCK_CONTEXT) assert result is None def test_error_leaf_in_dict_does_not_affect_other_keys(self): @@ -98,7 +101,7 @@ def side_effect(expr, ctx): return "good" with patch(_RESOLVE_PATH, side_effect=side_effect): - result = resolve_payload_fields( + result = resolve_target_fields( {"ok": "$.event.event_id", "bad": "$.bad["}, _MOCK_CONTEXT, ) @@ -106,14 +109,14 @@ def side_effect(expr, ctx): def test_dollar_selector_resolves_full_context(self): with patch(_RESOLVE_PATH, return_value=_MOCK_CONTEXT) as mock_resolve: - result = resolve_payload_fields("$", _MOCK_CONTEXT) + result = resolve_target_fields("$", _MOCK_CONTEXT) assert result == _MOCK_CONTEXT mock_resolve.assert_called_once_with("$", _MOCK_CONTEXT) def test_nested_dict_depth_tracking(self): # Three levels deep should still work (depth starts at 0) with patch(_RESOLVE_PATH, return_value="leaf"): - result = resolve_payload_fields( + result = resolve_target_fields( {"a": {"b": {"c": "$.event.event_id"}}}, _MOCK_CONTEXT, ) diff --git a/docs/designs/gateway-triggers/gap.md b/docs/designs/gateway-triggers/gap.md new file mode 100644 index 0000000000..d3aca08511 --- /dev/null +++ b/docs/designs/gateway-triggers/gap.md @@ -0,0 +1,140 @@ +# Gateway Triggers — Gap + +The delta between **what exists today** and **what the proposal requires**. Every row is +something that must be built, moved, or decided; the "Source" column names what it is +patterned on (per `mimics.md`), and "Kind" classifies it: + +- **extract** — move shipped code into a shared home (the connection only). +- **mimic** — replicate an existing pattern in new triggers-domain files. +- **net-new** — no precedent; needs a design decision before code (per `mimics.md` § + Triggers vs Everything). +- **decision** — an open question to lock before or during build (from proposal § Risks + and `mapping.md` § Open questions). + +Nothing here changes the outbound `webhooks` domain or the `/tools` HTTP contract — both +are invariants (proposal § Success criteria). + +--- + +## 1. What exists today (the baseline) + +| Capability | Where | Reusable as-is? | +|---|---|---| +| Composio **auth** (initiate/status/refresh/revoke) | `ComposioToolsAdapter` (`core/tools/providers/composio/adapter.py`) | Yes — **extract** the auth verbs to the shared connection adapter | +| Connection persistence | `ToolConnectionDBE` / `tool_connections` (`dbs/postgres/tools/dbes.py:38`) | Yes — **rename** to `gateway_connections` (already domain-neutral) | +| Connection CRUD + OAuth callback | `ToolsService` (`core/tools/service.py:138-383`), `/tools/connections/...` + `/callback` (`router.py:785`) | Yes — **extract** to shared service; `/tools/connections` contract frozen | +| Action catalog (providers/integrations/actions) | `core/tools` catalog + `apis/fastapi/tools` | Pattern only — **mimic** for events | +| Composio call surface (httpx `_get/_post/_delete`, slug mapping) | `ComposioToolsAdapter` | Pattern only — **mimic** for the triggers REST surface | +| Two-table subscription/delivery model | `webhooks`: `webhook_subscriptions` + `webhook_deliveries` (`core/webhooks/`, `dbs/postgres/webhooks/`) | Pattern only — **mimic** (separate tables, no reuse) | +| DBA mixins for a subscription/delivery domain | `dbs/postgres/webhooks/dbas.py` | Pattern only — **mimic** (tools has no `dbas.py`) | +| Payload-mapping template + resolver | `payload_fields` + `resolve_payload_fields` (`core/webhooks/delivery.py:95`) → `resolve_json_selector` (`sdk/utils/resolvers.py:114`) | Resolver **reused** (promote + rename); template **mimicked** as `inputs_fields` | +| Inbound, signature-verified provider webhook | billing `POST /billing/stripe/events/` (`ee/.../billing/router.py:106,240`) | Pattern only — **mimic** the ingress shape | +| Workflow dispatch seam | `WorkflowsService.invoke_workflow` (`core/workflows/service.py:1698`) | Reused **as-is** — no new execution path | +| `env.composio` (api_key/api_url/enabled) | `utils/env.py:507`; wiring `entrypoints/routers.py:578` | Reused; **add** `COMPOSIO_WEBHOOK_SECRET` | + +> Tools never persisted a per-use record and webhooks never had a provider connection; +> **triggers is the first domain that needs both** a connection *and* a per-event standing +> record — which is why the connection is extracted (shared) and the subscription/delivery +> pair is mimicked (triggers-owned). + +--- + +## 2. The gap, by domain + +### 2.1 Shared `connections` domain (extract — A2-2) + +The connection moves out of `/tools` into a routerless shared domain. + +| # | Item | Kind | Source / note | +|---|---|---|---| +| C1 | `gateway_connections` table — rename `tool_connections` (+ `uq_`/`ix_`), no data transform | extract | `dbes.py:38`; table already domain-neutral | +| C2 | Migration authored **once in the shared `core_oss` chain** (runs in both editions), **not** the parked legacy `core` tree nor EE-only `core_ee` | extract | rename op only; `core` is frozen at `park00000000`; `gateway_connections` is shared schema. See `oss-ee-convergence/migration-chains-and-edition-switch.md` | +| C3 | `core/gateway/connections/` — service + DAO + interface, **no router** | extract | from `ToolsService` connection code (`service.py:138-383`) | +| C4 | `ConnectionsGatewayInterface` + Composio **auth** adapter (initiate/status/refresh/revoke) | extract | from `ComposioToolsAdapter` auth verbs | +| C5 | Repoint tools' connection auth at the shared service; `/tools/connections` contract frozen | extract | ~4 code refs: `dbes.py`, `dao.py:72`, `router.py:160` | +| C6 | `/tools/connections` and `/triggers/connections` both delegate to the one shared service over the same rows | mimic | no `/gateway/connections` route exists | +| C7 | **Cross-domain revoke rule**: revoke-for-everyone + show usage; deleting a subscription must not revoke the connection | net-new / decision | no prior connection had two consumers (`mimics.md` §6) | + +### 2.2 `triggers` domain — events catalog + adapter (mimic Tools) + +| # | Item | Kind | Source / note | +|---|---|---|---| +| E1 | Domain skeleton `apis/fastapi/triggers/`, `core/triggers/`, `dbs/postgres/triggers/` | mimic | tools layout | +| E2 | `ComposioTriggersAdapter` (own httpx client; `triggers_types`, `trigger_instances/...`) implementing `TriggersGatewayInterface` | mimic | `ComposioToolsAdapter` shape | +| E3 | Events catalog: `/triggers/catalog/.../integrations/{i}/events/{event_key}` returning the event's `trigger_config` schema | mimic | tools action catalog (`action → event`) | +| E4 | Wiring block in `entrypoints/routers.py` next to tools; adapter built only when `env.composio.enabled` | mimic | `routers.py:578` | +| E5 | **Exact Composio v3 REST paths** for trigger types/instances | decision | verify vs live OpenAPI (SDK names stable) | + +### 2.3 `triggers` domain — subscriptions + deliveries (mimic Webhooks) + +| # | Item | Kind | Source / note | +|---|---|---|---| +| S1 | `subscriptions` table: project-scoped, FlagsDBA (enabled/valid), DataDBA with `ti_*`, `trigger_config`, `inputs_fields`, destination `references`/`selector`, workflow ref; **FK → `gateway_connections`** | mimic | `webhook_subscriptions` (`types.py:116`) | +| S2 | `deliveries` table: one audit row per inbound event — resolved `inputs`, workflow `references`, `result`/`error`; migration defined once in `core_oss` | mimic | `webhook_deliveries` (`types.py:156`) | +| S3 | DBA mixins for both tables | mimic | `dbs/postgres/webhooks/dbas.py` (tools has none) | +| S4 | Subscription CRUD routes `/triggers/subscriptions/` · `/query` · `/{id}` · `/{id}/refresh` · `/{id}/revoke` + create/disable/delete the Composio `ti_*` via the adapter | mimic | `/webhooks/subscriptions/` + adapter calls | +| S5 | Delivery read routes `/triggers/deliveries` · `/{id}` · `/query` | mimic | `/webhooks/deliveries` | + +### 2.4 `triggers` domain — ingress (mimic Billing) + +| # | Item | Kind | Source / note | +|---|---|---|---| +| I1 | `POST /triggers/composio/events/` — read raw body before parsing | mimic | billing `/stripe/events/` | +| I2 | HMAC-SHA256 verify over `{id}.{ts}.{body}` with `COMPOSIO_WEBHOOK_SECRET`; 401 on bad sig; 200 no-op when secret unset | mimic | billing uses `stripe.Webhook.construct_event`; `research.md` § Webhook verification | +| I3 | Recover `project_id` from `metadata.user_id`; route `metadata.trigger_id` → local subscription; 200-skip unknown/disabled | mimic | billing's payload-scoping; `research.md` §1 | +| I4 | **Idempotency** dedup on `metadata.id` (store: column vs cache) | net-new / decision | billing leans on Stripe; we own it | +| I5 | Optional `target`-style env fan-out guard (one Composio webhook URL → many deployments) | decision | cf. `env.stripe.webhook_target` | +| I6 | **One-time project webhook-URL registration** with Composio (API vs dashboard, per-env) | net-new / decision | no precedent (`research.md` §4.2) | + +### 2.5 `triggers` domain — mapping + dispatch (mimic Webhooks resolver + net-new binding) + +| # | Item | Kind | Source / note | +|---|---|---|---| +| M1 | Promote `resolve_payload_fields` → `resolve_target_fields` into `agenta.sdk.utils.resolvers`; update the webhooks call site to the new name | mimic / extract | `mapping.md` §5/§6; lands at this point | +| M2 | `inputs_fields` template stored on the subscription; resolves into `WorkflowServiceRequest.data.inputs` **only** | mimic | `mapping.md` §3, §4.2 | +| M3 | `TRIGGER_EVENT_FIELDS` allowlist (event `data`/`type`/`timestamp`/curated `metadata`; never `ca_*`/secrets); context `{event, subscription, scope}` | mimic | `EVENT_CONTEXT_FIELDS` analogue | +| M4 | Destination = workflow `references` (+ `selector`), the `/retrieve` shape; drop into `request.references` at dispatch | mimic | `mapping.md` §4.1; `invoke_workflow` threads it (`service.py:556-557`) | +| M5 | **Trigger ↔ workflow binding** — store + resolve the workflow ref at dispatch | net-new | no domain binds a provider resource to a workflow | +| M6 | **System-initiated `invoke_workflow`** — what identity (`user_id`) a no-human invocation runs as | net-new / decision | seam only ever called request-scoped (`mimics.md` §2) | +| M7 | **Async dispatch** — ack-fast + enqueue vs inline (avoid webhook timeout → retry storm) | net-new / decision | proposal § Risks | +| M8 | **Default mapping** (`"$"` vs stricter) and **schema validation** of `inputs_fields` against the bound workflow's input schema | decision | `mapping.md` §6 | +| M9 | **Dispatch retry policy** for a failed invocation recorded in `deliveries` vs Composio redelivery | decision | `mapping.md` §6 | + +### 2.6 Frontend + +| # | Item | Kind | Source / note | +|---|---|---|---| +| F1 | "Triggers" surface on a connected integration: events browse, create subscription (pick event + bind workflow + mapping), list/disable/delete | mimic | tools UI (`web/.../gatewayTool`, `web/oss/.../settings/Tools`) | +| F2 | FE expects **overlapping connection reads** across `/tools/connections` and `/triggers/connections` (same rows) | net-new | consequence of A2-2 | +| F3 | Deliveries view (audit log) | mimic | could defer past v1 | + +--- + +## 3. Cross-cutting decisions to lock (consolidated) + +These appear above tagged `decision`; collected here because they gate multiple work items +and should be settled (some before code, some during). + +| Decision | Gates | Lean / default | Lock by | +|---|---|---|---| +| Exact Composio v3 REST paths (E5) | E2, E3, S4 | verify vs live OpenAPI | before adapter code | +| Project webhook-URL registration (I6) | ingress end-to-end test | manual setup step documented if API-less | before ingress test | +| Cross-domain revoke rule (C7) | C3–C6, F2 | revoke-for-everyone + show usage | before connection extract lands | +| Idempotency store (I4) | I-lane, dispatch | column on `deliveries` (dedup on `metadata.id`) | with deliveries table | +| Sync vs async dispatch (M7) | dispatch lane | async (ack-fast) | before dispatch code | +| System-initiated `user_id` (M6) | dispatch lane | a project-system identity (resolve from project) | before dispatch code | +| Default mapping + validation (M8) | subscription create | inputs-only default; validation = stretch | before subscription activate | +| Dispatch retry policy (M9) | deliveries semantics | bounded retries, else rely on Composio | with dispatch | + +--- + +## 4. Out of scope (restating non-goals so the gap isn't read as larger than it is) + +- No merge with / routing through the outbound `webhooks` domain. +- No workflow-hooks involvement. +- No downstream consumer beyond a single `invoke_workflow` per event (no eval/queue/re-emit). +- No new workflow execution path. +- No custom-OAuth ingress registration; managed-auth only. +- No polling fallback we own (Composio normalizes to one webhook). +- No SDK dependency (httpx direct, as tools). +- No EE-only gating beyond what tools already carry. diff --git a/docs/designs/gateway-triggers/mapping.md b/docs/designs/gateway-triggers/mapping.md new file mode 100644 index 0000000000..774508d77d --- /dev/null +++ b/docs/designs/gateway-triggers/mapping.md @@ -0,0 +1,330 @@ +# Gateway Triggers — Mapping & Config + +How the outbound **webhooks** domain lets a subscriber *shape the payload* it receives, +and how the same mechanism applies — in the opposite direction — to mapping an inbound +trigger **event** into a workflow invocation. + +This is the inbound dual of the webhook payload-mapping problem, so we copy the webhook +mechanism rather than invent one. + +--- + +## 1. How webhooks define their mapping today + +A webhook subscription stores a **payload template** and the delivery layer resolves it +against a curated **context** at send time. + +### The config field + +`WebhookSubscriptionData.payload_fields: Optional[Dict[str, Any]]` +(`core/webhooks/types.py:119`). It is an arbitrary JSON structure that doubles as a +template: leaves that are *selector strings* get replaced by values pulled from context; +everything else is passed through literally. + +### The context it resolves against + +At delivery, `prepare_webhook_request` (`core/webhooks/delivery.py:118`) builds a fixed, +**allowlisted** context: + +```python +context = { + "event": {k: v for k, v in event.items() if k in EVENT_CONTEXT_FIELDS}, + "subscription": {k: v for k, v in subscription.items() if k in SUBSCRIPTION_CONTEXT_FIELDS}, + "scope": {"project_id": str(project_id)}, +} +``` + +- `EVENT_CONTEXT_FIELDS` = `{event_id, event_type, timestamp, created_at, attributes}` +- `SUBSCRIPTION_CONTEXT_FIELDS` = `{id, name, tags, meta, created_at, updated_at}` + (`core/webhooks/types.py:26`) + +The allowlist is the security boundary: a subscriber's template can only reference these +keys, never arbitrary internal state. + +### The resolver (the template language) + +`resolve_payload_fields` (`delivery.py:95`) — to be renamed `resolve_target_fields` when +promoted to the SDK (§5/§6) — walks the template recursively; each leaf goes +through `resolve_json_selector` (`sdks/python/agenta/sdk/utils/resolvers.py:114`): + +- string starting with `$` → **JSONPath** against context +- string starting with `/` → **JSON Pointer** against context +- anything else (plain string, number, dict, list) → returned **as-is** (literal) +- resolution failure → `None` (never raises); depth-capped (`MAX_RESOLVE_DEPTH`) + +Default when `payload_fields is None`: `"$"` — i.e. deliver the whole context +(`delivery.py:149`). + +### Worked example (webhooks) + +Template stored on the subscription: + +```json +{ + "kind": "agenta.event", + "type": "$.event.event_type", + "when": "$.event.timestamp", + "project": "$.scope.project_id", + "sub": "$.subscription.name" +} +``` + +Resolved and POSTed to the subscriber URL: + +```json +{ + "kind": "agenta.event", + "type": "traces.queried", + "when": "2026-06-18T10:00:00Z", + "project": "019abc...", + "sub": "my-prod-hook" +} +``` + +So the webhook "mapping" is: **subscriber-authored JSON template + selectors over an +allowlisted context, resolved at delivery.** Static where the subscriber wants constants, +dynamic where they reference `$.event.*` / `$.subscription.*` / `$.scope.*`. + +--- + +## 2. Decompose the webhook subscription: three independent concerns + +`WebhookSubscriptionData` (`core/webhooks/types.py:116`) bundles three concerns that are +actually independent. Separating them is the key to seeing what carries over to triggers +unchanged and what genuinely differs: + +```python +class WebhookSubscriptionData(BaseModel): + url, headers, auth_mode # DESTINATION — where/how to deliver + payload_fields # MAPPING — how to shape the body + event_types # FILTER — which events +``` + +| Concern | Webhook field | Carries to triggers? | +|---------|---------------|----------------------| +| **filter** — which events | `event_types` | **same idea** — which provider event this subscription watches | +| **mapping** — shape the data | `payload_fields` | **same mechanism** — identical resolver + context; the field is named `inputs_fields` because it maps into `data.inputs`, not a whole body (§3, §4.2) | +| **destination** — where it goes | `url`, `headers`, `auth_mode` | **different** — a workflow `references` + `selector`, not a by-value URL (§4.1) | + +So the answer to "why would mapping/context differ?": the **mechanism and context don't** +(same resolver, same `{event, subscription, scope}`). Two things do, and both follow from +the target being an internal workflow rather than an external URL: the **destination** is a +`references`/`selector` (§4.1), and the mapping field maps into **`data.inputs`** rather +than a whole HTTP body, so it is named `inputs_fields` (§4.2). + +--- + +## 3. Same mapping mechanism + context; field named for its target + +### The field — `inputs_fields` (webhooks' `payload_fields`, retargeted) + +Triggers store the **same kind of template** webhooks store in `payload_fields`: a JSON +structure with `$`/`/` selectors over context, same resolver, same default. The **field is +named `inputs_fields`** rather than `payload_fields` because it maps into +`WorkflowServiceRequest.data.inputs` (§4.2), not a whole HTTP body. The name states the +target — the same reason webhooks' field is called *payload*_fields (it maps the payload). + +```text +webhooks subscription: payload_fields → whole HTTP body +triggers subscription: inputs_fields → request.data.inputs +``` + +Mechanism, resolver, and context are identical; only the field name and its target differ. + +### Same context — `{event, subscription, scope}` + +Resist the temptation to expose the raw Composio envelope (`{data, metadata}`) directly. +Keep the **identical three-slot, allowlisted** context webhooks uses — the slots just bind +to the inbound analogues: + +| Slot | Webhooks (outbound) | Triggers (inbound) | +|------|---------------------|--------------------| +| `event` | the Agenta event that fired (allowlisted) | the verified provider event that arrived (allowlisted) | +| `subscription` | the webhook subscription (allowlisted) | the trigger subscription (allowlisted) | +| `scope` | `{project_id}` | `{project_id}` (recovered from `metadata.user_id`) | + +```python +# triggers — same shape as webhooks' prepare_webhook_request context +context = { + "event": {k: v for k, v in inbound_event.items() if k in TRIGGER_EVENT_FIELDS}, + "subscription": {k: v for k, v in subscription.items() if k in SUBSCRIPTION_CONTEXT_FIELDS}, + "scope": {"project_id": str(project_id)}, +} +``` + +`TRIGGER_EVENT_FIELDS` is the triggers analogue of `EVENT_CONTEXT_FIELDS` — an allowlist +over the inbound event (its `data`, `type`, `timestamp`, and curated `metadata` like +`trigger_slug`/`trigger_id`/`toolkit_slug`), never exposing `ca_*`, secrets, or connection +internals. Same discipline, same security boundary, identical resolver +(`resolve_target_fields` → `resolve_json_selector`, `$`/`/` selectors, literal +passthrough, null-on-miss). + +### Worked example (triggers) + +Subscription `inputs_fields` (Gmail "new message" → a triage workflow): + +```json +{ + "subject": "$.event.data.subject", + "from": "$.event.data.from", + "body": "$.event.data.message_text", + "received": "$.event.timestamp", + "watch": "$.subscription.name", + "source": "gmail" +} +``` + +Inbound event at `/triggers/composio/events/` (its allowlisted form becomes `context.event`), +resolved to: + +```json +{ + "subject": "Refund?", "from": "a@x.com", "body": "...", + "received": "2026-06-18T10:00:00Z", "watch": "support-triage", "source": "gmail" +} +``` + +**Important — this resolved object is *not* the whole request.** It becomes only +`WorkflowServiceRequest.data.inputs` (§4.2). The destination (which workflow) comes from a +separately-stored reference (§4.1), and the envelope/auth is filled by `invoke_workflow`. + +--- + +## 4. The two real differences: destination, and *what* the payload maps into + +The actual `invoke_workflow` request type is `WorkflowServiceRequest` +(= `WorkflowInvokeRequest`, `sdks/python/agenta/sdk/models/workflows.py:257-262`): + +```python +WorkflowBaseRequest: + version + references: Dict[str, Reference] # WHICH workflow/revision ← destination + links: Dict[str, Link] + selector: Selector # which slice to extract + secrets, credentials # auth — filled by invoke_workflow internally +WorkflowInvokeRequest(WorkflowBaseRequest): + data: WorkflowRequestData + revision, parameters, testcase, inputs, trace, outputs # the payload area +``` + +This makes two things precise that a naive "webhooks but inbound" framing gets wrong. + +### 4.1 Destination = `references` (+ `selector`), the existing /retrieve shape + +A webhook's destination is described **by value** — `url`, `headers`, `auth_mode` inline. +A trigger's destination is an Agenta **workflow**, an internal entity, so it is described +**by reference** using the **same `Reference` / `Selector` primitives the `/retrieve` and +inspect paths already use** — not an ad-hoc `{workflow_id, ...}`. + +`Reference(Identifier, Slug, Version)` = `{ id?, slug?, version? }` +(`sdks/.../models/shared.py:102`). `invoke_workflow` already threads +`request.references` / `request.selector` straight through (`service.py:556-557`). + +So the subscription stores a workflow **reference** (+ optional selector), and dispatch +drops it into `request.references`: + +```text +webhook destination: { url, headers, auth_mode } ← by value +trigger destination: references: { "workflow": Reference{id|slug, version} } [+ selector] + ← by reference, same as /retrieve +``` + +No new addressing scheme — reuse how workflows are referenced everywhere else. + +### 4.2 The mapping (`inputs_fields`) maps into `data.inputs`, NOT the whole request + +For **webhooks**, `payload_fields` maps to the **entire** HTTP body — an HTTP POST body +*is* the payload; there is nothing else. + +For **triggers**, the request envelope has dedicated structural slots — `references` +(destination, §4.1), `version`, `secrets`/`credentials` (auth, internal). The mapping must +**not** produce those. It produces only the "data fed in" slot, hence the field name +`inputs_fields`: + +```text +WorkflowServiceRequest +├─ references / selector ← destination (from §4.1; NOT from inputs_fields) +├─ version, secrets, credentials ← envelope/auth (internal; NOT mapped) +└─ data: WorkflowRequestData + └─ inputs ◄──────────────── inputs_fields resolves into HERE (and only here) +``` + +So the asymmetry, stated exactly: + +```text +webhooks: payload_fields → the whole HTTP body +triggers: inputs_fields → request.data.inputs (a sub-field of the request) +``` + +Whether any *other* `data.*` sub-fields are mappable (`parameters`? `testcase`?) is an open +call (§6); the safe default is **inputs only**. + +### 4.3 Deliveries (same pair, different fields) + +Webhooks is a **two-table** domain: `webhook_subscriptions` (the standing config) **and** +`webhook_deliveries` (one audit row per attempt) — `WebhookDelivery` / +`WebhookDeliveryData{url, headers, payload, response, error}` (`types.py:156`), with routes +`/webhooks/deliveries`, `/{id}`, `/query` (`router.py:110`). + +Triggers mirrors the pair: `subscriptions` **and** `deliveries`. A delivery row records one +inbound event being dispatched to its workflow — the by-reference destination, the resolved +inputs, and the outcome: + +```text +WebhookDeliveryData { url, headers, payload, response{status_code, body}, error } +TriggerDeliveryData { references (workflow), inputs (resolved inputs_fields), result, error } +``` + +This is the right call (not "maybe"): a delivery record is needed precisely for the cases +where the workflow's own trace does **not** exist — dispatch that fails *before* invocation +(bad mapping, workflow not found, connection invalid) or is deduped/skipped. It is also the +retry and observability surface, exactly as `webhook_deliveries` is for the outbound side. +Full table/route symmetry in `mimics.md` § Triggers vs Webhooks. + +--- + +## 5. What we reuse vs. what's new + +| Piece | Status | +|-------|--------| +| Mapping field | **same mechanism, retargeted name** — `inputs_fields` (vs. `payload_fields`); maps `data.inputs`, not a whole body | +| Context shape `{event, subscription, scope}` + allowlist discipline | **identical** — define `TRIGGER_EVENT_FIELDS` like `EVENT_CONTEXT_FIELDS`; reuse `SUBSCRIPTION_CONTEXT_FIELDS` | +| Selector resolver (`resolve_json_selector`) | **reuse** — already in `agenta.sdk.utils.resolvers` | +| Recursive template walk (`resolve_payload_fields` → `resolve_target_fields`) | **reuse + rename** — promote from `core/webhooks/delivery.py` to the SDK under the neutral name `resolve_target_fields`, so both domains consume it (avoids triggers→webhooks import) | +| `event_types` filter | **same idea** — which provider event the subscription watches | +| Destination | **reuse a different primitive** — workflow `Reference`/`Selector` (the `/retrieve` shape) instead of `url/headers/auth_mode` | +| Mapping *target* | **different** — `inputs_fields` resolves into `data.inputs` only, not the whole request (webhooks maps the whole body) | +| Two-table domain (subscriptions + deliveries) | **same shape** — `subscriptions` + `deliveries`, mirroring `webhook_subscriptions` + `webhook_deliveries` | +| Delivery record fields | **different fields, same idea** — `references + inputs + result` vs. `url + payload + response` | + +Net: **the resolver, the mapping mechanism, and the `{event, subscription, scope}` +context are reused/identical**, and like webhooks it is a **two-table** domain +(subscriptions + deliveries). The real differences all follow from the target being an +internal workflow: (a) the destination is a workflow *reference* (the `/retrieve` +`Reference`/`Selector`, not a by-value URL), and (b) the mapping field is `inputs_fields` +landing in `data.inputs`, not the whole body. + +--- + +## 6. Open questions + +- **Default mapping** — webhooks defaults `payload_fields` to `"$"` (whole context). + Triggers feeding a *typed* workflow may want a stricter `inputs_fields` default (e.g. + `"$.event.data"`) or require an explicit mapping before the subscription can activate. +- **Validation against the workflow's input schema** — should creating a subscription + validate `inputs_fields`' resolved shape against the bound workflow revision's expected + inputs? Webhooks has no downstream schema to check; triggers does — a new opportunity and + a new failure mode. +- **Delivery retries** — webhooks has `WEBHOOK_MAX_RETRIES = 5` on the outbound leg. What + is the retry policy for a failed *dispatch* (workflow invocation) recorded in + `deliveries`, vs. relying on Composio's own inbound redelivery? (The `deliveries` table + itself is decided — see §4.3.) +- **`TRIGGER_EVENT_FIELDS` contents** — which inbound-event keys to expose + (`data`, `type`, `timestamp`, curated `metadata`); keep `ca_*`/secrets out. +- **Resolver location + rename** — `resolve_payload_fields` lives in the webhooks domain; + promote it next to `resolve_json_selector` in `agenta.sdk.utils.resolvers` under the + neutral name **`resolve_target_fields`** (it resolves a template into *a* target, + whichever consumer's — whole body for webhooks, `data.inputs` for triggers), so triggers + and webhooks both consume it from the SDK. The webhooks call site updates to the new name + at that point — a docs-level decision now; the actual rename lands when the SDK promotion + happens (during the triggers build). diff --git a/docs/designs/gateway-triggers/mimics.md b/docs/designs/gateway-triggers/mimics.md new file mode 100644 index 0000000000..74e6a4c0b7 --- /dev/null +++ b/docs/designs/gateway-triggers/mimics.md @@ -0,0 +1,307 @@ +# Gateway Triggers — Mimics & Contrasts + +This doc maps each part of the work onto the existing Agenta pattern it relates to. +Two relationship kinds are used, and they are different: + +- **mimic** — *replicate the pattern in new triggers-domain files* (copy structure, swap + nouns; no imports across the boundary). Applies to events catalog, subscriptions, + ingress, dispatch. +- **share/extract** — *the same code/table serves both domains.* Applies to **one** thing + only: provider **connections** (`ca_*`), which are pulled out of `/tools` into a shared + `connections` domain and consumed by both (decision **A2-2**). + +Terminology: the triggers catalog leaf is an **event** (≈ a tools **action**). The created +state is **two** records with **different owners**: + +- **connection** — durable provider auth (`ca_*`). A **shared, gateway-level** record + (`gateway_connections`, renamed from `tool_connections`), used by both tools and + triggers. Not triggers-owned. +- **subscription** — a standing watch on one event (`ti_*` + config + workflow, FK → + connection), owned by the triggers domain. Modeled on a webhook subscription. Split from + the connection because one `ca_*` backs many `ti_*`. + +This file is organized as a set of pairwise comparisons: + +- [Triggers vs Tools](#triggers-vs-tools) — the structural template (events catalog, adapter) + the **shared** connection (extracted from tools) +- [Triggers vs Billing](#triggers-vs-billing) — the inbound-event ingress template +- [Triggers vs Webhooks](#triggers-vs-webhooks) — the two **subscription** species + the directional mirror +- [Triggers vs Everything (the net-new parts)](#triggers-vs-everything-the-net-new-parts) + +A one-line map of where each part comes from: + +| Part | Relationship | Source | +|------|--------------|--------| +| **event** catalog, triggers adapter, domain layout | mimic | **Tools** | +| provider **connection** (`ca_*`) | **share/extract** | **Tools** → shared `gateway_connections` | +| the **subscription** + **delivery** tables (two-table domain, CRUD, lifecycle) | mimic | **Webhooks** (`webhook_subscriptions` + `webhook_deliveries`) | +| inbound event endpoint, signature verify, payload-based scoping | mimic | **Billing** (Stripe `/stripe/events/`) | +| trigger↔workflow binding, system-initiated dispatch, idempotency | net new | **nothing** | + +> **Two parents, plus one shared organ.** The triggers code is a cross of **tools** +> (catalog/adapter machinery) and **webhooks** (the subscription model + lifecycle); the +> ingress endpoint comes from **billing**. Separately, the provider **connection** is not +> re-created at all — it is extracted from tools into a shared `connections` domain that +> both tools and triggers sit on (A2-2). The one sanctioned cross-domain runtime calls are +> triggers → the shared connections service (auth) and triggers → +> `WorkflowsService.invoke_workflow` (dispatch). + +--- + +## Triggers vs Tools + +Tools relates to triggers in **two** different ways, and it's important not to conflate +them: + +- **mimic** — the triggers *event catalog* and *Composio adapter* replicate the tools + catalog/adapter structure in new files. +- **share/extract** — the tools *connection* is not copied; it is **moved** into a shared + `connections` domain that both tools and triggers consume. + +### Part A — mimic: events catalog + triggers adapter + +New triggers-domain files, modeled on tools, swapping `action → event`: + +| Aspect | `/tools` | `/triggers` (new files, same shape) | +|--------|----------|-------------------------------------| +| Domain layout | `apis/fastapi/tools/`, `core/tools/`, `dbs/postgres/tools/` | `apis/fastapi/triggers/`, `core/triggers/`, `dbs/postgres/triggers/` | +| Layering | Router → Service → DAOInterface + GatewayInterface → impls | identical | +| Wiring | `tools` block in `entrypoints/routers.py:578` | `triggers` block next to it | +| Adapter | `ComposioToolsAdapter` (httpx, no SDK) | own `ComposioTriggersAdapter` (httpx, no SDK) | +| Catalog leaf | **actions** + `input_parameters` schema | **events** + `trigger_config` schema | +| Catalog route | `.../integrations/{i}/actions/{action_key}` | `.../integrations/{i}/events/{event_key}` | +| Env gate | `env.composio` | `env.composio` (shared value) + `COMPOSIO_WEBHOOK_SECRET` | + +### Part B — share/extract: the provider connection + +The tools connection (`ca_*`, OAuth, refresh, revoke) is **the same object** triggers +needs for auth. Rather than re-create it, extract it from `/tools` into a shared +`connections` domain (decision A2-2): + +| Aspect | before (tools-owned) | after (shared) | +|--------|----------------------|----------------| +| Table | `tool_connections` | `gateway_connections` (renamed; already domain-neutral) | +| Code | `core/tools` connection code + `ComposioToolsAdapter` auth methods | `core/gateway/connections/` + a `ConnectionsGatewayInterface` auth adapter | +| Router | `/tools/connections` router | **none of its own** — shared service has no router | +| HTTP surface | `/tools/connections` | `/tools/connections` **and** `/triggers/connections`, both delegating to the shared service (same rows) | +| Auth verbs | `initiate_connection`, `refresh`, `revoke`, `get_status` | unchanged, now in the shared service | +| Consumers | tools only | tools **and** triggers | + +The tools `/tools/connections` HTTP contract is unchanged; its handlers delegate to the +shared service. `ToolsService` connection management (`core/tools/service.py:138-383`) is +the code that *moves* (lightly generalized), not code that triggers re-creates. + +### Where they differ + +| | Tools | Triggers | +|---|-------|----------| +| Direction | outbound (we call the provider) | inbound (the provider calls us) | +| Source of work | an LLM/agent tool call | a provider event | +| Per-event work | synchronous response to caller | invoke the bound Agenta workflow | +| Per-use record | *(ephemeral tool call — nothing persisted)* | a **subscription** (`ti_*` + config + workflow), FK → shared connection | +| Relation to connection | uses it directly to call actions | references it from a standing subscription | +| Extra surface | — | an inbound ingress endpoint (no tools analogue — see Billing) | + +> **Connect once, used by both.** Because the connection is shared, a Gmail connected for +> tools is immediately usable by triggers and vice-versa — no second OAuth consent. The +> cost is a cross-domain revoke rule (revoking `ca_*` affects both; deleting a subscription +> must not revoke the connection). This is the inverse of rejected option B, where each +> domain owned its own connection and the user connected twice (see +> [Triggers vs Everything](#triggers-vs-everything-the-net-new-parts) and +> `proposal.md` § Alternatives). + +--- + +## Triggers vs Billing + +**Relationship: the ingress template.** The inbound event endpoint has **no analogue in +tools** (tools are outbound). Its only precedent in the codebase is billing's Stripe +webhook — Agenta's one existing inbound, signature-verified provider-event handler. This +is the most important pattern to copy correctly. + +Reference: `handle_events` at `api/ee/src/apis/fastapi/billing/router.py:240`, route at +`:106`. + +### What lines up (billing) + +| Aspect | `/billing` (Stripe) | `/triggers` (Composio) | +|--------|---------------------|------------------------| +| Route shape | `POST /billing/stripe/events/` | `POST /triggers/composio/events/` | +| Convention | `{domain}/{provider}/events/` | same | +| Body handling | `await request.body()` before parsing | same — raw body required for verify | +| Verification | `stripe.Webhook.construct_event(payload, sig, env.stripe.webhook_secret)` | HMAC-SHA256 over `{id}.{ts}.{body}`, `COMPOSIO_WEBHOOK_SECRET` | +| Bad signature | 401, return | 401, return | +| Unconfigured provider | 200 no-op (`"Stripe not configured"`) | 200 no-op if secret unset | +| Irrelevant/skipped event | 200 skip (so provider stops retrying) | 200 skip (unknown `trigger_id`, disabled, duplicate) | +| Tenant scope | from payload `metadata.organization_id` | from payload `metadata.user_id` → `project_id` | +| Routing key | event `type` | `metadata.trigger_id` → local row | +| Env fan-out guard | `metadata.target == env.stripe.webhook_target` | optional `target`-style guard (see below) | +| Boundary decorator | `@intercept_exceptions()` | same | + +Handler skeleton to lift: + +```python +payload = await request.body() # raw body BEFORE parsing — required for verify +# verify provider signature against raw body + secret; on failure → 401 + return +# extract scope from the payload, look up the local record, act +# always 2xx for events you intentionally skip (so the provider doesn't retry) +``` + +### Where they differ (billing) + +| | Billing (Stripe) | Triggers (Composio) | +|---|------------------|---------------------| +| Scope key | `organization_id` | `project_id` (from `user_id`) | +| What the event drives | subscription/meter state changes | invoke an Agenta workflow | +| Processing | effectively synchronous in-handler | likely ack-fast + async dispatch (avoid webhook timeout/retry storms) | +| Dedup | relies on Stripe semantics | **we** dedup on `metadata.id` (new) | +| Edition | EE-only | wherever tools ship | + +> **Worth copying: the `webhook_target` filter.** Stripe lets one account fan out to +> dev/staging/prod without cross-talk by checking `metadata.target` against +> `env.stripe.webhook_target`. One Composio project's single webhook URL serving multiple +> Agenta deployments has the same need — a `target`-style guard is a reasonable copy. + +--- + +## Triggers vs Webhooks + +**Relationship: the subscription + delivery model — and the conceptual mirror.** The +outbound `webhooks` domain (`api/oss/src/core/webhooks/`) matters to triggers in two +distinct ways: it owns the **two-table subscription/delivery** model the trigger records +are patterned on, *and* it is the directional mirror of the whole feature. As always: copy +the pattern into new files, do not touch `core/webhooks/`. + +Webhooks is a **two-table domain**: `webhook_subscriptions` (standing config) + +`webhook_deliveries` (one audit row per attempt). Triggers mirrors the **same pair**: +`subscriptions` + `deliveries`. + +### Part A1 — the two subscription species + +A **webhook subscription** already exists: a project subscribes to internal Agenta events +and they are delivered *out* to a URL. A **trigger subscription** is the inbound dual: a +project subscribes to provider events and they are delivered *in* to a workflow. Same +noun, same lifecycle shape, opposite direction. + +Webhook subscription shape — `WebhookSubscription` / +`WebhookSubscriptionData{url, event_types, auth_mode, secret, payload_fields}` (`core/webhooks/types.py:116`), +routes `/webhooks/subscriptions/` · `/query` · `/{id}` · `/{id}/test` +(`apis/fastapi/webhooks/router.py:55`). + +| Aspect | webhook subscription | trigger subscription | +|--------|----------------------|----------------------| +| Noun / table | `webhook_subscriptions` | `subscriptions` (triggers domain) | +| Routes | `/webhooks/subscriptions/` + `/query` + `/{id}` + `/{id}/test` | `/triggers/subscriptions/` + `/query` + `/{id}` + `/{id}/refresh` + `/{id}/revoke` | +| What you subscribe to | internal `EventType`s (`event_types`) | a provider **event** (Composio trigger type) | +| Direction | event delivered **out** to `data.url` | event delivered **in**, dispatched to a workflow | +| Destination | customer URL (`url/headers/auth_mode`, by value) | workflow `references` + `selector` (by reference) | +| Mapping field | `payload_fields` → whole body | `inputs_fields` → `data.inputs` (see `mapping.md`) | +| Secret | `secret` / `secret_id` (we sign outgoing) | `COMPOSIO_WEBHOOK_SECRET` (we verify incoming) | +| Project-scoped record w/ lifecycle | yes | yes | +| Mixins | `Identifier, Lifecycle, Header, Metadata` | same + `FlagsDBA`, `DataDBA` for `ti_*` + config + workflow ref + FK → connection | + +### Part A2 — the two delivery species + +`webhook_deliveries` records each outbound attempt; `deliveries` (triggers) records each +inbound event dispatched to its workflow. Same role (audit + retry surface), fields differ +only where the destination differs. + +`WebhookDelivery` / `WebhookDeliveryData{url, headers, payload, response{status_code, body}, error}` +(`core/webhooks/types.py:156`), routes `/webhooks/deliveries` · `/{id}` · `/query` +(`router.py:110`). + +| Aspect | webhook delivery | trigger delivery | +|--------|------------------|------------------| +| Table | `webhook_deliveries` | `deliveries` (triggers domain) | +| Routes | `/webhooks/deliveries` · `/{id}` · `/query` | `/triggers/deliveries` · `/{id}` · `/query` | +| One row per | outbound POST attempt | inbound event dispatched | +| Destination fields | `url`, `headers` | `references` (workflow) | +| Payload fields | `payload` (sent body) | `inputs` (resolved `inputs_fields`) | +| Outcome fields | `response{status_code, body}`, `error` | `result`, `error` | +| Why it exists | audit + retry of a failed POST | audit + retry of a failed dispatch — and the **only** record when dispatch fails *before* invocation (bad mapping, workflow not found), where no workflow trace exists | + +> The trigger `deliveries` table is **decided, not optional** — it is the dual of +> `webhook_deliveries`, and it is the sole audit/retry surface for dispatches that never +> reach the workflow. (Reasoning in `mapping.md` §4.3.) + +A trigger subscription is modeled on a webhook subscription for its **subscribe-to-events +lifecycle** (a project-scoped record naming what to watch, with CRUD + a secret). It does +**not** carry the provider auth — that lives in the shared `gateway_connections` row it +FKs to (A2-2). So: + +```text +trigger subscription = webhook subscription (subscribe to an event, /subscriptions CRUD, lifecycle) + + FK → shared connection (provider auth: ca_*, in the connections domain) + + workflow binding (net-new — see last section) +``` + +The connection half is **shared, not bundled** — see [Triggers vs Tools, Part B](#part-b--shareextract-the-provider-connection). + +### Part B — the directional mirror (the framing) + +```text +outbound webhooks: Agenta event ──▶ customer URL (we sign + POST out) +gateway triggers: provider event ──▶ Agenta workflow (we verify + invoke in) +``` + +As `webhooks` is to Agenta events, triggers are to provider events — pointed inward and +ending in a workflow. + +| | Outbound `webhooks` | Triggers | +|---|---------------------|----------| +| Direction | sender (Agenta → customer) | receiver (Composio → Agenta) | +| HMAC role | we **sign** outgoing | we **verify** incoming | +| Where the "subscription" lives | the Agenta `webhook_subscriptions` row | the Agenta `subscriptions` row **and** a Composio trigger instance it mirrors | +| Deliveries/retries | owned here (`WEBHOOK_MAX_RETRIES = 5`, delivery records) | inbound leg owned by Composio; our dispatch is the new part | +| Destination | an arbitrary customer URL | an Agenta workflow | +| Event source | internal `EventType`s | external provider events | +| Code reuse | **none** — must not route through it | — | + +> Despite the shared "subscription" noun and lifecycle, do **not** route trigger ingress +> through the webhooks subscription/delivery machinery, and do not share its tables. They +> are separate domains that happen to be duals — the similarity is a pattern to copy, not +> code to reuse. + +--- + +## Triggers vs Everything (the net-new parts) + +These have **no precedent** in tools, billing, or webhooks. They must be designed, and +they deserve the most review. + +1. **Trigger ↔ workflow binding.** Storing a workflow ref (workflow + + revision/environment) on the trigger row and resolving it at dispatch. Nothing in any + domain binds a provider resource to a workflow. + +2. **System-initiated `invoke_workflow`.** The seam exists + (`WorkflowsService.invoke_workflow`, `core/workflows/service.py:1698`) but has only + been called from human-initiated, request-scoped paths. A no-human, event-triggered + invocation is new — what identity it runs as is an open decision (proposal §Risks). + +3. **Event → `WorkflowServiceRequest` mapping.** Shaping an arbitrary provider event + payload into workflow inputs. No existing code maps external JSON into a workflow + request; the schema-mapping question is non-trivial. + +4. **Async dispatch + idempotency.** Billing's handler is effectively synchronous and + leans on Stripe's dedup. Invoking a workflow inline risks webhook timeouts → provider + retries → duplicate runs. Ack-fast-then-dispatch + `metadata.id` dedup is new behavior. + +5. **One-time project webhook-URL registration with Composio.** Tools never registered an + *inbound* URL with a provider; Stripe's is configured out-of-band in its dashboard. + How Composio's is registered (API vs dashboard) and managed per-environment is new + operational surface. + +6. **Connection extraction + cross-domain revoke (A2-2).** Pulling `tool_connections` out + into a shared `gateway_connections` domain is a migration + repoint of shipped tools + code (cheap — the table is already domain-neutral, ~4 refs). The genuinely *new + behavior* is the cross-domain lifecycle rule: revoking a shared `ca_*` affects both + tools and triggers (lean: revoke-for-everyone + show usage), and deleting a subscription + must not revoke the connection. No prior domain had a connection with two consumers. + +> Rule of thumb by relationship kind: +> - **mimic** (Tools §A events/adapter, Webhooks subscription, Billing ingress) — replicate +> the named file's structure into a new triggers-domain file and adjust nouns; never +> import or subclass across the boundary. +> - **share/extract** (Tools §B connection) — move the code into the shared `connections` +> domain and have both tools and triggers depend on it; the shared service *is* imported +> by both (that's the point). +> - **net new** (this section) — needs a design decision before code. diff --git a/docs/designs/gateway-triggers/plan.md b/docs/designs/gateway-triggers/plan.md new file mode 100644 index 0000000000..74a6f26830 --- /dev/null +++ b/docs/designs/gateway-triggers/plan.md @@ -0,0 +1,409 @@ +# Gateway Triggers — Plan + +Work breakdown for the gap (`gap.md`). The work splits into seven units; we look at them +through **three different lenses**, each with its own dependency semantics. Same seven units +in every view — only the edges differ. + +| View | Unit | Edge means | Fan-in? | Answers | +|------|------|-----------|---------|---------| +| **Work Packages** (WP) | a unit of functionality | *X functionally needs Y* (code/data dependency) | **yes** — the true DAG | what depends on what | +| **Work Lanes** (WL) | a GitButler branch | *X is `--anchor`ed on Y* (merge/review tree) | **no** — one parent per branch | how it merges | +| **Work Streams** (WS) | a parallel build assignment | *X builds against Y's frozen contract* (stub until merged) | n/a — all run at once | who builds what concurrently | + +Each WP closes a set of `gap.md` items and is independently **reviewable** (a coherent diff) +and **functional** (does something real and testable on its own) — see §3 for per-package +detail. The same unit carries one id in each view: a package is `WP{k}`, its lane node +`WL{k}`, its stream slot `WS{k}` (same `k`). + +**The seven units** (full scope in §3): + +| k | Unit | Area | +|---|------|------| +| 0 | Connection extract (A2-2): shared `gateway_connections` + service | api (touches shipped tools) | +| 1 | Events catalog + `ComposioTriggersAdapter` | api | +| 2 | Resolver promotion to SDK (`resolve_target_fields`) | sdk + webhooks | +| 3 | Subscriptions + deliveries tables + CRUD | api | +| 4 | Ingress + dispatch (receive → resolve → invoke → record) | api | +| 5 | Web: catalog + connections UI | web | +| 6 | Web: subscriptions + deliveries UI | web | + +--- + +## 1. Work Packages — functional dependencies (the true DAG, fan-in allowed) + +What each unit needs to *work*, from the data model and call graph. This is the ground truth; +the other two views are derived from it. Fan-in is real here — a node can need two others. + +```text +WP0 ─────────────┬──────────────▶ WP3 ──────────┬──────────▶ WP4 +(gateway_conns) │ (FK + adapter) ▲ │ (tables) + │ │ │ ▲ +WP1 ──┬──────────┘ │ │ │ +(catalog+adapter) │ (adapter)──────┘ │ │ + │ └────────────────────────▶ WP5 │ │ + │ (catalog+conns) │ │ +WP2 ──────────────────────────────────────────────┘ │ (resolver) +(resolver→SDK) │ + WP6 ─┘ + (subs/deliveries API ← WP3) +``` + +Edges (X ← Y reads "X functionally needs Y"): + +- **WP3 ← WP0** — `subscriptions` FKs `gateway_connections` (gap S1). +- **WP3 ← WP1** — creating the `ti_*` calls `ComposioTriggersAdapter.create_subscription` + (the *adapter*, not the catalog routes). → WP3 fans in on {WP0, WP1}. +- **WP4 ← WP3** — dispatch reads a subscription, writes a delivery row. +- **WP4 ← WP2** — dispatch imports the promoted `resolve_target_fields`. → WP4 fans in on + {WP3, WP2}. +- **WP5 ← WP1** (catalog API) **and ← WP0** (the `/…/connections` view over + `gateway_connections`). → WP5 fans in on {WP1, WP0}. +- **WP6 ← WP3** — the `/triggers/subscriptions` + `/triggers/deliveries` API. +- **WP0, WP1, WP2** — no in-feature dependency (roots). + +--- + +## 2. Work Lanes — merge tree (GitButler `--anchor`, no fan-in) + +A GitButler series is linear: each branch has exactly **one** `--anchor` parent (two parents +is a merge commit, which collapses the stack — `vibes/AGENTS.md`: "series need linear +history"). So the WP DAG must be **projected onto a tree**: every WP fan-in is resolved by +anchoring on *one* functional parent; the other functional parent(s) must simply be a +**transitive ancestor** in the tree (so the needed code is present in the branch). Fan-**out** +is allowed (a parent may have many children). + +The constraint that shapes the tree: **WP4 needs WP2's resolver**, so WP2 must sit on the +line *below* WP4 (an ancestor), not on a sibling branch — otherwise that edge would be a +fan-in the tree can't hold. Placing WP2 between WP1 and WP3 satisfies it: + +```text +main +└─ WL0 wp0-connections-extract + └─ WL1 wp1-events-catalog --anchor wp0 + ├─ WL2 wp2-resolver-promote --anchor wp1 (on the WL4 line, so WP2 is WL4's ancestor) + │ └─ WL3 wp3-subscriptions --anchor wp2 (ancestors wp2,wp1,wp0 ✓ cover WP0+WP1) + │ ├─ WL4 wp4-ingress-dispatch --anchor wp3 (ancestors incl. wp2 ✓ + wp3 ✓) + │ └─ WL6 wp6-web-subscriptions --anchor wp3 + └─ WL5 wp5-web-catalog --anchor wp1 (ancestors wp1,wp0 ✓) +``` + +**Every functional edge from §1 is covered by a tree ancestor**, with no branch having two +parents: + +| WP needs | satisfied in tree by | +|----------|----------------------| +| WP3 ← WP0, WP1 | WL3 anchored on WL2; WL0, WL1 are ancestors | +| WP4 ← WP3, WP2 | WL4 anchored on WL3; WL2 is an ancestor | +| WP5 ← WP1, WP0 | WL5 anchored on WL1; WL0 is an ancestor | +| WP6 ← WP3 | WL6 anchored on WL3 | + +Each PR sets `--base` to its anchor so the diff stays scoped. Merge is bottom-up along the +tree; because every dependency is a structural ancestor, **no cross-branch merge-order +coordination is required** — the property we couldn't get from parallel lanes. + +> Trade-off of the tree: it linearizes WP2 and WP5/WP6 under the WP1 line. That is a *merge* +> topology only — it does **not** mean they must be *built* in that order. See Work Streams. + +--- + +## 3. Work Streams — parallel subagent assignments (build against contracts, not merged code) + +A WS is a **self-contained build assignment** that one subagent can take end-to-end *right +now*, **in parallel with every other stream**, even though the feature's e2e behavior can't +be exercised until upstream WPs land. The lane tree (§2) is a merge topology; the WP DAG (§1) +is a runtime dependency graph. Neither is a build schedule — **all seven streams can be in +flight simultaneously** if each builds against an agreed *contract* rather than against the +other's merged code. + +**What makes that possible — freeze the inter-package contracts first (WS-PRE):** + +- `ConnectionsGatewayInterface` (WP0 ↔ WP3/WP5) — the shared-connection service signatures. +- `TriggersGatewayInterface` incl. `create_subscription` (WP1 ↔ WP3) — the adapter surface. +- `resolve_target_fields(template, context)` (WP2 ↔ WP4) — the resolver signature + the + `{event, subscription, scope}` context shape. +- The subscription/delivery **DTOs** and the `/triggers/*` **route+payload shapes** + (WP3 ↔ WP4/WP6, WP1 ↔ WP5). + +These are small, decidable up front (they're already specified across `mapping.md`, +`mimics.md`, and §4 here). Once frozen, a downstream stream codes against the interface and +**mocks/stubs the dependency in its own unit tests**; the real wiring + e2e test happens when +the dependency merges into its WL ancestor. + +```text +contracts frozen (WS-PRE) + ├─ WS0 WP0 connection extract ┐ + ├─ WS1 WP1 catalog + adapter │ all seven run concurrently; + ├─ WS2 WP2 resolver → SDK │ each subagent builds its WP to a + ├─ WS3 WP3 subscriptions │ complete, unit-tested PR against the + ├─ WS4 WP4 ingress + dispatch │ frozen contracts + stubs for upstream + ├─ WS5 WP5 web catalog/connections │ + └─ WS6 WP6 web subscriptions/deliv. ┘ + → e2e tests light up as WLs merge bottom-up +``` + +What each stream stubs until its dep is real (everything else it owns outright): + +| Stream | Builds | Stubs (frozen contract) until dep merges | +|--------|--------|-------------------------------------------| +| WS0 | shared connections service + migration | — (root) | +| WS1 | catalog + `ComposioTriggersAdapter` | — (root; live Composio creds for the real test) | +| WS2 | resolver move + webhooks repoint | — (root; webhooks suite is the proof) | +| WS3 | subscription/delivery tables + CRUD | `ConnectionsGatewayInterface` (WP0), `TriggersGatewayInterface` (WP1) | +| WS4 | ingress + dispatch | subscription DTO/DAO (WP3), `resolve_target_fields` (WP2) | +| WS5 | catalog/connections UI | catalog API (WP1), `/…/connections` (WP0) — mocked HTTP | +| WS6 | subscription/deliveries UI | `/triggers/subscriptions` + `/deliveries` API (WP3) — mocked HTTP | + +So the streams are assigned to subagents by **area** and run fully in parallel — api (0,1,3,4), +sdk+webhooks (2), web (5,6) — with the contract freeze (WS-PRE) as the one thing that must +happen before fan-out. The only sequential constraint left is *when e2e (not unit) tests can +pass*, and that follows the WL merge order automatically. + +--- + +## 4. Work packages (detail) + +Each WP lists scope, the gap items it closes, dependencies, and the acceptance bar. "AC" +follows the house rule: ungated endpoints get acceptance tests in **both** editions (OSS +basic account, EE inline business+developer account) — see `feedback_oss_ee_test_accounts`. + +### WP0 — Connection extract (A2-2) · WL0 root (anchor `main`) · WS0 + +Move the provider connection out of `/tools` into the shared, routerless `connections` +domain, leaving the `/tools/connections` contract byte-for-byte unchanged. + +- **Closes:** C1, C2, C3, C4, C5, C6 (and lands the C7 *rule* in code). +- **Scope:** + - Rename `tool_connections` → `gateway_connections` (+ `uq_`/`ix_`); rename-only (no data + transform). Author the revision **once in the shared `core_oss` chain** (rooted + `oss000000000`, version table `alembic_version_oss`), which runs in **both** editions — + EE ships the `oss/` tree and runs it from there (no copy in `core_ee`). **Not** the + parked legacy `core` tree (frozen at `park00000000`, where `tool_connections` was + originally added) and **not** `core_ee` (that chain is EE-only divergence; + `gateway_connections` is shared schema). See + `docs/designs/oss-ee-convergence/migration-chains-and-edition-switch.md`. + - Create `core/gateway/connections/` (service + DAO + `ConnectionsGatewayInterface`) and + `dbs/postgres/gateway/connections/` (DBE + DAO + mappings). **No router.** + - Move the Composio auth verbs (initiate/status/refresh/revoke) out of + `ComposioToolsAdapter` into the shared connection adapter. + - Repoint `ToolsService` connection management at the shared service; the + `/tools/connections` and `/callback` handlers now delegate. Fix the ~4 `tool_connections` string refs + (`dao.py:72` error match, `router.py:160` operation_id). + - Implement the **cross-domain revoke rule** (C7): revoke affects all consumers; expose a + "used by" usage read. (No trigger consumer exists yet — this is the rule + the seam.) +- **Functional deps (WP):** none (a root). +- **Lane (WL):** `WL0`, anchored on `main` — the tree root. +- **Stream (WS):** `WS0` — api area; a root, no stubs; runs in parallel with all streams. +- **Decision to lock first:** cross-domain revoke rule (gap C7). +- **AC:** every existing `/tools/connections` test passes **unchanged** (the contract-frozen + invariant); migration up/down clean on both editions; connect/refresh/revoke still work + end-to-end via `/tools/connections`. +- **Risk:** this is the one PR that edits shipped tools code. Keep it a pure refactor + + rename — no behavior change visible at `/tools`. Largest blast radius; review first. + +### WP1 — Triggers skeleton + events catalog + adapter · WL1 (anchor WL0) · WS1 + +Stand up the triggers domain, the read-only events catalog, and the triggers adapter. + +- **Closes:** E1, E2, E3, E4 (and resolves E5). +- **Scope:** + - Domain skeleton `apis/fastapi/triggers/`, `core/triggers/`, `dbs/postgres/triggers/` + (mirror tools layout). + - `ComposioTriggersAdapter` (own httpx client; `triggers_types`, + `trigger_instances/...`) behind `TriggersGatewayInterface`. + - Events catalog: `/triggers/catalog/.../integrations/{i}/events/{event_key}` returning + the event `trigger_config` schema. + - Wiring block in `entrypoints/routers.py` next to tools; built only when + `env.composio.enabled`. + - **Verify exact v3 REST paths against the live OpenAPI spec (E5).** +- **Functional alone:** yes — browse the catalog, fetch a config schema. Read-only, no + connection, no subscription. +- **Functional deps (WP):** none in-feature (uses `env.composio`, not the connection). A + root in the §1 DAG. +- **Lane (WL):** `WL1`, anchored on `WL0` (no functional need for WP0 — anchored here only + to keep the tree linear and make WL1 an ancestor of WL3/WL5). +- **Stream (WS):** `WS1` — api area; a root, no stubs (live Composio creds for the real + test); runs in parallel. +- **AC (both editions):** browse providers/integrations/events; fetch one event's config + schema; catalog empty/disabled when `env.composio` unset. + +### WP2 — Resolver promotion (SDK + webhooks) · WL2 (anchor WL1) · WS2 + +Promote the mapping resolver to the SDK under a neutral name so triggers and webhooks both +consume it without a cross-domain import. A complete, testable change on its own — its +**live consumer today** is webhooks, independent of triggers entirely. + +- **Closes:** M1. +- **Scope:** move `resolve_payload_fields` (`core/webhooks/delivery.py:95`) to + `agenta.sdk.utils.resolvers` as **`resolve_target_fields`** (next to `resolve_json_selector`); + update the webhooks call site to the new name. Pure move + rename, no behavior change. +- **Functional alone:** yes — webhooks delivery resolves payloads through the relocated + resolver; its suite is the proof. +- **Functional deps (WP):** none in-feature. A root in the §1 DAG. +- **Lane (WL):** `WL2`, anchored on `WL1` — *not* a functional need; placed on the line to + WL4 so the resolver is a structural ancestor of WP4 (the one consumer that needs it), + removing the cross-branch merge-order edge. +- **Stream (WS):** `WS2` — sdk+webhooks area; a root, no stubs (webhooks suite is the proof); + runs in parallel. +- **AC:** existing webhook delivery tests pass unchanged against the renamed/relocated + resolver. + +### WP3 — Subscriptions + deliveries · WL3 (anchor WL2) · WS3 + +The two-table heart of the domain. **Hard-depends on `gateway_connections` existing** (the +subscription FK). Functional as **subscription CRUD** before any dispatch exists. + +- **Closes:** S1, S2, S3, S4, S5. +- **Scope:** + - `subscriptions` table (FlagsDBA, DataDBA): `ti_*`, `trigger_config`, `inputs_fields`, + destination `references`/`selector`, workflow ref, **FK → `gateway_connections`**. + - `deliveries` table: resolved `inputs`, workflow `references`, `result`/`error`, plus the + `metadata.id` dedup column (I4). + - DBA mixins for both (mirror `dbs/postgres/webhooks/dbas.py`). + - Migration authored once in the shared `core_oss` chain (both editions, per WP0's note). + - Subscription CRUD `/triggers/subscriptions/` · `/query` · `/{id}` · `/{id}/refresh` · + `/{id}/revoke`, creating/disabling/deleting the Composio `ti_*` through the adapter and + referencing a shared connection (deleting a subscription must **not** revoke the + connection — C7). + - Delivery read routes `/triggers/deliveries` · `/{id}` · `/query`. +- **Functional alone:** yes — create/list/disable/delete a subscription (and its Composio + `ti_*`), read the deliveries table. The standing-watch lifecycle works end-to-end even + though nothing is dispatching into it yet. +- **Functional deps (WP):** **WP0** (FK → `gateway_connections`) **and** **WP1's adapter** + (`create_subscription` builds the `ti_*` — the adapter, not the catalog routes). A fan-in + in the §1 DAG. +- **Lane (WL):** `WL3`, anchored on `WL2`; both functional parents are tree ancestors (WL0 + and WL1 sit above WL2), so neither needs merge-order coordination and there is no stub. +- **Stream (WS):** `WS3` — api area; runs in parallel, stubbing `ConnectionsGatewayInterface` + (WP0) and `TriggersGatewayInterface` (WP1) against their frozen contracts until those merge. +- **Decision to lock first:** idempotency store (I4 — column on `deliveries`); default + mapping + validation posture (M8). +- **AC (both editions):** create a subscription on a shared connection bound to a workflow; + list/disable/delete; deleting it leaves the connection intact; deliveries list returns + rows. + +### WP4 — Ingress + dispatch · WL4 (anchor WL3) · WS4 + +Close the loop in **one** functional unit: an inbound event is received, verified, scoped, +resolved, and acted on. Ingress lives here (not as its own lane) because a verify-and-park +endpoint isn't functional on its own — the receive path only becomes real once it dispatches. + +- **Closes:** I1, I2, I3, I4, I5, I6, M2, M3, M4, M5, M6, M7, M9; consumes M1. +- **Scope (ingress half):** + - `POST /triggers/composio/events/` reading raw body before parse (mimic billing). + - HMAC-SHA256 verify over `{id}.{ts}.{body}` with `COMPOSIO_WEBHOOK_SECRET`; 401 bad sig; + 200 no-op when secret unset; add `COMPOSIO_WEBHOOK_SECRET` to `env`. + - Recover `project_id` from `metadata.user_id`; route `metadata.trigger_id` → local + subscription; 200-skip unknown/disabled; optional `target`-style env guard (I5). + - One-time project webhook-URL registration with Composio (I6). +- **Scope (dispatch half):** + - Resolve `inputs_fields` via `resolve_target_fields` against `{event, subscription, scope}` + with `TRIGGER_EVENT_FIELDS` (M2, M3) into `data.inputs` only. + - Build the `WorkflowServiceRequest`: destination from the stored workflow `references`/ + `selector` (M4); call `WorkflowsService.invoke_workflow` (M5). + - **System-initiated identity** (M6) — run as a resolved project-system `user_id`. + - **Async dispatch** (M7) — ack-fast + enqueue; ingress returns 2xx promptly. + - Real `metadata.id` dedup against `deliveries` (I4); write a delivery row per event with + outcome; dispatch retry policy (M9). +- **Functional alone:** yes — this is the first PR where a signed inbound event invokes a + workflow and lands a delivery row. The whole feature becomes usable here. +- **Functional deps (WP):** **WP3** (subscriptions + deliveries to read/write) **and** **WP2** + (imports `resolve_target_fields`). A fan-in in the §1 DAG. +- **Lane (WL):** `WL4`, anchored on `WL3`; WP2 (`WL2`) is a tree ancestor of WL3, so the + resolver import is structural — no merge-order edge, no old-location import. +- **Stream (WS):** `WS4` — api area; runs in parallel, stubbing the subscription DTO/DAO (WP3) + and `resolve_target_fields` (WP2) against their frozen contracts until those merge. +- **Decisions to lock first:** webhook-URL registration (I6), sync-vs-async (M7), system + `user_id` (M6), retry policy (M9). +- **AC (both editions):** forged signature → 401; unset secret → 200 no-op; signed event + for a known subscription → bound workflow invoked with the mapped inputs; duplicate + `metadata.id` → single invocation; bad mapping / missing workflow → a `deliveries` error + row (no workflow trace), still 2xx to the provider. + +### WP5 — Web: catalog + connections UI · WL5 (anchor WL1) · WS5 + +The browse half of the FE: providers/integrations/events and the connection list. + +- **Closes:** F1 (catalog/connect part), F2. +- **Scope:** "Triggers" entry on a connected integration — browse events and their config + schema (WP1 catalog API); show connections via `/triggers/connections`; handle the + **overlapping connection reads** across `/tools/connections` and `/triggers/connections` + (same rows, F2). +- **Functional alone:** yes — browse events and see connections against a merged WP1, even + before subscriptions exist. +- **Functional deps (WP):** **WP1** (catalog API) **and** **WP0** (the `/…/connections` view + over `gateway_connections`). A fan-in in the §1 DAG. +- **Lane (WL):** `WL5`, anchored on `WL1`; WP0 (`WL0`) is a tree ancestor, so both deps are + covered. (Sibling of WL2 under WL1 — fan-out off WL1 is fine.) +- **Stream (WS):** `WS5` — web area; runs in parallel, mocking the catalog (WP1) and + `/…/connections` (WP0) HTTP against their frozen shapes until those merge. +- **AC:** browse a connected integration's events; the same connection appears under both + tools and triggers without a second connect. + +### WP6 — Web: subscriptions + deliveries UI · WL6 (anchor WL3) · WS6 + +The management half of the FE: create/manage subscriptions and view deliveries. + +- **Closes:** F1 (subscribe part), F3. +- **Scope:** create a subscription (pick event + bind workflow + mapping), list / disable / + delete (WP3 subscription API); deliveries audit view (`/triggers/deliveries`, F3 — + deferrable past v1). +- **Functional alone:** yes — create and manage subscriptions against a merged WP3; a new + subscription simply shows no deliveries until WP4 dispatch lands. +- **Functional deps (WP):** **WP3** only (the `/triggers/subscriptions` + `/triggers/deliveries` + API). Independent of WP4 — the management UI doesn't need dispatch to exist. +- **Lane (WL):** `WL6`, anchored on `WL3` (sibling of WL4 — WL3 fans out to both). +- **Stream (WS):** `WS6` — web area; runs in parallel, mocking the WP3 HTTP surface + (`/triggers/subscriptions` and `/triggers/deliveries`) against its frozen shape until WP3 + merges. +- **AC:** create a workflow-bound subscription; list/disable/delete it; deliveries view + renders (empty until WP4). + +--- + +## 5. The three views, side by side + +Same seven units, the three edge sets together. Read across a row to see how one unit looks +in each lens. + +| k | Unit | Closes | WP — functional deps | WL — anchor | WS — area · stubs until dep merges | +|---|------|--------|----------------------|-------------|-------------------------------------| +| 0 | connection extract | C1–C7 | — | `main` | api · — | +| 1 | catalog + adapter | E1–E5 | — | WL0 | api · — | +| 2 | resolver → SDK | M1 | — | WL1 | sdk+webhooks · — | +| 3 | subscriptions + deliveries | S1–S5 | WP0, WP1 | WL2 | api · stubs ConnectionsGW (WP0), TriggersGW (WP1) | +| 4 | ingress + dispatch | I1–I6, M2–M9 | WP3, WP2 | WL3 | api · stubs subs DTO (WP3), resolver (WP2) | +| 5 | web catalog/connections | F1, F2 | WP1, WP0 | WL1 | web · mocks catalog (WP1), /connections (WP0) | +| 6 | web subscriptions/deliveries | F1, F3 | WP3 | WL3 | web · mocks /subscriptions+/deliveries (WP3) | + +The WL anchors form the tree of §2; every WP fan-in (rows 3, 4, 5) is covered because the +non-anchor parent is a tree ancestor. The WS column is the parallel-subagent view of §3 — all +seven build concurrently against frozen contracts (WS-PRE), stubbing the listed dep until it +merges; e2e tests light up in WL merge order. + +--- + +## 6. Risks & sequencing notes + +- **WP0 is the only PR that touches shipped tools code.** Keep it a pure refactor+rename + with the `/tools/connections` contract frozen; it is the tree root, so it is reviewed and + merged first regardless. A regression here hits live tools. +- **GitButler stacking caveat (from `vibes/AGENTS.md`):** keep the WL tree a true GitButler + stack (`--anchor`); do **not** sync branches by merging them into each other — a + merge-based series can collapse to a single addressable tip on unapply/re-apply. Snapshot + (`but oplog snapshot`) before risky stack surgery. +- **Stacked PR bases follow the WL anchors:** each PR sets `--base` to its anchor branch + (e.g. `wp3` `--base wp2`, `wp4` `--base wp3`, `wp5` `--base wp1`, `wp6` `--base wp3`) so + each shows only its own diff. +- **No merge-order coordination needed.** Because every functional dep is a WL ancestor (§2), + there is no "merge X before Y" rule to remember — the tree enforces it. (This is why the + tree linearizes WP2 and WP5 under WL1 rather than running them as free parallel lanes.) +- **Decisions that gate code** (from `gap.md` §3) close at the head of the WP that needs them + — revoke rule before WP0; REST paths (E5) before WP1's adapter; idempotency + mapping + default before WP3; async + identity + retry + URL-registration before WP4. +- **Build order ≠ lane order.** The WL tree is a merge topology; the WS view (§3) is parallel + build assignments against frozen contracts. A branch deep in the tree (e.g. WP4) can be in + active development while an ancestor (e.g. WP1) is still in review — GitButler lets you push + fixes mid-stack, and the contract freeze lets the subagent build before WP1 merges. +- **Contract freeze (WS-PRE) is the one true prerequisite.** Parallelism depends on the + inter-package interfaces (§3) being fixed before fan-out; a contract change after fan-out + forces a re-sync across the dependent streams. Lock them with the gate decisions above. diff --git a/docs/designs/gateway-triggers/proposal.md b/docs/designs/gateway-triggers/proposal.md new file mode 100644 index 0000000000..b364c699c1 --- /dev/null +++ b/docs/designs/gateway-triggers/proposal.md @@ -0,0 +1,236 @@ +# Gateway Triggers — Proposal + +## Summary + +Add **triggers** to the gateway as a first-class, standalone concept, symmetric to the +existing gateway **tools**. A trigger lets a project subscribe to an *inbound* event +from a connected provider (new Gmail message, new GitHub commit, new Slack message) and, +when that event fires, **invoke an Agenta workflow** with the event as input. Triggers +are a peer top-level domain (`/triggers`, alongside `/tools`) with their own router, +service, DAO, and `subscriptions` table. Provider connections (`ca_*`) are **shared**: an +extracted `connections` domain (table `gateway_connections`, renamed from +`tool_connections`) backs both tools and triggers, so a provider is connected once and +used from both (decision **A2-2**; see [Alternatives](#alternatives-considered)). + +The guiding analogy: + +```text +Agenta events ──▶ user endpoints (outbound; the existing `webhooks` domain) +Composio triggers ──▶ Agenta workflows (inbound; this design) +``` + +So a trigger is the inbound dual of an event subscription: where the `webhooks` domain +pushes Agenta-internal events *out* to a customer's URL, a gateway trigger pulls a +provider event *in* and runs it through an Agenta workflow. Triggers are their **own +domain concept** — not the outbound `webhooks` domain, and not workflow hooks. +See "Non-goals". + +## Why + +Tools answer "let the model *do* something in a provider." Triggers answer the inverse: +"let a provider *tell Agenta* something happened, and run an Agenta workflow on it." +Together they make the gateway bidirectional. This is the symmetric counterpart to the +existing outbound `webhooks` domain: Agenta events flow *out* to user endpoints; provider +triggers flow *in* to Agenta workflows. The `/tools` vertical already proved the +gateway-via-Composio pattern end to end; triggers replicate that proven structure in a +standalone domain for the inbound direction. + +## Goals + +1. **Event catalog** — browse the **events** a connected integration exposes, including + each event's required `trigger_config` schema. Symmetric to the tools action catalog. +2. **Subscription lifecycle** — on a (shared) connection, create / enable / disable / + delete many *subscriptions*, each a standing watch on one event bound to one workflow. + Persisted in the triggers domain's own `subscriptions` table; connection auth lives in + the shared `connections` domain. +3. **Ingress** — one server-owned, signature-verified inbound endpoint that receives + Composio's webhook deliveries, maps each event to the owning project + trigger + record, and dedups redeliveries. +4. **Dispatch to a workflow** — when a verified event arrives, invoke the Agenta + workflow bound to that subscription, passing the event as input. This is the + point of the feature: `Composio event → Agenta workflow`, mirroring + `Agenta event → user endpoint`. The binding (`subscription → workflow ref`) is + stored on the subscription record; dispatch calls the existing + `WorkflowsService.invoke_workflow(project_id, user_id, request)` seam + (`core/workflows/service.py:1698`). +5. **Peer `/triggers` domain alongside `/tools`** — triggers get their own top-level + endpoint (not nested under `/tools`), their own router, service, DAO, DTOs, and their + own `subscriptions` table. `/tools` for outbound actions, `/triggers` for inbound + events. Triggers' event-catalog, subscription, and dispatch code is separate from + tools'. +6. **Shared provider connections (decision: A2-2)** — the provider connection (`ca_*`) is + a **gateway-level primitive**, not a per-feature resource: one Composio connected + account is the same account whether a tool calls it or a trigger watches it. It is + extracted into a shared `connections` domain (service + DAO + `gateway_connections` + table, renamed from `tool_connections`) that has **no router of its own**. The HTTP + surface stays per-domain — `/tools/connections` and `/triggers/connections` — both + delegating to the shared service over the same rows. **Connect a provider once; use it + from both tools and triggers.** Tools' connection auth is repointed at the shared + service; the `/tools/connections` HTTP contract is unchanged. See + [Alternatives considered](#alternatives-considered) for the rejected fully-separate + option (B). +7. **Provider-agnostic shape** — model the shared connections adapter and the triggers + adapter behind ports so a future non-Composio provider drops in without touching + routers or services. + +## Non-goals + +- **Not the outbound `webhooks` domain.** That domain (Agenta → customer URLs, driven by + internal `EventType`s, with its own subscriptions/deliveries/retries) stays exactly as + is. Triggers are inbound (provider → Agenta) and are a separate domain with their own + router, service, and table. We do **not** merge them, and we do **not** route trigger + ingress through the webhooks subscription/delivery machinery in v1. +- **Not workflow hooks.** Workflow lifecycle hooks are an unrelated mechanism; triggers + do not extend, replace, or depend on them. +- **Workflow invocation is the only v1 consumer.** A trigger binds to exactly one + Agenta workflow and invokes it on each event. Other downstream consumers (evaluations, + queues, re-emitting as an internal Agenta event for the outbound `webhooks` domain) are + deliberately out of scope for v1 — the dispatch step is kept narrow: resolve the bound + workflow and call `invoke_workflow`. +- **No new workflow execution path.** Triggers invoke workflows through the existing + `WorkflowsService` seam; we do not build a parallel runner. +- **No custom-OAuth ingress registration** (registering Composio's ingress URL on a + customer's own OAuth app). Managed-auth only for v1. +- **No polling fallback we own.** Composio handles provider polling for polling-type + triggers; we only consume its single normalized webhook. +- **No SDK dependency.** `httpx` direct calls, same as tools. +- **No EE-only gating beyond what tools already have.** Triggers ship wherever tools do. + +## Shape of the solution (high level) + +```text +Provider ──event──▶ Composio ──signed webhook──▶ POST /triggers/composio/events/ + │ verify HMAC (raw body) + │ route metadata.trigger_id → local record + │ recover project from metadata.user_id + │ dedup on metadata.id + ▼ + resolve bound workflow ref on the record + ▼ + WorkflowsService.invoke_workflow( + project_id, user_id, request=event-as-input) + +Project ──▶ POST /triggers/connections/ (connect provider, OAuth) ──┐ shared connection (ca_*) + (or /tools/connections — same shared service + rows) │ (also usable from tools) + ──▶ POST /triggers/subscriptions/ (pick event + bind workflow) ├─▶ services ─▶ Composio v3 + ──▶ GET /triggers/catalog/.../events/... (events) ┘ (one ca_* ; many ti_* per ca_*) +``` + +Terminology (see `mimics.md`): catalog leaf = **event** (≈ tools **action**). The created +state is two records with different owners and cardinality: + +- **connection** — durable provider auth (`ca_*`), one per (project, provider, + integration). A **gateway-level** resource shared by tools and triggers, in the + `connections` domain. The inbound/outbound-neutral evolution of today's tool connection. +- **subscription** — a standing watch on one event (`ti_*` + `trigger_config` + bound + workflow), FK → connection. Owned by the triggers domain. The inbound dual of a + **webhook subscription**. + +Why split connection from subscription: a Composio connected account (`ca_*`) backs +**many** trigger instances (`ti_*`) — Gmail "new message" and "new starred message" share +one auth. Tools already separates durable auth from per-use detail (a connection holds +only auth; the action + arguments arrive per call). Triggers is the first domain that must +*persist* per-event detail, so the connection/subscription split makes the +1-connection → many-subscriptions cardinality explicit (connect once, subscribe many). + +Why share connections across domains (A2-2): `ca_*` is one real account regardless of +consumer; two rows for it would encode a lie and force a second OAuth consent. So: + +- **`connections` (shared domain, no router)** — `core/gateway/connections/` + + `dbs/postgres/gateway/connections/`. Owns OAuth initiate / callback / refresh / revoke + and the `gateway_connections` table (renamed from `tool_connections`; already + domain-neutral). Its Composio **auth** adapter implements a `ConnectionsGatewayInterface`. + **No `apis/fastapi/gateway/connections/` router** — the HTTP surface is the per-domain + `/tools/connections` and `/triggers/connections`, both delegating to this one service + over the same rows. +- **`triggers` (peer domain)** — `apis/fastapi/triggers/`, `core/triggers/`, + `dbs/postgres/triggers/`. A **two-table** domain mirroring webhooks' subscription + + delivery pair: + - `subscriptions` — project-scoped, FlagsDBA (enabled/valid), DataDBA with `ti_*`, the + mapping (`inputs_fields`), the destination (`references`/`selector`), and the **bound + workflow ref**; FK → shared connection. + - `deliveries` — one audit row per inbound event dispatched (resolved `inputs`, workflow + `references`, `result`/`error`); the audit + retry surface, mirroring + `webhook_deliveries`. + + Plus the event catalog, ingress, and dispatch. Three routers under `/triggers`: + - `/triggers/connections` — delegates to the shared `connections` service (the triggers + view onto `gateway_connections`). + - `/triggers/subscriptions` — the standing watches (own `subscriptions` table). + - `/triggers/deliveries` — the dispatch audit log (own `deliveries` table). + + (Plus the catalog routes and the `/triggers/composio/events/` ingress.) Its Composio + **triggers** adapter implements a `TriggersGatewayInterface` (`list_events`, `get_event`, + `create_subscription`, `set_subscription_status`, `delete_subscription`). It depends on + the shared `connections` service for auth and on `WorkflowsService` for dispatch. +- **`tools` (existing domain)** — unchanged HTTP contract; its connection auth is + repointed at the shared `connections` service. Keeps actions + execution. +- One provider-namespaced ingress endpoint, **`POST /triggers/composio/events/`**, + with HMAC verification keyed on a `COMPOSIO_WEBHOOK_SECRET`. This follows the + established `{domain}/{provider}/events/` convention — cf. billing's + `/billing/stripe/events/` (`api/ee/src/apis/fastapi/billing/router.py:106`), which + likewise reads the raw body and verifies a provider signature + (`stripe.Webhook.construct_event` with `env.stripe.webhook_secret`). Namespacing by + provider leaves room for a future `/triggers/{provider}/events/` without collision. + +## Success criteria + +- A project can connect Gmail **once** (a shared `gateway_connections` row), browse + Gmail's **events**, create a "new message" **subscription bound to a chosen Agenta + workflow** (and more subscriptions on the same connection without re-auth), and have + that workflow invoked with the event payload when a new message arrives. +- A Gmail already connected for tools is usable by triggers without reconnecting, and + vice-versa; the same connection shows in both `/tools/connections` and + `/triggers/connections` (same shared rows). +- The invocation is project-scoped and authenticated through the existing + `invoke_workflow` path (no new execution route). +- Disabling/deleting a subscription stops delivery and removes the Composio trigger + instance, without touching the shared connection. +- Forged or replayed deliveries are rejected (signature + dedup). +- No change to the outbound `webhooks` domain or to the existing `/tools` HTTP contract. + +## Risks / decisions to lock before build + +- **Exact Composio v3 trigger REST paths** (verify against live OpenAPI; SDK names are + stable). +- **How the project webhook URL is registered** (API vs dashboard) and whether one URL + per Composio project forces all projects through one ingress (it does — routing is + ours). +- **Event → workflow mapping** — worked out in [`mapping.md`](mapping.md): destination is a + workflow `references`/`selector` (the `/retrieve` shape); the `inputs_fields` template + (webhooks' `payload_fields`, retargeted) resolves the inbound event into + `WorkflowServiceRequest.data.inputs` via the reused selector resolver. Open sub-points: + the default mapping, schema-validation against the bound workflow, and what `user_id` a + system-initiated invocation runs as (no human in the loop). +- **Sync vs async dispatch** — invoke inline in the ingress request, or enqueue and ack + fast so Composio's webhook doesn't time out / retry. Leaning async. +- **Idempotency store** for `metadata.id` dedup (table column vs cache). +- **Cross-domain revoke rule (consequence of A2-2).** Because a connection is shared, + revoking a `ca_*` affects every consumer (tools actions + trigger subscriptions on it). + Lean: **revoke-for-everyone + show usage** ("used by tools / used by N subscriptions") + rather than cross-domain reference-counting. Deleting a subscription must *not* revoke + the shared connection. The FE must expect overlapping reads across the three connection + surfaces. This rule is the main new behavior A2-2 introduces. +- **`gateway_connections` migration.** Rename `tool_connections` → `gateway_connections` + (+ its `uq_`/`ix_` constraints); no data transform (table is already domain-neutral). + Repoint tools' connection auth (~4 references) at the shared `connections` service. The + `/tools/connections` contract stays frozen. + +## Alternatives considered + +### B — fully separate connections (rejected) + +`tool_connections` stays as-is; triggers gets its own `trigger_connections` (a mirror). +Zero migration, zero cross-domain coupling, no shared-lifecycle rule. + +**Why rejected:** it buys nothing for the user and encodes a falsehood. A Composio +connected account is one real account; modeling it as two rows forces the user to connect +the same provider **twice** (two OAuth consents, two "Gmail connected" states) for tools +vs. triggers, indefinitely. B is the smaller raw diff, but the cost is paid forever in +duplicate consent. A2-2 was chosen because the migration turned out cheap (`tool_connections` +is recent, ~4 references, and already provider-agnostic) — so the only real added cost of +A2-2 over B is the cross-domain revoke rule above, which is small and worth it. + +A2-1 (shared `gateway_connections` table but **separate rows per domain**) was also +rejected: it pays A2's migration cost while still forcing connect-twice — all of the cost, +none of the benefit. diff --git a/docs/designs/gateway-triggers/research.md b/docs/designs/gateway-triggers/research.md new file mode 100644 index 0000000000..35235183de --- /dev/null +++ b/docs/designs/gateway-triggers/research.md @@ -0,0 +1,403 @@ +# Gateway Triggers — Research + +Status quo, internal and external, for adding **triggers** (inbound provider events) +to the gateway alongside the existing **tools** (outbound action calls). + +--- + +## 0. Terminology and the shared-connection decision + +Three nouns, drawn from existing domains so the whole thing reads familiar: + +| Concept | Owner | Tools | Webhooks | Triggers | What it is | +|---------|-------|-------|----------|----------|------------| +| catalog leaf | per-domain | **action** | — | **event** | callable action vs. watchable event | +| provider auth | **shared** `connections` | connection (`ca_*`) | — | connection (`ca_*`) | one per (project, provider, integration), via OAuth | +| standing event watch | triggers | — | subscription | **subscription** (`ti_*` + config + workflow) | many per connection | + +Catalog hierarchy maps cleanly: + +```text +tools: providers / integrations / actions +triggers: providers / integrations / events +``` + +The created state is two records with **different owners**: + +```text +shared: connection (ca_*) ← gateway_connections; used by BOTH tools and triggers +triggers: event (catalog) → subscription (ti_* + trigger_config + workflow) ← FK → connection +``` + +**Why connection and subscription are split, and why the connection is shared (A2-2):** + +- *Split* — a Composio connected account (`ca_*`) backs many trigger instances (`ti_*`): + one Gmail auth serves "new message", "new starred", etc. So a **subscription** (one + standing watch, bound to one workflow) is separate from the **connection** (durable + auth). Connect once, subscribe many. Tools never persisted the per-use record (a tool + call is ephemeral); webhooks never had a connection (no provider to authenticate); + triggers is the first domain needing both. +- *Shared* — `ca_*` is one real account regardless of consumer. Rather than each domain + owning its own copy, the connection is extracted into a **shared `connections` domain** + (`gateway_connections` table, renamed from `tool_connections`; service + DAO, **no + router of its own**), consumed by both tools and triggers. Connect Gmail once → usable + from both. HTTP surface is per-domain — `/tools/connections` and `/triggers/connections` + both delegate to the one shared service over the same rows. + (Decision **A2-2**; rejected alternative **B** — fully separate connections — and full + reasoning in `proposal.md` § Alternatives and `mimics.md`.) + +Composio's own vocabulary ("trigger type", "trigger instance") is kept only when +describing the Composio API itself; in Agenta terms they are an **event** and the +provider-side half of a **subscription**. + +--- + +## 1. External: how Composio triggers work + +Composio's [Triggers](https://docs.composio.dev/docs/triggers) are the mirror image of +its tools. Tools are *outbound* — you call a provider action (`GMAIL_SEND_EMAIL`). +Triggers are *inbound* — a provider emits an event (new Slack message, new GitHub +commit, new Gmail message) and Composio delivers it to you. + +### Core concepts + +| Composio concept | Agenta term | Meaning | Composio ID prefix | +|------------------|-------------|---------|--------------------| +| **Trigger type** | **event** (catalog leaf) | Template defining an event to watch + required config. E.g. `GITHUB_COMMIT_EVENT` needs `owner`, `repo`. Each toolkit exposes its own trigger types. | (slug, e.g. `GITHUB_COMMIT_EVENT`) | +| **Trigger instance** | part of a **subscription** | A trigger type *instantiated* for one user + one connected account, with concrete config. Independently enable/disable/delete. | `ti_*` | +| **Connected account** | part of a **subscription** | The authenticated binding a trigger is scoped to. **A trigger cannot exist without one** — auth comes first. | `ca_*` | + +### Two delivery mechanisms (transparent to us) + +- **Webhook triggers** (Slack, Notion, Asana, Outlook): provider pushes to a + Composio-issued ingress URL in real time. +- **Polling triggers** (Gmail, Google Calendar): Composio polls the provider on a + schedule; with Composio-managed auth the worst-case source→delivery delay is ~15 min. + +Either way, Composio normalizes both into one outbound webhook to **our** subscription +URL. We never talk to the provider directly. + +### Lifecycle (per the docs) + +1. **Subscribe** (once per Composio project): tell Composio the single webhook URL to + deliver all trigger events to. +2. **Discover**: list trigger types for a toolkit; read each type's required `config`. +3. **Create**: create an active trigger instance scoped to a `user_id` + + connected account, with `trigger_config`. +4. **Receive**: events arrive at our subscription URL as HTTP POST; route on + `metadata.trigger_slug`. +5. **Manage**: enable / disable / delete instances. + +### SDK / REST surface + +The Python SDK (`composio.triggers.*`) wraps a REST surface. From the docs and SDK: + +```python +# Discover required config +trigger_type = composio.triggers.get_type("GITHUB_COMMIT_EVENT") +trigger_type.config # JSON Schema of required trigger_config + +# Create an instance (scoped to a user + their connected account) +trigger = composio.triggers.create( + slug="GITHUB_COMMIT_EVENT", + user_id="project_019abc...", + trigger_config={"owner": "composiohq", "repo": "composio"}, +) +trigger.trigger_id # ti_* + +# Local-dev only: SDK-managed subscription (websocket), not for prod +subscription = composio.triggers.subscribe() +@subscription.handle(trigger_id="ti_...") +def handler(data): ... +``` + +REST equivalents (we use `httpx` directly, no SDK — same decision as tools): + +| Operation | REST (v3) | +|-----------|-----------| +| List trigger types for a toolkit | `GET /api/v3/triggers_types?toolkit_slugs={slug}` | +| Get one trigger type (config schema) | `GET /api/v3/triggers_types/{slug}` | +| Create / upsert instance | `POST /api/v3/trigger_instances/{slug}/upsert` (`user_id`, `trigger_config`) | +| Enable / disable instance | `PATCH /api/v3/trigger_instances/manage/{trigger_id}` (`status`) | +| Delete instance | `DELETE /api/v3/trigger_instances/manage/{trigger_id}` | +| List instances | `GET /api/v3/trigger_instances` (filter by `user_id`, `toolkit`) | +| Set project webhook URL | project settings / `POST /api/v3/...webhook` (one-time, dashboard or API) | + +> Exact paths must be confirmed against the live OpenAPI spec during implementation; +> the SDK method names (`get_type`, `create`, `subscribe`) are stable. This is the +> same "verify against live spec" caveat that landed for the tools endpoints. + +### Webhook payload (V3, the default for new orgs) + +```json +{ + "type": "github_commit_event", + "timestamp": "2026-06-18T10:00:00Z", + "data": { /* provider event payload, trigger-type-specific */ }, + "metadata": { + "id": "evt_...", + "trigger_slug": "GITHUB_COMMIT_EVENT", + "trigger_id": "ti_...", + "toolkit_slug": "github", + "user_id": "project_019abc...", + "connected_account": { "id": "ca_...", "status": "ACTIVE" } + } +} +``` + +We route on `metadata.trigger_slug` (which trigger type) and `metadata.trigger_id` +(which instance) → our local trigger record → project scope. +`metadata.user_id` carries our `project_{project_id}` scope verbatim, the same +`user_id` strategy tools already use. + +### Webhook verification + +Composio signs every webhook with **HMAC-SHA256** (svix-style headers), per +[Verifying webhooks](https://docs.composio.dev/docs/webhook-verification): + +- Headers: `webhook-id`, `webhook-timestamp`, `webhook-signature`. +- Signing string: `{webhook-id}.{webhook-timestamp}.{raw-body}`. +- HMAC-SHA256 with the project webhook secret, base64-encoded; compare with + `hmac.compare_digest`. The `webhook-signature` header may carry a `v1,` prefix. + +```python +signing_string = f"{webhook_id}.{webhook_timestamp}.{raw_body}" +expected = base64.b64encode( + hmac.new(secret.encode(), signing_string.encode(), hashlib.sha256).digest() +).decode() +received = signature.split(",", 1)[1] if "," in signature else signature +ok = hmac.compare_digest(expected, received) +``` + +Verification needs the **raw request body** (not the parsed JSON), so the ingress +endpoint must read `await request.body()` before parsing. + +### Tools vs triggers — the symmetry + +| Axis | Tools (built) | Triggers (proposed) | +|------|---------------|---------------------| +| Direction | Outbound (we call provider) | Inbound (provider calls us) | +| Catalog leaf | **action** slug | **event** slug (Composio trigger type) | +| Durable auth record | **connection** (`ca_*`) | **same shared connection** (`gateway_connections`) | +| Per-use record | *(ephemeral tool call)* | **subscription** (`ti_*` + config + workflow), FK → connection | +| Connection routes | `/tools/connections` | `/triggers/connections` (both delegate to the shared service; no `/gateway/connections` route) | +| Per-domain routes | actions, `/call` | events catalog, `/subscriptions`, ingress | +| Config | arguments per call | `trigger_config` per subscription, set once | +| Entry point | `POST /tools/call` | inbound `POST /triggers/composio/events/` | +| HTTP domain | `/tools/*` | independent `/triggers/*` (peer, not nested) | +| Per-event work | synchronous response to caller | invoke the bound Agenta workflow | + +The single most important external fact: **a trigger, like a tool, is a Composio +resource scoped to a connected account.** Tools proved that pattern; triggers reuse the +**same** (shared) connected account and add events + subscriptions on top (see the +shared-connection decision A2-2 +below). + +--- + +## 2. Internal: how tools are integrated today + +The gateway-tools feature is **shipped** (not just designed). Layout follows the +standard domain shape from `api/AGENTS.md`. + +### Layers + +```text +api/oss/src/apis/fastapi/tools/ router.py · models.py · utils.py +api/oss/src/core/tools/ service.py · interfaces.py · dtos.py + registry.py · exceptions.py · utils.py +api/oss/src/core/tools/providers/composio/ adapter.py · catalog.py · dtos.py +api/oss/src/dbs/postgres/tools/ dbes.py · dao.py · mappings.py +``` + +Dependency direction (enforced): `Router → Service → DAOInterface + GatewayInterface → +DAO impl + Adapter impl`. Concrete wiring lives only in `api/entrypoints/routers.py`. + +### Domain layout — three verticals, shared connections (decision A2-2) + +**Decision:** connections are a **gateway-level primitive shared** by tools and triggers; +the trigger-specific state is a peer domain. Three verticals: + +1. **`connections` (shared, extracted)** — owns the provider connection `ca_*`: OAuth + initiate/callback/refresh/revoke and the `gateway_connections` table (renamed from + `tool_connections`). **No router of its own** — the HTTP surface is `/tools/connections` + and `/triggers/connections`, both delegating to this shared service over the same rows. + Code: `core/gateway/connections/`, `dbs/postgres/gateway/connections/` (service + DAO + + table; no `apis/fastapi/gateway/connections/`). +2. **`triggers` (peer to tools)** — owns events catalog, the `subscriptions` **and** + `deliveries` tables (a two-table domain mirroring webhooks' `webhook_subscriptions` + + `webhook_deliveries`), ingress, and dispatch. Depends on the shared `connections` + service for auth and on `WorkflowsService` for dispatch. +3. **`tools` (existing)** — unchanged HTTP contract; connection auth repointed at the + shared `connections` service. + +`/tools` remains the structural blueprint for the trigger-specific code (copy structure, +swap nouns `action → event`); the connections code is *extracted and shared*, not copied. +(Rejected alternative B — fully separate `trigger_connections` — and why, in +[`proposal.md` § Alternatives].) + +What each part is modeled on: + +- **Shared connections** — evolve the existing tool-connection code in place: + `ToolConnectionDBE` / `tool_connections` (`dbs/postgres/tools/dbes.py`) becomes the + `gateway_connections` DBE in the connections domain (already domain-neutral — no + `tool_`-specific columns). The Composio **auth** adapter (`initiate_connection`, + `get_connection_status`, `refresh_connection`, `revoke_connection` from + `ComposioToolsAdapter`) moves to a `ConnectionsGatewayInterface` in the connections + domain. Tools and triggers both consume it. +- **Triggers adapter** — a **new** `ComposioTriggersAdapter` (own httpx client, + modeled on `ComposioToolsAdapter`'s `_get/_post/_delete` + slug mapping) implementing a + `TriggersGatewayInterface` for the trigger REST surface (`triggers_types`, + `trigger_instances/...`). Helpers may be copied or promoted to a shared util. +- **`subscriptions` table** — modeled on `WebhookSubscription` / `webhook_subscriptions` + (`core/webhooks/types.py:116`): project-scoped, FlagsDBA (enabled/valid), carrying the + trigger instance (`ti_*`), the mapping (`inputs_fields`), the destination + (`references`/`selector`), and a FK → `gateway_connections`. Many per connection. +- **`deliveries` table** — modeled on `WebhookDelivery` / `webhook_deliveries` + (`core/webhooks/types.py:156`): one audit row per inbound event dispatched, carrying the + resolved `inputs`, the workflow `references`, and `result`/`error`. The audit + retry + surface — and the only record when dispatch fails before invocation. (See `mapping.md` + §4.3.) +- **Events catalog** — model on the tools catalog; leaf is **events**: + `/triggers/catalog/providers/{p}/integrations/{i}/events/{event_key}`, returning the + event's `trigger_config` JSON Schema (analogue of an action's `input_parameters`). +- **Service / router / DAO** — `TriggersService` (event-catalog browse, subscription CRUD, + ingress, dispatch) models on `ToolsService` + `WebhooksRouter`'s `/subscriptions/...` + shape; depends on its own DAO + triggers adapter + the shared connections service + + `WorkflowsService`. +- **Env** — `env.composio` (`api_key`, `api_url`) read directly; add + `COMPOSIO_WEBHOOK_SECRET`. + +Route map: + +| Surface | Route | Patterned on | +|---------|-------|--------------| +| connections (triggers view) | `/triggers/connections/` · `/query` · `/{id}` · `/{id}/refresh` · `/{id}/revoke` · `/callback` | tools connections (shared service) | +| connections (tools view) | `/tools/connections/...` | same shared service + rows | +| events catalog | `/triggers/catalog/.../integrations/{i}/events/{event_key}` | tools catalog | +| subscriptions | `/triggers/subscriptions/` · `/query` · `/{id}` · `/{id}/test` | webhook subscriptions | +| deliveries | `/triggers/deliveries` · `/{id}` · `/query` | webhook deliveries | +| ingress | `/triggers/composio/events/` | billing `/stripe/events/` | + +(There is **no** `/gateway/connections` route — the shared `connections` domain has no +router; the two views above are its only HTTP surfaces.) + +> Firm decisions: connections is a shared gateway primitive (`gateway_connections`, A2-2); +> `/triggers` is a peer domain owning subscriptions + dispatch; the sanctioned cross-domain +> runtime calls are triggers → connections service (auth) and triggers → +> `WorkflowsService.invoke_workflow` (dispatch). + +> **Consequence — cross-domain revoke.** Because `ca_*` is shared, revoking it affects +> both tools actions and trigger subscriptions on it. Lean: revoke-for-everyone + show +> usage; deleting a subscription must not revoke the connection. Connect once, used +> everywhere — the inverse of the connect-twice cost that rejected option B carried. + +### The workflow dispatch seam + +Dispatch invokes the existing +`WorkflowsService.invoke_workflow(*, project_id, user_id, request: WorkflowServiceRequest)` +(`core/workflows/service.py:1698`). It signs a secret token from the project's +workspace/org, resolves the workflow's service URL from the bound revision, and calls it. +Triggers build a `WorkflowServiceRequest` from the verified event and call this — no new +execution path. The open question is the **event → `WorkflowServiceRequest` mapping** and +what `user_id` a system-initiated (no-human) invocation runs as. + +### The OAuth callback is the closest existing analogue to a webhook ingress + +`GET /tools/connections/callback` (`router.py:785`) already implements the inbound +pattern we need for trigger ingress: + +- Server-owned callback URL with an **HMAC-signed `state` token** (`make_oauth_state` / + `decode_oauth_state`, keyed on `env.agenta.crypt_key`) that recovers `project_id` + without trusting the caller. +- Looks up the local connection by provider-side ID + (`activate_connection_by_provider_connection_id`) and mutates local state. +- Returns a controlled response. + +Trigger ingress is the same shape: verify a signature, recover project scope from the +payload's `user_id`/`trigger_id`, look up the local record, then act. + +### The Stripe webhook is the direct precedent for the ingress route shape + +Billing already has a provider-namespaced, signature-verified inbound webhook at +**`POST /billing/stripe/events/`** (`api/ee/src/apis/fastapi/billing/router.py:106`). It +reads the raw request body and verifies the provider signature via +`stripe.Webhook.construct_event(payload, sig, env.stripe.webhook_secret)`. This sets the +house convention for inbound provider events: `{domain}/{provider}/events/`. Trigger +ingress should follow it as **`/triggers/composio/events/`** (Composio HMAC-SHA256 in +place of Stripe's verifier, keyed on `COMPOSIO_WEBHOOK_SECRET`). Provider-namespacing +also leaves room for a second trigger provider at `/triggers/{provider}/events/`. + +### Connection scoping / `user_id` strategy + +`user_id = str(project_id)` is passed to Composio as the connected-account scope +(`service.py:230`). Every connection and therefore every trigger is implicitly +project-scoped. The webhook `metadata.user_id` echoes this back, so ingress can map an +inbound event to a project with no extra lookup table. + +### Config & wiring + +- `env.composio` (`utils/env.py:507`): `api_key`, `api_url`, `enabled` (key present). +- Wiring (`entrypoints/routers.py:578`): adapter built only when `env.composio.enabled`, + registered under key `composio`, injected into `ToolsService`, mounted via + `ToolsRouter`. Triggers slot into the same three spots. + +### Frontend + +Tools UI lives in `web/packages/agenta-entities/src/gatewayTool`, +`web/packages/agenta-entity-ui/src/gatewayTool`, and +`web/oss/src/components/pages/settings/Tools`. Catalog browse, connect (OAuth popup + +poll), list/delete connections. Triggers extend these surfaces (a "Triggers" tab on a +connected integration). + +--- + +## 3. Internal: the existing **outbound** webhooks domain (do not confuse) + +There is already a `webhooks` domain +(`api/oss/src/core/webhooks/`, `apis/fastapi/webhooks/`). It is **outbound**: Agenta +emits internal `EventType`s (e.g. `TRACES_QUERIED`) to subscriber-registered URLs, with +subscriptions, deliveries, retries (`WEBHOOK_MAX_RETRIES = 5`), and HMAC signing on the +*sending* side. + +This is the inverse of triggers: + +- **webhooks domain** = Agenta → outside world (we sign and send). +- **gateway triggers** = outside world (via Composio) → Agenta (we verify and receive). + +They are complementary and should **stay separate domains**. But there is a real +integration point: an inbound Composio trigger can be re-emitted as an internal Agenta +event, which the existing webhooks domain then fans out to customer subscribers. That +keeps "deliver events to customers" in one place and avoids a second outbound delivery +engine. See `proposal.md` for whether v1 includes that bridge. + +--- + +## 4. Open external unknowns to verify during implementation + +1. **Exact v3 REST paths** for trigger types / instances (`triggers_types`, + `trigger_instances/{slug}/upsert`, `.../manage/{id}`). SDK names are stable; REST + paths must be confirmed against the live OpenAPI spec — same caveat the tools + endpoints carried. +2. **How the project webhook URL is registered** — dashboard-only vs API. Determines + whether we can automate it per-environment or document a manual setup step. +3. **One webhook URL per Composio project** — all trigger events for all + projects/integrations arrive at a single ingress. Fan-out/routing is entirely on us + (route by `metadata.trigger_id` → local record). +4. **Retry / redelivery semantics** from Composio on a non-2xx from our ingress + (affects idempotency requirements — we must dedup on `metadata.id`). +5. **Custom-OAuth toolkits** may require registering the Composio ingress URL on the + provider's own OAuth app (noted in the Composio docs). Out of scope for managed-auth + v1 but flagged. + +## Sources + +- [Triggers | Composio](https://docs.composio.dev/docs/triggers) +- [Using Triggers | Composio](https://docs.composio.dev/docs/using-triggers) +- [Creating triggers | Composio](https://docs.composio.dev/docs/setting-up-triggers/creating-triggers) +- [Verifying webhooks | Composio](https://docs.composio.dev/docs/webhook-verification) +- [Triggers — TypeScript SDK reference | Composio](https://docs.composio.dev/sdk-reference/type-script/models/triggers) +- [Create or update a trigger | Composio API](https://docs.composio.dev/reference/api-reference/triggers/postTriggerInstancesBySlugUpsert) +- Internal: `api/oss/src/core/tools/`, `api/oss/src/apis/fastapi/tools/router.py`, + `api/oss/src/dbs/postgres/tools/`, `api/oss/src/core/webhooks/`, + `vibes/docs/designs/gateway-tools/` diff --git a/docs/designs/gateway-triggers/wp/WL-runbook.md b/docs/designs/gateway-triggers/wp/WL-runbook.md new file mode 100644 index 0000000000..cbc754077a --- /dev/null +++ b/docs/designs/gateway-triggers/wp/WL-runbook.md @@ -0,0 +1,156 @@ +# Work Lanes — runbook (GitButler) + Work Stream launch prompts + +How to create the WL branches in **`vibes/`** and spin up the WS subagents. Nothing +here is executed yet — these are the exact commands and prompts to run at kickoff. + +> **Where this runs:** ALL of this work — code, lanes, and docs — lives in **`vibes/`**, which +> is already on `gitbutler/workspace`. The sibling `application/` checkout is a separate repo +> and **must not be used for this work**. Subagents and `but` commands all operate in `vibes/`. + +## 1. The lane tree (recap from `../plan.md` §2) + +```text +main +└─ WL0 wp0-connections-extract + └─ WL1 wp1-events-catalog --anchor wp0 + ├─ WL2 wp2-resolver-promote --anchor wp1 + │ └─ WL3 wp3-subscriptions --anchor wp2 + │ ├─ WL4 wp4-ingress-dispatch --anchor wp3 + │ └─ WL6 wp6-web-subscriptions --anchor wp3 + └─ WL5 wp5-web-catalog --anchor wp1 +``` + +Every functional dep is a tree ancestor → no merge-order coordination (see plan §2). + +## 2. Create the lanes (run in `vibes/`, already in workspace mode) + +```bash +# take a snapshot first (recovery point) +but oplog snapshot -m "before gateway-triggers lanes" + +but branch new wp0-connections-extract +but branch new wp1-events-catalog --anchor wp0-connections-extract +but branch new wp2-resolver-promote --anchor wp1-events-catalog +but branch new wp3-subscriptions --anchor wp2-resolver-promote +but branch new wp4-ingress-dispatch --anchor wp3-subscriptions +but branch new wp5-web-catalog --anchor wp1-events-catalog +but branch new wp6-web-subscriptions --anchor wp3-subscriptions +``` + +PR bases (each shows only its own diff): `wp1 --base wp0`, `wp2 --base wp1`, +`wp3 --base wp2`, `wp4 --base wp3`, `wp5 --base wp1`, `wp6 --base wp3`. `wp0 --base main`. + +## 3. Docs lane (WL-x) + +The design docs in `vibes/docs/designs/gateway-triggers/**` go to their own lane in +**`vibes/`** (already in `gitbutler/workspace`): + +```bash +# in vibes/ +but branch new gateway-triggers-docs +but rub docs/designs/gateway-triggers gateway-triggers-docs # stage the folder to the lane +but commit gateway-triggers-docs --only -m "" +but push gateway-triggers-docs +gh pr create --head gateway-triggers-docs --base main --title "" --body "<body>" +``` + +Title + body authored with the `write-pr-description` skill — draft in [§5](#5-docs-pr-draft). + +## 4. WS launch prompts (paste after compact) + +**Git/GitButler is ours, not the subagents'.** We (the orchestrator) create the WL branches, +stage files to them, commit, push, and open PRs. A subagent **only writes source + test files +into the working tree** for its WP. It does **not** run `git`, `but`, `gh`, or any +branch/commit/push/PR command. After a subagent reports done, we assign its changes to the +right WL branch and commit them. + +**Subagents ask, they don't guess.** If a frozen contract looks wrong, a decision in the spec +is unresolved (e.g. WP0 revoke rule, WP4 sync-vs-async), or the scope is ambiguous, the +subagent **stops and returns the question** to us — it must not change a frozen contract, +pick an open decision, or expand scope on its own. We answer; it resumes. + +Freeze the **WS-PRE contracts** first (the interface blocks in each `WP{k}-specs.md`). Then +spawn one subagent per stream. Roots (WS0/WS1/WS2) need no stubs; WS3–WS6 build against the +frozen contracts and stub the named deps. + +Each prompt template: + +> You are implementing **WP{k}** of the gateway-triggers feature in the `vibes/` repo +> (working dir `/Users/junaway/Agenta/github/vibes`). **Do not touch the sibling +> `application/` checkout — it must not be used for this work.** +> Read your spec at `vibes/docs/designs/gateway-triggers/wp/WP{k}-specs.md` and the parent +> design docs it links (`../plan.md`, `../gap.md`, `../mapping.md`, `../mimics.md`, +> `../research.md`). +> +> **Do NOT touch git or GitButler.** Do not run `git`, `but`, `gh`, or any branch/commit/ +> push/PR command. Just create and edit the source and test files for WP{k} in the working +> tree. Branching, committing, and PRs are handled by the orchestrator after you report. +> +> Implement only WP{k}'s scope. For any dependency on another WP, code against the **frozen +> contract** in the specs and stub/mock it in tests (do NOT implement the dependency). Follow +> `vibes/api/AGENTS.md` (layering, DTOs, exceptions) and the migration rule in WP0 +> (`core_oss`, not the parked `core` tree). Write acceptance tests in both editions per the +> spec's AC. +> +> **If anything is unresolved — a frozen contract looks wrong, an open decision in the spec +> isn't decided, or scope is ambiguous — STOP and return the question.** Do not change a +> frozen contract, resolve an open decision, or expand scope yourself. +> +> Keep `vibes/docs/designs/gateway-triggers/wp/WP{k}-status.md` updated as you progress (this +> file is fine to edit — it is notes, not git). List the files you changed in your final +> report so the orchestrator can commit them to the right lane. + +| Stream | files land for branch | (anchor, set by us) | stubs against frozen contract | +|--------|----------------------|---------------------|-------------------------------| +| WS0 | wp0-connections-extract | main | — | +| WS1 | wp1-events-catalog | wp0 | — | +| WS2 | wp2-resolver-promote | wp1 | — | +| WS3 | wp3-subscriptions | wp2 | ConnectionsGW (WP0), TriggersGW (WP1) | +| WS4 | wp4-ingress-dispatch | wp3 | Subscription DTO/DAO (WP3), resolver (WP2) | +| WS5 | wp5-web-catalog | wp1 | catalog API (WP1), /connections (WP0) | +| WS6 | wp6-web-subscriptions | wp3 | /subscriptions + /deliveries (WP3) | + +The "branch" / "anchor" columns are **our** bookkeeping for where we commit the subagent's +output — the subagent itself is branch-agnostic and just writes files. Because subagents don't +touch git, two streams whose files don't overlap can run concurrently in the same tree; we +separate their changes onto the right lanes at commit time (`but rub <path> <branch>` then +`but commit <branch> --only`). + +Recommended kickoff: spawn **WS0, WS1, WS2** first (contract-free roots), then WS3–WS6 once +their upstream contracts are confirmed stable. + +## 5. Docs PR draft + +**Title:** `[docs] Plan gateway triggers: research, proposal, and WP/WL/WS breakdown` + +**Body:** + +``` +## Context +We are adding inbound provider events ("triggers") to the gateway as the dual of the +existing outbound webhooks: Composio triggers invoke Agenta workflows, the way Agenta events +already POST to user endpoints. Before writing code we needed the design fixed and the build +broken into parallelizable units. + +## Changes +Adds the gateway-triggers design set under docs/designs/gateway-triggers/: + +- research, proposal, gap, mimics, mapping: the status quo, the goal, the delta, the + parallels to tools/billing/webhooks, and how the webhook payload-mapping mechanism is + reused for event-to-workflow input mapping. +- plan.md: the work seen through three views over the same seven units. Work Packages are the + functional DAG (fan-in allowed). Work Lanes are the GitButler merge tree (one parent per + branch, no fan-in). Work Streams are parallel subagent assignments that build against frozen + inter-package contracts and stub their upstreams. +- wp/: per-package specs (WP{k}-specs.md) and trackers (WP{k}-status.md), plus this runbook + with the exact `but` lane commands and the subagent launch prompts. + +No application code changes. The connection extract (WP0) documents the one migration +subtlety: it lands in the shared core_oss chain, not the parked core tree. + +## Notes +- Lanes are not created yet; this PR is the plan only. +- The migration-chain rule cross-references docs/designs/oss-ee-convergence. +``` + +(Authored per `write-pr-description`: context-first, concrete, no em dashes, no padding.) diff --git a/docs/designs/gateway-triggers/wp/WP0-specs.md b/docs/designs/gateway-triggers/wp/WP0-specs.md new file mode 100644 index 0000000000..c2303b2e80 --- /dev/null +++ b/docs/designs/gateway-triggers/wp/WP0-specs.md @@ -0,0 +1,104 @@ +# WP0 — Connection extract (A2-2) + +**Lane** WL0 (root, anchor `main`) · **Stream** WS0 (api) · **Area** api (touches shipped tools) + +Parent docs: [`../plan.md`](../plan.md) §4, [`../gap.md`](../gap.md) §2.1, [`../proposal.md`](../proposal.md) (A2-2), +[`../mimics.md`](../mimics.md) (Triggers vs Tools, Part B). + +## Goal + +Move the provider connection out of `/tools` into a shared, **routerless** `connections` +domain, leaving the `/tools/connections` HTTP contract byte-for-byte unchanged. This is the +FK root: `gateway_connections` must exist before any subscription can reference it. + +## Closes (gap items) + +C1, C2, C3, C4, C5, C6 — and lands the **C7** cross-domain revoke *rule* in code. + +## Scope + +- **Migration** — rename `tool_connections` → `gateway_connections` (+ its `uq_`/`ix_` + constraints); rename-only, **no data transform**. Author the revision **once in the shared + `core_oss` chain** (rooted `oss000000000`, version table `alembic_version_oss`), which runs + in **both** editions — EE ships the `oss/` tree and runs it from there (no copy in + `core_ee`). **Not** the parked legacy `core` tree (frozen at `park00000000`) and **not** + `core_ee` (EE-only divergence; `gateway_connections` is shared schema). See + `application/docs/designs/oss-ee-convergence/migration-chains-and-edition-switch.md`. +- **Domain** — create `core/gateway/connections/` (service + DAO + `ConnectionsGatewayInterface`) + and `dbs/postgres/gateway/connections/` (DBE + DAO + mappings). **No router.** +- **Adapter** — move the Composio **auth** verbs (`initiate_connection`, `get_connection_status`, + `refresh_connection`, `revoke_connection`) out of `ComposioToolsAdapter` into the shared + connection adapter behind `ConnectionsGatewayInterface`. +- **Repoint tools** — `ToolsService` connection management delegates to `ConnectionsService`; + the `/tools/connections` + `/callback` handlers call through it. Fix only the FORCED + `tool_connections` string refs: tablename + `uq_`/`ix_` in `dbs/postgres/tools/dbes.py`, and + the `uq_tool_connections_*` IntegrityError match at `dao.py:72`. **B2: do NOT rename + `operation_id="query_tool_connections"` at `apis/fastapi/tools/router.py:160`** — it is part + of the frozen `/tools` OpenAPI contract; the table rename does not require touching it. +- **C7 rule (B3)** — `revoke_connection` keeps today's **local-only** behavior verbatim + (`is_valid=False` on the row; **no** provider call, **no** cascade — provider revoke stays + on DELETE). Because tools and triggers read the **same** `gateway_connections` row, that one + flag IS the cross-domain effect ("revoke-for-everyone" via the shared row, not a new provider + call). C7 additionally ships the `usage()` read ("used by tools / N subs") + the seam. + Subscription delete must **not** revoke the connection. + +## Contracts this WP freezes (consumed by WS3, WS5 — freeze in WS-PRE) + +**B1 = Option A (full extract, no leaks).** Two layers, two names — mirroring how tools is +built (`ToolsGatewayInterface` adapter + `ToolsService`). WS3/WS5 freeze against +**`ConnectionsService`**, not the adapter port. **Nothing in `connections` imports from +`tools`** (no leak): `ToolsService` depends on `ConnectionsService`, never the reverse. + +```text +# SERVICE — project-scoped, owns gateway_connections, returns domain DTOs. WS3/WS5 consume THIS. +ConnectionsService: + initiate_connection(*, project_id, provider, integration, ...) -> Connection + get_connection_status(*, project_id, connection_id) -> Status + refresh_connection(*, project_id, connection_id) -> Connection + revoke_connection(*, project_id, connection_id) -> Connection # is_valid=False on the shared row → cross-domain (C7, B3) + list_connections(*, project_id, ...) -> list[Connection] # backs /tools|/triggers/connections views + usage(*, project_id, connection_id) -> Usage # "used by tools / N subs" (what C7 ships) + +# ADAPTER PORT — provider-keyed, returns provider data. The 4 Composio auth verbs move behind THIS. +ConnectionsGatewayInterface: + initiate_connection(*, request: ConnectionRequest) -> ConnectionResponse + get_connection_status(*, provider_connection_id) -> dict + refresh_connection(*, provider_connection_id, ...) -> dict + revoke_connection(*, provider_connection_id) -> bool + +Connection DTO: { id (ca_*), project_id, provider, integration, slug, status, ... } +gateway_connections columns: (unchanged from tool_connections; already domain-neutral) +``` + +`ToolsService` delegates connection management to `ConnectionsService`. `ToolsGatewayInterface` +keeps only the tool-specific verbs (`execute`, catalog); the connection auth verbs move out to +`ConnectionsGatewayInterface` (implemented by a shared `ComposioConnectionsAdapter`). + +## Functional deps + +None — root. + +## Stubs needed + +None. + +## Decisions (RESOLVED — locked by orchestrator) + +- **B1** = Option A: full extract, two names (`ConnectionsService` + `ConnectionsGatewayInterface`), + no `connections → tools` import. WS3/WS5 freeze against `ConnectionsService`. +- **B2** = do not rename the `query_tool_connections` operation_id; only forced table refs change. +- **B3 / C7** = local-only `is_valid=False` revoke, cross-domain via the shared row; ship the + `usage()` read. Subscription delete must not revoke the connection. + +## Acceptance criteria + +- Every existing `/tools/connections` test passes **unchanged** (contract-frozen invariant). +- Migration up/down clean on **both** editions; `core_oss` chain head advances; legacy `core` + untouched. +- connect / refresh / revoke still work end-to-end via `/tools/connections`. +- (No triggers-side AC — no consumer yet.) + +## Risk + +The only PR that edits shipped tools code. Keep it a pure refactor + rename — **no behavior +change visible at `/tools`**. Largest blast radius; reviewed and merged first (it is WL0). diff --git a/docs/designs/gateway-triggers/wp/WP0-status.md b/docs/designs/gateway-triggers/wp/WP0-status.md new file mode 100644 index 0000000000..e91b90318c --- /dev/null +++ b/docs/designs/gateway-triggers/wp/WP0-status.md @@ -0,0 +1,65 @@ +# WP0 — Status + +**Lane** WL0 · **Stream** WS0 · **Branch** `wp0-connections-extract` (not yet created) + +| Field | Value | +|-------|-------| +| State | IMPLEMENTED (awaiting orchestrator commit/PR) — B1/B2/B3 resolved in spec | +| Contract frozen (WS-PRE) | ☑ `ConnectionsService` + `ConnectionsGatewayInterface` + `Connection`/`Usage` DTOs | +| Branch created | ☐ (orchestrator) | +| Subagent | WP0 impl | +| PR | — (orchestrator) | + +## Checklist + +- [x] Migration: `tool_connections` → `gateway_connections` in `core_oss` (both editions) +- [x] `core/connections/` service + DAO interface + `ConnectionsService` + `ConnectionsGatewayInterface` +- [x] `dbs/postgres/connections/` DBE + DAO + mappings +- [x] Move Composio auth verbs into shared `ComposioConnectionsAdapter` +- [x] Repoint `ToolsService` + `/tools/connections` + `/callback` handlers (delegate) +- [x] C7 cross-domain revoke rule (local-only `is_valid=False`) + `usage()` read +- [x] AC: existing `/tools/connections` contract unchanged (14 operation_ids preserved, incl. `query_tool_connections`) +- [ ] AC: migration up/down clean, both editions (needs live DB — run in CI/stack) +- [ ] PR opened `--base main` (orchestrator) + +## Decisions + +- [x] C7 revoke rule confirmed: local-only `is_valid=False` on the shared row; no provider + call, no cascade; provider revoke stays on DELETE. `usage()` is read-only seam. + +## Notes + +All three prior blockers resolved per the updated spec and implemented: + +- **B1 (Option A, full extract):** `ConnectionsService` (project-scoped, owns + `gateway_connections`, returns `Connection` DTOs) is the WS3/WS5 contract; + `ConnectionsGatewayInterface` is the provider-keyed adapter port holding only the four + auth verbs, implemented by `ComposioConnectionsAdapter`. `ConnectionsDAOInterface` is + the persistence port. Nothing in `connections` imports from `tools`; `ToolsService` + depends on `ConnectionsService` (one-way). `ToolsGatewayInterface` keeps only catalog + + `execute`; `ComposioToolsAdapter` lost the four auth verbs and its `_delete` helper. +- **B2:** `operation_id="query_tool_connections"` left untouched. The table rename moved the + table-defining code wholesale into `dbs/postgres/connections/dbes.py` as + `gateway_connections` with `uq_/ix_gateway_connections_*`; the old `dbs/postgres/tools` + package (DBE/DAO/mappings) was deleted (full extract ⇒ no in-place patch and no duplicate + SQLAlchemy mapping of the same table). The `uq_` IntegrityError match moved with it. +- **B3 / C7:** `ConnectionsService.revoke_connection` keeps today's local-only semantics + verbatim (`is_valid=False`, no provider call, no cascade). `usage()` reports + `tools=True` / `subscriptions=0` (seam; no subscription consumer exists yet). + +Layout chosen `core/connections/` + `dbs/postgres/connections/` (flat, matching existing +`core/tools/` and `core/triggers/`), not a `gateway/` subtree — the task brief specified +the flat paths and no `gateway/` tree exists in the working copy. + +Migration authored once at `core_oss` head `oss000000002` (revises `oss000000001`), +rename-only via `op.rename_table` + `RENAME CONSTRAINT` + `RENAME INDEX`, with a clean +inverse `downgrade`. Legacy `core` chain (parked `e5f6a1b2c3d4`) untouched; `core_ee` not +touched. OAuth state utils moved to `core/connections/utils.py`; the callback URL still +points at `/tools/connections/callback` (handler stays on the tools router) so the public +contract is byte-for-byte unchanged. + +Acceptance tests added in both editions: +`oss/tests/pytest/acceptance/tools/test_tools_connections.py` and +`ee/tests/pytest/acceptance/tools/test_tools_connections.py` (DB-only query + 404 always +run; create/revoke gated on `COMPOSIO_API_KEY`). Updated the lifecycle-conventions unit +test to register `connections.dbes` instead of the deleted `tools.dbes`. diff --git a/docs/designs/gateway-triggers/wp/WP1-specs.md b/docs/designs/gateway-triggers/wp/WP1-specs.md new file mode 100644 index 0000000000..c8e8afc5a7 --- /dev/null +++ b/docs/designs/gateway-triggers/wp/WP1-specs.md @@ -0,0 +1,66 @@ +# WP1 — Triggers skeleton + events catalog + adapter + +**Lane** WL1 (anchor WL0) · **Stream** WS1 (api) · **Area** api + +Parent docs: [`../plan.md`](../plan.md) §4, [`../gap.md`](../gap.md) §2.2, [`../mimics.md`](../mimics.md) (Triggers vs Tools, Part A). + +## Goal + +Stand up the triggers domain skeleton, the read-only **events** catalog, and the +`ComposioTriggersAdapter` that later WPs call to manage `ti_*` instances. + +## Closes (gap items) + +E1, E2, E3, E4 — and resolves **E5** (verify v3 REST paths). + +## Scope + +- **Skeleton** — `apis/fastapi/triggers/`, `core/triggers/`, `dbs/postgres/triggers/` + (mirror the tools layout; `action → event`). +- **Adapter** — `ComposioTriggersAdapter` (own httpx client, no SDK; `_get/_post/_delete` + + slug mapping modeled on `ComposioToolsAdapter`) behind `TriggersGatewayInterface`: + `list_events`, `get_event`, `create_subscription`, `set_subscription_status`, + `delete_subscription`. +- **Catalog** — `/triggers/catalog/.../integrations/{i}/events/{event_key}` returning the + event's `trigger_config` JSON Schema (analogue of an action's `input_parameters`). +- **Wiring** — `triggers` block in `entrypoints/routers.py` next to tools; adapter built + only when `env.composio.enabled`. +- **Permission** — introduce a dedicated **`VIEW_TRIGGERS`** permission (mirror the tools + triad in `api/ee/src/core/access/permissions/types.py`: add a `# Triggers` block and + register `VIEW_TRIGGERS` into the viewer `default_permissions`). Catalog routes gate on + `Permission.VIEW_TRIGGERS` — **do NOT reuse `VIEW_TOOLS`**. +- **E5** — verify exact Composio v3 REST paths (`triggers_types`, `trigger_instances/...`) + against the live OpenAPI spec; SDK method names are stable, paths must be confirmed. + +## Contracts this WP freezes (consumed by WS3, WS5 — freeze in WS-PRE) + +```text +TriggersGatewayInterface: + list_events(*, provider, integration) -> list[Event] + get_event(*, event_key) -> EventType # carries trigger_config JSON Schema + create_subscription(*, project_id, event_key, connected_account_id, trigger_config) -> "ti_*" + set_subscription_status(*, trigger_id, enabled: bool) -> None + delete_subscription(*, trigger_id) -> None +Catalog HTTP: GET /triggers/catalog/providers/{p}/integrations/{i}/events[/{event_key}] +Event DTO: { key, provider, integration, trigger_config: <JSONSchema>, ... } +``` + +## Functional deps + +None in-feature (uses `env.composio`, not the connection). Root in the §1 DAG. + +## Stubs needed + +None. + +## Decision to lock first + +**E5 — exact v3 REST paths** (verify vs live OpenAPI; the adapter can't be written +correctly without them). + +## Acceptance criteria (both editions) + +- Browse providers / integrations / events. +- Fetch one event's `trigger_config` schema. +- Catalog empty / disabled when `env.composio` unset. +- (Real adapter calls need live Composio creds — gate the integration test on that.) diff --git a/docs/designs/gateway-triggers/wp/WP1-status.md b/docs/designs/gateway-triggers/wp/WP1-status.md new file mode 100644 index 0000000000..a1d9102275 --- /dev/null +++ b/docs/designs/gateway-triggers/wp/WP1-status.md @@ -0,0 +1,43 @@ +# WP1 — Status + +**Lane** WL1 · **Stream** WS1 · **Branch** `wp1-events-catalog` (not yet created) + +| Field | Value | +|-------|-------| +| State | CODE COMPLETE (awaiting orchestrator commit/PR) | +| Contract frozen (WS-PRE) | ☑ `TriggersGatewayInterface` + `Event` DTO + catalog routes (implemented as written) | +| Branch created | ☐ (anchor `wp0-connections-extract`) | +| Subagent | — | +| PR | — | + +## Checklist + +- [x] Domain skeleton (apis/fastapi/triggers, core/triggers, dbs/postgres/triggers) +- [x] `ComposioTriggersAdapter` behind `TriggersGatewayInterface` (catalog + subscription verbs) +- [x] Events catalog routes + `trigger_config` schema return +- [x] Wiring in `entrypoints/routers.py` (gated on `env.composio.enabled`; lifespan close added) +- [x] E5: v3 REST paths verified vs live Composio API reference +- [x] AC: browse + fetch schema, both editions (provider catalog ungated; event browse gated on COMPOSIO_API_KEY) +- [ ] PR opened `--base wp0-connections-extract` (orchestrator) + +## Decisions + +- [x] E5 paths confirmed (verified against live Composio API reference, docs.composio.dev): + - List trigger types: `GET /triggers_types` (query `toolkit_slugs`, `limit`, `cursor`) + - Get one trigger type (config schema): `GET /triggers_types/{slug}` + - Create/upsert instance: `POST /trigger_instances/{slug}/upsert` (body `connected_account_id`, `trigger_config`) + - Enable/disable instance: `PATCH /trigger_instances/manage/{trigger_id}` (body `status` = `"enable"`/`"disable"`) + - Delete instance: `DELETE /trigger_instances/manage/{trigger_id}` + - All paths are relative to `env.composio.api_url` (default `/api/v3`); adapter builds `f"{api_url}{path}"` exactly like `ComposioToolsAdapter`. Docs currently surface these under the `v3.1` minor; the path *segments* (what E5 asked to confirm) are stable across v3/v3.1 and we keep the shared `env.composio.api_url` base. + +## Notes / blockers + +- E5 resolved without live creds: paths confirmed from the public Composio API reference (no auth needed). +- WP1 adds **no new env var**: it reuses the existing `env.composio` (enabled = key present). + `COMPOSIO_WEBHOOK_SECRET` is deliberately deferred to WP4 (ingress, gap I2) — adding it + now would be a consumer-less dead config. +- `dbs/postgres/triggers/` is an empty package skeleton in WP1 — the `subscriptions`/`deliveries` + tables + DAO + mappings are WP3 scope, so no DBE/migration here. +- EE catalog is gated on the existing `VIEW_TOOLS` permission (no `VIEW_TRIGGERS` introduced — + triggers share the gateway permission surface, per gap non-goal "no EE-only gating beyond tools"). +- Files changed listed in the final report to the orchestrator. diff --git a/docs/designs/gateway-triggers/wp/WP2-specs.md b/docs/designs/gateway-triggers/wp/WP2-specs.md new file mode 100644 index 0000000000..1b4fb11961 --- /dev/null +++ b/docs/designs/gateway-triggers/wp/WP2-specs.md @@ -0,0 +1,51 @@ +# WP2 — Resolver promotion (SDK + webhooks) + +**Lane** WL2 (anchor WL1) · **Stream** WS2 (sdk+webhooks) · **Area** sdk + webhooks + +Parent docs: [`../plan.md`](../plan.md) §4, [`../gap.md`](../gap.md) §2.5 (M1), [`../mapping.md`](../mapping.md) §5/§6. + +## Goal + +Promote the mapping resolver to the SDK under a neutral name so triggers and webhooks both +consume it without a cross-domain import. A complete, testable change on its own — its **live +consumer today is webhooks**, independent of triggers entirely. + +## Closes (gap items) + +M1. + +## Scope + +- Move `resolve_payload_fields` (`core/webhooks/delivery.py:95`) to + `agenta.sdk.utils.resolvers`, renamed **`resolve_target_fields`** (next to the existing + `resolve_json_selector` at `:114`). +- Update the webhooks call site to the new name/location. +- Pure move + rename — **no behavior change**. (It resolves a template into *a* target — + whole body for webhooks, `data.inputs` for triggers — hence the neutral name.) + +## Contracts this WP freezes (consumed by WS4 — freeze in WS-PRE) + +```text +agenta.sdk.utils.resolvers.resolve_target_fields(template, context) -> dict + # template: arbitrary JSON; leaves with $/ selectors resolved against context, else literal + # context: { event, subscription, scope } (allowlisted slots) + # null-on-miss, depth-capped (MAX_RESOLVE_DEPTH); default template "$" = whole context +``` + +## Functional deps + +None in-feature. Root in the §1 DAG. + +## Stubs needed + +None. + +## Decision to lock first + +None hard. (Confirm the SDK module path `agenta.sdk.utils.resolvers` is where it lands.) + +## Acceptance criteria + +- Existing **webhook delivery tests pass unchanged** against the renamed/relocated resolver. +- `resolve_target_fields` importable from `agenta.sdk.utils.resolvers`; no triggers→webhooks + import path introduced. diff --git a/docs/designs/gateway-triggers/wp/WP2-status.md b/docs/designs/gateway-triggers/wp/WP2-status.md new file mode 100644 index 0000000000..55d03ceebe --- /dev/null +++ b/docs/designs/gateway-triggers/wp/WP2-status.md @@ -0,0 +1,43 @@ +# WP2 — Status + +**Lane** WL2 · **Stream** WS2 · **Branch** `wp2-resolver-promote` (not yet created) + +| Field | Value | +|-------|-------| +| State | IMPLEMENTED (awaiting commit by orchestrator) | +| Contract frozen (WS-PRE) | ☑ `resolve_target_fields(template, context)` signature | +| Branch created | ☐ (anchor `wp1-events-catalog`) | +| Subagent | WP2 build agent | +| PR | — | + +## Checklist + +- [x] Move `resolve_payload_fields` → `agenta.sdk.utils.resolvers.resolve_target_fields` +- [x] Update webhooks call site +- [x] AC: webhooks delivery suite green, unchanged +- [ ] PR opened `--base wp1-events-catalog` + +## Decisions + +- [x] SDK module path confirmed — `sdks/python/agenta/sdk/utils/resolvers.py` + already exists and exports `resolve_json_selector`; `resolve_target_fields` + added next to it. No conflict. + +## Notes / blockers + +- Pure move + rename, no behavior change. `MAX_RESOLVE_DEPTH` (=10) moved with the + function into the SDK resolvers module (it only governed this recursion). +- Webhooks `delivery.py` now imports `resolve_target_fields` from the SDK and dropped + its local `resolve_payload_fields` + `MAX_RESOLVE_DEPTH`. +- Test file `test_webhooks_tasks.py`: imports + the `resolve_json_selector` patch target + repointed to `agenta.sdk.utils.resolvers`; assertions unchanged. All 19 tests pass. +- No triggers code touched; no triggers→webhooks import path introduced. +- Env note: the locally installed editable `agenta` resolves to the sibling `vibes` + worktree, so tests were run with `PYTHONPATH=.../application/sdks/python` to exercise + the edited SDK in this tree. + +## Files changed (for the orchestrator) + +- `sdks/python/agenta/sdk/utils/resolvers.py` (add `resolve_target_fields` + `MAX_RESOLVE_DEPTH`) +- `api/oss/src/core/webhooks/delivery.py` (import + call site; drop local fn/const) +- `api/oss/tests/pytest/unit/webhooks/test_webhooks_tasks.py` (import + patch target rename) diff --git a/docs/designs/gateway-triggers/wp/WP3-specs.md b/docs/designs/gateway-triggers/wp/WP3-specs.md new file mode 100644 index 0000000000..e92a38c16d --- /dev/null +++ b/docs/designs/gateway-triggers/wp/WP3-specs.md @@ -0,0 +1,64 @@ +# WP3 — Subscriptions + deliveries + +**Lane** WL3 (anchor WL2) · **Stream** WS3 (api) · **Area** api + +Parent docs: [`../plan.md`](../plan.md) §4, [`../gap.md`](../gap.md) §2.3, [`../mimics.md`](../mimics.md) (Triggers vs Webhooks), +[`../mapping.md`](../mapping.md) §3–§4. + +## Goal + +The two-table heart of the domain, modeled on webhooks' `webhook_subscriptions` + +`webhook_deliveries`. Functional as **subscription CRUD** before any dispatch exists. + +## Closes (gap items) + +S1, S2, S3, S4, S5. + +## Scope + +- **`subscriptions` table** (FlagsDBA enabled/valid, DataDBA): `ti_*`, `trigger_config`, + `inputs_fields` (the mapping template), destination `references`/`selector`, the bound + **workflow ref**, **FK → `gateway_connections`**. Many per connection. +- **`deliveries` table** (modeled on `webhook_deliveries`): resolved `inputs`, workflow + `references`, `result`/`error`, plus the `metadata.id` **dedup column** (I4). +- **DBA mixins** for both (mirror `dbs/postgres/webhooks/dbas.py`; tools has none). +- **Migration** authored once in the shared `core_oss` chain (both editions, per WP0's rule). +- **Subscription CRUD** `/triggers/subscriptions/` · `/query` · `/{id}` · `/{id}/refresh` · + `/{id}/revoke` — create/disable/delete the Composio `ti_*` through the adapter + (`TriggersGatewayInterface.create_subscription` etc.), referencing a shared connection. + Deleting a subscription must **not** revoke the connection (C7). +- **Delivery read** routes `/triggers/deliveries` · `/{id}` · `/query`. + +## Contracts this WP freezes (consumed by WS4, WS6 — freeze in WS-PRE) + +```text +Subscription DTO: { id, project_id, connection_id (FK), event_key, ti_id, trigger_config, + inputs_fields, references, selector, enabled, valid, ... } +Delivery DTO: { id, subscription_id, event_id (metadata.id), inputs, references, result, error, ... } +HTTP: /triggers/subscriptions/{,query,{id},{id}/refresh,{id}/revoke}; /triggers/deliveries/{,{id},query} +DAO surface (for WP4): get_subscription_by_trigger_id, write_delivery, dedup_seen(event_id) +``` + +## Functional deps (fan-in) + +- **WP0** — `subscriptions` FKs `gateway_connections`. +- **WP1** — `create_subscription` builds the `ti_*` via `TriggersGatewayInterface` (the + adapter, **not** the catalog routes). + +## Stubs needed (until deps merge) + +- `ConnectionsGatewayInterface` (WP0) — stub the connection lookup/FK target. +- `TriggersGatewayInterface` (WP1) — stub `create_subscription`/`set_status`/`delete`. + +Both against their frozen WS-PRE contracts; mock in unit tests. + +## Decisions to lock first + +- **Idempotency store (I4)** — lean: a `metadata.id` dedup column on `deliveries`. +- **Default mapping + validation posture (M8)** — inputs-only default; schema validation a stretch. + +## Acceptance criteria (both editions) + +- Create a subscription on a shared connection bound to a workflow. +- List / disable / delete it; deleting it leaves the connection intact (C7). +- Deliveries list returns rows. diff --git a/docs/designs/gateway-triggers/wp/WP3-status.md b/docs/designs/gateway-triggers/wp/WP3-status.md new file mode 100644 index 0000000000..16863c239c --- /dev/null +++ b/docs/designs/gateway-triggers/wp/WP3-status.md @@ -0,0 +1,60 @@ +# WP3 — Status + +**Lane** WL3 · **Stream** WS3 · **Branch** `wp3-subscriptions` (created by orchestrator) + +| Field | Value | +|-------|-------| +| State | IMPLEMENTED (pending commit + live-API test run) | +| Contract frozen (WS-PRE) | ☑ Subscription/Delivery DTOs + routes + DAO surface | +| Consumes frozen | ☑ ConnectionsGW (WP0) ☑ TriggersGW (WP1) | +| Branch created | (orchestrator) | +| Subagent | WP3 build | +| PR | — | + +## Checklist + +- [x] `trigger_subscriptions` table (FlagsDBA enabled/valid, DataDBA, FK → gateway_connections) +- [x] `trigger_deliveries` table (+ `event_id` = provider `metadata.id` dedup column, unique per subscription) +- [x] DBA mixins (mirror webhooks/dbas.py) — `dbs/postgres/triggers/dbas.py` +- [x] Migration in `core_oss` (`oss000000003`, down_revision `oss000000002`; runs in both editions) +- [x] Subscription CRUD routes + adapter calls (ti_* create / set-status / delete) +- [x] Delivery read routes (`/triggers/deliveries`, `/{id}`, `/query`) +- [x] DAO surface for WP4: `get_subscription_by_trigger_id`, `write_delivery`, `dedup_seen` +- [x] AC tests (OSS + EE): list/query/404 DB-only; create/list/disable/delete + C7 gated on COMPOSIO_API_KEY +- [ ] PR opened `--base wp2-resolver-promote` (orchestrator) + +## Decisions (locked, built to) + +- [x] I4 idempotency — `event_id` (String) dedup column on `trigger_deliveries`, unique on + `(project_id, subscription_id, event_id)`; `write_delivery` upserts on it, `dedup_seen` checks it. +- [x] M8 default mapping — inputs-only; `inputs_fields` template stored on the subscription, resolved + (by WP4) via the promoted `agenta.sdk.utils.resolvers.resolve_target_fields`. No schema validation. + +## Implementation notes + +- Tables named `trigger_subscriptions` / `trigger_deliveries` (domain-prefixed, mirroring + `webhook_subscriptions`/`webhook_deliveries`) — NOT bare `subscriptions`/`deliveries`, which would + collide with EE billing subscriptions. +- Subscription DTO nests `event_key`/`ti_id`/`trigger_config`/`inputs_fields`/`references`/`selector` + under `data` (exactly as webhooks nests `event_types`/`payload_fields` under `data`); `connection_id`, + `enabled`, `valid` are top-level. The frozen field inventory is satisfied; nesting follows the + webhooks precedent it mirrors. +- `enabled`/`valid` persist in the FlagsDBA `flags` JSONB (`{"enabled":..,"valid":..}`). +- C7 enforced: `delete_subscription` / `revoke_subscription` only touch the provider trigger instance + (`ti_*`) via the adapter, never the shared `gateway_connections` row. +- EE permissions: added `EDIT_TRIGGERS` to EDITOR_PERMISSIONS and `RUN_TRIGGERS` to ANNOTATOR_PERMISSIONS + (parallel to `EDIT_TOOLS`/`RUN_TOOLS`) so the developer role can actually exercise subscription CRUD — + the enum values existed but were ungranted to every role except owner. See blocker note below. + +## Notes / blockers + +- **Testing seam (not a blocker, but a constraint):** acceptance tests run over HTTP against a live API, + so the Composio adapter cannot be dependency-injected/mocked. The instruction "mock the adapter" is + satisfied in spirit by gating the adapter-dependent path (create → ti_* → disable → delete, plus the + C7 connection-intact assertion) on `COMPOSIO_API_KEY`, exactly as the existing tools/connections and + triggers/catalog suites do. DB-only reads/queries/404s run unconditionally and prove the migration + landed. If a true adapter mock is wanted, it needs a unit-test harness against `TriggersService` + (out of WP3's acceptance-test scope). +- **EE permission grant (flagged for review):** I added `EDIT_TRIGGERS`/`RUN_TRIGGERS` to the + editor/annotator role sets. This is the minimal change to make the locked `EDIT_TRIGGERS` gating + functional for non-owner roles; if WP1 intended a different role mapping, adjust there. diff --git a/docs/designs/gateway-triggers/wp/WP4-specs.md b/docs/designs/gateway-triggers/wp/WP4-specs.md new file mode 100644 index 0000000000..71596abc24 --- /dev/null +++ b/docs/designs/gateway-triggers/wp/WP4-specs.md @@ -0,0 +1,58 @@ +# WP4 — Ingress + dispatch + +**Lane** WL4 (anchor WL3) · **Stream** WS4 (api) · **Area** api + +Parent docs: [`../plan.md`](../plan.md) §4, [`../gap.md`](../gap.md) §2.4 + §2.5, [`../mimics.md`](../mimics.md) (Triggers vs Billing; +Triggers vs Everything), [`../mapping.md`](../mapping.md) §3–§4. + +## Goal + +Close the loop in **one** functional unit: an inbound event is received, verified, scoped, +resolved, and acted on. Ingress lives here (not its own lane) because a verify-and-park +endpoint isn't functional — the receive path only becomes real once it dispatches. + +## Closes (gap items) + +I1, I2, I3, I4, I5, I6, M2, M3, M4, M5, M6, M7, M9 — and consumes **M1** (the resolver). + +## Scope — ingress half (mimic billing `/stripe/events/`) + +- `POST /triggers/composio/events/` — read raw body **before** parsing. +- HMAC-SHA256 verify over `{id}.{ts}.{body}` with `COMPOSIO_WEBHOOK_SECRET`; 401 bad sig; + 200 no-op when secret unset; add `COMPOSIO_WEBHOOK_SECRET` to `env`. +- Recover `project_id` from `metadata.user_id`; route `metadata.trigger_id` → local + subscription; 200-skip unknown/disabled; optional `target`-style env fan-out guard (I5). +- One-time project webhook-URL registration with Composio (I6). + +## Scope — dispatch half + +- Resolve `inputs_fields` via `resolve_target_fields` against `{event, subscription, scope}` + with `TRIGGER_EVENT_FIELDS` (M2, M3) into `data.inputs` **only**. +- Build the `WorkflowServiceRequest`: destination from the stored workflow `references`/ + `selector` (M4); call `WorkflowsService.invoke_workflow(project_id, user_id, request)` (M5). +- **System-initiated identity** (M6) — run as a resolved project-system `user_id`. +- **Async dispatch** (M7) — ack-fast + enqueue; ingress returns 2xx promptly. +- Real `metadata.id` dedup against `deliveries` (I4); write a delivery row per event with + outcome; dispatch retry policy (M9). + +## Functional deps (fan-in) + +- **WP3** — reads the subscription, writes a `deliveries` row (DTO + DAO surface). +- **WP2** — imports `resolve_target_fields`. + +## Stubs needed (until deps merge) + +- Subscription DTO/DAO (WP3) — stub `get_subscription_by_trigger_id` + `write_delivery`. +- `resolve_target_fields` (WP2) — import against the frozen signature. + +## Decisions to lock first + +Webhook-URL registration (I6), sync-vs-async (M7), system `user_id` (M6), retry policy (M9). + +## Acceptance criteria (both editions) + +- Forged signature → 401; unset secret → 200 no-op. +- Signed event for a known subscription → bound workflow invoked with the mapped inputs. +- Duplicate `metadata.id` → **single** invocation. +- Bad mapping / missing workflow → a `deliveries` **error row** (no workflow trace), still + 2xx to the provider. diff --git a/docs/designs/gateway-triggers/wp/WP4-status.md b/docs/designs/gateway-triggers/wp/WP4-status.md new file mode 100644 index 0000000000..2da5f306e1 --- /dev/null +++ b/docs/designs/gateway-triggers/wp/WP4-status.md @@ -0,0 +1,36 @@ +# WP4 — Status + +**Lane** WL4 · **Stream** WS4 · **Branch** `wp4-ingress-dispatch` (not yet created) + +| Field | Value | +|-------|-------| +| State | NOT STARTED | +| Consumes frozen | ☐ Subscription DTO/DAO (WP3) ☐ `resolve_target_fields` (WP2) | +| Branch created | ☐ (anchor `wp3-subscriptions`) | +| Subagent | — | +| PR | — | + +## Checklist + +- [ ] `POST /triggers/composio/events/` raw-body + HMAC verify + `COMPOSIO_WEBHOOK_SECRET` +- [ ] project/trigger scoping + 200-skip + target guard (I5) +- [ ] webhook-URL registration (I6) +- [ ] resolve `inputs_fields` → `data.inputs` (M2, M3) +- [ ] build request refs/selector (M4) + `invoke_workflow` (M5) +- [ ] system `user_id` (M6) +- [ ] async dispatch (M7) +- [ ] metadata.id dedup (I4) + delivery rows + retry (M9) +- [ ] Stub WP3 DAO + WP2 resolver until merged +- [ ] AC: 401 / no-op / invoke / dedup / error-row +- [ ] PR opened `--base wp3-subscriptions` + +## Decisions + +- [ ] I6 webhook-URL registration +- [ ] M7 sync vs async +- [ ] M6 system identity +- [ ] M9 retry policy + +## Notes / blockers + +_(none yet)_ diff --git a/docs/designs/gateway-triggers/wp/WP5-specs.md b/docs/designs/gateway-triggers/wp/WP5-specs.md new file mode 100644 index 0000000000..73d1e2a4cb --- /dev/null +++ b/docs/designs/gateway-triggers/wp/WP5-specs.md @@ -0,0 +1,43 @@ +# WP5 — Web: catalog + connections UI + +**Lane** WL5 (anchor WL1) · **Stream** WS5 (web) · **Area** web + +Parent docs: [`../plan.md`](../plan.md) §4, [`../gap.md`](../gap.md) §2.6 (F1 browse, F2). + +## Goal + +The browse half of the FE: providers / integrations / events and the connection list, on a +"Triggers" surface of a connected integration. + +## Closes (gap items) + +F1 (catalog/connect part), F2. + +## Scope + +- "Triggers" entry on a connected integration — browse events and their `trigger_config` + schema (WP1 catalog API). +- Show connections via `/triggers/connections`. +- Handle the **overlapping connection reads** across `/tools/connections` and + `/triggers/connections` (same shared rows, F2) — the FE must tolerate the same connection + appearing in both lists. +- Reuse the existing tools UI surfaces: `web/packages/agenta-entities/src/gatewayTool`, + `web/packages/agenta-entity-ui/src/gatewayTool`, `web/oss/src/components/pages/settings/Tools`. + +## Functional deps (fan-in) + +- **WP1** — the catalog API. +- **WP0** — the `/…/connections` view over `gateway_connections`. + +## Stubs needed (until deps merge) + +- Mock the catalog (WP1) and `/…/connections` (WP0) HTTP against their frozen shapes. + +## Decisions to lock first + +None hard (consumes frozen API shapes). + +## Acceptance criteria + +- Browse a connected integration's events. +- The same connection appears under **both** tools and triggers without a second connect. diff --git a/docs/designs/gateway-triggers/wp/WP5-status.md b/docs/designs/gateway-triggers/wp/WP5-status.md new file mode 100644 index 0000000000..617c70644f --- /dev/null +++ b/docs/designs/gateway-triggers/wp/WP5-status.md @@ -0,0 +1,76 @@ +# WP5 — Status + +**Lane** WL5 · **Stream** WS5 · **Branch** `wp5-web-catalog` (not yet created) + +| Field | Value | +|-------|-------| +| State | IMPLEMENTED (awaiting branch/PR) | +| Consumes frozen | ☑ catalog API (WP1) ☑ /…/connections (WP0) | +| Branch created | ☐ (anchor `wp1-events-catalog`) | +| Subagent | WS5 | +| PR | — | + +## Checklist + +- [x] "Triggers" surface on a connected integration (settings tab + section) +- [x] Events browse + `trigger_config` schema view (WP1 API) +- [x] Connections list via `/triggers/connections` +- [x] F2: tolerate overlapping connection reads (tools ∩ triggers) +- [x] Mock WP1/WP0 HTTP until merged (unit tests stub axios at the boundary) +- [x] AC: browse events; connection shows under both +- [ ] PR opened `--base wp1-events-catalog` + +## What was built + +New `@agenta/entities/gatewayTrigger` (state + queries) and +`@agenta/entity-ui/gatewayTrigger` (events drawer), mirroring `gatewayTool`. New OSS +`settings/Triggers` surface wired as a `triggers` settings tab (gated by `isToolsEnabled()`, +the shared Composio gate). The Triggers section lists the shared connections and opens an +events drawer per connection; selecting an event shows its `trigger_config` schema +(read-only, via the reused `SchemaForm`). + +### Files + +Entities (`web/packages/agenta-entities/`): + +- `src/gatewayTrigger/core/types.ts` (+ `core/index.ts`) +- `src/gatewayTrigger/api/{client,api,index}.ts` +- `src/gatewayTrigger/state/{atoms,index}.ts` +- `src/gatewayTrigger/hooks/{useCatalogEvents,useTriggerEvent,useTriggerConnections,index}.ts` +- `src/gatewayTrigger/index.ts` +- `tests/unit/gatewayTriggerApi.test.ts` +- `package.json` (added `./gatewayTrigger` export) + +Entity-UI (`web/packages/agenta-entity-ui/`): + +- `src/gatewayTrigger/drawers/TriggerEventsDrawer.tsx` +- `src/gatewayTrigger/index.ts` +- `package.json` (added `./gatewayTrigger` export) + +OSS (`web/oss/`): + +- `src/components/pages/settings/Triggers/Triggers.tsx` +- `src/components/pages/settings/Triggers/components/GatewayTriggersSection.tsx` +- `src/pages/w/[workspace_id]/p/[project_id]/settings/index.tsx` (triggers tab) +- `src/components/Sidebar/SettingsSidebar.tsx` (triggers menu item) + +## Notes / blockers + +- **Fern client gap (follow-up, not a blocker):** the shipped WP1 catalog API is NOT yet + in the Fern-generated `@agentaai/api-client` (no `triggers` resource). Per the WS5 stub + strategy this layer uses the shared axios instance with zod boundary validation (the + local schemas mirror `core/triggers/dtos.py` + `triggers/models.py` verbatim). When the + client is regenerated with a `triggers` resource, `gatewayTrigger/api/*` collapses onto + `getAgentaSdkClient().triggers` the same way `gatewayTool` does — a mechanical swap. +- **`/triggers/connections` consumed against the frozen WP0 shape, not yet shipped.** The + triggers router (`api/oss/src/apis/fastapi/triggers/router.py`) currently exposes only + the catalog routes; the `/triggers/connections` view over `gateway_connections` (WP0) is + not mounted there yet. The FE calls `POST /triggers/connections/query` mirroring + `POST /tools/connections/query` (same `{count, connections: Connection[]}` shape, same + shared rows). This is exactly the WP0 dep WS5 stubs until it merges; unit tests cover the + request/response shape. No backend change is in WP5 scope. +- **F2 handled explicitly:** trigger connections use their own React-Query keys + (`["triggers", "connections", …]`), distinct from tools (`["tools", …]`), so the same + shared row in both lists causes no cache or rowKey collision. The connection TS type is + aliased to the gatewayTool type so the two lists are byte-compatible; no duplicate-connect + path exists on the triggers surface (it only reads + browses events). diff --git a/docs/designs/gateway-triggers/wp/WP6-specs.md b/docs/designs/gateway-triggers/wp/WP6-specs.md new file mode 100644 index 0000000000..6370bc4da8 --- /dev/null +++ b/docs/designs/gateway-triggers/wp/WP6-specs.md @@ -0,0 +1,38 @@ +# WP6 — Web: subscriptions + deliveries UI + +**Lane** WL6 (anchor WL3) · **Stream** WS6 (web) · **Area** web + +Parent docs: [`../plan.md`](../plan.md) §4, [`../gap.md`](../gap.md) §2.6 (F1 subscribe, F3). + +## Goal + +The management half of the FE: create / manage subscriptions and view deliveries. + +## Closes (gap items) + +F1 (subscribe part), F3. + +## Scope + +- Create a subscription — pick event + bind workflow + author the mapping (`inputs_fields`) — + via the WP3 subscription API. +- List / disable / delete subscriptions. +- Deliveries audit view (`/triggers/deliveries`, F3 — deferrable past v1). + +## Functional deps + +- **WP3** only — the `/triggers/subscriptions` + `/triggers/deliveries` API. Independent of + WP4 (the management UI doesn't need dispatch to exist). + +## Stubs needed (until deps merge) + +- Mock the WP3 HTTP surface against its frozen shape. + +## Decisions to lock first + +None hard (consumes the frozen WP3 API). + +## Acceptance criteria + +- Create a workflow-bound subscription; list / disable / delete it. +- Deliveries view renders (empty until WP4 dispatch lands). diff --git a/docs/designs/gateway-triggers/wp/WP6-status.md b/docs/designs/gateway-triggers/wp/WP6-status.md new file mode 100644 index 0000000000..d96d03b64c --- /dev/null +++ b/docs/designs/gateway-triggers/wp/WP6-status.md @@ -0,0 +1,24 @@ +# WP6 — Status + +**Lane** WL6 · **Stream** WS6 · **Branch** `wp6-web-subscriptions` (not yet created) + +| Field | Value | +|-------|-------| +| State | NOT STARTED | +| Consumes frozen | ☐ /triggers/subscriptions + /deliveries (WP3) | +| Branch created | ☐ (anchor `wp3-subscriptions`) | +| Subagent | — | +| PR | — | + +## Checklist + +- [ ] Create subscription (event + workflow binding + mapping) +- [ ] List / disable / delete +- [ ] Deliveries audit view (F3, deferrable) +- [ ] Mock WP3 HTTP until merged +- [ ] AC: create/manage; deliveries renders empty +- [ ] PR opened `--base wp3-subscriptions` + +## Notes / blockers + +_(none yet)_ diff --git a/hosting/docker-compose/ee/docker-compose.dev.yml b/hosting/docker-compose/ee/docker-compose.dev.yml index c08109d846..ead20d0bcd 100644 --- a/hosting/docker-compose/ee/docker-compose.dev.yml +++ b/hosting/docker-compose/ee/docker-compose.dev.yml @@ -268,6 +268,51 @@ services: retries: 3 start_period: 20s + worker-triggers: + # === IMAGE ================================================ # + image: agenta-ee-dev-api:latest + # === EXECUTION ============================================ # + command: > + watchmedo auto-restart --directory=/app/ee/src --directory=/app/ee/databases --directory=/app/oss/src + --directory=/app/oss/databases --directory=/app/entrypoints --directory=/sdks/python/agenta + --directory=/clients/python/agenta_client --pattern=*.py --recursive --ignore-patterns=*/tests/* -- + python -m entrypoints.worker_triggers + # === STORAGE ============================================== # + volumes: + - ../../../api/ee:/app/ee + - ../../../api/oss:/app/oss + - ../../../api/entrypoints:/app/entrypoints + - ../../../sdks/python:/sdks/python + - ../../../clients/python:/clients/python + # === CONFIGURATION ======================================== # + env_file: + - ${ENV_FILE:-./.env.ee.dev} + environment: + DOCKER_NETWORK_MODE: ${DOCKER_NETWORK_MODE:-bridge} + # === NETWORK ============================================== # + networks: + - agenta-network + extra_hosts: + - "host.docker.internal:host-gateway" + # === ORCHESTRATION ======================================== # + depends_on: + postgres: + condition: service_healthy + alembic: + condition: service_completed_successfully + redis-volatile: + condition: service_healthy + redis-durable: + condition: service_healthy + # === LIFECYCLE ============================================ # + restart: always + healthcheck: + test: ["CMD", "python", "-c", "import pathlib,sys; cmd=pathlib.Path('/proc/1/cmdline').read_bytes().decode('utf-8','ignore'); sys.exit(0 if 'entrypoints.worker_triggers' in cmd else 1)"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 20s + worker-events: # === IMAGE ================================================ # image: agenta-ee-dev-api:latest diff --git a/hosting/docker-compose/ee/docker-compose.gh.local.yml b/hosting/docker-compose/ee/docker-compose.gh.local.yml index 7e72548082..4565e37e32 100644 --- a/hosting/docker-compose/ee/docker-compose.gh.local.yml +++ b/hosting/docker-compose/ee/docker-compose.gh.local.yml @@ -191,6 +191,47 @@ services: retries: 3 start_period: 20s + worker-triggers: + # === IMAGE ================================================ # + build: + context: ../../.. + dockerfile: api/ee/docker/Dockerfile.gh + # === EXECUTION ============================================ # + command: + [ + "newrelic-admin", + "run-program", + "python", + "-m", + "entrypoints.worker_triggers", + ] + # === CONFIGURATION ======================================== # + env_file: + - ${ENV_FILE:-./.env.ee.gh} + # === NETWORK ============================================== # + networks: + - agenta-ee-gh-network + extra_hosts: + - "host.docker.internal:host-gateway" + # === ORCHESTRATION ======================================== # + depends_on: + postgres: + condition: service_healthy + alembic: + condition: service_completed_successfully + redis-volatile: + condition: service_healthy + redis-durable: + condition: service_healthy + # === LIFECYCLE ============================================ # + restart: always + healthcheck: + test: ["CMD", "python", "-c", "import pathlib,sys; cmd=pathlib.Path('/proc/1/cmdline').read_bytes().decode('utf-8','ignore'); sys.exit(0 if 'entrypoints.worker_triggers' in cmd else 1)"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 20s + worker-events: # === IMAGE ================================================ # build: diff --git a/hosting/docker-compose/ee/docker-compose.gh.yml b/hosting/docker-compose/ee/docker-compose.gh.yml index a74e799626..e25c845cc8 100644 --- a/hosting/docker-compose/ee/docker-compose.gh.yml +++ b/hosting/docker-compose/ee/docker-compose.gh.yml @@ -188,6 +188,45 @@ services: retries: 3 start_period: 20s + worker-triggers: + # === IMAGE ================================================ # + image: ghcr.io/agenta-ai/${AGENTA_API_IMAGE_NAME:-internal-ee-agenta-api}:${AGENTA_API_IMAGE_TAG:-latest} + # === EXECUTION ============================================ # + command: + [ + "python", + "-m", + "entrypoints.worker_triggers", + ] + # === CONFIGURATION ======================================== # + env_file: + - ${ENV_FILE:-./.env.ee.gh} + environment: + - DOCKER_NETWORK_MODE=${DOCKER_NETWORK_MODE:-bridge} + # === NETWORK ============================================== # + networks: + - agenta-ee-gh-network + extra_hosts: + - "host.docker.internal:host-gateway" + # === ORCHESTRATION ======================================== # + depends_on: + postgres: + condition: service_healthy + alembic: + condition: service_completed_successfully + redis-volatile: + condition: service_healthy + redis-durable: + condition: service_healthy + # === LIFECYCLE ============================================ # + restart: always + healthcheck: + test: ["CMD", "python", "-c", "import pathlib,sys; cmd=pathlib.Path('/proc/1/cmdline').read_bytes().decode('utf-8','ignore'); sys.exit(0 if 'entrypoints.worker_triggers' in cmd else 1)"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 20s + worker-events: # === IMAGE ================================================ # image: ghcr.io/agenta-ai/${AGENTA_API_IMAGE_NAME:-internal-ee-agenta-api}:${AGENTA_API_IMAGE_TAG:-latest} diff --git a/hosting/docker-compose/oss/docker-compose.dev.yml b/hosting/docker-compose/oss/docker-compose.dev.yml index 583d8b9496..7d482328cc 100644 --- a/hosting/docker-compose/oss/docker-compose.dev.yml +++ b/hosting/docker-compose/oss/docker-compose.dev.yml @@ -260,6 +260,50 @@ services: retries: 3 start_period: 20s + worker-triggers: + # === IMAGE ================================================ # + image: agenta-oss-dev-api:latest + # === EXECUTION ============================================ # + command: > + watchmedo auto-restart --directory=/app/oss/src --directory=/app/oss/databases --directory=/app/entrypoints + --directory=/sdks/python/agenta --directory=/clients/python/agenta_client --pattern=*.py --recursive --ignore-patterns=*/tests/* -- + python -m entrypoints.worker_triggers + # === STORAGE ============================================== # + volumes: + # + - ../../../api/oss:/app/oss + - ../../../api/entrypoints:/app/entrypoints + - ../../../sdks/python:/sdks/python + - ../../../clients/python:/clients/python + # === CONFIGURATION ======================================== # + env_file: + - ${ENV_FILE:-./.env.oss.dev} + environment: + DOCKER_NETWORK_MODE: ${DOCKER_NETWORK_MODE:-bridge} + # === NETWORK ============================================== # + networks: + - agenta-network + extra_hosts: + - "host.docker.internal:host-gateway" + # === ORCHESTRATION ======================================== # + depends_on: + postgres: + condition: service_healthy + alembic: + condition: service_completed_successfully + redis-volatile: + condition: service_healthy + redis-durable: + condition: service_healthy + # === LIFECYCLE ============================================ # + restart: always + healthcheck: + test: ["CMD", "python", "-c", "import pathlib,sys; cmd=pathlib.Path('/proc/1/cmdline').read_bytes().decode('utf-8','ignore'); sys.exit(0 if 'entrypoints.worker_triggers' in cmd else 1)"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 20s + worker-events: # === IMAGE ================================================ # image: agenta-oss-dev-api:latest diff --git a/hosting/docker-compose/oss/docker-compose.gh.local.yml b/hosting/docker-compose/oss/docker-compose.gh.local.yml index 4bc21b5293..c84f4db9fd 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.local.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.local.yml @@ -189,6 +189,47 @@ services: retries: 3 start_period: 20s + worker-triggers: + # === IMAGE ================================================ # + build: + context: ../../.. + dockerfile: api/oss/docker/Dockerfile.gh + # === EXECUTION ============================================ # + command: + [ + "newrelic-admin", + "run-program", + "python", + "-m", + "entrypoints.worker_triggers", + ] + # === CONFIGURATION ======================================== # + env_file: + - ${ENV_FILE:-./.env.oss.gh} + # === NETWORK ============================================== # + networks: + - agenta-oss-gh-network + extra_hosts: + - "host.docker.internal:host-gateway" + # === ORCHESTRATION ======================================== # + depends_on: + postgres: + condition: service_healthy + alembic: + condition: service_completed_successfully + redis-volatile: + condition: service_healthy + redis-durable: + condition: service_healthy + # === LIFECYCLE ============================================ # + restart: always + healthcheck: + test: ["CMD", "python", "-c", "import pathlib,sys; cmd=pathlib.Path('/proc/1/cmdline').read_bytes().decode('utf-8','ignore'); sys.exit(0 if 'entrypoints.worker_triggers' in cmd else 1)"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 20s + worker-events: # === IMAGE ================================================ # build: diff --git a/hosting/docker-compose/oss/docker-compose.gh.ssl.yml b/hosting/docker-compose/oss/docker-compose.gh.ssl.yml index 71dda7e426..94700680ad 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.ssl.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.ssl.yml @@ -202,6 +202,47 @@ services: retries: 3 start_period: 20s + worker-triggers: + # === IMAGE ================================================ # + build: + context: ../../.. + dockerfile: api/oss/docker/Dockerfile.gh + # === EXECUTION ============================================ # + command: + [ + "newrelic-admin", + "run-program", + "python", + "-m", + "entrypoints.worker_triggers", + ] + # === CONFIGURATION ======================================== # + env_file: + - ${ENV_FILE:-./.env.oss.gh} + # === NETWORK ============================================== # + networks: + - agenta-gh-ssl-network + extra_hosts: + - "host.docker.internal:host-gateway" + # === ORCHESTRATION ======================================== # + depends_on: + postgres: + condition: service_healthy + alembic: + condition: service_completed_successfully + redis-volatile: + condition: service_healthy + redis-durable: + condition: service_healthy + # === LIFECYCLE ============================================ # + restart: always + healthcheck: + test: ["CMD", "python", "-c", "import pathlib,sys; cmd=pathlib.Path('/proc/1/cmdline').read_bytes().decode('utf-8','ignore'); sys.exit(0 if 'entrypoints.worker_triggers' in cmd else 1)"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 20s + worker-events: # === IMAGE ================================================ # build: diff --git a/hosting/docker-compose/oss/docker-compose.gh.yml b/hosting/docker-compose/oss/docker-compose.gh.yml index d39ffb8643..f0a78b3b66 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.yml @@ -201,6 +201,50 @@ services: retries: 3 start_period: 20s + worker-triggers: + # === IMAGE ================================================ # + # build: + # context: ../../.. + # dockerfile: api/oss/docker/Dockerfile.gh + image: ghcr.io/agenta-ai/${AGENTA_API_IMAGE_NAME:-agenta-api}:${AGENTA_API_IMAGE_TAG:-latest} + # === EXECUTION ============================================ # + command: + [ + "newrelic-admin", + "run-program", + "python", + "-m", + "entrypoints.worker_triggers", + ] + # === CONFIGURATION ======================================== # + env_file: + - ${ENV_FILE:-./.env.oss.gh} + environment: + - DOCKER_NETWORK_MODE=${DOCKER_NETWORK_MODE:-bridge} + # === NETWORK ============================================== # + networks: + - agenta-oss-gh-network + extra_hosts: + - "host.docker.internal:host-gateway" + # === ORCHESTRATION ======================================== # + depends_on: + postgres: + condition: service_healthy + alembic: + condition: service_completed_successfully + redis-volatile: + condition: service_healthy + redis-durable: + condition: service_healthy + # === LIFECYCLE ============================================ # + restart: always + healthcheck: + test: ["CMD", "python", "-c", "import pathlib,sys; cmd=pathlib.Path('/proc/1/cmdline').read_bytes().decode('utf-8','ignore'); sys.exit(0 if 'entrypoints.worker_triggers' in cmd else 1)"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 20s + worker-events: # === IMAGE ================================================ # # build: diff --git a/sdks/python/agenta/sdk/utils/resolvers.py b/sdks/python/agenta/sdk/utils/resolvers.py index b7b51ed5c4..a512a27489 100644 --- a/sdks/python/agenta/sdk/utils/resolvers.py +++ b/sdks/python/agenta/sdk/utils/resolvers.py @@ -12,6 +12,8 @@ log = get_module_logger(__name__) +MAX_RESOLVE_DEPTH = 10 + # ========= Scheme detection ========= @@ -132,3 +134,33 @@ def resolve_json_selector(value: Any, data: Dict[str, Any]) -> Any: log.debug("Failed to resolve JSON selector %r: %s", value, exc) return None return value + + +def resolve_target_fields( + template: Any, + context: Dict[str, Any], + *, + _depth: int = 0, +) -> Any: + """Resolve a template into a target by resolving its selector leaves. + + Walks ``template`` (arbitrary JSON); each leaf is passed through + ``resolve_json_selector`` against *context* (``$``/``/`` selectors resolved, + everything else returned literally). Null-on-miss, depth-capped at + ``MAX_RESOLVE_DEPTH``. + """ + if _depth > MAX_RESOLVE_DEPTH: + return None + if isinstance(template, dict): + return { + k: resolve_target_fields(v, context, _depth=_depth + 1) + for k, v in template.items() + } + if isinstance(template, list): + return [ + resolve_target_fields(item, context, _depth=_depth + 1) for item in template + ] + try: + return resolve_json_selector(template, context) + except Exception: + return None diff --git a/sdks/python/oss/tests/pytest/unit/test_resolvers.py b/sdks/python/oss/tests/pytest/unit/test_resolvers.py new file mode 100644 index 0000000000..281b8ef013 --- /dev/null +++ b/sdks/python/oss/tests/pytest/unit/test_resolvers.py @@ -0,0 +1,125 @@ +"""Unit tests for the shared selector-resolution helpers. + +Pure logic, no network or database. These live in ``agenta.sdk.utils.resolvers`` +so API-side code (webhook delivery, trigger dispatch) can reuse them; this suite +gives the SDK home its own coverage instead of relying on the api-side callers. +""" + +from agenta.sdk.utils.resolvers import ( + MAX_RESOLVE_DEPTH, + detect_scheme, + resolve_dot_notation, + resolve_json_selector, + resolve_target_fields, +) + +_CONTEXT = { + "event": { + "data": {"issue": {"number": 7}}, + "type": "github.issue.opened", + "timestamp": "2024-01-01T00:00:00Z", + }, + "subscription": {"id": "sub-1", "name": "watch"}, + "scope": {"project_id": "proj-1"}, +} + + +class TestDetectScheme: + def test_json_path(self): + assert detect_scheme("$.event.type") == "json-path" + + def test_json_pointer(self): + assert detect_scheme("/event/type") == "json-pointer" + + def test_dot_notation(self): + assert detect_scheme("event.type") == "dot-notation" + + +class TestResolveJsonSelector: + def test_json_path_leaf(self): + assert resolve_json_selector("$.event.type", _CONTEXT) == "github.issue.opened" + + def test_json_pointer_leaf(self): + assert resolve_json_selector("/scope/project_id", _CONTEXT) == "proj-1" + + def test_nested_path(self): + assert resolve_json_selector("$.event.data.issue.number", _CONTEXT) == 7 + + def test_plain_string_returned_literally(self): + assert resolve_json_selector("just a string", _CONTEXT) == "just a string" + + def test_non_string_returned_literally(self): + assert resolve_json_selector(42, _CONTEXT) == 42 + + def test_missing_path_returns_none(self): + assert resolve_json_selector("$.event.nope", _CONTEXT) is None + + def test_malformed_path_returns_none(self): + assert resolve_json_selector("$.bad[", _CONTEXT) is None + + +class TestResolveDotNotation: + def test_literal_key_with_dots(self): + assert resolve_dot_notation("a.b", {"a.b": "literal"}) == "literal" + + def test_nested_traversal(self): + assert resolve_dot_notation("a.b", {"a": {"b": "nested"}}) == "nested" + + def test_empty_expr_raises_keyerror(self): + try: + resolve_dot_notation("", {}) + assert False, "expected KeyError" + except KeyError: + pass + + def test_bracket_syntax_raises_valueerror(self): + try: + resolve_dot_notation("a[0]", {"a": [1]}) + assert False, "expected ValueError" + except ValueError: + pass + + +class TestResolveTargetFields: + def test_whole_context_passthrough(self): + assert resolve_target_fields("$", _CONTEXT) == _CONTEXT + + def test_dict_template_resolves_each_leaf(self): + template = {"number": "$.event.data.issue.number", "kind": "$.event.type"} + assert resolve_target_fields(template, _CONTEXT) == { + "number": 7, + "kind": "github.issue.opened", + } + + def test_list_template_resolves_each_item(self): + assert resolve_target_fields(["$.scope.project_id", "literal"], _CONTEXT) == [ + "proj-1", + "literal", + ] + + def test_nested_structure(self): + template = {"outer": {"inner": ["$.subscription.id"]}} + assert resolve_target_fields(template, _CONTEXT) == { + "outer": {"inner": ["sub-1"]} + } + + def test_missing_leaf_becomes_none_without_dropping_siblings(self): + template = {"ok": "$.event.type", "miss": "$.event.nope"} + assert resolve_target_fields(template, _CONTEXT) == { + "ok": "github.issue.opened", + "miss": None, + } + + def test_depth_over_limit_returns_none(self): + assert ( + resolve_target_fields( + "$.event.type", _CONTEXT, _depth=MAX_RESOLVE_DEPTH + 1 + ) + is None + ) + + def test_depth_at_limit_still_resolves(self): + assert ( + resolve_target_fields("$.event.type", _CONTEXT, _depth=MAX_RESOLVE_DEPTH) + == "github.issue.opened" + ) diff --git a/web/oss/src/components/Sidebar/SettingsSidebar.tsx b/web/oss/src/components/Sidebar/SettingsSidebar.tsx index bdb3fe25ec..93529955c6 100644 --- a/web/oss/src/components/Sidebar/SettingsSidebar.tsx +++ b/web/oss/src/components/Sidebar/SettingsSidebar.tsx @@ -5,6 +5,7 @@ import { Buildings, ClockCounterClockwise, Key, + Lightning, Link, Receipt, Sparkle, @@ -46,6 +47,7 @@ const SettingsSidebar: FC<SettingsSidebarProps> = ({lastPath}) => { const canShowUsageBilling = isEE() && isOwner const billingEnabled = isBillingEnabled() const canShowTools = isToolsEnabled() + const canShowTriggers = isToolsEnabled() // Audit Log is an EE feature. Within EE the tab is gated by `view_events`; // the page content is gated separately by the `Flag.AUDIT` entitlement. const canShowAuditLog = isEE() && canViewEvents @@ -57,6 +59,7 @@ const SettingsSidebar: FC<SettingsSidebarProps> = ({lastPath}) => { (requestedTab === "organization" && !canShowOrganization) || (requestedTab === "billing" && !canShowUsageBilling) || (requestedTab === "tools" && !canShowTools) || + (requestedTab === "triggers" && !canShowTriggers) || (requestedTab === "apiKeys" && !canViewApiKeys) || (requestedTab === "auditLog" && !canShowAuditLog) || (requestedTab === "account" && !canShowAccount) @@ -69,6 +72,7 @@ const SettingsSidebar: FC<SettingsSidebarProps> = ({lastPath}) => { canShowUsageBilling, canShowOrganization, canShowTools, + canShowTriggers, canViewApiKeys, canShowAuditLog, canShowAccount, @@ -107,6 +111,15 @@ const SettingsSidebar: FC<SettingsSidebarProps> = ({lastPath}) => { }, ] : []), + ...(canShowTriggers + ? [ + { + key: "triggers", + title: "Triggers", + icon: <Lightning size={16} className="mt-0.5" />, + }, + ] + : []), { key: "automations", title: "Automations", @@ -156,6 +169,7 @@ const SettingsSidebar: FC<SettingsSidebarProps> = ({lastPath}) => { billingEnabled, canShowOrganization, canShowTools, + canShowTriggers, canViewApiKeys, canShowAuditLog, canShowAccount, diff --git a/web/oss/src/components/pages/settings/Triggers/Triggers.tsx b/web/oss/src/components/pages/settings/Triggers/Triggers.tsx new file mode 100644 index 0000000000..1bb4ebdf6c --- /dev/null +++ b/web/oss/src/components/pages/settings/Triggers/Triggers.tsx @@ -0,0 +1,11 @@ +import GatewaySubscriptionsSection from "./components/GatewaySubscriptionsSection" +import GatewayTriggersSection from "./components/GatewayTriggersSection" + +export default function Triggers() { + return ( + <div className="flex flex-col gap-6"> + <GatewayTriggersSection /> + <GatewaySubscriptionsSection /> + </div> + ) +} diff --git a/web/oss/src/components/pages/settings/Triggers/components/GatewaySubscriptionsSection.tsx b/web/oss/src/components/pages/settings/Triggers/components/GatewaySubscriptionsSection.tsx new file mode 100644 index 0000000000..6e06bde2cc --- /dev/null +++ b/web/oss/src/components/pages/settings/Triggers/components/GatewaySubscriptionsSection.tsx @@ -0,0 +1,264 @@ +import {useCallback, useMemo} from "react" + +import { + deliveriesDrawerAtom, + subscriptionDrawerAtom, + useTriggerConnectionsQuery, + useTriggerSubscription, + useTriggerSubscriptions, + type TriggerSubscription, +} from "@agenta/entities/gatewayTrigger" +import {TriggerDeliveriesDrawer, TriggerSubscriptionDrawer} from "@agenta/entity-ui/gatewayTrigger" +import {MoreOutlined} from "@ant-design/icons" +import { + ArrowsClockwise, + GearSix, + ListChecks, + PencilSimpleLine, + Plus, + Trash, + XCircle, +} from "@phosphor-icons/react" +import {Button, Dropdown, Empty, Table, Tag, Typography, message} from "antd" +import type {ColumnsType} from "antd/es/table" +import {useSetAtom} from "jotai" + +import {formatDay} from "@/oss/lib/helpers/dateTimeHelper" + +export default function GatewaySubscriptionsSection() { + const {subscriptions, isLoading} = useTriggerSubscriptions() + const {connections} = useTriggerConnectionsQuery() + const {revoke, refresh, remove, isMutating} = useTriggerSubscription() + const openDrawer = useSetAtom(subscriptionDrawerAtom) + const openDeliveries = useSetAtom(deliveriesDrawerAtom) + + const connectionLabel = useCallback( + (connectionId?: string) => { + const c = connections.find((conn) => conn.id === connectionId) + return c ? c.name || c.slug || c.integration_key : (connectionId ?? "-") + }, + [connections], + ) + + const handleCreate = useCallback(() => openDrawer({}), [openDrawer]) + + const handleEdit = useCallback( + (record: TriggerSubscription) => openDrawer({subscriptionId: record.id ?? undefined}), + [openDrawer], + ) + + const handleRevoke = useCallback( + async (record: TriggerSubscription) => { + if (!record.id) return + try { + await revoke(record.id) + message.success("Subscription revoked") + } catch { + message.error("Failed to revoke subscription") + } + }, + [revoke], + ) + + const handleRefresh = useCallback( + async (record: TriggerSubscription) => { + if (!record.id) return + try { + await refresh(record.id) + message.success("Subscription refreshed") + } catch { + message.error("Failed to refresh subscription") + } + }, + [refresh], + ) + + const handleDelete = useCallback( + async (record: TriggerSubscription) => { + if (!record.id) return + try { + await remove(record.id) + message.success("Subscription deleted") + } catch { + message.error("Failed to delete subscription") + } + }, + [remove], + ) + + const columns: ColumnsType<TriggerSubscription> = useMemo( + () => [ + { + title: "Name", + key: "name", + onHeaderCell: () => ({style: {minWidth: 160}}), + render: (_, record) => ( + <Typography.Text>{record.name || record.id || "-"}</Typography.Text> + ), + }, + { + title: "Connection", + key: "connection", + onHeaderCell: () => ({style: {minWidth: 160}}), + render: (_, record) => ( + <Typography.Text>{connectionLabel(record.connection_id)}</Typography.Text> + ), + }, + { + title: "Event", + key: "event", + onHeaderCell: () => ({style: {minWidth: 160}}), + render: (_, record) => ( + <Tag + bordered={false} + color="default" + className="bg-[var(--ag-c-0517290F)] px-2 py-[1px]" + > + {record.data?.event_key ?? "-"} + </Tag> + ), + }, + { + title: "Status", + key: "status", + onHeaderCell: () => ({style: {minWidth: 120}}), + render: (_, record) => + !record.valid ? ( + <Tag color="red">Invalid</Tag> + ) : record.enabled ? ( + <Tag color="green">Enabled</Tag> + ) : ( + <Tag>Disabled</Tag> + ), + }, + { + title: "Created at", + dataIndex: "created_at", + key: "created_at", + onHeaderCell: () => ({style: {minWidth: 160}}), + render: (value: string) => + value ? formatDay({date: value, outputFormat: "YYYY-MM-DD HH:mm"}) : "-", + }, + { + title: <GearSix size={16} />, + key: "actions", + width: 61, + fixed: "right" as const, + align: "center" as const, + render: (_, record) => ( + <Dropdown + trigger={["click"]} + styles={{root: {width: 180}}} + menu={{ + items: [ + { + key: "deliveries", + label: "View deliveries", + icon: <ListChecks size={16} />, + onClick: (e) => { + e.domEvent.stopPropagation() + if (record.id) + openDeliveries({ + subscriptionId: record.id, + subscriptionName: record.name ?? undefined, + }) + }, + }, + { + key: "edit", + label: "Edit", + icon: <PencilSimpleLine size={16} />, + onClick: (e) => { + e.domEvent.stopPropagation() + handleEdit(record) + }, + }, + { + key: "refresh", + label: "Refresh", + icon: <ArrowsClockwise size={16} />, + onClick: (e) => { + e.domEvent.stopPropagation() + handleRefresh(record) + }, + }, + {type: "divider" as const}, + { + key: "revoke", + label: "Revoke", + icon: <XCircle size={16} />, + onClick: (e) => { + e.domEvent.stopPropagation() + handleRevoke(record) + }, + }, + { + key: "delete", + label: "Delete", + icon: <Trash size={16} />, + danger: true, + onClick: (e) => { + e.domEvent.stopPropagation() + handleDelete(record) + }, + }, + ], + }} + > + <Button + type="text" + icon={<MoreOutlined />} + aria-label="Open subscription actions" + onClick={(e) => e.stopPropagation()} + /> + </Dropdown> + ), + }, + ], + [connectionLabel, handleDelete, handleEdit, handleRefresh, handleRevoke, openDeliveries], + ) + + return ( + <> + <section className="flex flex-col gap-2"> + <div className="flex items-center justify-between"> + <Typography.Text className="text-sm font-medium"> + Trigger subscriptions + </Typography.Text> + <Button + type="primary" + size="small" + icon={<Plus size={14} />} + onClick={handleCreate} + disabled={connections.length === 0} + > + New subscription + </Button> + </div> + + <Typography.Text type="secondary" className="text-xs"> + Bind a provider event to a workflow. Each subscription dispatches matching + events to its bound workflow. + </Typography.Text> + + <Table<TriggerSubscription> + className="ph-no-capture" + columns={columns} + dataSource={subscriptions} + rowKey={(record) => record.id ?? record.slug ?? record.data?.event_key ?? ""} + bordered + pagination={false} + loading={isLoading || isMutating} + locale={{emptyText: <Empty description="No subscriptions yet" />}} + onRow={(record) => ({ + onClick: () => handleEdit(record), + className: "cursor-pointer", + })} + /> + </section> + + <TriggerSubscriptionDrawer /> + <TriggerDeliveriesDrawer /> + </> + ) +} diff --git a/web/oss/src/components/pages/settings/Triggers/components/GatewayTriggersSection.tsx b/web/oss/src/components/pages/settings/Triggers/components/GatewayTriggersSection.tsx new file mode 100644 index 0000000000..f853ef2eb8 --- /dev/null +++ b/web/oss/src/components/pages/settings/Triggers/components/GatewayTriggersSection.tsx @@ -0,0 +1,134 @@ +import {useCallback, useMemo} from "react" + +import { + eventsDrawerAtom, + useTriggerConnectionsQuery, + type TriggerConnection, +} from "@agenta/entities/gatewayTrigger" +import {ConnectionStatusBadge} from "@agenta/entity-ui/gatewayTool" +import {TriggerEventsDrawer} from "@agenta/entity-ui/gatewayTrigger" +import {Lightning} from "@phosphor-icons/react" +import {Button, Empty, Table, Tag, Tooltip, Typography} from "antd" +import type {ColumnsType} from "antd/es/table" +import {useSetAtom} from "jotai" + +import {formatDay} from "@/oss/lib/helpers/dateTimeHelper" + +const DEFAULT_PROVIDER = "composio" + +export default function GatewayTriggersSection() { + const {connections, isLoading} = useTriggerConnectionsQuery() + const setEventsDrawer = useSetAtom(eventsDrawerAtom) + + const openEvents = useCallback( + (record: TriggerConnection) => { + setEventsDrawer({ + providerKey: record.provider_key ?? DEFAULT_PROVIDER, + integrationKey: record.integration_key, + integrationName: record.name ?? record.slug ?? record.integration_key, + connectionId: record.id ?? undefined, + }) + }, + [setEventsDrawer], + ) + + const columns: ColumnsType<TriggerConnection> = useMemo( + () => [ + { + title: "Integration", + key: "integration", + onHeaderCell: () => ({style: {minWidth: 160}}), + render: (_, record) => ( + <Tag + bordered={false} + color="default" + className="bg-[var(--ag-c-0517290F)] px-2 py-[1px]" + > + {record.integration_key} + </Tag> + ), + }, + { + title: "Name", + key: "name", + onHeaderCell: () => ({style: {minWidth: 160}}), + render: (_, record) => ( + <Typography.Text>{record.name || record.slug}</Typography.Text> + ), + }, + { + title: "Status", + key: "status", + onHeaderCell: () => ({style: {minWidth: 120}}), + render: (_, record) => <ConnectionStatusBadge connection={record} />, + }, + { + title: "Created at", + dataIndex: "created_at", + key: "created_at", + onHeaderCell: () => ({style: {minWidth: 160}}), + render: (value: string) => + value ? formatDay({date: value, outputFormat: "YYYY-MM-DD HH:mm"}) : "-", + }, + { + title: "", + key: "actions", + width: 120, + fixed: "right", + align: "right", + render: (_, record) => ( + <Button + size="small" + icon={<Lightning size={14} />} + onClick={(e) => { + e.stopPropagation() + openEvents(record) + }} + > + Events + </Button> + ), + }, + ], + [openEvents], + ) + + return ( + <> + <section className="flex flex-col gap-2"> + <div className="flex items-center gap-2"> + <Typography.Text className="text-sm font-medium"> + Trigger integrations + </Typography.Text> + <Tooltip title="Browse the events of a connected integration"> + <Lightning size={14} /> + </Tooltip> + </div> + + <Typography.Text type="secondary" className="text-xs"> + Triggers reuse the same connections as tools. Connect an integration under + Tools, then browse its events here. + </Typography.Text> + + <Table<TriggerConnection> + className="ph-no-capture" + columns={columns} + dataSource={connections} + rowKey={(record) => record.id ?? record.slug ?? record.integration_key} + bordered + pagination={false} + loading={isLoading} + locale={{ + emptyText: <Empty description="No connected integrations yet" />, + }} + onRow={(record) => ({ + onClick: () => openEvents(record), + className: "cursor-pointer", + })} + /> + </section> + + <TriggerEventsDrawer /> + </> + ) +} diff --git a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/settings/index.tsx b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/settings/index.tsx index 3ab8b8929a..4d758005d9 100644 --- a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/settings/index.tsx +++ b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/settings/index.tsx @@ -39,6 +39,10 @@ const Tools = dynamic(() => import("@/oss/components/pages/settings/Tools/Tools" ssr: false, }) +const Triggers = dynamic(() => import("@/oss/components/pages/settings/Triggers/Triggers"), { + ssr: false, +}) + const Organization = dynamic(() => import("@/oss/components/pages/settings/Organization"), { ssr: false, }) @@ -71,12 +75,14 @@ export const Settings: React.FC<SettingsProps> = ({AuditLogComponent}) => { const canShowBilling = isEE() && isOwner const billingEnabled = isBillingEnabled() const canShowTools = isToolsEnabled() + const canShowTriggers = isToolsEnabled() const canShowAuditLog = isEE() && canViewEvents const canShowAccount = isEE() const resolvedTab = (tab === "organization" && !canShowOrganization) || (tab === "billing" && !canShowBilling) || (tab === "tools" && !canShowTools) || + (tab === "triggers" && !canShowTriggers) || (tab === "apiKeys" && !canViewApiKeys) || (tab === "auditLog" && !canShowAuditLog) || (tab === "account" && !canShowAccount) @@ -124,6 +130,8 @@ export const Settings: React.FC<SettingsProps> = ({AuditLogComponent}) => { return "Providers & Models" case "tools": return "Tools" + case "triggers": + return "Triggers" case "apiKeys": return "API Keys" case "automations": @@ -177,6 +185,8 @@ export const Settings: React.FC<SettingsProps> = ({AuditLogComponent}) => { return {content: <Secrets />, title: "Providers & Models"} case "tools": return {content: <Tools />, title: "Tools"} + case "triggers": + return {content: <Triggers />, title: "Triggers"} case "apiKeys": return {content: <APIKeys />, title: "API Keys"} case "billing": diff --git a/web/packages/agenta-entities/package.json b/web/packages/agenta-entities/package.json index 2d9b25ba74..5ace2985e9 100644 --- a/web/packages/agenta-entities/package.json +++ b/web/packages/agenta-entities/package.json @@ -50,6 +50,7 @@ "./event/state": "./src/event/state/index.ts", "./secret": "./src/secret/index.ts", "./gatewayTool": "./src/gatewayTool/index.ts", + "./gatewayTrigger": "./src/gatewayTrigger/index.ts", "./environment": "./src/environment/index.ts", "./simpleQueue": "./src/simpleQueue/index.ts", "./simpleQueue/etl": "./src/simpleQueue/etl/index.ts", diff --git a/web/packages/agenta-entities/src/gatewayTrigger/api/api.ts b/web/packages/agenta-entities/src/gatewayTrigger/api/api.ts new file mode 100644 index 0000000000..08faeca609 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/api/api.ts @@ -0,0 +1,274 @@ +/** + * Gateway-trigger API functions. + * + * Catalog browse + connection list over the `/triggers/*` endpoints. Each + * response is validated against the frozen zod schema at the boundary + * (`safeParseWithLogging`), so a backend drift surfaces as a logged parse + * failure rather than a downstream crash. + * + * `/triggers/connections/query` reads the same shared `gateway_connections` + * rows as `/tools/connections/query` (WP0); the connection shape is reused + * from gatewayTool so the two lists stay byte-compatible (F2). + */ + +import {safeParseWithLogging} from "../../shared" +import { + triggerCatalogEventResponseSchema, + triggerCatalogEventsResponseSchema, + triggerCatalogProviderResponseSchema, + triggerCatalogProvidersResponseSchema, + triggerConnectionsResponseSchema, + triggerDeliveriesResponseSchema, + triggerDeliveryResponseSchema, + triggerSubscriptionResponseSchema, + triggerSubscriptionsResponseSchema, + type TriggerCatalogEventResponse, + type TriggerCatalogEventsResponse, + type TriggerCatalogProviderResponse, + type TriggerCatalogProvidersResponse, + type TriggerConnectionsResponse, + type TriggerDeliveriesResponse, + type TriggerDeliveryQuery, + type TriggerDeliveryResponse, + type TriggerSubscriptionCreate, + type TriggerSubscriptionEdit, + type TriggerSubscriptionQuery, + type TriggerSubscriptionResponse, + type TriggerSubscriptionsResponse, +} from "../core/types" + +import {axios, projectScopedParams, triggersBaseUrl} from "./client" + +// --- Catalog browse --- + +export const fetchTriggerProviders = async (): Promise<TriggerCatalogProvidersResponse> => { + const {data} = await axios.get(`${triggersBaseUrl()}/catalog/providers/`, projectScopedParams()) + return ( + safeParseWithLogging( + triggerCatalogProvidersResponseSchema, + data, + "[fetchTriggerProviders]", + ) ?? {count: 0, providers: []} + ) +} + +export const fetchTriggerProvider = async ( + providerKey: string, +): Promise<TriggerCatalogProviderResponse> => { + const {data} = await axios.get( + `${triggersBaseUrl()}/catalog/providers/${providerKey}`, + projectScopedParams(), + ) + return ( + safeParseWithLogging( + triggerCatalogProviderResponseSchema, + data, + "[fetchTriggerProvider]", + ) ?? {count: 0, provider: null} + ) +} + +export const fetchTriggerEvents = async ( + providerKey: string, + integrationKey: string, + params?: {query?: string; limit?: number; cursor?: string}, +): Promise<TriggerCatalogEventsResponse> => { + const {data} = await axios.get( + `${triggersBaseUrl()}/catalog/providers/${providerKey}/integrations/${integrationKey}/events/`, + projectScopedParams({ + query: params?.query, + limit: params?.limit, + cursor: params?.cursor, + }), + ) + return ( + safeParseWithLogging(triggerCatalogEventsResponseSchema, data, "[fetchTriggerEvents]") ?? { + count: 0, + total: 0, + cursor: null, + events: [], + } + ) +} + +export const fetchTriggerEvent = async ( + providerKey: string, + integrationKey: string, + eventKey: string, +): Promise<TriggerCatalogEventResponse> => { + const {data} = await axios.get( + `${triggersBaseUrl()}/catalog/providers/${providerKey}/integrations/${integrationKey}/events/${eventKey}`, + projectScopedParams(), + ) + return ( + safeParseWithLogging(triggerCatalogEventResponseSchema, data, "[fetchTriggerEvent]") ?? { + count: 0, + event: null, + } + ) +} + +// --- Connections (shared rows, WP0 view; F2) --- + +export const queryTriggerConnections = async (params?: { + provider_key?: string + integration_key?: string +}): Promise<TriggerConnectionsResponse> => { + const {data} = await axios.post( + `${triggersBaseUrl()}/connections/query`, + { + provider_key: params?.provider_key, + integration_key: params?.integration_key, + }, + projectScopedParams(), + ) + const validated = safeParseWithLogging( + triggerConnectionsResponseSchema, + data, + "[queryTriggerConnections]", + ) + return (validated as TriggerConnectionsResponse | null) ?? {count: 0, connections: []} +} + +// --- Subscriptions --- + +export const queryTriggerSubscriptions = async ( + subscription?: TriggerSubscriptionQuery, +): Promise<TriggerSubscriptionsResponse> => { + const {data} = await axios.post( + `${triggersBaseUrl()}/subscriptions/query`, + {subscription: subscription ?? null}, + projectScopedParams(), + ) + return ( + safeParseWithLogging( + triggerSubscriptionsResponseSchema, + data, + "[queryTriggerSubscriptions]", + ) ?? {count: 0, subscriptions: []} + ) +} + +export const fetchTriggerSubscription = async ( + subscriptionId: string, +): Promise<TriggerSubscriptionResponse> => { + const {data} = await axios.get( + `${triggersBaseUrl()}/subscriptions/${subscriptionId}`, + projectScopedParams(), + ) + return ( + safeParseWithLogging( + triggerSubscriptionResponseSchema, + data, + "[fetchTriggerSubscription]", + ) ?? {count: 0, subscription: null} + ) +} + +export const createTriggerSubscription = async ( + subscription: TriggerSubscriptionCreate, +): Promise<TriggerSubscriptionResponse> => { + const {data} = await axios.post( + `${triggersBaseUrl()}/subscriptions/`, + {subscription}, + projectScopedParams(), + ) + return ( + safeParseWithLogging( + triggerSubscriptionResponseSchema, + data, + "[createTriggerSubscription]", + ) ?? {count: 0, subscription: null} + ) +} + +export const editTriggerSubscription = async ( + subscription: TriggerSubscriptionEdit, +): Promise<TriggerSubscriptionResponse> => { + const {data} = await axios.put( + `${triggersBaseUrl()}/subscriptions/${subscription.id}`, + {subscription}, + projectScopedParams(), + ) + return ( + safeParseWithLogging( + triggerSubscriptionResponseSchema, + data, + "[editTriggerSubscription]", + ) ?? {count: 0, subscription: null} + ) +} + +export const refreshTriggerSubscription = async ( + subscriptionId: string, +): Promise<TriggerSubscriptionResponse> => { + const {data} = await axios.post( + `${triggersBaseUrl()}/subscriptions/${subscriptionId}/refresh`, + {}, + projectScopedParams(), + ) + return ( + safeParseWithLogging( + triggerSubscriptionResponseSchema, + data, + "[refreshTriggerSubscription]", + ) ?? {count: 0, subscription: null} + ) +} + +export const revokeTriggerSubscription = async ( + subscriptionId: string, +): Promise<TriggerSubscriptionResponse> => { + const {data} = await axios.post( + `${triggersBaseUrl()}/subscriptions/${subscriptionId}/revoke`, + {}, + projectScopedParams(), + ) + return ( + safeParseWithLogging( + triggerSubscriptionResponseSchema, + data, + "[revokeTriggerSubscription]", + ) ?? {count: 0, subscription: null} + ) +} + +export const deleteTriggerSubscription = async (subscriptionId: string): Promise<void> => { + await axios.delete( + `${triggersBaseUrl()}/subscriptions/${subscriptionId}`, + projectScopedParams(), + ) +} + +// --- Deliveries (read-only) --- + +export const queryTriggerDeliveries = async ( + delivery?: TriggerDeliveryQuery, +): Promise<TriggerDeliveriesResponse> => { + const {data} = await axios.post( + `${triggersBaseUrl()}/deliveries/query`, + {delivery: delivery ?? null}, + projectScopedParams(), + ) + return ( + safeParseWithLogging(triggerDeliveriesResponseSchema, data, "[queryTriggerDeliveries]") ?? { + count: 0, + deliveries: [], + } + ) +} + +export const fetchTriggerDelivery = async ( + deliveryId: string, +): Promise<TriggerDeliveryResponse> => { + const {data} = await axios.get( + `${triggersBaseUrl()}/deliveries/${deliveryId}`, + projectScopedParams(), + ) + return ( + safeParseWithLogging(triggerDeliveryResponseSchema, data, "[fetchTriggerDelivery]") ?? { + count: 0, + delivery: null, + } + ) +} diff --git a/web/packages/agenta-entities/src/gatewayTrigger/api/client.ts b/web/packages/agenta-entities/src/gatewayTrigger/api/client.ts new file mode 100644 index 0000000000..ef1785b52b --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/api/client.ts @@ -0,0 +1,30 @@ +import {axios, getAgentaApiUrl} from "@agenta/shared/api" +import {projectIdAtom} from "@agenta/shared/state" +import {getDefaultStore} from "jotai" + +/** + * HTTP client for the `/triggers/*` API. + * + * The triggers catalog isn't in the Fern client yet (WP1 hasn't been + * regenerated into `@agentaai/api-client`), so we use the shared axios + * instance. Once the client gains a `triggers` resource this module collapses + * onto `getAgentaSdkClient().triggers` like `gatewayTool/api/client.ts`. + */ +export const triggersBaseUrl = () => `${getAgentaApiUrl()}/triggers` + +/** + * Scope a request to the current project. The shared axios interceptor does + * not inject `project_id`, so we mirror `gatewayTool`'s `projectScopedRequest` + * and read it from the shared atom. + */ +export function projectScopedParams(extra?: Record<string, unknown>) { + const projectId = getDefaultStore().get(projectIdAtom) + return { + params: { + ...(projectId ? {project_id: projectId} : {}), + ...(extra ?? {}), + }, + } +} + +export {axios} diff --git a/web/packages/agenta-entities/src/gatewayTrigger/api/index.ts b/web/packages/agenta-entities/src/gatewayTrigger/api/index.ts new file mode 100644 index 0000000000..f99959cd17 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/api/index.ts @@ -0,0 +1,17 @@ +export { + createTriggerSubscription, + deleteTriggerSubscription, + editTriggerSubscription, + fetchTriggerDelivery, + fetchTriggerEvent, + fetchTriggerEvents, + fetchTriggerProvider, + fetchTriggerProviders, + fetchTriggerSubscription, + queryTriggerConnections, + queryTriggerDeliveries, + queryTriggerSubscriptions, + refreshTriggerSubscription, + revokeTriggerSubscription, +} from "./api" +export {triggersBaseUrl, projectScopedParams} from "./client" diff --git a/web/packages/agenta-entities/src/gatewayTrigger/core/index.ts b/web/packages/agenta-entities/src/gatewayTrigger/core/index.ts new file mode 100644 index 0000000000..51f739d012 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/core/index.ts @@ -0,0 +1 @@ +export * from "./types" diff --git a/web/packages/agenta-entities/src/gatewayTrigger/core/types.ts b/web/packages/agenta-entities/src/gatewayTrigger/core/types.ts new file mode 100644 index 0000000000..1ba1a27bb4 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/core/types.ts @@ -0,0 +1,301 @@ +/** + * Gateway-trigger domain types. + * + * The triggers catalog API (WP1) is not yet in the Fern-generated client, so + * the wire shapes are declared here as zod schemas mirroring the frozen + * backend DTOs (`api/oss/src/core/triggers/dtos.py`, + * `api/oss/src/apis/fastapi/triggers/models.py`). Validation runs at the API + * boundary, exactly as `web/AGENTS.md` prescribes for the Fern path. When the + * client is regenerated with a `triggers` resource these aliases swap to + * `AgentaApi.*` mechanically. + * + * Connections are shared rows (WP0): the same `gateway_connections` surface + * both `/tools/connections` and `/triggers/connections`. We reuse the + * gatewayTool connection type so the two lists are byte-compatible (F2). + */ + +import {z} from "zod" + +import type {ToolConnection, ToolConnectionsResponse} from "../../gatewayTool/core/types" + +// --------------------------------------------------------------------------- +// Catalog +// --------------------------------------------------------------------------- + +export const triggerProviderKindSchema = z.enum(["composio"]) +export type TriggerProviderKind = z.infer<typeof triggerProviderKindSchema> + +export const triggerCatalogProviderSchema = z + .object({ + key: triggerProviderKindSchema, + name: z.string(), + description: z.string().nullish(), + }) + .passthrough() +export type TriggerCatalogProvider = z.infer<typeof triggerCatalogProviderSchema> + +export const triggerCatalogEventSchema = z + .object({ + key: z.string(), + name: z.string(), + description: z.string().nullish(), + provider: z.string().nullish(), + integration: z.string().nullish(), + categories: z.array(z.string()).default([]), + logo: z.string().nullish(), + }) + .passthrough() +export type TriggerCatalogEvent = z.infer<typeof triggerCatalogEventSchema> + +export const triggerCatalogEventDetailsSchema = triggerCatalogEventSchema.extend({ + trigger_config: z.record(z.string(), z.unknown()).nullish(), + payload: z.record(z.string(), z.unknown()).nullish(), +}) +export type TriggerCatalogEventDetails = z.infer<typeof triggerCatalogEventDetailsSchema> + +export const triggerCatalogProvidersResponseSchema = z + .object({ + count: z.number().default(0), + providers: z.array(triggerCatalogProviderSchema).default([]), + }) + .passthrough() +export type TriggerCatalogProvidersResponse = z.infer<typeof triggerCatalogProvidersResponseSchema> + +export const triggerCatalogProviderResponseSchema = z + .object({ + count: z.number().default(0), + provider: triggerCatalogProviderSchema.nullish(), + }) + .passthrough() +export type TriggerCatalogProviderResponse = z.infer<typeof triggerCatalogProviderResponseSchema> + +export const triggerCatalogEventsResponseSchema = z + .object({ + count: z.number().default(0), + total: z.number().default(0), + cursor: z.string().nullish(), + events: z.array(triggerCatalogEventSchema).default([]), + }) + .passthrough() +export type TriggerCatalogEventsResponse = z.infer<typeof triggerCatalogEventsResponseSchema> + +export const triggerCatalogEventResponseSchema = z + .object({ + count: z.number().default(0), + event: triggerCatalogEventDetailsSchema.nullish(), + }) + .passthrough() +export type TriggerCatalogEventResponse = z.infer<typeof triggerCatalogEventResponseSchema> + +// --------------------------------------------------------------------------- +// Connections — shared `gateway_connections` rows (WP0). Same shape as +// `/tools/connections`; the FE treats both lists as the same rows (F2). The TS +// type aliases the gatewayTool Fern type so the two lists are byte-compatible; +// the schema validates the axios boundary (the triggers client isn't Fern yet). +// --------------------------------------------------------------------------- + +const jsonRecordSchema = z.record(z.string(), z.unknown()).nullish() + +export const triggerConnectionSchema = z + .object({ + flags: jsonRecordSchema, + tags: jsonRecordSchema, + meta: jsonRecordSchema, + created_at: z.string().nullish(), + updated_at: z.string().nullish(), + deleted_at: z.string().nullish(), + created_by_id: z.string().nullish(), + updated_by_id: z.string().nullish(), + deleted_by_id: z.string().nullish(), + name: z.string().nullish(), + description: z.string().nullish(), + slug: z.string().nullish(), + id: z.string().nullish(), + provider_key: z.string(), + integration_key: z.string(), + data: jsonRecordSchema, + status: z.unknown().nullish(), + }) + .passthrough() + +export const triggerConnectionsResponseSchema = z + .object({ + count: z.number().default(0), + connections: z.array(triggerConnectionSchema).default([]), + }) + .passthrough() + +export type TriggerConnection = ToolConnection +export type TriggerConnectionsResponse = ToolConnectionsResponse + +export {isConnectionActive, isConnectionValid} from "../../gatewayTool/core/types" + +// --------------------------------------------------------------------------- +// Subscriptions — a standing watch binding a provider event to a workflow. +// +// Mirrors the frozen backend DTOs (`api/oss/src/core/triggers/dtos.py`: +// TriggerSubscription / *Create / *Edit / *Query). Validated at the axios +// boundary; the aliases swap to `AgentaApi.*` once the triggers resource lands +// in the Fern client. +// --------------------------------------------------------------------------- + +// A workflow reference (the /retrieve shape): {id, slug?, version?}. +export const triggerReferenceSchema = z + .object({ + id: z.string().nullish(), + slug: z.string().nullish(), + version: z.string().nullish(), + }) + .passthrough() +export type TriggerReference = z.infer<typeof triggerReferenceSchema> + +export const triggerSelectorSchema = z + .object({ + key: z.string().nullish(), + path: z.string().nullish(), + }) + .passthrough() +export type TriggerSelector = z.infer<typeof triggerSelectorSchema> + +export const triggerSubscriptionDataSchema = z + .object({ + event_key: z.string(), + ti_id: z.string().nullish(), + trigger_config: z.record(z.string(), z.unknown()).nullish(), + inputs_fields: z.record(z.string(), z.unknown()).nullish(), + references: z.record(z.string(), triggerReferenceSchema).nullish(), + selector: triggerSelectorSchema.nullish(), + }) + .passthrough() +export type TriggerSubscriptionData = z.infer<typeof triggerSubscriptionDataSchema> + +export const triggerSubscriptionSchema = z + .object({ + id: z.string().nullish(), + slug: z.string().nullish(), + name: z.string().nullish(), + description: z.string().nullish(), + flags: jsonRecordSchema, + tags: jsonRecordSchema, + meta: jsonRecordSchema, + created_at: z.string().nullish(), + updated_at: z.string().nullish(), + deleted_at: z.string().nullish(), + created_by_id: z.string().nullish(), + updated_by_id: z.string().nullish(), + deleted_by_id: z.string().nullish(), + connection_id: z.string(), + data: triggerSubscriptionDataSchema, + enabled: z.boolean().default(true), + valid: z.boolean().default(true), + }) + .passthrough() +export type TriggerSubscription = z.infer<typeof triggerSubscriptionSchema> + +export const triggerSubscriptionResponseSchema = z + .object({ + count: z.number().default(0), + subscription: triggerSubscriptionSchema.nullish(), + }) + .passthrough() +export type TriggerSubscriptionResponse = z.infer<typeof triggerSubscriptionResponseSchema> + +export const triggerSubscriptionsResponseSchema = z + .object({ + count: z.number().default(0), + subscriptions: z.array(triggerSubscriptionSchema).default([]), + }) + .passthrough() +export type TriggerSubscriptionsResponse = z.infer<typeof triggerSubscriptionsResponseSchema> + +// Create body (Header + Metadata + connection_id + data); no id. +export interface TriggerSubscriptionCreate { + name?: string | null + description?: string | null + flags?: Record<string, unknown> | null + tags?: Record<string, unknown> | null + meta?: Record<string, unknown> | null + connection_id: string + data: TriggerSubscriptionData +} + +// Edit body — full PUT: Identifier + Header + Metadata + connection_id + data + flags. +export interface TriggerSubscriptionEdit extends TriggerSubscriptionCreate { + id: string + enabled: boolean + valid: boolean +} + +export interface TriggerSubscriptionQuery { + name?: string + connection_id?: string + event_key?: string +} + +// --------------------------------------------------------------------------- +// Deliveries — read-only audit rows, one per inbound event dispatched. +// Mirrors `TriggerDelivery` / `TriggerDeliveryQuery`. `status` is the shared +// `core.shared.dtos.Status` (timestamp/type/code/message/stacktrace). +// --------------------------------------------------------------------------- + +export const triggerStatusSchema = z + .object({ + timestamp: z.string().nullish(), + type: z.string().nullish(), + code: z.string().nullish(), + message: z.string().nullish(), + stacktrace: z.string().nullish(), + }) + .passthrough() +export type TriggerStatus = z.infer<typeof triggerStatusSchema> + +export const triggerDeliveryDataSchema = z + .object({ + event_key: z.string().nullish(), + references: z.record(z.string(), triggerReferenceSchema).nullish(), + inputs: z.record(z.string(), z.unknown()).nullish(), + result: z.record(z.string(), z.unknown()).nullish(), + error: z.string().nullish(), + }) + .passthrough() +export type TriggerDeliveryData = z.infer<typeof triggerDeliveryDataSchema> + +export const triggerDeliverySchema = z + .object({ + id: z.string().nullish(), + slug: z.string().nullish(), + created_at: z.string().nullish(), + updated_at: z.string().nullish(), + deleted_at: z.string().nullish(), + created_by_id: z.string().nullish(), + updated_by_id: z.string().nullish(), + deleted_by_id: z.string().nullish(), + status: triggerStatusSchema, + data: triggerDeliveryDataSchema.nullish(), + subscription_id: z.string(), + event_id: z.string(), + }) + .passthrough() +export type TriggerDelivery = z.infer<typeof triggerDeliverySchema> + +export const triggerDeliveryResponseSchema = z + .object({ + count: z.number().default(0), + delivery: triggerDeliverySchema.nullish(), + }) + .passthrough() +export type TriggerDeliveryResponse = z.infer<typeof triggerDeliveryResponseSchema> + +export const triggerDeliveriesResponseSchema = z + .object({ + count: z.number().default(0), + deliveries: z.array(triggerDeliverySchema).default([]), + }) + .passthrough() +export type TriggerDeliveriesResponse = z.infer<typeof triggerDeliveriesResponseSchema> + +export interface TriggerDeliveryQuery { + status?: TriggerStatus + subscription_id?: string + event_id?: string +} diff --git a/web/packages/agenta-entities/src/gatewayTrigger/hooks/index.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/index.ts new file mode 100644 index 0000000000..31afdb9936 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/index.ts @@ -0,0 +1,16 @@ +export {catalogEventsInfiniteFamily, eventsSearchAtom, useCatalogEvents} from "./useCatalogEvents" +export {triggerEventDetailQueryFamily, useTriggerEvent} from "./useTriggerEvent" +export { + triggerConnectionsQueryAtom, + triggerIntegrationConnectionsAtomFamily, + useTriggerConnectionsQuery, + useTriggerIntegrationConnections, +} from "./useTriggerConnections" +export { + triggerConnectionSubscriptionsAtomFamily, + triggerSubscriptionsQueryAtom, + useTriggerConnectionSubscriptions, + useTriggerSubscriptions, +} from "./useTriggerSubscriptions" +export {triggerSubscriptionQueryAtomFamily, useTriggerSubscription} from "./useTriggerSubscription" +export {triggerDeliveriesAtomFamily, useTriggerDeliveries} from "./useTriggerDeliveries" diff --git a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useCatalogEvents.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useCatalogEvents.ts new file mode 100644 index 0000000000..b5cc548b58 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useCatalogEvents.ts @@ -0,0 +1,84 @@ +import {useCallback, useEffect, useMemo, useRef, useState} from "react" + +import {atom, useAtomValue, useSetAtom} from "jotai" +import {atomFamily} from "jotai/utils" +import {atomWithInfiniteQuery} from "jotai-tanstack-query" + +import {fetchTriggerEvents} from "../api" +import type {TriggerCatalogEvent, TriggerCatalogEventsResponse} from "../core/types" + +const DEFAULT_PROVIDER = "composio" +const CHUNK_SIZE = 10 +const PREFETCH = 2 + +// Server-side search atom — set by the drawer, drives the query +export const eventsSearchAtom = atom("") + +export const catalogEventsInfiniteFamily = atomFamily((integrationKey: string) => + atomWithInfiniteQuery<TriggerCatalogEventsResponse>((get) => { + const search = get(eventsSearchAtom) + + return { + queryKey: ["triggers", "catalog", "events", DEFAULT_PROVIDER, integrationKey, search], + queryFn: async ({pageParam}) => + fetchTriggerEvents(DEFAULT_PROVIDER, integrationKey, { + query: search || undefined, + limit: CHUNK_SIZE, + cursor: (pageParam as string) || undefined, + }), + initialPageParam: "", + getNextPageParam: (lastPage) => lastPage.cursor ?? undefined, + staleTime: 5 * 60_000, + refetchOnWindowFocus: false, + enabled: !!integrationKey, + } + }), +) + +export const useCatalogEvents = (integrationKey: string) => { + const query = useAtomValue(catalogEventsInfiniteFamily(integrationKey)) + const setSearch = useSetAtom(eventsSearchAtom) + + const events = useMemo<TriggerCatalogEvent[]>(() => { + const pages = query.data?.pages ?? [] + return pages.flatMap((p) => p.events ?? []) + }, [query.data?.pages]) + + const total = useMemo(() => { + const pages = query.data?.pages ?? [] + return pages.length > 0 ? (pages[0].total ?? 0) : 0 + }, [query.data?.pages]) + + const [targetPages, setTargetPages] = useState(1 + PREFETCH) + const loadedPages = query.data?.pages?.length ?? 0 + + const prevLoadedRef = useRef(loadedPages) + useEffect(() => { + if (loadedPages === 0 && prevLoadedRef.current > 0) { + setTargetPages(1 + PREFETCH) + } + prevLoadedRef.current = loadedPages + }, [loadedPages]) + + const requestMore = useCallback(() => { + setTargetPages((t) => t + PREFETCH) + }, []) + + useEffect(() => { + if (loadedPages < targetPages && query.hasNextPage && !query.isFetchingNextPage) { + query.fetchNextPage() + } + }, [loadedPages, targetPages, query.hasNextPage, query.isFetchingNextPage, query.fetchNextPage]) + + return { + events, + total, + prefetchThreshold: PREFETCH * CHUNK_SIZE, + isLoading: query.isPending, + isFetchingNextPage: query.isFetchingNextPage, + hasNextPage: query.hasNextPage ?? false, + error: query.error, + requestMore, + setSearch, + } +} diff --git a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerConnections.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerConnections.ts new file mode 100644 index 0000000000..ed5c3aff98 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerConnections.ts @@ -0,0 +1,66 @@ +import {useMemo} from "react" + +import {useAtomValue} from "jotai" +import {atomFamily} from "jotai/utils" +import {atomWithQuery} from "jotai-tanstack-query" + +import {queryTriggerConnections} from "../api" +import type {TriggerConnection, TriggerConnectionsResponse} from "../core/types" + +const DEFAULT_PROVIDER = "composio" + +// Full list of trigger connections (shared `gateway_connections` rows, F2). +export const triggerConnectionsQueryAtom = atomWithQuery<TriggerConnectionsResponse>(() => ({ + queryKey: ["triggers", "connections"], + queryFn: () => queryTriggerConnections(), + staleTime: 30_000, + refetchOnWindowFocus: false, +})) + +export const useTriggerConnectionsQuery = () => { + const query = useAtomValue(triggerConnectionsQueryAtom) + + const connections = useMemo<TriggerConnection[]>( + () => query.data?.connections ?? [], + [query.data?.connections], + ) + + return { + connections, + count: query.data?.count ?? 0, + isLoading: query.isPending, + error: query.error, + refetch: query.refetch, + } +} + +// Connections scoped to a single integration. +export const triggerIntegrationConnectionsAtomFamily = atomFamily((integrationKey: string) => + atomWithQuery<TriggerConnectionsResponse>(() => ({ + queryKey: ["triggers", "connections", DEFAULT_PROVIDER, integrationKey], + queryFn: () => + queryTriggerConnections({ + provider_key: DEFAULT_PROVIDER, + integration_key: integrationKey, + }), + staleTime: 30_000, + refetchOnWindowFocus: false, + enabled: !!integrationKey, + })), +) + +export const useTriggerIntegrationConnections = (integrationKey: string) => { + const query = useAtomValue(triggerIntegrationConnectionsAtomFamily(integrationKey)) + + const connections = useMemo<TriggerConnection[]>( + () => query.data?.connections ?? [], + [query.data?.connections], + ) + + return { + connections, + count: query.data?.count ?? 0, + isLoading: query.isPending, + error: query.error, + } +} diff --git a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerDeliveries.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerDeliveries.ts new file mode 100644 index 0000000000..c35f7156ae --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerDeliveries.ts @@ -0,0 +1,36 @@ +import {useMemo} from "react" + +import {useAtomValue} from "jotai" +import {atomFamily} from "jotai/utils" +import {atomWithQuery} from "jotai-tanstack-query" + +import {queryTriggerDeliveries} from "../api" +import type {TriggerDelivery, TriggerDeliveriesResponse} from "../core/types" + +// Deliveries scoped to one subscription. Distinct from subscription keys. +export const triggerDeliveriesAtomFamily = atomFamily((subscriptionId: string) => + atomWithQuery<TriggerDeliveriesResponse>(() => ({ + queryKey: ["triggers", "deliveries", subscriptionId], + queryFn: () => queryTriggerDeliveries({subscription_id: subscriptionId}), + staleTime: 15_000, + refetchOnWindowFocus: false, + enabled: !!subscriptionId, + })), +) + +export const useTriggerDeliveries = (subscriptionId?: string) => { + const query = useAtomValue(triggerDeliveriesAtomFamily(subscriptionId ?? "")) + + const deliveries = useMemo<TriggerDelivery[]>( + () => query.data?.deliveries ?? [], + [query.data?.deliveries], + ) + + return { + deliveries, + count: query.data?.count ?? 0, + isLoading: subscriptionId ? query.isPending : false, + error: query.error, + refetch: query.refetch, + } +} diff --git a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerEvent.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerEvent.ts new file mode 100644 index 0000000000..912cef0700 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerEvent.ts @@ -0,0 +1,37 @@ +import {useAtomValue} from "jotai" +import {atomFamily} from "jotai/utils" +import {atomWithQuery} from "jotai-tanstack-query" + +import {fetchTriggerEvent} from "../api" +import type {TriggerCatalogEventResponse} from "../core/types" + +const DEFAULT_PROVIDER = "composio" + +export const triggerEventDetailQueryFamily = atomFamily( + ({integrationKey, eventKey}: {integrationKey: string; eventKey: string}) => + atomWithQuery<TriggerCatalogEventResponse>(() => ({ + queryKey: [ + "triggers", + "catalog", + "eventDetail", + DEFAULT_PROVIDER, + integrationKey, + eventKey, + ], + queryFn: () => fetchTriggerEvent(DEFAULT_PROVIDER, integrationKey, eventKey), + staleTime: 5 * 60_000, + refetchOnWindowFocus: false, + enabled: !!integrationKey && !!eventKey, + })), + (a, b) => a.integrationKey === b.integrationKey && a.eventKey === b.eventKey, +) + +export const useTriggerEvent = (integrationKey: string, eventKey: string) => { + const query = useAtomValue(triggerEventDetailQueryFamily({integrationKey, eventKey})) + + return { + event: query.data?.event ?? null, + isLoading: query.isPending, + error: query.error, + } +} diff --git a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSubscription.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSubscription.ts new file mode 100644 index 0000000000..cbb9122b1e --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSubscription.ts @@ -0,0 +1,94 @@ +import {useCallback, useState} from "react" + +import {queryClient} from "@agenta/shared/api" +import {useAtomValue} from "jotai" +import {atomFamily} from "jotai/utils" +import {atomWithQuery} from "jotai-tanstack-query" + +import { + createTriggerSubscription, + deleteTriggerSubscription, + editTriggerSubscription, + fetchTriggerSubscription, + refreshTriggerSubscription, + revokeTriggerSubscription, +} from "../api" +import type { + TriggerSubscription, + TriggerSubscriptionCreate, + TriggerSubscriptionEdit, + TriggerSubscriptionResponse, +} from "../core/types" + +const invalidateSubscriptions = () => { + queryClient.invalidateQueries({queryKey: ["triggers", "subscriptions"]}) +} + +// Single subscription (used to source the full PUT body before editing). +export const triggerSubscriptionQueryAtomFamily = atomFamily((subscriptionId: string) => + atomWithQuery<TriggerSubscriptionResponse>(() => ({ + queryKey: ["triggers", "subscriptions", "detail", subscriptionId], + queryFn: () => fetchTriggerSubscription(subscriptionId), + staleTime: 30_000, + refetchOnWindowFocus: false, + enabled: !!subscriptionId, + })), +) + +export const useTriggerSubscription = (subscriptionId?: string) => { + const query = useAtomValue(triggerSubscriptionQueryAtomFamily(subscriptionId ?? "")) + const [isMutating, setIsMutating] = useState(false) + + const run = useCallback( + async ( + fn: () => Promise<TriggerSubscriptionResponse>, + ): Promise<TriggerSubscription | null> => { + setIsMutating(true) + try { + const res = await fn() + invalidateSubscriptions() + return res.subscription ?? null + } finally { + setIsMutating(false) + } + }, + [], + ) + + const create = useCallback( + (subscription: TriggerSubscriptionCreate) => + run(() => createTriggerSubscription(subscription)), + [run], + ) + + const edit = useCallback( + (subscription: TriggerSubscriptionEdit) => run(() => editTriggerSubscription(subscription)), + [run], + ) + + const revoke = useCallback((id: string) => run(() => revokeTriggerSubscription(id)), [run]) + + const refresh = useCallback((id: string) => run(() => refreshTriggerSubscription(id)), [run]) + + const remove = useCallback(async (id: string) => { + setIsMutating(true) + try { + await deleteTriggerSubscription(id) + invalidateSubscriptions() + } finally { + setIsMutating(false) + } + }, []) + + return { + subscription: subscriptionId ? (query.data?.subscription ?? null) : null, + isLoading: subscriptionId ? query.isPending : false, + error: query.error, + isMutating, + create, + edit, + revoke, + refresh, + remove, + } +} diff --git a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSubscriptions.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSubscriptions.ts new file mode 100644 index 0000000000..80df5d48b3 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSubscriptions.ts @@ -0,0 +1,60 @@ +import {useMemo} from "react" + +import {useAtomValue} from "jotai" +import {atomFamily} from "jotai/utils" +import {atomWithQuery} from "jotai-tanstack-query" + +import {queryTriggerSubscriptions} from "../api" +import type {TriggerSubscription, TriggerSubscriptionsResponse} from "../core/types" + +// Distinct from the catalog/connection keys (["triggers", "catalog"|"connections"]). +export const triggerSubscriptionsQueryAtom = atomWithQuery<TriggerSubscriptionsResponse>(() => ({ + queryKey: ["triggers", "subscriptions"], + queryFn: () => queryTriggerSubscriptions(), + staleTime: 30_000, + refetchOnWindowFocus: false, +})) + +export const useTriggerSubscriptions = () => { + const query = useAtomValue(triggerSubscriptionsQueryAtom) + + const subscriptions = useMemo<TriggerSubscription[]>( + () => query.data?.subscriptions ?? [], + [query.data?.subscriptions], + ) + + return { + subscriptions, + count: query.data?.count ?? 0, + isLoading: query.isPending, + error: query.error, + refetch: query.refetch, + } +} + +// Subscriptions scoped to a single connection. +export const triggerConnectionSubscriptionsAtomFamily = atomFamily((connectionId: string) => + atomWithQuery<TriggerSubscriptionsResponse>(() => ({ + queryKey: ["triggers", "subscriptions", "connection", connectionId], + queryFn: () => queryTriggerSubscriptions({connection_id: connectionId}), + staleTime: 30_000, + refetchOnWindowFocus: false, + enabled: !!connectionId, + })), +) + +export const useTriggerConnectionSubscriptions = (connectionId: string) => { + const query = useAtomValue(triggerConnectionSubscriptionsAtomFamily(connectionId)) + + const subscriptions = useMemo<TriggerSubscription[]>( + () => query.data?.subscriptions ?? [], + [query.data?.subscriptions], + ) + + return { + subscriptions, + count: query.data?.count ?? 0, + isLoading: query.isPending, + error: query.error, + } +} diff --git a/web/packages/agenta-entities/src/gatewayTrigger/index.ts b/web/packages/agenta-entities/src/gatewayTrigger/index.ts new file mode 100644 index 0000000000..24fb926f9c --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/index.ts @@ -0,0 +1,102 @@ +/** + * Gateway-trigger entity module. + * + * Browser-side state and queries for the `/triggers/*` endpoint family: + * the read-only events catalog (WP1) and the shared connection list (WP0). + * + * Mirrors `gatewayTool`. The catalog isn't in the Fern client yet, so the API + * layer uses the shared axios instance with zod validation at the boundary + * (see `api/api.ts`); it collapses onto the Fern `triggers` resource once the + * client is regenerated. + */ + +// --------------------------------------------------------------------------- +// CORE — domain types +// --------------------------------------------------------------------------- + +export type { + TriggerCatalogEvent, + TriggerCatalogEventDetails, + TriggerCatalogEventResponse, + TriggerCatalogEventsResponse, + TriggerCatalogProvider, + TriggerCatalogProviderResponse, + TriggerCatalogProvidersResponse, + TriggerConnection, + TriggerConnectionsResponse, + TriggerDelivery, + TriggerDeliveriesResponse, + TriggerDeliveryData, + TriggerDeliveryQuery, + TriggerDeliveryResponse, + TriggerProviderKind, + TriggerReference, + TriggerSelector, + TriggerStatus, + TriggerSubscription, + TriggerSubscriptionCreate, + TriggerSubscriptionData, + TriggerSubscriptionEdit, + TriggerSubscriptionQuery, + TriggerSubscriptionResponse, + TriggerSubscriptionsResponse, +} from "./core" +export {isConnectionActive, isConnectionValid} from "./core" + +// --------------------------------------------------------------------------- +// API — HTTP wrappers (axios + zod boundary validation) +// --------------------------------------------------------------------------- + +export { + createTriggerSubscription, + deleteTriggerSubscription, + editTriggerSubscription, + fetchTriggerDelivery, + fetchTriggerEvent, + fetchTriggerEvents, + fetchTriggerProvider, + fetchTriggerProviders, + fetchTriggerSubscription, + queryTriggerConnections, + queryTriggerDeliveries, + queryTriggerSubscriptions, + refreshTriggerSubscription, + revokeTriggerSubscription, +} from "./api" + +// --------------------------------------------------------------------------- +// STATE — drawer + selection atoms +// --------------------------------------------------------------------------- + +export { + deliveriesDrawerAtom, + eventsDrawerAtom, + eventSearchAtom, + selectedCatalogEventAtom, + subscriptionDrawerAtom, +} from "./state" +export type {DeliveriesDrawerState, EventsDrawerState, SubscriptionDrawerState} from "./state" + +// --------------------------------------------------------------------------- +// HOOKS — query hooks for React consumers +// --------------------------------------------------------------------------- + +export { + catalogEventsInfiniteFamily, + eventsSearchAtom, + triggerConnectionsQueryAtom, + triggerConnectionSubscriptionsAtomFamily, + triggerDeliveriesAtomFamily, + triggerEventDetailQueryFamily, + triggerIntegrationConnectionsAtomFamily, + triggerSubscriptionQueryAtomFamily, + triggerSubscriptionsQueryAtom, + useCatalogEvents, + useTriggerConnectionsQuery, + useTriggerConnectionSubscriptions, + useTriggerDeliveries, + useTriggerEvent, + useTriggerIntegrationConnections, + useTriggerSubscription, + useTriggerSubscriptions, +} from "./hooks" diff --git a/web/packages/agenta-entities/src/gatewayTrigger/state/atoms.ts b/web/packages/agenta-entities/src/gatewayTrigger/state/atoms.ts new file mode 100644 index 0000000000..c9a823eeab --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/state/atoms.ts @@ -0,0 +1,38 @@ +import {atom} from "jotai" + +// --------------------------------------------------------------------------- +// Events drawer state — opened against a connected integration +// --------------------------------------------------------------------------- + +export interface EventsDrawerState { + providerKey: string + integrationKey: string + integrationName?: string + connectionId?: string +} +export const eventsDrawerAtom = atom<EventsDrawerState | null>(null) + +// Drawer-local browsing state (reset on close) +export const eventSearchAtom = atom("") +export const selectedCatalogEventAtom = atom<string | null>(null) + +// --------------------------------------------------------------------------- +// Subscription drawer state — create (no id) or edit (existing subscription id) +// --------------------------------------------------------------------------- + +export interface SubscriptionDrawerState { + // Edit mode when set; create mode otherwise. + subscriptionId?: string + // Optional create-mode prefill from a chosen connection. + connectionId?: string + integrationKey?: string + integrationName?: string +} +export const subscriptionDrawerAtom = atom<SubscriptionDrawerState | null>(null) + +// Deliveries drawer state — opened against one subscription. +export interface DeliveriesDrawerState { + subscriptionId: string + subscriptionName?: string +} +export const deliveriesDrawerAtom = atom<DeliveriesDrawerState | null>(null) diff --git a/web/packages/agenta-entities/src/gatewayTrigger/state/index.ts b/web/packages/agenta-entities/src/gatewayTrigger/state/index.ts new file mode 100644 index 0000000000..d5f81c8210 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/state/index.ts @@ -0,0 +1,8 @@ +export { + deliveriesDrawerAtom, + eventsDrawerAtom, + eventSearchAtom, + selectedCatalogEventAtom, + subscriptionDrawerAtom, +} from "./atoms" +export type {DeliveriesDrawerState, EventsDrawerState, SubscriptionDrawerState} from "./atoms" diff --git a/web/packages/agenta-entities/tests/unit/gatewayTriggerApi.test.ts b/web/packages/agenta-entities/tests/unit/gatewayTriggerApi.test.ts new file mode 100644 index 0000000000..faad94054c --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/gatewayTriggerApi.test.ts @@ -0,0 +1,282 @@ +/** + * Unit tests for the gateway-trigger API layer. + * + * The triggers catalog isn't in the Fern client yet, so these functions call + * the shared axios instance and validate the response against the frozen zod + * schema at the boundary. Tests stub `@agenta/shared/api` (axios + URL) and the + * project store so we can introspect the request shape and confirm boundary + * validation without hitting the network. + * + * AC coverage: + * - Catalog browse: events are fetched against the WP1 API shape. + * - F2: `/triggers/connections/query` reads the same shared connection rows + * that `/tools/connections/query` returns, with no second connect. + */ + +import {beforeEach, describe, expect, it, vi} from "vitest" + +const {get, post} = vi.hoisted(() => ({get: vi.fn(), post: vi.fn()})) + +vi.mock("@agenta/shared/api", () => ({ + axios: {get, post}, + getAgentaApiUrl: () => "https://api.test", +})) + +vi.mock("@agenta/shared/state", () => ({ + projectIdAtom: {__type: "projectIdAtom"}, +})) + +vi.mock("jotai", async (importOriginal) => { + const actual = await importOriginal<typeof import("jotai")>() + return {...actual, getDefaultStore: () => ({get: () => "proj-42"})} +}) + +import { + createTriggerSubscription, + fetchTriggerSubscription, + fetchTriggerEvent, + fetchTriggerEvents, + fetchTriggerProviders, + queryTriggerConnections, + queryTriggerDeliveries, + queryTriggerSubscriptions, +} from "../../src/gatewayTrigger/api/api" + +beforeEach(() => { + get.mockReset() + post.mockReset() +}) + +describe("catalog browse", () => { + it("lists providers and scopes the request to the project", async () => { + get.mockResolvedValueOnce({ + data: {count: 1, providers: [{key: "composio", name: "Composio"}]}, + }) + + const res = await fetchTriggerProviders() + + const [url, opts] = get.mock.calls[0] + expect(url).toBe("https://api.test/triggers/catalog/providers/") + expect(opts.params).toMatchObject({project_id: "proj-42"}) + expect(res.providers[0].key).toBe("composio") + }) + + it("fetches an integration's events against the WP1 path with cursor params", async () => { + get.mockResolvedValueOnce({ + data: { + count: 1, + total: 1, + cursor: "next", + events: [{key: "github_star", name: "Repo starred", categories: []}], + }, + }) + + const res = await fetchTriggerEvents("composio", "github", { + query: "star", + limit: 10, + cursor: "c1", + }) + + const [url, opts] = get.mock.calls[0] + expect(url).toBe( + "https://api.test/triggers/catalog/providers/composio/integrations/github/events/", + ) + expect(opts.params).toMatchObject({ + project_id: "proj-42", + query: "star", + limit: 10, + cursor: "c1", + }) + expect(res.events).toHaveLength(1) + expect(res.cursor).toBe("next") + }) + + it("returns an event's trigger_config schema", async () => { + const triggerConfig = { + type: "object", + properties: {owner: {type: "string"}, repo: {type: "string"}}, + required: ["owner", "repo"], + } + get.mockResolvedValueOnce({ + data: { + count: 1, + event: { + key: "github_star", + name: "Repo starred", + categories: [], + trigger_config: triggerConfig, + }, + }, + }) + + const res = await fetchTriggerEvent("composio", "github", "github_star") + + const [url] = get.mock.calls[0] + expect(url).toBe( + "https://api.test/triggers/catalog/providers/composio/integrations/github/events/github_star", + ) + expect(res.event?.trigger_config).toEqual(triggerConfig) + }) + + it("falls back to an empty response when the payload fails validation", async () => { + get.mockResolvedValueOnce({data: {events: "not-an-array"}}) + + const res = await fetchTriggerEvents("composio", "github") + + expect(res).toEqual({count: 0, total: 0, cursor: null, events: []}) + }) +}) + +describe("connections (F2 — shared rows)", () => { + it("queries the same shared connection rows surfaced by /tools/connections", async () => { + // A row created via /tools/connections; it appears verbatim under + // /triggers/connections without a second connect. + const sharedRow = { + id: "conn-1", + slug: "github-prod", + name: "GitHub prod", + provider_key: "composio", + integration_key: "github", + flags: {is_active: true, is_valid: true}, + } + post.mockResolvedValueOnce({data: {count: 1, connections: [sharedRow]}}) + + const res = await queryTriggerConnections({ + provider_key: "composio", + integration_key: "github", + }) + + const [url, body, opts] = post.mock.calls[0] + expect(url).toBe("https://api.test/triggers/connections/query") + expect(body).toEqual({provider_key: "composio", integration_key: "github"}) + expect(opts.params).toMatchObject({project_id: "proj-42"}) + expect(res.connections[0]).toMatchObject({id: "conn-1", integration_key: "github"}) + }) + + it("tolerates a connection with no flags (no crash, no second connect path)", async () => { + post.mockResolvedValueOnce({ + data: { + count: 1, + connections: [{id: "conn-2", provider_key: "composio", integration_key: "slack"}], + }, + }) + + const res = await queryTriggerConnections() + + expect(res.connections).toHaveLength(1) + expect(res.connections[0].integration_key).toBe("slack") + }) + + it("falls back to an empty list when the payload fails validation", async () => { + post.mockResolvedValueOnce({data: {connections: 42}}) + + const res = await queryTriggerConnections() + + expect(res).toEqual({count: 0, connections: []}) + }) +}) + +describe("subscriptions", () => { + const sampleSubscription = { + id: "sub-1", + name: "Star watch", + connection_id: "conn-1", + enabled: true, + valid: true, + data: { + event_key: "github_star_added_event", + ti_id: "ti_abc", + trigger_config: {owner: "agenta", repo: "agenta"}, + inputs_fields: {message: "{{event.data.action}}"}, + references: {workflow_revision: {id: "rev-1"}}, + }, + } + + it("creates a subscription with the {subscription} envelope and project scope", async () => { + post.mockResolvedValueOnce({data: {count: 1, subscription: sampleSubscription}}) + + const res = await createTriggerSubscription({ + name: "Star watch", + connection_id: "conn-1", + data: { + event_key: "github_star_added_event", + inputs_fields: {message: "{{event.data.action}}"}, + references: {workflow_revision: {id: "rev-1"}}, + }, + }) + + const [url, body, opts] = post.mock.calls[0] + expect(url).toBe("https://api.test/triggers/subscriptions/") + expect(body.subscription.connection_id).toBe("conn-1") + expect(body.subscription.data.references.workflow_revision.id).toBe("rev-1") + expect(opts.params).toMatchObject({project_id: "proj-42"}) + expect(res.subscription?.id).toBe("sub-1") + }) + + it("queries subscriptions and passes the filter under {subscription}", async () => { + post.mockResolvedValueOnce({data: {count: 1, subscriptions: [sampleSubscription]}}) + + const res = await queryTriggerSubscriptions({connection_id: "conn-1"}) + + const [url, body] = post.mock.calls[0] + expect(url).toBe("https://api.test/triggers/subscriptions/query") + expect(body).toEqual({subscription: {connection_id: "conn-1"}}) + expect(res.subscriptions).toHaveLength(1) + expect(res.subscriptions[0].data.event_key).toBe("github_star_added_event") + }) + + it("fetches a single subscription by id", async () => { + get.mockResolvedValueOnce({data: {count: 1, subscription: sampleSubscription}}) + + const res = await fetchTriggerSubscription("sub-1") + + const [url, opts] = get.mock.calls[0] + expect(url).toBe("https://api.test/triggers/subscriptions/sub-1") + expect(opts.params).toMatchObject({project_id: "proj-42"}) + expect(res.subscription?.connection_id).toBe("conn-1") + }) + + it("falls back to an empty list when the subscriptions payload fails validation", async () => { + post.mockResolvedValueOnce({data: {subscriptions: "nope"}}) + + const res = await queryTriggerSubscriptions() + + expect(res).toEqual({count: 0, subscriptions: []}) + }) +}) + +describe("deliveries (read-only)", () => { + it("queries deliveries for a subscription under the {delivery} envelope", async () => { + post.mockResolvedValueOnce({ + data: { + count: 1, + deliveries: [ + { + id: "del-1", + subscription_id: "sub-1", + event_id: "evt-123", + status: {type: "success", code: "200", timestamp: "2026-06-18T00:00:00Z"}, + data: {event_key: "github_star_added_event", result: {ok: true}}, + }, + ], + }, + }) + + const res = await queryTriggerDeliveries({subscription_id: "sub-1"}) + + const [url, body, opts] = post.mock.calls[0] + expect(url).toBe("https://api.test/triggers/deliveries/query") + expect(body).toEqual({delivery: {subscription_id: "sub-1"}}) + expect(opts.params).toMatchObject({project_id: "proj-42"}) + expect(res.deliveries[0].event_id).toBe("evt-123") + expect(res.deliveries[0].status.type).toBe("success") + }) + + it("falls back to an empty list when the deliveries payload fails validation", async () => { + post.mockResolvedValueOnce({data: {deliveries: 7}}) + + const res = await queryTriggerDeliveries() + + expect(res).toEqual({count: 0, deliveries: []}) + }) +}) diff --git a/web/packages/agenta-entity-ui/package.json b/web/packages/agenta-entity-ui/package.json index fe30c9997a..1b94855f70 100644 --- a/web/packages/agenta-entity-ui/package.json +++ b/web/packages/agenta-entity-ui/package.json @@ -18,6 +18,7 @@ "./adapters": "./src/adapters/index.ts", "./drill-in": "./src/DrillInView/index.ts", "./gatewayTool": "./src/gatewayTool/index.ts", + "./gatewayTrigger": "./src/gatewayTrigger/index.ts", "./modals": "./src/modals/index.ts", "./selection": "./src/selection/index.ts", "./template-format": "./src/template-format/index.ts", diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerDeliveriesDrawer.tsx b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerDeliveriesDrawer.tsx new file mode 100644 index 0000000000..aefa936709 --- /dev/null +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerDeliveriesDrawer.tsx @@ -0,0 +1,162 @@ +import {useMemo} from "react" + +import { + deliveriesDrawerAtom, + useTriggerDeliveries, + type TriggerDelivery, +} from "@agenta/entities/gatewayTrigger" +import {Editor} from "@agenta/ui/editor" +import {Alert, Drawer, Empty, Spin, Table, Tag, Tooltip, Typography} from "antd" +import type {ColumnsType} from "antd/es/table" +import {useAtom} from "jotai" + +// --------------------------------------------------------------------------- +// TriggerDeliveriesDrawer — read-only delivery history for one subscription. +// +// One audit row per inbound event dispatched to the bound workflow: status, +// event_id, result/error, timestamps. The inbound dual of webhook deliveries. +// --------------------------------------------------------------------------- + +function statusColor(type?: string | null): string { + switch ((type ?? "").toLowerCase()) { + case "success": + case "delivered": + case "ok": + return "green" + case "error": + case "failed": + case "failure": + return "red" + case "pending": + case "running": + return "blue" + default: + return "default" + } +} + +export default function TriggerDeliveriesDrawer() { + const [state, setState] = useAtom(deliveriesDrawerAtom) + const open = !!state + + const {deliveries, isLoading} = useTriggerDeliveries(state?.subscriptionId) + + const columns: ColumnsType<TriggerDelivery> = useMemo( + () => [ + { + title: "Status", + key: "status", + onHeaderCell: () => ({style: {minWidth: 120}}), + render: (_, record) => { + const type = record.status?.type ?? record.status?.code + return ( + <Tooltip title={record.status?.message ?? undefined}> + <Tag color={statusColor(record.status?.type)}>{type ?? "unknown"}</Tag> + </Tooltip> + ) + }, + }, + { + title: "Event ID", + dataIndex: "event_id", + key: "event_id", + onHeaderCell: () => ({style: {minWidth: 180}}), + render: (value: string) => ( + <Typography.Text className="text-xs" copyable={{text: value}}> + {value} + </Typography.Text> + ), + }, + { + title: "Result", + key: "result", + onHeaderCell: () => ({style: {minWidth: 200}}), + render: (_, record) => { + if (record.data?.error) { + return ( + <Typography.Text type="danger" className="text-xs" ellipsis> + {record.data.error} + </Typography.Text> + ) + } + const result = record.data?.result + if (!result || Object.keys(result).length === 0) { + return <Typography.Text type="secondary">-</Typography.Text> + } + return ( + <Typography.Text className="text-xs" ellipsis> + {JSON.stringify(result)} + </Typography.Text> + ) + }, + }, + { + title: "When", + key: "timestamp", + onHeaderCell: () => ({style: {minWidth: 160}}), + render: (_, record) => { + const ts = record.status?.timestamp ?? record.created_at + return ( + <Typography.Text className="text-xs"> + {ts ? new Date(ts).toLocaleString() : "-"} + </Typography.Text> + ) + }, + }, + ], + [], + ) + + return ( + <Drawer + open={open} + onClose={() => setState(null)} + title={`Deliveries${state?.subscriptionName ? ` · ${state.subscriptionName}` : ""}`} + width={720} + destroyOnClose + > + {isLoading ? ( + <div className="flex items-center justify-center py-12"> + <Spin /> + </div> + ) : ( + <Table<TriggerDelivery> + columns={columns} + dataSource={deliveries} + rowKey={(record) => record.id ?? record.event_id} + bordered + size="small" + pagination={false} + locale={{emptyText: <Empty description="No deliveries yet" />}} + expandable={{ + expandedRowRender: (record) => + record.data?.error ? ( + <Alert + type="error" + message="Delivery failed" + description={record.data.error} + showIcon + /> + ) : ( + <div className="rounded-lg border border-solid border-gray-300 dark:border-gray-700 overflow-hidden"> + <Editor + initialValue={JSON.stringify( + record.data?.result ?? {}, + null, + 2, + )} + codeOnly + showToolbar={false} + language="json" + disabled + dimensions={{width: "100%", height: 160}} + /> + </div> + ), + rowExpandable: (record) => !!record.data?.result || !!record.data?.error, + }} + /> + )} + </Drawer> + ) +} diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerEventsDrawer.tsx b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerEventsDrawer.tsx new file mode 100644 index 0000000000..2887a3c0ab --- /dev/null +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerEventsDrawer.tsx @@ -0,0 +1,250 @@ +import React, {useCallback, useMemo, useRef, useState} from "react" + +import { + eventsDrawerAtom, + eventsSearchAtom, + useCatalogEvents, + useTriggerEvent, + type TriggerCatalogEvent, +} from "@agenta/entities/gatewayTrigger" +import {useDebouncedAtomSearch} from "@agenta/shared/hooks" +import {ScrollSentinel, ScrollToTopButton} from "@agenta/ui" +import {ArrowLeft, MagnifyingGlass} from "@phosphor-icons/react" +import {Card, Divider, Drawer, Empty, Form, Input, Spin, Tag, Typography} from "antd" +import {useAtom, useSetAtom} from "jotai" + +import SchemaForm from "../../gatewayTool/components/SchemaForm" + +// --------------------------------------------------------------------------- +// TriggerEventsDrawer (root) — opened against a connected integration +// --------------------------------------------------------------------------- + +export default function TriggerEventsDrawer() { + const [state, setState] = useAtom(eventsDrawerAtom) + const [selectedEvent, setSelectedEvent] = useState<TriggerCatalogEvent | null>(null) + const setEventsSearch = useSetAtom(eventsSearchAtom) + + const open = !!state + + const handleClose = useCallback(() => { + setState(null) + setSelectedEvent(null) + setEventsSearch("") + }, [setState, setEventsSearch]) + + const handleBack = useCallback(() => { + setSelectedEvent(null) + }, []) + + return ( + <Drawer + open={open} + onClose={handleClose} + title={ + selectedEvent + ? "Event" + : `Events${state?.integrationName ? ` · ${state.integrationName}` : ""}` + } + size="large" + destroyOnClose + styles={{ + body: { + padding: 0, + display: "flex", + flexDirection: "column", + overflow: "hidden", + }, + }} + > + {state && + (selectedEvent ? ( + <EventDetailView + integrationKey={state.integrationKey} + event={selectedEvent} + onBack={handleBack} + /> + ) : ( + <EventsView integrationKey={state.integrationKey} onSelect={setSelectedEvent} /> + ))} + </Drawer> + ) +} + +// --------------------------------------------------------------------------- +// Events view (sticky header + scrollable content) +// --------------------------------------------------------------------------- + +function EventsView({ + integrationKey, + onSelect, +}: { + integrationKey: string + onSelect: (event: TriggerCatalogEvent) => void +}) { + const setAtom = useSetAtom(eventsSearchAtom) + const search = useDebouncedAtomSearch(setAtom) + const scrollRef = useRef<HTMLDivElement>(null) + + const { + events, + total, + prefetchThreshold, + isLoading, + hasNextPage, + isFetchingNextPage, + requestMore, + } = useCatalogEvents(integrationKey) + + const sentinelIndex = useMemo( + () => Math.max(0, events.length - prefetchThreshold), + [events.length, prefetchThreshold], + ) + + return ( + <div className="flex flex-col h-full overflow-hidden"> + <div className="flex flex-col gap-3 px-6 pt-4 pb-3 shrink-0"> + <Input + placeholder="Search events…" + prefix={<MagnifyingGlass size={16} />} + value={search.value} + onChange={(e) => search.onChange(e.target.value)} + allowClear + onClear={() => search.onChange("")} + /> + <Typography.Text type="secondary" className="text-xs"> + {total} event{total !== 1 ? "s" : ""} + </Typography.Text> + </div> + + <Divider className="!m-0" /> + + <div + ref={scrollRef} + className="flex-1 overflow-y-auto overscroll-contain px-6 py-3 relative" + > + {isLoading && events.length === 0 ? ( + <div className="flex items-center justify-center py-8"> + <Spin /> + </div> + ) : events.length === 0 ? ( + <Empty description="No events found" /> + ) : ( + <div className="flex flex-col gap-2"> + {events.map((event, i) => ( + <React.Fragment key={event.key}> + {i === sentinelIndex && ( + <ScrollSentinel + onVisible={requestMore} + hasMore={hasNextPage} + isFetching={isFetchingNextPage} + /> + )} + <Card + hoverable + onClick={() => onSelect(event)} + className="cursor-pointer" + size="small" + > + <div className="flex flex-col gap-0.5"> + <div className="flex items-center gap-2"> + <Typography.Text strong className="truncate"> + {event.name} + </Typography.Text> + {event.categories?.slice(0, 2).map((c) => ( + <Tag key={c} className="text-xs"> + {c} + </Tag> + ))} + </div> + {event.description && ( + <Typography.Text type="secondary" className="text-xs"> + {event.description} + </Typography.Text> + )} + </div> + </Card> + </React.Fragment> + ))} + + <ScrollSentinel + onVisible={requestMore} + hasMore={hasNextPage} + isFetching={isFetchingNextPage} + /> + + {isFetchingNextPage && ( + <div className="flex items-center justify-center py-4"> + <Spin size="small" /> + </div> + )} + </div> + )} + + <ScrollToTopButton scrollRef={scrollRef} /> + </div> + </div> + ) +} + +// --------------------------------------------------------------------------- +// Event detail — read-only `trigger_config` schema +// --------------------------------------------------------------------------- + +function EventDetailView({ + integrationKey, + event, + onBack, +}: { + integrationKey: string + event: TriggerCatalogEvent + onBack: () => void +}) { + const [form] = Form.useForm() + const {event: detail, isLoading} = useTriggerEvent(integrationKey, event.key) + + const schema = (detail?.trigger_config ?? null) as Record<string, unknown> | null + + return ( + <div className="flex flex-col h-full overflow-hidden"> + <div className="flex flex-col gap-2 px-6 pt-4 pb-3 shrink-0"> + <div className="flex items-center gap-3"> + <button + type="button" + aria-label="Go back" + onClick={onBack} + className="shrink-0 cursor-pointer bg-transparent border-0 p-0 inline-flex items-center" + > + <ArrowLeft size={16} /> + </button> + <Typography.Text strong className="truncate flex-1"> + {event.name} + </Typography.Text> + </div> + {event.description && ( + <Typography.Paragraph type="secondary" className="!text-xs !mb-0"> + {event.description} + </Typography.Paragraph> + )} + </div> + + <Divider className="!m-0" /> + + <div className="flex-1 overflow-y-auto overscroll-contain px-6 py-4"> + <Typography.Text className="text-sm font-medium"> + Trigger configuration + </Typography.Text> + <div className="mt-3"> + {isLoading ? ( + <div className="flex items-center justify-center py-8"> + <Spin /> + </div> + ) : schema && Object.keys(schema).length > 0 ? ( + <SchemaForm schema={schema} form={form} disabled /> + ) : ( + <Empty description="This event has no configuration" /> + )} + </div> + </div> + </div> + ) +} diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerSubscriptionDrawer.tsx b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerSubscriptionDrawer.tsx new file mode 100644 index 0000000000..edb631f5d7 --- /dev/null +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerSubscriptionDrawer.tsx @@ -0,0 +1,340 @@ +import {useCallback, useEffect, useMemo, useRef, useState} from "react" + +import { + subscriptionDrawerAtom, + useTriggerConnectionsQuery, + useTriggerEvent, + useTriggerSubscription, + type TriggerConnection, + type TriggerSubscriptionCreate, + type TriggerSubscriptionData, + type TriggerSubscriptionEdit, +} from "@agenta/entities/gatewayTrigger" +import {Editor} from "@agenta/ui/editor" +import {Lightning} from "@phosphor-icons/react" +import {Button, Divider, Drawer, Form, Input, Select, Spin, Switch, Typography, message} from "antd" +import {useAtom} from "jotai" + +import SchemaForm, {type SchemaFormHandle} from "../../gatewayTool/components/SchemaForm" +import { + EntityPicker, + workflowRevisionAdapter, + type WorkflowRevisionSelectionResult, +} from "../../selection" + +const DEFAULT_PROVIDER = "composio" + +// --------------------------------------------------------------------------- +// TriggerSubscriptionDrawer (root) — create or edit a subscription. +// +// Binds a provider event (catalog) on a connected integration to a workflow +// revision. Edits are full-PUT: the body is sourced from the freshly-fetched +// subscription and only owned fields are overridden. +// --------------------------------------------------------------------------- + +export default function TriggerSubscriptionDrawer() { + const [state, setState] = useAtom(subscriptionDrawerAtom) + const open = !!state + const isEdit = !!state?.subscriptionId + + const handleClose = useCallback(() => setState(null), [setState]) + + return ( + <Drawer + open={open} + onClose={handleClose} + title={isEdit ? "Edit subscription" : "New subscription"} + width={640} + destroyOnClose + styles={{ + body: {padding: 0, display: "flex", flexDirection: "column", overflow: "hidden"}, + }} + > + {state && ( + <SubscriptionForm key={state.subscriptionId ?? "new"} onClose={handleClose} /> + )} + </Drawer> + ) +} + +// --------------------------------------------------------------------------- +// Subscription form +// --------------------------------------------------------------------------- + +function SubscriptionForm({onClose}: {onClose: () => void}) { + const [state] = useAtom(subscriptionDrawerAtom) + const subscriptionId = state?.subscriptionId + const isEdit = !!subscriptionId + + const {connections, isLoading: connectionsLoading} = useTriggerConnectionsQuery() + const { + subscription, + isLoading: subLoading, + isMutating, + create, + edit, + } = useTriggerSubscription(subscriptionId) + + const [name, setName] = useState("") + const [connectionId, setConnectionId] = useState<string | undefined>(state?.connectionId) + const [eventKey, setEventKey] = useState("") + const [enabled, setEnabled] = useState(true) + const [workflowRevId, setWorkflowRevId] = useState<string | null>(null) + const [workflowLabel, setWorkflowLabel] = useState<string | null>(null) + const [inputsText, setInputsText] = useState("{}") + const [inputsError, setInputsError] = useState<string | null>(null) + + const [configForm] = Form.useForm() + const configFormRef = useRef<SchemaFormHandle>(null) + + // Prefill from the freshly-fetched subscription (edit mode). + useEffect(() => { + if (!isEdit || !subscription) return + setName(subscription.name ?? "") + setConnectionId(subscription.connection_id) + setEventKey(subscription.data?.event_key ?? "") + setEnabled(subscription.enabled ?? true) + const wfId = subscription.data?.references?.workflow_revision?.id ?? null + setWorkflowRevId(wfId) + setWorkflowLabel(wfId) + setInputsText(JSON.stringify(subscription.data?.inputs_fields ?? {}, null, 2)) + }, [isEdit, subscription]) + + const selectedConnection = useMemo<TriggerConnection | undefined>( + () => connections.find((c) => c.id === connectionId), + [connections, connectionId], + ) + + const integrationKey = selectedConnection?.integration_key ?? "" + + // trigger_config schema for the chosen event (catalog detail). + const {event: eventDetail, isLoading: eventLoading} = useTriggerEvent(integrationKey, eventKey) + const triggerConfigSchema = (eventDetail?.trigger_config ?? null) as Record< + string, + unknown + > | null + + // Seed the config form with existing trigger_config on edit. + useEffect(() => { + if (isEdit && subscription?.data?.trigger_config) { + configForm.setFieldsValue(subscription.data.trigger_config) + } + }, [isEdit, subscription, configForm]) + + const handleSubmit = useCallback(async () => { + if (!connectionId) { + message.error("Select a connection") + return + } + if (!eventKey) { + message.error("Select an event") + return + } + if (!workflowRevId) { + message.error("Bind a workflow") + return + } + + let inputsFields: Record<string, unknown> = {} + try { + inputsFields = inputsText.trim() ? JSON.parse(inputsText) : {} + setInputsError(null) + } catch { + setInputsError("Invalid JSON") + message.error("inputs mapping is not valid JSON") + return + } + + let triggerConfig: Record<string, unknown> | undefined + try { + triggerConfig = (await configFormRef.current?.getValues()) ?? undefined + } catch { + // form validation failed + return + } + + const data: TriggerSubscriptionData = { + event_key: eventKey, + trigger_config: triggerConfig, + inputs_fields: inputsFields, + references: {workflow_revision: {id: workflowRevId}}, + } + + try { + if (isEdit && subscription) { + // Full PUT — carry the whole entity, override owned fields. + const body: TriggerSubscriptionEdit = { + id: subscription.id as string, + name: name || null, + description: subscription.description ?? null, + flags: subscription.flags ?? null, + tags: subscription.tags ?? null, + meta: subscription.meta ?? null, + connection_id: connectionId, + data: {...subscription.data, ...data}, + enabled, + valid: subscription.valid ?? true, + } + const result = await edit(body) + if (!result) { + message.error("Failed to update subscription") + return + } + message.success("Subscription updated") + } else { + const body: TriggerSubscriptionCreate = { + name: name || null, + connection_id: connectionId, + data, + } + const result = await create(body) + if (!result) { + message.error("Failed to create subscription") + return + } + message.success("Subscription created") + } + onClose() + } catch { + message.error("Failed to save subscription") + } + }, [ + connectionId, + eventKey, + workflowRevId, + inputsText, + isEdit, + subscription, + name, + enabled, + edit, + create, + onClose, + ]) + + if (isEdit && subLoading) { + return ( + <div className="flex items-center justify-center py-12"> + <Spin /> + </div> + ) + } + + return ( + <div className="flex flex-col h-full overflow-hidden"> + <div className="flex-1 overflow-y-auto overscroll-contain px-6 py-4"> + <Form layout="vertical"> + <Form.Item label="Name"> + <Input + placeholder="Subscription name" + value={name} + onChange={(e) => setName(e.target.value)} + /> + </Form.Item> + + <Form.Item label="Connection" required> + <Select + placeholder="Select a connected integration" + value={connectionId} + onChange={(v) => { + setConnectionId(v) + setEventKey("") + }} + loading={connectionsLoading} + disabled={isEdit} + options={connections.map((c) => ({ + value: c.id ?? "", + label: `${c.name || c.slug || c.integration_key} (${c.integration_key})`, + }))} + /> + </Form.Item> + + <Form.Item label="Event" required> + <Input + placeholder="Event key (e.g. github_star_added_event)" + prefix={<Lightning size={14} />} + value={eventKey} + onChange={(e) => setEventKey(e.target.value)} + disabled={!connectionId} + /> + <Typography.Text type="secondary" className="text-xs"> + Provider: {DEFAULT_PROVIDER} + {integrationKey ? ` · ${integrationKey}` : ""} + </Typography.Text> + </Form.Item> + + <Form.Item label="Bound workflow" required> + <div className="flex items-center gap-2"> + <EntityPicker<WorkflowRevisionSelectionResult> + variant="popover-cascader" + adapter={workflowRevisionAdapter} + onSelect={(selection) => { + setWorkflowRevId(selection.id) + setWorkflowLabel(selection.label) + }} + size="small" + placeholder={workflowLabel ?? "Select workflow revision"} + /> + {workflowLabel && ( + <Typography.Text type="secondary" className="text-xs truncate"> + {workflowLabel} + </Typography.Text> + )} + </div> + </Form.Item> + + <Divider className="!my-2" /> + + <Typography.Text strong className="text-sm"> + Trigger configuration + </Typography.Text> + <div className="mt-2 mb-4"> + {eventLoading ? ( + <div className="flex items-center justify-center py-6"> + <Spin /> + </div> + ) : ( + <SchemaForm + ref={configFormRef} + schema={triggerConfigSchema} + form={configForm} + disabled={isMutating} + /> + )} + </div> + + <Form.Item + label="Inputs mapping" + validateStatus={inputsError ? "error" : undefined} + help={inputsError ?? "Maps event context to the workflow inputs (JSON)"} + > + <div className="rounded-lg border border-solid border-gray-300 dark:border-gray-700 overflow-hidden"> + <Editor + initialValue={inputsText || "{}"} + onChange={({textContent}) => setInputsText(textContent)} + codeOnly + showToolbar={false} + language="json" + dimensions={{width: "100%", height: 120}} + disabled={isMutating} + /> + </div> + </Form.Item> + + <Form.Item label="Enabled"> + <Switch checked={enabled} onChange={setEnabled} /> + </Form.Item> + </Form> + </div> + + <Divider className="!m-0" /> + + <div className="flex justify-end gap-2 px-6 py-3 shrink-0"> + <Button onClick={onClose}>Cancel</Button> + <Button type="primary" loading={isMutating} onClick={handleSubmit}> + {isEdit ? "Save" : "Create"} + </Button> + </div> + </div> + ) +} diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/index.ts b/web/packages/agenta-entity-ui/src/gatewayTrigger/index.ts new file mode 100644 index 0000000000..4ea0d0551d --- /dev/null +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/index.ts @@ -0,0 +1,12 @@ +/** + * Gateway-trigger entity UI. + * + * Atom-driven drawer for browsing a connected integration's events and viewing + * each event's `trigger_config` schema. State and data come from + * `@agenta/entities/gatewayTrigger`; this layer is purely the UI. Mirrors + * `gatewayTool`. + */ + +export {default as TriggerEventsDrawer} from "./drawers/TriggerEventsDrawer" +export {default as TriggerSubscriptionDrawer} from "./drawers/TriggerSubscriptionDrawer" +export {default as TriggerDeliveriesDrawer} from "./drawers/TriggerDeliveriesDrawer" From ee49e6c0f153009a099f63ecfaff829544449f32 Mon Sep 17 00:00:00 2001 From: Juan Pablo Vega <jp@agenta.ai> Date: Fri, 19 Jun 2026 11:57:38 +0200 Subject: [PATCH 2/5] test(api): align default-workspace test with oldest-membership behavior get_default_workspace_id no longer prefers owner-role (multi-org: an invitee owns their own empty personal workspace). Assert oldest membership wins regardless of role. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- api/oss/tests/pytest/unit/services/test_db_manager.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/api/oss/tests/pytest/unit/services/test_db_manager.py b/api/oss/tests/pytest/unit/services/test_db_manager.py index 0e380438ef..8b93b80261 100644 --- a/api/oss/tests/pytest/unit/services/test_db_manager.py +++ b/api/oss/tests/pytest/unit/services/test_db_manager.py @@ -55,7 +55,9 @@ def _patch_core_session(monkeypatch, memberships): @pytest.mark.asyncio -async def test_get_default_workspace_id_prefers_owner_membership(monkeypatch): +async def test_get_default_workspace_id_ignores_owner_role(monkeypatch): + # Owner-role is NOT preferred: under multi-org an invitee owns their own + # empty personal workspace, so the oldest membership wins regardless of role. owner_workspace_id = uuid4() editor_workspace_id = uuid4() @@ -77,7 +79,7 @@ async def test_get_default_workspace_id_prefers_owner_membership(monkeypatch): workspace_id = await db_manager.get_default_workspace_id(str(uuid4())) - assert workspace_id == str(owner_workspace_id) + assert workspace_id == str(editor_workspace_id) @pytest.mark.asyncio From 338939ccdd1edc503a8e20467ac7fa3c6720c9b9 Mon Sep 17 00:00:00 2001 From: Juan Pablo Vega <jp@agenta.ai> Date: Fri, 19 Jun 2026 11:59:57 +0200 Subject: [PATCH 3/5] chore(api): silence OpenTelemetry SelectableGroups deprecation warning in tests Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- api/pytest.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/pytest.ini b/api/pytest.ini index 10ed8253ac..7ed3465305 100644 --- a/api/pytest.ini +++ b/api/pytest.ini @@ -5,6 +5,8 @@ testpaths = ee/tests/pytest addopts = -ra -n auto --self-contained-html asyncio_mode = auto +filterwarnings = + ignore:SelectableGroups dict interface is deprecated:DeprecationWarning:opentelemetry.util._importlib_metadata markers = coverage_smoke: breadth over depth coverage_full: breadth and depth From ce43b265ece7520efe227ac9e5a265da9478ad96 Mon Sep 17 00:00:00 2001 From: Juan Pablo Vega <jp@agenta.ai> Date: Fri, 19 Jun 2026 18:53:59 +0200 Subject: [PATCH 4/5] feat(triggers): normalize event context + align mapping suggestions Normalize the inbound provider envelope in the dispatcher into a stable context (event.attributes + synthetic trigger_id/trigger_type/timestamp/ created_at), parallel to webhooks' event context. Resolve and complete the bound workflow reference on subscription create/edit (the /deploy pattern) so a variant id is resolved to a runnable revision. Align the drawer's mapping suggestions + live preview to the same normalized shape. Update trigger tests to the new shape and always-verify ingress; gate the create-roundtrip acceptance tests on an ACTIVE connected account. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../triggers/test_triggers_connections.py | 227 +++++++++ .../triggers/test_triggers_subscriptions.py | 12 +- api/entrypoints/dispatcher_composio.py | 106 ++++ api/entrypoints/routers.py | 31 ++ api/oss/src/apis/fastapi/tools/models.py | 13 +- api/oss/src/apis/fastapi/tools/router.py | 27 +- api/oss/src/apis/fastapi/triggers/models.py | 41 +- api/oss/src/apis/fastapi/triggers/router.py | 446 +++++++++++++++-- api/oss/src/core/gateway/catalog/__init__.py | 0 api/oss/src/core/gateway/catalog/dtos.py | 46 ++ .../src/core/gateway/catalog/interfaces.py | 38 ++ .../gateway/catalog/providers/__init__.py | 0 .../catalog/providers/composio/__init__.py | 5 + .../catalog/providers/composio/adapter.py | 230 +++++++++ api/oss/src/core/gateway/catalog/registry.py | 27 + api/oss/src/core/gateway/catalog/service.py | 69 +++ .../connections/providers/composio/adapter.py | 22 +- .../src/core/gateway/providers/__init__.py | 0 .../gateway/providers/composio/__init__.py | 0 .../core/gateway/providers/composio/errors.py | 23 + api/oss/src/core/tools/dtos.py | 50 +- .../core/tools/providers/composio/adapter.py | 17 +- api/oss/src/core/tools/service.py | 96 ++-- api/oss/src/core/triggers/dtos.py | 44 +- api/oss/src/core/triggers/interfaces.py | 5 + .../triggers/providers/composio/adapter.py | 55 ++- api/oss/src/core/triggers/service.py | 271 +++++++++- api/oss/src/core/triggers/utils.py | 68 +++ api/oss/src/middlewares/auth.py | 8 +- .../src/tasks/asyncio/triggers/dispatcher.py | 29 +- api/oss/src/utils/env.py | 7 +- .../manual/triggers/try_composio_triggers.py | 400 +++++++++++++++ .../triggers/test_triggers_connections.py | 140 ++++++ .../triggers/test_triggers_ingress.py | 119 +++-- .../triggers/test_triggers_subscriptions.py | 13 +- .../unit/triggers/test_triggers_dispatcher.py | 54 +- .../unit/triggers/test_triggers_signature.py | 130 ++--- .../docker-compose/ee/docker-compose.dev.yml | 31 ++ .../ee/docker-compose.gh.local.yml | 31 ++ .../docker-compose/ee/docker-compose.gh.yml | 31 ++ .../docker-compose/oss/docker-compose.dev.yml | 31 ++ .../oss/docker-compose.gh.local.yml | 31 ++ .../oss/docker-compose.gh.ssl.yml | 31 ++ .../docker-compose/oss/docker-compose.gh.yml | 31 ++ hosting/docker-compose/run.sh | 14 +- web/_reference/agenta-sdk/src/types.ts | 4 +- .../DrillInView/OSSdrillInUIProvider.tsx | 18 +- .../assets/GatewayToolsPanel.tsx | 18 +- .../components/Sidebar/SettingsSidebar.tsx | 6 +- .../Modals/DeleteWebhookModal.tsx} | 18 +- .../Modals/SecretRevealModal.tsx | 2 +- .../RequestPreview.tsx | 12 +- .../WebhookDrawer.tsx} | 84 ++-- .../WebhookFieldRenderer.tsx} | 2 +- .../WebhookLogsTab.tsx} | 12 +- .../assets/constants.ts | 4 +- .../{Automations => Webhooks}/assets/types.ts | 6 +- .../utils/buildPreviewRequest.ts | 4 +- .../utils/buildSubscription.ts | 6 +- .../utils/handleTestResult.ts | 12 +- .../widgets/AdvanceConfigWidget.tsx | 0 .../widgets/DispatchAlertWidget.tsx | 0 .../widgets/HeaderListWidget.tsx | 0 .../pages/settings/APIKeys/APIKeys.tsx | 2 +- .../Tools/components/GatewayToolsSection.tsx | 26 +- .../Tools/hooks/useIntegrationDetail.ts | 12 +- .../Tools/hooks/useToolsConnections.ts | 4 +- .../Tools/hooks/useToolsIntegrations.ts | 4 +- .../GatewaySubscriptionsSection.tsx | 47 +- .../components/GatewayTriggersSection.tsx | 193 ++++++-- .../Automations.tsx => Webhooks/Webhooks.tsx} | 68 ++- .../p/[project_id]/settings/index.tsx | 21 +- .../services/{automations => webhooks}/api.ts | 0 .../{automations => webhooks}/types.ts | 10 +- web/oss/src/state/automations/state.ts | 9 - .../state/{automations => webhooks}/atoms.ts | 30 +- web/oss/src/state/webhooks/state.ts | 9 + web/oss/src/styles/globals.css | 26 +- .../src/gatewayTool/api/api.ts | 18 +- .../src/gatewayTool/api/index.ts | 16 +- .../src/gatewayTool/hooks/index.ts | 35 +- ...ActionDetail.ts => useToolActionDetail.ts} | 10 +- ...logActions.ts => useToolCatalogActions.ts} | 16 +- ...tions.ts => useToolCatalogIntegrations.ts} | 16 +- ...Actions.ts => useToolConnectionActions.ts} | 6 +- ...tionQuery.ts => useToolConnectionQuery.ts} | 11 +- ...onsQuery.ts => useToolConnectionsQuery.ts} | 10 +- ...ns.ts => useToolIntegrationConnections.ts} | 10 +- ...nDetail.ts => useToolIntegrationDetail.ts} | 10 +- .../agenta-entities/src/gatewayTool/index.ts | 54 +- .../src/gatewayTool/state/atoms.ts | 4 +- .../src/gatewayTool/state/index.ts | 4 +- .../src/gatewayTrigger/api/api.ts | 120 +++++ .../src/gatewayTrigger/api/client.ts | 19 + .../src/gatewayTrigger/api/index.ts | 9 +- .../src/gatewayTrigger/core/types.ts | 56 ++- .../src/gatewayTrigger/hooks/index.ts | 12 +- ...ogEvents.ts => useTriggerCatalogEvents.ts} | 12 +- .../hooks/useTriggerCatalogIntegrations.ts | 81 +++ .../hooks/useTriggerConnectionActions.ts | 36 ++ .../gatewayTrigger/hooks/useTriggerEvent.ts | 4 +- .../src/gatewayTrigger/index.ts | 34 +- .../src/gatewayTrigger/state/atoms.ts | 16 +- .../src/gatewayTrigger/state/index.ts | 11 +- web/packages/agenta-entities/src/index.ts | 4 +- .../src/gatewayTool/components/SchemaForm.tsx | 1 + .../src/gatewayTool/drawers/CatalogDrawer.tsx | 32 +- .../src/gatewayTool/drawers/ConnectDrawer.tsx | 6 +- .../drawers/ConnectionManagerDrawer.tsx | 12 +- .../drawers/ToolExecutionDrawer.tsx | 22 +- .../drawers/TriggerCatalogDrawer.tsx | 466 ++++++++++++++++++ .../drawers/TriggerConnectDrawer.tsx | 281 +++++++++++ .../drawers/TriggerDeliveriesDrawer.tsx | 4 +- .../drawers/TriggerEventsDrawer.tsx | 14 +- .../drawers/TriggerSubscriptionDrawer.tsx | 353 ++++++++++++- .../src/gatewayTrigger/index.ts | 2 + .../base.fixture/providerHelpers/index.ts | 4 +- 117 files changed, 4835 insertions(+), 765 deletions(-) create mode 100644 api/ee/tests/pytest/acceptance/triggers/test_triggers_connections.py create mode 100644 api/entrypoints/dispatcher_composio.py create mode 100644 api/oss/src/core/gateway/catalog/__init__.py create mode 100644 api/oss/src/core/gateway/catalog/dtos.py create mode 100644 api/oss/src/core/gateway/catalog/interfaces.py create mode 100644 api/oss/src/core/gateway/catalog/providers/__init__.py create mode 100644 api/oss/src/core/gateway/catalog/providers/composio/__init__.py create mode 100644 api/oss/src/core/gateway/catalog/providers/composio/adapter.py create mode 100644 api/oss/src/core/gateway/catalog/registry.py create mode 100644 api/oss/src/core/gateway/catalog/service.py create mode 100644 api/oss/src/core/gateway/providers/__init__.py create mode 100644 api/oss/src/core/gateway/providers/composio/__init__.py create mode 100644 api/oss/src/core/gateway/providers/composio/errors.py create mode 100644 api/oss/src/core/triggers/utils.py create mode 100644 api/oss/tests/manual/triggers/try_composio_triggers.py create mode 100644 api/oss/tests/pytest/acceptance/triggers/test_triggers_connections.py rename web/oss/src/components/{Automations/Modals/DeleteAutomationModal.tsx => Webhooks/Modals/DeleteWebhookModal.tsx} (67%) rename web/oss/src/components/{Automations => Webhooks}/Modals/SecretRevealModal.tsx (96%) rename web/oss/src/components/{Automations => Webhooks}/RequestPreview.tsx (92%) rename web/oss/src/components/{Automations/AutomationDrawer.tsx => Webhooks/WebhookDrawer.tsx} (85%) rename web/oss/src/components/{Automations/AutomationFieldRenderer.tsx => Webhooks/WebhookFieldRenderer.tsx} (98%) rename web/oss/src/components/{Automations/AutomationLogsTab.tsx => Webhooks/WebhookLogsTab.tsx} (93%) rename web/oss/src/components/{Automations => Webhooks}/assets/constants.ts (98%) rename web/oss/src/components/{Automations => Webhooks}/assets/types.ts (87%) rename web/oss/src/components/{Automations => Webhooks}/utils/buildPreviewRequest.ts (98%) rename web/oss/src/components/{Automations => Webhooks}/utils/buildSubscription.ts (96%) rename web/oss/src/components/{Automations => Webhooks}/utils/handleTestResult.ts (53%) rename web/oss/src/components/{Automations => Webhooks}/widgets/AdvanceConfigWidget.tsx (100%) rename web/oss/src/components/{Automations => Webhooks}/widgets/DispatchAlertWidget.tsx (100%) rename web/oss/src/components/{Automations => Webhooks}/widgets/HeaderListWidget.tsx (100%) rename web/oss/src/components/pages/settings/{Automations/Automations.tsx => Webhooks/Webhooks.tsx} (79%) rename web/oss/src/services/{automations => webhooks}/api.ts (100%) rename web/oss/src/services/{automations => webhooks}/types.ts (91%) delete mode 100644 web/oss/src/state/automations/state.ts rename web/oss/src/state/{automations => webhooks}/atoms.ts (74%) create mode 100644 web/oss/src/state/webhooks/state.ts rename web/packages/agenta-entities/src/gatewayTool/hooks/{useActionDetail.ts => useToolActionDetail.ts} (71%) rename web/packages/agenta-entities/src/gatewayTool/hooks/{useCatalogActions.ts => useToolCatalogActions.ts} (85%) rename web/packages/agenta-entities/src/gatewayTool/hooks/{useCatalogIntegrations.ts => useToolCatalogIntegrations.ts} (87%) rename web/packages/agenta-entities/src/gatewayTool/hooks/{useConnectionActions.ts => useToolConnectionActions.ts} (74%) rename web/packages/agenta-entities/src/gatewayTool/hooks/{useConnectionQuery.ts => useToolConnectionQuery.ts} (74%) rename web/packages/agenta-entities/src/gatewayTool/hooks/{useConnectionsQuery.ts => useToolConnectionsQuery.ts} (62%) rename web/packages/agenta-entities/src/gatewayTool/hooks/{useIntegrationConnections.ts => useToolIntegrationConnections.ts} (73%) rename web/packages/agenta-entities/src/gatewayTool/hooks/{useIntegrationDetail.ts => useToolIntegrationDetail.ts} (63%) rename web/packages/agenta-entities/src/gatewayTrigger/hooks/{useCatalogEvents.ts => useTriggerCatalogEvents.ts} (86%) create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerCatalogIntegrations.ts create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerConnectionActions.ts create mode 100644 web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerCatalogDrawer.tsx create mode 100644 web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerConnectDrawer.tsx diff --git a/api/ee/tests/pytest/acceptance/triggers/test_triggers_connections.py b/api/ee/tests/pytest/acceptance/triggers/test_triggers_connections.py new file mode 100644 index 0000000000..8dbb6955f6 --- /dev/null +++ b/api/ee/tests/pytest/acceptance/triggers/test_triggers_connections.py @@ -0,0 +1,227 @@ +"""EE acceptance tests for the /triggers/connections contract. + +Mirrors the OSS suite (oss/tests/pytest/acceptance/triggers/test_triggers_connections.py) +but exercises /triggers/connections as a business-plan, developer-role account. +Under EE the endpoints are gated on the triggers permission surface +(VIEW_TRIGGERS for reads, EDIT_TRIGGERS for writes); a developer role carries +both, so this verifies the contract behaves once the gate is satisfied. + +Triggers exposes an independent surface over the SAME shared +``gateway_connections`` rows that ``/tools/connections`` uses; the +cross-visibility roundtrip pins that a connection made on one side is visible +and manageable from the other. + +The query endpoint is DB-only and needs no Composio credentials. Create / revoke +make real provider calls, so those are gated on COMPOSIO_API_KEY. + +Requires a running API. +""" + +import os +from uuid import uuid4 + +import pytest +import requests + +from utils.constants import BASE_TIMEOUT + + +_COMPOSIO_ENABLED = bool(os.getenv("COMPOSIO_API_KEY")) +_requires_composio = pytest.mark.skipif( + not _COMPOSIO_ENABLED, + reason="needs live Composio credentials (COMPOSIO_API_KEY)", +) + + +def _create_developer_business_account(admin_api): + uid = uuid4().hex[:12] + email = f"trig-connections-dev-{uid}@test.agenta.ai" + resp = admin_api( + "POST", + "/admin/simple/accounts/", + json={ + "accounts": { + "u": { + "user": {"email": email}, + "options": { + "create_api_keys": True, + "return_api_keys": True, + "seed_defaults": False, + }, + "subscription": {"plan": "cloud_v0_business"}, + "organization_memberships": [ + { + "organization_ref": {"ref": "org"}, + "user_ref": {"ref": "user"}, + "role": "developer", + } + ], + "workspace_memberships": [ + { + "workspace_ref": {"ref": "wrk"}, + "user_ref": {"ref": "user"}, + "role": "developer", + } + ], + "project_memberships": [ + { + "project_ref": {"ref": "prj"}, + "user_ref": {"ref": "user"}, + "role": "developer", + } + ], + } + } + }, + ) + assert resp.status_code == 200, resp.text + account = resp.json()["accounts"]["u"] + return { + "email": email, + "credentials": f"ApiKey {account['api_keys']['key']}", + } + + +def _delete_account_by_email(admin_api, *, email): + resp = admin_api( + "DELETE", + "/admin/simple/accounts/", + json={"accounts": {"u": {"user": {"email": email}}}, "confirm": "delete"}, + ) + assert resp.status_code == 204, resp.text + + +@pytest.fixture(scope="class") +def connections_api(admin_api, ag_env): + account = _create_developer_business_account(admin_api) + + def _request(method: str, endpoint: str, **kwargs): + headers = kwargs.pop("headers", {}) + headers.setdefault("Authorization", account["credentials"]) + return requests.request( + method=method, + url=f"{ag_env['api_url']}{endpoint}", + headers=headers, + timeout=BASE_TIMEOUT, + **kwargs, + ) + + yield _request + + _delete_account_by_email(admin_api, email=account["email"]) + + +class TestTriggersConnectionsQuery: + def test_query_connections_returns_200(self, connections_api): + response = connections_api("POST", "/triggers/connections/query") + assert response.status_code == 200 + + def test_query_connections_response_shape(self, connections_api): + body = connections_api("POST", "/triggers/connections/query").json() + assert "count" in body + assert "connections" in body + assert isinstance(body["connections"], list) + assert body["count"] == len(body["connections"]) + + +class TestTriggersConnectionsGet: + def test_get_unknown_connection_returns_404(self, connections_api): + response = connections_api("GET", f"/triggers/connections/{uuid4()}") + assert response.status_code == 404 + + +@_requires_composio +class TestTriggersConnectionsLifecycle: + def test_create_revoke_roundtrip(self, connections_api): + slug = f"acc-{uuid4().hex[:8]}" + create = connections_api( + "POST", + "/triggers/connections/", + json={ + "connection": { + "slug": slug, + "provider_key": "composio", + "integration_key": "github", + "data": {"auth_scheme": "oauth"}, + } + }, + ) + assert create.status_code == 200, create.text + connection_id = create.json()["connection"]["id"] + + revoke = connections_api( + "POST", f"/triggers/connections/{connection_id}/revoke" + ) + assert revoke.status_code == 200, revoke.text + assert revoke.json()["connection"]["flags"]["is_valid"] is False + + delete = connections_api("DELETE", f"/triggers/connections/{connection_id}") + assert delete.status_code == 204, delete.text + + +@_requires_composio +class TestConnectionsCrossVisibility: + """The two surfaces are independent but share rows: a connection made on one + side appears on the other, and is manageable from either.""" + + def test_created_on_triggers_is_visible_on_tools(self, connections_api): + slug = f"acc-{uuid4().hex[:8]}" + create = connections_api( + "POST", + "/triggers/connections/", + json={ + "connection": { + "slug": slug, + "provider_key": "composio", + "integration_key": "github", + "data": {"auth_scheme": "oauth"}, + } + }, + ) + assert create.status_code == 200, create.text + connection_id = create.json()["connection"]["id"] + + tools_ids = [ + c["id"] + for c in connections_api("POST", "/tools/connections/query").json()[ + "connections" + ] + ] + assert connection_id in tools_ids + + fetched = connections_api("GET", f"/tools/connections/{connection_id}") + assert fetched.status_code == 200, fetched.text + + delete = connections_api("DELETE", f"/tools/connections/{connection_id}") + assert delete.status_code == 204, delete.text + + def test_created_on_tools_is_visible_on_triggers(self, connections_api): + slug = f"acc-{uuid4().hex[:8]}" + create = connections_api( + "POST", + "/tools/connections/", + json={ + "connection": { + "slug": slug, + "provider_key": "composio", + "integration_key": "github", + "data": {"auth_scheme": "oauth"}, + } + }, + ) + assert create.status_code == 200, create.text + connection_id = create.json()["connection"]["id"] + + trigger_ids = [ + c["id"] + for c in connections_api("POST", "/triggers/connections/query").json()[ + "connections" + ] + ] + assert connection_id in trigger_ids + + fetched = connections_api("GET", f"/triggers/connections/{connection_id}") + assert fetched.status_code == 200, fetched.text + + delete = connections_api("DELETE", f"/triggers/connections/{connection_id}") + assert delete.status_code == 204, delete.text diff --git a/api/ee/tests/pytest/acceptance/triggers/test_triggers_subscriptions.py b/api/ee/tests/pytest/acceptance/triggers/test_triggers_subscriptions.py index d68b42acaa..37c71418a3 100644 --- a/api/ee/tests/pytest/acceptance/triggers/test_triggers_subscriptions.py +++ b/api/ee/tests/pytest/acceptance/triggers/test_triggers_subscriptions.py @@ -28,6 +28,13 @@ reason="needs live Composio credentials (COMPOSIO_API_KEY)", ) +# Minting a trigger instance needs an ACTIVE connected account, which a stub +# OAuth connection never reaches in CI (no interactive auth). +_requires_connected_account = pytest.mark.skipif( + not os.getenv("COMPOSIO_TEST_CONNECTED_ACCOUNT"), + reason="needs COMPOSIO_TEST_CONNECTED_ACCOUNT (an ACTIVE connected account)", +) + def _create_developer_business_account(admin_api): uid = uuid4().hex[:12] @@ -161,6 +168,7 @@ def test_fetch_unknown_delivery_returns_404(self, triggers_api): @_requires_composio +@_requires_connected_account class TestTriggerSubscriptionsLifecycle: def _create_connection(self, triggers_api): slug = f"acc-{uuid4().hex[:8]}" @@ -191,8 +199,8 @@ def test_create_list_disable_delete_keeps_connection(self, triggers_api): "connection_id": connection_id, "data": { "event_key": "GITHUB_STAR_ADDED_EVENT", - "trigger_config": {}, - "inputs_fields": {"repo": "$.event.data.repository"}, + "trigger_config": {"owner": "acme", "repo": "widgets"}, + "inputs_fields": {"repo": "$.event.attributes.repository"}, "references": {"workflow": {"slug": "triage"}}, }, } diff --git a/api/entrypoints/dispatcher_composio.py b/api/entrypoints/dispatcher_composio.py new file mode 100644 index 0000000000..fe8a832bde --- /dev/null +++ b/api/entrypoints/dispatcher_composio.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Dev-only Composio→Agenta event bridge (the `stripe listen` equivalent). + +Composio has no CLI tunnel, so for local dev this subscribes to trigger events +over Composio's WebSocket (``composio.triggers.subscribe()``) and forwards each +one to the local ingress, signed with the same webhook secret the API verifies +against — so the real HMAC path is exercised, not bypassed. + +Per-dev isolation needs nothing here: the API drops any event whose ``ti_*`` is +not in this environment's DB, so each dev only processes their own instances. + +Usage (host): + set -a; source hosting/docker-compose/ee/.env.ee.dev; set +a + AGENTA_INGRESS_URL=http://localhost/api/triggers/composio/events/ \ + python api/entrypoints/dispatcher_composio.py + +In docker-compose it runs as the `triggers-bridge` service (profile +`with-tunnel`, on by default; disable with `run.sh --no-tunnel`) and forwards to +http://api:8000/triggers/composio/events/. +""" + +import hashlib +import hmac +import json +import os +import sys +import time +import uuid + +import httpx +from composio import Composio + +INGRESS_URL = os.getenv( + "AGENTA_INGRESS_URL", "http://api:8000/triggers/composio/events/" +) +COMPOSIO_API_URL = os.getenv( + "COMPOSIO_API_URL", "https://backend.composio.dev/api/v3" +).rstrip("/") + + +def _webhook_secret(api_key: str) -> str: + """Wait for the API to register the webhook, then return its secret.""" + headers = {"x-api-key": api_key, "Content-Type": "application/json"} + with httpx.Client(timeout=20, base_url=COMPOSIO_API_URL) as c: + while True: + try: + r = c.get("/webhook_subscriptions", headers=headers) + r.raise_for_status() + items = r.json().get("items", []) + if items: + return items[0]["secret"] + except Exception as e: # noqa: BLE001 + print(f"[bridge] waiting for webhook subscription: {e}") + print("[bridge] no webhook subscription yet — waiting for the API…") + time.sleep(5) + + +def _sign(secret: str, webhook_id: str, timestamp: str, body: bytes) -> str: + signed = f"{webhook_id}.{timestamp}.{body.decode('utf-8', errors='replace')}" + return hmac.new(secret.encode(), signed.encode(), hashlib.sha256).hexdigest() + + +def main() -> int: + api_key = os.getenv("COMPOSIO_API_KEY") + if not api_key: + sys.exit("COMPOSIO_API_KEY not set.") + + secret = _webhook_secret(api_key) + composio = Composio(api_key=api_key) + forward = httpx.Client(timeout=20) + + print(f"[bridge] forwarding Composio events → {INGRESS_URL}") + subscription = composio.triggers.subscribe() + + @subscription.handle() + def _on_event(data) -> None: # noqa: ANN001 + md = dict(data.get("metadata") or {}) + trigger_id = md.get("trigger_id") or md.get("id") or data.get("id") + event_id = f"evt_{uuid.uuid4().hex}" + md["trigger_id"] = trigger_id + md["id"] = event_id + envelope = {**data, "metadata": md} + + print(f"[bridge] event {trigger_id} {event_id}:") + print(json.dumps(envelope, default=str, indent=2)) + + body = json.dumps(envelope, default=str).encode() + timestamp = str(int(time.time())) + headers = { + "Content-Type": "application/json", + "webhook-id": event_id, + "webhook-timestamp": timestamp, + "webhook-signature": _sign(secret, event_id, timestamp, body), + } + try: + resp = forward.post(INGRESS_URL, content=body, headers=headers) + print(f"[bridge] {trigger_id} {event_id} → {resp.status_code}") + except Exception as e: # noqa: BLE001 + print(f"[bridge] forward failed: {e}") + + subscription.wait_forever() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/api/entrypoints/routers.py b/api/entrypoints/routers.py index 800abd074d..4aa1418c31 100644 --- a/api/entrypoints/routers.py +++ b/api/entrypoints/routers.py @@ -140,6 +140,9 @@ ) from oss.src.core.gateway.connections.registry import ConnectionsGatewayRegistry from oss.src.core.gateway.connections.service import ConnectionsService +from oss.src.core.gateway.catalog.providers.composio import ComposioCatalogAdapter +from oss.src.core.gateway.catalog.registry import CatalogGatewayRegistry +from oss.src.core.gateway.catalog.service import CatalogService from oss.src.core.tools.providers.composio import ComposioToolsAdapter from oss.src.core.tools.registry import ToolsGatewayRegistry from oss.src.core.tools.service import ToolsService @@ -219,6 +222,15 @@ async def lifespan(*args, **kwargs): await _triggers_broker.startup() + # Best-effort: ingestion re-resolves on demand if this fails. + if env.composio.enabled: + try: + await triggers_service.ensure_webhook_registered() + except Exception as e: # noqa: BLE001 + log.warning( + "Composio trigger webhook registration failed at startup: %s", e + ) + yield await _triggers_broker.shutdown() @@ -619,6 +631,22 @@ async def lifespan(*args, **kwargs): adapter_registry=connections_adapter_registry, ) +# Shared catalog adapter + service (providers + integrations; tools AND triggers) +_composio_catalog_adapters = {} +if env.composio.enabled: + _composio_catalog_adapters["composio"] = ComposioCatalogAdapter( + api_key=env.composio.api_key, # type: ignore[arg-type] # guarded by .enabled + api_url=env.composio.api_url, + ) + +catalog_adapter_registry = CatalogGatewayRegistry( + adapters=_composio_catalog_adapters, +) + +catalog_service = CatalogService( + adapter_registry=catalog_adapter_registry, +) + # Tools adapter + service _composio_adapters = {} if env.composio.enabled: @@ -635,6 +663,7 @@ async def lifespan(*args, **kwargs): tools_service = ToolsService( connections_service=connections_service, + catalog_service=catalog_service, adapter_registry=tools_adapter_registry, ) @@ -654,8 +683,10 @@ async def lifespan(*args, **kwargs): triggers_service = TriggersService( adapter_registry=triggers_adapter_registry, + catalog_service=catalog_service, triggers_dao=triggers_dao, connections_service=connections_service, + workflows_service=workflows_service, ) # Producer side of the inbound dispatch pipeline: the ingress route enqueues diff --git a/api/oss/src/apis/fastapi/tools/models.py b/api/oss/src/apis/fastapi/tools/models.py index 3dab664ab2..891b276c22 100644 --- a/api/oss/src/apis/fastapi/tools/models.py +++ b/api/oss/src/apis/fastapi/tools/models.py @@ -2,10 +2,6 @@ from pydantic import BaseModel -from oss.src.core.gateway.connections.dtos import ( - Connection, - ConnectionCreate, -) from oss.src.core.tools.dtos import ( # Tool Catalog ToolCatalogAction, @@ -14,6 +10,9 @@ ToolCatalogIntegrationDetails, ToolCatalogProvider, ToolCatalogProviderDetails, + # Tool Connections + ToolConnection, + ToolConnectionCreate, # Tool Calls ToolResult, ) @@ -68,17 +67,17 @@ class ToolCatalogActionsResponse(BaseModel): class ToolConnectionCreateRequest(BaseModel): - connection: ConnectionCreate + connection: ToolConnectionCreate class ToolConnectionResponse(BaseModel): count: int = 0 - connection: Optional[Connection] = None + connection: Optional[ToolConnection] = None class ToolConnectionsResponse(BaseModel): count: int = 0 - connections: List[Connection] = [] + connections: List[ToolConnection] = [] # --------------------------------------------------------------------------- diff --git a/api/oss/src/apis/fastapi/tools/router.py b/api/oss/src/apis/fastapi/tools/router.py index 32b68d49eb..5c43006cfa 100644 --- a/api/oss/src/apis/fastapi/tools/router.py +++ b/api/oss/src/apis/fastapi/tools/router.py @@ -67,7 +67,13 @@ def handle_adapter_exceptions(): - """Map unknown providers to 404 and upstream 401 failures to 424.""" + """Map provider/adapter failures to HTTP, surfacing the upstream detail. + + Unknown providers → 404. Any upstream failure (Composio 4xx such as a + rejected argument set, or a malformed response) → 424 carrying the + provider's own message so the client can show it instead of a generic 500. + A true upstream 5xx → 502. + """ def decorator(func): @wraps(func) @@ -80,17 +86,22 @@ async def wrapper(*args, **kwargs): detail=str(e), ) from e except AdapterError as e: + detail = e.detail or e.message cause = e.__cause__ - if not ( - isinstance(cause, httpx.HTTPStatusError) + upstream_status = ( + cause.response.status_code + if isinstance(cause, httpx.HTTPStatusError) and cause.response is not None - and cause.response.status_code == status.HTTP_401_UNAUTHORIZED - ): - raise - + else None + ) + if upstream_status is not None and upstream_status >= 500: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=detail, + ) from e raise HTTPException( status_code=status.HTTP_424_FAILED_DEPENDENCY, - detail=e.message, + detail=detail, ) from e return wrapper diff --git a/api/oss/src/apis/fastapi/triggers/models.py b/api/oss/src/apis/fastapi/triggers/models.py index 9e13dd38f4..f6fd8f0815 100644 --- a/api/oss/src/apis/fastapi/triggers/models.py +++ b/api/oss/src/apis/fastapi/triggers/models.py @@ -6,7 +6,10 @@ from oss.src.core.triggers.dtos import ( TriggerCatalogEvent, TriggerCatalogEventDetails, + TriggerCatalogIntegration, TriggerCatalogProvider, + TriggerConnection, + TriggerConnectionCreate, TriggerDelivery, TriggerDeliveryQuery, TriggerSubscription, @@ -17,7 +20,8 @@ # --------------------------------------------------------------------------- -# Trigger Catalog +# Trigger Catalog — providers + integrations are SHARED (gateway/catalog); +# events are the trigger-specific leaf. # --------------------------------------------------------------------------- @@ -31,6 +35,18 @@ class TriggerCatalogProvidersResponse(BaseModel): providers: List[TriggerCatalogProvider] = Field(default_factory=list) +class TriggerCatalogIntegrationResponse(BaseModel): + count: int = 0 + integration: Optional[TriggerCatalogIntegration] = None + + +class TriggerCatalogIntegrationsResponse(BaseModel): + count: int = 0 + total: int = 0 + cursor: Optional[str] = None + integrations: List[TriggerCatalogIntegration] = Field(default_factory=list) + + class TriggerCatalogEventResponse(BaseModel): count: int = 0 event: Optional[TriggerCatalogEventDetails] = None @@ -43,6 +59,29 @@ class TriggerCatalogEventsResponse(BaseModel): events: List[TriggerCatalogEvent] = Field(default_factory=list) +# --------------------------------------------------------------------------- +# Trigger Connections +# +# Connections are shared `gateway_connections` rows; triggers exposes an +# independent `/triggers/connections/*` surface over the SAME ConnectionsService +# that tools uses, so a connection made from either side is visible from both. +# --------------------------------------------------------------------------- + + +class TriggerConnectionCreateRequest(BaseModel): + connection: TriggerConnectionCreate + + +class TriggerConnectionResponse(BaseModel): + count: int = 0 + connection: Optional[TriggerConnection] = None + + +class TriggerConnectionsResponse(BaseModel): + count: int = 0 + connections: List[TriggerConnection] = Field(default_factory=list) + + # --------------------------------------------------------------------------- # Trigger Subscriptions # --------------------------------------------------------------------------- diff --git a/api/oss/src/apis/fastapi/triggers/router.py b/api/oss/src/apis/fastapi/triggers/router.py index 1bb1d66f80..6cdecc1175 100644 --- a/api/oss/src/apis/fastapi/triggers/router.py +++ b/api/oss/src/apis/fastapi/triggers/router.py @@ -1,5 +1,3 @@ -import hashlib -import hmac from functools import wraps from json import JSONDecodeError, loads from typing import Any, Optional @@ -13,13 +11,17 @@ from oss.src.utils.logging import get_module_logger from oss.src.utils.caching import get_cache, set_cache from oss.src.utils.common import is_ee -from oss.src.utils.env import env from oss.src.apis.fastapi.triggers.models import ( TriggerCatalogEventResponse, TriggerCatalogEventsResponse, + TriggerCatalogIntegrationResponse, + TriggerCatalogIntegrationsResponse, TriggerCatalogProviderResponse, TriggerCatalogProvidersResponse, + TriggerConnectionCreateRequest, + TriggerConnectionResponse, + TriggerConnectionsResponse, TriggerDeliveriesResponse, TriggerDeliveryQueryRequest, TriggerDeliveryResponse, @@ -50,7 +52,13 @@ def handle_adapter_exceptions(): - """Map unknown providers to 404 and upstream 401 failures to 424.""" + """Map provider/adapter failures to HTTP, surfacing the upstream detail. + + Unknown providers → 404. Any upstream failure (Composio 4xx such as a + rejected ``trigger_config``, or a malformed response) → 424 carrying the + provider's own message so the client can show it instead of a generic 500. + A true upstream 5xx → 502. + """ def decorator(func): @wraps(func) @@ -63,17 +71,22 @@ async def wrapper(*args, **kwargs): detail=str(e), ) from e except AdapterError as e: + detail = e.detail or e.message cause = e.__cause__ - if not ( - isinstance(cause, httpx.HTTPStatusError) + upstream_status = ( + cause.response.status_code + if isinstance(cause, httpx.HTTPStatusError) and cause.response is not None - and cause.response.status_code == status.HTTP_401_UNAUTHORIZED - ): - raise - + else None + ) + if upstream_status is not None and upstream_status >= 500: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=detail, + ) from e raise HTTPException( status_code=status.HTTP_424_FAILED_DEPENDENCY, - detail=e.message, + detail=detail, ) from e return wrapper @@ -81,36 +94,6 @@ async def wrapper(*args, **kwargs): return decorator -def _verify_composio_signature( - *, - body: bytes, - headers: Any, -) -> bool: - """HMAC-SHA256 verify over ``{id}.{ts}.{body}`` with ``COMPOSIO_WEBHOOK_SECRET``. - - Returns True when the secret is unset (no-op) or the signature matches. - """ - secret = env.composio.webhook_secret - if not secret: - return True - - signature = headers.get("webhook-signature") or headers.get("x-composio-signature") - webhook_id = headers.get("webhook-id") or "" - timestamp = headers.get("webhook-timestamp") or "" - if not signature: - return False - - signed = f"{webhook_id}.{timestamp}.{body.decode('utf-8', errors='replace')}" - expected = hmac.new( - secret.encode("utf-8"), - signed.encode("utf-8"), - hashlib.sha256, - ).hexdigest() - - provided = signature.split(",")[-1].strip() - return hmac.compare_digest(expected, provided) - - class TriggersRouter: def __init__( self, @@ -125,7 +108,7 @@ def __init__( # --- Trigger Ingress (inbound provider events) --- self.router.add_api_route( - "/composio/events", + "/composio/events/", self.ingest_composio_event, methods=["POST"], operation_id="ingest_composio_event", @@ -150,6 +133,22 @@ def __init__( response_model=TriggerCatalogProviderResponse, response_model_exclude_none=True, ) + self.router.add_api_route( + "/catalog/providers/{provider_key}/integrations/", + self.list_integrations, + methods=["GET"], + operation_id="list_trigger_integrations", + response_model=TriggerCatalogIntegrationsResponse, + response_model_exclude_none=True, + ) + self.router.add_api_route( + "/catalog/providers/{provider_key}/integrations/{integration_key}", + self.get_integration, + methods=["GET"], + operation_id="fetch_trigger_integration", + response_model=TriggerCatalogIntegrationResponse, + response_model_exclude_none=True, + ) self.router.add_api_route( "/catalog/providers/{provider_key}/integrations/{integration_key}/events/", self.list_events, @@ -167,6 +166,56 @@ def __init__( response_model_exclude_none=True, ) + # --- Trigger Connections --- + # Shared `gateway_connections` rows; independent surface from tools. + self.router.add_api_route( + "/connections/query", + self.query_connections, + methods=["POST"], + operation_id="query_trigger_connections", + response_model=TriggerConnectionsResponse, + response_model_exclude_none=True, + ) + self.router.add_api_route( + "/connections/", + self.create_connection, + methods=["POST"], + operation_id="create_trigger_connection", + response_model=TriggerConnectionResponse, + response_model_exclude_none=True, + ) + self.router.add_api_route( + "/connections/{connection_id}", + self.get_connection, + methods=["GET"], + operation_id="fetch_trigger_connection", + response_model=TriggerConnectionResponse, + response_model_exclude_none=True, + ) + self.router.add_api_route( + "/connections/{connection_id}", + self.delete_connection, + methods=["DELETE"], + operation_id="delete_trigger_connection", + status_code=status.HTTP_204_NO_CONTENT, + ) + self.router.add_api_route( + "/connections/{connection_id}/refresh", + self.refresh_connection, + methods=["POST"], + operation_id="refresh_trigger_connection", + response_model=TriggerConnectionResponse, + response_model_exclude_none=True, + ) + self.router.add_api_route( + "/connections/{connection_id}/revoke", + self.revoke_connection, + methods=["POST"], + operation_id="revoke_trigger_connection", + response_model=TriggerConnectionResponse, + response_model_exclude_none=True, + ) + # --- Trigger Subscriptions --- self.router.add_api_route( "/subscriptions/", @@ -268,6 +317,193 @@ def __init__( status_code=status.HTTP_200_OK, ) + # ----------------------------------------------------------------------- + # Trigger Connections + # + # Independent surface over the SAME shared ConnectionsService that tools + # uses; both read/write the `gateway_connections` rows, so a connection + # made from either side is visible from both. The OAuth callback stays on + # `/tools/connections/callback` by design (shared public contract). + # ----------------------------------------------------------------------- + + @intercept_exceptions() + async def query_connections( + self, + request: Request, + *, + provider_key: Optional[str] = Query(default=None), + integration_key: Optional[str] = Query(default=None), + ) -> TriggerConnectionsResponse: + if is_ee(): + has_permission = await check_action_access( + user_uid=request.state.user_id, + project_id=request.state.project_id, + permission=Permission.VIEW_TRIGGERS, + ) + if not has_permission: + raise FORBIDDEN_EXCEPTION + + connections = await self.triggers_service.query_connections( + project_id=UUID(request.state.project_id), + provider_key=provider_key, + integration_key=integration_key, + ) + return TriggerConnectionsResponse( + count=len(connections), + connections=connections, + ) + + @intercept_exceptions() + async def create_connection( + self, + request: Request, + *, + body: TriggerConnectionCreateRequest, + ) -> TriggerConnectionResponse: + if is_ee(): + has_permission = await check_action_access( + user_uid=request.state.user_id, + project_id=request.state.project_id, + permission=Permission.EDIT_TRIGGERS, + ) + if not has_permission: + raise FORBIDDEN_EXCEPTION + + slug = body.connection.slug + if "." in slug or "__" in slug: + return JSONResponse( + status_code=422, + content={ + "detail": ( + "Connection slug must not contain dots or " + "consecutive underscores. " + "Use single hyphens or underscores as separators." + ) + }, + ) + + if isinstance(body.connection.data, dict): + body.connection.data = { + k: v + for k, v in body.connection.data.items() + if k not in {"callback_url", "auth_scheme"} + } or None + + connection = await self.triggers_service.create_connection( + project_id=UUID(request.state.project_id), + user_id=UUID(request.state.user_id), + # + connection_create=body.connection, + ) + + return TriggerConnectionResponse( + count=1, + connection=connection, + ) + + @intercept_exceptions() + async def get_connection( + self, + request: Request, + connection_id: UUID, + ) -> TriggerConnectionResponse: + if is_ee(): + has_permission = await check_action_access( + user_uid=request.state.user_id, + project_id=request.state.project_id, + permission=Permission.VIEW_TRIGGERS, + ) + if not has_permission: + raise FORBIDDEN_EXCEPTION + + connection = await self.triggers_service.get_connection( + project_id=UUID(request.state.project_id), + connection_id=connection_id, + ) + if not connection: + return JSONResponse( + status_code=404, + content={"detail": "Connection not found"}, + ) + + return TriggerConnectionResponse( + count=1, + connection=connection, + ) + + @intercept_exceptions() + async def delete_connection( + self, + request: Request, + connection_id: UUID, + ) -> None: + if is_ee(): + has_permission = await check_action_access( + user_uid=request.state.user_id, + project_id=request.state.project_id, + permission=Permission.EDIT_TRIGGERS, + ) + if not has_permission: + raise FORBIDDEN_EXCEPTION + + await self.triggers_service.delete_connection( + project_id=UUID(request.state.project_id), + connection_id=connection_id, + ) + + @intercept_exceptions() + async def refresh_connection( + self, + request: Request, + connection_id: UUID, + *, + force: bool = Query(default=False), + ) -> TriggerConnectionResponse: + if is_ee(): + has_permission = await check_action_access( + user_uid=request.state.user_id, + project_id=request.state.project_id, + permission=Permission.EDIT_TRIGGERS, + ) + if not has_permission: + raise FORBIDDEN_EXCEPTION + + connection = await self.triggers_service.refresh_connection( + project_id=UUID(request.state.project_id), + connection_id=connection_id, + force=force, + ) + + return TriggerConnectionResponse( + count=1, + connection=connection, + ) + + @intercept_exceptions() + async def revoke_connection( + self, + request: Request, + connection_id: UUID, + ) -> TriggerConnectionResponse: + if is_ee(): + has_permission = await check_action_access( + user_uid=request.state.user_id, + project_id=request.state.project_id, + permission=Permission.EDIT_TRIGGERS, + ) + if not has_permission: + raise FORBIDDEN_EXCEPTION + + connection = await self.triggers_service.revoke_connection( + project_id=UUID(request.state.project_id), + connection_id=connection_id, + ) + + return TriggerConnectionResponse( + count=1, + connection=connection, + ) + # ----------------------------------------------------------------------- # Trigger Catalog # ----------------------------------------------------------------------- @@ -364,6 +600,128 @@ async def get_provider( return response + @intercept_exceptions() + @handle_adapter_exceptions() + async def list_integrations( + self, + request: Request, + provider_key: str, + *, + search: Optional[str] = Query(default=None), + sort_by: Optional[str] = Query(default=None), + limit: Optional[int] = Query(default=None), + cursor: Optional[str] = Query(default=None), + ) -> TriggerCatalogIntegrationsResponse: + if is_ee(): + has_permission = await check_action_access( + user_uid=request.state.user_id, + project_id=request.state.project_id, + permission=Permission.VIEW_TRIGGERS, + ) + if not has_permission: + raise FORBIDDEN_EXCEPTION + + cache_key = { + "provider_key": provider_key, + "search": search, + "sort_by": sort_by, + "limit": limit, + "cursor": cursor, + } + cached = await get_cache( + project_id=None, + namespace="triggers:catalog:integrations", + key=cache_key, + model=TriggerCatalogIntegrationsResponse, + ) + if cached: + return cached + + ( + integrations, + next_cursor, + total, + ) = await self.triggers_service.list_integrations( + provider_key=provider_key, + search=search, + sort_by=sort_by, + limit=limit, + cursor=cursor, + ) + items = list(integrations) + + response = TriggerCatalogIntegrationsResponse( + count=len(items), + total=total, + cursor=next_cursor, + integrations=items, + ) + + await set_cache( + project_id=None, + namespace="triggers:catalog:integrations", + key=cache_key, + value=response, + ttl=5 * 60, + ) + + return response + + @intercept_exceptions() + @handle_adapter_exceptions() + async def get_integration( + self, + request: Request, + provider_key: str, + integration_key: str, + ) -> TriggerCatalogIntegrationResponse: + if is_ee(): + has_permission = await check_action_access( + user_uid=request.state.user_id, + project_id=request.state.project_id, + permission=Permission.VIEW_TRIGGERS, + ) + if not has_permission: + raise FORBIDDEN_EXCEPTION + + cache_key = { + "provider_key": provider_key, + "integration_key": integration_key, + } + cached = await get_cache( + project_id=None, + namespace="triggers:catalog:integration", + key=cache_key, + model=TriggerCatalogIntegrationResponse, + ) + if cached: + return cached + + integration = await self.triggers_service.get_integration( + provider_key=provider_key, + integration_key=integration_key, + ) + if not integration: + return JSONResponse( + status_code=404, + content={"detail": "Integration not found"}, + ) + + response = TriggerCatalogIntegrationResponse( + count=1, + integration=integration, + ) + + await set_cache( + project_id=None, + namespace="triggers:catalog:integration", + key=cache_key, + value=response, + ttl=5 * 60, + ) + + return response + @intercept_exceptions() @handle_adapter_exceptions() async def list_events( @@ -775,7 +1133,9 @@ async def ingest_composio_event( """ body = await request.body() - if not _verify_composio_signature(body=body, headers=request.headers): + if not await self.triggers_service.verify_signature( + body=body, headers=request.headers + ): return JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, content={"status": "error", "detail": "Signature verification failed"}, diff --git a/api/oss/src/core/gateway/catalog/__init__.py b/api/oss/src/core/gateway/catalog/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/src/core/gateway/catalog/dtos.py b/api/oss/src/core/gateway/catalog/dtos.py new file mode 100644 index 0000000000..858200ccce --- /dev/null +++ b/api/oss/src/core/gateway/catalog/dtos.py @@ -0,0 +1,46 @@ +"""Shared catalog DTOs for the gateway. + +Providers and integrations are shared across tools and triggers (same Composio +toolkits), so they live here once and both domains consume them directly — +mirroring how `gateway/connections/dtos.py::Connection` is shared. The split +leaves (tool *actions* vs trigger *events*) stay in their own domains. +""" + +from enum import Enum +from typing import List, Optional + +from pydantic import BaseModel + + +class CatalogProviderKind(str, Enum): + COMPOSIO = "composio" + AGENTA = "agenta" + + +class CatalogAuthScheme(str, Enum): + OAUTH = "oauth" + API_KEY = "api_key" + + +class CatalogProvider(BaseModel): + key: CatalogProviderKind + # + name: str + description: Optional[str] = None + # + integrations_count: Optional[int] = None + + +class CatalogIntegration(BaseModel): + key: str + # + name: str + description: Optional[str] = None + # + categories: List[str] = [] + logo: Optional[str] = None + url: Optional[str] = None + # + actions_count: Optional[int] = None + # + auth_schemes: Optional[List[CatalogAuthScheme]] = None diff --git a/api/oss/src/core/gateway/catalog/interfaces.py b/api/oss/src/core/gateway/catalog/interfaces.py new file mode 100644 index 0000000000..3217cd0de0 --- /dev/null +++ b/api/oss/src/core/gateway/catalog/interfaces.py @@ -0,0 +1,38 @@ +from abc import ABC, abstractmethod +from typing import List, Optional, Tuple + +from oss.src.core.gateway.catalog.dtos import ( + CatalogIntegration, + CatalogProvider, +) + + +class CatalogGatewayInterface(ABC): + """Port for browsing a provider's catalog (providers + integrations). + + Shared by tools and triggers: both browse the same Composio toolkits. The + split leaves (actions for tools, events for triggers) are NOT here — each + domain owns its own leaf adapter. + """ + + @abstractmethod + async def list_providers(self) -> List[CatalogProvider]: ... + + @abstractmethod + async def list_integrations( + self, + *, + search: Optional[str] = None, + sort_by: Optional[str] = None, + limit: Optional[int] = None, + cursor: Optional[str] = None, + ) -> Tuple[List[CatalogIntegration], Optional[str], int]: + """Returns (items, next_cursor, total_items).""" + ... + + @abstractmethod + async def get_integration( + self, + *, + integration_key: str, + ) -> Optional[CatalogIntegration]: ... diff --git a/api/oss/src/core/gateway/catalog/providers/__init__.py b/api/oss/src/core/gateway/catalog/providers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/src/core/gateway/catalog/providers/composio/__init__.py b/api/oss/src/core/gateway/catalog/providers/composio/__init__.py new file mode 100644 index 0000000000..47904c03a1 --- /dev/null +++ b/api/oss/src/core/gateway/catalog/providers/composio/__init__.py @@ -0,0 +1,5 @@ +from oss.src.core.gateway.catalog.providers.composio.adapter import ( + ComposioCatalogAdapter, +) + +__all__ = ["ComposioCatalogAdapter"] diff --git a/api/oss/src/core/gateway/catalog/providers/composio/adapter.py b/api/oss/src/core/gateway/catalog/providers/composio/adapter.py new file mode 100644 index 0000000000..3c4f839218 --- /dev/null +++ b/api/oss/src/core/gateway/catalog/providers/composio/adapter.py @@ -0,0 +1,230 @@ +"""Composio catalog adapter — shared providers + integrations for the gateway. + +Backs the shared ``CatalogService`` (tools AND triggers). Implements the +provider listing and integration browse/get against Composio ``/toolkits``, +returning the shared ``Catalog*`` DTOs. The per-domain leaf reads (tool actions +/ trigger events) live in their own domain adapters and are NOT here. + +Parser logic mirrors ``core/tools/providers/composio/catalog.py`` (the prior +home of integration browse) so the wire shape is unchanged. +""" + +from typing import Any, Dict, List, Optional, Tuple + +import httpx + +from oss.src.utils.logging import get_module_logger +from oss.src.core.gateway.catalog.dtos import ( + CatalogAuthScheme, + CatalogIntegration, + CatalogProvider, +) +from oss.src.core.gateway.catalog.interfaces import CatalogGatewayInterface +from oss.src.core.gateway.connections.exceptions import AdapterError +from oss.src.core.gateway.providers.composio.errors import composio_error_detail +from oss.src.utils.env import env + + +log = get_module_logger(__name__) + +DEFAULT_PAGE_SIZE = 20 +MAX_PAGE_SIZE = 1000 + + +class ComposioCatalogAdapter(CatalogGatewayInterface): + """Composio V3 catalog adapter — cursor-based pagination over /toolkits.""" + + def __init__( + self, + *, + api_key: str, + api_url: Optional[str] = None, + ): + self.api_key = api_key + self.api_url = (api_url or env.composio.api_url).rstrip("/") + self._client = httpx.AsyncClient(timeout=30.0) + + async def close(self) -> None: + await self._client.aclose() + + def _headers(self) -> Dict[str, str]: + return {"x-api-key": self.api_key, "Content-Type": "application/json"} + + async def _count_integrations(self) -> Optional[int]: + try: + resp = await self._client.get( + f"{self.api_url}/toolkits", + headers=self._headers(), + params={"limit": 1}, + timeout=10.0, + ) + resp.raise_for_status() + data = resp.json() + except httpx.HTTPError as e: + raise AdapterError( + provider_key="composio", + operation="count_integrations", + detail=composio_error_detail(e), + ) from e + + return data.get("total_items") if isinstance(data, dict) else None + + async def list_providers(self) -> List[CatalogProvider]: + integrations_count = await self._count_integrations() + return [ + CatalogProvider( + key="composio", + name="Composio", + description="Third-party integrations via Composio", + integrations_count=integrations_count, + ) + ] + + async def get_integration( + self, + *, + integration_key: str, + ) -> Optional[CatalogIntegration]: + try: + resp = await self._client.get( + f"{self.api_url}/toolkits/{integration_key}", + headers=self._headers(), + timeout=15.0, + ) + if resp.status_code == 404: + return None + resp.raise_for_status() + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + return None + raise AdapterError( + provider_key="composio", + operation="get_integration", + detail=composio_error_detail(e), + ) from e + except httpx.HTTPError as e: + raise AdapterError( + provider_key="composio", + operation="get_integration", + detail=composio_error_detail(e), + ) from e + + return _parse_integration_detail(resp.json()) + + async def list_integrations( + self, + *, + search: Optional[str] = None, + sort_by: Optional[str] = None, + limit: Optional[int] = None, + cursor: Optional[str] = None, + ) -> Tuple[List[CatalogIntegration], Optional[str], int]: + page_limit = min(limit, MAX_PAGE_SIZE) if limit else DEFAULT_PAGE_SIZE + + params: Dict[str, Any] = {"limit": page_limit} + if search and len(search) >= 3: + params["search"] = search + if sort_by: + params["sort_by"] = sort_by + if cursor: + params["cursor"] = cursor + + try: + resp = await self._client.get( + f"{self.api_url}/toolkits", + headers=self._headers(), + params=params, + timeout=30.0, + ) + resp.raise_for_status() + data = resp.json() + except httpx.HTTPError as e: + raise AdapterError( + provider_key="composio", + operation="list_integrations", + detail=composio_error_detail(e), + ) from e + + items_raw: List[Dict[str, Any]] = ( + data.get("items", []) if isinstance(data, dict) else data + ) + next_cursor: Optional[str] = ( + data.get("next_cursor") if isinstance(data, dict) else None + ) + total_items: int = ( + data.get("total_items", len(items_raw)) + if isinstance(data, dict) + else len(items_raw) + ) + + items = [_parse_integration(item) for item in items_raw] + + return items, next_cursor, total_items + + +# --------------------------------------------------------------------------- +# Parsers +# --------------------------------------------------------------------------- + +_AUTH_SCHEME_MAP: Dict[str, CatalogAuthScheme] = { + "oauth": CatalogAuthScheme.OAUTH, + "oauth2": CatalogAuthScheme.OAUTH, + "oauth1": CatalogAuthScheme.OAUTH, + "api_key": CatalogAuthScheme.API_KEY, + "apikey": CatalogAuthScheme.API_KEY, + "api key": CatalogAuthScheme.API_KEY, +} + + +def _parse_integration(item: Dict[str, Any]) -> CatalogIntegration: + meta = item.get("meta") or {} + + auth_schemes: List[CatalogAuthScheme] = [] + for s in item.get("auth_schemes", []): + mode = (s if isinstance(s, str) else s.get("auth_mode", "")).lower() + mapped = _AUTH_SCHEME_MAP.get(mode) + if mapped and mapped not in auth_schemes: + auth_schemes.append(mapped) + + raw_cats = meta.get("categories") or [] + categories = [c["name"] if isinstance(c, dict) else str(c) for c in raw_cats if c] + + return CatalogIntegration( + key=item.get("slug", ""), + name=item.get("name", ""), + description=meta.get("description"), + logo=meta.get("logo"), + url=meta.get("app_url"), + actions_count=meta.get("tools_count"), + auth_schemes=auth_schemes or None, + categories=categories, + ) + + +def _parse_integration_detail(item: Dict[str, Any]) -> CatalogIntegration: + """Parse GET /toolkits/{slug}; auth lives in composio_managed_auth_schemes.""" + meta = item.get("meta") or {} + + auth_schemes: List[CatalogAuthScheme] = [] + for s in item.get("composio_managed_auth_schemes", []): + if isinstance(s, dict): + mode = (s.get("name") or s.get("auth_mode") or "").lower() + else: + mode = str(s).lower() + mapped = _AUTH_SCHEME_MAP.get(mode) + if mapped and mapped not in auth_schemes: + auth_schemes.append(mapped) + + raw_cats = meta.get("categories") or [] + categories = [c["name"] if isinstance(c, dict) else str(c) for c in raw_cats if c] + + return CatalogIntegration( + key=item.get("slug", ""), + name=item.get("name", ""), + description=meta.get("description"), + logo=meta.get("logo"), + url=meta.get("app_url"), + actions_count=meta.get("tools_count"), + auth_schemes=auth_schemes or None, + categories=categories, + ) diff --git a/api/oss/src/core/gateway/catalog/registry.py b/api/oss/src/core/gateway/catalog/registry.py new file mode 100644 index 0000000000..451bf69c20 --- /dev/null +++ b/api/oss/src/core/gateway/catalog/registry.py @@ -0,0 +1,27 @@ +from typing import Dict, ItemsView + +from oss.src.core.gateway.catalog.interfaces import CatalogGatewayInterface +from oss.src.core.gateway.connections.exceptions import ProviderNotFoundError + + +class CatalogGatewayRegistry: + """Dispatches to the correct catalog adapter based on provider_key.""" + + def __init__( + self, + *, + adapters: Dict[str, CatalogGatewayInterface], + ): + self._adapters = adapters + + def get(self, provider_key: str) -> CatalogGatewayInterface: + adapter = self._adapters.get(provider_key) + if not adapter: + raise ProviderNotFoundError(provider_key) + return adapter + + def keys(self) -> list[str]: + return list(self._adapters.keys()) + + def items(self) -> ItemsView[str, CatalogGatewayInterface]: + return self._adapters.items() diff --git a/api/oss/src/core/gateway/catalog/service.py b/api/oss/src/core/gateway/catalog/service.py new file mode 100644 index 0000000000..cbc415bcee --- /dev/null +++ b/api/oss/src/core/gateway/catalog/service.py @@ -0,0 +1,69 @@ +"""Shared catalog service — providers + integrations for tools AND triggers. + +Both domains browse the same provider catalog (Composio toolkits), so the read +logic lives here once and each router calls it. The leaf reads (tool actions / +trigger events) stay in their own domain services. +""" + +from typing import List, Optional, Tuple + +from oss.src.core.gateway.catalog.dtos import ( + CatalogIntegration, + CatalogProvider, +) +from oss.src.core.gateway.catalog.registry import CatalogGatewayRegistry + + +class CatalogService: + def __init__( + self, + *, + adapter_registry: CatalogGatewayRegistry, + ): + self.adapter_registry = adapter_registry + + async def list_providers(self) -> List[CatalogProvider]: + results: List[CatalogProvider] = [] + for _key, adapter in self.adapter_registry.items(): + providers = await adapter.list_providers() + results.extend(providers) + return results + + async def get_provider( + self, + *, + provider_key: str, + ) -> Optional[CatalogProvider]: + adapter = self.adapter_registry.get(provider_key) + providers = await adapter.list_providers() + for p in providers: + if p.key == provider_key: + return p + return None + + async def list_integrations( + self, + *, + provider_key: str, + # + search: Optional[str] = None, + sort_by: Optional[str] = None, + limit: Optional[int] = None, + cursor: Optional[str] = None, + ) -> Tuple[List[CatalogIntegration], Optional[str], int]: + adapter = self.adapter_registry.get(provider_key) + return await adapter.list_integrations( + search=search, + sort_by=sort_by, + limit=limit, + cursor=cursor, + ) + + async def get_integration( + self, + *, + provider_key: str, + integration_key: str, + ) -> Optional[CatalogIntegration]: + adapter = self.adapter_registry.get(provider_key) + return await adapter.get_integration(integration_key=integration_key) diff --git a/api/oss/src/core/gateway/connections/providers/composio/adapter.py b/api/oss/src/core/gateway/connections/providers/composio/adapter.py index 6f9cbe67c0..3f2e91af60 100644 --- a/api/oss/src/core/gateway/connections/providers/composio/adapter.py +++ b/api/oss/src/core/gateway/connections/providers/composio/adapter.py @@ -10,12 +10,12 @@ ) from oss.src.core.gateway.connections.interfaces import ConnectionsGatewayInterface from oss.src.core.gateway.connections.exceptions import AdapterError +from oss.src.core.gateway.providers.composio.errors import composio_error_detail +from oss.src.utils.env import env log = get_module_logger(__name__) -COMPOSIO_DEFAULT_API_URL = "https://backend.composio.dev/api/v3" - class ComposioConnectionsAdapter(ConnectionsGatewayInterface): """Composio V3 connection auth adapter — uses httpx directly (no SDK). @@ -29,10 +29,10 @@ def __init__( self, *, api_key: str, - api_url: str = COMPOSIO_DEFAULT_API_URL, + api_url: Optional[str] = None, ): self.api_key = api_key - self.api_url = api_url.rstrip("/") + self.api_url = (api_url or env.composio.api_url).rstrip("/") # Shared client — one connection pool for the adapter's lifetime. # Call close() on shutdown (wired in entrypoints/routers.py lifespan). self._client = httpx.AsyncClient(timeout=30.0) @@ -117,13 +117,13 @@ async def initiate_connection( raise AdapterError( provider_key="composio", operation="initiate_connection.validate_toolkit", - detail=str(e), + detail=composio_error_detail(e), ) from e except httpx.HTTPError as e: raise AdapterError( provider_key="composio", operation="initiate_connection.validate_toolkit", - detail=str(e), + detail=composio_error_detail(e), ) from e # Step 2: create an auth config for this integration. @@ -169,7 +169,7 @@ async def initiate_connection( raise AdapterError( provider_key="composio", operation="initiate_connection.create_auth_config", - detail=str(e), + detail=composio_error_detail(e), ) from e auth_config_id = (auth_config_result.get("auth_config") or {}).get("id") @@ -200,7 +200,7 @@ async def initiate_connection( raise AdapterError( provider_key="composio", operation="initiate_connection", - detail=str(e), + detail=composio_error_detail(e), ) from e provider_connection_id = result.get("connected_account_id", "") @@ -230,7 +230,7 @@ async def get_connection_status( raise AdapterError( provider_key="composio", operation="get_connection_status", - detail=str(e), + detail=composio_error_detail(e), ) from e return { @@ -278,7 +278,7 @@ async def refresh_connection( raise AdapterError( provider_key="composio", operation="refresh_connection", - detail=str(e), + detail=composio_error_detail(e), ) from e return { @@ -298,5 +298,5 @@ async def revoke_connection( raise AdapterError( provider_key="composio", operation="revoke_connection", - detail=str(e), + detail=composio_error_detail(e), ) from e diff --git a/api/oss/src/core/gateway/providers/__init__.py b/api/oss/src/core/gateway/providers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/src/core/gateway/providers/composio/__init__.py b/api/oss/src/core/gateway/providers/composio/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/src/core/gateway/providers/composio/errors.py b/api/oss/src/core/gateway/providers/composio/errors.py new file mode 100644 index 0000000000..0ce48d083e --- /dev/null +++ b/api/oss/src/core/gateway/providers/composio/errors.py @@ -0,0 +1,23 @@ +import httpx + + +def composio_error_detail(e: httpx.HTTPError) -> str: + """Best-effort human-readable detail from a Composio HTTP error. + + Composio returns ``{"error": {"message": ...}}`` on 4xx; surface that so the + real cause (e.g. mutually-exclusive fields) reaches the client instead of a + bare ``400 Bad Request``. + """ + response = getattr(e, "response", None) + if response is not None: + try: + body = response.json() + err = body.get("error") if isinstance(body, dict) else None + if isinstance(err, dict) and err.get("message"): + return str(err["message"]) + if response.text: + return response.text + except Exception: + if response.text: + return response.text + return str(e) diff --git a/api/oss/src/core/tools/dtos.py b/api/oss/src/core/tools/dtos.py index 2c1ac2bf82..d6c531c3a5 100644 --- a/api/oss/src/core/tools/dtos.py +++ b/api/oss/src/core/tools/dtos.py @@ -4,6 +4,14 @@ from agenta.sdk.models.workflows import JsonSchemas from pydantic import BaseModel +from oss.src.core.gateway.catalog.dtos import ( + CatalogIntegration, + CatalogProvider, +) +from oss.src.core.gateway.connections.dtos import ( + Connection, + ConnectionCreate, +) from oss.src.core.shared.dtos import ( Identifier, Json, @@ -48,39 +56,39 @@ class ToolCatalogActionDetails(ToolCatalogAction): scopes: Optional[List[str]] = None -class ToolCatalogIntegration(BaseModel): - key: str - # - name: str - description: Optional[str] = None - # - categories: List[str] = [] - logo: Optional[str] = None - url: Optional[str] = None - # - actions_count: Optional[int] = None - # - auth_schemes: Optional[List[ToolAuthScheme]] = None +# Providers + integrations are SHARED across tools and triggers — defined once +# in gateway/catalog and inherited here so the tool-specific "details" leaves +# (nested actions) can extend them without duplicating the base shape. +class ToolCatalogIntegration(CatalogIntegration): + pass class ToolCatalogIntegrationDetails(ToolCatalogIntegration): actions: Optional[List[ToolCatalogAction]] = None -class ToolCatalogProvider(BaseModel): - key: ToolProviderKind - # - name: str - description: Optional[str] = None - # - integrations_count: Optional[int] = None - # +class ToolCatalogProvider(CatalogProvider): + pass class ToolCatalogProviderDetails(ToolCatalogProvider): integrations: Optional[List[ToolCatalogIntegration]] = None +# --------------------------------------------------------------------------- +# Tool Connections — shared `gateway_connections` rows, inherited here so the +# tools router/models never reference the generic gateway DTOs directly. +# --------------------------------------------------------------------------- + + +class ToolConnection(Connection): + pass + + +class ToolConnectionCreate(ConnectionCreate): + pass + + # --------------------------------------------------------------------------- # Tool Calls # --------------------------------------------------------------------------- diff --git a/api/oss/src/core/tools/providers/composio/adapter.py b/api/oss/src/core/tools/providers/composio/adapter.py index 82dfb56e83..6ae7601426 100644 --- a/api/oss/src/core/tools/providers/composio/adapter.py +++ b/api/oss/src/core/tools/providers/composio/adapter.py @@ -15,12 +15,12 @@ from oss.src.core.tools.interfaces import ToolsGatewayInterface from oss.src.core.tools.exceptions import AdapterError from oss.src.core.tools.providers.composio.catalog import ComposioCatalogClient +from oss.src.core.gateway.providers.composio.errors import composio_error_detail +from oss.src.utils.env import env log = get_module_logger(__name__) -COMPOSIO_DEFAULT_API_URL = "https://backend.composio.dev/api/v3" - class ComposioToolsAdapter(ComposioCatalogClient, ToolsGatewayInterface): """Composio V3 API adapter — uses httpx directly (no SDK). @@ -34,10 +34,10 @@ def __init__( self, *, api_key: str, - api_url: str = COMPOSIO_DEFAULT_API_URL, + api_url: Optional[str] = None, ): self.api_key = api_key - self.api_url = api_url.rstrip("/") + self.api_url = (api_url or env.composio.api_url).rstrip("/") # Shared client — one connection pool for the adapter's lifetime. # Call close() on shutdown (wired in entrypoints/routers.py lifespan). self._client = httpx.AsyncClient(timeout=30.0) @@ -128,13 +128,13 @@ async def get_action( raise AdapterError( provider_key="composio", operation="get_action", - detail=str(e), + detail=composio_error_detail(e), ) from e except httpx.HTTPError as e: raise AdapterError( provider_key="composio", operation="get_action", - detail=str(e), + detail=composio_error_detail(e), ) from e input_params = item.get("input_parameters") @@ -180,17 +180,16 @@ async def execute( json=payload, ) except httpx.HTTPStatusError as e: - body = e.response.text if e.response is not None else "" raise AdapterError( provider_key="composio", operation="execute", - detail=f"{e} — response: {body}", + detail=composio_error_detail(e), ) from e except httpx.HTTPError as e: raise AdapterError( provider_key="composio", operation="execute", - detail=str(e), + detail=composio_error_detail(e), ) from e return ToolExecutionResponse( diff --git a/api/oss/src/core/tools/service.py b/api/oss/src/core/tools/service.py index df680b21b2..a8976f33b4 100644 --- a/api/oss/src/core/tools/service.py +++ b/api/oss/src/core/tools/service.py @@ -3,7 +3,7 @@ from oss.src.utils.logging import get_module_logger -from oss.src.core.gateway.connections.dtos import Connection, ConnectionCreate +from oss.src.core.gateway.catalog.service import CatalogService from oss.src.core.gateway.connections.service import ConnectionsService from oss.src.core.tools.dtos import ( @@ -11,6 +11,8 @@ ToolCatalogActionDetails, ToolCatalogIntegration, ToolCatalogProvider, + ToolConnection, + ToolConnectionCreate, ToolExecutionRequest, ToolExecutionResponse, ) @@ -25,35 +27,33 @@ def __init__( self, *, connections_service: ConnectionsService, + catalog_service: CatalogService, adapter_registry: ToolsGatewayRegistry, ): self.connections_service = connections_service + self.catalog_service = catalog_service self.adapter_registry = adapter_registry # ----------------------------------------------------------------------- - # Catalog browse + # Catalog browse — providers + integrations come from the SHARED gateway + # catalog service; this layer narrows them to the tools subclass DTOs so the + # router only ever sees tools-domain types. Actions are the tools-specific + # leaf (via the tools adapter). # ----------------------------------------------------------------------- async def list_providers(self) -> List[ToolCatalogProvider]: - """Return all providers across registered adapters.""" - results: List[ToolCatalogProvider] = [] - for _key, adapter in self.adapter_registry.items(): - providers = await adapter.list_providers() - results.extend(providers) - return results + providers = await self.catalog_service.list_providers() + return [ToolCatalogProvider.model_validate(p.model_dump()) for p in providers] async def get_provider( self, *, provider_key: str, ) -> Optional[ToolCatalogProvider]: - """Return a single provider by key, or None if not found.""" - adapter = self.adapter_registry.get(provider_key) - providers = await adapter.list_providers() - for p in providers: - if p.key == provider_key: - return p - return None + provider = await self.catalog_service.get_provider(provider_key=provider_key) + if not provider: + return None + return ToolCatalogProvider.model_validate(provider.model_dump()) async def list_integrations( self, @@ -65,15 +65,17 @@ async def list_integrations( limit: Optional[int] = None, cursor: Optional[str] = None, ) -> Tuple[List[ToolCatalogIntegration], Optional[str], int]: - """List integrations for a provider with optional filtering and pagination.""" - adapter = self.adapter_registry.get(provider_key) - integrations, next_cursor, total = await adapter.list_integrations( + integrations, next_cursor, total = await self.catalog_service.list_integrations( + provider_key=provider_key, search=search, sort_by=sort_by, limit=limit, cursor=cursor, ) - return integrations, next_cursor, total + items = [ + ToolCatalogIntegration.model_validate(i.model_dump()) for i in integrations + ] + return items, next_cursor, total async def get_integration( self, @@ -81,9 +83,13 @@ async def get_integration( provider_key: str, integration_key: str, ) -> Optional[ToolCatalogIntegration]: - """Return a single integration by key, or None if not found.""" - adapter = self.adapter_registry.get(provider_key) - return await adapter.get_integration(integration_key=integration_key) + integration = await self.catalog_service.get_integration( + provider_key=provider_key, + integration_key=integration_key, + ) + if not integration: + return None + return ToolCatalogIntegration.model_validate(integration.model_dump()) async def list_actions( self, @@ -126,6 +132,10 @@ async def get_action( # Connection management (delegated to ConnectionsService — one-way dep) # ----------------------------------------------------------------------- + @staticmethod + def _as_tool_connection(conn) -> Optional[ToolConnection]: + return ToolConnection.model_validate(conn.model_dump()) if conn else None + async def query_connections( self, *, @@ -134,13 +144,14 @@ async def query_connections( provider_key: Optional[str] = None, integration_key: Optional[str] = None, is_active: Optional[bool] = True, - ) -> List[Connection]: - return await self.connections_service.query_connections( + ) -> List[ToolConnection]: + conns = await self.connections_service.query_connections( project_id=project_id, provider_key=provider_key, integration_key=integration_key, is_active=is_active, ) + return [ToolConnection.model_validate(c.model_dump()) for c in conns] async def list_connections( self, @@ -148,43 +159,47 @@ async def list_connections( project_id: UUID, provider_key: str, integration_key: str, - ) -> List[Connection]: - return await self.connections_service.list_connections( + ) -> List[ToolConnection]: + conns = await self.connections_service.list_connections( project_id=project_id, provider_key=provider_key, integration_key=integration_key, ) + return [ToolConnection.model_validate(c.model_dump()) for c in conns] async def get_connection( self, *, project_id: UUID, connection_id: UUID, - ) -> Optional[Connection]: - return await self.connections_service.get_connection( + ) -> Optional[ToolConnection]: + conn = await self.connections_service.get_connection( project_id=project_id, connection_id=connection_id, ) + return self._as_tool_connection(conn) async def find_connection_by_provider_connection_id( self, *, provider_connection_id: str, - ) -> Optional[Connection]: - return await self.connections_service.find_connection_by_provider_connection_id( + ) -> Optional[ToolConnection]: + conn = await self.connections_service.find_connection_by_provider_connection_id( provider_connection_id=provider_connection_id, ) + return self._as_tool_connection(conn) async def activate_connection_by_provider_connection_id( self, *, provider_connection_id: str, project_id: Optional[UUID] = None, - ) -> Optional[Connection]: - return await self.connections_service.activate_connection_by_provider_connection_id( + ) -> Optional[ToolConnection]: + conn = await self.connections_service.activate_connection_by_provider_connection_id( provider_connection_id=provider_connection_id, project_id=project_id, ) + return self._as_tool_connection(conn) async def create_connection( self, @@ -192,14 +207,15 @@ async def create_connection( project_id: UUID, user_id: UUID, # - connection_create: ConnectionCreate, - ) -> Connection: - return await self.connections_service.initiate_connection( + connection_create: ToolConnectionCreate, + ) -> ToolConnection: + conn = await self.connections_service.initiate_connection( project_id=project_id, user_id=user_id, # connection_create=connection_create, ) + return ToolConnection.model_validate(conn.model_dump()) async def delete_connection( self, @@ -217,11 +233,12 @@ async def revoke_connection( *, project_id: UUID, connection_id: UUID, - ) -> Connection: - return await self.connections_service.revoke_connection( + ) -> ToolConnection: + conn = await self.connections_service.revoke_connection( project_id=project_id, connection_id=connection_id, ) + return ToolConnection.model_validate(conn.model_dump()) async def refresh_connection( self, @@ -230,12 +247,13 @@ async def refresh_connection( connection_id: UUID, # force: bool = False, - ) -> Connection: - return await self.connections_service.refresh_connection( + ) -> ToolConnection: + conn = await self.connections_service.refresh_connection( project_id=project_id, connection_id=connection_id, force=force, ) + return ToolConnection.model_validate(conn.model_dump()) # ----------------------------------------------------------------------- # Tool execution diff --git a/api/oss/src/core/triggers/dtos.py b/api/oss/src/core/triggers/dtos.py index 2d7a1769f3..029f359d21 100644 --- a/api/oss/src/core/triggers/dtos.py +++ b/api/oss/src/core/triggers/dtos.py @@ -4,6 +4,14 @@ from pydantic import BaseModel, Field +from oss.src.core.gateway.catalog.dtos import ( + CatalogIntegration, + CatalogProvider, +) +from oss.src.core.gateway.connections.dtos import ( + Connection, + ConnectionCreate, +) from oss.src.core.shared.dtos import ( Header, Identifier, @@ -60,11 +68,28 @@ class TriggerCatalogEventDetails(TriggerCatalogEvent): payload: Optional[Dict[str, Any]] = None -class TriggerCatalogProvider(BaseModel): - key: TriggerProviderKind - # - name: str - description: Optional[str] = None +# Providers + integrations are SHARED across tools and triggers — defined once +# in gateway/catalog and inherited here as the triggers-side subclasses. +class TriggerCatalogProvider(CatalogProvider): + pass + + +class TriggerCatalogIntegration(CatalogIntegration): + pass + + +# --------------------------------------------------------------------------- +# Trigger Connections — shared `gateway_connections` rows, inherited here so the +# triggers router/models never reference the generic gateway DTOs directly. +# --------------------------------------------------------------------------- + + +class TriggerConnection(Connection): + pass + + +class TriggerConnectionCreate(ConnectionCreate): + pass # --------------------------------------------------------------------------- @@ -75,11 +100,12 @@ class TriggerCatalogProvider(BaseModel): # ca_*/secrets/connection internals are never exposed. # --------------------------------------------------------------------------- -TRIGGER_EVENT_FIELDS = { - "data", - "type", +TRIGGER_CONTEXT_FIELDS = { + "trigger_id", + "trigger_type", "timestamp", - "metadata", + "created_at", + "attributes", } SUBSCRIPTION_CONTEXT_FIELDS = { diff --git a/api/oss/src/core/triggers/interfaces.py b/api/oss/src/core/triggers/interfaces.py index 5221b52349..a6b4cbe55b 100644 --- a/api/oss/src/core/triggers/interfaces.py +++ b/api/oss/src/core/triggers/interfaces.py @@ -82,6 +82,11 @@ async def delete_subscription( """Permanently delete the provider-side trigger instance.""" ... + @abstractmethod + async def ensure_webhook_subscription(self, *, webhook_url: str) -> str: + """Idempotently ensure the project-level delivery webhook; return its secret.""" + ... + class TriggersDAOInterface(ABC): """Persistence contract for the triggers domain (subscriptions + deliveries).""" diff --git a/api/oss/src/core/triggers/providers/composio/adapter.py b/api/oss/src/core/triggers/providers/composio/adapter.py index 20fd9dd212..cb49ecc18c 100644 --- a/api/oss/src/core/triggers/providers/composio/adapter.py +++ b/api/oss/src/core/triggers/providers/composio/adapter.py @@ -14,11 +14,13 @@ from oss.src.core.triggers.providers.composio.catalog import ( ComposioTriggersCatalogClient, ) +from oss.src.core.gateway.providers.composio.errors import composio_error_detail +from oss.src.utils.env import env log = get_module_logger(__name__) -COMPOSIO_DEFAULT_API_URL = "https://backend.composio.dev/api/v3" +_WEBHOOK_EVENT = "composio.trigger.message" class ComposioTriggersAdapter(ComposioTriggersCatalogClient, TriggersGatewayInterface): @@ -41,10 +43,10 @@ def __init__( self, *, api_key: str, - api_url: str = COMPOSIO_DEFAULT_API_URL, + api_url: Optional[str] = None, ): self.api_key = api_key - self.api_url = api_url.rstrip("/") + self.api_url = (api_url or env.composio.api_url).rstrip("/") # Shared client — one connection pool for the adapter's lifetime. # Call close() on shutdown (wired in entrypoints/routers.py lifespan). self._client = httpx.AsyncClient(timeout=30.0) @@ -59,6 +61,14 @@ def _headers(self) -> Dict[str, str]: "Content-Type": "application/json", } + async def _get(self, path: str) -> Any: + resp = await self._client.get( + f"{self.api_url}{path}", + headers=self._headers(), + ) + resp.raise_for_status() + return resp.json() + async def _post( self, path: str, @@ -115,6 +125,39 @@ async def list_providers(self) -> List[TriggerCatalogProvider]: # list_events and get_event are inherited from ComposioTriggersCatalogClient # and satisfy the TriggersGatewayInterface catalog contract. + # ----------------------------------------------------------------------- + # Webhook subscription (project-level event delivery → Agenta ingress) + # ----------------------------------------------------------------------- + + async def ensure_webhook_subscription(self, *, webhook_url: str) -> str: + """GET-or-create-then-GET the delivery webhook; return its secret. + + The one-per-project cap arbitrates the race: the 409 loser re-reads the + winner's secret. + """ + try: + existing = await self._get("/webhook_subscriptions") + items = existing.get("items", []) if isinstance(existing, dict) else [] + if items: + return items[0]["secret"] + + resp = await self._client.post( + f"{self.api_url}/webhook_subscriptions", + headers=self._headers(), + json={"webhook_url": webhook_url, "enabled_events": [_WEBHOOK_EVENT]}, + ) + if resp.status_code == 409: + again = await self._get("/webhook_subscriptions") + return again["items"][0]["secret"] + resp.raise_for_status() + return resp.json()["secret"] + except httpx.HTTPError as e: + raise AdapterError( + provider_key="composio", + operation="ensure_webhook_subscription", + detail=composio_error_detail(e), + ) from e + # ----------------------------------------------------------------------- # Subscriptions (provider-side trigger instances — ti_*) — consumed by WP3 # ----------------------------------------------------------------------- @@ -141,7 +184,7 @@ async def create_subscription( raise AdapterError( provider_key="composio", operation="create_subscription", - detail=str(e), + detail=composio_error_detail(e), ) from e trigger_id = result.get("trigger_id") or result.get("id") @@ -169,7 +212,7 @@ async def set_subscription_status( raise AdapterError( provider_key="composio", operation="set_subscription_status", - detail=str(e), + detail=composio_error_detail(e), ) from e async def delete_subscription( @@ -183,5 +226,5 @@ async def delete_subscription( raise AdapterError( provider_key="composio", operation="delete_subscription", - detail=str(e), + detail=composio_error_detail(e), ) from e diff --git a/api/oss/src/core/triggers/service.py b/api/oss/src/core/triggers/service.py index 349f5fd889..76f6769288 100644 --- a/api/oss/src/core/triggers/service.py +++ b/api/oss/src/core/triggers/service.py @@ -1,13 +1,19 @@ -from typing import List, Optional, Tuple +import hashlib +import hmac +from typing import List, Mapping, Optional, Tuple from uuid import UUID from oss.src.utils.logging import get_module_logger +from oss.src.core.gateway.catalog.service import CatalogService from oss.src.core.gateway.connections.service import ConnectionsService from oss.src.core.triggers.dtos import ( TriggerCatalogEvent, TriggerCatalogEventDetails, + TriggerCatalogIntegration, TriggerCatalogProvider, + TriggerConnection, + TriggerConnectionCreate, TriggerDelivery, TriggerDeliveryCreate, TriggerDeliveryQuery, @@ -22,7 +28,9 @@ ) from oss.src.core.triggers.interfaces import TriggersDAOInterface from oss.src.core.triggers.registry import TriggersGatewayRegistry -from oss.src.core.shared.dtos import Windowing +from oss.src.core.triggers.utils import WebhookSecretResolver +from oss.src.core.shared.dtos import Reference, Windowing +from oss.src.core.workflows.service import WorkflowsService log = get_module_logger(__name__) @@ -41,41 +49,79 @@ def __init__( self, *, adapter_registry: TriggersGatewayRegistry, + catalog_service: CatalogService, triggers_dao: Optional[TriggersDAOInterface] = None, connections_service: Optional[ConnectionsService] = None, + workflows_service: Optional[WorkflowsService] = None, ): self.adapter_registry = adapter_registry + self.catalog_service = catalog_service self.dao = triggers_dao self.connections_service = connections_service + self.workflows_service = workflows_service + self.webhook_secret_resolver = WebhookSecretResolver( + adapter_registry=adapter_registry, + ) # ----------------------------------------------------------------------- - # Catalog browse + # Catalog browse — providers + integrations come from the SHARED gateway + # catalog service; this layer narrows them to the triggers subclass DTOs so + # the router only ever sees triggers-domain types. Events are the + # triggers-specific leaf (via the triggers adapter). # ----------------------------------------------------------------------- async def list_providers(self) -> List[TriggerCatalogProvider]: - """Return all providers across registered adapters.""" - results: List[TriggerCatalogProvider] = [] - for _key, adapter in self.adapter_registry.items(): - providers = await adapter.list_providers() - results.extend(providers) - return results + providers = await self.catalog_service.list_providers() + return [ + TriggerCatalogProvider.model_validate(p.model_dump()) for p in providers + ] async def get_provider( self, *, provider_key: str, ) -> Optional[TriggerCatalogProvider]: - """Return a single provider by key. + provider = await self.catalog_service.get_provider(provider_key=provider_key) + if not provider: + return None + return TriggerCatalogProvider.model_validate(provider.model_dump()) - Raises ``ProviderNotFoundError`` for an unregistered key (mapped to 404 - at the router); returns None when the adapter has no matching provider. - """ - adapter = self.adapter_registry.get(provider_key) - providers = await adapter.list_providers() - for p in providers: - if p.key == provider_key: - return p - return None + async def list_integrations( + self, + *, + provider_key: str, + # + search: Optional[str] = None, + sort_by: Optional[str] = None, + limit: Optional[int] = None, + cursor: Optional[str] = None, + ) -> Tuple[List[TriggerCatalogIntegration], Optional[str], int]: + integrations, next_cursor, total = await self.catalog_service.list_integrations( + provider_key=provider_key, + search=search, + sort_by=sort_by, + limit=limit, + cursor=cursor, + ) + items = [ + TriggerCatalogIntegration.model_validate(i.model_dump()) + for i in integrations + ] + return items, next_cursor, total + + async def get_integration( + self, + *, + provider_key: str, + integration_key: str, + ) -> Optional[TriggerCatalogIntegration]: + integration = await self.catalog_service.get_integration( + provider_key=provider_key, + integration_key=integration_key, + ) + if not integration: + return None + return TriggerCatalogIntegration.model_validate(integration.model_dump()) async def list_events( self, @@ -110,6 +156,99 @@ async def get_event( event_key=event_key, ) + # ----------------------------------------------------------------------- + # Connections — shared `gateway_connections` rows via the shared + # ConnectionsService; narrowed to the triggers subclass so the router only + # ever sees triggers-domain types. Independent surface from tools; both + # operate over the same rows. + # ----------------------------------------------------------------------- + + @staticmethod + def _as_trigger_connection(conn) -> Optional[TriggerConnection]: + return TriggerConnection.model_validate(conn.model_dump()) if conn else None + + async def query_connections( + self, + *, + project_id: UUID, + provider_key: Optional[str] = None, + integration_key: Optional[str] = None, + is_active: Optional[bool] = True, + ) -> List[TriggerConnection]: + conns = await self.connections_service.query_connections( + project_id=project_id, + provider_key=provider_key, + integration_key=integration_key, + is_active=is_active, + ) + return [TriggerConnection.model_validate(c.model_dump()) for c in conns] + + async def get_connection( + self, + *, + project_id: UUID, + connection_id: UUID, + ) -> Optional[TriggerConnection]: + conn = await self.connections_service.get_connection( + project_id=project_id, + connection_id=connection_id, + ) + return self._as_trigger_connection(conn) + + async def create_connection( + self, + *, + project_id: UUID, + user_id: UUID, + # + connection_create: TriggerConnectionCreate, + ) -> TriggerConnection: + conn = await self.connections_service.initiate_connection( + project_id=project_id, + user_id=user_id, + # + connection_create=connection_create, + ) + return TriggerConnection.model_validate(conn.model_dump()) + + async def delete_connection( + self, + *, + project_id: UUID, + connection_id: UUID, + ) -> bool: + return await self.connections_service.delete_connection( + project_id=project_id, + connection_id=connection_id, + ) + + async def refresh_connection( + self, + *, + project_id: UUID, + connection_id: UUID, + # + force: bool = False, + ) -> TriggerConnection: + conn = await self.connections_service.refresh_connection( + project_id=project_id, + connection_id=connection_id, + force=force, + ) + return TriggerConnection.model_validate(conn.model_dump()) + + async def revoke_connection( + self, + *, + project_id: UUID, + connection_id: UUID, + ) -> TriggerConnection: + conn = await self.connections_service.revoke_connection( + project_id=project_id, + connection_id=connection_id, + ) + return TriggerConnection.model_validate(conn.model_dump()) + # ----------------------------------------------------------------------- # Subscriptions # ----------------------------------------------------------------------- @@ -128,6 +267,47 @@ async def _require_connection( raise ConnectionNotFoundError(connection_id=str(connection_id)) return connection + async def _normalize_references( + self, + *, + project_id: UUID, + references: Optional[dict], + ) -> None: + """Resolve the bound workflow ref to a runnable revision, in place. + + The UI may send a variant id (or a bare/partial ref) under + ``workflow_revision``; resolve it to the actual workflow revision (by + revision id, else by variant id → latest) and rewrite id/slug/version so + the dispatcher's ``invoke_workflow`` finds the service uri (mirrors the + reference completion done on /deploy). + """ + if not references or not self.workflows_service: + return + + ref = references.get("workflow_revision") + ref_id = getattr(ref, "id", None) if ref else None + if not ref_id: + return + + revision = await self.workflows_service.fetch_workflow_revision( + project_id=project_id, + workflow_revision_ref=Reference(id=ref_id), + ) + if revision is None: + # Not a revision id — try it as a variant id (latest revision). + revision = await self.workflows_service.fetch_workflow_revision( + project_id=project_id, + workflow_variant_ref=Reference(id=ref_id), + ) + if revision is None: + return + + references["workflow_revision"] = Reference( + id=revision.id, + slug=revision.slug, + version=revision.version, + ) + async def create_subscription( self, *, @@ -137,6 +317,11 @@ async def create_subscription( subscription: TriggerSubscriptionCreate, ) -> TriggerSubscription: """Mint the provider-side ``ti_*`` on a shared connection, then persist.""" + await self._normalize_references( + project_id=project_id, + references=subscription.data.references, + ) + connection = await self._require_connection( project_id=project_id, connection_id=subscription.connection_id, @@ -203,6 +388,11 @@ async def edit_subscription( if existing is None: return None + await self._normalize_references( + project_id=project_id, + references=subscription.data.references, + ) + ti_id = existing.data.ti_id if ti_id is not None and subscription.enabled != existing.enabled: connection = await self._require_connection( @@ -388,3 +578,46 @@ async def write_delivery( user_id=user_id, delivery=delivery, ) + + # ----------------------------------------------------------------------- + # Inbound webhook — registration + signature verification + # ----------------------------------------------------------------------- + + async def ensure_webhook_registered(self) -> None: + """Ensure Composio's delivery webhook exists (startup, herd-safe).""" + await self.webhook_secret_resolver.resolve() + + async def verify_signature( + self, *, body: bytes, headers: Mapping[str, str] + ) -> bool: + """Verify Composio's HMAC over ``{webhook-id}.{webhook-timestamp}.{body}``. + + On mismatch, refresh the secret once (it rotates if the subscription is + recreated) and retry before rejecting. + """ + signature = headers.get("webhook-signature") or headers.get( + "x-composio-signature" + ) + if not signature: + return False + + webhook_id = headers.get("webhook-id") or "" + timestamp = headers.get("webhook-timestamp") or "" + signed = f"{webhook_id}.{timestamp}.{body.decode('utf-8', errors='replace')}" + provided = signature.split(",")[-1].strip() + + for force_refresh in (False, True): + secret = await self.webhook_secret_resolver.resolve( + force_refresh=force_refresh, + ) + if not secret: + return False + expected = hmac.new( + secret.encode("utf-8"), + signed.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if hmac.compare_digest(expected, provided): + return True + + return False diff --git a/api/oss/src/core/triggers/utils.py b/api/oss/src/core/triggers/utils.py new file mode 100644 index 0000000000..1266327dd7 --- /dev/null +++ b/api/oss/src/core/triggers/utils.py @@ -0,0 +1,68 @@ +"""Composio trigger-webhook signing-secret resolver. + +The secret is Composio-generated (one per project, not user-supplied), so +Composio is the source of truth and the value is cached encrypted in Redis. +""" + +from typing import Optional + +from oss.src.core.triggers.registry import TriggersGatewayRegistry +from oss.src.utils.caching import get_cache, set_cache +from oss.src.utils.crypting import decrypt, encrypt +from oss.src.utils.logging import get_module_logger +from oss.src.utils.env import env + +log = get_module_logger(__name__) + +_CACHE_NAMESPACE = "composio:triggers" +_CACHE_KEY = "webhook_secret" +_CACHE_TTL = 3600 # 1h — re-derived from Composio on expiry; flush is harmless +_INGRESS_PATH = "/triggers/composio/events/" +# Composio requires public HTTPS; in dev the tunnel delivers over WebSocket so +# the URL is only a placeholder for the secret. RFC 2606 reserved host — resolves +# (passes Composio's SSRF check) but is never actually delivered to. +_DUMMY_HTTPS_URL = "https://example.com/" + + +class WebhookSecretResolver: + """Resolve the Composio webhook signing secret: cache → Composio → cache.""" + + def __init__( + self, + *, + adapter_registry: TriggersGatewayRegistry, + provider_key: str = "composio", + ): + self._registry = adapter_registry + self._provider_key = provider_key + + def _webhook_url(self) -> str: + if env.composio.webhook_url: + return env.composio.webhook_url + url = f"{env.agenta.api_url.rstrip('/')}{_INGRESS_PATH}" + return url if url.startswith("https://") else _DUMMY_HTTPS_URL + + async def resolve(self, *, force_refresh: bool = False) -> Optional[str]: + """Return the signing secret, or None if it cannot be resolved.""" + if not force_refresh: + cached = await get_cache(namespace=_CACHE_NAMESPACE, key=_CACHE_KEY) + if cached: + return decrypt(cached) + + try: + adapter = self._registry.get(self._provider_key) + secret = await adapter.ensure_webhook_subscription( + webhook_url=self._webhook_url(), + ) + except Exception as e: + log.error("failed to ensure Composio webhook subscription: %s", e) + return None + + await set_cache( + namespace=_CACHE_NAMESPACE, + key=_CACHE_KEY, + value=encrypt(secret), + ttl=_CACHE_TTL, + ) + + return secret diff --git a/api/oss/src/middlewares/auth.py b/api/oss/src/middlewares/auth.py index bdbc1ee8c9..8ef34f6c5e 100644 --- a/api/oss/src/middlewares/auth.py +++ b/api/oss/src/middlewares/auth.py @@ -70,10 +70,10 @@ "/preview/tools/connections/callback", "/api/preview/tools/connections/callback", # TRIGGERS — inbound provider events arrive from Composio with no auth token - "/triggers/composio/events", - "/api/triggers/composio/events", - "/preview/triggers/composio/events", - "/api/preview/triggers/composio/events", + "/triggers/composio/events/", + "/api/triggers/composio/events/", + "/preview/triggers/composio/events/", + "/api/preview/triggers/composio/events/", ) _ADMIN_ENDPOINT_IDENTIFIER = "/admin/" diff --git a/api/oss/src/tasks/asyncio/triggers/dispatcher.py b/api/oss/src/tasks/asyncio/triggers/dispatcher.py index 3c2bcbdfe3..363ca5f2b0 100644 --- a/api/oss/src/tasks/asyncio/triggers/dispatcher.py +++ b/api/oss/src/tasks/asyncio/triggers/dispatcher.py @@ -8,6 +8,7 @@ Self-contained so it can run inside its own TaskIQ worker process. """ +from datetime import datetime, timezone from typing import Any, Dict, Optional from uuid import UUID @@ -15,7 +16,7 @@ from oss.src.core.shared.dtos import Status from oss.src.core.triggers.dtos import ( - TRIGGER_EVENT_FIELDS, + TRIGGER_CONTEXT_FIELDS, SUBSCRIPTION_CONTEXT_FIELDS, TriggerDeliveryCreate, TriggerDeliveryData, @@ -23,6 +24,7 @@ ) from oss.src.core.triggers.interfaces import TriggersDAOInterface from oss.src.core.workflows.service import WorkflowsService +from oss.src.utils.env import env from oss.src.utils.logging import get_module_logger from agenta.sdk.decorators.running import WorkflowServiceRequest @@ -52,8 +54,19 @@ def _build_context( project_id: UUID, ) -> Dict[str, Any]: sub_dump = subscription.model_dump(mode="json", exclude_none=True) + metadata = event.get("metadata") or {} + now = datetime.now(timezone.utc).isoformat() + normalized = { + "trigger_id": metadata.get("trigger_id"), + "trigger_type": metadata.get("trigger_slug"), + "timestamp": now, + "created_at": now, + "attributes": event.get("payload"), + } return { - "event": {k: v for k, v in event.items() if k in TRIGGER_EVENT_FIELDS}, + "event": { + k: v for k, v in normalized.items() if k in TRIGGER_CONTEXT_FIELDS + }, "subscription": { k: v for k, v in sub_dump.items() if k in SUBSCRIPTION_CONTEXT_FIELDS }, @@ -73,9 +86,9 @@ async def dispatch( ) if resolved is None: - log.info( - "[TRIGGERS DISPATCHER] Unknown trigger_id %s — skipping", trigger_id - ) + # Unknown ti_* is normal isolation unless a target is configured. + level = log.warning if env.composio.webhook_target else log.info + level("[TRIGGERS DISPATCHER] Unknown trigger_id %s — skipping", trigger_id) return project_id, subscription = resolved @@ -164,6 +177,7 @@ async def dispatch( request=request, ) except Exception as e: + log.error("[TRIGGERS DISPATCHER] invoke failed: %s", e, exc_info=True) await self._write_delivery( project_id=project_id, user_id=user_id, @@ -219,6 +233,11 @@ async def dispatch( } ), ) + log.info( + "[TRIGGERS DISPATCHER] dispatch complete subscription=%s event=%s status=200", + subscription.id, + event_id, + ) async def _write_delivery( self, diff --git a/api/oss/src/utils/env.py b/api/oss/src/utils/env.py index 993ab83725..a0593d4355 100644 --- a/api/oss/src/utils/env.py +++ b/api/oss/src/utils/env.py @@ -510,7 +510,12 @@ class ComposioConfig(BaseModel): api_key: str | None = os.getenv("COMPOSIO_API_KEY") api_url: str = os.getenv("COMPOSIO_API_URL", "https://backend.composio.dev/api/v3") - webhook_secret: str | None = os.getenv("COMPOSIO_WEBHOOK_SECRET") + # Dev: when set, unknown-trigger drops log at WARNING instead of INFO. + webhook_target: str | None = os.getenv("COMPOSIO_WEBHOOK_TARGET") + # Override the registered webhook URL. Composio requires public HTTPS; in dev + # (http://localhost) the tunnel delivers over WebSocket, so this only needs to + # be a valid public HTTPS placeholder to mint the subscription's secret. + webhook_url: str | None = os.getenv("COMPOSIO_WEBHOOK_URL") @property def enabled(self) -> bool: diff --git a/api/oss/tests/manual/triggers/try_composio_triggers.py b/api/oss/tests/manual/triggers/try_composio_triggers.py new file mode 100644 index 0000000000..3e253c4cc7 --- /dev/null +++ b/api/oss/tests/manual/triggers/try_composio_triggers.py @@ -0,0 +1,400 @@ +#!/usr/bin/env python3 +"""Smoke-test Composio Slack triggers via the official Python SDK. + +Covers the two flows from the docs: + - Creating triggers: https://docs.composio.dev/docs/setting-up-triggers/creating-triggers + - Subscribing to events: https://docs.composio.dev/docs/setting-up-triggers/subscribing-to-events + +The app talks to Composio over raw httpx, but the SDK is the fastest way to +both create trigger instances and *watch live events* (WebSocket) so we can see +whether triggers actually fire — which is the usual reason they "don't work". + +Usage: + set -a; source hosting/docker-compose/ee/.env.ee.dev; set +a + + # 1. List Slack trigger types + their config schemas + python api/oss/tests/manual/triggers/try_composio_triggers.py list + + # 2. List currently-active trigger instances + python api/oss/tests/manual/triggers/try_composio_triggers.py active + + # 3. Create the three triggers on channel C0BBC650QNT + python api/oss/tests/manual/triggers/try_composio_triggers.py create + + # 4. Watch live events (send a message / add a reaction in Slack to test) + python api/oss/tests/manual/triggers/try_composio_triggers.py watch + + # 5. Inspect / register Agenta's webhook delivery URL (the missing link) + python api/oss/tests/manual/triggers/try_composio_triggers.py webhooks + AGENTA_WEBHOOK_URL=https://<host>/api/triggers/composio/events/ \ + python api/oss/tests/manual/triggers/try_composio_triggers.py register + +Optional env: + COMPOSIO_API_KEY required + SLACK_CHANNEL_ID default C0BBC650QNT + SLACK_CONNECTED_ACCOUNT optional; auto-detected (first ACTIVE Slack account) + AGENTA_WEBHOOK_URL for `register`; your reachable ingress (trailing slash) +""" + +import json +import os +import sys +from typing import Any, Dict, List, Optional + +from composio import Composio + +CHANNEL_ID = os.getenv("SLACK_CHANNEL_ID", "C0BBC650QNT") +TOOLKIT = "slack" + +# Intent → Composio trigger-type slug. Slack has TWO families: +# SLACK_RECEIVE_MESSAGE / SLACK_REACTION_* → no config, fire workspace-wide +# SLACK_CHANNEL_MESSAGE_RECEIVED / SLACK_MESSAGE_REACTION_* → accept channel_id +# We want channel-scoped, so use the latter. "message_sent" intentionally has no +# Slack equivalent — Slack/Composio only expose messages *received*. +TRIGGERS = { + "message_received": "SLACK_CHANNEL_MESSAGE_RECEIVED", + "reaction_added": "SLACK_MESSAGE_REACTION_ADDED", + "reaction_removed": "SLACK_MESSAGE_REACTION_REMOVED", +} + + +def _client() -> Composio: + key = os.getenv("COMPOSIO_API_KEY") + if not key: + sys.exit( + "COMPOSIO_API_KEY not set.\n" + " set -a; source hosting/docker-compose/ee/.env.ee.dev; set +a" + ) + return Composio(api_key=key) + + +def _hr(title: str) -> None: + print(f"\n{'=' * 70}\n{title}\n{'=' * 70}") + + +def _items(resp: Any) -> List[Any]: + items = getattr(resp, "items", None) + return ( + list(items) if items is not None else (resp if isinstance(resp, list) else []) + ) + + +def _active_slack_account(composio: Composio) -> Optional[str]: + override = os.getenv("SLACK_CONNECTED_ACCOUNT") + if override: + return override + resp = composio.connected_accounts.list(toolkit_slugs=[TOOLKIT]) + for acc in _items(resp): + status = getattr(acc, "status", None) + acc_id = getattr(acc, "id", None) + if status == "ACTIVE": + return acc_id + return None + + +def cmd_list(composio: Composio) -> None: + _hr(f"Slack trigger types (toolkit={TOOLKIT})") + resp = composio.triggers.list(toolkit_slugs=[TOOLKIT], limit=100) + for it in _items(resp): + slug = getattr(it, "slug", "?") + name = getattr(it, "name", "") + print(f" - {slug:40} {name}") + + _hr("Config schemas for the triggers we care about") + for intent, slug in TRIGGERS.items(): + try: + detail = composio.triggers.get_type(slug=slug) + except Exception as e: # noqa: BLE001 + print(f"\n {intent} → {slug}: get_type FAILED: {e}") + continue + config = getattr(detail, "config", None) + print(f"\n {intent} → {slug}") + print(f" config: {json.dumps(config, indent=2, default=str)}") + + +def cmd_active(composio: Composio) -> None: + _hr("Active trigger instances") + resp = composio.triggers.list_active(limit=100) + items = _items(resp) + if not items: + print(" (none)") + return + for it in items: + print( + f" - id={getattr(it, 'id', '?')} " + f"trigger={getattr(it, 'trigger_name', getattr(it, 'trigger_slug', '?'))} " + f"state={getattr(it, 'state', getattr(it, 'status', '?'))}" + ) + + +def cmd_create(composio: Composio) -> None: + account_id = _active_slack_account(composio) + if not account_id: + sys.exit("No ACTIVE Slack connected account found. Connect Slack first.") + print(f"Using connected_account_id={account_id}, channel_id={CHANNEL_ID}") + + _hr("Creating trigger instances") + for intent, slug in TRIGGERS.items(): + # Only channel_id — channel_type is mutually exclusive with it on Slack. + trigger_config: Dict[str, Any] = {"channel_id": CHANNEL_ID} + try: + result = composio.triggers.create( + slug=slug, + connected_account_id=account_id, + trigger_config=trigger_config, + ) + except Exception as e: # noqa: BLE001 + print(f" ❌ {intent} ({slug}): {e}") + continue + ti_id = getattr(result, "trigger_id", None) or getattr(result, "id", None) + print(f" ✅ {intent} ({slug}) → {ti_id}") + + +def cmd_watch(composio: Composio) -> None: + _hr("Subscribing to live trigger events (WebSocket)") + print( + "Go to Slack and send a message / add a reaction in the watched channel.\n" + "Events should print below. Ctrl-C to stop.\n" + ) + subscription = composio.triggers.subscribe() + + @subscription.handle(toolkit=TOOLKIT) + def _on_event(data: Any) -> None: # noqa: ANN401 + print(f"\n🔔 EVENT:\n{json.dumps(data, indent=2, default=str)}") + + subscription.wait_forever() + + +# Composio's webhook subscription API isn't on the SDK resource we use, so the +# webhook commands hit the REST API directly (same as the app's httpx adapters). +_WEBHOOK_EVENT = "composio.trigger.message" + + +def _rest(composio: Composio) -> Any: + import httpx # local: only the webhook commands need raw REST + + return httpx.Client( + base_url=os.getenv("COMPOSIO_API_URL", "https://backend.composio.dev/api/v3"), + headers={ + "x-api-key": os.environ["COMPOSIO_API_KEY"], + "Content-Type": "application/json", + }, + timeout=20.0, + ) + + +def cmd_webhooks(composio: Composio) -> None: + _hr("Registered webhook subscriptions") + with _rest(composio) as c: + r = c.get("/webhook_subscriptions") + r.raise_for_status() + items = r.json().get("items", []) + if not items: + print( + " (none) — Composio has nowhere to deliver events. Agenta will NOT\n" + " receive triggers until a webhook is registered (see `register`)." + ) + return + for it in items: + print( + f" - id={it.get('id')} url={it.get('webhook_url')} " + f"events={it.get('enabled_events')}" + ) + + +def cmd_register(composio: Composio) -> None: + """Register Agenta's ingress URL so Composio actually delivers events. + + Set AGENTA_WEBHOOK_URL to your reachable ingress, e.g. + https://<host>/api/triggers/composio/events/ (note the trailing slash) + """ + url = os.getenv("AGENTA_WEBHOOK_URL") + if not url: + sys.exit( + "Set AGENTA_WEBHOOK_URL to your reachable ingress, e.g.\n" + " https://<host>/api/triggers/composio/events/" + ) + _hr(f"Registering webhook → {url}") + with _rest(composio) as c: + r = c.post( + "/webhook_subscriptions", + json={"webhook_url": url, "enabled_events": [_WEBHOOK_EVENT]}, + ) + if not r.is_success: + sys.exit(f"❌ register failed ({r.status_code}): {r.text}") + body = r.json() + print(f" ✅ id={body.get('id')}") + print("\n Set this in your env so signature verification passes:") + print(f" COMPOSIO_WEBHOOK_SECRET={body.get('secret')}") + + +def _ensure_subscription(client: Any, url: str) -> str: + """Idempotent GET-or-create-then-GET — the per-container startup operation. + + Composio caps webhook_subscriptions at 1 per project, so this is the lockless + convergence primitive: whoever creates first wins; everyone else gets 409 and + re-reads the winner's secret. The secret is always readable on GET. + """ + r = client.get("/webhook_subscriptions") + r.raise_for_status() + items = r.json().get("items", []) + if items: + return items[0]["secret"] + + r = client.post( + "/webhook_subscriptions", + json={"webhook_url": url, "enabled_events": [_WEBHOOK_EVENT]}, + ) + if r.status_code == 201: + return r.json()["secret"] + if r.status_code == 409: # lost the race — re-read the winner's secret + g = client.get("/webhook_subscriptions") + g.raise_for_status() + return g.json()["items"][0]["secret"] + raise RuntimeError(f"ensure_subscription failed ({r.status_code}): {r.text}") + + +_CACHE_KEY = "composio:triggers:webhook_secret" + + +# Faithful copy of oss.src.utils.crypting (AGENTA_CRYPT_KEY → sha256 → b64 → Fernet) +# so the script runs standalone on the host, outside the app's pythonpath. +def _fernet() -> Any: + import base64 + import hashlib + + from cryptography.fernet import Fernet + + crypt_key = os.getenv("AGENTA_CRYPT_KEY") or "replace-me" + key_material = hashlib.sha256(crypt_key.encode()).digest() + return Fernet(base64.urlsafe_b64encode(key_material)) + + +def encrypt(value: str) -> str: + return _fernet().encrypt(value.encode()).decode() + + +def decrypt(value: str) -> str: + return _fernet().decrypt(value.encode()).decode() + + +class _SharedCache: + """Stand-in for the volatile Redis cache (transport we already trust). + + Stores Fernet-ENCRYPTED ciphertext, exactly as the app would put a secret in + Redis. Thread-safe dict so concurrent 'containers' share one store. + """ + + def __init__(self) -> None: + import threading + + self._d: Dict[str, str] = {} + self._lock = threading.Lock() + + def get(self, key: str) -> Optional[str]: + with self._lock: + return self._d.get(key) + + def setex(self, key: str, _ttl: int, ciphertext: str) -> None: + with self._lock: + self._d[key] = ciphertext + + +def _resolve_secret(client: Any, cache: _SharedCache, url: str, *, force: bool) -> str: + """The real app primitive: cache(decrypt) → else Composio → cache(encrypt). + + Mirrors get_webhook_secret(): Composio is the source of truth, the cache + holds Fernet ciphertext, and a miss re-derives idempotently. + """ + if not force: + cached = cache.get(_CACHE_KEY) + if cached: + return decrypt(cached) # ciphertext → plaintext + secret = _ensure_subscription(client, url) + cache.setex(_CACHE_KEY, 3600, encrypt(secret)) # plaintext → ciphertext + return secret + + +def cmd_converge(composio: Composio) -> None: + """N containers race to register at startup; assert convergence + crypt round-trip. + + Each 'container' runs the real resolver (cache→Composio→cache) concurrently + with its OWN http client. They must all land the SAME secret, the cache must + hold Fernet CIPHERTEXT (not plaintext), and decrypt must round-trip it. + """ + import concurrent.futures as cf + + import httpx + + url = os.getenv( + "AGENTA_WEBHOOK_URL", + "https://webhook.site/00000000-0000-0000-0000-00000000c0de", + ) + n = int(os.getenv("CONTAINERS", "6")) + base = os.getenv("COMPOSIO_API_URL", "https://backend.composio.dev/api/v3") + key = os.environ["COMPOSIO_API_KEY"] + headers = {"x-api-key": key, "Content-Type": "application/json"} + cache = _SharedCache() + + # Clean slate so we exercise the create-race, not a pre-existing sub. + with httpx.Client(timeout=20, base_url=base, headers=headers) as c: + for it in c.get("/webhook_subscriptions").json().get("items", []): + c.delete(f"/webhook_subscriptions/{it['id']}") + + _hr(f"Convergence + crypt: {n} containers racing to register {url}") + + def one(i: int) -> str: + with httpx.Client(timeout=20, base_url=base, headers=headers) as c: + secret = _resolve_secret(c, cache, url, force=False) + print(f" container#{i}: secret={secret[:12]}…") + return secret + + with cf.ThreadPoolExecutor(max_workers=n) as ex: + secrets_seen = list(ex.map(one, range(1, n + 1))) + + uniq = set(secrets_seen) + cached_ciphertext = cache.get(_CACHE_KEY) or "" + + print("\n================ VERDICT ================") + print(f" containers: {n} | distinct secrets resolved: {len(uniq)}") + print(f" ALL CONVERGED to one secret? -> {len(uniq) == 1}") + print( + f" cache holds CIPHERTEXT (not plain)? -> {cached_ciphertext != secrets_seen[0]}" + ) + print( + f" decrypt(cache) == resolved secret? -> {decrypt(cached_ciphertext) == secrets_seen[0]}" + ) + + # force-refresh path: bypass cache, re-derive from Composio, must match. + with httpx.Client(timeout=20, base_url=base, headers=headers) as c: + refreshed = _resolve_secret(c, cache, url, force=True) + print(f" force-refresh re-reads same secret? -> {refreshed == secrets_seen[0]}") + + with httpx.Client(timeout=20, base_url=base, headers=headers) as c: + for it in c.get("/webhook_subscriptions").json().get("items", []): + c.delete(f"/webhook_subscriptions/{it['id']}") + print(" cleaned up.") + + +COMMANDS = { + "list": cmd_list, + "active": cmd_active, + "create": cmd_create, + "converge": cmd_converge, + "watch": cmd_watch, + "webhooks": cmd_webhooks, + "register": cmd_register, +} + + +def main() -> int: + cmd = sys.argv[1] if len(sys.argv) > 1 else "list" + if cmd not in COMMANDS: + sys.exit(f"Unknown command {cmd!r}. One of: {', '.join(COMMANDS)}") + composio = _client() + COMMANDS[cmd](composio) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/api/oss/tests/pytest/acceptance/triggers/test_triggers_connections.py b/api/oss/tests/pytest/acceptance/triggers/test_triggers_connections.py new file mode 100644 index 0000000000..3ab934a19f --- /dev/null +++ b/api/oss/tests/pytest/acceptance/triggers/test_triggers_connections.py @@ -0,0 +1,140 @@ +"""Acceptance tests for the /triggers/connections contract. + +Triggers exposes an independent ``/triggers/connections/*`` surface over the +SAME shared ``gateway_connections`` rows that ``/tools/connections/*`` uses. +The two endpoints do not depend on each other, yet a connection made from one +side is visible from the other — that cross-visibility is the invariant pinned +here. + +The query / get endpoints are DB-only (no Composio credentials needed). Create +/ revoke make real provider calls, so the lifecycle + cross-visibility roundtrip +is gated on COMPOSIO_API_KEY. +""" + +import os +from uuid import uuid4 + +import pytest + + +_COMPOSIO_ENABLED = bool(os.getenv("COMPOSIO_API_KEY")) +_requires_composio = pytest.mark.skipif( + not _COMPOSIO_ENABLED, + reason="needs live Composio credentials (COMPOSIO_API_KEY)", +) + + +class TestTriggersConnectionsQuery: + def test_query_connections_returns_200(self, authed_api): + response = authed_api("POST", "/triggers/connections/query") + assert response.status_code == 200 + + def test_query_connections_response_shape(self, authed_api): + body = authed_api("POST", "/triggers/connections/query").json() + assert "count" in body + assert "connections" in body + assert isinstance(body["connections"], list) + assert body["count"] == len(body["connections"]) + + +class TestTriggersConnectionsGet: + def test_get_unknown_connection_returns_404(self, authed_api): + response = authed_api("GET", f"/triggers/connections/{uuid4()}") + assert response.status_code == 404 + + +@_requires_composio +class TestTriggersConnectionsLifecycle: + def test_create_revoke_roundtrip(self, authed_api): + slug = f"acc-{uuid4().hex[:8]}" + create = authed_api( + "POST", + "/triggers/connections/", + json={ + "connection": { + "slug": slug, + "provider_key": "composio", + "integration_key": "github", + "data": {"auth_scheme": "oauth"}, + } + }, + ) + assert create.status_code == 200, create.text + connection_id = create.json()["connection"]["id"] + + revoke = authed_api("POST", f"/triggers/connections/{connection_id}/revoke") + assert revoke.status_code == 200, revoke.text + assert revoke.json()["connection"]["flags"]["is_valid"] is False + + delete = authed_api("DELETE", f"/triggers/connections/{connection_id}") + assert delete.status_code == 204, delete.text + + +@_requires_composio +class TestConnectionsCrossVisibility: + """The two surfaces are independent but share rows: a connection made on one + side appears on the other, and is manageable from either.""" + + def test_created_on_triggers_is_visible_on_tools(self, authed_api): + slug = f"acc-{uuid4().hex[:8]}" + create = authed_api( + "POST", + "/triggers/connections/", + json={ + "connection": { + "slug": slug, + "provider_key": "composio", + "integration_key": "github", + "data": {"auth_scheme": "oauth"}, + } + }, + ) + assert create.status_code == 200, create.text + connection_id = create.json()["connection"]["id"] + + # Visible via the tools query surface… + tools_ids = [ + c["id"] + for c in authed_api("POST", "/tools/connections/query").json()[ + "connections" + ] + ] + assert connection_id in tools_ids + + # …and fetchable + manageable via the tools surface. + fetched = authed_api("GET", f"/tools/connections/{connection_id}") + assert fetched.status_code == 200, fetched.text + + delete = authed_api("DELETE", f"/tools/connections/{connection_id}") + assert delete.status_code == 204, delete.text + + def test_created_on_tools_is_visible_on_triggers(self, authed_api): + slug = f"acc-{uuid4().hex[:8]}" + create = authed_api( + "POST", + "/tools/connections/", + json={ + "connection": { + "slug": slug, + "provider_key": "composio", + "integration_key": "github", + "data": {"auth_scheme": "oauth"}, + } + }, + ) + assert create.status_code == 200, create.text + connection_id = create.json()["connection"]["id"] + + trigger_ids = [ + c["id"] + for c in authed_api("POST", "/triggers/connections/query").json()[ + "connections" + ] + ] + assert connection_id in trigger_ids + + fetched = authed_api("GET", f"/triggers/connections/{connection_id}") + assert fetched.status_code == 200, fetched.text + + delete = authed_api("DELETE", f"/triggers/connections/{connection_id}") + assert delete.status_code == 204, delete.text diff --git a/api/oss/tests/pytest/acceptance/triggers/test_triggers_ingress.py b/api/oss/tests/pytest/acceptance/triggers/test_triggers_ingress.py index d76db95ed3..c1d56964e5 100644 --- a/api/oss/tests/pytest/acceptance/triggers/test_triggers_ingress.py +++ b/api/oss/tests/pytest/acceptance/triggers/test_triggers_ingress.py @@ -1,82 +1,91 @@ """Acceptance tests for POST /triggers/composio/events (inbound ingress). The ingress is the inbound dual of webhooks: a public (no Agenta auth) endpoint -that Composio POSTs provider events to. It ACKs fast (202) and enqueues dispatch -asynchronously; the actual workflow run + delivery write happen in a separate -worker, so the unconditional paths here are DB-free: +that Composio POSTs provider events to. It verifies the Composio HMAC signature +(secret resolved from Composio, cached encrypted in Redis), ACKs fast (202), and +enqueues dispatch asynchronously; the workflow run + delivery write happen in a +separate worker. Unlike the Stripe receiver, an unsigned/forged event is NOT a +no-op — verification is unconditional, so such requests are rejected with 401. - - an event for an unknown trigger id is a clean 202 no-op (nothing to route); - - an event with no routable metadata is a clean 202 no-op. - -The signature-rejection path only bites when COMPOSIO_WEBHOOK_SECRET is set -(unset → 200/202 no-op, mirroring the Stripe receiver), so it is gated on that. -The full signed-event -> workflow-invoked -> single-delivery roundtrip needs the -live Composio adapter and a bound workflow, so it is gated on COMPOSIO_API_KEY. +The signature-rejection path only fires when a webhook secret can be resolved, +which needs Composio enabled (COMPOSIO_API_KEY). The full signed-event -> +workflow-invoked -> single-delivery roundtrip also needs a bound workflow, so it +too is gated on COMPOSIO_API_KEY. Requires a running API. """ +import hashlib +import hmac +import json import os from uuid import uuid4 +import httpx import pytest _COMPOSIO_ENABLED = bool(os.getenv("COMPOSIO_API_KEY")) -_WEBHOOK_SECRET = os.getenv("COMPOSIO_WEBHOOK_SECRET") +_COMPOSIO_API_URL = os.getenv( + "COMPOSIO_API_URL", "https://backend.composio.dev/api/v3" +).rstrip("/") + + +def _resolve_webhook_secret() -> str: + """Read the project's Composio webhook secret (same path the API uses).""" + api_key = os.getenv("COMPOSIO_API_KEY") + with httpx.Client(timeout=20, base_url=_COMPOSIO_API_URL) as client: + resp = client.get( + "/webhook_subscriptions", + headers={"x-api-key": api_key, "Content-Type": "application/json"}, + ) + resp.raise_for_status() + items = resp.json().get("items", []) + return items[0]["secret"] if items else "" + + +def _sign(secret: str, webhook_id: str, timestamp: str, body: bytes) -> str: + signed = f"{webhook_id}.{timestamp}.{body.decode('utf-8')}" + return hmac.new(secret.encode(), signed.encode(), hashlib.sha256).hexdigest() + _requires_composio = pytest.mark.skipif( not _COMPOSIO_ENABLED, reason="needs live Composio credentials (COMPOSIO_API_KEY)", ) -_requires_webhook_secret = pytest.mark.skipif( - not _WEBHOOK_SECRET, - reason="needs COMPOSIO_WEBHOOK_SECRET set to verify signature rejection", + +# Minting a trigger instance needs an ACTIVE connected account, which a stub +# OAuth connection never reaches in CI (no interactive auth). +_requires_connected_account = pytest.mark.skipif( + not os.getenv("COMPOSIO_TEST_CONNECTED_ACCOUNT"), + reason="needs COMPOSIO_TEST_CONNECTED_ACCOUNT (an ACTIVE connected account)", ) # --------------------------------------------------------------------------- -# DB-only: unknown trigger / no metadata are clean 202 no-ops +# Signature verification is unconditional — unsigned/forged events are rejected. +# Needs a resolvable webhook secret, which requires Composio enabled. # --------------------------------------------------------------------------- -class TestTriggerIngressNoOps: - def test_unknown_trigger_id_is_accepted_noop(self, unauthed_api): +@_requires_composio +class TestTriggerIngressSignature: + def test_unsigned_event_is_rejected(self, unauthed_api): response = unauthed_api( "POST", - "/triggers/composio/events", + "/triggers/composio/events/", json={ "type": "github_star_added_event", - "metadata": { - "trigger_id": f"ti_{uuid4().hex}", - "id": uuid4().hex, - }, - "data": {"repository": "acme/widgets"}, + "metadata": {"trigger_id": f"ti_{uuid4().hex}", "id": uuid4().hex}, + "payload": {"repository": "acme/widgets"}, }, ) - assert response.status_code == 202, response.text - assert response.json()["status"] == "accepted" - - def test_no_routable_metadata_is_accepted_noop(self, unauthed_api): - response = unauthed_api( - "POST", - "/triggers/composio/events", - json={"type": "some_event", "data": {}}, - ) - assert response.status_code == 202, response.text - assert response.json()["status"] == "accepted" - - def test_empty_body_is_accepted_noop(self, unauthed_api): - response = unauthed_api("POST", "/triggers/composio/events", data=b"") - assert response.status_code == 202, response.text - + assert response.status_code == 401, response.text -@_requires_webhook_secret -class TestTriggerIngressSignature: def test_forged_signature_is_rejected(self, unauthed_api): response = unauthed_api( "POST", - "/triggers/composio/events", + "/triggers/composio/events/", headers={ "webhook-id": "msg_1", "webhook-timestamp": "1700000000", @@ -88,6 +97,10 @@ def test_forged_signature_is_rejected(self, unauthed_api): ) assert response.status_code == 401, response.text + def test_empty_unsigned_body_is_rejected(self, unauthed_api): + response = unauthed_api("POST", "/triggers/composio/events/", data=b"") + assert response.status_code == 401, response.text + # --------------------------------------------------------------------------- # Dedup (needs Composio) — a duplicate metadata.id does not double-write a @@ -96,6 +109,7 @@ def test_forged_signature_is_rejected(self, unauthed_api): @_requires_composio +@_requires_connected_account class TestTriggerIngressDedup: def test_duplicate_event_id_writes_single_delivery(self, authed_api, unauthed_api): # Create a connection + subscription so an inbound ti_* resolves locally. @@ -124,8 +138,8 @@ def test_duplicate_event_id_writes_single_delivery(self, authed_api, unauthed_ap "connection_id": connection_id, "data": { "event_key": "GITHUB_STAR_ADDED_EVENT", - "trigger_config": {}, - "inputs_fields": {"repo": "$.event.data.repository"}, + "trigger_config": {"owner": "acme", "repo": "widgets"}, + "inputs_fields": {"repo": "$.event.attributes.repository"}, "references": {"workflow": {"slug": "triage"}}, }, } @@ -140,12 +154,23 @@ def test_duplicate_event_id_writes_single_delivery(self, authed_api, unauthed_ap envelope = { "type": "github_star_added_event", "metadata": {"trigger_id": ti_id, "id": event_id}, - "data": {"repository": "acme/widgets"}, + "payload": {"repository": "acme/widgets"}, + } + body = json.dumps(envelope).encode() + timestamp = "1700000000" + secret = _resolve_webhook_secret() + headers = { + "Content-Type": "application/json", + "webhook-id": event_id, + "webhook-timestamp": timestamp, + "webhook-signature": _sign(secret, event_id, timestamp, body), } - # Post the same event twice (provider redelivery) — dedup must hold. + # Post the same signed event twice (provider redelivery) — dedup must hold. for _ in range(2): - ack = unauthed_api("POST", "/triggers/composio/events", json=envelope) + ack = unauthed_api( + "POST", "/triggers/composio/events/", data=body, headers=headers + ) assert ack.status_code == 202, ack.text # The dispatch is async; the dedup guard means at most one delivery row diff --git a/api/oss/tests/pytest/acceptance/triggers/test_triggers_subscriptions.py b/api/oss/tests/pytest/acceptance/triggers/test_triggers_subscriptions.py index cd519cc3f2..3fbda74c16 100644 --- a/api/oss/tests/pytest/acceptance/triggers/test_triggers_subscriptions.py +++ b/api/oss/tests/pytest/acceptance/triggers/test_triggers_subscriptions.py @@ -24,6 +24,14 @@ reason="needs live Composio credentials (COMPOSIO_API_KEY)", ) +# Minting a trigger instance needs an ACTIVE connected account, which a stub +# OAuth connection never reaches in CI (no interactive auth). Gate the create +# roundtrip on a pre-connected account being supplied. +_requires_connected_account = pytest.mark.skipif( + not os.getenv("COMPOSIO_TEST_CONNECTED_ACCOUNT"), + reason="needs COMPOSIO_TEST_CONNECTED_ACCOUNT (an ACTIVE connected account)", +) + # --------------------------------------------------------------------------- # DB-only: reads, queries, 404s (no Composio needed) @@ -87,6 +95,7 @@ def test_fetch_unknown_delivery_returns_404(self, authed_api): @_requires_composio +@_requires_connected_account class TestTriggerSubscriptionsLifecycle: def _create_connection(self, authed_api): slug = f"acc-{uuid4().hex[:8]}" @@ -118,8 +127,8 @@ def test_create_list_disable_delete_keeps_connection(self, authed_api): "connection_id": connection_id, "data": { "event_key": "GITHUB_STAR_ADDED_EVENT", - "trigger_config": {}, - "inputs_fields": {"repo": "$.event.data.repository"}, + "trigger_config": {"owner": "acme", "repo": "widgets"}, + "inputs_fields": {"repo": "$.event.attributes.repository"}, "references": {"workflow": {"slug": "triage"}}, }, } diff --git a/api/oss/tests/pytest/unit/triggers/test_triggers_dispatcher.py b/api/oss/tests/pytest/unit/triggers/test_triggers_dispatcher.py index e50fcf157b..70ff01ebbe 100644 --- a/api/oss/tests/pytest/unit/triggers/test_triggers_dispatcher.py +++ b/api/oss/tests/pytest/unit/triggers/test_triggers_dispatcher.py @@ -38,7 +38,57 @@ def _make_dao(*, resolved, seen=False): return dao -_EVENT = {"type": "github.issue.opened", "data": {"issue": {"number": 7}}} +# Raw provider envelope (Composio webhook shape): the message lives under +# `payload`, the routing ids under `metadata`. The dispatcher normalizes this +# into `event.attributes` + synthetic `event.trigger_*` before mapping. +_EVENT = { + "metadata": { + "trigger_id": "ti_1", + "trigger_slug": "github.issue.opened", + }, + "payload": {"issue": {"number": 7}}, +} + + +def test_build_context_normalizes_provider_envelope(): + project_id = uuid4() + subscription = _make_subscription() + dispatcher = TriggersDispatcher( + triggers_dao=MagicMock(), workflows_service=MagicMock() + ) + + context = dispatcher._build_context( + event=_EVENT, + subscription=subscription, + project_id=project_id, + ) + + event = context["event"] + assert event["trigger_id"] == "ti_1" + assert event["trigger_type"] == "github.issue.opened" + assert event["attributes"] == {"issue": {"number": 7}} + assert event["timestamp"] == event["created_at"] + # Raw provider keys never leak into the resolution context. + assert "payload" not in event + assert "metadata" not in event + assert context["scope"] == {"project_id": str(project_id)} + + +def test_build_context_tolerates_missing_metadata_and_payload(): + dispatcher = TriggersDispatcher( + triggers_dao=MagicMock(), workflows_service=MagicMock() + ) + + context = dispatcher._build_context( + event={}, + subscription=_make_subscription(), + project_id=uuid4(), + ) + + event = context["event"] + assert event["trigger_id"] is None + assert event["trigger_type"] is None + assert event["attributes"] is None async def test_unknown_trigger_id_is_skipped(): @@ -98,7 +148,7 @@ async def test_happy_path_invokes_workflow_and_writes_success(): reference = Reference(slug="wf-1") subscription = _make_subscription( references={"workflow": reference}, - inputs_fields={"number": "$.event.data.issue.number"}, + inputs_fields={"number": "$.event.attributes.issue.number"}, ) dao = _make_dao(resolved=(project_id, subscription)) diff --git a/api/oss/tests/pytest/unit/triggers/test_triggers_signature.py b/api/oss/tests/pytest/unit/triggers/test_triggers_signature.py index d0d49ee0b7..7a60df19ea 100644 --- a/api/oss/tests/pytest/unit/triggers/test_triggers_signature.py +++ b/api/oss/tests/pytest/unit/triggers/test_triggers_signature.py @@ -1,24 +1,23 @@ """Unit tests for Composio webhook signature verification. -Pure HMAC logic, no network or database. The acceptance suite only exercises -this path when ``COMPOSIO_WEBHOOK_SECRET`` is present in the runner; these tests -pin the security contract (forged/missing signatures rejected) unconditionally. +Pure HMAC logic, no network or database. Verification lives on +``TriggersService.verify_signature``; the secret is resolved from Composio +(cached encrypted in Redis), so here the resolver is stubbed. The contract: +forged/missing signatures and an unresolvable secret are all rejected. """ import hashlib import hmac -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock -from oss.src.apis.fastapi.triggers.router import _verify_composio_signature +from oss.src.core.triggers.service import TriggersService _SECRET = "whsec_test_secret" _WEBHOOK_ID = "wh-1" _TIMESTAMP = "1700000000" _BODY = b'{"type":"github.issue.opened"}' -_ENV_PATH = "oss.src.apis.fastapi.triggers.router.env" - def _sign(secret: str, webhook_id: str, timestamp: str, body: bytes) -> str: signed = f"{webhook_id}.{timestamp}.{body.decode('utf-8')}" @@ -29,78 +28,83 @@ def _sign(secret: str, webhook_id: str, timestamp: str, body: bytes) -> str: ).hexdigest() -class _Env: - """Minimal stand-in for the shared env object's composio config.""" - - class composio: # noqa: N801 - mirrors env.composio attribute access - webhook_secret = None - +def _service(*, secret): + """A TriggersService whose secret resolver returns ``secret``.""" + service = TriggersService( + adapter_registry=MagicMock(), + catalog_service=MagicMock(), + ) + service.webhook_secret_resolver.resolve = AsyncMock(return_value=secret) + return service -def _env_with_secret(secret): - env = _Env() - env.composio.webhook_secret = secret - return env +def _headers(sig): + return { + "webhook-signature": sig, + "webhook-id": _WEBHOOK_ID, + "webhook-timestamp": _TIMESTAMP, + } -class TestVerifyComposioSignature: - def test_unset_secret_is_noop_accept(self): - with patch(_ENV_PATH, _env_with_secret(None)): - assert _verify_composio_signature(body=_BODY, headers={}) is True - def test_valid_signature_accepted(self): +class TestVerifySignature: + async def test_valid_signature_accepted(self): + service = _service(secret=_SECRET) sig = _sign(_SECRET, _WEBHOOK_ID, _TIMESTAMP, _BODY) - headers = { - "webhook-signature": sig, - "webhook-id": _WEBHOOK_ID, - "webhook-timestamp": _TIMESTAMP, - } - with patch(_ENV_PATH, _env_with_secret(_SECRET)): - assert _verify_composio_signature(body=_BODY, headers=headers) is True + assert await service.verify_signature(body=_BODY, headers=_headers(sig)) is True - def test_valid_signature_with_versioned_prefix_accepted(self): + async def test_valid_signature_with_versioned_prefix_accepted(self): # Composio sends "v1,<sig>"; only the last comma-part is the digest. + service = _service(secret=_SECRET) sig = _sign(_SECRET, _WEBHOOK_ID, _TIMESTAMP, _BODY) - headers = { - "webhook-signature": f"v1,{sig}", - "webhook-id": _WEBHOOK_ID, - "webhook-timestamp": _TIMESTAMP, - } - with patch(_ENV_PATH, _env_with_secret(_SECRET)): - assert _verify_composio_signature(body=_BODY, headers=headers) is True - - def test_forged_signature_rejected(self): - headers = { - "webhook-signature": "deadbeef", - "webhook-id": _WEBHOOK_ID, - "webhook-timestamp": _TIMESTAMP, - } - with patch(_ENV_PATH, _env_with_secret(_SECRET)): - assert _verify_composio_signature(body=_BODY, headers=headers) is False - - def test_missing_signature_header_rejected(self): + headers = _headers(f"v1,{sig}") + assert await service.verify_signature(body=_BODY, headers=headers) is True + + async def test_forged_signature_rejected(self): + service = _service(secret=_SECRET) + assert ( + await service.verify_signature(body=_BODY, headers=_headers("deadbeef")) + is False + ) + + async def test_missing_signature_header_rejected(self): + service = _service(secret=_SECRET) headers = {"webhook-id": _WEBHOOK_ID, "webhook-timestamp": _TIMESTAMP} - with patch(_ENV_PATH, _env_with_secret(_SECRET)): - assert _verify_composio_signature(body=_BODY, headers=headers) is False + assert await service.verify_signature(body=_BODY, headers=headers) is False - def test_tampered_body_rejected(self): + async def test_tampered_body_rejected(self): + service = _service(secret=_SECRET) sig = _sign(_SECRET, _WEBHOOK_ID, _TIMESTAMP, _BODY) - headers = { - "webhook-signature": sig, - "webhook-id": _WEBHOOK_ID, - "webhook-timestamp": _TIMESTAMP, - } - with patch(_ENV_PATH, _env_with_secret(_SECRET)): - assert ( - _verify_composio_signature(body=b'{"type":"tampered"}', headers=headers) - is False + assert ( + await service.verify_signature( + body=b'{"type":"tampered"}', headers=_headers(sig) ) + is False + ) - def test_x_composio_signature_header_alias(self): + async def test_unresolvable_secret_rejected(self): + service = _service(secret=None) + sig = _sign(_SECRET, _WEBHOOK_ID, _TIMESTAMP, _BODY) + assert ( + await service.verify_signature(body=_BODY, headers=_headers(sig)) is False + ) + + async def test_x_composio_signature_header_alias(self): + service = _service(secret=_SECRET) sig = _sign(_SECRET, _WEBHOOK_ID, _TIMESTAMP, _BODY) headers = { "x-composio-signature": sig, "webhook-id": _WEBHOOK_ID, "webhook-timestamp": _TIMESTAMP, } - with patch(_ENV_PATH, _env_with_secret(_SECRET)): - assert _verify_composio_signature(body=_BODY, headers=headers) is True + assert await service.verify_signature(body=_BODY, headers=headers) is True + + async def test_mismatch_triggers_one_refresh_retry(self): + # First resolve returns a wrong secret; the forced refresh returns the + # right one — the valid signature must then be accepted. + service = _service(secret=_SECRET) + service.webhook_secret_resolver.resolve = AsyncMock( + side_effect=["wrong_secret", _SECRET] + ) + sig = _sign(_SECRET, _WEBHOOK_ID, _TIMESTAMP, _BODY) + assert await service.verify_signature(body=_BODY, headers=_headers(sig)) is True + assert service.webhook_secret_resolver.resolve.await_count == 2 diff --git a/hosting/docker-compose/ee/docker-compose.dev.yml b/hosting/docker-compose/ee/docker-compose.dev.yml index ead20d0bcd..5dcc36209c 100644 --- a/hosting/docker-compose/ee/docker-compose.dev.yml +++ b/hosting/docker-compose/ee/docker-compose.dev.yml @@ -631,6 +631,37 @@ services: # === LIFECYCLE ============================================ # restart: always + # Dev tunnel for Composio trigger events (disable: run.sh --no-tunnel). + composio: + # === ACTIVATION =========================================== # + profiles: + - with-tunnel + # === IMAGE ================================================ # + image: python:3.13-slim-trixie + # === EXECUTION ============================================ # + command: + - bash + - -c + - "pip install --quiet --root-user-action=ignore composio httpx && python /app/dispatcher_composio.py" + # === STORAGE ============================================== # + volumes: + - ../../../api/entrypoints/dispatcher_composio.py:/app/dispatcher_composio.py:ro + # === CONFIGURATION ======================================== # + env_file: + - ${ENV_FILE:-./.env.ee.dev} + environment: + AGENTA_INGRESS_URL: http://api:8000/triggers/composio/events/ + PYTHONUNBUFFERED: "1" + # === NETWORK ============================================== # + networks: + - agenta-network + # === ORCHESTRATION ======================================== # + depends_on: + api: + condition: service_started + # === LIFECYCLE ============================================ # + restart: always + networks: agenta-network: diff --git a/hosting/docker-compose/ee/docker-compose.gh.local.yml b/hosting/docker-compose/ee/docker-compose.gh.local.yml index 4565e37e32..65ab36ed80 100644 --- a/hosting/docker-compose/ee/docker-compose.gh.local.yml +++ b/hosting/docker-compose/ee/docker-compose.gh.local.yml @@ -513,6 +513,37 @@ services: # === LIFECYCLE ============================================ # restart: always + # Dev tunnel for Composio trigger events (disable: run.sh --no-tunnel). + composio: + # === ACTIVATION =========================================== # + profiles: + - with-tunnel + # === IMAGE ================================================ # + image: python:3.13-slim-trixie + # === EXECUTION ============================================ # + command: + - bash + - -c + - "pip install --quiet --root-user-action=ignore composio httpx && python /app/dispatcher_composio.py" + # === STORAGE ============================================== # + volumes: + - ../../../api/entrypoints/dispatcher_composio.py:/app/dispatcher_composio.py:ro + # === CONFIGURATION ======================================== # + env_file: + - ${ENV_FILE:-./.env.ee.gh} + environment: + AGENTA_INGRESS_URL: http://api:8000/triggers/composio/events/ + PYTHONUNBUFFERED: "1" + # === NETWORK ============================================== # + networks: + - agenta-ee-gh-network + # === ORCHESTRATION ======================================== # + depends_on: + api: + condition: service_started + # === LIFECYCLE ============================================ # + restart: always + networks: agenta-ee-gh-network: diff --git a/hosting/docker-compose/ee/docker-compose.gh.yml b/hosting/docker-compose/ee/docker-compose.gh.yml index e25c845cc8..1f421786b0 100644 --- a/hosting/docker-compose/ee/docker-compose.gh.yml +++ b/hosting/docker-compose/ee/docker-compose.gh.yml @@ -480,6 +480,37 @@ services: timeout: 5s retries: 5 + # Dev tunnel for Composio trigger events (disable: run.sh --no-tunnel). + composio: + # === ACTIVATION =========================================== # + profiles: + - with-tunnel + # === IMAGE ================================================ # + image: python:3.13-slim-trixie + # === EXECUTION ============================================ # + command: + - bash + - -c + - "pip install --quiet --root-user-action=ignore composio httpx && python /app/dispatcher_composio.py" + # === STORAGE ============================================== # + volumes: + - ../../../api/entrypoints/dispatcher_composio.py:/app/dispatcher_composio.py:ro + # === CONFIGURATION ======================================== # + env_file: + - ${ENV_FILE:-./.env.ee.gh} + environment: + AGENTA_INGRESS_URL: http://api:8000/triggers/composio/events/ + PYTHONUNBUFFERED: "1" + # === NETWORK ============================================== # + networks: + - agenta-ee-gh-network + # === ORCHESTRATION ======================================== # + depends_on: + api: + condition: service_started + # === LIFECYCLE ============================================ # + restart: always + networks: agenta-ee-gh-network: diff --git a/hosting/docker-compose/oss/docker-compose.dev.yml b/hosting/docker-compose/oss/docker-compose.dev.yml index 7d482328cc..692a364c4e 100644 --- a/hosting/docker-compose/oss/docker-compose.dev.yml +++ b/hosting/docker-compose/oss/docker-compose.dev.yml @@ -612,6 +612,37 @@ services: # # + # Dev tunnel for Composio trigger events (disable: run.sh --no-tunnel). + composio: + # === ACTIVATION =========================================== # + profiles: + - with-tunnel + # === IMAGE ================================================ # + image: python:3.13-slim-trixie + # === EXECUTION ============================================ # + command: + - bash + - -c + - "pip install --quiet --root-user-action=ignore composio httpx && python /app/dispatcher_composio.py" + # === STORAGE ============================================== # + volumes: + - ../../../api/entrypoints/dispatcher_composio.py:/app/dispatcher_composio.py:ro + # === CONFIGURATION ======================================== # + env_file: + - ${ENV_FILE:-./.env.oss.dev} + environment: + AGENTA_INGRESS_URL: http://api:8000/triggers/composio/events/ + PYTHONUNBUFFERED: "1" + # === NETWORK ============================================== # + networks: + - agenta-network + # === ORCHESTRATION ======================================== # + depends_on: + api: + condition: service_started + # === LIFECYCLE ============================================ # + restart: always + networks: agenta-network: diff --git a/hosting/docker-compose/oss/docker-compose.gh.local.yml b/hosting/docker-compose/oss/docker-compose.gh.local.yml index c84f4db9fd..87df7dbd7a 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.local.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.local.yml @@ -516,6 +516,37 @@ services: timeout: 5s retries: 5 + # Dev tunnel for Composio trigger events (disable: run.sh --no-tunnel). + composio: + # === ACTIVATION =========================================== # + profiles: + - with-tunnel + # === IMAGE ================================================ # + image: python:3.13-slim-trixie + # === EXECUTION ============================================ # + command: + - bash + - -c + - "pip install --quiet --root-user-action=ignore composio httpx && python /app/dispatcher_composio.py" + # === STORAGE ============================================== # + volumes: + - ../../../api/entrypoints/dispatcher_composio.py:/app/dispatcher_composio.py:ro + # === CONFIGURATION ======================================== # + env_file: + - ${ENV_FILE:-./.env.oss.gh} + environment: + AGENTA_INGRESS_URL: http://api:8000/triggers/composio/events/ + PYTHONUNBUFFERED: "1" + # === NETWORK ============================================== # + networks: + - agenta-oss-gh-network + # === ORCHESTRATION ======================================== # + depends_on: + api: + condition: service_started + # === LIFECYCLE ============================================ # + restart: always + networks: agenta-oss-gh-network: diff --git a/hosting/docker-compose/oss/docker-compose.gh.ssl.yml b/hosting/docker-compose/oss/docker-compose.gh.ssl.yml index 94700680ad..23982b7b78 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.ssl.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.ssl.yml @@ -503,6 +503,37 @@ services: timeout: 5s retries: 5 + # Dev tunnel for Composio trigger events (disable: run.sh --no-tunnel). + composio: + # === ACTIVATION =========================================== # + profiles: + - with-tunnel + # === IMAGE ================================================ # + image: python:3.13-slim-trixie + # === EXECUTION ============================================ # + command: + - bash + - -c + - "pip install --quiet --root-user-action=ignore composio httpx && python /app/dispatcher_composio.py" + # === STORAGE ============================================== # + volumes: + - ../../../api/entrypoints/dispatcher_composio.py:/app/dispatcher_composio.py:ro + # === CONFIGURATION ======================================== # + env_file: + - ${ENV_FILE:-./.env.oss.gh} + environment: + AGENTA_INGRESS_URL: http://api:8000/triggers/composio/events/ + PYTHONUNBUFFERED: "1" + # === NETWORK ============================================== # + networks: + - agenta-gh-ssl-network + # === ORCHESTRATION ======================================== # + depends_on: + api: + condition: service_started + # === LIFECYCLE ============================================ # + restart: always + networks: agenta-gh-ssl-network: diff --git a/hosting/docker-compose/oss/docker-compose.gh.yml b/hosting/docker-compose/oss/docker-compose.gh.yml index f0a78b3b66..336dadca20 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.yml @@ -540,6 +540,37 @@ services: timeout: 5s retries: 5 + # Dev tunnel for Composio trigger events (disable: run.sh --no-tunnel). + composio: + # === ACTIVATION =========================================== # + profiles: + - with-tunnel + # === IMAGE ================================================ # + image: python:3.13-slim-trixie + # === EXECUTION ============================================ # + command: + - bash + - -c + - "pip install --quiet --root-user-action=ignore composio httpx && python /app/dispatcher_composio.py" + # === STORAGE ============================================== # + volumes: + - ../../../api/entrypoints/dispatcher_composio.py:/app/dispatcher_composio.py:ro + # === CONFIGURATION ======================================== # + env_file: + - ${ENV_FILE:-./.env.oss.gh} + environment: + AGENTA_INGRESS_URL: http://api:8000/triggers/composio/events/ + PYTHONUNBUFFERED: "1" + # === NETWORK ============================================== # + networks: + - agenta-oss-gh-network + # === ORCHESTRATION ======================================== # + depends_on: + api: + condition: service_started + # === LIFECYCLE ============================================ # + restart: always + networks: agenta-oss-gh-network: diff --git a/hosting/docker-compose/run.sh b/hosting/docker-compose/run.sh index d79a98b270..4ba3e9fb76 100755 --- a/hosting/docker-compose/run.sh +++ b/hosting/docker-compose/run.sh @@ -19,6 +19,7 @@ NO_CACHE=false # Default to using cache PULL_ENABLED= # Stage-dependent default applied after parsing: gh→true, dev→false NUKE=false # Default to not nuking volumes DOWN=false # Default to up; --down only stops containers +WITH_TUNNEL=true # Composio trigger-event tunnel; disable with --no-tunnel show_usage() { echo "Usage: $0 [OPTIONS]" @@ -58,6 +59,10 @@ show_usage() { echo " --ssl Use SSL proxy stage (requires --image gh)" echo " --nginx Use nginx proxy (default: traefik)" echo "" + echo "Triggers:" + echo " --no-tunnel Disable the Composio trigger-event tunnel" + echo " (use when the host has a public ingress URL)" + echo "" echo "Miscellaneous:" echo " --help Show this help message and exit" exit 0 @@ -228,6 +233,9 @@ while [[ "$#" -gt 0 ]]; do --nuke) NUKE=true ;; + --no-tunnel) + WITH_TUNNEL=false + ;; --down) DOWN=true ;; @@ -332,6 +340,10 @@ else COMPOSE_CMD+=" --profile with-traefik" fi +if $WITH_TUNNEL; then + COMPOSE_CMD+=" --profile with-tunnel" +fi + if $NO_CACHE; then echo "Building containers with no cache..." $COMPOSE_CMD build --parallel --no-cache || error_exit "Build failed" @@ -348,7 +360,7 @@ fi echo "Stopping existing Docker containers..." # Include all profiles to ensure clean shutdown -SHUTDOWN_CMD="$COMPOSE_CMD --profile with-web --profile with-nginx --profile with-traefik down" +SHUTDOWN_CMD="$COMPOSE_CMD --profile with-web --profile with-nginx --profile with-traefik --profile with-tunnel down" if $NUKE; then SHUTDOWN_CMD+=" --volumes" diff --git a/web/_reference/agenta-sdk/src/types.ts b/web/_reference/agenta-sdk/src/types.ts index e49b09ed78..5ab9334a9a 100644 --- a/web/_reference/agenta-sdk/src/types.ts +++ b/web/_reference/agenta-sdk/src/types.ts @@ -102,7 +102,7 @@ export interface QueryRevisionResponse { } | null } -// ─── Webhook / Automation ─────────────────────────────────────────────────── +// ─── Webhook ──────────────────────────────────────--------------───────────── export type WebhookEventType = "environments.revisions.committed" | "webhooks.subscriptions.tested" @@ -231,7 +231,7 @@ export interface WebhookDeliveriesResponse { deliveries: WebhookDelivery[] } -// ─── Automation Form Types (UI-level, not API DTOs) ───────────────────────── +// ─── Webhook Form Types (UI-level, not API DTOs) ──────────----─────────────── export type AutomationProvider = "webhook" | "github" diff --git a/web/oss/src/components/DrillInView/OSSdrillInUIProvider.tsx b/web/oss/src/components/DrillInView/OSSdrillInUIProvider.tsx index 761655c75d..c62fb4993e 100644 --- a/web/oss/src/components/DrillInView/OSSdrillInUIProvider.tsx +++ b/web/oss/src/components/DrillInView/OSSdrillInUIProvider.tsx @@ -25,11 +25,11 @@ import {useMemo, type ReactNode} from "react" import { buildToolSlug, - catalogDrawerOpenAtom, - fetchActionDetail as fetchToolActionDetail, - useCatalogActions, - useConnectionsQuery, - useIntegrationDetail, + fetchToolActionDetail, + toolCatalogDrawerOpenAtom, + useToolCatalogActions, + useToolConnectionsQuery, + useToolIntegrationDetail, } from "@agenta/entities/gatewayTool" import {DrillInUIProvider, type GatewayToolsBridge} from "@agenta/entity-ui/drill-in" import {EditorProvider} from "@agenta/ui/editor" @@ -44,7 +44,7 @@ interface OSSdrillInUIProviderProps { } function useGatewayToolsIntegrationInfo(integrationKey: string) { - const {integration, isLoading} = useIntegrationDetail(integrationKey) + const {integration, isLoading} = useToolIntegrationDetail(integrationKey) return { name: integration?.name, logo: integration?.logo, @@ -53,7 +53,7 @@ function useGatewayToolsIntegrationInfo(integrationKey: string) { } function useGatewayToolsCatalogActions(integrationKey: string) { - const res = useCatalogActions(integrationKey) + const res = useToolCatalogActions(integrationKey) return { actions: res.actions.map((action) => ({key: action.key, name: action.name})), total: res.total, @@ -115,8 +115,8 @@ function GatewayToolsEnabledProvider({ children: ReactNode llmProviderConfig: ReturnType<typeof useLLMProviderConfig>["llmProviderConfig"] }) { - const {connections, isLoading} = useConnectionsQuery() - const setCatalogDrawerOpen = useSetAtom(catalogDrawerOpenAtom) + const {connections, isLoading} = useToolConnectionsQuery() + const setCatalogDrawerOpen = useSetAtom(toolCatalogDrawerOpenAtom) const gatewayTools = useMemo<GatewayToolsBridge>( () => ({ diff --git a/web/oss/src/components/Playground/Components/PlaygroundVariantConfigPrompt/assets/GatewayToolsPanel.tsx b/web/oss/src/components/Playground/Components/PlaygroundVariantConfigPrompt/assets/GatewayToolsPanel.tsx index 63c58c794c..a49af971e9 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundVariantConfigPrompt/assets/GatewayToolsPanel.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundVariantConfigPrompt/assets/GatewayToolsPanel.tsx @@ -1,10 +1,10 @@ import {useMemo} from "react" import { - useConnectionsQuery, - catalogDrawerOpenAtom, - executionDrawerAtom, - useIntegrationDetail, + toolCatalogDrawerOpenAtom, + toolExecutionDrawerAtom, + useToolConnectionsQuery, + useToolIntegrationDetail, type ToolConnection, } from "@agenta/entities/gatewayTool" import { @@ -22,9 +22,9 @@ interface GatewayToolsPanelProps { } export default function GatewayToolsPanel({mountDrawers = false}: GatewayToolsPanelProps) { - const {connections, isLoading, refetch} = useConnectionsQuery() - const setCatalogOpen = useSetAtom(catalogDrawerOpenAtom) - const setExecutionDrawer = useSetAtom(executionDrawerAtom) + const {connections, isLoading, refetch} = useToolConnectionsQuery() + const setCatalogOpen = useSetAtom(toolCatalogDrawerOpenAtom) + const setExecutionDrawer = useSetAtom(toolExecutionDrawerAtom) // Group connections by integration const grouped = useMemo(() => { @@ -113,7 +113,7 @@ export default function GatewayToolsPanel({mountDrawers = false}: GatewayToolsPa } function IntegrationSectionLabel({integrationKey}: {integrationKey: string}) { - const {integration} = useIntegrationDetail(integrationKey) + const {integration} = useToolIntegrationDetail(integrationKey) const label = integration?.name || integrationKey.replace(/_/g, " ") const logo = integration?.logo @@ -136,7 +136,7 @@ function IntegrationSectionLabel({integrationKey}: {integrationKey: string}) { function ConnectionRow({connection, onTest}: {connection: ToolConnection; onTest: () => void}) { const isReady = connection.flags?.is_active && connection.flags?.is_valid - const {integration} = useIntegrationDetail(connection.integration_key) + const {integration} = useToolIntegrationDetail(connection.integration_key) const label = integration?.name || connection.integration_key.replace(/_/g, " ") const logo = integration?.logo diff --git a/web/oss/src/components/Sidebar/SettingsSidebar.tsx b/web/oss/src/components/Sidebar/SettingsSidebar.tsx index 93529955c6..cc735e12d3 100644 --- a/web/oss/src/components/Sidebar/SettingsSidebar.tsx +++ b/web/oss/src/components/Sidebar/SettingsSidebar.tsx @@ -99,7 +99,7 @@ const SettingsSidebar: FC<SettingsSidebarProps> = ({lastPath}) => { : []), { key: "secrets", - title: "Providers & Models", + title: "Models", icon: <Sparkle size={16} className="mt-0.5" />, }, ...(canShowTools @@ -121,8 +121,8 @@ const SettingsSidebar: FC<SettingsSidebarProps> = ({lastPath}) => { ] : []), { - key: "automations", - title: "Automations", + key: "webhooks", + title: "Webhooks", icon: <Link size={16} className="mt-0.5" />, divider: true, }, diff --git a/web/oss/src/components/Automations/Modals/DeleteAutomationModal.tsx b/web/oss/src/components/Webhooks/Modals/DeleteWebhookModal.tsx similarity index 67% rename from web/oss/src/components/Automations/Modals/DeleteAutomationModal.tsx rename to web/oss/src/components/Webhooks/Modals/DeleteWebhookModal.tsx index 5456b45c05..acfd639c18 100644 --- a/web/oss/src/components/Automations/Modals/DeleteAutomationModal.tsx +++ b/web/oss/src/components/Webhooks/Modals/DeleteWebhookModal.tsx @@ -4,11 +4,11 @@ import {EnhancedModal} from "@agenta/ui" import {message} from "antd" import {useAtom, useSetAtom} from "jotai" -import {deleteAutomationAtom} from "@/oss/state/automations/atoms" -import {webhookToDeleteAtom} from "@/oss/state/automations/state" +import {deleteWebhookAtom} from "@/oss/state/webhooks/atoms" +import {webhookToDeleteAtom} from "@/oss/state/webhooks/state" -const DeleteAutomationModal = () => { - const deleteWebhookSubscription = useSetAtom(deleteAutomationAtom) +const DeleteWebhookModal = () => { + const deleteWebhookSubscription = useSetAtom(deleteWebhookAtom) const [webhookToDelete, setWebhookToDelete] = useAtom(webhookToDeleteAtom) const [isDeleteModalLoading, setIsDeleteModalLoading] = useState(false) @@ -17,10 +17,10 @@ const DeleteAutomationModal = () => { setIsDeleteModalLoading(true) try { await deleteWebhookSubscription(webhookToDelete.id) - message.success("Automation deleted successfully") + message.success("Webhook deleted successfully") setWebhookToDelete(null) } catch (error) { - message.error("Failed to delete automation") + message.error("Failed to delete webhook") } finally { setIsDeleteModalLoading(false) } @@ -28,7 +28,7 @@ const DeleteAutomationModal = () => { return ( <EnhancedModal - title="Delete Automation" + title="Delete Webhook" open={!!webhookToDelete} onOk={handleDeleteConfirm} onCancel={() => setWebhookToDelete(null)} @@ -38,9 +38,9 @@ const DeleteAutomationModal = () => { confirmLoading={isDeleteModalLoading} okButtonProps={{danger: true}} > - <p>Are you sure you want to delete this automation?</p> + <p>Are you sure you want to delete this webhook?</p> </EnhancedModal> ) } -export default DeleteAutomationModal +export default DeleteWebhookModal diff --git a/web/oss/src/components/Automations/Modals/SecretRevealModal.tsx b/web/oss/src/components/Webhooks/Modals/SecretRevealModal.tsx similarity index 96% rename from web/oss/src/components/Automations/Modals/SecretRevealModal.tsx rename to web/oss/src/components/Webhooks/Modals/SecretRevealModal.tsx index c462699398..de01df49c9 100644 --- a/web/oss/src/components/Automations/Modals/SecretRevealModal.tsx +++ b/web/oss/src/components/Webhooks/Modals/SecretRevealModal.tsx @@ -5,7 +5,7 @@ import {Typography} from "antd" import {useAtom} from "jotai" import {copyToClipboard} from "@/oss/lib/helpers/copyToClipboard" -import {createdWebhookSecretAtom} from "@/oss/state/automations/state" +import {createdWebhookSecretAtom} from "@/oss/state/webhooks/state" const SecretRevealModal: React.FC = () => { const [createdWebhookSecret, setCreatedWebhookSecret] = useAtom(createdWebhookSecretAtom) diff --git a/web/oss/src/components/Automations/RequestPreview.tsx b/web/oss/src/components/Webhooks/RequestPreview.tsx similarity index 92% rename from web/oss/src/components/Automations/RequestPreview.tsx rename to web/oss/src/components/Webhooks/RequestPreview.tsx index c0954acc46..08af407faa 100644 --- a/web/oss/src/components/Automations/RequestPreview.tsx +++ b/web/oss/src/components/Webhooks/RequestPreview.tsx @@ -4,10 +4,10 @@ import {CheckOutlined, CopyOutlined} from "@ant-design/icons" import {Button, Form, FormInstance, Tooltip} from "antd" import {useAtomValue} from "jotai" -import {AutomationFormValues} from "@/oss/services/automations/types" -import {editingAutomationAtom} from "@/oss/state/automations/state" +import {WebhookFormValues} from "@/oss/services/webhooks/types" import {userAtom} from "@/oss/state/profile/selectors/user" import {projectIdAtom} from "@/oss/state/project" +import {editingWebhookAtom} from "@/oss/state/webhooks/state" import {buildPreviewRequest} from "./utils/buildPreviewRequest" @@ -71,9 +71,9 @@ export const RequestPreview: FC<Props> = ({form}) => { const [copied, setCopied] = useState(false) const projectId = useAtomValue(projectIdAtom) const user = useAtomValue(userAtom) - const editingAutomation = useAtomValue(editingAutomationAtom) + const editingWebhook = useAtomValue(editingWebhookAtom) - const formValues: AutomationFormValues = Form.useWatch((values) => values, form) || { + const formValues: WebhookFormValues = Form.useWatch((values) => values, form) || { provider: "webhook", } @@ -81,13 +81,13 @@ export const RequestPreview: FC<Props> = ({form}) => { try { return buildPreviewRequest(formValues, { projectId: projectId || undefined, - subscriptionId: editingAutomation?.id, + subscriptionId: editingWebhook?.id, userId: user?.id, }) } catch { return null } - }, [formValues, projectId, user?.id, editingAutomation?.id]) + }, [formValues, projectId, user?.id, editingWebhook?.id]) if (!preview || !preview.url) { return null diff --git a/web/oss/src/components/Automations/AutomationDrawer.tsx b/web/oss/src/components/Webhooks/WebhookDrawer.tsx similarity index 85% rename from web/oss/src/components/Automations/AutomationDrawer.tsx rename to web/oss/src/components/Webhooks/WebhookDrawer.tsx index 4892f53c79..c40bb0b932 100644 --- a/web/oss/src/components/Automations/AutomationDrawer.tsx +++ b/web/oss/src/components/Webhooks/WebhookDrawer.tsx @@ -6,42 +6,38 @@ import {useAtom, useSetAtom} from "jotai" import EnhancedDrawer from "@/oss/components/EnhancedUIs/Drawer" import { - AutomationProvider, + WebhookProvider, WebhookSubscriptionCreateRequest, WebhookSubscriptionEditRequest, -} from "@/oss/services/automations/types" -import { - createAutomationAtom, - testAutomationAtom, - updateAutomationAtom, -} from "@/oss/state/automations/atoms" +} from "@/oss/services/webhooks/types" +import {createWebhookAtom, testWebhookAtom, updateWebhookAtom} from "@/oss/state/webhooks/atoms" import { createdWebhookSecretAtom, - editingAutomationAtom, - isAutomationDrawerOpenAtom, + editingWebhookAtom, + isWebhookDrawerOpenAtom, selectedProviderAtom, -} from "@/oss/state/automations/state" +} from "@/oss/state/webhooks/state" -import {AUTOMATION_SCHEMA, EVENT_OPTIONS} from "./assets/constants" -import {AutomationFieldRenderer} from "./AutomationFieldRenderer" -import AutomationLogsTab from "./AutomationLogsTab" +import {WEBHOOK_SCHEMA, EVENT_OPTIONS} from "./assets/constants" import {RequestPreview} from "./RequestPreview" import {buildSubscription} from "./utils/buildSubscription" -import {AUTOMATION_TEST_FAILURE_MESSAGE, handleTestResult} from "./utils/handleTestResult" +import {WEBHOOK_TEST_FAILURE_MESSAGE, handleTestResult} from "./utils/handleTestResult" +import {WebhookFieldRenderer} from "./WebhookFieldRenderer" +import WebhookLogsTab from "./WebhookLogsTab" -const AutomationDrawer = ({onSuccess}: {onSuccess: () => void}) => { +const WebhookDrawer = ({onSuccess}: {onSuccess: () => void}) => { const [form] = Form.useForm() - const [open, setOpen] = useAtom(isAutomationDrawerOpenAtom) - const [initialValues, setEditingWebhook] = useAtom(editingAutomationAtom) + const [open, setOpen] = useAtom(isWebhookDrawerOpenAtom) + const [initialValues, setEditingWebhook] = useAtom(editingWebhookAtom) const [activeTab, setActiveTab] = useState("configuration") const [isTesting, setIsTesting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) const setCreatedWebhookSecret = useSetAtom(createdWebhookSecretAtom) const [selectedProvider, setSelectedProvider] = useAtom(selectedProviderAtom) - const createAutomation = useSetAtom(createAutomationAtom) - const testAutomation = useSetAtom(testAutomationAtom) - const updateAutomation = useSetAtom(updateAutomationAtom) + const createWebhook = useSetAtom(createWebhookAtom) + const testWebhook = useSetAtom(testWebhookAtom) + const updateWebhook = useSetAtom(updateWebhookAtom) const isEdit = !!initialValues @@ -68,7 +64,7 @@ const AutomationDrawer = ({onSuccess}: {onSuccess: () => void}) => { } catch { isGitHub = false } - const provider: AutomationProvider = isGitHub ? "github" : "webhook" + const provider: WebhookProvider = isGitHub ? "github" : "webhook" setSelectedProvider(provider) // Map the headers from Record<string, string> back to Antd Form.List [{key, value}] @@ -160,16 +156,16 @@ const AutomationDrawer = ({onSuccess}: {onSuccess: () => void}) => { try { setIsTesting(true) const {payload} = await buildPayloadFromForm() - const response = await testAutomation(payload) + const response = await testWebhook(payload) handleTestResult(response) } catch (error) { if ((error as {errorFields?: unknown}).errorFields) return console.error(error) - message.error(AUTOMATION_TEST_FAILURE_MESSAGE, 10) + message.error(WEBHOOK_TEST_FAILURE_MESSAGE, 10) } finally { setIsTesting(false) } - }, [buildPayloadFromForm, open, testAutomation]) + }, [buildPayloadFromForm, open, testWebhook]) const handleOk = useCallback(async () => { try { @@ -182,7 +178,7 @@ const AutomationDrawer = ({onSuccess}: {onSuccess: () => void}) => { | undefined if (isEdit && initialValues?.id) { - await updateAutomation({ + await updateWebhook({ webhookSubscriptionId: initialValues.id, payload: payload as WebhookSubscriptionEditRequest, }) @@ -193,9 +189,9 @@ const AutomationDrawer = ({onSuccess}: {onSuccess: () => void}) => { id: initialValues.id, }, } - message.success("Automation updated successfully") + message.success("Webhook updated successfully") } else { - const response = await createAutomation(payload as WebhookSubscriptionCreateRequest) + const response = await createWebhook(payload as WebhookSubscriptionCreateRequest) subscriptionId = response.subscription?.id const webhookSecret = response.subscription?.secret || response.subscription?.secret_id @@ -218,7 +214,7 @@ const AutomationDrawer = ({onSuccess}: {onSuccess: () => void}) => { } } - message.success("Automation created successfully") + message.success("Webhook created successfully") } onSuccess() @@ -226,12 +222,12 @@ const AutomationDrawer = ({onSuccess}: {onSuccess: () => void}) => { if (subscriptionId && testPayload) { try { - const response = await testAutomation(testPayload) + const response = await testWebhook(testPayload) handleTestResult(response) } catch (error) { console.error(error) message.warning( - "Automation saved, but the connection test could not complete. You can retry it from the drawer or table.", + "Webhook saved, but the connection test could not complete. You can retry it from the drawer or table.", 10, ) } @@ -239,7 +235,7 @@ const AutomationDrawer = ({onSuccess}: {onSuccess: () => void}) => { } catch (error) { if ((error as {errorFields?: unknown}).errorFields) return console.error(error) - message.error(isEdit ? "Failed to update automation" : "Failed to create automation") + message.error(isEdit ? "Failed to update webhook" : "Failed to create webhook") } finally { setIsSubmitting(false) } @@ -251,15 +247,15 @@ const AutomationDrawer = ({onSuccess}: {onSuccess: () => void}) => { onCancel, setCreatedWebhookSecret, buildPayloadFromForm, - createAutomation, - testAutomation, - updateAutomation, + createWebhook, + testWebhook, + updateWebhook, selectedProvider, ]) const providerOptions = useMemo( () => - AUTOMATION_SCHEMA.map((provider) => ({ + WEBHOOK_SCHEMA.map((provider) => ({ label: ( <div className="flex items-center gap-2"> {createElement(provider.icon)} @@ -272,7 +268,7 @@ const AutomationDrawer = ({onSuccess}: {onSuccess: () => void}) => { ) const selectedProviderConfig = useMemo( - () => AUTOMATION_SCHEMA.find((s) => s.provider === selectedProvider), + () => WEBHOOK_SCHEMA.find((s) => s.provider === selectedProvider), [selectedProvider], ) @@ -289,8 +285,8 @@ const AutomationDrawer = ({onSuccess}: {onSuccess: () => void}) => { children: ( <div className="flex flex-col gap-3"> <div className="mb-4 text-gray-500"> - Set up an automation to trigger external services when specific events - occur within Agenta. + Set up a webhook to trigger external services when specific events occur + within Agenta. </div> <Form @@ -354,7 +350,7 @@ const AutomationDrawer = ({onSuccess}: {onSuccess: () => void}) => { {selectedProviderConfig.subtitle} </Typography.Text> </div> - <AutomationFieldRenderer + <WebhookFieldRenderer fields={selectedProviderConfig.fields} isEditMode={isEdit} /> @@ -385,7 +381,7 @@ const AutomationDrawer = ({onSuccess}: {onSuccess: () => void}) => { label: "Logs", children: activeTab === "logs" ? ( - <AutomationLogsTab subscriptionId={initialValues.id} /> + <WebhookLogsTab subscriptionId={initialValues.id} /> ) : null, }, ] @@ -405,7 +401,7 @@ const AutomationDrawer = ({onSuccess}: {onSuccess: () => void}) => { return ( <> <EnhancedDrawer - title={isEdit ? "Edit Automation" : "Add Automation"} + title={isEdit ? "Edit Webhook" : "Add Webhook"} extra={ <Tooltip title="Documentation"> <Button @@ -415,7 +411,7 @@ const AutomationDrawer = ({onSuccess}: {onSuccess: () => void}) => { href={docsUrl} target="_blank" rel="noopener noreferrer" - aria-label="Open automation documentation" + aria-label="Open webhook documentation" /> </Tooltip> } @@ -435,7 +431,7 @@ const AutomationDrawer = ({onSuccess}: {onSuccess: () => void}) => { Test Connection </Button> <Button type="primary" onClick={handleOk} loading={isSubmitting}> - {isEdit ? "Update Automation" : "Create Automation"} + {isEdit ? "Update Webhook" : "Create Webhook"} </Button> </div> </div> @@ -454,4 +450,4 @@ const AutomationDrawer = ({onSuccess}: {onSuccess: () => void}) => { ) } -export default AutomationDrawer +export default WebhookDrawer diff --git a/web/oss/src/components/Automations/AutomationFieldRenderer.tsx b/web/oss/src/components/Webhooks/WebhookFieldRenderer.tsx similarity index 98% rename from web/oss/src/components/Automations/AutomationFieldRenderer.tsx rename to web/oss/src/components/Webhooks/WebhookFieldRenderer.tsx index c23b4f1381..b5ee9f207d 100644 --- a/web/oss/src/components/Automations/AutomationFieldRenderer.tsx +++ b/web/oss/src/components/Webhooks/WebhookFieldRenderer.tsx @@ -143,7 +143,7 @@ const FieldRendererItem = ({field, isEditMode}: {field: FieldDescriptor; isEditM ) } -export const AutomationFieldRenderer = ({fields, isEditMode}: Props) => { +export const WebhookFieldRenderer = ({fields, isEditMode}: Props) => { return ( <> {fields.map((field) => ( diff --git a/web/oss/src/components/Automations/AutomationLogsTab.tsx b/web/oss/src/components/Webhooks/WebhookLogsTab.tsx similarity index 93% rename from web/oss/src/components/Automations/AutomationLogsTab.tsx rename to web/oss/src/components/Webhooks/WebhookLogsTab.tsx index de9c03bba2..9cea4a8419 100644 --- a/web/oss/src/components/Automations/AutomationLogsTab.tsx +++ b/web/oss/src/components/Webhooks/WebhookLogsTab.tsx @@ -5,8 +5,8 @@ import {Empty, Skeleton} from "antd" import {useAtomValue} from "jotai" import SimpleSharedEditor from "@/oss/components/EditorViews/SimpleSharedEditor" -import {WebhookDelivery} from "@/oss/services/automations/types" -import {automationDeliveriesAtomFamily} from "@/oss/state/automations/atoms" +import {WebhookDelivery} from "@/oss/services/webhooks/types" +import {webhookDeliveriesAtomFamily} from "@/oss/state/webhooks/atoms" const formatTimestamp = (value?: string) => { if (!value) return "-" @@ -81,10 +81,8 @@ const DeliveryListItem = ({ ) } -export const AutomationLogsTab = ({subscriptionId}: {subscriptionId: string}) => { - const {data: deliveries, isPending} = useAtomValue( - automationDeliveriesAtomFamily(subscriptionId), - ) +export const WebhookLogsTab = ({subscriptionId}: {subscriptionId: string}) => { + const {data: deliveries, isPending} = useAtomValue(webhookDeliveriesAtomFamily(subscriptionId)) const [selectedDeliveryId, setSelectedDeliveryId] = useState<string | null>(null) useEffect(() => { @@ -177,4 +175,4 @@ export const AutomationLogsTab = ({subscriptionId}: {subscriptionId: string}) => ) } -export default AutomationLogsTab +export default WebhookLogsTab diff --git a/web/oss/src/components/Automations/assets/constants.ts b/web/oss/src/components/Webhooks/assets/constants.ts similarity index 98% rename from web/oss/src/components/Automations/assets/constants.ts rename to web/oss/src/components/Webhooks/assets/constants.ts index 70e865e070..5f0dfdd616 100644 --- a/web/oss/src/components/Automations/assets/constants.ts +++ b/web/oss/src/components/Webhooks/assets/constants.ts @@ -1,6 +1,6 @@ import {GithubOutlined, LinkOutlined} from "@ant-design/icons" -import {AutomationSchemaEntry} from "../assets/types" +import {WebhookSchemaEntry} from "../assets/types" export const EVENT_OPTIONS = [ { @@ -39,7 +39,7 @@ export const GITHUB_PAYLOAD_TEMPLATES: Record<string, Record<string, unknown>> = }, } -export const AUTOMATION_SCHEMA: AutomationSchemaEntry[] = [ +export const WEBHOOK_SCHEMA: WebhookSchemaEntry[] = [ { provider: "webhook", label: "Webhook", diff --git a/web/oss/src/components/Automations/assets/types.ts b/web/oss/src/components/Webhooks/assets/types.ts similarity index 87% rename from web/oss/src/components/Automations/assets/types.ts rename to web/oss/src/components/Webhooks/assets/types.ts index ff04b8c731..380256b27e 100644 --- a/web/oss/src/components/Automations/assets/types.ts +++ b/web/oss/src/components/Webhooks/assets/types.ts @@ -2,7 +2,7 @@ import React from "react" import type {Rule} from "antd/lib/form" -import {AutomationProvider} from "@/oss/services/automations/types" +import {WebhookProvider} from "@/oss/services/webhooks/types" export type FieldComponent = | "input" @@ -32,8 +32,8 @@ export interface FieldDescriptor { } } -export interface AutomationSchemaEntry { - provider: AutomationProvider +export interface WebhookSchemaEntry { + provider: WebhookProvider label: string icon: React.ComponentType description: string diff --git a/web/oss/src/components/Automations/utils/buildPreviewRequest.ts b/web/oss/src/components/Webhooks/utils/buildPreviewRequest.ts similarity index 98% rename from web/oss/src/components/Automations/utils/buildPreviewRequest.ts rename to web/oss/src/components/Webhooks/utils/buildPreviewRequest.ts index ce9428c2a9..4b5c1465c7 100644 --- a/web/oss/src/components/Automations/utils/buildPreviewRequest.ts +++ b/web/oss/src/components/Webhooks/utils/buildPreviewRequest.ts @@ -1,4 +1,4 @@ -import {AutomationFormValues, WebhookEventType} from "@/oss/services/automations/types" +import {WebhookFormValues, WebhookEventType} from "@/oss/services/webhooks/types" import {GITHUB_HEADERS, GITHUB_PAYLOAD_TEMPLATES, GITHUB_URL_TEMPLATES} from "../assets/constants" @@ -170,7 +170,7 @@ const resolvePayloadMocks = (payload: any, eventContext: Record<string, any>): a * Masks tokens and resolves payload templates so the user sees what Agenta sends. */ export const buildPreviewRequest = ( - formValues: AutomationFormValues, + formValues: WebhookFormValues, ctx?: PreviewContext, ): PreviewRequest => { const { diff --git a/web/oss/src/components/Automations/utils/buildSubscription.ts b/web/oss/src/components/Webhooks/utils/buildSubscription.ts similarity index 96% rename from web/oss/src/components/Automations/utils/buildSubscription.ts rename to web/oss/src/components/Webhooks/utils/buildSubscription.ts index 3a23c7d379..73cd543e92 100644 --- a/web/oss/src/components/Automations/utils/buildSubscription.ts +++ b/web/oss/src/components/Webhooks/utils/buildSubscription.ts @@ -1,8 +1,8 @@ import { - AutomationFormValues, + WebhookFormValues, WebhookSubscriptionCreateRequest, WebhookSubscriptionEditRequest, -} from "@/oss/services/automations/types" +} from "@/oss/services/webhooks/types" import {GITHUB_HEADERS, GITHUB_PAYLOAD_TEMPLATES, GITHUB_URL_TEMPLATES} from "../assets/constants" @@ -10,7 +10,7 @@ import {GITHUB_HEADERS, GITHUB_PAYLOAD_TEMPLATES, GITHUB_URL_TEMPLATES} from ".. * Transforms form values into the backend subscription shape per provider. */ export const buildSubscription = ( - formValues: AutomationFormValues, + formValues: WebhookFormValues, isEdit: boolean, subscriptionId?: string, ): WebhookSubscriptionCreateRequest | WebhookSubscriptionEditRequest => { diff --git a/web/oss/src/components/Automations/utils/handleTestResult.ts b/web/oss/src/components/Webhooks/utils/handleTestResult.ts similarity index 53% rename from web/oss/src/components/Automations/utils/handleTestResult.ts rename to web/oss/src/components/Webhooks/utils/handleTestResult.ts index 19483e25d7..4e707162ad 100644 --- a/web/oss/src/components/Automations/utils/handleTestResult.ts +++ b/web/oss/src/components/Webhooks/utils/handleTestResult.ts @@ -1,10 +1,10 @@ import {message} from "antd" -import {WebhookDeliveryResponse} from "@/oss/services/automations/types" +import {WebhookDeliveryResponse} from "@/oss/services/webhooks/types" -export const AUTOMATION_TEST_SUCCESS_MESSAGE = "Automation test successful." -export const AUTOMATION_TEST_FAILURE_MESSAGE = - "Automation test failed. Please edit settings and try again." +export const WEBHOOK_TEST_SUCCESS_MESSAGE = "Webhook test successful." +export const WEBHOOK_TEST_FAILURE_MESSAGE = + "Webhook test failed. Please edit settings and try again." /** * Handles the response from a webhook test and shows appropriate success/error messages. @@ -16,8 +16,8 @@ export const handleTestResult = (response: WebhookDeliveryResponse) => { const failureSuffix = statusCode ? ` [${statusCode}]` : "" if (isSuccess) { - message.success(AUTOMATION_TEST_SUCCESS_MESSAGE, 10) + message.success(WEBHOOK_TEST_SUCCESS_MESSAGE, 10) } else { - message.error(`${AUTOMATION_TEST_FAILURE_MESSAGE}${failureSuffix}`, 10) + message.error(`${WEBHOOK_TEST_FAILURE_MESSAGE}${failureSuffix}`, 10) } } diff --git a/web/oss/src/components/Automations/widgets/AdvanceConfigWidget.tsx b/web/oss/src/components/Webhooks/widgets/AdvanceConfigWidget.tsx similarity index 100% rename from web/oss/src/components/Automations/widgets/AdvanceConfigWidget.tsx rename to web/oss/src/components/Webhooks/widgets/AdvanceConfigWidget.tsx diff --git a/web/oss/src/components/Automations/widgets/DispatchAlertWidget.tsx b/web/oss/src/components/Webhooks/widgets/DispatchAlertWidget.tsx similarity index 100% rename from web/oss/src/components/Automations/widgets/DispatchAlertWidget.tsx rename to web/oss/src/components/Webhooks/widgets/DispatchAlertWidget.tsx diff --git a/web/oss/src/components/Automations/widgets/HeaderListWidget.tsx b/web/oss/src/components/Webhooks/widgets/HeaderListWidget.tsx similarity index 100% rename from web/oss/src/components/Automations/widgets/HeaderListWidget.tsx rename to web/oss/src/components/Webhooks/widgets/HeaderListWidget.tsx diff --git a/web/oss/src/components/pages/settings/APIKeys/APIKeys.tsx b/web/oss/src/components/pages/settings/APIKeys/APIKeys.tsx index 37425bc426..503bd44bde 100644 --- a/web/oss/src/components/pages/settings/APIKeys/APIKeys.tsx +++ b/web/oss/src/components/pages/settings/APIKeys/APIKeys.tsx @@ -231,7 +231,7 @@ const APIKeys: React.FC = () => { icon={<Plus size={14} className="mt-0.2" />} onClick={createKey} > - Generate API key + Generate </Button> </div> ) : null} diff --git a/web/oss/src/components/pages/settings/Tools/components/GatewayToolsSection.tsx b/web/oss/src/components/pages/settings/Tools/components/GatewayToolsSection.tsx index ec512b1c92..468f080f9a 100644 --- a/web/oss/src/components/pages/settings/Tools/components/GatewayToolsSection.tsx +++ b/web/oss/src/components/pages/settings/Tools/components/GatewayToolsSection.tsx @@ -1,11 +1,11 @@ import {useCallback, useMemo, useState} from "react" import { - useConnectionsQuery, - useConnectionActions, - catalogDrawerOpenAtom, - executionDrawerAtom, - fetchConnection, + fetchToolConnection, + toolCatalogDrawerOpenAtom, + toolExecutionDrawerAtom, + useToolConnectionActions, + useToolConnectionsQuery, type ToolConnection, } from "@agenta/entities/gatewayTool" import { @@ -24,11 +24,11 @@ import {getAgentaApiUrl, getAgentaWebUrl} from "@/oss/lib/helpers/api" import {formatDay} from "@/oss/lib/helpers/dateTimeHelper" export default function GatewayToolsSection() { - const {connections, isLoading, refetch} = useConnectionsQuery() + const {connections, isLoading, refetch} = useToolConnectionsQuery() const {handleDelete, handleRefresh, handleRevoke, invalidateConnections} = - useConnectionActions() - const setCatalogOpen = useSetAtom(catalogDrawerOpenAtom) - const setExecutionDrawer = useSetAtom(executionDrawerAtom) + useToolConnectionActions() + const setCatalogOpen = useSetAtom(toolCatalogDrawerOpenAtom) + const setExecutionDrawer = useSetAtom(toolExecutionDrawerAtom) const [reloading, setReloading] = useState(false) const reloadAll = useCallback(async () => { @@ -39,7 +39,7 @@ export default function GatewayToolsSection() { connections .map((c) => c.id) .filter((id): id is string => typeof id === "string") - .map((id) => fetchConnection(id)), + .map((id) => fetchToolConnection(id)), ) invalidateConnections() } finally { @@ -82,7 +82,7 @@ export default function GatewayToolsSection() { // Poll the individual connection endpoint which checks // Composio for status and updates is_valid in the DB. try { - await fetchConnection(connectionId) + await fetchToolConnection(connectionId) } catch { /* best-effort */ } @@ -296,10 +296,6 @@ export default function GatewayToolsSection() { <> <section className="flex flex-col gap-2"> <div className="flex items-center gap-2"> - <Typography.Text className="text-sm font-medium"> - Third-party tool integrations - </Typography.Text> - <Button icon={<Plus size={14} />} type="primary" diff --git a/web/oss/src/components/pages/settings/Tools/hooks/useIntegrationDetail.ts b/web/oss/src/components/pages/settings/Tools/hooks/useIntegrationDetail.ts index f9d6a096b0..1d3b0961f2 100644 --- a/web/oss/src/components/pages/settings/Tools/hooks/useIntegrationDetail.ts +++ b/web/oss/src/components/pages/settings/Tools/hooks/useIntegrationDetail.ts @@ -1,7 +1,7 @@ import { - fetchActions, - integrationDetailQueryFamily, - queryConnections, + fetchToolActions, + queryToolConnections, + toolIntegrationDetailQueryFamily, type ToolCatalogActionsResponse, type ToolConnectionsResponse, } from "@agenta/entities/gatewayTool" @@ -14,7 +14,7 @@ const DEFAULT_PROVIDER = "composio" export const integrationActionsQueryFamily = atomFamily((integrationKey: string) => atomWithQuery<ToolCatalogActionsResponse>(() => ({ queryKey: ["tools", "actions", DEFAULT_PROVIDER, integrationKey], - queryFn: () => fetchActions(DEFAULT_PROVIDER, integrationKey, {important: true}), + queryFn: () => fetchToolActions(DEFAULT_PROVIDER, integrationKey, {important: true}), staleTime: 5 * 60_000, refetchOnWindowFocus: false, enabled: !!integrationKey, @@ -25,7 +25,7 @@ export const integrationConnectionsQueryFamily = atomFamily((integrationKey: str atomWithQuery<ToolConnectionsResponse>(() => ({ queryKey: ["tools", "integrationConnections", DEFAULT_PROVIDER, integrationKey], queryFn: () => - queryConnections({ + queryToolConnections({ provider_key: DEFAULT_PROVIDER, integration_key: integrationKey, }), @@ -36,7 +36,7 @@ export const integrationConnectionsQueryFamily = atomFamily((integrationKey: str ) export const useIntegrationDetail = (integrationKey: string) => { - const detailQuery = useAtomValue(integrationDetailQueryFamily(integrationKey)) + const detailQuery = useAtomValue(toolIntegrationDetailQueryFamily(integrationKey)) const actionsQuery = useAtomValue(integrationActionsQueryFamily(integrationKey)) const connectionsQuery = useAtomValue(integrationConnectionsQueryFamily(integrationKey)) diff --git a/web/oss/src/components/pages/settings/Tools/hooks/useToolsConnections.ts b/web/oss/src/components/pages/settings/Tools/hooks/useToolsConnections.ts index 6f6d4d75e4..94a388bfe3 100644 --- a/web/oss/src/components/pages/settings/Tools/hooks/useToolsConnections.ts +++ b/web/oss/src/components/pages/settings/Tools/hooks/useToolsConnections.ts @@ -1,7 +1,7 @@ import {useCallback} from "react" import { - createConnection, + createToolConnection, deleteToolConnection, refreshToolConnection, type ToolConnectionCreatePayload, @@ -53,7 +53,7 @@ export const useToolsConnections = (integrationKey: string) => { }, } - const result = await createConnection(request) + const result = await createToolConnection(request) invalidate() return result }, diff --git a/web/oss/src/components/pages/settings/Tools/hooks/useToolsIntegrations.ts b/web/oss/src/components/pages/settings/Tools/hooks/useToolsIntegrations.ts index 5a00133347..f3b7bc481a 100644 --- a/web/oss/src/components/pages/settings/Tools/hooks/useToolsIntegrations.ts +++ b/web/oss/src/components/pages/settings/Tools/hooks/useToolsIntegrations.ts @@ -1,5 +1,5 @@ import { - fetchIntegrations, + fetchToolIntegrations, type ToolCatalogIntegration, type ToolCatalogIntegrationDetails, type ToolCatalogIntegrationsResponse, @@ -13,7 +13,7 @@ type CatalogIntegrationItem = ToolCatalogIntegration | ToolCatalogIntegrationDet export const integrationsQueryAtom = atomWithQuery<ToolCatalogIntegrationsResponse>(() => ({ queryKey: ["tools", "integrations", DEFAULT_PROVIDER], - queryFn: () => fetchIntegrations(DEFAULT_PROVIDER), + queryFn: () => fetchToolIntegrations(DEFAULT_PROVIDER), staleTime: 5 * 60_000, refetchOnWindowFocus: false, })) diff --git a/web/oss/src/components/pages/settings/Triggers/components/GatewaySubscriptionsSection.tsx b/web/oss/src/components/pages/settings/Triggers/components/GatewaySubscriptionsSection.tsx index 6e06bde2cc..f114488401 100644 --- a/web/oss/src/components/pages/settings/Triggers/components/GatewaySubscriptionsSection.tsx +++ b/web/oss/src/components/pages/settings/Triggers/components/GatewaySubscriptionsSection.tsx @@ -1,8 +1,8 @@ -import {useCallback, useMemo} from "react" +import {useCallback, useMemo, useState} from "react" import { - deliveriesDrawerAtom, - subscriptionDrawerAtom, + triggerDeliveriesDrawerAtom, + triggerSubscriptionDrawerAtom, useTriggerConnectionsQuery, useTriggerSubscription, useTriggerSubscriptions, @@ -11,6 +11,7 @@ import { import {TriggerDeliveriesDrawer, TriggerSubscriptionDrawer} from "@agenta/entity-ui/gatewayTrigger" import {MoreOutlined} from "@ant-design/icons" import { + ArrowClockwise, ArrowsClockwise, GearSix, ListChecks, @@ -19,18 +20,28 @@ import { Trash, XCircle, } from "@phosphor-icons/react" -import {Button, Dropdown, Empty, Table, Tag, Typography, message} from "antd" +import {Button, Dropdown, Empty, Table, Tag, Tooltip, Typography, message} from "antd" import type {ColumnsType} from "antd/es/table" import {useSetAtom} from "jotai" import {formatDay} from "@/oss/lib/helpers/dateTimeHelper" export default function GatewaySubscriptionsSection() { - const {subscriptions, isLoading} = useTriggerSubscriptions() + const {subscriptions, isLoading, refetch} = useTriggerSubscriptions() const {connections} = useTriggerConnectionsQuery() const {revoke, refresh, remove, isMutating} = useTriggerSubscription() - const openDrawer = useSetAtom(subscriptionDrawerAtom) - const openDeliveries = useSetAtom(deliveriesDrawerAtom) + const openDrawer = useSetAtom(triggerSubscriptionDrawerAtom) + const openDeliveries = useSetAtom(triggerDeliveriesDrawerAtom) + const [reloading, setReloading] = useState(false) + + const reloadAll = useCallback(async () => { + setReloading(true) + try { + await refetch() + } finally { + setReloading(false) + } + }, [refetch]) const connectionLabel = useCallback( (connectionId?: string) => { @@ -221,10 +232,7 @@ export default function GatewaySubscriptionsSection() { return ( <> <section className="flex flex-col gap-2"> - <div className="flex items-center justify-between"> - <Typography.Text className="text-sm font-medium"> - Trigger subscriptions - </Typography.Text> + <div className="flex items-center gap-2"> <Button type="primary" size="small" @@ -232,15 +240,20 @@ export default function GatewaySubscriptionsSection() { onClick={handleCreate} disabled={connections.length === 0} > - New subscription + Subscribe </Button> + <Tooltip title="Reload all subscriptions"> + <Button + icon={<ArrowClockwise size={14} />} + type="text" + size="small" + aria-label="Reload all subscriptions" + loading={reloading} + onClick={reloadAll} + /> + </Tooltip> </div> - <Typography.Text type="secondary" className="text-xs"> - Bind a provider event to a workflow. Each subscription dispatches matching - events to its bound workflow. - </Typography.Text> - <Table<TriggerSubscription> className="ph-no-capture" columns={columns} diff --git a/web/oss/src/components/pages/settings/Triggers/components/GatewayTriggersSection.tsx b/web/oss/src/components/pages/settings/Triggers/components/GatewayTriggersSection.tsx index f853ef2eb8..76d7814d10 100644 --- a/web/oss/src/components/pages/settings/Triggers/components/GatewayTriggersSection.tsx +++ b/web/oss/src/components/pages/settings/Triggers/components/GatewayTriggersSection.tsx @@ -1,24 +1,49 @@ -import {useCallback, useMemo} from "react" +import {useCallback, useMemo, useState} from "react" import { - eventsDrawerAtom, + fetchTriggerConnection, + triggerCatalogDrawerOpenAtom, + triggerEventsDrawerAtom, + useTriggerConnectionActions, useTriggerConnectionsQuery, type TriggerConnection, } from "@agenta/entities/gatewayTrigger" import {ConnectionStatusBadge} from "@agenta/entity-ui/gatewayTool" -import {TriggerEventsDrawer} from "@agenta/entity-ui/gatewayTrigger" -import {Lightning} from "@phosphor-icons/react" -import {Button, Empty, Table, Tag, Tooltip, Typography} from "antd" +import {TriggerCatalogDrawer, TriggerEventsDrawer} from "@agenta/entity-ui/gatewayTrigger" +import {MoreOutlined} from "@ant-design/icons" +import {ArrowClockwise, Lightning, Plus, Trash, XCircle} from "@phosphor-icons/react" +import {Button, Dropdown, Empty, Table, Tag, Tooltip, Typography, message} from "antd" import type {ColumnsType} from "antd/es/table" import {useSetAtom} from "jotai" +import AlertPopup from "@/oss/components/AlertPopup/AlertPopup" import {formatDay} from "@/oss/lib/helpers/dateTimeHelper" const DEFAULT_PROVIDER = "composio" export default function GatewayTriggersSection() { - const {connections, isLoading} = useTriggerConnectionsQuery() - const setEventsDrawer = useSetAtom(eventsDrawerAtom) + const {connections, isLoading, refetch} = useTriggerConnectionsQuery() + const {handleDelete, handleRefresh, handleRevoke, invalidateConnections} = + useTriggerConnectionActions() + const setEventsDrawer = useSetAtom(triggerEventsDrawerAtom) + const setCatalogOpen = useSetAtom(triggerCatalogDrawerOpenAtom) + const [reloading, setReloading] = useState(false) + + const reloadAll = useCallback(async () => { + setReloading(true) + try { + // Poll each connection individually to trigger Composio status sync. + await Promise.allSettled( + connections + .map((c) => c.id) + .filter((id): id is string => typeof id === "string") + .map((id) => fetchTriggerConnection(id)), + ) + invalidateConnections() + } finally { + setReloading(false) + } + }, [connections, invalidateConnections]) const openEvents = useCallback( (record: TriggerConnection) => { @@ -32,6 +57,59 @@ export default function GatewayTriggersSection() { [setEventsDrawer], ) + const onRefresh = useCallback( + async (connection: TriggerConnection) => { + if (!connection.id) return + try { + await handleRefresh(connection.id) + message.success("Connection refreshed") + } catch { + message.error("Failed to refresh connection") + } + }, + [handleRefresh], + ) + + const confirmRevoke = useCallback( + (connection: TriggerConnection) => { + AlertPopup({ + title: "Revoke Connection", + message: + "This will mark the connection as invalid. You can refresh it later to reactivate.", + onOk: async () => { + if (!connection.id) return + try { + await handleRevoke(connection.id) + message.success("Connection revoked") + } catch { + message.error("Failed to revoke connection") + } + }, + }) + }, + [handleRevoke], + ) + + const confirmDelete = useCallback( + (connection: TriggerConnection) => { + AlertPopup({ + title: "Delete Connection", + message: + "Are you sure you want to delete this connection? This action is irreversible.", + onOk: async () => { + if (!connection.id) return + try { + await handleDelete(connection.id) + message.success("Connection deleted") + } catch { + message.error("Failed to delete connection") + } + }, + }) + }, + [handleDelete], + ) + const columns: ColumnsType<TriggerConnection> = useMemo( () => [ { @@ -56,6 +134,13 @@ export default function GatewayTriggersSection() { <Typography.Text>{record.name || record.slug}</Typography.Text> ), }, + { + title: "Slug", + dataIndex: "slug", + key: "slug", + onHeaderCell: () => ({style: {minWidth: 160}}), + render: (slug: string) => <Typography.Text>{slug}</Typography.Text>, + }, { title: "Status", key: "status", @@ -73,43 +158,94 @@ export default function GatewayTriggersSection() { { title: "", key: "actions", - width: 120, + width: 48, fixed: "right", - align: "right", + align: "center", render: (_, record) => ( - <Button - size="small" - icon={<Lightning size={14} />} - onClick={(e) => { - e.stopPropagation() - openEvents(record) + <Dropdown + trigger={["click"]} + styles={{root: {width: 180}}} + menu={{ + items: [ + { + key: "events", + label: "Browse events", + icon: <Lightning size={16} />, + onClick: (e) => { + e.domEvent.stopPropagation() + openEvents(record) + }, + }, + { + key: "refresh", + label: "Refresh", + icon: <ArrowClockwise size={16} />, + onClick: (e) => { + e.domEvent.stopPropagation() + onRefresh(record) + }, + }, + { + key: "revoke", + label: "Revoke", + icon: <XCircle size={16} />, + disabled: !record.flags?.is_valid, + onClick: (e) => { + e.domEvent.stopPropagation() + confirmRevoke(record) + }, + }, + {type: "divider"}, + { + key: "delete", + label: "Delete", + icon: <Trash size={16} />, + danger: true, + onClick: (e) => { + e.domEvent.stopPropagation() + confirmDelete(record) + }, + }, + ], }} > - Events - </Button> + <Button + onClick={(e) => e.stopPropagation()} + type="text" + aria-label="Open connection actions" + icon={<MoreOutlined />} + /> + </Dropdown> ), }, ], - [openEvents], + [openEvents, onRefresh, confirmRevoke, confirmDelete], ) return ( <> <section className="flex flex-col gap-2"> <div className="flex items-center gap-2"> - <Typography.Text className="text-sm font-medium"> - Trigger integrations - </Typography.Text> - <Tooltip title="Browse the events of a connected integration"> - <Lightning size={14} /> + <Button + icon={<Plus size={14} />} + type="primary" + size="small" + onClick={() => setCatalogOpen(true)} + > + Connect + </Button> + <Tooltip title="Reload all connections"> + <Button + icon={<ArrowClockwise size={14} />} + type="text" + size="small" + aria-label="Reload all connections" + loading={reloading} + onClick={reloadAll} + /> </Tooltip> </div> - <Typography.Text type="secondary" className="text-xs"> - Triggers reuse the same connections as tools. Connect an integration under - Tools, then browse its events here. - </Typography.Text> - <Table<TriggerConnection> className="ph-no-capture" columns={columns} @@ -128,6 +264,7 @@ export default function GatewayTriggersSection() { /> </section> + <TriggerCatalogDrawer onConnectionCreated={refetch} /> <TriggerEventsDrawer /> </> ) diff --git a/web/oss/src/components/pages/settings/Automations/Automations.tsx b/web/oss/src/components/pages/settings/Webhooks/Webhooks.tsx similarity index 79% rename from web/oss/src/components/pages/settings/Automations/Automations.tsx rename to web/oss/src/components/pages/settings/Webhooks/Webhooks.tsx index fc5165cef3..ccf0cabd78 100644 --- a/web/oss/src/components/pages/settings/Automations/Automations.tsx +++ b/web/oss/src/components/pages/settings/Webhooks/Webhooks.tsx @@ -1,24 +1,24 @@ import {useCallback, useMemo, useState} from "react" import {MoreOutlined} from "@ant-design/icons" -import {GearSix, PencilSimpleLine, Play, Plus, Trash} from "@phosphor-icons/react" -import {Button, Dropdown, Table, Typography, message} from "antd" +import {ArrowClockwise, GearSix, PencilSimpleLine, Play, Plus, Trash} from "@phosphor-icons/react" +import {Button, Dropdown, Table, Tooltip, Typography, message} from "antd" import {useAtom, useSetAtom} from "jotai" -import AutomationDrawer from "@/oss/components/Automations/AutomationDrawer" -import DeleteAutomationModal from "@/oss/components/Automations/Modals/DeleteAutomationModal" -import SecretRevealModal from "@/oss/components/Automations/Modals/SecretRevealModal" +import DeleteWebhookModal from "@/oss/components/Webhooks/Modals/DeleteWebhookModal" +import SecretRevealModal from "@/oss/components/Webhooks/Modals/SecretRevealModal" import { - AUTOMATION_TEST_FAILURE_MESSAGE, + WEBHOOK_TEST_FAILURE_MESSAGE, handleTestResult, -} from "@/oss/components/Automations/utils/handleTestResult" -import {AutomationProvider, WebhookSubscription} from "@/oss/services/automations/types" -import {automationsAtom, testAutomationAtom} from "@/oss/state/automations/atoms" +} from "@/oss/components/Webhooks/utils/handleTestResult" +import WebhookDrawer from "@/oss/components/Webhooks/WebhookDrawer" +import {WebhookProvider, WebhookSubscription} from "@/oss/services/webhooks/types" +import {webhooksAtom, testWebhookAtom} from "@/oss/state/webhooks/atoms" import { - editingAutomationAtom, - isAutomationDrawerOpenAtom, + editingWebhookAtom, + isWebhookDrawerOpenAtom, webhookToDeleteAtom, -} from "@/oss/state/automations/state" +} from "@/oss/state/webhooks/state" const isGitHubApiUrl = (url?: string | null): boolean => { if (!url) { @@ -32,7 +32,7 @@ const isGitHubApiUrl = (url?: string | null): boolean => { } } -const getProviderLabel = (url?: string | null): AutomationProvider => { +const getProviderLabel = (url?: string | null): WebhookProvider => { return isGitHubApiUrl(url) ? "github" : "webhook" } @@ -51,14 +51,24 @@ const formatDestination = (url?: string) => { return url } -const Automations: React.FC = () => { - const [{data: webhooks, isPending: isLoading}] = useAtom(automationsAtom) - const setIsDrawerOpen = useSetAtom(isAutomationDrawerOpenAtom) - const setEditingWebhook = useSetAtom(editingAutomationAtom) - const testWebhookSubscription = useSetAtom(testAutomationAtom) +const Webhooks: React.FC = () => { + const [{data: webhooks, isPending: isLoading, refetch}] = useAtom(webhooksAtom) + const setIsDrawerOpen = useSetAtom(isWebhookDrawerOpenAtom) + const setEditingWebhook = useSetAtom(editingWebhookAtom) + const testWebhookSubscription = useSetAtom(testWebhookAtom) const setWebhookToDelete = useSetAtom(webhookToDeleteAtom) const [testingWebhookId, setTestingWebhookId] = useState<string | null>(null) + const [reloading, setReloading] = useState(false) + + const reloadAll = useCallback(async () => { + setReloading(true) + try { + await refetch() + } finally { + setReloading(false) + } + }, [refetch]) const handleCreate = useCallback(() => { setEditingWebhook(undefined) @@ -95,7 +105,7 @@ const Automations: React.FC = () => { handleTestResult(response) } catch (error) { console.error(error) - message.error(AUTOMATION_TEST_FAILURE_MESSAGE, 10) + message.error(WEBHOOK_TEST_FAILURE_MESSAGE, 10) } finally { setTestingWebhookId(null) } @@ -214,7 +224,7 @@ const Automations: React.FC = () => { type="text" icon={<MoreOutlined />} loading={testingWebhookId === record.id} - aria-label="Open automation actions" + aria-label="Open webhook actions" onClick={(e) => e.stopPropagation()} /> </Dropdown> @@ -233,8 +243,18 @@ const Automations: React.FC = () => { icon={<Plus size={14} />} onClick={handleCreate} > - Add Automation + Subscribe </Button> + <Tooltip title="Reload all webhooks"> + <Button + icon={<ArrowClockwise size={14} />} + type="text" + size="small" + aria-label="Reload all webhooks" + loading={reloading} + onClick={reloadAll} + /> + </Tooltip> </div> <Table @@ -250,11 +270,11 @@ const Automations: React.FC = () => { })} /> - <AutomationDrawer onSuccess={handleModalSuccess} /> - <DeleteAutomationModal /> + <WebhookDrawer onSuccess={handleModalSuccess} /> + <DeleteWebhookModal /> <SecretRevealModal /> </section> ) } -export default Automations +export default Webhooks diff --git a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/settings/index.tsx b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/settings/index.tsx index 4d758005d9..199aee28e5 100644 --- a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/settings/index.tsx +++ b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/settings/index.tsx @@ -52,12 +52,9 @@ const DeleteAccount = dynamic( {ssr: false}, ) -const Automations = dynamic( - () => import("@/oss/components/pages/settings/Automations/Automations"), - { - ssr: false, - }, -) +const Webhooks = dynamic(() => import("@/oss/components/pages/settings/Webhooks/Webhooks"), { + ssr: false, +}) interface SettingsProps { AuditLogComponent?: React.ComponentType @@ -127,15 +124,15 @@ export const Settings: React.FC<SettingsProps> = ({AuditLogComponent}) => { case "projects": return "Projects" case "secrets": - return "Providers & Models" + return "Models" case "tools": return "Tools" case "triggers": return "Triggers" case "apiKeys": return "API Keys" - case "automations": - return "Automations" + case "webhooks": + return "Webhooks" case "auditLog": return "Audit Log" case "account": @@ -182,7 +179,7 @@ export const Settings: React.FC<SettingsProps> = ({AuditLogComponent}) => { ), } case "secrets": - return {content: <Secrets />, title: "Providers & Models"} + return {content: <Secrets />, title: "Models"} case "tools": return {content: <Tools />, title: "Tools"} case "triggers": @@ -194,8 +191,8 @@ export const Settings: React.FC<SettingsProps> = ({AuditLogComponent}) => { content: <Billing />, title: billingEnabled ? "Usage & Billing" : "Usage", } - case "automations": - return {content: <Automations />, title: "Automations"} + case "webhooks": + return {content: <Webhooks />, title: "Webhooks"} case "auditLog": return { content: AuditLogComponent ? <AuditLogComponent /> : <WorkspaceManage />, diff --git a/web/oss/src/services/automations/api.ts b/web/oss/src/services/webhooks/api.ts similarity index 100% rename from web/oss/src/services/automations/api.ts rename to web/oss/src/services/webhooks/api.ts diff --git a/web/oss/src/services/automations/types.ts b/web/oss/src/services/webhooks/types.ts similarity index 91% rename from web/oss/src/services/automations/types.ts rename to web/oss/src/services/webhooks/types.ts index 2b3ed5e238..8678b01b9b 100644 --- a/web/oss/src/services/automations/types.ts +++ b/web/oss/src/services/webhooks/types.ts @@ -15,24 +15,24 @@ export interface WebhookSubscriptionData { event_types?: WebhookEventType[] } -export type AutomationProvider = "webhook" | "github" +export type WebhookProvider = "webhook" | "github" export type GitHubDispatchType = "repository_dispatch" | "workflow_dispatch" -interface AutomationFormValuesBase<P extends AutomationProvider = AutomationProvider> { +interface WebhookFormValuesBase<P extends WebhookProvider = WebhookProvider> { provider: P name?: string event_types?: WebhookEventType[] } -export interface WebhookFormValues extends AutomationFormValuesBase<"webhook"> { +export interface WebhookConfigFormValues extends WebhookFormValuesBase<"webhook"> { url?: string headers?: Record<string, string> auth_mode?: "signature" | "authorization" auth_value?: string } -export interface GitHubFormValues extends AutomationFormValuesBase<"github"> { +export interface GitHubFormValues extends WebhookFormValuesBase<"github"> { github_sub_type?: GitHubDispatchType github_repo?: string github_pat?: string @@ -40,7 +40,7 @@ export interface GitHubFormValues extends AutomationFormValuesBase<"github"> { github_branch?: string } -export type AutomationFormValues = WebhookFormValues | GitHubFormValues +export type WebhookFormValues = WebhookConfigFormValues | GitHubFormValues /** Full subscription as returned by the backend */ export interface WebhookSubscription { diff --git a/web/oss/src/state/automations/state.ts b/web/oss/src/state/automations/state.ts deleted file mode 100644 index d0fecc0055..0000000000 --- a/web/oss/src/state/automations/state.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {atom} from "jotai" - -import {AutomationProvider, WebhookSubscription} from "@/oss/services/automations/types" - -export const isAutomationDrawerOpenAtom = atom<boolean>(false) -export const editingAutomationAtom = atom<WebhookSubscription | undefined>(undefined) -export const createdWebhookSecretAtom = atom<string | null>(null) -export const selectedProviderAtom = atom<AutomationProvider>("webhook") -export const webhookToDeleteAtom = atom<WebhookSubscription | null>(null) diff --git a/web/oss/src/state/automations/atoms.ts b/web/oss/src/state/webhooks/atoms.ts similarity index 74% rename from web/oss/src/state/automations/atoms.ts rename to web/oss/src/state/webhooks/atoms.ts index a58d0f0158..43bc4b8861 100644 --- a/web/oss/src/state/automations/atoms.ts +++ b/web/oss/src/state/webhooks/atoms.ts @@ -10,19 +10,19 @@ import { queryWebhookSubscriptions, testWebhookSubscription, editWebhookSubscription, -} from "@/oss/services/automations/api" +} from "@/oss/services/webhooks/api" import { WebhookSubscriptionTestRequest, WebhookSubscriptionCreateRequest, WebhookSubscriptionEditRequest, -} from "@/oss/services/automations/types" +} from "@/oss/services/webhooks/types" import {projectIdAtom} from "@/oss/state/project" -export const automationsAtom = atomWithQuery((get) => { +export const webhooksAtom = atomWithQuery((get) => { const projectId = get(projectIdAtom) return { - queryKey: ["automations", projectId], + queryKey: ["webhooks", projectId], queryFn: async () => { const response = await queryWebhookSubscriptions(projectId ?? undefined) return response.subscriptions @@ -34,12 +34,12 @@ export const automationsAtom = atomWithQuery((get) => { } }) -export const automationDeliveriesAtomFamily = atomFamily((webhookSubscriptionId: string | null) => +export const webhookDeliveriesAtomFamily = atomFamily((webhookSubscriptionId: string | null) => atomWithQuery((get) => { const projectId = get(projectIdAtom) return { - queryKey: ["automation-deliveries", projectId, webhookSubscriptionId], + queryKey: ["webhook-deliveries", projectId, webhookSubscriptionId], queryFn: async () => { if (!webhookSubscriptionId) { return [] @@ -67,17 +67,17 @@ export const automationDeliveriesAtomFamily = atomFamily((webhookSubscriptionId: }), ) -export const createAutomationAtom = atom( +export const createWebhookAtom = atom( null, async (get, _set, payload: WebhookSubscriptionCreateRequest) => { const projectId = get(projectIdAtom) const res = await createWebhookSubscription(payload, projectId ?? undefined) - await queryClient.invalidateQueries({queryKey: ["automations"]}) + await queryClient.invalidateQueries({queryKey: ["webhooks"]}) return res }, ) -export const updateAutomationAtom = atom( +export const updateWebhookAtom = atom( null, async ( get, @@ -93,24 +93,24 @@ export const updateAutomationAtom = atom( payload, projectId ?? undefined, ) - await queryClient.invalidateQueries({queryKey: ["automations"]}) + await queryClient.invalidateQueries({queryKey: ["webhooks"]}) return res }, ) -export const deleteAutomationAtom = atom(null, async (get, _set, webhookSubscriptionId: string) => { +export const deleteWebhookAtom = atom(null, async (get, _set, webhookSubscriptionId: string) => { const projectId = get(projectIdAtom) await deleteWebhookSubscription(webhookSubscriptionId, projectId ?? undefined) - await queryClient.invalidateQueries({queryKey: ["automations"]}) + await queryClient.invalidateQueries({queryKey: ["webhooks"]}) }) -export const testAutomationAtom = atom( +export const testWebhookAtom = atom( null, async (get, _set, payload: WebhookSubscriptionTestRequest) => { const projectId = get(projectIdAtom) const res = await testWebhookSubscription(payload, projectId ?? undefined) - await queryClient.invalidateQueries({queryKey: ["automations"]}) - await queryClient.invalidateQueries({queryKey: ["automation-deliveries"]}) + await queryClient.invalidateQueries({queryKey: ["webhooks"]}) + await queryClient.invalidateQueries({queryKey: ["webhook-deliveries"]}) return res }, ) diff --git a/web/oss/src/state/webhooks/state.ts b/web/oss/src/state/webhooks/state.ts new file mode 100644 index 0000000000..2d311c4167 --- /dev/null +++ b/web/oss/src/state/webhooks/state.ts @@ -0,0 +1,9 @@ +import {atom} from "jotai" + +import {WebhookProvider, WebhookSubscription} from "@/oss/services/webhooks/types" + +export const isWebhookDrawerOpenAtom = atom<boolean>(false) +export const editingWebhookAtom = atom<WebhookSubscription | undefined>(undefined) +export const createdWebhookSecretAtom = atom<string | null>(null) +export const selectedProviderAtom = atom<WebhookProvider>("webhook") +export const webhookToDeleteAtom = atom<WebhookSubscription | null>(null) diff --git a/web/oss/src/styles/globals.css b/web/oss/src/styles/globals.css index bcf2162872..02c6f6caf6 100644 --- a/web/oss/src/styles/globals.css +++ b/web/oss/src/styles/globals.css @@ -381,7 +381,7 @@ body { .org-domains-table .ant-table, .org-providers-table .ant-table, -.automations-table .ant-table { +.webhooks-table .ant-table { table-layout: fixed; } @@ -392,8 +392,8 @@ body { width: 5%; } -.automations-table .ant-table-thead > tr > th:nth-child(1), -.automations-table .ant-table-tbody > tr > td:nth-child(1) { +.webhooks-table .ant-table-thead > tr > th:nth-child(1), +.webhooks-table .ant-table-tbody > tr > td:nth-child(1) { width: 18%; } @@ -404,8 +404,8 @@ body { width: 20%; } -.automations-table .ant-table-thead > tr > th:nth-child(2), -.automations-table .ant-table-tbody > tr > td:nth-child(2) { +.webhooks-table .ant-table-thead > tr > th:nth-child(2), +.webhooks-table .ant-table-tbody > tr > td:nth-child(2) { width: 12%; } @@ -416,8 +416,8 @@ body { width: 30%; } -.automations-table .ant-table-thead > tr > th:nth-child(3), -.automations-table .ant-table-tbody > tr > td:nth-child(3) { +.webhooks-table .ant-table-thead > tr > th:nth-child(3), +.webhooks-table .ant-table-tbody > tr > td:nth-child(3) { width: 26%; } @@ -428,18 +428,18 @@ body { width: 20%; } -.automations-table .ant-table-thead > tr > th:nth-child(4), -.automations-table .ant-table-tbody > tr > td:nth-child(4) { +.webhooks-table .ant-table-thead > tr > th:nth-child(4), +.webhooks-table .ant-table-tbody > tr > td:nth-child(4) { width: 20%; } -.automations-table .ant-table-thead > tr > th:nth-child(5), -.automations-table .ant-table-tbody > tr > td:nth-child(5) { +.webhooks-table .ant-table-thead > tr > th:nth-child(5), +.webhooks-table .ant-table-tbody > tr > td:nth-child(5) { width: 12%; } -.automations-table .ant-table-thead > tr > th:nth-child(6), -.automations-table .ant-table-tbody > tr > td:nth-child(6) { +.webhooks-table .ant-table-thead > tr > th:nth-child(6), +.webhooks-table .ant-table-tbody > tr > td:nth-child(6) { width: 12%; } diff --git a/web/packages/agenta-entities/src/gatewayTool/api/api.ts b/web/packages/agenta-entities/src/gatewayTool/api/api.ts index bfab129333..a37305a764 100644 --- a/web/packages/agenta-entities/src/gatewayTool/api/api.ts +++ b/web/packages/agenta-entities/src/gatewayTool/api/api.ts @@ -24,11 +24,11 @@ import {getToolsClient, projectScopedRequest} from "./client" // --- Catalog browse --- -export const fetchProviders = async (): Promise<ToolCatalogProvidersResponse> => { +export const fetchToolProviders = async (): Promise<ToolCatalogProvidersResponse> => { return getToolsClient().listToolProviders({}, projectScopedRequest()) } -export const fetchIntegrations = async ( +export const fetchToolIntegrations = async ( providerKey: string, params?: {search?: string; sort_by?: string; limit?: number; cursor?: string}, ): Promise<ToolCatalogIntegrationsResponse> => { @@ -44,7 +44,7 @@ export const fetchIntegrations = async ( ) } -export const fetchIntegrationDetail = async ( +export const fetchToolIntegrationDetail = async ( providerKey: string, integrationKey: string, ): Promise<ToolCatalogIntegrationResponse> => { @@ -54,7 +54,7 @@ export const fetchIntegrationDetail = async ( ) } -export const fetchActions = async ( +export const fetchToolActions = async ( providerKey: string, integrationKey: string, params?: { @@ -87,7 +87,7 @@ export const fetchActions = async ( ) } -export const fetchActionDetail = async ( +export const fetchToolActionDetail = async ( providerKey: string, integrationKey: string, actionKey: string, @@ -104,7 +104,7 @@ export const fetchActionDetail = async ( // --- Connections --- -export const queryConnections = async (params?: { +export const queryToolConnections = async (params?: { provider_key?: string integration_key?: string }): Promise<ToolConnectionsResponse> => { @@ -117,14 +117,16 @@ export const queryConnections = async (params?: { ) } -export const fetchConnection = async (connectionId: string): Promise<ToolConnectionResponse> => { +export const fetchToolConnection = async ( + connectionId: string, +): Promise<ToolConnectionResponse> => { return getToolsClient().fetchToolConnection( {connection_id: connectionId}, projectScopedRequest(), ) } -export const createConnection = async ( +export const createToolConnection = async ( payload: ToolConnectionCreatePayload, ): Promise<ToolConnectionResponse> => { // Cast through Parameters<...> because Fern's typed payload doesn't diff --git a/web/packages/agenta-entities/src/gatewayTool/api/index.ts b/web/packages/agenta-entities/src/gatewayTool/api/index.ts index 6a5a712e2c..450a984d05 100644 --- a/web/packages/agenta-entities/src/gatewayTool/api/index.ts +++ b/web/packages/agenta-entities/src/gatewayTool/api/index.ts @@ -1,15 +1,15 @@ export {getToolsClient, projectScopedRequest} from "./client" export { - createConnection, + createToolConnection, deleteToolConnection, executeToolCall, - fetchActionDetail, - fetchActions, - fetchConnection, - fetchIntegrationDetail, - fetchIntegrations, - fetchProviders, - queryConnections, + fetchToolActionDetail, + fetchToolActions, + fetchToolConnection, + fetchToolIntegrationDetail, + fetchToolIntegrations, + fetchToolProviders, + queryToolConnections, refreshToolConnection, revokeToolConnection, } from "./api" diff --git a/web/packages/agenta-entities/src/gatewayTool/hooks/index.ts b/web/packages/agenta-entities/src/gatewayTool/hooks/index.ts index a18ecd3bb5..9571410a10 100644 --- a/web/packages/agenta-entities/src/gatewayTool/hooks/index.ts +++ b/web/packages/agenta-entities/src/gatewayTool/hooks/index.ts @@ -1,20 +1,23 @@ -export {actionDetailQueryFamily, useActionDetail} from "./useActionDetail" +export {toolActionDetailQueryFamily, useToolActionDetail} from "./useToolActionDetail" export { - actionsSearchAtom, - catalogActionsInfiniteFamily, - useCatalogActions, -} from "./useCatalogActions" + toolActionsSearchAtom, + toolCatalogActionsInfiniteFamily, + useToolCatalogActions, +} from "./useToolCatalogActions" export { - catalogIntegrationsInfiniteAtom, - integrationsSearchAtom, - useCatalogIntegrations, -} from "./useCatalogIntegrations" -export {useConnectionActions} from "./useConnectionActions" -export {connectionQueryAtomFamily, useConnectionQuery} from "./useConnectionQuery" -export {connectionsQueryAtom, useConnectionsQuery} from "./useConnectionsQuery" + toolCatalogIntegrationsInfiniteAtom, + toolIntegrationsSearchAtom, + useToolCatalogIntegrations, +} from "./useToolCatalogIntegrations" +export {useToolConnectionActions} from "./useToolConnectionActions" +export {toolConnectionQueryAtomFamily, useToolConnectionQuery} from "./useToolConnectionQuery" +export {toolConnectionsQueryAtom, useToolConnectionsQuery} from "./useToolConnectionsQuery" export { - integrationConnectionsAtomFamily, - useIntegrationConnections, -} from "./useIntegrationConnections" -export {integrationDetailQueryFamily, useIntegrationDetail} from "./useIntegrationDetail" + toolIntegrationConnectionsAtomFamily, + useToolIntegrationConnections, +} from "./useToolIntegrationConnections" +export { + toolIntegrationDetailQueryFamily, + useToolIntegrationDetail, +} from "./useToolIntegrationDetail" export {buildToolSlug, useToolExecution} from "./useToolExecution" diff --git a/web/packages/agenta-entities/src/gatewayTool/hooks/useActionDetail.ts b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolActionDetail.ts similarity index 71% rename from web/packages/agenta-entities/src/gatewayTool/hooks/useActionDetail.ts rename to web/packages/agenta-entities/src/gatewayTool/hooks/useToolActionDetail.ts index cadb93f1cd..2ea06e5b69 100644 --- a/web/packages/agenta-entities/src/gatewayTool/hooks/useActionDetail.ts +++ b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolActionDetail.ts @@ -2,12 +2,12 @@ import {useAtomValue} from "jotai" import {atomFamily} from "jotai/utils" import {atomWithQuery} from "jotai-tanstack-query" -import {fetchActionDetail} from "../api" +import {fetchToolActionDetail} from "../api" import type {ToolCatalogActionResponse} from "../core/types" const DEFAULT_PROVIDER = "composio" -export const actionDetailQueryFamily = atomFamily( +export const toolActionDetailQueryFamily = atomFamily( ({integrationKey, actionKey}: {integrationKey: string; actionKey: string}) => atomWithQuery<ToolCatalogActionResponse>(() => ({ queryKey: [ @@ -18,7 +18,7 @@ export const actionDetailQueryFamily = atomFamily( integrationKey, actionKey, ], - queryFn: () => fetchActionDetail(DEFAULT_PROVIDER, integrationKey, actionKey), + queryFn: () => fetchToolActionDetail(DEFAULT_PROVIDER, integrationKey, actionKey), staleTime: 5 * 60_000, refetchOnWindowFocus: false, enabled: !!integrationKey && !!actionKey, @@ -26,8 +26,8 @@ export const actionDetailQueryFamily = atomFamily( (a, b) => a.integrationKey === b.integrationKey && a.actionKey === b.actionKey, ) -export const useActionDetail = (integrationKey: string, actionKey: string) => { - const query = useAtomValue(actionDetailQueryFamily({integrationKey, actionKey})) +export const useToolActionDetail = (integrationKey: string, actionKey: string) => { + const query = useAtomValue(toolActionDetailQueryFamily({integrationKey, actionKey})) return { action: query.data?.action ?? null, diff --git a/web/packages/agenta-entities/src/gatewayTool/hooks/useCatalogActions.ts b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolCatalogActions.ts similarity index 85% rename from web/packages/agenta-entities/src/gatewayTool/hooks/useCatalogActions.ts rename to web/packages/agenta-entities/src/gatewayTool/hooks/useToolCatalogActions.ts index 1d8921391f..4f720d3212 100644 --- a/web/packages/agenta-entities/src/gatewayTool/hooks/useCatalogActions.ts +++ b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolCatalogActions.ts @@ -4,7 +4,7 @@ import {atom, useAtomValue, useSetAtom} from "jotai" import {atomFamily} from "jotai/utils" import {atomWithInfiniteQuery} from "jotai-tanstack-query" -import {fetchActions} from "../api" +import {fetchToolActions} from "../api" import type { ToolCatalogAction, ToolCatalogActionDetails, @@ -18,16 +18,16 @@ const CHUNK_SIZE = 10 const PREFETCH = 2 // Server-side search atom — set by the drawer, drives the query -export const actionsSearchAtom = atom("") +export const toolActionsSearchAtom = atom("") -export const catalogActionsInfiniteFamily = atomFamily((integrationKey: string) => +export const toolCatalogActionsInfiniteFamily = atomFamily((integrationKey: string) => atomWithInfiniteQuery<ToolCatalogActionsResponse>((get) => { - const search = get(actionsSearchAtom) + const search = get(toolActionsSearchAtom) return { queryKey: ["tools", "catalog", "actions", DEFAULT_PROVIDER, integrationKey, search], queryFn: async ({pageParam}) => - fetchActions(DEFAULT_PROVIDER, integrationKey, { + fetchToolActions(DEFAULT_PROVIDER, integrationKey, { query: search || undefined, limit: CHUNK_SIZE, cursor: (pageParam as string) || undefined, @@ -41,9 +41,9 @@ export const catalogActionsInfiniteFamily = atomFamily((integrationKey: string) }), ) -export const useCatalogActions = (integrationKey: string) => { - const query = useAtomValue(catalogActionsInfiniteFamily(integrationKey)) - const setSearch = useSetAtom(actionsSearchAtom) +export const useToolCatalogActions = (integrationKey: string) => { + const query = useAtomValue(toolCatalogActionsInfiniteFamily(integrationKey)) + const setSearch = useSetAtom(toolActionsSearchAtom) const actions = useMemo<CatalogActionItem[]>(() => { const pages = query.data?.pages ?? [] diff --git a/web/packages/agenta-entities/src/gatewayTool/hooks/useCatalogIntegrations.ts b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolCatalogIntegrations.ts similarity index 87% rename from web/packages/agenta-entities/src/gatewayTool/hooks/useCatalogIntegrations.ts rename to web/packages/agenta-entities/src/gatewayTool/hooks/useToolCatalogIntegrations.ts index 16cedf741a..fb8ccfde92 100644 --- a/web/packages/agenta-entities/src/gatewayTool/hooks/useCatalogIntegrations.ts +++ b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolCatalogIntegrations.ts @@ -3,7 +3,7 @@ import {useCallback, useEffect, useMemo, useRef, useState} from "react" import {atom, useAtomValue, useSetAtom} from "jotai" import {atomWithInfiniteQuery} from "jotai-tanstack-query" -import {fetchIntegrations} from "../api" +import {fetchToolIntegrations} from "../api" import type { ToolCatalogIntegration, ToolCatalogIntegrationDetails, @@ -17,16 +17,16 @@ const CHUNK_SIZE = 10 const PREFETCH = 2 // Server-side search atom — set by the drawer, drives the query -export const integrationsSearchAtom = atom("") +export const toolIntegrationsSearchAtom = atom("") -export const catalogIntegrationsInfiniteAtom = +export const toolCatalogIntegrationsInfiniteAtom = atomWithInfiniteQuery<ToolCatalogIntegrationsResponse>((get) => { - const search = get(integrationsSearchAtom) + const search = get(toolIntegrationsSearchAtom) return { queryKey: ["tools", "catalog", "integrations", DEFAULT_PROVIDER, search], queryFn: async ({pageParam}) => - fetchIntegrations(DEFAULT_PROVIDER, { + fetchToolIntegrations(DEFAULT_PROVIDER, { search: search.length >= 3 ? search : undefined, limit: CHUNK_SIZE, cursor: (pageParam as string) || undefined, @@ -38,9 +38,9 @@ export const catalogIntegrationsInfiniteAtom = } }) -export const useCatalogIntegrations = () => { - const query = useAtomValue(catalogIntegrationsInfiniteAtom) - const setSearch = useSetAtom(integrationsSearchAtom) +export const useToolCatalogIntegrations = () => { + const query = useAtomValue(toolCatalogIntegrationsInfiniteAtom) + const setSearch = useSetAtom(toolIntegrationsSearchAtom) const integrations = useMemo<CatalogIntegrationItem[]>(() => { const pages = query.data?.pages ?? [] diff --git a/web/packages/agenta-entities/src/gatewayTool/hooks/useConnectionActions.ts b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolConnectionActions.ts similarity index 74% rename from web/packages/agenta-entities/src/gatewayTool/hooks/useConnectionActions.ts rename to web/packages/agenta-entities/src/gatewayTool/hooks/useToolConnectionActions.ts index bf02c29178..13ed0cc5d7 100644 --- a/web/packages/agenta-entities/src/gatewayTool/hooks/useConnectionActions.ts +++ b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolConnectionActions.ts @@ -4,12 +4,16 @@ import {queryClient} from "@agenta/shared/api" import {deleteToolConnection, refreshToolConnection, revokeToolConnection} from "../api" +// Tools and triggers are independent surfaces over the SAME shared +// `gateway_connections` rows, so a write here must also invalidate the triggers +// list — otherwise a connection removed from tools would read as stale there. const invalidateConnections = () => { queryClient.invalidateQueries({queryKey: ["tools", "connections"]}) queryClient.invalidateQueries({queryKey: ["tools", "catalog"]}) + queryClient.invalidateQueries({queryKey: ["triggers", "connections"]}) } -export const useConnectionActions = () => { +export const useToolConnectionActions = () => { const handleDelete = useCallback(async (connectionId: string) => { await deleteToolConnection(connectionId) invalidateConnections() diff --git a/web/packages/agenta-entities/src/gatewayTool/hooks/useConnectionQuery.ts b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolConnectionQuery.ts similarity index 74% rename from web/packages/agenta-entities/src/gatewayTool/hooks/useConnectionQuery.ts rename to web/packages/agenta-entities/src/gatewayTool/hooks/useToolConnectionQuery.ts index ffbaa2fb08..32490b8cc0 100644 --- a/web/packages/agenta-entities/src/gatewayTool/hooks/useConnectionQuery.ts +++ b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolConnectionQuery.ts @@ -4,7 +4,7 @@ import {atom, useAtomValue} from "jotai" import {atomFamily} from "jotai/utils" import {atomWithQuery} from "jotai-tanstack-query" -import {fetchConnection} from "../api" +import {fetchToolConnection} from "../api" import type {ToolConnectionResponse} from "../core/types" interface ConnectionQueryState { @@ -14,10 +14,10 @@ interface ConnectionQueryState { refetch: () => Promise<unknown> } -export const connectionQueryAtomFamily = atomFamily((connectionId: string) => +export const toolConnectionQueryAtomFamily = atomFamily((connectionId: string) => atomWithQuery<ToolConnectionResponse>(() => ({ queryKey: ["tools", "connections", connectionId], - queryFn: () => fetchConnection(connectionId), + queryFn: () => fetchToolConnection(connectionId), enabled: !!connectionId, staleTime: 30_000, refetchOnWindowFocus: false, @@ -31,9 +31,10 @@ const emptyConnectionQueryAtom = atom<ConnectionQueryState>({ refetch: async () => ({}), }) -export const useConnectionQuery = (connectionId?: string) => { +export const useToolConnectionQuery = (connectionId?: string) => { const queryAtom = useMemo( - () => (connectionId ? connectionQueryAtomFamily(connectionId) : emptyConnectionQueryAtom), + () => + connectionId ? toolConnectionQueryAtomFamily(connectionId) : emptyConnectionQueryAtom, [connectionId], ) const query = useAtomValue(queryAtom) diff --git a/web/packages/agenta-entities/src/gatewayTool/hooks/useConnectionsQuery.ts b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolConnectionsQuery.ts similarity index 62% rename from web/packages/agenta-entities/src/gatewayTool/hooks/useConnectionsQuery.ts rename to web/packages/agenta-entities/src/gatewayTool/hooks/useToolConnectionsQuery.ts index dc5f3b4bf8..c2cf171df3 100644 --- a/web/packages/agenta-entities/src/gatewayTool/hooks/useConnectionsQuery.ts +++ b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolConnectionsQuery.ts @@ -1,18 +1,18 @@ import {useAtomValue} from "jotai" import {atomWithQuery} from "jotai-tanstack-query" -import {queryConnections} from "../api" +import {queryToolConnections} from "../api" import type {ToolConnectionsResponse} from "../core/types" -export const connectionsQueryAtom = atomWithQuery<ToolConnectionsResponse>(() => ({ +export const toolConnectionsQueryAtom = atomWithQuery<ToolConnectionsResponse>(() => ({ queryKey: ["tools", "connections"], - queryFn: () => queryConnections(), + queryFn: () => queryToolConnections(), staleTime: 30_000, refetchOnWindowFocus: false, })) -export const useConnectionsQuery = () => { - const query = useAtomValue(connectionsQueryAtom) +export const useToolConnectionsQuery = () => { + const query = useAtomValue(toolConnectionsQueryAtom) return { connections: query.data?.connections ?? [], diff --git a/web/packages/agenta-entities/src/gatewayTool/hooks/useIntegrationConnections.ts b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolIntegrationConnections.ts similarity index 73% rename from web/packages/agenta-entities/src/gatewayTool/hooks/useIntegrationConnections.ts rename to web/packages/agenta-entities/src/gatewayTool/hooks/useToolIntegrationConnections.ts index 34637d4a0e..4c16a1539d 100644 --- a/web/packages/agenta-entities/src/gatewayTool/hooks/useIntegrationConnections.ts +++ b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolIntegrationConnections.ts @@ -4,16 +4,16 @@ import {useAtomValue} from "jotai" import {atomFamily} from "jotai/utils" import {atomWithQuery} from "jotai-tanstack-query" -import {queryConnections} from "../api" +import {queryToolConnections} from "../api" import type {ToolConnection, ToolConnectionsResponse} from "../core/types" const DEFAULT_PROVIDER = "composio" -export const integrationConnectionsAtomFamily = atomFamily((integrationKey: string) => +export const toolIntegrationConnectionsAtomFamily = atomFamily((integrationKey: string) => atomWithQuery<ToolConnectionsResponse>(() => ({ queryKey: ["tools", "connections", DEFAULT_PROVIDER, integrationKey], queryFn: () => - queryConnections({ + queryToolConnections({ provider_key: DEFAULT_PROVIDER, integration_key: integrationKey, }), @@ -23,8 +23,8 @@ export const integrationConnectionsAtomFamily = atomFamily((integrationKey: stri })), ) -export const useIntegrationConnections = (integrationKey: string) => { - const query = useAtomValue(integrationConnectionsAtomFamily(integrationKey)) +export const useToolIntegrationConnections = (integrationKey: string) => { + const query = useAtomValue(toolIntegrationConnectionsAtomFamily(integrationKey)) const connections = useMemo<ToolConnection[]>( () => query.data?.connections ?? [], diff --git a/web/packages/agenta-entities/src/gatewayTool/hooks/useIntegrationDetail.ts b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolIntegrationDetail.ts similarity index 63% rename from web/packages/agenta-entities/src/gatewayTool/hooks/useIntegrationDetail.ts rename to web/packages/agenta-entities/src/gatewayTool/hooks/useToolIntegrationDetail.ts index a45bb5f1ef..ce51eab118 100644 --- a/web/packages/agenta-entities/src/gatewayTool/hooks/useIntegrationDetail.ts +++ b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolIntegrationDetail.ts @@ -2,23 +2,23 @@ import {useAtomValue} from "jotai" import {atomFamily} from "jotai/utils" import {atomWithQuery} from "jotai-tanstack-query" -import {fetchIntegrationDetail} from "../api" +import {fetchToolIntegrationDetail} from "../api" import type {ToolCatalogIntegrationResponse} from "../core/types" const DEFAULT_PROVIDER = "composio" -export const integrationDetailQueryFamily = atomFamily((integrationKey: string) => +export const toolIntegrationDetailQueryFamily = atomFamily((integrationKey: string) => atomWithQuery<ToolCatalogIntegrationResponse>(() => ({ queryKey: ["tools", "catalog", "integrationDetail", DEFAULT_PROVIDER, integrationKey], - queryFn: () => fetchIntegrationDetail(DEFAULT_PROVIDER, integrationKey), + queryFn: () => fetchToolIntegrationDetail(DEFAULT_PROVIDER, integrationKey), staleTime: 5 * 60_000, refetchOnWindowFocus: false, enabled: !!integrationKey, })), ) -export const useIntegrationDetail = (integrationKey: string) => { - const query = useAtomValue(integrationDetailQueryFamily(integrationKey)) +export const useToolIntegrationDetail = (integrationKey: string) => { + const query = useAtomValue(toolIntegrationDetailQueryFamily(integrationKey)) return { integration: query.data?.integration ?? null, diff --git a/web/packages/agenta-entities/src/gatewayTool/index.ts b/web/packages/agenta-entities/src/gatewayTool/index.ts index 97f011b22d..f4bf10a015 100644 --- a/web/packages/agenta-entities/src/gatewayTool/index.ts +++ b/web/packages/agenta-entities/src/gatewayTool/index.ts @@ -53,18 +53,18 @@ export {isConnectionActive, isConnectionValid} from "./core" // --------------------------------------------------------------------------- export { - createConnection, + createToolConnection, deleteToolConnection, executeToolCall, - fetchActionDetail, - fetchActions, - fetchConnection, - fetchIntegrationDetail, - fetchIntegrations, - fetchProviders, + fetchToolActionDetail, + fetchToolActions, + fetchToolConnection, + fetchToolIntegrationDetail, + fetchToolIntegrations, + fetchToolProviders, getToolsClient, projectScopedRequest, - queryConnections, + queryToolConnections, refreshToolConnection, revokeToolConnection, } from "./api" @@ -75,12 +75,12 @@ export { export { actionSearchAtom, - catalogDrawerOpenAtom, catalogSearchAtom, connectionDrawerAtom, - executionDrawerAtom, selectedCatalogActionAtom, selectedCatalogIntegrationAtom, + toolCatalogDrawerOpenAtom, + toolExecutionDrawerAtom, } from "./state" export type {ConnectionDrawerState, ExecutionDrawerState} from "./state" @@ -89,25 +89,25 @@ export type {ConnectionDrawerState, ExecutionDrawerState} from "./state" // --------------------------------------------------------------------------- export { - actionDetailQueryFamily, - actionsSearchAtom, buildToolSlug, - catalogActionsInfiniteFamily, - catalogIntegrationsInfiniteAtom, - connectionQueryAtomFamily, - connectionsQueryAtom, - integrationConnectionsAtomFamily, - integrationDetailQueryFamily, - integrationsSearchAtom, - useActionDetail, - useCatalogActions, - useCatalogIntegrations, - useConnectionActions, - useConnectionQuery, - useConnectionsQuery, - useIntegrationConnections, - useIntegrationDetail, + toolActionDetailQueryFamily, + toolActionsSearchAtom, + toolCatalogActionsInfiniteFamily, + toolCatalogIntegrationsInfiniteAtom, + toolConnectionQueryAtomFamily, + toolConnectionsQueryAtom, + toolIntegrationConnectionsAtomFamily, + toolIntegrationDetailQueryFamily, + toolIntegrationsSearchAtom, + useToolActionDetail, + useToolCatalogActions, + useToolCatalogIntegrations, + useToolConnectionActions, + useToolConnectionQuery, + useToolConnectionsQuery, useToolExecution, + useToolIntegrationConnections, + useToolIntegrationDetail, } from "./hooks" // --------------------------------------------------------------------------- diff --git a/web/packages/agenta-entities/src/gatewayTool/state/atoms.ts b/web/packages/agenta-entities/src/gatewayTool/state/atoms.ts index 5b0f2f7853..8b9a3692ca 100644 --- a/web/packages/agenta-entities/src/gatewayTool/state/atoms.ts +++ b/web/packages/agenta-entities/src/gatewayTool/state/atoms.ts @@ -4,7 +4,7 @@ import {atom} from "jotai" // Drawer state // --------------------------------------------------------------------------- -export const catalogDrawerOpenAtom = atom(false) +export const toolCatalogDrawerOpenAtom = atom(false) export interface ConnectionDrawerState { connectionId: string @@ -20,7 +20,7 @@ export interface ExecutionDrawerState { integrationLogo?: string actionKey?: string } -export const executionDrawerAtom = atom<ExecutionDrawerState | null>(null) +export const toolExecutionDrawerAtom = atom<ExecutionDrawerState | null>(null) // --------------------------------------------------------------------------- // Catalog browsing state (drawer-local, reset on close) diff --git a/web/packages/agenta-entities/src/gatewayTool/state/index.ts b/web/packages/agenta-entities/src/gatewayTool/state/index.ts index 2b97f95a72..3a1f62e1fc 100644 --- a/web/packages/agenta-entities/src/gatewayTool/state/index.ts +++ b/web/packages/agenta-entities/src/gatewayTool/state/index.ts @@ -1,10 +1,10 @@ export { actionSearchAtom, - catalogDrawerOpenAtom, catalogSearchAtom, connectionDrawerAtom, - executionDrawerAtom, selectedCatalogActionAtom, selectedCatalogIntegrationAtom, + toolCatalogDrawerOpenAtom, + toolExecutionDrawerAtom, } from "./atoms" export type {ConnectionDrawerState, ExecutionDrawerState} from "./atoms" diff --git a/web/packages/agenta-entities/src/gatewayTrigger/api/api.ts b/web/packages/agenta-entities/src/gatewayTrigger/api/api.ts index 08faeca609..c29d01a5da 100644 --- a/web/packages/agenta-entities/src/gatewayTrigger/api/api.ts +++ b/web/packages/agenta-entities/src/gatewayTrigger/api/api.ts @@ -15,8 +15,11 @@ import {safeParseWithLogging} from "../../shared" import { triggerCatalogEventResponseSchema, triggerCatalogEventsResponseSchema, + triggerCatalogIntegrationResponseSchema, + triggerCatalogIntegrationsResponseSchema, triggerCatalogProviderResponseSchema, triggerCatalogProvidersResponseSchema, + triggerConnectionResponseSchema, triggerConnectionsResponseSchema, triggerDeliveriesResponseSchema, triggerDeliveryResponseSchema, @@ -24,8 +27,12 @@ import { triggerSubscriptionsResponseSchema, type TriggerCatalogEventResponse, type TriggerCatalogEventsResponse, + type TriggerCatalogIntegrationResponse, + type TriggerCatalogIntegrationsResponse, type TriggerCatalogProviderResponse, type TriggerCatalogProvidersResponse, + type TriggerConnectionCreatePayload, + type TriggerConnectionResponse, type TriggerConnectionsResponse, type TriggerDeliveriesResponse, type TriggerDeliveryQuery, @@ -108,6 +115,47 @@ export const fetchTriggerEvent = async ( ) } +// --- Integrations (shared catalog with tools; browsed independently) --- + +export const fetchTriggerIntegrations = async ( + providerKey: string, + params?: {search?: string; sort_by?: string; limit?: number; cursor?: string}, +): Promise<TriggerCatalogIntegrationsResponse> => { + const {data} = await axios.get( + `${triggersBaseUrl()}/catalog/providers/${providerKey}/integrations/`, + projectScopedParams({ + search: params?.search, + sort_by: params?.sort_by, + limit: params?.limit, + cursor: params?.cursor, + }), + ) + return ( + safeParseWithLogging( + triggerCatalogIntegrationsResponseSchema, + data, + "[fetchTriggerIntegrations]", + ) ?? {count: 0, total: 0, cursor: null, integrations: []} + ) +} + +export const fetchTriggerIntegration = async ( + providerKey: string, + integrationKey: string, +): Promise<TriggerCatalogIntegrationResponse> => { + const {data} = await axios.get( + `${triggersBaseUrl()}/catalog/providers/${providerKey}/integrations/${integrationKey}`, + projectScopedParams(), + ) + return ( + safeParseWithLogging( + triggerCatalogIntegrationResponseSchema, + data, + "[fetchTriggerIntegration]", + ) ?? {count: 0, integration: null} + ) +} + // --- Connections (shared rows, WP0 view; F2) --- export const queryTriggerConnections = async (params?: { @@ -130,6 +178,78 @@ export const queryTriggerConnections = async (params?: { return (validated as TriggerConnectionsResponse | null) ?? {count: 0, connections: []} } +export const fetchTriggerConnection = async ( + connectionId: string, +): Promise<TriggerConnectionResponse> => { + const {data} = await axios.get( + `${triggersBaseUrl()}/connections/${connectionId}`, + projectScopedParams(), + ) + return ( + (safeParseWithLogging( + triggerConnectionResponseSchema, + data, + "[fetchTriggerConnection]", + ) as TriggerConnectionResponse | null) ?? {count: 0, connection: null} + ) +} + +export const createTriggerConnection = async ( + payload: TriggerConnectionCreatePayload, +): Promise<TriggerConnectionResponse> => { + const {data} = await axios.post( + `${triggersBaseUrl()}/connections/`, + payload, + projectScopedParams(), + ) + return ( + (safeParseWithLogging( + triggerConnectionResponseSchema, + data, + "[createTriggerConnection]", + ) as TriggerConnectionResponse | null) ?? {count: 0, connection: null} + ) +} + +export const deleteTriggerConnection = async (connectionId: string): Promise<void> => { + await axios.delete(`${triggersBaseUrl()}/connections/${connectionId}`, projectScopedParams()) +} + +export const refreshTriggerConnection = async ( + connectionId: string, + force?: boolean, +): Promise<TriggerConnectionResponse> => { + const {data} = await axios.post( + `${triggersBaseUrl()}/connections/${connectionId}/refresh`, + null, + projectScopedParams(force === undefined ? undefined : {force}), + ) + return ( + (safeParseWithLogging( + triggerConnectionResponseSchema, + data, + "[refreshTriggerConnection]", + ) as TriggerConnectionResponse | null) ?? {count: 0, connection: null} + ) +} + +export const revokeTriggerConnection = async ( + connectionId: string, +): Promise<TriggerConnectionResponse> => { + const {data} = await axios.post( + `${triggersBaseUrl()}/connections/${connectionId}/revoke`, + null, + projectScopedParams(), + ) + return ( + (safeParseWithLogging( + triggerConnectionResponseSchema, + data, + "[revokeTriggerConnection]", + ) as TriggerConnectionResponse | null) ?? {count: 0, connection: null} + ) +} + // --- Subscriptions --- export const queryTriggerSubscriptions = async ( diff --git a/web/packages/agenta-entities/src/gatewayTrigger/api/client.ts b/web/packages/agenta-entities/src/gatewayTrigger/api/client.ts index ef1785b52b..98c53a2def 100644 --- a/web/packages/agenta-entities/src/gatewayTrigger/api/client.ts +++ b/web/packages/agenta-entities/src/gatewayTrigger/api/client.ts @@ -27,4 +27,23 @@ export function projectScopedParams(extra?: Record<string, unknown>) { } } +/** + * Pull a human-readable message out of an axios error from the `/triggers/*` + * API. The backend surfaces upstream provider failures (e.g. a Composio 4xx + * rejecting a `trigger_config`) as a FastAPI `detail` — a plain string for + * domain/adapter errors, or `{message}` for an intercepted 500. Falls back to + * the axios message, then to `fallback`. + */ +export function triggerApiErrorMessage(error: unknown, fallback: string): string { + const detail = (error as {response?: {data?: {detail?: unknown}}})?.response?.data?.detail + if (typeof detail === "string" && detail.trim()) return detail + if (detail && typeof detail === "object") { + const message = (detail as {message?: unknown}).message + if (typeof message === "string" && message.trim()) return message + } + const axiosMessage = (error as {message?: unknown})?.message + if (typeof axiosMessage === "string" && axiosMessage.trim()) return axiosMessage + return fallback +} + export {axios} diff --git a/web/packages/agenta-entities/src/gatewayTrigger/api/index.ts b/web/packages/agenta-entities/src/gatewayTrigger/api/index.ts index f99959cd17..55bc33b12d 100644 --- a/web/packages/agenta-entities/src/gatewayTrigger/api/index.ts +++ b/web/packages/agenta-entities/src/gatewayTrigger/api/index.ts @@ -1,17 +1,24 @@ export { + createTriggerConnection, createTriggerSubscription, + deleteTriggerConnection, deleteTriggerSubscription, editTriggerSubscription, + fetchTriggerConnection, fetchTriggerDelivery, fetchTriggerEvent, fetchTriggerEvents, + fetchTriggerIntegration, + fetchTriggerIntegrations, fetchTriggerProvider, fetchTriggerProviders, fetchTriggerSubscription, queryTriggerConnections, queryTriggerDeliveries, queryTriggerSubscriptions, + refreshTriggerConnection, refreshTriggerSubscription, + revokeTriggerConnection, revokeTriggerSubscription, } from "./api" -export {triggersBaseUrl, projectScopedParams} from "./client" +export {triggersBaseUrl, projectScopedParams, triggerApiErrorMessage} from "./client" diff --git a/web/packages/agenta-entities/src/gatewayTrigger/core/types.ts b/web/packages/agenta-entities/src/gatewayTrigger/core/types.ts index 1ba1a27bb4..b2747fd288 100644 --- a/web/packages/agenta-entities/src/gatewayTrigger/core/types.ts +++ b/web/packages/agenta-entities/src/gatewayTrigger/core/types.ts @@ -16,7 +16,12 @@ import {z} from "zod" -import type {ToolConnection, ToolConnectionsResponse} from "../../gatewayTool/core/types" +import type { + ToolConnection, + ToolConnectionCreatePayload, + ToolConnectionResponse, + ToolConnectionsResponse, +} from "../../gatewayTool/core/types" // --------------------------------------------------------------------------- // Catalog @@ -69,6 +74,44 @@ export const triggerCatalogProviderResponseSchema = z .passthrough() export type TriggerCatalogProviderResponse = z.infer<typeof triggerCatalogProviderResponseSchema> +// Integrations — SHARED catalog with tools (gateway/catalog); browsed +// independently from `/triggers/catalog/.../integrations/`. +export const triggerCatalogIntegrationSchema = z + .object({ + key: z.string(), + name: z.string(), + description: z.string().nullish(), + categories: z.array(z.string()).default([]), + logo: z.string().nullish(), + url: z.string().nullish(), + actions_count: z.number().nullish(), + auth_schemes: z.array(z.string()).nullish(), + }) + .passthrough() +export type TriggerCatalogIntegration = z.infer<typeof triggerCatalogIntegrationSchema> + +export const triggerCatalogIntegrationsResponseSchema = z + .object({ + count: z.number().default(0), + total: z.number().default(0), + cursor: z.string().nullish(), + integrations: z.array(triggerCatalogIntegrationSchema).default([]), + }) + .passthrough() +export type TriggerCatalogIntegrationsResponse = z.infer< + typeof triggerCatalogIntegrationsResponseSchema +> + +export const triggerCatalogIntegrationResponseSchema = z + .object({ + count: z.number().default(0), + integration: triggerCatalogIntegrationSchema.nullish(), + }) + .passthrough() +export type TriggerCatalogIntegrationResponse = z.infer< + typeof triggerCatalogIntegrationResponseSchema +> + export const triggerCatalogEventsResponseSchema = z .object({ count: z.number().default(0), @@ -125,8 +168,19 @@ export const triggerConnectionsResponseSchema = z }) .passthrough() +export const triggerConnectionResponseSchema = z + .object({ + count: z.number().default(0), + connection: triggerConnectionSchema.nullish(), + }) + .passthrough() + export type TriggerConnection = ToolConnection export type TriggerConnectionsResponse = ToolConnectionsResponse +// Write surface reuses the gatewayTool shapes — same shared `gateway_connections` +// rows, byte-compatible (F2). Independent endpoint, identical payload. +export type TriggerConnectionResponse = ToolConnectionResponse +export type TriggerConnectionCreatePayload = ToolConnectionCreatePayload export {isConnectionActive, isConnectionValid} from "../../gatewayTool/core/types" diff --git a/web/packages/agenta-entities/src/gatewayTrigger/hooks/index.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/index.ts index 31afdb9936..ddd626f0a7 100644 --- a/web/packages/agenta-entities/src/gatewayTrigger/hooks/index.ts +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/index.ts @@ -1,4 +1,13 @@ -export {catalogEventsInfiniteFamily, eventsSearchAtom, useCatalogEvents} from "./useCatalogEvents" +export { + triggerCatalogEventsInfiniteFamily, + triggerEventsSearchAtom, + useTriggerCatalogEvents, +} from "./useTriggerCatalogEvents" +export { + triggerCatalogIntegrationsInfiniteAtom, + triggerIntegrationsSearchAtom, + useTriggerCatalogIntegrations, +} from "./useTriggerCatalogIntegrations" export {triggerEventDetailQueryFamily, useTriggerEvent} from "./useTriggerEvent" export { triggerConnectionsQueryAtom, @@ -6,6 +15,7 @@ export { useTriggerConnectionsQuery, useTriggerIntegrationConnections, } from "./useTriggerConnections" +export {useTriggerConnectionActions} from "./useTriggerConnectionActions" export { triggerConnectionSubscriptionsAtomFamily, triggerSubscriptionsQueryAtom, diff --git a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useCatalogEvents.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerCatalogEvents.ts similarity index 86% rename from web/packages/agenta-entities/src/gatewayTrigger/hooks/useCatalogEvents.ts rename to web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerCatalogEvents.ts index b5cc548b58..b4e099d7d9 100644 --- a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useCatalogEvents.ts +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerCatalogEvents.ts @@ -12,11 +12,11 @@ const CHUNK_SIZE = 10 const PREFETCH = 2 // Server-side search atom — set by the drawer, drives the query -export const eventsSearchAtom = atom("") +export const triggerEventsSearchAtom = atom("") -export const catalogEventsInfiniteFamily = atomFamily((integrationKey: string) => +export const triggerCatalogEventsInfiniteFamily = atomFamily((integrationKey: string) => atomWithInfiniteQuery<TriggerCatalogEventsResponse>((get) => { - const search = get(eventsSearchAtom) + const search = get(triggerEventsSearchAtom) return { queryKey: ["triggers", "catalog", "events", DEFAULT_PROVIDER, integrationKey, search], @@ -35,9 +35,9 @@ export const catalogEventsInfiniteFamily = atomFamily((integrationKey: string) = }), ) -export const useCatalogEvents = (integrationKey: string) => { - const query = useAtomValue(catalogEventsInfiniteFamily(integrationKey)) - const setSearch = useSetAtom(eventsSearchAtom) +export const useTriggerCatalogEvents = (integrationKey: string) => { + const query = useAtomValue(triggerCatalogEventsInfiniteFamily(integrationKey)) + const setSearch = useSetAtom(triggerEventsSearchAtom) const events = useMemo<TriggerCatalogEvent[]>(() => { const pages = query.data?.pages ?? [] diff --git a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerCatalogIntegrations.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerCatalogIntegrations.ts new file mode 100644 index 0000000000..5c99e80b3b --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerCatalogIntegrations.ts @@ -0,0 +1,81 @@ +import {useCallback, useEffect, useMemo, useRef, useState} from "react" + +import {atom, useAtomValue, useSetAtom} from "jotai" +import {atomWithInfiniteQuery} from "jotai-tanstack-query" + +import {fetchTriggerIntegrations} from "../api" +import type {TriggerCatalogIntegration, TriggerCatalogIntegrationsResponse} from "../core/types" + +const DEFAULT_PROVIDER = "composio" +const CHUNK_SIZE = 10 +const PREFETCH = 2 + +// Server-side search atom — set by the drawer, drives the query. +export const triggerIntegrationsSearchAtom = atom("") + +export const triggerCatalogIntegrationsInfiniteAtom = + atomWithInfiniteQuery<TriggerCatalogIntegrationsResponse>((get) => { + const search = get(triggerIntegrationsSearchAtom) + + return { + queryKey: ["triggers", "catalog", "integrations", DEFAULT_PROVIDER, search], + queryFn: async ({pageParam}) => + fetchTriggerIntegrations(DEFAULT_PROVIDER, { + search: search.length >= 3 ? search : undefined, + limit: CHUNK_SIZE, + cursor: (pageParam as string) || undefined, + }), + initialPageParam: "", + getNextPageParam: (lastPage) => lastPage.cursor ?? undefined, + staleTime: 5 * 60_000, + refetchOnWindowFocus: false, + } + }) + +export const useTriggerCatalogIntegrations = () => { + const query = useAtomValue(triggerCatalogIntegrationsInfiniteAtom) + const setSearch = useSetAtom(triggerIntegrationsSearchAtom) + + const integrations = useMemo<TriggerCatalogIntegration[]>(() => { + const pages = query.data?.pages ?? [] + return pages.flatMap((p) => p.integrations ?? []) + }, [query.data?.pages]) + + const total = useMemo(() => { + const pages = query.data?.pages ?? [] + return pages.length > 0 ? (pages[0].total ?? 0) : 0 + }, [query.data?.pages]) + + const [targetPages, setTargetPages] = useState(1 + PREFETCH) + const loadedPages = query.data?.pages?.length ?? 0 + + const prevLoadedRef = useRef(loadedPages) + useEffect(() => { + if (loadedPages === 0 && prevLoadedRef.current > 0) { + setTargetPages(1 + PREFETCH) + } + prevLoadedRef.current = loadedPages + }, [loadedPages]) + + const requestMore = useCallback(() => { + setTargetPages((t) => t + PREFETCH) + }, []) + + useEffect(() => { + if (loadedPages < targetPages && query.hasNextPage && !query.isFetchingNextPage) { + query.fetchNextPage() + } + }, [loadedPages, targetPages, query.hasNextPage, query.isFetchingNextPage, query.fetchNextPage]) + + return { + integrations, + total, + prefetchThreshold: PREFETCH * CHUNK_SIZE, + isLoading: query.isPending, + isFetchingNextPage: query.isFetchingNextPage, + hasNextPage: query.hasNextPage ?? false, + error: query.error, + requestMore, + setSearch, + } +} diff --git a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerConnectionActions.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerConnectionActions.ts new file mode 100644 index 0000000000..02722c3c59 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerConnectionActions.ts @@ -0,0 +1,36 @@ +import {useCallback} from "react" + +import {queryClient} from "@agenta/shared/api" + +import {deleteTriggerConnection, refreshTriggerConnection, revokeTriggerConnection} from "../api" + +// Tools and triggers are independent surfaces over the SAME shared +// `gateway_connections` rows, so a write on either side must invalidate BOTH +// caches — otherwise a connection created/removed from triggers would read as +// stale on the tools list (and vice-versa). +const invalidateConnections = () => { + queryClient.invalidateQueries({queryKey: ["triggers", "connections"]}) + queryClient.invalidateQueries({queryKey: ["tools", "connections"]}) + queryClient.invalidateQueries({queryKey: ["tools", "catalog"]}) +} + +export const useTriggerConnectionActions = () => { + const handleDelete = useCallback(async (connectionId: string) => { + await deleteTriggerConnection(connectionId) + invalidateConnections() + }, []) + + const handleRefresh = useCallback(async (connectionId: string, force?: boolean) => { + const result = await refreshTriggerConnection(connectionId, force) + invalidateConnections() + return result + }, []) + + const handleRevoke = useCallback(async (connectionId: string) => { + const result = await revokeTriggerConnection(connectionId) + invalidateConnections() + return result + }, []) + + return {handleDelete, handleRefresh, handleRevoke, invalidateConnections} +} diff --git a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerEvent.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerEvent.ts index 912cef0700..94ac383212 100644 --- a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerEvent.ts +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerEvent.ts @@ -31,7 +31,9 @@ export const useTriggerEvent = (integrationKey: string, eventKey: string) => { return { event: query.data?.event ?? null, - isLoading: query.isPending, + // `isPending` is true for a *disabled* query (no event selected yet), so + // gate on actual in-flight fetching to avoid a perpetual spinner. + isLoading: query.isFetching, error: query.error, } } diff --git a/web/packages/agenta-entities/src/gatewayTrigger/index.ts b/web/packages/agenta-entities/src/gatewayTrigger/index.ts index 24fb926f9c..1cd4b95174 100644 --- a/web/packages/agenta-entities/src/gatewayTrigger/index.ts +++ b/web/packages/agenta-entities/src/gatewayTrigger/index.ts @@ -19,10 +19,15 @@ export type { TriggerCatalogEventDetails, TriggerCatalogEventResponse, TriggerCatalogEventsResponse, + TriggerCatalogIntegration, + TriggerCatalogIntegrationResponse, + TriggerCatalogIntegrationsResponse, TriggerCatalogProvider, TriggerCatalogProviderResponse, TriggerCatalogProvidersResponse, TriggerConnection, + TriggerConnectionCreatePayload, + TriggerConnectionResponse, TriggerConnectionsResponse, TriggerDelivery, TriggerDeliveriesResponse, @@ -48,20 +53,28 @@ export {isConnectionActive, isConnectionValid} from "./core" // --------------------------------------------------------------------------- export { + createTriggerConnection, createTriggerSubscription, + deleteTriggerConnection, deleteTriggerSubscription, editTriggerSubscription, + fetchTriggerConnection, fetchTriggerDelivery, fetchTriggerEvent, fetchTriggerEvents, + fetchTriggerIntegration, + fetchTriggerIntegrations, fetchTriggerProvider, fetchTriggerProviders, fetchTriggerSubscription, queryTriggerConnections, queryTriggerDeliveries, queryTriggerSubscriptions, + refreshTriggerConnection, refreshTriggerSubscription, + revokeTriggerConnection, revokeTriggerSubscription, + triggerApiErrorMessage, } from "./api" // --------------------------------------------------------------------------- @@ -69,11 +82,12 @@ export { // --------------------------------------------------------------------------- export { - deliveriesDrawerAtom, - eventsDrawerAtom, - eventSearchAtom, - selectedCatalogEventAtom, - subscriptionDrawerAtom, + triggerCatalogDrawerOpenAtom, + triggerDeliveriesDrawerAtom, + triggerEventsDrawerAtom, + triggerEventSearchAtom, + triggerSelectedCatalogEventAtom, + triggerSubscriptionDrawerAtom, } from "./state" export type {DeliveriesDrawerState, EventsDrawerState, SubscriptionDrawerState} from "./state" @@ -82,16 +96,20 @@ export type {DeliveriesDrawerState, EventsDrawerState, SubscriptionDrawerState} // --------------------------------------------------------------------------- export { - catalogEventsInfiniteFamily, - eventsSearchAtom, + triggerCatalogEventsInfiniteFamily, + triggerCatalogIntegrationsInfiniteAtom, triggerConnectionsQueryAtom, triggerConnectionSubscriptionsAtomFamily, triggerDeliveriesAtomFamily, triggerEventDetailQueryFamily, + triggerEventsSearchAtom, triggerIntegrationConnectionsAtomFamily, + triggerIntegrationsSearchAtom, triggerSubscriptionQueryAtomFamily, triggerSubscriptionsQueryAtom, - useCatalogEvents, + useTriggerCatalogEvents, + useTriggerCatalogIntegrations, + useTriggerConnectionActions, useTriggerConnectionsQuery, useTriggerConnectionSubscriptions, useTriggerDeliveries, diff --git a/web/packages/agenta-entities/src/gatewayTrigger/state/atoms.ts b/web/packages/agenta-entities/src/gatewayTrigger/state/atoms.ts index c9a823eeab..7c7e04b31a 100644 --- a/web/packages/agenta-entities/src/gatewayTrigger/state/atoms.ts +++ b/web/packages/agenta-entities/src/gatewayTrigger/state/atoms.ts @@ -1,5 +1,11 @@ import {atom} from "jotai" +// --------------------------------------------------------------------------- +// Catalog drawer — browse integrations to connect (independent of tools) +// --------------------------------------------------------------------------- + +export const triggerCatalogDrawerOpenAtom = atom(false) + // --------------------------------------------------------------------------- // Events drawer state — opened against a connected integration // --------------------------------------------------------------------------- @@ -10,11 +16,11 @@ export interface EventsDrawerState { integrationName?: string connectionId?: string } -export const eventsDrawerAtom = atom<EventsDrawerState | null>(null) +export const triggerEventsDrawerAtom = atom<EventsDrawerState | null>(null) // Drawer-local browsing state (reset on close) -export const eventSearchAtom = atom("") -export const selectedCatalogEventAtom = atom<string | null>(null) +export const triggerEventSearchAtom = atom("") +export const triggerSelectedCatalogEventAtom = atom<string | null>(null) // --------------------------------------------------------------------------- // Subscription drawer state — create (no id) or edit (existing subscription id) @@ -28,11 +34,11 @@ export interface SubscriptionDrawerState { integrationKey?: string integrationName?: string } -export const subscriptionDrawerAtom = atom<SubscriptionDrawerState | null>(null) +export const triggerSubscriptionDrawerAtom = atom<SubscriptionDrawerState | null>(null) // Deliveries drawer state — opened against one subscription. export interface DeliveriesDrawerState { subscriptionId: string subscriptionName?: string } -export const deliveriesDrawerAtom = atom<DeliveriesDrawerState | null>(null) +export const triggerDeliveriesDrawerAtom = atom<DeliveriesDrawerState | null>(null) diff --git a/web/packages/agenta-entities/src/gatewayTrigger/state/index.ts b/web/packages/agenta-entities/src/gatewayTrigger/state/index.ts index d5f81c8210..6e69c8ed45 100644 --- a/web/packages/agenta-entities/src/gatewayTrigger/state/index.ts +++ b/web/packages/agenta-entities/src/gatewayTrigger/state/index.ts @@ -1,8 +1,9 @@ export { - deliveriesDrawerAtom, - eventsDrawerAtom, - eventSearchAtom, - selectedCatalogEventAtom, - subscriptionDrawerAtom, + triggerCatalogDrawerOpenAtom, + triggerDeliveriesDrawerAtom, + triggerEventsDrawerAtom, + triggerEventSearchAtom, + triggerSelectedCatalogEventAtom, + triggerSubscriptionDrawerAtom, } from "./atoms" export type {DeliveriesDrawerState, EventsDrawerState, SubscriptionDrawerState} from "./atoms" diff --git a/web/packages/agenta-entities/src/index.ts b/web/packages/agenta-entities/src/index.ts index c35ca0806e..a73dab5415 100644 --- a/web/packages/agenta-entities/src/index.ts +++ b/web/packages/agenta-entities/src/index.ts @@ -288,6 +288,6 @@ export type {Annotation, AnnotationDraft} from "./annotation" // import { annotationMolecule, encodeAnnotationId } from '@agenta/entities/annotation' // import { evaluationRunMolecule } from '@agenta/entities/evaluationRun' // import { -// useCatalogIntegrations, -// catalogDrawerOpenAtom, +// useToolCatalogIntegrations, +// toolCatalogDrawerOpenAtom, // } from '@agenta/entities/gatewayTool' diff --git a/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx b/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx index 256034ec21..d0e221bea3 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTool/components/SchemaForm.tsx @@ -318,6 +318,7 @@ function SchemaFormField({field, depth = 0}: {field: FormFieldDescriptor; depth? > <Select placeholder={field.label} + allowClear={!field.required} options={(field.enumValues ?? []).map((v) => ({value: v, label: v}))} /> </Form.Item> diff --git a/web/packages/agenta-entity-ui/src/gatewayTool/drawers/CatalogDrawer.tsx b/web/packages/agenta-entity-ui/src/gatewayTool/drawers/CatalogDrawer.tsx index 2bee8225fd..af8fb84bfc 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTool/drawers/CatalogDrawer.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTool/drawers/CatalogDrawer.tsx @@ -1,14 +1,14 @@ import React, {useCallback, useMemo, useRef, useState} from "react" import { - actionsSearchAtom, - catalogDrawerOpenAtom, - executionDrawerAtom, - integrationsSearchAtom, isConnectionActive, - useCatalogActions, - useCatalogIntegrations, - useIntegrationConnections, + toolActionsSearchAtom, + toolCatalogDrawerOpenAtom, + toolExecutionDrawerAtom, + toolIntegrationsSearchAtom, + useToolCatalogActions, + useToolCatalogIntegrations, + useToolIntegrationConnections, type ToolCatalogIntegration, type ToolCatalogIntegrationDetails, type ToolConnection, @@ -66,7 +66,7 @@ interface Props { } export default function CatalogDrawer({onConnectionCreated}: Props) { - const [open, setOpen] = useAtom(catalogDrawerOpenAtom) + const [open, setOpen] = useAtom(toolCatalogDrawerOpenAtom) const [selectedIntegration, setSelectedIntegration] = useState<CatalogIntegrationItem | null>( null, ) @@ -74,8 +74,8 @@ export default function CatalogDrawer({onConnectionCreated}: Props) { null, ) - const setIntegrationsSearch = useSetAtom(integrationsSearchAtom) - const setActionsSearch = useSetAtom(actionsSearchAtom) + const setIntegrationsSearch = useSetAtom(toolIntegrationsSearchAtom) + const setActionsSearch = useSetAtom(toolActionsSearchAtom) const handleClose = useCallback(() => { setOpen(false) @@ -148,7 +148,7 @@ export default function CatalogDrawer({onConnectionCreated}: Props) { // --------------------------------------------------------------------------- function IntegrationsView({onSelect}: {onSelect: (integration: CatalogIntegrationItem) => void}) { - const setAtom = useSetAtom(integrationsSearchAtom) + const setAtom = useSetAtom(toolIntegrationsSearchAtom) const search = useDebouncedAtomSearch(setAtom) const scrollRef = useRef<HTMLDivElement>(null) @@ -160,7 +160,7 @@ function IntegrationsView({onSelect}: {onSelect: (integration: CatalogIntegratio hasNextPage, isFetchingNextPage, requestMore, - } = useCatalogIntegrations() + } = useToolCatalogIntegrations() const sentinelIndex = useMemo( () => Math.max(0, integrations.length - prefetchThreshold), @@ -290,11 +290,11 @@ function ActionsView({ onBack: () => void onConnect: () => void }) { - const setAtom = useSetAtom(actionsSearchAtom) + const setAtom = useSetAtom(toolActionsSearchAtom) const search = useDebouncedAtomSearch(setAtom) const scrollRef = useRef<HTMLDivElement>(null) - const setExecutionDrawer = useSetAtom(executionDrawerAtom) - const {connections} = useIntegrationConnections(integration.key) + const setExecutionDrawer = useSetAtom(toolExecutionDrawerAtom) + const {connections} = useToolIntegrationConnections(integration.key) const handleOpenConnection = useCallback( (conn: ToolConnection) => { @@ -334,7 +334,7 @@ function ActionsView({ hasNextPage, isFetchingNextPage, requestMore, - } = useCatalogActions(integration.key) + } = useToolCatalogActions(integration.key) const sentinelIndex = useMemo( () => Math.max(0, actions.length - prefetchThreshold), diff --git a/web/packages/agenta-entity-ui/src/gatewayTool/drawers/ConnectDrawer.tsx b/web/packages/agenta-entity-ui/src/gatewayTool/drawers/ConnectDrawer.tsx index 40820a7b48..7c69c60132 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTool/drawers/ConnectDrawer.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTool/drawers/ConnectDrawer.tsx @@ -1,6 +1,6 @@ import {useCallback, useRef, useState} from "react" -import {createConnection, fetchConnection} from "@agenta/entities/gatewayTool" +import {createToolConnection, fetchToolConnection} from "@agenta/entities/gatewayTool" import {getAgentaApiUrl, getAgentaWebUrl, queryClient} from "@agenta/shared/api" import {generateDefaultSlug, randomAlphanumeric} from "@agenta/shared/utils" import {EnhancedModal, ModalContent, ModalFooter} from "@agenta/ui" @@ -77,7 +77,7 @@ export default function ConnectDrawer({ const values = await form.validateFields() setLoading(true) - const result = await createConnection({ + const result = await createToolConnection({ connection: { slug: values.slug, name: values.name || values.slug, @@ -111,7 +111,7 @@ export default function ConnectDrawer({ window.focus() if (connectionId) { try { - await fetchConnection(connectionId) + await fetchToolConnection(connectionId) } catch { /* best-effort */ } diff --git a/web/packages/agenta-entity-ui/src/gatewayTool/drawers/ConnectionManagerDrawer.tsx b/web/packages/agenta-entity-ui/src/gatewayTool/drawers/ConnectionManagerDrawer.tsx index c774ad59c4..02f339c67e 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTool/drawers/ConnectionManagerDrawer.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTool/drawers/ConnectionManagerDrawer.tsx @@ -2,11 +2,11 @@ import {useCallback, useState} from "react" import { connectionDrawerAtom, - executionDrawerAtom, isConnectionActive, isConnectionValid, - useConnectionActions, - useConnectionQuery, + toolExecutionDrawerAtom, + useToolConnectionActions, + useToolConnectionQuery, type ToolConnection, } from "@agenta/entities/gatewayTool" import {getAgentaApiUrl, getAgentaWebUrl, queryClient} from "@agenta/shared/api" @@ -26,11 +26,11 @@ function formatCreatedAt(value: string | null | undefined): string { export default function ConnectionManagerDrawer() { const [state, setState] = useAtom(connectionDrawerAtom) - const setExecution = useSetAtom(executionDrawerAtom) + const setExecution = useSetAtom(toolExecutionDrawerAtom) const open = !!state - const {handleDelete, handleRefresh, handleRevoke} = useConnectionActions() + const {handleDelete, handleRefresh, handleRevoke} = useToolConnectionActions() const connectionId = state?.connectionId - const {connection, isLoading, refetch} = useConnectionQuery(connectionId) + const {connection, isLoading, refetch} = useToolConnectionQuery(connectionId) const [actionLoading, setActionLoading] = useState<string | null>(null) diff --git a/web/packages/agenta-entity-ui/src/gatewayTool/drawers/ToolExecutionDrawer.tsx b/web/packages/agenta-entity-ui/src/gatewayTool/drawers/ToolExecutionDrawer.tsx index 90f8d3fdb4..747c669599 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTool/drawers/ToolExecutionDrawer.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTool/drawers/ToolExecutionDrawer.tsx @@ -1,12 +1,12 @@ import React, {useCallback, useMemo, useRef, useState} from "react" import { - actionsSearchAtom, - executionDrawerAtom, - useActionDetail, - useCatalogActions, - useIntegrationDetail, + toolActionsSearchAtom, + toolExecutionDrawerAtom, + useToolActionDetail, + useToolCatalogActions, useToolExecution, + useToolIntegrationDetail, type ToolCatalogAction, type ToolCatalogActionDetails, } from "@agenta/entities/gatewayTool" @@ -50,13 +50,13 @@ const DEFAULT_PROVIDER = "composio" // --------------------------------------------------------------------------- export default function ToolExecutionDrawer() { - const [state, setState] = useAtom(executionDrawerAtom) + const [state, setState] = useAtom(toolExecutionDrawerAtom) const open = !!state const [selectedAction, setSelectedAction] = useState<CatalogActionItem | null>(null) - const setActionsSearch = useSetAtom(actionsSearchAtom) + const setActionsSearch = useSetAtom(toolActionsSearchAtom) // Fetch integration info as fallback when name/logo not in state - const {integration} = useIntegrationDetail(state?.integrationKey ?? "") + const {integration} = useToolIntegrationDetail(state?.integrationKey ?? "") const integrationName = state?.integrationName ?? integration?.name const integrationLogo = state?.integrationLogo ?? integration?.logo @@ -139,7 +139,7 @@ function ActionPickerStep({ connectionSlug: string onSelectAction: (action: CatalogActionItem) => void }) { - const setAtom = useSetAtom(actionsSearchAtom) + const setAtom = useSetAtom(toolActionsSearchAtom) const search = useDebouncedAtomSearch(setAtom) const scrollRef = useRef<HTMLDivElement>(null) @@ -151,7 +151,7 @@ function ActionPickerStep({ hasNextPage, isFetchingNextPage, requestMore, - } = useCatalogActions(integrationKey) + } = useToolCatalogActions(integrationKey) const sentinelIndex = useMemo( () => Math.max(0, actions.length - prefetchThreshold), @@ -297,7 +297,7 @@ function ActionDetailStep({ const [form] = Form.useForm() const schemaFormRef = useRef<SchemaFormHandle>(null) const scrollRef = useRef<HTMLDivElement>(null) - const {action, isLoading: detailLoading} = useActionDetail(integrationKey, actionKey) + const {action, isLoading: detailLoading} = useToolActionDetail(integrationKey, actionKey) const {execute, isExecuting, result, error} = useToolExecution() const [viewMode, setViewMode] = useState<"form" | "json">("form") diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerCatalogDrawer.tsx b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerCatalogDrawer.tsx new file mode 100644 index 0000000000..70888c6d54 --- /dev/null +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerCatalogDrawer.tsx @@ -0,0 +1,466 @@ +import React, {useCallback, useMemo, useRef, useState} from "react" + +import { + isConnectionActive, + triggerCatalogDrawerOpenAtom, + triggerEventsDrawerAtom, + triggerEventsSearchAtom, + triggerIntegrationsSearchAtom, + useTriggerCatalogEvents, + useTriggerCatalogIntegrations, + useTriggerIntegrationConnections, + type TriggerCatalogEvent, + type TriggerCatalogIntegration, + type TriggerConnection, +} from "@agenta/entities/gatewayTrigger" +import {useDebouncedAtomSearch} from "@agenta/shared/hooks" +import {ScrollSentinel, ScrollToTopButton} from "@agenta/ui" +import {ArrowLeft, CaretDown, MagnifyingGlass, Plus} from "@phosphor-icons/react" +import type {MenuProps} from "antd" +import { + Badge, + Button, + Card, + Divider, + Drawer, + Dropdown, + Empty, + Input, + Spin, + Tag, + Typography, +} from "antd" +import {useAtom, useSetAtom} from "jotai" +import Image from "next/image" + +import TriggerConnectDrawer from "./TriggerConnectDrawer" + +// --------------------------------------------------------------------------- +// Expandable description — 2-line clamp with inline "see more" / "see less" +// (identical to gatewayTool CatalogDrawer). +// --------------------------------------------------------------------------- + +function ExpandableText({text}: {text: string}) { + return ( + <Typography.Paragraph + type="secondary" + className="!text-xs !mb-0" + ellipsis={{ + rows: 3, + expandable: "collapsible", + symbol: (expanded) => (expanded ? "see less" : "see more"), + }} + > + {text} + </Typography.Paragraph> + ) +} + +// --------------------------------------------------------------------------- +// TriggerCatalogDrawer (root) — mirrors gatewayTool CatalogDrawer with the +// tools "action" leaf swapped for the triggers "event" leaf. +// --------------------------------------------------------------------------- + +interface Props { + onConnectionCreated?: () => void +} + +export default function TriggerCatalogDrawer({onConnectionCreated}: Props) { + const [open, setOpen] = useAtom(triggerCatalogDrawerOpenAtom) + const [selectedIntegration, setSelectedIntegration] = + useState<TriggerCatalogIntegration | null>(null) + const [connectIntegration, setConnectIntegration] = useState<TriggerCatalogIntegration | null>( + null, + ) + + const setIntegrationsSearch = useSetAtom(triggerIntegrationsSearchAtom) + const setEventsSearch = useSetAtom(triggerEventsSearchAtom) + + const handleClose = useCallback(() => { + setOpen(false) + setSelectedIntegration(null) + setConnectIntegration(null) + setIntegrationsSearch("") + setEventsSearch("") + }, [setOpen, setIntegrationsSearch, setEventsSearch]) + + const handleBack = useCallback(() => { + setSelectedIntegration(null) + setEventsSearch("") + }, [setEventsSearch]) + + const handleConnect = useCallback((integration: TriggerCatalogIntegration) => { + setConnectIntegration(integration) + }, []) + + const handleConnectionSuccess = useCallback(() => { + handleClose() + onConnectionCreated?.() + }, [handleClose, onConnectionCreated]) + + return ( + <> + <Drawer + open={open} + onClose={handleClose} + title={selectedIntegration ? "Browse Events" : "Browse Integrations"} + size="large" + destroyOnClose + styles={{ + body: { + padding: 0, + display: "flex", + flexDirection: "column", + overflow: "hidden", + }, + }} + > + {selectedIntegration ? ( + <EventsView + integration={selectedIntegration} + onBack={handleBack} + onConnect={() => handleConnect(selectedIntegration)} + /> + ) : ( + <IntegrationsView onSelect={setSelectedIntegration} /> + )} + </Drawer> + + {connectIntegration && ( + <TriggerConnectDrawer + open={!!connectIntegration} + integrationKey={connectIntegration.key} + integrationName={connectIntegration.name} + integrationLogo={connectIntegration.logo ?? undefined} + integrationDescription={connectIntegration.description ?? undefined} + authSchemes={connectIntegration.auth_schemes ?? []} + onClose={() => setConnectIntegration(null)} + onSuccess={handleConnectionSuccess} + /> + )} + </> + ) +} + +// --------------------------------------------------------------------------- +// Integrations view +// --------------------------------------------------------------------------- + +function IntegrationsView({ + onSelect, +}: { + onSelect: (integration: TriggerCatalogIntegration) => void +}) { + const setAtom = useSetAtom(triggerIntegrationsSearchAtom) + const search = useDebouncedAtomSearch(setAtom) + const scrollRef = useRef<HTMLDivElement>(null) + + const { + integrations, + total, + prefetchThreshold, + isLoading, + hasNextPage, + isFetchingNextPage, + requestMore, + } = useTriggerCatalogIntegrations() + + const sentinelIndex = useMemo( + () => Math.max(0, integrations.length - prefetchThreshold), + [integrations.length, prefetchThreshold], + ) + + if (isLoading && integrations.length === 0) { + return ( + <div className="flex items-center justify-center py-12"> + <Spin /> + </div> + ) + } + + return ( + <div className="flex flex-col h-full overflow-hidden"> + <div className="flex flex-col gap-3 px-6 pt-4 pb-3 shrink-0"> + <Input + placeholder="Search integrations…" + prefix={<MagnifyingGlass size={16} />} + value={search.value} + onChange={(e) => search.onChange(e.target.value)} + allowClear + onClear={() => search.onChange("")} + /> + <Typography.Text type="secondary" className="text-xs"> + {total} integration{total !== 1 ? "s" : ""} + </Typography.Text> + </div> + + <Divider className="!m-0" /> + + <div + ref={scrollRef} + className="flex-1 overflow-y-auto overscroll-contain px-6 py-3 relative" + > + {integrations.length === 0 ? ( + <Empty description="No integrations found" /> + ) : ( + <div className="flex flex-col gap-2"> + {integrations.map((integration, i) => ( + <React.Fragment key={integration.key}> + {i === sentinelIndex && ( + <ScrollSentinel + onVisible={requestMore} + hasMore={hasNextPage} + isFetching={isFetchingNextPage} + /> + )} + <Card + hoverable + onClick={() => onSelect(integration)} + className="cursor-pointer" + size="small" + > + <div className="flex items-start gap-3"> + {integration.logo && ( + <Image + src={integration.logo} + alt={integration.name} + width={32} + height={32} + className="w-8 h-8 rounded object-contain shrink-0" + unoptimized + /> + )} + <div className="flex flex-col gap-0.5 min-w-0 flex-1"> + <div className="flex items-center gap-2"> + <Typography.Text strong className="truncate"> + {integration.name} + </Typography.Text> + {integration.actions_count != null && ( + <Badge + count={`${integration.actions_count} actions`} + size="small" + color="blue" + /> + )} + </div> + {integration.description && ( + <Typography.Text + type="secondary" + className="text-xs line-clamp-2" + > + {integration.description} + </Typography.Text> + )} + </div> + </div> + </Card> + </React.Fragment> + ))} + + <ScrollSentinel + onVisible={requestMore} + hasMore={hasNextPage} + isFetching={isFetchingNextPage} + /> + + {isFetchingNextPage && ( + <div className="flex items-center justify-center py-4"> + <Spin size="small" /> + </div> + )} + </div> + )} + + <ScrollToTopButton scrollRef={scrollRef} /> + </div> + </div> + ) +} + +// --------------------------------------------------------------------------- +// Events view — browse an integration's events; Connect + open-events on a +// chosen existing connection (mirrors tools ActionsView). +// --------------------------------------------------------------------------- + +function EventsView({ + integration, + onBack, + onConnect, +}: { + integration: TriggerCatalogIntegration + onBack: () => void + onConnect: () => void +}) { + const setAtom = useSetAtom(triggerEventsSearchAtom) + const search = useDebouncedAtomSearch(setAtom) + const scrollRef = useRef<HTMLDivElement>(null) + const setEventsDrawer = useSetAtom(triggerEventsDrawerAtom) + const {connections} = useTriggerIntegrationConnections(integration.key) + + const handleOpenConnectionEvents = useCallback( + (conn: TriggerConnection) => { + setEventsDrawer({ + providerKey: conn.provider_key ?? "composio", + integrationKey: conn.integration_key, + integrationName: integration.name, + connectionId: conn.id ?? undefined, + }) + }, + [setEventsDrawer, integration.name], + ) + + const connectMenuItems = useMemo<MenuProps["items"]>( + () => + connections.map((conn) => ({ + key: conn.id ?? conn.slug ?? "", + label: ( + <div className="flex items-center gap-2"> + <span className="truncate">{conn.name || conn.slug}</span> + {isConnectionActive(conn) && ( + <span className="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0" /> + )} + </div> + ), + onClick: () => handleOpenConnectionEvents(conn), + })), + [connections, handleOpenConnectionEvents], + ) + + const { + events, + total, + prefetchThreshold, + isLoading, + hasNextPage, + isFetchingNextPage, + requestMore, + } = useTriggerCatalogEvents(integration.key) + + const sentinelIndex = useMemo( + () => Math.max(0, events.length - prefetchThreshold), + [events.length, prefetchThreshold], + ) + + return ( + <div className="flex flex-col h-full overflow-hidden"> + <div className="flex flex-col gap-3 px-6 pt-4 pb-3 shrink-0"> + <div className="flex items-center gap-3"> + <Button + type="text" + aria-label="Go back" + icon={<ArrowLeft size={16} />} + onClick={onBack} + className="shrink-0" + /> + {integration.logo && ( + <Image + src={integration.logo} + alt={integration.name} + width={32} + height={32} + className="w-8 h-8 rounded object-contain shrink-0" + unoptimized + /> + )} + <Typography.Text strong className="truncate flex-1"> + {integration.name} + </Typography.Text> + <div className="shrink-0"> + {connections.length > 0 ? ( + <Dropdown.Button + type="primary" + trigger={["click"]} + menu={{items: connectMenuItems}} + icon={<CaretDown size={12} />} + onClick={onConnect} + > + <Plus size={14} /> + Connect + </Dropdown.Button> + ) : ( + <Button type="primary" icon={<Plus size={14} />} onClick={onConnect}> + Connect + </Button> + )} + </div> + </div> + {integration.description && <ExpandableText text={integration.description} />} + + <Input + placeholder="Search events…" + prefix={<MagnifyingGlass size={16} />} + value={search.value} + onChange={(e) => search.onChange(e.target.value)} + allowClear + onClear={() => search.onChange("")} + /> + + <Typography.Text type="secondary" className="text-xs"> + {total} event{total !== 1 ? "s" : ""} + </Typography.Text> + </div> + + <Divider className="!m-0" /> + + <div + ref={scrollRef} + className="flex-1 overflow-y-auto overscroll-contain px-6 py-3 relative" + > + {isLoading && events.length === 0 ? ( + <div className="flex items-center justify-center py-8"> + <Spin /> + </div> + ) : events.length === 0 ? ( + <Empty description="No events found" /> + ) : ( + <div className="flex flex-col gap-2"> + {events.map((event: TriggerCatalogEvent, i) => ( + <React.Fragment key={event.key}> + {i === sentinelIndex && ( + <ScrollSentinel + onVisible={requestMore} + hasMore={hasNextPage} + isFetching={isFetchingNextPage} + /> + )} + <Card hoverable className="cursor-pointer" size="small"> + <div className="flex flex-col gap-0.5"> + <div className="flex items-center gap-2"> + <Typography.Text strong className="truncate"> + {event.name} + </Typography.Text> + {event.categories?.slice(0, 2).map((c) => ( + <Tag key={c} className="text-xs"> + {c} + </Tag> + ))} + </div> + {event.description && ( + <Typography.Text type="secondary" className="text-xs"> + {event.description} + </Typography.Text> + )} + </div> + </Card> + </React.Fragment> + ))} + + <ScrollSentinel + onVisible={requestMore} + hasMore={hasNextPage} + isFetching={isFetchingNextPage} + /> + + {isFetchingNextPage && ( + <div className="flex items-center justify-center py-4"> + <Spin size="small" /> + </div> + )} + </div> + )} + + <ScrollToTopButton scrollRef={scrollRef} /> + </div> + </div> + ) +} diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerConnectDrawer.tsx b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerConnectDrawer.tsx new file mode 100644 index 0000000000..f87cd0687a --- /dev/null +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerConnectDrawer.tsx @@ -0,0 +1,281 @@ +import {useCallback, useRef, useState} from "react" + +import {createTriggerConnection, fetchTriggerConnection} from "@agenta/entities/gatewayTrigger" +import {getAgentaApiUrl, getAgentaWebUrl, queryClient} from "@agenta/shared/api" +import {generateDefaultSlug, randomAlphanumeric} from "@agenta/shared/utils" +import {EnhancedModal, ModalContent, ModalFooter} from "@agenta/ui" +import {Divider, Form, Input, message, Select, Tooltip, Typography} from "antd" +import Image from "next/image" + +const DEFAULT_PROVIDER = "composio" + +type AuthMode = "oauth" | "api_key" + +interface Props { + open: boolean + integrationKey: string + integrationName: string + integrationLogo?: string + integrationDescription?: string + authSchemes: string[] + onClose: () => void + onSuccess?: () => void +} + +function resolveAvailableModes(authSchemes: string[]): AuthMode[] { + const modes: AuthMode[] = [] + if (authSchemes.some((s) => s.toLowerCase().includes("oauth"))) modes.push("oauth") + if ( + authSchemes.some( + (s) => s.toLowerCase().includes("api_key") || s.toLowerCase().includes("basic"), + ) + ) + modes.push("api_key") + if (modes.length === 0) modes.push("oauth") + return modes +} + +// Tools and triggers are independent surfaces over the SAME shared +// `gateway_connections` rows; invalidate both lists so a connection made from +// triggers shows up on the tools list and vice-versa. +function invalidateConnections() { + queryClient.invalidateQueries({queryKey: ["triggers", "connections"]}) + queryClient.invalidateQueries({queryKey: ["tools", "connections"]}) + queryClient.invalidateQueries({queryKey: ["triggers", "catalog"]}) +} + +export default function TriggerConnectDrawer({ + open, + integrationKey, + integrationName, + integrationLogo, + integrationDescription, + authSchemes, + onClose, + onSuccess, +}: Props) { + const [loading, setLoading] = useState(false) + const [form] = Form.useForm() + const slugTouchedRef = useRef(false) + const slugSuffixRef = useRef(randomAlphanumeric(3)) + + const availableModes = resolveAvailableModes(authSchemes) + const [selectedMode, setSelectedMode] = useState<AuthMode>(availableModes[0] || "oauth") + + const handleClose = useCallback(() => { + form.resetFields() + slugTouchedRef.current = false + slugSuffixRef.current = randomAlphanumeric(3) + setLoading(false) + onClose() + }, [form, onClose]) + + const buildDefaultSlug = useCallback((name: string) => { + return generateDefaultSlug(name, slugSuffixRef.current) + }, []) + + const handleSubmit = useCallback(async () => { + try { + const values = await form.validateFields() + setLoading(true) + + const result = await createTriggerConnection({ + connection: { + slug: values.slug, + name: values.name || values.slug, + provider_key: DEFAULT_PROVIDER, + integration_key: integrationKey, + data: {auth_scheme: selectedMode}, + }, + }) + + invalidateConnections() + + const redirectUrl = (result.connection?.data as Record<string, unknown> | undefined) + ?.redirect_url + if (typeof redirectUrl === "string" && redirectUrl) { + // Composio handles all auth (OAuth and API key) via its redirect UI. + // The OAuth callback is the shared /tools/connections/callback (one + // public contract over the shared row), so it posts the same + // `tools:oauth:complete` message we listen for here. + const popup = window.open( + redirectUrl, + "triggers_oauth", + "width=600,height=700,popup=yes", + ) + if (!popup) { + setLoading(false) + message.warning("Popup blocked. Redirecting in this tab.") + window.location.assign(redirectUrl) + return + } + + const connectionId = result.connection?.id + + const onAuthDone = async () => { + window.focus() + if (connectionId) { + try { + await fetchTriggerConnection(connectionId) + } catch { + /* best-effort */ + } + } + invalidateConnections() + handleClose() + onSuccess?.() + } + + const trustedOrigins = new Set<string>([window.location.origin]) + for (const url of [getAgentaApiUrl(), getAgentaWebUrl()]) { + if (!url) continue + try { + trustedOrigins.add(new URL(url).origin) + } catch { + // ignore invalid env URLs + } + } + + const handler = (event: MessageEvent) => { + if ( + event.data?.type === "tools:oauth:complete" && + trustedOrigins.has(event.origin) + ) { + window.removeEventListener("message", handler) + void onAuthDone() + } + } + window.addEventListener("message", handler) + + const pollTimer = setInterval(() => { + if (popup && popup.closed) { + clearInterval(pollTimer) + window.removeEventListener("message", handler) + void onAuthDone() + } + }, 1000) + } else { + handleClose() + onSuccess?.() + } + } catch { + setLoading(false) + } + }, [form, selectedMode, integrationKey, handleClose, onSuccess]) + + return ( + <EnhancedModal + open={open} + onCancel={handleClose} + title={`Connect to ${integrationName}`} + footer={null} + width={480} + destroyOnClose + > + <ModalContent> + <div className="flex items-center gap-3"> + {integrationLogo && ( + <Image + src={integrationLogo} + alt={integrationName} + width={36} + height={36} + className="w-9 h-9 rounded object-contain shrink-0" + unoptimized + /> + )} + <div className="flex flex-col min-w-0"> + <Typography.Text strong className="leading-snug"> + {integrationName} + </Typography.Text> + {integrationDescription && ( + <Typography.Text type="secondary" className="!text-xs line-clamp-2"> + {integrationDescription} + </Typography.Text> + )} + </div> + </div> + + <Divider className="!m-0" /> + + <Form + form={form} + layout="vertical" + className="!mb-0" + initialValues={{ + name: integrationName, + slug: buildDefaultSlug(integrationName || ""), + }} + requiredMark={(label, {required}) => ( + <> + {label} + {required && <span className="text-red-500 ml-1">*</span>} + </> + )} + > + <Form.Item + name="name" + label={ + <Tooltip title="Display name for this connection"> + <span>Name</span> + </Tooltip> + } + className="!mb-4" + > + <Input + placeholder={`e.g. My ${integrationName} Account`} + onChange={(e) => { + if (!slugTouchedRef.current) { + form.setFieldValue( + "slug", + buildDefaultSlug(e.target.value || integrationName || ""), + ) + } + }} + /> + </Form.Item> + + <Form.Item + name="slug" + label={ + <Tooltip title="Unique identifier — lowercase letters, numbers, and hyphens only"> + <span>Slug</span> + </Tooltip> + } + rules={[{required: true, message: "Required"}]} + className={availableModes.length > 1 ? "!mb-4" : "!mb-0"} + > + <Input + placeholder={`e.g. my-${integrationKey}`} + onChange={() => { + slugTouchedRef.current = true + }} + /> + </Form.Item> + + {availableModes.length > 1 && ( + <Form.Item label="Auth Method" className="!mb-0"> + <Select + value={selectedMode} + onChange={setSelectedMode} + options={availableModes.map((m) => ({ + value: m, + label: m === "oauth" ? "OAuth" : "API Key", + }))} + /> + </Form.Item> + )} + </Form> + + <Divider className="!m-0" /> + + <ModalFooter + onCancel={handleClose} + onConfirm={handleSubmit} + confirmLabel="Connect" + isLoading={loading} + /> + </ModalContent> + </EnhancedModal> + ) +} diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerDeliveriesDrawer.tsx b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerDeliveriesDrawer.tsx index aefa936709..fae1597052 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerDeliveriesDrawer.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerDeliveriesDrawer.tsx @@ -1,7 +1,7 @@ import {useMemo} from "react" import { - deliveriesDrawerAtom, + triggerDeliveriesDrawerAtom, useTriggerDeliveries, type TriggerDelivery, } from "@agenta/entities/gatewayTrigger" @@ -36,7 +36,7 @@ function statusColor(type?: string | null): string { } export default function TriggerDeliveriesDrawer() { - const [state, setState] = useAtom(deliveriesDrawerAtom) + const [state, setState] = useAtom(triggerDeliveriesDrawerAtom) const open = !!state const {deliveries, isLoading} = useTriggerDeliveries(state?.subscriptionId) diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerEventsDrawer.tsx b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerEventsDrawer.tsx index 2887a3c0ab..e0588f9291 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerEventsDrawer.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerEventsDrawer.tsx @@ -1,9 +1,9 @@ import React, {useCallback, useMemo, useRef, useState} from "react" import { - eventsDrawerAtom, - eventsSearchAtom, - useCatalogEvents, + triggerEventsDrawerAtom, + triggerEventsSearchAtom, + useTriggerCatalogEvents, useTriggerEvent, type TriggerCatalogEvent, } from "@agenta/entities/gatewayTrigger" @@ -20,9 +20,9 @@ import SchemaForm from "../../gatewayTool/components/SchemaForm" // --------------------------------------------------------------------------- export default function TriggerEventsDrawer() { - const [state, setState] = useAtom(eventsDrawerAtom) + const [state, setState] = useAtom(triggerEventsDrawerAtom) const [selectedEvent, setSelectedEvent] = useState<TriggerCatalogEvent | null>(null) - const setEventsSearch = useSetAtom(eventsSearchAtom) + const setEventsSearch = useSetAtom(triggerEventsSearchAtom) const open = !!state @@ -81,7 +81,7 @@ function EventsView({ integrationKey: string onSelect: (event: TriggerCatalogEvent) => void }) { - const setAtom = useSetAtom(eventsSearchAtom) + const setAtom = useSetAtom(triggerEventsSearchAtom) const search = useDebouncedAtomSearch(setAtom) const scrollRef = useRef<HTMLDivElement>(null) @@ -93,7 +93,7 @@ function EventsView({ hasNextPage, isFetchingNextPage, requestMore, - } = useCatalogEvents(integrationKey) + } = useTriggerCatalogEvents(integrationKey) const sentinelIndex = useMemo( () => Math.max(0, events.length - prefetchThreshold), diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerSubscriptionDrawer.tsx b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerSubscriptionDrawer.tsx index edb631f5d7..9ee905eb5a 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerSubscriptionDrawer.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerSubscriptionDrawer.tsx @@ -1,7 +1,9 @@ import {useCallback, useEffect, useMemo, useRef, useState} from "react" import { - subscriptionDrawerAtom, + triggerApiErrorMessage, + triggerSubscriptionDrawerAtom, + useTriggerCatalogEvents, useTriggerConnectionsQuery, useTriggerEvent, useTriggerSubscription, @@ -33,7 +35,7 @@ const DEFAULT_PROVIDER = "composio" // --------------------------------------------------------------------------- export default function TriggerSubscriptionDrawer() { - const [state, setState] = useAtom(subscriptionDrawerAtom) + const [state, setState] = useAtom(triggerSubscriptionDrawerAtom) const open = !!state const isEdit = !!state?.subscriptionId @@ -62,7 +64,7 @@ export default function TriggerSubscriptionDrawer() { // --------------------------------------------------------------------------- function SubscriptionForm({onClose}: {onClose: () => void}) { - const [state] = useAtom(subscriptionDrawerAtom) + const [state] = useAtom(triggerSubscriptionDrawerAtom) const subscriptionId = state?.subscriptionId const isEdit = !!subscriptionId @@ -195,8 +197,8 @@ function SubscriptionForm({onClose}: {onClose: () => void}) { message.success("Subscription created") } onClose() - } catch { - message.error("Failed to save subscription") + } catch (error) { + message.error(triggerApiErrorMessage(error, "Failed to save subscription")) } }, [ connectionId, @@ -250,11 +252,10 @@ function SubscriptionForm({onClose}: {onClose: () => void}) { </Form.Item> <Form.Item label="Event" required> - <Input - placeholder="Event key (e.g. github_star_added_event)" - prefix={<Lightning size={14} />} + <EventSelect + integrationKey={integrationKey} value={eventKey} - onChange={(e) => setEventKey(e.target.value)} + onChange={setEventKey} disabled={!connectionId} /> <Typography.Text type="secondary" className="text-xs"> @@ -289,7 +290,11 @@ function SubscriptionForm({onClose}: {onClose: () => void}) { Trigger configuration </Typography.Text> <div className="mt-2 mb-4"> - {eventLoading ? ( + {!eventKey ? ( + <Typography.Text type="secondary" className="text-xs"> + Select an event to configure its trigger. + </Typography.Text> + ) : eventLoading ? ( <div className="flex items-center justify-center py-6"> <Spin /> </div> @@ -303,23 +308,16 @@ function SubscriptionForm({onClose}: {onClose: () => void}) { )} </div> - <Form.Item - label="Inputs mapping" - validateStatus={inputsError ? "error" : undefined} - help={inputsError ?? "Maps event context to the workflow inputs (JSON)"} - > - <div className="rounded-lg border border-solid border-gray-300 dark:border-gray-700 overflow-hidden"> - <Editor - initialValue={inputsText || "{}"} - onChange={({textContent}) => setInputsText(textContent)} - codeOnly - showToolbar={false} - language="json" - dimensions={{width: "100%", height: 120}} - disabled={isMutating} - /> - </div> - </Form.Item> + <InputsMappingField + value={inputsText} + onChange={setInputsText} + error={inputsError} + onErrorChange={setInputsError} + eventPayload={ + (eventDetail?.payload ?? null) as Record<string, unknown> | null + } + disabled={isMutating} + /> <Form.Item label="Enabled"> <Switch checked={enabled} onChange={setEnabled} /> @@ -338,3 +336,304 @@ function SubscriptionForm({onClose}: {onClose: () => void}) { </div> ) } + +// --------------------------------------------------------------------------- +// EventSelect — searchable dropdown of the connection's catalog events. +// +// The subscription data model binds ONE event (event_key: str), so this is a +// single-select. It loads events for the chosen integration via the shared +// catalog hook, with server-side search and scroll-to-load-more. +// --------------------------------------------------------------------------- + +function EventSelect({ + integrationKey, + value, + onChange, + disabled, +}: { + integrationKey: string + value: string + onChange: (eventKey: string) => void + disabled?: boolean +}) { + const {events, isLoading, isFetchingNextPage, hasNextPage, requestMore, setSearch} = + useTriggerCatalogEvents(integrationKey) + + // Keep the selected value visible even if it isn't in the current + // (search-filtered / paginated) page — e.g. an edit prefilled event_key. + const options = useMemo(() => { + const opts = events.map((e) => ({ + value: e.key, + label: e.name ? `${e.name} (${e.key})` : e.key, + })) + if (value && !opts.some((o) => o.value === value)) { + opts.unshift({value, label: value}) + } + return opts + }, [events, value]) + + return ( + <Select + showSearch + placeholder="Select an event" + suffixIcon={<Lightning size={14} />} + value={value || undefined} + onChange={onChange} + onSearch={setSearch} + filterOption={false} + loading={isLoading} + disabled={disabled} + notFoundContent={isLoading ? <Spin size="small" /> : null} + options={options} + onPopupScroll={(e) => { + const t = e.currentTarget + if ( + hasNextPage && + !isFetchingNextPage && + t.scrollTop + t.offsetHeight >= t.scrollHeight - 32 + ) { + requestMore() + } + }} + /> + ) +} + +// --------------------------------------------------------------------------- +// InputsMappingField — JSON editor with live selector validation + path hints. +// +// The mapping is arbitrary JSON; each leaf STRING is a selector resolved at +// delivery time against the event payload (mirrors the backend +// `resolve_target_fields`): `$...` = JSONPath, `/...` = JSON Pointer, anything +// else is a literal. We validate JSON syntax + each selector live, and preview +// what each selector resolves to against the event's sample payload. +// --------------------------------------------------------------------------- + +function InputsMappingField({ + value, + onChange, + error, + onErrorChange, + eventPayload, + disabled, +}: { + value: string + onChange: (next: string) => void + error: string | null + onErrorChange: (next: string | null) => void + eventPayload: Record<string, unknown> | null + disabled?: boolean +}) { + // Selectors resolve against the normalized context the backend builds + // (dispatcher `_build_context`), not the raw provider payload. + const context = useMemo(() => buildPreviewContext(eventPayload), [eventPayload]) + + // Parse + validate live; collect a per-leaf resolution preview. + const {leaves, parseError} = useMemo(() => analyzeMapping(value, context), [value, context]) + + useEffect(() => { + onErrorChange(parseError) + }, [parseError, onErrorChange]) + + const payloadKeys = useMemo( + () => + Object.keys( + (context.event as {attributes?: Record<string, unknown>})?.attributes ?? {}, + ).map((k) => `event.attributes.${k}`), + [context], + ) + + return ( + <Form.Item + label="Inputs mapping" + validateStatus={error ? "error" : undefined} + help={error ?? "Maps event context to the workflow inputs (JSON)"} + > + <div className="rounded-lg border border-solid border-gray-300 dark:border-gray-700 overflow-hidden"> + <Editor + initialValue={value || "{}"} + onChange={({textContent}) => onChange(textContent)} + codeOnly + showToolbar={false} + language="json" + dimensions={{width: "100%", height: 120}} + disabled={disabled} + /> + </div> + + <Typography.Text type="secondary" className="!text-[11px] leading-snug block mt-1"> + String values are selectors against the event payload: <code>$.path</code>{" "} + (JSONPath), <code>/path</code> (JSON Pointer), or a literal. + </Typography.Text> + + {payloadKeys.length > 0 && ( + <div className="mt-1 flex flex-wrap items-center gap-1"> + <Typography.Text type="secondary" className="!text-[11px]"> + Available: + </Typography.Text> + {payloadKeys.slice(0, 12).map((k) => ( + <code + key={k} + className="text-[11px] px-1 rounded bg-gray-100 dark:bg-gray-800" + > + $.{k} + </code> + ))} + {payloadKeys.length > 12 && ( + <Typography.Text type="secondary" className="!text-[11px]"> + +{payloadKeys.length - 12} more + </Typography.Text> + )} + </div> + )} + + {!parseError && leaves.length > 0 && ( + <div className="mt-1.5 flex flex-col gap-0.5"> + {leaves.map((leaf, i) => ( + <div + key={`${leaf.key}-${i}`} + className="flex items-center gap-1.5 text-[11px] leading-snug" + > + <code className="text-gray-500">{leaf.key}</code> + <span className="text-gray-400">→</span> + {leaf.isSelector ? ( + leaf.resolved === undefined ? ( + <Typography.Text type="warning" className="!text-[11px]"> + no sample value + </Typography.Text> + ) : ( + <code className="text-green-600 dark:text-green-400 truncate max-w-[280px]"> + {leaf.resolved} + </code> + ) + ) : ( + <Typography.Text type="secondary" className="!text-[11px]"> + literal + </Typography.Text> + )} + </div> + ))} + </div> + )} + </Form.Item> + ) +} + +// --------------------------------------------------------------------------- +// Mapping analysis + lightweight selector resolution (preview only). +// +// Full JSONPath/Pointer evaluation happens server-side; here we resolve the +// common dot/bracket and pointer forms just to show a "resolves to" preview. +// Anything we can't resolve shows as "no sample value" (never a hard error). +// --------------------------------------------------------------------------- + +interface MappingLeaf { + key: string + isSelector: boolean + resolved?: string +} + +// Mirror of the backend dispatcher `_build_context`: the raw provider payload +// becomes `event.attributes`, alongside the synthetic event fields. Selectors in +// the mapping resolve against this shape, so previews match delivery. +function buildPreviewContext(payload: Record<string, unknown> | null): Record<string, unknown> { + return { + event: { + trigger_id: "ti_…", + trigger_type: "…", + timestamp: "…", + created_at: "…", + attributes: payload ?? {}, + }, + } +} + +function analyzeMapping( + text: string, + context: Record<string, unknown> | null, +): {leaves: MappingLeaf[]; parseError: string | null} { + const trimmed = text.trim() + if (!trimmed) return {leaves: [], parseError: null} + + let parsed: unknown + try { + parsed = JSON.parse(trimmed) + } catch (e) { + return {leaves: [], parseError: e instanceof Error ? e.message : "Invalid JSON"} + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return {leaves: [], parseError: "Mapping must be a JSON object"} + } + + const leaves: MappingLeaf[] = [] + for (const [key, raw] of Object.entries(parsed as Record<string, unknown>)) { + if (typeof raw !== "string") { + leaves.push({key, isSelector: false}) + continue + } + const isSelector = raw.startsWith("$") || raw.startsWith("/") + if (!isSelector) { + leaves.push({key, isSelector: false}) + continue + } + const resolved = context ? resolveSelectorPreview(raw, context) : undefined + leaves.push({ + key, + isSelector: true, + resolved: resolved === undefined ? undefined : previewValue(resolved), + }) + } + return {leaves, parseError: null} +} + +function previewValue(value: unknown): string { + if (typeof value === "string") return value + try { + return JSON.stringify(value) + } catch { + return String(value) + } +} + +/** Best-effort resolution of `$.a.b[0]` / `$["a"]["b"]` / `/a/b/0`. */ +function resolveSelectorPreview(selector: string, data: Record<string, unknown>): unknown { + try { + if (selector === "$") return data + if (selector.startsWith("/")) { + const tokens = selector + .split("/") + .slice(1) + .map((t) => t.replace(/~1/g, "/").replace(/~0/g, "~")) + return walk(data, tokens) + } + if (selector.startsWith("$")) { + const tokens = selector + .slice(1) + .replace(/\[(\d+)\]/g, ".$1") + .replace(/\[["'](.*?)["']\]/g, ".$1") + .split(".") + .filter((t) => t.length > 0) + return walk(data, tokens) + } + } catch { + return undefined + } + return undefined +} + +function walk(data: unknown, tokens: string[]): unknown { + let cur: unknown = data + for (const token of tokens) { + if (cur == null) return undefined + if (Array.isArray(cur)) { + const idx = Number(token) + if (!Number.isInteger(idx)) return undefined + cur = cur[idx] + } else if (typeof cur === "object") { + cur = (cur as Record<string, unknown>)[token] + } else { + return undefined + } + } + return cur +} diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/index.ts b/web/packages/agenta-entity-ui/src/gatewayTrigger/index.ts index 4ea0d0551d..272cdbb8b5 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTrigger/index.ts +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/index.ts @@ -7,6 +7,8 @@ * `gatewayTool`. */ +export {default as TriggerCatalogDrawer} from "./drawers/TriggerCatalogDrawer" +export {default as TriggerConnectDrawer} from "./drawers/TriggerConnectDrawer" export {default as TriggerEventsDrawer} from "./drawers/TriggerEventsDrawer" export {default as TriggerSubscriptionDrawer} from "./drawers/TriggerSubscriptionDrawer" export {default as TriggerDeliveriesDrawer} from "./drawers/TriggerDeliveriesDrawer" diff --git a/web/tests/tests/fixtures/base.fixture/providerHelpers/index.ts b/web/tests/tests/fixtures/base.fixture/providerHelpers/index.ts index e42b1f4d52..9b322a6a33 100644 --- a/web/tests/tests/fixtures/base.fixture/providerHelpers/index.ts +++ b/web/tests/tests/fixtures/base.fixture/providerHelpers/index.ts @@ -137,7 +137,7 @@ async function waitForModelsPageReady(page: Page): Promise<void> { const pathname = new URL(page.url()).pathname const hasScopedSettingsPath = /\/w\/[^/]+\/p\/[^/]+\/settings$/.test(pathname) const headingVisible = await page - .getByRole("heading", {name: "Providers & Models"}) + .getByRole("heading", {name: "Models"}) .isVisible() .catch(() => false) const sectionVisible = await customProvidersSection.isVisible().catch(() => false) @@ -181,7 +181,7 @@ async function navigateToModels(page: Page, uiHelpers: UIHelpers): Promise<void> await page.goto(`${projectBasePath}/settings?tab=secrets`, {waitUntil: "domcontentloaded"}) await uiHelpers.expectPath("/settings") - await expect(page.getByRole("heading", {name: "Providers & Models"})).toBeVisible({ + await expect(page.getByRole("heading", {name: "Models"})).toBeVisible({ timeout: 15000, }) await expect(getCustomProvidersSection(page)).toBeVisible({timeout: 15000}) From c5c520652a654513656c56d392796f187f8224c4 Mon Sep 17 00:00:00 2001 From: Juan Pablo Vega <jp@agenta.ai> Date: Fri, 19 Jun 2026 20:12:18 +0200 Subject: [PATCH 5/5] fix(triggers): normalize bound workflow ref into the full family Resolve the bound reference via the canonical WorkflowsService.retrieve_workflow_revision (handles application/evaluator/ workflow + environment families) and rebuild the completed family with build_retrieval_info, so invoke_workflow finds the service uri. Raise TriggerReferenceInvalid when it cannot resolve. Skip soft-deleted subscriptions in the ti_* resolver. FE: scope the picker to application workflows and send the reference family by its true kind. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- api/oss/src/core/triggers/exceptions.py | 10 +++ api/oss/src/core/triggers/service.py | 71 +++++++++++++------ api/oss/src/dbs/postgres/triggers/dao.py | 2 + .../drawers/TriggerSubscriptionDrawer.tsx | 32 +++++++-- 4 files changed, 88 insertions(+), 27 deletions(-) diff --git a/api/oss/src/core/triggers/exceptions.py b/api/oss/src/core/triggers/exceptions.py index 092144ceff..6187840624 100644 --- a/api/oss/src/core/triggers/exceptions.py +++ b/api/oss/src/core/triggers/exceptions.py @@ -25,6 +25,16 @@ def __init__(self, *, subscription_id: str): super().__init__(f"Trigger subscription not found: {subscription_id}") +class TriggerReferenceInvalid(TriggersError): + """Raised when a bound workflow reference cannot be resolved to a revision.""" + + def __init__( + self, + message: str = "Bound workflow reference could not be resolved.", + ): + super().__init__(message) + + class ConnectionNotFoundError(TriggersError): """Raised when a subscription references a connection that does not exist.""" diff --git a/api/oss/src/core/triggers/service.py b/api/oss/src/core/triggers/service.py index 76f6769288..5f80b90e21 100644 --- a/api/oss/src/core/triggers/service.py +++ b/api/oss/src/core/triggers/service.py @@ -25,10 +25,12 @@ from oss.src.core.triggers.exceptions import ( ConnectionNotFoundError, SubscriptionNotFoundError, + TriggerReferenceInvalid, ) from oss.src.core.triggers.interfaces import TriggersDAOInterface from oss.src.core.triggers.registry import TriggersGatewayRegistry from oss.src.core.triggers.utils import WebhookSecretResolver +from oss.src.core.git.utils import build_retrieval_info from oss.src.core.shared.dtos import Reference, Windowing from oss.src.core.workflows.service import WorkflowsService @@ -273,40 +275,63 @@ async def _normalize_references( project_id: UUID, references: Optional[dict], ) -> None: - """Resolve the bound workflow ref to a runnable revision, in place. - - The UI may send a variant id (or a bare/partial ref) under - ``workflow_revision``; resolve it to the actual workflow revision (by - revision id, else by variant id → latest) and rewrite id/slug/version so - the dispatcher's ``invoke_workflow`` finds the service uri (mirrors the - reference completion done on /deploy). + """Complete the bound reference family in place, via the canonical retrieve. + + The FE sends a partial family under the proper prefix (``application`` / + ``evaluator``, or ``environment`` + ``application``). Delegate to + ``WorkflowsService.retrieve_workflow_revision`` (which resolves every + family, environment-backed included) and rebuild the completed family from + the resolved revision with ``build_retrieval_info`` — so the dispatcher's + ``invoke_workflow`` finds the service uri. """ if not references or not self.workflows_service: return - ref = references.get("workflow_revision") - ref_id = getattr(ref, "id", None) if ref else None - if not ref_id: + def _ref(value): + if value is None: + return None + return value if isinstance(value, Reference) else Reference(**dict(value)) + + prefix = next( + ( + p + for p in ("application", "evaluator", "workflow") + if any(references.get(k) for k in (p, f"{p}_variant", f"{p}_revision")) + ), + None, + ) + environment_ref = _ref(references.get("environment")) + if prefix is None and environment_ref is None: return - revision = await self.workflows_service.fetch_workflow_revision( + key = None + if environment_ref is not None: + artifact = _ref(references.get("application") or references.get("workflow")) + artifact_slug = getattr(artifact, "slug", None) + key = f"{artifact_slug}.revision" if artifact_slug else None + + revision, _, _ = await self.workflows_service.retrieve_workflow_revision( project_id=project_id, - workflow_revision_ref=Reference(id=ref_id), + environment_ref=environment_ref, + key=key, + workflow_ref=_ref(references.get(prefix)) if prefix else None, + workflow_variant_ref=( + _ref(references.get(f"{prefix}_variant")) if prefix else None + ), + workflow_revision_ref=( + _ref(references.get(f"{prefix}_revision")) if prefix else None + ), ) if revision is None: - # Not a revision id — try it as a variant id (latest revision). - revision = await self.workflows_service.fetch_workflow_revision( - project_id=project_id, - workflow_variant_ref=Reference(id=ref_id), + raise TriggerReferenceInvalid( + "Bound workflow reference could not be resolved to a runnable revision." ) - if revision is None: - return - references["workflow_revision"] = Reference( - id=revision.id, - slug=revision.slug, - version=revision.version, - ) + entity_type = "application" if environment_ref is not None else prefix + info = build_retrieval_info(revision=revision, entity_type=entity_type) + + references.clear() + references.update(info.references if info else {}) async def create_subscription( self, diff --git a/api/oss/src/dbs/postgres/triggers/dao.py b/api/oss/src/dbs/postgres/triggers/dao.py index c53bf2b9eb..bcf119d365 100644 --- a/api/oss/src/dbs/postgres/triggers/dao.py +++ b/api/oss/src/dbs/postgres/triggers/dao.py @@ -218,6 +218,7 @@ async def get_subscription_by_trigger_id( select(TriggerSubscriptionDBE) .filter( TriggerSubscriptionDBE.data["ti_id"].astext == trigger_id, + TriggerSubscriptionDBE.deleted_at.is_(None), ) .limit(1) ) @@ -243,6 +244,7 @@ async def get_project_and_subscription_by_trigger_id( select(TriggerSubscriptionDBE) .filter( TriggerSubscriptionDBE.data["ti_id"].astext == trigger_id, + TriggerSubscriptionDBE.deleted_at.is_(None), ) .limit(1) ) diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerSubscriptionDrawer.tsx b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerSubscriptionDrawer.tsx index 9ee905eb5a..631ddeda38 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerSubscriptionDrawer.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerSubscriptionDrawer.tsx @@ -12,6 +12,7 @@ import { type TriggerSubscriptionData, type TriggerSubscriptionEdit, } from "@agenta/entities/gatewayTrigger" +import {appWorkflowsListQueryStateAtom} from "@agenta/entities/workflow" import {Editor} from "@agenta/ui/editor" import {Lightning} from "@phosphor-icons/react" import {Button, Divider, Drawer, Form, Input, Select, Spin, Switch, Typography, message} from "antd" @@ -19,13 +20,19 @@ import {useAtom} from "jotai" import SchemaForm, {type SchemaFormHandle} from "../../gatewayTool/components/SchemaForm" import { + createWorkflowRevisionAdapter, EntityPicker, - workflowRevisionAdapter, type WorkflowRevisionSelectionResult, } from "../../selection" const DEFAULT_PROVIDER = "composio" +// The bound reference is always `application_*` (see handleSubmit), so the picker +// only offers application workflows (is_application=True). +const applicationRevisionAdapter = createWorkflowRevisionAdapter({ + workflowListAtom: appWorkflowsListQueryStateAtom, +}) + // --------------------------------------------------------------------------- // TriggerSubscriptionDrawer (root) — create or edit a subscription. // @@ -82,6 +89,8 @@ function SubscriptionForm({onClose}: {onClose: () => void}) { const [eventKey, setEventKey] = useState("") const [enabled, setEnabled] = useState(true) const [workflowRevId, setWorkflowRevId] = useState<string | null>(null) + const [workflowSelection, setWorkflowSelection] = + useState<WorkflowRevisionSelectionResult | null>(null) const [workflowLabel, setWorkflowLabel] = useState<string | null>(null) const [inputsText, setInputsText] = useState("{}") const [inputsError, setInputsError] = useState<string | null>(null) @@ -96,7 +105,10 @@ function SubscriptionForm({onClose}: {onClose: () => void}) { setConnectionId(subscription.connection_id) setEventKey(subscription.data?.event_key ?? "") setEnabled(subscription.enabled ?? true) - const wfId = subscription.data?.references?.workflow_revision?.id ?? null + const wfId = + subscription.data?.references?.application_revision?.id ?? + subscription.data?.references?.workflow_revision?.id ?? + null setWorkflowRevId(wfId) setWorkflowLabel(wfId) setInputsText(JSON.stringify(subscription.data?.inputs_fields ?? {}, null, 2)) @@ -155,11 +167,22 @@ function SubscriptionForm({onClose}: {onClose: () => void}) { return } + // On a fresh pick, send the application family by the picker's ids (its + // leaf is the variant id). Without a re-pick (edit), resend the stored + // already-complete references. The BE completes the family either way. + const meta = workflowSelection?.metadata + const references = meta + ? { + ...(meta.workflowId ? {application: {id: meta.workflowId}} : {}), + application_variant: {id: workflowRevId}, + } + : (subscription?.data?.references ?? {application_variant: {id: workflowRevId}}) + const data: TriggerSubscriptionData = { event_key: eventKey, trigger_config: triggerConfig, inputs_fields: inputsFields, - references: {workflow_revision: {id: workflowRevId}}, + references, } try { @@ -268,9 +291,10 @@ function SubscriptionForm({onClose}: {onClose: () => void}) { <div className="flex items-center gap-2"> <EntityPicker<WorkflowRevisionSelectionResult> variant="popover-cascader" - adapter={workflowRevisionAdapter} + adapter={applicationRevisionAdapter} onSelect={(selection) => { setWorkflowRevId(selection.id) + setWorkflowSelection(selection) setWorkflowLabel(selection.label) }} size="small"