Skip to content
7 changes: 4 additions & 3 deletions apps/docs/content/docs/integrations/elasticsearch.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ Retrieve index information including settings, mappings, and aliases.

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `index` | json | Index information including aliases, mappings, and settings |
| `indices` | json | Matched indices keyed by index name, each with its aliases, mappings, and settings |

### Elasticsearch Cluster Health

Expand All @@ -324,7 +324,7 @@ Get the health status of the Elasticsearch cluster.
| `username` | string | No | Username for basic auth |
| `password` | string | No | Password for basic auth |
| `waitForStatus` | string | No | Wait until cluster reaches this status: green, yellow, or red |
| `timeout` | string | No | Timeout for the wait operation \(e.g., 30s, 1m\) |
| `clusterTimeout` | string | No | How long Elasticsearch waits for the cluster to reach the requested status, as an Elasticsearch time value \(e.g., 30s, 1m\). Not named "timeout": that name is reserved by the tool transport as a client-side abort deadline in milliseconds. |

#### Output

Expand Down Expand Up @@ -377,12 +377,13 @@ List all indices in the Elasticsearch cluster with their health, status, and sta
| `apiKey` | string | No | Elasticsearch API key |
| `username` | string | No | Username for basic auth |
| `password` | string | No | Password for basic auth |
| `includeSystemIndices` | boolean | No | Include Elasticsearch system indices \(names starting with "."\). Omitted by default. |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Summary message about the indices |
| `indices` | json | Array of index information objects |
| `indices` | json | Array of index information objects \(index, health, status, docsCount, storeSize, primaryShards, replicaShards\). System indices are omitted unless includeSystemIndices is set. |


48 changes: 42 additions & 6 deletions apps/sim/blocks/blocks/elasticsearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,15 +483,32 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
condition: { field: 'operation', value: 'elasticsearch_cluster_health' },
},

// Cluster health timeout
// Cluster health timeout. The subBlock id stays `timeout` so saved workflow
// state keeps resolving; `tools.config.params` remaps it to `clusterTimeout`
// and clears the transport's reserved `timeout` key.
{
id: 'timeout',
title: 'Timeout (seconds)',
title: 'Timeout',
type: 'short-input',
placeholder: '30',
placeholder: '30s',
mode: 'advanced',
condition: { field: 'operation', value: 'elasticsearch_cluster_health' },
},

// Include system indices
{
id: 'includeSystemIndices',
title: 'Include System Indices',
type: 'dropdown',
options: [
{ label: 'No', id: '' },
{ label: 'Yes', id: 'true' },
],
value: () => '',
mode: 'advanced',
condition: { field: 'operation', value: 'elasticsearch_list_indices' },
},

// Retry on conflict
{
id: 'retryOnConflict',
Expand Down Expand Up @@ -528,9 +545,15 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
if (params.size) result.size = Number(params.size)
if (params.from) result.from = Number(params.from)
if (params.retryOnConflict) result.retryOnConflict = Number(params.retryOnConflict)
if (params.timeout && typeof params.timeout === 'string') {
result.timeout = params.timeout.endsWith('s') ? params.timeout : `${params.timeout}s`

if (params.includeSystemIndices === 'true') result.includeSystemIndices = true

const rawTimeout = typeof params.timeout === 'string' ? params.timeout.trim() : ''
if (rawTimeout) {
result.clusterTimeout = /^\d+$/.test(rawTimeout) ? `${rawTimeout}s` : rawTimeout
}
result.timeout = undefined

return result
},
},
Expand Down Expand Up @@ -559,7 +582,14 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
mappings: { type: 'string', description: 'Index mappings as JSON' },
refresh: { type: 'string', description: 'Refresh policy' },
waitForStatus: { type: 'string', description: 'Wait for cluster status' },
timeout: { type: 'string', description: 'Timeout for wait operations' },
timeout: {
type: 'string',
description: 'How long Elasticsearch waits for the cluster to reach the requested status',
},
includeSystemIndices: {
type: 'string',
description: 'Include Elasticsearch system indices when listing',
},
retryOnConflict: { type: 'number', description: 'Retry attempts on conflict' },
},

Expand All @@ -581,8 +611,14 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
items: { type: 'json', description: 'Bulk operation results' },
// Count outputs
count: { type: 'number', description: 'Document count' },
_shards: {
type: 'json',
description: 'Shard statistics (total, successful, skipped, failed)',
},
// Index outputs
acknowledged: { type: 'boolean', description: 'Whether operation was acknowledged' },
// List indices outputs
message: { type: 'string', description: 'Summary message about the indices listed' },
// Cluster outputs
cluster_name: { type: 'string', description: 'Cluster name' },
status: { type: 'string', description: 'Cluster health status' },
Expand Down
45 changes: 3 additions & 42 deletions apps/sim/tools/elasticsearch/bulk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,49 +2,9 @@ import type {
ElasticsearchBulkParams,
ElasticsearchBulkResponse,
} from '@/tools/elasticsearch/types'
import { buildAuthHeaders, buildBaseUrl } from '@/tools/elasticsearch/utils'
import type { ToolConfig } from '@/tools/types'

function buildBaseUrl(params: ElasticsearchBulkParams): string {
if (params.deploymentType === 'cloud' && params.cloudId) {
const parts = params.cloudId.split(':')
if (parts.length >= 2) {
try {
const decoded = Buffer.from(parts[1], 'base64').toString('utf-8')
const [esHost] = decoded.split('$')
if (esHost) {
return `https://${parts[0]}.${esHost}`
}
} catch {
// Fallback
}
}
throw new Error('Invalid Cloud ID format')
}

if (!params.host) {
throw new Error('Host is required for self-hosted deployments')
}

return params.host.replace(/\/$/, '')
}

function buildAuthHeaders(params: ElasticsearchBulkParams): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/x-ndjson',
}

if (params.authMethod === 'api_key' && params.apiKey) {
headers.Authorization = `ApiKey ${params.apiKey}`
} else if (params.authMethod === 'basic_auth' && params.username && params.password) {
const credentials = Buffer.from(`${params.username}:${params.password}`).toString('base64')
headers.Authorization = `Basic ${credentials}`
} else {
throw new Error('Invalid authentication configuration')
}

return headers
}

export const bulkTool: ToolConfig<ElasticsearchBulkParams, ElasticsearchBulkResponse> = {
id: 'elasticsearch_bulk',
name: 'Elasticsearch Bulk Operations',
Expand Down Expand Up @@ -127,7 +87,8 @@ export const bulkTool: ToolConfig<ElasticsearchBulkParams, ElasticsearchBulkResp
return url
},
method: 'POST',
headers: (params) => buildAuthHeaders(params),
headers: (params) => buildAuthHeaders(params, 'application/x-ndjson'),
redirectPolicy: () => ({ mode: 'legacy', sendCredentialsOnCrossOriginRedirect: false }),
body: (params) => {
// The body should be NDJSON format - we pass it as raw string
// Ensure it ends with a newline
Expand Down
83 changes: 83 additions & 0 deletions apps/sim/tools/elasticsearch/cluster_health.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { ElasticsearchBlock } from '@/blocks/blocks/elasticsearch'
import * as elasticsearchTools from '@/tools/elasticsearch'
import { prepareToolRequest } from '@/tools/request-transport'
import type { ToolConfig } from '@/tools/types'

const CONNECTION = {
deploymentType: 'self_hosted',
host: 'https://es.example.com',
authMethod: 'api_key',
apiKey: 'test-key',
} as const

function mapBlockParams(params: Record<string, unknown>): Record<string, unknown> {
const config = ElasticsearchBlock.tools.config
if (!config?.params) throw new Error('block has no params mapper')
return config.params(params) as Record<string, unknown>
}

/**
* `tools/request-transport.ts` reads `params.timeout` as the outbound HTTP
* deadline in milliseconds. A tool param of that name therefore arms a client
* abort as a side effect of asking Elasticsearch to wait.
*/
describe('cluster health timeout does not arm a client abort', () => {
it('sends the wait on the wire and leaves no client deadline', () => {
const prepared = prepareToolRequest(
elasticsearchTools.elasticsearchClusterHealthTool as ToolConfig,
{ ...CONNECTION, clusterTimeout: '30s', waitForStatus: 'yellow' }
)
expect(prepared.url).toContain('timeout=30s')
expect(prepared.url).toContain('wait_for_status=yellow')
expect(prepared.timeout).toBeUndefined()
})

it('ignores a stray transport timeout left in saved state', () => {
const mapped = mapBlockParams({ operation: 'elasticsearch_cluster_health', timeout: '30' })
expect(mapped.timeout).toBeUndefined()
expect(mapped.clusterTimeout).toBe('30s')
})

it('declares no param named timeout', () => {
expect(
Object.keys(elasticsearchTools.elasticsearchClusterHealthTool.params ?? {})
).not.toContain('timeout')
})
})

describe('cluster health timeout units', () => {
it.each([
['30', '30s'],
['30s', '30s'],
['1m', '1m'],
['500ms', '500ms'],
['2h', '2h'],
])('maps %o to %o', (input, expected) => {
const mapped = mapBlockParams({ operation: 'elasticsearch_cluster_health', timeout: input })
expect(mapped.clusterTimeout).toBe(expected)
})

it('emits nothing for a blank timeout', () => {
expect(
mapBlockParams({ operation: 'elasticsearch_cluster_health', timeout: ' ' })
).not.toHaveProperty('clusterTimeout')
})
})

describe('list indices system-index opt-in', () => {
it('coerces the dropdown string to a real boolean', () => {
expect(
mapBlockParams({ operation: 'elasticsearch_list_indices', includeSystemIndices: 'true' })
).toMatchObject({ includeSystemIndices: true })
})

it('omits the flag when the dropdown is left at its default', () => {
expect(
mapBlockParams({ operation: 'elasticsearch_list_indices', includeSystemIndices: '' })
).not.toHaveProperty('includeSystemIndices')
})
})
52 changes: 7 additions & 45 deletions apps/sim/tools/elasticsearch/cluster_health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,49 +2,9 @@ import type {
ElasticsearchClusterHealthParams,
ElasticsearchClusterHealthResponse,
} from '@/tools/elasticsearch/types'
import { buildAuthHeaders, buildBaseUrl } from '@/tools/elasticsearch/utils'
import type { ToolConfig } from '@/tools/types'

function buildBaseUrl(params: ElasticsearchClusterHealthParams): string {
if (params.deploymentType === 'cloud' && params.cloudId) {
const parts = params.cloudId.split(':')
if (parts.length >= 2) {
try {
const decoded = Buffer.from(parts[1], 'base64').toString('utf-8')
const [esHost] = decoded.split('$')
if (esHost) {
return `https://${parts[0]}.${esHost}`
}
} catch {
// Fallback
}
}
throw new Error('Invalid Cloud ID format')
}

if (!params.host) {
throw new Error('Host is required for self-hosted deployments')
}

return params.host.replace(/\/$/, '')
}

function buildAuthHeaders(params: ElasticsearchClusterHealthParams): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}

if (params.authMethod === 'api_key' && params.apiKey) {
headers.Authorization = `ApiKey ${params.apiKey}`
} else if (params.authMethod === 'basic_auth' && params.username && params.password) {
const credentials = Buffer.from(`${params.username}:${params.password}`).toString('base64')
headers.Authorization = `Basic ${credentials}`
} else {
throw new Error('Invalid authentication configuration')
}

return headers
}

export const clusterHealthTool: ToolConfig<
ElasticsearchClusterHealthParams,
ElasticsearchClusterHealthResponse
Expand Down Expand Up @@ -100,10 +60,11 @@ export const clusterHealthTool: ToolConfig<
required: false,
description: 'Wait until cluster reaches this status: green, yellow, or red',
},
timeout: {
clusterTimeout: {
type: 'string',
required: false,
description: 'Timeout for the wait operation (e.g., 30s, 1m)',
description:
'How long Elasticsearch waits for the cluster to reach the requested status, as an Elasticsearch time value (e.g., 30s, 1m). Not named "timeout": that name is reserved by the tool transport as a client-side abort deadline in milliseconds.',
},
},

Expand All @@ -116,8 +77,8 @@ export const clusterHealthTool: ToolConfig<
if (params.waitForStatus) {
queryParams.push(`wait_for_status=${params.waitForStatus}`)
}
if (params.timeout) {
queryParams.push(`timeout=${encodeURIComponent(params.timeout)}`)
if (params.clusterTimeout) {
queryParams.push(`timeout=${encodeURIComponent(params.clusterTimeout)}`)
}
if (queryParams.length > 0) {
url += `?${queryParams.join('&')}`
Expand All @@ -127,6 +88,7 @@ export const clusterHealthTool: ToolConfig<
},
method: 'GET',
headers: (params) => buildAuthHeaders(params),
redirectPolicy: () => ({ mode: 'legacy', sendCredentialsOnCrossOriginRedirect: false }),
},

transformResponse: async (response: Response) => {
Expand Down
Loading
Loading