Skip to content

Commit 664de15

Browse files
committed
Merge remote-tracking branch 'origin/staging' into fix/secrets-sanitization-trace-spans
2 parents 9754d5f + 0bcf64a commit 664de15

48 files changed

Lines changed: 1789 additions & 345 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ jobs:
252252
echo "ERROR: DEV_TRIGGER_ACCESS_TOKEN and TRIGGER_PROJECT_ID repo secrets must both be set" >&2
253253
exit 1
254254
fi
255-
bunx trigger.dev@4.4.3 deploy --env preview --branch dev-sim
255+
bunx trigger.dev@4.5.7 deploy --env preview --branch dev-sim
256256
257257
# Main/staging: build AMD64 images and push sha-tagged images to ECR + GHCR.
258258
# Runs in parallel with tests — only immutable sha tags are pushed here, and

apps/sim/app/(landing)/integrations/(shell)/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import {
66
type FAQItem,
77
INTEGRATIONS,
88
type Integration,
9-
POPULAR_WORKFLOWS,
109
toIntegrationSummary,
1110
} from '@/lib/integrations'
11+
import { POPULAR_WORKFLOWS } from '@/lib/integrations/popular-workflows'
1212
import { withFilteredNoindex } from '@/lib/landing/seo'
1313
import { JsonLd } from '@/app/(landing)/components/json-ld'
1414
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'

apps/sim/app/api/emails/preview/route.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
renderPasswordResetEmail,
1212
renderPaymentFailedEmail,
1313
renderPlanWelcomeEmail,
14+
renderScheduleDisabledEmail,
15+
renderUsageLimitReachedEmail,
1416
renderUsageThresholdEmail,
1517
renderWelcomeEmail,
1618
renderWorkspaceInvitationEmail,
@@ -93,6 +95,43 @@ const emailTemplates = {
9395
billingPortalUrl: 'https://sim.ai/settings/billing',
9496
failureReason: 'Card declined',
9597
}),
98+
'usage-limit-reached': () =>
99+
renderUsageLimitReachedEmail({
100+
userName: 'John',
101+
planName: 'Pro',
102+
scope: 'user',
103+
currentUsage: 20,
104+
limit: 20,
105+
ctaLink: 'https://sim.ai/settings/billing',
106+
}),
107+
'usage-limit-reached-org': () =>
108+
renderUsageLimitReachedEmail({
109+
userName: 'John',
110+
planName: 'Team',
111+
scope: 'organization',
112+
currentUsage: 500,
113+
limit: 500,
114+
ctaLink: 'https://sim.ai/organization/org_123/settings/billing',
115+
}),
116+
117+
// Operational notification emails
118+
'schedule-disabled': () =>
119+
renderScheduleDisabledEmail({
120+
recipientName: 'John',
121+
kind: 'workflow',
122+
resourceName: 'Daily digest',
123+
reason: 'consecutive_failures',
124+
failedCount: 100,
125+
manageLink: 'https://sim.ai/workspace/ws_123/w/wf_456',
126+
}),
127+
'schedule-disabled-auth': () =>
128+
renderScheduleDisabledEmail({
129+
recipientName: 'John',
130+
kind: 'job',
131+
resourceName: 'Weekly report',
132+
reason: 'authentication_error',
133+
manageLink: 'https://sim.ai/workspace/ws_123/scheduled-tasks',
134+
}),
96135
} as const
97136

98137
type EmailTemplate = keyof typeof emailTemplates
@@ -122,7 +161,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
122161
'plan-welcome-team',
123162
'credit-purchase',
124163
'payment-failed',
164+
'usage-limit-reached',
165+
'usage-limit-reached-org',
125166
],
167+
Notifications: ['schedule-disabled', 'schedule-disabled-auth'],
126168
}
127169

128170
const categoryHtml = Object.entries(categories)

apps/sim/app/api/schedules/execute/route.test.ts

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ const {
3030
mockShouldExecuteInline,
3131
mockResolveSystemBillingAttribution,
3232
mockAssertBillingAttributionSnapshot,
33+
mockApplyScheduleFailureUpdate,
34+
mockNotifyScheduleAutoDisabled,
3335
} = vi.hoisted(() => ({
3436
mockVerifyCronAuth: vi.fn().mockReturnValue(null),
3537
mockExecuteScheduleJob: vi.fn().mockResolvedValue(undefined),
@@ -44,6 +46,8 @@ const {
4446
mockShouldExecuteInline: vi.fn().mockReturnValue(false),
4547
mockResolveSystemBillingAttribution: vi.fn(),
4648
mockAssertBillingAttributionSnapshot: vi.fn(),
49+
mockApplyScheduleFailureUpdate: vi.fn().mockResolvedValue({ updated: true, disabled: false }),
50+
mockNotifyScheduleAutoDisabled: vi.fn().mockResolvedValue(undefined),
4751
}))
4852

4953
vi.mock('@/lib/auth/internal', () => ({
@@ -59,15 +63,11 @@ vi.mock('@/background/schedule-execution', () => ({
5963
executeScheduleJob: mockExecuteScheduleJob,
6064
executeJobInline: mockExecuteJobInline,
6165
releaseScheduleLock: mockReleaseScheduleLock,
62-
buildScheduleFailureUpdate: (now: Date, nextRunAt: Date | null) => ({
63-
updatedAt: now,
64-
lastQueuedAt: null,
65-
nextRunAt,
66-
failedCount: { type: 'sql' },
67-
lastFailedAt: now,
68-
status: { type: 'sql' },
69-
infraRetryCount: 0,
70-
}),
66+
applyScheduleFailureUpdate: mockApplyScheduleFailureUpdate,
67+
}))
68+
69+
vi.mock('@/lib/workflows/schedules/disable-notifications', () => ({
70+
notifyScheduleAutoDisabled: mockNotifyScheduleAutoDisabled,
7171
}))
7272

7373
vi.mock('@/lib/core/async-jobs', () => ({
@@ -730,11 +730,11 @@ describe('Scheduled Workflow Execution API Route', () => {
730730
error: expect.stringContaining('exhausted retry attempts'),
731731
})
732732
)
733-
expect(dbChainMockFns.set).toHaveBeenCalledWith(
733+
expect(mockApplyScheduleFailureUpdate).toHaveBeenCalledWith(
734734
expect.objectContaining({
735-
lastQueuedAt: null,
736-
lastFailedAt: expect.any(Date),
737-
nextRunAt: expect.any(Date),
735+
scheduleId: 'schedule-1',
736+
expectedLastQueuedAt: claimedAt,
737+
executor: expect.anything(),
738738
})
739739
)
740740
})
@@ -779,12 +779,11 @@ describe('Scheduled Workflow Execution API Route', () => {
779779

780780
await runScheduleTick('test-request-id')
781781
expect(mockEnqueue).not.toHaveBeenCalled()
782-
expect(dbChainMockFns.set).toHaveBeenCalledWith(
782+
expect(mockApplyScheduleFailureUpdate).toHaveBeenCalledWith(
783783
expect.objectContaining({
784-
lastQueuedAt: null,
785-
lastFailedAt: expect.any(Date),
784+
scheduleId: schedule.id,
785+
expectedLastQueuedAt: claimedAt,
786786
nextRunAt: expect.any(Date),
787-
infraRetryCount: 0,
788787
})
789788
)
790789
expect(dbChainMockFns.set).not.toHaveBeenCalledWith(
@@ -794,6 +793,44 @@ describe('Scheduled Workflow Execution API Route', () => {
794793
)
795794
})
796795

796+
it('emails the schedule owners when a non-retryable setup failure disables the schedule', async () => {
797+
const claimedAt = new Date('2025-01-01T00:00:00.000Z')
798+
const schedule = {
799+
...SINGLE_SCHEDULE[0],
800+
lastQueuedAt: claimedAt,
801+
}
802+
mockGetJob.mockRejectedValueOnce(new Error('bad setup invariant'))
803+
mockApplyScheduleFailureUpdate.mockResolvedValueOnce({ updated: true, disabled: true })
804+
dbChainMockFns.limit
805+
.mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS)
806+
.mockResolvedValueOnce([])
807+
dbChainMockFns.returning.mockReturnValueOnce([schedule]).mockReturnValueOnce([])
808+
809+
await runScheduleTick('test-request-id')
810+
811+
expect(mockNotifyScheduleAutoDisabled).toHaveBeenCalledWith(
812+
expect.objectContaining({ scheduleId: schedule.id, reason: 'consecutive_failures' })
813+
)
814+
})
815+
816+
it('does not email when the failure update leaves the schedule active', async () => {
817+
const claimedAt = new Date('2025-01-01T00:00:00.000Z')
818+
const schedule = {
819+
...SINGLE_SCHEDULE[0],
820+
lastQueuedAt: claimedAt,
821+
}
822+
mockGetJob.mockRejectedValueOnce(new Error('bad setup invariant'))
823+
mockApplyScheduleFailureUpdate.mockResolvedValueOnce({ updated: true, disabled: false })
824+
dbChainMockFns.limit
825+
.mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS)
826+
.mockResolvedValueOnce([])
827+
dbChainMockFns.returning.mockReturnValueOnce([schedule]).mockReturnValueOnce([])
828+
829+
await runScheduleTick('test-request-id')
830+
831+
expect(mockNotifyScheduleAutoDisabled).not.toHaveBeenCalled()
832+
})
833+
797834
it('uses one backend mode decision for slot accounting and schedule processing', async () => {
798835
mockShouldExecuteInline.mockReturnValue(true)
799836
dbChainMockFns.limit

apps/sim/app/api/schedules/execute/route.ts

Lines changed: 57 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
2222
import { runDetached } from '@/lib/core/utils/background'
2323
import { generateRequestId } from '@/lib/core/utils/request'
2424
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
25+
import { notifyScheduleAutoDisabled } from '@/lib/workflows/schedules/disable-notifications'
2526
import {
2627
SCHEDULE_EXECUTION_CONCURRENCY_LIMIT,
2728
SCHEDULE_EXECUTION_QUEUE_NAME,
@@ -31,7 +32,7 @@ import {
3132
} from '@/lib/workflows/schedules/execution-limits'
3233
import { calculateScheduleInfraRetryDelayMs } from '@/lib/workflows/schedules/retry'
3334
import {
34-
buildScheduleFailureUpdate,
35+
applyScheduleFailureUpdate,
3536
executeJobInline,
3637
executeScheduleJob,
3738
releaseScheduleLock,
@@ -43,6 +44,12 @@ export const maxDuration = 3600
4344

4445
const logger = createLogger('ScheduledExecuteAPI')
4546
const WORKFLOW_CHUNK_SIZE = 100
47+
/**
48+
* Recovery sweeps a batch of up to `STALE_SCHEDULE_RECOVERY_BATCH_SIZE` schedules,
49+
* each fanning out to every workspace admin. Cap the mail so one tick can't turn
50+
* into hundreds of inline sends; the remainder is logged.
51+
*/
52+
const STALE_SCHEDULE_RECOVERY_NOTIFY_LIMIT = 25
4653
const JOB_CHUNK_SIZE = 100
4754
const MAX_TICK_DURATION_MS = 3 * 60 * 1000
4855
const STALE_SCHEDULE_CLAIM_MS = getMaxExecutionTimeout()
@@ -394,20 +401,22 @@ async function markClaimedScheduleFailed(
394401
context: string
395402
): Promise<void> {
396403
const now = new Date()
397-
await db
398-
.update(workflowSchedule)
399-
.set(buildScheduleFailureUpdate(now, getScheduleNextRunAt(schedule, now)))
400-
.where(
401-
and(
402-
eq(workflowSchedule.id, schedule.id),
403-
isNull(workflowSchedule.archivedAt),
404-
eq(workflowSchedule.lastQueuedAt, expectedLastQueuedAt)
405-
)
406-
)
407-
.catch((error) => {
408-
logger.error(`[${requestId}] ${context}`, error)
409-
throw error
404+
const { disabled } = await applyScheduleFailureUpdate({
405+
scheduleId: schedule.id,
406+
now,
407+
nextRunAt: getScheduleNextRunAt(schedule, now),
408+
expectedLastQueuedAt,
409+
requestId,
410+
context,
411+
})
412+
413+
if (disabled) {
414+
await notifyScheduleAutoDisabled({
415+
scheduleId: schedule.id,
416+
reason: 'consecutive_failures',
417+
requestId,
410418
})
419+
}
411420
}
412421

413422
async function deferClaimedScheduleAfterQueueFailure(
@@ -491,6 +500,13 @@ async function handleClaimedScheduleSetupFailure(
491500
async function recoverStaleDatabaseScheduleJobs(now: Date): Promise<void> {
492501
const staleStartedBefore = getStaleScheduleExecutionCutoff(now)
493502

503+
/**
504+
* Collected inside the transaction, flushed after it commits. Emailing inside
505+
* would both notify about writes a rollback discards and issue pooled-client
506+
* reads while the transaction still holds row locks under the advisory lock.
507+
*/
508+
const disabledScheduleIds: string[] = []
509+
494510
await db.transaction(async (tx) => {
495511
const [lock] = await tx.execute<{ acquired: boolean }>(
496512
sql`SELECT pg_try_advisory_xact_lock(hashtextextended(${SCHEDULE_EXECUTION_QUEUE_NAME}, 0)) AS acquired`
@@ -539,16 +555,17 @@ async function recoverStaleDatabaseScheduleJobs(now: Date): Promise<void> {
539555
const claimedAt = getSchedulePayloadClaimedAt(payload)
540556
if (!payload || !claimedAt) continue
541557

542-
await tx
543-
.update(workflowSchedule)
544-
.set(buildScheduleFailureUpdate(now, getScheduleNextRunAt(payload, now)))
545-
.where(
546-
and(
547-
eq(workflowSchedule.id, payload.scheduleId),
548-
isNull(workflowSchedule.archivedAt),
549-
eq(workflowSchedule.lastQueuedAt, claimedAt)
550-
)
551-
)
558+
const { disabled } = await applyScheduleFailureUpdate({
559+
scheduleId: payload.scheduleId,
560+
now,
561+
nextRunAt: getScheduleNextRunAt(payload, now),
562+
expectedLastQueuedAt: claimedAt,
563+
requestId: 'stale-schedule-recovery',
564+
context: `Error updating schedule ${payload.scheduleId} after stale lease recovery`,
565+
executor: tx,
566+
})
567+
568+
if (disabled) disabledScheduleIds.push(payload.scheduleId)
552569
}
553570

554571
if (retryableRows.length > 0) {
@@ -568,6 +585,22 @@ async function recoverStaleDatabaseScheduleJobs(now: Date): Promise<void> {
568585
)
569586
}
570587
})
588+
589+
const notifiable = disabledScheduleIds.slice(0, STALE_SCHEDULE_RECOVERY_NOTIFY_LIMIT)
590+
if (disabledScheduleIds.length > notifiable.length) {
591+
logger.warn('Capped schedule auto-disable notifications for stale recovery batch', {
592+
disabled: disabledScheduleIds.length,
593+
notified: notifiable.length,
594+
})
595+
}
596+
597+
for (const scheduleId of notifiable) {
598+
await notifyScheduleAutoDisabled({
599+
scheduleId,
600+
reason: 'consecutive_failures',
601+
requestId: 'stale-schedule-recovery',
602+
})
603+
}
571604
}
572605

573606
function isStaleDatabaseScheduleJob(job: { status: string; startedAt?: Date }): boolean {

apps/sim/app/upgrade/page.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { redirect } from 'next/navigation'
2+
import { getSession } from '@/lib/auth'
3+
import { isUpgradeReason, UPGRADE_REASON_PARAM } from '@/lib/billing/upgrade-reasons'
4+
5+
/**
6+
* Public upgrade entry, for callers that cannot know a workspace id — a
7+
* self-hosted deployment linking its users to the hosted plans, or an email
8+
* that predates a workspace switch.
9+
*
10+
* Workspace resolution is not repeated here: `/workspace` already owns it,
11+
* including local recency, last-active fallback, stale-session recovery, and
12+
* the no-workspace creation policy.
13+
*/
14+
export default async function UpgradePage({
15+
searchParams,
16+
}: {
17+
searchParams: Promise<Record<string, string | string[] | undefined>>
18+
}) {
19+
const [session, params] = await Promise.all([getSession(), searchParams])
20+
21+
const rawReason = params[UPGRADE_REASON_PARAM]
22+
const reasonValue = Array.isArray(rawReason) ? rawReason[0] : rawReason
23+
const reason = isUpgradeReason(reasonValue) ? reasonValue : undefined
24+
25+
const target = reason
26+
? `/workspace?redirect=upgrade&${UPGRADE_REASON_PARAM}=${reason}`
27+
: '/workspace?redirect=upgrade'
28+
29+
// `/workspace` recovers a signed-out visitor by hard-navigating to `/login`
30+
// with no callback, which would drop the upgrade intent — carry it here
31+
// instead, where the destination is still known.
32+
if (!session?.user) {
33+
redirect(`/login?callbackUrl=${encodeURIComponent(target)}`)
34+
}
35+
36+
redirect(target)
37+
}

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { cn } from '@sim/emcn'
33
import type { ReactNodeViewProps } from '@tiptap/react'
44
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
55
import { useParams, useRouter } from 'next/navigation'
6-
import { getBareIconStyle, type StyleableIcon } from '@/blocks/icon-color'
6+
import { getBareIconStyle, type StyleableIcon } from '@/blocks/brand-icon-style'
77
import { mentionIcon } from './mention-icon'
88
import { MarkdownMention, type MentionAttrs } from './mention-node'
99
import { simLinkPath } from './sim-link'

apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
import { AgentSkillsIcon, McpIcon } from '@/components/icons'
1414
import { getDocumentIcon } from '@/components/icons/document-icons'
1515
import type { ChatContextKind, ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types'
16-
import { getBareIconStyle } from '@/blocks/icon-color'
16+
import { getBareIconStyle } from '@/blocks/brand-icon-style'
1717
import { getBlockRegistry } from '@/blocks/registry'
1818

1919
interface RenderIconArgs {

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
import { getReadTargetBlock } from '@/lib/copilot/tools/client/read-block'
1313
import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args'
1414
import { getToolStatusDisplayTitle, getWaitCountdownTitle } from '@/lib/copilot/tools/tool-display'
15-
import { getBareIconStyle } from '@/blocks/icon-color'
15+
import { getBareIconStyle } from '@/blocks/brand-icon-style'
1616
import { getBlockByToolName } from '@/blocks/registry'
1717
import type { ToolCallStatus } from '../../../../types'
1818
import { resolveToolDisplayState } from '../../utils'

0 commit comments

Comments
 (0)