11import { BuildManifest } from "@trigger.dev/core/v3/schemas" ;
2- import { cp } from "node:fs/promises" ;
2+ import { cp , link , mkdir , readdir , readFile } from "node:fs/promises" ;
3+ import { createHash } from "node:crypto" ;
4+ import { existsSync } from "node:fs" ;
5+ import { join } from "node:path" ;
36import { logger } from "../utilities/logger.js" ;
7+ import { sanitizeHashForFilename } from "../utilities/fileSystem.js" ;
48
59export async function copyManifestToDir (
610 manifest : BuildManifest ,
711 source : string ,
8- destination : string
12+ destination : string ,
13+ storeDir ?: string
914) : Promise < BuildManifest > {
10- // Copy the dir in destination to workerDir
11- await cp ( source , destination , { recursive : true } ) ;
15+ // Copy the dir from source to destination
16+ // If storeDir is provided, create hardlinks for files that exist in the store
17+ if ( storeDir ) {
18+ await copyDirWithStore ( source , destination , storeDir , manifest . outputHashes ) ;
19+ } else {
20+ await cp ( source , destination , { recursive : true } ) ;
21+ }
1222
13- logger . debug ( "Copied manifest to dir" , { source, destination } ) ;
23+ logger . debug ( "Copied manifest to dir" , { source, destination, storeDir } ) ;
1424
1525 // Then update the manifest to point to the new workerDir
1626 const updatedManifest = { ...manifest } ;
@@ -37,3 +47,68 @@ export async function copyManifestToDir(
3747
3848 return updatedManifest ;
3949}
50+
51+ /**
52+ * Computes a hash of file contents to use as content-addressable key.
53+ * This is a fallback for when outputHashes is not available.
54+ */
55+ async function computeFileHash ( filePath : string ) : Promise < string > {
56+ const contents = await readFile ( filePath ) ;
57+ return createHash ( "sha256" ) . update ( contents ) . digest ( "hex" ) . slice ( 0 , 16 ) ;
58+ }
59+
60+ /**
61+ * Recursively copies a directory, using hardlinks for files that exist in the store.
62+ * This preserves disk space savings from the content-addressable store.
63+ *
64+ * @param source - Source directory path
65+ * @param destination - Destination directory path
66+ * @param storeDir - Content-addressable store directory
67+ * @param outputHashes - Optional map of file paths to their content hashes (from BuildManifest)
68+ */
69+ async function copyDirWithStore (
70+ source : string ,
71+ destination : string ,
72+ storeDir : string ,
73+ outputHashes ?: Record < string , string >
74+ ) : Promise < void > {
75+ await mkdir ( destination , { recursive : true } ) ;
76+
77+ const entries = await readdir ( source , { withFileTypes : true } ) ;
78+
79+ for ( const entry of entries ) {
80+ const sourcePath = join ( source , entry . name ) ;
81+ const destPath = join ( destination , entry . name ) ;
82+
83+ if ( entry . isDirectory ( ) ) {
84+ // Recursively copy subdirectories
85+ await copyDirWithStore ( sourcePath , destPath , storeDir , outputHashes ) ;
86+ } else if ( entry . isFile ( ) ) {
87+ // Try to get hash from manifest first, otherwise compute it
88+ const contentHash = outputHashes ?. [ sourcePath ] ?? ( await computeFileHash ( sourcePath ) ) ;
89+ // Sanitize hash to be filesystem-safe (base64 can contain / and +)
90+ const safeHash = sanitizeHashForFilename ( contentHash ) ;
91+ const storePath = join ( storeDir , safeHash ) ;
92+
93+ if ( existsSync ( storePath ) ) {
94+ // Create hardlink to store file
95+ // Fall back to copy if hardlink fails (e.g., on Windows or cross-device)
96+ try {
97+ await link ( storePath , destPath ) ;
98+ } catch ( linkError ) {
99+ try {
100+ await cp ( storePath , destPath ) ;
101+ } catch ( copyError ) {
102+ throw linkError ; // Rethrow original error if copy also fails
103+ }
104+ }
105+ } else {
106+ // File wasn't in the store - copy normally
107+ await cp ( sourcePath , destPath ) ;
108+ }
109+ } else if ( entry . isSymbolicLink ( ) ) {
110+ // Preserve symbolic links (e.g., node_modules links)
111+ await cp ( sourcePath , destPath , { verbatimSymlinks : true } ) ;
112+ }
113+ }
114+ }
0 commit comments