Skip to content

Commit b04fee8

Browse files
BillLeoutsakosvl346Bill Leoutsakoswaleedlatif1
authored
fix(deployment): prevent trigger registry initialization crash (#6342)
* fix(deployment): initialize block registry before triggers * fix(triggers): break the triggers <-> blocks initialization cycle Replaces the import-order guard from the previous commit with the structural fix. Block configs spread `getTrigger('...').subBlocks` while their module body runs, so `blocks/*` depends on `triggers/*` by design. Thirteen edges closed the loop back the other way, which made module evaluation order load-bearing: enter the graph through `@/triggers` and a block config calls `getTrigger()` before `TRIGGER_REGISTRY` is initialized, throwing ReferenceError: Cannot access 'TRIGGER_REGISTRY' before initialization Eleven deployment routes crashed on import: `POST /api/workflows/[id]/deploy`, the v1 public and admin deploy/rollback/activate routes, both deployment-version routes, and the three custom-tool deployment routes. All of them funnel through `lib/webhooks/deploy.ts`, which stayed safe only because it imported a value from `@/blocks` — biome sorts that above `@/triggers`, so the safe barrel always evaluated first. #6272 deleted that import as unused cleanup and took the whole surface with it. The reverse edges came from two places, both layering violations rather than anything inherent to triggers: - `triggers/index.ts` imported the mock-payload generator from `trigger-utils`, which imports `@/blocks` for unrelated helpers. The generator is pure, so it moves to `lib/workflows/triggers/mock-payload.ts` and both callers import it there. - Eleven trigger modules statically imported the editor's Zustand stores to read sub-block values inside `fetchOptions`/`fetchOptionById`. Those reads now go through `triggers/editor-state.ts`, which loads the stores with a dynamic `import()` — resolved when the resolver is called, not during module evaluation, so it carries no initialization-order obligation. Side effect: `@/triggers` drops from 744 statically reachable modules to 526. The block registry, the workflow Zustand stores and their React Query graph are no longer pulled into every server module that imports a trigger. `scripts/check-trigger-block-cycle.ts` fails the build if a static edge returns, and reports the shortest offending chain. The existing suite could not have caught this — `deploy.test.ts` mocks both `@/blocks/registry` and `@/triggers`, and `vitest.setup.ts` mocks `@/blocks/registry` globally, so it passed 18/18 against the broken code. --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local> Co-authored-by: Waleed Latif <walif6@gmail.com>
1 parent a4973ec commit b04fee8

18 files changed

Lines changed: 391 additions & 196 deletions

File tree

.github/workflows/test-build.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,9 @@ jobs:
159159
- name: Tool request transport boundary audit
160160
run: bun run check:tool-request-boundary
161161

162+
- name: Trigger/block initialization cycle audit
163+
run: bun run check:trigger-block-cycle
164+
162165
- name: SQL Date binding audit
163166
run: bun run check:sql-date-binding
164167

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* Mock payload generation from a trigger's `outputs` definition.
3+
*
4+
* Deliberately dependency-free. `@/triggers` imports this module, so anything reachable
5+
* from here becomes reachable from the trigger barrel — and reaching `@/blocks` (directly,
6+
* or via `trigger-utils`) recreates the `triggers` <-> `blocks` initialization cycle that
7+
* `scripts/check-trigger-block-cycle.ts` guards against.
8+
*/
9+
10+
/**
11+
* Generates mock data based on the output type definition
12+
*/
13+
function generateMockValue(type: string, _description?: string, fieldName?: string): unknown {
14+
const name = fieldName || 'value'
15+
16+
switch (type) {
17+
case 'string':
18+
return `mock_${name}`
19+
20+
case 'number':
21+
return 42
22+
23+
case 'boolean':
24+
return true
25+
26+
case 'array':
27+
return [
28+
{
29+
id: 'item_1',
30+
name: 'Sample Item',
31+
value: 'Sample Value',
32+
},
33+
]
34+
35+
case 'json':
36+
case 'object':
37+
return {
38+
id: 'sample_id',
39+
name: 'Sample Object',
40+
status: 'active',
41+
}
42+
43+
default:
44+
return null
45+
}
46+
}
47+
48+
/**
49+
* Recursively processes nested output structures, expanding JSON-Schema-style
50+
* objects/arrays that define `properties` or `items` instead of returning
51+
* a generic placeholder.
52+
*/
53+
function processOutputField(key: string, field: unknown, depth = 0, maxDepth = 10): unknown {
54+
if (depth > maxDepth) {
55+
return null
56+
}
57+
58+
if (
59+
field &&
60+
typeof field === 'object' &&
61+
'type' in field &&
62+
typeof (field as Record<string, unknown>).type === 'string'
63+
) {
64+
const typedField = field as {
65+
type: string
66+
description?: string
67+
properties?: Record<string, unknown>
68+
items?: unknown
69+
}
70+
71+
if (
72+
(typedField.type === 'object' || typedField.type === 'json') &&
73+
typedField.properties &&
74+
typeof typedField.properties === 'object'
75+
) {
76+
const nestedObject: Record<string, unknown> = {}
77+
for (const [nestedKey, nestedField] of Object.entries(typedField.properties)) {
78+
nestedObject[nestedKey] = processOutputField(nestedKey, nestedField, depth + 1, maxDepth)
79+
}
80+
return nestedObject
81+
}
82+
83+
if (typedField.type === 'array' && typedField.items && typeof typedField.items === 'object') {
84+
const itemValue = processOutputField(`${key}_item`, typedField.items, depth + 1, maxDepth)
85+
return [itemValue]
86+
}
87+
88+
return generateMockValue(typedField.type, typedField.description, key)
89+
}
90+
91+
if (field && typeof field === 'object' && !Array.isArray(field)) {
92+
const nestedObject: Record<string, unknown> = {}
93+
for (const [nestedKey, nestedField] of Object.entries(field)) {
94+
nestedObject[nestedKey] = processOutputField(nestedKey, nestedField, depth + 1, maxDepth)
95+
}
96+
return nestedObject
97+
}
98+
99+
return null
100+
}
101+
102+
/**
103+
* Generates a mock payload based on outputs definition
104+
*/
105+
export function generateMockPayloadFromOutputsDefinition(
106+
outputs: Record<string, unknown>
107+
): Record<string, unknown> {
108+
const mockPayload: Record<string, unknown> = {}
109+
110+
for (const [key, output] of Object.entries(outputs)) {
111+
if (key === 'visualization') {
112+
continue
113+
}
114+
mockPayload[key] = processOutputField(key, output)
115+
}
116+
117+
return mockPayload
118+
}

apps/sim/lib/workflows/triggers/trigger-utils.ts

Lines changed: 1 addition & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import { toError } from '@sim/utils/errors'
33
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
4+
import { generateMockPayloadFromOutputsDefinition } from '@/lib/workflows/triggers/mock-payload'
45
import { type StartBlockCandidate, StartBlockPath } from '@/lib/workflows/triggers/triggers'
56
import { getAllBlocks, getBlock } from '@/blocks'
67
import type { BlockConfig } from '@/blocks/types'
@@ -25,123 +26,6 @@ export function hasValidStartBlockInState(state: WorkflowState | null | undefine
2526
return !!startBlock
2627
}
2728

28-
/**
29-
* Generates mock data based on the output type definition
30-
*/
31-
function generateMockValue(type: string, _description?: string, fieldName?: string): unknown {
32-
const name = fieldName || 'value'
33-
34-
switch (type) {
35-
case 'string':
36-
return `mock_${name}`
37-
38-
case 'number':
39-
return 42
40-
41-
case 'boolean':
42-
return true
43-
44-
case 'array':
45-
return [
46-
{
47-
id: 'item_1',
48-
name: 'Sample Item',
49-
value: 'Sample Value',
50-
},
51-
]
52-
53-
case 'json':
54-
case 'object':
55-
return {
56-
id: 'sample_id',
57-
name: 'Sample Object',
58-
status: 'active',
59-
}
60-
61-
default:
62-
return null
63-
}
64-
}
65-
66-
/**
67-
* Recursively processes nested output structures, expanding JSON-Schema-style
68-
* objects/arrays that define `properties` or `items` instead of returning
69-
* a generic placeholder.
70-
*/
71-
function processOutputField(key: string, field: unknown, depth = 0, maxDepth = 10): unknown {
72-
if (depth > maxDepth) {
73-
return null
74-
}
75-
76-
if (
77-
field &&
78-
typeof field === 'object' &&
79-
'type' in field &&
80-
typeof (field as Record<string, unknown>).type === 'string'
81-
) {
82-
const typedField = field as {
83-
type: string
84-
description?: string
85-
properties?: Record<string, unknown>
86-
items?: unknown
87-
}
88-
89-
if (
90-
(typedField.type === 'object' || typedField.type === 'json') &&
91-
typedField.properties &&
92-
typeof typedField.properties === 'object'
93-
) {
94-
const nestedObject: Record<string, unknown> = {}
95-
for (const [nestedKey, nestedField] of Object.entries(typedField.properties)) {
96-
nestedObject[nestedKey] = processOutputField(nestedKey, nestedField, depth + 1, maxDepth)
97-
}
98-
return nestedObject
99-
}
100-
101-
if (typedField.type === 'array' && typedField.items && typeof typedField.items === 'object') {
102-
const itemValue = processOutputField(`${key}_item`, typedField.items, depth + 1, maxDepth)
103-
return [itemValue]
104-
}
105-
106-
return generateMockValue(typedField.type, typedField.description, key)
107-
}
108-
109-
if (field && typeof field === 'object' && !Array.isArray(field)) {
110-
const nestedObject: Record<string, unknown> = {}
111-
for (const [nestedKey, nestedField] of Object.entries(field)) {
112-
nestedObject[nestedKey] = processOutputField(nestedKey, nestedField, depth + 1, maxDepth)
113-
}
114-
return nestedObject
115-
}
116-
117-
return null
118-
}
119-
120-
/**
121-
* Generates mock payload from outputs object
122-
*/
123-
function generateMockPayloadFromOutputs(outputs: Record<string, unknown>): Record<string, unknown> {
124-
const mockPayload: Record<string, unknown> = {}
125-
126-
for (const [key, output] of Object.entries(outputs)) {
127-
if (key === 'visualization') {
128-
continue
129-
}
130-
mockPayload[key] = processOutputField(key, output)
131-
}
132-
133-
return mockPayload
134-
}
135-
136-
/**
137-
* Generates a mock payload based on outputs definition
138-
*/
139-
export function generateMockPayloadFromOutputsDefinition(
140-
outputs: Record<string, unknown>
141-
): Record<string, unknown> {
142-
return generateMockPayloadFromOutputs(outputs)
143-
}
144-
14529
interface TriggerInfo {
14630
id: string
14731
name: string

apps/sim/triggers/clickup/subblocks.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,15 @@ import { createLogger } from '@sim/logger'
22
import { requestJson } from '@/lib/api/client/request'
33
import { clickupWorkspacesSelectorContract } from '@/lib/api/contracts/selectors/clickup'
44
import type { SubBlockConfig } from '@/blocks/types'
5-
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
65
import { clickupSetupInstructions } from '@/triggers/clickup/utils'
6+
import { readSubBlockValue } from '@/triggers/editor-state'
77

88
const logger = createLogger('ClickUpTriggerSubBlocks')
99

1010
async function fetchWorkspaceOptions(
1111
blockId: string
1212
): Promise<Array<{ id: string; label: string }>> {
13-
const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as
14-
| string
15-
| null
13+
const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as string | null
1614
if (!credentialId) {
1715
throw new Error('No ClickUp credential selected')
1816
}

apps/sim/triggers/editor-state.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* Editor-state readers for trigger sub-block option resolvers.
3+
*
4+
* Trigger definitions are a definition layer: `@/triggers` must not reach `@/blocks`
5+
* through a static import, because block configs spread `getTrigger(...).subBlocks` at
6+
* module scope. A static edge makes the two barrels mutually recursive, and whichever
7+
* one an entry point reaches first wins — enter through `@/triggers` and `getTrigger()`
8+
* runs before `TRIGGER_REGISTRY` is initialized, throwing
9+
* `ReferenceError: Cannot access 'TRIGGER_REGISTRY' before initialization`.
10+
*
11+
* The Zustand stores below sit on the far side of that edge (`subblock/store` imports
12+
* `@/blocks`), so they are loaded with a dynamic `import()`. Dynamic imports resolve at
13+
* call time rather than during module evaluation, so they carry no initialization-order
14+
* obligation. Every caller is an editor-side `fetchOptions`/`fetchOptionById` resolver
15+
* that already runs asynchronously, long after both registries are built.
16+
*
17+
* `scripts/check-trigger-block-cycle.ts` fails the build if a static edge reappears.
18+
*/
19+
20+
/** The value the user has entered for `subBlockId` on `blockId` in the open workflow. */
21+
export async function readSubBlockValue(blockId: string, subBlockId: string): Promise<unknown> {
22+
const { useSubBlockStore } = await import('@/stores/workflows/subblock/store')
23+
return useSubBlockStore.getState().getValue(blockId, subBlockId)
24+
}
25+
26+
/**
27+
* Every stored sub-block value for `blockId`, for resolvers that read several fields at
28+
* once. Returns `undefined` when the block has no stored values yet.
29+
*/
30+
export async function readBlockValues(
31+
blockId: string
32+
): Promise<Record<string, unknown> | undefined> {
33+
const [{ useSubBlockStore }, { useWorkflowRegistry }] = await Promise.all([
34+
import('@/stores/workflows/subblock/store'),
35+
import('@/stores/workflows/registry/store'),
36+
])
37+
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
38+
if (!activeWorkflowId) return undefined
39+
return useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[blockId]
40+
}
41+
42+
/** The active workspace's workflows, for trigger sub-blocks that select other workflows. */
43+
export async function readWorkspaceWorkflowOptions(options?: {
44+
excludeActiveWorkflow?: boolean
45+
}): Promise<Array<{ label: string; id: string }>> {
46+
const { fetchWorkspaceWorkflowOptions } = await import('@/lib/workflows/subblocks/options')
47+
return fetchWorkspaceWorkflowOptions(options)
48+
}
49+
50+
/** The workflow and workspace the editor currently has open. */
51+
export async function readActiveWorkflowContext(): Promise<{
52+
activeWorkflowId: string | null
53+
workspaceId: string | null
54+
}> {
55+
const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store')
56+
const state = useWorkflowRegistry.getState()
57+
return {
58+
activeWorkflowId: state.activeWorkflowId,
59+
workspaceId: state.hydration.workspaceId,
60+
}
61+
}

apps/sim/triggers/gmail/poller.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
22
import { GmailIcon } from '@/components/icons'
33
import { requestJson } from '@/lib/api/client/request'
44
import { gmailLabelsSelectorContract } from '@/lib/api/contracts/selectors/google'
5-
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
5+
import { readSubBlockValue } from '@/triggers/editor-state'
66
import type { TriggerConfig } from '@/triggers/types'
77

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

0 commit comments

Comments
 (0)