@kapeta/local-cluster-service 0.53.4 → 0.54.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,6 +13,7 @@ import {
13
13
  GeneratedResult,
14
14
  MODE_CREATE_ONLY,
15
15
  } from '@kapeta/codegen';
16
+
16
17
  import { BlockDefinition } from '@kapeta/schemas';
17
18
  import { codeGeneratorManager } from '../codeGeneratorManager';
18
19
  import { STORM_ID, stormClient } from './stormClient';
@@ -23,6 +24,7 @@ import {
23
24
  StormEventFileChunk,
24
25
  StormEventFileDone,
25
26
  StormEventFileLogical,
27
+ StormEventScreen,
26
28
  } from './events';
27
29
  import { BlockDefinitionInfo, StormEventParser } from './event-parser';
28
30
  import { ConversationItem, StormFileImplementationPrompt, StormFileInfo, StormStream } from './stream';
@@ -31,11 +33,14 @@ import { writeFile } from 'fs/promises';
31
33
  import path from 'path';
32
34
  import Path, { join } from 'path';
33
35
  import os from 'node:os';
34
- import { readFileSync, writeFileSync } from 'fs';
36
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
35
37
  import YAML from 'yaml';
36
- import * as fs from 'node:fs';
38
+ import assert from 'assert';
37
39
 
38
- type ImplementationGenerator = (prompt: StormFileImplementationPrompt, conversationId?: string) => Promise<StormStream>;
40
+ type ImplementationGenerator<T = StormFileImplementationPrompt> = (
41
+ prompt: T,
42
+ conversationId?: string
43
+ ) => Promise<StormStream>;
39
44
 
40
45
  interface ErrorClassification {
41
46
  error: string;
@@ -266,9 +271,13 @@ export class StormCodegen {
266
271
  const blockUri = parseKapetaUri(block.uri);
267
272
 
268
273
  const relevantFiles: StormFileInfo[] = allFiles.filter(
269
- (file) => file.type !== AIFileTypes.IGNORE && file.type !== AIFileTypes.WEB_SCREEN
274
+ (file) =>
275
+ file.type !== AIFileTypes.IGNORE &&
276
+ file.type !== AIFileTypes.WEB_SCREEN &&
277
+ file.type !== AIFileTypes.WEB_ROUTER
270
278
  );
271
- const uiTemplates: StormFileInfo[] = allFiles.filter((file) => file.type === AIFileTypes.WEB_SCREEN);
279
+ const uiTemplates = allFiles.filter((file) => file.type === AIFileTypes.WEB_SCREEN);
280
+ const webRouters = allFiles.filter((file) => file.type === AIFileTypes.WEB_ROUTER);
272
281
  const screenFiles: StormEventFileDone[] = [];
273
282
  let filteredEvents = [] as StormEvent[];
274
283
  for (const event of this.events) {
@@ -277,36 +286,96 @@ export class StormCodegen {
277
286
  filteredEvents = [];
278
287
  }
279
288
  }
280
- if (uiTemplates.length > 0) {
281
- const uiStream = await stormClient.createUIImplementation({
282
- events: filteredEvents,
283
- templates: uiTemplates,
284
- context: relevantFiles,
285
- blockName: block.aiName,
286
- prompt: this.userPrompt,
287
- });
288
289
 
289
- uiStream.on('data', (evt) => {
290
- const uiFile = this.handleUiOutput(blockUri, block.aiName, evt);
291
- if (uiFile != undefined) {
292
- screenFiles.push(uiFile);
293
- }
294
- });
290
+ const screenEvents: StormEventScreen[] = [];
291
+ // generate screens
292
+ const screenStream = await stormClient.listScreens({
293
+ events: filteredEvents,
294
+ templates: uiTemplates,
295
+ context: relevantFiles,
296
+ blockName: block.aiName,
297
+ prompt: this.userPrompt,
298
+ });
299
+ screenStream.on('data', (evt) => {
300
+ if (evt.type === 'SCREEN') {
301
+ screenEvents.push(evt);
302
+ }
303
+ this.handleUiOutput(blockUri, block.aiName, evt);
304
+ });
295
305
 
296
- this.out.on('aborted', () => {
297
- uiStream.abort();
298
- });
306
+ this.out.on('aborted', () => {
307
+ screenStream.abort();
308
+ });
299
309
 
300
- await uiStream.waitForDone();
301
- }
310
+ await screenStream.waitForDone();
311
+
312
+ // screenfiles
313
+ const screenTemplates = screenEvents
314
+ .map((screenEvent) => ({
315
+ ...uiTemplates.find((template) => template.filename.endsWith(screenEvent.payload.template)),
316
+ filename: screenEvent.payload.filename,
317
+ }))
318
+ .filter((tpl): tpl is StormFileInfo => !!tpl.content);
319
+
320
+ await Promise.all(
321
+ screenTemplates.concat(webRouters).map(async (template) => {
322
+ const payload = {
323
+ events: filteredEvents,
324
+ blockName: block.aiName,
325
+ filename: template.filename,
326
+ template: template,
327
+ context: relevantFiles.concat([
328
+ {
329
+ type: AIFileTypes.INSTRUCTIONS,
330
+ mode: MODE_CREATE_ONLY,
331
+ permissions: '0644',
332
+ filename: '<screens>.md',
333
+ content: `
334
+ # Generated screens
335
+
336
+ ${JSON.stringify({ screenEvents })}
337
+
338
+ `,
339
+ },
340
+ ]),
341
+ prompt: this.userPrompt,
342
+ };
343
+
344
+ const uiStream = await stormClient.createUIImplementation(payload);
345
+
346
+ uiStream.on('data', (evt) => {
347
+ const uiFile = this.handleUiOutput(blockUri, block.aiName, evt);
348
+ if (uiFile != undefined) {
349
+ screenFiles.push(uiFile);
350
+ }
351
+ });
352
+
353
+ this.out.on('aborted', () => {
354
+ uiStream.abort();
355
+ });
356
+
357
+ await uiStream.waitForDone();
358
+ })
359
+ );
302
360
 
303
361
  if (this.isAborted()) {
304
362
  return;
305
363
  }
364
+ const basePath = this.getBasePath(block.content.metadata.name);
365
+
366
+ const screenFilesConverted = screenFiles.map((screenFile) => {
367
+ return {
368
+ filename: screenFile.payload.filename,
369
+ content: screenFile.payload.content,
370
+ mode: MODE_CREATE_ONLY,
371
+ permissions: '0644',
372
+ type: AIFileTypes.WEB_SCREEN,
373
+ };
374
+ });
306
375
 
307
376
  // Gather the context files for implementation. These will be all be passed to the AI
308
377
  const contextFiles: StormFileInfo[] = relevantFiles.filter(
309
- (file) => ![AIFileTypes.SERVICE, AIFileTypes.WEB_SCREEN].includes(file.type)
378
+ (file) => ![AIFileTypes.SERVICE, AIFileTypes.WEB_SCREEN, AIFileTypes.WEB_ROUTER].includes(file.type)
310
379
  );
311
380
 
312
381
  // Send the service and UI templates to the AI. These will be sent one-by-one in addition to the context files
@@ -321,12 +390,15 @@ export class StormCodegen {
321
390
  );
322
391
  }
323
392
 
324
- const basePath = this.getBasePath(block.content.metadata.name);
325
-
326
393
  if (this.isAborted()) {
327
394
  return;
328
395
  }
329
396
 
397
+ for (const screenFile of screenFilesConverted) {
398
+ const filePath = join(basePath, screenFile.filename);
399
+ await writeFile(filePath, screenFile.content);
400
+ }
401
+
330
402
  for (const serviceFile of serviceFiles) {
331
403
  const filePath = join(basePath, serviceFile.filename);
332
404
  await writeFile(filePath, serviceFile.content);
@@ -345,16 +417,6 @@ export class StormCodegen {
345
417
  await writeFile(filePath, screenFile.payload.content);
346
418
  }
347
419
 
348
- const screenFilesConverted = screenFiles.map((screenFile) => {
349
- return {
350
- filename: screenFile.payload.filename,
351
- content: screenFile.payload.content,
352
- mode: MODE_CREATE_ONLY,
353
- permissions: '0644',
354
- type: AIFileTypes.WEB_SCREEN,
355
- };
356
- });
357
- allFiles.push(...screenFilesConverted);
358
420
  const blockRef = block.uri;
359
421
 
360
422
  this.emitBlockStatus(blockUri, block.aiName, StormEventBlockStatusType.QA);
@@ -594,7 +656,7 @@ export class StormCodegen {
594
656
  files.add(filename);
595
657
 
596
658
  const requestedFiles = Array.from(files).flatMap((file) => {
597
- if (fs.existsSync(file)) {
659
+ if (existsSync(file)) {
598
660
  return file;
599
661
  }
600
662
 
@@ -696,7 +758,7 @@ export class StormCodegen {
696
758
  return;
697
759
  }
698
760
 
699
- if (file.type === AIFileTypes.WEB_SCREEN) {
761
+ if ([AIFileTypes.WEB_ROUTER, AIFileTypes.WEB_SCREEN].includes(file.type)) {
700
762
  // Don't send the web screen files to the stream yet
701
763
  // They will need to be implemented by the AI
702
764
  return;
@@ -26,7 +26,7 @@ import {
26
26
  DSLAPIParser,
27
27
  DSLController,
28
28
  DSLConverters,
29
- DSLDataTypeParser,
29
+ DSLDataTypeParser, DSLEntityType,
30
30
  DSLMethod,
31
31
  DSLParser,
32
32
  KAPLANG_ID,
@@ -325,13 +325,13 @@ export class StormEventParser {
325
325
  this.connections.push(evt.payload);
326
326
  break;
327
327
  case 'API_RETRY':
328
- Object.values(this.blocks).forEach(block => {
328
+ Object.values(this.blocks).forEach((block) => {
329
329
  block.types = [];
330
330
  block.apis = [];
331
331
  });
332
332
  break;
333
333
  case 'MODEL_RETRY':
334
- Object.values(this.blocks).forEach(block => {
334
+ Object.values(this.blocks).forEach((block) => {
335
335
  block.models = [];
336
336
  });
337
337
  break;
@@ -537,7 +537,7 @@ export class StormEventParser {
537
537
 
538
538
  const blockSpec = blockDefinitionInfo.content.spec;
539
539
 
540
- let apiResource: Resource | undefined = undefined;
540
+ const apiResources: { [key: string]: Resource | undefined } = {};
541
541
  let dbResource: Resource | undefined = undefined;
542
542
 
543
543
  blockInfo.resources.forEach((resource) => {
@@ -546,10 +546,7 @@ export class StormEventParser {
546
546
  };
547
547
  switch (resource.type) {
548
548
  case 'API':
549
- if (apiResource) {
550
- break;
551
- }
552
- apiResource = {
549
+ const apiResource = {
553
550
  kind: this.toResourceKind(resource.type),
554
551
  metadata: {
555
552
  name: resource.name,
@@ -565,6 +562,7 @@ export class StormEventParser {
565
562
  } satisfies SourceCode,
566
563
  },
567
564
  };
565
+ apiResources[resource.name] = apiResource;
568
566
  blockSpec.providers!.push(apiResource);
569
567
  break;
570
568
  case 'CLIENT':
@@ -646,11 +644,30 @@ export class StormEventParser {
646
644
  }
647
645
  });
648
646
 
649
- if (apiResource) {
650
- blockInfo.apis.forEach((api) => {
651
- apiResource!.spec.source.value += api + '\n\n';
652
- });
653
- }
647
+ blockInfo.apis.forEach((api) => {
648
+ const dslApi = DSLAPIParser.parse(api, {
649
+ ignoreSemantics: true,
650
+ }) as (DSLMethod | DSLController)[];
651
+
652
+ let exactMatch = false;
653
+ if (dslApi[0] && dslApi[0].type == DSLEntityType.CONTROLLER) {
654
+ const name = dslApi[0].name.toLowerCase();
655
+ const apiResourceName = Object.keys(apiResources).find((key) => key.indexOf(name) > -1);
656
+ if (apiResourceName) {
657
+ const exactResource = apiResources[apiResourceName];
658
+ exactResource!.spec.source.value += api + '\n\n';
659
+ exactMatch = true;
660
+ }
661
+ }
662
+
663
+ if (!exactMatch) {
664
+ // if we couldn't place the given api on the exact resource we just park it on the first
665
+ // available rest resource
666
+ const firstKey = Object.keys(apiResources)[0];
667
+ const firstEntry = apiResources[firstKey];
668
+ firstEntry!.spec.source.value += api + '\n\n';
669
+ }
670
+ });
654
671
 
655
672
  blockInfo.types.forEach((type) => {
656
673
  blockSpec.entities!.source!.value += type + '\n';
@@ -193,6 +193,7 @@ export interface StormEventScreen {
193
193
  template: string;
194
194
  description: string;
195
195
  url: string;
196
+ filename: string;
196
197
  };
197
198
  }
198
199
 
@@ -12,6 +12,7 @@ import {
12
12
  StormFileImplementationPrompt,
13
13
  StormStream,
14
14
  StormUIImplementationPrompt,
15
+ StormUIListPrompt,
15
16
  } from './stream';
16
17
  import { getRawAsset } from 'node:sea';
17
18
 
@@ -112,8 +113,15 @@ class StormClient {
112
113
  });
113
114
  }
114
115
 
116
+ public listScreens(prompt: StormUIListPrompt, conversationId?: string) {
117
+ return this.send('/v2/ui/list', {
118
+ prompt,
119
+ conversationId,
120
+ });
121
+ }
122
+
115
123
  public createUIImplementation(prompt: StormUIImplementationPrompt, conversationId?: string) {
116
- return this.send<StormUIImplementationPrompt>('/v2/ui/merge', {
124
+ return this.send('/v2/ui/merge', {
117
125
  prompt,
118
126
  conversationId,
119
127
  });
@@ -117,6 +117,15 @@ export interface StormFileImplementationPrompt {
117
117
  }
118
118
 
119
119
  export interface StormUIImplementationPrompt {
120
+ events: StormEvent[];
121
+ template: StormFileInfo;
122
+ filename: string;
123
+ context: StormFileInfo[];
124
+ blockName: string;
125
+ prompt: string;
126
+ }
127
+
128
+ export interface StormUIListPrompt {
120
129
  events: StormEvent[];
121
130
  templates: StormFileInfo[];
122
131
  context: StormFileInfo[];
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { StormEventParser } from '../../src/storm/event-parser';
7
7
  import { StormEvent } from '../../src/storm/events';
8
+ import simpleBlogEvents from './simple-blog-events.json';
8
9
 
9
10
  const parserOptions = {
10
11
  serviceKind: 'kapeta/block-service:local',
@@ -179,4 +180,22 @@ describe('event-parser', () => {
179
180
  expect(result.plan.spec.connections[0].provider.blockId).toBe(serviceBlockInstance.id);
180
181
  expect(result.plan.spec.connections[0].provider.resourceName).toBe(apiResource?.metadata.name);
181
182
  });
183
+
184
+ it('it will split api into correct provider', () => {
185
+ const events = simpleBlogEvents as StormEvent[];
186
+ const parser = new StormEventParser(parserOptions);
187
+ events.forEach((event) => parser.processEvent('kapeta', event));
188
+
189
+ const result = parser.toResult('kapeta');
190
+
191
+ const blogService = result.blocks.find((block) => block.aiName === 'blog-service');
192
+ expect(blogService).toBeDefined();
193
+ expect(blogService?.content).toBeDefined();
194
+
195
+ const apiProviders = blogService?.content?.spec?.providers?.filter((provider) => provider.kind === 'kapeta/block-type-api:local');
196
+ expect(apiProviders).toBeDefined();
197
+ expect(apiProviders!.length).toBe(2);
198
+ expect(apiProviders!["0"].spec.source.value).not.toBe('');
199
+ expect(apiProviders!["1"].spec.source.value).not.toBe('');
200
+ });
182
201
  });