1use serde::{Deserialize, Serialize};
9use serde_json::Value as JsonValue;
10
11use crate::runtime_language::RuntimeLanguage;
12
13#[derive(Clone, Debug, Serialize, Deserialize)]
15pub struct InvocationDescriptor {
16 entrypoint: String,
17 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub language: Option<RuntimeLanguage>,
20 #[serde(default)]
22 pub inputs: Vec<FieldDescriptor>,
23 #[serde(default)]
25 pub outputs: Vec<FieldDescriptor>,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub params: Option<JsonValue>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub window: Option<WindowConfig>,
32 #[serde(default)]
34 pub limits: InvocationLimits,
35}
36
37impl InvocationDescriptor {
38 pub fn new(entrypoint: impl Into<String>) -> Self {
40 let entrypoint = entrypoint.into();
41 Self {
42 entrypoint: sanitize_entrypoint(entrypoint),
43 language: None,
44 inputs: Vec::new(),
45 outputs: Vec::new(),
46 params: None,
47 window: None,
48 limits: InvocationLimits::default(),
49 }
50 }
51
52 pub fn trivial(entrypoint: impl Into<String>) -> Self {
54 Self::new(entrypoint)
55 }
56
57 pub fn entrypoint(&self) -> &str {
59 &self.entrypoint
60 }
61
62 pub fn with_limits(mut self, limits: InvocationLimits) -> Self {
64 self.limits = limits;
65 self
66 }
67
68 pub fn validate(&self) -> Result<(), DescriptorError> {
70 if self.entrypoint.trim().is_empty() {
71 return Err(DescriptorError::InvalidEntrypoint);
72 }
73 for field in self.inputs.iter().chain(self.outputs.iter()) {
74 field.validate()?;
75 }
76 self.limits.validate()?;
77 if let Some(window) = &self.window {
78 window.validate()?;
79 }
80 Ok(())
81 }
82}
83
84#[derive(Clone, Debug, Serialize, Deserialize)]
86pub struct FieldDescriptor {
87 pub name: String,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub type_tag: Option<String>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub metadata: Option<JsonValue>,
95}
96
97impl FieldDescriptor {
98 fn validate(&self) -> Result<(), DescriptorError> {
99 if self.name.trim().is_empty() {
100 return Err(DescriptorError::InvalidFieldName);
101 }
102 Ok(())
103 }
104}
105
106#[derive(Clone, Debug, Default, Serialize, Deserialize)]
108pub struct WindowConfig {
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub size: Option<u64>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub step: Option<u64>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub stride: Option<u64>,
118}
119
120impl WindowConfig {
121 fn validate(&self) -> Result<(), DescriptorError> {
122 for value in [self.size, self.step, self.stride].into_iter().flatten() {
123 if value == 0 {
124 return Err(DescriptorError::InvalidWindowConfig);
125 }
126 }
127 Ok(())
128 }
129}
130
131#[derive(Clone, Debug, Default, Serialize, Deserialize)]
133pub struct InvocationLimits {
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub wall_ms: Option<u64>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub heap_mb: Option<u64>,
140 #[serde(default, skip_serializing_if = "Option::is_none", alias = "cpu_fuel")]
142 pub cpu_ms: Option<u64>,
143}
144
145impl InvocationLimits {
146 fn validate(&self) -> Result<(), DescriptorError> {
147 if let Some(0) = self.wall_ms {
148 return Err(DescriptorError::InvalidLimits);
149 }
150 if let Some(0) = self.heap_mb {
151 return Err(DescriptorError::InvalidLimits);
152 }
153 if let Some(0) = self.cpu_ms {
154 return Err(DescriptorError::InvalidLimits);
155 }
156 Ok(())
157 }
158
159 pub fn merged_with(&self, override_limits: Option<&InvocationLimits>) -> InvocationLimits {
161 fn merge(primary: Option<u64>, override_value: Option<u64>) -> Option<u64> {
162 match (primary, override_value) {
163 (Some(a), Some(b)) => Some(a.min(b)),
164 (Some(a), None) => Some(a),
165 (None, Some(b)) => Some(b),
166 (None, None) => None,
167 }
168 }
169
170 if let Some(override_limits) = override_limits {
171 InvocationLimits {
172 wall_ms: merge(self.wall_ms, override_limits.wall_ms),
173 heap_mb: merge(self.heap_mb, override_limits.heap_mb),
174 cpu_ms: merge(self.cpu_ms, override_limits.cpu_ms),
175 }
176 } else {
177 self.clone()
178 }
179 }
180}
181
182#[derive(Debug, thiserror::Error)]
184pub enum DescriptorError {
185 #[error("descriptor entrypoint cannot be empty")]
186 InvalidEntrypoint,
187 #[error("descriptor field name cannot be empty")]
188 InvalidFieldName,
189 #[error("descriptor window configuration cannot contain zero values")]
190 InvalidWindowConfig,
191 #[error("descriptor limits must be positive when specified")]
192 InvalidLimits,
193}
194
195fn sanitize_entrypoint(entrypoint: String) -> String {
196 entrypoint.trim().to_owned()
197}
198
199#[cfg(test)]
200mod tests {
201 use super::{FieldDescriptor, InvocationDescriptor, InvocationLimits};
202
203 #[test]
204 fn trivial_descriptor_sanitizes_entrypoint() {
205 let descriptor = InvocationDescriptor::trivial(" module:func ");
206 assert_eq!(descriptor.entrypoint(), "module:func");
207 assert!(descriptor.validate().is_ok());
208 }
209
210 #[test]
211 fn validate_rejects_empty_entrypoint() {
212 let descriptor = InvocationDescriptor::trivial(" ");
213 assert!(descriptor.validate().is_err());
214 }
215
216 #[test]
217 fn validate_accepts_multiple_outputs() {
218 let mut descriptor = InvocationDescriptor::trivial("pkg:handler");
219 descriptor.outputs.push(FieldDescriptor {
220 name: "first".into(),
221 type_tag: None,
222 metadata: None,
223 });
224 descriptor.outputs.push(FieldDescriptor {
225 name: "second".into(),
226 type_tag: None,
227 metadata: None,
228 });
229 assert!(descriptor.validate().is_ok());
230 }
231
232 #[test]
233 fn limits_merge_prefers_tighter_budget() {
234 let base = InvocationLimits {
235 wall_ms: Some(2_000),
236 heap_mb: Some(512),
237 cpu_ms: None,
238 };
239 let override_limits = InvocationLimits {
240 wall_ms: Some(1_000),
241 heap_mb: Some(1_024),
242 cpu_ms: Some(10_000),
243 };
244 let merged = base.merged_with(Some(&override_limits));
245 assert_eq!(merged.wall_ms, Some(1_000));
246 assert_eq!(merged.heap_mb, Some(512));
247 assert_eq!(merged.cpu_ms, Some(10_000));
248 }
249}