diff --git a/AGENTS.md b/AGENTS.md index 14a73c0f3e..f145e37178 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) @@ -73,6 +177,30 @@ If so, use the `but` CLI instead of raw `git branch`/`git commit`: - Ant Design token changes: run `pnpm generate:tailwind-tokens` in the `web` folder and commit the generated file. +## Local dev loop (deploy + test) + +From the repo root. **`load-env` must match the edition and image you deploy** — the env +file and the `run.sh` flags always agree: + +- OSS + dev → `load-env hosting/docker-compose/oss/.env.oss.dev` + `run.sh --oss --dev` +- OSS + gh → `load-env hosting/docker-compose/oss/.env.oss.gh` + `run.sh --oss --gh` +- EE + dev → `load-env hosting/docker-compose/ee/.env.ee.dev` + `run.sh --ee --dev` +- EE + gh → `load-env hosting/docker-compose/ee/.env.ee.gh` + `run.sh --ee --gh` + +- `load-env ` — load env vars into the shell (pick the row above). +- `bash ./hosting/docker-compose/run.sh --build` — deploy to the local + docker-compose stack (`--oss`/`--ee`, `--dev`/`--gh`; `--down` to stop, `--nuke` to drop + volumes). Use the SAME edition/image as load-env. +- `cd && py-run-tests` — run that area's tests, where `area` is one of + `sdks/python`, `api`, or `services` + (`py-run-tests` = `uv sync --locked && uv run --no-sync python run-tests.py`). +- Postgres is reachable locally with `username:password`; EE DB name is `agenta_ee_core`. +- Tests mint ephemeral accounts + API keys via the admin endpoint + `POST /admin/simple/accounts/` (with `Authorization: Access AUTH_KEY`, + `create_api_keys/return_api_keys: true`). Reuse the fixtures in + `api/oss/tests/pytest/utils/accounts.py` (`foo_account`/`cls_account`/`mod_account` → + `{api_url, credentials: "ApiKey ..."}`); do not hand-roll account creation. + ## Environment config - For API configuration, add new environment variables to `api/oss/src/utils/env.py` and diff --git a/api/ee/docker/Dockerfile.dev b/api/ee/docker/Dockerfile.dev index 47c1c63169..2e6d1d7f14 100644 --- a/api/ee/docker/Dockerfile.dev +++ b/api/ee/docker/Dockerfile.dev @@ -61,6 +61,8 @@ ENV PYTHONPATH=/sdks/python:/clients/python COPY ./api/oss/src/crons/queries.sh /queries.sh COPY ./api/oss/src/crons/queries.txt /etc/cron.d/queries-cron +COPY ./api/oss/src/crons/triggers.sh /triggers.sh +COPY ./api/oss/src/crons/triggers.txt /etc/cron.d/triggers-cron COPY ./api/ee/src/crons/meters.sh /meters.sh COPY ./api/ee/src/crons/meters.txt /etc/cron.d/meters-cron COPY ./api/ee/src/crons/spans.sh /spans.sh @@ -68,10 +70,10 @@ COPY ./api/ee/src/crons/spans.txt /etc/cron.d/spans-cron COPY ./api/ee/src/crons/events.sh /events.sh COPY ./api/ee/src/crons/events.txt /etc/cron.d/events-cron -RUN chmod +x /queries.sh /meters.sh /spans.sh /events.sh \ - && chmod 0644 /etc/cron.d/queries-cron /etc/cron.d/meters-cron /etc/cron.d/spans-cron /etc/cron.d/events-cron \ - && for f in /etc/cron.d/queries-cron /etc/cron.d/meters-cron /etc/cron.d/spans-cron /etc/cron.d/events-cron; do sed -i -e '$a\' "$f"; done \ - && cat /etc/cron.d/queries-cron /etc/cron.d/meters-cron /etc/cron.d/spans-cron /etc/cron.d/events-cron \ +RUN chmod +x /queries.sh /triggers.sh /meters.sh /spans.sh /events.sh \ + && chmod 0644 /etc/cron.d/queries-cron /etc/cron.d/triggers-cron /etc/cron.d/meters-cron /etc/cron.d/spans-cron /etc/cron.d/events-cron \ + && for f in /etc/cron.d/queries-cron /etc/cron.d/triggers-cron /etc/cron.d/meters-cron /etc/cron.d/spans-cron /etc/cron.d/events-cron; do sed -i -e '$a\' "$f"; done \ + && cat /etc/cron.d/queries-cron /etc/cron.d/triggers-cron /etc/cron.d/meters-cron /etc/cron.d/spans-cron /etc/cron.d/events-cron \ | sed -E 's/^(([^[:space:]]+[[:space:]]+){5})root[[:space:]]+/\1/' \ | sed 's| >> /proc/1/fd/1 2>&1||' > /app/crontab \ && chown agenta:agenta /app/crontab diff --git a/api/ee/docker/Dockerfile.gh b/api/ee/docker/Dockerfile.gh index 3fa795af20..2b355d23e9 100644 --- a/api/ee/docker/Dockerfile.gh +++ b/api/ee/docker/Dockerfile.gh @@ -95,6 +95,8 @@ RUN groupadd --gid 10001 agenta && \ # Copy stable cron files first (change less frequently) COPY --chmod=755 ./api/oss/src/crons/queries.sh /queries.sh COPY --chmod=644 ./api/oss/src/crons/queries.txt /etc/cron.d/queries-cron +COPY --chmod=755 ./api/oss/src/crons/triggers.sh /triggers.sh +COPY --chmod=644 ./api/oss/src/crons/triggers.txt /etc/cron.d/triggers-cron COPY --chmod=755 ./api/ee/src/crons/meters.sh /meters.sh COPY --chmod=644 ./api/ee/src/crons/meters.txt /etc/cron.d/meters-cron COPY --chmod=755 ./api/ee/src/crons/spans.sh /spans.sh @@ -114,10 +116,10 @@ COPY --chown=agenta:agenta --from=builder /clients/python /clients/python # Generate supercronic-compatible crontab (strip user field and /proc redirects) RUN set -eux; \ - for cron_file in /etc/cron.d/queries-cron /etc/cron.d/meters-cron /etc/cron.d/spans-cron /etc/cron.d/events-cron; do \ + for cron_file in /etc/cron.d/queries-cron /etc/cron.d/triggers-cron /etc/cron.d/meters-cron /etc/cron.d/spans-cron /etc/cron.d/events-cron; do \ sed -i -e '$a\' "${cron_file}"; \ done; \ - cat /etc/cron.d/queries-cron /etc/cron.d/meters-cron /etc/cron.d/spans-cron /etc/cron.d/events-cron \ + cat /etc/cron.d/queries-cron /etc/cron.d/triggers-cron /etc/cron.d/meters-cron /etc/cron.d/spans-cron /etc/cron.d/events-cron \ | sed -E 's/^(([^[:space:]]+[[:space:]]+){5})root[[:space:]]+/\1/' \ | sed 's| >> /proc/1/fd/1 2>&1||' > /app/crontab && \ chown agenta:agenta /app/crontab 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..16d2b312c5 --- /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. + +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_connections.py b/api/ee/tests/pytest/acceptance/triggers/test_triggers_connections.py new file mode 100644 index 0000000000..fa7408be5c --- /dev/null +++ b/api/ee/tests/pytest/acceptance/triggers/test_triggers_connections.py @@ -0,0 +1,230 @@ +"""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"] + + try: + 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 + finally: + 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"] + + try: + 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 + finally: + 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"] + + try: + 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 + finally: + 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 new file mode 100644 index 0000000000..7a402ee866 --- /dev/null +++ b/api/ee/tests/pytest/acceptance/triggers/test_triggers_subscriptions.py @@ -0,0 +1,237 @@ +"""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)", +) + +# 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] + 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 +@_requires_connected_account +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) + + try: + 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": {"owner": "acme", "repo": "widgets"}, + "inputs_fields": {"repo": "$.event.attributes.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["trigger_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 + finally: + triggers_api("DELETE", f"/tools/connections/{connection_id}") 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 d90b38c5f1..0a00aa67e1 100644 --- a/api/entrypoints/routers.py +++ b/api/entrypoints/routers.py @@ -134,11 +134,27 @@ 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.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 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 +220,30 @@ async def lifespan(*args, **kwargs): warn_deprecated_env_vars() validate_required_env_vars() + 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() + 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 +337,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 +479,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 +614,39 @@ 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, +) + +# 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: @@ -589,10 +662,56 @@ async def lifespan(*args, **kwargs): ) tools_service = ToolsService( - tools_dao=tools_dao, + connections_service=connections_service, + catalog_service=catalog_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, + 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 +# `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, + triggers_dao=triggers_dao, +) + +triggers_service.schedule_dispatch_task = _triggers_worker.dispatch_schedule + _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 +826,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 +1198,26 @@ 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=triggers.admin_router, + prefix="/admin/triggers", + tags=["Triggers", "Admin"], + 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..96ccd5e727 --- /dev/null +++ b/api/entrypoints/worker_triggers.py @@ -0,0 +1,143 @@ +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, + triggers_dao=triggers_dao, +) + + +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..4c119a6c9c --- /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). 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..4fb4ca8b0c --- /dev/null +++ b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000003_add_trigger_subscriptions_and_deliveries.py @@ -0,0 +1,300 @@ +"""add trigger_subscriptions, trigger_schedules and trigger_deliveries tables + +The heart of the gateway-triggers domain, modeled on +webhook_subscriptions + webhook_deliveries. A subscription FKs the shared +gateway_connections row (many subscriptions per connection); a schedule is the +cron-driven analogue with no connection; a delivery dedups on the event id +(metadata.id for subscriptions, the tick id for schedules) per parent (I4) and +belongs to exactly one parent (XOR). 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("trigger_id", sa.String(), nullable=True), + 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, + ) + op.create_index( + "ix_trigger_subscriptions_trigger_id", + "trigger_subscriptions", + ["project_id", "trigger_id"], + unique=True, + postgresql_where=sa.text("trigger_id IS NOT NULL AND deleted_at IS NULL"), + ) + + # -- TRIGGER SCHEDULES ------------------------------------------------------ + op.create_table( + "trigger_schedules", + sa.Column("project_id", sa.UUID(), nullable=False), + sa.Column("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.PrimaryKeyConstraint("project_id", "id"), + ) + + op.create_index( + "ix_trigger_schedules_project_id_created_at", + "trigger_schedules", + ["project_id", "created_at"], + unique=False, + ) + op.create_index( + "ix_trigger_schedules_project_id_deleted_at", + "trigger_schedules", + ["project_id", "deleted_at"], + unique=False, + ) + op.create_index( + "ix_trigger_schedules_active", + "trigger_schedules", + ["project_id"], + unique=False, + postgresql_where=sa.text( + "(flags ->> 'is_active') = 'true' AND deleted_at IS NULL" + ), + ) + + # -- 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=True), + sa.Column("schedule_id", sa.UUID(), nullable=True), + 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.ForeignKeyConstraint( + ["project_id", "schedule_id"], + ["trigger_schedules.project_id", "trigger_schedules.id"], + ondelete="CASCADE", + ), + sa.CheckConstraint( + "(subscription_id IS NULL) <> (schedule_id IS NULL)", + name="ck_trigger_deliveries_exactly_one_parent", + ), + 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_schedule_id_created_at", + "trigger_deliveries", + ["schedule_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, + postgresql_where=sa.text("subscription_id IS NOT NULL"), + ) + op.create_index( + "ix_trigger_deliveries_schedule_id_event_id", + "trigger_deliveries", + ["project_id", "schedule_id", "event_id"], + unique=True, + postgresql_where=sa.text("schedule_id IS NOT NULL"), + ) + + +def downgrade() -> None: + op.drop_index( + "ix_trigger_deliveries_schedule_id_event_id", + table_name="trigger_deliveries", + ) + op.drop_index( + "ix_trigger_deliveries_subscription_id_event_id", + table_name="trigger_deliveries", + ) + op.drop_index( + "ix_trigger_deliveries_schedule_id_created_at", + 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_schedules_active", + table_name="trigger_schedules", + ) + op.drop_index( + "ix_trigger_schedules_project_id_deleted_at", + table_name="trigger_schedules", + ) + op.drop_index( + "ix_trigger_schedules_project_id_created_at", + table_name="trigger_schedules", + ) + op.drop_table("trigger_schedules") + + op.drop_index( + "ix_trigger_subscriptions_trigger_id", + table_name="trigger_subscriptions", + ) + 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/databases/postgres/migrations/core_oss/versions/oss000000004_add_webhook_subscription_flags.py b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000004_add_webhook_subscription_flags.py new file mode 100644 index 0000000000..40ab58eb57 --- /dev/null +++ b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000004_add_webhook_subscription_flags.py @@ -0,0 +1,57 @@ +"""add webhook subscription flags + +Backfill flags.is_active=true on existing webhook_subscriptions and add a +partial active index. The flags JSONB column already exists on the released +core chain, so this is data-only — no column is added. + +Revision ID: oss000000004 +Revises: oss000000003 +Create Date: 2026-06-21 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "oss000000004" +down_revision: Union[str, None] = "oss000000003" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Only backfill rows that have no is_active yet — never overwrite an + # already-set (e.g. paused) value. + op.execute( + "UPDATE webhook_subscriptions " + "SET flags = jsonb_set(COALESCE(flags, '{}'::jsonb), '{is_active}', 'true'::jsonb, true) " + "WHERE flags IS NULL OR flags ->> 'is_active' IS NULL" + ) + op.create_index( + "ix_webhook_subscriptions_active", + "webhook_subscriptions", + ["project_id"], + unique=False, + postgresql_where=sa.text( + "(flags ->> 'is_active') = 'true' AND deleted_at IS NULL" + ), + ) + + +def downgrade() -> None: + op.drop_index( + "ix_webhook_subscriptions_active", + table_name="webhook_subscriptions", + ) + # Mirror the upgrade: only strip the is_active=true the backfill added to + # rows that had no flags. Rows carrying other flags (or is_active=false) + # predate this migration's intent and keep their state. + op.execute( + "UPDATE webhook_subscriptions " + "SET flags = flags - 'is_active' " + "WHERE flags = '{\"is_active\": true}'::jsonb" + ) diff --git a/api/oss/docker/Dockerfile.dev b/api/oss/docker/Dockerfile.dev index 23b7e5016c..409f4c3084 100644 --- a/api/oss/docker/Dockerfile.dev +++ b/api/oss/docker/Dockerfile.dev @@ -61,11 +61,14 @@ ENV PYTHONPATH=/sdks/python:/clients/python COPY ./api/oss/src/crons/queries.sh /queries.sh COPY ./api/oss/src/crons/queries.txt /etc/cron.d/queries-cron +COPY ./api/oss/src/crons/triggers.sh /triggers.sh +COPY ./api/oss/src/crons/triggers.txt /etc/cron.d/triggers-cron -RUN chmod +x /queries.sh \ - && chmod 0644 /etc/cron.d/queries-cron \ +RUN chmod +x /queries.sh /triggers.sh \ + && chmod 0644 /etc/cron.d/queries-cron /etc/cron.d/triggers-cron \ && sed -i -e '$a\' /etc/cron.d/queries-cron \ - && sed -E 's/^(([^[:space:]]+[[:space:]]+){5})root[[:space:]]+/\1/' /etc/cron.d/queries-cron \ + && sed -i -e '$a\' /etc/cron.d/triggers-cron \ + && sed -E 's/^(([^[:space:]]+[[:space:]]+){5})root[[:space:]]+/\1/' /etc/cron.d/queries-cron /etc/cron.d/triggers-cron \ | sed 's| >> /proc/1/fd/1 2>&1||' > /app/crontab \ && chown agenta:agenta /app/crontab diff --git a/api/oss/docker/Dockerfile.gh b/api/oss/docker/Dockerfile.gh index 593be10cb4..1182606efc 100644 --- a/api/oss/docker/Dockerfile.gh +++ b/api/oss/docker/Dockerfile.gh @@ -95,6 +95,8 @@ RUN groupadd --gid 10001 agenta && \ # Copy stable cron files first (change less frequently) COPY --chmod=755 ./api/oss/src/crons/queries.sh /queries.sh COPY --chmod=644 ./api/oss/src/crons/queries.txt /etc/cron.d/queries-cron +COPY --chmod=755 ./api/oss/src/crons/triggers.sh /triggers.sh +COPY --chmod=644 ./api/oss/src/crons/triggers.txt /etc/cron.d/triggers-cron # Copy dependencies from builder COPY --from=builder /opt/venv /opt/venv @@ -107,10 +109,10 @@ COPY --chown=agenta:agenta --from=builder /clients/python /clients/python # Generate supercronic-compatible crontab (strip user field and /proc redirects) RUN set -eux; \ - for cron_file in /etc/cron.d/queries-cron; do \ + for cron_file in /etc/cron.d/queries-cron /etc/cron.d/triggers-cron; do \ sed -i -e '$a\' "${cron_file}"; \ done; \ - sed -E 's/^(([^[:space:]]+[[:space:]]+){5})root[[:space:]]+/\1/' /etc/cron.d/queries-cron \ + sed -E 's/^(([^[:space:]]+[[:space:]]+){5})root[[:space:]]+/\1/' /etc/cron.d/queries-cron /etc/cron.d/triggers-cron \ | sed 's| >> /proc/1/fd/1 2>&1||' > /app/crontab && \ chown agenta:agenta /app/crontab diff --git a/api/oss/src/apis/fastapi/tools/router.py b/api/oss/src/apis/fastapi/tools/router.py index 043d114fa7..0d27e581a0 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,25 +67,41 @@ def handle_adapter_exceptions(): - """Convert only upstream 401 AdapterError failures to 424 Failed Dependency.""" + """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) 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: + 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 @@ -362,19 +379,19 @@ async def list_integrations( if cached: return cached - integrations, next_cursor, total = await self.tools_service.list_integrations( + page = await self.tools_service.list_integrations( provider_key=provider_key, search=search, sort_by=sort_by, limit=limit, cursor=cursor, ) - items = list(integrations) + items = list(page.integrations) response = ToolCatalogIntegrationsResponse( count=len(items), - total=total, - cursor=next_cursor, + total=page.total, + cursor=page.next_cursor, integrations=items, ) @@ -487,7 +504,7 @@ async def list_actions( if cached: return cached - actions, next_cursor, total = await self.tools_service.list_actions( + page = await self.tools_service.list_actions( provider_key=provider_key, integration_key=integration_key, query=query, @@ -497,7 +514,7 @@ async def list_actions( ) items = [] - for action in actions: + for action in page.actions: if full_details: # Call route handler to benefit from cache reuse action_response = await self.get_action( @@ -518,8 +535,8 @@ async def list_actions( response = ToolCatalogActionsResponse( count=len(items), - total=total, - cursor=next_cursor, + total=page.total, + cursor=page.next_cursor, actions=items, ) @@ -811,7 +828,9 @@ async def callback_connection( ), ) - # Decode HMAC-signed state to recover project scope. + # Decode HMAC-signed state to recover project scope. Activation is + # project-scoped, so a missing/invalid state is fatal — we never activate + # without a resolved project_id. project_id: Optional[UUID] = None if state: payload = decode_oauth_state(state, secret_key=env.agenta.crypt_key) @@ -825,6 +844,15 @@ async def callback_connection( else: log.warning("OAuth callback received without state token") + if project_id is None: + return HTMLResponse( + status_code=400, + content=_oauth_card( + success=False, + error="Connection could not be activated. Please try again.", + ), + ) + # Activate the connection — this is the critical path. conn = None try: 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..7421ef23e8 --- /dev/null +++ b/api/oss/src/apis/fastapi/triggers/models.py @@ -0,0 +1,188 @@ +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, + TriggerCatalogIntegration, + TriggerCatalogProvider, + TriggerConnection, + TriggerConnectionCreate, + TriggerDelivery, + TriggerDeliveryQuery, + TriggerSchedule, + TriggerScheduleCreate, + TriggerScheduleEdit, + TriggerScheduleQuery, + TriggerSubscription, + TriggerSubscriptionCreate, + TriggerSubscriptionEdit, + TriggerSubscriptionQuery, +) + + +# --------------------------------------------------------------------------- +# Trigger Catalog — providers + integrations are SHARED (gateway/catalog); +# events are the trigger-specific leaf. +# --------------------------------------------------------------------------- + + +class TriggerCatalogProviderResponse(BaseModel): + count: int = 0 + provider: Optional[TriggerCatalogProvider] = None + + +class TriggerCatalogProvidersResponse(BaseModel): + count: int = 0 + 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 + + +class TriggerCatalogEventsResponse(BaseModel): + count: int = 0 + total: int = 0 + cursor: Optional[str] = None + 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 +# --------------------------------------------------------------------------- + + +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 Schedules +# --------------------------------------------------------------------------- + + +class TriggerScheduleCreateRequest(BaseModel): + schedule: TriggerScheduleCreate + + +class TriggerScheduleEditRequest(BaseModel): + schedule: TriggerScheduleEdit + + +class TriggerScheduleQueryRequest(BaseModel): + schedule: Optional[TriggerScheduleQuery] = None + + windowing: Optional[Windowing] = None + + +class TriggerScheduleResponse(BaseModel): + count: int = 0 + schedule: Optional[TriggerSchedule] = None + + +class TriggerSchedulesResponse(BaseModel): + count: int = 0 + schedules: List[TriggerSchedule] = 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..880aded8bd --- /dev/null +++ b/api/oss/src/apis/fastapi/triggers/router.py @@ -0,0 +1,1569 @@ +import asyncio +from datetime import datetime, timedelta +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.apis.fastapi.triggers.models import ( + TriggerCatalogEventResponse, + TriggerCatalogEventsResponse, + TriggerCatalogIntegrationResponse, + TriggerCatalogIntegrationsResponse, + TriggerCatalogProviderResponse, + TriggerCatalogProvidersResponse, + TriggerConnectionCreateRequest, + TriggerConnectionResponse, + TriggerConnectionsResponse, + TriggerDeliveriesResponse, + TriggerDeliveryQueryRequest, + TriggerDeliveryResponse, + TriggerEventAck, + TriggerScheduleCreateRequest, + TriggerScheduleEditRequest, + TriggerScheduleQueryRequest, + TriggerScheduleResponse, + TriggerSchedulesResponse, + TriggerSubscriptionCreateRequest, + TriggerSubscriptionEditRequest, + TriggerSubscriptionQueryRequest, + TriggerSubscriptionResponse, + TriggerSubscriptionsResponse, +) +from oss.src.core.triggers.exceptions import ( + AdapterError, + ConnectionNotFoundError, + ProviderNotFoundError, + ScheduleNotFoundError, + SubscriptionNotFoundError, + TriggerReferenceInvalid, + TriggerScheduleInvalid, +) +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__) + +_ENQUEUE_TIMEOUT_SECONDS = 5.0 + + +def handle_adapter_exceptions(): + """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) + 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: + detail = e.detail or e.message + cause = e.__cause__ + upstream_status = ( + cause.response.status_code + if isinstance(cause, httpx.HTTPStatusError) + and cause.response is not None + 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=detail, + ) from e + + return wrapper + + return decorator + + +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/", + 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, + 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 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/", + 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}/start", + self.start_subscription, + methods=["POST"], + operation_id="start_trigger_subscription", + response_model=TriggerSubscriptionResponse, + response_model_exclude_none=True, + status_code=status.HTTP_200_OK, + ) + self.router.add_api_route( + "/subscriptions/{subscription_id}/stop", + self.stop_subscription, + methods=["POST"], + operation_id="stop_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 Schedules --- + self.router.add_api_route( + "/schedules", + self.create_schedule, + methods=["POST"], + operation_id="create_trigger_schedule", + response_model=TriggerScheduleResponse, + response_model_exclude_none=True, + status_code=status.HTTP_200_OK, + ) + self.router.add_api_route( + "/schedules", + self.list_schedules, + methods=["GET"], + operation_id="list_trigger_schedules", + response_model=TriggerSchedulesResponse, + response_model_exclude_none=True, + status_code=status.HTTP_200_OK, + ) + self.router.add_api_route( + "/schedules/query", + self.query_schedules, + methods=["POST"], + operation_id="query_trigger_schedules", + response_model=TriggerSchedulesResponse, + response_model_exclude_none=True, + status_code=status.HTTP_200_OK, + ) + self.router.add_api_route( + "/schedules/{schedule_id}", + self.fetch_schedule, + methods=["GET"], + operation_id="fetch_trigger_schedule", + response_model=TriggerScheduleResponse, + response_model_exclude_none=True, + status_code=status.HTTP_200_OK, + ) + self.router.add_api_route( + "/schedules/{schedule_id}", + self.edit_schedule, + methods=["PUT"], + operation_id="edit_trigger_schedule", + response_model=TriggerScheduleResponse, + response_model_exclude_none=True, + status_code=status.HTTP_200_OK, + ) + self.router.add_api_route( + "/schedules/{schedule_id}", + self.delete_schedule, + methods=["DELETE"], + operation_id="delete_trigger_schedule", + status_code=status.HTTP_204_NO_CONTENT, + ) + self.router.add_api_route( + "/schedules/{schedule_id}/start", + self.start_schedule, + methods=["POST"], + operation_id="start_trigger_schedule", + response_model=TriggerScheduleResponse, + response_model_exclude_none=True, + status_code=status.HTTP_200_OK, + ) + self.router.add_api_route( + "/schedules/{schedule_id}/stop", + self.stop_schedule, + methods=["POST"], + operation_id="stop_trigger_schedule", + response_model=TriggerScheduleResponse, + response_model_exclude_none=True, + status_code=status.HTTP_200_OK, + ) + + # --- Trigger Schedules (admin) --- + # The cron driver POSTs to /admin/triggers/schedules/refresh (mounted in + # entrypoints/routers.py under prefix /admin/triggers). No auth/entitlement. + self.admin_router = APIRouter() + self.admin_router.add_api_route( + "/schedules/refresh", + self.refresh_schedules, + methods=["POST"], + operation_id="refresh_trigger_schedules", + ) + + # --- 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 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 + # ----------------------------------------------------------------------- + + @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_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 + + page = await self.triggers_service.list_integrations( + provider_key=provider_key, + search=search, + sort_by=sort_by, + limit=limit, + cursor=cursor, + ) + items = list(page.integrations) + + response = TriggerCatalogIntegrationsResponse( + count=len(items), + total=page.total, + cursor=page.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( + 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 + + page = await self.triggers_service.list_events( + provider_key=provider_key, + integration_key=integration_key, + query=query, + limit=limit, + cursor=cursor, + ) + items = list(page.events) + + response = TriggerCatalogEventsResponse( + count=len(items), + total=page.total, + cursor=page.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, + ) + + @intercept_exceptions() + async def start_subscription( + self, + request: Request, + *, + subscription_id: UUID, + ) -> TriggerSubscriptionResponse: + return await self._set_subscription_active( + request=request, + subscription_id=subscription_id, + is_active=True, + ) + + @intercept_exceptions() + async def stop_subscription( + self, + request: Request, + *, + subscription_id: UUID, + ) -> TriggerSubscriptionResponse: + return await self._set_subscription_active( + request=request, + subscription_id=subscription_id, + is_active=False, + ) + + async def _set_subscription_active( + self, + *, + request: Request, + subscription_id: UUID, + is_active: bool, + ) -> TriggerSubscriptionResponse: + await self._check(request, Permission.EDIT_TRIGGERS if is_ee() else None) + + try: + subscription = await self.triggers_service.set_subscription_active( + project_id=UUID(request.state.project_id), + user_id=UUID(str(request.state.user_id)), + # + subscription_id=subscription_id, + is_active=is_active, + ) + except SubscriptionNotFoundError as e: + raise HTTPException(status_code=404, detail=e.message) from e + + return TriggerSubscriptionResponse( + count=1, + subscription=subscription, + ) + + # ----------------------------------------------------------------------- + # Trigger Schedules + # ----------------------------------------------------------------------- + + @intercept_exceptions() + async def create_schedule( + self, + request: Request, + *, + body: TriggerScheduleCreateRequest, + ) -> TriggerScheduleResponse: + await self._check(request, Permission.EDIT_TRIGGERS if is_ee() else None) + + try: + schedule = await self.triggers_service.create_schedule( + project_id=UUID(request.state.project_id), + user_id=UUID(str(request.state.user_id)), + # + schedule=body.schedule, + ) + except TriggerScheduleInvalid as e: + raise HTTPException(status_code=422, detail=e.message) from e + except TriggerReferenceInvalid as e: + raise HTTPException(status_code=422, detail=e.message) from e + + return TriggerScheduleResponse( + count=1 if schedule else 0, + schedule=schedule, + ) + + @intercept_exceptions() + async def list_schedules( + self, + request: Request, + ) -> TriggerSchedulesResponse: + await self._check(request, Permission.VIEW_TRIGGERS if is_ee() else None) + + schedules = await self.triggers_service.query_schedules( + project_id=UUID(request.state.project_id), + ) + + return TriggerSchedulesResponse( + count=len(schedules), + schedules=schedules, + ) + + @intercept_exceptions() + async def query_schedules( + self, + request: Request, + *, + body: TriggerScheduleQueryRequest, + ) -> TriggerSchedulesResponse: + await self._check(request, Permission.VIEW_TRIGGERS if is_ee() else None) + + schedules = await self.triggers_service.query_schedules( + project_id=UUID(request.state.project_id), + # + schedule=body.schedule, + # + windowing=body.windowing, + ) + + return TriggerSchedulesResponse( + count=len(schedules), + schedules=schedules, + ) + + @intercept_exceptions() + async def fetch_schedule( + self, + request: Request, + *, + schedule_id: UUID, + ) -> TriggerScheduleResponse: + await self._check(request, Permission.VIEW_TRIGGERS if is_ee() else None) + + schedule = await self.triggers_service.fetch_schedule( + project_id=UUID(request.state.project_id), + # + schedule_id=schedule_id, + ) + if not schedule: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Trigger schedule not found", + ) + + return TriggerScheduleResponse( + count=1, + schedule=schedule, + ) + + @intercept_exceptions() + async def edit_schedule( + self, + request: Request, + *, + schedule_id: UUID, + body: TriggerScheduleEditRequest, + ) -> TriggerScheduleResponse: + await self._check(request, Permission.EDIT_TRIGGERS if is_ee() else None) + + if str(schedule_id) != str(body.schedule.id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Path schedule_id does not match body id", + ) + + try: + schedule = await self.triggers_service.edit_schedule( + project_id=UUID(request.state.project_id), + user_id=UUID(str(request.state.user_id)), + # + schedule=body.schedule, + ) + except TriggerScheduleInvalid as e: + raise HTTPException(status_code=422, detail=e.message) from e + except TriggerReferenceInvalid as e: + raise HTTPException(status_code=422, detail=e.message) from e + + if not schedule: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Trigger schedule not found", + ) + + return TriggerScheduleResponse( + count=1, + schedule=schedule, + ) + + @intercept_exceptions() + async def delete_schedule( + self, + request: Request, + *, + schedule_id: UUID, + ) -> None: + await self._check(request, Permission.EDIT_TRIGGERS if is_ee() else None) + + deleted = await self.triggers_service.delete_schedule( + project_id=UUID(request.state.project_id), + # + schedule_id=schedule_id, + ) + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Trigger schedule not found", + ) + + @intercept_exceptions() + async def start_schedule( + self, + request: Request, + *, + schedule_id: UUID, + ) -> TriggerScheduleResponse: + return await self._set_schedule_active( + request=request, + schedule_id=schedule_id, + is_active=True, + ) + + @intercept_exceptions() + async def stop_schedule( + self, + request: Request, + *, + schedule_id: UUID, + ) -> TriggerScheduleResponse: + return await self._set_schedule_active( + request=request, + schedule_id=schedule_id, + is_active=False, + ) + + async def _set_schedule_active( + self, + *, + request: Request, + schedule_id: UUID, + is_active: bool, + ) -> TriggerScheduleResponse: + await self._check(request, Permission.EDIT_TRIGGERS if is_ee() else None) + + try: + schedule = await self.triggers_service.set_schedule_active( + project_id=UUID(request.state.project_id), + user_id=UUID(str(request.state.user_id)), + # + schedule_id=schedule_id, + is_active=is_active, + ) + except ScheduleNotFoundError as e: + raise HTTPException(status_code=404, detail=e.message) from e + + return TriggerScheduleResponse( + count=1, + schedule=schedule, + ) + + @intercept_exceptions() + async def refresh_schedules( + self, + *, + trigger_interval: int = Query(1, ge=1, le=60), + trigger_datetime: datetime = Query(None), + ) -> Any: + # ---------------------------------------------------------------------- + # THIS IS AN ADMIN ENDPOINT + # NO CHECK FOR PERMISSIONS / ENTITLEMENTS + # ---------------------------------------------------------------------- + + if not trigger_datetime or not trigger_interval: + return {"status": "error"} + + timestamp = trigger_datetime - timedelta(minutes=trigger_interval) + + check = await self.triggers_service.refresh_schedules( + timestamp=timestamp, + interval=trigger_interval, + ) + + if not check: + return {"status": "failure"} + + return {"status": "success"} + + # ----------------------------------------------------------------------- + # 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 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"}, + ) + + 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: + try: + await asyncio.wait_for( + self.dispatch_task.kiq( + trigger_id=str(trigger_id), + event_id=str(event_id), + event=envelope, + ), + timeout=_ENQUEUE_TIMEOUT_SECONDS, + ) + except Exception as e: + log.error("Failed to enqueue trigger event: %s", e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Failed to enqueue trigger event", + ) from e + + return TriggerEventAck(status="accepted") diff --git a/api/oss/src/apis/fastapi/webhooks/router.py b/api/oss/src/apis/fastapi/webhooks/router.py index 6ab1173d2a..85653c4b4a 100644 --- a/api/oss/src/apis/fastapi/webhooks/router.py +++ b/api/oss/src/apis/fastapi/webhooks/router.py @@ -103,6 +103,24 @@ def __init__( response_model_exclude_none=True, status_code=status.HTTP_200_OK, ) + self.router.add_api_route( + "/subscriptions/{subscription_id}/start", + self.start_subscription, + methods=["POST"], + operation_id="start_webhook_subscription", + response_model=WebhookSubscriptionResponse, + response_model_exclude_none=True, + status_code=status.HTTP_200_OK, + ) + self.router.add_api_route( + "/subscriptions/{subscription_id}/stop", + self.stop_subscription, + methods=["POST"], + operation_id="stop_webhook_subscription", + response_model=WebhookSubscriptionResponse, + response_model_exclude_none=True, + status_code=status.HTTP_200_OK, + ) # --- WEBHOOK DELIVERIES --------------------------------------------- # @@ -373,6 +391,84 @@ async def query_subscriptions( subscriptions=subscriptions, ) + async def _set_subscription_active( + self, + *, + request: Request, + subscription_id: UUID, + is_active: bool, + ) -> WebhookSubscriptionResponse: + 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.EDIT_WEBHOOKS, + ) + if not has_permission: + raise FORBIDDEN_EXCEPTION # type: ignore + + subscription = await self.webhooks_service.set_subscription_active( + project_id=UUID(request.state.project_id), + user_id=UUID(str(request.state.user_id)), + # + subscription_id=subscription_id, + is_active=is_active, + ) + + if not subscription: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Webhook subscription not found", + ) + + await set_cache( + namespace="webhooks", + project_id=str(request.state.project_id), + key=f"subscription:{subscription.id}", + value=subscription.model_copy( + update={"secret": encrypt(subscription.secret)} + ) + if subscription.secret + else subscription, + ttl=AGENTA_CACHE_TTL, + ) + await invalidate_cache( + namespace="webhooks", + project_id=str(request.state.project_id), + key="subscriptions", + ) + + return WebhookSubscriptionResponse( + count=1, + subscription=subscription, + ) + + @intercept_exceptions() + async def start_subscription( + self, + request: Request, + *, + subscription_id: UUID, + ) -> WebhookSubscriptionResponse: + return await self._set_subscription_active( + request=request, + subscription_id=subscription_id, + is_active=True, + ) + + @intercept_exceptions() + async def stop_subscription( + self, + request: Request, + *, + subscription_id: UUID, + ) -> WebhookSubscriptionResponse: + return await self._set_subscription_active( + request=request, + subscription_id=subscription_id, + is_active=False, + ) + # --- WEBHOOK DELIVERIES ------------------------------------------------- # @intercept_exceptions() @@ -506,6 +602,7 @@ async def _test_subscription_impl( name=existing.name, description=existing.description, data=existing.data, + flags=existing.flags, secret=existing.secret, ) 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/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..854cc9330b --- /dev/null +++ b/api/oss/src/core/gateway/catalog/dtos.py @@ -0,0 +1,54 @@ +"""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 + + +class CatalogIntegrationsPage(BaseModel): + """A cursor-paginated page of integrations from a provider catalog.""" + + integrations: List[CatalogIntegration] = [] + next_cursor: Optional[str] = None + total: int = 0 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..e0a58be8e4 --- /dev/null +++ b/api/oss/src/core/gateway/catalog/interfaces.py @@ -0,0 +1,37 @@ +from abc import ABC, abstractmethod +from typing import List, Optional + +from oss.src.core.gateway.catalog.dtos import ( + CatalogIntegration, + CatalogIntegrationsPage, + 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, + ) -> CatalogIntegrationsPage: ... + + @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..1f03732b08 --- /dev/null +++ b/api/oss/src/core/gateway/catalog/providers/composio/adapter.py @@ -0,0 +1,235 @@ +"""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 + +import httpx + +from oss.src.utils.logging import get_module_logger +from oss.src.core.gateway.catalog.dtos import ( + CatalogAuthScheme, + CatalogIntegration, + CatalogIntegrationsPage, + 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, + ) -> CatalogIntegrationsPage: + 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 CatalogIntegrationsPage( + integrations=items, + next_cursor=next_cursor, + total=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..eba90cfb0b --- /dev/null +++ b/api/oss/src/core/gateway/catalog/registry.py @@ -0,0 +1,26 @@ +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: + if provider_key not in self._adapters: + raise ProviderNotFoundError(provider_key) + return self._adapters[provider_key] + + 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..c3beac49e6 --- /dev/null +++ b/api/oss/src/core/gateway/catalog/service.py @@ -0,0 +1,70 @@ +"""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 + +from oss.src.core.gateway.catalog.dtos import ( + CatalogIntegration, + CatalogIntegrationsPage, + 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, + ) -> CatalogIntegrationsPage: + 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/__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..c86d177cf3 --- /dev/null +++ b/api/oss/src/core/gateway/connections/dtos.py @@ -0,0 +1,149 @@ +from enum import Enum +from typing import Any, Dict, Optional, Union + +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 + # + # Either the typed create input (from the API) or the provider-shaped payload + # the service builds before persistence (provider field names are opaque here). + data: Optional[Union[ConnectionCreateData, Json]] = 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) + + +class ConnectionStatusResponse(BaseModel): + """Output DTO from ConnectionsGatewayInterface.get_connection_status.""" + + status: Optional[str] = None + is_valid: bool = False + + +class ConnectionRefreshResponse(BaseModel): + """Output DTO from ConnectionsGatewayInterface.refresh_connection.""" + + id: Optional[str] = None + status: Optional[str] = None + is_valid: Optional[bool] = None + redirect_url: Optional[str] = None + auth_config_id: Optional[str] = None 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..2156a62adb --- /dev/null +++ b/api/oss/src/core/gateway/connections/interfaces.py @@ -0,0 +1,130 @@ +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, + ConnectionRefreshResponse, + ConnectionRequest, + ConnectionResponse, + ConnectionStatusResponse, +) + + +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, + *, + project_id: UUID, + provider_connection_id: str, + ) -> Optional[Connection]: ... + + @abstractmethod + async def activate_connection_by_provider_id( + self, + *, + project_id: UUID, + provider_connection_id: str, + ) -> 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, + ) -> ConnectionStatusResponse: + """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, + ) -> ConnectionRefreshResponse: ... + + @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..82d410bb0a --- /dev/null +++ b/api/oss/src/core/gateway/connections/providers/composio/adapter.py @@ -0,0 +1,304 @@ +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 ( + ConnectionRefreshResponse, + ConnectionRequest, + ConnectionResponse, + ConnectionStatusResponse, +) +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__) + + +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: Optional[str] = None, + ): + self.api_key = api_key + 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) + + 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=composio_error_detail(e), + ) from e + except httpx.HTTPError as e: + raise AdapterError( + provider_key="composio", + operation="initiate_connection.validate_toolkit", + detail=composio_error_detail(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=composio_error_detail(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=composio_error_detail(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, + ) -> ConnectionStatusResponse: + 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=composio_error_detail(e), + ) from e + + return ConnectionStatusResponse( + 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, + ) -> ConnectionRefreshResponse: + # 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 ConnectionRefreshResponse( + 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=composio_error_detail(e), + ) from e + + return ConnectionRefreshResponse( + 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=composio_error_detail(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..fd7e2d80e9 --- /dev/null +++ b/api/oss/src/core/gateway/connections/service.py @@ -0,0 +1,329 @@ +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, + *, + project_id: UUID, + provider_connection_id: str, + ) -> Optional[Connection]: + """Find a project's connection by its provider-side ID (for OAuth callbacks).""" + return await self.connections_dao.find_connection_by_provider_id( + project_id=project_id, + provider_connection_id=provider_connection_id, + ) + + async def activate_connection_by_provider_connection_id( + self, + *, + project_id: UUID, + provider_connection_id: str, + ) -> Optional[Connection]: + """Mark a connection valid+active after OAuth completes.""" + return await self.connections_dao.activate_connection_by_provider_id( + project_id=project_id, + provider_connection_id=provider_connection_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 + + # 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.id or provider_connection_id + auth_config_id = result.auth_config_id + is_valid = result.is_valid if result.is_valid is not None else conn.is_valid + + redirect_url = result.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/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 a588965f61..c5ef4920bb 100644 --- a/api/oss/src/core/tools/dtos.py +++ b/api/oss/src/core/tools/dtos.py @@ -1,15 +1,21 @@ from enum import Enum -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Union 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, + ConnectionCreateData, + ConnectionStatus, +) from oss.src.core.shared.dtos import ( - Header, Identifier, - Lifecycle, - Metadata, - Slug, Json, Status, ) @@ -52,18 +58,10 @@ 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 - # +# 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): auth_schemes: Optional[List[ToolAuthScheme]] = None @@ -71,83 +69,52 @@ class ToolCatalogIntegrationDetails(ToolCatalogIntegration): actions: Optional[List[ToolCatalogAction]] = None -class ToolCatalogProvider(BaseModel): +class ToolCatalogProvider(CatalogProvider): key: ToolProviderKind - # - name: str - description: Optional[str] = None - # - integrations_count: Optional[int] = None - # class ToolCatalogProviderDetails(ToolCatalogProvider): integrations: Optional[List[ToolCatalogIntegration]] = None +class ToolCatalogIntegrationsPage(BaseModel): + """A cursor-paginated page of tool integrations.""" + + integrations: List[ToolCatalogIntegration] = [] + next_cursor: Optional[str] = None + total: int = 0 + + +class ToolCatalogActionsPage(BaseModel): + """A cursor-paginated page of tool actions.""" + + actions: List[ToolCatalogAction] = [] + next_cursor: Optional[str] = None + total: int = 0 + + # --------------------------------------------------------------------------- -# Tool Connections +# Tool Connections — shared `gateway_connections` rows, inherited here so the +# tools router/models never reference the generic gateway DTOs directly. # --------------------------------------------------------------------------- -class ToolConnectionStatus(BaseModel): - redirect_url: Optional[str] = None +class ToolConnectionStatus(ConnectionStatus): + pass -class ToolConnectionCreateData(BaseModel): - callback_url: Optional[str] = None - # +class ToolConnectionCreateData(ConnectionCreateData): auth_scheme: Optional[ToolAuthScheme] = None -class ToolConnection( - Identifier, - Slug, - Header, - Lifecycle, - Metadata, -): +class ToolConnection(Connection): 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, -): + +class ToolConnectionCreate(ConnectionCreate): provider_key: ToolProviderKind - integration_key: str - # - data: Optional[ToolConnectionCreateData] = None + data: Optional[Union[ToolConnectionCreateData, Json]] = None # --------------------------------------------------------------------------- @@ -191,32 +158,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..fe96d871a4 100644 --- a/api/oss/src/core/tools/interfaces.py +++ b/api/oss/src/core/tools/interfaces.py @@ -1,181 +1,69 @@ -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 + +from oss.src.core.tools.dtos import ( + ToolCatalogActionDetails, + ToolCatalogActionsPage, + ToolCatalogIntegration, + ToolCatalogIntegrationsPage, + 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, + ) -> ToolCatalogIntegrationsPage: ... + + @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, + ) -> ToolCatalogActionsPage: ... + + @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..6ae7601426 100644 --- a/api/oss/src/core/tools/providers/composio/adapter.py +++ b/api/oss/src/core/tools/providers/composio/adapter.py @@ -9,37 +9,35 @@ from oss.src.core.tools.dtos import ( ToolCatalogActionDetails, ToolCatalogProvider, - ToolConnectionRequest, - ToolConnectionResponse, ToolExecutionRequest, ToolExecutionResponse, ) 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). 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__( 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) @@ -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 # ----------------------------------------------------------------------- @@ -138,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") @@ -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 # ----------------------------------------------------------------------- @@ -401,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/providers/composio/catalog.py b/api/oss/src/core/tools/providers/composio/catalog.py index 0ffa589027..c9598a9d63 100644 --- a/api/oss/src/core/tools/providers/composio/catalog.py +++ b/api/oss/src/core/tools/providers/composio/catalog.py @@ -8,7 +8,7 @@ as-is between our API and Composio's API. """ -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional import httpx @@ -16,7 +16,9 @@ from oss.src.core.tools.dtos import ( ToolAuthScheme, ToolCatalogAction, + ToolCatalogActionsPage, ToolCatalogIntegration, + ToolCatalogIntegrationsPage, ) from oss.src.core.tools.exceptions import AdapterError @@ -118,7 +120,7 @@ async def list_integrations( sort_by: Optional[str] = None, limit: Optional[int] = None, cursor: Optional[str] = None, - ) -> Tuple[List[ToolCatalogIntegration], Optional[str], int]: + ) -> ToolCatalogIntegrationsPage: """Fetch one page of integrations from Composio. Args: @@ -126,10 +128,6 @@ async def list_integrations( sort_by: Optional sort — "usage" or "alphabetically" limit: Items per page (max 1000) cursor: Composio next_cursor from a previous response - - Returns: - (items, next_cursor, total_items) - next_cursor is None when on the last page """ page_limit = min(limit, MAX_PAGE_SIZE) if limit else DEFAULT_PAGE_SIZE @@ -179,7 +177,11 @@ async def list_integrations( next_cursor, ) - return items, next_cursor, total_items + return ToolCatalogIntegrationsPage( + integrations=items, + next_cursor=next_cursor, + total=total_items, + ) # ----------------------------------------------------------------------- # Action listing @@ -194,7 +196,7 @@ async def list_actions( important: Optional[bool] = None, # reserved; not forwarded to Composio limit: Optional[int] = None, cursor: Optional[str] = None, - ) -> Tuple[List[ToolCatalogAction], Optional[str], int]: + ) -> ToolCatalogActionsPage: """Fetch one page of actions for an integration from Composio. Args: @@ -204,10 +206,6 @@ async def list_actions( important: Reserved for future filtering; not forwarded upstream limit: Items per page (max 1000) cursor: Composio next_cursor from a previous response - - Returns: - (items, next_cursor, total_items) - next_cursor is None when on the last page """ page_limit = min(limit, MAX_PAGE_SIZE) if limit else DEFAULT_PAGE_SIZE @@ -268,7 +266,11 @@ async def list_actions( next_cursor, ) - return items, next_cursor, total_items + return ToolCatalogActionsPage( + actions=items, + next_cursor=next_cursor, + total=total_items, + ) # --------------------------------------------------------------------------- diff --git a/api/oss/src/core/tools/service.py b/api/oss/src/core/tools/service.py index f603bc4d42..f2a52a6c07 100644 --- a/api/oss/src/core/tools/service.py +++ b/api/oss/src/core/tools/service.py @@ -1,410 +1,291 @@ -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 +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.tools.dtos import ( + ToolCatalogActionDetails, + ToolCatalogActionsPage, + ToolCatalogIntegration, + ToolCatalogIntegrationsPage, + ToolCatalogProvider, + ToolConnection, + ToolConnectionCreate, + ToolExecutionRequest, + ToolExecutionResponse, +) +from oss.src.core.tools.registry import ToolsGatewayRegistry + + +log = get_module_logger(__name__) + + +class ToolsService: + 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 — 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]: + 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]: + 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, + *, + provider_key: str, + # + search: Optional[str] = None, + sort_by: Optional[str] = None, + limit: Optional[int] = None, + cursor: Optional[str] = None, + ) -> ToolCatalogIntegrationsPage: + page = await self.catalog_service.list_integrations( + provider_key=provider_key, + search=search, + sort_by=sort_by, + limit=limit, + cursor=cursor, + ) + items = [ + ToolCatalogIntegration.model_validate(i.model_dump()) + for i in page.integrations + ] + return ToolCatalogIntegrationsPage( + integrations=items, + next_cursor=page.next_cursor, + total=page.total, + ) + + async def get_integration( + self, + *, + provider_key: str, + integration_key: str, + ) -> Optional[ToolCatalogIntegration]: + 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, + *, + 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, + ) -> ToolCatalogActionsPage: + """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) + # ----------------------------------------------------------------------- + + @staticmethod + def _as_tool_connection(conn) -> Optional[ToolConnection]: + return ToolConnection.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[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, + *, + project_id: UUID, + provider_key: str, + integration_key: str, + ) -> 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[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, + *, + project_id: UUID, + provider_connection_id: str, + ) -> Optional[ToolConnection]: + conn = await self.connections_service.find_connection_by_provider_connection_id( + project_id=project_id, + provider_connection_id=provider_connection_id, + ) + return self._as_tool_connection(conn) + + async def activate_connection_by_provider_connection_id( + self, + *, + project_id: UUID, + provider_connection_id: str, + ) -> Optional[ToolConnection]: + conn = await self.connections_service.activate_connection_by_provider_connection_id( + project_id=project_id, + provider_connection_id=provider_connection_id, + ) + return self._as_tool_connection(conn) + + async def create_connection( + self, + *, + project_id: UUID, + user_id: UUID, + # + 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, + *, + 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, + ) -> 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, + *, + project_id: UUID, + connection_id: UUID, + # + force: bool = False, + ) -> 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 + # ----------------------------------------------------------------------- + + 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..5466afc102 --- /dev/null +++ b/api/oss/src/core/triggers/dtos.py @@ -0,0 +1,307 @@ +from enum import Enum +from typing import Any, Dict, List, Optional, Union +from uuid import UUID + +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, + ConnectionCreateData, + ConnectionStatus, +) +from oss.src.core.shared.dtos import ( + Header, + Identifier, + Json, + Lifecycle, + Metadata, + Reference, + Selector, + Status, +) + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +TRIGGER_MAX_RETRIES = 5 + + +# --------------------------------------------------------------------------- +# Trigger Enums +# --------------------------------------------------------------------------- + + +class TriggerProviderKind(str, Enum): + COMPOSIO = "composio" + + +class TriggerAuthScheme(str, Enum): + OAUTH = "oauth" + API_KEY = "api_key" + + +# --------------------------------------------------------------------------- +# 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): + trigger_config: Optional[Dict[str, Any]] = None + payload: Optional[Dict[str, Any]] = 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): + key: TriggerProviderKind + + +class TriggerCatalogIntegration(CatalogIntegration): + auth_schemes: Optional[List[TriggerAuthScheme]] = None + + +class TriggerCatalogIntegrationsPage(BaseModel): + """A cursor-paginated page of trigger integrations.""" + + integrations: List[TriggerCatalogIntegration] = [] + next_cursor: Optional[str] = None + total: int = 0 + + +class TriggerCatalogEventsPage(BaseModel): + """A cursor-paginated page of trigger events.""" + + events: List[TriggerCatalogEvent] = [] + next_cursor: Optional[str] = None + total: int = 0 + + +# --------------------------------------------------------------------------- +# Trigger Connections — shared `gateway_connections` rows, inherited here so the +# triggers router/models never reference the generic gateway DTOs directly. +# --------------------------------------------------------------------------- + + +class TriggerConnectionStatus(ConnectionStatus): + pass + + +class TriggerConnectionCreateData(ConnectionCreateData): + auth_scheme: Optional[TriggerAuthScheme] = None + + +class TriggerConnection(Connection): + provider_key: TriggerProviderKind + status: Optional[TriggerConnectionStatus] = None + + +class TriggerConnectionCreate(ConnectionCreate): + provider_key: TriggerProviderKind + data: Optional[Union[TriggerConnectionCreateData, Json]] = 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_CONTEXT_FIELDS = { + "event_id", + "event_type", + "timestamp", + "created_at", + "attributes", +} + +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_*``) is a top-level lookup key (indexed), not config inside ``data``. +# --------------------------------------------------------------------------- + + +class TriggerSubscriptionFlags(BaseModel): + # is_active = user play/pause switch; is_valid = provider connection still good + # (Composio can revoke a connection out from under a subscription). + is_active: bool = True + is_valid: bool = True + + +class TriggerSubscriptionData(BaseModel): + event_key: str + # + 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 + # + trigger_id: Optional[str] = None + # + data: TriggerSubscriptionData + # + flags: TriggerSubscriptionFlags = Field(default_factory=TriggerSubscriptionFlags) + + +class TriggerSubscriptionCreate(Header, Metadata): + connection_id: UUID + # + data: TriggerSubscriptionData + + +class TriggerSubscriptionEdit(Identifier, Header, Metadata): + connection_id: UUID + # + data: TriggerSubscriptionData + # + flags: TriggerSubscriptionFlags = Field(default_factory=TriggerSubscriptionFlags) + + +class TriggerSubscriptionQuery(BaseModel): + name: Optional[str] = None + connection_id: Optional[UUID] = None + event_key: Optional[str] = None + + +# --------------------------------------------------------------------------- +# Trigger Schedules +# +# A cron-driven analogue to a trigger subscription. Same mapping + bound-workflow +# reference, but fired by our own cron tick (``croniter.match`` on the rounded +# trigger_datetime) instead of a Composio event. No connection_id, no trigger_id. +# --------------------------------------------------------------------------- + + +class TriggerScheduleFlags(BaseModel): + # No is_valid: a schedule has no external connection to invalidate. + is_active: bool = True + + +class TriggerScheduleData(BaseModel): + event_key: str + # + # PERIOD — a 5-field cron expression (UTC, 1-minute floor); validated via croniter. + schedule: str + # + # 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 TriggerSchedule(Identifier, Lifecycle, Header, Metadata): + data: TriggerScheduleData + # + flags: TriggerScheduleFlags = Field(default_factory=TriggerScheduleFlags) + + +class TriggerScheduleCreate(Header, Metadata): + data: TriggerScheduleData + + +class TriggerScheduleEdit(Identifier, Header, Metadata): + data: TriggerScheduleData + # + flags: TriggerScheduleFlags = Field(default_factory=TriggerScheduleFlags) + + +class TriggerScheduleQuery(BaseModel): + name: Optional[str] = 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 + + # Exactly one of subscription_id / schedule_id is set (XOR — enforced in DB). + subscription_id: Optional[UUID] = None + schedule_id: Optional[UUID] = None + event_id: str + + +class TriggerDeliveryCreate(Identifier): + status: Status + + data: Optional[TriggerDeliveryData] = None + + subscription_id: Optional[UUID] = None + schedule_id: Optional[UUID] = None + event_id: str + + +class TriggerDeliveryQuery(BaseModel): + status: Optional[Status] = None + + subscription_id: Optional[UUID] = None + schedule_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..a2423984b4 --- /dev/null +++ b/api/oss/src/core/triggers/exceptions.py @@ -0,0 +1,85 @@ +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 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 ScheduleNotFoundError(TriggersError): + """Raised when a schedule_id does not exist in the project.""" + + def __init__(self, *, schedule_id: str): + self.schedule_id = schedule_id + super().__init__(f"Trigger schedule not found: {schedule_id}") + + +class TriggerScheduleInvalid(TriggersError): + """Raised when a schedule's cron expression is not a valid 5-field expression.""" + + def __init__( + self, + message: str = "Schedule must be a valid 5-field cron expression.", + *, + schedule: Optional[str] = None, + reason: Optional[str] = None, + ): + self.schedule = schedule + self.reason = reason + super().__init__(message) + + +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..bcd9811070 --- /dev/null +++ b/api/oss/src/core/triggers/interfaces.py @@ -0,0 +1,282 @@ +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 ( + TriggerCatalogEventDetails, + TriggerCatalogEventsPage, + TriggerCatalogProvider, + TriggerDelivery, + TriggerDeliveryCreate, + TriggerDeliveryQuery, + TriggerSchedule, + TriggerScheduleCreate, + TriggerScheduleEdit, + TriggerScheduleQuery, + TriggerSubscription, + TriggerSubscriptionCreate, + TriggerSubscriptionEdit, + TriggerSubscriptionQuery, +) + + +class TriggersGatewayInterface(ABC): + """Port for external trigger providers (Composio, ...). + + The catalog reads (``list_events``/``get_event``) back the events catalog; + the subscription verbs build/manage the provider-side trigger instance + (``ti_*``) stored 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, + ) -> TriggerCatalogEventsPage: ... + + @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.""" + ... + + @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).""" + + # --- subscriptions ------------------------------------------------------ # + + @abstractmethod + async def create_subscription( + self, + *, + project_id: UUID, + user_id: UUID, + # + subscription: TriggerSubscriptionCreate, + # + trigger_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_project_and_subscription_by_trigger_id( + self, + *, + trigger_id: str, + ) -> Optional[Tuple[UUID, TriggerSubscription]]: + """Resolve a ``ti_*`` to its (project_id, subscription). + + Deliberately cross-project: an inbound Composio event carries only the + provider ``ti_*`` and no tenant scope, so this lookup *recovers* the + project from the (partial-unique) ``trigger_id`` column. The only sanctioned + unscoped DAO read — every other read/write takes ``project_id``. + """ + ... + + # --- deliveries --------------------------------------------------------- # + + @abstractmethod + async def write_delivery( + self, + *, + project_id: UUID, + user_id: Optional[UUID], + # + delivery: TriggerDeliveryCreate, + ) -> TriggerDelivery: + """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: + """True if a delivery for this event_id already exists.""" + ... + + @abstractmethod + async def dedup_seen_schedule( + self, + *, + project_id: UUID, + schedule_id: UUID, + event_id: str, + ) -> bool: + """True if a delivery for this (schedule, event_id) already exists.""" + ... + + # --- schedules ---------------------------------------------------------- # + + @abstractmethod + async def create_schedule( + self, + *, + project_id: UUID, + user_id: UUID, + # + schedule: TriggerScheduleCreate, + ) -> TriggerSchedule: ... + + @abstractmethod + async def fetch_schedule( + self, + *, + project_id: UUID, + # + schedule_id: UUID, + ) -> Optional[TriggerSchedule]: ... + + @abstractmethod + async def edit_schedule( + self, + *, + project_id: UUID, + user_id: UUID, + # + schedule: TriggerScheduleEdit, + ) -> Optional[TriggerSchedule]: ... + + @abstractmethod + async def delete_schedule( + self, + *, + project_id: UUID, + # + schedule_id: UUID, + ) -> bool: ... + + @abstractmethod + async def query_schedules( + self, + *, + project_id: UUID, + # + schedule: Optional[TriggerScheduleQuery] = None, + # + windowing: Optional[Windowing] = None, + ) -> List[TriggerSchedule]: ... + + @abstractmethod + async def fetch_active_schedules( + self, + *, + project_id: Optional[UUID] = None, + ) -> List[TriggerSchedule]: ... + + @abstractmethod + async def fetch_active_schedules_with_project( + self, + *, + project_id: Optional[UUID] = None, + ) -> List[Tuple[UUID, TriggerSchedule]]: ... 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..86fffef56c --- /dev/null +++ b/api/oss/src/core/triggers/providers/composio/adapter.py @@ -0,0 +1,246 @@ +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, +) +from oss.src.core.gateway.providers.composio.errors import composio_error_detail +from oss.src.utils.env import env + + +log = get_module_logger(__name__) + +_WEBHOOK_EVENT = "composio.trigger.message" + + +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. + + 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: Optional[str] = None, + ): + self.api_key = api_key + 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) + + 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) -> 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, + *, + 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. + + # ----------------------------------------------------------------------- + # 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") + secret = self._first_webhook_secret(existing) + if secret: + return 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") + secret = self._first_webhook_secret(again) + if not secret: + raise AdapterError( + provider_key="composio", + operation="ensure_webhook_subscription", + detail="409 conflict returned no readable webhook secret", + ) + return 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 + + @staticmethod + def _first_webhook_secret(payload: object) -> Optional[str]: + if not isinstance(payload, dict): + return None + items = payload.get("items", []) + if items and isinstance(items[0], dict): + return items[0].get("secret") + return None + + # ----------------------------------------------------------------------- + # Subscriptions (provider-side trigger instances — ti_*) + # ----------------------------------------------------------------------- + + 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=composio_error_detail(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=composio_error_detail(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=composio_error_detail(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..e5ed51282b --- /dev/null +++ b/api/oss/src/core/triggers/providers/composio/catalog.py @@ -0,0 +1,193 @@ +"""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 + +import httpx + +from oss.src.utils.logging import get_module_logger +from oss.src.core.triggers.dtos import ( + TriggerCatalogEvent, + TriggerCatalogEventDetails, + TriggerCatalogEventsPage, +) +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, + ) -> TriggerCatalogEventsPage: + """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 TriggerCatalogEventsPage( + events=items, + next_cursor=next_cursor, + total=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..f7332af2e8 --- /dev/null +++ b/api/oss/src/core/triggers/service.py @@ -0,0 +1,971 @@ +import asyncio +import hashlib +import hmac +from datetime import datetime +from typing import Any, List, Mapping, Optional +from uuid import UUID + +from croniter import croniter + +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 ( + TriggerCatalogEventDetails, + TriggerCatalogEventsPage, + TriggerCatalogIntegration, + TriggerCatalogIntegrationsPage, + TriggerCatalogProvider, + TriggerConnection, + TriggerConnectionCreate, + TriggerDelivery, + TriggerDeliveryCreate, + TriggerDeliveryQuery, + TriggerSchedule, + TriggerScheduleCreate, + TriggerScheduleEdit, + TriggerScheduleQuery, + TriggerSubscription, + TriggerSubscriptionCreate, + TriggerSubscriptionEdit, + TriggerSubscriptionQuery, +) +from oss.src.core.triggers.exceptions import ( + AdapterError, + ConnectionNotFoundError, + ScheduleNotFoundError, + SubscriptionNotFoundError, + TriggerReferenceInvalid, + TriggerScheduleInvalid, +) +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 + + +log = get_module_logger(__name__) + +_ENQUEUE_TIMEOUT_SECONDS = 5.0 + + +class TriggersService: + """Triggers domain orchestration. + + Covers the read-only events catalog and subscription/delivery CRUD. + 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, + catalog_service: CatalogService, + triggers_dao: TriggersDAOInterface, + connections_service: ConnectionsService, + workflows_service: WorkflowsService, + # Assigned post-construction in the composition root (worker wiring); guarded at use. + schedule_dispatch_task: Optional[Any] = 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.schedule_dispatch_task = schedule_dispatch_task + self.webhook_secret_resolver = WebhookSecretResolver( + adapter_registry=adapter_registry, + ) + + # ----------------------------------------------------------------------- + # 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]: + 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]: + provider = await self.catalog_service.get_provider(provider_key=provider_key) + if not provider: + return None + return TriggerCatalogProvider.model_validate(provider.model_dump()) + + 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, + ) -> TriggerCatalogIntegrationsPage: + page = 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 page.integrations + ] + return TriggerCatalogIntegrationsPage( + integrations=items, + next_cursor=page.next_cursor, + total=page.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, + *, + provider_key: str, + integration_key: str, + # + query: Optional[str] = None, + limit: Optional[int] = None, + cursor: Optional[str] = None, + ) -> TriggerCatalogEventsPage: + """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, + ) + + # ----------------------------------------------------------------------- + # 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 + # ----------------------------------------------------------------------- + + 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 _normalize_references( + self, + *, + project_id: UUID, + references: Optional[dict], + ) -> None: + """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 + + 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 + + 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, + 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: + raise TriggerReferenceInvalid( + "Bound workflow reference could not be resolved to a runnable revision." + ) + + 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, + *, + project_id: UUID, + user_id: UUID, + # + 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, + ) + + adapter = self.adapter_registry.get(connection.provider_key.value) + + trigger_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, + # + trigger_id=trigger_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 _sync_provider_enabled( + self, + *, + project_id: UUID, + subscription: TriggerSubscription, + is_active: bool, + is_valid: bool, + ) -> None: + """Reflect the combined desired state onto the provider ``ti_*``. + + The provider trigger should only fire when the subscription is BOTH + locally active and provider-valid, so ``enabled = is_active and is_valid``. + Single source of truth for edit/start/stop/refresh/revoke so they can't + disagree and re-enable a revoked/paused trigger. + """ + trigger_id = subscription.trigger_id + if trigger_id is None: + return + connection = await self._require_connection( + project_id=project_id, + connection_id=subscription.connection_id, + ) + adapter = self.adapter_registry.get(connection.provider_key.value) + await adapter.set_subscription_status( + trigger_id=trigger_id, + enabled=is_active and is_valid, + ) + + async def edit_subscription( + self, + *, + project_id: UUID, + user_id: UUID, + # + subscription: TriggerSubscriptionEdit, + ) -> Optional[TriggerSubscription]: + """Full-PUT edit. Reflects the combined is_active/is_valid onto ``ti_*``.""" + existing = await self.dao.fetch_subscription( + project_id=project_id, + subscription_id=subscription.id, + ) + if existing is None: + return None + + await self._normalize_references( + project_id=project_id, + references=subscription.data.references, + ) + + if subscription.flags.is_active != existing.flags.is_active: + await self._sync_provider_enabled( + project_id=project_id, + subscription=existing, + is_active=subscription.flags.is_active, + is_valid=existing.flags.is_valid, + ) + + 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 + + trigger_id = existing.trigger_id + if trigger_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=trigger_id) + except AdapterError: + # Provider-side trigger may already be gone; local delete is + # the source of truth. Unexpected errors are left to surface. + log.warning( + "Failed to delete provider trigger %s; proceeding with local delete", + trigger_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-sync the provider ``ti_*`` and mark the row valid.""" + return await self._set_valid( + project_id=project_id, + user_id=user_id, + subscription_id=subscription_id, + is_valid=True, + ) + + async def revoke_subscription( + self, + *, + project_id: UUID, + user_id: UUID, + # + subscription_id: UUID, + ) -> TriggerSubscription: + """Disable the provider ``ti_*`` and mark the row invalid. + + Drives the third-party-sync axis (``is_valid``); the user's local + play/pause (``is_active``) is left untouched, as is the shared + connection (C7). + """ + return await self._set_valid( + project_id=project_id, + user_id=user_id, + subscription_id=subscription_id, + is_valid=False, + ) + + async def set_subscription_active( + self, + *, + project_id: UUID, + user_id: UUID, + # + subscription_id: UUID, + is_active: bool, + ) -> TriggerSubscription: + """Full-PUT play/pause toggle; touches only local is_active (never is_valid). + + Distinct from /revoke, which drives the provider ti_* / is_valid axis. + """ + 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)) + + await self._sync_provider_enabled( + project_id=project_id, + subscription=existing, + is_active=is_active, + is_valid=existing.flags.is_valid, + ) + + 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, + flags=existing.flags.model_copy(update={"is_active": is_active}), + ) + + updated = await self.dao.edit_subscription( + project_id=project_id, + user_id=user_id, + subscription=edit, + ) + + return updated or existing + + async def _set_valid( + self, + *, + project_id: UUID, + user_id: UUID, + subscription_id: UUID, + is_valid: 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)) + + await self._sync_provider_enabled( + project_id=project_id, + subscription=existing, + is_active=existing.flags.is_active, + is_valid=is_valid, + ) + + 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, + flags=existing.flags.model_copy(update={"is_valid": is_valid}), + ) + + updated = await self.dao.edit_subscription( + project_id=project_id, + user_id=user_id, + subscription=edit, + ) + + return updated or existing + + # ----------------------------------------------------------------------- + # Schedules + # ----------------------------------------------------------------------- + + @staticmethod + def _validate_schedule(expr: str) -> None: + """Reject anything that is not a valid 5-field cron expression (UTC).""" + if not isinstance(expr, str) or len(expr.split()) != 5: + raise TriggerScheduleInvalid( + schedule=expr if isinstance(expr, str) else None, + reason="not a 5-field cron expression", + ) + if not croniter.is_valid(expr): + raise TriggerScheduleInvalid( + schedule=expr, + reason="cron expression is not parseable", + ) + + async def create_schedule( + self, + *, + project_id: UUID, + user_id: UUID, + # + schedule: TriggerScheduleCreate, + ) -> TriggerSchedule: + self._validate_schedule(schedule.data.schedule) + + await self._normalize_references( + project_id=project_id, + references=schedule.data.references, + ) + + return await self.dao.create_schedule( + project_id=project_id, + user_id=user_id, + # + schedule=schedule, + ) + + async def fetch_schedule( + self, + *, + project_id: UUID, + # + schedule_id: UUID, + ) -> Optional[TriggerSchedule]: + return await self.dao.fetch_schedule( + project_id=project_id, + schedule_id=schedule_id, + ) + + async def query_schedules( + self, + *, + project_id: UUID, + # + schedule: Optional[TriggerScheduleQuery] = None, + # + windowing: Optional[Windowing] = None, + ) -> List[TriggerSchedule]: + return await self.dao.query_schedules( + project_id=project_id, + schedule=schedule, + windowing=windowing, + ) + + async def edit_schedule( + self, + *, + project_id: UUID, + user_id: UUID, + # + schedule: TriggerScheduleEdit, + ) -> Optional[TriggerSchedule]: + """Full-PUT edit (load the current row, override owned fields).""" + existing = await self.dao.fetch_schedule( + project_id=project_id, + schedule_id=schedule.id, + ) + if existing is None: + return None + + self._validate_schedule(schedule.data.schedule) + + await self._normalize_references( + project_id=project_id, + references=schedule.data.references, + ) + + return await self.dao.edit_schedule( + project_id=project_id, + user_id=user_id, + schedule=schedule, + ) + + async def delete_schedule( + self, + *, + project_id: UUID, + # + schedule_id: UUID, + ) -> bool: + return await self.dao.delete_schedule( + project_id=project_id, + schedule_id=schedule_id, + ) + + async def set_schedule_active( + self, + *, + project_id: UUID, + user_id: UUID, + # + schedule_id: UUID, + is_active: bool, + ) -> TriggerSchedule: + """Full-PUT play/pause toggle; touches only flags.is_active.""" + existing = await self.dao.fetch_schedule( + project_id=project_id, + schedule_id=schedule_id, + ) + if existing is None: + raise ScheduleNotFoundError(schedule_id=str(schedule_id)) + + edit = TriggerScheduleEdit( + id=existing.id, + name=existing.name, + description=existing.description, + tags=existing.tags, + meta=existing.meta, + data=existing.data, + flags=existing.flags.model_copy(update={"is_active": is_active}), + ) + + updated = await self.dao.edit_schedule( + project_id=project_id, + user_id=user_id, + schedule=edit, + ) + + return updated or existing + + async def refresh_schedules( + self, + *, + timestamp: datetime, + interval: int, + ) -> bool: + """Fire every active schedule whose cron matches this tick. + + Mirrors live-eval ``refresh_runs``: point-in-time ``croniter.match`` gate, + deterministic ``event_id`` per (schedule, tick) for dedup, enqueue onto the + schedule dispatch task. + """ + log.info( + f"[SCHEDULE] Refreshing schedules at {timestamp} every {interval} minute(s)" + ) + + if not timestamp: + return False + + try: + schedules = await self.dao.fetch_active_schedules_with_project() + except Exception as e: # pylint: disable=broad-exception-caught + log.error(f"[SCHEDULE] Error fetching active schedules: {e}", exc_info=True) + return False + + if self.schedule_dispatch_task is None: + log.warning( + "[SCHEDULE] Taskiq client is not configured; skipping schedule dispatch" + ) + return False + + failures = 0 + for project_id, schedule in schedules: + try: + if not croniter.match(schedule.data.schedule, timestamp): + continue + + event_id = f"{schedule.id}:{timestamp.isoformat()}" + + already_seen = await self.dao.dedup_seen_schedule( + project_id=project_id, + schedule_id=schedule.id, + event_id=event_id, + ) + if already_seen: + continue + + event = { + "metadata": { + "trigger_slug": schedule.data.event_key, + "id": event_id, + }, + "payload": {"timestamp": timestamp.isoformat()}, + } + + log.info( + "[SCHEDULE] Dispatching...", + project_id=project_id, + schedule_id=schedule.id, + timestamp=timestamp, + ) + + await asyncio.wait_for( + self.schedule_dispatch_task.kiq( + project_id=str(project_id), + event_id=event_id, + event=event, + schedule=schedule.model_dump(mode="json"), + ), + timeout=_ENQUEUE_TIMEOUT_SECONDS, + ) + + log.info( + "[SCHEDULE] Dispatched. ", + project_id=project_id, + schedule_id=schedule.id, + ) + + except Exception as e: # pylint: disable=broad-exception-caught + failures += 1 + log.error( + f"[SCHEDULE] Error refreshing schedule {schedule.id}: {e}", + exc_info=True, + ) + + # Report failure if any schedule dropped, so the cron/admin caller can + # surface a non-200 instead of seeing a false success. + return failures == 0 + + # ----------------------------------------------------------------------- + # 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, + ) + + # ----------------------------------------------------------------------- + # 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}``. + + Confirmed against live events: Composio sends a lowercase hex digest. + + 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 "" + # Byte-exact signing input: avoids the lossy utf-8 decode for non-utf-8 bodies. + signed_bytes = f"{webhook_id}.{timestamp}.".encode("utf-8") + body + 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_bytes, hashlib.sha256 + ).hexdigest() + + if hmac.compare_digest(expected, provided): + return True + + log.warning("[TRIGGER SIGNATURE] no match webhook_id=%s", webhook_id) + 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/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/core/webhooks/service.py b/api/oss/src/core/webhooks/service.py index 759423eb6a..3f319d1aaa 100644 --- a/api/oss/src/core/webhooks/service.py +++ b/api/oss/src/core/webhooks/service.py @@ -450,6 +450,55 @@ async def delete_subscription( subscription_id=subscription_id, ) + async def set_subscription_active( + self, + *, + project_id: UUID, + user_id: UUID, + # + subscription_id: UUID, + is_active: bool, + ) -> Optional[WebhookSubscription]: + """Full-PUT toggle of the play/pause switch; touches only is_active.""" + existing = await self.dao.fetch_subscription( + project_id=project_id, + subscription_id=subscription_id, + ) + + if existing is None: + return None + + edit = WebhookSubscriptionEdit( + id=existing.id, + name=existing.name, + description=existing.description, + tags=existing.tags, + meta=existing.meta, + data=existing.data, + flags=existing.flags.model_copy(update={"is_active": is_active}), + ) + + result = await self.dao.edit_subscription( + project_id=project_id, + user_id=user_id, + subscription=edit, + ) + + if result is None: + return None + + if result.secret_id: + secret_value = await self._resolve_secret( + project_id=project_id, + secret_id=result.secret_id, + ) + result = self._with_secret( + subscription=result, + secret=secret_value, + ) + + return result + # --- deliveries --------------------------------------------------------- # async def fetch_delivery( diff --git a/api/oss/src/core/webhooks/types.py b/api/oss/src/core/webhooks/types.py index ee2027fd2f..8dca2fc39c 100644 --- a/api/oss/src/core/webhooks/types.py +++ b/api/oss/src/core/webhooks/types.py @@ -2,7 +2,7 @@ from typing import Any, Dict, List, Literal, Optional from uuid import UUID -from pydantic import BaseModel, HttpUrl +from pydantic import BaseModel, Field, HttpUrl from oss.src.core.events.types import EventType from oss.src.core.shared.dtos import ( @@ -113,6 +113,11 @@ def values(cls) -> List[str]: # --- WEBHOOK SUBSCRIPTIONS -------------------------------------------------- # +class WebhookSubscriptionFlags(BaseModel): + # No is_valid: a webhook has no external connection validity concept. + is_active: bool = True + + class WebhookSubscriptionData(BaseModel): url: HttpUrl headers: Optional[Dict[str, str]] = None @@ -125,6 +130,8 @@ class WebhookSubscriptionData(BaseModel): class WebhookSubscription(Identifier, Lifecycle, Header, Metadata): data: WebhookSubscriptionData + flags: WebhookSubscriptionFlags = Field(default_factory=WebhookSubscriptionFlags) + secret_id: Optional[UUID] = None secret: Optional[str] = None @@ -138,6 +145,8 @@ class WebhookSubscriptionCreate(Header, Metadata): class WebhookSubscriptionEdit(Identifier, Lifecycle, Header, Metadata): data: WebhookSubscriptionData + flags: WebhookSubscriptionFlags = Field(default_factory=WebhookSubscriptionFlags) + secret: Optional[str] = None diff --git a/api/oss/src/crons/queries.sh b/api/oss/src/crons/queries.sh index 689adccc97..f43fcdab13 100644 --- a/api/oss/src/crons/queries.sh +++ b/api/oss/src/crons/queries.sh @@ -5,6 +5,7 @@ AGENTA_AUTH_KEY="${AGENTA_AUTH_KEY:-replace-me}" TRIGGER_INTERVAL=$(awk '/queries\.sh/ {split($1, a, "/"); print (a[2] ? a[2] : 1); exit}' /app/crontab) NOW_UTC=$(date -u "+%Y-%m-%dT%H:%M:00Z") MINUTE=$(date -u "+%M" | sed 's/^0*//') +MINUTE="${MINUTE:-0}" ROUNDED_MINUTE=$(( (MINUTE / TRIGGER_INTERVAL) * TRIGGER_INTERVAL )) TRIGGER_DATETIME=$(date -u "+%Y-%m-%dT%H") TRIGGER_DATETIME="${TRIGGER_DATETIME}:$(printf "%02d" $ROUNDED_MINUTE):00Z" @@ -13,12 +14,35 @@ TRIGGER_DATETIME="${TRIGGER_DATETIME}:$(printf "%02d" $ROUNDED_MINUTE):00Z" echo "--------------------------------------------------------" echo "[$(date)] queries.sh running from cron" -# Make POST request, show status and response -curl \ +# Make POST request with bounded timeouts; decode curl/HTTP failures instead of +# masking them (mirrors api/ee/src/crons/{meters,events,spans}.sh). +RESPONSE=$(curl \ + --max-time 30 \ + --connect-timeout 10 \ -s \ -w "\nHTTP_STATUS:%{http_code}\n" \ -X POST \ -H "Authorization: Access ${AGENTA_AUTH_KEY}" \ - "http://api:8000/admin/evaluations/runs/refresh?trigger_interval=${TRIGGER_INTERVAL}&trigger_datetime=${TRIGGER_DATETIME}" || echo "❌ CURL failed" + "http://api:8000/admin/evaluations/runs/refresh?trigger_interval=${TRIGGER_INTERVAL}&trigger_datetime=${TRIGGER_DATETIME}" 2>&1) || CURL_EXIT=$? + +if [ -n "${CURL_EXIT:-}" ]; then + echo "❌ CURL failed with exit code: ${CURL_EXIT}" + case ${CURL_EXIT} in + 6) echo " Could not resolve host" ;; + 7) echo " Failed to connect to host" ;; + 28) echo " Operation timeout (exceeded 30s)" ;; + 52) echo " Empty reply from server" ;; + 56) echo " Failure in receiving network data" ;; + *) echo " Unknown curl error" ;; + esac +else + echo "${RESPONSE}" + HTTP_CODE=$(echo "${RESPONSE}" | grep "HTTP_STATUS:" | cut -d: -f2) + if [ "${HTTP_CODE}" = "200" ]; then + echo "✅ Runs refresh completed successfully" + else + echo "❌ Runs refresh failed with HTTP ${HTTP_CODE}" + fi +fi echo "[$(date)] queries.sh done" diff --git a/api/oss/src/crons/triggers.sh b/api/oss/src/crons/triggers.sh new file mode 100644 index 0000000000..7cc466f125 --- /dev/null +++ b/api/oss/src/crons/triggers.sh @@ -0,0 +1,48 @@ +#!/bin/sh +set -eu + +AGENTA_AUTH_KEY="${AGENTA_AUTH_KEY:-replace-me}" +TRIGGER_INTERVAL=$(awk '/triggers\.sh/ {split($1, a, "/"); print (a[2] ? a[2] : 1); exit}' /app/crontab) +NOW_UTC=$(date -u "+%Y-%m-%dT%H:%M:00Z") +MINUTE=$(date -u "+%M" | sed 's/^0*//') +MINUTE="${MINUTE:-0}" +ROUNDED_MINUTE=$(( (MINUTE / TRIGGER_INTERVAL) * TRIGGER_INTERVAL )) +TRIGGER_DATETIME=$(date -u "+%Y-%m-%dT%H") +TRIGGER_DATETIME="${TRIGGER_DATETIME}:$(printf "%02d" $ROUNDED_MINUTE):00Z" + + +echo "--------------------------------------------------------" +echo "[$(date)] triggers.sh running from cron" + +# Make POST request with bounded timeouts; decode curl/HTTP failures instead of +# masking them (mirrors api/ee/src/crons/{meters,events,spans}.sh). +RESPONSE=$(curl \ + --max-time 30 \ + --connect-timeout 10 \ + -s \ + -w "\nHTTP_STATUS:%{http_code}\n" \ + -X POST \ + -H "Authorization: Access ${AGENTA_AUTH_KEY}" \ + "http://api:8000/admin/triggers/schedules/refresh?trigger_interval=${TRIGGER_INTERVAL}&trigger_datetime=${TRIGGER_DATETIME}" 2>&1) || CURL_EXIT=$? + +if [ -n "${CURL_EXIT:-}" ]; then + echo "❌ CURL failed with exit code: ${CURL_EXIT}" + case ${CURL_EXIT} in + 6) echo " Could not resolve host" ;; + 7) echo " Failed to connect to host" ;; + 28) echo " Operation timeout (exceeded 30s)" ;; + 52) echo " Empty reply from server" ;; + 56) echo " Failure in receiving network data" ;; + *) echo " Unknown curl error" ;; + esac +else + echo "${RESPONSE}" + HTTP_CODE=$(echo "${RESPONSE}" | grep "HTTP_STATUS:" | cut -d: -f2) + if [ "${HTTP_CODE}" = "200" ]; then + echo "✅ Schedule refresh completed successfully" + else + echo "❌ Schedule refresh failed with HTTP ${HTTP_CODE}" + fi +fi + +echo "[$(date)] triggers.sh done" diff --git a/api/oss/src/crons/triggers.txt b/api/oss/src/crons/triggers.txt new file mode 100644 index 0000000000..e31a800d54 --- /dev/null +++ b/api/oss/src/crons/triggers.txt @@ -0,0 +1 @@ +* * * * * root sh /triggers.sh >> /proc/1/fd/1 2>&1 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 71% rename from api/oss/src/dbs/postgres/tools/dao.py rename to api/oss/src/dbs/postgres/gateway/connections/dao.py index c3cefe279c..77f21cb8ce 100644 --- a/api/oss/src/dbs/postgres/tools/dao.py +++ b/api/oss/src/dbs/postgres/gateway/connections/dao.py @@ -1,282 +1,284 @@ -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, + *, + project_id: UUID, + provider_connection_id: str, + ) -> 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.project_id == project_id, + 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 + + 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, + *, + project_id: UUID, + provider_connection_id: str, + ) -> Optional[Connection]: + """Lookup a connection by provider-side connected_account_id within a project.""" + async with self.engine.session() as session: + stmt = ( + select(self.ConnectionDBE) + .filter( + self.ConnectionDBE.project_id == project_id, + 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..dc8d645e66 --- /dev/null +++ b/api/oss/src/dbs/postgres/triggers/dao.py @@ -0,0 +1,637 @@ +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, + TriggerSchedule, + TriggerScheduleCreate, + TriggerScheduleEdit, + TriggerScheduleQuery, + 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, + TriggerScheduleDBE, + TriggerSubscriptionDBE, +) +from oss.src.dbs.postgres.triggers.mappings import ( + map_delivery_dbe_to_dto, + map_delivery_dto_to_dbe_create, + map_schedule_dbe_to_dto, + map_schedule_dto_to_dbe_create, + map_schedule_dto_to_dbe_edit, + 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, + # + trigger_id: str, + ) -> TriggerSubscription: + subscription_dbe = map_subscription_dto_to_dbe_create( + project_id=project_id, + user_id=user_id, + # + subscription=subscription, + # + trigger_id=trigger_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_project_and_subscription_by_trigger_id( + self, + *, + trigger_id: str, + ) -> Optional[Tuple[UUID, TriggerSubscription]]: + # Deliberately unscoped: inbound Composio events carry only the provider + # trigger_id (ti_*) and no tenant scope, so this recovers project_id from + # it. The one sanctioned cross-project read (trigger_id is partial-unique). + async with self.engine.session() as session: + stmt = ( + select(TriggerSubscriptionDBE) + .filter( + TriggerSubscriptionDBE.trigger_id == trigger_id, + TriggerSubscriptionDBE.deleted_at.is_(None), + ) + .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, + ) + + by_schedule = delivery.subscription_id is None + + index_elements = ( + ["project_id", "schedule_id", "event_id"] + if by_schedule + else ["project_id", "subscription_id", "event_id"] + ) + index_where = ( + TriggerDeliveryDBE.schedule_id.isnot(None) + if by_schedule + else TriggerDeliveryDBE.subscription_id.isnot(None) + ) + + 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=index_elements, + index_where=index_where, + 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.schedule_id == delivery.schedule_id + if by_schedule + else 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.schedule_id is not None: + stmt = stmt.filter( + TriggerDeliveryDBE.schedule_id == delivery.schedule_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 + + async def dedup_seen_schedule( + self, + *, + project_id: UUID, + schedule_id: UUID, + event_id: str, + ) -> bool: + async with self.engine.session() as session: + stmt = ( + select(TriggerDeliveryDBE.id) + .where( + TriggerDeliveryDBE.project_id == project_id, + TriggerDeliveryDBE.schedule_id == schedule_id, + TriggerDeliveryDBE.event_id == event_id, + ) + .limit(1) + ) + + result = await session.execute(stmt) + + return result.scalar_one_or_none() is not None + + # --- SCHEDULES ---------------------------------------------------------- # + + async def create_schedule( + self, + *, + project_id: UUID, + user_id: UUID, + # + schedule: TriggerScheduleCreate, + ) -> TriggerSchedule: + schedule_dbe = map_schedule_dto_to_dbe_create( + project_id=project_id, + user_id=user_id, + # + schedule=schedule, + ) + + async with self.engine.session() as session: + session.add(schedule_dbe) + + await session.commit() + + await session.refresh(schedule_dbe) + + return map_schedule_dbe_to_dto( + schedule_dbe=schedule_dbe, + ) + + async def fetch_schedule( + self, + *, + project_id: UUID, + # + schedule_id: UUID, + ) -> Optional[TriggerSchedule]: + async with self.engine.session() as session: + stmt = select(TriggerScheduleDBE).where( + TriggerScheduleDBE.project_id == project_id, + TriggerScheduleDBE.id == schedule_id, + ) + + result = await session.execute(stmt) + + schedule_dbe = result.scalar_one_or_none() + + if not schedule_dbe: + return None + + return map_schedule_dbe_to_dto( + schedule_dbe=schedule_dbe, + ) + + async def edit_schedule( + self, + *, + project_id: UUID, + user_id: UUID, + # + schedule: TriggerScheduleEdit, + ) -> Optional[TriggerSchedule]: + async with self.engine.session() as session: + stmt = select(TriggerScheduleDBE).where( + TriggerScheduleDBE.id == schedule.id, + TriggerScheduleDBE.project_id == project_id, + ) + + result = await session.execute(stmt) + + schedule_dbe = result.scalar_one_or_none() + + if not schedule_dbe: + return None + + map_schedule_dto_to_dbe_edit( + schedule_dbe=schedule_dbe, + # + user_id=user_id, + # + schedule=schedule, + ) + + await session.commit() + + await session.refresh(schedule_dbe) + + return map_schedule_dbe_to_dto( + schedule_dbe=schedule_dbe, + ) + + async def delete_schedule( + self, + *, + project_id: UUID, + # + schedule_id: UUID, + ) -> bool: + async with self.engine.session() as session: + stmt = select(TriggerScheduleDBE).where( + TriggerScheduleDBE.project_id == project_id, + TriggerScheduleDBE.id == schedule_id, + ) + + result = await session.execute(stmt) + + schedule_dbe = result.scalar_one_or_none() + + if not schedule_dbe: + return False + + await session.delete(schedule_dbe) + + await session.commit() + + return True + + async def query_schedules( + self, + *, + project_id: UUID, + # + schedule: Optional[TriggerScheduleQuery] = None, + # + windowing: Optional[Windowing] = None, + ) -> List[TriggerSchedule]: + async with self.engine.session() as session: + stmt = select(TriggerScheduleDBE).filter( + TriggerScheduleDBE.project_id == project_id, + ) + + if schedule: + if schedule.name is not None: + stmt = stmt.filter( + TriggerScheduleDBE.name.ilike(f"%{schedule.name}%"), + ) + + if schedule.event_key is not None: + stmt = stmt.filter( + TriggerScheduleDBE.data["event_key"].astext + == schedule.event_key, + ) + + if windowing: + stmt = apply_windowing( + stmt=stmt, + DBE=TriggerScheduleDBE, + attribute="id", + order="descending", + windowing=windowing, + ) + + result = await session.execute(stmt) + + return [ + map_schedule_dbe_to_dto(schedule_dbe=dbe) + for dbe in result.scalars().all() + ] + + async def fetch_active_schedules( + self, + *, + project_id: Optional[UUID] = None, + ) -> List[TriggerSchedule]: + async with self.engine.session() as session: + stmt = select(TriggerScheduleDBE).where( + TriggerScheduleDBE.flags["is_active"].astext == "true", + TriggerScheduleDBE.deleted_at.is_(None), + ) + + if project_id is not None: + stmt = stmt.where( + TriggerScheduleDBE.project_id == project_id, + ) + + result = await session.execute(stmt) + + return [ + map_schedule_dbe_to_dto(schedule_dbe=dbe) + for dbe in result.scalars().all() + ] + + async def fetch_active_schedules_with_project( + self, + *, + project_id: Optional[UUID] = None, + ) -> List[Tuple[UUID, TriggerSchedule]]: + async with self.engine.session() as session: + stmt = select(TriggerScheduleDBE).where( + TriggerScheduleDBE.flags["is_active"].astext == "true", + TriggerScheduleDBE.deleted_at.is_(None), + ) + + if project_id is not None: + stmt = stmt.where( + TriggerScheduleDBE.project_id == project_id, + ) + + result = await session.execute(stmt) + + return [ + (dbe.project_id, map_schedule_dbe_to_dto(schedule_dbe=dbe)) + for dbe in result.scalars().all() + ] 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..42723ef07a --- /dev/null +++ b/api/oss/src/dbs/postgres/triggers/dbas.py @@ -0,0 +1,76 @@ +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, + ) + + trigger_id = Column( + String, + nullable=True, + ) + + +class TriggerScheduleDBA( + ProjectScopeDBA, + LifecycleDBA, + IdentifierDBA, + HeaderDBA, + DataDBA, + FlagsDBA, + TagsDBA, + MetaDBA, +): + __abstract__ = True + + +class TriggerDeliveryDBA( + ProjectScopeDBA, + LifecycleDBA, + IdentifierDBA, + StatusDBA, + DataDBA, +): + __abstract__ = True + + subscription_id = Column( + UUID(as_uuid=True), + nullable=True, + ) + + schedule_id = Column( + UUID(as_uuid=True), + nullable=True, + ) + + # provider metadata.id — an arbitrary provider string, unique per parent + 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..1f2223656b --- /dev/null +++ b/api/oss/src/dbs/postgres/triggers/dbes.py @@ -0,0 +1,137 @@ +from sqlalchemy import ( + CheckConstraint, + ForeignKeyConstraint, + Index, + PrimaryKeyConstraint, + text, +) + +from oss.src.dbs.postgres.shared.base import Base +from oss.src.dbs.postgres.triggers.dbas import ( + TriggerDeliveryDBA, + TriggerScheduleDBA, + 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", + ), + Index( + "ix_trigger_subscriptions_trigger_id", + "project_id", + "trigger_id", + unique=True, + postgresql_where=text("trigger_id IS NOT NULL AND deleted_at IS NULL"), + ), + ) + + +class TriggerScheduleDBE(Base, TriggerScheduleDBA): + __tablename__ = "trigger_schedules" + + __table_args__ = ( + ForeignKeyConstraint( + ["project_id"], + ["projects.id"], + ondelete="CASCADE", + ), + PrimaryKeyConstraint("project_id", "id"), + Index( + "ix_trigger_schedules_project_id_created_at", + "project_id", + "created_at", + ), + Index( + "ix_trigger_schedules_project_id_deleted_at", + "project_id", + "deleted_at", + ), + Index( + "ix_trigger_schedules_active", + "project_id", + postgresql_where=text( + "(flags ->> 'is_active') = 'true' AND deleted_at IS NULL" + ), + ), + ) + + +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", + ), + ForeignKeyConstraint( + ["project_id", "schedule_id"], + ["trigger_schedules.project_id", "trigger_schedules.id"], + ondelete="CASCADE", + ), + CheckConstraint( + "(subscription_id IS NULL) <> (schedule_id IS NULL)", + name="ck_trigger_deliveries_exactly_one_parent", + ), + 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, + postgresql_where=text("subscription_id IS NOT NULL"), + ), + Index( + "ix_trigger_deliveries_schedule_id_event_id", + "project_id", + "schedule_id", + "event_id", + unique=True, + postgresql_where=text("schedule_id IS NOT NULL"), + ), + ) 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..5b695799ac --- /dev/null +++ b/api/oss/src/dbs/postgres/triggers/mappings.py @@ -0,0 +1,243 @@ +from uuid import UUID + +from oss.src.core.shared.dtos import Status +from oss.src.core.triggers.dtos import ( + TriggerDelivery, + TriggerDeliveryCreate, + TriggerDeliveryData, + TriggerSchedule, + TriggerScheduleCreate, + TriggerScheduleData, + TriggerScheduleEdit, + TriggerScheduleFlags, + TriggerSubscription, + TriggerSubscriptionCreate, + TriggerSubscriptionData, + TriggerSubscriptionEdit, + TriggerSubscriptionFlags, +) + +from oss.src.dbs.postgres.triggers.dbes import ( + TriggerDeliveryDBE, + TriggerScheduleDBE, + TriggerSubscriptionDBE, +) + + +# --- Subscription ----------------------------------------------------------- # + + +def map_subscription_dto_to_dbe_create( + *, + project_id: UUID, + user_id: UUID, + # + subscription: TriggerSubscriptionCreate, + # + trigger_id: str, +) -> TriggerSubscriptionDBE: + return TriggerSubscriptionDBE( + project_id=project_id, + # + created_by_id=user_id, + # + connection_id=subscription.connection_id, + trigger_id=trigger_id, + # + name=subscription.name, + description=subscription.description, + tags=subscription.tags, + meta=subscription.meta, + # + flags=TriggerSubscriptionFlags().model_dump(), + # + data=subscription.data.model_dump(mode="json", exclude_none=True), + ) + + +def map_subscription_dbe_to_dto( + *, + subscription_dbe: TriggerSubscriptionDBE, +) -> TriggerSubscription: + 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, + trigger_id=subscription_dbe.trigger_id, + # + name=subscription_dbe.name, + description=subscription_dbe.description, + # + tags=subscription_dbe.tags, + meta=subscription_dbe.meta, + # + data=TriggerSubscriptionData.model_validate(subscription_dbe.data), + # + flags=TriggerSubscriptionFlags(**(subscription_dbe.flags or {})), + ) + + +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 + + subscription_dbe.data = subscription.data.model_dump(mode="json", exclude_none=True) + + subscription_dbe.flags = subscription.flags.model_dump() + + +# --- Schedule --------------------------------------------------------------- # + + +def map_schedule_dto_to_dbe_create( + *, + project_id: UUID, + user_id: UUID, + # + schedule: TriggerScheduleCreate, +) -> TriggerScheduleDBE: + return TriggerScheduleDBE( + project_id=project_id, + # + created_by_id=user_id, + # + name=schedule.name, + description=schedule.description, + tags=schedule.tags, + meta=schedule.meta, + # + flags=TriggerScheduleFlags().model_dump(), + # + data=schedule.data.model_dump(mode="json", exclude_none=True), + ) + + +def map_schedule_dbe_to_dto( + *, + schedule_dbe: TriggerScheduleDBE, +) -> TriggerSchedule: + return TriggerSchedule( + id=schedule_dbe.id, + # + created_at=schedule_dbe.created_at, + updated_at=schedule_dbe.updated_at, + deleted_at=schedule_dbe.deleted_at, + created_by_id=schedule_dbe.created_by_id, + updated_by_id=schedule_dbe.updated_by_id, + deleted_by_id=schedule_dbe.deleted_by_id, + # + name=schedule_dbe.name, + description=schedule_dbe.description, + # + tags=schedule_dbe.tags, + meta=schedule_dbe.meta, + # + data=TriggerScheduleData.model_validate(schedule_dbe.data), + # + flags=TriggerScheduleFlags(**(schedule_dbe.flags or {})), + ) + + +def map_schedule_dto_to_dbe_edit( + *, + schedule_dbe: TriggerScheduleDBE, + # + user_id: UUID, + # + schedule: TriggerScheduleEdit, +) -> None: + schedule_dbe.updated_by_id = user_id + + schedule_dbe.name = schedule.name + schedule_dbe.description = schedule.description + + schedule_dbe.tags = schedule.tags + schedule_dbe.meta = schedule.meta + + schedule_dbe.data = schedule.data.model_dump(mode="json", exclude_none=True) + + schedule_dbe.flags = schedule.flags.model_dump() + + +# --- 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, + schedule_id=delivery.schedule_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, + schedule_id=delivery_dbe.schedule_id, + # + event_id=delivery_dbe.event_id, + ) diff --git a/api/oss/src/dbs/postgres/webhooks/mappings.py b/api/oss/src/dbs/postgres/webhooks/mappings.py index 30621cd119..afcca58b75 100644 --- a/api/oss/src/dbs/postgres/webhooks/mappings.py +++ b/api/oss/src/dbs/postgres/webhooks/mappings.py @@ -9,6 +9,7 @@ WebhookSubscriptionCreate, WebhookSubscriptionData, WebhookSubscriptionEdit, + WebhookSubscriptionFlags, ) from oss.src.dbs.postgres.webhooks.dbes import ( @@ -46,6 +47,8 @@ def map_subscription_dto_to_dbe_create( if subscription.data else None, # + flags=WebhookSubscriptionFlags().model_dump(), + # secret_id=secret_id, ) @@ -74,6 +77,8 @@ def map_subscription_dbe_to_dto( if subscription_dbe.data else None, # + flags=WebhookSubscriptionFlags(**(subscription_dbe.flags or {})), + # secret_id=subscription_dbe.secret_id, ) @@ -105,6 +110,8 @@ def map_subscription_dto_to_dbe_edit( else None ) + subscription_dbe.flags = subscription.flags.model_dump() + if secret_id is not None: subscription_dbe.secret_id = secret_id diff --git a/api/oss/src/middlewares/auth.py b/api/oss/src/middlewares/auth.py index 1cf4ab698b..8ef34f6c5e 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..19323b8502 --- /dev/null +++ b/api/oss/src/tasks/asyncio/triggers/dispatcher.py @@ -0,0 +1,329 @@ +"""Trigger dispatcher — asyncio side of the inbound pipeline. + +Entity-agnostic: ``dispatch`` runs one already-resolved entity (a +``TriggerSubscription`` from the Composio path, or a ``TriggerSchedule`` from the +cron path) against its bound workflow, dedups on ``event_id``, maps +``inputs_fields`` into the workflow inputs, and records one delivery row. The +``ti_*`` → subscription lookup lives in the worker, not here. + +Self-contained so it can run inside its own TaskIQ worker process. +""" + +from datetime import datetime, timezone +from typing import Any, Dict, Optional, Union +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_CONTEXT_FIELDS, + SUBSCRIPTION_CONTEXT_FIELDS, + TriggerDeliveryCreate, + TriggerDeliveryData, + TriggerSchedule, + 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], + entity: Union[TriggerSubscription, TriggerSchedule], + project_id: UUID, + ) -> Dict[str, Any]: + sub_dump = entity.model_dump(mode="json", exclude_none=True) + metadata = event.get("metadata") or {} + now = datetime.now(timezone.utc).isoformat() + normalized = { + "event_id": metadata.get("id"), + "event_type": metadata.get("trigger_slug"), + "timestamp": now, + "created_at": now, + "attributes": event.get("payload"), + } + return { + "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 + }, + "scope": {"project_id": str(project_id)}, + } + + async def dispatch_subscription( + self, + *, + project_id: UUID, + subscription: TriggerSubscription, + event_id: str, + event: Dict[str, Any], + ) -> None: + """Dispatch an inbound provider event for one subscription. + + Subscription-only gates (dedup on the provider event_id, is_valid → + failed delivery) run here, then the path converges on ``_run``. + """ + if not subscription.flags.is_active: + log.info( + "[TRIGGERS DISPATCHER] Subscription %s inactive — 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 + + # is_valid is NOT a silent skip: write a failed delivery so the user sees + # why nothing ran, and never invoke the workflow. + if not subscription.flags.is_valid: + log.info( + "[TRIGGERS DISPATCHER] Subscription %s is invalid — failed delivery", + subscription.id, + ) + await self._write_delivery( + project_id=project_id, + user_id=subscription.created_by_id, + delivery_id=uuid_compat.uuid7(), + subscription_id=subscription.id, + schedule_id=None, + event_id=event_id, + status=Status(code="409", message="failed"), + data=TriggerDeliveryData( + event_key=subscription.data.event_key, + references=subscription.data.references, + error="Subscription is invalid (provider connection revoked or unsynced)", + ), + ) + return + + await self._run( + project_id=project_id, + entity=subscription, + event_id=event_id, + event=event, + ) + + async def dispatch_schedule( + self, + *, + project_id: UUID, + schedule: TriggerSchedule, + event_id: str, + event: Dict[str, Any], + ) -> None: + """Dispatch a cron tick for one schedule (no provider/dedup/validity gates).""" + if not schedule.flags.is_active: + log.info( + "[TRIGGERS DISPATCHER] Schedule %s inactive — skipping", + schedule.id, + ) + return + + await self._run( + project_id=project_id, + entity=schedule, + event_id=event_id, + event=event, + ) + + async def _run( + self, + *, + project_id: UUID, + entity: Union[TriggerSubscription, TriggerSchedule], + event_id: str, + event: Dict[str, Any], + ) -> None: + """Shared path once a subscription/schedule is cleared to fire: resolve + inputs + references, invoke the bound workflow, and record the delivery.""" + is_subscription = isinstance(entity, TriggerSubscription) + subscription_id = entity.id if is_subscription else None + schedule_id = None if is_subscription else entity.id + + context = self._build_context( + event=event, + entity=entity, + project_id=project_id, + ) + + template = entity.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 entity.data.references.items() + } + if entity.data.references + else None + ) + selector = ( + entity.data.selector.model_dump(mode="json", exclude_none=True) + if entity.data.selector + else None + ) + + delivery_id = uuid_compat.uuid7() + user_id = entity.created_by_id + + delivery_data = TriggerDeliveryData( + event_key=entity.data.event_key, + references=entity.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, + schedule_id=schedule_id, + event_id=event_id, + status=Status(code="400", message="failed"), + data=delivery_data.model_copy( + update={"error": "Entity 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: + log.error("[TRIGGERS DISPATCHER] invoke failed: %s", e, exc_info=True) + await self._write_delivery( + project_id=project_id, + user_id=user_id, + delivery_id=delivery_id, + subscription_id=subscription_id, + schedule_id=schedule_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, + schedule_id=schedule_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, + schedule_id=schedule_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, + } + } + ), + ) + log.info( + "[TRIGGERS DISPATCHER] dispatch complete entity=%s event=%s status=200", + entity.id, + event_id, + ) + + async def _write_delivery( + self, + *, + project_id: UUID, + user_id: Optional[UUID], + delivery_id: UUID, + subscription_id: Optional[UUID], + schedule_id: Optional[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, + schedule_id=schedule_id, + event_id=event_id, + status=status, + data=data, + ), + ) diff --git a/api/oss/src/tasks/asyncio/webhooks/dispatcher.py b/api/oss/src/tasks/asyncio/webhooks/dispatcher.py index c0ff2570fc..70d080a98d 100644 --- a/api/oss/src/tasks/asyncio/webhooks/dispatcher.py +++ b/api/oss/src/tasks/asyncio/webhooks/dispatcher.py @@ -8,6 +8,7 @@ its own consumer process later without changing its internal logic. """ +import asyncio from typing import Any, Dict, List, Optional from uuid import UUID @@ -27,6 +28,8 @@ log = get_module_logger(__name__) +_ENQUEUE_TIMEOUT_SECONDS = 5.0 + class WebhooksDispatcher: """Dispatches webhook delivery tasks for a batch of ingested events. @@ -249,6 +252,14 @@ async def dispatch( ) for sub in matching: + flags = getattr(sub, "flags", None) + if flags is not None and not getattr(flags, "is_active", True): + log.info( + f"[WEBHOOKS DISPATCHER] Skipping subscription {sub.id} " + f"— inactive" + ) + continue + if not sub.secret: log.warning( f"[WEBHOOKS DISPATCHER] Skipping subscription {sub.id} " @@ -259,32 +270,35 @@ async def dispatch( try: delivery_id = uuid_compat.uuid7() - await self.deliver_task.kiq( - project_id=str(project_id), - # - delivery_id=str(delivery_id), - # - subscription_id=str(sub.id), - event_id=str(event.event_id), - # - url=str(sub.data.url), - headers=sub.data.headers or {}, - payload_fields=sub.data.payload_fields, - auth_mode=sub.data.auth_mode, - # - event_type=event_type, - # - subscription=sub.model_dump( - mode="json", - exclude_none=True, - exclude={"secret", "secret_id"}, - ), - event=event.model_dump( - mode="json", - exclude_none=True, + await asyncio.wait_for( + self.deliver_task.kiq( + project_id=str(project_id), + # + delivery_id=str(delivery_id), + # + subscription_id=str(sub.id), + event_id=str(event.event_id), + # + url=str(sub.data.url), + headers=sub.data.headers or {}, + payload_fields=sub.data.payload_fields, + auth_mode=sub.data.auth_mode, + # + event_type=event_type, + # + subscription=sub.model_dump( + mode="json", + exclude_none=True, + exclude={"secret", "secret_id"}, + ), + event=event.model_dump( + mode="json", + exclude_none=True, + ), + # + encrypted_secret=encrypt(sub.secret), ), - # - encrypted_secret=encrypt(sub.secret), + timeout=_ENQUEUE_TIMEOUT_SECONDS, ) log.info( f"[WEBHOOKS DISPATCHER] Enqueued delivery " 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..33e0ab7e37 --- /dev/null +++ b/api/oss/src/tasks/taskiq/triggers/worker.py @@ -0,0 +1,114 @@ +from typing import Any, Dict +from uuid import UUID + +from taskiq import AsyncBroker, Context, TaskiqDepends + +from oss.src.core.triggers.dtos import TRIGGER_MAX_RETRIES, TriggerSchedule +from oss.src.core.triggers.interfaces import TriggersDAOInterface +from oss.src.tasks.asyncio.triggers.dispatcher import TriggersDispatcher +from oss.src.utils.env import env +from oss.src.utils.logging import get_module_logger + +log = get_module_logger(__name__) + + +class TriggersWorker: + """Registers and owns the TaskIQ trigger dispatch tasks. + + Schedules and Composio subscriptions use separate tasks because their entry + differs (DB lookup vs inline row); both converge on ``dispatcher.dispatch``. + """ + + def __init__( + self, + *, + broker: AsyncBroker, + dispatcher: TriggersDispatcher, + triggers_dao: TriggersDAOInterface, + ): + self.broker = broker + self.dispatcher = dispatcher + self.triggers_dao = triggers_dao + + 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}" + ) + + resolved = ( + await self.triggers_dao.get_project_and_subscription_by_trigger_id( + trigger_id=trigger_id, + ) + ) + + if resolved is None: + level = log.warning if env.composio.webhook_target else log.info + level( + "[TASK] triggers.dispatch Unknown trigger_id %s — skipping", + trigger_id, + ) + return + + project_id, subscription = resolved + + await self.dispatcher.dispatch_subscription( + project_id=project_id, + subscription=subscription, + event_id=event_id, + event=event, + ) + + self.dispatch_trigger = dispatch_trigger + + @self.broker.task( + task_name="triggers.dispatch_schedule", + retry_on_error=True, + max_retries=TRIGGER_MAX_RETRIES, + ) + async def dispatch_schedule( + *, + project_id: str, + event_id: str, + event: Dict[str, Any], + schedule: Dict[str, Any], + # + context: Context = TaskiqDepends(), + ) -> None: + entity = TriggerSchedule.model_validate(schedule) + + log.info( + f"[TASK] triggers.dispatch_schedule " + f"schedule={entity.id} event={event_id}" + ) + + await self.dispatcher.dispatch_schedule( + project_id=UUID(project_id), + schedule=entity, + event_id=event_id, + event=event, + ) + + self.dispatch_schedule = dispatch_schedule diff --git a/api/oss/src/utils/env.py b/api/oss/src/utils/env.py index 585386c33e..a0593d4355 100644 --- a/api/oss/src/utils/env.py +++ b/api/oss/src/utils/env.py @@ -510,6 +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") + # 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..9757bfcef0 --- /dev/null +++ b/api/oss/tests/manual/triggers/try_composio_triggers.py @@ -0,0 +1,401 @@ +#!/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:///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 + trigger_id = getattr(result, "trigger_id", None) or getattr(result, "id", None) + print(f" ✅ {intent} ({slug}) → {trigger_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:///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:///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") + if not url: + sys.exit( + "Set AGENTA_WEBHOOK_URL to a registration target for the convergence run" + ) + 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/tools/test_tools_connections.py b/api/oss/tests/pytest/acceptance/tools/test_tools_connections.py new file mode 100644 index 0000000000..5baaa7e26e --- /dev/null +++ b/api/oss/tests/pytest/acceptance/tools/test_tools_connections.py @@ -0,0 +1,72 @@ +"""Acceptance tests for the /tools/connections contract. + +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_connections.py b/api/oss/tests/pytest/acceptance/triggers/test_triggers_connections.py new file mode 100644 index 0000000000..0e8b74d745 --- /dev/null +++ b/api/oss/tests/pytest/acceptance/triggers/test_triggers_connections.py @@ -0,0 +1,143 @@ +"""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"] + + try: + 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 + finally: + 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"] + + try: + # 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 + finally: + 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"] + + try: + 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 + finally: + 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 new file mode 100644 index 0000000000..f0f79f812b --- /dev/null +++ b/api/oss/tests/pytest/acceptance/triggers/test_triggers_ingress.py @@ -0,0 +1,190 @@ +"""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 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. + +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")) +_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)", +) + +# 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)", +) + + +# --------------------------------------------------------------------------- +# Signature verification is unconditional — unsigned/forged events are rejected. +# Needs a resolvable webhook secret, which requires Composio enabled. +# --------------------------------------------------------------------------- + + +@_requires_composio +class TestTriggerIngressSignature: + def test_unsigned_event_is_rejected(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}, + "payload": {"repository": "acme/widgets"}, + }, + ) + assert response.status_code == 401, response.text + + 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 + + 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 +# delivery. Exercised end-to-end via a real subscription bound to a workflow. +# --------------------------------------------------------------------------- + + +@_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. + 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": {"owner": "acme", "repo": "widgets"}, + "inputs_fields": {"repo": "$.event.attributes.repository"}, + "references": {"workflow": {"slug": "triage"}}, + }, + } + }, + ) + assert create.status_code == 200, create.text + sub = create.json()["subscription"] + subscription_id = sub["id"] + trigger_id = sub["trigger_id"] + + event_id = uuid4().hex + envelope = { + "type": "github_star_added_event", + "metadata": {"trigger_id": trigger_id, "id": event_id}, + "payload": {"repository": "acme/widgets"}, + } + body = json.dumps(envelope).encode() + timestamp = "1700000000" + secret = _resolve_webhook_secret() + if not secret: + pytest.skip("no Composio webhook secret resolvable; signing would 401") + headers = { + "Content-Type": "application/json", + "webhook-id": event_id, + "webhook-timestamp": timestamp, + "webhook-signature": _sign(secret, event_id, timestamp, body), + } + + # Post the same signed event twice (provider redelivery) — dedup must hold. + for _ in range(2): + 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 + # 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_schedules.py b/api/oss/tests/pytest/acceptance/triggers/test_triggers_schedules.py new file mode 100644 index 0000000000..56799df22b --- /dev/null +++ b/api/oss/tests/pytest/acceptance/triggers/test_triggers_schedules.py @@ -0,0 +1,202 @@ +"""Acceptance tests for /triggers/schedules/*. + +Schedules are the cron-driven dual of subscriptions: a 5-field cron expression +fires the same dispatch path on each matching tick. Unlike subscriptions they +bind no provider connection, so the full create -> list -> stop/start -> edit -> +delete roundtrip runs without Composio credentials. + +Requires a running API. +""" + +from uuid import uuid4 + + +def _create_workflow(authed_api): + """Build a workflow + variant + committed revision; return its slug.""" + slug = f"sched-wf-{uuid4().hex[:8]}" + + wf = authed_api( + "POST", "/workflows/", json={"workflow": {"slug": slug, "name": slug}} + ) + assert wf.status_code == 200, wf.text + workflow_id = wf.json()["workflow"]["id"] + + variant = authed_api( + "POST", + "/workflows/variants/", + json={ + "workflow_variant": { + "slug": f"{slug}-v", + "name": "Default", + "workflow_id": workflow_id, + } + }, + ) + assert variant.status_code == 200, variant.text + variant_id = variant.json()["workflow_variant"]["id"] + + commit = authed_api( + "POST", + "/workflows/revisions/commit", + json={ + "workflow_revision": { + "slug": f"{slug}-v1", + "workflow_id": workflow_id, + "workflow_variant_id": variant_id, + "message": "initial", + } + }, + ) + assert commit.status_code == 200, commit.text + return slug + + +def _schedule_payload(*, name=None, schedule="*/5 * * * *", workflow_slug=None): + slug = uuid4().hex[:8] + data = { + "event_key": "cron.tick", + "schedule": schedule, + "inputs_fields": {"now": "$.event.timestamp"}, + } + if workflow_slug is not None: + data["references"] = {"workflow": {"slug": workflow_slug}} + return { + "schedule": { + "name": name or f"sched-{slug}", + "description": "Acceptance test schedule", + "data": data, + } + } + + +# --------------------------------------------------------------------------- +# DB-only reads, queries, 404s +# --------------------------------------------------------------------------- + + +class TestTriggerSchedulesReads: + def test_list_schedules_returns_200_empty(self, authed_api): + response = authed_api("GET", "/triggers/schedules") + assert response.status_code == 200, response.text + body = response.json() + assert "count" in body + assert "schedules" in body + assert isinstance(body["schedules"], list) + assert body["count"] == len(body["schedules"]) + + def test_query_schedules_returns_200(self, authed_api): + response = authed_api("POST", "/triggers/schedules/query", json={}) + assert response.status_code == 200 + body = response.json() + assert body["count"] == len(body["schedules"]) + + def test_fetch_unknown_schedule_returns_404(self, authed_api): + response = authed_api("GET", f"/triggers/schedules/{uuid4()}") + assert response.status_code == 404 + + def test_delete_unknown_schedule_returns_404(self, authed_api): + response = authed_api("DELETE", f"/triggers/schedules/{uuid4()}") + assert response.status_code == 404 + + def test_start_unknown_schedule_returns_404(self, authed_api): + response = authed_api("POST", f"/triggers/schedules/{uuid4()}/start") + assert response.status_code == 404 + + +# --------------------------------------------------------------------------- +# Cron validation — invalid expressions are rejected at create +# --------------------------------------------------------------------------- + + +class TestTriggerSchedulesValidation: + def test_non_cron_expression_is_rejected(self, authed_api): + response = authed_api( + "POST", "/triggers/schedules/", json=_schedule_payload(schedule="not-cron") + ) + assert response.status_code == 422, response.text + + def test_six_field_seconds_form_is_rejected(self, authed_api): + response = authed_api( + "POST", + "/triggers/schedules/", + json=_schedule_payload(schedule="* * * * * *"), + ) + assert response.status_code == 422, response.text + + def test_unresolvable_workflow_reference_is_rejected(self, authed_api): + payload = _schedule_payload() + payload["schedule"]["data"]["references"] = { + "workflow": {"slug": f"missing-{uuid4().hex[:8]}"} + } + response = authed_api("POST", "/triggers/schedules/", json=payload) + assert response.status_code == 422, response.text + + +# --------------------------------------------------------------------------- +# Full lifecycle — no Composio needed (schedules bind no connection) +# --------------------------------------------------------------------------- + + +class TestTriggerSchedulesLifecycle: + def test_create_list_stop_start_edit_delete(self, authed_api): + workflow_slug = _create_workflow(authed_api) + + # CREATE — active by default, bound to a real workflow + create = authed_api( + "POST", + "/triggers/schedules/", + json=_schedule_payload(workflow_slug=workflow_slug), + ) + assert create.status_code == 200, create.text + sched = create.json()["schedule"] + schedule_id = sched["id"] + assert sched["data"]["schedule"] == "*/5 * * * *" + assert sched["flags"]["is_active"] is True + # The bound reference is stored as the normalized family. + assert sched["data"]["references"] + + # LIST + list_resp = authed_api("GET", "/triggers/schedules") + assert list_resp.status_code == 200, list_resp.text + listing = list_resp.json() + assert any(s["id"] == schedule_id for s in listing["schedules"]) + + # STOP -> is_active False (round-trips through fetch) + stop = authed_api("POST", f"/triggers/schedules/{schedule_id}/stop") + assert stop.status_code == 200, stop.text + assert stop.json()["schedule"]["flags"]["is_active"] is False + fetched = authed_api("GET", f"/triggers/schedules/{schedule_id}").json() + assert fetched["schedule"]["flags"]["is_active"] is False + + # START -> is_active True + start = authed_api("POST", f"/triggers/schedules/{schedule_id}/start") + assert start.status_code == 200, start.text + assert start.json()["schedule"]["flags"]["is_active"] is True + + # EDIT (full PUT) — change cron, carry every other field forward + edit = authed_api( + "PUT", + f"/triggers/schedules/{schedule_id}", + json={ + "schedule": { + "id": schedule_id, + "name": sched["name"], + "description": sched["description"], + "data": { + "event_key": "cron.tick", + "schedule": "0 * * * *", + "inputs_fields": sched["data"]["inputs_fields"], + "references": {"workflow": {"slug": workflow_slug}}, + }, + } + }, + ) + assert edit.status_code == 200, edit.text + assert edit.json()["schedule"]["data"]["schedule"] == "0 * * * *" + + # DELETE + delete = authed_api("DELETE", f"/triggers/schedules/{schedule_id}") + assert delete.status_code == 204 + + fetch = authed_api("GET", f"/triggers/schedules/{schedule_id}") + assert fetch.status_code == 404 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..6db49a1817 --- /dev/null +++ b/api/oss/tests/pytest/acceptance/triggers/test_triggers_subscriptions.py @@ -0,0 +1,164 @@ +"""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)", +) + +# 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) +# --------------------------------------------------------------------------- + + +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 +@_requires_connected_account +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": {"owner": "acme", "repo": "widgets"}, + "inputs_fields": {"repo": "$.event.attributes.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["trigger_id"] is not None + assert sub["flags"]["is_active"] 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"]["flags"]["is_active"] 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/acceptance/webhooks/test_webhooks_basics.py b/api/oss/tests/pytest/acceptance/webhooks/test_webhooks_basics.py index ef10bdb940..92bdc0f18d 100644 --- a/api/oss/tests/pytest/acceptance/webhooks/test_webhooks_basics.py +++ b/api/oss/tests/pytest/acceptance/webhooks/test_webhooks_basics.py @@ -175,6 +175,42 @@ def test_delete_webhook_subscription_not_found(self, authed_api): # ---------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# TestWebhooksSubscriptionsActive — play/pause via /start and /stop +# --------------------------------------------------------------------------- + + +class TestWebhooksSubscriptionsActive: + def test_new_subscription_is_active_by_default(self, authed_api): + create = authed_api( + "POST", "/webhooks/subscriptions/", json=_subscription_payload() + ) + assert create.status_code == 200 + assert create.json()["subscription"]["flags"]["is_active"] is True + + def test_stop_then_start_round_trips_is_active(self, authed_api): + create = authed_api( + "POST", "/webhooks/subscriptions/", json=_subscription_payload() + ) + assert create.status_code == 200 + subscription_id = create.json()["subscription"]["id"] + + stop = authed_api("POST", f"/webhooks/subscriptions/{subscription_id}/stop") + assert stop.status_code == 200, stop.text + assert stop.json()["subscription"]["flags"]["is_active"] is False + + fetched = authed_api("GET", f"/webhooks/subscriptions/{subscription_id}").json() + assert fetched["subscription"]["flags"]["is_active"] is False + + start = authed_api("POST", f"/webhooks/subscriptions/{subscription_id}/start") + assert start.status_code == 200, start.text + assert start.json()["subscription"]["flags"]["is_active"] is True + + def test_start_unknown_subscription_returns_404(self, authed_api): + response = authed_api("POST", f"/webhooks/subscriptions/{uuid4()}/start") + assert response.status_code == 404 + + # --------------------------------------------------------------------------- # TestWebhooksSubscriptionsAuthMode # --------------------------------------------------------------------------- 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..046de27aed --- /dev/null +++ b/api/oss/tests/pytest/unit/triggers/test_triggers_dispatcher.py @@ -0,0 +1,239 @@ +"""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: inactive entity, +dedup, missing workflow reference, and the happy path. The trigger_id lookup moved to +the worker, so unknown-trigger handling is no longer the dispatcher's concern. +""" + +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.core.triggers.dtos import ( + TriggerSubscription, + TriggerSubscriptionData, + TriggerSubscriptionFlags, +) +from oss.src.tasks.asyncio.triggers.dispatcher import TriggersDispatcher + + +def _make_subscription( + *, is_active=True, is_valid=True, references=None, inputs_fields=None +): + return TriggerSubscription( + id=uuid4(), + created_by_id=uuid4(), + connection_id=uuid4(), + flags=TriggerSubscriptionFlags(is_active=is_active, is_valid=is_valid), + data=TriggerSubscriptionData( + event_key="github.issue.opened", + inputs_fields=inputs_fields, + references=references, + selector=None, + ), + ) + + +def _make_dao(*, seen=False): + dao = MagicMock() + dao.dedup_seen = AsyncMock(return_value=seen) + dao.write_delivery = AsyncMock() + return dao + + +# Raw provider envelope (Composio webhook shape): the message lives under +# `payload`, the routing ids under `metadata` (`trigger_id` = the provider ti_*, +# `id` = the per-delivery event id). The dispatcher normalizes this into +# `event.{event_id,event_type,attributes}` before mapping. +_EVENT = { + "metadata": { + "trigger_id": "ti_1", + "id": "evt_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, + entity=subscription, + project_id=project_id, + ) + + event = context["event"] + assert event["event_id"] == "evt_1" + assert event["event_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={}, + entity=_make_subscription(), + project_id=uuid4(), + ) + + event = context["event"] + assert event["event_id"] is None + assert event["event_type"] is None + assert event["attributes"] is None + + +async def test_inactive_entity_is_skipped(): + project_id = uuid4() + subscription = _make_subscription(is_active=False) + dao = _make_dao() + dispatcher = TriggersDispatcher(triggers_dao=dao, workflows_service=MagicMock()) + + await dispatcher.dispatch_subscription( + project_id=project_id, subscription=subscription, event_id="e1", event=_EVENT + ) + + dao.dedup_seen.assert_not_awaited() + dao.write_delivery.assert_not_awaited() + + +async def test_invalid_subscription_is_not_silently_skipped(): + project_id = uuid4() + subscription = _make_subscription( + is_valid=False, references={"workflow": Reference(slug="wf-1")} + ) + dao = _make_dao() + workflows = MagicMock() + workflows.invoke_workflow = AsyncMock( + return_value=SimpleNamespace( + status=SimpleNamespace(code=200, message="success"), + outputs={"ok": True}, + trace_id="tr-1", + span_id="sp-1", + ) + ) + dispatcher = TriggersDispatcher(triggers_dao=dao, workflows_service=workflows) + + await dispatcher.dispatch_subscription( + project_id=project_id, subscription=subscription, event_id="e1", event=_EVENT + ) + + dao.dedup_seen.assert_awaited_once() + # The workflow must NOT run for an invalid subscription, and a failed + # delivery must be recorded so the user can see why. + workflows.invoke_workflow.assert_not_awaited() + dao.write_delivery.assert_awaited_once() + delivery = dao.write_delivery.await_args.kwargs["delivery"] + assert delivery.status.code == "409" + assert "invalid" in delivery.data.error.lower() + + +async def test_duplicate_event_is_skipped(): + project_id = uuid4() + subscription = _make_subscription(references={"workflow": Reference(slug="wf-1")}) + dao = _make_dao(seen=True) + dispatcher = TriggersDispatcher(triggers_dao=dao, workflows_service=MagicMock()) + + await dispatcher.dispatch_subscription( + project_id=project_id, subscription=subscription, 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() + workflows = MagicMock() + workflows.invoke_workflow = AsyncMock() + dispatcher = TriggersDispatcher(triggers_dao=dao, workflows_service=workflows) + + await dispatcher.dispatch_subscription( + project_id=project_id, subscription=subscription, 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.attributes.issue.number"}, + ) + dao = _make_dao() + + 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_subscription( + project_id=project_id, subscription=subscription, 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.subscription_id == subscription.id + assert delivery.schedule_id is None + 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() + + 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_subscription( + project_id=project_id, subscription=subscription, 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_schedules_refresh.py b/api/oss/tests/pytest/unit/triggers/test_triggers_schedules_refresh.py new file mode 100644 index 0000000000..db4ecd1104 --- /dev/null +++ b/api/oss/tests/pytest/unit/triggers/test_triggers_schedules_refresh.py @@ -0,0 +1,124 @@ +"""Unit tests for the schedule cron fire-gate. + +Pins ``TriggersService._validate_schedule`` (cron expression contract) and +``refresh_schedules`` (the point-in-time ``croniter.match`` gate, per-tick dedup, +and the failure-aware return). Stubs the DAO and the dispatch task; no DB, no +Composio. Mirrors live-eval ``refresh_runs``. +""" + +from datetime import datetime, timezone +from uuid import uuid4 + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from oss.src.core.triggers.dtos import ( + TriggerSchedule, + TriggerScheduleData, + TriggerScheduleFlags, +) +from oss.src.core.triggers.exceptions import TriggerScheduleInvalid +from oss.src.core.triggers.service import TriggersService + + +# A tick that matches "* * * * *" and "0 * * * *" (top of the hour, UTC). +_TICK = datetime(2026, 6, 22, 10, 0, 0, tzinfo=timezone.utc) + + +def _make_schedule(*, expr="* * * * *", is_active=True): + return TriggerSchedule( + id=uuid4(), + created_by_id=uuid4(), + flags=TriggerScheduleFlags(is_active=is_active), + data=TriggerScheduleData(event_key="report.daily", schedule=expr), + ) + + +def _service(*, schedules=None, seen=False, with_task=True): + dao = MagicMock() + dao.fetch_active_schedules_with_project = AsyncMock( + return_value=[(uuid4(), s) for s in (schedules or [])] + ) + dao.dedup_seen_schedule = AsyncMock(return_value=seen) + service = TriggersService( + adapter_registry=MagicMock(), + catalog_service=MagicMock(), + triggers_dao=dao, + connections_service=MagicMock(), + workflows_service=MagicMock(), + ) + if with_task: + service.schedule_dispatch_task = MagicMock(kiq=AsyncMock()) + return service, dao + + +class TestValidateSchedule: + def test_accepts_valid_five_field_cron(self): + TriggersService._validate_schedule("*/5 * * * *") + + @pytest.mark.parametrize("expr", ["* * * *", "* * * * * *", "", "daily"]) + def test_rejects_wrong_field_count(self, expr): + with pytest.raises(TriggerScheduleInvalid): + TriggersService._validate_schedule(expr) + + def test_rejects_unparseable_cron(self): + with pytest.raises(TriggerScheduleInvalid): + TriggersService._validate_schedule("99 * * * *") + + def test_non_string_is_rejected_without_crashing(self): + with pytest.raises(TriggerScheduleInvalid): + TriggersService._validate_schedule(None) # type: ignore[arg-type] + + +class TestRefreshSchedules: + async def test_matching_schedule_is_dispatched(self): + sched = _make_schedule(expr="0 * * * *") + service, _ = _service(schedules=[sched]) + ok = await service.refresh_schedules(timestamp=_TICK, interval=1) + assert ok is True + service.schedule_dispatch_task.kiq.assert_awaited_once() + + async def test_non_matching_schedule_is_skipped(self): + # Fires only at minute 30; the tick is at minute 0. + sched = _make_schedule(expr="30 * * * *") + service, _ = _service(schedules=[sched]) + ok = await service.refresh_schedules(timestamp=_TICK, interval=1) + assert ok is True + service.schedule_dispatch_task.kiq.assert_not_awaited() + + async def test_already_seen_tick_is_not_redispatched(self): + sched = _make_schedule(expr="* * * * *") + service, _ = _service(schedules=[sched], seen=True) + ok = await service.refresh_schedules(timestamp=_TICK, interval=1) + assert ok is True + service.schedule_dispatch_task.kiq.assert_not_awaited() + + async def test_deterministic_event_id_per_tick(self): + sched = _make_schedule(expr="* * * * *") + service, dao = _service(schedules=[sched]) + await service.refresh_schedules(timestamp=_TICK, interval=1) + _, kwargs = service.schedule_dispatch_task.kiq.await_args + assert kwargs["event_id"] == f"{sched.id}:{_TICK.isoformat()}" + # The dedup probe uses the same id. + assert ( + dao.dedup_seen_schedule.await_args.kwargs["event_id"] == kwargs["event_id"] + ) + + async def test_dispatch_failure_returns_false(self): + sched = _make_schedule(expr="* * * * *") + service, _ = _service(schedules=[sched]) + service.schedule_dispatch_task.kiq = AsyncMock(side_effect=RuntimeError("boom")) + ok = await service.refresh_schedules(timestamp=_TICK, interval=1) + assert ok is False + + async def test_no_timestamp_returns_false(self): + service, _ = _service(schedules=[_make_schedule()]) + ok = await service.refresh_schedules(timestamp=None, interval=1) + assert ok is False + + async def test_unconfigured_task_returns_false(self): + service, _ = _service(schedules=[_make_schedule()], with_task=False) + service.schedule_dispatch_task = None + ok = await service.refresh_schedules(timestamp=_TICK, interval=1) + assert ok is False 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..e7fdf37e07 --- /dev/null +++ b/api/oss/tests/pytest/unit/triggers/test_triggers_signature.py @@ -0,0 +1,113 @@ +"""Unit tests for Composio webhook signature verification. + +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 AsyncMock, MagicMock + +from oss.src.core.triggers.service import TriggersService + +_SECRET = "whsec_test_secret" +_WEBHOOK_ID = "wh-1" +_TIMESTAMP = "1700000000" +_BODY = b'{"type":"github.issue.opened"}' + + +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() + + +def _service(*, secret): + """A TriggersService whose secret resolver returns ``secret``.""" + service = TriggersService( + adapter_registry=MagicMock(), + catalog_service=MagicMock(), + triggers_dao=MagicMock(), + connections_service=MagicMock(), + workflows_service=MagicMock(), + ) + service.webhook_secret_resolver.resolve = AsyncMock(return_value=secret) + return service + + +def _headers(sig): + return { + "webhook-signature": sig, + "webhook-id": _WEBHOOK_ID, + "webhook-timestamp": _TIMESTAMP, + } + + +class TestVerifySignature: + async def test_valid_signature_accepted(self): + service = _service(secret=_SECRET) + sig = _sign(_SECRET, _WEBHOOK_ID, _TIMESTAMP, _BODY) + assert await service.verify_signature(body=_BODY, headers=_headers(sig)) is True + + async def test_valid_signature_with_versioned_prefix_accepted(self): + # Composio sends "v1,"; only the last comma-part is the digest. + service = _service(secret=_SECRET) + sig = _sign(_SECRET, _WEBHOOK_ID, _TIMESTAMP, _BODY) + 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} + assert await service.verify_signature(body=_BODY, headers=headers) is False + + async def test_tampered_body_rejected(self): + service = _service(secret=_SECRET) + sig = _sign(_SECRET, _WEBHOOK_ID, _TIMESTAMP, _BODY) + assert ( + await service.verify_signature( + body=b'{"type":"tampered"}', headers=_headers(sig) + ) + is False + ) + + 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, + } + 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/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/api/pyproject.toml b/api/pyproject.toml index 49b67d707e..75b4aefa41 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -40,6 +40,7 @@ dependencies = [ "newrelic>=13,<14", "dnspython>=2,<3", "opentelemetry-proto>=1,<2", + "croniter>=6,<7", ] [dependency-groups] 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 diff --git a/api/uv.lock b/api/uv.lock index 46e0565926..95cf4a6efc 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -266,6 +266,7 @@ dependencies = [ { name = "alembic" }, { name = "asyncpg" }, { name = "cachetools" }, + { name = "croniter" }, { name = "cryptography" }, { name = "dnspython" }, { name = "fastapi" }, @@ -315,6 +316,7 @@ requires-dist = [ { name = "alembic", specifier = ">=1,<2" }, { name = "asyncpg", specifier = ">=0.31,<0.32" }, { name = "cachetools", specifier = ">=7,<8" }, + { name = "croniter", specifier = ">=6,<7" }, { name = "cryptography", specifier = ">=49,<50" }, { name = "dnspython", specifier = ">=2,<3" }, { name = "fastapi", specifier = ">=0.137,<0.138" }, @@ -570,6 +572,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "croniter" +version = "6.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab", size = 113762, upload-time = "2026-03-15T08:43:48.112Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960", size = 45422, upload-time = "2026-03-15T08:43:46.626Z" }, +] + [[package]] name = "cryptography" version = "49.0.0" diff --git a/clients/python/agenta_client/__init__.py b/clients/python/agenta_client/__init__.py index a4bb68b9e0..2c7f7d4c18 100644 --- a/clients/python/agenta_client/__init__.py +++ b/clients/python/agenta_client/__init__.py @@ -5,9 +5,9 @@ import typing from importlib import import_module if typing.TYPE_CHECKING: - from .types import AdminAccountCreateOptions, AdminAccountRead, AdminAccountsCreate, AdminAccountsDelete, AdminAccountsDeleteTarget, AdminAccountsResponse, AdminApiKeyCreate, AdminApiKeyResponse, AdminDeleteResponse, AdminDeletedEntities, AdminDeletedEntity, AdminOrganizationCreate, AdminOrganizationMembershipCreate, AdminOrganizationMembershipRead, AdminOrganizationRead, AdminProjectCreate, AdminProjectMembershipCreate, AdminProjectMembershipRead, AdminProjectRead, AdminSimpleAccountCreate, AdminSimpleAccountDeleteEntry, AdminSimpleAccountRead, AdminSimpleAccountsApiKeysCreate, AdminSimpleAccountsCreate, AdminSimpleAccountsDelete, AdminSimpleAccountsOrganizationsCreate, AdminSimpleAccountsOrganizationsMembershipsCreate, AdminSimpleAccountsOrganizationsTransferOwnership, AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjects, AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjectsZero, AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspaces, AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspacesZero, AdminSimpleAccountsOrganizationsTransferOwnershipResponse, AdminSimpleAccountsProjectsCreate, AdminSimpleAccountsProjectsMembershipsCreate, AdminSimpleAccountsResponse, AdminSimpleAccountsUsersCreate, AdminSimpleAccountsUsersIdentitiesCreate, AdminSimpleAccountsUsersResetPassword, AdminSimpleAccountsWorkspacesCreate, AdminSimpleAccountsWorkspacesMembershipsCreate, AdminStructuredError, AdminSubscriptionCreate, AdminSubscriptionRead, AdminUserCreate, AdminUserIdentityCreate, AdminUserIdentityRead, AdminUserIdentityReadStatus, AdminUserRead, AdminWorkspaceCreate, AdminWorkspaceMembershipCreate, AdminWorkspaceMembershipRead, AdminWorkspaceRead, Analytics, AnalyticsResponse, Annotation, AnnotationCreate, AnnotationCreateLinks, AnnotationEdit, AnnotationEditLinks, AnnotationLinkResponse, AnnotationLinks, AnnotationQuery, AnnotationQueryLinks, AnnotationResponse, AnnotationsResponse, Application, ApplicationArtifactFlags, ApplicationArtifactQueryFlags, ApplicationCatalogPreset, ApplicationCatalogPresetResponse, ApplicationCatalogPresetsResponse, ApplicationCatalogTemplate, ApplicationCatalogTemplateResponse, ApplicationCatalogTemplatesResponse, ApplicationCatalogType, ApplicationCatalogTypesResponse, ApplicationCreate, ApplicationEdit, ApplicationFlags, ApplicationQuery, ApplicationResponse, ApplicationRevisionCommit, ApplicationRevisionCreate, ApplicationRevisionDataInput, ApplicationRevisionDataInputHeadersValue, ApplicationRevisionDataInputRuntime, ApplicationRevisionDataOutput, ApplicationRevisionDataOutputHeadersValue, ApplicationRevisionDataOutputRuntime, ApplicationRevisionEdit, ApplicationRevisionFlags, ApplicationRevisionInput, ApplicationRevisionOutput, ApplicationRevisionQuery, ApplicationRevisionQueryFlags, ApplicationRevisionResolveResponse, ApplicationRevisionResponse, ApplicationRevisionsLog, ApplicationRevisionsResponse, ApplicationVariant, ApplicationVariantCreate, ApplicationVariantEdit, ApplicationVariantFlags, ApplicationVariantFork, ApplicationVariantResponse, ApplicationVariantsResponse, ApplicationsResponse, BodyConfigsFetchVariantsConfigsFetchPost, Bucket, CollectStatusResponse, ComparisonOperator, Condition, ConditionOperator, ConditionOptions, ConditionValue, ConfigResponseModel, CustomModelSettingsDto, CustomProviderDto, CustomProviderKind, CustomProviderSettingsDto, DictOperator, DiscoverResponse, DiscoverResponseMethodsValue, EeSrcModelsApiOrganizationModelsOrganization, EntityRef, Environment, EnvironmentCreate, EnvironmentEdit, EnvironmentFlags, EnvironmentQueryFlags, EnvironmentResponse, EnvironmentRevisionCommit, EnvironmentRevisionCreate, EnvironmentRevisionData, EnvironmentRevisionDelta, EnvironmentRevisionEdit, EnvironmentRevisionInput, EnvironmentRevisionOutput, EnvironmentRevisionResolveResponse, EnvironmentRevisionResponse, EnvironmentRevisionsLog, EnvironmentRevisionsResponse, EnvironmentVariant, EnvironmentVariantCreate, EnvironmentVariantEdit, EnvironmentVariantFork, EnvironmentVariantResponse, EnvironmentVariantsResponse, EnvironmentsResponse, ErrorPolicy, EvaluationMetrics, EvaluationMetricsCreate, EvaluationMetricsIdsResponse, EvaluationMetricsQuery, EvaluationMetricsQueryScenarioIds, EvaluationMetricsQueryTimestamps, EvaluationMetricsRefresh, EvaluationMetricsResponse, EvaluationMetricsSetRequest, EvaluationQueue, EvaluationQueueCreate, EvaluationQueueData, EvaluationQueueEdit, EvaluationQueueFlags, EvaluationQueueIdResponse, EvaluationQueueIdsResponse, EvaluationQueueQuery, EvaluationQueueQueryFlags, EvaluationQueueResponse, EvaluationQueueScenariosQuery, EvaluationQueuesResponse, EvaluationResult, EvaluationResultCreate, EvaluationResultIdResponse, EvaluationResultIdsResponse, EvaluationResultQuery, EvaluationResultResponse, EvaluationResultsResponse, EvaluationResultsSetRequest, EvaluationRun, EvaluationRunCreate, EvaluationRunDataConcurrency, EvaluationRunDataInput, EvaluationRunDataMapping, EvaluationRunDataMappingColumn, EvaluationRunDataMappingStep, EvaluationRunDataOutput, EvaluationRunDataStepInput, EvaluationRunDataStepInputKey, EvaluationRunDataStepInputOrigin, EvaluationRunDataStepInputType, EvaluationRunDataStepOutput, EvaluationRunDataStepOutputOrigin, EvaluationRunDataStepOutputType, EvaluationRunEdit, EvaluationRunFlags, EvaluationRunIdResponse, EvaluationRunIdsRequest, EvaluationRunIdsResponse, EvaluationRunQuery, EvaluationRunQueryFlags, EvaluationRunResponse, EvaluationRunsResponse, EvaluationScenario, EvaluationScenarioCreate, EvaluationScenarioEdit, EvaluationScenarioIdResponse, EvaluationScenarioIdsResponse, EvaluationScenarioQuery, EvaluationScenarioResponse, EvaluationScenariosResponse, EvaluationStatus, Evaluator, EvaluatorArtifactFlags, EvaluatorArtifactQueryFlags, EvaluatorCatalogPreset, EvaluatorCatalogPresetResponse, EvaluatorCatalogPresetsResponse, EvaluatorCatalogTemplate, EvaluatorCatalogTemplateResponse, EvaluatorCatalogTemplatesResponse, EvaluatorCatalogType, EvaluatorCatalogTypesResponse, EvaluatorCreate, EvaluatorEdit, EvaluatorFlags, EvaluatorQuery, EvaluatorResponse, EvaluatorRevisionCommit, EvaluatorRevisionCreate, EvaluatorRevisionDataInput, EvaluatorRevisionDataInputHeadersValue, EvaluatorRevisionDataInputRuntime, EvaluatorRevisionDataOutput, EvaluatorRevisionDataOutputHeadersValue, EvaluatorRevisionDataOutputRuntime, EvaluatorRevisionEdit, EvaluatorRevisionFlags, EvaluatorRevisionInput, EvaluatorRevisionOutput, EvaluatorRevisionQuery, EvaluatorRevisionQueryFlags, EvaluatorRevisionResolveResponse, EvaluatorRevisionResponse, EvaluatorRevisionsLog, EvaluatorRevisionsResponse, EvaluatorTemplate, EvaluatorTemplatesResponse, EvaluatorVariant, EvaluatorVariantCreate, EvaluatorVariantEdit, EvaluatorVariantFlags, EvaluatorVariantFork, EvaluatorVariantResponse, EvaluatorVariantsResponse, EvaluatorsResponse, Event, EventQuery, EventType, EventsQueryResponse, ExistenceOperator, FilteringInput, FilteringInputConditionsItem, FilteringOutput, FilteringOutputConditionsItem, Focus, Folder, FolderCreate, FolderEdit, FolderIdResponse, FolderKind, FolderQuery, FolderQueryKinds, FolderResponse, FoldersResponse, Format, Formatting, FullJsonInput, FullJsonOutput, Header, HttpValidationError, InviteRequest, Invocation, InvocationCreate, InvocationCreateLinks, InvocationEdit, InvocationEditLinks, InvocationLinkResponse, InvocationLinks, InvocationQuery, InvocationQueryLinks, InvocationResponse, InvocationsResponse, JsonSchemasInput, JsonSchemasOutput, LabelJsonInput, LabelJsonOutput, LegacyLifecycleDto, ListApiKeysResponse, ListOperator, ListOptions, LogicalOperator, MetricSpec, MetricType, MetricsBucket, NumericOperator, OTelEventInput, OTelEventInputTimestamp, OTelEventOutput, OTelEventOutputTimestamp, OTelHashInput, OTelHashOutput, OTelLinkInput, OTelLinkOutput, OTelLinksResponse, OTelReferenceInput, OTelReferenceOutput, OTelSpanKind, OTelStatusCode, OTelTracingRequest, OTelTracingResponse, OldAnalyticsResponse, OrganizationDetails, OrganizationDomainResponse, OrganizationProviderResponse, OrganizationUpdate, OssSrcModelsApiOrganizationModelsOrganization, Permission, ProjectsResponse, QueriesResponse, Query, QueryCreate, QueryEdit, QueryFlags, QueryQueryFlags, QueryResponse, QueryRevision, QueryRevisionCommit, QueryRevisionCreate, QueryRevisionDataInput, QueryRevisionDataOutput, QueryRevisionEdit, QueryRevisionQuery, QueryRevisionResponse, QueryRevisionsLog, QueryRevisionsResponse, QueryVariant, QueryVariantCreate, QueryVariantEdit, QueryVariantFork, QueryVariantQuery, QueryVariantResponse, QueryVariantsResponse, Reference, ReferenceRequestModelInput, ReferenceRequestModelOutput, RequestType, ResolutionInfo, RetrievalInfo, SecretDto, SecretDtoData, SecretKind, SecretResponseDto, SecretResponseDtoData, SessionIdsResponse, SimpleApplication, SimpleApplicationCreate, SimpleApplicationDataInput, SimpleApplicationDataInputHeadersValue, SimpleApplicationDataInputRuntime, SimpleApplicationDataOutput, SimpleApplicationDataOutputHeadersValue, SimpleApplicationDataOutputRuntime, SimpleApplicationEdit, SimpleApplicationFlags, SimpleApplicationQuery, SimpleApplicationQueryFlags, SimpleApplicationResponse, SimpleApplicationsResponse, SimpleEnvironment, SimpleEnvironmentCreate, SimpleEnvironmentEdit, SimpleEnvironmentQuery, SimpleEnvironmentResponse, SimpleEnvironmentsResponse, SimpleEvaluation, SimpleEvaluationCreate, SimpleEvaluationData, SimpleEvaluationDataApplicationSteps, SimpleEvaluationDataApplicationStepsOneValue, SimpleEvaluationDataEvaluatorSteps, SimpleEvaluationDataEvaluatorStepsOneValue, SimpleEvaluationDataQuerySteps, SimpleEvaluationDataQueryStepsOneValue, SimpleEvaluationDataTestsetSteps, SimpleEvaluationDataTestsetStepsOneValue, SimpleEvaluationEdit, SimpleEvaluationIdResponse, SimpleEvaluationQuery, SimpleEvaluationResponse, SimpleEvaluationsResponse, SimpleEvaluator, SimpleEvaluatorCreate, SimpleEvaluatorDataInput, SimpleEvaluatorDataInputHeadersValue, SimpleEvaluatorDataInputRuntime, SimpleEvaluatorDataOutput, SimpleEvaluatorDataOutputHeadersValue, SimpleEvaluatorDataOutputRuntime, SimpleEvaluatorEdit, SimpleEvaluatorFlags, SimpleEvaluatorQuery, SimpleEvaluatorQueryFlags, SimpleEvaluatorResponse, SimpleEvaluatorsResponse, SimpleQueriesResponse, SimpleQuery, SimpleQueryCreate, SimpleQueryEdit, SimpleQueryQuery, SimpleQueryResponse, SimpleQueue, SimpleQueueCreate, SimpleQueueData, SimpleQueueDataEvaluators, SimpleQueueDataEvaluatorsOneValue, SimpleQueueIdResponse, SimpleQueueIdsResponse, SimpleQueueKind, SimpleQueueQuery, SimpleQueueResponse, SimpleQueueScenariosQuery, SimpleQueueScenariosResponse, SimpleQueueSettings, SimpleQueuesResponse, SimpleTestset, SimpleTestsetCreate, SimpleTestsetEdit, SimpleTestsetQuery, SimpleTestsetResponse, SimpleTestsetsResponse, SimpleTrace, SimpleTraceChannel, SimpleTraceCreate, SimpleTraceCreateLinks, SimpleTraceEdit, SimpleTraceEditLinks, SimpleTraceKind, SimpleTraceLinkResponse, SimpleTraceLinks, SimpleTraceOrigin, SimpleTraceQuery, SimpleTraceQueryLinks, SimpleTraceReferences, SimpleTraceResponse, SimpleTracesResponse, SimpleWorkflow, SimpleWorkflowCreate, SimpleWorkflowDataInput, SimpleWorkflowDataInputHeadersValue, SimpleWorkflowDataInputRuntime, SimpleWorkflowDataOutput, SimpleWorkflowDataOutputHeadersValue, SimpleWorkflowDataOutputRuntime, SimpleWorkflowEdit, SimpleWorkflowFlags, SimpleWorkflowQuery, SimpleWorkflowQueryFlags, SimpleWorkflowResponse, SimpleWorkflowsResponse, SpanInput, SpanInputEndTime, SpanInputStartTime, SpanOutput, SpanOutputEndTime, SpanOutputStartTime, SpanResponse, SpanType, SpansNodeInput, SpansNodeInputEndTime, SpansNodeInputSpansValue, SpansNodeInputStartTime, SpansNodeOutput, SpansNodeOutputEndTime, SpansNodeOutputSpansValue, SpansNodeOutputStartTime, SpansResponse, SpansTreeInput, SpansTreeInputSpansValue, SpansTreeOutput, SpansTreeOutputSpansValue, SsoProviderDto, SsoProviderInfo, SsoProviderSettingsDto, SsoProviders, StandardProviderDto, StandardProviderKind, StandardProviderSettingsDto, Status, StringOperator, TestcaseInput, TestcaseOutput, TestcaseResponse, TestcasesResponse, Testset, TestsetCreate, TestsetEdit, TestsetFlags, TestsetQuery, TestsetResponse, TestsetRevision, TestsetRevisionCommit, TestsetRevisionCreate, TestsetRevisionDataInput, TestsetRevisionDataOutput, TestsetRevisionDelta, TestsetRevisionDeltaColumns, TestsetRevisionDeltaRows, TestsetRevisionEdit, TestsetRevisionQuery, TestsetRevisionResponse, TestsetRevisionsLog, TestsetRevisionsResponse, TestsetVariant, TestsetVariantCreate, TestsetVariantEdit, TestsetVariantFork, TestsetVariantQuery, TestsetVariantResponse, TestsetVariantsResponse, TestsetsResponse, TextOptions, ToolAuthScheme, ToolCallData, ToolCallFunction, ToolCallResponse, ToolCatalogAction, ToolCatalogActionDetails, ToolCatalogActionResponse, ToolCatalogActionResponseAction, ToolCatalogActionsResponse, ToolCatalogActionsResponseActionsItem, ToolCatalogIntegration, ToolCatalogIntegrationDetails, ToolCatalogIntegrationResponse, ToolCatalogIntegrationResponseIntegration, ToolCatalogIntegrationsResponse, ToolCatalogIntegrationsResponseIntegrationsItem, ToolCatalogProvider, ToolCatalogProviderDetails, ToolCatalogProviderResponse, ToolCatalogProviderResponseProvider, ToolCatalogProvidersResponse, ToolCatalogProvidersResponseProvidersItem, ToolConnection, ToolConnectionCreate, ToolConnectionCreateData, ToolConnectionResponse, ToolConnectionStatus, ToolConnectionsResponse, ToolProviderKind, ToolResult, ToolResultData, TraceIdResponse, TraceIdsResponse, TraceInput, TraceInputSpansValue, TraceOutput, TraceOutputSpansValue, TraceRequest, TraceResponse, TraceType, TracesRequest, TracesResponse, TracingQuery, UserIdsResponse, ValidationError, ValidationErrorLocItem, WebhookDeliveriesResponse, WebhookDelivery, WebhookDeliveryCreate, WebhookDeliveryData, WebhookDeliveryQuery, WebhookDeliveryResponse, WebhookDeliveryResponseInfo, WebhookEventType, WebhookProviderDto, WebhookProviderSettingsDto, WebhookSubscription, WebhookSubscriptionCreate, WebhookSubscriptionData, WebhookSubscriptionDataAuthMode, WebhookSubscriptionEdit, WebhookSubscriptionQuery, WebhookSubscriptionResponse, WebhookSubscriptionsResponse, Windowing, WindowingOrder, Workflow, WorkflowArtifactFlags, WorkflowCatalogFlags, WorkflowCatalogPreset, WorkflowCatalogPresetResponse, WorkflowCatalogPresetsResponse, WorkflowCatalogTemplate, WorkflowCatalogTemplateResponse, WorkflowCatalogTemplatesResponse, WorkflowCatalogType, WorkflowCatalogTypeResponse, WorkflowCatalogTypesResponse, WorkflowCreate, WorkflowEdit, WorkflowFlags, WorkflowResponse, WorkflowRevisionCommit, WorkflowRevisionCreate, WorkflowRevisionDataInput, WorkflowRevisionDataInputHeadersValue, WorkflowRevisionDataInputRuntime, WorkflowRevisionDataOutput, WorkflowRevisionDataOutputHeadersValue, WorkflowRevisionDataOutputRuntime, WorkflowRevisionEdit, WorkflowRevisionFlags, WorkflowRevisionInput, WorkflowRevisionOutput, WorkflowRevisionResolveResponse, WorkflowRevisionResponse, WorkflowRevisionsLog, WorkflowRevisionsResponse, WorkflowVariant, WorkflowVariantCreate, WorkflowVariantEdit, WorkflowVariantFlags, WorkflowVariantFork, WorkflowVariantResponse, WorkflowVariantsResponse, WorkflowsResponse, Workspace, WorkspaceMemberResponse, WorkspacePermission, WorkspaceResponse + from .types import AdminAccountCreateOptions, AdminAccountRead, AdminAccountsCreate, AdminAccountsDelete, AdminAccountsDeleteTarget, AdminAccountsResponse, AdminApiKeyCreate, AdminApiKeyResponse, AdminDeleteResponse, AdminDeletedEntities, AdminDeletedEntity, AdminOrganizationCreate, AdminOrganizationMembershipCreate, AdminOrganizationMembershipRead, AdminOrganizationRead, AdminProjectCreate, AdminProjectMembershipCreate, AdminProjectMembershipRead, AdminProjectRead, AdminSimpleAccountCreate, AdminSimpleAccountDeleteEntry, AdminSimpleAccountRead, AdminSimpleAccountsApiKeysCreate, AdminSimpleAccountsCreate, AdminSimpleAccountsDelete, AdminSimpleAccountsOrganizationsCreate, AdminSimpleAccountsOrganizationsMembershipsCreate, AdminSimpleAccountsOrganizationsTransferOwnership, AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjects, AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjectsZero, AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspaces, AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspacesZero, AdminSimpleAccountsOrganizationsTransferOwnershipResponse, AdminSimpleAccountsProjectsCreate, AdminSimpleAccountsProjectsMembershipsCreate, AdminSimpleAccountsResponse, AdminSimpleAccountsUsersCreate, AdminSimpleAccountsUsersIdentitiesCreate, AdminSimpleAccountsUsersResetPassword, AdminSimpleAccountsWorkspacesCreate, AdminSimpleAccountsWorkspacesMembershipsCreate, AdminStructuredError, AdminSubscriptionCreate, AdminSubscriptionRead, AdminUserCreate, AdminUserIdentityCreate, AdminUserIdentityRead, AdminUserIdentityReadStatus, AdminUserRead, AdminWorkspaceCreate, AdminWorkspaceMembershipCreate, AdminWorkspaceMembershipRead, AdminWorkspaceRead, Analytics, AnalyticsResponse, Annotation, AnnotationCreate, AnnotationCreateLinks, AnnotationEdit, AnnotationEditLinks, AnnotationLinkResponse, AnnotationLinks, AnnotationQuery, AnnotationQueryLinks, AnnotationResponse, AnnotationsResponse, Application, ApplicationArtifactFlags, ApplicationArtifactQueryFlags, ApplicationCatalogPreset, ApplicationCatalogPresetResponse, ApplicationCatalogPresetsResponse, ApplicationCatalogTemplate, ApplicationCatalogTemplateResponse, ApplicationCatalogTemplatesResponse, ApplicationCatalogType, ApplicationCatalogTypesResponse, ApplicationCreate, ApplicationEdit, ApplicationFlags, ApplicationQuery, ApplicationResponse, ApplicationRevisionCommit, ApplicationRevisionCreate, ApplicationRevisionDataInput, ApplicationRevisionDataInputHeadersValue, ApplicationRevisionDataInputRuntime, ApplicationRevisionDataOutput, ApplicationRevisionDataOutputHeadersValue, ApplicationRevisionDataOutputRuntime, ApplicationRevisionEdit, ApplicationRevisionFlags, ApplicationRevisionInput, ApplicationRevisionOutput, ApplicationRevisionQuery, ApplicationRevisionQueryFlags, ApplicationRevisionResolveResponse, ApplicationRevisionResponse, ApplicationRevisionsLog, ApplicationRevisionsResponse, ApplicationVariant, ApplicationVariantCreate, ApplicationVariantEdit, ApplicationVariantFlags, ApplicationVariantFork, ApplicationVariantResponse, ApplicationVariantsResponse, ApplicationsResponse, BodyConfigsFetchVariantsConfigsFetchPost, Bucket, CollectStatusResponse, ComparisonOperator, Condition, ConditionOperator, ConditionOptions, ConditionValue, ConfigResponseModel, CustomModelSettingsDto, CustomProviderDto, CustomProviderKind, CustomProviderSettingsDto, DictOperator, DiscoverResponse, DiscoverResponseMethodsValue, EeSrcModelsApiOrganizationModelsOrganization, EntityRef, Environment, EnvironmentCreate, EnvironmentEdit, EnvironmentFlags, EnvironmentQueryFlags, EnvironmentResponse, EnvironmentRevisionCommit, EnvironmentRevisionCreate, EnvironmentRevisionData, EnvironmentRevisionDelta, EnvironmentRevisionEdit, EnvironmentRevisionInput, EnvironmentRevisionOutput, EnvironmentRevisionResolveResponse, EnvironmentRevisionResponse, EnvironmentRevisionsLog, EnvironmentRevisionsResponse, EnvironmentVariant, EnvironmentVariantCreate, EnvironmentVariantEdit, EnvironmentVariantFork, EnvironmentVariantResponse, EnvironmentVariantsResponse, EnvironmentsResponse, ErrorPolicy, EvaluationMetrics, EvaluationMetricsCreate, EvaluationMetricsIdsResponse, EvaluationMetricsQuery, EvaluationMetricsQueryScenarioIds, EvaluationMetricsQueryTimestamps, EvaluationMetricsRefresh, EvaluationMetricsResponse, EvaluationMetricsSetRequest, EvaluationQueue, EvaluationQueueCreate, EvaluationQueueData, EvaluationQueueEdit, EvaluationQueueFlags, EvaluationQueueIdResponse, EvaluationQueueIdsResponse, EvaluationQueueQuery, EvaluationQueueQueryFlags, EvaluationQueueResponse, EvaluationQueueScenariosQuery, EvaluationQueuesResponse, EvaluationResult, EvaluationResultCreate, EvaluationResultIdResponse, EvaluationResultIdsResponse, EvaluationResultQuery, EvaluationResultResponse, EvaluationResultsResponse, EvaluationResultsSetRequest, EvaluationRun, EvaluationRunCreate, EvaluationRunDataConcurrency, EvaluationRunDataInput, EvaluationRunDataMapping, EvaluationRunDataMappingColumn, EvaluationRunDataMappingStep, EvaluationRunDataOutput, EvaluationRunDataStepInput, EvaluationRunDataStepInputKey, EvaluationRunDataStepInputOrigin, EvaluationRunDataStepInputType, EvaluationRunDataStepOutput, EvaluationRunDataStepOutputOrigin, EvaluationRunDataStepOutputType, EvaluationRunEdit, EvaluationRunFlags, EvaluationRunIdResponse, EvaluationRunIdsRequest, EvaluationRunIdsResponse, EvaluationRunQuery, EvaluationRunQueryFlags, EvaluationRunResponse, EvaluationRunsResponse, EvaluationScenario, EvaluationScenarioCreate, EvaluationScenarioEdit, EvaluationScenarioIdResponse, EvaluationScenarioIdsResponse, EvaluationScenarioQuery, EvaluationScenarioResponse, EvaluationScenariosResponse, EvaluationStatus, Evaluator, EvaluatorArtifactFlags, EvaluatorArtifactQueryFlags, EvaluatorCatalogPreset, EvaluatorCatalogPresetResponse, EvaluatorCatalogPresetsResponse, EvaluatorCatalogTemplate, EvaluatorCatalogTemplateResponse, EvaluatorCatalogTemplatesResponse, EvaluatorCatalogType, EvaluatorCatalogTypesResponse, EvaluatorCreate, EvaluatorEdit, EvaluatorFlags, EvaluatorQuery, EvaluatorResponse, EvaluatorRevisionCommit, EvaluatorRevisionCreate, EvaluatorRevisionDataInput, EvaluatorRevisionDataInputHeadersValue, EvaluatorRevisionDataInputRuntime, EvaluatorRevisionDataOutput, EvaluatorRevisionDataOutputHeadersValue, EvaluatorRevisionDataOutputRuntime, EvaluatorRevisionEdit, EvaluatorRevisionFlags, EvaluatorRevisionInput, EvaluatorRevisionOutput, EvaluatorRevisionQuery, EvaluatorRevisionQueryFlags, EvaluatorRevisionResolveResponse, EvaluatorRevisionResponse, EvaluatorRevisionsLog, EvaluatorRevisionsResponse, EvaluatorTemplate, EvaluatorTemplatesResponse, EvaluatorVariant, EvaluatorVariantCreate, EvaluatorVariantEdit, EvaluatorVariantFlags, EvaluatorVariantFork, EvaluatorVariantResponse, EvaluatorVariantsResponse, EvaluatorsResponse, Event, EventQuery, EventType, EventsQueryResponse, ExistenceOperator, FilteringInput, FilteringInputConditionsItem, FilteringOutput, FilteringOutputConditionsItem, Focus, Folder, FolderCreate, FolderEdit, FolderIdResponse, FolderKind, FolderQuery, FolderQueryKinds, FolderResponse, FoldersResponse, Format, Formatting, FullJsonInput, FullJsonOutput, Header, HttpValidationError, InviteRequest, Invocation, InvocationCreate, InvocationCreateLinks, InvocationEdit, InvocationEditLinks, InvocationLinkResponse, InvocationLinks, InvocationQuery, InvocationQueryLinks, InvocationResponse, InvocationsResponse, JsonSchemasInput, JsonSchemasOutput, LabelJsonInput, LabelJsonOutput, LegacyLifecycleDto, ListApiKeysResponse, ListOperator, ListOptions, LogicalOperator, MetricSpec, MetricType, MetricsBucket, NumericOperator, OTelEventInput, OTelEventInputTimestamp, OTelEventOutput, OTelEventOutputTimestamp, OTelHashInput, OTelHashOutput, OTelLinkInput, OTelLinkOutput, OTelLinksResponse, OTelReferenceInput, OTelReferenceOutput, OTelSpanKind, OTelStatusCode, OTelTracingRequest, OTelTracingResponse, OldAnalyticsResponse, OrganizationDetails, OrganizationDomainResponse, OrganizationProviderResponse, OrganizationUpdate, OssSrcModelsApiOrganizationModelsOrganization, Permission, ProjectsResponse, QueriesResponse, Query, QueryCreate, QueryEdit, QueryFlags, QueryQueryFlags, QueryResponse, QueryRevision, QueryRevisionCommit, QueryRevisionCreate, QueryRevisionDataInput, QueryRevisionDataOutput, QueryRevisionEdit, QueryRevisionQuery, QueryRevisionResponse, QueryRevisionsLog, QueryRevisionsResponse, QueryVariant, QueryVariantCreate, QueryVariantEdit, QueryVariantFork, QueryVariantQuery, QueryVariantResponse, QueryVariantsResponse, Reference, ReferenceRequestModelInput, ReferenceRequestModelOutput, RequestType, ResolutionInfo, RetrievalInfo, SecretDto, SecretDtoData, SecretKind, SecretResponseDto, SecretResponseDtoData, Selector, SessionIdsResponse, SimpleApplication, SimpleApplicationCreate, SimpleApplicationDataInput, SimpleApplicationDataInputHeadersValue, SimpleApplicationDataInputRuntime, SimpleApplicationDataOutput, SimpleApplicationDataOutputHeadersValue, SimpleApplicationDataOutputRuntime, SimpleApplicationEdit, SimpleApplicationFlags, SimpleApplicationQuery, SimpleApplicationQueryFlags, SimpleApplicationResponse, SimpleApplicationsResponse, SimpleEnvironment, SimpleEnvironmentCreate, SimpleEnvironmentEdit, SimpleEnvironmentQuery, SimpleEnvironmentResponse, SimpleEnvironmentsResponse, SimpleEvaluation, SimpleEvaluationCreate, SimpleEvaluationData, SimpleEvaluationDataApplicationSteps, SimpleEvaluationDataApplicationStepsOneValue, SimpleEvaluationDataEvaluatorSteps, SimpleEvaluationDataEvaluatorStepsOneValue, SimpleEvaluationDataQuerySteps, SimpleEvaluationDataQueryStepsOneValue, SimpleEvaluationDataTestsetSteps, SimpleEvaluationDataTestsetStepsOneValue, SimpleEvaluationEdit, SimpleEvaluationIdResponse, SimpleEvaluationQuery, SimpleEvaluationResponse, SimpleEvaluationsResponse, SimpleEvaluator, SimpleEvaluatorCreate, SimpleEvaluatorDataInput, SimpleEvaluatorDataInputHeadersValue, SimpleEvaluatorDataInputRuntime, SimpleEvaluatorDataOutput, SimpleEvaluatorDataOutputHeadersValue, SimpleEvaluatorDataOutputRuntime, SimpleEvaluatorEdit, SimpleEvaluatorFlags, SimpleEvaluatorQuery, SimpleEvaluatorQueryFlags, SimpleEvaluatorResponse, SimpleEvaluatorsResponse, SimpleQueriesResponse, SimpleQuery, SimpleQueryCreate, SimpleQueryEdit, SimpleQueryQuery, SimpleQueryResponse, SimpleQueue, SimpleQueueCreate, SimpleQueueData, SimpleQueueDataEvaluators, SimpleQueueDataEvaluatorsOneValue, SimpleQueueIdResponse, SimpleQueueIdsResponse, SimpleQueueKind, SimpleQueueQuery, SimpleQueueResponse, SimpleQueueScenariosQuery, SimpleQueueScenariosResponse, SimpleQueueSettings, SimpleQueuesResponse, SimpleTestset, SimpleTestsetCreate, SimpleTestsetEdit, SimpleTestsetQuery, SimpleTestsetResponse, SimpleTestsetsResponse, SimpleTrace, SimpleTraceChannel, SimpleTraceCreate, SimpleTraceCreateLinks, SimpleTraceEdit, SimpleTraceEditLinks, SimpleTraceKind, SimpleTraceLinkResponse, SimpleTraceLinks, SimpleTraceOrigin, SimpleTraceQuery, SimpleTraceQueryLinks, SimpleTraceReferences, SimpleTraceResponse, SimpleTracesResponse, SimpleWorkflow, SimpleWorkflowCreate, SimpleWorkflowDataInput, SimpleWorkflowDataInputHeadersValue, SimpleWorkflowDataInputRuntime, SimpleWorkflowDataOutput, SimpleWorkflowDataOutputHeadersValue, SimpleWorkflowDataOutputRuntime, SimpleWorkflowEdit, SimpleWorkflowFlags, SimpleWorkflowQuery, SimpleWorkflowQueryFlags, SimpleWorkflowResponse, SimpleWorkflowsResponse, SpanInput, SpanInputEndTime, SpanInputStartTime, SpanOutput, SpanOutputEndTime, SpanOutputStartTime, SpanResponse, SpanType, SpansNodeInput, SpansNodeInputEndTime, SpansNodeInputSpansValue, SpansNodeInputStartTime, SpansNodeOutput, SpansNodeOutputEndTime, SpansNodeOutputSpansValue, SpansNodeOutputStartTime, SpansResponse, SpansTreeInput, SpansTreeInputSpansValue, SpansTreeOutput, SpansTreeOutputSpansValue, SsoProviderDto, SsoProviderInfo, SsoProviderSettingsDto, SsoProviders, StandardProviderDto, StandardProviderKind, StandardProviderSettingsDto, Status, StringOperator, TestcaseInput, TestcaseOutput, TestcaseResponse, TestcasesResponse, Testset, TestsetCreate, TestsetEdit, TestsetFlags, TestsetQuery, TestsetResponse, TestsetRevision, TestsetRevisionCommit, TestsetRevisionCreate, TestsetRevisionDataInput, TestsetRevisionDataOutput, TestsetRevisionDelta, TestsetRevisionDeltaColumns, TestsetRevisionDeltaRows, TestsetRevisionEdit, TestsetRevisionQuery, TestsetRevisionResponse, TestsetRevisionsLog, TestsetRevisionsResponse, TestsetVariant, TestsetVariantCreate, TestsetVariantEdit, TestsetVariantFork, TestsetVariantQuery, TestsetVariantResponse, TestsetVariantsResponse, TestsetsResponse, TextOptions, ToolAuthScheme, ToolCallData, ToolCallFunction, ToolCallResponse, ToolCatalogAction, ToolCatalogActionDetails, ToolCatalogActionResponse, ToolCatalogActionResponseAction, ToolCatalogActionsResponse, ToolCatalogActionsResponseActionsItem, ToolCatalogIntegration, ToolCatalogIntegrationDetails, ToolCatalogIntegrationResponse, ToolCatalogIntegrationResponseIntegration, ToolCatalogIntegrationsResponse, ToolCatalogIntegrationsResponseIntegrationsItem, ToolCatalogProvider, ToolCatalogProviderDetails, ToolCatalogProviderResponse, ToolCatalogProviderResponseProvider, ToolCatalogProvidersResponse, ToolCatalogProvidersResponseProvidersItem, ToolConnection, ToolConnectionCreate, ToolConnectionCreateData, ToolConnectionResponse, ToolConnectionStatus, ToolConnectionsResponse, ToolProviderKind, ToolResult, ToolResultData, TraceIdResponse, TraceIdsResponse, TraceInput, TraceInputSpansValue, TraceOutput, TraceOutputSpansValue, TraceRequest, TraceResponse, TraceType, TracesRequest, TracesResponse, TracingQuery, TriggerAuthScheme, TriggerCatalogEvent, TriggerCatalogEventDetails, TriggerCatalogEventResponse, TriggerCatalogEventsResponse, TriggerCatalogIntegration, TriggerCatalogIntegrationResponse, TriggerCatalogIntegrationsResponse, TriggerCatalogProvider, TriggerCatalogProviderResponse, TriggerCatalogProvidersResponse, TriggerConnection, TriggerConnectionCreate, TriggerConnectionCreateData, TriggerConnectionResponse, TriggerConnectionStatus, TriggerConnectionsResponse, TriggerDeliveriesResponse, TriggerDelivery, TriggerDeliveryData, TriggerDeliveryQuery, TriggerDeliveryResponse, TriggerEventAck, TriggerProviderKind, TriggerSchedule, TriggerScheduleCreate, TriggerScheduleData, TriggerScheduleEdit, TriggerScheduleFlags, TriggerScheduleQuery, TriggerScheduleResponse, TriggerSchedulesResponse, TriggerSubscription, TriggerSubscriptionCreate, TriggerSubscriptionData, TriggerSubscriptionEdit, TriggerSubscriptionFlags, TriggerSubscriptionQuery, TriggerSubscriptionResponse, TriggerSubscriptionsResponse, UserIdsResponse, ValidationError, ValidationErrorLocItem, WebhookDeliveriesResponse, WebhookDelivery, WebhookDeliveryCreate, WebhookDeliveryData, WebhookDeliveryQuery, WebhookDeliveryResponse, WebhookDeliveryResponseInfo, WebhookEventType, WebhookProviderDto, WebhookProviderSettingsDto, WebhookSubscription, WebhookSubscriptionCreate, WebhookSubscriptionData, WebhookSubscriptionDataAuthMode, WebhookSubscriptionEdit, WebhookSubscriptionFlags, WebhookSubscriptionQuery, WebhookSubscriptionResponse, WebhookSubscriptionsResponse, Windowing, WindowingOrder, Workflow, WorkflowArtifactFlags, WorkflowCatalogFlags, WorkflowCatalogPreset, WorkflowCatalogPresetResponse, WorkflowCatalogPresetsResponse, WorkflowCatalogTemplate, WorkflowCatalogTemplateResponse, WorkflowCatalogTemplatesResponse, WorkflowCatalogType, WorkflowCatalogTypeResponse, WorkflowCatalogTypesResponse, WorkflowCreate, WorkflowEdit, WorkflowFlags, WorkflowResponse, WorkflowRevisionCommit, WorkflowRevisionCreate, WorkflowRevisionDataInput, WorkflowRevisionDataInputHeadersValue, WorkflowRevisionDataInputRuntime, WorkflowRevisionDataOutput, WorkflowRevisionDataOutputHeadersValue, WorkflowRevisionDataOutputRuntime, WorkflowRevisionEdit, WorkflowRevisionFlags, WorkflowRevisionInput, WorkflowRevisionOutput, WorkflowRevisionResolveResponse, WorkflowRevisionResponse, WorkflowRevisionsLog, WorkflowRevisionsResponse, WorkflowVariant, WorkflowVariantCreate, WorkflowVariantEdit, WorkflowVariantFlags, WorkflowVariantFork, WorkflowVariantResponse, WorkflowVariantsResponse, WorkflowsResponse, Workspace, WorkspaceMemberResponse, WorkspacePermission, WorkspaceResponse from .errors import UnprocessableEntityError - from . import access, annotations, applications, billing, environments, evaluations, evaluators, events, folders, invocations, keys, legacy, organizations, projects, queries, secrets, status, testcases, testsets, tools, traces, users, webhooks, workflows, workspaces + from . import access, annotations, applications, billing, environments, evaluations, evaluators, events, folders, invocations, keys, legacy, organizations, projects, queries, secrets, status, testcases, testsets, tools, traces, triggers, users, webhooks, workflows, workspaces from .applications import QueryApplicationVariantsRequestOrder from .client import AgentaApi, AsyncAgentaApi from .environment import AgentaApiEnvironment @@ -19,7 +19,7 @@ from .traces import QuerySpansAnalyticsRequestNewest, QuerySpansAnalyticsRequestOldest from .webhooks import WebhookSubscriptionTestRequestSubscription from .workflows import QueryWorkflowRevisionsRequestOrder, QueryWorkflowVariantsRequestOrder, QueryWorkflowsRequestOrder -_dynamic_imports: typing.Dict[str, str] = {"AdminAccountCreateOptions": ".types", "AdminAccountRead": ".types", "AdminAccountsCreate": ".types", "AdminAccountsDelete": ".types", "AdminAccountsDeleteTarget": ".types", "AdminAccountsResponse": ".types", "AdminApiKeyCreate": ".types", "AdminApiKeyResponse": ".types", "AdminDeleteResponse": ".types", "AdminDeletedEntities": ".types", "AdminDeletedEntity": ".types", "AdminOrganizationCreate": ".types", "AdminOrganizationMembershipCreate": ".types", "AdminOrganizationMembershipRead": ".types", "AdminOrganizationRead": ".types", "AdminProjectCreate": ".types", "AdminProjectMembershipCreate": ".types", "AdminProjectMembershipRead": ".types", "AdminProjectRead": ".types", "AdminSimpleAccountCreate": ".types", "AdminSimpleAccountDeleteEntry": ".types", "AdminSimpleAccountRead": ".types", "AdminSimpleAccountsApiKeysCreate": ".types", "AdminSimpleAccountsCreate": ".types", "AdminSimpleAccountsDelete": ".types", "AdminSimpleAccountsOrganizationsCreate": ".types", "AdminSimpleAccountsOrganizationsMembershipsCreate": ".types", "AdminSimpleAccountsOrganizationsTransferOwnership": ".types", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjects": ".types", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjectsZero": ".types", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspaces": ".types", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspacesZero": ".types", "AdminSimpleAccountsOrganizationsTransferOwnershipResponse": ".types", "AdminSimpleAccountsProjectsCreate": ".types", "AdminSimpleAccountsProjectsMembershipsCreate": ".types", "AdminSimpleAccountsResponse": ".types", "AdminSimpleAccountsUsersCreate": ".types", "AdminSimpleAccountsUsersIdentitiesCreate": ".types", "AdminSimpleAccountsUsersResetPassword": ".types", "AdminSimpleAccountsWorkspacesCreate": ".types", "AdminSimpleAccountsWorkspacesMembershipsCreate": ".types", "AdminStructuredError": ".types", "AdminSubscriptionCreate": ".types", "AdminSubscriptionRead": ".types", "AdminUserCreate": ".types", "AdminUserIdentityCreate": ".types", "AdminUserIdentityRead": ".types", "AdminUserIdentityReadStatus": ".types", "AdminUserRead": ".types", "AdminWorkspaceCreate": ".types", "AdminWorkspaceMembershipCreate": ".types", "AdminWorkspaceMembershipRead": ".types", "AdminWorkspaceRead": ".types", "AgentaApi": ".client", "AgentaApiEnvironment": ".environment", "Analytics": ".types", "AnalyticsResponse": ".types", "Annotation": ".types", "AnnotationCreate": ".types", "AnnotationCreateLinks": ".types", "AnnotationEdit": ".types", "AnnotationEditLinks": ".types", "AnnotationLinkResponse": ".types", "AnnotationLinks": ".types", "AnnotationQuery": ".types", "AnnotationQueryLinks": ".types", "AnnotationResponse": ".types", "AnnotationsResponse": ".types", "Application": ".types", "ApplicationArtifactFlags": ".types", "ApplicationArtifactQueryFlags": ".types", "ApplicationCatalogPreset": ".types", "ApplicationCatalogPresetResponse": ".types", "ApplicationCatalogPresetsResponse": ".types", "ApplicationCatalogTemplate": ".types", "ApplicationCatalogTemplateResponse": ".types", "ApplicationCatalogTemplatesResponse": ".types", "ApplicationCatalogType": ".types", "ApplicationCatalogTypesResponse": ".types", "ApplicationCreate": ".types", "ApplicationEdit": ".types", "ApplicationFlags": ".types", "ApplicationQuery": ".types", "ApplicationResponse": ".types", "ApplicationRevisionCommit": ".types", "ApplicationRevisionCreate": ".types", "ApplicationRevisionDataInput": ".types", "ApplicationRevisionDataInputHeadersValue": ".types", "ApplicationRevisionDataInputRuntime": ".types", "ApplicationRevisionDataOutput": ".types", "ApplicationRevisionDataOutputHeadersValue": ".types", "ApplicationRevisionDataOutputRuntime": ".types", "ApplicationRevisionEdit": ".types", "ApplicationRevisionFlags": ".types", "ApplicationRevisionInput": ".types", "ApplicationRevisionOutput": ".types", "ApplicationRevisionQuery": ".types", "ApplicationRevisionQueryFlags": ".types", "ApplicationRevisionResolveResponse": ".types", "ApplicationRevisionResponse": ".types", "ApplicationRevisionsLog": ".types", "ApplicationRevisionsResponse": ".types", "ApplicationVariant": ".types", "ApplicationVariantCreate": ".types", "ApplicationVariantEdit": ".types", "ApplicationVariantFlags": ".types", "ApplicationVariantFork": ".types", "ApplicationVariantResponse": ".types", "ApplicationVariantsResponse": ".types", "ApplicationsResponse": ".types", "AsyncAgentaApi": ".client", "BodyConfigsFetchVariantsConfigsFetchPost": ".types", "Bucket": ".types", "CollectStatusResponse": ".types", "ComparisonOperator": ".types", "Condition": ".types", "ConditionOperator": ".types", "ConditionOptions": ".types", "ConditionValue": ".types", "ConfigResponseModel": ".types", "CreateSimpleTestsetFromFileRequestFileType": ".testsets", "CreateTestsetRevisionFromFileRequestFileType": ".testsets", "CustomModelSettingsDto": ".types", "CustomProviderDto": ".types", "CustomProviderKind": ".types", "CustomProviderSettingsDto": ".types", "DictOperator": ".types", "DiscoverResponse": ".types", "DiscoverResponseMethodsValue": ".types", "EditSimpleTestsetFromFileRequestFileType": ".testsets", "EeSrcModelsApiOrganizationModelsOrganization": ".types", "EntityRef": ".types", "Environment": ".types", "EnvironmentCreate": ".types", "EnvironmentEdit": ".types", "EnvironmentFlags": ".types", "EnvironmentQueryFlags": ".types", "EnvironmentResponse": ".types", "EnvironmentRevisionCommit": ".types", "EnvironmentRevisionCreate": ".types", "EnvironmentRevisionData": ".types", "EnvironmentRevisionDelta": ".types", "EnvironmentRevisionEdit": ".types", "EnvironmentRevisionInput": ".types", "EnvironmentRevisionOutput": ".types", "EnvironmentRevisionResolveResponse": ".types", "EnvironmentRevisionResponse": ".types", "EnvironmentRevisionsLog": ".types", "EnvironmentRevisionsResponse": ".types", "EnvironmentVariant": ".types", "EnvironmentVariantCreate": ".types", "EnvironmentVariantEdit": ".types", "EnvironmentVariantFork": ".types", "EnvironmentVariantResponse": ".types", "EnvironmentVariantsResponse": ".types", "EnvironmentsResponse": ".types", "ErrorPolicy": ".types", "EvaluationMetrics": ".types", "EvaluationMetricsCreate": ".types", "EvaluationMetricsIdsResponse": ".types", "EvaluationMetricsQuery": ".types", "EvaluationMetricsQueryScenarioIds": ".types", "EvaluationMetricsQueryTimestamps": ".types", "EvaluationMetricsRefresh": ".types", "EvaluationMetricsResponse": ".types", "EvaluationMetricsSetRequest": ".types", "EvaluationQueue": ".types", "EvaluationQueueCreate": ".types", "EvaluationQueueData": ".types", "EvaluationQueueEdit": ".types", "EvaluationQueueFlags": ".types", "EvaluationQueueIdResponse": ".types", "EvaluationQueueIdsResponse": ".types", "EvaluationQueueQuery": ".types", "EvaluationQueueQueryFlags": ".types", "EvaluationQueueResponse": ".types", "EvaluationQueueScenariosQuery": ".types", "EvaluationQueuesResponse": ".types", "EvaluationResult": ".types", "EvaluationResultCreate": ".types", "EvaluationResultIdResponse": ".types", "EvaluationResultIdsResponse": ".types", "EvaluationResultQuery": ".types", "EvaluationResultResponse": ".types", "EvaluationResultsResponse": ".types", "EvaluationResultsSetRequest": ".types", "EvaluationRun": ".types", "EvaluationRunCreate": ".types", "EvaluationRunDataConcurrency": ".types", "EvaluationRunDataInput": ".types", "EvaluationRunDataMapping": ".types", "EvaluationRunDataMappingColumn": ".types", "EvaluationRunDataMappingStep": ".types", "EvaluationRunDataOutput": ".types", "EvaluationRunDataStepInput": ".types", "EvaluationRunDataStepInputKey": ".types", "EvaluationRunDataStepInputOrigin": ".types", "EvaluationRunDataStepInputType": ".types", "EvaluationRunDataStepOutput": ".types", "EvaluationRunDataStepOutputOrigin": ".types", "EvaluationRunDataStepOutputType": ".types", "EvaluationRunEdit": ".types", "EvaluationRunFlags": ".types", "EvaluationRunIdResponse": ".types", "EvaluationRunIdsRequest": ".types", "EvaluationRunIdsResponse": ".types", "EvaluationRunQuery": ".types", "EvaluationRunQueryFlags": ".types", "EvaluationRunResponse": ".types", "EvaluationRunsResponse": ".types", "EvaluationScenario": ".types", "EvaluationScenarioCreate": ".types", "EvaluationScenarioEdit": ".types", "EvaluationScenarioIdResponse": ".types", "EvaluationScenarioIdsResponse": ".types", "EvaluationScenarioQuery": ".types", "EvaluationScenarioResponse": ".types", "EvaluationScenariosResponse": ".types", "EvaluationStatus": ".types", "Evaluator": ".types", "EvaluatorArtifactFlags": ".types", "EvaluatorArtifactQueryFlags": ".types", "EvaluatorCatalogPreset": ".types", "EvaluatorCatalogPresetResponse": ".types", "EvaluatorCatalogPresetsResponse": ".types", "EvaluatorCatalogTemplate": ".types", "EvaluatorCatalogTemplateResponse": ".types", "EvaluatorCatalogTemplatesResponse": ".types", "EvaluatorCatalogType": ".types", "EvaluatorCatalogTypesResponse": ".types", "EvaluatorCreate": ".types", "EvaluatorEdit": ".types", "EvaluatorFlags": ".types", "EvaluatorQuery": ".types", "EvaluatorResponse": ".types", "EvaluatorRevisionCommit": ".types", "EvaluatorRevisionCreate": ".types", "EvaluatorRevisionDataInput": ".types", "EvaluatorRevisionDataInputHeadersValue": ".types", "EvaluatorRevisionDataInputRuntime": ".types", "EvaluatorRevisionDataOutput": ".types", "EvaluatorRevisionDataOutputHeadersValue": ".types", "EvaluatorRevisionDataOutputRuntime": ".types", "EvaluatorRevisionEdit": ".types", "EvaluatorRevisionFlags": ".types", "EvaluatorRevisionInput": ".types", "EvaluatorRevisionOutput": ".types", "EvaluatorRevisionQuery": ".types", "EvaluatorRevisionQueryFlags": ".types", "EvaluatorRevisionResolveResponse": ".types", "EvaluatorRevisionResponse": ".types", "EvaluatorRevisionsLog": ".types", "EvaluatorRevisionsResponse": ".types", "EvaluatorTemplate": ".types", "EvaluatorTemplatesResponse": ".types", "EvaluatorVariant": ".types", "EvaluatorVariantCreate": ".types", "EvaluatorVariantEdit": ".types", "EvaluatorVariantFlags": ".types", "EvaluatorVariantFork": ".types", "EvaluatorVariantResponse": ".types", "EvaluatorVariantsResponse": ".types", "EvaluatorsResponse": ".types", "Event": ".types", "EventQuery": ".types", "EventType": ".types", "EventsQueryResponse": ".types", "ExistenceOperator": ".types", "FetchLegacyAnalyticsRequestNewest": ".legacy", "FetchLegacyAnalyticsRequestOldest": ".legacy", "FetchSimpleTestsetToFileRequestFileType": ".testsets", "FetchTestsetRevisionToFileRequestFileType": ".testsets", "FilteringInput": ".types", "FilteringInputConditionsItem": ".types", "FilteringOutput": ".types", "FilteringOutputConditionsItem": ".types", "Focus": ".types", "Folder": ".types", "FolderCreate": ".types", "FolderEdit": ".types", "FolderIdResponse": ".types", "FolderKind": ".types", "FolderQuery": ".types", "FolderQueryKinds": ".types", "FolderResponse": ".types", "FoldersResponse": ".types", "Format": ".types", "Formatting": ".types", typing.Any: ".types", typing.Any: ".types", "Header": ".types", "HttpValidationError": ".types", "InviteRequest": ".types", "Invocation": ".types", "InvocationCreate": ".types", "InvocationCreateLinks": ".types", "InvocationEdit": ".types", "InvocationEditLinks": ".types", "InvocationLinkResponse": ".types", "InvocationLinks": ".types", "InvocationQuery": ".types", "InvocationQueryLinks": ".types", "InvocationResponse": ".types", "InvocationsResponse": ".types", "JsonSchemasInput": ".types", "JsonSchemasOutput": ".types", typing.Any: ".types", typing.Any: ".types", "LegacyLifecycleDto": ".types", "ListApiKeysResponse": ".types", "ListOperator": ".types", "ListOptions": ".types", "LogicalOperator": ".types", "MetricSpec": ".types", "MetricType": ".types", "MetricsBucket": ".types", "NumericOperator": ".types", "OTelEventInput": ".types", "OTelEventInputTimestamp": ".types", "OTelEventOutput": ".types", "OTelEventOutputTimestamp": ".types", "OTelHashInput": ".types", "OTelHashOutput": ".types", "OTelLinkInput": ".types", "OTelLinkOutput": ".types", "OTelLinksResponse": ".types", "OTelReferenceInput": ".types", "OTelReferenceOutput": ".types", "OTelSpanKind": ".types", "OTelStatusCode": ".types", "OTelTracingRequest": ".types", "OTelTracingResponse": ".types", "OldAnalyticsResponse": ".types", "OrganizationDetails": ".types", "OrganizationDomainResponse": ".types", "OrganizationProviderResponse": ".types", "OrganizationUpdate": ".types", "OssSrcModelsApiOrganizationModelsOrganization": ".types", "Permission": ".types", "ProjectsResponse": ".types", "QueriesResponse": ".types", "Query": ".types", "QueryApplicationVariantsRequestOrder": ".applications", "QueryCreate": ".types", "QueryEdit": ".types", "QueryEnvironmentRevisionsRequestOrder": ".environments", "QueryEnvironmentVariantsRequestOrder": ".environments", "QueryEnvironmentsRequestOrder": ".environments", "QueryEvaluatorVariantsRequestOrder": ".evaluators", "QueryFlags": ".types", "QueryQueriesRequestOrder": ".queries", "QueryQueryFlags": ".types", "QueryResponse": ".types", "QueryRevision": ".types", "QueryRevisionCommit": ".types", "QueryRevisionCreate": ".types", "QueryRevisionDataInput": ".types", "QueryRevisionDataOutput": ".types", "QueryRevisionEdit": ".types", "QueryRevisionQuery": ".types", "QueryRevisionResponse": ".types", "QueryRevisionsLog": ".types", "QueryRevisionsResponse": ".types", "QuerySpansAnalyticsRequestNewest": ".traces", "QuerySpansAnalyticsRequestOldest": ".traces", "QueryVariant": ".types", "QueryVariantCreate": ".types", "QueryVariantEdit": ".types", "QueryVariantFork": ".types", "QueryVariantQuery": ".types", "QueryVariantResponse": ".types", "QueryVariantsResponse": ".types", "QueryWorkflowRevisionsRequestOrder": ".workflows", "QueryWorkflowVariantsRequestOrder": ".workflows", "QueryWorkflowsRequestOrder": ".workflows", "Reference": ".types", "ReferenceRequestModelInput": ".types", "ReferenceRequestModelOutput": ".types", "RequestType": ".types", "ResolutionInfo": ".types", "RetrievalInfo": ".types", "SecretDto": ".types", "SecretDtoData": ".types", "SecretKind": ".types", "SecretResponseDto": ".types", "SecretResponseDtoData": ".types", "SessionIdsResponse": ".types", "SimpleApplication": ".types", "SimpleApplicationCreate": ".types", "SimpleApplicationDataInput": ".types", "SimpleApplicationDataInputHeadersValue": ".types", "SimpleApplicationDataInputRuntime": ".types", "SimpleApplicationDataOutput": ".types", "SimpleApplicationDataOutputHeadersValue": ".types", "SimpleApplicationDataOutputRuntime": ".types", "SimpleApplicationEdit": ".types", "SimpleApplicationFlags": ".types", "SimpleApplicationQuery": ".types", "SimpleApplicationQueryFlags": ".types", "SimpleApplicationResponse": ".types", "SimpleApplicationsResponse": ".types", "SimpleEnvironment": ".types", "SimpleEnvironmentCreate": ".types", "SimpleEnvironmentEdit": ".types", "SimpleEnvironmentQuery": ".types", "SimpleEnvironmentResponse": ".types", "SimpleEnvironmentsResponse": ".types", "SimpleEvaluation": ".types", "SimpleEvaluationCreate": ".types", "SimpleEvaluationData": ".types", "SimpleEvaluationDataApplicationSteps": ".types", "SimpleEvaluationDataApplicationStepsOneValue": ".types", "SimpleEvaluationDataEvaluatorSteps": ".types", "SimpleEvaluationDataEvaluatorStepsOneValue": ".types", "SimpleEvaluationDataQuerySteps": ".types", "SimpleEvaluationDataQueryStepsOneValue": ".types", "SimpleEvaluationDataTestsetSteps": ".types", "SimpleEvaluationDataTestsetStepsOneValue": ".types", "SimpleEvaluationEdit": ".types", "SimpleEvaluationIdResponse": ".types", "SimpleEvaluationQuery": ".types", "SimpleEvaluationResponse": ".types", "SimpleEvaluationsResponse": ".types", "SimpleEvaluator": ".types", "SimpleEvaluatorCreate": ".types", "SimpleEvaluatorDataInput": ".types", "SimpleEvaluatorDataInputHeadersValue": ".types", "SimpleEvaluatorDataInputRuntime": ".types", "SimpleEvaluatorDataOutput": ".types", "SimpleEvaluatorDataOutputHeadersValue": ".types", "SimpleEvaluatorDataOutputRuntime": ".types", "SimpleEvaluatorEdit": ".types", "SimpleEvaluatorFlags": ".types", "SimpleEvaluatorQuery": ".types", "SimpleEvaluatorQueryFlags": ".types", "SimpleEvaluatorResponse": ".types", "SimpleEvaluatorsResponse": ".types", "SimpleQueriesResponse": ".types", "SimpleQuery": ".types", "SimpleQueryCreate": ".types", "SimpleQueryEdit": ".types", "SimpleQueryQuery": ".types", "SimpleQueryResponse": ".types", "SimpleQueue": ".types", "SimpleQueueCreate": ".types", "SimpleQueueData": ".types", "SimpleQueueDataEvaluators": ".types", "SimpleQueueDataEvaluatorsOneValue": ".types", "SimpleQueueIdResponse": ".types", "SimpleQueueIdsResponse": ".types", "SimpleQueueKind": ".types", "SimpleQueueQuery": ".types", "SimpleQueueResponse": ".types", "SimpleQueueScenariosQuery": ".types", "SimpleQueueScenariosResponse": ".types", "SimpleQueueSettings": ".types", "SimpleQueuesResponse": ".types", "SimpleTestset": ".types", "SimpleTestsetCreate": ".types", "SimpleTestsetEdit": ".types", "SimpleTestsetQuery": ".types", "SimpleTestsetResponse": ".types", "SimpleTestsetsResponse": ".types", "SimpleTrace": ".types", "SimpleTraceChannel": ".types", "SimpleTraceCreate": ".types", "SimpleTraceCreateLinks": ".types", "SimpleTraceEdit": ".types", "SimpleTraceEditLinks": ".types", "SimpleTraceKind": ".types", "SimpleTraceLinkResponse": ".types", "SimpleTraceLinks": ".types", "SimpleTraceOrigin": ".types", "SimpleTraceQuery": ".types", "SimpleTraceQueryLinks": ".types", "SimpleTraceReferences": ".types", "SimpleTraceResponse": ".types", "SimpleTracesResponse": ".types", "SimpleWorkflow": ".types", "SimpleWorkflowCreate": ".types", "SimpleWorkflowDataInput": ".types", "SimpleWorkflowDataInputHeadersValue": ".types", "SimpleWorkflowDataInputRuntime": ".types", "SimpleWorkflowDataOutput": ".types", "SimpleWorkflowDataOutputHeadersValue": ".types", "SimpleWorkflowDataOutputRuntime": ".types", "SimpleWorkflowEdit": ".types", "SimpleWorkflowFlags": ".types", "SimpleWorkflowQuery": ".types", "SimpleWorkflowQueryFlags": ".types", "SimpleWorkflowResponse": ".types", "SimpleWorkflowsResponse": ".types", "SpanInput": ".types", "SpanInputEndTime": ".types", "SpanInputStartTime": ".types", "SpanOutput": ".types", "SpanOutputEndTime": ".types", "SpanOutputStartTime": ".types", "SpanResponse": ".types", "SpanType": ".types", "SpansNodeInput": ".types", "SpansNodeInputEndTime": ".types", "SpansNodeInputSpansValue": ".types", "SpansNodeInputStartTime": ".types", "SpansNodeOutput": ".types", "SpansNodeOutputEndTime": ".types", "SpansNodeOutputSpansValue": ".types", "SpansNodeOutputStartTime": ".types", "SpansResponse": ".types", "SpansTreeInput": ".types", "SpansTreeInputSpansValue": ".types", "SpansTreeOutput": ".types", "SpansTreeOutputSpansValue": ".types", "SsoProviderDto": ".types", "SsoProviderInfo": ".types", "SsoProviderSettingsDto": ".types", "SsoProviders": ".types", "StandardProviderDto": ".types", "StandardProviderKind": ".types", "StandardProviderSettingsDto": ".types", "Status": ".types", "StringOperator": ".types", "TestcaseInput": ".types", "TestcaseOutput": ".types", "TestcaseResponse": ".types", "TestcasesResponse": ".types", "Testset": ".types", "TestsetCreate": ".types", "TestsetEdit": ".types", "TestsetFlags": ".types", "TestsetQuery": ".types", "TestsetResponse": ".types", "TestsetRevision": ".types", "TestsetRevisionCommit": ".types", "TestsetRevisionCreate": ".types", "TestsetRevisionDataInput": ".types", "TestsetRevisionDataOutput": ".types", "TestsetRevisionDelta": ".types", "TestsetRevisionDeltaColumns": ".types", "TestsetRevisionDeltaRows": ".types", "TestsetRevisionEdit": ".types", "TestsetRevisionQuery": ".types", "TestsetRevisionResponse": ".types", "TestsetRevisionsLog": ".types", "TestsetRevisionsResponse": ".types", "TestsetVariant": ".types", "TestsetVariantCreate": ".types", "TestsetVariantEdit": ".types", "TestsetVariantFork": ".types", "TestsetVariantQuery": ".types", "TestsetVariantResponse": ".types", "TestsetVariantsResponse": ".types", "TestsetsResponse": ".types", "TextOptions": ".types", "ToolAuthScheme": ".types", "ToolCallData": ".types", "ToolCallFunction": ".types", "ToolCallResponse": ".types", "ToolCatalogAction": ".types", "ToolCatalogActionDetails": ".types", "ToolCatalogActionResponse": ".types", "ToolCatalogActionResponseAction": ".types", "ToolCatalogActionsResponse": ".types", "ToolCatalogActionsResponseActionsItem": ".types", "ToolCatalogIntegration": ".types", "ToolCatalogIntegrationDetails": ".types", "ToolCatalogIntegrationResponse": ".types", "ToolCatalogIntegrationResponseIntegration": ".types", "ToolCatalogIntegrationsResponse": ".types", "ToolCatalogIntegrationsResponseIntegrationsItem": ".types", "ToolCatalogProvider": ".types", "ToolCatalogProviderDetails": ".types", "ToolCatalogProviderResponse": ".types", "ToolCatalogProviderResponseProvider": ".types", "ToolCatalogProvidersResponse": ".types", "ToolCatalogProvidersResponseProvidersItem": ".types", "ToolConnection": ".types", "ToolConnectionCreate": ".types", "ToolConnectionCreateData": ".types", "ToolConnectionResponse": ".types", "ToolConnectionStatus": ".types", "ToolConnectionsResponse": ".types", "ToolProviderKind": ".types", "ToolResult": ".types", "ToolResultData": ".types", "TraceIdResponse": ".types", "TraceIdsResponse": ".types", "TraceInput": ".types", "TraceInputSpansValue": ".types", "TraceOutput": ".types", "TraceOutputSpansValue": ".types", "TraceRequest": ".types", "TraceResponse": ".types", "TraceType": ".types", "TracesRequest": ".types", "TracesResponse": ".types", "TracingQuery": ".types", "UnprocessableEntityError": ".errors", "UserIdsResponse": ".types", "ValidationError": ".types", "ValidationErrorLocItem": ".types", "WebhookDeliveriesResponse": ".types", "WebhookDelivery": ".types", "WebhookDeliveryCreate": ".types", "WebhookDeliveryData": ".types", "WebhookDeliveryQuery": ".types", "WebhookDeliveryResponse": ".types", "WebhookDeliveryResponseInfo": ".types", "WebhookEventType": ".types", "WebhookProviderDto": ".types", "WebhookProviderSettingsDto": ".types", "WebhookSubscription": ".types", "WebhookSubscriptionCreate": ".types", "WebhookSubscriptionData": ".types", "WebhookSubscriptionDataAuthMode": ".types", "WebhookSubscriptionEdit": ".types", "WebhookSubscriptionQuery": ".types", "WebhookSubscriptionResponse": ".types", "WebhookSubscriptionTestRequestSubscription": ".webhooks", "WebhookSubscriptionsResponse": ".types", "Windowing": ".types", "WindowingOrder": ".types", "Workflow": ".types", "WorkflowArtifactFlags": ".types", "WorkflowCatalogFlags": ".types", "WorkflowCatalogPreset": ".types", "WorkflowCatalogPresetResponse": ".types", "WorkflowCatalogPresetsResponse": ".types", "WorkflowCatalogTemplate": ".types", "WorkflowCatalogTemplateResponse": ".types", "WorkflowCatalogTemplatesResponse": ".types", "WorkflowCatalogType": ".types", "WorkflowCatalogTypeResponse": ".types", "WorkflowCatalogTypesResponse": ".types", "WorkflowCreate": ".types", "WorkflowEdit": ".types", "WorkflowFlags": ".types", "WorkflowResponse": ".types", "WorkflowRevisionCommit": ".types", "WorkflowRevisionCreate": ".types", "WorkflowRevisionDataInput": ".types", "WorkflowRevisionDataInputHeadersValue": ".types", "WorkflowRevisionDataInputRuntime": ".types", "WorkflowRevisionDataOutput": ".types", "WorkflowRevisionDataOutputHeadersValue": ".types", "WorkflowRevisionDataOutputRuntime": ".types", "WorkflowRevisionEdit": ".types", "WorkflowRevisionFlags": ".types", "WorkflowRevisionInput": ".types", "WorkflowRevisionOutput": ".types", "WorkflowRevisionResolveResponse": ".types", "WorkflowRevisionResponse": ".types", "WorkflowRevisionsLog": ".types", "WorkflowRevisionsResponse": ".types", "WorkflowVariant": ".types", "WorkflowVariantCreate": ".types", "WorkflowVariantEdit": ".types", "WorkflowVariantFlags": ".types", "WorkflowVariantFork": ".types", "WorkflowVariantResponse": ".types", "WorkflowVariantsResponse": ".types", "WorkflowsResponse": ".types", "Workspace": ".types", "WorkspaceMemberResponse": ".types", "WorkspacePermission": ".types", "WorkspaceResponse": ".types", "access": ".access", "annotations": ".annotations", "applications": ".applications", "billing": ".billing", "environments": ".environments", "evaluations": ".evaluations", "evaluators": ".evaluators", "events": ".events", "folders": ".folders", "invocations": ".invocations", "keys": ".keys", "legacy": ".legacy", "organizations": ".organizations", "projects": ".projects", "queries": ".queries", "secrets": ".secrets", "status": ".status", "testcases": ".testcases", "testsets": ".testsets", "tools": ".tools", "traces": ".traces", "users": ".users", "webhooks": ".webhooks", "workflows": ".workflows", "workspaces": ".workspaces"} +_dynamic_imports: typing.Dict[str, str] = {"AdminAccountCreateOptions": ".types", "AdminAccountRead": ".types", "AdminAccountsCreate": ".types", "AdminAccountsDelete": ".types", "AdminAccountsDeleteTarget": ".types", "AdminAccountsResponse": ".types", "AdminApiKeyCreate": ".types", "AdminApiKeyResponse": ".types", "AdminDeleteResponse": ".types", "AdminDeletedEntities": ".types", "AdminDeletedEntity": ".types", "AdminOrganizationCreate": ".types", "AdminOrganizationMembershipCreate": ".types", "AdminOrganizationMembershipRead": ".types", "AdminOrganizationRead": ".types", "AdminProjectCreate": ".types", "AdminProjectMembershipCreate": ".types", "AdminProjectMembershipRead": ".types", "AdminProjectRead": ".types", "AdminSimpleAccountCreate": ".types", "AdminSimpleAccountDeleteEntry": ".types", "AdminSimpleAccountRead": ".types", "AdminSimpleAccountsApiKeysCreate": ".types", "AdminSimpleAccountsCreate": ".types", "AdminSimpleAccountsDelete": ".types", "AdminSimpleAccountsOrganizationsCreate": ".types", "AdminSimpleAccountsOrganizationsMembershipsCreate": ".types", "AdminSimpleAccountsOrganizationsTransferOwnership": ".types", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjects": ".types", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjectsZero": ".types", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspaces": ".types", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspacesZero": ".types", "AdminSimpleAccountsOrganizationsTransferOwnershipResponse": ".types", "AdminSimpleAccountsProjectsCreate": ".types", "AdminSimpleAccountsProjectsMembershipsCreate": ".types", "AdminSimpleAccountsResponse": ".types", "AdminSimpleAccountsUsersCreate": ".types", "AdminSimpleAccountsUsersIdentitiesCreate": ".types", "AdminSimpleAccountsUsersResetPassword": ".types", "AdminSimpleAccountsWorkspacesCreate": ".types", "AdminSimpleAccountsWorkspacesMembershipsCreate": ".types", "AdminStructuredError": ".types", "AdminSubscriptionCreate": ".types", "AdminSubscriptionRead": ".types", "AdminUserCreate": ".types", "AdminUserIdentityCreate": ".types", "AdminUserIdentityRead": ".types", "AdminUserIdentityReadStatus": ".types", "AdminUserRead": ".types", "AdminWorkspaceCreate": ".types", "AdminWorkspaceMembershipCreate": ".types", "AdminWorkspaceMembershipRead": ".types", "AdminWorkspaceRead": ".types", "AgentaApi": ".client", "AgentaApiEnvironment": ".environment", "Analytics": ".types", "AnalyticsResponse": ".types", "Annotation": ".types", "AnnotationCreate": ".types", "AnnotationCreateLinks": ".types", "AnnotationEdit": ".types", "AnnotationEditLinks": ".types", "AnnotationLinkResponse": ".types", "AnnotationLinks": ".types", "AnnotationQuery": ".types", "AnnotationQueryLinks": ".types", "AnnotationResponse": ".types", "AnnotationsResponse": ".types", "Application": ".types", "ApplicationArtifactFlags": ".types", "ApplicationArtifactQueryFlags": ".types", "ApplicationCatalogPreset": ".types", "ApplicationCatalogPresetResponse": ".types", "ApplicationCatalogPresetsResponse": ".types", "ApplicationCatalogTemplate": ".types", "ApplicationCatalogTemplateResponse": ".types", "ApplicationCatalogTemplatesResponse": ".types", "ApplicationCatalogType": ".types", "ApplicationCatalogTypesResponse": ".types", "ApplicationCreate": ".types", "ApplicationEdit": ".types", "ApplicationFlags": ".types", "ApplicationQuery": ".types", "ApplicationResponse": ".types", "ApplicationRevisionCommit": ".types", "ApplicationRevisionCreate": ".types", "ApplicationRevisionDataInput": ".types", "ApplicationRevisionDataInputHeadersValue": ".types", "ApplicationRevisionDataInputRuntime": ".types", "ApplicationRevisionDataOutput": ".types", "ApplicationRevisionDataOutputHeadersValue": ".types", "ApplicationRevisionDataOutputRuntime": ".types", "ApplicationRevisionEdit": ".types", "ApplicationRevisionFlags": ".types", "ApplicationRevisionInput": ".types", "ApplicationRevisionOutput": ".types", "ApplicationRevisionQuery": ".types", "ApplicationRevisionQueryFlags": ".types", "ApplicationRevisionResolveResponse": ".types", "ApplicationRevisionResponse": ".types", "ApplicationRevisionsLog": ".types", "ApplicationRevisionsResponse": ".types", "ApplicationVariant": ".types", "ApplicationVariantCreate": ".types", "ApplicationVariantEdit": ".types", "ApplicationVariantFlags": ".types", "ApplicationVariantFork": ".types", "ApplicationVariantResponse": ".types", "ApplicationVariantsResponse": ".types", "ApplicationsResponse": ".types", "AsyncAgentaApi": ".client", "BodyConfigsFetchVariantsConfigsFetchPost": ".types", "Bucket": ".types", "CollectStatusResponse": ".types", "ComparisonOperator": ".types", "Condition": ".types", "ConditionOperator": ".types", "ConditionOptions": ".types", "ConditionValue": ".types", "ConfigResponseModel": ".types", "CreateSimpleTestsetFromFileRequestFileType": ".testsets", "CreateTestsetRevisionFromFileRequestFileType": ".testsets", "CustomModelSettingsDto": ".types", "CustomProviderDto": ".types", "CustomProviderKind": ".types", "CustomProviderSettingsDto": ".types", "DictOperator": ".types", "DiscoverResponse": ".types", "DiscoverResponseMethodsValue": ".types", "EditSimpleTestsetFromFileRequestFileType": ".testsets", "EeSrcModelsApiOrganizationModelsOrganization": ".types", "EntityRef": ".types", "Environment": ".types", "EnvironmentCreate": ".types", "EnvironmentEdit": ".types", "EnvironmentFlags": ".types", "EnvironmentQueryFlags": ".types", "EnvironmentResponse": ".types", "EnvironmentRevisionCommit": ".types", "EnvironmentRevisionCreate": ".types", "EnvironmentRevisionData": ".types", "EnvironmentRevisionDelta": ".types", "EnvironmentRevisionEdit": ".types", "EnvironmentRevisionInput": ".types", "EnvironmentRevisionOutput": ".types", "EnvironmentRevisionResolveResponse": ".types", "EnvironmentRevisionResponse": ".types", "EnvironmentRevisionsLog": ".types", "EnvironmentRevisionsResponse": ".types", "EnvironmentVariant": ".types", "EnvironmentVariantCreate": ".types", "EnvironmentVariantEdit": ".types", "EnvironmentVariantFork": ".types", "EnvironmentVariantResponse": ".types", "EnvironmentVariantsResponse": ".types", "EnvironmentsResponse": ".types", "ErrorPolicy": ".types", "EvaluationMetrics": ".types", "EvaluationMetricsCreate": ".types", "EvaluationMetricsIdsResponse": ".types", "EvaluationMetricsQuery": ".types", "EvaluationMetricsQueryScenarioIds": ".types", "EvaluationMetricsQueryTimestamps": ".types", "EvaluationMetricsRefresh": ".types", "EvaluationMetricsResponse": ".types", "EvaluationMetricsSetRequest": ".types", "EvaluationQueue": ".types", "EvaluationQueueCreate": ".types", "EvaluationQueueData": ".types", "EvaluationQueueEdit": ".types", "EvaluationQueueFlags": ".types", "EvaluationQueueIdResponse": ".types", "EvaluationQueueIdsResponse": ".types", "EvaluationQueueQuery": ".types", "EvaluationQueueQueryFlags": ".types", "EvaluationQueueResponse": ".types", "EvaluationQueueScenariosQuery": ".types", "EvaluationQueuesResponse": ".types", "EvaluationResult": ".types", "EvaluationResultCreate": ".types", "EvaluationResultIdResponse": ".types", "EvaluationResultIdsResponse": ".types", "EvaluationResultQuery": ".types", "EvaluationResultResponse": ".types", "EvaluationResultsResponse": ".types", "EvaluationResultsSetRequest": ".types", "EvaluationRun": ".types", "EvaluationRunCreate": ".types", "EvaluationRunDataConcurrency": ".types", "EvaluationRunDataInput": ".types", "EvaluationRunDataMapping": ".types", "EvaluationRunDataMappingColumn": ".types", "EvaluationRunDataMappingStep": ".types", "EvaluationRunDataOutput": ".types", "EvaluationRunDataStepInput": ".types", "EvaluationRunDataStepInputKey": ".types", "EvaluationRunDataStepInputOrigin": ".types", "EvaluationRunDataStepInputType": ".types", "EvaluationRunDataStepOutput": ".types", "EvaluationRunDataStepOutputOrigin": ".types", "EvaluationRunDataStepOutputType": ".types", "EvaluationRunEdit": ".types", "EvaluationRunFlags": ".types", "EvaluationRunIdResponse": ".types", "EvaluationRunIdsRequest": ".types", "EvaluationRunIdsResponse": ".types", "EvaluationRunQuery": ".types", "EvaluationRunQueryFlags": ".types", "EvaluationRunResponse": ".types", "EvaluationRunsResponse": ".types", "EvaluationScenario": ".types", "EvaluationScenarioCreate": ".types", "EvaluationScenarioEdit": ".types", "EvaluationScenarioIdResponse": ".types", "EvaluationScenarioIdsResponse": ".types", "EvaluationScenarioQuery": ".types", "EvaluationScenarioResponse": ".types", "EvaluationScenariosResponse": ".types", "EvaluationStatus": ".types", "Evaluator": ".types", "EvaluatorArtifactFlags": ".types", "EvaluatorArtifactQueryFlags": ".types", "EvaluatorCatalogPreset": ".types", "EvaluatorCatalogPresetResponse": ".types", "EvaluatorCatalogPresetsResponse": ".types", "EvaluatorCatalogTemplate": ".types", "EvaluatorCatalogTemplateResponse": ".types", "EvaluatorCatalogTemplatesResponse": ".types", "EvaluatorCatalogType": ".types", "EvaluatorCatalogTypesResponse": ".types", "EvaluatorCreate": ".types", "EvaluatorEdit": ".types", "EvaluatorFlags": ".types", "EvaluatorQuery": ".types", "EvaluatorResponse": ".types", "EvaluatorRevisionCommit": ".types", "EvaluatorRevisionCreate": ".types", "EvaluatorRevisionDataInput": ".types", "EvaluatorRevisionDataInputHeadersValue": ".types", "EvaluatorRevisionDataInputRuntime": ".types", "EvaluatorRevisionDataOutput": ".types", "EvaluatorRevisionDataOutputHeadersValue": ".types", "EvaluatorRevisionDataOutputRuntime": ".types", "EvaluatorRevisionEdit": ".types", "EvaluatorRevisionFlags": ".types", "EvaluatorRevisionInput": ".types", "EvaluatorRevisionOutput": ".types", "EvaluatorRevisionQuery": ".types", "EvaluatorRevisionQueryFlags": ".types", "EvaluatorRevisionResolveResponse": ".types", "EvaluatorRevisionResponse": ".types", "EvaluatorRevisionsLog": ".types", "EvaluatorRevisionsResponse": ".types", "EvaluatorTemplate": ".types", "EvaluatorTemplatesResponse": ".types", "EvaluatorVariant": ".types", "EvaluatorVariantCreate": ".types", "EvaluatorVariantEdit": ".types", "EvaluatorVariantFlags": ".types", "EvaluatorVariantFork": ".types", "EvaluatorVariantResponse": ".types", "EvaluatorVariantsResponse": ".types", "EvaluatorsResponse": ".types", "Event": ".types", "EventQuery": ".types", "EventType": ".types", "EventsQueryResponse": ".types", "ExistenceOperator": ".types", "FetchLegacyAnalyticsRequestNewest": ".legacy", "FetchLegacyAnalyticsRequestOldest": ".legacy", "FetchSimpleTestsetToFileRequestFileType": ".testsets", "FetchTestsetRevisionToFileRequestFileType": ".testsets", "FilteringInput": ".types", "FilteringInputConditionsItem": ".types", "FilteringOutput": ".types", "FilteringOutputConditionsItem": ".types", "Focus": ".types", "Folder": ".types", "FolderCreate": ".types", "FolderEdit": ".types", "FolderIdResponse": ".types", "FolderKind": ".types", "FolderQuery": ".types", "FolderQueryKinds": ".types", "FolderResponse": ".types", "FoldersResponse": ".types", "Format": ".types", "Formatting": ".types", typing.Any: ".types", typing.Any: ".types", "Header": ".types", "HttpValidationError": ".types", "InviteRequest": ".types", "Invocation": ".types", "InvocationCreate": ".types", "InvocationCreateLinks": ".types", "InvocationEdit": ".types", "InvocationEditLinks": ".types", "InvocationLinkResponse": ".types", "InvocationLinks": ".types", "InvocationQuery": ".types", "InvocationQueryLinks": ".types", "InvocationResponse": ".types", "InvocationsResponse": ".types", "JsonSchemasInput": ".types", "JsonSchemasOutput": ".types", typing.Any: ".types", typing.Any: ".types", "LegacyLifecycleDto": ".types", "ListApiKeysResponse": ".types", "ListOperator": ".types", "ListOptions": ".types", "LogicalOperator": ".types", "MetricSpec": ".types", "MetricType": ".types", "MetricsBucket": ".types", "NumericOperator": ".types", "OTelEventInput": ".types", "OTelEventInputTimestamp": ".types", "OTelEventOutput": ".types", "OTelEventOutputTimestamp": ".types", "OTelHashInput": ".types", "OTelHashOutput": ".types", "OTelLinkInput": ".types", "OTelLinkOutput": ".types", "OTelLinksResponse": ".types", "OTelReferenceInput": ".types", "OTelReferenceOutput": ".types", "OTelSpanKind": ".types", "OTelStatusCode": ".types", "OTelTracingRequest": ".types", "OTelTracingResponse": ".types", "OldAnalyticsResponse": ".types", "OrganizationDetails": ".types", "OrganizationDomainResponse": ".types", "OrganizationProviderResponse": ".types", "OrganizationUpdate": ".types", "OssSrcModelsApiOrganizationModelsOrganization": ".types", "Permission": ".types", "ProjectsResponse": ".types", "QueriesResponse": ".types", "Query": ".types", "QueryApplicationVariantsRequestOrder": ".applications", "QueryCreate": ".types", "QueryEdit": ".types", "QueryEnvironmentRevisionsRequestOrder": ".environments", "QueryEnvironmentVariantsRequestOrder": ".environments", "QueryEnvironmentsRequestOrder": ".environments", "QueryEvaluatorVariantsRequestOrder": ".evaluators", "QueryFlags": ".types", "QueryQueriesRequestOrder": ".queries", "QueryQueryFlags": ".types", "QueryResponse": ".types", "QueryRevision": ".types", "QueryRevisionCommit": ".types", "QueryRevisionCreate": ".types", "QueryRevisionDataInput": ".types", "QueryRevisionDataOutput": ".types", "QueryRevisionEdit": ".types", "QueryRevisionQuery": ".types", "QueryRevisionResponse": ".types", "QueryRevisionsLog": ".types", "QueryRevisionsResponse": ".types", "QuerySpansAnalyticsRequestNewest": ".traces", "QuerySpansAnalyticsRequestOldest": ".traces", "QueryVariant": ".types", "QueryVariantCreate": ".types", "QueryVariantEdit": ".types", "QueryVariantFork": ".types", "QueryVariantQuery": ".types", "QueryVariantResponse": ".types", "QueryVariantsResponse": ".types", "QueryWorkflowRevisionsRequestOrder": ".workflows", "QueryWorkflowVariantsRequestOrder": ".workflows", "QueryWorkflowsRequestOrder": ".workflows", "Reference": ".types", "ReferenceRequestModelInput": ".types", "ReferenceRequestModelOutput": ".types", "RequestType": ".types", "ResolutionInfo": ".types", "RetrievalInfo": ".types", "SecretDto": ".types", "SecretDtoData": ".types", "SecretKind": ".types", "SecretResponseDto": ".types", "SecretResponseDtoData": ".types", "Selector": ".types", "SessionIdsResponse": ".types", "SimpleApplication": ".types", "SimpleApplicationCreate": ".types", "SimpleApplicationDataInput": ".types", "SimpleApplicationDataInputHeadersValue": ".types", "SimpleApplicationDataInputRuntime": ".types", "SimpleApplicationDataOutput": ".types", "SimpleApplicationDataOutputHeadersValue": ".types", "SimpleApplicationDataOutputRuntime": ".types", "SimpleApplicationEdit": ".types", "SimpleApplicationFlags": ".types", "SimpleApplicationQuery": ".types", "SimpleApplicationQueryFlags": ".types", "SimpleApplicationResponse": ".types", "SimpleApplicationsResponse": ".types", "SimpleEnvironment": ".types", "SimpleEnvironmentCreate": ".types", "SimpleEnvironmentEdit": ".types", "SimpleEnvironmentQuery": ".types", "SimpleEnvironmentResponse": ".types", "SimpleEnvironmentsResponse": ".types", "SimpleEvaluation": ".types", "SimpleEvaluationCreate": ".types", "SimpleEvaluationData": ".types", "SimpleEvaluationDataApplicationSteps": ".types", "SimpleEvaluationDataApplicationStepsOneValue": ".types", "SimpleEvaluationDataEvaluatorSteps": ".types", "SimpleEvaluationDataEvaluatorStepsOneValue": ".types", "SimpleEvaluationDataQuerySteps": ".types", "SimpleEvaluationDataQueryStepsOneValue": ".types", "SimpleEvaluationDataTestsetSteps": ".types", "SimpleEvaluationDataTestsetStepsOneValue": ".types", "SimpleEvaluationEdit": ".types", "SimpleEvaluationIdResponse": ".types", "SimpleEvaluationQuery": ".types", "SimpleEvaluationResponse": ".types", "SimpleEvaluationsResponse": ".types", "SimpleEvaluator": ".types", "SimpleEvaluatorCreate": ".types", "SimpleEvaluatorDataInput": ".types", "SimpleEvaluatorDataInputHeadersValue": ".types", "SimpleEvaluatorDataInputRuntime": ".types", "SimpleEvaluatorDataOutput": ".types", "SimpleEvaluatorDataOutputHeadersValue": ".types", "SimpleEvaluatorDataOutputRuntime": ".types", "SimpleEvaluatorEdit": ".types", "SimpleEvaluatorFlags": ".types", "SimpleEvaluatorQuery": ".types", "SimpleEvaluatorQueryFlags": ".types", "SimpleEvaluatorResponse": ".types", "SimpleEvaluatorsResponse": ".types", "SimpleQueriesResponse": ".types", "SimpleQuery": ".types", "SimpleQueryCreate": ".types", "SimpleQueryEdit": ".types", "SimpleQueryQuery": ".types", "SimpleQueryResponse": ".types", "SimpleQueue": ".types", "SimpleQueueCreate": ".types", "SimpleQueueData": ".types", "SimpleQueueDataEvaluators": ".types", "SimpleQueueDataEvaluatorsOneValue": ".types", "SimpleQueueIdResponse": ".types", "SimpleQueueIdsResponse": ".types", "SimpleQueueKind": ".types", "SimpleQueueQuery": ".types", "SimpleQueueResponse": ".types", "SimpleQueueScenariosQuery": ".types", "SimpleQueueScenariosResponse": ".types", "SimpleQueueSettings": ".types", "SimpleQueuesResponse": ".types", "SimpleTestset": ".types", "SimpleTestsetCreate": ".types", "SimpleTestsetEdit": ".types", "SimpleTestsetQuery": ".types", "SimpleTestsetResponse": ".types", "SimpleTestsetsResponse": ".types", "SimpleTrace": ".types", "SimpleTraceChannel": ".types", "SimpleTraceCreate": ".types", "SimpleTraceCreateLinks": ".types", "SimpleTraceEdit": ".types", "SimpleTraceEditLinks": ".types", "SimpleTraceKind": ".types", "SimpleTraceLinkResponse": ".types", "SimpleTraceLinks": ".types", "SimpleTraceOrigin": ".types", "SimpleTraceQuery": ".types", "SimpleTraceQueryLinks": ".types", "SimpleTraceReferences": ".types", "SimpleTraceResponse": ".types", "SimpleTracesResponse": ".types", "SimpleWorkflow": ".types", "SimpleWorkflowCreate": ".types", "SimpleWorkflowDataInput": ".types", "SimpleWorkflowDataInputHeadersValue": ".types", "SimpleWorkflowDataInputRuntime": ".types", "SimpleWorkflowDataOutput": ".types", "SimpleWorkflowDataOutputHeadersValue": ".types", "SimpleWorkflowDataOutputRuntime": ".types", "SimpleWorkflowEdit": ".types", "SimpleWorkflowFlags": ".types", "SimpleWorkflowQuery": ".types", "SimpleWorkflowQueryFlags": ".types", "SimpleWorkflowResponse": ".types", "SimpleWorkflowsResponse": ".types", "SpanInput": ".types", "SpanInputEndTime": ".types", "SpanInputStartTime": ".types", "SpanOutput": ".types", "SpanOutputEndTime": ".types", "SpanOutputStartTime": ".types", "SpanResponse": ".types", "SpanType": ".types", "SpansNodeInput": ".types", "SpansNodeInputEndTime": ".types", "SpansNodeInputSpansValue": ".types", "SpansNodeInputStartTime": ".types", "SpansNodeOutput": ".types", "SpansNodeOutputEndTime": ".types", "SpansNodeOutputSpansValue": ".types", "SpansNodeOutputStartTime": ".types", "SpansResponse": ".types", "SpansTreeInput": ".types", "SpansTreeInputSpansValue": ".types", "SpansTreeOutput": ".types", "SpansTreeOutputSpansValue": ".types", "SsoProviderDto": ".types", "SsoProviderInfo": ".types", "SsoProviderSettingsDto": ".types", "SsoProviders": ".types", "StandardProviderDto": ".types", "StandardProviderKind": ".types", "StandardProviderSettingsDto": ".types", "Status": ".types", "StringOperator": ".types", "TestcaseInput": ".types", "TestcaseOutput": ".types", "TestcaseResponse": ".types", "TestcasesResponse": ".types", "Testset": ".types", "TestsetCreate": ".types", "TestsetEdit": ".types", "TestsetFlags": ".types", "TestsetQuery": ".types", "TestsetResponse": ".types", "TestsetRevision": ".types", "TestsetRevisionCommit": ".types", "TestsetRevisionCreate": ".types", "TestsetRevisionDataInput": ".types", "TestsetRevisionDataOutput": ".types", "TestsetRevisionDelta": ".types", "TestsetRevisionDeltaColumns": ".types", "TestsetRevisionDeltaRows": ".types", "TestsetRevisionEdit": ".types", "TestsetRevisionQuery": ".types", "TestsetRevisionResponse": ".types", "TestsetRevisionsLog": ".types", "TestsetRevisionsResponse": ".types", "TestsetVariant": ".types", "TestsetVariantCreate": ".types", "TestsetVariantEdit": ".types", "TestsetVariantFork": ".types", "TestsetVariantQuery": ".types", "TestsetVariantResponse": ".types", "TestsetVariantsResponse": ".types", "TestsetsResponse": ".types", "TextOptions": ".types", "ToolAuthScheme": ".types", "ToolCallData": ".types", "ToolCallFunction": ".types", "ToolCallResponse": ".types", "ToolCatalogAction": ".types", "ToolCatalogActionDetails": ".types", "ToolCatalogActionResponse": ".types", "ToolCatalogActionResponseAction": ".types", "ToolCatalogActionsResponse": ".types", "ToolCatalogActionsResponseActionsItem": ".types", "ToolCatalogIntegration": ".types", "ToolCatalogIntegrationDetails": ".types", "ToolCatalogIntegrationResponse": ".types", "ToolCatalogIntegrationResponseIntegration": ".types", "ToolCatalogIntegrationsResponse": ".types", "ToolCatalogIntegrationsResponseIntegrationsItem": ".types", "ToolCatalogProvider": ".types", "ToolCatalogProviderDetails": ".types", "ToolCatalogProviderResponse": ".types", "ToolCatalogProviderResponseProvider": ".types", "ToolCatalogProvidersResponse": ".types", "ToolCatalogProvidersResponseProvidersItem": ".types", "ToolConnection": ".types", "ToolConnectionCreate": ".types", "ToolConnectionCreateData": ".types", "ToolConnectionResponse": ".types", "ToolConnectionStatus": ".types", "ToolConnectionsResponse": ".types", "ToolProviderKind": ".types", "ToolResult": ".types", "ToolResultData": ".types", "TraceIdResponse": ".types", "TraceIdsResponse": ".types", "TraceInput": ".types", "TraceInputSpansValue": ".types", "TraceOutput": ".types", "TraceOutputSpansValue": ".types", "TraceRequest": ".types", "TraceResponse": ".types", "TraceType": ".types", "TracesRequest": ".types", "TracesResponse": ".types", "TracingQuery": ".types", "TriggerAuthScheme": ".types", "TriggerCatalogEvent": ".types", "TriggerCatalogEventDetails": ".types", "TriggerCatalogEventResponse": ".types", "TriggerCatalogEventsResponse": ".types", "TriggerCatalogIntegration": ".types", "TriggerCatalogIntegrationResponse": ".types", "TriggerCatalogIntegrationsResponse": ".types", "TriggerCatalogProvider": ".types", "TriggerCatalogProviderResponse": ".types", "TriggerCatalogProvidersResponse": ".types", "TriggerConnection": ".types", "TriggerConnectionCreate": ".types", "TriggerConnectionCreateData": ".types", "TriggerConnectionResponse": ".types", "TriggerConnectionStatus": ".types", "TriggerConnectionsResponse": ".types", "TriggerDeliveriesResponse": ".types", "TriggerDelivery": ".types", "TriggerDeliveryData": ".types", "TriggerDeliveryQuery": ".types", "TriggerDeliveryResponse": ".types", "TriggerEventAck": ".types", "TriggerProviderKind": ".types", "TriggerSchedule": ".types", "TriggerScheduleCreate": ".types", "TriggerScheduleData": ".types", "TriggerScheduleEdit": ".types", "TriggerScheduleFlags": ".types", "TriggerScheduleQuery": ".types", "TriggerScheduleResponse": ".types", "TriggerSchedulesResponse": ".types", "TriggerSubscription": ".types", "TriggerSubscriptionCreate": ".types", "TriggerSubscriptionData": ".types", "TriggerSubscriptionEdit": ".types", "TriggerSubscriptionFlags": ".types", "TriggerSubscriptionQuery": ".types", "TriggerSubscriptionResponse": ".types", "TriggerSubscriptionsResponse": ".types", "UnprocessableEntityError": ".errors", "UserIdsResponse": ".types", "ValidationError": ".types", "ValidationErrorLocItem": ".types", "WebhookDeliveriesResponse": ".types", "WebhookDelivery": ".types", "WebhookDeliveryCreate": ".types", "WebhookDeliveryData": ".types", "WebhookDeliveryQuery": ".types", "WebhookDeliveryResponse": ".types", "WebhookDeliveryResponseInfo": ".types", "WebhookEventType": ".types", "WebhookProviderDto": ".types", "WebhookProviderSettingsDto": ".types", "WebhookSubscription": ".types", "WebhookSubscriptionCreate": ".types", "WebhookSubscriptionData": ".types", "WebhookSubscriptionDataAuthMode": ".types", "WebhookSubscriptionEdit": ".types", "WebhookSubscriptionFlags": ".types", "WebhookSubscriptionQuery": ".types", "WebhookSubscriptionResponse": ".types", "WebhookSubscriptionTestRequestSubscription": ".webhooks", "WebhookSubscriptionsResponse": ".types", "Windowing": ".types", "WindowingOrder": ".types", "Workflow": ".types", "WorkflowArtifactFlags": ".types", "WorkflowCatalogFlags": ".types", "WorkflowCatalogPreset": ".types", "WorkflowCatalogPresetResponse": ".types", "WorkflowCatalogPresetsResponse": ".types", "WorkflowCatalogTemplate": ".types", "WorkflowCatalogTemplateResponse": ".types", "WorkflowCatalogTemplatesResponse": ".types", "WorkflowCatalogType": ".types", "WorkflowCatalogTypeResponse": ".types", "WorkflowCatalogTypesResponse": ".types", "WorkflowCreate": ".types", "WorkflowEdit": ".types", "WorkflowFlags": ".types", "WorkflowResponse": ".types", "WorkflowRevisionCommit": ".types", "WorkflowRevisionCreate": ".types", "WorkflowRevisionDataInput": ".types", "WorkflowRevisionDataInputHeadersValue": ".types", "WorkflowRevisionDataInputRuntime": ".types", "WorkflowRevisionDataOutput": ".types", "WorkflowRevisionDataOutputHeadersValue": ".types", "WorkflowRevisionDataOutputRuntime": ".types", "WorkflowRevisionEdit": ".types", "WorkflowRevisionFlags": ".types", "WorkflowRevisionInput": ".types", "WorkflowRevisionOutput": ".types", "WorkflowRevisionResolveResponse": ".types", "WorkflowRevisionResponse": ".types", "WorkflowRevisionsLog": ".types", "WorkflowRevisionsResponse": ".types", "WorkflowVariant": ".types", "WorkflowVariantCreate": ".types", "WorkflowVariantEdit": ".types", "WorkflowVariantFlags": ".types", "WorkflowVariantFork": ".types", "WorkflowVariantResponse": ".types", "WorkflowVariantsResponse": ".types", "WorkflowsResponse": ".types", "Workspace": ".types", "WorkspaceMemberResponse": ".types", "WorkspacePermission": ".types", "WorkspaceResponse": ".types", "access": ".access", "annotations": ".annotations", "applications": ".applications", "billing": ".billing", "environments": ".environments", "evaluations": ".evaluations", "evaluators": ".evaluators", "events": ".events", "folders": ".folders", "invocations": ".invocations", "keys": ".keys", "legacy": ".legacy", "organizations": ".organizations", "projects": ".projects", "queries": ".queries", "secrets": ".secrets", "status": ".status", "testcases": ".testcases", "testsets": ".testsets", "tools": ".tools", "traces": ".traces", "triggers": ".triggers", "users": ".users", "webhooks": ".webhooks", "workflows": ".workflows", "workspaces": ".workspaces"} def __getattr__(attr_name: str) -> typing.Any: module_name = _dynamic_imports.get(attr_name) if module_name is None: @@ -37,4 +37,4 @@ def __getattr__(attr_name: str) -> typing.Any: def __dir__(): lazy_attrs = list(_dynamic_imports.keys()) return sorted(lazy_attrs) -__all__ = ["AdminAccountCreateOptions", "AdminAccountRead", "AdminAccountsCreate", "AdminAccountsDelete", "AdminAccountsDeleteTarget", "AdminAccountsResponse", "AdminApiKeyCreate", "AdminApiKeyResponse", "AdminDeleteResponse", "AdminDeletedEntities", "AdminDeletedEntity", "AdminOrganizationCreate", "AdminOrganizationMembershipCreate", "AdminOrganizationMembershipRead", "AdminOrganizationRead", "AdminProjectCreate", "AdminProjectMembershipCreate", "AdminProjectMembershipRead", "AdminProjectRead", "AdminSimpleAccountCreate", "AdminSimpleAccountDeleteEntry", "AdminSimpleAccountRead", "AdminSimpleAccountsApiKeysCreate", "AdminSimpleAccountsCreate", "AdminSimpleAccountsDelete", "AdminSimpleAccountsOrganizationsCreate", "AdminSimpleAccountsOrganizationsMembershipsCreate", "AdminSimpleAccountsOrganizationsTransferOwnership", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjects", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjectsZero", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspaces", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspacesZero", "AdminSimpleAccountsOrganizationsTransferOwnershipResponse", "AdminSimpleAccountsProjectsCreate", "AdminSimpleAccountsProjectsMembershipsCreate", "AdminSimpleAccountsResponse", "AdminSimpleAccountsUsersCreate", "AdminSimpleAccountsUsersIdentitiesCreate", "AdminSimpleAccountsUsersResetPassword", "AdminSimpleAccountsWorkspacesCreate", "AdminSimpleAccountsWorkspacesMembershipsCreate", "AdminStructuredError", "AdminSubscriptionCreate", "AdminSubscriptionRead", "AdminUserCreate", "AdminUserIdentityCreate", "AdminUserIdentityRead", "AdminUserIdentityReadStatus", "AdminUserRead", "AdminWorkspaceCreate", "AdminWorkspaceMembershipCreate", "AdminWorkspaceMembershipRead", "AdminWorkspaceRead", "AgentaApi", "AgentaApiEnvironment", "Analytics", "AnalyticsResponse", "Annotation", "AnnotationCreate", "AnnotationCreateLinks", "AnnotationEdit", "AnnotationEditLinks", "AnnotationLinkResponse", "AnnotationLinks", "AnnotationQuery", "AnnotationQueryLinks", "AnnotationResponse", "AnnotationsResponse", "Application", "ApplicationArtifactFlags", "ApplicationArtifactQueryFlags", "ApplicationCatalogPreset", "ApplicationCatalogPresetResponse", "ApplicationCatalogPresetsResponse", "ApplicationCatalogTemplate", "ApplicationCatalogTemplateResponse", "ApplicationCatalogTemplatesResponse", "ApplicationCatalogType", "ApplicationCatalogTypesResponse", "ApplicationCreate", "ApplicationEdit", "ApplicationFlags", "ApplicationQuery", "ApplicationResponse", "ApplicationRevisionCommit", "ApplicationRevisionCreate", "ApplicationRevisionDataInput", "ApplicationRevisionDataInputHeadersValue", "ApplicationRevisionDataInputRuntime", "ApplicationRevisionDataOutput", "ApplicationRevisionDataOutputHeadersValue", "ApplicationRevisionDataOutputRuntime", "ApplicationRevisionEdit", "ApplicationRevisionFlags", "ApplicationRevisionInput", "ApplicationRevisionOutput", "ApplicationRevisionQuery", "ApplicationRevisionQueryFlags", "ApplicationRevisionResolveResponse", "ApplicationRevisionResponse", "ApplicationRevisionsLog", "ApplicationRevisionsResponse", "ApplicationVariant", "ApplicationVariantCreate", "ApplicationVariantEdit", "ApplicationVariantFlags", "ApplicationVariantFork", "ApplicationVariantResponse", "ApplicationVariantsResponse", "ApplicationsResponse", "AsyncAgentaApi", "BodyConfigsFetchVariantsConfigsFetchPost", "Bucket", "CollectStatusResponse", "ComparisonOperator", "Condition", "ConditionOperator", "ConditionOptions", "ConditionValue", "ConfigResponseModel", "CreateSimpleTestsetFromFileRequestFileType", "CreateTestsetRevisionFromFileRequestFileType", "CustomModelSettingsDto", "CustomProviderDto", "CustomProviderKind", "CustomProviderSettingsDto", "DictOperator", "DiscoverResponse", "DiscoverResponseMethodsValue", "EditSimpleTestsetFromFileRequestFileType", "EeSrcModelsApiOrganizationModelsOrganization", "EntityRef", "Environment", "EnvironmentCreate", "EnvironmentEdit", "EnvironmentFlags", "EnvironmentQueryFlags", "EnvironmentResponse", "EnvironmentRevisionCommit", "EnvironmentRevisionCreate", "EnvironmentRevisionData", "EnvironmentRevisionDelta", "EnvironmentRevisionEdit", "EnvironmentRevisionInput", "EnvironmentRevisionOutput", "EnvironmentRevisionResolveResponse", "EnvironmentRevisionResponse", "EnvironmentRevisionsLog", "EnvironmentRevisionsResponse", "EnvironmentVariant", "EnvironmentVariantCreate", "EnvironmentVariantEdit", "EnvironmentVariantFork", "EnvironmentVariantResponse", "EnvironmentVariantsResponse", "EnvironmentsResponse", "ErrorPolicy", "EvaluationMetrics", "EvaluationMetricsCreate", "EvaluationMetricsIdsResponse", "EvaluationMetricsQuery", "EvaluationMetricsQueryScenarioIds", "EvaluationMetricsQueryTimestamps", "EvaluationMetricsRefresh", "EvaluationMetricsResponse", "EvaluationMetricsSetRequest", "EvaluationQueue", "EvaluationQueueCreate", "EvaluationQueueData", "EvaluationQueueEdit", "EvaluationQueueFlags", "EvaluationQueueIdResponse", "EvaluationQueueIdsResponse", "EvaluationQueueQuery", "EvaluationQueueQueryFlags", "EvaluationQueueResponse", "EvaluationQueueScenariosQuery", "EvaluationQueuesResponse", "EvaluationResult", "EvaluationResultCreate", "EvaluationResultIdResponse", "EvaluationResultIdsResponse", "EvaluationResultQuery", "EvaluationResultResponse", "EvaluationResultsResponse", "EvaluationResultsSetRequest", "EvaluationRun", "EvaluationRunCreate", "EvaluationRunDataConcurrency", "EvaluationRunDataInput", "EvaluationRunDataMapping", "EvaluationRunDataMappingColumn", "EvaluationRunDataMappingStep", "EvaluationRunDataOutput", "EvaluationRunDataStepInput", "EvaluationRunDataStepInputKey", "EvaluationRunDataStepInputOrigin", "EvaluationRunDataStepInputType", "EvaluationRunDataStepOutput", "EvaluationRunDataStepOutputOrigin", "EvaluationRunDataStepOutputType", "EvaluationRunEdit", "EvaluationRunFlags", "EvaluationRunIdResponse", "EvaluationRunIdsRequest", "EvaluationRunIdsResponse", "EvaluationRunQuery", "EvaluationRunQueryFlags", "EvaluationRunResponse", "EvaluationRunsResponse", "EvaluationScenario", "EvaluationScenarioCreate", "EvaluationScenarioEdit", "EvaluationScenarioIdResponse", "EvaluationScenarioIdsResponse", "EvaluationScenarioQuery", "EvaluationScenarioResponse", "EvaluationScenariosResponse", "EvaluationStatus", "Evaluator", "EvaluatorArtifactFlags", "EvaluatorArtifactQueryFlags", "EvaluatorCatalogPreset", "EvaluatorCatalogPresetResponse", "EvaluatorCatalogPresetsResponse", "EvaluatorCatalogTemplate", "EvaluatorCatalogTemplateResponse", "EvaluatorCatalogTemplatesResponse", "EvaluatorCatalogType", "EvaluatorCatalogTypesResponse", "EvaluatorCreate", "EvaluatorEdit", "EvaluatorFlags", "EvaluatorQuery", "EvaluatorResponse", "EvaluatorRevisionCommit", "EvaluatorRevisionCreate", "EvaluatorRevisionDataInput", "EvaluatorRevisionDataInputHeadersValue", "EvaluatorRevisionDataInputRuntime", "EvaluatorRevisionDataOutput", "EvaluatorRevisionDataOutputHeadersValue", "EvaluatorRevisionDataOutputRuntime", "EvaluatorRevisionEdit", "EvaluatorRevisionFlags", "EvaluatorRevisionInput", "EvaluatorRevisionOutput", "EvaluatorRevisionQuery", "EvaluatorRevisionQueryFlags", "EvaluatorRevisionResolveResponse", "EvaluatorRevisionResponse", "EvaluatorRevisionsLog", "EvaluatorRevisionsResponse", "EvaluatorTemplate", "EvaluatorTemplatesResponse", "EvaluatorVariant", "EvaluatorVariantCreate", "EvaluatorVariantEdit", "EvaluatorVariantFlags", "EvaluatorVariantFork", "EvaluatorVariantResponse", "EvaluatorVariantsResponse", "EvaluatorsResponse", "Event", "EventQuery", "EventType", "EventsQueryResponse", "ExistenceOperator", "FetchLegacyAnalyticsRequestNewest", "FetchLegacyAnalyticsRequestOldest", "FetchSimpleTestsetToFileRequestFileType", "FetchTestsetRevisionToFileRequestFileType", "FilteringInput", "FilteringInputConditionsItem", "FilteringOutput", "FilteringOutputConditionsItem", "Focus", "Folder", "FolderCreate", "FolderEdit", "FolderIdResponse", "FolderKind", "FolderQuery", "FolderQueryKinds", "FolderResponse", "FoldersResponse", "Format", "Formatting", typing.Any, typing.Any, "Header", "HttpValidationError", "InviteRequest", "Invocation", "InvocationCreate", "InvocationCreateLinks", "InvocationEdit", "InvocationEditLinks", "InvocationLinkResponse", "InvocationLinks", "InvocationQuery", "InvocationQueryLinks", "InvocationResponse", "InvocationsResponse", "JsonSchemasInput", "JsonSchemasOutput", typing.Any, typing.Any, "LegacyLifecycleDto", "ListApiKeysResponse", "ListOperator", "ListOptions", "LogicalOperator", "MetricSpec", "MetricType", "MetricsBucket", "NumericOperator", "OTelEventInput", "OTelEventInputTimestamp", "OTelEventOutput", "OTelEventOutputTimestamp", "OTelHashInput", "OTelHashOutput", "OTelLinkInput", "OTelLinkOutput", "OTelLinksResponse", "OTelReferenceInput", "OTelReferenceOutput", "OTelSpanKind", "OTelStatusCode", "OTelTracingRequest", "OTelTracingResponse", "OldAnalyticsResponse", "OrganizationDetails", "OrganizationDomainResponse", "OrganizationProviderResponse", "OrganizationUpdate", "OssSrcModelsApiOrganizationModelsOrganization", "Permission", "ProjectsResponse", "QueriesResponse", "Query", "QueryApplicationVariantsRequestOrder", "QueryCreate", "QueryEdit", "QueryEnvironmentRevisionsRequestOrder", "QueryEnvironmentVariantsRequestOrder", "QueryEnvironmentsRequestOrder", "QueryEvaluatorVariantsRequestOrder", "QueryFlags", "QueryQueriesRequestOrder", "QueryQueryFlags", "QueryResponse", "QueryRevision", "QueryRevisionCommit", "QueryRevisionCreate", "QueryRevisionDataInput", "QueryRevisionDataOutput", "QueryRevisionEdit", "QueryRevisionQuery", "QueryRevisionResponse", "QueryRevisionsLog", "QueryRevisionsResponse", "QuerySpansAnalyticsRequestNewest", "QuerySpansAnalyticsRequestOldest", "QueryVariant", "QueryVariantCreate", "QueryVariantEdit", "QueryVariantFork", "QueryVariantQuery", "QueryVariantResponse", "QueryVariantsResponse", "QueryWorkflowRevisionsRequestOrder", "QueryWorkflowVariantsRequestOrder", "QueryWorkflowsRequestOrder", "Reference", "ReferenceRequestModelInput", "ReferenceRequestModelOutput", "RequestType", "ResolutionInfo", "RetrievalInfo", "SecretDto", "SecretDtoData", "SecretKind", "SecretResponseDto", "SecretResponseDtoData", "SessionIdsResponse", "SimpleApplication", "SimpleApplicationCreate", "SimpleApplicationDataInput", "SimpleApplicationDataInputHeadersValue", "SimpleApplicationDataInputRuntime", "SimpleApplicationDataOutput", "SimpleApplicationDataOutputHeadersValue", "SimpleApplicationDataOutputRuntime", "SimpleApplicationEdit", "SimpleApplicationFlags", "SimpleApplicationQuery", "SimpleApplicationQueryFlags", "SimpleApplicationResponse", "SimpleApplicationsResponse", "SimpleEnvironment", "SimpleEnvironmentCreate", "SimpleEnvironmentEdit", "SimpleEnvironmentQuery", "SimpleEnvironmentResponse", "SimpleEnvironmentsResponse", "SimpleEvaluation", "SimpleEvaluationCreate", "SimpleEvaluationData", "SimpleEvaluationDataApplicationSteps", "SimpleEvaluationDataApplicationStepsOneValue", "SimpleEvaluationDataEvaluatorSteps", "SimpleEvaluationDataEvaluatorStepsOneValue", "SimpleEvaluationDataQuerySteps", "SimpleEvaluationDataQueryStepsOneValue", "SimpleEvaluationDataTestsetSteps", "SimpleEvaluationDataTestsetStepsOneValue", "SimpleEvaluationEdit", "SimpleEvaluationIdResponse", "SimpleEvaluationQuery", "SimpleEvaluationResponse", "SimpleEvaluationsResponse", "SimpleEvaluator", "SimpleEvaluatorCreate", "SimpleEvaluatorDataInput", "SimpleEvaluatorDataInputHeadersValue", "SimpleEvaluatorDataInputRuntime", "SimpleEvaluatorDataOutput", "SimpleEvaluatorDataOutputHeadersValue", "SimpleEvaluatorDataOutputRuntime", "SimpleEvaluatorEdit", "SimpleEvaluatorFlags", "SimpleEvaluatorQuery", "SimpleEvaluatorQueryFlags", "SimpleEvaluatorResponse", "SimpleEvaluatorsResponse", "SimpleQueriesResponse", "SimpleQuery", "SimpleQueryCreate", "SimpleQueryEdit", "SimpleQueryQuery", "SimpleQueryResponse", "SimpleQueue", "SimpleQueueCreate", "SimpleQueueData", "SimpleQueueDataEvaluators", "SimpleQueueDataEvaluatorsOneValue", "SimpleQueueIdResponse", "SimpleQueueIdsResponse", "SimpleQueueKind", "SimpleQueueQuery", "SimpleQueueResponse", "SimpleQueueScenariosQuery", "SimpleQueueScenariosResponse", "SimpleQueueSettings", "SimpleQueuesResponse", "SimpleTestset", "SimpleTestsetCreate", "SimpleTestsetEdit", "SimpleTestsetQuery", "SimpleTestsetResponse", "SimpleTestsetsResponse", "SimpleTrace", "SimpleTraceChannel", "SimpleTraceCreate", "SimpleTraceCreateLinks", "SimpleTraceEdit", "SimpleTraceEditLinks", "SimpleTraceKind", "SimpleTraceLinkResponse", "SimpleTraceLinks", "SimpleTraceOrigin", "SimpleTraceQuery", "SimpleTraceQueryLinks", "SimpleTraceReferences", "SimpleTraceResponse", "SimpleTracesResponse", "SimpleWorkflow", "SimpleWorkflowCreate", "SimpleWorkflowDataInput", "SimpleWorkflowDataInputHeadersValue", "SimpleWorkflowDataInputRuntime", "SimpleWorkflowDataOutput", "SimpleWorkflowDataOutputHeadersValue", "SimpleWorkflowDataOutputRuntime", "SimpleWorkflowEdit", "SimpleWorkflowFlags", "SimpleWorkflowQuery", "SimpleWorkflowQueryFlags", "SimpleWorkflowResponse", "SimpleWorkflowsResponse", "SpanInput", "SpanInputEndTime", "SpanInputStartTime", "SpanOutput", "SpanOutputEndTime", "SpanOutputStartTime", "SpanResponse", "SpanType", "SpansNodeInput", "SpansNodeInputEndTime", "SpansNodeInputSpansValue", "SpansNodeInputStartTime", "SpansNodeOutput", "SpansNodeOutputEndTime", "SpansNodeOutputSpansValue", "SpansNodeOutputStartTime", "SpansResponse", "SpansTreeInput", "SpansTreeInputSpansValue", "SpansTreeOutput", "SpansTreeOutputSpansValue", "SsoProviderDto", "SsoProviderInfo", "SsoProviderSettingsDto", "SsoProviders", "StandardProviderDto", "StandardProviderKind", "StandardProviderSettingsDto", "Status", "StringOperator", "TestcaseInput", "TestcaseOutput", "TestcaseResponse", "TestcasesResponse", "Testset", "TestsetCreate", "TestsetEdit", "TestsetFlags", "TestsetQuery", "TestsetResponse", "TestsetRevision", "TestsetRevisionCommit", "TestsetRevisionCreate", "TestsetRevisionDataInput", "TestsetRevisionDataOutput", "TestsetRevisionDelta", "TestsetRevisionDeltaColumns", "TestsetRevisionDeltaRows", "TestsetRevisionEdit", "TestsetRevisionQuery", "TestsetRevisionResponse", "TestsetRevisionsLog", "TestsetRevisionsResponse", "TestsetVariant", "TestsetVariantCreate", "TestsetVariantEdit", "TestsetVariantFork", "TestsetVariantQuery", "TestsetVariantResponse", "TestsetVariantsResponse", "TestsetsResponse", "TextOptions", "ToolAuthScheme", "ToolCallData", "ToolCallFunction", "ToolCallResponse", "ToolCatalogAction", "ToolCatalogActionDetails", "ToolCatalogActionResponse", "ToolCatalogActionResponseAction", "ToolCatalogActionsResponse", "ToolCatalogActionsResponseActionsItem", "ToolCatalogIntegration", "ToolCatalogIntegrationDetails", "ToolCatalogIntegrationResponse", "ToolCatalogIntegrationResponseIntegration", "ToolCatalogIntegrationsResponse", "ToolCatalogIntegrationsResponseIntegrationsItem", "ToolCatalogProvider", "ToolCatalogProviderDetails", "ToolCatalogProviderResponse", "ToolCatalogProviderResponseProvider", "ToolCatalogProvidersResponse", "ToolCatalogProvidersResponseProvidersItem", "ToolConnection", "ToolConnectionCreate", "ToolConnectionCreateData", "ToolConnectionResponse", "ToolConnectionStatus", "ToolConnectionsResponse", "ToolProviderKind", "ToolResult", "ToolResultData", "TraceIdResponse", "TraceIdsResponse", "TraceInput", "TraceInputSpansValue", "TraceOutput", "TraceOutputSpansValue", "TraceRequest", "TraceResponse", "TraceType", "TracesRequest", "TracesResponse", "TracingQuery", "UnprocessableEntityError", "UserIdsResponse", "ValidationError", "ValidationErrorLocItem", "WebhookDeliveriesResponse", "WebhookDelivery", "WebhookDeliveryCreate", "WebhookDeliveryData", "WebhookDeliveryQuery", "WebhookDeliveryResponse", "WebhookDeliveryResponseInfo", "WebhookEventType", "WebhookProviderDto", "WebhookProviderSettingsDto", "WebhookSubscription", "WebhookSubscriptionCreate", "WebhookSubscriptionData", "WebhookSubscriptionDataAuthMode", "WebhookSubscriptionEdit", "WebhookSubscriptionQuery", "WebhookSubscriptionResponse", "WebhookSubscriptionTestRequestSubscription", "WebhookSubscriptionsResponse", "Windowing", "WindowingOrder", "Workflow", "WorkflowArtifactFlags", "WorkflowCatalogFlags", "WorkflowCatalogPreset", "WorkflowCatalogPresetResponse", "WorkflowCatalogPresetsResponse", "WorkflowCatalogTemplate", "WorkflowCatalogTemplateResponse", "WorkflowCatalogTemplatesResponse", "WorkflowCatalogType", "WorkflowCatalogTypeResponse", "WorkflowCatalogTypesResponse", "WorkflowCreate", "WorkflowEdit", "WorkflowFlags", "WorkflowResponse", "WorkflowRevisionCommit", "WorkflowRevisionCreate", "WorkflowRevisionDataInput", "WorkflowRevisionDataInputHeadersValue", "WorkflowRevisionDataInputRuntime", "WorkflowRevisionDataOutput", "WorkflowRevisionDataOutputHeadersValue", "WorkflowRevisionDataOutputRuntime", "WorkflowRevisionEdit", "WorkflowRevisionFlags", "WorkflowRevisionInput", "WorkflowRevisionOutput", "WorkflowRevisionResolveResponse", "WorkflowRevisionResponse", "WorkflowRevisionsLog", "WorkflowRevisionsResponse", "WorkflowVariant", "WorkflowVariantCreate", "WorkflowVariantEdit", "WorkflowVariantFlags", "WorkflowVariantFork", "WorkflowVariantResponse", "WorkflowVariantsResponse", "WorkflowsResponse", "Workspace", "WorkspaceMemberResponse", "WorkspacePermission", "WorkspaceResponse", "access", "annotations", "applications", "billing", "environments", "evaluations", "evaluators", "events", "folders", "invocations", "keys", "legacy", "organizations", "projects", "queries", "secrets", "status", "testcases", "testsets", "tools", "traces", "users", "webhooks", "workflows", "workspaces"] +__all__ = ["AdminAccountCreateOptions", "AdminAccountRead", "AdminAccountsCreate", "AdminAccountsDelete", "AdminAccountsDeleteTarget", "AdminAccountsResponse", "AdminApiKeyCreate", "AdminApiKeyResponse", "AdminDeleteResponse", "AdminDeletedEntities", "AdminDeletedEntity", "AdminOrganizationCreate", "AdminOrganizationMembershipCreate", "AdminOrganizationMembershipRead", "AdminOrganizationRead", "AdminProjectCreate", "AdminProjectMembershipCreate", "AdminProjectMembershipRead", "AdminProjectRead", "AdminSimpleAccountCreate", "AdminSimpleAccountDeleteEntry", "AdminSimpleAccountRead", "AdminSimpleAccountsApiKeysCreate", "AdminSimpleAccountsCreate", "AdminSimpleAccountsDelete", "AdminSimpleAccountsOrganizationsCreate", "AdminSimpleAccountsOrganizationsMembershipsCreate", "AdminSimpleAccountsOrganizationsTransferOwnership", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjects", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjectsZero", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspaces", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspacesZero", "AdminSimpleAccountsOrganizationsTransferOwnershipResponse", "AdminSimpleAccountsProjectsCreate", "AdminSimpleAccountsProjectsMembershipsCreate", "AdminSimpleAccountsResponse", "AdminSimpleAccountsUsersCreate", "AdminSimpleAccountsUsersIdentitiesCreate", "AdminSimpleAccountsUsersResetPassword", "AdminSimpleAccountsWorkspacesCreate", "AdminSimpleAccountsWorkspacesMembershipsCreate", "AdminStructuredError", "AdminSubscriptionCreate", "AdminSubscriptionRead", "AdminUserCreate", "AdminUserIdentityCreate", "AdminUserIdentityRead", "AdminUserIdentityReadStatus", "AdminUserRead", "AdminWorkspaceCreate", "AdminWorkspaceMembershipCreate", "AdminWorkspaceMembershipRead", "AdminWorkspaceRead", "AgentaApi", "AgentaApiEnvironment", "Analytics", "AnalyticsResponse", "Annotation", "AnnotationCreate", "AnnotationCreateLinks", "AnnotationEdit", "AnnotationEditLinks", "AnnotationLinkResponse", "AnnotationLinks", "AnnotationQuery", "AnnotationQueryLinks", "AnnotationResponse", "AnnotationsResponse", "Application", "ApplicationArtifactFlags", "ApplicationArtifactQueryFlags", "ApplicationCatalogPreset", "ApplicationCatalogPresetResponse", "ApplicationCatalogPresetsResponse", "ApplicationCatalogTemplate", "ApplicationCatalogTemplateResponse", "ApplicationCatalogTemplatesResponse", "ApplicationCatalogType", "ApplicationCatalogTypesResponse", "ApplicationCreate", "ApplicationEdit", "ApplicationFlags", "ApplicationQuery", "ApplicationResponse", "ApplicationRevisionCommit", "ApplicationRevisionCreate", "ApplicationRevisionDataInput", "ApplicationRevisionDataInputHeadersValue", "ApplicationRevisionDataInputRuntime", "ApplicationRevisionDataOutput", "ApplicationRevisionDataOutputHeadersValue", "ApplicationRevisionDataOutputRuntime", "ApplicationRevisionEdit", "ApplicationRevisionFlags", "ApplicationRevisionInput", "ApplicationRevisionOutput", "ApplicationRevisionQuery", "ApplicationRevisionQueryFlags", "ApplicationRevisionResolveResponse", "ApplicationRevisionResponse", "ApplicationRevisionsLog", "ApplicationRevisionsResponse", "ApplicationVariant", "ApplicationVariantCreate", "ApplicationVariantEdit", "ApplicationVariantFlags", "ApplicationVariantFork", "ApplicationVariantResponse", "ApplicationVariantsResponse", "ApplicationsResponse", "AsyncAgentaApi", "BodyConfigsFetchVariantsConfigsFetchPost", "Bucket", "CollectStatusResponse", "ComparisonOperator", "Condition", "ConditionOperator", "ConditionOptions", "ConditionValue", "ConfigResponseModel", "CreateSimpleTestsetFromFileRequestFileType", "CreateTestsetRevisionFromFileRequestFileType", "CustomModelSettingsDto", "CustomProviderDto", "CustomProviderKind", "CustomProviderSettingsDto", "DictOperator", "DiscoverResponse", "DiscoverResponseMethodsValue", "EditSimpleTestsetFromFileRequestFileType", "EeSrcModelsApiOrganizationModelsOrganization", "EntityRef", "Environment", "EnvironmentCreate", "EnvironmentEdit", "EnvironmentFlags", "EnvironmentQueryFlags", "EnvironmentResponse", "EnvironmentRevisionCommit", "EnvironmentRevisionCreate", "EnvironmentRevisionData", "EnvironmentRevisionDelta", "EnvironmentRevisionEdit", "EnvironmentRevisionInput", "EnvironmentRevisionOutput", "EnvironmentRevisionResolveResponse", "EnvironmentRevisionResponse", "EnvironmentRevisionsLog", "EnvironmentRevisionsResponse", "EnvironmentVariant", "EnvironmentVariantCreate", "EnvironmentVariantEdit", "EnvironmentVariantFork", "EnvironmentVariantResponse", "EnvironmentVariantsResponse", "EnvironmentsResponse", "ErrorPolicy", "EvaluationMetrics", "EvaluationMetricsCreate", "EvaluationMetricsIdsResponse", "EvaluationMetricsQuery", "EvaluationMetricsQueryScenarioIds", "EvaluationMetricsQueryTimestamps", "EvaluationMetricsRefresh", "EvaluationMetricsResponse", "EvaluationMetricsSetRequest", "EvaluationQueue", "EvaluationQueueCreate", "EvaluationQueueData", "EvaluationQueueEdit", "EvaluationQueueFlags", "EvaluationQueueIdResponse", "EvaluationQueueIdsResponse", "EvaluationQueueQuery", "EvaluationQueueQueryFlags", "EvaluationQueueResponse", "EvaluationQueueScenariosQuery", "EvaluationQueuesResponse", "EvaluationResult", "EvaluationResultCreate", "EvaluationResultIdResponse", "EvaluationResultIdsResponse", "EvaluationResultQuery", "EvaluationResultResponse", "EvaluationResultsResponse", "EvaluationResultsSetRequest", "EvaluationRun", "EvaluationRunCreate", "EvaluationRunDataConcurrency", "EvaluationRunDataInput", "EvaluationRunDataMapping", "EvaluationRunDataMappingColumn", "EvaluationRunDataMappingStep", "EvaluationRunDataOutput", "EvaluationRunDataStepInput", "EvaluationRunDataStepInputKey", "EvaluationRunDataStepInputOrigin", "EvaluationRunDataStepInputType", "EvaluationRunDataStepOutput", "EvaluationRunDataStepOutputOrigin", "EvaluationRunDataStepOutputType", "EvaluationRunEdit", "EvaluationRunFlags", "EvaluationRunIdResponse", "EvaluationRunIdsRequest", "EvaluationRunIdsResponse", "EvaluationRunQuery", "EvaluationRunQueryFlags", "EvaluationRunResponse", "EvaluationRunsResponse", "EvaluationScenario", "EvaluationScenarioCreate", "EvaluationScenarioEdit", "EvaluationScenarioIdResponse", "EvaluationScenarioIdsResponse", "EvaluationScenarioQuery", "EvaluationScenarioResponse", "EvaluationScenariosResponse", "EvaluationStatus", "Evaluator", "EvaluatorArtifactFlags", "EvaluatorArtifactQueryFlags", "EvaluatorCatalogPreset", "EvaluatorCatalogPresetResponse", "EvaluatorCatalogPresetsResponse", "EvaluatorCatalogTemplate", "EvaluatorCatalogTemplateResponse", "EvaluatorCatalogTemplatesResponse", "EvaluatorCatalogType", "EvaluatorCatalogTypesResponse", "EvaluatorCreate", "EvaluatorEdit", "EvaluatorFlags", "EvaluatorQuery", "EvaluatorResponse", "EvaluatorRevisionCommit", "EvaluatorRevisionCreate", "EvaluatorRevisionDataInput", "EvaluatorRevisionDataInputHeadersValue", "EvaluatorRevisionDataInputRuntime", "EvaluatorRevisionDataOutput", "EvaluatorRevisionDataOutputHeadersValue", "EvaluatorRevisionDataOutputRuntime", "EvaluatorRevisionEdit", "EvaluatorRevisionFlags", "EvaluatorRevisionInput", "EvaluatorRevisionOutput", "EvaluatorRevisionQuery", "EvaluatorRevisionQueryFlags", "EvaluatorRevisionResolveResponse", "EvaluatorRevisionResponse", "EvaluatorRevisionsLog", "EvaluatorRevisionsResponse", "EvaluatorTemplate", "EvaluatorTemplatesResponse", "EvaluatorVariant", "EvaluatorVariantCreate", "EvaluatorVariantEdit", "EvaluatorVariantFlags", "EvaluatorVariantFork", "EvaluatorVariantResponse", "EvaluatorVariantsResponse", "EvaluatorsResponse", "Event", "EventQuery", "EventType", "EventsQueryResponse", "ExistenceOperator", "FetchLegacyAnalyticsRequestNewest", "FetchLegacyAnalyticsRequestOldest", "FetchSimpleTestsetToFileRequestFileType", "FetchTestsetRevisionToFileRequestFileType", "FilteringInput", "FilteringInputConditionsItem", "FilteringOutput", "FilteringOutputConditionsItem", "Focus", "Folder", "FolderCreate", "FolderEdit", "FolderIdResponse", "FolderKind", "FolderQuery", "FolderQueryKinds", "FolderResponse", "FoldersResponse", "Format", "Formatting", typing.Any, typing.Any, "Header", "HttpValidationError", "InviteRequest", "Invocation", "InvocationCreate", "InvocationCreateLinks", "InvocationEdit", "InvocationEditLinks", "InvocationLinkResponse", "InvocationLinks", "InvocationQuery", "InvocationQueryLinks", "InvocationResponse", "InvocationsResponse", "JsonSchemasInput", "JsonSchemasOutput", typing.Any, typing.Any, "LegacyLifecycleDto", "ListApiKeysResponse", "ListOperator", "ListOptions", "LogicalOperator", "MetricSpec", "MetricType", "MetricsBucket", "NumericOperator", "OTelEventInput", "OTelEventInputTimestamp", "OTelEventOutput", "OTelEventOutputTimestamp", "OTelHashInput", "OTelHashOutput", "OTelLinkInput", "OTelLinkOutput", "OTelLinksResponse", "OTelReferenceInput", "OTelReferenceOutput", "OTelSpanKind", "OTelStatusCode", "OTelTracingRequest", "OTelTracingResponse", "OldAnalyticsResponse", "OrganizationDetails", "OrganizationDomainResponse", "OrganizationProviderResponse", "OrganizationUpdate", "OssSrcModelsApiOrganizationModelsOrganization", "Permission", "ProjectsResponse", "QueriesResponse", "Query", "QueryApplicationVariantsRequestOrder", "QueryCreate", "QueryEdit", "QueryEnvironmentRevisionsRequestOrder", "QueryEnvironmentVariantsRequestOrder", "QueryEnvironmentsRequestOrder", "QueryEvaluatorVariantsRequestOrder", "QueryFlags", "QueryQueriesRequestOrder", "QueryQueryFlags", "QueryResponse", "QueryRevision", "QueryRevisionCommit", "QueryRevisionCreate", "QueryRevisionDataInput", "QueryRevisionDataOutput", "QueryRevisionEdit", "QueryRevisionQuery", "QueryRevisionResponse", "QueryRevisionsLog", "QueryRevisionsResponse", "QuerySpansAnalyticsRequestNewest", "QuerySpansAnalyticsRequestOldest", "QueryVariant", "QueryVariantCreate", "QueryVariantEdit", "QueryVariantFork", "QueryVariantQuery", "QueryVariantResponse", "QueryVariantsResponse", "QueryWorkflowRevisionsRequestOrder", "QueryWorkflowVariantsRequestOrder", "QueryWorkflowsRequestOrder", "Reference", "ReferenceRequestModelInput", "ReferenceRequestModelOutput", "RequestType", "ResolutionInfo", "RetrievalInfo", "SecretDto", "SecretDtoData", "SecretKind", "SecretResponseDto", "SecretResponseDtoData", "Selector", "SessionIdsResponse", "SimpleApplication", "SimpleApplicationCreate", "SimpleApplicationDataInput", "SimpleApplicationDataInputHeadersValue", "SimpleApplicationDataInputRuntime", "SimpleApplicationDataOutput", "SimpleApplicationDataOutputHeadersValue", "SimpleApplicationDataOutputRuntime", "SimpleApplicationEdit", "SimpleApplicationFlags", "SimpleApplicationQuery", "SimpleApplicationQueryFlags", "SimpleApplicationResponse", "SimpleApplicationsResponse", "SimpleEnvironment", "SimpleEnvironmentCreate", "SimpleEnvironmentEdit", "SimpleEnvironmentQuery", "SimpleEnvironmentResponse", "SimpleEnvironmentsResponse", "SimpleEvaluation", "SimpleEvaluationCreate", "SimpleEvaluationData", "SimpleEvaluationDataApplicationSteps", "SimpleEvaluationDataApplicationStepsOneValue", "SimpleEvaluationDataEvaluatorSteps", "SimpleEvaluationDataEvaluatorStepsOneValue", "SimpleEvaluationDataQuerySteps", "SimpleEvaluationDataQueryStepsOneValue", "SimpleEvaluationDataTestsetSteps", "SimpleEvaluationDataTestsetStepsOneValue", "SimpleEvaluationEdit", "SimpleEvaluationIdResponse", "SimpleEvaluationQuery", "SimpleEvaluationResponse", "SimpleEvaluationsResponse", "SimpleEvaluator", "SimpleEvaluatorCreate", "SimpleEvaluatorDataInput", "SimpleEvaluatorDataInputHeadersValue", "SimpleEvaluatorDataInputRuntime", "SimpleEvaluatorDataOutput", "SimpleEvaluatorDataOutputHeadersValue", "SimpleEvaluatorDataOutputRuntime", "SimpleEvaluatorEdit", "SimpleEvaluatorFlags", "SimpleEvaluatorQuery", "SimpleEvaluatorQueryFlags", "SimpleEvaluatorResponse", "SimpleEvaluatorsResponse", "SimpleQueriesResponse", "SimpleQuery", "SimpleQueryCreate", "SimpleQueryEdit", "SimpleQueryQuery", "SimpleQueryResponse", "SimpleQueue", "SimpleQueueCreate", "SimpleQueueData", "SimpleQueueDataEvaluators", "SimpleQueueDataEvaluatorsOneValue", "SimpleQueueIdResponse", "SimpleQueueIdsResponse", "SimpleQueueKind", "SimpleQueueQuery", "SimpleQueueResponse", "SimpleQueueScenariosQuery", "SimpleQueueScenariosResponse", "SimpleQueueSettings", "SimpleQueuesResponse", "SimpleTestset", "SimpleTestsetCreate", "SimpleTestsetEdit", "SimpleTestsetQuery", "SimpleTestsetResponse", "SimpleTestsetsResponse", "SimpleTrace", "SimpleTraceChannel", "SimpleTraceCreate", "SimpleTraceCreateLinks", "SimpleTraceEdit", "SimpleTraceEditLinks", "SimpleTraceKind", "SimpleTraceLinkResponse", "SimpleTraceLinks", "SimpleTraceOrigin", "SimpleTraceQuery", "SimpleTraceQueryLinks", "SimpleTraceReferences", "SimpleTraceResponse", "SimpleTracesResponse", "SimpleWorkflow", "SimpleWorkflowCreate", "SimpleWorkflowDataInput", "SimpleWorkflowDataInputHeadersValue", "SimpleWorkflowDataInputRuntime", "SimpleWorkflowDataOutput", "SimpleWorkflowDataOutputHeadersValue", "SimpleWorkflowDataOutputRuntime", "SimpleWorkflowEdit", "SimpleWorkflowFlags", "SimpleWorkflowQuery", "SimpleWorkflowQueryFlags", "SimpleWorkflowResponse", "SimpleWorkflowsResponse", "SpanInput", "SpanInputEndTime", "SpanInputStartTime", "SpanOutput", "SpanOutputEndTime", "SpanOutputStartTime", "SpanResponse", "SpanType", "SpansNodeInput", "SpansNodeInputEndTime", "SpansNodeInputSpansValue", "SpansNodeInputStartTime", "SpansNodeOutput", "SpansNodeOutputEndTime", "SpansNodeOutputSpansValue", "SpansNodeOutputStartTime", "SpansResponse", "SpansTreeInput", "SpansTreeInputSpansValue", "SpansTreeOutput", "SpansTreeOutputSpansValue", "SsoProviderDto", "SsoProviderInfo", "SsoProviderSettingsDto", "SsoProviders", "StandardProviderDto", "StandardProviderKind", "StandardProviderSettingsDto", "Status", "StringOperator", "TestcaseInput", "TestcaseOutput", "TestcaseResponse", "TestcasesResponse", "Testset", "TestsetCreate", "TestsetEdit", "TestsetFlags", "TestsetQuery", "TestsetResponse", "TestsetRevision", "TestsetRevisionCommit", "TestsetRevisionCreate", "TestsetRevisionDataInput", "TestsetRevisionDataOutput", "TestsetRevisionDelta", "TestsetRevisionDeltaColumns", "TestsetRevisionDeltaRows", "TestsetRevisionEdit", "TestsetRevisionQuery", "TestsetRevisionResponse", "TestsetRevisionsLog", "TestsetRevisionsResponse", "TestsetVariant", "TestsetVariantCreate", "TestsetVariantEdit", "TestsetVariantFork", "TestsetVariantQuery", "TestsetVariantResponse", "TestsetVariantsResponse", "TestsetsResponse", "TextOptions", "ToolAuthScheme", "ToolCallData", "ToolCallFunction", "ToolCallResponse", "ToolCatalogAction", "ToolCatalogActionDetails", "ToolCatalogActionResponse", "ToolCatalogActionResponseAction", "ToolCatalogActionsResponse", "ToolCatalogActionsResponseActionsItem", "ToolCatalogIntegration", "ToolCatalogIntegrationDetails", "ToolCatalogIntegrationResponse", "ToolCatalogIntegrationResponseIntegration", "ToolCatalogIntegrationsResponse", "ToolCatalogIntegrationsResponseIntegrationsItem", "ToolCatalogProvider", "ToolCatalogProviderDetails", "ToolCatalogProviderResponse", "ToolCatalogProviderResponseProvider", "ToolCatalogProvidersResponse", "ToolCatalogProvidersResponseProvidersItem", "ToolConnection", "ToolConnectionCreate", "ToolConnectionCreateData", "ToolConnectionResponse", "ToolConnectionStatus", "ToolConnectionsResponse", "ToolProviderKind", "ToolResult", "ToolResultData", "TraceIdResponse", "TraceIdsResponse", "TraceInput", "TraceInputSpansValue", "TraceOutput", "TraceOutputSpansValue", "TraceRequest", "TraceResponse", "TraceType", "TracesRequest", "TracesResponse", "TracingQuery", "TriggerAuthScheme", "TriggerCatalogEvent", "TriggerCatalogEventDetails", "TriggerCatalogEventResponse", "TriggerCatalogEventsResponse", "TriggerCatalogIntegration", "TriggerCatalogIntegrationResponse", "TriggerCatalogIntegrationsResponse", "TriggerCatalogProvider", "TriggerCatalogProviderResponse", "TriggerCatalogProvidersResponse", "TriggerConnection", "TriggerConnectionCreate", "TriggerConnectionCreateData", "TriggerConnectionResponse", "TriggerConnectionStatus", "TriggerConnectionsResponse", "TriggerDeliveriesResponse", "TriggerDelivery", "TriggerDeliveryData", "TriggerDeliveryQuery", "TriggerDeliveryResponse", "TriggerEventAck", "TriggerProviderKind", "TriggerSchedule", "TriggerScheduleCreate", "TriggerScheduleData", "TriggerScheduleEdit", "TriggerScheduleFlags", "TriggerScheduleQuery", "TriggerScheduleResponse", "TriggerSchedulesResponse", "TriggerSubscription", "TriggerSubscriptionCreate", "TriggerSubscriptionData", "TriggerSubscriptionEdit", "TriggerSubscriptionFlags", "TriggerSubscriptionQuery", "TriggerSubscriptionResponse", "TriggerSubscriptionsResponse", "UnprocessableEntityError", "UserIdsResponse", "ValidationError", "ValidationErrorLocItem", "WebhookDeliveriesResponse", "WebhookDelivery", "WebhookDeliveryCreate", "WebhookDeliveryData", "WebhookDeliveryQuery", "WebhookDeliveryResponse", "WebhookDeliveryResponseInfo", "WebhookEventType", "WebhookProviderDto", "WebhookProviderSettingsDto", "WebhookSubscription", "WebhookSubscriptionCreate", "WebhookSubscriptionData", "WebhookSubscriptionDataAuthMode", "WebhookSubscriptionEdit", "WebhookSubscriptionFlags", "WebhookSubscriptionQuery", "WebhookSubscriptionResponse", "WebhookSubscriptionTestRequestSubscription", "WebhookSubscriptionsResponse", "Windowing", "WindowingOrder", "Workflow", "WorkflowArtifactFlags", "WorkflowCatalogFlags", "WorkflowCatalogPreset", "WorkflowCatalogPresetResponse", "WorkflowCatalogPresetsResponse", "WorkflowCatalogTemplate", "WorkflowCatalogTemplateResponse", "WorkflowCatalogTemplatesResponse", "WorkflowCatalogType", "WorkflowCatalogTypeResponse", "WorkflowCatalogTypesResponse", "WorkflowCreate", "WorkflowEdit", "WorkflowFlags", "WorkflowResponse", "WorkflowRevisionCommit", "WorkflowRevisionCreate", "WorkflowRevisionDataInput", "WorkflowRevisionDataInputHeadersValue", "WorkflowRevisionDataInputRuntime", "WorkflowRevisionDataOutput", "WorkflowRevisionDataOutputHeadersValue", "WorkflowRevisionDataOutputRuntime", "WorkflowRevisionEdit", "WorkflowRevisionFlags", "WorkflowRevisionInput", "WorkflowRevisionOutput", "WorkflowRevisionResolveResponse", "WorkflowRevisionResponse", "WorkflowRevisionsLog", "WorkflowRevisionsResponse", "WorkflowVariant", "WorkflowVariantCreate", "WorkflowVariantEdit", "WorkflowVariantFlags", "WorkflowVariantFork", "WorkflowVariantResponse", "WorkflowVariantsResponse", "WorkflowsResponse", "Workspace", "WorkspaceMemberResponse", "WorkspacePermission", "WorkspaceResponse", "access", "annotations", "applications", "billing", "environments", "evaluations", "evaluators", "events", "folders", "invocations", "keys", "legacy", "organizations", "projects", "queries", "secrets", "status", "testcases", "testsets", "tools", "traces", "triggers", "users", "webhooks", "workflows", "workspaces"] diff --git a/clients/python/agenta_client/client.py b/clients/python/agenta_client/client.py index a451eb5fc8..5456f5d715 100644 --- a/clients/python/agenta_client/client.py +++ b/clients/python/agenta_client/client.py @@ -30,6 +30,7 @@ from .testsets.client import AsyncTestsetsClient, TestsetsClient from .tools.client import AsyncToolsClient, ToolsClient from .traces.client import AsyncTracesClient, TracesClient + from .triggers.client import AsyncTriggersClient, TriggersClient from .users.client import AsyncUsersClient, UsersClient from .webhooks.client import AsyncWebhooksClient, WebhooksClient from .workflows.client import AsyncWorkflowsClient, WorkflowsClient @@ -98,6 +99,7 @@ def __init__(self, *, base_url: typing.Optional[str] = None, environment: Agenta self._evaluators: typing.Optional[EvaluatorsClient] = None self._environments: typing.Optional[EnvironmentsClient] = None self._tools: typing.Optional[ToolsClient] = None + self._triggers: typing.Optional[TriggersClient] = None self._evaluations: typing.Optional[EvaluationsClient] = None self._status: typing.Optional[StatusClient] = None self._projects: typing.Optional[ProjectsClient] = None @@ -244,6 +246,13 @@ def tools(self): self._tools = ToolsClient(client_wrapper=self._client_wrapper) return self._tools + @property + def triggers(self): + if self._triggers is None: + from .triggers.client import TriggersClient # noqa: E402 + self._triggers = TriggersClient(client_wrapper=self._client_wrapper) + return self._triggers + @property def evaluations(self): if self._evaluations is None: @@ -342,6 +351,7 @@ def __init__(self, *, base_url: typing.Optional[str] = None, environment: Agenta self._evaluators: typing.Optional[AsyncEvaluatorsClient] = None self._environments: typing.Optional[AsyncEnvironmentsClient] = None self._tools: typing.Optional[AsyncToolsClient] = None + self._triggers: typing.Optional[AsyncTriggersClient] = None self._evaluations: typing.Optional[AsyncEvaluationsClient] = None self._status: typing.Optional[AsyncStatusClient] = None self._projects: typing.Optional[AsyncProjectsClient] = None @@ -488,6 +498,13 @@ def tools(self): self._tools = AsyncToolsClient(client_wrapper=self._client_wrapper) return self._tools + @property + def triggers(self): + if self._triggers is None: + from .triggers.client import AsyncTriggersClient # noqa: E402 + self._triggers = AsyncTriggersClient(client_wrapper=self._client_wrapper) + return self._triggers + @property def evaluations(self): if self._evaluations is None: diff --git a/clients/python/agenta_client/triggers/__init__.py b/clients/python/agenta_client/triggers/__init__.py new file mode 100644 index 0000000000..5cde0202dc --- /dev/null +++ b/clients/python/agenta_client/triggers/__init__.py @@ -0,0 +1,4 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + diff --git a/clients/python/agenta_client/triggers/client.py b/clients/python/agenta_client/triggers/client.py new file mode 100644 index 0000000000..3a4bcf48ad --- /dev/null +++ b/clients/python/agenta_client/triggers/client.py @@ -0,0 +1,2324 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.request_options import RequestOptions +from ..types.trigger_catalog_event_response import TriggerCatalogEventResponse +from ..types.trigger_catalog_events_response import TriggerCatalogEventsResponse +from ..types.trigger_catalog_integration_response import TriggerCatalogIntegrationResponse +from ..types.trigger_catalog_integrations_response import TriggerCatalogIntegrationsResponse +from ..types.trigger_catalog_provider_response import TriggerCatalogProviderResponse +from ..types.trigger_catalog_providers_response import TriggerCatalogProvidersResponse +from ..types.trigger_connection_create import TriggerConnectionCreate +from ..types.trigger_connection_response import TriggerConnectionResponse +from ..types.trigger_connections_response import TriggerConnectionsResponse +from ..types.trigger_deliveries_response import TriggerDeliveriesResponse +from ..types.trigger_delivery_query import TriggerDeliveryQuery +from ..types.trigger_delivery_response import TriggerDeliveryResponse +from ..types.trigger_event_ack import TriggerEventAck +from ..types.trigger_schedule_create import TriggerScheduleCreate +from ..types.trigger_schedule_edit import TriggerScheduleEdit +from ..types.trigger_schedule_query import TriggerScheduleQuery +from ..types.trigger_schedule_response import TriggerScheduleResponse +from ..types.trigger_schedules_response import TriggerSchedulesResponse +from ..types.trigger_subscription_create import TriggerSubscriptionCreate +from ..types.trigger_subscription_edit import TriggerSubscriptionEdit +from ..types.trigger_subscription_query import TriggerSubscriptionQuery +from ..types.trigger_subscription_response import TriggerSubscriptionResponse +from ..types.trigger_subscriptions_response import TriggerSubscriptionsResponse +from ..types.windowing import Windowing +from .raw_client import AsyncRawTriggersClient, RawTriggersClient + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) +class TriggersClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._raw_client = RawTriggersClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> RawTriggersClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + RawTriggersClient + """ + return self._raw_client + + def ingest_composio_event(self, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerEventAck: + """ + 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. + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerEventAck + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.ingest_composio_event() + """ + _response = self._raw_client.ingest_composio_event(request_options=request_options) + return _response.data + + def list_trigger_providers(self, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerCatalogProvidersResponse: + """ + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerCatalogProvidersResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.list_trigger_providers() + """ + _response = self._raw_client.list_trigger_providers(request_options=request_options) + return _response.data + + def fetch_trigger_provider(self, provider_key: str, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerCatalogProviderResponse: + """ + Parameters + ---------- + provider_key : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerCatalogProviderResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.fetch_trigger_provider( + provider_key="provider_key", + ) + """ + _response = self._raw_client.fetch_trigger_provider(provider_key, request_options=request_options) + return _response.data + + def list_trigger_integrations(self, provider_key: str, *, search: typing.Optional[str] = None, sort_by: typing.Optional[str] = None, limit: typing.Optional[int] = None, cursor: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None) -> TriggerCatalogIntegrationsResponse: + """ + Parameters + ---------- + provider_key : str + + search : typing.Optional[str] + + sort_by : typing.Optional[str] + + limit : typing.Optional[int] + + cursor : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerCatalogIntegrationsResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.list_trigger_integrations( + provider_key="provider_key", + ) + """ + _response = self._raw_client.list_trigger_integrations(provider_key, search=search, sort_by=sort_by, limit=limit, cursor=cursor, request_options=request_options) + return _response.data + + def fetch_trigger_integration(self, provider_key: str, integration_key: str, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerCatalogIntegrationResponse: + """ + Parameters + ---------- + provider_key : str + + integration_key : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerCatalogIntegrationResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.fetch_trigger_integration( + provider_key="provider_key", + integration_key="integration_key", + ) + """ + _response = self._raw_client.fetch_trigger_integration(provider_key, integration_key, request_options=request_options) + return _response.data + + def list_trigger_events(self, provider_key: str, integration_key: str, *, query: typing.Optional[str] = None, limit: typing.Optional[int] = None, cursor: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None) -> TriggerCatalogEventsResponse: + """ + Parameters + ---------- + provider_key : str + + integration_key : str + + query : typing.Optional[str] + + limit : typing.Optional[int] + + cursor : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerCatalogEventsResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.list_trigger_events( + provider_key="provider_key", + integration_key="integration_key", + ) + """ + _response = self._raw_client.list_trigger_events(provider_key, integration_key, query=query, limit=limit, cursor=cursor, request_options=request_options) + return _response.data + + def fetch_trigger_event(self, provider_key: str, integration_key: str, event_key: str, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerCatalogEventResponse: + """ + Parameters + ---------- + provider_key : str + + integration_key : str + + event_key : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerCatalogEventResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.fetch_trigger_event( + provider_key="provider_key", + integration_key="integration_key", + event_key="event_key", + ) + """ + _response = self._raw_client.fetch_trigger_event(provider_key, integration_key, event_key, request_options=request_options) + return _response.data + + def query_trigger_connections(self, *, provider_key: typing.Optional[str] = None, integration_key: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None) -> TriggerConnectionsResponse: + """ + Parameters + ---------- + provider_key : typing.Optional[str] + + integration_key : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerConnectionsResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.query_trigger_connections() + """ + _response = self._raw_client.query_trigger_connections(provider_key=provider_key, integration_key=integration_key, request_options=request_options) + return _response.data + + def create_trigger_connection(self, *, connection: TriggerConnectionCreate, request_options: typing.Optional[RequestOptions] = None) -> TriggerConnectionResponse: + """ + Parameters + ---------- + connection : TriggerConnectionCreate + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerConnectionResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi, TriggerConnectionCreate + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.create_trigger_connection( + connection=TriggerConnectionCreate( + provider_key="composio", + integration_key="integration_key", + ), + ) + """ + _response = self._raw_client.create_trigger_connection(connection=connection, request_options=request_options) + return _response.data + + def fetch_trigger_connection(self, connection_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerConnectionResponse: + """ + Parameters + ---------- + connection_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerConnectionResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.fetch_trigger_connection( + connection_id="connection_id", + ) + """ + _response = self._raw_client.fetch_trigger_connection(connection_id, request_options=request_options) + return _response.data + + def delete_trigger_connection(self, connection_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> None: + """ + Parameters + ---------- + connection_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.delete_trigger_connection( + connection_id="connection_id", + ) + """ + _response = self._raw_client.delete_trigger_connection(connection_id, request_options=request_options) + return _response.data + + def refresh_trigger_connection(self, connection_id: str, *, force: typing.Optional[bool] = None, request_options: typing.Optional[RequestOptions] = None) -> TriggerConnectionResponse: + """ + Parameters + ---------- + connection_id : str + + force : typing.Optional[bool] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerConnectionResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.refresh_trigger_connection( + connection_id="connection_id", + ) + """ + _response = self._raw_client.refresh_trigger_connection(connection_id, force=force, request_options=request_options) + return _response.data + + def revoke_trigger_connection(self, connection_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerConnectionResponse: + """ + Parameters + ---------- + connection_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerConnectionResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.revoke_trigger_connection( + connection_id="connection_id", + ) + """ + _response = self._raw_client.revoke_trigger_connection(connection_id, request_options=request_options) + return _response.data + + def list_trigger_subscriptions(self, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerSubscriptionsResponse: + """ + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerSubscriptionsResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.list_trigger_subscriptions() + """ + _response = self._raw_client.list_trigger_subscriptions(request_options=request_options) + return _response.data + + def create_trigger_subscription(self, *, subscription: TriggerSubscriptionCreate, request_options: typing.Optional[RequestOptions] = None) -> TriggerSubscriptionResponse: + """ + Parameters + ---------- + subscription : TriggerSubscriptionCreate + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerSubscriptionResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi, TriggerSubscriptionCreate, TriggerSubscriptionData + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.create_trigger_subscription( + subscription=TriggerSubscriptionCreate( + connection_id="connection_id", + data=TriggerSubscriptionData( + event_key="event_key", + ), + ), + ) + """ + _response = self._raw_client.create_trigger_subscription(subscription=subscription, request_options=request_options) + return _response.data + + def query_trigger_subscriptions(self, *, subscription: typing.Optional[TriggerSubscriptionQuery] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> TriggerSubscriptionsResponse: + """ + Parameters + ---------- + subscription : typing.Optional[TriggerSubscriptionQuery] + + windowing : typing.Optional[Windowing] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerSubscriptionsResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.query_trigger_subscriptions() + """ + _response = self._raw_client.query_trigger_subscriptions(subscription=subscription, windowing=windowing, request_options=request_options) + return _response.data + + def refresh_trigger_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerSubscriptionResponse: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerSubscriptionResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.refresh_trigger_subscription( + subscription_id="subscription_id", + ) + """ + _response = self._raw_client.refresh_trigger_subscription(subscription_id, request_options=request_options) + return _response.data + + def revoke_trigger_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerSubscriptionResponse: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerSubscriptionResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.revoke_trigger_subscription( + subscription_id="subscription_id", + ) + """ + _response = self._raw_client.revoke_trigger_subscription(subscription_id, request_options=request_options) + return _response.data + + def start_trigger_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerSubscriptionResponse: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerSubscriptionResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.start_trigger_subscription( + subscription_id="subscription_id", + ) + """ + _response = self._raw_client.start_trigger_subscription(subscription_id, request_options=request_options) + return _response.data + + def stop_trigger_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerSubscriptionResponse: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerSubscriptionResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.stop_trigger_subscription( + subscription_id="subscription_id", + ) + """ + _response = self._raw_client.stop_trigger_subscription(subscription_id, request_options=request_options) + return _response.data + + def fetch_trigger_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerSubscriptionResponse: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerSubscriptionResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.fetch_trigger_subscription( + subscription_id="subscription_id", + ) + """ + _response = self._raw_client.fetch_trigger_subscription(subscription_id, request_options=request_options) + return _response.data + + def edit_trigger_subscription(self, subscription_id: str, *, subscription: TriggerSubscriptionEdit, request_options: typing.Optional[RequestOptions] = None) -> TriggerSubscriptionResponse: + """ + Parameters + ---------- + subscription_id : str + + subscription : TriggerSubscriptionEdit + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerSubscriptionResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi, TriggerSubscriptionData, TriggerSubscriptionEdit + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.edit_trigger_subscription( + subscription_id="subscription_id", + subscription=TriggerSubscriptionEdit( + connection_id="connection_id", + data=TriggerSubscriptionData( + event_key="event_key", + ), + ), + ) + """ + _response = self._raw_client.edit_trigger_subscription(subscription_id, subscription=subscription, request_options=request_options) + return _response.data + + def delete_trigger_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> None: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.delete_trigger_subscription( + subscription_id="subscription_id", + ) + """ + _response = self._raw_client.delete_trigger_subscription(subscription_id, request_options=request_options) + return _response.data + + def list_trigger_schedules(self, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerSchedulesResponse: + """ + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerSchedulesResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.list_trigger_schedules() + """ + _response = self._raw_client.list_trigger_schedules(request_options=request_options) + return _response.data + + def create_trigger_schedule(self, *, schedule: TriggerScheduleCreate, request_options: typing.Optional[RequestOptions] = None) -> TriggerScheduleResponse: + """ + Parameters + ---------- + schedule : TriggerScheduleCreate + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerScheduleResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi, TriggerScheduleCreate, TriggerScheduleData + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.create_trigger_schedule( + schedule=TriggerScheduleCreate( + data=TriggerScheduleData( + event_key="event_key", + schedule="schedule", + ), + ), + ) + """ + _response = self._raw_client.create_trigger_schedule(schedule=schedule, request_options=request_options) + return _response.data + + def query_trigger_schedules(self, *, schedule: typing.Optional[TriggerScheduleQuery] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> TriggerSchedulesResponse: + """ + Parameters + ---------- + schedule : typing.Optional[TriggerScheduleQuery] + + windowing : typing.Optional[Windowing] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerSchedulesResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.query_trigger_schedules() + """ + _response = self._raw_client.query_trigger_schedules(schedule=schedule, windowing=windowing, request_options=request_options) + return _response.data + + def fetch_trigger_schedule(self, schedule_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerScheduleResponse: + """ + Parameters + ---------- + schedule_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerScheduleResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.fetch_trigger_schedule( + schedule_id="schedule_id", + ) + """ + _response = self._raw_client.fetch_trigger_schedule(schedule_id, request_options=request_options) + return _response.data + + def edit_trigger_schedule(self, schedule_id: str, *, schedule: TriggerScheduleEdit, request_options: typing.Optional[RequestOptions] = None) -> TriggerScheduleResponse: + """ + Parameters + ---------- + schedule_id : str + + schedule : TriggerScheduleEdit + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerScheduleResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi, TriggerScheduleData, TriggerScheduleEdit + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.edit_trigger_schedule( + schedule_id="schedule_id", + schedule=TriggerScheduleEdit( + data=TriggerScheduleData( + event_key="event_key", + schedule="schedule", + ), + ), + ) + """ + _response = self._raw_client.edit_trigger_schedule(schedule_id, schedule=schedule, request_options=request_options) + return _response.data + + def delete_trigger_schedule(self, schedule_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> None: + """ + Parameters + ---------- + schedule_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.delete_trigger_schedule( + schedule_id="schedule_id", + ) + """ + _response = self._raw_client.delete_trigger_schedule(schedule_id, request_options=request_options) + return _response.data + + def start_trigger_schedule(self, schedule_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerScheduleResponse: + """ + Parameters + ---------- + schedule_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerScheduleResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.start_trigger_schedule( + schedule_id="schedule_id", + ) + """ + _response = self._raw_client.start_trigger_schedule(schedule_id, request_options=request_options) + return _response.data + + def stop_trigger_schedule(self, schedule_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerScheduleResponse: + """ + Parameters + ---------- + schedule_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerScheduleResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.stop_trigger_schedule( + schedule_id="schedule_id", + ) + """ + _response = self._raw_client.stop_trigger_schedule(schedule_id, request_options=request_options) + return _response.data + + def list_trigger_deliveries(self, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerDeliveriesResponse: + """ + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerDeliveriesResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.list_trigger_deliveries() + """ + _response = self._raw_client.list_trigger_deliveries(request_options=request_options) + return _response.data + + def query_trigger_deliveries(self, *, delivery: typing.Optional[TriggerDeliveryQuery] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> TriggerDeliveriesResponse: + """ + Parameters + ---------- + delivery : typing.Optional[TriggerDeliveryQuery] + + windowing : typing.Optional[Windowing] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerDeliveriesResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.query_trigger_deliveries() + """ + _response = self._raw_client.query_trigger_deliveries(delivery=delivery, windowing=windowing, request_options=request_options) + return _response.data + + def fetch_trigger_delivery(self, delivery_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerDeliveryResponse: + """ + Parameters + ---------- + delivery_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerDeliveryResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.triggers.fetch_trigger_delivery( + delivery_id="delivery_id", + ) + """ + _response = self._raw_client.fetch_trigger_delivery(delivery_id, request_options=request_options) + return _response.data +class AsyncTriggersClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._raw_client = AsyncRawTriggersClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> AsyncRawTriggersClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + AsyncRawTriggersClient + """ + return self._raw_client + + async def ingest_composio_event(self, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerEventAck: + """ + 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. + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerEventAck + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.ingest_composio_event() + + + asyncio.run(main()) + """ + _response = await self._raw_client.ingest_composio_event(request_options=request_options) + return _response.data + + async def list_trigger_providers(self, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerCatalogProvidersResponse: + """ + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerCatalogProvidersResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.list_trigger_providers() + + + asyncio.run(main()) + """ + _response = await self._raw_client.list_trigger_providers(request_options=request_options) + return _response.data + + async def fetch_trigger_provider(self, provider_key: str, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerCatalogProviderResponse: + """ + Parameters + ---------- + provider_key : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerCatalogProviderResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.fetch_trigger_provider( + provider_key="provider_key", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.fetch_trigger_provider(provider_key, request_options=request_options) + return _response.data + + async def list_trigger_integrations(self, provider_key: str, *, search: typing.Optional[str] = None, sort_by: typing.Optional[str] = None, limit: typing.Optional[int] = None, cursor: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None) -> TriggerCatalogIntegrationsResponse: + """ + Parameters + ---------- + provider_key : str + + search : typing.Optional[str] + + sort_by : typing.Optional[str] + + limit : typing.Optional[int] + + cursor : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerCatalogIntegrationsResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.list_trigger_integrations( + provider_key="provider_key", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.list_trigger_integrations(provider_key, search=search, sort_by=sort_by, limit=limit, cursor=cursor, request_options=request_options) + return _response.data + + async def fetch_trigger_integration(self, provider_key: str, integration_key: str, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerCatalogIntegrationResponse: + """ + Parameters + ---------- + provider_key : str + + integration_key : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerCatalogIntegrationResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.fetch_trigger_integration( + provider_key="provider_key", + integration_key="integration_key", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.fetch_trigger_integration(provider_key, integration_key, request_options=request_options) + return _response.data + + async def list_trigger_events(self, provider_key: str, integration_key: str, *, query: typing.Optional[str] = None, limit: typing.Optional[int] = None, cursor: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None) -> TriggerCatalogEventsResponse: + """ + Parameters + ---------- + provider_key : str + + integration_key : str + + query : typing.Optional[str] + + limit : typing.Optional[int] + + cursor : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerCatalogEventsResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.list_trigger_events( + provider_key="provider_key", + integration_key="integration_key", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.list_trigger_events(provider_key, integration_key, query=query, limit=limit, cursor=cursor, request_options=request_options) + return _response.data + + async def fetch_trigger_event(self, provider_key: str, integration_key: str, event_key: str, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerCatalogEventResponse: + """ + Parameters + ---------- + provider_key : str + + integration_key : str + + event_key : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerCatalogEventResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.fetch_trigger_event( + provider_key="provider_key", + integration_key="integration_key", + event_key="event_key", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.fetch_trigger_event(provider_key, integration_key, event_key, request_options=request_options) + return _response.data + + async def query_trigger_connections(self, *, provider_key: typing.Optional[str] = None, integration_key: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None) -> TriggerConnectionsResponse: + """ + Parameters + ---------- + provider_key : typing.Optional[str] + + integration_key : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerConnectionsResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.query_trigger_connections() + + + asyncio.run(main()) + """ + _response = await self._raw_client.query_trigger_connections(provider_key=provider_key, integration_key=integration_key, request_options=request_options) + return _response.data + + async def create_trigger_connection(self, *, connection: TriggerConnectionCreate, request_options: typing.Optional[RequestOptions] = None) -> TriggerConnectionResponse: + """ + Parameters + ---------- + connection : TriggerConnectionCreate + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerConnectionResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi, TriggerConnectionCreate + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.create_trigger_connection( + connection=TriggerConnectionCreate( + provider_key="composio", + integration_key="integration_key", + ), + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.create_trigger_connection(connection=connection, request_options=request_options) + return _response.data + + async def fetch_trigger_connection(self, connection_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerConnectionResponse: + """ + Parameters + ---------- + connection_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerConnectionResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.fetch_trigger_connection( + connection_id="connection_id", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.fetch_trigger_connection(connection_id, request_options=request_options) + return _response.data + + async def delete_trigger_connection(self, connection_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> None: + """ + Parameters + ---------- + connection_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.delete_trigger_connection( + connection_id="connection_id", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.delete_trigger_connection(connection_id, request_options=request_options) + return _response.data + + async def refresh_trigger_connection(self, connection_id: str, *, force: typing.Optional[bool] = None, request_options: typing.Optional[RequestOptions] = None) -> TriggerConnectionResponse: + """ + Parameters + ---------- + connection_id : str + + force : typing.Optional[bool] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerConnectionResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.refresh_trigger_connection( + connection_id="connection_id", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.refresh_trigger_connection(connection_id, force=force, request_options=request_options) + return _response.data + + async def revoke_trigger_connection(self, connection_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerConnectionResponse: + """ + Parameters + ---------- + connection_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerConnectionResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.revoke_trigger_connection( + connection_id="connection_id", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.revoke_trigger_connection(connection_id, request_options=request_options) + return _response.data + + async def list_trigger_subscriptions(self, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerSubscriptionsResponse: + """ + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerSubscriptionsResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.list_trigger_subscriptions() + + + asyncio.run(main()) + """ + _response = await self._raw_client.list_trigger_subscriptions(request_options=request_options) + return _response.data + + async def create_trigger_subscription(self, *, subscription: TriggerSubscriptionCreate, request_options: typing.Optional[RequestOptions] = None) -> TriggerSubscriptionResponse: + """ + Parameters + ---------- + subscription : TriggerSubscriptionCreate + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerSubscriptionResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import ( + AsyncAgentaApi, + TriggerSubscriptionCreate, + TriggerSubscriptionData, + ) + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.create_trigger_subscription( + subscription=TriggerSubscriptionCreate( + connection_id="connection_id", + data=TriggerSubscriptionData( + event_key="event_key", + ), + ), + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.create_trigger_subscription(subscription=subscription, request_options=request_options) + return _response.data + + async def query_trigger_subscriptions(self, *, subscription: typing.Optional[TriggerSubscriptionQuery] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> TriggerSubscriptionsResponse: + """ + Parameters + ---------- + subscription : typing.Optional[TriggerSubscriptionQuery] + + windowing : typing.Optional[Windowing] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerSubscriptionsResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.query_trigger_subscriptions() + + + asyncio.run(main()) + """ + _response = await self._raw_client.query_trigger_subscriptions(subscription=subscription, windowing=windowing, request_options=request_options) + return _response.data + + async def refresh_trigger_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerSubscriptionResponse: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerSubscriptionResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.refresh_trigger_subscription( + subscription_id="subscription_id", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.refresh_trigger_subscription(subscription_id, request_options=request_options) + return _response.data + + async def revoke_trigger_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerSubscriptionResponse: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerSubscriptionResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.revoke_trigger_subscription( + subscription_id="subscription_id", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.revoke_trigger_subscription(subscription_id, request_options=request_options) + return _response.data + + async def start_trigger_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerSubscriptionResponse: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerSubscriptionResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.start_trigger_subscription( + subscription_id="subscription_id", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.start_trigger_subscription(subscription_id, request_options=request_options) + return _response.data + + async def stop_trigger_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerSubscriptionResponse: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerSubscriptionResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.stop_trigger_subscription( + subscription_id="subscription_id", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.stop_trigger_subscription(subscription_id, request_options=request_options) + return _response.data + + async def fetch_trigger_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerSubscriptionResponse: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerSubscriptionResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.fetch_trigger_subscription( + subscription_id="subscription_id", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.fetch_trigger_subscription(subscription_id, request_options=request_options) + return _response.data + + async def edit_trigger_subscription(self, subscription_id: str, *, subscription: TriggerSubscriptionEdit, request_options: typing.Optional[RequestOptions] = None) -> TriggerSubscriptionResponse: + """ + Parameters + ---------- + subscription_id : str + + subscription : TriggerSubscriptionEdit + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerSubscriptionResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import ( + AsyncAgentaApi, + TriggerSubscriptionData, + TriggerSubscriptionEdit, + ) + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.edit_trigger_subscription( + subscription_id="subscription_id", + subscription=TriggerSubscriptionEdit( + connection_id="connection_id", + data=TriggerSubscriptionData( + event_key="event_key", + ), + ), + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.edit_trigger_subscription(subscription_id, subscription=subscription, request_options=request_options) + return _response.data + + async def delete_trigger_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> None: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.delete_trigger_subscription( + subscription_id="subscription_id", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.delete_trigger_subscription(subscription_id, request_options=request_options) + return _response.data + + async def list_trigger_schedules(self, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerSchedulesResponse: + """ + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerSchedulesResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.list_trigger_schedules() + + + asyncio.run(main()) + """ + _response = await self._raw_client.list_trigger_schedules(request_options=request_options) + return _response.data + + async def create_trigger_schedule(self, *, schedule: TriggerScheduleCreate, request_options: typing.Optional[RequestOptions] = None) -> TriggerScheduleResponse: + """ + Parameters + ---------- + schedule : TriggerScheduleCreate + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerScheduleResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi, TriggerScheduleCreate, TriggerScheduleData + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.create_trigger_schedule( + schedule=TriggerScheduleCreate( + data=TriggerScheduleData( + event_key="event_key", + schedule="schedule", + ), + ), + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.create_trigger_schedule(schedule=schedule, request_options=request_options) + return _response.data + + async def query_trigger_schedules(self, *, schedule: typing.Optional[TriggerScheduleQuery] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> TriggerSchedulesResponse: + """ + Parameters + ---------- + schedule : typing.Optional[TriggerScheduleQuery] + + windowing : typing.Optional[Windowing] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerSchedulesResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.query_trigger_schedules() + + + asyncio.run(main()) + """ + _response = await self._raw_client.query_trigger_schedules(schedule=schedule, windowing=windowing, request_options=request_options) + return _response.data + + async def fetch_trigger_schedule(self, schedule_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerScheduleResponse: + """ + Parameters + ---------- + schedule_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerScheduleResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.fetch_trigger_schedule( + schedule_id="schedule_id", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.fetch_trigger_schedule(schedule_id, request_options=request_options) + return _response.data + + async def edit_trigger_schedule(self, schedule_id: str, *, schedule: TriggerScheduleEdit, request_options: typing.Optional[RequestOptions] = None) -> TriggerScheduleResponse: + """ + Parameters + ---------- + schedule_id : str + + schedule : TriggerScheduleEdit + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerScheduleResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi, TriggerScheduleData, TriggerScheduleEdit + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.edit_trigger_schedule( + schedule_id="schedule_id", + schedule=TriggerScheduleEdit( + data=TriggerScheduleData( + event_key="event_key", + schedule="schedule", + ), + ), + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.edit_trigger_schedule(schedule_id, schedule=schedule, request_options=request_options) + return _response.data + + async def delete_trigger_schedule(self, schedule_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> None: + """ + Parameters + ---------- + schedule_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.delete_trigger_schedule( + schedule_id="schedule_id", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.delete_trigger_schedule(schedule_id, request_options=request_options) + return _response.data + + async def start_trigger_schedule(self, schedule_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerScheduleResponse: + """ + Parameters + ---------- + schedule_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerScheduleResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.start_trigger_schedule( + schedule_id="schedule_id", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.start_trigger_schedule(schedule_id, request_options=request_options) + return _response.data + + async def stop_trigger_schedule(self, schedule_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerScheduleResponse: + """ + Parameters + ---------- + schedule_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerScheduleResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.stop_trigger_schedule( + schedule_id="schedule_id", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.stop_trigger_schedule(schedule_id, request_options=request_options) + return _response.data + + async def list_trigger_deliveries(self, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerDeliveriesResponse: + """ + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerDeliveriesResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.list_trigger_deliveries() + + + asyncio.run(main()) + """ + _response = await self._raw_client.list_trigger_deliveries(request_options=request_options) + return _response.data + + async def query_trigger_deliveries(self, *, delivery: typing.Optional[TriggerDeliveryQuery] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> TriggerDeliveriesResponse: + """ + Parameters + ---------- + delivery : typing.Optional[TriggerDeliveryQuery] + + windowing : typing.Optional[Windowing] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerDeliveriesResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.query_trigger_deliveries() + + + asyncio.run(main()) + """ + _response = await self._raw_client.query_trigger_deliveries(delivery=delivery, windowing=windowing, request_options=request_options) + return _response.data + + async def fetch_trigger_delivery(self, delivery_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> TriggerDeliveryResponse: + """ + Parameters + ---------- + delivery_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TriggerDeliveryResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.triggers.fetch_trigger_delivery( + delivery_id="delivery_id", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.fetch_trigger_delivery(delivery_id, request_options=request_options) + return _response.data diff --git a/clients/python/agenta_client/triggers/raw_client.py b/clients/python/agenta_client/triggers/raw_client.py new file mode 100644 index 0000000000..84ef197a09 --- /dev/null +++ b/clients/python/agenta_client/triggers/raw_client.py @@ -0,0 +1,2835 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing +from json.decoder import JSONDecodeError + +from ..core.api_error import ApiError +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.http_response import AsyncHttpResponse, HttpResponse +from ..core.jsonable_encoder import jsonable_encoder +from ..core.pydantic_utilities import parse_obj_as +from ..core.request_options import RequestOptions +from ..core.serialization import convert_and_respect_annotation_metadata +from ..errors.unprocessable_entity_error import UnprocessableEntityError +from ..types.http_validation_error import HttpValidationError +from ..types.trigger_catalog_event_response import TriggerCatalogEventResponse +from ..types.trigger_catalog_events_response import TriggerCatalogEventsResponse +from ..types.trigger_catalog_integration_response import TriggerCatalogIntegrationResponse +from ..types.trigger_catalog_integrations_response import TriggerCatalogIntegrationsResponse +from ..types.trigger_catalog_provider_response import TriggerCatalogProviderResponse +from ..types.trigger_catalog_providers_response import TriggerCatalogProvidersResponse +from ..types.trigger_connection_create import TriggerConnectionCreate +from ..types.trigger_connection_response import TriggerConnectionResponse +from ..types.trigger_connections_response import TriggerConnectionsResponse +from ..types.trigger_deliveries_response import TriggerDeliveriesResponse +from ..types.trigger_delivery_query import TriggerDeliveryQuery +from ..types.trigger_delivery_response import TriggerDeliveryResponse +from ..types.trigger_event_ack import TriggerEventAck +from ..types.trigger_schedule_create import TriggerScheduleCreate +from ..types.trigger_schedule_edit import TriggerScheduleEdit +from ..types.trigger_schedule_query import TriggerScheduleQuery +from ..types.trigger_schedule_response import TriggerScheduleResponse +from ..types.trigger_schedules_response import TriggerSchedulesResponse +from ..types.trigger_subscription_create import TriggerSubscriptionCreate +from ..types.trigger_subscription_edit import TriggerSubscriptionEdit +from ..types.trigger_subscription_query import TriggerSubscriptionQuery +from ..types.trigger_subscription_response import TriggerSubscriptionResponse +from ..types.trigger_subscriptions_response import TriggerSubscriptionsResponse +from ..types.windowing import Windowing + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) +class RawTriggersClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._client_wrapper = client_wrapper + + def ingest_composio_event(self, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerEventAck]: + """ + 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. + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerEventAck] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + "triggers/composio/events/",method="POST", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerEventAck, + parse_obj_as( + type_ =TriggerEventAck, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def list_trigger_providers(self, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerCatalogProvidersResponse]: + """ + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerCatalogProvidersResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + "triggers/catalog/providers/",method="GET", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerCatalogProvidersResponse, + parse_obj_as( + type_ =TriggerCatalogProvidersResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def fetch_trigger_provider(self, provider_key: str, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerCatalogProviderResponse]: + """ + Parameters + ---------- + provider_key : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerCatalogProviderResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + f"triggers/catalog/providers/{jsonable_encoder(provider_key)}",method="GET", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerCatalogProviderResponse, + parse_obj_as( + type_ =TriggerCatalogProviderResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def list_trigger_integrations(self, provider_key: str, *, search: typing.Optional[str] = None, sort_by: typing.Optional[str] = None, limit: typing.Optional[int] = None, cursor: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerCatalogIntegrationsResponse]: + """ + Parameters + ---------- + provider_key : str + + search : typing.Optional[str] + + sort_by : typing.Optional[str] + + limit : typing.Optional[int] + + cursor : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerCatalogIntegrationsResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + f"triggers/catalog/providers/{jsonable_encoder(provider_key)}/integrations/",method="GET", + params={"search": search, "sort_by": sort_by, "limit": limit, "cursor": cursor, } + , + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerCatalogIntegrationsResponse, + parse_obj_as( + type_ =TriggerCatalogIntegrationsResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def fetch_trigger_integration(self, provider_key: str, integration_key: str, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerCatalogIntegrationResponse]: + """ + Parameters + ---------- + provider_key : str + + integration_key : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerCatalogIntegrationResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + f"triggers/catalog/providers/{jsonable_encoder(provider_key)}/integrations/{jsonable_encoder(integration_key)}",method="GET", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerCatalogIntegrationResponse, + parse_obj_as( + type_ =TriggerCatalogIntegrationResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def list_trigger_events(self, provider_key: str, integration_key: str, *, query: typing.Optional[str] = None, limit: typing.Optional[int] = None, cursor: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerCatalogEventsResponse]: + """ + Parameters + ---------- + provider_key : str + + integration_key : str + + query : typing.Optional[str] + + limit : typing.Optional[int] + + cursor : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerCatalogEventsResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + f"triggers/catalog/providers/{jsonable_encoder(provider_key)}/integrations/{jsonable_encoder(integration_key)}/events/",method="GET", + params={"query": query, "limit": limit, "cursor": cursor, } + , + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerCatalogEventsResponse, + parse_obj_as( + type_ =TriggerCatalogEventsResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def fetch_trigger_event(self, provider_key: str, integration_key: str, event_key: str, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerCatalogEventResponse]: + """ + Parameters + ---------- + provider_key : str + + integration_key : str + + event_key : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerCatalogEventResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + f"triggers/catalog/providers/{jsonable_encoder(provider_key)}/integrations/{jsonable_encoder(integration_key)}/events/{jsonable_encoder(event_key)}",method="GET", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerCatalogEventResponse, + parse_obj_as( + type_ =TriggerCatalogEventResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def query_trigger_connections(self, *, provider_key: typing.Optional[str] = None, integration_key: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerConnectionsResponse]: + """ + Parameters + ---------- + provider_key : typing.Optional[str] + + integration_key : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerConnectionsResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + "triggers/connections/query",method="POST", + params={"provider_key": provider_key, "integration_key": integration_key, } + , + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerConnectionsResponse, + parse_obj_as( + type_ =TriggerConnectionsResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def create_trigger_connection(self, *, connection: TriggerConnectionCreate, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerConnectionResponse]: + """ + Parameters + ---------- + connection : TriggerConnectionCreate + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerConnectionResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + "triggers/connections/",method="POST", + json={ + "connection": convert_and_respect_annotation_metadata(object_=connection, annotation=TriggerConnectionCreate, direction="write"), + } + , + headers={"content-type": "application/json", } + , + request_options=request_options,omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerConnectionResponse, + parse_obj_as( + type_ =TriggerConnectionResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def fetch_trigger_connection(self, connection_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerConnectionResponse]: + """ + Parameters + ---------- + connection_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerConnectionResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + f"triggers/connections/{jsonable_encoder(connection_id)}",method="GET", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerConnectionResponse, + parse_obj_as( + type_ =TriggerConnectionResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def delete_trigger_connection(self, connection_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[None]: + """ + Parameters + ---------- + connection_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[None] + """ + _response = self._client_wrapper.httpx_client.request( + f"triggers/connections/{jsonable_encoder(connection_id)}",method="DELETE", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + return HttpResponse(response=_response, data=None) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def refresh_trigger_connection(self, connection_id: str, *, force: typing.Optional[bool] = None, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerConnectionResponse]: + """ + Parameters + ---------- + connection_id : str + + force : typing.Optional[bool] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerConnectionResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + f"triggers/connections/{jsonable_encoder(connection_id)}/refresh",method="POST", + params={"force": force, } + , + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerConnectionResponse, + parse_obj_as( + type_ =TriggerConnectionResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def revoke_trigger_connection(self, connection_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerConnectionResponse]: + """ + Parameters + ---------- + connection_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerConnectionResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + f"triggers/connections/{jsonable_encoder(connection_id)}/revoke",method="POST", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerConnectionResponse, + parse_obj_as( + type_ =TriggerConnectionResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def list_trigger_subscriptions(self, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerSubscriptionsResponse]: + """ + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerSubscriptionsResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + "triggers/subscriptions/",method="GET", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerSubscriptionsResponse, + parse_obj_as( + type_ =TriggerSubscriptionsResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def create_trigger_subscription(self, *, subscription: TriggerSubscriptionCreate, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerSubscriptionResponse]: + """ + Parameters + ---------- + subscription : TriggerSubscriptionCreate + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerSubscriptionResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + "triggers/subscriptions/",method="POST", + json={ + "subscription": convert_and_respect_annotation_metadata(object_=subscription, annotation=TriggerSubscriptionCreate, direction="write"), + } + , + headers={"content-type": "application/json", } + , + request_options=request_options,omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerSubscriptionResponse, + parse_obj_as( + type_ =TriggerSubscriptionResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def query_trigger_subscriptions(self, *, subscription: typing.Optional[TriggerSubscriptionQuery] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerSubscriptionsResponse]: + """ + Parameters + ---------- + subscription : typing.Optional[TriggerSubscriptionQuery] + + windowing : typing.Optional[Windowing] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerSubscriptionsResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + "triggers/subscriptions/query",method="POST", + json={ + "subscription": convert_and_respect_annotation_metadata(object_=subscription, annotation=typing.Optional[TriggerSubscriptionQuery], direction="write"), + "windowing": convert_and_respect_annotation_metadata(object_=windowing, annotation=typing.Optional[Windowing], direction="write"), + } + , + headers={"content-type": "application/json", } + , + request_options=request_options,omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerSubscriptionsResponse, + parse_obj_as( + type_ =TriggerSubscriptionsResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def refresh_trigger_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerSubscriptionResponse]: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerSubscriptionResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + f"triggers/subscriptions/{jsonable_encoder(subscription_id)}/refresh",method="POST", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerSubscriptionResponse, + parse_obj_as( + type_ =TriggerSubscriptionResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def revoke_trigger_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerSubscriptionResponse]: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerSubscriptionResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + f"triggers/subscriptions/{jsonable_encoder(subscription_id)}/revoke",method="POST", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerSubscriptionResponse, + parse_obj_as( + type_ =TriggerSubscriptionResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def start_trigger_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerSubscriptionResponse]: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerSubscriptionResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + f"triggers/subscriptions/{jsonable_encoder(subscription_id)}/start",method="POST", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerSubscriptionResponse, + parse_obj_as( + type_ =TriggerSubscriptionResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def stop_trigger_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerSubscriptionResponse]: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerSubscriptionResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + f"triggers/subscriptions/{jsonable_encoder(subscription_id)}/stop",method="POST", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerSubscriptionResponse, + parse_obj_as( + type_ =TriggerSubscriptionResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def fetch_trigger_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerSubscriptionResponse]: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerSubscriptionResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + f"triggers/subscriptions/{jsonable_encoder(subscription_id)}",method="GET", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerSubscriptionResponse, + parse_obj_as( + type_ =TriggerSubscriptionResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def edit_trigger_subscription(self, subscription_id: str, *, subscription: TriggerSubscriptionEdit, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerSubscriptionResponse]: + """ + Parameters + ---------- + subscription_id : str + + subscription : TriggerSubscriptionEdit + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerSubscriptionResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + f"triggers/subscriptions/{jsonable_encoder(subscription_id)}",method="PUT", + json={ + "subscription": convert_and_respect_annotation_metadata(object_=subscription, annotation=TriggerSubscriptionEdit, direction="write"), + } + , + headers={"content-type": "application/json", } + , + request_options=request_options,omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerSubscriptionResponse, + parse_obj_as( + type_ =TriggerSubscriptionResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def delete_trigger_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[None]: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[None] + """ + _response = self._client_wrapper.httpx_client.request( + f"triggers/subscriptions/{jsonable_encoder(subscription_id)}",method="DELETE", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + return HttpResponse(response=_response, data=None) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def list_trigger_schedules(self, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerSchedulesResponse]: + """ + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerSchedulesResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + "triggers/schedules",method="GET", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerSchedulesResponse, + parse_obj_as( + type_ =TriggerSchedulesResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def create_trigger_schedule(self, *, schedule: TriggerScheduleCreate, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerScheduleResponse]: + """ + Parameters + ---------- + schedule : TriggerScheduleCreate + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerScheduleResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + "triggers/schedules",method="POST", + json={ + "schedule": convert_and_respect_annotation_metadata(object_=schedule, annotation=TriggerScheduleCreate, direction="write"), + } + , + headers={"content-type": "application/json", } + , + request_options=request_options,omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerScheduleResponse, + parse_obj_as( + type_ =TriggerScheduleResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def query_trigger_schedules(self, *, schedule: typing.Optional[TriggerScheduleQuery] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerSchedulesResponse]: + """ + Parameters + ---------- + schedule : typing.Optional[TriggerScheduleQuery] + + windowing : typing.Optional[Windowing] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerSchedulesResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + "triggers/schedules/query",method="POST", + json={ + "schedule": convert_and_respect_annotation_metadata(object_=schedule, annotation=typing.Optional[TriggerScheduleQuery], direction="write"), + "windowing": convert_and_respect_annotation_metadata(object_=windowing, annotation=typing.Optional[Windowing], direction="write"), + } + , + headers={"content-type": "application/json", } + , + request_options=request_options,omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerSchedulesResponse, + parse_obj_as( + type_ =TriggerSchedulesResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def fetch_trigger_schedule(self, schedule_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerScheduleResponse]: + """ + Parameters + ---------- + schedule_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerScheduleResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + f"triggers/schedules/{jsonable_encoder(schedule_id)}",method="GET", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerScheduleResponse, + parse_obj_as( + type_ =TriggerScheduleResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def edit_trigger_schedule(self, schedule_id: str, *, schedule: TriggerScheduleEdit, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerScheduleResponse]: + """ + Parameters + ---------- + schedule_id : str + + schedule : TriggerScheduleEdit + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerScheduleResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + f"triggers/schedules/{jsonable_encoder(schedule_id)}",method="PUT", + json={ + "schedule": convert_and_respect_annotation_metadata(object_=schedule, annotation=TriggerScheduleEdit, direction="write"), + } + , + headers={"content-type": "application/json", } + , + request_options=request_options,omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerScheduleResponse, + parse_obj_as( + type_ =TriggerScheduleResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def delete_trigger_schedule(self, schedule_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[None]: + """ + Parameters + ---------- + schedule_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[None] + """ + _response = self._client_wrapper.httpx_client.request( + f"triggers/schedules/{jsonable_encoder(schedule_id)}",method="DELETE", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + return HttpResponse(response=_response, data=None) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def start_trigger_schedule(self, schedule_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerScheduleResponse]: + """ + Parameters + ---------- + schedule_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerScheduleResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + f"triggers/schedules/{jsonable_encoder(schedule_id)}/start",method="POST", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerScheduleResponse, + parse_obj_as( + type_ =TriggerScheduleResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def stop_trigger_schedule(self, schedule_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerScheduleResponse]: + """ + Parameters + ---------- + schedule_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerScheduleResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + f"triggers/schedules/{jsonable_encoder(schedule_id)}/stop",method="POST", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerScheduleResponse, + parse_obj_as( + type_ =TriggerScheduleResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def list_trigger_deliveries(self, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerDeliveriesResponse]: + """ + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerDeliveriesResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + "triggers/deliveries",method="GET", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerDeliveriesResponse, + parse_obj_as( + type_ =TriggerDeliveriesResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def query_trigger_deliveries(self, *, delivery: typing.Optional[TriggerDeliveryQuery] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerDeliveriesResponse]: + """ + Parameters + ---------- + delivery : typing.Optional[TriggerDeliveryQuery] + + windowing : typing.Optional[Windowing] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerDeliveriesResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + "triggers/deliveries/query",method="POST", + json={ + "delivery": convert_and_respect_annotation_metadata(object_=delivery, annotation=typing.Optional[TriggerDeliveryQuery], direction="write"), + "windowing": convert_and_respect_annotation_metadata(object_=windowing, annotation=typing.Optional[Windowing], direction="write"), + } + , + headers={"content-type": "application/json", } + , + request_options=request_options,omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerDeliveriesResponse, + parse_obj_as( + type_ =TriggerDeliveriesResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def fetch_trigger_delivery(self, delivery_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[TriggerDeliveryResponse]: + """ + Parameters + ---------- + delivery_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TriggerDeliveryResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + f"triggers/deliveries/{jsonable_encoder(delivery_id)}",method="GET", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerDeliveryResponse, + parse_obj_as( + type_ =TriggerDeliveryResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) +class AsyncRawTriggersClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def ingest_composio_event(self, *, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerEventAck]: + """ + 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. + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerEventAck] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + "triggers/composio/events/",method="POST", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerEventAck, + parse_obj_as( + type_ =TriggerEventAck, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def list_trigger_providers(self, *, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerCatalogProvidersResponse]: + """ + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerCatalogProvidersResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + "triggers/catalog/providers/",method="GET", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerCatalogProvidersResponse, + parse_obj_as( + type_ =TriggerCatalogProvidersResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def fetch_trigger_provider(self, provider_key: str, *, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerCatalogProviderResponse]: + """ + Parameters + ---------- + provider_key : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerCatalogProviderResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + f"triggers/catalog/providers/{jsonable_encoder(provider_key)}",method="GET", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerCatalogProviderResponse, + parse_obj_as( + type_ =TriggerCatalogProviderResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def list_trigger_integrations(self, provider_key: str, *, search: typing.Optional[str] = None, sort_by: typing.Optional[str] = None, limit: typing.Optional[int] = None, cursor: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerCatalogIntegrationsResponse]: + """ + Parameters + ---------- + provider_key : str + + search : typing.Optional[str] + + sort_by : typing.Optional[str] + + limit : typing.Optional[int] + + cursor : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerCatalogIntegrationsResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + f"triggers/catalog/providers/{jsonable_encoder(provider_key)}/integrations/",method="GET", + params={"search": search, "sort_by": sort_by, "limit": limit, "cursor": cursor, } + , + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerCatalogIntegrationsResponse, + parse_obj_as( + type_ =TriggerCatalogIntegrationsResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def fetch_trigger_integration(self, provider_key: str, integration_key: str, *, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerCatalogIntegrationResponse]: + """ + Parameters + ---------- + provider_key : str + + integration_key : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerCatalogIntegrationResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + f"triggers/catalog/providers/{jsonable_encoder(provider_key)}/integrations/{jsonable_encoder(integration_key)}",method="GET", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerCatalogIntegrationResponse, + parse_obj_as( + type_ =TriggerCatalogIntegrationResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def list_trigger_events(self, provider_key: str, integration_key: str, *, query: typing.Optional[str] = None, limit: typing.Optional[int] = None, cursor: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerCatalogEventsResponse]: + """ + Parameters + ---------- + provider_key : str + + integration_key : str + + query : typing.Optional[str] + + limit : typing.Optional[int] + + cursor : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerCatalogEventsResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + f"triggers/catalog/providers/{jsonable_encoder(provider_key)}/integrations/{jsonable_encoder(integration_key)}/events/",method="GET", + params={"query": query, "limit": limit, "cursor": cursor, } + , + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerCatalogEventsResponse, + parse_obj_as( + type_ =TriggerCatalogEventsResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def fetch_trigger_event(self, provider_key: str, integration_key: str, event_key: str, *, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerCatalogEventResponse]: + """ + Parameters + ---------- + provider_key : str + + integration_key : str + + event_key : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerCatalogEventResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + f"triggers/catalog/providers/{jsonable_encoder(provider_key)}/integrations/{jsonable_encoder(integration_key)}/events/{jsonable_encoder(event_key)}",method="GET", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerCatalogEventResponse, + parse_obj_as( + type_ =TriggerCatalogEventResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def query_trigger_connections(self, *, provider_key: typing.Optional[str] = None, integration_key: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerConnectionsResponse]: + """ + Parameters + ---------- + provider_key : typing.Optional[str] + + integration_key : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerConnectionsResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + "triggers/connections/query",method="POST", + params={"provider_key": provider_key, "integration_key": integration_key, } + , + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerConnectionsResponse, + parse_obj_as( + type_ =TriggerConnectionsResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def create_trigger_connection(self, *, connection: TriggerConnectionCreate, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerConnectionResponse]: + """ + Parameters + ---------- + connection : TriggerConnectionCreate + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerConnectionResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + "triggers/connections/",method="POST", + json={ + "connection": convert_and_respect_annotation_metadata(object_=connection, annotation=TriggerConnectionCreate, direction="write"), + } + , + headers={"content-type": "application/json", } + , + request_options=request_options,omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerConnectionResponse, + parse_obj_as( + type_ =TriggerConnectionResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def fetch_trigger_connection(self, connection_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerConnectionResponse]: + """ + Parameters + ---------- + connection_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerConnectionResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + f"triggers/connections/{jsonable_encoder(connection_id)}",method="GET", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerConnectionResponse, + parse_obj_as( + type_ =TriggerConnectionResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def delete_trigger_connection(self, connection_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[None]: + """ + Parameters + ---------- + connection_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[None] + """ + _response = await self._client_wrapper.httpx_client.request( + f"triggers/connections/{jsonable_encoder(connection_id)}",method="DELETE", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + return AsyncHttpResponse(response=_response, data=None) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def refresh_trigger_connection(self, connection_id: str, *, force: typing.Optional[bool] = None, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerConnectionResponse]: + """ + Parameters + ---------- + connection_id : str + + force : typing.Optional[bool] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerConnectionResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + f"triggers/connections/{jsonable_encoder(connection_id)}/refresh",method="POST", + params={"force": force, } + , + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerConnectionResponse, + parse_obj_as( + type_ =TriggerConnectionResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def revoke_trigger_connection(self, connection_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerConnectionResponse]: + """ + Parameters + ---------- + connection_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerConnectionResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + f"triggers/connections/{jsonable_encoder(connection_id)}/revoke",method="POST", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerConnectionResponse, + parse_obj_as( + type_ =TriggerConnectionResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def list_trigger_subscriptions(self, *, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerSubscriptionsResponse]: + """ + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerSubscriptionsResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + "triggers/subscriptions/",method="GET", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerSubscriptionsResponse, + parse_obj_as( + type_ =TriggerSubscriptionsResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def create_trigger_subscription(self, *, subscription: TriggerSubscriptionCreate, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerSubscriptionResponse]: + """ + Parameters + ---------- + subscription : TriggerSubscriptionCreate + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerSubscriptionResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + "triggers/subscriptions/",method="POST", + json={ + "subscription": convert_and_respect_annotation_metadata(object_=subscription, annotation=TriggerSubscriptionCreate, direction="write"), + } + , + headers={"content-type": "application/json", } + , + request_options=request_options,omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerSubscriptionResponse, + parse_obj_as( + type_ =TriggerSubscriptionResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def query_trigger_subscriptions(self, *, subscription: typing.Optional[TriggerSubscriptionQuery] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerSubscriptionsResponse]: + """ + Parameters + ---------- + subscription : typing.Optional[TriggerSubscriptionQuery] + + windowing : typing.Optional[Windowing] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerSubscriptionsResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + "triggers/subscriptions/query",method="POST", + json={ + "subscription": convert_and_respect_annotation_metadata(object_=subscription, annotation=typing.Optional[TriggerSubscriptionQuery], direction="write"), + "windowing": convert_and_respect_annotation_metadata(object_=windowing, annotation=typing.Optional[Windowing], direction="write"), + } + , + headers={"content-type": "application/json", } + , + request_options=request_options,omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerSubscriptionsResponse, + parse_obj_as( + type_ =TriggerSubscriptionsResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def refresh_trigger_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerSubscriptionResponse]: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerSubscriptionResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + f"triggers/subscriptions/{jsonable_encoder(subscription_id)}/refresh",method="POST", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerSubscriptionResponse, + parse_obj_as( + type_ =TriggerSubscriptionResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def revoke_trigger_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerSubscriptionResponse]: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerSubscriptionResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + f"triggers/subscriptions/{jsonable_encoder(subscription_id)}/revoke",method="POST", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerSubscriptionResponse, + parse_obj_as( + type_ =TriggerSubscriptionResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def start_trigger_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerSubscriptionResponse]: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerSubscriptionResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + f"triggers/subscriptions/{jsonable_encoder(subscription_id)}/start",method="POST", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerSubscriptionResponse, + parse_obj_as( + type_ =TriggerSubscriptionResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def stop_trigger_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerSubscriptionResponse]: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerSubscriptionResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + f"triggers/subscriptions/{jsonable_encoder(subscription_id)}/stop",method="POST", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerSubscriptionResponse, + parse_obj_as( + type_ =TriggerSubscriptionResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def fetch_trigger_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerSubscriptionResponse]: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerSubscriptionResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + f"triggers/subscriptions/{jsonable_encoder(subscription_id)}",method="GET", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerSubscriptionResponse, + parse_obj_as( + type_ =TriggerSubscriptionResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def edit_trigger_subscription(self, subscription_id: str, *, subscription: TriggerSubscriptionEdit, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerSubscriptionResponse]: + """ + Parameters + ---------- + subscription_id : str + + subscription : TriggerSubscriptionEdit + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerSubscriptionResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + f"triggers/subscriptions/{jsonable_encoder(subscription_id)}",method="PUT", + json={ + "subscription": convert_and_respect_annotation_metadata(object_=subscription, annotation=TriggerSubscriptionEdit, direction="write"), + } + , + headers={"content-type": "application/json", } + , + request_options=request_options,omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerSubscriptionResponse, + parse_obj_as( + type_ =TriggerSubscriptionResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def delete_trigger_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[None]: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[None] + """ + _response = await self._client_wrapper.httpx_client.request( + f"triggers/subscriptions/{jsonable_encoder(subscription_id)}",method="DELETE", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + return AsyncHttpResponse(response=_response, data=None) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def list_trigger_schedules(self, *, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerSchedulesResponse]: + """ + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerSchedulesResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + "triggers/schedules",method="GET", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerSchedulesResponse, + parse_obj_as( + type_ =TriggerSchedulesResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def create_trigger_schedule(self, *, schedule: TriggerScheduleCreate, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerScheduleResponse]: + """ + Parameters + ---------- + schedule : TriggerScheduleCreate + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerScheduleResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + "triggers/schedules",method="POST", + json={ + "schedule": convert_and_respect_annotation_metadata(object_=schedule, annotation=TriggerScheduleCreate, direction="write"), + } + , + headers={"content-type": "application/json", } + , + request_options=request_options,omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerScheduleResponse, + parse_obj_as( + type_ =TriggerScheduleResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def query_trigger_schedules(self, *, schedule: typing.Optional[TriggerScheduleQuery] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerSchedulesResponse]: + """ + Parameters + ---------- + schedule : typing.Optional[TriggerScheduleQuery] + + windowing : typing.Optional[Windowing] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerSchedulesResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + "triggers/schedules/query",method="POST", + json={ + "schedule": convert_and_respect_annotation_metadata(object_=schedule, annotation=typing.Optional[TriggerScheduleQuery], direction="write"), + "windowing": convert_and_respect_annotation_metadata(object_=windowing, annotation=typing.Optional[Windowing], direction="write"), + } + , + headers={"content-type": "application/json", } + , + request_options=request_options,omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerSchedulesResponse, + parse_obj_as( + type_ =TriggerSchedulesResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def fetch_trigger_schedule(self, schedule_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerScheduleResponse]: + """ + Parameters + ---------- + schedule_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerScheduleResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + f"triggers/schedules/{jsonable_encoder(schedule_id)}",method="GET", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerScheduleResponse, + parse_obj_as( + type_ =TriggerScheduleResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def edit_trigger_schedule(self, schedule_id: str, *, schedule: TriggerScheduleEdit, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerScheduleResponse]: + """ + Parameters + ---------- + schedule_id : str + + schedule : TriggerScheduleEdit + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerScheduleResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + f"triggers/schedules/{jsonable_encoder(schedule_id)}",method="PUT", + json={ + "schedule": convert_and_respect_annotation_metadata(object_=schedule, annotation=TriggerScheduleEdit, direction="write"), + } + , + headers={"content-type": "application/json", } + , + request_options=request_options,omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerScheduleResponse, + parse_obj_as( + type_ =TriggerScheduleResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def delete_trigger_schedule(self, schedule_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[None]: + """ + Parameters + ---------- + schedule_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[None] + """ + _response = await self._client_wrapper.httpx_client.request( + f"triggers/schedules/{jsonable_encoder(schedule_id)}",method="DELETE", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + return AsyncHttpResponse(response=_response, data=None) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def start_trigger_schedule(self, schedule_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerScheduleResponse]: + """ + Parameters + ---------- + schedule_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerScheduleResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + f"triggers/schedules/{jsonable_encoder(schedule_id)}/start",method="POST", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerScheduleResponse, + parse_obj_as( + type_ =TriggerScheduleResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def stop_trigger_schedule(self, schedule_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerScheduleResponse]: + """ + Parameters + ---------- + schedule_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerScheduleResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + f"triggers/schedules/{jsonable_encoder(schedule_id)}/stop",method="POST", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerScheduleResponse, + parse_obj_as( + type_ =TriggerScheduleResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def list_trigger_deliveries(self, *, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerDeliveriesResponse]: + """ + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerDeliveriesResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + "triggers/deliveries",method="GET", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerDeliveriesResponse, + parse_obj_as( + type_ =TriggerDeliveriesResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def query_trigger_deliveries(self, *, delivery: typing.Optional[TriggerDeliveryQuery] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerDeliveriesResponse]: + """ + Parameters + ---------- + delivery : typing.Optional[TriggerDeliveryQuery] + + windowing : typing.Optional[Windowing] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerDeliveriesResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + "triggers/deliveries/query",method="POST", + json={ + "delivery": convert_and_respect_annotation_metadata(object_=delivery, annotation=typing.Optional[TriggerDeliveryQuery], direction="write"), + "windowing": convert_and_respect_annotation_metadata(object_=windowing, annotation=typing.Optional[Windowing], direction="write"), + } + , + headers={"content-type": "application/json", } + , + request_options=request_options,omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerDeliveriesResponse, + parse_obj_as( + type_ =TriggerDeliveriesResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def fetch_trigger_delivery(self, delivery_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[TriggerDeliveryResponse]: + """ + Parameters + ---------- + delivery_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TriggerDeliveryResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + f"triggers/deliveries/{jsonable_encoder(delivery_id)}",method="GET", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TriggerDeliveryResponse, + parse_obj_as( + type_ =TriggerDeliveryResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/clients/python/agenta_client/types/__init__.py b/clients/python/agenta_client/types/__init__.py index 4d6a7e93af..7f56817fe0 100644 --- a/clients/python/agenta_client/types/__init__.py +++ b/clients/python/agenta_client/types/__init__.py @@ -370,6 +370,7 @@ from .secret_kind import SecretKind from .secret_response_dto import SecretResponseDto from .secret_response_dto_data import SecretResponseDtoData + from .selector import Selector from .session_ids_response import SessionIdsResponse from .simple_application import SimpleApplication from .simple_application_create import SimpleApplicationCreate @@ -581,6 +582,46 @@ from .traces_request import TracesRequest from .traces_response import TracesResponse from .tracing_query import TracingQuery + from .trigger_auth_scheme import TriggerAuthScheme + from .trigger_catalog_event import TriggerCatalogEvent + from .trigger_catalog_event_details import TriggerCatalogEventDetails + from .trigger_catalog_event_response import TriggerCatalogEventResponse + from .trigger_catalog_events_response import TriggerCatalogEventsResponse + from .trigger_catalog_integration import TriggerCatalogIntegration + from .trigger_catalog_integration_response import TriggerCatalogIntegrationResponse + from .trigger_catalog_integrations_response import TriggerCatalogIntegrationsResponse + from .trigger_catalog_provider import TriggerCatalogProvider + from .trigger_catalog_provider_response import TriggerCatalogProviderResponse + from .trigger_catalog_providers_response import TriggerCatalogProvidersResponse + from .trigger_connection import TriggerConnection + from .trigger_connection_create import TriggerConnectionCreate + from .trigger_connection_create_data import TriggerConnectionCreateData + from .trigger_connection_response import TriggerConnectionResponse + from .trigger_connection_status import TriggerConnectionStatus + from .trigger_connections_response import TriggerConnectionsResponse + from .trigger_deliveries_response import TriggerDeliveriesResponse + from .trigger_delivery import TriggerDelivery + from .trigger_delivery_data import TriggerDeliveryData + from .trigger_delivery_query import TriggerDeliveryQuery + from .trigger_delivery_response import TriggerDeliveryResponse + from .trigger_event_ack import TriggerEventAck + from .trigger_provider_kind import TriggerProviderKind + from .trigger_schedule import TriggerSchedule + from .trigger_schedule_create import TriggerScheduleCreate + from .trigger_schedule_data import TriggerScheduleData + from .trigger_schedule_edit import TriggerScheduleEdit + from .trigger_schedule_flags import TriggerScheduleFlags + from .trigger_schedule_query import TriggerScheduleQuery + from .trigger_schedule_response import TriggerScheduleResponse + from .trigger_schedules_response import TriggerSchedulesResponse + from .trigger_subscription import TriggerSubscription + from .trigger_subscription_create import TriggerSubscriptionCreate + from .trigger_subscription_data import TriggerSubscriptionData + from .trigger_subscription_edit import TriggerSubscriptionEdit + from .trigger_subscription_flags import TriggerSubscriptionFlags + from .trigger_subscription_query import TriggerSubscriptionQuery + from .trigger_subscription_response import TriggerSubscriptionResponse + from .trigger_subscriptions_response import TriggerSubscriptionsResponse from .user_ids_response import UserIdsResponse from .validation_error import ValidationError from .validation_error_loc_item import ValidationErrorLocItem @@ -599,6 +640,7 @@ from .webhook_subscription_data import WebhookSubscriptionData from .webhook_subscription_data_auth_mode import WebhookSubscriptionDataAuthMode from .webhook_subscription_edit import WebhookSubscriptionEdit + from .webhook_subscription_flags import WebhookSubscriptionFlags from .webhook_subscription_query import WebhookSubscriptionQuery from .webhook_subscription_response import WebhookSubscriptionResponse from .webhook_subscriptions_response import WebhookSubscriptionsResponse @@ -648,7 +690,7 @@ from .workspace_member_response import WorkspaceMemberResponse from .workspace_permission import WorkspacePermission from .workspace_response import WorkspaceResponse -_dynamic_imports: typing.Dict[str, str] = {"AdminAccountCreateOptions": ".admin_account_create_options", "AdminAccountRead": ".admin_account_read", "AdminAccountsCreate": ".admin_accounts_create", "AdminAccountsDelete": ".admin_accounts_delete", "AdminAccountsDeleteTarget": ".admin_accounts_delete_target", "AdminAccountsResponse": ".admin_accounts_response", "AdminApiKeyCreate": ".admin_api_key_create", "AdminApiKeyResponse": ".admin_api_key_response", "AdminDeleteResponse": ".admin_delete_response", "AdminDeletedEntities": ".admin_deleted_entities", "AdminDeletedEntity": ".admin_deleted_entity", "AdminOrganizationCreate": ".admin_organization_create", "AdminOrganizationMembershipCreate": ".admin_organization_membership_create", "AdminOrganizationMembershipRead": ".admin_organization_membership_read", "AdminOrganizationRead": ".admin_organization_read", "AdminProjectCreate": ".admin_project_create", "AdminProjectMembershipCreate": ".admin_project_membership_create", "AdminProjectMembershipRead": ".admin_project_membership_read", "AdminProjectRead": ".admin_project_read", "AdminSimpleAccountCreate": ".admin_simple_account_create", "AdminSimpleAccountDeleteEntry": ".admin_simple_account_delete_entry", "AdminSimpleAccountRead": ".admin_simple_account_read", "AdminSimpleAccountsApiKeysCreate": ".admin_simple_accounts_api_keys_create", "AdminSimpleAccountsCreate": ".admin_simple_accounts_create", "AdminSimpleAccountsDelete": ".admin_simple_accounts_delete", "AdminSimpleAccountsOrganizationsCreate": ".admin_simple_accounts_organizations_create", "AdminSimpleAccountsOrganizationsMembershipsCreate": ".admin_simple_accounts_organizations_memberships_create", "AdminSimpleAccountsOrganizationsTransferOwnership": ".admin_simple_accounts_organizations_transfer_ownership", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjects": ".admin_simple_accounts_organizations_transfer_ownership_include_projects", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjectsZero": ".admin_simple_accounts_organizations_transfer_ownership_include_projects_zero", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspaces": ".admin_simple_accounts_organizations_transfer_ownership_include_workspaces", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspacesZero": ".admin_simple_accounts_organizations_transfer_ownership_include_workspaces_zero", "AdminSimpleAccountsOrganizationsTransferOwnershipResponse": ".admin_simple_accounts_organizations_transfer_ownership_response", "AdminSimpleAccountsProjectsCreate": ".admin_simple_accounts_projects_create", "AdminSimpleAccountsProjectsMembershipsCreate": ".admin_simple_accounts_projects_memberships_create", "AdminSimpleAccountsResponse": ".admin_simple_accounts_response", "AdminSimpleAccountsUsersCreate": ".admin_simple_accounts_users_create", "AdminSimpleAccountsUsersIdentitiesCreate": ".admin_simple_accounts_users_identities_create", "AdminSimpleAccountsUsersResetPassword": ".admin_simple_accounts_users_reset_password", "AdminSimpleAccountsWorkspacesCreate": ".admin_simple_accounts_workspaces_create", "AdminSimpleAccountsWorkspacesMembershipsCreate": ".admin_simple_accounts_workspaces_memberships_create", "AdminStructuredError": ".admin_structured_error", "AdminSubscriptionCreate": ".admin_subscription_create", "AdminSubscriptionRead": ".admin_subscription_read", "AdminUserCreate": ".admin_user_create", "AdminUserIdentityCreate": ".admin_user_identity_create", "AdminUserIdentityRead": ".admin_user_identity_read", "AdminUserIdentityReadStatus": ".admin_user_identity_read_status", "AdminUserRead": ".admin_user_read", "AdminWorkspaceCreate": ".admin_workspace_create", "AdminWorkspaceMembershipCreate": ".admin_workspace_membership_create", "AdminWorkspaceMembershipRead": ".admin_workspace_membership_read", "AdminWorkspaceRead": ".admin_workspace_read", "Analytics": ".analytics", "AnalyticsResponse": ".analytics_response", "Annotation": ".annotation", "AnnotationCreate": ".annotation_create", "AnnotationCreateLinks": ".annotation_create_links", "AnnotationEdit": ".annotation_edit", "AnnotationEditLinks": ".annotation_edit_links", "AnnotationLinkResponse": ".annotation_link_response", "AnnotationLinks": ".annotation_links", "AnnotationQuery": ".annotation_query", "AnnotationQueryLinks": ".annotation_query_links", "AnnotationResponse": ".annotation_response", "AnnotationsResponse": ".annotations_response", "Application": ".application", "ApplicationArtifactFlags": ".application_artifact_flags", "ApplicationArtifactQueryFlags": ".application_artifact_query_flags", "ApplicationCatalogPreset": ".application_catalog_preset", "ApplicationCatalogPresetResponse": ".application_catalog_preset_response", "ApplicationCatalogPresetsResponse": ".application_catalog_presets_response", "ApplicationCatalogTemplate": ".application_catalog_template", "ApplicationCatalogTemplateResponse": ".application_catalog_template_response", "ApplicationCatalogTemplatesResponse": ".application_catalog_templates_response", "ApplicationCatalogType": ".application_catalog_type", "ApplicationCatalogTypesResponse": ".application_catalog_types_response", "ApplicationCreate": ".application_create", "ApplicationEdit": ".application_edit", "ApplicationFlags": ".application_flags", "ApplicationQuery": ".application_query", "ApplicationResponse": ".application_response", "ApplicationRevisionCommit": ".application_revision_commit", "ApplicationRevisionCreate": ".application_revision_create", "ApplicationRevisionDataInput": ".application_revision_data_input", "ApplicationRevisionDataInputHeadersValue": ".application_revision_data_input_headers_value", "ApplicationRevisionDataInputRuntime": ".application_revision_data_input_runtime", "ApplicationRevisionDataOutput": ".application_revision_data_output", "ApplicationRevisionDataOutputHeadersValue": ".application_revision_data_output_headers_value", "ApplicationRevisionDataOutputRuntime": ".application_revision_data_output_runtime", "ApplicationRevisionEdit": ".application_revision_edit", "ApplicationRevisionFlags": ".application_revision_flags", "ApplicationRevisionInput": ".application_revision_input", "ApplicationRevisionOutput": ".application_revision_output", "ApplicationRevisionQuery": ".application_revision_query", "ApplicationRevisionQueryFlags": ".application_revision_query_flags", "ApplicationRevisionResolveResponse": ".application_revision_resolve_response", "ApplicationRevisionResponse": ".application_revision_response", "ApplicationRevisionsLog": ".application_revisions_log", "ApplicationRevisionsResponse": ".application_revisions_response", "ApplicationVariant": ".application_variant", "ApplicationVariantCreate": ".application_variant_create", "ApplicationVariantEdit": ".application_variant_edit", "ApplicationVariantFlags": ".application_variant_flags", "ApplicationVariantFork": ".application_variant_fork", "ApplicationVariantResponse": ".application_variant_response", "ApplicationVariantsResponse": ".application_variants_response", "ApplicationsResponse": ".applications_response", "BodyConfigsFetchVariantsConfigsFetchPost": ".body_configs_fetch_variants_configs_fetch_post", "Bucket": ".bucket", "CollectStatusResponse": ".collect_status_response", "ComparisonOperator": ".comparison_operator", "Condition": ".condition", "ConditionOperator": ".condition_operator", "ConditionOptions": ".condition_options", "ConditionValue": ".condition_value", "ConfigResponseModel": ".config_response_model", "CustomModelSettingsDto": ".custom_model_settings_dto", "CustomProviderDto": ".custom_provider_dto", "CustomProviderKind": ".custom_provider_kind", "CustomProviderSettingsDto": ".custom_provider_settings_dto", "DictOperator": ".dict_operator", "DiscoverResponse": ".discover_response", "DiscoverResponseMethodsValue": ".discover_response_methods_value", "EeSrcModelsApiOrganizationModelsOrganization": ".ee_src_models_api_organization_models_organization", "EntityRef": ".entity_ref", "Environment": ".environment", "EnvironmentCreate": ".environment_create", "EnvironmentEdit": ".environment_edit", "EnvironmentFlags": ".environment_flags", "EnvironmentQueryFlags": ".environment_query_flags", "EnvironmentResponse": ".environment_response", "EnvironmentRevisionCommit": ".environment_revision_commit", "EnvironmentRevisionCreate": ".environment_revision_create", "EnvironmentRevisionData": ".environment_revision_data", "EnvironmentRevisionDelta": ".environment_revision_delta", "EnvironmentRevisionEdit": ".environment_revision_edit", "EnvironmentRevisionInput": ".environment_revision_input", "EnvironmentRevisionOutput": ".environment_revision_output", "EnvironmentRevisionResolveResponse": ".environment_revision_resolve_response", "EnvironmentRevisionResponse": ".environment_revision_response", "EnvironmentRevisionsLog": ".environment_revisions_log", "EnvironmentRevisionsResponse": ".environment_revisions_response", "EnvironmentVariant": ".environment_variant", "EnvironmentVariantCreate": ".environment_variant_create", "EnvironmentVariantEdit": ".environment_variant_edit", "EnvironmentVariantFork": ".environment_variant_fork", "EnvironmentVariantResponse": ".environment_variant_response", "EnvironmentVariantsResponse": ".environment_variants_response", "EnvironmentsResponse": ".environments_response", "ErrorPolicy": ".error_policy", "EvaluationMetrics": ".evaluation_metrics", "EvaluationMetricsCreate": ".evaluation_metrics_create", "EvaluationMetricsIdsResponse": ".evaluation_metrics_ids_response", "EvaluationMetricsQuery": ".evaluation_metrics_query", "EvaluationMetricsQueryScenarioIds": ".evaluation_metrics_query_scenario_ids", "EvaluationMetricsQueryTimestamps": ".evaluation_metrics_query_timestamps", "EvaluationMetricsRefresh": ".evaluation_metrics_refresh", "EvaluationMetricsResponse": ".evaluation_metrics_response", "EvaluationMetricsSetRequest": ".evaluation_metrics_set_request", "EvaluationQueue": ".evaluation_queue", "EvaluationQueueCreate": ".evaluation_queue_create", "EvaluationQueueData": ".evaluation_queue_data", "EvaluationQueueEdit": ".evaluation_queue_edit", "EvaluationQueueFlags": ".evaluation_queue_flags", "EvaluationQueueIdResponse": ".evaluation_queue_id_response", "EvaluationQueueIdsResponse": ".evaluation_queue_ids_response", "EvaluationQueueQuery": ".evaluation_queue_query", "EvaluationQueueQueryFlags": ".evaluation_queue_query_flags", "EvaluationQueueResponse": ".evaluation_queue_response", "EvaluationQueueScenariosQuery": ".evaluation_queue_scenarios_query", "EvaluationQueuesResponse": ".evaluation_queues_response", "EvaluationResult": ".evaluation_result", "EvaluationResultCreate": ".evaluation_result_create", "EvaluationResultIdResponse": ".evaluation_result_id_response", "EvaluationResultIdsResponse": ".evaluation_result_ids_response", "EvaluationResultQuery": ".evaluation_result_query", "EvaluationResultResponse": ".evaluation_result_response", "EvaluationResultsResponse": ".evaluation_results_response", "EvaluationResultsSetRequest": ".evaluation_results_set_request", "EvaluationRun": ".evaluation_run", "EvaluationRunCreate": ".evaluation_run_create", "EvaluationRunDataConcurrency": ".evaluation_run_data_concurrency", "EvaluationRunDataInput": ".evaluation_run_data_input", "EvaluationRunDataMapping": ".evaluation_run_data_mapping", "EvaluationRunDataMappingColumn": ".evaluation_run_data_mapping_column", "EvaluationRunDataMappingStep": ".evaluation_run_data_mapping_step", "EvaluationRunDataOutput": ".evaluation_run_data_output", "EvaluationRunDataStepInput": ".evaluation_run_data_step_input", "EvaluationRunDataStepInputKey": ".evaluation_run_data_step_input_key", "EvaluationRunDataStepInputOrigin": ".evaluation_run_data_step_input_origin", "EvaluationRunDataStepInputType": ".evaluation_run_data_step_input_type", "EvaluationRunDataStepOutput": ".evaluation_run_data_step_output", "EvaluationRunDataStepOutputOrigin": ".evaluation_run_data_step_output_origin", "EvaluationRunDataStepOutputType": ".evaluation_run_data_step_output_type", "EvaluationRunEdit": ".evaluation_run_edit", "EvaluationRunFlags": ".evaluation_run_flags", "EvaluationRunIdResponse": ".evaluation_run_id_response", "EvaluationRunIdsRequest": ".evaluation_run_ids_request", "EvaluationRunIdsResponse": ".evaluation_run_ids_response", "EvaluationRunQuery": ".evaluation_run_query", "EvaluationRunQueryFlags": ".evaluation_run_query_flags", "EvaluationRunResponse": ".evaluation_run_response", "EvaluationRunsResponse": ".evaluation_runs_response", "EvaluationScenario": ".evaluation_scenario", "EvaluationScenarioCreate": ".evaluation_scenario_create", "EvaluationScenarioEdit": ".evaluation_scenario_edit", "EvaluationScenarioIdResponse": ".evaluation_scenario_id_response", "EvaluationScenarioIdsResponse": ".evaluation_scenario_ids_response", "EvaluationScenarioQuery": ".evaluation_scenario_query", "EvaluationScenarioResponse": ".evaluation_scenario_response", "EvaluationScenariosResponse": ".evaluation_scenarios_response", "EvaluationStatus": ".evaluation_status", "Evaluator": ".evaluator", "EvaluatorArtifactFlags": ".evaluator_artifact_flags", "EvaluatorArtifactQueryFlags": ".evaluator_artifact_query_flags", "EvaluatorCatalogPreset": ".evaluator_catalog_preset", "EvaluatorCatalogPresetResponse": ".evaluator_catalog_preset_response", "EvaluatorCatalogPresetsResponse": ".evaluator_catalog_presets_response", "EvaluatorCatalogTemplate": ".evaluator_catalog_template", "EvaluatorCatalogTemplateResponse": ".evaluator_catalog_template_response", "EvaluatorCatalogTemplatesResponse": ".evaluator_catalog_templates_response", "EvaluatorCatalogType": ".evaluator_catalog_type", "EvaluatorCatalogTypesResponse": ".evaluator_catalog_types_response", "EvaluatorCreate": ".evaluator_create", "EvaluatorEdit": ".evaluator_edit", "EvaluatorFlags": ".evaluator_flags", "EvaluatorQuery": ".evaluator_query", "EvaluatorResponse": ".evaluator_response", "EvaluatorRevisionCommit": ".evaluator_revision_commit", "EvaluatorRevisionCreate": ".evaluator_revision_create", "EvaluatorRevisionDataInput": ".evaluator_revision_data_input", "EvaluatorRevisionDataInputHeadersValue": ".evaluator_revision_data_input_headers_value", "EvaluatorRevisionDataInputRuntime": ".evaluator_revision_data_input_runtime", "EvaluatorRevisionDataOutput": ".evaluator_revision_data_output", "EvaluatorRevisionDataOutputHeadersValue": ".evaluator_revision_data_output_headers_value", "EvaluatorRevisionDataOutputRuntime": ".evaluator_revision_data_output_runtime", "EvaluatorRevisionEdit": ".evaluator_revision_edit", "EvaluatorRevisionFlags": ".evaluator_revision_flags", "EvaluatorRevisionInput": ".evaluator_revision_input", "EvaluatorRevisionOutput": ".evaluator_revision_output", "EvaluatorRevisionQuery": ".evaluator_revision_query", "EvaluatorRevisionQueryFlags": ".evaluator_revision_query_flags", "EvaluatorRevisionResolveResponse": ".evaluator_revision_resolve_response", "EvaluatorRevisionResponse": ".evaluator_revision_response", "EvaluatorRevisionsLog": ".evaluator_revisions_log", "EvaluatorRevisionsResponse": ".evaluator_revisions_response", "EvaluatorTemplate": ".evaluator_template", "EvaluatorTemplatesResponse": ".evaluator_templates_response", "EvaluatorVariant": ".evaluator_variant", "EvaluatorVariantCreate": ".evaluator_variant_create", "EvaluatorVariantEdit": ".evaluator_variant_edit", "EvaluatorVariantFlags": ".evaluator_variant_flags", "EvaluatorVariantFork": ".evaluator_variant_fork", "EvaluatorVariantResponse": ".evaluator_variant_response", "EvaluatorVariantsResponse": ".evaluator_variants_response", "EvaluatorsResponse": ".evaluators_response", "Event": ".event", "EventQuery": ".event_query", "EventType": ".event_type", "EventsQueryResponse": ".events_query_response", "ExistenceOperator": ".existence_operator", "FilteringInput": ".filtering_input", "FilteringInputConditionsItem": ".filtering_input_conditions_item", "FilteringOutput": ".filtering_output", "FilteringOutputConditionsItem": ".filtering_output_conditions_item", "Focus": ".focus", "Folder": ".folder", "FolderCreate": ".folder_create", "FolderEdit": ".folder_edit", "FolderIdResponse": ".folder_id_response", "FolderKind": ".folder_kind", "FolderQuery": ".folder_query", "FolderQueryKinds": ".folder_query_kinds", "FolderResponse": ".folder_response", "FoldersResponse": ".folders_response", "Format": ".format", "Formatting": ".formatting", typing.Any: ".full_json_input", typing.Any: ".full_json_output", "Header": ".header", "HttpValidationError": ".http_validation_error", "InviteRequest": ".invite_request", "Invocation": ".invocation", "InvocationCreate": ".invocation_create", "InvocationCreateLinks": ".invocation_create_links", "InvocationEdit": ".invocation_edit", "InvocationEditLinks": ".invocation_edit_links", "InvocationLinkResponse": ".invocation_link_response", "InvocationLinks": ".invocation_links", "InvocationQuery": ".invocation_query", "InvocationQueryLinks": ".invocation_query_links", "InvocationResponse": ".invocation_response", "InvocationsResponse": ".invocations_response", "JsonSchemasInput": ".json_schemas_input", "JsonSchemasOutput": ".json_schemas_output", typing.Any: ".label_json_input", typing.Any: ".label_json_output", "LegacyLifecycleDto": ".legacy_lifecycle_dto", "ListApiKeysResponse": ".list_api_keys_response", "ListOperator": ".list_operator", "ListOptions": ".list_options", "LogicalOperator": ".logical_operator", "MetricSpec": ".metric_spec", "MetricType": ".metric_type", "MetricsBucket": ".metrics_bucket", "NumericOperator": ".numeric_operator", "OTelEventInput": ".o_tel_event_input", "OTelEventInputTimestamp": ".o_tel_event_input_timestamp", "OTelEventOutput": ".o_tel_event_output", "OTelEventOutputTimestamp": ".o_tel_event_output_timestamp", "OTelHashInput": ".o_tel_hash_input", "OTelHashOutput": ".o_tel_hash_output", "OTelLinkInput": ".o_tel_link_input", "OTelLinkOutput": ".o_tel_link_output", "OTelLinksResponse": ".o_tel_links_response", "OTelReferenceInput": ".o_tel_reference_input", "OTelReferenceOutput": ".o_tel_reference_output", "OTelSpanKind": ".o_tel_span_kind", "OTelStatusCode": ".o_tel_status_code", "OTelTracingRequest": ".o_tel_tracing_request", "OTelTracingResponse": ".o_tel_tracing_response", "OldAnalyticsResponse": ".old_analytics_response", "OrganizationDetails": ".organization_details", "OrganizationDomainResponse": ".organization_domain_response", "OrganizationProviderResponse": ".organization_provider_response", "OrganizationUpdate": ".organization_update", "OssSrcModelsApiOrganizationModelsOrganization": ".oss_src_models_api_organization_models_organization", "Permission": ".permission", "ProjectsResponse": ".projects_response", "QueriesResponse": ".queries_response", "Query": ".query", "QueryCreate": ".query_create", "QueryEdit": ".query_edit", "QueryFlags": ".query_flags", "QueryQueryFlags": ".query_query_flags", "QueryResponse": ".query_response", "QueryRevision": ".query_revision", "QueryRevisionCommit": ".query_revision_commit", "QueryRevisionCreate": ".query_revision_create", "QueryRevisionDataInput": ".query_revision_data_input", "QueryRevisionDataOutput": ".query_revision_data_output", "QueryRevisionEdit": ".query_revision_edit", "QueryRevisionQuery": ".query_revision_query", "QueryRevisionResponse": ".query_revision_response", "QueryRevisionsLog": ".query_revisions_log", "QueryRevisionsResponse": ".query_revisions_response", "QueryVariant": ".query_variant", "QueryVariantCreate": ".query_variant_create", "QueryVariantEdit": ".query_variant_edit", "QueryVariantFork": ".query_variant_fork", "QueryVariantQuery": ".query_variant_query", "QueryVariantResponse": ".query_variant_response", "QueryVariantsResponse": ".query_variants_response", "Reference": ".reference", "ReferenceRequestModelInput": ".reference_request_model_input", "ReferenceRequestModelOutput": ".reference_request_model_output", "RequestType": ".request_type", "ResolutionInfo": ".resolution_info", "RetrievalInfo": ".retrieval_info", "SecretDto": ".secret_dto", "SecretDtoData": ".secret_dto_data", "SecretKind": ".secret_kind", "SecretResponseDto": ".secret_response_dto", "SecretResponseDtoData": ".secret_response_dto_data", "SessionIdsResponse": ".session_ids_response", "SimpleApplication": ".simple_application", "SimpleApplicationCreate": ".simple_application_create", "SimpleApplicationDataInput": ".simple_application_data_input", "SimpleApplicationDataInputHeadersValue": ".simple_application_data_input_headers_value", "SimpleApplicationDataInputRuntime": ".simple_application_data_input_runtime", "SimpleApplicationDataOutput": ".simple_application_data_output", "SimpleApplicationDataOutputHeadersValue": ".simple_application_data_output_headers_value", "SimpleApplicationDataOutputRuntime": ".simple_application_data_output_runtime", "SimpleApplicationEdit": ".simple_application_edit", "SimpleApplicationFlags": ".simple_application_flags", "SimpleApplicationQuery": ".simple_application_query", "SimpleApplicationQueryFlags": ".simple_application_query_flags", "SimpleApplicationResponse": ".simple_application_response", "SimpleApplicationsResponse": ".simple_applications_response", "SimpleEnvironment": ".simple_environment", "SimpleEnvironmentCreate": ".simple_environment_create", "SimpleEnvironmentEdit": ".simple_environment_edit", "SimpleEnvironmentQuery": ".simple_environment_query", "SimpleEnvironmentResponse": ".simple_environment_response", "SimpleEnvironmentsResponse": ".simple_environments_response", "SimpleEvaluation": ".simple_evaluation", "SimpleEvaluationCreate": ".simple_evaluation_create", "SimpleEvaluationData": ".simple_evaluation_data", "SimpleEvaluationDataApplicationSteps": ".simple_evaluation_data_application_steps", "SimpleEvaluationDataApplicationStepsOneValue": ".simple_evaluation_data_application_steps_one_value", "SimpleEvaluationDataEvaluatorSteps": ".simple_evaluation_data_evaluator_steps", "SimpleEvaluationDataEvaluatorStepsOneValue": ".simple_evaluation_data_evaluator_steps_one_value", "SimpleEvaluationDataQuerySteps": ".simple_evaluation_data_query_steps", "SimpleEvaluationDataQueryStepsOneValue": ".simple_evaluation_data_query_steps_one_value", "SimpleEvaluationDataTestsetSteps": ".simple_evaluation_data_testset_steps", "SimpleEvaluationDataTestsetStepsOneValue": ".simple_evaluation_data_testset_steps_one_value", "SimpleEvaluationEdit": ".simple_evaluation_edit", "SimpleEvaluationIdResponse": ".simple_evaluation_id_response", "SimpleEvaluationQuery": ".simple_evaluation_query", "SimpleEvaluationResponse": ".simple_evaluation_response", "SimpleEvaluationsResponse": ".simple_evaluations_response", "SimpleEvaluator": ".simple_evaluator", "SimpleEvaluatorCreate": ".simple_evaluator_create", "SimpleEvaluatorDataInput": ".simple_evaluator_data_input", "SimpleEvaluatorDataInputHeadersValue": ".simple_evaluator_data_input_headers_value", "SimpleEvaluatorDataInputRuntime": ".simple_evaluator_data_input_runtime", "SimpleEvaluatorDataOutput": ".simple_evaluator_data_output", "SimpleEvaluatorDataOutputHeadersValue": ".simple_evaluator_data_output_headers_value", "SimpleEvaluatorDataOutputRuntime": ".simple_evaluator_data_output_runtime", "SimpleEvaluatorEdit": ".simple_evaluator_edit", "SimpleEvaluatorFlags": ".simple_evaluator_flags", "SimpleEvaluatorQuery": ".simple_evaluator_query", "SimpleEvaluatorQueryFlags": ".simple_evaluator_query_flags", "SimpleEvaluatorResponse": ".simple_evaluator_response", "SimpleEvaluatorsResponse": ".simple_evaluators_response", "SimpleQueriesResponse": ".simple_queries_response", "SimpleQuery": ".simple_query", "SimpleQueryCreate": ".simple_query_create", "SimpleQueryEdit": ".simple_query_edit", "SimpleQueryQuery": ".simple_query_query", "SimpleQueryResponse": ".simple_query_response", "SimpleQueue": ".simple_queue", "SimpleQueueCreate": ".simple_queue_create", "SimpleQueueData": ".simple_queue_data", "SimpleQueueDataEvaluators": ".simple_queue_data_evaluators", "SimpleQueueDataEvaluatorsOneValue": ".simple_queue_data_evaluators_one_value", "SimpleQueueIdResponse": ".simple_queue_id_response", "SimpleQueueIdsResponse": ".simple_queue_ids_response", "SimpleQueueKind": ".simple_queue_kind", "SimpleQueueQuery": ".simple_queue_query", "SimpleQueueResponse": ".simple_queue_response", "SimpleQueueScenariosQuery": ".simple_queue_scenarios_query", "SimpleQueueScenariosResponse": ".simple_queue_scenarios_response", "SimpleQueueSettings": ".simple_queue_settings", "SimpleQueuesResponse": ".simple_queues_response", "SimpleTestset": ".simple_testset", "SimpleTestsetCreate": ".simple_testset_create", "SimpleTestsetEdit": ".simple_testset_edit", "SimpleTestsetQuery": ".simple_testset_query", "SimpleTestsetResponse": ".simple_testset_response", "SimpleTestsetsResponse": ".simple_testsets_response", "SimpleTrace": ".simple_trace", "SimpleTraceChannel": ".simple_trace_channel", "SimpleTraceCreate": ".simple_trace_create", "SimpleTraceCreateLinks": ".simple_trace_create_links", "SimpleTraceEdit": ".simple_trace_edit", "SimpleTraceEditLinks": ".simple_trace_edit_links", "SimpleTraceKind": ".simple_trace_kind", "SimpleTraceLinkResponse": ".simple_trace_link_response", "SimpleTraceLinks": ".simple_trace_links", "SimpleTraceOrigin": ".simple_trace_origin", "SimpleTraceQuery": ".simple_trace_query", "SimpleTraceQueryLinks": ".simple_trace_query_links", "SimpleTraceReferences": ".simple_trace_references", "SimpleTraceResponse": ".simple_trace_response", "SimpleTracesResponse": ".simple_traces_response", "SimpleWorkflow": ".simple_workflow", "SimpleWorkflowCreate": ".simple_workflow_create", "SimpleWorkflowDataInput": ".simple_workflow_data_input", "SimpleWorkflowDataInputHeadersValue": ".simple_workflow_data_input_headers_value", "SimpleWorkflowDataInputRuntime": ".simple_workflow_data_input_runtime", "SimpleWorkflowDataOutput": ".simple_workflow_data_output", "SimpleWorkflowDataOutputHeadersValue": ".simple_workflow_data_output_headers_value", "SimpleWorkflowDataOutputRuntime": ".simple_workflow_data_output_runtime", "SimpleWorkflowEdit": ".simple_workflow_edit", "SimpleWorkflowFlags": ".simple_workflow_flags", "SimpleWorkflowQuery": ".simple_workflow_query", "SimpleWorkflowQueryFlags": ".simple_workflow_query_flags", "SimpleWorkflowResponse": ".simple_workflow_response", "SimpleWorkflowsResponse": ".simple_workflows_response", "SpanInput": ".span_input", "SpanInputEndTime": ".span_input_end_time", "SpanInputStartTime": ".span_input_start_time", "SpanOutput": ".span_output", "SpanOutputEndTime": ".span_output_end_time", "SpanOutputStartTime": ".span_output_start_time", "SpanResponse": ".span_response", "SpanType": ".span_type", "SpansNodeInput": ".spans_node_input", "SpansNodeInputEndTime": ".spans_node_input_end_time", "SpansNodeInputSpansValue": ".spans_node_input_spans_value", "SpansNodeInputStartTime": ".spans_node_input_start_time", "SpansNodeOutput": ".spans_node_output", "SpansNodeOutputEndTime": ".spans_node_output_end_time", "SpansNodeOutputSpansValue": ".spans_node_output_spans_value", "SpansNodeOutputStartTime": ".spans_node_output_start_time", "SpansResponse": ".spans_response", "SpansTreeInput": ".spans_tree_input", "SpansTreeInputSpansValue": ".spans_tree_input_spans_value", "SpansTreeOutput": ".spans_tree_output", "SpansTreeOutputSpansValue": ".spans_tree_output_spans_value", "SsoProviderDto": ".sso_provider_dto", "SsoProviderInfo": ".sso_provider_info", "SsoProviderSettingsDto": ".sso_provider_settings_dto", "SsoProviders": ".sso_providers", "StandardProviderDto": ".standard_provider_dto", "StandardProviderKind": ".standard_provider_kind", "StandardProviderSettingsDto": ".standard_provider_settings_dto", "Status": ".status", "StringOperator": ".string_operator", "TestcaseInput": ".testcase_input", "TestcaseOutput": ".testcase_output", "TestcaseResponse": ".testcase_response", "TestcasesResponse": ".testcases_response", "Testset": ".testset", "TestsetCreate": ".testset_create", "TestsetEdit": ".testset_edit", "TestsetFlags": ".testset_flags", "TestsetQuery": ".testset_query", "TestsetResponse": ".testset_response", "TestsetRevision": ".testset_revision", "TestsetRevisionCommit": ".testset_revision_commit", "TestsetRevisionCreate": ".testset_revision_create", "TestsetRevisionDataInput": ".testset_revision_data_input", "TestsetRevisionDataOutput": ".testset_revision_data_output", "TestsetRevisionDelta": ".testset_revision_delta", "TestsetRevisionDeltaColumns": ".testset_revision_delta_columns", "TestsetRevisionDeltaRows": ".testset_revision_delta_rows", "TestsetRevisionEdit": ".testset_revision_edit", "TestsetRevisionQuery": ".testset_revision_query", "TestsetRevisionResponse": ".testset_revision_response", "TestsetRevisionsLog": ".testset_revisions_log", "TestsetRevisionsResponse": ".testset_revisions_response", "TestsetVariant": ".testset_variant", "TestsetVariantCreate": ".testset_variant_create", "TestsetVariantEdit": ".testset_variant_edit", "TestsetVariantFork": ".testset_variant_fork", "TestsetVariantQuery": ".testset_variant_query", "TestsetVariantResponse": ".testset_variant_response", "TestsetVariantsResponse": ".testset_variants_response", "TestsetsResponse": ".testsets_response", "TextOptions": ".text_options", "ToolAuthScheme": ".tool_auth_scheme", "ToolCallData": ".tool_call_data", "ToolCallFunction": ".tool_call_function", "ToolCallResponse": ".tool_call_response", "ToolCatalogAction": ".tool_catalog_action", "ToolCatalogActionDetails": ".tool_catalog_action_details", "ToolCatalogActionResponse": ".tool_catalog_action_response", "ToolCatalogActionResponseAction": ".tool_catalog_action_response_action", "ToolCatalogActionsResponse": ".tool_catalog_actions_response", "ToolCatalogActionsResponseActionsItem": ".tool_catalog_actions_response_actions_item", "ToolCatalogIntegration": ".tool_catalog_integration", "ToolCatalogIntegrationDetails": ".tool_catalog_integration_details", "ToolCatalogIntegrationResponse": ".tool_catalog_integration_response", "ToolCatalogIntegrationResponseIntegration": ".tool_catalog_integration_response_integration", "ToolCatalogIntegrationsResponse": ".tool_catalog_integrations_response", "ToolCatalogIntegrationsResponseIntegrationsItem": ".tool_catalog_integrations_response_integrations_item", "ToolCatalogProvider": ".tool_catalog_provider", "ToolCatalogProviderDetails": ".tool_catalog_provider_details", "ToolCatalogProviderResponse": ".tool_catalog_provider_response", "ToolCatalogProviderResponseProvider": ".tool_catalog_provider_response_provider", "ToolCatalogProvidersResponse": ".tool_catalog_providers_response", "ToolCatalogProvidersResponseProvidersItem": ".tool_catalog_providers_response_providers_item", "ToolConnection": ".tool_connection", "ToolConnectionCreate": ".tool_connection_create", "ToolConnectionCreateData": ".tool_connection_create_data", "ToolConnectionResponse": ".tool_connection_response", "ToolConnectionStatus": ".tool_connection_status", "ToolConnectionsResponse": ".tool_connections_response", "ToolProviderKind": ".tool_provider_kind", "ToolResult": ".tool_result", "ToolResultData": ".tool_result_data", "TraceIdResponse": ".trace_id_response", "TraceIdsResponse": ".trace_ids_response", "TraceInput": ".trace_input", "TraceInputSpansValue": ".trace_input_spans_value", "TraceOutput": ".trace_output", "TraceOutputSpansValue": ".trace_output_spans_value", "TraceRequest": ".trace_request", "TraceResponse": ".trace_response", "TraceType": ".trace_type", "TracesRequest": ".traces_request", "TracesResponse": ".traces_response", "TracingQuery": ".tracing_query", "UserIdsResponse": ".user_ids_response", "ValidationError": ".validation_error", "ValidationErrorLocItem": ".validation_error_loc_item", "WebhookDeliveriesResponse": ".webhook_deliveries_response", "WebhookDelivery": ".webhook_delivery", "WebhookDeliveryCreate": ".webhook_delivery_create", "WebhookDeliveryData": ".webhook_delivery_data", "WebhookDeliveryQuery": ".webhook_delivery_query", "WebhookDeliveryResponse": ".webhook_delivery_response", "WebhookDeliveryResponseInfo": ".webhook_delivery_response_info", "WebhookEventType": ".webhook_event_type", "WebhookProviderDto": ".webhook_provider_dto", "WebhookProviderSettingsDto": ".webhook_provider_settings_dto", "WebhookSubscription": ".webhook_subscription", "WebhookSubscriptionCreate": ".webhook_subscription_create", "WebhookSubscriptionData": ".webhook_subscription_data", "WebhookSubscriptionDataAuthMode": ".webhook_subscription_data_auth_mode", "WebhookSubscriptionEdit": ".webhook_subscription_edit", "WebhookSubscriptionQuery": ".webhook_subscription_query", "WebhookSubscriptionResponse": ".webhook_subscription_response", "WebhookSubscriptionsResponse": ".webhook_subscriptions_response", "Windowing": ".windowing", "WindowingOrder": ".windowing_order", "Workflow": ".workflow", "WorkflowArtifactFlags": ".workflow_artifact_flags", "WorkflowCatalogFlags": ".workflow_catalog_flags", "WorkflowCatalogPreset": ".workflow_catalog_preset", "WorkflowCatalogPresetResponse": ".workflow_catalog_preset_response", "WorkflowCatalogPresetsResponse": ".workflow_catalog_presets_response", "WorkflowCatalogTemplate": ".workflow_catalog_template", "WorkflowCatalogTemplateResponse": ".workflow_catalog_template_response", "WorkflowCatalogTemplatesResponse": ".workflow_catalog_templates_response", "WorkflowCatalogType": ".workflow_catalog_type", "WorkflowCatalogTypeResponse": ".workflow_catalog_type_response", "WorkflowCatalogTypesResponse": ".workflow_catalog_types_response", "WorkflowCreate": ".workflow_create", "WorkflowEdit": ".workflow_edit", "WorkflowFlags": ".workflow_flags", "WorkflowResponse": ".workflow_response", "WorkflowRevisionCommit": ".workflow_revision_commit", "WorkflowRevisionCreate": ".workflow_revision_create", "WorkflowRevisionDataInput": ".workflow_revision_data_input", "WorkflowRevisionDataInputHeadersValue": ".workflow_revision_data_input_headers_value", "WorkflowRevisionDataInputRuntime": ".workflow_revision_data_input_runtime", "WorkflowRevisionDataOutput": ".workflow_revision_data_output", "WorkflowRevisionDataOutputHeadersValue": ".workflow_revision_data_output_headers_value", "WorkflowRevisionDataOutputRuntime": ".workflow_revision_data_output_runtime", "WorkflowRevisionEdit": ".workflow_revision_edit", "WorkflowRevisionFlags": ".workflow_revision_flags", "WorkflowRevisionInput": ".workflow_revision_input", "WorkflowRevisionOutput": ".workflow_revision_output", "WorkflowRevisionResolveResponse": ".workflow_revision_resolve_response", "WorkflowRevisionResponse": ".workflow_revision_response", "WorkflowRevisionsLog": ".workflow_revisions_log", "WorkflowRevisionsResponse": ".workflow_revisions_response", "WorkflowVariant": ".workflow_variant", "WorkflowVariantCreate": ".workflow_variant_create", "WorkflowVariantEdit": ".workflow_variant_edit", "WorkflowVariantFlags": ".workflow_variant_flags", "WorkflowVariantFork": ".workflow_variant_fork", "WorkflowVariantResponse": ".workflow_variant_response", "WorkflowVariantsResponse": ".workflow_variants_response", "WorkflowsResponse": ".workflows_response", "Workspace": ".workspace", "WorkspaceMemberResponse": ".workspace_member_response", "WorkspacePermission": ".workspace_permission", "WorkspaceResponse": ".workspace_response"} +_dynamic_imports: typing.Dict[str, str] = {"AdminAccountCreateOptions": ".admin_account_create_options", "AdminAccountRead": ".admin_account_read", "AdminAccountsCreate": ".admin_accounts_create", "AdminAccountsDelete": ".admin_accounts_delete", "AdminAccountsDeleteTarget": ".admin_accounts_delete_target", "AdminAccountsResponse": ".admin_accounts_response", "AdminApiKeyCreate": ".admin_api_key_create", "AdminApiKeyResponse": ".admin_api_key_response", "AdminDeleteResponse": ".admin_delete_response", "AdminDeletedEntities": ".admin_deleted_entities", "AdminDeletedEntity": ".admin_deleted_entity", "AdminOrganizationCreate": ".admin_organization_create", "AdminOrganizationMembershipCreate": ".admin_organization_membership_create", "AdminOrganizationMembershipRead": ".admin_organization_membership_read", "AdminOrganizationRead": ".admin_organization_read", "AdminProjectCreate": ".admin_project_create", "AdminProjectMembershipCreate": ".admin_project_membership_create", "AdminProjectMembershipRead": ".admin_project_membership_read", "AdminProjectRead": ".admin_project_read", "AdminSimpleAccountCreate": ".admin_simple_account_create", "AdminSimpleAccountDeleteEntry": ".admin_simple_account_delete_entry", "AdminSimpleAccountRead": ".admin_simple_account_read", "AdminSimpleAccountsApiKeysCreate": ".admin_simple_accounts_api_keys_create", "AdminSimpleAccountsCreate": ".admin_simple_accounts_create", "AdminSimpleAccountsDelete": ".admin_simple_accounts_delete", "AdminSimpleAccountsOrganizationsCreate": ".admin_simple_accounts_organizations_create", "AdminSimpleAccountsOrganizationsMembershipsCreate": ".admin_simple_accounts_organizations_memberships_create", "AdminSimpleAccountsOrganizationsTransferOwnership": ".admin_simple_accounts_organizations_transfer_ownership", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjects": ".admin_simple_accounts_organizations_transfer_ownership_include_projects", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjectsZero": ".admin_simple_accounts_organizations_transfer_ownership_include_projects_zero", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspaces": ".admin_simple_accounts_organizations_transfer_ownership_include_workspaces", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspacesZero": ".admin_simple_accounts_organizations_transfer_ownership_include_workspaces_zero", "AdminSimpleAccountsOrganizationsTransferOwnershipResponse": ".admin_simple_accounts_organizations_transfer_ownership_response", "AdminSimpleAccountsProjectsCreate": ".admin_simple_accounts_projects_create", "AdminSimpleAccountsProjectsMembershipsCreate": ".admin_simple_accounts_projects_memberships_create", "AdminSimpleAccountsResponse": ".admin_simple_accounts_response", "AdminSimpleAccountsUsersCreate": ".admin_simple_accounts_users_create", "AdminSimpleAccountsUsersIdentitiesCreate": ".admin_simple_accounts_users_identities_create", "AdminSimpleAccountsUsersResetPassword": ".admin_simple_accounts_users_reset_password", "AdminSimpleAccountsWorkspacesCreate": ".admin_simple_accounts_workspaces_create", "AdminSimpleAccountsWorkspacesMembershipsCreate": ".admin_simple_accounts_workspaces_memberships_create", "AdminStructuredError": ".admin_structured_error", "AdminSubscriptionCreate": ".admin_subscription_create", "AdminSubscriptionRead": ".admin_subscription_read", "AdminUserCreate": ".admin_user_create", "AdminUserIdentityCreate": ".admin_user_identity_create", "AdminUserIdentityRead": ".admin_user_identity_read", "AdminUserIdentityReadStatus": ".admin_user_identity_read_status", "AdminUserRead": ".admin_user_read", "AdminWorkspaceCreate": ".admin_workspace_create", "AdminWorkspaceMembershipCreate": ".admin_workspace_membership_create", "AdminWorkspaceMembershipRead": ".admin_workspace_membership_read", "AdminWorkspaceRead": ".admin_workspace_read", "Analytics": ".analytics", "AnalyticsResponse": ".analytics_response", "Annotation": ".annotation", "AnnotationCreate": ".annotation_create", "AnnotationCreateLinks": ".annotation_create_links", "AnnotationEdit": ".annotation_edit", "AnnotationEditLinks": ".annotation_edit_links", "AnnotationLinkResponse": ".annotation_link_response", "AnnotationLinks": ".annotation_links", "AnnotationQuery": ".annotation_query", "AnnotationQueryLinks": ".annotation_query_links", "AnnotationResponse": ".annotation_response", "AnnotationsResponse": ".annotations_response", "Application": ".application", "ApplicationArtifactFlags": ".application_artifact_flags", "ApplicationArtifactQueryFlags": ".application_artifact_query_flags", "ApplicationCatalogPreset": ".application_catalog_preset", "ApplicationCatalogPresetResponse": ".application_catalog_preset_response", "ApplicationCatalogPresetsResponse": ".application_catalog_presets_response", "ApplicationCatalogTemplate": ".application_catalog_template", "ApplicationCatalogTemplateResponse": ".application_catalog_template_response", "ApplicationCatalogTemplatesResponse": ".application_catalog_templates_response", "ApplicationCatalogType": ".application_catalog_type", "ApplicationCatalogTypesResponse": ".application_catalog_types_response", "ApplicationCreate": ".application_create", "ApplicationEdit": ".application_edit", "ApplicationFlags": ".application_flags", "ApplicationQuery": ".application_query", "ApplicationResponse": ".application_response", "ApplicationRevisionCommit": ".application_revision_commit", "ApplicationRevisionCreate": ".application_revision_create", "ApplicationRevisionDataInput": ".application_revision_data_input", "ApplicationRevisionDataInputHeadersValue": ".application_revision_data_input_headers_value", "ApplicationRevisionDataInputRuntime": ".application_revision_data_input_runtime", "ApplicationRevisionDataOutput": ".application_revision_data_output", "ApplicationRevisionDataOutputHeadersValue": ".application_revision_data_output_headers_value", "ApplicationRevisionDataOutputRuntime": ".application_revision_data_output_runtime", "ApplicationRevisionEdit": ".application_revision_edit", "ApplicationRevisionFlags": ".application_revision_flags", "ApplicationRevisionInput": ".application_revision_input", "ApplicationRevisionOutput": ".application_revision_output", "ApplicationRevisionQuery": ".application_revision_query", "ApplicationRevisionQueryFlags": ".application_revision_query_flags", "ApplicationRevisionResolveResponse": ".application_revision_resolve_response", "ApplicationRevisionResponse": ".application_revision_response", "ApplicationRevisionsLog": ".application_revisions_log", "ApplicationRevisionsResponse": ".application_revisions_response", "ApplicationVariant": ".application_variant", "ApplicationVariantCreate": ".application_variant_create", "ApplicationVariantEdit": ".application_variant_edit", "ApplicationVariantFlags": ".application_variant_flags", "ApplicationVariantFork": ".application_variant_fork", "ApplicationVariantResponse": ".application_variant_response", "ApplicationVariantsResponse": ".application_variants_response", "ApplicationsResponse": ".applications_response", "BodyConfigsFetchVariantsConfigsFetchPost": ".body_configs_fetch_variants_configs_fetch_post", "Bucket": ".bucket", "CollectStatusResponse": ".collect_status_response", "ComparisonOperator": ".comparison_operator", "Condition": ".condition", "ConditionOperator": ".condition_operator", "ConditionOptions": ".condition_options", "ConditionValue": ".condition_value", "ConfigResponseModel": ".config_response_model", "CustomModelSettingsDto": ".custom_model_settings_dto", "CustomProviderDto": ".custom_provider_dto", "CustomProviderKind": ".custom_provider_kind", "CustomProviderSettingsDto": ".custom_provider_settings_dto", "DictOperator": ".dict_operator", "DiscoverResponse": ".discover_response", "DiscoverResponseMethodsValue": ".discover_response_methods_value", "EeSrcModelsApiOrganizationModelsOrganization": ".ee_src_models_api_organization_models_organization", "EntityRef": ".entity_ref", "Environment": ".environment", "EnvironmentCreate": ".environment_create", "EnvironmentEdit": ".environment_edit", "EnvironmentFlags": ".environment_flags", "EnvironmentQueryFlags": ".environment_query_flags", "EnvironmentResponse": ".environment_response", "EnvironmentRevisionCommit": ".environment_revision_commit", "EnvironmentRevisionCreate": ".environment_revision_create", "EnvironmentRevisionData": ".environment_revision_data", "EnvironmentRevisionDelta": ".environment_revision_delta", "EnvironmentRevisionEdit": ".environment_revision_edit", "EnvironmentRevisionInput": ".environment_revision_input", "EnvironmentRevisionOutput": ".environment_revision_output", "EnvironmentRevisionResolveResponse": ".environment_revision_resolve_response", "EnvironmentRevisionResponse": ".environment_revision_response", "EnvironmentRevisionsLog": ".environment_revisions_log", "EnvironmentRevisionsResponse": ".environment_revisions_response", "EnvironmentVariant": ".environment_variant", "EnvironmentVariantCreate": ".environment_variant_create", "EnvironmentVariantEdit": ".environment_variant_edit", "EnvironmentVariantFork": ".environment_variant_fork", "EnvironmentVariantResponse": ".environment_variant_response", "EnvironmentVariantsResponse": ".environment_variants_response", "EnvironmentsResponse": ".environments_response", "ErrorPolicy": ".error_policy", "EvaluationMetrics": ".evaluation_metrics", "EvaluationMetricsCreate": ".evaluation_metrics_create", "EvaluationMetricsIdsResponse": ".evaluation_metrics_ids_response", "EvaluationMetricsQuery": ".evaluation_metrics_query", "EvaluationMetricsQueryScenarioIds": ".evaluation_metrics_query_scenario_ids", "EvaluationMetricsQueryTimestamps": ".evaluation_metrics_query_timestamps", "EvaluationMetricsRefresh": ".evaluation_metrics_refresh", "EvaluationMetricsResponse": ".evaluation_metrics_response", "EvaluationMetricsSetRequest": ".evaluation_metrics_set_request", "EvaluationQueue": ".evaluation_queue", "EvaluationQueueCreate": ".evaluation_queue_create", "EvaluationQueueData": ".evaluation_queue_data", "EvaluationQueueEdit": ".evaluation_queue_edit", "EvaluationQueueFlags": ".evaluation_queue_flags", "EvaluationQueueIdResponse": ".evaluation_queue_id_response", "EvaluationQueueIdsResponse": ".evaluation_queue_ids_response", "EvaluationQueueQuery": ".evaluation_queue_query", "EvaluationQueueQueryFlags": ".evaluation_queue_query_flags", "EvaluationQueueResponse": ".evaluation_queue_response", "EvaluationQueueScenariosQuery": ".evaluation_queue_scenarios_query", "EvaluationQueuesResponse": ".evaluation_queues_response", "EvaluationResult": ".evaluation_result", "EvaluationResultCreate": ".evaluation_result_create", "EvaluationResultIdResponse": ".evaluation_result_id_response", "EvaluationResultIdsResponse": ".evaluation_result_ids_response", "EvaluationResultQuery": ".evaluation_result_query", "EvaluationResultResponse": ".evaluation_result_response", "EvaluationResultsResponse": ".evaluation_results_response", "EvaluationResultsSetRequest": ".evaluation_results_set_request", "EvaluationRun": ".evaluation_run", "EvaluationRunCreate": ".evaluation_run_create", "EvaluationRunDataConcurrency": ".evaluation_run_data_concurrency", "EvaluationRunDataInput": ".evaluation_run_data_input", "EvaluationRunDataMapping": ".evaluation_run_data_mapping", "EvaluationRunDataMappingColumn": ".evaluation_run_data_mapping_column", "EvaluationRunDataMappingStep": ".evaluation_run_data_mapping_step", "EvaluationRunDataOutput": ".evaluation_run_data_output", "EvaluationRunDataStepInput": ".evaluation_run_data_step_input", "EvaluationRunDataStepInputKey": ".evaluation_run_data_step_input_key", "EvaluationRunDataStepInputOrigin": ".evaluation_run_data_step_input_origin", "EvaluationRunDataStepInputType": ".evaluation_run_data_step_input_type", "EvaluationRunDataStepOutput": ".evaluation_run_data_step_output", "EvaluationRunDataStepOutputOrigin": ".evaluation_run_data_step_output_origin", "EvaluationRunDataStepOutputType": ".evaluation_run_data_step_output_type", "EvaluationRunEdit": ".evaluation_run_edit", "EvaluationRunFlags": ".evaluation_run_flags", "EvaluationRunIdResponse": ".evaluation_run_id_response", "EvaluationRunIdsRequest": ".evaluation_run_ids_request", "EvaluationRunIdsResponse": ".evaluation_run_ids_response", "EvaluationRunQuery": ".evaluation_run_query", "EvaluationRunQueryFlags": ".evaluation_run_query_flags", "EvaluationRunResponse": ".evaluation_run_response", "EvaluationRunsResponse": ".evaluation_runs_response", "EvaluationScenario": ".evaluation_scenario", "EvaluationScenarioCreate": ".evaluation_scenario_create", "EvaluationScenarioEdit": ".evaluation_scenario_edit", "EvaluationScenarioIdResponse": ".evaluation_scenario_id_response", "EvaluationScenarioIdsResponse": ".evaluation_scenario_ids_response", "EvaluationScenarioQuery": ".evaluation_scenario_query", "EvaluationScenarioResponse": ".evaluation_scenario_response", "EvaluationScenariosResponse": ".evaluation_scenarios_response", "EvaluationStatus": ".evaluation_status", "Evaluator": ".evaluator", "EvaluatorArtifactFlags": ".evaluator_artifact_flags", "EvaluatorArtifactQueryFlags": ".evaluator_artifact_query_flags", "EvaluatorCatalogPreset": ".evaluator_catalog_preset", "EvaluatorCatalogPresetResponse": ".evaluator_catalog_preset_response", "EvaluatorCatalogPresetsResponse": ".evaluator_catalog_presets_response", "EvaluatorCatalogTemplate": ".evaluator_catalog_template", "EvaluatorCatalogTemplateResponse": ".evaluator_catalog_template_response", "EvaluatorCatalogTemplatesResponse": ".evaluator_catalog_templates_response", "EvaluatorCatalogType": ".evaluator_catalog_type", "EvaluatorCatalogTypesResponse": ".evaluator_catalog_types_response", "EvaluatorCreate": ".evaluator_create", "EvaluatorEdit": ".evaluator_edit", "EvaluatorFlags": ".evaluator_flags", "EvaluatorQuery": ".evaluator_query", "EvaluatorResponse": ".evaluator_response", "EvaluatorRevisionCommit": ".evaluator_revision_commit", "EvaluatorRevisionCreate": ".evaluator_revision_create", "EvaluatorRevisionDataInput": ".evaluator_revision_data_input", "EvaluatorRevisionDataInputHeadersValue": ".evaluator_revision_data_input_headers_value", "EvaluatorRevisionDataInputRuntime": ".evaluator_revision_data_input_runtime", "EvaluatorRevisionDataOutput": ".evaluator_revision_data_output", "EvaluatorRevisionDataOutputHeadersValue": ".evaluator_revision_data_output_headers_value", "EvaluatorRevisionDataOutputRuntime": ".evaluator_revision_data_output_runtime", "EvaluatorRevisionEdit": ".evaluator_revision_edit", "EvaluatorRevisionFlags": ".evaluator_revision_flags", "EvaluatorRevisionInput": ".evaluator_revision_input", "EvaluatorRevisionOutput": ".evaluator_revision_output", "EvaluatorRevisionQuery": ".evaluator_revision_query", "EvaluatorRevisionQueryFlags": ".evaluator_revision_query_flags", "EvaluatorRevisionResolveResponse": ".evaluator_revision_resolve_response", "EvaluatorRevisionResponse": ".evaluator_revision_response", "EvaluatorRevisionsLog": ".evaluator_revisions_log", "EvaluatorRevisionsResponse": ".evaluator_revisions_response", "EvaluatorTemplate": ".evaluator_template", "EvaluatorTemplatesResponse": ".evaluator_templates_response", "EvaluatorVariant": ".evaluator_variant", "EvaluatorVariantCreate": ".evaluator_variant_create", "EvaluatorVariantEdit": ".evaluator_variant_edit", "EvaluatorVariantFlags": ".evaluator_variant_flags", "EvaluatorVariantFork": ".evaluator_variant_fork", "EvaluatorVariantResponse": ".evaluator_variant_response", "EvaluatorVariantsResponse": ".evaluator_variants_response", "EvaluatorsResponse": ".evaluators_response", "Event": ".event", "EventQuery": ".event_query", "EventType": ".event_type", "EventsQueryResponse": ".events_query_response", "ExistenceOperator": ".existence_operator", "FilteringInput": ".filtering_input", "FilteringInputConditionsItem": ".filtering_input_conditions_item", "FilteringOutput": ".filtering_output", "FilteringOutputConditionsItem": ".filtering_output_conditions_item", "Focus": ".focus", "Folder": ".folder", "FolderCreate": ".folder_create", "FolderEdit": ".folder_edit", "FolderIdResponse": ".folder_id_response", "FolderKind": ".folder_kind", "FolderQuery": ".folder_query", "FolderQueryKinds": ".folder_query_kinds", "FolderResponse": ".folder_response", "FoldersResponse": ".folders_response", "Format": ".format", "Formatting": ".formatting", typing.Any: ".full_json_input", typing.Any: ".full_json_output", "Header": ".header", "HttpValidationError": ".http_validation_error", "InviteRequest": ".invite_request", "Invocation": ".invocation", "InvocationCreate": ".invocation_create", "InvocationCreateLinks": ".invocation_create_links", "InvocationEdit": ".invocation_edit", "InvocationEditLinks": ".invocation_edit_links", "InvocationLinkResponse": ".invocation_link_response", "InvocationLinks": ".invocation_links", "InvocationQuery": ".invocation_query", "InvocationQueryLinks": ".invocation_query_links", "InvocationResponse": ".invocation_response", "InvocationsResponse": ".invocations_response", "JsonSchemasInput": ".json_schemas_input", "JsonSchemasOutput": ".json_schemas_output", typing.Any: ".label_json_input", typing.Any: ".label_json_output", "LegacyLifecycleDto": ".legacy_lifecycle_dto", "ListApiKeysResponse": ".list_api_keys_response", "ListOperator": ".list_operator", "ListOptions": ".list_options", "LogicalOperator": ".logical_operator", "MetricSpec": ".metric_spec", "MetricType": ".metric_type", "MetricsBucket": ".metrics_bucket", "NumericOperator": ".numeric_operator", "OTelEventInput": ".o_tel_event_input", "OTelEventInputTimestamp": ".o_tel_event_input_timestamp", "OTelEventOutput": ".o_tel_event_output", "OTelEventOutputTimestamp": ".o_tel_event_output_timestamp", "OTelHashInput": ".o_tel_hash_input", "OTelHashOutput": ".o_tel_hash_output", "OTelLinkInput": ".o_tel_link_input", "OTelLinkOutput": ".o_tel_link_output", "OTelLinksResponse": ".o_tel_links_response", "OTelReferenceInput": ".o_tel_reference_input", "OTelReferenceOutput": ".o_tel_reference_output", "OTelSpanKind": ".o_tel_span_kind", "OTelStatusCode": ".o_tel_status_code", "OTelTracingRequest": ".o_tel_tracing_request", "OTelTracingResponse": ".o_tel_tracing_response", "OldAnalyticsResponse": ".old_analytics_response", "OrganizationDetails": ".organization_details", "OrganizationDomainResponse": ".organization_domain_response", "OrganizationProviderResponse": ".organization_provider_response", "OrganizationUpdate": ".organization_update", "OssSrcModelsApiOrganizationModelsOrganization": ".oss_src_models_api_organization_models_organization", "Permission": ".permission", "ProjectsResponse": ".projects_response", "QueriesResponse": ".queries_response", "Query": ".query", "QueryCreate": ".query_create", "QueryEdit": ".query_edit", "QueryFlags": ".query_flags", "QueryQueryFlags": ".query_query_flags", "QueryResponse": ".query_response", "QueryRevision": ".query_revision", "QueryRevisionCommit": ".query_revision_commit", "QueryRevisionCreate": ".query_revision_create", "QueryRevisionDataInput": ".query_revision_data_input", "QueryRevisionDataOutput": ".query_revision_data_output", "QueryRevisionEdit": ".query_revision_edit", "QueryRevisionQuery": ".query_revision_query", "QueryRevisionResponse": ".query_revision_response", "QueryRevisionsLog": ".query_revisions_log", "QueryRevisionsResponse": ".query_revisions_response", "QueryVariant": ".query_variant", "QueryVariantCreate": ".query_variant_create", "QueryVariantEdit": ".query_variant_edit", "QueryVariantFork": ".query_variant_fork", "QueryVariantQuery": ".query_variant_query", "QueryVariantResponse": ".query_variant_response", "QueryVariantsResponse": ".query_variants_response", "Reference": ".reference", "ReferenceRequestModelInput": ".reference_request_model_input", "ReferenceRequestModelOutput": ".reference_request_model_output", "RequestType": ".request_type", "ResolutionInfo": ".resolution_info", "RetrievalInfo": ".retrieval_info", "SecretDto": ".secret_dto", "SecretDtoData": ".secret_dto_data", "SecretKind": ".secret_kind", "SecretResponseDto": ".secret_response_dto", "SecretResponseDtoData": ".secret_response_dto_data", "Selector": ".selector", "SessionIdsResponse": ".session_ids_response", "SimpleApplication": ".simple_application", "SimpleApplicationCreate": ".simple_application_create", "SimpleApplicationDataInput": ".simple_application_data_input", "SimpleApplicationDataInputHeadersValue": ".simple_application_data_input_headers_value", "SimpleApplicationDataInputRuntime": ".simple_application_data_input_runtime", "SimpleApplicationDataOutput": ".simple_application_data_output", "SimpleApplicationDataOutputHeadersValue": ".simple_application_data_output_headers_value", "SimpleApplicationDataOutputRuntime": ".simple_application_data_output_runtime", "SimpleApplicationEdit": ".simple_application_edit", "SimpleApplicationFlags": ".simple_application_flags", "SimpleApplicationQuery": ".simple_application_query", "SimpleApplicationQueryFlags": ".simple_application_query_flags", "SimpleApplicationResponse": ".simple_application_response", "SimpleApplicationsResponse": ".simple_applications_response", "SimpleEnvironment": ".simple_environment", "SimpleEnvironmentCreate": ".simple_environment_create", "SimpleEnvironmentEdit": ".simple_environment_edit", "SimpleEnvironmentQuery": ".simple_environment_query", "SimpleEnvironmentResponse": ".simple_environment_response", "SimpleEnvironmentsResponse": ".simple_environments_response", "SimpleEvaluation": ".simple_evaluation", "SimpleEvaluationCreate": ".simple_evaluation_create", "SimpleEvaluationData": ".simple_evaluation_data", "SimpleEvaluationDataApplicationSteps": ".simple_evaluation_data_application_steps", "SimpleEvaluationDataApplicationStepsOneValue": ".simple_evaluation_data_application_steps_one_value", "SimpleEvaluationDataEvaluatorSteps": ".simple_evaluation_data_evaluator_steps", "SimpleEvaluationDataEvaluatorStepsOneValue": ".simple_evaluation_data_evaluator_steps_one_value", "SimpleEvaluationDataQuerySteps": ".simple_evaluation_data_query_steps", "SimpleEvaluationDataQueryStepsOneValue": ".simple_evaluation_data_query_steps_one_value", "SimpleEvaluationDataTestsetSteps": ".simple_evaluation_data_testset_steps", "SimpleEvaluationDataTestsetStepsOneValue": ".simple_evaluation_data_testset_steps_one_value", "SimpleEvaluationEdit": ".simple_evaluation_edit", "SimpleEvaluationIdResponse": ".simple_evaluation_id_response", "SimpleEvaluationQuery": ".simple_evaluation_query", "SimpleEvaluationResponse": ".simple_evaluation_response", "SimpleEvaluationsResponse": ".simple_evaluations_response", "SimpleEvaluator": ".simple_evaluator", "SimpleEvaluatorCreate": ".simple_evaluator_create", "SimpleEvaluatorDataInput": ".simple_evaluator_data_input", "SimpleEvaluatorDataInputHeadersValue": ".simple_evaluator_data_input_headers_value", "SimpleEvaluatorDataInputRuntime": ".simple_evaluator_data_input_runtime", "SimpleEvaluatorDataOutput": ".simple_evaluator_data_output", "SimpleEvaluatorDataOutputHeadersValue": ".simple_evaluator_data_output_headers_value", "SimpleEvaluatorDataOutputRuntime": ".simple_evaluator_data_output_runtime", "SimpleEvaluatorEdit": ".simple_evaluator_edit", "SimpleEvaluatorFlags": ".simple_evaluator_flags", "SimpleEvaluatorQuery": ".simple_evaluator_query", "SimpleEvaluatorQueryFlags": ".simple_evaluator_query_flags", "SimpleEvaluatorResponse": ".simple_evaluator_response", "SimpleEvaluatorsResponse": ".simple_evaluators_response", "SimpleQueriesResponse": ".simple_queries_response", "SimpleQuery": ".simple_query", "SimpleQueryCreate": ".simple_query_create", "SimpleQueryEdit": ".simple_query_edit", "SimpleQueryQuery": ".simple_query_query", "SimpleQueryResponse": ".simple_query_response", "SimpleQueue": ".simple_queue", "SimpleQueueCreate": ".simple_queue_create", "SimpleQueueData": ".simple_queue_data", "SimpleQueueDataEvaluators": ".simple_queue_data_evaluators", "SimpleQueueDataEvaluatorsOneValue": ".simple_queue_data_evaluators_one_value", "SimpleQueueIdResponse": ".simple_queue_id_response", "SimpleQueueIdsResponse": ".simple_queue_ids_response", "SimpleQueueKind": ".simple_queue_kind", "SimpleQueueQuery": ".simple_queue_query", "SimpleQueueResponse": ".simple_queue_response", "SimpleQueueScenariosQuery": ".simple_queue_scenarios_query", "SimpleQueueScenariosResponse": ".simple_queue_scenarios_response", "SimpleQueueSettings": ".simple_queue_settings", "SimpleQueuesResponse": ".simple_queues_response", "SimpleTestset": ".simple_testset", "SimpleTestsetCreate": ".simple_testset_create", "SimpleTestsetEdit": ".simple_testset_edit", "SimpleTestsetQuery": ".simple_testset_query", "SimpleTestsetResponse": ".simple_testset_response", "SimpleTestsetsResponse": ".simple_testsets_response", "SimpleTrace": ".simple_trace", "SimpleTraceChannel": ".simple_trace_channel", "SimpleTraceCreate": ".simple_trace_create", "SimpleTraceCreateLinks": ".simple_trace_create_links", "SimpleTraceEdit": ".simple_trace_edit", "SimpleTraceEditLinks": ".simple_trace_edit_links", "SimpleTraceKind": ".simple_trace_kind", "SimpleTraceLinkResponse": ".simple_trace_link_response", "SimpleTraceLinks": ".simple_trace_links", "SimpleTraceOrigin": ".simple_trace_origin", "SimpleTraceQuery": ".simple_trace_query", "SimpleTraceQueryLinks": ".simple_trace_query_links", "SimpleTraceReferences": ".simple_trace_references", "SimpleTraceResponse": ".simple_trace_response", "SimpleTracesResponse": ".simple_traces_response", "SimpleWorkflow": ".simple_workflow", "SimpleWorkflowCreate": ".simple_workflow_create", "SimpleWorkflowDataInput": ".simple_workflow_data_input", "SimpleWorkflowDataInputHeadersValue": ".simple_workflow_data_input_headers_value", "SimpleWorkflowDataInputRuntime": ".simple_workflow_data_input_runtime", "SimpleWorkflowDataOutput": ".simple_workflow_data_output", "SimpleWorkflowDataOutputHeadersValue": ".simple_workflow_data_output_headers_value", "SimpleWorkflowDataOutputRuntime": ".simple_workflow_data_output_runtime", "SimpleWorkflowEdit": ".simple_workflow_edit", "SimpleWorkflowFlags": ".simple_workflow_flags", "SimpleWorkflowQuery": ".simple_workflow_query", "SimpleWorkflowQueryFlags": ".simple_workflow_query_flags", "SimpleWorkflowResponse": ".simple_workflow_response", "SimpleWorkflowsResponse": ".simple_workflows_response", "SpanInput": ".span_input", "SpanInputEndTime": ".span_input_end_time", "SpanInputStartTime": ".span_input_start_time", "SpanOutput": ".span_output", "SpanOutputEndTime": ".span_output_end_time", "SpanOutputStartTime": ".span_output_start_time", "SpanResponse": ".span_response", "SpanType": ".span_type", "SpansNodeInput": ".spans_node_input", "SpansNodeInputEndTime": ".spans_node_input_end_time", "SpansNodeInputSpansValue": ".spans_node_input_spans_value", "SpansNodeInputStartTime": ".spans_node_input_start_time", "SpansNodeOutput": ".spans_node_output", "SpansNodeOutputEndTime": ".spans_node_output_end_time", "SpansNodeOutputSpansValue": ".spans_node_output_spans_value", "SpansNodeOutputStartTime": ".spans_node_output_start_time", "SpansResponse": ".spans_response", "SpansTreeInput": ".spans_tree_input", "SpansTreeInputSpansValue": ".spans_tree_input_spans_value", "SpansTreeOutput": ".spans_tree_output", "SpansTreeOutputSpansValue": ".spans_tree_output_spans_value", "SsoProviderDto": ".sso_provider_dto", "SsoProviderInfo": ".sso_provider_info", "SsoProviderSettingsDto": ".sso_provider_settings_dto", "SsoProviders": ".sso_providers", "StandardProviderDto": ".standard_provider_dto", "StandardProviderKind": ".standard_provider_kind", "StandardProviderSettingsDto": ".standard_provider_settings_dto", "Status": ".status", "StringOperator": ".string_operator", "TestcaseInput": ".testcase_input", "TestcaseOutput": ".testcase_output", "TestcaseResponse": ".testcase_response", "TestcasesResponse": ".testcases_response", "Testset": ".testset", "TestsetCreate": ".testset_create", "TestsetEdit": ".testset_edit", "TestsetFlags": ".testset_flags", "TestsetQuery": ".testset_query", "TestsetResponse": ".testset_response", "TestsetRevision": ".testset_revision", "TestsetRevisionCommit": ".testset_revision_commit", "TestsetRevisionCreate": ".testset_revision_create", "TestsetRevisionDataInput": ".testset_revision_data_input", "TestsetRevisionDataOutput": ".testset_revision_data_output", "TestsetRevisionDelta": ".testset_revision_delta", "TestsetRevisionDeltaColumns": ".testset_revision_delta_columns", "TestsetRevisionDeltaRows": ".testset_revision_delta_rows", "TestsetRevisionEdit": ".testset_revision_edit", "TestsetRevisionQuery": ".testset_revision_query", "TestsetRevisionResponse": ".testset_revision_response", "TestsetRevisionsLog": ".testset_revisions_log", "TestsetRevisionsResponse": ".testset_revisions_response", "TestsetVariant": ".testset_variant", "TestsetVariantCreate": ".testset_variant_create", "TestsetVariantEdit": ".testset_variant_edit", "TestsetVariantFork": ".testset_variant_fork", "TestsetVariantQuery": ".testset_variant_query", "TestsetVariantResponse": ".testset_variant_response", "TestsetVariantsResponse": ".testset_variants_response", "TestsetsResponse": ".testsets_response", "TextOptions": ".text_options", "ToolAuthScheme": ".tool_auth_scheme", "ToolCallData": ".tool_call_data", "ToolCallFunction": ".tool_call_function", "ToolCallResponse": ".tool_call_response", "ToolCatalogAction": ".tool_catalog_action", "ToolCatalogActionDetails": ".tool_catalog_action_details", "ToolCatalogActionResponse": ".tool_catalog_action_response", "ToolCatalogActionResponseAction": ".tool_catalog_action_response_action", "ToolCatalogActionsResponse": ".tool_catalog_actions_response", "ToolCatalogActionsResponseActionsItem": ".tool_catalog_actions_response_actions_item", "ToolCatalogIntegration": ".tool_catalog_integration", "ToolCatalogIntegrationDetails": ".tool_catalog_integration_details", "ToolCatalogIntegrationResponse": ".tool_catalog_integration_response", "ToolCatalogIntegrationResponseIntegration": ".tool_catalog_integration_response_integration", "ToolCatalogIntegrationsResponse": ".tool_catalog_integrations_response", "ToolCatalogIntegrationsResponseIntegrationsItem": ".tool_catalog_integrations_response_integrations_item", "ToolCatalogProvider": ".tool_catalog_provider", "ToolCatalogProviderDetails": ".tool_catalog_provider_details", "ToolCatalogProviderResponse": ".tool_catalog_provider_response", "ToolCatalogProviderResponseProvider": ".tool_catalog_provider_response_provider", "ToolCatalogProvidersResponse": ".tool_catalog_providers_response", "ToolCatalogProvidersResponseProvidersItem": ".tool_catalog_providers_response_providers_item", "ToolConnection": ".tool_connection", "ToolConnectionCreate": ".tool_connection_create", "ToolConnectionCreateData": ".tool_connection_create_data", "ToolConnectionResponse": ".tool_connection_response", "ToolConnectionStatus": ".tool_connection_status", "ToolConnectionsResponse": ".tool_connections_response", "ToolProviderKind": ".tool_provider_kind", "ToolResult": ".tool_result", "ToolResultData": ".tool_result_data", "TraceIdResponse": ".trace_id_response", "TraceIdsResponse": ".trace_ids_response", "TraceInput": ".trace_input", "TraceInputSpansValue": ".trace_input_spans_value", "TraceOutput": ".trace_output", "TraceOutputSpansValue": ".trace_output_spans_value", "TraceRequest": ".trace_request", "TraceResponse": ".trace_response", "TraceType": ".trace_type", "TracesRequest": ".traces_request", "TracesResponse": ".traces_response", "TracingQuery": ".tracing_query", "TriggerAuthScheme": ".trigger_auth_scheme", "TriggerCatalogEvent": ".trigger_catalog_event", "TriggerCatalogEventDetails": ".trigger_catalog_event_details", "TriggerCatalogEventResponse": ".trigger_catalog_event_response", "TriggerCatalogEventsResponse": ".trigger_catalog_events_response", "TriggerCatalogIntegration": ".trigger_catalog_integration", "TriggerCatalogIntegrationResponse": ".trigger_catalog_integration_response", "TriggerCatalogIntegrationsResponse": ".trigger_catalog_integrations_response", "TriggerCatalogProvider": ".trigger_catalog_provider", "TriggerCatalogProviderResponse": ".trigger_catalog_provider_response", "TriggerCatalogProvidersResponse": ".trigger_catalog_providers_response", "TriggerConnection": ".trigger_connection", "TriggerConnectionCreate": ".trigger_connection_create", "TriggerConnectionCreateData": ".trigger_connection_create_data", "TriggerConnectionResponse": ".trigger_connection_response", "TriggerConnectionStatus": ".trigger_connection_status", "TriggerConnectionsResponse": ".trigger_connections_response", "TriggerDeliveriesResponse": ".trigger_deliveries_response", "TriggerDelivery": ".trigger_delivery", "TriggerDeliveryData": ".trigger_delivery_data", "TriggerDeliveryQuery": ".trigger_delivery_query", "TriggerDeliveryResponse": ".trigger_delivery_response", "TriggerEventAck": ".trigger_event_ack", "TriggerProviderKind": ".trigger_provider_kind", "TriggerSchedule": ".trigger_schedule", "TriggerScheduleCreate": ".trigger_schedule_create", "TriggerScheduleData": ".trigger_schedule_data", "TriggerScheduleEdit": ".trigger_schedule_edit", "TriggerScheduleFlags": ".trigger_schedule_flags", "TriggerScheduleQuery": ".trigger_schedule_query", "TriggerScheduleResponse": ".trigger_schedule_response", "TriggerSchedulesResponse": ".trigger_schedules_response", "TriggerSubscription": ".trigger_subscription", "TriggerSubscriptionCreate": ".trigger_subscription_create", "TriggerSubscriptionData": ".trigger_subscription_data", "TriggerSubscriptionEdit": ".trigger_subscription_edit", "TriggerSubscriptionFlags": ".trigger_subscription_flags", "TriggerSubscriptionQuery": ".trigger_subscription_query", "TriggerSubscriptionResponse": ".trigger_subscription_response", "TriggerSubscriptionsResponse": ".trigger_subscriptions_response", "UserIdsResponse": ".user_ids_response", "ValidationError": ".validation_error", "ValidationErrorLocItem": ".validation_error_loc_item", "WebhookDeliveriesResponse": ".webhook_deliveries_response", "WebhookDelivery": ".webhook_delivery", "WebhookDeliveryCreate": ".webhook_delivery_create", "WebhookDeliveryData": ".webhook_delivery_data", "WebhookDeliveryQuery": ".webhook_delivery_query", "WebhookDeliveryResponse": ".webhook_delivery_response", "WebhookDeliveryResponseInfo": ".webhook_delivery_response_info", "WebhookEventType": ".webhook_event_type", "WebhookProviderDto": ".webhook_provider_dto", "WebhookProviderSettingsDto": ".webhook_provider_settings_dto", "WebhookSubscription": ".webhook_subscription", "WebhookSubscriptionCreate": ".webhook_subscription_create", "WebhookSubscriptionData": ".webhook_subscription_data", "WebhookSubscriptionDataAuthMode": ".webhook_subscription_data_auth_mode", "WebhookSubscriptionEdit": ".webhook_subscription_edit", "WebhookSubscriptionFlags": ".webhook_subscription_flags", "WebhookSubscriptionQuery": ".webhook_subscription_query", "WebhookSubscriptionResponse": ".webhook_subscription_response", "WebhookSubscriptionsResponse": ".webhook_subscriptions_response", "Windowing": ".windowing", "WindowingOrder": ".windowing_order", "Workflow": ".workflow", "WorkflowArtifactFlags": ".workflow_artifact_flags", "WorkflowCatalogFlags": ".workflow_catalog_flags", "WorkflowCatalogPreset": ".workflow_catalog_preset", "WorkflowCatalogPresetResponse": ".workflow_catalog_preset_response", "WorkflowCatalogPresetsResponse": ".workflow_catalog_presets_response", "WorkflowCatalogTemplate": ".workflow_catalog_template", "WorkflowCatalogTemplateResponse": ".workflow_catalog_template_response", "WorkflowCatalogTemplatesResponse": ".workflow_catalog_templates_response", "WorkflowCatalogType": ".workflow_catalog_type", "WorkflowCatalogTypeResponse": ".workflow_catalog_type_response", "WorkflowCatalogTypesResponse": ".workflow_catalog_types_response", "WorkflowCreate": ".workflow_create", "WorkflowEdit": ".workflow_edit", "WorkflowFlags": ".workflow_flags", "WorkflowResponse": ".workflow_response", "WorkflowRevisionCommit": ".workflow_revision_commit", "WorkflowRevisionCreate": ".workflow_revision_create", "WorkflowRevisionDataInput": ".workflow_revision_data_input", "WorkflowRevisionDataInputHeadersValue": ".workflow_revision_data_input_headers_value", "WorkflowRevisionDataInputRuntime": ".workflow_revision_data_input_runtime", "WorkflowRevisionDataOutput": ".workflow_revision_data_output", "WorkflowRevisionDataOutputHeadersValue": ".workflow_revision_data_output_headers_value", "WorkflowRevisionDataOutputRuntime": ".workflow_revision_data_output_runtime", "WorkflowRevisionEdit": ".workflow_revision_edit", "WorkflowRevisionFlags": ".workflow_revision_flags", "WorkflowRevisionInput": ".workflow_revision_input", "WorkflowRevisionOutput": ".workflow_revision_output", "WorkflowRevisionResolveResponse": ".workflow_revision_resolve_response", "WorkflowRevisionResponse": ".workflow_revision_response", "WorkflowRevisionsLog": ".workflow_revisions_log", "WorkflowRevisionsResponse": ".workflow_revisions_response", "WorkflowVariant": ".workflow_variant", "WorkflowVariantCreate": ".workflow_variant_create", "WorkflowVariantEdit": ".workflow_variant_edit", "WorkflowVariantFlags": ".workflow_variant_flags", "WorkflowVariantFork": ".workflow_variant_fork", "WorkflowVariantResponse": ".workflow_variant_response", "WorkflowVariantsResponse": ".workflow_variants_response", "WorkflowsResponse": ".workflows_response", "Workspace": ".workspace", "WorkspaceMemberResponse": ".workspace_member_response", "WorkspacePermission": ".workspace_permission", "WorkspaceResponse": ".workspace_response"} def __getattr__(attr_name: str) -> typing.Any: module_name = _dynamic_imports.get(attr_name) if module_name is None: @@ -666,4 +708,4 @@ def __getattr__(attr_name: str) -> typing.Any: def __dir__(): lazy_attrs = list(_dynamic_imports.keys()) return sorted(lazy_attrs) -__all__ = ["AdminAccountCreateOptions", "AdminAccountRead", "AdminAccountsCreate", "AdminAccountsDelete", "AdminAccountsDeleteTarget", "AdminAccountsResponse", "AdminApiKeyCreate", "AdminApiKeyResponse", "AdminDeleteResponse", "AdminDeletedEntities", "AdminDeletedEntity", "AdminOrganizationCreate", "AdminOrganizationMembershipCreate", "AdminOrganizationMembershipRead", "AdminOrganizationRead", "AdminProjectCreate", "AdminProjectMembershipCreate", "AdminProjectMembershipRead", "AdminProjectRead", "AdminSimpleAccountCreate", "AdminSimpleAccountDeleteEntry", "AdminSimpleAccountRead", "AdminSimpleAccountsApiKeysCreate", "AdminSimpleAccountsCreate", "AdminSimpleAccountsDelete", "AdminSimpleAccountsOrganizationsCreate", "AdminSimpleAccountsOrganizationsMembershipsCreate", "AdminSimpleAccountsOrganizationsTransferOwnership", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjects", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjectsZero", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspaces", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspacesZero", "AdminSimpleAccountsOrganizationsTransferOwnershipResponse", "AdminSimpleAccountsProjectsCreate", "AdminSimpleAccountsProjectsMembershipsCreate", "AdminSimpleAccountsResponse", "AdminSimpleAccountsUsersCreate", "AdminSimpleAccountsUsersIdentitiesCreate", "AdminSimpleAccountsUsersResetPassword", "AdminSimpleAccountsWorkspacesCreate", "AdminSimpleAccountsWorkspacesMembershipsCreate", "AdminStructuredError", "AdminSubscriptionCreate", "AdminSubscriptionRead", "AdminUserCreate", "AdminUserIdentityCreate", "AdminUserIdentityRead", "AdminUserIdentityReadStatus", "AdminUserRead", "AdminWorkspaceCreate", "AdminWorkspaceMembershipCreate", "AdminWorkspaceMembershipRead", "AdminWorkspaceRead", "Analytics", "AnalyticsResponse", "Annotation", "AnnotationCreate", "AnnotationCreateLinks", "AnnotationEdit", "AnnotationEditLinks", "AnnotationLinkResponse", "AnnotationLinks", "AnnotationQuery", "AnnotationQueryLinks", "AnnotationResponse", "AnnotationsResponse", "Application", "ApplicationArtifactFlags", "ApplicationArtifactQueryFlags", "ApplicationCatalogPreset", "ApplicationCatalogPresetResponse", "ApplicationCatalogPresetsResponse", "ApplicationCatalogTemplate", "ApplicationCatalogTemplateResponse", "ApplicationCatalogTemplatesResponse", "ApplicationCatalogType", "ApplicationCatalogTypesResponse", "ApplicationCreate", "ApplicationEdit", "ApplicationFlags", "ApplicationQuery", "ApplicationResponse", "ApplicationRevisionCommit", "ApplicationRevisionCreate", "ApplicationRevisionDataInput", "ApplicationRevisionDataInputHeadersValue", "ApplicationRevisionDataInputRuntime", "ApplicationRevisionDataOutput", "ApplicationRevisionDataOutputHeadersValue", "ApplicationRevisionDataOutputRuntime", "ApplicationRevisionEdit", "ApplicationRevisionFlags", "ApplicationRevisionInput", "ApplicationRevisionOutput", "ApplicationRevisionQuery", "ApplicationRevisionQueryFlags", "ApplicationRevisionResolveResponse", "ApplicationRevisionResponse", "ApplicationRevisionsLog", "ApplicationRevisionsResponse", "ApplicationVariant", "ApplicationVariantCreate", "ApplicationVariantEdit", "ApplicationVariantFlags", "ApplicationVariantFork", "ApplicationVariantResponse", "ApplicationVariantsResponse", "ApplicationsResponse", "BodyConfigsFetchVariantsConfigsFetchPost", "Bucket", "CollectStatusResponse", "ComparisonOperator", "Condition", "ConditionOperator", "ConditionOptions", "ConditionValue", "ConfigResponseModel", "CustomModelSettingsDto", "CustomProviderDto", "CustomProviderKind", "CustomProviderSettingsDto", "DictOperator", "DiscoverResponse", "DiscoverResponseMethodsValue", "EeSrcModelsApiOrganizationModelsOrganization", "EntityRef", "Environment", "EnvironmentCreate", "EnvironmentEdit", "EnvironmentFlags", "EnvironmentQueryFlags", "EnvironmentResponse", "EnvironmentRevisionCommit", "EnvironmentRevisionCreate", "EnvironmentRevisionData", "EnvironmentRevisionDelta", "EnvironmentRevisionEdit", "EnvironmentRevisionInput", "EnvironmentRevisionOutput", "EnvironmentRevisionResolveResponse", "EnvironmentRevisionResponse", "EnvironmentRevisionsLog", "EnvironmentRevisionsResponse", "EnvironmentVariant", "EnvironmentVariantCreate", "EnvironmentVariantEdit", "EnvironmentVariantFork", "EnvironmentVariantResponse", "EnvironmentVariantsResponse", "EnvironmentsResponse", "ErrorPolicy", "EvaluationMetrics", "EvaluationMetricsCreate", "EvaluationMetricsIdsResponse", "EvaluationMetricsQuery", "EvaluationMetricsQueryScenarioIds", "EvaluationMetricsQueryTimestamps", "EvaluationMetricsRefresh", "EvaluationMetricsResponse", "EvaluationMetricsSetRequest", "EvaluationQueue", "EvaluationQueueCreate", "EvaluationQueueData", "EvaluationQueueEdit", "EvaluationQueueFlags", "EvaluationQueueIdResponse", "EvaluationQueueIdsResponse", "EvaluationQueueQuery", "EvaluationQueueQueryFlags", "EvaluationQueueResponse", "EvaluationQueueScenariosQuery", "EvaluationQueuesResponse", "EvaluationResult", "EvaluationResultCreate", "EvaluationResultIdResponse", "EvaluationResultIdsResponse", "EvaluationResultQuery", "EvaluationResultResponse", "EvaluationResultsResponse", "EvaluationResultsSetRequest", "EvaluationRun", "EvaluationRunCreate", "EvaluationRunDataConcurrency", "EvaluationRunDataInput", "EvaluationRunDataMapping", "EvaluationRunDataMappingColumn", "EvaluationRunDataMappingStep", "EvaluationRunDataOutput", "EvaluationRunDataStepInput", "EvaluationRunDataStepInputKey", "EvaluationRunDataStepInputOrigin", "EvaluationRunDataStepInputType", "EvaluationRunDataStepOutput", "EvaluationRunDataStepOutputOrigin", "EvaluationRunDataStepOutputType", "EvaluationRunEdit", "EvaluationRunFlags", "EvaluationRunIdResponse", "EvaluationRunIdsRequest", "EvaluationRunIdsResponse", "EvaluationRunQuery", "EvaluationRunQueryFlags", "EvaluationRunResponse", "EvaluationRunsResponse", "EvaluationScenario", "EvaluationScenarioCreate", "EvaluationScenarioEdit", "EvaluationScenarioIdResponse", "EvaluationScenarioIdsResponse", "EvaluationScenarioQuery", "EvaluationScenarioResponse", "EvaluationScenariosResponse", "EvaluationStatus", "Evaluator", "EvaluatorArtifactFlags", "EvaluatorArtifactQueryFlags", "EvaluatorCatalogPreset", "EvaluatorCatalogPresetResponse", "EvaluatorCatalogPresetsResponse", "EvaluatorCatalogTemplate", "EvaluatorCatalogTemplateResponse", "EvaluatorCatalogTemplatesResponse", "EvaluatorCatalogType", "EvaluatorCatalogTypesResponse", "EvaluatorCreate", "EvaluatorEdit", "EvaluatorFlags", "EvaluatorQuery", "EvaluatorResponse", "EvaluatorRevisionCommit", "EvaluatorRevisionCreate", "EvaluatorRevisionDataInput", "EvaluatorRevisionDataInputHeadersValue", "EvaluatorRevisionDataInputRuntime", "EvaluatorRevisionDataOutput", "EvaluatorRevisionDataOutputHeadersValue", "EvaluatorRevisionDataOutputRuntime", "EvaluatorRevisionEdit", "EvaluatorRevisionFlags", "EvaluatorRevisionInput", "EvaluatorRevisionOutput", "EvaluatorRevisionQuery", "EvaluatorRevisionQueryFlags", "EvaluatorRevisionResolveResponse", "EvaluatorRevisionResponse", "EvaluatorRevisionsLog", "EvaluatorRevisionsResponse", "EvaluatorTemplate", "EvaluatorTemplatesResponse", "EvaluatorVariant", "EvaluatorVariantCreate", "EvaluatorVariantEdit", "EvaluatorVariantFlags", "EvaluatorVariantFork", "EvaluatorVariantResponse", "EvaluatorVariantsResponse", "EvaluatorsResponse", "Event", "EventQuery", "EventType", "EventsQueryResponse", "ExistenceOperator", "FilteringInput", "FilteringInputConditionsItem", "FilteringOutput", "FilteringOutputConditionsItem", "Focus", "Folder", "FolderCreate", "FolderEdit", "FolderIdResponse", "FolderKind", "FolderQuery", "FolderQueryKinds", "FolderResponse", "FoldersResponse", "Format", "Formatting", typing.Any, typing.Any, "Header", "HttpValidationError", "InviteRequest", "Invocation", "InvocationCreate", "InvocationCreateLinks", "InvocationEdit", "InvocationEditLinks", "InvocationLinkResponse", "InvocationLinks", "InvocationQuery", "InvocationQueryLinks", "InvocationResponse", "InvocationsResponse", "JsonSchemasInput", "JsonSchemasOutput", typing.Any, typing.Any, "LegacyLifecycleDto", "ListApiKeysResponse", "ListOperator", "ListOptions", "LogicalOperator", "MetricSpec", "MetricType", "MetricsBucket", "NumericOperator", "OTelEventInput", "OTelEventInputTimestamp", "OTelEventOutput", "OTelEventOutputTimestamp", "OTelHashInput", "OTelHashOutput", "OTelLinkInput", "OTelLinkOutput", "OTelLinksResponse", "OTelReferenceInput", "OTelReferenceOutput", "OTelSpanKind", "OTelStatusCode", "OTelTracingRequest", "OTelTracingResponse", "OldAnalyticsResponse", "OrganizationDetails", "OrganizationDomainResponse", "OrganizationProviderResponse", "OrganizationUpdate", "OssSrcModelsApiOrganizationModelsOrganization", "Permission", "ProjectsResponse", "QueriesResponse", "Query", "QueryCreate", "QueryEdit", "QueryFlags", "QueryQueryFlags", "QueryResponse", "QueryRevision", "QueryRevisionCommit", "QueryRevisionCreate", "QueryRevisionDataInput", "QueryRevisionDataOutput", "QueryRevisionEdit", "QueryRevisionQuery", "QueryRevisionResponse", "QueryRevisionsLog", "QueryRevisionsResponse", "QueryVariant", "QueryVariantCreate", "QueryVariantEdit", "QueryVariantFork", "QueryVariantQuery", "QueryVariantResponse", "QueryVariantsResponse", "Reference", "ReferenceRequestModelInput", "ReferenceRequestModelOutput", "RequestType", "ResolutionInfo", "RetrievalInfo", "SecretDto", "SecretDtoData", "SecretKind", "SecretResponseDto", "SecretResponseDtoData", "SessionIdsResponse", "SimpleApplication", "SimpleApplicationCreate", "SimpleApplicationDataInput", "SimpleApplicationDataInputHeadersValue", "SimpleApplicationDataInputRuntime", "SimpleApplicationDataOutput", "SimpleApplicationDataOutputHeadersValue", "SimpleApplicationDataOutputRuntime", "SimpleApplicationEdit", "SimpleApplicationFlags", "SimpleApplicationQuery", "SimpleApplicationQueryFlags", "SimpleApplicationResponse", "SimpleApplicationsResponse", "SimpleEnvironment", "SimpleEnvironmentCreate", "SimpleEnvironmentEdit", "SimpleEnvironmentQuery", "SimpleEnvironmentResponse", "SimpleEnvironmentsResponse", "SimpleEvaluation", "SimpleEvaluationCreate", "SimpleEvaluationData", "SimpleEvaluationDataApplicationSteps", "SimpleEvaluationDataApplicationStepsOneValue", "SimpleEvaluationDataEvaluatorSteps", "SimpleEvaluationDataEvaluatorStepsOneValue", "SimpleEvaluationDataQuerySteps", "SimpleEvaluationDataQueryStepsOneValue", "SimpleEvaluationDataTestsetSteps", "SimpleEvaluationDataTestsetStepsOneValue", "SimpleEvaluationEdit", "SimpleEvaluationIdResponse", "SimpleEvaluationQuery", "SimpleEvaluationResponse", "SimpleEvaluationsResponse", "SimpleEvaluator", "SimpleEvaluatorCreate", "SimpleEvaluatorDataInput", "SimpleEvaluatorDataInputHeadersValue", "SimpleEvaluatorDataInputRuntime", "SimpleEvaluatorDataOutput", "SimpleEvaluatorDataOutputHeadersValue", "SimpleEvaluatorDataOutputRuntime", "SimpleEvaluatorEdit", "SimpleEvaluatorFlags", "SimpleEvaluatorQuery", "SimpleEvaluatorQueryFlags", "SimpleEvaluatorResponse", "SimpleEvaluatorsResponse", "SimpleQueriesResponse", "SimpleQuery", "SimpleQueryCreate", "SimpleQueryEdit", "SimpleQueryQuery", "SimpleQueryResponse", "SimpleQueue", "SimpleQueueCreate", "SimpleQueueData", "SimpleQueueDataEvaluators", "SimpleQueueDataEvaluatorsOneValue", "SimpleQueueIdResponse", "SimpleQueueIdsResponse", "SimpleQueueKind", "SimpleQueueQuery", "SimpleQueueResponse", "SimpleQueueScenariosQuery", "SimpleQueueScenariosResponse", "SimpleQueueSettings", "SimpleQueuesResponse", "SimpleTestset", "SimpleTestsetCreate", "SimpleTestsetEdit", "SimpleTestsetQuery", "SimpleTestsetResponse", "SimpleTestsetsResponse", "SimpleTrace", "SimpleTraceChannel", "SimpleTraceCreate", "SimpleTraceCreateLinks", "SimpleTraceEdit", "SimpleTraceEditLinks", "SimpleTraceKind", "SimpleTraceLinkResponse", "SimpleTraceLinks", "SimpleTraceOrigin", "SimpleTraceQuery", "SimpleTraceQueryLinks", "SimpleTraceReferences", "SimpleTraceResponse", "SimpleTracesResponse", "SimpleWorkflow", "SimpleWorkflowCreate", "SimpleWorkflowDataInput", "SimpleWorkflowDataInputHeadersValue", "SimpleWorkflowDataInputRuntime", "SimpleWorkflowDataOutput", "SimpleWorkflowDataOutputHeadersValue", "SimpleWorkflowDataOutputRuntime", "SimpleWorkflowEdit", "SimpleWorkflowFlags", "SimpleWorkflowQuery", "SimpleWorkflowQueryFlags", "SimpleWorkflowResponse", "SimpleWorkflowsResponse", "SpanInput", "SpanInputEndTime", "SpanInputStartTime", "SpanOutput", "SpanOutputEndTime", "SpanOutputStartTime", "SpanResponse", "SpanType", "SpansNodeInput", "SpansNodeInputEndTime", "SpansNodeInputSpansValue", "SpansNodeInputStartTime", "SpansNodeOutput", "SpansNodeOutputEndTime", "SpansNodeOutputSpansValue", "SpansNodeOutputStartTime", "SpansResponse", "SpansTreeInput", "SpansTreeInputSpansValue", "SpansTreeOutput", "SpansTreeOutputSpansValue", "SsoProviderDto", "SsoProviderInfo", "SsoProviderSettingsDto", "SsoProviders", "StandardProviderDto", "StandardProviderKind", "StandardProviderSettingsDto", "Status", "StringOperator", "TestcaseInput", "TestcaseOutput", "TestcaseResponse", "TestcasesResponse", "Testset", "TestsetCreate", "TestsetEdit", "TestsetFlags", "TestsetQuery", "TestsetResponse", "TestsetRevision", "TestsetRevisionCommit", "TestsetRevisionCreate", "TestsetRevisionDataInput", "TestsetRevisionDataOutput", "TestsetRevisionDelta", "TestsetRevisionDeltaColumns", "TestsetRevisionDeltaRows", "TestsetRevisionEdit", "TestsetRevisionQuery", "TestsetRevisionResponse", "TestsetRevisionsLog", "TestsetRevisionsResponse", "TestsetVariant", "TestsetVariantCreate", "TestsetVariantEdit", "TestsetVariantFork", "TestsetVariantQuery", "TestsetVariantResponse", "TestsetVariantsResponse", "TestsetsResponse", "TextOptions", "ToolAuthScheme", "ToolCallData", "ToolCallFunction", "ToolCallResponse", "ToolCatalogAction", "ToolCatalogActionDetails", "ToolCatalogActionResponse", "ToolCatalogActionResponseAction", "ToolCatalogActionsResponse", "ToolCatalogActionsResponseActionsItem", "ToolCatalogIntegration", "ToolCatalogIntegrationDetails", "ToolCatalogIntegrationResponse", "ToolCatalogIntegrationResponseIntegration", "ToolCatalogIntegrationsResponse", "ToolCatalogIntegrationsResponseIntegrationsItem", "ToolCatalogProvider", "ToolCatalogProviderDetails", "ToolCatalogProviderResponse", "ToolCatalogProviderResponseProvider", "ToolCatalogProvidersResponse", "ToolCatalogProvidersResponseProvidersItem", "ToolConnection", "ToolConnectionCreate", "ToolConnectionCreateData", "ToolConnectionResponse", "ToolConnectionStatus", "ToolConnectionsResponse", "ToolProviderKind", "ToolResult", "ToolResultData", "TraceIdResponse", "TraceIdsResponse", "TraceInput", "TraceInputSpansValue", "TraceOutput", "TraceOutputSpansValue", "TraceRequest", "TraceResponse", "TraceType", "TracesRequest", "TracesResponse", "TracingQuery", "UserIdsResponse", "ValidationError", "ValidationErrorLocItem", "WebhookDeliveriesResponse", "WebhookDelivery", "WebhookDeliveryCreate", "WebhookDeliveryData", "WebhookDeliveryQuery", "WebhookDeliveryResponse", "WebhookDeliveryResponseInfo", "WebhookEventType", "WebhookProviderDto", "WebhookProviderSettingsDto", "WebhookSubscription", "WebhookSubscriptionCreate", "WebhookSubscriptionData", "WebhookSubscriptionDataAuthMode", "WebhookSubscriptionEdit", "WebhookSubscriptionQuery", "WebhookSubscriptionResponse", "WebhookSubscriptionsResponse", "Windowing", "WindowingOrder", "Workflow", "WorkflowArtifactFlags", "WorkflowCatalogFlags", "WorkflowCatalogPreset", "WorkflowCatalogPresetResponse", "WorkflowCatalogPresetsResponse", "WorkflowCatalogTemplate", "WorkflowCatalogTemplateResponse", "WorkflowCatalogTemplatesResponse", "WorkflowCatalogType", "WorkflowCatalogTypeResponse", "WorkflowCatalogTypesResponse", "WorkflowCreate", "WorkflowEdit", "WorkflowFlags", "WorkflowResponse", "WorkflowRevisionCommit", "WorkflowRevisionCreate", "WorkflowRevisionDataInput", "WorkflowRevisionDataInputHeadersValue", "WorkflowRevisionDataInputRuntime", "WorkflowRevisionDataOutput", "WorkflowRevisionDataOutputHeadersValue", "WorkflowRevisionDataOutputRuntime", "WorkflowRevisionEdit", "WorkflowRevisionFlags", "WorkflowRevisionInput", "WorkflowRevisionOutput", "WorkflowRevisionResolveResponse", "WorkflowRevisionResponse", "WorkflowRevisionsLog", "WorkflowRevisionsResponse", "WorkflowVariant", "WorkflowVariantCreate", "WorkflowVariantEdit", "WorkflowVariantFlags", "WorkflowVariantFork", "WorkflowVariantResponse", "WorkflowVariantsResponse", "WorkflowsResponse", "Workspace", "WorkspaceMemberResponse", "WorkspacePermission", "WorkspaceResponse"] +__all__ = ["AdminAccountCreateOptions", "AdminAccountRead", "AdminAccountsCreate", "AdminAccountsDelete", "AdminAccountsDeleteTarget", "AdminAccountsResponse", "AdminApiKeyCreate", "AdminApiKeyResponse", "AdminDeleteResponse", "AdminDeletedEntities", "AdminDeletedEntity", "AdminOrganizationCreate", "AdminOrganizationMembershipCreate", "AdminOrganizationMembershipRead", "AdminOrganizationRead", "AdminProjectCreate", "AdminProjectMembershipCreate", "AdminProjectMembershipRead", "AdminProjectRead", "AdminSimpleAccountCreate", "AdminSimpleAccountDeleteEntry", "AdminSimpleAccountRead", "AdminSimpleAccountsApiKeysCreate", "AdminSimpleAccountsCreate", "AdminSimpleAccountsDelete", "AdminSimpleAccountsOrganizationsCreate", "AdminSimpleAccountsOrganizationsMembershipsCreate", "AdminSimpleAccountsOrganizationsTransferOwnership", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjects", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjectsZero", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspaces", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspacesZero", "AdminSimpleAccountsOrganizationsTransferOwnershipResponse", "AdminSimpleAccountsProjectsCreate", "AdminSimpleAccountsProjectsMembershipsCreate", "AdminSimpleAccountsResponse", "AdminSimpleAccountsUsersCreate", "AdminSimpleAccountsUsersIdentitiesCreate", "AdminSimpleAccountsUsersResetPassword", "AdminSimpleAccountsWorkspacesCreate", "AdminSimpleAccountsWorkspacesMembershipsCreate", "AdminStructuredError", "AdminSubscriptionCreate", "AdminSubscriptionRead", "AdminUserCreate", "AdminUserIdentityCreate", "AdminUserIdentityRead", "AdminUserIdentityReadStatus", "AdminUserRead", "AdminWorkspaceCreate", "AdminWorkspaceMembershipCreate", "AdminWorkspaceMembershipRead", "AdminWorkspaceRead", "Analytics", "AnalyticsResponse", "Annotation", "AnnotationCreate", "AnnotationCreateLinks", "AnnotationEdit", "AnnotationEditLinks", "AnnotationLinkResponse", "AnnotationLinks", "AnnotationQuery", "AnnotationQueryLinks", "AnnotationResponse", "AnnotationsResponse", "Application", "ApplicationArtifactFlags", "ApplicationArtifactQueryFlags", "ApplicationCatalogPreset", "ApplicationCatalogPresetResponse", "ApplicationCatalogPresetsResponse", "ApplicationCatalogTemplate", "ApplicationCatalogTemplateResponse", "ApplicationCatalogTemplatesResponse", "ApplicationCatalogType", "ApplicationCatalogTypesResponse", "ApplicationCreate", "ApplicationEdit", "ApplicationFlags", "ApplicationQuery", "ApplicationResponse", "ApplicationRevisionCommit", "ApplicationRevisionCreate", "ApplicationRevisionDataInput", "ApplicationRevisionDataInputHeadersValue", "ApplicationRevisionDataInputRuntime", "ApplicationRevisionDataOutput", "ApplicationRevisionDataOutputHeadersValue", "ApplicationRevisionDataOutputRuntime", "ApplicationRevisionEdit", "ApplicationRevisionFlags", "ApplicationRevisionInput", "ApplicationRevisionOutput", "ApplicationRevisionQuery", "ApplicationRevisionQueryFlags", "ApplicationRevisionResolveResponse", "ApplicationRevisionResponse", "ApplicationRevisionsLog", "ApplicationRevisionsResponse", "ApplicationVariant", "ApplicationVariantCreate", "ApplicationVariantEdit", "ApplicationVariantFlags", "ApplicationVariantFork", "ApplicationVariantResponse", "ApplicationVariantsResponse", "ApplicationsResponse", "BodyConfigsFetchVariantsConfigsFetchPost", "Bucket", "CollectStatusResponse", "ComparisonOperator", "Condition", "ConditionOperator", "ConditionOptions", "ConditionValue", "ConfigResponseModel", "CustomModelSettingsDto", "CustomProviderDto", "CustomProviderKind", "CustomProviderSettingsDto", "DictOperator", "DiscoverResponse", "DiscoverResponseMethodsValue", "EeSrcModelsApiOrganizationModelsOrganization", "EntityRef", "Environment", "EnvironmentCreate", "EnvironmentEdit", "EnvironmentFlags", "EnvironmentQueryFlags", "EnvironmentResponse", "EnvironmentRevisionCommit", "EnvironmentRevisionCreate", "EnvironmentRevisionData", "EnvironmentRevisionDelta", "EnvironmentRevisionEdit", "EnvironmentRevisionInput", "EnvironmentRevisionOutput", "EnvironmentRevisionResolveResponse", "EnvironmentRevisionResponse", "EnvironmentRevisionsLog", "EnvironmentRevisionsResponse", "EnvironmentVariant", "EnvironmentVariantCreate", "EnvironmentVariantEdit", "EnvironmentVariantFork", "EnvironmentVariantResponse", "EnvironmentVariantsResponse", "EnvironmentsResponse", "ErrorPolicy", "EvaluationMetrics", "EvaluationMetricsCreate", "EvaluationMetricsIdsResponse", "EvaluationMetricsQuery", "EvaluationMetricsQueryScenarioIds", "EvaluationMetricsQueryTimestamps", "EvaluationMetricsRefresh", "EvaluationMetricsResponse", "EvaluationMetricsSetRequest", "EvaluationQueue", "EvaluationQueueCreate", "EvaluationQueueData", "EvaluationQueueEdit", "EvaluationQueueFlags", "EvaluationQueueIdResponse", "EvaluationQueueIdsResponse", "EvaluationQueueQuery", "EvaluationQueueQueryFlags", "EvaluationQueueResponse", "EvaluationQueueScenariosQuery", "EvaluationQueuesResponse", "EvaluationResult", "EvaluationResultCreate", "EvaluationResultIdResponse", "EvaluationResultIdsResponse", "EvaluationResultQuery", "EvaluationResultResponse", "EvaluationResultsResponse", "EvaluationResultsSetRequest", "EvaluationRun", "EvaluationRunCreate", "EvaluationRunDataConcurrency", "EvaluationRunDataInput", "EvaluationRunDataMapping", "EvaluationRunDataMappingColumn", "EvaluationRunDataMappingStep", "EvaluationRunDataOutput", "EvaluationRunDataStepInput", "EvaluationRunDataStepInputKey", "EvaluationRunDataStepInputOrigin", "EvaluationRunDataStepInputType", "EvaluationRunDataStepOutput", "EvaluationRunDataStepOutputOrigin", "EvaluationRunDataStepOutputType", "EvaluationRunEdit", "EvaluationRunFlags", "EvaluationRunIdResponse", "EvaluationRunIdsRequest", "EvaluationRunIdsResponse", "EvaluationRunQuery", "EvaluationRunQueryFlags", "EvaluationRunResponse", "EvaluationRunsResponse", "EvaluationScenario", "EvaluationScenarioCreate", "EvaluationScenarioEdit", "EvaluationScenarioIdResponse", "EvaluationScenarioIdsResponse", "EvaluationScenarioQuery", "EvaluationScenarioResponse", "EvaluationScenariosResponse", "EvaluationStatus", "Evaluator", "EvaluatorArtifactFlags", "EvaluatorArtifactQueryFlags", "EvaluatorCatalogPreset", "EvaluatorCatalogPresetResponse", "EvaluatorCatalogPresetsResponse", "EvaluatorCatalogTemplate", "EvaluatorCatalogTemplateResponse", "EvaluatorCatalogTemplatesResponse", "EvaluatorCatalogType", "EvaluatorCatalogTypesResponse", "EvaluatorCreate", "EvaluatorEdit", "EvaluatorFlags", "EvaluatorQuery", "EvaluatorResponse", "EvaluatorRevisionCommit", "EvaluatorRevisionCreate", "EvaluatorRevisionDataInput", "EvaluatorRevisionDataInputHeadersValue", "EvaluatorRevisionDataInputRuntime", "EvaluatorRevisionDataOutput", "EvaluatorRevisionDataOutputHeadersValue", "EvaluatorRevisionDataOutputRuntime", "EvaluatorRevisionEdit", "EvaluatorRevisionFlags", "EvaluatorRevisionInput", "EvaluatorRevisionOutput", "EvaluatorRevisionQuery", "EvaluatorRevisionQueryFlags", "EvaluatorRevisionResolveResponse", "EvaluatorRevisionResponse", "EvaluatorRevisionsLog", "EvaluatorRevisionsResponse", "EvaluatorTemplate", "EvaluatorTemplatesResponse", "EvaluatorVariant", "EvaluatorVariantCreate", "EvaluatorVariantEdit", "EvaluatorVariantFlags", "EvaluatorVariantFork", "EvaluatorVariantResponse", "EvaluatorVariantsResponse", "EvaluatorsResponse", "Event", "EventQuery", "EventType", "EventsQueryResponse", "ExistenceOperator", "FilteringInput", "FilteringInputConditionsItem", "FilteringOutput", "FilteringOutputConditionsItem", "Focus", "Folder", "FolderCreate", "FolderEdit", "FolderIdResponse", "FolderKind", "FolderQuery", "FolderQueryKinds", "FolderResponse", "FoldersResponse", "Format", "Formatting", typing.Any, typing.Any, "Header", "HttpValidationError", "InviteRequest", "Invocation", "InvocationCreate", "InvocationCreateLinks", "InvocationEdit", "InvocationEditLinks", "InvocationLinkResponse", "InvocationLinks", "InvocationQuery", "InvocationQueryLinks", "InvocationResponse", "InvocationsResponse", "JsonSchemasInput", "JsonSchemasOutput", typing.Any, typing.Any, "LegacyLifecycleDto", "ListApiKeysResponse", "ListOperator", "ListOptions", "LogicalOperator", "MetricSpec", "MetricType", "MetricsBucket", "NumericOperator", "OTelEventInput", "OTelEventInputTimestamp", "OTelEventOutput", "OTelEventOutputTimestamp", "OTelHashInput", "OTelHashOutput", "OTelLinkInput", "OTelLinkOutput", "OTelLinksResponse", "OTelReferenceInput", "OTelReferenceOutput", "OTelSpanKind", "OTelStatusCode", "OTelTracingRequest", "OTelTracingResponse", "OldAnalyticsResponse", "OrganizationDetails", "OrganizationDomainResponse", "OrganizationProviderResponse", "OrganizationUpdate", "OssSrcModelsApiOrganizationModelsOrganization", "Permission", "ProjectsResponse", "QueriesResponse", "Query", "QueryCreate", "QueryEdit", "QueryFlags", "QueryQueryFlags", "QueryResponse", "QueryRevision", "QueryRevisionCommit", "QueryRevisionCreate", "QueryRevisionDataInput", "QueryRevisionDataOutput", "QueryRevisionEdit", "QueryRevisionQuery", "QueryRevisionResponse", "QueryRevisionsLog", "QueryRevisionsResponse", "QueryVariant", "QueryVariantCreate", "QueryVariantEdit", "QueryVariantFork", "QueryVariantQuery", "QueryVariantResponse", "QueryVariantsResponse", "Reference", "ReferenceRequestModelInput", "ReferenceRequestModelOutput", "RequestType", "ResolutionInfo", "RetrievalInfo", "SecretDto", "SecretDtoData", "SecretKind", "SecretResponseDto", "SecretResponseDtoData", "Selector", "SessionIdsResponse", "SimpleApplication", "SimpleApplicationCreate", "SimpleApplicationDataInput", "SimpleApplicationDataInputHeadersValue", "SimpleApplicationDataInputRuntime", "SimpleApplicationDataOutput", "SimpleApplicationDataOutputHeadersValue", "SimpleApplicationDataOutputRuntime", "SimpleApplicationEdit", "SimpleApplicationFlags", "SimpleApplicationQuery", "SimpleApplicationQueryFlags", "SimpleApplicationResponse", "SimpleApplicationsResponse", "SimpleEnvironment", "SimpleEnvironmentCreate", "SimpleEnvironmentEdit", "SimpleEnvironmentQuery", "SimpleEnvironmentResponse", "SimpleEnvironmentsResponse", "SimpleEvaluation", "SimpleEvaluationCreate", "SimpleEvaluationData", "SimpleEvaluationDataApplicationSteps", "SimpleEvaluationDataApplicationStepsOneValue", "SimpleEvaluationDataEvaluatorSteps", "SimpleEvaluationDataEvaluatorStepsOneValue", "SimpleEvaluationDataQuerySteps", "SimpleEvaluationDataQueryStepsOneValue", "SimpleEvaluationDataTestsetSteps", "SimpleEvaluationDataTestsetStepsOneValue", "SimpleEvaluationEdit", "SimpleEvaluationIdResponse", "SimpleEvaluationQuery", "SimpleEvaluationResponse", "SimpleEvaluationsResponse", "SimpleEvaluator", "SimpleEvaluatorCreate", "SimpleEvaluatorDataInput", "SimpleEvaluatorDataInputHeadersValue", "SimpleEvaluatorDataInputRuntime", "SimpleEvaluatorDataOutput", "SimpleEvaluatorDataOutputHeadersValue", "SimpleEvaluatorDataOutputRuntime", "SimpleEvaluatorEdit", "SimpleEvaluatorFlags", "SimpleEvaluatorQuery", "SimpleEvaluatorQueryFlags", "SimpleEvaluatorResponse", "SimpleEvaluatorsResponse", "SimpleQueriesResponse", "SimpleQuery", "SimpleQueryCreate", "SimpleQueryEdit", "SimpleQueryQuery", "SimpleQueryResponse", "SimpleQueue", "SimpleQueueCreate", "SimpleQueueData", "SimpleQueueDataEvaluators", "SimpleQueueDataEvaluatorsOneValue", "SimpleQueueIdResponse", "SimpleQueueIdsResponse", "SimpleQueueKind", "SimpleQueueQuery", "SimpleQueueResponse", "SimpleQueueScenariosQuery", "SimpleQueueScenariosResponse", "SimpleQueueSettings", "SimpleQueuesResponse", "SimpleTestset", "SimpleTestsetCreate", "SimpleTestsetEdit", "SimpleTestsetQuery", "SimpleTestsetResponse", "SimpleTestsetsResponse", "SimpleTrace", "SimpleTraceChannel", "SimpleTraceCreate", "SimpleTraceCreateLinks", "SimpleTraceEdit", "SimpleTraceEditLinks", "SimpleTraceKind", "SimpleTraceLinkResponse", "SimpleTraceLinks", "SimpleTraceOrigin", "SimpleTraceQuery", "SimpleTraceQueryLinks", "SimpleTraceReferences", "SimpleTraceResponse", "SimpleTracesResponse", "SimpleWorkflow", "SimpleWorkflowCreate", "SimpleWorkflowDataInput", "SimpleWorkflowDataInputHeadersValue", "SimpleWorkflowDataInputRuntime", "SimpleWorkflowDataOutput", "SimpleWorkflowDataOutputHeadersValue", "SimpleWorkflowDataOutputRuntime", "SimpleWorkflowEdit", "SimpleWorkflowFlags", "SimpleWorkflowQuery", "SimpleWorkflowQueryFlags", "SimpleWorkflowResponse", "SimpleWorkflowsResponse", "SpanInput", "SpanInputEndTime", "SpanInputStartTime", "SpanOutput", "SpanOutputEndTime", "SpanOutputStartTime", "SpanResponse", "SpanType", "SpansNodeInput", "SpansNodeInputEndTime", "SpansNodeInputSpansValue", "SpansNodeInputStartTime", "SpansNodeOutput", "SpansNodeOutputEndTime", "SpansNodeOutputSpansValue", "SpansNodeOutputStartTime", "SpansResponse", "SpansTreeInput", "SpansTreeInputSpansValue", "SpansTreeOutput", "SpansTreeOutputSpansValue", "SsoProviderDto", "SsoProviderInfo", "SsoProviderSettingsDto", "SsoProviders", "StandardProviderDto", "StandardProviderKind", "StandardProviderSettingsDto", "Status", "StringOperator", "TestcaseInput", "TestcaseOutput", "TestcaseResponse", "TestcasesResponse", "Testset", "TestsetCreate", "TestsetEdit", "TestsetFlags", "TestsetQuery", "TestsetResponse", "TestsetRevision", "TestsetRevisionCommit", "TestsetRevisionCreate", "TestsetRevisionDataInput", "TestsetRevisionDataOutput", "TestsetRevisionDelta", "TestsetRevisionDeltaColumns", "TestsetRevisionDeltaRows", "TestsetRevisionEdit", "TestsetRevisionQuery", "TestsetRevisionResponse", "TestsetRevisionsLog", "TestsetRevisionsResponse", "TestsetVariant", "TestsetVariantCreate", "TestsetVariantEdit", "TestsetVariantFork", "TestsetVariantQuery", "TestsetVariantResponse", "TestsetVariantsResponse", "TestsetsResponse", "TextOptions", "ToolAuthScheme", "ToolCallData", "ToolCallFunction", "ToolCallResponse", "ToolCatalogAction", "ToolCatalogActionDetails", "ToolCatalogActionResponse", "ToolCatalogActionResponseAction", "ToolCatalogActionsResponse", "ToolCatalogActionsResponseActionsItem", "ToolCatalogIntegration", "ToolCatalogIntegrationDetails", "ToolCatalogIntegrationResponse", "ToolCatalogIntegrationResponseIntegration", "ToolCatalogIntegrationsResponse", "ToolCatalogIntegrationsResponseIntegrationsItem", "ToolCatalogProvider", "ToolCatalogProviderDetails", "ToolCatalogProviderResponse", "ToolCatalogProviderResponseProvider", "ToolCatalogProvidersResponse", "ToolCatalogProvidersResponseProvidersItem", "ToolConnection", "ToolConnectionCreate", "ToolConnectionCreateData", "ToolConnectionResponse", "ToolConnectionStatus", "ToolConnectionsResponse", "ToolProviderKind", "ToolResult", "ToolResultData", "TraceIdResponse", "TraceIdsResponse", "TraceInput", "TraceInputSpansValue", "TraceOutput", "TraceOutputSpansValue", "TraceRequest", "TraceResponse", "TraceType", "TracesRequest", "TracesResponse", "TracingQuery", "TriggerAuthScheme", "TriggerCatalogEvent", "TriggerCatalogEventDetails", "TriggerCatalogEventResponse", "TriggerCatalogEventsResponse", "TriggerCatalogIntegration", "TriggerCatalogIntegrationResponse", "TriggerCatalogIntegrationsResponse", "TriggerCatalogProvider", "TriggerCatalogProviderResponse", "TriggerCatalogProvidersResponse", "TriggerConnection", "TriggerConnectionCreate", "TriggerConnectionCreateData", "TriggerConnectionResponse", "TriggerConnectionStatus", "TriggerConnectionsResponse", "TriggerDeliveriesResponse", "TriggerDelivery", "TriggerDeliveryData", "TriggerDeliveryQuery", "TriggerDeliveryResponse", "TriggerEventAck", "TriggerProviderKind", "TriggerSchedule", "TriggerScheduleCreate", "TriggerScheduleData", "TriggerScheduleEdit", "TriggerScheduleFlags", "TriggerScheduleQuery", "TriggerScheduleResponse", "TriggerSchedulesResponse", "TriggerSubscription", "TriggerSubscriptionCreate", "TriggerSubscriptionData", "TriggerSubscriptionEdit", "TriggerSubscriptionFlags", "TriggerSubscriptionQuery", "TriggerSubscriptionResponse", "TriggerSubscriptionsResponse", "UserIdsResponse", "ValidationError", "ValidationErrorLocItem", "WebhookDeliveriesResponse", "WebhookDelivery", "WebhookDeliveryCreate", "WebhookDeliveryData", "WebhookDeliveryQuery", "WebhookDeliveryResponse", "WebhookDeliveryResponseInfo", "WebhookEventType", "WebhookProviderDto", "WebhookProviderSettingsDto", "WebhookSubscription", "WebhookSubscriptionCreate", "WebhookSubscriptionData", "WebhookSubscriptionDataAuthMode", "WebhookSubscriptionEdit", "WebhookSubscriptionFlags", "WebhookSubscriptionQuery", "WebhookSubscriptionResponse", "WebhookSubscriptionsResponse", "Windowing", "WindowingOrder", "Workflow", "WorkflowArtifactFlags", "WorkflowCatalogFlags", "WorkflowCatalogPreset", "WorkflowCatalogPresetResponse", "WorkflowCatalogPresetsResponse", "WorkflowCatalogTemplate", "WorkflowCatalogTemplateResponse", "WorkflowCatalogTemplatesResponse", "WorkflowCatalogType", "WorkflowCatalogTypeResponse", "WorkflowCatalogTypesResponse", "WorkflowCreate", "WorkflowEdit", "WorkflowFlags", "WorkflowResponse", "WorkflowRevisionCommit", "WorkflowRevisionCreate", "WorkflowRevisionDataInput", "WorkflowRevisionDataInputHeadersValue", "WorkflowRevisionDataInputRuntime", "WorkflowRevisionDataOutput", "WorkflowRevisionDataOutputHeadersValue", "WorkflowRevisionDataOutputRuntime", "WorkflowRevisionEdit", "WorkflowRevisionFlags", "WorkflowRevisionInput", "WorkflowRevisionOutput", "WorkflowRevisionResolveResponse", "WorkflowRevisionResponse", "WorkflowRevisionsLog", "WorkflowRevisionsResponse", "WorkflowVariant", "WorkflowVariantCreate", "WorkflowVariantEdit", "WorkflowVariantFlags", "WorkflowVariantFork", "WorkflowVariantResponse", "WorkflowVariantsResponse", "WorkflowsResponse", "Workspace", "WorkspaceMemberResponse", "WorkspacePermission", "WorkspaceResponse"] diff --git a/clients/python/agenta_client/types/permission.py b/clients/python/agenta_client/types/permission.py index 47c399c22a..770bec88d7 100644 --- a/clients/python/agenta_client/types/permission.py +++ b/clients/python/agenta_client/types/permission.py @@ -2,4 +2,4 @@ import typing -Permission = typing.Union[typing.Literal["read_system", "view_applications", "edit_application", "create_app_variant", "delete_app_variant", "modify_variant_configurations", "delete_application_variant", "run_service", "view_webhooks", "edit_webhooks", "view_secret", "edit_secret", "view_spans", "edit_spans", "view_folders", "edit_folders", "view_api_keys", "edit_api_keys", "view_app_environment_deployment", "edit_app_environment_deployment", "create_app_environment_deployment", "view_testset", "edit_testset", "create_testset", "delete_testset", "view_evaluation", "run_evaluations", "edit_evaluation", "create_evaluation", "delete_evaluation", "deploy_application", "view_workspace", "edit_workspace", "create_workspace", "delete_workspace", "modify_user_roles", "add_new_user_to_workspace", "edit_organization", "delete_organization", "add_new_user_to_organization", "reset_password", "view_billing", "edit_billing", "view_workflows", "edit_workflows", "run_workflows", "view_evaluators", "edit_evaluators", "view_environments", "edit_environments", "deploy_environments", "view_queries", "edit_queries", "view_testsets", "edit_testsets", "view_annotations", "edit_annotations", "view_invocations", "edit_invocations", "view_evaluation_runs", "edit_evaluation_runs", "view_evaluation_scenarios", "edit_evaluation_scenarios", "view_evaluation_results", "edit_evaluation_results", "view_evaluation_metrics", "edit_evaluation_metrics", "view_evaluation_queues", "edit_evaluation_queues", "view_events", "view_tools", "edit_tools", "run_tools"], typing.Any] +Permission = typing.Union[typing.Literal["read_system", "view_applications", "edit_application", "create_app_variant", "delete_app_variant", "modify_variant_configurations", "delete_application_variant", "run_service", "view_webhooks", "edit_webhooks", "view_secret", "edit_secret", "view_spans", "edit_spans", "view_folders", "edit_folders", "view_api_keys", "edit_api_keys", "view_app_environment_deployment", "edit_app_environment_deployment", "create_app_environment_deployment", "view_testset", "edit_testset", "create_testset", "delete_testset", "view_evaluation", "run_evaluations", "edit_evaluation", "create_evaluation", "delete_evaluation", "deploy_application", "view_workspace", "edit_workspace", "create_workspace", "delete_workspace", "modify_user_roles", "add_new_user_to_workspace", "edit_organization", "delete_organization", "add_new_user_to_organization", "reset_password", "view_billing", "edit_billing", "view_workflows", "edit_workflows", "run_workflows", "view_evaluators", "edit_evaluators", "view_environments", "edit_environments", "deploy_environments", "view_queries", "edit_queries", "view_testsets", "edit_testsets", "view_annotations", "edit_annotations", "view_invocations", "edit_invocations", "view_evaluation_runs", "edit_evaluation_runs", "view_evaluation_scenarios", "edit_evaluation_scenarios", "view_evaluation_results", "edit_evaluation_results", "view_evaluation_metrics", "edit_evaluation_metrics", "view_evaluation_queues", "edit_evaluation_queues", "view_events", "view_tools", "edit_tools", "run_tools", "view_triggers", "edit_triggers", "run_triggers"], typing.Any] diff --git a/clients/python/agenta_client/types/selector.py b/clients/python/agenta_client/types/selector.py new file mode 100644 index 0000000000..b3b460e4a2 --- /dev/null +++ b/clients/python/agenta_client/types/selector.py @@ -0,0 +1,32 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class Selector(UniversalBaseModel): + """ + Selector for extracting specific data from entities. + + Placed alongside Reference for data extraction from referenced entities. + + Fields: + - **key**: For environment revisions only. Navigates to data.references., + follows the entity pointer found there (e.g. workflow_revision), fetches that + entity, then applies path against its data. + - **path**: Dot notation path into the resolved entity's data. + If key is set, path applies to the secondary entity's data. + If key is not set, path applies directly to the referenced entity's data. + """ + key: typing.Optional[str] = None + path: typing.Optional[str] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_auth_scheme.py b/clients/python/agenta_client/types/trigger_auth_scheme.py new file mode 100644 index 0000000000..ce444673d9 --- /dev/null +++ b/clients/python/agenta_client/types/trigger_auth_scheme.py @@ -0,0 +1,5 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +TriggerAuthScheme = typing.Union[typing.Literal["oauth", "api_key"], typing.Any] diff --git a/clients/python/agenta_client/types/trigger_catalog_event.py b/clients/python/agenta_client/types/trigger_catalog_event.py new file mode 100644 index 0000000000..b7f2d4f739 --- /dev/null +++ b/clients/python/agenta_client/types/trigger_catalog_event.py @@ -0,0 +1,24 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class TriggerCatalogEvent(UniversalBaseModel): + key: str + name: str + description: typing.Optional[str] = None + provider: typing.Optional[str] = None + integration: typing.Optional[str] = None + categories: typing.Optional[typing.List[str]] = None + logo: typing.Optional[str] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_catalog_event_details.py b/clients/python/agenta_client/types/trigger_catalog_event_details.py new file mode 100644 index 0000000000..09002e8b37 --- /dev/null +++ b/clients/python/agenta_client/types/trigger_catalog_event_details.py @@ -0,0 +1,26 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class TriggerCatalogEventDetails(UniversalBaseModel): + key: str + name: str + description: typing.Optional[str] = None + provider: typing.Optional[str] = None + integration: typing.Optional[str] = None + categories: typing.Optional[typing.List[str]] = None + logo: typing.Optional[str] = None + trigger_config: typing.Optional[typing.Dict[str, typing.Any]] = None + payload: typing.Optional[typing.Dict[str, typing.Any]] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_catalog_event_response.py b/clients/python/agenta_client/types/trigger_catalog_event_response.py new file mode 100644 index 0000000000..ef437f179b --- /dev/null +++ b/clients/python/agenta_client/types/trigger_catalog_event_response.py @@ -0,0 +1,20 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .trigger_catalog_event_details import TriggerCatalogEventDetails + + +class TriggerCatalogEventResponse(UniversalBaseModel): + count: typing.Optional[int] = None + event: typing.Optional[TriggerCatalogEventDetails] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_catalog_events_response.py b/clients/python/agenta_client/types/trigger_catalog_events_response.py new file mode 100644 index 0000000000..f40e2dc364 --- /dev/null +++ b/clients/python/agenta_client/types/trigger_catalog_events_response.py @@ -0,0 +1,22 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .trigger_catalog_event import TriggerCatalogEvent + + +class TriggerCatalogEventsResponse(UniversalBaseModel): + count: typing.Optional[int] = None + total: typing.Optional[int] = None + cursor: typing.Optional[str] = None + events: typing.Optional[typing.List[TriggerCatalogEvent]] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_catalog_integration.py b/clients/python/agenta_client/types/trigger_catalog_integration.py new file mode 100644 index 0000000000..63c0b74040 --- /dev/null +++ b/clients/python/agenta_client/types/trigger_catalog_integration.py @@ -0,0 +1,26 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .trigger_auth_scheme import TriggerAuthScheme + + +class TriggerCatalogIntegration(UniversalBaseModel): + key: str + name: str + description: typing.Optional[str] = None + categories: typing.Optional[typing.List[str]] = None + logo: typing.Optional[str] = None + url: typing.Optional[str] = None + actions_count: typing.Optional[int] = None + auth_schemes: typing.Optional[typing.List[TriggerAuthScheme]] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_catalog_integration_response.py b/clients/python/agenta_client/types/trigger_catalog_integration_response.py new file mode 100644 index 0000000000..54c6498b09 --- /dev/null +++ b/clients/python/agenta_client/types/trigger_catalog_integration_response.py @@ -0,0 +1,20 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .trigger_catalog_integration import TriggerCatalogIntegration + + +class TriggerCatalogIntegrationResponse(UniversalBaseModel): + count: typing.Optional[int] = None + integration: typing.Optional[TriggerCatalogIntegration] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_catalog_integrations_response.py b/clients/python/agenta_client/types/trigger_catalog_integrations_response.py new file mode 100644 index 0000000000..d5c99c5c7a --- /dev/null +++ b/clients/python/agenta_client/types/trigger_catalog_integrations_response.py @@ -0,0 +1,22 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .trigger_catalog_integration import TriggerCatalogIntegration + + +class TriggerCatalogIntegrationsResponse(UniversalBaseModel): + count: typing.Optional[int] = None + total: typing.Optional[int] = None + cursor: typing.Optional[str] = None + integrations: typing.Optional[typing.List[TriggerCatalogIntegration]] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_catalog_provider.py b/clients/python/agenta_client/types/trigger_catalog_provider.py new file mode 100644 index 0000000000..551c5c1b45 --- /dev/null +++ b/clients/python/agenta_client/types/trigger_catalog_provider.py @@ -0,0 +1,22 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .trigger_provider_kind import TriggerProviderKind + + +class TriggerCatalogProvider(UniversalBaseModel): + key: TriggerProviderKind + name: str + description: typing.Optional[str] = None + integrations_count: typing.Optional[int] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_catalog_provider_response.py b/clients/python/agenta_client/types/trigger_catalog_provider_response.py new file mode 100644 index 0000000000..b49e1c75ff --- /dev/null +++ b/clients/python/agenta_client/types/trigger_catalog_provider_response.py @@ -0,0 +1,20 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .trigger_catalog_provider import TriggerCatalogProvider + + +class TriggerCatalogProviderResponse(UniversalBaseModel): + count: typing.Optional[int] = None + provider: typing.Optional[TriggerCatalogProvider] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_catalog_providers_response.py b/clients/python/agenta_client/types/trigger_catalog_providers_response.py new file mode 100644 index 0000000000..2153ace50f --- /dev/null +++ b/clients/python/agenta_client/types/trigger_catalog_providers_response.py @@ -0,0 +1,20 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .trigger_catalog_provider import TriggerCatalogProvider + + +class TriggerCatalogProvidersResponse(UniversalBaseModel): + count: typing.Optional[int] = None + providers: typing.Optional[typing.List[TriggerCatalogProvider]] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_connection.py b/clients/python/agenta_client/types/trigger_connection.py new file mode 100644 index 0000000000..2e2f0ea9ac --- /dev/null +++ b/clients/python/agenta_client/types/trigger_connection.py @@ -0,0 +1,42 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel, update_forward_refs +from .trigger_connection_status import TriggerConnectionStatus +from .trigger_provider_kind import TriggerProviderKind + + +class TriggerConnection(UniversalBaseModel): + flags: typing.Optional[typing.Dict[str, typing.Any]] = None + tags: typing.Optional[typing.Dict[str, typing.Any]] = None + meta: typing.Optional[typing.Dict[str, typing.Any]] = None + created_at: typing.Optional[dt.datetime] = None + updated_at: typing.Optional[dt.datetime] = None + deleted_at: typing.Optional[dt.datetime] = None + created_by_id: typing.Optional[str] = None + updated_by_id: typing.Optional[str] = None + deleted_by_id: typing.Optional[str] = None + name: typing.Optional[str] = None + description: typing.Optional[str] = None + slug: typing.Optional[str] = None + id: typing.Optional[str] = None + provider_key: TriggerProviderKind + integration_key: str + data: typing.Optional[typing.Dict[str, typing.Any]] = None + status: typing.Optional[TriggerConnectionStatus] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow +from .label_json_output import LabelJsonOutput # noqa: E402, I001 +from .full_json_output import FullJsonOutput # noqa: E402, I001 +update_forward_refs(TriggerConnection, FullJsonOutput=FullJsonOutput, LabelJsonOutput=LabelJsonOutput) diff --git a/clients/python/agenta_client/types/trigger_connection_create.py b/clients/python/agenta_client/types/trigger_connection_create.py new file mode 100644 index 0000000000..48ca735d88 --- /dev/null +++ b/clients/python/agenta_client/types/trigger_connection_create.py @@ -0,0 +1,33 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel, update_forward_refs +from .trigger_connection_create_data import TriggerConnectionCreateData +from .trigger_provider_kind import TriggerProviderKind + + +class TriggerConnectionCreate(UniversalBaseModel): + flags: typing.Optional[typing.Dict[str, typing.Any]] = None + tags: typing.Optional[typing.Dict[str, typing.Any]] = None + meta: typing.Optional[typing.Dict[str, typing.Any]] = None + name: typing.Optional[str] = None + description: typing.Optional[str] = None + slug: typing.Optional[str] = None + provider_key: TriggerProviderKind + integration_key: str + data: typing.Optional[TriggerConnectionCreateData] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow +from .label_json_input import LabelJsonInput # noqa: E402, I001 +from .full_json_input import FullJsonInput # noqa: E402, I001 +update_forward_refs(TriggerConnectionCreate, FullJsonInput=FullJsonInput, LabelJsonInput=LabelJsonInput) diff --git a/clients/python/agenta_client/types/trigger_connection_create_data.py b/clients/python/agenta_client/types/trigger_connection_create_data.py new file mode 100644 index 0000000000..e27e9b4346 --- /dev/null +++ b/clients/python/agenta_client/types/trigger_connection_create_data.py @@ -0,0 +1,20 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .trigger_auth_scheme import TriggerAuthScheme + + +class TriggerConnectionCreateData(UniversalBaseModel): + callback_url: typing.Optional[str] = None + auth_scheme: typing.Optional[TriggerAuthScheme] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_connection_response.py b/clients/python/agenta_client/types/trigger_connection_response.py new file mode 100644 index 0000000000..9760e4f7ea --- /dev/null +++ b/clients/python/agenta_client/types/trigger_connection_response.py @@ -0,0 +1,20 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .trigger_connection import TriggerConnection + + +class TriggerConnectionResponse(UniversalBaseModel): + count: typing.Optional[int] = None + connection: typing.Optional[TriggerConnection] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_connection_status.py b/clients/python/agenta_client/types/trigger_connection_status.py new file mode 100644 index 0000000000..2839cdcce4 --- /dev/null +++ b/clients/python/agenta_client/types/trigger_connection_status.py @@ -0,0 +1,18 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class TriggerConnectionStatus(UniversalBaseModel): + redirect_url: typing.Optional[str] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_connections_response.py b/clients/python/agenta_client/types/trigger_connections_response.py new file mode 100644 index 0000000000..44de9c728c --- /dev/null +++ b/clients/python/agenta_client/types/trigger_connections_response.py @@ -0,0 +1,20 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .trigger_connection import TriggerConnection + + +class TriggerConnectionsResponse(UniversalBaseModel): + count: typing.Optional[int] = None + connections: typing.Optional[typing.List[TriggerConnection]] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_deliveries_response.py b/clients/python/agenta_client/types/trigger_deliveries_response.py new file mode 100644 index 0000000000..c59c8b0016 --- /dev/null +++ b/clients/python/agenta_client/types/trigger_deliveries_response.py @@ -0,0 +1,20 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .trigger_delivery import TriggerDelivery + + +class TriggerDeliveriesResponse(UniversalBaseModel): + count: typing.Optional[int] = None + deliveries: typing.Optional[typing.List[TriggerDelivery]] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_delivery.py b/clients/python/agenta_client/types/trigger_delivery.py new file mode 100644 index 0000000000..2cfd999ae8 --- /dev/null +++ b/clients/python/agenta_client/types/trigger_delivery.py @@ -0,0 +1,32 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .status import Status +from .trigger_delivery_data import TriggerDeliveryData + + +class TriggerDelivery(UniversalBaseModel): + created_at: typing.Optional[dt.datetime] = None + updated_at: typing.Optional[dt.datetime] = None + deleted_at: typing.Optional[dt.datetime] = None + created_by_id: typing.Optional[str] = None + updated_by_id: typing.Optional[str] = None + deleted_by_id: typing.Optional[str] = None + id: typing.Optional[str] = None + status: Status + data: typing.Optional[TriggerDeliveryData] = None + subscription_id: typing.Optional[str] = None + schedule_id: typing.Optional[str] = None + event_id: str + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_delivery_data.py b/clients/python/agenta_client/types/trigger_delivery_data.py new file mode 100644 index 0000000000..14a3508a2a --- /dev/null +++ b/clients/python/agenta_client/types/trigger_delivery_data.py @@ -0,0 +1,23 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .reference import Reference + + +class TriggerDeliveryData(UniversalBaseModel): + event_key: typing.Optional[str] = None + references: typing.Optional[typing.Dict[str, typing.Optional[Reference]]] = None + inputs: typing.Optional[typing.Dict[str, typing.Any]] = None + result: typing.Optional[typing.Dict[str, typing.Any]] = None + error: typing.Optional[str] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_delivery_query.py b/clients/python/agenta_client/types/trigger_delivery_query.py new file mode 100644 index 0000000000..01087d4745 --- /dev/null +++ b/clients/python/agenta_client/types/trigger_delivery_query.py @@ -0,0 +1,22 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .status import Status + + +class TriggerDeliveryQuery(UniversalBaseModel): + status: typing.Optional[Status] = None + subscription_id: typing.Optional[str] = None + schedule_id: typing.Optional[str] = None + event_id: typing.Optional[str] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_delivery_response.py b/clients/python/agenta_client/types/trigger_delivery_response.py new file mode 100644 index 0000000000..be428f83c1 --- /dev/null +++ b/clients/python/agenta_client/types/trigger_delivery_response.py @@ -0,0 +1,20 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .trigger_delivery import TriggerDelivery + + +class TriggerDeliveryResponse(UniversalBaseModel): + count: typing.Optional[int] = None + delivery: typing.Optional[TriggerDelivery] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_event_ack.py b/clients/python/agenta_client/types/trigger_event_ack.py new file mode 100644 index 0000000000..5d2b3ad262 --- /dev/null +++ b/clients/python/agenta_client/types/trigger_event_ack.py @@ -0,0 +1,19 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class TriggerEventAck(UniversalBaseModel): + status: typing.Optional[str] = None + detail: typing.Optional[str] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_provider_kind.py b/clients/python/agenta_client/types/trigger_provider_kind.py new file mode 100644 index 0000000000..69e2e1d2e5 --- /dev/null +++ b/clients/python/agenta_client/types/trigger_provider_kind.py @@ -0,0 +1,5 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +TriggerProviderKind = typing.Union[typing.Literal["composio"], typing.Any] diff --git a/clients/python/agenta_client/types/trigger_schedule.py b/clients/python/agenta_client/types/trigger_schedule.py new file mode 100644 index 0000000000..9efc42aa96 --- /dev/null +++ b/clients/python/agenta_client/types/trigger_schedule.py @@ -0,0 +1,38 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel, update_forward_refs +from .trigger_schedule_data import TriggerScheduleData +from .trigger_schedule_flags import TriggerScheduleFlags + + +class TriggerSchedule(UniversalBaseModel): + flags: typing.Optional[TriggerScheduleFlags] = None + tags: typing.Optional[typing.Dict[str, typing.Any]] = None + meta: typing.Optional[typing.Dict[str, typing.Any]] = None + name: typing.Optional[str] = None + description: typing.Optional[str] = None + created_at: typing.Optional[dt.datetime] = None + updated_at: typing.Optional[dt.datetime] = None + deleted_at: typing.Optional[dt.datetime] = None + created_by_id: typing.Optional[str] = None + updated_by_id: typing.Optional[str] = None + deleted_by_id: typing.Optional[str] = None + id: typing.Optional[str] = None + data: TriggerScheduleData + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow +from .label_json_output import LabelJsonOutput # noqa: E402, I001 +from .full_json_output import FullJsonOutput # noqa: E402, I001 +update_forward_refs(TriggerSchedule, FullJsonOutput=FullJsonOutput, LabelJsonOutput=LabelJsonOutput) diff --git a/clients/python/agenta_client/types/trigger_schedule_create.py b/clients/python/agenta_client/types/trigger_schedule_create.py new file mode 100644 index 0000000000..5990b6ff93 --- /dev/null +++ b/clients/python/agenta_client/types/trigger_schedule_create.py @@ -0,0 +1,29 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel, update_forward_refs +from .trigger_schedule_data import TriggerScheduleData + + +class TriggerScheduleCreate(UniversalBaseModel): + flags: typing.Optional[typing.Dict[str, typing.Any]] = None + tags: typing.Optional[typing.Dict[str, typing.Any]] = None + meta: typing.Optional[typing.Dict[str, typing.Any]] = None + name: typing.Optional[str] = None + description: typing.Optional[str] = None + data: TriggerScheduleData + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow +from .label_json_input import LabelJsonInput # noqa: E402, I001 +from .full_json_input import FullJsonInput # noqa: E402, I001 +update_forward_refs(TriggerScheduleCreate, FullJsonInput=FullJsonInput, LabelJsonInput=LabelJsonInput) diff --git a/clients/python/agenta_client/types/trigger_schedule_data.py b/clients/python/agenta_client/types/trigger_schedule_data.py new file mode 100644 index 0000000000..97cd0eab3b --- /dev/null +++ b/clients/python/agenta_client/types/trigger_schedule_data.py @@ -0,0 +1,24 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .reference import Reference +from .selector import Selector + + +class TriggerScheduleData(UniversalBaseModel): + event_key: str + schedule: str + inputs_fields: typing.Optional[typing.Dict[str, typing.Any]] = None + references: typing.Optional[typing.Dict[str, typing.Optional[Reference]]] = None + selector: typing.Optional[Selector] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_schedule_edit.py b/clients/python/agenta_client/types/trigger_schedule_edit.py new file mode 100644 index 0000000000..32b3c91c1e --- /dev/null +++ b/clients/python/agenta_client/types/trigger_schedule_edit.py @@ -0,0 +1,31 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel, update_forward_refs +from .trigger_schedule_data import TriggerScheduleData +from .trigger_schedule_flags import TriggerScheduleFlags + + +class TriggerScheduleEdit(UniversalBaseModel): + flags: typing.Optional[TriggerScheduleFlags] = None + tags: typing.Optional[typing.Dict[str, typing.Any]] = None + meta: typing.Optional[typing.Dict[str, typing.Any]] = None + name: typing.Optional[str] = None + description: typing.Optional[str] = None + id: typing.Optional[str] = None + data: TriggerScheduleData + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow +from .label_json_input import LabelJsonInput # noqa: E402, I001 +from .full_json_input import FullJsonInput # noqa: E402, I001 +update_forward_refs(TriggerScheduleEdit, FullJsonInput=FullJsonInput, LabelJsonInput=LabelJsonInput) diff --git a/clients/python/agenta_client/types/trigger_schedule_flags.py b/clients/python/agenta_client/types/trigger_schedule_flags.py new file mode 100644 index 0000000000..b5e899d98b --- /dev/null +++ b/clients/python/agenta_client/types/trigger_schedule_flags.py @@ -0,0 +1,18 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class TriggerScheduleFlags(UniversalBaseModel): + is_active: typing.Optional[bool] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_schedule_query.py b/clients/python/agenta_client/types/trigger_schedule_query.py new file mode 100644 index 0000000000..54809a987e --- /dev/null +++ b/clients/python/agenta_client/types/trigger_schedule_query.py @@ -0,0 +1,19 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class TriggerScheduleQuery(UniversalBaseModel): + name: typing.Optional[str] = None + event_key: typing.Optional[str] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_schedule_response.py b/clients/python/agenta_client/types/trigger_schedule_response.py new file mode 100644 index 0000000000..b5ead4b207 --- /dev/null +++ b/clients/python/agenta_client/types/trigger_schedule_response.py @@ -0,0 +1,20 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .trigger_schedule import TriggerSchedule + + +class TriggerScheduleResponse(UniversalBaseModel): + count: typing.Optional[int] = None + schedule: typing.Optional[TriggerSchedule] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_schedules_response.py b/clients/python/agenta_client/types/trigger_schedules_response.py new file mode 100644 index 0000000000..5e209cbe58 --- /dev/null +++ b/clients/python/agenta_client/types/trigger_schedules_response.py @@ -0,0 +1,20 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .trigger_schedule import TriggerSchedule + + +class TriggerSchedulesResponse(UniversalBaseModel): + count: typing.Optional[int] = None + schedules: typing.Optional[typing.List[TriggerSchedule]] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_subscription.py b/clients/python/agenta_client/types/trigger_subscription.py new file mode 100644 index 0000000000..bf951f66ed --- /dev/null +++ b/clients/python/agenta_client/types/trigger_subscription.py @@ -0,0 +1,40 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel, update_forward_refs +from .trigger_subscription_data import TriggerSubscriptionData +from .trigger_subscription_flags import TriggerSubscriptionFlags + + +class TriggerSubscription(UniversalBaseModel): + flags: typing.Optional[TriggerSubscriptionFlags] = None + tags: typing.Optional[typing.Dict[str, typing.Any]] = None + meta: typing.Optional[typing.Dict[str, typing.Any]] = None + name: typing.Optional[str] = None + description: typing.Optional[str] = None + created_at: typing.Optional[dt.datetime] = None + updated_at: typing.Optional[dt.datetime] = None + deleted_at: typing.Optional[dt.datetime] = None + created_by_id: typing.Optional[str] = None + updated_by_id: typing.Optional[str] = None + deleted_by_id: typing.Optional[str] = None + id: typing.Optional[str] = None + connection_id: str + trigger_id: typing.Optional[str] = None + data: TriggerSubscriptionData + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow +from .label_json_output import LabelJsonOutput # noqa: E402, I001 +from .full_json_output import FullJsonOutput # noqa: E402, I001 +update_forward_refs(TriggerSubscription, FullJsonOutput=FullJsonOutput, LabelJsonOutput=LabelJsonOutput) diff --git a/clients/python/agenta_client/types/trigger_subscription_create.py b/clients/python/agenta_client/types/trigger_subscription_create.py new file mode 100644 index 0000000000..89f896df39 --- /dev/null +++ b/clients/python/agenta_client/types/trigger_subscription_create.py @@ -0,0 +1,30 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel, update_forward_refs +from .trigger_subscription_data import TriggerSubscriptionData + + +class TriggerSubscriptionCreate(UniversalBaseModel): + flags: typing.Optional[typing.Dict[str, typing.Any]] = None + tags: typing.Optional[typing.Dict[str, typing.Any]] = None + meta: typing.Optional[typing.Dict[str, typing.Any]] = None + name: typing.Optional[str] = None + description: typing.Optional[str] = None + connection_id: str + data: TriggerSubscriptionData + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow +from .label_json_input import LabelJsonInput # noqa: E402, I001 +from .full_json_input import FullJsonInput # noqa: E402, I001 +update_forward_refs(TriggerSubscriptionCreate, FullJsonInput=FullJsonInput, LabelJsonInput=LabelJsonInput) diff --git a/clients/python/agenta_client/types/trigger_subscription_data.py b/clients/python/agenta_client/types/trigger_subscription_data.py new file mode 100644 index 0000000000..b50dddf126 --- /dev/null +++ b/clients/python/agenta_client/types/trigger_subscription_data.py @@ -0,0 +1,24 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .reference import Reference +from .selector import Selector + + +class TriggerSubscriptionData(UniversalBaseModel): + event_key: str + trigger_config: typing.Optional[typing.Dict[str, typing.Any]] = None + inputs_fields: typing.Optional[typing.Dict[str, typing.Any]] = None + references: typing.Optional[typing.Dict[str, typing.Optional[Reference]]] = None + selector: typing.Optional[Selector] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_subscription_edit.py b/clients/python/agenta_client/types/trigger_subscription_edit.py new file mode 100644 index 0000000000..b2fb5d570e --- /dev/null +++ b/clients/python/agenta_client/types/trigger_subscription_edit.py @@ -0,0 +1,32 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel, update_forward_refs +from .trigger_subscription_data import TriggerSubscriptionData +from .trigger_subscription_flags import TriggerSubscriptionFlags + + +class TriggerSubscriptionEdit(UniversalBaseModel): + flags: typing.Optional[TriggerSubscriptionFlags] = None + tags: typing.Optional[typing.Dict[str, typing.Any]] = None + meta: typing.Optional[typing.Dict[str, typing.Any]] = None + name: typing.Optional[str] = None + description: typing.Optional[str] = None + id: typing.Optional[str] = None + connection_id: str + data: TriggerSubscriptionData + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow +from .label_json_input import LabelJsonInput # noqa: E402, I001 +from .full_json_input import FullJsonInput # noqa: E402, I001 +update_forward_refs(TriggerSubscriptionEdit, FullJsonInput=FullJsonInput, LabelJsonInput=LabelJsonInput) diff --git a/clients/python/agenta_client/types/trigger_subscription_flags.py b/clients/python/agenta_client/types/trigger_subscription_flags.py new file mode 100644 index 0000000000..27e06f8807 --- /dev/null +++ b/clients/python/agenta_client/types/trigger_subscription_flags.py @@ -0,0 +1,19 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class TriggerSubscriptionFlags(UniversalBaseModel): + is_active: typing.Optional[bool] = None + is_valid: typing.Optional[bool] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_subscription_query.py b/clients/python/agenta_client/types/trigger_subscription_query.py new file mode 100644 index 0000000000..1529014546 --- /dev/null +++ b/clients/python/agenta_client/types/trigger_subscription_query.py @@ -0,0 +1,20 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class TriggerSubscriptionQuery(UniversalBaseModel): + name: typing.Optional[str] = None + connection_id: typing.Optional[str] = None + event_key: typing.Optional[str] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_subscription_response.py b/clients/python/agenta_client/types/trigger_subscription_response.py new file mode 100644 index 0000000000..cd9f35126e --- /dev/null +++ b/clients/python/agenta_client/types/trigger_subscription_response.py @@ -0,0 +1,20 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .trigger_subscription import TriggerSubscription + + +class TriggerSubscriptionResponse(UniversalBaseModel): + count: typing.Optional[int] = None + subscription: typing.Optional[TriggerSubscription] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/trigger_subscriptions_response.py b/clients/python/agenta_client/types/trigger_subscriptions_response.py new file mode 100644 index 0000000000..026a8f2222 --- /dev/null +++ b/clients/python/agenta_client/types/trigger_subscriptions_response.py @@ -0,0 +1,20 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .trigger_subscription import TriggerSubscription + + +class TriggerSubscriptionsResponse(UniversalBaseModel): + count: typing.Optional[int] = None + subscriptions: typing.Optional[typing.List[TriggerSubscription]] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/webhook_subscription.py b/clients/python/agenta_client/types/webhook_subscription.py index 40a1bd97e6..b4fe7debaa 100644 --- a/clients/python/agenta_client/types/webhook_subscription.py +++ b/clients/python/agenta_client/types/webhook_subscription.py @@ -8,10 +8,11 @@ import pydantic from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel, update_forward_refs from .webhook_subscription_data import WebhookSubscriptionData +from .webhook_subscription_flags import WebhookSubscriptionFlags class WebhookSubscription(UniversalBaseModel): - flags: typing.Optional[typing.Dict[str, typing.Any]] = None + flags: typing.Optional[WebhookSubscriptionFlags] = None tags: typing.Optional[typing.Dict[str, typing.Any]] = None meta: typing.Optional[typing.Dict[str, typing.Any]] = None name: typing.Optional[str] = None diff --git a/clients/python/agenta_client/types/webhook_subscription_edit.py b/clients/python/agenta_client/types/webhook_subscription_edit.py index 901983b3fb..ed7db2bc1b 100644 --- a/clients/python/agenta_client/types/webhook_subscription_edit.py +++ b/clients/python/agenta_client/types/webhook_subscription_edit.py @@ -8,10 +8,11 @@ import pydantic from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel, update_forward_refs from .webhook_subscription_data import WebhookSubscriptionData +from .webhook_subscription_flags import WebhookSubscriptionFlags class WebhookSubscriptionEdit(UniversalBaseModel): - flags: typing.Optional[typing.Dict[str, typing.Any]] = None + flags: typing.Optional[WebhookSubscriptionFlags] = None tags: typing.Optional[typing.Dict[str, typing.Any]] = None meta: typing.Optional[typing.Dict[str, typing.Any]] = None name: typing.Optional[str] = None diff --git a/clients/python/agenta_client/types/webhook_subscription_flags.py b/clients/python/agenta_client/types/webhook_subscription_flags.py new file mode 100644 index 0000000000..0584e691f7 --- /dev/null +++ b/clients/python/agenta_client/types/webhook_subscription_flags.py @@ -0,0 +1,18 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class WebhookSubscriptionFlags(UniversalBaseModel): + is_active: typing.Optional[bool] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/webhooks/client.py b/clients/python/agenta_client/webhooks/client.py index f896e42cf8..ef70c5672b 100644 --- a/clients/python/agenta_client/webhooks/client.py +++ b/clients/python/agenta_client/webhooks/client.py @@ -214,6 +214,62 @@ def query_webhook_subscriptions(self, *, subscription: typing.Optional[WebhookSu _response = self._raw_client.query_webhook_subscriptions(subscription=subscription, include_archived=include_archived, windowing=windowing, request_options=request_options) return _response.data + def start_webhook_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> WebhookSubscriptionResponse: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + WebhookSubscriptionResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.webhooks.start_webhook_subscription( + subscription_id="subscription_id", + ) + """ + _response = self._raw_client.start_webhook_subscription(subscription_id, request_options=request_options) + return _response.data + + def stop_webhook_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> WebhookSubscriptionResponse: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + WebhookSubscriptionResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.webhooks.stop_webhook_subscription( + subscription_id="subscription_id", + ) + """ + _response = self._raw_client.stop_webhook_subscription(subscription_id, request_options=request_options) + return _response.data + def create_webhook_delivery(self, *, delivery: WebhookDeliveryCreate, request_options: typing.Optional[RequestOptions] = None) -> WebhookDeliveryResponse: """ Parameters @@ -554,6 +610,78 @@ async def main() -> None: _response = await self._raw_client.query_webhook_subscriptions(subscription=subscription, include_archived=include_archived, windowing=windowing, request_options=request_options) return _response.data + async def start_webhook_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> WebhookSubscriptionResponse: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + WebhookSubscriptionResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.webhooks.start_webhook_subscription( + subscription_id="subscription_id", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.start_webhook_subscription(subscription_id, request_options=request_options) + return _response.data + + async def stop_webhook_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> WebhookSubscriptionResponse: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + WebhookSubscriptionResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.webhooks.stop_webhook_subscription( + subscription_id="subscription_id", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.stop_webhook_subscription(subscription_id, request_options=request_options) + return _response.data + async def create_webhook_delivery(self, *, delivery: WebhookDeliveryCreate, request_options: typing.Optional[RequestOptions] = None) -> WebhookDeliveryResponse: """ Parameters diff --git a/clients/python/agenta_client/webhooks/raw_client.py b/clients/python/agenta_client/webhooks/raw_client.py index 2a9c6c634e..69ab6a60aa 100644 --- a/clients/python/agenta_client/webhooks/raw_client.py +++ b/clients/python/agenta_client/webhooks/raw_client.py @@ -301,6 +301,86 @@ def query_webhook_subscriptions(self, *, subscription: typing.Optional[WebhookSu raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + def start_webhook_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[WebhookSubscriptionResponse]: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[WebhookSubscriptionResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + f"webhooks/subscriptions/{jsonable_encoder(subscription_id)}/start",method="POST", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + WebhookSubscriptionResponse, + parse_obj_as( + type_ =WebhookSubscriptionResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def stop_webhook_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[WebhookSubscriptionResponse]: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[WebhookSubscriptionResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + f"webhooks/subscriptions/{jsonable_encoder(subscription_id)}/stop",method="POST", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + WebhookSubscriptionResponse, + parse_obj_as( + type_ =WebhookSubscriptionResponse, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + def create_webhook_delivery(self, *, delivery: WebhookDeliveryCreate, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[WebhookDeliveryResponse]: """ Parameters @@ -715,6 +795,86 @@ async def query_webhook_subscriptions(self, *, subscription: typing.Optional[Web raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + async def start_webhook_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[WebhookSubscriptionResponse]: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[WebhookSubscriptionResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + f"webhooks/subscriptions/{jsonable_encoder(subscription_id)}/start",method="POST", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + WebhookSubscriptionResponse, + parse_obj_as( + type_ =WebhookSubscriptionResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def stop_webhook_subscription(self, subscription_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[WebhookSubscriptionResponse]: + """ + Parameters + ---------- + subscription_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[WebhookSubscriptionResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + f"webhooks/subscriptions/{jsonable_encoder(subscription_id)}/stop",method="POST", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + WebhookSubscriptionResponse, + parse_obj_as( + type_ =WebhookSubscriptionResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + async def create_webhook_delivery(self, *, delivery: WebhookDeliveryCreate, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[WebhookDeliveryResponse]: """ Parameters diff --git a/docs/designs/gateway-triggers/findings.md b/docs/designs/gateway-triggers/findings.md new file mode 100644 index 0000000000..339a06d2a6 --- /dev/null +++ b/docs/designs/gateway-triggers/findings.md @@ -0,0 +1,292 @@ +# Gateway Triggers — Findings + +| Field | Value | +|-------|-------| +| Path | `docs/designs/gateway-triggers/` (whole PR — subscriptions, ingress, gateway, UI, schedules) | +| Branch | `gateway-triggers-all` (base `main`) | +| PR | [#4749](https://github.com/Agenta-AI/agenta/pull/4749) | +| Depth | deep | +| Synced | 2026-06-22 (scan-codebase + sync-findings against PR #4749) | +| Severity scheme | P0 / P1 / P2 / P3 | + +## Sources + +- **scan**: fresh-context review of the branch diff vs `origin/main` (3 parallel passes: triggers core+API, gateway+DAO+migrations, web+cron+docker). +- **sync**: CodeRabbit review on PR #4749 (27 inline comments; 5 already marked "Addressed in commit"; 2 CodeQL alerts). + +## Summary + +| ID | Sev | Status | Area | Summary | +|----|-----|--------|------|---------| +| F1 | P0 | fixed | Security | Composio signature compared as **hex**, but provider may send **base64**. **Resolved**: live events confirmed lowercase **hex** (5/5 `matched via HEX`, byte-exact `signed_bytes`, body_len=1068). Collapsed to hex-only; dropped the diagnostic dual-accept + base64 branch. | +| F2 | P1 | verified-ok | Security/Multitenancy | **Verified not fail-open**: ingress (`router.py:1526`) returns 401 on `verify_signature` False; the service returns False on missing signature AND unresolvable secret (`if not secret: return False`). The flagged `_verify_composio_signature` helper no longer exists — consolidated into the service. No path returns 200 on empty secret. | +| F3 | P1 | fixed | Multitenancy | DAO `ti_id` subscription lookups. **Fixed locally**: removed dead unscoped `get_subscription_by_trigger_id`; documented the surviving inbound-resolve method as the one sanctioned cross-project read. | +| F4 | P1 | fixed | Multitenancy | `gateway/connections/dao.py` provider-id lookup/activation. **Fixed locally**: `project_id` now mandatory through DAO→service→tools-service; OAuth callback fails if project_id unresolved. | +| F5 | P1 | fixed | Correctness | `TriggersService.__init__` accepted `Optional` deps then dereferenced them. **Fixed**: `triggers_dao`/`connections_service`/`workflows_service` now required (37 unguarded derefs; comp-root always supplies them). `schedule_dispatch_task` stays `Optional` — legitimately deferred-assigned in the comp-root and guarded at use. Signature unit test updated to pass the three (unused by that path). | +| F6 | P1 | fixed | API contract | `gateway/connections/interfaces.py` `Dict[str,Any]` returns. **Fixed locally**: `ConnectionStatusResponse` / `ConnectionRefreshResponse` DTOs. | +| F7 | P1 | fixed (auto) | Correctness | web `projectScopedParams()` lets `extra.project_id` override scoped value. **Unambiguous — fixed locally.** | +| F8 | P2 | fixed (auto) | Reliability | web catalog hooks auto-prefetch with no `isError` guard → tight failing-fetch loop. **Unambiguous — fixed locally.** | +| F9 | P1 | fixed | Correctness | `verify_signature` lossy `decode('utf-8', errors='replace')`. **Fixed locally**: signs over byte-exact `{id}.{ts}.` + body bytes. | +| F10 | P2 | fixed (auto) | Robustness | adapter `ensure_webhook` 409 retry path `again["items"][0]["secret"]` unguarded → `IndexError`/`KeyError` bypasses the `httpx.HTTPError` wrapper. **Fixed locally.** | +| F11 | P2 | fixed | Correctness | core `delete_subscription` bare `Exception`. **Fixed locally**: narrowed to typed `AdapterError` (best-effort provider cleanup); unexpected errors surface to the route's `@intercept_exceptions`. | +| F12 | P2 | fixed | Reliability | Enqueue with no timeout. **Fixed locally**: `asyncio.wait_for(..., 5s)` on all 3 PR `.kiq()` sites (ingress→503, schedule dispatch, webhook deliver). Eval-runner `.kiq()` left untouched (pre-existing, not in PR). | +| F13 | P2 | wontfix (dev-only) | Supply chain | composio bridge (`dispatcher_composio.py`, 7 compose files) runs unpinned `pip install composio httpx` at container start. It's a `with-tunnel`-profile **dev container** (the live tunnel that forwards verified events to ingress), not a shipped prod service. **User decision (2026-06-22): keep unpinned.** | +| F14 | P2 | fixed | API contract | catalog pagination **tuples**. **Fixed locally**: page DTOs across gateway + tools + triggers (integrations, actions, events) per decision "all catalog pagination". | +| F15 | P2 | fixed (auto) | Robustness | `catalog/registry.py` lookup uses truthiness instead of explicit missing-key detection. **Fixed locally.** | +| F16 | P2 | fixed | Testing | Extracted the drawer's selector-preview logic (`resolveSelectorPreview`/`previewValue`, JSONPath-lite + JSON Pointer) into pure `core/selectorPreview.ts` and added 12 unit tests beside the cron suite. The components themselves are thin React with no extractable logic; the package's vitest is `node`-env (no RTL/jsdom), matching the cron-extraction precedent. `ActiveToggle` render-testing would need DOM infra this codebase doesn't have. | +| F17 | P2 | fixed | Testing | Added `test_triggers_schedules_refresh.py` (15 tests): `_validate_schedule` contract + `refresh_schedules` fire-gate (croniter.match, per-tick dedup, deterministic event_id, failure→False). Signature rotation + dedup keys already covered (signature/dispatcher suites). Full-PUT edit preservation stays in acceptance coverage (needs DAO round-trip). | +| F18 | P2 | fixed | Testing | Wrapped all six lifecycle tests (oss+ee connections, ee subscriptions) in `try/finally` so the created connection/subscription is deleted even on assertion failure. | +| F19 | P3 | fixed (partial) | Security | Manual operator script. **Fixed locally**: removed the hardcoded public `webhook.site` default URL (`cmd_converge` now requires `AGENTA_WEBHOOK_URL`). Secret prints KEPT — they are the intended output of a manual dev tool (`cmd_register`), confirmed by user. | +| F20 | P3 | fixed | Migration | Made the `oss000000004` downgrade symmetric with the upgrade: only strips `is_active` from rows whose flags are exactly `{"is_active": true}` (the backfill's own output), so pre-existing/richer flags survive a rollback. | +| F21 | — | wontfix | Migration | In-place edit of `oss000000003` flagged by scan as Alembic-immutability violation — **this is the documented, decided strategy** (unreleased migration, fresh-DB-only, `--nuke` required). Not a bug. See Notes. | +| F22 | P3 | fixed | API contract | `connections/service.py` `type: ignore` dict assignment. **Fixed locally**: widened `ConnectionCreate.data` to `Union[ConnectionCreateData, Json]`; dropped the `type: ignore`. | +| F23 | P0 | wontfix (false-positive) | Correctness | "Schedule dispatch task never wired" — **verified false**: `routers.py:713` assigns `triggers_service.schedule_dispatch_task = _triggers_worker.dispatch_schedule` after worker construction. CodeRabbit saw only the `:684` ctor call, not the deferred assignment. | +| F24 | — | wontfix | Security | `/admin/triggers/schedules/refresh` no-auth posture **matches the platform convention** (evaluations `refresh_runs` — our reuse anchor — has the identical `# NO CHECK` comment; `/admin/*` is network-isolated, cron POSTs to `http://api:8000`). Not a new bug. | +| F25 | P1 | fixed | Correctness | Dispatcher had no `is_valid` gate. **Fixed**: invalid subscription → 409 failed delivery, no invoke; unit test strengthened (`invoke_workflow.assert_not_awaited`). | +| F26 | P1 | fixed | Correctness | `refresh_schedules()` now tracks `failures` and returns `failures == 0` so the caller sees non-200 on dropped runs. | +| F27 | P1 | fixed | Migration | `oss000000004` backfill now `jsonb_set(...) WHERE flags->>'is_active' IS NULL` — never overwrites an existing value. | +| F28 | P1 | fixed | Correctness | `WebhookSubscriptionEdit.flags` now `Optional[...] = None`; edit service merges existing DB flags when omitted (full-PUT-from-current). | +| F29 | P1 | fixed | Correctness | `MINUTE="${MINUTE:-0}"` guard added to **both** `triggers.sh` and `queries.sh`. | +| F30 | P2 | fixed | Reliability | Both OSS crons brought up to the EE cron pattern (bounded `--connect-timeout`/`--max-time`, curl-exit decode, HTTP-status check). | +| F31 | P2 | fixed | Correctness | New `_sync_provider_enabled` helper computes provider `enabled = is_active and is_valid`, used by edit/start-stop/refresh/revoke. | +| F32 | P2 | fixed | API contract | `TriggerScheduleInvalid` gains `schedule`/`reason` structured context; raise sites populate them. | +| F33 | P2 | fixed | Migration/Perf | Added `ix_trigger_deliveries_schedule_id_created_at` (+ downgrade) in `oss000000003`. | +| F34 | P3 | fixed | Testing | `test_triggers_schedules.py` list flows assert status before `.json()`. | +| F35 | P3 | fixed | Docs | `AGENTS.md` test-run example rewritten as `cd ` with an explicit area list. | +| F37 | P3 | fixed | Testing | Class already had `@_requires_composio`/`@_requires_connected_account`; added a `pytest.skip` when `_resolve_webhook_secret()` returns empty so a missing secret skips rather than 401-flaking the dedup assertion. | +| F38 | P0 | fixed | Wiring/Reliability | Triggers worker entrypoint crash-loops: the dispatcher refactor added a required `triggers_dao` kwarg to `TriggersWorker`, wired in `routers.py` but **missed in `worker_triggers.py`**. Ingress verified+enqueued every event (202) but nothing consumed the queue. **Found via live run** (worker container restarting every ~7s); **fixed locally** by passing `triggers_dao` (already constructed). Confirmed: worker boots, 2 deliveries written. | +| F39 | P2 | wontfix (convention) | Testing | The `os.getenv("COMPOSIO_API_KEY")` at line 24 is a pytest skip-gate, **identical** to the sibling `test_tools_connections.py:19` and every other trigger/tool acceptance test. "Do as other tests" = leave it; the shared `env` app-config object is for application code, not test-collection preconditions. CodeRabbit false positive. | +| F40 | P1 | fixed | Web/Build | `gatewayTrigger/core/types.ts` subscription schema defined `flags` twice (generic `jsonRecordSchema` + typed `triggerSubscriptionFlagsSchema`) → TS1117 duplicate-key, **broke `@agenta/entities` build** on the branch. Surfaced while building for F16. Removed the stray generic `flags`; the schedule schema's typed-flags-then-generic-tags/meta ordering is the correct pattern. | +| F41 | P0 | fixed | Hosting/Deploy | Triggers queue consumer (`entrypoints.worker_triggers`) wired into **every docker-compose variant** but **absent from the Helm chart** — no deployment template, helper, or values knob. Helm-deployed clusters would ingest+enqueue events (and tick schedules via the baked crontab) but have nothing consuming the queue → no delivery ever fires (the F38 gap, permanent on k8s). **Fixed locally**: added `worker-triggers-deployment.yaml` (modeled on worker-webhooks — image, commonEnv, init containers, NewRelic branch, pgrep liveness), `agenta.workerTriggers.enabled/.replicas` helpers, and the `workerTriggers` block in both values examples. Verified with `helm template` (all branches) + `helm lint` (0 failed). | + +## Notes + +- **F21 is not a defect.** `status.md` records the decision: the trigger tables are unreleased, no production DB has run `oss000000003`, so editing it in place (rather than stacking ALTERs) is intentional and requires `--nuke` on already-migrated dev DBs. Recorded only so the scan observation isn't re-raised every pass. +- **"Addressed in commit" CodeRabbit comments** (already fixed upstream, verify in working tree, then resolve threads): router `_verify_composio_signature` fail-closed (ce43b26), service edit provider-binding desync, `mappings.py` client-controlled `ti_id` overwrite, dispatcher persist-before-reraise short-circuit. These map to F2-adjacent items; not re-opened unless the working tree contradicts. +- Many findings (F4, F6, F11, F12, F14, F15, F19, F22) live in the **subscriptions/ingress/gateway** code shipped earlier in this same PR, not the schedules work — but they're in PR #4749's diff so they belong here. +- **`trigger_id` vs `event_id` naming verified (not a defect):** `trigger_id` = provider trigger-instance `ti_*` (`metadata.trigger_id`/`nano_id`), used only to resolve the subscription; `event_id` = per-delivery unique id (`metadata.id`), used only for dedup + the delivery key. Consistent end-to-end (ingress → worker → dispatcher). Schedules synthesize `event_id = "{schedule.id}:{timestamp}"` and carry no `trigger_id`. + +## Naming consistency pass (F36, user, 2026-06-22) + +Canonical glossary — six distinct things, each with one name: +gateway **connection ID** (`connection_id`, the `ca_*`), trigger **subscription ID** +(subscription `id`), **trigger ID** (the provider `ti_*`), trigger **delivery ID** +(delivery `id`), **event ID** (`metadata.id`, per-delivery), **event type** +(`metadata.trigger_slug`, the event kind). + +- **F36 — fixed.** Renamed our internal `ti_id` → `trigger_id` everywhere it's *our* field: DTO `TriggerSubscription.trigger_id`, DB column + index (`oss000000003`, in-place; nuke required), DBE/dbas/DAO/mappings/interface/service, web zod schema + tests. **Provider wire contract untouched** — the composio adapter and the ingress envelope still read Composio's own `metadata.trigger_id`/`nano_id`/`id` verbatim (Composio itself calls it `trigger_id`, so this aligns rather than conflicts). +- Event-resolution context (`$.event.*`) no longer exposes the trigger instance: dropped `trigger_id`, renamed `trigger_type` → **`event_type`**, added **`event_id`** (`metadata.id`). `TRIGGER_CONTEXT_FIELDS`, `_build_context`, the drawer preview, and the schedule-acceptance template (`$.event.timestamp`) updated to match. Requires a nuke/redeploy (DB column rename). + +## Decisions (user, 2026-06-22) + +- **F1/F9:** Don't blind-switch the encoding. Add code + debug logs that capture the raw event and try BOTH the current (hex) and suggested (base64) decode so a real event reveals which path errors. Diagnostic-first. +- **F3/F4:** Make `project_id` **mandatory** in all routes and services. The only allowed cross-project lookups are **explicit** exceptions (inbound Composio events resolving an unknown `ti_id`; admin routes). Document those explicitly. +- **F6/F14/F22:** No gateway code in this PR is released yet → avoid `Dict[str,Any]`/tuple/`type: ignore`; introduce proper DTOs now. +- **F11:** Use domain exceptions; fall back to the standard `suppress_exceptions`/`intercept_exceptions` decorators rather than a bare `except`. +- **F12:** Add the enqueue timeout, **5 seconds**, and apply it to **all** `.kiq()` enqueue sites, not just the ingress one. +- **F19:** Remove the secret-printing / public-URL bits **if the test does not need them**. +- Resolve/close clearly-stale or already-fixed PR threads **without** leaving comments. + +## Open Findings + +None — all findings (F1–F41) are resolved, verified-ok, or dispositioned wontfix. See Closed Findings. + +## Closed Findings + +### [CLOSED] F1 — Composio signature hex vs base64 (resolved against live events) +- **Origin** sync (CodeRabbit, critical) + scan · **Severity** P0 · **Status** fixed +- **Files** `api/oss/src/core/triggers/service.py` `verify_signature` +- **Fix applied** Diagnostic-first per decision: temporarily computed both hex and base64 digests, accepted either, logged which matched + raw inputs. Live Composio events then resolved it — **5/5 matched via HEX**, `force_refresh=False`, byte-exact `signed_bytes` (body_len=1068), never base64. Collapsed to hex-only: dropped the base64 branch, the dual-accept, the debug dump, and the now-unused `base64` import. Negative-path test (forged `deadbeef`) still rejects; signature unit suite unaffected (it signs with hex). + +### [CLOSED] F3 — `ti_id` subscription lookups lack tenant scope +- **Origin** sync + scan · **Severity** P1 · **Status** fixed +- **Files** `api/oss/src/dbs/postgres/triggers/dao.py`, `core/triggers/interfaces.py` +- **Fix applied** Removed the dead, unscoped `get_subscription_by_trigger_id` (no callers). The surviving `get_project_and_subscription_by_trigger_id` is documented as the **one sanctioned cross-project read** — the inbound-event exception per the F3/F4 decision (the event carries only `ti_*`, no tenant scope). + +### [CLOSED] F4 — Connection provider-id lookup/activation make `project_id` optional +- **Origin** sync (CodeRabbit) · **Severity** P1 · **Status** fixed +- **Files** `dbs/postgres/gateway/connections/dao.py`, `core/gateway/connections/{interfaces,service}.py`, `core/tools/service.py`, `apis/fastapi/tools/router.py` +- **Fix applied** `project_id` now mandatory through DAO → connections service → tools service. The OAuth callback fails with the error card if it can't resolve `project_id` from the signed state (never activates cross-project). + +### [CLOSED] F6 — `Dict[str, Any]` returns in gateway connections interface +- **Origin** sync (CodeRabbit) + scan · **Severity** P1 · **Status** fixed +- **Files** `core/gateway/connections/{dtos,interfaces}.py`, `.../providers/composio/adapter.py`, `core/gateway/connections/service.py` +- **Fix applied** Added `ConnectionStatusResponse` / `ConnectionRefreshResponse` DTOs; interface + adapter return them; service reads attributes instead of `.get(...)`. + +### [CLOSED] F7 — web `projectScopedParams()` lets `extra` override `project_id` +- **Origin** sync (CodeRabbit) + scan · **Severity** P1 · **Status** fixed +- **Files** `web/packages/agenta-entities/src/gatewayTrigger/api/client.ts` +- **Fix applied** Reordered spreads so scoped `project_id` always wins (`extra` first, scoped value last). Matches CodeRabbit's committable suggestion. + +### [CLOSED] F8 — web catalog hooks auto-prefetch with no `isError` guard +- **Origin** scan + sync (CodeRabbit) · **Severity** P2 · **Status** fixed +- **Files** `web/.../gatewayTrigger/hooks/useTriggerCatalogIntegrations.ts`, `useTriggerCatalogEvents.ts` +- **Fix applied** Added `&& !query.isError` to the prefetch effect guard (and dep array) in both hooks. + +### [CLOSED] F9 — `errors='replace'` corrupts signed payload +- **Origin** sync (CodeRabbit) · **Severity** P1 · **Status** fixed +- **Files** `api/oss/src/core/triggers/service.py` +- **Fix applied** Signs over byte-exact `f"{id}.{ts}.".encode() + body` (no lossy utf-8 decode). Folded into the F1 diagnostic rewrite. + +### [CLOSED] F10 — adapter 409 webhook-secret retry path unguarded +- **Origin** sync (CodeRabbit) · **Severity** P2 · **Status** fixed +- **Files** `api/oss/src/core/triggers/providers/composio/adapter.py` +- **Fix applied** Added `_first_webhook_secret` guard helper; the 409 retry raises `AdapterError` instead of `IndexError`/`KeyError` when no readable secret is returned. + +### [CLOSED] F11 — bare `Exception` swallow in `delete_subscription` +- **Origin** sync (CodeRabbit) + scan · **Severity** P2 · **Status** fixed +- **Files** `api/oss/src/core/triggers/service.py` +- **Fix applied** Narrowed to typed `AdapterError` (best-effort provider cleanup); unexpected exceptions now surface to the route's `@intercept_exceptions` — per the F11 decision. + +### [CLOSED] F12 — enqueue has no timeout/error shaping +- **Origin** sync (CodeRabbit) · **Severity** P2 · **Status** fixed +- **Files** `apis/fastapi/triggers/router.py`, `core/triggers/service.py`, `tasks/asyncio/webhooks/dispatcher.py` +- **Fix applied** `asyncio.wait_for(..., timeout=5s)` on all three PR `.kiq()` sites (ingress → 503; schedule dispatch + webhook deliver already catch+continue). Eval-runner `.kiq()` left untouched (pre-existing, not in this PR). + +### [CLOSED] F14 — tuple pagination returns instead of DTO (catalog) +- **Origin** sync (CodeRabbit) · **Severity** P2 · **Status** fixed +- **Files** `core/gateway/catalog/{dtos,interfaces,service}.py` + composio adapter; `core/tools/{dtos,interfaces,service}.py` + catalog adapter; `core/triggers/{dtos,interfaces,service}.py` + catalog adapter; both routers +- **Fix applied** Per "all catalog pagination" decision: `CatalogIntegrationsPage`, `ToolCatalogIntegrationsPage`, `ToolCatalogActionsPage`, `TriggerCatalogIntegrationsPage`, `TriggerCatalogEventsPage` DTOs replace every `Tuple[List[...], Optional[str], int]`. The two genuinely-internal DAO tuples (`get_project_and_subscription_by_trigger_id`, `fetch_active_schedules_with_project`) are intentionally kept. + +### [CLOSED] F15 — registry lookup uses truthiness, not explicit missing-key +- **Origin** sync (CodeRabbit) · **Severity** P2 · **Status** fixed +- **Files** `api/oss/src/core/gateway/catalog/registry.py` +- **Fix applied** `if provider_key not in self._adapters: raise` then index. + +### [CLOSED] F19 — public default URL in manual test +- **Origin** sync (CodeQL + CodeRabbit) · **Severity** P3 · **Status** fixed (partial) +- **Files** `api/oss/tests/manual/triggers/try_composio_triggers.py` +- **Fix applied** Removed the hardcoded public `webhook.site` default URL; `cmd_converge` now requires `AGENTA_WEBHOOK_URL` (matching `cmd_register`). Secret prints KEPT — they are the intended output of a manual operator script, confirmed by the user. + +### [CLOSED] F21 — in-place edit of `oss000000003` (scan flagged as Alembic violation) +- **Origin** scan · **Status** wontfix (documented decision) +- See Notes. The migration is unreleased; in-place edit is the chosen strategy. + +### [CLOSED] F22 — `type: ignore` dict assignment to DTO field +- **Origin** scan · **Severity** P3 · **Status** fixed +- **Files** `api/oss/src/core/gateway/connections/{dtos,service}.py` +- **Fix applied** Widened `ConnectionCreate.data` to `Optional[Union[ConnectionCreateData, Json]]` (the service builds a provider-shaped persistence dict); dropped the `# type: ignore`. + +### [CLOSED] F23 — Schedule dispatch task wiring (false positive) +- **Origin** sync (CodeRabbit, critical) · **Severity** P0 · **Status** wontfix (false-positive) +- **Files** `api/entrypoints/routers.py:713` +- **Verdict** CodeRabbit saw only the `TriggersService(...)` ctor at :684 and missed the deferred assignment at `:713` (`triggers_service.schedule_dispatch_task = _triggers_worker.dispatch_schedule`). The task IS wired; `refresh_schedules` does not hit the unconfigured branch. Validated green on nuke+redeploy. + +### [CLOSED] F25 — Dispatcher `is_valid` gate +- **Origin** sync (CodeRabbit) · **Severity** P1 · **Status** fixed (committed `af24dad`) +- **Files** `api/oss/src/tasks/asyncio/triggers/dispatcher.py` +- **Fix applied** `dispatch_subscription` writes a 409 failed delivery and returns before invoke when `is_valid` is false; unit test asserts `invoke_workflow` not awaited + status 409. + +### [CLOSED] F26 — `refresh_schedules` success after dispatch failures +- **Origin** sync (CodeRabbit) · **Severity** P1 · **Status** fixed (committed `af24dad`) +- **Files** `api/oss/src/core/triggers/service.py` +- **Fix applied** Counts `failures`; returns `failures == 0`. + +### [CLOSED] F27 — `oss000000004` backfill overwrites existing `is_active` +- **Origin** sync (CodeRabbit) · **Severity** P1 · **Status** fixed (committed `af24dad`) +- **Files** `.../oss000000004_add_webhook_subscription_flags.py` +- **Fix applied** `jsonb_set(...) WHERE flags IS NULL OR flags ->> 'is_active' IS NULL`. (Subsumes F20's upgrade concern.) + +### [CLOSED] F28 — Webhook edit resurrects paused subscriptions +- **Origin** sync (CodeRabbit) · **Severity** P1 · **Status** fixed (committed `af24dad`) +- **Files** `api/oss/src/apis/fastapi/webhooks/router.py` +- **Fix applied** Kept `flags` required (full-PUT, per the edits-are-full-PUT rule — reverted the optional/merge misstep); the `test_subscription` server-side builder now carries `flags=existing.flags`. The main edit route already passes the client's full body. + +### [CLOSED] F29 — `triggers.sh` aborts at `:00` minute +- **Origin** sync (CodeRabbit) · **Severity** P1 · **Status** fixed (committed `af24dad`) +- **Files** `api/oss/src/crons/triggers.sh`, `queries.sh` +- **Fix applied** `MINUTE="${MINUTE:-0}"` in both crons. + +### [CLOSED] F30 — cron curl masks failures / no timeouts +- **Origin** sync (CodeRabbit) · **Severity** P2 · **Status** fixed (committed `af24dad`) +- **Files** `api/oss/src/crons/triggers.sh`, `queries.sh` +- **Fix applied** Both OSS crons brought to the EE pattern (`--connect-timeout`/`--max-time`, curl-exit decode, HTTP-status check) rather than inventing a new `--fail` style. + +### [CLOSED] F31 — Provider enablement computed inconsistently +- **Origin** sync (CodeRabbit) · **Severity** P2 · **Status** fixed (committed `af24dad`) +- **Files** `api/oss/src/core/triggers/service.py` +- **Fix applied** `_sync_provider_enabled` helper computes `enabled = is_active and is_valid`, used by edit/start/stop/refresh/revoke. + +### [CLOSED] F32 — `TriggerScheduleInvalid` structured context +- **Origin** sync (CodeRabbit) · **Severity** P2 · **Status** fixed (committed `af24dad`) +- **Files** `api/oss/src/core/triggers/exceptions.py`, `service.py` +- **Fix applied** Added `schedule`/`reason`; raise sites populate them. + +### [CLOSED] F33 — Missing schedule-delivery ordering index +- **Origin** sync (CodeRabbit) · **Severity** P2 · **Status** fixed (committed `af24dad`) +- **Files** `.../oss000000003_...py` +- **Fix applied** Added `ix_trigger_deliveries_schedule_id_created_at` (+ downgrade). + +### [CLOSED] F34 — schedules test consumes body before status assert +- **Origin** sync (CodeRabbit) · **Severity** P3 · **Status** fixed (committed `af24dad`) +- **Files** `api/oss/tests/pytest/acceptance/triggers/test_triggers_schedules.py` +- **Fix applied** Assert status before `.json()` in both list flows. + +### [CLOSED] F35 — AGENTS.md test-run example syntax +- **Origin** sync (CodeRabbit) · **Severity** P3 · **Status** fixed (committed `af24dad`) +- **Files** `AGENTS.md` +- **Fix applied** Rewritten as `cd ` with an explicit area list. + +### [CLOSED] F36 — `ti_id` → `trigger_id` naming consistency +- **Origin** user · **Status** fixed (committed `af24dad`) +- **Fix applied** See the "Naming consistency pass" note above. Validated green on nuke+redeploy (1007 sdk / 1862 api / 158 services). + +### [CLOSED] F38 — Triggers worker entrypoint crash-loop (missing `triggers_dao`) +- **Origin** live run (manual E2E) · **Severity** P0 · **Status** fixed +- **Files** `api/entrypoints/worker_triggers.py` +- **Fix applied** The dispatcher refactor (dedup + `is_valid` 409 gate) added a required `triggers_dao` kwarg to `TriggersWorker.__init__`. `routers.py` was updated; `worker_triggers.py:94` was not, so the worker container raised `TypeError: ... missing 'triggers_dao'` and restarted every ~7s. Ingress kept returning 202 (verify + enqueue succeed) but nothing drained `queues:triggers` → no deliveries. Passed `triggers_dao=triggers_dao` (already constructed at line 58). Worker now boots (`Listening started`); confirmed against live DB — 2 deliveries written, one per path (subscription event + cron schedule). Tests didn't catch it: unit tests construct the worker with the dao directly; nothing exercises entrypoint wiring. + +### [CLOSED] F2 — Router signature fail-open on missing secret +- **Origin** sync (CodeRabbit) · **Severity** P1 · **Status** verified-ok +- **Files** `api/oss/src/apis/fastapi/triggers/router.py:1526` +- **Fix applied** Verified against HEAD — not fail-open. Ingress returns 401 when `verify_signature` is False; the service returns False on both missing signature and unresolvable secret (`if not secret: return False`). The flagged `_verify_composio_signature` helper no longer exists (consolidated into the service). No path returns 200 on empty secret. Thread resolved. + +### [CLOSED] F5 — `TriggersService` constructor accepts None deps then dereferences them +- **Origin** sync (CodeRabbit) + scan · **Severity** P1 · **Status** fixed +- **Files** `api/oss/src/core/triggers/service.py`, `api/oss/tests/pytest/unit/triggers/test_triggers_signature.py` +- **Fix applied** Made `triggers_dao`/`connections_service`/`workflows_service` required (37 unguarded derefs; the composition root always supplies them). `schedule_dispatch_task` stays `Optional` — it's legitimately assigned post-construction in the comp-root (`routers.py:713`) and guarded at use (`if self.schedule_dispatch_task is None`). Updated the signature unit test to pass the three (unused by that path). + +### [CLOSED] F16 — web unit tests for the trigger drawers +- **Origin** scan · **Severity** P2 · **Status** fixed +- **Files** `web/packages/agenta-entities/src/gatewayTrigger/core/selectorPreview.ts` (new), `tests/unit/gatewayTriggerSelectorPreview.test.ts` (new), `web/packages/agenta-entity-ui/.../TriggerSubscriptionDrawer.tsx` +- **Fix applied** Extracted the only real pure logic in the drawers — selector resolution (`resolveSelectorPreview`/`previewValue`, JSONPath-lite + JSON Pointer + escape decoding) — into `core/selectorPreview.ts`, mirroring the `core/cron.ts` precedent, and added 12 unit tests. The components are otherwise thin React; the package's vitest is `node`-env (no RTL/jsdom), so rendering `ActiveToggle`/drawers would require DOM infra this codebase doesn't use. Cron + API-client mapping already had unit coverage. + +### [CLOSED] F17 — api unit tests for the cron fire-gate +- **Origin** scan · **Severity** P2 · **Status** fixed +- **Files** `api/oss/tests/pytest/unit/triggers/test_triggers_schedules_refresh.py` (new) +- **Fix applied** 15 tests: `_validate_schedule` (accepts valid 5-field, rejects wrong field count / unparseable / non-string) + `refresh_schedules` (croniter.match gate fires only matching ticks, per-tick dedup skip, deterministic `event_id`, dispatch failure → False, no-timestamp/unconfigured-task → False). Signature rotation and dedup keys were already covered (signature + dispatcher suites); full-PUT edit preservation stays in acceptance coverage (needs a DAO round-trip). + +### [CLOSED] F18 — acceptance lifecycle tests leak provider state on failure +- **Origin** sync (CodeRabbit) · **Severity** P2 · **Status** fixed +- **Files** `api/oss/.../test_triggers_connections.py`, `api/ee/.../test_triggers_connections.py`, `api/ee/.../test_triggers_subscriptions.py` +- **Fix applied** Wrapped all six live-resource tests in `try/finally` so the created connection (and subscription) is deleted even when an assertion fails mid-test. + +### [CLOSED] F20 — `oss000000004` downgrade JSONB subtraction not symmetric +- **Origin** scan · **Severity** P3 · **Status** fixed +- **Files** `.../oss000000004_add_webhook_subscription_flags.py` +- **Fix applied** Downgrade now strips `is_active` only from rows whose flags are exactly `{"is_active": true}` (what the backfill created from NULL/empty), so rows carrying other flags or `is_active=false` keep their state on rollback. Mirrors the upgrade's "only touch what we added" intent. + +### [CLOSED] F37 — ingress dedup test precondition can 401-flake +- **Origin** sync (CodeRabbit, minor) · **Severity** P3 · **Status** fixed +- **Files** `api/oss/tests/pytest/acceptance/triggers/test_triggers_ingress.py` +- **Fix applied** The class already carried `@_requires_composio`/`@_requires_connected_account`; added a `pytest.skip` when `_resolve_webhook_secret()` returns empty, so a missing secret skips cleanly instead of 401-flaking the dedup assertion. + +### [CLOSED] F39 — connection test reads config via `os.getenv` (wontfix, convention) +- **Origin** sync (CodeRabbit, refactor) · **Severity** P2 · **Status** wontfix +- **Files** `api/oss/tests/pytest/acceptance/triggers/test_triggers_connections.py:24` +- **Fix applied** None — per "do as other tests". The `os.getenv("COMPOSIO_API_KEY")` is a pytest skip-gate identical to `test_tools_connections.py:19` and every sibling trigger/tool acceptance test. The shared `env` app-config object is for application code, not test-collection preconditions. CodeRabbit false positive; thread resolved. + +### [CLOSED] F40 — duplicate `flags` key breaks `@agenta/entities` build +- **Origin** build (surfaced during F16) · **Severity** P1 · **Status** fixed +- **Files** `web/packages/agenta-entities/src/gatewayTrigger/core/types.ts` +- **Fix applied** The subscription zod schema declared `flags` twice — generic `jsonRecordSchema` and typed `triggerSubscriptionFlagsSchema` — a TS1117 duplicate-key that failed `tsc --noEmit` for the whole package. Removed the stray generic `flags`, keeping the typed one (the schedule schema's typed-flags + generic-tags/meta ordering is the correct template; `tags`/`meta` are legitimately open `jsonRecordSchema` dicts). + +### [CLOSED] F13 — unpinned `pip install` in composio bridge (wontfix, dev-only) +- **Origin** sync (CodeRabbit) + scan · **Severity** P2 · **Status** wontfix +- **Files** `dispatcher_composio.py` invoked from 7 compose files (oss+ee `.dev`/`.gh`/`.gh.local`/`.gh.ssl`) +- **Fix applied** None. The composio bridge is a `with-tunnel`-profile dev container (the local tunnel that `composio.triggers.subscribe()`s and forwards verified events to ingress), not a shipped production service, so runtime `pip install composio httpx` reproducibility isn't a prod-supply-chain concern. **User decision (2026-06-22): keep unpinned.** Thread resolved. + +### [CLOSED] F24 — Schedule refresh admin endpoint skips auth/entitlement (wontfix) +- **Origin** sync (CodeRabbit) · **Severity** — · **Status** wontfix (convention) +- **Files** `api/oss/src/apis/fastapi/triggers/router.py:1426` +- **Fix applied** None. Verified to match the platform convention — the evaluations `refresh_runs` admin cron (our reuse anchor) carries the identical `# NO CHECK FOR PERMISSIONS / ENTITLEMENTS`; `/admin/*` is network-isolated and the cron POSTs to `http://api:8000`. Not a new bug. **User confirmed wontfix (2026-06-22).** Would only change as a platform-wide decision to add internal-auth to ALL `/admin/*` crons. Thread resolved. 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/schedules/plan.md b/docs/designs/gateway-triggers/schedules/plan.md new file mode 100644 index 0000000000..07317c5850 --- /dev/null +++ b/docs/designs/gateway-triggers/schedules/plan.md @@ -0,0 +1,290 @@ +# Trigger Schedules — Plan + +A cron-driven analogue to trigger subscriptions. A subscription fires when Composio delivers +an event; a **schedule** fires when our own cron tick matches its cron expression. Both emit +the same trigger event down the **same dispatch path** (worker → dispatcher → invoke → delivery). +The only runtime difference is the producer. + +This builds on the shipped (but unreleased) gateway-triggers domain in `core/triggers/`. Because +the trigger tables have **not been released**, the schema changes edit the **initial migration +`oss000000003` in place** rather than stacking new ALTER migrations. + +--- + +## 0. Decisions (locked) + +| Area | Decision | +|------|----------| +| Edition | OSS, alongside `core/triggers/` | +| Period | Cron expression (string), **5-field** (1-minute floor), **UTC**, every-minute allowed | +| Period validation | `croniter` at create/edit; reject unparseable / non-5-field as 422 | +| Fire gate | `croniter.match(schedule, trigger_datetime)` — stateless, idempotent, **skip on missed tick** (no catch-up) | +| Base tick | Every **1 minute** (reuse live-eval cadence) | +| Cron endpoint | `POST /admin/triggers/schedules/refresh?trigger_interval&trigger_datetime` | +| Cron wiring | New `crons/triggers.{sh,txt}` + OSS Dockerfile (dev + gh); shared `cron` container | +| Active query | Partial index `(project_id) WHERE flags->>'is_active'='true' AND deleted_at IS NULL` | +| Dispatcher | Lookup lifted **out** of `dispatch`; new signature `dispatch(*, project_id, entity, event_id, event)` | +| Gate semantics | `is_active=false` → silent skip; `is_valid=false` → **not** silent (failed-delivery path) | +| Deliveries | **Same `trigger_deliveries` table**; nullable `subscription_id` + new nullable `schedule_id`; XOR check | +| Typed flags | `TriggerSubscriptionFlags{is_active,is_valid}`, `TriggerScheduleFlags{is_active}` — nested models, no bare `Flags()` | +| Realign subs | `enabled/valid` → `is_active/is_valid` (code-layer; JSONB bag not enumerated in migration) | +| `ti_id` | Promoted to top-level indexed column, **removed from `data` JSONB** | +| Migration (triggers) | All trigger schema folded into `oss000000003` (edit-in-place; unreleased) | +| Migration (webhooks) | New `oss000000004` (core_oss) — data-only backfill `flags.is_active=true` (column already exists, released chain) | +| Play/pause | `/start` + `/stop` POST routes flipping `is_active`, on **all three** domains (trigger_subscriptions, trigger_schedules, webhook_subscriptions) | +| Webhook flags | `is_active` **only** — no `is_valid` (webhooks have no external connection / no validity concept) | +| Reuse | References via `_normalize_references` / `WorkflowsService.retrieve_workflow_revision`; FE drawer mirrors `TriggerSubscriptionDrawer` | + +### Flag matrix (per domain) + +| Domain | Flags | Migration | Why | +|--------|-------|-----------|-----| +| `trigger_subscriptions` | `is_active`, `is_valid` | code-layer rename in `oss000000003` shape | Composio connection can be revoked → `is_valid` | +| `trigger_schedules` | `is_active` | new table in `oss000000003` | no external connection | +| `webhook_subscriptions` | `is_active` | **new `oss000000004`** (data-only) | no external connection; just a URL — no validity concept | + +--- + +## 1. Work Packages — functional dependencies (the true DAG) + +Six units. Fan-in is real: a node can need two others. + +```text +WP0 ───────────────┬────────────────▶ WP2 ──────────┬─────────▶ WP3 ──────▶ WP4 +(migration: tables, │ (schedule DAO ▲ │ (refresh (cron + ti_id, deliveries) │ reads tables) │ │ service) wiring) + │ │ │ ▲ +WP1 ───────────────┘ │ │ │ +(DTOs: flags, ti_id, │ │ │ + schedule data) ──────────────────────┘ │ │ + │ │ │ + └──────────────────────▶ WPD ──────────────────┘ │ + (dispatcher refactor) ──────────┘ + +WP5 (web) ◀── WP3 (schedule CRUD API contract) +``` + +Edges (`X ← Y` reads "X functionally needs Y"): + +- **WP1 ← (none)** — pure DTO layer; defines the contracts everything else builds against. +- **WP0 ← WP1** — the migration column shapes (`ti_id`, `flags` keys, `schedule_id`) mirror the DTOs. +- **WP2 ← WP0, WP1** — schedule DAO/mappings read the new tables and (de)serialize the typed flags / `ti_id` column. +- **WPD ← WP1, WP2** — dispatcher refactor consumes the resolved entity (subscription **or** schedule) and the generalized delivery write. +- **WP3 ← WP2, WPD** — `refresh_schedules` iterates active schedules (DAO) and dispatches via the refactored dispatcher; schedule CRUD reuses `_normalize_references`. +- **WP4 ← WP3** — cron `.sh`/`.txt`/Dockerfile fire the `/admin/triggers/schedules/refresh` route added in WP3. +- **WP6 ← WP1, WPD** — `/start`·`/stop` flip `flags.is_active` (DTOs from WP1); webhook side also needs the WPD `is_active` dispatcher gate + the `oss000000004` flag backfill. Touches all three domains. +- **WP5 ← WP3, WP6** — web builds against the schedule CRUD contract (WP3) and the play/pause routes (WP6). + +**Critical path:** WP1 → WP0 → WP2 → WPD → WP3 → WP4. WP6 forks off {WP1, WPD}; WP5 forks off {WP3, WP6}. + +--- + +## 2. Migration — single edit to `oss000000003` (WP0) + +`oss000000003_add_trigger_subscriptions_and_deliveries.py` is the **initial, unreleased** +migration. Edit it in place; no backfill anywhere (no rows exist). New table order matters: +`trigger_schedules` must be created **before** `trigger_deliveries` so the new FK resolves. + +### 2.1 `trigger_subscriptions` — promote `ti_id` + +- Add column `ti_id` (`sa.String()`, nullable) — born top-level, never in JSONB. +- Add partial unique index: + ```sql + CREATE UNIQUE INDEX ix_trigger_subscriptions_ti_id + ON trigger_subscriptions (project_id, ti_id) + WHERE ti_id IS NOT NULL AND deleted_at IS NULL; + ``` +- Flags untouched in migration — `enabled→is_active`/`valid→is_valid` is a code-layer rename; + the `flags` JSONB bag is not enumerated here. + +### 2.2 `trigger_schedules` — new table (insert before deliveries) + +Mirrors `trigger_subscriptions` **minus** `connection_id` and `ti_id`. Columns: `project_id`, +`id`, `name`, `description`, `data` (JSON — holds `event_key`, `schedule` cron expr, +`inputs_fields`, `references`, `selector`), `flags` (JSONB), `meta`, `tags`, full lifecycle +(`created_at`/`updated_at`/`deleted_at` + `*_by_id`). FK `project_id → projects` CASCADE. +PK `(project_id, id)`. Indexes: + +- `ix_trigger_schedules_project_id_created_at` `(project_id, created_at)` +- `ix_trigger_schedules_project_id_deleted_at` `(project_id, deleted_at)` +- partial active index: + ```sql + CREATE INDEX ix_trigger_schedules_active + ON trigger_schedules (project_id) + WHERE (flags ->> 'is_active') = 'true' AND deleted_at IS NULL; + ``` + Predicate must match the DAO filter verbatim or Postgres won't use it. + +### 2.3 `trigger_deliveries` — generalize to both sources + +- `subscription_id` → `nullable=True`. +- Add `schedule_id` (`sa.UUID()`, nullable) + composite FK + `(project_id, schedule_id) → trigger_schedules(project_id, id)` CASCADE. +- XOR check constraint: + ```sql + CHECK ((subscription_id IS NULL) <> (schedule_id IS NULL)) + ``` +- Replace the single unique dedup index with **two partial unique indexes**: + ```sql + CREATE UNIQUE INDEX ix_trigger_deliveries_subscription_id_event_id + ON trigger_deliveries (project_id, subscription_id, event_id) + WHERE subscription_id IS NOT NULL; + CREATE UNIQUE INDEX ix_trigger_deliveries_schedule_id_event_id + ON trigger_deliveries (project_id, schedule_id, event_id) + WHERE schedule_id IS NOT NULL; + ``` + +### 2.4 `downgrade()` + +Mirror everything: drop the two partial dedup indexes + restore the single one, drop the XOR +check + `schedule_id` FK/column, restore `subscription_id` NOT NULL, drop `trigger_schedules` +(+ its indexes), drop the `ti_id` index + column. + +### 2.5 `oss000000004` — webhook flags (NEW core_oss migration, data-only) + +Webhooks are on the **separate, released `core` chain** (`f0a1b2c3d4e5_add_webhooks`, which +already has a child `ab12cd34ef56`), so they do **not** get edit-in-place. The +`webhook_subscriptions.flags` JSONB **column already exists** — this migration only backfills +values. Parents on `oss000000003` (current core_oss head); runs both editions. + +- `upgrade()`: backfill `is_active=true` on every live row, idempotent merge: + ```sql + UPDATE webhook_subscriptions + SET flags = COALESCE(flags, '{}'::jsonb) || '{"is_active": true}'::jsonb; + ``` +- `upgrade()` (cont.): add the partial active index (the dispatcher now gates on it): + ```sql + CREATE INDEX ix_webhook_subscriptions_active + ON webhook_subscriptions (project_id) + WHERE (flags ->> 'is_active') = 'true' AND deleted_at IS NULL; + ``` +- `downgrade()`: drop the index; strip the key + (`SET flags = flags - 'is_active'`). +- **No `is_valid`** — webhooks have no validity concept. + +--- + +## 3. Per-package detail + +### WP1 — DTOs & typed flags (`core/triggers/dtos.py`) + +- **`TriggerSubscriptionFlags`** `{is_active: bool = True, is_valid: bool = True}`. +- **`TriggerScheduleFlags`** `{is_active: bool = True}`. +- **`WebhookSubscriptionFlags`** `{is_active: bool = True}` (in `core/webhooks/types.py`) — webhooks have no validity concept, so **no `is_valid`**. +- **`TriggerSubscription`**: replace top-level `enabled`/`valid` with `flags: TriggerSubscriptionFlags`; + add top-level `ti_id: Optional[str]`. `TriggerSubscriptionData` loses `ti_id`. +- **`TriggerScheduleData`** `{event_key, schedule: str, inputs_fields, references, selector}`. +- **`TriggerSchedule(Identifier, Lifecycle, Header, Metadata)`** `{data, flags}` + `TriggerScheduleCreate/Edit/Query`. +- **`TriggerDelivery`/`TriggerDeliveryData`**: add nullable `schedule_id`; `subscription_id` nullable. +- Reuse the existing context-field allowlists; schedule reuses `TRIGGER_CONTEXT_FIELDS`. + +**AC:** flag models serialize to/from `{is_active, ...}`; no bare-bool top-level on subscription. + +### WP0 — Migration (see §2) + +**AC:** `alembic upgrade head` + `downgrade` clean, both editions (live DB / CI). + +### WP2 — DAO, DBE, mappings (`dbs/postgres/triggers/`) + +- `dbas.py`: `TriggerScheduleDBA` (mirror subscription DBA, drop `connection_id`); subscription DBA gains `ti_id` column. +- `dbes.py`: `TriggerScheduleDBE` (`__tablename__ = "trigger_schedules"`, indexes incl. partial active); deliveries DBE gains `schedule_id` FK + split dedup indexes + XOR check. +- `mappings.py`: typed-flag (de)serialization for both domains; stop stuffing `ti_id` into `data` (read/write the column; the PUT-preserve logic at mappings.py:107-111 moves to the column); schedule create/edit/read mappings. +- `dao.py`: `ti_id` lookups (dao.py:220, dao.py:246) filter the **column**; new `TriggerSchedulesDAO` with `fetch_active_schedules` (partial-index query), CRUD, generalized `write_delivery` (sets whichever FK applies). + +**AC:** dispatcher lookup is an index seek; `fetch_active_schedules` uses the partial index. + +### WPD — Dispatcher refactor (`tasks/asyncio/triggers/dispatcher.py`, `tasks/taskiq/triggers/worker.py`) + +- New signature: `dispatch(*, project_id, entity, event_id, event)` — lookup removed from the body. +- Body becomes entity-agnostic: gate on `entity.flags.is_active` (silent skip); `is_valid=false` + falls through to the existing failed-delivery branch; dedup + `_build_context` + invoke + + `write_delivery` shared verbatim. `write_delivery` sets `subscription_id` **or** `schedule_id` + from the entity type. +- **Composio path**: the *worker task* (not HTTP ingress — ingress must keep ack-fast) does the + `ti_id` → subscription lookup, then calls `dispatch`. +- **Schedule path**: `refresh_schedules` already holds the schedule row; enqueues a task that + calls `dispatch` directly — no lookup. +- **Webhook dispatcher** (`tasks/asyncio/webhooks/dispatcher.py`): add an `is_active` gate + (silent skip when paused). Today it has no flag gate at all — this is the runtime half of the + new webhook play/pause. + +**AC:** existing dispatcher unit tests pass after signature shift; Composio happy-path unchanged end-to-end; a paused webhook subscription does not dispatch. + +### WP3 — Service & router (`core/triggers/service.py`, `apis/fastapi/triggers/router.py`, `entrypoints/routers.py`) + +- Service: schedule CRUD reusing `_normalize_references` / `retrieve_workflow_revision`; + cron-expr validation (5-field, parseable) at create/edit; `refresh_schedules(timestamp, interval)` + → `fetch_active_schedules` → `croniter.match(schedule, trigger_datetime)` gate → build envelope → enqueue dispatch. + Subscription reads change `existing.enabled` → `existing.flags.is_active`, `existing.data.ti_id` → `existing.ti_id`. +- Router: schedule CRUD routes + admin `POST /refresh`; register admin router under `/admin/triggers/schedules` in `entrypoints/routers.py`. + +**AC:** schedule CRUD round-trips; refresh dispatches only schedules whose cron matches the tick. + +### WP4 — Cron wiring (`crons/`, OSS Dockerfiles, `pyproject.toml`) + +- `api/oss/src/crons/triggers.sh` (mirror `queries.sh`; POST to `/admin/triggers/schedules/refresh`). +- `api/oss/src/crons/triggers.txt` (`* * * * *` — every minute). +- OSS `Dockerfile.dev` + `Dockerfile` (gh): copy `triggers.sh`/`triggers.txt` into the crontab pipeline; compose `cron` service volume mount for `triggers.sh`. +- Add `croniter` to `api/pyproject.toml` base deps. + +**AC:** cron container fires the endpoint each minute; the schedule whose cron matches actually invokes its workflow (observed delivery row). + +### WP6 — Play/pause (`/start` + `/stop`) across all three domains + +A start/stop pair (POST a verb to flip `is_active`), following the existing noun-verb route +shape (`/revoke`, live-eval `/close`+`/open`). `/start` sets `is_active=true`, `/stop` sets +`is_active=false`. Per-item only (`/{id}/start`, `/{id}/stop`). + +- **`trigger_subscriptions`** (`apis/fastapi/triggers/router.py` + service): `/subscriptions/{id}/start|stop`. +- **`trigger_schedules`** (same router/service): `/schedules/{id}/start|stop`. +- **`webhook_subscriptions`** (`apis/fastapi/webhooks/router.py` + `core/webhooks/service.py` + mappings): + `/subscriptions/{id}/start|stop`. This is the bulk of the webhook work — webhook mappings + **currently ignore `flags` entirely**, so they must start (de)serializing `WebhookSubscriptionFlags`. +- Toggle is a focused flag flip (full-PUT semantics on these non-git entities: source the full + entity, override only `flags.is_active`). + +**AC:** `/start`/`/stop` flips `is_active` on each domain; a stopped subscription/schedule does +not dispatch (trigger via dispatcher gate, webhook via WPD gate, schedule via `fetch_active_schedules`). + +### WP5 — Web (`agenta-entity-ui/src/gatewayTrigger/`) + +The web extension covers **three** surfaces, all in the gateway-trigger entity-ui package: + +1. **Schedule drawer** — create/edit, mirroring `TriggerSubscriptionDrawer.tsx`. Swap the + Composio event picker for a **cron-expression field** (with a human-readable "next run" + hint validated client-side); reuse the reference-family build from `runnable/deploy.ts` + (application/evaluator/environment families) exactly as the subscription drawer does. +2. **Schedules list/table** — mirror the subscriptions list; columns: name, cron expr (rendered + human-readable), bound workflow, `is_active` state, last delivery. Row actions include + **play/pause**. +3. **Play/pause control** — a toggle on each row/drawer for **all three** entity types + (subscriptions, schedules, webhooks) calling the WP6 `/start`·`/stop` routes; optimistic + update of `flags.is_active`. Subscription/webhook drawer prefill reads `flags.is_active` + (subscription rename `enabled` → `flags.is_active`). + +Data layer: schedule query/mutation atoms mirroring the subscription atoms (list, get, create, +edit, delete, start, stop); deliveries view reused (delivery rows now carry `schedule_id`). + +**AC:** create/edit a schedule from the UI (references sent as the full prefixed family, edit +prefills correctly); play/pause toggles state on all three entity types; schedules list renders +cron + state. + +--- + +## 4. Test plan + +| Layer | Coverage | +|-------|----------| +| Unit | cron-expr validation (valid / non-5-field / unparseable); fire-gate `croniter.match` boundary cases; `TriggerScheduleFlags`/`TriggerSubscriptionFlags`/`WebhookSubscriptionFlags` (de)serialization; dispatcher refactor (entity-agnostic gate + delivery FK selection); webhook `is_active` gate | +| Acceptance | schedule CRUD round-trip (both editions per test convention); `/admin/triggers/schedules/refresh` dispatches matching schedules only; delivery row written with `schedule_id`; `/start`·`/stop` flips `is_active` and a stopped entity does not dispatch (all three domains) | +| Migration | `upgrade`/`downgrade` clean, both editions (live DB / CI) | + +OSS uses `cls_account`; EE uses inline business+developer account. + +--- + +## 5. Out of scope (deferred) + +- Webhooks gain `is_active` + play/pause (WP6), but **no `is_valid`** and no other lifecycle work. +- No per-schedule timezone (UTC only); no missed-tick catch-up; no arbitrary start anchor. +- No stored watermark / tick counter — the fire gate is a pure function of `trigger_datetime`. diff --git a/docs/designs/gateway-triggers/schedules/status.md b/docs/designs/gateway-triggers/schedules/status.md new file mode 100644 index 0000000000..e47cc483a9 --- /dev/null +++ b/docs/designs/gateway-triggers/schedules/status.md @@ -0,0 +1,100 @@ +# Trigger Schedules — Status + +Cron-driven analogue to trigger subscriptions. See `plan.md` for the full work breakdown. + +| Field | Value | +|-------|-------| +| State | WP1 CONTRACT DONE (in working tree) — Wave 1 (WP0/WP2/WPD/WP3/WP4/WP6/WP5) ready to fan out | +| Domain | OSS, `core/triggers/` (extends shipped-but-unreleased gateway-triggers) | +| Migration strategy | Triggers: edit `oss000000003` in place; Webhooks: new data-only `oss000000004` | +| Dispatch reuse | Same worker → dispatcher → invoke → delivery path; producer differs only | +| Code written | **WP1 only** — `core/triggers/dtos.py` + `exceptions.py` (frozen contract) | +| Orchestration | `wp/contracts.md` (frozen WP1), `wp/orchestration.md` (two-wave plan + broken call sites), `wp/WP{0,2,D,3,4,5,6}-spec.md` | + +## Parallel build plan (two waves) + +- **Wave 0 (done):** WP1 DTOs + exceptions written serially as the frozen contract. This + intentionally broke reads of `subscription.enabled`/`.valid`/`.data.ti_id` — fixed in Wave 1. +- **Wave 1 (fan out after compact):** spawn a subagent per WP against `wp/contracts.md` + + `wp/WP*-spec.md`. WP4 + WP5 are fully independent; the rest code against documented signatures + and stitch. Verification steps in `wp/orchestration.md`. + +## Work packages + +| WP | Unit | Depends on | State | +|----|------|-----------|-------| +| WP1 | DTOs: typed flags, `ti_id` top-level, `TriggerSchedule*` | — | ☐ not started | +| WP0 | Migration: edit `oss000000003` (tables, `ti_id`, deliveries) | WP1 | ☐ not started | +| WP2 | DAO / DBE / mappings (schedules + `ti_id` column + flags) | WP0, WP1 | ☐ not started | +| WPD | Dispatcher refactor (entity-agnostic `dispatch`) | WP1, WP2 | ☐ not started | +| WP3 | Service `refresh_schedules` + CRUD + admin route | WP2, WPD | ☐ not started | +| WP4 | Cron wiring (`triggers.sh/.txt`, Dockerfile, `croniter` dep) | WP3 | ☐ not started | +| WP6 | Play/pause `/start`·`/stop` (3 domains) + webhook `is_active` migration `oss000000004` | WP1, WPD | ☐ not started | +| WP5 | Web: schedule drawer + list + play/pause (3 domains) + prefill rename | WP3, WP6 | ☐ not started | + +**Critical path:** WP1 → WP0 → WP2 → WPD → WP3 → WP4. WP6 forks off {WP1, WPD}; WP5 forks off {WP3, WP6}. + +## Checklist + +- [ ] WP1: `TriggerSubscriptionFlags{is_active,is_valid}`, `TriggerScheduleFlags{is_active}`, `WebhookSubscriptionFlags{is_active}` (no `is_valid`) +- [ ] WP1: `ti_id` → top-level `TriggerSubscription` field (out of `TriggerSubscriptionData`) +- [ ] WP1: `TriggerScheduleData{event_key, schedule, inputs_fields, references, selector}` + `TriggerSchedule*` CRUD DTOs +- [ ] WP1: `TriggerDelivery` gains nullable `schedule_id`; `subscription_id` nullable +- [ ] WP0: `oss000000003` — `ti_id` column + partial unique index on `trigger_subscriptions` +- [ ] WP0: `oss000000003` — new `trigger_schedules` table (before deliveries) + partial active index +- [ ] WP0: `oss000000003` — deliveries `subscription_id` nullable, `schedule_id` + FK + XOR check + split dedup indexes +- [ ] WP0: `downgrade()` mirrors all of the above +- [ ] WP2: `TriggerScheduleDBE` + `TriggerSchedulesDAO.fetch_active_schedules` (partial-index query) +- [ ] WP2: `ti_id` lookups (dao.py:220,246) filter the column; mappings stop stuffing `ti_id` into `data` +- [ ] WP2: typed-flag (de)serialization both domains; generalized `write_delivery` +- [ ] WPD: `dispatch(*, project_id, entity, event_id, event)`; lookup moves to worker task +- [ ] WPD: `is_active` silent skip; `is_valid=false` → failed-delivery path +- [ ] WP3: cron-expr validation (5-field, parseable) at create/edit +- [ ] WP3: `refresh_schedules` + `croniter.match` fire gate +- [ ] WP3: schedule CRUD routes + `/admin/triggers/schedules/refresh`; register admin router +- [ ] WP3: subscription reads `enabled→flags.is_active`, `data.ti_id→ti_id` +- [ ] WP4: `crons/triggers.{sh,txt}` + OSS Dockerfile (dev + gh) + compose mount +- [ ] WP4: `croniter` in `api/pyproject.toml` +- [ ] WP6: `/start`·`/stop` routes (flip `is_active`) on trigger_subscriptions, trigger_schedules, webhook_subscriptions +- [ ] WP6: webhook mappings start (de)serializing `flags` (currently ignored); webhook dispatcher `is_active` gate +- [ ] WP6: migration `oss000000004` (core_oss) — backfill `webhook_subscriptions.flags.is_active=true` + partial active index +- [ ] WP5: schedule drawer mirroring `TriggerSubscriptionDrawer`; schedules list/table; play/pause control (3 domains); subscription prefill rename +- [ ] Tests: unit (cron validation, fire gate, flag models incl. webhook, dispatcher + webhook gate) + acceptance (CRUD, refresh, start/stop ×3), both editions +- [ ] Migration up/down clean, both editions (live DB / CI) + +## Decisions + +- [x] Period = **cron expression**, 5-field, UTC, 1-minute floor, every-minute allowed; validated via `croniter`. +- [x] Fire gate = `croniter.match(schedule, trigger_datetime)` — stateless, idempotent, **skip on missed tick** (no catch-up). +- [x] Base tick = **1 minute** (reuse live-eval cadence). +- [x] Cron endpoint named `/admin/triggers/schedules/refresh` (matches `/admin//refresh` convention). +- [x] Active-schedule query backed by a **partial index** (`flags->>'is_active'='true' AND deleted_at IS NULL`); predicate matches DAO filter exactly. +- [x] Dispatcher refactor (**X.2**): lift the `ti_id` lookup out of `dispatch`; pass the resolved entity in. Composio worker task does the lookup; refresh service passes the schedule row directly. Steps 2–5 shared verbatim. +- [x] Gate semantics: `is_active=false` → silent skip; `is_valid=false` → **not** silent (failed-delivery path, for visibility). +- [x] Deliveries reuse the **same `trigger_deliveries` table**; nullable `subscription_id` + new nullable `schedule_id`; XOR check `(subscription_id IS NULL) <> (schedule_id IS NULL)`; two partial unique dedup indexes. +- [x] Typed flag models per domain (`TriggerSubscriptionFlags`, `TriggerScheduleFlags`) — **no bare `Flags()`**, no top-level hoisted bools. +- [x] Realign existing trigger subscriptions `enabled/valid → is_active/is_valid` (code-layer; webhooks left untouched, still flag-less). +- [x] Promote `ti_id` to a top-level indexed column, **fully removed from `data` JSONB** (clean, no drift). +- [x] **Trigger schema: no new migration** — fold into the initial `oss000000003` (unreleased; edit in place). `trigger_schedules` created before `trigger_deliveries` so the FK resolves. +- [x] **Webhook flags: separate `oss000000004`** (core_oss, data-only). Webhooks are on the released `core` chain and already have a `flags` column, so this only backfills `is_active=true` (+ partial index) — **no edit-in-place, no `is_valid`**. +- [x] Play/pause = **`/start` + `/stop`** POST routes flipping `is_active` (mirrors `/revoke`, live-eval `/open`·`/close`), on **all three** domains. +- [x] Webhooks get `is_active` + play/pause (full parity) — reverses the earlier "leave webhooks alone" defer. But **no `is_valid`** (no validity concept) and no other webhook lifecycle work. +- [x] Edition = OSS, alongside `core/triggers/`. + +## Notes + +- The trigger tables are **unreleased**, so there is no production DB that has run `oss000000003`. + Editing it in place avoids stacking ALTER-on-ALTER for a schema no one has applied. No backfill + is needed — no rows exist. +- Webhook subscriptions had no flags today (only `deleted_at`), though the `flags` JSONB **column + already exists** (created in the released `core` chain). This work gives them `is_active` + play/pause + (WP6) via a data-only `oss000000004` backfill — but **no `is_valid`**: webhooks have no `/revoke`, + no external connection, nothing to invalidate. `is_valid` is trigger-specific (Composio can revoke a + connection out from under a subscription). +- Reuse anchors (no reinvention): references via `_normalize_references` / + `WorkflowsService.retrieve_workflow_revision`; web drawer mirrors + `TriggerSubscriptionDrawer.tsx`; reference-family build mirrors `runnable/deploy.ts`; + cron mechanism mirrors `crons/queries.{sh,txt}` + live-eval `refresh_runs`. +- Live evals have **no per-run interval** — the tick is global and every active run fires every + tick. The per-schedule cron expression is genuinely net-new logic; there was no prior pattern + to copy for it. diff --git a/docs/designs/gateway-triggers/schedules/wp/WP0-spec.md b/docs/designs/gateway-triggers/schedules/wp/WP0-spec.md new file mode 100644 index 0000000000..b2f4d8063f --- /dev/null +++ b/docs/designs/gateway-triggers/schedules/wp/WP0-spec.md @@ -0,0 +1,71 @@ +# WP0 — Migration (edit `oss000000003` in place) + +Read `contracts.md` first. Build against the frozen WP1 DTOs. + +## File + +`api/oss/databases/postgres/migrations/core_oss/versions/oss000000003_add_trigger_subscriptions_and_deliveries.py` +— the **initial, unreleased** migration. Edit in place. No backfill (no rows exist). Mirror every +`upgrade()` change in `downgrade()`. + +## Changes (order matters) + +### 1. `trigger_subscriptions` — promote `ti_id` +In the existing `op.create_table("trigger_subscriptions", ...)`, add: +- `sa.Column("ti_id", sa.String(), nullable=True)` (place near `connection_id`). +After the table, add: +```python +op.create_index( + "ix_trigger_subscriptions_ti_id", + "trigger_subscriptions", + ["project_id", "ti_id"], + unique=True, + postgresql_where=sa.text("ti_id IS NOT NULL AND deleted_at IS NULL"), +) +``` + +### 2. `trigger_schedules` — NEW table, created BEFORE `trigger_deliveries` +Mirror the `trigger_subscriptions` create_table but **drop `connection_id` and the +gateway_connections FK**; keep `project_id`, `id`, `name`, `description`, `data` (JSON), +`flags` (JSONB), `meta`, `tags`, full lifecycle columns. FK `project_id → projects` CASCADE, +PK `(project_id, id)`. Indexes: +```python +op.create_index("ix_trigger_schedules_project_id_created_at", "trigger_schedules", + ["project_id", "created_at"], unique=False) +op.create_index("ix_trigger_schedules_project_id_deleted_at", "trigger_schedules", + ["project_id", "deleted_at"], unique=False) +op.create_index("ix_trigger_schedules_active", "trigger_schedules", ["project_id"], + unique=False, + postgresql_where=sa.text("(flags ->> 'is_active') = 'true' AND deleted_at IS NULL")) +``` + +### 3. `trigger_deliveries` — generalize +In its `create_table`: +- `subscription_id` → `nullable=True`. +- add `sa.Column("schedule_id", sa.UUID(), nullable=True)`. +- add composite FK `(project_id, schedule_id) → trigger_schedules(project_id, id)` CASCADE. +- add `sa.CheckConstraint("(subscription_id IS NULL) <> (schedule_id IS NULL)", + name="ck_trigger_deliveries_exactly_one_parent")`. +Replace the single unique index `ix_trigger_deliveries_subscription_id_event_id` with two partial: +```python +op.create_index("ix_trigger_deliveries_subscription_id_event_id", "trigger_deliveries", + ["project_id", "subscription_id", "event_id"], unique=True, + postgresql_where=sa.text("subscription_id IS NOT NULL")) +op.create_index("ix_trigger_deliveries_schedule_id_event_id", "trigger_deliveries", + ["project_id", "schedule_id", "event_id"], unique=True, + postgresql_where=sa.text("schedule_id IS NOT NULL")) +``` + +### 4. `downgrade()` +Reverse-order mirror: drop the two delivery partial indexes (+ recreate the old single one if you +want strict symmetry), drop the check + schedule_id FK/column, restore `subscription_id` NOT NULL, +drop `trigger_schedules` (+ its 3 indexes), drop `ix_trigger_subscriptions_ti_id` + the `ti_id` column. + +## AC +- `alembic upgrade head` then `downgrade` clean, both editions (run in CI/live stack). +- The partial-index predicates EXACTLY match the DAO filters in WP2 (`flags->>'is_active'='true'`, + `ti_id IS NOT NULL AND deleted_at IS NULL`). + +## Do NOT +- Do not touch webhook tables (that's WP6's `oss000000004`). +- Do not add a flags column or enumerate flag keys — flags live in the existing JSONB `flags` column. diff --git a/docs/designs/gateway-triggers/schedules/wp/WP2-spec.md b/docs/designs/gateway-triggers/schedules/wp/WP2-spec.md new file mode 100644 index 0000000000..ba253f4481 --- /dev/null +++ b/docs/designs/gateway-triggers/schedules/wp/WP2-spec.md @@ -0,0 +1,52 @@ +# WP2 — DAO, DBE, mappings (`dbs/postgres/triggers/`) + +Read `contracts.md` first. Build against frozen WP1 DTOs and the WP0 schema. + +## Files +- `api/oss/src/dbs/postgres/triggers/dbas.py` +- `api/oss/src/dbs/postgres/triggers/dbes.py` +- `api/oss/src/dbs/postgres/triggers/mappings.py` +- `api/oss/src/dbs/postgres/triggers/dao.py` + +## dbas.py +- `TriggerSubscriptionDBA`: add `ti_id = Column(String, nullable=True)` (alongside `connection_id`). +- NEW `TriggerScheduleDBA`: mirror `TriggerSubscriptionDBA` but **without `connection_id`** + (same mixins: ProjectScope, Lifecycle, Identifier, Header, Data, Flags, Tags, Meta). +- `TriggerDeliveryDBA`: `subscription_id` → `nullable=True`; add `schedule_id = Column(UUID(as_uuid=True), nullable=True)`. + +## dbes.py +- NEW `TriggerScheduleDBE(Base, TriggerScheduleDBA)`, `__tablename__ = "trigger_schedules"`, + table_args mirroring `TriggerSubscriptionDBE` minus the gateway_connections FK + connection index; + add the partial active index `ix_trigger_schedules_active` (predicate must match WP0 verbatim) and + the created_at/deleted_at indexes. +- `TriggerSubscriptionDBE`: add `ix_trigger_subscriptions_ti_id` partial unique index to table_args. +- `TriggerDeliveryDBE`: add `(project_id, schedule_id) → trigger_schedules` FK; replace the single + unique dedup index with the two partial ones; add the XOR `CheckConstraint`. + +## mappings.py (CURRENT broken anchors) +- DELETE `_SUBSCRIPTION_FLAGS = ("enabled","valid")` (line 22) and `_flags_to_dbe(enabled, valid)` (line 25). +- Replace with typed flag (de)serialization using `TriggerSubscriptionFlags` / + `TriggerScheduleFlags`: DBE.flags JSONB `{"is_active":..., "is_valid":...}` ↔ the flag model + (use `flags.model_dump()` / `TriggerSubscriptionFlags(**(dbe.flags or {}))`). +- Stop stuffing `ti_id` into `data` (current create maps `data={"ti_id": ...}` at mappings.py:36-38; + the PUT-preserve logic at 107-111). Instead read/write the **`ti_id` column**. On full-PUT edit, + preserve the existing `ti_id` column value when the client omits it. +- Add schedule create/edit/read mappings (`TriggerSchedule*` ↔ `TriggerScheduleDBE`). No ti_id, no connection_id. +- Delivery mapping: carry both `subscription_id` and `schedule_id` (one will be None). + +## dao.py +- The two `ti_id` lookups currently filter `TriggerSubscriptionDBE.data["ti_id"].astext == trigger_id` + (dao.py:220 and dao.py:246) → repoint to `TriggerSubscriptionDBE.ti_id == trigger_id`. +- `create_subscription` currently takes `ti_id` and routes it into `data` — route it to the column. +- NEW `TriggerSchedulesDAO` (or extend `TriggersDAO`): `fetch_active_schedules(*, project_id=None)` + using the partial active index filter (`flags->>'is_active'='true' AND deleted_at IS NULL`); plus + schedule CRUD (`create/get/edit/delete/query`) mirroring subscription CRUD. +- Generalize `write_delivery` to set `subscription_id` OR `schedule_id`. Add a schedule-side dedup + (`dedup_seen` variant keyed on schedule_id) if dispatch dedups schedule events. +- **Per memory `dao_one_connection_per_call`:** one `engine.session()` per call; do not call + session-opening helpers inside row loops. + +## AC +- Subscription dispatch lookup is an index seek on the `ti_id` column. +- `fetch_active_schedules` uses the partial index. +- `ruff check` clean; no reference to `enabled`/`valid`/`data["ti_id"]` remains in this folder. diff --git a/docs/designs/gateway-triggers/schedules/wp/WP3-spec.md b/docs/designs/gateway-triggers/schedules/wp/WP3-spec.md new file mode 100644 index 0000000000..226154b8e9 --- /dev/null +++ b/docs/designs/gateway-triggers/schedules/wp/WP3-spec.md @@ -0,0 +1,58 @@ +# WP3 — Service & router (schedule CRUD + refresh) + +Read `contracts.md` first. Depends on WP2 (DAO methods) and WPD (dispatch signature) — documented. + +## Files +- `api/oss/src/core/triggers/service.py` +- `api/oss/src/apis/fastapi/triggers/router.py` +- `api/oss/src/apis/fastapi/triggers/models.py` (request/response envelopes) +- `api/entrypoints/routers.py` (register the admin router) + +## service.py — fix broken subscription reads first +- `existing.data.ti_id` (lines 421, 458, 530) → `existing.ti_id`. +- `subscription.enabled` / `existing.enabled` (lines 422, 430) → `.flags.is_active`. +- `existing.valid` (line 551) → `existing.flags.is_valid`. + +## service.py — schedule CRUD + refresh (NEW) +- Schedule CRUD reusing the existing `_normalize_references` helper (same one subscriptions use → + `WorkflowsService.retrieve_workflow_revision`; expands application/evaluator/environment families). + Raise `TriggerReferenceInvalid` when unresolvable. Edits are full-PUT (memory `feedback_edits_full_put`). +- Cron validation: a helper `_validate_schedule(expr)` that rejects non-5-field / unparseable via + `croniter` → raise `TriggerScheduleInvalid`. Enforce exactly 5 fields (reject 6-field seconds form). +- `refresh_schedules(*, timestamp, interval)`: + - `fetch_active_schedules()` (WP2 DAO). + - For each, fire gate `croniter.match(schedule.data.schedule, timestamp)` (timestamp = the rounded + `trigger_datetime`). Skip non-matches. + - For matches, build the trigger event envelope and enqueue dispatch via the schedule path + (WPD). event_id should be deterministic per (schedule, tick) for dedup, e.g. + `f"{schedule.id}:{timestamp.isoformat()}"`. Log `[SCHEDULE] Dispatching` / `Dispatched` (mirror + live-eval `[LIVE]` logs). +- Mirror live-eval `refresh_runs` shape (`core/evaluations/service.py`): `newest/oldest` not needed — + the cron `match` is point-in-time. + +## router.py — routes (NEW) +Register in `__init__` via `self.router.add_api_route(...)` (precedent: subscription routes at +router.py:220-310; `/revoke` at 256-257). Add: +- `POST /schedules` (create), `GET /schedules` (list/query), `GET /schedules/{id}`, + `PUT /schedules/{id}` (full edit), `DELETE /schedules/{id}`. +- `POST /schedules/{id}/start` and `POST /schedules/{id}/stop` — see WP6 (may be authored there; + coordinate so they aren't double-added). +- Admin: build `self.admin_router = APIRouter()` (pattern: evaluations router.py:141,146) and add + `POST /refresh` (params `trigger_interval: int = Query(1, ge=1, le=60)`, + `trigger_datetime: datetime = Query(None)`; NO auth/entitlement check — admin endpoint). Compute + `timestamp` and call `triggers_service.refresh_schedules(...)`. +- `@intercept_exceptions()` at each route boundary; catch `TriggerScheduleInvalid` →422, + `ScheduleNotFoundError`→404, `TriggerReferenceInvalid`→422. + +## entrypoints/routers.py +- `TriggersRouter` is built at routers.py:826 and mounted at 1199-1205. Mount the new admin router: + `app.include_router(router=triggers.admin_router, prefix="/admin/triggers", tags=["Triggers","Admin"], include_in_schema=False)` (mirror evaluations at 1212-1213). The cron POSTs to + `/admin/triggers/schedules/refresh`, so either set the route path to `/schedules/refresh` on the + admin_router under prefix `/admin/triggers`, or prefix `/admin/triggers/schedules` with path `/refresh`. + Pick one and keep WP4's curl URL in sync. +- `triggers_service` needs the schedules DAO (WP2) + the schedule dispatch enqueue (WPD) injected here. + +## AC +- Schedule CRUD round-trips; references stored as the full normalized family. +- `/admin/triggers/schedules/refresh` dispatches ONLY schedules whose cron matches the tick. +- Invalid cron → 422 with `TriggerScheduleInvalid` message. diff --git a/docs/designs/gateway-triggers/schedules/wp/WP4-spec.md b/docs/designs/gateway-triggers/schedules/wp/WP4-spec.md new file mode 100644 index 0000000000..48d8014c8c --- /dev/null +++ b/docs/designs/gateway-triggers/schedules/wp/WP4-spec.md @@ -0,0 +1,57 @@ +# WP4 — Cron wiring (infra; fully independent) + +Read `contracts.md` first. Independent of the api-core WPs — only needs the endpoint URL from WP3 +(`/admin/triggers/schedules/refresh`). + +## Files +- NEW `api/oss/src/crons/triggers.sh` +- NEW `api/oss/src/crons/triggers.txt` +- `api/oss/docker/Dockerfile.dev` +- `api/oss/docker/Dockerfile` (the gh/prod one — confirm exact path) +- `hosting/docker-compose/oss/docker-compose.dev.yml` (cron service volume mount) +- `hosting/docker-compose/ee/docker-compose.dev.yml` (cron service volume mount) +- `api/pyproject.toml` (add `croniter`) + +## triggers.sh +Mirror `api/oss/src/crons/queries.sh` exactly, but POST to the schedules refresh endpoint: +```sh +"http://api:8000/admin/triggers/schedules/refresh?trigger_interval=${TRIGGER_INTERVAL}&trigger_datetime=${TRIGGER_DATETIME}" +``` +Keep the same `TRIGGER_INTERVAL` extraction from `/app/crontab`, the same rounded-minute +`TRIGGER_DATETIME` computation, and the `Authorization: Access ${AGENTA_AUTH_KEY}` header. +Change the `awk` pattern to match `triggers\.sh`. + +## triggers.txt +Mirror `queries.txt` — every minute: +``` +* * * * * root sh /triggers.sh >> /proc/1/fd/1 2>&1 +``` + +## Dockerfiles +In both OSS Dockerfiles, copy `triggers.sh` → `/triggers.sh` and `triggers.txt` → +`/etc/cron.d/triggers-cron`, then ADD both to the chmod/sed crontab-build pipeline exactly like +`queries-cron` (see `Dockerfile.dev:62-70` pattern: chmod, sed `$a\`, strip `root`, strip the +`>> /proc/1/fd/1` redirect, concat into `/app/crontab`). + +## docker-compose (dev, both editions) +The `cron` service mounts each `.sh`. Add: +```yaml +- ../../../api/oss/src/crons/triggers.sh:/triggers.sh +``` +to the `cron` service `volumes:` in both `oss/docker-compose.dev.yml` and `ee/docker-compose.dev.yml` +(EE's cron already mounts queries.sh + meters/spans/events.sh). + +## pyproject.toml +Add `croniter` to `api/pyproject.toml` base dependencies (used by WP3 validation + refresh, runs in +api + cron + worker images). Pure-Python, no native build. + +## AC +- After rebuild, the `cron` container fires `/admin/triggers/schedules/refresh` every minute (visible + in `docker logs`). +- `croniter` importable in the api image. + +## Notes / open +- Confirm the exact prod Dockerfile path (`api/oss/docker/Dockerfile` vs `.gh`); the dev one is + authoritative for local verification. +- Schedules are OSS-only but the cron mechanism is shared; mounting in the EE compose cron is correct + because EE runs the OSS routers too. diff --git a/docs/designs/gateway-triggers/schedules/wp/WP5-spec.md b/docs/designs/gateway-triggers/schedules/wp/WP5-spec.md new file mode 100644 index 0000000000..1c54052015 --- /dev/null +++ b/docs/designs/gateway-triggers/schedules/wp/WP5-spec.md @@ -0,0 +1,48 @@ +# WP5 — Web (schedule UI + play/pause across 3 domains) + +Read `contracts.md` first. Depends on WP3 (schedule CRUD API) + WP6 (start/stop routes) — code +against the documented API shapes. Independent tree (web/), fully parallel. + +Apply the `agenta-package-practices` skill (package vs app placement, molecules, EntityPicker, +loadable/runnable bridges, package unit tests). + +## Location +`web/packages/agenta-entity-ui/src/gatewayTrigger/` (alongside the existing +`drawers/TriggerSubscriptionDrawer.tsx`). Data atoms likely under `web/packages/agenta-entities/`. + +## Three surfaces + +### 1. Schedule drawer (create/edit) +Mirror `drawers/TriggerSubscriptionDrawer.tsx`. Differences: +- Replace the Composio event picker with a **cron-expression field**. Validate client-side and show a + human-readable "next runs" hint (consider `cronstrue` or a tiny local parser; confirm dep policy). +- Reuse the reference-family build exactly as the subscription drawer / `runnable/deploy.ts` + (`web/packages/agenta-entities/src/runnable/deploy.ts`): send `application{,_variant,_revision}` or + evaluator/environment-by-slug families. The drawer sends `data.references` already normalized by FE + prefix; BE completes the family. +- Send `data: { event_key, schedule, inputs_fields, references, selector }` per `TriggerScheduleData`. + +### 2. Schedules list / table +Mirror the subscriptions list. Columns: name, cron (rendered human-readable), bound workflow, +`is_active` state, last delivery. Row actions: edit, delete, **play/pause**. + +### 3. Play/pause control (all three entity types) +A toggle on row + drawer for trigger subscriptions, trigger schedules, AND webhook subscriptions, +calling the WP6 routes `POST /{id}/start` and `POST /{id}/stop`. Optimistic update of +`flags.is_active`. Subscription + webhook drawers: prefill reads `flags.is_active` (subscription +rename from old top-level `enabled`). + +## Data layer +Schedule query/mutation atoms mirroring the subscription atoms: list, get, create, edit, delete, +start, stop. The deliveries view is reused — delivery rows now carry `schedule_id` as well as +`subscription_id` (filter/group accordingly). + +## AC +- Create/edit a schedule from the UI; references sent as the full prefixed family; edit prefills correctly. +- Play/pause toggles `is_active` on all three entity types and reflects state after refetch. +- Schedules list renders cron + active state. +- `pnpm lint-fix` clean in `web/`; package unit tests for new atoms/components. + +## Notes +- The Fern/generated API client may need regen once WP3/WP6 routes land — coordinate at stitch time. +- Don't invent reference-building; reuse `runnable/deploy.ts` (hard rule from prior work). diff --git a/docs/designs/gateway-triggers/schedules/wp/WP6-spec.md b/docs/designs/gateway-triggers/schedules/wp/WP6-spec.md new file mode 100644 index 0000000000..09a1785693 --- /dev/null +++ b/docs/designs/gateway-triggers/schedules/wp/WP6-spec.md @@ -0,0 +1,52 @@ +# WP6 — Play/pause (`/start` + `/stop`) across all three domains + webhook flags + +Read `contracts.md` first. Depends on WP1 (flag DTOs) and WPD (webhook is_active gate) — documented. + +## Routes (all per-item POST, flip `is_active`) +Shape mirrors `/revoke` and live-eval `/open`·`/close`. `/start` → `is_active=true`, +`/stop` → `is_active=false`. Full-PUT semantics on these non-git entities: load the full current +entity, override only `flags.is_active`, write it back (memory `feedback_edits_full_put`). + +### Trigger subscriptions +- `api/oss/src/apis/fastapi/triggers/router.py`: `POST /subscriptions/{id}/start` + `/stop`. +- `api/oss/src/core/triggers/service.py`: `set_subscription_active(*, project_id, user_id, + subscription_id, is_active)` (or start/stop methods). NOTE: subscription has BOTH `is_active` + and `is_valid` — only touch `is_active`. If the existing `/revoke` semantics overlap, keep + `/revoke` (provider-side) distinct from `/stop` (local is_active). + +### Trigger schedules +- Same router/service: `POST /schedules/{id}/start` + `/stop`. (Coordinate with WP3 so these are + added once — recommend WP6 owns all start/stop routes, WP3 owns CRUD + refresh.) + +### Webhook subscriptions — the bulk of WP6 +- `api/oss/src/core/webhooks/types.py`: add `WebhookSubscriptionFlags(BaseModel){is_active: bool = True}` + and a `flags` field on `WebhookSubscription` + `WebhookSubscriptionEdit` + (`flags: WebhookSubscriptionFlags = Field(default_factory=...)`). **No `is_valid`.** +- `api/oss/src/dbs/postgres/webhooks/mappings.py`: webhook mappings **currently ignore `flags` + entirely** (`map_subscription_dto_to_dbe_edit` at mappings.py:81 maps name/desc/tags/meta/data but + not flags). Add flags ser/de like the trigger mappings: write `flags.model_dump()` into the JSONB + `flags` column on create/edit, read `WebhookSubscriptionFlags(**(dbe.flags or {}))` on read. +- `api/oss/src/apis/fastapi/webhooks/router.py`: `POST /subscriptions/{id}/start` + `/stop`. +- `api/oss/src/core/webhooks/service.py`: the start/stop service methods. +- Webhook dispatcher `is_active` gate is in WPD (coordinate). + +## Migration — NEW `oss000000004` (webhook flags only) +`api/oss/databases/postgres/migrations/core_oss/versions/oss000000004_add_webhook_subscription_flags.py` +- `revision = "oss000000004"`, `down_revision = "oss000000003"`. +- Webhooks are on the RELEASED `core` chain and the `flags` JSONB **column already exists** — this is + DATA-ONLY (backfill) + an index. Do NOT add a column, do NOT edit the webhook create migration. +- `upgrade()`: + ```python + op.execute("UPDATE webhook_subscriptions SET flags = COALESCE(flags, '{}'::jsonb) || '{\"is_active\": true}'::jsonb") + op.create_index("ix_webhook_subscriptions_active", "webhook_subscriptions", ["project_id"], + unique=False, + postgresql_where=sa.text("(flags ->> 'is_active') = 'true' AND deleted_at IS NULL")) + ``` +- `downgrade()`: drop the index; `op.execute("UPDATE webhook_subscriptions SET flags = flags - 'is_active'")`. + +## AC +- `/start`·`/stop` flips `is_active` on each of the three domains (round-trip via GET). +- A stopped entity does not dispatch (trigger: WPD gate; webhook: WPD gate; schedule: + `fetch_active_schedules` excludes it). +- `oss000000004` up/down clean, both editions; webhook rows get `is_active=true`. +- Webhook DTO has `flags.is_active` but NO `is_valid`. diff --git a/docs/designs/gateway-triggers/schedules/wp/WPD-spec.md b/docs/designs/gateway-triggers/schedules/wp/WPD-spec.md new file mode 100644 index 0000000000..d62a38dba4 --- /dev/null +++ b/docs/designs/gateway-triggers/schedules/wp/WPD-spec.md @@ -0,0 +1,51 @@ +# WPD — Dispatcher refactor (entity-agnostic dispatch) + +Read `contracts.md` first. + +## Files +- `api/oss/src/tasks/asyncio/triggers/dispatcher.py` +- `api/oss/src/tasks/taskiq/triggers/worker.py` +- `api/oss/src/tasks/asyncio/webhooks/dispatcher.py` (add `is_active` gate) + +## Triggers dispatcher (`tasks/asyncio/triggers/dispatcher.py`) +CURRENT: `dispatch(*, trigger_id, event_id, event)` does the `ti_id` lookup (calls +`get_project_and_subscription_by_trigger_id`), then gates on `subscription.enabled` (line 96), +dedups, builds context, invokes, writes delivery. + +NEW signature (frozen in contracts.md): +```python +async def dispatch(self, *, project_id: UUID, entity, event_id: str, event: Dict[str, Any]) -> None +``` +- REMOVE the `ti_id` lookup from the body. `entity` is a `TriggerSubscription` OR `TriggerSchedule`. +- Gate: `if not entity.flags.is_active: return` (silent skip). For a subscription, if + `not entity.flags.is_valid` do NOT silent-skip — let it proceed and land in the existing + failed-delivery branch (so the user sees why). (Schedules have no `is_valid`.) +- `_build_context`, mapping (`entity.data.inputs_fields/references/selector`), invoke, + `write_delivery` stay as-is — but `write_delivery` now sets `subscription_id` if the entity is a + subscription, else `schedule_id`. Use `isinstance` or a small `entity_kind` discriminator. +- `created_by_id` / `id` reads work for both (both have them via Lifecycle/Identifier). + +## Triggers worker (`tasks/taskiq/triggers/worker.py`) +CURRENT task `triggers.dispatch` (worker.py:33) calls `self.dispatcher.dispatch(trigger_id, event_id, event)`. +- The **Composio path** must now do the `ti_id` → (project_id, subscription) lookup HERE (the worker + runs async off the queue, so the DAO call is fine — unlike the HTTP ingress which must stay fast). + Then call the new `dispatch(project_id=..., entity=subscription, ...)`. If lookup returns None, + keep the existing "unknown trigger_id — skip" behavior. +- The worker needs the DAO. Wiring is in `entrypoints/routers.py` (`_triggers_dispatcher` / + `_triggers_worker`); coordinate with WP3 if a new task (e.g. `triggers.dispatch_schedule`) is added. +- **Schedule path:** WP3's `refresh_schedules` enqueues a task that already has the schedule row (or + its id+project) and calls `dispatch(entity=schedule, ...)` — no lookup. Decide with WP3 whether + this is the same task with an `entity_kind` arg or a second task; document the choice in the code. + +## Webhook dispatcher (`tasks/asyncio/webhooks/dispatcher.py`) +- Add an `is_active` gate: after resolving the webhook subscription, `if not + subscription.flags.is_active: `. Today there is NO flag gate. Requires WP6's + `WebhookSubscriptionFlags` + webhook mappings reading flags — coordinate; if WP6's flag model + isn't merged yet, code against the documented `subscription.flags.is_active`. + +## AC +- Existing dispatcher unit tests updated for the new signature and pass + (`api/oss/tests/pytest/unit/triggers/test_triggers_dispatcher.py` — the `_make_subscription` + helper uses `enabled=`; update to `flags`). +- Composio happy path unchanged end-to-end (event → invoke → delivery with `subscription_id`). +- A paused webhook subscription does not dispatch. diff --git a/docs/designs/gateway-triggers/schedules/wp/contracts.md b/docs/designs/gateway-triggers/schedules/wp/contracts.md new file mode 100644 index 0000000000..8001deb149 --- /dev/null +++ b/docs/designs/gateway-triggers/schedules/wp/contracts.md @@ -0,0 +1,142 @@ +# Frozen Contracts (WP1) — build against these + +WP1 is **implemented and committed to the working tree** in +`api/oss/src/core/triggers/dtos.py` and `exceptions.py`. These are the FROZEN signatures every +other WP imports against. Do **not** change them; if a WP needs a contract change, stop and flag it. + +> ⚠️ WP1 intentionally **broke** existing reads of `subscription.enabled` / `.valid` / `.data.ti_id`. +> Fixing those call sites is the job of WP2/WPD/WP3 (call sites listed in `orchestration.md §broken`). + +## Typed flags (NEW) + +```python +# core/triggers/dtos.py +class TriggerSubscriptionFlags(BaseModel): + is_active: bool = True + is_valid: bool = True # provider connection still good (Composio revoke) + +class TriggerScheduleFlags(BaseModel): + is_active: bool = True # no is_valid — schedule has no external connection + +# core/webhooks/types.py (WP6 ADDS THIS — not yet written) +class WebhookSubscriptionFlags(BaseModel): + is_active: bool = True # no is_valid — webhooks have no validity concept +``` + +## Trigger subscription (CHANGED: ti_id promoted, flags typed) + +```python +class TriggerSubscriptionData(BaseModel): + event_key: str + trigger_config: Optional[Dict[str, Any]] = None # ti_id REMOVED from here + inputs_fields: Optional[Dict[str, Any]] = None + references: Optional[Dict[str, Reference]] = None + selector: Optional[Selector] = None + +class TriggerSubscription(Identifier, Lifecycle, Header, Metadata): + connection_id: UUID + ti_id: Optional[str] = None # NOW top-level (was data.ti_id) + data: TriggerSubscriptionData + flags: TriggerSubscriptionFlags = Field(default_factory=TriggerSubscriptionFlags) + # was: enabled: bool / valid: bool + +class TriggerSubscriptionCreate(Header, Metadata): + connection_id: UUID + data: TriggerSubscriptionData + +class TriggerSubscriptionEdit(Identifier, Header, Metadata): + connection_id: UUID + data: TriggerSubscriptionData + flags: TriggerSubscriptionFlags = Field(default_factory=TriggerSubscriptionFlags) + +class TriggerSubscriptionQuery(BaseModel): + name / connection_id / event_key # unchanged +``` + +## Trigger schedule (NEW) + +```python +class TriggerScheduleData(BaseModel): + event_key: str + schedule: str # 5-field cron expr, UTC, validated via croniter + inputs_fields: Optional[Dict[str, Any]] = None + references: Optional[Dict[str, Reference]] = None + selector: Optional[Selector] = None + +class TriggerSchedule(Identifier, Lifecycle, Header, Metadata): + data: TriggerScheduleData + flags: TriggerScheduleFlags = Field(default_factory=TriggerScheduleFlags) + +class TriggerScheduleCreate(Header, Metadata): + data: TriggerScheduleData + +class TriggerScheduleEdit(Identifier, Header, Metadata): + data: TriggerScheduleData + flags: TriggerScheduleFlags = Field(default_factory=TriggerScheduleFlags) + +class TriggerScheduleQuery(BaseModel): + name: Optional[str] = None + event_key: Optional[str] = None +``` + +## Trigger delivery (CHANGED: schedule_id added, both ids nullable) + +```python +class TriggerDelivery(Identifier, Lifecycle): + status: Status + data: Optional[TriggerDeliveryData] = None + subscription_id: Optional[UUID] = None # XOR with schedule_id (DB-enforced) + schedule_id: Optional[UUID] = None + event_id: str + +class TriggerDeliveryCreate(Identifier): # same fields as above (+ Identifier) +class TriggerDeliveryQuery(BaseModel): # status / subscription_id / schedule_id / event_id +``` +`TriggerDeliveryData` is unchanged (`event_key, references, inputs, result, error`). + +## Exceptions (NEW in core/triggers/exceptions.py) + +```python +class ScheduleNotFoundError(TriggersError): # __init__(*, schedule_id: str) +class TriggerScheduleInvalid(TriggersError): # __init__(message="...5-field cron...") +``` +Existing: `TriggersError`, `ProviderNotFoundError`, `SubscriptionNotFoundError`, +`TriggerReferenceInvalid`, `ConnectionNotFoundError`, `AdapterError`. + +## Dispatcher contract (WPD defines; WP3/Composio call) + +```python +# tasks/asyncio/triggers/dispatcher.py — NEW signature +async def dispatch(self, *, project_id: UUID, entity, event_id: str, event: Dict[str, Any]) -> None +``` +- `entity` is a `TriggerSubscription` OR `TriggerSchedule` (duck-typed: both have + `.flags.is_active`, `.data.{inputs_fields,references,selector}`, `.created_by_id`, `.id`). +- gate: `entity.flags.is_active` False → silent skip. For subscriptions, `flags.is_valid` + False → fall through to the failed-delivery path (NOT silent). +- `write_delivery` sets `subscription_id` if entity is a subscription, else `schedule_id`. +- The `ti_id` → subscription lookup is REMOVED from `dispatch`; the Composio **worker task** + does it before calling `dispatch`. + +## DB / migration contract (WP0 / WP6) + +- `trigger_subscriptions`: new `ti_id` String column + partial unique index + `(project_id, ti_id) WHERE ti_id IS NOT NULL AND deleted_at IS NULL`. Flags stay JSONB + (`flags->>'is_active'`, `flags->>'is_valid'`) — no migration column for flags. +- `trigger_schedules` (new table): mirror subscriptions minus `connection_id`/`ti_id`; partial + active index `(project_id) WHERE flags->>'is_active'='true' AND deleted_at IS NULL`. +- `trigger_deliveries`: `subscription_id` nullable; add `schedule_id` UUID + composite FK + `(project_id, schedule_id) → trigger_schedules` CASCADE; XOR check + `(subscription_id IS NULL) <> (schedule_id IS NULL)`; two partial unique dedup indexes. +- All of the above edited **in place** in `oss000000003` (unreleased). +- `webhook_subscriptions`: NEW `oss000000004` (core_oss, data-only) — backfill + `flags.is_active=true` + partial active index. Column already exists. + +## House conventions (from api/AGENTS.md — apply in every WP) + +- keyword-only params (`*`); `#`-grouped signature sections. +- Router → Service → DAO Interface → DAO Impl → DB. Domain exceptions in `core/.../exceptions.py`, + caught at the router boundary. Never raise HTTPException from services. +- Lifecycle routes use `POST /{id}/` (precedent: `/archive`, `/revoke`). We use + `/{id}/start` + `/{id}/stop`. +- Services return typed DTOs, never dicts/tuples. Mapping lives in `dbs/postgres/*/mappings.py`. +- `ruff format` then `ruff check --fix` before done. diff --git a/docs/designs/gateway-triggers/schedules/wp/orchestration.md b/docs/designs/gateway-triggers/schedules/wp/orchestration.md new file mode 100644 index 0000000000..6b781fca89 --- /dev/null +++ b/docs/designs/gateway-triggers/schedules/wp/orchestration.md @@ -0,0 +1,61 @@ +# Orchestration — two-wave parallel build + +**Status:** WP1 (contract) DONE in working tree. Waiting to fan out the rest after compact. + +## The waves + +- **Wave 0 (done, serial):** WP1 DTOs + exceptions written to `core/triggers/dtos.py` / + `exceptions.py`. These are the frozen contract (`contracts.md`). This intentionally broke some + existing call sites (see §broken) — those are fixed by WP2/WPD/WP3. +- **Wave 1 (parallel subagents):** WP0, WP2, WPD, WP3, WP4, WP6, WP5. Each builds its layer + against the frozen WP1 contract + its own `WP*-spec.md`. They do **not** import each other's + not-yet-written code — they code against the documented signatures and we stitch. + +## Why these can run in parallel despite the linear DAG + +The DAG (`WP1→WP0→WP2→WPD→WP3→WP4`) is a *runtime* dependency. For *authoring*, the WP1 contract +is real code, so each WP can write its files against the frozen types without the others being +done. Stitching is clean because every seam (DTO fields, dispatcher signature, DAO method names) +is pinned in `contracts.md`. Genuinely independent: WP4 (infra) and WP5 (web, different tree). + +## Launch grouping (suggested) + +All seven can launch at once. If throttling, this order maximizes early stitching: + +1. WP0 (migration) + WP2 (DAO/mappings) + WPD (dispatcher) — the api core, against the contract. +2. WP3 (service/router) + WP6 (play/pause + webhook flags) — depend on WPD/WP2 signatures (documented). +3. WP4 (cron infra) + WP5 (web) — fully independent. + +## Stitching / verification after Wave 1 + +1. `cd api && ruff format && ruff check` — must pass (catches contract drift / unused imports). +2. `python -c "import oss.src.apis.fastapi.triggers.router"` (and webhooks router) — import-time + wiring check; catches signature mismatches at the seams. +3. Run the unit suite: `pytest api/oss/tests/pytest/unit/triggers/ -q`. +4. Migration up/down on a live DB (or CI): `alembic upgrade head` then `downgrade`. +5. Acceptance: schedule CRUD + `/start`·`/stop` round-trips, both editions. + +## Call sites WP1 broke (must be fixed by Wave 1) + +These reference the OLD contract (`enabled`/`valid`/`data.ti_id`) and will not run until fixed: + +| File:line | Old read | Fix in | New form | +|-----------|----------|--------|----------| +| `tasks/asyncio/triggers/dispatcher.py:96` | `subscription.enabled` | WPD | `entity.flags.is_active` | +| `dbs/postgres/triggers/mappings.py:22` | `_SUBSCRIPTION_FLAGS = ("enabled","valid")` | WP2 | typed flags ser/de | +| `dbs/postgres/triggers/mappings.py:25` | `_flags_to_dbe(enabled, valid)` | WP2 | flags from `TriggerSubscriptionFlags` | +| `dbs/postgres/triggers/mappings.py:52,115-117` | `_flags_to_dbe(...)` | WP2 | as above | +| `core/triggers/service.py:421,458,530` | `existing.data.ti_id` | WP3 | `existing.ti_id` | +| `core/triggers/service.py:422,430` | `subscription.enabled`/`existing.enabled` | WP3 | `.flags.is_active` | +| `core/triggers/service.py:551` | `existing.valid` | WP3 | `existing.flags.is_valid` | + +Also `dao.py:220,246` filter `TriggerSubscriptionDBE.data["ti_id"].astext` — WP2 repoints these +to the new `ti_id` column. + +## GitButler + +Lane for this work: `gateway-triggers-all` (the schedules work continues there) OR a new stacked +lane per the design's fan-out — decide at fan-out time. Per AGENTS.md: one lane's files assigned +at a time, `but commit --only`, verify with `git show --stat `, push with +`but push ` then confirm SHAs match `git ls-remote`. Co-author trailer: +`Co-Authored-By: Claude Opus 4.8 (1M context) `. 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..585ccc68d1 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 @@ -322,6 +367,7 @@ services: # === STORAGE ============================================== # volumes: - ../../../api/oss/src/crons/queries.sh:/queries.sh + - ../../../api/oss/src/crons/triggers.sh:/triggers.sh - ../../../api/ee/src/crons/meters.sh:/meters.sh - ../../../api/ee/src/crons/spans.sh:/spans.sh - ../../../api/ee/src/crons/events.sh:/events.sh @@ -586,6 +632,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 20465efb50..84483399c7 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: @@ -473,6 +514,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 83adfeda76..35c4b82060 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} @@ -442,6 +481,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 583d8b9496..8d38bcc113 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 @@ -314,6 +358,7 @@ services: volumes: # - ../../../api/oss/src/crons/queries.sh:/queries.sh + - ../../../api/oss/src/crons/triggers.sh:/triggers.sh # === CONFIGURATION ======================================== # env_file: - ${ENV_FILE:-./.env.oss.dev} @@ -568,6 +613,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 98efae76df..6011f36873 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: @@ -476,6 +517,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 71dda7e426..23982b7b78 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: @@ -462,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 b217180177..302bc8e2ba 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: @@ -497,6 +541,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/hosting/kubernetes/ee/values.ee.example.yaml b/hosting/kubernetes/ee/values.ee.example.yaml index dbf7604b74..1ae50dea38 100644 --- a/hosting/kubernetes/ee/values.ee.example.yaml +++ b/hosting/kubernetes/ee/values.ee.example.yaml @@ -463,6 +463,9 @@ posthog: # workerEvents: # enabled: true # replicas: 1 +# workerTriggers: +# enabled: true +# replicas: 1 # cron: # enabled: true # replicas: 1 diff --git a/hosting/kubernetes/helm/templates/_helpers.tpl b/hosting/kubernetes/helm/templates/_helpers.tpl index 9359dacb51..343c52f273 100644 --- a/hosting/kubernetes/helm/templates/_helpers.tpl +++ b/hosting/kubernetes/helm/templates/_helpers.tpl @@ -101,6 +101,10 @@ app.kubernetes.io/instance: {{ .Release.Name }} {{- $v := (default dict .Values.workerEvents).enabled -}} {{- if kindIs "invalid" $v }}true{{- else }}{{- $v -}}{{- end }} {{- end }} +{{- define "agenta.workerTriggers.enabled" -}} +{{- $v := (default dict .Values.workerTriggers).enabled -}} +{{- if kindIs "invalid" $v }}true{{- else }}{{- $v -}}{{- end }} +{{- end }} {{- define "agenta.ingress.enabled" -}} {{- $v := (default dict .Values.ingress).enabled -}} {{- if kindIs "invalid" $v }}true{{- else }}{{- $v -}}{{- end }} @@ -121,6 +125,7 @@ app.kubernetes.io/instance: {{ .Release.Name }} {{- define "agenta.workerTracing.replicas" -}}{{ default 1 (default dict .Values.workerTracing).replicas }}{{- end }} {{- define "agenta.workerWebhooks.replicas" -}}{{ default 1 (default dict .Values.workerWebhooks).replicas }}{{- end }} {{- define "agenta.workerEvents.replicas" -}}{{ default 1 (default dict .Values.workerEvents).replicas }}{{- end }} +{{- define "agenta.workerTriggers.replicas" -}}{{ default 1 (default dict .Values.workerTriggers).replicas }}{{- end }} {{/* ================================================================ Workers (gunicorn worker count, default 2). diff --git a/hosting/kubernetes/helm/templates/worker-triggers-deployment.yaml b/hosting/kubernetes/helm/templates/worker-triggers-deployment.yaml new file mode 100644 index 0000000000..2bd48406b4 --- /dev/null +++ b/hosting/kubernetes/helm/templates/worker-triggers-deployment.yaml @@ -0,0 +1,73 @@ +{{- $values := include "agenta.values" . | fromYaml -}} +{{- $workerTriggers := default dict $values.workerTriggers -}} +{{- $newrelic := default dict $values.newrelic -}} +{{- if eq (include "agenta.workerTriggers.enabled" .) "true" }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "agenta.fullname" . }}-worker-triggers + labels: + {{- include "agenta.labels" . | nindent 4 }} + app.kubernetes.io/component: worker-triggers +spec: + replicas: {{ include "agenta.workerTriggers.replicas" . }} + selector: + matchLabels: + {{- include "agenta.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: worker-triggers + template: + metadata: + labels: + {{- include "agenta.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: worker-triggers + spec: + {{- include "agenta.imagePullSecrets" . | nindent 6 }} + serviceAccountName: {{ include "agenta.serviceAccountName" . }} + {{- with $workerTriggers.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- $init := include "agenta.initContainers" . }} + {{- if $init }} + initContainers: + {{- $init | nindent 8 }} + {{- end }} + containers: + - name: worker-triggers + image: {{ include "agenta.apiImage" . }} + imagePullPolicy: {{ include "agenta.api.pullPolicy" . }} + {{- with $workerTriggers.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + command: {{ if $newrelic.licenseKey }}["newrelic-admin", "run-program", "python", "-m", "entrypoints.worker_triggers"]{{ else }}["python", "-m", "entrypoints.worker_triggers"]{{ end }} + env: + {{- include "agenta.commonEnv" . | nindent 12 }} + {{- range $key, $val := $workerTriggers.env }} + - name: {{ $key }} + value: {{ $val | quote }} + {{- end }} + livenessProbe: + exec: + command: ["pgrep", "-f", "entrypoints.worker_triggers"] + initialDelaySeconds: 15 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + {{- with $workerTriggers.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with $workerTriggers.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $workerTriggers.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $workerTriggers.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/hosting/kubernetes/oss/values.oss.example.yaml b/hosting/kubernetes/oss/values.oss.example.yaml index 650874404a..af7bf14c36 100644 --- a/hosting/kubernetes/oss/values.oss.example.yaml +++ b/hosting/kubernetes/oss/values.oss.example.yaml @@ -460,6 +460,9 @@ posthog: # workerEvents: # enabled: true # replicas: 1 +# workerTriggers: +# enabled: true +# replicas: 1 # cron: # enabled: true # replicas: 1 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/_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 bdb3fe25ec..cc735e12d3 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, @@ -95,7 +99,7 @@ const SettingsSidebar: FC<SettingsSidebarProps> = ({lastPath}) => { : []), { key: "secrets", - title: "Providers & Models", + title: "Models", icon: <Sparkle size={16} className="mt-0.5" />, }, ...(canShowTools @@ -107,9 +111,18 @@ const SettingsSidebar: FC<SettingsSidebarProps> = ({lastPath}) => { }, ] : []), + ...(canShowTriggers + ? [ + { + key: "triggers", + title: "Triggers", + icon: <Lightning size={16} className="mt-0.5" />, + }, + ] + : []), { - key: "automations", - title: "Automations", + key: "webhooks", + title: "Webhooks", icon: <Link size={16} className="mt-0.5" />, divider: true, }, @@ -156,6 +169,7 @@ const SettingsSidebar: FC<SettingsSidebarProps> = ({lastPath}) => { billingEnabled, canShowOrganization, canShowTools, + canShowTriggers, canViewApiKeys, canShowAuditLog, canShowAccount, 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 78% rename from web/oss/src/components/Automations/AutomationDrawer.tsx rename to web/oss/src/components/Webhooks/WebhookDrawer.tsx index 4892f53c79..fc8803d7dc 100644 --- a/web/oss/src/components/Automations/AutomationDrawer.tsx +++ b/web/oss/src/components/Webhooks/WebhookDrawer.tsx @@ -1,49 +1,65 @@ import {createElement, useCallback, useEffect, useMemo, useState} from "react" import {BookOpen} from "@phosphor-icons/react" -import {Button, Collapse, Form, Input, message, Select, Tabs, Tooltip, Typography} from "antd" +import { + Button, + Collapse, + Form, + Input, + message, + Select, + Switch, + Tabs, + Tooltip, + Typography, +} from "antd" import {useAtom, useSetAtom} from "jotai" import EnhancedDrawer from "@/oss/components/EnhancedUIs/Drawer" import { - AutomationProvider, + WebhookProvider, WebhookSubscriptionCreateRequest, WebhookSubscriptionEditRequest, -} from "@/oss/services/automations/types" +} from "@/oss/services/webhooks/types" import { - createAutomationAtom, - testAutomationAtom, - updateAutomationAtom, -} from "@/oss/state/automations/atoms" + createWebhookAtom, + setWebhookActiveAtom, + 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 setWebhookActive = useSetAtom(setWebhookActiveAtom) const isEdit = !!initialValues + // WP6: start/stop state lives in `flags.is_active`; prefill from it. + const [isActive, setIsActive] = useState(true) + const [isTogglingActive, setIsTogglingActive] = useState(false) const onCancel = useCallback(() => { setOpen(false) @@ -60,6 +76,9 @@ const AutomationDrawer = ({onSuccess}: {onSuccess: () => void}) => { setActiveTab("configuration") if (initialValues) { + // WP6: prefill the active state from `flags.is_active` (default true). + const active = initialValues.flags?.is_active + setIsActive(active === undefined || active === null ? true : Boolean(active)) // Determine provider via heuristic since no meta field is stored. let isGitHub = false try { @@ -68,7 +87,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}] @@ -118,6 +137,7 @@ const AutomationDrawer = ({onSuccess}: {onSuccess: () => void}) => { github_branch, }) } else { + setIsActive(true) form.resetFields() setSelectedProvider("webhook") form.setFieldsValue({ @@ -160,16 +180,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 +202,7 @@ const AutomationDrawer = ({onSuccess}: {onSuccess: () => void}) => { | undefined if (isEdit && initialValues?.id) { - await updateAutomation({ + await updateWebhook({ webhookSubscriptionId: initialValues.id, payload: payload as WebhookSubscriptionEditRequest, }) @@ -193,9 +213,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 +238,7 @@ const AutomationDrawer = ({onSuccess}: {onSuccess: () => void}) => { } } - message.success("Automation created successfully") + message.success("Webhook created successfully") } onSuccess() @@ -226,12 +246,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 +259,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 +271,33 @@ const AutomationDrawer = ({onSuccess}: {onSuccess: () => void}) => { onCancel, setCreatedWebhookSecret, buildPayloadFromForm, - createAutomation, - testAutomation, - updateAutomation, + createWebhook, + testWebhook, + updateWebhook, selectedProvider, ]) + const handleToggleActive = useCallback( + async (next: boolean) => { + if (!initialValues?.id) return + setIsActive(next) + setIsTogglingActive(true) + try { + await setWebhookActive({id: initialValues.id, active: next}) + message.success(next ? "Webhook resumed" : "Webhook paused") + } catch { + setIsActive(!next) + message.error("Failed to update webhook") + } finally { + setIsTogglingActive(false) + } + }, + [initialValues?.id, setWebhookActive], + ) + const providerOptions = useMemo( () => - AUTOMATION_SCHEMA.map((provider) => ({ + WEBHOOK_SCHEMA.map((provider) => ({ label: ( <div className="flex items-center gap-2"> {createElement(provider.icon)} @@ -272,7 +310,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 +327,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 @@ -326,6 +364,16 @@ const AutomationDrawer = ({onSuccess}: {onSuccess: () => void}) => { <Input placeholder="Production deploy hook" /> </Form.Item> + {isEdit && ( + <Form.Item label="Active" className="!mb-0"> + <Switch + checked={isActive} + loading={isTogglingActive} + onChange={handleToggleActive} + /> + </Form.Item> + )} + <Form.Item name="events" label="Event Types" @@ -354,7 +402,7 @@ const AutomationDrawer = ({onSuccess}: {onSuccess: () => void}) => { {selectedProviderConfig.subtitle} </Typography.Text> </div> - <AutomationFieldRenderer + <WebhookFieldRenderer fields={selectedProviderConfig.fields} isEditMode={isEdit} /> @@ -385,7 +433,7 @@ const AutomationDrawer = ({onSuccess}: {onSuccess: () => void}) => { label: "Logs", children: activeTab === "logs" ? ( - <AutomationLogsTab subscriptionId={initialValues.id} /> + <WebhookLogsTab subscriptionId={initialValues.id} /> ) : null, }, ] @@ -394,8 +442,11 @@ const AutomationDrawer = ({onSuccess}: {onSuccess: () => void}) => { [ activeTab, form, + handleToggleActive, initialValues?.id, + isActive, isEdit, + isTogglingActive, providerOptions, selectedProviderConfig, setSelectedProvider, @@ -405,7 +456,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 +466,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 +486,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 +505,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/Automations/Automations.tsx b/web/oss/src/components/pages/settings/Automations/Automations.tsx deleted file mode 100644 index fc5165cef3..0000000000 --- a/web/oss/src/components/pages/settings/Automations/Automations.tsx +++ /dev/null @@ -1,260 +0,0 @@ -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 {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 { - AUTOMATION_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" -import { - editingAutomationAtom, - isAutomationDrawerOpenAtom, - webhookToDeleteAtom, -} from "@/oss/state/automations/state" - -const isGitHubApiUrl = (url?: string | null): boolean => { - if (!url) { - return false - } - try { - const parsed = new URL(url) - return parsed.hostname === "api.github.com" - } catch { - return false - } -} - -const getProviderLabel = (url?: string | null): AutomationProvider => { - return isGitHubApiUrl(url) ? "github" : "webhook" -} - -const formatDestination = (url?: string) => { - if (!url) { - return "-" - } - - if (isGitHubApiUrl(url)) { - const repoMatch = url.match(/repos\/([^\/]+\/[^\/]+)\//) - if (repoMatch) { - return repoMatch[1] - } - } - - 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 setWebhookToDelete = useSetAtom(webhookToDeleteAtom) - - const [testingWebhookId, setTestingWebhookId] = useState<string | null>(null) - - const handleCreate = useCallback(() => { - setEditingWebhook(undefined) - setIsDrawerOpen(true) - }, [setEditingWebhook, setIsDrawerOpen]) - - const handleEdit = useCallback( - (webhook: WebhookSubscription) => { - setEditingWebhook(webhook) - setIsDrawerOpen(true) - }, - [setEditingWebhook, setIsDrawerOpen], - ) - - const handleDeleteClick = useCallback( - (webhook: WebhookSubscription) => { - setWebhookToDelete(webhook) - }, - [setWebhookToDelete], - ) - - const handleTestWebhook = useCallback( - async (webhook: WebhookSubscription) => { - try { - setTestingWebhookId(webhook.id) - const response = await testWebhookSubscription({ - subscription: { - id: webhook.id, - name: webhook.name, - description: webhook.description, - data: webhook.data, - }, - }) - handleTestResult(response) - } catch (error) { - console.error(error) - message.error(AUTOMATION_TEST_FAILURE_MESSAGE, 10) - } finally { - setTestingWebhookId(null) - } - }, - [testWebhookSubscription], - ) - - const handleModalSuccess = useCallback(() => { - setIsDrawerOpen(false) - setEditingWebhook(undefined) - }, [setIsDrawerOpen, setEditingWebhook]) - - const columns = useMemo( - () => [ - { - title: "Name", - dataIndex: "name", - key: "name", - onHeaderCell: () => ({ - style: {minWidth: 160}, - }), - render: (name: string | undefined) => ( - <Typography.Text>{name || "-"}</Typography.Text> - ), - }, - { - title: "Type", - key: "provider", - onHeaderCell: () => ({ - style: {minWidth: 100}, - }), - render: (_: any, record: WebhookSubscription) => { - const provider = getProviderLabel(record.data?.url) - return ( - <Typography.Text> - {provider === "github" ? "GitHub" : "Webhook"} - </Typography.Text> - ) - }, - }, - { - title: "Target", - dataIndex: ["data", "url"], - key: "url", - onHeaderCell: () => ({ - style: {minWidth: 160}, - }), - render: (url?: string) => ( - <Typography.Text ellipsis style={{maxWidth: 320}} title={url}> - {formatDestination(url)} - </Typography.Text> - ), - }, - { - title: "Events", - dataIndex: ["data", "event_types"], - key: "events", - onHeaderCell: () => ({ - style: {minWidth: 160}, - }), - render: (events?: string[]) => { - const value = events?.join(", ") || "-" - return ( - <Typography.Text ellipsis style={{maxWidth: 260}} title={value}> - {value} - </Typography.Text> - ) - }, - }, - { - title: <GearSix size={16} />, - key: "actions", - width: 61, - fixed: "right" as const, - align: "center" as const, - render: (_: any, record: WebhookSubscription) => ( - <Dropdown - trigger={["click"]} - styles={{root: {width: 180}}} - menu={{ - items: [ - { - key: "test", - label: "Test", - icon: <Play size={16} />, - disabled: testingWebhookId !== null, - onClick: (e: any) => { - e.domEvent.stopPropagation() - handleTestWebhook(record) - }, - }, - { - key: "edit", - label: "Edit", - icon: <PencilSimpleLine size={16} />, - onClick: (e: any) => { - e.domEvent.stopPropagation() - handleEdit(record) - }, - }, - {type: "divider" as const}, - { - key: "delete", - label: "Delete", - icon: <Trash size={16} />, - danger: true, - onClick: (e: any) => { - e.domEvent.stopPropagation() - handleDeleteClick(record) - }, - }, - ], - }} - > - <Button - type="text" - icon={<MoreOutlined />} - loading={testingWebhookId === record.id} - aria-label="Open automation actions" - onClick={(e) => e.stopPropagation()} - /> - </Dropdown> - ), - }, - ], - [handleDeleteClick, handleEdit, handleTestWebhook, testingWebhookId], - ) - - return ( - <section className="flex flex-col gap-2"> - <div className="flex items-center gap-2"> - <Button - type="primary" - size="small" - icon={<Plus size={14} />} - onClick={handleCreate} - > - Add Automation - </Button> - </div> - - <Table - columns={columns} - dataSource={webhooks ?? []} - loading={isLoading} - rowKey="id" - bordered - pagination={false} - onRow={(record) => ({ - onClick: () => handleEdit(record), - style: {cursor: "pointer"}, - })} - /> - - <AutomationDrawer onSuccess={handleModalSuccess} /> - <DeleteAutomationModal /> - <SecretRevealModal /> - </section> - ) -} - -export default Automations 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/Triggers.tsx b/web/oss/src/components/pages/settings/Triggers/Triggers.tsx new file mode 100644 index 0000000000..727dfaf093 --- /dev/null +++ b/web/oss/src/components/pages/settings/Triggers/Triggers.tsx @@ -0,0 +1,18 @@ +import {TriggerDeliveriesDrawer} from "@agenta/entity-ui/gatewayTrigger" + +import GatewaySchedulesSection from "./components/GatewaySchedulesSection" +import GatewaySubscriptionsSection from "./components/GatewaySubscriptionsSection" +import GatewayTriggersSection from "./components/GatewayTriggersSection" + +export default function Triggers() { + return ( + <div className="flex flex-col gap-6"> + <GatewayTriggersSection /> + <GatewaySubscriptionsSection /> + <GatewaySchedulesSection /> + {/* One shared deliveries drawer for both subscriptions and schedules + (both bind the same atom; rendering it once avoids a duplicate). */} + <TriggerDeliveriesDrawer /> + </div> + ) +} diff --git a/web/oss/src/components/pages/settings/Triggers/components/GatewaySchedulesSection.tsx b/web/oss/src/components/pages/settings/Triggers/components/GatewaySchedulesSection.tsx new file mode 100644 index 0000000000..528dd64935 --- /dev/null +++ b/web/oss/src/components/pages/settings/Triggers/components/GatewaySchedulesSection.tsx @@ -0,0 +1,243 @@ +import {useCallback, useMemo, useState} from "react" + +import { + describeCron, + isEntityActive, + triggerDeliveriesDrawerAtom, + triggerScheduleDrawerAtom, + useTriggerSchedule, + useTriggerSchedules, + type TriggerSchedule, +} from "@agenta/entities/gatewayTrigger" +import {ActiveToggle, TriggerScheduleDrawer} from "@agenta/entity-ui/gatewayTrigger" +import {MoreOutlined} from "@ant-design/icons" +import { + ArrowClockwise, + GearSix, + ListChecks, + PencilSimpleLine, + Plus, + Trash, +} 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 {formatDay} from "@/oss/lib/helpers/dateTimeHelper" + +export default function GatewaySchedulesSection() { + const {schedules, isLoading, refetch} = useTriggerSchedules() + const {remove, setActive, isMutating} = useTriggerSchedule() + const openDrawer = useSetAtom(triggerScheduleDrawerAtom) + const openDeliveries = useSetAtom(triggerDeliveriesDrawerAtom) + const [reloading, setReloading] = useState(false) + + const reloadAll = useCallback(async () => { + setReloading(true) + try { + await refetch() + } finally { + setReloading(false) + } + }, [refetch]) + + const handleCreate = useCallback(() => openDrawer({}), [openDrawer]) + + const handleEdit = useCallback( + (record: TriggerSchedule) => openDrawer({scheduleId: record.id ?? undefined}), + [openDrawer], + ) + + const handleDelete = useCallback( + async (record: TriggerSchedule) => { + if (!record.id) return + try { + await remove(record.id) + message.success("Schedule deleted") + } catch { + message.error("Failed to delete schedule") + } + }, + [remove], + ) + + const handleToggle = useCallback( + (record: TriggerSchedule) => async (next: boolean) => { + if (!record.id) return + await setActive(record.id, next) + }, + [setActive], + ) + + const columns: ColumnsType<TriggerSchedule> = useMemo( + () => [ + { + title: "Name", + key: "name", + onHeaderCell: () => ({style: {minWidth: 160}}), + render: (_, record) => ( + <Typography.Text>{record.name || record.id || "-"}</Typography.Text> + ), + }, + { + title: "Schedule", + key: "schedule", + onHeaderCell: () => ({style: {minWidth: 200}}), + render: (_, record) => { + const cron = record.data?.schedule + if (!cron) return <Typography.Text type="secondary">-</Typography.Text> + return ( + <Tooltip title={cron}> + <Typography.Text>{describeCron(cron)}</Typography.Text> + </Tooltip> + ) + }, + }, + { + title: "Bound workflow", + key: "workflow", + onHeaderCell: () => ({style: {minWidth: 160}}), + render: (_, record) => { + const refs = record.data?.references + const wfId = + refs?.application?.id ?? + refs?.application_variant?.id ?? + refs?.application_revision?.id ?? + null + return ( + <Typography.Text className="text-xs" ellipsis> + {wfId ?? "-"} + </Typography.Text> + ) + }, + }, + { + title: "Status", + key: "status", + onHeaderCell: () => ({style: {minWidth: 100}}), + render: (_, record) => + isEntityActive(record) ? <Tag color="green">Active</Tag> : <Tag>Paused</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: 96, + fixed: "right" as const, + align: "center" as const, + render: (_, record) => ( + <div className="flex items-center justify-center gap-1"> + <ActiveToggle + active={isEntityActive(record)} + onToggle={handleToggle(record)} + disabled={!record.id} + activatedMessage="Schedule resumed" + pausedMessage="Schedule paused" + errorMessage="Failed to update schedule" + /> + <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({ + owner: {kind: "schedule", id: record.id}, + name: record.name ?? undefined, + }) + }, + }, + { + key: "edit", + label: "Edit", + icon: <PencilSimpleLine size={16} />, + onClick: (e) => { + e.domEvent.stopPropagation() + handleEdit(record) + }, + }, + {type: "divider" as const}, + { + 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 schedule actions" + onClick={(e) => e.stopPropagation()} + /> + </Dropdown> + </div> + ), + }, + ], + [handleDelete, handleEdit, handleToggle, openDeliveries], + ) + + return ( + <> + <section className="flex flex-col gap-2"> + <div className="flex items-center gap-2"> + <Button + type="primary" + size="small" + icon={<Plus size={14} />} + onClick={handleCreate} + > + Schedule + </Button> + <Tooltip title="Reload all schedules"> + <Button + icon={<ArrowClockwise size={14} />} + type="text" + size="small" + aria-label="Reload all schedules" + loading={reloading} + onClick={reloadAll} + /> + </Tooltip> + </div> + + <Table<TriggerSchedule> + className="ph-no-capture" + columns={columns} + dataSource={schedules} + rowKey={(record) => record.id ?? record.slug ?? record.data?.schedule ?? ""} + bordered + pagination={false} + loading={isLoading || isMutating} + locale={{emptyText: <Empty description="No schedules yet" />}} + onRow={(record) => ({ + onClick: () => handleEdit(record), + className: "cursor-pointer", + })} + /> + </section> + + <TriggerScheduleDrawer /> + </> + ) +} 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..8ceecc5e38 --- /dev/null +++ b/web/oss/src/components/pages/settings/Triggers/components/GatewaySubscriptionsSection.tsx @@ -0,0 +1,305 @@ +import {useCallback, useMemo, useState} from "react" + +import { + isEntityActive, + isEntityValid, + triggerDeliveriesDrawerAtom, + triggerSubscriptionDrawerAtom, + useTriggerConnectionsQuery, + useTriggerSubscription, + useTriggerSubscriptions, + type TriggerSubscription, +} from "@agenta/entities/gatewayTrigger" +import {ActiveToggle, TriggerSubscriptionDrawer} from "@agenta/entity-ui/gatewayTrigger" +import {MoreOutlined} from "@ant-design/icons" +import { + ArrowClockwise, + ArrowsClockwise, + GearSix, + ListChecks, + PencilSimpleLine, + 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 {formatDay} from "@/oss/lib/helpers/dateTimeHelper" + +export default function GatewaySubscriptionsSection() { + const {subscriptions, isLoading, refetch} = useTriggerSubscriptions() + const {connections} = useTriggerConnectionsQuery() + const {revoke, refresh, remove, setActive, isMutating} = useTriggerSubscription() + 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) => { + 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 handleToggle = useCallback( + (record: TriggerSubscription) => async (next: boolean) => { + if (!record.id) return + await setActive(record.id, next) + }, + [setActive], + ) + + 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) => + // WP1: top-level `enabled`/`valid` are gone; read flags. + !isEntityValid(record) ? ( + <Tag color="red">Invalid</Tag> + ) : isEntityActive(record) ? ( + <Tag color="green">Active</Tag> + ) : ( + <Tag>Paused</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: 96, + fixed: "right" as const, + align: "center" as const, + render: (_, record) => ( + <div className="flex items-center justify-center gap-1"> + <ActiveToggle + active={isEntityActive(record)} + onToggle={handleToggle(record)} + disabled={!record.id || !isEntityValid(record)} + activatedMessage="Subscription resumed" + pausedMessage="Subscription paused" + errorMessage="Failed to update subscription" + /> + <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({ + owner: {kind: "subscription", id: record.id}, + name: 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> + </div> + ), + }, + ], + [ + connectionLabel, + handleDelete, + handleEdit, + handleRefresh, + handleRevoke, + handleToggle, + openDeliveries, + ], + ) + + return ( + <> + <section className="flex flex-col gap-2"> + <div className="flex items-center gap-2"> + <Button + type="primary" + size="small" + icon={<Plus size={14} />} + onClick={handleCreate} + disabled={connections.length === 0} + > + 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> + + <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 /> + </> + ) +} 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..76d7814d10 --- /dev/null +++ b/web/oss/src/components/pages/settings/Triggers/components/GatewayTriggersSection.tsx @@ -0,0 +1,271 @@ +import {useCallback, useMemo, useState} from "react" + +import { + fetchTriggerConnection, + triggerCatalogDrawerOpenAtom, + triggerEventsDrawerAtom, + useTriggerConnectionActions, + useTriggerConnectionsQuery, + type TriggerConnection, +} from "@agenta/entities/gatewayTrigger" +import {ConnectionStatusBadge} from "@agenta/entity-ui/gatewayTool" +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, 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) => { + 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 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( + () => [ + { + 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: "Slug", + dataIndex: "slug", + key: "slug", + onHeaderCell: () => ({style: {minWidth: 160}}), + render: (slug: string) => <Typography.Text>{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: 48, + fixed: "right", + align: "center", + render: (_, 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) + }, + }, + ], + }} + > + <Button + onClick={(e) => e.stopPropagation()} + type="text" + aria-label="Open connection actions" + icon={<MoreOutlined />} + /> + </Dropdown> + ), + }, + ], + [openEvents, onRefresh, confirmRevoke, confirmDelete], + ) + + return ( + <> + <section className="flex flex-col gap-2"> + <div className="flex items-center gap-2"> + <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> + + <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> + + <TriggerCatalogDrawer onConnectionCreated={refetch} /> + <TriggerEventsDrawer /> + </> + ) +} diff --git a/web/oss/src/components/pages/settings/Webhooks/Webhooks.tsx b/web/oss/src/components/pages/settings/Webhooks/Webhooks.tsx new file mode 100644 index 0000000000..d85411b0fd --- /dev/null +++ b/web/oss/src/components/pages/settings/Webhooks/Webhooks.tsx @@ -0,0 +1,313 @@ +import {useCallback, useMemo, useState} from "react" + +import {ActiveToggle} from "@agenta/entity-ui/gatewayTrigger" +import {MoreOutlined} from "@ant-design/icons" +import {ArrowClockwise, GearSix, PencilSimpleLine, Play, Plus, Trash} from "@phosphor-icons/react" +import {Button, Dropdown, Table, Tag, Tooltip, Typography, message} from "antd" +import {useAtom, useSetAtom} from "jotai" + +import DeleteWebhookModal from "@/oss/components/Webhooks/Modals/DeleteWebhookModal" +import SecretRevealModal from "@/oss/components/Webhooks/Modals/SecretRevealModal" +import { + WEBHOOK_TEST_FAILURE_MESSAGE, + handleTestResult, +} from "@/oss/components/Webhooks/utils/handleTestResult" +import WebhookDrawer from "@/oss/components/Webhooks/WebhookDrawer" +import {WebhookProvider, WebhookSubscription} from "@/oss/services/webhooks/types" +import {setWebhookActiveAtom, webhooksAtom, testWebhookAtom} from "@/oss/state/webhooks/atoms" +import { + editingWebhookAtom, + isWebhookDrawerOpenAtom, + webhookToDeleteAtom, +} from "@/oss/state/webhooks/state" + +const isGitHubApiUrl = (url?: string | null): boolean => { + if (!url) { + return false + } + try { + const parsed = new URL(url) + return parsed.hostname === "api.github.com" + } catch { + return false + } +} + +const getProviderLabel = (url?: string | null): WebhookProvider => { + return isGitHubApiUrl(url) ? "github" : "webhook" +} + +// WP6: webhooks now carry `flags.is_active`; default true when absent. +const isWebhookActive = (webhook: WebhookSubscription): boolean => { + const raw = webhook.flags?.is_active + return raw === undefined || raw === null ? true : Boolean(raw) +} + +const formatDestination = (url?: string) => { + if (!url) { + return "-" + } + + if (isGitHubApiUrl(url)) { + const repoMatch = url.match(/repos\/([^\/]+\/[^\/]+)\//) + if (repoMatch) { + return repoMatch[1] + } + } + + return url +} + +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 setWebhookActive = useSetAtom(setWebhookActiveAtom) + 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) + setIsDrawerOpen(true) + }, [setEditingWebhook, setIsDrawerOpen]) + + const handleEdit = useCallback( + (webhook: WebhookSubscription) => { + setEditingWebhook(webhook) + setIsDrawerOpen(true) + }, + [setEditingWebhook, setIsDrawerOpen], + ) + + const handleDeleteClick = useCallback( + (webhook: WebhookSubscription) => { + setWebhookToDelete(webhook) + }, + [setWebhookToDelete], + ) + + const handleTestWebhook = useCallback( + async (webhook: WebhookSubscription) => { + try { + setTestingWebhookId(webhook.id) + const response = await testWebhookSubscription({ + subscription: { + id: webhook.id, + name: webhook.name, + description: webhook.description, + data: webhook.data, + }, + }) + handleTestResult(response) + } catch (error) { + console.error(error) + message.error(WEBHOOK_TEST_FAILURE_MESSAGE, 10) + } finally { + setTestingWebhookId(null) + } + }, + [testWebhookSubscription], + ) + + const handleToggle = useCallback( + (webhook: WebhookSubscription) => async (next: boolean) => { + await setWebhookActive({id: webhook.id, active: next}) + }, + [setWebhookActive], + ) + + const handleModalSuccess = useCallback(() => { + setIsDrawerOpen(false) + setEditingWebhook(undefined) + }, [setIsDrawerOpen, setEditingWebhook]) + + const columns = useMemo( + () => [ + { + title: "Name", + dataIndex: "name", + key: "name", + onHeaderCell: () => ({ + style: {minWidth: 160}, + }), + render: (name: string | undefined) => ( + <Typography.Text>{name || "-"}</Typography.Text> + ), + }, + { + title: "Type", + key: "provider", + onHeaderCell: () => ({ + style: {minWidth: 100}, + }), + render: (_: any, record: WebhookSubscription) => { + const provider = getProviderLabel(record.data?.url) + return ( + <Typography.Text> + {provider === "github" ? "GitHub" : "Webhook"} + </Typography.Text> + ) + }, + }, + { + title: "Target", + dataIndex: ["data", "url"], + key: "url", + onHeaderCell: () => ({ + style: {minWidth: 160}, + }), + render: (url?: string) => ( + <Typography.Text ellipsis style={{maxWidth: 320}} title={url}> + {formatDestination(url)} + </Typography.Text> + ), + }, + { + title: "Events", + dataIndex: ["data", "event_types"], + key: "events", + onHeaderCell: () => ({ + style: {minWidth: 160}, + }), + render: (events?: string[]) => { + const value = events?.join(", ") || "-" + return ( + <Typography.Text ellipsis style={{maxWidth: 260}} title={value}> + {value} + </Typography.Text> + ) + }, + }, + { + title: "Status", + key: "status", + onHeaderCell: () => ({ + style: {minWidth: 100}, + }), + render: (_: any, record: WebhookSubscription) => + isWebhookActive(record) ? <Tag color="green">Active</Tag> : <Tag>Paused</Tag>, + }, + { + title: <GearSix size={16} />, + key: "actions", + width: 96, + fixed: "right" as const, + align: "center" as const, + render: (_: any, record: WebhookSubscription) => ( + <div className="flex items-center justify-center gap-1"> + <ActiveToggle + active={isWebhookActive(record)} + onToggle={handleToggle(record)} + activatedMessage="Webhook resumed" + pausedMessage="Webhook paused" + errorMessage="Failed to update webhook" + /> + <Dropdown + trigger={["click"]} + styles={{root: {width: 180}}} + menu={{ + items: [ + { + key: "test", + label: "Test", + icon: <Play size={16} />, + disabled: testingWebhookId !== null, + onClick: (e: any) => { + e.domEvent.stopPropagation() + handleTestWebhook(record) + }, + }, + { + key: "edit", + label: "Edit", + icon: <PencilSimpleLine size={16} />, + onClick: (e: any) => { + e.domEvent.stopPropagation() + handleEdit(record) + }, + }, + {type: "divider" as const}, + { + key: "delete", + label: "Delete", + icon: <Trash size={16} />, + danger: true, + onClick: (e: any) => { + e.domEvent.stopPropagation() + handleDeleteClick(record) + }, + }, + ], + }} + > + <Button + type="text" + icon={<MoreOutlined />} + loading={testingWebhookId === record.id} + aria-label="Open webhook actions" + onClick={(e) => e.stopPropagation()} + /> + </Dropdown> + </div> + ), + }, + ], + [handleDeleteClick, handleEdit, handleTestWebhook, handleToggle, testingWebhookId], + ) + + return ( + <section className="flex flex-col gap-2"> + <div className="flex items-center gap-2"> + <Button + type="primary" + size="small" + icon={<Plus size={14} />} + onClick={handleCreate} + > + 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 + columns={columns} + dataSource={webhooks ?? []} + loading={isLoading} + rowKey="id" + bordered + pagination={false} + onRow={(record) => ({ + onClick: () => handleEdit(record), + style: {cursor: "pointer"}, + })} + /> + + <WebhookDrawer onSuccess={handleModalSuccess} /> + <DeleteWebhookModal /> + <SecretRevealModal /> + </section> + ) +} + +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 3ab8b8929a..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 @@ -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, }) @@ -48,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 @@ -71,12 +72,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) @@ -121,13 +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": @@ -174,9 +179,11 @@ 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": + return {content: <Triggers />, title: "Triggers"} case "apiKeys": return {content: <APIKeys />, title: "API Keys"} case "billing": @@ -184,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 73% rename from web/oss/src/services/automations/api.ts rename to web/oss/src/services/webhooks/api.ts index 3d6a1a2cac..91e169b4d2 100644 --- a/web/oss/src/services/automations/api.ts +++ b/web/oss/src/services/webhooks/api.ts @@ -55,6 +55,32 @@ const queryWebhookSubscriptions = async ( return response.data } +// Lifecycle verbs toggling `flags.is_active` (WP6). Mirror the trigger +// subscription/schedule start/stop routes: POST /subscriptions/{id}/{verb}. +const startWebhookSubscription = async ( + webhookSubscriptionId: string, + projectId?: string, +): Promise<WebhookSubscriptionResponse> => { + const response = await axios.post( + `${getAgentaApiUrl()}/webhooks/subscriptions/${webhookSubscriptionId}/start`, + {}, + {params: projectId ? {project_id: projectId} : undefined}, + ) + return response.data +} + +const stopWebhookSubscription = async ( + webhookSubscriptionId: string, + projectId?: string, +): Promise<WebhookSubscriptionResponse> => { + const response = await axios.post( + `${getAgentaApiUrl()}/webhooks/subscriptions/${webhookSubscriptionId}/stop`, + {}, + {params: projectId ? {project_id: projectId} : undefined}, + ) + return response.data +} + const testWebhookSubscription = async ( data: WebhookSubscriptionTestRequest, projectId?: string, @@ -80,6 +106,8 @@ export { deleteWebhookSubscription, queryWebhookDeliveries, queryWebhookSubscriptions, + startWebhookSubscription, + stopWebhookSubscription, testWebhookSubscription, editWebhookSubscription, } diff --git a/web/oss/src/services/automations/types.ts b/web/oss/src/services/webhooks/types.ts similarity index 88% rename from web/oss/src/services/automations/types.ts rename to web/oss/src/services/webhooks/types.ts index 2b3ed5e238..e9fb1f284d 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,12 @@ export interface GitHubFormValues extends AutomationFormValuesBase<"github"> { github_branch?: string } -export type AutomationFormValues = WebhookFormValues | GitHubFormValues +export type WebhookFormValues = WebhookConfigFormValues | GitHubFormValues + +/** Typed flags (WP6): webhooks carry only `is_active` (no validity concept). */ +export interface WebhookSubscriptionFlags { + is_active?: boolean +} /** Full subscription as returned by the backend */ export interface WebhookSubscription { @@ -51,6 +56,7 @@ export interface WebhookSubscription { created_at: string updated_at: string data: WebhookSubscriptionData + flags?: WebhookSubscriptionFlags secret?: string secret_id?: string } 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 56% rename from web/oss/src/state/automations/atoms.ts rename to web/oss/src/state/webhooks/atoms.ts index a58d0f0158..e62bf6c247 100644 --- a/web/oss/src/state/automations/atoms.ts +++ b/web/oss/src/state/webhooks/atoms.ts @@ -8,21 +8,24 @@ import { deleteWebhookSubscription, queryWebhookDeliveries, queryWebhookSubscriptions, + startWebhookSubscription, + stopWebhookSubscription, testWebhookSubscription, editWebhookSubscription, -} from "@/oss/services/automations/api" +} from "@/oss/services/webhooks/api" import { + WebhookSubscription, 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 +37,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 +70,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 +96,53 @@ 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( +// Optimistic play/pause: flip `flags.is_active` in the list cache, call the +// start/stop route, refetch on success, roll back + refetch on failure. +export const setWebhookActiveAtom = atom( + null, + async (get, _set, {id, active}: {id: string; active: boolean}) => { + const projectId = get(projectIdAtom) + const listKey = ["webhooks", projectId] + const prev = queryClient.getQueryData<WebhookSubscription[]>(listKey) + if (prev) { + queryClient.setQueryData<WebhookSubscription[]>( + listKey, + prev.map((w) => + w.id === id ? {...w, flags: {...(w.flags ?? {}), is_active: active}} : w, + ), + ) + } + try { + await (active + ? startWebhookSubscription(id, projectId ?? undefined) + : stopWebhookSubscription(id, projectId ?? undefined)) + await queryClient.invalidateQueries({queryKey: ["webhooks"]}) + } catch (error) { + if (prev) queryClient.setQueryData(listKey, prev) + await queryClient.invalidateQueries({queryKey: ["webhooks"]}) + throw error + } + }, +) + +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-api-client/src/generated/Client.ts b/web/packages/agenta-api-client/src/generated/Client.ts index 294bbff9eb..2a6917f2f6 100644 --- a/web/packages/agenta-api-client/src/generated/Client.ts +++ b/web/packages/agenta-api-client/src/generated/Client.ts @@ -21,6 +21,7 @@ import { TestcasesClient } from "./api/resources/testcases/client/Client.js"; import { TestsetsClient } from "./api/resources/testsets/client/Client.js"; import { ToolsClient } from "./api/resources/tools/client/Client.js"; import { TracesClient } from "./api/resources/traces/client/Client.js"; +import { TriggersClient } from "./api/resources/triggers/client/Client.js"; import { UsersClient } from "./api/resources/users/client/Client.js"; import { WebhooksClient } from "./api/resources/webhooks/client/Client.js"; import { WorkflowsClient } from "./api/resources/workflows/client/Client.js"; @@ -57,6 +58,7 @@ export class AgentaApiClient { protected _evaluators: EvaluatorsClient | undefined; protected _environments: EnvironmentsClient | undefined; protected _tools: ToolsClient | undefined; + protected _triggers: TriggersClient | undefined; protected _evaluations: EvaluationsClient | undefined; protected _status: StatusClient | undefined; protected _projects: ProjectsClient | undefined; @@ -147,6 +149,10 @@ export class AgentaApiClient { return (this._tools ??= new ToolsClient(this._options)); } + public get triggers(): TriggersClient { + return (this._triggers ??= new TriggersClient(this._options)); + } + public get evaluations(): EvaluationsClient { return (this._evaluations ??= new EvaluationsClient(this._options)); } diff --git a/web/packages/agenta-api-client/src/generated/api/resources/index.ts b/web/packages/agenta-api-client/src/generated/api/resources/index.ts index f4eedd2806..bdacae37d8 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/index.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/index.ts @@ -46,6 +46,8 @@ export * as tools from "./tools/index.js"; export * from "./traces/client/requests/index.js"; export * as traces from "./traces/index.js"; export * from "./traces/types/index.js"; +export * from "./triggers/client/requests/index.js"; +export * as triggers from "./triggers/index.js"; export * from "./users/client/requests/index.js"; export * as users from "./users/index.js"; export * from "./webhooks/client/requests/index.js"; diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/Client.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/Client.ts new file mode 100644 index 0000000000..424c43aa7a --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/Client.ts @@ -0,0 +1,2515 @@ +// This file was auto-generated by Fern from our API Definition. + +import type { BaseClientOptions, BaseRequestOptions } from "../../../../BaseClient.js"; +import { type NormalizedClientOptionsWithAuth, normalizeClientOptionsWithAuth } from "../../../../BaseClient.js"; +import { mergeHeaders } from "../../../../core/headers.js"; +import * as core from "../../../../core/index.js"; +import * as environments from "../../../../environments.js"; +import { handleNonStatusCodeError } from "../../../../errors/handleNonStatusCodeError.js"; +import * as errors from "../../../../errors/index.js"; +import * as AgentaApi from "../../../index.js"; + +export declare namespace TriggersClient { + export type Options = BaseClientOptions; + + export interface RequestOptions extends BaseRequestOptions {} +} + +/** + * Inbound provider event triggers and their watchable event catalog. + */ +export class TriggersClient { + protected readonly _options: NormalizedClientOptionsWithAuth<TriggersClient.Options>; + + constructor(options: TriggersClient.Options) { + this._options = normalizeClientOptionsWithAuth(options); + } + + /** + * 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. + * + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @example + * await client.triggers.ingestComposioEvent() + */ + public ingestComposioEvent( + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerEventAck> { + return core.HttpResponsePromise.fromPromise(this.__ingestComposioEvent(requestOptions)); + } + + private async __ingestComposioEvent( + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerEventAck>> { + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + "triggers/composio/events/", + ), + method: "POST", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as AgentaApi.TriggerEventAck, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "POST", "/triggers/composio/events/"); + } + + /** + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @example + * await client.triggers.listTriggerProviders() + */ + public listTriggerProviders( + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerCatalogProvidersResponse> { + return core.HttpResponsePromise.fromPromise(this.__listTriggerProviders(requestOptions)); + } + + private async __listTriggerProviders( + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerCatalogProvidersResponse>> { + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + "triggers/catalog/providers/", + ), + method: "GET", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { + data: _response.body as AgentaApi.TriggerCatalogProvidersResponse, + rawResponse: _response.rawResponse, + }; + } + + if (_response.error.reason === "status-code") { + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "GET", "/triggers/catalog/providers/"); + } + + /** + * @param {AgentaApi.FetchTriggerProviderRequest} request + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.triggers.fetchTriggerProvider({ + * provider_key: "provider_key" + * }) + */ + public fetchTriggerProvider( + request: AgentaApi.FetchTriggerProviderRequest, + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerCatalogProviderResponse> { + return core.HttpResponsePromise.fromPromise(this.__fetchTriggerProvider(request, requestOptions)); + } + + private async __fetchTriggerProvider( + request: AgentaApi.FetchTriggerProviderRequest, + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerCatalogProviderResponse>> { + const { provider_key: providerKey } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `triggers/catalog/providers/${core.url.encodePathParam(providerKey)}`, + ), + method: "GET", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { + data: _response.body as AgentaApi.TriggerCatalogProviderResponse, + rawResponse: _response.rawResponse, + }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "GET", + "/triggers/catalog/providers/{provider_key}", + ); + } + + /** + * @param {AgentaApi.ListTriggerIntegrationsRequest} request + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.triggers.listTriggerIntegrations({ + * provider_key: "provider_key" + * }) + */ + public listTriggerIntegrations( + request: AgentaApi.ListTriggerIntegrationsRequest, + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerCatalogIntegrationsResponse> { + return core.HttpResponsePromise.fromPromise(this.__listTriggerIntegrations(request, requestOptions)); + } + + private async __listTriggerIntegrations( + request: AgentaApi.ListTriggerIntegrationsRequest, + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerCatalogIntegrationsResponse>> { + const { provider_key: providerKey, search, sort_by: sortBy, limit, cursor } = request; + const _queryParams: Record<string, unknown> = { + search, + sort_by: sortBy, + limit, + cursor, + }; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `triggers/catalog/providers/${core.url.encodePathParam(providerKey)}/integrations/`, + ), + method: "GET", + headers: _headers, + queryParameters: { ..._queryParams, ...requestOptions?.queryParams }, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { + data: _response.body as AgentaApi.TriggerCatalogIntegrationsResponse, + rawResponse: _response.rawResponse, + }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "GET", + "/triggers/catalog/providers/{provider_key}/integrations/", + ); + } + + /** + * @param {AgentaApi.FetchTriggerIntegrationRequest} request + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.triggers.fetchTriggerIntegration({ + * provider_key: "provider_key", + * integration_key: "integration_key" + * }) + */ + public fetchTriggerIntegration( + request: AgentaApi.FetchTriggerIntegrationRequest, + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerCatalogIntegrationResponse> { + return core.HttpResponsePromise.fromPromise(this.__fetchTriggerIntegration(request, requestOptions)); + } + + private async __fetchTriggerIntegration( + request: AgentaApi.FetchTriggerIntegrationRequest, + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerCatalogIntegrationResponse>> { + const { provider_key: providerKey, integration_key: integrationKey } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `triggers/catalog/providers/${core.url.encodePathParam(providerKey)}/integrations/${core.url.encodePathParam(integrationKey)}`, + ), + method: "GET", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { + data: _response.body as AgentaApi.TriggerCatalogIntegrationResponse, + rawResponse: _response.rawResponse, + }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "GET", + "/triggers/catalog/providers/{provider_key}/integrations/{integration_key}", + ); + } + + /** + * @param {AgentaApi.ListTriggerEventsRequest} request + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.triggers.listTriggerEvents({ + * provider_key: "provider_key", + * integration_key: "integration_key" + * }) + */ + public listTriggerEvents( + request: AgentaApi.ListTriggerEventsRequest, + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerCatalogEventsResponse> { + return core.HttpResponsePromise.fromPromise(this.__listTriggerEvents(request, requestOptions)); + } + + private async __listTriggerEvents( + request: AgentaApi.ListTriggerEventsRequest, + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerCatalogEventsResponse>> { + const { provider_key: providerKey, integration_key: integrationKey, query, limit, cursor } = request; + const _queryParams: Record<string, unknown> = { + query, + limit, + cursor, + }; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `triggers/catalog/providers/${core.url.encodePathParam(providerKey)}/integrations/${core.url.encodePathParam(integrationKey)}/events/`, + ), + method: "GET", + headers: _headers, + queryParameters: { ..._queryParams, ...requestOptions?.queryParams }, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { + data: _response.body as AgentaApi.TriggerCatalogEventsResponse, + rawResponse: _response.rawResponse, + }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "GET", + "/triggers/catalog/providers/{provider_key}/integrations/{integration_key}/events/", + ); + } + + /** + * @param {AgentaApi.FetchTriggerEventRequest} request + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.triggers.fetchTriggerEvent({ + * provider_key: "provider_key", + * integration_key: "integration_key", + * event_key: "event_key" + * }) + */ + public fetchTriggerEvent( + request: AgentaApi.FetchTriggerEventRequest, + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerCatalogEventResponse> { + return core.HttpResponsePromise.fromPromise(this.__fetchTriggerEvent(request, requestOptions)); + } + + private async __fetchTriggerEvent( + request: AgentaApi.FetchTriggerEventRequest, + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerCatalogEventResponse>> { + const { provider_key: providerKey, integration_key: integrationKey, event_key: eventKey } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `triggers/catalog/providers/${core.url.encodePathParam(providerKey)}/integrations/${core.url.encodePathParam(integrationKey)}/events/${core.url.encodePathParam(eventKey)}`, + ), + method: "GET", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { + data: _response.body as AgentaApi.TriggerCatalogEventResponse, + rawResponse: _response.rawResponse, + }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "GET", + "/triggers/catalog/providers/{provider_key}/integrations/{integration_key}/events/{event_key}", + ); + } + + /** + * @param {AgentaApi.QueryTriggerConnectionsRequest} request + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.triggers.queryTriggerConnections() + */ + public queryTriggerConnections( + request: AgentaApi.QueryTriggerConnectionsRequest = {}, + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerConnectionsResponse> { + return core.HttpResponsePromise.fromPromise(this.__queryTriggerConnections(request, requestOptions)); + } + + private async __queryTriggerConnections( + request: AgentaApi.QueryTriggerConnectionsRequest = {}, + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerConnectionsResponse>> { + const { provider_key: providerKey, integration_key: integrationKey } = request; + const _queryParams: Record<string, unknown> = { + provider_key: providerKey, + integration_key: integrationKey, + }; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + "triggers/connections/query", + ), + method: "POST", + headers: _headers, + queryParameters: { ..._queryParams, ...requestOptions?.queryParams }, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as AgentaApi.TriggerConnectionsResponse, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "POST", "/triggers/connections/query"); + } + + /** + * @param {AgentaApi.TriggerConnectionCreateRequest} request + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.triggers.createTriggerConnection({ + * connection: { + * provider_key: "composio", + * integration_key: "integration_key" + * } + * }) + */ + public createTriggerConnection( + request: AgentaApi.TriggerConnectionCreateRequest, + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerConnectionResponse> { + return core.HttpResponsePromise.fromPromise(this.__createTriggerConnection(request, requestOptions)); + } + + private async __createTriggerConnection( + request: AgentaApi.TriggerConnectionCreateRequest, + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerConnectionResponse>> { + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + "triggers/connections/", + ), + method: "POST", + headers: _headers, + contentType: "application/json", + queryParameters: requestOptions?.queryParams, + requestType: "json", + body: request, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as AgentaApi.TriggerConnectionResponse, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "POST", "/triggers/connections/"); + } + + /** + * @param {AgentaApi.FetchTriggerConnectionRequest} request + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.triggers.fetchTriggerConnection({ + * connection_id: "connection_id" + * }) + */ + public fetchTriggerConnection( + request: AgentaApi.FetchTriggerConnectionRequest, + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerConnectionResponse> { + return core.HttpResponsePromise.fromPromise(this.__fetchTriggerConnection(request, requestOptions)); + } + + private async __fetchTriggerConnection( + request: AgentaApi.FetchTriggerConnectionRequest, + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerConnectionResponse>> { + const { connection_id: connectionId } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `triggers/connections/${core.url.encodePathParam(connectionId)}`, + ), + method: "GET", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as AgentaApi.TriggerConnectionResponse, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "GET", + "/triggers/connections/{connection_id}", + ); + } + + /** + * @param {AgentaApi.DeleteTriggerConnectionRequest} request + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.triggers.deleteTriggerConnection({ + * connection_id: "connection_id" + * }) + */ + public deleteTriggerConnection( + request: AgentaApi.DeleteTriggerConnectionRequest, + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<void> { + return core.HttpResponsePromise.fromPromise(this.__deleteTriggerConnection(request, requestOptions)); + } + + private async __deleteTriggerConnection( + request: AgentaApi.DeleteTriggerConnectionRequest, + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<void>> { + const { connection_id: connectionId } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `triggers/connections/${core.url.encodePathParam(connectionId)}`, + ), + method: "DELETE", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: undefined, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "DELETE", + "/triggers/connections/{connection_id}", + ); + } + + /** + * @param {AgentaApi.RefreshTriggerConnectionRequest} request + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.triggers.refreshTriggerConnection({ + * connection_id: "connection_id" + * }) + */ + public refreshTriggerConnection( + request: AgentaApi.RefreshTriggerConnectionRequest, + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerConnectionResponse> { + return core.HttpResponsePromise.fromPromise(this.__refreshTriggerConnection(request, requestOptions)); + } + + private async __refreshTriggerConnection( + request: AgentaApi.RefreshTriggerConnectionRequest, + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerConnectionResponse>> { + const { connection_id: connectionId, force } = request; + const _queryParams: Record<string, unknown> = { + force, + }; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `triggers/connections/${core.url.encodePathParam(connectionId)}/refresh`, + ), + method: "POST", + headers: _headers, + queryParameters: { ..._queryParams, ...requestOptions?.queryParams }, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as AgentaApi.TriggerConnectionResponse, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "POST", + "/triggers/connections/{connection_id}/refresh", + ); + } + + /** + * @param {AgentaApi.RevokeTriggerConnectionRequest} request + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.triggers.revokeTriggerConnection({ + * connection_id: "connection_id" + * }) + */ + public revokeTriggerConnection( + request: AgentaApi.RevokeTriggerConnectionRequest, + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerConnectionResponse> { + return core.HttpResponsePromise.fromPromise(this.__revokeTriggerConnection(request, requestOptions)); + } + + private async __revokeTriggerConnection( + request: AgentaApi.RevokeTriggerConnectionRequest, + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerConnectionResponse>> { + const { connection_id: connectionId } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `triggers/connections/${core.url.encodePathParam(connectionId)}/revoke`, + ), + method: "POST", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as AgentaApi.TriggerConnectionResponse, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "POST", + "/triggers/connections/{connection_id}/revoke", + ); + } + + /** + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @example + * await client.triggers.listTriggerSubscriptions() + */ + public listTriggerSubscriptions( + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerSubscriptionsResponse> { + return core.HttpResponsePromise.fromPromise(this.__listTriggerSubscriptions(requestOptions)); + } + + private async __listTriggerSubscriptions( + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerSubscriptionsResponse>> { + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + "triggers/subscriptions/", + ), + method: "GET", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { + data: _response.body as AgentaApi.TriggerSubscriptionsResponse, + rawResponse: _response.rawResponse, + }; + } + + if (_response.error.reason === "status-code") { + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "GET", "/triggers/subscriptions/"); + } + + /** + * @param {AgentaApi.TriggerSubscriptionCreateRequest} request + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.triggers.createTriggerSubscription({ + * subscription: { + * connection_id: "connection_id", + * data: { + * event_key: "event_key" + * } + * } + * }) + */ + public createTriggerSubscription( + request: AgentaApi.TriggerSubscriptionCreateRequest, + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerSubscriptionResponse> { + return core.HttpResponsePromise.fromPromise(this.__createTriggerSubscription(request, requestOptions)); + } + + private async __createTriggerSubscription( + request: AgentaApi.TriggerSubscriptionCreateRequest, + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerSubscriptionResponse>> { + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + "triggers/subscriptions/", + ), + method: "POST", + headers: _headers, + contentType: "application/json", + queryParameters: requestOptions?.queryParams, + requestType: "json", + body: request, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { + data: _response.body as AgentaApi.TriggerSubscriptionResponse, + rawResponse: _response.rawResponse, + }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "POST", "/triggers/subscriptions/"); + } + + /** + * @param {AgentaApi.TriggerSubscriptionQueryRequest} request + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.triggers.queryTriggerSubscriptions() + */ + public queryTriggerSubscriptions( + request: AgentaApi.TriggerSubscriptionQueryRequest = {}, + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerSubscriptionsResponse> { + return core.HttpResponsePromise.fromPromise(this.__queryTriggerSubscriptions(request, requestOptions)); + } + + private async __queryTriggerSubscriptions( + request: AgentaApi.TriggerSubscriptionQueryRequest = {}, + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerSubscriptionsResponse>> { + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + "triggers/subscriptions/query", + ), + method: "POST", + headers: _headers, + contentType: "application/json", + queryParameters: requestOptions?.queryParams, + requestType: "json", + body: request, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { + data: _response.body as AgentaApi.TriggerSubscriptionsResponse, + rawResponse: _response.rawResponse, + }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "POST", + "/triggers/subscriptions/query", + ); + } + + /** + * @param {AgentaApi.RefreshTriggerSubscriptionRequest} request + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.triggers.refreshTriggerSubscription({ + * subscription_id: "subscription_id" + * }) + */ + public refreshTriggerSubscription( + request: AgentaApi.RefreshTriggerSubscriptionRequest, + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerSubscriptionResponse> { + return core.HttpResponsePromise.fromPromise(this.__refreshTriggerSubscription(request, requestOptions)); + } + + private async __refreshTriggerSubscription( + request: AgentaApi.RefreshTriggerSubscriptionRequest, + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerSubscriptionResponse>> { + const { subscription_id: subscriptionId } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `triggers/subscriptions/${core.url.encodePathParam(subscriptionId)}/refresh`, + ), + method: "POST", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { + data: _response.body as AgentaApi.TriggerSubscriptionResponse, + rawResponse: _response.rawResponse, + }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "POST", + "/triggers/subscriptions/{subscription_id}/refresh", + ); + } + + /** + * @param {AgentaApi.RevokeTriggerSubscriptionRequest} request + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.triggers.revokeTriggerSubscription({ + * subscription_id: "subscription_id" + * }) + */ + public revokeTriggerSubscription( + request: AgentaApi.RevokeTriggerSubscriptionRequest, + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerSubscriptionResponse> { + return core.HttpResponsePromise.fromPromise(this.__revokeTriggerSubscription(request, requestOptions)); + } + + private async __revokeTriggerSubscription( + request: AgentaApi.RevokeTriggerSubscriptionRequest, + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerSubscriptionResponse>> { + const { subscription_id: subscriptionId } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `triggers/subscriptions/${core.url.encodePathParam(subscriptionId)}/revoke`, + ), + method: "POST", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { + data: _response.body as AgentaApi.TriggerSubscriptionResponse, + rawResponse: _response.rawResponse, + }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "POST", + "/triggers/subscriptions/{subscription_id}/revoke", + ); + } + + /** + * @param {AgentaApi.StartTriggerSubscriptionRequest} request + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.triggers.startTriggerSubscription({ + * subscription_id: "subscription_id" + * }) + */ + public startTriggerSubscription( + request: AgentaApi.StartTriggerSubscriptionRequest, + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerSubscriptionResponse> { + return core.HttpResponsePromise.fromPromise(this.__startTriggerSubscription(request, requestOptions)); + } + + private async __startTriggerSubscription( + request: AgentaApi.StartTriggerSubscriptionRequest, + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerSubscriptionResponse>> { + const { subscription_id: subscriptionId } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `triggers/subscriptions/${core.url.encodePathParam(subscriptionId)}/start`, + ), + method: "POST", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { + data: _response.body as AgentaApi.TriggerSubscriptionResponse, + rawResponse: _response.rawResponse, + }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "POST", + "/triggers/subscriptions/{subscription_id}/start", + ); + } + + /** + * @param {AgentaApi.StopTriggerSubscriptionRequest} request + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.triggers.stopTriggerSubscription({ + * subscription_id: "subscription_id" + * }) + */ + public stopTriggerSubscription( + request: AgentaApi.StopTriggerSubscriptionRequest, + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerSubscriptionResponse> { + return core.HttpResponsePromise.fromPromise(this.__stopTriggerSubscription(request, requestOptions)); + } + + private async __stopTriggerSubscription( + request: AgentaApi.StopTriggerSubscriptionRequest, + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerSubscriptionResponse>> { + const { subscription_id: subscriptionId } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `triggers/subscriptions/${core.url.encodePathParam(subscriptionId)}/stop`, + ), + method: "POST", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { + data: _response.body as AgentaApi.TriggerSubscriptionResponse, + rawResponse: _response.rawResponse, + }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "POST", + "/triggers/subscriptions/{subscription_id}/stop", + ); + } + + /** + * @param {AgentaApi.FetchTriggerSubscriptionRequest} request + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.triggers.fetchTriggerSubscription({ + * subscription_id: "subscription_id" + * }) + */ + public fetchTriggerSubscription( + request: AgentaApi.FetchTriggerSubscriptionRequest, + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerSubscriptionResponse> { + return core.HttpResponsePromise.fromPromise(this.__fetchTriggerSubscription(request, requestOptions)); + } + + private async __fetchTriggerSubscription( + request: AgentaApi.FetchTriggerSubscriptionRequest, + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerSubscriptionResponse>> { + const { subscription_id: subscriptionId } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `triggers/subscriptions/${core.url.encodePathParam(subscriptionId)}`, + ), + method: "GET", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { + data: _response.body as AgentaApi.TriggerSubscriptionResponse, + rawResponse: _response.rawResponse, + }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "GET", + "/triggers/subscriptions/{subscription_id}", + ); + } + + /** + * @param {AgentaApi.TriggerSubscriptionEditRequest} request + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.triggers.editTriggerSubscription({ + * subscription_id: "subscription_id", + * subscription: { + * connection_id: "connection_id", + * data: { + * event_key: "event_key" + * } + * } + * }) + */ + public editTriggerSubscription( + request: AgentaApi.TriggerSubscriptionEditRequest, + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerSubscriptionResponse> { + return core.HttpResponsePromise.fromPromise(this.__editTriggerSubscription(request, requestOptions)); + } + + private async __editTriggerSubscription( + request: AgentaApi.TriggerSubscriptionEditRequest, + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerSubscriptionResponse>> { + const { subscription_id: subscriptionId, ..._body } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `triggers/subscriptions/${core.url.encodePathParam(subscriptionId)}`, + ), + method: "PUT", + headers: _headers, + contentType: "application/json", + queryParameters: requestOptions?.queryParams, + requestType: "json", + body: _body, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { + data: _response.body as AgentaApi.TriggerSubscriptionResponse, + rawResponse: _response.rawResponse, + }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "PUT", + "/triggers/subscriptions/{subscription_id}", + ); + } + + /** + * @param {AgentaApi.DeleteTriggerSubscriptionRequest} request + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.triggers.deleteTriggerSubscription({ + * subscription_id: "subscription_id" + * }) + */ + public deleteTriggerSubscription( + request: AgentaApi.DeleteTriggerSubscriptionRequest, + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<void> { + return core.HttpResponsePromise.fromPromise(this.__deleteTriggerSubscription(request, requestOptions)); + } + + private async __deleteTriggerSubscription( + request: AgentaApi.DeleteTriggerSubscriptionRequest, + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<void>> { + const { subscription_id: subscriptionId } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `triggers/subscriptions/${core.url.encodePathParam(subscriptionId)}`, + ), + method: "DELETE", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: undefined, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "DELETE", + "/triggers/subscriptions/{subscription_id}", + ); + } + + /** + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @example + * await client.triggers.listTriggerSchedules() + */ + public listTriggerSchedules( + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerSchedulesResponse> { + return core.HttpResponsePromise.fromPromise(this.__listTriggerSchedules(requestOptions)); + } + + private async __listTriggerSchedules( + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerSchedulesResponse>> { + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + "triggers/schedules", + ), + method: "GET", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as AgentaApi.TriggerSchedulesResponse, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "GET", "/triggers/schedules"); + } + + /** + * @param {AgentaApi.TriggerScheduleCreateRequest} request + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.triggers.createTriggerSchedule({ + * schedule: { + * data: { + * event_key: "event_key", + * schedule: "schedule" + * } + * } + * }) + */ + public createTriggerSchedule( + request: AgentaApi.TriggerScheduleCreateRequest, + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerScheduleResponse> { + return core.HttpResponsePromise.fromPromise(this.__createTriggerSchedule(request, requestOptions)); + } + + private async __createTriggerSchedule( + request: AgentaApi.TriggerScheduleCreateRequest, + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerScheduleResponse>> { + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + "triggers/schedules", + ), + method: "POST", + headers: _headers, + contentType: "application/json", + queryParameters: requestOptions?.queryParams, + requestType: "json", + body: request, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as AgentaApi.TriggerScheduleResponse, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "POST", "/triggers/schedules"); + } + + /** + * @param {AgentaApi.TriggerScheduleQueryRequest} request + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.triggers.queryTriggerSchedules() + */ + public queryTriggerSchedules( + request: AgentaApi.TriggerScheduleQueryRequest = {}, + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerSchedulesResponse> { + return core.HttpResponsePromise.fromPromise(this.__queryTriggerSchedules(request, requestOptions)); + } + + private async __queryTriggerSchedules( + request: AgentaApi.TriggerScheduleQueryRequest = {}, + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerSchedulesResponse>> { + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + "triggers/schedules/query", + ), + method: "POST", + headers: _headers, + contentType: "application/json", + queryParameters: requestOptions?.queryParams, + requestType: "json", + body: request, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as AgentaApi.TriggerSchedulesResponse, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "POST", "/triggers/schedules/query"); + } + + /** + * @param {AgentaApi.FetchTriggerScheduleRequest} request + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.triggers.fetchTriggerSchedule({ + * schedule_id: "schedule_id" + * }) + */ + public fetchTriggerSchedule( + request: AgentaApi.FetchTriggerScheduleRequest, + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerScheduleResponse> { + return core.HttpResponsePromise.fromPromise(this.__fetchTriggerSchedule(request, requestOptions)); + } + + private async __fetchTriggerSchedule( + request: AgentaApi.FetchTriggerScheduleRequest, + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerScheduleResponse>> { + const { schedule_id: scheduleId } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `triggers/schedules/${core.url.encodePathParam(scheduleId)}`, + ), + method: "GET", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as AgentaApi.TriggerScheduleResponse, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "GET", + "/triggers/schedules/{schedule_id}", + ); + } + + /** + * @param {AgentaApi.TriggerScheduleEditRequest} request + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.triggers.editTriggerSchedule({ + * schedule_id: "schedule_id", + * schedule: { + * data: { + * event_key: "event_key", + * schedule: "schedule" + * } + * } + * }) + */ + public editTriggerSchedule( + request: AgentaApi.TriggerScheduleEditRequest, + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerScheduleResponse> { + return core.HttpResponsePromise.fromPromise(this.__editTriggerSchedule(request, requestOptions)); + } + + private async __editTriggerSchedule( + request: AgentaApi.TriggerScheduleEditRequest, + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerScheduleResponse>> { + const { schedule_id: scheduleId, ..._body } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `triggers/schedules/${core.url.encodePathParam(scheduleId)}`, + ), + method: "PUT", + headers: _headers, + contentType: "application/json", + queryParameters: requestOptions?.queryParams, + requestType: "json", + body: _body, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as AgentaApi.TriggerScheduleResponse, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "PUT", + "/triggers/schedules/{schedule_id}", + ); + } + + /** + * @param {AgentaApi.DeleteTriggerScheduleRequest} request + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.triggers.deleteTriggerSchedule({ + * schedule_id: "schedule_id" + * }) + */ + public deleteTriggerSchedule( + request: AgentaApi.DeleteTriggerScheduleRequest, + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<void> { + return core.HttpResponsePromise.fromPromise(this.__deleteTriggerSchedule(request, requestOptions)); + } + + private async __deleteTriggerSchedule( + request: AgentaApi.DeleteTriggerScheduleRequest, + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<void>> { + const { schedule_id: scheduleId } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `triggers/schedules/${core.url.encodePathParam(scheduleId)}`, + ), + method: "DELETE", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: undefined, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "DELETE", + "/triggers/schedules/{schedule_id}", + ); + } + + /** + * @param {AgentaApi.StartTriggerScheduleRequest} request + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.triggers.startTriggerSchedule({ + * schedule_id: "schedule_id" + * }) + */ + public startTriggerSchedule( + request: AgentaApi.StartTriggerScheduleRequest, + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerScheduleResponse> { + return core.HttpResponsePromise.fromPromise(this.__startTriggerSchedule(request, requestOptions)); + } + + private async __startTriggerSchedule( + request: AgentaApi.StartTriggerScheduleRequest, + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerScheduleResponse>> { + const { schedule_id: scheduleId } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `triggers/schedules/${core.url.encodePathParam(scheduleId)}/start`, + ), + method: "POST", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as AgentaApi.TriggerScheduleResponse, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "POST", + "/triggers/schedules/{schedule_id}/start", + ); + } + + /** + * @param {AgentaApi.StopTriggerScheduleRequest} request + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.triggers.stopTriggerSchedule({ + * schedule_id: "schedule_id" + * }) + */ + public stopTriggerSchedule( + request: AgentaApi.StopTriggerScheduleRequest, + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerScheduleResponse> { + return core.HttpResponsePromise.fromPromise(this.__stopTriggerSchedule(request, requestOptions)); + } + + private async __stopTriggerSchedule( + request: AgentaApi.StopTriggerScheduleRequest, + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerScheduleResponse>> { + const { schedule_id: scheduleId } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `triggers/schedules/${core.url.encodePathParam(scheduleId)}/stop`, + ), + method: "POST", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as AgentaApi.TriggerScheduleResponse, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "POST", + "/triggers/schedules/{schedule_id}/stop", + ); + } + + /** + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @example + * await client.triggers.listTriggerDeliveries() + */ + public listTriggerDeliveries( + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerDeliveriesResponse> { + return core.HttpResponsePromise.fromPromise(this.__listTriggerDeliveries(requestOptions)); + } + + private async __listTriggerDeliveries( + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerDeliveriesResponse>> { + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + "triggers/deliveries", + ), + method: "GET", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as AgentaApi.TriggerDeliveriesResponse, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "GET", "/triggers/deliveries"); + } + + /** + * @param {AgentaApi.TriggerDeliveryQueryRequest} request + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.triggers.queryTriggerDeliveries() + */ + public queryTriggerDeliveries( + request: AgentaApi.TriggerDeliveryQueryRequest = {}, + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerDeliveriesResponse> { + return core.HttpResponsePromise.fromPromise(this.__queryTriggerDeliveries(request, requestOptions)); + } + + private async __queryTriggerDeliveries( + request: AgentaApi.TriggerDeliveryQueryRequest = {}, + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerDeliveriesResponse>> { + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + "triggers/deliveries/query", + ), + method: "POST", + headers: _headers, + contentType: "application/json", + queryParameters: requestOptions?.queryParams, + requestType: "json", + body: request, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as AgentaApi.TriggerDeliveriesResponse, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "POST", "/triggers/deliveries/query"); + } + + /** + * @param {AgentaApi.FetchTriggerDeliveryRequest} request + * @param {TriggersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.triggers.fetchTriggerDelivery({ + * delivery_id: "delivery_id" + * }) + */ + public fetchTriggerDelivery( + request: AgentaApi.FetchTriggerDeliveryRequest, + requestOptions?: TriggersClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.TriggerDeliveryResponse> { + return core.HttpResponsePromise.fromPromise(this.__fetchTriggerDelivery(request, requestOptions)); + } + + private async __fetchTriggerDelivery( + request: AgentaApi.FetchTriggerDeliveryRequest, + requestOptions?: TriggersClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.TriggerDeliveryResponse>> { + const { delivery_id: deliveryId } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `triggers/deliveries/${core.url.encodePathParam(deliveryId)}`, + ), + method: "GET", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as AgentaApi.TriggerDeliveryResponse, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "GET", + "/triggers/deliveries/{delivery_id}", + ); + } +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/index.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/index.ts new file mode 100644 index 0000000000..195f9aa8a8 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/index.ts @@ -0,0 +1 @@ +export * from "./requests/index.js"; diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/DeleteTriggerConnectionRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/DeleteTriggerConnectionRequest.ts new file mode 100644 index 0000000000..f414ac24c2 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/DeleteTriggerConnectionRequest.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * connection_id: "connection_id" + * } + */ +export interface DeleteTriggerConnectionRequest { + connection_id: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/DeleteTriggerScheduleRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/DeleteTriggerScheduleRequest.ts new file mode 100644 index 0000000000..fc22b983bb --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/DeleteTriggerScheduleRequest.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * schedule_id: "schedule_id" + * } + */ +export interface DeleteTriggerScheduleRequest { + schedule_id: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/DeleteTriggerSubscriptionRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/DeleteTriggerSubscriptionRequest.ts new file mode 100644 index 0000000000..2f1ccfc10f --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/DeleteTriggerSubscriptionRequest.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * subscription_id: "subscription_id" + * } + */ +export interface DeleteTriggerSubscriptionRequest { + subscription_id: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/FetchTriggerConnectionRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/FetchTriggerConnectionRequest.ts new file mode 100644 index 0000000000..c7c576cb83 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/FetchTriggerConnectionRequest.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * connection_id: "connection_id" + * } + */ +export interface FetchTriggerConnectionRequest { + connection_id: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/FetchTriggerDeliveryRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/FetchTriggerDeliveryRequest.ts new file mode 100644 index 0000000000..85db961a9d --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/FetchTriggerDeliveryRequest.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * delivery_id: "delivery_id" + * } + */ +export interface FetchTriggerDeliveryRequest { + delivery_id: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/FetchTriggerEventRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/FetchTriggerEventRequest.ts new file mode 100644 index 0000000000..902ae53340 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/FetchTriggerEventRequest.ts @@ -0,0 +1,15 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * provider_key: "provider_key", + * integration_key: "integration_key", + * event_key: "event_key" + * } + */ +export interface FetchTriggerEventRequest { + provider_key: string; + integration_key: string; + event_key: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/FetchTriggerIntegrationRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/FetchTriggerIntegrationRequest.ts new file mode 100644 index 0000000000..c505b0a434 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/FetchTriggerIntegrationRequest.ts @@ -0,0 +1,13 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * provider_key: "provider_key", + * integration_key: "integration_key" + * } + */ +export interface FetchTriggerIntegrationRequest { + provider_key: string; + integration_key: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/FetchTriggerProviderRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/FetchTriggerProviderRequest.ts new file mode 100644 index 0000000000..6b8403031a --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/FetchTriggerProviderRequest.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * provider_key: "provider_key" + * } + */ +export interface FetchTriggerProviderRequest { + provider_key: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/FetchTriggerScheduleRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/FetchTriggerScheduleRequest.ts new file mode 100644 index 0000000000..e8e9d61f54 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/FetchTriggerScheduleRequest.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * schedule_id: "schedule_id" + * } + */ +export interface FetchTriggerScheduleRequest { + schedule_id: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/FetchTriggerSubscriptionRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/FetchTriggerSubscriptionRequest.ts new file mode 100644 index 0000000000..b6f3f7fcd0 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/FetchTriggerSubscriptionRequest.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * subscription_id: "subscription_id" + * } + */ +export interface FetchTriggerSubscriptionRequest { + subscription_id: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/ListTriggerEventsRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/ListTriggerEventsRequest.ts new file mode 100644 index 0000000000..a45f651e8d --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/ListTriggerEventsRequest.ts @@ -0,0 +1,16 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * provider_key: "provider_key", + * integration_key: "integration_key" + * } + */ +export interface ListTriggerEventsRequest { + provider_key: string; + integration_key: string; + query?: string | null; + limit?: number | null; + cursor?: string | null; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/ListTriggerIntegrationsRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/ListTriggerIntegrationsRequest.ts new file mode 100644 index 0000000000..0ae4bc4174 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/ListTriggerIntegrationsRequest.ts @@ -0,0 +1,15 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * provider_key: "provider_key" + * } + */ +export interface ListTriggerIntegrationsRequest { + provider_key: string; + search?: string | null; + sort_by?: string | null; + limit?: number | null; + cursor?: string | null; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/QueryTriggerConnectionsRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/QueryTriggerConnectionsRequest.ts new file mode 100644 index 0000000000..5f0d8d9b3e --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/QueryTriggerConnectionsRequest.ts @@ -0,0 +1,10 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * {} + */ +export interface QueryTriggerConnectionsRequest { + provider_key?: string | null; + integration_key?: string | null; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/RefreshTriggerConnectionRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/RefreshTriggerConnectionRequest.ts new file mode 100644 index 0000000000..c68685b048 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/RefreshTriggerConnectionRequest.ts @@ -0,0 +1,12 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * connection_id: "connection_id" + * } + */ +export interface RefreshTriggerConnectionRequest { + connection_id: string; + force?: boolean; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/RefreshTriggerSubscriptionRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/RefreshTriggerSubscriptionRequest.ts new file mode 100644 index 0000000000..79070768a0 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/RefreshTriggerSubscriptionRequest.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * subscription_id: "subscription_id" + * } + */ +export interface RefreshTriggerSubscriptionRequest { + subscription_id: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/RevokeTriggerConnectionRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/RevokeTriggerConnectionRequest.ts new file mode 100644 index 0000000000..d769bb2e9e --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/RevokeTriggerConnectionRequest.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * connection_id: "connection_id" + * } + */ +export interface RevokeTriggerConnectionRequest { + connection_id: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/RevokeTriggerSubscriptionRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/RevokeTriggerSubscriptionRequest.ts new file mode 100644 index 0000000000..4647980942 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/RevokeTriggerSubscriptionRequest.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * subscription_id: "subscription_id" + * } + */ +export interface RevokeTriggerSubscriptionRequest { + subscription_id: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/StartTriggerScheduleRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/StartTriggerScheduleRequest.ts new file mode 100644 index 0000000000..502d197b99 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/StartTriggerScheduleRequest.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * schedule_id: "schedule_id" + * } + */ +export interface StartTriggerScheduleRequest { + schedule_id: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/StartTriggerSubscriptionRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/StartTriggerSubscriptionRequest.ts new file mode 100644 index 0000000000..60dab3c296 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/StartTriggerSubscriptionRequest.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * subscription_id: "subscription_id" + * } + */ +export interface StartTriggerSubscriptionRequest { + subscription_id: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/StopTriggerScheduleRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/StopTriggerScheduleRequest.ts new file mode 100644 index 0000000000..c81a081ab4 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/StopTriggerScheduleRequest.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * schedule_id: "schedule_id" + * } + */ +export interface StopTriggerScheduleRequest { + schedule_id: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/StopTriggerSubscriptionRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/StopTriggerSubscriptionRequest.ts new file mode 100644 index 0000000000..f944f6e2f6 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/StopTriggerSubscriptionRequest.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * subscription_id: "subscription_id" + * } + */ +export interface StopTriggerSubscriptionRequest { + subscription_id: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/TriggerConnectionCreateRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/TriggerConnectionCreateRequest.ts new file mode 100644 index 0000000000..42b1c54867 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/TriggerConnectionCreateRequest.ts @@ -0,0 +1,16 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../../../../index.js"; + +/** + * @example + * { + * connection: { + * provider_key: "composio", + * integration_key: "integration_key" + * } + * } + */ +export interface TriggerConnectionCreateRequest { + connection: AgentaApi.TriggerConnectionCreate; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/TriggerDeliveryQueryRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/TriggerDeliveryQueryRequest.ts new file mode 100644 index 0000000000..d001919d17 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/TriggerDeliveryQueryRequest.ts @@ -0,0 +1,12 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../../../../index.js"; + +/** + * @example + * {} + */ +export interface TriggerDeliveryQueryRequest { + delivery?: AgentaApi.TriggerDeliveryQuery | null; + windowing?: AgentaApi.Windowing | null; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/TriggerScheduleCreateRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/TriggerScheduleCreateRequest.ts new file mode 100644 index 0000000000..ef701fbf4e --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/TriggerScheduleCreateRequest.ts @@ -0,0 +1,18 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../../../../index.js"; + +/** + * @example + * { + * schedule: { + * data: { + * event_key: "event_key", + * schedule: "schedule" + * } + * } + * } + */ +export interface TriggerScheduleCreateRequest { + schedule: AgentaApi.TriggerScheduleCreate; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/TriggerScheduleEditRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/TriggerScheduleEditRequest.ts new file mode 100644 index 0000000000..13622848d7 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/TriggerScheduleEditRequest.ts @@ -0,0 +1,20 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../../../../index.js"; + +/** + * @example + * { + * schedule_id: "schedule_id", + * schedule: { + * data: { + * event_key: "event_key", + * schedule: "schedule" + * } + * } + * } + */ +export interface TriggerScheduleEditRequest { + schedule_id: string; + schedule: AgentaApi.TriggerScheduleEdit; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/TriggerScheduleQueryRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/TriggerScheduleQueryRequest.ts new file mode 100644 index 0000000000..86d16cee37 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/TriggerScheduleQueryRequest.ts @@ -0,0 +1,12 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../../../../index.js"; + +/** + * @example + * {} + */ +export interface TriggerScheduleQueryRequest { + schedule?: AgentaApi.TriggerScheduleQuery | null; + windowing?: AgentaApi.Windowing | null; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/TriggerSubscriptionCreateRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/TriggerSubscriptionCreateRequest.ts new file mode 100644 index 0000000000..579965640c --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/TriggerSubscriptionCreateRequest.ts @@ -0,0 +1,18 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../../../../index.js"; + +/** + * @example + * { + * subscription: { + * connection_id: "connection_id", + * data: { + * event_key: "event_key" + * } + * } + * } + */ +export interface TriggerSubscriptionCreateRequest { + subscription: AgentaApi.TriggerSubscriptionCreate; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/TriggerSubscriptionEditRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/TriggerSubscriptionEditRequest.ts new file mode 100644 index 0000000000..1ea2013ed9 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/TriggerSubscriptionEditRequest.ts @@ -0,0 +1,20 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../../../../index.js"; + +/** + * @example + * { + * subscription_id: "subscription_id", + * subscription: { + * connection_id: "connection_id", + * data: { + * event_key: "event_key" + * } + * } + * } + */ +export interface TriggerSubscriptionEditRequest { + subscription_id: string; + subscription: AgentaApi.TriggerSubscriptionEdit; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/TriggerSubscriptionQueryRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/TriggerSubscriptionQueryRequest.ts new file mode 100644 index 0000000000..9ab5f78302 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/TriggerSubscriptionQueryRequest.ts @@ -0,0 +1,12 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../../../../index.js"; + +/** + * @example + * {} + */ +export interface TriggerSubscriptionQueryRequest { + subscription?: AgentaApi.TriggerSubscriptionQuery | null; + windowing?: AgentaApi.Windowing | null; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/index.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/index.ts new file mode 100644 index 0000000000..597dae1f2a --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/client/requests/index.ts @@ -0,0 +1,29 @@ +export type { DeleteTriggerConnectionRequest } from "./DeleteTriggerConnectionRequest.js"; +export type { DeleteTriggerScheduleRequest } from "./DeleteTriggerScheduleRequest.js"; +export type { DeleteTriggerSubscriptionRequest } from "./DeleteTriggerSubscriptionRequest.js"; +export type { FetchTriggerConnectionRequest } from "./FetchTriggerConnectionRequest.js"; +export type { FetchTriggerDeliveryRequest } from "./FetchTriggerDeliveryRequest.js"; +export type { FetchTriggerEventRequest } from "./FetchTriggerEventRequest.js"; +export type { FetchTriggerIntegrationRequest } from "./FetchTriggerIntegrationRequest.js"; +export type { FetchTriggerProviderRequest } from "./FetchTriggerProviderRequest.js"; +export type { FetchTriggerScheduleRequest } from "./FetchTriggerScheduleRequest.js"; +export type { FetchTriggerSubscriptionRequest } from "./FetchTriggerSubscriptionRequest.js"; +export type { ListTriggerEventsRequest } from "./ListTriggerEventsRequest.js"; +export type { ListTriggerIntegrationsRequest } from "./ListTriggerIntegrationsRequest.js"; +export type { QueryTriggerConnectionsRequest } from "./QueryTriggerConnectionsRequest.js"; +export type { RefreshTriggerConnectionRequest } from "./RefreshTriggerConnectionRequest.js"; +export type { RefreshTriggerSubscriptionRequest } from "./RefreshTriggerSubscriptionRequest.js"; +export type { RevokeTriggerConnectionRequest } from "./RevokeTriggerConnectionRequest.js"; +export type { RevokeTriggerSubscriptionRequest } from "./RevokeTriggerSubscriptionRequest.js"; +export type { StartTriggerScheduleRequest } from "./StartTriggerScheduleRequest.js"; +export type { StartTriggerSubscriptionRequest } from "./StartTriggerSubscriptionRequest.js"; +export type { StopTriggerScheduleRequest } from "./StopTriggerScheduleRequest.js"; +export type { StopTriggerSubscriptionRequest } from "./StopTriggerSubscriptionRequest.js"; +export type { TriggerConnectionCreateRequest } from "./TriggerConnectionCreateRequest.js"; +export type { TriggerDeliveryQueryRequest } from "./TriggerDeliveryQueryRequest.js"; +export type { TriggerScheduleCreateRequest } from "./TriggerScheduleCreateRequest.js"; +export type { TriggerScheduleEditRequest } from "./TriggerScheduleEditRequest.js"; +export type { TriggerScheduleQueryRequest } from "./TriggerScheduleQueryRequest.js"; +export type { TriggerSubscriptionCreateRequest } from "./TriggerSubscriptionCreateRequest.js"; +export type { TriggerSubscriptionEditRequest } from "./TriggerSubscriptionEditRequest.js"; +export type { TriggerSubscriptionQueryRequest } from "./TriggerSubscriptionQueryRequest.js"; diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/exports.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/exports.ts new file mode 100644 index 0000000000..7daeef65fe --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/exports.ts @@ -0,0 +1,4 @@ +// This file was auto-generated by Fern from our API Definition. + +export { TriggersClient } from "./client/Client.js"; +export * from "./client/index.js"; diff --git a/web/packages/agenta-api-client/src/generated/api/resources/triggers/index.ts b/web/packages/agenta-api-client/src/generated/api/resources/triggers/index.ts new file mode 100644 index 0000000000..914b8c3c72 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/triggers/index.ts @@ -0,0 +1 @@ +export * from "./client/index.js"; diff --git a/web/packages/agenta-api-client/src/generated/api/resources/webhooks/client/Client.ts b/web/packages/agenta-api-client/src/generated/api/resources/webhooks/client/Client.ts index 285c0c4e13..775b7a15ad 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/webhooks/client/Client.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/webhooks/client/Client.ts @@ -485,6 +485,160 @@ export class WebhooksClient { ); } + /** + * @param {AgentaApi.StartWebhookSubscriptionRequest} request + * @param {WebhooksClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.webhooks.startWebhookSubscription({ + * subscription_id: "subscription_id" + * }) + */ + public startWebhookSubscription( + request: AgentaApi.StartWebhookSubscriptionRequest, + requestOptions?: WebhooksClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.WebhookSubscriptionResponse> { + return core.HttpResponsePromise.fromPromise(this.__startWebhookSubscription(request, requestOptions)); + } + + private async __startWebhookSubscription( + request: AgentaApi.StartWebhookSubscriptionRequest, + requestOptions?: WebhooksClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.WebhookSubscriptionResponse>> { + const { subscription_id: subscriptionId } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `webhooks/subscriptions/${core.url.encodePathParam(subscriptionId)}/start`, + ), + method: "POST", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { + data: _response.body as AgentaApi.WebhookSubscriptionResponse, + rawResponse: _response.rawResponse, + }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "POST", + "/webhooks/subscriptions/{subscription_id}/start", + ); + } + + /** + * @param {AgentaApi.StopWebhookSubscriptionRequest} request + * @param {WebhooksClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.webhooks.stopWebhookSubscription({ + * subscription_id: "subscription_id" + * }) + */ + public stopWebhookSubscription( + request: AgentaApi.StopWebhookSubscriptionRequest, + requestOptions?: WebhooksClient.RequestOptions, + ): core.HttpResponsePromise<AgentaApi.WebhookSubscriptionResponse> { + return core.HttpResponsePromise.fromPromise(this.__stopWebhookSubscription(request, requestOptions)); + } + + private async __stopWebhookSubscription( + request: AgentaApi.StopWebhookSubscriptionRequest, + requestOptions?: WebhooksClient.RequestOptions, + ): Promise<core.WithRawResponse<AgentaApi.WebhookSubscriptionResponse>> { + const { subscription_id: subscriptionId } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `webhooks/subscriptions/${core.url.encodePathParam(subscriptionId)}/stop`, + ), + method: "POST", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { + data: _response.body as AgentaApi.WebhookSubscriptionResponse, + rawResponse: _response.rawResponse, + }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "POST", + "/webhooks/subscriptions/{subscription_id}/stop", + ); + } + /** * @param {AgentaApi.WebhookDeliveryCreateRequest} request * @param {WebhooksClient.RequestOptions} requestOptions - Request-specific configuration. diff --git a/web/packages/agenta-api-client/src/generated/api/resources/webhooks/client/requests/StartWebhookSubscriptionRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/webhooks/client/requests/StartWebhookSubscriptionRequest.ts new file mode 100644 index 0000000000..1d4a990eb0 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/webhooks/client/requests/StartWebhookSubscriptionRequest.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * subscription_id: "subscription_id" + * } + */ +export interface StartWebhookSubscriptionRequest { + subscription_id: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/webhooks/client/requests/StopWebhookSubscriptionRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/webhooks/client/requests/StopWebhookSubscriptionRequest.ts new file mode 100644 index 0000000000..5e1874e0d1 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/webhooks/client/requests/StopWebhookSubscriptionRequest.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * subscription_id: "subscription_id" + * } + */ +export interface StopWebhookSubscriptionRequest { + subscription_id: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/webhooks/client/requests/index.ts b/web/packages/agenta-api-client/src/generated/api/resources/webhooks/client/requests/index.ts index 7c2614207b..f8c9833c3a 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/webhooks/client/requests/index.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/webhooks/client/requests/index.ts @@ -1,6 +1,8 @@ export type { DeleteWebhookSubscriptionRequest } from "./DeleteWebhookSubscriptionRequest.js"; export type { FetchWebhookDeliveryRequest } from "./FetchWebhookDeliveryRequest.js"; export type { FetchWebhookSubscriptionRequest } from "./FetchWebhookSubscriptionRequest.js"; +export type { StartWebhookSubscriptionRequest } from "./StartWebhookSubscriptionRequest.js"; +export type { StopWebhookSubscriptionRequest } from "./StopWebhookSubscriptionRequest.js"; export type { WebhookDeliveryCreateRequest } from "./WebhookDeliveryCreateRequest.js"; export type { WebhookDeliveryQueryRequest } from "./WebhookDeliveryQueryRequest.js"; export type { WebhookSubscriptionCreateRequest } from "./WebhookSubscriptionCreateRequest.js"; diff --git a/web/packages/agenta-api-client/src/generated/api/types/Permission.ts b/web/packages/agenta-api-client/src/generated/api/types/Permission.ts index 7daa618a8d..3ad4a367df 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/Permission.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/Permission.ts @@ -74,5 +74,8 @@ export const Permission = { ViewTools: "view_tools", EditTools: "edit_tools", RunTools: "run_tools", + ViewTriggers: "view_triggers", + EditTriggers: "edit_triggers", + RunTriggers: "run_triggers", } as const; export type Permission = (typeof Permission)[keyof typeof Permission]; diff --git a/web/packages/agenta-api-client/src/generated/api/types/Selector.ts b/web/packages/agenta-api-client/src/generated/api/types/Selector.ts new file mode 100644 index 0000000000..7610220473 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/Selector.ts @@ -0,0 +1,19 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * Selector for extracting specific data from entities. + * + * Placed alongside Reference for data extraction from referenced entities. + * + * Fields: + * - **key**: For environment revisions only. Navigates to data.references.<key>, + * follows the entity pointer found there (e.g. workflow_revision), fetches that + * entity, then applies path against its data. + * - **path**: Dot notation path into the resolved entity's data. + * If key is set, path applies to the secondary entity's data. + * If key is not set, path applies directly to the referenced entity's data. + */ +export interface Selector { + key?: (string | null) | undefined; + path?: (string | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerAuthScheme.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerAuthScheme.ts new file mode 100644 index 0000000000..b1f4318384 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerAuthScheme.ts @@ -0,0 +1,7 @@ +// This file was auto-generated by Fern from our API Definition. + +export const TriggerAuthScheme = { + Oauth: "oauth", + ApiKey: "api_key", +} as const; +export type TriggerAuthScheme = (typeof TriggerAuthScheme)[keyof typeof TriggerAuthScheme]; diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogEvent.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogEvent.ts new file mode 100644 index 0000000000..b152dbde12 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogEvent.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface TriggerCatalogEvent { + key: string; + name: string; + description?: (string | null) | undefined; + provider?: (string | null) | undefined; + integration?: (string | null) | undefined; + categories?: string[] | undefined; + logo?: (string | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogEventDetails.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogEventDetails.ts new file mode 100644 index 0000000000..dc88a4edfd --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogEventDetails.ts @@ -0,0 +1,13 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface TriggerCatalogEventDetails { + key: string; + name: string; + description?: (string | null) | undefined; + provider?: (string | null) | undefined; + integration?: (string | null) | undefined; + categories?: string[] | undefined; + logo?: (string | null) | undefined; + trigger_config?: (Record<string, unknown> | null) | undefined; + payload?: (Record<string, unknown> | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogEventResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogEventResponse.ts new file mode 100644 index 0000000000..a77d9d7a60 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogEventResponse.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerCatalogEventResponse { + count?: number | undefined; + event?: (AgentaApi.TriggerCatalogEventDetails | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogEventsResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogEventsResponse.ts new file mode 100644 index 0000000000..2478d3cc8d --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogEventsResponse.ts @@ -0,0 +1,10 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerCatalogEventsResponse { + count?: number | undefined; + total?: number | undefined; + cursor?: (string | null) | undefined; + events?: AgentaApi.TriggerCatalogEvent[] | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogIntegration.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogIntegration.ts new file mode 100644 index 0000000000..b5984ead4d --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogIntegration.ts @@ -0,0 +1,14 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerCatalogIntegration { + key: string; + name: string; + description?: (string | null) | undefined; + categories?: string[] | undefined; + logo?: (string | null) | undefined; + url?: (string | null) | undefined; + actions_count?: (number | null) | undefined; + auth_schemes?: (AgentaApi.TriggerAuthScheme[] | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogIntegrationResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogIntegrationResponse.ts new file mode 100644 index 0000000000..dade96cd95 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogIntegrationResponse.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerCatalogIntegrationResponse { + count?: number | undefined; + integration?: (AgentaApi.TriggerCatalogIntegration | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogIntegrationsResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogIntegrationsResponse.ts new file mode 100644 index 0000000000..faa80d7a0b --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogIntegrationsResponse.ts @@ -0,0 +1,10 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerCatalogIntegrationsResponse { + count?: number | undefined; + total?: number | undefined; + cursor?: (string | null) | undefined; + integrations?: AgentaApi.TriggerCatalogIntegration[] | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogProvider.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogProvider.ts new file mode 100644 index 0000000000..dac0b4989d --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogProvider.ts @@ -0,0 +1,10 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerCatalogProvider { + key: AgentaApi.TriggerProviderKind; + name: string; + description?: (string | null) | undefined; + integrations_count?: (number | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogProviderResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogProviderResponse.ts new file mode 100644 index 0000000000..b0d3e3708a --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogProviderResponse.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerCatalogProviderResponse { + count?: number | undefined; + provider?: (AgentaApi.TriggerCatalogProvider | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogProvidersResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogProvidersResponse.ts new file mode 100644 index 0000000000..922125c861 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerCatalogProvidersResponse.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerCatalogProvidersResponse { + count?: number | undefined; + providers?: AgentaApi.TriggerCatalogProvider[] | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerConnection.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerConnection.ts new file mode 100644 index 0000000000..49db67993b --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerConnection.ts @@ -0,0 +1,23 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerConnection { + flags?: (Record<string, AgentaApi.LabelJsonOutput | null> | null) | undefined; + tags?: (Record<string, AgentaApi.LabelJsonOutput | null> | null) | undefined; + meta?: (Record<string, AgentaApi.FullJsonOutput | null> | null) | undefined; + created_at?: (string | null) | undefined; + updated_at?: (string | null) | undefined; + deleted_at?: (string | null) | undefined; + created_by_id?: (string | null) | undefined; + updated_by_id?: (string | null) | undefined; + deleted_by_id?: (string | null) | undefined; + name?: (string | null) | undefined; + description?: (string | null) | undefined; + slug?: (string | null) | undefined; + id?: (string | null) | undefined; + provider_key: AgentaApi.TriggerProviderKind; + integration_key: string; + data?: (Record<string, AgentaApi.FullJsonOutput | null> | null) | undefined; + status?: (AgentaApi.TriggerConnectionStatus | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerConnectionCreate.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerConnectionCreate.ts new file mode 100644 index 0000000000..3d625ff057 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerConnectionCreate.ts @@ -0,0 +1,15 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerConnectionCreate { + flags?: (Record<string, AgentaApi.LabelJsonInput | null> | null) | undefined; + tags?: (Record<string, AgentaApi.LabelJsonInput | null> | null) | undefined; + meta?: (Record<string, AgentaApi.FullJsonInput | null> | null) | undefined; + name?: (string | null) | undefined; + description?: (string | null) | undefined; + slug?: (string | null) | undefined; + provider_key: AgentaApi.TriggerProviderKind; + integration_key: string; + data?: (AgentaApi.TriggerConnectionCreateData | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerConnectionCreateData.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerConnectionCreateData.ts new file mode 100644 index 0000000000..5ab31556f8 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerConnectionCreateData.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerConnectionCreateData { + callback_url?: (string | null) | undefined; + auth_scheme?: (AgentaApi.TriggerAuthScheme | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerConnectionResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerConnectionResponse.ts new file mode 100644 index 0000000000..ca52ddad2d --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerConnectionResponse.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerConnectionResponse { + count?: number | undefined; + connection?: (AgentaApi.TriggerConnection | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerConnectionStatus.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerConnectionStatus.ts new file mode 100644 index 0000000000..abd1589e5b --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerConnectionStatus.ts @@ -0,0 +1,5 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface TriggerConnectionStatus { + redirect_url?: (string | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerConnectionsResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerConnectionsResponse.ts new file mode 100644 index 0000000000..6e12d1131f --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerConnectionsResponse.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerConnectionsResponse { + count?: number | undefined; + connections?: AgentaApi.TriggerConnection[] | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerDeliveriesResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerDeliveriesResponse.ts new file mode 100644 index 0000000000..f51903bdfb --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerDeliveriesResponse.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerDeliveriesResponse { + count?: number | undefined; + deliveries?: AgentaApi.TriggerDelivery[] | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerDelivery.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerDelivery.ts new file mode 100644 index 0000000000..af323519be --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerDelivery.ts @@ -0,0 +1,18 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerDelivery { + created_at?: (string | null) | undefined; + updated_at?: (string | null) | undefined; + deleted_at?: (string | null) | undefined; + created_by_id?: (string | null) | undefined; + updated_by_id?: (string | null) | undefined; + deleted_by_id?: (string | null) | undefined; + id?: (string | null) | undefined; + status: AgentaApi.Status; + data?: (AgentaApi.TriggerDeliveryData | null) | undefined; + subscription_id?: (string | null) | undefined; + schedule_id?: (string | null) | undefined; + event_id: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerDeliveryData.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerDeliveryData.ts new file mode 100644 index 0000000000..7fc5733c2b --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerDeliveryData.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerDeliveryData { + event_key?: (string | null) | undefined; + references?: (Record<string, AgentaApi.Reference | null> | null) | undefined; + inputs?: (Record<string, unknown> | null) | undefined; + result?: (Record<string, unknown> | null) | undefined; + error?: (string | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerDeliveryQuery.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerDeliveryQuery.ts new file mode 100644 index 0000000000..9ad45b2b88 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerDeliveryQuery.ts @@ -0,0 +1,10 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerDeliveryQuery { + status?: (AgentaApi.Status | null) | undefined; + subscription_id?: (string | null) | undefined; + schedule_id?: (string | null) | undefined; + event_id?: (string | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerDeliveryResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerDeliveryResponse.ts new file mode 100644 index 0000000000..5512014c6b --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerDeliveryResponse.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerDeliveryResponse { + count?: number | undefined; + delivery?: (AgentaApi.TriggerDelivery | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerEventAck.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerEventAck.ts new file mode 100644 index 0000000000..990dfb8e51 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerEventAck.ts @@ -0,0 +1,6 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface TriggerEventAck { + status?: string | undefined; + detail?: (string | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerProviderKind.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerProviderKind.ts new file mode 100644 index 0000000000..dc36efa5f5 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerProviderKind.ts @@ -0,0 +1,6 @@ +// This file was auto-generated by Fern from our API Definition. + +export const TriggerProviderKind = { + Composio: "composio", +} as const; +export type TriggerProviderKind = (typeof TriggerProviderKind)[keyof typeof TriggerProviderKind]; diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerSchedule.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerSchedule.ts new file mode 100644 index 0000000000..083cca83ab --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerSchedule.ts @@ -0,0 +1,19 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerSchedule { + flags?: AgentaApi.TriggerScheduleFlags | undefined; + tags?: (Record<string, AgentaApi.LabelJsonOutput | null> | null) | undefined; + meta?: (Record<string, AgentaApi.FullJsonOutput | null> | null) | undefined; + name?: (string | null) | undefined; + description?: (string | null) | undefined; + created_at?: (string | null) | undefined; + updated_at?: (string | null) | undefined; + deleted_at?: (string | null) | undefined; + created_by_id?: (string | null) | undefined; + updated_by_id?: (string | null) | undefined; + deleted_by_id?: (string | null) | undefined; + id?: (string | null) | undefined; + data: AgentaApi.TriggerScheduleData; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerScheduleCreate.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerScheduleCreate.ts new file mode 100644 index 0000000000..29200c49bb --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerScheduleCreate.ts @@ -0,0 +1,12 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerScheduleCreate { + flags?: (Record<string, AgentaApi.LabelJsonInput | null> | null) | undefined; + tags?: (Record<string, AgentaApi.LabelJsonInput | null> | null) | undefined; + meta?: (Record<string, AgentaApi.FullJsonInput | null> | null) | undefined; + name?: (string | null) | undefined; + description?: (string | null) | undefined; + data: AgentaApi.TriggerScheduleData; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerScheduleData.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerScheduleData.ts new file mode 100644 index 0000000000..46753596dd --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerScheduleData.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerScheduleData { + event_key: string; + schedule: string; + inputs_fields?: (Record<string, unknown> | null) | undefined; + references?: (Record<string, AgentaApi.Reference | null> | null) | undefined; + selector?: (AgentaApi.Selector | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerScheduleEdit.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerScheduleEdit.ts new file mode 100644 index 0000000000..c127245a88 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerScheduleEdit.ts @@ -0,0 +1,13 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerScheduleEdit { + flags?: AgentaApi.TriggerScheduleFlags | undefined; + tags?: (Record<string, AgentaApi.LabelJsonInput | null> | null) | undefined; + meta?: (Record<string, AgentaApi.FullJsonInput | null> | null) | undefined; + name?: (string | null) | undefined; + description?: (string | null) | undefined; + id?: (string | null) | undefined; + data: AgentaApi.TriggerScheduleData; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerScheduleFlags.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerScheduleFlags.ts new file mode 100644 index 0000000000..e1be9522e4 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerScheduleFlags.ts @@ -0,0 +1,5 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface TriggerScheduleFlags { + is_active?: boolean | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerScheduleQuery.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerScheduleQuery.ts new file mode 100644 index 0000000000..fd325ccbf9 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerScheduleQuery.ts @@ -0,0 +1,6 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface TriggerScheduleQuery { + name?: (string | null) | undefined; + event_key?: (string | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerScheduleResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerScheduleResponse.ts new file mode 100644 index 0000000000..e3643bb7c0 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerScheduleResponse.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerScheduleResponse { + count?: number | undefined; + schedule?: (AgentaApi.TriggerSchedule | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerSchedulesResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerSchedulesResponse.ts new file mode 100644 index 0000000000..8d09e91fb9 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerSchedulesResponse.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerSchedulesResponse { + count?: number | undefined; + schedules?: AgentaApi.TriggerSchedule[] | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerSubscription.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerSubscription.ts new file mode 100644 index 0000000000..6efeb9fd8e --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerSubscription.ts @@ -0,0 +1,21 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerSubscription { + flags?: AgentaApi.TriggerSubscriptionFlags | undefined; + tags?: (Record<string, AgentaApi.LabelJsonOutput | null> | null) | undefined; + meta?: (Record<string, AgentaApi.FullJsonOutput | null> | null) | undefined; + name?: (string | null) | undefined; + description?: (string | null) | undefined; + created_at?: (string | null) | undefined; + updated_at?: (string | null) | undefined; + deleted_at?: (string | null) | undefined; + created_by_id?: (string | null) | undefined; + updated_by_id?: (string | null) | undefined; + deleted_by_id?: (string | null) | undefined; + id?: (string | null) | undefined; + connection_id: string; + trigger_id?: (string | null) | undefined; + data: AgentaApi.TriggerSubscriptionData; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerSubscriptionCreate.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerSubscriptionCreate.ts new file mode 100644 index 0000000000..5e1bbb280c --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerSubscriptionCreate.ts @@ -0,0 +1,13 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerSubscriptionCreate { + flags?: (Record<string, AgentaApi.LabelJsonInput | null> | null) | undefined; + tags?: (Record<string, AgentaApi.LabelJsonInput | null> | null) | undefined; + meta?: (Record<string, AgentaApi.FullJsonInput | null> | null) | undefined; + name?: (string | null) | undefined; + description?: (string | null) | undefined; + connection_id: string; + data: AgentaApi.TriggerSubscriptionData; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerSubscriptionData.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerSubscriptionData.ts new file mode 100644 index 0000000000..8bce8ddfd5 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerSubscriptionData.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerSubscriptionData { + event_key: string; + trigger_config?: (Record<string, unknown> | null) | undefined; + inputs_fields?: (Record<string, unknown> | null) | undefined; + references?: (Record<string, AgentaApi.Reference | null> | null) | undefined; + selector?: (AgentaApi.Selector | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerSubscriptionEdit.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerSubscriptionEdit.ts new file mode 100644 index 0000000000..02373df98e --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerSubscriptionEdit.ts @@ -0,0 +1,14 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerSubscriptionEdit { + flags?: AgentaApi.TriggerSubscriptionFlags | undefined; + tags?: (Record<string, AgentaApi.LabelJsonInput | null> | null) | undefined; + meta?: (Record<string, AgentaApi.FullJsonInput | null> | null) | undefined; + name?: (string | null) | undefined; + description?: (string | null) | undefined; + id?: (string | null) | undefined; + connection_id: string; + data: AgentaApi.TriggerSubscriptionData; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerSubscriptionFlags.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerSubscriptionFlags.ts new file mode 100644 index 0000000000..86c609eea8 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerSubscriptionFlags.ts @@ -0,0 +1,6 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface TriggerSubscriptionFlags { + is_active?: boolean | undefined; + is_valid?: boolean | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerSubscriptionQuery.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerSubscriptionQuery.ts new file mode 100644 index 0000000000..55925c2b5d --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerSubscriptionQuery.ts @@ -0,0 +1,7 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface TriggerSubscriptionQuery { + name?: (string | null) | undefined; + connection_id?: (string | null) | undefined; + event_key?: (string | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerSubscriptionResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerSubscriptionResponse.ts new file mode 100644 index 0000000000..4236f62e91 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerSubscriptionResponse.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerSubscriptionResponse { + count?: number | undefined; + subscription?: (AgentaApi.TriggerSubscription | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/TriggerSubscriptionsResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/TriggerSubscriptionsResponse.ts new file mode 100644 index 0000000000..95ec2a67fe --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/TriggerSubscriptionsResponse.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface TriggerSubscriptionsResponse { + count?: number | undefined; + subscriptions?: AgentaApi.TriggerSubscription[] | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/WebhookSubscription.ts b/web/packages/agenta-api-client/src/generated/api/types/WebhookSubscription.ts index 232131b047..1f7912e7cf 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/WebhookSubscription.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/WebhookSubscription.ts @@ -3,7 +3,7 @@ import type * as AgentaApi from "../index.js"; export interface WebhookSubscription { - flags?: (Record<string, AgentaApi.LabelJsonOutput | null> | null) | undefined; + flags?: AgentaApi.WebhookSubscriptionFlags | undefined; tags?: (Record<string, AgentaApi.LabelJsonOutput | null> | null) | undefined; meta?: (Record<string, AgentaApi.FullJsonOutput | null> | null) | undefined; name?: (string | null) | undefined; diff --git a/web/packages/agenta-api-client/src/generated/api/types/WebhookSubscriptionEdit.ts b/web/packages/agenta-api-client/src/generated/api/types/WebhookSubscriptionEdit.ts index ab344057ba..d57064fcea 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/WebhookSubscriptionEdit.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/WebhookSubscriptionEdit.ts @@ -3,7 +3,7 @@ import type * as AgentaApi from "../index.js"; export interface WebhookSubscriptionEdit { - flags?: (Record<string, AgentaApi.LabelJsonInput | null> | null) | undefined; + flags?: AgentaApi.WebhookSubscriptionFlags | undefined; tags?: (Record<string, AgentaApi.LabelJsonInput | null> | null) | undefined; meta?: (Record<string, AgentaApi.FullJsonInput | null> | null) | undefined; name?: (string | null) | undefined; diff --git a/web/packages/agenta-api-client/src/generated/api/types/WebhookSubscriptionFlags.ts b/web/packages/agenta-api-client/src/generated/api/types/WebhookSubscriptionFlags.ts new file mode 100644 index 0000000000..bf8e27e57c --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/WebhookSubscriptionFlags.ts @@ -0,0 +1,5 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface WebhookSubscriptionFlags { + is_active?: boolean | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/index.ts b/web/packages/agenta-api-client/src/generated/api/types/index.ts index 063bf5c4f6..4db26fef4d 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/index.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/index.ts @@ -325,6 +325,7 @@ export * from "./RetrievalInfo.js"; export * from "./SecretDto.js"; export * from "./SecretKind.js"; export * from "./SecretResponseDto.js"; +export * from "./Selector.js"; export * from "./SessionIdsResponse.js"; export * from "./SimpleApplication.js"; export * from "./SimpleApplicationCreate.js"; @@ -490,6 +491,46 @@ export * from "./TracesRequest.js"; export * from "./TracesResponse.js"; export * from "./TraceType.js"; export * from "./TracingQuery.js"; +export * from "./TriggerAuthScheme.js"; +export * from "./TriggerCatalogEvent.js"; +export * from "./TriggerCatalogEventDetails.js"; +export * from "./TriggerCatalogEventResponse.js"; +export * from "./TriggerCatalogEventsResponse.js"; +export * from "./TriggerCatalogIntegration.js"; +export * from "./TriggerCatalogIntegrationResponse.js"; +export * from "./TriggerCatalogIntegrationsResponse.js"; +export * from "./TriggerCatalogProvider.js"; +export * from "./TriggerCatalogProviderResponse.js"; +export * from "./TriggerCatalogProvidersResponse.js"; +export * from "./TriggerConnection.js"; +export * from "./TriggerConnectionCreate.js"; +export * from "./TriggerConnectionCreateData.js"; +export * from "./TriggerConnectionResponse.js"; +export * from "./TriggerConnectionStatus.js"; +export * from "./TriggerConnectionsResponse.js"; +export * from "./TriggerDeliveriesResponse.js"; +export * from "./TriggerDelivery.js"; +export * from "./TriggerDeliveryData.js"; +export * from "./TriggerDeliveryQuery.js"; +export * from "./TriggerDeliveryResponse.js"; +export * from "./TriggerEventAck.js"; +export * from "./TriggerProviderKind.js"; +export * from "./TriggerSchedule.js"; +export * from "./TriggerScheduleCreate.js"; +export * from "./TriggerScheduleData.js"; +export * from "./TriggerScheduleEdit.js"; +export * from "./TriggerScheduleFlags.js"; +export * from "./TriggerScheduleQuery.js"; +export * from "./TriggerScheduleResponse.js"; +export * from "./TriggerSchedulesResponse.js"; +export * from "./TriggerSubscription.js"; +export * from "./TriggerSubscriptionCreate.js"; +export * from "./TriggerSubscriptionData.js"; +export * from "./TriggerSubscriptionEdit.js"; +export * from "./TriggerSubscriptionFlags.js"; +export * from "./TriggerSubscriptionQuery.js"; +export * from "./TriggerSubscriptionResponse.js"; +export * from "./TriggerSubscriptionsResponse.js"; export * from "./UserIdsResponse.js"; export * from "./ValidationError.js"; export * from "./WebhookDeliveriesResponse.js"; @@ -506,6 +547,7 @@ export * from "./WebhookSubscription.js"; export * from "./WebhookSubscriptionCreate.js"; export * from "./WebhookSubscriptionData.js"; export * from "./WebhookSubscriptionEdit.js"; +export * from "./WebhookSubscriptionFlags.js"; export * from "./WebhookSubscriptionQuery.js"; export * from "./WebhookSubscriptionResponse.js"; export * from "./WebhookSubscriptionsResponse.js"; 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/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 new file mode 100644 index 0000000000..f3bd2783ce --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/api/api.ts @@ -0,0 +1,537 @@ +/** + * 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`; the connection shape is reused from + * gatewayTool so the two lists stay byte-compatible. + */ + +import {safeParseWithLogging} from "../../shared" +import { + triggerCatalogEventResponseSchema, + triggerCatalogEventsResponseSchema, + triggerCatalogIntegrationResponseSchema, + triggerCatalogIntegrationsResponseSchema, + triggerCatalogProviderResponseSchema, + triggerCatalogProvidersResponseSchema, + triggerConnectionResponseSchema, + triggerConnectionsResponseSchema, + triggerDeliveriesResponseSchema, + triggerDeliveryResponseSchema, + triggerScheduleResponseSchema, + triggerSchedulesResponseSchema, + triggerSubscriptionResponseSchema, + triggerSubscriptionsResponseSchema, + type TriggerCatalogEventResponse, + type TriggerCatalogEventsResponse, + type TriggerCatalogIntegrationResponse, + type TriggerCatalogIntegrationsResponse, + type TriggerCatalogProviderResponse, + type TriggerCatalogProvidersResponse, + type TriggerConnectionCreatePayload, + type TriggerConnectionResponse, + type TriggerConnectionsResponse, + type TriggerDeliveriesResponse, + type TriggerDeliveryQuery, + type TriggerDeliveryResponse, + type TriggerScheduleCreate, + type TriggerScheduleEdit, + type TriggerScheduleQuery, + type TriggerScheduleResponse, + type TriggerSchedulesResponse, + 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, + } + ) +} + +// --- 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 `gateway_connections` rows) --- + +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: []} +} + +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 ( + 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(), + ) +} + +// --- Subscription start/stop --- +// Lifecycle verbs toggling `flags.is_active` via `POST /subscriptions/{id}/<verb>`. + +export const startTriggerSubscription = async ( + subscriptionId: string, +): Promise<TriggerSubscriptionResponse> => { + const {data} = await axios.post( + `${triggersBaseUrl()}/subscriptions/${subscriptionId}/start`, + {}, + projectScopedParams(), + ) + return ( + safeParseWithLogging( + triggerSubscriptionResponseSchema, + data, + "[startTriggerSubscription]", + ) ?? {count: 0, subscription: null} + ) +} + +export const stopTriggerSubscription = async ( + subscriptionId: string, +): Promise<TriggerSubscriptionResponse> => { + const {data} = await axios.post( + `${triggersBaseUrl()}/subscriptions/${subscriptionId}/stop`, + {}, + projectScopedParams(), + ) + return ( + safeParseWithLogging( + triggerSubscriptionResponseSchema, + data, + "[stopTriggerSubscription]", + ) ?? {count: 0, subscription: null} + ) +} + +// --- Schedules — recurring cron timers binding a tick to a workflow --- + +export const queryTriggerSchedules = async ( + schedule?: TriggerScheduleQuery, +): Promise<TriggerSchedulesResponse> => { + const {data} = await axios.post( + `${triggersBaseUrl()}/schedules/query`, + {schedule: schedule ?? null}, + projectScopedParams(), + ) + return ( + safeParseWithLogging(triggerSchedulesResponseSchema, data, "[queryTriggerSchedules]") ?? { + count: 0, + schedules: [], + } + ) +} + +export const fetchTriggerSchedule = async ( + scheduleId: string, +): Promise<TriggerScheduleResponse> => { + const {data} = await axios.get( + `${triggersBaseUrl()}/schedules/${scheduleId}`, + projectScopedParams(), + ) + return ( + safeParseWithLogging(triggerScheduleResponseSchema, data, "[fetchTriggerSchedule]") ?? { + count: 0, + schedule: null, + } + ) +} + +export const createTriggerSchedule = async ( + schedule: TriggerScheduleCreate, +): Promise<TriggerScheduleResponse> => { + const {data} = await axios.post( + `${triggersBaseUrl()}/schedules/`, + {schedule}, + projectScopedParams(), + ) + return ( + safeParseWithLogging(triggerScheduleResponseSchema, data, "[createTriggerSchedule]") ?? { + count: 0, + schedule: null, + } + ) +} + +export const editTriggerSchedule = async ( + schedule: TriggerScheduleEdit, +): Promise<TriggerScheduleResponse> => { + const {data} = await axios.put( + `${triggersBaseUrl()}/schedules/${schedule.id}`, + {schedule}, + projectScopedParams(), + ) + return ( + safeParseWithLogging(triggerScheduleResponseSchema, data, "[editTriggerSchedule]") ?? { + count: 0, + schedule: null, + } + ) +} + +export const deleteTriggerSchedule = async (scheduleId: string): Promise<void> => { + await axios.delete(`${triggersBaseUrl()}/schedules/${scheduleId}`, projectScopedParams()) +} + +export const startTriggerSchedule = async ( + scheduleId: string, +): Promise<TriggerScheduleResponse> => { + const {data} = await axios.post( + `${triggersBaseUrl()}/schedules/${scheduleId}/start`, + {}, + projectScopedParams(), + ) + return ( + safeParseWithLogging(triggerScheduleResponseSchema, data, "[startTriggerSchedule]") ?? { + count: 0, + schedule: null, + } + ) +} + +export const stopTriggerSchedule = async (scheduleId: string): Promise<TriggerScheduleResponse> => { + const {data} = await axios.post( + `${triggersBaseUrl()}/schedules/${scheduleId}/stop`, + {}, + projectScopedParams(), + ) + return ( + safeParseWithLogging(triggerScheduleResponseSchema, data, "[stopTriggerSchedule]") ?? { + count: 0, + schedule: null, + } + ) +} + +// --- 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..0a1ab7047c --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/api/client.ts @@ -0,0 +1,48 @@ +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, 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: { + ...(extra ?? {}), + ...(projectId ? {project_id: projectId} : {}), + }, + } +} + +/** + * 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 new file mode 100644 index 0000000000..5e3eb7e6e9 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/api/index.ts @@ -0,0 +1,33 @@ +export { + createTriggerConnection, + createTriggerSchedule, + createTriggerSubscription, + deleteTriggerConnection, + deleteTriggerSchedule, + deleteTriggerSubscription, + editTriggerSchedule, + editTriggerSubscription, + fetchTriggerConnection, + fetchTriggerDelivery, + fetchTriggerEvent, + fetchTriggerEvents, + fetchTriggerIntegration, + fetchTriggerIntegrations, + fetchTriggerProvider, + fetchTriggerProviders, + fetchTriggerSchedule, + fetchTriggerSubscription, + queryTriggerConnections, + queryTriggerDeliveries, + queryTriggerSchedules, + queryTriggerSubscriptions, + refreshTriggerConnection, + refreshTriggerSubscription, + revokeTriggerConnection, + revokeTriggerSubscription, + startTriggerSchedule, + startTriggerSubscription, + stopTriggerSchedule, + stopTriggerSubscription, +} from "./api" +export {triggersBaseUrl, projectScopedParams, triggerApiErrorMessage} from "./client" diff --git a/web/packages/agenta-entities/src/gatewayTrigger/core/cron.ts b/web/packages/agenta-entities/src/gatewayTrigger/core/cron.ts new file mode 100644 index 0000000000..eb2587586a --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/core/cron.ts @@ -0,0 +1,169 @@ +/** + * Cron helpers for trigger schedules. + * + * Schedules use a 5-field cron expression (minute hour day-of-month month + * day-of-week), interpreted in UTC by the backend (validated server-side via + * croniter). The web has no cron dependency, so this is a tiny, dependency-free + * parser/validator used purely for client-side validation, a human-readable + * description, and a "next runs" preview hint. The backend remains the source of + * truth; this never blocks a value the backend would accept beyond field bounds. + */ + +const FIELD_BOUNDS: {min: number; max: number}[] = [ + {min: 0, max: 59}, // minute + {min: 0, max: 23}, // hour + {min: 1, max: 31}, // day of month + {min: 1, max: 12}, // month + {min: 0, max: 6}, // day of week (0 = Sunday) +] + +const FIELD_NAMES = ["minute", "hour", "day-of-month", "month", "day-of-week"] + +export interface CronValidationResult { + valid: boolean + error?: string +} + +/** Split + sanity-check a 5-field cron expression. */ +export function validateCron(expression: string): CronValidationResult { + const trimmed = expression.trim() + if (!trimmed) return {valid: false, error: "Cron expression is required"} + + const fields = trimmed.split(/\s+/) + if (fields.length !== 5) { + return { + valid: false, + error: `Expected 5 fields (minute hour day month weekday), got ${fields.length}`, + } + } + + for (let i = 0; i < fields.length; i++) { + const fieldError = validateField(fields[i], FIELD_BOUNDS[i]) + if (fieldError) return {valid: false, error: `Invalid ${FIELD_NAMES[i]}: ${fieldError}`} + } + + return {valid: true} +} + +/** Validate one cron field supporting star, step, range, list, and plain values. */ +function validateField(field: string, bounds: {min: number; max: number}): string | null { + for (const part of field.split(",")) { + const [range, stepRaw] = part.split("/") + if (stepRaw !== undefined) { + const step = Number(stepRaw) + if (!Number.isInteger(step) || step <= 0) return `bad step "${stepRaw}"` + } + if (range === "*") continue + if (range.includes("-")) { + const [a, b] = range.split("-") + const lo = Number(a) + const hi = Number(b) + if (!inBounds(lo, bounds) || !inBounds(hi, bounds) || lo > hi) + return `bad range "${range}"` + continue + } + const value = Number(range) + if (!inBounds(value, bounds)) return `"${range}" out of ${bounds.min}-${bounds.max}` + } + return null +} + +function inBounds(value: number, bounds: {min: number; max: number}): boolean { + return Number.isInteger(value) && value >= bounds.min && value <= bounds.max +} + +const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] + +/** + * A best-effort human-readable description of a cron expression. Handles the + * common shapes (every minute/hour, daily at HH:MM, weekly on a weekday); falls + * back to echoing the raw expression for anything more exotic. + */ +export function describeCron(expression: string): string { + const {valid} = validateCron(expression) + if (!valid) return expression + + const [minute, hour, dom, month, dow] = expression.trim().split(/\s+/) + + if (minute === "*" && hour === "*" && dom === "*" && month === "*" && dow === "*") + return "Every minute (UTC)" + + const stepMatch = minute.match(/^\*\/(\d+)$/) + if (stepMatch && hour === "*" && dom === "*" && month === "*" && dow === "*") + return `Every ${stepMatch[1]} minutes (UTC)` + + if (minute === "0" && hour === "*" && dom === "*" && month === "*" && dow === "*") + return "Every hour (UTC)" + + const isTime = /^\d+$/.test(minute) && /^\d+$/.test(hour) + if (isTime && dom === "*" && month === "*") { + const time = `${pad(hour)}:${pad(minute)} UTC` + if (dow === "*") return `Every day at ${time}` + if (/^\d$/.test(dow)) return `Every ${DAY_NAMES[Number(dow)]} at ${time}` + } + + return `${expression} (UTC)` +} + +function pad(value: string): string { + return value.padStart(2, "0") +} + +/** + * Compute the next `count` UTC fire times for a 5-field cron expression by + * minute-stepping forward (capped) and matching each field. Returns ISO + * strings. Used only for the drawer's "next runs" preview. + */ +export function nextCronRuns(expression: string, count = 3, from: Date = new Date()): Date[] { + if (!validateCron(expression).valid) return [] + + const [minute, hour, dom, month, dow] = expression.trim().split(/\s+/) + const runs: Date[] = [] + + // Start at the next whole minute, in UTC. + const cursor = new Date(from) + cursor.setUTCSeconds(0, 0) + cursor.setUTCMinutes(cursor.getUTCMinutes() + 1) + + // Cap the scan at one year of minutes to avoid an unbounded loop. + const MAX_STEPS = 366 * 24 * 60 + for (let step = 0; step < MAX_STEPS && runs.length < count; step++) { + if ( + matchField(cursor.getUTCMinutes(), minute, FIELD_BOUNDS[0]) && + matchField(cursor.getUTCHours(), hour, FIELD_BOUNDS[1]) && + matchField(cursor.getUTCDate(), dom, FIELD_BOUNDS[2]) && + matchField(cursor.getUTCMonth() + 1, month, FIELD_BOUNDS[3]) && + matchField(cursor.getUTCDay(), dow, FIELD_BOUNDS[4]) + ) { + runs.push(new Date(cursor)) + } + cursor.setUTCMinutes(cursor.getUTCMinutes() + 1) + } + + return runs +} + +/** Does `value` satisfy a single cron field (star, step, range, list, plain)? */ +function matchField(value: number, field: string, bounds: {min: number; max: number}): boolean { + for (const part of field.split(",")) { + const [range, stepRaw] = part.split("/") + const step = stepRaw !== undefined ? Number(stepRaw) : 1 + + let lo = bounds.min + let hi = bounds.max + if (range !== "*") { + if (range.includes("-")) { + const [a, b] = range.split("-") + lo = Number(a) + hi = Number(b) + } else { + lo = Number(range) + hi = Number(range) + } + } + + if (value < lo || value > hi) continue + if ((value - lo) % step === 0) return true + } + return false +} 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/selectorPreview.ts b/web/packages/agenta-entities/src/gatewayTrigger/core/selectorPreview.ts new file mode 100644 index 0000000000..1cb8e3418c --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/core/selectorPreview.ts @@ -0,0 +1,62 @@ +/** + * Selector resolution for the subscription mapping preview. + * + * A subscription maps workflow inputs from the event context via selectors: + * JSONPath-lite (`$.a.b[0]`, `$["a"]["b"]`) or JSON Pointer (`/a/b/0`). The + * drawer resolves them against a sample context to preview what each field + * would receive. Dependency-free and best-effort: an unresolved selector yields + * `undefined` rather than throwing. The backend remains the source of truth. + */ + +/** Render a resolved value for display. */ +export 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`. */ +export 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-entities/src/gatewayTrigger/core/types.ts b/web/packages/agenta-entities/src/gatewayTrigger/core/types.ts new file mode 100644 index 0000000000..86076a3e4b --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/core/types.ts @@ -0,0 +1,460 @@ +/** + * Gateway-trigger domain types. + * + * The triggers catalog API is not yet in the Fern-generated client, so the + * wire shapes are declared here as zod schemas mirroring the backend DTOs + * (`api/oss/src/core/triggers/dtos.py`, + * `api/oss/src/apis/fastapi/triggers/models.py`). Validation runs at the API + * boundary. Connections are shared `gateway_connections` rows, so the + * gatewayTool connection type is reused to keep both lists byte-compatible. + */ + +import {z} from "zod" + +import type { + ToolConnection, + ToolConnectionCreatePayload, + ToolConnectionResponse, + 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> + +// 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), + 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. The TS type aliases the +// gatewayTool Fern type so both 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 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: independent endpoint, identical payload. +export type TriggerConnectionResponse = ToolConnectionResponse +export type TriggerConnectionCreatePayload = ToolConnectionCreatePayload + +export {isConnectionActive, isConnectionValid} from "../../gatewayTool/core/types" + +// --------------------------------------------------------------------------- +// Subscriptions — a standing watch binding a provider event to a workflow. +// Mirrors the backend DTOs (`api/oss/src/core/triggers/dtos.py`). Validated at +// the axios boundary. +// --------------------------------------------------------------------------- + +// 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> + +// Start/stop state lives in `flags.is_active` / `flags.is_valid`. +export const triggerSubscriptionFlagsSchema = z + .object({ + is_active: z.boolean().default(true), + is_valid: z.boolean().default(true), + }) + .passthrough() +export type TriggerSubscriptionFlags = z.infer<typeof triggerSubscriptionFlagsSchema> + +export const triggerSubscriptionDataSchema = z + .object({ + event_key: z.string(), + 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(), + 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(), + trigger_id: z.string().nullish(), + data: triggerSubscriptionDataSchema, + flags: triggerSubscriptionFlagsSchema.nullish(), + }) + .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 + flags: {is_active: boolean; is_valid: boolean} & Record<string, unknown> +} + +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(), + // XOR (DB-enforced): a delivery belongs to a subscription OR a schedule. + subscription_id: z.string().nullish(), + schedule_id: z.string().nullish(), + 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 + schedule_id?: string + event_id?: string +} + +// --------------------------------------------------------------------------- +// Schedules — a standing cron timer binding a recurring tick to a workflow. +// Mirrors the backend DTOs (`api/oss/src/core/triggers/dtos.py`). A schedule +// has no connection — it fires on its own UTC 5-field cron clock, so +// `flags.is_active` is the only lifecycle flag (no `is_valid`). Validated at +// the axios boundary. +// --------------------------------------------------------------------------- + +export const triggerScheduleFlagsSchema = z + .object({ + is_active: z.boolean().default(true), + }) + .passthrough() +export type TriggerScheduleFlags = z.infer<typeof triggerScheduleFlagsSchema> + +export const triggerScheduleDataSchema = z + .object({ + event_key: z.string(), + // 5-field cron expression, UTC, validated client-side via the local helper. + schedule: z.string(), + inputs_fields: z.record(z.string(), z.unknown()).nullish(), + references: z.record(z.string(), triggerReferenceSchema).nullish(), + selector: triggerSelectorSchema.nullish(), + }) + .passthrough() +export type TriggerScheduleData = z.infer<typeof triggerScheduleDataSchema> + +export const triggerScheduleSchema = z + .object({ + id: z.string().nullish(), + slug: z.string().nullish(), + name: z.string().nullish(), + description: z.string().nullish(), + flags: triggerScheduleFlagsSchema.nullish(), + 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(), + data: triggerScheduleDataSchema, + }) + .passthrough() +export type TriggerSchedule = z.infer<typeof triggerScheduleSchema> + +export const triggerScheduleResponseSchema = z + .object({ + count: z.number().default(0), + schedule: triggerScheduleSchema.nullish(), + }) + .passthrough() +export type TriggerScheduleResponse = z.infer<typeof triggerScheduleResponseSchema> + +export const triggerSchedulesResponseSchema = z + .object({ + count: z.number().default(0), + schedules: z.array(triggerScheduleSchema).default([]), + }) + .passthrough() +export type TriggerSchedulesResponse = z.infer<typeof triggerSchedulesResponseSchema> + +// Create body (Header + Metadata + data); no id, no connection_id. +export interface TriggerScheduleCreate { + name?: string | null + description?: string | null + flags?: Record<string, unknown> | null + tags?: Record<string, unknown> | null + meta?: Record<string, unknown> | null + data: TriggerScheduleData +} + +// Edit body — full PUT: Identifier + Header + Metadata + data + flags. +export interface TriggerScheduleEdit extends TriggerScheduleCreate { + id: string + flags: {is_active: boolean} & Record<string, unknown> +} + +export interface TriggerScheduleQuery { + name?: string + event_key?: string +} + +// --------------------------------------------------------------------------- +// Shared flag readers. These accept any of the three lifecycle entities +// (trigger subscription, trigger schedule, webhook subscription) which all +// expose the same `flags.is_active` shape. +// --------------------------------------------------------------------------- + +/** Read `flags.is_active`, defaulting to `true` when the flag is absent. */ +export function isEntityActive(entity?: {flags?: Record<string, unknown> | null} | null): boolean { + const raw = entity?.flags?.is_active + return raw === undefined || raw === null ? true : Boolean(raw) +} + +/** + * Read `flags.is_valid`, defaulting to `true` when the flag is absent. Only + * trigger/webhook subscriptions carry validity (schedules have no external + * connection, so they have no `is_valid`). + */ +export function isEntityValid(entity?: {flags?: Record<string, unknown> | null} | null): boolean { + const raw = entity?.flags?.is_valid + return raw === undefined || raw === null ? true : Boolean(raw) +} 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..fe11a7cd57 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/index.ts @@ -0,0 +1,28 @@ +export { + triggerCatalogEventsInfiniteFamily, + triggerEventsSearchAtom, + useTriggerCatalogEvents, +} from "./useTriggerCatalogEvents" +export { + triggerCatalogIntegrationsInfiniteAtom, + triggerIntegrationsSearchAtom, + useTriggerCatalogIntegrations, +} from "./useTriggerCatalogIntegrations" +export {triggerEventDetailQueryFamily, useTriggerEvent} from "./useTriggerEvent" +export { + triggerConnectionsQueryAtom, + triggerIntegrationConnectionsAtomFamily, + useTriggerConnectionsQuery, + useTriggerIntegrationConnections, +} from "./useTriggerConnections" +export {useTriggerConnectionActions} from "./useTriggerConnectionActions" +export { + triggerConnectionSubscriptionsAtomFamily, + triggerSubscriptionsQueryAtom, + useTriggerConnectionSubscriptions, + useTriggerSubscriptions, +} from "./useTriggerSubscriptions" +export {triggerSubscriptionQueryAtomFamily, useTriggerSubscription} from "./useTriggerSubscription" +export {triggerSchedulesQueryAtom, useTriggerSchedules} from "./useTriggerSchedules" +export {triggerScheduleQueryAtomFamily, useTriggerSchedule} from "./useTriggerSchedule" +export {triggerDeliveriesAtomFamily, useTriggerDeliveries} from "./useTriggerDeliveries" diff --git a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerCatalogEvents.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerCatalogEvents.ts new file mode 100644 index 0000000000..4c01a992ff --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerCatalogEvents.ts @@ -0,0 +1,96 @@ +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 triggerEventsSearchAtom = atom("") + +export const triggerCatalogEventsInfiniteFamily = atomFamily((integrationKey: string) => + atomWithInfiniteQuery<TriggerCatalogEventsResponse>((get) => { + const search = get(triggerEventsSearchAtom) + + 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 useTriggerCatalogEvents = (integrationKey: string) => { + const query = useAtomValue(triggerCatalogEventsInfiniteFamily(integrationKey)) + const setSearch = useSetAtom(triggerEventsSearchAtom) + + 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.isError + ) { + query.fetchNextPage() + } + }, [ + loadedPages, + targetPages, + query.hasNextPage, + query.isFetchingNextPage, + query.isError, + 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/useTriggerCatalogIntegrations.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerCatalogIntegrations.ts new file mode 100644 index 0000000000..07842ecca4 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerCatalogIntegrations.ts @@ -0,0 +1,93 @@ +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.isError + ) { + query.fetchNextPage() + } + }, [ + loadedPages, + targetPages, + query.hasNextPage, + query.isFetchingNextPage, + query.isError, + 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/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..59b0185304 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerDeliveries.ts @@ -0,0 +1,52 @@ +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" + +// A delivery belongs to a subscription OR a schedule (XOR, DB-enforced). The +// deliveries view is reused for both; the family is keyed on the owner kind+id +// so the two never share a cache entry. +interface DeliveriesOwner { + kind: "subscription" | "schedule" + id: string +} + +const ownerKey = (owner: DeliveriesOwner) => `${owner.kind}:${owner.id}` + +export const triggerDeliveriesAtomFamily = atomFamily( + (owner: DeliveriesOwner) => + atomWithQuery<TriggerDeliveriesResponse>(() => ({ + queryKey: ["triggers", "deliveries", owner.kind, owner.id], + queryFn: () => + queryTriggerDeliveries( + owner.kind === "subscription" + ? {subscription_id: owner.id} + : {schedule_id: owner.id}, + ), + staleTime: 15_000, + refetchOnWindowFocus: false, + enabled: !!owner.id, + })), + (a, b) => ownerKey(a) === ownerKey(b), +) + +export const useTriggerDeliveries = (owner?: DeliveriesOwner) => { + const query = useAtomValue(triggerDeliveriesAtomFamily(owner ?? {kind: "subscription", id: ""})) + + const deliveries = useMemo<TriggerDelivery[]>( + () => query.data?.deliveries ?? [], + [query.data?.deliveries], + ) + + return { + deliveries, + count: query.data?.count ?? 0, + isLoading: owner?.id ? 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..94ac383212 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerEvent.ts @@ -0,0 +1,39 @@ +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, + // `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/hooks/useTriggerSchedule.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSchedule.ts new file mode 100644 index 0000000000..8bc2fb2a6d --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSchedule.ts @@ -0,0 +1,101 @@ +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 { + createTriggerSchedule, + deleteTriggerSchedule, + editTriggerSchedule, + fetchTriggerSchedule, + startTriggerSchedule, + stopTriggerSchedule, +} from "../api" +import type { + TriggerSchedule, + TriggerScheduleCreate, + TriggerScheduleEdit, + TriggerScheduleResponse, +} from "../core/types" +import {applyScheduleActiveOptimistic} from "../state/optimistic" + +const invalidateSchedules = () => { + queryClient.invalidateQueries({queryKey: ["triggers", "schedules"]}) +} + +// Single schedule (used to source the full PUT body before editing). +export const triggerScheduleQueryAtomFamily = atomFamily((scheduleId: string) => + atomWithQuery<TriggerScheduleResponse>(() => ({ + queryKey: ["triggers", "schedules", "detail", scheduleId], + queryFn: () => fetchTriggerSchedule(scheduleId), + staleTime: 30_000, + refetchOnWindowFocus: false, + enabled: !!scheduleId, + })), +) + +export const useTriggerSchedule = (scheduleId?: string) => { + const query = useAtomValue(triggerScheduleQueryAtomFamily(scheduleId ?? "")) + const [isMutating, setIsMutating] = useState(false) + + const run = useCallback( + async (fn: () => Promise<TriggerScheduleResponse>): Promise<TriggerSchedule | null> => { + setIsMutating(true) + try { + const res = await fn() + invalidateSchedules() + return res.schedule ?? null + } finally { + setIsMutating(false) + } + }, + [], + ) + + const create = useCallback( + (schedule: TriggerScheduleCreate) => run(() => createTriggerSchedule(schedule)), + [run], + ) + + const edit = useCallback( + (schedule: TriggerScheduleEdit) => run(() => editTriggerSchedule(schedule)), + [run], + ) + + const remove = useCallback(async (id: string) => { + setIsMutating(true) + try { + await deleteTriggerSchedule(id) + invalidateSchedules() + } finally { + setIsMutating(false) + } + }, []) + + // Optimistically flip `flags.is_active` in the list cache, then call the + // start/stop route; on failure the cache is rolled back and refetched. + const setActive = useCallback(async (id: string, active: boolean): Promise<void> => { + const rollback = applyScheduleActiveOptimistic(id, active) + try { + await (active ? startTriggerSchedule(id) : stopTriggerSchedule(id)) + invalidateSchedules() + } catch (error) { + rollback() + invalidateSchedules() + throw error + } + }, []) + + return { + schedule: scheduleId ? (query.data?.schedule ?? null) : null, + isLoading: scheduleId ? query.isPending : false, + error: query.error, + isMutating, + create, + edit, + remove, + setActive, + } +} diff --git a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSchedules.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSchedules.ts new file mode 100644 index 0000000000..249683161a --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSchedules.ts @@ -0,0 +1,32 @@ +import {useMemo} from "react" + +import {useAtomValue} from "jotai" +import {atomWithQuery} from "jotai-tanstack-query" + +import {queryTriggerSchedules} from "../api" +import type {TriggerSchedule, TriggerSchedulesResponse} from "../core/types" + +// Distinct from subscription/catalog/connection keys. +export const triggerSchedulesQueryAtom = atomWithQuery<TriggerSchedulesResponse>(() => ({ + queryKey: ["triggers", "schedules"], + queryFn: () => queryTriggerSchedules(), + staleTime: 30_000, + refetchOnWindowFocus: false, +})) + +export const useTriggerSchedules = () => { + const query = useAtomValue(triggerSchedulesQueryAtom) + + const schedules = useMemo<TriggerSchedule[]>( + () => query.data?.schedules ?? [], + [query.data?.schedules], + ) + + return { + schedules, + count: query.data?.count ?? 0, + isLoading: query.isPending, + error: query.error, + refetch: query.refetch, + } +} 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..3accc2d5ec --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSubscription.ts @@ -0,0 +1,112 @@ +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, + startTriggerSubscription, + stopTriggerSubscription, +} from "../api" +import type { + TriggerSubscription, + TriggerSubscriptionCreate, + TriggerSubscriptionEdit, + TriggerSubscriptionResponse, +} from "../core/types" +import {applySubscriptionActiveOptimistic} from "../state/optimistic" + +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) + } + }, []) + + // Optimistic play/pause: flip `flags.is_active` in the cache, call + // start/stop, roll back on failure. + const setActive = useCallback(async (id: string, active: boolean): Promise<void> => { + const rollback = applySubscriptionActiveOptimistic(id, active) + try { + await (active ? startTriggerSubscription(id) : stopTriggerSubscription(id)) + invalidateSubscriptions() + } catch (error) { + rollback() + invalidateSubscriptions() + throw error + } + }, []) + + return { + subscription: subscriptionId ? (query.data?.subscription ?? null) : null, + isLoading: subscriptionId ? query.isPending : false, + error: query.error, + isMutating, + create, + edit, + revoke, + refresh, + remove, + setActive, + } +} 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..4cce8e31fd --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/index.ts @@ -0,0 +1,153 @@ +/** + * Gateway-trigger entity module. + * + * Browser-side state and queries for the `/triggers/*` endpoint family: + * the read-only events catalog and the shared connection list. + * + * 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, + TriggerCatalogIntegration, + TriggerCatalogIntegrationResponse, + TriggerCatalogIntegrationsResponse, + TriggerCatalogProvider, + TriggerCatalogProviderResponse, + TriggerCatalogProvidersResponse, + TriggerConnection, + TriggerConnectionCreatePayload, + TriggerConnectionResponse, + TriggerConnectionsResponse, + TriggerDelivery, + TriggerDeliveriesResponse, + TriggerDeliveryData, + TriggerDeliveryQuery, + TriggerDeliveryResponse, + TriggerProviderKind, + TriggerReference, + TriggerSchedule, + TriggerScheduleCreate, + TriggerScheduleData, + TriggerScheduleEdit, + TriggerScheduleFlags, + TriggerScheduleQuery, + TriggerScheduleResponse, + TriggerSchedulesResponse, + TriggerSelector, + TriggerStatus, + TriggerSubscription, + TriggerSubscriptionCreate, + TriggerSubscriptionData, + TriggerSubscriptionEdit, + TriggerSubscriptionFlags, + TriggerSubscriptionQuery, + TriggerSubscriptionResponse, + TriggerSubscriptionsResponse, +} from "./core" +export {isConnectionActive, isConnectionValid, isEntityActive, isEntityValid} from "./core" +export {describeCron, nextCronRuns, validateCron} from "./core/cron" +export type {CronValidationResult} from "./core/cron" +export {previewValue, resolveSelectorPreview} from "./core/selectorPreview" + +// --------------------------------------------------------------------------- +// API — HTTP wrappers (axios + zod boundary validation) +// --------------------------------------------------------------------------- + +export { + createTriggerConnection, + createTriggerSchedule, + createTriggerSubscription, + deleteTriggerConnection, + deleteTriggerSchedule, + deleteTriggerSubscription, + editTriggerSchedule, + editTriggerSubscription, + fetchTriggerConnection, + fetchTriggerDelivery, + fetchTriggerEvent, + fetchTriggerEvents, + fetchTriggerIntegration, + fetchTriggerIntegrations, + fetchTriggerProvider, + fetchTriggerProviders, + fetchTriggerSchedule, + fetchTriggerSubscription, + queryTriggerConnections, + queryTriggerDeliveries, + queryTriggerSchedules, + queryTriggerSubscriptions, + refreshTriggerConnection, + refreshTriggerSubscription, + revokeTriggerConnection, + revokeTriggerSubscription, + startTriggerSchedule, + startTriggerSubscription, + stopTriggerSchedule, + stopTriggerSubscription, + triggerApiErrorMessage, +} from "./api" + +// --------------------------------------------------------------------------- +// STATE — drawer + selection atoms +// --------------------------------------------------------------------------- + +export { + applyScheduleActiveOptimistic, + applySubscriptionActiveOptimistic, + triggerCatalogDrawerOpenAtom, + triggerDeliveriesDrawerAtom, + triggerEventsDrawerAtom, + triggerEventSearchAtom, + triggerScheduleDrawerAtom, + triggerSelectedCatalogEventAtom, + triggerSubscriptionDrawerAtom, +} from "./state" +export type { + DeliveriesDrawerState, + EventsDrawerState, + ScheduleDrawerState, + SubscriptionDrawerState, +} from "./state" + +// --------------------------------------------------------------------------- +// HOOKS — query hooks for React consumers +// --------------------------------------------------------------------------- + +export { + triggerCatalogEventsInfiniteFamily, + triggerCatalogIntegrationsInfiniteAtom, + triggerConnectionsQueryAtom, + triggerConnectionSubscriptionsAtomFamily, + triggerDeliveriesAtomFamily, + triggerEventDetailQueryFamily, + triggerEventsSearchAtom, + triggerIntegrationConnectionsAtomFamily, + triggerIntegrationsSearchAtom, + triggerScheduleQueryAtomFamily, + triggerSchedulesQueryAtom, + triggerSubscriptionQueryAtomFamily, + triggerSubscriptionsQueryAtom, + useTriggerCatalogEvents, + useTriggerCatalogIntegrations, + useTriggerConnectionActions, + useTriggerConnectionsQuery, + useTriggerConnectionSubscriptions, + useTriggerDeliveries, + useTriggerEvent, + useTriggerIntegrationConnections, + useTriggerSchedule, + useTriggerSchedules, + 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..4cb1e46af9 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/state/atoms.ts @@ -0,0 +1,58 @@ +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 +// --------------------------------------------------------------------------- + +export interface EventsDrawerState { + providerKey: string + integrationKey: string + integrationName?: string + connectionId?: string +} +export const triggerEventsDrawerAtom = atom<EventsDrawerState | null>(null) + +// Drawer-local browsing state (reset on close) +export const triggerEventSearchAtom = atom("") +export const triggerSelectedCatalogEventAtom = 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 triggerSubscriptionDrawerAtom = atom<SubscriptionDrawerState | null>(null) + +// --------------------------------------------------------------------------- +// Schedule drawer state — create (no id) or edit (existing schedule id) +// --------------------------------------------------------------------------- + +export interface ScheduleDrawerState { + // Edit mode when set; create mode otherwise. + scheduleId?: string +} +export const triggerScheduleDrawerAtom = atom<ScheduleDrawerState | null>(null) + +// --------------------------------------------------------------------------- +// Deliveries drawer state — opened against one subscription OR one schedule +// (a delivery belongs to exactly one of the two; XOR, DB-enforced). +// --------------------------------------------------------------------------- + +export interface DeliveriesDrawerState { + owner: {kind: "subscription" | "schedule"; id: string} + name?: string +} +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 new file mode 100644 index 0000000000..4d3c5f6ad7 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/state/index.ts @@ -0,0 +1,16 @@ +export { + triggerCatalogDrawerOpenAtom, + triggerDeliveriesDrawerAtom, + triggerEventsDrawerAtom, + triggerEventSearchAtom, + triggerScheduleDrawerAtom, + triggerSelectedCatalogEventAtom, + triggerSubscriptionDrawerAtom, +} from "./atoms" +export type { + DeliveriesDrawerState, + EventsDrawerState, + ScheduleDrawerState, + SubscriptionDrawerState, +} from "./atoms" +export {applyScheduleActiveOptimistic, applySubscriptionActiveOptimistic} from "./optimistic" diff --git a/web/packages/agenta-entities/src/gatewayTrigger/state/optimistic.ts b/web/packages/agenta-entities/src/gatewayTrigger/state/optimistic.ts new file mode 100644 index 0000000000..cfbad471d9 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/state/optimistic.ts @@ -0,0 +1,90 @@ +/** + * Optimistic `flags.is_active` updates for trigger schedules and subscriptions. + * + * Play/pause should feel instant: we flip `flags.is_active` in the TanStack + * Query caches up front, call the start/stop route, and roll back on failure. + * Each helper returns a `rollback` closure that restores the prior cache state. + */ + +import {queryClient} from "@agenta/shared/api" + +import type { + TriggerSchedule, + TriggerSchedulesResponse, + TriggerScheduleResponse, + TriggerSubscription, + TriggerSubscriptionsResponse, + TriggerSubscriptionResponse, +} from "../core/types" + +interface Entity { + id?: string | null + flags?: Record<string, unknown> | null +} + +function withActiveFlag<T extends Entity>(entity: T, active: boolean): T { + return {...entity, flags: {...(entity.flags ?? {}), is_active: active}} +} + +// --- Schedules --- + +export function applyScheduleActiveOptimistic(scheduleId: string, active: boolean): () => void { + const listKey = ["triggers", "schedules"] + const detailKey = ["triggers", "schedules", "detail", scheduleId] + + const prevList = queryClient.getQueryData<TriggerSchedulesResponse>(listKey) + const prevDetail = queryClient.getQueryData<TriggerScheduleResponse>(detailKey) + + if (prevList) { + queryClient.setQueryData<TriggerSchedulesResponse>(listKey, { + ...prevList, + schedules: prevList.schedules.map((s: TriggerSchedule) => + s.id === scheduleId ? withActiveFlag(s, active) : s, + ), + }) + } + if (prevDetail?.schedule) { + queryClient.setQueryData<TriggerScheduleResponse>(detailKey, { + ...prevDetail, + schedule: withActiveFlag(prevDetail.schedule, active), + }) + } + + return () => { + if (prevList) queryClient.setQueryData(listKey, prevList) + if (prevDetail) queryClient.setQueryData(detailKey, prevDetail) + } +} + +// --- Subscriptions --- + +export function applySubscriptionActiveOptimistic( + subscriptionId: string, + active: boolean, +): () => void { + const listKey = ["triggers", "subscriptions"] + const detailKey = ["triggers", "subscriptions", "detail", subscriptionId] + + const prevList = queryClient.getQueryData<TriggerSubscriptionsResponse>(listKey) + const prevDetail = queryClient.getQueryData<TriggerSubscriptionResponse>(detailKey) + + if (prevList) { + queryClient.setQueryData<TriggerSubscriptionsResponse>(listKey, { + ...prevList, + subscriptions: prevList.subscriptions.map((s: TriggerSubscription) => + s.id === subscriptionId ? withActiveFlag(s, active) : s, + ), + }) + } + if (prevDetail?.subscription) { + queryClient.setQueryData<TriggerSubscriptionResponse>(detailKey, { + ...prevDetail, + subscription: withActiveFlag(prevDetail.subscription, active), + }) + } + + return () => { + if (prevList) queryClient.setQueryData(listKey, prevList) + if (prevDetail) queryClient.setQueryData(detailKey, prevDetail) + } +} 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-entities/tests/unit/gatewayTriggerApi.test.ts b/web/packages/agenta-entities/tests/unit/gatewayTriggerApi.test.ts new file mode 100644 index 0000000000..075fdbb830 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/gatewayTriggerApi.test.ts @@ -0,0 +1,391 @@ +/** + * 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. + * + * Coverage: + * - Catalog browse: events are fetched against the triggers API shape. + * - `/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, put} = vi.hoisted(() => ({get: vi.fn(), post: vi.fn(), put: vi.fn()})) + +vi.mock("@agenta/shared/api", () => ({ + axios: {get, post, put}, + 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 { + createTriggerSchedule, + createTriggerSubscription, + editTriggerSchedule, + fetchTriggerSchedule, + fetchTriggerSubscription, + fetchTriggerEvent, + fetchTriggerEvents, + fetchTriggerProviders, + queryTriggerConnections, + queryTriggerDeliveries, + queryTriggerSchedules, + queryTriggerSubscriptions, + startTriggerSchedule, + startTriggerSubscription, + stopTriggerSchedule, + stopTriggerSubscription, +} from "../../src/gatewayTrigger/api/api" + +beforeEach(() => { + get.mockReset() + post.mockReset() + put.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 triggers 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", + trigger_id: "ti_abc", + data: { + event_key: "github_star_added_event", + 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("schedules (recurring cron timers)", () => { + const sampleSchedule = { + id: "sch-1", + name: "Nightly run", + flags: {is_active: true}, + data: { + event_key: "schedule.tick", + schedule: "0 9 * * *", + inputs_fields: {greeting: "hello"}, + references: {application_variant: {id: "var-1"}}, + }, + } + + it("creates a schedule with the {schedule} envelope and project scope", async () => { + post.mockResolvedValueOnce({data: {count: 1, schedule: sampleSchedule}}) + + const res = await createTriggerSchedule({ + name: "Nightly run", + data: { + event_key: "schedule.tick", + schedule: "0 9 * * *", + inputs_fields: {greeting: "hello"}, + references: {application_variant: {id: "var-1"}}, + }, + }) + + const [url, body, opts] = post.mock.calls[0] + expect(url).toBe("https://api.test/triggers/schedules/") + expect(body.schedule.data.schedule).toBe("0 9 * * *") + expect(body.schedule.data.references.application_variant.id).toBe("var-1") + expect(opts.params).toMatchObject({project_id: "proj-42"}) + expect(res.schedule?.id).toBe("sch-1") + }) + + it("edits a schedule with a full PUT to /schedules/{id}", async () => { + put.mockResolvedValueOnce({ + data: {count: 1, schedule: {...sampleSchedule, flags: {is_active: false}}}, + }) + + const res = await editTriggerSchedule({ + id: "sch-1", + name: "Nightly run", + data: sampleSchedule.data, + flags: {is_active: false}, + }) + + const [url, body] = put.mock.calls[0] + expect(url).toBe("https://api.test/triggers/schedules/sch-1") + expect(body.schedule.flags.is_active).toBe(false) + expect(res.schedule?.id).toBe("sch-1") + }) + + it("queries schedules under the {schedule} envelope", async () => { + post.mockResolvedValueOnce({data: {count: 1, schedules: [sampleSchedule]}}) + + const res = await queryTriggerSchedules({event_key: "schedule.tick"}) + + const [url, body] = post.mock.calls[0] + expect(url).toBe("https://api.test/triggers/schedules/query") + expect(body).toEqual({schedule: {event_key: "schedule.tick"}}) + expect(res.schedules[0].data.schedule).toBe("0 9 * * *") + }) + + it("fetches a single schedule by id", async () => { + get.mockResolvedValueOnce({data: {count: 1, schedule: sampleSchedule}}) + + const res = await fetchTriggerSchedule("sch-1") + + const [url] = get.mock.calls[0] + expect(url).toBe("https://api.test/triggers/schedules/sch-1") + expect(res.schedule?.data.schedule).toBe("0 9 * * *") + }) + + it("starts and stops a schedule via the lifecycle verb routes", async () => { + post.mockResolvedValueOnce({data: {count: 1, schedule: sampleSchedule}}) + await startTriggerSchedule("sch-1") + expect(post.mock.calls[0][0]).toBe("https://api.test/triggers/schedules/sch-1/start") + + post.mockResolvedValueOnce({data: {count: 1, schedule: sampleSchedule}}) + await stopTriggerSchedule("sch-1") + expect(post.mock.calls[1][0]).toBe("https://api.test/triggers/schedules/sch-1/stop") + }) + + it("falls back to an empty list when the schedules payload fails validation", async () => { + post.mockResolvedValueOnce({data: {schedules: "nope"}}) + const res = await queryTriggerSchedules() + expect(res).toEqual({count: 0, schedules: []}) + }) +}) + +describe("subscription start/stop", () => { + it("starts and stops a subscription via the lifecycle verb routes", async () => { + post.mockResolvedValueOnce({data: {count: 1, subscription: {id: "sub-1"}}}) + await startTriggerSubscription("sub-1") + expect(post.mock.calls[0][0]).toBe("https://api.test/triggers/subscriptions/sub-1/start") + + post.mockResolvedValueOnce({data: {count: 1, subscription: {id: "sub-1"}}}) + await stopTriggerSubscription("sub-1") + expect(post.mock.calls[1][0]).toBe("https://api.test/triggers/subscriptions/sub-1/stop") + }) +}) + +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-entities/tests/unit/gatewayTriggerCron.test.ts b/web/packages/agenta-entities/tests/unit/gatewayTriggerCron.test.ts new file mode 100644 index 0000000000..9beaa68776 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/gatewayTriggerCron.test.ts @@ -0,0 +1,85 @@ +/** + * Unit tests for the trigger-schedule cron helpers. + * + * Schedules use a 5-field UTC cron expression. The web has no cron dependency, + * so `core/cron.ts` is a tiny local validator + describer + "next runs" preview + * (the backend croniter remains the source of truth). These tests pin the + * validation bounds, the human-readable description, and the next-run scan. + */ + +import {describe, expect, it} from "vitest" + +import {describeCron, nextCronRuns, validateCron} from "../../src/gatewayTrigger/core/cron" + +describe("validateCron", () => { + it("accepts a well-formed 5-field expression", () => { + expect(validateCron("0 9 * * *")).toEqual({valid: true}) + expect(validateCron("*/15 * * * *")).toEqual({valid: true}) + expect(validateCron("0 0 1-15 1,6 1-5")).toEqual({valid: true}) + }) + + it("rejects an empty expression", () => { + expect(validateCron(" ")).toMatchObject({valid: false}) + }) + + it("rejects the wrong number of fields", () => { + const res = validateCron("0 9 * *") + expect(res.valid).toBe(false) + expect(res.error).toContain("5 fields") + }) + + it("rejects out-of-bounds field values", () => { + expect(validateCron("99 * * * *").valid).toBe(false) // minute > 59 + expect(validateCron("0 24 * * *").valid).toBe(false) // hour > 23 + expect(validateCron("0 0 0 * *").valid).toBe(false) // day-of-month < 1 + expect(validateCron("0 0 * 13 *").valid).toBe(false) // month > 12 + expect(validateCron("0 0 * * 7").valid).toBe(false) // weekday > 6 + }) + + it("rejects a bad step and a reversed range", () => { + expect(validateCron("*/0 * * * *").valid).toBe(false) + expect(validateCron("0 0 10-5 * *").valid).toBe(false) + }) +}) + +describe("describeCron", () => { + it("describes the common shapes", () => { + expect(describeCron("* * * * *")).toBe("Every minute (UTC)") + expect(describeCron("*/5 * * * *")).toBe("Every 5 minutes (UTC)") + expect(describeCron("0 * * * *")).toBe("Every hour (UTC)") + expect(describeCron("30 9 * * *")).toBe("Every day at 09:30 UTC") + expect(describeCron("0 9 * * 1")).toBe("Every Monday at 09:00 UTC") + }) + + it("echoes the raw expression for exotic shapes", () => { + expect(describeCron("0 9 1,15 * *")).toBe("0 9 1,15 * * (UTC)") + }) + + it("echoes an invalid expression unchanged", () => { + expect(describeCron("nonsense")).toBe("nonsense") + }) +}) + +describe("nextCronRuns", () => { + it("returns the requested count of UTC fire times for a daily schedule", () => { + const from = new Date("2026-06-21T08:00:00Z") + const runs = nextCronRuns("0 9 * * *", 3, from) + + expect(runs).toHaveLength(3) + expect(runs[0].toISOString()).toBe("2026-06-21T09:00:00.000Z") + expect(runs[1].toISOString()).toBe("2026-06-22T09:00:00.000Z") + expect(runs[2].toISOString()).toBe("2026-06-23T09:00:00.000Z") + }) + + it("steps every-N-minutes from the next whole minute", () => { + const from = new Date("2026-06-21T08:00:30Z") + const runs = nextCronRuns("*/15 * * * *", 2, from) + + expect(runs[0].toISOString()).toBe("2026-06-21T08:15:00.000Z") + expect(runs[1].toISOString()).toBe("2026-06-21T08:30:00.000Z") + }) + + it("returns an empty list for an invalid expression", () => { + expect(nextCronRuns("nope", 3)).toEqual([]) + }) +}) diff --git a/web/packages/agenta-entities/tests/unit/gatewayTriggerSelectorPreview.test.ts b/web/packages/agenta-entities/tests/unit/gatewayTriggerSelectorPreview.test.ts new file mode 100644 index 0000000000..40ea124ce0 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/gatewayTriggerSelectorPreview.test.ts @@ -0,0 +1,84 @@ +/** + * Unit tests for the subscription mapping selector-preview helpers. + * + * The subscription drawer maps workflow inputs from the event context via + * selectors: JSONPath-lite (`$.a.b[0]`, `$["a"]`) or JSON Pointer (`/a/b/0`). + * These pin resolution across the supported syntaxes and the best-effort + * (never-throw) failure behavior. The backend remains the source of truth. + */ + +import {describe, expect, it} from "vitest" + +import {previewValue, resolveSelectorPreview} from "../../src/gatewayTrigger/core/selectorPreview" + +const CONTEXT = { + event: { + event_id: "evt_1", + event_type: "github.issue.opened", + attributes: { + repository: "acme/widgets", + labels: ["bug", "p0"], + author: {login: "octocat"}, + }, + }, +} + +describe("resolveSelectorPreview", () => { + it("returns the whole context for the root selector", () => { + expect(resolveSelectorPreview("$", CONTEXT)).toBe(CONTEXT) + }) + + it("resolves a dotted JSONPath", () => { + expect(resolveSelectorPreview("$.event.event_type", CONTEXT)).toBe("github.issue.opened") + expect(resolveSelectorPreview("$.event.attributes.author.login", CONTEXT)).toBe("octocat") + }) + + it("resolves array index in bracket form", () => { + expect(resolveSelectorPreview("$.event.attributes.labels[0]", CONTEXT)).toBe("bug") + expect(resolveSelectorPreview("$.event.attributes.labels[1]", CONTEXT)).toBe("p0") + }) + + it("resolves quoted bracket keys", () => { + expect(resolveSelectorPreview('$.event["attributes"]["repository"]', CONTEXT)).toBe( + "acme/widgets", + ) + }) + + it("resolves JSON Pointer syntax", () => { + expect(resolveSelectorPreview("/event/event_id", CONTEXT)).toBe("evt_1") + expect(resolveSelectorPreview("/event/attributes/labels/0", CONTEXT)).toBe("bug") + }) + + it("decodes JSON Pointer escapes (~1 -> /, ~0 -> ~)", () => { + const data = {"a/b": {"c~d": 42}} as Record<string, unknown> + expect(resolveSelectorPreview("/a~1b/c~0d", data)).toBe(42) + }) + + it("returns undefined for a missing path", () => { + expect(resolveSelectorPreview("$.event.nope.deeper", CONTEXT)).toBeUndefined() + }) + + it("returns undefined for a non-integer array index", () => { + expect(resolveSelectorPreview("$.event.attributes.labels.x", CONTEXT)).toBeUndefined() + }) + + it("returns undefined for an unsupported selector form", () => { + expect(resolveSelectorPreview("event.event_id", CONTEXT)).toBeUndefined() + }) + + it("returns undefined when walking past a scalar", () => { + expect(resolveSelectorPreview("$.event.event_id.deeper", CONTEXT)).toBeUndefined() + }) +}) + +describe("previewValue", () => { + it("passes strings through unchanged", () => { + expect(previewValue("hello")).toBe("hello") + }) + + it("JSON-stringifies non-strings", () => { + expect(previewValue(42)).toBe("42") + expect(previewValue(["a", "b"])).toBe('["a","b"]') + expect(previewValue({k: 1})).toBe('{"k":1}') + }) +}) 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/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/components/ActiveToggle.tsx b/web/packages/agenta-entity-ui/src/gatewayTrigger/components/ActiveToggle.tsx new file mode 100644 index 0000000000..d26ea7c8c5 --- /dev/null +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/components/ActiveToggle.tsx @@ -0,0 +1,67 @@ +import {useCallback, useState} from "react" + +import {Pause, Play} from "@phosphor-icons/react" +import {Button, Tooltip, message} from "antd" + +// --------------------------------------------------------------------------- +// ActiveToggle — shared play/pause control for the three lifecycle entities +// (trigger subscription, trigger schedule, webhook subscription). They all +// expose `flags.is_active`; the parent wires `onToggle` to the matching +// start/stop route (with optimistic cache update). This component only owns the +// in-flight spinner + error surfacing so each list/drawer reuses it verbatim. +// --------------------------------------------------------------------------- + +export interface ActiveToggleProps { + active: boolean + onToggle: (next: boolean) => Promise<void> + disabled?: boolean + size?: "small" | "middle" | "large" + /** Shown on success/failure; defaults are generic. */ + activatedMessage?: string + pausedMessage?: string + errorMessage?: string +} + +export default function ActiveToggle({ + active, + onToggle, + disabled, + size = "small", + activatedMessage = "Activated", + pausedMessage = "Paused", + errorMessage = "Failed to update state", +}: ActiveToggleProps) { + const [loading, setLoading] = useState(false) + + const handleClick = useCallback( + async (e: React.MouseEvent) => { + e.stopPropagation() + const next = !active + setLoading(true) + try { + await onToggle(next) + message.success(next ? activatedMessage : pausedMessage) + } catch { + message.error(errorMessage) + } finally { + setLoading(false) + } + }, + [active, onToggle, activatedMessage, pausedMessage, errorMessage], + ) + + return ( + <Tooltip title={active ? "Pause" : "Resume"}> + <Button + type="text" + size={size} + loading={loading} + disabled={disabled} + aria-label={active ? "Pause" : "Resume"} + aria-pressed={active} + icon={active ? <Pause size={16} /> : <Play size={16} />} + onClick={handleClick} + /> + </Tooltip> + ) +} 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 new file mode 100644 index 0000000000..2c56a70c0e --- /dev/null +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerDeliveriesDrawer.tsx @@ -0,0 +1,163 @@ +import {useMemo} from "react" + +import { + triggerDeliveriesDrawerAtom, + 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 +// OR one schedule (a delivery belongs to exactly one of the two; XOR). +// +// One audit row per dispatch 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(triggerDeliveriesDrawerAtom) + const open = !!state + + const {deliveries, isLoading} = useTriggerDeliveries(state?.owner) + + 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?.name ? ` · ${state.name}` : ""}`} + 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..e0588f9291 --- /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 { + triggerEventsDrawerAtom, + triggerEventsSearchAtom, + useTriggerCatalogEvents, + 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(triggerEventsDrawerAtom) + const [selectedEvent, setSelectedEvent] = useState<TriggerCatalogEvent | null>(null) + const setEventsSearch = useSetAtom(triggerEventsSearchAtom) + + 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(triggerEventsSearchAtom) + const search = useDebouncedAtomSearch(setAtom) + const scrollRef = useRef<HTMLDivElement>(null) + + const { + events, + total, + prefetchThreshold, + isLoading, + hasNextPage, + isFetchingNextPage, + requestMore, + } = useTriggerCatalogEvents(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/TriggerScheduleDrawer.tsx b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerScheduleDrawer.tsx new file mode 100644 index 0000000000..44b2125993 --- /dev/null +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerScheduleDrawer.tsx @@ -0,0 +1,355 @@ +import {useCallback, useEffect, useMemo, useState} from "react" + +import { + describeCron, + isEntityActive, + nextCronRuns, + triggerApiErrorMessage, + triggerScheduleDrawerAtom, + useTriggerSchedule, + validateCron, + type TriggerScheduleCreate, + type TriggerScheduleData, + type TriggerScheduleEdit, +} from "@agenta/entities/gatewayTrigger" +import {appWorkflowsListQueryStateAtom} from "@agenta/entities/workflow" +import {Editor} from "@agenta/ui/editor" +import {Button, Divider, Drawer, Form, Input, Spin, Switch, Typography, message} from "antd" +import {useAtom} from "jotai" + +import { + createWorkflowRevisionAdapter, + EntityPicker, + type WorkflowRevisionSelectionResult, +} from "../../selection" + +const DEFAULT_CRON = "0 9 * * *" +// A schedule fires a synthetic tick; there is no provider event, but the data +// model still requires an `event_key`. We use a stable schedule-tick key. +const SCHEDULE_EVENT_KEY = "schedule.tick" + +// Schedules bind the `application_*` reference family (same as subscriptions), +// so the picker only offers application workflows (is_application=True). +const applicationRevisionAdapter = createWorkflowRevisionAdapter({ + workflowListAtom: appWorkflowsListQueryStateAtom, +}) + +// --------------------------------------------------------------------------- +// TriggerScheduleDrawer (root) — create or edit a schedule. +// +// Binds a recurring UTC cron tick to a workflow revision. Edits are full-PUT: +// the body is sourced from the freshly-fetched schedule and only owned fields +// are overridden. Mirrors TriggerSubscriptionDrawer; the Composio event picker +// is replaced by a validated cron-expression field with a "next runs" hint. +// --------------------------------------------------------------------------- + +export default function TriggerScheduleDrawer() { + const [state, setState] = useAtom(triggerScheduleDrawerAtom) + const open = !!state + const isEdit = !!state?.scheduleId + + const handleClose = useCallback(() => setState(null), [setState]) + + return ( + <Drawer + open={open} + onClose={handleClose} + title={isEdit ? "Edit schedule" : "New schedule"} + width={640} + destroyOnClose + styles={{ + body: {padding: 0, display: "flex", flexDirection: "column", overflow: "hidden"}, + }} + > + {state && <ScheduleForm key={state.scheduleId ?? "new"} onClose={handleClose} />} + </Drawer> + ) +} + +// --------------------------------------------------------------------------- +// Schedule form +// --------------------------------------------------------------------------- + +function ScheduleForm({onClose}: {onClose: () => void}) { + const [state] = useAtom(triggerScheduleDrawerAtom) + const scheduleId = state?.scheduleId + const isEdit = !!scheduleId + + const { + schedule, + isLoading: scheduleLoading, + isMutating, + create, + edit, + } = useTriggerSchedule(scheduleId) + + const [name, setName] = useState("") + const [cron, setCron] = useState(DEFAULT_CRON) + 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) + + // Prefill from the freshly-fetched schedule (edit mode). + useEffect(() => { + if (!isEdit || !schedule) return + setName(schedule.name ?? "") + setCron(schedule.data?.schedule ?? DEFAULT_CRON) + setEnabled(isEntityActive(schedule)) + const wfId = + schedule.data?.references?.application_revision?.id ?? + schedule.data?.references?.application_variant?.id ?? + schedule.data?.references?.workflow_revision?.id ?? + null + setWorkflowRevId(wfId) + setWorkflowLabel(wfId) + setInputsText(JSON.stringify(schedule.data?.inputs_fields ?? {}, null, 2)) + }, [isEdit, schedule]) + + const cronValidation = useMemo(() => validateCron(cron), [cron]) + + const handleSubmit = useCallback(async () => { + if (!cronValidation.valid) { + message.error(cronValidation.error ?? "Invalid cron expression") + 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 + } + + // 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}, + } + : (schedule?.data?.references ?? {application_variant: {id: workflowRevId}}) + + const data: TriggerScheduleData = { + event_key: schedule?.data?.event_key ?? SCHEDULE_EVENT_KEY, + schedule: cron.trim(), + inputs_fields: inputsFields, + references, + } + + try { + if (isEdit && schedule) { + // Full PUT — carry the whole entity, override owned fields. + const body: TriggerScheduleEdit = { + id: schedule.id as string, + name: name || null, + description: schedule.description ?? null, + tags: schedule.tags ?? null, + meta: schedule.meta ?? null, + data: {...schedule.data, ...data}, + flags: {...(schedule.flags ?? {}), is_active: enabled}, + } + const result = await edit(body) + if (!result) { + message.error("Failed to update schedule") + return + } + message.success("Schedule updated") + } else { + const body: TriggerScheduleCreate = { + name: name || null, + data, + } + const result = await create(body) + if (!result) { + message.error("Failed to create schedule") + return + } + message.success("Schedule created") + } + onClose() + } catch (error) { + message.error(triggerApiErrorMessage(error, "Failed to save schedule")) + } + }, [ + cronValidation, + cron, + workflowRevId, + workflowSelection, + inputsText, + isEdit, + schedule, + name, + enabled, + edit, + create, + onClose, + ]) + + if (isEdit && scheduleLoading) { + 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="Schedule name" + value={name} + onChange={(e) => setName(e.target.value)} + /> + </Form.Item> + + <CronField value={cron} onChange={setCron} /> + + <Form.Item label="Bound workflow" required> + <div className="flex items-center gap-2"> + <EntityPicker<WorkflowRevisionSelectionResult> + variant="popover-cascader" + adapter={applicationRevisionAdapter} + onSelect={(selection) => { + setWorkflowRevId(selection.id) + setWorkflowSelection(selection) + 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" /> + + <InputsField + value={inputsText} + onChange={setInputsText} + error={inputsError} + disabled={isMutating} + /> + + <Form.Item label="Active"> + <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> + ) +} + +// --------------------------------------------------------------------------- +// CronField — a 5-field cron input with client-side validation, a +// human-readable description, and a "next runs" UTC preview. The backend +// (croniter) remains the source of truth; this is a fast local check + hint. +// --------------------------------------------------------------------------- + +function CronField({value, onChange}: {value: string; onChange: (next: string) => void}) { + const validation = useMemo(() => validateCron(value), [value]) + const description = useMemo( + () => (validation.valid ? describeCron(value) : null), + [validation.valid, value], + ) + const nextRuns = useMemo( + () => (validation.valid ? nextCronRuns(value, 3) : []), + [validation.valid, value], + ) + + return ( + <Form.Item + label="Schedule (cron)" + required + validateStatus={validation.valid ? undefined : "error"} + help={validation.valid ? description : validation.error} + > + <Input + placeholder="minute hour day month weekday (UTC)" + value={value} + onChange={(e) => onChange(e.target.value)} + /> + <Typography.Text type="secondary" className="!text-[11px] leading-snug block mt-1"> + 5-field cron in UTC (e.g. <code>0 9 * * *</code> = every day at 09:00 UTC). + </Typography.Text> + {validation.valid && nextRuns.length > 0 && ( + <div className="mt-1 flex flex-col gap-0.5"> + <Typography.Text type="secondary" className="!text-[11px]"> + Next runs (UTC): + </Typography.Text> + {nextRuns.map((run) => ( + <code key={run.toISOString()} className="text-[11px] text-gray-500"> + {run.toISOString().replace("T", " ").replace(".000Z", " UTC")} + </code> + ))} + </div> + )} + </Form.Item> + ) +} + +// --------------------------------------------------------------------------- +// InputsField — JSON editor for the static inputs passed to the workflow on +// each tick. A schedule has no event payload, so (unlike subscriptions) the +// values are literals rather than payload selectors. +// --------------------------------------------------------------------------- + +function InputsField({ + value, + onChange, + error, + disabled, +}: { + value: string + onChange: (next: string) => void + error: string | null + disabled?: boolean +}) { + return ( + <Form.Item + label="Inputs" + validateStatus={error ? "error" : undefined} + help={error ?? "Static inputs passed to the workflow on each tick (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> + </Form.Item> + ) +} 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..fe3e5a9759 --- /dev/null +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerSubscriptionDrawer.tsx @@ -0,0 +1,617 @@ +import {useCallback, useEffect, useMemo, useRef, useState} from "react" + +import { + isEntityActive, + isEntityValid, + previewValue, + resolveSelectorPreview, + triggerApiErrorMessage, + triggerSubscriptionDrawerAtom, + useTriggerCatalogEvents, + useTriggerConnectionsQuery, + useTriggerEvent, + useTriggerSubscription, + type TriggerConnection, + type TriggerSubscriptionCreate, + 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" +import {useAtom} from "jotai" + +import SchemaForm, {type SchemaFormHandle} from "../../gatewayTool/components/SchemaForm" +import { + createWorkflowRevisionAdapter, + EntityPicker, + 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. +// +// 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(triggerSubscriptionDrawerAtom) + 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(triggerSubscriptionDrawerAtom) + 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 [workflowSelection, setWorkflowSelection] = + useState<WorkflowRevisionSelectionResult | 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(isEntityActive(subscription)) + 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)) + }, [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 + } + + // 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, + } + + 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, + tags: subscription.tags ?? null, + meta: subscription.meta ?? null, + connection_id: connectionId, + data: {...subscription.data, ...data}, + flags: { + ...(subscription.flags ?? {}), + is_active: enabled, + is_valid: isEntityValid(subscription), + }, + } + 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 (error) { + message.error(triggerApiErrorMessage(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> + <EventSelect + integrationKey={integrationKey} + value={eventKey} + onChange={setEventKey} + 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={applicationRevisionAdapter} + onSelect={(selection) => { + setWorkflowRevId(selection.id) + setWorkflowSelection(selection) + 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"> + {!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> + ) : ( + <SchemaForm + ref={configFormRef} + schema={triggerConfigSchema} + form={configForm} + disabled={isMutating} + /> + )} + </div> + + <InputsMappingField + value={inputsText} + onChange={setInputsText} + error={inputsError} + onErrorChange={setInputsError} + eventPayload={ + (eventDetail?.payload ?? null) as Record<string, unknown> | null + } + disabled={isMutating} + /> + + <Form.Item label="Active"> + <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> + ) +} + +// --------------------------------------------------------------------------- +// 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: { + event_id: "evt_…", + event_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} +} 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..21bf52eeb8 --- /dev/null +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/index.ts @@ -0,0 +1,17 @@ +/** + * 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 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 TriggerScheduleDrawer} from "./drawers/TriggerScheduleDrawer" +export {default as TriggerDeliveriesDrawer} from "./drawers/TriggerDeliveriesDrawer" +export {default as ActiveToggle} from "./components/ActiveToggle" +export type {ActiveToggleProps} from "./components/ActiveToggle" diff --git a/web/tests/playwright.config.ts b/web/tests/playwright.config.ts index 3b45592525..16856f5fdc 100644 --- a/web/tests/playwright.config.ts +++ b/web/tests/playwright.config.ts @@ -34,7 +34,7 @@ export default defineConfig({ fullyParallel: false, // Temporarily disabled parallel worker forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : process.env.RETRIES ? parseInt(process.env.RETRIES) : 0, - workers: 1, // Temporarily disabled parallel worker + // workers: 1, // Temporarily disabled parallel worker reporter: [ ["html", {outputFolder: getReportDir()}], ["junit", {outputFile: getJunitPath()}], diff --git a/web/tests/playwright/global-setup.ts b/web/tests/playwright/global-setup.ts index 5c1f16d14e..846428c518 100644 --- a/web/tests/playwright/global-setup.ts +++ b/web/tests/playwright/global-setup.ts @@ -18,6 +18,21 @@ import { type AuthMode = "auto" | "password" | "otp" type TestmailClient = ReturnType<typeof getTestmailClient> +/** + * Read the auth method the backend serves from the `/auth/discover` response. + * The payload is `{exists, methods: {"email:password"?: true, "email:otp"?: true, ...}}` + * — only available methods are present. Prefer password (local dev / no SMTP does a + * direct signup with no email verification); fall back to otp only when password is + * absent. Returns null when discovery was missing/unparseable so the caller keeps `auto`. + */ +function resolveDiscoveredAuthMode(discovery: unknown): AuthMode | null { + const methods = (discovery as {methods?: Record<string, unknown>} | null)?.methods + if (!methods || typeof methods !== "object") return null + if (methods["email:password"]) return "password" + if (methods["email:otp"]) return "otp" + return null +} + function getApiURL(webURL: string): string { if (process.env.AGENTA_API_URL) return process.env.AGENTA_API_URL try { @@ -536,7 +551,9 @@ async function authenticateUserImpl({ const continueButton = page.getByRole("button", {name: "Continue", exact: true}) // Wait for and intercept the discovery API call when clicking Continue - const discoveryTimeout = new Promise((resolve) => setTimeout(resolve, 15000)) + const discoveryTimeout = new Promise<null>((resolve) => + setTimeout(() => resolve(null), 15000), + ) const discoveryPromise = Promise.race([ waitForApiResponse(page, { route: /\/api\/auth\/discover(?:\?|$)/, @@ -555,8 +572,19 @@ async function authenticateUserImpl({ // Wait for discovery to complete so the auth method UI is fully rendered console.log("[global-setup] Waiting for auth discovery to complete") - await discoveryPromise + const discovery = await discoveryPromise console.log("[global-setup] Auth discovery completed") + + // The backend authoritatively tells us which email method it serves: + // `email:password` (local dev / no SMTP — direct signup, no verification) + // vs `email:otp` (SMTP/SendGrid enabled). Promote `auto` to the concrete + // method the deployment actually offers so we don't demand Testmail/OTP on + // a stack that only does password. + const discovered = resolveDiscoveredAuthMode(discovery) + if (authMode === "auto" && discovered) { + authMode = discovered + console.log(`[global-setup] Discovery resolved auth mode: ${authMode}`) + } } const verifyEmailLocator = page.getByText("Verify your email") diff --git a/web/tests/playwright/scripts/run-tests.ts b/web/tests/playwright/scripts/run-tests.ts index d7970a319d..6aa5a21a7f 100644 --- a/web/tests/playwright/scripts/run-tests.ts +++ b/web/tests/playwright/scripts/run-tests.ts @@ -188,7 +188,9 @@ function buildPlaywrightArgs(grepPatterns: string[], playwrightArgs: string[]): */ function runVitestLayer(targetLayer: TestLayer, grepPatterns: string[]): number { const script = LAYER_PACKAGE_SCRIPT[targetLayer] - const pnpmArgs = ["-r", "--if-present", "run", script] + // Exclude this orchestration workspace: its test:acceptance script invokes + // this runner and would recursively run the acceptance suite a second time. + const pnpmArgs = ["--filter", "!agenta-web-tests", "-r", "--if-present", "run", script] // Forward dimension markers to vitest as a name filter, mirroring how the // Playwright phase turns them into --grep. Args after `--` reach vitest. 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})