refactor(utils): consolidate duplicated helpers onto @sim/utils (#5509)

* refactor(utils): consolidate duplicated helpers onto @sim/utils

Replaces ~90 hand-rolled reimplementations of error-message extraction,
postgres error-code checks, sleep, Math.random, retry/backoff, object
filtering/omission, noop, string truncation, date/time formatting, email
normalization, and plain-object type guards with the shared @sim/utils
exports. Wires check:utils into CI (test-build.yml) so these patterns
don't regress.

* fix(test): mock @sim/utils/random instead of Math.random in schedule-execute tests

The schedules/execute route previously used Math.random() for jitter delay;
this consolidation PR switched it to randomInt() from @sim/utils/random,
which is backed by crypto.getRandomValues() rather than Math.random(). The
route.test.ts spies on Math.random() no longer had any effect, so jitter
became real random delay instead of the deterministic 0ms the tests expect,
causing intermittent 10s timeouts in CI.

* fix(retry): preserve uncapped Retry-After comparison in tools/index.ts

parseRetryAfter() caps its return value at 30s by default. tools/index.ts
compares the parsed Retry-After against a caller-configured maxDelayMs to
decide whether to skip a retry entirely -- capping before that comparison
silently defeats the skip check whenever maxDelayMs is configured above
30s, since a Retry-After between 30s and maxDelayMs would incorrectly look
"within limits" and get retried instead of skipped (caught by Cursor
Bugbot). Added an optional maxMs param (default unchanged) so tools/index.ts
can request the raw, uncapped value for its own comparison while
backoffWithJitter still clamps the actual sleep duration to maxDelayMs.
Added a regression test covering maxDelayMs > 30s.

* fix(utils): fall back to Intl-resolved abbreviation for unmapped timezones

getTimezoneAbbreviation only covered 9 hardcoded IANA zones and returned
the raw IANA string for everything else, degrading schedule descriptions
for zones like Europe/Berlin or America/Toronto (caught by Greptile). The
deleted local implementation in schedules/utils.ts resolved any valid IANA
timezone generically via Intl.DateTimeFormat's short timeZoneName. Restore
that as a fallback so only genuinely invalid timezone strings return
themselves unchanged.
This commit is contained in:
Waleed
2026-07-08 11:26:23 -07:00
committed by GitHub
parent 099f525d50
commit 9d34fbea1b
98 changed files with 310 additions and 327 deletions
+3
View File
@@ -116,6 +116,9 @@ jobs:
- name: API contract boundary audit
run: bun run check:api-validation:strict
- name: Shared utils enforcement audit
run: bun run check:utils
- name: Zustand v5 selector audit
run: bun run check:zustand-v5
+4 -3
View File
@@ -11,6 +11,7 @@ import {
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { normalizeEmail } from '@sim/utils/string'
import { useRouter, useSearchParams } from 'next/navigation'
import { requestJson } from '@/lib/api/client/request'
import { forgetPasswordContract } from '@/lib/api/contracts'
@@ -45,7 +46,7 @@ const validateEmailField = (emailValue: string): string[] => {
return errors
}
const validation = quickValidateEmail(emailValue.trim().toLowerCase())
const validation = quickValidateEmail(normalizeEmail(emailValue))
if (!validation.isValid) {
errors.push(validation.reason || 'Please enter a valid email address.')
}
@@ -159,7 +160,7 @@ export default function LoginPage({
const formData = new FormData(e.currentTarget)
const emailRaw = formData.get('email') as string
const email = emailRaw.trim().toLowerCase()
const email = normalizeEmail(emailRaw)
const emailValidationErrors = validateEmailField(email)
setEmailErrors(emailValidationErrors)
@@ -277,7 +278,7 @@ export default function LoginPage({
return
}
const emailValidation = quickValidateEmail(forgotPasswordEmail.trim().toLowerCase())
const emailValidation = quickValidateEmail(normalizeEmail(forgotPasswordEmail))
if (!emailValidation.isValid) {
setResetStatus({
type: 'error',
@@ -18,6 +18,7 @@ import {
TableRow,
Tooltip,
} from '@sim/emcn'
import { formatDateTime } from '@sim/utils/formatting'
import { useQueryClient } from '@tanstack/react-query'
import { RefreshCw } from 'lucide-react'
import { useRouter } from 'next/navigation'
@@ -68,7 +69,7 @@ const STATUS_BADGE_VARIANT: Record<string, 'orange' | 'blue' | 'green' | 'red' |
function formatDate(value: string | null): string {
if (!value) return '—'
try {
return new Date(value).toLocaleString()
return formatDateTime(new Date(value))
} catch {
return value
}
@@ -4,6 +4,7 @@ import { type ReactNode, useRef, useState } from 'react'
import { Streamdown } from 'streamdown'
import 'streamdown/styles.css'
import { Avatar, AvatarFallback, AvatarImage, Chip, cn } from '@sim/emcn'
import { formatDate } from '@sim/utils/formatting'
import type { ChangelogEntry, GitHubRelease } from '@/app/(landing)/changelog/types'
import { mapReleases, releasesEndpoint } from '@/app/(landing)/changelog/utils'
@@ -53,14 +54,6 @@ function isContributorsLabel(children: ReactNode): boolean {
return /^\s*contributors\s*:?\s*$/i.test(String(children))
}
function formatDate(value: string): string {
return new Date(value).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
export function ChangelogTimeline({ initialEntries }: ChangelogTimelineProps) {
const [entries, setEntries] = useState<ChangelogEntry[]>(initialEntries)
const [loading, setLoading] = useState<boolean>(false)
@@ -133,7 +126,9 @@ export function ChangelogTimeline({ initialEntries }: ChangelogTimelineProps) {
</div>
) : null}
</div>
<span className='text-[12px] text-[var(--text-muted)]'>{formatDate(entry.date)}</span>
<span className='text-[12px] text-[var(--text-muted)]'>
{formatDate(new Date(entry.date))}
</span>
</div>
<div aria-hidden='true' className='mt-[9px] mb-3 h-px bg-[var(--border)]' />
+2 -2
View File
@@ -2,7 +2,7 @@ import { createSign } from 'crypto'
import { db } from '@sim/db'
import { account, credential } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { getPostgresErrorCode, toError } from '@sim/utils/errors'
import { and, desc, eq } from 'drizzle-orm'
import { withLeaderLock } from '@/lib/concurrency/leader-lock'
import { coalesceLocally } from '@/lib/concurrency/singleflight'
@@ -281,7 +281,7 @@ export async function safeAccountInsert(
await db.insert(account).values(data)
logger.info(`Created new ${context.provider} account for user`, { userId: data.userId })
} catch (error: any) {
if (error?.code === '23505') {
if (getPostgresErrorCode(error) === '23505') {
logger.error(`Duplicate ${context.provider} account detected, credential already exists`, {
userId: data.userId,
identifier: context.identifier,
@@ -2,6 +2,7 @@
* @vitest-environment node
*/
import { createMockRequest, dbChainMock, dbChainMockFns } from '@sim/testing'
import { generateShortId } from '@sim/utils/id'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetSession, mockGetStripeClient, mockStripeInvoicesList } = vi.hoisted(() => ({
@@ -25,7 +26,7 @@ import { GET } from '@/app/api/billing/invoices/route'
function makeInvoice(overrides: Record<string, unknown> = {}) {
return {
id: `in_${Math.random().toString(36).slice(2)}`,
id: `in_${generateShortId()}`,
number: 'INV-1',
created: 1700000000,
total: 1000,
@@ -12,6 +12,7 @@ import {
redisConfigMockFns,
resetDbChainMock,
} from '@sim/testing'
import { sleep } from '@sim/utils/helpers'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockVerifyCronAuth } = vi.hoisted(() => ({
@@ -37,7 +38,7 @@ function createRequest() {
)
}
const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0))
const flushMicrotasks = () => sleep(0)
describe('Teams subscription renewal route (fire-and-forget)', () => {
beforeEach(() => {
@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
import { normalizeEmail } from '@sim/utils/string'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { renderOTPEmail } from '@/components/emails'
@@ -71,7 +72,7 @@ export const POST = withRouteHandler(
const { token } = parsed.data.params
// Normalize once so allow-list matching, OTP storage, and the verify lookup
// all key off the same value (allow-list entries are stored lowercase).
const email = parsed.data.body.email.trim().toLowerCase()
const email = normalizeEmail(parsed.data.body.email)
const resolved = await resolveActiveShareByToken(token)
if (!resolved) {
@@ -133,7 +134,7 @@ export const PUT = withRouteHandler(
if (!parsed.success) return parsed.response
const { token } = parsed.data.params
const { otp } = parsed.data.body
const email = parsed.data.body.email.trim().toLowerCase()
const email = normalizeEmail(parsed.data.body.email)
const resolved = await resolveActiveShareByToken(token)
if (!resolved) {
@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
import { normalizeEmail } from '@sim/utils/string'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { publicFileSSOContract } from '@/lib/api/contracts/public-shares'
@@ -53,7 +54,7 @@ export const POST = withRouteHandler(
const parsed = await parseRequest(publicFileSSOContract, request, context)
if (!parsed.success) return parsed.response
const { token } = parsed.data.params
const email = parsed.data.body.email.trim().toLowerCase()
const email = normalizeEmail(parsed.data.body.email)
const resolved = await resolveActiveShareByToken(token)
if (!resolved) {
+4 -3
View File
@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { functionExecuteContract } from '@/lib/api/contracts'
import { parseRequest } from '@/lib/api/server'
@@ -1021,7 +1022,7 @@ async function maybeExportSandboxFileToWorkspace(args: {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to export sandbox file',
error: getErrorMessage(error, 'Failed to export sandbox file'),
output: { result: null, stdout: cleanStdout(stdout), executionTime },
},
{ status: 400 }
@@ -1165,7 +1166,7 @@ async function maybeExportSandboxFilesToWorkspace(args: {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Invalid sandbox output destination',
error: getErrorMessage(error, 'Invalid sandbox output destination'),
output: {
result: null,
stdout: cleanStdout(args.stdout),
@@ -1220,7 +1221,7 @@ async function maybeExportSandboxFilesToWorkspace(args: {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to export sandbox files',
error: getErrorMessage(error, 'Failed to export sandbox files'),
output: {
result: null,
stdout: cleanStdout(args.stdout),
+2 -1
View File
@@ -1,6 +1,7 @@
import { dbReplica } from '@sim/db'
import { workflow, workflowExecutionLogs } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { and, desc, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
@@ -139,7 +140,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
} catch (rowError) {
logger.warn('Skipping unserializable execution data for export row', {
executionId: r.executionId,
error: rowError instanceof Error ? rowError.message : String(rowError),
error: getErrorMessage(rowError),
})
}
const line = [
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
import type { NextRequest } from 'next/server'
import { mcpServerTestBodySchema } from '@/lib/api/contracts/mcp'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -59,7 +60,7 @@ function sanitizeConnectionError(error: unknown): string {
}
const firstLine = error.message.split('\n')[0]
return firstLine.length > 200 ? `${firstLine.slice(0, 200)}...` : firstLine
return truncate(firstLine, 200)
}
/**
+2 -1
View File
@@ -1,6 +1,7 @@
import { db } from '@sim/db'
import { memory } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getPostgresErrorCode } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, eq, isNull, like } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
@@ -224,7 +225,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
{ status: 200 }
)
} catch (error: any) {
if (error.code === '23505') {
if (getPostgresErrorCode(error) === '23505') {
return NextResponse.json(
{ success: false, error: { message: 'Memory with this key already exists' } },
{ status: 409 }
+2 -2
View File
@@ -326,12 +326,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
: 'Mothership execute error',
{
requestId,
error: error instanceof Error ? error.message : 'Unknown error',
error: getErrorMessage(error, 'Unknown error'),
}
)
send({
type: 'error',
error: error instanceof Error ? error.message : 'Internal server error',
error: getErrorMessage(error, 'Internal server error'),
})
} finally {
allowExplicitAbort = false
@@ -12,6 +12,7 @@ import {
import { createLogger } from '@sim/logger'
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
import { getErrorMessage } from '@sim/utils/errors'
import { normalizeEmail } from '@sim/utils/string'
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
@@ -178,7 +179,7 @@ export const POST = withRouteHandler(
new Set(
invitationEmails
.map((raw) => {
const normalized = raw.trim().toLowerCase()
const normalized = normalizeEmail(raw)
return quickValidateEmail(normalized).isValid ? normalized : null
})
.filter((email): email is string => !!email)
@@ -572,7 +573,7 @@ export const POST = withRouteHandler(
(email) => pendingEmails.includes(email) && !memberUserIdByEmail.has(email)
),
invalidEmails: invitationEmails.filter(
(email) => !quickValidateEmail(email.trim().toLowerCase()).isValid
(email) => !quickValidateEmail(normalizeEmail(email)).isValid
),
workspaceGrantsPerInvite: validGrants.length,
...(seatValidation
@@ -9,6 +9,7 @@ import {
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
import { normalizeEmail } from '@sim/utils/string'
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
@@ -212,7 +213,7 @@ export const POST = withRouteHandler(
const { email, role = 'member' } = parsed.data.body
// Validate and normalize email
const normalizedEmail = email.trim().toLowerCase()
const normalizedEmail = normalizeEmail(email)
const validation = quickValidateEmail(normalizedEmail)
if (!validation.isValid) {
return NextResponse.json(
@@ -140,6 +140,10 @@ vi.mock('@sim/utils/id', () => ({
),
}))
vi.mock('@sim/utils/random', () => ({
randomInt: vi.fn(() => 0),
}))
import { GET, runScheduleTick } from './route'
const SINGLE_SCHEDULE = [
@@ -382,7 +386,6 @@ describe('Scheduled Workflow Execution API Route', () => {
it('executes database fallback schedules through durable async job rows', async () => {
mockShouldExecuteInline.mockReturnValue(true)
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
dbChainMockFns.limit
.mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS)
.mockResolvedValueOnce([])
@@ -390,33 +393,28 @@ describe('Scheduled Workflow Execution API Route', () => {
.mockReturnValueOnce(SINGLE_SCHEDULE)
.mockResolvedValueOnce([{ id: 'job-id-1' }])
try {
await runScheduleTick('test-request-id')
expect(mockEnqueue).toHaveBeenCalledWith(
'schedule-execution',
expect.objectContaining({ scheduleId: 'schedule-1' }),
expect.objectContaining({
jobId: expect.stringMatching(/^schedule_[0-9a-f]{32}$/),
metadata: expect.objectContaining({
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
}),
})
)
expect(mockStartJob).not.toHaveBeenCalled()
expect(mockExecuteScheduleJob).toHaveBeenCalledWith(
expect.objectContaining({ scheduleId: 'schedule-1' })
)
expect(mockCompleteJob).toHaveBeenCalledWith('job-id-1', null)
} finally {
randomSpy.mockRestore()
}
await runScheduleTick('test-request-id')
expect(mockEnqueue).toHaveBeenCalledWith(
'schedule-execution',
expect.objectContaining({ scheduleId: 'schedule-1' }),
expect.objectContaining({
jobId: expect.stringMatching(/^schedule_[0-9a-f]{32}$/),
metadata: expect.objectContaining({
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
}),
})
)
expect(mockStartJob).not.toHaveBeenCalled()
expect(mockExecuteScheduleJob).toHaveBeenCalledWith(
expect.objectContaining({ scheduleId: 'schedule-1' })
)
expect(mockCompleteJob).toHaveBeenCalledWith('job-id-1', null)
})
it('releases database fallback claims when the global concurrency cap is full', async () => {
mockShouldExecuteInline.mockReturnValue(true)
const claimedAt = new Date('2025-01-01T00:00:00.000Z')
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
mockProcessingCounts(0, 0, 50)
dbChainMockFns.limit
.mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS)
@@ -425,20 +423,15 @@ describe('Scheduled Workflow Execution API Route', () => {
.mockReturnValueOnce([{ ...SINGLE_SCHEDULE[0], lastQueuedAt: claimedAt }])
.mockResolvedValueOnce([])
try {
await runScheduleTick('test-request-id')
expect(mockEnqueue).toHaveBeenCalled()
expect(mockExecuteScheduleJob).not.toHaveBeenCalled()
expect(mockCompleteJob).not.toHaveBeenCalled()
expect(mockReleaseScheduleLock).not.toHaveBeenCalled()
} finally {
randomSpy.mockRestore()
}
await runScheduleTick('test-request-id')
expect(mockEnqueue).toHaveBeenCalled()
expect(mockExecuteScheduleJob).not.toHaveBeenCalled()
expect(mockCompleteJob).not.toHaveBeenCalled()
expect(mockReleaseScheduleLock).not.toHaveBeenCalled()
})
it('recovers stale database fallback processing jobs before resuming them', async () => {
mockShouldExecuteInline.mockReturnValue(true)
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
const staleStartedAt = new Date('2024-12-31T00:00:00.000Z')
mockProcessingCounts(0, 0)
mockGetJob
@@ -473,25 +466,21 @@ describe('Scheduled Workflow Execution API Route', () => {
.mockReturnValueOnce([{ ...SINGLE_SCHEDULE[0], lastQueuedAt: new Date('2025-01-01') }])
.mockResolvedValueOnce([{ id: 'job-id-1' }])
try {
await runScheduleTick('test-request-id')
expect(mockExecuteScheduleJob).toHaveBeenCalledWith(
expect.objectContaining({ scheduleId: 'schedule-1' })
)
expect(mockCompleteJob).toHaveBeenCalledWith(
expect.stringMatching(/^schedule_[0-9a-f]{32}$/),
null
)
expect(dbChainMockFns.set).toHaveBeenCalledWith(
expect.objectContaining({
status: 'pending',
startedAt: null,
error: expect.stringContaining('stale schedule execution processing lease'),
})
)
} finally {
randomSpy.mockRestore()
}
await runScheduleTick('test-request-id')
expect(mockExecuteScheduleJob).toHaveBeenCalledWith(
expect.objectContaining({ scheduleId: 'schedule-1' })
)
expect(mockCompleteJob).toHaveBeenCalledWith(
expect.stringMatching(/^schedule_[0-9a-f]{32}$/),
null
)
expect(dbChainMockFns.set).toHaveBeenCalledWith(
expect.objectContaining({
status: 'pending',
startedAt: null,
error: expect.stringContaining('stale schedule execution processing lease'),
})
)
})
it('resumes pending database fallback jobs without waiting for a stale schedule claim', async () => {
@@ -653,7 +642,6 @@ describe('Scheduled Workflow Execution API Route', () => {
it('uses one backend mode decision for slot accounting and schedule processing', async () => {
mockShouldExecuteInline.mockReturnValue(true)
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
dbChainMockFns.limit
.mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS)
.mockResolvedValueOnce([])
@@ -661,15 +649,11 @@ describe('Scheduled Workflow Execution API Route', () => {
.mockReturnValueOnce(SINGLE_SCHEDULE)
.mockResolvedValueOnce([{ id: 'job-id-1' }])
try {
await runScheduleTick('test-request-id')
expect(mockShouldExecuteInline).toHaveBeenCalledTimes(1)
expect(mockExecuteScheduleJob).toHaveBeenCalledWith(
expect.objectContaining({ scheduleId: 'schedule-1' })
)
} finally {
randomSpy.mockRestore()
}
await runScheduleTick('test-request-id')
expect(mockShouldExecuteInline).toHaveBeenCalledTimes(1)
expect(mockExecuteScheduleJob).toHaveBeenCalledWith(
expect.objectContaining({ scheduleId: 'schedule-1' })
)
})
it('restores the original claim token when an active durable job owns the occurrence', async () => {
+2 -1
View File
@@ -4,6 +4,7 @@ import { sha256Hex } from '@sim/security/hash'
import { toError } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { generateId } from '@sim/utils/id'
import { randomInt } from '@sim/utils/random'
import { backoffWithJitter } from '@sim/utils/retry'
import { Cron } from 'croner'
import { and, asc, eq, inArray, isNull, lt, lte, or, sql } from 'drizzle-orm'
@@ -770,7 +771,7 @@ async function processScheduleItem(
let enqueuedJobId: string | null = null
try {
const delayMs = Math.floor(Math.random() * SCHEDULE_JITTER_MAX_MS)
const delayMs = randomInt(0, SCHEDULE_JITTER_MAX_MS)
const scheduleJobId = buildScheduleExecutionJobId(schedule)
const existingJob = await jobQueue.getJob(scheduleJobId)
@@ -2,6 +2,7 @@
* @vitest-environment node
*/
import { hybridAuthMockFns, permissionsMock, permissionsMockFns } from '@sim/testing'
import { getErrorMessage } from '@sim/utils/errors'
import type { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -40,7 +41,7 @@ vi.mock('@/app/api/table/utils', async () => {
{ status: error.code === 'FILE_TOO_LARGE' ? 413 : 400 }
),
rowWriteErrorResponse: (error: unknown) => {
const message = error instanceof Error ? error.message : String(error)
const message = getErrorMessage(error)
return message.includes('row limit')
? NextResponse.json({ error: message }, { status: 400 })
: null
+2 -1
View File
@@ -27,6 +27,7 @@ import { db } from '@sim/db'
import { organization, subscription, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateShortId } from '@sim/utils/id'
import { normalizeEmail } from '@sim/utils/string'
import { and, eq, inArray } from 'drizzle-orm'
import { adminV1IssueCreditsContract } from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
@@ -88,7 +89,7 @@ export const POST = withRouteHandler(
return badRequestResponse('Either userId or email is required')
}
const normalizedEmail = email.toLowerCase().trim()
const normalizedEmail = normalizeEmail(email)
const [userData] = await db
.select({ id: user.id, email: user.email })
.from(user)
@@ -3,6 +3,7 @@
*
* @vitest-environment node
*/
import { getErrorMessage } from '@sim/utils/errors'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -32,7 +33,7 @@ vi.mock('@/app/api/v1/knowledge/utils', () => ({
resolveKnowledgeBase: mockResolveKnowledgeBase,
serializeDate: (date: unknown) => (date instanceof Date ? date.toISOString() : date),
handleError: (_requestId: string, error: unknown) =>
new Response(JSON.stringify({ error: error instanceof Error ? error.message : 'error' }), {
new Response(JSON.stringify({ error: getErrorMessage(error, 'error') }), {
status: 500,
}),
}))
@@ -4,6 +4,7 @@
* @vitest-environment node
*/
import { createMockRequest, redisConfigMock, redisConfigMockFns } from '@sim/testing'
import { sleep } from '@sim/utils/helpers'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockVerifyCronAuth, mockPollProvider } = vi.hoisted(() => ({
@@ -32,7 +33,7 @@ function createContext(provider: string) {
return { params: Promise.resolve({ provider }) }
}
const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0))
const flushMicrotasks = () => sleep(0)
describe('webhook polling route (fire-and-forget)', () => {
beforeEach(() => {
@@ -4,6 +4,7 @@
* @vitest-environment node
*/
import { createMockRequest, redisConfigMock, redisConfigMockFns } from '@sim/testing'
import { sleep } from '@sim/utils/helpers'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockVerifyCronAuth, mockPollNoActivityEvents } = vi.hoisted(() => ({
@@ -29,7 +30,7 @@ function createRequest() {
return createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/workspace-events/poll')
}
const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0))
const flushMicrotasks = () => sleep(0)
describe('workspace events polling route (fire-and-forget)', () => {
beforeEach(() => {
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react'
import { cn, Input, InputOTP, InputOTPGroup, InputOTPSlot, Label } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { normalizeEmail } from '@sim/utils/string'
import { useRouter } from 'next/navigation'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { AuthSubmitButton } from '@/app/(auth)/components'
@@ -37,13 +38,13 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) {
}, [countdown])
const sendCode = async () => {
if (!quickValidateEmail(email.trim().toLowerCase()).isValid) {
if (!quickValidateEmail(normalizeEmail(email)).isValid) {
setError('Please enter a valid email address.')
return
}
setError(null)
try {
await requestOtp.mutateAsync({ email: email.trim().toLowerCase() })
await requestOtp.mutateAsync({ email: normalizeEmail(email) })
setSent(true)
setOtp('')
} catch (err) {
@@ -55,7 +56,7 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) {
if (code.length !== 6) return
setError(null)
try {
await verifyOtp.mutateAsync({ email: email.trim().toLowerCase(), otp: code })
await verifyOtp.mutateAsync({ email: normalizeEmail(email), otp: code })
router.refresh()
} catch (err) {
setError(getErrorMessage(err, 'Invalid verification code'))
@@ -65,7 +66,7 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) {
const resend = async () => {
setCountdown(30)
try {
await requestOtp.mutateAsync({ email: email.trim().toLowerCase() })
await requestOtp.mutateAsync({ email: normalizeEmail(email) })
setOtp('')
setError(null)
} catch (err) {
@@ -3,6 +3,7 @@
import { useState } from 'react'
import { cn, Input, Label } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { normalizeEmail } from '@sim/utils/string'
import { useRouter } from 'next/navigation'
import { requestJson } from '@/lib/api/client/request'
import { publicFileSSOContract } from '@/lib/api/contracts/public-shares'
@@ -26,14 +27,14 @@ export function PublicFileSSOAuth({ token }: PublicFileSSOAuthProps) {
const [isLoading, setIsLoading] = useState(false)
const handleAuthenticate = async () => {
if (!quickValidateEmail(email.trim().toLowerCase()).isValid) {
if (!quickValidateEmail(normalizeEmail(email)).isValid) {
setError('Please enter a valid email address.')
return
}
setError(null)
setIsLoading(true)
try {
const normalizedEmail = email.trim().toLowerCase()
const normalizedEmail = normalizeEmail(email)
const { eligible } = await requestJson(publicFileSSOContract, {
params: { token },
body: { email: normalizedEmail },
@@ -1,6 +1,7 @@
'use client'
import type { ComponentType } from 'react'
import { noop } from '@sim/utils/helpers'
import type { BreadcrumbItem } from '@/app/workspace/[workspaceId]/components/resource/components/resource-header'
import {
Resource,
@@ -43,8 +44,6 @@ interface ResourceChromeFallbackProps {
hasFilter?: boolean
}
const noop = () => {}
/**
* Route-transition fallback rendered by each resource route's `loading.tsx`. It
* paints the REAL resource chrome the header (icon/title or breadcrumbs + the
@@ -13,6 +13,8 @@
* falsely marks the file dirty ("unsaved changes"). The fix normalizes the dirty-check baseline to
* the canonical form; this asserts that normalized form equals what the live editor emits.
*/
import { sleep } from '@sim/utils/helpers'
import { Editor } from '@tiptap/core'
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import { createMarkdownEditorExtensions } from './editor-extensions'
@@ -94,7 +96,7 @@ describe('baseline neutralizes the mount-time dirty signal', () => {
},
})
await new Promise((resolve) => setTimeout(resolve, 30))
await sleep(30)
// The deferred mount transaction re-serializes to canonical markdown; the baseline must match it
// exactly, so `content === savedContent` and the file is never falsely dirty on open.
@@ -8,6 +8,8 @@
* text inside is genuinely editable via a normal ProseMirror transaction, surviving serialization
* back to markdown.
*/
import { sleep } from '@sim/utils/helpers'
import { Editor } from '@tiptap/core'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMarkdownEditorExtensions } from './editor-extensions'
@@ -52,7 +54,7 @@ function posOf(ed: Editor, typeName: string): number {
/** React node views flush on a microtask after mount, so DOM assertions need one tick. */
function nextTick(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0))
return sleep(0)
}
// The hover "Raw HTML"/"Footnote" badge is rendered by `RawBlockView` through
@@ -9,6 +9,7 @@ import {
useMemo,
useRef,
} from 'react'
import { noop } from '@sim/utils/helpers'
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
import type { ChatContext } from '@/stores/panel'
@@ -31,8 +32,6 @@ interface ChatSurfaceContextValue {
onWorkspaceResourceSelect: (resource: MothershipResource) => void
}
const noop = () => {}
const ChatSurfaceContext = createContext<ChatSurfaceContextValue>({
onContextAdd: noop,
onContextRemove: noop,
@@ -3,6 +3,7 @@
import { type ComponentType, type CSSProperties, useMemo, useState } from 'react'
import { ArrowRight, ChevronDown, chipVariants, cn, Expandable, ExpandableContent } from '@sim/emcn'
import { Shuffle, Table } from '@sim/emcn/icons'
import { randomFloat } from '@sim/utils/random'
import { stripVersionSuffix } from '@sim/utils/string'
import { useParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
@@ -156,7 +157,7 @@ function weightedSample<T>(pool: readonly T[], n: number, weightOf: (item: T) =>
while (out.length < n && remaining.length > 0) {
const total = remaining.reduce((sum, entry) => sum + entry.weight, 0)
if (total <= 0) break
let roll = Math.random() * total
let roll = randomFloat() * total
const index = remaining.findIndex((entry) => {
roll -= entry.weight
return roll <= 0
@@ -1,3 +1,4 @@
import { isRecordLike as isRecord } from '@sim/utils/object'
import { resolveStreamToolOutcome } from '@/lib/copilot/chat/stream-tool-outcome'
import {
MothershipStreamV1CompletionStatus,
@@ -175,10 +176,6 @@ function finalizeStaleWorkspaceFiles(model: TurnModel, spanId: string): void {
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined
}
@@ -17,6 +17,7 @@ import {
Trash,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { formatDate } from '@sim/utils/formatting'
import { ALL_TAG_SLOTS, type AllTagSlot, MAX_TAG_SLOTS } from '@/lib/knowledge/constants'
import type { DocumentTag } from '@/lib/knowledge/tags/types'
import type { DocumentData } from '@/lib/knowledge/types'
@@ -60,13 +61,9 @@ function formatValueForDisplay(value: string, fieldType: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
if (typeof value === 'string' && (value.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(value))) {
return new Date(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate()
).toLocaleDateString()
return formatDate(new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
}
return date.toLocaleDateString()
return formatDate(date)
} catch {
return value
}
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } fro
import { Badge, ChipCombobox, ChipConfirmModal, Plus, Trash } from '@sim/emcn'
import { Database } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { truncate } from '@sim/utils/string'
import { ChevronDown, ChevronUp, FileText, Pencil, Tag } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { useQueryStates } from 'nuqs'
@@ -108,7 +109,7 @@ function truncateContent(content: string, maxLength = 150, searchQuery = ''): st
}
}
return `${content.substring(0, maxLength)}...`
return truncate(content, maxLength)
}
const CHUNK_COLUMNS: ResourceColumn[] = [
@@ -2,6 +2,7 @@
import { Plus } from '@sim/emcn'
import { Database } from '@sim/emcn/icons'
import { noop } from '@sim/utils/helpers'
import { FileText } from 'lucide-react'
import {
type BreadcrumbItem,
@@ -9,8 +10,6 @@ import {
ResourceChromeFallback,
} from '@/app/workspace/[workspaceId]/components'
const noop = () => {}
const COLUMNS = [
{ id: 'content', header: 'Content' },
{ id: 'index', header: 'Index', widthMultiplier: 0.6 },
@@ -2,14 +2,13 @@
import { Plus } from '@sim/emcn'
import { Database } from '@sim/emcn/icons'
import { noop } from '@sim/utils/helpers'
import {
type BreadcrumbItem,
type ChromeActionSpec,
ResourceChromeFallback,
} from '@/app/workspace/[workspaceId]/components'
const noop = () => {}
const COLUMNS = [
{ id: 'name', header: 'Name', widthMultiplier: 0.8 },
{ id: 'size', header: 'Size', widthMultiplier: 0.75 },
@@ -1,6 +1,6 @@
import React from 'react'
import { Badge } from '@sim/emcn'
import { formatDuration } from '@sim/utils/formatting'
import { formatDuration, formatRelativeTime } from '@sim/utils/formatting'
import { format } from 'date-fns'
import type { WorkflowLogDetail } from '@/lib/api/contracts/logs'
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
@@ -224,23 +224,7 @@ export const formatDate = (dateString: string) => {
compact: format(date, 'MMM d HH:mm:ss'),
compactDate: format(date, 'MMM d').toUpperCase(),
compactTime: format(date, 'h:mm a'),
relative: (() => {
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return 'just now'
if (diffMins < 60) return `${diffMins}m ago`
const diffHours = Math.floor(diffMins / 60)
if (diffHours < 24) return `${diffHours}h ago`
const diffDays = Math.floor(diffHours / 24)
if (diffDays === 1) return 'yesterday'
if (diffDays < 7) return `${diffDays}d ago`
return format(date, 'MMM d')
})(),
relative: formatRelativeTime(dateString),
}
}
@@ -14,6 +14,7 @@ import {
import { createLogger } from '@sim/logger'
import { isOrgAdminRole } from '@sim/platform-authz/predicates'
import { getErrorMessage } from '@sim/utils/errors'
import { formatDate } from '@sim/utils/formatting'
import { useQueryClient } from '@tanstack/react-query'
import { useParams, useRouter } from 'next/navigation'
import { useSession, useSubscription } from '@/lib/auth/auth-client'
@@ -82,15 +83,6 @@ function getInvoiceStatusBadge(status: string | null): InvoiceStatusBadge {
return INVOICE_STATUS_BADGES[status ?? ''] ?? { variant: 'gray', label: status ?? 'Unknown' }
}
/** Format a Unix-seconds timestamp as a short human-readable date. */
function formatInvoiceDate(createdSeconds: number): string {
return new Date(createdSeconds * 1000).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
/** Cached currency formatters, keyed by upper-cased ISO currency code. */
const invoiceAmountFormatters = new Map<string, Intl.NumberFormat>()
@@ -413,7 +405,7 @@ export function Billing() {
const invoices = (invoicesData?.invoices ?? []).map((invoice) => ({
id: invoice.id,
date: formatInvoiceDate(invoice.created),
date: formatDate(new Date(invoice.created * 1000)),
amount: formatInvoiceAmount(invoice.total, invoice.currency),
badge: getInvoiceStatusBadge(invoice.status),
url: invoice.hostedInvoiceUrl ?? invoice.invoicePdf,
@@ -2,6 +2,7 @@
import { useCallback, useMemo, useState } from 'react'
import { Badge, Button, ChipInput, ChipSelect, cn, Label, Skeleton } from '@sim/emcn'
import { formatDateTime } from '@sim/utils/formatting'
import { useParams } from 'next/navigation'
import { useQueryStates } from 'nuqs'
import { AnthropicIcon, OpenAIIcon } from '@/components/icons'
@@ -78,7 +79,9 @@ function formatCost(cost: number) {
function formatDate(d: string | null | undefined) {
if (!d) return '—'
return new Date(d).toLocaleString()
const date = new Date(d)
if (Number.isNaN(date.getTime())) return '—'
return formatDateTime(date)
}
function Divider() {
@@ -5,6 +5,7 @@ import { ChipDropdown, ChipInput, Search, toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { isOrgAdminRole } from '@sim/platform-authz/predicates'
import { getErrorMessage } from '@sim/utils/errors'
import { formatDate } from '@sim/utils/formatting'
import {
type OrgRole,
type PermissionType,
@@ -58,10 +59,6 @@ function capitalize(value: string) {
return value.charAt(0).toUpperCase() + value.slice(1)
}
function formatJoinedDate(iso: string) {
return new Date(iso).toLocaleDateString('en-US')
}
function copyToClipboard(text: string) {
void navigator.clipboard.writeText(text)
}
@@ -126,7 +123,7 @@ export function OrganizationMemberLists({
name={member.name}
email={member.email}
image={member.image}
status={`Joined ${formatJoinedDate(member.createdAt)}`}
status={`Joined ${formatDate(new Date(member.createdAt))}`}
roleControl={
editable ? (
<ChipDropdown
@@ -294,7 +291,7 @@ export function OrganizationMemberLists({
name={member.name}
email={member.email}
image={member.image}
status={`Joined ${formatJoinedDate(member.createdAt)}`}
status={`Joined ${formatDate(new Date(member.createdAt))}`}
roleControl={
<RoleLockTooltip reason={lockReason}>
<ChipDropdown
@@ -3,6 +3,7 @@
import { useCallback, useMemo, useState } from 'react'
import { ChipDropdown, Plus, toast } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { formatDate } from '@sim/utils/formatting'
import { useQueryClient } from '@tanstack/react-query'
import { useParams, useRouter } from 'next/navigation'
import { debounce, useQueryState } from 'nuqs'
@@ -60,10 +61,6 @@ interface Teammate {
roleSource?: WorkspaceRoleSource
}
function formatJoinedDate(iso: string) {
return new Date(iso).toLocaleDateString('en-US')
}
function copyToClipboard(text: string) {
void navigator.clipboard.writeText(text)
}
@@ -131,7 +128,7 @@ export function Teammates() {
name: member.name ?? member.email,
image: member.image,
role: member.permissionType,
status: `Joined ${formatJoinedDate(member.joinedAt)}`,
status: `Joined ${formatDate(new Date(member.joinedAt))}`,
isPending: false,
userId: member.userId,
roleSource: member.roleSource,
@@ -1,13 +1,12 @@
'use client'
import { Table as TableIcon } from '@sim/emcn/icons'
import { noop } from '@sim/utils/helpers'
import {
type BreadcrumbItem,
ResourceChromeFallback,
} from '@/app/workspace/[workspaceId]/components'
const noop = () => {}
const BREADCRUMBS: BreadcrumbItem[] = [
{ label: 'Tables', icon: TableIcon, onClick: noop },
{ label: '…', terminal: true },
@@ -23,6 +23,7 @@ import {
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES } from '@/lib/table/constants'
import { buildAutoMapping, parseCsvBuffer } from '@/lib/table/import'
import type { TableDefinition } from '@/lib/table/types'
@@ -88,7 +89,7 @@ function summarizeImportError(message: string): string {
}
const trimmed = message.trim()
if (trimmed.length > 180) return `${trimmed.slice(0, 177)}...`
if (trimmed.length > 180) return truncate(trimmed, 177)
return trimmed
}
@@ -18,6 +18,7 @@ import {
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { normalizeEmail } from '@sim/utils/string'
import { AlertTriangle, Check } from 'lucide-react'
import { GeneratedPasswordInput } from '@/components/ui'
import { getEnv, isTruthy } from '@/lib/core/config/env'
@@ -623,7 +624,7 @@ function AuthSelector({
const addEmail = (email: string): boolean => {
if (!email.trim()) return false
const normalized = email.trim().toLowerCase()
const normalized = normalizeEmail(email)
const isDomainPattern = normalized.startsWith('@')
const validation = quickValidateEmail(normalized)
const isValid = validation.isValid || isDomainPattern
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { noop } from '@sim/utils/helpers'
import { useParams } from 'next/navigation'
import { getFolderPath } from '@/lib/folders/tree'
import { useReorderFolders } from '@/hooks/queries/folders'
@@ -40,8 +41,6 @@ const NOOP_DRAG_HANDLERS = {
const createNoopDragHandlers = () => NOOP_DRAG_HANDLERS
const noop = () => {}
/** Root folder vs root workflow scope: API/cache may use null or undefined for "no parent". */
function isSameFolderScope(
parentOrFolderId: string | null | undefined,
@@ -1,6 +1,7 @@
'use client'
import { createContext, type RefObject, useContext, useMemo } from 'react'
import { noop } from '@sim/utils/helpers'
interface SidebarListContextValue {
/** Whether any drag operation is currently in progress */
@@ -23,7 +24,6 @@ interface SidebarListContextValue {
onItemDragEnd: () => void
}
const noop = () => {}
const noopActiveWorkflowIdRef: RefObject<string | undefined> = { current: undefined }
/**
+2 -3
View File
@@ -1,3 +1,4 @@
import { getErrorMessage } from '@sim/utils/errors'
import { CloudFormationIcon } from '@/components/icons'
import type { BlockConfig, BlockMeta } from '@/blocks/types'
import { IntegrationType } from '@/blocks/types'
@@ -379,9 +380,7 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
try {
return JSON.parse(value)
} catch (parseError) {
throw new Error(
`Invalid JSON in ${fieldName}: ${parseError instanceof Error ? parseError.message : String(parseError)}`
)
throw new Error(`Invalid JSON in ${fieldName}: ${getErrorMessage(parseError)}`)
}
}
return undefined
+2 -3
View File
@@ -1,3 +1,4 @@
import { getErrorMessage } from '@sim/utils/errors'
import { NewRelicIcon } from '@/components/icons'
import type { BlockConfig, BlockMeta } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
@@ -13,9 +14,7 @@ function parseCustomAttributes(value: unknown): NewRelicCustomAttributes | undef
try {
return JSON.parse(trimmed) as NewRelicCustomAttributes
} catch (error) {
throw new Error(
`Invalid JSON for customAttributes: ${error instanceof Error ? error.message : String(error)}`
)
throw new Error(`Invalid JSON for customAttributes: ${getErrorMessage(error)}`)
}
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { getErrorMessage } from '@sim/utils/errors'
import { SquareIcon } from '@/components/icons'
import type { BlockConfig, BlockMeta } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
@@ -723,7 +724,7 @@ export const SquareBlock: BlockConfig<SquareResponse> = {
parsed = JSON.parse(value)
} catch (error) {
throw new Error(
`Invalid JSON in "${field}": ${error instanceof Error ? error.message : 'unknown error'}`
`Invalid JSON in "${field}": ${getErrorMessage(error, 'unknown error')}`
)
}
}
+2 -3
View File
@@ -1,3 +1,4 @@
import { getErrorMessage } from '@sim/utils/errors'
import { WizaIcon } from '@/components/icons'
import type { BlockConfig, BlockMeta } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
@@ -394,9 +395,7 @@ Return ONLY the JSON object - no explanations, no extra text.`,
try {
parsed[field] = JSON.parse(value)
} catch (err) {
throw new Error(
`Invalid JSON in Wiza "${field}" filter: ${err instanceof Error ? err.message : String(err)}`
)
throw new Error(`Invalid JSON in Wiza "${field}" filter: ${getErrorMessage(err)}`)
}
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import { parseMultiValue, parseTagDate } from '@/connectors/utils'
@@ -228,7 +229,7 @@ function tweetSourceUrl(tweetId: string, username?: string): string {
function tweetTitle(text: string): string {
const firstLine = text.split('\n')[0].trim()
if (!firstLine) return 'Tweet'
return firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine
return firstLine.length > 80 ? truncate(firstLine, 77) : firstLine
}
/**
@@ -13,6 +13,7 @@ import {
Label,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { ArrowRight, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import { getEnv, isTruthy } from '@/lib/core/config/env'
@@ -106,7 +107,7 @@ export function AccessControl() {
setNewGroupWorkspaceIds([])
} catch (error) {
logger.error('Failed to create permission group', error)
setCreateError(error instanceof Error ? error.message : 'Failed to create permission group')
setCreateError(getErrorMessage(error, 'Failed to create permission group'))
}
}, [
newGroupName,
@@ -25,6 +25,7 @@ import {
import { ArrowLeft } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { formatDate } from '@sim/utils/formatting'
import { ChevronDown, Plus } from 'lucide-react'
import type { ShareAuthType } from '@/lib/api/contracts/public-shares'
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
@@ -1378,7 +1379,7 @@ export function GroupDetail({
name={member.userName || member.userEmail || 'Unknown'}
email={member.userEmail || member.userName || 'Unknown'}
image={member.userImage}
status={`Added ${new Date(member.assignedAt).toLocaleDateString()}`}
status={`Added ${formatDate(new Date(member.assignedAt))}`}
menu={
<RowActionsMenu
label='Member actions'
@@ -3,6 +3,7 @@ import { mcpServers } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { truncate } from '@sim/utils/string'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import { normalizeStringRecord, normalizeWorkflowVariables } from '@/lib/core/utils/records'
import { createMcpToolId } from '@/lib/mcp/utils'
@@ -1176,7 +1177,7 @@ export class AgentBlockHandler implements BlockHandler {
}
} catch (error) {
logger.error('LLM did not adhere to structured response format:', {
content: content.substring(0, 200) + (content.length > 200 ? '...' : ''),
content: truncate(content, 200),
responseFormat: responseFormat,
})
+2 -1
View File
@@ -2,6 +2,7 @@
* @vitest-environment jsdom
*/
import { act, type ReactNode } from 'react'
import { sleep } from '@sim/utils/helpers'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -89,7 +90,7 @@ async function flush() {
await act(async () => {
for (let i = 0; i < 5; i++) {
await Promise.resolve()
await new Promise((resolve) => setTimeout(resolve, 0))
await sleep(0)
}
})
}
@@ -1,3 +1,4 @@
import { isRecordLike } from '@sim/utils/object'
import { z } from 'zod'
import type {
ContractBody,
@@ -37,7 +38,7 @@ const AssumeRoleSchema = z.object({
if (!v) return true
try {
const parsed = JSON.parse(v)
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
return isRecordLike(parsed)
} catch {
return false
}
@@ -1,6 +1,7 @@
import { db } from '@sim/db'
import { invitation, member, organization, subscription, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { normalizeEmail } from '@sim/utils/string'
import { and, count, eq, gt, ne } from 'drizzle-orm'
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
import { isEnterprise, isFree } from '@/lib/billing/plan-helpers'
@@ -231,7 +232,7 @@ export async function validateBulkInvitations(
try {
const uniqueEmails = [...new Set(emailList)]
const validEmails = uniqueEmails.filter(
(email) => quickValidateEmail(email.trim().toLowerCase()).isValid
(email) => quickValidateEmail(normalizeEmail(email)).isValid
)
const duplicateEmails = emailList.filter((email, index) => emailList.indexOf(email) !== index)
@@ -10,6 +10,7 @@ import {
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import type {
VfsSnapshotV1,
@@ -305,7 +306,7 @@ export function buildWorkspaceMd(data: WorkspaceMdData): string {
if (j.lifecycle !== 'persistent') line += ` [${j.lifecycle}]`
if (j.cronExpression) line += `, cron: ${j.cronExpression}`
if (j.sourceTaskName) line += `, task: ${j.sourceTaskName}`
const promptPreview = j.prompt.length > 80 ? `${j.prompt.slice(0, 77)}...` : j.prompt
const promptPreview = j.prompt.length > 80 ? truncate(j.prompt, 77) : j.prompt
line += `\n ${promptPreview}`
return line
})
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { sha256Hex } from '@sim/security/hash'
import { getErrorMessage } from '@sim/utils/errors'
import { isE2BDocEnabled } from '@/lib/core/config/env-flags'
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
import { executeInE2B, executeShellInE2B, type SandboxFile } from '@/lib/execution/e2b'
@@ -152,7 +153,7 @@ async function stageReferencedImages(source: string, workspaceId: string): Promi
logger.warn('Failed to resolve referenced image for doc compile', {
workspaceId,
fileId,
error: err instanceof Error ? err.message : String(err),
error: getErrorMessage(err),
})
continue
}
@@ -177,7 +178,7 @@ async function stageReferencedImages(source: string, workspaceId: string): Promi
logger.warn('Failed to stage referenced image for doc compile', {
workspaceId,
fileId,
error: err instanceof Error ? err.message : String(err),
error: getErrorMessage(err),
})
continue
}
@@ -1,5 +1,6 @@
import { createHash } from 'node:crypto'
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { downloadFile, uploadFile } from '@/lib/uploads/core/storage-service'
const logger = createLogger('CopilotDocCompiledStore')
@@ -63,8 +64,8 @@ export async function storeCompiledDoc(
} catch (err) {
logger.error('Failed to store compiled doc artifact', {
key,
error: err instanceof Error ? err.message : String(err),
error: getErrorMessage(err),
})
throw err instanceof Error ? err : new Error(String(err))
throw toError(err)
}
}
@@ -1,3 +1,4 @@
import { generateShortId } from '@sim/utils/id'
import { describe, expect, it, vi } from 'vitest'
import {
consumeLatestFileIntent,
@@ -23,7 +24,7 @@ function makeIntent(overrides: Partial<PendingFileIntent>): PendingFileIntent {
}
function uniqueWorkspace(): string {
return `ws-${Math.random().toString(36).slice(2)}`
return `ws-${generateShortId()}`
}
describe('file-intent-store channel scoping', () => {
@@ -3,6 +3,7 @@ import { knowledgeConnector } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { truncate } from '@sim/utils/string'
import { and, eq, isNull } from 'drizzle-orm'
import { generateInternalToken } from '@/lib/auth/internal'
import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor'
@@ -265,7 +266,7 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
return {
success: true,
message: `Found ${results.length} result(s) for query "${args.query.substring(0, 50)}${args.query.length > 50 ? '...' : ''}"`,
message: `Found ${results.length} result(s) for query "${truncate(args.query, 50)}"`,
data: {
knowledgeBaseId: args.knowledgeBaseId,
knowledgeBaseName: kb.name,
@@ -1,3 +1,4 @@
import { sleep } from '@sim/utils/helpers'
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import type {
ConsumeResult,
@@ -413,7 +414,7 @@ describe('HostedKeyRateLimiter', () => {
controller.signal
)
// Let the first bucket check run and the sleep begin, then abort.
await new Promise((resolve) => setTimeout(resolve, 20))
await sleep(20)
controller.abort()
const result = await promise
+3 -2
View File
@@ -1,6 +1,7 @@
import { safeCompare } from '@sim/security/compare'
import { sha256Hex } from '@sim/security/hash'
import { hmacSha256Hex } from '@sim/security/hmac'
import { normalizeEmail } from '@sim/utils/string'
import type { NextResponse } from 'next/server'
import { env } from '@/lib/core/config/env'
import { isDev } from '@/lib/core/config/env-flags'
@@ -114,8 +115,8 @@ export function setDeploymentAuthCookie(
* sides, so callers don't need to normalize before calling.
*/
export function isEmailAllowed(email: string, allowedEmails: string[]): boolean {
const normalizedEmail = email.trim().toLowerCase()
const normalizedAllowed = allowedEmails.map((allowed) => allowed.trim().toLowerCase())
const normalizedEmail = normalizeEmail(email)
const normalizedAllowed = allowedEmails.map(normalizeEmail)
if (normalizedAllowed.includes(normalizedEmail)) {
return true
+2 -1
View File
@@ -1,10 +1,11 @@
/**
* @vitest-environment node
*/
import { sleep } from '@sim/utils/helpers'
import { describe, expect, it, vi } from 'vitest'
import { runDetached } from '@/lib/core/utils/background'
const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0))
const flushMicrotasks = () => sleep(0)
describe('runDetached', () => {
it('runs the work without the caller awaiting it', async () => {
+4 -11
View File
@@ -1,5 +1,6 @@
import { Readable } from 'node:stream'
import type { ReadableStream as NodeReadableStream } from 'node:stream/web'
import { getErrorMessage } from '@sim/utils/errors'
import busboy from 'busboy'
/**
@@ -115,12 +116,7 @@ export function readMultipart(
limits: { fileSize: maxFileBytes, files: 1 },
})
} catch (err) {
reject(
new MultipartError(
'NOT_MULTIPART',
err instanceof Error ? err.message : 'Invalid multipart request'
)
)
reject(new MultipartError('NOT_MULTIPART', getErrorMessage(err, 'Invalid multipart request')))
return
}
@@ -228,7 +224,7 @@ export function readMultipart(
})
bb.on('error', (err) => {
const message = err instanceof Error ? err.message : 'Failed to parse multipart body'
const message = getErrorMessage(err, 'Failed to parse multipart body')
settle(() => reject(new MultipartError('PARSE_ERROR', message)))
})
@@ -243,10 +239,7 @@ export function readMultipart(
reject(
err instanceof MultipartError
? err
: new MultipartError(
'PARSE_ERROR',
err instanceof Error ? err.message : 'Failed to read request body'
)
: new MultipartError('PARSE_ERROR', getErrorMessage(err, 'Failed to read request body'))
)
)
})
+1 -1
View File
@@ -7,6 +7,7 @@
import { EventEmitter } from 'events'
import { createLogger } from '@sim/logger'
import { noop } from '@sim/utils/helpers'
import Redis, { type RedisOptions } from 'ioredis'
import { env } from '@/lib/core/config/env'
import { getRedisConnectionDefaults } from '@/lib/core/config/redis'
@@ -99,7 +100,6 @@ class RedisPubSubChannel<T> implements PubSubChannel<T> {
this.disposed = true
this.handlers.clear()
const noop = () => {}
this.pub.removeAllListeners()
this.sub.removeAllListeners()
this.pub.on('error', noop)
+1 -1
View File
@@ -173,7 +173,7 @@ async function readSandboxOutputFile(
} catch (error) {
logger.warn('Failed to read requested sandbox output file', {
outputSandboxPath,
error: error instanceof Error ? error.message : String(error),
error: getErrorMessage(error),
})
return undefined
}
+2 -3
View File
@@ -1,6 +1,7 @@
import { existsSync } from 'fs'
import { readFile } from 'fs/promises'
import { createLogger } from '@sim/logger'
import { truncate } from '@sim/utils/string'
import * as XLSX from 'xlsx'
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
@@ -199,9 +200,7 @@ export class XlsxParser implements FileParser {
let cellStr = String(cell)
// Truncate very long cells
if (cellStr.length > CONFIG.MAX_CELL_LENGTH) {
cellStr = `${cellStr.substring(0, CONFIG.MAX_CELL_LENGTH)}...`
}
cellStr = truncate(cellStr, CONFIG.MAX_CELL_LENGTH)
return sanitizeTextForUTF8(cellStr)
}
@@ -2,6 +2,7 @@
* @vitest-environment node
*/
import { authOAuthUtilsMock, urlsMock } from '@sim/testing'
import { generateShortId } from '@sim/utils/id'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db', () => ({ db: {} }))
@@ -233,7 +234,7 @@ describe('chunkOpsByByteBudget', () => {
const addOp = (sizeBytes?: number) => ({
type: 'add' as const,
extDoc: {
externalId: `e-${Math.random()}`,
externalId: `e-${generateShortId()}`,
title: 'f',
content: 'x',
contentHash: 'h',
@@ -244,7 +245,7 @@ describe('chunkOpsByByteBudget', () => {
const skipOp = (sizeBytes: number) => ({
type: 'skip' as const,
extDoc: {
externalId: `s-${Math.random()}`,
externalId: `s-${generateShortId()}`,
title: 'f',
content: '',
contentHash: 'h',
@@ -7,7 +7,7 @@ import {
knowledgeConnectorSyncLog,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { randomInt } from '@sim/utils/random'
import { and, eq, gt, inArray, isNotNull, isNull, lt, ne, or, sql } from 'drizzle-orm'
@@ -733,8 +733,7 @@ export async function executeSync(
result.docsFailed++
logger.error('Failed to hydrate deferred document', {
connectorId,
error:
outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason),
error: getErrorMessage(outcome.reason),
})
}
}
@@ -799,8 +798,7 @@ export async function executeSync(
logger.error('Failed to process document', {
connectorId,
externalId: batch[j].extDoc.externalId,
error:
outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason),
error: getErrorMessage(outcome.reason),
})
}
}
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { isRecordLike as isRecord } from '@sim/utils/object'
import { getRedisClient } from '@/lib/core/config/redis'
import { getExecutionReservationTtlMs } from '@/lib/core/execution-limits'
import type { ExecutionLastCompletedBlock, ExecutionLastStartedBlock } from '@/lib/logs/types'
@@ -157,10 +158,6 @@ function safeJsonParse(raw: string | undefined): unknown {
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/**
* Parse a stored last-started marker, rebuilding it from validated fields so a
* stale or wrong-shaped Redis value can never reach API consumers.
+2 -1
View File
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
import { Twilio } from 'twilio'
import { env } from '@/lib/core/config/env'
@@ -66,7 +67,7 @@ export async function sendSMS(options: SMSOptions): Promise<SendSMSResult> {
if (!twilioClient) {
logger.error('SMS sending failed: Twilio not configured', {
to,
body: `${body.substring(0, 50)}...`,
body: truncate(body, 50),
from: fromNumber,
})
return {
+2 -1
View File
@@ -29,6 +29,7 @@ import {
StandardUnit,
} from '@aws-sdk/client-cloudwatch'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
const logger = createLogger('HostedKeyMetrics')
@@ -133,7 +134,7 @@ export async function flushHostedKeyMetrics(): Promise<void> {
// Telemetry must never break the request path — log and drop the batch.
logger.warn('PutMetricData failed; dropping batch', {
count: MetricData.length,
error: err instanceof Error ? err.message : String(err),
error: getErrorMessage(err),
})
}
}
+6 -2
View File
@@ -5,6 +5,7 @@
* Uses text extraction (->>) for comparisons and pattern matching.
*/
import { isRecordLike } from '@sim/utils/object'
import type { SQL } from 'drizzle-orm'
import { sql } from 'drizzle-orm'
import { getColumnId } from '@/lib/table/column-keys'
@@ -311,7 +312,7 @@ function buildFieldCondition(
const conditions: SQL[] = []
if (typeof condition === 'object' && condition !== null && !Array.isArray(condition)) {
if (isRecordLike(condition)) {
for (const [op, value] of Object.entries(condition)) {
// Validate operator to ensure only allowed operators are used
validateOperator(op)
@@ -405,7 +406,10 @@ function buildFieldCondition(
} else {
// Simple value (primitive or null) - shorthand for equality.
// Example: { name: 'John' } is equivalent to { name: { $eq: 'John' } }
conditions.push(buildContainmentClause(tableName, field, condition))
// isRecordLike's negation can't structurally exclude ConditionOperators (no index
// signature), unlike the prior typeof-based narrowing, so the JsonValue-only shape
// of this branch is asserted rather than inferred.
conditions.push(buildContainmentClause(tableName, field, condition as JsonValue))
}
return conditions
+2 -4
View File
@@ -4,6 +4,7 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
import {
LLM_BLOCK_TYPES,
MAX_PREVIEW_LENGTH,
@@ -103,10 +104,7 @@ export function extractTextContent(input: unknown): string {
* Creates a preview of text for logging (truncated)
*/
export function createTextPreview(text: string): string {
if (text.length <= MAX_PREVIEW_LENGTH) {
return text
}
return `${text.substring(0, MAX_PREVIEW_LENGTH)}...`
return truncate(text, MAX_PREVIEW_LENGTH)
}
/**
+2 -1
View File
@@ -2,6 +2,7 @@ import { db, webhook, workflow, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { truncate } from '@sim/utils/string'
import { and, eq, isNull, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { tryAdmit } from '@/lib/core/admission/gate'
@@ -102,7 +103,7 @@ export async function parseWebhookBody(
logger.error(`[${requestId}] Failed to parse webhook body`, {
error: toError(parseError).message,
contentType: request.headers.get('content-type'),
bodyPreview: `${rawBody?.slice(0, 100)}...`,
bodyPreview: truncate(rawBody ?? '', 100),
})
return new NextResponse('Invalid payload format', { status: 400 })
}
+1 -22
View File
@@ -1,6 +1,6 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { formatDateTime } from '@sim/utils/formatting'
import { formatDateTime, getTimezoneAbbreviation } from '@sim/utils/formatting'
import { Cron } from 'croner'
import cronstrue from 'cronstrue'
@@ -476,27 +476,6 @@ export function calculateNextRunTime(
}
}
/**
* Helper function to get a friendly timezone abbreviation.
* Uses Intl.DateTimeFormat to get the correct abbreviation for the current time,
* automatically handling DST transitions.
*/
function getTimezoneAbbreviation(timezone: string): string {
if (timezone === 'UTC') return 'UTC'
try {
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
timeZoneName: 'short',
})
const parts = formatter.formatToParts(new Date())
const tzPart = parts.find((p) => p.type === 'timeZoneName')
return tzPart?.value || timezone
} catch {
return timezone
}
}
/**
* Converts a cron expression to a human-readable string format
* Uses the cronstrue library for accurate parsing of complex cron expressions
+1 -1
View File
@@ -302,7 +302,7 @@ export const getDisplayValue = (value: unknown): string => {
try {
const json = JSON.stringify(parsedValue)
if (json.length <= 40) return json
return `${json.slice(0, 37)}...`
return truncate(json, 37)
} catch {
return '-'
}
@@ -4,6 +4,7 @@
*/
import { isOrgAdminRole } from '@sim/platform-authz/predicates'
import { normalizeEmail } from '@sim/utils/string'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import type { Organization } from '@/lib/workspaces/organization/types'
@@ -88,5 +89,5 @@ export function validateSlug(slug: string): boolean {
* Validate email format
*/
export function validateEmail(email: string): boolean {
return quickValidateEmail(email.trim().toLowerCase()).isValid
return quickValidateEmail(normalizeEmail(email)).isValid
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { createLogger, type Logger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { omit } from '@sim/utils/object'
import type OpenAI from 'openai'
import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
import type { CompletionUsage } from 'openai/resources/completions'
@@ -642,7 +643,7 @@ export async function transformBlockTool(
)
const sourceIds = [group.basicId, ...group.advancedIds].filter(Boolean) as string[]
sourceIds.forEach((id) => delete result[id])
result = omit(result, sourceIds)
if (chosen !== undefined) {
result[group.canonicalId] = chosen
+2 -3
View File
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { truncate } from '@sim/utils/string'
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import type { ChatMessage, ChatState } from './types'
@@ -109,9 +110,7 @@ export const useChatStore = create<ChatState>()(
let stringValue = typeof value === 'object' ? JSON.stringify(value) : String(value)
// Truncate very long strings
if (stringValue.length > 2000) {
stringValue = `${stringValue.substring(0, 2000)}...`
}
stringValue = truncate(stringValue, 2000)
// Escape quotes and wrap in quotes if contains special characters
if (
+2 -4
View File
@@ -1,3 +1,4 @@
import { isRecordLike } from '@sim/utils/object'
import type { AmplitudeFunnelsParams, AmplitudeFunnelsResponse } from '@/tools/amplitude/types'
import { getDashboardHost } from '@/tools/amplitude/utils'
import type { ToolConfig } from '@/tools/types'
@@ -100,10 +101,7 @@ export const funnelsTool: ToolConfig<AmplitudeFunnelsParams, AmplitudeFunnelsRes
} catch {
throw new Error('Amplitude Funnels: "events" must be a valid JSON array of event objects')
}
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value)
if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every(isPlainObject)) {
if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every(isRecordLike)) {
throw new Error(
'Amplitude Funnels: "events" must be a non-empty JSON array of event objects'
)
+2 -1
View File
@@ -1,3 +1,4 @@
import { truncate } from '@sim/utils/string'
import type { CreateIssueCommentParams, IssueCommentResponse } from '@/tools/github/types'
import { COMMENT_OUTPUT_PROPERTIES, USER_OUTPUT } from '@/tools/github/types'
import type { ToolConfig } from '@/tools/types'
@@ -58,7 +59,7 @@ export const issueCommentTool: ToolConfig<CreateIssueCommentParams, IssueComment
transformResponse: async (response) => {
const data = await response.json()
const content = `Comment created on issue #${data.issue_url.split('/').pop()}: "${data.body.substring(0, 100)}${data.body.length > 100 ? '...' : ''}"`
const content = `Comment created on issue #${data.issue_url.split('/').pop()}: "${truncate(data.body, 100)}"`
return {
success: true,
+2 -1
View File
@@ -1,3 +1,4 @@
import { truncate } from '@sim/utils/string'
import type { CommentsListResponse, ListIssueCommentsParams } from '@/tools/github/types'
import { COMMENT_OUTPUT_PROPERTIES, USER_OUTPUT } from '@/tools/github/types'
import type { ToolConfig } from '@/tools/types'
@@ -84,7 +85,7 @@ export const listIssueCommentsTool: ToolConfig<ListIssueCommentsParams, Comments
.slice(0, 5)
.map(
(c: any) =>
`- ${c.user.login} (${new Date(c.created_at).toLocaleDateString()}): "${c.body.substring(0, 80)}${c.body.length > 80 ? '...' : ''}"`
`- ${c.user.login} (${new Date(c.created_at).toLocaleDateString()}): "${truncate(c.body, 80)}"`
)
.join('\n')}`
: ''
+2 -1
View File
@@ -1,3 +1,4 @@
import { truncate } from '@sim/utils/string'
import type { CommentsListResponse, ListPRCommentsParams } from '@/tools/github/types'
import { PR_COMMENT_OUTPUT_PROPERTIES, USER_OUTPUT } from '@/tools/github/types'
import type { ToolConfig } from '@/tools/types'
@@ -100,7 +101,7 @@ export const listPRCommentsTool: ToolConfig<ListPRCommentsParams, CommentsListRe
.slice(0, 5)
.map(
(c: any) =>
`- ${c.user.login} on ${c.path}${c.line ? `:${c.line}` : ''} (${new Date(c.created_at).toLocaleDateString()}): "${c.body.substring(0, 80)}${c.body.length > 80 ? '...' : ''}"`
`- ${c.user.login} on ${c.path}${c.line ? `:${c.line}` : ''} (${new Date(c.created_at).toLocaleDateString()}): "${truncate(c.body, 80)}"`
)
.join('\n')}`
: ''
+2 -1
View File
@@ -1,3 +1,4 @@
import { truncate } from '@sim/utils/string'
import type { IssueCommentResponse, UpdateCommentParams } from '@/tools/github/types'
import { COMMENT_OUTPUT_PROPERTIES, USER_OUTPUT } from '@/tools/github/types'
import type { ToolConfig } from '@/tools/types'
@@ -58,7 +59,7 @@ export const updateCommentTool: ToolConfig<UpdateCommentParams, IssueCommentResp
transformResponse: async (response) => {
const data = await response.json()
const content = `Comment #${data.id} updated: "${data.body.substring(0, 100)}${data.body.length > 100 ? '...' : ''}"`
const content = `Comment #${data.id} updated: "${truncate(data.body, 100)}"`
return {
success: true,
+2 -5
View File
@@ -1,9 +1,6 @@
import { getErrorMessage } from '@sim/utils/errors'
import type { Workflow } from '@/tools/incidentio/types'
function getJsonParseErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function toStringValue(value: unknown): string {
return typeof value === 'string' ? value : String(value ?? '')
}
@@ -34,7 +31,7 @@ export function parseIncidentioJsonParam(
try {
return JSON.parse(jsonString)
} catch (error) {
throw new Error(`Invalid JSON for ${paramName}: ${getJsonParseErrorMessage(error)}`)
throw new Error(`Invalid JSON for ${paramName}: ${getErrorMessage(error)}`)
}
}
+22
View File
@@ -1896,6 +1896,28 @@ describe('MCP Tool Execution', () => {
expect(result.success).toBe(false)
})
it('skips retry when Retry-After exceeds a maxDelayMs configured above the 30s default cap', async () => {
global.fetch = Object.assign(
vi
.fn()
.mockResolvedValueOnce(
makeJsonResponse(429, { error: 'rate limited' }, { 'retry-after': '50' })
)
.mockResolvedValueOnce(makeJsonResponse(200, { ok: true })),
{ preconnect: vi.fn() }
) as typeof fetch
const result = await executeTool('http_request', {
url: '/api/test',
method: 'GET',
retries: 3,
retryMaxDelayMs: 40000,
})
expect(global.fetch).toHaveBeenCalledTimes(1)
expect(result.success).toBe(false)
})
it('retries when Retry-After header is within maxDelayMs', async () => {
global.fetch = Object.assign(
vi
+19 -36
View File
@@ -1,7 +1,7 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { randomFloat } from '@sim/utils/random'
import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry'
import { getBYOKKey } from '@/lib/api-key/byok'
import { generateInternalToken } from '@/lib/auth/internal'
import { isHosted } from '@/lib/core/config/env-flags'
@@ -475,7 +475,7 @@ async function executeWithRetry<T>(
throw error
}
const delayMs = baseDelayMs * 2 ** attempt
const delayMs = backoffWithJitter(attempt + 1, null, { baseMs: baseDelayMs })
// Track throttling event via telemetry
PlatformEvents.hostedKeyRateLimited({
@@ -1486,26 +1486,6 @@ function isRetryableFailure(error: unknown, status?: number): boolean {
return false
}
function calculateBackoff(attempt: number, initialDelayMs: number, maxDelayMs: number): number {
const base = Math.min(initialDelayMs * 2 ** attempt, maxDelayMs)
return Math.round(base / 2 + randomFloat() * (base / 2))
}
function parseRetryAfterHeader(header: string | null): number {
if (!header) return 0
const trimmed = header.trim()
if (/^\d+$/.test(trimmed)) {
const seconds = Number.parseInt(trimmed, 10)
return seconds > 0 ? seconds * 1000 : 0
}
const date = new Date(trimmed)
if (!Number.isNaN(date.getTime())) {
const deltaMs = date.getTime() - Date.now()
return deltaMs > 0 ? deltaMs : 0
}
return 0
}
function shouldRetryWithoutReadingBody(
status: number,
headers: { get(name: string): string | null },
@@ -1515,7 +1495,10 @@ function shouldRetryWithoutReadingBody(
if (!retryConfig || isLastAttempt || !isRetryableFailure(null, status)) {
return false
}
return parseRetryAfterHeader(headers.get('retry-after')) <= retryConfig.maxDelayMs
return (
(parseRetryAfter(headers.get('retry-after'), Number.POSITIVE_INFINITY) ?? 0) <=
retryConfig.maxDelayMs
)
}
/**
@@ -1742,11 +1725,10 @@ async function executeToolRequest(
if (!retryConfig || isLastAttempt || !isRetryableFailure(error)) {
throw error
}
const delayMs = calculateBackoff(
attempt,
retryConfig.initialDelayMs,
retryConfig.maxDelayMs
)
const delayMs = backoffWithJitter(attempt + 1, null, {
baseMs: retryConfig.initialDelayMs,
maxMs: retryConfig.maxDelayMs,
})
logger.warn(
`[${requestId}] Retrying ${toolId} after error (attempt ${attempt + 1}/${maxAttempts})`,
{ delayMs }
@@ -1762,8 +1744,11 @@ async function executeToolRequest(
!response.ok &&
isRetryableFailure(null, response.status)
) {
const retryAfterMs = parseRetryAfterHeader(response.headers.get('retry-after'))
if (retryAfterMs > retryConfig.maxDelayMs) {
const retryAfterMs = parseRetryAfter(
response.headers.get('retry-after'),
Number.POSITIVE_INFINITY
)
if (retryAfterMs !== null && retryAfterMs > retryConfig.maxDelayMs) {
logger.warn(
`[${requestId}] Retry-After (${retryAfterMs}ms) exceeds maxDelayMs (${retryConfig.maxDelayMs}ms), skipping retry`
)
@@ -1774,12 +1759,10 @@ async function executeToolRequest(
} catch {
// Ignore errors when consuming body
}
const backoffMs = calculateBackoff(
attempt,
retryConfig.initialDelayMs,
retryConfig.maxDelayMs
)
const delayMs = Math.max(backoffMs, retryAfterMs)
const delayMs = backoffWithJitter(attempt + 1, retryAfterMs, {
baseMs: retryConfig.initialDelayMs,
maxMs: retryConfig.maxDelayMs,
})
logger.warn(
`[${requestId}] Retrying ${toolId} after HTTP ${response.status} (attempt ${attempt + 1}/${maxAttempts})`,
{ delayMs }
+3 -2
View File
@@ -1,3 +1,4 @@
import { isRecordLike } from '@sim/utils/object'
import type { NotionUpdateBlockParams } from '@/tools/notion/types'
import { BLOCK_OUTPUT_PROPERTIES } from '@/tools/notion/types'
import type { ToolConfig } from '@/tools/types'
@@ -19,12 +20,12 @@ interface NotionUpdateBlockResponse {
function parseBlock(block: Record<string, any> | string): Record<string, any> {
if (typeof block === 'string') {
const parsed = JSON.parse(block)
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
if (!isRecordLike(parsed)) {
throw new Error('block must be a JSON object describing the block-type fields to update')
}
return parsed
}
if (typeof block === 'object' && block !== null && !Array.isArray(block)) return block
if (isRecordLike(block)) return block
throw new Error('block must be a JSON object describing the block-type fields to update')
}
+1
View File
@@ -419,6 +419,7 @@
"name": "@sim/logger",
"version": "0.1.0",
"dependencies": {
"@sim/utils": "workspace:*",
"chalk": "5.6.2",
},
"devDependencies": {
+1
View File
@@ -25,6 +25,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@sim/utils": "workspace:*",
"chalk": "5.6.2"
},
"devDependencies": {
+2 -1
View File
@@ -4,6 +4,7 @@
* Framework-agnostic logging utilities for the Sim platform.
* Provides standardized console logging with environment-aware configuration.
*/
import { filterUndefined } from '@sim/utils/object'
import chalk from 'chalk'
import { getRequestContext } from './request-context'
@@ -240,7 +241,7 @@ export class Logger {
...this.metadata,
}
: this.metadata
const metadataEntries = Object.entries(effectiveMetadata).filter(([_, v]) => v !== undefined)
const metadataEntries = Object.entries(filterUndefined(effectiveMetadata))
const metadataStr =
metadataEntries.length > 0
? ` {${metadataEntries.map(([k, v]) => `${k}=${v}`).join(' ')}}`
+5
View File
@@ -28,6 +28,11 @@ describe('getTimezoneAbbreviation', () => {
expect(getTimezoneAbbreviation('Unknown/Zone')).toBe('Unknown/Zone')
})
it('resolves a valid IANA timezone outside the hardcoded map via Intl instead of the raw string', () => {
const result = getTimezoneAbbreviation('Europe/Berlin', new Date('2023-01-15'))
expect(result).not.toBe('Europe/Berlin')
})
it('returns PST or PDT for Los Angeles', () => {
const result = getTimezoneAbbreviation('America/Los_Angeles', new Date('2023-01-15'))
expect(['PST', 'PDT']).toContain(result)
+9 -1
View File
@@ -48,7 +48,15 @@ export function getTimezoneAbbreviation(timezone: string, date: Date = new Date(
return timezoneMap[timezone].standard
}
return timezone
try {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
timeZoneName: 'short',
}).formatToParts(date)
return parts.find((p) => p.type === 'timeZoneName')?.value || timezone
} catch {
return timezone
}
}
/**
+9 -6
View File
@@ -32,28 +32,31 @@ export function backoffWithJitter(
return exponential * (0.8 + jitter * 0.4)
}
/** Maximum `Retry-After` value honored: 30 s. Prevents a misconfigured upstream from stalling callers. */
/** Default maximum `Retry-After` value honored: 30 s. Prevents a misconfigured upstream from stalling callers. */
const RETRY_AFTER_MAX_MS = 30_000
/**
* Parses an HTTP `Retry-After` header (either delta-seconds or an HTTP-date)
* into a millisecond delay, capped at 30 s.
* into a millisecond delay, capped at `maxMs` (default 30 s).
* Returns `null` when the header is absent or unparseable so callers can fall
* back to their own backoff.
* back to their own backoff. Pass the caller's own `maxDelayMs` as `maxMs` when
* that value needs to be compared against the parsed delay (e.g. to decide
* whether to skip a retry) otherwise the default cap silently truncates the
* comparison.
*/
export function parseRetryAfter(header: string | null): number | null {
export function parseRetryAfter(header: string | null, maxMs = RETRY_AFTER_MAX_MS): number | null {
if (!header) return null
const trimmed = header.trim()
if (trimmed.length === 0) return null
const seconds = Number(trimmed)
if (Number.isFinite(seconds) && seconds >= 0) {
return Math.min(Math.floor(seconds * 1000), RETRY_AFTER_MAX_MS)
return Math.min(Math.floor(seconds * 1000), maxMs)
}
const dateMs = Date.parse(trimmed)
if (!Number.isNaN(dateMs)) {
const delta = dateMs - Date.now()
if (delta <= 0) return 0
return Math.min(delta, RETRY_AFTER_MAX_MS)
return Math.min(delta, maxMs)
}
return null
}
@@ -1,3 +1,4 @@
import { filterUndefined } from '@sim/utils/object'
import type { BlockState, SubBlockState } from '@sim/workflow-types/workflow'
export const DEFAULT_SUBBLOCK_TYPE = 'short-input'
@@ -60,7 +61,7 @@ export function mergeSubblockStateWithValues(
const blockSubBlocks = block.subBlocks || {}
const blockValues = subBlockValues[id] || {}
const filteredValues = Object.fromEntries(
Object.entries(blockValues).filter(([, value]) => value !== null && value !== undefined)
Object.entries(filterUndefined(blockValues)).filter(([, value]) => value !== null)
)
const mergedSubBlocks = mergeSubBlockValues(blockSubBlocks, filteredValues) as Record<
+2 -1
View File
@@ -2,6 +2,7 @@
import { execSync } from 'node:child_process'
import { Octokit } from '@octokit/rest'
import { sleep } from '@sim/utils/helpers'
const GITHUB_TOKEN = process.env.GH_PAT
const REPO_OWNER = 'simstudioai'
@@ -139,7 +140,7 @@ async function fetchGitHubCommitDetails(
prNumber,
})
await new Promise((resolve) => setTimeout(resolve, 100))
await sleep(100)
} catch (error: any) {
console.warn(`⚠️ Could not fetch commit ${hash.substring(0, 7)}: ${error?.message || error}`)