Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8649c067a | ||
|
|
499067ae14 | ||
|
|
ca84d28157 | ||
|
|
d1a4902460 | ||
|
|
89ad87aa64 | ||
|
|
0c5c9c844e | ||
|
|
41571ea377 | ||
|
|
570038ad65 |
@@ -207,7 +207,7 @@ All dev environments (Claude Code web, Cursor, local) use one script:
|
||||
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.
|
||||
This handles everything: starts Postgres + Redis (auto-detects local services vs Docker), creates databases, copies `.env` files, and initializes the database schema (migrations + seed). Idempotent — safe to run multiple times.
|
||||
|
||||
- `--docker` — force Docker mode (uses `packages/twenty-docker/docker-compose.dev.yml`)
|
||||
- `--down` — stop services
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "github-connector",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
|
||||
+1
@@ -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');
|
||||
|
||||
+6
-1
@@ -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,
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+1
@@ -15,5 +15,6 @@ export async function batchUpsertConsolidatedReviews(
|
||||
eventCount: true,
|
||||
reviewerId: true,
|
||||
pullRequestId: true,
|
||||
isSelfReview: true,
|
||||
}) as Promise<PullRequestReviewRow[]>;
|
||||
}
|
||||
|
||||
+11
-2
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
+13
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
+1
@@ -8,4 +8,5 @@ export type PullRequestReviewRow = {
|
||||
eventCount?: number | null;
|
||||
reviewerId?: string | null;
|
||||
pullRequestId?: string | null;
|
||||
isSelfReview?: boolean | null;
|
||||
};
|
||||
|
||||
+17
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
+66
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+9
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
+2
-1
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
+86
-27
@@ -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({
|
||||
|
||||
+4
@@ -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: [
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -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', () => {
|
||||
+10
-10
@@ -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(
|
||||
|
||||
+2
-2
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
+8
-1
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+138
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
+47
@@ -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;
|
||||
};
|
||||
+2
-2
@@ -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"],
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
# 1. Starts Postgres + Redis (local services or Docker, auto-detected)
|
||||
# 2. Creates 'default' and 'test' databases
|
||||
# 3. Copies .env.example -> .env for front and server
|
||||
# 4. Initializes database schema (runs migrations + seeds)
|
||||
#
|
||||
# Usage (from repo root):
|
||||
# bash packages/twenty-utils/setup-dev-env.sh # start + configure
|
||||
@@ -241,6 +242,34 @@ else
|
||||
done
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 4. Initialize database schema (build server, run migrations, seed)
|
||||
# =============================================================================
|
||||
schema_exists() {
|
||||
local query="SELECT 1 FROM information_schema.tables WHERE table_schema = 'core' LIMIT 1;"
|
||||
if command -v psql &>/dev/null; then
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d default -tAc "$query" 2>/dev/null | grep -q 1
|
||||
elif can_use_docker && docker compose -f "$COMPOSE_FILE" ps --quiet db 2>/dev/null | grep -q .; then
|
||||
docker compose -f "$COMPOSE_FILE" exec -T db psql -U postgres -d default -tAc "$query" 2>/dev/null | grep -q 1
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
if command -v npx &>/dev/null && [ -d node_modules ]; then
|
||||
if schema_exists; then
|
||||
ok "Database schema already initialized"
|
||||
else
|
||||
info "Initializing database schema (this may take a minute)..."
|
||||
npx nx database:reset twenty-server
|
||||
ok "Database schema initialized"
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo " NOTE: node_modules not found — skipping database schema initialization."
|
||||
echo " Run 'yarn install' then 'npx nx database:reset twenty-server' to initialize the schema."
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
echo ""
|
||||
echo "Dev environment ready."
|
||||
|
||||
Reference in New Issue
Block a user