Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bda5f0d68 | ||
|
|
5a1ae372dc | ||
|
|
51e3eddb29 | ||
|
|
33a403c0bd | ||
|
|
27db8bbae4 | ||
|
|
84ba7eb8dc | ||
|
|
a3d73740a1 | ||
|
|
0d3c7a47af | ||
|
|
2dbdd72e65 | ||
|
|
f20d04eb6e | ||
|
|
f72898063a |
@@ -0,0 +1,106 @@
|
||||
name: CI New UI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
merge_group:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
changed-files-check:
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: ./.github/workflows/changed-files.yaml
|
||||
with:
|
||||
files: |
|
||||
package.json
|
||||
yarn.lock
|
||||
packages/twenty-new-ui/**
|
||||
packages/twenty-shared/**
|
||||
new-ui-task:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
task: [lint, typecheck, test]
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 10
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Run ${{ matrix.task }}
|
||||
run: npx nx ${{ matrix.task }} twenty-new-ui
|
||||
new-ui-sb-build:
|
||||
needs: changed-files-check
|
||||
if: >-
|
||||
always() &&
|
||||
(github.event_name == 'push' ||
|
||||
needs.changed-files-check.outputs.any_changed == 'true')
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 10
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Build storybook
|
||||
run: npx nx storybook:build twenty-new-ui
|
||||
- name: Upload storybook build
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: storybook-twenty-new-ui
|
||||
path: packages/twenty-new-ui/storybook-static
|
||||
retention-days: 1
|
||||
new-ui-sb-test:
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
needs: new-ui-sb-build
|
||||
if: always() && needs.new-ui-sb-build.result == 'success'
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 10
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Build dependencies
|
||||
run: npx nx build twenty-shared
|
||||
- name: Download storybook build
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: storybook-twenty-new-ui
|
||||
path: packages/twenty-new-ui/storybook-static
|
||||
- name: Install Playwright
|
||||
run: |
|
||||
cd packages/twenty-new-ui
|
||||
npx playwright install
|
||||
- name: Run storybook tests
|
||||
run: npx nx storybook:test twenty-new-ui
|
||||
- name: Upload screenshots for visual regression
|
||||
if: always() && !cancelled()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: argos-screenshots-twenty-new-ui
|
||||
path: packages/twenty-new-ui/screenshots
|
||||
retention-days: 1
|
||||
ci-new-ui-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check, new-ui-task, new-ui-sb-build, new-ui-sb-test]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
@@ -3,10 +3,13 @@ name: Visual Regression Dispatch
|
||||
# Dispatches visual regression processing to ci-privileged after CI completes.
|
||||
# Runs in the context of the base repo (not the fork) so it has access to secrets,
|
||||
# making it work for external contributor PRs.
|
||||
#
|
||||
# All dispatches use the same event_type=visual-regression with project/artifact_name
|
||||
# in the payload. ci-privileged routes to the correct Argos project based on these.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI UI']
|
||||
workflows: ['CI UI', 'CI New UI']
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
@@ -15,19 +18,37 @@ permissions:
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
dispatch-pr:
|
||||
if: >-
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
resolve-context:
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
workflow_name: ${{ steps.context.outputs.workflow_name }}
|
||||
artifact_name: ${{ steps.context.outputs.artifact_name }}
|
||||
has_artifact: ${{ steps.check-artifact.outputs.exists }}
|
||||
is_pr: ${{ steps.pr-info.outputs.has_pr }}
|
||||
pr_number: ${{ steps.pr-info.outputs.pr_number }}
|
||||
merge_base_sha: ${{ steps.merge-base.outputs.sha }}
|
||||
steps:
|
||||
- name: Resolve workflow context
|
||||
id: context
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const name = context.payload.workflow_run.name;
|
||||
const artifactName = name === 'CI New UI'
|
||||
? 'argos-screenshots-twenty-new-ui'
|
||||
: 'argos-screenshots-twenty-ui';
|
||||
core.setOutput('workflow_name', name);
|
||||
core.setOutput('artifact_name', artifactName);
|
||||
core.info(`Workflow: ${name}, artifact: ${artifactName}`);
|
||||
|
||||
- name: Check if screenshots artifact exists
|
||||
id: check-artifact
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const artifactName = 'argos-screenshots-twenty-ui';
|
||||
const artifactName = '${{ steps.context.outputs.artifact_name }}';
|
||||
const runId = context.payload.workflow_run.id;
|
||||
|
||||
const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
@@ -44,7 +65,9 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Get PR number
|
||||
if: steps.check-artifact.outputs.exists == 'true'
|
||||
if: >-
|
||||
steps.check-artifact.outputs.exists == 'true' &&
|
||||
github.event.workflow_run.event == 'pull_request'
|
||||
id: pr-info
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
@@ -111,73 +134,152 @@ jobs:
|
||||
core.setOutput('sha', '');
|
||||
}
|
||||
|
||||
# ── Dispatch: twenty-ui pixel diff (CI UI, PRs + main) ──
|
||||
dispatch-twenty-ui:
|
||||
needs: resolve-context
|
||||
if: >-
|
||||
needs.resolve-context.outputs.workflow_name == 'CI UI' &&
|
||||
needs.resolve-context.outputs.has_artifact == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Dispatch to ci-privileged
|
||||
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
|
||||
PR_NUMBER: ${{ steps.pr-info.outputs.pr_number }}
|
||||
PR_NUMBER: ${{ needs.resolve-context.outputs.pr_number }}
|
||||
WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
COMMIT: ${{ github.event.workflow_run.head_sha }}
|
||||
REFERENCE_COMMIT: ${{ steps.merge-base.outputs.sha }}
|
||||
REFERENCE_COMMIT: ${{ needs.resolve-context.outputs.merge_base_sha }}
|
||||
ARTIFACT_NAME: ${{ needs.resolve-context.outputs.artifact_name }}
|
||||
run: |
|
||||
ARGS=(
|
||||
--method POST
|
||||
-f event_type=visual-regression
|
||||
-f "client_payload[pr_number]=$PR_NUMBER"
|
||||
-f "client_payload[project]=twenty-ui"
|
||||
-f "client_payload[artifact_name]=$ARTIFACT_NAME"
|
||||
-f "client_payload[run_id]=$WORKFLOW_RUN_ID"
|
||||
-f "client_payload[repo]=$REPOSITORY"
|
||||
-f "client_payload[branch]=$BRANCH"
|
||||
-f "client_payload[commit]=$COMMIT"
|
||||
)
|
||||
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
ARGS+=(-f "client_payload[pr_number]=$PR_NUMBER")
|
||||
fi
|
||||
if [ -n "$REFERENCE_COMMIT" ]; then
|
||||
ARGS+=(-f "client_payload[reference_commit]=$REFERENCE_COMMIT")
|
||||
fi
|
||||
|
||||
gh api repos/twentyhq/ci-privileged/dispatches "${ARGS[@]}"
|
||||
|
||||
# ── Dispatch: twenty-new-ui pixel diff (CI New UI, PRs + main) ──
|
||||
dispatch-twenty-new-ui:
|
||||
needs: resolve-context
|
||||
if: >-
|
||||
needs.resolve-context.outputs.workflow_name == 'CI New UI' &&
|
||||
needs.resolve-context.outputs.has_artifact == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Dispatch to ci-privileged
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
|
||||
WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
COMMIT: ${{ github.event.workflow_run.head_sha }}
|
||||
PR_NUMBER: ${{ needs.resolve-context.outputs.pr_number }}
|
||||
REFERENCE_COMMIT: ${{ needs.resolve-context.outputs.merge_base_sha }}
|
||||
ARTIFACT_NAME: ${{ needs.resolve-context.outputs.artifact_name }}
|
||||
run: |
|
||||
ARGS=(
|
||||
--method POST
|
||||
-f event_type=visual-regression
|
||||
-f "client_payload[project]=twenty-new-ui"
|
||||
-f "client_payload[artifact_name]=$ARTIFACT_NAME"
|
||||
-f "client_payload[run_id]=$WORKFLOW_RUN_ID"
|
||||
-f "client_payload[repo]=$REPOSITORY"
|
||||
-f "client_payload[branch]=$BRANCH"
|
||||
-f "client_payload[commit]=$COMMIT"
|
||||
)
|
||||
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
ARGS+=(-f "client_payload[pr_number]=$PR_NUMBER")
|
||||
fi
|
||||
if [ -n "$REFERENCE_COMMIT" ]; then
|
||||
ARGS+=(-f "client_payload[reference_commit]=$REFERENCE_COMMIT")
|
||||
fi
|
||||
|
||||
gh api repos/twentyhq/ci-privileged/dispatches "${ARGS[@]}"
|
||||
|
||||
# ── Dispatch: cross-comparison baseline (CI UI on main → twenty-ui-vs-new-ui) ──
|
||||
dispatch-comparison-baseline:
|
||||
needs: resolve-context
|
||||
if: >-
|
||||
needs.resolve-context.outputs.workflow_name == 'CI UI' &&
|
||||
needs.resolve-context.outputs.has_artifact == 'true' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_branch == 'main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Dispatch to ci-privileged (comparison baseline)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
|
||||
WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
COMMIT: ${{ github.event.workflow_run.head_sha }}
|
||||
ARTIFACT_NAME: ${{ needs.resolve-context.outputs.artifact_name }}
|
||||
run: |
|
||||
gh api repos/twentyhq/ci-privileged/dispatches \
|
||||
--method POST \
|
||||
-f event_type=visual-regression \
|
||||
-f "client_payload[project]=twenty-ui-vs-new-ui" \
|
||||
-f "client_payload[artifact_name]=$ARTIFACT_NAME" \
|
||||
-f "client_payload[run_id]=$WORKFLOW_RUN_ID" \
|
||||
-f "client_payload[repo]=$REPOSITORY" \
|
||||
-f "client_payload[branch]=$BRANCH" \
|
||||
-f "client_payload[commit]=$COMMIT"
|
||||
|
||||
# ── Dispatch: cross-comparison PR (CI New UI on PRs → twenty-ui-vs-new-ui) ──
|
||||
dispatch-comparison-pr:
|
||||
needs: resolve-context
|
||||
if: >-
|
||||
needs.resolve-context.outputs.workflow_name == 'CI New UI' &&
|
||||
needs.resolve-context.outputs.has_artifact == 'true' &&
|
||||
needs.resolve-context.outputs.is_pr == 'true' &&
|
||||
github.event.workflow_run.event == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Dispatch to ci-privileged (comparison PR)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
|
||||
WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
COMMIT: ${{ github.event.workflow_run.head_sha }}
|
||||
PR_NUMBER: ${{ needs.resolve-context.outputs.pr_number }}
|
||||
REFERENCE_COMMIT: ${{ needs.resolve-context.outputs.merge_base_sha }}
|
||||
ARTIFACT_NAME: ${{ needs.resolve-context.outputs.artifact_name }}
|
||||
run: |
|
||||
ARGS=(
|
||||
--method POST
|
||||
-f event_type=visual-regression
|
||||
-f "client_payload[project]=twenty-ui-vs-new-ui"
|
||||
-f "client_payload[artifact_name]=$ARTIFACT_NAME"
|
||||
-f "client_payload[run_id]=$WORKFLOW_RUN_ID"
|
||||
-f "client_payload[repo]=$REPOSITORY"
|
||||
-f "client_payload[branch]=$BRANCH"
|
||||
-f "client_payload[commit]=$COMMIT"
|
||||
-f "client_payload[pr_number]=$PR_NUMBER"
|
||||
)
|
||||
|
||||
if [ -n "$REFERENCE_COMMIT" ]; then
|
||||
ARGS+=(-f "client_payload[reference_commit]=$REFERENCE_COMMIT")
|
||||
fi
|
||||
|
||||
gh api repos/twentyhq/ci-privileged/dispatches "${ARGS[@]}"
|
||||
|
||||
dispatch-main:
|
||||
if: >-
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_branch == 'main' &&
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check if screenshots artifact exists
|
||||
id: check-artifact
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const runId = context.payload.workflow_run.id;
|
||||
const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: runId,
|
||||
});
|
||||
|
||||
const found = artifacts.artifacts.some(a => a.name === 'argos-screenshots-twenty-ui');
|
||||
core.setOutput('exists', found ? 'true' : 'false');
|
||||
|
||||
if (!found) {
|
||||
core.info(`Artifact not found in run ${runId} — skipping`);
|
||||
}
|
||||
|
||||
- name: Dispatch to ci-privileged
|
||||
if: steps.check-artifact.outputs.exists == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
|
||||
WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
COMMIT: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
gh api repos/twentyhq/ci-privileged/dispatches \
|
||||
--method POST \
|
||||
-f event_type=visual-regression \
|
||||
-f "client_payload[run_id]=$WORKFLOW_RUN_ID" \
|
||||
-f "client_payload[repo]=$REPOSITORY" \
|
||||
-f "client_payload[branch]=$BRANCH" \
|
||||
-f "client_payload[commit]=$COMMIT"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "2.10.0",
|
||||
"version": "2.11.0",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-client-sdk",
|
||||
"version": "2.10.0",
|
||||
"version": "2.11.0",
|
||||
"sideEffects": false,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
@@ -215,11 +215,63 @@ Apollo error formatting, and the icon/theme-color pickers tied to Twenty's icon
|
||||
- **Workbench** — Storybook (`@storybook/react-vite`). Every component has stories covering variants, sizes, and states (via `storybook-addon-pseudo-states`), in light and dark, with `autodocs`.
|
||||
- **Functional** — component/interaction tests via `@storybook/addon-vitest` (real browser); unit tests (Jest) for hooks/utilities; coverage gate via `@storybook/addon-coverage`.
|
||||
- **Accessibility** — Storybook a11y addon (axe-core) with `parameters.a11y.test = 'error'` so violations fail CI.
|
||||
- **Visual parity** — visual regression (Chromatic or test-runner image snapshots) plus side-by-side stories rendering the old and new component with identical props; a pixel-diff threshold is the per-component acceptance gate.
|
||||
- **Visual parity** — visual regression via Argos (self-hosted) plus a cross-package comparison project that diffs `twenty-new-ui` stories against `twenty-ui` stories with identical names; a pixel-diff threshold is the per-component acceptance gate. See [Visual regression](#visual-regression) below.
|
||||
- **Performance & size** — `size-limit` per entry point with budgets; tree-shaking fixtures (importing one component must not pull the library); build-time tracking; render benchmarks via React Profiler; load-time via Lighthouse/Playwright on the built Storybook. As one concrete benchmark, a dedicated **stress story** renders a very large number of a single component (e.g. 10,000 buttons) and measures total render time — compared against the `twenty-ui` equivalent and gated against a budget to catch per-instance overhead regressions.
|
||||
|
||||
CI surfaces a per-PR diff table (`twenty-ui` vs `twenty-new-ui`) for size, a11y, and visual changes.
|
||||
|
||||
## Visual regression
|
||||
|
||||
Two Argos projects (on argos.twenty-internal.com) provide visual regression in CI:
|
||||
|
||||
1. **`twenty-new-ui`** — pixel diff of `twenty-new-ui` stories against the `main` branch baseline. Catches regressions introduced by a PR.
|
||||
2. **`twenty-ui-vs-new-ui`** — cross-package comparison. The baseline is always `twenty-ui` screenshots from `main`; PR builds upload `twenty-new-ui` screenshots and diff them against the `twenty-ui` baseline. This shows exactly which components still differ between the two implementations.
|
||||
|
||||
For the cross-package comparison to produce meaningful diffs, stories in `twenty-new-ui` must use the **same title hierarchy** as `twenty-ui` (e.g. `UI/Input/Toggle`).
|
||||
|
||||
### Local visual diff
|
||||
|
||||
Run a pixel diff of `twenty-new-ui` components against `twenty-ui` using the self-hosted Argos instance.
|
||||
|
||||
**Prerequisites:**
|
||||
- AWS SSO configured and logged in (`aws sso login --profile twenty-dev`)
|
||||
- `twenty-infra/super-cli` cloned (sibling of this repo)
|
||||
|
||||
**1. Start the Argos tunnel**
|
||||
|
||||
In the `twenty-infra/super-cli` directory:
|
||||
|
||||
yarn cli argos-tunnel
|
||||
|
||||
This port-forwards the Argos service to `http://127.0.0.1:4002`.
|
||||
Wait until the CLI shows "Argos tunnel is running".
|
||||
|
||||
**2. Set your Argos token**
|
||||
|
||||
Create a `.env` file in `packages/twenty-new-ui/` (gitignored):
|
||||
|
||||
ARGOS_TOKEN=<your-token-from-argos-project-settings>
|
||||
|
||||
**3. Run the visual diff**
|
||||
|
||||
From the repo root:
|
||||
|
||||
npx nx storybook:visual-diff twenty-new-ui
|
||||
|
||||
This builds Storybook, captures screenshots of every story, and uploads
|
||||
them to Argos with build name `<username>/twenty-new-ui`. The diff
|
||||
compares against the latest approved baseline.
|
||||
|
||||
To run `twenty-ui`'s visual diff in the same Argos instance (to build the
|
||||
cross-package comparison baseline):
|
||||
|
||||
npx nx storybook:visual-diff twenty-ui
|
||||
|
||||
**4. View results**
|
||||
|
||||
Open `http://127.0.0.1:4002` in your browser (while the tunnel is running)
|
||||
to review diffs.
|
||||
|
||||
## Build & publishing
|
||||
|
||||
- Vite library mode, dual ESM/CJS, `vite-plugin-dts`, `vite-plugin-svgr`; SCSS via Vite's built-in `sass`; no Babel.
|
||||
@@ -267,7 +319,7 @@ a passing visual-parity diff, and a within-budget size entry.
|
||||
1. Published package name: `twenty-new-ui` now, renamed to `twenty-ui` at cut-over (Phase 6).
|
||||
2. Styling: confirm SCSS Modules vs vanilla-extract vs plain CSS Modules.
|
||||
3. Variants helper: `clsx` + `data-*` vs `cva`.
|
||||
4. Visual regression tooling: Chromatic vs self-hosted image snapshots.
|
||||
4. ~~Visual regression tooling: Chromatic vs self-hosted image snapshots.~~ **Resolved:** Argos (self-hosted at argos.twenty-internal.com). See [Visual regression](#visual-regression).
|
||||
5. How aggressively to drop `framer-motion` in favor of CSS/Base UI transitions.
|
||||
6. Scope of `assets` / `testing` / `json-visualizer`: port verbatim or modernize.
|
||||
7. Where to draw the generic-vs-app-specific line for `modules/ui`, and whether hybrid components live as a headless core in `twenty-new-ui` with a thin app wrapper in `twenty-front`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-sdk",
|
||||
"version": "2.10.0",
|
||||
"version": "2.11.0",
|
||||
"sideEffects": false,
|
||||
"bin": {
|
||||
"twenty": "dist/cli.cjs"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { FileApi } from '@/cli/utilities/api/file-api';
|
||||
import { LogicFunctionApi } from '@/cli/utilities/api/logic-function-api';
|
||||
import { SchemaApi } from '@/cli/utilities/api/schema-api';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
import { type SyncAction } from 'twenty-shared/metadata';
|
||||
|
||||
type ApiServiceOptions = {
|
||||
disableInterceptors?: boolean;
|
||||
@@ -72,7 +73,12 @@ export class ApiService {
|
||||
return this.applicationApi.createDevelopmentApplication(...args);
|
||||
}
|
||||
|
||||
syncApplication(manifest: Manifest): Promise<ApiResponse> {
|
||||
syncApplication(manifest: Manifest): Promise<
|
||||
ApiResponse<{
|
||||
applicationUniversalIdentifier: string;
|
||||
actions: SyncAction[];
|
||||
}>
|
||||
> {
|
||||
return this.applicationApi.syncApplication(manifest);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ApiResponse } from '@/cli/utilities/api/api-response-type';
|
||||
import axios, { type AxiosInstance, type AxiosResponse } from 'axios';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
import { type SyncAction } from 'twenty-shared/metadata';
|
||||
|
||||
export class ApplicationApi {
|
||||
constructor(private readonly client: AxiosInstance) {}
|
||||
@@ -251,7 +252,12 @@ export class ApplicationApi {
|
||||
}
|
||||
}
|
||||
|
||||
async syncApplication(manifest: Manifest): Promise<ApiResponse> {
|
||||
async syncApplication(manifest: Manifest): Promise<
|
||||
ApiResponse<{
|
||||
applicationUniversalIdentifier: string;
|
||||
actions: SyncAction[];
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const mutation = `
|
||||
mutation SyncApplication($manifest: JSON!) {
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import { type SyncAction } from 'twenty-shared/metadata';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatSyncActionsSummary } from '@/cli/utilities/dev/orchestrator/steps/format-sync-actions-summary';
|
||||
|
||||
describe('formatSyncActionsSummary', () => {
|
||||
it('reports no changes when the actions list is empty', () => {
|
||||
expect(formatSyncActionsSummary([])).toEqual([
|
||||
{ message: 'No metadata changes', status: 'info' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('summarizes created, updated and deleted actions with their identifiers', () => {
|
||||
const events = formatSyncActionsSummary([
|
||||
{
|
||||
type: 'create',
|
||||
metadataName: 'objectMetadata',
|
||||
flatEntity: {
|
||||
universalIdentifier: 'uid-object',
|
||||
nameSingular: 'rocket',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'create',
|
||||
metadataName: 'fieldMetadata',
|
||||
flatEntity: {
|
||||
universalIdentifier: 'uid-field',
|
||||
name: 'timelineActivities',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'update',
|
||||
metadataName: 'fieldMetadata',
|
||||
universalIdentifier: 'uid-updated-field',
|
||||
},
|
||||
{
|
||||
type: 'delete',
|
||||
metadataName: 'pageLayout',
|
||||
universalIdentifier: 'uid-page-layout',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
message: 'Metadata changes: 2 created, 1 updated, 1 deleted',
|
||||
status: 'info',
|
||||
},
|
||||
{ message: ' created objectMetadata rocket', status: 'info' },
|
||||
{ message: ' created fieldMetadata timelineActivities', status: 'info' },
|
||||
{ message: ' updated fieldMetadata uid-updated-field', status: 'info' },
|
||||
{ message: ' deleted pageLayout uid-page-layout', status: 'info' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the universal identifier when a created entity has no name', () => {
|
||||
const events = formatSyncActionsSummary([
|
||||
{
|
||||
type: 'create',
|
||||
metadataName: 'fieldMetadata',
|
||||
flatEntity: { universalIdentifier: 'uid-nameless' },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(events).toEqual([
|
||||
{ message: 'Metadata changes: 1 created', status: 'info' },
|
||||
{ message: ' created fieldMetadata uid-nameless', status: 'info' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('truncates the detail lines when there are more changes than the display limit', () => {
|
||||
const actions: SyncAction[] = Array.from({ length: 55 }, (_, index) => ({
|
||||
type: 'create' as const,
|
||||
metadataName: 'fieldMetadata' as const,
|
||||
flatEntity: {
|
||||
universalIdentifier: `uid-${index}`,
|
||||
name: `field${index}`,
|
||||
},
|
||||
}));
|
||||
|
||||
const events = formatSyncActionsSummary(actions);
|
||||
|
||||
expect(events[0]).toEqual({
|
||||
message: 'Metadata changes: 55 created',
|
||||
status: 'info',
|
||||
});
|
||||
expect(events).toHaveLength(52);
|
||||
expect(events[51]).toEqual({
|
||||
message: ' …and 5 more change(s)',
|
||||
status: 'info',
|
||||
});
|
||||
});
|
||||
});
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { type ApiService } from '@/cli/utilities/api/api-service';
|
||||
import { OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
import { SyncApplicationOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
|
||||
vi.mock('@/cli/utilities/build/manifest/manifest-update-checksums', () => ({
|
||||
manifestUpdateChecksums: ({ manifest }: { manifest: unknown }) => manifest,
|
||||
}));
|
||||
|
||||
vi.mock('@/cli/utilities/build/manifest/manifest-writer', () => ({
|
||||
writeManifestToOutput: vi.fn(),
|
||||
}));
|
||||
|
||||
const buildStep = (
|
||||
syncApplication: ApiService['syncApplication'],
|
||||
): { state: OrchestratorState; step: SyncApplicationOrchestratorStep } => {
|
||||
const state = new OrchestratorState({ appPath: '/tmp/app' });
|
||||
|
||||
const apiService = { syncApplication } as unknown as ApiService;
|
||||
|
||||
const step = new SyncApplicationOrchestratorStep({
|
||||
apiService,
|
||||
state,
|
||||
notify: () => {},
|
||||
});
|
||||
|
||||
return { state, step };
|
||||
};
|
||||
|
||||
const executeInput = {
|
||||
manifest: {
|
||||
application: { displayName: 'Demo' },
|
||||
} as unknown as Manifest,
|
||||
builtFileInfos: new Map(),
|
||||
appPath: '/tmp/app',
|
||||
};
|
||||
|
||||
describe('SyncApplicationOrchestratorStep', () => {
|
||||
it('renders the applied metadata changes on a successful sync', async () => {
|
||||
const syncApplication = vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
applicationUniversalIdentifier: 'app-uid',
|
||||
actions: [
|
||||
{
|
||||
type: 'create',
|
||||
metadataName: 'fieldMetadata',
|
||||
flatEntity: {
|
||||
universalIdentifier: 'uid-field',
|
||||
name: 'timelineActivities',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const { state, step } = buildStep(syncApplication);
|
||||
|
||||
await step.execute(executeInput);
|
||||
|
||||
const messages = state.events.map((event) => event.message);
|
||||
|
||||
expect(messages).toContain('Metadata changes: 1 created');
|
||||
expect(messages).toContain(' created fieldMetadata timelineActivities');
|
||||
expect(messages).toContain('✓ Synced');
|
||||
});
|
||||
|
||||
it('reports no metadata changes when the sync applies nothing', async () => {
|
||||
const syncApplication = vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: { applicationUniversalIdentifier: 'app-uid', actions: [] },
|
||||
});
|
||||
|
||||
const { state, step } = buildStep(syncApplication);
|
||||
|
||||
await step.execute(executeInput);
|
||||
|
||||
const messages = state.events.map((event) => event.message);
|
||||
|
||||
expect(messages).toContain('No metadata changes');
|
||||
expect(messages).toContain('✓ Synced');
|
||||
});
|
||||
});
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { type OrchestratorStateStepEvent } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
import { type SyncAction } from 'twenty-shared/metadata';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const MAX_DETAIL_LINES = 50;
|
||||
|
||||
const VERB_BY_TYPE = {
|
||||
create: 'created',
|
||||
update: 'updated',
|
||||
delete: 'deleted',
|
||||
} as const;
|
||||
|
||||
const getActionLabel = (action: SyncAction): string => {
|
||||
if (action.type === 'create') {
|
||||
return (
|
||||
action.flatEntity?.name ??
|
||||
action.flatEntity?.nameSingular ??
|
||||
action.flatEntity?.universalIdentifier ??
|
||||
'unknown'
|
||||
);
|
||||
}
|
||||
|
||||
return action.universalIdentifier;
|
||||
};
|
||||
|
||||
export const formatSyncActionsSummary = (
|
||||
actions: SyncAction[],
|
||||
): OrchestratorStateStepEvent[] => {
|
||||
if (actions.length === 0) {
|
||||
return [{ message: 'No metadata changes', status: 'info' }];
|
||||
}
|
||||
|
||||
const counts = { create: 0, update: 0, delete: 0 };
|
||||
|
||||
for (const action of actions) {
|
||||
counts[action.type] += 1;
|
||||
}
|
||||
|
||||
const summaryParts = [
|
||||
counts.create > 0 ? `${counts.create} created` : null,
|
||||
counts.update > 0 ? `${counts.update} updated` : null,
|
||||
counts.delete > 0 ? `${counts.delete} deleted` : null,
|
||||
].filter(isDefined);
|
||||
|
||||
const events: OrchestratorStateStepEvent[] = [
|
||||
{ message: `Metadata changes: ${summaryParts.join(', ')}`, status: 'info' },
|
||||
];
|
||||
|
||||
const visibleActions = actions.slice(0, MAX_DETAIL_LINES);
|
||||
|
||||
for (const action of visibleActions) {
|
||||
events.push({
|
||||
message: ` ${VERB_BY_TYPE[action.type]} ${action.metadataName} ${getActionLabel(action)}`,
|
||||
status: 'info',
|
||||
});
|
||||
}
|
||||
|
||||
const hiddenCount = actions.length - visibleActions.length;
|
||||
|
||||
if (hiddenCount > 0) {
|
||||
events.push({
|
||||
message: ` …and ${hiddenCount} more change(s)`,
|
||||
status: 'info',
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
};
|
||||
+4
@@ -7,6 +7,7 @@ import {
|
||||
type OrchestratorStateStepEvent,
|
||||
type OrchestratorStateSyncStatus,
|
||||
} from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
import { formatSyncActionsSummary } from '@/cli/utilities/dev/orchestrator/steps/format-sync-actions-summary';
|
||||
import { formatManifestValidationErrors } from '@/cli/utilities/error/format-manifest-validation-errors';
|
||||
import { serializeError } from '@/cli/utilities/error/serialize-error';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
@@ -69,6 +70,9 @@ export class SyncApplicationOrchestratorStep {
|
||||
const syncResult = await this.apiService.syncApplication(manifest);
|
||||
|
||||
if (syncResult.success) {
|
||||
const syncData = syncResult.data;
|
||||
|
||||
events.push(...formatSyncActionsSummary(syncData.actions));
|
||||
events.push({ message: '✓ Synced', status: 'success' });
|
||||
step.output = { syncStatus: 'synced', error: null };
|
||||
step.status = 'done';
|
||||
|
||||
+4
-2
@@ -144,9 +144,11 @@ export class SyncCallRecordingStandardObjectsCommand extends ActiveOrSuspendedWo
|
||||
];
|
||||
|
||||
if (!isDefined(calendarEventObjectMetadata)) {
|
||||
throw new Error(
|
||||
`calendarEvent object not found for workspace ${workspaceId}`,
|
||||
this.logger.warn(
|
||||
`calendarEvent object not found for workspace ${workspaceId}, skipping CallRecording standard metadata sync`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { twentyStandardFlatApplication } =
|
||||
|
||||
+2
@@ -5,6 +5,7 @@ import { ApplicationManifestModule } from 'src/engine/core-modules/application/a
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { ApplicationDevelopmentResolver } from 'src/engine/core-modules/application/application-development/application-development.resolver';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { CacheLockModule } from 'src/engine/core-modules/cache-lock/cache-lock.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
|
||||
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
|
||||
@@ -17,6 +18,7 @@ import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/wor
|
||||
ApplicationModule,
|
||||
ApplicationManifestModule,
|
||||
ApplicationRegistrationModule,
|
||||
CacheLockModule,
|
||||
FeatureFlagModule,
|
||||
SdkClientModule,
|
||||
TokenModule,
|
||||
|
||||
+15
@@ -33,6 +33,7 @@ import {
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
|
||||
import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { validateFilePath } from 'src/engine/core-modules/file-storage/utils/validate-file-path.util';
|
||||
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
|
||||
@@ -52,6 +53,8 @@ import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
const APP_DEV_RATE_LIMIT_MAX = 30;
|
||||
const APP_DEV_RATE_LIMIT_WINDOW_MS = 30_000;
|
||||
|
||||
const APP_SYNC_LOCK_OPTIONS = { ttl: 60_000, ms: 500, maxRetries: 120 };
|
||||
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@MetadataResolver()
|
||||
@UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor)
|
||||
@@ -71,6 +74,7 @@ export class ApplicationDevelopmentResolver {
|
||||
private readonly sdkClientGenerationService: SdkClientGenerationService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly throttlerService: ThrottlerService,
|
||||
private readonly cacheLockService: CacheLockService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => DevelopmentApplicationDTO)
|
||||
@@ -137,6 +141,17 @@ export class ApplicationDevelopmentResolver {
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
return this.cacheLockService.withLock(
|
||||
() => this.applyManifestSync(manifest, workspaceId),
|
||||
`app-sync:${workspaceId}`,
|
||||
APP_SYNC_LOCK_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
private async applyManifestSync(
|
||||
manifest: ApplicationInput['manifest'],
|
||||
workspaceId: string,
|
||||
): Promise<WorkspaceMigrationDTO> {
|
||||
const applicationRegistrationId = await this.findApplicationRegistrationId(
|
||||
manifest.application.universalIdentifier,
|
||||
);
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
import { type SyncAction } from 'twenty-shared/metadata';
|
||||
|
||||
@ObjectType('WorkspaceMigration')
|
||||
export class WorkspaceMigrationDTO {
|
||||
@@ -8,5 +9,5 @@ export class WorkspaceMigrationDTO {
|
||||
applicationUniversalIdentifier: string;
|
||||
|
||||
@Field(() => GraphQLJSON)
|
||||
actions: unknown[];
|
||||
actions: SyncAction[];
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ClickHouseModule } from 'src/database/clickHouse/clickHouse.module';
|
||||
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
|
||||
import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module';
|
||||
import { BillingGaugeService } from 'src/engine/core-modules/billing/billing-gauge.service';
|
||||
import { BillingResolver } from 'src/engine/core-modules/billing/billing.resolver';
|
||||
import { BillingSyncCustomerDataCommand } from 'src/engine/core-modules/billing/commands/billing-sync-customer-data.command';
|
||||
@@ -49,6 +50,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
|
||||
@Module({
|
||||
imports: [
|
||||
ClickHouseModule,
|
||||
CoreEntityCacheModule,
|
||||
FeatureFlagModule,
|
||||
StripeModule,
|
||||
MessageQueueModule,
|
||||
|
||||
+1
@@ -263,6 +263,7 @@ export class BillingSubscriptionService {
|
||||
billingSubscription.stripeSubscriptionId,
|
||||
{
|
||||
trial_end: 'now',
|
||||
cancel_at_period_end: false,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+15
@@ -3,10 +3,12 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
|
||||
import { formatDateTimeForClickHouse } from 'src/database/clickHouse/clickHouse.util';
|
||||
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
|
||||
import {
|
||||
BillingException,
|
||||
BillingExceptionCode,
|
||||
@@ -49,6 +51,7 @@ export class BillingUsageService {
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly clickHouseService: ClickHouseService,
|
||||
private readonly billingUsageCapService: BillingUsageCapService,
|
||||
private readonly coreEntityCacheService: CoreEntityCacheService,
|
||||
) {}
|
||||
|
||||
async canFeatureBeUsed(workspaceId: string): Promise<boolean> {
|
||||
@@ -348,6 +351,18 @@ export class BillingUsageService {
|
||||
return true;
|
||||
}
|
||||
|
||||
const workspace = await this.coreEntityCacheService.get(
|
||||
'workspaceEntity',
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (
|
||||
isDefined(workspace) &&
|
||||
workspace.activationStatus === WorkspaceActivationStatus.SUSPENDED
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { billingSubscription: subscription } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'billingSubscription',
|
||||
|
||||
@@ -114,6 +114,15 @@ export class BullMQDriver
|
||||
Sentry.withIsolationScope(async () => {
|
||||
applyWorkspaceSentryContextFromJobData(job.data);
|
||||
|
||||
const queueLatency = Math.max(0, Date.now() - job.timestamp);
|
||||
|
||||
this.metricsService.recordHistogram({
|
||||
key: MetricsKeys.JobLatencyMs,
|
||||
value: queueLatency,
|
||||
unit: 'ms',
|
||||
attributes: { queue: queueName, job_name: job.name },
|
||||
});
|
||||
|
||||
// TODO: Correctly support for job.id
|
||||
const timeStart = performance.now();
|
||||
const workspaceId = job.data?.workspaceId;
|
||||
|
||||
@@ -40,6 +40,7 @@ export enum MetricsKeys {
|
||||
JobCompleted = 'job/completed',
|
||||
JobFailed = 'job/failed',
|
||||
JobWaiting = 'job/waiting',
|
||||
JobLatencyMs = 'job/latency-ms',
|
||||
AiChatTurnLatencyMs = 'ai-chat/turn-latency-ms',
|
||||
AiChatStepLatencyMs = 'ai-chat/step-latency-ms',
|
||||
AiChatTtftMs = 'ai-chat/ttft-ms',
|
||||
|
||||
+1
-1
@@ -7,4 +7,4 @@
|
||||
* |___/
|
||||
*/
|
||||
|
||||
export const TWENTY_CURRENT_VERSION = '2.10.0' as const;
|
||||
export const TWENTY_CURRENT_VERSION = '2.11.0' as const;
|
||||
|
||||
+1
-1
@@ -8,5 +8,5 @@
|
||||
*/
|
||||
|
||||
export const TWENTY_NEXT_VERSIONS = [
|
||||
'2.11.0',
|
||||
'2.12.0',
|
||||
] as const;
|
||||
|
||||
+1
@@ -21,4 +21,5 @@ export const TWENTY_PREVIOUS_VERSIONS = [
|
||||
'2.7.0',
|
||||
'2.8.0',
|
||||
'2.9.0',
|
||||
'2.10.0',
|
||||
] as const;
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ Report: {
|
||||
`;
|
||||
|
||||
exports[`formatUpgradeErrorForStorage should format a WorkspaceMigrationRunnerException with EXECUTION_FAILED 1`] = `
|
||||
"[WorkspaceMigrationRunnerException] Migration action 'create' for 'objectMetadata' failed
|
||||
"[WorkspaceMigrationRunnerException] Migration action 'create' for 'objectMetadata' (universalIdentifier: test-object-uid) failed
|
||||
Code: EXECUTION_FAILED
|
||||
Action: create on objectMetadata
|
||||
Metadata error: column "label" cannot be null
|
||||
|
||||
+1
@@ -57,6 +57,7 @@ describe('formatUpgradeErrorForStorage', () => {
|
||||
const action = {
|
||||
type: 'create',
|
||||
metadataName: 'objectMetadata',
|
||||
flatEntity: { universalIdentifier: 'test-object-uid' },
|
||||
} as unknown as AllUniversalWorkspaceMigrationAction;
|
||||
|
||||
const error = new WorkspaceMigrationRunnerException({
|
||||
|
||||
+12
-1
@@ -279,11 +279,22 @@ export class WorkspaceRolesPermissionsCacheService extends WorkspaceCacheProvide
|
||||
const hasPermissionFromSettingPermissions = isDefined(
|
||||
rolePermissionFlags.find(
|
||||
(rolePermissionFlag) =>
|
||||
rolePermissionFlag.permissionFlag.universalIdentifier ===
|
||||
this.getRolePermissionFlagUniversalIdentifier(rolePermissionFlag) ===
|
||||
permissionFlagUniversalIdentifier,
|
||||
),
|
||||
);
|
||||
|
||||
return hasPermissionFromRole || hasPermissionFromSettingPermissions;
|
||||
}
|
||||
|
||||
private getRolePermissionFlagUniversalIdentifier(
|
||||
rolePermissionFlag: RolePermissionFlagEntity,
|
||||
): string {
|
||||
// The `permissionFlag` relation is stripped during upgrades until the 2.6.0
|
||||
// cursor (@WasIntroducedInUpgrade), so fall back to the legacy `flag` column.
|
||||
return (
|
||||
rolePermissionFlag.permissionFlag?.universalIdentifier ??
|
||||
SystemPermissionFlag[rolePermissionFlag.flag]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { type AllUniversalWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common';
|
||||
import {
|
||||
WorkspaceMigrationRunnerException,
|
||||
WorkspaceMigrationRunnerExceptionCode,
|
||||
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception';
|
||||
|
||||
describe('WorkspaceMigrationRunnerException', () => {
|
||||
it('includes the universal identifier of a failed create action in the message', () => {
|
||||
const action = {
|
||||
type: 'create',
|
||||
metadataName: 'fieldMetadata',
|
||||
flatEntity: {
|
||||
universalIdentifier: '20202020-6736-4337-b5c4-8b39fae325a5',
|
||||
},
|
||||
} as unknown as AllUniversalWorkspaceMigrationAction;
|
||||
|
||||
const exception = new WorkspaceMigrationRunnerException({
|
||||
code: WorkspaceMigrationRunnerExceptionCode.EXECUTION_FAILED,
|
||||
action,
|
||||
errors: {},
|
||||
});
|
||||
|
||||
expect(exception.message).toBe(
|
||||
"Migration action 'create' for 'fieldMetadata' (universalIdentifier: 20202020-6736-4337-b5c4-8b39fae325a5) failed",
|
||||
);
|
||||
});
|
||||
|
||||
it('includes the universal identifier of a failed delete action in the message', () => {
|
||||
const action = {
|
||||
type: 'delete',
|
||||
metadataName: 'pageLayout',
|
||||
universalIdentifier: 'uid-page-layout',
|
||||
} as unknown as AllUniversalWorkspaceMigrationAction;
|
||||
|
||||
const exception = new WorkspaceMigrationRunnerException({
|
||||
code: WorkspaceMigrationRunnerExceptionCode.EXECUTION_FAILED,
|
||||
action,
|
||||
errors: {},
|
||||
});
|
||||
|
||||
expect(exception.message).toBe(
|
||||
"Migration action 'delete' for 'pageLayout' (universalIdentifier: uid-page-layout) failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
+25
-1
@@ -34,6 +34,25 @@ export type WorkspaceMigrationRunnerExecutionErrors = {
|
||||
actionTranspilation?: Error;
|
||||
};
|
||||
|
||||
const getActionUniversalIdentifierOrThrow = (
|
||||
action: AllUniversalWorkspaceMigrationAction,
|
||||
): string => {
|
||||
if (action.type === 'create') {
|
||||
const universalIdentifier = action.flatEntity?.universalIdentifier;
|
||||
|
||||
if (!universalIdentifier) {
|
||||
throw new WorkspaceMigrationRunnerException({
|
||||
message: `Missing universalIdentifier on create action for '${action.metadataName}'`,
|
||||
code: WorkspaceMigrationRunnerExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
});
|
||||
}
|
||||
|
||||
return universalIdentifier;
|
||||
}
|
||||
|
||||
return action.universalIdentifier;
|
||||
};
|
||||
|
||||
const {
|
||||
// oxlint-disable-next-line unused-imports/no-unused-vars
|
||||
EXECUTION_FAILED: WorkspaceMigrationRunnerExceptionExecutionFailedCode,
|
||||
@@ -62,8 +81,13 @@ export class WorkspaceMigrationRunnerException extends CustomError {
|
||||
|
||||
constructor(args: WorkspaceMigrationRunnerExceptionConstructorArgs) {
|
||||
if (args.code === WorkspaceMigrationRunnerExceptionCode.EXECUTION_FAILED) {
|
||||
const universalIdentifier = getActionUniversalIdentifierOrThrow(
|
||||
args.action,
|
||||
);
|
||||
const identifierClause = ` (universalIdentifier: ${universalIdentifier})`;
|
||||
|
||||
super(
|
||||
`Migration action '${args.action.type}' for '${args.action.metadataName}' failed`,
|
||||
`Migration action '${args.action.type}' for '${args.action.metadataName}'${identifierClause} failed`,
|
||||
);
|
||||
|
||||
this.code = args.code;
|
||||
|
||||
+11
-2
@@ -1,6 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isDefined, isValidUuid } from 'twenty-shared/utils';
|
||||
|
||||
import { CommandMenuItemService } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.service';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
@@ -378,8 +378,17 @@ export class WorkflowCommonWorkspaceService {
|
||||
for (const workflowVersion of workflowVersions) {
|
||||
for (const step of workflowVersion.steps ?? []) {
|
||||
if (step.type === WorkflowActionType.CODE) {
|
||||
const logicFunctionId = step.settings.input.logicFunctionId;
|
||||
|
||||
if (!isValidUuid(logicFunctionId)) {
|
||||
this.logger.warn(
|
||||
`Skipping destroy for CODE step with undefined logicFunctionId in workflow ${workflowId}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.logicFunctionFromSourceService.deleteOneWithSource({
|
||||
id: step.settings.input.logicFunctionId,
|
||||
id: logicFunctionId,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
+2
-2
@@ -188,7 +188,7 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => {
|
||||
nextStepIds: [],
|
||||
settings: {
|
||||
input: {
|
||||
logicFunctionId: 'function-id',
|
||||
logicFunctionId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
},
|
||||
outputSchema: {},
|
||||
errorHandlingOptions: {
|
||||
@@ -206,7 +206,7 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => {
|
||||
expect(
|
||||
logicFunctionFromSourceService.deleteOneWithSource,
|
||||
).toHaveBeenCalledWith({
|
||||
id: 'function-id',
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
workspaceId: mockWorkspaceId,
|
||||
});
|
||||
});
|
||||
|
||||
+4
@@ -91,6 +91,10 @@ export class WorkflowVersionStepOperationsWorkspaceService {
|
||||
}) {
|
||||
switch (step.type) {
|
||||
case WorkflowActionType.CODE: {
|
||||
if (!isValidUuid(step.settings.input.logicFunctionId)) {
|
||||
break;
|
||||
}
|
||||
|
||||
await this.logicFunctionFromSourceService.deleteOneWithSource({
|
||||
id: step.settings.input.logicFunctionId,
|
||||
workspaceId,
|
||||
|
||||
+14
@@ -9,6 +9,10 @@ import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-perm
|
||||
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
|
||||
import { WorkflowVersionStatus } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
|
||||
import { WorkflowStatus } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
|
||||
import {
|
||||
WorkflowVersionStepException,
|
||||
WorkflowVersionStepExceptionCode,
|
||||
} from 'src/modules/workflow/common/exceptions/workflow-version-step.exception';
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import {
|
||||
type WorkflowToolContext,
|
||||
@@ -116,6 +120,16 @@ This is the most efficient way for AI to create workflows as it handles all the
|
||||
activate?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
const codeSteps = parameters.steps.filter(
|
||||
(step) => step.type === ('CODE' as string),
|
||||
);
|
||||
|
||||
if (codeSteps.length > 0) {
|
||||
throw new WorkflowVersionStepException(
|
||||
'CODE steps cannot be created via create_complete_workflow because it does not create the underlying logic function. Use create_workflow_version_step instead.',
|
||||
WorkflowVersionStepExceptionCode.INVALID_REQUEST,
|
||||
);
|
||||
}
|
||||
const workflowId = await createWorkflow({
|
||||
deps,
|
||||
context,
|
||||
|
||||
@@ -25,6 +25,7 @@ export type {
|
||||
MetadataValidationErrorResponse,
|
||||
} from './types/MetadataValidationError';
|
||||
export { WorkspaceMigrationV2ExceptionCode } from './types/MetadataValidationError';
|
||||
export type { SyncAction } from './types/sync-action.type';
|
||||
export { addCustomSuffixIfIsReserved } from './utils/add-custom-suffix-if-reserved.util';
|
||||
export { computeMetadataNameFromLabel } from './utils/compute-metadata-name-from-label.util';
|
||||
export { computeMetadataNamesFromLabelsOrThrow } from './utils/compute-metadata-names-from-labels-or-throw.util';
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { type AllMetadataName } from '@/metadata/types/all-metadata-name.type';
|
||||
|
||||
type SyncCreateAction = {
|
||||
type: 'create';
|
||||
metadataName: AllMetadataName;
|
||||
flatEntity?: {
|
||||
name?: string | null;
|
||||
nameSingular?: string | null;
|
||||
universalIdentifier?: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type SyncUpdateAction = {
|
||||
type: 'update';
|
||||
metadataName: AllMetadataName;
|
||||
universalIdentifier: string;
|
||||
};
|
||||
|
||||
type SyncDeleteAction = {
|
||||
type: 'delete';
|
||||
metadataName: AllMetadataName;
|
||||
universalIdentifier: string;
|
||||
};
|
||||
|
||||
export type SyncAction = SyncCreateAction | SyncUpdateAction | SyncDeleteAction;
|
||||
Reference in New Issue
Block a user