diff --git a/brain/knowledge/decisions/000027-an-agent-is-a-project-scoped-row-that-flow-steps-reference-live.md b/brain/knowledge/decisions/000027-an-agent-is-a-project-scoped-row-that-flow-steps-reference-live.md new file mode 100644 index 000000000000..5f89e3249a1f --- /dev/null +++ b/brain/knowledge/decisions/000027-an-agent-is-a-project-scoped-row-that-flow-steps-reference-live.md @@ -0,0 +1,44 @@ +--- +title: An agent is a project-scoped row that flow steps reference live +icon: 🎯 +status: proposed +--- + +## Decision + +An `agent` row holds instructions, tools, model, max steps and structured output. A Run Agent step +stores only `agentId` and the server resolves the config at run start, so editing an agent changes the +next run of every flow linking it with no republish. **Detach & customise** copies the config inline +and clears `agentId` for steps that must differ. + +## Context + +Agreed 2026-08-12. The runtime already reads tools off the job payload rather than the flow version, +and `flow_version.agentIds` + `extractAgentIds` survive from the deleted 2025 agents module — so a +live reference costs no worker change and "which flows use this agent" comes free. Project scope +follows the tools: flow tools resolve by `projectId` and connections match on +`ArrayContains([projectId])`, so a platform-scoped agent would rebuild project scoping inside the row. + +## Why + +The point of naming an agent is to improve it once. A snapshot lets every flow drift independently and +turns "make the agent better" back into per-flow editing — the pain the feature exists to remove. The +rejected alternative was exactly that: dropdown prefills the step, link then gone. + +## Consequences + +- **It widens unattended authorisation.** Decision 000024 held that configuring a tool on an agent step + authorises it to run unattended; that authorisation now lives on the agent. We treat editing an agent + as editing a flow someone else published — `WRITE_AGENT` gates it, the edit is audit-logged, and the + editor shows "Used in N flows" before saving. If too weak in practice, the next move is a per-agent + "allow unattended writes" flag, not re-litigating the reference. +- **Moving a flow between projects breaks the link** — the id is project-local, and git sync carries + connections but not agents. Hence `externalId` on the row from day one; project state must upsert + agents by `(projectId, externalId)`, and until it does the import must fail loudly. +- Two orphaned tables (`agent`, `agent_run`) from the 2025 module are dropped so the entity can take + the obvious name. `breaking = true` for rollback safety, no `⛓️‍💥 breaking-change` label. +- **Autonomy stays a flow** — "Run on a schedule" scaffolds a real flow with a linked step, so there is + one execution model and one observability path. +- **Chatting with an agent is a third `AgentRunSource`**, not a nullable-`agentId` check: every gate + already branches on `source`, and it keeps agent conversations out of the Chat list for free. Unlike + a flow step it is **attended** — taint starts `false` and approval must not auto-decline. diff --git a/brain/knowledge/engineering/server-module-anatomy.md b/brain/knowledge/engineering/server-module-anatomy.md index 17733266ec46..9abab1828333 100644 --- a/brain/knowledge/engineering/server-module-anatomy.md +++ b/brain/knowledge/engineering/server-module-anatomy.md @@ -129,8 +129,11 @@ Verify with `npm run lint-dev` and `npm run test-api`. - **`getEntities()` and `getMigrations()` are both manual.** Nothing is auto-discovered. A missing entity registration fails silently at runtime; a missing migration registration means the migration simply never runs. - **The migration generator emits the wrong interface.** Every generated file must be patched from `MigrationInterface` to this repo's `Migration`, or CI rejects it. Never hand-write the SQL instead — generate from the entity diff, then patch. + - **Exception: the generator diffs against *your* database, so a surviving table of the same name produces an `ALTER`, not a `CREATE`.** Adding `AgentEntity` (table `agent`) emitted a mutation of the dead 2025 `agent` table — `DROP COLUMN systemPrompt`, then `ADD "iconKey" character varying NOT NULL` with no default, which fails on any table that has rows — and left `agent_run` untouched. When you are deliberately replacing an orphaned table, hand-write `DROP TABLE IF EXISTS … CASCADE` + `CREATE TABLE`, and check `pg_constraint` for FKs pointing at it first. +- **`npm run check-migrations` can pass while your database is untouched.** It sources `.env.tests` (not `.env.dev`) and pipes `migration:run` to `/dev/null`, so it reported "No changes in database schema were found" against a database still holding the pre-migration table. Treat a green run as "the entity and *some* database agree", not as proof your migration executed. To actually verify, run `migration:run` against the dev DB with an explicit `AP_POSTGRES_HOST` and then inspect `information_schema.columns`, `pg_indexes` and `pg_constraint`. +- **Migration timestamps collide across unmerged branches.** `migrations` is keyed by class name, so two branches can both claim `1824000000000` and only conflict at merge. Before picking a timestamp, check the applied ledger (`select name from migrations order by id desc limit 5`) as well as the files on `main` — a timestamp can already be in use by a branch you cannot see. - **PGlite has one connection, so `CONCURRENTLY` breaks it.** Guard on `system.get(AppSystemProp.DB_TYPE) === DatabaseType.PGLITE` and issue a plain `CREATE INDEX` on that branch. When you do use `CONCURRENTLY`, set `transaction = false` on the migration class — PostgreSQL requires it outside a transaction. -- **`UpdateResult.affected` is `undefined` on PGlite — never branch on it.** TypeORM's Postgres driver sets `affected` from `raw.rowCount`, and `typeorm-pglite` returns PGlite's `Results` (`{ rows, fields, affectedRows }`) with no `rowCount`. So the compare-and-set idiom `if (result.affected === 0) return null` is *always false* on PGlite and every predicate in the `WHERE` becomes decorative — the guard silently passes. This is not test-only: `AP_DB_TYPE=PGLITE` is the documented one-line Docker install (`docs/install/options/docker.mdx`). It hit MCP OAuth (`mcpOAuthCodeService.consume`), where it made authorization codes replayable, unbound to their client and redirect_uri, and immune to expiry. Use `.returning('*')` and test `updateResult.raw` for emptiness instead — that works on both drivers. Same idiom still live in `ee/agent/agent-rpc-handlers.ts` and `ee/projects/platform-project-service.ts`. Integration tests run on PGlite, so a test written against this idiom passes without proving anything. +- **`UpdateResult.affected` is `undefined` on PGlite — never branch on it.** TypeORM's Postgres driver sets `affected` from `raw.rowCount`, and `typeorm-pglite` returns PGlite's `Results` (`{ rows, fields, affectedRows }`) with no `rowCount`. So the compare-and-set idiom `if (result.affected === 0) return null` is *always false* on PGlite and every predicate in the `WHERE` becomes decorative — the guard silently passes. This is not test-only: `AP_DB_TYPE=PGLITE` is the documented one-line Docker install (`docs/install/options/docker.mdx`). It hit MCP OAuth (`mcpOAuthCodeService.consume`), where it made authorization codes replayable, unbound to their client and redirect_uri, and immune to expiry. Use `.returning('*')` and test `updateResult.raw` for emptiness instead — that works on both drivers. Confirmed against the pinned `@electric-sql/pglite` 0.3.14: a plain `UPDATE` answers `{ rows, fields, affectedRows }` with **`rowCount: undefined`**, while the same statement with `RETURNING *` fills `rows` correctly (0 on no match, 1 on match). Note PGlite *does* report `affectedRows` — it is only `rowCount`, the field TypeORM reads, that is missing, so "PGlite loses the count" is the wrong mental model. The remaining call sites were converted in 2026-08 (`ee/agent/agent-rpc-handlers.ts`, `ee/projects/platform-project-service.ts`); a `.affected` that only feeds a log line was left alone. **Integration tests here run on Postgres** (`.env.tests` points at a real server), so they cannot catch this class at all — run the suite with `AP_DB_TYPE=PGLITE` prefixed to exercise it, which works today and is how the fix was proven red-to-green. Prefer `.returning('id')` over `.returning('*')`: on a table like `agent_conversation` the star form hauls the whole `messages` jsonb back on every write, and a row only has to be counted, not read. - **`breaking = true` is the rollback-safety flag, not the customer-facing one.** It marks destructive DDL (`DROP TABLE`/`DROP COLUMN`, `ADD ... NOT NULL` without a default) for `rollback-migrations.ts`. It does *not* by itself mean the PR needs the `⛓️‍💥 breaking-change` label — decide that from upgrade impact on self-hosters and API consumers. - **A new `AppSystemProp` needs three edits, not one.** Add the enum entry in `system-props.ts`, a default in `systemPropDefaultValues` (`system.ts`), *and* a validator in `systemPropValidators` (`system-validator.ts`). Miss the validator and `validateEnvPropsOnStartup` throws `systemPropValidators[prop] is not a function` at boot — every API test fails on setup, not just the new one. Document the var in `docs/install/reference/environment-variables.mdx` too. - **`permission: undefined` on `securityAccess.project(...)` silently allows any project member.** The argument is required in practice even though the type tolerates omitting it. diff --git a/brain/knowledge/execution-runtime/index.md b/brain/knowledge/execution-runtime/index.md index f0dfb83947e8..899ef70ff7c8 100644 --- a/brain/knowledge/execution-runtime/index.md +++ b/brain/knowledge/execution-runtime/index.md @@ -41,7 +41,7 @@ The four calls a run emits to the app during execution: `updateRunProgress`, `up - **In-flight Run** — a worker is actively executing it; has a FlowRun row + checkpointed log in Postgres/S3, survives worker or Redis loss. ### ⚠️ Gotchas -- **An agent tool's piece can go un-provisioned → `PieceNotFoundError` at runtime.** `extractAgentToolPieceRefs` (`flow-provisioning.ts`) strict-`safeParse`s each `agentTools` entry against `AgentPieceTool` and silently `return []`s on failure. `PredefinedInputsStructure` *requires* `fields`, but flow versions still carry the legacy flat `predefinedInput` (`{ auth, model, … }`) — those all fail to parse, so their pieces never get installed. The engine's `agentTools.tools()` does **no** validation and tolerates the legacy shape, so it happily tries to load the missing piece and the run dies `INTERNAL_ERROR` with an empty `failedStep`. Provisioning must not be stricter than the engine. +- **A flow's sandbox never needs an agent tool's piece — do not re-add provisioning for it.** Since the agent step became a thin client (#14699, #14730) a configured piece tool runs outside the flow entirely: `agent-worker-tools.ts` → RPC `executePieceTool` → `piece-tool-runner.ts` → `flow-run-utils.ts` → `actionRunService` submits a **separate action run that resolves its own piece** from `pieceName@pieceVersion`. The flow bundle only ever needs `@activepieces/piece-ai`. `flow-provisioning.ts` used to scan `step.settings.input['agentTools']` and union the result into `resolvePieces` (`extractAgentToolPieceRefs`, deleted 2026-08); it was installing packages into a sandbox nothing loaded them from. The lesson it was written for still holds wherever a validate-then-provision pair exists: **provisioning must not be stricter than the engine.** It strict-`safeParse`d each entry against `AgentPieceTool` and silently `return []`ed on failure, while the engine tolerated the legacy flat `predefinedInput` shape — so pieces went un-provisioned and runs died `INTERNAL_ERROR` with an empty `failedStep`. - **A wrong Flow Bundle is sticky forever.** `parseManifest` only invalidates on `schemaVersion !== LATEST_FLOW_SCHEMA_VERSION`. A bundle published by buggy/older worker code stays "valid", keeps being served for that locked flow version, and short-circuits `resolvePieces` — so fixing the resolver code does **not** heal affected flows. Recovery is deleting the `FLOW_BUNDLE` file row (its id **is** the `flowVersionId`) + S3 object, or republishing the flow. Worth a bundle-format/generation field in the manifest. - **The piece-bundle CDN prefix moved, and the flag is off by default again.** `CDN_PIECES_URL` (`piece-bundle.ts`) points at `https://cdn.activepieces.com/pieces/bundled/` — a 2026-08-13 seeding of the *repackaged, self-contained* tarballs, anonymously readable (`200`). It replaces `pieces/retro/`, whose ~1735 objects all answered **`403 AccessDenied`** on both `cdn.activepieces.com` and the Spaces origin (object ACL, not the CDN); since `cdnBundleExists` counts only `2xx` as present, that tier silently bought nothing but a wasted `HEAD` per resolve. `AP_USE_CDN_FOR_BUNDLES` defaults to `false` — opt in per deployment. Two sharp edges survive the move: `release-pieces.yml` does **not** mirror to the bucket, so any version published after a seeding permanently misses; and `safeHttp.axios` sets no `timeout`, so an egress policy that blackholes the CDN hangs the existence check for the OS TCP connect timeout on the piece-install path instead of failing fast. Auditing a prefix means an **anonymous** `curl` against the exact URL the server builds — an authenticated `ls` proves only that the bytes exist. Verified end-to-end on staging 2026-08-13 with the flag on: 1745 objects / 746 pieces, anonymously listable and readable, and the tarball a worker caches at `cache/v14/common/pieces/-/bundle.tgz` is **byte-identical** (md5 == CDN ETag) to the public object and carries `src/bundle.cjs`. The seeding holds one version per minor line as of that date, so *latest* versions 404 and fall back to npm — the "published after a seeding permanently misses" edge is the common case, not the rare one. - **Turning `AP_USE_CDN_FOR_BUNDLES` on is a one-way door for every piece version resolved during the rollout.** The flag is per-app-container, and a rolling deploy runs flagged and unflagged containers side by side. An unflagged container that resolves a piece writes the **npm** tarball into `pieces/v2/`, and because `resolve()` checks S3 before the CDN that version is pinned to the unbundled copy permanently — it never re-resolves, so finishing the rollout does not heal it. Measured on staging with only *two* app containers (Aug 2026): `text-helper 0.5.1` came back as the 18 KB npm tarball (md5 `68334b5c…`) instead of the 396 KB CDN bundle (`fcdc62c9…`), while pieces resolved by the flagged container correctly logged `source:"cdn"`. Cloud prod is **35 app containers across 5 hosts**, so the window is far wider and lands on the hottest piece versions first. Deploying canary first surfaces it but does not avoid it; the only clean fixes are pre-seeding `pieces/v2/` from the CDN before flipping, or deleting the poisoned keys afterwards. diff --git a/brain/knowledge/flows-execution/flow-runs.md b/brain/knowledge/flows-execution/flow-runs.md index 6e3fdf32eb41..9f279fab2976 100644 --- a/brain/knowledge/flows-execution/flow-runs.md +++ b/brain/knowledge/flows-execution/flow-runs.md @@ -29,6 +29,7 @@ A Flow Run records one execution of a specific flow version, from trigger to ter - **Failed-trigger payload** survives past BullMQ job completion only because `buildFailedTriggerContext` writes it into the trigger step's `output` slot. - **The trigger step's status IS the raw-vs-extracted discriminator for retry** — there is no separate field (a `payload` field was tried and removed as redundant). `FAILED` means "`output` holds a raw event, re-run `run()` on it" (`executeTrigger: true`); `SUCCEEDED` means "`output` is already the trigger's result, replay as-is" (`executeTrigger: false`). So any code that *fabricates* a trigger step without the engine having run — the `QUOTA_EXCEEDED` admission gate is the first — must pick the status from where its payload came: raw for sync webhooks, extracted for anything sourced from the worker RPC `submitPayloads` (which passes post-`TriggerHookType.RUN` output). Get it wrong on a polling trigger and retry re-polls against an already-advanced `lastPoll` cursor, so the run gets `undefined` or an unrelated newer item *and* silently consumes those fresh items' own runs. - **Big step outputs**: over 32 KB inline → stored as a `LogSliceRef` pointer to a `FLOW_RUN_LOG_SLICE` file (`outputType === SLICE`); missing backing file throws `ENTITY_NOT_FOUND` (loud retry failure). Step *inputs* over 2 KB (`AP_FLOW_RUN_LOG_INPUT_TRUNCATE_THRESHOLD_KB`) become a display-only truncation placeholder. +- **An INTERNAL_ERROR run does not record why it failed** — read the BullMQ job's `failedReason`, not the run. `reportFlowStatus` (`execute-flow.ts`) only forwards `logsFileId` when `data.logsFileId` is set, which is nil on every `BEGIN` run, so `engineRunCallbackService.uploadRunLog` skips persisting `internalError` into the log file. The run page and the log file both show nothing; the engine's actual error (plus its stderr) survives only in the job that `jobBroker.completeJob` moved to failed. On a dedicated worker group that job lives in `platform--jobs`, **not** `workerJobs` — pass `--queue` to `debug-failed-job.js` or it reports "job not found". - Retries only allowed on terminal states within `EXECUTION_DATA_RETENTION_DAYS`. - **Credit metering (Autumn)**: on terminal runs (paid editions), `onFinish` does two tryCatch-wrapped billing steps that never break run completion. (1) A PRODUCTION run not in `QUOTA_EXCEEDED` charges +1 apCredit via `billingProvider.trackCredits` with idempotency key `{runId}:run`. (2) `flowRunAiUsageTracker` pre-scans the flow version for `@activepieces/piece-ai` steps, extracts per-provider/model usage from step outputs (`flow-run-ai-usage-extractor` — recurses into loops, fetches `FLOW_RUN_LOG_SLICE` files, falls back to flow-version settings on `**REDACTED**` models), meters `Σ(messages × model credit weight) + toolCalls` to Autumn (`{runId}:ai`, plus `{runId}:appSumoAi` for the managed-ACTIVEPIECES AppSumo cap), then emits the `AI_USAGE_PER_RUN` PostHog event — the license key is only the PostHog distinctId, no longer a gate on metering. - **Credit gate is fail-open at admission**: the worker RPC `submitPayloads` checks `shouldBlockOnCredits` (blocks only when the platform is `billingEnforced` AND the cached balance is exhausted; CE default and Autumn-outage behavior is false). A blocked run is still admitted — as a `QUOTA_EXCEEDED` run with the trigger payload persisted in its log — so it stays retryable once credits return instead of being dropped. **`AP_EDITION=ee` skips the gate entirely** (`shouldBlockRunOnCredits` returns `false` before any provider call) so self-hosters pay no Redis/Autumn latency on admission — a temporary measure, see decision 000020. diff --git a/bun.lock b/bun.lock index 2932d3a1b32e..038b6187a90c 100644 --- a/bun.lock +++ b/bun.lock @@ -162,7 +162,7 @@ }, "packages/core/shared": { "name": "@activepieces/shared", - "version": "0.131.0", + "version": "0.132.0", "dependencies": { "@activepieces/core-execution": "workspace:*", "@activepieces/core-formula": "workspace:*", @@ -3756,7 +3756,7 @@ }, "packages/pieces/community/google-contacts": { "name": "@activepieces/piece-google-contacts", - "version": "0.4.8", + "version": "0.4.9", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -4415,7 +4415,7 @@ }, "packages/pieces/community/imap": { "name": "@activepieces/piece-imap", - "version": "0.4.7", + "version": "0.4.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -7589,7 +7589,7 @@ }, "packages/pieces/community/resend": { "name": "@activepieces/piece-resend", - "version": "0.4.4", + "version": "0.4.5", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -9795,7 +9795,7 @@ }, "packages/pieces/community/wordpress": { "name": "@activepieces/piece-wordpress", - "version": "0.4.7", + "version": "0.4.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", diff --git a/packages/core/shared/package.json b/packages/core/shared/package.json index d8e9003cbfd4..d7d6847ba387 100644 --- a/packages/core/shared/package.json +++ b/packages/core/shared/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/shared", - "version": "0.131.0", + "version": "0.133.0", "type": "commonjs", "sideEffects": false, "main": "./dist/src/index.js", diff --git a/packages/core/shared/src/lib/ee/agent/agent.ts b/packages/core/shared/src/lib/ee/agent/agent.ts new file mode 100644 index 000000000000..e87f83d2eb96 --- /dev/null +++ b/packages/core/shared/src/lib/ee/agent/agent.ts @@ -0,0 +1,99 @@ +import { AgentOutputField, AgentTool } from '@activepieces/core-execution' +import { AIProviderName, ApId, BaseModelSchema, Nullable } from '@activepieces/core-utils' +import { z } from 'zod' +import { formErrors } from '../../form-errors' +import { ColorName } from '../../management/project/project' + +const MAX_AGENT_TEXT_LENGTH = 51_200 +const MAX_AGENT_TOOLS = 100 +const MAX_AGENT_OUTPUT_FIELDS = 50 +const MAX_AGENT_STEP_BUDGET = 1_000 +const MAX_AGENT_SHARED_MEMBERS = 200 +const MAX_AGENT_PAGE_SIZE = 100 +const DEFAULT_AGENT_MAX_STEPS = 20 + +enum AgentVisibility { + PROJECT = 'PROJECT', + RESTRICTED = 'RESTRICTED', +} + +enum AgentIcon { + BOT = 'bot', + SPARKLES = 'sparkles', + MESSAGE = 'message-square', + USERS = 'users', + BOOK = 'book-open', + CHART = 'chart-line', + CALENDAR = 'calendar', + MAIL = 'mail', + GLOBE = 'globe', + FILE = 'file-text', + SEARCH = 'search', + ZAP = 'zap', +} + +const AgentConfig = z.object({ + instructions: z.string().max(MAX_AGENT_TEXT_LENGTH), + provider: Nullable(z.enum(AIProviderName)), + modelName: Nullable(z.string()), + maxSteps: z.number().int().positive().max(MAX_AGENT_STEP_BUDGET).default(DEFAULT_AGENT_MAX_STEPS), + tools: z.array(AgentTool).max(MAX_AGENT_TOOLS).default([]), + structuredOutput: z.array(AgentOutputField).max(MAX_AGENT_OUTPUT_FIELDS).default([]), +}) + +const Agent = z.object({ + ...BaseModelSchema, + projectId: ApId, + ownerId: ApId, + externalId: z.string(), + displayName: z.string(), + description: Nullable(z.string()), + icon: z.enum(AgentIcon), + color: z.enum(ColorName), + visibility: z.enum(AgentVisibility), + sharedWithUserIds: z.array(ApId), + draft: AgentConfig, + published: Nullable(AgentConfig), +}) + +const CreateAgentRequest = z.object({ + projectId: ApId, + displayName: z.string().min(1, formErrors.required), + description: Nullable(z.string()), + icon: z.enum(AgentIcon), + color: z.enum(ColorName), + visibility: z.enum(AgentVisibility).optional(), + sharedWithUserIds: z.array(ApId).max(MAX_AGENT_SHARED_MEMBERS).optional(), + draft: AgentConfig, +}) + +const UpdateAgentRequest = CreateAgentRequest.omit({ projectId: true }).partial() + +const ListAgentsRequest = z.object({ + projectId: z.optional(ApId), + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(MAX_AGENT_PAGE_SIZE).optional(), +}) + +export { + Agent, + AgentConfig, + AgentIcon, + AgentVisibility, + CreateAgentRequest, + DEFAULT_AGENT_MAX_STEPS, + ListAgentsRequest, + MAX_AGENT_OUTPUT_FIELDS, + MAX_AGENT_PAGE_SIZE, + MAX_AGENT_SHARED_MEMBERS, + MAX_AGENT_STEP_BUDGET, + MAX_AGENT_TEXT_LENGTH, + MAX_AGENT_TOOLS, + UpdateAgentRequest, +} + +export type Agent = z.infer +export type AgentConfig = z.infer +export type CreateAgentRequest = z.infer +export type ListAgentsRequest = z.infer +export type UpdateAgentRequest = z.infer diff --git a/packages/core/shared/src/lib/ee/agent/index.ts b/packages/core/shared/src/lib/ee/agent/index.ts index c689e2364fb4..739cf1b6ac80 100644 --- a/packages/core/shared/src/lib/ee/agent/index.ts +++ b/packages/core/shared/src/lib/ee/agent/index.ts @@ -2,6 +2,7 @@ import { AgentPromptOverride, AgentRunSource } from '@activepieces/core-executio import { BaseModelSchema, Nullable } from '@activepieces/core-utils' import { z } from 'zod' import { formErrors } from '../../form-errors' +import { MAX_AGENT_TEXT_LENGTH } from './agent' const MAX_FILE_BINARY_SIZE = 10 * 1024 * 1024 const MAX_FILE_BASE64_CHARS = Math.ceil(MAX_FILE_BINARY_SIZE * 4 / 3) @@ -260,7 +261,7 @@ export const InstructAgentMemoryRequest = z.object({ export type InstructAgentMemoryRequest = z.infer export const SendAgentMessageRequest = z.object({ - content: z.string().max(51200), + content: z.string().max(MAX_AGENT_TEXT_LENGTH), runId: z.string().optional(), files: z.array(AgentMessageFile).max(10).optional(), }).refine( @@ -278,8 +279,8 @@ export type SetAgentMessageFeedbackRequest = z.infer val.userMessage !== undefined || (val.userMessages !== undefined && val.userMessages.length > 0), @@ -363,6 +364,7 @@ export type BatchProgressData = { export type AgentAllowedMimeType = typeof CHAT_ALLOWED_MIME_TYPES[number] export { CHAT_ALLOWED_MIME_TYPES } +export * from './agent' export { agentToolClassification } from './tool-classification' export { agentToolPhases, type AgentPhase } from './tool-phases' export { chatVisibility, type ResolveChatEnabledParams } from './chat-visibility' diff --git a/packages/core/shared/src/lib/ee/audit-events/index.ts b/packages/core/shared/src/lib/ee/audit-events/index.ts index a4d15f44dd5d..5e2bc10df1a5 100644 --- a/packages/core/shared/src/lib/ee/audit-events/index.ts +++ b/packages/core/shared/src/lib/ee/audit-events/index.ts @@ -34,6 +34,9 @@ export enum ApplicationEventName { FOLDER_DELETED = 'folder.deleted', CONNECTION_UPSERTED = 'connection.upserted', CONNECTION_DELETED = 'connection.deleted', + AGENT_CREATED = 'agent.created', + AGENT_UPDATED = 'agent.updated', + AGENT_DELETED = 'agent.deleted', VARIABLE_UPSERTED = 'variable.upserted', VARIABLE_DELETED = 'variable.deleted', VARIABLE_VALUE_REVEALED = 'variable.value.revealed', @@ -99,6 +102,24 @@ export const ConnectionDeletedEvent = z.object({ }) export type ConnectionDeletedEvent = z.infer +const AgentEventData = z.object({ + agent: z.object({ + id: z.string(), + displayName: z.string(), + }), +}) + +export const AgentAuditEvent = z.object({ + ...BaseAuditEventProps, + action: z.union([ + z.literal(ApplicationEventName.AGENT_CREATED), + z.literal(ApplicationEventName.AGENT_UPDATED), + z.literal(ApplicationEventName.AGENT_DELETED), + ]), + data: AgentEventData, +}) +export type AgentAuditEvent = z.infer + const VariableEventData = z.object({ variable: z.object({ id: z.string(), @@ -478,6 +499,7 @@ export const ProjectReplacedEvent = z.object({ export type ProjectReplacedEvent = z.infer export const ApplicationEvent = z.union([ + AgentAuditEvent, ConnectionEvent, VariableEvent, FlowCreatedEvent, @@ -534,6 +556,12 @@ export function summarizeApplicationEvent(event: ApplicationEvent) { return `${event.data.connection.displayName} (${event.data.connection.externalId}) is updated` case ApplicationEventName.CONNECTION_DELETED: return `${event.data.connection.displayName} (${event.data.connection.externalId}) is deleted` + case ApplicationEventName.AGENT_CREATED: + return `Agent ${event.data.agent.displayName} is created` + case ApplicationEventName.AGENT_UPDATED: + return `Agent ${event.data.agent.displayName} is updated` + case ApplicationEventName.AGENT_DELETED: + return `Agent ${event.data.agent.displayName} is deleted` case ApplicationEventName.VARIABLE_UPSERTED: return `Variable ${event.data.variable.name} is created or updated` case ApplicationEventName.VARIABLE_DELETED: diff --git a/packages/core/shared/src/lib/ee/audit-events/mock-event-builder.ts b/packages/core/shared/src/lib/ee/audit-events/mock-event-builder.ts index a386272f8058..ec0c0999233c 100644 --- a/packages/core/shared/src/lib/ee/audit-events/mock-event-builder.ts +++ b/packages/core/shared/src/lib/ee/audit-events/mock-event-builder.ts @@ -1,6 +1,7 @@ import { FlowOperationType, FlowStatus } from '@activepieces/core-execution' import { apId, PlatformId, ProjectId } from '@activepieces/core-utils' import { + AgentAuditEvent, ApplicationEvent, ApplicationEventName, AuthenticationEvent, @@ -168,6 +169,21 @@ export const buildMockEvent = ({ event, platformId, projectId }: BuildMockEventP } return mock } + case ApplicationEventName.AGENT_CREATED: + case ApplicationEventName.AGENT_UPDATED: + case ApplicationEventName.AGENT_DELETED: { + const mock: AgentAuditEvent = { + ...baseEnvelope, + action: event, + data: { + agent: { + id: apId(), + displayName: 'Marketing agent', + }, + }, + } + return mock + } case ApplicationEventName.VARIABLE_UPSERTED: case ApplicationEventName.VARIABLE_DELETED: case ApplicationEventName.VARIABLE_VALUE_REVEALED: { diff --git a/packages/core/shared/src/lib/ee/authn/access-control-list.ts b/packages/core/shared/src/lib/ee/authn/access-control-list.ts index ee7f626d2610..05450cea40b2 100644 --- a/packages/core/shared/src/lib/ee/authn/access-control-list.ts +++ b/packages/core/shared/src/lib/ee/authn/access-control-list.ts @@ -30,6 +30,8 @@ export const rolePermissions: Record = { Permission.WRITE_KNOWLEDGE_BASE, Permission.READ_VARIABLE, Permission.WRITE_VARIABLE, + Permission.READ_AGENT, + Permission.WRITE_AGENT, ], [DefaultProjectRole.EDITOR]: [ Permission.READ_APP_CONNECTION, @@ -54,6 +56,8 @@ export const rolePermissions: Record = { Permission.WRITE_KNOWLEDGE_BASE, Permission.READ_VARIABLE, Permission.WRITE_VARIABLE, + Permission.READ_AGENT, + Permission.WRITE_AGENT, ], [DefaultProjectRole.VIEWER]: [ Permission.READ_APP_CONNECTION, @@ -67,5 +71,6 @@ export const rolePermissions: Record = { Permission.READ_MCP, Permission.READ_KNOWLEDGE_BASE, Permission.READ_VARIABLE, + Permission.READ_AGENT, ], } diff --git a/packages/core/shared/src/lib/ee/billing/index.ts b/packages/core/shared/src/lib/ee/billing/index.ts index 9c5487fc960f..179656e9f184 100644 --- a/packages/core/shared/src/lib/ee/billing/index.ts +++ b/packages/core/shared/src/lib/ee/billing/index.ts @@ -91,6 +91,7 @@ export const AUTUMN_FREE_PLAN: PlatformPlanWithOnlyLimits = { embeddingEnabled: false, aiProvidersEnabled: false, chatEnabled: true, + agentsEnabled: true, workerGroupsEnabled: false, globalConnectionsEnabled: false, customRolesEnabled: false, @@ -117,6 +118,7 @@ export const OPEN_SOURCE_PLAN: PlatformPlanWithOnlyLimits = { embeddingEnabled: false, aiProvidersEnabled: true, chatEnabled: false, + agentsEnabled: false, workerGroupsEnabled: false, globalConnectionsEnabled: false, customRolesEnabled: false, diff --git a/packages/core/shared/src/lib/management/platform/platform.model.ts b/packages/core/shared/src/lib/management/platform/platform.model.ts index 9c46961d6936..bbee7c2ae5ee 100644 --- a/packages/core/shared/src/lib/management/platform/platform.model.ts +++ b/packages/core/shared/src/lib/management/platform/platform.model.ts @@ -57,6 +57,7 @@ export enum FeatureFlagId { EMBEDDING_ENABLED = 'embeddingEnabled', AI_PROVIDERS_ENABLED = 'aiProvidersEnabled', CHAT_ENABLED = 'chatEnabled', + AGENTS_ENABLED = 'agentsEnabled', WORKER_GROUPS_ENABLED = 'workerGroupsEnabled', MANAGE_PIECES_ENABLED = 'managePiecesEnabled', MANAGE_TEMPLATES_ENABLED = 'manageTemplatesEnabled', @@ -93,6 +94,7 @@ export const PlatformPlan = z.object({ embeddingEnabled: z.boolean(), aiProvidersEnabled: z.boolean(), chatEnabled: z.boolean(), + agentsEnabled: z.boolean(), workerGroupsEnabled: z.boolean(), managePiecesEnabled: z.boolean(), manageTemplatesEnabled: z.boolean(), diff --git a/packages/core/utils/src/lib/permission.ts b/packages/core/utils/src/lib/permission.ts index 7ff71d879f78..c6899be6401f 100644 --- a/packages/core/utils/src/lib/permission.ts +++ b/packages/core/utils/src/lib/permission.ts @@ -27,6 +27,8 @@ export enum Permission { WRITE_KNOWLEDGE_BASE = 'WRITE_KNOWLEDGE_BASE', READ_VARIABLE = 'READ_VARIABLE', WRITE_VARIABLE = 'WRITE_VARIABLE', + READ_AGENT = 'READ_AGENT', + WRITE_AGENT = 'WRITE_AGENT', } export enum RoleType { diff --git a/packages/pieces/community/google-drive/src/i18n/translation.json b/packages/pieces/community/google-drive/src/i18n/translation.json index e31dc12971c0..7748aaa16fe7 100644 --- a/packages/pieces/community/google-drive/src/i18n/translation.json +++ b/packages/pieces/community/google-drive/src/i18n/translation.json @@ -15,6 +15,7 @@ "Move File": "Move File", "Delete file": "Delete file", "Trash file": "Trash file", + "Export Folder as Zip": "Export Folder as Zip", "Create Folder": "Create Folder", "Create File from Text": "Create File from Text", "Upload File": "Upload File", @@ -66,6 +67,7 @@ "Moves a file from one folder to another.": "Moves a file from one folder to another.", "Delete permanently a file from your Google Drive": "Delete permanently a file from your Google Drive", "Move a file to the trash in your Google Drive": "Move a file to the trash in your Google Drive", + "Recursively export a Google Drive folder (with all subfolders) as a single zip file.": "Recursively export a Google Drive folder (with all subfolders) as a single zip file.", "Fetch a file from a public URL and upload it into Google Drive.": "Fetch a file from a public URL and upload it into Google Drive.", "Replace an existing Drive file's bytes, keeping its ID and name.": "Replace an existing Drive file's bytes, keeping its ID and name.", "Export a native Google Workspace file (Doc/Sheet/Slides) to a chosen format.": "Export a native Google Workspace file (Doc/Sheet/Slides) to a chosen format.", @@ -119,6 +121,14 @@ "User email": "User email", "Role": "Role", "Send invitation email": "Send invitation email", + "Markdown": "Markdown", + "Folder": "Folder", + "Google Docs": "Google Docs", + "Google Sheets": "Google Sheets", + "Google Slides": "Google Slides", + "Output Zip File Name": "Output Zip File Name", + "Use password": "Use password", + "Password options": "Password options", "Parent Folder ID": "Parent Folder ID", "Source URL": "Source URL", "MIME Type": "MIME Type", @@ -182,6 +192,12 @@ "You can use **Search Folder/File** action to retrieve ID.": "You can use **Search Folder/File** action to retrieve ID.", "The ID of the file to delete": "The ID of the file to delete", "The ID of the file to trash": "The ID of the file to trash", + "Zip paths mirror the Drive folder exactly, with no renaming. The action fails before downloading anything if: a file and a folder share a name in the same Drive folder, two items share a name, or a Google Doc/Sheet/Slides export lands on a name that already exists (e.g. a Sheet named \"Report\" exported as PDF alongside an existing \"Report.pdf\"); or an included item's name contains \"/\" or \"\\\", or is exactly \".\" or \"..\" (not usable as a zip path segment). Rename the conflicting or unsafe item in Drive and re-r": "Zip paths mirror the Drive folder exactly, with no renaming. The action fails before downloading anything if: a file and a folder share a name in the same Drive folder, two items share a name, or a Google Doc/Sheet/Slides export lands on a name that already exists (e.g. a Sheet named \"Report\" exported as PDF alongside an existing \"Report.pdf\"); or an included item's name contains \"/\" or \"\\\", or is exactly \".\" or \"..\" (not usable as a zip path segment). Rename the conflicting or unsafe item in Drive and re-run.", + "The Drive folder to export (including all subfolders).": "The Drive folder to export (including all subfolders).", + "How to include native Google Docs found in the folder.": "How to include native Google Docs found in the folder.", + "How to include native Google Sheets found in the folder.": "How to include native Google Sheets found in the folder.", + "How to include native Google Slides found in the folder.": "How to include native Google Slides found in the folder.", + "Enable password protection for the zip file": "Enable password protection for the zip file", "The ID of the folder to create the new folder inside. Leave empty to create it in the root of My Drive. Resolve a folder ID with `drive_search_files`.": "The ID of the folder to create the new folder inside. Leave empty to create it in the root of My Drive. Resolve a folder ID with `drive_search_files`.", "The ID of the folder to create the file inside. Leave empty to create it in the root of My Drive. Resolve a folder ID with `drive_search_files`.": "The ID of the folder to create the file inside. Leave empty to create it in the root of My Drive. Resolve a folder ID with `drive_search_files`.", "The ID of the folder to upload the file into. Leave empty to upload to the root of My Drive. Resolve a folder ID with `drive_search_files`.": "The ID of the folder to upload the file into. Leave empty to upload to the root of My Drive. Resolve a folder ID with `drive_search_files`.", @@ -254,14 +270,17 @@ "All": "All", "Files": "Files", "Folders": "Folders", - "Google Sheets": "Google Sheets", - "Google Docs": "Google Docs", "Organizer": "Organizer", "File Organizer": "File Organizer", "Writer": "Writer", "Commenter": "Commenter", "Reader": "Reader", "Editor": "Editor", + "Word (DOCX)": "Word (DOCX)", + "PDF": "PDF", + "Skip": "Skip", + "Excel (XLSX)": "Excel (XLSX)", + "PowerPoint (PPTX)": "PowerPoint (PPTX)", "Word (DOCX) — Docs": "Word (DOCX) — Docs", "PDF — Docs/Sheets/Slides": "PDF — Docs/Sheets/Slides", "HTML — Docs": "HTML — Docs", diff --git a/packages/server/api/src/app/database/database-connection.ts b/packages/server/api/src/app/database/database-connection.ts index 79251f6bfdb1..0ea4cddef070 100644 --- a/packages/server/api/src/app/database/database-connection.ts +++ b/packages/server/api/src/app/database/database-connection.ts @@ -9,6 +9,7 @@ import { PlatformAnalyticsReportEntity } from '../analytics/platform-analytics-r import { AppConnectionEntity } from '../app-connection/app-connection.entity' import { UserIdentityEntity } from '../authentication/user-identity/user-identity-entity' import { AgentConversationEntity } from '../ee/agent/agent-conversation-entity' +import { AgentEntity } from '../ee/agent/agent-entity' import { ChatRolloutUserEntity } from '../ee/agent/chat-rollout-user-entity' import { UserMemoryEntity } from '../ee/agent/user-memory-entity' import { AlertEntity } from '../ee/alerts/alerts-entity' @@ -106,6 +107,7 @@ function getEntities(): EntitySchema[] { KnowledgeBaseFileEntity, KnowledgeBaseChunkEntity, ToolSearchIndexEntity, + AgentEntity, AgentConversationEntity, ChatRolloutUserEntity, UserMemoryEntity, diff --git a/packages/server/api/src/app/database/migration/postgres/1825000000000-AddAgentTable.ts b/packages/server/api/src/app/database/migration/postgres/1825000000000-AddAgentTable.ts new file mode 100644 index 000000000000..f182b88e1d47 --- /dev/null +++ b/packages/server/api/src/app/database/migration/postgres/1825000000000-AddAgentTable.ts @@ -0,0 +1,56 @@ +import { QueryRunner } from 'typeorm' +import { Migration } from '../../migration' + +export class AddAgentTable1825000000000 implements Migration { + name = 'AddAgentTable1825000000000' + breaking = true + release = '0.88.1' + transaction = true + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP TABLE IF EXISTS "agent_run" CASCADE') + await queryRunner.query('DROP TABLE IF EXISTS "agent" CASCADE') + + await queryRunner.query(` + CREATE TABLE "agent" ( + "id" character varying(21) NOT NULL, + "created" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "projectId" character varying(21) NOT NULL, + "ownerId" character varying(21) NOT NULL, + "externalId" character varying NOT NULL, + "displayName" character varying NOT NULL, + "description" character varying, + "icon" character varying NOT NULL, + "color" character varying NOT NULL, + "visibility" character varying NOT NULL, + "sharedWithUserIds" character varying array NOT NULL DEFAULT '{}', + "draft" jsonb NOT NULL, + "published" jsonb, + CONSTRAINT "pk_agent" PRIMARY KEY ("id") + ) + `) + + await queryRunner.query(` + CREATE INDEX "idx_agent_project_created_id" ON "agent" ("projectId", "created", "id") + `) + + await queryRunner.query(` + CREATE UNIQUE INDEX "idx_agent_project_external_id" ON "agent" ("projectId", "externalId") + `) + + await queryRunner.query(` + ALTER TABLE "agent" + ADD CONSTRAINT "fk_agent_project_id" FOREIGN KEY ("projectId") REFERENCES "project"("id") ON DELETE CASCADE ON UPDATE NO ACTION + `) + + await queryRunner.query(` + ALTER TABLE "agent" + ADD CONSTRAINT "fk_agent_owner_id" FOREIGN KEY ("ownerId") REFERENCES "user"("id") ON UPDATE NO ACTION + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP TABLE IF EXISTS "agent" CASCADE') + } +} diff --git a/packages/server/api/src/app/database/postgres-connection.ts b/packages/server/api/src/app/database/postgres-connection.ts index 638f18f05b58..080651fc6d07 100644 --- a/packages/server/api/src/app/database/postgres-connection.ts +++ b/packages/server/api/src/app/database/postgres-connection.ts @@ -414,6 +414,7 @@ import { AddAuditEventPlatformIdCreatedIdIndex1820000000000 } from './migration/ import { AddAgentConversationFlowStepRetentionIndex1821000000000 } from './migration/postgres/1821000000000-AddAgentConversationFlowStepRetentionIndex' import { RenameChatTablesToAgent1822000000000 } from './migration/postgres/1822000000000-RenameChatTablesToAgent' import { AddRenamedChatTableCompatViews1823000000000 } from './migration/postgres/1823000000000-AddRenamedChatTableCompatViews' +import { AddAgentTable1825000000000 } from './migration/postgres/1825000000000-AddAgentTable' const getSslConfig = (): boolean | TlsOptions => { const useSsl = system.get(AppSystemProp.POSTGRES_USE_SSL) @@ -843,6 +844,7 @@ export const getMigrations = (): (new () => Migration)[] => { AddAgentConversationFlowStepRetentionIndex1821000000000, RenameChatTablesToAgent1822000000000, AddRenamedChatTableCompatViews1823000000000, + AddAgentTable1825000000000, ] return migrations } 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 61b56041c47c..eb60fd529dd1 100644 --- a/packages/server/api/src/app/ee/agent/agent-controller.ts +++ b/packages/server/api/src/app/ee/agent/agent-controller.ts @@ -1,506 +1,171 @@ -import { ActivepiecesError, apId, ErrorCode, isNil, spreadIfDefined, tryCatch } from '@activepieces/core-utils' -import { AgentConversationStatus, CreateAgentConversationRequest, ImportAgentMemoryRequest, InstructAgentMemoryRequest, LATEST_JOB_DATA_SCHEMA_VERSION, PrincipalType, SendAgentMessageRequest, SERVICE_KEY_SECURITY_OPENAPI, SetAgentMessageFeedbackRequest, UpdateAgentConversationRequest, UpdateAgentMemoryRequest, WorkerJobType } from '@activepieces/shared' -import { FastifyBaseLogger } from 'fastify' +import { ApId, assertNotNullOrUndefined, Permission, SeekPage, UserId } from '@activepieces/core-utils' +import { Agent, ApplicationEventName, CreateAgentRequest, ListAgentsRequest, PrincipalType, SERVICE_KEY_SECURITY_OPENAPI, UpdateAgentRequest } from '@activepieces/shared' +import { FastifyRequest } from 'fastify' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { StatusCodes } from 'http-status-codes' import { z } from 'zod' -import { aiProviderService } from '../../ai/ai-provider-service' +import { ProjectResourceType } from '../../core/security/authorization/common' import { securityAccess } from '../../core/security/authorization/fastify-security' -import { assertCreditsAndAppSumoNotExceeded } from '../../platform/billing-provider' -import { jobQueue, JobType } from '../../workers/job-queue/job-queue' -import { agentApprovalGate } from './agent-approval-gate' -import { agentHelpers } from './agent-helpers' -import { agentMemoryAi } from './agent-memory-ai' +import { applicationEvents } from '../../helper/application-events' +import { securityHelper } from '../../helper/security-helper' +import { AgentEntity } from './agent-entity' import { agentService } from './agent-service' -import { chatAnalyticsTelemetry } from './chat-analytics-sync' -import { chatPlanGrant } from './chat-plan-grant' -import { chatRolloutService } from './chat-rollout-service' -import { findConnectionsForPiece } from './tools/agent-tools' - -const CHAT_PRINCIPALS = [PrincipalType.USER] as const export const agentController: FastifyPluginAsyncZod = async (app) => { - - app.post('/conversations', CreateConversationRoute, async (request, reply) => { - const conversation = await agentService(request.log).createConversation({ - platformId: request.principal.platform.id, - userId: request.principal.id, + app.post('/', CreateAgentRoute, async (request, reply) => { + const ownerId = await resolveUserId(request) + const agent = await agentService(request.log).create({ + projectId: request.projectId, + ownerId, request: request.body, }) - return reply.status(StatusCodes.CREATED).send(conversation) + applicationEvents(request.log).sendUserEvent(request, { + action: ApplicationEventName.AGENT_CREATED, + data: { agent: { id: agent.id, displayName: agent.displayName } }, + }) + return reply.status(StatusCodes.CREATED).send(agent) }) - app.get('/conversations', ListConversationsRoute, async (request) => { - return agentService(request.log).listConversations({ + app.get('/', ListAgentsRoute, async (request): Promise> => { + return agentService(request.log).list({ platformId: request.principal.platform.id, - userId: request.principal.id, + userId: await resolveUserId(request), + projectId: request.query.projectId, cursor: request.query.cursor, - limit: request.query.limit ?? 20, + limit: request.query.limit, }) }) - app.get('/conversations/:id', GetConversationRoute, async (request) => { - return agentService(request.log).getConversationOrThrow({ + app.get('/:id', GetAgentRoute, async (request): Promise => { + return agentService(request.log).getOneOrThrow({ id: request.params.id, - platformId: request.principal.platform.id, - userId: request.principal.id, + projectId: request.projectId, + userId: await resolveUserId(request), }) }) - app.post('/conversations/:id', UpdateConversationRoute, async (request) => { - return agentService(request.log).updateConversation({ + app.post('/:id', UpdateAgentRoute, async (request): Promise => { + const agent = await agentService(request.log).update({ id: request.params.id, - platformId: request.principal.platform.id, - userId: request.principal.id, + projectId: request.projectId, + userId: await resolveUserId(request), request: request.body, }) - }) - - app.delete('/conversations/:id', DeleteConversationRoute, async (request, reply) => { - await agentService(request.log).deleteConversation({ - id: request.params.id, - platformId: request.principal.platform.id, - userId: request.principal.id, + applicationEvents(request.log).sendUserEvent(request, { + action: ApplicationEventName.AGENT_UPDATED, + data: { agent: { id: agent.id, displayName: agent.displayName } }, }) - return reply.status(StatusCodes.NO_CONTENT).send() + return agent }) - app.get('/conversations/:id/messages', GetMessagesRoute, async (request) => { - return agentService(request.log).getMessages({ + app.delete('/:id', DeleteAgentRoute, async (request, reply): Promise => { + const agent = await agentService(request.log).delete({ id: request.params.id, - platformId: request.principal.platform.id, - userId: request.principal.id, + projectId: request.projectId, + userId: await resolveUserId(request), }) - }) - - app.post('/conversations/:id/messages/:messageIndex/feedback', SetMessageFeedbackRoute, async (request, reply) => { - await agentService(request.log).setMessageFeedback({ - id: request.params.id, - platformId: request.principal.platform.id, - userId: request.principal.id, - messageIndex: request.params.messageIndex, - request: request.body, + applicationEvents(request.log).sendUserEvent(request, { + action: ApplicationEventName.AGENT_DELETED, + data: { agent: { id: agent.id, displayName: agent.displayName } }, }) - return reply.status(StatusCodes.OK).send({ success: true }) - }) - - app.post('/funnel/landing', FunnelLandingRoute, async (request, reply) => { - // Cloud rollout: record that this user opened the chat page, then refresh the console - // funnel snapshot. Awaited recordLanding so the pushed landed count includes this landing. - await chatRolloutService.recordLanding({ - userId: request.principal.id, - platformId: request.principal.platform.id, - }) - chatAnalyticsTelemetry(request.log).sendRolloutFunnelUpdate() return reply.status(StatusCodes.NO_CONTENT).send() }) - - app.post('/conversations/:id/messages', SendMessageRoute, async (request, reply) => { - const { content, runId: clientRunId, files } = request.body - const conversationId = request.params.id - const userId = request.principal.id - const platformId = request.principal.platform.id - const log = request.log.child({ conversation: { id: conversationId }, user: { id: userId }, platform: { id: platformId } }) - - log.info({ filesCount: files?.length ?? 0, contentLength: content.length }, '[agentController] Chat message received') - - const conversation = await agentService(log).getConversationOrThrow({ - id: conversationId, - platformId, - userId, - }) - - await assertAgentMessageRateLimitNotExceeded({ platformId, userId, log }) - - // Cloud rollout: count this user as a distinct chatter (no-op off cloud, deduped). - const { needsCreditDecision } = await chatRolloutService.recordChatted({ userId, platformId }) - // Refresh the console rollout funnel snapshot (chatted count just changed). - chatAnalyticsTelemetry(log).sendRolloutFunnelUpdate() - if (needsCreditDecision) { - const { error } = await tryCatch(() => chatPlanGrant.grant({ userId, platformId, log })) - if (!isNil(error)) { - log.warn({ error, platform: { id: platformId }, user: { id: userId } }, '[agentController] Chat plan grant failed; continuing to the credit gate') - } - } - - const runId = typeof clientRunId === 'string' ? clientRunId : apId() - const runLog = log.child({ run: { id: runId } }) - - // Claim ownership atomically in the DB — the single source of truth that - // saveAgentMessages/updateAgentProgress/heartbeat fence against. A late write from the - // preempted run is rejected as soon as this UPDATE commits (its runId no longer matches), - // with no Redis/DB split to race through. The prior owner is read from the same row. - const preemptedRunId = conversation.status === AgentConversationStatus.STREAMING - ? conversation.activeRunId - : null - await agentHelpers.conversationRepo().update(conversationId, { activeRunId: runId }) - - if (conversation.status === AgentConversationStatus.STREAMING) { - log.info({ ...spreadIfDefined('preemptedRunId', preemptedRunId ?? undefined) }, '[agentController] Cancelling in-flight run before new message') - const cancelPromises = [ - agentApprovalGate.requestCancel({ conversationId }), - ] - if (preemptedRunId) { - cancelPromises.push(agentApprovalGate.requestCancel({ conversationId, runId: preemptedRunId })) - } - await Promise.all(cancelPromises) - await agentHelpers.conversationRepo().update(conversationId, { - status: AgentConversationStatus.IDLE, - }) - await agentApprovalGate.clearPendingGate({ conversationId }) - } - - await assertChatProviderConfigured({ platformId, log }) - await assertCreditsAndAppSumoNotExceeded({ platformId, log }) - - await jobQueue(runLog).add({ - id: apId(), - type: JobType.ONE_TIME, - data: { - schemaVersion: LATEST_JOB_DATA_SCHEMA_VERSION, - jobType: WorkerJobType.EXECUTE_AGENT_RUN, - conversationId, - runId, - projectId: conversation.projectId ?? null, - platformId, - userId, - userMessage: content, - modelName: conversation.modelName ?? null, - files, - }, - }) - runLog.info({ job: { type: WorkerJobType.EXECUTE_AGENT_RUN } }, '[agentController] Enqueued chat agent job') - - return reply.status(StatusCodes.OK).send({ conversationId, runId }) - }) - - app.post('/tool-approvals/:gateId', ToolApprovalRoute, async (request, reply) => { - request.log.info({ gate: { id: request.params.gateId }, approved: request.body.approved }, '[agentController] Tool approval received') - await agentApprovalGate.resolveGate({ - gateId: request.params.gateId, - approved: request.body.approved, - payload: request.body.payload, - log: request.log, - }) - return reply.status(StatusCodes.OK).send({ success: true }) - }) - - app.post('/conversations/:id/cancel', CancelConversationRoute, async (request, reply) => { - const conversationId = request.params.id - const platformId = request.principal.platform.id - const userId = request.principal.id - const log = request.log.child({ conversation: { id: conversationId }, user: { id: userId }, platform: { id: platformId } }) - const conversation = await agentService(log).getConversationOrThrow({ id: conversationId, platformId, userId }) - const activeRunId = conversation.activeRunId - log.info({ ...spreadIfDefined('activeRunId', activeRunId ?? undefined) }, '[agentController] Cancel requested') - const cancelPromises = [ - agentApprovalGate.requestCancel({ conversationId }), - ] - if (activeRunId) { - cancelPromises.push(agentApprovalGate.requestCancel({ conversationId, runId: activeRunId })) - } - await Promise.all(cancelPromises) - await agentHelpers.conversationRepo().update(conversationId, { - status: AgentConversationStatus.IDLE, - }) - await agentApprovalGate.clearPendingGate({ conversationId }) - return reply.status(StatusCodes.OK).send({ success: true }) - }) - - app.get('/conversations/:id/pending-gate', GetPendingGateRoute, async (request, reply) => { - const conversationId = request.params.id - const platformId = request.principal.platform.id - const userId = request.principal.id - const conversation = await agentService(request.log).getConversationOrThrow({ id: conversationId, platformId, userId }) - const gate = await agentApprovalGate.getPendingGate({ conversationId }) - // A preempted run can leave (or race in) a pending gate keyed by conversation; only surface - // the gate when it belongs to the run that currently owns the conversation. - const gateRunId = gate?.runId - const staleGate = !isNil(gateRunId) && !isNil(conversation.activeRunId) && gateRunId !== conversation.activeRunId - return reply.status(StatusCodes.OK).send(staleGate ? null : gate) - }) - - app.get('/conversations/:id/connections', GetPickerConnectionsRoute, async (request, reply) => { - const conversationId = request.params.id - const platformId = request.principal.platform.id - const userId = request.principal.id - await agentService(request.log).getConversationOrThrow({ id: conversationId, platformId, userId }) - const pieceName = request.query.pieceName - const cached = await agentApprovalGate.getAvailableConnections({ conversationId, pieceName }) - if (cached.length > 0) { - return reply.status(StatusCodes.OK).send(cached) - } - const projects = await agentHelpers.getUserProjects({ platformId, userId, log: request.log }) - const result = await findConnectionsForPiece({ pieceName, projects, platformId, log: request.log }) - if ('pickConnection' in result) { - await agentApprovalGate.storeAvailableConnections({ conversationId, pieceName, connections: result.connections }) - return reply.status(StatusCodes.OK).send(result.connections) - } - return reply.status(StatusCodes.OK).send([]) - }) - - app.get('/memory', GetMemoryRoute, async (request) => { - return agentHelpers.getUserMemory({ - platformId: request.principal.platform.id, - userId: request.principal.id, - }) - }) - - app.post('/memory', UpdateMemoryRoute, async (request) => { - return agentHelpers.saveUserMemory({ - platformId: request.principal.platform.id, - userId: request.principal.id, - instructions: request.body.instructions, - memories: request.body.memories, - }) - }) - - app.post('/memory/import', ImportMemoryRoute, async (request) => { - const platformId = request.principal.platform.id - const userId = request.principal.id - const draft = await agentMemoryAi.extract({ platformId, text: request.body.text, log: request.log }) - const current = await agentHelpers.getUserMemory({ platformId, userId }) - return agentHelpers.saveUserMemory({ - platformId, - userId, - memories: [...current.memories, ...draft.memories], - baseMemories: current.memories, - }) - }) - - app.post('/memory/instruct', InstructMemoryRoute, async (request) => { - return agentMemoryAi.applyInstruction({ - platformId: request.principal.platform.id, - userId: request.principal.id, - instruction: request.body.instruction, - log: request.log, - }) - }) - -} - -async function assertChatProviderConfigured({ platformId, log }: { platformId: string, log: FastifyBaseLogger }): Promise { - const provider = await aiProviderService(log).getChatProviderName({ platformId }) - if (isNil(provider)) { - throw new ActivepiecesError({ - code: ErrorCode.ENTITY_NOT_FOUND, - params: { entityId: platformId, entityType: 'ChatAiProvider' }, - }) - } } -const CHAT_MESSAGES_PER_WINDOW = 40 -const CHAT_MESSAGE_RATE_WINDOW_SECONDS = 10 * 60 - -// Per-user flood guard: nothing else bounds how fast a user fires messages, and each one enqueues a -// worker job and spends credits. Complements the credit balance, which bounds spend, not rate. -async function assertAgentMessageRateLimitNotExceeded({ platformId, userId, log }: { platformId: string, userId: string, log: FastifyBaseLogger }): Promise { - const { allowed, count } = await agentHelpers.incrementAndCheckLimit({ - key: `chat-message-rate:${platformId}:${userId}`, - limit: CHAT_MESSAGES_PER_WINDOW, - ttlSeconds: CHAT_MESSAGE_RATE_WINDOW_SECONDS, - }) - if (!allowed) { - log.warn({ user: { id: userId }, count }, '[agentController] Chat message rate limit exceeded') - throw new ActivepiecesError({ - code: ErrorCode.CHAT_MESSAGE_LIMIT_EXCEEDED, - params: { limit: CHAT_MESSAGES_PER_WINDOW, windowSeconds: CHAT_MESSAGE_RATE_WINDOW_SECONDS }, - }) - } +async function resolveUserId(request: FastifyRequest): Promise { + const userId = await securityHelper.getUserIdFromRequest(request) + assertNotNullOrUndefined(userId, 'userId') + return userId } -const CreateConversationRoute = { +const CreateAgentRoute = { config: { - security: securityAccess.publicPlatform(CHAT_PRINCIPALS), + security: securityAccess.project( + [PrincipalType.USER, PrincipalType.SERVICE], + Permission.WRITE_AGENT, + { type: ProjectResourceType.BODY }, + ), }, schema: { - tags: ['agent'], + tags: ['agents'], security: [SERVICE_KEY_SECURITY_OPENAPI], - body: CreateAgentConversationRequest, + description: 'Create an agent in a project', + body: CreateAgentRequest, + response: { + [StatusCodes.CREATED]: Agent, + }, }, } -const ListConversationsRoute = { +const ListAgentsRoute = { config: { - security: securityAccess.publicPlatform(CHAT_PRINCIPALS), + security: securityAccess.publicPlatform([PrincipalType.USER, PrincipalType.SERVICE]), }, schema: { - tags: ['agent'], + tags: ['agents'], security: [SERVICE_KEY_SECURITY_OPENAPI], - querystring: z.object({ - cursor: z.string().optional(), - limit: z.coerce.number().int().min(1).max(100).default(20).optional(), - }), + description: 'List agents across every project the caller can read', + querystring: ListAgentsRequest, + response: { + [StatusCodes.OK]: SeekPage(Agent), + }, }, } -const CONVERSATION_PARAMS = z.object({ id: z.string() }) - -const GetConversationRoute = { +const GetAgentRoute = { config: { - security: securityAccess.publicPlatform(CHAT_PRINCIPALS), + security: securityAccess.project( + [PrincipalType.USER, PrincipalType.SERVICE], + Permission.READ_AGENT, + { type: ProjectResourceType.TABLE, tableName: AgentEntity }, + ), }, schema: { - tags: ['agent'], + tags: ['agents'], security: [SERVICE_KEY_SECURITY_OPENAPI], - params: CONVERSATION_PARAMS, + description: 'Get an agent', + params: z.object({ id: ApId }), + response: { + [StatusCodes.OK]: Agent, + }, }, } -const UpdateConversationRoute = { +const UpdateAgentRoute = { config: { - security: securityAccess.publicPlatform(CHAT_PRINCIPALS), + security: securityAccess.project( + [PrincipalType.USER, PrincipalType.SERVICE], + Permission.WRITE_AGENT, + { type: ProjectResourceType.TABLE, tableName: AgentEntity }, + ), }, schema: { - tags: ['agent'], + tags: ['agents'], security: [SERVICE_KEY_SECURITY_OPENAPI], - params: CONVERSATION_PARAMS, - body: UpdateAgentConversationRequest, + description: 'Update an agent', + params: z.object({ id: ApId }), + body: UpdateAgentRequest, + response: { + [StatusCodes.OK]: Agent, + }, }, } -const DeleteConversationRoute = { +const DeleteAgentRoute = { config: { - security: securityAccess.publicPlatform(CHAT_PRINCIPALS), + security: securityAccess.project( + [PrincipalType.USER, PrincipalType.SERVICE], + Permission.WRITE_AGENT, + { type: ProjectResourceType.TABLE, tableName: AgentEntity }, + ), }, schema: { - tags: ['agent'], + tags: ['agents'], security: [SERVICE_KEY_SECURITY_OPENAPI], - params: CONVERSATION_PARAMS, + description: 'Delete an agent, unless a published flow uses it', + params: z.object({ id: ApId }), + response: { + [StatusCodes.NO_CONTENT]: z.never(), + }, }, } - -const GetMessagesRoute = { - config: { - security: securityAccess.publicPlatform(CHAT_PRINCIPALS), - }, - schema: { - tags: ['agent'], - security: [SERVICE_KEY_SECURITY_OPENAPI], - params: CONVERSATION_PARAMS, - }, -} - -const SetMessageFeedbackRoute = { - config: { - security: securityAccess.publicPlatform(CHAT_PRINCIPALS), - }, - schema: { - tags: ['agent'], - security: [SERVICE_KEY_SECURITY_OPENAPI], - params: z.object({ id: z.string(), messageIndex: z.coerce.number().int().min(0) }), - body: SetAgentMessageFeedbackRequest, - }, -} - -const SendMessageRoute = { - config: { - security: securityAccess.publicPlatform(CHAT_PRINCIPALS), - }, - schema: { - tags: ['agent'], - security: [SERVICE_KEY_SECURITY_OPENAPI], - params: CONVERSATION_PARAMS, - body: SendAgentMessageRequest, - }, -} - -const FunnelLandingRoute = { - config: { - security: securityAccess.publicPlatform(CHAT_PRINCIPALS), - }, - schema: { - tags: ['agent'], - security: [SERVICE_KEY_SECURITY_OPENAPI], - }, -} - -const ToolApprovalRoute = { - config: { - security: securityAccess.publicPlatform(CHAT_PRINCIPALS), - }, - schema: { - tags: ['agent'], - security: [SERVICE_KEY_SECURITY_OPENAPI], - params: z.object({ gateId: z.string() }), - body: z.object({ approved: z.boolean(), payload: z.record(z.string(), z.unknown()).optional() }), - }, -} - -const GetPendingGateRoute = { - config: { - security: securityAccess.publicPlatform(CHAT_PRINCIPALS), - }, - schema: { - tags: ['agent'], - security: [SERVICE_KEY_SECURITY_OPENAPI], - params: CONVERSATION_PARAMS, - }, -} - -const GetPickerConnectionsRoute = { - config: { - security: securityAccess.publicPlatform(CHAT_PRINCIPALS), - }, - schema: { - tags: ['agent'], - security: [SERVICE_KEY_SECURITY_OPENAPI], - params: CONVERSATION_PARAMS, - querystring: z.object({ pieceName: z.string() }), - }, -} - -const GetMemoryRoute = { - config: { - security: securityAccess.publicPlatform(CHAT_PRINCIPALS), - }, - schema: { - tags: ['agent'], - security: [SERVICE_KEY_SECURITY_OPENAPI], - }, -} - -const UpdateMemoryRoute = { - config: { - security: securityAccess.publicPlatform(CHAT_PRINCIPALS), - }, - schema: { - tags: ['agent'], - security: [SERVICE_KEY_SECURITY_OPENAPI], - body: UpdateAgentMemoryRequest, - }, -} - -const ImportMemoryRoute = { - config: { - security: securityAccess.publicPlatform(CHAT_PRINCIPALS), - }, - schema: { - tags: ['agent'], - security: [SERVICE_KEY_SECURITY_OPENAPI], - body: ImportAgentMemoryRequest, - }, -} - -const InstructMemoryRoute = { - config: { - security: securityAccess.publicPlatform(CHAT_PRINCIPALS), - }, - schema: { - tags: ['agent'], - security: [SERVICE_KEY_SECURITY_OPENAPI], - body: InstructAgentMemoryRequest, - }, -} - -const CancelConversationRoute = { - config: { - security: securityAccess.publicPlatform(CHAT_PRINCIPALS), - }, - schema: { - tags: ['agent'], - security: [SERVICE_KEY_SECURITY_OPENAPI], - params: CONVERSATION_PARAMS, - }, -} - 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 new file mode 100644 index 000000000000..d6e1d8c03b8a --- /dev/null +++ b/packages/server/api/src/app/ee/agent/agent-conversation-controller.ts @@ -0,0 +1,506 @@ +import { ActivepiecesError, apId, ErrorCode, isNil, spreadIfDefined, tryCatch } from '@activepieces/core-utils' +import { AgentConversationStatus, CreateAgentConversationRequest, ImportAgentMemoryRequest, InstructAgentMemoryRequest, LATEST_JOB_DATA_SCHEMA_VERSION, PrincipalType, SendAgentMessageRequest, SERVICE_KEY_SECURITY_OPENAPI, SetAgentMessageFeedbackRequest, UpdateAgentConversationRequest, UpdateAgentMemoryRequest, WorkerJobType } from '@activepieces/shared' +import { FastifyBaseLogger } from 'fastify' +import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' +import { StatusCodes } from 'http-status-codes' +import { z } from 'zod' +import { aiProviderService } from '../../ai/ai-provider-service' +import { securityAccess } from '../../core/security/authorization/fastify-security' +import { assertCreditsAndAppSumoNotExceeded } from '../../platform/billing-provider' +import { jobQueue, JobType } from '../../workers/job-queue/job-queue' +import { agentApprovalGate } from './agent-approval-gate' +import { agentConversationService } from './agent-conversation-service' +import { agentHelpers } from './agent-helpers' +import { agentMemoryAi } from './agent-memory-ai' +import { chatAnalyticsTelemetry } from './chat-analytics-sync' +import { chatPlanGrant } from './chat-plan-grant' +import { chatRolloutService } from './chat-rollout-service' +import { findConnectionsForPiece } from './tools/agent-tools' + +const CHAT_PRINCIPALS = [PrincipalType.USER] as const + +export const agentConversationController: FastifyPluginAsyncZod = async (app) => { + + app.post('/conversations', CreateConversationRoute, async (request, reply) => { + const conversation = await agentConversationService(request.log).createConversation({ + platformId: request.principal.platform.id, + userId: request.principal.id, + request: request.body, + }) + return reply.status(StatusCodes.CREATED).send(conversation) + }) + + app.get('/conversations', ListConversationsRoute, async (request) => { + return agentConversationService(request.log).listConversations({ + platformId: request.principal.platform.id, + userId: request.principal.id, + cursor: request.query.cursor, + limit: request.query.limit ?? 20, + }) + }) + + app.get('/conversations/:id', GetConversationRoute, async (request) => { + return agentConversationService(request.log).getConversationOrThrow({ + id: request.params.id, + platformId: request.principal.platform.id, + userId: request.principal.id, + }) + }) + + app.post('/conversations/:id', UpdateConversationRoute, async (request) => { + return agentConversationService(request.log).updateConversation({ + id: request.params.id, + platformId: request.principal.platform.id, + userId: request.principal.id, + request: request.body, + }) + }) + + app.delete('/conversations/:id', DeleteConversationRoute, async (request, reply) => { + await agentConversationService(request.log).deleteConversation({ + id: request.params.id, + platformId: request.principal.platform.id, + userId: request.principal.id, + }) + return reply.status(StatusCodes.NO_CONTENT).send() + }) + + app.get('/conversations/:id/messages', GetMessagesRoute, async (request) => { + return agentConversationService(request.log).getMessages({ + id: request.params.id, + platformId: request.principal.platform.id, + userId: request.principal.id, + }) + }) + + app.post('/conversations/:id/messages/:messageIndex/feedback', SetMessageFeedbackRoute, async (request, reply) => { + await agentConversationService(request.log).setMessageFeedback({ + id: request.params.id, + platformId: request.principal.platform.id, + userId: request.principal.id, + messageIndex: request.params.messageIndex, + request: request.body, + }) + return reply.status(StatusCodes.OK).send({ success: true }) + }) + + app.post('/funnel/landing', FunnelLandingRoute, async (request, reply) => { + // Cloud rollout: record that this user opened the chat page, then refresh the console + // funnel snapshot. Awaited recordLanding so the pushed landed count includes this landing. + await chatRolloutService.recordLanding({ + userId: request.principal.id, + platformId: request.principal.platform.id, + }) + chatAnalyticsTelemetry(request.log).sendRolloutFunnelUpdate() + return reply.status(StatusCodes.NO_CONTENT).send() + }) + + app.post('/conversations/:id/messages', SendMessageRoute, async (request, reply) => { + const { content, runId: clientRunId, files } = request.body + const conversationId = request.params.id + const userId = request.principal.id + const platformId = request.principal.platform.id + const log = request.log.child({ conversation: { id: conversationId }, user: { id: userId }, platform: { id: platformId } }) + + log.info({ filesCount: files?.length ?? 0, contentLength: content.length }, '[agentConversationController] Chat message received') + + const conversation = await agentConversationService(log).getConversationOrThrow({ + id: conversationId, + platformId, + userId, + }) + + await assertAgentMessageRateLimitNotExceeded({ platformId, userId, log }) + + // Cloud rollout: count this user as a distinct chatter (no-op off cloud, deduped). + const { needsCreditDecision } = await chatRolloutService.recordChatted({ userId, platformId }) + // Refresh the console rollout funnel snapshot (chatted count just changed). + chatAnalyticsTelemetry(log).sendRolloutFunnelUpdate() + if (needsCreditDecision) { + const { error } = await tryCatch(() => chatPlanGrant.grant({ userId, platformId, log })) + if (!isNil(error)) { + log.warn({ error, platform: { id: platformId }, user: { id: userId } }, '[agentConversationController] Chat plan grant failed; continuing to the credit gate') + } + } + + const runId = typeof clientRunId === 'string' ? clientRunId : apId() + const runLog = log.child({ run: { id: runId } }) + + // Claim ownership atomically in the DB — the single source of truth that + // saveAgentMessages/updateAgentProgress/heartbeat fence against. A late write from the + // preempted run is rejected as soon as this UPDATE commits (its runId no longer matches), + // with no Redis/DB split to race through. The prior owner is read from the same row. + const preemptedRunId = conversation.status === AgentConversationStatus.STREAMING + ? conversation.activeRunId + : null + await agentHelpers.conversationRepo().update(conversationId, { activeRunId: runId }) + + if (conversation.status === AgentConversationStatus.STREAMING) { + log.info({ ...spreadIfDefined('preemptedRunId', preemptedRunId ?? undefined) }, '[agentConversationController] Cancelling in-flight run before new message') + const cancelPromises = [ + agentApprovalGate.requestCancel({ conversationId }), + ] + if (preemptedRunId) { + cancelPromises.push(agentApprovalGate.requestCancel({ conversationId, runId: preemptedRunId })) + } + await Promise.all(cancelPromises) + await agentHelpers.conversationRepo().update(conversationId, { + status: AgentConversationStatus.IDLE, + }) + await agentApprovalGate.clearPendingGate({ conversationId }) + } + + await assertChatProviderConfigured({ platformId, log }) + await assertCreditsAndAppSumoNotExceeded({ platformId, log }) + + await jobQueue(runLog).add({ + id: apId(), + type: JobType.ONE_TIME, + data: { + schemaVersion: LATEST_JOB_DATA_SCHEMA_VERSION, + jobType: WorkerJobType.EXECUTE_AGENT_RUN, + conversationId, + runId, + projectId: conversation.projectId ?? null, + platformId, + userId, + userMessage: content, + modelName: conversation.modelName ?? null, + files, + }, + }) + runLog.info({ job: { type: WorkerJobType.EXECUTE_AGENT_RUN } }, '[agentConversationController] Enqueued chat agent job') + + return reply.status(StatusCodes.OK).send({ conversationId, runId }) + }) + + app.post('/tool-approvals/:gateId', ToolApprovalRoute, async (request, reply) => { + request.log.info({ gate: { id: request.params.gateId }, approved: request.body.approved }, '[agentConversationController] Tool approval received') + await agentApprovalGate.resolveGate({ + gateId: request.params.gateId, + approved: request.body.approved, + payload: request.body.payload, + log: request.log, + }) + return reply.status(StatusCodes.OK).send({ success: true }) + }) + + app.post('/conversations/:id/cancel', CancelConversationRoute, async (request, reply) => { + const conversationId = request.params.id + const platformId = request.principal.platform.id + const userId = request.principal.id + const log = request.log.child({ conversation: { id: conversationId }, user: { id: userId }, platform: { id: platformId } }) + const conversation = await agentConversationService(log).getConversationOrThrow({ id: conversationId, platformId, userId }) + const activeRunId = conversation.activeRunId + log.info({ ...spreadIfDefined('activeRunId', activeRunId ?? undefined) }, '[agentConversationController] Cancel requested') + const cancelPromises = [ + agentApprovalGate.requestCancel({ conversationId }), + ] + if (activeRunId) { + cancelPromises.push(agentApprovalGate.requestCancel({ conversationId, runId: activeRunId })) + } + await Promise.all(cancelPromises) + await agentHelpers.conversationRepo().update(conversationId, { + status: AgentConversationStatus.IDLE, + }) + await agentApprovalGate.clearPendingGate({ conversationId }) + return reply.status(StatusCodes.OK).send({ success: true }) + }) + + app.get('/conversations/:id/pending-gate', GetPendingGateRoute, async (request, reply) => { + const conversationId = request.params.id + const platformId = request.principal.platform.id + const userId = request.principal.id + const conversation = await agentConversationService(request.log).getConversationOrThrow({ id: conversationId, platformId, userId }) + const gate = await agentApprovalGate.getPendingGate({ conversationId }) + // A preempted run can leave (or race in) a pending gate keyed by conversation; only surface + // the gate when it belongs to the run that currently owns the conversation. + const gateRunId = gate?.runId + const staleGate = !isNil(gateRunId) && !isNil(conversation.activeRunId) && gateRunId !== conversation.activeRunId + return reply.status(StatusCodes.OK).send(staleGate ? null : gate) + }) + + app.get('/conversations/:id/connections', GetPickerConnectionsRoute, async (request, reply) => { + const conversationId = request.params.id + const platformId = request.principal.platform.id + const userId = request.principal.id + await agentConversationService(request.log).getConversationOrThrow({ id: conversationId, platformId, userId }) + const pieceName = request.query.pieceName + const cached = await agentApprovalGate.getAvailableConnections({ conversationId, pieceName }) + if (cached.length > 0) { + return reply.status(StatusCodes.OK).send(cached) + } + const projects = await agentHelpers.getUserProjects({ platformId, userId, log: request.log }) + const result = await findConnectionsForPiece({ pieceName, projects, platformId, log: request.log }) + if ('pickConnection' in result) { + await agentApprovalGate.storeAvailableConnections({ conversationId, pieceName, connections: result.connections }) + return reply.status(StatusCodes.OK).send(result.connections) + } + return reply.status(StatusCodes.OK).send([]) + }) + + app.get('/memory', GetMemoryRoute, async (request) => { + return agentHelpers.getUserMemory({ + platformId: request.principal.platform.id, + userId: request.principal.id, + }) + }) + + app.post('/memory', UpdateMemoryRoute, async (request) => { + return agentHelpers.saveUserMemory({ + platformId: request.principal.platform.id, + userId: request.principal.id, + instructions: request.body.instructions, + memories: request.body.memories, + }) + }) + + app.post('/memory/import', ImportMemoryRoute, async (request) => { + const platformId = request.principal.platform.id + const userId = request.principal.id + const draft = await agentMemoryAi.extract({ platformId, text: request.body.text, log: request.log }) + const current = await agentHelpers.getUserMemory({ platformId, userId }) + return agentHelpers.saveUserMemory({ + platformId, + userId, + memories: [...current.memories, ...draft.memories], + baseMemories: current.memories, + }) + }) + + app.post('/memory/instruct', InstructMemoryRoute, async (request) => { + return agentMemoryAi.applyInstruction({ + platformId: request.principal.platform.id, + userId: request.principal.id, + instruction: request.body.instruction, + log: request.log, + }) + }) + +} + +async function assertChatProviderConfigured({ platformId, log }: { platformId: string, log: FastifyBaseLogger }): Promise { + const provider = await aiProviderService(log).getChatProviderName({ platformId }) + if (isNil(provider)) { + throw new ActivepiecesError({ + code: ErrorCode.ENTITY_NOT_FOUND, + params: { entityId: platformId, entityType: 'ChatAiProvider' }, + }) + } +} + +const CHAT_MESSAGES_PER_WINDOW = 40 +const CHAT_MESSAGE_RATE_WINDOW_SECONDS = 10 * 60 + +// Per-user flood guard: nothing else bounds how fast a user fires messages, and each one enqueues a +// worker job and spends credits. Complements the credit balance, which bounds spend, not rate. +async function assertAgentMessageRateLimitNotExceeded({ platformId, userId, log }: { platformId: string, userId: string, log: FastifyBaseLogger }): Promise { + const { allowed, count } = await agentHelpers.incrementAndCheckLimit({ + key: `chat-message-rate:${platformId}:${userId}`, + limit: CHAT_MESSAGES_PER_WINDOW, + ttlSeconds: CHAT_MESSAGE_RATE_WINDOW_SECONDS, + }) + if (!allowed) { + log.warn({ user: { id: userId }, count }, '[agentConversationController] Chat message rate limit exceeded') + throw new ActivepiecesError({ + code: ErrorCode.CHAT_MESSAGE_LIMIT_EXCEEDED, + params: { limit: CHAT_MESSAGES_PER_WINDOW, windowSeconds: CHAT_MESSAGE_RATE_WINDOW_SECONDS }, + }) + } +} + +const CreateConversationRoute = { + config: { + security: securityAccess.publicPlatform(CHAT_PRINCIPALS), + }, + schema: { + tags: ['agent'], + security: [SERVICE_KEY_SECURITY_OPENAPI], + body: CreateAgentConversationRequest, + }, +} + +const ListConversationsRoute = { + config: { + security: securityAccess.publicPlatform(CHAT_PRINCIPALS), + }, + schema: { + tags: ['agent'], + security: [SERVICE_KEY_SECURITY_OPENAPI], + querystring: z.object({ + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).default(20).optional(), + }), + }, +} + +const CONVERSATION_PARAMS = z.object({ id: z.string() }) + +const GetConversationRoute = { + config: { + security: securityAccess.publicPlatform(CHAT_PRINCIPALS), + }, + schema: { + tags: ['agent'], + security: [SERVICE_KEY_SECURITY_OPENAPI], + params: CONVERSATION_PARAMS, + }, +} + +const UpdateConversationRoute = { + config: { + security: securityAccess.publicPlatform(CHAT_PRINCIPALS), + }, + schema: { + tags: ['agent'], + security: [SERVICE_KEY_SECURITY_OPENAPI], + params: CONVERSATION_PARAMS, + body: UpdateAgentConversationRequest, + }, +} + +const DeleteConversationRoute = { + config: { + security: securityAccess.publicPlatform(CHAT_PRINCIPALS), + }, + schema: { + tags: ['agent'], + security: [SERVICE_KEY_SECURITY_OPENAPI], + params: CONVERSATION_PARAMS, + }, +} + +const GetMessagesRoute = { + config: { + security: securityAccess.publicPlatform(CHAT_PRINCIPALS), + }, + schema: { + tags: ['agent'], + security: [SERVICE_KEY_SECURITY_OPENAPI], + params: CONVERSATION_PARAMS, + }, +} + +const SetMessageFeedbackRoute = { + config: { + security: securityAccess.publicPlatform(CHAT_PRINCIPALS), + }, + schema: { + tags: ['agent'], + security: [SERVICE_KEY_SECURITY_OPENAPI], + params: z.object({ id: z.string(), messageIndex: z.coerce.number().int().min(0) }), + body: SetAgentMessageFeedbackRequest, + }, +} + +const SendMessageRoute = { + config: { + security: securityAccess.publicPlatform(CHAT_PRINCIPALS), + }, + schema: { + tags: ['agent'], + security: [SERVICE_KEY_SECURITY_OPENAPI], + params: CONVERSATION_PARAMS, + body: SendAgentMessageRequest, + }, +} + +const FunnelLandingRoute = { + config: { + security: securityAccess.publicPlatform(CHAT_PRINCIPALS), + }, + schema: { + tags: ['agent'], + security: [SERVICE_KEY_SECURITY_OPENAPI], + }, +} + +const ToolApprovalRoute = { + config: { + security: securityAccess.publicPlatform(CHAT_PRINCIPALS), + }, + schema: { + tags: ['agent'], + security: [SERVICE_KEY_SECURITY_OPENAPI], + params: z.object({ gateId: z.string() }), + body: z.object({ approved: z.boolean(), payload: z.record(z.string(), z.unknown()).optional() }), + }, +} + +const GetPendingGateRoute = { + config: { + security: securityAccess.publicPlatform(CHAT_PRINCIPALS), + }, + schema: { + tags: ['agent'], + security: [SERVICE_KEY_SECURITY_OPENAPI], + params: CONVERSATION_PARAMS, + }, +} + +const GetPickerConnectionsRoute = { + config: { + security: securityAccess.publicPlatform(CHAT_PRINCIPALS), + }, + schema: { + tags: ['agent'], + security: [SERVICE_KEY_SECURITY_OPENAPI], + params: CONVERSATION_PARAMS, + querystring: z.object({ pieceName: z.string() }), + }, +} + +const GetMemoryRoute = { + config: { + security: securityAccess.publicPlatform(CHAT_PRINCIPALS), + }, + schema: { + tags: ['agent'], + security: [SERVICE_KEY_SECURITY_OPENAPI], + }, +} + +const UpdateMemoryRoute = { + config: { + security: securityAccess.publicPlatform(CHAT_PRINCIPALS), + }, + schema: { + tags: ['agent'], + security: [SERVICE_KEY_SECURITY_OPENAPI], + body: UpdateAgentMemoryRequest, + }, +} + +const ImportMemoryRoute = { + config: { + security: securityAccess.publicPlatform(CHAT_PRINCIPALS), + }, + schema: { + tags: ['agent'], + security: [SERVICE_KEY_SECURITY_OPENAPI], + body: ImportAgentMemoryRequest, + }, +} + +const InstructMemoryRoute = { + config: { + security: securityAccess.publicPlatform(CHAT_PRINCIPALS), + }, + schema: { + tags: ['agent'], + security: [SERVICE_KEY_SECURITY_OPENAPI], + body: InstructAgentMemoryRequest, + }, +} + +const CancelConversationRoute = { + config: { + security: securityAccess.publicPlatform(CHAT_PRINCIPALS), + }, + schema: { + tags: ['agent'], + security: [SERVICE_KEY_SECURITY_OPENAPI], + params: CONVERSATION_PARAMS, + }, +} + 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 new file mode 100644 index 000000000000..57c26ff13cc2 --- /dev/null +++ b/packages/server/api/src/app/ee/agent/agent-conversation-service.ts @@ -0,0 +1,169 @@ +import { ActivepiecesError, apId, ErrorCode, isNil, sanitizeObjectForPostgresql, SeekPage, spreadIfDefined } from '@activepieces/core-utils' +import { AgentConversation, AgentConversationStatus, AgentHistoryMessage, AgentRunSource, CreateAgentConversationRequest, PersistedAgentMessage, PersistedAgentRole, SetAgentMessageFeedbackRequest, UpdateAgentConversationRequest } from '@activepieces/shared' +import { ModelMessage } from 'ai' +import { FastifyBaseLogger } from 'fastify' +import { buildPaginator } from '../../helper/pagination/build-paginator' +import { paginationHelper } from '../../helper/pagination/pagination-utils' +import { Order } from '../../helper/pagination/paginator' +import { agentApprovalGate } from './agent-approval-gate' +import { AgentConversationEntity } from './agent-conversation-entity' +import { agentHelpers, EVAL_CONVERSATION_ID_PREFIX, isEvalConversationId } from './agent-helpers' +import { agentHistory } from './history/agent-history' + +export const agentConversationService = (log: FastifyBaseLogger) => ({ + async createConversation({ platformId, userId, request, id }: CreateConversationParams): Promise { + const conversation = await agentHelpers.conversationRepo().save({ + id: id ?? apId(), + platformId, + projectId: null, + userId, + source: AgentRunSource.CHAT, + title: request.title ?? null, + modelName: request.modelName ?? null, + messages: [], + }) + log.info({ conversation: { id: conversation.id }, platform: { id: platformId }, user: { id: userId } }, '[agentConversationService] Conversation created') + return conversation + }, + + async listConversations({ platformId, userId, cursor, limit }: ListConversationsParams): Promise> { + const decodedCursor = paginationHelper.decodeCursor(cursor) + const paginator = buildPaginator({ + entity: AgentConversationEntity, + query: { + limit, + orderBy: [ + { field: 'created', order: Order.DESC }, + { field: 'id', order: Order.DESC }, + ], + afterCursor: decodedCursor.nextCursor, + beforeCursor: decodedCursor.previousCursor, + }, + }) + + const queryBuilder = agentHelpers.conversationRepo() + .createQueryBuilder('agent_conversation') + .select([ + 'agent_conversation.id', + 'agent_conversation.created', + 'agent_conversation.updated', + 'agent_conversation.platformId', + 'agent_conversation.projectId', + 'agent_conversation.userId', + 'agent_conversation.title', + 'agent_conversation.modelName', + 'agent_conversation.status', + ]) + .where({ platformId, userId }) + // Eval conversations are owned by the platform owner; keep them out of the regular list. + .andWhere('agent_conversation.id NOT LIKE :evalPrefix', { evalPrefix: `${EVAL_CONVERSATION_ID_PREFIX}%` }) + .andWhere('agent_conversation.source = :chatSource', { chatSource: AgentRunSource.CHAT }) + + const { data, cursor: paginationCursor } = await paginator.paginate(queryBuilder) + return paginationHelper.createPage(data, paginationCursor) + }, + + async getConversationOrThrow({ id, platformId, userId }: ConversationIdentifier): Promise { + // Eval conversations must never be opened or messaged through the regular (non-dry-run) chat + // path — that would run real tools against a conversation meant to be side-effect-free. + if (isEvalConversationId(id)) { + throw new ActivepiecesError({ code: ErrorCode.ENTITY_NOT_FOUND, params: { entityId: id, entityType: 'AgentConversation' } }) + } + const conversation = await agentHelpers.getConversationOrThrow({ id, platformId, userId, log }) + if (conversation.source !== AgentRunSource.CHAT) { + throw new ActivepiecesError({ code: ErrorCode.ENTITY_NOT_FOUND, params: { entityId: id, entityType: 'AgentConversation' } }) + } + return conversation + }, + + async updateConversation({ id, platformId, userId, request }: UpdateConversationParams): Promise { + const conversation = await this.getConversationOrThrow({ id, platformId, userId }) + const updates = { + ...spreadIfDefined('title', request.title), + ...spreadIfDefined('modelName', request.modelName), + } + + if (Object.keys(updates).length > 0) { + await agentHelpers.conversationRepo().update(conversation.id, updates) + } + return { ...conversation, ...updates } + }, + + async deleteConversation({ id, platformId, userId }: ConversationIdentifier): Promise { + const conversation = await this.getConversationOrThrow({ id, platformId, userId }) + if (conversation.status === AgentConversationStatus.STREAMING) { + await agentApprovalGate.requestCancel({ conversationId: id }) + await agentHelpers.conversationRepo().update(id, { + status: AgentConversationStatus.IDLE, + }) + } + await agentHelpers.conversationRepo().delete(conversation.id) + log.info({ conversation: { id }, platform: { id: platformId }, user: { id: userId } }, '[agentConversationService] Conversation deleted') + }, + + async getMessages({ id, platformId, userId }: ConversationIdentifier): Promise<{ data: PersistedAgentMessage[] | AgentHistoryMessage[] }> { + const conversation = await this.getConversationOrThrow({ id, platformId, userId }) + if (conversation.uiMessages) { + return { data: conversation.uiMessages } + } + const messages = agentHistory.reconstruct(conversation.messages as ModelMessage[]) + return { data: messages } + }, + + async setMessageFeedback({ id, platformId, userId, messageIndex, request }: SetMessageFeedbackParams): Promise { + const conversation = await this.getConversationOrThrow({ id, platformId, userId }) + const target = conversation.uiMessages?.[messageIndex] + if (isNil(target) || target.role !== PersistedAgentRole.ASSISTANT) { + throw new ActivepiecesError({ + code: ErrorCode.ENTITY_NOT_FOUND, + params: { entityType: 'AgentMessage', entityId: `${id}#${messageIndex}` }, + }) + } + // Patch only this message's feedback via atomic jsonb_set, never a full-array rewrite — a + // concurrent worker append during a STREAMING turn must not be clobbered by a stale snapshot. + const repo = agentHelpers.conversationRepo() + const table = repo.metadata.tableName + const path = `{${messageIndex},feedback}` + if (isNil(request.rating)) { + await repo.query(`UPDATE "${table}" SET "uiMessages" = "uiMessages" #- $1::text[] WHERE id = $2`, [path, conversation.id]) + } + else { + const reasons = request.reasons?.length ? request.reasons : undefined + const comment = request.comment?.trim() || undefined + const feedback = { rating: request.rating, ...spreadIfDefined('reasons', reasons), ...spreadIfDefined('comment', comment) } + const value = JSON.stringify(sanitizeObjectForPostgresql(feedback)) + await repo.query(`UPDATE "${table}" SET "uiMessages" = jsonb_set("uiMessages", $1::text[], $2::jsonb, true) WHERE id = $3`, [path, value, conversation.id]) + } + log.info({ conversation: { id }, messageIndex, rating: request.rating }, '[agentConversationService] Message feedback recorded') + }, + +}) + +type CreateConversationParams = { + platformId: string + userId: string + request: CreateAgentConversationRequest + id?: string +} + +type ListConversationsParams = { + platformId: string + userId: string + cursor?: string + limit: number +} + +type ConversationIdentifier = { + id: string + platformId: string + userId: string +} + +type UpdateConversationParams = ConversationIdentifier & { + request: UpdateAgentConversationRequest +} + +type SetMessageFeedbackParams = ConversationIdentifier & { + messageIndex: number + request: SetAgentMessageFeedbackRequest +} diff --git a/packages/server/api/src/app/ee/agent/agent-entity.ts b/packages/server/api/src/app/ee/agent/agent-entity.ts new file mode 100644 index 000000000000..258fbd8035d3 --- /dev/null +++ b/packages/server/api/src/app/ee/agent/agent-entity.ts @@ -0,0 +1,92 @@ +import { Agent, Project, User } from '@activepieces/shared' +import { EntitySchema } from 'typeorm' +import { ApIdSchema, BaseColumnSchemaPart } from '../../database/database-common' + +export type AgentWithRelations = Agent & { + owner: User + project: Project +} + +export const AgentEntity = new EntitySchema({ + name: 'agent', + columns: { + ...BaseColumnSchemaPart, + projectId: { + ...ApIdSchema, + nullable: false, + }, + ownerId: { + ...ApIdSchema, + nullable: false, + }, + externalId: { + type: String, + nullable: false, + }, + displayName: { + type: String, + nullable: false, + }, + description: { + type: String, + nullable: true, + }, + icon: { + type: String, + nullable: false, + }, + color: { + type: String, + nullable: false, + }, + visibility: { + type: String, + nullable: false, + }, + sharedWithUserIds: { + type: String, + array: true, + nullable: false, + default: '{}', + }, + draft: { + type: 'jsonb', + nullable: false, + }, + published: { + type: 'jsonb', + nullable: true, + }, + }, + indices: [ + { + name: 'idx_agent_project_created_id', + columns: ['projectId', 'created', 'id'], + }, + { + name: 'idx_agent_project_external_id', + columns: ['projectId', 'externalId'], + unique: true, + }, + ], + relations: { + owner: { + type: 'many-to-one', + target: 'user', + joinColumn: { + name: 'ownerId', + foreignKeyConstraintName: 'fk_agent_owner_id', + }, + }, + project: { + type: 'many-to-one', + target: 'project', + cascade: true, + onDelete: 'CASCADE', + joinColumn: { + name: 'projectId', + foreignKeyConstraintName: 'fk_agent_project_id', + }, + }, + }, +}) diff --git a/packages/server/api/src/app/ee/agent/agent-eval-controller.ts b/packages/server/api/src/app/ee/agent/agent-eval-controller.ts index 7c89d71bdb45..61a1d684ef48 100644 --- a/packages/server/api/src/app/ee/agent/agent-eval-controller.ts +++ b/packages/server/api/src/app/ee/agent/agent-eval-controller.ts @@ -1,5 +1,5 @@ import { apId, isNil } from '@activepieces/core-utils' -import { AgentConversationStatus, AgentPromptOverride, LATEST_JOB_DATA_SCHEMA_VERSION, PersistedAgentRole, SimulateAgentRequest, WorkerJobType } from '@activepieces/shared' +import { AgentConversationStatus, AgentPromptOverride, LATEST_JOB_DATA_SCHEMA_VERSION, MAX_AGENT_TEXT_LENGTH, PersistedAgentRole, SimulateAgentRequest, WorkerJobType } from '@activepieces/shared' import { FastifyBaseLogger, FastifyReply, FastifyRequest } from 'fastify' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { StatusCodes } from 'http-status-codes' @@ -9,8 +9,8 @@ import { system } from '../../helper/system/system' import { AppSystemProp } from '../../helper/system/system-props' import { platformService } from '../../platform/platform.service' import { jobQueue, JobType } from '../../workers/job-queue/job-queue' +import { agentConversationService } from './agent-conversation-service' import { agentHelpers, EVAL_CONVERSATION_ID_PREFIX, isEvalConversationId } from './agent-helpers' -import { agentService } from './agent-service' import { agentPrompt } from './prompt/agent-prompt' const API_KEY_HEADER = 'api-key' @@ -59,7 +59,7 @@ const agentEvalController: FastifyPluginAsyncZod = async (app) => { const platform = await platformService(log).getOneOrThrow(platformId) const evalUserId = platform.ownerId - const conversation = await agentService(log).createConversation({ + const conversation = await agentConversationService(log).createConversation({ platformId, userId: evalUserId, request: {}, @@ -142,7 +142,7 @@ const agentEvalController: FastifyPluginAsyncZod = async (app) => { const platform = await platformService(log).getOneOrThrow(platformId) evalPlatformId = platformId evalUserId = platform.ownerId - const conversation = await agentService(log).createConversation({ platformId, userId: evalUserId, request: {}, id: (EVAL_CONVERSATION_ID_PREFIX + apId()).slice(0, 21) }) + const conversation = await agentConversationService(log).createConversation({ platformId, userId: evalUserId, request: {}, id: (EVAL_CONVERSATION_ID_PREFIX + apId()).slice(0, 21) }) convId = conversation.id priorAssistantTurns = 0 } @@ -257,7 +257,7 @@ const SimulateRoute = { const EvalTurnStartRequest = z.object({ conversationId: z.string().optional(), platformId: z.string().optional(), - userMessage: z.string().min(1).max(51200), + userMessage: z.string().min(1).max(MAX_AGENT_TEXT_LENGTH), promptOverride: AgentPromptOverride.optional(), // Opt-in (default off): run the turn with tools actually executing against the platform // owner's real connections, instead of the dry-run playground stub. The failure-mode eval 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 67d7e3977fdb..bc1e8d4aa1d0 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 @@ -54,7 +54,7 @@ async function updateConversationForRun({ conversationId, runId, updates }: { conversationId: string runId?: string updates: Record -}) { +}): Promise { const builder = agentHelpers.conversationRepo() .createQueryBuilder() .update() @@ -63,7 +63,9 @@ async function updateConversationForRun({ conversationId, runId, updates }: { if (!isNil(runId)) { builder.andWhere('("activeRunId" IS NULL OR "activeRunId" = :runId)', { runId }) } - return builder.execute() + const result = await builder.returning('id').execute() + const updatedRows: unknown[] = result.raw ?? [] + return updatedRows.length > 0 } function buildCapabilitiesNote({ currentDate, searchAvailable, fetchAvailable, scrapeAvailable, imageAvailable, emailAvailable, userEmail }: { @@ -208,8 +210,10 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ .update() .set({ status: AgentConversationStatus.STREAMING }) .where('id = :id AND status != :streaming', { id: conversationId, streaming: AgentConversationStatus.STREAMING }) + .returning('id') .execute() - if (lockResult.affected === 0) { + const lockedRows: unknown[] = lockResult.raw ?? [] + if (lockedRows.length === 0) { log.warn({ conversation: { id: conversationId } }, '[agentRpc#getAgentConfig] Concurrent run rejected (conversation already STREAMING)') throw new ActivepiecesError({ code: ErrorCode.VALIDATION, @@ -425,8 +429,7 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ }, '[agentRpc#saveAgentMessages] Refused shrinking save — kept incrementally-persisted history') } - const saveResult = await updateConversationForRun({ conversationId: input.conversationId, runId: input.runId, updates }) - const saveLanded = saveResult.affected !== 0 + const saveLanded = await updateConversationForRun({ conversationId: input.conversationId, runId: input.runId, updates }) if (!saveLanded) { log.warn({ conversation: { id: input.conversationId }, run: { id: input.runId } }, 'saveAgentMessages: no row updated — conversation deleted or superseded by a newer run; skipping analytics and usage tracking') } diff --git a/packages/server/api/src/app/ee/agent/agent-run-controller.ts b/packages/server/api/src/app/ee/agent/agent-run-controller.ts index cdd081974168..72d27f4adb84 100644 --- a/packages/server/api/src/app/ee/agent/agent-run-controller.ts +++ b/packages/server/api/src/app/ee/agent/agent-run-controller.ts @@ -1,5 +1,5 @@ import { ActivepiecesError, apId, ApId, assertNotNullOrUndefined, ErrorCode, isNil, unique } from '@activepieces/core-utils' -import { AgentFlowTool, AgentOutputField, AgentRunSource, AgentTool, AgentToolType, AIProviderName, LATEST_JOB_DATA_SCHEMA_VERSION, PrincipalType, ResolvedAgentFlowTool, TASK_COMPLETION_TOOL_NAME, WorkerJobType } from '@activepieces/shared' +import { AgentFlowTool, AgentOutputField, AgentRunSource, AgentTool, AgentToolType, AIProviderName, LATEST_JOB_DATA_SCHEMA_VERSION, MAX_AGENT_OUTPUT_FIELDS, MAX_AGENT_STEP_BUDGET, MAX_AGENT_TEXT_LENGTH, MAX_AGENT_TOOLS, PrincipalType, ResolvedAgentFlowTool, TASK_COMPLETION_TOOL_NAME, WorkerJobType } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { StatusCodes } from 'http-status-codes' @@ -129,19 +129,15 @@ async function resolveFlowTools({ projectId, flowToolRequests, log }: { } const RUNS_PER_MINUTE = 60 -const MAX_INSTRUCTION_LENGTH = 51_200 -const MAX_TOOLS = 100 const BUILT_IN_TOOL_PREFIX = 'ap_' -const MAX_OUTPUT_FIELDS = 50 -const MAX_STEP_BUDGET = 1_000 const StartAgentRunRequest = z.object({ - instruction: z.string().min(1).max(MAX_INSTRUCTION_LENGTH), + instruction: z.string().min(1).max(MAX_AGENT_TEXT_LENGTH), flowRunId: ApId, waitpointId: ApId, - tools: z.array(AgentTool).max(MAX_TOOLS).optional(), - structuredOutput: z.array(AgentOutputField).max(MAX_OUTPUT_FIELDS).optional(), - maxSteps: z.number().int().positive().max(MAX_STEP_BUDGET).optional(), + tools: z.array(AgentTool).max(MAX_AGENT_TOOLS).optional(), + structuredOutput: z.array(AgentOutputField).max(MAX_AGENT_OUTPUT_FIELDS).optional(), + maxSteps: z.number().int().positive().max(MAX_AGENT_STEP_BUDGET).optional(), modelName: z.string().optional(), provider: z.enum(AIProviderName).optional(), }) 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 81b3b1d7a027..487b60cdc6a8 100644 --- a/packages/server/api/src/app/ee/agent/agent-service.ts +++ b/packages/server/api/src/app/ee/agent/agent-service.ts @@ -1,169 +1,170 @@ -import { ActivepiecesError, apId, ErrorCode, isNil, sanitizeObjectForPostgresql, SeekPage, spreadIfDefined } from '@activepieces/core-utils' -import { AgentConversation, AgentConversationStatus, AgentHistoryMessage, AgentRunSource, CreateAgentConversationRequest, PersistedAgentMessage, PersistedAgentRole, SetAgentMessageFeedbackRequest, UpdateAgentConversationRequest } from '@activepieces/shared' -import { ModelMessage } from 'ai' +import { ActivepiecesError, ApId, apId, Cursor, ErrorCode, isNil, Permission, PlatformId, ProjectId, SeekPage, UserId } from '@activepieces/core-utils' +import { Agent, AgentVisibility, CreateAgentRequest, UpdateAgentRequest } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' +import { Brackets, In, SelectQueryBuilder } from 'typeorm' +import { repoFactory } from '../../core/db/repo-factory' import { buildPaginator } from '../../helper/pagination/build-paginator' import { paginationHelper } from '../../helper/pagination/pagination-utils' -import { Order } from '../../helper/pagination/paginator' -import { agentApprovalGate } from './agent-approval-gate' -import { AgentConversationEntity } from './agent-conversation-entity' -import { agentHelpers, EVAL_CONVERSATION_ID_PREFIX, isEvalConversationId } from './agent-helpers' -import { agentHistory } from './history/agent-history' +import { userService } from '../../user/user-service' +import { projectMemberService } from '../projects/project-members/project-member.service' +import { AgentEntity, AgentWithRelations } from './agent-entity' +import { agentHelpers } from './agent-helpers' + +const DEFAULT_PAGE_SIZE = 20 + +export const agentRepo = repoFactory(AgentEntity) export const agentService = (log: FastifyBaseLogger) => ({ - async createConversation({ platformId, userId, request, id }: CreateConversationParams): Promise { - const conversation = await agentHelpers.conversationRepo().save({ - id: id ?? apId(), - platformId, - projectId: null, - userId, - source: AgentRunSource.CHAT, - title: request.title ?? null, - modelName: request.modelName ?? null, - messages: [], + async create({ projectId, ownerId, request }: CreateParams): Promise { + const visibility = request.visibility ?? AgentVisibility.PROJECT + return agentRepo().save({ + id: apId(), + projectId, + ownerId, + externalId: apId(), + displayName: request.displayName, + description: request.description ?? null, + icon: request.icon, + color: request.color, + visibility, + sharedWithUserIds: await resolveShare({ visibility, sharedWithUserIds: request.sharedWithUserIds, projectId, log }), + draft: request.draft, + published: null, }) - log.info({ conversation: { id: conversation.id }, platform: { id: platformId }, user: { id: userId } }, '[agentService] Conversation created') - return conversation }, - async listConversations({ platformId, userId, cursor, limit }: ListConversationsParams): Promise> { - const decodedCursor = paginationHelper.decodeCursor(cursor) + async list({ platformId, userId, projectId, cursor, limit }: ListParams): Promise> { + const readableProjectIds = await resolveReadableProjectIds({ platformId, userId, projectId, log }) + if (readableProjectIds.length === 0) { + return paginationHelper.createPage([], null) + } + + const { nextCursor, previousCursor } = paginationHelper.decodeCursor(cursor) const paginator = buildPaginator({ - entity: AgentConversationEntity, + entity: AgentEntity, query: { - limit, - orderBy: [ - { field: 'created', order: Order.DESC }, - { field: 'id', order: Order.DESC }, - ], - afterCursor: decodedCursor.nextCursor, - beforeCursor: decodedCursor.previousCursor, + limit: limit ?? DEFAULT_PAGE_SIZE, + order: 'DESC', + afterCursor: nextCursor, + beforeCursor: previousCursor, }, }) - const queryBuilder = agentHelpers.conversationRepo() - .createQueryBuilder('agent_conversation') - .select([ - 'agent_conversation.id', - 'agent_conversation.created', - 'agent_conversation.updated', - 'agent_conversation.platformId', - 'agent_conversation.projectId', - 'agent_conversation.userId', - 'agent_conversation.title', - 'agent_conversation.modelName', - 'agent_conversation.status', - ]) - .where({ platformId, userId }) - // Eval conversations are owned by the platform owner; keep them out of the regular list. - .andWhere('agent_conversation.id NOT LIKE :evalPrefix', { evalPrefix: `${EVAL_CONVERSATION_ID_PREFIX}%` }) - .andWhere('agent_conversation.source = :chatSource', { chatSource: AgentRunSource.CHAT }) - - const { data, cursor: paginationCursor } = await paginator.paginate(queryBuilder) - return paginationHelper.createPage(data, paginationCursor) + const { data, cursor: newCursor } = await paginator.paginate( + visibleAgents({ userId }).andWhere({ projectId: In(readableProjectIds) }), + ) + return paginationHelper.createPage(data, newCursor) }, - async getConversationOrThrow({ id, platformId, userId }: ConversationIdentifier): Promise { - // Eval conversations must never be opened or messaged through the regular (non-dry-run) chat - // path — that would run real tools against a conversation meant to be side-effect-free. - if (isEvalConversationId(id)) { - throw new ActivepiecesError({ code: ErrorCode.ENTITY_NOT_FOUND, params: { entityId: id, entityType: 'AgentConversation' } }) + async getOneOrThrow({ id, projectId, userId }: GetParams): Promise { + const agent = await visibleAgents({ userId }).andWhere({ id, projectId }).getOne() + if (isNil(agent)) { + throw agentNotFound(id) } - const conversation = await agentHelpers.getConversationOrThrow({ id, platformId, userId, log }) - if (conversation.source !== AgentRunSource.CHAT) { - throw new ActivepiecesError({ code: ErrorCode.ENTITY_NOT_FOUND, params: { entityId: id, entityType: 'AgentConversation' } }) - } - return conversation + return agent }, - async updateConversation({ id, platformId, userId, request }: UpdateConversationParams): Promise { - const conversation = await this.getConversationOrThrow({ id, platformId, userId }) - const updates = { - ...spreadIfDefined('title', request.title), - ...spreadIfDefined('modelName', request.modelName), - } - - if (Object.keys(updates).length > 0) { - await agentHelpers.conversationRepo().update(conversation.id, updates) - } - return { ...conversation, ...updates } + async update({ id, projectId, userId, request }: UpdateParams): Promise { + const agent = await this.getOneOrThrow({ id, projectId, userId }) + const visibility = request.visibility ?? agent.visibility + const sharedWithUserIds = await resolveShare({ + visibility, + sharedWithUserIds: request.sharedWithUserIds ?? agent.sharedWithUserIds, + projectId, + log, + }) + return agentRepo().save({ ...agent, ...request, visibility, sharedWithUserIds }) }, - async deleteConversation({ id, platformId, userId }: ConversationIdentifier): Promise { - const conversation = await this.getConversationOrThrow({ id, platformId, userId }) - if (conversation.status === AgentConversationStatus.STREAMING) { - await agentApprovalGate.requestCancel({ conversationId: id }) - await agentHelpers.conversationRepo().update(id, { - status: AgentConversationStatus.IDLE, - }) - } - await agentHelpers.conversationRepo().delete(conversation.id) - log.info({ conversation: { id }, platform: { id: platformId }, user: { id: userId } }, '[agentService] Conversation deleted') + async delete({ id, projectId, userId }: GetParams): Promise { + const agent = await this.getOneOrThrow({ id, projectId, userId }) + await agentRepo().delete({ id, projectId }) + return agent }, +}) - async getMessages({ id, platformId, userId }: ConversationIdentifier): Promise<{ data: PersistedAgentMessage[] | AgentHistoryMessage[] }> { - const conversation = await this.getConversationOrThrow({ id, platformId, userId }) - if (conversation.uiMessages) { - return { data: conversation.uiMessages } - } - const messages = agentHistory.reconstruct(conversation.messages as ModelMessage[]) - return { data: messages } - }, +function visibleAgents({ userId }: { userId: UserId }): SelectQueryBuilder { + return agentRepo() + .createQueryBuilder('agent') + .where(new Brackets((qb) => { + qb.where('agent.visibility = :projectVisibility', { projectVisibility: AgentVisibility.PROJECT }) + .orWhere('agent."ownerId" = :userId', { userId }) + .orWhere(':userId = ANY(agent."sharedWithUserIds")', { userId }) + })) +} - async setMessageFeedback({ id, platformId, userId, messageIndex, request }: SetMessageFeedbackParams): Promise { - const conversation = await this.getConversationOrThrow({ id, platformId, userId }) - const target = conversation.uiMessages?.[messageIndex] - if (isNil(target) || target.role !== PersistedAgentRole.ASSISTANT) { - throw new ActivepiecesError({ - code: ErrorCode.ENTITY_NOT_FOUND, - params: { entityType: 'AgentMessage', entityId: `${id}#${messageIndex}` }, - }) - } - // Patch only this message's feedback via atomic jsonb_set, never a full-array rewrite — a - // concurrent worker append during a STREAMING turn must not be clobbered by a stale snapshot. - const repo = agentHelpers.conversationRepo() - const table = repo.metadata.tableName - const path = `{${messageIndex},feedback}` - if (isNil(request.rating)) { - await repo.query(`UPDATE "${table}" SET "uiMessages" = "uiMessages" #- $1::text[] WHERE id = $2`, [path, conversation.id]) - } - else { - const reasons = request.reasons?.length ? request.reasons : undefined - const comment = request.comment?.trim() || undefined - const feedback = { rating: request.rating, ...spreadIfDefined('reasons', reasons), ...spreadIfDefined('comment', comment) } - const value = JSON.stringify(sanitizeObjectForPostgresql(feedback)) - await repo.query(`UPDATE "${table}" SET "uiMessages" = jsonb_set("uiMessages", $1::text[], $2::jsonb, true) WHERE id = $3`, [path, value, conversation.id]) - } - log.info({ conversation: { id }, messageIndex, rating: request.rating }, '[agentService] Message feedback recorded') - }, +async function resolveShare({ visibility, sharedWithUserIds, projectId, log }: ResolveShareParams): Promise { + if (visibility === AgentVisibility.PROJECT || isNil(sharedWithUserIds) || sharedWithUserIds.length === 0) { + return [] + } + const uniqueUserIds = [...new Set(sharedWithUserIds)] + const members = await projectMemberService(log).listProjectMemberUserIds({ projectId }) + const strangers = uniqueUserIds.filter((userId) => !members.includes(userId)) + if (strangers.length > 0) { + throw new ActivepiecesError({ + code: ErrorCode.VALIDATION, + params: { message: 'An agent can only be shared with people who are already in its project' }, + }) + } + return uniqueUserIds +} -}) +async function resolveReadableProjectIds({ platformId, userId, projectId, log }: ResolveProjectsParams): Promise { + const users = userService(log) + const user = await users.getOneOrFail({ id: userId }) + const isPrivileged = users.isUserPrivileged(user) + const projects = await agentHelpers.getUserProjects({ platformId, userId, log }) + const permittedProjectIds = isPrivileged + ? [] + : await projectMemberService(log).listProjectIdsWithPermission({ userId, platformId, permission: Permission.READ_AGENT }) + + return projects + .filter((project) => isPrivileged || project.ownerId === userId || permittedProjectIds.includes(project.id)) + .map((project) => project.id) + .filter((id) => isNil(projectId) || id === projectId) +} + +function agentNotFound(id: ApId): ActivepiecesError { + return new ActivepiecesError({ + code: ErrorCode.ENTITY_NOT_FOUND, + params: { entityId: id, entityType: 'agent' }, + }) +} + +type CreateParams = { + projectId: ProjectId + ownerId: UserId + request: CreateAgentRequest +} -type CreateConversationParams = { - platformId: string - userId: string - request: CreateAgentConversationRequest - id?: string +type ListParams = { + platformId: PlatformId + userId: UserId + projectId?: ProjectId + cursor?: Cursor + limit?: number } -type ListConversationsParams = { - platformId: string - userId: string - cursor?: string - limit: number +type GetParams = { + id: ApId + projectId: ProjectId + userId: UserId } -type ConversationIdentifier = { - id: string - platformId: string - userId: string +type UpdateParams = GetParams & { + request: UpdateAgentRequest } -type UpdateConversationParams = ConversationIdentifier & { - request: UpdateAgentConversationRequest +type ResolveProjectsParams = { + platformId: PlatformId + userId: UserId + projectId?: ProjectId + log: FastifyBaseLogger } -type SetMessageFeedbackParams = ConversationIdentifier & { - messageIndex: number - request: SetAgentMessageFeedbackRequest +type ResolveShareParams = { + visibility: AgentVisibility + sharedWithUserIds?: UserId[] + projectId: ProjectId + log: FastifyBaseLogger } diff --git a/packages/server/api/src/app/ee/agent/agent.module.ts b/packages/server/api/src/app/ee/agent/agent.module.ts index 73c38a69325b..fac001dd5038 100644 --- a/packages/server/api/src/app/ee/agent/agent.module.ts +++ b/packages/server/api/src/app/ee/agent/agent.module.ts @@ -1,12 +1,18 @@ import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' +import { platformMustHaveFeatureEnabled } from '../authentication/ee-authorization' import { agentController } from './agent-controller' +import { agentConversationController } from './agent-conversation-controller' import { agentRunController } from './agent-run-controller' import { chatVisibilityGuard } from './chat-visibility-helper' export const agentModule: FastifyPluginAsyncZod = async (app) => { await app.register(async (chatSurface) => { chatSurface.addHook('preHandler', chatVisibilityGuard) - await chatSurface.register(agentController, { prefix: '/v1/agents' }) + await chatSurface.register(agentConversationController, { prefix: '/v1/agents' }) + }) + await app.register(async (agentSurface) => { + agentSurface.addHook('preHandler', platformMustHaveFeatureEnabled((platform) => platform.plan.agentsEnabled)) + await agentSurface.register(agentController, { prefix: '/v1/agents' }) }) await app.register(agentRunController, { prefix: '/v1/agents' }) } diff --git a/packages/server/api/src/app/ee/platform/platform-plan/platform-plan.entity.ts b/packages/server/api/src/app/ee/platform/platform-plan/platform-plan.entity.ts index c8e8bf526545..91248492602f 100644 --- a/packages/server/api/src/app/ee/platform/platform-plan/platform-plan.entity.ts +++ b/packages/server/api/src/app/ee/platform/platform-plan/platform-plan.entity.ts @@ -30,7 +30,6 @@ type RetiredPlatformPlanColumns = { maxAutoTopUpCreditsMonthly: number | null lastFreeAiCreditsRenewalDate: Date | null includedAiCredits: number - agentsEnabled: boolean teamProjectsLimit: string } @@ -69,6 +68,10 @@ export const PlatformPlanEntity = new EntitySchema({ chatEnabled: { type: Boolean, }, + agentsEnabled: { + type: Boolean, + default: true, + }, workerGroupsEnabled: { type: Boolean, default: false, @@ -221,11 +224,6 @@ export const PlatformPlanEntity = new EntitySchema({ default: 0, }, /** @deprecated see RetiredPlatformPlanColumns */ - agentsEnabled: { - type: Boolean, - default: true, - }, - /** @deprecated see RetiredPlatformPlanColumns */ teamProjectsLimit: { type: String, default: 'NONE', diff --git a/packages/server/api/src/app/ee/projects/platform-project-service.ts b/packages/server/api/src/app/ee/projects/platform-project-service.ts index 49faaf7bb46f..ae7db8eb9a4e 100644 --- a/packages/server/api/src/app/ee/projects/platform-project-service.ts +++ b/packages/server/api/src/app/ee/projects/platform-project-service.ts @@ -222,8 +222,14 @@ export const platformProjectService = (log: FastifyBaseLogger) => ({ }, async markForDeletion({ id, platformId }: DeleteProjectParams): Promise { - const result = await projectRepo().softDelete({ id, platformId }) - if (result.affected === 0) { + const result = await projectRepo() + .createQueryBuilder() + .softDelete() + .where('"id" = :id AND "platformId" = :platformId', { id, platformId }) + .returning('id') + .execute() + const deletedRows: unknown[] = result.raw ?? [] + if (deletedRows.length === 0) { throw new ActivepiecesError({ code: ErrorCode.ENTITY_NOT_FOUND, params: { diff --git a/packages/server/api/src/app/ee/projects/project-members/project-member.service.ts b/packages/server/api/src/app/ee/projects/project-members/project-member.service.ts index 97057f1bba5b..7b4b3ab0c1fd 100644 --- a/packages/server/api/src/app/ee/projects/project-members/project-member.service.ts +++ b/packages/server/api/src/app/ee/projects/project-members/project-member.service.ts @@ -204,17 +204,30 @@ export const projectMemberService = (log: FastifyBaseLogger) => ({ return new Map(result.map(r => [r.projectId, parseInt(r.count)])) }, - async hasPermissionOnAnyProject({ userId, platformId, permission }: HasPermissionOnAnyProjectParams): Promise { - const count = await repo() + async hasPermissionOnAnyProject(params: HasPermissionOnAnyProjectParams): Promise { + const projectIds = await this.listProjectIdsWithPermission(params) + return projectIds.length > 0 + }, + async listProjectIdsWithPermission({ userId, platformId, permission }: HasPermissionOnAnyProjectParams): Promise { + const rows = await repo() .createQueryBuilder('project_member') + .select('project_member.projectId', 'projectId') .innerJoin('project_member.projectRole', 'project_role') .innerJoin('project_member.project', 'project') .where('project_member.userId = :userId', { userId }) .andWhere('project_member.platformId = :platformId', { platformId }) .andWhere(':permission = ANY(project_role.permissions)', { permission }) .andWhere('project.deleted IS NULL') - .getCount() - return count > 0 + .getRawMany<{ projectId: ProjectId }>() + return rows.map((row) => row.projectId) + }, + async listProjectMemberUserIds({ projectId }: { projectId: ProjectId }): Promise { + const rows = await repo() + .createQueryBuilder('project_member') + .select('project_member.userId', 'userId') + .where('project_member.projectId = :projectId', { projectId }) + .getRawMany<{ userId: UserId }>() + return rows.map((row) => row.userId) }, async countActiveUsersByProjects(projectIds: ProjectId[]): Promise> { if (projectIds.length === 0) return new Map() diff --git a/packages/server/api/src/app/flows/flow-run/waitpoint/waitpoint-service.ts b/packages/server/api/src/app/flows/flow-run/waitpoint/waitpoint-service.ts index cfc452905fbd..6b3c34f5413f 100644 --- a/packages/server/api/src/app/flows/flow-run/waitpoint/waitpoint-service.ts +++ b/packages/server/api/src/app/flows/flow-run/waitpoint/waitpoint-service.ts @@ -49,25 +49,23 @@ export const waitpointService = (log: FastifyBaseLogger) => ({ const inserted = waitpoint.id === id if (inserted) { log.info({ flowRun: { id: params.flowRunId }, waitpoint: { id } }, '[waitpointService#createForPause] Waitpoint created') - // Any waitpoint may carry a deadline, not only a delay. A webhook waitpoint whose caller - // never comes back would otherwise hold the run forever. - if (!isNil(params.resumeDateTime)) { - await systemJobsSchedule(log).upsertJob({ - job: { - name: SystemJobName.RESUME_DELAY_WAITPOINT, - data: { flowRunId: params.flowRunId, projectId: params.projectId, waitpointId: id }, - jobId: `resume-delay-${params.flowRunId}`, - }, - schedule: { - type: 'one-time', - date: dayjs(params.resumeDateTime), - }, - }) - } } else { log.info({ flowRun: { id: params.flowRunId }, existingStatus: waitpoint.status }, '[waitpointService#createForPause] Waitpoint already exists') } + if (!isNil(params.resumeDateTime)) { + await systemJobsSchedule(log).upsertJob({ + job: { + name: SystemJobName.RESUME_DELAY_WAITPOINT, + data: { flowRunId: params.flowRunId, projectId: params.projectId, waitpointId: waitpoint.id }, + jobId: `resume-delay-${params.flowRunId}`, + }, + schedule: { + type: 'one-time', + date: dayjs(params.resumeDateTime), + }, + }) + } return { inserted, waitpoint } }, diff --git a/packages/server/api/src/app/pieces/metadata/piece-metadata-service.ts b/packages/server/api/src/app/pieces/metadata/piece-metadata-service.ts index 0c0b87b2fa05..e1239befae9d 100644 --- a/packages/server/api/src/app/pieces/metadata/piece-metadata-service.ts +++ b/packages/server/api/src/app/pieces/metadata/piece-metadata-service.ts @@ -158,13 +158,10 @@ export const pieceMetadataService = (log: FastifyBaseLogger) => { }, async bulkDelete(pieces: { name: string, version: string }[]): Promise { - const results = await Promise.all(pieces.map((piece) => + await Promise.all(pieces.map((piece) => pieceRepos().delete({ name: piece.name, version: piece.version }), )) - const anyDeleted = results.some((result) => !isNil(result.affected) && result.affected > 0) - if (anyDeleted) { - await pieceCache(log).invalidate() - } + await pieceCache(log).invalidate() }, async delete({ id, platformId }: DeleteParams): Promise { diff --git a/packages/server/api/test/helpers/mocks/index.ts b/packages/server/api/test/helpers/mocks/index.ts index fa75fe48e1dd..0aa08a5303d8 100644 --- a/packages/server/api/test/helpers/mocks/index.ts +++ b/packages/server/api/test/helpers/mocks/index.ts @@ -176,6 +176,7 @@ export const createMockPlatformPlan = (platformPlan?: Partial): Pl embeddingEnabled: platformPlan?.embeddingEnabled ?? false, aiProvidersEnabled: platformPlan?.aiProvidersEnabled ?? false, chatEnabled: platformPlan?.chatEnabled ?? false, + agentsEnabled: platformPlan?.agentsEnabled ?? false, workerGroupsEnabled: platformPlan?.workerGroupsEnabled ?? false, billedTeamProjectsLimit: platformPlan?.billedTeamProjectsLimit === undefined ? 0 : platformPlan.billedTeamProjectsLimit, usersLimit: platformPlan?.usersLimit ?? null, diff --git a/packages/server/api/test/integration/ce/agent/agent-entity.test.ts b/packages/server/api/test/integration/ce/agent/agent-entity.test.ts new file mode 100644 index 000000000000..acd39c35ee4e --- /dev/null +++ b/packages/server/api/test/integration/ce/agent/agent-entity.test.ts @@ -0,0 +1,83 @@ +import { apId } from '@activepieces/core-utils' +import { AgentIcon, AgentVisibility, ColorName, DEFAULT_AGENT_MAX_STEPS } from '@activepieces/shared' +import { FastifyInstance } from 'fastify' +import { databaseConnection } from '../../../../src/app/database/database-connection' +import { AgentEntity } from '../../../../src/app/ee/agent/agent-entity' +import { mockAndSaveBasicSetup } from '../../../helpers/mocks' +import { setupTestEnvironment } from '../../../helpers/test-setup' + +let app: FastifyInstance + +const repo = () => databaseConnection().getRepository(AgentEntity) + +async function seedProject() { + const { mockOwner, mockProject } = await mockAndSaveBasicSetup() + return { user: mockOwner, project: mockProject } +} + +function mockAgent(projectId: string, ownerId: string, overrides: Record = {}) { + return { + id: apId(), + projectId, + ownerId, + externalId: apId(), + displayName: 'Marketing agent', + description: null, + icon: AgentIcon.SPARKLES, + color: ColorName.PURPLE, + visibility: AgentVisibility.PROJECT, + draft: { + instructions: 'Draft launch posts.', + provider: null, + modelName: null, + maxSteps: DEFAULT_AGENT_MAX_STEPS, + tools: [], + structuredOutput: [], + }, + published: null, + ...overrides, + } +} + +beforeAll(async () => { + app = await setupTestEnvironment() +}) + +afterAll(async () => { + await app?.close() +}) + +describe('agent table', () => { + it('defaults sharedWithUserIds to an empty array, since typeorm sends an explicit null rather than omitting it', async () => { + const { project, user } = await seedProject() + const saved = await repo().save(mockAgent(project.id, user.id)) + + expect((await repo().findOneByOrFail({ id: saved.id })).sharedWithUserIds).toStrictEqual([]) + }) + + it('scopes externalId to the project, so two projects may hold the same one', async () => { + const first = await seedProject() + const second = await seedProject() + const externalId = 'marketing-agent' + + await repo().save(mockAgent(first.project.id, first.user.id, { externalId })) + await expect(repo().save(mockAgent(second.project.id, second.user.id, { externalId }))).resolves.toBeDefined() + await expect(repo().save(mockAgent(first.project.id, first.user.id, { externalId }))).rejects.toThrow() + }) + + it('deletes a project\'s agents with the project', async () => { + const { project, user } = await seedProject() + const saved = await repo().save(mockAgent(project.id, user.id)) + + await databaseConnection().getRepository('project').delete({ id: project.id }) + + expect(await repo().findOneBy({ id: saved.id })).toBeNull() + }) + + it('refuses to delete a user who still owns an agent, so published flows keep working', async () => { + const { project, user } = await seedProject() + await repo().save(mockAgent(project.id, user.id)) + + await expect(databaseConnection().getRepository('user').delete({ id: user.id })).rejects.toThrow() + }) +}) diff --git a/packages/server/api/test/integration/ce/flows/flow-run/waitpoint.test.ts b/packages/server/api/test/integration/ce/flows/flow-run/waitpoint.test.ts index bf59488d7e29..ac2f00d26082 100644 --- a/packages/server/api/test/integration/ce/flows/flow-run/waitpoint.test.ts +++ b/packages/server/api/test/integration/ce/flows/flow-run/waitpoint.test.ts @@ -2,6 +2,7 @@ import { apId } from '@activepieces/core-utils' import { FlowRunStatus, FlowVersionState, PauseType, RunEnvironment } from '@activepieces/shared' import { FastifyInstance } from 'fastify' import { waitpointService } from '../../../../../src/app/flows/flow-run/waitpoint/waitpoint-service' +import * as systemJobModule from '../../../../../src/app/helper/system-jobs/system-job' import { WaitpointStatus } from '../../../../../src/app/flows/flow-run/waitpoint/waitpoint-types' import { db } from '../../../../helpers/db' import { createMockFlow, createMockFlowRun, createMockFlowVersion } from '../../../../helpers/mocks' @@ -10,6 +11,7 @@ import { setupTestEnvironment, teardownTestEnvironment } from '../../../../helpe let app: FastifyInstance let ctx: TestContext +const originalSystemJobsSchedule = systemJobModule.systemJobsSchedule beforeAll(async () => { app = await setupTestEnvironment() @@ -23,6 +25,10 @@ beforeEach(async () => { ctx = await createTestContext(app) }) +afterEach(() => { + vi.restoreAllMocks() +}) + async function createFlowRun(params?: { status?: FlowRunStatus }) { const flow = createMockFlow({ projectId: ctx.project.id }) await db.save('flow', flow) @@ -144,6 +150,31 @@ describe('Waitpoint service', () => { expect(result.waitpoint.httpRequestId).toBe('reply-1') }) + it('should reschedule the resume job when a DELAY pause is retried after the row exists', async () => { + const { flowRun } = await createFlowRun() + const pauseParams = { + flowRunId: flowRun.id, + projectId: ctx.project.id, + stepName: 'delay_step', + type: PauseType.DELAY, + resumeDateTime: new Date(Date.now() + 60000).toISOString(), + } + const upsertJobSpy = vi.fn() + vi.spyOn(systemJobModule, 'systemJobsSchedule').mockImplementation((log) => ({ + ...originalSystemJobsSchedule(log), + upsertJob: upsertJobSpy, + })) + + const first = await waitpointService(app.log).createForPause(pauseParams) + const retried = await waitpointService(app.log).createForPause(pauseParams) + + expect(first.inserted).toBe(true) + expect(retried.inserted).toBe(false) + expect(retried.waitpoint.id).toBe(first.waitpoint.id) + expect(upsertJobSpy).toHaveBeenCalledTimes(2) + expect(upsertJobSpy.mock.calls[1][0].job.data.waitpointId).toBe(first.waitpoint.id) + }) + it('should correctly map WEBHOOK pause fields', async () => { const { flowRun } = await createFlowRun() 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 new file mode 100644 index 000000000000..056457da2e24 --- /dev/null +++ b/packages/server/api/test/integration/ee/agent/agent-controller.test.ts @@ -0,0 +1,191 @@ +import { apId } from '@activepieces/core-utils' +import { AgentIcon, AgentVisibility, ColorName, DefaultProjectRole } from '@activepieces/shared' +import { FastifyInstance } from 'fastify' +import { StatusCodes } from 'http-status-codes' +import { createMemberContext, createTestContext, TestContext } from '../../../helpers/test-context' +import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' + +let app: FastifyInstance + +const agentBody = (projectId: string, overrides: Record = {}) => ({ + projectId, + displayName: 'Marketing agent', + icon: AgentIcon.SPARKLES, + color: ColorName.PURPLE, + draft: { + instructions: 'Draft launch posts.', + provider: null, + modelName: null, + maxSteps: 5, + tools: [], + structuredOutput: [], + }, + ...overrides, +}) + +async function context(): Promise { + return createTestContext(app, { plan: { agentsEnabled: true } }) +} + +async function createAgent(ctx: TestContext, overrides: Record = {}) { + const response = await ctx.post('/v1/agents', agentBody(ctx.project.id, overrides)) + expect(response.statusCode).toBe(StatusCodes.CREATED) + return response.json() +} + +beforeAll(async () => { + app = await setupTestEnvironment() +}) + +afterAll(async () => { + await teardownTestEnvironment() +}) + +describe('agent crud', () => { + it('creates an agent owned by the caller, in draft, unpublished', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + + expect(agent.ownerId).toBe(ctx.user.id) + expect(agent.projectId).toBe(ctx.project.id) + expect(agent.visibility).toBe(AgentVisibility.PROJECT) + expect(agent.published).toBeNull() + expect(agent.draft.instructions).toBe('Draft launch posts.') + }) + + it('updates only the fields the request names', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + + const response = await ctx.post(`/v1/agents/${agent.id}`, { displayName: 'Renamed' }) + + expect(response.statusCode).toBe(StatusCodes.OK) + expect(response.json().displayName).toBe('Renamed') + expect(response.json().draft.instructions).toBe('Draft launch posts.') + }) + + it('deletes an agent', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + + expect((await ctx.delete(`/v1/agents/${agent.id}`)).statusCode).toBe(StatusCodes.NO_CONTENT) + expect((await ctx.get(`/v1/agents/${agent.id}`)).statusCode).toBe(StatusCodes.NOT_FOUND) + }) +}) + +describe('agent project isolation', () => { + it.each([ + ['read', (ctx: TestContext, id: string) => ctx.get(`/v1/agents/${id}`)], + ['update', (ctx: TestContext, id: string) => ctx.post(`/v1/agents/${id}`, { displayName: 'Hijacked' })], + ['delete', (ctx: TestContext, id: string) => ctx.delete(`/v1/agents/${id}`)], + ])('refuses to %s an agent belonging to another project, and leaves it untouched', async (_action, attempt) => { + const owner = await context() + const stranger = await context() + const agent = await createAgent(owner) + + expect((await attempt(stranger, agent.id)).statusCode).toBe(StatusCodes.FORBIDDEN) + expect((await owner.get(`/v1/agents/${agent.id}`)).json().displayName).toBe('Marketing agent') + }) + + it('refuses to create an agent in a project the caller is not a member of', async () => { + const owner = await context() + const stranger = await context() + + expect((await stranger.post('/v1/agents', agentBody(owner.project.id))).statusCode).toBe(StatusCodes.FORBIDDEN) + }) + + it('never lists another project\'s agents', async () => { + const owner = await context() + const stranger = await context() + await createAgent(owner) + const own = await createAgent(stranger) + + const listed = (await stranger.get('/v1/agents')).json().data + expect(listed.map((row: { id: string }) => row.id)).toStrictEqual([own.id]) + }) +}) + +describe('agent sharing rules', () => { + it('refuses to share with someone who is not in the project', async () => { + const owner = await context() + const outsider = await context() + + const response = await owner.post('/v1/agents', agentBody(owner.project.id, { + visibility: AgentVisibility.RESTRICTED, + sharedWithUserIds: [outsider.user.id], + })) + + expect(response.statusCode).toBe(StatusCodes.CONFLICT) + }) + + it('drops the share list when the agent goes back to project-wide', async () => { + const owner = await context() + const member = await createMemberContext(app, owner, { projectRole: DefaultProjectRole.EDITOR }) + const agent = await createAgent(owner, { + visibility: AgentVisibility.RESTRICTED, + sharedWithUserIds: [member.user.id], + }) + + const response = await owner.post(`/v1/agents/${agent.id}`, { visibility: AgentVisibility.PROJECT }) + + expect(response.json().sharedWithUserIds).toStrictEqual([]) + }) +}) + +describe('agent list across projects', () => { + it('narrows to one project on request, and never widens to a project the caller cannot read', async () => { + const owner = await context() + const stranger = await context() + const agent = await createAgent(owner) + + const narrowed = (await owner.get('/v1/agents', { projectId: owner.project.id })).json().data + expect(narrowed.map((row: { id: string }) => row.id)).toStrictEqual([agent.id]) + + const foreign = (await owner.get('/v1/agents', { projectId: stranger.project.id })).json().data + expect(foreign).toStrictEqual([]) + }) + + it('refuses a page size that would disable pagination', async () => { + const ctx = await context() + + expect((await ctx.get('/v1/agents', { limit: '-1' })).statusCode).toBe(StatusCodes.BAD_REQUEST) + expect((await ctx.get('/v1/agents', { limit: '1000000' })).statusCode).toBe(StatusCodes.BAD_REQUEST) + }) +}) + +describe('agent permissions', () => { + it('lets a viewer read an agent but never create or change one', async () => { + const owner = await context() + const viewer = await createMemberContext(app, owner, { projectRole: DefaultProjectRole.VIEWER }) + const agent = await createAgent(owner) + + expect((await viewer.get(`/v1/agents/${agent.id}`)).statusCode).toBe(StatusCodes.OK) + expect((await viewer.post('/v1/agents', agentBody(owner.project.id))).statusCode).toBe(StatusCodes.FORBIDDEN) + expect((await viewer.post(`/v1/agents/${agent.id}`, { displayName: 'Nope' })).statusCode).toBe(StatusCodes.FORBIDDEN) + expect((await viewer.delete(`/v1/agents/${agent.id}`)).statusCode).toBe(StatusCodes.FORBIDDEN) + }) +}) + +describe('agent routes coexist with the chat routes already on /v1/agents', () => { + it('does not swallow the static sibling routes with /:id', async () => { + const ctx = await createTestContext(app, { plan: { agentsEnabled: true, chatEnabled: true } }) + + expect((await ctx.get('/v1/agents/memory')).statusCode).toBe(StatusCodes.OK) + expect((await ctx.get('/v1/agents/conversations')).statusCode).toBe(StatusCodes.OK) + }) + + it('reports a missing agent as not found rather than routing it elsewhere', async () => { + const ctx = await context() + + expect((await ctx.get(`/v1/agents/${apId()}`)).statusCode).toBe(StatusCodes.NOT_FOUND) + }) +}) + +describe('agent feature gate', () => { + it('refuses every agent route when the platform does not have agents', async () => { + const ctx = await createTestContext(app, { plan: { agentsEnabled: false } }) + + expect((await ctx.get('/v1/agents')).statusCode).toBe(StatusCodes.PAYMENT_REQUIRED) + expect((await ctx.post('/v1/agents', agentBody(ctx.project.id))).statusCode).toBe(StatusCodes.PAYMENT_REQUIRED) + }) +}) diff --git a/packages/server/api/test/integration/ee/projects/platform-project-service.test.ts b/packages/server/api/test/integration/ee/projects/platform-project-service.test.ts new file mode 100644 index 000000000000..993111a54365 --- /dev/null +++ b/packages/server/api/test/integration/ee/projects/platform-project-service.test.ts @@ -0,0 +1,45 @@ +import { apId } from '@activepieces/core-utils' +import { FastifyInstance } from 'fastify' +import { platformProjectService } from '../../../../src/app/ee/projects/platform-project-service' +import { mockAndSaveBasicSetup } from '../../../helpers/mocks' +import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' + +let app: FastifyInstance + +beforeAll(async () => { + app = await setupTestEnvironment() +}) + +afterAll(async () => { + await teardownTestEnvironment() +}) + +describe('markForDeletion', () => { + it('refuses a project id that does not exist', async () => { + const { mockPlatform } = await mockAndSaveBasicSetup() + + await expect(platformProjectService(app.log).markForDeletion({ + id: apId(), + platformId: mockPlatform.id, + })).rejects.toThrow() + }) + + it('refuses a project that belongs to another platform', async () => { + const owner = await mockAndSaveBasicSetup() + const stranger = await mockAndSaveBasicSetup() + + await expect(platformProjectService(app.log).markForDeletion({ + id: owner.mockProject.id, + platformId: stranger.mockPlatform.id, + })).rejects.toThrow() + }) + + it('soft deletes a project of its own platform', async () => { + const { mockPlatform, mockProject } = await mockAndSaveBasicSetup() + + await expect(platformProjectService(app.log).markForDeletion({ + id: mockProject.id, + platformId: mockPlatform.id, + })).resolves.toBeUndefined() + }) +}) diff --git a/packages/server/api/test/unit/app/ee/agent/agent-rpc-handlers.test.ts b/packages/server/api/test/unit/app/ee/agent/agent-rpc-handlers.test.ts index 2dc2e17d96b5..ea30a067bc3d 100644 --- a/packages/server/api/test/unit/app/ee/agent/agent-rpc-handlers.test.ts +++ b/packages/server/api/test/unit/app/ee/agent/agent-rpc-handlers.test.ts @@ -18,7 +18,7 @@ const { mockSet, mockWhere, mockAndWhere, mockExecute, mockFindOneBy, mockFindOn mockSet: vi.fn(), mockWhere: vi.fn(), mockAndWhere: vi.fn(), - mockExecute: vi.fn().mockResolvedValue({ affected: 1 }), + mockExecute: vi.fn().mockResolvedValue({ raw: [{ id: 'conv-1' }] }), mockFindOneBy: vi.fn().mockResolvedValue(null), mockFindOne: vi.fn().mockResolvedValue(null), mockTrack: vi.fn().mockResolvedValue(undefined), @@ -86,7 +86,8 @@ type QueryBuilderMock = { set: (values: unknown) => QueryBuilderMock where: (sql: string, params: unknown) => QueryBuilderMock andWhere: (sql: string, params: unknown) => QueryBuilderMock - execute: () => Promise<{ affected: number }> + returning: (columns: string) => QueryBuilderMock + execute: () => Promise<{ raw?: unknown[] }> } vi.mock('../../../../../src/app/ee/agent/agent-helpers', () => ({ @@ -103,6 +104,7 @@ vi.mock('../../../../../src/app/ee/agent/agent-helpers', () => ({ set: (values) => { mockSet(values); return builder }, where: (_sql, params) => { mockWhere(params); return builder }, andWhere: (_sql, params) => { mockAndWhere(params); return builder }, + returning: () => builder, execute: mockExecute, } return builder @@ -220,7 +222,7 @@ describe('agentRpcHandlers.saveAgentMessages — billing a row the run no longer }) it('does not bill when the fenced save was rejected (preempted by a newer run)', async () => { - mockExecute.mockResolvedValue({ affected: 0 }) + mockExecute.mockResolvedValue({ raw: [] }) mockFindOneBy.mockResolvedValue({ id: 'conv-1', messages: [{ role: 'user' }] }) await callSaveChatMessages({ conversationId: 'conv-1', runId: 'run-1', messages: [{ role: 'user' }, { role: 'assistant' }], uiMessages: [{ role: 'assistant' }] }) @@ -230,7 +232,7 @@ describe('agentRpcHandlers.saveAgentMessages — billing a row the run no longer }) it('bills under the owning run id when the save landed', async () => { - mockExecute.mockResolvedValue({ affected: 1 }) + mockExecute.mockResolvedValue({ raw: [{ id: 'conv-1' }] }) mockFindOneBy.mockResolvedValue({ id: 'conv-1', messages: [{ role: 'user' }] }) await callSaveChatMessages({ conversationId: 'conv-1', runId: 'run-1', messages: [{ role: 'user' }, { role: 'assistant' }], uiMessages: [{ role: 'assistant' }] }) @@ -239,13 +241,13 @@ describe('agentRpcHandlers.saveAgentMessages — billing a row the run no longer expect(mockTrack.mock.calls[0][0]).toMatchObject({ runId: 'run-1' }) }) - it('still bills when affected is undefined (driver reports no row count)', async () => { + it('does not bill when the write returned nothing, on any driver', async () => { mockExecute.mockResolvedValue({}) mockFindOneBy.mockResolvedValue({ id: 'conv-1', messages: [{ role: 'user' }] }) await callSaveChatMessages({ conversationId: 'conv-1', runId: 'run-1', messages: [{ role: 'user' }, { role: 'assistant' }], uiMessages: [{ role: 'assistant' }] }) - expect(mockTrack).toHaveBeenCalledTimes(1) + expect(mockTrack).not.toHaveBeenCalled() }) }) diff --git a/packages/server/engine/src/lib/api/engine-file-api.ts b/packages/server/engine/src/lib/api/engine-file-api.ts index b60c04056d17..d6376c922113 100644 --- a/packages/server/engine/src/lib/api/engine-file-api.ts +++ b/packages/server/engine/src/lib/api/engine-file-api.ts @@ -2,16 +2,10 @@ import { Readable } from 'node:stream' import { promisify } from 'node:util' import { zstdDecompress as zstdDecompressCallback } from 'node:zlib' import { EngineFileNotFoundError, EngineGenericError, FileCompression, FileType, isZstdCompressed } from '@activepieces/shared' -import fetchRetry from 'fetch-retry' +import { retryFetch } from './retry-fetch' const zstdDecompress = promisify(zstdDecompressCallback) -const RETRY_CONFIG = { - retries: 3, - retryDelay: 3000, - retryOn: [408, 429, 500, 502, 503, 504], -} as const - const READ_URL_HEADER = 'x-ap-file-read-url' const FILE_TYPE_HEADER = 'x-ap-file-type' const FILE_NAME_HEADER = 'x-ap-file-name' @@ -33,15 +27,13 @@ export const engineFileApi = { return resolveUploadReadUrl(fileId, response) } - const fetchWithRetry = fetchRetry(global.fetch) const headers = buildPutHeaders({ type, fileName, compression, contentLength: data.length }) - const initial = await fetchWithRetry(putUrl, { + const initial = await retryFetch(putUrl, { method: 'PUT', body: data, headers, redirect: 'manual', - ...RETRY_CONFIG, }) if (initial.status >= 300 && initial.status < 400) { @@ -49,12 +41,11 @@ export const engineFileApi = { if (!location) { throw new EngineGenericError('EngineFileUploadError', 'Server returned a redirect without a Location header') } - const s3Response = await fetchWithRetry(location, { + const s3Response = await retryFetch(location, { method: 'PUT', body: data, headers: stripApHeaders(headers), redirect: 'follow', - ...RETRY_CONFIG, }) if (!s3Response.ok) { throw new EngineGenericError( @@ -72,11 +63,9 @@ export const engineFileApi = { return resolveUploadReadUrl(fileId, initial) }, async download({ engineToken, apiUrl, fileId }: DownloadFileParams): Promise { - const fetchWithRetry = fetchRetry(global.fetch) - const response = await fetchWithRetry(`${apiUrl}v1/files/${fileId}?token=${encodeURIComponent(engineToken)}`, { + const response = await retryFetch(`${apiUrl}v1/files/${fileId}?token=${encodeURIComponent(engineToken)}`, { method: 'GET', redirect: 'follow', - ...RETRY_CONFIG, }) if (!response.ok) { // A gone file (deleted/expired trigger payload or run log) never recovers on retry and is a diff --git a/packages/server/engine/src/lib/api/engine-run-api.ts b/packages/server/engine/src/lib/api/engine-run-api.ts index b20abdf30770..c2b0075d479b 100644 --- a/packages/server/engine/src/lib/api/engine-run-api.ts +++ b/packages/server/engine/src/lib/api/engine-run-api.ts @@ -1,43 +1,29 @@ import { EngineGenericError, SendFlowResponseRequest, UpdateRunProgressRequest, UpdateStepProgressRequest, UploadRunLogsRequest } from '@activepieces/shared' -import fetchRetry from 'fetch-retry' - -const TERMINAL_RETRY_CONFIG = { - retries: 3, - retryDelay: 3000, - retryOn: [408, 429, 500, 502, 503, 504], -} as const - -const PROGRESS_RETRY_CONFIG = { - retries: 3, - retryDelay: 3000, - retryOn: [408, 429, 500, 502, 503, 504], -} as const +import { retryFetch } from './retry-fetch' export const engineRunApi = { async updateRunProgress({ apiUrl, engineToken, request }: RunProgressParams): Promise { - await post({ apiUrl, engineToken, path: 'run-progress', body: request, retry: PROGRESS_RETRY_CONFIG }) + await post({ apiUrl, engineToken, path: 'run-progress', body: request }) }, async updateStepProgress({ apiUrl, engineToken, request }: StepProgressParams): Promise { - await post({ apiUrl, engineToken, path: 'step-progress', body: request, retry: PROGRESS_RETRY_CONFIG }) + await post({ apiUrl, engineToken, path: 'step-progress', body: request, fetcher: global.fetch }) }, async uploadRunLog({ apiUrl, engineToken, request }: RunLogParams): Promise { - await post({ apiUrl, engineToken, path: 'run-logs', body: request, retry: TERMINAL_RETRY_CONFIG }) + await post({ apiUrl, engineToken, path: 'run-logs', body: request }) }, async sendFlowResponse({ apiUrl, engineToken, request }: FlowResponseParams): Promise { - await post({ apiUrl, engineToken, path: 'flow-response', body: request, retry: TERMINAL_RETRY_CONFIG }) + await post({ apiUrl, engineToken, path: 'flow-response', body: request }) }, } -async function post({ apiUrl, engineToken, path, body, retry }: PostParams): Promise { - const fetchWithRetry = fetchRetry(global.fetch) - const response = await fetchWithRetry(`${apiUrl}v1/engine/${path}`, { +async function post({ apiUrl, engineToken, path, body, fetcher = retryFetch }: PostParams): Promise { + const response = await fetcher(`${apiUrl}v1/engine/${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${engineToken}`, }, body: JSON.stringify(body), - ...retry, }) if (!response.ok) { throw new EngineGenericError( @@ -60,5 +46,5 @@ type FlowResponseParams = BaseParams & { request: SendFlowResponseRequest } type PostParams = BaseParams & { path: string body: unknown - retry: { retries: number, retryDelay?: number, retryOn?: number[] } + fetcher?: typeof retryFetch } diff --git a/packages/server/engine/src/lib/api/retry-fetch.ts b/packages/server/engine/src/lib/api/retry-fetch.ts new file mode 100644 index 000000000000..f20afc0e3970 --- /dev/null +++ b/packages/server/engine/src/lib/api/retry-fetch.ts @@ -0,0 +1,11 @@ +import fetchRetry from 'fetch-retry' + +export function retryFetch(input: string | URL, init?: RequestInit): Promise { + return fetchRetry(global.fetch, RETRY_OPTIONS)(input, init) +} + +const RETRY_OPTIONS = { + retries: 3, + retryDelay: 3000, + retryOn: [408, 429, 500, 502, 503, 504], +} diff --git a/packages/server/engine/src/lib/handler/context/engine-constants.ts b/packages/server/engine/src/lib/handler/context/engine-constants.ts index a69282639ddc..729b99bd375d 100644 --- a/packages/server/engine/src/lib/handler/context/engine-constants.ts +++ b/packages/server/engine/src/lib/handler/context/engine-constants.ts @@ -1,6 +1,7 @@ import { ensureTrailingSlash, isNil, PlatformId, ProjectId } from '@activepieces/core-utils' import { ContextVersion } from '@activepieces/pieces-framework' import { BaseEngineOperation, BeginExecuteFlowOperation, DEFAULT_MCP_DATA, EngineGenericError, ExecutePropsOptions, ExecuteTriggerOperation, ExecutionState, ExecutionType, flowStructureUtil, FlowTrigger, FlowVersionState, Project, ResumeExecuteFlowOperation, ResumePayload, RunEnvironment, StreamStepProgress, TriggerHookType } from '@activepieces/shared' +import { retryFetch } from '../../api/retry-fetch' import { createPropsResolver, PropsResolver } from '../../variables/props-resolver' type RetryConstants = { @@ -179,7 +180,7 @@ export class EngineConstants { const getWorkerProjectEndpoint = `${this.internalApiUrl}v1/worker/project` - const response = await fetch(getWorkerProjectEndpoint, { + const response = await retryFetch(getWorkerProjectEndpoint, { headers: { Authorization: `Bearer ${this.engineToken}`, }, diff --git a/packages/server/engine/src/lib/piece-context/connection-resolver.ts b/packages/server/engine/src/lib/piece-context/connection-resolver.ts index d8809146722d..cb070e1f4ac1 100644 --- a/packages/server/engine/src/lib/piece-context/connection-resolver.ts +++ b/packages/server/engine/src/lib/piece-context/connection-resolver.ts @@ -1,5 +1,6 @@ import { ContextVersion } from '@activepieces/pieces-framework' import { AppConnection, AppConnectionStatus, AppConnectionType, AppConnectionValue, ConnectionExpiredError, ConnectionLoadingError, ConnectionNotFoundError, ConnectionPieceMismatchError, ExecutionError, FetchError } from '@activepieces/shared' +import { retryFetch } from '../api/retry-fetch' import { utils } from '../utils' export const createConnectionResolver = ({ projectId, engineToken, apiUrl, contextVersion, pieceName }: CreateConnectionResolverParams): ConnectionResolver => { @@ -8,7 +9,7 @@ export const createConnectionResolver = ({ projectId, engineToken, apiUrl, conte const url = `${apiUrl}v1/worker/app-connections/${encodeURIComponent(externalId)}?projectId=${projectId}` const { data: connectionValue, error: connectionValueError } = await utils.tryCatchAndThrowOnEngineError((async () => { - const response = await fetch(url, { + const response = await retryFetch(url, { method: 'GET', headers: { Authorization: `Bearer ${engineToken}`, diff --git a/packages/server/engine/src/lib/piece-context/flows.ts b/packages/server/engine/src/lib/piece-context/flows.ts index 151675051fb1..0c35009721d1 100644 --- a/packages/server/engine/src/lib/piece-context/flows.ts +++ b/packages/server/engine/src/lib/piece-context/flows.ts @@ -1,6 +1,7 @@ import { SeekPage } from '@activepieces/core-utils' import { FlowsContext, ListFlowsContextParams } from '@activepieces/pieces-framework' import { FetchError, PopulatedFlow } from '@activepieces/shared' +import { retryFetch } from '../api/retry-fetch' export const createFlowsContext = ({ engineToken, internalApiUrl, flowId, flowVersionId }: CreateFlowsServiceParams): FlowsContext => { return { @@ -10,7 +11,7 @@ export const createFlowsContext = ({ engineToken, internalApiUrl, flowId, flowVe queryParams.set('externalIds', params.externalIds.join(',')) } const url = `${internalApiUrl}v1/engine/populated-flows?${queryParams.toString()}` - const response = await fetch(url, { + const response = await retryFetch(url, { method: 'GET', headers: { Authorization: `Bearer ${engineToken}`, diff --git a/packages/server/engine/src/lib/piece-context/store.ts b/packages/server/engine/src/lib/piece-context/store.ts index 7a6a768ef66c..6176b51b5f9c 100644 --- a/packages/server/engine/src/lib/piece-context/store.ts +++ b/packages/server/engine/src/lib/piece-context/store.ts @@ -2,6 +2,7 @@ import { URL } from 'node:url' import { FlowId, isNil } from '@activepieces/core-utils' import { Store, StoreScope } from '@activepieces/pieces-framework' import { DeleteStoreEntryRequest, ExecutionError, FetchError, PutStoreEntryRequest, StorageError, StorageInvalidKeyError, StorageLimitError, STORE_KEY_MAX_LENGTH, STORE_VALUE_MAX_SIZE, StoreEntry } from '@activepieces/shared' +import { retryFetch } from '../api/retry-fetch' import { utils } from '../utils' export function createContextStore({ apiUrl, prefix, flowId, engineToken }: { apiUrl: string, prefix: string, flowId: FlowId, engineToken: string }): Store { @@ -40,7 +41,7 @@ function createStoreClient({ engineToken, apiUrl }: CreateStoreClientParams): St const url = buildUrl(apiUrl, key) const { data: storeEntry, error: storeEntryError } = await utils.tryCatchAndThrowOnEngineError((async () => { - const response = await fetch(url, { + const response = await retryFetch(url, { headers: { Authorization: `Bearer ${engineToken}`, }, @@ -72,7 +73,7 @@ function createStoreClient({ engineToken, apiUrl }: CreateStoreClientParams): St if (sizeOfValue > STORE_VALUE_MAX_SIZE) { throw new StorageLimitError(request.key, STORE_VALUE_MAX_SIZE) } - const response = await fetch(url, { + const response = await retryFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -107,7 +108,7 @@ function createStoreClient({ engineToken, apiUrl }: CreateStoreClientParams): St const url = buildUrl(apiUrl, request.key) const { data: storeEntry, error: storeEntryError } = await utils.tryCatchAndThrowOnEngineError((async () => { - const response = await fetch(url, { + const response = await retryFetch(url, { method: 'DELETE', headers: { Authorization: `Bearer ${engineToken}`, diff --git a/packages/server/engine/src/lib/piece-context/variable-resolver.ts b/packages/server/engine/src/lib/piece-context/variable-resolver.ts index 74d222c7c4fa..33d88d923f2a 100644 --- a/packages/server/engine/src/lib/piece-context/variable-resolver.ts +++ b/packages/server/engine/src/lib/piece-context/variable-resolver.ts @@ -1,4 +1,5 @@ import { EngineGenericError, ExecutionError, FetchError, VariableNotFoundError } from '@activepieces/shared' +import { retryFetch } from '../api/retry-fetch' import { utils } from '../utils' export const createVariableResolver = ({ projectId: _projectId, engineToken, apiUrl }: CreateVariableResolverParams): VariableResolver => { @@ -7,7 +8,7 @@ export const createVariableResolver = ({ projectId: _projectId, engineToken, api const url = `${apiUrl}v1/worker/variables/${encodeURIComponent(name)}` const { data: value, error: fetchError } = await utils.tryCatchAndThrowOnEngineError((async () => { - const response = await fetch(url, { + const response = await retryFetch(url, { method: 'GET', headers: { Authorization: `Bearer ${engineToken}`, diff --git a/packages/server/engine/src/lib/piece-context/waitpoint-client.ts b/packages/server/engine/src/lib/piece-context/waitpoint-client.ts index 0822974f5f9b..69af7dc0e0c5 100644 --- a/packages/server/engine/src/lib/piece-context/waitpoint-client.ts +++ b/packages/server/engine/src/lib/piece-context/waitpoint-client.ts @@ -1,8 +1,9 @@ import { CreateWaitpointRequest, CreateWaitpointResponse, EngineGenericError } from '@activepieces/shared' +import { retryFetch } from '../api/retry-fetch' export const waitpointClient = { create: async ({ apiUrl, engineToken, ...body }: CreateWaitpointClientRequest): Promise => { - const response = await fetch(`${apiUrl}v1/waitpoints`, { + const response = await retryFetch(`${apiUrl}v1/waitpoints`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/packages/server/engine/test/piece-context/connection-resolver.test.ts b/packages/server/engine/test/piece-context/connection-resolver.test.ts index 211d35348a31..6fba59960604 100644 --- a/packages/server/engine/test/piece-context/connection-resolver.test.ts +++ b/packages/server/engine/test/piece-context/connection-resolver.test.ts @@ -28,6 +28,11 @@ describe('connection-resolver service', () => { beforeEach(() => { vi.restoreAllMocks() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() }) it('V1 happy path returns connection.value', async () => { @@ -110,11 +115,52 @@ describe('connection-resolver service', () => { await expect(resolver.obtain('my-connection')).rejects.toThrow(ConnectionExpiredError) }) - it('throws ConnectionLoadingError on non-404 error', async () => { - vi.spyOn(global, 'fetch').mockResolvedValue(new Response(null, { status: 500 })) + it('retries a transient network failure and resolves', async () => { + const connection = makeConnection() + const fetchSpy = vi.spyOn(global, 'fetch') + .mockRejectedValueOnce(new TypeError('fetch failed')) + .mockResolvedValue(new Response( + JSON.stringify(connection), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + )) + + const resolver = createConnectionResolver(RESOLVER_PARAMS) + const result = await drainRetries(resolver.obtain('my-connection')) + + expect(result).toEqual(connection.value) + expect(fetchSpy).toHaveBeenCalledTimes(2) + }) + + it('retries a transient 500 and resolves', async () => { + const connection = makeConnection() + const fetchSpy = vi.spyOn(global, 'fetch') + .mockResolvedValueOnce(new Response(null, { status: 500 })) + .mockResolvedValue(new Response( + JSON.stringify(connection), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + )) + + const resolver = createConnectionResolver(RESOLVER_PARAMS) + const result = await drainRetries(resolver.obtain('my-connection')) + + expect(result).toEqual(connection.value) + expect(fetchSpy).toHaveBeenCalledTimes(2) + }) + + it('throws ConnectionLoadingError when 500 outlives the retries', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(new Response(null, { status: 500 })) const resolver = createConnectionResolver(RESOLVER_PARAMS) - await expect(resolver.obtain('my-connection')).rejects.toThrow(ConnectionLoadingError) + await expect(drainRetries(resolver.obtain('my-connection'))).rejects.toThrow(ConnectionLoadingError) + expect(fetchSpy).toHaveBeenCalledTimes(4) + }) + + it('throws FetchError when the network failure outlives the retries', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockRejectedValue(new TypeError('fetch failed')) + + const resolver = createConnectionResolver(RESOLVER_PARAMS) + await expect(drainRetries(resolver.obtain('my-connection'))).rejects.toThrow(FetchError) + expect(fetchSpy).toHaveBeenCalledTimes(4) }) describe('AP_ENFORCE_CONNECTION_PIECE_BINDING', () => { @@ -178,11 +224,10 @@ describe('connection-resolver service', () => { }) }) }) - - it('throws FetchError on network failure', async () => { - vi.spyOn(global, 'fetch').mockRejectedValue(new TypeError('fetch failed')) - - const resolver = createConnectionResolver(RESOLVER_PARAMS) - await expect(resolver.obtain('my-connection')).rejects.toThrow(FetchError) - }) }) + +async function drainRetries(pending: Promise): Promise { + pending.catch(() => undefined) + await vi.runAllTimersAsync() + return pending +} diff --git a/packages/server/sandbox/src/lib/cache/flow/flow-provisioning.ts b/packages/server/sandbox/src/lib/cache/flow/flow-provisioning.ts index ed143c800384..b81bcaa53ad7 100644 --- a/packages/server/sandbox/src/lib/cache/flow/flow-provisioning.ts +++ b/packages/server/sandbox/src/lib/cache/flow/flow-provisioning.ts @@ -1,6 +1,6 @@ import { isNil, tryCatch } from '@activepieces/core-utils' import { type ApLogger, wideEvent } from '@activepieces/server-utils' -import { AgentPieceTool, FailedStep, FlowActionType, flowStructureUtil, FlowVersion, FlowVersionState, LATEST_FLOW_SCHEMA_VERSION, PiecePackage, Step, WorkerToApiContract } from '@activepieces/shared' +import { FailedStep, FlowVersion, FlowVersionState, LATEST_FLOW_SCHEMA_VERSION, PiecePackage, WorkerToApiContract } from '@activepieces/shared' import { CodeArtifact, SandboxSettings } from '../../types' import { pieceCache, PieceNotFoundError } from '../pieces/piece-cache' import { flowBundleStore } from './flow-bundle-store' @@ -70,8 +70,7 @@ async function resolvePieces({ flowVersion, platformId, log, apiClient, basePath pieceName: step.settings.pieceName, pieceVersion: step.settings.pieceVersion, })) - const agentToolPieceRefs = flowStructureUtil.getAllSteps(flowVersion.trigger).flatMap(extractAgentToolPieceRefs) - const uniquePieceRefs = dedupePieceRefs([...stepPieceRefs, ...agentToolPieceRefs]) + const uniquePieceRefs = dedupePieceRefs(stepPieceRefs) return Promise.all(uniquePieceRefs.map((ref) => pieceCache(log, apiClient, basePath, getSettings).getPiece({ pieceName: ref.pieceName, @@ -84,8 +83,7 @@ async function resolvePieces({ flowVersion, platformId, log, apiClient, basePath function buildMissingPieceFailedStep({ flowVersion, missingPiece }: BuildMissingPieceFailedStepParams): FailedStep { const pieceSteps = flowSteps.piece(flowVersion) const stepMatch = pieceSteps.find((step) => step.settings.pieceName === missingPiece.pieceName && step.settings.pieceVersion === missingPiece.pieceVersion) - const agentToolMatch = pieceSteps.find((step) => extractAgentToolPieceRefs(step).some((ref) => ref.pieceName === missingPiece.pieceName && ref.pieceVersion === missingPiece.pieceVersion)) - const step = stepMatch ?? agentToolMatch ?? flowVersion.trigger + const step = stepMatch ?? flowVersion.trigger return { name: step.name, displayName: step.displayName, @@ -93,28 +91,6 @@ function buildMissingPieceFailedStep({ flowVersion, missingPiece }: BuildMissing } } -// Pieces used as agent tools live in a PIECE step's `agentTools` input, not as their own flow steps, so the -// step-based scan above misses them and the engine would fail at runtime with the tool's piece uninstalled. -function extractAgentToolPieceRefs(step: Step): PieceRef[] { - if (step.type !== FlowActionType.PIECE) { - return [] - } - const agentTools = step.settings.input['agentTools'] - if (!Array.isArray(agentTools)) { - return [] - } - return agentTools.flatMap((tool: unknown) => { - const parsed = AgentPieceTool.safeParse(tool) - if (!parsed.success) { - return [] - } - return [{ - pieceName: parsed.data.pieceMetadata.pieceName, - pieceVersion: parsed.data.pieceMetadata.pieceVersion, - }] - }) -} - function dedupePieceRefs(refs: PieceRef[]): PieceRef[] { const byKey = new Map() for (const ref of refs) { diff --git a/packages/web/public/locales/de/translation.json b/packages/web/public/locales/de/translation.json index 437d80dc7268..42f4822d83fa 100644 --- a/packages/web/public/locales/de/translation.json +++ b/packages/web/public/locales/de/translation.json @@ -161,18 +161,42 @@ "Adds Success and Failure branches, errors go into Failure.": "Adds Success and Failure branches, errors go into Failure.", "Retry on Failure": "Bei Fehler wiederholen", "Retries up to 4 times before failing the step.": "Retries up to 4 times before failing the step.", + "Hide": "", + "{count, plural, =1 {1 option} other {# options}}": "", "Remove": "Entfernen", "Add Item": "Add Item", "Connection": "Verbindung", "input value is invalid, please contact support": "Eingabewert ist ungültig, bitte wenden Sie sich an den Support", "Info copied to clipboard, please send it to support": "Info in die Zwischenablage kopiert, bitte an den Support senden", "Info": "Info", - "Dynamic value": "Dynamischer Wert", "File Input i.e a url or file passed from a previous step": "Dateieingabe, z.B. eine URL oder Datei, die aus einem vorherigen Schritt übergeben wurde", "Date Input must comply with ISO 8601 format": "Datumeingabe muss dem ISO 8601-Format entsprechen", + "After": "", + "Before": "", "Select an option": "Select an option", "Unexpected error, please retry": "Unerwarteter Fehler, bitte erneut versuchen", "Unexpected error, please refresh the page or contact support": "Unerwarteter Fehler, bitte aktualisieren Sie die Seite oder kontaktieren Sie den Support", + "Dynamic value": "Dynamischer Wert", + "No filters added": "", + "Without filters, this step returns the most recent results. Add a filter to narrow them.": "", + "Remove filter": "", + "Add filter": "", + "Filter by…": "", + "No filters found": "", + "Added": "", + "Returns up to {count} results": "", + "No filters — newest first": "", + "{count, plural, =1 {# filter applied} other {# filters applied}} · newest first": "", + "Active filters": "", + "Clear all": "Alle löschen", + "No filters yet": "", + "Not a valid email address": "", + "Click to edit": "", + "Add another": "", + "Type an email and press Enter": "", + "Decrease": "", + "Increase": "", + "{count, plural, other {# chars}}": "", "Deprecated": "Veraltet", "Use": "Benutzen", "instead": "statt", @@ -181,11 +205,19 @@ "See All": "Alle anzeigen", "Type to search functions...": "Tippe um Funktionen zu suchen...", "No functions found": "Keine Funktionen gefunden", + "Link URL": "", + "Bold": "Fett", + "Italic": "Kursiv", + "Underline": "Unterstrichen", + "Bullet list": "", + "Link": "", "Error": "Fehler", "Preview": "Vorschau", "empty": "leer", "To create an agent, you'll first need to connect to OpenAI in platform settings.": "Um einen Agent zu erstellen, musst du zuerst eine Verbindung zu OpenAI in den Plattform-Einstellungen herstellen.", "AI piece is not available for this platform": "AI piece is not available for this platform", + "Read": "Lesen", + "Write": "Schreiben", "Explore": "Erforschen", "Apps": "Apps", "Utility": "Hilfsmittel", @@ -791,7 +823,6 @@ "Start running your flows to see time saved": "Beginnen Sie mit der Ausführung Ihrer Flows, um die eingesparte Zeit zu sehen", "Search owners...": "Eigentümer suchen...", "No owners found": "Keine Besitzer gefunden", - "Clear all": "Alle löschen", "Unlock Impact Analytics": "Wirkungsanalysen freischalten", "View impact analytics and metrics for the active flows across your platform": "Anzeigen von Wirkungsanalysen und -metriken für die aktiven Flows auf Ihrer Plattform", "View impact analytics and metrics for the active flows.": "Anzeigen von Wirkungsanalysen und -metriken für die aktiven Flows.", @@ -1143,8 +1174,6 @@ "Review and manage permissions for this role.": "Überprüfen und verwalten Sie die Berechtigungen für diese Rolle.", "Role Name": "Rollenname", "None": "Keine", - "Read": "Lesen", - "Write": "Schreiben", "View the users assigned to this role": "Benutzer anzeigen, die dieser Rolle zugewiesen sind", "No users found": "Keine Benutzer gefunden", "Start by assigning users to this role": "Beginnen Sie damit, Benutzer dieser Rolle zuzuweisen", @@ -1515,9 +1544,6 @@ "Any flows currently using these connections": "Jederzeit Flows, die derzeit diese Verbindungen nutzen", "will break immediately": "sofort unterbrochen werden", "Strike": "Durchstreichen", - "Bold": "Fett", - "Italic": "Kursiv", - "Underline": "Unterstrichen", "Image": "Bild", "Loading...": "Lädt...", "useMultiSelect must be used within MultiSelectProvider": "useMultiSelect muss innerhalb des MultiSelectProviders verwendet werden", diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index 1093091bc692..e1fef388ac5e 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -42,9 +42,6 @@ "Dock": "Dock", "Minimize": "Minimize", "Result": "Result", - "Hide": "Hide", - "{count, plural, =1 {1 option} other {# options}}": "{count, plural, =1 {1 option} other {# options}}", - "Use dynamic value": "Use dynamic value", "Data Selector": "Data Selector", "Data": "Data", "Variables": "Variables", @@ -164,18 +161,42 @@ "Adds Success and Failure branches, errors go into Failure.": "Adds Success and Failure branches, errors go into Failure.", "Retry on Failure": "Retry on Failure", "Retries up to 4 times before failing the step.": "Retries up to 4 times before failing the step.", + "Hide": "Hide", + "{count, plural, =1 {1 option} other {# options}}": "{count, plural, =1 {1 option} other {# options}}", "Remove": "Remove", "Add Item": "Add Item", "Connection": "Connection", "input value is invalid, please contact support": "input value is invalid, please contact support", "Info copied to clipboard, please send it to support": "Info copied to clipboard, please send it to support", "Info": "Info", - "Dynamic value": "Dynamic value", "File Input i.e a url or file passed from a previous step": "File Input i.e a url or file passed from a previous step", "Date Input must comply with ISO 8601 format": "Date Input must comply with ISO 8601 format", + "After": "After", + "Before": "Before", "Select an option": "Select an option", "Unexpected error, please retry": "Unexpected error, please retry", "Unexpected error, please refresh the page or contact support": "Unexpected error, please refresh the page or contact support", + "Dynamic value": "Dynamic value", + "No filters added": "No filters added", + "Without filters, this step returns the most recent results. Add a filter to narrow them.": "Without filters, this step returns the most recent results. Add a filter to narrow them.", + "Remove filter": "Remove filter", + "Add filter": "Add filter", + "Filter by…": "Filter by…", + "No filters found": "No filters found", + "Added": "Added", + "Returns up to {count} results": "Returns up to {count} results", + "No filters — newest first": "No filters — newest first", + "{count, plural, =1 {# filter applied} other {# filters applied}} · newest first": "{count, plural, =1 {# filter applied} other {# filters applied}} · newest first", + "Active filters": "Active filters", + "Clear all": "Clear all", + "No filters yet": "No filters yet", + "Not a valid email address": "Not a valid email address", + "Click to edit": "Click to edit", + "Add another": "Add another", + "Type an email and press Enter": "Type an email and press Enter", + "Decrease": "Decrease", + "Increase": "Increase", + "{count, plural, other {# chars}}": "{count, plural, other {# chars}}", "Deprecated": "Deprecated", "Use": "Use", "instead": "instead", @@ -183,44 +204,20 @@ "to apply": "to apply", "See All": "See All", "Type to search functions...": "Type to search functions...", - "Type an email and press Enter": "Type an email and press Enter", - "Add another": "Add another", - "Click to edit": "Click to edit", - "Not a valid email address": "Not a valid email address", - "{count, plural, other {# chars}}": "{count, plural, other {# chars}}", + "No functions found": "No functions found", + "Link URL": "Link URL", "Bold": "Bold", "Italic": "Italic", "Underline": "Underline", "Bullet list": "Bullet list", "Link": "Link", - "Link URL": "Link URL", - "Active filters": "Active filters", - "No filters yet": "No filters yet", - "Add filter": "Add filter", - "Added": "Added", - "Remove filter": "Remove filter", - "No filters added": "No filters added", - "Without filters, this step returns the most recent results. Add a filter to narrow them.": "Without filters, this step returns the most recent results. Add a filter to narrow them.", - "Filter by…": "Filter by…", - "No filters found": "No filters found", - "No filters — newest first": "No filters — newest first", - "Returns up to {count} results": "Returns up to {count} results", - "{count, plural, =1 {# filter applied} other {# filters applied}} · newest first": "{count, plural, =1 {# filter applied} other {# filters applied}} · newest first", - "Last 24 hours": "Last 24 hours", - "Last 90 days": "Last 90 days", - "Custom range…": "Custom range…", - "Decrease": "Decrease", - "Increase": "Increase", - "Any time": "Any time", - "This month": "This month", - "After": "After", - "Before": "Before", - "No functions found": "No functions found", "Error": "Error", "Preview": "Preview", "empty": "empty", "To create an agent, you'll first need to connect to OpenAI in platform settings.": "To create an agent, you'll first need to connect to OpenAI in platform settings.", "AI piece is not available for this platform": "AI piece is not available for this platform", + "Read": "Read", + "Write": "Write", "Explore": "Explore", "Apps": "Apps", "Utility": "Utility", @@ -826,7 +823,6 @@ "Start running your flows to see time saved": "Start running your flows to see time saved", "Search owners...": "Search owners...", "No owners found": "No owners found", - "Clear all": "Clear all", "Unlock Impact Analytics": "Unlock Impact Analytics", "View impact analytics and metrics for the active flows across your platform": "View impact analytics and metrics for the active flows across your platform", "View impact analytics and metrics for the active flows.": "View impact analytics and metrics for the active flows.", @@ -1178,8 +1174,6 @@ "Review and manage permissions for this role.": "Review and manage permissions for this role.", "Role Name": "Role Name", "None": "None", - "Read": "Read", - "Write": "Write", "View the users assigned to this role": "View the users assigned to this role", "No users found": "No users found", "Start by assigning users to this role": "Start by assigning users to this role", @@ -1550,9 +1544,6 @@ "Any flows currently using these connections": "Any flows currently using these connections", "will break immediately": "will break immediately", "Strike": "Strike", - "Bold": "Bold", - "Italic": "Italic", - "Underline": "Underline", "Image": "Image", "Loading...": "Loading...", "useMultiSelect must be used within MultiSelectProvider": "useMultiSelect must be used within MultiSelectProvider", diff --git a/packages/web/public/locales/es/translation.json b/packages/web/public/locales/es/translation.json index 8c3e19b5a19e..47ca491ad40a 100644 --- a/packages/web/public/locales/es/translation.json +++ b/packages/web/public/locales/es/translation.json @@ -161,18 +161,42 @@ "Adds Success and Failure branches, errors go into Failure.": "Adds Success and Failure branches, errors go into Failure.", "Retry on Failure": "Reintentar en Fallo", "Retries up to 4 times before failing the step.": "Retries up to 4 times before failing the step.", + "Hide": "", + "{count, plural, =1 {1 option} other {# options}}": "", "Remove": "Eliminar", "Add Item": "Añadir ítem", "Connection": "Conexión", "input value is invalid, please contact support": "El valor de entrada es inválido, por favor contacte con soporte", "Info copied to clipboard, please send it to support": "Información copiada al portapapeles, por favor envíela al soporte", "Info": "Información", - "Dynamic value": "Valor dinámico", "File Input i.e a url or file passed from a previous step": "Entrada de archivo, es decir, una URL o archivo pasado desde un paso anterior", "Date Input must comply with ISO 8601 format": "La entrada de fecha debe cumplir con el formato ISO 8601", + "After": "", + "Before": "", "Select an option": "Seleccione una opción", "Unexpected error, please retry": "Error inesperado, vuelva a intentarlo", "Unexpected error, please refresh the page or contact support": "Error inesperado, por favor actualiza la página o contacta con el soporte técnico", + "Dynamic value": "Valor dinámico", + "No filters added": "", + "Without filters, this step returns the most recent results. Add a filter to narrow them.": "", + "Remove filter": "", + "Add filter": "", + "Filter by…": "", + "No filters found": "", + "Added": "", + "Returns up to {count} results": "", + "No filters — newest first": "", + "{count, plural, =1 {# filter applied} other {# filters applied}} · newest first": "", + "Active filters": "", + "Clear all": "Limpiar todo", + "No filters yet": "", + "Not a valid email address": "", + "Click to edit": "", + "Add another": "", + "Type an email and press Enter": "", + "Decrease": "", + "Increase": "", + "{count, plural, other {# chars}}": "", "Deprecated": "Desaprobado", "Use": "Usar", "instead": "en vez", @@ -181,11 +205,19 @@ "See All": "Ver todos", "Type to search functions...": "Escriba para buscar funciones...", "No functions found": "No se encontraron funciones", + "Link URL": "", + "Bold": "Negrita", + "Italic": "Cursiva", + "Underline": "Subrayar", + "Bullet list": "", + "Link": "", "Error": "Error", "Preview": "Vista previa", "empty": "vacío", "To create an agent, you'll first need to connect to OpenAI in platform settings.": "Para crear un agente, primero tendrás que conectarte a OpenAI en la configuración de la plataforma.", "AI piece is not available for this platform": "AI piece is not available for this platform", + "Read": "Leer", + "Write": "Escribir", "Explore": "Explorar", "Apps": "Aplicaciones", "Utility": "Utilidad", @@ -791,7 +823,6 @@ "Start running your flows to see time saved": "Comienza a ejecutar tus flujos para ver el tiempo guardado", "Search owners...": "Buscar propietarios...", "No owners found": "No se encontraron propietarios", - "Clear all": "Limpiar todo", "Unlock Impact Analytics": "Desbloquear Análisis de Impacto", "View impact analytics and metrics for the active flows across your platform": "Ver análisis e indicadores de impacto para los flujos activos en toda tu plataforma", "View impact analytics and metrics for the active flows.": "Ver análisis e indicadores de impacto para los flujos activos.", @@ -1143,8 +1174,6 @@ "Review and manage permissions for this role.": "Revisar y administrar permisos para este rol.", "Role Name": "Nombre del Rol", "None": "Ninguna", - "Read": "Leer", - "Write": "Escribir", "View the users assigned to this role": "Ver los usuarios asignados a este rol", "No users found": "No hay usuarios", "Start by assigning users to this role": "Comenzar asignando usuarios a este rol", @@ -1515,9 +1544,6 @@ "Any flows currently using these connections": "Cualquier flujo que actualmente use estas conexiones", "will break immediately": "se romperá inmediatamente", "Strike": "Tachar", - "Bold": "Negrita", - "Italic": "Cursiva", - "Underline": "Subrayar", "Image": "Imagen", "Loading...": "Cargando...", "useMultiSelect must be used within MultiSelectProvider": "useMultiSelect debe ser utilizado en MultiSelectProvider", diff --git a/packages/web/public/locales/fr/translation.json b/packages/web/public/locales/fr/translation.json index b59c63d73398..0ba786cffbca 100644 --- a/packages/web/public/locales/fr/translation.json +++ b/packages/web/public/locales/fr/translation.json @@ -161,18 +161,42 @@ "Adds Success and Failure branches, errors go into Failure.": "Adds Success and Failure branches, errors go into Failure.", "Retry on Failure": "Réessayer en cas d'échec", "Retries up to 4 times before failing the step.": "Retries up to 4 times before failing the step.", + "Hide": "", + "{count, plural, =1 {1 option} other {# options}}": "", "Remove": "Retirer", "Add Item": "Ajouter un élément", "Connection": "Connexion", "input value is invalid, please contact support": "la valeur en entrée est invalide, veuillez contacter le support", "Info copied to clipboard, please send it to support": "Info copiée dans le presse-papiers, veuillez l'envoyer au support", "Info": "Infos", - "Dynamic value": "Valeur dynamique", "File Input i.e a url or file passed from a previous step": "L'entrée du fichier est une URL ou un fichier passé depuis l'étape précédente", "Date Input must comply with ISO 8601 format": "La saisie de la date doit respecter le format ISO 8601", + "After": "", + "Before": "", "Select an option": "Choisir une option", "Unexpected error, please retry": "Erreur inattendue, veuillez réessayer", "Unexpected error, please refresh the page or contact support": "Erreur inattendue, veuillez actualiser la page ou contacter le support", + "Dynamic value": "Valeur dynamique", + "No filters added": "", + "Without filters, this step returns the most recent results. Add a filter to narrow them.": "", + "Remove filter": "", + "Add filter": "", + "Filter by…": "", + "No filters found": "", + "Added": "", + "Returns up to {count} results": "", + "No filters — newest first": "", + "{count, plural, =1 {# filter applied} other {# filters applied}} · newest first": "", + "Active filters": "", + "Clear all": "Tout effacer", + "No filters yet": "", + "Not a valid email address": "", + "Click to edit": "", + "Add another": "", + "Type an email and press Enter": "", + "Decrease": "", + "Increase": "", + "{count, plural, other {# chars}}": "", "Deprecated": "Déprécié", "Use": "Utiliser", "instead": "à la place", @@ -181,11 +205,19 @@ "See All": "Tout voir", "Type to search functions...": "Tapez pour les fonctions de recherche...", "No functions found": "Aucune fonction trouvée", + "Link URL": "", + "Bold": "Gras", + "Italic": "Italique", + "Underline": "Souligner", + "Bullet list": "", + "Link": "", "Error": "Error", "Preview": "Aperçu", "empty": "vide", "To create an agent, you'll first need to connect to OpenAI in platform settings.": "Pour créer un agent, vous devez d'abord vous connecter à OpenAI dans les paramètres de la plateforme.", "AI piece is not available for this platform": "", + "Read": "Lire", + "Write": "Écrire", "Explore": "Découvrir", "Apps": "Connecteurs", "Utility": "Outils", @@ -791,7 +823,6 @@ "Start running your flows to see time saved": "Commencez à faire fonctionner vos flux pour voir le temps économisé", "Search owners...": "Rechercher des propriétaires...", "No owners found": "Aucun propriétaire trouvé", - "Clear all": "Tout effacer", "Unlock Impact Analytics": "Débloquer les Analyses d'Impact", "View impact analytics and metrics for the active flows across your platform": "Visualisez les analyses d'impact et les metrics pour les flux actifs sur votre plateforme", "View impact analytics and metrics for the active flows.": "Visualisez les analyses d'impact et les metrics pour les flux actifs.", @@ -1143,8 +1174,6 @@ "Review and manage permissions for this role.": "Revoir et gérer les autorisations pour ce rôle.", "Role Name": "Nom du rôle", "None": "Aucun", - "Read": "Lire", - "Write": "Écrire", "View the users assigned to this role": "Voir les utilisateurs assignés à ce rôle", "No users found": "Aucun utilisateur trouvé", "Start by assigning users to this role": "Commencez par attribuer des utilisateurs à ce rôle", @@ -1515,9 +1544,6 @@ "Any flows currently using these connections": "Tous les flux utilisant actuellement ces connexions", "will break immediately": "casseront immédiatement", "Strike": "Barrer", - "Bold": "Gras", - "Italic": "Italique", - "Underline": "Souligner", "Image": "Image", "Loading...": "Loading...", "useMultiSelect must be used within MultiSelectProvider": "useMultiSelect doit être utilisé dans MultiSelectProvider", diff --git a/packages/web/public/locales/ja/translation.json b/packages/web/public/locales/ja/translation.json index 27d84822d3bc..0bcded2f6289 100644 --- a/packages/web/public/locales/ja/translation.json +++ b/packages/web/public/locales/ja/translation.json @@ -161,18 +161,42 @@ "Adds Success and Failure branches, errors go into Failure.": "Adds Success and Failure branches, errors go into Failure.", "Retry on Failure": "失敗時に再試行", "Retries up to 4 times before failing the step.": "Retries up to 4 times before failing the step.", + "Hide": "", + "{count, plural, =1 {1 option} other {# options}}": "", "Remove": "削除", "Add Item": "アイテムを追加", "Connection": "接続", "input value is invalid, please contact support": "入力値が無効です。サポートにお問い合わせください", "Info copied to clipboard, please send it to support": "情報がクリップボードにコピーされました。サポートへ送信してください", "Info": "情報", - "Dynamic value": "動的値", "File Input i.e a url or file passed from a previous step": "ファイル入力、つまり前のステップから渡されたURLまたはファイル", "Date Input must comply with ISO 8601 format": "日付入力はISO 8601形式に準拠する必要があります", + "After": "", + "Before": "", "Select an option": "オプションを選択", "Unexpected error, please retry": "予期しないエラーが発生しました。再試行してください。", "Unexpected error, please refresh the page or contact support": "予期しないエラー。ページを更新するか、サポートにお問い合わせください。", + "Dynamic value": "動的値", + "No filters added": "", + "Without filters, this step returns the most recent results. Add a filter to narrow them.": "", + "Remove filter": "", + "Add filter": "", + "Filter by…": "", + "No filters found": "", + "Added": "", + "Returns up to {count} results": "", + "No filters — newest first": "", + "{count, plural, =1 {# filter applied} other {# filters applied}} · newest first": "", + "Active filters": "", + "Clear all": "すべてクリア", + "No filters yet": "", + "Not a valid email address": "", + "Click to edit": "", + "Add another": "", + "Type an email and press Enter": "", + "Decrease": "", + "Increase": "", + "{count, plural, other {# chars}}": "", "Deprecated": "非推奨です", "Use": "使用", "instead": "代わりに", @@ -181,11 +205,19 @@ "See All": "すべて見る", "Type to search functions...": "関数を検索するタイプ...", "No functions found": "関数が見つかりません", + "Link URL": "", + "Bold": "ボールド", + "Italic": "イタリック", + "Underline": "下線", + "Bullet list": "", + "Link": "", "Error": "エラー", "Preview": "プレビュー", "empty": "空", "To create an agent, you'll first need to connect to OpenAI in platform settings.": "エージェントを作成するには、まずプラットフォーム設定で OpenAI に接続する必要があります。", "AI piece is not available for this platform": "AI piece is not available for this platform", + "Read": "既読にする", + "Write": "書き込み", "Explore": "探索", "Apps": "アプリ", "Utility": "ユーティリティ", @@ -791,7 +823,6 @@ "Start running your flows to see time saved": "フローを実行して保存された時間を確認してください", "Search owners...": "所有者を検索…", "No owners found": "所有者が見つかりません", - "Clear all": "すべてクリア", "Unlock Impact Analytics": "影響の解析を解除", "View impact analytics and metrics for the active flows across your platform": "プラットフォーム全体でアクティブなフローの影響分析とメトリクスを表示", "View impact analytics and metrics for the active flows.": "アクティブフローの影響解析とメトリクスを表示。", @@ -1143,8 +1174,6 @@ "Review and manage permissions for this role.": "このロールの権限を確認および管理します。", "Role Name": "役割名", "None": "なし", - "Read": "既読にする", - "Write": "書き込み", "View the users assigned to this role": "この役割に割り当てられたユーザーを表示", "No users found": "ユーザーが見つかりませんでした", "Start by assigning users to this role": "このロールにユーザーを割り当てることから始めます", @@ -1515,9 +1544,6 @@ "Any flows currently using these connections": "現在、これらの接続を使用しているフロー", "will break immediately": "即座に壊れます", "Strike": "ストライク", - "Bold": "ボールド", - "Italic": "イタリック", - "Underline": "下線", "Image": "画像", "Loading...": "読み込み中...", "useMultiSelect must be used within MultiSelectProvider": "useMultiSelectはMultiSelectProvider内で使用する必要があります", diff --git a/packages/web/public/locales/nl/translation.json b/packages/web/public/locales/nl/translation.json index 33fbc7925658..abb29ef6b28e 100644 --- a/packages/web/public/locales/nl/translation.json +++ b/packages/web/public/locales/nl/translation.json @@ -161,18 +161,42 @@ "Adds Success and Failure branches, errors go into Failure.": "Adds Success and Failure branches, errors go into Failure.", "Retry on Failure": "Opnieuw proberen bij fout", "Retries up to 4 times before failing the step.": "Retries up to 4 times before failing the step.", + "Hide": "", + "{count, plural, =1 {1 option} other {# options}}": "", "Remove": "Verwijderen", "Add Item": "Item toevoegen", "Connection": "Koppeling", "input value is invalid, please contact support": "invoerwaarde is ongeldig, neem contact op met support", "Info copied to clipboard, please send it to support": "Info gekopieerd naar klembord, stuur het naar support", "Info": "Info", - "Dynamic value": "Dynamische waarde", "File Input i.e a url or file passed from a previous step": "Bestandsinvoer d.w.z. een url of bestand van een vorige stap", "Date Input must comply with ISO 8601 format": "Datum invoer moet voldoen aan ISO 8601 formaat", + "After": "", + "Before": "", "Select an option": "Selecteer een optie", "Unexpected error, please retry": "Onverwachte fout, probeer opnieuw", "Unexpected error, please refresh the page or contact support": "Onverwachte fout, ververs de pagina of neem contact op met support", + "Dynamic value": "Dynamische waarde", + "No filters added": "", + "Without filters, this step returns the most recent results. Add a filter to narrow them.": "", + "Remove filter": "", + "Add filter": "", + "Filter by…": "", + "No filters found": "", + "Added": "", + "Returns up to {count} results": "", + "No filters — newest first": "", + "{count, plural, =1 {# filter applied} other {# filters applied}} · newest first": "", + "Active filters": "", + "Clear all": "Alles wissen", + "No filters yet": "", + "Not a valid email address": "", + "Click to edit": "", + "Add another": "", + "Type an email and press Enter": "", + "Decrease": "", + "Increase": "", + "{count, plural, other {# chars}}": "", "Deprecated": "Afgekeurd", "Use": "Gebruik", "instead": "in plaats daarvan", @@ -181,11 +205,19 @@ "See All": "Alles weergeven", "Type to search functions...": "Typ om functies te zoeken...", "No functions found": "Geen functies gevonden", + "Link URL": "", + "Bold": "Vet", + "Italic": "Cursief", + "Underline": "Onderstrepen", + "Bullet list": "", + "Link": "", "Error": "Foutmelding", "Preview": "Voorvertoning", "empty": "Leeg", "To create an agent, you'll first need to connect to OpenAI in platform settings.": "Om een agent aan te maken, moet je eerst verbinding maken met OpenAI in platforminstellingen.", "AI piece is not available for this platform": "AI piece is not available for this platform", + "Read": "Lezen", + "Write": "Schrijven", "Explore": "Verken", "Apps": "Applicaties", "Utility": "Hulpprogramma's", @@ -791,7 +823,6 @@ "Start running your flows to see time saved": "Start met het uitvoeren van je stromen om de bespaarde tijd te zien", "Search owners...": "Eigenaren zoeken...", "No owners found": "Geen eigenaren gevonden", - "Clear all": "Alles wissen", "Unlock Impact Analytics": "Ontgrendel Impact Analytics", "View impact analytics and metrics for the active flows across your platform": "Bekijk impactstatistieken en meetwaarden voor de actieve stromen in je platform", "View impact analytics and metrics for the active flows.": "Bekijk impactstatistieken en meetwaarden voor de actieve stromen.", @@ -1143,8 +1174,6 @@ "Review and manage permissions for this role.": "Controleer en beheer de rechten voor deze rol.", "Role Name": "Rol naam", "None": "geen", - "Read": "Lezen", - "Write": "Schrijven", "View the users assigned to this role": "Bekijk de gebruikers die zijn toegewezen aan deze rol", "No users found": "Geen gebruikers gevonden", "Start by assigning users to this role": "Begin met het toewijzen van gebruikers aan deze rol", @@ -1515,9 +1544,6 @@ "Any flows currently using these connections": "Alle flows die deze verbindingen momenteel gebruiken", "will break immediately": "zullen onmiddellijk breken", "Strike": "Strike", - "Bold": "Vet", - "Italic": "Cursief", - "Underline": "Onderstrepen", "Image": "Afbeelding", "Loading...": "Laden...", "useMultiSelect must be used within MultiSelectProvider": "useMultiSelect moet worden gebruikt binnen MultiSelectProvider", diff --git a/packages/web/public/locales/pt/translation.json b/packages/web/public/locales/pt/translation.json index 009477cddd34..982fb6db737f 100644 --- a/packages/web/public/locales/pt/translation.json +++ b/packages/web/public/locales/pt/translation.json @@ -161,18 +161,42 @@ "Adds Success and Failure branches, errors go into Failure.": "Adds Success and Failure branches, errors go into Failure.", "Retry on Failure": "Tentar novamente ao falhar", "Retries up to 4 times before failing the step.": "Retries up to 4 times before failing the step.", + "Hide": "", + "{count, plural, =1 {1 option} other {# options}}": "", "Remove": "Remover", "Add Item": "Adicionar Item", "Connection": "Conexão", "input value is invalid, please contact support": "valor de entrada é inválido, entre em contato com o suporte", "Info copied to clipboard, please send it to support": "Informações copiadas para a área de transferência, envie para o suporte", "Info": "Informações", - "Dynamic value": "Valor dinâmico", "File Input i.e a url or file passed from a previous step": "Entrada de Arquivo, ou seja, uma URL ou arquivo passado de uma etapa anterior", "Date Input must comply with ISO 8601 format": "A entrada de data deve estar em conformidade com o formato ISO 8601", + "After": "", + "Before": "", "Select an option": "Selecionar uma opção", "Unexpected error, please retry": "Erro inesperado, por favor tente novamente", "Unexpected error, please refresh the page or contact support": "Erro inesperado, por favor atualize a página ou contacte o suporte", + "Dynamic value": "Valor dinâmico", + "No filters added": "", + "Without filters, this step returns the most recent results. Add a filter to narrow them.": "", + "Remove filter": "", + "Add filter": "", + "Filter by…": "", + "No filters found": "", + "Added": "", + "Returns up to {count} results": "", + "No filters — newest first": "", + "{count, plural, =1 {# filter applied} other {# filters applied}} · newest first": "", + "Active filters": "", + "Clear all": "Limpar tudo", + "No filters yet": "", + "Not a valid email address": "", + "Click to edit": "", + "Add another": "", + "Type an email and press Enter": "", + "Decrease": "", + "Increase": "", + "{count, plural, other {# chars}}": "", "Deprecated": "Obsoleto", "Use": "Utilizar", "instead": "em vez", @@ -181,11 +205,19 @@ "See All": "Ver todos", "Type to search functions...": "Digite para pesquisar funções...", "No functions found": "Nenhuma função encontrada", + "Link URL": "", + "Bold": "Negrito", + "Italic": "Itálico", + "Underline": "Sublinhado", + "Bullet list": "", + "Link": "", "Error": "Erro", "Preview": "Pré-visualizar", "empty": "Vazio", "To create an agent, you'll first need to connect to OpenAI in platform settings.": "Para criar um agente, primeiro você precisará se conectar ao OpenAI nas configurações da plataforma.", "AI piece is not available for this platform": "AI piece is not available for this platform", + "Read": "Lido", + "Write": "Salvar", "Explore": "EXPLORAR", "Apps": "Aplicativos", "Utility": "Utilidade", @@ -791,7 +823,6 @@ "Start running your flows to see time saved": "Comece a executar seus fluxos para ver o tempo salvo", "Search owners...": "Pesquisar proprietários...", "No owners found": "Nenhum proprietário encontrado", - "Clear all": "Limpar tudo", "Unlock Impact Analytics": "Desbloquear Análise de Impacto", "View impact analytics and metrics for the active flows across your platform": "Veja as análises e métricas de impacto para os fluxos ativos em sua plataforma", "View impact analytics and metrics for the active flows.": "Veja as análises e métricas de impacto para os fluxos ativos.", @@ -1143,8 +1174,6 @@ "Review and manage permissions for this role.": "Revise e gerencie permissões para este papel.", "Role Name": "Nome da Função", "None": "Nenhuma", - "Read": "Lido", - "Write": "Salvar", "View the users assigned to this role": "Visualizar os usuários atribuídos a esta função", "No users found": "Nenhum usuário encontrado", "Start by assigning users to this role": "Comece atribuindo usuários a este papel", @@ -1515,9 +1544,6 @@ "Any flows currently using these connections": "Qualquer fluxo que atualmente utiliza essas conexões", "will break immediately": "quebrará imediatamente", "Strike": "Traço", - "Bold": "Negrito", - "Italic": "Itálico", - "Underline": "Sublinhado", "Image": "Imagem:", "Loading...": "Carregando...", "useMultiSelect must be used within MultiSelectProvider": "useMultiSelect deve ser usado dentro de MultiSelectProvider", diff --git a/packages/web/public/locales/zh-TW/translation.json b/packages/web/public/locales/zh-TW/translation.json index 1ea86c3c007d..e08404299d25 100644 --- a/packages/web/public/locales/zh-TW/translation.json +++ b/packages/web/public/locales/zh-TW/translation.json @@ -161,18 +161,42 @@ "Adds Success and Failure branches, errors go into Failure.": "", "Retry on Failure": "", "Retries up to 4 times before failing the step.": "", + "Hide": "", + "{count, plural, =1 {1 option} other {# options}}": "", "Remove": "", "Add Item": "", "Connection": "", "input value is invalid, please contact support": "", "Info copied to clipboard, please send it to support": "", "Info": "", - "Dynamic value": "", "File Input i.e a url or file passed from a previous step": "", "Date Input must comply with ISO 8601 format": "", + "After": "", + "Before": "", "Select an option": "", "Unexpected error, please retry": "", "Unexpected error, please refresh the page or contact support": "", + "Dynamic value": "", + "No filters added": "", + "Without filters, this step returns the most recent results. Add a filter to narrow them.": "", + "Remove filter": "", + "Add filter": "", + "Filter by…": "", + "No filters found": "", + "Added": "", + "Returns up to {count} results": "", + "No filters — newest first": "", + "{count, plural, =1 {# filter applied} other {# filters applied}} · newest first": "", + "Active filters": "", + "Clear all": "", + "No filters yet": "", + "Not a valid email address": "", + "Click to edit": "", + "Add another": "", + "Type an email and press Enter": "", + "Decrease": "", + "Increase": "", + "{count, plural, other {# chars}}": "", "Deprecated": "", "Use": "", "instead": "", @@ -181,11 +205,19 @@ "See All": "", "Type to search functions...": "", "No functions found": "", + "Link URL": "", + "Bold": "", + "Italic": "", + "Underline": "", + "Bullet list": "", + "Link": "", "Error": "", "Preview": "", "empty": "", "To create an agent, you'll first need to connect to OpenAI in platform settings.": "", "AI piece is not available for this platform": "", + "Read": "", + "Write": "", "Explore": "", "Apps": "", "Utility": "", @@ -791,7 +823,6 @@ "Start running your flows to see time saved": "", "Search owners...": "", "No owners found": "", - "Clear all": "", "Unlock Impact Analytics": "", "View impact analytics and metrics for the active flows across your platform": "", "View impact analytics and metrics for the active flows.": "", @@ -1143,8 +1174,6 @@ "Review and manage permissions for this role.": "", "Role Name": "", "None": "", - "Read": "", - "Write": "", "View the users assigned to this role": "", "No users found": "", "Start by assigning users to this role": "", @@ -1515,9 +1544,6 @@ "Any flows currently using these connections": "", "will break immediately": "", "Strike": "", - "Bold": "", - "Italic": "", - "Underline": "", "Image": "", "Loading...": "", "useMultiSelect must be used within MultiSelectProvider": "", diff --git a/packages/web/public/locales/zh/translation.json b/packages/web/public/locales/zh/translation.json index 975eea710152..07b874dddae8 100644 --- a/packages/web/public/locales/zh/translation.json +++ b/packages/web/public/locales/zh/translation.json @@ -161,18 +161,42 @@ "Adds Success and Failure branches, errors go into Failure.": "Adds Success and Failure branches, errors go into Failure.", "Retry on Failure": "失败后重试", "Retries up to 4 times before failing the step.": "Retries up to 4 times before failing the step.", + "Hide": "", + "{count, plural, =1 {1 option} other {# options}}": "", "Remove": "删除", "Add Item": "添加项目", "Connection": "连接", "input value is invalid, please contact support": "輸入值無效,請聯繫支持", "Info copied to clipboard, please send it to support": "信息已拷貝到剪貼板,請發送給支持", "Info": "信息", - "Dynamic value": "动态值", "File Input i.e a url or file passed from a previous step": "文件輸入,例如從前一步傳遞的URL或文件", "Date Input must comply with ISO 8601 format": "日期輸入必須符合ISO 8601格式", + "After": "", + "Before": "", "Select an option": "选择一个选项", "Unexpected error, please retry": "意外错误,请重试", "Unexpected error, please refresh the page or contact support": "意外错误,请刷新页面或联系客服。", + "Dynamic value": "动态值", + "No filters added": "", + "Without filters, this step returns the most recent results. Add a filter to narrow them.": "", + "Remove filter": "", + "Add filter": "", + "Filter by…": "", + "No filters found": "", + "Added": "", + "Returns up to {count} results": "", + "No filters — newest first": "", + "{count, plural, =1 {# filter applied} other {# filters applied}} · newest first": "", + "Active filters": "", + "Clear all": "清除所有", + "No filters yet": "", + "Not a valid email address": "", + "Click to edit": "", + "Add another": "", + "Type an email and press Enter": "", + "Decrease": "", + "Increase": "", + "{count, plural, other {# chars}}": "", "Deprecated": "Deprecated", "Use": "Use", "instead": "instead", @@ -181,11 +205,19 @@ "See All": "See All", "Type to search functions...": "Type to search functions...", "No functions found": "No functions found", + "Link URL": "", + "Bold": "加粗", + "Italic": "斜體", + "Underline": "下劃線", + "Bullet list": "", + "Link": "", "Error": "错误", "Preview": "Preview", "empty": "empty", "To create an agent, you'll first need to connect to OpenAI in platform settings.": "To create an agent, you'll first need to connect to OpenAI in platform settings.", "AI piece is not available for this platform": "AI piece is not available for this platform", + "Read": "已读", + "Write": "写入", "Explore": "Explore", "Apps": "应用程序", "Utility": "Utility", @@ -791,7 +823,6 @@ "Start running your flows to see time saved": "開始運行您的流以查看節省的時間", "Search owners...": "搜索擁有者……", "No owners found": "找不到擁有者", - "Clear all": "清除所有", "Unlock Impact Analytics": "解鎖影響分析", "View impact analytics and metrics for the active flows across your platform": "查看平台上活躍流程的影響分析與指標", "View impact analytics and metrics for the active flows.": "查看活躍流程的影響分析與指標。", @@ -1143,8 +1174,6 @@ "Review and manage permissions for this role.": "查看和管理此角色的權限。", "Role Name": "角色名称", "None": "无", - "Read": "已读", - "Write": "写入", "View the users assigned to this role": "查看分配给此角色的用户", "No users found": "未找到用户", "Start by assigning users to this role": "從分配使用者到此角色開始", @@ -1515,9 +1544,6 @@ "Any flows currently using these connections": "當前使用這些連接的任何流", "will break immediately": "將立即失效", "Strike": "刪除線", - "Bold": "加粗", - "Italic": "斜體", - "Underline": "下劃線", "Image": "圖像", "Loading...": "加载中...", "useMultiSelect must be used within MultiSelectProvider": "使用 MultiselectProvider 必须使用",