Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code 3c0627958d fix(workflow): validate recordFilter operands in Find Records action before executing filter conversion
https://sonarly.com/issue/31353?type=bug

When a workflow's Find Records action contains a RELATION-type filter with the operand `EQUAL_TO`, the workflow executor crashes with an unhandled error: `Error: Unknown operand EQUAL_TO for RELATION filter`. The operand `EQUAL_TO` has never existed in the Twenty codebase — it is not present in `ViewFilterOperand`, `ViewFilterOperandDeprecated`, or anywhere in git history. The value was injected into a persisted workflow step's settings, bypassing the frontend UI (which correctly restricts RELATION operands to IS, IS_NOT, IS_EMPTY, IS_NOT_EMPTY). This was possible because the Zod validation schema for Find Records action settings uses `z.array(z.any())` for `recordFilters`, performing zero validation on filter operand values. At execution time, `turnRecordFilterIntoRecordGqlOperationFilter` encounters the unrecognized operand in the RELATION case's switch statement and throws, killing the entire workflow run.

Fix: Changed the RELATION filter's `default` case in `turnRecordFilterIntoRecordGqlOperationFilter` from `throw new Error(...)` to `return;` (returning `undefined`).

This is the exact same fix pattern applied in commit aba5c93a6f for the TEXT filter type. The function's return type is `RecordGqlOperationFilter | undefined`, and all callers in `computeRecordGqlOperationFilter` already filter results with `.filter(isDefined)`. Returning `undefined` gracefully skips the unrecognized operand instead of crashing the workflow execution.

When a workflow's Find Records action has a RELATION filter with an invalid operand like `EQUAL_TO` (which never existed in the codebase and was injected via API/integration), the filter is now silently skipped rather than killing the entire workflow run. The workflow continues executing with the remaining valid filters.

The deeper issue — the permissive `z.array(z.any())` Zod schema in `find-records-action-settings-schema.ts` that allows invalid operands to be persisted — remains as a separate improvement opportunity. This fix addresses the immediate user-facing crash at the execution layer.
2026-04-26 17:04:49 +00:00
Charles BochetandGitHub 499067ae14 fix(logic-function): serialize LocalDriver layer builds with cache lock (#20054)
## Summary

Fixes a race condition in `LocalDriver` where concurrent `execute()`
calls for the same application can trash each other's layer build,
causing logic function executions to fail with either:

- `Error: ENOENT: process.cwd failed with error no such file or
directory, the current working directory was likely removed without
changing the working directory, uv_cwd` (the yarn-install child's `cwd`
got wiped mid-run), or
- `ENOENT: no such file or directory, open '…/<pkg>/<file>.js.map'`
raised from yarn's berry link step (files under the deps layer vanish
while yarn is extracting).

Both are thrown from `…/deps/<checksum>/.yarn/releases/yarn-4.9.2.cjs` —
i.e. the yarn process `copyYarnEngineAndBuildDependencies` spawns with
`cwd: buildDirectory`.

## Root cause

`LocalDriver.createLayerIfNotExist` (and `ensureSdkLayer`) both follow a
check-then-act pattern with no mutual exclusion:

```ts
if (await pathExists(depsNodeModulesPath)) return;
await fs.rm(depsLayerPath, { recursive: true, force: true });
await copyDependenciesInMemory(...);
await copyYarnEngineAndBuildDependencies(depsLayerPath); // spawns yarn with cwd=depsLayerPath
```

The deps layer path is shared across all `execute()` calls that match a
given `yarnLockChecksum`. Two concurrent callers (e.g. a webhook
invocation + a cron-triggered logic function firing while the first
run's layer is still being built) both see no `node_modules`, both
`fs.rm` the directory, and one's yarn child ends up with its `cwd` or
its extraction target gone.

## Fix

Wrap the critical sections with `CacheLockService.withLock` — the same
cache-backed lock the Lambda driver already uses for its layer/executor
builds:

- `createLayerIfNotExist` → lock key
`local-driver-deps-layer:${yarnLockChecksum ?? 'default'}`
- `ensureSdkLayer` → lock key
`local-driver-sdk-layer:${workspaceId}:${applicationUniversalIdentifier}`

A fast-path `pathExists` check is kept outside the lock (so warm
executions still skip lock acquisition entirely), and the existence +
staleness check is **repeated inside the lock** so followers no-op after
the leader finishes.

Lock parameters mirror the Lambda driver's layer-build lock (`ttl=120s`,
`retry=500ms`, `maxRetries=240`).

`cacheLockService` is now passed to `LocalDriver` from
`LogicFunctionDriverFactory` (it was already injected there for the
Lambda driver).
2026-04-26 12:44:40 +00:00
nitinandGitHub ca84d28157 [AI] ai usage line chart date gap filling (#20048)
https://github.com/user-attachments/assets/ecd37dfb-daae-41c3-8316-a9d923463eca
2026-04-26 12:41:16 +00:00
nitinandGitHub d1a4902460 [AI] Drop 'serialization' from tool output naming (#20052)
didnt made sense anymore -- we dont really 'serialize'
2026-04-26 14:10:01 +02:00
Paul RastoinandGitHub 89ad87aa64 Make twenty-front build env agnostic (#20055)
## Introduction
In aim to reduce and optimize the number of twenty-front build we do
during our cd process and allow twenty-front build promotion

### Build time

**Nothing is baked.** The `build/` directory is a clean, env-agnostic
artifact. `index.html` contains the empty placeholder:

```html
<script id="twenty-env-config">
  window._env_ = {
    // This will be overwritten
  };
</script>
```

The JS bundles contain no hardcoded server URL.

---

### Deploy mode 1: Frontend served by the backend (Docker / NestJS)

1. Container starts, NestJS boots in `main.ts`
2. `generateFrontConfig()` runs, reads `process.env.SERVER_URL`
3. Rewrites `dist/front/index.html`, replacing the placeholder with:
   ```html
   <script id="twenty-env-config">
     window._env_ = {
       REACT_APP_SERVER_BASE_URL: "https://api.example.com"
     };
   </script>
   ```
4. NestJS serves the static `dist/front/` directory
5. Browser loads `index.html`, `window._env_` is set before the app JS
executes
6. `src/config/index.ts` reads `window._env_.REACT_APP_SERVER_BASE_URL`
and uses it

---

### Deploy mode 2: Frontend served standalone (CDN / nginx / static
server)

1. Take the `build/` artifact as-is
2. Before serving, run at deploy time:
   ```bash
REACT_APP_SERVER_BASE_URL=https://api.example.com sh
./scripts/inject-runtime-env.sh
   ```
3. This does the same `sed` replacement on `build/index.html`
4. Serve the `build/` directory with your static server of choice
5. Same resolution in the browser:
`window._env_.REACT_APP_SERVER_BASE_URL` is picked up by
`src/config/index.ts`

---

### Fallback: no injection at all

If neither mechanism runs (e.g. local dev with `vite dev`),
`window._env_.REACT_APP_SERVER_BASE_URL` is `undefined`, and
`getDefaultUrl()` kicks in:
- **Localhost**: returns `http://localhost:3000`
- **Non-localhost**: returns same-origin (`window.location.origin`)
2026-04-26 07:05:54 +00:00
Charles BochetandGitHub 0c5c9c844e feat(github-connector): exclude self-reviews from review counts (#20050)
## Summary

- Adds an `isSelfReview` boolean to the consolidated `PullRequestReview`
record (`true` when the reviewer is the same contributor as the PR
author).
- Filters `isSelfReview === true` rows out of the top-reviewers
leaderboard (`top-contributors`) and per-contributor review stats
(`contributor-stats`) so contributors are credited only for reviews on
**other people's** PRs.
- Both the live ingestion path (`fetch-prs`) and the recompute job
(`recompute-pull-request-reviews`) now thread `prAuthorId` to
`buildConsolidatedRow`, so re-running either is sufficient to backfill
existing rows — no extra migration needed.

## Test plan

- [x] `yarn test
src/modules/github/pull-request-review/utils/consolidate-reviews.integration-test.ts`
— 20 tests passing, including new coverage for the `isSelfReview` helper
and `buildConsolidatedRow` payload.
- [ ] After merge: re-run `fetch-prs` (or
`recompute-pull-request-reviews`) once on a target workspace to backfill
`isSelfReview` on existing `PullRequestReview` rows, then verify the
top-reviewers leaderboard no longer credits self-reviews.


Made with [Cursor](https://cursor.com)
2026-04-25 23:34:01 +02:00
Charles BochetandGitHub 41571ea377 feat(front-component-renderer): forward offset/movement coordinates on serialised events (#20046)
## Summary

Adds `offsetX`, `offsetY`, `movementX`, `movementY` to
`SerializedEventData` and the host event serialiser so apps can reason
about element-relative pointer positions without trying to read the host
element's bounding rect (which is impossible from a remote-DOM worker).

## Motivation

I was building a front-component with click-to-drop-pin and trackpad
pan/zoom (custom OSM tile renderer). Two real bugs surfaced from the
current event-serialisation surface:

1. **Wheel pan/zoom was broken.** The host already forwards
`deltaX`/`deltaY`, but app authors naturally read them off the
React-style event handler argument as `e.deltaX`/`e.deltaY`. Because
remote-DOM bridges everything via `RemoteEvent extends
CustomEvent<Detail>`, the payload actually arrives at `e.detail.deltaX`.
Reading the wrong place gives `undefined`, and `undefined < 0 ===
false`, so every wheel notch zoomed in the same direction. App code now
uses `e.detail`, but this was a sharp papercut worth flagging in docs /
a helper (separate change).

2. **Element-local click coords are unobtainable from a worker.** With
only `clientX/Y`, an app needs the stage's bounding rect to translate
viewport coordinates to local — which can't be read across the worker
boundary. `offsetX`/`offsetY` close that gap with a one-read solution.
`movementX`/`movementY` round out the set for any future drag-style
interactions if `mousemove` later joins the allow-list.
2026-04-25 10:12:28 +00:00
570038ad65 chore: sync AI model catalog from models.dev (#20045)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

This PR updates pricing, context windows, and model availability based
on the latest data.
New models meeting inclusion criteria (tool calling, pricing data,
context limits) are added automatically.
Deprecated models are detected based on cost-efficiency within the same
model family.

**Please review before merging** — verify no critical models were
incorrectly deprecated.

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-04-25 08:26:54 +02:00
Charles BochetandGitHub 88add35f0b Bump twenty-sdk, twenty-client-sdk, create-twenty-app to 2.1.0 (#20041)
## Summary

- Bumps `twenty-sdk` from `2.1.0-canary.1` to `2.1.0`.
- Bumps `twenty-client-sdk` from `2.1.0-canary.1` to `2.1.0`.
- Bumps `create-twenty-app` from `2.1.0-canary.1` to `2.1.0`.
2026-04-24 23:10:40 +02:00
Charles BochetandGitHub d74e3fa3b5 chore(server): bump current version to 2.2.0 (#20040)
## Summary

We are releasing Twenty v2.2.0. This PR sets up the
upgrade-version-command machinery for the new release line:

- Promote `2.1.0` into `TWENTY_PREVIOUS_VERSIONS` (it just shipped)
- Set `TWENTY_CURRENT_VERSION` to `2.2.0`
- Reset `TWENTY_NEXT_VERSIONS` to `[]`
- Refresh the `InstanceCommandGenerationService` snapshots to reflect
the new current version (`2.2.0` / `2-2-` slug)
- Update the failing-sequence-runner snapshot to include `2.2.0` in the
covered versions list

The `2-2/` upgrade-version-command module is already in place and wired
into `WorkspaceCommandProviderModule`, so future upgrade commands
targeting `2.2.0` can land directly under `2-2/` (or be generated
against `--version 2.2.0`).
2026-04-24 22:19:16 +02:00
Charles BochetandGitHub aad77b5315 Bump twenty-sdk, twenty-client-sdk, create-twenty-app to 2.1.0-canary.1 (#20038)
## Summary

- Bumps `twenty-sdk` from `2.1.0` to `2.1.0-canary.1`.
- Bumps `twenty-client-sdk` from `2.0.0` to `2.1.0-canary.1`.
- Bumps `create-twenty-app` from `2.0.0` to `2.1.0-canary.1`.
2026-04-24 19:57:02 +00:00
bba102efe3 chore: remove accidentally committed .claude-pr/ directory (#20036)
## Summary

Removes the `.claude-pr/` directory at the repo root, which contains
byte-identical duplicates of `CLAUDE.md` and `.mcp.json`.

## Why

- Added in #19517 (a PR about file-attachment support for agent chat),
unrelated to its content — looks like an accidental commit of a local
scratch directory.
- Not referenced from any workflow, script, or doc in the repository.
`.github/workflows/claude.yml` uses the root `CLAUDE.md` / `.mcp.json`,
not the copies under `.claude-pr/`.
- Because it's a duplicate of the source of truth, it will silently
drift out of sync over time.

## Test plan

- [x] Confirmed no references to `.claude-pr` anywhere in `*.yml`,
`*.yaml`, `*.json`, `*.md`, `*.sh`, `*.ts`, `*.tsx`
- [x] Confirmed `.github/workflows/claude.yml` does not reference it
- [x] `.claude-pr/CLAUDE.md` and `.claude-pr/.mcp.json` are
byte-identical to the root copies at the time of this PR

If this directory is actually needed by an internal tool I missed, feel
free to close — happy to be corrected.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 21:53:16 +02:00
46 changed files with 484 additions and 337 deletions
-22
View File
@@ -1,22 +0,0 @@
{
"mcpServers": {
"postgres": {
"type": "stdio",
"command": "bash",
"args": ["-c", "source packages/twenty-server/.env && npx -y @modelcontextprotocol/server-postgres \"$PG_DATABASE_URL\""],
"env": {}
},
"playwright": {
"type": "stdio",
"command": "npx",
"args": ["@playwright/mcp@latest", "--no-sandbox", "--headless"],
"env": {}
},
"context7": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@upstash/context7-mcp"],
"env": {}
}
}
}
-223
View File
@@ -1,223 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Twenty is an open-source CRM built with modern technologies in a monorepo structure. The codebase is organized as an Nx workspace with multiple packages.
## Key Commands
### Development
```bash
# Start development environment (frontend + backend + worker)
yarn start
# Individual package development
npx nx start twenty-front # Start frontend dev server
npx nx start twenty-server # Start backend server
npx nx run twenty-server:worker # Start background worker
```
### Testing
```bash
# Preferred: run a single test file (fast)
npx jest path/to/test.test.ts --config=packages/PROJECT/jest.config.mjs
# Run all tests for a package
npx nx test twenty-front # Frontend unit tests
npx nx test twenty-server # Backend unit tests
npx nx run twenty-server:test:integration:with-db-reset # Integration tests with DB reset
# To run an indivual test or a pattern of tests, use the following command:
cd packages/{workspace} && npx jest "pattern or filename"
# Storybook
npx nx storybook:build twenty-front
npx nx storybook:test twenty-front
# When testing the UI end to end, click on "Continue with Email" and use the prefilled credentials.
```
### Code Quality
```bash
# Linting (diff with main - fastest, always prefer this)
npx nx lint:diff-with-main twenty-front
npx nx lint:diff-with-main twenty-server
npx nx lint:diff-with-main twenty-front --configuration=fix # Auto-fix
# Linting (full project - slower, use only when needed)
npx nx lint twenty-front
npx nx lint twenty-server
# Type checking
npx nx typecheck twenty-front
npx nx typecheck twenty-server
# Format code
npx nx fmt twenty-front
npx nx fmt twenty-server
```
### Build
```bash
# Build packages (twenty-shared must be built first)
npx nx build twenty-shared
npx nx build twenty-front
npx nx build twenty-server
```
### Database Operations
```bash
# Database management
npx nx database:reset twenty-server # Reset database
npx nx run twenty-server:database:init:prod # Initialize database
npx nx run twenty-server:database:migrate:prod # Run instance commands (fast only)
# Generate an instance command (fast or slow)
npx nx run twenty-server:database:migrate:generate --name <name> --type <fast|slow>
```
### Database Inspection (Postgres MCP)
A read-only Postgres MCP server is configured in `.mcp.json`. Use it to:
- Inspect workspace data, metadata, and object definitions while developing
- Verify migration results (columns, types, constraints) after running migrations
- Explore the multi-tenant schema structure (core, metadata, workspace-specific schemas)
- Debug issues by querying raw data to confirm whether a bug is frontend, backend, or data-level
- Inspect metadata tables to debug GraphQL schema generation issues
This server is read-only — for write operations (reset, migrations, sync), use the CLI commands above.
### GraphQL
```bash
# Generate GraphQL types (run after schema changes)
npx nx run twenty-front:graphql:generate
npx nx run twenty-front:graphql:generate --configuration=metadata
```
## Architecture Overview
### Tech Stack
- **Frontend**: React 18, TypeScript, Jotai (state management), Linaria (styling), Vite
- **Backend**: NestJS, TypeORM, PostgreSQL, Redis, GraphQL (with GraphQL Yoga)
- **Monorepo**: Nx workspace managed with Yarn 4
### Package Structure
```
packages/
├── twenty-front/ # React frontend application
├── twenty-server/ # NestJS backend API
├── twenty-ui/ # Shared UI components library
├── twenty-shared/ # Common types and utilities
├── twenty-emails/ # Email templates with React Email
├── twenty-website/ # Next.js documentation website
├── twenty-zapier/ # Zapier integration
└── twenty-e2e-testing/ # Playwright E2E tests
```
### Key Development Principles
- **Functional components only** (no class components)
- **Named exports only** (no default exports)
- **Types over interfaces** (except when extending third-party interfaces)
- **String literals over enums** (except for GraphQL enums)
- **No 'any' type allowed** — strict TypeScript enforced
- **Event handlers preferred over useEffect** for state updates
- **Props down, events up** — unidirectional data flow
- **Composition over inheritance**
- **No abbreviations** in variable names (`user` not `u`, `fieldMetadata` not `fm`)
### Naming Conventions
- **Variables/functions**: camelCase
- **Constants**: SCREAMING_SNAKE_CASE
- **Types/Classes**: PascalCase (suffix component props with `Props`, e.g. `ButtonProps`)
- **Files/directories**: kebab-case with descriptive suffixes (`.component.tsx`, `.service.ts`, `.entity.ts`, `.dto.ts`, `.module.ts`)
- **TypeScript generics**: descriptive names (`TData` not `T`)
### File Structure
- Components under 300 lines, services under 500 lines
- Components in their own directories with tests and stories
- Use `index.ts` barrel exports for clean imports
- Import order: external libraries first, then internal (`@/`), then relative
### Comments
- Use short-form comments (`//`), not JSDoc blocks
- Explain WHY (business logic), not WHAT
- Do not comment obvious code
- Multi-line comments use multiple `//` lines, not `/** */`
### State Management
- **Jotai** for global state: atoms for primitive state, selectors for derived state, atom families for dynamic collections
- Component-specific state with React hooks (`useState`, `useReducer` for complex logic)
- GraphQL cache managed by Apollo Client
- Use functional state updates: `setState(prev => prev + 1)`
### Backend Architecture
- **NestJS modules** for feature organization
- **TypeORM** for database ORM with PostgreSQL
- **GraphQL** API with code-first approach
- **Redis** for caching and session management
- **BullMQ** for background job processing
### Database & Upgrade Commands
- **PostgreSQL** as primary database
- **Redis** for caching and sessions
- **ClickHouse** for analytics (when enabled)
- When changing entity files, generate an **instance command** (`database:migrate:generate --name <name> --type <fast|slow>`)
- **Fast** instance commands handle schema changes; **slow** ones add a `runDataMigration` step for data backfills
- **Workspace commands** iterate over all active/suspended workspaces for per-workspace upgrades
- Commands use `@RegisteredInstanceCommand` and `@RegisteredWorkspaceCommand` decorators for automatic discovery
- Include both `up` and `down` logic in instance commands
- Never delete or rewrite committed instance command `up`/`down` logic
- See `packages/twenty-server/docs/UPGRADE_COMMANDS.md` for full documentation
### Utility Helpers
Use existing helpers from `twenty-shared` instead of manual type guards:
- `isDefined()`, `isNonEmptyString()`, `isNonEmptyArray()`
## Development Workflow
IMPORTANT: Use Context7 for code generation, setup or configuration steps, or library/API documentation. Automatically use the Context7 MCP tools to resolve library IDs and get library docs without waiting for explicit requests.
### Before Making Changes
1. Always run linting (`lint:diff-with-main`) and type checking after code changes
2. Test changes with relevant test suites (prefer single-file test runs)
3. Ensure instance commands are generated for entity changes (`database:migrate:generate`)
4. Check that GraphQL schema changes are backward compatible
5. Run `graphql:generate` after any GraphQL schema changes
### Code Style Notes
- Use **Linaria** for styling with zero-runtime CSS-in-JS (styled-components pattern)
- Follow **Nx** workspace conventions for imports
- Use **Lingui** for internationalization
- Apply security first, then formatting (sanitize before format)
### Testing Strategy
- **Test behavior, not implementation** — focus on user perspective
- **Test pyramid**: 70% unit, 20% integration, 10% E2E
- Query by user-visible elements (text, roles, labels) over test IDs
- Use `@testing-library/user-event` for realistic interactions
- Descriptive test names: "should [behavior] when [condition]"
- Clear mocks between tests with `jest.clearAllMocks()`
## Dev Environment Setup
All dev environments (Claude Code web, Cursor, local) use one script:
```bash
bash packages/twenty-utils/setup-dev-env.sh
```
This handles everything: starts Postgres + Redis (auto-detects local services vs Docker), creates databases, and copies `.env` files. Idempotent — safe to run multiple times.
- `--docker` — force Docker mode (uses `packages/twenty-docker/docker-compose.dev.yml`)
- `--down` — stop services
- `--reset` — wipe data and restart fresh
- **Skip the setup script** for tasks that only read code — architecture questions, code review, documentation, etc.
**Note:** CI workflows (GitHub Actions) manage services via Actions service containers and run setup steps individually — they don't use this script.
## Important Files
- `nx.json` - Nx workspace configuration with task definitions
- `tsconfig.base.json` - Base TypeScript configuration
- `package.json` - Root package with workspace definitions
- `.cursor/rules/` - Detailed development guidelines and best practices
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "2.0.0",
"version": "2.1.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
@@ -1,6 +1,6 @@
{
"name": "github-connector",
"version": "0.1.0",
"version": "0.2.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -77,6 +77,7 @@ describe('PullRequestReview object', () => {
expect(names).toContain('firstSubmittedAt');
expect(names).toContain('lastSubmittedAt');
expect(names).toContain('eventCount');
expect(names).toContain('isSelfReview');
expect(names).toContain('reviewer');
expect(names).toContain('pullRequest');
expect(names).toContain('reviewEvents');
@@ -298,7 +298,12 @@ const handler = async (
const res = await client.query({
pullRequestReviews: {
__args: {
filter: { reviewerId: { eq: contributorId } },
filter: {
and: [
{ reviewerId: { eq: contributorId } },
{ isSelfReview: { eq: false } },
],
},
orderBy: [{ firstSubmittedAt: 'DescNullsLast' }],
first: PAGE_SIZE,
after: cursor,
@@ -198,6 +198,7 @@ const handler = async (
const res = await client.query({
pullRequestReviews: {
__args: {
filter: { isSelfReview: { eq: false } },
orderBy: [{ firstSubmittedAt: 'DescNullsLast' }],
first: PAGE_SIZE,
after: cursor,
@@ -15,5 +15,6 @@ export async function batchUpsertConsolidatedReviews(
eventCount: true,
reviewerId: true,
pullRequestId: true,
isSelfReview: true,
}) as Promise<PullRequestReviewRow[]>;
}
@@ -23,7 +23,10 @@ type ReviewEventNode = {
reviewerId: string | null;
pullRequestId: string | null;
reviewer: { ghLogin: string | null } | null;
pullRequest: { githubNumber: number | null } | null;
pullRequest: {
githubNumber: number | null;
authorId: string | null;
} | null;
};
type GroupContext = {
@@ -31,6 +34,7 @@ type GroupContext = {
reviewerId: string | null;
prNumber: number | null;
reviewerLogin: string | null;
prAuthorId: string | null;
events: { state: ReviewEventState; submittedAt: string | null }[];
};
@@ -68,7 +72,7 @@ const handler = async (_event: RoutePayload<unknown>) => {
reviewerId: true,
pullRequestId: true,
reviewer: { ghLogin: true },
pullRequest: { githubNumber: true },
pullRequest: { githubNumber: true, authorId: true },
},
},
pageInfo: { hasNextPage: true, endCursor: true },
@@ -95,6 +99,7 @@ const handler = async (_event: RoutePayload<unknown>) => {
reviewerId: node.reviewerId,
prNumber: node.pullRequest?.githubNumber ?? null,
reviewerLogin: node.reviewer?.ghLogin ?? null,
prAuthorId: node.pullRequest?.authorId ?? null,
events: [],
};
groups.set(key, group);
@@ -105,6 +110,9 @@ const handler = async (_event: RoutePayload<unknown>) => {
if (group.prNumber === null && node.pullRequest?.githubNumber != null) {
group.prNumber = node.pullRequest.githubNumber;
}
if (group.prAuthorId === null && node.pullRequest?.authorId) {
group.prAuthorId = node.pullRequest.authorId;
}
group.events.push({
state: node.state,
submittedAt: node.submittedAt,
@@ -123,6 +131,7 @@ const handler = async (_event: RoutePayload<unknown>) => {
prNumber: group.prNumber,
reviewerLogin: group.reviewerLogin,
events: group.events,
prAuthorId: group.prAuthorId,
}),
);
}
@@ -24,6 +24,9 @@ export const REVIEW_EVENT_COUNT_FIELD_UNIVERSAL_IDENTIFIER =
export const PULL_REQUEST_REVIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'b4d61187-1b78-5d6e-9752-79f994ff6d55';
export const REVIEW_IS_SELF_REVIEW_FIELD_UNIVERSAL_IDENTIFIER =
'c1f3e8a2-9b5d-4e7f-8a6c-2d4b6f8e1a93';
enum ReviewState {
APPROVED = 'APPROVED',
CHANGES_REQUESTED = 'CHANGES_REQUESTED',
@@ -116,5 +119,15 @@ export default defineObject({
icon: 'IconHash',
defaultValue: 0,
},
{
universalIdentifier: REVIEW_IS_SELF_REVIEW_FIELD_UNIVERSAL_IDENTIFIER,
name: 'isSelfReview',
type: FieldType.BOOLEAN,
label: 'Self review',
description:
'True when the reviewer is the same contributor as the PR author. Self reviews are excluded from review-count aggregations (top reviewers, contributor stats, etc.) so contributors are credited only for reviews on other people\u2019s PRs.',
icon: 'IconUserCheck',
defaultValue: false,
},
],
});
@@ -8,4 +8,5 @@ export type PullRequestReviewRow = {
eventCount?: number | null;
reviewerId?: string | null;
pullRequestId?: string | null;
isSelfReview?: boolean | null;
};
@@ -12,6 +12,7 @@ export type ConsolidatedReviewUpsertInput = {
eventCount: number;
reviewerId: string | null;
pullRequestId: string;
isSelfReview: boolean;
};
export type BuildConsolidatedRowParams = {
@@ -20,8 +21,23 @@ export type BuildConsolidatedRowParams = {
prNumber: number | null;
reviewerLogin: string | null;
events: ReviewEventForConsolidation[];
/**
* Author of the PR being reviewed. When non-null and equal to `reviewerId`
* the consolidated row is flagged as a self-review so downstream
* aggregations (top reviewers, contributor stats, etc.) can exclude it.
*/
prAuthorId?: string | null;
};
export const isSelfReview = (
reviewerId: string | null,
prAuthorId: string | null | undefined,
): boolean =>
reviewerId !== null &&
prAuthorId !== null &&
prAuthorId !== undefined &&
reviewerId === prAuthorId;
export const buildReviewKey = (
pullRequestId: string,
reviewerId: string | null,
@@ -49,5 +65,6 @@ export const buildConsolidatedRow = (
eventCount: verdict.eventCount,
reviewerId: params.reviewerId,
pullRequestId: params.pullRequestId,
isSelfReview: isSelfReview(params.reviewerId, params.prAuthorId ?? null),
};
};
@@ -8,6 +8,7 @@ import {
buildConsolidatedRow,
buildReviewKey,
buildConsolidatedTitle,
isSelfReview,
} from 'src/modules/github/pull-request-review/utils/build-consolidated-row';
const evt = (
@@ -140,6 +141,71 @@ describe('buildConsolidatedRow', () => {
eventCount: 3,
reviewerId: 'rev-2',
pullRequestId: 'pr-1',
isSelfReview: false,
});
});
it('flags isSelfReview when prAuthorId equals reviewerId', () => {
const row = buildConsolidatedRow({
pullRequestId: 'pr-1',
reviewerId: 'alice',
prNumber: 7,
reviewerLogin: 'alice',
prAuthorId: 'alice',
events: [evt('COMMENTED', '2025-04-01T10:00:00Z')],
});
expect(row.isSelfReview).toBe(true);
});
it('does not flag isSelfReview when prAuthorId differs from reviewerId', () => {
const row = buildConsolidatedRow({
pullRequestId: 'pr-1',
reviewerId: 'alice',
prNumber: 7,
reviewerLogin: 'alice',
prAuthorId: 'bob',
events: [evt('APPROVED', '2025-04-01T10:00:00Z')],
});
expect(row.isSelfReview).toBe(false);
});
it('does not flag isSelfReview when prAuthorId is missing', () => {
const row = buildConsolidatedRow({
pullRequestId: 'pr-1',
reviewerId: 'alice',
prNumber: 7,
reviewerLogin: 'alice',
events: [evt('APPROVED', '2025-04-01T10:00:00Z')],
});
expect(row.isSelfReview).toBe(false);
});
it('does not flag isSelfReview when reviewerId is null (ghost reviewer)', () => {
const row = buildConsolidatedRow({
pullRequestId: 'pr-1',
reviewerId: null,
prNumber: 7,
reviewerLogin: null,
prAuthorId: null,
events: [evt('COMMENTED', '2025-04-01T10:00:00Z')],
});
expect(row.isSelfReview).toBe(false);
});
});
describe('isSelfReview', () => {
it('returns true only when both ids are present and equal', () => {
expect(isSelfReview('a', 'a')).toBe(true);
});
it('returns false when ids differ', () => {
expect(isSelfReview('a', 'b')).toBe(false);
});
it('returns false when either side is null/undefined', () => {
expect(isSelfReview(null, 'a')).toBe(false);
expect(isSelfReview('a', null)).toBe(false);
expect(isSelfReview('a', undefined)).toBe(false);
expect(isSelfReview(null, null)).toBe(false);
});
});
@@ -110,9 +110,16 @@ const handler = async (event: RoutePayload<FetchPrsPayload>) => {
reviewerId: string | null;
prNumber: number;
reviewerLogin: string | null;
prAuthorId: string | null;
events: { state: ReviewEventState; submittedAt: string | null }[];
};
const authorIdByPullRequestId = new Map<string, string | null>();
for (const pr of prData) {
const id = prIdByNumber.get(pr.githubNumber);
if (id) authorIdByPullRequestId.set(id, pr.authorId);
}
let skippedReviews = 0;
const reviewEventData: ReviewEventInput[] = [];
const groups = new Map<GroupKey, GroupContext>();
@@ -141,6 +148,7 @@ const handler = async (event: RoutePayload<FetchPrsPayload>) => {
reviewerId,
prNumber: pr.number,
reviewerLogin: review.author?.login ?? null,
prAuthorId: authorIdByPullRequestId.get(pullRequestId) ?? null,
events: [],
};
groups.set(key, group);
@@ -177,6 +185,7 @@ const handler = async (event: RoutePayload<FetchPrsPayload>) => {
prNumber: group.prNumber,
reviewerLogin: group.reviewerLogin,
events: group.events,
prAuthorId: group.prAuthorId,
}),
);
const consolidatedRecords = await timed(
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-client-sdk",
"version": "2.0.0",
"version": "2.1.0",
"sideEffects": false,
"license": "AGPL-3.0",
"scripts": {
-7
View File
@@ -49,8 +49,6 @@ RUN yarn workspaces focus --production twenty-emails twenty-shared twenty-sdk tw
FROM common-deps AS twenty-front-build
ARG REACT_APP_SERVER_BASE_URL
COPY ./packages/twenty-front /app/packages/twenty-front
COPY ./packages/twenty-front-component-renderer /app/packages/twenty-front-component-renderer
COPY ./packages/twenty-ui /app/packages/twenty-ui
@@ -138,9 +136,6 @@ USER 1000
FROM twenty-server AS twenty
ARG REACT_APP_SERVER_BASE_URL
ENV REACT_APP_SERVER_BASE_URL=$REACT_APP_SERVER_BASE_URL
COPY --chown=1000 --from=twenty-front-build /app/packages/twenty-front/build /app/packages/twenty-server/dist/front
LABEL org.opencontainers.image.description="Twenty image with backend and frontend."
@@ -232,7 +227,6 @@ RUN mkdir -p /data/postgres /data/redis /app/packages/twenty-server/.local-stora
&& chown -R postgres:postgres /data/postgres \
&& chown 1000:1000 /data/redis /app/packages/twenty-server/.local-storage
ARG REACT_APP_SERVER_BASE_URL
ARG APP_VERSION=0.0.0
ENV S6_KEEP_ENV=1
@@ -241,7 +235,6 @@ ENV PG_DATABASE_URL=postgres://twenty:twenty@localhost:5432/default \
REDIS_URL=redis://localhost:6379 \
STORAGE_TYPE=local \
APP_SECRET=twenty-app-dev-secret-not-for-production \
REACT_APP_SERVER_BASE_URL=$REACT_APP_SERVER_BASE_URL \
APP_VERSION=$APP_VERSION \
NODE_ENV=development \
NODE_PORT=2020 \
@@ -10,6 +10,10 @@ export type SerializedEventData = {
pageY?: number;
screenX?: number;
screenY?: number;
offsetX?: number;
offsetY?: number;
movementX?: number;
movementY?: number;
button?: number;
buttons?: number;
key?: string;
@@ -81,6 +81,12 @@ const serializeEvent = (event: unknown): SerializedEventData => {
if ('pageY' in domEvent) serialized.pageY = domEvent.pageY as number;
if ('screenX' in domEvent) serialized.screenX = domEvent.screenX as number;
if ('screenY' in domEvent) serialized.screenY = domEvent.screenY as number;
if ('offsetX' in domEvent) serialized.offsetX = domEvent.offsetX as number;
if ('offsetY' in domEvent) serialized.offsetY = domEvent.offsetY as number;
if ('movementX' in domEvent)
serialized.movementX = domEvent.movementX as number;
if ('movementY' in domEvent)
serialized.movementY = domEvent.movementY as number;
if ('button' in domEvent) serialized.button = domEvent.button as number;
if ('buttons' in domEvent) serialized.buttons = domEvent.buttons as number;
+2 -2
View File
@@ -3,8 +3,8 @@
"private": true,
"type": "module",
"scripts": {
"build": "NODE_ENV=production NODE_OPTIONS=--max-old-space-size=8192 npx vite build && sh ./scripts/inject-runtime-env.sh",
"build:sourcemaps": "NODE_ENV=production VITE_BUILD_SOURCEMAP=true NODE_OPTIONS=--max-old-space-size=8192 npx vite build && sh ./scripts/inject-runtime-env.sh",
"build": "NODE_ENV=production NODE_OPTIONS=--max-old-space-size=8192 npx vite build",
"build:sourcemaps": "NODE_ENV=production VITE_BUILD_SOURCEMAP=true NODE_OPTIONS=--max-old-space-size=8192 npx vite build",
"start:prod": "NODE_ENV=production npx serve -s build",
"tsup": "npx tsup"
},
@@ -1,5 +1,10 @@
#!/bin/sh
if [ -z "$REACT_APP_SERVER_BASE_URL" ]; then
echo "Error: REACT_APP_SERVER_BASE_URL is not set."
exit 1
fi
echo "Injecting runtime environment variables into index.html..."
CONFIG_BLOCK=$(cat << EOF
+1 -3
View File
@@ -18,6 +18,4 @@ const getDefaultUrl = () => {
};
export const REACT_APP_SERVER_BASE_URL =
window._env_?.REACT_APP_SERVER_BASE_URL ||
process.env.REACT_APP_SERVER_BASE_URL ||
getDefaultUrl();
window._env_?.REACT_APP_SERVER_BASE_URL || getDefaultUrl();
@@ -62,7 +62,8 @@ export const SettingsAdminAI = () => {
const billing = useAtomStateValue(billingState);
const isBillingEnabled = billing?.isBillingEnabled ?? false;
const hasEnterpriseAccess =
isBillingEnabled || currentWorkspace?.hasValidEnterpriseKey === true;
isBillingEnabled ||
currentWorkspace?.hasValidEnterpriseValidityToken === true;
const [usagePeriod, setUsagePeriod] = useState<PeriodPreset>('30d');
const periodOptions = getPeriodOptions();
const usageDates = getPeriodDates(usagePeriod);
@@ -7,9 +7,9 @@ import { SettingsEnterpriseFeatureGateCard } from '@/settings/components/Setting
import { UsageBreakdownPieSection } from '@/settings/usage/components/UsageBreakdownPieSection';
import { UsageByUserTableSection } from '@/settings/usage/components/UsageByUserTableSection';
import { UsageDailyChartSection } from '@/settings/usage/components/UsageDailyChartSection';
import { UsageSectionSkeleton } from '@/settings/usage/components/UsageSectionSkeleton';
import { AI_OPERATION_TYPES } from '@/settings/usage/constants/AiOperationTypes';
import { useUsageAnalyticsData } from '@/settings/usage/hooks/useUsageAnalyticsData';
import { UsageSectionSkeleton } from '@/settings/usage/components/UsageSectionSkeleton';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { t } from '@lingui/core/macro';
import { SettingsPath } from 'twenty-shared/types';
@@ -25,7 +25,8 @@ export const SettingsAiUsageTab = () => {
const isClickHouseConfigured = useAtomStateValue(isClickHouseConfiguredState);
const hasEnterpriseAccess =
isBillingEnabled || currentWorkspace?.hasValidEnterpriseKey === true;
isBillingEnabled ||
currentWorkspace?.hasValidEnterpriseValidityToken === true;
const shouldSkipQuery = !hasEnterpriseAccess || !isClickHouseConfigured;
@@ -1,8 +1,10 @@
import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
export const mockedApolloClient = new ApolloClient({
link: new HttpLink({
uri: process.env.REACT_APP_SERVER_BASE_URL + '/metadata',
uri: REACT_APP_SERVER_BASE_URL + '/metadata',
}),
cache: new InMemoryCache(),
});
@@ -1,8 +1,10 @@
import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
export const mockedApolloCoreClient = new ApolloClient({
link: new HttpLink({
uri: process.env.REACT_APP_SERVER_BASE_URL + '/graphql',
uri: REACT_APP_SERVER_BASE_URL + '/graphql',
}),
cache: new InMemoryCache(),
});
-5
View File
@@ -20,7 +20,6 @@ export default defineConfig(({ mode }) => {
const env = loadEnv(mode, __dirname, '');
const {
REACT_APP_SERVER_BASE_URL,
VITE_BUILD_SOURCEMAP,
VITE_HOST,
SSL_CERT_PATH,
@@ -232,11 +231,7 @@ export default defineConfig(({ mode }) => {
envPrefix: 'REACT_APP_',
define: {
_env_: {
REACT_APP_SERVER_BASE_URL,
},
'process.env': {
REACT_APP_SERVER_BASE_URL,
IS_DEBUG_MODE,
IS_DEV_ENV: mode === 'development' ? 'true' : 'false',
},
@@ -26,13 +26,13 @@ export class TestFastInstanceCommand implements FastInstanceCommand {
exports[`InstanceCommandGenerationService should escape backslashes in SQL queries 1`] = `
{
"className": "UpdatePathFastInstanceCommand",
"fileName": "2-1-instance-command-fast-1775000000000-update-path.ts",
"fileName": "2-2-instance-command-fast-1775000000000-update-path.ts",
"fileTemplate": "import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.1.0', 1775000000000)
@RegisteredInstanceCommand('2.2.0', 1775000000000)
export class UpdatePathFastInstanceCommand implements FastInstanceCommand {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('UPDATE "core"."config" SET "value" = E\\'path\\\\\\\\to\\\\\\\\file\\'');
@@ -49,13 +49,13 @@ export class UpdatePathFastInstanceCommand implements FastInstanceCommand {
exports[`InstanceCommandGenerationService should escape single quotes in SQL queries 1`] = `
{
"className": "UpdateConfigFastInstanceCommand",
"fileName": "2-1-instance-command-fast-1775000000000-update-config.ts",
"fileName": "2-2-instance-command-fast-1775000000000-update-config.ts",
"fileTemplate": "import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.1.0', 1775000000000)
@RegisteredInstanceCommand('2.2.0', 1775000000000)
export class UpdateConfigFastInstanceCommand implements FastInstanceCommand {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('UPDATE "core"."config" SET "value" = \\'it\\'\\'s done\\'');
@@ -72,13 +72,13 @@ export class UpdateConfigFastInstanceCommand implements FastInstanceCommand {
exports[`InstanceCommandGenerationService should generate a migration with a single up/down query 1`] = `
{
"className": "AddFooColumnFastInstanceCommand",
"fileName": "2-1-instance-command-fast-1775000000000-add-foo-column.ts",
"fileName": "2-2-instance-command-fast-1775000000000-add-foo-column.ts",
"fileTemplate": "import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.1.0', 1775000000000)
@RegisteredInstanceCommand('2.2.0', 1775000000000)
export class AddFooColumnFastInstanceCommand implements FastInstanceCommand {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "core"."user" ADD "foo" varchar');
@@ -95,13 +95,13 @@ export class AddFooColumnFastInstanceCommand implements FastInstanceCommand {
exports[`InstanceCommandGenerationService should generate a migration with multiple queries 1`] = `
{
"className": "CreateTaskTableFastInstanceCommand",
"fileName": "2-1-instance-command-fast-1775000000000-create-task-table.ts",
"fileName": "2-2-instance-command-fast-1775000000000-create-task-table.ts",
"fileTemplate": "import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.1.0', 1775000000000)
@RegisteredInstanceCommand('2.2.0', 1775000000000)
export class CreateTaskTableFastInstanceCommand implements FastInstanceCommand {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('CREATE TABLE "core"."task" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" varchar NOT NULL)');
@@ -120,13 +120,13 @@ export class CreateTaskTableFastInstanceCommand implements FastInstanceCommand {
exports[`InstanceCommandGenerationService should generate a migration with query parameters 1`] = `
{
"className": "SeedSettingFastInstanceCommand",
"fileName": "2-1-instance-command-fast-1775000000000-seed-setting.ts",
"fileName": "2-2-instance-command-fast-1775000000000-seed-setting.ts",
"fileTemplate": "import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.1.0', 1775000000000)
@RegisteredInstanceCommand('2.2.0', 1775000000000)
export class SeedSettingFastInstanceCommand implements FastInstanceCommand {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('INSERT INTO "core"."setting" ("key", "value") VALUES ($1, $2)', ["theme","dark"]);
@@ -143,13 +143,13 @@ export class SeedSettingFastInstanceCommand implements FastInstanceCommand {
exports[`InstanceCommandGenerationService should generate a slow instance command with populated up/down 1`] = `
{
"className": "MakeColumnNotNullableSlowInstanceCommand",
"fileName": "2-1-instance-command-slow-1775000000000-make-column-not-nullable.ts",
"fileName": "2-2-instance-command-slow-1775000000000-make-column-not-nullable.ts",
"fileTemplate": "import { DataSource, QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface';
@RegisteredInstanceCommand('2.1.0', 1775000000000, { type: 'slow' })
@RegisteredInstanceCommand('2.2.0', 1775000000000, { type: 'slow' })
export class MakeColumnNotNullableSlowInstanceCommand implements SlowInstanceCommand {
async runDataMigration(dataSource: DataSource): Promise<void> {
// TODO: implement data backfill before the DDL migration
@@ -170,13 +170,13 @@ export class MakeColumnNotNullableSlowInstanceCommand implements SlowInstanceCom
exports[`InstanceCommandGenerationService should use default migration name in class and file names 1`] = `
{
"className": "AutoGeneratedFastInstanceCommand",
"fileName": "2-1-instance-command-fast-1775000000000-auto-generated.ts",
"fileName": "2-2-instance-command-fast-1775000000000-auto-generated.ts",
"fileTemplate": "import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.1.0', 1775000000000)
@RegisteredInstanceCommand('2.2.0', 1775000000000)
export class AutoGeneratedFastInstanceCommand implements FastInstanceCommand {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "core"."user" ADD "bar" integer');
@@ -14,6 +14,7 @@ import {
} from 'src/engine/core-modules/logic-function/logic-function-drivers/interfaces/logic-function-driver.interface';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { type CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
import { LOGIC_FUNCTION_EXECUTOR_TMPDIR_FOLDER } from 'src/engine/core-modules/logic-function/logic-function-drivers/constants/logic-function-executor-tmpdir-folder';
import { ConsoleListener } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/intercept-console';
import { TemporaryDirManager } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/temporary-dir-manager';
@@ -22,10 +23,18 @@ import { LogicFunctionExecutionStatus } from 'src/engine/metadata-modules/logic-
import { copyYarnEngineAndBuildDependencies } from 'src/engine/core-modules/application/application-package/utils/copy-yarn-engine-and-build-dependencies';
import type { LogicFunctionResourceService } from 'src/engine/core-modules/logic-function/logic-function-resource/logic-function-resource.service';
import type { SdkClientArchiveService } from 'src/engine/core-modules/sdk-client/sdk-client-archive.service';
import type { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
const LAYER_BUILD_LOCK_TTL_MS = 120_000;
const LAYER_BUILD_LOCK_RETRY_MS = 500;
const LAYER_BUILD_LOCK_MAX_RETRIES = 240;
const LAYER_BUILD_READY_SENTINEL = '.twenty-layer-ready';
export interface LocalDriverOptions {
logicFunctionResourceService: LogicFunctionResourceService;
sdkClientArchiveService: SdkClientArchiveService;
cacheLockService: CacheLockService;
workspaceCacheService: WorkspaceCacheService;
}
const pathExists = async (targetPath: string): Promise<boolean> => {
@@ -41,10 +50,14 @@ const pathExists = async (targetPath: string): Promise<boolean> => {
export class LocalDriver implements LogicFunctionDriver {
private readonly logicFunctionResourceService: LogicFunctionResourceService;
private readonly sdkClientArchiveService: SdkClientArchiveService;
private readonly cacheLockService: CacheLockService;
private readonly workspaceCacheService: WorkspaceCacheService;
constructor(options: LocalDriverOptions) {
this.logicFunctionResourceService = options.logicFunctionResourceService;
this.sdkClientArchiveService = options.sdkClientArchiveService;
this.cacheLockService = options.cacheLockService;
this.workspaceCacheService = options.workspaceCacheService;
}
private getDepsLayerPath(flatApplication: FlatApplication): string {
@@ -75,23 +88,40 @@ export class LocalDriver implements LogicFunctionDriver {
applicationUniversalIdentifier: string;
}): Promise<void> {
const depsLayerPath = this.getDepsLayerPath(flatApplication);
const depsNodeModulesPath = join(depsLayerPath, 'node_modules');
const depsReadySentinelPath = join(
depsLayerPath,
LAYER_BUILD_READY_SENTINEL,
);
const nodeModulesExist = await pathExists(depsNodeModulesPath);
if (nodeModulesExist) {
if (await pathExists(depsReadySentinelPath)) {
return;
}
// Wipe any partial leftovers from a previously failed build
await fs.rm(depsLayerPath, { recursive: true, force: true });
const lockKey = `local-driver-deps-layer:${flatApplication.yarnLockChecksum ?? 'default'}`;
await this.logicFunctionResourceService.copyDependenciesInMemory({
applicationUniversalIdentifier,
workspaceId: flatApplication.workspaceId,
inMemoryFolderPath: depsLayerPath,
});
await copyYarnEngineAndBuildDependencies(depsLayerPath);
await this.cacheLockService.withLock(
async () => {
if (await pathExists(depsReadySentinelPath)) {
return;
}
await fs.rm(depsLayerPath, { recursive: true, force: true });
await this.logicFunctionResourceService.copyDependenciesInMemory({
applicationUniversalIdentifier,
workspaceId: flatApplication.workspaceId,
inMemoryFolderPath: depsLayerPath,
});
await copyYarnEngineAndBuildDependencies(depsLayerPath);
await fs.writeFile(depsReadySentinelPath, '');
},
lockKey,
{
ttl: LAYER_BUILD_LOCK_TTL_MS,
ms: LAYER_BUILD_LOCK_RETRY_MS,
maxRetries: LAYER_BUILD_LOCK_MAX_RETRIES,
},
);
}
private async ensureSdkLayer({
@@ -106,28 +136,57 @@ export class LocalDriver implements LogicFunctionDriver {
applicationUniversalIdentifier,
});
const sdkNodeModulesPath = join(sdkLayerPath, 'node_modules');
const sdkReadySentinelPath = join(sdkLayerPath, LAYER_BUILD_READY_SENTINEL);
const nodeModulesExist = await pathExists(sdkNodeModulesPath);
if (nodeModulesExist && !flatApplication.isSdkLayerStale) {
if (
(await pathExists(sdkReadySentinelPath)) &&
!flatApplication.isSdkLayerStale
) {
return;
}
await fs.rm(sdkLayerPath, { recursive: true, force: true });
const lockKey = `local-driver-sdk-layer:${flatApplication.workspaceId}:${applicationUniversalIdentifier}`;
const sdkPackagePath = join(sdkNodeModulesPath, 'twenty-client-sdk');
await this.cacheLockService.withLock(
async () => {
const { flatApplicationMaps } =
await this.workspaceCacheService.getOrRecompute(
flatApplication.workspaceId,
['flatApplicationMaps'],
);
const freshFlatApplication =
flatApplicationMaps.byId[flatApplication.id];
const isStale = freshFlatApplication?.isSdkLayerStale ?? true;
await this.sdkClientArchiveService.downloadAndExtractToPackage({
workspaceId: flatApplication.workspaceId,
applicationId: flatApplication.id,
applicationUniversalIdentifier,
targetPackagePath: sdkPackagePath,
});
if ((await pathExists(sdkReadySentinelPath)) && !isStale) {
return;
}
await this.sdkClientArchiveService.markSdkLayerFresh({
applicationId: flatApplication.id,
workspaceId: flatApplication.workspaceId,
});
await fs.rm(sdkLayerPath, { recursive: true, force: true });
const sdkPackagePath = join(sdkNodeModulesPath, 'twenty-client-sdk');
await this.sdkClientArchiveService.downloadAndExtractToPackage({
workspaceId: flatApplication.workspaceId,
applicationId: flatApplication.id,
applicationUniversalIdentifier,
targetPackagePath: sdkPackagePath,
});
await this.sdkClientArchiveService.markSdkLayerFresh({
applicationId: flatApplication.id,
workspaceId: flatApplication.workspaceId,
});
await fs.writeFile(sdkReadySentinelPath, '');
},
lockKey,
{
ttl: LAYER_BUILD_LOCK_TTL_MS,
ms: LAYER_BUILD_LOCK_RETRY_MS,
maxRetries: LAYER_BUILD_LOCK_MAX_RETRIES,
},
);
}
async transpile({
@@ -17,6 +17,7 @@ import { DriverFactoryBase } from 'src/engine/core-modules/twenty-config/dynamic
import { ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum';
import { ConfigGroupHashService } from 'src/engine/core-modules/twenty-config/services/config-group-hash.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
@Injectable()
export class LogicFunctionDriverFactory extends DriverFactoryBase<LogicFunctionDriver> {
@@ -26,6 +27,7 @@ export class LogicFunctionDriverFactory extends DriverFactoryBase<LogicFunctionD
private readonly logicFunctionResourceService: LogicFunctionResourceService,
private readonly sdkClientArchiveService: SdkClientArchiveService,
private readonly cacheLockService: CacheLockService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {
super(twentyConfigService, configGroupHashService);
}
@@ -51,6 +53,8 @@ export class LogicFunctionDriverFactory extends DriverFactoryBase<LogicFunctionD
return new LocalDriver({
logicFunctionResourceService: this.logicFunctionResourceService,
sdkClientArchiveService: this.sdkClientArchiveService,
cacheLockService: this.cacheLockService,
workspaceCacheService: this.workspaceCacheService,
});
case LogicFunctionDriverType.LAMBDA: {
@@ -7,6 +7,7 @@ import { LogicFunctionTriggerModule } from 'src/engine/core-modules/logic-functi
import { LogicFunctionExecutorModule } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.module';
import { SdkClientModule } from 'src/engine/core-modules/sdk-client/sdk-client.module';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@Global()
@Module({})
@@ -21,6 +22,7 @@ export class LogicFunctionModule {
LogicFunctionTriggerModule,
LogicFunctionExecutorModule,
SdkClientModule,
WorkspaceCacheModule,
],
providers: [LogicFunctionDriverFactory],
exports: [
@@ -8,5 +8,5 @@ export type ToolRetrievalOptions = {
// Apply output compaction (strip nulls/empty values) to dispatch results
// before returning. Chat enables this to reduce token usage in the
// conversation context; MCP and workflow agents leave raw output intact.
serializeOutput?: boolean;
compactOutput?: boolean;
};
@@ -1,4 +1,4 @@
import { stripEmptyValues } from 'src/engine/core-modules/tool-provider/output-serialization/strip-empty-values.util';
import { stripEmptyValues } from 'src/engine/core-modules/tool-provider/output-transforms/strip-empty-values.util';
describe('stripEmptyValues', () => {
it('should remove null values', () => {
@@ -7,7 +7,7 @@ import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/
import { type ToolRetrievalOptions } from 'src/engine/core-modules/tool-provider/interfaces/tool-retrieval-options.type';
import { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/tool-providers.token';
import { compactToolOutput } from 'src/engine/core-modules/tool-provider/output-serialization/compact-tool-output.util';
import { compactToolOutput } from 'src/engine/core-modules/tool-provider/output-transforms/compact-tool-output.util';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type LearnToolsAspect } from 'src/engine/core-modules/tool-provider/tools/learn-tools.tool';
import { type ToolContext } from 'src/engine/core-modules/tool-provider/types/tool-context.type';
@@ -107,12 +107,12 @@ export class ToolRegistryService {
options?: {
wrapWithErrorContext?: boolean;
includeLoadingMessage?: boolean;
serializeOutput?: boolean;
compactOutput?: boolean;
},
): ToolSet {
const toolSet: ToolSet = {};
const includeLoadingMessage = options?.includeLoadingMessage ?? true;
const serializeOutput = options?.serializeOutput ?? false;
const compactOutput = options?.compactOutput ?? false;
for (const descriptor of descriptors) {
const baseSchema = descriptor.inputSchema as Record<string, unknown>;
@@ -133,7 +133,7 @@ export class ToolRegistryService {
context,
);
return serializeOutput
return compactOutput
? (compactToolOutput(result) as ToolOutput)
: result;
};
@@ -170,7 +170,7 @@ export class ToolRegistryService {
context: ToolContext,
options?: {
includeLoadingMessage?: boolean;
serializeOutput?: boolean;
compactOutput?: boolean;
},
): Promise<ToolSet> {
const fullContext = this.buildContextFromToolContext(context);
@@ -190,7 +190,7 @@ export class ToolRegistryService {
return this.hydrateToolSet(descriptors, fullContext, {
includeLoadingMessage: options?.includeLoadingMessage,
serializeOutput: options?.serializeOutput,
compactOutput: options?.compactOutput,
});
}
@@ -236,7 +236,7 @@ export class ToolRegistryService {
toolName: string,
args: Record<string, unknown> | undefined,
context: ToolContext,
options?: { serializeOutput?: boolean },
options?: { compactOutput?: boolean },
): Promise<ToolOutput> {
try {
const fullContext = this.buildContextFromToolContext(context);
@@ -258,7 +258,7 @@ export class ToolRegistryService {
fullContext,
);
return options?.serializeOutput
return options?.compactOutput
? (compactToolOutput(result) as ToolOutput)
: result;
} catch (error) {
@@ -286,7 +286,7 @@ export class ToolRegistryService {
excludeTools,
wrapWithErrorContext,
includeLoadingMessage,
serializeOutput,
compactOutput,
} = options;
const categorySet = categories ? new Set(categories) : undefined;
@@ -321,7 +321,7 @@ export class ToolRegistryService {
const toolSet = this.hydrateToolSet(filteredDescriptors, context, {
wrapWithErrorContext,
includeLoadingMessage,
serializeOutput,
compactOutput,
});
this.logger.log(
@@ -49,7 +49,7 @@ export const createExecuteToolTool = (
context: ToolContext,
options?: {
excludeTools?: Set<string>;
serializeOutput?: boolean;
compactOutput?: boolean;
},
) => ({
description:
@@ -67,7 +67,7 @@ export const createExecuteToolTool = (
}
return toolRegistry.resolveAndExecute(toolName, args, context, {
serializeOutput: options?.serializeOutput,
compactOutput: options?.compactOutput,
});
},
});
@@ -1 +1 @@
export const TWENTY_CURRENT_VERSION = '2.1.0' as const;
export const TWENTY_CURRENT_VERSION = '2.2.0' as const;
@@ -1 +1 @@
export const TWENTY_NEXT_VERSIONS = ['2.2.0'] as const;
export const TWENTY_NEXT_VERSIONS = [] as const;
@@ -3,4 +3,5 @@ export const TWENTY_PREVIOUS_VERSIONS = [
'1.22.0',
'1.23.0',
'2.0.0',
'2.1.0',
] as const;
@@ -4,6 +4,7 @@ import { Injectable } from '@nestjs/common';
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
import { formatDateForClickHouse } from 'src/database/clickHouse/clickHouse.util';
import { fillUsageTimeSeriesGaps } from 'src/engine/core-modules/usage/utils/fill-usage-time-series-gaps.util';
import { toDisplayCredits } from 'src/engine/core-modules/usage/utils/to-display-credits.util';
import { toDollars } from 'src/engine/core-modules/usage/utils/to-dollars.util';
@@ -230,9 +231,15 @@ export class UsageAnalyticsService {
},
);
return rows.map((row) => ({
const points = rows.map((row) => ({
date: row.date,
creditsUsed: row.creditsUsedMicro,
}));
return fillUsageTimeSeriesGaps({
rows: points,
periodStart,
periodEnd,
});
}
}
@@ -0,0 +1,138 @@
import { fillUsageTimeSeriesGaps } from 'src/engine/core-modules/usage/utils/fill-usage-time-series-gaps.util';
describe('fillUsageTimeSeriesGaps', () => {
it('should return all-zero series across the period when no rows are returned', () => {
const result = fillUsageTimeSeriesGaps({
rows: [],
periodStart: new Date('2026-04-20T00:00:00.000Z'),
periodEnd: new Date('2026-04-23T23:59:59.999Z'),
});
expect(result).toEqual([
{ date: '2026-04-20', creditsUsed: 0 },
{ date: '2026-04-21', creditsUsed: 0 },
{ date: '2026-04-22', creditsUsed: 0 },
{ date: '2026-04-23', creditsUsed: 0 },
]);
});
it('should fill internal gaps with zero while preserving real values', () => {
const result = fillUsageTimeSeriesGaps({
rows: [
{ date: '2026-04-20', creditsUsed: 100 },
{ date: '2026-04-22', creditsUsed: 250 },
],
periodStart: new Date('2026-04-20T00:00:00.000Z'),
periodEnd: new Date('2026-04-23T23:59:59.999Z'),
});
expect(result).toEqual([
{ date: '2026-04-20', creditsUsed: 100 },
{ date: '2026-04-21', creditsUsed: 0 },
{ date: '2026-04-22', creditsUsed: 250 },
{ date: '2026-04-23', creditsUsed: 0 },
]);
});
it('should pad trailing dates with zero when data stops before period end (Félix bug)', () => {
const result = fillUsageTimeSeriesGaps({
rows: [
{ date: '2026-04-15', creditsUsed: 50 },
{ date: '2026-04-16', creditsUsed: 75 },
],
periodStart: new Date('2026-04-15T00:00:00.000Z'),
periodEnd: new Date('2026-04-20T23:59:59.999Z'),
});
expect(result).toEqual([
{ date: '2026-04-15', creditsUsed: 50 },
{ date: '2026-04-16', creditsUsed: 75 },
{ date: '2026-04-17', creditsUsed: 0 },
{ date: '2026-04-18', creditsUsed: 0 },
{ date: '2026-04-19', creditsUsed: 0 },
{ date: '2026-04-20', creditsUsed: 0 },
]);
});
it('should pad leading dates with zero when data starts after period start', () => {
const result = fillUsageTimeSeriesGaps({
rows: [{ date: '2026-04-22', creditsUsed: 99 }],
periodStart: new Date('2026-04-20T00:00:00.000Z'),
periodEnd: new Date('2026-04-23T23:59:59.999Z'),
});
expect(result).toEqual([
{ date: '2026-04-20', creditsUsed: 0 },
{ date: '2026-04-21', creditsUsed: 0 },
{ date: '2026-04-22', creditsUsed: 99 },
{ date: '2026-04-23', creditsUsed: 0 },
]);
});
it('should return data unchanged when every day in period is already present', () => {
const rows = [
{ date: '2026-04-20', creditsUsed: 1 },
{ date: '2026-04-21', creditsUsed: 2 },
{ date: '2026-04-22', creditsUsed: 3 },
];
const result = fillUsageTimeSeriesGaps({
rows,
periodStart: new Date('2026-04-20T00:00:00.000Z'),
periodEnd: new Date('2026-04-22T23:59:59.999Z'),
});
expect(result).toEqual(rows);
});
it('should return a single entry when period spans one day', () => {
const result = fillUsageTimeSeriesGaps({
rows: [{ date: '2026-04-23', creditsUsed: 42 }],
periodStart: new Date('2026-04-23T00:00:00.000Z'),
periodEnd: new Date('2026-04-23T23:59:59.999Z'),
});
expect(result).toEqual([{ date: '2026-04-23', creditsUsed: 42 }]);
});
it('should return an empty array when periodStart is after periodEnd', () => {
const result = fillUsageTimeSeriesGaps({
rows: [],
periodStart: new Date('2026-04-25T00:00:00.000Z'),
periodEnd: new Date('2026-04-20T23:59:59.999Z'),
});
expect(result).toEqual([]);
});
it('should treat periodEnd as exclusive to match SQL `timestamp < periodEnd` semantics', () => {
const result = fillUsageTimeSeriesGaps({
rows: [{ date: '2026-04-23', creditsUsed: 10 }],
periodStart: new Date('2026-04-22T00:00:00.000Z'),
periodEnd: new Date('2026-04-24T00:00:00.000Z'),
});
expect(result).toEqual([
{ date: '2026-04-22', creditsUsed: 0 },
{ date: '2026-04-23', creditsUsed: 10 },
]);
});
it('should return dates in ascending order', () => {
const result = fillUsageTimeSeriesGaps({
rows: [
{ date: '2026-04-22', creditsUsed: 5 },
{ date: '2026-04-20', creditsUsed: 1 },
],
periodStart: new Date('2026-04-20T00:00:00.000Z'),
periodEnd: new Date('2026-04-23T23:59:59.999Z'),
});
expect(result.map((point) => point.date)).toEqual([
'2026-04-20',
'2026-04-21',
'2026-04-22',
'2026-04-23',
]);
});
});
@@ -0,0 +1,47 @@
import { type Temporal } from 'temporal-polyfill';
import {
isPlainDateAfter,
isPlainDateBeforeOrEqual,
parseToPlainDateOrThrow,
} from 'twenty-shared/utils';
import { type UsageTimeSeriesPoint } from 'src/engine/core-modules/usage/services/usage-analytics.service';
type FillUsageTimeSeriesGapsParams = {
rows: UsageTimeSeriesPoint[];
periodStart: Date;
periodEnd: Date;
};
export const fillUsageTimeSeriesGaps = ({
rows,
periodStart,
periodEnd,
}: FillUsageTimeSeriesGapsParams): UsageTimeSeriesPoint[] => {
const startDate = parseToPlainDateOrThrow(periodStart.toISOString());
const lastIncludedInstant = new Date(periodEnd.getTime() - 1);
const endDate = parseToPlainDateOrThrow(lastIncludedInstant.toISOString());
if (isPlainDateAfter(startDate, endDate)) {
return [];
}
const rowsByDate = new Map<string, UsageTimeSeriesPoint>();
for (const row of rows) {
rowsByDate.set(row.date, row);
}
const filled: UsageTimeSeriesPoint[] = [];
let currentDateCursor: Temporal.PlainDate = startDate;
while (isPlainDateBeforeOrEqual(currentDateCursor, endDate)) {
const key = currentDateCursor.toString();
const existing = rowsByDate.get(key);
filled.push(existing ?? { date: key, creditsUsed: 0 });
currentDateCursor = currentDateCursor.add({ days: 1 });
}
return filled;
};
@@ -138,7 +138,7 @@ export class ChatExecutionService {
const preloadedTools = await this.toolRegistry.getToolsByName(
AI_CHAT_TOOL_NAMES_TO_PRELOAD,
toolContext,
{ serializeOutput: true },
{ compactOutput: true },
);
const resolvedModelId = modelId ?? workspace.smartModel;
@@ -186,7 +186,7 @@ export class ChatExecutionService {
[EXECUTE_TOOL_TOOL_NAME]: createExecuteToolTool(
this.toolRegistry,
toolContext,
{ serializeOutput: true },
{ compactOutput: true },
),
[LOAD_SKILL_TOOL_NAME]: createLoadSkillTool(
(skillNames) =>
@@ -55,6 +55,12 @@
"inputCostPerMillionTokens": 5,
"outputCostPerMillionTokens": 30,
"cachedInputCostPerMillionTokens": 0.5,
"longContextCost": {
"inputCostPerMillionTokens": 10,
"outputCostPerMillionTokens": 45,
"thresholdTokens": 200000,
"cachedInputCostPerMillionTokens": 1
},
"contextWindowTokens": 1050000,
"maxOutputTokens": 130000,
"modalities": ["image", "pdf"],
@@ -1,3 +1,3 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`UpgradeSequenceRunnerService — failing sequence (integration) should throw when cursor command is not found in the sequence 1`] = `"Step "RemovedCommand" not found in upgrade sequence. The sequence only covers versions [1.21.0, 1.22.0, 1.23.0, 2.0.0, 2.1.0]. Please upgrade to 1.21.0 first."`;
exports[`UpgradeSequenceRunnerService — failing sequence (integration) should throw when cursor command is not found in the sequence 1`] = `"Step "RemovedCommand" not found in upgrade sequence. The sequence only covers versions [1.21.0, 1.22.0, 1.23.0, 2.0.0, 2.1.0, 2.2.0]. Please upgrade to 1.21.0 first."`;
@@ -559,9 +559,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
};
}
default:
throw new Error(
`Unknown operand ${recordFilter.operand} for ${filterType} filter`,
);
return;
}
}
case 'CURRENCY': {