From b3e163902953c4170c3479277c4229ce417f9f43 Mon Sep 17 00:00:00 2001 From: frostebite Date: Thu, 5 Mar 2026 09:38:55 +0000 Subject: [PATCH] =?UTF-8?q?feat(orchestrator):=20generic=20artifact=20syst?= =?UTF-8?q?em=20=E2=80=94=20output=20types,=20manifests,=20and=20collectio?= =?UTF-8?q?n=20service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../orchestrator/services/output/index.ts | 3 + .../services/output/output-manifest.ts | 41 ++++++ .../services/output/output-service.ts | 111 ++++++++++++++ .../services/output/output-type-registry.ts | 138 ++++++++++++++++++ 4 files changed, 293 insertions(+) create mode 100644 src/model/orchestrator/services/output/index.ts create mode 100644 src/model/orchestrator/services/output/output-manifest.ts create mode 100644 src/model/orchestrator/services/output/output-service.ts create mode 100644 src/model/orchestrator/services/output/output-type-registry.ts diff --git a/src/model/orchestrator/services/output/index.ts b/src/model/orchestrator/services/output/index.ts new file mode 100644 index 00000000..a03e605f --- /dev/null +++ b/src/model/orchestrator/services/output/index.ts @@ -0,0 +1,3 @@ +export { OutputManifest, OutputEntry } from './output-manifest'; +export { OutputTypeRegistry, OutputTypeDefinition } from './output-type-registry'; +export { OutputService } from './output-service'; diff --git a/src/model/orchestrator/services/output/output-manifest.ts b/src/model/orchestrator/services/output/output-manifest.ts new file mode 100644 index 00000000..256554af --- /dev/null +++ b/src/model/orchestrator/services/output/output-manifest.ts @@ -0,0 +1,41 @@ +/** + * Structured build output manifest. + * Describes all artifacts produced by a build with type, path, size, hash, and metadata. + */ + +export interface OutputEntry { + /** Output type identifier (e.g., 'build', 'test-results', 'images') */ + type: string; + + /** Relative path to the output */ + path: string; + + /** Output format (e.g., 'nunit3', 'junit', 'json') */ + format?: string; + + /** File size in bytes */ + size?: number; + + /** Content hash (e.g., 'sha256:abc...') */ + hash?: string; + + /** Individual files within the output path */ + files?: string[]; + + /** Type-specific summary (e.g., test counts, build size) */ + summary?: Record; + + /** Arbitrary metadata */ + metadata?: Record; +} + +export interface OutputManifest { + /** Unique build identifier */ + buildGuid: string; + + /** ISO 8601 timestamp */ + timestamp: string; + + /** All outputs produced by this build */ + outputs: OutputEntry[]; +} diff --git a/src/model/orchestrator/services/output/output-service.ts b/src/model/orchestrator/services/output/output-service.ts new file mode 100644 index 00000000..83f5918b --- /dev/null +++ b/src/model/orchestrator/services/output/output-service.ts @@ -0,0 +1,111 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import OrchestratorLogger from '../core/orchestrator-logger'; +import { OutputManifest, OutputEntry } from './output-manifest'; +import { OutputTypeRegistry } from './output-type-registry'; + +/** + * Service for collecting, manifesting, and managing build outputs. + * + * After a build completes, this service scans declared output paths, + * generates a structured manifest, and prepares outputs for post-processing. + */ +export class OutputService { + /** + * Collect outputs from the workspace and generate a manifest. + * + * @param projectPath - Path to the Unity project root + * @param buildGuid - Unique build identifier + * @param outputTypesInput - Comma-separated output type names + * @param manifestPath - Where to write the manifest JSON (optional) + * @returns The generated output manifest + */ + static async collectOutputs( + projectPath: string, + buildGuid: string, + outputTypesInput: string, + manifestPath?: string, + ): Promise { + const types = OutputTypeRegistry.parseOutputTypes(outputTypesInput); + const manifest: OutputManifest = { + buildGuid, + timestamp: new Date().toISOString(), + outputs: [], + }; + + if (types.length === 0) { + OrchestratorLogger.log('[Output] No output types declared, skipping collection'); + + return manifest; + } + + OrchestratorLogger.log(`[Output] Collecting ${types.length} output type(s): ${types.map((t) => t.name).join(', ')}`); + + for (const typeDef of types) { + const outputPath = path.join(projectPath, typeDef.defaultPath.replace('{platform}', process.env.BUILD_TARGET || 'Unknown')); + + if (!fs.existsSync(outputPath)) { + OrchestratorLogger.log(`[Output] No output found for '${typeDef.name}' at ${outputPath}`); + continue; + } + + const entry: OutputEntry = { + type: typeDef.name, + path: typeDef.defaultPath, + }; + + // Collect file listing for directory outputs + try { + const stat = fs.statSync(outputPath); + if (stat.isDirectory()) { + entry.files = fs.readdirSync(outputPath); + entry.size = OutputService.getDirectorySize(outputPath); + } else { + entry.size = stat.size; + } + } catch { + OrchestratorLogger.logWarning(`[Output] Failed to stat output '${typeDef.name}' at ${outputPath}`); + } + + manifest.outputs.push(entry); + OrchestratorLogger.log(`[Output] Collected '${typeDef.name}': ${entry.files?.length || 1} file(s), ${entry.size || 0} bytes`); + } + + // Write manifest to disk + if (manifestPath) { + try { + const manifestDir = path.dirname(manifestPath); + fs.mkdirSync(manifestDir, { recursive: true }); + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8'); + OrchestratorLogger.log(`[Output] Manifest written to ${manifestPath}`); + } catch (error: any) { + OrchestratorLogger.logWarning(`[Output] Failed to write manifest: ${error.message}`); + } + } + + return manifest; + } + + /** + * Calculate total size of a directory recursively. + */ + private static getDirectorySize(dirPath: string): number { + let totalSize = 0; + + try { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + totalSize += OutputService.getDirectorySize(fullPath); + } else { + totalSize += fs.statSync(fullPath).size; + } + } + } catch { + // Ignore errors in size calculation + } + + return totalSize; + } +} diff --git a/src/model/orchestrator/services/output/output-type-registry.ts b/src/model/orchestrator/services/output/output-type-registry.ts new file mode 100644 index 00000000..13c38200 --- /dev/null +++ b/src/model/orchestrator/services/output/output-type-registry.ts @@ -0,0 +1,138 @@ +import OrchestratorLogger from '../core/orchestrator-logger'; + +/** + * Registry of known output types with default paths and processing hints. + */ + +export interface OutputTypeDefinition { + /** Type identifier */ + name: string; + + /** Default output path (relative to project root) */ + defaultPath: string; + + /** Human-readable description */ + description: string; + + /** Whether this type is built-in or user-registered */ + builtIn: boolean; +} + +export class OutputTypeRegistry { + private static readonly builtInTypes: Record = { + build: { + name: 'build', + defaultPath: './Builds/{platform}/', + description: 'Standard game build artifact', + builtIn: true, + }, + 'test-results': { + name: 'test-results', + defaultPath: './TestResults/', + description: 'NUnit/JUnit XML test results', + builtIn: true, + }, + 'server-build': { + name: 'server-build', + defaultPath: './Builds/{platform}-server/', + description: 'Dedicated server build artifact', + builtIn: true, + }, + 'data-export': { + name: 'data-export', + defaultPath: './Exports/', + description: 'Exported data files (CSV, JSON, binary)', + builtIn: true, + }, + images: { + name: 'images', + defaultPath: './Captures/', + description: 'Screenshots, render captures, atlas previews', + builtIn: true, + }, + logs: { + name: 'logs', + defaultPath: './Logs/', + description: 'Structured build and test logs', + builtIn: true, + }, + metrics: { + name: 'metrics', + defaultPath: './Metrics/', + description: 'Build performance metrics and asset statistics', + builtIn: true, + }, + coverage: { + name: 'coverage', + defaultPath: './Coverage/', + description: 'Code coverage reports', + builtIn: true, + }, + }; + + private static customTypes: Record = {}; + + /** + * Get a type definition by name. Checks custom types first, then built-in. + */ + static getType(name: string): OutputTypeDefinition | undefined { + return OutputTypeRegistry.customTypes[name] || OutputTypeRegistry.builtInTypes[name]; + } + + /** + * Get all registered types (built-in + custom). + */ + static getAllTypes(): OutputTypeDefinition[] { + return [ + ...Object.values(OutputTypeRegistry.builtInTypes), + ...Object.values(OutputTypeRegistry.customTypes), + ]; + } + + /** + * Register a custom output type. + */ + static registerType(definition: OutputTypeDefinition): void { + if (OutputTypeRegistry.builtInTypes[definition.name]) { + OrchestratorLogger.logWarning( + `[OutputTypes] Cannot override built-in type '${definition.name}'`, + ); + + return; + } + + OutputTypeRegistry.customTypes[definition.name] = { ...definition, builtIn: false }; + OrchestratorLogger.log(`[OutputTypes] Registered custom type '${definition.name}'`); + } + + /** + * Parse a comma-separated output types string into type definitions. + * Unknown types are logged as warnings and skipped. + */ + static parseOutputTypes(outputTypesInput: string): OutputTypeDefinition[] { + if (!outputTypesInput) { + return []; + } + + const names = outputTypesInput.split(',').map((s) => s.trim()).filter(Boolean); + const types: OutputTypeDefinition[] = []; + + for (const name of names) { + const typeDef = OutputTypeRegistry.getType(name); + if (typeDef) { + types.push(typeDef); + } else { + OrchestratorLogger.logWarning(`[OutputTypes] Unknown output type '${name}', skipping`); + } + } + + return types; + } + + /** + * Reset custom types (for testing). + */ + static resetCustomTypes(): void { + OutputTypeRegistry.customTypes = {}; + } +}