@principal-ai/principal-view-core 0.5.16 → 0.6.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.
@@ -0,0 +1,521 @@
1
+ /**
2
+ * Execution File Discovery
3
+ *
4
+ * Discovers execution artifacts and canvas files in a repository with monorepo awareness.
5
+ * Uses codebase-composition to intelligently detect package boundaries and workspace patterns.
6
+ */
7
+
8
+ import { PackageLayerModule } from '@principal-ai/codebase-composition';
9
+ import type { PackageLayer } from '@principal-ai/codebase-composition';
10
+
11
+ /**
12
+ * Represents a discovered execution artifact file
13
+ */
14
+ export interface ExecutionFile {
15
+ /** Unique identifier for this execution */
16
+ id: string;
17
+ /** Display name for this execution */
18
+ name: string;
19
+ /** Full file path (relative to repository root) */
20
+ path: string;
21
+ /** Canvas basename (without extension) that this execution is linked to */
22
+ canvasBasename: string;
23
+ /** Package context if this execution belongs to a specific package */
24
+ packageContext?: PackageContext;
25
+ }
26
+
27
+ /**
28
+ * Represents a discovered canvas file
29
+ */
30
+ export interface CanvasFile {
31
+ /** Unique identifier for this canvas */
32
+ id: string;
33
+ /** Display name for this canvas */
34
+ name: string;
35
+ /** Full file path (relative to repository root) */
36
+ path: string;
37
+ /** Canvas basename (without .otel.canvas or .canvas extension) */
38
+ basename: string;
39
+ /** Package context if this canvas belongs to a specific package */
40
+ packageContext?: PackageContext;
41
+ }
42
+
43
+ /**
44
+ * Package context for execution/canvas files
45
+ */
46
+ export interface PackageContext {
47
+ /** Package name (from package.json, Cargo.toml, etc.) */
48
+ name: string;
49
+ /** Package directory path (relative to repository root) */
50
+ packagePath: string;
51
+ /** Package type (npm, cargo, go, etc.) */
52
+ packageType?: string;
53
+ }
54
+
55
+ /**
56
+ * Execution artifact metadata
57
+ */
58
+ export interface ExecutionMetadata {
59
+ name: string;
60
+ canvasName?: string;
61
+ exportedAt?: string;
62
+ source?: string;
63
+ framework?: string;
64
+ status?: 'success' | 'error' | 'OK';
65
+ spanCount: number;
66
+ eventCount: number;
67
+ }
68
+
69
+ /**
70
+ * Execution span structure
71
+ */
72
+ export interface ExecutionSpan {
73
+ id: string;
74
+ name: string;
75
+ startTime: number;
76
+ endTime?: number;
77
+ duration?: number;
78
+ status?: string;
79
+ attributes?: Record<string, any>;
80
+ events: Array<{
81
+ time: number;
82
+ name: string;
83
+ attributes?: Record<string, any>;
84
+ }>;
85
+ }
86
+
87
+ /**
88
+ * Execution artifact file structure
89
+ */
90
+ export interface ExecutionArtifact {
91
+ metadata?: {
92
+ canvasName?: string;
93
+ exportedAt?: string;
94
+ source?: string;
95
+ framework?: string;
96
+ status?: 'success' | 'error';
97
+ };
98
+ spans: ExecutionSpan[];
99
+ }
100
+
101
+ /**
102
+ * File tree entry for discovery
103
+ */
104
+ export interface FileTreeEntry {
105
+ path?: string;
106
+ relativePath?: string;
107
+ name?: string;
108
+ }
109
+
110
+ /**
111
+ * Options for execution file discovery
112
+ */
113
+ export interface DiscoveryOptions {
114
+ /** Whether to include package-level .principal-views folders */
115
+ includePackagePrincipalViews?: boolean;
116
+ /** Custom execution folder names (defaults to ['__executions__']) */
117
+ executionFolders?: string[];
118
+ /** Custom canvas extensions (defaults to ['.otel.canvas']) */
119
+ canvasExtensions?: string[];
120
+ }
121
+
122
+ /**
123
+ * Execution File Discovery Engine
124
+ *
125
+ * Discovers execution artifacts and canvas files using monorepo-aware logic.
126
+ */
127
+ export class ExecutionFileDiscovery {
128
+ private packageLayerModule: PackageLayerModule;
129
+ private options: Required<DiscoveryOptions>;
130
+
131
+ constructor(options: DiscoveryOptions = {}) {
132
+ this.packageLayerModule = new PackageLayerModule();
133
+ this.options = {
134
+ includePackagePrincipalViews: options.includePackagePrincipalViews ?? true,
135
+ executionFolders: options.executionFolders ?? ['__executions__'],
136
+ canvasExtensions: options.canvasExtensions ?? ['.otel.canvas'],
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Find all execution artifact files in the repository
142
+ */
143
+ async findExecutionFiles(files: FileTreeEntry[]): Promise<ExecutionFile[]> {
144
+ const executionFiles: ExecutionFile[] = [];
145
+
146
+ // Detect packages in the repository
147
+ const packages = await this.detectPackages(files);
148
+
149
+ // Search for execution files in each package
150
+ for (const pkg of packages) {
151
+ const pkgExecutions = this.findExecutionsInPackage(pkg, files);
152
+ executionFiles.push(...pkgExecutions);
153
+ }
154
+
155
+ // Search in root-level locations
156
+ const rootExecutions = this.findExecutionsInRoot(files);
157
+ executionFiles.push(...rootExecutions);
158
+
159
+ // Sort by package name, then by basename
160
+ return this.sortExecutionFiles(executionFiles);
161
+ }
162
+
163
+ /**
164
+ * Find all canvas files in the repository
165
+ */
166
+ async findCanvasFiles(files: FileTreeEntry[]): Promise<CanvasFile[]> {
167
+ const canvasFiles: CanvasFile[] = [];
168
+
169
+ // Detect packages in the repository
170
+ const packages = await this.detectPackages(files);
171
+
172
+ // Search for canvas files in root .principal-views/
173
+ const rootCanvases = this.findCanvasesInDirectory('.principal-views', files);
174
+ canvasFiles.push(...rootCanvases);
175
+
176
+ // Search for canvas files in package-level .principal-views/ if enabled
177
+ if (this.options.includePackagePrincipalViews) {
178
+ for (const pkg of packages) {
179
+ const pkgCanvases = this.findCanvasesInDirectory(
180
+ `${pkg.packageData.path}/.principal-views`,
181
+ files,
182
+ this.createPackageContext(pkg)
183
+ );
184
+ canvasFiles.push(...pkgCanvases);
185
+ }
186
+ }
187
+
188
+ // Sort by name
189
+ return canvasFiles.sort((a, b) => a.name.localeCompare(b.name));
190
+ }
191
+
192
+ /**
193
+ * Find execution artifact for a given canvas
194
+ * Now package-aware - can disambiguate between packages
195
+ */
196
+ findExecutionForCanvas(
197
+ canvas: CanvasFile,
198
+ executionFiles: ExecutionFile[]
199
+ ): ExecutionFile | null {
200
+ // First, try exact package match if canvas has package context
201
+ if (canvas.packageContext) {
202
+ const exactMatch = executionFiles.find(
203
+ exec =>
204
+ exec.canvasBasename === canvas.basename &&
205
+ exec.packageContext?.name === canvas.packageContext?.name
206
+ );
207
+ if (exactMatch) return exactMatch;
208
+ }
209
+
210
+ // Fallback to basename-only match (for root-level canvases)
211
+ return executionFiles.find(exec => exec.canvasBasename === canvas.basename) || null;
212
+ }
213
+
214
+ /**
215
+ * Find canvas file for a given execution
216
+ */
217
+ findCanvasForExecution(
218
+ execution: ExecutionFile,
219
+ canvasFiles: CanvasFile[]
220
+ ): CanvasFile | null {
221
+ // First, try exact package match if execution has package context
222
+ if (execution.packageContext) {
223
+ const exactMatch = canvasFiles.find(
224
+ canvas =>
225
+ canvas.basename === execution.canvasBasename &&
226
+ canvas.packageContext?.name === execution.packageContext?.name
227
+ );
228
+ if (exactMatch) return exactMatch;
229
+ }
230
+
231
+ // Fallback to basename-only match (for root-level executions)
232
+ return canvasFiles.find(canvas => canvas.basename === execution.canvasBasename) || null;
233
+ }
234
+
235
+ /**
236
+ * Parse execution artifact JSON
237
+ */
238
+ static parseExecutionArtifact(content: string): ExecutionArtifact {
239
+ try {
240
+ const parsed = JSON.parse(content);
241
+ return parsed as ExecutionArtifact;
242
+ } catch (error) {
243
+ throw new Error(`Failed to parse execution artifact JSON: ${(error as Error).message}`);
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Get spans from an artifact
249
+ */
250
+ static getSpans(artifact: ExecutionArtifact): ExecutionSpan[] {
251
+ return artifact.spans || [];
252
+ }
253
+
254
+ /**
255
+ * Extract metadata from an execution artifact
256
+ */
257
+ static getExecutionMetadata(artifact: ExecutionArtifact): ExecutionMetadata {
258
+ const spans = ExecutionFileDiscovery.getSpans(artifact);
259
+ const spanCount = spans.length;
260
+
261
+ const eventCount = spans.reduce((total, span) => {
262
+ return total + (span.events?.length || 0);
263
+ }, 0);
264
+
265
+ const metadata = artifact.metadata;
266
+
267
+ let status: 'success' | 'error' | 'OK' = 'success';
268
+ if (metadata?.status) {
269
+ status = metadata.status;
270
+ } else if (spans.length > 0) {
271
+ const hasError = spans.some(
272
+ s => s.status === 'ERROR' || s.status === 'error' || s.status === 'FAILED'
273
+ );
274
+ status = hasError ? 'error' : 'OK';
275
+ }
276
+
277
+ return {
278
+ name: metadata?.canvasName || 'Untitled Execution',
279
+ canvasName: metadata?.canvasName,
280
+ exportedAt: metadata?.exportedAt,
281
+ source: metadata?.source,
282
+ framework: metadata?.framework,
283
+ status,
284
+ spanCount,
285
+ eventCount,
286
+ };
287
+ }
288
+
289
+ // Private helper methods
290
+
291
+ /**
292
+ * Detect packages using codebase-composition
293
+ */
294
+ private async detectPackages(files: FileTreeEntry[]): Promise<PackageLayer[]> {
295
+ try {
296
+ // Convert files to the format expected by codebase-composition
297
+ const fileTree = this.convertToFileTree(files);
298
+ const packages = await this.packageLayerModule.discoverPackages(fileTree);
299
+ return packages || [];
300
+ } catch (error) {
301
+ // If package detection fails, continue with fallback patterns
302
+ console.warn('Package detection failed, using fallback patterns:', error);
303
+ return [];
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Convert file entries to file tree format for codebase-composition
309
+ */
310
+ private convertToFileTree(files: FileTreeEntry[]): any {
311
+ // This is a simplified conversion - codebase-composition expects a specific format
312
+ // In practice, the caller should provide a proper FileTree from repository-abstraction
313
+ return {
314
+ files: files.map(f => ({
315
+ path: f.relativePath || f.path || '',
316
+ name: f.name || (f.relativePath || f.path || '').split('/').pop() || '',
317
+ })),
318
+ };
319
+ }
320
+
321
+ /**
322
+ * Find executions in a specific package
323
+ */
324
+ private findExecutionsInPackage(
325
+ pkg: PackageLayer,
326
+ files: FileTreeEntry[]
327
+ ): ExecutionFile[] {
328
+ const executions: ExecutionFile[] = [];
329
+ const pkgPath = pkg.packageData.path || '';
330
+ const packageContext = this.createPackageContext(pkg);
331
+
332
+ for (const execFolder of this.options.executionFolders) {
333
+ const execPath = pkgPath ? `${pkgPath}/${execFolder}` : execFolder;
334
+
335
+ for (const file of files) {
336
+ const filePath = file.relativePath || file.path || '';
337
+ const fileName = file.name || filePath.split('/').pop() || '';
338
+
339
+ if (filePath.startsWith(execPath + '/') && this.isExecutionFile(fileName)) {
340
+ const basename = this.extractExecutionBasename(fileName);
341
+ executions.push({
342
+ id: this.generateExecutionId(basename, packageContext),
343
+ name: this.formatDisplayName(basename),
344
+ path: filePath,
345
+ canvasBasename: basename,
346
+ packageContext,
347
+ });
348
+ }
349
+ }
350
+ }
351
+
352
+ return executions;
353
+ }
354
+
355
+ /**
356
+ * Find executions in root-level locations
357
+ */
358
+ private findExecutionsInRoot(files: FileTreeEntry[]): ExecutionFile[] {
359
+ const executions: ExecutionFile[] = [];
360
+
361
+ // Root __executions__/
362
+ for (const execFolder of this.options.executionFolders) {
363
+ for (const file of files) {
364
+ const filePath = file.relativePath || file.path || '';
365
+ const fileName = file.name || filePath.split('/').pop() || '';
366
+
367
+ if (filePath.startsWith(`${execFolder}/`) && this.isExecutionFile(fileName)) {
368
+ const basename = this.extractExecutionBasename(fileName);
369
+ executions.push({
370
+ id: `root-${basename}`,
371
+ name: this.formatDisplayName(basename),
372
+ path: filePath,
373
+ canvasBasename: basename,
374
+ });
375
+ }
376
+ }
377
+ }
378
+
379
+ // .principal-views/__executions__/
380
+ const pvExecPath = '.principal-views/__executions__';
381
+ for (const file of files) {
382
+ const filePath = file.relativePath || file.path || '';
383
+ const fileName = file.name || filePath.split('/').pop() || '';
384
+
385
+ if (filePath.startsWith(pvExecPath + '/') && this.isExecutionFile(fileName)) {
386
+ const basename = this.extractExecutionBasename(fileName);
387
+ executions.push({
388
+ id: `pv-${basename}`,
389
+ name: this.formatDisplayName(basename),
390
+ path: filePath,
391
+ canvasBasename: basename,
392
+ });
393
+ }
394
+ }
395
+
396
+ return executions;
397
+ }
398
+
399
+ /**
400
+ * Find canvas files in a specific directory
401
+ */
402
+ private findCanvasesInDirectory(
403
+ directory: string,
404
+ files: FileTreeEntry[],
405
+ packageContext?: PackageContext
406
+ ): CanvasFile[] {
407
+ const canvases: CanvasFile[] = [];
408
+
409
+ for (const file of files) {
410
+ const filePath = file.relativePath || file.path || '';
411
+ const fileName = file.name || filePath.split('/').pop() || '';
412
+
413
+ if (filePath.startsWith(directory + '/') && this.isCanvasFile(fileName)) {
414
+ const basename = this.extractCanvasBasename(fileName);
415
+ canvases.push({
416
+ id: this.generateCanvasId(basename, packageContext),
417
+ name: this.formatDisplayName(basename),
418
+ path: filePath,
419
+ basename,
420
+ packageContext,
421
+ });
422
+ }
423
+ }
424
+
425
+ return canvases;
426
+ }
427
+
428
+ /**
429
+ * Create package context from PackageLayer
430
+ */
431
+ private createPackageContext(pkg: PackageLayer): PackageContext {
432
+ return {
433
+ name: pkg.packageData.name || 'unknown',
434
+ packagePath: pkg.packageData.path || '',
435
+ packageType: pkg.type,
436
+ };
437
+ }
438
+
439
+ /**
440
+ * Check if filename is an execution file
441
+ */
442
+ private isExecutionFile(filename: string): boolean {
443
+ return /\.(spans|execution|events)\.json$/.test(filename);
444
+ }
445
+
446
+ /**
447
+ * Check if filename is a canvas file
448
+ */
449
+ private isCanvasFile(filename: string): boolean {
450
+ return this.options.canvasExtensions.some(ext => filename.endsWith(ext));
451
+ }
452
+
453
+ /**
454
+ * Extract basename from execution filename
455
+ */
456
+ private extractExecutionBasename(filename: string): string {
457
+ return filename.replace(/\.(spans|execution|events)\.json$/, '');
458
+ }
459
+
460
+ /**
461
+ * Extract basename from canvas filename
462
+ */
463
+ private extractCanvasBasename(filename: string): string {
464
+ for (const ext of this.options.canvasExtensions) {
465
+ if (filename.endsWith(ext)) {
466
+ return filename.slice(0, -ext.length);
467
+ }
468
+ }
469
+ return filename;
470
+ }
471
+
472
+ /**
473
+ * Format display name from basename (kebab-case to Title Case)
474
+ */
475
+ private formatDisplayName(basename: string): string {
476
+ return basename
477
+ .split('-')
478
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
479
+ .join(' ');
480
+ }
481
+
482
+ /**
483
+ * Generate unique ID for execution file
484
+ */
485
+ private generateExecutionId(basename: string, packageContext?: PackageContext): string {
486
+ if (packageContext) {
487
+ return `${packageContext.name}-${basename}`;
488
+ }
489
+ return basename;
490
+ }
491
+
492
+ /**
493
+ * Generate unique ID for canvas file
494
+ */
495
+ private generateCanvasId(basename: string, packageContext?: PackageContext): string {
496
+ if (packageContext) {
497
+ return `${packageContext.name}-${basename}`;
498
+ }
499
+ return basename;
500
+ }
501
+
502
+ /**
503
+ * Sort execution files by package name, then by basename
504
+ */
505
+ private sortExecutionFiles(files: ExecutionFile[]): ExecutionFile[] {
506
+ return files.sort((a, b) => {
507
+ // Sort by package name first
508
+ if (a.packageContext && b.packageContext) {
509
+ const pkgCompare = a.packageContext.name.localeCompare(b.packageContext.name);
510
+ if (pkgCompare !== 0) return pkgCompare;
511
+ } else if (a.packageContext) {
512
+ return -1;
513
+ } else if (b.packageContext) {
514
+ return 1;
515
+ }
516
+
517
+ // Then by basename
518
+ return a.canvasBasename.localeCompare(b.canvasBasename);
519
+ });
520
+ }
521
+ }
@@ -9,6 +9,8 @@ import {
9
9
  markTestFailed,
10
10
  setSpanAttribute,
11
11
  exportSpans,
12
+ log,
13
+ recordLog,
12
14
  } from '../../test/otel-setup';
13
15
 
14
16
  describe('GraphConverter', () => {
@@ -24,11 +26,25 @@ describe('GraphConverter', () => {
24
26
  });
25
27
 
26
28
  try {
29
+ // TRACE log at test start (automatically correlated with testSpan)
30
+ log('TRACE', 'Entering test case for simple config conversion', {
31
+ 'service.name': 'graph-converter-service',
32
+ 'service.version': '1.0.0',
33
+ });
34
+
27
35
  // Setup - record as event
28
36
  addEvent(testSpan, 'setup.started', {
29
37
  description: 'Creating test configuration with 2 nodes and 1 edge',
30
38
  });
31
39
 
40
+ // INFO log during setup
41
+ log('INFO', 'Initializing test configuration', {
42
+ 'service.name': 'graph-converter-service',
43
+ }, {
44
+ 'config.nodeCount': 2,
45
+ 'config.edgeCount': 1,
46
+ });
47
+
32
48
  const config: PathBasedGraphConfiguration = {
33
49
  metadata: {
34
50
  name: 'Test Config',
@@ -70,6 +86,15 @@ describe('GraphConverter', () => {
70
86
  'config.edges': 1,
71
87
  });
72
88
 
89
+ // DEBUG log with structured data after setup
90
+ log('DEBUG', {
91
+ phase: 'setup-complete',
92
+ nodeTypes: Object.keys(config.nodeTypes),
93
+ edgeTypes: Object.keys(config.edgeTypes),
94
+ }, {
95
+ 'service.name': 'graph-converter-service',
96
+ });
97
+
73
98
  // Execution - record as event
74
99
  // Note: execution happens in GraphConverter.ts, not the test file
75
100
  addEvent(testSpan, 'execution.started', {
@@ -78,8 +103,25 @@ describe('GraphConverter', () => {
78
103
  'code.lineno': 15,
79
104
  });
80
105
 
106
+ // INFO log before conversion
107
+ log('INFO', 'Starting graph conversion process', {
108
+ 'service.name': 'graph-converter-service',
109
+ }, {
110
+ 'operation': 'configToGraph',
111
+ 'inputSize.bytes': JSON.stringify(config).length,
112
+ });
113
+
81
114
  const result = GraphConverter.configToGraph(config);
82
115
 
116
+ // DEBUG log after conversion with result details
117
+ log('DEBUG', 'Conversion completed successfully', {
118
+ 'service.name': 'graph-converter-service',
119
+ }, {
120
+ 'output.nodeCount': result.nodes.length,
121
+ 'output.edgeCount': result.edges.length,
122
+ 'processingTime.ms': 5,
123
+ });
124
+
83
125
  addEvent(testSpan, 'execution.complete', {
84
126
  'result.nodes.count': result.nodes.length,
85
127
  'result.edges.count': result.edges.length,
@@ -92,6 +134,13 @@ describe('GraphConverter', () => {
92
134
  assertions: 'Verifying nodes and edges structure',
93
135
  });
94
136
 
137
+ // INFO log at assertion phase
138
+ log('INFO', 'Running assertions on converted graph', {
139
+ 'service.name': 'graph-converter-service',
140
+ }, {
141
+ 'totalAssertions': 11,
142
+ });
143
+
95
144
  expect(result.nodes).toHaveLength(2);
96
145
  expect(result.edges).toHaveLength(1);
97
146
 
@@ -116,8 +165,24 @@ describe('GraphConverter', () => {
116
165
  'assertions.failed': 0,
117
166
  });
118
167
 
168
+ // INFO log on successful test completion
169
+ log('INFO', 'All assertions passed - test successful', {
170
+ 'service.name': 'graph-converter-service',
171
+ }, {
172
+ 'test.status': 'passed',
173
+ 'test.duration.ms': Date.now() - testSpan.startTime,
174
+ });
175
+
119
176
  markTestPassed(testSpan);
120
177
  } catch (error) {
178
+ // ERROR log on failure
179
+ log('ERROR', `Test failed: ${(error as Error).message}`, {
180
+ 'service.name': 'graph-converter-service',
181
+ }, {
182
+ 'test.status': 'failed',
183
+ 'error.type': (error as Error).name,
184
+ });
185
+
121
186
  addEvent(testSpan, 'test.failed', {
122
187
  error: (error as Error).message,
123
188
  });
@@ -125,6 +190,20 @@ describe('GraphConverter', () => {
125
190
  throw error;
126
191
  } finally {
127
192
  endSpan(testSpan);
193
+
194
+ // Uncorrelated log after span ends (simulating cleanup/background task)
195
+ recordLog({
196
+ severity: 'DEBUG',
197
+ body: 'Test cleanup completed',
198
+ resource: {
199
+ 'service.name': 'test-cleanup-service',
200
+ 'cleanup.type': 'post-test',
201
+ },
202
+ attributes: {
203
+ 'test.name': 'should convert simple config to nodes and edges',
204
+ },
205
+ // No traceId/spanId - this is an uncorrelated log
206
+ });
128
207
  }
129
208
  });
130
209