Compare commits

..

35 Commits

Author SHA1 Message Date
Waleed
e9c4251c1c v0.5.68: router block reasoning, executor improvements, variable resolution consolidation, helm updates (#2946)
* improvement(workflow-item): stabilize avatar layout and fix name truncation (#2939)

* improvement(workflow-item): stabilize avatar layout and fix name truncation

* fix(avatars): revert overflow bg to hardcoded color for contrast

* fix(executor): stop parallel execution when block errors (#2940)

* improvement(helm): add per-deployment extraVolumes support (#2942)

* fix(gmail): expose messageId field in read email block (#2943)

* fix(resolver): consolidate reference resolution  (#2941)

* fix(resolver): consolidate code to resolve references

* fix edge cases

* use already formatted error

* fix multi index

* fix backwards compat reachability

* handle backwards compatibility accurately

* use shared constant correctly

* feat(router): expose reasoning output in router v2 block (#2945)

* fix(copilot): always allow, credential masking (#2947)

* Fix always allow, credential validation

* Credential masking

* Autoload

* fix(executor): handle condition dead-end branches in loops (#2944)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
2026-01-22 13:48:15 -08:00
Waleed
cc2be33d6b v0.5.67: loading, password reset, ui improvements, helm updates (#2928)
* fix(zustand): updated to useShallow from deprecated createWithEqualityFn (#2919)

* fix(logger): use direct env access for webpack inlining (#2920)

* fix(notifications): text overflow with line-clamp (#2921)

* chore(helm): add env vars for Vertex AI, orgs, and telemetry (#2922)

* fix(auth): improve reset password flow and consolidate brand detection (#2924)

* fix(auth): improve reset password flow and consolidate brand detection

* fix(auth): set errorHandled for EMAIL_NOT_VERIFIED to prevent duplicate error

* fix(auth): clear success message on login errors

* chore(auth): fix import order per lint

* fix(action-bar): duplicate subflows with children (#2923)

* fix(action-bar): duplicate subflows with children

* fix(action-bar): add validateTriggerPaste for subflow duplicate

* fix(resolver): agent response format, input formats, root level (#2925)

* fix(resolvers): agent response format, input formats, root level

* fix response block initial seeding

* fix tests

* fix(messages-input): fix cursor alignment and auto-resize with overlay (#2926)

* fix(messages-input): fix cursor alignment and auto-resize with overlay

* fixed remaining zustand warnings

* fix(stores): remove dead code causing log spam on startup (#2927)

* fix(stores): remove dead code causing log spam on startup

* fix(stores): replace custom tools zustand store with react query cache

* improvement(ui): use BrandedButton and BrandedLink components (#2930)

- Refactor auth forms to use BrandedButton component
- Add BrandedLink component for changelog page
- Reduce code duplication in login, signup, reset-password forms
- Update star count default value

* fix(custom-tools): remove unsafe title fallback in getCustomTool (#2929)

* fix(custom-tools): remove unsafe title fallback in getCustomTool

* fix(custom-tools): restore title fallback in getCustomTool lookup

Custom tools are referenced by title (custom_${title}), not database ID.
The title fallback is required for client-side tool resolution to work.

* fix(null-bodies): empty bodies handling (#2931)

* fix(null-statuses): empty bodies handling

* address bugbot comment

* fix(token-refresh): microsoft, notion, x, linear (#2933)

* fix(microsoft): proactive refresh needed

* fix(x): missing token refresh flag

* notion and linear missing flag too

* address bugbot comment

* fix(auth): handle EMAIL_NOT_VERIFIED in onError callback (#2932)

* fix(auth): handle EMAIL_NOT_VERIFIED in onError callback

* refactor(auth): extract redirectToVerify helper to reduce duplication

* fix(workflow-selector): use dedicated selector for workflow dropdown (#2934)

* feat(workflow-block): preview (#2935)

* improvement(copilot): tool configs to show nested props (#2936)

* fix(auth): add genericOAuth providers to trustedProviders (#2937)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
2026-01-21 22:53:25 -08:00
Vikhyath Mondreti
45371e521e v0.5.66: external http requests fix, ring highlighting 2026-01-21 02:55:39 -08:00
Waleed
0ce0f98aa5 v0.5.65: gemini updates, textract integration, ui updates (#2909)
* fix(google): wrap primitive tool responses for Gemini API compatibility (#2900)

* fix(canonical): copilot path + update parent (#2901)

* fix(rss): add top-level title, link, pubDate fields to RSS trigger output (#2902)

* fix(rss): add top-level title, link, pubDate fields to RSS trigger output

* fix(imap): add top-level fields to IMAP trigger output

* improvement(browseruse): add profile id param (#2903)

* improvement(browseruse): add profile id param

* make request a stub since we have directExec

* improvement(executor): upgraded abort controller to handle aborts for loops and parallels (#2880)

* improvement(executor): upgraded abort controller to handle aborts for loops and parallels

* comments

* improvement(files): update execution for passing base64 strings (#2906)

* progress

* improvement(execution): update execution for passing base64 strings

* fix types

* cleanup comments

* path security vuln

* reject promise correctly

* fix redirect case

* remove proxy routes

* fix tests

* use ipaddr

* feat(tools): added textract, added v2 for mistral, updated tag dropdown (#2904)

* feat(tools): added textract

* cleanup

* ack pr comments

* reorder

* removed upload for textract async version

* fix additional fields dropdown in editor, update parser to leave validation to be done on the server

* added mistral v2, files v2, and finalized textract

* updated the rest of the old file patterns, updated mistral outputs for v2

* updated tag dropdown to parse non-operation fields as well

* updated extension finder

* cleanup

* added description for inputs to workflow

* use helper for internal route check

* fix tag dropdown merge conflict change

* remove duplicate code

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>

* fix(ui): change add inputs button to match output selector (#2907)

* fix(canvas): removed invite to workspace from canvas popover (#2908)

* fix(canvas): removed invite to workspace

* removed unused props

* fix(copilot): legacy tool display names (#2911)

* fix(a2a): canonical merge  (#2912)

* fix canonical merge

* fix empty array case

* fix(change-detection): copilot diffs have extra field (#2913)

* improvement(logs): improved logs ui bugs, added subflow disable UI (#2910)

* improvement(logs): improved logs ui bugs, added subflow disable UI

* added duplicate to action bar for subflows

* feat(broadcast): email v0.5 (#2905)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
2026-01-20 23:54:55 -08:00
Waleed
dff1c9d083 v0.5.64: unsubscribe, search improvements, metrics, additional SSO configuration 2026-01-20 00:34:11 -08:00
Vikhyath Mondreti
b09f683072 v0.5.63: ui and performance improvements, more google tools 2026-01-18 15:22:42 -08:00
Vikhyath Mondreti
a8bb0db660 v0.5.62: webhook bug fixes, seeding default subblock values, block selection fixes 2026-01-16 20:27:06 -08:00
Waleed
af82820a28 v0.5.61: webhook improvements, workflow controls, react query for deployment status, chat fixes, reducto and pulse OCR, linear fixes 2026-01-16 18:06:23 -08:00
Waleed
4372841797 v0.5.60: invitation flow improvements, chat fixes, a2a improvements, additional copilot actions 2026-01-15 00:02:18 -08:00
Waleed
5e8c843241 v0.5.59: a2a support, documentation 2026-01-13 13:21:21 -08:00
Waleed
7bf3d73ee6 v0.5.58: export folders, new tools, permissions groups enhancements 2026-01-13 00:56:59 -08:00
Vikhyath Mondreti
7ffc11a738 v0.5.57: subagents, context menu improvements, bug fixes 2026-01-11 11:38:40 -08:00
Waleed
be578e2ed7 v0.5.56: batch operations, access control and permission groups, billing fixes 2026-01-10 00:31:34 -08:00
Waleed
f415e5edc4 v0.5.55: polling groups, bedrock provider, devcontainer fixes, workflow preview enhancements 2026-01-08 23:36:56 -08:00
Waleed
13a6e6c3fa v0.5.54: seo, model blacklist, helm chart updates, fireflies integration, autoconnect improvements, billing fixes 2026-01-07 16:09:45 -08:00
Waleed
f5ab7f21ae v0.5.53: hotkey improvements, added redis fallback, fixes for workflow tool 2026-01-06 23:34:52 -08:00
Waleed
bfb6fffe38 v0.5.52: new port-based router block, combobox expression and variable support 2026-01-06 16:14:10 -08:00
Waleed
4fbec0a43f v0.5.51: triggers, kb, condition block improvements, supabase and grain integration updates 2026-01-06 14:26:46 -08:00
Waleed
585f5e365b v0.5.50: import improvements, ui upgrades, kb styling and performance improvements 2026-01-05 00:35:55 -08:00
Waleed
3792bdd252 v0.5.49: hitl improvements, new email styles, imap trigger, logs context menu (#2672)
* feat(logs-context-menu): consolidated logs utils and types, added logs record context menu (#2659)

* feat(email): welcome email; improvement(emails): ui/ux (#2658)

* feat(email): welcome email; improvement(emails): ui/ux

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

Co-authored-by: waleed <walif6@gmail.com>

* fix(logging): hitl + trigger dev crash protection (#2664)

* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture

* feat(imap): added support for imap trigger (#2663)

* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup

* feat(i18n): update translations (#2665)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* fix(grain): updated grain trigger to auto-establish trigger (#2666)

Co-authored-by: aadamgough <adam@sim.ai>

* feat(admin): routes to manage deployments (#2667)

* feat(admin): routes to manage deployments

* fix naming fo deployed by

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date (#2668)

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date

* removed unused params, cleaned up redundant utils

* improvement(invite): aligned styling (#2669)

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments

---------

Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: aadamgough <adam@sim.ai>
2026-01-03 13:19:18 -08:00
Waleed
eb5d1f3e5b v0.5.48: copy-paste workflow blocks, docs updates, mcp tool fixes 2025-12-31 18:00:04 -08:00
Waleed
54ab82c8dd v0.5.47: deploy workflow as mcp, kb chunks tokenizer, UI improvements, jira service management tools 2025-12-30 23:18:58 -08:00
Waleed
f895bf469b v0.5.46: build improvements, greptile, light mode improvements 2025-12-29 02:17:52 -08:00
Waleed
dd3209af06 v0.5.45: light mode fixes, realtime usage indicator, docker build improvements 2025-12-27 19:57:42 -08:00
Waleed
b6ba3b50a7 v0.5.44: keyboard shortcuts, autolayout, light mode, byok, testing improvements 2025-12-26 21:25:19 -08:00
Waleed
b304233062 v0.5.43: export logs, circleback, grain, vertex, code hygiene, schedule improvements 2025-12-23 19:19:18 -08:00
Vikhyath Mondreti
57e4b49bd6 v0.5.42: fix memory migration 2025-12-23 01:24:54 -08:00
Vikhyath Mondreti
e12dd204ed v0.5.41: memory fixes, copilot improvements, knowledgebase improvements, LLM providers standardization 2025-12-23 00:15:18 -08:00
Vikhyath Mondreti
3d9d9cbc54 v0.5.40: supabase ops to allow non-public schemas, jira uuid 2025-12-21 22:28:05 -08:00
Waleed
0f4ec962ad v0.5.39: notion, workflow variables fixes 2025-12-20 20:44:00 -08:00
Waleed
4827866f9a v0.5.38: snap to grid, copilot ux improvements, billing line items 2025-12-20 17:24:38 -08:00
Waleed
3e697d9ed9 v0.5.37: redaction utils consolidation, logs updates, autoconnect improvements, additional kb tag types 2025-12-19 22:31:55 -08:00
Martin Yankov
4431a1a484 fix(helm): add custom egress rules to realtime network policy (#2481)
The realtime service network policy was missing the custom egress rules section
that allows configuration of additional egress rules via values.yaml. This caused
the realtime pods to be unable to connect to external databases (e.g., PostgreSQL
on port 5432) when using external database configurations.

The app network policy already had this section, but the realtime network policy
was missing it, creating an inconsistency and preventing the realtime service
from accessing external databases configured via networkPolicy.egress values.

This fix adds the same custom egress rules template section to the realtime
network policy, matching the app network policy behavior and allowing users to
configure database connectivity via values.yaml.
2025-12-19 18:59:08 -08:00
Waleed
4d1a9a3f22 v0.5.36: hitl improvements, opengraph, slack fixes, one-click unsubscribe, auth checks, new db indexes 2025-12-19 01:27:49 -08:00
Vikhyath Mondreti
eb07a080fb v0.5.35: helm updates, copilot improvements, 404 for docs, salesforce fixes, subflow resize clamping 2025-12-18 16:23:19 -08:00
16 changed files with 133 additions and 10620 deletions

View File

@@ -1312,16 +1312,15 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
if (currentLoop && isLoopBlock) {
containingLoopBlockId = blockId
const loopType = currentLoop.loopType || 'for'
const contextualTags: string[] = ['index']
if (loopType === 'forEach') {
contextualTags.push('currentItem')
contextualTags.push('items')
}
const loopBlock = blocks[blockId]
if (loopBlock) {
const loopBlockName = loopBlock.name || loopBlock.type
const normalizedLoopName = normalizeName(loopBlockName)
const contextualTags: string[] = [`${normalizedLoopName}.index`]
if (loopType === 'forEach') {
contextualTags.push(`${normalizedLoopName}.currentItem`)
contextualTags.push(`${normalizedLoopName}.items`)
}
loopBlockGroup = {
blockName: loopBlockName,
@@ -1329,23 +1328,21 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
blockType: 'loop',
tags: contextualTags,
distance: 0,
isContextual: true,
}
}
} else if (containingLoop) {
const [loopId, loop] = containingLoop
containingLoopBlockId = loopId
const loopType = loop.loopType || 'for'
const contextualTags: string[] = ['index']
if (loopType === 'forEach') {
contextualTags.push('currentItem')
contextualTags.push('items')
}
const containingLoopBlock = blocks[loopId]
if (containingLoopBlock) {
const loopBlockName = containingLoopBlock.name || containingLoopBlock.type
const normalizedLoopName = normalizeName(loopBlockName)
const contextualTags: string[] = [`${normalizedLoopName}.index`]
if (loopType === 'forEach') {
contextualTags.push(`${normalizedLoopName}.currentItem`)
contextualTags.push(`${normalizedLoopName}.items`)
}
loopBlockGroup = {
blockName: loopBlockName,
@@ -1353,7 +1350,6 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
blockType: 'loop',
tags: contextualTags,
distance: 0,
isContextual: true,
}
}
}
@@ -1367,16 +1363,15 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const [parallelId, parallel] = containingParallel
containingParallelBlockId = parallelId
const parallelType = parallel.parallelType || 'count'
const contextualTags: string[] = ['index']
if (parallelType === 'collection') {
contextualTags.push('currentItem')
contextualTags.push('items')
}
const containingParallelBlock = blocks[parallelId]
if (containingParallelBlock) {
const parallelBlockName = containingParallelBlock.name || containingParallelBlock.type
const normalizedParallelName = normalizeName(parallelBlockName)
const contextualTags: string[] = [`${normalizedParallelName}.index`]
if (parallelType === 'collection') {
contextualTags.push(`${normalizedParallelName}.currentItem`)
contextualTags.push(`${normalizedParallelName}.items`)
}
parallelBlockGroup = {
blockName: parallelBlockName,
@@ -1384,7 +1379,6 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
blockType: 'parallel',
tags: contextualTags,
distance: 0,
isContextual: true,
}
}
}
@@ -1651,29 +1645,38 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const nestedBlockTagGroups: NestedBlockTagGroup[] = useMemo(() => {
return filteredBlockTagGroups.map((group: BlockTagGroup) => {
const normalizedBlockName = normalizeName(group.blockName)
// Handle loop/parallel contextual tags (index, currentItem, items)
const directTags: NestedTag[] = []
const tagsForTree: string[] = []
group.tags.forEach((tag: string) => {
const tagParts = tag.split('.')
if (tagParts.length === 1) {
// Loop/parallel contextual tags without block prefix
if (
(group.blockType === 'loop' || group.blockType === 'parallel') &&
tagParts.length === 1
) {
directTags.push({
key: tag,
display: tag,
fullTag: tag,
})
} else if (tagParts.length === 2) {
// Direct property like blockname.property
directTags.push({
key: tagParts[1],
display: tagParts[1],
fullTag: tag,
})
} else {
// Nested property - add to tree builder
tagsForTree.push(tag)
}
})
// Build recursive tree from nested tags
const nestedTags = [...directTags, ...buildNestedTagTree(tagsForTree, normalizedBlockName)]
return {
@@ -1797,19 +1800,13 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
processedTag = tag
}
} else if (
blockGroup?.isContextual &&
blockGroup &&
(blockGroup.blockType === 'loop' || blockGroup.blockType === 'parallel')
) {
const tagParts = tag.split('.')
if (tagParts.length === 1) {
processedTag = blockGroup.blockType
if (!tag.includes('.') && ['index', 'currentItem', 'items'].includes(tag)) {
processedTag = `${blockGroup.blockType}.${tag}`
} else {
const lastPart = tagParts[tagParts.length - 1]
if (['index', 'currentItem', 'items'].includes(lastPart)) {
processedTag = `${blockGroup.blockType}.${lastPart}`
} else {
processedTag = tag
}
processedTag = tag
}
}

View File

@@ -7,8 +7,6 @@ export interface BlockTagGroup {
blockType: string
tags: string[]
distance: number
/** True if this is a contextual group (loop/parallel iteration context available inside the subflow) */
isContextual?: boolean
}
/**

View File

@@ -120,12 +120,6 @@ export const SPECIAL_REFERENCE_PREFIXES = [
REFERENCE.PREFIX.VARIABLE,
] as const
export const RESERVED_BLOCK_NAMES = [
REFERENCE.PREFIX.LOOP,
REFERENCE.PREFIX.PARALLEL,
REFERENCE.PREFIX.VARIABLE,
] as const
export const LOOP_REFERENCE = {
ITERATION: 'iteration',
INDEX: 'index',

View File

@@ -1,7 +1,6 @@
import { loggerMock } from '@sim/testing'
import { describe, expect, it, vi } from 'vitest'
import type { LoopScope } from '@/executor/execution/state'
import { InvalidFieldError } from '@/executor/utils/block-reference'
import { LoopResolver } from './loop'
import type { ResolutionContext } from './reference'
@@ -63,12 +62,7 @@ function createTestContext(
describe('LoopResolver', () => {
describe('canResolve', () => {
it.concurrent('should return true for bare loop reference', () => {
const resolver = new LoopResolver(createTestWorkflow())
expect(resolver.canResolve('<loop>')).toBe(true)
})
it.concurrent('should return true for known loop properties', () => {
it.concurrent('should return true for loop references', () => {
const resolver = new LoopResolver(createTestWorkflow())
expect(resolver.canResolve('<loop.index>')).toBe(true)
expect(resolver.canResolve('<loop.iteration>')).toBe(true)
@@ -84,13 +78,6 @@ describe('LoopResolver', () => {
expect(resolver.canResolve('<loop.items.0>')).toBe(true)
})
it.concurrent('should return true for unknown loop properties (validates in resolve)', () => {
const resolver = new LoopResolver(createTestWorkflow())
expect(resolver.canResolve('<loop.results>')).toBe(true)
expect(resolver.canResolve('<loop.output>')).toBe(true)
expect(resolver.canResolve('<loop.unknownProperty>')).toBe(true)
})
it.concurrent('should return false for non-loop references', () => {
const resolver = new LoopResolver(createTestWorkflow())
expect(resolver.canResolve('<block.output>')).toBe(false)
@@ -194,34 +181,20 @@ describe('LoopResolver', () => {
})
describe('edge cases', () => {
it.concurrent('should return context object for bare loop reference', () => {
const resolver = new LoopResolver(createTestWorkflow())
const loopScope = createLoopScope({ iteration: 2, item: 'test', items: ['a', 'b', 'c'] })
const ctx = createTestContext('block-1', loopScope)
expect(resolver.resolve('<loop>', ctx)).toEqual({
index: 2,
currentItem: 'test',
items: ['a', 'b', 'c'],
})
})
it.concurrent('should return minimal context object for for-loop (no items)', () => {
const resolver = new LoopResolver(createTestWorkflow())
const loopScope = createLoopScope({ iteration: 5 })
const ctx = createTestContext('block-1', loopScope)
expect(resolver.resolve('<loop>', ctx)).toEqual({
index: 5,
})
})
it.concurrent('should throw InvalidFieldError for unknown loop property', () => {
it.concurrent('should return undefined for invalid loop reference (missing property)', () => {
const resolver = new LoopResolver(createTestWorkflow())
const loopScope = createLoopScope({ iteration: 0 })
const ctx = createTestContext('block-1', loopScope)
expect(() => resolver.resolve('<loop.unknownProperty>', ctx)).toThrow(InvalidFieldError)
expect(resolver.resolve('<loop>', ctx)).toBeUndefined()
})
it.concurrent('should return undefined for unknown loop property', () => {
const resolver = new LoopResolver(createTestWorkflow())
const loopScope = createLoopScope({ iteration: 0 })
const ctx = createTestContext('block-1', loopScope)
expect(resolver.resolve('<loop.unknownProperty>', ctx)).toBeUndefined()
})
it.concurrent('should handle iteration index 0 correctly', () => {

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { isReference, parseReferencePath, REFERENCE } from '@/executor/constants'
import { InvalidFieldError } from '@/executor/utils/block-reference'
import { extractBaseBlockId } from '@/executor/utils/subflow-utils'
import {
navigatePath,
@@ -14,8 +13,6 @@ const logger = createLogger('LoopResolver')
export class LoopResolver implements Resolver {
constructor(private workflow: SerializedWorkflow) {}
private static KNOWN_PROPERTIES = ['iteration', 'index', 'item', 'currentItem', 'items']
canResolve(reference: string): boolean {
if (!isReference(reference)) {
return false
@@ -30,15 +27,16 @@ export class LoopResolver implements Resolver {
resolve(reference: string, context: ResolutionContext): any {
const parts = parseReferencePath(reference)
if (parts.length === 0) {
logger.warn('Invalid loop reference', { reference })
if (parts.length < 2) {
logger.warn('Invalid loop reference - missing property', { reference })
return undefined
}
const loopId = this.findLoopForBlock(context.currentNodeId)
const [_, property, ...pathParts] = parts
let loopScope = context.loopScope
if (!loopScope) {
const loopId = this.findLoopForBlock(context.currentNodeId)
if (!loopId) {
return undefined
}
@@ -50,27 +48,6 @@ export class LoopResolver implements Resolver {
return undefined
}
const isForEach = loopId ? this.isForEachLoop(loopId) : loopScope.items !== undefined
if (parts.length === 1) {
const result: Record<string, any> = {
index: loopScope.iteration,
}
if (loopScope.item !== undefined) {
result.currentItem = loopScope.item
}
if (loopScope.items !== undefined) {
result.items = loopScope.items
}
return result
}
const [_, property, ...pathParts] = parts
if (!LoopResolver.KNOWN_PROPERTIES.includes(property)) {
const availableFields = isForEach ? ['index', 'currentItem', 'items'] : ['index']
throw new InvalidFieldError('loop', property, availableFields)
}
let value: any
switch (property) {
case 'iteration':
@@ -84,8 +61,12 @@ export class LoopResolver implements Resolver {
case 'items':
value = loopScope.items
break
default:
logger.warn('Unknown loop property', { property })
return undefined
}
// If there are additional path parts, navigate deeper
if (pathParts.length > 0) {
return navigatePath(value, pathParts)
}
@@ -104,9 +85,4 @@ export class LoopResolver implements Resolver {
return undefined
}
private isForEachLoop(loopId: string): boolean {
const loopConfig = this.workflow.loops?.[loopId]
return loopConfig?.loopType === 'forEach'
}
}

View File

@@ -1,6 +1,5 @@
import { loggerMock } from '@sim/testing'
import { describe, expect, it, vi } from 'vitest'
import { InvalidFieldError } from '@/executor/utils/block-reference'
import { ParallelResolver } from './parallel'
import type { ResolutionContext } from './reference'
@@ -82,12 +81,7 @@ function createTestContext(
describe('ParallelResolver', () => {
describe('canResolve', () => {
it.concurrent('should return true for bare parallel reference', () => {
const resolver = new ParallelResolver(createTestWorkflow())
expect(resolver.canResolve('<parallel>')).toBe(true)
})
it.concurrent('should return true for known parallel properties', () => {
it.concurrent('should return true for parallel references', () => {
const resolver = new ParallelResolver(createTestWorkflow())
expect(resolver.canResolve('<parallel.index>')).toBe(true)
expect(resolver.canResolve('<parallel.currentItem>')).toBe(true)
@@ -100,16 +94,6 @@ describe('ParallelResolver', () => {
expect(resolver.canResolve('<parallel.items.0>')).toBe(true)
})
it.concurrent(
'should return true for unknown parallel properties (validates in resolve)',
() => {
const resolver = new ParallelResolver(createTestWorkflow())
expect(resolver.canResolve('<parallel.results>')).toBe(true)
expect(resolver.canResolve('<parallel.output>')).toBe(true)
expect(resolver.canResolve('<parallel.unknownProperty>')).toBe(true)
}
)
it.concurrent('should return false for non-parallel references', () => {
const resolver = new ParallelResolver(createTestWorkflow())
expect(resolver.canResolve('<block.output>')).toBe(false)
@@ -270,40 +254,24 @@ describe('ParallelResolver', () => {
})
describe('edge cases', () => {
it.concurrent('should return context object for bare parallel reference', () => {
const workflow = createTestWorkflow({
'parallel-1': { nodes: ['block-1'], distribution: ['a', 'b', 'c'] },
})
const resolver = new ParallelResolver(workflow)
const ctx = createTestContext('block-1₍1₎')
it.concurrent(
'should return undefined for invalid parallel reference (missing property)',
() => {
const resolver = new ParallelResolver(createTestWorkflow())
const ctx = createTestContext('block-1₍0₎')
expect(resolver.resolve('<parallel>', ctx)).toEqual({
index: 1,
currentItem: 'b',
items: ['a', 'b', 'c'],
})
})
expect(resolver.resolve('<parallel>', ctx)).toBeUndefined()
}
)
it.concurrent('should return minimal context object when no distribution', () => {
const workflow = createTestWorkflow({
'parallel-1': { nodes: ['block-1'] },
})
const resolver = new ParallelResolver(workflow)
const ctx = createTestContext('block-1₍0₎')
const result = resolver.resolve('<parallel>', ctx)
expect(result).toHaveProperty('index', 0)
expect(result).toHaveProperty('items')
})
it.concurrent('should throw InvalidFieldError for unknown parallel property', () => {
it.concurrent('should return undefined for unknown parallel property', () => {
const workflow = createTestWorkflow({
'parallel-1': { nodes: ['block-1'], distribution: ['a'] },
})
const resolver = new ParallelResolver(workflow)
const ctx = createTestContext('block-1₍0₎')
expect(() => resolver.resolve('<parallel.unknownProperty>', ctx)).toThrow(InvalidFieldError)
expect(resolver.resolve('<parallel.unknownProperty>', ctx)).toBeUndefined()
})
it.concurrent('should return undefined when block is not in any parallel', () => {

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { isReference, parseReferencePath, REFERENCE } from '@/executor/constants'
import { InvalidFieldError } from '@/executor/utils/block-reference'
import { extractBaseBlockId, extractBranchIndex } from '@/executor/utils/subflow-utils'
import {
navigatePath,
@@ -14,8 +13,6 @@ const logger = createLogger('ParallelResolver')
export class ParallelResolver implements Resolver {
constructor(private workflow: SerializedWorkflow) {}
private static KNOWN_PROPERTIES = ['index', 'currentItem', 'items']
canResolve(reference: string): boolean {
if (!isReference(reference)) {
return false
@@ -30,11 +27,12 @@ export class ParallelResolver implements Resolver {
resolve(reference: string, context: ResolutionContext): any {
const parts = parseReferencePath(reference)
if (parts.length === 0) {
logger.warn('Invalid parallel reference', { reference })
if (parts.length < 2) {
logger.warn('Invalid parallel reference - missing property', { reference })
return undefined
}
const [_, property, ...pathParts] = parts
const parallelId = this.findParallelForBlock(context.currentNodeId)
if (!parallelId) {
return undefined
@@ -51,33 +49,11 @@ export class ParallelResolver implements Resolver {
return undefined
}
// First try to get items from the parallel scope (resolved at runtime)
// This is the same pattern as LoopResolver reading from loopScope.items
const parallelScope = context.executionContext.parallelExecutions?.get(parallelId)
const distributionItems = parallelScope?.items ?? this.getDistributionItems(parallelConfig)
if (parts.length === 1) {
const result: Record<string, any> = {
index: branchIndex,
}
if (distributionItems !== undefined) {
result.items = distributionItems
if (Array.isArray(distributionItems)) {
result.currentItem = distributionItems[branchIndex]
} else if (typeof distributionItems === 'object' && distributionItems !== null) {
const keys = Object.keys(distributionItems)
const key = keys[branchIndex]
result.currentItem = key !== undefined ? distributionItems[key] : undefined
}
}
return result
}
const [_, property, ...pathParts] = parts
if (!ParallelResolver.KNOWN_PROPERTIES.includes(property)) {
const isCollection = parallelConfig.parallelType === 'collection'
const availableFields = isCollection ? ['index', 'currentItem', 'items'] : ['index']
throw new InvalidFieldError('parallel', property, availableFields)
}
let value: any
switch (property) {
case 'index':
@@ -97,8 +73,12 @@ export class ParallelResolver implements Resolver {
case 'items':
value = distributionItems
break
default:
logger.warn('Unknown parallel property', { property })
return undefined
}
// If there are additional path parts, navigate deeper
if (pathParts.length > 0) {
return navigatePath(value, pathParts)
}

View File

@@ -5,7 +5,7 @@ import { useShallow } from 'zustand/react/shallow'
import { useSession } from '@/lib/auth/auth-client'
import { useSocket } from '@/app/workspace/providers/socket-provider'
import { getBlock } from '@/blocks'
import { normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants'
import { normalizeName } from '@/executor/constants'
import { useUndoRedo } from '@/hooks/use-undo-redo'
import {
BLOCK_OPERATIONS,
@@ -740,16 +740,6 @@ export function useCollaborativeWorkflow() {
return { success: false, error: 'Block name cannot be empty' }
}
if ((RESERVED_BLOCK_NAMES as readonly string[]).includes(normalizedNewName)) {
logger.error(`Cannot rename block to reserved name: "${trimmedName}"`)
useNotificationStore.getState().addNotification({
level: 'error',
message: `"${trimmedName}" is a reserved name and cannot be used`,
workflowId: activeWorkflowId || undefined,
})
return { success: false, error: `"${trimmedName}" is a reserved name` }
}
const currentBlocks = useWorkflowStore.getState().blocks
const conflictingBlock = Object.entries(currentBlocks).find(
([blockId, block]) => blockId !== id && normalizeName(block.name) === normalizedNewName

View File

@@ -14,7 +14,7 @@ import { validateWorkflowState } from '@/lib/workflows/sanitization/validation'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { getAllBlocks, getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { EDGE, normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants'
import { EDGE, normalizeName } from '@/executor/constants'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
@@ -63,7 +63,6 @@ type SkippedItemType =
| 'invalid_subflow_parent'
| 'nested_subflow_not_allowed'
| 'duplicate_block_name'
| 'reserved_block_name'
| 'duplicate_trigger'
| 'duplicate_single_instance_block'
@@ -1684,8 +1683,7 @@ function applyOperationsToWorkflowState(
}
}
if (params?.name !== undefined) {
const normalizedName = normalizeName(params.name)
if (!normalizedName) {
if (!normalizeName(params.name)) {
logSkippedItem(skippedItems, {
type: 'missing_required_params',
operationType: 'edit',
@@ -1693,14 +1691,6 @@ function applyOperationsToWorkflowState(
reason: `Cannot rename to empty name`,
details: { requestedName: params.name },
})
} else if ((RESERVED_BLOCK_NAMES as readonly string[]).includes(normalizedName)) {
logSkippedItem(skippedItems, {
type: 'reserved_block_name',
operationType: 'edit',
blockId: block_id,
reason: `Cannot rename to "${params.name}" - this is a reserved name`,
details: { requestedName: params.name },
})
} else {
const conflictingBlock = findBlockWithDuplicateNormalizedName(
modifiedState.blocks,
@@ -1921,8 +1911,7 @@ function applyOperationsToWorkflowState(
}
case 'add': {
const addNormalizedName = params?.name ? normalizeName(params.name) : ''
if (!params?.type || !params?.name || !addNormalizedName) {
if (!params?.type || !params?.name || !normalizeName(params.name)) {
logSkippedItem(skippedItems, {
type: 'missing_required_params',
operationType: 'add',
@@ -1933,17 +1922,6 @@ function applyOperationsToWorkflowState(
break
}
if ((RESERVED_BLOCK_NAMES as readonly string[]).includes(addNormalizedName)) {
logSkippedItem(skippedItems, {
type: 'reserved_block_name',
operationType: 'add',
blockId: block_id,
reason: `Block name "${params.name}" is a reserved name and cannot be used`,
details: { requestedName: params.name },
})
break
}
const conflictingBlock = findBlockWithDuplicateNormalizedName(
modifiedState.blocks,
params.name,

View File

@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { idempotencyKey } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, count, inArray, like, lt, max, min, sql } from 'drizzle-orm'
import { and, eq, lt } from 'drizzle-orm'
const logger = createLogger('IdempotencyCleanup')
@@ -19,8 +19,7 @@ export interface CleanupOptions {
batchSize?: number
/**
* Specific namespace prefix to clean up (e.g., 'webhook', 'polling')
* Keys are prefixed with namespace, so this filters by key prefix
* Specific namespace to clean up, or undefined to clean all namespaces
*/
namespace?: string
}
@@ -54,17 +53,13 @@ export async function cleanupExpiredIdempotencyKeys(
while (hasMore) {
try {
// Build where condition - filter by cutoff date and optionally by namespace prefix
const whereCondition = namespace
? and(
lt(idempotencyKey.createdAt, cutoffDate),
like(idempotencyKey.key, `${namespace}:%`)
)
? and(lt(idempotencyKey.createdAt, cutoffDate), eq(idempotencyKey.namespace, namespace))
: lt(idempotencyKey.createdAt, cutoffDate)
// Find keys to delete with limit
// First, find IDs to delete with limit
const toDelete = await db
.select({ key: idempotencyKey.key })
.select({ key: idempotencyKey.key, namespace: idempotencyKey.namespace })
.from(idempotencyKey)
.where(whereCondition)
.limit(batchSize)
@@ -73,13 +68,14 @@ export async function cleanupExpiredIdempotencyKeys(
break
}
// Delete the found records by key
// Delete the found records
const deleteResult = await db
.delete(idempotencyKey)
.where(
inArray(
idempotencyKey.key,
toDelete.map((item) => item.key)
and(
...toDelete.map((item) =>
and(eq(idempotencyKey.key, item.key), eq(idempotencyKey.namespace, item.namespace))
)
)
)
.returning({ key: idempotencyKey.key })
@@ -130,7 +126,6 @@ export async function cleanupExpiredIdempotencyKeys(
/**
* Get statistics about idempotency key usage
* Uses SQL aggregations to avoid loading all keys into memory
*/
export async function getIdempotencyKeyStats(): Promise<{
totalKeys: number
@@ -139,35 +134,34 @@ export async function getIdempotencyKeyStats(): Promise<{
newestKey: Date | null
}> {
try {
// Get total count and date range in a single query
const [statsResult] = await db
const allKeys = await db
.select({
totalKeys: count(),
oldestKey: min(idempotencyKey.createdAt),
newestKey: max(idempotencyKey.createdAt),
namespace: idempotencyKey.namespace,
createdAt: idempotencyKey.createdAt,
})
.from(idempotencyKey)
// Get counts by namespace prefix using SQL substring
// Extracts everything before the first ':' as the namespace
const namespaceStats = await db
.select({
namespace: sql<string>`split_part(${idempotencyKey.key}, ':', 1)`.as('namespace'),
count: count(),
})
.from(idempotencyKey)
.groupBy(sql`split_part(${idempotencyKey.key}, ':', 1)`)
const totalKeys = allKeys.length
const keysByNamespace: Record<string, number> = {}
for (const row of namespaceStats) {
keysByNamespace[row.namespace || 'unknown'] = row.count
let oldestKey: Date | null = null
let newestKey: Date | null = null
for (const key of allKeys) {
keysByNamespace[key.namespace] = (keysByNamespace[key.namespace] || 0) + 1
if (!oldestKey || key.createdAt < oldestKey) {
oldestKey = key.createdAt
}
if (!newestKey || key.createdAt > newestKey) {
newestKey = key.createdAt
}
}
return {
totalKeys: statsResult?.totalKeys ?? 0,
totalKeys,
keysByNamespace,
oldestKey: statsResult?.oldestKey ?? null,
newestKey: statsResult?.newestKey ?? null,
oldestKey,
newestKey,
}
} catch (error) {
logger.error('Failed to get idempotency key stats:', error)

View File

@@ -2,7 +2,7 @@ import { randomUUID } from 'crypto'
import { db } from '@sim/db'
import { idempotencyKey } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import { getRedisClient } from '@/lib/core/config/redis'
import { getStorageMethod, type StorageMethod } from '@/lib/core/storage'
import { extractProviderIdentifierFromBody } from '@/lib/webhooks/provider-utils'
@@ -124,7 +124,12 @@ export class IdempotencyService {
const existing = await db
.select({ result: idempotencyKey.result, createdAt: idempotencyKey.createdAt })
.from(idempotencyKey)
.where(eq(idempotencyKey.key, normalizedKey))
.where(
and(
eq(idempotencyKey.key, normalizedKey),
eq(idempotencyKey.namespace, this.config.namespace)
)
)
.limit(1)
if (existing.length > 0) {
@@ -219,12 +224,11 @@ export class IdempotencyService {
.insert(idempotencyKey)
.values({
key: normalizedKey,
namespace: this.config.namespace,
result: inProgressResult,
createdAt: new Date(),
})
.onConflictDoNothing({
target: [idempotencyKey.key],
})
.onConflictDoNothing()
.returning({ key: idempotencyKey.key })
if (insertResult.length > 0) {
@@ -239,7 +243,12 @@ export class IdempotencyService {
const existing = await db
.select({ result: idempotencyKey.result })
.from(idempotencyKey)
.where(eq(idempotencyKey.key, normalizedKey))
.where(
and(
eq(idempotencyKey.key, normalizedKey),
eq(idempotencyKey.namespace, this.config.namespace)
)
)
.limit(1)
const existingResult =
@@ -271,7 +280,12 @@ export class IdempotencyService {
const existing = await db
.select({ result: idempotencyKey.result })
.from(idempotencyKey)
.where(eq(idempotencyKey.key, normalizedKey))
.where(
and(
eq(idempotencyKey.key, normalizedKey),
eq(idempotencyKey.namespace, this.config.namespace)
)
)
.limit(1)
currentResult = existing.length > 0 ? (existing[0].result as ProcessingResult) : null
}
@@ -325,11 +339,12 @@ export class IdempotencyService {
.insert(idempotencyKey)
.values({
key: normalizedKey,
namespace: this.config.namespace,
result: result,
createdAt: new Date(),
})
.onConflictDoUpdate({
target: [idempotencyKey.key],
target: [idempotencyKey.key, idempotencyKey.namespace],
set: {
result: result,
createdAt: new Date(),

View File

@@ -7,7 +7,7 @@ import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types'
import { normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants'
import { normalizeName } from '@/executor/constants'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { filterNewEdges, getUniqueBlockName, mergeSubblockState } from '@/stores/workflows/utils'
@@ -726,11 +726,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
return { success: false, changedSubblocks: [] }
}
if ((RESERVED_BLOCK_NAMES as readonly string[]).includes(normalizedNewName)) {
logger.error(`Cannot rename block to reserved name: "${name}"`)
return { success: false, changedSubblocks: [] }
}
const newState = {
blocks: {
...get().blocks,

View File

@@ -1,4 +0,0 @@
DROP INDEX "idempotency_key_namespace_unique";--> statement-breakpoint
DROP INDEX "idempotency_key_namespace_idx";--> statement-breakpoint
ALTER TABLE "idempotency_key" ADD PRIMARY KEY ("key");--> statement-breakpoint
ALTER TABLE "idempotency_key" DROP COLUMN "namespace";

File diff suppressed because it is too large Load Diff

View File

@@ -1023,13 +1023,6 @@
"when": 1768867605608,
"tag": "0146_cultured_ikaris",
"breakpoints": true
},
{
"idx": 147,
"version": "7",
"when": 1769134350805,
"tag": "0147_rare_firebrand",
"breakpoints": true
}
]
}

View File

@@ -1656,13 +1656,20 @@ export const workflowDeploymentVersion = pgTable(
export const idempotencyKey = pgTable(
'idempotency_key',
{
key: text('key').primaryKey(),
key: text('key').notNull(),
namespace: text('namespace').notNull().default('default'),
result: json('result').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
// Primary key is combination of key and namespace
keyNamespacePk: uniqueIndex('idempotency_key_namespace_unique').on(table.key, table.namespace),
// Index for cleanup operations by creation time
createdAtIdx: index('idempotency_key_created_at_idx').on(table.createdAt),
// Index for namespace-based queries
namespaceIdx: index('idempotency_key_namespace_idx').on(table.namespace),
})
)