diff --git a/x-pack/platform/plugins/shared/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap b/x-pack/platform/plugins/shared/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap index 23a7e09e2cb01..f39ea3e8dcd28 100644 --- a/x-pack/platform/plugins/shared/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap +++ b/x-pack/platform/plugins/shared/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap @@ -10053,30 +10053,73 @@ Object { ], "type": "string", }, - "message": Object { + "prompts": Object { "flags": Object { "error": [Function], }, - "metas": Array [ - Object { - "x-oas-min-length": 1, - }, - ], - "rules": Array [ + "items": Array [ Object { - "args": Object { - "method": [Function], + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", }, - "name": "custom", - }, - Object { - "args": Object { - "method": [Function], + "keys": Object { + "message": Object { + "flags": Object { + "error": [Function], + }, + "metas": Array [ + Object { + "x-oas-min-length": 1, + }, + ], + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "statuses": Object { + "flags": Object { + "error": [Function], + }, + "items": Array [ + Object { + "flags": Object { + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + ], + "type": "array", + }, }, - "name": "custom", + "type": "object", }, ], - "type": "string", + "type": "array", }, "rule": Object { "flags": Object { diff --git a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/common/constants.ts b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/common/constants.ts new file mode 100644 index 0000000000000..f5977cd72ad7b --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/common/constants.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + ALERT_STATUS_ACTIVE, + ALERT_STATUS_RECOVERED, + ALERT_STATUS_UNTRACKED, +} from '@kbn/rule-data-utils'; + +export const ALERT_STATUSES = [ALERT_STATUS_ACTIVE, ALERT_STATUS_RECOVERED, ALERT_STATUS_UNTRACKED]; diff --git a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/rule_connector/ai_assistant.tsx b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/rule_connector/ai_assistant.tsx index 79d9678733941..ea3590740ab57 100644 --- a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/rule_connector/ai_assistant.tsx +++ b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/rule_connector/ai_assistant.tsx @@ -36,7 +36,11 @@ export function getConnectorType( actionParams: ObsAIAssistantActionParams ): Promise> => { const validationResult = { - errors: { connector: new Array(), message: new Array() }, + errors: { + connector: [] as string[], + message: [] as string[], + prompts: [] as string[], + }, }; if (!actionParams.connector) { diff --git a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/rule_connector/types.ts b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/rule_connector/types.ts index ed19f3cb74794..08afd5b62a14b 100644 --- a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/rule_connector/types.ts +++ b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/rule_connector/types.ts @@ -7,5 +7,9 @@ export interface ObsAIAssistantActionParams { connector: string; - message: string; + prompts?: Array<{ + message: string; + statuses: string[]; + }>; + message?: string; // this is a legacy field } diff --git a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/server/rule_connector/index.test.ts b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/server/rule_connector/index.test.ts index 04fd10c3e506f..e2c0f97d14a0d 100644 --- a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/server/rule_connector/index.test.ts +++ b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/server/rule_connector/index.test.ts @@ -8,7 +8,9 @@ import { AlertHit } from '@kbn/alerting-plugin/server/types'; import { ObservabilityAIAssistantRouteHandlerResources } from '@kbn/observability-ai-assistant-plugin/server/routes/types'; import { getFakeKibanaRequest } from '@kbn/security-plugin/server/authentication/api_keys/fake_kibana_request'; +import { ALERT_STATUS_ACTIVE } from '@kbn/rule-data-utils'; import { OBSERVABILITY_AI_ASSISTANT_CONNECTOR_ID } from '../../common/rule_connector'; +import { ALERT_STATUSES } from '../../common/constants'; import { getObsAIAssistantConnectorAdapter, getObsAIAssistantConnectorType, @@ -18,6 +20,63 @@ import { Observable } from 'rxjs'; import { MessageRole } from '@kbn/observability-ai-assistant-plugin/public'; import { AlertDetailsContextualInsightsService } from '@kbn/observability-plugin/server/services'; +const buildConversation = (contentMessage: string) => [ + { + '@timestamp': expect.any(String), + message: { + role: MessageRole.System, + content: '', + }, + }, + { + '@timestamp': expect.any(String), + message: { + role: MessageRole.User, + content: contentMessage, + }, + }, + { + '@timestamp': expect.any(String), + message: { + role: MessageRole.Assistant, + content: '', + function_call: { + name: 'get_alerts_context', + arguments: JSON.stringify({}), + trigger: MessageRole.Assistant as const, + }, + }, + }, + { + '@timestamp': expect.any(String), + message: { + role: MessageRole.User, + name: 'get_alerts_context', + content: expect.any(String), + }, + }, + { + '@timestamp': expect.any(String), + message: { + role: MessageRole.Assistant, + content: '', + function_call: { + name: 'get_connectors', + arguments: JSON.stringify({}), + trigger: MessageRole.Assistant as const, + }, + }, + }, + { + '@timestamp': expect.any(String), + message: { + role: MessageRole.User, + name: 'get_connectors', + content: JSON.stringify({ connectors: [{ id: 'connector_1' }] }), + }, + }, +]; + describe('observabilityAIAssistant rule_connector', () => { describe('getObsAIAssistantConnectorAdapter', () => { it('uses correct connector_id', () => { @@ -42,7 +101,7 @@ describe('observabilityAIAssistant rule_connector', () => { expect(params).toEqual({ connector: '.azure', - message: 'hello', + prompts: [{ message: 'hello', statuses: ALERT_STATUSES }], rule: { id: 'foo', name: 'bar', tags: [], ruleUrl: 'http://myrule.com' }, alerts: { new: [{ _id: 'new_alert' }], @@ -52,11 +111,85 @@ describe('observabilityAIAssistant rule_connector', () => { }); }); - describe('getObsAIAssistantConnectorType', () => { - it('is correctly configured', () => { - const initResources = jest - .fn() - .mockResolvedValue({} as ObservabilityAIAssistantRouteHandlerResources); + describe('Connector Type - getObsAIAssistantConnectorType', () => { + const completeMock = jest.fn().mockReturnValue(new Observable()); + + const initResources = jest.fn().mockResolvedValue({ + service: { + getClient: async () => ({ complete: completeMock }), + getFunctionClient: async () => ({ + getFunctions: () => [], + getInstructions: () => [], + getAdhocInstructions: () => [], + }), + }, + context: {}, + plugins: { + core: { + start: () => + Promise.resolve({ + http: { basePath: { publicBaseUrl: 'http://kibana.com' } }, + }), + }, + actions: { + start: async () => { + return { + getActionsClientWithRequest: jest.fn().mockResolvedValue({ + async getAll() { + return [{ id: 'connector_1' }]; + }, + }), + }; + }, + }, + }, + } as unknown as ObservabilityAIAssistantRouteHandlerResources); + + const adapter = getObsAIAssistantConnectorAdapter(); + const buildActionParams = (params: { + connector: string; + message?: string; + prompts?: Array<{ message: string; statuses: string[] }>; + }) => { + return adapter.buildActionParams({ + params, + rule: { id: 'foo', name: 'bar', tags: [], consumer: '', producer: '' }, + spaceId: 'default', + alerts: { + all: { count: 1, data: [] }, + new: { + count: 1, + data: [ + { + '@timestamp': new Date().toISOString(), + _id: 'new_alert', + _index: 'alert_index', + 'kibana.alert.instance.id': 'instance_id', + 'kibana.alert.rule.category': 'rule_category', + 'kibana.alert.rule.consumer': 'rule_consumer', + 'kibana.alert.rule.name': 'rule_name', + 'kibana.alert.rule.producer': 'rule_producer', + 'kibana.alert.rule.revision': 1, + 'kibana.alert.rule.tags': [], + 'kibana.alert.rule.rule_type_id': 'rule_type_id', + 'kibana.alert.uuid': 'alert_uuid', + 'kibana.alert.rule.uuid': 'rule_uuid', + 'kibana.alert.start': new Date().toISOString(), + 'kibana.alert.status': ALERT_STATUS_ACTIVE, + 'kibana.space_ids': ['default'], + }, + ], + }, + ongoing: { count: 1, data: [] }, + recovered: { count: 0, data: [] }, + }, + }); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should have the correct configuration', () => { const connectorType = getObsAIAssistantConnectorType( initResources, new AlertDetailsContextualInsightsService() @@ -66,10 +199,7 @@ describe('observabilityAIAssistant rule_connector', () => { expect(connectorType.minimumLicenseRequired).toEqual('enterprise'); }); - it('does not execute when no new or recovered alerts', async () => { - const initResources = jest - .fn() - .mockResolvedValue({} as ObservabilityAIAssistantRouteHandlerResources); + it('should not execute when there are no new or recovered alerts', async () => { const connectorType = getObsAIAssistantConnectorType( initResources, new AlertDetailsContextualInsightsService() @@ -83,38 +213,40 @@ describe('observabilityAIAssistant rule_connector', () => { expect(initResources).not.toHaveBeenCalled(); }); - it('calls complete api', async () => { - const completeMock = jest.fn().mockReturnValue(new Observable()); - const initResources = jest.fn().mockResolvedValue({ - service: { - getClient: async () => ({ complete: completeMock }), - getFunctionClient: async () => ({ - getFunctions: () => [], - getInstructions: () => [], - getAdhocInstructions: () => [], - }), - }, - context: {}, - plugins: { - core: { - start: () => - Promise.resolve({ - http: { basePath: { publicBaseUrl: 'http://kibana.com' } }, - }), - }, - actions: { - start: async () => { - return { - getActionsClientWithRequest: jest.fn().mockResolvedValue({ - async getAll() { - return [{ id: 'connector_1' }]; - }, - }), - }; - }, - }, - }, - } as unknown as ObservabilityAIAssistantRouteHandlerResources); + it('should call the complete API with a single message', async () => { + const message = 'hello'; + const params = buildActionParams({ connector: 'azure-open-ai', message }); + const connectorType = getObsAIAssistantConnectorType( + initResources, + new AlertDetailsContextualInsightsService() + ); + const result = await connectorType.executor({ + actionId: 'observability-ai-assistant', + request: getFakeKibanaRequest({ id: 'foo', api_key: 'bar' }), + params, + } as unknown as ObsAIAssistantConnectorTypeExecutorOptions); + + expect(result).toEqual({ actionId: 'observability-ai-assistant', status: 'ok' }); + expect(initResources).toHaveBeenCalledTimes(1); + expect(completeMock).toHaveBeenCalledTimes(1); + + expect(completeMock).toHaveBeenCalledWith( + expect.objectContaining({ + persist: true, + isPublic: true, + connectorId: 'azure-open-ai', + kibanaPublicUrl: 'http://kibana.com', + messages: buildConversation(message), + }) + ); + }); + + it('executes the complete API with a single prompt', async () => { + const message = 'hello'; + const params = buildActionParams({ + connector: 'azure-open-ai', + prompts: [{ message, statuses: ALERT_STATUSES }], + }); const connectorType = getObsAIAssistantConnectorType( initResources, @@ -123,11 +255,7 @@ describe('observabilityAIAssistant rule_connector', () => { const result = await connectorType.executor({ actionId: 'observability-ai-assistant', request: getFakeKibanaRequest({ id: 'foo', api_key: 'bar' }), - params: { - message: 'hello', - connector: 'azure-open-ai', - alerts: { new: [{ _id: 'new_alert' }], recovered: [] }, - }, + params, } as unknown as ObsAIAssistantConnectorTypeExecutorOptions); expect(result).toEqual({ actionId: 'observability-ai-assistant', status: 'ok' }); @@ -140,62 +268,52 @@ describe('observabilityAIAssistant rule_connector', () => { isPublic: true, connectorId: 'azure-open-ai', kibanaPublicUrl: 'http://kibana.com', - messages: [ - { - '@timestamp': expect.any(String), - message: { - role: MessageRole.System, - content: '', - }, - }, - { - '@timestamp': expect.any(String), - message: { - role: MessageRole.User, - content: 'hello', - }, - }, - { - '@timestamp': expect.any(String), - message: { - role: MessageRole.Assistant, - content: '', - function_call: { - name: 'get_alerts_context', - arguments: JSON.stringify({}), - trigger: MessageRole.Assistant as const, - }, - }, - }, - { - '@timestamp': expect.any(String), - message: { - role: MessageRole.User, - name: 'get_alerts_context', - content: expect.any(String), - }, - }, - { - '@timestamp': expect.any(String), - message: { - role: MessageRole.Assistant, - content: '', - function_call: { - name: 'get_connectors', - arguments: JSON.stringify({}), - trigger: MessageRole.Assistant as const, - }, - }, - }, - { - '@timestamp': expect.any(String), - message: { - role: MessageRole.User, - name: 'get_connectors', - content: JSON.stringify({ connectors: [{ id: 'connector_1' }] }), - }, - }, - ], + messages: buildConversation(message), + }) + ); + }); + + it('should call the complete API with multiple prompts', async () => { + const message = 'hello'; + const message2 = 'bye'; + const params = buildActionParams({ + connector: 'azure-open-ai', + prompts: [ + { message, statuses: ALERT_STATUSES }, + { message: message2, statuses: ALERT_STATUSES }, + ], + }); + + const connectorType = getObsAIAssistantConnectorType( + initResources, + new AlertDetailsContextualInsightsService() + ); + const result = await connectorType.executor({ + actionId: 'observability-ai-assistant', + request: getFakeKibanaRequest({ id: 'foo', api_key: 'bar' }), + params, + } as unknown as ObsAIAssistantConnectorTypeExecutorOptions); + + expect(result).toEqual({ actionId: 'observability-ai-assistant', status: 'ok' }); + expect(initResources).toHaveBeenCalledTimes(1); + expect(completeMock).toHaveBeenCalledTimes(2); + + expect(completeMock).toHaveBeenCalledWith( + expect.objectContaining({ + persist: true, + isPublic: true, + connectorId: 'azure-open-ai', + kibanaPublicUrl: 'http://kibana.com', + messages: buildConversation(message), + }) + ); + expect(completeMock).toHaveBeenCalledWith( + expect.objectContaining({ + persist: true, + isPublic: true, + connectorId: 'azure-open-ai', + kibanaPublicUrl: 'http://kibana.com', + messages: buildConversation(message2), }) ); }); diff --git a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/server/rule_connector/index.ts b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/server/rule_connector/index.ts index 7ad9d78889461..207e4e9488354 100644 --- a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/server/rule_connector/index.ts +++ b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/server/rule_connector/index.ts @@ -37,8 +37,13 @@ import { AlertDetailsContextualInsightsService } from '@kbn/observability-plugin import { getSystemMessageFromInstructions } from '@kbn/observability-ai-assistant-plugin/server/service/util/get_system_message_from_instructions'; import { AdHocInstruction } from '@kbn/observability-ai-assistant-plugin/common/types'; import { EXECUTE_CONNECTOR_FUNCTION_NAME } from '@kbn/observability-ai-assistant-plugin/server/functions/execute_connector'; +import { ObservabilityAIAssistantClient } from '@kbn/observability-ai-assistant-plugin/server'; +import { ChatFunctionClient } from '@kbn/observability-ai-assistant-plugin/server/service/chat_function_client'; +import { ActionsClient } from '@kbn/actions-plugin/server'; +import { PublicMethodsOf } from '@kbn/utility-types'; import { convertSchemaToOpenApi } from './convert_schema_to_open_api'; import { OBSERVABILITY_AI_ASSISTANT_CONNECTOR_ID } from '../../common/rule_connector'; +import { ALERT_STATUSES } from '../../common/constants'; const CONNECTOR_PRIVILEGES = ['api:observabilityAIAssistant', 'app:observabilityAIAssistant']; @@ -64,7 +69,16 @@ const connectorParamsSchemas: Record = { const ParamsSchema = schema.object({ connector: schema.string(), - message: schema.string({ minLength: 1 }), + prompts: schema.maybe( + schema.arrayOf( + schema.object({ + statuses: schema.arrayOf(schema.string()), + message: schema.string({ minLength: 1 }), + }) + ) + ), + status: schema.maybe(schema.string()), + message: schema.maybe(schema.string({ minLength: 1 })), // this is a legacy field }); const RuleSchema = schema.object({ @@ -83,7 +97,12 @@ const AlertSummarySchema = schema.object({ const ConnectorParamsSchema = schema.object({ connector: schema.string(), - message: schema.string({ minLength: 1 }), + prompts: schema.arrayOf( + schema.object({ + statuses: schema.arrayOf(schema.string()), + message: schema.string({ minLength: 1 }), + }) + ), rule: RuleSchema, alerts: AlertSummarySchema, }); @@ -140,7 +159,7 @@ function renderParameterTemplates( ): ConnectorParamsType { return { connector: params.connector, - message: params.message, + prompts: params.prompts, rule: params.rule, alerts: params.alerts, }; @@ -151,19 +170,18 @@ async function executor( initResources: (request: KibanaRequest) => Promise, alertDetailsContextService: AlertDetailsContextualInsightsService ): Promise> { - const request = execOptions.request; - const alerts = execOptions.params.alerts; - - if (!request) { - throw new Error('AI Assistant connector requires a kibana request'); - } + const { request, params } = execOptions; - if (alerts.new.length === 0 && alerts.recovered.length === 0) { + if ((params.alerts?.new || []).length === 0 && (params.alerts?.recovered || []).length === 0) { // connector could be executed with only ongoing actions. we use this path as // dedup mechanism to prevent triggering the same worfklow for an ongoing alert return { actionId: execOptions.actionId, status: 'ok' }; } + if (!request) { + throw new Error('AI Assistant connector requires a kibana request'); + } + const resources = await initResources(request); const client = await resources.service.getClient({ request, scopes: ['observability'] }); const functionClient = await resources.service.getFunctionClient({ @@ -177,6 +195,52 @@ async function executor( await resources.plugins.actions.start() ).getActionsClientWithRequest(request); + await Promise.all( + params.prompts.map((prompt) => + executeAlertsChatCompletion( + resources, + prompt, + params, + alertDetailsContextService, + client, + functionClient, + actionsClient, + execOptions.logger + ) + ) + ); + + return { actionId: execOptions.actionId, status: 'ok' }; +} + +async function executeAlertsChatCompletion( + resources: ObservabilityAIAssistantRouteHandlerResources, + prompt: { statuses: string[]; message: string }, + params: ConnectorParamsType, + alertDetailsContextService: AlertDetailsContextualInsightsService, + client: ObservabilityAIAssistantClient, + functionClient: ChatFunctionClient, + actionsClient: PublicMethodsOf, + logger: Logger +): Promise { + const alerts = { + new: [...(params.alerts?.new || [])], + recovered: [...(params.alerts?.recovered || [])], + }; + + if (ALERT_STATUSES.some((status) => prompt.statuses.includes(status))) { + alerts.new = alerts.new.filter((alert) => + prompt.statuses.includes(get(alert, 'kibana.alert.status')) + ); + alerts.recovered = alerts.recovered.filter((alert) => + prompt.statuses.includes(get(alert, 'kibana.alert.status')) + ); + } + + if (alerts.new.length === 0 && alerts.recovered.length === 0) { + return; + } + const connectorsList = await actionsClient.getAll().then((connectors) => { return connectors.map((connector) => { if (connector.actionTypeId in connectorParamsSchemas) { @@ -210,8 +274,8 @@ If available, include the link of the conversation at the end of your answer.` text: dedent( `The execute_connector function can be used to invoke Kibana connectors. To send to the Slack connector, you need the following arguments: - - the "id" of the connector - - the "params" parameter that you will fill with the message + - the "id" of the connector + - the "params" parameter that you will fill with the message Please include both "id" and "params.message" in the function arguments when executing the Slack connector..` ), }; @@ -219,10 +283,10 @@ If available, include the link of the conversation at the end of your answer.` } const alertsContext = await getAlertsContext( - execOptions.params.rule, - execOptions.params.alerts, + params.rule, + alerts, async (alert: Record) => { - const prompt = await alertDetailsContextService.getAlertDetailsContext( + const alertDetailsContext = await alertDetailsContextService.getAlertDetailsContext( { core: resources.context.core, licensing: resources.context.licensing, @@ -235,7 +299,7 @@ If available, include the link of the conversation at the end of your answer.` 'host.name': get(alert, 'host.name'), } ); - return prompt + return alertDetailsContext .map(({ description, data }) => `${description}:\n${JSON.stringify(data, null, 2)}`) .join('\n\n'); } @@ -246,7 +310,7 @@ If available, include the link of the conversation at the end of your answer.` functionClient, persist: true, isPublic: true, - connectorId: execOptions.params.connector, + connectorId: params.connector, signal: new AbortController().signal, kibanaPublicUrl: (await resources.plugins.core.start()).http.basePath.publicBaseUrl, instructions: [backgroundInstruction], @@ -267,7 +331,7 @@ If available, include the link of the conversation at the end of your answer.` '@timestamp': new Date().toISOString(), message: { role: MessageRole.User, - content: execOptions.params.message, + content: prompt.message, }, }, { @@ -323,11 +387,9 @@ If available, include the link of the conversation at the end of your answer.` .pipe(concatenateChatCompletionChunks()) .subscribe({ error: (err) => { - execOptions.logger.error(err); + logger.error(err); }, }); - - return { actionId: execOptions.actionId, status: 'ok' }; } export const getObsAIAssistantConnectorAdapter = (): ConnectorAdapter< @@ -341,7 +403,15 @@ export const getObsAIAssistantConnectorAdapter = (): ConnectorAdapter< buildActionParams: ({ params, rule, ruleUrl, alerts }) => { return { connector: params.connector, - message: params.message, + // Ensure backwards compatibility by using the message field as a prompt if prompts are missing + prompts: params.prompts + ? params.prompts + : [ + { + statuses: ALERT_STATUSES, + message: params.message || '', + }, + ], rule: { id: rule.id, name: rule.name, tags: rule.tags, ruleUrl: ruleUrl ?? null }, alerts: { new: alerts.new.data, diff --git a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/tsconfig.json b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/tsconfig.json index 212a36a502441..6dc33536ebac1 100644 --- a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/tsconfig.json +++ b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/tsconfig.json @@ -82,8 +82,8 @@ "@kbn/product-doc-common", "@kbn/charts-theme", "@kbn/ai-assistant-icon", + "@kbn/rule-data-utils", + "@kbn/utility-types", ], - "exclude": [ - "target/**/*" - ] + "exclude": ["target/**/*"] }