diff --git a/brain/knowledge/connections-auth/ce-authentication.md b/brain/knowledge/connections-auth/ce-authentication.md index 7bb8647f2319..5be36fcc5804 100644 --- a/brain/knowledge/connections-auth/ce-authentication.md +++ b/brain/knowledge/connections-auth/ce-authentication.md @@ -16,8 +16,8 @@ The core (all-editions) auth layer: user identity creation, sign-in, and JWT ses - Token is a short-lived JWT (7 days) signed with a shared secret. `PrincipalType`: USER, ENGINE, WORKER, SERVICE, UNKNOWN, ONBOARDING. - Endpoints (all rate-limited via `API_RATE_LIMIT_AUTHN_*`): `POST /v1/authentication/sign-up`, `/sign-in`, `/switch-platform`. - First sign-up side effects: creates identity → User (PlatformRole.ADMIN) → default PERSONAL project; sends OTP on Cloud prod, auto-verifies otherwise; fires `USER_CREATED` flag + `SIGNED_UP` telemetry. -- **`signUp` has two arms and only one of them can create a platform.** When `params.platformId` is set (self-hosted, or a custom domain) the member joins that existing platform through `getOrCreateWithProject` and no platform is ever created or named. When it is nil (Cloud only) the identity is created first, then `getPreferredPlatformId` looks for a platform the identity already belongs to; finding none it returns an ONBOARDING response and the member finishes at `/create-platform`. `getPreferredPlatformId` returns null on every non-Cloud edition. **The member never types a platform name; they type their own, and the platform name is derived from it.** `completeSignUp` takes a single `fullName` field (that is the whole of `CompleteSignUpRequest`) and calls `signupNames.platformNameFromSignup`, which prefers the company read off a work email domain (`"Activepieces"`) and falls back to the person (`"'s Platform"`, then the capitalised first token of the email local part, then `"My Platform"`). The project name follows from the platform name via `personalProjectName`. -- **ONBOARDING** is the pre-platform principal: `authenticationUtils.getOnboardingResponse` mints it with `platformId: null, projectId: null` for a verified identity that belongs to no platform yet, so the member can call `POST /v1/platforms` (`securityAccess.unscoped([ONBOARDING, USER])`) and land on `/create-platform`. It is Cloud-only in practice, because on self-hosted `platformUtils.getPlatformIdForRequest` falls back to `getOldestPlatform()` and there is always a platform to join. `accessTokenManager.assertUserSession` still revalidates it against `tokenVersion` + `verified`. +- **`signUp` has two arms and only one of them can create a platform.** When `params.platformId` is set (self-hosted, or a custom domain) the member joins that existing platform through `getOrCreateWithProject` and no platform is ever created or named. When it is nil (Cloud only) the identity is created first, then `getPreferredPlatformId` looks for a platform the identity already belongs to; finding none it hands off to `authenticationUtils.provisionOrOnboard`, which creates the platform straight away when the identity already carries a name someone gave us, and only falls back to an ONBOARDING response (finished at `/create-platform`) when the name is the placeholder derived from the email. `getPreferredPlatformId` returns null on every non-Cloud edition. **The member never types a platform name; they type their own, and the platform name is derived from it.** `completeSignUp` takes a single `fullName` field (that is the whole of `CompleteSignUpRequest`) and calls `signupNames.platformNameFromSignup`, which prefers the company read off a work email domain (`"Activepieces"`) and falls back to the person (`"'s Platform"`, then the capitalised first token of the email local part, then `"My Platform"`). The project name follows from the platform name via `personalProjectName`. +- **ONBOARDING** is the pre-platform principal: `authenticationUtils.getOnboardingResponse` mints it with `platformId: null, projectId: null` for a verified identity that belongs to no platform yet **and whose name we only guessed**, so the member can call `POST /v1/platforms` (`securityAccess.unscoped([ONBOARDING, USER])`) and land on `/create-platform`. It is Cloud-only in practice, because on self-hosted `platformUtils.getPlatformIdForRequest` falls back to `getOldestPlatform()` and there is always a platform to join. `accessTokenManager.assertUserSession` still revalidates it against `tokenVersion` + `verified`. - **Passwordless sign-in** (`EMAIL_LOGIN`) is a typed 6-digit code on the same OTP primitive, offered only when `ApFlagId.SMTP_CONFIGURED` is true, with password as the fallback path. See [000027](../decisions/000027-email-sign-in-is-a-typed-code-on-the-existing-otp-primitive.md) for the code-not-link, edition-reach and anti-enumeration reasoning. ### Gotchas @@ -27,10 +27,11 @@ The core (all-editions) auth layer: user identity creation, sign-in, and JWT ses - Sessions are invalidated by rotating `tokenVersion` on `UserIdentity`. - **A new unauthenticated endpoint must be added to `disallowedRoutes` in `packages/web/src/lib/api.ts`**, otherwise the SPA attaches whatever stale bearer token is still in storage and the call fails in exactly the situation the endpoint exists for. - **The three signup guards in `authentication-utils.ts` differ in what they leak.** `assertEmailAuthIsEnabled` and `assertDomainIsAllowed` describe platform configuration, so surfacing their errors is safe. `assertUserIsInvitedToPlatformOrProject` describes one address, so surfacing it turns any public auth endpoint into an invitation oracle. All three are also inert unless `plan.ssoEnabled`. +- **We ask for a name only when we do not already have one, and `signupNames.isPlaceholderName` is what decides.** A name counts as a placeholder when the last name is empty *and* the first name matches `firstNameFromEmail` for that address case-insensitively — exactly what `requestCode` seeds an emailed-code identity with. Anything else provisions the platform without a second question, and the two other producers of a name cannot collide with the placeholder shape: `SignUpRequest` types `firstName`/`lastName` as `SAFE_STRING_PATTERN` (`^[^./]+$`, so an empty last name is a 400 at the schema, not just a required field in the form), and the Google callback substitutes `'john'`/`'doe'` when the provider omits a name. The comparison must stay case-insensitive: `requestCode` derives the name from the raw address while the identity stores it lowercased, so `AhmadTash@…` would otherwise look like a name its owner typed. - **A nil `projectId` on the principal means "go to /create-platform" in four separate places.** Anything that mints a platform-less session has to satisfy all of them, not just the route guard. - **Platform naming reads the email domain first, and "is this a work address" is a denylist of consumer brands.** `ahmad@activepieces.com` yields `"Activepieces"` while `ahmad@gmail.com` yields `"Ahmad's Platform"`. Two details are easy to get wrong when touching `signup-names.ts`. The denylist is keyed on the **registrable label**, not the full domain, so `yahoo.co.uk` is caught by the single entry `yahoo`. And the label is picked as the second-to-last domain part, stepping back one more when the part before the TLD is itself a public suffix (`co`, `com`, `ac`, ...), so `mail.activepieces.com`, `activepieces.co.uk` and `eu.activepieces.co.uk` all resolve to `Activepieces` rather than to `Mail`, `Co` or `Eu`. It is a heuristic, not a public-suffix list: a company sitting on an unlisted two-part suffix gets the suffix as its name. Only new signups are affected; existing platforms keep their names. - **The route no longer decides sign-in vs sign-up — the card does.** `/sign-in`, `/sign-up` and `/create-platform` all render the same `AuthLanding`; `/sign-up` is a bare redirect to `/sign-in`. Which form you get is a function of two flags: with `SMTP_CONFIGURED` the card opens on the email-code step and the classic password form exists *only* behind the "Use password" link; without it you land on a password form directly, and `USER_CREATED` picks sign-up (first ever account, no mode switch offered) over sign-in. So the same URL renders three different DOMs across Cloud, a seeded self-host, and a fresh install — anything scripting this screen has to branch, and password sign-*up* is simply unreachable once SMTP is on. -- **`/create-platform` is that same card opening on its name step**, off the ONBOARDING token rather than a route param — submitting the name is what mints the platform and project and swaps ONBOARDING for USER. The field is the *person's* `Full Name` (`data-testid="auth-full-name"`), not a workspace name. A brand-new account therefore needs *two* form submissions before it has a project, which is easy to miss when automating first-run signup. +- **`/create-platform` is that same card opening on its name step**, off the ONBOARDING token rather than a route param — submitting the name is what mints the platform and project and swaps ONBOARDING for USER. The field is the *person's* `Full Name` (`data-testid="auth-full-name"`), not a workspace name. **Only the emailed-code path reaches it**: password sign-up and Google already collected a name, so those sessions are provisioned in the same request and land in the product with one form submission. ### Key files Entry point: `authenticationService`, a log-taking factory called per request from `authentication.controller.ts`, registered as `authenticationModule` in `app.ts`. diff --git a/brain/knowledge/decisions/000014-streaming-file-inputs-resolve-to-a-lazy-apstreamingfile.md b/brain/knowledge/decisions/000014-streaming-file-inputs-resolve-to-a-lazy-apstreamingfile.md index 5546af0fcc6e..4ac166e0f62d 100644 --- a/brain/knowledge/decisions/000014-streaming-file-inputs-resolve-to-a-lazy-apstreamingfile.md +++ b/brain/knowledge/decisions/000014-streaming-file-inputs-resolve-to-a-lazy-apstreamingfile.md @@ -23,5 +23,6 @@ A piece that uploads a large file to an external service (Amazon S3, Dropbox, Go - There is **no** `AP_MAX_FILE_SIZE_MB` **ceiling** on the streamed URL input. Intentional — the feature exists to move large files, and the prior buffered path was likewise unbounded. A cap is deferred; it would need a counting pass-through stream that aborts past the limit. - The `body` is **one-shot** — no whole-stream retry. Since #14347 `lib-storage` buffers each \~5 MB part before sending, so transient part-level errors (connection reset, throttling, HTTP 500) *are* retried within the upload. What remains non-retryable is the transfer as a whole: the source `Readable` cannot be re-read, so a failure that outlives the part retries cannot be replayed. Whole-stream retry would require buffering, which defeats streaming. - **A stream body bypasses `httpClient`'s retry loop entirely** (`isStream ? 0 : retries` in `packages/pieces/common/src/lib/http/core/fetch-http-client.ts`). The loop reuses the body serialized before the first attempt, and both shapes it treats as a stream — a raw `Readable` and the `PassThrough` a `form-data` payload is piped into — are one-shot, so a retried request replays a drained stream and sends an empty or truncated body. Silently corrupting an upload is worse than not retrying it. The blast radius is wider than the file pieces: this is shared `pieces-common` behaviour, so *any* piece passing a stream or `form-data` body to `httpClient` now loses its `retries` setting. The rejected alternative was a body-*factory* API (`() => Readable`, as Azure's `BufferScheduler` does per block): neither source is re-readable, so every caller would have to learn to rebuild its body — a large change for a case the chunking uploaders (S3 `Upload`, Azure `uploadStream`) already handle better. +- Engines older than 0.87.0 ignore the `streaming` flag and deliver a buffered `ApFile` (no `body`, no `size`), and the registry serves latest piece versions to them regardless. Consumers must therefore never read `.body`/`.size` off the prop directly: `streamUtils.toStreamingBody` in `pieces-common` normalizes both shapes, and `FileProperty` types the streaming value as `ApStreamingFile | ApFile` so a direct read fails to compile (GIT-1808). - The URL `fetch` opens the source connection at **input-resolution time** (before `run()`), like the buffered path. - SSRF posture is unchanged from the existing buffered `handleUrlFile`: the same raw `fetch` also legitimately retrieves AP's own internal http `readUrl`s, so the https-only + `redirect:'error'` guard used by external-only piece code (e.g. SimplyPrint) is deliberately **not** applied here. diff --git a/brain/knowledge/engineering/ci-pr-review-hygiene.md b/brain/knowledge/engineering/ci-pr-review-hygiene.md index 4ac813ea142f..c2779187e649 100644 --- a/brain/knowledge/engineering/ci-pr-review-hygiene.md +++ b/brain/knowledge/engineering/ci-pr-review-hygiene.md @@ -35,4 +35,6 @@ Enforcement is the **`Codeowners review` repository ruleset** (active on the def - **The preview-server remove tool can't clean containers once the repo dir is gone.** Its `stop()` skips `docker compose down` when `repos//docker-compose.yml` doesn't exist, so an env whose repo folder was deleted first leaves containers running forever — re-running `remove` is a no-op for them. Clean those manually via compose labels: `docker ps -aq --filter "label=com.docker.compose.project="` (same filter works for `docker volume ls`). When auditing envs against PR state: read the real branch from the clone's HEAD (`git -C repos/ symbolic-ref --short HEAD`) since subdomains flatten `/` to `-`; a clone sitting on `main` means the branch was deleted after merge; and an env with **no PR at all** is a manual `workflow_dispatch` preview — don't auto-delete those (bulk cleanup 2026-08-20 removed 27 closed-PR envs, reclaimed 32.5GB). - **A unit test added under `packages/server/api/test/unit/` never runs in CI.** `ci.yml` runs exactly two test commands: `turbo run test` filtered to engine/shared/sandbox/ai-providers/pieces-framework/web, and `turbo run test-ce test-ee test-cloud check-migrations --filter=api`. The api package *has* a `test-unit` script (`vitest run test/unit`), but no workflow invokes it and the root `test-unit` filter list does not include api — so the 10+ files already sitting in `test/unit/**` are dead weight, and a new one passes review while protecting nothing. `packages/core/execution` is in the same position. Until the wiring changes, put api coverage that must actually gate merges in `test/integration/ce|ee|cloud`, and if you do add a unit test, say in the PR that you ran it locally and paste the result. - **`tools/scripts/` is outside the lint and test wiring.** ESLint ignores it, and `npm run test-unit` only covers engine/shared/web. A script there with real policy logic must run its own tests from its own workflow — `pr-size.yml` runs `bun test tools/scripts/pr-size-check.test.ts` as a step before the check itself. +- **Reopening a bot-closed external PR is futile until a core member adds `keep-open` first.** `close-external-prs.yml` triggers on `pull_request_target` `[opened, reopened]`, so every reopen re-runs the same comment-then-close step; its `if` exempts OWNER/MEMBER/COLLABORATOR, bots, and the `keep-open` label, and nothing else. A docs PR from an outside contributor ([#15031](https://github.com/activepieces/activepieces/pull/15031)) was reopened 13 times over two days and closed 13 times within seconds of each, until a member labelled it `keep-open` and reopened it once. The same job also runs a nightly `actions/stale` pass that closes any PR idle 60 days. The lasting fix for a change worth keeping is to re-open it from a branch owned by someone with write access — author association, not the diff, is what the gate reads. +- **`license/cla` keys off the commit author email, so re-opening someone else's branch under your own name does not clear it.** CLA-assistant walks every commit in the PR rather than the PR author, and an author email that matches no GitHub account can never be matched to a signature — the 47 commits carried over onto [#15092](https://github.com/activepieces/activepieces/pull/15092) were authored as `ashrafsam@mac.lan`, a local hostname, so the check sat at `not_signed` on a PR opened by a member. It is not in the `main` ruleset's required-checks list, but it is red on the page and a reviewer reads that as unmergeable. Either the original author signs through the PR link, or the commits get re-authored to an email tied to their GitHub account before you open it. - **A branch that predates the `brain/` → `brain/knowledge/` move cannot edit a brain page in place — GitHub will call the PR conflicting even when `git merge` is clean locally.** Git follows the rename and merges the modification into the new path; GitHub's mergeability check does not, so it reports `modify/delete` on the old path and the PR goes `dirty`. Local `git merge-tree --write-tree` exits 0 and hides the problem; reproduce what GitHub sees with `git merge -X no-renames origin/main`. Fix: merge `origin/main` into the branch first, which lands the edit at the new path, then push. diff --git a/brain/knowledge/engineering/helm-chart.md b/brain/knowledge/engineering/helm-chart.md new file mode 100644 index 000000000000..f707d1dcdbf8 --- /dev/null +++ b/brain/knowledge/engineering/helm-chart.md @@ -0,0 +1,24 @@ +--- +icon: ⛵ +--- + +# Helm Chart + +The Kubernetes install we ship to self-hosters, at `deploy/activepieces-helm/`. It is the Kubernetes peer of the `docker-compose.yml` on the Docker page — same app, different orchestrator — and it is **not** how our own Cloud deploys (see *Cloud Deployment Paths*, which runs Kamal and k3s). + +## Two paths for an AP_* variable +`templates/deployment.yaml` builds one `env:` list from two values keys, in this fixed order: + +- **`activepiecesConfig`** — a flat map rendered as plain `value:` entries. Rendered **first**. The shipped default holds only `AP_CONTAINER_TYPE`. +- **`activepiecesEnvVariables`** — a map of *secret name* → *list of var names*, rendered as `secretKeyRef` with `optional: true`. Rendered **second**. The shipped default routes `AP_EDITION`, `AP_EXECUTION_MODE`, `AP_ENCRYPTION_KEY`, `AP_JWT_SECRET` and the queue/auth vars through three secrets the chart does not create. + +## What the chart creates +Only two secrets, both `data: {}` with mittwald `secret-generator` annotations that fill them in-cluster: `-secrets` (encryption key) and `-jwt-secret`. Postgres and Redis come from the Bitnami subcharts unless disabled. + +## Key files +- `deploy/activepieces-helm` — chart, `values.yaml`, and `templates/` + +## Gotchas +- **Setting the same `AP_*` var in both values keys puts two entries with one name in the pod spec, and the secret wins.** `activepiecesConfig` renders before `activepiecesEnvVariables`, and for duplicate env names the later entry is what the container process sees. Since the shipped `values.yaml` already lists `AP_EDITION` and `AP_EXECUTION_MODE` under `activepieces-config-secrets`, a user who follows the docs *and* has created that secret silently gets the secret's edition, not the one they set. `optional: true` saves the common case — with no such secret the ref is skipped and the plain value survives — so this reads as "works on my cluster" right up until someone populates the secret. Set each variable in exactly one place. +- **`activepieces-config-secrets`, `activepieces-auth-secrets` and `activepieces-queue-secrets` do not exist until you make them, and the script `values.yaml` names for the job is not in the repo.** The comment points at `deploy/scripts/apply-secrets.sh --secret-name `; there is no `deploy/scripts/` directory. Every ref is `optional: true`, so a fresh `helm install` comes up anyway on the app's own defaults — which is why the gap survived: nothing fails, the vars are just quietly absent. +- **`AP_EDITION=ee` needs `AP_EXECUTION_MODE` set in the same breath or the pod will not boot.** `system-validator.ts` throws for `cloud`/`ee` in production unless the mode is one of `SANDBOX_PROCESS`, `SANDBOX_CODE_ONLY`, `SANDBOX_CODE_AND_PROCESS`, and the default is `UNSANDBOXED`. The error names the execution mode, not the edition, so it reads as a sandboxing problem rather than the edition switch that caused it. diff --git a/brain/knowledge/engineering/index.md b/brain/knowledge/engineering/index.md index 47f707f5a294..84039feb6af3 100644 --- a/brain/knowledge/engineering/index.md +++ b/brain/knowledge/engineering/index.md @@ -32,6 +32,7 @@ The **Activepieces engineering brain**: how the system works, and *why* it was b - **Server Module Anatomy** — the six files of a server module (entity → migration → repo → service → controller → module), and the manual registration steps nothing auto-discovers - **Web Feature Anatomy** — the frontend feature folder, its barrel, route guards, and when a query gets the global error dialog - **Cloud Deployment Paths** — canary → prod, the `cloud-hotfix` override, and the breaking-migration gate that blocks both +- **Helm Chart** — the Kubernetes install we ship to self-hosters, its two competing paths for an `AP_*` variable, and the secrets it never creates - **CI PR Review Hygiene** — draft-first Greptile review, the per-area PR size gate, and the workflow conventions reviewers keep re-litigating - **E2E Tests & Monitors** — the one Playwright suite behind CI, the Checkly monitors that run it against production Cloud, and the BetterStack script the repo pushes on merge - **Architecture Spine** — the load-bearing structure of the codebase, and the gotchas that come with it: request-body `.max()` as data loss, TypeORM soft-delete across a canary window, and canary not proxying websockets diff --git a/brain/knowledge/flows-execution/chat.md b/brain/knowledge/flows-execution/chat.md index a93b3152640d..e83de92354cc 100644 --- a/brain/knowledge/flows-execution/chat.md +++ b/brain/knowledge/flows-execution/chat.md @@ -45,6 +45,10 @@ A turn is kept alive / reclaimed by three separate mechanisms in `execute-agent- - **`ai` and `evlog` are version-coupled.** evlog ≤2.18.1 imports `TelemetryIntegration` from `ai`, which v7 renamed to `Telemetry`, so bumping `ai` to 7 without bumping `evlog` (≥2.22.4, which peers `ai >=6.0.168 <8.0.0` and supports both v6 and v7 hooks) will not compile. That evlog bump in turn changes `DefinedAuditAction` from `` to `` and breaks `helper/audit-events.ts` — drop the explicit annotation and let `defineAuditAction`'s inference supply it. - **AI SDK v7 is ESM-only, and that is NOT a reason to convert the server to ESM.** `ai@7` ships `type: module` with no `require` condition, but the CJS server consumes it fine through Node's `require(esm)` (Node 22.12+/24, verified), and TS 5.5.4 resolves its types under `module: CommonJS` + `moduleResolution: node` because a root `main` and an adjacent `index.d.ts` still exist and `skipLibCheck` is on. No ESM migration, no TypeScript upgrade. Mixed `ai` majors across workspaces are also safe and intentional — `bunfig.toml` sets `linker = "isolated"`, so pieces/framework/engine can stay on v6 while the agent path runs v7. +- **`ap_show_connection_required` is an alias of `ap_show_connection_picker`, not a smaller capability.** Both names resolve to the same `ConnectionPickerCard`, which lists every account the caller has for that piece and offers "Use a different account"; the only schema difference is an optional `status: 'missing' | 'error'` hint. So an allow-list that grants one name and asserts the other is absent proves nothing: verified live on the agent surface, granting only `ap_show_connection_required` renders "Which account should I use?". The card also fetches the account list itself from the frontend, keyed by `conversationId`, so the tool payload cannot constrain what it offers. A repair-only variant therefore lives in the endpoint feeding the card, not in the tool set. + +- **On a saved-agent run, choosing a different account in the connection card does nothing.** `onConnectionSelected` writes into `selectedConnectionByPiece` (`execute-agent-run.ts`), which is read only through `getSelectedAuth`, passed only to the MCP tool set — and `AgentRunSource.AGENT` is not granted `groups.mcp` at all. Configured piece tools carry the agent's stored `pieceMetadata` auth instead. So the card reports the account as connected while the tool keeps calling on the pinned one. Only the in-place Reconnect actually repairs an agent run, because it re-authorizes the same connection row the agent is pinned to. So `/v1/agents/conversations/:id/connections` answers `{ connections, reconnectOnly }`, and for an `AGENT`-source conversation returns only the accounts that agent's tools pin. Three things that branch has to get right, each of which was a live bug first: match on `(projectId, externalId)`, because `externalId` is caller-supplied and its index is **not** unique, so a same-id row in another project can pose as the pinned one; read the pin through `published ?? draft`, the same as the run; and unwrap the pre-0.87 `{{connections['id']}}` template form, or an older agent reads as having no pinned account and the card tells the user their live account is gone. The card must also carry the row's own `projectId` into the reconnect dialog, which otherwise falls back to the session project and repairs the wrong one. + ### Key files Entry point: `agentModule`, the Fastify plugin registered in `packages/server/api/src/app/app.ts`. diff --git a/bun.lock b/bun.lock index 9eb8ba408fef..9879aacaecfb 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "activepieces", @@ -117,7 +116,7 @@ }, "packages/core/execution": { "name": "@activepieces/core-execution", - "version": "0.14.0", + "version": "0.15.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -162,7 +161,7 @@ }, "packages/core/shared": { "name": "@activepieces/shared", - "version": "0.143.0", + "version": "0.146.0", "dependencies": { "@activepieces/core-execution": "workspace:*", "@activepieces/core-formula": "workspace:*", @@ -213,7 +212,7 @@ }, "packages/pieces/common": { "name": "@activepieces/pieces-common", - "version": "0.12.9", + "version": "0.13.0", "dependencies": { "@activepieces/core-utils": "workspace:*", "@activepieces/pieces-framework": "workspace:*", @@ -222,6 +221,7 @@ }, "devDependencies": { "tslib": "2.6.2", + "vitest": "3.2.6", }, }, "packages/pieces/community/activecampaign": { @@ -544,7 +544,7 @@ }, "packages/pieces/community/amazon-s3": { "name": "@activepieces/piece-amazon-s3", - "version": "0.6.7", + "version": "0.6.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -932,7 +932,7 @@ }, "packages/pieces/community/azure-blob-storage": { "name": "@activepieces/piece-azure-blob-storage", - "version": "0.1.9", + "version": "0.1.10", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -2735,7 +2735,7 @@ }, "packages/pieces/community/dropbox": { "name": "@activepieces/piece-dropbox", - "version": "0.7.7", + "version": "0.7.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -3789,7 +3789,7 @@ }, "packages/pieces/community/google-drive": { "name": "@activepieces/piece-google-drive", - "version": "0.9.0", + "version": "0.9.1", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -5745,7 +5745,7 @@ }, "packages/pieces/community/microsoft-onedrive": { "name": "@activepieces/piece-microsoft-onedrive", - "version": "0.4.6", + "version": "0.4.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -5759,6 +5759,7 @@ "devDependencies": { "@types/mime-types": "2.1.1", "tslib": "2.6.2", + "vitest": "3.2.6", }, }, "packages/pieces/community/microsoft-onenote": { @@ -5822,7 +5823,7 @@ }, "packages/pieces/community/microsoft-sharepoint": { "name": "@activepieces/piece-microsoft-sharepoint", - "version": "0.3.7", + "version": "0.3.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -10480,7 +10481,7 @@ }, "packages/pieces/core/sftp": { "name": "@activepieces/piece-sftp", - "version": "0.5.7", + "version": "0.5.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -10533,7 +10534,7 @@ }, "packages/pieces/core/subflows": { "name": "@activepieces/piece-subflows", - "version": "0.6.3", + "version": "0.6.4", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -10632,7 +10633,7 @@ }, "packages/pieces/framework": { "name": "@activepieces/pieces-framework", - "version": "0.37.0", + "version": "0.38.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", diff --git a/docs/_snippets/embed-feature.mdx b/docs/_snippets/embed-feature.mdx new file mode 100644 index 000000000000..6a3e24245e06 --- /dev/null +++ b/docs/_snippets/embed-feature.mdx @@ -0,0 +1,3 @@ + +Embedding is available on our enterprise plan. [Talk to sales](https://www.activepieces.com/sales) and we'll help you scope it. + diff --git a/docs/_snippets/enterprise-feature.mdx b/docs/_snippets/enterprise-feature.mdx index 84c27338fbb2..2e857b2bae31 100644 --- a/docs/_snippets/enterprise-feature.mdx +++ b/docs/_snippets/enterprise-feature.mdx @@ -1,3 +1,3 @@ - -This feature is available in our paid editions. Contact us [here](https://www.activepieces.com/sales), and we'll be delighted to assist you! - \ No newline at end of file + +This is a paid feature. See [plans and pricing](https://www.activepieces.com/pricing), or [talk to sales](https://www.activepieces.com/sales) about an enterprise plan. + diff --git a/docs/admin-guide/guides/setup-ai-providers.mdx b/docs/admin-guide/guides/setup-ai-providers.mdx index 3b4b73177f7f..99ba50c97a96 100644 --- a/docs/admin-guide/guides/setup-ai-providers.mdx +++ b/docs/admin-guide/guides/setup-ai-providers.mdx @@ -1,31 +1,64 @@ --- title: "Setup AI Providers" -description: "" +description: "Bring your own AI keys and use them across every project" icon: "sparkles" --- -AI providers are configured by the platform admin to centrally manage credentials and access, making [AI pieces](https://www.activepieces.com/pieces/ai) and their features available to everyone in all projects. +AI providers are configured once by the platform admin, with your own keys. Every project then gets [AI pieces](https://www.activepieces.com/pieces/ai), agents, and AI steps without anyone else handling a credential. -## Supported Providers +## How to set one up -- **OpenAI** -- **Anthropic** -- **Gemini** -- **Vercel AI Gateway** -- **Cloudflare AI Gateway** +Go to **Platform Admin** → **AI Center**, pick a provider, and add your key. The setup screen carries the exact steps for getting a key from that provider. -## How to Setup +![Manage AI Providers](/resources/screenshots/configure-ai-provider.png) -Go to **Admin Console** → **AI** page. Add your provider's base URL and API key. These settings apply to all projects. +## Supported providers -![Manage AI Providers](/resources/screenshots/configure-ai-provider.png) + +
+ **Model providers** + - OpenAI + - Anthropic + - Google Gemini + - Mistral AI + - DeepSeek + - xAI + - Qwen + - Z.ai + - MiniMax + - Moonshot AI +
+
+ **Cloud platforms** + - Azure + - AWS Bedrock + + **Gateways** + - OpenRouter + - Cloudflare AI Gateway + + **Anything else** + - Other (OpenAI Compatible) +
+
-## Cost Control & Logging + +**Other (OpenAI Compatible)** covers any endpoint that speaks the OpenAI API, including self-hosted models and gateways not listed here. Point it at your own base URL. + -Use an AI gateway like **Vercel AI Gateway** or **Cloudflare AI Gateway** to: +## Cost control and logging + + +Spend on your own key goes to your provider, so put a gateway in front of it when you want limits and an audit trail: - Set rate limits and budgets -- Log and monitor all AI requests +- Log and monitor every AI request - Track usage across projects -Just set the gateway URL as your provider's base URL in the Admin Console. +**OpenRouter** and **Cloudflare AI Gateway** are set up like any other provider. Any other gateway that speaks the OpenAI API works through **Other (OpenAI Compatible)**, so LiteLLM, Portkey, Helicone, and self-hosted routers are all fine. + +**Azure** and **AWS Bedrock** give you the same thing through your cloud account's own quotas and logs. + + +You can add several providers at once and pick the model per agent or per step, so the expensive models only run on the work that needs them. + diff --git a/docs/admin-guide/overview.mdx b/docs/admin-guide/overview.mdx index 1a13136a6a82..310057ab453e 100644 --- a/docs/admin-guide/overview.mdx +++ b/docs/admin-guide/overview.mdx @@ -1,23 +1,67 @@ --- -title: "Overview" -icon: "hand-wave" -description: "Manage and customize your Activepieces instance" +title: "Enterprise Control" +sidebarTitle: "Overview" +description: "Give your organization AI automation without giving up control" +icon: "crown" --- -The **Platform Admin** is the centralized admin panel for managing your Activepieces instance. It's designed for teams and organizations that want full control over users, integrations, security, and internal automation. +Most AI tools ask you to choose: let people move fast, or keep control. Activepieces is built so you don't have to. -## What Can You Do? + +Most of these controls are on paid plans. Every plan can run on Cloud or self-hosted. See [plans and pricing](https://www.activepieces.com/pricing). + -With Platform Admin, you can: +Everything is designed around one question: **can IT hand automation to the business and still answer for it?** Who can build, which apps they can reach, where credentials live, what the AI is allowed to do, and what happened afterwards. -- **Custom Branding:** Tailor the appearance of Activepieces to match your organization's identity, including colors, logos, and fonts. +That's why regulated industries and governments run Activepieces, often fully self-hosted and network-gapped, on their own infrastructure. -- **Project Management:** Create, edit, and organize projects for internal teams and users. +## What control looks like -- **Piece Management:** Control which integration pieces are available, including managing custom or internal pieces for your team's workflows. + + + Projects, roles, and permissions, so teams are separated by default. + + + SSO and SCIM, so access follows your identity provider. + + + Bring your own vault. Credentials never have to sit in the product. + + + Decide which apps and pieces your organization can use at all. + + + Your providers, your models, your budget. + + + Audit logs for every meaningful action, streamable to your SIEM. + + -- **User Management:** Add and remove users, send invitations, and assign roles and permissions. +## Why it matters for AI -- **AI Provider Management:** Configure and manage AI providers (like OpenAI, Anthropic, etc.) available for use in your flows. +Shadow AI is the current version of shadow IT. People will automate with AI whether or not you provide a way, and the risk isn't the automation, it's not knowing it exists. -- **SSO & Security:** Configure Single Sign-On (SSO) providers and manage security settings to ensure your instance is secure. \ No newline at end of file +Activepieces makes the sanctioned path the easy one: + +| Concern | The control | +|---|---| +| Data leaving your network | Self-host it, fully network-gapped | +| Which models see your data | [Your own AI keys and providers](/admin-guide/guides/setup-ai-providers) | +| Credentials in the wrong hands | [External secret managers](/admin-guide/guides/secret-managers/overview) | +| Unreviewed changes reaching production | [Project releases](/admin-guide/guides/project-releases) and environments | +| No record of what was done | [Audit logs](/admin-guide/security/audit-logs/overview) and [event streaming](/admin-guide/guides/event-streaming) | + +## Start here + + + + Separate teams, data, and connections. + + + Sign in with your identity provider. + + + How we build and what we recommend. + + diff --git a/docs/agents/create.mdx b/docs/agents/create.mdx new file mode 100644 index 000000000000..83d3489e4f2c --- /dev/null +++ b/docs/agents/create.mdx @@ -0,0 +1,55 @@ +--- +title: "Create an agent" +sidebarTitle: "Create an agent" +description: "The three routes in, and what each setting is for" +icon: "pen" +--- + +## Pick a starting point + + + + Say what you need in a sentence. + + + Start from one already written. + + + Write the instructions yourself. + + + +The prompt box sits at the top of the Agents page. Templates and blank are behind **New agent**. All three land in the same editor, and all three need an [AI provider connected](/admin-guide/guides/setup-ai-providers). + +## Set it up + +The **Configure** tab holds everything that changes what the agent does. + + + + Write down the job, how to decide, and when to stop. + + + Give it the app actions, flows, and MCP servers it needs to reach. + + + Attach the documents and tables it should look things up in. + + + Pick the model that answers and the key that pays for it. + + + Name the fields you want back, so a flow can use the result. + + + Cap how many actions it can take in one run, so a stuck agent stops. + + + +## Try it + +The editor has a conversation panel beside the configuration. Talk to the agent there, watch which tools it reaches for, and adjust the instructions until it behaves. Changes take effect as soon as you save. + + +Brief it like a capable new colleague. Say the goal and the edges, not every keystroke. The two people forget most: what "done" looks like, and which calls it should hand back to a human. + diff --git a/docs/agents/in-flows.mdx b/docs/agents/in-flows.mdx new file mode 100644 index 000000000000..fdc9839d0b33 --- /dev/null +++ b/docs/agents/in-flows.mdx @@ -0,0 +1,46 @@ +--- +title: "Agents in Flows" +sidebarTitle: "Agents in Flows" +description: "Use an agent as a step, and act on what it returns" +icon: "sitemap" +--- + +Drop an agent into a [flow](/flows/building-flows) as a step. The flow handles what's certain, the agent handles the rest. + +## What people use it for + + + + Looks up a lead and returns a brief with a fit score. + + + Reads an email, classifies it, sets the urgency. + + + Files the invoice, updates the system, posts the summary. + + + + +If you can write the rule in one sentence without saying "depends", it's a condition, not an agent. + + +## Get a result you can build on + +Prose is hard to branch on, because the phrasing drifts every run. + +> "This looks fairly urgent, someone should pick it up today." + +Ask for **structured output** instead and you get fields: + +| Field | Type | Example | +|---|---|---| +| `category` | Text | `billing` | +| `urgency` | Number | `4` | +| `needs_human` | Boolean | `true` | + +Later steps pick these up from the [data selector](/flows/passing-data), the same as any other step's output. Route on `needs_human`, sort by `urgency`, drop `category` into the Slack message. + + +Ask for the fewest fields the next step needs. Every extra field is one more thing to get wrong. + diff --git a/docs/agents/knowledge.mdx b/docs/agents/knowledge.mdx new file mode 100644 index 000000000000..8aceec88767d --- /dev/null +++ b/docs/agents/knowledge.mdx @@ -0,0 +1,55 @@ +--- +title: "Agent Knowledge" +sidebarTitle: "Agent Knowledge" +description: "Give an agent something to look things up in" +icon: "book" +--- + +Attach a document or a table and the agent looks the answer up instead of guessing at it. + +## Two sources + + + + Upload a document. PDF, DOCX, TXT, or CSV. + + + Point at a table already in your project. + + + +Both live in the **Knowledge Base** section of the agent editor, below Agent Tools. Add one with **Add File Source** or **Add Table Source**. + + +Two things have to be in place or the section does not appear at all. Your Postgres needs the `pgvector` extension, which Activepieces installs on startup when the database offers it — Cloud and the standard self-hosted images are covered, but a managed Postgres without `pgvector` hides knowledge entirely. And the agent's model provider has to support embeddings: OpenAI, Google, Azure, and OpenRouter do. + + +## Which one + +| | File | Table | +|---|---|---| +| **Holds** | Documents, policies, guides | Rows of data | +| **Changes** | Rarely | Constantly | +| **Updated by** | Re-uploading | Your flows, live | + + +If a flow already writes the data, use a table. The agent then reads what's true right now, with nothing to re-upload. + + +## Why not just paste it in + +Two reasons, and the first is size. + +Everything in an agent's instructions has to fit the model's context window, and all of it is read on **every single run**. That caps you at a few pages, and makes every run slower and more expensive whether the content was relevant or not. + +Knowledge works the other way round. A file is split into chunks and indexed, and the agent searches it only when it needs something, pulling back the passages that match. A three-hundred-page policy manual costs nothing until a question actually touches it. + +The second reason is freshness. A product list pasted into instructions works until the list changes, and then you have two versions of the truth and one of them is wrong. + +Instructions are for **how to behave**. Knowledge is for **what's true**. + +## Say when to use it + +Knowledge tells the agent what it can look up, not when to bother. Put that in the [instructions](/agents/create): + +> Before answering pricing questions, check the pricing table. Never quote a figure that isn't in it. diff --git a/docs/agents/overview.mdx b/docs/agents/overview.mdx new file mode 100644 index 000000000000..78a05761a784 --- /dev/null +++ b/docs/agents/overview.mdx @@ -0,0 +1,43 @@ +--- +title: "Agents" +sidebarTitle: "Overview" +description: "AI workers that do the parts you can't draw as steps" +icon: "robot" +--- + +Describe the job. Give it tools. It figures out the rest. + + + Agents in an Activepieces project + + +## What is an agent? + +An agent is someone you brief rather than something you configure. You write down the job in plain language, give it access to the apps it needs, and it handles the work as it arrives. + +Each time it runs, it reads the situation, decides which tools to use, and keeps going until the job is done. Two similar cases can be handled differently, the same way a colleague would treat them differently. + +## Flow or agent? + +| | Flow | Agent | +|---|---|---| +| **The logic is** | Written down once | Worked out each run | +| **Same input twice** | Same steps every time | May handle it differently | +| **Best when** | You know the procedure | The work varies | + +## What it can do + + + + Any action from 760+ pieces becomes a tool it can call. + + + Answers grounded in your documents and tables. + + + Run one as a step and get structured data back. + + + Say what you need and the agent gets written for you. + + diff --git a/docs/agents/tools.mdx b/docs/agents/tools.mdx new file mode 100644 index 000000000000..3996e78c357c --- /dev/null +++ b/docs/agents/tools.mdx @@ -0,0 +1,62 @@ +--- +title: "Agent Tools" +sidebarTitle: "Agent Tools" +description: "How an agent reaches your apps, your flows, and your systems" +icon: "wrench" +--- + +Tools are how an agent reaches the rest of your company, and it uses them two ways. + +**To find out.** It searches Slack, Linear, Drive, or any of 760+ apps at the moment it needs the answer, rather than working from whatever you pasted into the instructions. + +**To act.** It raises the invoice, drafts the document, updates the record. + +Attach none and it can only talk. + + + + A single action from one of your connected apps. + + + One of your own flows, handed over as a tool. + + + An external server, for systems that aren't pieces. + + + +## Piece tools + +You attach individual actions, not whole apps. An agent given **Send message** can post to Slack and nothing else. + +Each input on that action is one of three modes, so you pin what has to be exact and leave the rest to the agent. + + + + It writes the value. + + + You pin it. + + + Nothing is sent. + + + +Pin the Slack channel, let the agent write the message. + +## Flow tools + +The most underused one. When part of a job has to happen the same way every time, build that part as a flow and hand it over. + +The agent chooses the moment. The flow guarantees the steps. + +## MCP servers + +Paste the URL and pick how it authenticates: none, an access token, an API key, or custom headers. Streamable HTTP, SSE, and plain HTTP all work. + +Activepieces lists the server's tools as soon as you add it, so you know it works before an agent depends on it. + + +When an agent keeps getting something wrong, ask what it was missing. Usually it needed somewhere to look something up, not a longer prompt. + diff --git a/docs/ai/chat.mdx b/docs/ai/chat.mdx new file mode 100644 index 000000000000..c25e937f1e30 --- /dev/null +++ b/docs/ai/chat.mdx @@ -0,0 +1,33 @@ +--- +title: "Build with Chat" +sidebarTitle: "Chat" +description: "Describe what you want and Activepieces builds it" +icon: "message" +--- + +Instead of dragging steps together, describe the outcome you want and Chat builds the agent or automation for you. It picks the pieces, wires the steps, and fills in the configuration. + +## Getting started + +Open **Chat** in the sidebar and describe what you want in plain language. Be specific about the outcome rather than the steps: + +- "When a new row is added to my CRM, summarise it and post to Slack" +- "Every Monday, pull last week's support tickets and email me the themes" +- "Watch this inbox and create a task for anything that looks like a bug report" + +Chat asks for anything it needs, such as which account to use, and builds the flow. You can review every step before publishing. + +## What it can do + +- Build flows from a description, choosing the pieces for you +- Connect the apps a flow needs +- Explain what an existing flow does +- Change a flow you already have + +## After it builds + +Chat produces a normal flow. Open it in the builder, edit any step by hand, and [publish it](/flows/publishing-flows) when you're happy. Nothing is locked or special about a flow that Chat created. + +## Prefer your own AI client? + +If you'd rather build from Claude, Cursor, or any other MCP client, the [MCP server](/mcp/overview) exposes the same capabilities as tools. diff --git a/docs/build-pieces/piece-reference/large-file-streaming.mdx b/docs/build-pieces/piece-reference/large-file-streaming.mdx index a5db6f163b67..e66b1c3ff4de 100644 --- a/docs/build-pieces/piece-reference/large-file-streaming.mdx +++ b/docs/build-pieces/piece-reference/large-file-streaming.mdx @@ -114,8 +114,9 @@ async run(context) { ### Reading a file as a stream -Add `streaming: true` to a `Property.File`. The property then resolves to an -`ApStreamingFile` instead of an `ApFile`: +Add `streaming: true` to a `Property.File`. On platforms 0.87.0 and later the property +resolves to an `ApStreamingFile`; older engines still deliver a buffered `ApFile`, so the +value is typed as `ApStreamingFile | ApFile`: ```ts type ApStreamingFile = { @@ -126,6 +127,17 @@ type ApStreamingFile = { }; ``` +Never read `.body` or `.size` off the property directly — normalize it first with +`streamUtils.toStreamingBody` from `@activepieces/pieces-common`. It passes an +`ApStreamingFile` through untouched and wraps an `ApFile`'s buffer into a `Readable` with an +exact `size`: + +```ts +import { streamUtils } from '@activepieces/pieces-common'; + +const { body, size } = streamUtils.toStreamingBody(context.propsValue.file); +``` + Consume `body` directly. How you hand it to the destination depends on what that destination's client accepts, in order of preference: @@ -143,7 +155,7 @@ props: { }), }, async run(context) { - const { file } = context.propsValue; + const { body } = streamUtils.toStreamingBody(context.propsValue.file); const s3 = await resolveS3Client({ authProps, server: context.server }); await new Upload({ @@ -151,30 +163,31 @@ async run(context) { params: { Bucket: bucket, Key: finalFileName, - Body: file.body, + Body: body, }, }).done(); } ``` **2. An SDK that takes a stream directly.** Some clients accept a `Readable` as-is: Google -Drive's `media.body`, SFTP's `client.put`. Just pass `file.body`. +Drive's `media.body`, SFTP's `client.put`. Just pass `body`. **3. A single-request HTTP upload.** If the destination is a plain `PUT`/`POST` that needs an -explicit `Content-Length`, you have to use `file.size`, and `size` is best-effort, so this +explicit `Content-Length`, you have to use `size`, and `size` is best-effort, so this path needs a buffered fallback for when it is missing. Dropbox, SharePoint and OneDrive all look like this: ```ts import { buffer as readableToBuffer } from 'node:stream/consumers'; +const { body: streamedBody, size } = streamUtils.toStreamingBody(context.propsValue.file); const headers: Record = { 'Content-Type': 'application/octet-stream' }; let body; -if (file.size != null) { - headers['Content-Length'] = String(file.size); - body = file.body; +if (size != null) { + headers['Content-Length'] = String(size); + body = streamedBody; } else { - body = await readableToBuffer(file.body); + body = await readableToBuffer(streamedBody); } ``` diff --git a/docs/docs.json b/docs/docs.json index decf220d5bdd..2ef74cda325d 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -20,8 +20,8 @@ ], "primary": { "type": "button", - "label": "Get Started", - "href": "https://www.activepieces.com/plans" + "label": "Sign Up", + "href": "https://cloud.activepieces.com/sign-up" } }, "redirects": [ @@ -96,7 +96,7 @@ "navigation": { "tabs": [ { - "tab": "Overview", + "tab": "Get Started", "groups": [ { "group": "Overview", @@ -104,6 +104,16 @@ "overview/welcome" ] }, + { + "group": "Agents", + "pages": [ + "agents/overview", + "agents/create", + "agents/tools", + "agents/knowledge", + "agents/in-flows" + ] + }, { "group": "Flows", "pages": [ @@ -118,88 +128,111 @@ ] }, { - "group": "MCP Server", + "group": "Tables", "pages": [ - "mcp/overview", - "mcp/tools", - "mcp/tool-search" + "tables/overview", + "tables/fields", + "tables/in-flows", + "tables/with-agents", + "tables/import-export" ] }, { - "group": "About", + "group": "Build with AI", "pages": [ - "about/i18n", - "about/changelog", - "about/license" - ] - } - ] - }, - { - "tab": "Admin Guide", - "groups": [ - { - "group": "Guides", - "pages": [ - "admin-guide/guides/structure-projects", - "admin-guide/guides/manage-pieces", - "admin-guide/guides/sso", - { - "group": "SCIM", - "icon": "users", - "pages": [ - "admin-guide/guides/scim/overview", - "admin-guide/guides/scim/providers/okta", - "admin-guide/guides/scim/providers/microsoft-entra-id" - ] - }, - "admin-guide/guides/manage-oauth2", - "admin-guide/guides/setup-ai-providers", - { - "group": "Secret Managers", - "icon": "key", - "pages": [ - "admin-guide/guides/secret-managers/overview", - "admin-guide/guides/secret-managers/aws", - "admin-guide/guides/secret-managers/hashicorp", - "admin-guide/guides/secret-managers/cyberark-conjur", - "admin-guide/guides/secret-managers/onepassword" - ] - }, - "admin-guide/guides/permissions", - "admin-guide/guides/event-streaming", - "admin-guide/guides/project-releases", - "admin-guide/guides/project-replace-cli", - "admin-guide/guides/manage-concurrency" + "ai/chat", + "mcp/overview" ] }, { - "group": "Security", + "group": "About", "pages": [ - "admin-guide/security/practices", + "about/i18n", + "about/changelog", + "about/license", { - "group": "Audit Logs", + "group": "Company Handbook", "icon": "book", "pages": [ - "admin-guide/security/audit-logs/overview", - "admin-guide/security/audit-logs/flow-created", - "admin-guide/security/audit-logs/flow-updated", - "admin-guide/security/audit-logs/flow-deleted", - "admin-guide/security/audit-logs/flow-published", - "admin-guide/security/audit-logs/flow-activated", - "admin-guide/security/audit-logs/flow-deactivated", - "admin-guide/security/audit-logs/connection-upserted", - "admin-guide/security/audit-logs/connection-deleted", - "admin-guide/security/audit-logs/flow-run-started", - "admin-guide/security/audit-logs/flow-run-finished", - "admin-guide/security/audit-logs/folder-created", - "admin-guide/security/audit-logs/folder-updated", - "admin-guide/security/audit-logs/folder-deleted", - "admin-guide/security/audit-logs/user-signed-in", - "admin-guide/security/audit-logs/user-signed-up", - "admin-guide/security/audit-logs/user-email-verified", - "admin-guide/security/audit-logs/user-password-reset", - "admin-guide/security/audit-logs/signing-key-created" + { + "group": "Handbook", + "pages": [ + "handbook/overview", + "handbook/team" + ] + }, + { + "group": "Hiring", + "icon": "user", + "pages": [ + "handbook/hiring/hiring", + "handbook/hiring/levels", + "handbook/hiring/team", + "handbook/hiring/compensation" + ] + }, + { + "group": "Customer Support", + "icon": "hero", + "pages": [ + "handbook/customer-support/overview", + "handbook/customer-support/tone", + "handbook/customer-support/pylon", + "handbook/customer-support/handle-requests" + ] + }, + { + "group": "Engineering Onboarding", + "icon": "code", + "pages": [ + "handbook/engineering/overview", + "handbook/engineering/onboarding/onboarding-check-list", + "handbook/engineering/onboarding/how-we-work", + "handbook/engineering/onboarding/on-call", + "handbook/engineering/onboarding/downtime-incident", + "handbook/engineering/onboarding/stack", + "handbook/engineering/onboarding/release-cycle" + ] + }, + { + "group": "Engineering Playbooks", + "icon": "code", + "pages": [ + "handbook/engineering/playbooks/run-ee", + "handbook/engineering/playbooks/building-for-self-hosting", + "handbook/engineering/playbooks/setup-betterstack", + "handbook/engineering/playbooks/releases", + "handbook/engineering/playbooks/canary-deployment", + "handbook/engineering/playbooks/queue-metrics", + "handbook/engineering/playbooks/infrastructure", + "handbook/engineering/playbooks/database-migration", + "handbook/engineering/playbooks/structured-logging", + "handbook/engineering/playbooks/security-advisory-response", + "handbook/engineering/playbooks/product-announcement", + "handbook/engineering/playbooks/frontend-best-practices", + "handbook/engineering/playbooks/e2e-tests", + "handbook/engineering/playbooks/testing-strategy", + "handbook/engineering/playbooks/connect-claude-to-chrome", + "handbook/engineering/playbooks/ai-engineering-guide", + "handbook/engineering/playbooks/pr-review-sla", + { + "group": "Postmortems", + "icon": "triangle-exclamation", + "pages": [ + "handbook/engineering/postmortems/2026-03-19-redis-and-delay-overload", + "handbook/engineering/postmortems/2026-03-redis-queue-events-overload", + "handbook/engineering/postmortems/2026-03-16-infrastructure-upgrade" + ] + } + ] + }, + { + "group": "Product", + "icon": "tool", + "pages": [ + "handbook/product/interface-design" + ] + } ] } ] @@ -207,18 +240,19 @@ ] }, { - "tab": "Deploy", + "tab": "Install Activepieces", "groups": [ { - "group": "Get Started", + "group": "Install Activepieces", "pages": [ "install/overview", - "install/options/docker", "install/options/docker-compose", + "install/options/helm", + "install/configure-operate/enterprise-license", { "group": "Other Options", "pages": [ - "install/options/helm", + "install/options/docker", "install/options/easypanel", "install/options/aws", "install/options/gcp", @@ -229,7 +263,7 @@ ] }, { - "group": "Configure & Operate", + "group": "Self hosting", "pages": [ "install/configure-operate/production-setup", { @@ -238,7 +272,6 @@ "pages": [ "install/configure-operate/separate-workers", "install/configure-operate/sandboxing", - "install/configure-operate/enterprise-license", "install/configure-operate/setup-ssl", "install/configure-operate/setup-s3" ] @@ -301,27 +334,79 @@ ] }, { - "tab": "Embedding", + "tab": "Enterprise Control", + "icon": "crown", "groups": [ { - "group": "Essentials", + "group": "Overview", "pages": [ - "embedding/overview", - "embedding/configure-embedding", - "embedding/provision-users", - "embedding/embed-builder" + "admin-guide/overview" ] }, { - "group": "Misc", + "group": "Guides", "pages": [ - "embedding/customize-pieces", - "embedding/embed-connections", - "embedding/embeddable-mcp", - "embedding/navigation", - "embedding/predefined-connection", - "embedding/sdk-changelog", - "embedding/sdk-server-requests" + "admin-guide/guides/structure-projects", + "admin-guide/guides/manage-pieces", + "admin-guide/guides/sso", + { + "group": "SCIM", + "icon": "users", + "pages": [ + "admin-guide/guides/scim/overview", + "admin-guide/guides/scim/providers/okta", + "admin-guide/guides/scim/providers/microsoft-entra-id" + ] + }, + "admin-guide/guides/manage-oauth2", + "admin-guide/guides/setup-ai-providers", + { + "group": "Secret Managers", + "icon": "key", + "pages": [ + "admin-guide/guides/secret-managers/overview", + "admin-guide/guides/secret-managers/aws", + "admin-guide/guides/secret-managers/hashicorp", + "admin-guide/guides/secret-managers/cyberark-conjur", + "admin-guide/guides/secret-managers/onepassword" + ] + }, + "admin-guide/guides/permissions", + "admin-guide/guides/event-streaming", + "admin-guide/guides/project-releases", + "admin-guide/guides/project-replace-cli", + "admin-guide/guides/manage-concurrency" + ] + }, + { + "group": "Security", + "pages": [ + "admin-guide/security/practices", + { + "group": "Audit Logs", + "icon": "book", + "pages": [ + "admin-guide/security/audit-logs/overview", + "admin-guide/security/audit-logs/flow-created", + "admin-guide/security/audit-logs/flow-updated", + "admin-guide/security/audit-logs/flow-deleted", + "admin-guide/security/audit-logs/flow-published", + "admin-guide/security/audit-logs/flow-activated", + "admin-guide/security/audit-logs/flow-deactivated", + "admin-guide/security/audit-logs/connection-upserted", + "admin-guide/security/audit-logs/connection-deleted", + "admin-guide/security/audit-logs/flow-run-started", + "admin-guide/security/audit-logs/flow-run-finished", + "admin-guide/security/audit-logs/folder-created", + "admin-guide/security/audit-logs/folder-updated", + "admin-guide/security/audit-logs/folder-deleted", + "admin-guide/security/audit-logs/user-signed-in", + "admin-guide/security/audit-logs/user-signed-up", + "admin-guide/security/audit-logs/user-email-verified", + "admin-guide/security/audit-logs/user-password-reset", + "admin-guide/security/audit-logs/signing-key-created" + ] + } ] } ] @@ -398,6 +483,32 @@ } ] }, + { + "tab": "Embed SDK", + "groups": [ + { + "group": "Essentials", + "pages": [ + "embedding/overview", + "embedding/configure-embedding", + "embedding/provision-users", + "embedding/embed-builder" + ] + }, + { + "group": "Misc", + "pages": [ + "embedding/customize-pieces", + "embedding/embed-connections", + "embedding/embeddable-mcp", + "embedding/navigation", + "embedding/predefined-connection", + "embedding/sdk-changelog", + "embedding/sdk-server-requests" + ] + } + ] + }, { "tab": "API Reference", "groups": [ @@ -407,6 +518,13 @@ "endpoints/overview" ] }, + { + "group": "MCP Tools", + "pages": [ + "mcp/tools", + "mcp/tool-search" + ] + }, { "group": "Endpoints", "pages": [ @@ -564,90 +682,6 @@ ] } ] - }, - { - "tab": "Handbook", - "groups": [ - { - "group": "Handbook", - "pages": [ - "handbook/overview", - "handbook/team" - ] - }, - { - "group": "Hiring", - "icon": "user", - "pages": [ - "handbook/hiring/hiring", - "handbook/hiring/levels", - "handbook/hiring/team", - "handbook/hiring/compensation" - ] - }, - { - "group": "Customer Support", - "icon": "hero", - "pages": [ - "handbook/customer-support/overview", - "handbook/customer-support/tone", - "handbook/customer-support/pylon", - "handbook/customer-support/handle-requests" - ] - }, - { - "group": "Engineering Onboarding", - "icon": "code", - "pages": [ - "handbook/engineering/overview", - "handbook/engineering/onboarding/onboarding-check-list", - "handbook/engineering/onboarding/how-we-work", - "handbook/engineering/onboarding/on-call", - "handbook/engineering/onboarding/downtime-incident", - "handbook/engineering/onboarding/stack", - "handbook/engineering/onboarding/release-cycle" - ] - }, - { - "group": "Engineering Playbooks", - "icon": "code", - "pages": [ - "handbook/engineering/playbooks/run-ee", - "handbook/engineering/playbooks/building-for-self-hosting", - "handbook/engineering/playbooks/setup-betterstack", - "handbook/engineering/playbooks/releases", - "handbook/engineering/playbooks/canary-deployment", - "handbook/engineering/playbooks/queue-metrics", - "handbook/engineering/playbooks/infrastructure", - "handbook/engineering/playbooks/database-migration", - "handbook/engineering/playbooks/structured-logging", - "handbook/engineering/playbooks/security-advisory-response", - "handbook/engineering/playbooks/product-announcement", - "handbook/engineering/playbooks/frontend-best-practices", - "handbook/engineering/playbooks/e2e-tests", - "handbook/engineering/playbooks/testing-strategy", - "handbook/engineering/playbooks/connect-claude-to-chrome", - "handbook/engineering/playbooks/ai-engineering-guide", - "handbook/engineering/playbooks/pr-review-sla", - { - "group": "Postmortems", - "icon": "triangle-exclamation", - "pages": [ - "handbook/engineering/postmortems/2026-03-19-redis-and-delay-overload", - "handbook/engineering/postmortems/2026-03-redis-queue-events-overload", - "handbook/engineering/postmortems/2026-03-16-infrastructure-upgrade" - ] - } - ] - }, - { - "group": "Product", - "icon": "tool", - "pages": [ - "handbook/product/interface-design" - ] - } - ] } ], "global": {} @@ -669,4 +703,4 @@ "linkedin": "https://linkedin.com/company/activepieces" } } -} \ No newline at end of file +} diff --git a/docs/embedding/configure-embedding.mdx b/docs/embedding/configure-embedding.mdx index d89a73250dba..c8a37a8cd523 100644 --- a/docs/embedding/configure-embedding.mdx +++ b/docs/embedding/configure-embedding.mdx @@ -4,7 +4,7 @@ description: "Set up your platform before provisioning users" icon: "sliders" --- - + Before you provision users and generate signing keys, complete the embed onboarding in **Platform Settings → Security → Embedding**. diff --git a/docs/embedding/customize-pieces.mdx b/docs/embedding/customize-pieces.mdx index b2ac46d411af..fa3d123427a4 100644 --- a/docs/embedding/customize-pieces.mdx +++ b/docs/embedding/customize-pieces.mdx @@ -1,9 +1,9 @@ --- title: "Show/Hide Pieces" -description: "" +description: "Choose which pieces each customer can see" icon: "puzzle" --- - + If you would like to only show specific pieces to your embedding users, we recommend using **Piece Sets**. diff --git a/docs/embedding/embed-builder.mdx b/docs/embedding/embed-builder.mdx index 4373acf40a4f..7346caf55a93 100644 --- a/docs/embedding/embed-builder.mdx +++ b/docs/embedding/embed-builder.mdx @@ -1,10 +1,10 @@ --- title: "Embed Builder" -description: "" +description: "Render the Activepieces builder inside your application" icon: "wrench" --- - + This documentation explains how to embed the Activepieces iframe inside your application and customize it. diff --git a/docs/embedding/embeddable-mcp.mdx b/docs/embedding/embeddable-mcp.mdx index 011bd3b625bb..7ae541e08736 100644 --- a/docs/embedding/embeddable-mcp.mdx +++ b/docs/embedding/embeddable-mcp.mdx @@ -4,7 +4,7 @@ description: "Let your embedded users connect their automations to AI with one c icon: "plug" --- - + ## What it does diff --git a/docs/embedding/overview.mdx b/docs/embedding/overview.mdx index 07d528c52e0c..a81fd55507ce 100644 --- a/docs/embedding/overview.mdx +++ b/docs/embedding/overview.mdx @@ -4,7 +4,7 @@ description: 'Understanding how embedding works' icon: "cube" --- - + This section provides an overview of how to embed the Activepieces builder in your application and automatically provision the user. diff --git a/docs/embedding/provision-users.mdx b/docs/embedding/provision-users.mdx index 7e9be3418885..d24d70cc70e3 100644 --- a/docs/embedding/provision-users.mdx +++ b/docs/embedding/provision-users.mdx @@ -4,7 +4,7 @@ description: "Automatically authenticate your SaaS users to your Activepieces in icon: 'user' --- - + ## Overview diff --git a/docs/handbook/engineering/playbooks/run-ee.mdx b/docs/handbook/engineering/playbooks/run-ee.mdx index 3f599c715ec0..367d5a397bab 100644 --- a/docs/handbook/engineering/playbooks/run-ee.mdx +++ b/docs/handbook/engineering/playbooks/run-ee.mdx @@ -43,7 +43,7 @@ AP_JWT_SECRET=secret After signing in, activate the license key by going to **Platform Admin -> Setup -> License Keys** - ![Activation License Key](/resources/screenshots/activation-license-key-settings.png) + ![Activation License Key](/resources/screenshots/activation-license-key-settings.webp) \ No newline at end of file diff --git a/docs/install/configure-operate/enterprise-license.mdx b/docs/install/configure-operate/enterprise-license.mdx index 080f982dbe91..35520db81789 100644 --- a/docs/install/configure-operate/enterprise-license.mdx +++ b/docs/install/configure-operate/enterprise-license.mdx @@ -1,31 +1,56 @@ --- -title: "Enterprise License" -description: "Activate the optional self-hosted Enterprise Edition with a license key" +title: "Activate an Enterprise License Key" +sidebarTitle: "License key" +description: "Unlock paid features on your self-hosted instance" icon: "key" --- - -This page applies only to the **optional** Enterprise Edition. Community Edition is free, open source, and needs no license — if you're running CE, you can skip this. - +Activepieces runs on the free plan by default. Every plan on the [pricing page](https://www.activepieces.com/pricing) is available self-hosted. - -For licensing inquiries regarding the self-hosted enterprise edition, please reach out to `sales@activepieces.com`, as the code and Docker image are not covered by the MIT license. - + +**Upgrading to Plus or Team? You don't need this page.** Subscribe from inside the app and your plan applies automatically, with no key to enter. + -You can request a trial key from within the app or in the cloud by filling out the form. Alternatively, you can contact sales at [https://www.activepieces.com/sales](https://www.activepieces.com/sales).

Please know that when your trial runs out, all enterprise [features](https://www.activepieces.com/pricing) will be shut down meaning any user other than the platform admin will be deactivated, and your private pieces will be deleted, which could result in flows using them to fail.
+This page is for **custom and enterprise plans**. [Talk to us](https://www.activepieces.com/sales) and we'll send your license key once the agreement is in place. - -Before version 0.73.0, you cannot switch from CE to EE directly. We suggest upgrading to 0.73.0 with the same edition first, then switch `AP_EDITION`. - +## Features on paid plans - -Enterprise edition must use `PostgreSQL` as the database backend and `Redis` as the Queue System. - +Your key activates whichever of these your plan includes. -## Installation + + + - Projects and multi-tenancy + - SSO + - Standard and custom roles + - SCIM user provisioning + + + - Audit logs + - Secret managers + - Piece management + + + - Releases and Git Sync + - Worker groups + - Global connections + + + - Embeddable builder and JavaScript SDK + - Custom branding and domains + - Template management + + -1. Set the `AP_EDITION` environment variable to `ee`. -2. Set `AP_EXECUTION_MODE` to anything other than `UNSANDBOXED` (see [Sandboxing Mode](./sandboxing)). -3. Once your instance is up, activate the license key under **Platform Admin → Setup → License Keys**. +## How to activate a license key -![Activation License Key](/resources/screenshots/activation-license-key-settings.png) +Open **Platform Admin → Setup → Billing & subscription**, scroll to **Have a custom plan?**, and click **Activate license key**. Paste your key and save. No restart needed, and your flows and data stay as they are. + + + Activating a license key from Billing and subscription + + +## Billing page not showing + +The page appears when your instance runs the Enterprise edition. [Self host (Docker)](/install/options/docker-compose) and [Kubernetes](/install/options/helm) set that up for you. If you installed another way, follow either guide to switch. + +Installs on an embedded database, including [Hobby (Docker)](/install/options/docker), cannot run Enterprise at all. diff --git a/docs/install/options/docker-compose.mdx b/docs/install/options/docker-compose.mdx index 6df71316e586..db1bc1770c1c 100755 --- a/docs/install/options/docker-compose.mdx +++ b/docs/install/options/docker-compose.mdx @@ -1,122 +1,266 @@ --- -title: "Docker Compose" -description: "" -icon: "book" +title: "Self Host Activepieces" +sidebarTitle: "Self host (Docker)" +description: "Docker Compose with PostgreSQL and Redis" +icon: "docker" --- -To get up and running quickly with Activepieces, we will use the Activepieces Docker image. Follow these steps: +## Install with one command (recommended) -## Prerequisites +One line writes the config, generates your secrets, and starts everything. -You need to have [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) and [Docker](https://docs.docker.com/get-docker/) installed on your machine in order to set up Activepieces via Docker Compose. +Run it in the folder where you want Activepieces to live: -## Installing +```bash +curl -fsSL https://get.activepieces.com | sh +``` -**1. Clone Activepieces repository.** +Or hand it to your AI assistant: -Use the command line to clone Activepieces repository: + +Install Activepieces on this machine by running `curl -fsSL https://get.activepieces.com | sh` from a folder I choose. It needs Docker Compose v2, so check `docker compose version` first and tell me if it is missing. If port 8080 is already in use, re-run with `--port` and a free port. When it finishes, confirm the stack is healthy with `docker compose -p activepieces ps` and `curl http://localhost:8080/api/v1/health`, then tell me the URL to open and remind me to back up the generated `.env` file. + -```bash -git clone https://github.com/activepieces/activepieces.git -``` + +**Requirements** +- [Docker Compose v2](https://docs.docker.com/compose/install/). The old `docker-compose` will not work. +- At least 2 vCPU and 4 GB RAM. +- On Windows, WSL2. Run the command inside it. + + + +Use `--port ` if 8080 is taken, and `--dir ` to install somewhere other than `./activepieces`. Want to read it before running it? `curl -fsSL https://get.activepieces.com` prints the script. + + +## Alternative: run Docker Compose yourself + +Use this if you would rather not pipe a remote script into a shell, or your change process needs the compose file in front of it before anything starts. + +You get the same four containers, set up by hand. -**2. Go to the repository folder.** + +**1. Get the compose file** ```bash +git clone --depth 1 https://github.com/activepieces/activepieces.git cd activepieces ``` -**3.Generate Environment variable** - -Run the following command from the command prompt / terminal +**2. Generate your secrets** ```bash sh tools/deploy.sh ``` - -If none of the above methods work, you can rename the .env.example file in the root directory to .env and fill in the necessary information within the file. - +This copies `.env.example` to `.env` and fills in the passwords and keys. It needs `openssl`. If `openssl` is missing it will still report success while leaving the values blank, so check before you continue: + +```bash +grep -E '^(AP_ENCRYPTION_KEY|AP_JWT_SECRET|AP_POSTGRES_PASSWORD)=' .env +``` -**4. Run Activepieces.** +Every one of those must have a value. If any is empty, install `openssl` and run `sh tools/deploy.sh` again. - -Please note that "docker-compose" (with a dash) is an outdated version of Docker Compose and it will not work properly. We strongly recommend downloading and installing version 2 from the [here](https://docs.docker.com/compose/install/) to use Docker Compose. - +**3. Set the edition** + +Add this to `.env`. Without it you get the Community edition, and you will not be able to activate a license key later. ```bash -docker compose -p activepieces up +AP_EDITION=ee +AP_EXECUTION_MODE=SANDBOX_CODE_ONLY ``` -## 4. Configure Webhook URL (Important for Triggers, Optional If you have public IP) +`.env.example` ships `AP_EXECUTION_MODE=UNSANDBOXED`, which the server rejects at startup on `ee`. -**Note:** By default, Activepieces will try to use your public IP for webhooks. If you are self-hosting on a personal machine, you must configure the frontend URL so that the webhook is accessible from the internet. +**4. Point the worker at the app** -**Optional:** The easiest way to expose your webhook URL on localhost is by using a service like ngrok. However, it is not suitable for production use. +In `docker-compose.yml`, give the `worker` service its own `AP_FRONTEND_URL`: + +```yaml +worker: + environment: + - AP_CONTAINER_TYPE=WORKER + - AP_FRONTEND_URL=http://app +``` + +Both services share `.env`, where `AP_FRONTEND_URL` is your public URL. That address means "the app" to a browser but "myself" to the worker container, so without this override the worker cannot open its socket and the Workers page stays empty. + +**5. Start it** -1. Install ngrok -2. Run the following command: ```bash -ngrok http 8080 +docker compose -p activepieces up -d ``` -3. Replace `AP_FRONTEND_URL` environment variable in `.env` with the ngrok url. -![Ngrok](../../resources/screenshots/docker-ngrok.png) + +Two more things worth changing before production. The image tag in `docker-compose.yml` is pinned to a specific release, so bump it yourself when you upgrade. And `worker` is set to `replicas: 5`, which is more than a single small machine wants; see [Production Setup](/install/configure-operate/production-setup) for sizing. + + + + +## Open Activepieces + +Go to [http://localhost:8080](http://localhost:8080), or `http://:` if you installed on a remote server or changed the port. + +The first account you create becomes the platform administrator. There is no default username or password. -When deploying for production, ensure that you update the database credentials and properly set the environment variables. +Your secrets are written to `activepieces/.env`. Back that file up. -Review the [configurations guide](/install/reference/environment-variables) to make any necessary adjustments. +Without `AP_ENCRYPTION_KEY`, stored connections cannot be decrypted, even from a full database backup. -## Upgrading +## Check it's working + +```bash +docker compose -p activepieces ps +curl http://localhost:8080/api/v1/health +``` + +All four containers should be `Up`, and the health endpoint should respond. + +Then sign in and open **Platform Admin → Infrastructure → Workers**. You should see at least one worker. If the list is empty, see [Troubleshooting](#troubleshooting). + +## What you've just set up + +Four containers, defined in `activepieces/docker-compose.yml`: + +| Container | Role | +|---|---| +| `app` | API and UI, served on port 8080 | +| `worker` | Runs your flows | +| `postgres` | Flows, runs, and connections | +| `redis` | Job queue | + +**Your data is not in the `activepieces` folder.** It lives in the `postgres_data` Docker volume, so backing up the folder does not back up your flows. + +## Activate a license key (optional) + +Your install runs on the free plan by default. -To upgrade to new versions, which are installed using docker compose, perform the following steps. First, open a terminal in the activepieces repository directory and run the following commands. +If you have a trial or paid license key, activate it to unlock the paid features. See [License key](/install/configure-operate/enterprise-license). -### Automatic Pull +## Make webhooks reachable (optional) -**1. Run the update script** +Skip this if your server already has a public URL. + +Webhooks and app triggers need an address that third parties can reach. On a personal machine, expose it with a tunnel such as ngrok: ```bash -sh tools/update.sh +ngrok http 8080 ``` -### Manually Pull +Then set `AP_FRONTEND_URL` in `.env` to the ngrok URL and restart. + + + Copying the public URL from ngrok + + + +ngrok is fine for testing but not suitable for production. In production, point `AP_FRONTEND_URL` at your real domain. + + +## Upgrade + +Back up first: -**1. Pull the new docker compose file** ```bash -git pull +docker compose -p activepieces exec postgres pg_dump -U postgres activepieces > backup.sql ``` -**2. Pull the new images** +Then upgrade: + ```bash -docker compose pull +curl -fsSL https://get.activepieces.com | sh -s -- --upgrade ``` -**3. Review changelog for breaking changes** +Your `.env`, your data, and any edits to `docker-compose.yml` are left alone. -Please review breaking changes in the [changelog](../reference/breaking-changes). +Review [breaking changes](/install/reference/breaking-changes) before upgrading. -**4. Run the updated docker images** + +The version is pinned in `AP_VERSION` inside `.env`. Set it yourself and re-run the upgrade to move to a specific release. + + +## Uninstall + +Stop Activepieces and keep your data: + +```bash +curl -fsSL https://get.activepieces.com | sh -s -- --uninstall ``` -docker compose up -d --remove-orphans + +Stop it and delete everything, including the database: + +```bash +curl -fsSL https://get.activepieces.com | sh -s -- --uninstall --purge ``` -Congratulations! You have now successfully updated the version. +## Troubleshooting + + + +Check which Docker Compose you have: + +```bash +docker compose version +``` -## Deleting +If that errors, you are on Compose v1. The old `docker-compose` will not work with this setup. Install [Docker Compose v2](https://docs.docker.com/compose/install/). + -The following command is capable of deleting all Docker containers and associated data, and therefore should be used with caution: + +Your worker cannot reach the app. Check its logs: +```bash +docker compose -p activepieces logs worker | grep -i socket ``` -sh tools/reset.sh + +Repeated `Socket.IO connection error` means `AP_FRONTEND_URL` on the **worker** points at an address that does not resolve from inside the container. `localhost` refers to the worker itself, not the app. It must be the app's service name on the Docker network: + +```yaml +worker: + environment: + - AP_FRONTEND_URL=http://app ``` - -Executing this command will result in the removal of all Docker containers and the data stored within them. It is important to be aware of the potentially hazardous nature of this command before proceeding. - +The app's own `AP_FRONTEND_URL` should stay as your public URL. See [Websocket Issues](/install/troubleshooting/websocket-issues). + + + +Install on another port: + +```bash +curl -fsSL https://get.activepieces.com | sh -s -- --port 8090 +``` + +On an existing install, change `AP_HOST_PORT` in `.env` and run `docker compose -p activepieces up -d`. + +Do not set `AP_PORT`. That is the app's own listen port inside the container, and changing it breaks the port mapping. + + + +Read the startup error: + +```bash +docker compose -p activepieces logs app | grep -i "failed to start" +``` + +A common cause is `AP_EXECUTION_MODE=UNSANDBOXED` with `AP_EDITION=ee`, which is rejected at startup. Use `SANDBOX_CODE_ONLY` instead. See [Sandboxing Mode](/install/configure-operate/sandboxing). + + + +```bash +docker compose -p activepieces logs -f app +docker compose -p activepieces logs -f worker +``` + + +## Going to production + +Read [Production Setup](/install/configure-operate/production-setup). It's the one opinionated production shape, and every sizing choice flows from a single number. +For the full list of settings, see [Environment Variables](/install/reference/environment-variables). + diff --git a/docs/install/options/docker.mdx b/docs/install/options/docker.mdx index 343bf19d8bca..a4e319cbd286 100755 --- a/docs/install/options/docker.mdx +++ b/docs/install/options/docker.mdx @@ -1,86 +1,105 @@ --- -title: "Docker" -description: "Single docker image deployment with PGLite and Memory Queue" -icon: "docker" +title: "Hobbyist Installation" +sidebarTitle: "Hobby (Docker)" +description: "Single container with an embedded database, for personal use on one machine" --- - -This setup is only meant for personal use or testing. It runs on [PGLite](https://pglite.dev/) (embedded PostgreSQL) and an in-memory Redis queue, which supports only a single instance on a single machine. For production or multi-instance setups, you must use Docker Compose with PostgreSQL and Redis. You will not be able to upgrade from Community Edition to Enterprise out of the box, as Enterprise does not support PGLite. - - -To get up and running quickly with Activepieces, we will use the Activepieces Docker image. Follow these steps: + +Best for personal projects on one machine. It runs on an embedded database, so it cannot scale, cannot move to Enterprise, and moving off it means starting over. -## Prerequisites - -You need to have [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) and [Docker](https://docs.docker.com/get-docker/) installed on your machine in order to set up Activepieces via Docker Compose. +Unless you're happy with that, use [Self hosting](/install/options/docker-compose) instead. + ## Install -### Pull Image and Run Docker image - -Pull the Activepieces Docker image and run the container with the following command: +Run this anywhere: ```bash -docker run -d -p 8080:80 -v ~/.activepieces:/root/.activepieces -e AP_REDIS_TYPE=MEMORY -e AP_DB_TYPE=PGLITE -e AP_FRONTEND_URL="http://localhost:8080" activepieces/activepieces:latest +docker run -d --name activepieces -p 8080:80 \ + -v ~/.activepieces:/root/.activepieces \ + -e AP_DB_TYPE=PGLITE \ + -e AP_REDIS_TYPE=MEMORY \ + -e AP_FRONTEND_URL="http://localhost:8080" \ + activepieces/activepieces:latest ``` -### Configure Webhook URL (Important for Triggers, Optional If you have public IP) +Or hand it to your AI assistant: + + +Install Activepieces on this machine for personal use with a single Docker container. Run `docker run -d --name activepieces -p 8080:80 -v ~/.activepieces:/root/.activepieces -e AP_DB_TYPE=PGLITE -e AP_REDIS_TYPE=MEMORY -e AP_FRONTEND_URL="http://localhost:8080" activepieces/activepieces:latest`. If port 8080 is already in use, pick a free port and change both the `-p` mapping and `AP_FRONTEND_URL` to match. When it finishes, wait until `curl http://localhost:8080/api/v1/health` responds, then tell me the URL to open. + + + +**Requirements** +- [Docker](https://docs.docker.com/get-docker/), and nothing else. + + +Everything runs in one container with the database embedded, so there is no PostgreSQL or Redis to set up. Your data lives in `~/.activepieces` on your machine. + +## Open Activepieces + +Go to [http://localhost:8080](http://localhost:8080), or `http://:` if you installed on a remote server or changed the port. + +The first account you create becomes the administrator. There is no default username or password. + +## Make webhooks reachable (optional) -**Note:** By default, Activepieces will try to use your public IP for webhooks. If you are self-hosting on a personal machine, you must configure the frontend URL so that the webhook is accessible from the internet. +Skip this if your machine already has a public URL. -**Optional:** The easiest way to expose your webhook URL on localhost is by using a service like ngrok. However, it is not suitable for production use. +Webhooks and app triggers need an address that third parties can reach. On a personal machine, expose it with a tunnel such as ngrok: -1. Install ngrok -2. Run the following command: ```bash ngrok http 8080 ``` -3. Replace `AP_FRONTEND_URL` environment variable in the command line above. -![Ngrok](../../resources/screenshots/docker-ngrok.png) +Then set `AP_FRONTEND_URL` to the ngrok URL and run the container again. + + Copying the public URL from ngrok + + +ngrok is fine for testing but not suitable for production. In production, point `AP_FRONTEND_URL` at your real domain. + -## Upgrade +## Upgrade -Please follow the steps below: +Back up first, since the container is replaced: -### Step 1: Back Up Your Data (Recommended) - -Before proceeding with the upgrade, it is always a good practice to back up your Activepieces data to avoid any potential data loss during the update process. - -1. **Stop the Current Activepieces Container:** If your Activepieces container is running, stop it using the following command: - ```bash - docker stop activepieces_container_name - ``` +```bash +docker stop activepieces +cp -r ~/.activepieces ~/.activepieces-backup +``` -2. **Backup Activepieces Data Directory:** By default, Activepieces data is stored in the `~/.activepieces` directory on your host machine. Create a backup of this directory to a safe location using the following command: - ```bash - cp -r ~/.activepieces ~/.activepieces_backup - ``` +Then pull the new image and start a fresh container with the same command you used to install: -### Step 2: Update the Docker Image +```bash +docker rm activepieces +docker pull activepieces/activepieces:latest +``` -1. **Pull the Latest Activepieces Docker Image:** Run the following command to pull the latest Activepieces Docker image from Docker Hub: - ```bash - docker pull activepieces/activepieces:latest - ``` +Your data survives because it lives in `~/.activepieces`, not in the container. -### Step 3: Remove the Existing Activepieces Container +## Uninstall -1. **Stop and Remove the Current Activepieces Container:** If your Activepieces container is running, stop and remove it using the following commands: - ```bash - docker stop activepieces_container_name - docker rm activepieces_container_name - ``` +Remove the container and keep your data: -### Step 4: Run the Updated Activepieces Container +```bash +docker rm -f activepieces +``` -Now, run the updated Activepieces container with the latest image using the same command you used during the initial setup. Be sure to replace `activepieces_container_name` with the desired name for your new container. +Delete your data too: ```bash -docker run -d -p 8080:80 -v ~/.activepieces:/root/.activepieces -e AP_REDIS_TYPE=MEMORY -e AP_DB_TYPE=PGLITE -e AP_FRONTEND_URL="http://localhost:8080" --name activepieces_container_name activepieces/activepieces:latest +rm -rf ~/.activepieces ``` +## Good to know + + +This setup cannot be upgraded to Enterprise. Enterprise does not support the embedded database, so moving to it means starting over. If that is a possibility, start with [Self hosting](/install/options/docker-compose). + -Congratulations! You have successfully upgraded your Activepieces Docker deployment + +It runs on [PGLite](https://pglite.dev/) (embedded PostgreSQL) and an in-memory queue, which supports a single instance on a single machine. For production or multi-instance setups, use [Self hosting](/install/options/docker-compose) with PostgreSQL and Redis. + diff --git a/docs/install/options/helm.mdx b/docs/install/options/helm.mdx index 3e58fa7684b2..2e363b919282 100644 --- a/docs/install/options/helm.mdx +++ b/docs/install/options/helm.mdx @@ -1,6 +1,8 @@ --- -title: 'Helm' -description: 'Deploy Activepieces on Kubernetes using Helm' +title: 'Kubernetes Installation' +sidebarTitle: 'Kubernetes (Helm)' +icon: 'ship' +description: 'Deploy Activepieces on Kubernetes using the official Helm chart' --- This guide walks you through deploying Activepieces on Kubernetes using the official Helm chart. @@ -111,7 +113,21 @@ helm dependency update ### 3. Create a Values File Create a `my-values.yaml` file with your configuration. You can use the [example values file](https://github.com/activepieces/activepieces/blob/main/deploy/activepieces-helm/values.yaml) as a reference. -The Helm chart has sensible defaults for required values while leaving the optional ones empty, but you should customize these core values for production +The Helm chart has sensible defaults for required values while leaving the optional ones empty, but you should customize these core values for production. + +Include these so you can activate a [license key](/install/configure-operate/enterprise-license) later: + +```yaml +activepiecesConfig: + AP_EDITION: "ee" + AP_EXECUTION_MODE: "SANDBOX_CODE_ONLY" +``` + + +Set both, and set each variable in **one place only**. `activepiecesConfig` writes plain values into the pod, while `activepiecesEnvVariables` pulls the same names from Kubernetes secrets you create yourself and renders *after* it, so a secret that holds the key wins. The chart lists `AP_EDITION` and `AP_EXECUTION_MODE` under `activepieces-config-secrets` by default, so either remove them from there or set them in that secret instead of here. + +`AP_EDITION=ee` with the default execution mode is rejected at startup and the app will not boot. + ### 4. Install Activepieces @@ -175,26 +191,9 @@ kubectl port-forward svc/activepieces 4200:80 --namespace default kubectl get all --namespace default ``` -## Editions - -Activepieces supports three editions: - -- **`ce` (Community Edition)**: Open-source version with all core features (default) -- **`ee` (Enterprise Edition)**: Self-hosted edition with advanced features like SSO, RBAC, and audit logs -- **`cloud`**: For Activepieces Cloud deployments - -Set the edition in your values file: - -```yaml -activepieces: - edition: "ce" # or "ee" for Enterprise Edition -``` - -For Enterprise Edition features and licensing, visit [activepieces.com](https://www.activepieces.com/docs/admin-console/overview). - ## Environment Variables -For a complete list of configuration options, see the [Environment Variables](/install/reference/environment-variables) documentation. Most environment variables can be configured through the Helm values file under the `activepieces` section. +For a complete list of configuration options, see the [Environment Variables](/install/reference/environment-variables) documentation. Most environment variables can be configured through the Helm values file, either as plain values under `activepiecesConfig` or injected from your own secrets under `activepiecesEnvVariables`. ## Execution Modes diff --git a/docs/install/overview.mdx b/docs/install/overview.mdx index ef3716db4182..9fa7b8ccc85e 100755 --- a/docs/install/overview.mdx +++ b/docs/install/overview.mdx @@ -1,46 +1,65 @@ --- -title: "Overview" +title: "Install Options" +sidebarTitle: "Install options" icon: "hand-wave" description: "Introduction to the different ways to install Activepieces" --- -Activepieces Community Edition can be deployed using **Docker**, **Docker Compose**, and **Kubernetes**. +## Quick install - -Community Edition is **free** and **open source**. +Run this where you want Activepieces to live. When it finishes, follow [Self host (Docker)](./options/docker-compose) for the next steps: opening Activepieces, activating a license key, upgrading, and fixing problems. -You can read the difference between the editions [here](https://www.activepieces.com/pricing). - +```bash +curl -fsSL https://get.activepieces.com | sh +``` - -**Going to production?** Pick an install method below to get running, then read [Production Setup](./configure-operate/production-setup) — it's the one opinionated production shape, and every sizing choice flows from a single number. - + +Install Activepieces on this machine by running `curl -fsSL https://get.activepieces.com | sh` from a folder I choose. It needs Docker Compose v2, so check `docker compose version` first and tell me if it is missing. If port 8080 is already in use, re-run with `--port` and a free port. When it finishes, confirm the stack is healthy with `docker compose -p activepieces ps` and `curl http://localhost:8080/api/v1/health`, then tell me the URL to open and remind me to back up the generated `.env` file. + -## Recommended Options +--- + +## All options - -Deploy Activepieces as a single Docker container using the PGLite database. + + **Recommended.** Docker Compose with PostgreSQL and Redis. - - Deploy Activepieces with **Redis** and **PostgreSQL** setup. + + Helm chart. Only if you already run a cluster. -## Other Options + + + **Fastest.** Start free. We host and manage it for you. + + + +## Other ways to deploy + +Provider templates and one-click hosts. Maintained outside the core team, so versions can lag behind, and they install the free Community edition. - - Install on Kubernetes with Helm. + + Provisioned with Pulumi. + + + + Deployed as a VM template. } href="./options/railway"> - 1-Click Install on Railway. + Deploy from a template. + + + + Managed hosting with one-click install. } href="./options/easypanel"> - 1-Click Install with Easypanel template, maintained by the community. - - - - 1-Click Install on Elestio. - - - - Install on AWS with Pulumi. - - - - Install on GCP as a VM template. + Community-maintained template. } href="https://www.pikapods.com/pods?run=activepieces"> - Instantly run on PikaPods from $2.9/month. + Hosted pods from $2.9/month. - Easily install on RepoCloud using this template, maintained by the community. + Community-maintained template. } href="https://zeabur.com/templates/LNTQDF"> - 1-Click Install on Zeabur. + Deploy from a template. - -## Cloud Edition - - - - This is the fastest option. - - diff --git a/docs/install/reference/breaking-changes.mdx b/docs/install/reference/breaking-changes.mdx index 265759460207..0a566df3436b 100644 --- a/docs/install/reference/breaking-changes.mdx +++ b/docs/install/reference/breaking-changes.mdx @@ -343,6 +343,7 @@ When creating a project now, the platform owner email doesn't automatically get ### Do you need to take action? - If you are currently using MCP, review the linked announcement for important migration details and upgrade guidance. +- If you are below 0.73.0 and plan to switch `AP_EDITION` from `ce` to `ee`, upgrade to 0.73.0 on your current edition first, then switch. Edition-gated migrations are recorded as run without executing, so switching earlier leaves the schema incomplete. ## 0.71.0 diff --git a/docs/overview/welcome.mdx b/docs/overview/welcome.mdx index a854ce71ed88..7c2355bb4437 100644 --- a/docs/overview/welcome.mdx +++ b/docs/overview/welcome.mdx @@ -1,66 +1,117 @@ --- sidebarTitle: "Welcome" -title: "Welcome" -icon: 'hand-wave' -description: "Your friendliest open source all-in-one automation tool, designed to be extensible." +title: "Welcome to Activepieces" +icon: "hand-wave" +description: "AI automation for teams, with governance and security built in" --- - +Describe what you want in chat, and Activepieces builds the agent or automation for you. Bring your own AI keys, run it on your own infrastructure, and keep the governance your company needs. - - Learn how to work with Activepieces + + Nothing to set up or maintain. + + + One command on your own infrastructure. - - Browse available pieces + + +## The building blocks + +Mix them however you like. An agent can call a flow, a flow can run an agent, and both can read and write the same tables. + + + + AI that does tasks using your apps. - - Learn how to install Activepieces + + A trigger, then the steps you choose. - - How to Build Pieces and Contribute + + Your data, in rows and columns. +Works where your team works. **760+ apps** and counting. -# 🔥 Why Activepieces is Different: +
SlackGmailGoogle SheetsNotionHubSpotSalesforceOpenAIAirtableGitHub
-- **💖 Loved by Everyone**: Intuitive interface and great experience for both technical and non-technical users with a quick learning curve. - +
StripeShopifyDiscordMicrosoft TeamsGoogle CalendarGoogle DriveLinearAsanaIntercom
-![](/resources/templates.gif) -- **🌐 Open Ecosystem:** All pieces are open source and available on npmjs.com, **60% of the pieces are contributed by the community**. +## Build with AI -- **🛠️ Pieces are written in Typescript**: Pieces are npm packages in TypeScript, offering full customization with the best developer experience, including **hot reloading** for **local** piece development on your machine. 😎 + + + Describe the outcome and Chat builds the flow. **Beta** + + + Build from your own AI client. + + +Connect from any MCP client, including: -![](/resources/create-action.png) +
+ + + Claude + + + + Copilot + + + + Cursor + + + + Gemini CLI + + + + Windsurf + + + + Zed + +
+## Maximum control and security + +Give the business AI automation without losing the ability to answer for it. Decide who can build, which apps they can reach, which models see your data, and where credentials live. + + + + Your providers, your models, your budget. + + + Bring your vault. Credentials never sit in the product. + + + SSO and SCIM, so access follows your identity provider. + + + Projects, roles, and permissions. + + + Decide which apps your organization can use at all. + + + Audit logs, streamable to your SIEM. + + -- **🤖 AI-Ready**: Native AI pieces and agents are built into Activepieces. Integrating AI into your flows is seamless and simple—experiment with popular providers, or quickly create custom agents using our easy-to-use AI SDK. +## Embed Activepieces in your product -- **🏢 Enterprise-Ready**: Developers set up the tools, and anyone in the organization can use the no-code builder. Full customization from branding to control. +Your customers get the whole builder inside your app, under your brand: flows, tables, connections, and run history. -- **🔒 Secure by Design**: Self-hosted and network-gapped for maximum security and control over your data. + +Embedding is available on our enterprise plan. [Talk to sales](https://www.activepieces.com/sales) and we'll help you scope it. + -- **🧠 Human in Loop**: Delay execution for a period of time or require approval. These are just pieces built on top of the piece framework, and you can build many pieces like that. 🎨 +- **[Embed the builder](/embedding/embed-builder):** Runs inside your app with your logo and colors. +- **[Provision users](/embedding/provision-users):** Your users are created from a token, so they never sign in twice. +- **[Preset connections](/embedding/predefined-connection):** Set up a customer's credentials before they open the builder. +- **[Customize pieces](/embedding/customize-pieces):** Choose which apps each customer can see. diff --git a/docs/resources/screenshots/activation-license-key-settings.png b/docs/resources/screenshots/activation-license-key-settings.png deleted file mode 100644 index 508ff3282b65..000000000000 Binary files a/docs/resources/screenshots/activation-license-key-settings.png and /dev/null differ diff --git a/docs/resources/screenshots/activation-license-key-settings.webp b/docs/resources/screenshots/activation-license-key-settings.webp new file mode 100644 index 000000000000..9d8e6902bb31 Binary files /dev/null and b/docs/resources/screenshots/activation-license-key-settings.webp differ diff --git a/docs/resources/screenshots/agents-list.png b/docs/resources/screenshots/agents-list.png new file mode 100644 index 000000000000..b8236f8d8da9 Binary files /dev/null and b/docs/resources/screenshots/agents-list.png differ diff --git a/docs/tables/fields.mdx b/docs/tables/fields.mdx new file mode 100644 index 000000000000..d6b2b9571946 --- /dev/null +++ b/docs/tables/fields.mdx @@ -0,0 +1,38 @@ +--- +title: "Fields and records" +sidebarTitle: "Fields and records" +description: "The building blocks of a table" +icon: "table-columns" +--- + +A table is a set of **fields**, and each row of data is a **record**. + +## Field types + +| Type | Use it for | Why not just text | +|---|---|---| +| **Text** | Names, notes, IDs, anything freeform | | +| **Number** | Amounts, counts, scores | So you can sort and compare properly | +| **Date** | Birthdays, due dates, calendar days | So "before" and "after" mean something | +| **Date & Time** | Timestamps, appointments | So the time of day survives | +| **Dropdown** | A fixed set of options | So values stay consistent and typos can't creep in | + + +Reach for **Dropdown** whenever a field has a known set of values, such as status or priority. It's the difference between a table you can filter and one full of "In Progress", "in progress" and "InProgress". + + +## Adding records + +Three ways in: + +- **By hand**, typing straight into the table +- **From a flow**, using the Tables piece to create or update records +- **From a CSV**, see [Import and export](/tables/import-export) + +## Editing the table itself + +You can rename a table, add and delete fields, delete records, or clear a table entirely. + + +Deleting a field removes its data from every record in the table. There's no undo. + diff --git a/docs/tables/import-export.mdx b/docs/tables/import-export.mdx new file mode 100644 index 000000000000..697b9b971586 --- /dev/null +++ b/docs/tables/import-export.mdx @@ -0,0 +1,25 @@ +--- +title: "Import and export" +sidebarTitle: "Import and export" +description: "Move data in and out with CSV" +icon: "file-csv" +--- + +## Importing a CSV + +Import a CSV straight into a table. If you're unsure of the expected shape, **export a template** first and fill it in, which avoids most import failures. + + +If a CSV can't be parsed, the import fails rather than importing part of it. Check that headers match your fields and that the file is genuinely comma-separated. + +A table holds [10,000 records](/install/reference/environment-variables) by default. A file with more rows than the table has room left for still imports, but only up to that limit and the remaining rows are dropped without an error, so check the record count afterwards. + + +## Exporting + +- **Download data** from the table, for a spreadsheet or a backup +- **Download table data** as a step inside a flow, when you want it on a schedule + +## Backups + +Table data lives in your Activepieces database. If you self-host, it's covered by your normal database backup, see [Self host](/install/options/docker-compose). A periodic CSV export is a cheap extra copy for the tables you'd hate to lose. diff --git a/docs/tables/in-flows.mdx b/docs/tables/in-flows.mdx new file mode 100644 index 000000000000..6b7ac52806ee --- /dev/null +++ b/docs/tables/in-flows.mdx @@ -0,0 +1,43 @@ +--- +title: "Using tables in flows" +sidebarTitle: "Use in flows" +description: "Read, write, and react to your data" +icon: "sitemap" +--- + +The Tables piece gives flows full access to your data, both as steps and as triggers. + +## Reading + +- **Find Records** — search a table for the rows you want +- **Get Record** — fetch one by ID + +## Writing + +- **Create Record(s)** — add one or many +- **Update Record** — change an existing row + +## Removing + +- **Delete Record(s)** — remove one or many rows +- **Clear Table** — empty it while keeping the fields + +## Managing + +- **Create Table**, **Delete Table**, **Download Table** + +## Starting a flow when data changes + +Tables can also **trigger** a flow, which is usually the more interesting half: + +| Trigger | Fires when | +|---|---| +| **New Record Created** | A row is added | +| **Record Updated** | A row changes | +| **Record Deleted** | A row is removed | + +That means you can react to data instead of polling for it. Something writes a lead into a table, and enrichment starts on its own. + + +Splitting work across a table like this keeps each flow small. One flow collects, one reacts. Either can fail and be retried without redoing the other. + diff --git a/docs/tables/overview.mdx b/docs/tables/overview.mdx new file mode 100644 index 000000000000..2c51df8b88c6 --- /dev/null +++ b/docs/tables/overview.mdx @@ -0,0 +1,35 @@ +--- +title: "Tables" +sidebarTitle: "Overview" +description: "Store data inside Activepieces and use it from flows and agents" +icon: "table" +--- + +Tables hold structured data inside Activepieces, so a flow has somewhere to put results and an agent has something to look things up in. No external database to run. + +## Why they exist + +Automations usually need somewhere to remember things: which leads you already contacted, which invoices are unpaid, what the agent decided last week. Without that, every flow starts from nothing. + + + + Five field types, and how data gets in. + + + Read, write, and start flows when data changes. + + + Turn a table into an agent's knowledge base. + + + Bring a CSV in, take your data out. + + + +## A shape that comes up often + +1. A flow collects something, such as inbound leads, and **writes a record** +2. A **new record** trigger starts a second flow to enrich or route it +3. An **agent** uses the same table as a knowledge base when answering questions about it + +The table is what lets those three share state. diff --git a/docs/tables/with-agents.mdx b/docs/tables/with-agents.mdx new file mode 100644 index 000000000000..0c3ff253f1cf --- /dev/null +++ b/docs/tables/with-agents.mdx @@ -0,0 +1,31 @@ +--- +title: "Using tables with agents" +sidebarTitle: "Use with agents" +description: "Give an agent your data to look things up in" +icon: "robot" +--- + +A table can be attached to an [agent](/agents/overview) as a **knowledge base**, letting it look things up before deciding what to do. + +## Why not just put it in the instructions + +You could paste your product list into an agent's instructions. It works until the list changes, and then you have two versions of the truth and one of them is wrong. + +A table stays live. Update a row and the agent's next answer reflects it, with no edit to the agent. + +## Good candidates + +- Product or pricing data +- Customer or account records +- Policies, rules, approved wording +- Anything a person would look up rather than memorise + +## Adding one + +In the agent editor, find the **Knowledge Base** section and choose **Add Table Source**. Files work the same way through **Add File Source**, for content that isn't already in a table. The section only shows when [knowledge is available](/agents/knowledge) for your database and model provider. + +## Instructions still matter + +A knowledge base tells the agent *what it can look up*, not *when to*. Say so in the [instructions](/agents/create): + +> Before answering pricing questions, check the pricing table. Never quote a figure that isn't in it. diff --git a/packages/core/execution/package.json b/packages/core/execution/package.json index f070ef06aa7b..0a0ba48b19bd 100644 --- a/packages/core/execution/package.json +++ b/packages/core/execution/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/core-execution", - "version": "0.14.0", + "version": "0.15.0", "type": "commonjs", "main": "./dist/src/index.js", "scripts": { diff --git a/packages/core/execution/src/lib/workers/job-data.ts b/packages/core/execution/src/lib/workers/job-data.ts index e9695f3465f3..0ba6c24f4431 100644 --- a/packages/core/execution/src/lib/workers/job-data.ts +++ b/packages/core/execution/src/lib/workers/job-data.ts @@ -301,6 +301,7 @@ export enum AgentRunSource { CHAT = 'CHAT', FLOW_STEP = 'FLOW_STEP', AGENT = 'AGENT', + AGENT_BUILDER = 'AGENT_BUILDER', } export const AgentPromptOverride = z.object({ diff --git a/packages/core/shared/package.json b/packages/core/shared/package.json index 362069b51786..3fdacb919e3d 100644 --- a/packages/core/shared/package.json +++ b/packages/core/shared/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/shared", - "version": "0.143.0", + "version": "0.146.0", "type": "commonjs", "sideEffects": false, "main": "./dist/src/index.js", diff --git a/packages/core/shared/src/lib/ee/agent/agent.ts b/packages/core/shared/src/lib/ee/agent/agent.ts index 09d393c17f29..3add889daeb6 100644 --- a/packages/core/shared/src/lib/ee/agent/agent.ts +++ b/packages/core/shared/src/lib/ee/agent/agent.ts @@ -5,6 +5,7 @@ import { formErrors } from '../../form-errors' import { ColorName } from '../../management/project/project' const MAX_AGENT_TEXT_LENGTH = 51_200 +const MAX_SUGGESTED_AGENT_TOOLS = 4 const MAX_AGENT_TOOLS = 100 const MAX_AGENT_OUTPUT_FIELDS = 50 const MAX_AGENT_STEP_BUDGET = 1_000 @@ -86,7 +87,7 @@ const CreateAgentRequest = z.object({ const UpdateAgentRequest = CreateAgentRequest.omit({ projectId: true }).partial() -const DraftAgentResponse = z.object({ +const AgentDraftFields = z.object({ displayName: z.string().min(1, formErrors.required).max(MAX_AGENT_NAME_LENGTH), description: z.string().max(MAX_AGENT_NAME_LENGTH), icon: z.enum(AgentIcon).catch(AgentIcon.BOT), @@ -94,7 +95,13 @@ const DraftAgentResponse = z.object({ instructions: z.string().min(1, formErrors.required).max(MAX_AGENT_TEXT_LENGTH), }) -const AgentTemplate = DraftAgentResponse.extend({ id: z.string() }) +const DraftAgentResponse = AgentDraftFields.extend({ + tools: z.array(AgentTool).max(MAX_SUGGESTED_AGENT_TOOLS), + provider: Nullable(z.enum(AIProviderName)), + modelName: Nullable(z.string().max(MAX_AGENT_NAME_LENGTH)), +}) + +const AgentTemplate = AgentDraftFields.extend({ id: z.string() }) const DraftAgentRequest = z.object({ projectId: ApId, @@ -122,6 +129,7 @@ export { CreateAgentRequest, DEFAULT_AGENT_MAX_STEPS, DraftAgentRequest, + AgentDraftFields, DraftAgentResponse, ListAgentsRequest, MAX_AGENT_OUTPUT_FIELDS, @@ -134,6 +142,7 @@ export { MAX_AGENT_STEP_BUDGET, MAX_AGENT_TEXT_LENGTH, MAX_AGENT_TOOLS, + MAX_SUGGESTED_AGENT_TOOLS, UpdateAgentRequest, } @@ -143,6 +152,7 @@ export type AgentConfig = z.infer export type AgentTemplate = z.infer export type CreateAgentRequest = z.infer export type DraftAgentRequest = z.infer +export type AgentDraftFields = z.infer export type DraftAgentResponse = z.infer export type ListAgentsRequest = z.infer export type UpdateAgentRequest = z.infer diff --git a/packages/core/shared/src/lib/ee/agent/index.ts b/packages/core/shared/src/lib/ee/agent/index.ts index 7fe292c77324..c58d7686f993 100644 --- a/packages/core/shared/src/lib/ee/agent/index.ts +++ b/packages/core/shared/src/lib/ee/agent/index.ts @@ -219,6 +219,8 @@ export const CreateAgentConversationRequest = z.object({ title: z.optional(Nullable(z.string())), modelName: z.optional(Nullable(z.string())), agentId: z.optional(z.string()), + builder: z.optional(z.boolean()), + projectId: z.optional(z.string()), }) export type CreateAgentConversationRequest = z.infer diff --git a/packages/core/shared/src/lib/ee/audit-events/index.ts b/packages/core/shared/src/lib/ee/audit-events/index.ts index e7ad0fc19aa4..1b7bd2b04de8 100644 --- a/packages/core/shared/src/lib/ee/audit-events/index.ts +++ b/packages/core/shared/src/lib/ee/audit-events/index.ts @@ -22,6 +22,8 @@ export enum ApplicationEventName { FLOW_CREATED = 'flow.created', FLOW_DELETED = 'flow.deleted', FLOW_UPDATED = 'flow.updated', + FLOW_PIECES_UPGRADED = 'flow.pieces.upgraded', + FLOW_PIECES_REVERTED = 'flow.pieces.reverted', FLOW_PUBLISHED = 'flow.published', FLOW_ACTIVATED = 'flow.activated', FLOW_DEACTIVATED = 'flow.deactivated', @@ -319,6 +321,41 @@ export const FlowUpdatedEvent = z.object({ export type FlowUpdatedEvent = z.infer +export const FlowPiecesUpgradedEvent = z.object({ + ...BaseAuditEventProps, + action: z.literal(ApplicationEventName.FLOW_PIECES_UPGRADED), + data: z.object({ + flowId: z.string(), + flowVersionId: z.string(), + steps: z.array(z.object({ + stepName: z.string(), + actionOrTriggerName: z.string(), + decision: z.enum(['UPGRADED', 'KEPT']), + prevVersion: z.string(), + newVersion: Nullable(z.string()), + })), + }), +}) + +export type FlowPiecesUpgradedEvent = z.infer + +export const FlowPiecesRevertedEvent = z.object({ + ...BaseAuditEventProps, + action: z.literal(ApplicationEventName.FLOW_PIECES_REVERTED), + data: z.object({ + flowId: z.string(), + flowVersionId: z.string(), + steps: z.array(z.object({ + stepName: z.string(), + actionOrTriggerName: z.string(), + prevVersion: z.string(), + newVersion: z.string(), + })), + }), +}) + +export type FlowPiecesRevertedEvent = z.infer + const FlowLifecycleEventData = z.object({ flow: Flow.pick({ id: true, externalId: true, created: true, updated: true }), flowVersion: FlowVersion.pick({ @@ -511,6 +548,8 @@ export const ApplicationEvent = z.union([ FlowCreatedEvent, FlowDeletedEvent, FlowUpdatedEvent, + FlowPiecesUpgradedEvent, + FlowPiecesRevertedEvent, FlowPublishedEvent, FlowActivatedEvent, FlowDeactivatedEvent, @@ -544,6 +583,13 @@ export function summarizeApplicationEvent(event: ApplicationEvent) { } case ApplicationEventName.FLOW_CREATED: return `Flow ${event.data.flow.id} is created` + case ApplicationEventName.FLOW_PIECES_UPGRADED: { + const upgradedCount = event.data.steps.filter((step) => step.decision === 'UPGRADED').length + const keptCount = event.data.steps.length - upgradedCount + return `Flow ${event.data.flowId} piece versions upgraded (${upgradedCount} upgraded, ${keptCount} kept)` + } + case ApplicationEventName.FLOW_PIECES_REVERTED: + return `Flow ${event.data.flowId} piece versions reverted (${event.data.steps.length} steps)` case ApplicationEventName.FLOW_DELETED: return `Flow ${event.data.flow.id} (${event.data.flowVersion.displayName}) is deleted` case ApplicationEventName.FLOW_PUBLISHED: diff --git a/packages/core/shared/src/lib/ee/audit-events/mock-event-builder.ts b/packages/core/shared/src/lib/ee/audit-events/mock-event-builder.ts index b95c4099debb..e6dcf303b151 100644 --- a/packages/core/shared/src/lib/ee/audit-events/mock-event-builder.ts +++ b/packages/core/shared/src/lib/ee/audit-events/mock-event-builder.ts @@ -10,6 +10,8 @@ import { FlowCreatedEvent, FlowDeactivatedEvent, FlowDeletedEvent, + FlowPiecesRevertedEvent, + FlowPiecesUpgradedEvent, FlowPublishedEvent, FlowRunEvent, FlowUpdatedEvent, @@ -82,6 +84,35 @@ export const buildMockEvent = ({ event, platformId, projectId }: BuildMockEventP } return mock } + case ApplicationEventName.FLOW_PIECES_UPGRADED: { + const mock: FlowPiecesUpgradedEvent = { + ...baseEnvelope, + action: ApplicationEventName.FLOW_PIECES_UPGRADED, + data: { + flowId: flow.id, + flowVersionId: flowVersion.id, + steps: [ + { stepName: 'step_1', actionOrTriggerName: 'send_email', decision: 'UPGRADED', prevVersion: '0.1.0', newVersion: '0.2.0' }, + { stepName: 'step_2', actionOrTriggerName: 'delete_row', decision: 'KEPT', prevVersion: '0.1.0', newVersion: null }, + ], + }, + } + return mock + } + case ApplicationEventName.FLOW_PIECES_REVERTED: { + const mock: FlowPiecesRevertedEvent = { + ...baseEnvelope, + action: ApplicationEventName.FLOW_PIECES_REVERTED, + data: { + flowId: flow.id, + flowVersionId: flowVersion.id, + steps: [ + { stepName: 'step_1', actionOrTriggerName: 'send_email', prevVersion: '0.2.0', newVersion: '0.1.0' }, + ], + }, + } + return mock + } case ApplicationEventName.FLOW_DELETED: { const mock: FlowDeletedEvent = { ...baseEnvelope, diff --git a/packages/pieces/common/package.json b/packages/pieces/common/package.json index bf4944d7dee5..816000305af8 100644 --- a/packages/pieces/common/package.json +++ b/packages/pieces/common/package.json @@ -1,13 +1,14 @@ { "name": "@activepieces/pieces-common", - "version": "0.12.9", + "version": "0.13.0", "type": "commonjs", "sideEffects": false, "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { "build": "tsc -p tsconfig.lib.json && cp package.json dist/", - "lint": "eslint 'src/**/*.ts'" + "lint": "eslint 'src/**/*.ts'", + "test": "vitest run" }, "dependencies": { "@activepieces/pieces-framework": "workspace:*", @@ -16,6 +17,7 @@ "zod": "4.3.6" }, "devDependencies": { - "tslib": "2.6.2" + "tslib": "2.6.2", + "vitest": "3.2.6" } } diff --git a/packages/pieces/common/src/lib/stream/index.ts b/packages/pieces/common/src/lib/stream/index.ts index 661602d3def6..8e38cbb3d8d1 100644 --- a/packages/pieces/common/src/lib/stream/index.ts +++ b/packages/pieces/common/src/lib/stream/index.ts @@ -1,4 +1,15 @@ import { Readable } from 'node:stream'; +import type { ApFile, ApStreamingFile } from '@activepieces/pieces-framework'; + +function toStreamingBody(file: ApStreamingFile | ApFile): { + body: Readable; + size: number | undefined; +} { + if ('body' in file) { + return { body: file.body, size: file.size }; + } + return { body: Readable.from(file.data), size: file.data.length }; +} async function* readChunks({ readable, @@ -13,7 +24,7 @@ async function* readChunks({ pending.push(Buffer.isBuffer(data) ? data : Buffer.from(data)); pendingLength += pending[pending.length - 1].length; while (pendingLength >= chunkSize) { - const combined = Buffer.concat(pending); + const combined = pending.length === 1 ? pending[0] : Buffer.concat(pending); yield combined.subarray(0, chunkSize); const rest = combined.subarray(chunkSize); pending = rest.length > 0 ? [rest] : []; @@ -21,8 +32,8 @@ async function* readChunks({ } } if (pendingLength > 0) { - yield Buffer.concat(pending); + yield pending.length === 1 ? pending[0] : Buffer.concat(pending); } } -export const streamUtils = { readChunks }; +export const streamUtils = { readChunks, toStreamingBody }; diff --git a/packages/pieces/common/test/stream-utils.test.ts b/packages/pieces/common/test/stream-utils.test.ts new file mode 100644 index 000000000000..8a77f8e73edc --- /dev/null +++ b/packages/pieces/common/test/stream-utils.test.ts @@ -0,0 +1,58 @@ +/// + +import { Readable } from 'node:stream'; +import { buffer as readableToBuffer } from 'node:stream/consumers'; +import { ApFile, ApStreamingFile } from '@activepieces/pieces-framework'; +import { streamUtils } from '../src'; + +describe('streamUtils.toStreamingBody', () => { + test('wraps a buffered ApFile into a readable body with its exact size, even from another bundle where instanceof would fail', async () => { + const data = Buffer.from('cross-bundle-bytes'); + const file = new ApFile('report.xlsx', data, 'xlsx'); + Object.setPrototypeOf(file, Object.prototype); + + const { body, size } = streamUtils.toStreamingBody(file); + + expect(size).toBe(data.length); + expect(await readableToBuffer(body)).toEqual(data); + }); + + test('passes an ApStreamingFile body and size through untouched', () => { + const streamBody = Readable.from(Buffer.from('streamed')); + const file: ApStreamingFile = { + filename: 'export.csv', + extension: 'csv', + size: 8, + body: streamBody, + }; + + const { body, size } = streamUtils.toStreamingBody(file); + + expect(body).toBe(streamBody); + expect(size).toBe(8); + }); + + test('readChunks splits a single large buffer into exact chunkSize chunks', async () => { + const data = Buffer.from(Array.from({ length: 25 }, (_, i) => i)); + const chunks: Buffer[] = []; + for await (const chunk of streamUtils.readChunks({ readable: Readable.from(data), chunkSize: 10 })) { + chunks.push(chunk); + } + + expect(chunks.map((chunk) => chunk.length)).toEqual([10, 10, 5]); + expect(Buffer.concat(chunks).equals(data)).toBe(true); + }); + + test('keeps a sizeless ApStreamingFile sizeless', () => { + const streamBody = Readable.from(Buffer.from('no-content-length')); + const file: ApStreamingFile = { + filename: 'unknown.bin', + body: streamBody, + }; + + const { body, size } = streamUtils.toStreamingBody(file); + + expect(body).toBe(streamBody); + expect(size).toBeUndefined(); + }); +}); diff --git a/packages/pieces/common/vitest.config.ts b/packages/pieces/common/vitest.config.ts new file mode 100644 index 000000000000..957f431f105b --- /dev/null +++ b/packages/pieces/common/vitest.config.ts @@ -0,0 +1,16 @@ +import path from 'path' +import { defineConfig } from 'vitest/config' + +const repoRoot = path.resolve(__dirname, '../../..') + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, + resolve: { + alias: { + '@activepieces/pieces-framework': path.resolve(repoRoot, 'packages/pieces/framework/src/index.ts'), + }, + }, +}) diff --git a/packages/pieces/community/amazon-s3/package.json b/packages/pieces/community/amazon-s3/package.json index d6f095e1907a..61e9d47a2561 100644 --- a/packages/pieces/community/amazon-s3/package.json +++ b/packages/pieces/community/amazon-s3/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-amazon-s3", - "version": "0.6.7", + "version": "0.6.8", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { diff --git a/packages/pieces/community/amazon-s3/src/lib/actions/upload-file.ts b/packages/pieces/community/amazon-s3/src/lib/actions/upload-file.ts index 065f2d0664bc..984e65e4be60 100644 --- a/packages/pieces/community/amazon-s3/src/lib/actions/upload-file.ts +++ b/packages/pieces/community/amazon-s3/src/lib/actions/upload-file.ts @@ -1,4 +1,5 @@ import { Property, createAction } from '@activepieces/pieces-framework'; +import { streamUtils } from '@activepieces/pieces-common'; import { Upload } from '@aws-sdk/lib-storage'; import { amazonS3CombinedAuth, S3AuthProps } from '../auth'; import { resolveS3Client } from '../common'; @@ -102,6 +103,7 @@ export const amazons3UploadFile = createAction({ // Streams the body in 5MB parts instead of buffering the whole file in the // sandbox. Each part is buffered before it is sent, so the SDK can replay it // on a retry; files under one part size go out as a plain PutObject. + const { body } = streamUtils.toStreamingBody(file); const uploadResponse = await new Upload({ client: s3, params: { @@ -109,7 +111,7 @@ export const amazons3UploadFile = createAction({ Key: finalFileName, ACL: acl as ObjectCannedACL | undefined, ContentType: contentType, - Body: file.body, + Body: body, }, }).done(); diff --git a/packages/pieces/community/azure-blob-storage/package.json b/packages/pieces/community/azure-blob-storage/package.json index cdaa0428ecb6..aaa8691dba86 100644 --- a/packages/pieces/community/azure-blob-storage/package.json +++ b/packages/pieces/community/azure-blob-storage/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-azure-blob-storage", - "version": "0.1.9", + "version": "0.1.10", "type": "commonjs", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", diff --git a/packages/pieces/community/azure-blob-storage/src/lib/actions/create-blob.ts b/packages/pieces/community/azure-blob-storage/src/lib/actions/create-blob.ts index ba294083aa35..ac53710665b4 100644 --- a/packages/pieces/community/azure-blob-storage/src/lib/actions/create-blob.ts +++ b/packages/pieces/community/azure-blob-storage/src/lib/actions/create-blob.ts @@ -1,4 +1,5 @@ import { createAction, Property } from '@activepieces/pieces-framework'; +import { streamUtils } from '@activepieces/pieces-common'; import { azureBlobStorageAuth } from '../auth'; import { BlobServiceClient, Tags } from '@azure/storage-blob'; import { containerProp } from '../common'; @@ -32,12 +33,13 @@ export const createBlob = createAction({ }, async run(context) { const { container, blobName, file, tags } = context.propsValue; + const { body } = streamUtils.toStreamingBody(file); const auth = context.auth.props; const blobServiceClient = BlobServiceClient.fromConnectionString(auth.connectionString); const containerClient = blobServiceClient.getContainerClient(container); const blockBlobClient = containerClient.getBlockBlobClient(blobName); - return await blockBlobClient.uploadStream(file.body, undefined, undefined, { tags: tags as Tags }); + return await blockBlobClient.uploadStream(body, undefined, undefined, { tags: tags as Tags }); }, }); diff --git a/packages/pieces/community/dropbox/package.json b/packages/pieces/community/dropbox/package.json index 208aabbc70f9..25c5dd35676f 100644 --- a/packages/pieces/community/dropbox/package.json +++ b/packages/pieces/community/dropbox/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-dropbox", - "version": "0.7.7", + "version": "0.7.8", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/community/dropbox/src/lib/actions/upload-file.ts b/packages/pieces/community/dropbox/src/lib/actions/upload-file.ts index b340f345a8b6..d0d74a730368 100644 --- a/packages/pieces/community/dropbox/src/lib/actions/upload-file.ts +++ b/packages/pieces/community/dropbox/src/lib/actions/upload-file.ts @@ -56,7 +56,7 @@ export const dropboxUploadFile = createAction({ }), }, async run(context) { - const fileData = context.propsValue.file; + const { body, size } = streamUtils.toStreamingBody(context.propsValue.file); const token = context.auth.access_token; const commit = { autorename: context.propsValue.autorename, @@ -69,17 +69,17 @@ export const dropboxUploadFile = createAction({ // A known size within the single-request cap lets us stream the body straight // through with an explicit Content-Length. Larger files, and sources that don't // report a size, go through an upload session so nothing is buffered whole. - if (fileData.size != null && fileData.size <= SINGLE_REQUEST_LIMIT) { + if (size != null && size <= SINGLE_REQUEST_LIMIT) { return await sendToDropbox({ endpoint: 'files/upload', apiArg: commit, - body: fileData.body, - contentLength: fileData.size, + body, + contentLength: size, token, }); } - return await uploadInSession({ body: fileData.body, commit, token }); + return await uploadInSession({ body, commit, token }); }, }); diff --git a/packages/pieces/community/google-drive/package.json b/packages/pieces/community/google-drive/package.json index c90897be9ae8..1489763c36f2 100644 --- a/packages/pieces/community/google-drive/package.json +++ b/packages/pieces/community/google-drive/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-google-drive", - "version": "0.9.0", + "version": "0.9.1", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { diff --git a/packages/pieces/community/google-drive/src/lib/action/upload-file.ts b/packages/pieces/community/google-drive/src/lib/action/upload-file.ts index a18db7ebfc95..3d105c75b038 100644 --- a/packages/pieces/community/google-drive/src/lib/action/upload-file.ts +++ b/packages/pieces/community/google-drive/src/lib/action/upload-file.ts @@ -1,4 +1,5 @@ import { createAction, Property } from '@activepieces/pieces-framework'; +import { streamUtils } from '@activepieces/pieces-common'; import mime from 'mime-types'; import { drive as googleDrive } from '@googleapis/drive'; import { googleDriveAuth, createGoogleClient } from '../auth'; @@ -31,6 +32,7 @@ export const googleDriveUploadFile = createAction({ outputSchema: uploadGdriveFileActionOutputSchema, async run(context) { const fileData = context.propsValue.file; + const { body } = streamUtils.toStreamingBody(fileData); const mimeType = mime.lookup(fileData.extension ?? '') || 'application/octet-stream'; const authClient = await createGoogleClient(context.auth); @@ -45,7 +47,7 @@ export const googleDriveUploadFile = createAction({ }, media: { mimeType, - body: fileData.body, + body, }, supportsAllDrives: context.propsValue.include_team_drives ?? false, fields: 'id, name, mimeType, kind', diff --git a/packages/pieces/community/microsoft-onedrive/package.json b/packages/pieces/community/microsoft-onedrive/package.json index 24fc786ec5eb..0b352d250ce7 100644 --- a/packages/pieces/community/microsoft-onedrive/package.json +++ b/packages/pieces/community/microsoft-onedrive/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-microsoft-onedrive", - "version": "0.4.6", + "version": "0.4.7", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { @@ -16,10 +16,12 @@ "scripts": { "build": "tsc -p tsconfig.lib.json && cp package.json dist/", "bundle": "node ../../../../dist/packages/cli/src/index.js pieces bundle", - "lint": "eslint 'src/**/*.ts'" + "lint": "eslint 'src/**/*.ts'", + "test": "vitest run" }, "devDependencies": { "@types/mime-types": "2.1.1", - "tslib": "2.6.2" + "tslib": "2.6.2", + "vitest": "3.2.6" } } diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/upload-file.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/upload-file.ts index 3221146345ad..7b5b4e3d125c 100644 --- a/packages/pieces/community/microsoft-onedrive/src/lib/actions/upload-file.ts +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/upload-file.ts @@ -52,10 +52,9 @@ export const uploadFile = createAction({ // Chunked upload needs the total size upfront for the Content-Range header. // When the source doesn't report a size, buffer once and use its length — // same behaviour as before streaming — then re-wrap so both paths stream. - let fileSize = fileData.size; - let body = fileData.body; + let { body, size: fileSize } = streamUtils.toStreamingBody(fileData); if (fileSize == null) { - const buffered = await readableToBuffer(fileData.body); + const buffered = await readableToBuffer(body); fileSize = buffered.length; body = Readable.from(buffered); } diff --git a/packages/pieces/community/microsoft-onedrive/test/upload-file-old-engine.test.ts b/packages/pieces/community/microsoft-onedrive/test/upload-file-old-engine.test.ts new file mode 100644 index 000000000000..233e74f7225f --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/test/upload-file-old-engine.test.ts @@ -0,0 +1,100 @@ +/// + +import { buffer as readableToBuffer } from 'node:stream/consumers'; +import { Readable } from 'node:stream'; +import { ApFile, createMockActionContext } from '@activepieces/pieces-framework'; +import { uploadFile } from '../src/lib/actions/upload-file'; + +const { sendRequest } = vi.hoisted(() => ({ + sendRequest: vi.fn(), +})); + +vi.mock('@activepieces/pieces-common', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + httpClient: { sendRequest }, + }; +}); + +type UploadFileContext = Parameters[0]; + +function buildContext(file: ApFile | { body: Readable; filename: string; extension?: string; size?: number }): UploadFileContext { + const base = createMockActionContext({ + propsValue: { + fileName: 'report.xlsx', + file, + parentId: 'root', + markdown: undefined, + }, + }); + return { + ...base, + auth: { access_token: 'test-token' }, + } as unknown as UploadFileContext; +} + +describe('OneDrive upload file on engines without streaming file support', () => { + beforeEach(() => { + sendRequest.mockReset(); + sendRequest.mockResolvedValue({ body: { id: 'uploaded-item-id' } }); + }); + + test('uploads a buffered ApFile produced by a pre-0.87.0 engine', async () => { + const data = Buffer.from('old-engine-spreadsheet-bytes'); + const file = new ApFile('report.xlsx', data, 'xlsx'); + Object.setPrototypeOf(file, Object.prototype); + + const result = await uploadFile.run(buildContext(file)); + + expect(result).toEqual({ id: 'uploaded-item-id' }); + expect(sendRequest).toHaveBeenCalledTimes(1); + const request = sendRequest.mock.calls[0][0]; + expect(request.headers['Content-length']).toBe(String(data.length)); + expect(await readableToBuffer(request.body)).toEqual(data); + }); + + test('chunk-uploads a large buffered ApFile through the upload session', async () => { + const data = Buffer.alloc(12 * 1024 * 1024); + for (let i = 0; i < data.length; i += 4096) { + data.writeUInt32BE(i, i); + } + const file = new ApFile('report.xlsx', data, 'xlsx'); + Object.setPrototypeOf(file, Object.prototype); + sendRequest.mockResolvedValueOnce({ body: { uploadUrl: 'https://upload.example/session' } }); + + const uploaded: Buffer[] = []; + const ranges: string[] = []; + sendRequest.mockImplementation(async ({ body, headers }) => { + uploaded.push(Buffer.from(body)); + ranges.push(headers['Content-Range']); + return { body: { id: 'uploaded-item-id' } }; + }); + + const result = await uploadFile.run(buildContext(file)); + + expect(result).toEqual({ id: 'uploaded-item-id' }); + expect(ranges).toEqual([ + `bytes 0-${10485760 - 1}/${data.length}`, + `bytes 10485760-${data.length - 1}/${data.length}`, + ]); + expect(Buffer.concat(uploaded).equals(data)).toBe(true); + }); + + test('still streams an ApStreamingFile from a current engine', async () => { + const data = Buffer.from('new-engine-streamed-bytes'); + const file = { + filename: 'report.xlsx', + extension: 'xlsx', + size: data.length, + body: Readable.from(data), + }; + + const result = await uploadFile.run(buildContext(file)); + + expect(result).toEqual({ id: 'uploaded-item-id' }); + const request = sendRequest.mock.calls[0][0]; + expect(request.headers['Content-length']).toBe(String(data.length)); + expect(await readableToBuffer(request.body)).toEqual(data); + }); +}); diff --git a/packages/pieces/community/microsoft-onedrive/vitest.config.ts b/packages/pieces/community/microsoft-onedrive/vitest.config.ts new file mode 100644 index 000000000000..ba8ade4a1780 --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/vitest.config.ts @@ -0,0 +1,17 @@ +import path from 'path' +import { defineConfig } from 'vitest/config' + +const repoRoot = path.resolve(__dirname, '../../../..') + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, + resolve: { + alias: { + '@activepieces/pieces-framework': path.resolve(repoRoot, 'packages/pieces/framework/src/index.ts'), + '@activepieces/pieces-common': path.resolve(repoRoot, 'packages/pieces/common/src/index.ts'), + }, + }, +}) diff --git a/packages/pieces/community/microsoft-sharepoint/package.json b/packages/pieces/community/microsoft-sharepoint/package.json index 42bc14593b84..ceeed7669a7c 100644 --- a/packages/pieces/community/microsoft-sharepoint/package.json +++ b/packages/pieces/community/microsoft-sharepoint/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-microsoft-sharepoint", - "version": "0.3.7", + "version": "0.3.8", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { diff --git a/packages/pieces/community/microsoft-sharepoint/src/lib/actions/upload-file.ts b/packages/pieces/community/microsoft-sharepoint/src/lib/actions/upload-file.ts index 593670c9b991..4e26dff2b09d 100644 --- a/packages/pieces/community/microsoft-sharepoint/src/lib/actions/upload-file.ts +++ b/packages/pieces/community/microsoft-sharepoint/src/lib/actions/upload-file.ts @@ -59,10 +59,9 @@ export const uploadFile = createAction({ // Chunked upload needs the total size upfront for the Content-Range header. // When the source doesn't report a size, buffer once and use its length — // same behaviour as before streaming — then re-wrap so both paths stream. - let fileSize = file.size; - let body = file.body; + let { body, size: fileSize } = streamUtils.toStreamingBody(file); if (fileSize == null) { - const buffered = await readableToBuffer(file.body); + const buffered = await readableToBuffer(body); fileSize = buffered.length; body = Readable.from(buffered); } diff --git a/packages/pieces/core/sftp/package.json b/packages/pieces/core/sftp/package.json index 9661d4588f94..846d51f8bca9 100644 --- a/packages/pieces/core/sftp/package.json +++ b/packages/pieces/core/sftp/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-sftp", - "version": "0.5.7", + "version": "0.5.8", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { diff --git a/packages/pieces/core/sftp/src/lib/actions/upload-file.ts b/packages/pieces/core/sftp/src/lib/actions/upload-file.ts index cb18334d638e..530b31f6896f 100644 --- a/packages/pieces/core/sftp/src/lib/actions/upload-file.ts +++ b/packages/pieces/core/sftp/src/lib/actions/upload-file.ts @@ -1,4 +1,5 @@ import { createAction, Property } from '@activepieces/pieces-framework'; +import { streamUtils } from '@activepieces/pieces-common'; import Client from 'ssh2-sftp-client'; import { Client as FTPClient, FTPError } from 'basic-ftp'; import { endClient, getClient, getProtocolBackwardCompatibility } from '../common'; @@ -46,17 +47,17 @@ export const uploadFileAction = createAction({ async run(context) { const client = await getClient(context.auth.props); const fileName = context.propsValue['fileName']; - const fileContent = context.propsValue['fileContent']; + const { body } = streamUtils.toStreamingBody(context.propsValue['fileContent']); const protocolBackwardCompatibility = await getProtocolBackwardCompatibility(context.auth.props.protocol); try { switch (protocolBackwardCompatibility) { case 'ftps': case 'ftp': - await uploadFileToFTP(client as FTPClient, fileName, fileContent.body); + await uploadFileToFTP(client as FTPClient, fileName, body); break; default: case 'sftp': - await uploadFileToSFTP(client as Client, fileName, fileContent.body); + await uploadFileToSFTP(client as Client, fileName, body); break; } return { diff --git a/packages/pieces/core/subflows/package.json b/packages/pieces/core/subflows/package.json index 774091557090..2e02225bc77b 100644 --- a/packages/pieces/core/subflows/package.json +++ b/packages/pieces/core/subflows/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-subflows", - "version": "0.6.3", + "version": "0.6.4", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/core/subflows/src/lib/actions/stream-csv-to-flow.ts b/packages/pieces/core/subflows/src/lib/actions/stream-csv-to-flow.ts index 97fc19b4e57d..74a9601c6824 100644 --- a/packages/pieces/core/subflows/src/lib/actions/stream-csv-to-flow.ts +++ b/packages/pieces/core/subflows/src/lib/actions/stream-csv-to-flow.ts @@ -1,4 +1,5 @@ import { createAction, Property } from '@activepieces/pieces-framework'; +import { streamUtils } from '@activepieces/pieces-common'; import { createCsvParser, CsvRow } from '../csv'; import { dispatchToSubflow, findEnabledSubflowOrThrow, subflowDropdown } from '../common'; import { fanOutBatches } from '../fan-out'; @@ -53,12 +54,13 @@ export const streamCsvToSubflows = createAction({ }, async run(context) { const { file, batchSize, delimiter, extraData } = context.propsValue; + const { body } = streamUtils.toStreamingBody(file); if ( !Number.isInteger(batchSize) || batchSize < 1 || batchSize > MAX_BATCH_SIZE ) { - file.body.destroy(); + body.destroy(); throw new Error( JSON.stringify({ message: `Rows per batch must be an integer between 1 and ${MAX_BATCH_SIZE}.`, @@ -70,14 +72,14 @@ export const streamCsvToSubflows = createAction({ flowsContext: context.flows, externalId: context.propsValue.subflow, }).catch((error) => { - file.body.destroy(); + body.destroy(); throw error; }); let firstRow: CsvRow | undefined; const { parser, getHeaders } = createCsvParser({ delimiter }); - file.body.pipe(parser); - file.body.on('error', (err) => { + body.pipe(parser); + body.on('error', (err) => { if (!parser.destroyed) { parser.destroy(err); } @@ -113,7 +115,7 @@ export const streamCsvToSubflows = createAction({ return { headers: getHeaders(), firstRow, ...result }; } finally { parser.destroy(); - file.body.destroy(); + body.destroy(); } }, errorHandlingOptions: { diff --git a/packages/pieces/framework/package.json b/packages/pieces/framework/package.json index cacb8a518ebb..73503e92a6a7 100644 --- a/packages/pieces/framework/package.json +++ b/packages/pieces/framework/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/pieces-framework", - "version": "0.37.0", + "version": "0.38.0", "type": "commonjs", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", diff --git a/packages/pieces/framework/src/lib/property/input/file-property.ts b/packages/pieces/framework/src/lib/property/input/file-property.ts index 6e7141c298f2..433c3a3a430a 100644 --- a/packages/pieces/framework/src/lib/property/input/file-property.ts +++ b/packages/pieces/framework/src/lib/property/input/file-property.ts @@ -30,4 +30,4 @@ export type ApStreamingFile = { export type FileProperty = BasePropertySchema & { streaming?: S; -} & TPropertyValue; +} & TPropertyValue; diff --git a/packages/server/api/src/app/app-connection/app-connection-service/app-connection-service.ts b/packages/server/api/src/app/app-connection/app-connection-service/app-connection-service.ts index 5cee11466485..0174396030d0 100644 --- a/packages/server/api/src/app/app-connection/app-connection-service/app-connection-service.ts +++ b/packages/server/api/src/app/app-connection/app-connection-service/app-connection-service.ts @@ -236,6 +236,20 @@ export const appConnectionService = (log: FastifyBaseLogger) => ({ return this.removeSensitiveData(connection) }, + async listConnectedPieces({ projectId, platformId, limit }: { projectId: ProjectId, platformId: PlatformId, limit: number }): Promise<{ pieceName: string, externalId: string }[]> { + return appConnectionsRepo().createQueryBuilder('connection') + .select('connection.pieceName', 'pieceName') + .addSelect('connection.externalId', 'externalId') + .distinctOn(['connection.pieceName']) + .where('connection.platformId = :platformId', { platformId }) + .andWhere(':projectId = ANY(connection.projectIds)', { projectId }) + .andWhere('connection.status = :status', { status: AppConnectionStatus.ACTIVE }) + .orderBy('connection.pieceName', 'ASC') + .addOrderBy('connection.created', 'ASC') + .limit(limit) + .getRawMany() + }, + async getManyConnectionStates(params: GetManyParams): Promise { const connections = await appConnectionsRepo().find({ where: { diff --git a/packages/server/api/src/app/app.ts b/packages/server/api/src/app/app.ts index 358cc11d5e17..7efe6f5466bc 100644 --- a/packages/server/api/src/app/app.ts +++ b/packages/server/api/src/app/app.ts @@ -1,7 +1,7 @@ import { isNil, spreadIfDefined } from '@activepieces/core-utils' import { PieceMetadata } from '@activepieces/pieces-framework' import { apVersionUtil, onCallService, UNKNOWN_VERSION, wideEvent } from '@activepieces/server-utils' -import { AddAllowedEmbedOriginsRequestBody, ApEdition, ApEnvironment, AppConnectionWithoutSensitiveData, ApplicationEventName, ConnectionDeletedEvent, ConnectionUpsertedEvent, Flow, FlowActivatedEvent, FlowCreatedEvent, FlowDeactivatedEvent, FlowDeletedEvent, FlowPublishedEvent, FlowRun, FlowRunFinishedEvent, FlowRunRetriedEvent, FlowRunStartedEvent, FlowUpdatedEvent, Folder, FolderCreatedEvent, FolderDeletedEvent, FolderUpdatedEvent, GitRepoWithoutSensitiveData, ProjectMember, ProjectRelease, ProjectReleaseEvent, ProjectRoleEvent, ProjectWithLimits, SigningKeyEvent, SignUpEvent, Template, UserEmailVerifiedEvent, UserInvitation, UserPasswordResetEvent, UserSignedInEvent, UserWithMetaInformation } from '@activepieces/shared' +import { AddAllowedEmbedOriginsRequestBody, ApEdition, ApEnvironment, AppConnectionWithoutSensitiveData, ApplicationEventName, ConnectionDeletedEvent, ConnectionUpsertedEvent, Flow, FlowActivatedEvent, FlowCreatedEvent, FlowDeactivatedEvent, FlowDeletedEvent, FlowPiecesRevertedEvent, FlowPiecesUpgradedEvent, FlowPublishedEvent, FlowRun, FlowRunFinishedEvent, FlowRunRetriedEvent, FlowRunStartedEvent, FlowUpdatedEvent, Folder, FolderCreatedEvent, FolderDeletedEvent, FolderUpdatedEvent, GitRepoWithoutSensitiveData, ProjectMember, ProjectRelease, ProjectReleaseEvent, ProjectRoleEvent, ProjectWithLimits, SigningKeyEvent, SignUpEvent, Template, UserEmailVerifiedEvent, UserInvitation, UserPasswordResetEvent, UserSignedInEvent, UserWithMetaInformation } from '@activepieces/shared' import replyFrom from '@fastify/reply-from' import swagger from '@fastify/swagger' import { createAdapter } from '@socket.io/redis-adapter' @@ -483,6 +483,8 @@ function extractProjectId(principal: { projectId?: string } | null | undefined): function registerOpenApiSchemas() { globalRegistry.add(FlowCreatedEvent, { id: ApplicationEventName.FLOW_CREATED }) globalRegistry.add(FlowUpdatedEvent, { id: ApplicationEventName.FLOW_UPDATED }) + globalRegistry.add(FlowPiecesUpgradedEvent, { id: ApplicationEventName.FLOW_PIECES_UPGRADED }) + globalRegistry.add(FlowPiecesRevertedEvent, { id: ApplicationEventName.FLOW_PIECES_REVERTED }) globalRegistry.add(FlowDeletedEvent, { id: ApplicationEventName.FLOW_DELETED }) globalRegistry.add(FlowPublishedEvent, { id: ApplicationEventName.FLOW_PUBLISHED }) globalRegistry.add(FlowActivatedEvent, { id: ApplicationEventName.FLOW_ACTIVATED }) diff --git a/packages/server/api/src/app/authentication/authentication-utils.ts b/packages/server/api/src/app/authentication/authentication-utils.ts index d5d9ec2ad5fe..32c4dc9f3f63 100644 --- a/packages/server/api/src/app/authentication/authentication-utils.ts +++ b/packages/server/api/src/app/authentication/authentication-utils.ts @@ -9,6 +9,7 @@ import { projectService } from '../project/project-service' import { userService } from '../user/user-service' import { userInvitationsService } from '../user-invitations/user-invitation.service' import { accessTokenManager } from './lib/access-token-manager' +import { signupNames } from './lib/signup-names' import { userIdentityService } from './user-identity/user-identity-service' export const authenticationUtils = (log: FastifyBaseLogger) => ({ @@ -112,6 +113,21 @@ export const authenticationUtils = (log: FastifyBaseLogger) => ({ } }, + async provisionOrOnboard({ identityId }: ProvisionOrOnboardParams): Promise { + const identity = await userIdentityService(log).getOneOrFail({ id: identityId }) + if (!identity.verified || signupNames.isPlaceholderName(identity)) { + return this.getOnboardingResponse({ identityId }) + } + const { response } = await platformService(log).createPlatformWithProject({ + identityId, + name: signupNames.platformNameFromSignup({ firstName: identity.firstName, email: identity.email }), + invalidatePreviousTokens: false, + isFirstPlatform: true, + callerTokenVersion: undefined, + }) + return response + }, + async assertDomainIsAllowed({ email, platformId, @@ -278,6 +294,10 @@ type AssertUserIsInvitedToPlatformOrProjectParams = { platformId: string } +type ProvisionOrOnboardParams = { + identityId: string +} + type GetOnboardingResponseParams = { identityId: string } diff --git a/packages/server/api/src/app/authentication/authentication.service.ts b/packages/server/api/src/app/authentication/authentication.service.ts index 7cfd2d44cb17..4ec6c5181b1d 100644 --- a/packages/server/api/src/app/authentication/authentication.service.ts +++ b/packages/server/api/src/app/authentication/authentication.service.ts @@ -79,8 +79,8 @@ export const authenticationService = (log: FastifyBaseLogger) => ({ await authenticationUtils(log).sendTelemetry({ identity: userIdentity, user, projectId: authResponse.projectId ?? '' }) return authResponse } - log.info({ email: params.email, provider: params.provider }, 'User signed up without platform') - return authenticationUtils(log).getOnboardingResponse({ identityId: userIdentity.id }) + log.info({ email: params.email, provider: params.provider }, 'User signed up without a platform to join') + return authenticationUtils(log).provisionOrOnboard({ identityId: userIdentity.id }) }, async signInWithPassword(params: SignInWithPasswordParams): Promise { @@ -88,8 +88,8 @@ export const authenticationService = (log: FastifyBaseLogger) => ({ const platformId = isNil(params.predefinedPlatformId) ? await getPreferredPlatformId(identity.id, log) : params.predefinedPlatformId if (isNil(platformId)) { // always cloud - log.info({ email: params.email }, 'User signed in without an active platform on cloud, returning onboarding token') - return authenticationUtils(log).getOnboardingResponse({ identityId: identity.id }) + log.info({ email: params.email }, 'User signed in without an active platform on cloud') + return authenticationUtils(log).provisionOrOnboard({ identityId: identity.id }) } await authenticationUtils(log).assertEmailAuthIsEnabled({ @@ -121,7 +121,7 @@ export const authenticationService = (log: FastifyBaseLogger) => ({ if (isNil(platformId)) { // always cloud if (!isNil(userIdentity)) { - return authenticationUtils(log).getOnboardingResponse({ identityId: userIdentity.id }) + return authenticationUtils(log).provisionOrOnboard({ identityId: userIdentity.id }) } return authenticationService(log).signUp({ email: params.email, diff --git a/packages/server/api/src/app/authentication/lib/signup-names.ts b/packages/server/api/src/app/authentication/lib/signup-names.ts index ac6ac146599d..955b337a799b 100644 --- a/packages/server/api/src/app/authentication/lib/signup-names.ts +++ b/packages/server/api/src/app/authentication/lib/signup-names.ts @@ -114,12 +114,17 @@ function splitFullName({ fullName, email }: SplitFullNameParams): SplitName { } } +function isPlaceholderName({ firstName, lastName, email }: IsPlaceholderNameParams): boolean { + return lastName.trim().length === 0 && firstName.trim().toLowerCase() === firstNameFromEmail(email).toLowerCase() +} + export const signupNames = { firstNameFromEmail, platformNameFromPerson, platformNameFromSignup, companyNameFromWorkEmail, splitFullName, + isPlaceholderName, } type PlatformNameFromPersonParams = { @@ -132,6 +137,12 @@ type PlatformNameFromSignupParams = { email: string } +type IsPlaceholderNameParams = { + firstName: string + lastName: string + email: string +} + type SplitFullNameParams = { fullName: string email: string diff --git a/packages/server/api/src/app/authentication/passwordless-auth.service.ts b/packages/server/api/src/app/authentication/passwordless-auth.service.ts index 501457bd87ba..473efa340c5c 100644 --- a/packages/server/api/src/app/authentication/passwordless-auth.service.ts +++ b/packages/server/api/src/app/authentication/passwordless-auth.service.ts @@ -117,7 +117,7 @@ export const passwordlessAuthService = (log: FastifyBaseLogger) => ({ projectId: null, }) } - return authenticationUtils(log).getOnboardingResponse({ identityId: verifiedIdentity.id }) + return authenticationUtils(log).provisionOrOnboard({ identityId: verifiedIdentity.id }) }, async completeSignUp({ identityId, fullName }: CompleteSignUpParams): Promise { diff --git a/packages/server/api/src/app/database/migration/postgres/1832000000000-BackfillChatPersonalizationForExistingUsers.ts b/packages/server/api/src/app/database/migration/postgres/1832000000000-BackfillChatPersonalizationForExistingUsers.ts index baefecb6956d..43920a3760d1 100644 --- a/packages/server/api/src/app/database/migration/postgres/1832000000000-BackfillChatPersonalizationForExistingUsers.ts +++ b/packages/server/api/src/app/database/migration/postgres/1832000000000-BackfillChatPersonalizationForExistingUsers.ts @@ -16,7 +16,7 @@ export class BackfillChatPersonalizationForExistingUsers1832000000000 implements "user"."id", 'DISMISSED_LEGACY' FROM "user" - WHERE "user"."platformId" IS NOT NULL + INNER JOIN "platform" ON "platform"."id" = "user"."platformId" ON CONFLICT DO NOTHING `) } diff --git a/packages/server/api/src/app/ee/agent/agent-controller.ts b/packages/server/api/src/app/ee/agent/agent-controller.ts index eae45dad67be..69095a9a3471 100644 --- a/packages/server/api/src/app/ee/agent/agent-controller.ts +++ b/packages/server/api/src/app/ee/agent/agent-controller.ts @@ -22,6 +22,7 @@ export const agentController: FastifyPluginAsyncZod = async (app) => { app.post('/', CreateAgentRoute, async (request, reply) => { const ownerId = await resolveUserId(request) const agent = await agentService(request.log).create({ + platformId: request.principal.platform.id, projectId: request.projectId, ownerId, request: request.body, diff --git a/packages/server/api/src/app/ee/agent/agent-conversation-controller.ts b/packages/server/api/src/app/ee/agent/agent-conversation-controller.ts index f214146f8190..c8dc55808ba7 100644 --- a/packages/server/api/src/app/ee/agent/agent-conversation-controller.ts +++ b/packages/server/api/src/app/ee/agent/agent-conversation-controller.ts @@ -1,10 +1,11 @@ import { ActivepiecesError, apId, ErrorCode, isNil, spreadIfDefined, tryCatch } from '@activepieces/core-utils' -import { AgentConversationStatus, AgentRunSource, CreateAgentConversationRequest, ImportAgentMemoryRequest, InstructAgentMemoryRequest, LATEST_JOB_DATA_SCHEMA_VERSION, PrincipalType, SendAgentMessageRequest, SERVICE_KEY_SECURITY_OPENAPI, SetAgentMessageFeedbackRequest, UpdateAgentConversationRequest, UpdateAgentMemoryRequest, WorkerJobType } from '@activepieces/shared' +import { AgentConversation, AgentConversationStatus, AgentRunSource, AgentToolType, CreateAgentConversationRequest, ImportAgentMemoryRequest, InstructAgentMemoryRequest, LATEST_JOB_DATA_SCHEMA_VERSION, PrincipalType, SendAgentMessageRequest, SERVICE_KEY_SECURITY_OPENAPI, SetAgentMessageFeedbackRequest, UpdateAgentConversationRequest, UpdateAgentMemoryRequest, WorkerJobType } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { StatusCodes } from 'http-status-codes' import { z } from 'zod' import { securityAccess } from '../../core/security/authorization/fastify-security' +import { mcpUtils } from '../../mcp/tools/mcp-utils' import { assertCreditsAndAppSumoNotExceeded } from '../../platform/billing-provider' import { jobQueue, JobType } from '../../workers/job-queue/job-queue' import { agentApprovalGate } from './agent-approval-gate' @@ -15,10 +16,14 @@ import { agentService } from './agent-service' import { chatAnalyticsTelemetry } from './chat-analytics-sync' import { chatPlanGrant } from './chat-plan-grant' import { chatRolloutService } from './chat-rollout-service' +import { agentPrompt } from './prompt/agent-prompt' import { findConnectionsForPiece } from './tools/agent-tools' const CHAT_PRINCIPALS = [PrincipalType.USER] as const +// Tools configured before 0.87 stored the pin as a template rather than the bare id. +const CONNECTION_TEMPLATE = /^\{\{connections\['([^']+)'\]\}\}$/ + export const agentConversationController: FastifyPluginAsyncZod = async (app) => { app.post('/conversations', CreateConversationRoute, async (request, reply) => { @@ -155,9 +160,10 @@ export const agentConversationController: FastifyPluginAsyncZod = async (app) => ? null : await agentService(log).getOneOrThrowByPlatform({ id: conversation.agentId, platformId, userId }) const agentConfig = agent?.published ?? agent?.draft ?? null + const isBuilder = conversation.source === AgentRunSource.AGENT_BUILDER // resolveRunProvider and the assertion below both fall through to the platform's chat // provider when no provider is named. An agent answers on its own model or it does not run. - if (!isNil(agent) && (isNil(agentConfig?.provider) || isNil(agentConfig?.modelName))) { + if (!isNil(agent) && !isBuilder && (isNil(agentConfig?.provider) || isNil(agentConfig?.modelName))) { throw new ActivepiecesError({ code: ErrorCode.VALIDATION, params: { message: 'Pick a model for this agent before talking to it' }, @@ -192,11 +198,12 @@ export const agentConversationController: FastifyPluginAsyncZod = async (app) => platformId, userId, userMessage: content, - modelName: isNil(agent) ? conversation.modelName ?? null : agentConfig?.modelName ?? null, + modelName: conversation.source === AgentRunSource.AGENT ? agentConfig?.modelName ?? null : conversation.modelName ?? null, files, - ...spreadIfDefined('source', isNil(agent) ? undefined : AgentRunSource.AGENT), + ...spreadIfDefined('source', conversation.source === AgentRunSource.CHAT ? undefined : conversation.source), ...spreadIfDefined('messageSource', request.body.messageSource), - ...(isNil(agentConfig) ? {} : { + ...(isBuilder ? { promptOverride: { system: agentPrompt.buildBuilderSystemPrompt({ agent }) } } : {}), + ...(isNil(agentConfig) || isBuilder ? {} : { tools: agentConfig.tools, structuredOutput: agentConfig.structuredOutput, maxSteps: agentConfig.maxSteps, @@ -261,19 +268,20 @@ export const agentConversationController: FastifyPluginAsyncZod = async (app) => const conversationId = request.params.id const platformId = request.principal.platform.id const userId = request.principal.id - await agentConversationService(request.log).getConversationOrThrow({ id: conversationId, platformId, userId }) + const conversation = await agentConversationService(request.log).getConversationOrThrow({ id: conversationId, platformId, userId }) const pieceName = request.query.pieceName + const pinned = await pinnedAccounts({ conversation, pieceName, platformId, userId, log: request.log }) const cached = await agentApprovalGate.getAvailableConnections({ conversationId, pieceName }) if (cached.length > 0) { - return reply.status(StatusCodes.OK).send(cached) + return reply.status(StatusCodes.OK).send(connectionOffer({ connections: cached, pinned })) } const projects = await agentHelpers.getUserProjects({ platformId, userId, log: request.log }) const result = await findConnectionsForPiece({ pieceName, projects, platformId, log: request.log }) - if ('pickConnection' in result) { - await agentApprovalGate.storeAvailableConnections({ conversationId, pieceName, connections: result.connections }) - return reply.status(StatusCodes.OK).send(result.connections) + if (!('pickConnection' in result)) { + return reply.status(StatusCodes.OK).send(connectionOffer({ connections: [], pinned })) } - return reply.status(StatusCodes.OK).send([]) + await agentApprovalGate.storeAvailableConnections({ conversationId, pieceName, connections: result.connections }) + return reply.status(StatusCodes.OK).send(connectionOffer({ connections: result.connections, pinned })) }) app.get('/memory', GetMemoryRoute, async (request) => { @@ -319,6 +327,51 @@ export const agentConversationController: FastifyPluginAsyncZod = async (app) => const CHAT_MESSAGES_PER_WINDOW = 40 const CHAT_MESSAGE_RATE_WINDOW_SECONDS = 10 * 60 +// A saved agent's configured tools carry their pinned auth themselves and never consult the run's +// selection, so any other account offered here would report a switch that never happens. +async function pinnedAccounts({ conversation, pieceName, platformId, userId, log }: { + conversation: AgentConversation + pieceName: string + platformId: string + userId: string + log: FastifyBaseLogger +}): Promise { + const { agentId } = conversation + if (conversation.source !== AgentRunSource.AGENT || isNil(agentId)) { + return null + } + const agent = await agentService(log).getOneOrThrowByPlatform({ id: agentId, platformId, userId }) + const normalizedPiece = normalizePiece(pieceName) + const config = agent.published ?? agent.draft + const externalIds = config.tools.flatMap((tool) => { + if (tool.type !== AgentToolType.PIECE || normalizePiece(tool.pieceMetadata.pieceName) !== normalizedPiece) { + return [] + } + const auth = tool.pieceMetadata.predefinedInput?.auth + return isNil(auth) ? [] : [auth.match(CONNECTION_TEMPLATE)?.[1] ?? auth] + }) + return { externalIds, projectId: agent.projectId } +} + +// externalId is caller-supplied and its index is not unique, so two projects on one platform can +// carry the same one. The agent's own project has to match or a lookalike row slips through. +function connectionOffer({ connections, pinned }: { + connections: T[] + pinned: PinnedAccounts | null +}): { connections: T[], reconnectOnly: boolean } { + if (isNil(pinned) || pinned.externalIds.length === 0) { + return { connections, reconnectOnly: false } + } + return { + connections: connections.filter((connection) => connection.projectId === pinned.projectId && pinned.externalIds.includes(connection.externalId)), + reconnectOnly: true, + } +} + +function normalizePiece(pieceName: string): string { + return mcpUtils.normalizePieceName(pieceName) ?? pieceName +} + // Per-user flood guard: nothing else bounds how fast a user fires messages, and each one enqueues a // worker job and spends credits. Complements the credit balance, which bounds spend, not rate. async function assertAgentMessageRateLimitNotExceeded({ platformId, userId, log }: { platformId: string, userId: string, log: FastifyBaseLogger }): Promise { @@ -532,3 +585,7 @@ const CancelConversationRoute = { }, } +type PinnedAccounts = { + externalIds: string[] + projectId: string +} diff --git a/packages/server/api/src/app/ee/agent/agent-conversation-service.ts b/packages/server/api/src/app/ee/agent/agent-conversation-service.ts index 103a7d425dbf..e4394c1eea1c 100644 --- a/packages/server/api/src/app/ee/agent/agent-conversation-service.ts +++ b/packages/server/api/src/app/ee/agent/agent-conversation-service.ts @@ -1,5 +1,5 @@ import { ActivepiecesError, apId, ErrorCode, isNil, sanitizeObjectForPostgresql, SeekPage, spreadIfDefined } from '@activepieces/core-utils' -import { AgentConversation, AgentConversationStatus, AgentHistoryMessage, AgentRunSource, CreateAgentConversationRequest, PersistedAgentMessage, PersistedAgentRole, SetAgentMessageFeedbackRequest, UpdateAgentConversationRequest } from '@activepieces/shared' +import { Agent, AgentConversation, AgentConversationStatus, AgentHistoryMessage, AgentRunSource, CreateAgentConversationRequest, PersistedAgentMessage, PersistedAgentRole, SetAgentMessageFeedbackRequest, UpdateAgentConversationRequest } from '@activepieces/shared' import { ModelMessage } from 'ai' import { FastifyBaseLogger } from 'fastify' import { buildPaginator } from '../../helper/pagination/build-paginator' @@ -16,13 +16,23 @@ export const agentConversationService = (log: FastifyBaseLogger) => ({ const agent = isNil(request.agentId) ? null : await agentService(log).getOneOrThrowByPlatform({ id: request.agentId, platformId, userId }) + const builder = request.builder === true + const builderProjectId = builder + ? await resolveBuilderProject({ agent, requestedProjectId: request.projectId, platformId, userId, log }) + : null + const existingBuilder = builder && !isNil(agent) + ? await agentHelpers.conversationRepo().findOneBy({ agentId: agent.id, userId, platformId, source: AgentRunSource.AGENT_BUILDER }) + : null + if (!isNil(existingBuilder)) { + return existingBuilder + } const conversation = await agentHelpers.conversationRepo().save({ id: id ?? apId(), platformId, - projectId: agent?.projectId ?? null, + projectId: agent?.projectId ?? builderProjectId, userId, agentId: agent?.id ?? null, - source: isNil(agent) ? AgentRunSource.CHAT : AgentRunSource.AGENT, + source: builder ? AgentRunSource.AGENT_BUILDER : isNil(agent) ? AgentRunSource.CHAT : AgentRunSource.AGENT, title: request.title ?? null, modelName: request.modelName ?? null, messages: [], @@ -82,7 +92,7 @@ export const agentConversationService = (log: FastifyBaseLogger) => ({ throw new ActivepiecesError({ code: ErrorCode.ENTITY_NOT_FOUND, params: { entityId: id, entityType: 'AgentConversation' } }) } const conversation = await agentHelpers.getConversationOrThrow({ id, platformId, userId, log }) - if (![AgentRunSource.CHAT, AgentRunSource.AGENT].includes(conversation.source)) { + if (![AgentRunSource.CHAT, AgentRunSource.AGENT, AgentRunSource.AGENT_BUILDER].includes(conversation.source)) { throw new ActivepiecesError({ code: ErrorCode.ENTITY_NOT_FOUND, params: { entityId: id, entityType: 'AgentConversation' } }) } return conversation @@ -166,6 +176,32 @@ type ListConversationsParams = { agentId?: string } +async function resolveBuilderProject({ agent, requestedProjectId, platformId, userId, log }: { + agent: Agent | null + requestedProjectId?: string + platformId: string + userId: string + log: FastifyBaseLogger +}): Promise { + if (!isNil(agent)) { + return agent.projectId + } + if (isNil(requestedProjectId)) { + throw new ActivepiecesError({ + code: ErrorCode.VALIDATION, + params: { message: 'A builder conversation needs either an agentId to change or a projectId to build in' }, + }) + } + const projects = await agentHelpers.getUserProjects({ platformId, userId, log }) + if (!projects.some((project) => project.id === requestedProjectId)) { + throw new ActivepiecesError({ + code: ErrorCode.ENTITY_NOT_FOUND, + params: { entityId: requestedProjectId, entityType: 'Project' }, + }) + } + return requestedProjectId +} + type ConversationIdentifier = { id: string platformId: string diff --git a/packages/server/api/src/app/ee/agent/agent-draft-ai.ts b/packages/server/api/src/app/ee/agent/agent-draft-ai.ts index 29b8c252ecab..46de3939fe9a 100644 --- a/packages/server/api/src/app/ee/agent/agent-draft-ai.ts +++ b/packages/server/api/src/app/ee/agent/agent-draft-ai.ts @@ -2,9 +2,12 @@ import { readFileSync } from 'node:fs' import path from 'node:path' import { ActivepiecesError, AIProviderName, apId, ErrorCode, isNil, PlatformId, ProjectId, tryCatch, tryCatchSync } from '@activepieces/core-utils' import { agentAiUtils } from '@activepieces/server-utils' -import { CHAT_BYOK_CREDIT_WEIGHT, DEFAULT_CHAT_TIER_ID, DraftAgentResponse, isAppSumoCreditedPlan } from '@activepieces/shared' +import { AgentDraftFields, AgentTool, AgentToolType, CHAT_BYOK_CREDIT_WEIGHT, DEFAULT_CHAT_TIER_ID, DraftAgentResponse, isAppSumoCreditedPlan, MAX_SUGGESTED_AGENT_TOOLS, mcpToolNameUtils } from '@activepieces/shared' import { APICallError, generateText, LanguageModel } from 'ai' import { FastifyBaseLogger } from 'fastify' +import { z } from 'zod' +import { appConnectionService } from '../../app-connection/app-connection-service/app-connection-service' +import { pieceMetadataService } from '../../pieces/metadata/piece-metadata-service' import { trackBillingAndSendTelemetry } from '../../platform/billing-and-telemetry' import { CreditUsageSource } from '../../platform/billing-provider' import { platformPlanService } from '../platform/platform-plan/platform-plan.service' @@ -14,9 +17,14 @@ const DRAFT_TIMEOUT_MS = 30_000 const REPLY_LOG_LIMIT = 500 const REASON_LIMIT = 200 const FAST_TIER_ID = 'fast' +const CANDIDATE_PIECE_LIMIT = 8 const DRAFT_SYSTEM_PROMPT = readFileSync(path.resolve('packages/server/api/src/assets/prompts/agent-draft-prompt.md'), 'utf8') export const agentDraftAi = (log: FastifyBaseLogger) => ({ + async candidatesForProject({ projectId, platformId }: { projectId: ProjectId, platformId: PlatformId }): Promise { + return connectedCandidates({ projectId, platformId, log }) + }, + async draft({ platformId, projectId, prompt }: DraftParams): Promise { const { data: resolved, error: modelError } = await tryCatch(() => agentHelpers.resolveTierModel({ platformId, tierId: FAST_TIER_ID, scope: agentHelpers.runScopeOrThrow({ projectId }), log })) if (!isNil(modelError) || isNil(resolved)) { @@ -29,13 +37,14 @@ export const agentDraftAi = (log: FastifyBaseLogger) => ({ // Drafting asks for the cheap tier, which is a different model from the one chat runs on, so // an account that can serve one and not the other has working chat and failing drafts. A // refused key will refuse again, but anything else is worth one attempt on chat's own model. - let attempt = await runDraft({ model: resolved.model, prompt }) + const candidates = await connectedCandidates({ projectId, platformId, log }) + let attempt = await runDraft({ model: resolved.model, prompt: withCandidates({ prompt, candidates }) }) let usedModelId = resolved.modelId if (!isNil(attempt.error) && !rejectedCredentials(statusOf(attempt.error))) { const { data: fallback } = await tryCatch(() => agentHelpers.resolveTierModel({ platformId, tierId: DEFAULT_CHAT_TIER_ID, scope: agentHelpers.runScopeOrThrow({ projectId }), log })) if (!isNil(fallback) && fallback.modelId !== resolved.modelId) { log.warn({ from: resolved.modelId, to: fallback.modelId, platform: { id: platformId } }, '[agentDraftAi] Retrying the draft on the model chat runs on') - attempt = await runDraft({ model: fallback.model, prompt }) + attempt = await runDraft({ model: fallback.model, prompt: withCandidates({ prompt, candidates }) }) usedModelId = fallback.modelId } } @@ -62,10 +71,70 @@ export const agentDraftAi = (log: FastifyBaseLogger) => ({ }) } await debitDraft({ platformId, projectId, log }) - return parsed + return { + ...parsed, + tools: resolveToolPicks({ picks: parsed.tools, candidates }), + provider: resolved.provider, + modelName: agentHelpers.resolveModelIdForProvider({ provider: resolved.provider, selectedModel: DEFAULT_CHAT_TIER_ID }), + } }, }) +// Only what the project already has a connection for is offered, so a drafted agent can run rather +// than arriving with tools nobody has signed into. Everything the model names is looked up again +// below: a piece or action it invented is dropped, never stored. +async function connectedCandidates({ projectId, platformId, log }: { projectId: ProjectId, platformId: PlatformId, log: FastifyBaseLogger }): Promise { + const { data: connected } = await tryCatch(() => appConnectionService(log).listConnectedPieces({ projectId, platformId, limit: CANDIDATE_PIECE_LIMIT })) + const resolved = await Promise.all((connected ?? []).map(async ({ pieceName, externalId: connectionExternalId }) => { + const { data: piece } = await tryCatch(() => pieceMetadataService(log).get({ name: pieceName, projectId, platformId })) + if (isNil(piece)) { + return [] + } + const candidate: Candidate = { + pieceName, + pieceVersion: piece.version, + connectionExternalId, + actionNames: Object.keys(piece.actions), + } + return [candidate] + })) + return resolved.flat().filter((candidate) => candidate.actionNames.length > 0) +} + +function withCandidates({ prompt, candidates }: { prompt: string, candidates: Candidate[] }): string { + if (candidates.length === 0) { + return `${prompt}\n\nConnected apps: none. Return an empty tools list.` + } + const listed = candidates.map((candidate) => `${candidate.pieceName} (${candidate.actionNames.join(', ')})`).join('\n') + return `${prompt}\n\nConnected apps:\n${listed}` +} + +function resolveToolPicks({ picks, candidates }: { picks: DraftReply['tools'], candidates: Candidate[] }): AgentTool[] { + const seen = new Set() + return picks.flatMap((pick) => { + const candidate = candidates.find((entry) => entry.pieceName === pick.pieceName) + if (isNil(candidate) || !candidate.actionNames.includes(pick.actionName)) { + return [] + } + const key = `${candidate.pieceName}:${pick.actionName}` + if (seen.has(key)) { + return [] + } + seen.add(key) + const tool: AgentTool = { + type: AgentToolType.PIECE, + toolName: mcpToolNameUtils.createPieceToolName(candidate.pieceName, pick.actionName), + pieceMetadata: { + pieceName: candidate.pieceName, + pieceVersion: candidate.pieceVersion, + actionName: pick.actionName, + predefinedInput: { auth: candidate.connectionExternalId, fields: {} }, + }, + } + return [tool] + }).slice(0, MAX_SUGGESTED_AGENT_TOOLS) +} + // The telemetry sink renders the SDK's wrapped provider failure as "[object Object]". // 401 is the key itself being refused, and the body says so in terms written for whoever holds it // rather than whoever configured it: OpenRouter answers "User not found". 403 is a key that @@ -103,7 +172,7 @@ function describeError(error: unknown): string { return parts.filter((part) => part.length > 0).join(' | ') } -function parseDraft(raw: string): DraftAgentResponse | null { +function parseDraft(raw: string): DraftReply | null { const start = raw.indexOf('{') const end = raw.lastIndexOf('}') if (start === -1 || end <= start) { @@ -113,7 +182,7 @@ function parseDraft(raw: string): DraftAgentResponse | null { if (!isNil(error)) { return null } - const parsed = DraftAgentResponse.safeParse(json) + const parsed = DraftReply.safeParse(json) return parsed.success ? parsed.data : null } @@ -141,6 +210,21 @@ async function debitDraft({ platformId, projectId, log }: { platformId: Platform } } +const DraftReply = AgentDraftFields.extend({ + tools: z.array(z.object({ pieceName: z.string(), actionName: z.string() })).default([]), +}) + +export const agentDraftTools = { withCandidates, resolveToolPicks } + +type DraftReply = z.infer + +type Candidate = { + pieceName: string + pieceVersion: string + connectionExternalId: string + actionNames: string[] +} + type DraftParams = { platformId: PlatformId projectId: ProjectId diff --git a/packages/server/api/src/app/ee/agent/agent-helpers.ts b/packages/server/api/src/app/ee/agent/agent-helpers.ts index 1d2e8ee74efe..6ac0f921676f 100644 --- a/packages/server/api/src/app/ee/agent/agent-helpers.ts +++ b/packages/server/api/src/app/ee/agent/agent-helpers.ts @@ -226,8 +226,7 @@ async function resolveChatProviderName({ platformId, projectId, log }: { platfor if (isNil(projectId)) { return null } - const result = await tryCatch(() => aiProviderService(log).getChatProviderName({ platformId, scope: { type: 'project', projectId } })) - return result.error ? null : result.data + return aiProviderService(log).getChatProviderName({ platformId, scope: runScopeOrThrow({ projectId }) }) } async function recoverAllStaleStreamingConversations({ log }: { log: FastifyBaseLogger }): Promise<{ recovered: number }> { diff --git a/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts b/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts index 26325564f27a..b68f6b0a1df7 100644 --- a/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts +++ b/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts @@ -39,7 +39,8 @@ const CHAT_ONLY_TOOL_PREFIX = '__' const OWNER_SCOPED_TOOLS = ['ap_remember'] const ATTENDED_STATE_TOOLS = ['__cancel_check', '__approval_wait', '__store_pending_gate', '__store_selected_connection'] const CONFIGURED_TOOL_SOURCES: AgentRunSource[] = [AgentRunSource.FLOW_STEP, AgentRunSource.AGENT] -const UNATTENDED_FORBIDDEN_TOOLS = ['ap_run_code', 'ap_execute_action', 'ap_explore_data', 'ap_list_across_projects', 'ap_list_agents', 'ap_create_agent', 'ap_update_agent', 'ap_add_agent_tool', 'ap_remove_agent_tool'] +const AGENT_SURFACE_TOOLS = ['ap_list_agents', 'ap_create_agent', 'ap_update_agent', 'ap_add_agent_tool', 'ap_remove_agent_tool'] +const UNATTENDED_FORBIDDEN_TOOLS = ['ap_run_code', 'ap_execute_action', 'ap_explore_data', 'ap_list_across_projects', ...AGENT_SURFACE_TOOLS] const KNOWLEDGE_BASE_SEARCH_LIMIT = 5 const KNOWLEDGE_BASE_SIMILARITY_THRESHOLD = 0.5 @@ -83,7 +84,8 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ const isFlowStep = requestedSource === AgentRunSource.FLOW_STEP // A saved agent answers from its own instructions, so one person's remembered preferences // must not change how it behaves for everyone else who talks to it. - const carriesChatContext = requestedSource !== AgentRunSource.FLOW_STEP && requestedSource !== AgentRunSource.AGENT + const isBuilder = requestedSource === AgentRunSource.AGENT_BUILDER + const carriesChatContext = requestedSource !== AgentRunSource.FLOW_STEP && requestedSource !== AgentRunSource.AGENT && !isBuilder const [conversation, userProjects, enabledAiTools] = await Promise.all([ loadOrStartConversation({ conversationId, platformId, userId, source: requestedSource, projectId: requestedProjectId, modelName }), @@ -91,15 +93,13 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ aiToolConfigService(log).getEnabledTools({ platformId }), ]) - const [scopedMcpCredentials, runMemory, runUser, platformResult, identityResult] = !carriesChatContext - ? [{ mcpServerUrl: null, mcpToken: null }, { instructions: null, memories: [] as string[] }, null, null, null] - : await Promise.all([ - agentMcp.getCredentials({ platformId, userId, log }), - agentHelpers.getUserMemory({ platformId, userId }), - userService(log).getMetaInformation({ id: userId }), - tryCatch(() => platformService(log).getOneOrThrow(platformId)), - tryCatch(() => chatPersonalizationService(log).getIdentityEnrichment({ platformId, userId })), - ]) + const [scopedMcpCredentials, runMemory, runUser, platformResult, identityResult] = await Promise.all([ + carriesChatContext || isBuilder ? agentMcp.getCredentials({ platformId, userId, log }) : { mcpServerUrl: null, mcpToken: null }, + carriesChatContext ? agentHelpers.getUserMemory({ platformId, userId }) : { instructions: null, memories: [] as string[] }, + carriesChatContext ? userService(log).getMetaInformation({ id: userId }) : null, + carriesChatContext ? tryCatch(() => platformService(log).getOneOrThrow(platformId)) : null, + carriesChatContext ? tryCatch(() => chatPersonalizationService(log).getIdentityEnrichment({ platformId, userId })) : null, + ]) const runUserEmail = runUser?.email ?? '' const userIdentity: UserIdentity | null = isNil(runUser) ? null @@ -144,7 +144,7 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ const aiTools: GetEnabledAiToolsResponse = dryRun ? {} : enabledAiTools const actingRun = !dryRun && !discoveryOnly const emailEnabled = actingRun && carriesChatContext && smtpEmailSender(log).isSmtpConfigured() - const agentsAvailable = actingRun && carriesChatContext && await agentHelpers.agentsSurfaceAvailable({ platformId, log }) + const agentsAvailable = actingRun && (carriesChatContext || isBuilder) && await agentHelpers.agentsSurfaceAvailable({ platformId, log }) const fetchAvailable = !dryRun // Tavily takes precedence over native LLM search; native is only the no-Tavily fallback. const tavilySearchAvailable = !isNil(aiTools.webSearch) @@ -551,7 +551,10 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ } const chatOnlyTool = !ATTENDED_STATE_TOOLS.includes(input.toolName) && (input.toolName.startsWith(CHAT_ONLY_TOOL_PREFIX) || OWNER_SCOPED_TOOLS.includes(input.toolName) || UNATTENDED_FORBIDDEN_TOOLS.includes(input.toolName)) - if (chatOnlyTool && input.source !== AgentRunSource.CHAT) { + const allowedSources = AGENT_SURFACE_TOOLS.includes(input.toolName) + ? [AgentRunSource.CHAT, AgentRunSource.AGENT_BUILDER] + : [AgentRunSource.CHAT] + if (chatOnlyTool && !allowedSources.includes(input.source)) { log.error({ tool: { name: input.toolName }, source: input.source }, '[agentRpc#executeAgentTool] Rejected a chat-only tool for a non-chat run — the worker should not have called it') throw new ActivepiecesError({ code: ErrorCode.AUTHORIZATION, diff --git a/packages/server/api/src/app/ee/agent/agent-service.ts b/packages/server/api/src/app/ee/agent/agent-service.ts index 0e152118a262..1b7eb3b9f7da 100644 --- a/packages/server/api/src/app/ee/agent/agent-service.ts +++ b/packages/server/api/src/app/ee/agent/agent-service.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto' import { AgentToolType, McpAuthType } from '@activepieces/core-piece-types' import { ActivepiecesError, ApId, apId, Cursor, ErrorCode, isNil, omit, Permission, PlatformId, ProjectId, sanitizeObjectForPostgresql, SeekPage, UserId } from '@activepieces/core-utils' -import { Agent, AgentConfig, AgentSummary, agentUtils, AgentVisibility, CreateAgentRequest, DefaultProjectRole, Project, ProjectType, UpdateAgentRequest } from '@activepieces/shared' +import { Agent, AgentConfig, AgentSummary, agentUtils, AgentVisibility, CreateAgentRequest, DEFAULT_CHAT_TIER_ID, DefaultProjectRole, Project, ProjectType, UpdateAgentRequest } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import { Brackets, In, SelectQueryBuilder } from 'typeorm' import { repoFactory } from '../../core/db/repo-factory' @@ -22,8 +22,9 @@ export const agentAudit = { describePublished } export const agentRedaction = { withoutToolSecrets } export const agentService = (log: FastifyBaseLogger) => ({ - async create({ projectId, ownerId, request }: CreateParams): Promise { + async create({ platformId, projectId, ownerId, request }: CreateParams): Promise { const visibility = request.visibility ?? AgentVisibility.PROJECT + const draft = await withDefaultModel({ draft: request.draft, platformId, projectId, log }) return agentRepo().save({ id: apId(), projectId, @@ -35,7 +36,7 @@ export const agentService = (log: FastifyBaseLogger) => ({ color: request.color, visibility, sharedWithUserIds: await resolveShare({ visibility, requested: request.sharedWithUserIds, stored: [], projectId, log }), - draft: sanitizeObjectForPostgresql(request.draft), + draft: sanitizeObjectForPostgresql(draft), published: null, }) }, @@ -192,6 +193,22 @@ async function isProjectAdministrator({ projectId, userId, log }: { projectId: P return role?.name === DefaultProjectRole.ADMIN } +async function withDefaultModel({ draft, platformId, projectId, log }: { + draft: AgentConfig + platformId: PlatformId + projectId: ProjectId + log: FastifyBaseLogger +}): Promise { + if (!isNil(draft.modelName)) { + return draft + } + const provider = await agentHelpers.resolveChatProviderName({ platformId, projectId, log }) + if (isNil(provider)) { + return draft + } + return { ...draft, provider, modelName: agentHelpers.resolveModelIdForProvider({ provider, selectedModel: DEFAULT_CHAT_TIER_ID }) } +} + async function resolveShare({ visibility, requested, stored, projectId, log }: ResolveShareParams): Promise { if (visibility === AgentVisibility.PROJECT) { return [] @@ -293,6 +310,7 @@ function agentNotFound(id: ApId): ActivepiecesError { } type CreateParams = { + platformId: PlatformId projectId: ProjectId ownerId: UserId request: CreateAgentRequest diff --git a/packages/server/api/src/app/ee/agent/chat-analytics-sync.ts b/packages/server/api/src/app/ee/agent/chat-analytics-sync.ts index 6f93fe5de5eb..d7ca3456fbb8 100644 --- a/packages/server/api/src/app/ee/agent/chat-analytics-sync.ts +++ b/packages/server/api/src/app/ee/agent/chat-analytics-sync.ts @@ -144,7 +144,7 @@ async function resolveLookups({ conversations, log }: { Promise.all(uniquePlatformIds.map(async (platformId): Promise<[string, string | null]> => [platformId, await resolvePlatformName({ platformId, log })])), Promise.all(uniqueScopes.map(async (conversation): Promise<[string, AIProviderName | null]> => [ chatProviderCacheKey(conversation), - await agentHelpers.resolveChatProviderName({ platformId: conversation.platformId, projectId: conversation.projectId ?? null, log }), + await resolveProviderName({ platformId: conversation.platformId, projectId: conversation.projectId ?? null, log }), ])), ]) @@ -217,7 +217,7 @@ async function toSyncPayload({ conversation, licenseKey, log, userCache, platfor }): Promise> { const userEmail = userCache?.get(conversation.userId) ?? await resolveUserEmail({ userId: conversation.userId, log }) const platformName = platformCache?.get(conversation.platformId) ?? await resolvePlatformName({ platformId: conversation.platformId, log }) - const provider = providerCache?.get(chatProviderCacheKey(conversation)) ?? await agentHelpers.resolveChatProviderName({ platformId: conversation.platformId, projectId: conversation.projectId ?? null, log }) + const provider = providerCache?.get(chatProviderCacheKey(conversation)) ?? await resolveProviderName({ platformId: conversation.platformId, projectId: conversation.projectId ?? null, log }) const messages = agentHistory.resolveMessages({ conversation, log }) @@ -275,6 +275,11 @@ async function resolvePlatformName({ platformId, log }: { platformId: string, lo return result.error ? null : result.data.name } +async function resolveProviderName({ platformId, projectId, log }: { platformId: string, projectId: string | null, log: FastifyBaseLogger }): Promise { + const result = await tryCatch(() => agentHelpers.resolveChatProviderName({ platformId, projectId, log })) + return result.error ? null : result.data +} + type ConversationLookups = { userCache: Map platformCache: Map diff --git a/packages/server/api/src/app/ee/agent/prompt/agent-prompt.ts b/packages/server/api/src/app/ee/agent/prompt/agent-prompt.ts index a02e15d68eaf..5c006c501515 100644 --- a/packages/server/api/src/app/ee/agent/prompt/agent-prompt.ts +++ b/packages/server/api/src/app/ee/agent/prompt/agent-prompt.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' import path from 'node:path' -import { Project, ProjectType } from '@activepieces/shared' +import { isNil } from '@activepieces/core-utils' +import { Agent, AgentConfig, AgentToolType, Project, ProjectType } from '@activepieces/shared' function loadPromptTemplate(filename: string): string { return readFileSync(path.resolve(`packages/server/api/src/assets/prompts/${filename}`), 'utf8') @@ -10,6 +11,7 @@ const GUIDE_TOPICS = ['build_flow', 'one_time_task', 'error_handling', 'http_fal const PROMPT_TEMPLATES = { system: loadPromptTemplate('chat-system-prompt.md'), + builder: loadPromptTemplate('agent-builder-prompt.md'), projectSelected: loadPromptTemplate('chat-project-context-selected.md'), noProject: loadPromptTemplate('chat-project-context-none.md'), } @@ -72,8 +74,31 @@ function buildAgentSystemPrompt({ projects, currentProjectId, frontendUrl, templ .replaceAll('{{FRONTEND_URL}}', frontendUrl) } +function buildBuilderSystemPrompt({ agent }: { agent: Agent | null }): string { + const state = isNil(agent) + ? 'No agent yet. Create one as soon as you know what job it should do, then keep changing that one.' + : [ + `Agent: ${agent.displayName} (id ${agent.id})`, + `Description: ${agent.description ?? 'none yet'}`, + `Instructions: ${agent.draft.instructions.length > 0 ? agent.draft.instructions : 'none yet'}`, + `Tools: ${describeTools(agent.draft.tools)}`, + `Published: ${isNil(agent.published) ? 'never — nothing runs this agent yet' : 'yes, and the published version keeps running until a change is published'}`, + ].join('\n') + return PROMPT_TEMPLATES.builder.replace('{{AGENT_STATE}}', state) +} + +function describeTools(tools: AgentConfig['tools']): string { + if (tools.length === 0) { + return 'none' + } + return tools.map((tool) => tool.type === AgentToolType.PIECE + ? `${tool.pieceMetadata.actionName} (${tool.pieceMetadata.pieceName})` + : tool.toolName).join(', ') +} + export const agentPrompt = { buildSystemPrompt: buildAgentSystemPrompt, + buildBuilderSystemPrompt, guides: GUIDES, projectDisplayName, sources: { diff --git a/packages/server/api/src/app/ee/agent/prompt/agent-surface-notes.ts b/packages/server/api/src/app/ee/agent/prompt/agent-surface-notes.ts index 6a45827479ad..d7c25cb53905 100644 --- a/packages/server/api/src/app/ee/agent/prompt/agent-surface-notes.ts +++ b/packages/server/api/src/app/ee/agent/prompt/agent-surface-notes.ts @@ -18,13 +18,14 @@ function buildRunNotes({ source, messageSource, currentDate, searchAvailable, fe memory: RunMemory }): string { const isChat = source === AgentRunSource.CHAT + const readsTheWeb = source !== AgentRunSource.AGENT_BUILDER return (isChat && !isNil(userIdentity) ? agentUserIdentity.buildNote(userIdentity) : '') + buildCapabilitiesNote({ currentDate, - searchAvailable, - fetchAvailable, - scrapeAvailable, - imageAvailable: imageAvailable && source !== AgentRunSource.FLOW_STEP, + searchAvailable: searchAvailable && readsTheWeb, + fetchAvailable: fetchAvailable && readsTheWeb, + scrapeAvailable: scrapeAvailable && readsTheWeb, + imageAvailable: imageAvailable && source !== AgentRunSource.FLOW_STEP && readsTheWeb, emailAvailable: emailAvailable && isChat, userEmail, }) @@ -32,6 +33,7 @@ function buildRunNotes({ source, messageSource, currentDate, searchAvailable, fe + (isChat && !isNil(connections) ? buildConnectionInventoryNote(connections) : '') + (isChat ? buildMemoryNote(memory) : '') + (isChat && messageSource === 'onboarding' ? ONBOARDING_FIRST_MESSAGE_NOTE : '') + + (source === AgentRunSource.AGENT ? RECONNECT_NOTE : '') } const ONBOARDING_FIRST_MESSAGE_NOTE = [ @@ -132,6 +134,14 @@ function buildMemoryNote({ instructions, memories }: RunMemory): string { export const agentSurfaceNotes = { buildRunNotes } +const RECONNECT_NOTE = [ + '\n\n## When one of your tools cannot sign in', + 'A tool failing with unauthorized, forbidden, invalid credentials or expired token means the account behind it needs reconnecting.', + 'Say in one line which tool could not sign in, then call `ap_show_connection_picker` with that tool\'s piece and display name, which gives them a card to reconnect it. Show the card instead of explaining the problem, and never instead of saying anything.', + 'The card only offers reconnecting the account this agent already uses. It does not list other accounts and returns nothing for you to pass anywhere.', + 'If they reconnect, carry on with what you were asked. If they dismiss it, say what you cannot do without it rather than trying again.', +].join('\n') + const AGENTS_NOTE = [ '\n\n## Saved agents', 'This project can hold saved agents: named, reusable agents with their own instructions and tools, which the user can chat with and reuse.', diff --git a/packages/server/api/src/app/ee/agent/tools/agent-tools.ts b/packages/server/api/src/app/ee/agent/tools/agent-tools.ts index 86cf18a0c729..cfdd09eaeeb0 100644 --- a/packages/server/api/src/app/ee/agent/tools/agent-tools.ts +++ b/packages/server/api/src/app/ee/agent/tools/agent-tools.ts @@ -219,8 +219,9 @@ function nonEmpty(value: unknown): string | undefined { return isString(value) && value.trim().length > 0 ? value.trim() : undefined } -async function createAgentFromChat({ toolInput, projectId, userId, log }: { +async function createAgentFromChat({ toolInput, platformId, projectId, userId, log }: { toolInput: Record + platformId: string projectId: string userId: string log: FastifyBaseLogger @@ -231,6 +232,7 @@ async function createAgentFromChat({ toolInput, projectId, userId, log }: { return { error: 'An agent needs a name and instructions.' } } const agent = await agentService(log).create({ + platformId, projectId, ownerId: userId, request: { @@ -535,7 +537,7 @@ async function executeCrossProjectTool({ toolName, toolInput, platformId, userId return data.map(({ id, displayName, description, isPublished, toolCount }) => ({ agentId: id, displayName, description, published: isPublished, toolCount })) } if (toolName === 'ap_create_agent') { - return createAgentFromChat({ toolInput, projectId, userId, log }) + return createAgentFromChat({ toolInput, platformId, projectId, userId, log }) } const agentId = nonEmpty(toolInput.agentId) if (isNil(agentId)) { diff --git a/packages/server/api/src/app/flows/flow-version/piece-upgrade.module.ts b/packages/server/api/src/app/flows/flow-version/piece-upgrade.module.ts index 02d1e2cace96..3bd291352a4d 100644 --- a/packages/server/api/src/app/flows/flow-version/piece-upgrade.module.ts +++ b/packages/server/api/src/app/flows/flow-version/piece-upgrade.module.ts @@ -17,6 +17,10 @@ const pieceUpgradeController: FastifyPluginAsyncZod = async (app) => { app.post('/upgrade-pieces', UpgradeFlowPiecesRequest, async (req) => { return pieceUpgradeService(req.log).upgradeFlows(req.body) }) + + app.post('/revert-upgrade', RevertFlowPiecesRequest, async (req) => { + return pieceUpgradeService(req.log).revertFlows(req.body) + }) } async function checkAdminApiKeyPreHandler(req: FastifyRequest, res: FastifyReply): Promise { @@ -39,3 +43,14 @@ const UpgradeFlowPiecesRequest = { security: securityAccess.public(), }, } + +const RevertFlowPiecesRequest = { + schema: { + body: z.object({ + flowIds: z.array(z.string()).min(1), + }), + }, + config: { + security: securityAccess.public(), + }, +} diff --git a/packages/server/api/src/app/flows/flow-version/piece-upgrade.service.ts b/packages/server/api/src/app/flows/flow-version/piece-upgrade.service.ts index 6a164b410076..f606798713a4 100644 --- a/packages/server/api/src/app/flows/flow-version/piece-upgrade.service.ts +++ b/packages/server/api/src/app/flows/flow-version/piece-upgrade.service.ts @@ -1,6 +1,10 @@ import { isNil, spreadIfDefined, unique } from '@activepieces/core-utils' -import { FlowAction, FlowActionType, flowStructureUtil, FlowTrigger, FlowTriggerType, FlowVersion } from '@activepieces/shared' +import { ApplicationEventName, Flow, FlowAction, FlowActionType, FlowPiecesUpgradedEvent, flowStructureUtil, FlowTrigger, FlowTriggerType, FlowVersion } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' +import { repoFactory } from '../../core/db/repo-factory' +import { AuditEventEntity } from '../../ee/audit-logs/audit-event-entity' +import { applicationEvents } from '../../helper/application-events' +import { projectService } from '../../project/project-service' import { flowRepo } from '../flow/flow.repo' import { flowVersionRepo } from './flow-version.service' import { pieceUpgradeRegister } from './piece-upgrade-register' @@ -9,8 +13,113 @@ export const pieceUpgradeService = (log: FastifyBaseLogger) => ({ async upgradeFlows({ flowIds, projectId }: UpgradeFlowsParams): Promise { return Promise.all(unique(flowIds).map((flowId) => upgradeFlow({ flowId, projectId, log }))) }, + async revertFlows({ flowIds }: RevertFlowsParams): Promise { + return Promise.all(unique(flowIds).map((flowId) => revertFlow({ flowId, log }))) + }, }) +const auditEventRepo = repoFactory(AuditEventEntity) + +async function revertFlow({ flowId, log }: RevertFlowParams): Promise { + const flow = await flowRepo().findOneBy({ id: flowId }) + if (isNil(flow)) { + return { flowId, found: false, upgradedSteps: [] } + } + const platformId = await projectService(log).getPlatformId(flow.projectId) + const events = await auditEventRepo().createQueryBuilder('event') + .where('event.platformId = :platformId', { platformId }) + .andWhere('event.projectId = :projectId', { projectId: flow.projectId }) + .andWhere('event.action = :action', { action: ApplicationEventName.FLOW_PIECES_UPGRADED }) + .andWhere('event.data->>\'flowId\' = :flowId', { flowId }) + .orderBy('event.created', 'DESC') + .getMany() + const upgradeEvents = events.map((event) => FlowPiecesUpgradedEvent.shape.data.parse(event.data)) + if (upgradeEvents.length === 0) { + return { flowId, found: false, upgradedSteps: [] } + } + + const revertsByVersion = new Map>() + for (const eventData of upgradeEvents) { + const versionReverts = revertsByVersion.get(eventData.flowVersionId) ?? new Map() + for (const step of eventData.steps) { + if (step.decision === 'UPGRADED' && !isNil(step.newVersion) && !versionReverts.has(step.stepName)) { + versionReverts.set(step.stepName, { prevVersion: step.prevVersion, newVersion: step.newVersion }) + } + } + revertsByVersion.set(eventData.flowVersionId, versionReverts) + } + + const revertedSteps: UpgradedStep[] = [] + for (const [flowVersionId, versionReverts] of revertsByVersion) { + const flowVersion = await flowVersionRepo().findOneBy({ id: flowVersionId, flowId }) + if (isNil(flowVersion)) { + continue + } + revertedSteps.push(...await revertFlowVersion({ flow, platformId, flowVersion, versionReverts, log })) + } + return { flowId, found: true, upgradedSteps: revertedSteps } +} + +async function revertFlowVersion({ flow, platformId, flowVersion, versionReverts, log }: RevertFlowVersionParams): Promise { + const steps = flowStructureUtil.getAllSteps(flowVersion.trigger) + const applied = steps.flatMap((step) => { + if (step.type !== FlowActionType.PIECE && step.type !== FlowTriggerType.PIECE) { + return [] + } + const revert = versionReverts.get(step.name) + const usedStepName = getUsedStepName(step) + if (isNil(revert) || isNil(usedStepName) || step.settings.pieceVersion !== revert.newVersion) { + return [] + } + return [{ + flowVersionId: flowVersion.id, + stepName: step.name, + pieceName: step.settings.pieceName, + actionOrTriggerName: usedStepName, + fromVersion: revert.newVersion, + toVersion: revert.prevVersion, + }] + }) + if (applied.length === 0) { + return [] + } + const stepNameToPrevVersion = Object.fromEntries(applied.map((step) => [step.stepName, step.toVersion])) + const newFlowVersion = flowStructureUtil.transferFlow(flowVersion, (step) => { + const prevVersion = stepNameToPrevVersion[step.name] + if (isNil(prevVersion)) { + return step + } + return { + ...step, + settings: { + ...step.settings, + pieceVersion: prevVersion, + }, + } + }) + const updated = await updateTriggerIfUnchanged({ flowVersion, newTrigger: newFlowVersion.trigger }) + if (!updated) { + log.warn({ flowVersion: { id: flowVersion.id } }, '[pieceUpgradeService] flow version changed concurrently, skipping revert') + return [] + } + + applicationEvents(log).sendUserEvent({ platformId, projectId: flow.projectId }, { + action: ApplicationEventName.FLOW_PIECES_REVERTED, + data: { + flowId: flow.id, + flowVersionId: flowVersion.id, + steps: applied.map((step) => ({ + stepName: step.stepName, + actionOrTriggerName: step.actionOrTriggerName, + prevVersion: step.fromVersion, + newVersion: step.toVersion, + })), + }, + }) + + return applied.map(({ actionOrTriggerName: _actionOrTriggerName, ...upgradedStep }) => upgradedStep) +} + async function upgradeFlow({ flowId, projectId, log }: UpgradeFlowParams): Promise { const flow = await flowRepo().findOneBy({ id: flowId, ...spreadIfDefined('projectId', projectId) }) if (isNil(flow)) { @@ -26,50 +135,78 @@ async function upgradeFlow({ flowId, projectId, log }: UpgradeFlowParams): Promi if (isNil(version)) { continue } - upgradedSteps.push(...await upgradeFlowVersion({ flowVersion: version, log })) + upgradedSteps.push(...await upgradeFlowVersion({ flow, flowVersion: version, log })) } return { flowId, found: true, upgradedSteps } } -async function upgradeFlowVersion({ flowVersion, log }: UpgradeFlowVersionParams): Promise { +async function upgradeFlowVersion({ flow, flowVersion, log }: UpgradeFlowVersionParams): Promise { const steps = flowStructureUtil.getAllSteps(flowVersion.trigger) - const upgradedSteps: UpgradedStep[] = [] + const decisions: StepUpgradeDecision[] = [] for (const step of steps) { - const upgradedVersion = await resolveStepUpgrade({ step, flowVersion, log }) - if (!isNil(upgradedVersion)) { - upgradedSteps.push({ - flowVersionId: flowVersion.id, - stepName: step.name, - pieceName: step.settings.pieceName, - fromVersion: step.settings.pieceVersion, - toVersion: upgradedVersion, - }) + const decision = await resolveStepDecision({ step, flowVersion, log }) + if (!isNil(decision)) { + decisions.push(decision) } } - if (upgradedSteps.length === 0) { + if (decisions.length === 0) { return [] } - const stepNameToNewVersion = Object.fromEntries(upgradedSteps.map((upgrade) => [upgrade.stepName, upgrade.toVersion])) - const newFlowVersion = flowStructureUtil.transferFlow(flowVersion, (step) => { - const newVersion = stepNameToNewVersion[step.name] - if (isNil(newVersion)) { - return step - } - return { - ...step, - settings: { - ...step.settings, - pieceVersion: newVersion, - }, + const upgraded = decisions.filter((decision): decision is UpgradedStepDecision => decision.decision === 'UPGRADED') + if (upgraded.length > 0) { + const stepNameToNewVersion = Object.fromEntries(upgraded.map((decision) => [decision.stepName, decision.newVersion])) + const newFlowVersion = flowStructureUtil.transferFlow(flowVersion, (step) => { + const newVersion = stepNameToNewVersion[step.name] + if (isNil(newVersion)) { + return step + } + return { + ...step, + settings: { + ...step.settings, + pieceVersion: newVersion, + }, + } + }) + const updated = await updateTriggerIfUnchanged({ flowVersion, newTrigger: newFlowVersion.trigger }) + if (!updated) { + log.warn({ flowVersion: { id: flowVersion.id } }, '[pieceUpgradeService] flow version changed concurrently, skipping upgrade') + return [] } + } + + const platformId = await projectService(log).getPlatformId(flow.projectId) + applicationEvents(log).sendUserEvent({ platformId, projectId: flow.projectId }, { + action: ApplicationEventName.FLOW_PIECES_UPGRADED, + data: { + flowId: flow.id, + flowVersionId: flowVersion.id, + steps: decisions.map(toLogStep), + }, }) - await flowVersionRepo().update(flowVersion.id, { trigger: newFlowVersion.trigger }) - return upgradedSteps + + return upgraded.map((decision) => ({ + flowVersionId: flowVersion.id, + stepName: decision.stepName, + pieceName: decision.pieceName, + fromVersion: decision.prevVersion, + toVersion: decision.newVersion, + })) +} + +function toLogStep(decision: StepUpgradeDecision): PieceUpgradeAuditStep { + return { + stepName: decision.stepName, + actionOrTriggerName: decision.actionOrTriggerName, + decision: decision.decision, + prevVersion: decision.prevVersion, + newVersion: decision.newVersion, + } } -async function resolveStepUpgrade({ step, flowVersion, log }: ResolveStepUpgradeParams): Promise { +async function resolveStepDecision({ step, flowVersion, log }: ResolveStepDecisionParams): Promise { if (step.type !== FlowActionType.PIECE && step.type !== FlowTriggerType.PIECE) { return undefined } @@ -90,17 +227,33 @@ async function resolveStepUpgrade({ step, flowVersion, log }: ResolveStepUpgrade return undefined } + const base = { + stepName: step.name, + pieceName, + actionOrTriggerName: usedStepName, + prevVersion: pieceVersion, + } const decision = pieceUpgradeRegister.resolveDecision({ entry, usedStepName }) switch (decision.outcome) { case 'upgraded': log.info({ ...logContext, upgrade: { toVersion: decision.toVersion } }, '[pieceUpgradeService] piece upgrade pass') - return decision.toVersion + return { ...base, decision: 'UPGRADED', newVersion: decision.toVersion } case 'kept': log.warn({ ...logContext, upgrade: { target: entry.target, flaggedStep: usedStepName } }, '[pieceUpgradeService] step flagged unsafe in upgrade register, keeping current version') - return undefined + return { ...base, decision: 'KEPT', newVersion: null } } } +async function updateTriggerIfUnchanged({ flowVersion, newTrigger }: UpdateTriggerIfUnchangedParams): Promise { + const updateResult = await flowVersionRepo().createQueryBuilder() + .update() + .set({ trigger: newTrigger }) + .where('id = :id', { id: flowVersion.id }) + .andWhere('trigger = CAST(:snapshot AS jsonb)', { snapshot: JSON.stringify(flowVersion.trigger) }) + .execute() + return updateResult.affected === 1 +} + function getUsedStepName(step: FlowAction | FlowTrigger): string | undefined { if (step.type === FlowTriggerType.PIECE) { return step.settings.triggerName ?? undefined @@ -125,11 +278,65 @@ export type FlowPieceUpgradeResult = { upgradedSteps: UpgradedStep[] } +type PieceUpgradeAuditStep = { + stepName: string + actionOrTriggerName: string + decision: 'UPGRADED' | 'KEPT' + prevVersion: string + newVersion: string | null +} + +type StepUpgradeDecisionBase = { + stepName: string + pieceName: string + actionOrTriggerName: string + prevVersion: string +} + +type UpgradedStepDecision = StepUpgradeDecisionBase & { + decision: 'UPGRADED' + newVersion: string +} + +type KeptStepDecision = StepUpgradeDecisionBase & { + decision: 'KEPT' + newVersion: null +} + +type StepUpgradeDecision = UpgradedStepDecision | KeptStepDecision + type UpgradeFlowsParams = { flowIds: string[] projectId?: string } +type RevertFlowsParams = { + flowIds: string[] +} + +type RevertFlowParams = { + flowId: string + log: FastifyBaseLogger +} + +type StepRevert = { + prevVersion: string + newVersion: string +} + +type UpdateTriggerIfUnchangedParams = { + flowVersion: FlowVersion + newTrigger: FlowVersion['trigger'] +} + +type RevertFlowVersionParams = { + flow: Flow + platformId: string + flowVersion: FlowVersion + versionReverts: Map + log: FastifyBaseLogger +} + type UpgradeFlowParams = { flowId: string projectId?: string @@ -137,11 +344,12 @@ type UpgradeFlowParams = { } type UpgradeFlowVersionParams = { + flow: Flow flowVersion: FlowVersion log: FastifyBaseLogger } -type ResolveStepUpgradeParams = { +type ResolveStepDecisionParams = { step: FlowAction | FlowTrigger flowVersion: FlowVersion log: FastifyBaseLogger diff --git a/packages/server/api/src/assets/prompts/agent-builder-prompt.md b/packages/server/api/src/assets/prompts/agent-builder-prompt.md new file mode 100644 index 000000000000..1cc444b457a6 --- /dev/null +++ b/packages/server/api/src/assets/prompts/agent-builder-prompt.md @@ -0,0 +1,23 @@ +You build one saved agent, by talking to the person who wants it. You are not that agent, and you never answer as it. + +Open by saying what you will do, in one sentence, then ask for the job it should do if you were not told. + +## What you are working on + +{{AGENT_STATE}} + +## How to work + +Change the agent with your tools rather than describing what the person should click. When you have made a change, say what you changed in one line and what it now does. + +Write instructions as the agent's own standing brief, in second person. Say how to decide, not only what to do, and tell it to ask rather than guess when the input it needs is missing. + +Give it a tool only when the job needs one, and look the piece up before you name it. A tool for an app the project has no connection to will ask this person for one, so prefer what is already connected unless they say otherwise. + +Do not add a tool that sends, posts, deletes or pays without saying so plainly in the same message. Those are what run unattended once this agent is used in a flow. + +## Publishing + +What you edit is the draft. The published version is what flows and other people keep running, so a change is not live until it is published. Publish only when asked, and never imply a change is live when it is not. + +If the person asks the agent a question instead of asking you to change it, tell them the Test tab beside you is where they talk to it. diff --git a/packages/server/api/src/assets/prompts/agent-draft-prompt.md b/packages/server/api/src/assets/prompts/agent-draft-prompt.md index c7f5b30094b8..86e7042b3c1b 100644 --- a/packages/server/api/src/assets/prompts/agent-draft-prompt.md +++ b/packages/server/api/src/assets/prompts/agent-draft-prompt.md @@ -8,9 +8,13 @@ icon: pick the one that matches the work, and only use bot when nothing else fit instructions is three to five sentences addressed to the agent as "You ...". It states how to decide rather than only what to do; it states one thing the agent must never do; and it states what to do when the input it needs is missing, which is to ask rather than guess. -The agent can fetch a URL and scrape a page, and can usually search the web. It has no other tools unless someone adds them later, so never tell it to send, post, or update anything, and give it a fallback for when it cannot search. +The agent can fetch a URL and scrape a page, and can usually search the web. + +tools is what else it should be able to do, chosen only from the connected apps listed below. Pick an action only when the sentence actually calls for it, at most four, and prefer reading over writing when either would do. Name the piece and the action exactly as they are written in that list. Return an empty list when nothing there fits, and never invent a piece or an action that is not listed. + +Write instructions for the tools you picked and nothing else. If you picked no tool that sends, posts, or updates anything, do not tell the agent to do those things, and give it a fallback for when it cannot search. Reply with the JSON object and nothing else. No prose before or after it, and no code fence. -Example. Sentence: "help me follow up after customer calls" -{"displayName":"Meeting follow-up","description":"Turns notes into decisions, owners, and next steps.","icon":"calendar","color":"GREEN","instructions":"You turn meeting notes into a follow-up. Separate decisions from discussion, and give every action an owner and a date. If an action has no owner in the notes, list it as unassigned rather than guessing. If you were given no notes, ask for them instead of inventing a summary. Keep it short enough to read on a phone."} +Example. Sentence: "help me follow up after customer calls". Connected apps: @activepieces/piece-gmail (send_email, gmail_search_mail), @activepieces/piece-slack (send_channel_message) +{"displayName":"Meeting follow-up","description":"Turns notes into decisions, owners, and next steps.","icon":"calendar","color":"GREEN","tools":[{"pieceName":"@activepieces/piece-gmail","actionName":"send_email"}],"instructions":"You turn meeting notes into a follow-up. Separate decisions from discussion, and give every action an owner and a date. If an action has no owner in the notes, list it as unassigned rather than guessing. Email the summary only when you are asked to, and never to anyone outside the attendee list. If you were given no notes, ask for them instead of inventing a summary. Keep it short enough to read on a phone."} diff --git a/packages/server/api/test/integration/ce/authentication/authentication.test.ts b/packages/server/api/test/integration/ce/authentication/authentication.test.ts index 77302cafef0e..772072015310 100644 --- a/packages/server/api/test/integration/ce/authentication/authentication.test.ts +++ b/packages/server/api/test/integration/ce/authentication/authentication.test.ts @@ -1,5 +1,7 @@ +import { UserIdentityProvider } from '@activepieces/shared' import { FastifyInstance } from 'fastify' import { StatusCodes } from 'http-status-codes' +import { userIdentityService } from '../../../../src/app/authentication/user-identity/user-identity-service' import { databaseConnection } from '../../../../src/app/database/database-connection' import { createMockSignInRequest, @@ -26,9 +28,9 @@ beforeEach(async () => { }) describe('Authentication API', () => { describe('Sign up Endpoint', () => { - it('Adds new user with onboarding token', async () => { + it('Signs the new member in with a platform of their own', async () => { // arrange - const mockSignUpRequest = createMockSignUpRequest() + const mockSignUpRequest = createMockSignUpRequest({ email: 'ahmad.tash@activepieces.com' }) // act const response = await app?.inject({ @@ -49,15 +51,15 @@ describe('Authentication API', () => { expect(responseBody?.trackEvents).toBe(mockSignUpRequest.trackEvents) expect(responseBody?.newsLetter).toBe(mockSignUpRequest.newsLetter) expect(responseBody?.status).toBe('ACTIVE') - expect(responseBody?.platformId).toBeNull() expect(responseBody?.externalId).toBe(null) - expect(responseBody?.projectId).toBeNull() + expect(responseBody?.platformId).not.toBeNull() + expect(responseBody?.projectId).not.toBeNull() expect(responseBody?.token).toBeDefined() }) - it('Does not create project or platform on signup', async () => { + it('Creates the platform and project from the name given at sign up', async () => { // arrange - const mockSignUpRequest = createMockSignUpRequest() + const mockSignUpRequest = createMockSignUpRequest({ email: 'ahmad.tash@activepieces.com' }) // act const response = await app?.inject({ @@ -69,19 +71,22 @@ describe('Authentication API', () => { // assert expect(response?.statusCode).toBe(StatusCodes.OK) + const platform = await databaseConnection().getRepository('platform').findOneBy({ id: response?.json()?.platformId }) + expect(platform?.name).toBe('Activepieces') + const platformCount = await databaseConnection().getRepository('platform').count() const projectCount = await databaseConnection().getRepository('project').count() - expect(platformCount).toBe(0) - expect(projectCount).toBe(0) + expect(platformCount).toBe(1) + expect(projectCount).toBe(1) }) }) describe('Sign in Endpoint', () => { - it('Logs in with onboarding token when no platform exists', async () => { + it('Signs in to the platform created at sign up', async () => { // arrange const mockSignUpRequest = createMockSignUpRequest() - await app?.inject({ + const signUpResponse = await app?.inject({ method: 'POST', url: '/api/v1/authentication/sign-up', body: mockSignUpRequest, @@ -102,10 +107,71 @@ describe('Authentication API', () => { // assert const responseBody = response?.json() + expect(response?.statusCode).toBe(StatusCodes.OK) + expect(responseBody?.platformId).toBe(signUpResponse?.json()?.platformId) + expect(responseBody?.projectId).toBe(signUpResponse?.json()?.projectId) + expect(await databaseConnection().getRepository('platform').count()).toBe(1) + }) + + it('Creates the platform for a member who verified their email before signing in', async () => { + // arrange + const password = 'password-that-verifies' + await userIdentityService(app!.log).create({ + email: 'ahmad.tash@activepieces.com', + password, + firstName: 'Ahmad', + lastName: 'Tash', + trackEvents: false, + newsLetter: false, + provider: UserIdentityProvider.EMAIL, + verified: true, + }) + + // act + const response = await app?.inject({ + method: 'POST', + url: '/api/v1/authentication/sign-in', + body: createMockSignInRequest({ email: 'ahmad.tash@activepieces.com', password }), + }) + + // assert + const responseBody = response?.json() + + expect(response?.statusCode).toBe(StatusCodes.OK) + expect(responseBody?.platformId).not.toBeNull() + expect(responseBody?.projectId).not.toBeNull() + expect(responseBody?.firstName).toBe('Ahmad') + expect(responseBody?.lastName).toBe('Tash') + }) + + it('Hands a pre-platform session to a member whose name was only guessed from their address', async () => { + // arrange + const password = 'password-that-verifies' + await userIdentityService(app!.log).create({ + email: 'ahmad.tash@activepieces.com', + password, + firstName: 'Ahmad', + lastName: '', + trackEvents: false, + newsLetter: false, + provider: UserIdentityProvider.EMAIL, + verified: true, + }) + + // act + const response = await app?.inject({ + method: 'POST', + url: '/api/v1/authentication/sign-in', + body: createMockSignInRequest({ email: 'ahmad.tash@activepieces.com', password }), + }) + + // assert + const responseBody = response?.json() + expect(response?.statusCode).toBe(StatusCodes.OK) expect(responseBody?.platformId).toBeNull() expect(responseBody?.projectId).toBeNull() - expect(responseBody?.token).toBeDefined() + expect(await databaseConnection().getRepository('platform').count()).toBe(0) }) it('Fails if password doesn\'t match', async () => { diff --git a/packages/server/api/test/integration/ce/authentication/passwordless-authn.test.ts b/packages/server/api/test/integration/ce/authentication/passwordless-authn.test.ts index 6ef4fa58617b..84ca86674783 100644 --- a/packages/server/api/test/integration/ce/authentication/passwordless-authn.test.ts +++ b/packages/server/api/test/integration/ce/authentication/passwordless-authn.test.ts @@ -229,6 +229,29 @@ describe('Passwordless Authentication API', () => { expect(await databaseConnection().getRepository('platform').count()).toBe(0) }) + it('skips the name step for a member whose name we already have', async () => { + await userIdentityService(app!.log).create({ + email: EMAIL, + password: 'password-that-verifies', + firstName: 'Ahmad', + lastName: 'Tash', + trackEvents: false, + newsLetter: false, + provider: UserIdentityProvider.EMAIL, + verified: true, + }) + await requestCode(EMAIL) + const otp = await storedOtp(EMAIL) + + const response = await verifyCode({ email: EMAIL, code: otp!.value }) + + expect(response?.statusCode).toBe(StatusCodes.OK) + const body = response?.json() + expect(body?.platformId).not.toBeNull() + expect(body?.projectId).not.toBeNull() + expect(await databaseConnection().getRepository('platform').count()).toBe(1) + }) + it('names the platform after the company on a work address', async () => { await requestCode(EMAIL) const otp = await storedOtp(EMAIL) diff --git a/packages/server/api/test/integration/cloud/authn/cloud-authn.test.ts b/packages/server/api/test/integration/cloud/authn/cloud-authn.test.ts index 46ec6a06f215..2b2f9aaacebb 100644 --- a/packages/server/api/test/integration/cloud/authn/cloud-authn.test.ts +++ b/packages/server/api/test/integration/cloud/authn/cloud-authn.test.ts @@ -1,13 +1,13 @@ import { ProjectRole } from '@activepieces/core-utils' -import { ApEdition, DefaultProjectRole, InvitationStatus, InvitationType, OtpType, PlatformRole, Principal, PrincipalType, ProjectType, UserStatus } from '@activepieces/shared' +import { ApEdition, DefaultProjectRole, InvitationStatus, InvitationType, OtpType, PlatformRole, ProjectType, UserStatus } from '@activepieces/shared' import { faker } from '@faker-js/faker' import dayjs from 'dayjs' import { FastifyBaseLogger, FastifyInstance } from 'fastify' import { StatusCodes } from 'http-status-codes' import { Mock } from 'vitest' +import { signupNames } from '../../../../src/app/authentication/lib/signup-names' import { databaseConnection } from '../../../../src/app/database/database-connection' import * as emailServiceFile from '../../../../src/app/ee/helper/email/email-service' -import { jwtUtils } from '../../../../src/app/helper/jwt-utils' import { system } from '../../../../src/app/helper/system/system' import { decodeToken } from '../../../helpers/auth' import { db } from '../../../helpers/db' @@ -469,7 +469,7 @@ describe('Authentication API', () => { expect(responseBody?.code).toBe('INVALID_CREDENTIALS') }) - it('Onboarding response if user status is INACTIVE', async () => { + it('Refuses a deactivated member instead of starting them over', async () => { // arrange const mockEmail = faker.internet.email() const mockPassword = 'password' @@ -523,13 +523,8 @@ describe('Authentication API', () => { const responseBody = response?.json() // assert - // In non-cloud editions, the sign-in fails with FORBIDDEN because the platform - // is not found via Host header resolution. In cloud edition, it returns onboarding response for the user so he can create new platform. - expect([StatusCodes.OK]).toContain(response?.statusCode) - expect(responseBody?.token).toBeDefined() - const decoded = jwtUtils.decode({ jwt: responseBody?.token }) - expect(decoded.payload.type).toBe(PrincipalType.ONBOARDING) - + expect(response?.statusCode).toBe(StatusCodes.FORBIDDEN) + expect(responseBody?.code).toBe('USER_IS_INACTIVE') }) it('Fails If the email auth is not enabled', async () => { @@ -632,9 +627,13 @@ describe('Authentication API', () => { async function getOnboardingToken(): Promise { const password = faker.internet.password() + const email = faker.internet.email().toLowerCase() const mockUserIdentity = createMockUserIdentity({ + email, password, verified: true, + firstName: signupNames.firstNameFromEmail(email), + lastName: '', }) await db.save('user_identity', mockUserIdentity) diff --git a/packages/server/api/test/integration/ee/agent/agent-builder-conversation.test.ts b/packages/server/api/test/integration/ee/agent/agent-builder-conversation.test.ts new file mode 100644 index 000000000000..cb2932bc1f43 --- /dev/null +++ b/packages/server/api/test/integration/ee/agent/agent-builder-conversation.test.ts @@ -0,0 +1,185 @@ +import { AIProviderName } from '@activepieces/core-utils' +import { AgentIcon, AgentRunSource, ColorName } from '@activepieces/shared' +import { FastifyInstance } from 'fastify' +import { StatusCodes } from 'http-status-codes' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { agentRpcHandlers } from '../../../../src/app/ee/agent/agent-rpc-handlers' +import { agentPrompt } from '../../../../src/app/ee/agent/prompt/agent-prompt' +import { db } from '../../../helpers/db' +import { mockAndSaveAIProvider } from '../../../helpers/mocks' +import { createTestContext, TestContext } from '../../../helpers/test-context' +import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' + +let app: FastifyInstance + +const CONVERSATIONS_URL = '/v1/agents/conversations' + +beforeAll(async () => { + process.env.AP_AGENTS_ENABLED = 'true' + app = await setupTestEnvironment() +}) + +afterAll(async () => { + await teardownTestEnvironment() +}) + +async function context(): Promise { + return createTestContext(app, { plan: { agentsEnabled: true, chatEnabled: true } }) +} + +async function createAgent(ctx: TestContext) { + const response = await ctx.post('/v1/agents', { + projectId: ctx.project.id, + displayName: 'Inbox triage', + description: 'Sorts unread mail.', + icon: AgentIcon.MAIL, + color: ColorName.BLUE, + draft: { instructions: 'Sort unread mail.', provider: null, providerConfigId: null, modelName: null, maxSteps: 5, tools: [], structuredOutput: [] }, + }) + expect(response.statusCode).toBe(StatusCodes.CREATED) + return response.json() +} + +describe('starting a builder conversation', () => { + it('takes the project from the agent it is going to change', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + + const response = await ctx.post(CONVERSATIONS_URL, { agentId: agent.id, builder: true }) + + expect(response.statusCode).toBe(StatusCodes.CREATED) + expect(response.json().source).toBe(AgentRunSource.AGENT_BUILDER) + expect(response.json().projectId).toBe(ctx.project.id) + expect(response.json().agentId).toBe(agent.id) + }) + + it('builds a new agent in a project the caller names', async () => { + const ctx = await context() + + const response = await ctx.post(CONVERSATIONS_URL, { builder: true, projectId: ctx.project.id }) + + expect(response.statusCode).toBe(StatusCodes.CREATED) + expect(response.json().source).toBe(AgentRunSource.AGENT_BUILDER) + expect(response.json().projectId).toBe(ctx.project.id) + expect(response.json().agentId).toBeNull() + }) + + it('refuses a builder with nothing to build and nowhere to build it', async () => { + const ctx = await context() + + const response = await ctx.post(CONVERSATIONS_URL, { builder: true }) + + expect(response.statusCode).toBe(StatusCodes.CONFLICT) + expect(response.json().params.message).toContain('agentId') + }) + + it('refuses a project the caller cannot reach', async () => { + const stranger = await context() + const owner = await context() + + const response = await stranger.post(CONVERSATIONS_URL, { builder: true, projectId: owner.project.id }) + + expect(response.statusCode).toBe(StatusCodes.NOT_FOUND) + }) + + it('leaves an ordinary agent conversation on the agent source', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + + const response = await ctx.post(CONVERSATIONS_URL, { agentId: agent.id }) + + expect(response.json().source).toBe(AgentRunSource.AGENT) + }) + + it('stays out of the chat list and out of the agent history', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + const builder = await ctx.post(CONVERSATIONS_URL, { agentId: agent.id, builder: true }) + const builderId = builder.json().id + + const chatList = await ctx.get(CONVERSATIONS_URL) + const agentList = await ctx.get(CONVERSATIONS_URL, { agentId: agent.id }) + + expect(chatList.json().data.map((row: { id: string }) => row.id)).not.toContain(builderId) + expect(agentList.json().data.map((row: { id: string }) => row.id)).not.toContain(builderId) + }) +}) + +describe('whose model a builder run answers on', () => { + // An agent that names no model cannot be talked to, and that is the point of the guard. The + // builder is not that agent: refusing it here would leave a half-made agent unbuildable, which + // is exactly the state the builder exists to get you out of. + it('builds an agent that names no model, where talking to that agent is refused', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + await mockAndSaveAIProvider({ platformId: ctx.platform.id, provider: AIProviderName.OPENROUTER, enabledForChat: true }) + + const asAgent = await ctx.post(CONVERSATIONS_URL, { agentId: agent.id }) + const refused = await ctx.post(`${CONVERSATIONS_URL}/${asAgent.json().id}/messages`, { content: 'hello' }) + + const asBuilder = await ctx.post(CONVERSATIONS_URL, { agentId: agent.id, builder: true }) + const accepted = await ctx.post(`${CONVERSATIONS_URL}/${asBuilder.json().id}/messages`, { content: 'give it a gmail tool' }) + + expect(refused.statusCode).toBe(StatusCodes.CONFLICT) + expect(refused.json().params.message).toContain('Pick a model') + expect(accepted.statusCode).toBe(StatusCodes.OK) + }) +}) + +describe('what the builder can actually reach at run time', () => { + // The worker lists ap_research_pieces and ap_list_connections for the builder, and those come + // from the project MCP set, which only exists when the run carries MCP credentials. The tool + // policy test cannot see this: it hand-builds the group. So assert the config the worker is + // handed, which is where the tools either exist or silently do not. + it('is handed the mcp credentials its piece lookup depends on', async () => { + const ctx = await context() + const saved = await mockAndSaveAIProvider({ platformId: ctx.platform.id, provider: AIProviderName.OPENROUTER }) + await db.update('ai_provider', saved.id, { enabledForChat: true }) + const agent = await createAgent(ctx) + const conversation = await ctx.post(CONVERSATIONS_URL, { agentId: agent.id, builder: true }) + + const config = await agentRpcHandlers(app.log).getAgentConfig({ + conversationId: conversation.json().id, + platformId: ctx.platform.id, + userId: ctx.user.id, + userMessage: 'give it a gmail tool', + modelName: null, + source: AgentRunSource.AGENT_BUILDER, + }) + + expect(config.mcpCredentials).not.toBeNull() + expect(config.agentsAvailable).toBe(true) + }) +}) + +describe('what the builder is told', () => { + it('is told the agent it is looking at, so the first turn does not spend a call finding out', () => { + const prompt = agentPrompt.buildBuilderSystemPrompt({ + agent: { + id: 'agent-1', + displayName: 'Inbox triage', + description: 'Sorts unread mail.', + draft: { instructions: 'Sort unread mail.', tools: [] }, + published: null, + } as never, + }) + + expect(prompt).toContain('Inbox triage') + expect(prompt).toContain('Sort unread mail.') + expect(prompt).toContain('Tools: none') + expect(prompt).toContain('nothing runs this agent yet') + }) + + it('says there is no agent yet when it is starting one', () => { + const prompt = agentPrompt.buildBuilderSystemPrompt({ agent: null }) + + expect(prompt).toContain('No agent yet') + }) + + it('never tells the builder to answer as the agent', () => { + const prompt = agentPrompt.buildBuilderSystemPrompt({ agent: null }) + + expect(prompt).toContain('You are not that agent') + expect(prompt).toContain('Test tab') + }) +}) diff --git a/packages/server/api/test/integration/ee/agent/agent-builder-gate.test.ts b/packages/server/api/test/integration/ee/agent/agent-builder-gate.test.ts new file mode 100644 index 000000000000..2cf95c663063 --- /dev/null +++ b/packages/server/api/test/integration/ee/agent/agent-builder-gate.test.ts @@ -0,0 +1,54 @@ +import { AgentRunSource, ErrorCode } from '@activepieces/shared' +import { FastifyInstance } from 'fastify' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { agentRpcHandlers } from '../../../../src/app/ee/agent/agent-rpc-handlers' +import { createTestContext } from '../../../helpers/test-context' +import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' + +let app: FastifyInstance + +const AGENT_TOOL = 'ap_add_agent_tool' + +beforeAll(async () => { + app = await setupTestEnvironment() +}) + +afterAll(async () => { + await teardownTestEnvironment() +}) + +async function refusalFor(source: AgentRunSource): Promise { + const ctx = await createTestContext(app, { plan: { agentsEnabled: true, chatEnabled: true } }) + try { + await agentRpcHandlers(app.log).executeAgentTool({ + toolName: AGENT_TOOL, + toolInput: {}, + source, + conversationId: 'does-not-exist', + platformId: ctx.platform.id, + userId: ctx.user.id, + }) + return '' + } + catch (error) { + const params = (error as { error?: { code?: string, params?: { message?: string } } }).error + return `${params?.code ?? ''} ${params?.params?.message ?? ''}` + } +} + +describe('which surfaces the server will run an agent tool for', () => { + // The worker lists these tools for the builder, so the server refusing them is the mistake this + // feature has made four times: a surface handed tools its own backend then rejects. + it('does not turn the builder away from the tools its policy lists', async () => { + const refusal = await refusalFor(AgentRunSource.AGENT_BUILDER) + + expect(refusal).not.toContain('only available to chat runs') + }) + + it('still turns away a surface that was never listed them', async () => { + const refusal = await refusalFor(AgentRunSource.AGENT) + + expect(refusal).toContain(ErrorCode.AUTHORIZATION) + expect(refusal).toContain('only available to chat runs') + }) +}) diff --git a/packages/server/api/test/integration/ee/agent/agent-connection-repair.test.ts b/packages/server/api/test/integration/ee/agent/agent-connection-repair.test.ts new file mode 100644 index 000000000000..d8cf46fc0c7a --- /dev/null +++ b/packages/server/api/test/integration/ee/agent/agent-connection-repair.test.ts @@ -0,0 +1,229 @@ +import { apId } from '@activepieces/core-utils' +import { AgentIcon, AgentTool, AgentToolType, ColorName, DefaultProjectRole, ProjectRole } from '@activepieces/shared' +import { FastifyInstance } from 'fastify' +import { StatusCodes } from 'http-status-codes' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { encryptUtils } from '../../../../src/app/helper/encryption' +import { db } from '../../../helpers/db' +import { createMockConnection, createMockProjectMember } from '../../../helpers/mocks' +import { createTestContext, TestContext } from '../../../helpers/test-context' +import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' + +let app: FastifyInstance + +const GMAIL = '@activepieces/piece-gmail' +const SLACK = '@activepieces/piece-slack' + +beforeAll(async () => { + app = await setupTestEnvironment() +}) + +afterAll(async () => { + await teardownTestEnvironment() +}) + +async function context(): Promise { + return createTestContext(app, { plan: { agentsEnabled: true, chatEnabled: true } }) +} + +async function saveConnection({ ctx, externalId }: { ctx: TestContext, externalId: string }) { + const connection = createMockConnection({ + platformId: ctx.platform.id, + projectIds: [ctx.project.id], + pieceName: GMAIL, + externalId, + displayName: externalId, + }, ctx.user.id) + await db.save('app_connection', { ...connection, value: await encryptUtils.encryptObject(connection.value) }) + return connection +} + +async function createAgent({ ctx, pinnedExternalId, extraTools = [] }: { ctx: TestContext, pinnedExternalId: string | null, extraTools?: AgentTool[] }) { + const response = await ctx.post('/v1/agents', { + projectId: ctx.project.id, + displayName: 'Email organizer', + icon: AgentIcon.MAIL, + color: ColorName.BLUE, + draft: { + instructions: 'Sort unread mail.', + provider: null, + modelName: null, + maxSteps: 5, + structuredOutput: [], + tools: [{ + type: AgentToolType.PIECE, + toolName: 'gmail-gmail_search_mail_aaaaaa_mcp', + pieceMetadata: { + pieceName: GMAIL, + pieceVersion: '0.0.0', + actionName: 'gmail_search_mail', + ...(pinnedExternalId === null ? {} : { predefinedInput: { auth: pinnedExternalId, fields: {} } }), + }, + }, ...extraTools], + }, + }) + expect(response.statusCode).toBe(StatusCodes.CREATED) + return response.json() +} + +async function pickerConnections({ ctx, conversationId, pieceName = GMAIL }: { ctx: TestContext, conversationId: string, pieceName?: string }) { + const response = await ctx.get(`/v1/agents/conversations/${conversationId}/connections?pieceName=${encodeURIComponent(pieceName)}`) + expect(response.statusCode).toBe(StatusCodes.OK) + return response.json() +} + +describe('which accounts a conversation may be offered', () => { + it('offers a saved agent only the account its own tool is pinned to', async () => { + const ctx = await context() + const pinned = await saveConnection({ ctx, externalId: apId() }) + const other = await saveConnection({ ctx, externalId: apId() }) + const agent = await createAgent({ ctx, pinnedExternalId: pinned.externalId }) + const conversation = await ctx.post('/v1/agents/conversations', { agentId: agent.id }) + + const conversationId = conversation.json().id + const body = await pickerConnections({ ctx, conversationId }) + const secondRead = await pickerConnections({ ctx, conversationId }) + + expect(body.reconnectOnly).toBe(true) + expect(body.connections.map((connection: { externalId: string }) => connection.externalId)).toEqual([pinned.externalId]) + expect(body.connections.map((connection: { externalId: string }) => connection.externalId)).not.toContain(other.externalId) + expect(secondRead).toEqual(body) + }) + + it('narrows the account the agent actually runs on, which is the published one', async () => { + const ctx = await context() + const published = await saveConnection({ ctx, externalId: apId() }) + const draftOnly = await saveConnection({ ctx, externalId: apId() }) + const agent = await createAgent({ ctx, pinnedExternalId: published.externalId }) + expect((await ctx.post(`/v1/agents/${agent.id}/publish`, {})).statusCode).toBe(StatusCodes.OK) + const moved = await ctx.post(`/v1/agents/${agent.id}`, { + draft: { ...agent.draft, tools: [{ ...agent.draft.tools[0], pieceMetadata: { ...agent.draft.tools[0].pieceMetadata, predefinedInput: { auth: draftOnly.externalId, fields: {} } } }] }, + }) + expect(moved.statusCode).toBe(StatusCodes.OK) + const conversation = await ctx.post('/v1/agents/conversations', { agentId: agent.id }) + + const body = await pickerConnections({ ctx, conversationId: conversation.json().id }) + + expect(body.connections.map((connection: { externalId: string }) => connection.externalId)).toEqual([published.externalId]) + }) + + it('falls back to the full picker where the agent pinned no account, because there is nothing to repair', async () => { + const ctx = await context() + await saveConnection({ ctx, externalId: apId() }) + const agent = await createAgent({ ctx, pinnedExternalId: null }) + const conversation = await ctx.post('/v1/agents/conversations', { agentId: agent.id }) + + const body = await pickerConnections({ ctx, conversationId: conversation.json().id }) + + expect(body.reconnectOnly).toBe(false) + }) + + it('never offers a lookalike account from another project, since externalId is not unique', async () => { + const ctx = await context() + const shared = 'gmail-shared' + const pinned = await saveConnection({ ctx, externalId: shared }) + const otherProjectId = apId() + await db.save('project', { ...ctx.project, id: otherProjectId, externalId: apId(), displayName: 'Second' }) + const role = await db.findOneByOrFail('project_role', { name: DefaultProjectRole.ADMIN }) + await db.save('project_member', createMockProjectMember({ + userId: ctx.user.id, + projectId: otherProjectId, + platformId: ctx.platform.id, + projectRoleId: role.id, + })) + const lookalike = createMockConnection({ + platformId: ctx.platform.id, + projectIds: [otherProjectId], + pieceName: GMAIL, + externalId: shared, + displayName: 'lookalike', + }, ctx.user.id) + await db.save('app_connection', { ...lookalike, value: await encryptUtils.encryptObject(lookalike.value) }) + + const asChat = await ctx.post('/v1/agents/conversations', {}) + const chatBody = await pickerConnections({ ctx, conversationId: asChat.json().id }) + expect(chatBody.connections.length).toBeGreaterThan(1) + + const agent = await createAgent({ ctx, pinnedExternalId: pinned.externalId }) + const asAgent = await ctx.post('/v1/agents/conversations', { agentId: agent.id }) + const body = await pickerConnections({ ctx, conversationId: asAgent.json().id }) + + expect(body.reconnectOnly).toBe(true) + expect(body.connections).toHaveLength(1) + expect(body.connections[0].projectId).toBe(ctx.project.id) + }) + + it('says the pinned account is gone rather than offering the rest', async () => { + const ctx = await context() + const other = await saveConnection({ ctx, externalId: apId() }) + const agent = await createAgent({ ctx, pinnedExternalId: 'a-connection-that-was-deleted' }) + const conversation = await ctx.post('/v1/agents/conversations', { agentId: agent.id }) + + const body = await pickerConnections({ ctx, conversationId: conversation.json().id }) + + expect(body.reconnectOnly).toBe(true) + expect(body.connections).toEqual([]) + expect(body.connections.map((connection: { externalId: string }) => connection.externalId)).not.toContain(other.externalId) + }) + + it('reads a pin that an older tool stored as a template, rather than calling the account gone', async () => { + const ctx = await context() + const pinned = await saveConnection({ ctx, externalId: apId() }) + const agent = await createAgent({ ctx, pinnedExternalId: `{{connections['${pinned.externalId}']}}` }) + const conversation = await ctx.post('/v1/agents/conversations', { agentId: agent.id }) + + const body = await pickerConnections({ ctx, conversationId: conversation.json().id }) + + expect(body.reconnectOnly).toBe(true) + expect(body.connections.map((connection: { externalId: string }) => connection.externalId)).toEqual([pinned.externalId]) + }) + + it('narrows only the piece that was asked about, leaving the agent other tools alone', async () => { + const ctx = await context() + const gmail = await saveConnection({ ctx, externalId: apId() }) + const slack = createMockConnection({ + platformId: ctx.platform.id, + projectIds: [ctx.project.id], + pieceName: SLACK, + externalId: apId(), + displayName: 'slack account', + }, ctx.user.id) + await db.save('app_connection', { ...slack, value: await encryptUtils.encryptObject(slack.value) }) + const agent = await createAgent({ ctx, pinnedExternalId: gmail.externalId, extraTools: [{ + type: AgentToolType.PIECE, + toolName: 'slack-send_channel_message_bbbbbb_mcp', + pieceMetadata: { pieceName: SLACK, pieceVersion: '0.0.0', actionName: 'send_channel_message' }, + }] }) + const conversation = await ctx.post('/v1/agents/conversations', { agentId: agent.id }) + const conversationId = conversation.json().id + + const gmailBody = await pickerConnections({ ctx, conversationId, pieceName: GMAIL }) + const slackBody = await pickerConnections({ ctx, conversationId, pieceName: SLACK }) + + expect(gmailBody.reconnectOnly).toBe(true) + expect(slackBody.reconnectOnly).toBe(false) + }) + + it('leaves the builder the full picker, because pinning an account is what it is for', async () => { + const ctx = await context() + const pinned = await saveConnection({ ctx, externalId: apId() }) + await saveConnection({ ctx, externalId: apId() }) + const agent = await createAgent({ ctx, pinnedExternalId: pinned.externalId }) + const conversation = await ctx.post('/v1/agents/conversations', { agentId: agent.id, builder: true }) + + const body = await pickerConnections({ ctx, conversationId: conversation.json().id }) + + expect(body.reconnectOnly).toBe(false) + expect(body.connections.length).toBeGreaterThan(1) + }) + + it('leaves a chat conversation the full picker', async () => { + const ctx = await context() + await saveConnection({ ctx, externalId: apId() }) + const conversation = await ctx.post('/v1/agents/conversations', {}) + + const body = await pickerConnections({ ctx, conversationId: conversation.json().id }) + + expect(body.reconnectOnly).toBe(false) + }) +}) diff --git a/packages/server/api/test/integration/ee/agent/agent-controller.test.ts b/packages/server/api/test/integration/ee/agent/agent-controller.test.ts index 248b786e0418..55a15a48dfbd 100644 --- a/packages/server/api/test/integration/ee/agent/agent-controller.test.ts +++ b/packages/server/api/test/integration/ee/agent/agent-controller.test.ts @@ -1,8 +1,9 @@ -import { apId } from '@activepieces/core-utils' +import { AIProviderName, apId } from '@activepieces/core-utils' import { AgentIcon, AgentVisibility, ColorName, DEFAULT_AGENT_MAX_STEPS, DefaultProjectRole, MAX_DRAFT_PROMPT_LENGTH } from '@activepieces/shared' import { FastifyInstance } from 'fastify' import { StatusCodes } from 'http-status-codes' import { db } from '../../../helpers/db' +import { mockAndSaveAIProvider } from '../../../helpers/mocks' import { createMemberContext, createTestContext, TestContext } from '../../../helpers/test-context' import { DRAFTS_PER_MINUTE } from '../../../../src/app/ee/agent/agent-controller' import { AGENT_TEMPLATES } from '../../../../src/app/ee/agent/agent-templates' @@ -45,6 +46,33 @@ afterAll(async () => { }) describe('agent crud', () => { + it('fills in the platform model when the request names none', async () => { + const ctx = await context() + await mockAndSaveAIProvider({ platformId: ctx.platform.id, provider: AIProviderName.OPENROUTER, enabledForChat: true }) + + const agent = await createAgent(ctx) + + expect(agent.draft.modelName).toBe('anthropic/claude-sonnet-4.6') + expect(agent.draft.provider).toBe(AIProviderName.OPENROUTER) + }) + + it('keeps a model the request did name, even where a default was available', async () => { + const ctx = await context() + await mockAndSaveAIProvider({ platformId: ctx.platform.id, provider: AIProviderName.OPENROUTER, enabledForChat: true }) + + const agent = await createAgent(ctx, { draft: { ...agentBody(ctx.project.id).draft, provider: AIProviderName.OPENROUTER, modelName: 'anthropic/claude-haiku-4.5' } }) + + expect(agent.draft.modelName).toBe('anthropic/claude-haiku-4.5') + }) + + it('leaves the model empty where the platform has no chat provider', async () => { + const ctx = await context() + + const agent = await createAgent(ctx) + + expect(agent.draft.modelName).toBeNull() + }) + it('creates an agent owned by the caller, in draft, unpublished', async () => { const ctx = await context() const agent = await createAgent(ctx) diff --git a/packages/server/api/test/integration/ee/agent/agent-draft-candidates.test.ts b/packages/server/api/test/integration/ee/agent/agent-draft-candidates.test.ts new file mode 100644 index 000000000000..409e1782dc5e --- /dev/null +++ b/packages/server/api/test/integration/ee/agent/agent-draft-candidates.test.ts @@ -0,0 +1,98 @@ +import { AppConnectionStatus, PackageType, PieceType } from '@activepieces/shared' +import { FastifyInstance } from 'fastify' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { agentDraftAi } from '../../../../src/app/ee/agent/agent-draft-ai' +import { db } from '../../../helpers/db' +import { createMockConnection, createMockPieceMetadata } from '../../../helpers/mocks' +import { createTestContext, TestContext } from '../../../helpers/test-context' +import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' + +let app: FastifyInstance + +const APPS = ['alpha', 'bravo', 'charlie', 'delta', 'echo', 'foxtrot', 'golf', 'hotel', 'india', 'juliet'] + +beforeAll(async () => { + app = await setupTestEnvironment() + for (const name of APPS) { + await db.save('piece_metadata', createMockPieceMetadata({ + name: `@activepieces/piece-${name}`, + displayName: name, + version: '1.0.0', + pieceType: PieceType.OFFICIAL, + packageType: PackageType.REGISTRY, + platformId: undefined, + actions: { + read_it: { name: 'read_it', displayName: 'Read', description: 'Read', requireAuth: true, props: {} }, + write_it: { name: 'write_it', displayName: 'Write', description: 'Write', requireAuth: true, props: {} }, + }, + })) + } +}) + +afterAll(async () => { + await teardownTestEnvironment() +}) + +// createMockConnection pins status to ACTIVE whatever it is passed, so a broken one is set after. +async function connect(ctx: TestContext, app_: string, status: AppConnectionStatus, count = 1): Promise { + for (let index = 0; index < count; index++) { + const connection = createMockConnection({ + projectIds: [ctx.project.id], + platformId: ctx.platform.id, + pieceName: `@activepieces/piece-${app_}`, + }, ctx.user.id) + await db.save('app_connection', connection) + if (status !== AppConnectionStatus.ACTIVE) { + await db.update('app_connection', connection.id, { status }) + } + } +} + +async function offeredApps(ctx: TestContext): Promise { + const candidates = await agentDraftAi(app.log).candidatesForProject({ projectId: ctx.project.id, platformId: ctx.platform.id }) + return candidates.map((candidate) => candidate.pieceName.replace('@activepieces/piece-', '')).sort() +} + +describe('which apps a draft may suggest tools from', () => { + it('offers an app the project has a working connection for', async () => { + const ctx = await createTestContext(app) + await connect(ctx, 'alpha', AppConnectionStatus.ACTIVE) + + expect(await offeredApps(ctx)).toEqual(['alpha']) + }) + + // A tool bound to a broken account reads as ready on the card and fails on first use, which is + // the whole reason suggestions are bounded to what is connected. + it('never offers an app whose only connection is broken', async () => { + const ctx = await createTestContext(app) + await connect(ctx, 'alpha', AppConnectionStatus.ERROR) + await connect(ctx, 'bravo', AppConnectionStatus.MISSING) + await connect(ctx, 'charlie', AppConnectionStatus.ACTIVE) + + expect(await offeredApps(ctx)).toEqual(['charlie']) + }) + + it('offers an app with a broken account alongside a working one', async () => { + const ctx = await createTestContext(app) + await connect(ctx, 'alpha', AppConnectionStatus.ERROR) + await connect(ctx, 'alpha', AppConnectionStatus.ACTIVE) + + expect(await offeredApps(ctx)).toEqual(['alpha']) + }) + + // Counting connection rows let a few apps with many accounts each fill a budget meant for apps. + it('counts apps, not accounts, when many accounts belong to few apps', async () => { + const ctx = await createTestContext(app) + for (const name of APPS) { + await connect(ctx, name, AppConnectionStatus.ACTIVE, 12) + } + + expect(await offeredApps(ctx)).toEqual(['alpha', 'bravo', 'charlie', 'delta', 'echo', 'foxtrot', 'golf', 'hotel']) + }) + + it('offers nothing when the project has connected nothing', async () => { + const ctx = await createTestContext(app) + + expect(await offeredApps(ctx)).toEqual([]) + }) +}) diff --git a/packages/server/api/test/integration/ee/agent/agent-draft-tools.test.ts b/packages/server/api/test/integration/ee/agent/agent-draft-tools.test.ts new file mode 100644 index 000000000000..ed99b0ee8247 --- /dev/null +++ b/packages/server/api/test/integration/ee/agent/agent-draft-tools.test.ts @@ -0,0 +1,119 @@ +import { AgentToolType, mcpToolNameUtils } from '@activepieces/shared' +import { describe, expect, it } from 'vitest' +import { agentDraftTools } from '../../../../src/app/ee/agent/agent-draft-ai' + +const NOTES = { + pieceName: '@activepieces/piece-test-notes', + pieceVersion: '0.4.2', + connectionExternalId: 'conn-notes', + actionNames: ['save_note', 'read_note'], +} + +const MEMOS = { + pieceName: '@activepieces/piece-test-memos', + pieceVersion: '1.0.0', + connectionExternalId: 'conn-memos', + actionNames: ['save_note'], +} + +describe('what the model is offered', () => { + it('lists only the pieces the project has a connection for, with their actions', () => { + const prompt = agentDraftTools.withCandidates({ prompt: 'watch competitor pricing', candidates: [NOTES] }) + + expect(prompt).toContain('watch competitor pricing') + expect(prompt).toContain('@activepieces/piece-test-notes (save_note, read_note)') + }) + + it('says so plainly when the project has no connections, rather than leaving it to guess', () => { + const prompt = agentDraftTools.withCandidates({ prompt: 'watch competitor pricing', candidates: [] }) + + expect(prompt).toContain('none') + expect(prompt).toContain('empty tools list') + }) +}) + +describe('what a pick is allowed to become', () => { + it('pins the version and the connection the server resolved, not anything the model said', () => { + const tools = agentDraftTools.resolveToolPicks({ + picks: [{ pieceName: NOTES.pieceName, actionName: 'save_note' }], + candidates: [NOTES], + }) + + expect(tools).toHaveLength(1) + expect(tools[0].type).toBe(AgentToolType.PIECE) + expect(tools[0].toolName).toBe(mcpToolNameUtils.createPieceToolName(NOTES.pieceName, 'save_note')) + expect(tools[0]).toMatchObject({ + pieceMetadata: { + pieceName: NOTES.pieceName, + pieceVersion: NOTES.pieceVersion, + actionName: 'save_note', + predefinedInput: { auth: NOTES.connectionExternalId }, + }, + }) + }) + + it('drops a piece the project never connected', () => { + const tools = agentDraftTools.resolveToolPicks({ + picks: [{ pieceName: '@activepieces/piece-slack', actionName: 'send_channel_message' }], + candidates: [NOTES], + }) + + expect(tools).toEqual([]) + }) + + it('drops an action the connected piece does not have', () => { + const tools = agentDraftTools.resolveToolPicks({ + picks: [{ pieceName: NOTES.pieceName, actionName: 'delete_everything' }], + candidates: [NOTES], + }) + + expect(tools).toEqual([]) + }) + + it('keeps the real picks out of a reply that mixes them with invented ones', () => { + const tools = agentDraftTools.resolveToolPicks({ + picks: [ + { pieceName: '@activepieces/piece-slack', actionName: 'send_channel_message' }, + { pieceName: NOTES.pieceName, actionName: 'read_note' }, + ], + candidates: [NOTES], + }) + + expect(tools.map((tool) => tool.pieceMetadata?.actionName)).toEqual(['read_note']) + }) + + it('gives two pieces that name their action the same their own tool names', () => { + const tools = agentDraftTools.resolveToolPicks({ + picks: [ + { pieceName: NOTES.pieceName, actionName: 'save_note' }, + { pieceName: MEMOS.pieceName, actionName: 'save_note' }, + ], + candidates: [NOTES, MEMOS], + }) + + expect(tools).toHaveLength(2) + expect(new Set(tools.map((tool) => tool.toolName)).size).toBe(2) + }) + + it('asks for the same action twice and gets it once', () => { + const tools = agentDraftTools.resolveToolPicks({ + picks: [ + { pieceName: NOTES.pieceName, actionName: 'save_note' }, + { pieceName: NOTES.pieceName, actionName: 'save_note' }, + ], + candidates: [NOTES], + }) + + expect(tools).toHaveLength(1) + }) + + it('stops at four however many the model returns', () => { + const wide = { ...NOTES, actionNames: ['a', 'b', 'c', 'd', 'e', 'f'] } + const tools = agentDraftTools.resolveToolPicks({ + picks: wide.actionNames.map((actionName) => ({ pieceName: wide.pieceName, actionName })), + candidates: [wide], + }) + + expect(tools).toHaveLength(4) + }) +}) diff --git a/packages/server/api/test/integration/ee/agent/agent-turn.test.ts b/packages/server/api/test/integration/ee/agent/agent-turn.test.ts index 3f77b68603f7..14c3414be324 100644 --- a/packages/server/api/test/integration/ee/agent/agent-turn.test.ts +++ b/packages/server/api/test/integration/ee/agent/agent-turn.test.ts @@ -100,8 +100,8 @@ describe('an agent conversation', () => { describe('the model an agent answers on', () => { it('refuses to run an agent that names no model, even when the platform has a chat provider', async () => { const ctx = await context() + const agent = await createAgent(ctx) await enableForChat(ctx.platform.id, AIProviderName.OPENROUTER) - const agent = await createAgent(ctx, { provider: null, modelName: null }) const conversation = await startConversation(ctx, agent.id) const response = await ctx.post(`${CONVERSATIONS_URL}/${conversation.id}/messages`, { diff --git a/packages/server/api/test/integration/ee/authn/ee-authn.test.ts b/packages/server/api/test/integration/ee/authn/ee-authn.test.ts index 857760e75122..cb87b6f0f2d8 100644 --- a/packages/server/api/test/integration/ee/authn/ee-authn.test.ts +++ b/packages/server/api/test/integration/ee/authn/ee-authn.test.ts @@ -15,7 +15,7 @@ afterAll(async () => { }) describe('Authentication API', () => { describe('Sign up Endpoint', () => { - it('Adds new user with onboarding token', async () => { + it('Adds new user with a platform of their own', async () => { // arrange const mockSignUpRequest = createMockSignUpRequest() @@ -39,9 +39,9 @@ describe('Authentication API', () => { expect(responseBody?.password).toBeUndefined() expect(responseBody?.status).toBe('ACTIVE') expect(responseBody?.verified).toBe(true) - expect(responseBody?.platformId).toBeNull() + expect(responseBody?.platformId).not.toBeNull() expect(responseBody?.externalId).toBeNull() - expect(responseBody?.projectId).toBeNull() + expect(responseBody?.projectId).not.toBeNull() expect(responseBody?.token).toBeDefined() }) }) diff --git a/packages/server/api/test/unit/app/authentication/signup-names.test.ts b/packages/server/api/test/unit/app/authentication/signup-names.test.ts index 9958d0e8aeb0..0d616ea9262e 100644 --- a/packages/server/api/test/unit/app/authentication/signup-names.test.ts +++ b/packages/server/api/test/unit/app/authentication/signup-names.test.ts @@ -193,4 +193,23 @@ describe('signupNames', () => { }) }) + describe('isPlaceholderName', () => { + it.each([ + ['ahmad@activepieces.com', 'Ahmad', ''], + ['ahmad.tash@activepieces.com', 'Ahmad', ''], + ['...@activepieces.com', 'there', ''], + ['ahmadtash@activepieces.com', 'AhmadTash', ''], + ])('reads the name seeded from %s as a placeholder', (email, firstName, lastName) => { + expect(signupNames.isPlaceholderName({ firstName, lastName, email })).toBe(true) + }) + + it.each([ + ['ahmad@activepieces.com', 'Ahmad', 'Tash'], + ['ahmad@activepieces.com', 'Sam', ''], + ['ahmad.tash@activepieces.com', 'Ahmad Tash', ''], + ])('reads %s named %s %s as a name its owner gave', (email, firstName, lastName) => { + expect(signupNames.isPlaceholderName({ firstName, lastName, email })).toBe(false) + }) + }) + }) diff --git a/packages/server/api/test/unit/app/ee/agent/agent-model-resolution.test.ts b/packages/server/api/test/unit/app/ee/agent/agent-model-resolution.test.ts index 6ba72999c266..dc9ca9818c7a 100644 --- a/packages/server/api/test/unit/app/ee/agent/agent-model-resolution.test.ts +++ b/packages/server/api/test/unit/app/ee/agent/agent-model-resolution.test.ts @@ -1,7 +1,13 @@ import { AIProviderName } from '@activepieces/core-utils' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { agentHelpers } from '../../../../../src/app/ee/agent/agent-helpers' +const getChatProviderName = vi.fn() + +vi.mock('../../../../../src/app/ai/ai-provider-service', () => ({ + aiProviderService: () => ({ getChatProviderName }), +})) + const resolve = ({ provider, selectedModel }: { provider: AIProviderName, selectedModel: string | null }) => agentHelpers.resolveModelIdForProvider({ provider, selectedModel }) @@ -101,8 +107,23 @@ describe('runScopeOrThrow', () => { }) describe('resolveChatProviderName', () => { + const log = { info: () => undefined, warn: () => undefined, error: () => undefined, debug: () => undefined } as never + it('reports no provider for a conversation with no project, rather than guessing one platform-wide', async () => { - const log = { info: () => undefined, warn: () => undefined, error: () => undefined, debug: () => undefined } - await expect(agentHelpers.resolveChatProviderName({ platformId: 'plat-1', projectId: null, log: log as never })).resolves.toBeNull() + await expect(agentHelpers.resolveChatProviderName({ platformId: 'plat-1', projectId: null, log })).resolves.toBeNull() + }) + + it('lets a lookup failure surface, so no caller reads a fault as a platform with no provider', async () => { + getChatProviderName.mockRejectedValueOnce(new Error('connection terminated')) + + await expect(agentHelpers.resolveChatProviderName({ platformId: 'plat-1', projectId: 'proj-1', log })).rejects.toThrow('connection terminated') + }) + + it('asks only for keys the project may use, never platform-wide', async () => { + getChatProviderName.mockResolvedValueOnce(AIProviderName.OPENROUTER) + + await agentHelpers.resolveChatProviderName({ platformId: 'plat-1', projectId: 'proj-1', log }) + + expect(getChatProviderName).toHaveBeenCalledWith({ platformId: 'plat-1', scope: { type: 'project', projectId: 'proj-1' } }) }) }) diff --git a/packages/server/api/test/unit/app/ee/agent/agent-surface-notes.test.ts b/packages/server/api/test/unit/app/ee/agent/agent-surface-notes.test.ts index a0185e89d318..5218b8ae6108 100644 --- a/packages/server/api/test/unit/app/ee/agent/agent-surface-notes.test.ts +++ b/packages/server/api/test/unit/app/ee/agent/agent-surface-notes.test.ts @@ -39,6 +39,22 @@ function notesFor(source: AgentRunSource): string { } describe('what each surface is told it can do', () => { + it('tells an agent run to offer the connection card, and tells nobody else', () => { + expect(notesFor(AgentRunSource.AGENT)).toContain('ap_show_connection_picker') + expect(notesFor(AgentRunSource.CHAT)).not.toContain('cannot sign in') + expect(notesFor(AgentRunSource.FLOW_STEP)).not.toContain('cannot sign in') + expect(notesFor(AgentRunSource.AGENT_BUILDER)).not.toContain('cannot sign in') + }) + + it('never tells the builder it can read the web, because its tool set has no web in it', () => { + const notes = notesFor(AgentRunSource.AGENT_BUILDER) + + expect(notes).not.toContain('ap_web_search') + expect(notes).not.toContain('ap_fetch_url') + expect(notes).not.toContain('ap_scrape_url') + expect(notes).not.toContain('ap_generate_image') + }) + it('only tells a chat run about saved agents, and only where the surface exists', () => { expect(notesFor(AgentRunSource.CHAT)).toContain('Saved agents') expect(notesFor(AgentRunSource.FLOW_STEP)).not.toContain('Saved agents') diff --git a/packages/server/worker/src/lib/execute/jobs/ee/agent/agent-tool-policy.ts b/packages/server/worker/src/lib/execute/jobs/ee/agent/agent-tool-policy.ts index 460b0e2a58d8..deb5b5dcf89f 100644 --- a/packages/server/worker/src/lib/execute/jobs/ee/agent/agent-tool-policy.ts +++ b/packages/server/worker/src/lib/execute/jobs/ee/agent/agent-tool-policy.ts @@ -29,10 +29,18 @@ function selectToolsForSource({ source, groups }: { source: AgentRunSource, grou ...groups.configuredFlow, ...groups.knowledgeBase, } + if (source === AgentRunSource.AGENT_BUILDER) { + return { + ...groups.agentSurface, + ...pick({ tools: groups.display, names: ['ap_show_questions', 'ap_show_quick_replies', 'ap_show_connection_picker', 'ap_show_connection_required'] }), + ...pick({ tools: groups.mcp, names: ['ap_research_pieces', 'ap_list_connections'] }), + ...groups.thinking, + } + } if (source === AgentRunSource.AGENT) { return { ...configured, - ...pick({ tools: groups.display, names: ['ap_show_questions', 'ap_show_quick_replies', 'ap_show_showcase'] }), + ...pick({ tools: groups.display, names: ['ap_show_questions', 'ap_show_quick_replies', 'ap_show_showcase', 'ap_show_connection_picker'] }), ...groups.web, ...groups.thinking, ...groups.completion, diff --git a/packages/server/worker/test/lib/execute/jobs/ee/agent/agent-tool-policy.test.ts b/packages/server/worker/test/lib/execute/jobs/ee/agent/agent-tool-policy.test.ts index 386df8d72d45..797f669f623d 100644 --- a/packages/server/worker/test/lib/execute/jobs/ee/agent/agent-tool-policy.test.ts +++ b/packages/server/worker/test/lib/execute/jobs/ee/agent/agent-tool-policy.test.ts @@ -17,7 +17,7 @@ const GROUPS: AgentToolGroups = { buildPlan: toolSet('ap_set_build_plan'), email: toolSet('ap_send_email'), agentSurface: toolSet('ap_list_agents', 'ap_create_agent', 'ap_update_agent', 'ap_add_agent_tool', 'ap_remove_agent_tool'), - mcp: toolSet('ap_create_flow', 'ap_test_flow'), + mcp: toolSet('ap_create_flow', 'ap_test_flow', 'ap_research_pieces', 'ap_list_connections'), configuredPiece: toolSet('gmail_find_email'), configuredFlow: toolSet('run_my_flow'), knowledgeBase: toolSet('search_handbook'), @@ -44,15 +44,52 @@ describe('what a chat run may reach', () => { describe('what may reach the tools that build saved agents', () => { const AGENT_SURFACE_TOOLS = ['ap_list_agents', 'ap_create_agent', 'ap_update_agent', 'ap_add_agent_tool', 'ap_remove_agent_tool'] - it('only a chat run, since the other surfaces have nobody to review what was made', () => { + it('a chat run and the builder, and no surface with nobody reading', () => { for (const toolName of AGENT_SURFACE_TOOLS) { expect(namesFor(AgentRunSource.CHAT), toolName).toContain(toolName) + expect(namesFor(AgentRunSource.AGENT_BUILDER), toolName).toContain(toolName) expect(namesFor(AgentRunSource.AGENT), toolName).not.toContain(toolName) expect(namesFor(AgentRunSource.FLOW_STEP), toolName).not.toContain(toolName) } }) }) +describe('what the agent builder may reach', () => { + it('reaches enough to find a piece and a connection for it', () => { + const names = namesFor(AgentRunSource.AGENT_BUILDER) + + expect(names).toContain('ap_research_pieces') + expect(names).toContain('ap_list_connections') + expect(names).toContain('ap_show_connection_picker') + expect(names).toContain('ap_show_questions') + expect(names).toContain('ap_update_thinking_status') + }) + + it('never reaches the flow-building surface it sits beside, nor the web', () => { + const names = namesFor(AgentRunSource.AGENT_BUILDER) + + expect(names).not.toContain('ap_web_search') + expect(names).not.toContain('ap_fetch_url') + expect(names).not.toContain('ap_create_flow') + expect(names).not.toContain('ap_test_flow') + expect(names).not.toContain('ap_set_build_plan') + expect(names).not.toContain('ap_set_phase') + expect(names).not.toContain('ap_select_project') + expect(names).not.toContain('ap_deselect_project') + expect(names).not.toContain('ap_execute_action') + expect(names).not.toContain('ap_send_email') + }) + + it('runs no tool the agent itself was configured with, since it is building that agent rather than being it', () => { + const names = namesFor(AgentRunSource.AGENT_BUILDER) + + expect(names).not.toContain('gmail_find_email') + expect(names).not.toContain('run_my_flow') + expect(names).not.toContain('search_handbook') + expect(names).not.toContain('ap_return_output') + }) +}) + describe('what an agent conversation may reach', () => { it('reaches the tools someone configured for it', () => { const names = namesFor(AgentRunSource.AGENT) @@ -68,14 +105,14 @@ describe('what an agent conversation may reach', () => { expect(names).toContain('ap_show_questions') expect(names).toContain('ap_show_quick_replies') + expect(names).toContain('ap_show_connection_picker') expect(names).toContain('ap_generate_image') expect(names).toContain('ap_update_thinking_status') }) - it('never offers to change a connection or project its owner pinned', () => { + it('never reaches the surfaces that switch project or hunt for other credentials', () => { const names = namesFor(AgentRunSource.AGENT) - expect(names).not.toContain('ap_show_connection_picker') expect(names).not.toContain('ap_show_connection_required') expect(names).not.toContain('ap_show_mcp_reconnect') expect(names).not.toContain('ap_show_project_picker') diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index 35c90cb6d06c..2b19f19dfa09 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -2515,5 +2515,10 @@ "Recently used": "Recently used", "Resets in {days, plural, =1 {# day} other {# days}}": "Resets in {days, plural, =1 {# day} other {# days}}", "Show all projects": "Show all projects", - "Sort pinned projects": "Sort pinned projects" + "Sort pinned projects": "Sort pinned projects", + "Flow pieces upgraded": "Flow pieces upgraded", + "kept at {version}": "kept at {version}", + "Flow pieces reverted": "Flow pieces reverted", + "The {name} account this agent uses is gone. Update the agent tools with a working account.": "The {name} account this agent uses is gone. Update the agent tools with a working account.", + "Could not load your accounts. Try again in a moment.": "Could not load your accounts. Try again in a moment." } diff --git a/packages/web/src/app/routes/chat-with-ai/components/connection-picker-card.tsx b/packages/web/src/app/routes/chat-with-ai/components/connection-picker-card.tsx index 1aa2853a846c..c163904559dd 100644 --- a/packages/web/src/app/routes/chat-with-ai/components/connection-picker-card.tsx +++ b/packages/web/src/app/routes/chat-with-ai/components/connection-picker-card.tsx @@ -24,6 +24,7 @@ import { isConnectionHealthy, normalizePieceName, pickDefaultConnectionExternalId, + resolveConnectionCardState, } from '../lib/message-parsers'; import { useConversationId } from '../lib/use-conversation-id'; @@ -166,23 +167,33 @@ export function ConnectionPickerCard({ const pieceName = normalizePieceName(picker.piece); const shouldFetch = !picker.connections?.length && !!conversationId && isInteractive; - const { data: fetchedConnections, isLoading: isFetchingConnections } = - useQuery({ - queryKey: ['chat-picker-connections', conversationId, pieceName], - queryFn: async () => { - const conns = await chatApi.getPickerConnections({ + const { + data: fetchedConnections, + isLoading: isFetchingConnections, + isError: connectionsFailed, + } = useQuery({ + queryKey: ['chat-picker-connections', conversationId, pieceName], + queryFn: async () => { + const { connections, reconnectOnly } = await chatApi.getPickerConnections( + { conversationId: conversationId!, pieceName, - }); - return conns.map((c) => ({ + }, + ); + return { + reconnectOnly, + connections: connections.map((c) => ({ ...c, status: c.status as AppConnectionStatus, - })); - }, - enabled: shouldFetch, - }); + })), + }; + }, + enabled: shouldFetch, + }); - const resolvedConnections = picker.connections ?? fetchedConnections ?? []; + const resolvedConnections = + picker.connections ?? fetchedConnections?.connections ?? []; + const reconnectOnly = fetchedConnections?.reconnectOnly ?? false; const filteredPicker = useMemo(() => { if (!selectedProjectId) return { ...picker, connections: resolvedConnections }; @@ -195,6 +206,9 @@ export function ConnectionPickerCard({ name: pieceName, }); const [connectDialogOpen, setConnectDialogOpen] = useState(false); + const [reconnectProjectId, setReconnectProjectId] = useState( + null, + ); const [reconnectConnection, setReconnectConnection] = useState(null); const [selectedConnection, setSelectedConnection] = @@ -259,11 +273,16 @@ export function ConnectionPickerCard({ const handleReconnect = (externalId: string) => { const fullConnection = fullConnections[externalId]; if (!fullConnection) return; + setReconnectProjectId( + filteredPicker.connections.find((c) => c.externalId === externalId) + ?.projectId ?? null, + ); setReconnectConnection(fullConnection); setConnectDialogOpen(true); }; const handleNewConnection = () => { + setReconnectProjectId(null); setReconnectConnection(null); setConnectDialogOpen(true); }; @@ -300,13 +319,21 @@ export function ConnectionPickerCard({ } const hasConnections = filteredPicker.connections.length > 0; + const { offersOtherAccounts, canContinue, emptyMessage } = + resolveConnectionCardState({ + reconnectOnly, + connectionsFailed, + healthyCount: healthyConnections.length, + }); return ( <> onDismiss?.()} title={ - hasConnections + reconnectOnly + ? t('Reconnect {name}', { name: filteredPicker.displayName }) + : hasConnections ? t('Which {name} account should I use?', { name: filteredPicker.displayName, }) @@ -315,9 +342,18 @@ export function ConnectionPickerCard({ > {!hasConnections && (
- {t('No {name} account connected yet', { - name: filteredPicker.displayName, - })} + {emptyMessage === 'loadFailed' + ? t('Could not load your accounts. Try again in a moment.') + : emptyMessage === 'pinnedAccountGone' + ? t( + 'The {name} account this agent uses is gone. Update the agent tools with a working account.', + { + name: filteredPicker.displayName, + }, + ) + : t('No {name} account connected yet', { + name: filteredPicker.displayName, + })}
)} @@ -334,7 +370,7 @@ export function ConnectionPickerCard({ const row = ( <> - {healthy ? ( + {healthy && !reconnectOnly ? ( - {!healthy && - (status === AppConnectionStatus.MISSING ? ( + {(!healthy || reconnectOnly) && + (status === AppConnectionStatus.MISSING && !reconnectOnly ? ( - - + )} - {hasConnections && ( + {canContinue && (