Skip to content

Commit 7661b02

Browse files
committed
fix(elasticsearch): per-service Cloud ID ports, date math, and three declared-shape defects
Cloud ID: every component of a decoded Cloud ID may carry its own port, and the ES UUID's port overrides the parent DN's. We used parts[1] raw, so a Cloud ID of that form produced https://uuid:9244.host:9243 -- an invalid URL that fetch rejects with no actionable message. Elastic's own beats fixtures pin all three directions, including that Kibana's port never affects the ES origin. get_index: the target accepts wildcards and comma-separated lists, returning one key per resolved index. Keeping Object.keys(data)[0] silently discarded every other index. Now also emits the full keyed map and a matched count, so data_stream and lifecycle survive too. index names: rejecting a slash broke Elastic's documented date-math form <logstash-{now/d}>. An index name cannot contain a literal slash, so the rejection protected nothing -- and an encoded slash is inert, because the URL parser does not treat %2F as a separator, unlike %2e which it decodes and removes. A local helper drops only the separator check; documentId stays on the strict shared guard. esTimeout: the unit coercion lived only in the block, and Copilot calls executeAppTool without ever running tools.config.params -- so a model sending "30" hit ES with a unitless duration and got a 400. Normalization now lives in the tool and the block calls the same helper. aggregations was declared as an output on both tool and block but no aggs param exists, so it could never be populated. Removed, and the size:0 rationale corrected -- it asks for hits.total without materializing documents, which is not the aggregations idiom.
1 parent c5aef36 commit 7661b02

17 files changed

Lines changed: 593 additions & 85 deletions

apps/sim/blocks/blocks/elasticsearch.ts

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { ElasticsearchIcon } from '@/components/icons'
22
import type { BlockConfig, BlockMeta } from '@/blocks/types'
33
import { AuthMode, IntegrationType } from '@/blocks/types'
44
import type { ElasticsearchResponse } from '@/tools/elasticsearch/types'
5-
import { optionalNumber } from '@/tools/elasticsearch/utils'
5+
import { normalizeEsDuration, optionalNumber } from '@/tools/elasticsearch/utils'
66

77
export const ElasticsearchBlock: BlockConfig<ElasticsearchResponse> = {
88
type: 'elasticsearch',
@@ -557,13 +557,16 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
557557
const retryOnConflict = optionalNumber(params.retryOnConflict, 'retryOnConflict')
558558
if (retryOnConflict !== undefined) result.retryOnConflict = retryOnConflict
559559

560-
const rawTimeout = typeof params.timeout === 'string' ? params.timeout.trim() : ''
561-
if (rawTimeout) {
562-
/**
563-
* Only a bare number is given the implied `s` unit. A previous
564-
* `endsWith('s')` test rewrote "1m" to "1ms" — one millisecond.
565-
*/
566-
result.esTimeout = /^\d+$/.test(rawTimeout) ? `${rawTimeout}s` : rawTimeout
560+
/**
561+
* The unit coercion itself lives in `normalizeEsDuration` on the tool,
562+
* not here: Copilot calls `executeAppTool` directly and never runs this
563+
* mapping, so a normalization owned by the block would apply to one
564+
* calling surface only. This call keeps the subBlock's raw value from
565+
* being forwarded under a different name than the tool expects.
566+
*/
567+
const esTimeout = normalizeEsDuration(params.timeout)
568+
if (esTimeout) {
569+
result.esTimeout = esTimeout
567570
}
568571

569572
return result
@@ -607,7 +610,6 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
607610
hits: { type: 'json', description: 'Search results' },
608611
took: { type: 'number', description: 'Time taken in milliseconds' },
609612
timed_out: { type: 'boolean', description: 'Whether the operation timed out' },
610-
aggregations: { type: 'json', description: 'Aggregation results' },
611613
// Document outputs
612614
_index: { type: 'string', description: 'Index name' },
613615
_id: { type: 'string', description: 'Document ID' },
@@ -616,12 +618,12 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
616618
result: {
617619
type: 'string',
618620
description:
619-
'Operation result (created, updated, deleted, or noop). A missing document fails the call with a 404 error rather than returning "not_found".',
621+
'Operation result (created, updated, deleted, or noop). On the single-document operations a missing document fails the call with a 404 rather than returning "not_found"; Bulk Operations instead reports a per-item "not_found" inside a successful response, under items.',
620622
},
621623
found: {
622624
type: 'boolean',
623625
description:
624-
'Always true. A missing document fails the call with a 404 error rather than returning found: false.',
626+
'Always true on Get Document — a missing document fails the call with a 404 rather than returning found: false.',
625627
},
626628
// Bulk outputs
627629
errors: { type: 'boolean', description: 'Whether any errors occurred' },
@@ -631,14 +633,22 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
631633
// Index outputs
632634
acknowledged: { type: 'boolean', description: 'Whether operation was acknowledged' },
633635
index: { type: 'string', description: 'Index name' },
636+
matchedCount: {
637+
type: 'number',
638+
description: 'How many indices a Get Index target matched',
639+
},
634640
aliases: { type: 'json', description: 'Aliases defined on the index' },
635641
mappings: { type: 'json', description: 'Field mappings for the index' },
636642
settings: { type: 'json', description: 'Index settings' },
637643
// Cluster outputs
638644
cluster_name: { type: 'string', description: 'Cluster name' },
639645
status: { type: 'string', description: 'Cluster health status' },
640646
number_of_nodes: { type: 'number', description: 'Number of nodes' },
641-
indices: { type: 'json', description: 'Index statistics' },
647+
indices: {
648+
type: 'json',
649+
description:
650+
'Every index the operation returned: the array of index rows from List Indices, the per-index map from Get Index (each entry carrying its aliases, mappings, settings, and any data_stream or lifecycle), or the cluster index statistics from Cluster Stats.',
651+
},
642652
nodes: { type: 'json', description: 'Node statistics' },
643653
},
644654
}

apps/sim/tools/elasticsearch/block-params.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,22 @@ describe('elasticsearch block outputs', () => {
8383
}
8484
})
8585
})
86+
87+
describe('elasticsearch block declares no unreachable aggregations', () => {
88+
it('does not advertise an aggregations output, since no subBlock or input feeds one', () => {
89+
expect(ElasticsearchBlock.outputs).not.toHaveProperty('aggregations')
90+
expect(ElasticsearchBlock.inputs).not.toHaveProperty('aggs')
91+
expect(ElasticsearchBlock.subBlocks.some((b) => b.id === 'aggs')).toBe(false)
92+
})
93+
94+
it('declares the get_index multi-target outputs so a wildcard result is reachable', () => {
95+
expect(ElasticsearchBlock.outputs.matchedCount?.type).toBe('number')
96+
expect(ElasticsearchBlock.outputs.indices?.type).toBe('json')
97+
})
98+
99+
it('still maps a bare timeout to seconds via the shared tool-side normalizer', () => {
100+
expect(transform({ timeout: '30' }).esTimeout).toBe('30s')
101+
expect(transform({ timeout: '1m' }).esTimeout).toBe('1m')
102+
expect(transform({ timeout: '45s' }).esTimeout).toBe('45s')
103+
})
104+
})

apps/sim/tools/elasticsearch/bulk.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@ import type {
22
ElasticsearchBulkParams,
33
ElasticsearchBulkResponse,
44
} from '@/tools/elasticsearch/types'
5-
import { buildAuthHeaders, buildBaseUrl } from '@/tools/elasticsearch/utils'
5+
import { buildAuthHeaders, buildBaseUrl, safeIndexPathSegment } from '@/tools/elasticsearch/utils'
66
import type { ToolConfig } from '@/tools/types'
7-
import { safeUrlPathSegment } from '@/tools/url-path'
87

98
export const bulkTool: ToolConfig<ElasticsearchBulkParams, ElasticsearchBulkResponse> = {
109
id: 'elasticsearch_bulk',
@@ -78,7 +77,7 @@ export const bulkTool: ToolConfig<ElasticsearchBulkParams, ElasticsearchBulkResp
7877
url: (params) => {
7978
const baseUrl = buildBaseUrl(params)
8079
let url = params.index
81-
? `${baseUrl}/${safeUrlPathSegment(params.index, 'index')}/_bulk`
80+
? `${baseUrl}/${safeIndexPathSegment(params.index, 'index')}/_bulk`
8281
: `${baseUrl}/_bulk`
8382

8483
if (params.refresh) {

apps/sim/tools/elasticsearch/cluster_health.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type {
22
ElasticsearchClusterHealthParams,
33
ElasticsearchClusterHealthResponse,
44
} from '@/tools/elasticsearch/types'
5-
import { buildAuthHeaders, buildBaseUrl } from '@/tools/elasticsearch/utils'
5+
import { buildAuthHeaders, buildBaseUrl, normalizeEsDuration } from '@/tools/elasticsearch/utils'
66
import type { ToolConfig } from '@/tools/types'
77

88
export const clusterHealthTool: ToolConfig<
@@ -63,8 +63,9 @@ export const clusterHealthTool: ToolConfig<
6363
esTimeout: {
6464
type: 'string',
6565
required: false,
66+
visibility: 'user-or-llm',
6667
description:
67-
'Elasticsearch wait timeout as a duration string (e.g., 30s, 1m). Named esTimeout because the executor reserves "timeout" for the transport deadline in milliseconds.',
68+
'Elasticsearch wait timeout as a duration string (e.g., 30s, 1m). A bare number is read as seconds. Named esTimeout because the executor reserves "timeout" for the transport deadline in milliseconds.',
6869
},
6970
},
7071

@@ -77,8 +78,9 @@ export const clusterHealthTool: ToolConfig<
7778
if (params.waitForStatus) {
7879
queryParams.push(`wait_for_status=${encodeURIComponent(params.waitForStatus)}`)
7980
}
80-
if (params.esTimeout) {
81-
queryParams.push(`timeout=${encodeURIComponent(params.esTimeout)}`)
81+
const esTimeout = normalizeEsDuration(params.esTimeout)
82+
if (esTimeout) {
83+
queryParams.push(`timeout=${encodeURIComponent(esTimeout)}`)
8284
}
8385
if (queryParams.length > 0) {
8486
url += `?${queryParams.join('&')}`

apps/sim/tools/elasticsearch/count.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@ import type {
22
ElasticsearchCountParams,
33
ElasticsearchCountResponse,
44
} from '@/tools/elasticsearch/types'
5-
import { buildAuthHeaders, buildBaseUrl } from '@/tools/elasticsearch/utils'
5+
import { buildAuthHeaders, buildBaseUrl, safeIndexPathSegment } from '@/tools/elasticsearch/utils'
66
import type { ToolConfig } from '@/tools/types'
7-
import { safeUrlPathSegment } from '@/tools/url-path'
87

98
export const countTool: ToolConfig<ElasticsearchCountParams, ElasticsearchCountResponse> = {
109
id: 'elasticsearch_count',
@@ -71,7 +70,7 @@ export const countTool: ToolConfig<ElasticsearchCountParams, ElasticsearchCountR
7170
request: {
7271
url: (params) => {
7372
const baseUrl = buildBaseUrl(params)
74-
return `${baseUrl}/${safeUrlPathSegment(params.index, 'index')}/_count`
73+
return `${baseUrl}/${safeIndexPathSegment(params.index, 'index')}/_count`
7574
},
7675
method: 'POST',
7776
headers: (params) => buildAuthHeaders(params),

apps/sim/tools/elasticsearch/create_index.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@ import type {
22
ElasticsearchCreateIndexParams,
33
ElasticsearchIndexResponse,
44
} from '@/tools/elasticsearch/types'
5-
import { buildAuthHeaders, buildBaseUrl } from '@/tools/elasticsearch/utils'
5+
import { buildAuthHeaders, buildBaseUrl, safeIndexPathSegment } from '@/tools/elasticsearch/utils'
66
import type { ToolConfig } from '@/tools/types'
7-
import { safeUrlPathSegment } from '@/tools/url-path'
87

98
export const createIndexTool: ToolConfig<
109
ElasticsearchCreateIndexParams,
@@ -77,7 +76,7 @@ export const createIndexTool: ToolConfig<
7776
request: {
7877
url: (params) => {
7978
const baseUrl = buildBaseUrl(params)
80-
return `${baseUrl}/${safeUrlPathSegment(params.index, 'index')}`
79+
return `${baseUrl}/${safeIndexPathSegment(params.index, 'index')}`
8180
},
8281
method: 'PUT',
8382
headers: (params) => buildAuthHeaders(params),

apps/sim/tools/elasticsearch/delete_document.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type {
22
ElasticsearchDeleteDocumentParams,
33
ElasticsearchDocumentResponse,
44
} from '@/tools/elasticsearch/types'
5-
import { buildAuthHeaders, buildBaseUrl } from '@/tools/elasticsearch/utils'
5+
import { buildAuthHeaders, buildBaseUrl, safeIndexPathSegment } from '@/tools/elasticsearch/utils'
66
import type { ToolConfig } from '@/tools/types'
77
import { safeUrlPathSegment } from '@/tools/url-path'
88

@@ -78,7 +78,7 @@ export const deleteDocumentTool: ToolConfig<
7878
request: {
7979
url: (params) => {
8080
const baseUrl = buildBaseUrl(params)
81-
let url = `${baseUrl}/${safeUrlPathSegment(params.index, 'index')}/_doc/${safeUrlPathSegment(params.documentId, 'documentId')}`
81+
let url = `${baseUrl}/${safeIndexPathSegment(params.index, 'index')}/_doc/${safeUrlPathSegment(params.documentId, 'documentId')}`
8282

8383
if (params.refresh) {
8484
url += `?refresh=${encodeURIComponent(params.refresh)}`

apps/sim/tools/elasticsearch/delete_index.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@ import type {
22
ElasticsearchDeleteIndexParams,
33
ElasticsearchIndexResponse,
44
} from '@/tools/elasticsearch/types'
5-
import { buildAuthHeaders, buildBaseUrl } from '@/tools/elasticsearch/utils'
5+
import { buildAuthHeaders, buildBaseUrl, safeIndexPathSegment } from '@/tools/elasticsearch/utils'
66
import type { ToolConfig } from '@/tools/types'
7-
import { safeUrlPathSegment } from '@/tools/url-path'
87

98
export const deleteIndexTool: ToolConfig<
109
ElasticsearchDeleteIndexParams,
@@ -67,7 +66,7 @@ export const deleteIndexTool: ToolConfig<
6766
request: {
6867
url: (params) => {
6968
const baseUrl = buildBaseUrl(params)
70-
return `${baseUrl}/${safeUrlPathSegment(params.index, 'index')}`
69+
return `${baseUrl}/${safeIndexPathSegment(params.index, 'index')}`
7170
},
7271
method: 'DELETE',
7372
headers: (params) => buildAuthHeaders(params),

apps/sim/tools/elasticsearch/get_document.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type {
22
ElasticsearchDocumentResponse,
33
ElasticsearchGetDocumentParams,
44
} from '@/tools/elasticsearch/types'
5-
import { buildAuthHeaders, buildBaseUrl } from '@/tools/elasticsearch/utils'
5+
import { buildAuthHeaders, buildBaseUrl, safeIndexPathSegment } from '@/tools/elasticsearch/utils'
66
import type { ToolConfig } from '@/tools/types'
77
import { safeUrlPathSegment } from '@/tools/url-path'
88

@@ -83,7 +83,7 @@ export const getDocumentTool: ToolConfig<
8383
request: {
8484
url: (params) => {
8585
const baseUrl = buildBaseUrl(params)
86-
let url = `${baseUrl}/${safeUrlPathSegment(params.index, 'index')}/_doc/${safeUrlPathSegment(params.documentId, 'documentId')}`
86+
let url = `${baseUrl}/${safeIndexPathSegment(params.index, 'index')}/_doc/${safeUrlPathSegment(params.documentId, 'documentId')}`
8787

8888
const queryParams: string[] = []
8989
if (params.sourceIncludes) {

apps/sim/tools/elasticsearch/get_index.ts

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import type {
22
ElasticsearchGetIndexParams,
3+
ElasticsearchIndexInfoMap,
34
ElasticsearchIndexInfoResponse,
45
} from '@/tools/elasticsearch/types'
5-
import { buildAuthHeaders, buildBaseUrl } from '@/tools/elasticsearch/utils'
6+
import { buildAuthHeaders, buildBaseUrl, safeIndexPathSegment } from '@/tools/elasticsearch/utils'
67
import type { ToolConfig } from '@/tools/types'
7-
import { safeUrlPathSegment } from '@/tools/url-path'
88

99
export const getIndexTool: ToolConfig<ElasticsearchGetIndexParams, ElasticsearchIndexInfoResponse> =
1010
{
1111
id: 'elasticsearch_get_index',
1212
name: 'Elasticsearch Get Index',
13-
description: 'Retrieve index information including settings, mappings, and aliases.',
13+
description:
14+
'Retrieve index information including settings, mappings, and aliases. Accepts a comma-separated list of indices, data streams, and aliases, and supports wildcards.',
1415
version: '1.0.0',
1516

1617
params: {
@@ -58,30 +59,38 @@ export const getIndexTool: ToolConfig<ElasticsearchGetIndexParams, Elasticsearch
5859
type: 'string',
5960
required: true,
6061
visibility: 'user-or-llm',
61-
description: 'Index name to retrieve info for (e.g., "products", "logs-2024")',
62+
description:
63+
'Index, data stream, or alias to retrieve info for. Accepts a comma-separated list and wildcards (e.g., "products", "logs-2024", "logs-*", "a,b").',
6264
},
6365
},
6466

6567
request: {
6668
url: (params) => {
6769
const baseUrl = buildBaseUrl(params)
68-
return `${baseUrl}/${safeUrlPathSegment(params.index, 'index')}`
70+
return `${baseUrl}/${safeIndexPathSegment(params.index, 'index')}`
6971
},
7072
method: 'GET',
7173
headers: (params) => buildAuthHeaders(params),
7274
},
7375

76+
/**
77+
* Elasticsearch keys this response by resolved index name, one entry per
78+
* matched target. A wildcard (`logs-*`) or comma-separated list (`a,b`)
79+
* therefore returns several entries, so taking a single key would discard
80+
* the rest with no error. Every entry is preserved under `indices`, and
81+
* `matchedCount` makes a multi-target result visible without inspecting it.
82+
*
83+
* `index`/`aliases`/`mappings`/`settings` stay flattened from the first
84+
* entry so the single-index shape the outputs declare is unchanged. That
85+
* flattening is also lossy in a second way — an entry can carry
86+
* `data_stream` and `lifecycle`, which have no flattened slot — and
87+
* `indices` is where those survive intact.
88+
*/
7489
transformResponse: async (response: Response) => {
75-
const data = (await response.json()) as Record<
76-
string,
77-
{
78-
aliases?: Record<string, unknown>
79-
mappings?: Record<string, unknown>
80-
settings?: Record<string, unknown>
81-
}
82-
>
90+
const data = (await response.json()) as ElasticsearchIndexInfoMap
8391

84-
const [indexName] = Object.keys(data)
92+
const indexNames = Object.keys(data)
93+
const [indexName] = indexNames
8594
const info = indexName ? data[indexName] : undefined
8695

8796
return {
@@ -91,6 +100,8 @@ export const getIndexTool: ToolConfig<ElasticsearchGetIndexParams, Elasticsearch
91100
aliases: info?.aliases ?? {},
92101
mappings: info?.mappings ?? {},
93102
settings: info?.settings ?? {},
103+
indices: data,
104+
matchedCount: indexNames.length,
94105
},
95106
}
96107
},
@@ -112,5 +123,14 @@ export const getIndexTool: ToolConfig<ElasticsearchGetIndexParams, Elasticsearch
112123
type: 'json',
113124
description: 'Index settings',
114125
},
126+
indices: {
127+
type: 'json',
128+
description:
129+
'Every matched index keyed by its resolved name, each with its aliases, mappings, settings, and any data_stream or lifecycle. Populated for single- and multi-target requests alike.',
130+
},
131+
matchedCount: {
132+
type: 'number',
133+
description: 'How many indices, data streams, or aliases the target matched',
134+
},
115135
},
116136
}

0 commit comments

Comments
 (0)