Skip to content

Commit 3d8e2e3

Browse files
lorenabalankibanamachineseanstory
authored
[WorkplaceAI] Enhance CRUD API with workflows & onechat (elastic#246561)
## Summary Closes [[WorkplaceAI] Add CRUD API for managing data connectors](elastic/search-team#12059) This adds capability to create/delete workflows & tools. Added some idempotency logic on DELETE so that we handle: * case where workflows or tools or ksc was already deleted - gracefully move on with deletion of any remaining resources if there any left, otherwise report success * partial cascade deletes - don't delete SO, but update it with remaining resources IDs (that we failed to delete) Also added unit tests and more logging, and refactored into helper functions for readability and to keep code DRYer. ## Testing ``` yarn test:jest x-pack/platform/plugins/shared/data_connectors/server/routes/ ``` Or spin up local kibana and test out endpoints in dev console ``` GET kbn:/api/data_connectors GET kbn:/api/data_connectors/9932f24c-9974-4c8d-9a10-79eef5368201 DELETE kbn:/api/data_connectors DELETE kbn:/api/data_connectors/9932f24c-9974-4c8d-9a10-79eef5368201 POST kbn:/api/data_connectors { "type": "notion", "name": "My beloved Notion", "token": "..." } ``` ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This was checked for breaking HTTP API changes, and any breaking changes have been approved by the breaking-change committee. The `release_note:breaking` label should be applied in these situations. - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) - [x] Review the [backport guidelines](https://docs.google.com/document/d/1VyN5k91e5OVumlc0Gb9RPa3h1ewuPE705nRtioPiTvY/edit?usp=sharing) and apply applicable `backport:*` labels. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Sean Story <sean.story@elastic.co>
1 parent 2335ab7 commit 3d8e2e3

16 files changed

Lines changed: 1770 additions & 271 deletions

File tree

src/platform/plugins/shared/workflows_management/server/workflows_management/routes/delete_workflows_bulk.test.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,12 @@ describe('DELETE /api/workflows', () => {
4747
});
4848

4949
it('should delete multiple workflows successfully', async () => {
50-
workflowsApi.deleteWorkflows = jest.fn().mockResolvedValue(undefined);
50+
const mockResult = {
51+
total: 3,
52+
deleted: 3,
53+
failures: [],
54+
};
55+
workflowsApi.deleteWorkflows = jest.fn().mockResolvedValue(mockResult);
5156

5257
const mockContext = {};
5358
const mockRequest = {
@@ -66,11 +71,16 @@ describe('DELETE /api/workflows', () => {
6671
'default',
6772
mockRequest
6873
);
69-
expect(mockResponse.ok).toHaveBeenCalledWith();
74+
expect(mockResponse.ok).toHaveBeenCalledWith({ body: mockResult });
7075
});
7176

7277
it('should handle empty ids array gracefully', async () => {
73-
workflowsApi.deleteWorkflows = jest.fn().mockResolvedValue(undefined);
78+
const mockResult = {
79+
total: 3,
80+
deleted: 3,
81+
failures: [],
82+
};
83+
workflowsApi.deleteWorkflows = jest.fn().mockResolvedValue(mockResult);
7484

7585
const mockContext = {};
7686
const mockRequest = {
@@ -85,7 +95,7 @@ describe('DELETE /api/workflows', () => {
8595
await routeHandler(mockContext, mockRequest, mockResponse);
8696

8797
expect(workflowsApi.deleteWorkflows).toHaveBeenCalledWith([], 'default', mockRequest);
88-
expect(mockResponse.ok).toHaveBeenCalledWith();
98+
expect(mockResponse.ok).toHaveBeenCalledWith({ body: mockResult });
8999
});
90100

91101
it('should handle API errors gracefully', async () => {
@@ -113,7 +123,12 @@ describe('DELETE /api/workflows', () => {
113123
});
114124

115125
it('should work with different space contexts', async () => {
116-
workflowsApi.deleteWorkflows = jest.fn().mockResolvedValue(undefined);
126+
const mockResult = {
127+
total: 2,
128+
deleted: 2,
129+
failures: [],
130+
};
131+
workflowsApi.deleteWorkflows = jest.fn().mockResolvedValue(mockResult);
117132
mockSpaces.getSpaceId = jest.fn().mockReturnValue('custom-space');
118133

119134
const mockContext = {};
@@ -133,7 +148,7 @@ describe('DELETE /api/workflows', () => {
133148
'custom-space',
134149
mockRequest
135150
);
136-
expect(mockResponse.ok).toHaveBeenCalledWith();
151+
expect(mockResponse.ok).toHaveBeenCalledWith({ body: mockResult });
137152
});
138153

139154
it('should handle Elasticsearch connection errors', async () => {

src/platform/plugins/shared/workflows_management/server/workflows_management/routes/delete_workflows_bulk.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ export function registerDeleteWorkflowsBulkRoute({
3434
try {
3535
const { ids } = request.body as { ids: string[] };
3636
const spaceId = spaces.getSpaceId(request);
37-
await api.deleteWorkflows(ids, spaceId, request);
38-
return response.ok();
37+
const result = await api.deleteWorkflows(ids, spaceId, request);
38+
return response.ok({ body: result });
3939
} catch (error) {
4040
return handleRouteError(response, error);
4141
}

src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_api.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@ export interface GetWorkflowsParams {
5252
_full?: boolean;
5353
}
5454

55+
export interface DeleteWorkflowsResponse {
56+
total: number;
57+
deleted: number;
58+
failures: Array<{
59+
id: string;
60+
error: string;
61+
}>;
62+
}
63+
5564
export interface GetWorkflowExecutionLogsParams {
5665
executionId: string;
5766
stepExecutionId?: string;
@@ -170,7 +179,7 @@ export class WorkflowsManagementApi {
170179
workflowIds: string[],
171180
spaceId: string,
172181
request: KibanaRequest
173-
): Promise<void> {
182+
): Promise<DeleteWorkflowsResponse> {
174183
return this.workflowsService.deleteWorkflows(workflowIds, spaceId);
175184
}
176185

src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_service.test.ts

Lines changed: 82 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ describe('WorkflowsService', () => {
8282
index: jest.fn().mockResolvedValue({ _id: 'test-id' }),
8383
update: jest.fn().mockResolvedValue({ _id: 'test-id' }),
8484
delete: jest.fn().mockResolvedValue({ _id: 'test-id' }),
85+
bulk: jest.fn().mockResolvedValue({ items: [] }),
8586
} as any;
8687

8788
mockLogger = loggerMock.create();
@@ -1193,14 +1194,27 @@ steps:
11931194
total: { value: 1 },
11941195
},
11951196
} as any);
1196-
mockEsClient.index.mockResolvedValue({ _id: 'test-workflow-id' } as any);
1197+
mockEsClient.bulk.mockResolvedValue({
1198+
items: [
1199+
{
1200+
index: {
1201+
_id: 'test-workflow-id',
1202+
status: 200,
1203+
},
1204+
},
1205+
],
1206+
} as any);
11971207

1198-
await service.deleteWorkflows(['test-workflow-id'], 'default');
1208+
const result = await service.deleteWorkflows(['test-workflow-id'], 'default');
1209+
1210+
expect(result).toEqual({
1211+
total: 1,
1212+
deleted: 1,
1213+
failures: [],
1214+
});
11991215

12001216
expect(mockEsClient.search).toHaveBeenCalledWith(
12011217
expect.objectContaining({
1202-
index: '.workflows-workflows',
1203-
allow_no_indices: true,
12041218
query: {
12051219
bool: {
12061220
must: [{ ids: { values: ['test-workflow-id'] } }, { term: { spaceId: 'default' } }],
@@ -1209,18 +1223,16 @@ steps:
12091223
size: 1,
12101224
})
12111225
);
1212-
expect(mockEsClient.index).toHaveBeenCalledWith(
1213-
expect.objectContaining({
1214-
id: 'test-workflow-id',
1215-
index: '.workflows-workflows',
1216-
document: expect.objectContaining({
1217-
enabled: false,
1218-
deleted_at: expect.any(Date),
1219-
}),
1220-
refresh: 'wait_for',
1221-
require_alias: true,
1222-
})
1223-
);
1226+
// Verify bulk was called with correct structure
1227+
const bulkCall = mockEsClient.bulk.mock.calls[0][0];
1228+
expect(bulkCall.index).toBe('.workflows-workflows');
1229+
expect(bulkCall.operations).toBeDefined();
1230+
expect(bulkCall.operations).toHaveLength(2); // metadata + document
1231+
expect(bulkCall.operations![0]).toEqual({ index: { _id: 'test-workflow-id' } });
1232+
expect(bulkCall.operations![1]).toMatchObject({
1233+
enabled: false,
1234+
deleted_at: expect.any(Date),
1235+
});
12241236
});
12251237

12261238
it('should handle not found workflows gracefully', async () => {
@@ -1231,7 +1243,60 @@ steps:
12311243
},
12321244
} as any);
12331245

1234-
await expect(service.deleteWorkflows(['non-existent-id'], 'default')).resolves.not.toThrow();
1246+
const result = await service.deleteWorkflows(['non-existent-id'], 'default');
1247+
1248+
expect(result).toEqual({
1249+
total: 1,
1250+
deleted: 1,
1251+
failures: [],
1252+
});
1253+
});
1254+
1255+
it('should handle partial failures when deleting multiple workflows', async () => {
1256+
const mockWorkflowDocument2 = {
1257+
_id: 'workflow-2',
1258+
_source: {
1259+
...mockWorkflowDocument._source,
1260+
name: 'Test Workflow 2',
1261+
},
1262+
};
1263+
1264+
// Mock search to return both workflows
1265+
mockEsClient.search.mockResolvedValue({
1266+
hits: {
1267+
hits: [{ ...mockWorkflowDocument, _id: 'workflow-1' }, mockWorkflowDocument2],
1268+
total: { value: 2 },
1269+
},
1270+
} as any);
1271+
1272+
// Mock bulk operation where one succeeds and one fails
1273+
mockEsClient.bulk.mockResolvedValue({
1274+
items: [
1275+
{
1276+
index: {
1277+
_id: 'workflow-1',
1278+
status: 200,
1279+
},
1280+
},
1281+
{
1282+
index: {
1283+
_id: 'workflow-2',
1284+
status: 500,
1285+
error: {
1286+
reason: 'Database error',
1287+
},
1288+
},
1289+
},
1290+
],
1291+
} as any);
1292+
1293+
const result = await service.deleteWorkflows(['workflow-1', 'workflow-2'], 'default');
1294+
1295+
expect(result).toEqual({
1296+
total: 2,
1297+
deleted: 1,
1298+
failures: [{ id: 'workflow-2', error: 'Database error' }],
1299+
});
12351300
});
12361301
});
12371302

src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_service.ts

Lines changed: 57 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import { searchStepExecutions } from './lib/search_step_executions';
5555
import { searchWorkflowExecutions } from './lib/search_workflow_executions';
5656

5757
import type {
58+
DeleteWorkflowsResponse,
5859
GetAvailableConnectorsResponse,
5960
GetStepExecutionParams,
6061
GetWorkflowsParams,
@@ -468,47 +469,74 @@ export class WorkflowsService {
468469
}
469470
}
470471

471-
public async deleteWorkflows(ids: string[], spaceId: string): Promise<void> {
472+
public async deleteWorkflows(ids: string[], spaceId: string): Promise<DeleteWorkflowsResponse> {
472473
if (!this.workflowStorage) {
473474
throw new Error('WorkflowsService not initialized');
474475
}
475476

476477
const now = new Date();
478+
const failures: Array<{ id: string; error: string }> = [];
479+
const client = this.workflowStorage.getClient();
477480

478-
// Soft delete by setting deleted_at timestamp
479-
for (const id of ids) {
481+
// Bulk fetch all workflows in a single search call
482+
const searchResponse = await client.search({
483+
query: {
484+
bool: {
485+
must: [{ ids: { values: ids } }, { term: { spaceId } }],
486+
},
487+
},
488+
size: ids.length,
489+
track_total_hits: false,
490+
});
491+
492+
// Build bulk operations for all found workflows
493+
const bulkOperations = searchResponse.hits.hits.map((hit) => ({
494+
index: {
495+
_id: hit._id,
496+
document: {
497+
...hit._source,
498+
deleted_at: now,
499+
enabled: false,
500+
},
501+
},
502+
}));
503+
504+
// Bulk update all found workflows in a single call
505+
if (bulkOperations.length > 0) {
480506
try {
481-
// Check if workflow exists and belongs to the correct space
482-
const searchResponse = await this.workflowStorage.getClient().search({
483-
query: {
484-
bool: {
485-
must: [{ ids: { values: [id] } }, { term: { spaceId } }],
486-
},
487-
},
488-
size: 1,
489-
track_total_hits: false,
507+
const bulkResponse = await client.bulk({
508+
operations: bulkOperations,
490509
});
491510

492-
if (searchResponse.hits.hits.length > 0) {
493-
const existingDocument = searchResponse.hits.hits[0];
494-
const updatedData = {
495-
...existingDocument._source,
496-
deleted_at: now,
497-
enabled: false,
498-
};
499-
500-
await this.workflowStorage.getClient().index({
501-
id,
502-
document: updatedData,
503-
});
504-
}
511+
// Process bulk response to track successes and failures
512+
bulkResponse.items.forEach((item) => {
513+
const operation = item.index;
514+
if (operation?.error) {
515+
failures.push({
516+
id: operation._id ?? 'unknown',
517+
error:
518+
typeof operation.error === 'object' && 'reason' in operation.error
519+
? operation.error.reason ?? JSON.stringify(operation.error)
520+
: JSON.stringify(operation.error),
521+
});
522+
}
523+
});
505524
} catch (error) {
506-
if (error.statusCode !== 404) {
507-
throw error;
508-
}
509-
// Ignore not found errors for soft delete
525+
// If the entire bulk operation fails, mark all as failed
526+
bulkOperations.forEach((op) => {
527+
failures.push({
528+
id: op.index._id ?? 'unknown',
529+
error: error instanceof Error ? error.message : String(error),
530+
});
531+
});
510532
}
511533
}
534+
535+
return {
536+
total: ids.length,
537+
deleted: ids.length - failures.length,
538+
failures,
539+
};
512540
}
513541

514542
public async getWorkflows(params: GetWorkflowsParams, spaceId: string): Promise<WorkflowListDto> {

x-pack/platform/plugins/shared/data_connectors/kibana.jsonc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"browser": true,
1111
"server": true,
1212
"configPath": ["xpack", "dataConnectors"],
13-
"requiredPlugins": ["actions", "dataSourcesRegistry"],
13+
"requiredPlugins": ["actions", "dataSourcesRegistry", "agentBuilder", "workflowsManagement"],
1414
"optionalPlugins": [],
1515
"requiredBundles": ["kibanaReact"],
1616
"extraPublicDirs": [

x-pack/platform/plugins/shared/data_connectors/moon.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,16 @@ dependsOn:
3131
- '@kbn/connector-specs'
3232
- '@kbn/actions-plugin'
3333
- '@kbn/core-saved-objects-common'
34+
- '@kbn/core-saved-objects-api-server-mocks'
35+
- '@kbn/logging-mocks'
36+
- '@kbn/core-http-server-mocks'
37+
- '@kbn/core-saved-objects-api-server'
38+
- '@kbn/core-http-server'
39+
- '@kbn/logging'
40+
- '@kbn/core-saved-objects-utils-server'
41+
- '@kbn/workflows-management-plugin'
42+
- '@kbn/agent-builder-common'
43+
- '@kbn/agent-builder-plugin'
3444
tags:
3545
- plugin
3646
- prod

x-pack/platform/plugins/shared/data_connectors/server/data_sources/notion/workflows.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ inputs:
2121
- "data_source"
2222
steps:
2323
- name: search-page-by-title
24-
type: notion.searchPageByTitle
24+
type: notion.searchPageOrDSByTitle
2525
connector-id: ${stackConnectorId}
2626
with:
2727
query: "\${{inputs.query_string}}"

0 commit comments

Comments
 (0)