mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
Merge branch 'master' into execution-suspend-worker-shutdown
This commit is contained in:
@@ -659,6 +659,12 @@ nodes get LLM-generated pin data). So:
|
||||
otherwise
|
||||
accept the static-data red as a harness limit and carry the logic in
|
||||
`outcomeExpectations`. Note the agent may *choose* static-data dedup on its own.
|
||||
- **Message an Agent runs the called Agent with its real model.** The harness
|
||||
mocks the Agent's tools, MCP servers and web search, and records those calls
|
||||
under the calling node. It turns off the Agent's memory, vector stores and chat
|
||||
integrations. Only OpenAI models are supported today, and only when
|
||||
`EVAL_OPENAI_API_KEY` is set (CI sets it): put the Agent on an `openai/*` model
|
||||
and declare an `openAiApi` credential.
|
||||
- Don't assert exact counts that depend on mock generation ("exactly 7 posts").
|
||||
Say "fewer than the original 10".
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ model; reuse only what applies. Decorators, all from `@n8n/decorators`:
|
||||
| `@Get/@Post/@Put/@Patch/@Delete('/path')` | Route method. |
|
||||
| `@ApiKeyScope('res:action')` | API-key grant check. |
|
||||
| `@ProjectScope/@GlobalScope('res:action')` | User RBAC check. |
|
||||
| `@ApiResponse(status)` / `@ApiResponse(status, Dto)` | Success status + (optional) output DTO; registry `.parse()`s + strips the return value. Exactly one per route — a second `@ApiResponse` throws. `204` can't carry a DTO — throws. |
|
||||
| `@ApiResponse(status)` / `@ApiResponse(status, Dto)` | Success status + (optional) output DTO; registry `.parse()`s + strips the return value. Exactly one per route — a second `@ApiResponse` throws. `204` can't carry a DTO or a binary body — throws. For a binary response body (returned as `{ body, headers? }`), use `@ApiResponse(status, { mediaType, description?, headers? })` — see [Binary response bodies](reference.md#binary-response-bodies). |
|
||||
| `@ApiErrorResponse(status)` | Declares an additional documented non-2xx status (e.g. `404`, `409`). Stack multiple for more than one. `400`/`401`/`403` are added automatically (body/query present, always, and `@ApiKeyScope` present, respectively) — don't declare those yourself. |
|
||||
| `@ApiSummary(text)` / `@ApiDescription(text)` / `@ApiTags([...])` | OpenAPI summary/description/tags. `@ApiTags` sorts alphabetically regardless of the order you pass. All optional but expected on every real route. |
|
||||
| `@Query` / `@Body` / `@Param('name')` | Bind + validate via a `Z.class` DTO / path param. `@Body` is JSON by default; `@Body({ mediaType: 'multipart/form-data', uploadLimits })` takes a `multipart/form-data` body instead — see [Request body media types](reference.md#request-body-media-types). |
|
||||
|
||||
@@ -146,6 +146,35 @@ handler per media type (`REQUEST_BODY_HANDLERS` in
|
||||
`REQUEST_BODY_HANDLERS` — the registry, resolver, generator and `/discover`
|
||||
need no change, since they all read the handler, not the media type.
|
||||
|
||||
## Binary response bodies
|
||||
|
||||
`@ApiResponse(status, { mediaType, description?, headers? })` declares a
|
||||
success body that the controller method returns as `{ body, headers? }`, for
|
||||
example an `application/gzip` stream. The framework treats options whose
|
||||
`mediaType` is in `BINARY_RESPONSE_MEDIA_TYPES` as a binary response.
|
||||
`mediaType` accepts only those types.
|
||||
|
||||
- `body` is a `Buffer` or a `Readable`. The method returns the result and never
|
||||
writes to `res`.
|
||||
- `headers` holds the response headers. Every header declared in `headers` must
|
||||
be in the result. A missing header fails the request with a `500`. Matching is
|
||||
case-insensitive.
|
||||
- The registry checks the headers, then sets the declared status, the headers,
|
||||
and `Content-Type: <mediaType>`. A header in the result cannot replace the
|
||||
`Content-Type`.
|
||||
- A stream's first chunk is read before any header is set. So a stream that
|
||||
fails before its first chunk gives the normal JSON error response, with no
|
||||
binary `Content-Type`. A method that throws before it returns does the same.
|
||||
- After the first chunk is sent, an error ends the response. A client that
|
||||
disconnects is not an error, and the framework destroys the stream.
|
||||
- A method that returns no binary body fails with a `500`.
|
||||
- The generator documents the body as `{ type: string, format: binary }`
|
||||
under the `mediaType` content key, with the description and headers.
|
||||
The description defaults to `Operation successful.`.
|
||||
|
||||
The runtime part is `sendBinaryResponse` in
|
||||
`packages/cli/src/public-api/media-types/binary-response.ts`.
|
||||
|
||||
## Migrating legacy EOV endpoints
|
||||
|
||||
Legacy `express-openapi-validator` endpoints live under
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -69,10 +69,15 @@ are namespaced under `n8n:`. Use `n8n:` prefix when invoking them (e.g.
|
||||
|
||||
## Essential Commands
|
||||
|
||||
For full-repo lint and typecheck, use `pnpm agent:lint` and
|
||||
`pnpm agent:typecheck`. They save full logs and return compact results.
|
||||
For routine pre-commit checks, follow [Verify changes](#verify-changes).
|
||||
For focused tests, use `pnpm agent:test` or `pnpm agent:playwright`.
|
||||
Run each command with `--help` for its package, script, and file options.
|
||||
Pass test files and worker limits after `--`. Run each command with `--help`
|
||||
for its options.
|
||||
|
||||
`pnpm agent:lint` and `pnpm agent:typecheck` run full-repo checks by default.
|
||||
With `--filter <package>`, they still check the whole package. These wrappers
|
||||
save logs and reduce console output. They do not reduce CPU or memory use.
|
||||
Do not use them as routine pre-commit checks.
|
||||
|
||||
### Fresh checkout / agent setup
|
||||
|
||||
@@ -314,9 +319,20 @@ extends one of those layers):
|
||||
|
||||
### Verify changes
|
||||
|
||||
- Run focused tests from the owning package: `pnpm test <test-file>`.
|
||||
- Run that package's `pnpm lint` and `pnpm typecheck` before committing code.
|
||||
Build first when shared types or cross-package dependencies change.
|
||||
- Before committing, run formatting and lint checks on changed files. Use
|
||||
the package's configured tools through `pnpm exec` with explicit file paths.
|
||||
Do not append file paths to `pnpm lint`; package scripts can still check
|
||||
the whole package.
|
||||
- Run tests that cover the changed behavior from the owning package:
|
||||
`pnpm test <test-file> --maxWorkers=1`. Or use the root wrapper:
|
||||
`pnpm agent:test --filter <package> -- <test-file> --maxWorkers=1`.
|
||||
For Playwright, use `pnpm agent:playwright -- <spec-file> --workers=1`.
|
||||
- For routine commits, run checks one at a time. Use one test worker by
|
||||
default. Do not run full frontend typechecks locally. Use CI for
|
||||
comprehensive lint, typechecks, and test suites.
|
||||
- For documentation-only changes, review the diff and run `git diff --check`.
|
||||
Skip code tests when no behavior changed.
|
||||
- Report which checks ran and which checks were left to CI.
|
||||
- Use Vitest for unit tests. Use
|
||||
[Playwright](packages/quality/testing/playwright/AGENTS.md) when a test needs its
|
||||
browser, fixtures, or managed containers.
|
||||
|
||||
Generated
+13
@@ -75,6 +75,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
|
||||
| [public.execution_metadata](public.execution_metadata.md) | 4 | | BASE TABLE |
|
||||
| [public.folder](public.folder.md) | 6 | | BASE TABLE |
|
||||
| [public.folder_tag](public.folder_tag.md) | 2 | | BASE TABLE |
|
||||
| [public.idempotency_key](public.idempotency_key.md) | 9 | | BASE TABLE |
|
||||
| [public.insights_by_period](public.insights_by_period.md) | 6 | | BASE TABLE |
|
||||
| [public.insights_metadata](public.insights_metadata.md) | 5 | | BASE TABLE |
|
||||
| [public.insights_raw](public.insights_raw.md) | 5 | | BASE TABLE |
|
||||
@@ -300,6 +301,7 @@ erDiagram
|
||||
"public.folder" }o--o| "public.folder" : "FOREIGN KEY (#quot;parentFolderId#quot;) REFERENCES folder(id) ON DELETE CASCADE"
|
||||
"public.folder_tag" }o--|| "public.tag_entity" : "FOREIGN KEY (#quot;tagId#quot;) REFERENCES tag_entity(id) ON DELETE CASCADE"
|
||||
"public.folder_tag" }o--|| "public.folder" : "FOREIGN KEY (#quot;folderId#quot;) REFERENCES folder(id) ON DELETE CASCADE"
|
||||
"public.idempotency_key" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
|
||||
"public.insights_by_period" }o--|| "public.insights_metadata" : "FOREIGN KEY (#quot;metaId#quot;) REFERENCES insights_metadata(#quot;metaId#quot;) ON DELETE CASCADE"
|
||||
"public.insights_metadata" }o--o| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE SET NULL"
|
||||
"public.insights_metadata" }o--o| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE SET NULL"
|
||||
@@ -1123,6 +1125,17 @@ erDiagram
|
||||
varchar_36_ folderId FK
|
||||
varchar_36_ tagId FK
|
||||
}
|
||||
"public.idempotency_key" {
|
||||
timestamp_3__with_time_zone createdAt
|
||||
text fingerprint
|
||||
varchar_16_ id
|
||||
varchar_128_ idempotencyKey
|
||||
json responseBody
|
||||
smallint responseStatus
|
||||
varchar_16_ status
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
uuid userId FK
|
||||
}
|
||||
"public.insights_by_period" {
|
||||
integer id
|
||||
integer metaId FK
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# public.idempotency_key
|
||||
|
||||
## Columns
|
||||
|
||||
| Name | Type | Default | Nullable | Children | Parents | Comment |
|
||||
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
|
||||
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
|
||||
| fingerprint | text | | false | | | Hash of the request method, path, and body |
|
||||
| id | varchar(16) | | false | | | Application-generated n8n nano ID |
|
||||
| idempotencyKey | varchar(128) | | false | | | Client Idempotency-Key header. Opaque ASCII, length 1 to 128 |
|
||||
| responseBody | json | | true | | | Stored response body. NULL while status is processing |
|
||||
| responseStatus | smallint | | true | | | HTTP status of the stored response. NULL while processing, required once completed |
|
||||
| status | varchar(16) | 'processing'::character varying | false | | | processing while the handler runs. completed after the response is stored |
|
||||
| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
|
||||
| userId | uuid | | false | | [public.user](public.user.md) | |
|
||||
|
||||
## Constraints
|
||||
|
||||
| Name | Type | Definition |
|
||||
| ---- | ---- | ---------- |
|
||||
| CHK_idempotency_key_responseStatus | CHECK | CHECK (((((status)::text = 'processing'::text) AND ("responseStatus" IS NULL)) OR (((status)::text = 'completed'::text) AND ("responseStatus" IS NOT NULL)))) |
|
||||
| CHK_idempotency_key_status | CHECK | CHECK (((status)::text = ANY ((ARRAY['processing'::character varying, 'completed'::character varying])::text[]))) |
|
||||
| FK_idempotency_key_userId | FOREIGN KEY | FOREIGN KEY ("userId") REFERENCES "user"(id) ON DELETE CASCADE |
|
||||
| PK_213f125e14469be304f9ff1d452 | PRIMARY KEY | PRIMARY KEY (id) |
|
||||
| idempotency_key_createdAt_not_null | n | NOT NULL "createdAt" |
|
||||
| idempotency_key_fingerprint_not_null | n | NOT NULL fingerprint |
|
||||
| idempotency_key_id_not_null | n | NOT NULL id |
|
||||
| idempotency_key_idempotencyKey_not_null | n | NOT NULL "idempotencyKey" |
|
||||
| idempotency_key_status_not_null | n | NOT NULL status |
|
||||
| idempotency_key_updatedAt_not_null | n | NOT NULL "updatedAt" |
|
||||
| idempotency_key_userId_not_null | n | NOT NULL "userId" |
|
||||
|
||||
## Indexes
|
||||
|
||||
| Name | Definition |
|
||||
| ---- | ---------- |
|
||||
| IDX_25289244569e505292cde510d7 | CREATE UNIQUE INDEX "IDX_25289244569e505292cde510d7" ON public.idempotency_key USING btree ("userId", "idempotencyKey") |
|
||||
| IDX_38536b1937c55d305af4d69a97 | CREATE INDEX "IDX_38536b1937c55d305af4d69a97" ON public.idempotency_key USING btree ("createdAt") |
|
||||
| PK_213f125e14469be304f9ff1d452 | CREATE UNIQUE INDEX "PK_213f125e14469be304f9ff1d452" ON public.idempotency_key USING btree (id) |
|
||||
|
||||
## Relations
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
|
||||
"public.idempotency_key" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
|
||||
|
||||
"public.idempotency_key" {
|
||||
timestamp_3__with_time_zone createdAt
|
||||
text fingerprint
|
||||
varchar_16_ id
|
||||
varchar_128_ idempotencyKey
|
||||
json responseBody
|
||||
smallint responseStatus
|
||||
varchar_16_ status
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
uuid userId FK
|
||||
}
|
||||
"public.user" {
|
||||
timestamp_3__with_time_zone createdAt
|
||||
boolean disabled
|
||||
varchar_255_ email
|
||||
varchar_32_ firstName
|
||||
uuid id
|
||||
date lastActiveAt
|
||||
varchar_32_ lastName
|
||||
boolean mfaEnabled
|
||||
text mfaRecoveryCodes
|
||||
text mfaSecret
|
||||
varchar_255_ password
|
||||
json personalizationAnswers
|
||||
varchar_128_ roleSlug FK
|
||||
json settings
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> Generated by [tbls](https://github.com/k1LoW/tbls)
|
||||
+13
-1
@@ -8,7 +8,7 @@
|
||||
| disabled | boolean | false | false | | | |
|
||||
| email | varchar(255) | | true | | | |
|
||||
| firstName | varchar(32) | | true | | | |
|
||||
| id | uuid | gen_random_uuid() | false | [public.activity_event](public.activity_event.md) [public.agent_eval_dataset](public.agent_eval_dataset.md) [public.agent_eval_rating](public.agent_eval_rating.md) [public.agent_eval_run](public.agent_eval_run.md) [public.agent_execution_threads](public.agent_execution_threads.md) [public.agent_history](public.agent_history.md) [public.ai_preference](public.ai_preference.md) [public.auth_identity](public.auth_identity.md) [public.chat_hub_agents](public.chat_hub_agents.md) [public.chat_hub_sessions](public.chat_hub_sessions.md) [public.chat_hub_tools](public.chat_hub_tools.md) [public.dynamic_credential_user_entry](public.dynamic_credential_user_entry.md) [public.evaluation_collection](public.evaluation_collection.md) [public.instance_ai_mcp_registry_connections](public.instance_ai_mcp_registry_connections.md) [public.instance_ai_pending_confirmations](public.instance_ai_pending_confirmations.md) [public.instance_ai_thread_grants](public.instance_ai_thread_grants.md) [public.instance_ai_thread_tabs](public.instance_ai_thread_tabs.md) [public.migration_workflow_owner](public.migration_workflow_owner.md) [public.oauth_access_tokens](public.oauth_access_tokens.md) [public.oauth_authorization_codes](public.oauth_authorization_codes.md) [public.oauth_refresh_tokens](public.oauth_refresh_tokens.md) [public.oauth_user_consents](public.oauth_user_consents.md) [public.project](public.project.md) [public.project_relation](public.project_relation.md) [public.self_healing_result](public.self_healing_result.md) [public.trusted_source_identity](public.trusted_source_identity.md) [public.user_api_keys](public.user_api_keys.md) [public.user_favorites](public.user_favorites.md) [public.workflow_builder_session](public.workflow_builder_session.md) [public.workflow_publish_history](public.workflow_publish_history.md) [public.workflow_review_activity](public.workflow_review_activity.md) [public.workflow_review_activity_comment](public.workflow_review_activity_comment.md) [public.workflow_review_request](public.workflow_review_request.md) [public.workflow_review_request_authors](public.workflow_review_request_authors.md) [public.workflow_review_request_reviewers](public.workflow_review_request_reviewers.md) [public.workflow_suggestion](public.workflow_suggestion.md) [public.workflow_suggestion_activity](public.workflow_suggestion_activity.md) | | |
|
||||
| id | uuid | gen_random_uuid() | false | [public.activity_event](public.activity_event.md) [public.agent_eval_dataset](public.agent_eval_dataset.md) [public.agent_eval_rating](public.agent_eval_rating.md) [public.agent_eval_run](public.agent_eval_run.md) [public.agent_execution_threads](public.agent_execution_threads.md) [public.agent_history](public.agent_history.md) [public.ai_preference](public.ai_preference.md) [public.auth_identity](public.auth_identity.md) [public.chat_hub_agents](public.chat_hub_agents.md) [public.chat_hub_sessions](public.chat_hub_sessions.md) [public.chat_hub_tools](public.chat_hub_tools.md) [public.dynamic_credential_user_entry](public.dynamic_credential_user_entry.md) [public.evaluation_collection](public.evaluation_collection.md) [public.idempotency_key](public.idempotency_key.md) [public.instance_ai_mcp_registry_connections](public.instance_ai_mcp_registry_connections.md) [public.instance_ai_pending_confirmations](public.instance_ai_pending_confirmations.md) [public.instance_ai_thread_grants](public.instance_ai_thread_grants.md) [public.instance_ai_thread_tabs](public.instance_ai_thread_tabs.md) [public.migration_workflow_owner](public.migration_workflow_owner.md) [public.oauth_access_tokens](public.oauth_access_tokens.md) [public.oauth_authorization_codes](public.oauth_authorization_codes.md) [public.oauth_refresh_tokens](public.oauth_refresh_tokens.md) [public.oauth_user_consents](public.oauth_user_consents.md) [public.project](public.project.md) [public.project_relation](public.project_relation.md) [public.self_healing_result](public.self_healing_result.md) [public.trusted_source_identity](public.trusted_source_identity.md) [public.user_api_keys](public.user_api_keys.md) [public.user_favorites](public.user_favorites.md) [public.workflow_builder_session](public.workflow_builder_session.md) [public.workflow_publish_history](public.workflow_publish_history.md) [public.workflow_review_activity](public.workflow_review_activity.md) [public.workflow_review_activity_comment](public.workflow_review_activity_comment.md) [public.workflow_review_request](public.workflow_review_request.md) [public.workflow_review_request_authors](public.workflow_review_request_authors.md) [public.workflow_review_request_reviewers](public.workflow_review_request_reviewers.md) [public.workflow_suggestion](public.workflow_suggestion.md) [public.workflow_suggestion_activity](public.workflow_suggestion_activity.md) | | |
|
||||
| lastActiveAt | date | | true | | | |
|
||||
| lastName | varchar(32) | | true | | | |
|
||||
| mfaEnabled | boolean | false | false | | | |
|
||||
@@ -61,6 +61,7 @@ erDiagram
|
||||
"public.chat_hub_tools" }o--|| "public.user" : "FOREIGN KEY (#quot;ownerId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
|
||||
"public.dynamic_credential_user_entry" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
|
||||
"public.evaluation_collection" }o--o| "public.user" : "FOREIGN KEY (#quot;createdById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
|
||||
"public.idempotency_key" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
|
||||
"public.instance_ai_mcp_registry_connections" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
|
||||
"public.instance_ai_pending_confirmations" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
|
||||
"public.instance_ai_thread_grants" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
|
||||
@@ -268,6 +269,17 @@ erDiagram
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
varchar_36_ workflowId FK
|
||||
}
|
||||
"public.idempotency_key" {
|
||||
timestamp_3__with_time_zone createdAt
|
||||
text fingerprint
|
||||
varchar_16_ id
|
||||
varchar_128_ idempotencyKey
|
||||
json responseBody
|
||||
smallint responseStatus
|
||||
varchar_16_ status
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
uuid userId FK
|
||||
}
|
||||
"public.instance_ai_mcp_registry_connections" {
|
||||
timestamp_3__with_time_zone createdAt
|
||||
varchar_36_ credentialId FK
|
||||
|
||||
Generated
+13
@@ -75,6 +75,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
|
||||
| [execution_metadata](execution_metadata.md) | 4 | | table |
|
||||
| [folder](folder.md) | 6 | | table |
|
||||
| [folder_tag](folder_tag.md) | 2 | | table |
|
||||
| [idempotency_key](idempotency_key.md) | 9 | | table |
|
||||
| [insights_by_period](insights_by_period.md) | 6 | | table |
|
||||
| [insights_metadata](insights_metadata.md) | 5 | | table |
|
||||
| [insights_raw](insights_raw.md) | 5 | | table |
|
||||
@@ -282,6 +283,7 @@ erDiagram
|
||||
"folder" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"folder_tag" |o--|| "tag_entity" : "FOREIGN KEY (tagId) REFERENCES tag_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"folder_tag" |o--|| "folder" : "FOREIGN KEY (folderId) REFERENCES folder (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"idempotency_key" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"insights_by_period" }o--|| "insights_metadata" : "FOREIGN KEY (metaId) REFERENCES insights_metadata (metaId) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"insights_metadata" }o--o| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
|
||||
"insights_metadata" }o--o| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
|
||||
@@ -1109,6 +1111,17 @@ erDiagram
|
||||
varchar_36_ folderId PK
|
||||
varchar_36_ tagId PK
|
||||
}
|
||||
"idempotency_key" {
|
||||
datetime_3_ createdAt
|
||||
TEXT fingerprint
|
||||
varchar_16_ id PK
|
||||
varchar_128_ idempotencyKey
|
||||
TEXT responseBody
|
||||
smallint responseStatus
|
||||
varchar_16_ status
|
||||
datetime_3_ updatedAt
|
||||
varchar userId FK
|
||||
}
|
||||
"insights_by_period" {
|
||||
INTEGER id
|
||||
INTEGER metaId FK
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
# idempotency_key
|
||||
|
||||
## Description
|
||||
|
||||
<details>
|
||||
<summary><strong>Table Definition</strong></summary>
|
||||
|
||||
```sql
|
||||
CREATE TABLE "idempotency_key" ("id" varchar(16) PRIMARY KEY NOT NULL, "userId" varchar NOT NULL, "idempotencyKey" varchar(128) NOT NULL, "fingerprint" text NOT NULL, "status" varchar(16) NOT NULL DEFAULT ('processing'), "responseStatus" smallint, "responseBody" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_idempotency_key_responseStatus" CHECK (("status" = 'processing' AND "responseStatus" IS NULL) OR ("status" = 'completed' AND "responseStatus" IS NOT NULL)), CONSTRAINT "CHK_idempotency_key_status" CHECK ("status" IN ('processing', 'completed')), CONSTRAINT "FK_idempotency_key_userId" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Columns
|
||||
|
||||
| Name | Type | Default | Nullable | Children | Parents | Comment |
|
||||
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
|
||||
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
|
||||
| fingerprint | TEXT | | false | | | |
|
||||
| id | varchar(16) | | false | | | |
|
||||
| idempotencyKey | varchar(128) | | false | | | |
|
||||
| responseBody | TEXT | | true | | | |
|
||||
| responseStatus | smallint | | true | | | |
|
||||
| status | varchar(16) | 'processing' | false | | | |
|
||||
| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
|
||||
| userId | varchar | | false | | [user](user.md) | |
|
||||
|
||||
## Constraints
|
||||
|
||||
| Name | Type | Definition |
|
||||
| ---- | ---- | ---------- |
|
||||
| - | CHECK | CHECK (("status" = 'processing' AND "responseStatus" IS NULL) OR ("status" = 'completed' AND "responseStatus" IS NOT NULL)) |
|
||||
| - | CHECK | CHECK ("status" IN ('processing', 'completed')) |
|
||||
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
|
||||
| id | PRIMARY KEY | PRIMARY KEY (id) |
|
||||
| sqlite_autoindex_idempotency_key_1 | PRIMARY KEY | PRIMARY KEY (id) |
|
||||
|
||||
## Indexes
|
||||
|
||||
| Name | Definition |
|
||||
| ---- | ---------- |
|
||||
| IDX_25289244569e505292cde510d7 | CREATE UNIQUE INDEX "IDX_25289244569e505292cde510d7" ON "idempotency_key" ("userId", "idempotencyKey") |
|
||||
| IDX_38536b1937c55d305af4d69a97 | CREATE INDEX "IDX_38536b1937c55d305af4d69a97" ON "idempotency_key" ("createdAt") |
|
||||
| sqlite_autoindex_idempotency_key_1 | PRIMARY KEY (id) |
|
||||
|
||||
## Relations
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
|
||||
"idempotency_key" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
|
||||
"idempotency_key" {
|
||||
datetime_3_ createdAt
|
||||
TEXT fingerprint
|
||||
varchar_16_ id PK
|
||||
varchar_128_ idempotencyKey
|
||||
TEXT responseBody
|
||||
smallint responseStatus
|
||||
varchar_16_ status
|
||||
datetime_3_ updatedAt
|
||||
varchar userId FK
|
||||
}
|
||||
"user" {
|
||||
datetime_3_ createdAt
|
||||
boolean disabled
|
||||
varchar_255_ email
|
||||
varchar_32_ firstName
|
||||
varchar id PK
|
||||
date lastActiveAt
|
||||
varchar_32_ lastName
|
||||
boolean mfaEnabled
|
||||
TEXT mfaRecoveryCodes
|
||||
TEXT mfaSecret
|
||||
varchar password
|
||||
TEXT personalizationAnswers
|
||||
varchar_128_ roleSlug FK
|
||||
TEXT settings
|
||||
datetime_3_ updatedAt
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> Generated by [tbls](https://github.com/k1LoW/tbls)
|
||||
Generated
+13
-1
@@ -19,7 +19,7 @@ CREATE TABLE "user" ("id" varchar PRIMARY KEY, "email" varchar(255), "firstName"
|
||||
| disabled | boolean | FALSE | false | | | |
|
||||
| email | varchar(255) | | true | | | |
|
||||
| firstName | varchar(32) | | true | | | |
|
||||
| id | varchar | | true | [activity_event](activity_event.md) [agent_eval_dataset](agent_eval_dataset.md) [agent_eval_rating](agent_eval_rating.md) [agent_eval_run](agent_eval_run.md) [agent_execution_threads](agent_execution_threads.md) [agent_history](agent_history.md) [ai_preference](ai_preference.md) [auth_identity](auth_identity.md) [chat_hub_agents](chat_hub_agents.md) [chat_hub_sessions](chat_hub_sessions.md) [chat_hub_tools](chat_hub_tools.md) [dynamic_credential_user_entry](dynamic_credential_user_entry.md) [evaluation_collection](evaluation_collection.md) [instance_ai_mcp_registry_connections](instance_ai_mcp_registry_connections.md) [instance_ai_pending_confirmations](instance_ai_pending_confirmations.md) [instance_ai_thread_grants](instance_ai_thread_grants.md) [instance_ai_thread_tabs](instance_ai_thread_tabs.md) [migration_workflow_owner](migration_workflow_owner.md) [oauth_access_tokens](oauth_access_tokens.md) [oauth_authorization_codes](oauth_authorization_codes.md) [oauth_refresh_tokens](oauth_refresh_tokens.md) [oauth_user_consents](oauth_user_consents.md) [project](project.md) [project_relation](project_relation.md) [self_healing_result](self_healing_result.md) [trusted_source_identity](trusted_source_identity.md) [user_api_keys](user_api_keys.md) [user_favorites](user_favorites.md) [workflow_builder_session](workflow_builder_session.md) [workflow_publish_history](workflow_publish_history.md) [workflow_review_activity](workflow_review_activity.md) [workflow_review_activity_comment](workflow_review_activity_comment.md) [workflow_review_request](workflow_review_request.md) [workflow_review_request_authors](workflow_review_request_authors.md) [workflow_review_request_reviewers](workflow_review_request_reviewers.md) [workflow_suggestion](workflow_suggestion.md) [workflow_suggestion_activity](workflow_suggestion_activity.md) | | |
|
||||
| id | varchar | | true | [activity_event](activity_event.md) [agent_eval_dataset](agent_eval_dataset.md) [agent_eval_rating](agent_eval_rating.md) [agent_eval_run](agent_eval_run.md) [agent_execution_threads](agent_execution_threads.md) [agent_history](agent_history.md) [ai_preference](ai_preference.md) [auth_identity](auth_identity.md) [chat_hub_agents](chat_hub_agents.md) [chat_hub_sessions](chat_hub_sessions.md) [chat_hub_tools](chat_hub_tools.md) [dynamic_credential_user_entry](dynamic_credential_user_entry.md) [evaluation_collection](evaluation_collection.md) [idempotency_key](idempotency_key.md) [instance_ai_mcp_registry_connections](instance_ai_mcp_registry_connections.md) [instance_ai_pending_confirmations](instance_ai_pending_confirmations.md) [instance_ai_thread_grants](instance_ai_thread_grants.md) [instance_ai_thread_tabs](instance_ai_thread_tabs.md) [migration_workflow_owner](migration_workflow_owner.md) [oauth_access_tokens](oauth_access_tokens.md) [oauth_authorization_codes](oauth_authorization_codes.md) [oauth_refresh_tokens](oauth_refresh_tokens.md) [oauth_user_consents](oauth_user_consents.md) [project](project.md) [project_relation](project_relation.md) [self_healing_result](self_healing_result.md) [trusted_source_identity](trusted_source_identity.md) [user_api_keys](user_api_keys.md) [user_favorites](user_favorites.md) [workflow_builder_session](workflow_builder_session.md) [workflow_publish_history](workflow_publish_history.md) [workflow_review_activity](workflow_review_activity.md) [workflow_review_activity_comment](workflow_review_activity_comment.md) [workflow_review_request](workflow_review_request.md) [workflow_review_request_authors](workflow_review_request_authors.md) [workflow_review_request_reviewers](workflow_review_request_reviewers.md) [workflow_suggestion](workflow_suggestion.md) [workflow_suggestion_activity](workflow_suggestion_activity.md) | | |
|
||||
| lastActiveAt | date | | true | | | |
|
||||
| lastName | varchar(32) | | true | | | |
|
||||
| mfaEnabled | boolean | FALSE | false | | | |
|
||||
@@ -67,6 +67,7 @@ erDiagram
|
||||
"chat_hub_tools" }o--|| "user" : "FOREIGN KEY (ownerId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"dynamic_credential_user_entry" |o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"evaluation_collection" }o--o| "user" : "FOREIGN KEY (createdById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
|
||||
"idempotency_key" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"instance_ai_mcp_registry_connections" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"instance_ai_pending_confirmations" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"instance_ai_thread_grants" |o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
@@ -274,6 +275,17 @@ erDiagram
|
||||
datetime_3_ updatedAt
|
||||
varchar_36_ workflowId FK
|
||||
}
|
||||
"idempotency_key" {
|
||||
datetime_3_ createdAt
|
||||
TEXT fingerprint
|
||||
varchar_16_ id PK
|
||||
varchar_128_ idempotencyKey
|
||||
TEXT responseBody
|
||||
smallint responseStatus
|
||||
varchar_16_ status
|
||||
datetime_3_ updatedAt
|
||||
varchar userId FK
|
||||
}
|
||||
"instance_ai_mcp_registry_connections" {
|
||||
datetime_3_ createdAt
|
||||
varchar_36_ credentialId FK
|
||||
|
||||
@@ -43,6 +43,13 @@ describe('criteria judge', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('asks for a short reason of one or two sentences', () => {
|
||||
const prompt = buildCriteriaPrompt('the question', 'the answer', 'the rule');
|
||||
|
||||
expect(prompt).toContain('Keep the reasoning to 1 or 2 short sentences');
|
||||
expect(prompt).toContain('"reasoning": "<1-2 short sentences>"');
|
||||
});
|
||||
|
||||
it('puts the rule, the user message and the response in the prompt', () => {
|
||||
const prompt = buildCriteriaPrompt('the question', 'the answer', 'the rule');
|
||||
|
||||
|
||||
@@ -25,7 +25,9 @@ export function buildCriteriaPrompt(input: string, output: string, criteria: str
|
||||
'- pass = the response satisfies the rule',
|
||||
'- fail = the response breaks the rule, or does not do what the rule requires',
|
||||
'',
|
||||
'Respond with ONLY a JSON object (no markdown fences): {"pass": true/false, "reasoning": "<explanation>"}',
|
||||
'Keep the reasoning to 1 or 2 short sentences: say why the response passes or fails the rule, with no preamble and no quotes of the response.',
|
||||
'',
|
||||
'Respond with ONLY a JSON object (no markdown fences): {"pass": true/false, "reasoning": "<1-2 short sentences>"}',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ export type {
|
||||
export { FINISH_REASONS, isFinishReason } from './types';
|
||||
export type { ProviderOptions } from '@ai-sdk/provider-utils';
|
||||
export { AgentEvent } from './types';
|
||||
export type { AgentEventData, AgentEventHandler } from './types';
|
||||
export type { AgentEventData, AgentEventHandler, ForwardedChildChunk } from './types';
|
||||
export {
|
||||
OBSERVATION_LOG_MARKERS,
|
||||
OBSERVATION_LOG_STATUSES,
|
||||
|
||||
@@ -62,6 +62,30 @@ describe('AgentChatMessageDto', () => {
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts an object client context and keeps it in the parsed payload', () => {
|
||||
const clientContext = { timeZone: 'Europe/Helsinki', nested: { ids: ['a', 'b'] }, flag: null };
|
||||
const payload = { message: 'hello', clientContext };
|
||||
expect(AgentChatMessageDto.parse(payload)).toEqual(payload);
|
||||
expect(AgentChatMessageDto.safeParse({ message: 'hello', clientContext: {} }).success).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a payload without a client context', () => {
|
||||
const parsed = AgentChatMessageDto.parse({ message: 'hello' });
|
||||
expect(parsed).not.toHaveProperty('clientContext');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['null', null],
|
||||
['an array', [{ timeZone: 'UTC' }]],
|
||||
['a string', 'UTC'],
|
||||
['a number', 1],
|
||||
['a boolean', true],
|
||||
])('rejects a client context that is %s', (_label, clientContext) => {
|
||||
expect(AgentChatMessageDto.safeParse({ message: 'hello', clientContext }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects attachments with empty fileName or mimeType', () => {
|
||||
expect(
|
||||
AgentChatMessageDto.safeParse({
|
||||
|
||||
@@ -258,6 +258,12 @@ const agentChatMessageShape = {
|
||||
.array(agentChatAttachmentSchema)
|
||||
.max(MAX_AGENT_CHAT_ATTACHMENTS_PER_MESSAGE)
|
||||
.optional(),
|
||||
/**
|
||||
* Per-message context from the client (for example the user's time zone).
|
||||
* The Agents layer does not read it. A system agent's provider defines its
|
||||
* shape and validates it. Project agents ignore it.
|
||||
*/
|
||||
clientContext: z.record(z.unknown()).optional(),
|
||||
};
|
||||
|
||||
const agentChatMessageSchema = z
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class AssignMigrationOwnerDto extends Z.class({
|
||||
userId: z.string().min(1),
|
||||
}) {}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { jsonValueSchema } from '../../schemas/json-value.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
const jsonObjectSchema = z.object({}).catchall(jsonValueSchema);
|
||||
const credentialDataSchema: z.ZodType<ICredentialDataDecryptedObject> = z.record(
|
||||
z.string(),
|
||||
z.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.array(z.string()),
|
||||
jsonObjectSchema,
|
||||
z.array(jsonObjectSchema),
|
||||
]),
|
||||
);
|
||||
|
||||
export class CredentialOptionsRequestDto extends Z.class({
|
||||
type: z.string().min(1),
|
||||
data: credentialDataSchema,
|
||||
propertyName: z.string().min(1),
|
||||
filter: z.string().optional(),
|
||||
paginationToken: z.string().optional(),
|
||||
}) {}
|
||||
@@ -54,6 +54,7 @@ export type { DiscoverDataPublic } from './discover/discover-public.dto';
|
||||
|
||||
export { BreakingChangeReportQueryDto } from './breaking-changes/breaking-change-report-query.dto';
|
||||
export { UpdateMigrationFindingStatusRequestDto } from './breaking-changes/update-migration-finding-status-request.dto';
|
||||
export { AssignMigrationOwnerDto } from './breaking-changes/assign-migration-owner.dto';
|
||||
|
||||
export {
|
||||
AuditPublicDto,
|
||||
@@ -167,6 +168,7 @@ export { SourceControlPullResponsePublicDto } from './source-control/source-cont
|
||||
|
||||
export { CreateCredentialDto } from './credentials/create-credential.dto';
|
||||
export { TestCredentialRequestDto } from './credentials/test-credential-request.dto';
|
||||
export { CredentialOptionsRequestDto } from './credentials/credential-options-request.dto';
|
||||
export {
|
||||
CredentialPublicDto,
|
||||
CredentialListPublicDto,
|
||||
@@ -352,26 +354,49 @@ export {
|
||||
export { PaginationDto, MAX_ITEMS_PER_PAGE } from './pagination/pagination.dto';
|
||||
|
||||
export type {
|
||||
NodeTypePolicyRule,
|
||||
NodeTypePolicySelector,
|
||||
NonDelegatingPolicyAction,
|
||||
NonDelegatingPolicyRule,
|
||||
PolicyAction,
|
||||
PolicyRule,
|
||||
PolicySelector,
|
||||
} from './type-availability-policies/policy-rule.types';
|
||||
export { policySelectorSchema } from './type-availability-policies/policy-selector.schema';
|
||||
export { isNodeTypePolicyRule } from './type-availability-policies/policy-rule.types';
|
||||
export {
|
||||
credentialTypePolicySelectorSchema,
|
||||
nodeTypePolicySelectorSchema,
|
||||
} from './type-availability-policies/policy-selector.schema';
|
||||
export {
|
||||
credentialTypePolicyRuleSchemas,
|
||||
nodeTypePolicyRuleSchemas,
|
||||
nonDelegatingPolicyActionSchema,
|
||||
nonDelegatingPolicyRuleListSchema,
|
||||
policyActionSchema,
|
||||
policyRuleListSchema,
|
||||
policyRuleSchema,
|
||||
} from './type-availability-policies/policy-rule.schema';
|
||||
export { PutInstancePolicyDto } from './type-availability-policies/put-instance-policy.dto';
|
||||
export { PutProjectPolicyDto } from './type-availability-policies/put-project-policy.dto';
|
||||
export { CreatePolicyDocumentDto } from './type-availability-policies/create-policy-document.dto';
|
||||
export { UpdatePolicyDocumentDto } from './type-availability-policies/update-policy-document.dto';
|
||||
export {
|
||||
PutCredentialTypeInstancePolicyDto,
|
||||
PutInstancePolicyDto,
|
||||
} from './type-availability-policies/put-instance-policy.dto';
|
||||
export {
|
||||
PutCredentialTypeProjectPolicyDto,
|
||||
PutProjectPolicyDto,
|
||||
} from './type-availability-policies/put-project-policy.dto';
|
||||
export {
|
||||
CreateCredentialTypePolicyDocumentDto,
|
||||
CreatePolicyDocumentDto,
|
||||
} from './type-availability-policies/create-policy-document.dto';
|
||||
export {
|
||||
UpdateCredentialTypePolicyDocumentDto,
|
||||
UpdatePolicyDocumentDto,
|
||||
} from './type-availability-policies/update-policy-document.dto';
|
||||
export { ReplaceAttachmentsDto } from './type-availability-policies/replace-attachments.dto';
|
||||
export {
|
||||
CredentialTypePolicyAttachmentsPublicDto,
|
||||
CredentialTypePolicyDocumentListPublicDto,
|
||||
CredentialTypePolicyDocumentPublicDto,
|
||||
CredentialTypePolicyDocumentWriteResultPublicDto,
|
||||
CredentialTypePolicyEffectivePublicDto,
|
||||
CredentialTypePolicyEffectiveWriteResultPublicDto,
|
||||
ListNodeTypePolicyDocumentsQueryDto,
|
||||
PolicyAttachmentsPublicDto,
|
||||
PolicyDocumentListPublicDto,
|
||||
|
||||
@@ -40,6 +40,26 @@ describe('UpdateOtelSettingsDto', () => {
|
||||
expect(result.data.exporterProtocol).toBe('http/protobuf');
|
||||
});
|
||||
|
||||
it('defaults the start span flags to false when omitted (body predates the fields)', () => {
|
||||
const result = UpdateOtelSettingsDto.safeParse(validSettings);
|
||||
|
||||
assert(result.success, 'Expected a body without the start span flags to stay valid');
|
||||
expect(result.data.emitWorkflowStartSpan).toBe(false);
|
||||
expect(result.data.emitNodeStartSpan).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts the start span flags', () => {
|
||||
const result = UpdateOtelSettingsDto.safeParse({
|
||||
...validSettings,
|
||||
emitWorkflowStartSpan: true,
|
||||
emitNodeStartSpan: false,
|
||||
});
|
||||
|
||||
assert(result.success, 'Expected the start span flags to be valid');
|
||||
expect(result.data.emitWorkflowStartSpan).toBe(true);
|
||||
expect(result.data.emitNodeStartSpan).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a full body', () => {
|
||||
const result = UpdateOtelSettingsDto.safeParse(validSettings);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
@@ -20,6 +20,8 @@ export class OtelSettingsPublicDto extends Z.class(
|
||||
tracesSampleRate: z.number().openapi(docs.tracesSampleRate),
|
||||
startupConnectivityTimeoutMs: z.number().openapi(docs.startupConnectivityTimeoutMs),
|
||||
includeNodeSpans: z.boolean().openapi(docs.includeNodeSpans),
|
||||
emitWorkflowStartSpan: z.boolean().openapi(docs.emitWorkflowStartSpan),
|
||||
emitNodeStartSpan: z.boolean().openapi(docs.emitNodeStartSpan),
|
||||
injectOutbound: z.boolean().openapi(docs.injectOutbound),
|
||||
productionExecutionsOnly: z.boolean().openapi(docs.productionExecutionsOnly),
|
||||
},
|
||||
@@ -43,6 +45,8 @@ export class UpdateOtelSettingsPublicDto extends Z.class(
|
||||
docs.startupConnectivityTimeoutMs,
|
||||
),
|
||||
includeNodeSpans: fields.includeNodeSpans.openapi(docs.includeNodeSpans),
|
||||
emitWorkflowStartSpan: fields.emitWorkflowStartSpan.openapi(docs.emitWorkflowStartSpan),
|
||||
emitNodeStartSpan: fields.emitNodeStartSpan.openapi(docs.emitNodeStartSpan),
|
||||
injectOutbound: fields.injectOutbound.openapi(docs.injectOutbound),
|
||||
productionExecutionsOnly: fields.productionExecutionsOnly.openapi(
|
||||
docs.productionExecutionsOnly,
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { ZodOpenAPIMetadata } from '@asteasolutions/zod-to-openapi';
|
||||
export const otelSettingsPublicDescription =
|
||||
'The OpenTelemetry configuration, matching the fields exposed in the UI. On a write this is a ' +
|
||||
'full replacement: every field must be provided, except `exporterProtocol`, which defaults to ' +
|
||||
'`http/protobuf` when omitted. Fields managed declaratively via environment variables are ' +
|
||||
'`http/protobuf` when omitted, and `emitWorkflowStartSpan` and `emitNodeStartSpan`, which ' +
|
||||
'default to `false` when omitted. Fields managed declaratively via environment variables are ' +
|
||||
'returned with their effective value. A write can repeat these values, but changing them returns 409.';
|
||||
|
||||
export const otelSettingsPublicFieldDocs = {
|
||||
@@ -63,6 +64,21 @@ export const otelSettingsPublicFieldDocs = {
|
||||
description: 'Whether to emit a span for each node execution in addition to the workflow span.',
|
||||
example: true,
|
||||
},
|
||||
emitWorkflowStartSpan: {
|
||||
description:
|
||||
'Whether to emit a `workflow.execute.started` span with a duration of 0 ms when each ' +
|
||||
'execution or resumed segment starts. Optional: a write that omits it selects `false`, but an ' +
|
||||
'instance that sets this field to `true` with an environment variable rejects that write with 409.',
|
||||
example: false,
|
||||
},
|
||||
emitNodeStartSpan: {
|
||||
description:
|
||||
'Whether to emit a `node.execute.started` span with a duration of 0 ms when each node span ' +
|
||||
'starts. This doubles the number of node spans. Has no effect when `includeNodeSpans` is ' +
|
||||
'false. Optional: a write that omits it selects `false`, but an instance that sets this field ' +
|
||||
'to `true` with an environment variable rejects that write with 409.',
|
||||
example: false,
|
||||
},
|
||||
injectOutbound: {
|
||||
description:
|
||||
'Whether to inject trace context headers into outbound HTTP requests made by nodes.',
|
||||
|
||||
@@ -25,6 +25,9 @@ export class UpdateOtelSettingsDto extends Z.class({
|
||||
tracesSampleRate: z.number().min(0).max(1),
|
||||
startupConnectivityTimeoutMs: z.number().int().nonnegative(),
|
||||
includeNodeSpans: z.boolean(),
|
||||
// Defaulted so a body from before these fields existed still parses.
|
||||
emitWorkflowStartSpan: z.boolean().default(false),
|
||||
emitNodeStartSpan: z.boolean().default(false),
|
||||
injectOutbound: z.boolean(),
|
||||
productionExecutionsOnly: z.boolean(),
|
||||
}) {}
|
||||
|
||||
+25
-64
@@ -1,29 +1,21 @@
|
||||
import { ExportPackageRequestDto } from '../export-package-request.dto';
|
||||
|
||||
describe('ExportPackageRequestDto', () => {
|
||||
describe('workflowIds', () => {
|
||||
it('accepts a non-empty array of workflow ids', () => {
|
||||
const result = ExportPackageRequestDto.safeParse({ workflowIds: ['wf-1', 'wf-2'] });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts up to 300 workflow ids', () => {
|
||||
const workflowIds = Array.from({ length: 300 }, (_, i) => `wf-${i}`);
|
||||
expect(ExportPackageRequestDto.safeParse({ workflowIds }).success).toBe(true);
|
||||
describe.each(['workflowIds', 'agentIds'] as const)('%s', (field) => {
|
||||
it('accepts and trims up to 300 IDs', () => {
|
||||
const ids = Array.from({ length: 300 }, (_, index) => `id-${index}`);
|
||||
const parsed = ExportPackageRequestDto.parse({ [field]: ids.map((id) => ` ${id} `) });
|
||||
expect(parsed[field]).toEqual(ids);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: 'empty workflowIds array', request: { workflowIds: [] } },
|
||||
{ name: 'both empty arrays', request: { workflowIds: [], projectIds: [] } },
|
||||
{ name: 'empty-string workflow id', request: { workflowIds: [''] } },
|
||||
{ name: 'whitespace-only workflow id', request: { workflowIds: [' '] } },
|
||||
{ name: 'non-string workflow id', request: { workflowIds: [123] } },
|
||||
{
|
||||
name: 'more than 300 workflow ids',
|
||||
request: { workflowIds: Array.from({ length: 301 }, (_, i) => `wf-${i}`) },
|
||||
},
|
||||
])('rejects $name', ({ request }) => {
|
||||
expect(ExportPackageRequestDto.safeParse(request).success).toBe(false);
|
||||
{ name: 'empty array', ids: [] },
|
||||
{ name: 'empty ID', ids: [''] },
|
||||
{ name: 'whitespace ID', ids: [' '] },
|
||||
{ name: 'non-string ID', ids: [123] },
|
||||
{ name: 'more than 300 IDs', ids: Array.from({ length: 301 }, (_, i) => `id-${i}`) },
|
||||
])('rejects $name', ({ ids }) => {
|
||||
expect(ExportPackageRequestDto.safeParse({ [field]: ids }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -144,67 +136,36 @@ describe('ExportPackageRequestDto', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('missingWorkflowDependencyPolicy', () => {
|
||||
it.each(['fail', 'reference-only', 'include-in-package'])(
|
||||
'accepts %s',
|
||||
(missingWorkflowDependencyPolicy) => {
|
||||
const result = ExportPackageRequestDto.safeParse({
|
||||
workflowIds: ['wf-1'],
|
||||
missingWorkflowDependencyPolicy,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
},
|
||||
);
|
||||
describe('dependencyPolicy', () => {
|
||||
it.each(['fail', 'reference-only', 'include-in-package'])('accepts %s', (dependencyPolicy) => {
|
||||
expect(ExportPackageRequestDto.parse({ dependencyPolicy }).dependencyPolicy).toBe(
|
||||
dependencyPolicy,
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults to fail', () => {
|
||||
const result = ExportPackageRequestDto.safeParse({ workflowIds: ['wf-1'] });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.missingWorkflowDependencyPolicy).toBe('fail');
|
||||
}
|
||||
expect(ExportPackageRequestDto.parse({}).dependencyPolicy).toBe('fail');
|
||||
});
|
||||
|
||||
it('rejects unknown values', () => {
|
||||
const result = ExportPackageRequestDto.safeParse({
|
||||
workflowIds: ['wf-1'],
|
||||
missingWorkflowDependencyPolicy: 'skip',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(ExportPackageRequestDto.safeParse({ dependencyPolicy: 'skip' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflowVersionPolicy', () => {
|
||||
describe('versionPolicy', () => {
|
||||
it.each(['published-strict', 'prefer-published', 'ignore-unpublished', 'latest'])(
|
||||
'accepts %s',
|
||||
(workflowVersionPolicy) => {
|
||||
const result = ExportPackageRequestDto.safeParse({
|
||||
workflowIds: ['wf-1'],
|
||||
workflowVersionPolicy,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
(versionPolicy) => {
|
||||
expect(ExportPackageRequestDto.parse({ versionPolicy }).versionPolicy).toBe(versionPolicy);
|
||||
},
|
||||
);
|
||||
|
||||
it('defaults to latest', () => {
|
||||
const result = ExportPackageRequestDto.safeParse({ workflowIds: ['wf-1'] });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.workflowVersionPolicy).toBe('latest');
|
||||
}
|
||||
expect(ExportPackageRequestDto.parse({}).versionPolicy).toBe('latest');
|
||||
});
|
||||
|
||||
it('rejects unknown values', () => {
|
||||
const result = ExportPackageRequestDto.safeParse({
|
||||
workflowIds: ['wf-1'],
|
||||
workflowVersionPolicy: 'published',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(ExportPackageRequestDto.safeParse({ versionPolicy: 'published' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,16 +3,17 @@ import { z } from 'zod';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class ExportPackageRequestDto extends Z.class({
|
||||
agentIds: z.array(z.string().trim().min(1)).min(1).max(300).optional(),
|
||||
workflowIds: z.array(z.string().trim().min(1)).min(1).max(300).optional(),
|
||||
folderIds: z.array(z.string().trim().min(1)).min(1).max(300).optional(),
|
||||
projectIds: z.array(z.string().trim().min(1)).min(1).max(300).optional(),
|
||||
includeVariableValues: z.boolean().default(true),
|
||||
includeTags: z.boolean().default(true),
|
||||
missingWorkflowDependencyPolicy: z
|
||||
dependencyPolicy: z
|
||||
.enum(['fail', 'reference-only', 'include-in-package'])
|
||||
.optional()
|
||||
.default('fail'),
|
||||
workflowVersionPolicy: z
|
||||
versionPolicy: z
|
||||
.enum(['published-strict', 'prefer-published', 'ignore-unpublished', 'latest'])
|
||||
.optional()
|
||||
.default('latest'),
|
||||
|
||||
@@ -299,3 +299,15 @@ export const policyViolationIssueOpenApi: ZodOpenAPIMetadata = {
|
||||
'workflow would get, so dropping one leaves the workflows that call it pointing at a row ' +
|
||||
'nothing ever wrote.',
|
||||
};
|
||||
|
||||
export const credentialPolicyViolationFieldDocs = {
|
||||
sourceId: { description: 'Credential id as it appears in the package.' },
|
||||
usedBy: { description: 'Package consumers that use this credential.' },
|
||||
} as const satisfies Record<string, ZodOpenAPIMetadata>;
|
||||
|
||||
export const credentialPolicyViolationIssueOpenApi: ZodOpenAPIMetadata = {
|
||||
description:
|
||||
'A credential the import would write that the credential-save policy refused, for ' +
|
||||
'example a stub of a credential type this instance does not allow. The import stops ' +
|
||||
'before any writes.',
|
||||
};
|
||||
|
||||
@@ -3,6 +3,8 @@ import '../../openapi-extend';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
credentialPolicyViolationFieldDocs,
|
||||
credentialPolicyViolationIssueOpenApi,
|
||||
credentialUnresolvedFieldDocs,
|
||||
credentialUnresolvedIssueOpenApi,
|
||||
dataTableSchemaOperationOpenApi,
|
||||
@@ -347,6 +349,19 @@ const policyViolationIssueSchema = z
|
||||
})
|
||||
.openapi(policyViolationIssueOpenApi);
|
||||
|
||||
const credentialPolicyViolationIssueSchema = z
|
||||
.object({
|
||||
type: z.literal('credential-policy-violation'),
|
||||
sourceId: z.string().openapi(credentialPolicyViolationFieldDocs.sourceId),
|
||||
name: z.string().optional(),
|
||||
credentialType: z.string(),
|
||||
usedBy: z
|
||||
.array(packageRequirementConsumerSchema)
|
||||
.openapi(credentialPolicyViolationFieldDocs.usedBy),
|
||||
violations: z.array(policyViolationSchema),
|
||||
})
|
||||
.openapi(credentialPolicyViolationIssueOpenApi);
|
||||
|
||||
export const importBlockingIssueSchema = z
|
||||
.discriminatedUnion('type', [
|
||||
workflowConflictIssueSchema,
|
||||
@@ -367,6 +382,7 @@ export const importBlockingIssueSchema = z
|
||||
variableLimitExceededIssueSchema,
|
||||
missingNodeTypeIssueSchema,
|
||||
policyViolationIssueSchema,
|
||||
credentialPolicyViolationIssueSchema,
|
||||
])
|
||||
.openapi('ImportBlockingIssue');
|
||||
|
||||
|
||||
+36
-3
@@ -1,11 +1,16 @@
|
||||
import {
|
||||
credentialTypePolicyRuleSchemas,
|
||||
nodeTypePolicyRuleSchemas,
|
||||
nonDelegatingPolicyActionSchema,
|
||||
nonDelegatingPolicyRuleListSchema,
|
||||
policyActionSchema,
|
||||
policyRuleListSchema,
|
||||
policyRuleSchema,
|
||||
} from '../policy-rule.schema';
|
||||
|
||||
const {
|
||||
rule: policyRuleSchema,
|
||||
ruleList: policyRuleListSchema,
|
||||
nonDelegatingRuleList: nonDelegatingPolicyRuleListSchema,
|
||||
} = nodeTypePolicyRuleSchemas;
|
||||
|
||||
const nameSelector = { kind: 'name' as const, value: 'n8n-nodes-base.slack' };
|
||||
|
||||
describe('policyActionSchema', () => {
|
||||
@@ -105,3 +110,31 @@ describe('nonDelegatingPolicyRuleListSchema', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('rule schemas per kind', () => {
|
||||
const extendsRule = {
|
||||
id: 'r1',
|
||||
action: 'deny',
|
||||
selector: { kind: 'extends', value: 'oAuth2Api' },
|
||||
};
|
||||
|
||||
it('rejects an extends rule in a node type policy', () => {
|
||||
expect(nodeTypePolicyRuleSchemas.ruleList.safeParse([extendsRule]).success).toBe(false);
|
||||
expect(nodeTypePolicyRuleSchemas.nonDelegatingRuleList.safeParse([extendsRule]).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts an extends rule in a credential type policy', () => {
|
||||
expect(credentialTypePolicyRuleSchemas.ruleList.safeParse([extendsRule]).success).toBe(true);
|
||||
expect(
|
||||
credentialTypePolicyRuleSchemas.nonDelegatingRuleList.safeParse([extendsRule]).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects duplicate rule ids in a credential type policy', () => {
|
||||
expect(
|
||||
credentialTypePolicyRuleSchemas.ruleList.safeParse([extendsRule, extendsRule]).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { isNodeTypePolicyRule } from '../policy-rule.types';
|
||||
|
||||
describe('isNodeTypePolicyRule', () => {
|
||||
it.each(['name', 'package'] as const)('accepts a %s rule', (kind) => {
|
||||
expect(isNodeTypePolicyRule({ id: 'r1', action: 'deny', selector: { kind, value: 'x' } })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an extends rule, which only credential type policies accept', () => {
|
||||
expect(
|
||||
isNodeTypePolicyRule({
|
||||
id: 'r1',
|
||||
action: 'deny',
|
||||
selector: { kind: 'extends', value: 'oAuth2Api' },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
+25
-11
@@ -1,27 +1,41 @@
|
||||
import { policySelectorSchema } from '../policy-selector.schema';
|
||||
import {
|
||||
credentialTypePolicySelectorSchema,
|
||||
nodeTypePolicySelectorSchema,
|
||||
} from '../policy-selector.schema';
|
||||
|
||||
describe('policySelectorSchema', () => {
|
||||
describe.each([
|
||||
['nodeTypePolicySelectorSchema', nodeTypePolicySelectorSchema],
|
||||
['credentialTypePolicySelectorSchema', credentialTypePolicySelectorSchema],
|
||||
] as const)('%s', (_, schema) => {
|
||||
it('accepts a valid name selector', () => {
|
||||
expect(
|
||||
policySelectorSchema.safeParse({ kind: 'name', value: 'n8n-nodes-base.slack' }).success,
|
||||
).toBe(true);
|
||||
expect(schema.safeParse({ kind: 'name', value: 'n8n-nodes-base.slack' }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a valid package selector', () => {
|
||||
expect(
|
||||
policySelectorSchema.safeParse({ kind: 'package', value: 'n8n-nodes-base' }).success,
|
||||
).toBe(true);
|
||||
expect(schema.safeParse({ kind: 'package', value: 'n8n-nodes-base' }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an empty value', () => {
|
||||
expect(policySelectorSchema.safeParse({ kind: 'name', value: '' }).success).toBe(false);
|
||||
expect(schema.safeParse({ kind: 'name', value: '' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an unknown kind', () => {
|
||||
expect(policySelectorSchema.safeParse({ kind: 'glob', value: '*' }).success).toBe(false);
|
||||
expect(schema.safeParse({ kind: 'glob', value: '*' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a selector missing its value', () => {
|
||||
expect(policySelectorSchema.safeParse({ kind: 'name' }).success).toBe(false);
|
||||
expect(schema.safeParse({ kind: 'name' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extends selector', () => {
|
||||
const selector = { kind: 'extends', value: 'oAuth2Api' };
|
||||
|
||||
it('is rejected by the node type schema', () => {
|
||||
expect(nodeTypePolicySelectorSchema.safeParse(selector).success).toBe(false);
|
||||
});
|
||||
|
||||
it('is accepted by the credential type schema', () => {
|
||||
expect(credentialTypePolicySelectorSchema.safeParse(selector).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+8
-3
@@ -1,7 +1,12 @@
|
||||
import { policyRuleListSchema } from './policy-rule.schema';
|
||||
import { credentialTypePolicyRuleSchemas, nodeTypePolicyRuleSchemas } from './policy-rule.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
/** Request body for creating a policy document. */
|
||||
/** Request body for creating a node type policy document. */
|
||||
export class CreatePolicyDocumentDto extends Z.class({
|
||||
rules: policyRuleListSchema,
|
||||
rules: nodeTypePolicyRuleSchemas.ruleList,
|
||||
}) {}
|
||||
|
||||
/** Request body for creating a credential type policy document. */
|
||||
export class CreateCredentialTypePolicyDocumentDto extends Z.class({
|
||||
rules: credentialTypePolicyRuleSchemas.ruleList,
|
||||
}) {}
|
||||
|
||||
+79
-55
@@ -1,14 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { policyActionSchema, policyRuleSchema } from './policy-rule.schema';
|
||||
import {
|
||||
credentialTypePolicyRuleSchemas,
|
||||
nodeTypePolicyRuleSchemas,
|
||||
policyActionSchema,
|
||||
} from './policy-rule.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
import { publicApiPaginationSchema } from '../pagination/pagination.dto';
|
||||
|
||||
/**
|
||||
* Response shapes for the public policy routes, shared by every policy `kind` (node types
|
||||
* today, credential types later). Request bodies reuse the internal DTOs
|
||||
* (`PutInstancePolicyDto`, `PutProjectPolicyDto`, …) so validation is identical on both
|
||||
* surfaces; only the responses need a public allowlist.
|
||||
* Response shapes for the public policy routes, one set per policy `kind` because each kind
|
||||
* accepts different selectors. Request bodies reuse the internal DTOs (`PutInstancePolicyDto`,
|
||||
* `PutCredentialTypeInstancePolicyDto`, …), so validation is identical on both surfaces.
|
||||
*/
|
||||
|
||||
/** A rule that never matches because an earlier rule in the same list already covers it. */
|
||||
@@ -17,65 +20,86 @@ const shadowWarningSchema = z.object({
|
||||
shadowedByRuleId: z.string(),
|
||||
});
|
||||
|
||||
/** Plain array on purpose: the response never re-runs the duplicate-id refinement. */
|
||||
const policyRulesSchema = z.array(policyRuleSchema);
|
||||
/** Plain rule arrays on purpose: a response never re-runs the duplicate-id refinement. */
|
||||
function publicSchemasFor<Rule extends z.ZodTypeAny>(rule: Rule) {
|
||||
const rules = z.array(rule);
|
||||
|
||||
const effectivePolicySchema = z.object({
|
||||
// `null` until the scope is first written; the scope then reports `version: 0`.
|
||||
scopeId: z.string().nullable(),
|
||||
rules: policyRulesSchema,
|
||||
defaultAction: policyActionSchema,
|
||||
version: z.number().int().nonnegative(),
|
||||
});
|
||||
const effectivePolicy = z.object({
|
||||
// `null` until the scope is first written; the scope then reports `version: 0`.
|
||||
scopeId: z.string().nullable(),
|
||||
rules,
|
||||
defaultAction: policyActionSchema,
|
||||
version: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
/** `GET` of a scope's composed policy. */
|
||||
export class PolicyEffectivePublicDto extends Z.class(effectivePolicySchema.shape) {}
|
||||
const policyDocument = z.object({
|
||||
id: z.string(),
|
||||
kind: z.string(),
|
||||
rules,
|
||||
version: z.number().int().positive(),
|
||||
/** A user id, or the literal `environment` for env-bootstrap writes. */
|
||||
updatedBy: z.string(),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
/** `PUT` of a scope's composed policy: the new state plus shadowing warnings. */
|
||||
export class PolicyEffectiveWriteResultPublicDto extends Z.class({
|
||||
...effectivePolicySchema.shape,
|
||||
warnings: z.array(shadowWarningSchema),
|
||||
}) {}
|
||||
const attachment = z.object({
|
||||
policyId: z.string(),
|
||||
rules,
|
||||
priority: z.number().int(),
|
||||
isFloor: z.boolean(),
|
||||
});
|
||||
|
||||
const policyDocumentSchema = z.object({
|
||||
id: z.string(),
|
||||
kind: z.string(),
|
||||
rules: policyRulesSchema,
|
||||
version: z.number().int().positive(),
|
||||
/** A user id, or the literal `environment` for env-bootstrap writes. */
|
||||
updatedBy: z.string(),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
});
|
||||
return {
|
||||
effective: effectivePolicy.shape,
|
||||
effectiveWriteResult: { ...effectivePolicy.shape, warnings: z.array(shadowWarningSchema) },
|
||||
document: policyDocument.shape,
|
||||
documentWriteResult: { policy: policyDocument, warnings: z.array(shadowWarningSchema) },
|
||||
documentList: { data: z.array(policyDocument), nextCursor: z.string().nullable() },
|
||||
// The scope's version after a replace, which always bumps it.
|
||||
attachments: { attachments: z.array(attachment), version: z.number().int().positive() },
|
||||
};
|
||||
}
|
||||
|
||||
/** One reusable policy document. */
|
||||
export class PolicyDocumentPublicDto extends Z.class(policyDocumentSchema.shape) {}
|
||||
const nodeType = publicSchemasFor(nodeTypePolicyRuleSchemas.rule);
|
||||
const credentialType = publicSchemasFor(credentialTypePolicyRuleSchemas.rule);
|
||||
|
||||
/** Create or update of a policy document: the document plus shadowing warnings. */
|
||||
export class PolicyDocumentWriteResultPublicDto extends Z.class({
|
||||
policy: policyDocumentSchema,
|
||||
warnings: z.array(shadowWarningSchema),
|
||||
}) {}
|
||||
/** `GET` of a scope's composed node type policy. */
|
||||
export class PolicyEffectivePublicDto extends Z.class(nodeType.effective) {}
|
||||
|
||||
export class PolicyDocumentListPublicDto extends Z.class({
|
||||
data: z.array(policyDocumentSchema),
|
||||
nextCursor: z.string().nullable(),
|
||||
}) {}
|
||||
/** `PUT` of a scope's composed node type policy: the new state plus shadowing warnings. */
|
||||
export class PolicyEffectiveWriteResultPublicDto extends Z.class(nodeType.effectiveWriteResult) {}
|
||||
|
||||
/** One reusable node type policy document. */
|
||||
export class PolicyDocumentPublicDto extends Z.class(nodeType.document) {}
|
||||
|
||||
/** Create or update of a node type policy document, plus shadowing warnings. */
|
||||
export class PolicyDocumentWriteResultPublicDto extends Z.class(nodeType.documentWriteResult) {}
|
||||
|
||||
export class PolicyDocumentListPublicDto extends Z.class(nodeType.documentList) {}
|
||||
|
||||
/** The attachments on one node type scope after a replace. */
|
||||
export class PolicyAttachmentsPublicDto extends Z.class(nodeType.attachments) {}
|
||||
|
||||
export class CredentialTypePolicyEffectivePublicDto extends Z.class(credentialType.effective) {}
|
||||
|
||||
export class CredentialTypePolicyEffectiveWriteResultPublicDto extends Z.class(
|
||||
credentialType.effectiveWriteResult,
|
||||
) {}
|
||||
|
||||
export class CredentialTypePolicyDocumentPublicDto extends Z.class(credentialType.document) {}
|
||||
|
||||
export class CredentialTypePolicyDocumentWriteResultPublicDto extends Z.class(
|
||||
credentialType.documentWriteResult,
|
||||
) {}
|
||||
|
||||
export class CredentialTypePolicyDocumentListPublicDto extends Z.class(
|
||||
credentialType.documentList,
|
||||
) {}
|
||||
|
||||
export class CredentialTypePolicyAttachmentsPublicDto extends Z.class(credentialType.attachments) {}
|
||||
|
||||
export class ListNodeTypePolicyDocumentsQueryDto extends Z.class({
|
||||
limit: publicApiPaginationSchema.limit,
|
||||
cursor: z.string().optional(),
|
||||
}) {}
|
||||
|
||||
const policyAttachmentSchema = z.object({
|
||||
policyId: z.string(),
|
||||
rules: policyRulesSchema,
|
||||
priority: z.number().int(),
|
||||
isFloor: z.boolean(),
|
||||
});
|
||||
|
||||
/** The attachments on one scope after a replace, with the scope's bumped version. */
|
||||
export class PolicyAttachmentsPublicDto extends Z.class({
|
||||
attachments: z.array(policyAttachmentSchema),
|
||||
version: z.number().int().positive(),
|
||||
}) {}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type {
|
||||
NodeTypePolicyRule,
|
||||
NonDelegatingPolicyAction,
|
||||
NonDelegatingPolicyRule,
|
||||
PolicyAction,
|
||||
PolicyRule,
|
||||
} from './policy-rule.types';
|
||||
import { policySelectorSchema } from './policy-selector.schema';
|
||||
import {
|
||||
credentialTypePolicySelectorSchema,
|
||||
nodeTypePolicySelectorSchema,
|
||||
} from './policy-selector.schema';
|
||||
|
||||
/**
|
||||
* Zod counterpart of `PolicyAction` in `./policy-rule.types.ts`. The `satisfies` check
|
||||
@@ -19,16 +23,14 @@ export const policyActionSchema = z.enum([
|
||||
]) satisfies z.ZodType<PolicyAction>;
|
||||
|
||||
/**
|
||||
* Zod counterpart of `PolicyRule` in `./policy-rule.types.ts`. The `satisfies` check
|
||||
* keeps this schema honest against that type.
|
||||
* `delegate` is only meaningful where a narrower scope exists to opt in — instance scope
|
||||
* today. There is no scope narrower than project, so a project-scope write rejects it outright.
|
||||
*/
|
||||
export const policyRuleSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
action: policyActionSchema,
|
||||
selector: policySelectorSchema,
|
||||
}) satisfies z.ZodType<PolicyRule>;
|
||||
export const nonDelegatingPolicyActionSchema = policyActionSchema.exclude([
|
||||
'delegate',
|
||||
]) satisfies z.ZodType<NonDelegatingPolicyAction>;
|
||||
|
||||
function rejectDuplicateRuleIds(rules: PolicyRule[], ctx: z.RefinementCtx): void {
|
||||
function rejectDuplicateRuleIds(rules: ReadonlyArray<{ id: string }>, ctx: z.RefinementCtx): void {
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
for (const [index, rule] of rules.entries()) {
|
||||
@@ -45,22 +47,35 @@ function rejectDuplicateRuleIds(rules: PolicyRule[], ctx: z.RefinementCtx): void
|
||||
}
|
||||
}
|
||||
|
||||
/** An ordered list of rules for one policy document. Rejects duplicate rule ids. */
|
||||
export const policyRuleListSchema = z.array(policyRuleSchema).superRefine(rejectDuplicateRuleIds);
|
||||
|
||||
/**
|
||||
* `delegate` is only meaningful where a narrower scope exists to opt in — instance scope
|
||||
* today. There is no scope narrower than project, so a project-scope write rejects it outright.
|
||||
* One rule, an ordered rule list, and the project-scope list that also rejects `delegate`, for
|
||||
* one kind's selectors. Both lists reject duplicate rule ids.
|
||||
*/
|
||||
export const nonDelegatingPolicyActionSchema = policyActionSchema.exclude([
|
||||
'delegate',
|
||||
]) satisfies z.ZodType<NonDelegatingPolicyAction>;
|
||||
function ruleSchemasFor<Selector extends z.ZodTypeAny>(selector: Selector) {
|
||||
const rule = z.object({ id: z.string().min(1), action: policyActionSchema, selector });
|
||||
const nonDelegatingRule = rule.extend({ action: nonDelegatingPolicyActionSchema });
|
||||
|
||||
const nonDelegatingPolicyRuleSchema = policyRuleSchema.extend({
|
||||
action: nonDelegatingPolicyActionSchema,
|
||||
}) satisfies z.ZodType<NonDelegatingPolicyRule>;
|
||||
return {
|
||||
rule,
|
||||
ruleList: z.array(rule).superRefine(rejectDuplicateRuleIds),
|
||||
nonDelegatingRuleList: z.array(nonDelegatingRule).superRefine(rejectDuplicateRuleIds),
|
||||
};
|
||||
}
|
||||
|
||||
/** Same as `policyRuleListSchema`, but for project scope: rejects a `delegate` rule too. */
|
||||
export const nonDelegatingPolicyRuleListSchema = z
|
||||
.array(nonDelegatingPolicyRuleSchema)
|
||||
.superRefine(rejectDuplicateRuleIds);
|
||||
/** What one kind's rule schemas must parse into. `satisfies` keeps each kind honest against it. */
|
||||
type RuleSchemas<Rule, NonDelegatingRule> = {
|
||||
rule: z.ZodType<Rule>;
|
||||
ruleList: z.ZodType<Rule[]>;
|
||||
nonDelegatingRuleList: z.ZodType<NonDelegatingRule[]>;
|
||||
};
|
||||
|
||||
export const nodeTypePolicyRuleSchemas = ruleSchemasFor(
|
||||
nodeTypePolicySelectorSchema,
|
||||
) satisfies RuleSchemas<
|
||||
NodeTypePolicyRule,
|
||||
NodeTypePolicyRule & { readonly action: NonDelegatingPolicyAction }
|
||||
>;
|
||||
|
||||
export const credentialTypePolicyRuleSchemas = ruleSchemasFor(
|
||||
credentialTypePolicySelectorSchema,
|
||||
) satisfies RuleSchemas<PolicyRule, NonDelegatingPolicyRule>;
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
/**
|
||||
* Domain types for node type availability policies, shared by the internal and public API
|
||||
* Domain types for type availability policies, shared by the internal and public API
|
||||
* request DTOs and by the backend evaluator.
|
||||
*/
|
||||
|
||||
/** Matches a node type by its exact full name, or by its package segment. */
|
||||
/**
|
||||
* Matches a type by its exact full name, or by its package segment. `extends` is for credential
|
||||
* types only: it matches the named type and every type built on it through `extends`.
|
||||
*/
|
||||
export type PolicySelector =
|
||||
| { readonly kind: 'name'; readonly value: string }
|
||||
| { readonly kind: 'package'; readonly value: string };
|
||||
| { readonly kind: 'package'; readonly value: string }
|
||||
| { readonly kind: 'extends'; readonly value: string };
|
||||
|
||||
/** The selectors a node type policy accepts. */
|
||||
export type NodeTypePolicySelector = Exclude<PolicySelector, { readonly kind: 'extends' }>;
|
||||
|
||||
export type PolicyAction = 'allow' | 'deny' | 'delegate';
|
||||
|
||||
@@ -17,6 +24,15 @@ export type PolicyRule = {
|
||||
readonly selector: PolicySelector;
|
||||
};
|
||||
|
||||
/** A `PolicyRule` as a node type policy accepts it. */
|
||||
export type NodeTypePolicyRule = Omit<PolicyRule, 'selector'> & {
|
||||
readonly selector: NodeTypePolicySelector;
|
||||
};
|
||||
|
||||
export function isNodeTypePolicyRule(rule: PolicyRule): rule is NodeTypePolicyRule {
|
||||
return rule.selector.kind !== 'extends';
|
||||
}
|
||||
|
||||
/**
|
||||
* The actions a project scope accepts. `delegate` defers to a narrower scope, and none exists
|
||||
* below project, so project-scope writes never accept it.
|
||||
|
||||
+18
-9
@@ -1,13 +1,22 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { PolicySelector } from './policy-rule.types';
|
||||
import type { NodeTypePolicySelector, PolicySelector } from './policy-rule.types';
|
||||
|
||||
/**
|
||||
* Zod counterpart of `PolicySelector` in `./policy-rule.types.ts`. The `satisfies` check
|
||||
* keeps this schema honest against that type — it fails to compile if this schema ever
|
||||
* accepts a shape `PolicySelector` doesn't.
|
||||
*/
|
||||
export const policySelectorSchema = z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('name'), value: z.string().min(1) }),
|
||||
z.object({ kind: z.literal('package'), value: z.string().min(1) }),
|
||||
const nameSelectorSchema = z.object({ kind: z.literal('name'), value: z.string().min(1) });
|
||||
const packageSelectorSchema = z.object({ kind: z.literal('package'), value: z.string().min(1) });
|
||||
const extendsSelectorSchema = z
|
||||
.object({ kind: z.literal('extends'), value: z.string().min(1) })
|
||||
.describe('Matches the named credential type and every type that extends it');
|
||||
|
||||
/** The selectors a node type policy accepts. `satisfies` keeps it honest against the type. */
|
||||
export const nodeTypePolicySelectorSchema = z.discriminatedUnion('kind', [
|
||||
nameSelectorSchema,
|
||||
packageSelectorSchema,
|
||||
]) satisfies z.ZodType<NodeTypePolicySelector>;
|
||||
|
||||
/** The selectors a credential type policy accepts: the node type ones plus `extends`. */
|
||||
export const credentialTypePolicySelectorSchema = z.discriminatedUnion('kind', [
|
||||
nameSelectorSchema,
|
||||
packageSelectorSchema,
|
||||
extendsSelectorSchema,
|
||||
]) satisfies z.ZodType<PolicySelector>;
|
||||
|
||||
+18
-5
@@ -1,12 +1,25 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { policyActionSchema, policyRuleListSchema } from './policy-rule.schema';
|
||||
import {
|
||||
credentialTypePolicyRuleSchemas,
|
||||
nodeTypePolicyRuleSchemas,
|
||||
policyActionSchema,
|
||||
} from './policy-rule.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
/** Request body for setting the composed instance policy. */
|
||||
// Optimistic concurrency: callers send the version they last read, so a stale write is rejected.
|
||||
const versionSchema = z.number().int().nonnegative();
|
||||
|
||||
/** Request body for setting the composed instance node type policy. */
|
||||
export class PutInstancePolicyDto extends Z.class({
|
||||
rules: policyRuleListSchema,
|
||||
rules: nodeTypePolicyRuleSchemas.ruleList,
|
||||
defaultAction: policyActionSchema,
|
||||
// Optimistic concurrency: callers send the version they last read, so a stale write is rejected.
|
||||
version: z.number().int().nonnegative(),
|
||||
version: versionSchema,
|
||||
}) {}
|
||||
|
||||
/** Request body for setting the composed instance credential type policy. */
|
||||
export class PutCredentialTypeInstancePolicyDto extends Z.class({
|
||||
rules: credentialTypePolicyRuleSchemas.ruleList,
|
||||
defaultAction: policyActionSchema,
|
||||
version: versionSchema,
|
||||
}) {}
|
||||
|
||||
+15
-5
@@ -1,19 +1,29 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
credentialTypePolicyRuleSchemas,
|
||||
nodeTypePolicyRuleSchemas,
|
||||
nonDelegatingPolicyActionSchema,
|
||||
nonDelegatingPolicyRuleListSchema,
|
||||
} from './policy-rule.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
// Optimistic concurrency: callers send the version they last read, so a stale write is rejected.
|
||||
const versionSchema = z.number().int().nonnegative();
|
||||
|
||||
/**
|
||||
* Request body for setting a project's composed policy. Same shape as
|
||||
* Request body for setting a project's composed node type policy. Same shape as
|
||||
* `PutInstancePolicyDto`, except `delegate` is rejected — there is no scope narrower than
|
||||
* project for it to defer to.
|
||||
*/
|
||||
export class PutProjectPolicyDto extends Z.class({
|
||||
rules: nonDelegatingPolicyRuleListSchema,
|
||||
rules: nodeTypePolicyRuleSchemas.nonDelegatingRuleList,
|
||||
defaultAction: nonDelegatingPolicyActionSchema,
|
||||
// Optimistic concurrency: callers send the version they last read, so a stale write is rejected.
|
||||
version: z.number().int().nonnegative(),
|
||||
version: versionSchema,
|
||||
}) {}
|
||||
|
||||
/** Same as `PutProjectPolicyDto`, for a project's credential type policy. */
|
||||
export class PutCredentialTypeProjectPolicyDto extends Z.class({
|
||||
rules: credentialTypePolicyRuleSchemas.nonDelegatingRuleList,
|
||||
defaultAction: nonDelegatingPolicyActionSchema,
|
||||
version: versionSchema,
|
||||
}) {}
|
||||
|
||||
+13
-5
@@ -1,11 +1,19 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { policyRuleListSchema } from './policy-rule.schema';
|
||||
import { credentialTypePolicyRuleSchemas, nodeTypePolicyRuleSchemas } from './policy-rule.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
/** Request body for replacing a policy document's rules. */
|
||||
// Optimistic concurrency: callers send the version they last read, so a stale write is rejected.
|
||||
const versionSchema = z.number().int().nonnegative();
|
||||
|
||||
/** Request body for replacing a node type policy document's rules. */
|
||||
export class UpdatePolicyDocumentDto extends Z.class({
|
||||
rules: policyRuleListSchema,
|
||||
// Optimistic concurrency: callers send the version they last read, so a stale write is rejected.
|
||||
version: z.number().int().nonnegative(),
|
||||
rules: nodeTypePolicyRuleSchemas.ruleList,
|
||||
version: versionSchema,
|
||||
}) {}
|
||||
|
||||
/** Request body for replacing a credential type policy document's rules. */
|
||||
export class UpdateCredentialTypePolicyDocumentDto extends Z.class({
|
||||
rules: credentialTypePolicyRuleSchemas.ruleList,
|
||||
version: versionSchema,
|
||||
}) {}
|
||||
|
||||
@@ -3,7 +3,9 @@ import { z } from 'zod';
|
||||
import { baseWorkflowShape } from './base-workflow.dto';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class UpdateWorkflowDto extends Z.class(
|
||||
export class UpdateWorkflowDto extends Z.class({
|
||||
// Make all base fields optional for partial updates
|
||||
z.object(baseWorkflowShape).partial().shape,
|
||||
) {}
|
||||
...z.object(baseWorkflowShape).partial().shape,
|
||||
/** The history version this update restores. Its nodes keep their credentials. */
|
||||
restoredFromVersionId: z.string().optional(),
|
||||
}) {}
|
||||
|
||||
@@ -329,6 +329,9 @@ export type {
|
||||
BreakingChangeRuleImpact,
|
||||
BreakingChangeRecommendation,
|
||||
BreakingChangeAffectedWorkflow,
|
||||
BreakingChangeWorkflowOwner,
|
||||
MigrationWorkflowOwnerResponse,
|
||||
MigrationOwnerSource,
|
||||
BreakingChangeInstanceIssue,
|
||||
BreakingChangeWorkflowIssue,
|
||||
BreakingChangeInstanceRuleResult,
|
||||
@@ -349,6 +352,7 @@ export {
|
||||
breakingChangeVersionSchema,
|
||||
migrationFindingStatusSchema,
|
||||
migrationFindingTriageStatusSchema,
|
||||
migrationOwnerSourceSchema,
|
||||
} from './schemas/breaking-changes.schema';
|
||||
|
||||
export type {
|
||||
@@ -861,6 +865,11 @@ export {
|
||||
PreviewRunOptionsDto,
|
||||
rerunResultOptionsSchema,
|
||||
RerunResultOptionsDto,
|
||||
ApplyAgentEvalSuggestionsDto,
|
||||
ApplyPreviewSuggestionDto,
|
||||
applyPreviewSuggestionSchema,
|
||||
applyAgentEvalSuggestionsSchema,
|
||||
MAX_APPLY_SUGGESTIONS,
|
||||
} from './schemas/agent-evals.schema';
|
||||
export type {
|
||||
AgentEvalColumnMapping,
|
||||
@@ -891,6 +900,10 @@ export type {
|
||||
PreviewRunOptions,
|
||||
PreviewRunResult,
|
||||
RerunResultOptions,
|
||||
ApplyAgentEvalSuggestionsOptions,
|
||||
ApplyAgentEvalSuggestionsResult,
|
||||
ApplyPreviewSuggestionOptions,
|
||||
ApplyPreviewSuggestionResult,
|
||||
} from './schemas/agent-evals.schema';
|
||||
|
||||
export {
|
||||
|
||||
@@ -78,6 +78,8 @@ export const agentEvalVerdictSchema = z.object({
|
||||
outcome: agentEvalVerdictOutcomeSchema.nullable(),
|
||||
/** The judge's explanation, or the error message when `status` is `'error'`. */
|
||||
reasoning: z.string().nullable(),
|
||||
/** One instruction that would fix a failed rule. Only set on a `fail` outcome, and absent on older rows. */
|
||||
suggestion: z.string().nullable().optional(),
|
||||
});
|
||||
export type AgentEvalVerdict = z.infer<typeof agentEvalVerdictSchema>;
|
||||
|
||||
@@ -377,3 +379,40 @@ const rerunResultOptionsShape = {
|
||||
export const rerunResultOptionsSchema = z.object(rerunResultOptionsShape);
|
||||
export type RerunResultOptions = z.infer<typeof rerunResultOptionsSchema>;
|
||||
export class RerunResultOptionsDto extends Z.class(rerunResultOptionsShape) {}
|
||||
|
||||
// Applies the fix suggestions stored on failed results: the backend rewrites the
|
||||
// agent's instructions once to include all of them, then reruns only these results.
|
||||
export const MAX_APPLY_SUGGESTIONS = 10;
|
||||
const applyAgentEvalSuggestionsShape = {
|
||||
resultIds: z.array(z.string().min(1)).min(1).max(MAX_APPLY_SUGGESTIONS),
|
||||
};
|
||||
export const applyAgentEvalSuggestionsSchema = z.object(applyAgentEvalSuggestionsShape);
|
||||
export type ApplyAgentEvalSuggestionsOptions = z.infer<typeof applyAgentEvalSuggestionsSchema>;
|
||||
export class ApplyAgentEvalSuggestionsDto extends Z.class(applyAgentEvalSuggestionsShape) {}
|
||||
|
||||
export type ApplyAgentEvalSuggestionsResult = {
|
||||
/** Hash of the saved agent config, so the editor can adopt it without a conflict. */
|
||||
configHash: string;
|
||||
/** The reran results, in the order they were requested. */
|
||||
results: AgentEvalResultRecord[];
|
||||
};
|
||||
|
||||
// Applies the suggestion on a preview run's failed first check. A preview run
|
||||
// saves nothing, so the case travels in the body instead of a result id: the
|
||||
// backend rewrites the agent's instructions with the suggestion, then runs the
|
||||
// same case again.
|
||||
const applyPreviewSuggestionShape = {
|
||||
input: z.string().trim().min(1).max(10_000),
|
||||
whatToCheck: z.string().trim().min(1).max(10_000),
|
||||
suggestion: z.string().trim().min(1).max(10_000),
|
||||
};
|
||||
export const applyPreviewSuggestionSchema = z.object(applyPreviewSuggestionShape);
|
||||
export type ApplyPreviewSuggestionOptions = z.infer<typeof applyPreviewSuggestionSchema>;
|
||||
export class ApplyPreviewSuggestionDto extends Z.class(applyPreviewSuggestionShape) {}
|
||||
|
||||
export type ApplyPreviewSuggestionResult = {
|
||||
/** Hash of the saved agent config, so the editor can adopt it without a conflict. */
|
||||
configHash: string;
|
||||
/** The same case, run again against the rewritten instructions. */
|
||||
preview: PreviewRunResult;
|
||||
};
|
||||
|
||||
@@ -80,6 +80,23 @@ const workflowIssueSchema = instanceIssueSchema.extend({
|
||||
});
|
||||
export type BreakingChangeWorkflowIssue = z.infer<typeof workflowIssueSchema>;
|
||||
|
||||
/** `suggested` by the owner heuristic, or `assigned` by a person. */
|
||||
export const migrationOwnerSourceSchema = z.enum(['suggested', 'assigned']);
|
||||
export type MigrationOwnerSource = z.infer<typeof migrationOwnerSourceSchema>;
|
||||
|
||||
/** The user responsible for fixing a workflow's findings. The name and email columns of a user are nullable. */
|
||||
const workflowOwnerSchema = z.object({
|
||||
id: z.string(),
|
||||
firstName: z.string().nullable(),
|
||||
lastName: z.string().nullable(),
|
||||
email: z.string().nullable(),
|
||||
source: migrationOwnerSourceSchema,
|
||||
});
|
||||
export type BreakingChangeWorkflowOwner = z.infer<typeof workflowOwnerSchema>;
|
||||
|
||||
/** The owner of a workflow after a change to it. `null` when nobody owns it. */
|
||||
export type MigrationWorkflowOwnerResponse = { owner: BreakingChangeWorkflowOwner | null };
|
||||
|
||||
const affectedWorkflowSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
@@ -88,6 +105,10 @@ const affectedWorkflowSchema = z.object({
|
||||
lastUpdatedAt: z.date(),
|
||||
lastExecutedAt: z.date().optional(),
|
||||
issues: z.array(workflowIssueSchema),
|
||||
// Absent when no owner is known for the workflow.
|
||||
owner: workflowOwnerSchema.optional(),
|
||||
// The project that owns the workflow, so an owner picker can list its members.
|
||||
homeProjectId: z.string().optional(),
|
||||
});
|
||||
export type BreakingChangeAffectedWorkflow = z.infer<typeof affectedWorkflowSchema>;
|
||||
|
||||
|
||||
@@ -16,6 +16,13 @@ export {
|
||||
} from './services/role-deletion-check-proxy.service';
|
||||
export { RoleService } from './services/role.service';
|
||||
export { ProjectScopeService } from './services/project-scope.service';
|
||||
export {
|
||||
FavoriteResourceResolverRegistry,
|
||||
type FavoriteResourceMeta,
|
||||
type FavoriteResourceResolver,
|
||||
type ResolvedFavoriteResourceType,
|
||||
} from './services/favorite-resource-resolver.registry';
|
||||
export { WorkflowProjectService } from './services/workflow-project.service';
|
||||
export {
|
||||
OwnershipTransferHandlerRegistry,
|
||||
type ProjectOwnershipTransferHandler,
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import {
|
||||
FavoriteResourceResolverRegistry,
|
||||
type FavoriteResourceResolver,
|
||||
} from '../favorite-resource-resolver.registry';
|
||||
|
||||
describe('FavoriteResourceResolverRegistry', () => {
|
||||
it('returns a registered resolver', () => {
|
||||
const registry = new FavoriteResourceResolverRegistry();
|
||||
const resolver = mock<FavoriteResourceResolver>();
|
||||
|
||||
registry.register('dataTable', resolver);
|
||||
|
||||
expect(registry.get('dataTable')).toBe(resolver);
|
||||
});
|
||||
|
||||
it('returns undefined when no resolver is registered', () => {
|
||||
const registry = new FavoriteResourceResolverRegistry();
|
||||
|
||||
expect(registry.get('agent')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import type { Project, SharedWorkflowRepository } from '@n8n/db';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { CacheService } from '../cache/cache.service';
|
||||
import { WorkflowProjectService } from '../workflow-project.service';
|
||||
|
||||
describe('WorkflowProjectService', () => {
|
||||
const cacheService = mock<CacheService>();
|
||||
const sharedWorkflowRepository = mock<SharedWorkflowRepository>();
|
||||
const service = new WorkflowProjectService(cacheService, sharedWorkflowRepository);
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('returns the cached project', async () => {
|
||||
cacheService.getHashValue.mockResolvedValue({ id: 'project-1' });
|
||||
|
||||
const project = await service.getWorkflowProjectCached('workflow-1');
|
||||
|
||||
expect(project.id).toBe('project-1');
|
||||
expect(sharedWorkflowRepository.findOneOrFail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads and caches the owner project after a cache miss', async () => {
|
||||
const project = { id: 'project-1', name: 'Project' } as Project;
|
||||
cacheService.getHashValue.mockResolvedValue(undefined);
|
||||
sharedWorkflowRepository.findOneOrFail.mockResolvedValue({ project } as never);
|
||||
|
||||
await expect(service.getWorkflowProjectCached('workflow-1')).resolves.toBe(project);
|
||||
expect(sharedWorkflowRepository.findOneOrFail).toHaveBeenCalledWith({
|
||||
where: { workflowId: 'workflow-1', role: 'workflow:owner' },
|
||||
relations: ['project'],
|
||||
});
|
||||
expect(cacheService.setHash).toHaveBeenCalledWith('workflow-project', {
|
||||
'workflow-1': project,
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the owner project when the cached value is malformed', async () => {
|
||||
const project = { id: 'project-1' } as Project;
|
||||
cacheService.getHashValue.mockResolvedValue('invalid' as never);
|
||||
sharedWorkflowRepository.findOneOrFail.mockResolvedValue({ project } as never);
|
||||
|
||||
await expect(service.getWorkflowProjectCached('workflow-1')).resolves.toBe(project);
|
||||
expect(sharedWorkflowRepository.findOneOrFail).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('sets the cached owner project', async () => {
|
||||
const project = { id: 'project-1' } as Project;
|
||||
|
||||
await expect(service.setWorkflowProjectCacheEntry('workflow-1', project)).resolves.toBe(
|
||||
project,
|
||||
);
|
||||
expect(cacheService.setHash).toHaveBeenCalledWith('workflow-project', {
|
||||
'workflow-1': project,
|
||||
});
|
||||
});
|
||||
|
||||
it('invalidates cached workflows owned by a project', async () => {
|
||||
sharedWorkflowRepository.find.mockResolvedValue([
|
||||
{ workflowId: 'workflow-1' },
|
||||
{ workflowId: 'workflow-2' },
|
||||
] as never);
|
||||
|
||||
await service.invalidateWorkflowProjectCacheForProject('project-1');
|
||||
|
||||
expect(cacheService.deleteFromHash).toHaveBeenCalledTimes(2);
|
||||
expect(cacheService.deleteFromHash).toHaveBeenCalledWith('workflow-project', 'workflow-1');
|
||||
expect(cacheService.deleteFromHash).toHaveBeenCalledWith('workflow-project', 'workflow-2');
|
||||
});
|
||||
});
|
||||
+1
-9
@@ -4,19 +4,11 @@ import type { Scope } from '@n8n/permissions';
|
||||
|
||||
export type FavoriteResourceMeta = { name: string; projectId: string };
|
||||
|
||||
/** Favorite types resolved via a registered resolver; all others are handled by the favorites module itself. */
|
||||
/** Favorite types resolved by the backend module that owns the resource. */
|
||||
export type ResolvedFavoriteResourceType = Extract<FavoriteResourceType, 'dataTable' | 'agent'>;
|
||||
|
||||
/**
|
||||
* Resolves favorited resources owned by another backend module. The owning
|
||||
* module registers its resolver in its `init()`, so favorites never depends
|
||||
* on module internals; if the module is inactive, no resolver exists and
|
||||
* favorites of that type are treated as missing resources.
|
||||
*/
|
||||
export interface FavoriteResourceResolver {
|
||||
/** Scope granting access to the resource regardless of project membership. */
|
||||
readonly globalReadScope: Scope;
|
||||
/** Per-id metadata; ids missing from the map are treated as deleted or inaccessible. */
|
||||
findMeta(ids: string[]): Promise<Map<string, FavoriteResourceMeta>>;
|
||||
exists(id: string): Promise<boolean>;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { CacheService } from './cache/cache.service';
|
||||
import { Project, SharedWorkflowRepository } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
@Service()
|
||||
export class WorkflowProjectService {
|
||||
constructor(
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
|
||||
) {}
|
||||
|
||||
private reconstructProject(project: Partial<Project>): Project | undefined {
|
||||
if (typeof project !== 'object' || project === null) return undefined;
|
||||
return Object.assign(new Project(), project);
|
||||
}
|
||||
|
||||
async getWorkflowProjectCached(workflowId: string): Promise<Project> {
|
||||
const cachedValue = await this.cacheService.getHashValue<Partial<Project>>(
|
||||
'workflow-project',
|
||||
workflowId,
|
||||
);
|
||||
|
||||
if (cachedValue) {
|
||||
const project = this.reconstructProject(cachedValue);
|
||||
if (project) return project;
|
||||
}
|
||||
|
||||
const sharedWorkflow = await this.sharedWorkflowRepository.findOneOrFail({
|
||||
where: { workflowId, role: 'workflow:owner' },
|
||||
relations: ['project'],
|
||||
});
|
||||
|
||||
void this.cacheService.setHash('workflow-project', {
|
||||
[workflowId]: { ...sharedWorkflow.project },
|
||||
});
|
||||
|
||||
return sharedWorkflow.project;
|
||||
}
|
||||
|
||||
async setWorkflowProjectCacheEntry(workflowId: string, project: Project): Promise<Project> {
|
||||
void this.cacheService.setHash('workflow-project', {
|
||||
[workflowId]: { ...project },
|
||||
});
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
async invalidateWorkflowProjectCacheForProject(projectId: string): Promise<void> {
|
||||
const rows = await this.sharedWorkflowRepository.find({
|
||||
where: { projectId, role: 'workflow:owner' },
|
||||
select: ['workflowId'],
|
||||
});
|
||||
await this.invalidateWorkflowProjectCacheByIds(rows.map(({ workflowId }) => workflowId));
|
||||
}
|
||||
|
||||
async invalidateWorkflowProjectCacheByIds(workflowIds: string[]): Promise<void> {
|
||||
await Promise.all(
|
||||
workflowIds.map(
|
||||
async (workflowId) =>
|
||||
await this.cacheService.deleteFromHash('workflow-project', workflowId),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -29,28 +29,28 @@ n8n-cli package export -w abc --include-tags=false -o export.n8np
|
||||
| `--include-variable-values` | `true` (default) or `false`. Whether values of variables referenced by the exported workflows are bundled into the package. When `false`, variables still travel as name/type files (and in the package requirements), just without their values. |
|
||||
| `--include-tags` | `true` (default) or `false`. Whether tags assigned to the exported workflows are bundled into the package. When `false`, no tag data is included in the package. |
|
||||
| `--include-archived-workflows` | `false` (default) or `true`. Whether folder and project exports include their archived workflows. When `true`, they travel with `isArchived: true` and are archived on import. Workflows given by `--workflow-id` always export, also when archived. |
|
||||
| `--missing-workflow-dependency-policy` | Policy for missing static sub-workflow dependencies: `fail` aborts when any dependency is missing, `include-in-package` automatically adds missing static sub-workflows, and `reference-only` keeps them out of the package, listing them in the package requirements as workflows expected to already exist on the target. |
|
||||
| `--workflow-version-policy` | Which version of each workflow travels in the package: `latest` (default) exports the latest version whether or not it is published, `published-strict` exports the published version and aborts when any workflow has none, `prefer-published` falls back to the latest version where there is no published one, and `ignore-unpublished` leaves unpublished workflows out of the package entirely. |
|
||||
| `--dependency-policy` | Policy for workflow and agent dependencies outside the selected package contents. `fail` (default) aborts when a required definition is absent from the package. `include-in-package` adds accessible dependencies recursively. `reference-only` records external requirements without including their definitions. |
|
||||
| `--version-policy` | Which version of each workflow and agent to export. `latest` (default) exports the current draft. `published-strict` requires a published version. `prefer-published` uses the published version when available and the draft otherwise. `ignore-unpublished` skips unpublished selections. |
|
||||
| `--credential-export-policy` | Whether expression values from credential data are bundled into the package: `expression-values-only` (default on the instance) includes credential fields whose value is an n8n expression (for example `={{ $secrets.apiKey }}`); `no-values` keeps credential data out of the package, so each credential file carries only its id, name and type. Literal values never travel either way. |
|
||||
|
||||
Provide at least one `--workflow-id`, `--folder-id`, or `--project-id`. Requires
|
||||
the API key to hold `workflow:export` when exporting workflows or folders, or
|
||||
`project:export` when exporting projects.
|
||||
|
||||
A workflow has a latest version (what you see in the editor) and, once
|
||||
published, a published version; `--workflow-version-policy` picks which one
|
||||
travels. The chosen version decides which credentials, data tables, variables
|
||||
and sub-workflows are bundled alongside it, but the workflow's name, settings
|
||||
(including `errorWorkflow`) and tags are not versioned and always come from the
|
||||
latest version.
|
||||
`--version-policy` applies to workflows and agents, including agents in a
|
||||
project export. The selected version supplies the definition and its references.
|
||||
Workflow names, settings (including `errorWorkflow`), and tags always use their
|
||||
current values. Agent IDs, names, and MCP availability also use their current
|
||||
values. Under `ignore-unpublished`, dependencies that cannot be included cause
|
||||
the export to fail only when `--dependency-policy` is `fail` or
|
||||
`include-in-package`. With `reference-only`, the export records them as external
|
||||
requirements.
|
||||
|
||||
Statically referenced sub-workflows are dependencies of the package. How
|
||||
missing ones are handled depends on
|
||||
`--missing-workflow-dependency-policy`. With the default `fail` policy you include them yourself. With `include-in-package`, n8n resolves the static dependency graph and adds any
|
||||
missing sub-workflows to the package automatically, so you don't need to list
|
||||
them explicitly. With `reference-only`, missing sub-workflows stay out of the
|
||||
package and are only listed in the package requirements (by id, with a
|
||||
best-effort name), on the assumption that they and their own dependencies
|
||||
`--dependency-policy` also applies to workflows and agents. Dependencies include
|
||||
static workflow references and disabled agent references. With `fail`, include
|
||||
the required definitions in your selection. With `include-in-package`, n8n adds
|
||||
accessible dependencies recursively. With `reference-only`, external dependencies
|
||||
are listed in the package requirements. Their definitions and dependencies must
|
||||
already exist on the target instance.
|
||||
|
||||
## `package import`
|
||||
|
||||
@@ -333,19 +333,19 @@ describe('N8nClient packages', () => {
|
||||
expect(init.body).toBe(JSON.stringify({ workflowIds: ['a'], folderIds: ['f1'] }));
|
||||
});
|
||||
|
||||
it('includes the missing workflow dependency policy when provided', async () => {
|
||||
it('includes the dependency policy when provided', async () => {
|
||||
fetchMock.mockResolvedValue(binaryResponse(200, new Uint8Array([1])));
|
||||
|
||||
await client.exportPackage({
|
||||
projectIds: ['proj-1'],
|
||||
missingWorkflowDependencyPolicy: 'include-in-package',
|
||||
dependencyPolicy: 'include-in-package',
|
||||
});
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.body).toBe(
|
||||
JSON.stringify({
|
||||
projectIds: ['proj-1'],
|
||||
missingWorkflowDependencyPolicy: 'include-in-package',
|
||||
dependencyPolicy: 'include-in-package',
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -412,17 +412,17 @@ describe('N8nClient packages', () => {
|
||||
expect(init.body).toBe(JSON.stringify({ workflowIds: ['a'], includeTags: false }));
|
||||
});
|
||||
|
||||
it('includes the workflow version policy when provided', async () => {
|
||||
it('includes the version policy when provided', async () => {
|
||||
fetchMock.mockResolvedValue(binaryResponse(200, new Uint8Array([1])));
|
||||
|
||||
await client.exportPackage({
|
||||
workflowIds: ['a'],
|
||||
workflowVersionPolicy: 'published-strict',
|
||||
versionPolicy: 'published-strict',
|
||||
});
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.body).toBe(
|
||||
JSON.stringify({ workflowIds: ['a'], workflowVersionPolicy: 'published-strict' }),
|
||||
JSON.stringify({ workflowIds: ['a'], versionPolicy: 'published-strict' }),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -155,6 +155,54 @@ describe('toPackagesError', () => {
|
||||
expect(hint).toContain('workflow w1, workflow w2');
|
||||
});
|
||||
|
||||
it('lists credential-policy-violation issues with every violation', () => {
|
||||
const result = toPackagesError(
|
||||
new ApiError(422, 'Import blocked', undefined, {
|
||||
issues: [
|
||||
{
|
||||
type: 'credential-policy-violation',
|
||||
sourceId: 'c1',
|
||||
name: 'Prod GitHub',
|
||||
credentialType: 'githubApi',
|
||||
usedBy: [
|
||||
{ kind: 'workflow', id: 'w1' },
|
||||
{ kind: 'agent', id: 'a1' },
|
||||
],
|
||||
violations: [
|
||||
{ kind: 'k', checkId: 'a', message: 'GitHub API is not allowed' },
|
||||
{ kind: 'k', checkId: 'b', message: 'Second reason' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const hint = (result as ApiError).hint ?? '';
|
||||
expect(hint).toContain(
|
||||
'credential "Prod GitHub" (c1, githubApi) refused by policy: GitHub API is not allowed; Second reason, used by workflow w1, agent a1',
|
||||
);
|
||||
});
|
||||
|
||||
it('names a refused credential by its source id when it has no name', () => {
|
||||
const result = toPackagesError(
|
||||
new ApiError(422, 'Import blocked', undefined, {
|
||||
issues: [
|
||||
{
|
||||
type: 'credential-policy-violation',
|
||||
sourceId: 'c1',
|
||||
credentialType: 'githubApi',
|
||||
usedBy: [{ kind: 'workflow', id: 'w1' }],
|
||||
violations: [{ kind: 'k', checkId: 'a', message: 'Not allowed' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect((result as ApiError).hint ?? '').toContain(
|
||||
'credential "c1" (c1, githubApi) refused by policy: Not allowed',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
usedBy: [{ kind: 'workflow', id: 'w1' }],
|
||||
|
||||
@@ -16,8 +16,8 @@ interface ExportFlags {
|
||||
output: string;
|
||||
includeVariableValues?: string;
|
||||
includeTags?: string;
|
||||
missingWorkflowDependencyPolicy?: string;
|
||||
workflowVersionPolicy?: string;
|
||||
dependencyPolicy?: string;
|
||||
versionPolicy?: string;
|
||||
credentialExportPolicy?: string;
|
||||
includeArchivedWorkflows?: string;
|
||||
}
|
||||
@@ -48,7 +48,7 @@ function stubCommand(
|
||||
const internals = command as unknown as ExportInternals;
|
||||
// Bypass oclif arg parsing, connection setup, and the success/exit path.
|
||||
vi.spyOn(internals, 'parse').mockResolvedValue({
|
||||
flags: { missingWorkflowDependencyPolicy: 'fail', ...flags },
|
||||
flags: { dependencyPolicy: 'fail', ...flags },
|
||||
});
|
||||
vi.spyOn(internals, 'getClient').mockReturnValue({ exportPackage } as unknown as N8nClient);
|
||||
vi.spyOn(internals, 'succeed').mockImplementation(() => {});
|
||||
@@ -76,7 +76,7 @@ describe('package export command', () => {
|
||||
folderIds: [],
|
||||
includeVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
dependencyPolicy: 'fail',
|
||||
});
|
||||
expect(mockedWriteFileSync).toHaveBeenCalledWith('/tmp/team.n8np', Buffer.from([1, 2, 3]));
|
||||
});
|
||||
@@ -94,7 +94,7 @@ describe('package export command', () => {
|
||||
folderIds: ['fld-1'],
|
||||
includeVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
dependencyPolicy: 'fail',
|
||||
});
|
||||
expect(mockedWriteFileSync).toHaveBeenCalledWith('/tmp/folders.n8np', Buffer.from([1, 2, 3]));
|
||||
});
|
||||
@@ -113,16 +113,16 @@ describe('package export command', () => {
|
||||
folderIds: ['fld-1'],
|
||||
includeVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
dependencyPolicy: 'fail',
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards a non-default missing workflow dependency policy for workflows and folders', async () => {
|
||||
it('forwards a non-default dependency policy for workflows and folders', async () => {
|
||||
const { command, exportPackage } = stubCommand({
|
||||
workflowId: ['wf-1'],
|
||||
folderId: ['fld-1'],
|
||||
output: '/tmp/mixed.n8np',
|
||||
missingWorkflowDependencyPolicy: 'reference-only',
|
||||
dependencyPolicy: 'reference-only',
|
||||
});
|
||||
|
||||
await command.run();
|
||||
@@ -132,16 +132,16 @@ describe('package export command', () => {
|
||||
folderIds: ['fld-1'],
|
||||
includeVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'reference-only',
|
||||
dependencyPolicy: 'reference-only',
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards a non-default workflow version policy for workflows and folders', async () => {
|
||||
it('forwards a non-default version policy for workflows and folders', async () => {
|
||||
const { command, exportPackage } = stubCommand({
|
||||
workflowId: ['wf-1'],
|
||||
folderId: ['fld-1'],
|
||||
output: '/tmp/mixed.n8np',
|
||||
workflowVersionPolicy: 'published-strict',
|
||||
versionPolicy: 'published-strict',
|
||||
});
|
||||
|
||||
await command.run();
|
||||
@@ -151,16 +151,16 @@ describe('package export command', () => {
|
||||
folderIds: ['fld-1'],
|
||||
includeVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
workflowVersionPolicy: 'published-strict',
|
||||
dependencyPolicy: 'fail',
|
||||
versionPolicy: 'published-strict',
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards a non-default workflow version policy for projects', async () => {
|
||||
it('forwards a non-default version policy for projects', async () => {
|
||||
const { command, exportPackage } = stubCommand({
|
||||
projectId: ['proj-1'],
|
||||
output: '/tmp/projects.n8np',
|
||||
workflowVersionPolicy: 'ignore-unpublished',
|
||||
versionPolicy: 'ignore-unpublished',
|
||||
});
|
||||
|
||||
await command.run();
|
||||
@@ -169,8 +169,8 @@ describe('package export command', () => {
|
||||
projectIds: ['proj-1'],
|
||||
includeVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
workflowVersionPolicy: 'ignore-unpublished',
|
||||
dependencyPolicy: 'fail',
|
||||
versionPolicy: 'ignore-unpublished',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -188,7 +188,7 @@ describe('package export command', () => {
|
||||
folderIds: [],
|
||||
includeVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
dependencyPolicy: 'fail',
|
||||
includeArchivedWorkflows: true,
|
||||
});
|
||||
});
|
||||
@@ -207,7 +207,7 @@ describe('package export command', () => {
|
||||
folderIds: [],
|
||||
includeVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
dependencyPolicy: 'fail',
|
||||
credentialExportPolicy: 'no-values',
|
||||
});
|
||||
});
|
||||
@@ -225,7 +225,7 @@ describe('package export command', () => {
|
||||
projectIds: ['proj-1'],
|
||||
includeVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
dependencyPolicy: 'fail',
|
||||
credentialExportPolicy: 'no-values',
|
||||
});
|
||||
});
|
||||
@@ -242,16 +242,16 @@ describe('package export command', () => {
|
||||
projectIds: ['proj-1', 'proj-2'],
|
||||
includeVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
dependencyPolicy: 'fail',
|
||||
});
|
||||
expect(mockedWriteFileSync).toHaveBeenCalledWith('/tmp/projects.n8np', Buffer.from([1, 2, 3]));
|
||||
});
|
||||
|
||||
it('forwards a non-default missing workflow dependency policy for projects', async () => {
|
||||
it('forwards a non-default dependency policy for projects', async () => {
|
||||
const { command, exportPackage } = stubCommand({
|
||||
projectId: ['proj-1'],
|
||||
output: '/tmp/projects.n8np',
|
||||
missingWorkflowDependencyPolicy: 'include-in-package',
|
||||
dependencyPolicy: 'include-in-package',
|
||||
});
|
||||
|
||||
await command.run();
|
||||
@@ -260,7 +260,7 @@ describe('package export command', () => {
|
||||
projectIds: ['proj-1'],
|
||||
includeVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'include-in-package',
|
||||
dependencyPolicy: 'include-in-package',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -278,7 +278,7 @@ describe('package export command', () => {
|
||||
folderIds: [],
|
||||
includeVariableValues: false,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
dependencyPolicy: 'fail',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -295,7 +295,7 @@ describe('package export command', () => {
|
||||
projectIds: ['proj-1'],
|
||||
includeVariableValues: false,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
dependencyPolicy: 'fail',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -313,7 +313,7 @@ describe('package export command', () => {
|
||||
folderIds: [],
|
||||
includeVariableValues: true,
|
||||
includeTags: false,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
dependencyPolicy: 'fail',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -330,7 +330,7 @@ describe('package export command', () => {
|
||||
projectIds: ['proj-1'],
|
||||
includeVariableValues: true,
|
||||
includeTags: false,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
dependencyPolicy: 'fail',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -348,7 +348,7 @@ describe('package export command', () => {
|
||||
folderIds: [],
|
||||
includeVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
dependencyPolicy: 'fail',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -373,7 +373,7 @@ describe('package export command', () => {
|
||||
folderIds: [],
|
||||
includeVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
dependencyPolicy: 'fail',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -50,8 +50,8 @@ export interface ExportPackageFields {
|
||||
projectIds?: string[];
|
||||
includeVariableValues?: boolean;
|
||||
includeTags?: boolean;
|
||||
missingWorkflowDependencyPolicy?: string;
|
||||
workflowVersionPolicy?: string;
|
||||
dependencyPolicy?: string;
|
||||
versionPolicy?: string;
|
||||
credentialExportPolicy?: string;
|
||||
includeArchivedWorkflows?: boolean;
|
||||
}
|
||||
@@ -753,8 +753,8 @@ export class N8nClient {
|
||||
projectIds?: string[];
|
||||
includeVariableValues?: boolean;
|
||||
includeTags?: boolean;
|
||||
missingWorkflowDependencyPolicy?: string;
|
||||
workflowVersionPolicy?: string;
|
||||
dependencyPolicy?: string;
|
||||
versionPolicy?: string;
|
||||
credentialExportPolicy?: string;
|
||||
includeArchivedWorkflows?: boolean;
|
||||
} = {};
|
||||
@@ -764,9 +764,8 @@ export class N8nClient {
|
||||
// `undefined` is dropped by JSON serialization, so the API's default applies.
|
||||
body.includeVariableValues = fields.includeVariableValues;
|
||||
body.includeTags = fields.includeTags;
|
||||
if (fields.missingWorkflowDependencyPolicy)
|
||||
body.missingWorkflowDependencyPolicy = fields.missingWorkflowDependencyPolicy;
|
||||
if (fields.workflowVersionPolicy) body.workflowVersionPolicy = fields.workflowVersionPolicy;
|
||||
if (fields.dependencyPolicy) body.dependencyPolicy = fields.dependencyPolicy;
|
||||
if (fields.versionPolicy) body.versionPolicy = fields.versionPolicy;
|
||||
// Only sent when set, so an older server without this field in its schema never sees it.
|
||||
if (fields.credentialExportPolicy) body.credentialExportPolicy = fields.credentialExportPolicy;
|
||||
if (fields.includeArchivedWorkflows) body.includeArchivedWorkflows = true;
|
||||
|
||||
@@ -74,18 +74,18 @@ export default class PackageExport extends BaseCommand {
|
||||
default: 'true',
|
||||
aliases: ['include-tags'],
|
||||
}),
|
||||
missingWorkflowDependencyPolicy: Flags.string({
|
||||
dependencyPolicy: Flags.string({
|
||||
options: ['fail', 'reference-only', 'include-in-package'],
|
||||
default: 'fail',
|
||||
description:
|
||||
'What to do when a dependency workflow (sub-workflow) is not explicitly included in the package target',
|
||||
aliases: ['missing-workflow-dependency-policy'],
|
||||
'How to handle workflow and agent dependencies outside the selected package contents',
|
||||
aliases: ['dependency-policy'],
|
||||
}),
|
||||
workflowVersionPolicy: Flags.string({
|
||||
versionPolicy: Flags.string({
|
||||
options: ['published-strict', 'prefer-published', 'ignore-unpublished', 'latest'],
|
||||
default: 'latest',
|
||||
description: 'Which version of each workflow travels in the package',
|
||||
aliases: ['workflow-version-policy'],
|
||||
description: 'Which version of each workflow and agent to export',
|
||||
aliases: ['version-policy'],
|
||||
}),
|
||||
// No default: the key is only sent when set, so older servers that reject unknown fields keep working.
|
||||
credentialExportPolicy: Flags.string({
|
||||
@@ -110,8 +110,8 @@ export default class PackageExport extends BaseCommand {
|
||||
const projectIds = flags.projectId ?? [];
|
||||
const includeVariableValues = flags.includeVariableValues !== 'false';
|
||||
const includeTags = flags.includeTags !== 'false';
|
||||
const missingWorkflowDependencyPolicy = flags.missingWorkflowDependencyPolicy;
|
||||
const workflowVersionPolicy = flags.workflowVersionPolicy;
|
||||
const dependencyPolicy = flags.dependencyPolicy;
|
||||
const versionPolicy = flags.versionPolicy;
|
||||
const credentialExportPolicy = flags.credentialExportPolicy;
|
||||
const includeArchivedWorkflows = flags.includeArchivedWorkflows === 'true';
|
||||
|
||||
@@ -133,8 +133,8 @@ export default class PackageExport extends BaseCommand {
|
||||
projectIds,
|
||||
includeVariableValues,
|
||||
includeTags,
|
||||
missingWorkflowDependencyPolicy,
|
||||
workflowVersionPolicy,
|
||||
dependencyPolicy,
|
||||
versionPolicy,
|
||||
credentialExportPolicy,
|
||||
...(includeArchivedWorkflows ? { includeArchivedWorkflows } : {}),
|
||||
}
|
||||
@@ -143,8 +143,8 @@ export default class PackageExport extends BaseCommand {
|
||||
folderIds,
|
||||
includeVariableValues,
|
||||
includeTags,
|
||||
missingWorkflowDependencyPolicy,
|
||||
workflowVersionPolicy,
|
||||
dependencyPolicy,
|
||||
versionPolicy,
|
||||
credentialExportPolicy,
|
||||
...(includeArchivedWorkflows ? { includeArchivedWorkflows } : {}),
|
||||
},
|
||||
|
||||
@@ -33,6 +33,14 @@ type BlockingIssue =
|
||||
}
|
||||
| { type: 'folder-removal-forbidden'; folderId: string; name: string; projectId: string }
|
||||
| { type: 'credential-unresolved'; kind: string; sourceId: string; usedBy: RequirementConsumer[] }
|
||||
| {
|
||||
type: 'credential-policy-violation';
|
||||
sourceId: string;
|
||||
name?: string;
|
||||
credentialType: string;
|
||||
usedBy: RequirementConsumer[];
|
||||
violations: Array<{ message: string }>;
|
||||
}
|
||||
| { type: 'variable-unresolved'; name: string; usedBy: RequirementConsumer[] }
|
||||
| { type: 'variable-conflict'; name: string; projectId?: string; usedBy: RequirementConsumer[] }
|
||||
| {
|
||||
@@ -136,6 +144,13 @@ function formatIssue(issue: unknown): string {
|
||||
const usedBy = formatConsumers(it.usedBy);
|
||||
return `credential ${it.sourceId} unresolved (${it.kind}), used by ${usedBy}`;
|
||||
}
|
||||
if (it.type === 'credential-policy-violation') {
|
||||
const usedBy = formatConsumers(it.usedBy);
|
||||
const reasons = Array.isArray(it.violations)
|
||||
? it.violations.map(({ message }) => message).join('; ')
|
||||
: '';
|
||||
return `credential "${it.name ?? it.sourceId}" (${it.sourceId}, ${it.credentialType}) refused by policy: ${reasons}, used by ${usedBy}`;
|
||||
}
|
||||
if (it.type === 'variable-unresolved') {
|
||||
const usedBy = formatConsumers(it.usedBy);
|
||||
return `variable "${it.name}" unresolved, used by ${usedBy}`;
|
||||
|
||||
@@ -5,4 +5,11 @@ export class DeploymentConfig {
|
||||
/** Deployment type identifier (for example, `default`, `cloud`). Used for telemetry and feature behavior. */
|
||||
@Env('N8N_DEPLOYMENT_TYPE')
|
||||
type: string = 'default';
|
||||
|
||||
/**
|
||||
* Which official artifact installed n8n, with its version, for example `helm-chart/1.14.0`.
|
||||
* Set by the artifact itself. Telemetry only.
|
||||
*/
|
||||
@Env('N8N_DEPLOYMENT_ARTIFACT')
|
||||
artifact: string = '';
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ describe('GlobalConfig', () => {
|
||||
hideUsagePage: false,
|
||||
deployment: {
|
||||
type: 'default',
|
||||
artifact: '',
|
||||
},
|
||||
mfa: {
|
||||
enabled: true,
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
export const NPM_COMMUNITY_NODE_SEARCH_API_URL = 'https://api.npms.io/v2/';
|
||||
|
||||
/**
|
||||
* Node-authoring API level supported by the runtime that bundles this copy.
|
||||
*
|
||||
* Compatibility rule for community packages:
|
||||
* `package.n8n.n8nNodesApiVersion <= N8N_NODES_API_VERSION`.
|
||||
*
|
||||
* The level mirrors the n8n major for the v3 transition: `1` on master
|
||||
* (which ships as 2.x during the v3 window) and `3` on the `3.x` branch,
|
||||
* where it also serves as the v3 feature flag for node-authoring APIs.
|
||||
*
|
||||
* Lives here, not in `n8n-workflow`, so tooling that cannot depend on the
|
||||
* runtime packages (e.g. `@n8n/node-cli`) can read the same value.
|
||||
* Node-authoring API level this runtime supports, and so the highest
|
||||
* `n8nNodesApiVersion` a node may declare to be installed.
|
||||
*/
|
||||
export const N8N_NODES_API_VERSION = 1;
|
||||
export const N8N_NODES_API_VERSION = '1.0';
|
||||
|
||||
@@ -94,7 +94,11 @@ export class ExecutionEntity {
|
||||
storedAt: ExecutionDataStorageLocation;
|
||||
|
||||
@Column({ type: jsonColumnType, nullable: true })
|
||||
tracingContext: { traceparent: string; tracestate?: string } | null;
|
||||
tracingContext: {
|
||||
traceparent: string;
|
||||
tracestate?: string;
|
||||
identity?: Record<string, string>;
|
||||
} | null;
|
||||
/**
|
||||
* Optional caller-supplied key that uniquely identifies this logical
|
||||
* execution. Enforced unique via an index, so concurrent attempts
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, Relation } from '@n8n/typeorm';
|
||||
|
||||
import { JsonColumn, WithTimestampsAndStringId } from './abstract-entity';
|
||||
import type { User } from './user';
|
||||
|
||||
export const idempotencyKeyStatuses = ['processing', 'completed'] as const;
|
||||
|
||||
export type IdempotencyKeyStatus = (typeof idempotencyKeyStatuses)[number];
|
||||
|
||||
/**
|
||||
* One stored Public API write per user and Idempotency-Key.
|
||||
* A retry with the same user, key, and fingerprint returns this row.
|
||||
* The handler does not run again.
|
||||
*/
|
||||
@Entity({ name: 'idempotency_key' })
|
||||
@Index(['userId', 'idempotencyKey'], { unique: true })
|
||||
@Index(['createdAt'])
|
||||
export class IdempotencyKey extends WithTimestampsAndStringId {
|
||||
@Column({ type: 'uuid' })
|
||||
userId: string;
|
||||
|
||||
@ManyToOne('User', { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: Relation<User>;
|
||||
|
||||
@Column({ type: 'varchar', length: 128 })
|
||||
idempotencyKey: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
fingerprint: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 16, default: 'processing' })
|
||||
status: IdempotencyKeyStatus;
|
||||
|
||||
@Column({ type: 'smallint', nullable: true })
|
||||
responseStatus: number | null;
|
||||
|
||||
@JsonColumn({ nullable: true })
|
||||
responseBody: unknown;
|
||||
}
|
||||
@@ -37,6 +37,11 @@ import type { ExecutionDataStorageLocation } from './execution-entity';
|
||||
import { ExecutionMetadata } from './execution-metadata';
|
||||
import { Folder } from './folder';
|
||||
import { FolderTagMapping } from './folder-tag-mapping';
|
||||
import {
|
||||
IdempotencyKey,
|
||||
idempotencyKeyStatuses,
|
||||
type IdempotencyKeyStatus,
|
||||
} from './idempotency-key';
|
||||
import { InstanceCredentialAssignment } from './instance-credential-assignment';
|
||||
import { InvalidAuthToken } from './invalid-auth-token';
|
||||
import { PollerState } from './poller-state';
|
||||
@@ -124,6 +129,9 @@ export {
|
||||
type AgentEvalResultStatus,
|
||||
AgentEvalRating,
|
||||
type AgentEvalVote,
|
||||
IdempotencyKey,
|
||||
idempotencyKeyStatuses,
|
||||
type IdempotencyKeyStatus,
|
||||
InvalidAuthToken,
|
||||
InstanceCredentialAssignment,
|
||||
AiBuilderTemporaryWorkflow,
|
||||
@@ -219,6 +227,7 @@ export const entities = {
|
||||
AgentEvalRun,
|
||||
AgentEvalResult,
|
||||
AgentEvalRating,
|
||||
IdempotencyKey,
|
||||
InvalidAuthToken,
|
||||
InstanceCredentialAssignment,
|
||||
AiBuilderTemporaryWorkflow,
|
||||
|
||||
@@ -66,7 +66,11 @@ export interface IExecutionBase {
|
||||
* correlated across queue-mode boundaries.
|
||||
* @see https://www.w3.org/TR/trace-context/#traceparent-header
|
||||
*/
|
||||
tracingContext?: { traceparent: string; tracestate?: string } | null;
|
||||
tracingContext?: {
|
||||
traceparent: string;
|
||||
tracestate?: string;
|
||||
identity?: Record<string, string>;
|
||||
} | null;
|
||||
deletedAt?: Date | null; // see `ExecutionEntity.deletedAt`
|
||||
deduplicationKey?: string | null; // see `ExecutionEntity.deduplicationKey`
|
||||
jsonSizeBytes?: number; // see `ExecutionEntity.jsonSizeBytes`
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
const tableName = 'idempotency_key';
|
||||
|
||||
export class CreateIdempotencyKeyTable1791540050944 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { createTable, column }, escape, tablePrefix }: MigrationContext) {
|
||||
const status = escape.columnName('status');
|
||||
const responseStatus = escape.columnName('responseStatus');
|
||||
|
||||
await createTable(tableName)
|
||||
.withColumns(
|
||||
column('id').varchar(16).primary.comment('Application-generated n8n nano ID'),
|
||||
column('userId').uuid.notNull,
|
||||
column('idempotencyKey')
|
||||
.varchar(128)
|
||||
.notNull.comment('Client Idempotency-Key header. Opaque ASCII, length 1 to 128'),
|
||||
column('fingerprint').text.notNull.comment('Hash of the request method, path, and body'),
|
||||
column('status')
|
||||
.varchar(16)
|
||||
.notNull.default("'processing'")
|
||||
.withEnumCheck(['processing', 'completed'])
|
||||
.comment('processing while the handler runs. completed after the response is stored'),
|
||||
column('responseStatus').smallint.comment(
|
||||
'HTTP status of the stored response. NULL while processing, required once completed',
|
||||
),
|
||||
column('responseBody').json.comment(
|
||||
'Stored response body. NULL while status is processing',
|
||||
),
|
||||
)
|
||||
.withIndexOn(['userId', 'idempotencyKey'], true)
|
||||
.withIndexOn('createdAt')
|
||||
.withForeignKey('userId', {
|
||||
tableName: 'user',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
name: `FK_${tablePrefix}idempotency_key_userId`,
|
||||
})
|
||||
.withCheck(
|
||||
`CHK_${tablePrefix}idempotency_key_responseStatus`,
|
||||
`(${status} = 'processing' AND ${responseStatus} IS NULL) OR (${status} = 'completed' AND ${responseStatus} IS NOT NULL)`,
|
||||
).withTimestamps;
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
|
||||
await dropTable(tableName);
|
||||
}
|
||||
}
|
||||
@@ -318,6 +318,24 @@ export class CredentialsRepository extends BaseRepository<CredentialsEntity> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a project credential that has no `shared_credentials` row at all. Such a credential has
|
||||
* no owner, so the project-scoped lookups cannot find it. Returns `null` if the credential does
|
||||
* not exist or still has any `shared_credentials` row — even a non-owner share keeps the
|
||||
* credential reachable through the regular, access-checked delete path, so it is not orphaned.
|
||||
*/
|
||||
async findProjectCredentialWithoutOwner(
|
||||
credentialId: string,
|
||||
ctx: OperationContext = {},
|
||||
): Promise<CredentialsEntity | null> {
|
||||
const credential = await this.managerFor(ctx).findOne(CredentialsEntity, {
|
||||
where: { id: credentialId, usageScope: 'project' },
|
||||
relations: { shared: true },
|
||||
});
|
||||
if (!credential || credential.shared.length > 0) return null;
|
||||
return credential;
|
||||
}
|
||||
|
||||
async deleteInstanceCredentialIfUnassigned(
|
||||
credentialId: string,
|
||||
ctx: OperationContext = {},
|
||||
|
||||
@@ -83,7 +83,7 @@ export type CrashedExecution = {
|
||||
mode: WorkflowExecuteMode;
|
||||
startedAt: Date | null;
|
||||
stoppedAt: Date;
|
||||
tracingContext?: { traceparent: string; tracestate?: string };
|
||||
tracingContext?: { traceparent: string; tracestate?: string; identity?: Record<string, string> };
|
||||
workflowVersionId?: string;
|
||||
retryOf?: string;
|
||||
workflowCustomTelemetryTags?: IWorkflowSettings['customTelemetryTags'];
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Service } from '@n8n/di';
|
||||
import { DataSource } from '@n8n/typeorm';
|
||||
|
||||
import { BaseRepository } from './base-repository';
|
||||
import { IdempotencyKey } from '../entities';
|
||||
import { TransactionRunner } from '../services/transaction';
|
||||
|
||||
@Service()
|
||||
export class IdempotencyKeyRepository extends BaseRepository<IdempotencyKey> {
|
||||
constructor(dataSource: DataSource, transactionRunner: TransactionRunner) {
|
||||
super(IdempotencyKey, dataSource.manager, transactionRunner);
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ export { FolderRepository } from './folder.repository';
|
||||
export { FolderAccessRepository } from './folder-access.repository';
|
||||
export { FolderTagMappingRepository } from './folder-tag-mapping.repository';
|
||||
export { ScopeRepository } from './scope.repository';
|
||||
export { IdempotencyKeyRepository } from './idempotency-key.repository';
|
||||
export { InvalidAuthTokenRepository } from './invalid-auth-token.repository';
|
||||
export { InstanceCredentialAssignmentRepository } from './instance-credential-assignment.repository';
|
||||
export { LicenseMetricsRepository } from './license-metrics.repository';
|
||||
|
||||
@@ -5,7 +5,7 @@ import { z } from 'zod';
|
||||
import { ApiResponse } from '../api-response';
|
||||
import { ControllerRegistryMetadata } from '../controller-registry-metadata';
|
||||
import { Get } from '../route';
|
||||
import type { Controller } from '../types';
|
||||
import type { BinaryResponse, Controller } from '../types';
|
||||
|
||||
const ExampleDto = Z.class({
|
||||
id: z.string(),
|
||||
@@ -64,4 +64,62 @@ describe('@ApiResponse Decorator', () => {
|
||||
expect(route.successStatus).toBe(204);
|
||||
expect(route.responseDto).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should store binary response metadata and leave responseDto undefined', () => {
|
||||
const binaryOptions: BinaryResponse = {
|
||||
mediaType: 'application/gzip',
|
||||
description: 'Compressed archive',
|
||||
headers: {
|
||||
'X-Archive-Size': { description: 'Size of the archive' },
|
||||
},
|
||||
};
|
||||
|
||||
class TestController {
|
||||
@Get('/')
|
||||
@ApiResponse(200, binaryOptions)
|
||||
async handler() {}
|
||||
}
|
||||
|
||||
const route = controllerRegistryMetadata.getRouteMetadata(
|
||||
TestController as Controller,
|
||||
'handler',
|
||||
);
|
||||
expect(route.binaryResponse).toEqual(binaryOptions);
|
||||
expect(route.successStatus).toBe(200);
|
||||
expect(route.responseDto).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject a binary response with an unsupported media type', () => {
|
||||
expect(() => {
|
||||
class TestController {
|
||||
@Get('/')
|
||||
@ApiResponse(200, { mediaType: 'text/csv' } as never)
|
||||
async handler() {}
|
||||
}
|
||||
void TestController;
|
||||
}).toThrow('unsupported binary media type "text/csv"');
|
||||
});
|
||||
|
||||
it('should reject a body that is neither a response DTO nor binary options', () => {
|
||||
expect(() => {
|
||||
class TestController {
|
||||
@Get('/')
|
||||
@ApiResponse(200, {} as never)
|
||||
async handler() {}
|
||||
}
|
||||
void TestController;
|
||||
}).toThrow('neither a response DTO nor binary options');
|
||||
});
|
||||
|
||||
it('should reject 204 with binary response', () => {
|
||||
expect(() => {
|
||||
const binaryOptions = { mediaType: 'application/gzip' } as never;
|
||||
class TestController {
|
||||
@Get('/')
|
||||
@ApiResponse(204, binaryOptions)
|
||||
async handler() {}
|
||||
}
|
||||
void TestController;
|
||||
}).toThrow('declares a 204 @ApiResponse with a binary body');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,47 @@
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { ControllerRegistryMetadata } from './controller-registry-metadata';
|
||||
import type { Controller, ResponseDtoClass, SuccessStatus } from './types';
|
||||
import {
|
||||
BINARY_RESPONSE_MEDIA_TYPES,
|
||||
type BinaryResponse,
|
||||
type Controller,
|
||||
type ResponseDtoClass,
|
||||
type SuccessStatus,
|
||||
} from './types';
|
||||
|
||||
function hasMediaType(body: unknown): body is { mediaType: unknown } {
|
||||
return typeof body === 'object' && body !== null && 'mediaType' in body;
|
||||
}
|
||||
|
||||
function isBinaryResponse(body: unknown): body is BinaryResponse {
|
||||
return (
|
||||
hasMediaType(body) &&
|
||||
BINARY_RESPONSE_MEDIA_TYPES.some((mediaType) => mediaType === body.mediaType)
|
||||
);
|
||||
}
|
||||
|
||||
function isResponseDto(body: unknown): body is ResponseDtoClass {
|
||||
return (
|
||||
(typeof body === 'function' || (typeof body === 'object' && body !== null)) && 'parse' in body
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares what a route returns on success: its HTTP status, and its public output DTO if it sends a
|
||||
* body. Only one @ApiResponse decorator should be present per endpoint otherwise an error will be thrown.
|
||||
* Declares what a route returns on success: its HTTP status, and either its public output DTO or a
|
||||
* binary body that the controller method returns.
|
||||
*
|
||||
* Only one @ApiResponse decorator should be present per endpoint otherwise an error will be thrown.
|
||||
*/
|
||||
export const ApiResponse =
|
||||
(status: SuccessStatus, dto?: ResponseDtoClass): MethodDecorator =>
|
||||
(target, handlerName) => {
|
||||
export function ApiResponse(status: SuccessStatus, dto?: ResponseDtoClass): MethodDecorator;
|
||||
export function ApiResponse(
|
||||
status: Exclude<SuccessStatus, 204>,
|
||||
binary: BinaryResponse,
|
||||
): MethodDecorator;
|
||||
export function ApiResponse(
|
||||
status: SuccessStatus,
|
||||
body?: ResponseDtoClass | BinaryResponse,
|
||||
): MethodDecorator {
|
||||
return (target, handlerName) => {
|
||||
const routeMetadata = Container.get(ControllerRegistryMetadata).getRouteMetadata(
|
||||
target.constructor as Controller,
|
||||
String(handlerName),
|
||||
@@ -23,12 +55,32 @@ export const ApiResponse =
|
||||
}
|
||||
|
||||
// HTTP 204 No Content responses must not have a body
|
||||
if (status === 204 && dto !== undefined) {
|
||||
if (status === 204 && body !== undefined) {
|
||||
const bodyKind = isBinaryResponse(body) ? 'a binary body' : 'a response DTO';
|
||||
throw new Error(
|
||||
`${String(handlerName)} declares a 204 @ApiResponse with a response DTO - a 204 response must not have a body`,
|
||||
`${String(handlerName)} declares a 204 @ApiResponse with ${bodyKind} - a 204 response must not have a body`,
|
||||
);
|
||||
}
|
||||
|
||||
routeMetadata.successStatus = status;
|
||||
routeMetadata.responseDto = dto;
|
||||
|
||||
if (isBinaryResponse(body)) {
|
||||
routeMetadata.binaryResponse = body;
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasMediaType(body)) {
|
||||
throw new Error(
|
||||
`${String(handlerName)} declares an unsupported binary media type "${String(body.mediaType)}" - supported: ${BINARY_RESPONSE_MEDIA_TYPES.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (body !== undefined && !isResponseDto(body)) {
|
||||
throw new Error(
|
||||
`${String(handlerName)} declares an @ApiResponse body that is neither a response DTO nor binary options`,
|
||||
);
|
||||
}
|
||||
|
||||
routeMetadata.responseDto = body;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ export type {
|
||||
AccessScope,
|
||||
ApiKeyScopeRequirement,
|
||||
Arg,
|
||||
BinaryResponse,
|
||||
BinaryResponseMediaType,
|
||||
BinaryResult,
|
||||
Controller,
|
||||
CorsOptions,
|
||||
DeprecationInfo,
|
||||
@@ -30,6 +33,7 @@ export type {
|
||||
RequestBodyMediaOptions,
|
||||
RequestBodyMediaType,
|
||||
ResponseDtoClass,
|
||||
ResponseHeader,
|
||||
RouteMetadata,
|
||||
StaticRouterMetadata,
|
||||
SuccessStatus,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ZodClass } from '@n8n/api-types';
|
||||
import type { BooleanLicenseFeature } from '@n8n/constants';
|
||||
import type { Constructable } from '@n8n/di';
|
||||
import type { ApiKeyScope, Scope } from '@n8n/permissions';
|
||||
import type { Readable } from 'node:stream';
|
||||
import type { RequestHandler, Router } from 'express';
|
||||
import type { ZodTypeAny } from 'zod';
|
||||
|
||||
@@ -14,6 +15,24 @@ export type ApiKeyScopeRequirement =
|
||||
|
||||
export type ResponseDtoClass = Pick<ZodClass, 'parse'>;
|
||||
|
||||
export const BINARY_RESPONSE_MEDIA_TYPES = ['application/gzip'] as const;
|
||||
export type BinaryResponseMediaType = (typeof BINARY_RESPONSE_MEDIA_TYPES)[number];
|
||||
|
||||
export interface ResponseHeader {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface BinaryResponse {
|
||||
mediaType: BinaryResponseMediaType;
|
||||
description?: string;
|
||||
headers?: Record<string, ResponseHeader>;
|
||||
}
|
||||
|
||||
export interface BinaryResult {
|
||||
body: Buffer | Readable;
|
||||
headers?: Record<string, string | number | readonly string[]>;
|
||||
}
|
||||
|
||||
export type SuccessStatus = 200 | 201 | 202 | 204;
|
||||
|
||||
export interface ErrorResponse {
|
||||
@@ -99,6 +118,8 @@ export interface RouteMetadata {
|
||||
accessScope?: AccessScope;
|
||||
apiKeyScope?: ApiKeyScopeRequirement;
|
||||
responseDto?: ResponseDtoClass;
|
||||
/** Mutually exclusive with `responseDto`. */
|
||||
binaryResponse?: BinaryResponse;
|
||||
/** OpenAPI HTTP status sent on success, and documented as such. */
|
||||
successStatus?: SuccessStatus;
|
||||
/** OpenAPI operation summary. */
|
||||
|
||||
@@ -56,6 +56,11 @@ export type WorkflowExecuteAfterContext = {
|
||||
retryOf?: string;
|
||||
/** Who initiated the run. Unset means a regular user-initiated run. */
|
||||
source?: WorkflowExecutionSource;
|
||||
/**
|
||||
* False when this process only enqueued the run and waited for its job. In queue mode,
|
||||
* the process that ran the job fires its own `workflowExecuteAfter` for the same run.
|
||||
*/
|
||||
executedInThisProcess: boolean;
|
||||
};
|
||||
|
||||
export type WorkflowExecuteResumeContext = {
|
||||
|
||||
@@ -17,8 +17,9 @@ register at install time and the failure is opaque to the user.
|
||||
This rule enforces the structural contract:
|
||||
|
||||
- `package.json` must contain an `n8n` object.
|
||||
- `n8n.n8nNodesApiVersion` must be present and a positive integer. It must live
|
||||
inside `n8n`, not at the root.
|
||||
- `n8n.n8nNodesApiVersion` must be present and a positive integer, or a
|
||||
`"<major>"` or `"<major>.<minor>"` string such as `"3"` or `"3.1"`. It must
|
||||
live inside `n8n`, not at the root.
|
||||
- `n8n.nodes` must be a non-empty array of strings, each starting with `dist/`.
|
||||
- `n8n.credentials`, if present, must be an array of strings, each starting
|
||||
with `dist/`.
|
||||
@@ -53,7 +54,7 @@ the templates published by `@n8n/node-cli` and `@n8n/create-node`.
|
||||
{
|
||||
"name": "n8n-nodes-example",
|
||||
"n8n": {
|
||||
"n8nNodesApiVersion": "1",
|
||||
"n8nNodesApiVersion": "v1",
|
||||
"nodes": ["nodes/Foo/Foo.node.js"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"watch": "tsc --watch --project tsconfig.build.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@n8n/utils": "workspace:*",
|
||||
"@typescript-eslint/typescript-estree": "catalog:",
|
||||
"@typescript-eslint/utils": "catalog:",
|
||||
"fastest-levenshtein": "catalog:"
|
||||
|
||||
+30
-3
@@ -26,6 +26,21 @@ ruleTester.run('n8n-object-validation', N8nObjectValidationRule, {
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 2, "nodes": ["dist/x.js"] } }',
|
||||
},
|
||||
{
|
||||
name: 'major-only string api version is allowed',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "3", "nodes": ["dist/x.js"] } }',
|
||||
},
|
||||
{
|
||||
name: 'major.minor string api version is allowed',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "3.1", "nodes": ["dist/x.js"] } }',
|
||||
},
|
||||
{
|
||||
name: 'two-digit minor string api version is allowed',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "3.10", "nodes": ["dist/x.js"] } }',
|
||||
},
|
||||
{
|
||||
name: 'non-package.json file is ignored',
|
||||
filename: 'some-config.json',
|
||||
@@ -77,10 +92,22 @@ ruleTester.run('n8n-object-validation', N8nObjectValidationRule, {
|
||||
errors: [{ messageId: 'missingNodesApiVersion' }],
|
||||
},
|
||||
{
|
||||
name: 'n8nNodesApiVersion is a string',
|
||||
name: 'n8nNodesApiVersion is a non-numeric string',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "1", "nodes": ["dist/x.js"] } }',
|
||||
errors: [{ messageId: 'invalidNodesApiVersion', data: { value: '1' } }],
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "v3", "nodes": ["dist/x.js"] } }',
|
||||
errors: [{ messageId: 'invalidNodesApiVersion', data: { value: 'v3' } }],
|
||||
},
|
||||
{
|
||||
name: 'n8nNodesApiVersion is a string with a patch part',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "3.1.0", "nodes": ["dist/x.js"] } }',
|
||||
errors: [{ messageId: 'invalidNodesApiVersion', data: { value: '3.1.0' } }],
|
||||
},
|
||||
{
|
||||
name: 'n8nNodesApiVersion is a zero-major string',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "0.1", "nodes": ["dist/x.js"] } }',
|
||||
errors: [{ messageId: 'invalidNodesApiVersion', data: { value: '0.1' } }],
|
||||
},
|
||||
{
|
||||
name: 'n8nNodesApiVersion is zero',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseNodesApiLevel } from '@n8n/utils/nodes-api-level';
|
||||
import type { TSESLint, TSESTree } from '@typescript-eslint/utils';
|
||||
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
||||
|
||||
@@ -34,9 +35,9 @@ export const N8nObjectValidationRule = createRule<[], MessageIds>({
|
||||
wrongLocationApiVersion:
|
||||
'"n8nNodesApiVersion" must be inside the "n8n" section, not at the root level of package.json.',
|
||||
missingNodesApiVersion:
|
||||
'The "n8n" object must declare "n8nNodesApiVersion" (a positive integer).',
|
||||
'The "n8n" object must declare "n8nNodesApiVersion" (a positive integer, or a "<major>" or "<major>.<minor>" string).',
|
||||
invalidNodesApiVersion:
|
||||
'"n8n.n8nNodesApiVersion" must be a positive integer, got {{ value }}.',
|
||||
'"n8n.n8nNodesApiVersion" must be a positive integer, or a "<major>" or "<major>.<minor>" string, got {{ value }}.',
|
||||
missingN8nNodes: 'The "n8n" object must declare "nodes" as an array of "dist/" paths.',
|
||||
n8nNodesNotArray: '"n8n.nodes" must be an array of "dist/" paths.',
|
||||
emptyN8nNodes: '"n8n.nodes" must contain at least one path.',
|
||||
@@ -108,7 +109,7 @@ function validateApiVersion(context: Context, n8nObject: TSESTree.ObjectExpressi
|
||||
}
|
||||
|
||||
const valueNode = apiVersionProp.value;
|
||||
if (valueNode.type !== AST_NODE_TYPES.Literal || !isPositiveInteger(valueNode.value)) {
|
||||
if (valueNode.type !== AST_NODE_TYPES.Literal || parseNodesApiLevel(valueNode.value) === null) {
|
||||
context.report({
|
||||
node: apiVersionProp,
|
||||
messageId: 'invalidNodesApiVersion',
|
||||
@@ -194,7 +195,3 @@ function validatePathArray(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isPositiveInteger(value: unknown): boolean {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value > 0;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,22 @@ describe('inboundFrom', () => {
|
||||
expect(inbound.surface).toBe('trigger');
|
||||
expect(inbound.acceptedSourceIds).toEqual(['source-a']);
|
||||
});
|
||||
|
||||
it('leaves grant unset when the resource issues no grant', () => {
|
||||
expect(inboundFrom(resource, request)).not.toHaveProperty('grant');
|
||||
});
|
||||
|
||||
it('takes grant from the resource when it issues one', () => {
|
||||
const granting: ProtectedResource = {
|
||||
...resource,
|
||||
surface: 'trigger',
|
||||
getGrant: () => ({ audiences: [resourceUrl], executeAccessWorkflowId: 'wf-1' }),
|
||||
};
|
||||
|
||||
const inbound = inboundFrom(granting, request);
|
||||
|
||||
expect(inbound.grant).toEqual({ audiences: [resourceUrl], executeAccessWorkflowId: 'wf-1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractBearer', () => {
|
||||
|
||||
@@ -4,10 +4,12 @@ import type { Extracted, Inbound, Result } from './pipeline';
|
||||
/** Derives `Inbound` from the resource, so a surface integration cannot stamp it wrong. */
|
||||
export function inboundFrom(resource: ProtectedResource, request: Inbound['request']): Inbound {
|
||||
const acceptedSourceIds = resource.getAcceptedSourceIds?.();
|
||||
const grant = resource.getGrant?.();
|
||||
return {
|
||||
surface: resource.surface,
|
||||
resource: { url: resource.getResourceUrl(), acceptedAudiences: resource.getAudiences() },
|
||||
...(acceptedSourceIds !== undefined && { acceptedSourceIds }),
|
||||
...(grant !== undefined && { grant }),
|
||||
request,
|
||||
receivedAt: new Date(),
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ProtectedResourceRef } from '@n8n/permissions';
|
||||
import type { ProtectedResourceRef, ResourceGrant } from '@n8n/permissions';
|
||||
|
||||
import type { TrustedSource } from './trusted-source';
|
||||
import type { SurfaceId } from './trusted-source-config';
|
||||
@@ -9,6 +9,8 @@ export type Inbound = {
|
||||
resource: ProtectedResourceRef;
|
||||
/** A trigger's selected source narrows the set of sources accepted on the surface. */
|
||||
acceptedSourceIds?: string[];
|
||||
/** What the resource hands out to a caller. Absent when the resource issues no grant. */
|
||||
grant?: ResourceGrant;
|
||||
request: {
|
||||
method: string;
|
||||
url: string;
|
||||
@@ -47,11 +49,24 @@ export type RejectReason =
|
||||
| 'audience-mismatch'
|
||||
| 'not-an-access-token'
|
||||
| 'unknown-subject'
|
||||
| 'user-disabled'
|
||||
| 'binding-inactive'
|
||||
| 'link-refused'
|
||||
| 'provision-refused'
|
||||
| 'no-role';
|
||||
|
||||
/** The caller as the trusted source describes it, before n8n looks for a matching user. */
|
||||
export type ExternalIdentity = {
|
||||
subject: string;
|
||||
email?: string;
|
||||
emailVerified?: boolean;
|
||||
displayName?: string;
|
||||
clientId?: string;
|
||||
scopes: string[];
|
||||
assurance?: { acr?: string; amr?: string[]; authTime?: Date };
|
||||
raw: Readonly<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type Reject = { ok: false; reason: RejectReason; detail?: string };
|
||||
export type Ok<T> = { ok: true; value: T };
|
||||
export type Result<T> = Ok<T> | Reject;
|
||||
|
||||
@@ -671,6 +671,33 @@ describe('BackgroundTaskManager', () => {
|
||||
expect(manager.getRunningTasks('thread-1')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('aborts running tasks with the supplied reason and leaves other threads running', () => {
|
||||
const started = manager.spawn(
|
||||
makeSpawnOptions({
|
||||
taskId: 't1',
|
||||
threadId: 'thread-1',
|
||||
run: async () => await new Promise(() => {}),
|
||||
}),
|
||||
);
|
||||
manager.spawn(
|
||||
makeSpawnOptions({
|
||||
taskId: 't2',
|
||||
threadId: 'thread-2',
|
||||
run: async () => await new Promise(() => {}),
|
||||
}),
|
||||
);
|
||||
const reason = new Error('quota');
|
||||
|
||||
manager.cancelThread('thread-1', reason);
|
||||
|
||||
expect(started.status).toBe('started');
|
||||
if (started.status === 'started') {
|
||||
expect(started.task.abortController.signal.reason).toBe(reason);
|
||||
expect(started.task.abortController.signal.aborted).toBe(true);
|
||||
}
|
||||
expect(manager.getRunningTasks('thread-2')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not cancel tasks for other threads', () => {
|
||||
manager.spawn(
|
||||
makeSpawnOptions({
|
||||
|
||||
@@ -341,6 +341,35 @@ describe('RunStateRegistry', () => {
|
||||
expect(registry.getThreadUser('unknown')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listLiveThreadIdsForUser', () => {
|
||||
it('lists active threads before suspended threads and skips duplicates', () => {
|
||||
registry.startRun({
|
||||
threadId: 'thread-active',
|
||||
user: { id: 'user-1', name: 'Alice' },
|
||||
});
|
||||
registry.startRun({
|
||||
threadId: 'thread-other',
|
||||
user: { id: 'user-2', name: 'Bob' },
|
||||
});
|
||||
registry.startRun({
|
||||
threadId: 'thread-suspended',
|
||||
user: { id: 'user-1', name: 'Alice' },
|
||||
});
|
||||
registry.suspendRun(
|
||||
'thread-suspended',
|
||||
createSuspendedRunState({
|
||||
threadId: 'thread-suspended',
|
||||
user: { id: 'user-1', name: 'Alice' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(registry.listLiveThreadIdsForUser('user-1')).toEqual([
|
||||
'thread-active',
|
||||
'thread-suspended',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── getThreadStatus ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -173,6 +173,11 @@ export class BackgroundTaskManager {
|
||||
);
|
||||
}
|
||||
|
||||
/** Running tasks on every thread. A finished parent can leave a builder looping. */
|
||||
listRunningTasks(): ManagedBackgroundTask[] {
|
||||
return [...this.tasks.values()].filter((task) => task.status === 'running');
|
||||
}
|
||||
|
||||
/** Sub-agents running across every thread on this process. */
|
||||
runningTaskCount(): number {
|
||||
let count = 0;
|
||||
@@ -230,11 +235,11 @@ export class BackgroundTaskManager {
|
||||
return task;
|
||||
}
|
||||
|
||||
cancelThread(threadId: string): ManagedBackgroundTask[] {
|
||||
cancelThread(threadId: string, abortReason?: unknown): ManagedBackgroundTask[] {
|
||||
const cancelled: ManagedBackgroundTask[] = [];
|
||||
for (const [taskId, task] of this.tasks) {
|
||||
if (task.threadId !== threadId || task.status !== 'running') continue;
|
||||
task.abortController.abort();
|
||||
task.abortController.abort(abortReason);
|
||||
task.status = 'cancelled';
|
||||
cancelled.push(task);
|
||||
this.tasks.delete(taskId);
|
||||
|
||||
@@ -499,6 +499,29 @@ export class RunStateRegistry<TUser = unknown> {
|
||||
return this.threadUsers.get(threadId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Threads with a live run for this user. Active runs come first, then
|
||||
* suspended runs. A thread that is both is listed once.
|
||||
*/
|
||||
listLiveThreadIdsForUser(userId: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const threadIds: string[] = [];
|
||||
const add = (threadId: string) => {
|
||||
if (seen.has(threadId)) return;
|
||||
seen.add(threadId);
|
||||
threadIds.push(threadId);
|
||||
};
|
||||
|
||||
for (const run of this.activeRuns.values()) {
|
||||
if (run.userId === userId) add(run.threadId);
|
||||
}
|
||||
for (const run of this.suspendedRuns.values()) {
|
||||
const ownerId = run.userId ?? this.getUserId(run.user);
|
||||
if (ownerId === userId) add(run.threadId);
|
||||
}
|
||||
return threadIds;
|
||||
}
|
||||
|
||||
setTimeZone(threadId: string, timeZone: string): void {
|
||||
this.threadTimeZones.set(threadId, timeZone);
|
||||
}
|
||||
|
||||
@@ -750,6 +750,80 @@ describe('createBuildWorkflowTool', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('places added nodes without the invalid node groups it drops', async () => {
|
||||
const start = {
|
||||
id: 'start',
|
||||
name: 'Start',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {},
|
||||
};
|
||||
const notify = {
|
||||
id: 'notify',
|
||||
name: 'Notify',
|
||||
type: 'n8n-nodes-base.noOp',
|
||||
typeVersion: 1,
|
||||
position: [400, -160] as [number, number],
|
||||
parameters: {},
|
||||
};
|
||||
const existingWorkflow = { name: 'Grouped workflow', nodes: [start, notify], connections: {} };
|
||||
vi.mocked(compileWorkflowSource).mockResolvedValueOnce({
|
||||
success: true,
|
||||
workflow: {
|
||||
name: 'Grouped workflow',
|
||||
nodes: [
|
||||
start,
|
||||
notify,
|
||||
{
|
||||
id: 'set',
|
||||
name: 'Set',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: { Start: { main: [[{ node: 'Set', type: 'main', index: 0 }]] } },
|
||||
// The chip of this group would land on Notify and push Set down.
|
||||
nodeGroups: [{ id: 'group-1', name: 'Broken group', nodeIds: ['set', 'missing-node'] }],
|
||||
},
|
||||
warnings: [],
|
||||
compiler: 'sandbox-tsx',
|
||||
});
|
||||
const { context, filePath } = makeContext({
|
||||
source: 'workflow source',
|
||||
overrides: {
|
||||
workflowService: {
|
||||
updateFromWorkflowJSON: vi.fn(
|
||||
async (workflowId: string) =>
|
||||
await Promise.resolve({ id: workflowId, versionId: 'v-next' }),
|
||||
),
|
||||
get: vi.fn(
|
||||
async (workflowId: string) =>
|
||||
await Promise.resolve({
|
||||
id: workflowId,
|
||||
versionId: 'v-current',
|
||||
checksum: 'checksum-current',
|
||||
}),
|
||||
),
|
||||
getAsWorkflowJSON: vi.fn(async () => await Promise.resolve(existingWorkflow)),
|
||||
clearAiTemporary: vi.fn(async () => await Promise.resolve()),
|
||||
} as unknown as InstanceAiContext['workflowService'],
|
||||
},
|
||||
});
|
||||
|
||||
await executeTool<BuildToolOutput>(createBuildWorkflowTool(context), {
|
||||
filePath,
|
||||
workflowId: 'wf-existing',
|
||||
});
|
||||
|
||||
const savedWorkflow = vi.mocked(context.workflowService.updateFromWorkflowJSON).mock
|
||||
.calls[0]?.[1];
|
||||
expect(savedWorkflow?.nodeGroups).toEqual([]);
|
||||
expect(savedWorkflow?.nodes.find((node) => node.name === 'Set')?.position).toEqual([224, 0]);
|
||||
});
|
||||
|
||||
describe('grouping decision check', () => {
|
||||
const wideWorkflow = (nodeGroups?: Array<{ id: string; name: string; nodeIds: string[] }>) => ({
|
||||
name: 'Wide workflow',
|
||||
|
||||
+97
-1
@@ -158,13 +158,30 @@ describe('preserveExistingNodePositions', () => {
|
||||
expect(positionsByName(built).Orphan).toEqual([320, 672]);
|
||||
});
|
||||
|
||||
it('moves the nodes after an inserted node right to make room for it', async () => {
|
||||
const saved = workflow([node('A', [0, 0]), node('B', [224, 0]), node('Other', [0, 400])]);
|
||||
const built = workflow(
|
||||
[node('A', [0, 0]), node('X', [224, 0]), node('B', [448, 0]), node('Other', [0, 200])],
|
||||
{ ...wire('A', 'X'), ...wire('X', 'B') },
|
||||
);
|
||||
|
||||
await preserveExistingNodePositions(built, 'wf-1', contextReturning(saved));
|
||||
|
||||
expect(positionsByName(built)).toEqual({
|
||||
A: [0, 0],
|
||||
X: [224, 0],
|
||||
B: [448, 0],
|
||||
Other: [0, 400],
|
||||
});
|
||||
});
|
||||
|
||||
it('pushes an added node clear of a survivor it would have landed on', async () => {
|
||||
// D was dragged well to the right, so the median translation drops the new
|
||||
// node right on top of it.
|
||||
const saved = workflow([node('A', [100, 100]), node('B', [300, 100]), node('D', [780, 100])]);
|
||||
const built = workflow(
|
||||
[node('A', [0, 0]), node('B', [224, 0]), node('D', [448, 0]), node('C', [672, 0])],
|
||||
{ ...wire('B', 'C'), ...wire('C', 'D') },
|
||||
wire('D', 'C'),
|
||||
);
|
||||
|
||||
await preserveExistingNodePositions(built, 'wf-1', contextReturning(saved));
|
||||
@@ -174,6 +191,85 @@ describe('preserveExistingNodePositions', () => {
|
||||
expect(positionsByName(built).C).toEqual([768, 288]);
|
||||
});
|
||||
|
||||
it('uses the drawn height of a node with many outputs', async () => {
|
||||
// The canvas draws a node with four outputs 160px tall, not 96px.
|
||||
const saved = workflow([node('Route', [400, 400], 'n8n-nodes-base.switch')]);
|
||||
const built = workflow(
|
||||
[node('Route', [0, 0], 'n8n-nodes-base.switch'), node('C', [0, 128])],
|
||||
{ Route: { main: [[], [], [], [{ node: 'C', type: 'main', index: 0 }]] } },
|
||||
);
|
||||
|
||||
await preserveExistingNodePositions(built, 'wf-1', contextReturning(saved));
|
||||
|
||||
// Translated to y=528, inside the Switch, then pushed below it.
|
||||
expect(positionsByName(built).C).toEqual([400, 656]);
|
||||
});
|
||||
|
||||
it('sizes a node by the outputs its node type declares, also the unwired ones', async () => {
|
||||
const saved = workflow([node('Route', [400, 400], 'n8n-nodes-base.switch')]);
|
||||
const built = workflow(
|
||||
[node('Route', [0, 0], 'n8n-nodes-base.switch'), node('C', [0, 128])],
|
||||
wire('Route', 'C'),
|
||||
);
|
||||
const switchType = {
|
||||
description: {
|
||||
properties: [],
|
||||
inputs: ['main'],
|
||||
outputs: ['main', 'main', 'main', 'main'],
|
||||
},
|
||||
};
|
||||
const ctx = {
|
||||
...contextReturning(saved),
|
||||
nodeTypesProvider: {
|
||||
getByNameAndVersion: (type: string) => {
|
||||
if (type === 'n8n-nodes-base.switch') return switchType;
|
||||
throw new Error(`Unknown node type: ${type}`);
|
||||
},
|
||||
},
|
||||
} as unknown as InstanceAiContext;
|
||||
|
||||
await preserveExistingNodePositions(built, 'wf-1', ctx);
|
||||
|
||||
// One wire, but the canvas draws all four outputs: 160px tall, so C moves below it.
|
||||
expect(positionsByName(built).C).toEqual([400, 656]);
|
||||
});
|
||||
|
||||
it('pushes an added node clear of a collapsed group header', async () => {
|
||||
// The build laid everything out 400px higher. The group header hangs above
|
||||
// its members, so C lands on the header without touching a node.
|
||||
const saved = workflow([node('M1', [400, 400]), node('M2', [608, 400])]);
|
||||
const built = {
|
||||
...workflow([node('M1', [400, 0]), node('M2', [608, 0]), node('C', [448, -128])]),
|
||||
nodeGroups: [{ id: 'g', name: 'Group', nodeIds: ['m1', 'm2'] }],
|
||||
};
|
||||
|
||||
await preserveExistingNodePositions(built, 'wf-1', contextReturning(saved));
|
||||
|
||||
// Pushed below the header. M1 hides behind the collapsed header, so it does not count.
|
||||
expect(positionsByName(built).C).toEqual([448, 464]);
|
||||
});
|
||||
|
||||
it('moves a new group up when an existing member pins its header onto a node', async () => {
|
||||
// N joins existing node X in a new group. The header hangs off X, so pushing
|
||||
// N down never clears the header from the trigger.
|
||||
const saved = workflow([node('Trigger', [96, 400]), node('X', [432, 560])]);
|
||||
const built = {
|
||||
...workflow([node('Trigger', [96, 0]), node('N', [224, 160]), node('X', [432, 160])], {
|
||||
...wire('Trigger', 'N'),
|
||||
...wire('N', 'X'),
|
||||
}),
|
||||
nodeGroups: [{ id: 'g', name: 'Group', nodeIds: ['n', 'x'] }],
|
||||
};
|
||||
|
||||
await preserveExistingNodePositions(built, 'wf-1', contextReturning(saved));
|
||||
|
||||
expect(positionsByName(built)).toEqual({
|
||||
Trigger: [96, 400],
|
||||
X: [432, 560],
|
||||
N: [224, 336],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not push an added node off a sticky note', async () => {
|
||||
const saved = workflow([node('A', [96, 96]), sticky('Sticky Note', [400, 96])]);
|
||||
const built = workflow([
|
||||
|
||||
+119
@@ -577,6 +577,125 @@ describe('compileWorkflowSource > node positions in JSON sources', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('compileWorkflowSource > node positions in TypeScript sources', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(validateWorkflow).mockReturnValue({ valid: true, errors: [], warnings: [] });
|
||||
});
|
||||
|
||||
// The sandbox lays this row out as if Writer were a plain node, because no tool is wired.
|
||||
const sandboxWorkflow = (): WorkflowJSON => ({
|
||||
name: 'Briefing',
|
||||
nodes: [
|
||||
{
|
||||
id: 's',
|
||||
name: 'Start',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
},
|
||||
{ id: 'w', name: 'Writer', type: 'test.writer', typeVersion: 1, position: [224, 0] },
|
||||
{ id: 'e', name: 'Email', type: 'n8n-nodes-base.noOp', typeVersion: 1, position: [448, 0] },
|
||||
{
|
||||
id: 'n',
|
||||
name: 'Note',
|
||||
type: 'n8n-nodes-base.stickyNote',
|
||||
typeVersion: 1,
|
||||
position: [0, -200],
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
Start: { main: [[{ node: 'Writer', type: 'main', index: 0 }]] },
|
||||
Writer: { main: [[{ node: 'Email', type: 'main', index: 0 }]] },
|
||||
},
|
||||
});
|
||||
const compileWithWriterInputs = async (
|
||||
inputs: string[],
|
||||
extraNode?: WorkflowJSON['nodes'][number],
|
||||
) => {
|
||||
const workflow = sandboxWorkflow();
|
||||
if (extraNode) workflow.nodes.push(extraNode);
|
||||
vi.mocked(runInSandbox).mockResolvedValue({
|
||||
exitCode: 0,
|
||||
stdout: JSON.stringify({ success: true, workflow, warnings: [] }),
|
||||
stderr: '',
|
||||
});
|
||||
const nodeTypesProvider = {
|
||||
getByNameAndVersion: (type: string) => {
|
||||
if (type === 'test.writer')
|
||||
return { description: { properties: [], inputs, outputs: ['main'] } };
|
||||
throw new Error(`Unknown node type: ${type}`);
|
||||
},
|
||||
};
|
||||
const result = await compileWorkflowSource(
|
||||
makeContext({ nodeTypesProvider } as unknown as Partial<InstanceAiContext>),
|
||||
'src/workflows/main.workflow.ts',
|
||||
'workflow source',
|
||||
);
|
||||
if (!result.success) throw new Error('compile failed');
|
||||
return new Map(result.workflow.nodes.map((node) => [node.name, node]));
|
||||
};
|
||||
|
||||
it('lays the nodes out again when a node type declares an unwired port', async () => {
|
||||
// The Tools port makes the canvas draw Writer 224 wide.
|
||||
const nodes = await compileWithWriterInputs(['main', 'ai_tool']);
|
||||
|
||||
expect(nodes.get('Email')?.position[0]).toBeGreaterThan(
|
||||
(nodes.get('Writer')?.position[0] ?? 0) + 224,
|
||||
);
|
||||
expect(nodes.get('Note')?.position).toEqual([0, -200]);
|
||||
});
|
||||
|
||||
it('keeps a sticky note around the nodes it wrapped when the nodes move', async () => {
|
||||
// The note wraps Writer and Email in the sandbox layout, with a 24 px margin.
|
||||
const nodes = await compileWithWriterInputs(['main', 'ai_tool'], {
|
||||
id: 'g',
|
||||
name: 'Group',
|
||||
type: 'n8n-nodes-base.stickyNote',
|
||||
typeVersion: 1,
|
||||
position: [200, -80],
|
||||
parameters: { width: 368, height: 200 },
|
||||
});
|
||||
|
||||
const group = nodes.get('Group');
|
||||
const writer = nodes.get('Writer');
|
||||
const email = nodes.get('Email');
|
||||
expect(group?.position).toEqual([
|
||||
(writer?.position[0] ?? 0) - 24,
|
||||
(writer?.position[1] ?? 0) - 80,
|
||||
]);
|
||||
// Writer is drawn 224 wide and Email 96 wide, so the note grows to keep the margin.
|
||||
expect(group?.parameters).toEqual({
|
||||
width: (email?.position[0] ?? 0) + 96 + 24 - (group?.position[0] ?? 0),
|
||||
height: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps an unnamed sticky note around the nodes it wrapped', async () => {
|
||||
const nodes = await compileWithWriterInputs(['main', 'ai_tool'], {
|
||||
id: 'g',
|
||||
type: 'n8n-nodes-base.stickyNote',
|
||||
typeVersion: 1,
|
||||
position: [200, -80],
|
||||
parameters: { width: 368, height: 200 },
|
||||
});
|
||||
|
||||
const email = nodes.get('Email');
|
||||
const group = [...nodes.values()].find((node) => node.id === 'g');
|
||||
expect(group?.parameters?.width).toBe(
|
||||
(email?.position[0] ?? 0) + 96 + 24 - (group?.position[0] ?? 0),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the sandbox layout when the node types give the same sizes', async () => {
|
||||
const nodes = await compileWithWriterInputs(['main']);
|
||||
|
||||
expect([...nodes.values()].map((node) => node.position)).toEqual(
|
||||
sandboxWorkflow().nodes.map((node) => node.position),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compileWorkflowSource credential resolution', () => {
|
||||
const hosts = [
|
||||
{ type: 'stripeApi', hosts: ['api.stripe.com'] },
|
||||
|
||||
@@ -1343,7 +1343,6 @@ export function createBuildWorkflowTool(context: InstanceAiContext) {
|
||||
await preserveExistingSetupValues(json, targetWorkflowId, context);
|
||||
await ensureWebhookIds(json, targetWorkflowId, context);
|
||||
await preserveExistingNodeGroupIds(json, targetWorkflowId, context);
|
||||
await preserveExistingNodePositions(json, targetWorkflowId, context);
|
||||
const groupCountBeforeDrop = json.nodeGroups?.length ?? 0;
|
||||
const droppedGroupWarnings = nodeGroupDroppedWarnings(
|
||||
dropInvalidWorkflowJsonGroups(
|
||||
@@ -1355,6 +1354,8 @@ export function createBuildWorkflowTool(context: InstanceAiContext) {
|
||||
);
|
||||
droppedGroupCount = groupCountBeforeDrop - (json.nodeGroups?.length ?? 0);
|
||||
informational.push(...droppedGroupWarnings);
|
||||
// After the drop: placement reads group membership.
|
||||
await preserveExistingNodePositions(json, targetWorkflowId, context);
|
||||
|
||||
const topLevel = summarizeWorkflowTopLevelItems(json);
|
||||
const grouping: GroupingOutcome = {
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { isRecord } from '@n8n/utils/is-record';
|
||||
import type { NodeJSON, WorkflowJSON } from '@n8n/workflow-sdk';
|
||||
import type { NodeJSON, NodePorts, WorkflowJSON } from '@n8n/workflow-sdk';
|
||||
import {
|
||||
DEFAULT_NODE_SIZE,
|
||||
GRID_SIZE,
|
||||
GROUP_HEADER_HEIGHT,
|
||||
GROUP_HEADER_WIDTH_COLLAPSED,
|
||||
GROUP_PADDING_X,
|
||||
GROUP_PADDING_Y_TOP,
|
||||
NODE_X_SPACING,
|
||||
NODE_Y_SPACING,
|
||||
getWorkflowNodeDimensions,
|
||||
isStickyNoteType,
|
||||
resolveNodePorts,
|
||||
} from '@n8n/workflow-sdk';
|
||||
|
||||
import type { InstanceAiContext } from '../../types';
|
||||
@@ -17,6 +23,8 @@ interface Box {
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
/** Group index for a member, -1 for free nodes and group chips. */
|
||||
layer: number;
|
||||
}
|
||||
|
||||
const [NODE_WIDTH, NODE_HEIGHT] = DEFAULT_NODE_SIZE;
|
||||
@@ -37,8 +45,40 @@ function median(values: number[]): number {
|
||||
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
||||
}
|
||||
|
||||
function boxOf(node: NodeJSON): Box {
|
||||
return { x: node.position[0], y: node.position[1], width: NODE_WIDTH, height: NODE_HEIGHT };
|
||||
const nameOf = (node: NodeJSON) => node.name ?? '';
|
||||
|
||||
/** Everything reachable from `start` through `next`, in visit order. */
|
||||
function reach<T>(start: Iterable<T>, next: (item: T) => Iterable<T>): Set<T> {
|
||||
const seen = new Set(start);
|
||||
for (const item of seen) for (const other of next(item)) seen.add(other);
|
||||
return seen;
|
||||
}
|
||||
|
||||
/**
|
||||
* The chip a collapsed group draws, hung off its members' top-left corner like
|
||||
* the canvas does (computeGroupFrameRects). Saved positions describe the
|
||||
* collapsed view: when a group expands, the canvas pushes its neighbours away.
|
||||
*/
|
||||
function chipBox(members: NodeJSON[]): Box {
|
||||
const minX = Math.min(...members.map((node) => node.position[0]));
|
||||
const minY = Math.min(...members.map((node) => node.position[1]));
|
||||
return {
|
||||
x: snapToGrid(minX - GROUP_PADDING_X),
|
||||
y: snapToGrid(minY - GROUP_PADDING_Y_TOP - GROUP_HEADER_HEIGHT),
|
||||
width: GROUP_HEADER_WIDTH_COLLAPSED,
|
||||
height: GROUP_HEADER_HEIGHT,
|
||||
layer: -1,
|
||||
};
|
||||
}
|
||||
|
||||
/** Member nodes of each group, resolved from the group's node ids. */
|
||||
function groupMembers(json: WorkflowJSON): NodeJSON[][] {
|
||||
const byId = new Map(
|
||||
(json.nodes ?? []).flatMap((node) => (node.id ? [[node.id, node] as const] : [])),
|
||||
);
|
||||
return (json.nodeGroups ?? [])
|
||||
.map((group) => group.nodeIds.flatMap((id) => byId.get(id) ?? []))
|
||||
.filter((members) => members.length > 0);
|
||||
}
|
||||
|
||||
/** Overlap test with a one-grid-cell gutter, so nodes never end up flush. */
|
||||
@@ -51,31 +91,39 @@ function intersects(a: Box, b: Box): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Direct predecessors and successors by node name, across all connection types. */
|
||||
function buildAdjacency(json: WorkflowJSON): {
|
||||
parentsOf: Map<string, string[]>;
|
||||
childrenOf: Map<string, string[]>;
|
||||
} {
|
||||
const parentsOf = new Map<string, string[]>();
|
||||
const childrenOf = new Map<string, string[]>();
|
||||
interface Links {
|
||||
parentsOf: (name: string) => string[];
|
||||
childrenOf: (name: string) => string[];
|
||||
}
|
||||
|
||||
for (const [source, connectionsByType] of Object.entries(json.connections ?? {})) {
|
||||
/** Connections by node name in both directions, for the connection types that `keep` accepts. */
|
||||
function linksOf(json: WorkflowJSON, keep: (type: string) => boolean = () => true): Links {
|
||||
const parents = new Map<string, string[]>();
|
||||
const children = new Map<string, string[]>();
|
||||
const push = (map: Map<string, string[]>, key: string, value: string) => {
|
||||
const list = map.get(key);
|
||||
if (list) list.push(value);
|
||||
else map.set(key, [value]);
|
||||
};
|
||||
for (const [from, connectionsByType] of Object.entries(json.connections ?? {})) {
|
||||
if (!isRecord(connectionsByType)) continue;
|
||||
for (const groups of Object.values(connectionsByType)) {
|
||||
if (!Array.isArray(groups)) continue;
|
||||
for (const [type, groups] of Object.entries(connectionsByType)) {
|
||||
if (!keep(type) || !Array.isArray(groups)) continue;
|
||||
for (const group of groups) {
|
||||
if (!Array.isArray(group)) continue;
|
||||
for (const connection of group) {
|
||||
if (!isRecord(connection) || typeof connection.node !== 'string') continue;
|
||||
const target = connection.node;
|
||||
childrenOf.set(source, [...(childrenOf.get(source) ?? []), target]);
|
||||
parentsOf.set(target, [...(parentsOf.get(target) ?? []), source]);
|
||||
if (isRecord(connection) && typeof connection.node === 'string') {
|
||||
push(children, from, connection.node);
|
||||
push(parents, connection.node, from);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { parentsOf, childrenOf };
|
||||
return {
|
||||
parentsOf: (name) => parents.get(name) ?? [],
|
||||
childrenOf: (name) => children.get(name) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
interface Survivor {
|
||||
@@ -83,6 +131,95 @@ interface Survivor {
|
||||
saved: Position;
|
||||
}
|
||||
|
||||
/** True when the build moved survivors, i.e. it laid the whole graph out again. */
|
||||
function wasRelaidOut(survivors: Survivor[]): boolean {
|
||||
return survivors.some(
|
||||
({ node, saved }) => node.position[0] !== saved[0] || node.position[1] !== saved[1],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift existing nodes right to make room for a node inserted between them, as
|
||||
* the canvas does on insert. The room is the extra gap that the build's layout
|
||||
* gave the insert, compared to the saved gap. Upstream nodes stay in place.
|
||||
*
|
||||
* Mutates the survivors' saved positions. Returns the spot of each inserted node
|
||||
* next to its parent, in the saved frame.
|
||||
*/
|
||||
function makeRoomForInsertions(
|
||||
survivors: Survivor[],
|
||||
added: NodeJSON[],
|
||||
json: WorkflowJSON,
|
||||
): Map<NodeJSON, Position> {
|
||||
// Without a re-layout, the build's gaps say nothing about the saved canvas.
|
||||
if (!wasRelaidOut(survivors)) return new Map();
|
||||
|
||||
const main = linksOf(json, (type) => type === 'main');
|
||||
const ai = linksOf(json, (type) => type.startsWith('ai_'));
|
||||
const groups = groupMembers(json).map((members) => members.map(nameOf));
|
||||
const survivorByName = new Map(survivors.map((survivor) => [nameOf(survivor.node), survivor]));
|
||||
const addedNames = new Set(added.map(nameOf));
|
||||
const inserted: Array<[NodeJSON, Survivor]> = [];
|
||||
|
||||
for (const node of added) {
|
||||
const parent = survivors.find((survivor) =>
|
||||
main.childrenOf(nameOf(survivor.node)).includes(nameOf(node)),
|
||||
);
|
||||
if (!parent) continue;
|
||||
|
||||
// The first existing nodes after the insert, reached through added nodes only.
|
||||
const run = reach([nameOf(node)], (name) =>
|
||||
main.childrenOf(name).filter((child) => addedNames.has(child)),
|
||||
);
|
||||
const next = new Set(
|
||||
[...run]
|
||||
.flatMap((name) => main.childrenOf(name))
|
||||
.flatMap((child) => survivorByName.get(child) ?? []),
|
||||
);
|
||||
// Appended after the parent, not inserted.
|
||||
if (next.size === 0) continue;
|
||||
|
||||
// The downstream flow moves as one, with its AI sub-nodes and groups.
|
||||
const downstream = reach(
|
||||
[...next].map((survivor) => nameOf(survivor.node)),
|
||||
(name) => [
|
||||
...main.childrenOf(name),
|
||||
...ai.parentsOf(name),
|
||||
...groups.filter((group) => group.includes(name)).flat(),
|
||||
],
|
||||
);
|
||||
// ponytail: a loop back to the parent, or a group shared with it, keeps the canvas as is.
|
||||
if (downstream.has(nameOf(parent.node))) continue;
|
||||
|
||||
const room = Math.max(
|
||||
0,
|
||||
...[...next].map(
|
||||
(survivor) =>
|
||||
survivor.node.position[0] -
|
||||
parent.node.position[0] -
|
||||
(survivor.saved[0] - parent.saved[0]),
|
||||
),
|
||||
);
|
||||
for (const survivor of survivors) {
|
||||
if (downstream.has(nameOf(survivor.node))) {
|
||||
survivor.saved = [survivor.saved[0] + room, survivor.saved[1]];
|
||||
}
|
||||
}
|
||||
inserted.push([node, parent]);
|
||||
}
|
||||
|
||||
// After every shift, so a parent that moved for an earlier insert is current.
|
||||
return new Map(
|
||||
inserted.map(([node, parent]) => [
|
||||
node,
|
||||
[
|
||||
snapToGrid(parent.saved[0] + node.position[0] - parent.node.position[0]),
|
||||
snapToGrid(parent.saved[1] + node.position[1] - parent.node.position[1]),
|
||||
],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Offset that carries the build's layout frame onto the saved canvas.
|
||||
*
|
||||
@@ -97,10 +234,7 @@ function resolveTranslation(
|
||||
json: WorkflowJSON,
|
||||
): Position {
|
||||
// The build re-laid out the whole graph, so survivors give us the mapping directly.
|
||||
const relaidOut = survivors.some(
|
||||
({ node, saved }) => node.position[0] !== saved[0] || node.position[1] !== saved[1],
|
||||
);
|
||||
if (relaidOut) {
|
||||
if (wasRelaidOut(survivors)) {
|
||||
return [
|
||||
median(survivors.map(({ node, saved }) => saved[0] - node.position[0])),
|
||||
median(survivors.map(({ node, saved }) => saved[1] - node.position[1])),
|
||||
@@ -110,20 +244,20 @@ function resolveTranslation(
|
||||
// The build kept the survivors' positions, so the layout engine skipped them and
|
||||
// only the added nodes were placed — in a frame unrelated to the saved canvas.
|
||||
// Anchor on a wired neighbour that did survive.
|
||||
const savedByName = new Map(survivors.map(({ node, saved }) => [node.name ?? '', saved]));
|
||||
const { parentsOf, childrenOf } = buildAdjacency(json);
|
||||
const savedByName = new Map(survivors.map(({ node, saved }) => [nameOf(node), saved]));
|
||||
const { parentsOf, childrenOf } = linksOf(json);
|
||||
|
||||
for (const node of added) {
|
||||
if (!node.name) continue;
|
||||
|
||||
for (const parent of parentsOf.get(node.name) ?? []) {
|
||||
for (const parent of parentsOf(node.name)) {
|
||||
const anchor = savedByName.get(parent);
|
||||
if (anchor) {
|
||||
return [anchor[0] + NODE_STEP_X - node.position[0], anchor[1] - node.position[1]];
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of childrenOf.get(node.name) ?? []) {
|
||||
for (const child of childrenOf(node.name)) {
|
||||
const anchor = savedByName.get(child);
|
||||
if (anchor) {
|
||||
return [anchor[0] - NODE_STEP_X - node.position[0], anchor[1] - node.position[1]];
|
||||
@@ -140,29 +274,106 @@ function resolveTranslation(
|
||||
return [savedMinX - addedMinX, savedMaxY + NODE_HEIGHT + NODE_Y_SPACING - addedMinY];
|
||||
}
|
||||
|
||||
/**
|
||||
* Push added nodes down until they clear everything already on the canvas.
|
||||
* Sticky notes are ignored on both sides — they are meant to sit behind nodes.
|
||||
*/
|
||||
function separateAddedNodes(added: NodeJSON[], allNodes: NodeJSON[]): void {
|
||||
const addedSet = new Set(added);
|
||||
const occupied = allNodes
|
||||
.filter((node) => !addedSet.has(node) && !isStickyNoteType(node.type))
|
||||
.map(boxOf);
|
||||
function findCollision(
|
||||
boxes: Box[],
|
||||
occupied: Box[],
|
||||
): { index: number; box: Box; other: Box } | undefined {
|
||||
for (const [index, box] of boxes.entries()) {
|
||||
const other = occupied.find(
|
||||
(candidate) => candidate.layer === box.layer && intersects(box, candidate),
|
||||
);
|
||||
if (other) return { index, box, other };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const node of added) {
|
||||
if (isStickyNoteType(node.type)) continue;
|
||||
/** Shift a block in one direction until its footprint clears `occupied`. */
|
||||
function shiftUntilClear(
|
||||
block: NodeJSON[],
|
||||
footprint: () => Box[],
|
||||
occupied: Box[],
|
||||
direction: 'down' | 'up',
|
||||
): boolean {
|
||||
for (let step = 0; step < MAX_SEPARATION_STEPS; step++) {
|
||||
const collision = findCollision(footprint(), occupied);
|
||||
if (!collision) return true;
|
||||
|
||||
for (let step = 0; step < MAX_SEPARATION_STEPS; step++) {
|
||||
const collision = occupied.find((box) => intersects(box, boxOf(node)));
|
||||
if (!collision) break;
|
||||
node.position = [
|
||||
node.position[0],
|
||||
snapToGrid(collision.y + collision.height + NODE_Y_SPACING),
|
||||
];
|
||||
const { index, box, other } = collision;
|
||||
const deltaY =
|
||||
direction === 'down'
|
||||
? other.y + other.height + NODE_Y_SPACING - box.y
|
||||
: other.y - NODE_Y_SPACING - box.height - box.y;
|
||||
for (const node of block) {
|
||||
node.position = [node.position[0], snapToGrid(node.position[1] + deltaY)];
|
||||
}
|
||||
|
||||
occupied.push(boxOf(node));
|
||||
// The colliding chip did not move: an existing member pins that edge.
|
||||
if (footprint()[index].y === box.y) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move added nodes clear of the nodes and group chips already on the canvas.
|
||||
* Added nodes move in blocks (wired to each other or sharing a group), so the
|
||||
* layout engine's rows stay intact. A block goes down first. If an existing
|
||||
* member pins its group chip, it goes up instead.
|
||||
* Members of a collapsed group hide behind its chip, so they only collide with
|
||||
* each other. Sticky notes are ignored on both sides, because they sit behind nodes.
|
||||
*/
|
||||
function separateAddedNodes(
|
||||
added: NodeJSON[],
|
||||
json: WorkflowJSON,
|
||||
nodePorts: ReadonlyMap<string, NodePorts> | undefined,
|
||||
): void {
|
||||
const nodes = json.nodes ?? [];
|
||||
const addedSet = new Set(added);
|
||||
const addedByName = new Map(added.map((node) => [nameOf(node), node]));
|
||||
const groups = groupMembers(json);
|
||||
const { parentsOf, childrenOf } = linksOf(json);
|
||||
const sizes = getWorkflowNodeDimensions(json, nodePorts);
|
||||
|
||||
const boxesOf = (members: NodeJSON[]): Box[] =>
|
||||
members
|
||||
.filter((node) => !isStickyNoteType(node.type))
|
||||
.map((node) => {
|
||||
const size = sizes.get(nameOf(node)) ?? { width: NODE_WIDTH, height: NODE_HEIGHT };
|
||||
const layer = groups.findIndex((group) => group.includes(node));
|
||||
return { x: node.position[0], y: node.position[1], ...size, layer };
|
||||
});
|
||||
const footprintOf = (members: NodeJSON[]) => [
|
||||
...boxesOf(members),
|
||||
...groups.filter((group) => group.some((node) => members.includes(node))).map(chipBox),
|
||||
];
|
||||
|
||||
const occupied = [
|
||||
...boxesOf(nodes.filter((node) => !addedSet.has(node))),
|
||||
...groups.filter((group) => !group.some((node) => addedSet.has(node))).map(chipBox),
|
||||
];
|
||||
|
||||
// Added nodes wired to the node or in a group with it.
|
||||
const linkedTo = (node: NodeJSON) =>
|
||||
[
|
||||
...[...parentsOf(nameOf(node)), ...childrenOf(nameOf(node))].flatMap(
|
||||
(name) => addedByName.get(name) ?? [],
|
||||
),
|
||||
...groups.filter((group) => group.includes(node)).flat(),
|
||||
].filter((other) => addedSet.has(other));
|
||||
|
||||
const placed = new Set<NodeJSON>();
|
||||
for (const start of added) {
|
||||
if (placed.has(start)) continue;
|
||||
const block = [...reach([start], linkedTo)];
|
||||
block.forEach((node) => placed.add(node));
|
||||
|
||||
const footprint = () => footprintOf(block);
|
||||
const origin = block.map((node) => node.position);
|
||||
for (const direction of ['down', 'up'] as const) {
|
||||
if (shiftUntilClear(block, footprint, occupied, direction)) break;
|
||||
// ponytail: a block that clears neither way keeps its translated position.
|
||||
block.forEach((node, i) => (node.position = origin[i]));
|
||||
}
|
||||
occupied.push(...footprint());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +387,9 @@ function separateAddedNodes(added: NodeJSON[], allNodes: NodeJSON[]): void {
|
||||
* renamed node keeps its place, mirroring ensureWebhookIds.
|
||||
*
|
||||
* Nodes the build added are translated into the saved canvas's frame, keeping the
|
||||
* layout engine's relative arrangement, then nudged clear of anything they land on.
|
||||
* layout engine's relative arrangement, then moved clear of anything they land on.
|
||||
* A node inserted between existing nodes takes the spot next to its parent, and
|
||||
* the existing nodes after it move right to make room.
|
||||
*/
|
||||
export async function preserveExistingNodePositions(
|
||||
json: WorkflowJSON,
|
||||
@@ -243,9 +456,10 @@ export async function preserveExistingNodePositions(
|
||||
if (survivors.length === 0) return;
|
||||
|
||||
if (added.length > 0) {
|
||||
const spots = makeRoomForInsertions(survivors, added, json);
|
||||
const [deltaX, deltaY] = resolveTranslation(survivors, added, json);
|
||||
for (const node of added) {
|
||||
node.position = [
|
||||
node.position = spots.get(node) ?? [
|
||||
snapToGrid(node.position[0] + deltaX),
|
||||
snapToGrid(node.position[1] + deltaY),
|
||||
];
|
||||
@@ -256,5 +470,7 @@ export async function preserveExistingNodePositions(
|
||||
node.position = saved;
|
||||
}
|
||||
|
||||
if (added.length > 0) separateAddedNodes(added, nodes);
|
||||
if (added.length === 0) return;
|
||||
const nodeTypes = ctx.nodeTypesProvider;
|
||||
separateAddedNodes(added, json, nodeTypes && (await resolveNodePorts(json, nodeTypes)));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { isStickyNoteType, type WorkflowJSON } from '@n8n/workflow-sdk';
|
||||
|
||||
type WorkflowNode = WorkflowJSON['nodes'][number];
|
||||
type NodeSizes = ReadonlyMap<string, { width: number; height: number }>;
|
||||
|
||||
interface Box {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** A sticky note and the nodes that sit inside it, with the space it leaves around them. */
|
||||
export interface StickyNoteWrap {
|
||||
note: WorkflowNode;
|
||||
members: WorkflowNode[];
|
||||
margins: { left: number; top: number; right: number; bottom: number };
|
||||
}
|
||||
|
||||
function boxOf(node: WorkflowNode, sizes: NodeSizes): Box | undefined {
|
||||
// The layout keys a node without a name by its ID.
|
||||
const size = sizes.get(node.name ?? node.id);
|
||||
if (!size || !Array.isArray(node.position)) return undefined;
|
||||
return { x: node.position[0], y: node.position[1], ...size };
|
||||
}
|
||||
|
||||
function contains(outer: Box, inner: Box): boolean {
|
||||
return (
|
||||
inner.x >= outer.x &&
|
||||
inner.y >= outer.y &&
|
||||
inner.x + inner.width <= outer.x + outer.width &&
|
||||
inner.y + inner.height <= outer.y + outer.height
|
||||
);
|
||||
}
|
||||
|
||||
function wrappingBox(boxes: Box[]): Box {
|
||||
const minX = Math.min(...boxes.map((box) => box.x));
|
||||
const minY = Math.min(...boxes.map((box) => box.y));
|
||||
const maxX = Math.max(...boxes.map((box) => box.x + box.width));
|
||||
const maxY = Math.max(...boxes.map((box) => box.y + box.height));
|
||||
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
||||
}
|
||||
|
||||
function boxesOf(nodes: WorkflowNode[], sizes: NodeSizes): Box[] {
|
||||
return nodes.flatMap((node) => boxOf(node, sizes) ?? []);
|
||||
}
|
||||
|
||||
function wrapOf(json: WorkflowJSON, note: WorkflowNode, sizes: NodeSizes): StickyNoteWrap[] {
|
||||
const noteBox = boxOf(note, sizes);
|
||||
if (!noteBox) return [];
|
||||
const members = json.nodes.filter((node) => {
|
||||
if (isStickyNoteType(node.type)) return false;
|
||||
const box = boxOf(node, sizes);
|
||||
return box !== undefined && contains(noteBox, box);
|
||||
});
|
||||
if (members.length === 0) return [];
|
||||
const wrapped = wrappingBox(boxesOf(members, sizes));
|
||||
const margins = {
|
||||
left: wrapped.x - noteBox.x,
|
||||
top: wrapped.y - noteBox.y,
|
||||
right: noteBox.x + noteBox.width - (wrapped.x + wrapped.width),
|
||||
bottom: noteBox.y + noteBox.height - (wrapped.y + wrapped.height),
|
||||
};
|
||||
return [{ note, members, margins }];
|
||||
}
|
||||
|
||||
/**
|
||||
* The sticky notes that wrap other nodes. A JSON workflow does not record which nodes a
|
||||
* note belongs to, so a note owns the nodes that sit fully inside it.
|
||||
*/
|
||||
export function findStickyNoteWraps(json: WorkflowJSON, sizes: NodeSizes): StickyNoteWrap[] {
|
||||
return json.nodes
|
||||
.filter((node) => isStickyNoteType(node.type))
|
||||
.flatMap((note) => wrapOf(json, note, sizes));
|
||||
}
|
||||
|
||||
/** Moves and resizes each note so that it wraps its nodes again, with the same margins. */
|
||||
export function rewrapStickyNotes(wraps: StickyNoteWrap[], sizes: NodeSizes): void {
|
||||
for (const { note, members, margins } of wraps) {
|
||||
const boxes = boxesOf(members, sizes);
|
||||
if (boxes.length === 0) continue;
|
||||
const wrapped = wrappingBox(boxes);
|
||||
note.position = [wrapped.x - margins.left, wrapped.y - margins.top];
|
||||
note.parameters = {
|
||||
...note.parameters,
|
||||
width: wrapped.width + margins.left + margins.right,
|
||||
height: wrapped.height + margins.top + margins.bottom,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,14 @@ import { createAbortError, isAbortError } from '@n8n/agents';
|
||||
import { getWorkspaceRoot } from '@n8n/agents/sandbox';
|
||||
import { isRecord } from '@n8n/utils/is-record';
|
||||
import {
|
||||
getWorkflowNodeDimensions,
|
||||
isStickyNoteType,
|
||||
resolveNodePorts,
|
||||
validateWorkflow,
|
||||
workflow as workflowBuilder,
|
||||
type WorkflowJSON,
|
||||
} from '@n8n/workflow-sdk';
|
||||
import { normalizeNodeShape } from 'n8n-workflow';
|
||||
import { normalizeNodeShape, type INodeTypes } from 'n8n-workflow';
|
||||
|
||||
import { buildCredentialHostIndex, resolveCredentialByUrl } from './credential-url-resolver';
|
||||
import { detectArrayInputCollapse } from './detect-array-input-collapse';
|
||||
@@ -14,6 +17,7 @@ import { detectPythonCodeConstraints } from './detect-python-code-constraints';
|
||||
import { detectSlackBlocksShape } from './detect-slack-blocks-shape';
|
||||
import { detectUnparseableOpenAiSchema } from './detect-unparseable-openai-schema';
|
||||
import { detectWrongKindLocatorValues } from './detect-wrong-kind-locator';
|
||||
import { findStickyNoteWraps, rewrapStickyNotes } from './sticky-note-wrapping';
|
||||
import { collectValidationIssues, type ValidationWarning } from './workflow-validation-warnings';
|
||||
import { traceSandboxOperation, sandboxFileBytes } from '../../tracing/sandbox-tracing';
|
||||
import type { InstanceAiContext } from '../../types';
|
||||
@@ -186,6 +190,45 @@ function fillMissingNodePositions(json: WorkflowJSON): void {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The sandbox has no node types, so its layout sizes each node from its wires only. A node
|
||||
* with an unwired port (an AI node's Tools port, a Switch route) is drawn bigger on the canvas
|
||||
* and overlaps its neighbours. When a node type gives another size, lay the nodes out again.
|
||||
*/
|
||||
async function layOutWithNodeTypes(
|
||||
json: WorkflowJSON,
|
||||
nodeTypes: INodeTypes | undefined,
|
||||
): Promise<void> {
|
||||
if (!nodeTypes) return;
|
||||
const nodePorts = await resolveNodePorts(json, nodeTypes);
|
||||
const wired = getWorkflowNodeDimensions(json);
|
||||
const declared = getWorkflowNodeDimensions(json, nodePorts);
|
||||
const sameSize = [...declared].every(
|
||||
([name, size]) =>
|
||||
size.width === wired.get(name)?.width && size.height === wired.get(name)?.height,
|
||||
);
|
||||
if (sameSize) return;
|
||||
|
||||
// The layout leaves sticky notes out, so each note follows the nodes it wrapped.
|
||||
const wraps = findStickyNoteWraps(json, wired);
|
||||
const nodes = json.nodes
|
||||
.filter((node) => !isStickyNoteType(node.type))
|
||||
.map(({ position: _sandboxPosition, ...node }) => node as WorkflowJSON['nodes'][number]);
|
||||
let laidOut: WorkflowJSON;
|
||||
try {
|
||||
laidOut = workflowBuilder.fromJSON({ ...json, nodes }).toJSON({ tidyUp: true, nodePorts });
|
||||
} catch {
|
||||
// The sandbox layout is still usable.
|
||||
return;
|
||||
}
|
||||
|
||||
const positions = new Map(laidOut.nodes.map((node) => [node.name, node.position]));
|
||||
for (const node of json.nodes) {
|
||||
node.position = positions.get(node.name) ?? node.position;
|
||||
}
|
||||
rewrapStickyNotes(wraps, declared);
|
||||
}
|
||||
|
||||
function parseSandboxWarnings(value: unknown): ValidationWarning[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
|
||||
@@ -355,6 +398,8 @@ async function compileTypeScriptWorkflowSource(
|
||||
};
|
||||
}
|
||||
|
||||
await layOutWithNodeTypes(buildOutput.workflow, context.nodeTypesProvider);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
workflow: buildOutput.workflow,
|
||||
|
||||
@@ -274,6 +274,27 @@ routes: [
|
||||
If `meta.middleware` has no `'custom'` entry, the route resolves. The state of the module then
|
||||
makes no difference.
|
||||
|
||||
### Placeholder page for licensed features
|
||||
|
||||
An unlicensed module is inactive, so its settings links and routes disappear. To show a paywall
|
||||
instead, set `placeholderPage` with the module's license feature:
|
||||
|
||||
```ts
|
||||
placeholderPage: {
|
||||
licenseFlag: 'logStreaming', // a key of `settings.enterprise`
|
||||
component: async () => await import('./views/MyFeaturePaywall.vue'),
|
||||
},
|
||||
```
|
||||
|
||||
When the module is inactive and `licenseFlag` is off, its settings links stay and its routes show
|
||||
this page. The `available` getter and route `rbac` still apply. The shell adds the `'custom'` guard
|
||||
to these routes, so you don't need to list it. Route views must be lazy loaders. The shell picks
|
||||
the view or the placeholder once, on the first load of the route.
|
||||
|
||||
If the module is licensed but inactive, for example turned off with `N8N_DISABLED_MODULES`, it
|
||||
stays hidden. A module that is unlicensed and also turned off still shows the page, because the
|
||||
frontend can't tell it apart from a module that is only unlicensed.
|
||||
|
||||
## Import-light descriptors
|
||||
|
||||
The descriptor file can import **types and the SDK only**. Load views lazily. Read a store inside
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ICredentialType, INodeProperties, Icon } from 'n8n-workflow';
|
||||
|
||||
import { GOOGLE_VERTEX_CUSTOM_PROJECT, searchGoogleProjects } from '../utils/google-vertex';
|
||||
|
||||
export class GoogleVertexAiApi implements ICredentialType {
|
||||
name = 'googleVertexAiApi';
|
||||
|
||||
extends = ['googleApi'];
|
||||
|
||||
displayName = 'Google Vertex AI';
|
||||
|
||||
documentationUrl = 'googlevertexai';
|
||||
|
||||
icon: Icon = 'file:icons/google.svg';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Project',
|
||||
name: 'project',
|
||||
type: 'options',
|
||||
options: [{ name: 'Custom', value: GOOGLE_VERTEX_CUSTOM_PROJECT }],
|
||||
default: GOOGLE_VERTEX_CUSTOM_PROJECT,
|
||||
required: true,
|
||||
description: 'Select a Google Cloud project, or select Custom to enter a project ID',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'gcpProjectsList',
|
||||
loadOptionsDependsOn: ['email', 'privateKey'],
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Project ID',
|
||||
name: 'projectId',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'my-project-id',
|
||||
description: 'Google Cloud project to use with Vertex AI',
|
||||
displayOptions: { show: { project: [GOOGLE_VERTEX_CUSTOM_PROJECT] } },
|
||||
},
|
||||
// Hide inherited settings that the Vertex AI client does not use.
|
||||
{
|
||||
displayName: 'Impersonate a User',
|
||||
name: 'inpersonate',
|
||||
type: 'hidden',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
displayName: 'Email',
|
||||
name: 'delegatedEmail',
|
||||
type: 'hidden',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Set up for use in HTTP Request node',
|
||||
name: 'httpNode',
|
||||
type: 'hidden',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
displayName: 'HTTP Request Warning',
|
||||
name: 'httpWarning',
|
||||
type: 'hidden',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Scope(s)',
|
||||
name: 'scopes',
|
||||
type: 'hidden',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Allowed HTTP Request Domains',
|
||||
name: 'allowedHttpRequestDomains',
|
||||
type: 'hidden',
|
||||
default: 'all',
|
||||
},
|
||||
{
|
||||
displayName: 'Allowed Domains',
|
||||
name: 'allowedDomains',
|
||||
type: 'hidden',
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
|
||||
methods = { loadOptions: { gcpProjectsList: searchGoogleProjects } };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 48 48"><defs><path id="a" d="M44.5 20H24v8.5h11.8C34.7 33.9 30.1 37 24 37c-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4C34.6 4.1 29.6 2 24 2 11.8 2 2 11.8 2 24s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4"/></defs><clipPath id="b"><use xlink:href="#a" overflow="visible"/></clipPath><path fill="#FBBC05" d="M0 37V11l17 13z" clip-path="url(#b)"/><path fill="#EA4335" d="m0 11 17 13 7-6.1L48 14V0H0z" clip-path="url(#b)"/><path fill="#34A853" d="m0 37 30-23 7.9 1L48 0v48H0z" clip-path="url(#b)"/><path fill="#4285F4" d="M48 48 17 24l-4-3 35-10z" clip-path="url(#b)"/></svg>
|
||||
|
After Width: | Height: | Size: 687 B |
+27
-54
@@ -1,7 +1,5 @@
|
||||
import { ProjectsClient } from '@google-cloud/resource-manager';
|
||||
import { VertexAIEmbeddings } from '@langchain/google-vertexai';
|
||||
import { logWrapper, getConnectionHintNoticeField } from '@n8n/ai-utilities';
|
||||
import { formatPemBlock } from '@n8n/utils/format-pem-block';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
import type {
|
||||
ILoadOptionsFunctions,
|
||||
@@ -11,41 +9,30 @@ import type {
|
||||
SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { getVertexEndpoint, vertexLocationField } from '../../llms/gemini-common/vertex-location';
|
||||
|
||||
import {
|
||||
getVertexEndpoint,
|
||||
resolveVertexLocation,
|
||||
vertexLocationField,
|
||||
} from '../../llms/gemini-common/vertex-location';
|
||||
googleVertexAuthentication,
|
||||
googleVertexCredentials,
|
||||
googleVertexAiCredentialTest,
|
||||
resolveGoogleVertexCredentials,
|
||||
searchGoogleProjects,
|
||||
} from '@utils/google-vertex';
|
||||
|
||||
export class EmbeddingsGoogleVertex implements INodeType {
|
||||
methods = {
|
||||
credentialTest: { googleVertexAiCredentialTest },
|
||||
listSearch: {
|
||||
async gcpProjectsList(this: ILoadOptionsFunctions) {
|
||||
const results: Array<{ name: string; value: string }> = [];
|
||||
|
||||
const credentials = await this.getCredentials('googleApi');
|
||||
const privateKey = formatPemBlock(credentials.privateKey as string);
|
||||
const email = (credentials.email as string).trim();
|
||||
|
||||
const client = new ProjectsClient({
|
||||
credentials: {
|
||||
client_email: email,
|
||||
private_key: privateKey,
|
||||
},
|
||||
});
|
||||
|
||||
const [projects] = await client.searchProjects();
|
||||
|
||||
for (const project of projects) {
|
||||
if (project.projectId) {
|
||||
results.push({
|
||||
name: project.displayName ?? project.projectId,
|
||||
value: project.projectId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { results };
|
||||
async gcpProjectsList(
|
||||
this: ILoadOptionsFunctions,
|
||||
filter?: string,
|
||||
paginationToken?: string,
|
||||
) {
|
||||
return await searchGoogleProjects(
|
||||
await this.getCredentials('googleApi'),
|
||||
filter,
|
||||
paginationToken,
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -64,12 +51,7 @@ export class EmbeddingsGoogleVertex implements INodeType {
|
||||
ignoreHttpStatusErrors: true,
|
||||
baseURL: '={{ $credentials.host }}',
|
||||
},
|
||||
credentials: [
|
||||
{
|
||||
name: 'googleApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
credentials: googleVertexCredentials,
|
||||
codex: {
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
@@ -90,6 +72,7 @@ export class EmbeddingsGoogleVertex implements INodeType {
|
||||
outputNames: ['Embeddings'],
|
||||
|
||||
properties: [
|
||||
googleVertexAuthentication,
|
||||
getConnectionHintNoticeField([NodeConnectionTypes.AiVectorStore]),
|
||||
{
|
||||
displayName:
|
||||
@@ -101,6 +84,7 @@ export class EmbeddingsGoogleVertex implements INodeType {
|
||||
{
|
||||
displayName: 'Project ID',
|
||||
name: 'projectId',
|
||||
displayOptions: { show: { authentication: ['googleApi'] } },
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
@@ -134,29 +118,18 @@ export class EmbeddingsGoogleVertex implements INodeType {
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
const credentials = await this.getCredentials('googleApi');
|
||||
const privateKey = formatPemBlock(credentials.privateKey as string);
|
||||
const email = (credentials.email as string).trim();
|
||||
|
||||
// A node-level location overrides the credential region; multi-region
|
||||
// locations (eu/us) need a dedicated host the SDK doesn't build itself.
|
||||
const locationOverride = this.getNodeParameter('location', itemIndex, '') as string;
|
||||
const location = resolveVertexLocation(locationOverride, credentials.region as string);
|
||||
const { projectId, credentials, location } = await resolveGoogleVertexCredentials(
|
||||
this,
|
||||
itemIndex,
|
||||
);
|
||||
const endpoint = getVertexEndpoint(location);
|
||||
|
||||
const modelName = this.getNodeParameter('modelName', itemIndex) as string;
|
||||
|
||||
const projectId = this.getNodeParameter('projectId', itemIndex, '', {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const embeddings = new VertexAIEmbeddings({
|
||||
authOptions: {
|
||||
projectId,
|
||||
credentials: {
|
||||
client_email: email,
|
||||
private_key: privateKey,
|
||||
},
|
||||
credentials,
|
||||
},
|
||||
location,
|
||||
...(endpoint ? { endpoint } : {}),
|
||||
|
||||
+27
-15
@@ -29,7 +29,7 @@ describe('EmbeddingsGoogleVertex - location resolution', () => {
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
const setupMockContext = (location: string | undefined) => {
|
||||
const setupMockContext = (location: string | undefined, authentication?: string) => {
|
||||
mockContext = createMockExecuteFunction<ISupplyDataFunctions>(
|
||||
{},
|
||||
mockNode,
|
||||
@@ -39,9 +39,11 @@ describe('EmbeddingsGoogleVertex - location resolution', () => {
|
||||
privateKey: 'test-private-key',
|
||||
email: '[email protected]',
|
||||
region: 'us-central1',
|
||||
projectId: 'target-project',
|
||||
});
|
||||
mockContext.getNode = vi.fn().mockReturnValue(mockNode);
|
||||
mockContext.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'authentication') return authentication;
|
||||
if (paramName === 'modelName') return 'text-embedding-005';
|
||||
if (paramName === 'projectId') return 'test-project';
|
||||
if (paramName === 'location') return location;
|
||||
@@ -56,23 +58,33 @@ describe('EmbeddingsGoogleVertex - location resolution', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('routes the EU multi-region location through the .rep. data-residency endpoint', async () => {
|
||||
const mockContext = setupMockContext('eu');
|
||||
it.each([undefined, 'googleApi', 'googleVertexAiApi'])(
|
||||
'routes the EU override with authentication %s',
|
||||
async (authentication) => {
|
||||
const mockContext = setupMockContext('eu', authentication);
|
||||
|
||||
await node.supplyData.call(mockContext, 0);
|
||||
await node.supplyData.call(mockContext, 0);
|
||||
|
||||
const callArgs = MockedVertexAIEmbeddings.mock.calls[0][0];
|
||||
expect(callArgs.location).toBe('eu');
|
||||
expect(callArgs.endpoint).toBe('aiplatform.eu.rep.googleapis.com');
|
||||
});
|
||||
const callArgs = MockedVertexAIEmbeddings.mock.calls[0][0];
|
||||
expect(callArgs.location).toBe('eu');
|
||||
expect(mockContext.getCredentials).toHaveBeenCalledWith(authentication ?? 'googleApi');
|
||||
expect(callArgs.endpoint).toBe('aiplatform.eu.rep.googleapis.com');
|
||||
expect(callArgs.authOptions?.projectId).toBe(
|
||||
authentication === 'googleVertexAiApi' ? 'target-project' : 'test-project',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('falls back to the credential region with no endpoint override', async () => {
|
||||
const mockContext = setupMockContext('');
|
||||
it.each([undefined, 'googleVertexAiApi'])(
|
||||
'falls back to the credential region with authentication %s',
|
||||
async (authentication) => {
|
||||
const mockContext = setupMockContext('', authentication);
|
||||
|
||||
await node.supplyData.call(mockContext, 0);
|
||||
await node.supplyData.call(mockContext, 0);
|
||||
|
||||
const callArgs = MockedVertexAIEmbeddings.mock.calls[0][0];
|
||||
expect(callArgs.location).toBe('us-central1');
|
||||
expect(callArgs).not.toHaveProperty('endpoint');
|
||||
});
|
||||
const callArgs = MockedVertexAIEmbeddings.mock.calls[0][0];
|
||||
expect(callArgs.location).toBe('us-central1');
|
||||
expect(callArgs).not.toHaveProperty('endpoint');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
+4
-1
@@ -99,7 +99,10 @@ const modelRLC: INodeProperties = {
|
||||
},
|
||||
default: 'models/gemini-2.5-flash',
|
||||
builderHint: {
|
||||
propertyHint: MODEL_SELECTION_HINT,
|
||||
// The dropdown loads through declarative routing, which the builder's resource lookup cannot call.
|
||||
propertyHint:
|
||||
'This node declares no model-list lookup, so its models cannot be listed, even with a connected credential. For a new choice, use current Google provider catalog information and choose a Gemini 3.x or newer model ID with the "models/" prefix. Do not choose Gemini 2.x or older. ' +
|
||||
MODEL_SELECTION_HINT,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { LmChatGoogleGemini } from '../LmChatGoogleGemini.node';
|
||||
|
||||
describe('LmChatGoogleGemini', () => {
|
||||
it('should send every model field to the provider catalog with a Gemini 3.x floor', () => {
|
||||
const hints = new LmChatGoogleGemini().description.properties
|
||||
.filter((p) => p.name === 'modelName')
|
||||
.map((p) => p.builderHint?.propertyHint);
|
||||
|
||||
expect(hints).toHaveLength(3);
|
||||
for (const hint of hints) {
|
||||
expect(hint).toContain('even with a connected credential');
|
||||
expect(hint).toContain('use current Google provider catalog information');
|
||||
expect(hint).toContain('Do not choose Gemini 2.x or older');
|
||||
}
|
||||
});
|
||||
|
||||
it('should not name builder tools in the model hint', () => {
|
||||
const hints = new LmChatGoogleGemini().description.properties
|
||||
.filter((p) => p.name === 'modelName')
|
||||
.map((p) => p.builderHint?.propertyHint);
|
||||
|
||||
for (const hint of hints) {
|
||||
expect(hint).not.toMatch(/searchModels|explore-resources|nodes\(/);
|
||||
}
|
||||
});
|
||||
});
|
||||
+27
-54
@@ -1,4 +1,3 @@
|
||||
import { ProjectsClient } from '@google-cloud/resource-manager';
|
||||
import type { GoogleAISafetySetting } from '@langchain/google-common';
|
||||
import { ChatVertexAI, type ChatVertexAIInput } from '@langchain/google-vertexai';
|
||||
import {
|
||||
@@ -6,7 +5,6 @@ import {
|
||||
N8nLlmTracing,
|
||||
getConnectionHintNoticeField,
|
||||
} from '@n8n/ai-utilities';
|
||||
import { formatPemBlock } from '@n8n/utils/format-pem-block';
|
||||
import {
|
||||
NodeConnectionTypes,
|
||||
type INodeType,
|
||||
@@ -24,11 +22,15 @@ import { MODEL_SELECTION_HINT } from '@utils/model-builder-hints';
|
||||
|
||||
import { extractGoogleErrorMessage, makeErrorFromStatus } from './error-handling';
|
||||
import { getAdditionalOptions } from '../gemini-common/additional-options';
|
||||
import { getVertexEndpoint, vertexLocationField } from '../gemini-common/vertex-location';
|
||||
|
||||
import {
|
||||
getVertexEndpoint,
|
||||
resolveVertexLocation,
|
||||
vertexLocationField,
|
||||
} from '../gemini-common/vertex-location';
|
||||
googleVertexAuthentication,
|
||||
googleVertexCredentials,
|
||||
googleVertexAiCredentialTest,
|
||||
resolveGoogleVertexCredentials,
|
||||
searchGoogleProjects,
|
||||
} from '@utils/google-vertex';
|
||||
|
||||
function errorDescriptionMapper(error: NodeError) {
|
||||
if (error.description?.includes('properties: should be non-empty for OBJECT type')) {
|
||||
@@ -69,17 +71,14 @@ export class LmChatGoogleVertex implements INodeType {
|
||||
|
||||
outputs: [NodeConnectionTypes.AiLanguageModel],
|
||||
outputNames: ['Model'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'googleApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
credentials: googleVertexCredentials,
|
||||
properties: [
|
||||
googleVertexAuthentication,
|
||||
getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]),
|
||||
{
|
||||
displayName: 'Project ID',
|
||||
name: 'projectId',
|
||||
displayOptions: { show: { authentication: ['googleApi'] } },
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
@@ -119,54 +118,31 @@ export class LmChatGoogleVertex implements INodeType {
|
||||
};
|
||||
|
||||
methods = {
|
||||
credentialTest: { googleVertexAiCredentialTest },
|
||||
listSearch: {
|
||||
async gcpProjectsList(this: ILoadOptionsFunctions) {
|
||||
const results: Array<{ name: string; value: string }> = [];
|
||||
|
||||
const credentials = await this.getCredentials('googleApi');
|
||||
const privateKey = formatPemBlock(credentials.privateKey as string);
|
||||
const email = (credentials.email as string).trim();
|
||||
|
||||
const client = new ProjectsClient({
|
||||
credentials: {
|
||||
client_email: email,
|
||||
private_key: privateKey,
|
||||
},
|
||||
});
|
||||
|
||||
const [projects] = await client.searchProjects();
|
||||
|
||||
for (const project of projects) {
|
||||
if (project.projectId) {
|
||||
results.push({
|
||||
name: project.displayName ?? project.projectId,
|
||||
value: project.projectId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { results };
|
||||
async gcpProjectsList(
|
||||
this: ILoadOptionsFunctions,
|
||||
filter?: string,
|
||||
paginationToken?: string,
|
||||
) {
|
||||
return await searchGoogleProjects(
|
||||
await this.getCredentials('googleApi'),
|
||||
filter,
|
||||
paginationToken,
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
const credentials = await this.getCredentials('googleApi');
|
||||
const privateKey = formatPemBlock(credentials.privateKey as string);
|
||||
const email = (credentials.email as string).trim();
|
||||
|
||||
// A node-level location overrides the credential region; multi-region
|
||||
// locations (eu/us) need a dedicated host the SDK doesn't build itself.
|
||||
const locationOverride = this.getNodeParameter('location', itemIndex, '') as string;
|
||||
const location = resolveVertexLocation(locationOverride, credentials.region as string);
|
||||
const { projectId, credentials, location } = await resolveGoogleVertexCredentials(
|
||||
this,
|
||||
itemIndex,
|
||||
);
|
||||
const endpoint = getVertexEndpoint(location);
|
||||
|
||||
const modelName = this.getNodeParameter('modelName', itemIndex) as string;
|
||||
|
||||
const projectId = this.getNodeParameter('projectId', itemIndex, '', {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const options = this.getNodeParameter('options', itemIndex, {
|
||||
maxOutputTokens: 2048,
|
||||
temperature: 0.4,
|
||||
@@ -197,10 +173,7 @@ export class LmChatGoogleVertex implements INodeType {
|
||||
const modelConfig: ChatVertexAIInput = {
|
||||
authOptions: {
|
||||
projectId,
|
||||
credentials: {
|
||||
client_email: email,
|
||||
private_key: privateKey,
|
||||
},
|
||||
credentials,
|
||||
},
|
||||
location,
|
||||
...(endpoint ? { endpoint } : {}),
|
||||
|
||||
+62
-28
@@ -38,6 +38,7 @@ describe('LmChatGoogleVertex - Thinking Budget', () => {
|
||||
privateKey: 'test-private-key',
|
||||
email: '[email protected]',
|
||||
region: 'us-central1',
|
||||
projectId: ' target-project ',
|
||||
});
|
||||
mockContext.getNode = vi.fn().mockReturnValue(mockNode);
|
||||
//@ts-expect-error - Mocking
|
||||
@@ -78,6 +79,9 @@ describe('LmChatGoogleVertex - Thinking Budget', () => {
|
||||
});
|
||||
|
||||
await lmChatGoogleVertex.supplyData.call(mockContext, 0);
|
||||
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('projectId', 0, '', {
|
||||
extractValue: true,
|
||||
});
|
||||
expect(MockedChatVertexAI).toHaveBeenCalledTimes(1);
|
||||
const callArgs = MockedChatVertexAI.mock.calls[0][0];
|
||||
expect(callArgs).not.toHaveProperty('thinkingBudget');
|
||||
@@ -98,24 +102,32 @@ describe('LmChatGoogleVertex - Thinking Budget', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the node-level location override, with no endpoint override for global', async () => {
|
||||
const mockContext = setupMockContext();
|
||||
it.each([undefined, 'googleVertexAiApi'])(
|
||||
'uses the global node override with authentication %s',
|
||||
async (authentication) => {
|
||||
const mockContext = setupMockContext();
|
||||
|
||||
mockContext.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'modelName') return 'gemini-3.1-flash-lite';
|
||||
if (paramName === 'projectId') return 'test-project';
|
||||
if (paramName === 'location') return 'global';
|
||||
if (paramName === 'options') return {};
|
||||
if (paramName === 'options.safetySettings.values') return null;
|
||||
return undefined;
|
||||
});
|
||||
mockContext.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'authentication') return authentication;
|
||||
if (paramName === 'modelName') return 'gemini-3.1-flash-lite';
|
||||
if (paramName === 'projectId') return ' test-project ';
|
||||
if (paramName === 'location') return 'global';
|
||||
if (paramName === 'options') return {};
|
||||
if (paramName === 'options.safetySettings.values') return null;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatGoogleVertex.supplyData.call(mockContext, 0);
|
||||
await lmChatGoogleVertex.supplyData.call(mockContext, 0);
|
||||
|
||||
const callArgs = MockedChatVertexAI.mock.calls[0][0];
|
||||
expect(callArgs?.location).toBe('global');
|
||||
expect(callArgs).not.toHaveProperty('endpoint');
|
||||
});
|
||||
const callArgs = MockedChatVertexAI.mock.calls[0][0];
|
||||
expect(callArgs?.location).toBe('global');
|
||||
expect(mockContext.getCredentials).toHaveBeenCalledWith(authentication ?? 'googleApi');
|
||||
expect(callArgs).not.toHaveProperty('endpoint');
|
||||
expect(callArgs?.authOptions?.projectId).toBe(
|
||||
authentication === 'googleVertexAiApi' ? 'target-project' : 'test-project',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('routes the EU multi-region location through the .rep. data-residency endpoint', async () => {
|
||||
const mockContext = setupMockContext();
|
||||
@@ -136,23 +148,45 @@ describe('LmChatGoogleVertex - Thinking Budget', () => {
|
||||
expect(callArgs?.endpoint).toBe('aiplatform.eu.rep.googleapis.com');
|
||||
});
|
||||
|
||||
it('falls back to the credential region when no location override is set', async () => {
|
||||
const mockContext = setupMockContext();
|
||||
it.each([undefined, 'googleVertexAiApi'])(
|
||||
'falls back to the credential region with authentication %s',
|
||||
async (authentication) => {
|
||||
const mockContext = setupMockContext();
|
||||
|
||||
mockContext.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'modelName') return 'gemini-2.5-flash';
|
||||
if (paramName === 'projectId') return 'test-project';
|
||||
if (paramName === 'location') return '';
|
||||
if (paramName === 'options') return {};
|
||||
if (paramName === 'options.safetySettings.values') return null;
|
||||
mockContext.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'authentication') return authentication;
|
||||
if (paramName === 'modelName') return 'gemini-2.5-flash';
|
||||
if (paramName === 'projectId') return 'test-project';
|
||||
if (paramName === 'location') return '';
|
||||
if (paramName === 'options') return {};
|
||||
if (paramName === 'options.safetySettings.values') return null;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatGoogleVertex.supplyData.call(mockContext, 0);
|
||||
|
||||
const callArgs = MockedChatVertexAI.mock.calls[0][0];
|
||||
expect(callArgs?.location).toBe('us-central1');
|
||||
expect(callArgs).not.toHaveProperty('endpoint');
|
||||
},
|
||||
);
|
||||
|
||||
it('requires the new credential project even when a node project is stored', async () => {
|
||||
const context = setupMockContext();
|
||||
context.getCredentials.mockResolvedValue({
|
||||
email: '[email protected]',
|
||||
privateKey: 'test-private-key',
|
||||
region: 'global',
|
||||
projectId: '',
|
||||
});
|
||||
context.getNodeParameter.mockImplementation((name: string) => {
|
||||
if (name === 'authentication') return 'googleVertexAiApi';
|
||||
if (name === 'projectId') return 'stale-node-project';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatGoogleVertex.supplyData.call(mockContext, 0);
|
||||
|
||||
const callArgs = MockedChatVertexAI.mock.calls[0][0];
|
||||
expect(callArgs?.location).toBe('us-central1');
|
||||
expect(callArgs).not.toHaveProperty('endpoint');
|
||||
await expect(lmChatGoogleVertex.supplyData.call(context, 0)).rejects.toThrow('project ID');
|
||||
expect(MockedChatVertexAI).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should include thinkingBudget in model config when specified', async () => {
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"dist/credentials/DaytonaApi.credentials.js",
|
||||
"dist/credentials/DeepSeekApi.credentials.js",
|
||||
"dist/credentials/GooglePalmApi.credentials.js",
|
||||
"dist/credentials/GoogleVertexAiApi.credentials.js",
|
||||
"dist/credentials/GroqApi.credentials.js",
|
||||
"dist/credentials/HuggingFaceApi.credentials.js",
|
||||
"dist/credentials/MicrosoftAgent365Api.credentials.js",
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { getErrorMessage } from '@n8n/utils/errors/get-error-message';
|
||||
import {
|
||||
getGoogleAccessToken,
|
||||
getGoogleServiceAccountCredentials,
|
||||
} from 'n8n-nodes-base/google-service-account';
|
||||
import {
|
||||
NodeOperationError,
|
||||
type ICredentialDataDecryptedObject,
|
||||
type ICredentialsDecrypted,
|
||||
type ICredentialTestFunctions,
|
||||
type INodeCredentialDescription,
|
||||
type INodeCredentialTestResult,
|
||||
type INodeListSearchResult,
|
||||
type INodeProperties,
|
||||
type ISupplyDataFunctions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { resolveVertexLocation } from '../nodes/llms/gemini-common/vertex-location';
|
||||
|
||||
export const GOOGLE_VERTEX_CUSTOM_PROJECT = '__custom__';
|
||||
|
||||
export const googleVertexCredentials: INodeCredentialDescription[] = [
|
||||
{
|
||||
name: 'googleApi',
|
||||
required: true,
|
||||
displayOptions: { show: { authentication: ['googleApi'] } },
|
||||
},
|
||||
{
|
||||
name: 'googleVertexAiApi',
|
||||
required: true,
|
||||
testedBy: 'googleVertexAiCredentialTest',
|
||||
displayOptions: { show: { authentication: ['googleVertexAiApi'] } },
|
||||
},
|
||||
];
|
||||
|
||||
export const googleVertexAuthentication: INodeProperties = {
|
||||
displayName: 'Authentication',
|
||||
name: 'authentication',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{ name: 'Google Service Account', value: 'googleApi' },
|
||||
{ name: 'Google Vertex AI', value: 'googleVertexAiApi' },
|
||||
],
|
||||
default: 'googleApi',
|
||||
};
|
||||
|
||||
function getGoogleVertexProjectId(credentials: ICredentialDataDecryptedObject) {
|
||||
const project = credentials.project ?? GOOGLE_VERTEX_CUSTOM_PROJECT;
|
||||
return project === GOOGLE_VERTEX_CUSTOM_PROJECT ? credentials.projectId : project;
|
||||
}
|
||||
|
||||
export async function resolveGoogleVertexCredentials(
|
||||
context: ISupplyDataFunctions,
|
||||
itemIndex: number,
|
||||
) {
|
||||
const authentication = context.getNodeParameter('authentication', itemIndex, 'googleApi');
|
||||
const credentialType = authentication === 'googleVertexAiApi' ? 'googleVertexAiApi' : 'googleApi';
|
||||
const credentials = await context.getCredentials(credentialType);
|
||||
let projectId: unknown;
|
||||
if (credentialType === 'googleVertexAiApi') {
|
||||
projectId = getGoogleVertexProjectId(credentials);
|
||||
} else {
|
||||
projectId = context.getNodeParameter('projectId', itemIndex, '', { extractValue: true });
|
||||
}
|
||||
if (typeof projectId !== 'string' || !projectId.trim()) {
|
||||
throw new NodeOperationError(context.getNode(), 'Select or enter a Google Cloud project ID.');
|
||||
}
|
||||
if (typeof credentials.region !== 'string') {
|
||||
throw new NodeOperationError(context.getNode(), 'Select a Region in the credential.');
|
||||
}
|
||||
const locationOverride = context.getNodeParameter('location', itemIndex, '');
|
||||
const location = resolveVertexLocation(
|
||||
typeof locationOverride === 'string' ? locationOverride : undefined,
|
||||
credentials.region,
|
||||
);
|
||||
|
||||
return {
|
||||
projectId: projectId.trim(),
|
||||
location,
|
||||
credentials: getGoogleServiceAccountCredentials(credentials),
|
||||
};
|
||||
}
|
||||
|
||||
export async function googleVertexAiCredentialTest(
|
||||
this: ICredentialTestFunctions,
|
||||
credential: ICredentialsDecrypted,
|
||||
): Promise<INodeCredentialTestResult> {
|
||||
const data = credential.data ?? {};
|
||||
const projectId = getGoogleVertexProjectId(data);
|
||||
if (typeof projectId !== 'string' || !projectId.trim()) {
|
||||
return { status: 'Error', message: 'Select or enter a Google Cloud project ID.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const { client_email, private_key } = getGoogleServiceAccountCredentials(data);
|
||||
const token = await getGoogleAccessToken.call(
|
||||
this,
|
||||
{ email: client_email, privateKey: private_key },
|
||||
'vertex',
|
||||
);
|
||||
if (typeof token.access_token !== 'string' || !token.access_token) {
|
||||
return {
|
||||
status: 'Error',
|
||||
message: 'Could not get an access token. Check the service account email and private key.',
|
||||
};
|
||||
}
|
||||
|
||||
// Check project access without running a model or requiring the Resource Manager API.
|
||||
await this.helpers.request({
|
||||
method: 'GET',
|
||||
uri: `https://aiplatform.googleapis.com/v1/projects/${encodeURIComponent(projectId.trim())}/locations`,
|
||||
headers: { Authorization: `Bearer ${token.access_token}` },
|
||||
qs: { pageSize: 1 },
|
||||
json: true,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return { status: 'OK', message: 'Connection successful' };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'Error',
|
||||
message: `Could not connect to Vertex AI. Check the service account details, project ID, and Vertex AI permissions. ${getErrorMessage(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchGoogleProjects(
|
||||
credentials: ICredentialDataDecryptedObject,
|
||||
filter?: string,
|
||||
paginationToken?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
const { ProjectsClient } = await import('@google-cloud/resource-manager');
|
||||
const client = new ProjectsClient({
|
||||
credentials: getGoogleServiceAccountCredentials(credentials),
|
||||
});
|
||||
try {
|
||||
const trimmedFilter = filter?.trim();
|
||||
let query: string | undefined;
|
||||
if (trimmedFilter) {
|
||||
const prefix = JSON.stringify(`${trimmedFilter}*`);
|
||||
query = `displayName:${prefix} projectId:${prefix}`;
|
||||
}
|
||||
const [projects, nextPage] = await client.searchProjects(
|
||||
{ pageToken: paginationToken, ...(query ? { query } : {}) },
|
||||
{ autoPaginate: false },
|
||||
);
|
||||
return {
|
||||
results: projects.flatMap((project) => {
|
||||
if (!project.projectId) return [];
|
||||
return [
|
||||
{
|
||||
name: project.displayName
|
||||
? `${project.displayName} (${project.projectId})`
|
||||
: project.projectId,
|
||||
value: project.projectId,
|
||||
},
|
||||
];
|
||||
}),
|
||||
paginationToken: nextPage?.pageToken ?? undefined,
|
||||
};
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user