@@ -13,6 +13,10 @@ import { getRunningExpressApp } from '../../utils/getRunningExpressApp'
1313import { FineTuningJob } from '../../database/entities/FineTuningJob'
1414import logger from '../../utils/logger'
1515
16+ // Declare timer globals for Node.js
17+ declare function setTimeout ( cb : ( ...args : any [ ] ) => void , ms ?: number ) : any
18+ declare function clearTimeout ( id : any ) : void
19+
1620const execAsync = promisify ( exec )
1721
1822const FINETUNING_SERVICE_URL = process . env . FINETUNING_HOST ? `http://${ process . env . FINETUNING_HOST } :8015` : 'undefined'
@@ -36,93 +40,6 @@ const axiosClient: AxiosInstance = axios.create({
3640// In-memory mapping: filename (raw and decoded) -> { id, rawFilename }
3741const uploadedFileIdMap : Map < string , { id : string ; rawFilename : string } > = new Map ( )
3842
39- /**
40- * Helper function to zip a fine-tuning job output directory
41- * Checks if zip already exists and is up-to-date before creating a new one
42- * @param outputDir - Full path to the output directory for the job
43- * @param jobId - ID of the fine-tuning job
44- * @returns Path to the zipped file or null if failed
45- */
46- const ensureFineTuningOutputZip = async ( outputDir : string , jobId : string ) : Promise < string | null > => {
47- try {
48- // eslint-disable-next-line no-console
49- console . debug ( `finetuningService.ensureFineTuningOutputZip - processing output for job: ${ jobId } ` )
50-
51- // Validate output directory exists
52- if ( ! fs . existsSync ( outputDir ) ) {
53- // eslint-disable-next-line no-console
54- console . warn ( `finetuningService.ensureFineTuningOutputZip - output directory not found: ${ outputDir } ` )
55- return null
56- }
57-
58- const zipFilePath = `${ outputDir } .zip`
59- const outputStats = fs . statSync ( outputDir )
60-
61- // Check if zip exists and is up-to-date
62- if ( fs . existsSync ( zipFilePath ) ) {
63- const zipStats = fs . statSync ( zipFilePath )
64- // If zip is newer than the output directory, skip re-zipping
65- if ( zipStats . mtimeMs > outputStats . mtimeMs ) {
66- // eslint-disable-next-line no-console
67- console . debug ( `finetuningService.ensureFineTuningOutputZip - zip already up-to-date: ${ zipFilePath } ` )
68- return zipFilePath
69- }
70- // Remove outdated zip
71- try {
72- fs . unlinkSync ( zipFilePath )
73- // eslint-disable-next-line no-console
74- console . debug ( `finetuningService.ensureFineTuningOutputZip - removed outdated zip: ${ zipFilePath } ` )
75- } catch ( e ) {
76- // eslint-disable-next-line no-console
77- console . warn ( `finetuningService.ensureFineTuningOutputZip - failed to remove outdated zip: ${ e } ` )
78- }
79- }
80-
81- // Create zip file using archiver (standard ZIP format compatible with Windows)
82- // eslint-disable-next-line no-console
83- console . debug ( `finetuningService.ensureFineTuningOutputZip - starting to zip output for job ${ jobId } ` )
84- try {
85- return await new Promise ( ( resolve , reject ) => {
86- const output = fs . createWriteStream ( zipFilePath )
87- const archive = archiver ( 'zip' , {
88- zlib : { level : 6 } // compression level
89- } )
90-
91- output . on ( 'close' , ( ) => {
92- // eslint-disable-next-line no-console
93- console . debug ( `finetuningService.ensureFineTuningOutputZip - zip created successfully for job ${ jobId } : ${ zipFilePath } (${ archive . pointer ( ) } bytes)` )
94- resolve ( zipFilePath )
95- } )
96-
97- output . on ( 'error' , ( err : any ) => {
98- // eslint-disable-next-line no-console
99- console . error ( `finetuningService.ensureFineTuningOutputZip - write stream error: ${ err ?. message || err } ` )
100- reject ( err )
101- } )
102-
103- archive . on ( 'error' , ( err : any ) => {
104- // eslint-disable-next-line no-console
105- console . error ( `finetuningService.ensureFineTuningOutputZip - archiver error: ${ err ?. message || err } ` )
106- reject ( err )
107- } )
108-
109- archive . pipe ( output )
110- // Add the entire directory to the archive with basename as root
111- archive . directory ( outputDir , path . basename ( outputDir ) )
112- archive . finalize ( )
113- } )
114- } catch ( execErr : any ) {
115- // eslint-disable-next-line no-console
116- console . error ( `finetuningService.ensureFineTuningOutputZip - archiver failed for job ${ jobId } : ${ execErr ?. message || execErr } ` )
117- return null
118- }
119- } catch ( error : any ) {
120- // eslint-disable-next-line no-console
121- console . error ( `finetuningService.ensureFineTuningOutputZip - error: ${ error ?. message || error } ` )
122- return null
123- }
124- }
125-
12643/**
12744 * Upload a training file to the finetuning service
12845 */
@@ -783,12 +700,12 @@ const deleteFineTuningJob = async (fineTuningJobId: string) => {
783700}
784701
785702/**
786- * Download fine-tuning job output as a zip file
787- * Creates zip if needed, or returns existing zip immediately
703+ * Prepare fine-tuning job output as a zip file for download
704+ * Called by WebSocket to create and cache the zip
788705 * @param jobId - ID of the fine-tuning job
789706 * @returns Path to the zipped file or null if not found
790707 */
791- const downloadFineTuningOutput = async ( jobId : string ) : Promise < string | null > => {
708+ const prepareFineTuningOutputZip = async ( jobId : string ) : Promise < string | null > => {
792709 try {
793710 if ( ! jobId ) {
794711 throw new InternalFlowiseError ( StatusCodes . BAD_REQUEST , 'Job ID is required' )
@@ -816,18 +733,82 @@ const downloadFineTuningOutput = async (jobId: string): Promise<string | null> =
816733 throw new InternalFlowiseError ( StatusCodes . FORBIDDEN , 'Invalid job output path' )
817734 }
818735
819- // Ensure the output is zipped (returns immediately if zip is up-to-date)
820- const finalZipPath = await ensureFineTuningOutputZip ( jobOutputDir , jobId )
821- if ( ! finalZipPath ) {
822- throw new InternalFlowiseError (
823- StatusCodes . INTERNAL_SERVER_ERROR ,
824- `Failed to create zip for job ${ jobId } `
825- )
826- }
736+ const zipFilePath = `${ jobOutputDir } .zip`
827737
738+ // Create zip file using archiver
828739 // eslint-disable-next-line no-console
829- console . debug ( `finetuningService.downloadFineTuningOutput - file ready for download: ${ finalZipPath } ` )
830- return finalZipPath
740+ console . debug ( `finetuningService.downloadFineTuningOutput - creating zip for job ${ jobId } ` )
741+
742+ // Log directory contents for diagnostics
743+ try {
744+ const dirContents = fs . readdirSync ( jobOutputDir )
745+ // eslint-disable-next-line no-console
746+ console . debug ( `finetuningService.downloadFineTuningOutput - output directory contains ${ dirContents . length } items: ${ dirContents . slice ( 0 , 10 ) . join ( ', ' ) } ${ dirContents . length > 10 ? '...' : '' } ` )
747+ } catch ( e ) {
748+ // eslint-disable-next-line no-console
749+ console . warn ( `finetuningService.downloadFineTuningOutput - could not list directory: ${ e } ` )
750+ }
751+
752+ try {
753+ return await new Promise ( ( resolve , reject ) => {
754+ const output = fs . createWriteStream ( zipFilePath )
755+ const archive = archiver ( 'zip' , {
756+ zlib : { level : 0 } // no compression for speed
757+ } )
758+
759+ const zipTimeoutMs = 30 * 60 * 1000 // 30 minutes
760+ let resolved = false
761+
762+ const timeoutHandle = setTimeout ( ( ) => {
763+ if ( ! resolved ) {
764+ resolved = true
765+ // eslint-disable-next-line no-console
766+ console . error ( `finetuningService.downloadFineTuningOutput - archiver timeout for job ${ jobId } ` )
767+ try { output . destroy ( ) } catch ( e ) { }
768+ try { archive . destroy ( ) } catch ( e ) { }
769+ reject ( new Error ( 'Archiver timeout' ) )
770+ }
771+ } , zipTimeoutMs )
772+
773+ output . on ( 'close' , ( ) => {
774+ if ( ! resolved ) {
775+ resolved = true
776+ clearTimeout ( timeoutHandle )
777+ // eslint-disable-next-line no-console
778+ console . debug ( `finetuningService.downloadFineTuningOutput - zip created: ${ zipFilePath } ` )
779+ resolve ( zipFilePath )
780+ }
781+ } )
782+
783+ output . on ( 'error' , ( err : any ) => {
784+ if ( ! resolved ) {
785+ resolved = true
786+ clearTimeout ( timeoutHandle )
787+ // eslint-disable-next-line no-console
788+ console . error ( `finetuningService.downloadFineTuningOutput - write stream error: ${ err ?. message || err } ` )
789+ reject ( err )
790+ }
791+ } )
792+
793+ archive . on ( 'error' , ( err : any ) => {
794+ if ( ! resolved ) {
795+ resolved = true
796+ clearTimeout ( timeoutHandle )
797+ // eslint-disable-next-line no-console
798+ console . error ( `finetuningService.downloadFineTuningOutput - archiver error: ${ err ?. message || err } ` )
799+ reject ( err )
800+ }
801+ } )
802+
803+ archive . pipe ( output )
804+ archive . directory ( jobOutputDir , path . basename ( jobOutputDir ) )
805+ archive . finalize ( )
806+ } )
807+ } catch ( archiverErr : any ) {
808+ // eslint-disable-next-line no-console
809+ console . error ( `finetuningService.downloadFineTuningOutput - archiver failed for job ${ jobId } : ${ archiverErr ?. message || archiverErr } ` )
810+ return null
811+ }
831812 } catch ( error : any ) {
832813 if ( error instanceof InternalFlowiseError ) {
833814 throw error
@@ -841,6 +822,46 @@ const downloadFineTuningOutput = async (jobId: string): Promise<string | null> =
841822 }
842823}
843824
825+ /**
826+ * Download fine-tuning job output - HTTP endpoint
827+ * Returns path to cached ZIP file
828+ * @param jobId - ID of the fine-tuning job
829+ * @returns Path to the zipped file or null if not found
830+ */
831+ const downloadFineTuningOutput = async ( jobId : string ) : Promise < string | null > => {
832+ try {
833+ if ( ! jobId ) {
834+ return null
835+ }
836+
837+ const OUTPUT_BASE_DIR = '/tmp/finetuning/output'
838+ const zipFilePath = `${ OUTPUT_BASE_DIR } /${ jobId } .zip`
839+
840+ // Check if zip file exists
841+ if ( fs . existsSync ( zipFilePath ) ) {
842+ try {
843+ const stat = fs . statSync ( zipFilePath )
844+ if ( stat . size > 0 ) {
845+ // eslint-disable-next-line no-console
846+ console . debug ( `finetuningService.downloadFineTuningOutput - returning cached zip: ${ zipFilePath } ` )
847+ return zipFilePath
848+ }
849+ } catch ( e ) {
850+ // eslint-disable-next-line no-console
851+ console . warn ( `finetuningService.downloadFineTuningOutput - could not stat zip file: ${ e } ` )
852+ }
853+ }
854+
855+ // eslint-disable-next-line no-console
856+ console . warn ( `finetuningService.downloadFineTuningOutput - zip file not found: ${ zipFilePath } ` )
857+ return null
858+ } catch ( error : any ) {
859+ // eslint-disable-next-line no-console
860+ console . error ( `finetuningService.downloadFineTuningOutput - error: ${ error ?. message || error } ` )
861+ return null
862+ }
863+ }
864+
844865/**
845866 * Get logs for a fine-tuning job by querying the Ray head node HTTP API.
846867 * It will call: http://<RAY_HEAD_NODE>/api/jobs/<job_id>/logs
@@ -929,5 +950,6 @@ export default {
929950 cancelFineTuningJob,
930951 deleteFineTuningJob,
931952 getFineTuningJobLogs,
953+ prepareFineTuningOutputZip,
932954 downloadFineTuningOutput
933955}
0 commit comments