Skip to main content

runmat_execution_artifact/bundle/
builder.rs

1use std::path::Path;
2
3use runmat_execution::{Digest, ProgramRevision};
4use runmat_package::{FrozenProject, FrozenProjectHandoff};
5
6use crate::bundle::{
7    BuildResourceDeclaration, BundleCallable, BundleCodeClosure, BundleManifest,
8    CompiledPackageClosure, ExecutionBundle, ProjectRevisionRecord,
9    EXECUTION_BUNDLE_SCHEMA_VERSION,
10};
11use crate::{
12    ArtifactError, ArtifactResult, ExecutableForm, LogicalObject, ObjectNamespace, ProgramArtifact,
13    ProgramBuildRecipe,
14};
15
16struct Materialization {
17    recipe: ProgramBuildRecipe,
18    form: ExecutableForm,
19    executable_bytes: Vec<u8>,
20}
21
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23enum CodeClosureMode {
24    SourceProject,
25    Compiled,
26}
27
28pub trait SourceReader {
29    fn read(&self, path: &Path) -> ArtifactResult<Vec<u8>>;
30}
31
32impl<F> SourceReader for F
33where
34    F: Fn(&Path) -> ArtifactResult<Vec<u8>>,
35{
36    fn read(&self, path: &Path) -> ArtifactResult<Vec<u8>> {
37        self(path)
38    }
39}
40
41pub struct ExecutionBundleBuilder<'a, R> {
42    project: &'a FrozenProject,
43    revision: ProgramRevision,
44    reader: R,
45    recipes: Vec<ProgramBuildRecipe>,
46    materializations: Vec<Materialization>,
47    resources: BuildResourceDeclaration,
48    code_closure: CodeClosureMode,
49}
50
51impl<'a, R: SourceReader> ExecutionBundleBuilder<'a, R> {
52    pub fn new(
53        project: &'a FrozenProject,
54        revision: ProgramRevision,
55        reader: R,
56    ) -> ArtifactResult<Self> {
57        let project_revision = project.revision();
58        let exact_project_sources =
59            revision.source_digest().bytes() == project_revision.source_revision.bytes();
60        let exact_test_overlay = revision.domain_contribution("runmat.test.config").is_some();
61        if revision.graph_digest().bytes() != project_revision.graph_digest.bytes()
62            || (!exact_project_sources && !exact_test_overlay)
63        {
64            return Err(ArtifactError::Identity(
65                "program and frozen-project revisions differ without an exact test overlay".into(),
66            ));
67        }
68        Ok(Self {
69            project,
70            revision,
71            reader,
72            recipes: Vec::new(),
73            materializations: Vec::new(),
74            resources: BuildResourceDeclaration {
75                cpu_millicores: 1000,
76                memory_bytes: 1024 * 1024 * 1024,
77                scratch_bytes: 1024 * 1024 * 1024,
78            },
79            code_closure: CodeClosureMode::SourceProject,
80        })
81    }
82
83    pub fn with_recipe(mut self, recipe: ProgramBuildRecipe) -> Self {
84        self.recipes.push(recipe);
85        self
86    }
87
88    pub fn with_materialized_program(
89        mut self,
90        recipe: ProgramBuildRecipe,
91        form: ExecutableForm,
92        executable_bytes: Vec<u8>,
93    ) -> Self {
94        self.materializations.push(Materialization {
95            recipe,
96            form,
97            executable_bytes,
98        });
99        self
100    }
101
102    pub fn with_resources(mut self, resources: BuildResourceDeclaration) -> Self {
103        self.resources = resources;
104        self
105    }
106
107    /// Package an already compiled program without source files or a project
108    /// handoff. The immutable program revision and sorted package identities
109    /// remain in the bundle closure, so workers can validate the exact graph
110    /// without resolving or materializing it.
111    pub fn with_compiled_package_closure(mut self) -> Self {
112        self.code_closure = CodeClosureMode::Compiled;
113        self
114    }
115
116    pub fn build(mut self) -> ArtifactResult<ExecutionBundle> {
117        if self.code_closure == CodeClosureMode::Compiled
118            && (self.materializations.is_empty()
119                || self.materializations.iter().any(|materialization| {
120                    !matches!(
121                        materialization.form,
122                        ExecutableForm::ExecutableUnitV3
123                            | ExecutableForm::NativeObjectV1
124                            | ExecutableForm::MeshingWorkload
125                    )
126                }))
127        {
128            return Err(ArtifactError::Invalid(
129                "compiled package closure requires a compiled executable-unit or native-object artifact"
130                    .into(),
131            ));
132        }
133        let mut objects = Vec::new();
134        let mut callables = Vec::new();
135        let code_closure = match self.code_closure {
136            CodeClosureMode::SourceProject => {
137                let mut project_handoff = FrozenProjectHandoff::new(self.project.clone());
138                project_handoff.project.manifest_path = "runmat.toml".into();
139                project_handoff.project.workspace_root = ".".into();
140                for package in self.project.sources.packages.values() {
141                    for source in &package.sources {
142                        let path = self.project.access_paths.get(&source.id).ok_or_else(|| {
143                            ArtifactError::Invalid(format!(
144                                "source {} has no frozen access path",
145                                source.id.relative_path
146                            ))
147                        })?;
148                        let bytes = self.reader.read(path)?;
149                        if source.id.content_digest.bytes() != Digest::sha256(&bytes).bytes() {
150                            return Err(ArtifactError::Identity(format!(
151                                "source {} changed after project freeze",
152                                source.id.relative_path
153                            )));
154                        }
155                        let logical_name =
156                            format!("{}/{}", package.mount.logical_root, source.id.relative_path);
157                        project_handoff
158                            .project
159                            .access_paths
160                            .insert(source.id.clone(), logical_name.clone().into());
161                        objects.push(LogicalObject::new(
162                            ObjectNamespace::ProgramSource,
163                            logical_name,
164                            "text/x-matlab",
165                            bytes,
166                        )?);
167                        callables.push(BundleCallable {
168                            owner_identity: package.package_instance.to_string(),
169                            qualified_name: source.qualified_name.clone(),
170                            source_digest: Digest::from_bytes(*source.id.content_digest.bytes()),
171                        });
172                    }
173                }
174                BundleCodeClosure::SourceProject {
175                    handoff: project_handoff,
176                }
177            }
178            CodeClosureMode::Compiled => {
179                let mut package_instances = self
180                    .project
181                    .graph
182                    .packages
183                    .values()
184                    .map(|package| package.instance.to_string())
185                    .collect::<Vec<_>>();
186                package_instances.sort();
187                package_instances.dedup();
188                BundleCodeClosure::Compiled {
189                    package: CompiledPackageClosure {
190                        schema_version: CompiledPackageClosure::SCHEMA_VERSION,
191                        graph_digest: Digest::from_bytes(*self.project.graph.graph_digest.bytes()),
192                        source_digest: Digest::from_bytes(*self.project.sources.revision.bytes()),
193                        package_instances,
194                    },
195                }
196            }
197        };
198        objects.sort_by(|left, right| left.descriptor.cmp(&right.descriptor));
199        callables.sort();
200        let source_descriptors = objects
201            .iter()
202            .map(|object| object.descriptor.clone())
203            .collect::<Vec<_>>();
204        for materialization in &mut self.materializations {
205            attach_source_closure(
206                &mut materialization.recipe,
207                &self.revision,
208                &source_descriptors,
209            )?;
210        }
211        for recipe in &mut self.recipes {
212            attach_source_closure(recipe, &self.revision, &source_descriptors)?;
213        }
214        let mut artifacts = Vec::with_capacity(self.materializations.len());
215        for materialization in self.materializations {
216            let artifact = ProgramArtifact::materialize(
217                &materialization.recipe,
218                materialization.form,
219                materialization.executable_bytes,
220            )?;
221            self.recipes.push(materialization.recipe);
222            artifacts.push(artifact);
223        }
224        let mut keyed_recipes = self
225            .recipes
226            .into_iter()
227            .map(|recipe| Ok((recipe.id()?, recipe)))
228            .collect::<ArtifactResult<Vec<_>>>()?;
229        keyed_recipes.sort_by_key(|(id, _)| *id);
230        let recipes = keyed_recipes
231            .into_iter()
232            .map(|(_, recipe)| recipe)
233            .collect();
234        artifacts.sort_by_key(|artifact| artifact.id);
235        let project_revision = self.project.revision();
236        let manifest = BundleManifest {
237            schema_version: EXECUTION_BUNDLE_SCHEMA_VERSION,
238            program_revision: self.revision,
239            project_revision: ProjectRevisionRecord {
240                graph_digest: Digest::from_bytes(*project_revision.graph_digest.bytes()),
241                source_digest: Digest::from_bytes(*project_revision.source_revision.bytes()),
242            },
243            code_closure,
244            sources: source_descriptors,
245            callables,
246            recipes,
247            artifacts,
248            requested_capabilities: Default::default(),
249            resources: self.resources,
250            portable_environment: Vec::new(),
251        };
252        let bundle = ExecutionBundle { manifest, objects };
253        bundle.validate()?;
254        Ok(bundle)
255    }
256}
257
258fn attach_source_closure(
259    recipe: &mut ProgramBuildRecipe,
260    revision: &ProgramRevision,
261    source_descriptors: &[crate::ObjectDescriptor],
262) -> ArtifactResult<()> {
263    if &recipe.program_revision != revision {
264        return Err(ArtifactError::Identity(
265            "program recipe revision differs from the bundle".into(),
266        ));
267    }
268    if recipe.source_objects.is_empty() {
269        recipe.source_objects = source_descriptors.to_vec();
270    } else if recipe.source_objects != source_descriptors {
271        return Err(ArtifactError::Identity(
272            "program recipe source closure differs from the frozen project".into(),
273        ));
274    }
275    Ok(())
276}
277
278#[cfg(not(target_arch = "wasm32"))]
279impl<'a> ExecutionBundleBuilder<'a, fn(&Path) -> ArtifactResult<Vec<u8>>> {
280    pub fn native(project: &'a FrozenProject, revision: ProgramRevision) -> ArtifactResult<Self> {
281        Self::new(project, revision, native_read)
282    }
283}
284
285#[cfg(not(target_arch = "wasm32"))]
286fn native_read(path: &Path) -> ArtifactResult<Vec<u8>> {
287    std::fs::read(path).map_err(Into::into)
288}