diff --git a/src/failure-artifacts.ts b/src/failure-artifacts.ts index a2cacab..e5ba04b 100644 --- a/src/failure-artifacts.ts +++ b/src/failure-artifacts.ts @@ -1,8 +1,39 @@ +import { spawnSync } from 'child_process'; import fs from 'fs'; +import * as http from 'http'; +import * as https from 'https'; import os from 'os'; import path from 'path'; +import { URL } from 'url'; +import * as zlib from 'zlib'; + +const SENTIENCE_API_URL = 'https://api.sentienceapi.com'; + +/** + * Optional logger interface for SDK users + */ +export interface SentienceLogger { + info(message: string): void; + warn(message: string): void; + error(message: string): void; +} export type PersistMode = 'onFail' | 'always'; +export type ClipMode = 'off' | 'auto' | 'on'; + +export interface ClipOptions { + /** + * Clip generation mode: + * - "off": Never generate clips + * - "auto": Generate only if ffmpeg is available on PATH (default) + * - "on": Always attempt to generate (will warn if ffmpeg missing) + */ + mode?: ClipMode; + /** Frames per second for the generated video (default: 8) */ + fps?: number; + /** Duration of clip in seconds. If undefined, uses bufferSeconds */ + seconds?: number; +} export interface FailureArtifactsOptions { bufferSeconds?: number; @@ -12,6 +43,7 @@ export interface FailureArtifactsOptions { outputDir?: string; onBeforePersist?: ((ctx: RedactionContext) => RedactionResult) | null; redactSnapshotValues?: boolean; + clip?: ClipOptions; } interface FrameRecord { @@ -37,12 +69,119 @@ export interface RedactionResult { dropFrames?: boolean; } +/** Response from POST /v1/traces/artifacts/init */ +interface ArtifactsInitResponse { + upload_urls: Array<{ + name: string; + upload_url: string; + storage_key: string; + }>; + artifact_index_upload: { + upload_url: string; + storage_key: string; + }; + expires_in: number; +} + async function writeJsonAtomic(filePath: string, data: any): Promise { const tmpPath = `${filePath}.tmp`; await fs.promises.writeFile(tmpPath, JSON.stringify(data, null, 2)); await fs.promises.rename(tmpPath, filePath); } +/** + * Check if ffmpeg is available on the system PATH. + */ +function isFfmpegAvailable(): boolean { + try { + const result = spawnSync('ffmpeg', ['-version'], { + timeout: 5000, + stdio: 'pipe', + }); + return result.status === 0; + } catch { + return false; + } +} + +/** + * Generate an MP4 video clip from a directory of frames using ffmpeg. + */ +function generateClipFromFrames(framesDir: string, outputPath: string, fps: number = 8): boolean { + // Find all frame files and sort them + const files = fs + .readdirSync(framesDir) + .filter( + f => + f.startsWith('frame_') && (f.endsWith('.png') || f.endsWith('.jpeg') || f.endsWith('.jpg')) + ) + .sort(); + + if (files.length === 0) { + console.warn('No frame files found for clip generation'); + return false; + } + + // Create a temporary file list for ffmpeg concat demuxer + const listFile = path.join(framesDir, 'frames_list.txt'); + const frameDuration = 1.0 / fps; + + try { + // Write the frames list file + const listContent = + files.map(f => `file '${f}'\nduration ${frameDuration}`).join('\n') + + `\nfile '${files[files.length - 1]}'`; // ffmpeg concat quirk + + fs.writeFileSync(listFile, listContent); + + // Run ffmpeg to generate the clip + const result = spawnSync( + 'ffmpeg', + [ + '-y', + '-f', + 'concat', + '-safe', + '0', + '-i', + listFile, + '-vsync', + 'vfr', + '-pix_fmt', + 'yuv420p', + '-c:v', + 'libx264', + '-crf', + '23', + outputPath, + ], + { + timeout: 60000, // 1 minute timeout + cwd: framesDir, + stdio: 'pipe', + } + ); + + if (result.status !== 0) { + const stderr = result.stderr?.toString('utf-8').slice(0, 500) ?? ''; + console.warn(`ffmpeg failed with return code ${result.status}: ${stderr}`); + return false; + } + + return fs.existsSync(outputPath); + } catch (err) { + console.warn(`Error generating clip: ${err}`); + return false; + } finally { + // Clean up the list file + try { + fs.unlinkSync(listFile); + } catch { + // ignore + } + } +} + function redactSnapshotDefaults(payload: any): any { if (!payload || typeof payload !== 'object') { return payload; @@ -86,6 +225,11 @@ export class FailureArtifactBuffer { outputDir: options.outputDir ?? '.sentience/artifacts', onBeforePersist: options.onBeforePersist ?? null, redactSnapshotValues: options.redactSnapshotValues ?? true, + clip: { + mode: options.clip?.mode ?? 'auto', + fps: options.clip?.fps ?? 8, + seconds: options.clip?.seconds, + }, }; this.timeNow = timeNow; this.tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentience-artifacts-')); @@ -218,6 +362,42 @@ export class FailureArtifactBuffer { diagnosticsWritten = true; } + // Generate video clip from frames (optional, requires ffmpeg) + let clipGenerated = false; + const clipOptions = this.options.clip; + + if (!dropFrames && framePaths.length > 0 && clipOptions.mode !== 'off') { + let shouldGenerate = false; + + if (clipOptions.mode === 'auto') { + // Only generate if ffmpeg is available + shouldGenerate = isFfmpegAvailable(); + if (!shouldGenerate) { + // Silent in auto mode - just skip + } + } else if (clipOptions.mode === 'on') { + // Always attempt to generate + shouldGenerate = true; + if (!isFfmpegAvailable()) { + console.warn( + "ffmpeg not found on PATH but clip.mode='on'. " + + 'Install ffmpeg to generate video clips.' + ); + shouldGenerate = false; + } + } + + if (shouldGenerate) { + const clipPath = path.join(runDir, 'failure.mp4'); + clipGenerated = generateClipFromFrames(framesOut, clipPath, clipOptions.fps ?? 8); + if (clipGenerated) { + console.log(`Generated failure clip: ${clipPath}`); + } else { + console.warn('Failed to generate video clip'); + } + } + } + const manifest = { run_id: this.runId, created_at_ms: ts, @@ -228,6 +408,8 @@ export class FailureArtifactBuffer { frames: dropFrames ? [] : framePaths.map(p => ({ file: path.basename(p), ts: null })), snapshot: snapshotWritten ? 'snapshot.json' : null, diagnostics: diagnosticsWritten ? 'diagnostics.json' : null, + clip: clipGenerated ? 'failure.mp4' : null, + clip_fps: clipGenerated ? (clipOptions.fps ?? 8) : null, metadata: metadata ?? {}, frames_redacted: !dropFrames && Boolean(this.options.onBeforePersist), frames_dropped: dropFrames, @@ -241,4 +423,518 @@ export class FailureArtifactBuffer { async cleanup(): Promise { await fs.promises.rm(this.tempDir, { recursive: true, force: true }); } + + /** + * Upload persisted artifacts to cloud storage. + * + * This method uploads all artifacts from a persisted directory to cloud storage + * using presigned URLs from the gateway. It follows the same pattern as trace + * screenshot uploads. + * + * @param apiKey - Sentience API key for authentication + * @param apiUrl - Sentience API base URL (default: https://api.sentienceapi.com) + * @param persistedDir - Path to persisted artifacts directory. If undefined, uses the + * most recent persist() output directory. + * @param logger - Optional logger for progress/error messages + * @returns artifact_index_key on success, null on failure + * + * @example + * const buf = new FailureArtifactBuffer('run-123', options); + * await buf.addFrame(screenshotBytes); + * const runDir = await buf.persist('assertion failed', 'failure'); + * const artifactKey = await buf.uploadToCloud('sk-...'); + * // artifactKey can be passed to /v1/traces/complete + */ + async uploadToCloud( + apiKey: string, + apiUrl?: string, + persistedDir?: string, + logger?: SentienceLogger + ): Promise { + const baseUrl = apiUrl || SENTIENCE_API_URL; + + // Determine which directory to upload + let targetDir = persistedDir; + if (!targetDir) { + // Find most recent persisted directory + const outputDir = this.options.outputDir; + if (!fs.existsSync(outputDir)) { + logger?.warn('No artifacts directory found'); + return null; + } + + // Look for directories matching runId pattern + const entries = fs.readdirSync(outputDir, { withFileTypes: true }); + const matchingDirs = entries + .filter(e => e.isDirectory() && e.name.startsWith(this.runId)) + .map(e => ({ + name: e.name, + path: path.join(outputDir, e.name), + mtime: fs.statSync(path.join(outputDir, e.name)).mtimeMs, + })) + .sort((a, b) => b.mtime - a.mtime); + + if (matchingDirs.length === 0) { + logger?.warn(`No persisted artifacts found for runId=${this.runId}`); + return null; + } + targetDir = matchingDirs[0].path; + } + + if (!fs.existsSync(targetDir)) { + logger?.warn(`Artifacts directory not found: ${targetDir}`); + return null; + } + + // Read manifest to understand what files need uploading + const manifestPath = path.join(targetDir, 'manifest.json'); + if (!fs.existsSync(manifestPath)) { + logger?.warn('manifest.json not found in artifacts directory'); + return null; + } + + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + + // Build list of artifacts to upload + const artifacts = this.collectArtifactsForUpload(targetDir, manifest); + if (artifacts.length === 0) { + logger?.warn('No artifacts to upload'); + return null; + } + + logger?.info(`Uploading ${artifacts.length} artifact(s) to cloud`); + + // Request presigned URLs from gateway + const uploadUrls = await this.requestArtifactUrls(apiKey, baseUrl, artifacts, logger); + if (!uploadUrls) { + return null; + } + + // Upload artifacts in parallel + const artifactIndexKey = await this.uploadArtifacts(artifacts, uploadUrls, logger); + + if (artifactIndexKey) { + // Report completion to gateway + await this.completeArtifacts(apiKey, baseUrl, artifactIndexKey, artifacts, logger); + } + + return artifactIndexKey; + } + + private collectArtifactsForUpload( + persistedDir: string, + manifest: any + ): Array<{ name: string; sizeBytes: number; contentType: string; filePath: string }> { + const artifacts: Array<{ + name: string; + sizeBytes: number; + contentType: string; + filePath: string; + }> = []; + + // Core JSON artifacts + const jsonFiles = ['manifest.json', 'steps.json']; + if (manifest.snapshot) { + jsonFiles.push('snapshot.json'); + } + if (manifest.diagnostics) { + jsonFiles.push('diagnostics.json'); + } + + for (const filename of jsonFiles) { + const filePath = path.join(persistedDir, filename); + if (fs.existsSync(filePath)) { + artifacts.push({ + name: filename, + sizeBytes: fs.statSync(filePath).size, + contentType: 'application/json', + filePath, + }); + } + } + + // Video clip + if (manifest.clip) { + const clipPath = path.join(persistedDir, 'failure.mp4'); + if (fs.existsSync(clipPath)) { + artifacts.push({ + name: 'failure.mp4', + sizeBytes: fs.statSync(clipPath).size, + contentType: 'video/mp4', + filePath: clipPath, + }); + } + } + + // Frames + const framesDir = path.join(persistedDir, 'frames'); + if (fs.existsSync(framesDir)) { + const frameFiles = fs.readdirSync(framesDir).sort(); + for (const frameFile of frameFiles) { + const ext = path.extname(frameFile).toLowerCase(); + if (['.jpeg', '.jpg', '.png'].includes(ext)) { + const framePath = path.join(framesDir, frameFile); + const contentType = ext === '.png' ? 'image/png' : 'image/jpeg'; + artifacts.push({ + name: `frames/${frameFile}`, + sizeBytes: fs.statSync(framePath).size, + contentType, + filePath: framePath, + }); + } + } + } + + return artifacts; + } + + private async requestArtifactUrls( + apiKey: string, + apiUrl: string, + artifacts: Array<{ name: string; sizeBytes: number; contentType: string; filePath: string }>, + logger?: SentienceLogger + ): Promise { + try { + // Prepare request payload (exclude local path) + const artifactsPayload = artifacts.map(a => ({ + name: a.name, + size_bytes: a.sizeBytes, + content_type: a.contentType, + })); + + const body = JSON.stringify({ + run_id: this.runId, + artifacts: artifactsPayload, + }); + + return new Promise(resolve => { + const url = new URL(`${apiUrl}/v1/traces/artifacts/init`); + const protocol = url.protocol === 'https:' ? https : http; + + const options = { + hostname: url.hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: url.pathname + url.search, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + Authorization: `Bearer ${apiKey}`, + }, + timeout: 30000, + }; + + const req = protocol.request(options, res => { + let data = ''; + res.on('data', chunk => { + data += chunk; + }); + res.on('end', () => { + if (res.statusCode === 200) { + try { + resolve(JSON.parse(data)); + } catch { + logger?.warn('Failed to parse artifact upload URLs response'); + resolve(null); + } + } else { + logger?.warn(`Failed to get artifact upload URLs: HTTP ${res.statusCode}`); + resolve(null); + } + }); + }); + + req.on('error', error => { + logger?.error(`Error requesting artifact upload URLs: ${error.message}`); + resolve(null); + }); + + req.on('timeout', () => { + req.destroy(); + logger?.warn('Artifact URLs request timeout'); + resolve(null); + }); + + req.write(body); + req.end(); + }); + } catch (error: any) { + logger?.error(`Error requesting artifact upload URLs: ${error.message}`); + return null; + } + } + + private async uploadArtifacts( + artifacts: Array<{ name: string; sizeBytes: number; contentType: string; filePath: string }>, + uploadUrls: ArtifactsInitResponse, + logger?: SentienceLogger + ): Promise { + const urlMap = new Map(); + for (const item of uploadUrls.upload_urls) { + urlMap.set(item.name, item); + } + const indexUpload = uploadUrls.artifact_index_upload; + + const storageKeys = new Map(); + const uploadPromises: Promise<{ name: string; success: boolean }>[] = []; + + for (const artifact of artifacts) { + const urlInfo = urlMap.get(artifact.name); + if (!urlInfo) { + continue; + } + + const uploadPromise = this.uploadSingleArtifact(artifact, urlInfo, logger).then(success => ({ + name: artifact.name, + success, + })); + uploadPromises.push(uploadPromise); + } + + // Wait for all uploads + const results = await Promise.all(uploadPromises); + + let uploadedCount = 0; + const failedNames: string[] = []; + + for (const result of results) { + if (result.success) { + uploadedCount++; + const urlInfo = urlMap.get(result.name); + if (urlInfo?.storage_key) { + storageKeys.set(result.name, urlInfo.storage_key); + } + } else { + failedNames.push(result.name); + } + } + + if (uploadedCount === artifacts.length) { + logger?.info(`All ${uploadedCount} artifacts uploaded successfully`); + } else { + logger?.warn( + `Uploaded ${uploadedCount}/${artifacts.length} artifacts. Failed: ${failedNames.join(', ')}` + ); + } + + // Upload artifact index file + if (indexUpload && uploadedCount > 0) { + return this.uploadArtifactIndex(artifacts, storageKeys, indexUpload, logger); + } + + return null; + } + + private async uploadSingleArtifact( + artifact: { name: string; sizeBytes: number; contentType: string; filePath: string }, + urlInfo: any, + logger?: SentienceLogger + ): Promise { + try { + const data = fs.readFileSync(artifact.filePath); + + return new Promise(resolve => { + const url = new URL(urlInfo.upload_url); + const protocol = url.protocol === 'https:' ? https : http; + + const options = { + hostname: url.hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: url.pathname + url.search, + method: 'PUT', + headers: { + 'Content-Type': artifact.contentType, + 'Content-Length': data.length, + }, + timeout: 60000, + }; + + const req = protocol.request(options, res => { + res.on('data', () => {}); + res.on('end', () => { + if (res.statusCode === 200) { + resolve(true); + } else { + logger?.warn(`Artifact ${artifact.name} upload failed: HTTP ${res.statusCode}`); + resolve(false); + } + }); + }); + + req.on('error', error => { + logger?.warn(`Artifact ${artifact.name} upload error: ${error.message}`); + resolve(false); + }); + + req.on('timeout', () => { + req.destroy(); + logger?.warn(`Artifact ${artifact.name} upload timeout`); + resolve(false); + }); + + req.write(data); + req.end(); + }); + } catch (error: any) { + logger?.warn(`Artifact ${artifact.name} upload error: ${error.message}`); + return false; + } + } + + private async uploadArtifactIndex( + artifacts: Array<{ name: string; sizeBytes: number; contentType: string; filePath: string }>, + storageKeys: Map, + indexUpload: any, + logger?: SentienceLogger + ): Promise { + try { + // Build index content + const indexData = { + run_id: this.runId, + created_at_ms: Date.now(), + artifacts: artifacts + .filter(a => storageKeys.has(a.name)) + .map(a => ({ + name: a.name, + storage_key: storageKeys.get(a.name) || '', + content_type: a.contentType, + })), + }; + + // Compress and upload + const indexJson = Buffer.from(JSON.stringify(indexData, null, 2), 'utf-8'); + const compressed = zlib.gzipSync(indexJson); + + return new Promise(resolve => { + const url = new URL(indexUpload.upload_url); + const protocol = url.protocol === 'https:' ? https : http; + + const options = { + hostname: url.hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: url.pathname + url.search, + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Content-Encoding': 'gzip', + 'Content-Length': compressed.length, + }, + timeout: 30000, + }; + + const req = protocol.request(options, res => { + res.on('data', () => {}); + res.on('end', () => { + if (res.statusCode === 200) { + logger?.info('Artifact index uploaded successfully'); + resolve(indexUpload.storage_key || ''); + } else { + logger?.warn(`Artifact index upload failed: HTTP ${res.statusCode}`); + resolve(null); + } + }); + }); + + req.on('error', error => { + logger?.warn(`Error uploading artifact index: ${error.message}`); + resolve(null); + }); + + req.on('timeout', () => { + req.destroy(); + logger?.warn('Artifact index upload timeout'); + resolve(null); + }); + + req.write(compressed); + req.end(); + }); + } catch (error: any) { + logger?.warn(`Error uploading artifact index: ${error.message}`); + return null; + } + } + + private async completeArtifacts( + apiKey: string, + apiUrl: string, + artifactIndexKey: string, + artifacts: Array<{ name: string; sizeBytes: number; contentType: string; filePath: string }>, + logger?: SentienceLogger + ): Promise { + try { + // Calculate stats + const totalSize = artifacts.reduce((sum, a) => sum + a.sizeBytes, 0); + const framesArtifacts = artifacts.filter(a => a.name.startsWith('frames/')); + const framesTotal = framesArtifacts.reduce((sum, a) => sum + a.sizeBytes, 0); + + // Get individual file sizes + const manifestSize = artifacts.find(a => a.name === 'manifest.json')?.sizeBytes || 0; + const snapshotSize = artifacts.find(a => a.name === 'snapshot.json')?.sizeBytes || 0; + const diagnosticsSize = artifacts.find(a => a.name === 'diagnostics.json')?.sizeBytes || 0; + const stepsSize = artifacts.find(a => a.name === 'steps.json')?.sizeBytes || 0; + const clipSize = artifacts.find(a => a.name === 'failure.mp4')?.sizeBytes || 0; + + const body = JSON.stringify({ + run_id: this.runId, + artifact_index_key: artifactIndexKey, + stats: { + manifest_size_bytes: manifestSize, + snapshot_size_bytes: snapshotSize, + diagnostics_size_bytes: diagnosticsSize, + steps_size_bytes: stepsSize, + clip_size_bytes: clipSize, + frames_total_size_bytes: framesTotal, + frames_count: framesArtifacts.length, + total_artifact_size_bytes: totalSize, + }, + }); + + return new Promise(resolve => { + const url = new URL(`${apiUrl}/v1/traces/artifacts/complete`); + const protocol = url.protocol === 'https:' ? https : http; + + const options = { + hostname: url.hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: url.pathname + url.search, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + Authorization: `Bearer ${apiKey}`, + }, + timeout: 10000, + }; + + const req = protocol.request(options, res => { + res.on('data', () => {}); + res.on('end', () => { + if (res.statusCode === 200) { + logger?.info('Artifact completion reported to gateway'); + } else { + logger?.warn(`Failed to report artifact completion: HTTP ${res.statusCode}`); + } + resolve(); + }); + }); + + req.on('error', error => { + logger?.warn(`Error reporting artifact completion: ${error.message}`); + resolve(); + }); + + req.on('timeout', () => { + req.destroy(); + logger?.warn('Artifact completion request timeout'); + resolve(); + }); + + req.write(body); + req.end(); + }); + } catch (error: any) { + logger?.warn(`Error reporting artifact completion: ${error.message}`); + } + } } diff --git a/tests/failure-artifacts.test.ts b/tests/failure-artifacts.test.ts index 05eafe3..8d3e43c 100644 --- a/tests/failure-artifacts.test.ts +++ b/tests/failure-artifacts.test.ts @@ -83,4 +83,147 @@ describe('FailureArtifactBuffer', () => { expect(manifest.frame_count).toBe(0); expect(manifest.frames_dropped).toBe(true); }); + + // -------------------- Phase 4: Clip generation tests -------------------- + + it('clip mode off skips generation', async () => { + const tmp = makeTempDir('sentience-test-'); + const buf = new FailureArtifactBuffer('run-clip-off', { + outputDir: tmp, + clip: { mode: 'off' }, + }); + await buf.addFrame(Buffer.from('frame'), 'png'); + const runDir = await buf.persist('fail', 'failure'); + const manifest = JSON.parse( + fs.readFileSync(path.join(runDir as string, 'manifest.json'), 'utf-8') + ); + expect(manifest.clip).toBeNull(); + expect(manifest.clip_fps).toBeNull(); + }); + + it('manifest includes clip fields when frames exist', async () => { + const tmp = makeTempDir('sentience-test-'); + // With clip.mode='auto' and ffmpeg likely not available in test env, + // clip should be null but manifest should still include the fields + const buf = new FailureArtifactBuffer('run-clip-auto', { + outputDir: tmp, + clip: { mode: 'auto', fps: 10 }, + }); + await buf.addFrame(Buffer.from('frame'), 'png'); + const runDir = await buf.persist('fail', 'failure'); + const manifest = JSON.parse( + fs.readFileSync(path.join(runDir as string, 'manifest.json'), 'utf-8') + ); + // clip and clip_fps fields should exist in manifest (even if null) + expect('clip' in manifest).toBe(true); + expect('clip_fps' in manifest).toBe(true); + }); + + it('clip not generated when frames are dropped', async () => { + const tmp = makeTempDir('sentience-test-'); + const buf = new FailureArtifactBuffer('run-clip-dropped', { + outputDir: tmp, + clip: { mode: 'on' }, + onBeforePersist: () => ({ dropFrames: true }), + }); + await buf.addFrame(Buffer.from('frame'), 'png'); + const runDir = await buf.persist('fail', 'failure'); + const manifest = JSON.parse( + fs.readFileSync(path.join(runDir as string, 'manifest.json'), 'utf-8') + ); + expect(manifest.clip).toBeNull(); + expect(manifest.frames_dropped).toBe(true); + }); + + it('clip options use defaults when not specified', () => { + const tmp = makeTempDir('sentience-test-'); + const buf = new FailureArtifactBuffer('run-defaults', { outputDir: tmp }); + const opts = buf.getOptions(); + expect(opts.clip.mode).toBe('auto'); + expect(opts.clip.fps).toBe(8); + expect(opts.clip.seconds).toBeUndefined(); + }); + + it('clip options can be customized', () => { + const tmp = makeTempDir('sentience-test-'); + const buf = new FailureArtifactBuffer('run-custom', { + outputDir: tmp, + clip: { mode: 'on', fps: 15, seconds: 30 }, + }); + const opts = buf.getOptions(); + expect(opts.clip.mode).toBe('on'); + expect(opts.clip.fps).toBe(15); + expect(opts.clip.seconds).toBe(30); + }); + + // -------------------- Phase 5: Cloud upload tests -------------------- + + it('uploadToCloud returns null when no artifacts directory', async () => { + const tmp = makeTempDir('sentience-test-'); + const buf = new FailureArtifactBuffer('run-upload-1', { + outputDir: path.join(tmp, 'nonexistent'), + }); + + const result = await buf.uploadToCloud('test-key'); + expect(result).toBeNull(); + }); + + it('uploadToCloud returns null when no manifest', async () => { + const tmp = makeTempDir('sentience-test-'); + // Create a directory but no manifest + const runDir = path.join(tmp, 'run-upload-2-123'); + fs.mkdirSync(runDir, { recursive: true }); + + const buf = new FailureArtifactBuffer('run-upload-2', { outputDir: tmp }); + + const result = await buf.uploadToCloud('test-key', undefined, runDir); + expect(result).toBeNull(); + }); + + it('collectArtifactsForUpload collects correct files', async () => { + const tmp = makeTempDir('sentience-test-'); + const buf = new FailureArtifactBuffer('run-collect', { outputDir: tmp }); + await buf.addFrame(Buffer.from('frame1'), 'png'); + + const runDir = await buf.persist('fail', 'failure', { status: 'success' }, { confidence: 0.9 }); + expect(runDir).toBeTruthy(); + + // Read manifest + const manifest = JSON.parse(fs.readFileSync(path.join(runDir!, 'manifest.json'), 'utf-8')); + + // Use private method to collect artifacts (access via any) + const artifacts = (buf as any).collectArtifactsForUpload(runDir, manifest); + + // Should have: manifest.json, steps.json, snapshot.json, diagnostics.json, and 1 frame + const artifactNames = artifacts.map((a: any) => a.name); + expect(artifactNames).toContain('manifest.json'); + expect(artifactNames).toContain('steps.json'); + expect(artifactNames).toContain('snapshot.json'); + expect(artifactNames).toContain('diagnostics.json'); + expect(artifactNames.some((n: string) => n.startsWith('frames/'))).toBe(true); + + // Verify all files have valid properties + for (const artifact of artifacts) { + expect(fs.existsSync(artifact.filePath)).toBe(true); + expect(artifact.sizeBytes).toBeGreaterThan(0); + expect(['application/json', 'image/png', 'image/jpeg']).toContain(artifact.contentType); + } + }); + + it('uploadToCloud handles missing frames gracefully', async () => { + const tmp = makeTempDir('sentience-test-'); + const buf = new FailureArtifactBuffer('run-no-frames', { outputDir: tmp }); + + // Persist without adding frames - should still work + const runDir = await buf.persist('fail', 'failure', { status: 'success' }); + expect(runDir).toBeTruthy(); + + const manifest = JSON.parse(fs.readFileSync(path.join(runDir!, 'manifest.json'), 'utf-8')); + expect(manifest.frame_count).toBe(0); + + // Should still be able to collect artifacts (just no frames) + const artifacts = (buf as any).collectArtifactsForUpload(runDir, manifest); + const hasFrames = artifacts.some((a: any) => a.name.startsWith('frames/')); + expect(hasFrames).toBe(false); + }); });