mirror of
https://github.com/twentyhq/twenty.git
synced 2026-09-16 16:06:17 -04:00
c-async-csv-export-server
544
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
984781d2e5 |
[3/4] Desktop Recorder: add the Twenty desktop app (#25616)
Add the complete Twenty desktop application for recording calls locally. - View upcoming meetings and start recordings; retry failed joins and capture attempts. - Keep workspace state consistent if connection setup fails. - Show five recent recordings; open full history and search in Twenty. - Reuse the CLI OAuth flow from `twenty remote:add`; offer backend installation after sign-in if needed. - Package as a Yarn workspace, with native packaging isolated and safe Electron restarts during development. **Scope:** production implementation under `twenty-companion`, its workspace/lockfile entries, and desktop CI. The focused test suite follows in #25900 so both reviews remain bounded. **Latest validation:** the combined stack passes 115 desktop main/shared tests, desktop typecheck, and changed-file lint. Live browser sign-in remains to be verified. **Before release:** signed distribution and an end-to-end install on a current server. **Merge order:** #25632 → #25633 → #25616 → #25900. <details> <summary>Screenshots — desktop preview with sample data</summary> **Home: upcoming meetings and recent recordings**  **General settings and meeting preferences**  </details> |
||
|
|
330cf9c39d |
Publish Twenty app skills as a portable Agent Skills collection (skills.sh) (#25913)
Closes #22892. Publishes the Twenty app skills as a harness-agnostic Agent Skills collection installable with the open `skills` CLI. ```bash npx skills add twentyhq/twenty/packages/twenty-agent-skills --list npx skills add twentyhq/twenty/packages/twenty-agent-skills --skill create-app ``` Works in Claude Code, Codex, Cursor, Pi, and anything else the CLI supports. ## How it is built One canonical source, two generated distributions, validated on both ends: ``` packages/twenty-agent-skills/ skills/ canonical SKILL.md x5 ─┐ references/ canonical docs ─┤ │ validate-source.js (is the source correct?) ▼ build.js │ rewrites ../../references/ links to skill-relative, │ copies the transitive closure of referenced docs ▼ dist/skills/ portable skills ─┬─ validate-distribution.js (does dist match the source?) dist/.codex-plugin/ Codex plugin ─┘ │ ▼ verify-install.js (does what the CLI installed match dist, file for file?) ``` Each installed skill directory is self-contained: no monorepo paths, no sibling-skill or Codex-wrapper dependencies. The five skills: `create-app`, `develop-app`, `manage-app`, `publish-app`, and the optional `use-twenty-mcp`. ## What changed here **Single source of truth.** `references/concepts/operating-rules.md` is the only copy of the operating rules. `AGENTS.md` and every SKILL.md link to it and keep only their own additions; `assertOperatingRulesSingleSource` fails the build if any of them restates a rule headline, copies a sentence from a rule body, or drops the pointer. **Real install verification.** `verify-install.js` discovers every installed copy of a skill (`.claude/skills`, `.agents/skills`, symlinks included) and diffs it against `dist/` in both directions, then re-runs the self-containment and frontmatter assertions on the installed tree. CI installs each skill into Codex and Claude Code separately, then the whole collection, and verifies each one. A `main`-only job exercises the two remote URL forms, which can only resolve once merged. **Content regenerated against current `main`.** The previous attempt (#23011) carried July content (`yarn twenty dev --once`, no standalone-page layouts, a stale validator expectation). **Self-hosted is first class.** `references/use-twenty-mcp/setup.md` is now multi-harness MCP setup with user-supplied workspace URLs, custom domains and localhost as first-class targets. Validation fails if `create-app` stops presenting self-hosted as supported, or if MCP setup starts requiring a `twenty.com` workspace. **Telling the two skill families apart.** The repo also ships `syncable-entity-*` skills, which are internal guides for changing Twenty's own server code. Their descriptions now open with "Contributing to the Twenty codebase itself (twentyhq/twenty server internals), not for building apps on top of Twenty", `SKILLS.md` at the repo root maps every skill family and who it is for, and the README and quick-start point at the app skills with the package-scoped command. **Cursor cleanup** (from #25946, approved and merged into this branch, so both land together): `.cursor` is gone entirely — 16 `.mdc` rules, the background-agent config, the `CLAUDE.md` pointer, a gitignore entry and a `.cursorrules` editor association for a file that does not exist. The six `syncable-entity-*` skills moved to `.claude/skills/` next to `qa-scout`. That changes the bare-repo listing (`.claude/skills` is default-scanned, `.cursor/skills` was not), which is what the description prefix above is for. The package-scoped listing is unaffected and still returns exactly the five app skills, asserted by `agent-skills-validate`. **One package, not two** (#25970): `twenty-codex-plugin` used to hold a hand-synced copy; it was folded into this package so both distributions build from one source. **Review follow-ups:** `verify:install` is an nx target rather than a raw node invocation; `CANONICAL_SKILL_NAMES` has a single definition; the `scripts/` tree is ESM. ## Verified - `validate-source` → `build` → `validate-distribution` pass, plus 72 unit tests. - Real `skills` CLI: all five skills install into Claude Code and Codex, individually and together, each installed tree matching `dist/` file for file in both directions. - Self-hosted scaffold-and-develop smoke test against a local server: scaffolded an app, added an object with `yarn twenty dev:add`, synced with `yarn twenty apply`, confirmed `objectMetadata`, `navigationMenuItem` and the physical workspace table landed. Recorded in `SMOKE-TEST.md`. ## Known, not addressed here `create-twenty-app` still calls `yarn twenty dev --once` internally while the skills direct agents to `yarn twenty apply`. The skill content is consistent; the scaffolder's internals lag. Worth a separate issue. |
||
|
|
a3ba7be23d |
Add live previews to Twenty UI documentation (#25889)
Add embedded Storybook previews to all 17 Twenty UI component documentation pages, with light and dark themes and interactive examples. On `main` and SDK or UI release tags, `CD Storybook UI` builds the public Storybook, checks that the documentation's embedded story IDs exist, and uploads the static site as a GitHub Actions artifact. After the build, it dispatches `deploy-storybook-ui.yaml` in `twenty-factory` with the source run ID. Factory validates the successful source run, downloads its artifact, and publishes the static files to Cloudflare Pages without executing the downloaded site. Cloudflare credentials stay in factory's `storybook-ui` environment. `main` updates the production site; release tags publish version aliases such as `v2-42-0.twenty-ui-storybook.pages.dev`. Merge [twenty-factory #120](https://github.com/twentyhq/twenty-factory/pull/120) first and complete the Cloudflare project, token, and GitHub environment setup in its description before merging this PR. That description also covers validating the first deployment and switching `storybook.twenty.com` to Pages. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/25889?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
d927876015 |
fix(create-twenty-app): ship a lockfile so generated apps install under an age gate (#25879)
## The bug
A project scaffolded right after the 2.40.0 release could not install:
```
YN0016: twenty-ui@npm:1.0.0-alpha.2: All versions satisfying "1.0.0-alpha.2" are quarantined
```
## Root cause
Not publication timing, and not the exact version pins — **the template
ships an empty Yarn *v1* lockfile into a Yarn 4 project.** Yarn 4
discards it without a warning, so every install re-resolves all three
first-party packages from the registry.
A minimum release age (Yarn's `npmMinimalAgeGate`, pnpm's
`minimumReleaseAge`, npm's `min-release-age`) is a **resolution-time**
control. Reduced from the Yarn 4.13.0 bundle:
```js
candidates = versions matching range
approved = candidates.filter(isPackageApproved)
if (candidates.length > 0 && approved.length === 0)
throw new ReportError(16, `All versions satisfying "${range}" are quarantined`)
```
An exact pin makes the candidate set a singleton, so the gate has no
older candidate to fall back to — it can only throw. Re-resolution is
the only thing that exposes a project to it.
## Why the obvious fixes don't work
**Publish earlier.** Already done. Per npm's own publish times, 2.40.0
gave `twenty-ui` a **52 min 47 s** head start (`15:47:40Z` →
`16:40:27Z`). A gate is measured in days; the consumer picks the delay
*after* we publish, so no schedule can guarantee anything.
**Loosen to ranges.** Worse. Same 7-day gate, real `twenty-sdk`
versions:
| Range | Outcome |
|---|---|
| `2.40.0`, `~2.40.0`, `^2.40.0`, `^2.39.0` | quarantined — identical
failure |
| `^2.35.0`, `*`, `latest` | resolves to **2.38.0**, three minors behind
the template shipping with it |
Loud failure beats silent version skew against templates and docs
written for the new SDK.
**A custom dist-tag** installs cleanly under a gate — because Yarn never
age-checks non-`latest` tags. Undocumented, closable in any patch, and
it defeats the user's policy without anyone noticing.
**`npmMinimalAgeGate: 0`** in the generated `.yarnrc.yml` voids
inherited org-wide config from a file the user never wrote. Both
rejected.
## The fix
Generate a real Yarn 4 lockfile at release time and ship it in the
template. A resolved lockfile entry is never re-resolved, so the gate
never applies — and unlike the alternatives it changes no configuration:
the user's gate stays in force for every dependency they add afterwards,
and what they get instead is an explicit, integrity-hashed, reviewable
set of versions in their first commit.
**The trade this makes, stated plainly:** a shipped lockfile is
installed without re-resolution, which is exactly what lets it satisfy a
consumer's gate and equally what stops that gate from ever inspecting
these versions. Generation is therefore the only point at which an age
gate can apply to the tree a scaffolded project receives — so the
generator holds the monorepo's own `3d` gate and waives it for nothing
beyond the exact first-party versions being published.
## Changes
- **`create-twenty-app:generate-template-lock`** — new nx target
resolving the template against a given registry, under a `3d` age gate,
waiving only the exact `name@version` of the three packages this release
publishes. Refuses to emit a lockfile missing a package, lacking an
integrity checksum, or carrying `__archiveUrl` registry pins. Asserts
the template's `packageManager` matches the monorepo's pinned Yarn,
since a mismatch produces lockfiles the user's CI rejects with YN0028.
- **Dropped the inert v1 stub.** A build without a generated lockfile
still scaffolds working projects; the publish workflow refuses to
release in that state.
- **`install()` now fails instead of warning.** It previously caught a
failed `yarn install`, `console.warn`ed, then went on to git-init,
authenticate, sync and print "Application created successfully" over a
project with no `node_modules`.
- **Actionable YN0016 message** naming the packages Yarn actually
quarantined — parsed from its own output, not assumed to be the
first-party three — and offering a *package-scoped* waiver, never an
instruction to lower the gate. A quarantined dist-tag is waived by bare
name, since `npmPreapprovedPackages` matches with `semver.validRange`
and `pkg@latest` parses to `null`.
- **`yarn install --no-immutable`** during scaffolding. Yarn installs
immutably when `CI` is set, but this install is what rewrites the
lockfile's workspace root entry to the project's real name; immutable
mode rejects that with YN0028.
- **`twenty-ui` added to the monorepo's own `npmPreapprovedPackages`** —
it listed the two SDKs but not `twenty-ui`, part of why dogfooding never
surfaced this.
- **`RELEASE.md`** documenting the ordering constraint.
- **E2E restructured** to publish libraries → generate lockfile →
publish scaffolder, then scaffold and install **under a 30-day gate**.
The old E2E published seconds-old packages with no gate configured, so
it was structurally blind to this and passed while the release was
broken.
## Verification
Eleven experiments on the repo's own Yarn 4.13.0 against the live
registry, plus full runs against a local registry carrying packages
**published seconds earlier**:
| Scenario | Result |
|---|---|
| Reproduce with exact pin + 7d gate, no lockfile | `YN0016` — verbatim
production error |
| Empty v1 stub + 7d gate | `YN0016` — lockfile silently ignored |
| Control: scaffold with no lockfile, 30d gate | `YN0016` |
| **Real CLI scaffold + install, 30d gate** | **passes in 3 s**, all
three packages at the pinned version |
| Generated project's own `yarn install --immutable`, 30d gate |
**passes** |
| Lockfile committed by the scaffolder's initial commit | yes, 1256
lines |
| Generator gate is live: waived `twenty-ui@2.41.0`, seconds old |
resolves |
| Generator gate is live: unwaived `twenty-ui@2.41.1`, seconds old |
**quarantined** |
`tsgo --noEmit` clean · `oxlint` 0 errors · 20/20 jest tests (8 new).
Pairs with twentyhq/twenty-infra#944, which builds the lockfile between
publishing the libraries and publishing the scaffolder, adds
post-publication verification, and makes a partially-completed release
resumable.
## Docs
No app-docs change needed under `developers/extend/apps/`, verified by
hand twice — once for the original diff and again after `SETUP.md`
changed. The `Empty yarn.lock` entry in
`getting-started/troubleshooting.mdx` covers the SDK's
`validateYarnLock`, which triggers on a **zero-byte** file; the stub
removed here was two comment lines, so that entry was never about it.
`operations/publishing.mdx`'s `yarn install --immutable` line stays
accurate — and now actually succeeds on a generated project's first
push. `scaffolding.mdx` documents `twenty generate`, not this CLI.
The generated project's own `SETUP.md` does change: it previously
offered "delete `yarn.lock`" as the way to let your own age gate choose
versions, which walks the reader straight into the failure this PR
fixes. It now says deletion re-resolves pins published alongside the
scaffolder, that the install stops at `YN0016` until they age past the
gate, and points at the scoped waiver as the alternative to waiting.
## What this does not fix
`npx create-twenty-app@X` declares an exact runtime dependency on
`twenty-sdk@X`, which declares one on `twenty-client-sdk@X` — three
same-day packages resolved by npm with no lockfile in the picture. A
user with an npm or pnpm cooldown cannot scaffold on release day; that
is arguably their policy working as intended. Bundling the SDK is not a
way out (`esbuild` and `sharp` ship platform binaries); the real path,
if it becomes pressing, is deferring the CLI's SDK usage to the
generated project's local `twenty` binary.
Separately: `twenty-ui`'s `latest` dist-tag on npm still points at
`1.0.0-alpha.0` from June, published under AGPL-3.0 while the package is
now MIT. The next stable release fixes this by publishing under
`latest`.
|
||
|
|
a44c8bdf92 |
Add Twenty UI documentation and generated API references (#25791)
Add a UI Library documentation section so developers can get started with Twenty UI, customize its theme, and use its components. The section has guides for setup, theming, design tokens, dark mode, server rendering, and accessibility, plus a page per component (17 so far) with usage examples and a generated props reference. How the references are generated and kept in sync: - `nx generate:ui twenty-docs` extracts prop types and JSDoc from the twenty-ui components with react-docgen-typescript and the design tokens from the token pipeline, writes them to `packages/twenty-ui/generated`, and renders MDX snippets under `packages/twenty-docs/snippets/ui/generated`. - `nx check:ui twenty-docs` runs in CI Docs and fails when the data or snippets are stale, when a page and the generated snippets disagree, or when a guide example does not compile against the public entry points and peer dependencies. - Twenty UI's own props now carry JSDoc descriptions, and generation fails on an undocumented prop. Old `/twenty-ui/*` URLs redirect to the new pages. The pages use the existing Crowdin translation workflow; the generated snippets are shared across locales. |
||
|
|
dfa3a39efe |
Cover every translation pipeline with the normalizer (#25848)
The website pipeline had no normalize step at all, though it carries the same Lingui PO catalogs, through the same machine translator, as the app. It is clean today only because it holds eight times fewer strings (22,620 pairs against 173,789). **Website gets a normalize step.** The largest uncovered surface, closed with four lines of YAML. **Rules declare their catalog formats and the CLI selects with `--format`.** A new rule now protects every project holding that format, instead of only the workflow someone remembered to add it to. The ICU rules stay off MDX, where braces are JSX and would false-positive, so docs runs exactly the rule set and the targeted scan it ran before. `--rules` survives for narrowing a run by hand. **All three pulls normalize before downloading**, as the docs pull already did, so a repair lands in Crowdin before anything is fetched rather than a cycle later. Each normalize step now carries the same `force_pull` condition as the download it precedes, so a `workflow_call` asking for no download no longer mutates Crowdin. **`escaped-unicode` only decodes escapes the source does not itself carry**, and stands down entirely when the source is unknown, matching its three peers. A source that writes `\uXXXX` itself, such as a code sample, now survives translation. **`Record(s) selected` becomes `Records selected`.** The `(s)` invited a plural that five locales invented on a call site that passes no `count`; it is a mode label, not a count. This is the repo-side net. The matching Crowdin changes are already live: the app project's AI QA category was switched off entirely, so an ICU structural QA prompt now runs there and on the website project, and the shared pre-translation prompt no longer instructs the model to add plural forms to sources that have none. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/25848?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture>``<source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg">``</picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
3274fd9771 |
Add eloqnt lint and fix ICU placeholders in source strings (#25754)
Fixes source strings whose ICU placeholders were single-quoted, so the value rendered literally, and rewrites plural messages to use # instead of positional placeholders. Adds @eloqnt/cli and runs it as a non-blocking report in the i18n pull workflow. |
||
|
|
40040983de |
Repair machine-translation damage in Crowdin automatically (#25842)
Fixes translations corrupted by the Crowdin AI step — leaked response envelopes, invented markup, dangling ICU arguments — in Crowdin itself, so the fixes survive the sync. Details in the commit messages. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/25842?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
211303ad8f |
feat(ai): generate every deployment catalog from the model catalog and a spec (#25831)
First of two. This one adds the generator and puts self-host on it; the
second gives twenty-infra its private specs.
## Why
The daily sync keeps one record of what every model is: identity and
pricing from models.dev, intelligence, speed and cost per task from
Artificial Analysis, effort levels carried over by hand. That is the
truth about models.
A deployment serves a subset of those models through its own routes, and
until now it restated them. Each cloud environment carried a second
catalog with its own copy of prices, labels, context windows and
modalities, written by hand and kept in step by nothing. Diffing those
files against the catalog showed the result: almost every model was a
verbatim copy, and where they differed it was a stale copy rather than a
deliberate change. prod-eu lists the OpenAI models with `modalities:
["image"]` against the catalog's `["image", "pdf"]`, so it is quietly
refusing PDFs.
The fields never copied at all are the ones that mattered. A deployment
catalog carries no `efforts`, so no effort variant is registered and
every tier chain entry pinned at an effort is unreachable. It carries no
`benchmark`, so the product has nothing to compare models with.
## The shape
`ai-providers.json` used to be two things at once: the truth about
models, and one deployment's view of it. That is why only cloud could
get a spec. Splitting the two makes self-host and cloud the same
mechanism:
| File | What it is |
| --- | --- |
| `ai-models.json` | What every model is. No routes, no credentials.
Synced daily. |
| `ai-self-host-spec.json` | What a self-hosted deployment serves: the
five direct routes and their key templates. |
| `ai-providers.json` | What the server bundles. Generated from the two
above. |
Cloud is the same projection over a private spec. There is one generator
and two callers.
## What a spec says
Only what belongs to the deployment: which routes exist, their
credentials, and which catalog models each route serves.
```json
{
"providers": [
{
"name": "azure-foundry",
"npm": "@ai-sdk/azure",
"apiKey": "{{AZURE_FOUNDRY_API_KEY}}",
"dataResidency": "eu",
"labelSuffix": " (Azure)",
"models": ["gpt-5.6-luna", { "model": "gpt-5.6-sol", "cachedInputCostPerMillionTokens": 0.5 }]
},
{ "name": "openai", "npm": "@ai-sdk/openai", "models": { "vendor": "openai" } }
]
}
```
A route may rename a model it deploys under another id, and override the
prices it negotiated, the label and the deprecation flag. It cannot
restate what a model is: context window, modalities, efforts, benchmark
and per-effort benchmarks always come from the catalog. That is the
property that makes the drift above impossible rather than merely fixed.
Naming a model the catalog does not carry fails the run instead of
publishing a route to nothing, which is the failure that put every tier
on one model.
The second form is how self-host works: a route serving a whole vendor
picks up new models with the daily sync instead of waiting for an edit.
The provider labels and key templates that were hardcoded in
`build-catalog.util.ts` now live in the spec.
## Nothing changes for self-hosters
`ai-providers.json` is unmodified in this PR: the projection of
`ai-self-host-spec.json` over `ai-models.json` reproduces the committed
file exactly, key order included, so the diff is zero. A new test fails
if it ever stops matching, which is what stops the shipped catalog being
hand-edited back into a second copy.
## Runnable from a sparse checkout
`project.ts` resolves everything by source path, node builtins and
`packages/twenty-shared/src/ai` for the two guards. A repository holding
a private spec runs it with `npx tsx` against a sparse checkout, no
monorepo install and no API key. Verified in a bare directory holding
only the script, that shared directory and `ai-models.json`:
twenty-infra's prod-eu spec produces its committed catalog byte for
byte.
## Scope
The daily sync now writes `ai-models.json` and projects self-host from
it. 153 tests pass across the sync scripts and the ai-models module.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01N5ctWwYtKiq5VdfDG7ZvNy)_
|
||
|
|
d168df965e |
Simplify page layout manifest authoring (#25513)
## Summary Vertical-list widgets can now omit `position` and use array order, with an optional top-level `heightBehavior`. For example, `heightBehavior: 'TAB_VIEWPORT'` makes a widget fill the tab without repeating the tab mode or an index. Existing Canvas tabs, explicit widget indices, and nested height settings keep their behavior. SDK validation warns about deprecated shapes and explains the replacement; it does not rewrite manifests. Shared validation also checks the new height setting and viewport ordering before installation. Existing apps keep their published SDK syntax. This PR updates the local SDK fixture and current API documentation; app migrations can follow after the SDK is published. Stack 3/4, following the CI prerequisite and runtime height support. The final PR migrates eligible stored Canvas tabs. Review and merge order: 1. [App CI repairs](https://github.com/twentyhq/twenty/pull/25531) 2. [Widget height behavior](https://github.com/twentyhq/twenty/pull/25512) 3. [Manifest authoring](https://github.com/twentyhq/twenty/pull/25513) 4. [Existing Canvas data migration](https://github.com/twentyhq/twenty/pull/25511) <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/25513?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
3c128b3dae |
Fix app CI fixtures and registry upload limit (#25531)
The app CI jobs fail before exercising the app behavior: the local registry rejects the SDK publish payload with HTTP 413, and the Last Contact fixture tries to create a platform-owned message-channel association. Raise the test registry request limit to 50 MB and remove the unused association from the Last Contact fixture. Production permissions stay unchanged. Split out of #25513 so the widget/layout stack can be reviewed independently. Call Recorder already has its fixture repair on main in #25412, so that duplicate change is omitted. Validation: these two fixes are unchanged from the passing CI run on #25513. The new branch runs CI independently against main. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/25531?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
e8f9a165c7 |
Let the e2e local registry accept twenty-ui (#25765)
## What `create-app-e2e-minimal` fails for every pull request that runs it, at the "Publish packages to local registry" step: ``` ➤ YN0041: Invalid authentication (as an unknown user) ➤ YN0000: Failed with errors in 0s 813ms Error: Process completed with exit code 1 ``` The workflow publishes `twenty-client-sdk twenty-ui twenty-sdk create-twenty-app`, but `.github/verdaccio-config.yaml` grants `publish` to only three of them. `twenty-ui` falls through to the `'**'` entry, which sets `access` and a proxy but no `publish`, and verdaccio defaults publish to `$authenticated`. The job authenticates with a placeholder `npmAuthToken` and no htpasswd user, so the request is rejected as an unknown user. This has been failing since `twenty-ui` joined the publishable set. It is not intermittent: retries fail identically, because nothing about it depends on timing. Main looks unaffected only because the job is gated on changes under `packages/create-twenty-app/**`, `packages/twenty-sdk/**`, `packages/twenty-client-sdk/**` and `packages/twenty-shared/**`. Most pushes to main touch none of those, so the job is skipped and the run is green. On the runs where main did execute it, it failed with this same error. ## How `twenty-ui` gets the same rule the other published packages already have, so the local registry accepts it without authentication like its siblings. Adding the rule rather than dropping `twenty-ui` from `PUBLISHABLE_PACKAGES`, because its version is aligned with the SDK's, so the scaffolded app resolves `twenty-ui` at the CI version from this registry. Removing it from the list would move the failure to the install step instead of fixing it. ## Verification - Reproduced and fixed locally, running verdaccio exactly as the job does. Publishing a package named `twenty-ui` against the current config fails with `E401 Unable to authenticate`, which is this failure; against the config in this PR the same publish succeeds with `+ twenty-ui@0.0.0-authtest`. - The config parses, and every package the workflow publishes now has a publish rule. - The failure and its cause were read from the job logs of a run that executed the step, and confirmed identical on a run from `main`. - Size is not a concern for this registry's default 10 MB body limit: a clean `twenty-ui` build is about 11 MB across 872 artifacts before compression, so the tarball lands well under it. |
||
|
|
196d676bbc |
Align twenty-ui with SDK package versions (#25758)
`twenty-ui` uses an independent `1.0.0-alpha.2` version while the SDK packages use `2.40.0`. Align UI with the SDK packages and add the shared `set-local-version` target. New apps pin `twenty-ui`, `twenty-sdk`, and `twenty-client-sdk` to the scaffolder's version. The local-registry E2E workflow publishes UI alongside the SDK packages and checks all three dependency versions. Source docs and Codex plugin references describe the same policy, with a validator regression test that catches independent UI version guidance. This replaces the separate-alpha version pins from #25738 while preserving its explicit inclusion of the MIT `LICENSE` in the npm package. Plugin checklist rows touched: R3 (front component guidance) and R5 (app concepts reference). Companion: https://github.com/twentyhq/twenty-infra/pull/934 adds UI to SDK publishing, automatic version bumps, and release notes. Land both changes before the next version bump or SDK publication. Validation: 11 scaffolding tests and 35 plugin tests passed; plugin validation and direct typechecks for `twenty-ui` and `create-twenty-app` passed. An npm pack dry run confirmed the MIT `LICENSE` is included. The full registry/server E2E workflow was not run locally. |
||
|
|
f1d00f033d |
Add benchmarks, data residency and zero data retention to the AI model catalog (#25675)
The catalog sync now merges [Artificial Analysis](https://artificialanalysis.ai/) benchmark data into `ai-providers.json` alongside the models.dev facts it already carried, and emits a second artifact other repos can join against. It also fixes the level data residency is modelled at, and adds zero data retention next to it. ## One publisher, one reading, three figures models.dev is a catalog: it enumerates every model id a provider exposes plus what you need to call it. It publishes nothing about how good or how fast a model is. Artificial Analysis publishes exactly that, and supplies all three figures here: intelligence index, output tokens per second, and cost per task. Deliberately one source. An index is only meaningful against models scored the same way, so a second publisher would have to *replace* these numbers rather than fill their gaps — filling gaps means the scale a model is scored on depends on which publisher happened to rate it, and no consumer can compare two models without knowing which. The overlay names its publisher once at the top rather than tagging each model. Cost per task is the figure worth having for a reasoning model: it counts the tokens a model actually spends thinking, which a per-token price does not. That is the whole set, and the field is `benchmark`, singular: one publisher's reading of one model, taken on one date. `ai-model-benchmarks.json` stays plural because it holds one of these per model. ## Requires an API key Gated on `ARTIFICIAL_ANALYSIS_API_KEY`. Until that secret exists the sync writes the catalog with no benchmarks and an empty overlay, which is exactly what this branch commits. All the plumbing, matching and reporting works; the first sync run with a key populates it. A fetch failure degrades to an empty index rather than throwing. A leaderboard outage must never break the model catalog. ## Matching is the actual work Joining a catalog entry to a benchmark row is most of this diff, because the two name models differently: - Date suffixes are stripped, so `claude-sonnet-4-5-20250929` finds `claude-sonnet-4-5`. - Rolling aliases resolve to the dated release they point at, by fingerprinting price and limits against models.dev siblings. `mistral-large-latest` finds `mistral-large-2512` and re-resolves on its own when Mistral repoints it. This matters: `mistral-large-latest` is in both `DEFAULT_FAST_MODELS` and `DEFAULT_SMART_MODELS` and is never benchmarked under that name. - Names normalize across separators, case and Bedrock version suffixes. ## Reading the API correctly Three behaviours of that endpoint, taken from the reader already in twenty-factory's `ai-catalog-steward` rather than from the docs: - `0` means "not measured", so a naive read records a zero tokens-per-second as a measurement. - It carries several rows per model, some with no measurements. First row wins lets an empty row displace a measured one. - Only the speed figure is top level; the index sits under `evaluations` and cost per task under `cost_per_task`. None of these surface until a key is configured, which is why they are covered by tests rather than left to the first real run. ## The overlay `ai-model-benchmarks.json` sits next to the catalog, keyed by model, carrying what the catalog embeds plus the alias set the publisher gives. It exists so twenty-infra's per-environment catalogs can join the same numbers without merging two catalogs that answer different questions: this one is "what a self-hoster can plug in", those are "what we run, on our contracts, in our regions". A capability score is a property of the model, not the deployment, so it is the one thing that legitimately crosses that boundary. A model earns an entry only by being measured, so every overlay row and every catalog `benchmark` block carries a real figure. That is what keeps `BenchmarkMatch` and the overlay entry non-partial, and it removes the price-only row that produced two rounds of phantom-measurement bugs during review. twentyhq/twenty-factory#112 consumes this overlay and drops its own Artificial Analysis fetch as a result, taking the org down to one key against a 100/day limit and one matcher. ## Data residency and zero data retention A separate axis that lands in the same file. `dataResidency` already existed, sat on the **provider**, and was set in exactly zero catalogs, so the model hover card has been rendering nothing since it shipped. Provider level cannot be right. `amazon-bedrock` in dev and staging carries one region while serving both `eu.anthropic.*` and `global.anthropic.*` models, which do not route to the same place, and a wrong answer here is a compliance claim rather than a cosmetic bug. Models now declare their own, falling back to the provider value so an operator stamping a single-region custom provider through the admin panel still works unchanged. `zeroDataRetention` joins it, optional so `undefined` reads as *unasserted* rather than *no*. Claiming a route is not zero-retention when nobody has checked is as wrong as claiming it is. Neither figure is published anywhere. models.dev and Artificial Analysis carry no residency, retention or training field, so these are hand-declared, forever. They stay unset in the shipped catalog, because where a self-hosted instance processes and retains data depends on its own provider accounts and not on us; a test holds that line. The values that matter are twenty-infra's per-environment catalogs, which is data entry for whoever holds the contracts. What this change buys them is survival: `aiProvidersConfigSchema.parse` runs over the catalog fetched from S3 and silently stripped both fields until now. twentyhq/twenty-factory#112 forbids the steward from ever writing them, since it has nothing it could honestly cite. This is the data model, not the toggle. Filtering the picker to EU-only or ZDR-only belongs at `validateModelAvailability`, the single choke point both the picker and the execution paths already pass through, and wants the data populated first. ## Also - The sync PR body now carries a coverage summary naming every unrated model, so a matcher regression shows up instead of silently emptying the overlay. - `ai-sync-models-dev.ts` becomes `scripts/ai-catalog-sync/`; the name no longer described what it does. - `ModelsDevModel.release` / `.updated` were dead and did not match the API, which returns `release_date` / `last_updated`. Corrected, since alias resolution needs the release date. - `ai-providers.json` is deliberately untouched. Regenerating it here pulled in a day of unrelated models.dev price and context-window drift that the daily sync brings on its own and that only obscures this diff. ## Verification Sync runs clean and is idempotent. Lint, format and typecheck clean; 43 tests across the matcher, the API reader and the two committed artifacts. ## Follow-up, not in this PR `load-default-model-preferences.util.ts` still hardcodes the fast/smart chains, with a TODO asking for them to be derived from the catalog. That is what this data unblocks, and the steward is the natural place to do it. |
||
|
|
8997a6d864 |
Run direct consumer CI on twenty-ui changes and record known radio failures (#25670)
Changes confined to `packages/twenty-ui/**` could skip consumer CI, allowing sandbox regressions to reach main. Add that path to the renderer, SDK, and website workflows, which directly depend on the workspace UI package. Frontend and UI CI already cover the path. The Base UI 1.8 upgrade in #25615 introduced an `Element.matches(':disabled')` call that the renderer sandbox does not support ([CI failure](https://github.com/twentyhq/twenty/actions/runs/34342034359/job/102435795459?pr=25655)). Temporarily use the existing known-failure gallery checks with separate expectations for each runtime: React must fail exactly `CardPicker`, `Radio`, and `RadioGroup`; Preact must fail only `CardPicker`, while its radio controls render successfully. Unexpected failures and partial or complete fixes still fail the assertions, prompting an expectation update when sandbox support lands. Validation: - Checked eight workflow filters against UI source, stylesheet, and package-manifest changes, plus an unrelated documentation change. Renderer, SDK, and website filters match UI changes; email, server, create-app, and both example-app workflows keep their existing filters. The five reverted workflow files match the base branch exactly. - Actionlint reports no new diagnostics; existing local-action metadata errors remain. Two workflows also have pre-existing Prettier failures. The story formatting and `git diff --check` pass. - Replayed the current React and Preact input story tests in Chromium against the saved CI Storybook build. The original Preact expectation reproduces the linked CI failure; both stories pass with the runtime-specific expectations. Confirmed that Preact renders all three radio controls and that failure reports remain stable after waiting. Changed-story lint and formatting pass. The full suite was not rerun locally. |
||
|
|
e7d15f85db |
Stream nx task output so a killed task still prints its stack (#25560)
Nx buffers task output into an internal stream by default, so a task that gets killed takes its buffer with it. The job log ends on whatever last happened to flush and the crash is never printed — an integration shard currently exits 1 after 18 passing suites with no summary, no failing test and no stack trace. `--outputStyle=stream` is nx's own flag for this: "nx by default logs output to an internal output stream, enable this option to stream logs to stdout / stderr". |
||
|
|
2fa7f53553 |
Generate the twenty-ui theme artifacts from a single design-tokens source (#24375)
The two 1,000-line theme CSS files, the `themeCssVariables` accessor,
`ThemeType` and the 44 files under `src/theme/constants` were all
hand-maintained mirrors of the same ~994 values. This PR makes a
committed TypeScript token source the single source of truth and
generates every one of them from it.
## How it works
Token values live in `packages/twenty-ui/design-tokens/`, one file per
token group. `npx nx generateTokens twenty-ui` rebuilds every artifact
and formats it with oxfmt. A `generate:check` step on the CI lint job
reruns the generator and diffs its declared outputs, so a hand edit to a
generated value cannot merge.
```mermaid
flowchart LR
A["design-tokens/
TypeScript token source"] --> B["scripts/generateThemeTokens.ts"]
B --> C["theme-light.css / theme-dark.css"]
B --> D["themeCssVariables.ts
var() accessor"]
B --> E["themeTypes.ts
ThemeType"]
B --> F["ThemeLight.ts / ThemeDark.ts / ThemeCommon.ts
resolved static values"]
B --> G["MainColorNames.ts
ThemeColor vocabulary"]
B --> H["Animation.ts / GrayScaleLight.ts / GrayScaleDark.ts
standalone subtree constants"]
CI["generate:check (CI)"] -. "regenerates and fails on drift" .-> B
```
## Why this is useful
- Drift between the light CSS, the dark CSS, the accessor, the types and
the static theme objects becomes structurally impossible. Changing a
token is one edit in one file instead of five coordinated ones.
- `src/theme/constants` goes from 44 files to 10, seven of them
generated (`ThemeLight`, `ThemeDark`, `ThemeCommon`, `MainColorNames`,
`Animation`, `GrayScaleLight`, `GrayScaleDark`); the 35 that only held
values are deleted.
- Inside the token source, semantic tokens reference the primitives they
come from instead of copying them: `grayScale` is the gray scale, each
main color is that palette's `9` step, and `smRound` / `mdRound` are
`sm` / `md`. The round-corner and palette invariants hold by
construction rather than by test.
- `ANIMATION`, `GRAY_SCALE_LIGHT`, `GRAY_SCALE_DARK` and `THEME_COMMON`
are emitted as standalone literals rather than slices of `THEME_LIGHT`,
so importing one does not retain the whole 994-token object in a
consumer's bundle.
- `ThemeType` is generated and fully explicit, which removes the
hand-kept `NumericOverrides` mapping and typechecks faster than the
previous nested mapped types.
## Breaking changes
- The four IllustrationIcon CSS variables are renamed from the
historical double-dash names to `--t-illustration-icon-*`. These ship in
the published `./theme-light.css` and `./theme-dark.css` entrypoints, so
a downstream stylesheet writing `var(--t--illustration-icon-color-blue)`
silently resolves to nothing after upgrading. All in-repo consumers read
them through the accessor, which renames in the same commit.
- 35 exports are removed from `twenty-ui/theme` (`ACCENT_LIGHT`,
`BORDER_COMMON`, `COLOR_DARK`, ...). None had a consumer outside
`src/theme/constants`, and every value stays reachable as a subtree of
`THEME_LIGHT` / `THEME_DARK`. `twenty-front` and `twenty-website` need
no edits.
- `packages/twenty-ui/dist/theme-{light,dark}.css` are no longer tracked
in git. The vite `copy-theme-css` plugin is now their only writer;
twenty-front already required a `twenty-ui` build to resolve
`twenty-ui/style.css`.
## Notes for review
Of the ~6,900 added lines, ~3,350 are generated artifacts and ~2,700 are
machine-extracted token values. The reviewable surface is the ~800 lines
under `design-tokens/pipeline`, `design-tokens/__tests__` and
`scripts/`.
Values were machine-extracted, so the only CSS content diffs are the
header comment, a 2-line radius reorder aligning the CSS with the
accessor's token order, and the IllustrationIcon rename. No visual
change is possible, and Argos is flat. The generators are
byte-idempotent. `THEME_LIGHT.spacing` stays a variadic function, and
`background.noisy` stays a `var()` reference in the static trees rather
than inlining a 100 kB data URL into the bundle.
|
||
|
|
cda35be5b0 |
Return job handles from enqueueJob(s) and add a getJobs query (#25204)
Closes twentyhq/core-team-issues#2779 `enqueueJob(s)` returned `{ enqueued, logicFunctionUniversalIdentifier }` and no handle, so an app could never answer "did my job run, is it still running, did it fail". The 202-then-poll shape was not expressible. The job id existed at every layer and was thrown away at the driver boundary. ## Message queue layer - `MessageQueueDriver.add()` returns the job id (`undefined` when the existing dedup-prefix check skips the add). - `bulkAdd()` now takes one item per job (`{ data, jobId? }`) instead of a flat data array, and returns the ids. Updated at the three existing call sites (emailing campaigns ×2, database-event trigger). - New optional `getJobs(queueName, jobIds)` on the driver, implemented for BullMQ over `queue.getJob()` + `job.getState()` (no bulk `getJob` upstream, so it fans out over `Promise.all`). It returns `Partial<Record<jobId, QueueJobDetails>>` — sparse, because an id the queue no longer holds is simply absent, and the type says so. - `SyncDriver` now generates a real id instead of `''`. - `JobState` and its BullMQ mappings moved from `admin-panel/enums/` to `message-queue/enums/`, so reading job status from `application` no longer couples two sibling feature modules. The GraphQL enum keeps its name and members, so schemas are unchanged. ## Apps API - `enqueueJob` returns `jobId`, `enqueueJobs` returns `jobIds` in the order of the jobs passed. - Callers can name their own ids by passing `jobs: [{ payload, jobId }]`. `payloads` still works and is now deprecated in favour of `jobs`; exactly one of the two must be set. - New `getJobs(jobIds: [String!]!): [JobStatus!]!` query on the metadata API, behind the same `@AuthApplication` guard as the mutations, returning `state`, `attemptsMade`, `failedReason` and the three timestamps per job. - `getJobs` added to `twenty-sdk/logic-function`, with a spec matching the enqueue helpers. An id the queue no longer holds is omitted from the result rather than erroring, so one evicted id cannot fail a whole batch; the caller diffs what came back against what it asked for. A read is capped at `MAX_JOBS_PER_STATUS_READ` (200), mirroring `MAX_JOBS_PER_ENQUEUE` on the write side, so one authenticated request cannot fan out an unbounded number of Redis lookups. ## Scoping and idempotency BullMQ job ids are queue-global, so a naive `getJob(id)` would be a cross-tenant read. Ids are stored prefixed with the workspace id (`buildQueueJobId`), which makes a cross-workspace read structurally impossible rather than relying on an ownership check after the fact. The app never sees the prefix. The same prefixing makes a caller-supplied id an idempotency key: re-enqueuing an id the queue still holds is accepted but starts no second run. Two documented caveats — the guarantee lasts only as long as the job is retained, and ids are shared across apps within a workspace. ## Introspection of deprecated input fields (three consumers, one root cause) GraphQL introspection omits deprecated **input** fields by default (`inputFields(includeDeprecated: false)`). So deprecating `payloads` made it *disappear* from introspection rather than be marked deprecated — a deprecation behaving like a removal, breaking exactly the callers it exists to keep working. Two consumers are fixed here, because the requested deprecation is not implementable without them: - `twenty-client-sdk/scripts/generate-metadata-client.ts` now passes `inputValueDeprecation: true`, so `payloads` is emitted as `@deprecated(reason: "Use jobs instead.")` instead of vanishing from the SDK schema — the types apps actually compile against. Nothing else in the generated client changed. - `twenty-utils/graphql-introspection-query.graphql` now passes `includeDeprecated: true` to `inputFields` and selects the deprecation metadata, matching what it already does for `fields` and `enumValues`. Without it `api-breaking-changes` reported *"Input field payloads was removed from input object type EnqueueJobsInput"* and failed. Verified with `graphql-inspector` on an isolated schema pair: the change is now reported as *"Directive deprecated was added to input field payloads"*, no breaking changes. - `ci-breaking-changes.yaml` also now introspects both servers with the *same* query text. Checking out main mid-job was swapping in main's copy of the query file, so the two schema dumps were taken with different queries precisely when that file changes. The third consumer, the front's graphql-codegen, still drops deprecated input fields: `@graphql-tools/url-loader` exposes no `inputValueDeprecation` option. Nothing in `twenty-front` references `EnqueueJobsInput`, and the SDK types keep the field, so this is cosmetic — noted as a follow-up rather than silently accepted. ## Retention `QUEUE_RETENTION` is unchanged, so a status is readable for 4h after completing and 7d after failing, capped at the most recent 1000 jobs per state. Past that the id is simply absent from `getJobs`. This is called out in the docs, since "no longer known" is a different answer from "it never ran". A per-queue override for `logicFunctionQueue` is a follow-up if the window turns out to be too tight. Also out of scope, from the issue's adjacent list: per-application concurrency caps, and failed-job listing/requeue. ## Validation - `twenty-server` typecheck clean on the merged head; `oxlint --type-aware` and `oxfmt` clean on all changed files. - `npx nx test twenty-server`: 7313 passed. - `enqueue-job.integration-spec.ts` with db reset: 13 passed, including three new cases (a caller-supplied id reaching `COMPLETED` through the worker, unknown ids omitted rather than erroring, and `payloads` + `jobs` together rejected). - `npx nx test twenty-sdk` passed, including the new `get-jobs.spec.ts`. - All three codegen configurations (`metadata`, `admin`, `data`) re-run against a freshly built server; the committed generated files are current and the enum move produces no schema diff. <a href="https://cubic.dev/pr/twentyhq/twenty/pull/25204?utm_source=github" rel="nofollow noreferrer noopener" target="_blank">``<img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg">``</a> --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com> |
||
|
|
b88e49126d |
Port genql 3.0.5 test suite and run twenty-client-sdk tests in CI (#25092)
Follow-up to twentyhq/core-team-issues#2819 (genql vendor challenge): give the vendored genql fork a real safety net — upstream's own test suite, ported and enforced — and make CI actually run this package's tests. ## Context `packages/twenty-client-sdk/src/generate/genql` is a vendored copy of `@genql/cli@3.0.5` (upstream commit `4a547db46a9a614cc2b5958e28674af351898464`, now pinned in its README). Diffing it against upstream showed the engine (`render/`, `runtime/`) is near-verbatim while the orchestration was deliberately rewritten to drop the vulnerable dependency graph. What was missing: behavioral test coverage for the engine (the existing snapshot test pins bytes, not behavior), and any CI running the package's tests at all. ## Ported upstream 3.0.5 test suite `src/generate/__tests__/upstream-3.0.5/` ports the test suite of remorses/genql at the vendored version (mapping table in its README): - **Integration**: upstream's fixture schema (byte-verbatim), `simple.ts` (query-generation snapshots) and `execution.ts` (end-to-end: unions, interfaces, union-implements-interface, `__scalar`, batching, `GenqlError`, sync/async header functions). - **Unit**: `typeSelection` (the type-level `FieldsSelection` contract), `typeMap`, `renderTyping`, `comment`, `RenderContext`, `renderSchema`. This mirrors upstream's own CI model (its `genall` step regenerated the client from the fixture schema on every run, typechecked it with the tests, and ran the suite against it) and adds byte-level pinning upstream did not have. Adaptations (documented in the dir's README): - apollo-server v3 (deprecated) is replaced by graphql-js `graphql()` behind an injected `fetch`/`fetcher` — the identical client code path, no network, zero new dependencies. - mocha/sucrase/snap-shot-it/tsd become vitest. - A generated fixture client is checked in (pinned by a drift test with an `UPDATE_GENQL_UPSTREAM_FIXTURE=1` refresh mode) so the type-level assertions compile and are verified by the package typecheck. - tsd's `expectType` assertions are strengthened to exact: upstream typechecked its tests with plain tsc, never the tsd CLI, and under plain tsc `expectType<T>(value)` is only an assignability check — several assertions were looser than the real types. The exact assertions here pin what the 3.0.5 engine actually generates (`undefined` for absent fields, never `null`; literal `__typename` types). - Upstream's `typeMap` assertions ran in output-logging mode with stale expected values; they are enabled and corrected to the engine's actual output. Not ported: `parse`/`printer` tests (cover code Twenty did not vendor) and the subscription suite (already skipped upstream). Also: `runtime-templates.ts` (Twenty's `?raw` bundling mechanism, not upstream code) moved out of the vendored tree to `src/generate/`, so `src/generate/genql/` contains only the fork plus its README and LICENSE — and the file gains oxlint/oxfmt coverage it was previously excluded from. ## Dedicated CI workflow The package's tests never ran in CI: `ci-sdk.yaml`'s changed-files gate does not include `packages/twenty-client-sdk/**`, and its matrix runs `test:unit` while the package only defines `test`. This adds `ci-client-sdk.yaml` (modeled on `ci-shared.yaml`): lint / typecheck / test matrix via `nx affected` on a new `scope:client-sdk` tag, gated on `twenty-client-sdk` and `twenty-shared` changes. The `test` target now declares `dependsOn: ["^build"]` so `twenty-shared/dist` is fresh wherever the suite runs (CI and locally). ## Validation - `twenty-client-sdk` vitest: 15 files, 111 tests passing. - `tsgo -p tsconfig.json --noEmit`: clean (this is what arms the `@ts-expect-error`/`expectTypeOf` assertions). - `nx lint twenty-client-sdk` (oxlint + oxfmt): clean. - The `client-sdk-test` (lint / typecheck / test) jobs from the new workflow run green on this PR. |
||
|
|
4423973633 |
fix(ci): authenticate the app-dev test leg as the seeded admin user (#25028)
## Why The `dockerhub-latest` app CI leg (the one that runs an app's integration suite against the published `twentycrm/twenty-app-dev:latest` image) authenticates with a hardcoded **API_KEY** token. That broke for every app that executes deployed logic functions in its tests. Executing a logic function through `executeOneLogicFunction` now requires a **user** context: the resolver reads `@AuthUser()` and `@AuthUserWorkspaceId()`, both of which throw `ForbiddenException` for an API key. The SDK CLI's api-client turns that `FORBIDDEN` into a generic `Authentication failed: GraphQL auth error in response`. This requirement was added in #24493 ("give handlers an execution context, and a choice of whose access each call uses"). Evidence it's the image server, not app code: the leg passed on every published image up to `2.32.0` and has failed on every image from `2.35.0` on (the first images carrying #24493). A docs-only Slack PR (#24882, a 2-line README change) failed the leg with the identical error, while its `local` leg passed. ## What it does Swap the action's `api-key` output from an API_KEY to an **ACCESS token for Tim, the seeded admin**. - Executing logic functions needs a **user** (Tim is one), so the `executeOneLogicFunction` gate passes. - The app test's global setup also **deploys the app** (`appDevOnce`), which needs **admin** permissions. The image seeds with `--light`, where the dev seeder makes **Tim** the admin and Jane a plain member (Jane is the admin only in the non-light local seed). So the token must be Tim's, not Jane's. This mirrors the `local` leg, which already authenticates as its own seeded admin. The token is signed with the app-dev `APP_SECRET` the same way the previous API key was: `sha256(APP_SECRET + workspaceId + 'ACCESS')`. ## Scope and safety - Only file changed: `.github/actions/spawn-twenty-app-dev-test/action.yml`. - Its only consumer is `spawn-twenty-server`, so this affects every app's `dockerhub-latest` leg. That's the intent, they all hit the same latent break. - Safe: each app already authenticates as its seeded admin on its `local` leg and passes, so the same identity on the `dockerhub-latest` leg can't newly break a passing test; it only clears the user/admin gates the API key could not. ## Verification - The signing recipe was confirmed by reproducing the existing API_KEY's signature exactly, then re-signed with `type=ACCESS`. - Tim's ids are the dev-seeder constants; `seedCoreSchema` (users + user-workspaces) and the `--light` admin assignment run for this workspace. - Confirmed green: the `slack (dockerhub-latest)` leg passes with this token on the branch it was validated on. --- _Generated by [Claude Code](https://claude.ai/code/session_014rA1NFmPJS6RLjVkZoD4T4)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/25028?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
73a451d841 |
Make QA Scout failures quiet on PRs and legible in CI (#25060)
Fixes the useless comment on #24937 ([this one](https://github.com/twentyhq/twenty/pull/24937#issuecomment-5455448375)), a regression from #24934, and then fixes the gap that made it hard to diagnose. ## What went wrong #24934 told the Scout to checkpoint its verdict before the first scenario so a killed run would leave its last known state instead of silence. It overcorrected: on run [33190923955](https://github.com/twentyhq/twenty/actions/runs/33190923955) the agent hit the turn cap (`terminal_reason: max_turns`, 151 turns, $10.12) without ever rewriting those files, so the comment job published the placeholder verbatim — an INVESTIGATE verdict reading "Run still in progress" with all four scenarios `pending`. On a merged PR that is worse than saying nothing: it looks like a finding, and it isn't one. The stream log shows the agent had done the work — screenshots `01` through `10` including `05-unpinned-persisted-reload.png` and `09-cancel-restores-pinned.png` — it just never updated the verdict between scenarios. ## 1. Only real findings reach the PR `verdict.json` now carries a `status` (`in-progress` / `final`), and the comment job decides on evidence rather than on the file merely existing: | Verdict state | Behavior | |---|---| | `final`, FAIL or INVESTIGATE | posts (unchanged) | | `final`, PASS | quiet, or refreshes a stale comment (unchanged) | | `in-progress` with a failing scenario, or a top-level FAIL | posts under a `> [!WARNING]` that the findings are partial | | `in-progress`, nothing failing | **quiet** — job summary and artifact only | The top-level FAIL clause is [Greptile's catch](https://github.com/twentyhq/twenty/pull/25060#discussion_r3888567423): "app did not boot" is written before any scenario exists, so gating purely on failed scenarios would have suppressed the loudest signal the Scout has. The skill now also states that an early terminal verdict is `final` the moment it is written, so the workflow gate is a backstop rather than the only defense. A verdict with no `status` counts as `final`, so nothing regresses while the skill change rolls out. ## 2. A failed run now says why Related gap, and more pressing now that a dead run is deliberately silent on the PR: the summary said `No report produced (agent run failed or timed out)` and left the reason in an archive nobody downloads. Every failure so far was diagnosed by hand. The run step keeps stderr and the exit code next to the stream, and the summary reads both back — the final result line gives outcome, turns, duration, cost and error; stderr covers runs that die before producing one. A warning annotation puts the first stderr line on the run page. Rendered against the two real failures on record: ``` ## QA Scout The Scout produced no verdict. **Why it stopped** (exit code `128`) - outcome: `error_max_turns` · 151 turns · 11 min · $10.12 - error: Reached maximum number of turns (150) ``` ``` ## QA Scout The Scout produced no verdict. **Why it stopped** (exit code `1`) - the agent produced no result line, so it failed before or during startup <details><summary>stderr (last 10 lines)</summary> error: Claude Code returned an error result: You've hit your weekly limit ... </details> ``` That second one is live right now: the shared account is out of weekly quota until Aug 31 14:00 UTC, which is why this PR's own `PR Review / Security` and `Quality` checks are red. Not this diff; see [the comment above](https://github.com/twentyhq/twenty/pull/25060#issuecomment-5466952932). ## 3. Turn budget The dead run's permission denials show where its 151 turns went: copying screenshots the artifact already ships from `browser/`, `curl` boot checks whose rule was removed in #24907, and `python3`/`node` for JSON that `jq` handles. The skill now tells the Scout to leave screenshots where Playwright writes them, names the tools it actually has, and asks for two to four scenarios finalized by roughly the ten-minute mark. `--max-turns` stays at 150: with checkpoints that carry real results, hitting the cap degrades gracefully instead of producing noise. ## Validation Workflow YAML parsed; all three edited run blocks pass `bash -n`; the posting gate exercised against eight payload shapes including the exact JSON that produced the #24937 comment (quiet), an in-progress boot FAIL with both an empty and a pending scenario list (both post), a legacy verdict with no `status` (posts), and malformed JSON; the summary rendered against both real failure modes and against a healthy run (unchanged); the stderr tee and `PIPESTATUS` exit-code capture verified against a failing command. ## Cleanup The stale comment on #24937 cannot be edited from here, but once this merges and quota resets, dispatching CI E2E Main with `pr_number=24937` re-runs the Scout and overwrites that comment in place with a real verdict. |
||
|
|
94ca5a7c64 |
Fix QA Scout turn exhaustion found by the first live audit (#24934)
First live audit of the Scout (8 non-superseded push runs since the direct-CLI fix, 11:27Z to 14:00Z today) found one systemic bug; this PR fixes it. ## The numbers | Run | PR | Outcome | |---|---|---| | 11:32 | #24918 (i18n) | skipped by design | | 11:44 | #24916 (revert) | PASS, 92 turns, 6.2 min, $4.54 | | 12:16 | #24778 | died at turn 121, no verdict | | 12:30 | #24708 | PASS, 88 turns, 6.8 min, $4.28 | | 12:51 | #24780 (upgrade commands) | wrote a PASS verdict on turn 121, then killed before report.md | | 13:13, 13:47, 14:00 | 3 PRs | died at turn 121, no verdict | Five of eight runs hit the `--max-turns 120` cap. When they did, the artifact was empty or verdict-only, so the comment job stayed silent and the PR got nothing. ## Root cause The execution logs (captured since #24907) show every Bash command touching `/tmp/qa-scout` was denied: `wc -l server.log worker.log`, `ls -la /tmp/qa-scout/`, even `tail -n 2 server.log`. The CLI validates Bash file arguments against the session's working directories, and `/tmp/qa-scout` is outside the checkout, so no allowlist prefix rule can permit it. Agents adapted by paging through megabyte log files with the Read tool, which is exactly where the turn budget went. The one modest-PR runs finished under 92 turns; anything meatier blew the cap. ## Fixes - `--add-dir /tmp/qa-scout`: grants the directory, so one `tail -n +N` replaces dozens of Read pages. Root-cause fix; turn counts should drop well below the old baseline. - `--max-turns 150`: headroom while the fleet re-baselines (the 15-minute step timeout still bounds cost). - `--strict-mcp-config`: stops loading the repo's `.mcp.json`, whose postgres server the agent kept reaching for and getting denied. - `PGPASSWORD` in the step env: bare `psql -h localhost ...` now matches `Bash(psql *)` instead of dying on the `PGPASSWORD=... psql` env-assignment prefix. - Graceful degradation: the comment job and job summary now synthesize a body from `verdict.json` when `report.md` is missing, so a partial run surfaces its verdict instead of "nothing to post". The 12:51 run on #24780 had a complete PASS verdict that nobody saw. - Skill: write both output files right after scoping and update them in place, so any death leaves the last known state. ## Validation Workflow YAML parsed; all three edited run blocks pass `bash -n`; the jq fallback tested against FAIL-with-scenarios, PASS-minimal, and missing-scenarios inputs under pipefail; `--add-dir` and `--strict-mcp-config` verified present in the pinned CLI 2.1.247. --- _Generated by [Claude Code](https://claude.ai/code/session_01B1ML7TTsrqPbPnCtox9rBX)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/24934?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
e45ac77219 |
Audit runner egress on the QA Scout job (#24908)
Follow-up to #24856, first step of the containment hardening discussed in its security review. ## The gap this works toward closing The Scout's `--allowedTools` list scopes what the agent does, not where the runner can reach. The accepted residual in #24856 is that a prompt-injected agent could exfiltrate the inference token in real time over the runner's open egress (`psql` alone is arbitrarily powerful in the disposable env, so pure allowlisting cannot close this). The real fix is a runner-level egress block with an endpoint allowlist. Writing that allowlist blind would break the job on the first missing endpoint. ## What this PR does Adds `step-security/harden-runner` (sha-pinned to v2.21.0) as the first step of the `e2e-test` job with `egress-policy: audit`: - records every outbound endpoint the job actually uses (yarn install, Playwright, the Anthropic API, GitHub) in the StepSecurity insights for the run, - blocks nothing and changes no behavior; a failure of the action itself fails nothing downstream since it only observes. ## What comes after Once a few weeks of audit data show a stable endpoint set, a follow-up flips to `egress-policy: block` with that allowlist. At that point real-time exfiltration from the agent step becomes impossible rather than merely out of scope, and the same pattern can extend to the other agent-running workflows (`claude.yml`, and the cross-version Scout of #24906). ## Supply-chain note This adds a third-party action to the job. It is pinned by commit sha, runs in audit (observe-only) mode, and is the de facto standard for exactly this purpose; calling it out explicitly since the tradeoff (new dependency vs egress visibility) is a real one for reviewers to weigh. --- _Generated by [Claude Code](https://claude.ai/code/session_01B1ML7TTsrqPbPnCtox9rBX)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/24908?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
6af9183bc8 |
Fix the post-merge QA Scout (direct CLI invocation) and tune its allowlist (#24907)
Follow-up to #24856. Scope grew after the first live post-merge runs: **this PR is now the fix for the Scout's flagship mode, which turned out to be broken on main.** ## The bug (found by the first live runs) Runs [33059764654](https://github.com/twentyhq/twenty/actions/runs/33059764654) (merge of #24856 itself) and [33060899663](https://github.com/twentyhq/twenty/actions/runs/33060899663) (merge of #24854) both had the Scout step die in 252ms with: ``` Action failed with error: Unsupported event type: push ``` `claude-code-action` has no `push` case in its event parser, neither at the pinned v1.0.146 nor at latest (verified in its source: the switch covers issues, comments, reviews, pull_request, workflow_dispatch, repository_dispatch, schedule, workflow_run). Every pre-merge test used `workflow_dispatch`, which is supported, so the one untested path was the flagship one. Shadow-safety worked exactly as designed: the failure was swallowed, the comment job skipped gracefully, and main CI was unaffected. ## The fix Replace the action with a direct, pinned Claude Code CLI invocation (`@anthropic-ai/claude-code@2.1.247`): - No event-type opinion: works on push, label, and dispatch alike. - Strictly better containment: the action's GitHub bootstrap was the only reason a GitHub token entered the agent step. The step now holds no GitHub credential at all, only the inference token. - `--output-format stream-json` teed to `/tmp/qa-scout/execution.json` (ahead of the scrub) replaces the earlier capture step on this branch and is richer: per-turn detail plus permission denials for data-driven allowlist tuning. The flags are the same ones the action passed through `claude_args` to this same CLI, so behavior (allowlist enforcement, auto-deny, turn cap) is unchanged. ## Allowlist tuning (original scope) The first PASS run finished in 99 turns with 19 permission denials. Added read-only text tools the agent predictably reaches for: `jq`, `echo`, `diff`, `sort`, `uniq`, `cut`, `tr`, plus `TodoWrite`. All subprocess-free (`find` and `awk` were considered and dropped for `-exec`/`system()`); `psql` stays the single documented full-power exception, and `curl` stays excluded per the #24856 exfiltration reasoning. ## Validation Workflow YAML parsed; the run block passes `bash -n`; all CLI flags verified present in 2.1.247 (`--allowedTools`, `--mcp-config`, `--model`, `--output-format`, `-p`), with `--max-turns` proven by the action's own verbatim pass-through in the earlier PASS run. Until this merges, the post-merge Scout stays dead on main; label and dispatch modes are unaffected. |
||
|
|
0bcce772d2 |
Add QA Scout: post-merge browser QA agent on main (#24856)
## What A post-merge browser QA agent, riding the environment `ci-e2e-main` already boots on every push to main. After the deterministic Playwright suite, the QA Scout: 1. Resolves the merged PR for the pushed commit and collects its metadata, file list, and diff. Skips i18n translation PRs and titles carrying `[no-qa]` / `[noqa]`, mirroring `mustBeQa` in the eng app. 2. Runs Claude Code (`claude-code-action`, same pin as `claude.yml`) with the Playwright MCP against the already-running app at `localhost:3000`, following the new skill at `.claude/skills/qa-scout/SKILL.md`: derive 2 to 5 user-visible scenarios from the diff, execute them in the browser, and diff its own window of the server and worker logs to catch swallowed backend exceptions behind a green-looking UI (the 2.35 timelineActivity failure mode). 3. Writes `verdict.json` (`PASS | INVESTIGATE | FAIL`) and `report.md`, uploaded as the `qa-scout-report` artifact and rendered in the job summary. 4. On `FAIL` / `INVESTIGATE`, a separate job posts (or updates) a single marked comment on the merged PR, opening with a `[!CAUTION]` admonition. `PASS` stays quiet. The skill also works locally: engineers can run `/qa-scout` in Claude Code against a dev stack. ## Why Post-merge QA of merged PRs is today a fully manual Build Companion queue, and the deterministic e2e suite covers 7 flows. The Scout pre-chews that QA: it picks the risky flows for each specific diff, and it reads the logs while it clicks, which is exactly where silently-swallowed regressions hide. `hasQaBeenDoneOnMain` stays human-owned; the Scout's report lands where the human QA starts. ## Safety and rollout - **Shadow-safe**: every Scout step is `continue-on-error`, so it can never redden `ci-e2e-main` or affect `ci-e2e-main-status-check`. The suite's own behavior is unchanged (server/worker logs are now teed to files; console output is identical). - **Privilege separation**: the agent step runs under the workflow's read-only token and only gets `Read/Grep/Glob/Write`, a short Bash allowlist, and the Playwright MCP (headless Chrome, isolated profile, origin-restricted to localhost). `pull-requests: write` exists only in the `qa-scout-comment` job, which reads the structured artifact and never the agent's raw context, so untrusted page/log content the agent reads can never reach a privileged credential. - **Never on fork or PR events**: the Scout only runs on push to main and manual `workflow_dispatch`; merge-queue PR runs of this workflow are untouched. - **Kill switch**: set repo variable `QA_SCOUT_DISABLE_COMMENTS=true` to keep verdicts in artifacts/summaries only (shadow mode). - **Re-run**: Actions, CI E2E Main, Run workflow with `pr_number` re-runs the Scout for any merged PR. ## Validation - Workflow YAML parses; both embedded scripts pass `bash -n`; the heredoc MCP config is valid JSON. - The `mustBeQa` mirror was table-tested against real title shapes (`[no-qa]`, `[NOQA]`, `i18n - translations`, mid-title i18n mentions). - `@playwright/mcp@0.0.79` (current latest) flags used (`--headless`, `--browser chrome`, `--isolated`, `--output-dir`, `--allowed-origins`) verified against its CLI help. - `.gitignore` gains `!.claude/skills/` so the skill can live next to the committed `.claude/settings.json`. ## Follow-ups (separate PRs) - Wire the verdict into the eng app: a webhook route (like `/s/main-ci-failing`) writing the verdict onto the `pullRequest` record so Build Companion shows it in the QA queue. - Point the same skill at staging after deploy/upgrade, where aged data lives. - Have the nightly/deep variant draft Playwright specs for stable scenarios so the deterministic suite grows. --- _Generated by [Claude Code](https://claude.ai/code/session_01B1ML7TTsrqPbPnCtox9rBX)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/24856?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
588ed9a413 |
Remove IS_ORM_V2_READ_PATH_ENABLED and collapse to the ORM v2 path (#24692)
## What ORM v2 is now the only read/write path for workspace data. This removes the `IS_ORM_V2_READ_PATH_ENABLED` feature flag and every branch that gated on it, always taking the v2 path and deleting the now-dead v1 read/write branches. ## Changes - **Common query runners** (base / create-many / group-by / merge-many) always use the v2 read/write repositories. The legacy mutation query-builder, the v1 `GroupByWithRecordsService`, and the v1 merge transaction path are deleted. - **Nested-relations processor** always routes through the ORM v2 helper; the v1 `ProcessNestedRelationsV2Helper` (and its spec) is deleted. The dead v1 `workspaceDataSource` plumbing is removed from the query-runner context and all its consumers. - **`GlobalWorkspaceOrmManager`**: `getRepository` and `runInWorkspaceTransaction` always use v2; the v1 fallbacks and the `isOrmV2ReadPathEnabled` helper are removed. `loadWorkspaceContext` no longer builds the TypeORM `EntityMetadata` graph, and the `ORMEntityMetadatas` cache provider is now a no-op (the cache key stays because committed upgrade commands still invalidate it). - **Flag definition**: removed from `FeatureFlagKey`, `DEFAULT_FEATURE_FLAGS`, and the feature-flag cache-key invalidation util (inlined). Generated GraphQL enum types regenerated. - **CI**: the `server-integration-test` `orm-v2` matrix axis collapses to a single run now that the default run exercises v2. The temporary `TEST_FORCED_FEATURE_FLAGS` seeding/assertion mechanism is removed. ## Scope / follow-up The v1 `GlobalWorkspaceDataSource` is intentionally kept: a few services (`object-record-count`, `mostly-empty-fields`) still use it as a raw `pg` connection via `dataSource.query(...)`, not the ORM entity layer. Migrating those onto the v2 pool and deleting the v1 datasource/entity-manager/query-builders is a separate follow-up. ## Notes - Assumes `IS_ORM_V2_READ_PATH_ENABLED` is already enabled on all existing workspaces (not just defaulted for new ones). - `feature_flag.key` is a `text` column, so removing the enum member needs no DB migration; existing rows are inert. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/24692?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
9618231dfc |
feat(docs): gate MDX docs on Crowdin-safe placeholders (#24540)
Tracked as `DOCS-01` on twentyhq/core-team-issues#2784.
### Problem
Crowdin parses `<foo>` in prose as a tag rather than literal text, so an
angle bracket placeholder is dropped or mangled in every translated
page. Curly braces `{foo}` survive the round trip.
A `no-angle-bracket-placeholders` rule used to guard against this. It
ran as `'error'` on `**/*.mdx` in the root `eslint.config.mjs` from
#15751 (Nov 2025) until #18443 migrated us to oxlint. oxlint has no
`eslint-plugin-mdx` equivalent, so the whole `**/*.mdx` block went with
it and the rule stopped running. The file was carried into
`twenty-oxlint-rules` wired to nothing, and #24535 removed it as dead
code. So the guard has been silently off for about five months, not
missing by design.
This restores it in a form that runs without a JSX AST: a plain text
scan, no new dependencies.
### Change
- `scripts/lint-mdx.ts` scans the 204 English source pages and skips
`l/` (the 2613 generated Crowdin files, not hand-editable). A violation
there is only ever a symptom of one in the source.
- The `lint` target was `{}`, an empty no-op, while the `docs-lint` CI
job invoked `nx lint twenty-docs`. So the job has been passing without
running anything. The target now runs the package's own `.oxlintrc.json`
(nothing was running that either, so `scripts/*.ts` is linted for the
first time) followed by the MDX scan.
- A `test` target plus a vitest config for the package, wired into CI.
14 tests.
### On the backtick handling
The scanner skips angle brackets inside code spans, since they are
legitimate there. The first version did this with a running backtick
parity count over the whole file, which is unsound: one unpaired
backtick in prose flips parity and silently suppresses every finding
after it in the file, while the run still prints clean.
`docker-compose.mdx` was already in that state from line 210.
Fixed in
|
||
|
|
4373833b8d |
fix(ci): stop the i18n formatting ping-pong between Crowdin pull and push workflows (#24590)
## Symptom Since Aug 20, the i18n workflows open formatting-only translation PRs in a loop, even with no new translations. On main this shows up as pairs of exactly inverse commits minutes apart, e.g. #24586 (780 insertions / 1534 deletions) then #24587 (1534 / 780), same for #24571/#24572, #24575/#24576, #24579/#24580, and for the app #24577/#24578. App i18n PR volume jumped from 1-6/day to 14 (Aug 20) and 25 (Aug 21). ## The mismatch itself Crowdin's PO exporter and lingui's PO writer disagree on exactly one thing: multiline strings. Crowdin exports gettext-style `msgid ""` + quoted continuation lines; lingui writes the first line inline (`msgid "first line\n"`). Single-line strings are byte-identical in both. So a download flips every multiline entry, the resulting merge triggers the push workflow, its `lingui extract` flips them back, and the two workflows ping-pong forever. ## Why now: full timeline The mismatch is old and latent; it needed a Crowdin download to actually run against catalogs containing multiline strings, and scheduled downloads had not been running for about a year: - **Feb 2025** — the original i18n CI gated the scheduled Crowdin download on `lingui compile --strict` failing: "only download when translations are missing". Sound at the time, since `--strict` genuinely failed on missing translations. - **2025** — English became the fallback locale in the lingui configs. With a fallback, `--strict` treats missing translations as found and always succeeds, so the gate never opened and scheduled downloads silently stopped. - **Aug 28, 2025 (#14128)** — the symptom was diagnosed ("--strict does not fail since we've set english as a fallback locale... temporary hotfix"), but the hotfix un-gated only the *compile* step; the *download* step kept the dead condition. Scheduled downloads stayed dead; translations could only land via manual `force_pull` dispatches. - **May 2026 (#20171)** — the website pull workflow was created following the app one, dead gate included, so its scheduled downloads never worked (why "PRODUCT" stayed English on the zh homepage despite `产品` being approved in Crowdin since July 2). - **Meanwhile** — multiline strings accumulated in catalogs that had only ever been written by lingui: the website's competitor pricing pages (#23588, Jul 30 2026) and the app's custom-application markdown descriptions. - **Aug 20, 2026 (#24400)** — the dead gate was removed and downloads became unconditional on schedule. The first scheduled download in a long time brought Crowdin's wrapping of those multiline strings into the repo, and the ping-pong started within hours, in both projects. (Verified: every app i18n PR from Jul 15 to Aug 19 contained no downloaded translations — extract output plus a few encoding-fixer edits — then filled translations and inverse pairs appear from Aug 20.) ## Fix Two complementary changes: **1. Normalize downloads (root cause).** In `website-i18n-pull.yaml` and `i18n-pull.yaml`, run the corresponding `lingui:extract` targets on the downloaded catalogs before the change-detection diff. Extract merges the downloaded translations and rewrites the files in lingui's canonical format, so both sides converge on identical bytes: an unchanged download produces no diff and no PR, while real translation updates still flow through. (Validated in production by the loop itself: #24586 is exactly this command running over Crowdin-formatted catalogs, preserving every msgstr.) **2. Skip push runs for translation-only merges (trigger hygiene).** Exclude the locale catalog directories from the push workflows' `push` triggers (`paths` negation for the website, `paths-ignore` for the app, which previously ran a full install + extract on every merge to main). A translation-only merge can never change extract's input (source code), and its Crowdin upload step is already gated on extract detecting changes, so these runs were pure waste. An alternative considered: a Crowdin-side export transform (Crowdin Enterprise file processors) could rewrap PO exports into lingui's style instead, with zero CI steps. Trade-off: it lives in Crowdin's UI where repo contributors can't see or verify it. If a processor is set up later, change 1 becomes redundant and can be reverted. Docs workflows are untouched: docs translations are whole MDX files copied verbatim, no PO reformatting involved. Notes for after merge: main currently holds the Crowdin-formatted catalogs (from #24587), so the first pull run will produce one final normalization PR per project, then go quiet until there are actual new translations. Worth triggering both pull workflows manually once via workflow_dispatch to confirm. |
||
|
|
741849b20b |
fix(open-api): make request-body examples deterministic (#24591)
## Context Follow-up to #24544. While reading that job's failure output we noticed it listed 93 "changed operations" covering `/duplicates`, `/merge` and every base collection path, which read as a huge unintended breaking change. It was not: none of the 93 were breaking, and the same 93 are reported on **every** run of the breaking-changes job, including runs where nothing changed. Two independent problems, both fixed here. ## 1. The examples were random `generateRandomFieldValue` drew from the shared faker with no seed and used `v4()` for UUID fields, so the OpenAPI document differed on every generation. The breaking-changes job boots a server on the PR branch and one on main, generates a document from each, and diffs them. Since the examples are random, every operation with a request body differs between the two documents. From the #24544 artifact: ``` changedOperations: 93 of which incompatible: 0 changed ops whose ONLY difference is the request-body example: 93 / 93 ``` So the noise never failed anything on its own (`incompatible: false`), which is why it went unnoticed. It only got printed when the job failed for another reason, and there it buried the real signal. Examples now come from a private `Faker` instance seeded per field, so a field's example depends on the field and nothing else: not on generation order, not on when it ran. `faker.date.soon` is anchored to a fixed reference date for the same reason. The instance is private so seeding cannot disturb the shared faker other callers use. The seed derives from the field **name**, not its id, because ids differ between two freshly created workspaces and the two documents have to agree. The snapshot delta shows the second half of this. The same field used to get a different example in `ObjectName` and in `ObjectNameForUpdate` and now gets one value: ```diff "ObjectNameForUpdate": { "fieldCurrency": { - "amountMicros": "253000000", + "amountMicros": "773000000", // same as ObjectName now - "fieldNumber": 692.6302930536448, + "fieldNumber": 352.6893054576472, ``` ## 2. The job's own failure output was unreadable The printer dumped **every** changed operation whether or not it was incompatible, and read `.method` where openapi-diff puts `httpMethod` on changed operations (it is `.method` on `newEndpoints`/`missingEndpoints` only). So all 93 rendered as `?`: ``` Changed operations: - ? /companies - ? /companies/duplicates - ? /companies/merge ... 90 more ``` Narrowed to `select(.incompatible)` and reading `httpMethod`. Replayed against the real artifact from #24544, the failure summary goes from 93 unattributed lines to the 66 removed endpoints that actually broke the job: ``` Removed endpoints: - PATCH /restore/attachments/{id} - PATCH /restore/attachments ... 64 more ``` The non-breaking-changes log line still counts all changed operations, since "N operations differ, none breaking" is the useful number there. ## Test plan - New `generate-random-field-value.util.spec.ts`: 13 tests covering stability across 11 field types, independence from generation order, and that distinct fields still get distinct values. - `npx jest src/engine/core-modules/open-api --config=jest.config.mjs` — 4 suites, 26 tests, 4 snapshots passing. - Both rewritten jq programs run against the real `rest-api-diff.json` from #24544: 67 output lines, zero `?` placeholders. - oxfmt and oxlint clean; typecheck introduces no new errors. --- _Generated by [Claude Code](https://claude.ai/code/session_016pz8KcZkNpYoqngfKk5Yzm)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/24591?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
0dce0b5b13 |
Match participants on the transaction that inserted them (#24525)
Sync inserts participants through the transaction scope, but `matchParticipants` took its repository from the global ORM manager. With `IS_ORM_V2_READ_PATH_ENABLED` on, that update runs on a pooled connection that cannot see the just-inserted rows, so it updates nothing and leaves `personId` null. The matched event still fires off the in-memory participants, so the record page Timeline shows the event while the Calendar and Emails tabs show nothing. #24182 dropped the `transactionManager` argument `matchParticipants` used to take and nothing replaced it. This threads the scope back in, optional because the rematch jobs call it outside a transaction. Test run with `TEST_FORCED_FEATURE_FLAGS=IS_ORM_V2_READ_PATH_ENABLED`, on the new spec alone. <details> <summary>Before the fix — 2 failed</summary> ``` FAIL test/integration/google/messaging/participant-matching-existing-person.integration-spec.ts (7.359 s) Participant matching when the person already exists (integration) ✕ links the imported message participant to the person that already owned the handle (41 ms) ✕ links the imported calendar event participant to the person that already owned the handle (49 ms) ● Participant matching when the person already exists (integration) › links the imported message participant to the person that already owned the handle expect(received).toEqual(expected) // deep equality - Expected - 1 + Received + 1 Array [ - "66442b51-7c80-49b2-88f9-a4af2385179f", + null, ] 117 | KNOWN_SENDER, 118 | ), > 119 | ).toEqual([senderPersonId]); | ^ 120 | }, 60000); 121 | 122 | it('links the imported calendar event participant to the person that already owned the handle', async () => { at Object.<anonymous> (test/integration/google/messaging/participant-matching-existing-person.integration-spec.ts:119:7) ● Participant matching when the person already exists (integration) › links the imported calendar event participant to the person that already owned the handle expect(received).toEqual(expected) // deep equality - Expected - 1 + Received + 1 Array [ - "c08f8d45-1c3c-4ad5-af65-dd3198d645c8", + null, ] 127 | KNOWN_ATTENDEE, 128 | ), > 129 | ).toEqual([attendeePersonId]); | ^ 130 | }, 60000); 131 | }); 132 | at Object.<anonymous> (test/integration/google/messaging/participant-matching-existing-person.integration-spec.ts:129:7) Test Suites: 1 failed, 1 total Tests: 2 failed, 2 total Snapshots: 0 total Time: 7.418 s Ran all test suites matching /test\/integration\/google\/messaging\/participant-matching-existing-person.integration-spec.ts/i. ``` </details> <details> <summary>After the fix — 2 passed</summary> ``` PASS test/integration/google/messaging/participant-matching-existing-person.integration-spec.ts (5.883 s) Participant matching when the person already exists (integration) ✓ links the imported message participant to the person that already owned the handle (50 ms) ✓ links the imported calendar event participant to the person that already owned the handle (36 ms) Test Suites: 1 passed, 1 total Tests: 2 passed, 2 total Snapshots: 0 total Time: 6.111 s, estimated 8 s Ran all test suites matching /test\/integration\/google\/messaging\/participant-matching-existing-person.integration-spec.ts/i. ``` </details> <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/24525?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
9ec11bd7aa |
fix: repair escaped inline-code tags in Crowdin docs translations (#24397)
## Why Crowdin's `mdx_v2` parser HTML-escapes tags inside inline code (`\`<path>\`` → `\`<path>\``). `exclude_code_blocks` only protects fenced blocks, so one escaped span fails the **entire language build** and every other translated page in that language goes stale. This PR adds a repair for that, and puts it in a small rule-based normalizer instead of a third one-off script. The existing escaped-unicode fixer (`fix-crowdin-translations.ts`, used by the app pull) is folded into the same engine so the next corruption class is a ~4-line rule, not another script plus workflow step. ## What landed **New — docs inline-code repair** - Rule `escaped-inline-code-tags`: detect/unescape `<`/`>` (including hex/numeric entities) **only inside backtick spans**, so a literal `<` in prose is left alone. - Wired into `docs-i18n-pull.yaml` after pre-translate and before download (`--project=2 --rules=escaped-inline-code-tags`). Non-blocking. **Also — consolidate the existing unicode fixer** - Rule `escaped-unicode` replaces `fix-crowdin-translations.ts` (deleted). - App `i18n-pull.yaml` now calls the shared CLI (`--project=1 --rules=escaped-unicode`). **Shared engine (`packages/twenty-utils/crowdin-normalizer/`)** - One export per file: rules, Crowdin client helpers, CLI orchestrator. - Reads the **exported** translation (not stale suggestions). - Add-before-delete, so a failed re-add cannot leave a string empty; partial failures exit non-zero. - `--project` and `--rules` are required. Dry-run unless `--apply`. - `nx test twenty-utils` plus `ci-utils-test.yaml` on `pull_request` (`contents: read`), kept separate from `ci-utils.yaml` (`pull_request_target`). ## Testing - Unit tests pin: unescape only inside inline-code spans, prose entities untouched, unicode restoration, idempotent rules, concurrency helper. - Controlled injection on docs (project 2): injected `<...>` → dry-run found 1 → `--apply` restored the original → follow-up scan 0. - Live unicode dry-run on app (project 1): flagged real cases such as `S\u00f6k` → `Sök`. |
||
|
|
f415173ada |
fix: always pull app and website translations on schedule (#24400)
## Problem The Chinese homepage still shows English **PRODUCT** even though Crowdin has had `产品` (approved) since July 2. Same for ~99 other website strings, empty in every locale. Root cause: `lingui compile --strict` used to fail on empty `msgstr`, and that failure gated the scheduled Crowdin download. After English was set as `fallbackLocales.default`, `--strict` succeeds with missing translations, so the pull is skipped every 2 hours. Latest website run (`32316813115`) and app run (`32315907131`): compile `--strict` success → **Pull from Crowdin skipped**. Last merged website i18n PR was July 27. The website Crowdin project is already at 100% translated; the strings just never reach the repo. ## Fix Always download on `schedule` and `workflow_dispatch`. `workflow_call` still requires `force_pull` (pre-existing; nothing in this repo calls these workflows). If Crowdin has nothing new, the existing `git diff` no-ops and no PR is opened. Dropped the dead `--strict` compile that no longer gates anything. ## Out of scope Pre-translating leftover untranslated strings (TM/MT/AI) is a separate decision. It would fill real gaps in the **app** project (95–99%), but machine output goes straight into the repo and automerge, and `--translate-untranslated-only` makes it hard to undo. The website is already 100% in Crowdin, so that sweep would not have fixed Product. Docs already does this; app/website should get their own explicit yes. ## Test plan - [ ] After merge, run **Pull website translations from Crowdin** (or wait for the next schedule). - [ ] Confirm `packages/twenty-website/src/locales/zh.po` has `msgid "Product"` → `msgstr "产品"`. |
||
|
|
8d4a11e498 |
fix(server): timeline calendar events denied on the ORM v2 read path (#24403)
`getTimelineCalendarEventsFromObjectRecord` returns `PERMISSION_DENIED`
for every user, admins included, on any record that has calendar events,
in workspaces where `IS_ORM_V2_READ_PATH_ENABLED` is on.
```json
{
"message": "Entity performing the request does not have permission",
"extensions": { "subCode": "PERMISSION_DENIED", "code": "FORBIDDEN" }
}
```
## Cause
`TimelineCalendarEventService` runs under a system auth context but
built its calendar event repository without a `RolePermissionConfig`:
```ts
getRepository<CalendarEventWorkspaceEntity>(workspaceId, 'calendarEvent')
```
With no config the repository gets `objectRecordsPermissions = {}`
**and** `shouldBypassPermissionChecks = false`. That is not "the
caller's permissions" — a system auth context resolves no role at all,
so there is nothing to check against and every non-system object is
denied. `calendarEvent` and `calendarEventParticipant` are unaffected
because they are `isSystem: true` and get an early return in
`validateOperationIsPermittedOrThrow`. The participant relations are
not: `person` is a regular object and `workspaceMember` is explicitly
excluded from the system bypass.
On the v1 read path this never surfaced. Relation joins added by `find({
relations })` are emitted as bare alias selects, which the join check in
`permissions.utils.ts` cannot match, so those relations were never
validated. On the v2 read path each relation is loaded through a
separate `find()` on the target repository, inheriting the same empty
permissions, and that select is validated.
Only records that actually have calendar events are affected; empty
timelines return before the relation load.
## Fix
Pass `{ shouldBypassPermissionChecks: true }` to the calendar event
repository, which is what `resolveRolePermissionConfig` already returns
for a system auth context, and what the `workspaceMember` lookup in this
same function already does. No-op on the v1 path.
A `TODO` records the better long-term shape — running under the caller's
role instead of bypassing — which needs a redaction path first, since a
role that cannot read `person` would otherwise go from a full timeline
to a hard denial.
## Coverage
The ORM v2 CI leg only runs suites named in `ORM_V2_TEST_PATH_PATTERN`,
and `timeline-from-object-record` was not one of them, which is why this
reached production. The suite already seeds a calendar event with a
person participant, so it catches this once it runs; it is added to the
pattern here.
## Verification
Ran `timeline-from-object-record.integration-spec.ts` against a database
reset with `TEST_FORCED_FEATURE_FLAGS=IS_ORM_V2_READ_PATH_ENABLED`:
- before: 3 failed, 5 passed, each failure carrying the exact
`PERMISSION_DENIED` payload above
- after: 8 passed
The messaging half of the timeline is not affected. Its participant
query joins `person` and `workspaceMember` too, and
`getThreadParticipantsByThreadId` runs on every request regardless of
the GraphQL selection, but those tests pass under the flag both before
and after this change — explicit joins take a different validation path
than find-options relations.
## Follow-ups
twentyhq/core-team-issues#2777 collects what this turned up and
deliberately leaves out of scope: joined aliases going unvalidated on
the v2 path (which the messaging timeline currently depends on),
config-less `getRepository` silently meaning deny-all, and `location` /
`conferenceSolution` / `callRecordings` being returned ungated for
`METADATA`-visibility events on `main` today.
|
||
|
|
d2d3e500b8 |
Make cookie sessions the only web auth path (#24257)
Cookie-based web sessions have been running in production long enough to become the only web login path, ahead of deprecating JWT for the web app. JWT stays for OAuth/apps, and as the fallback credential for clients that predate the session cookie. - Removed `AUTH_COOKIE_SESSIONS_ENABLED` and every branch behind it: `UserSessionCookieService` and `UserSessionService` now always issue and read the session cookie. - Removed `isCookieSessionEnabled` from client config (entity, service, generated schema artifacts) and from the front end: the `isCookieSessionEnabled` atom is gone, `CookieSessionBootEffect` always probes, and `SettingsProfileDevicesSection` no longer skips its query. - Dropped the CI env plumbing and the `ci:auth-cookie-sessions` label, so the server integration suite runs against sessions like production. - Removed the config overrides and the disabled-path cases from the session integration suites and the CSRF middleware unit test. Existing instances with a DB override row for the key keep it in `core.keyValuePair`, where it is simply never read. Generated files were edited by hand since codegen needs a running server; the schema drift check in CI is the verification. |
||
|
|
e713700305 |
Remove the dedicated media-notes example app workflow (#24216)
Follow-up to #24203, removing `.github/workflows/ci-example-app-media-notes.yaml` per the review discussion — the dedicated per-app GitHub Action wasn't wanted. What still covers the recording pipeline per-PR: the renderer polyfill unit tests and the real-Chromium sandbox story (both in the renderer CI), plus the SDK e2e. The app's own Playwright e2e stays in the repo and runs locally per its README — it just no longer has CI wiring. Two notes: - If `ci-example-app-media-notes-status-check` was added to the branch-protection required checks, it should be removed there too (settings, not code) so future PRs don't wait on a check that no longer reports. - The sibling workflows (`ci-example-app-hello-world.yaml`, `ci-example-app-postcard.yaml`) follow the same copy-paste pattern — happy to remove those as well or fold them into one reusable `workflow_call` if you want the same treatment. --- _Generated by [Claude Code](https://claude.ai/code/session_01Dk7hVLhyPwMMV5Zj5KHQQZ)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/24216?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
3ea46b5453 |
App media recording via standard web API polyfills (compare with #24053) (#24203)
## What this is The **low-level alternative** to #24053, built from `main` so both PRs can be compared side by side. Same end-user experience — the media-notes app looks and behaves identically, the same host recording indicator names the recording app — but the recording surface for apps is the **standard web platform**, not a bespoke SDK API. Apps write: ```tsx const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const recorder = new MediaRecorder(stream); recorder.ondataavailable = (event) => chunks.push(event.data); recorder.onstop = async () => { const result = await uploadFile(new Blob(chunks, { type: recorder.mimeType }), { fieldMetadataId }); }; recorder.start(); ``` This is code any web developer already knows, and it's what third-party recording libraries call. ## SDK surface comparison | | #24053 (capability API) | This PR (polyfills) | |---|---|---| | SDK functions | `startAudioRecording`, `startVideoRecording`, `stopRecording`, `cancelRecording` | `uploadFile` only | | SDK types | 7 bespoke types | 5 types around `uploadFile` | | Recording semantics | defined by us, forever | defined by the web platform | | Third-party recorder libs | can't work | work if they stick to getUserMedia + MediaRecorder | | Pause/resume, timeslice streaming, `requestData`, track mute | not exposed | standard behavior | | Upload coupling | stop = upload to a FILES field | app decides what to do with the Blob | ## How it works - **Worker (sandbox)**: `polyfills/media/` installs `navigator.mediaDevices.getUserMedia`, `MediaStream`, `MediaStreamTrack`, and `MediaRecorder` classes backed by a `workerMediaBridge` (same pattern as the geometry store). `MediaRecorder.isTypeSupported` is synchronous, so it answers from a capability snapshot the host ships in the render context. - **Host**: a `FrontComponentMediaSessionHost` (renderer package) owns the real `getUserMedia` streams and `MediaRecorder` objects. Capture RPCs travel like `hostFetch`/geometry — renderer-owned thread functions, not the app-facing API map. Recorded chunks come back as `Blob`s through a new `pushMediaSessionEvents` worker export (Blobs are structured-cloneable). - **twenty-front**: `useFrontComponentMediaSession` adds the host policy — one live capture at a time (a second `getUserMedia` rejects with `NotReadableError`, exactly what a busy device produces natively) — and drives the recording indicator. - **Upload**: the one Twenty-specific piece. `uploadFile(blob, { fieldMetadataId, fileName? })` validates the target is a FILES field (anything else would strand the file as an unattachable orphan), sanitizes the file name, and returns `{ fileId, path, url, size, mimeType }`. ## Same safeguards as #24053 - **Non-spoofable host indicator** naming the recording application whenever a device is live, with a stop button. - The indicator's stop (or a revoked device) reaches the app as **standard track `ended` events** — this is actually *stronger* than #24053, where the app only discovered a host stop on its next call. The e2e test asserts the app auto-cancels with no further interaction. - Renderer unmount tears down all sessions; nothing can keep capturing after the component is gone. - FILES-field validation before any upload. ## Known limits (documented) - `srcObject` live preview inside the app can't work (a MediaStream can't cross the sandbox); the host indicator shows the self-view, same as #24053. - getUserMedia constraint details (deviceId, resolution) are accepted but not forwarded; `enumerateDevices` not provided. - `isTypeSupported` answers for a candidate list of common container/codec combos. ## Verification - **Real-browser proof**: a renderer sandbox story runs the full loop in actual Chromium — worker `getUserMedia` → `MediaRecorder` → host RPCs → Blob pushed back through the real bridge → standard `dataavailable`/`stop` events → rendered result. It already paid for itself: it caught that `crypto.randomUUID` doesn't exist in the opaque-origin sandbox realm (not a secure context), which unit tests can't detect. - 15 new renderer unit tests (bridge routing, polyfill class semantics incl. native details like "self-initiated `track.stop()` fires no `ended` event"). - 5 new `useFrontComponentExecutionContext` tests for `uploadFile` (FILES validation, path-separator stripping, failure mapping); suite 50/50. - media-notes e2e spec ported with identical test-ids; renderer jest 616/616; typecheck + lint green across twenty-sdk, twenty-front-component-renderer, twenty-front, and the app. ## Comparing the two PRs The app diff is the clearest comparison: `media-notes.front-component.tsx` here vs #24053 — same JSX, same test-ids, same states; only the engine differs (standard APIs + `uploadFile` vs the four bespoke SDK functions). --- _Generated by [Claude Code](https://claude.ai/code/session_01Dk7hVLhyPwMMV5Zj5KHQQZ)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/24203?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
655277cc63 |
Point CI dispatches at twentyhq/twenty-factory (#24196)
`twentyhq/ci-privileged` is being renamed to `twentyhq/twenty-factory`. Two things here resolve the repo by name and do not follow GitHub's rename redirect: - `actions/create-github-app-token` validates the `repositories:` input against the App installation, so the token mint returns 422 under the stale name - `gh workflow run --repo` POSTs to the repo API, where relying on a 301 redirect is not safe 8 mint steps and 8 dispatch targets across 7 workflows, plus comments in an 8th. **Merge after the rename, not before.** Until `twentyhq/twenty-factory` exists this branch mints against a repo that is not there. In the window between the rename and this merge, PR comments, AI review, preview envs, website previews, prod-parity e2e and visual regression dispatches all fail at the mint step, so this wants to go in immediately after the rename. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/24196?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
bbf584a5f2 |
feat(server): route create, upsert and merge through the ORM v2 write path (#24145)
Stacked on #24140 (update through v2). Completes the ORM v2 write path: create, upsert, merge, transactions, and relation connect/disconnect resolution. v1 stays in place; this does not retire it. Behind `IS_ORM_V2_READ_PATH_ENABLED` (the same flag). Flag off, nothing changes. ## Scope - **Create**: the non-upsert insert branch of `createMany` / `createOne` flag-branches to `WorkspaceRepositoryV2.runInsert`: `formatData` flattens each record, a multi-row `INSERT ... VALUES ... RETURNING` (`DEFAULT` for the columns a row omits, so Postgres defaults apply) is generated by `buildInsertStatement`, the insert permission is validated over the inserted columns, and `CREATED` then `UPSERTED` fire from an all-columns re-select of the inserted ids, matching the v1 insert builder. - **Upsert** (`createMany` with `upsert: true`): the read-then-split is unchanged; the insert half routes through `runInsert`, the update half through `runBatchUpdate` (each per-id update its own statement, one batch `UPDATED` + `UPSERTED` over the collected before/after images, matching v1's `updateMany`). Conflict-target derivation is unchanged. - **Transactions**: `WorkspaceDataSourceV2.transaction(work)` checks a client out of the pool, wraps `work` in `BEGIN` / `COMMIT` (rollback + rethrow on error), and hands back repositories bound to that client through a `ClientQueryExecutor`, so every statement and event snapshot inside runs on one connection. - **Merge**: `executeMergeWithinTransactionV2` re-points the losers' foreign keys to the survivor across each related object, hard-deletes the losers, and updates the survivor, each step a `runMutation` on a transaction-scoped repository, so the same `UPDATED` / `DESTROYED` / `UPSERTED` events fire as v1. - **Relation connect/disconnect**: `{connect}` / `{disconnect}` input on create, update and upsert is resolved to plain join-column values before the v2 write by `resolveNestedRelationsForOrmV2` on the base runner: it runs the unchanged v1 `RelationNestedQueries` (its lookup select stays on v1), then drops the relation field-name keys so only columns reach the v2 statement. - **Files fields**: synced through the same v1 `FilesFieldSync` the write query builders use. The repository computes the file diff against the before-image, enriches the record, and re-points the `File` rows after the statement runs. `FilesFieldSync` needs only the object name and `coreDataSource`, both already on the v2 repo's `internalContext`, so no TypeORM entity is built. With files folded in, the write path no longer has any content-based fallback: the old `writeDataIsSupportedByOrmV2` check is gone, so with the flag on **every** create / update / upsert / delete / merge routes to v2 regardless of the field types it touches. - **Merge relation-by-name**: the survivor update now runs `resolveNestedRelationsForOrmV2` before writing, the same relation resolution the other write paths use, and the `isMergeSupportedByOrmV2` gate is removed. Investigating it showed the gate was dead: the merge write path selects columns and join columns (`buildColumnsToSelect` maps relations to join columns) and loads relations by name only on the dry-run branch, so the merged data never carried a relation-by-name key. The resolution is a no-op on real merges and a safety net if that ever changes; the net effect is all merges route to v2. - **To-many join for group-by record ordering**: `WorkspaceSelectQueryBuilderV2.leftJoin` can now render a to-many join (child FK = parent id + soft-delete predicate) when the caller passes `allowToManyJoin`; only group-by "with records" record-ordering opts in, so filter traversal and relation loading still refuse to-many joins with the usual error. This is latent capability: the GraphQL order-by input excludes to-many relations (`generateSimpleRelationFieldOrderByInputType` returns `{}` for `ONE_TO_MANY`), so no API query can order by one today. The join is unit-tested against its exact SQL and activates automatically if that input is ever exposed. ## Stays on v1 (deliberately) - **v1 itself**: the TypeORM repository is still constructed per request. Retiring it is the separate follow-up, not this PR. ## Verification - Ran locally both flag states: the create/update permission suites (many + one), `people-create-many`, `upsert`, `composite-unique-index-upsert`, the field/object/RLS upsert suites, `mutation-relation-filter-rls`, `mutate-by-relation-field`, and `nested-relation-queries` (all 16 connect/disconnect cases across create-one, create-many upsert false/true, update-one, update-many, plus morph connect/disconnect), green flag-on and flag-off. - Exact-SQL unit tests for the insert/mutation statement builders and the new to-many join; full v2 unit suite green. `group-by-with-records-resolver` and `order-by-with-group-by` green flag-on (to-one record ordering unaffected by the join-site change). oxlint / oxfmt / typecheck clean. Write suites ride the flagged CI shard via `object-records-permissions` / `upsert` / `merge-many` / `nested-relation` / `group-by` / `files-field` (added to the pattern so the files-sync path is exercised flag-on in CI, where object storage is configured; the files-field suites can't run in a bare local env and fail there on v1 too). |
||
|
|
21a0439f75 |
Install changed apps on the preview environment, document app previews for external repos (#24143)
## What PRs touching `packages/twenty-apps/**` now get their apps **installed on the PR's preview environment** — the env built from the PR's own server and front — instead of a separate preview against a stock image. > Pivoted mid-review from an earlier design (dedicated stock-image app preview, see the closed twentyhq/ci-privileged#81): in the monorepo, apps and platform capabilities land in the same PR, so previewing an app against *released* Twenty answers the wrong question — an app exercising an SDK capability from the same PR can only work on the PR's build. The stock-image flow remains the right shape for dedicated app repositories, and ships here as a copy-pasteable docs template. ## Changes **`preview-env-dispatch.yaml`** - App roots (`examples`, `internal`, `public`) join the trigger paths, so app-only PRs get a preview env too. - A new step lists changed apps via the **PR files API** (nothing from the PR head is checked out under `pull_request_target`; a `gh api` failure fails loudly, only an empty match is a no-op) and passes them to ci-public as an `app_paths` input. - Concurrency now keys on the PR number: under `pull_request_target`, `github.ref` is the *base* ref, so unrelated PRs shared one group and cancelled each other's dispatches. **ci-public** (companion PR twentyhq/ci-public#5): `preview-env.yaml` installs each app after the env boots, using the PR's own SDK CLI — correct in this env, because the env *is* the PR's platform build. A failing app warns instead of killing the env platform reviewers still need. The env runs the e2e `APP_SECRET` (read from the PR tree at runtime) instead of a random one so the seeded workspace's well-known API key works for the CLI — which weakens nothing, since the env's login is already public knowledge (`SIGN_IN_PREFILLED` with seeded demo credentials on a public tunnel). **Docs**: `developers/extend/apps/operations/preview-deployments` — a standalone, copy-pasteable preview workflow for **dedicated app repositories** (tunnel → `docker run twentycrm/twenty-app-dev` → `yarn twenty` publish/install → PR comment → 5h keepalive), plus a section explaining why the monorepo previews on the PR build instead. Registered in `navigation/base-structure.json` (the docs.json source of truth) with regenerated artifacts. ## Merge order twentyhq/ci-public#5 first, then this PR. Until ci-public#5 lands, the extra `-f app_paths` input would be rejected by the old `preview-env.yaml`, breaking server previews — so please merge in order. twentyhq/ci-privileged#81 is closed (no comment-side change needed; the existing env comment covers it). ## Test - Both workflows YAML-validated; docs artifacts regenerated with the repo's own generator scripts (`docs:generate-navigation-template` / `docs:generate` / `docs:generate-paths`), so the docs-i18n sync bot no-ops. - The seeded API key + e2e `APP_SECRET` pairing is the exact combination this repo's example-app CI and my local runs used all day for `app:publish` / `app:install`. - End-to-end firing needs ci-public#5 on its `main`; after both merge, any member push to an app PR (e.g. #24053) exercises the full path. --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
a96e3b46cb |
ci: serialize the i18n workflows per branch (#24148)
## What Groups the six i18n workflows by `github.ref` instead of `github.run_id`, so runs on a branch queue behind each other instead of running concurrently. ## Why `Extract and upload translations` went red on `main` this morning ([run](https://github.com/twentyhq/twenty/actions/runs/31696154871/job/94434279330)): ``` To https://github.com/twentyhq/twenty ! [rejected] HEAD -> i18n (fetch first) error: failed to push some refs ``` The group is ```yaml group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} ``` and `github.head_ref` is only set for `pull_request` events. On a push to `main` it is empty, so the group falls back to `github.run_id` — unique per run. Every push to `main` starts its own concurrent run, they all check out the same bot branch, and whichever finishes second is rejected as non-fast-forward. Two pushes landed a minute apart (11:34 and 11:35) and the second run failed. Using `github.ref` gives one group per branch. On `main`, `cancel-in-progress` is already false, so a second run queues rather than racing; GitHub keeps only the newest queued run, which is the right outcome here since each run extracts from the current tip. Applied to all six workflows that push a shared bot branch (`i18n`, `i18n-docs`, `i18n-website`) — the push-triggered ones raced today, the scheduled ones carry the same latent bug. ## Note This does not cover a run racing the Crowdin bot's own force-push of `i18n`, or an `i18n-pull` run racing `i18n-push` for the same branch (they have distinct workflow names, so distinct groups). Neither has shown up in the failure log; worth revisiting if it does. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/24148?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
9cb06724b0 |
feat(server): route delete, destroy and restore through the ORM v2 write path (#24131)
Follow-up to the ORM v2 write statement layer (#24118). Routes `deleteMany`, `destroyMany` and `restoreMany` (and their `...One` delegates) through the v2 write path. Behind `IS_ORM_V2_READ_PATH_ENABLED` (the same flag; there is no separate write flag). Flag off, nothing changes. ## Scope Each runner flag-branches through a shared `runFilteredMutation` on the base runner: - **Off**: the existing v1 path (`buildMutationQueryBuilder` + the TypeORM mutation builder). - **On**: `buildMutationQueryBuilderV2` builds the filtered v2 select builder — rewriting a relation-traversal filter into an `id IN (subquery)` predicate with RLS inside the subquery, mirroring the v1 util — then `WorkspaceRepositoryV2.runMutation` owns the choreography the v1 mutation builders own: apply the row-level predicate, validate the write with the matching operation type (`soft-delete` / `restore` / `delete`), snapshot the affected rows before and after through a permission-bypassing all-columns select, run the statement, and emit the `DELETED` / `RESTORED` / `DESTROYED` batch event through the shared `formatTwentyOrmEventToDatabaseBatchEvent`. `getWriteRepository` on the base runner pins the primary (writes never hit the replica) and counts a new `orm-v2/write-path-used` metric. Deliberately matching v1: returned records come from `RETURNING` rather than a full re-select (the GraphQL layer reads only the selected fields either way); a mutation never adds the `deletedAt IS NULL` predicate, and the snapshot select is `withDeleted`, so restore reaches soft-deleted rows; hard delete snapshots its before-image with `getOne`, so a `DESTROYED` event carries at most one record — same as the v1 delete builder, not fixed here (that would change flag-off too). Update, insert and upsert stay on v1. ## Verification - Ran locally both ways: the delete/destroy/restore permission suites (many + one), plus `mutate-by-relation-field` and `mutation-relation-filter-rls` (the join-rewrite path), all green with the flag on and off. Broader flag-on sweep of `object-records-permissions`, `soft-deleted-relation` and `merge-many`: 17 suites / 67 tests green. - `mutate-by-relation-field` and `mutation-relation-filter-rls` added to `ORM_V2_TEST_PATH_PATTERN` (the delete/destroy/restore suites already ride `object-records-permissions`). 89 v2 builder unit tests still green. oxlint / oxfmt / typecheck clean. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/24131?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
2c9f808f7d |
ci: unbreak CI E2E Main (same-origin front + fix the specs behind the login gate) (#24115)
## What Unbreaks `CI E2E Main`, which has failed on every push to `main` for weeks. The job now serves the front build from the server instead of a separate static server on port 3001, and the four specs that were failing behind that gate are fixed. ## Why the suite was red **The login setup died on "Unable to Reach Back-end"**, so 8 of 9 tests never ran. Two changes stacked up: - Since the cookie-session migration (#23642) the front sends credentialed requests, and only the exact `SERVER_URL` / `FRONTEND_URL` origins are reflected in `Access-Control-Allow-Origin`. A front on `:3001` talking to an API on `:3000` was already broken for workspace subdomains (`apple.localhost:3001` is not in the allowlist). - #23779 then collapsed the front config to `window._env_?.REACT_APP_SERVER_BASE_URL || window.location.origin`. The job serves the production bundle with `npx serve` and never injects `window._env_`, so the front called `localhost:3001/client-config`, got the SPA fallback HTML back, and rendered the backend-unreachable screen. Serving the front from the server makes every request same-origin, subdomains included, so neither CORS nor the cookie allowlist is involved. It is also how the `twenty` docker image ships the front (`COPY ... /app/packages/twenty-server/dist/front`). ## Changes CI harness: - Copy `packages/twenty-front/build` into `packages/twenty-server/dist/front` before starting the server, and drop the `npx serve` step. - Start the server with `start:ci` (`nest start`) rather than `start` (`rimraf dist && nest start --watch`), which would delete the front we just copied. - Point Playwright's `FRONTEND_BASE_URL` and the server's `FRONTEND_URL` at `http://localhost:3000`. - Health-gate on `/healthz` (the real endpoint; `/health` 404s, and a bare `curl -s` accepted that 404 as ready). Specs behind the gate: - `onboarding`: run it against the workspace-agnostic base domain. The shared fixture sets `baseURL` to the workspace subdomain the login setup landed on, where the server correctly refuses to sign a new user up to an existing workspace. Also allow workspace creation on the e2e server (`IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS=false`, it defaults to admin-only), and make the sync-email and install-apps stages optional: both auto-skip when the instance has no connected-account provider and no vetted marketplace app, which is how the e2e server is configured. - `create-record`: the side panel button is `Expand record` since #23965, not `Open …`. - `create-kanban-view`: drop `isDragDisabled={options.length === 1}` on select-field options. dnd-kit sets `aria-disabled` on the drag activator, which is the whole row here since the row registers no drag handle, so a single option's inputs were announced as disabled to assistive tech and refused by Playwright. A one-item list cannot be reordered anyway, so disabling the drag bought nothing. ## Tests Full suite locally against a server serving the front build the same way, all 9 green: ``` ✅ setup › login.setup.ts › Login test ✅ authentication/onboarding.spec.ts › New workspace signup goes through every onboarding stage ✅ authentication/return-to-path.spec.ts › should redirect to deep link after login ✅ authentication/return-to-path.spec.ts › should preserve path with query params across login ✅ authentication/signup_invite_email.spec.ts › Sign up with invite link via email ✅ create-kanban-view.spec.ts › Create Industry Select Field ✅ create-kanban-view.spec.ts › Create Kanban View from Industry Select Field ✅ create-record.spec.ts › Create and update record ✅ workflow-creation.spec.ts › Create workflow ``` The first CI run of this branch (harness fix only) went from 0 passed / 1 failed / 8 not run to 5 passed, with exactly the three specs above failing; this push fixes those. |
||
|
|
371c016b54 |
feat(server): load mutation nested relations through the ORM v2 read path (#24113)
Follow-up to #24101 (nested relation loading on the ORM v2 read path). That PR routed the *read* runners' relation loading through v2 but left the mutation runners behind: `createMany`, `updateMany`, `deleteMany`, `destroyMany`, `restoreMany` and `mergeMany` all call `processNestedRelations` without passing the read-path options, so their post-write relation load stayed on v1 even with the flag on. Behind `IS_ORM_V2_READ_PATH_ENABLED`. Flag off, nothing changes. ## Scope Spreads `getNestedRelationsReadPathOptions(queryRunnerContext)` into the seven `processNestedRelations` call sites in the six mutation runners. `useReplica` follows `isReadOnly`, which is `false` for every mutation runner, so these relation loads read from the primary — correct, since they run after the write. `createMany` loads relations from a private helper that only receives slices of the context, so it takes the resolved options as a parameter rather than the whole context, matching what the group-by v2 service does. The writes themselves stay on v1. This PR only moves the relation reads that already happen after them. ## Verification - `object-records-permissions` and `merge-many` added to `ORM_V2_TEST_PATH_PATTERN`. Those are the suites where a mutation response selects relations (`permissions-on-relations`, `fields-permissions/read-permissions`, `companies-merge-many`); the rest of the directory comes along and gives the write permission suites flagged coverage too. - Ran locally both ways: 16 suites / 64 tests green with the flag on and off. oxlint / oxfmt / typecheck clean. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/24113?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
d82b6b192e |
feat(server): route groupBy through the ORM v2 read path (#24092)
Follow-up to the findMany / findOne / findDuplicates migrations. Routes `CommonGroupByQueryRunnerService` through the ORM v2 read path, behind `IS_ORM_V2_READ_PATH_ENABLED`. Flag off, nothing changes. groupBy has two paths, handled differently: **Aggregate group-by** — a transparent `getReadRepository` swap. Adds to the v2 builder: `groupBy` / `addGroupBy`, `getRawMany`, and `GROUP BY` emission. **group-by "with records"** — not a transparent swap. It wraps a builder subquery in a table-less `FROM (subquery)` `JSON_AGG` + `ROW_NUMBER() OVER (PARTITION BY …)` query, which v2's table-shape-based builder cannot represent. So: - `GroupByWithRecordsV2Service` builds the inner subquery with the v2 builder (`select([])` + `addSelect` for record columns / group dimensions / window rank, `andWhere` for the group predicates, `applyRowLevelPermissions`) and composes the outer `JSON_AGG` query as raw SQL, run through a new `repository.executeRaw` (compiles the builder's named params once and executes on the v2 pool). - The runner flag-branches: v2 service when the flag is on, the existing v1 service otherwise. Supporting v2 additions: `setParameter`, `applyRowLevelPermissions`. ## Verification - 72 v2 builder unit tests (new: GROUP BY / addGroupBy, getRawMany). - `group-by` added to `ORM_V2_TEST_PATH_PATTERN`, so the flagged shard runs all group-by suites (aggregate, with-records, both RLS variants, order-by-with-group-by, REST) against v2. - Ran locally both ways: all 7 group-by integration suites green with the flag on (52/52) and off (52/52). oxlint / oxfmt / typecheck clean. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/24092?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
587ade034a |
feat(server): route findDuplicates through the ORM v2 read path (#24088)
Follow-up to #24077 (findMany + findOne on the ORM v2 read path). Routes `findDuplicates` through v2 as well. Unlike the previous two, findDuplicates uses builder surface v2 didn't have, so this PR extends v2 first, then the runner is a pure repository swap (no v1-logic change): - `getCount()` — `SELECT COUNT(1)` reusing the same from / join / where / soft-delete / RLS building, ignoring projection, order and pagination. `COUNT(1)` is exact because v2 only ever joins to-one relations, which never duplicate root rows. - `take()` / `skip()` — aliases of `limit()` / `offset()` (v1's take/skip only diverge when a join can duplicate rows; v2 refuses to-many joins). - object-literal `where({ id: In([...]) })` — only `in` and equality are supported; keys are validated against the table shape (no injection via keys) and values bind as parameters. Anything else throws. Behind `IS_ORM_V2_READ_PATH_ENABLED`. Flag off, nothing changes. ## Verification - 32 v2 builder unit tests, incl. new ones asserting the COUNT SQL, take/skip -> LIMIT/OFFSET, and the object-where IN / equality / rejection paths. - `find-duplicates` added to `ORM_V2_TEST_PATH_PATTERN`, so the flagged shard runs `rest-api-core-find-duplicates` (data + ids paths, depth-1 relations, error cases) against the v2 SQL generator. - Ran locally both ways: find-duplicates suite green with the flag on and off. oxlint / oxfmt / typecheck clean. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/24088?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
bcdac3e824 |
feat(server): route findOne through the ORM v2 read path (#24077)
Follow-up to #23991. Routes the single-record read path through the ORM v2 read repository, exactly as findMany was routed there. Behind `IS_ORM_V2_READ_PATH_ENABLED`. Flag off, nothing changes. `CommonFindOneQueryRunnerService` now selects the read repository from the flag and builds its query off it; everything else (filter, soft-delete, `setFindOptions({ select })`, `getOne`, nested-relation loading on v1) is untouched. The v2 builder already covers `getOne`, `leftJoin` on to-one relations and the soft-delete default, so no v2 changes were needed. ## Scope - `CommonFindManyQueryRunnerService` and now `CommonFindOneQueryRunnerService` read through v2 under the flag. Every other runner still keeps `repository`. - Relation loading stays on v1, same as findMany. - No guard added for to-many-relation filters: findOne has no LIMIT correctness issue, an always-on guard would reject filters v1 currently serves (so "flag off, nothing changes" would break), and findOne is filtered by id / unique scalars in practice. ## Verification - `ORM_V2_TEST_PATH_PATTERN` gains `find-one`, so the flagged integration shard now runs both findOne suites against the v2 SQL generator. - New GraphQL suite `find-one-record` covers the paths where v2 composes SQL differently from TypeORM: composite reassembly, the to-one join, the soft-delete default filter and `deletedAt`-filter retrieval. The existing REST findOne suite (composite fields, depth-1 relation, 404, malformed uuid) joins the flagged pass too. - Ran locally both ways: new suite and REST findOne green with the flag off (v1) and on (v2). oxlint/oxfmt clean. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/24077?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
ad0c5d7a59 |
feat(server): ORM v2, a TypeORM-free workspace read path behind a flag (#23991)
Adds `src/engine/twenty-orm-v2/`: a second ORM that uses no TypeORM, exposes the same surface as `twenty-orm` so call sites do not change, and generates parameterised SQL from field metadata. Behind `IS_ORM_V2_READ_PATH_ENABLED`. Flag off, nothing changes. Follows the investigation in #23989. ## The idea Workspace entities have no classes — every one is synthesised per request from `objectMetadata` / `fieldMetadata`. So TypeORM's entity-metadata graph describes tables the metadata already describes. v2 drops that graph entirely. `WorkspaceTableShape` is the whole model: schema, table, columns, relations, composite-column mapping. Plain data, derived from the flat maps, no methods, no back-references, no cycles. **No ORM metadata anywhere** — where metadata is needed it is field metadata, as asked. ## Prepared statements The shared filter/order/RLS renderers already emit SQL with TypeORM-flavoured named parameters (`:name`, `:...list`). v2 keeps that dialect, so all three are reused unchanged, and `compileNamedParameters` converts to `$1..$n` at the last moment. It's a scanner rather than a regex, so `::text` casts, `'literals'` and `"identifiers"` containing colons are left alone. Statements then go to `pg` with a `name`, so Postgres parses and plans each one once per connection. Query text is generated from metadata and repeats across requests, which is the shape that benefits. Names are bounded at 1000 distinct SQL shapes; past the cap statements are sent unnamed and lose only the plan reuse. No new dependency: `pg` is already direct. ## Deliberately different from v1 **Permissions no longer parse SQL.** v1 recovers entity, operation and column list from `QueryExpressionMap`, including regex-matching `"alias"."column"` out of select strings, because the check runs downstream of a builder it didn't drive. v2 built the query, so it reports the columns it read per alias and the existing `validateOperationIsPermittedOrThrow` is called with them. **`take()` is a plain LIMIT.** v1's `take` triggers the two-phase `distinctAlias` query as soon as any join is present, stripping ORDER BY and LIMIT from the inner scan. Only a to-many join can duplicate root rows, and v2 refuses to join to-many at all, so LIMIT is correct. ## Deliberately identical - `getMany()` returns records with composites reassembled, via the same `formatResult`. - Soft delete is `deletedAt IS NULL` unless `withDeleted()`. A nested bracket group does not carry its own copy — a test caught that inheriting it changes OR-group semantics. - `orderBy` ignores `castToText`/`useLower` on the builder path, matching v1 exactly. - RLS predicates use the same resolver and renderer, rendered into the join `ON` clause for joined aliases so a LEFT JOIN does not become an inner join. ## Scope Wired into `CommonFindManyQueryRunnerService` only; every other runner keeps `repository`. Covered: `createQueryBuilder`, `clone`, `where`/`andWhere`/`orWhere` (strings and bracket factories), `setParameters`, `setFindOptions({select})`, `select`/`addSelect`, `orderBy`/`addOrderBy`, `groupBy`/`addGroupBy`, `leftJoin` to-one, `withDeleted`, `take`/`skip`/`limit`/`offset`, `getMany`/`getOne`/`getRawMany`/`getRawOne`/`getCount`, `getQuery`/`getQueryAndParameters`. Not covered, and stated plainly in the folder README: writes (still v1, and that is where `EntityMetadata` is genuinely used, so this shrinks what the metadata cache is for rather than deleting it), relation loading, the `find*` surface used by `src/modules`, transactions, DDL, group-by aggregates. One cast remains where the v2 builder is handed to the shared parsers, since they are typed against the TypeORM class rather than an interface — giving them a structural type removes it and is the next cleanup. ## Verification Unit: 32 new tests across 3 suites. Builder tests assert exact SQL text, since the emitted statement is this layer's contract. `npx jest src/engine/twenty-orm src/engine/api/common` green; `tsc --noEmit`, oxlint and oxfmt clean. Integration: the CI matrix gained an `orm-v2` dimension, so `server-integration-test` runs a second pass with `TEST_FORCED_FEATURE_FLAGS=IS_ORM_V2_READ_PATH_ENABLED`. Since findMany is the only runner on v2 today, that pass is scoped to the findMany surface — record reads plus the paths where v2 composes SQL differently from TypeORM: relation ordering and filtering, the per-parent limit, row-level predicates on joins, soft-deleted relations, composites. It is green on this head. That flagged pass earned its keep immediately: on its first run it caught three real defects, all fixed here — the shared filter parser reads `objectRecordsPermissions` off the query builder rather than the repository; custom object tables were derived from a property that does not exist on `FlatObjectMetadata`, so `pet` never became `_pet`; and a typo in `TEST_FORCED_FEATURE_FLAGS` was swallowed by the seeding command, which `globalSetup` now asserts against so a mistyped flag cannot leave the flagged pass green while testing the unflagged path. |
||
|
|
abe3b2d570 |
[BREAKING CHANGE] refactor(server): migrate the metadata API off nestjs-query (#23925)
## Context
Follow-up to the ongoing nestjs-query removal. The last two modules on
it were object-metadata and field-metadata, which relied on the
auto-generated cursor resolvers (`objects`, `fields` and their
connection subfields). They had to move together: the generated types
they share (`FieldEdge`, `FieldFilter`, `CursorPaging`, `PageInfo`,
`ConnectionCursor`, comparison inputs) would otherwise be defined twice
in the metadata schema.
As discussed, the cursor pagination in the common API is coupled to the
workspace flat-metadata maps, so only its generic primitives are reused
(`decodeCursor`, `encodeCursorData`, `getPaginationInfo`); core metadata
entities get a thin layer on top in `metadata-modules/pagination/`.
## What this does
**Hand-written replacements for the auto-generated resolvers**, on the
existing resolver classes, with the same guards (`WorkspaceAuthGuard` +
workspace scoping) and the same defaults (page size 10, max 1000, sort
disabled with fixed `id DESC`):
- `objects(paging, filter)`, `object(id)`, `Object.fields`,
`Object.indexMetadatas` on `ObjectMetadataResolver`
- `fields(paging, filter)`, `field(id)`, `Field.object` (via the
existing dataloader) on `FieldMetadataResolver`
**Shared pagination layer** (`metadata-modules/pagination/`):
- `findManyWithCursorPagination` — keyset pagination on `id` over a
TypeORM query builder
- `applyMetadataFilterToQueryBuilder` — translates the nestjs-query
filter shape (`and`/`or` + `eq`/`neq`/`in`/`like`/`is`/…) into
query-builder conditions
- Schema types matching the previously generated ones name-for-name:
`ConnectionCursor` scalar, `PageInfo`, `CursorPaging`,
`UUIDFilterComparison`, `BooleanFieldComparison`, plus per-entity
filter/edge/connection types
**Cleanup:**
- `ObjectMetadataService` / `FieldMetadataService` no longer extend
`TypeOrmQueryService`; the one consumer of the inherited API
(`field-metadata-tools.factory`) uses a new plain
`findManyWithinWorkspace` finder
- Leftover nestjs-query decorators stripped from the index-metadata,
index-field-metadata, search-field-metadata and logic-function DTOs
(they produced nothing in the schema)
- `PageInfoType` in the workspace schema builder uses a local
`ConnectionCursor` scalar
- `@ptc-org/nestjs-query-{core,graphql,typeorm}` removed from
twenty-server, along with both yarn patches, the root uuid resolution
and the dependabot ignore entry
## Schema compatibility
Verified byte-for-byte: with the migrated server running,
`graphql:generate --configuration=metadata` produced a **zero diff** on
`generated-metadata/graphql.ts`, and validated every frontend document
against the new schema.
One behavioral note: cursor values changed format (offset-style before,
keyset on `id` now). Cursors are opaque and short-lived, but a client
holding a pre-deploy cursor gets an "Invalid cursor" error on its next
page fetch after deploy.
## Testing
- All 68 object/field-metadata integration suites pass (500 tests, 263
snapshots), plus the REST metadata suites and the fields-permissions
suites that paginate `fields` (93 tests)
- Typecheck and lint clean
---
_Generated by [Claude
Code](https://claude.ai/code/session_01MoxpdyTsjqeG6VX73HPdZk)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23925?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
---------
Co-authored-by: Félix Malfait <felix@twenty.com>
|
||
|
|
d4c3759c70 |
ci(pr-review): stop a skipped label dispatch from cancelling the open dispatch (#23856)
## Problem The standard review silently does not run on PRs that get labelled by a bot right after opening. https://github.com/twentyhq/twenty/pull/23854 is an example: no `PR Review #23854` run exists in `ci-privileged` at all. | time | what | |---|---| | 10:08:35 | PR opened | | 10:08:39 | `twenty-eng-sync[bot]` adds the `-PR: draft` label | | 10:08:40 | dispatch run for `opened` starts, cancelled during "Set up job" | | 10:08:43 | dispatch run for `labeled` is skipped by the job `if` | Concurrency is evaluated before the job-level `if`, so the `labeled` run preempts and cancels the in-flight `opened` run and is then skipped itself (`-PR: draft` does not start with `pr-review-`). The sync bot labels within ~4 seconds of open, which is faster than the app-token mint step, so the `opened` dispatch loses this race essentially every time that label is applied. `opened` is the only event that resolves to the `standard` check, so with no later push the PR gets no review at all. Same class of gap as the one #23708 closed, moved down a layer: the trigger exists now but gets cancelled. ## Fix Scope the concurrency group by event action, and only cancel in-progress runs for `synchronize`. Rapid consecutive pushes still de-duplicate; `opened`, `ready_for_review` and `labeled` no longer cancel each other. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23856?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
997b2c38de |
Add cookie-session integration test suite (#23715)
Stacked on #23642. Integration suite for the cookie-session surface, organized as one successful/failing spec pair per stage of the session lifecycle. 14 spec files, ~36 tests, all over real HTTP against the booted app. ## Coverage by stage **1. Session creation on auth exchanges** (`successful-`/`failing-session-creation`) Flag gating (default off: tokens, no cookie, no row); httpOnly cookie snapshot with 180d expiry window; SHA-256 hash-at-rest with the row bound to the apple seed workspace; scripted sign-ins without an Origin header still get the cookie; login-CSRF refuses the cookie for disallowed origins while returning the token pair; sign-in over an existing session revokes it as `SUPERSEDED`; a failed credentials exchange mints nothing. **2. Cookie delivery** (`successful-session-cookie-delivery`, `secure-deployment-session-cookie`) The runtime side door (`AUTH_COOKIE_SAME_SITE=none` forces the secure path) pins the `__Host-`/`Secure`/`SameSite=None` variant in the default CI run. The exact production combination (`__Host-`, `Secure`, `SameSite=Lax`) is covered by a dedicated spec that requires the app to boot with an https `SERVER_URL`: the secure branch is decided by config, never the transport, so no TLS is needed. It skips itself on plain-http boots; CI runs it as an extra step on one shard with `SERVER_URL=https://localhost:3000`, including the `__Host-` round-trip and the plain-cookie-name downgrade refusal. **3. Per-request authentication and the CSRF read gate** (`successful-`/`failing-session-cookie-authentication`) A cookie-only request resolves the seeded user; a `sess_` token presented as Bearer is rejected; cookie-authenticated unsafe requests with a disallowed or missing Origin get 403 `CSRF_ORIGIN_MISMATCH`; an unknown session token is unauthenticated and its dead cookie is cleared. **3b. Workspace binding** (`successful-session-workspace-binding`) Tim signs into both seeded workspaces (apple and yc); each session row is bound to the workspace its exchange selected (`workspaceId` and `userWorkspaceId` pinned to the seed ids), and each cookie resolves to its own workspace context, with no request-side input able to pivot a session across workspaces. **3c. Credentialed CORS** (`cors-credentialed-origins`) Allowlisted origins get the reflected `Access-Control-Allow-Origin` plus `Access-Control-Allow-Credentials: true` and `Vary: Origin`, preflight included; other origins keep the public wildcard. See tooling notes: this surface was previously untestable. **4. Sessions API** (`successful-`/`failing-user-sessions-api`) `currentUserSessions` marks exactly the presented session as current; `revokeUserSession` revokes by id (`USER_REVOKED`) and drops it from the listing; `revokeAllOtherUserSessions` spares the presented session; cross-user revocation and unauthenticated listing are refused. **5. Exits** (`successful-sign-out`, `failing-session-expiration`) `signOut` revokes with `USER_SIGN_OUT`, clears the cookie, and reuse fails immediately (cache invalidated, not TTL-bound); a cookie-less sign-out clears nothing, so a cross-site POST cannot log a visitor out; absolute-lifetime and idle-timeout expiry both reject and clear the cookie. **7. Cleanup cron** (`user-session-cleanup-cron`) Both halves run in-process against fixtures spanning the 30d retention boundary. Sessions: expired/revoked-beyond-retention deleted; active, recently-expired, and idle-expired rows survive (the idle case pins the known predicate gap). Refresh tokens: old-expired and old-revoked deleted, fresh kept, and a long-expired token of another type survives, pinning the `type` filter that keeps the shared `appToken` table safe from the hard-delete. Not covered here by design: the impersonation park/restore sub-funnel (stage 6, follow-up) and the client-side funnel (stage 8, front-end scope). Password-change revocation and the renewal bridge are also left to follow-ups. ## How the flag is flipped `AUTH_COOKIE_SESSIONS_ENABLED` (and `AUTH_COOKIE_SAME_SITE` for the secure side door) are toggled at runtime through the admin panel config API, reusing the `twenty-config` test utils: `DatabaseConfigDriver.set` updates its cache synchronously and `TwentyConfigService` consults the DB driver before the env driver. No `.env.test` change, no app reboot, runs in the default CI environment without the `ci:auth-cookie-sessions` label. `SERVER_URL` is env-only, hence the dedicated CI step for the production secure-deployment spec. ## Shared tooling changes - **`applyCredentialedCors` extraction (src change)**: the integration harness booted with Nest's wildcard `cors: true`, not the credentialed-allowlist setup living in `main.ts`, so the CORS surface was untestable by construction. The setup moved into `applyCredentialedCors`, now called by both the production bootstrap and `createApp`, making the harness's CORS behavior the deployed one. Behavior-neutral for production. - `makeMetadataAPIRequest` accepts an explicit `null` token for unauthenticated requests. Passing `undefined` silently fell back to the default admin token (parameter defaults apply to `undefined`), which made supposedly public requests Bearer-authenticated, bypassing both the cookie auth path and the CSRF middleware. Existing call sites are unaffected. - The `GetLoginTokenFromCredentials` / `GetAuthTokensFromLoginToken` documents moved into shared query factories; the workspace-origin builder is extracted and generalized to any seeded subdomain (`buildWorkspaceOriginForSubdomain`, reused by `getAccessTokenForCredentials`). - Suite-local helpers: `signInWithCookieCapture` (full credentials exchange returning the raw supertest response, with a `workspaceSubdomain` option), `postMetadataOperationWithHeaders` (Origin/Cookie header control), cookie extraction for both cookie names, clearing-cookie detection, snapshot normalization (token and expiry redacted), and shared `ALLOWED_ORIGIN`/`DISALLOWED_ORIGIN` constants derived from `FRONTEND_URL`. Verified locally: full suite green in CI mode on both plain-http and https-`SERVER_URL` boots; oxlint and tsc clean. --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com> |