Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .github/workflows/test-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ jobs:
- name: Tool request transport boundary audit
run: bun run check:tool-request-boundary

- name: Trigger/block initialization cycle audit
run: bun run check:trigger-block-cycle

- name: SQL Date binding audit
run: bun run check:sql-date-binding

Expand Down
118 changes: 118 additions & 0 deletions apps/sim/lib/workflows/triggers/mock-payload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* Mock payload generation from a trigger's `outputs` definition.
*
* Deliberately dependency-free. `@/triggers` imports this module, so anything reachable
* from here becomes reachable from the trigger barrel — and reaching `@/blocks` (directly,
* or via `trigger-utils`) recreates the `triggers` <-> `blocks` initialization cycle that
* `scripts/check-trigger-block-cycle.ts` guards against.
*/

/**
* Generates mock data based on the output type definition
*/
function generateMockValue(type: string, _description?: string, fieldName?: string): unknown {
const name = fieldName || 'value'

switch (type) {
case 'string':
return `mock_${name}`

case 'number':
return 42

case 'boolean':
return true

case 'array':
return [
{
id: 'item_1',
name: 'Sample Item',
value: 'Sample Value',
},
]

case 'json':
case 'object':
return {
id: 'sample_id',
name: 'Sample Object',
status: 'active',
}

default:
return null
}
}

/**
* Recursively processes nested output structures, expanding JSON-Schema-style
* objects/arrays that define `properties` or `items` instead of returning
* a generic placeholder.
*/
function processOutputField(key: string, field: unknown, depth = 0, maxDepth = 10): unknown {
if (depth > maxDepth) {
return null
}

if (
field &&
typeof field === 'object' &&
'type' in field &&
typeof (field as Record<string, unknown>).type === 'string'
) {
const typedField = field as {
type: string
description?: string
properties?: Record<string, unknown>
items?: unknown
}

if (
(typedField.type === 'object' || typedField.type === 'json') &&
typedField.properties &&
typeof typedField.properties === 'object'
) {
const nestedObject: Record<string, unknown> = {}
for (const [nestedKey, nestedField] of Object.entries(typedField.properties)) {
nestedObject[nestedKey] = processOutputField(nestedKey, nestedField, depth + 1, maxDepth)
}
return nestedObject
}

if (typedField.type === 'array' && typedField.items && typeof typedField.items === 'object') {
const itemValue = processOutputField(`${key}_item`, typedField.items, depth + 1, maxDepth)
return [itemValue]
}

return generateMockValue(typedField.type, typedField.description, key)
}

if (field && typeof field === 'object' && !Array.isArray(field)) {
const nestedObject: Record<string, unknown> = {}
for (const [nestedKey, nestedField] of Object.entries(field)) {
nestedObject[nestedKey] = processOutputField(nestedKey, nestedField, depth + 1, maxDepth)
}
return nestedObject
}

return null
}

/**
* Generates a mock payload based on outputs definition
*/
export function generateMockPayloadFromOutputsDefinition(
outputs: Record<string, unknown>
): Record<string, unknown> {
const mockPayload: Record<string, unknown> = {}

for (const [key, output] of Object.entries(outputs)) {
if (key === 'visualization') {
continue
}
mockPayload[key] = processOutputField(key, output)
}

return mockPayload
}
118 changes: 1 addition & 117 deletions apps/sim/lib/workflows/triggers/trigger-utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import { generateMockPayloadFromOutputsDefinition } from '@/lib/workflows/triggers/mock-payload'
import { type StartBlockCandidate, StartBlockPath } from '@/lib/workflows/triggers/triggers'
import { getAllBlocks, getBlock } from '@/blocks'
import type { BlockConfig } from '@/blocks/types'
Expand All @@ -25,123 +26,6 @@ export function hasValidStartBlockInState(state: WorkflowState | null | undefine
return !!startBlock
}

/**
* Generates mock data based on the output type definition
*/
function generateMockValue(type: string, _description?: string, fieldName?: string): unknown {
const name = fieldName || 'value'

switch (type) {
case 'string':
return `mock_${name}`

case 'number':
return 42

case 'boolean':
return true

case 'array':
return [
{
id: 'item_1',
name: 'Sample Item',
value: 'Sample Value',
},
]

case 'json':
case 'object':
return {
id: 'sample_id',
name: 'Sample Object',
status: 'active',
}

default:
return null
}
}

/**
* Recursively processes nested output structures, expanding JSON-Schema-style
* objects/arrays that define `properties` or `items` instead of returning
* a generic placeholder.
*/
function processOutputField(key: string, field: unknown, depth = 0, maxDepth = 10): unknown {
if (depth > maxDepth) {
return null
}

if (
field &&
typeof field === 'object' &&
'type' in field &&
typeof (field as Record<string, unknown>).type === 'string'
) {
const typedField = field as {
type: string
description?: string
properties?: Record<string, unknown>
items?: unknown
}

if (
(typedField.type === 'object' || typedField.type === 'json') &&
typedField.properties &&
typeof typedField.properties === 'object'
) {
const nestedObject: Record<string, unknown> = {}
for (const [nestedKey, nestedField] of Object.entries(typedField.properties)) {
nestedObject[nestedKey] = processOutputField(nestedKey, nestedField, depth + 1, maxDepth)
}
return nestedObject
}

if (typedField.type === 'array' && typedField.items && typeof typedField.items === 'object') {
const itemValue = processOutputField(`${key}_item`, typedField.items, depth + 1, maxDepth)
return [itemValue]
}

return generateMockValue(typedField.type, typedField.description, key)
}

if (field && typeof field === 'object' && !Array.isArray(field)) {
const nestedObject: Record<string, unknown> = {}
for (const [nestedKey, nestedField] of Object.entries(field)) {
nestedObject[nestedKey] = processOutputField(nestedKey, nestedField, depth + 1, maxDepth)
}
return nestedObject
}

return null
}

/**
* Generates mock payload from outputs object
*/
function generateMockPayloadFromOutputs(outputs: Record<string, unknown>): Record<string, unknown> {
const mockPayload: Record<string, unknown> = {}

for (const [key, output] of Object.entries(outputs)) {
if (key === 'visualization') {
continue
}
mockPayload[key] = processOutputField(key, output)
}

return mockPayload
}

/**
* Generates a mock payload based on outputs definition
*/
export function generateMockPayloadFromOutputsDefinition(
outputs: Record<string, unknown>
): Record<string, unknown> {
return generateMockPayloadFromOutputs(outputs)
}

interface TriggerInfo {
id: string
name: string
Expand Down
6 changes: 2 additions & 4 deletions apps/sim/triggers/clickup/subblocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,15 @@ import { createLogger } from '@sim/logger'
import { requestJson } from '@/lib/api/client/request'
import { clickupWorkspacesSelectorContract } from '@/lib/api/contracts/selectors/clickup'
import type { SubBlockConfig } from '@/blocks/types'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { clickupSetupInstructions } from '@/triggers/clickup/utils'
import { readSubBlockValue } from '@/triggers/editor-state'

const logger = createLogger('ClickUpTriggerSubBlocks')

async function fetchWorkspaceOptions(
blockId: string
): Promise<Array<{ id: string; label: string }>> {
const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as
| string
| null
const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as string | null
if (!credentialId) {
throw new Error('No ClickUp credential selected')
}
Expand Down
61 changes: 61 additions & 0 deletions apps/sim/triggers/editor-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Editor-state readers for trigger sub-block option resolvers.
*
* Trigger definitions are a definition layer: `@/triggers` must not reach `@/blocks`
* through a static import, because block configs spread `getTrigger(...).subBlocks` at
* module scope. A static edge makes the two barrels mutually recursive, and whichever
* one an entry point reaches first wins — enter through `@/triggers` and `getTrigger()`
* runs before `TRIGGER_REGISTRY` is initialized, throwing
* `ReferenceError: Cannot access 'TRIGGER_REGISTRY' before initialization`.
*
* The Zustand stores below sit on the far side of that edge (`subblock/store` imports
* `@/blocks`), so they are loaded with a dynamic `import()`. Dynamic imports resolve at
* call time rather than during module evaluation, so they carry no initialization-order
* obligation. Every caller is an editor-side `fetchOptions`/`fetchOptionById` resolver
* that already runs asynchronously, long after both registries are built.
*
* `scripts/check-trigger-block-cycle.ts` fails the build if a static edge reappears.
*/

/** The value the user has entered for `subBlockId` on `blockId` in the open workflow. */
export async function readSubBlockValue(blockId: string, subBlockId: string): Promise<unknown> {
const { useSubBlockStore } = await import('@/stores/workflows/subblock/store')
return useSubBlockStore.getState().getValue(blockId, subBlockId)
}

/**
* Every stored sub-block value for `blockId`, for resolvers that read several fields at
* once. Returns `undefined` when the block has no stored values yet.
*/
export async function readBlockValues(
blockId: string
): Promise<Record<string, unknown> | undefined> {
const [{ useSubBlockStore }, { useWorkflowRegistry }] = await Promise.all([
import('@/stores/workflows/subblock/store'),
import('@/stores/workflows/registry/store'),
])
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId) return undefined
return useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[blockId]
}

/** The active workspace's workflows, for trigger sub-blocks that select other workflows. */
export async function readWorkspaceWorkflowOptions(options?: {
excludeActiveWorkflow?: boolean
}): Promise<Array<{ label: string; id: string }>> {
const { fetchWorkspaceWorkflowOptions } = await import('@/lib/workflows/subblocks/options')
return fetchWorkspaceWorkflowOptions(options)
}

/** The workflow and workspace the editor currently has open. */
export async function readActiveWorkflowContext(): Promise<{
activeWorkflowId: string | null
workspaceId: string | null
}> {
const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store')
const state = useWorkflowRegistry.getState()
return {
activeWorkflowId: state.activeWorkflowId,
workspaceId: state.hydration.workspaceId,
}
}
4 changes: 2 additions & 2 deletions apps/sim/triggers/gmail/poller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
import { GmailIcon } from '@/components/icons'
import { requestJson } from '@/lib/api/client/request'
import { gmailLabelsSelectorContract } from '@/lib/api/contracts/selectors/google'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { readSubBlockValue } from '@/triggers/editor-state'
import type { TriggerConfig } from '@/triggers/types'

const logger = createLogger('GmailPollingTrigger')
Expand Down Expand Up @@ -37,7 +37,7 @@ export const gmailPollingTrigger: TriggerConfig = {
required: false,
options: [], // Will be populated dynamically from user's Gmail labels
fetchOptions: async (blockId: string) => {
const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as
const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as
| string
| null
if (!credentialId) {
Expand Down
Loading
Loading