@kapeta/local-cluster-service 0.60.2 → 0.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,7 +13,7 @@ import { stringBody } from '../middleware/stringBody';
13
13
  import { KapetaBodyRequest } from '../types';
14
14
  import { StormCodegenRequest, StormContextRequest, StormCreateBlockRequest, StormStream } from './stream';
15
15
  import { ConversationIdHeader, stormClient, UIPagePrompt, UIPageEditPrompt, UIPageEditRequest } from './stormClient';
16
- import { Page, StormEvent, StormEventPage, StormEventPhaseType } from './events';
16
+ import { Page, StormEvent, StormEventPage, StormEventPhaseType, UIShell, UserJourneyScreen } from './events';
17
17
  import {
18
18
  createPhaseEndEvent,
19
19
  createPhaseStartEvent,
@@ -132,7 +132,9 @@ router.post('/:handle/ui', async (req: KapetaBodyRequest, res: Response) => {
132
132
 
133
133
  const aiRequest: StormContextRequest = JSON.parse(req.stringBody ?? '{}');
134
134
 
135
+ // Get user journeys
135
136
  const userJourneysStream = await stormClient.createUIUserJourneys(aiRequest.prompt, conversationId);
137
+ const outerConversationId = userJourneysStream.getConversationId();
136
138
 
137
139
  onRequestAborted(req, res, () => {
138
140
  userJourneysStream.abort();
@@ -140,23 +142,12 @@ router.post('/:handle/ui', async (req: KapetaBodyRequest, res: Response) => {
140
142
 
141
143
  res.set('Content-Type', 'application/x-ndjson');
142
144
  res.set('Access-Control-Expose-Headers', ConversationIdHeader);
143
- res.set(ConversationIdHeader, userJourneysStream.getConversationId());
145
+ res.set(ConversationIdHeader, outerConversationId);
144
146
 
145
- const promises: { [key: string]: Promise<void> } = {};
147
+ const uniqueUserJourneyScreens: Record<string, UserJourneyScreen> = {};
146
148
 
147
- const queue = new PromiseQueue(5);
148
- onRequestAborted(req, res, () => {
149
- queue.cancel();
150
- });
151
-
152
- const systemId = userJourneysStream.getConversationId();
153
-
154
- UI_SERVERS[systemId] = new UIServer(systemId);
155
- await UI_SERVERS[systemId].start();
156
-
157
- userJourneysStream.on('data', async (data: StormEvent) => {
149
+ userJourneysStream.on('data', (data: StormEvent) => {
158
150
  try {
159
- console.log('Processing user journey event', data);
160
151
  sendEvent(res, data);
161
152
  if (data.type !== 'USER_JOURNEY') {
162
153
  return;
@@ -167,54 +158,139 @@ router.post('/:handle/ui', async (req: KapetaBodyRequest, res: Response) => {
167
158
  }
168
159
 
169
160
  data.payload.screens.forEach((screen) => {
170
- if (screen.name in promises) {
171
- return;
161
+ if (!uniqueUserJourneyScreens[screen.name]) {
162
+ uniqueUserJourneyScreens[screen.name] = screen;
172
163
  }
173
- promises[screen.name] = queue.add(
174
- () =>
175
- new Promise(async (resolve, reject) => {
176
- try {
177
- const innerConversationId = uuid.v4();
178
- const screenStream = await stormClient.createUIPage(
179
- {
180
- prompt: screen.requirements,
181
- method: screen.method,
182
- path: screen.path,
183
- description: screen.requirements,
184
- name: screen.name,
185
- title: screen.title,
186
- filename: screen.filename,
187
- storage_prefix: userJourneysStream.getConversationId() + '_',
188
- },
189
- innerConversationId
190
- );
191
- const promises: Promise<void>[] = [];
192
- screenStream.on('data', (screenData: StormEvent) => {
193
- if (screenData.type === 'PAGE') {
194
- screenData.payload.conversationId = innerConversationId;
195
- promises.push(
196
- sendPageEvent(userJourneysStream.getConversationId(), screenData, res)
197
- );
198
- } else {
199
- sendEvent(res, screenData);
200
- }
201
- });
202
- screenStream.on('end', () => {
203
- Promise.allSettled(promises).finally(resolve);
204
- });
205
- } catch (e: any) {
206
- console.error('Failed to process screen', e);
207
- reject(e);
208
- }
209
- })
210
- );
211
164
  });
212
165
  } catch (e) {
213
166
  console.error('Failed to process event', e);
214
167
  }
215
168
  });
216
169
 
170
+ userJourneysStream.on('error', (error) => {
171
+ console.error('Error on userJourneysStream', error);
172
+ userJourneysStream.abort();
173
+ sendError(error, res);
174
+ });
175
+
217
176
  await waitForStormStream(userJourneysStream);
177
+
178
+ // Get the UI shells
179
+ const shellsStream = await stormClient.createUIShells(
180
+ {
181
+ pages: Object.values(uniqueUserJourneyScreens).map((screen) => ({
182
+ name: screen.name,
183
+ title: screen.title,
184
+ filename: screen.filename,
185
+ path: screen.path,
186
+ method: screen.method,
187
+ requirements: screen.requirements,
188
+ })),
189
+ },
190
+ conversationId
191
+ );
192
+
193
+ onRequestAborted(req, res, () => {
194
+ shellsStream.abort();
195
+ });
196
+
197
+ const uiShells: UIShell[] = [];
198
+
199
+ shellsStream.on('data', (data: StormEvent) => {
200
+ console.log('Processing shell event', data);
201
+ sendEvent(res, data);
202
+
203
+ if (data.type !== 'UI_SHELL') {
204
+ return;
205
+ }
206
+
207
+ if (shellsStream.isAborted()) {
208
+ return;
209
+ }
210
+
211
+ uiShells.push(data.payload);
212
+ });
213
+
214
+ shellsStream.on('error', (error) => {
215
+ console.error('Error on shellsStream', error);
216
+ shellsStream.abort();
217
+ sendError(error, res);
218
+ });
219
+
220
+ await waitForStormStream(shellsStream);
221
+
222
+ UI_SERVERS[outerConversationId] = new UIServer(outerConversationId);
223
+ await UI_SERVERS[outerConversationId].start();
224
+
225
+ // Get the pages (5 at a time)
226
+ const queue = new PromiseQueue(5);
227
+
228
+ onRequestAborted(req, res, () => {
229
+ queue.cancel();
230
+ });
231
+
232
+ for (const screen of Object.values(uniqueUserJourneyScreens)) {
233
+ await queue.add(
234
+ () =>
235
+ new Promise(async (resolve, reject) => {
236
+ try {
237
+ const innerConversationId = uuid.v4();
238
+ const screenStream = await stormClient.createUIPage(
239
+ {
240
+ prompt: screen.requirements,
241
+ method: screen.method,
242
+ path: screen.path,
243
+ description: screen.requirements,
244
+ name: screen.name,
245
+ title: screen.title,
246
+ filename: screen.filename,
247
+ storage_prefix: outerConversationId + '_',
248
+ shell_page: uiShells.find((shell) => shell.screens.includes(screen.name))?.content,
249
+ },
250
+ innerConversationId
251
+ );
252
+
253
+ const promiseList: Promise<void>[] = [];
254
+ screenStream.on('data', (screenData: StormEvent) => {
255
+ if (screenData.type === 'PAGE') {
256
+ promiseList.push(
257
+ sendPageEvent(
258
+ outerConversationId,
259
+ {
260
+ ...screenData,
261
+ payload: {
262
+ ...screenData.payload,
263
+ conversationId: innerConversationId,
264
+ },
265
+ },
266
+ res
267
+ )
268
+ );
269
+ } else {
270
+ sendEvent(res, screenData);
271
+ }
272
+ });
273
+
274
+ screenStream.on('end', async () => {
275
+ try {
276
+ await Promise.allSettled(promiseList).finally(() => resolve(true));
277
+ } catch (error) {
278
+ console.error('Failed to process screen', error);
279
+ }
280
+ });
281
+
282
+ screenStream.on('error', (error) => {
283
+ console.error('Error on screenStream', error);
284
+ screenStream.abort();
285
+ });
286
+ } catch (e) {
287
+ console.error('Failed to process screen', e);
288
+ reject(e);
289
+ }
290
+ })
291
+ );
292
+ }
293
+
218
294
  await queue.wait();
219
295
 
220
296
  if (userJourneysStream.isAborted()) {
@@ -222,8 +298,8 @@ router.post('/:handle/ui', async (req: KapetaBodyRequest, res: Response) => {
222
298
  }
223
299
 
224
300
  sendDone(res);
225
- } catch (err: any) {
226
- sendError(err, res);
301
+ } catch (err) {
302
+ sendError(err as Error, res);
227
303
  if (!res.closed) {
228
304
  res.end();
229
305
  }
@@ -21,6 +21,17 @@ export const STORM_ID = 'storm';
21
21
 
22
22
  export const ConversationIdHeader = 'Conversation-Id';
23
23
 
24
+ export interface UIShellsPrompt {
25
+ pages: {
26
+ name: string;
27
+ title: string;
28
+ filename: string;
29
+ path: string;
30
+ method: string;
31
+ requirements: string;
32
+ }[];
33
+ }
34
+
24
35
  export interface UIPagePrompt {
25
36
  name: string;
26
37
  title: string;
@@ -30,6 +41,7 @@ export interface UIPagePrompt {
30
41
  method: string;
31
42
  description: string;
32
43
  storage_prefix: string;
44
+ shell_page?: string;
33
45
  }
34
46
 
35
47
  export interface UIPageEditPrompt {
@@ -153,6 +165,13 @@ class StormClient {
153
165
  });
154
166
  }
155
167
 
168
+ public createUIShells(prompt: UIShellsPrompt, conversationId?: string) {
169
+ return this.send('/v2/ui/shells', {
170
+ prompt: JSON.stringify(prompt),
171
+ conversationId,
172
+ });
173
+ }
174
+
156
175
  public createUIPage(prompt: UIPagePrompt, conversationId?: string) {
157
176
  return this.send('/v2/ui/page', {
158
177
  prompt: prompt,
@@ -0,0 +1,143 @@
1
+ [
2
+ {
3
+ "type": "CREATE_BLOCK",
4
+ "reason": "Block updated",
5
+ "payload": {
6
+ "archetype": "",
7
+ "description": "Handles traffic from the backend services.",
8
+ "name": "api-gateway",
9
+ "resources": [
10
+ {
11
+ "description": "",
12
+ "name": "posts",
13
+ "to": "post-service",
14
+ "type": "CLIENT"
15
+ },
16
+ {
17
+ "description": "",
18
+ "name": "comments",
19
+ "to": "comment-service",
20
+ "type": "CLIENT"
21
+ }
22
+ ],
23
+ "type": "GATEWAY",
24
+ "blockRef": "kapeta://kapeta/api-gateway:local",
25
+ "instanceId": "6b247f30-dec4-5960-a8a0-f300caa95226"
26
+ },
27
+ "created": 1720784242064
28
+ },
29
+ {
30
+ "type": "CREATE_BLOCK",
31
+ "reason": "Block updated",
32
+ "payload": {
33
+ "archetype": "",
34
+ "description": "Manages the creation, editing, and deletion of blog posts.",
35
+ "name": "post-service",
36
+ "resources": [
37
+ {
38
+ "description": "The posts API provides endpoints for managing blog posts. It includes functionality for creating, editing, and deleting posts, as well as retrieving and searching for posts. The API is designed to be secure and follows best practices for authentication and authorization.",
39
+ "name": "posts",
40
+ "type": "API"
41
+ }
42
+ ],
43
+ "type": "BACKEND",
44
+ "blockRef": "kapeta://kapeta/post-service:local",
45
+ "instanceId": "4e002962-ca24-5b8b-bcea-395b9bbf7c26"
46
+ },
47
+ "created": 1720784243137
48
+ },
49
+ {
50
+ "type": "CREATE_BLOCK",
51
+ "reason": "Block updated",
52
+ "payload": {
53
+ "archetype": "",
54
+ "description": "Manages the creation, editing, and deletion of comments on blog posts.",
55
+ "name": "comment-service",
56
+ "resources": [
57
+ {
58
+ "description": "The comments API provides endpoints for managing comments on blog posts. It includes functionality for creating, editing, and deleting comments, as well as retrieving and searching for comments. The API is designed to be secure and follows best practices for authentication and authorization.",
59
+ "name": "comments",
60
+ "type": "API"
61
+ }
62
+ ],
63
+ "type": "BACKEND",
64
+ "blockRef": "kapeta://kapeta/comment-service:local",
65
+ "instanceId": "d4215220-f552-5a6d-9429-bef1c40c4d7c"
66
+ },
67
+ "created": 1720784243141
68
+ },
69
+ {
70
+ "type": "CREATE_TYPE",
71
+ "reason": "Create type for post-service",
72
+ "payload": {
73
+ "blockName": "post-service",
74
+ "content": "enum Status {\n NEW,\n ARCHIVED\n}\n\ntype Result {\n status: Status\n}\n\ntype User {\n id: string\n}",
75
+ "blockRef": "kapeta://kapeta/post-service:local",
76
+ "instanceId": "4e002962-ca24-5b8b-bcea-395b9bbf7c26"
77
+ },
78
+ "created": 1720784233286
79
+ },
80
+ {
81
+ "type": "CREATE_API",
82
+ "reason": "Create API for post-service",
83
+ "payload": {
84
+ "blockName": "post-service",
85
+ "content": "@GET(\"/status\")\ngetStatus(): Result",
86
+ "blockRef": "kapeta://kapeta/post-service:local",
87
+ "instanceId": "4e002962-ca24-5b8b-bcea-395b9bbf7c26"
88
+ },
89
+ "created": 1720784233290
90
+ },
91
+ {
92
+ "type": "CREATE_TYPE",
93
+ "reason": "Create type for comment-service",
94
+ "payload": {
95
+ "blockName": "comment-service",
96
+ "content": "enum Status {\n NEW,\n APPROVED\n}\n\ntype Result {\n status: Status\n}\n\ntype User {\n id: string\n}",
97
+ "blockRef": "kapeta://kapeta/comment-service:local",
98
+ "instanceId": "d4215220-f552-5a6d-9429-bef1c40c4d7c"
99
+ },
100
+ "created": 1720784241000
101
+ },
102
+ {
103
+ "type": "CREATE_API",
104
+ "reason": "Create API for comment-service",
105
+ "payload": {
106
+ "blockName": "comment-service",
107
+ "content": "@GET(\"/status\")\ngetStatus(): Result",
108
+ "blockRef": "kapeta://kapeta/comment-service:local",
109
+ "instanceId": "d4215220-f552-5a6d-9429-bef1c40c4d7c"
110
+ },
111
+ "created": 1720784241002
112
+ },
113
+ {
114
+ "type": "CREATE_CONNECTION",
115
+ "reason": "api-gateway needs to be able to serve the posts API from the post-service",
116
+ "payload": {
117
+ "fromComponent": "post-service",
118
+ "fromResource": "posts",
119
+ "fromResourceType": "API",
120
+ "toComponent": "api-gateway",
121
+ "toResource": "posts",
122
+ "toResourceType": "CLIENT",
123
+ "fromBlockId": "4e002962-ca24-5b8b-bcea-395b9bbf7c26",
124
+ "toBlockId": "6b247f30-dec4-5960-a8a0-f300caa95226"
125
+ },
126
+ "created": 1720784243173
127
+ },
128
+ {
129
+ "type": "CREATE_CONNECTION",
130
+ "reason": "api-gateway needs to be able to serve the comments API from the comment-service",
131
+ "payload": {
132
+ "fromComponent": "comment-service",
133
+ "fromResource": "comments",
134
+ "fromResourceType": "API",
135
+ "toComponent": "api-gateway",
136
+ "toResource": "comments",
137
+ "toResourceType": "CLIENT",
138
+ "fromBlockId": "d4215220-f552-5a6d-9429-bef1c40c4d7c",
139
+ "toBlockId": "6b247f30-dec4-5960-a8a0-f300caa95226"
140
+ },
141
+ "created": 1720784243174
142
+ }
143
+ ]
@@ -8,6 +8,8 @@ import { StormEvent } from '../../src/storm/events';
8
8
  import simpleBlogEvents from './simple-blog-events.json';
9
9
  import predefinedUserEvents from './predefined-user-events.json';
10
10
  import testEvents from './blog-events.json';
11
+ import duplicateEntitiesEvents from './duplicate-entities-events.json';
12
+ import { DSLAPIParser, DSLDataType, DSLDataTypeParser, DSLMethod } from '@kapeta/kaplang-core';
11
13
 
12
14
  export const parserOptions = {
13
15
  serviceKind: 'kapeta/block-service:local',
@@ -245,4 +247,41 @@ describe('event-parser', () => {
245
247
  const safeName = StormEventParser.toSafeArtifactName('Browser-based CRM Application');
246
248
  expect(safeName).toBe('browserbasedcrmapplication');
247
249
  });
250
+
251
+ it('rename duplicate entity names', async () => {
252
+ const events = duplicateEntitiesEvents as StormEvent[];
253
+ const parser = new StormEventParser(parserOptions);
254
+ for (const event of events) {
255
+ await parser.processEvent('kapeta', event);
256
+ }
257
+
258
+ const result = await parser.toResult('kapeta');
259
+ const apiGateway = result.blocks.find((block) => block.aiName === 'api-gateway');
260
+ expect(apiGateway).toBeDefined();
261
+
262
+ const dataTypes = DSLDataTypeParser.parse(apiGateway!.content!.spec!.entities!.source!.value, {
263
+ ignoreSemantics: true,
264
+ });
265
+ expect(dataTypes.map((type) => type.name)).toStrictEqual(['Status', 'Result', 'User', 'Status_1', 'Result_1']);
266
+
267
+ const conflictingType = dataTypes.find((type) => type.name === 'Result_1');
268
+ expect(conflictingType).toBeDefined();
269
+ const dataType = conflictingType as DSLDataType;
270
+ expect(dataType.properties?.length).toBe(1);
271
+ const dslDataTypeProperty = dataType.properties![0];
272
+ expect(dslDataTypeProperty.type).toBe('Status_1');
273
+
274
+ const commentsClient = apiGateway?.content!.spec!.consumers?.find(
275
+ (resource) => resource.metadata.name === 'comments'
276
+ );
277
+ expect(commentsClient).toBeDefined();
278
+
279
+ const methods = DSLAPIParser.parse(commentsClient!.spec!.source!.value, {
280
+ ignoreSemantics: true,
281
+ });
282
+ expect(methods).toBeDefined();
283
+ expect(methods.length).toBe(1);
284
+ const method = methods[0] as DSLMethod;
285
+ expect(method.returnType).toBe('Result_1');
286
+ });
248
287
  });