From 92264bf4e1398576d9e2d2ca876b243cb0a97975 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 18 Aug 2026 14:38:59 -0700 Subject: [PATCH 1/5] improvement(logs-block): filter runs by trigger type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Logs block could filter runs by workflow, status, time, cost, and duration, but not by how the run started — even though the underlying tool, the /api/logs contract, and the indexed trigger column all already accepted a comma-separated triggers filter. Adds a basic multi-select and an advanced free-text field behind the canonical `triggers` param, mirroring the block's existing workflow filter. Options come from the same registry the Logs page reads, so both surfaces name a run's origin identically; values sharing a label are merged into one option. Leaving the filter empty omits the param, so existing blocks query exactly as before. --- apps/sim/blocks/blocks/logs.test.ts | 62 +++++++++++++++++++ apps/sim/blocks/blocks/logs.ts | 36 ++++++++++- .../lib/workflows/subblocks/options.test.ts | 43 ++++++++++++- apps/sim/lib/workflows/subblocks/options.ts | 27 ++++++++ 4 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 apps/sim/blocks/blocks/logs.test.ts diff --git a/apps/sim/blocks/blocks/logs.test.ts b/apps/sim/blocks/blocks/logs.test.ts new file mode 100644 index 00000000000..a7c7b34782f --- /dev/null +++ b/apps/sim/blocks/blocks/logs.test.ts @@ -0,0 +1,62 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/workflows/subblocks/options', () => ({ + fetchTriggerTypeOptions: vi.fn(), + fetchWorkspaceWorkflowOptions: vi.fn(), +})) + +import { LogsV2Block } from '@/blocks/blocks/logs' + +function buildQueryParams(params: Record) { + return LogsV2Block.tools.config!.params!({ operation: 'query', ...params }) +} + +describe('LogsV2Block trigger filter', () => { + it('omits triggers when the filter is untouched, leaving pre-existing queries unfiltered', () => { + expect(buildQueryParams({}).triggers).toBeUndefined() + expect(buildQueryParams({ triggers: [] }).triggers).toBeUndefined() + expect(buildQueryParams({ triggers: '' }).triggers).toBeUndefined() + }) + + it('joins a multi-select selection into the comma-separated list the API expects', () => { + expect(buildQueryParams({ triggers: ['api', 'schedule'] }).triggers).toBe('api,schedule') + }) + + it('flattens a merged option id so one label can select several trigger values', () => { + expect(buildQueryParams({ triggers: ['api', 'copilot,mothership'] }).triggers).toBe( + 'api,copilot,mothership' + ) + }) + + it('accepts an advanced-mode string of provider ids', () => { + expect(buildQueryParams({ triggers: ' slack,gmail ' }).triggers).toBe('slack,gmail') + }) + + it('never sends triggers on the run-details operation', () => { + expect( + LogsV2Block.tools.config!.params!({ + operation: 'get_run_details', + runId: 'run-1', + triggers: ['api'], + }) + ).toEqual({ runId: 'run-1' }) + }) +}) + +describe('LogsV2Block trigger subblocks', () => { + const subBlockIds = LogsV2Block.subBlocks.map((subBlock) => subBlock.id) + + it('exposes basic and advanced modes behind one canonical param', () => { + expect(subBlockIds).toContain('triggerSelector') + expect(subBlockIds).toContain('manualTriggers') + + for (const id of ['triggerSelector', 'manualTriggers']) { + const subBlock = LogsV2Block.subBlocks.find((candidate) => candidate.id === id) + expect(subBlock?.canonicalParamId).toBe('triggers') + expect(subBlock?.condition).toEqual({ field: 'operation', value: 'query' }) + } + }) +}) diff --git a/apps/sim/blocks/blocks/logs.ts b/apps/sim/blocks/blocks/logs.ts index bef6bd2238d..123fb3c669a 100644 --- a/apps/sim/blocks/blocks/logs.ts +++ b/apps/sim/blocks/blocks/logs.ts @@ -1,5 +1,8 @@ import { Library } from '@sim/emcn/icons' -import { fetchWorkspaceWorkflowOptions } from '@/lib/workflows/subblocks/options' +import { + fetchTriggerTypeOptions, + fetchWorkspaceWorkflowOptions, +} from '@/lib/workflows/subblocks/options' import type { BlockConfig } from '@/blocks/types' export const LogsBlock: BlockConfig = { @@ -311,6 +314,9 @@ function joinIds(value: unknown): string | undefined { /** Workflow filter, whichever mode the card is in. */ const WORKFLOW_FIELD = ['workflowSelector', 'manualWorkflowIds'] as const +/** Trigger filter, whichever mode the card is in. */ +const TRIGGER_FIELD = ['triggerSelector', 'manualTriggers'] as const + export const LogsV2Block: BlockConfig = { type: 'logs_v2', name: 'Logs', @@ -335,6 +341,7 @@ export const LogsV2Block: BlockConfig = { 'Query workflow runs', { text: 'for', field: WORKFLOW_FIELD }, { text: ', with status', field: 'level' }, + { text: ', triggered by', field: TRIGGER_FIELD }, { text: ', over', field: 'timeRange' }, ], get_run_details: [{ text: 'Read the trace for run', field: 'runId', core: true }], @@ -389,6 +396,28 @@ export const LogsV2Block: BlockConfig = { placeholder: 'All statuses', condition: { field: 'operation', value: 'query' }, }, + { + id: 'triggerSelector', + title: 'Triggers', + type: 'dropdown', + multiSelect: true, + options: [], + placeholder: 'All triggers', + description: 'Only include runs started this way. Leave empty for all.', + mode: 'basic', + canonicalParamId: 'triggers', + condition: { field: 'operation', value: 'query' }, + fetchOptions: () => fetchTriggerTypeOptions(), + }, + { + id: 'manualTriggers', + title: 'Triggers', + type: 'short-input', + placeholder: 'Comma-separated trigger types (api, schedule, slack)', + mode: 'advanced', + canonicalParamId: 'triggers', + condition: { field: 'operation', value: 'query' }, + }, { id: 'timeRange', title: 'Time Range', @@ -548,6 +577,7 @@ export const LogsV2Block: BlockConfig = { return { workflowIds: joinIds(params.workflowIds), level, + triggers: joinIds(params.triggers), startDate: params.startDate || presetStartDate, endDate: params.endDate || undefined, costOperator: costValue !== undefined ? params.costOperator || undefined : undefined, @@ -566,6 +596,10 @@ export const LogsV2Block: BlockConfig = { operation: { type: 'string', description: 'Operation to perform' }, workflowIds: { type: 'array', description: 'Workflow IDs to filter by (canonical param)' }, level: { type: 'array', description: 'Statuses to include (empty for all)' }, + triggers: { + type: 'array', + description: 'Trigger types to include (canonical param, empty for all)', + }, timeRange: { type: 'string', description: 'Preset time window' }, startDate: { type: 'string', description: 'ISO 8601 lower bound (overrides Time Range)' }, endDate: { type: 'string', description: 'ISO 8601 upper bound' }, diff --git a/apps/sim/lib/workflows/subblocks/options.test.ts b/apps/sim/lib/workflows/subblocks/options.test.ts index a5146093bbc..ad70db442af 100644 --- a/apps/sim/lib/workflows/subblocks/options.test.ts +++ b/apps/sim/lib/workflows/subblocks/options.test.ts @@ -3,9 +3,10 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockFetchQuery, mockGetSubBlockValue } = vi.hoisted(() => ({ +const { mockFetchQuery, mockGetSubBlockValue, mockTriggerOptions } = vi.hoisted(() => ({ mockFetchQuery: vi.fn(), mockGetSubBlockValue: vi.fn(), + mockTriggerOptions: vi.fn(), })) vi.mock('@/app/_shell/providers/get-query-client', () => ({ @@ -24,6 +25,10 @@ vi.mock('@/stores/workflows/registry/store', () => ({ }, })) +vi.mock('@/lib/logs/get-trigger-options', () => ({ + getTriggerOptions: () => mockTriggerOptions(), +})) + vi.mock('@/stores/workflows/subblock/store', () => ({ useSubBlockStore: { getState: () => ({ getValue: mockGetSubBlockValue }), @@ -31,6 +36,7 @@ vi.mock('@/stores/workflows/subblock/store', () => ({ })) import { + fetchTriggerTypeOptions, fetchWorkspaceSandboxOption, fetchWorkspaceSandboxOptions, } from '@/lib/workflows/subblocks/options' @@ -101,3 +107,38 @@ describe('workspace sandbox options', () => { }) }) }) + +describe('fetchTriggerTypeOptions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('merges values that share a label into one comma-joined option', async () => { + mockTriggerOptions.mockReturnValue([ + { value: 'api', label: 'API', color: '#2563eb' }, + { value: 'copilot', label: 'Sim agent', color: '#ec4899' }, + { value: 'mothership', label: 'Sim agent', color: '#ec4899' }, + { value: 'slack', label: 'Slack', color: '#611f69' }, + ]) + + await expect(fetchTriggerTypeOptions()).resolves.toEqual([ + { id: 'api', label: 'API' }, + { id: 'copilot,mothership', label: 'Sim agent' }, + { id: 'slack', label: 'Slack' }, + ]) + }) + + it('preserves registry order so core trigger types lead the list', async () => { + mockTriggerOptions.mockReturnValue([ + { value: 'manual', label: 'Manual', color: '#6b7280' }, + { value: 'api', label: 'API', color: '#2563eb' }, + { value: 'airtable', label: 'Airtable', color: '#181d1f' }, + ]) + + await expect(fetchTriggerTypeOptions()).resolves.toEqual([ + { id: 'manual', label: 'Manual' }, + { id: 'api', label: 'API' }, + { id: 'airtable', label: 'Airtable' }, + ]) + }) +}) diff --git a/apps/sim/lib/workflows/subblocks/options.ts b/apps/sim/lib/workflows/subblocks/options.ts index 168ac0e8e3c..3e6b3f5e954 100644 --- a/apps/sim/lib/workflows/subblocks/options.ts +++ b/apps/sim/lib/workflows/subblocks/options.ts @@ -143,3 +143,30 @@ export async function fetchWorkspaceSandboxOption( } return option } + +/** + * Loads the trigger vocabulary the Logs page filter offers — the core trigger + * types plus one entry per registered webhook provider — for the Logs block's + * trigger filter, so both surfaces name a run's origin identically. + * + * The registry is reached lazily: `getTriggerOptions` reads the block and trigger + * registries, and importing it at module scope from a module that block + * definitions themselves import would close an initialization cycle. + * + * Entries sharing a label are merged into one option whose id is the comma-joined + * set of values (`copilot,mothership` for "Sim agent"). The filter is a + * comma-separated list end to end, so a merged id selects every value behind the + * label instead of offering two identical rows. + */ +export async function fetchTriggerTypeOptions(): Promise { + const { getTriggerOptions } = await import('@/lib/logs/get-trigger-options') + + const valuesByLabel = new Map() + for (const option of getTriggerOptions()) { + const values = valuesByLabel.get(option.label) + if (values) values.push(option.value) + else valuesByLabel.set(option.label, [option.value]) + } + + return Array.from(valuesByLabel, ([label, values]) => ({ id: values.join(','), label })) +} From 9f2469d4851c0a4c15079385b113f3a8de2a2936 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 18 Aug 2026 14:45:21 -0700 Subject: [PATCH 2/5] fix(logs-block): declare the triggers input as the string it becomes The generic handler JSON.parses any post-transform input declared 'array' or 'json'. Since `joinIds` has already turned the selection into a comma-separated string by then, the array declaration logged a parse warning on every run, and JSON-looking advanced input would have been turned into an array the tool does not accept. Matches the legacy Logs block, which already declares triggers as a string, and locks the invariant with a test. Also drops `any` from the new test helper. --- apps/sim/blocks/blocks/logs.test.ts | 10 +++++++++- apps/sim/blocks/blocks/logs.ts | 4 ++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/sim/blocks/blocks/logs.test.ts b/apps/sim/blocks/blocks/logs.test.ts index a7c7b34782f..10ae1ea3374 100644 --- a/apps/sim/blocks/blocks/logs.test.ts +++ b/apps/sim/blocks/blocks/logs.test.ts @@ -10,7 +10,7 @@ vi.mock('@/lib/workflows/subblocks/options', () => ({ import { LogsV2Block } from '@/blocks/blocks/logs' -function buildQueryParams(params: Record) { +function buildQueryParams(params: Record) { return LogsV2Block.tools.config!.params!({ operation: 'query', ...params }) } @@ -49,6 +49,14 @@ describe('LogsV2Block trigger filter', () => { describe('LogsV2Block trigger subblocks', () => { const subBlockIds = LogsV2Block.subBlocks.map((subBlock) => subBlock.id) + it('declares triggers as the string it is transformed into', () => { + // The generic handler JSON.parses any post-transform input declared 'array' or + // 'json', so declaring the joined string as an array would warn on every run + // and would turn JSON-looking advanced input into an array the tool rejects. + expect(LogsV2Block.inputs.triggers.type).toBe('string') + expect(typeof buildQueryParams({ triggers: ['api', 'schedule'] }).triggers).toBe('string') + }) + it('exposes basic and advanced modes behind one canonical param', () => { expect(subBlockIds).toContain('triggerSelector') expect(subBlockIds).toContain('manualTriggers') diff --git a/apps/sim/blocks/blocks/logs.ts b/apps/sim/blocks/blocks/logs.ts index 123fb3c669a..863c109161e 100644 --- a/apps/sim/blocks/blocks/logs.ts +++ b/apps/sim/blocks/blocks/logs.ts @@ -597,8 +597,8 @@ export const LogsV2Block: BlockConfig = { workflowIds: { type: 'array', description: 'Workflow IDs to filter by (canonical param)' }, level: { type: 'array', description: 'Statuses to include (empty for all)' }, triggers: { - type: 'array', - description: 'Trigger types to include (canonical param, empty for all)', + type: 'string', + description: 'Comma-separated trigger types to include (canonical param, empty for all)', }, timeRange: { type: 'string', description: 'Preset time window' }, startDate: { type: 'string', description: 'ISO 8601 lower bound (overrides Time Range)' }, From fd50a970a137d86a51b26d02a7d117362d4c330c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 18 Aug 2026 14:50:13 -0700 Subject: [PATCH 3/5] fix(logs-block): trim each entry when joining filter ids joinIds trimmed only the ends of an advanced-mode string, so a hand-typed 'api, schedule, slack' reached the query as ' schedule' and ' slack'. The filters split on commas without trimming, so those tokens matched no stored trigger and the filter silently returned nothing. Splits and trims every entry instead, which also covers empty tokens from a trailing comma and the multi-value ids behind merged trigger labels. --- apps/sim/blocks/blocks/logs.test.ts | 16 ++++++++++++++++ apps/sim/blocks/blocks/logs.ts | 22 +++++++++++++++------- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/apps/sim/blocks/blocks/logs.test.ts b/apps/sim/blocks/blocks/logs.test.ts index 10ae1ea3374..fd7fd92037d 100644 --- a/apps/sim/blocks/blocks/logs.test.ts +++ b/apps/sim/blocks/blocks/logs.test.ts @@ -35,6 +35,22 @@ describe('LogsV2Block trigger filter', () => { expect(buildQueryParams({ triggers: ' slack,gmail ' }).triggers).toBe('slack,gmail') }) + it('trims each hand-typed entry, not just the ends of the string', () => { + // The filters split on commas without trimming, so a surviving space would + // match no stored trigger and silently narrow the result set to nothing. + expect(buildQueryParams({ triggers: 'api, schedule, slack' }).triggers).toBe( + 'api,schedule,slack' + ) + expect(buildQueryParams({ triggers: 'api,,schedule,' }).triggers).toBe('api,schedule') + expect(buildQueryParams({ triggers: ' , ' }).triggers).toBeUndefined() + }) + + it('trims entries inside a multi-select selection too', () => { + expect(buildQueryParams({ triggers: ['api ', ' copilot, mothership'] }).triggers).toBe( + 'api,copilot,mothership' + ) + }) + it('never sends triggers on the run-details operation', () => { expect( LogsV2Block.tools.config!.params!({ diff --git a/apps/sim/blocks/blocks/logs.ts b/apps/sim/blocks/blocks/logs.ts index 863c109161e..a01d3535ec0 100644 --- a/apps/sim/blocks/blocks/logs.ts +++ b/apps/sim/blocks/blocks/logs.ts @@ -301,14 +301,22 @@ const TIME_RANGE_MS: Record = { 'past-30-days': 30 * 24 * 60 * 60 * 1000, } -/** Normalizes multi-select arrays or comma strings into a comma-separated string. */ +/** + * Normalizes multi-select arrays or comma strings into a comma-separated string. + * + * Every entry is itself split on commas and trimmed: a single option id can hold + * several values (the merged trigger labels), and advanced-mode fields are typed + * by hand. The filters this feeds split on commas without trimming, so a stray + * space would silently match nothing. + */ function joinIds(value: unknown): string | undefined { - if (Array.isArray(value)) { - const ids = value.filter((id): id is string => typeof id === 'string' && id.length > 0) - return ids.length > 0 ? ids.join(',') : undefined - } - if (typeof value === 'string' && value.trim().length > 0) return value.trim() - return undefined + const entries = Array.isArray(value) ? value : [value] + const ids = entries + .filter((entry): entry is string => typeof entry === 'string') + .flatMap((entry) => entry.split(',')) + .map((id) => id.trim()) + .filter((id) => id.length > 0) + return ids.length > 0 ? ids.join(',') : undefined } /** Workflow filter, whichever mode the card is in. */ From e1b61f5f97541f2f5b8b34ff22698b8d3a01c839 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 18 Aug 2026 15:04:14 -0700 Subject: [PATCH 4/5] test(logs-block): lock the shared joinIds output for existing filters joinIds is shared with the workflow and status filters, so the per-entry trimming added for hand-typed triggers must not move their output. Covers every value a stored multi-select or advanced field can hold, plus the block-saved-before-the-filter case where triggers must not reach the query. --- apps/sim/blocks/blocks/logs.test.ts | 39 +++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/apps/sim/blocks/blocks/logs.test.ts b/apps/sim/blocks/blocks/logs.test.ts index fd7fd92037d..58b1734845b 100644 --- a/apps/sim/blocks/blocks/logs.test.ts +++ b/apps/sim/blocks/blocks/logs.test.ts @@ -62,6 +62,45 @@ describe('LogsV2Block trigger filter', () => { }) }) +describe('LogsV2Block backwards compatibility', () => { + // `joinIds` is shared with the pre-existing workflow and status filters, so the + // per-entry trimming added for hand-typed triggers must not move their output. + // Every value a stored multi-select or advanced field can hold is listed here: + // option ids and workflow ids contain neither spaces nor commas. + const UUID = '3f2504e0-4f89-11d3-9a0c-0305e82c3301' + + it.each([ + ['unset', undefined, undefined], + ['empty selection', [], undefined], + ['one workflow', [UUID], UUID], + [ + 'two workflows', + [UUID, 'b7a1c2d3-0000-4000-8000-000000000001'], + `${UUID},b7a1c2d3-0000-4000-8000-000000000001`, + ], + ['advanced string', 'id-one,id-two', 'id-one,id-two'], + ['empty string', '', undefined], + ])('leaves workflowIds untouched for %s', (_name, value, expected) => { + expect(buildQueryParams({ workflowIds: value }).workflowIds).toBe(expected) + }) + + it.each([ + ['unset', undefined, undefined], + ['empty selection', [], undefined], + ['one status', ['info'], 'info'], + ['several statuses', ['info', 'error', 'cancelled'], 'info,error,cancelled'], + ])('leaves level untouched for %s', (_name, value, expected) => { + expect(buildQueryParams({ level: value }).level).toBe(expected) + }) + + it('omits triggers entirely for a block saved before the filter existed', () => { + const params = buildQueryParams({ workflowIds: [UUID], level: ['info'] }) + expect(params.triggers).toBeUndefined() + // Undefined values are dropped on serialization, so nothing reaches the query. + expect(JSON.stringify(params)).not.toContain('triggers') + }) +}) + describe('LogsV2Block trigger subblocks', () => { const subBlockIds = LogsV2Block.subBlocks.map((subBlock) => subBlock.id) From 70e6841404496b7dfcfcc1a2c0c94eef5740d8ee Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 18 Aug 2026 15:37:21 -0700 Subject: [PATCH 5/5] test(logs-block): exercise the trigger options against the real registry The fetcher reaches the block and trigger registries through a lazy import to avoid an initialization cycle, so a mocked test cannot show that the import resolves or that the registry is populated when the dropdown asks. Covers the populated list, unique labels, and the merged Sim agent option. --- .../subblocks/trigger-options-live.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 apps/sim/lib/workflows/subblocks/trigger-options-live.test.ts diff --git a/apps/sim/lib/workflows/subblocks/trigger-options-live.test.ts b/apps/sim/lib/workflows/subblocks/trigger-options-live.test.ts new file mode 100644 index 00000000000..c7ae616b54d --- /dev/null +++ b/apps/sim/lib/workflows/subblocks/trigger-options-live.test.ts @@ -0,0 +1,30 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { fetchTriggerTypeOptions } from '@/lib/workflows/subblocks/options' + +/** + * Exercises the real block and trigger registries rather than a mock: the + * fetcher reaches them through a lazy import specifically to avoid an + * initialization cycle, and a mocked test cannot show that the import resolves + * or that the registry is populated by the time the dropdown asks for options. + */ +describe('fetchTriggerTypeOptions against the real registry', () => { + it('resolves the lazy import into a populated list of unique labels', async () => { + const options = await fetchTriggerTypeOptions() + + expect(options.length).toBeGreaterThan(10) + expect(options.every((option) => option.id.length > 0 && option.label.length > 0)).toBe(true) + + const labels = options.map((option) => option.label) + expect(new Set(labels).size).toBe(labels.length) + }) + + it('merges the two Sim agent trigger values behind one option', async () => { + const options = await fetchTriggerTypeOptions() + + expect(options.find((option) => option.label === 'Sim agent')?.id).toBe('copilot,mothership') + expect(options.find((option) => option.label === 'API')?.id).toBe('api') + }) +})