diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15baa200de48..59a1503a059a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -149,7 +149,7 @@ jobs: set -euo pipefail pids=() - npx turbo run test --filter=@activepieces/engine --filter=@activepieces/shared --filter=@activepieces/sandbox --filter=@activepieces/ai-providers --filter=@activepieces/pieces-framework --filter=web & + npx turbo run test --filter=@activepieces/engine --filter=@activepieces/shared --filter=@activepieces/sandbox --filter=@activepieces/ai-providers --filter=@activepieces/core-execution --filter=@activepieces/pieces-framework --filter=web --filter=worker & pids+=($!) npx turbo run test-ce test-ee test-cloud check-migrations --filter=api & diff --git a/brain/knowledge/ai-intelligence/ai-agents.md b/brain/knowledge/ai-intelligence/ai-agents.md index 9d8f92440a5c..aea8d090342a 100644 --- a/brain/knowledge/ai-intelligence/ai-agents.md +++ b/brain/knowledge/ai-intelligence/ai-agents.md @@ -28,7 +28,13 @@ A flow step type (the `run_agent` action of `@activepieces/piece-ai`) that runs - **The enums and pure functions have exactly one home: `core/piece-types/src/lib/agents.ts`.** Do not re-declare `AgentToolType`, `McpAuthType`, `buildAuthHeaders`, `TASK_COMPLETION_TOOL_NAME`, or `mcpToolNameUtils` in `core-execution` — re-export them. They used to be duplicated byte-for-byte across both packages, which was silently load-bearing: if `createToolName` drifted, the tool names `migrate-v16` persisted would stop matching runtime names and every piece/flow/MCP call on a migrated flow would degrade to `ToolCallType.UNKNOWN`. `mcp-tool-name-util.test.ts` asserts both entry points resolve to the *same object*, so a re-fork fails the test rather than shipping. - The four `core/execution/src/lib/agents/` files are **not** uniform. `mcp-tool-name-util.ts` and `mcp.ts` are pure re-export shims (1 and 6 lines). `index.ts` and `tools.ts` re-export the canonical enums and functions but still **own** the execution-side plain-`zod` schema definitions — `tools.ts` declares the `AgentTool` union and the `McpAuth*` schemas, `index.ts` declares `AgentOutputField`, `MarkdownContentBlock`, `ToolCallContentBlock` and `AgentStepBlock`. Adding a field to one of those schemas means editing it there *and* in the `zod/mini` twin in `agents.ts`. - **A flow-step run must not reuse chat's resolution logic.** Four separate production failures came from this one assumption while moving the step server-side, each looking like its own bug. `resolveChatProvider` made a step need Chat's provider configured before it would run at all, so an instance that never uses Chat could not run an agent step — and it bit twice, because `resolveFastModel` reached the same helper underneath, so every *configured piece tool* failed with a bare `ENTITY_NOT_FOUND` long after the main model had been fixed. Grep for the transitive callers, not just the direct ones. `resolveModelIdForProvider` treats its argument as a *tier* id and falls back to the tier default when it is not in the curated chat list — a step configured for `claude-sonnet-4.5` silently ran `4.6`, because a step names a concrete model while chat names a tier. And the chat tool set reaches an unattended run, where a tool that asks the user a question is worse than useless: the agent opened a connection picker, read the empty answer as a refusal, and stopped. When a value crosses between the two surfaces, check what it *means* on each side, not just that the types line up. -- **A worker RPC failure reaches the worker as `error.message` and nothing else.** The envelope in `core/execution/src/lib/engine/rpc.ts` drops `ActivepiecesError.params` and the stack, so three unrelated causes (conversation gone, no chat-enabled provider, pinned provider has no row) all arrive as the same bare `ENTITY_NOT_FOUND` — unreadable in the failed-job list. `createRpcServer` logs the intact error on the app side; read *that* log, not the worker's. +- **A worker RPC failure carries `{ code, entityType }` now, but still no stack.** The envelope in `core/execution/src/lib/engine/rpc.ts` used to serialize `error.message` alone, so three unrelated causes (conversation gone, no chat-enabled provider, pinned provider has no row) all arrived as the same bare `ENTITY_NOT_FOUND`. `apErrorOf` now also ships an `ActivepiecesError`'s code and entity type, which the client re-attaches to the thrown error — read it with `apErrorOf(error)`, never by parsing the message. Deliberately **not** the whole `params`: it is typed `unknown`, and socket.io JSON-encodes this ack from inside a `catch` where nothing handles a throw, so one cyclic or BigInt-bearing params object would send no ack at all and stall the caller for the full 60s RPC timeout (the engine side would `process.exit(4)` on the unhandled rejection). `rpc.test.ts` pins this with a cyclic params case and a JSON-round-tripping fake socket — keep the projection narrow. +- **A failed agent run is a user's misconfiguration far more often than our bug, and only our bugs belong in the failed set.** `EXECUTE_AGENT_RUN` re-threw on everything except credit exhaustion, so ~5,900 unrecoverable user-config failures accumulated in the BullMQ failed set over one 30-day retention window (`REDIS_FAILED_JOB_RETENTION_DAYS`) and buried the real bugs. `classifyAgentRunError` (`run-agent-turn.ts`) splits them, and a user-class failure returns `EngineResponseStatus.USER_FAILURE`, which `job-broker.completeJob` completes exactly like `OK` while naming the outcome. Four things it gets deliberately right, each of which is a way to get it wrong: + - **The user-fault statuses are an allow-list (401/403/404), not `!APICallError.isRetryable`.** The SDK calls every 4xx non-retryable, so the tempting one-liner blames the user for a 400 from an illegal generated tool name or a 413 from a prompt still over the window after compaction — requests *we* built, and exactly the laundering the split exists to prevent. + - **The managed `activepieces` provider is never user-fault on auth.** It runs on our own OpenRouter key, so a 401 there fails every platform at once and must page. + - **Credit is read from a status or the specific `insufficient_quota` marker, never loose patterns over a response body.** OpenAI signals billing exhaustion as a *retryable* 429 with the marker in the **body**, so credit is checked before the retryable verdict — but scanning a body for `credits`/`402` made a provider 500 whose HTML error page said "credits" complete as a billing failure and hide a real outage. + - **`ENTITY_NOT_FOUND` counts only for an AI-provider `entityType`, and `VALIDATION` counts for nothing.** A bare not-found is our bug; the `VALIDATION` that reaches this surface is the conversation concurrency lock, and a conversation stuck `STREAMING` is a state worth keeping visible. + A completed job stores no `errorMessage`, so the `warn` log carrying `agentRun.errorClass` is the only remaining record. - **Whatever enqueues an agent run must pre-check the same thing the worker resolves.** The chat route asked "is any provider enabled for chat" while the worker looked up the run's *pinned* provider, and the flow-step route checked nothing at all — so a run enqueued fine and could only fail. Both now call `agentHelpers.assertRunProviderConfigured`, which mirrors the worker's lookup. A pre-check that answers a *different* question than the worker is worse than none: it makes the failure look impossible. - **Everything the agent job does before its try/catch has no recovery.** `getAgentConfig` used to run outside it, so a config failure sent no error to the chat client and never called `releaseFlowStep` — the flow run sat PAUSED until `AP_PAUSED_FLOW_TIMEOUT_DAYS`. Anything added above that block needs its own failure path, or a paused run leaks. - **Build the unattended tool set as an allow-list.** Removing chat tools by name failed three times running — display tools, then build-plan and phase tools, then `ap_discover_action_auth` and `ap_load_guide`, which live with the local tools and so survived a filter written by tool group. Grouping tracks where a tool was constructed, not whether it assumes someone is reading. A flow step gets exactly what it is listed: its configured piece actions, the public-web readers, and the structured-output tool. Anything added to chat later stays out by default. diff --git a/brain/knowledge/connections-auth/ce-authentication.md b/brain/knowledge/connections-auth/ce-authentication.md index 5be36fcc5804..b4a3bfeb5810 100644 --- a/brain/knowledge/connections-auth/ce-authentication.md +++ b/brain/knowledge/connections-auth/ce-authentication.md @@ -31,8 +31,9 @@ The core (all-editions) auth layer: user identity creation, sign-in, and JWT ses - **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. +- **The sign-in URL's query string survives the email-code journey but not a federated one.** `/sign-up` forwards its search to `/sign-in`, and the card never navigates, so `?foo=bar` is still there at the end. Google/SAML instead do `window.location.href = …` and only `from`, `providerName` and `activepiecesLogin` ride along in the OAuth `state`; the customer returns on `/redirect` and goes to `from` or `/create-platform`. Anything that has to outlive sign-in for *every* provider belongs in `localStorage`, not in the URL. +- **`from` gets you back to the route but not to its query string — `AuthenticatedDefaultRoute` used to drop it.** Both `DefaultRoute` and `AllowOnlyLoggedInUserOnlyGuard` build `from` as `location.pathname + location.search`, so a param on the original URL survives sign-in and `useRedirectAfterLogin` navigates back to it. The last hop was where it died: landing on `/` authenticated renders `AuthenticatedDefaultRoute`, which navigated to `determineDefaultRoute(...)` with no `search`, so anything hanging off `/?x=1` was gone before the project routes (and the guards mounted inside them) rendered. That `Navigate` now forwards a single allow-listed param (`TRIAL_KEY_QUERY_PARAM`, in `route-utils.ts` beside `NEW_FLOW_QUERY_PARAM`), which is what lets a trial activation link reach the signed-in screen that consumes it. It deliberately does **not** forward the whole search string: `AuthenticatedDefaultRoute` also serves the `/*` catch-all, so blanket forwarding would push the query string of every unmatched URL into the default route for whatever page later sits there to read. A param that must survive that hop has to be added to the allow-list. - **`/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/engineering/architecture-spine.md b/brain/knowledge/engineering/architecture-spine.md index 7cac79f8c819..29c29d4e1b5a 100644 --- a/brain/knowledge/engineering/architecture-spine.md +++ b/brain/knowledge/engineering/architecture-spine.md @@ -33,6 +33,9 @@ Activepieces: open-source AI-first workflow automation platform (self-hosted or ## Gotchas +**`has no exported member` after merging `main` is a stale `dist/`, not broken code.** The app typechecks resolve `@activepieces/core-*` through each package's built `.d.ts`, not its source, so a symbol `main` added to a thin core package is invisible to `packages/web` and `packages/server` until that package is rebuilt. It reads exactly like a bad merge — `tsc` names a real export that is right there in the source. Confirm by grepping the symbol in `packages/core//dist/`, then `npx turbo run build --filter=@activepieces/core-`. Hit 2026-08 merging `main` into a feature branch: `AI_PROVIDER_ENTITY_TYPES` (added by #15097) was in `core/piece-types/src` and re-exported from its index, but absent from `dist/`, so web's typecheck failed on `core/shared` importing it. + + **`distributedLock().runExclusive` waits for the *whole* `timeoutInSeconds` under contention — never put one on a request path.** `distributed-lock-factory.ts` configures Redlock with `retryCount = Math.ceil(timeout / 200)` and `retryDelay: 200`, so the retry budget is exactly the lock TTL: a `timeoutInSeconds: 15` lock retries 75 times before giving up, and each retry is its own Redis round-trip. N concurrent requests contending on one key therefore generate up to N×75 pure-retry commands against shared Redis *while* every one of them stalls for up to 15s. Read-mostly checks belong on the cache with the fetch scheduled behind the response (`rejectedPromiseHandler` + `distributedStore.runOnceWithin` gives cluster-wide dedupe without a lock); reserve `runExclusive` for genuine write serialization off the hot path. Surfaced 2026-08 in the Autumn credits gate (PR #14436, `f0638438`), where an exhausted or cold platform made every webhook, AI-proxy call and chat turn take a reverify lock plus a `platform_plan` SELECT plus a 5s Autumn HTTP call inline — a ~20s worst case on the highest-volume path in the product. Related: [[ee-platform-plans-billing]]. **Don't `.max()` a business limit on a request body — cap server-side.** A `.max()` on a request-body field rejects the *whole* request with a 400 the moment a user crosses it, so a user editing a list that reaches 50 items loses their entire save. Reserve `.max()` for a true trust-boundary DoS guard (Fastify's global body limit already covers gross abuse) and let business limits just *apply*: accept the input and `slice(0, MAX)` in the service layer, so the write always succeeds with the limit quietly enforced. Surfaced 2026-07 on `POST /v1/chat/memory`, where the schema's `.max(50)`/`.max(280)` duplicated a `slice` the save helper already did — redundant *and* a data-loss bug. diff --git a/brain/knowledge/engineering/web-feature-anatomy.md b/brain/knowledge/engineering/web-feature-anatomy.md index a7fc4c92705d..70d7bfdb7369 100644 --- a/brain/knowledge/engineering/web-feature-anatomy.md +++ b/brain/knowledge/engineering/web-feature-anatomy.md @@ -74,3 +74,5 @@ Verify with `npx turbo run lint --filter=web`, or `npm run lint-dev` for the who - **`npx turbo run serve --filter=web -- --mode=cloud` cannot do OAuth2 connections.** The provider redirects to `cloud.activepieces.com` after sign-in instead of your local frontend. Use API-key or basic-auth connections, or run a fully local backend. - **`--mode=cloud` also floods the terminal with `[vite] http proxy error: /ingest/... ETIMEDOUT 127.0.0.1:3000`.** The mode only redirects the API (`API_BASE_URL` → `https://cloud.activepieces.com` in `lib/api.ts`); PostHog still posts to the *relative* `api_host: '/ingest'` (a same-origin reverse proxy so ad blockers don't drop ingestion — `providers/telemetry-provider.tsx`, mirrored in prod by the `fastifyHttpProxy` in `server.ts`). Vite proxies `/ingest` to `127.0.0.1:3000`, which isn't running. Cloud flags also turn telemetry *on* (`TELEMETRY_ENABLED` + `EDITION=cloud`), unlike a local CE backend — so posthog-js keeps polling `/ingest/flags` and flushing `/ingest/e` every few seconds. Harmless, but note the same setup sends real dev clicks to production PostHog whenever `/ingest` does resolve; the clean fix is skipping `posthog.init` under `import.meta.env.DEV`. - **`packages/web`'s lint script only globs `src/**`, so nothing under `packages/web/test/` is ever linted** — not by CI's `lint` job, not by `npm run lint-dev`. Running `npx eslint 'test/**/*.{ts,tsx}'` from `packages/web` today reports 21 errors nobody has seen, so a new web test needs a manual eslint pass or it ships with errors. Most common trap: `testing-library/render-result-naming-convention` fires on any local helper whose name merely *starts with* `render` even when testing-library is not involved — renaming `render` to `renderTabText` does not silence it, only a name that doesn't begin with `render` does. +- **`AllowOnlyLoggedInUserOnlyGuard` calls its hooks after two early returns, and the linter only lets it.** `react-hooks/rules-of-hooks` does not flag member-expression calls, so `platformHooks.useCurrentPlatform()` / `flagsHooks.useFlags()` sail past it — but add a bare `useSomething()` there and the rule fires, correctly: `isLoggedIn()` can change between renders, so those calls really are conditional. Anything new that needs to run once a session is authenticated belongs in a null-rendering component placed inside the returned `` subtree, which mounts only after the guard passes. That is why automatic trial activation is `` and not a hook. +- **The layering is lint-enforced, not just a convention.** `packages/web/.eslintrc.json` has an `import/no-restricted-paths` zone making the codebase unidirectional: `src/app` may import `src/features`, and both may import `src/lib`/`hooks`/`components`/`types`/`utils` — never the reverse (the one exception is `app/query-client.ts`). So a hook that a public route needs belongs in `src/lib`, but anything rendering a feature's components has to live in that feature; you cannot keep the pair in one `lib` file. It fails as an `import/no-restricted-paths` **error**, not a warning, so it blocks lint. diff --git a/brain/knowledge/execution-runtime/workers.md b/brain/knowledge/execution-runtime/workers.md index 8553627d3e7d..b5a769f9f5bb 100644 --- a/brain/knowledge/execution-runtime/workers.md +++ b/brain/knowledge/execution-runtime/workers.md @@ -45,6 +45,7 @@ The deep `Resolver`/`Runtime` concurrency and bundle-caching model lives on the - **A new user-interaction `WorkerJobType` must be added to `USER_INTERACTION_JOB_TYPES`** in `packages/server/api/src/app/workers/job-queue/job-queue.ts`. `jobBroker.completeJob` only publishes the engine response back to the waiting webserver for job types in that set — miss it and the caller hangs to `WATCHER_SAFETY_TIMEOUT_MS` (5 min) with no error. `submitAndWaitForResponse` has only that backstop, so any *best-effort* caller must additionally cap itself (`Promise.race` with a short timeout); the losing engine job still runs to completion, so the cap buys back user latency, not fleet capacity. - **System-job `No handler` = the worker runs the wrong edition.** The single shared `system-job-queue` is consumed by whichever app instance runs `startWorker()`, and EE handlers only register in the CLOUD/ENTERPRISE branches of the edition switch. A worker on a different edition than the instance that *scheduled* the job throws `No handler for job ` every tick. Seen July 2026: ~14.6k failures, ~99% `chat-stale-sweep`, because the worker defaulted to community (`AP_EDITION` unset) inside a cloud deployment — CE jobs like `file-cleanup-trigger` ran fine on the same worker, every EE-scheduled job failed identically. Fix on the deployment (`AP_EDITION=cloud`), not by registering EE handlers in CE. The count looks huge because `removeOnComplete: true` hides successes and `removeOnFail` has an age cap but no count cap. - **`JobSchedulerJson.id` is almost always `undefined` — never filter schedulers on it.** BullMQ only populates `id` on the legacy `keyToData` path (raw `name:jobId:endDate:tz:pattern` zset members); for both modern job schedulers and hashed legacy repeatables it is absent, and the identity you pass to `removeJobScheduler` is `key`. `removeDeprecatedJobs` (`helper/system-jobs/system-job.ts`) filtered on `!isNil(f.id)`, so from 0.86.x it removed *nothing* — then the one-time pass found the scheduler's live delayed job and `job.remove()` threw `Job repeat:: belongs to a job scheduler and cannot be removed directly` (the lua refuses when `rjk` is still scored in the `repeat` zset), and the `Promise.all` aborted the rest of the cleanup. Order matters: remove the scheduler first, then the orphaned delayed job removes cleanly. Use `allSettled` for boot-time cleanup so one stuck entry can't block every other removal, and guard deprecated-name matching with `!knownJobNames.includes(name)` since the match is `startsWith`. +- **A test that stands up a fake API for the worker must give its `http.Server` a request listener, or settings never load.** `fetchAndStoreSettings` builds machine info *before* it emits `FETCH_WORKER_SETTINGS`, and `collectMachineInfo` calls `probeServerPing`, which fetches `/api/v1/health` raced against a 5 s sleep. Socket.IO's `attach` replaces the server's `request` listeners with its own and forwards non-socket.io paths to *the listeners that existed when it attached* — with a bare `createServer()` there are none, so `/api/v1/health` is accepted and never answered, the probe burns its full 5 s, and settings arrive just after a 5 s test deadline. Every assertion then fails as a timeout with nothing in the logs pointing at the probe. `worker.test.ts` gets this right (`createServer((_req, res) => res.end('{}'))`); `worker-settings-override.test.ts` did not, and its 9 tests failed this way for as long as the `worker` package was outside the CI test filter. #### `kamal app exec` on the worker image used to leak a permanent worker (fixed) diff --git a/brain/knowledge/platform-editions-ee/license-keys.md b/brain/knowledge/platform-editions-ee/license-keys.md index 31a7572a9021..9262a465d554 100644 --- a/brain/knowledge/platform-editions-ee/license-keys.md +++ b/brain/knowledge/platform-editions-ee/license-keys.md @@ -14,16 +14,19 @@ A license key is a self-hosted customer's **activation/recovery handle** for the - `refreshEntitlements` — fetches the Autumn customer and writes `mapAutumnFeaturesToPlatformPlan` output onto `platform_plan`: `plan`, `billedTeamProjectsLimit`, `usersLimit`, `activeFlowsLimit`, `includedCredits`, and every boolean flag feature. - `ensureEnrolled` — lazy enrollment under a `distributedLock`; if a `licenseKey` is already stored it re-activates through the console, otherwise `enrollFree` with the platform owner's email. - `provisionLicenseKeyIfPaid` — during `refreshEntitlements`, self-serve paid customers who never entered a key get one minted by the console and saved, so every paying platform ends up with a recovery handle. +- **Automatic trial activation** — `/?licenseKey=…` (any origin; self-hosters swap the domain) lets sales hand a customer one link instead of a key to paste. **Nothing is stored: the URL carries the key across sign-in.** `DefaultRoute` folds `pathname + search` into `from`, `useRedirectAfterLogin` navigates back to it, and for Google/SAML `from` rides the OAuth `state` — see [[ce-authentication]], whose `AuthenticatedDefaultRoute` note is why that last hop had to learn to carry `location.search`. An earlier version stashed the key in `sessionStorage` and stripped the param at sign-in; the write was suppressed with `tryCatchSync` while the strip was unconditional, so on any origin where Web Storage is blocked (sandboxed iframe, cookies-blocked browser, a privacy extension that stubs `setItem` and cannot even throw) the key vanished from both places silently. ``, rendered inside `AllowOnlyLoggedInUserOnlyGuard`, reads the param once a platform exists and takes over the viewport with `` — a full-screen card on the builder canvas with four states (`activating` with a fake asymptotic progress ramp, `success` with confetti and a 5s redirect to the default route, `not_admin` offering the link to forward, `failed` with retry and a support mailto). It is an overlay rather than a route so no destination is lost and no endpoint or migration is added. `AutomaticTrialActivation` decides whether to show it in a lazy `useState` initialiser — every input (the query param, `platform`, `edition`, platform role) is a suspense query or a synchronous read, so there is nothing to wait for — and capturing `alreadyLicensed` at mount is what stops the post-activation platform refetch from unmounting the success panel mid-confetti. **Capture precedes the scrub**: the key is in React state from the first render, and the screen deletes the param on mount, so the URL copy is only discarded once the key is already held. Two consequences worth knowing — a reload after the scrub loses the key (the user replays the original link), and if the parent skips (Community, or already licensed) nothing mounts to scrub, so the param lingers until the next navigation. The screen holds one `useEffect` (mount-only) that fires the activation and starts a 250ms clock; the view, the progress ramp, the client-side timeout and the redirect countdown are all *derived* from that clock plus the mutation's `isPending`/`isSuccess`/`isError` rather than stored, so the only state is `now`. Activation still posts to the existing `POST /v1/platform-billing/activate` via `useUpdateLisenceKey`, with both toasts suppressed (`messages: { success: null, error: null }`) because the screen renders the outcome itself. `AutomaticTrialActivation` is a component rather than a hook because the guard's early returns would make a hook call conditional. **The link is built in the console** (`packages/web/src/lib/activation-link.ts`, surfaced as "Copy activation link" in the license-key table and on the key-created page) and consumed here, with nothing but the `licenseKey` query-param name joining the two repos — rename the param or move the route and the console keeps handing out links that silently do nothing. ### Endpoints - `POST /v1/platform-billing/activate` — body `{ licenseKey }`, `securityAccess.platformAdminOnly([USER])`; thin wrapper over `billingProvider.activateLicense` with `platformId` from the principal. - `POST /v1/admin/platforms/apply-license-key` — cloud admin (module-level `api-key` header preHandler checked against `AppSystemProp.API_KEY`); body `{ email, licenseKey }`; resolves email → platform-admin user → owned platform, then calls the same `activateLicense`. ### Gotchas +- **On Community there is no activate route to no-op.** `billingProvider.activateLicense` is a CE no-op, but `platformPlanModule` is registered only under `ApEdition.CLOUD` and `ApEdition.ENTERPRISE` in `app.ts`, so `POST /v1/platform-billing/activate` **404s** on CE. Shared web code that activates a key must gate on the `EDITION` flag first, or a CE self-hoster gets an activation-failed message whose real cause is `AP_EDITION=ce`. - The key's contents are never read by AP — the console owns license data (`license_keys` table, plan-to-attach + term, trial issuance, `autumn_customers` ledger). Old-world per-feature flags on the key no longer exist. - In `activateLicense` the console call happens **before** `platform_plan.licenseKey` is saved — a rejected key is never persisted. - `AUTUMN_CONSOLE_URL` is a hardcoded constant in `autumn-utils.ts` (currently the testing console); all console calls go through `safeHttp` with a request timeout. - The `licenseKey` column on `platform_plan` is retained; there is no expiry job in AP — plan lapse is handled console/Autumn-side and lands here via entitlement refresh. **A license's `expiresAt` currently has no effect for non-trial keys:** the console's comp attach sends `customize: { price: null }` with no `ends_at`, so the comped plan never lapses. Nothing in AP reads `licenseExpiresAt` either. +- **A trial's term counts from key creation, not from activation.** Both console mint paths (`licenseKeysService.create` for sales, `externalService.generateKey` for the self-serve form) write `expiresAt = now + valid_days` up front, and both set `activatedAt` to that same creation timestamp — the column name is a misnomer, nothing ever restamps it. Console `activate` only ever *reads* `expiresAt` and attaches the **remainder** (`trialDays = ceil((expiresAt - now) / day)`), so every day a customer waits before activating is a day of trial they never get. A 20-day key activated on day 8 is a 12-day trial; on day 21 it is the dead-key case below. The only lever today is sales extending the expiry by hand. - **A trial key with a null or past `expiresAt` activates into no plan at all.** Console `activate` attaches only when `isTrial && trialDaysRemaining(expiresAt) >= 1`, else when `!isTrial` (comp) — a trial whose remaining days round to 0 falls through both branches, the customer is created with no subscription, and Autumn's `auto_enable` puts it on `free`. The platform then gets every EE flag revoked, one seat, `billingEnforced` on and powered-by branding on its first request after upgrade. - **The Autumn plan is the whole truth on refresh.** `mapAutumnFeaturesToPlatformPlan` does `flags[feature] = entitlements.flags[feature] ?? false`, so any flag the target plan omits is revoked — a license-key → plan mapping that drops one feature silently downgrades that customer. Audit a migration mapping flag-by-flag against the live Autumn catalog before shipping it, not just plan-by-plan. - Activation is fail-safe but retried: if the console call throws, credentials are never saved and the existing `platform_plan` flags stand; `ensureEnrolled` is re-attempted every 300s (`getEnrollAttemptKey`), and entitlement refresh is throttled to 15 min thereafter. diff --git a/bun.lock b/bun.lock index e12f596f80bd..96399c7cd14d 100644 --- a/bun.lock +++ b/bun.lock @@ -116,7 +116,7 @@ }, "packages/core/execution": { "name": "@activepieces/core-execution", - "version": "0.15.0", + "version": "0.17.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -148,7 +148,7 @@ }, "packages/core/piece-types": { "name": "@activepieces/core-piece-types", - "version": "0.5.0", + "version": "0.7.0", "dependencies": { "@activepieces/core-utils": "workspace:*", "tslib": "2.6.2", @@ -161,7 +161,7 @@ }, "packages/core/shared": { "name": "@activepieces/shared", - "version": "0.146.0", + "version": "0.147.0", "dependencies": { "@activepieces/core-execution": "workspace:*", "@activepieces/core-formula": "workspace:*", @@ -186,7 +186,7 @@ }, "packages/core/utils": { "name": "@activepieces/core-utils", - "version": "0.4.0", + "version": "0.5.0", "dependencies": { "deepmerge-ts": "7.1.0", "ipaddr.js": "2.3.0", @@ -334,7 +334,7 @@ }, "packages/pieces/community/ai": { "name": "@activepieces/piece-ai", - "version": "0.8.0", + "version": "0.9.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", diff --git a/docs/install/reference/breaking-changes.mdx b/docs/install/reference/breaking-changes.mdx index fda3d92d5d9f..3d3d81d07756 100644 --- a/docs/install/reference/breaking-changes.mdx +++ b/docs/install/reference/breaking-changes.mdx @@ -24,6 +24,21 @@ The `SMTP Blacklist Sender` checkbox is replaced by a `Blocked Sender Addresses` Nothing to configure or migrate, and no existing step stops working. Review any flow whose Create or Update Contact step relied on the old behaviour: a step that was silently wiping attributes will now leave them intact, and a step you were relying on to blacklist contacts must have the checkbox explicitly ticked. To block a sender for a contact, list that sender's address in `Blocked Sender Addresses` — it must be an active sender in your Brevo account, or Brevo answers *"One of the sender is invalid or inactive"*. +#### A Run Agent step whose turn dies now fails its flow run + +A Run Agent step whose turn died — no AI provider configured for the chosen vendor, a revoked API key, a model the provider no longer serves — used to report success. The step returned the failed agent result rather than raising it, so the step read SUCCEEDED, the run completed SUCCEEDED, and later steps acted on a payload that carried the failure inside it. Such a run is now FAILED, and the agent's error message is the step's error. + +Two cases are deliberately unaffected, because the agent did produce usable output: + +- A turn that finished but had a single tool call error. The agent may have recovered from it and reported on it, so those runs keep succeeding exactly as before. +- A turn that was cut short by the output limit or the run's usage budget. It still returns the work it completed, and the flow still continues. + +#### What you need to do + +Nothing to configure or migrate, and no new environment variable. Two things to check if either applies to you. If a flow relied on continuing past a dead agent step, tick **Continue on failure** on that step — it now takes effect, where previously the step never failed for it to apply to. If a flow branches on the agent result (a Router reading the step's `status`), that branch no longer runs when the turn dies, because the step raises instead of returning; move that handling onto the step's failure path. + +If you alert on run status, expect agent flows that were silently completing on a misconfigured provider to start showing as failed. That is the misconfiguration surfacing, not new breakage. + #### Workers no longer pre-warm the flow cache on startup by default Since v0.86.1 every worker pre-filled its local piece and code cache on startup by resolving and compiling every enabled flow on the platform. That warm-up costs memory and CPU proportional to the number of enabled flows: on instances with many flows it pinned each worker at its CPU limit for the duration and spiked memory enough to OOM-kill small workers, especially during upgrades when all workers restart at once. The warm-up is now opt-in behind the new `AP_PREWARM_CACHE_ON_STARTUP` worker environment variable, which defaults to `false`. When disabled, caches fill lazily on each flow's first run after a worker starts, exactly as they did before v0.86.1. diff --git a/package.json b/package.json index 82716d5fc510..51e358ffc1af 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "lint-pieces": "turbo run lint --filter='@activepieces/piece-*'", "lint-affected": "turbo run lint --affected", "lint-dev": "turbo run lint --filter='!@activepieces/piece-*' --force -- --fix", - "test-unit": "turbo run test --filter=@activepieces/engine --filter=@activepieces/shared --filter=@activepieces/sandbox --filter=@activepieces/core-utils --filter=@activepieces/server-utils --filter=@activepieces/pieces-framework --filter=web --filter=ee-embed-sdk", + "test-unit": "turbo run test --filter=@activepieces/engine --filter=@activepieces/shared --filter=@activepieces/sandbox --filter=@activepieces/core-utils --filter=@activepieces/core-execution --filter=@activepieces/server-utils --filter=@activepieces/pieces-framework --filter=web --filter=worker --filter=ee-embed-sdk", "test-api": "turbo run check-migrations test-ce test-ee test-cloud --filter=api --concurrency=1", "agent-evals": "set -a && . ./.env.dev && set +a && tsx --tsconfig packages/server/worker/test/lib/agent-eval/cli/tsconfig.json packages/server/worker/test/lib/agent-eval/cli/index.ts", "agent-evals:ci": "if [ -f ./.env.dev ]; then set -a && . ./.env.dev && set +a; fi && vitest run --dir packages/server/worker/test/lib/agent-eval", diff --git a/packages/core/execution/package.json b/packages/core/execution/package.json index 0a0ba48b19bd..a9ba86da9cd3 100644 --- a/packages/core/execution/package.json +++ b/packages/core/execution/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/core-execution", - "version": "0.15.0", + "version": "0.17.0", "type": "commonjs", "main": "./dist/src/index.js", "scripts": { diff --git a/packages/core/execution/src/lib/agents/index.ts b/packages/core/execution/src/lib/agents/index.ts index aa6225ada6ef..155fc3217738 100644 --- a/packages/core/execution/src/lib/agents/index.ts +++ b/packages/core/execution/src/lib/agents/index.ts @@ -28,6 +28,7 @@ export type AgentResult = { steps: AgentStepBlock[] status: AgentTaskStatus structuredOutput?: unknown + failure?: string } export const MarkdownContentBlock = z.object({ diff --git a/packages/core/execution/src/lib/engine/rpc.ts b/packages/core/execution/src/lib/engine/rpc.ts index 9a80d17ab4ce..a52aacd125fb 100644 --- a/packages/core/execution/src/lib/engine/rpc.ts +++ b/packages/core/execution/src/lib/engine/rpc.ts @@ -1,5 +1,8 @@ +import { ActivepiecesError, isObject, spreadIfNotUndefined } from '@activepieces/core-utils' + const RPC_EVENT = 'rpc' const NOTIFY_EVENT = 'rpc-notify' +const AP_ERROR_PROP = 'apError' // eslint-disable-next-line @typescript-eslint/no-explicit-any type Contract = Record any> @@ -22,7 +25,10 @@ export function createRpcClient( try { const result = await socket.timeout(timeoutMs).emitWithAck(RPC_EVENT, { method, payload }) if (isRpcErrorEnvelope(result)) { - throw new Error(`RPC [${method}] handler threw: ${result.__rpcError}`) + throw Object.assign( + new Error(`RPC [${method}] handler threw: ${result.__rpcError}`), + spreadIfNotUndefined(AP_ERROR_PROP, result.__rpcApError), + ) } return result } @@ -51,7 +57,10 @@ export function createRpcServer( } catch (error) { log?.error({ error, rpc: { method: msg.method } }, 'RPC handler threw') - ack({ __rpcError: error instanceof Error ? error.message : String(error) }) + ack({ + __rpcError: error instanceof Error ? error.message : String(error), + ...spreadIfNotUndefined('__rpcApError', apErrorOf(error)), + }) } }) } @@ -83,8 +92,22 @@ export function createNotifyServer( }) } -function isRpcErrorEnvelope(value: unknown): value is { __rpcError: string } { - return typeof value === 'object' && value !== null && '__rpcError' in value +export function apErrorOf(error: unknown): RpcApError | undefined { + const source = error instanceof ActivepiecesError ? error.error : isObject(error) ? error[AP_ERROR_PROP] : undefined + if (!isObject(source) || typeof source['code'] !== 'string') { + return undefined + } + const entityType = (isObject(source['params']) ? source['params'] : source)['entityType'] + return { code: source['code'], ...spreadIfNotUndefined('entityType', typeof entityType === 'string' ? entityType : undefined) } +} + +function isRpcErrorEnvelope(value: unknown): value is { __rpcError: string, __rpcApError?: unknown } { + return isObject(value) && '__rpcError' in value +} + +export type RpcApError = { + code: string + entityType?: string } type RpcLog = { diff --git a/packages/core/execution/test/automation/engine/rpc.test.ts b/packages/core/execution/test/automation/engine/rpc.test.ts new file mode 100644 index 000000000000..52e5d0c06a33 --- /dev/null +++ b/packages/core/execution/test/automation/engine/rpc.test.ts @@ -0,0 +1,67 @@ +import { ActivepiecesError, ErrorCode } from '@activepieces/core-utils' +import { describe, expect, it } from 'vitest' +import { apErrorOf, createRpcClient, createRpcServer } from '../../../src/lib/engine/rpc' + +type TestContract = { + boom: (input: unknown) => Promise +} + +function loopbackSocket() { + const listeners = new Map void) => void>() + return { + emit: () => undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + on: (event: string, listener: (...args: any[]) => void) => { + listeners.set(event, listener) + }, + timeout: () => ({ + emitWithAck: (event: string, msg: unknown) => new Promise((resolve) => { + listeners.get(event)?.(msg, (result: unknown) => resolve(JSON.parse(JSON.stringify(result)))) + }), + }), + } +} + +function clientFor(boom: () => unknown): TestContract { + const socket = loopbackSocket() + createRpcServer(socket, { boom }) + return createRpcClient(socket, 1_000) +} + +describe('rpc error envelope', () => { + it('carries the code and entity of an ActivepiecesError across the boundary', async () => { + const client = clientFor(() => { + throw new ActivepiecesError({ + code: ErrorCode.ENTITY_NOT_FOUND, + params: { entityId: 'GOOGLE', entityType: 'AIProvider' }, + }, 'the google AI provider is not configured on this platform') + }) + + const caught = await client.boom({}).catch((error: unknown) => error) + + expect(apErrorOf(caught)).toEqual({ code: ErrorCode.ENTITY_NOT_FOUND, entityType: 'AIProvider' }) + expect(caught).toBeInstanceOf(Error) + expect(String(caught)).toContain('the google AI provider is not configured on this platform') + }) + + it('leaves a plain error with nothing to read, so callers cannot mistake it for a known code', async () => { + const client = clientFor(() => { throw new Error('Cannot read properties of undefined') }) + + const caught = await client.boom({}).catch((error: unknown) => error) + + expect(apErrorOf(caught)).toBeUndefined() + expect(String(caught)).toContain('Cannot read properties of undefined') + }) + + it('drops a params object that could not survive the JSON ack', async () => { + const cyclic: Record = { entityType: 'AIProvider' } + cyclic['self'] = cyclic + const client = clientFor(() => { + throw new ActivepiecesError({ code: ErrorCode.ENTITY_NOT_FOUND, params: cyclic }) + }) + + const caught = await client.boom({}).catch((error: unknown) => error) + + expect(apErrorOf(caught)).toEqual({ code: ErrorCode.ENTITY_NOT_FOUND, entityType: 'AIProvider' }) + }) +}) diff --git a/packages/core/piece-types/package.json b/packages/core/piece-types/package.json index d031175a28a7..e83468658878 100644 --- a/packages/core/piece-types/package.json +++ b/packages/core/piece-types/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/core-piece-types", - "version": "0.5.0", + "version": "0.7.0", "type": "commonjs", "main": "./dist/src/index.js", "scripts": { diff --git a/packages/core/piece-types/src/lib/agents.ts b/packages/core/piece-types/src/lib/agents.ts index e028642f897a..cb8f0443e652 100644 --- a/packages/core/piece-types/src/lib/agents.ts +++ b/packages/core/piece-types/src/lib/agents.ts @@ -297,4 +297,5 @@ export type AgentResult = { steps: AgentStepBlock[] status: AgentTaskStatus structuredOutput?: unknown + failure?: string } diff --git a/packages/core/piece-types/src/lib/ai-providers.ts b/packages/core/piece-types/src/lib/ai-providers.ts index 71af927f91c5..a258a3caca1a 100644 --- a/packages/core/piece-types/src/lib/ai-providers.ts +++ b/packages/core/piece-types/src/lib/ai-providers.ts @@ -364,6 +364,11 @@ export const aiProviderUtils = { isCuratedChatModelId, } +export const AI_PROVIDER_ENTITY_TYPES = { + provider: 'AIProvider', + chatProvider: 'ChatAiProvider', +} as const + export type AIWebSearchMode = 'native' | 'plugin' export type OpenAiCompatibleVendor = diff --git a/packages/core/shared/package.json b/packages/core/shared/package.json index 3fdacb919e3d..2450cf7009d9 100644 --- a/packages/core/shared/package.json +++ b/packages/core/shared/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/shared", - "version": "0.146.0", + "version": "0.147.0", "type": "commonjs", "sideEffects": false, "main": "./dist/src/index.js", diff --git a/packages/core/shared/src/lib/management/ai-providers/index.ts b/packages/core/shared/src/lib/management/ai-providers/index.ts index 3c32f92c7588..935b20880ab9 100644 --- a/packages/core/shared/src/lib/management/ai-providers/index.ts +++ b/packages/core/shared/src/lib/management/ai-providers/index.ts @@ -412,6 +412,7 @@ export function splitCloudflareGatewayModelId(modelId: string): { } export { + AI_PROVIDER_ENTITY_TYPES, ALLOWED_CHAT_MODELS_BY_PROVIDER, ACTIVEPIECES_CHAT_TIERS, DEFAULT_CHAT_TIER_ID, diff --git a/packages/core/utils/package.json b/packages/core/utils/package.json index 6323a1ec0b5b..99a0cdf80669 100644 --- a/packages/core/utils/package.json +++ b/packages/core/utils/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/core-utils", - "version": "0.4.0", + "version": "0.5.0", "type": "commonjs", "main": "./dist/src/index.js", "scripts": { diff --git a/packages/core/utils/src/lib/try-catch.ts b/packages/core/utils/src/lib/try-catch.ts index a979460a6c87..d2e9b43d51fb 100644 --- a/packages/core/utils/src/lib/try-catch.ts +++ b/packages/core/utils/src/lib/try-catch.ts @@ -36,6 +36,17 @@ export function tryCatchSync( } } +export function toError(value: unknown): Error { + if (value instanceof Error) { + return value + } + if (typeof value === 'string') { + return new Error(value) + } + const { data: serialized } = tryCatchSync(() => JSON.stringify(value)) + return new Error(serialized ?? String(value)) +} + export type TypedResult = | { success: true, data: T } | { success: false, message: string } diff --git a/packages/pieces/community/ai/package.json b/packages/pieces/community/ai/package.json index a88ea2a51a96..66ed56d33e34 100644 --- a/packages/pieces/community/ai/package.json +++ b/packages/pieces/community/ai/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-ai", - "version": "0.8.0", + "version": "0.9.0", "type": "commonjs", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", diff --git a/packages/pieces/community/ai/src/lib/actions/agents/run-agent.ts b/packages/pieces/community/ai/src/lib/actions/agents/run-agent.ts index 6d88fd1984b8..b35e73cedcd8 100644 --- a/packages/pieces/community/ai/src/lib/actions/agents/run-agent.ts +++ b/packages/pieces/community/ai/src/lib/actions/agents/run-agent.ts @@ -116,6 +116,9 @@ export const runAgent = createAction({ if (isNil(result)) { throw new Error('The agent did not report a result before this step timed out'); } + if (!isNil(result.failure)) { + throw new Error(result.failure); + } return result; } diff --git a/packages/server/api/src/app/ai/ai-provider-service.ts b/packages/server/api/src/app/ai/ai-provider-service.ts index fe938beb5336..a2c25fe33e31 100644 --- a/packages/server/api/src/app/ai/ai-provider-service.ts +++ b/packages/server/api/src/app/ai/ai-provider-service.ts @@ -1,5 +1,5 @@ import { ActivepiecesError, AIProviderName, apId, ErrorCode, isNil, PlatformId, spreadIfDefined, unique } from '@activepieces/core-utils' -import { ActivePiecesProviderAuthConfig, AIProviderAuthConfig, AIProviderConfig, AIProviderModel, AiProviderProjectScope, AIProviderWithoutSensitiveData, CreateAIProviderRequest, GetProviderConfigResponse, ProjectAIProvider, UpdateAIProviderRequest } from '@activepieces/shared' +import { ActivePiecesProviderAuthConfig, AI_PROVIDER_ENTITY_TYPES, AIProviderAuthConfig, AIProviderConfig, AIProviderModel, AiProviderProjectScope, AIProviderWithoutSensitiveData, CreateAIProviderRequest, GetProviderConfigResponse, ProjectAIProvider, UpdateAIProviderRequest } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import cron from 'node-cron' import { repoFactory } from '../core/db/repo-factory' @@ -89,7 +89,7 @@ export const aiProviderService = (log: FastifyBaseLogger) => ({ if (isNil(aiProvider)) { throw new ActivepiecesError({ code: ErrorCode.ENTITY_NOT_FOUND, - params: { entityId: providerId, entityType: 'AIProvider' }, + params: { entityId: providerId, entityType: AI_PROVIDER_ENTITY_TYPES.provider }, }) } @@ -314,7 +314,7 @@ async function resolveEligibleRow({ platformId, provider, scope }: { platformId: code: ErrorCode.ENTITY_NOT_FOUND, params: { entityId: provider, - entityType: 'AIProvider', + entityType: AI_PROVIDER_ENTITY_TYPES.provider, }, }, scope.type === 'platform' ? `the ${provider} AI provider is not configured on this platform` @@ -334,7 +334,7 @@ async function resolveRowForScope({ platformId, provider, scope, configId }: { p code: ErrorCode.ENTITY_NOT_FOUND, params: { entityId: configId, - entityType: 'AIProvider', + entityType: AI_PROVIDER_ENTITY_TYPES.provider, }, }, scope.type === 'platform' ? `the ${provider} AI provider key is not configured on this platform` @@ -364,7 +364,7 @@ async function getRowByIdOrThrow({ platformId, configId }: { platformId: Platfor code: ErrorCode.ENTITY_NOT_FOUND, params: { entityId: configId, - entityType: 'AIProvider', + entityType: AI_PROVIDER_ENTITY_TYPES.provider, }, }) } 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 69095a9a3471..03e92e9e20dc 100644 --- a/packages/server/api/src/app/ee/agent/agent-controller.ts +++ b/packages/server/api/src/app/ee/agent/agent-controller.ts @@ -79,6 +79,7 @@ export const agentController: FastifyPluginAsyncZod = async (app) => { projectId: request.projectId, userId: await resolveUserId(request), request: request.body, + goLive: true, }) applicationEvents(request.log).sendUserEvent(request, { action: ApplicationEventName.AGENT_UPDATED, 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 c8dc55808ba7..b92301030e34 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 @@ -159,7 +159,7 @@ export const agentConversationController: FastifyPluginAsyncZod = async (app) => const agent = isNil(conversation.agentId) ? null : await agentService(log).getOneOrThrowByPlatform({ id: conversation.agentId, platformId, userId }) - const agentConfig = agent?.published ?? agent?.draft ?? null + const agentConfig = 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. @@ -342,7 +342,7 @@ async function pinnedAccounts({ conversation, pieceName, platformId, userId, log } const agent = await agentService(log).getOneOrThrowByPlatform({ id: agentId, platformId, userId }) const normalizedPiece = normalizePiece(pieceName) - const config = agent.published ?? agent.draft + const config = agent.draft const externalIds = config.tools.flatMap((tool) => { if (tool.type !== AgentToolType.PIECE || normalizePiece(tool.pieceMetadata.pieceName) !== normalizedPiece) { return [] 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 e4394c1eea1c..b34aa8afe59d 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 @@ -20,12 +20,6 @@ export const agentConversationService = (log: FastifyBaseLogger) => ({ 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, 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 6ac0f921676f..f296a3b2e260 100644 --- a/packages/server/api/src/app/ee/agent/agent-helpers.ts +++ b/packages/server/api/src/app/ee/agent/agent-helpers.ts @@ -1,6 +1,6 @@ import { ActivepiecesError, AIProviderName, apId, ErrorCode, isNil, spreadIfDefined, tryCatch, unique } from '@activepieces/core-utils' import { agentAiUtils } from '@activepieces/server-utils' -import { ACTIVEPIECES_CHAT_TIERS, AgentConversation, AgentConversationStatus, aiProviderUtils, DEFAULT_CHAT_TIER_ID, GetAgentMemoryResponse, GetProviderConfigResponse, Project, ProjectType, UserMemory } from '@activepieces/shared' +import { ACTIVEPIECES_CHAT_TIERS, AgentConversation, AgentConversationStatus, AI_PROVIDER_ENTITY_TYPES, aiProviderUtils, DEFAULT_CHAT_TIER_ID, GetAgentMemoryResponse, GetProviderConfigResponse, Project, ProjectType, UserMemory } from '@activepieces/shared' import { SharedV3ProviderOptions } from '@ai-sdk/provider' import { EmbeddingModel, LanguageModel } from 'ai' import { FastifyBaseLogger } from 'fastify' @@ -118,7 +118,7 @@ async function resolveChatProvider({ platformId, scope, log }: { platformId: str if (isNil(chatProvider)) { throw new ActivepiecesError({ code: ErrorCode.ENTITY_NOT_FOUND, - params: { entityId: platformId, entityType: 'ChatAiProvider' }, + params: { entityId: platformId, entityType: AI_PROVIDER_ENTITY_TYPES.chatProvider }, }, 'no AI provider on this platform is enabled for chat') } return chatProvider @@ -130,7 +130,7 @@ async function assertRunProviderConfigured({ platformId, provider, providerConfi if (isNil(chatProvider)) { throw new ActivepiecesError({ code: ErrorCode.ENTITY_NOT_FOUND, - params: { entityId: platformId, entityType: 'ChatAiProvider' }, + params: { entityId: platformId, entityType: AI_PROVIDER_ENTITY_TYPES.chatProvider }, }, 'no AI provider on this platform is enabled for chat') } return @@ -139,7 +139,7 @@ async function assertRunProviderConfigured({ platformId, provider, providerConfi if (!configured) { throw new ActivepiecesError({ code: ErrorCode.ENTITY_NOT_FOUND, - params: { entityId: provider, entityType: 'AIProvider' }, + params: { entityId: provider, entityType: AI_PROVIDER_ENTITY_TYPES.provider }, }, scope.type === 'platform' ? `the ${provider} AI provider is not configured on this platform` : `no ${provider} AI provider key is available to this project`) 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 b68f6b0a1df7..d4f551a6bbd2 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 @@ -173,8 +173,9 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ const selectedModel = modelName ?? conversation.modelName ?? null // The tier resolver finds no tier for a concrete model id and silently returns the default, // so a source that names its own model must never be routed through it. - const tier = agentHelpers.resolveTier({ tierId: carriesChatContext ? selectedModel : null }) - const resolvedModelId = !carriesChatContext && !isNil(modelName) + const namesItsOwnModel = requestedSource === AgentRunSource.FLOW_STEP || requestedSource === AgentRunSource.AGENT + const tier = agentHelpers.resolveTier({ tierId: namesItsOwnModel ? null : selectedModel }) + const resolvedModelId = namesItsOwnModel && !isNil(modelName) ? modelName : agentHelpers.resolveModelIdForProvider({ provider: providerConfig.provider, selectedModel }) 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 1b7eb3b9f7da..daadbe8b2197 100644 --- a/packages/server/api/src/app/ee/agent/agent-service.ts +++ b/packages/server/api/src/app/ee/agent/agent-service.ts @@ -89,7 +89,7 @@ export const agentService = (log: FastifyBaseLogger) => ({ return this.getOneOrThrow({ id, projectId: agent.projectId, userId }) }, - async update({ id, projectId, userId, request }: UpdateParams): Promise { + async update({ id, projectId, userId, request, goLive = false }: UpdateParams): Promise { const agent = await this.getOneOrThrow({ id, projectId, userId }) await assertMayChangeWhoCanSee({ agent, request, projectId, userId, log }) const visibility = request.visibility ?? agent.visibility @@ -101,7 +101,8 @@ export const agentService = (log: FastifyBaseLogger) => ({ log, }) const draft = isNil(request.draft) ? agent.draft : sanitizeObjectForPostgresql(request.draft) - await agentRepo().save({ ...omit(agent, ['published']), ...request, draft, visibility, sharedWithUserIds }) + const published = goLive && agentUtils.isPublishable(draft) ? draft : agent.published + await agentRepo().save({ ...omit(agent, ['published']), ...request, draft, published, visibility, sharedWithUserIds }) return this.getOneOrThrow({ id, projectId, userId }) }, @@ -342,6 +343,7 @@ type GetByPlatformParams = { type UpdateParams = GetParams & { request: UpdateAgentRequest + goLive?: boolean } type ResolveProjectsParams = { 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 5c006c501515..8977cddc154b 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 @@ -82,7 +82,7 @@ function buildBuilderSystemPrompt({ agent }: { agent: Agent | null }): string { `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'}`, + 'Your changes land as pending edits the person reviews. They go live when the person hits Save and go live, which is the only way anything is published, so say the change is ready for them to review rather than telling them to publish it.', ].join('\n') return PROMPT_TEMPLATES.builder.replace('{{AGENT_STATE}}', state) } diff --git a/packages/server/api/src/app/helper/error-handler.ts b/packages/server/api/src/app/helper/error-handler.ts index 455496a40e99..236c50a70ea6 100644 --- a/packages/server/api/src/app/helper/error-handler.ts +++ b/packages/server/api/src/app/helper/error-handler.ts @@ -44,7 +44,7 @@ export const enrichWideEventWithError = (error: unknown): void => { if (statusCode >= StatusCodes.INTERNAL_SERVER_ERROR) { wideErrorFields.stack = error.stack } - wideEvent.set({ error: wideErrorFields }) + wideEvent.set({ status: statusCode, error: wideErrorFields }) return } const parsed = parseError(error) @@ -65,7 +65,7 @@ export const enrichWideEventWithError = (error: unknown): void => { if (statusCode >= StatusCodes.INTERNAL_SERVER_ERROR.valueOf() && error instanceof Error) { wideErrorFields.stack = error.stack } - wideEvent.set({ error: wideErrorFields }) + wideEvent.set({ status: statusCode, error: wideErrorFields }) } function hasStatusCode(error: unknown): error is { statusCode: number } { diff --git a/packages/server/api/src/assets/prompts/agent-builder-prompt.md b/packages/server/api/src/assets/prompts/agent-builder-prompt.md index 1cc444b457a6..4a5109483d4b 100644 --- a/packages/server/api/src/assets/prompts/agent-builder-prompt.md +++ b/packages/server/api/src/assets/prompts/agent-builder-prompt.md @@ -16,8 +16,8 @@ Give it a tool only when the job needs one, and look the piece up before you nam 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 +## Going live -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. +Your edits land as pending changes the person reviews in the panel beside you. Nothing you do is live until they hit Save and go live, which is theirs to press and not yours. So say what you changed and that it is ready for them to review, and never imply a change is already running. 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/test/integration/ee/agent/agent-builder-conversation.test.ts b/packages/server/api/test/integration/ee/agent/agent-builder-conversation.test.ts index cb2932bc1f43..a1922e00bf95 100644 --- 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 @@ -1,5 +1,5 @@ import { AIProviderName } from '@activepieces/core-utils' -import { AgentIcon, AgentRunSource, ColorName } from '@activepieces/shared' +import { AgentIcon, AgentRunSource, DEFAULT_CHAT_TIER_ID, ColorName } from '@activepieces/shared' import { FastifyInstance } from 'fastify' import { StatusCodes } from 'http-status-codes' import { afterAll, beforeAll, describe, expect, it } from 'vitest' @@ -53,6 +53,29 @@ describe('starting a builder conversation', () => { expect(response.json().agentId).toBe(agent.id) }) + it('starts a fresh thread each time, so reopening does not resume an old edit session', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + + const first = await ctx.post(CONVERSATIONS_URL, { agentId: agent.id, builder: true }) + const second = await ctx.post(CONVERSATIONS_URL, { agentId: agent.id, builder: true }) + + expect(first.statusCode).toBe(StatusCodes.CREATED) + expect(second.statusCode).toBe(StatusCodes.CREATED) + expect(second.json().id).not.toBe(first.json().id) + }) + + it('keeps the builder threads out of the agent conversation list', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + await ctx.post(CONVERSATIONS_URL, { agentId: agent.id, builder: true }) + + const listed = await ctx.get(`${CONVERSATIONS_URL}?agentId=${agent.id}&limit=20`) + + expect(listed.statusCode).toBe(StatusCodes.OK) + expect(listed.json().data).toEqual([]) + }) + it('builds a new agent in a project the caller names', async () => { const ctx = await context() @@ -150,6 +173,26 @@ describe('what the builder can actually reach at run time', () => { expect(config.mcpCredentials).not.toBeNull() expect(config.agentsAvailable).toBe(true) }) + + it('resolves the tier it inherits from chat, rather than sending it to the provider', 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, modelName: DEFAULT_CHAT_TIER_ID }) + + 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: DEFAULT_CHAT_TIER_ID, + source: AgentRunSource.AGENT_BUILDER, + }) + + expect(config.modelId).not.toBe(DEFAULT_CHAT_TIER_ID) + expect(config.modelId).toBe('anthropic/claude-sonnet-4.6') + }) }) describe('what the builder is told', () => { @@ -167,7 +210,8 @@ describe('what the builder is told', () => { expect(prompt).toContain('Inbox triage') expect(prompt).toContain('Sort unread mail.') expect(prompt).toContain('Tools: none') - expect(prompt).toContain('nothing runs this agent yet') + expect(prompt).toContain('Save and go live') + expect(prompt).not.toContain('until it is published') }) it('says there is no agent yet when it is starting one', () => { 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 index d8cf46fc0c7a..4b05ac2f41a9 100644 --- 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 @@ -90,7 +90,7 @@ describe('which accounts a conversation may be offered', () => { expect(secondRead).toEqual(body) }) - it('narrows the account the agent actually runs on, which is the published one', async () => { + it('narrows the account the agent actually runs on, which is the draft one', async () => { const ctx = await context() const published = await saveConnection({ ctx, externalId: apId() }) const draftOnly = await saveConnection({ ctx, externalId: apId() }) @@ -104,7 +104,7 @@ describe('which accounts a conversation may be offered', () => { const body = await pickerConnections({ ctx, conversationId: conversation.json().id }) - expect(body.connections.map((connection: { externalId: string }) => connection.externalId)).toEqual([published.externalId]) + expect(body.connections.map((connection: { externalId: string }) => connection.externalId)).toEqual([draftOnly.externalId]) }) it('falls back to the full picker where the agent pinned no account, because there is nothing to repair', async () => { 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 55a15a48dfbd..2dfed2a894ab 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 @@ -115,16 +115,34 @@ describe('agent publish', () => { expect(response.json().published).toStrictEqual(response.json().draft) }) - it('leaves the published copy alone when the draft moves on', async () => { + it('carries the published copy along when the draft is saved', async () => { const ctx = await context() const agent = await createAgent(ctx) - await ctx.post(`/v1/agents/${agent.id}/publish`) await ctx.post(`/v1/agents/${agent.id}`, { draft: { ...agentBody(ctx.project.id).draft, instructions: 'Rewritten.' } }) const after = (await ctx.get(`/v1/agents/${agent.id}`)).json() expect(after.draft.instructions).toBe('Rewritten.') - expect(after.published.instructions).toBe('Draft launch posts.') + expect(after.published.instructions).toBe('Rewritten.') + }) + + it('publishes on the first save, so a flow can run an agent nobody published by hand', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + expect(agent.published).toBeNull() + + await ctx.post(`/v1/agents/${agent.id}`, { description: 'Now with a description.' }) + + expect((await ctx.get(`/v1/agents/${agent.id}`)).json().published).not.toBeNull() + }) + + it('publishes nothing while the instructions are empty, because there is nothing runnable to pin', async () => { + const ctx = await context() + const agent = await createAgent(ctx, { draft: { ...agentBody(ctx.project.id).draft, instructions: '' } }) + + await ctx.post(`/v1/agents/${agent.id}`, { description: 'Still empty.' }) + + expect((await ctx.get(`/v1/agents/${agent.id}`)).json().published).toBeNull() }) it.each([['spaces', ' '], ['tabs', '\t\t'], ['newlines', '\n\n'], ['empty', '']])( 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 14c3414be324..ad08a01eb2d5 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 @@ -97,6 +97,27 @@ describe('an agent conversation', () => { }) }) +describe('which version a conversation runs', () => { + it('answers on what was saved, with nothing left to publish afterwards', async () => { + const ctx = await context() + await enableForChat(ctx.platform.id, AIProviderName.OPENROUTER) + const agent = await createAgent(ctx, { modelName: CONFIGURED_MODEL, provider: AIProviderName.OPENROUTER }) + const cleared = await ctx.post(`/v1/agents/${agent.id}`, { + draft: { ...agent.draft, provider: null, modelName: null }, + }) + expect(cleared.statusCode).toBe(StatusCodes.OK) + expect(cleared.json().published.modelName).toBeNull() + const conversation = await startConversation(ctx, agent.id) + + const response = await ctx.post(`${CONVERSATIONS_URL}/${conversation.id}/messages`, { + content: 'hello', + }) + + expect(response.statusCode).toBe(StatusCodes.CONFLICT) + expect(response.json().params.message).toContain('Pick a model') + }) +}) + 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() diff --git a/packages/server/api/test/unit/app/helper/error-handler-wide-event.test.ts b/packages/server/api/test/unit/app/helper/error-handler-wide-event.test.ts new file mode 100644 index 000000000000..c7df59b6c57a --- /dev/null +++ b/packages/server/api/test/unit/app/helper/error-handler-wide-event.test.ts @@ -0,0 +1,40 @@ +import { ActivepiecesError, ErrorCode } from '@activepieces/core-utils' +import { StatusCodes } from 'http-status-codes' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockSet } = vi.hoisted(() => ({ mockSet: vi.fn() })) + +vi.mock('@activepieces/server-utils', async (importOriginal) => ({ + ...await importOriginal(), + wideEvent: { set: mockSet }, +})) + +import { enrichWideEventWithError } from '../../../../src/app/helper/error-handler' + +function loggedStatus(): unknown { + return mockSet.mock.calls[0][0].status +} + +describe('enrichWideEventWithError — a handled 4xx must not log as a 500', () => { + beforeEach(() => { + mockSet.mockClear() + }) + + it('records a quota rejection as payment required, not a server error', () => { + enrichWideEventWithError(new ActivepiecesError({ code: ErrorCode.QUOTA_EXCEEDED, params: { metric: 'credits', quota: 0 } })) + + expect(loggedStatus()).toBe(StatusCodes.PAYMENT_REQUIRED) + }) + + it('records a missing entity as not found', () => { + enrichWideEventWithError(new ActivepiecesError({ code: ErrorCode.ENTITY_NOT_FOUND, params: { entityType: 'AIProvider' } })) + + expect(loggedStatus()).toBe(StatusCodes.NOT_FOUND) + }) + + it('still records a genuine server fault as a 500', () => { + enrichWideEventWithError(new Error('Cannot read properties of undefined')) + + expect(loggedStatus()).toBe(StatusCodes.INTERNAL_SERVER_ERROR) + }) +}) diff --git a/packages/server/engine/esbuild.config.mjs b/packages/server/engine/esbuild.config.mjs index ab3f5c079a5f..663bc9c729c0 100644 --- a/packages/server/engine/esbuild.config.mjs +++ b/packages/server/engine/esbuild.config.mjs @@ -10,7 +10,7 @@ const pieceChildOutfile = path.join(outdir, 'piece-child.js'); const watch = process.argv.includes('--watch'); -fs.rmSync(outdir, { recursive: true, force: true }); +fs.mkdirSync(outdir, { recursive: true }); const zodLocaleTrim = { // Drop zod's 46 unused locale packs (~184KB). The app surfaces validation @@ -39,6 +39,7 @@ function rebuildLogger(outfile) { }); build.onEnd((result) => { if (result.metafile) { + fs.mkdirSync(path.dirname(outfile), { recursive: true }); fs.writeFileSync( outfile + '.meta.json', JSON.stringify(result.metafile) diff --git a/packages/server/utils/src/ap-logger.ts b/packages/server/utils/src/ap-logger.ts index 84b0449ff251..7c27c556e89f 100644 --- a/packages/server/utils/src/ap-logger.ts +++ b/packages/server/utils/src/ap-logger.ts @@ -1,3 +1,4 @@ +import { toError } from '@activepieces/core-utils' import { log } from 'evlog' import { wideEvent } from './wide-event' @@ -150,13 +151,12 @@ function normalizePinoArgsWithError(args: unknown[]): { message: string | undefi const second = args[1] const message = typeof second === 'string' ? second : undefined - // pino convention: obj.err or obj.error as Error const errField = first['err'] ?? first['error'] - if (errField instanceof Error) { + if (isRecord(errField)) { const { err: _err, error: _error, ...rest } = first void _err void _error - return { message, fields: rest, err: errField } + return { message, fields: rest, err: toError(errField) } } return { message, fields: first, err: undefined } } diff --git a/packages/server/utils/src/wide-event.ts b/packages/server/utils/src/wide-event.ts index 39ef44e11342..cdc2f5543bf5 100644 --- a/packages/server/utils/src/wide-event.ts +++ b/packages/server/utils/src/wide-event.ts @@ -1,3 +1,4 @@ +import { toError } from '@activepieces/core-utils' import { AsyncLocalStorage } from 'node:async_hooks' import { audit as standaloneAudit, AuditInput, RequestLogger, withAuditMethods } from 'evlog' @@ -14,8 +15,7 @@ function set(fields: Record): void { function error(err: unknown): void { const store = als.getStore() if (!store) return - const wrapped = err instanceof Error ? err : new Error(String(err)) - store.error(wrapped) + store.error(toError(err)) } async function timed({ name, fn }: { name: string, fn: () => Promise }): Promise { diff --git a/packages/server/utils/test/ap-logger.test.ts b/packages/server/utils/test/ap-logger.test.ts index fea1963dcdaf..22a081d1f940 100644 --- a/packages/server/utils/test/ap-logger.test.ts +++ b/packages/server/utils/test/ap-logger.test.ts @@ -88,6 +88,20 @@ describe('apLogger', () => { expect(arg.error).toContain('boom') }) + it('error(obj with a thrown object) keeps the payload instead of logging nothing', () => { + const logger = apLogger.create({}) + logger.error({ error: { code: 'TOOL_FAILED', tool: 'ap_web_search' } }, 'agent job failed') + expect(spies.logErrorSpy.mock.calls[0][0].error).toContain('TOOL_FAILED') + }) + + it('error(obj with a string error) leaves it as it is', () => { + const logger = apLogger.create({}) + logger.error({ error: 'connection refused', requestId: 'r1' }, 'context msg') + const arg = spies.logErrorSpy.mock.calls[0][0] + expect(arg.error).toBe('connection refused') + expect(arg.requestId).toBe('r1') + }) + it('error(obj with .err Error) extracts the error under the error key', () => { const logger = apLogger.create({}) const err = new Error('nested') diff --git a/packages/server/utils/test/evlog-wide-event.test.ts b/packages/server/utils/test/evlog-wide-event.test.ts index 130e93328bb7..a6c879fb6083 100644 --- a/packages/server/utils/test/evlog-wide-event.test.ts +++ b/packages/server/utils/test/evlog-wide-event.test.ts @@ -76,6 +76,32 @@ describe('wideEvent', () => { expect((err as Error).message).toBe('something went wrong') }) + it('error() keeps a thrown object\'s payload, which is the only place it survives', () => { + const logger = makeMockLogger() + wideEvent.run({ + logger, + fn: () => { + wideEvent.error({ code: 'TOOL_FAILED', tool: 'ap_web_search' }) + }, + }) + const [err] = logger._errors[0] + expect((err as Error).message).toContain('TOOL_FAILED') + expect((err as Error).message).toContain('ap_web_search') + }) + + it('error() does not throw on a value it cannot serialize', () => { + const logger = makeMockLogger() + const cyclic: Record = {} + cyclic['self'] = cyclic + wideEvent.run({ + logger, + fn: () => { + wideEvent.error(cyclic) + }, + }) + expect(logger._errors).toHaveLength(1) + }) + it('error() passes an existing Error directly', () => { const logger = makeMockLogger() const original = new Error('original') diff --git a/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-agent-run.ts b/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-agent-run.ts index a22330f6ab5c..9fc7c1672e7c 100644 --- a/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-agent-run.ts +++ b/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-agent-run.ts @@ -7,7 +7,7 @@ import { agentMcpClient, McpConnection } from './agent-mcp-client' import { stepResultFrom } from './agent-step-result' import { agentToolPolicy } from './agent-tool-policy' import { agentWorkerTools, GateDecision, TaintState } from './agent-worker-tools' -import { delayWithJitter, isTransientFailureText, runAgentTurn } from './run-agent-turn' +import { classifyAgentRunError, delayWithJitter, isTransientFailureText, runAgentTurn } from './run-agent-turn' const BATCH_SIZE = 10 const BATCH_FLUSH_MS = 50 @@ -360,18 +360,17 @@ export const executeAgentRunJob: JobHandler releaseFlowStep({ ctx, conversationId, flowRunId, waitpointId, output: failedResult, source, log })) // Empty arrays here mean "mark this turn ERROR" — they do NOT wipe history. The @@ -382,17 +381,14 @@ export const executeAgentRunJob: JobHandler {}) await sendEventWithRetry({ - event: { type: AgentEventType.ERROR, data: { message: clientMessage, ...spreadIfDefined('code', errorCode) } }, + event: { type: AgentEventType.ERROR, data: { message: clientMessage, ...spreadIfDefined('code', isCreditError ? ErrorCode.QUOTA_EXCEEDED : undefined) } }, }) await sendEventWithRetry({ event: { type: AgentEventType.FINISHED, data: { conversationId } }, }) - // Running out of AI credits is a user/billing condition, not an engine failure — the error has - // already been delivered to the user. Complete the job (OK); re-throwing would mark it - // INTERNAL_ERROR and fail+retry it (pointlessly — the user is still out of credits) and page. - if (isCreditError) { + if (errorClass !== 'internal') { if (isNil(releaseError)) { - return { kind: JobResultKind.FIRE_AND_FORGET, status: EngineResponseStatus.OK } + return { kind: JobResultKind.FIRE_AND_FORGET, status: EngineResponseStatus.USER_FAILURE } } throw releaseError } @@ -825,12 +821,6 @@ function sanitizeGeneratedTitle(rawTitle: string): string { .slice(0, 100) } -const CREDIT_ERROR_PATTERNS = [/credits/i, /\b402\b/, /payment.required/i] - -function isCreditExhaustedError(message: string): boolean { - return CREDIT_ERROR_PATTERNS.some((pattern) => pattern.test(message)) -} - function waitForAbort(signal: AbortSignal): Promise { if (signal.aborted) { return Promise.resolve() diff --git a/packages/server/worker/src/lib/execute/jobs/ee/agent/run-agent-turn.ts b/packages/server/worker/src/lib/execute/jobs/ee/agent/run-agent-turn.ts index 95a2b1f54393..005cf1f2cf04 100644 --- a/packages/server/worker/src/lib/execute/jobs/ee/agent/run-agent-turn.ts +++ b/packages/server/worker/src/lib/execute/jobs/ee/agent/run-agent-turn.ts @@ -1,7 +1,7 @@ -import { AIProviderName, isObject, spreadIfDefined, tryCatch, tryCatchSync } from '@activepieces/core-utils' +import { AIProviderName, ErrorCode, isNil, isObject, spreadIfDefined, tryCatch, tryCatchSync } from '@activepieces/core-utils' import { agentAiUtils, ContentPartLike } from '@activepieces/server-utils' -import { AgentPhase, agentToolClassification, agentToolPhases, aiProviderUtils, PersistedAgentPart } from '@activepieces/shared' -import { generateText, isLoopFinished, isStepCount, LanguageModel, LanguageModelUsage, ModelMessage, StepResultPerformance, StopCondition, streamText, ToolExecutionOptions, ToolSet } from 'ai' +import { AgentPhase, agentToolClassification, agentToolPhases, aiProviderUtils, apErrorOf, PersistedAgentPart } from '@activepieces/shared' +import { APICallError, generateText, isLoopFinished, isStepCount, LanguageModel, LanguageModelUsage, ModelMessage, RetryError, StepResultPerformance, StopCondition, streamText, ToolExecutionOptions, ToolSet } from 'ai' const MAX_RESPONSE_OUTPUT_TOKENS = 32_000 const MAX_AUTO_CONTINUATIONS = 3 @@ -12,6 +12,10 @@ const MAX_IDENTICAL_TOOL_FAILURES = 2 const IN_LOOP_COMPACTION_THRESHOLD = 0.6 const RUNAWAY_TURN_CONTEXT_MULTIPLE = 90 const STREAM_RETRY_BASE_DELAY_MS = 1_000 +const QUOTA_MARKER = /insufficient_quota/i +const CREDIT_ERROR_PATTERNS = [/credits/i, /\b402\b/, /payment.required/i, QUOTA_MARKER] +const USER_FAULT_STATUS_CODES = new Set([401, 403, 404]) +const USER_CONFIG_ENTITY_TYPES = new Set(['AIProvider', 'ChatAiProvider']) const CONTINUE_NUDGE = '[system note — not from the user] Your previous response was cut off by the output token limit before it finished. Continue exactly where you stopped. If a tool call was cut off, re-issue it in FULL. Do not repeat content you already produced.' const EMPTY_OUTPUT_NUDGE = '[system note — not from the user] Your previous step produced no visible reply to the user. Continue the task now: either call the next tool, or write your reply to the user. Do not stop silently.' @@ -319,6 +323,28 @@ function fingerprintInput(input: unknown): string { return data ?? '' } +export function classifyAgentRunError({ error, provider }: { error: unknown, provider?: string }): AgentRunErrorClass { + const cause = RetryError.isInstance(error) ? error.lastError : error + const apiError = APICallError.isInstance(cause) ? cause : undefined + const apError = apErrorOf(cause) + const message = cause instanceof Error ? cause.message : String(cause) + if (apError?.code === ErrorCode.QUOTA_EXCEEDED + || apiError?.statusCode === 402 + || CREDIT_ERROR_PATTERNS.some((pattern) => pattern.test(message)) + || QUOTA_MARKER.test(apiError?.responseBody ?? '')) { + return 'credit' + } + if (isNil(apiError)) { + return apError?.code === ErrorCode.ENTITY_NOT_FOUND && USER_CONFIG_ENTITY_TYPES.has(apError.entityType ?? '') + ? 'user' + : 'internal' + } + return USER_FAULT_STATUS_CODES.has(apiError.statusCode ?? 0) + && (apiError.statusCode === 404 || provider !== AIProviderName.ACTIVEPIECES) + ? 'user' + : 'internal' +} + // Transient = worth retrying (rate limit, 5xx, timeout, dropped socket); these are exempt from the // repeat-breaker so the agent isn't blocked from re-trying a call that can legitimately recover. export function isTransientFailureText(text: string): boolean { @@ -430,3 +456,5 @@ export type AgentTurnResult = { totalOutputTokens: number toolCalls: AgentTurnToolCall[] } + +type AgentRunErrorClass = 'credit' | 'user' | 'internal' diff --git a/packages/server/worker/test/lib/execute/jobs/ee/agent/execute-agent-run-config-failure.test.ts b/packages/server/worker/test/lib/execute/jobs/ee/agent/execute-agent-run-config-failure.test.ts index 1ba289d9b6fc..05c56ff81377 100644 --- a/packages/server/worker/test/lib/execute/jobs/ee/agent/execute-agent-run-config-failure.test.ts +++ b/packages/server/worker/test/lib/execute/jobs/ee/agent/execute-agent-run-config-failure.test.ts @@ -60,27 +60,38 @@ describe('executeAgentRunJob — a config failure must not swallow the turn', () waitpointId: 'waitpoint-1', }) - await expect(executeAgentRunJob.execute(ctx, data)).rejects.toThrow('ENTITY_NOT_FOUND') + const result = await executeAgentRunJob.execute(ctx, data) + expect(result.status).toBe(EngineResponseStatus.USER_FAILURE) expect(resumed).toHaveLength(1) expect(resumed[0].waitpointId).toBe('waitpoint-1') expect(JSON.stringify(resumed[0].output)).toContain('FAILED') + expect(resumed[0].output).toMatchObject({ failure: expect.stringContaining('ENTITY_NOT_FOUND') }) }) it('tells the chat client the turn failed instead of leaving it streaming', async () => { const { ctx, events } = buildContext() - await expect(executeAgentRunJob.execute(ctx, buildJobData({ source: AgentRunSource.CHAT }))).rejects.toThrow('ENTITY_NOT_FOUND') + const result = await executeAgentRunJob.execute(ctx, buildJobData({ source: AgentRunSource.CHAT })) + expect(result.status).toBe(EngineResponseStatus.USER_FAILURE) expect(events.map((event) => event.type)).toEqual([AgentEventType.ERROR, AgentEventType.FINISHED]) }) - it('completes the job when the platform is out of credits, so it is not retried or paged', async () => { + it('tells the client it was a billing failure, so the UI can offer a top-up', async () => { const { ctx, events } = buildContext(new Error('You have run out of AI credits')) const result = await executeAgentRunJob.execute(ctx, buildJobData({ source: AgentRunSource.CHAT })) - expect(result.status).toBe(EngineResponseStatus.OK) + expect(result.status).toBe(EngineResponseStatus.USER_FAILURE) + expect(events[0]).toMatchObject({ type: AgentEventType.ERROR, data: { code: ErrorCode.QUOTA_EXCEEDED } }) + }) + + it('still fails the job on an unrecognised error, so our own bugs are not laundered', async () => { + const { ctx, events } = buildContext(new Error('Cannot read properties of undefined')) + + await expect(executeAgentRunJob.execute(ctx, buildJobData({ source: AgentRunSource.CHAT }))).rejects.toThrow('Cannot read properties of undefined') + expect(events.map((event) => event.type)).toEqual([AgentEventType.ERROR, AgentEventType.FINISHED]) }) }) diff --git a/packages/server/worker/test/lib/execute/jobs/ee/agent/execute-agent-run.test.ts b/packages/server/worker/test/lib/execute/jobs/ee/agent/execute-agent-run.test.ts index 84a0ac4c7429..81ee5ce72d89 100644 --- a/packages/server/worker/test/lib/execute/jobs/ee/agent/execute-agent-run.test.ts +++ b/packages/server/worker/test/lib/execute/jobs/ee/agent/execute-agent-run.test.ts @@ -81,6 +81,17 @@ describe('stepResultFrom', () => { }) }) +describe('stepResultFrom — a turn that produced output must not fail the flow step', () => { + const at = '2026-08-05T00:00:00.000Z' + + it('leaves the fatal signal unset even when it reports an incomplete reason', () => { + const result = stepResultFrom({ tools: [], prompt: 'do it', uiParts: [], timestamp: at, failure: 'The response reached the output limit before the agent finished' }) + + expect(result.status).toBe('FAILED') + expect(result.failure).toBeUndefined() + }) +}) + describe('stepResultFrom — a failed tool call must not read as success', () => { const at = '2026-08-05T00:00:00.000Z' const failedCall = { diff --git a/packages/server/worker/test/lib/execute/jobs/ee/agent/run-agent-turn-guards.test.ts b/packages/server/worker/test/lib/execute/jobs/ee/agent/run-agent-turn-guards.test.ts index 8dff6f9f75c4..148d2305d5db 100644 --- a/packages/server/worker/test/lib/execute/jobs/ee/agent/run-agent-turn-guards.test.ts +++ b/packages/server/worker/test/lib/execute/jobs/ee/agent/run-agent-turn-guards.test.ts @@ -1,5 +1,11 @@ +import { ActivepiecesError, AIProviderName, ErrorCode } from '@activepieces/core-utils' +import { APICallError, RetryError } from 'ai' import { describe, expect, it } from 'vitest' -import { isTransientFailureText, looksEmptyResultText } from '../../../../../../src/lib/execute/jobs/ee/agent/run-agent-turn' +import { classifyAgentRunError, isTransientFailureText, looksEmptyResultText } from '../../../../../../src/lib/execute/jobs/ee/agent/run-agent-turn' + +function apiError({ statusCode, message, responseBody }: { statusCode: number, message: string, responseBody?: string }): APICallError { + return new APICallError({ message, url: 'https://provider.test/v1/chat', requestBodyValues: {}, statusCode, responseBody }) +} describe('isTransientFailureText', () => { it('flags retryable errors (rate limit, 5xx, timeout, dropped socket)', () => { @@ -26,3 +32,68 @@ describe('looksEmptyResultText', () => { expect(looksEmptyResultText('✅ done {"found":true,"result":[{"id":"r1"}]}')).toBe(false) }) }) + + +describe('classifyAgentRunError', () => { + const classify = (error: unknown, provider?: string): string => classifyAgentRunError({ error, ...(provider === undefined ? {} : { provider }) }) + const notFound = (entityType: string): ActivepiecesError => new ActivepiecesError({ code: ErrorCode.ENTITY_NOT_FOUND, params: { entityId: 'x', entityType } }) + + it.each([ + [400, 'internal'], [401, 'user'], [402, 'credit'], [403, 'user'], [404, 'user'], [408, 'internal'], + [409, 'internal'], [413, 'internal'], [422, 'internal'], [429, 'internal'], [500, 'internal'], [503, 'internal'], + ])('classifies a provider %i as %s', (statusCode, expected) => { + expect(classify(apiError({ statusCode, message: 'the provider said no' }))).toBe(expected) + }) + + it('never blames the user for the managed key, which is ours and fails everyone at once', () => { + for (const statusCode of [401, 403]) { + expect(classify(apiError({ statusCode, message: 'Unauthorized' }), AIProviderName.ACTIVEPIECES)).toBe('internal') + expect(classify(apiError({ statusCode, message: 'Unauthorized' }), AIProviderName.OPENAI)).toBe('user') + } + expect(classify(apiError({ statusCode: 404, message: 'No endpoints found' }), AIProviderName.ACTIVEPIECES)).toBe('user') + }) + + it('reads billing exhaustion out of a 429 body, which the provider marks retryable', () => { + expect(classify(apiError({ statusCode: 429, message: 'quota', responseBody: '{"code":"insufficient_quota"}' }))).toBe('credit') + }) + + it('does not let a 5xx error page mentioning credits masquerade as a billing failure', () => { + expect(classify(apiError({ statusCode: 500, message: 'Bad gateway', responseBody: 'Buy more credits' }))).toBe('internal') + expect(classify(apiError({ statusCode: 503, message: 'Unavailable', responseBody: 'trace-id 402 upstream down' }))).toBe('internal') + }) + + it('reports a real quota rejection as credit, so the client can offer a top-up', () => { + expect(classify(new Error('You have run out of AI credits'))).toBe('credit') + expect(classify(new ActivepiecesError({ code: ErrorCode.QUOTA_EXCEEDED, params: { metric: 'credits', quota: 0 } }))).toBe('credit') + }) + + it('unwraps the retry envelope the SDK adds after a retried attempt', () => { + expect(classify(new RetryError({ + message: 'Failed after 2 attempts', + reason: 'errorNotRetryable', + errors: [apiError({ statusCode: 429, message: 'Too Many Requests' }), apiError({ statusCode: 401, message: 'Unauthorized' })], + }))).toBe('user') + }) + + it('treats a missing AI provider as user config, but any other not-found as our bug', () => { + expect(classify(notFound('AIProvider'))).toBe('user') + expect(classify(notFound('ChatAiProvider'))).toBe('user') + expect(classify(notFound('Conversation'))).toBe('internal') + }) + + it('keeps a conversation stuck mid-stream visible instead of completing quietly', () => { + expect(classify(new ActivepiecesError({ code: ErrorCode.VALIDATION, params: { message: 'An agent is already running for this conversation' } }))).toBe('internal') + }) + + it('reads the error code an RPC failure now carries across the boundary', () => { + expect(classify(Object.assign(new Error('RPC [getAgentConfig] handler threw: ENTITY_NOT_FOUND'), { + apError: { code: ErrorCode.ENTITY_NOT_FOUND, entityType: 'AIProvider' }, + }))).toBe('user') + }) + + it('keeps an unrecognised error internal', () => { + for (const input of [new Error('Cannot read properties of undefined'), undefined, null, 'a string', {}]) { + expect(classify(input)).toBe('internal') + } + }) +}) diff --git a/packages/server/worker/test/lib/worker-settings-override.test.ts b/packages/server/worker/test/lib/worker-settings-override.test.ts index a5f5ae3514a8..c152aed32d56 100644 --- a/packages/server/worker/test/lib/worker-settings-override.test.ts +++ b/packages/server/worker/test/lib/worker-settings-override.test.ts @@ -94,7 +94,10 @@ describe('worker settings override', () => { let port: number beforeEach(async () => { - httpServer = createServer() + httpServer = createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end('{}') + }) ioServer = new IOServer(httpServer, { transports: ['websocket'], path: '/api/socket.io' }) await new Promise((resolve) => { httpServer.listen(0, () => { diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index 2b19f19dfa09..84401072a36f 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -2520,5 +2520,41 @@ "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." + "Could not load your accounts. Try again in a moment.": "Could not load your accounts. Try again in a moment.", + "Activating your trial": "Activating your trial", + "This usually takes under a minute. Keep this tab open \u2014 we'll drop you into your platform as soon as it's ready.": "This usually takes under a minute. Keep this tab open \u2014 we'll drop you into your platform as soon as it's ready.", + "Trial activation progress": "Trial activation progress", + "Verifying your account": "Verifying your account", + "Checking platform permissions": "Checking platform permissions", + "Provisioning your trial": "Provisioning your trial", + "Applying your license": "Applying your license", + "Finishing up": "Finishing up", + "Your trial is active": "Your trial is active", + "Everything on your plan is unlocked for your whole platform. Nothing else to set up \u2014 go build a flow.": "Everything on your plan is unlocked for your whole platform. Nothing else to set up \u2014 go build a flow.", + "Taking you there in {seconds}s": "Taking you there in {seconds}s", + "Redirecting": "Redirecting", + "A platform admin needs to do this": "A platform admin needs to do this", + "You are not a platform admin on this platform, please send the following link to a platform admin and let them sign in with it to kick off your trial.": "You are not a platform admin on this platform, please send the following link to a platform admin and let them sign in with it to kick off your trial.", + "Signed in as {email}. Sign out and back in with an admin account if that's you.": "Signed in as {email}. Sign out and back in with an admin account if that's you.", + "We couldn't activate your trial": "We couldn't activate your trial", + "Nothing changed on your platform. Try again \u2014 if it fails a second time, contact support and we'll turn it on manually.": "Nothing changed on your platform. Try again \u2014 if it fails a second time, contact support and we'll turn it on manually.", + "Try again": "Try again", + "Contact support": "Contact support", + "Edit with AI": "Edit with AI", + "Activepieces AI": "Activepieces AI", + "Close Edit with AI": "Close Edit with AI", + "Message the builder...": "Message the builder...", + "Back to the agent": "Back to the agent", + "Describe a change": "Describe a change", + "Adjust the instructions, add or remove tools, or change the model. For example: “Only reply to paying customers” or “Add Slack and Notion”.": "Adjust the instructions, add or remove tools, or change the model. For example: “Only reply to paying customers” or “Add Slack and Notion”.", + "Save and go live": "Save and go live", + "Needs a model before it can run": "Needs a model before it can run", + "Live — flows using this agent run these settings": "Live — flows using this agent run these settings", + "Live — every flow using this agent just got the update": "Live — every flow using this agent just got the update", + "Leave without saving?": "Leave without saving?", + "These edits have not gone live yet. Leave now and they are discarded.": "These edits have not gone live yet. Leave now and they are discarded.", + "Keep editing": "Keep editing", + "Not live yet": "Not live yet", + "Changes not live yet": "Changes not live yet", + "These changes are not live. Save and go live to hand them to this agent and every flow using it.": "These changes are not live. Save and go live to hand them to this agent and every flow using it." } diff --git a/packages/web/src/app/components/allow-logged-in-user-only-guard.tsx b/packages/web/src/app/components/allow-logged-in-user-only-guard.tsx index 18fc1eb71fd6..6b7fd5cd8101 100644 --- a/packages/web/src/app/components/allow-logged-in-user-only-guard.tsx +++ b/packages/web/src/app/components/allow-logged-in-user-only-guard.tsx @@ -2,6 +2,7 @@ import { Navigate, useLocation } from 'react-router-dom'; import { SocketProvider } from '@/components/providers/socket-provider'; import { useTelemetry } from '@/components/providers/telemetry-provider'; +import { AutomaticTrialActivation } from '@/features/billing'; import { projectCollectionUtils } from '@/features/projects'; import { flagsHooks } from '@/hooks/flags-hooks'; import { platformHooks } from '@/hooks/platform-hooks'; @@ -29,5 +30,10 @@ export const AllowOnlyLoggedInUserOnlyGuard = ({ platformHooks.useCurrentPlatform(); flagsHooks.useFlags(); projectCollectionUtils.useCurrentProject(); - return {children}; + return ( + + + {children} + + ); }; diff --git a/packages/web/src/app/guards/default-route.tsx b/packages/web/src/app/guards/default-route.tsx index 7c1aff181835..495218c9f4ba 100644 --- a/packages/web/src/app/guards/default-route.tsx +++ b/packages/web/src/app/guards/default-route.tsx @@ -4,7 +4,10 @@ import { Navigate, useLocation } from 'react-router-dom'; import { useAuthorization } from '@/hooks/authorization-hooks'; import { platformHooks } from '@/hooks/platform-hooks'; import { authenticationSession } from '@/lib/authentication-session'; -import { determineDefaultRoute } from '@/lib/route-utils'; +import { + determineDefaultRoute, + TRIAL_KEY_QUERY_PARAM, +} from '@/lib/route-utils'; import { NoProjectsState } from '../components/no-projects-state'; import { ProjectDashboardLayout } from '../components/project-layout'; @@ -31,7 +34,11 @@ export const DefaultRoute = () => { const AuthenticatedDefaultRoute = () => { const { checkAccess } = useAuthorization(); const { platform } = platformHooks.useCurrentPlatform(); + const location = useLocation(); const currentProjectId = authenticationSession.getProjectId(); + const trialKey = new URLSearchParams(location.search).get( + TRIAL_KEY_QUERY_PARAM, + ); if (isNil(currentProjectId)) { return ( @@ -41,10 +48,17 @@ const AuthenticatedDefaultRoute = () => { } return ( ); diff --git a/packages/web/src/app/routes/agents/id/index.tsx b/packages/web/src/app/routes/agents/id/index.tsx index e5ed2fcc2d96..449d0f7018aa 100644 --- a/packages/web/src/app/routes/agents/id/index.tsx +++ b/packages/web/src/app/routes/agents/id/index.tsx @@ -13,17 +13,24 @@ import { formErrors, } from '@activepieces/shared'; import { zodResolver } from '@hookform/resolvers/zod'; +import { useQueryClient } from '@tanstack/react-query'; import { t } from 'i18next'; import { - Check, + ChevronLeft, ChevronsLeft, ChevronsRight, - Circle, + Rocket, Settings2, + Sparkles, } from 'lucide-react'; -import { useState } from 'react'; +import { motion } from 'motion/react'; +import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; -import { useParams, useSearchParams } from 'react-router-dom'; +import { + unstable_useBlocker, + useParams, + useSearchParams, +} from 'react-router-dom'; import { toast } from 'sonner'; import { z } from 'zod'; @@ -32,6 +39,14 @@ import { LockedFeatureGuard } from '@/app/components/locked-feature-guard'; import { AIChatBox } from '@/app/routes/chat-with-ai/ai-chat-box'; import { ConversationList } from '@/app/routes/chat-with-ai/conversation-list'; import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; import { Form, FormControl, @@ -132,58 +147,6 @@ const requirementsFor = (agent: Agent): AgentRequirement[] => { ]; }; -const AgentNotReady = ({ - requirements, - onConfigure, -}: { - requirements: AgentRequirement[]; - onConfigure: () => void; -}) => ( -
-
-

- {t('Almost ready')} -

-

- {t('Fill these in and you can start talking to this agent.')} -

-
- -
    - {requirements.map((requirement) => ( -
  • - {requirement.met ? ( - - ) : ( - - )} - - - {requirement.label} - - - {requirement.hint} - - -
  • - ))} -
- - -
-); - const AgentEditorSkeleton = () => (
@@ -392,41 +355,117 @@ const ConfigureFields = ({ ); -const ConfigurePanel = ({ - agentId, - icon, - color, - displayName, - defaults, - onCollapse, +const useWarnBeforeLosingChanges = (hasChanges: boolean) => { + const blocker = unstable_useBlocker( + ({ currentLocation, nextLocation }) => + hasChanges && currentLocation.pathname !== nextLocation.pathname, + ); + + useEffect(() => { + if (!hasChanges) return; + const warn = (event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = ''; + }; + window.addEventListener('beforeunload', warn); + return () => window.removeEventListener('beforeunload', warn); + }, [hasChanges]); + + return blocker; +}; + +const LeaveWithoutSavingDialog = ({ + blocker, +}: { + blocker: ReturnType; +}) => ( + { + if (!open) blocker.reset?.(); + }} + > + + + {t('Leave without saving?')} + + {t( + 'These edits have not gone live yet. Leave now and they are discarded.', + )} + + + + + + + + +); + +const formValuesOf = (agent: Agent): ConfigureAgentInput => ({ + displayName: agent.displayName, + description: agent.description ?? '', + icon: agent.icon, + color: agent.color, + draft: agent.draft, +}); + +const liveValuesOf = (agent: Agent): ConfigureAgentInput | null => + isNil(agent.published) + ? null + : { ...formValuesOf(agent), draft: agent.published }; + +const AgentEditScreen = ({ + agent, + onExit, + onEdited, }: { - agentId: string; - icon: AgentIcon; - color: ColorName; - displayName: string; - defaults: ConfigureAgentInput; - onCollapse: () => void; + agent: Agent; + onExit: () => void; + onEdited: () => void; }) => { const [tab, setTab] = useState('configure'); + const [syncedDraft, setSyncedDraft] = useState(() => + formValuesOf(agent), + ); const form = useForm({ resolver: zodResolver(ConfigureAgentSchema), - defaultValues: defaults, + defaultValues: syncedDraft, mode: 'onChange', }); - const updateAgent = agentsMutations.useUpdateAgent({ id: agentId }); + const updateAgent = agentsMutations.useUpdateAgent({ id: agent.id }); + const [justLaunched, setJustLaunched] = useState(false); const values = form.watch(); const formNeedsModel = isNil(values.draft?.modelName) || isNil(values.draft?.provider); - // The model selector fills itself in on mount, which react-hook-form counts as the user editing. - const hasChanges = JSON.stringify(values) !== JSON.stringify(defaults); + const live = liveValuesOf(agent); + const hasChanges = + isNil(live) || JSON.stringify(values) !== JSON.stringify(live); + const unsavedTyping = JSON.stringify(values) !== JSON.stringify(syncedDraft); + const leaveBlocker = useWarnBeforeLosingChanges(unsavedTyping); + + useEffect(() => { + const fromServer = formValuesOf(agent); + if (JSON.stringify(fromServer) === JSON.stringify(syncedDraft)) return; + if (unsavedTyping) return; + form.reset(fromServer); + setSyncedDraft(fromServer); + }, [agent, syncedDraft, unsavedTyping, form]); const handleSubmit = (values: ConfigureAgentValues) => { form.clearErrors('root.serverError'); updateAgent.mutate(toUpdateRequest(values), { onSuccess: () => { form.reset(values); - toast(t('Agent saved')); + setSyncedDraft(values); + setJustLaunched(true); + window.setTimeout(() => setJustLaunched(false), 1600); + toast(t('Live — every flow using this agent just got the update')); }, onError: (error) => form.setError('root.serverError', { @@ -441,84 +480,171 @@ const ConfigurePanel = ({ return (
+ -
-
- -
- - {displayName} - - - {t('Agent configuration')} - -
- -
- - - - {t('Configure')} - {formNeedsModel && ( - - )} - - - {t('Settings')} - - - -
- - -
- {tab === 'configure' ? ( - - ) : ( - - )} - {form.formState.errors.root?.serverError && ( -

- {form.formState.errors.root.serverError.message} -

- )} +
+ + +
+ + {agent.displayName} + + + {justLaunched + ? t('Live') + : formNeedsModel + ? t('Needs a model before it can run') + : isNil(live) + ? t('Not live yet') + : hasChanges + ? t('Changes not live yet') + : t('Live — flows using this agent run these settings')} +
- - -
+ +
+
+ +
+ +
+
+ + + + {t('Configure')} + {formNeedsModel && ( + + )} + + + {t('Settings')} + + + +
+ +
+ {hasChanges && ( +

+ {t( + 'These changes are not live. Save and go live to hand them to this agent and every flow using it.', + )} +

+ )} + {tab === 'configure' ? ( + + ) : ( + + )} + {form.formState.errors.root?.serverError && ( +

+ {form.formState.errors.root.serverError.message} +

+ )} +
+
+
+
); }; +const AgentBuilderWelcome = () => ( +
+ + {t('Describe a change')} + + {t( + 'Adjust the instructions, add or remove tools, or change the model. For example: “Only reply to paying customers” or “Add Slack and Notion”.', + )} + +
+); + +const EditWithAIPane = ({ + agent, + onEdited, +}: { + agent: Agent; + onEdited: () => void; +}) => ( +
+
+ + {t('Edit with AI')} + + {t('Activepieces AI')} + +
+
+ } + /> +
+
+); + const AgentEditorContent = () => { const { agentId } = useParams<{ agentId: string }>(); + const queryClient = useQueryClient(); const agentsAvailable = useAgentsAvailable(); - const [configureOpen, setConfigureOpen] = useState(); + const [editing, setEditing] = useState(); const [conversationsOpen, setConversationsOpen] = useState(true); const [searchParams, setSearchParams] = useSearchParams(); const conversationId = @@ -556,7 +682,20 @@ const AgentEditorContent = () => { const requirements = requirementsFor(agent); const needsModel = requirements.some((requirement) => !requirement.met); - const isConfigureOpen = configureOpen ?? needsModel; + const isEditing = editing ?? needsModel; + const refetchAgent = () => + queryClient.invalidateQueries({ queryKey: ['agents', 'one', agent.id] }); + + if (isEditing) { + return ( + setEditing(false)} + onEdited={refetchAgent} + /> + ); + } return (
@@ -603,7 +742,7 @@ const AgentEditorContent = () => {
- {agent.draft.modelName && !isConfigureOpen && ( + {agent.draft.modelName && ( { {agent.draft.modelName} )} - {!isConfigureOpen && ( - - )} +
- {needsModel ? ( - setConfigureOpen(true)} - /> - ) : ( - - } - /> - )} -
-
- - +
); }; diff --git a/packages/web/src/app/routes/chat-with-ai/ai-chat-box.tsx b/packages/web/src/app/routes/chat-with-ai/ai-chat-box.tsx index 267287b44e90..4d75d7761212 100644 --- a/packages/web/src/app/routes/chat-with-ai/ai-chat-box.tsx +++ b/packages/web/src/app/routes/chat-with-ai/ai-chat-box.tsx @@ -53,6 +53,8 @@ import { getTextFromParts } from './lib/message-parsers'; export function AIChatBox({ incognito, agentId, + builder, + onTurnEnd, emptyState, footerNote, placeholder, @@ -72,6 +74,8 @@ export function AIChatBox({ credits.setCreditsExhausted(true), }); + const setStoreConversationId = useChatStoreContext( + (s) => s.setConversationId, + ); const quickReplies = useChatStoreContext((s) => s.quickReplies); const offerRecurringAutomation = useChatStoreContext( (s) => s.offerRecurringAutomation, @@ -128,6 +139,10 @@ function ChatBoxContent({ } }, [initialConversationId, setConversationId]); + useEffect(() => { + setStoreConversationId(conversationId ?? null); + }, [conversationId, setStoreConversationId]); + useEffect(() => { if (!isStreaming) return; const handler = (e: KeyboardEvent) => { @@ -487,6 +502,8 @@ function computeClaimedBuildIds( type AIChatBoxProps = { incognito: boolean; agentId?: string; + builder?: boolean; + onTurnEnd?: () => void; emptyState?: React.ReactNode; footerNote?: string; placeholder?: string; diff --git a/packages/web/src/app/routes/chat-with-ai/lib/use-conversation-id.ts b/packages/web/src/app/routes/chat-with-ai/lib/use-conversation-id.ts index 33c2746d097f..ac254bb20501 100644 --- a/packages/web/src/app/routes/chat-with-ai/lib/use-conversation-id.ts +++ b/packages/web/src/app/routes/chat-with-ai/lib/use-conversation-id.ts @@ -1,8 +1,12 @@ import { useParams } from 'react-router-dom'; +import { useChatStoreContext } from '@/features/chat/lib/chat-store-context'; + export function useConversationId(): string | undefined { + const fromStore = useChatStoreContext((s) => s.conversationId); const { conversationId } = useParams<{ conversationId: string }>(); return ( + fromStore ?? conversationId ?? new URLSearchParams(window.location.search).get('conversation') ?? window.location.pathname.match(/\/chat\/([^/]+)/)?.[1] diff --git a/packages/web/src/features/billing/components/activate-license-dialog.tsx b/packages/web/src/features/billing/components/activate-license-dialog.tsx index 4f220cc1207b..742e06b573dc 100644 --- a/packages/web/src/features/billing/components/activate-license-dialog.tsx +++ b/packages/web/src/features/billing/components/activate-license-dialog.tsx @@ -47,7 +47,7 @@ export const ActivateLicenseDialog = ({ }); const { mutate: activateLicenseKey, isPending } = - platformHooks.useUpdateLisenceKey(queryClinet); + platformHooks.useUpdateLisenceKey({ queryClient: queryClinet }); const handleSubmit = (data: LicenseKeySchema) => { form.clearErrors(); diff --git a/packages/web/src/features/billing/components/automatic-trial-activation.tsx b/packages/web/src/features/billing/components/automatic-trial-activation.tsx new file mode 100644 index 000000000000..09542bc4a7c1 --- /dev/null +++ b/packages/web/src/features/billing/components/automatic-trial-activation.tsx @@ -0,0 +1,375 @@ +import { isEmpty, isNil } from '@activepieces/core-utils'; +import { ApEdition, ApFlagId } from '@activepieces/shared'; +import { useQueryClient } from '@tanstack/react-query'; +import confetti from 'canvas-confetti'; +import { t } from 'i18next'; +import { Check, CircleX, TriangleAlert } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; + +import { CopyToClipboardInput } from '@/components/custom/clipboard/copy-to-clipboard'; +import { FullLogo } from '@/components/custom/full-logo'; +import { LoadingSpinner } from '@/components/custom/spinner'; +import { Button } from '@/components/ui/button'; +import { + useAuthorization, + useIsPlatformAdmin, +} from '@/hooks/authorization-hooks'; +import { flagsHooks } from '@/hooks/flags-hooks'; +import { platformHooks } from '@/hooks/platform-hooks'; +import { userHooks } from '@/hooks/user-hooks'; +import { + determineDefaultRoute, + TRIAL_KEY_QUERY_PARAM, +} from '@/lib/route-utils'; +import { cn } from '@/lib/utils'; + +export const AutomaticTrialActivation = () => { + const { platform } = platformHooks.useCurrentPlatform(); + const { data: edition } = flagsHooks.useFlag(ApFlagId.EDITION); + const isPlatformAdmin = useIsPlatformAdmin(); + const [searchParams] = useSearchParams(); + const [pendingKey, setPendingKey] = useState(() => { + const licenseKey = searchParams.get(TRIAL_KEY_QUERY_PARAM)?.trim(); + const platformLicenseKey = platform.plan.licenseKey; + const alreadyLicensed = + !isNil(platformLicenseKey) && !isEmpty(platformLicenseKey); + if ( + isNil(licenseKey) || + isEmpty(licenseKey) || + edition === ApEdition.COMMUNITY + ) { + return null; + } + return alreadyLicensed ? null : licenseKey; + }); + + const dismiss = useCallback(() => setPendingKey(null), []); + + if (isNil(pendingKey)) { + return null; + } + + return ( + + ); +}; + +const TrialActivationScreen = ({ + licenseKey, + isPlatformAdmin, + onDone, +}: TrialActivationScreenProps) => { + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const queryClient = useQueryClient(); + const { platform } = platformHooks.useCurrentPlatform(); + const { checkAccess } = useAuthorization(); + const { data: user } = userHooks.useCurrentUser(); + const [now, setNow] = useState(Date.now()); + const confettiCanvas = useRef(null); + const startedAt = useRef(Date.now()); + const succeededAt = useRef(null); + + const homeRoute = determineDefaultRoute({ + checkAccess, + chatEnabled: platform.plan.chatEnabled, + }); + + const returnToApp = useCallback(() => { + onDone(); + navigate(homeRoute); + }, [homeRoute, navigate, onDone]); + + const { + mutate: activateLicenseKey, + isPending, + isSuccess, + isError, + } = platformHooks.useUpdateLisenceKey({ + queryClient, + messages: { success: null, error: null }, + }); + + const activate = useCallback(() => { + startedAt.current = Date.now(); + succeededAt.current = null; + activateLicenseKey(licenseKey, { + onSuccess: () => { + succeededAt.current = Date.now(); + burstConfetti(confettiCanvas.current); + setTimeout(returnToApp, REDIRECT_SECONDS * 1000); + }, + }); + }, [activateLicenseKey, licenseKey, returnToApp]); + + useEffect(() => { + if (isPlatformAdmin) { + activate(); + } + const scrubbed = new URLSearchParams(searchParams); + scrubbed.delete(TRIAL_KEY_QUERY_PARAM); + setSearchParams(scrubbed, { replace: true }); + // Re-renders TrialActivationScreen every TICK_MS so that progress, + // the activation timeout and the redirect countdown stay derived from `now`. + const ticker = setInterval(() => setNow(Date.now()), TICK_MS); + return () => clearInterval(ticker); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const elapsed = Math.max(0, now - startedAt.current); + const progress = progressFor(elapsed); + const view = viewOf({ + isPlatformAdmin, + isSuccess, + isError, + isPending, + timedOut: elapsed >= ACTIVATION_TIMEOUT_MS, + }); + const secondsLeft = isNil(succeededAt.current) + ? REDIRECT_SECONDS + : Math.max( + 0, + REDIRECT_SECONDS - Math.floor((now - succeededAt.current) / 1000), + ); + + return ( +
+ +
+ + {view === 'activating' && ( +
+
+ + +
+ +
+
+
+
+
+
+
+ + {statusMessageFor(progress)} + + + {Math.round(progress)}% + +
+
+
+ )} + {view === 'success' && ( +
+
+ +
+ +
+ + + {secondsLeft > 0 + ? t('Taking you there in {seconds}s', { + seconds: secondsLeft, + }) + : t('Redirecting')} + +
+
+ )} + {view === 'not_admin' && ( +
+
+ +
+ + +

+ {t( + "Signed in as {email}. Sign out and back in with an admin account if that's you.", + { email: user?.email ?? '' }, + )} +

+
+ )} + {view === 'failed' && ( +
+
+ +
+ +
+ + +
+
+ )} +
+
+ ); +}; + +const TrialActivationCopy = ({ heading, body }: TrialActivationCopyProps) => ( +
+

+ {heading} +

+

+ {body} +

+
+); + +function viewOf({ + isPlatformAdmin, + isSuccess, + isError, + isPending, + timedOut, +}: ViewOfParams): TrialActivationView { + if (!isPlatformAdmin) { + return 'not_admin'; + } + if (isSuccess) { + return 'success'; + } + if (isPending && !timedOut) { + return 'activating'; + } + return isError || timedOut ? 'failed' : 'activating'; +} + +function progressFor(elapsedMs: number): number { + const ratio = Math.min(1, elapsedMs / PROGRESS_SPAN_MS); + const eased = 1 - Math.pow(1 - ratio, 3); + return INITIAL_PROGRESS + (PROGRESS_CEILING - INITIAL_PROGRESS) * eased; +} + +function statusMessageFor(progress: number): string { + const step = [...ACTIVATION_STEPS] + .reverse() + .find((candidate) => progress >= candidate.from); + return t(step?.message ?? ACTIVATION_STEPS[0].message); +} + +function activationLinkFor(licenseKey: string): string { + return `${ + window.location.origin + }/?${TRIAL_KEY_QUERY_PARAM}=${encodeURIComponent(licenseKey)}`; +} + +function burstConfetti(canvas: HTMLCanvasElement | null): void { + if (isNil(canvas)) { + return; + } + confetti.create(canvas, { resize: true })(CONFETTI_OPTIONS); +} + +const TICK_MS = 250; +const INITIAL_PROGRESS = 4; +const PROGRESS_CEILING = 95; +const PROGRESS_SPAN_MS = 20 * 1000; +const ACTIVATION_TIMEOUT_MS = 75 * 1000; +const REDIRECT_SECONDS = 5; +const SUPPORT_MAIL_HREF = `mailto:support@activepieces.com?subject=${encodeURIComponent( + 'Trial activation failed', +)}`; +const ACTIVATION_STEPS = [ + { from: 0, message: 'Verifying your account' }, + { from: 18, message: 'Checking platform permissions' }, + { from: 38, message: 'Provisioning your trial' }, + { from: 62, message: 'Applying your license' }, + { from: 84, message: 'Finishing up' }, +]; +const CONFETTI_OPTIONS: confetti.Options = { + particleCount: 140, + spread: 360, + startVelocity: 34, + ticks: 160, + gravity: 0.9, + decay: 0.93, + scalar: 0.85, + shapes: ['square'], + origin: { x: 0.5, y: 0.44 }, + colors: ['#8142E3', '#B592F0', '#10b981', '#f59e0b', '#0a0a0a'], + disableForReducedMotion: true, +}; + +type TrialActivationView = 'activating' | 'success' | 'not_admin' | 'failed'; + +type TrialActivationScreenProps = { + licenseKey: string; + isPlatformAdmin: boolean; + onDone: () => void; +}; + +type TrialActivationCopyProps = { + heading: string; + body: string; +}; + +type ViewOfParams = { + isPlatformAdmin: boolean; + isSuccess: boolean; + isError: boolean; + isPending: boolean; + timedOut: boolean; +}; diff --git a/packages/web/src/features/billing/index.ts b/packages/web/src/features/billing/index.ts index aadbba6c21ed..954f1ab8d8d0 100644 --- a/packages/web/src/features/billing/index.ts +++ b/packages/web/src/features/billing/index.ts @@ -29,3 +29,4 @@ export { planSelectorUtils, } from './components/plan-selector-utils'; export { useManagePlanDialogStore } from './stores/manage-plan-dialog-state'; +export { AutomaticTrialActivation } from './components/automatic-trial-activation'; diff --git a/packages/web/src/features/chat/lib/chat-store.ts b/packages/web/src/features/chat/lib/chat-store.ts index 65c485ca78a1..ac261ccf10e7 100644 --- a/packages/web/src/features/chat/lib/chat-store.ts +++ b/packages/web/src/features/chat/lib/chat-store.ts @@ -54,6 +54,7 @@ export type ToolCallMeta = { export type BuildState = BuildPlanEvent; export type ChatStoreState = { + conversationId: string | null; quickReplies: string[]; offerRecurringAutomation: boolean; toolCallMeta: Record; @@ -61,6 +62,7 @@ export type ChatStoreState = { dismissedGateIds: Record; lastDismissedFormId: string | null; + setConversationId: (conversationId: string | null) => void; approveGate: (gateId: string, payload?: Record) => void; rejectGate: (gateId: string) => void; dismissGate: (gateId: string) => void; @@ -83,6 +85,7 @@ function dismissAndCleanup( export const createChatStore = () => create((set) => ({ + conversationId: null, quickReplies: [], offerRecurringAutomation: false, toolCallMeta: {}, @@ -104,6 +107,9 @@ export const createChatStore = () => dismissForm: (messageId: string) => { set({ lastDismissedFormId: messageId }); }, + setConversationId: (conversationId: string | null) => { + set({ conversationId }); + }, resetInteractions: () => { set({ quickReplies: [], diff --git a/packages/web/src/features/chat/lib/use-chat.ts b/packages/web/src/features/chat/lib/use-chat.ts index 34fb83649a04..53cb31302847 100644 --- a/packages/web/src/features/chat/lib/use-chat.ts +++ b/packages/web/src/features/chat/lib/use-chat.ts @@ -250,14 +250,18 @@ type SendStatus = export function useAgentChat({ agentId, + builder, onTitleUpdate, onConversationCreated, onCreditsExhausted, + onTurnEnd, }: { agentId?: string; + builder?: boolean; onTitleUpdate?: (title: string) => void; onConversationCreated?: (conversationId: string) => void; onCreditsExhausted?: () => void; + onTurnEnd?: () => void; } = {}) { const store = useChatStoreApi(); @@ -522,6 +526,16 @@ export function useAgentChat({ }, [streamingQuickReplies, store]); const isStreamActive = streamPhase !== 'idle'; + const onTurnEndRef = useRef(onTurnEnd); + onTurnEndRef.current = onTurnEnd; + const wasStreamActiveRef = useRef(false); + useEffect(() => { + if (wasStreamActiveRef.current && !isStreamActive) { + onTurnEndRef.current?.(); + } + wasStreamActiveRef.current = isStreamActive; + }, [isStreamActive]); + const isStreaming = isStreamActive || sendStatusRef.current.type === 'submitting' || @@ -604,12 +618,13 @@ export function useAgentChat({ title: title ?? null, modelName: modelName ?? null, ...(agentId === undefined ? {} : { agentId }), + ...(builder === undefined ? {} : { builder }), }); conversationIdRef.current = conv.id; setConversationIdState(conv.id); return conv; }, - [agentId], + [agentId, builder], ); const sendMessage = useCallback( diff --git a/packages/web/src/hooks/platform-hooks.ts b/packages/web/src/hooks/platform-hooks.ts index f6da6b537e66..3c747ed7d711 100644 --- a/packages/web/src/hooks/platform-hooks.ts +++ b/packages/web/src/hooks/platform-hooks.ts @@ -1,3 +1,4 @@ +import { isNil } from '@activepieces/core-utils'; import { PlatformWithoutSensitiveData } from '@activepieces/shared'; import { QueryClient, @@ -49,7 +50,10 @@ export const platformHooks = { }, }; }, - useUpdateLisenceKey: (queryClient: QueryClient) => { + useUpdateLisenceKey: ({ + queryClient, + messages, + }: UseUpdateLicenseKeyParams) => { const currentPlatformId = authenticationSession.getPlatformId(); return useMutation({ @@ -67,11 +71,31 @@ export const platformHooks = { queryClient.invalidateQueries({ queryKey: ['platform-billing-subscription'], }); - toast.success(t('License activated successfully!')); + const successMessage = + messages?.success === undefined + ? t('License activated successfully!') + : messages.success; + if (!isNil(successMessage)) { + toast.success(successMessage); + } }, onError: () => { - toast.error(t('Activation failed, invalid license key')); + const errorMessage = + messages?.error === undefined + ? t('Activation failed, invalid license key') + : messages.error; + if (!isNil(errorMessage)) { + toast.error(errorMessage); + } }, }); }, }; + +export type UseUpdateLicenseKeyParams = { + queryClient: QueryClient; + messages?: { + success?: string | null; + error?: string | null; + }; +}; diff --git a/packages/web/src/lib/route-utils.ts b/packages/web/src/lib/route-utils.ts index 4d4f4451f024..9e5ce693d854 100644 --- a/packages/web/src/lib/route-utils.ts +++ b/packages/web/src/lib/route-utils.ts @@ -40,5 +40,6 @@ export const determineDefaultRoute = ({ return authenticationSession.appendProjectRoutePrefix('/settings'); }; +export const TRIAL_KEY_QUERY_PARAM = 'licenseKey'; export const NEW_FLOW_QUERY_PARAM = 'newFlow'; export const NEW_TABLE_QUERY_PARAM = 'newTable';