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 @@ -7995,6 +7995,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 @@ -8041,6 +8046,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 @@ -8109,6 +8119,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 @@ -8174,6 +8189,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 @@ -8282,6 +8302,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
205 changes: 205 additions & 0 deletions apps/realtime/src/database/operations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
/** @vitest-environment node */
import {
BLOCK_OPERATIONS,
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 }),
delete: vi.fn(),
insert: vi.fn(),
}

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',
type: 'agent',
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)
})
})

describe('atomic tool reordering', () => {
const block = {
id: 'agent-1',
type: 'agent',
name: 'Agent',
position: { x: 0, y: 0 },
locked: false,
subBlocks: {
tools: { id: 'tools', type: 'tool-input', value: [{ usageControlExpression: 'force' }] },
},
data: {},
}

beforeEach(() => {
vi.clearAllMocks()
mockTransaction.mockImplementation(
async (callback: (tx: typeof transaction) => Promise<void>) => callback(transaction)
)
mockSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
mockSelectWhere.mockImplementation(() =>
Object.assign(
Promise.resolve([{ ...block, subBlocks: { tools: { value: [{ type: 'function' }] } } }]),
{
limit: async () => [
{ ...block, subBlocks: { tools: { value: [{ type: 'function' }] } } },
],
}
)
)
})

it('persists a reordered tool array and its mode map in one write', async () => {
const first = { type: 'function', usageControlExpression: 'auto' }
const second = { type: 'function', usageControlExpression: 'force' }
const original = {
...block,
subBlocks: { tools: { id: 'tools', type: 'tool-input', value: [first, second] } },
data: { canonicalModes: { '1:agentToolUsageControl': 'advanced' } },
}
mockSelectWhere.mockResolvedValue([original])
mockSet.mockReturnValue({
where: () =>
Object.assign(Promise.resolve(undefined), { returning: async () => [{ id: block.id }] }),
})
const subBlocks = { tools: { id: 'tools', type: 'tool-input', value: [second, first] } }
const canonicalModes = { '0:agentToolUsageControl': 'advanced' }
await expect(
persistWorkflowOperation('workflow-1', {
operation: BLOCK_OPERATIONS.REPLACE_CANONICAL_MODES,
target: OPERATION_TARGETS.BLOCK,
timestamp: Date.now(),
payload: { id: block.id, subBlocks, data: { canonicalModes } },
})
).resolves.toBeUndefined()
expect(mockSet).toHaveBeenLastCalledWith(
expect.objectContaining({ subBlocks, data: { canonicalModes } })
)
})

it('refuses an atomic tool update inside a locked container', async () => {
mockSelectWhere.mockResolvedValue([
{ ...block, data: { parentId: 'container' } },
{ id: 'container', type: 'loop', locked: true, data: {} },
])
await expect(
persistWorkflowOperation('workflow-1', {
operation: BLOCK_OPERATIONS.REPLACE_CANONICAL_MODES,
target: OPERATION_TARGETS.BLOCK,
timestamp: Date.now(),
payload: { id: block.id, subBlocks: block.subBlocks, data: { canonicalModes: {} } },
})
).rejects.toThrow('locked')
expect(mockSet).toHaveBeenCalledTimes(1)
})
})
33 changes: 24 additions & 9 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 @@ -819,17 +820,34 @@ async function handleBlockOperationTx(
throw new Error('Missing required fields for replace canonical modes operation')
}

const existingBlock = await tx
.select({ data: workflowBlocks.data })
const allBlocks = await tx
.select({
id: workflowBlocks.id,
locked: workflowBlocks.locked,
subBlocks: workflowBlocks.subBlocks,
data: workflowBlocks.data,
})
.from(workflowBlocks)
.where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId)))
.limit(1)
.where(eq(workflowBlocks.workflowId, workflowId))
const blocksById = Object.fromEntries(
allBlocks.map((block: { id: string; locked: boolean; data: Record<string, unknown> }) => [
block.id,
block,
])
)
if (isWorkflowBlockProtected(payload.id, blocksById)) {
throw new Error(`Block ${payload.id} is locked or inside a locked container`)
}
const existingBlock = allBlocks.filter((block: { id: string }) => block.id === payload.id)

const currentData = (existingBlock?.[0]?.data as Record<string, unknown>) || {}

const subBlocks = { ...(existingBlock[0]?.subBlocks || {}), ...(payload.subBlocks || {}) }

const updateResult = await tx
.update(workflowBlocks)
.set({
...(payload.subBlocks ? { subBlocks } : {}),
data: {
...currentData,
canonicalModes: payload.data.canonicalModes,
Expand Down Expand Up @@ -1988,10 +2006,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 +2053,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
Loading
Loading