Skip to content

Commit 524b7d5

Browse files
committed
perf improvements
2 parents e6b7b92 + 7798e83 commit 524b7d5

141 files changed

Lines changed: 28908 additions & 1171 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.

apps/docs/content/docs/en/platform/enterprise/self-hosted.mdx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ ENTERPRISE_ENABLED=true
2323
NEXT_PUBLIC_ENTERPRISE_ENABLED=true
2424
```
2525

26-
That turns on organizations, permission groups, SSO, whitelabeling, audit logs, session policies, data retention, data drains, workspace forks, and the inbox.
26+
That turns on organizations, permission groups, SSO, whitelabeling, audit logs, session policies, data retention, data drains, workspace forks, sandboxes, and the inbox.
2727

2828
### Turning one feature off
2929

@@ -51,6 +51,9 @@ The individual flags also work on their own if you would rather opt in one at a
5151
| Data drains | `DATA_DRAINS_ENABLED` | `NEXT_PUBLIC_DATA_DRAINS_ENABLED` |
5252
| Workspace forks | `FORKING_ENABLED` ||
5353
| Sim Mailer inbox | `INBOX_ENABLED` | `NEXT_PUBLIC_INBOX_ENABLED` |
54+
| Sandboxes | `SANDBOXES_ENABLED` | `NEXT_PUBLIC_SANDBOXES_ENABLED` |
55+
56+
Sandboxes also need a remote execution provider, since the deployment builds the images itself: set `E2B_API_KEY`, or `SANDBOX_PROVIDER=daytona` with a `DAYTONA_API_KEY` that has `write:snapshots` and `write:sandboxes`. Without one the settings section appears but builds fail.
5457

5558
<Callout type="warning">
5659
Data retention is the one feature that deletes data. Its flag controls the cleanup pass, not the settings screen — retention windows are always configurable. Nothing is ever deleted until you enable it, and even then only against windows you configured explicitly. Sim never applies the hosted plan defaults to a self-hosted deployment.

apps/docs/content/docs/en/workflows/blocks/function.mdx

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,82 @@ Beyond the Python standard library, the sandbox ships E2B's data-science stack p
7474
- **Math and testing:** `sympy`, `pytest`
7575
- **Sim additions:** `awscli`, `yq`, `csvkit`
7676

77+
## Sandboxes
78+
79+
A sandbox is a named dependency set your workspace maintains — a language plus a
80+
list of pip or npm packages. Select one on a Function block and its code can
81+
import everything on that list. Leave it empty and the block runs on the default
82+
image, exactly as before.
83+
84+
Create and edit sandboxes in **SettingsSandboxes**. Only workspace admins can
85+
create or edit them. On sim.ai they need an active Max or Enterprise plan;
86+
self-hosted deployments turn them on with `SANDBOXES_ENABLED` (see
87+
[self-hosted enterprise](/platform/enterprise/self-hosted)). The section is
88+
hidden when a deployment has no sandbox provider configured.
89+
90+
1. **Name** the sandbox`bigquery-etl`, `scraping`, whatever the job is.
91+
2. Pick the **language**. A sandbox is language-scoped, so a Python block only
92+
ever lists Python sandboxes.
93+
3. Paste your **dependencies**, one per line. Version pins are optional.
94+
95+
```
96+
google-cloud-bigquery==3.25.0
97+
pyairtable>=3.0
98+
pandas
99+
```
100+
101+
Then open the block's advanced options and choose the sandbox under **Sandbox**.
102+
103+
In JavaScript the sandbox applies to code that uses `import` or `require`that is
104+
what sends the block to a remote sandbox in the first place, so a block without them
105+
keeps running locally and ignores the selection. Python always runs remotely, so a
106+
selected sandbox always applies.
107+
108+
<Callout type="info">
109+
Two sandboxes with the same language and the same package list share one build,
110+
so duplicating a set costs nothing. Editing a package list starts a new build;
111+
runs already in flight keep using the old one. Deleting a sandbox frees its build
112+
once nothing else uses it.
113+
</Callout>
114+
115+
### Build status
116+
117+
On sim.ai, each dependency set is prebuilt into a reusable image, so runs pay no
118+
install cost. The status row in Settings shows **Queued**, **Building**,
119+
**Ready**, or **Failed**. A failed build reports what went wronga package that
120+
does not exist, a version that has no match, a resolver conflictwith the
121+
installer log behind a disclosure.
122+
123+
Running a block before its sandbox is **Ready** stops the run and shows you the
124+
status. A failed build is retried periodically on its own; to retry immediately,
125+
save the sandbox again in Settings.
126+
127+
On a self-hosted deployment using Daytona, dependencies install inside the
128+
sandbox at the start of every run instead, adding roughly 10–30 seconds per
129+
execution. Prebuilt images require E2B.
130+
131+
### What is allowed
132+
133+
Package names and version specifiers only. URLs, `git+` references, `-e`, local
134+
paths, `--index-url`, and npm aliases are rejected, with the offending line
135+
number reported. A sandbox may declare up to 50 packages.
136+
137+
## Scoping secrets for agent tools
138+
139+
When a Function block is used as an Agent tool, its code can read every workspace
140+
secret by defaultboth `{{MY_SECRET}}` and `environmentVariables['MY_SECRET']`.
141+
142+
To narrow that, set **Secret access** to *Selected secrets* in the block's
143+
tool configuration and pick the names the code may read. Two things change:
144+
145+
- Only those secrets are injected. `{{OTHER_SECRET}}` no longer resolves either.
146+
- The selected **names** are added to the tool's description, so the model knows
147+
what it can reference. Values are never sent to the modelthey are injected
148+
server-side at execution.
149+
150+
Leaving the default (*All secrets*) resolves the list at run time, so a secret
151+
added next month is included automatically.
152+
77153
## Examples
78154

79155
### Reshape an API response
@@ -170,6 +246,6 @@ The lazy `sim.files` and `sim.values` helpers are available only in JavaScript f
170246
{ question: "When does code run locally vs. in a sandbox?", answer: "JavaScript without external imports runs in a local isolated sandbox for speed. JavaScript that uses import or require runs in E2B. Python always runs in the E2B sandbox, with or without imports." },
171247
{ question: "How do I reference outputs from other blocks inside my code?", answer: "Use angle-bracket syntax directly, like <agent.content> or <api.data>, with no quotes around the tag — Sim replaces it with the real value before execution. For environment variables, use double curly braces: {{API_KEY}}." },
172248
{ question: "What does the Function block return?", answer: "Two outputs: result (the return value of your code, read as <function.result>) and stdout (anything logged with console.log or print, read as <function.stdout>). Include a return statement in JavaScript, or print JSON in Python, to pass data downstream." },
173-
{ question: "Can I make HTTP requests from a Function block?", answer: "Yes. fetch() is available in JavaScript with async/await; libraries like axios are not, only the built-in fetch. In Python, use requests or httpx in the E2B sandbox." },
249+
{ question: "Can I make HTTP requests from a Function block?", answer: "Yes. fetch() is available in JavaScript with async/await; libraries like axios are only available when the block has a sandbox selected. In Python, use requests or httpx in the E2B sandbox." },
174250
{ question: "Is there a timeout for Function block execution?", answer: "Yes, a configurable execution timeout. If your code exceeds it, the run is terminated and the block reports an error. Keep this in mind for external calls or heavy processing." },
175251
]} />

apps/sim/app/api/credentials/route.test.ts

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,24 @@
33
*
44
* @vitest-environment node
55
*/
6-
import { auditMock, authMockFns, createMockRequest, posthogServerMock } from '@sim/testing'
6+
import {
7+
auditMock,
8+
authMockFns,
9+
createMockRequest,
10+
dbChainMockFns,
11+
posthogServerMock,
12+
resetDbChainMock,
13+
} from '@sim/testing'
714
import { beforeEach, describe, expect, it, vi } from 'vitest'
815
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
916

1017
const {
1118
mockCheckWorkspaceAccess,
12-
mockGetWorkspaceMembership,
19+
mockGetCredentialCreationWorkspaceContext,
1320
mockVerifyAndBuildServiceAccountSecret,
1421
} = vi.hoisted(() => ({
1522
mockCheckWorkspaceAccess: vi.fn(),
16-
mockGetWorkspaceMembership: vi.fn(),
23+
mockGetCredentialCreationWorkspaceContext: vi.fn(),
1724
mockVerifyAndBuildServiceAccountSecret: vi.fn(),
1825
}))
1926

@@ -25,7 +32,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
2532
}))
2633

2734
vi.mock('@/lib/credentials/environment', () => ({
28-
getWorkspaceMembership: mockGetWorkspaceMembership,
35+
getCredentialCreationWorkspaceContext: mockGetCredentialCreationWorkspaceContext,
2936
}))
3037

3138
vi.mock('@/lib/credentials/oauth', () => ({
@@ -52,6 +59,7 @@ const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555'
5259
describe('POST /api/credentials', () => {
5360
beforeEach(() => {
5461
vi.clearAllMocks()
62+
resetDbChainMock()
5563
authMockFns.mockGetSession.mockResolvedValue({
5664
user: { id: 'user-1', name: 'Test User', email: 'test@example.com' },
5765
})
@@ -60,7 +68,12 @@ describe('POST /api/credentials', () => {
6068
canWrite: true,
6169
canAdmin: true,
6270
})
63-
mockGetWorkspaceMembership.mockResolvedValue({ ownerId: 'user-1', memberUserIds: ['user-1'] })
71+
mockGetCredentialCreationWorkspaceContext.mockResolvedValue({
72+
ownerId: 'user-1',
73+
organizationId: 'org-1',
74+
memberUserIds: ['user-1'],
75+
canWrite: true,
76+
})
6477
})
6578

6679
describe('client-credential service accounts', () => {
@@ -156,5 +169,42 @@ describe('POST /api/credentials', () => {
156169
expect(data.error).toContain('clientSecret is required')
157170
expect(mockVerifyAndBuildServiceAccountSecret).not.toHaveBeenCalled()
158171
})
172+
173+
it('re-authorizes a personal credential after the shared org/user locks', async () => {
174+
mockGetCredentialCreationWorkspaceContext
175+
.mockResolvedValueOnce({
176+
ownerId: 'user-1',
177+
organizationId: 'org-1',
178+
memberUserIds: ['user-1'],
179+
canWrite: true,
180+
})
181+
.mockResolvedValueOnce({
182+
ownerId: 'org-owner',
183+
organizationId: 'org-1',
184+
memberUserIds: ['org-owner'],
185+
canWrite: false,
186+
})
187+
188+
const req = createMockRequest('POST', {
189+
workspaceId: WORKSPACE_ID,
190+
type: 'env_personal',
191+
envKey: 'MY_API_KEY',
192+
})
193+
194+
const response = await POST(req)
195+
const data = await response.json()
196+
197+
expect(response.status).toBe(403)
198+
expect(data).toEqual({ error: 'Write permission required' })
199+
expect(mockGetCredentialCreationWorkspaceContext).toHaveBeenCalledTimes(2)
200+
expect(dbChainMockFns.execute).toHaveBeenCalled()
201+
expect(mockGetCredentialCreationWorkspaceContext.mock.invocationCallOrder[0]).toBeLessThan(
202+
dbChainMockFns.execute.mock.invocationCallOrder[0]
203+
)
204+
expect(dbChainMockFns.execute.mock.invocationCallOrder.at(-1)).toBeLessThan(
205+
mockGetCredentialCreationWorkspaceContext.mock.invocationCallOrder[1]
206+
)
207+
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
208+
})
159209
})
160210
})

apps/sim/app/api/credentials/route.ts

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from '@/lib/api/contracts/credentials'
1414
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
1515
import { getSession } from '@/lib/auth'
16+
import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership'
1617
import { generateRequestId } from '@/lib/core/utils/request'
1718
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1819
import {
@@ -21,7 +22,7 @@ import {
2122
SHARED_CREDENTIAL_TYPES,
2223
} from '@/lib/credentials/access'
2324
import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account'
24-
import { getWorkspaceMembership } from '@/lib/credentials/environment'
25+
import { getCredentialCreationWorkspaceContext } from '@/lib/credentials/environment'
2526
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
2627
import {
2728
ServiceAccountSecretError,
@@ -509,10 +510,52 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
509510
resolvedProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID && clientCredentialId
510511
? clientCredentialId
511512
: generateId()
512-
const { ownerId: workspaceOwnerId, memberUserIds: workspaceMemberUserIds } =
513-
await getWorkspaceMembership(workspaceId)
514513

515-
await db.transaction(async (tx) => {
514+
const creationResult = await db.transaction(async (tx) => {
515+
/**
516+
* Discover the organization lock scope inside this transaction, then
517+
* acquire the same organization → user → membership locks as org
518+
* removal/transfer and re-authorize from the transaction before writing.
519+
*
520+
* If this insert wins, transfer sees the new source-owned personal
521+
* credential and blocks. If transfer wins, its permission/member cleanup
522+
* is visible to the authoritative re-read below and the insert is
523+
* refused.
524+
*/
525+
const plannedContext = await getCredentialCreationWorkspaceContext({
526+
executor: tx,
527+
workspaceId,
528+
userId: session.user.id,
529+
})
530+
if (!plannedContext) {
531+
return { success: false as const, status: 403 as const, error: 'Write permission required' }
532+
}
533+
534+
await acquireOrganizationUserMutationLocks(tx, {
535+
userId: session.user.id,
536+
organizationIds: plannedContext.organizationId ? [plannedContext.organizationId] : [],
537+
})
538+
539+
const currentContext = await getCredentialCreationWorkspaceContext({
540+
executor: tx,
541+
workspaceId,
542+
userId: session.user.id,
543+
forUpdate: true,
544+
})
545+
if (!currentContext) {
546+
return { success: false as const, status: 403 as const, error: 'Write permission required' }
547+
}
548+
if (currentContext.organizationId !== plannedContext.organizationId) {
549+
return {
550+
success: false as const,
551+
status: 409 as const,
552+
error: 'Workspace organization changed while creating the credential. Please retry.',
553+
}
554+
}
555+
if (!currentContext.canWrite) {
556+
return { success: false as const, status: 403 as const, error: 'Write permission required' }
557+
}
558+
516559
// service_account has no DB-level unique index on (workspaceId, providerId,
517560
// displayName), so we re-check inside the tx. OAuth/env_* are guarded by
518561
// partial unique indexes and fall through to the 23505 handler below.
@@ -542,9 +585,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
542585
updatedAt: now,
543586
})
544587

545-
if ((type === 'env_workspace' || type === 'service_account') && workspaceOwnerId) {
546-
if (workspaceMemberUserIds.length > 0) {
547-
for (const memberUserId of workspaceMemberUserIds) {
588+
if ((type === 'env_workspace' || type === 'service_account') && currentContext.ownerId) {
589+
if (currentContext.memberUserIds.length > 0) {
590+
for (const memberUserId of currentContext.memberUserIds) {
548591
const isAdmin = memberUserId === session.user.id
549592
await tx.insert(credentialMember).values({
550593
id: generateId(),
@@ -572,7 +615,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
572615
updatedAt: now,
573616
})
574617
}
618+
619+
return { success: true as const }
575620
})
621+
if (!creationResult.success) {
622+
return NextResponse.json({ error: creationResult.error }, { status: creationResult.status })
623+
}
576624

577625
const [created] = await db
578626
.select()
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { verifyCronAuth } from '@/lib/auth/internal'
5+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
6+
import { runCleanupSandboxImages } from '@/background/cleanup-sandbox-images'
7+
8+
export const dynamic = 'force-dynamic'
9+
10+
const logger = createLogger('CleanupSandboxImagesAPI')
11+
12+
/**
13+
* Retention sweep for prebuilt sandbox images. A runtime-strategy deployment has
14+
* nothing to collect, so the sweep is a no-op there rather than a special case
15+
* here.
16+
*/
17+
export const GET = withRouteHandler(async (request: NextRequest) => {
18+
const authError = verifyCronAuth(request, 'sandbox image cleanup')
19+
if (authError) return authError
20+
21+
try {
22+
const result = await runCleanupSandboxImages()
23+
return NextResponse.json({ success: true, ...result })
24+
} catch (error) {
25+
logger.error('Failed to sweep sandbox images', { error })
26+
return NextResponse.json(
27+
{ error: getErrorMessage(error, 'Failed to sweep sandbox images') },
28+
{ status: 500 }
29+
)
30+
}
31+
})

0 commit comments

Comments
 (0)