Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/docs/openapi-v2-workflows.json
Original file line number Diff line number Diff line change
Expand Up @@ -6388,6 +6388,11 @@
"enum": ["auto", "force", "none"],
"description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`."
},
"usageControlExpression": {
"type": "string",
"maxLength": 2048,
"description": "Variable-capable tool mode value used when the matching canonical mode is `advanced`. It must resolve to `auto`, `force`, or `none` at execution time."
},
"params": {
"type": "object",
"propertyNames": {
Expand Down Expand Up @@ -6434,6 +6439,11 @@
"type": "string",
"enum": ["auto", "force", "none"],
"description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`."
},
"usageControlExpression": {
"type": "string",
"maxLength": 2048,
"description": "Variable-capable tool mode value used when the matching canonical mode is `advanced`. It must resolve to `auto`, `force`, or `none` at execution time."
}
},
"required": ["type", "customToolId"],
Expand Down Expand Up @@ -6502,6 +6512,11 @@
"type": "string",
"enum": ["auto", "force", "none"],
"description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`."
},
"usageControlExpression": {
"type": "string",
"maxLength": 2048,
"description": "Variable-capable tool mode value used when the matching canonical mode is `advanced`. It must resolve to `auto`, `force`, or `none` at execution time."
}
},
"required": ["type", "schema", "code"],
Expand Down Expand Up @@ -6567,6 +6582,11 @@
"type": "string",
"enum": ["auto", "force", "none"],
"description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`."
},
"usageControlExpression": {
"type": "string",
"maxLength": 2048,
"description": "Variable-capable tool mode value used when the matching canonical mode is `advanced`. It must resolve to `auto`, `force`, or `none` at execution time."
}
},
"required": ["type", "params"],
Expand Down Expand Up @@ -6612,6 +6632,11 @@
"type": "string",
"enum": ["auto", "force", "none"],
"description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`."
},
"usageControlExpression": {
"type": "string",
"maxLength": 2048,
"description": "Variable-capable tool mode value used when the matching canonical mode is `advanced`. It must resolve to `auto`, `force`, or `none` at execution time."
}
},
"required": ["type", "params"],
Expand Down
120 changes: 120 additions & 0 deletions apps/realtime/src/database/operations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/** @vitest-environment node */
import { OPERATION_TARGETS, SUBBLOCK_OPERATIONS } from '@sim/realtime-protocol/constants'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockTransaction, mockSelectWhere, mockSet } = vi.hoisted(() => ({
mockTransaction: vi.fn(),
mockSelectWhere: vi.fn(),
mockSet: vi.fn(),
}))

vi.mock('@sim/audit', () => ({ AuditAction: {}, AuditResourceType: {}, recordAudit: vi.fn() }))
vi.mock('@sim/db', () => ({
instrumentPoolClient: vi.fn(),
resolveDbUrl: vi.fn(() => 'postgres://localhost/test'),
workflow: { id: 'workflow.id' },
workflowBlocks: { id: 'block.id', workflowId: 'block.workflowId' },
workflowEdges: {},
workflowSubflows: {},
}))
vi.mock('@sim/db/timestamps', () => ({ withUtcTimestamps: (options: unknown) => options }))
vi.mock('@sim/logger', () => ({
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}))
vi.mock('@sim/platform-authz/workflow', () => ({
getActiveWorkflowContext: vi.fn().mockResolvedValue({ id: 'workflow-1' }),
}))
vi.mock('@sim/workflow-persistence/load', () => ({
loadWorkflowFromNormalizedTablesRaw: vi.fn(),
}))
vi.mock('@sim/workflow-persistence/subblocks', () => ({ mergeSubBlockValues: vi.fn() }))
vi.mock('drizzle-orm', () => ({
and: vi.fn(),
eq: vi.fn(),
inArray: vi.fn(),
isNull: vi.fn(),
or: vi.fn(),
sql: vi.fn(),
}))
vi.mock('drizzle-orm/postgres-js', () => ({ drizzle: () => ({ transaction: mockTransaction }) }))
vi.mock('postgres', () => ({ default: vi.fn() }))
vi.mock('@/env', () => ({ env: { DATABASE_URL: 'postgres://localhost/test' } }))

import { persistWorkflowOperation } from '@/database/operations'

const transaction = {
select: () => ({ from: () => ({ where: mockSelectWhere }) }),
update: () => ({ set: mockSet }),
}

describe('search replacement persistence', () => {
const expected = [
{
type: 'function',
params: { language: 'javascript', code: 'return 1' },
usageControl: 'none',
usageControlExpression: 'auto',
},
]
const replacement = [{ ...expected[0], usageControlExpression: 'none' }]

beforeEach(() => {
vi.clearAllMocks()
mockTransaction.mockImplementation(
async (callback: (tx: typeof transaction) => Promise<void>) => callback(transaction)
)
mockSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
})

function replaceTools(stored: unknown, expectedValue: unknown = expected) {
mockSelectWhere.mockResolvedValue([
{
id: 'agent-1',
locked: false,
data: {},
subBlocks: { tools: { id: 'tools', type: 'tool-input', value: stored } },
},
])
return persistWorkflowOperation('workflow-1', {
operation: SUBBLOCK_OPERATIONS.BATCH_UPDATE,
target: OPERATION_TARGETS.SUBBLOCK,
timestamp: Date.now(),
payload: {
updates: [{ blockId: 'agent-1', subblockId: 'tools', value: replacement, expectedValue }],
},
})
}

it('accepts equivalent nested tool objects after JSONB changes their key order', async () => {
const stored = [
{
usageControlExpression: 'auto',
usageControl: 'none',
params: { code: 'return 1', language: 'javascript' },
type: 'function',
},
]

await expect(replaceTools(stored)).resolves.toBeUndefined()
expect(mockSet).toHaveBeenLastCalledWith(
expect.objectContaining({
subBlocks: { tools: { id: 'tools', type: 'tool-input', value: replacement } },
})
)
})

it('still rejects a permission expression changed by another editor', async () => {
await expect(
replaceTools([{ ...expected[0], usageControlExpression: 'force' }])
).rejects.toThrow('changed since replacement was planned')
expect(mockSet).toHaveBeenCalledTimes(1)
})

it('still rejects reordered tool arrays', async () => {
const another = { ...expected[0], usageControlExpression: 'force' }
await expect(replaceTools([another, expected[0]], [expected[0], another])).rejects.toThrow(
'changed since replacement was planned'
)
expect(mockSet).toHaveBeenCalledTimes(1)
})
})
8 changes: 3 additions & 5 deletions apps/realtime/src/database/operations.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isDeepStrictEqual } from 'node:util'
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import * as schema from '@sim/db'
import {
Expand Down Expand Up @@ -1988,10 +1989,6 @@ async function handleSubflowOperationTx(
}
}

function valuesEqual(left: unknown, right: unknown): boolean {
return JSON.stringify(left) === JSON.stringify(right)
}

// Subblock operations - targeted value updates without replacing workflow state
async function handleSubblockOperationTx(
tx: any,
Expand Down Expand Up @@ -2039,7 +2036,8 @@ async function handleSubblockOperationTx(
const subBlocks = { ...((block.subBlocks as Record<string, any>) || {}) }
const currentSubBlock = subBlocks[subblockId]
const currentValue = currentSubBlock?.value
if (expectedValue !== undefined && !valuesEqual(currentValue, expectedValue)) {
/** JSONB can reorder object keys; changed values and array order must still conflict. */
if (expectedValue !== undefined && !isDeepStrictEqual(currentValue, expectedValue)) {
throw new Error(`Subblock ${blockId}.${subblockId} changed since replacement was planned`)
}

Expand Down
1 change: 1 addition & 0 deletions apps/sim/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
# FORKING_ENABLED= # Workspace forks
# CREDENTIAL_GROUPS= # Enterprise managed OAuth collections
# TABLE_ROW_TTL= # Table TTL columns and expired-row cleanup
# AGENT_TOOL_PERMISSION_MODE=false # Variable-capable agent tool Permission Mode editor
# KNOWLEDGE_MEMBER_ACCESS= # Per-member knowledge connectors and hybrid-by-default retrieval
# ORGANIZATIONS_ENABLED= / NEXT_PUBLIC_ORGANIZATIONS_ENABLED= # Organizations only

Expand Down
5 changes: 4 additions & 1 deletion apps/sim/app/workspace/[workspaceId]/home/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
searchFilterParsers,
searchQueryParam,
} from '@/app/workspace/[workspaceId]/home/search-params'
import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
import { useFolders } from '@/hooks/queries/folders'
import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats'
import { useWorkflows } from '@/hooks/queries/workflows'
Expand Down Expand Up @@ -181,6 +182,7 @@ export function Home({ chatId, userName, userId }: HomeProps) {
[setSearchQueryParam, setSearchFilters]
)
const memberAccessAvailable = useMemberAccessAvailable()
const permissionModeEnabled = useFeatureFlag('agent-tool-permission-mode')
const [composerMode, setComposerMode] = useMothershipMode()
const hasCheckedLandingStorageRef = useRef(false)
const initialViewInputRef = useRef<HTMLDivElement>(null)
Expand All @@ -196,6 +198,7 @@ export function Home({ chatId, userName, userId }: HomeProps) {
content: seed.workflowJson,
filename: `${seed.workflowName}.json`,
workspaceId,
agentToolPermissionModeEnabled: permissionModeEnabled,
nameOverride: seed.workflowName,
descriptionOverride: seed.workflowDescription || undefined,
createWorkflow: async ({ name, description, workspaceId }) => {
Expand All @@ -222,7 +225,7 @@ export function Home({ chatId, userName, userId }: HomeProps) {
logger.error('Error creating workflow from landing workflow seed:', error)
}
},
[workspaceId]
[workspaceId, permissionModeEnabled]
)

useEffect(() => {
Expand Down
38 changes: 23 additions & 15 deletions apps/sim/app/workspace/[workspaceId]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { getActiveOrganizationId } from '@/lib/auth/session-response'
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
import { isTableRowTtlEnabled } from '@/lib/table/ttl-availability'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner'
Expand Down Expand Up @@ -47,25 +48,32 @@ export default async function WorkspaceLayout({
}

const activeOrganizationId = getActiveOrganizationId(session)
const [cookieStore, initialOrgSettings, , tableRowTtlEnabled] = await Promise.all([
cookies(),
hostContext.hostOrganizationId
? getOrgWhitelabelSettings(hostContext.hostOrganizationId)
: Promise.resolve(null),
prefetchWorkspaceSidebar(
queryClient,
workspaceId,
session.user.id,
hostContext,
activeOrganizationId
),
isTableRowTtlEnabled(),
])
const [cookieStore, initialOrgSettings, , tableRowTtlEnabled, agentToolPermissionModeEnabled] =
await Promise.all([
cookies(),
hostContext.hostOrganizationId
? getOrgWhitelabelSettings(hostContext.hostOrganizationId)
: Promise.resolve(null),
prefetchWorkspaceSidebar(
queryClient,
workspaceId,
session.user.id,
hostContext,
activeOrganizationId
),
isTableRowTtlEnabled(),
isFeatureEnabled('agent-tool-permission-mode'),
])
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'

return (
<HydrationBoundary state={dehydrate(queryClient)}>
<FeatureFlagsProvider flags={{ 'table-row-ttl': tableRowTtlEnabled }}>
<FeatureFlagsProvider
flags={{
'table-row-ttl': tableRowTtlEnabled,
'agent-tool-permission-mode': agentToolPermissionModeEnabled,
}}
>
<WorkspaceHostProvider workspaceId={workspaceId} initialContext={hostContext}>
<BrandingProvider
hostOrganizationId={hostContext.hostOrganizationId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { createContext, type ReactNode, useContext } from 'react'

export interface WorkspaceFeatureFlags {
'agent-tool-permission-mode': boolean
'table-row-ttl': boolean
}

Expand Down
Loading
Loading