Skip to main content

lash_lashlang_runtime/
lib.rs

1use std::sync::{Arc, Mutex};
2
3use sha2::{Digest, Sha256};
4
5#[cfg(feature = "testing")]
6pub mod testing;
7
8pub use lash_trace::{
9    TraceLashlangChildExecution, TraceLashlangEdgeSelection, TraceLashlangExecutionEvent,
10    TraceLashlangExecutionIdentity, TraceLashlangGraph, TraceLashlangGraphChildLink,
11    TraceLashlangGraphEdge, TraceLashlangGraphNode, TraceLashlangGraphStore, TraceLashlangMap,
12    TraceLashlangMapEdge, TraceLashlangMapNode, TraceLashlangNodeStatus, TraceLashlangStatus,
13};
14pub use lashlang::{
15    CompiledProcessCache, InMemoryLashlangArtifactStore, LASH_TYPE_KEY, LashlangAbilities,
16    LashlangArtifactStore, LashlangHostCatalog, LashlangHostEnvironment, LashlangLanguageFeatures,
17};
18
19/// Map the lashlang language crate's durability tier onto the runtime's
20/// [`lash_core::DurabilityTier`]. The two enums are parallel; this is the single
21/// bridge point between them (the parallel `LashlangDurabilityTier` alias is
22/// gone — process engines self-describe their tier via `lash_core::DurabilityTier`).
23pub fn lashlang_durability_tier(tier: lashlang::DurabilityTier) -> lash_core::DurabilityTier {
24    match tier {
25        lashlang::DurabilityTier::Inline => lash_core::DurabilityTier::Inline,
26        lashlang::DurabilityTier::Durable => lash_core::DurabilityTier::Durable,
27    }
28}
29
30pub const LASHLANG_ENGINE_KIND: &str = "lashlang";
31pub const LASHLANG_TOOL_BINDING_KEY: &str = "lashlang.tool";
32pub const LASHLANG_SURFACE_EXTENSION_ID: &str = "lashlang.surface";
33
34#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
35#[serde(default)]
36pub struct LashlangSurfaceContribution {
37    pub abilities: LashlangAbilities,
38    pub language_features: LashlangLanguageFeatures,
39    pub resources: LashlangHostCatalog,
40}
41
42impl LashlangSurfaceContribution {
43    pub fn new(
44        abilities: LashlangAbilities,
45        language_features: LashlangLanguageFeatures,
46        resources: LashlangHostCatalog,
47    ) -> Self {
48        Self {
49            abilities,
50            language_features,
51            resources,
52        }
53    }
54
55    pub fn from_surface(surface: LashlangSurface) -> Self {
56        Self {
57            abilities: surface.abilities,
58            language_features: surface.language_features,
59            resources: surface.resources,
60        }
61    }
62}
63
64#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
65pub struct LashlangToolBinding {
66    #[serde(default, skip_serializing_if = "Vec::is_empty")]
67    pub module_path: Vec<String>,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub operation: Option<String>,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub authority_type: Option<String>,
72    #[serde(default, skip_serializing_if = "Vec::is_empty")]
73    pub aliases: Vec<String>,
74}
75
76impl LashlangToolBinding {
77    pub fn new(
78        module_path: impl IntoIterator<Item = impl Into<String>>,
79        operation: impl Into<String>,
80    ) -> Self {
81        Self {
82            module_path: module_path.into_iter().map(Into::into).collect(),
83            operation: Some(operation.into()),
84            authority_type: None,
85            aliases: Vec::new(),
86        }
87    }
88
89    pub fn with_authority_type(mut self, authority_type: impl Into<String>) -> Self {
90        self.authority_type = Some(authority_type.into());
91        self
92    }
93
94    pub fn with_aliases(mut self, aliases: impl IntoIterator<Item = impl Into<String>>) -> Self {
95        self.aliases = aliases.into_iter().map(Into::into).collect();
96        self
97    }
98
99    pub fn executable_for(&self, tool_name: &str) -> Result<ResolvedLashlangToolBinding, String> {
100        if self.module_path.is_empty() {
101            return Err(format!(
102                "tool `{tool_name}` is missing an explicit Lashlang module path"
103            ));
104        }
105        for segment in &self.module_path {
106            validate_lashlang_identifier(tool_name, "module path segment", segment)?;
107        }
108        let operation = self
109            .operation
110            .as_deref()
111            .filter(|operation| !operation.trim().is_empty())
112            .ok_or_else(|| {
113                format!("tool `{tool_name}` is missing an explicit Lashlang operation name")
114            })?;
115        validate_lashlang_identifier(tool_name, "operation name", operation)?;
116        let authority_type = self
117            .authority_type
118            .as_deref()
119            .filter(|authority_type| !authority_type.trim().is_empty())
120            .map(ToOwned::to_owned)
121            .unwrap_or_else(|| default_authority_type(&self.module_path));
122        Ok(ResolvedLashlangToolBinding {
123            module_path: self.module_path.clone(),
124            operation: operation.to_string(),
125            authority_type,
126            aliases: self.aliases.clone(),
127        })
128    }
129
130    pub fn required_for_remote(
131        manifest: &lash_core::ToolManifest,
132    ) -> Result<ResolvedLashlangToolBinding, String> {
133        required_tool_lashlang_executable(manifest)
134    }
135
136    pub fn required_executable_for_remote(
137        &self,
138        tool_name: &str,
139    ) -> Result<ResolvedLashlangToolBinding, String> {
140        self.executable_for(tool_name)
141    }
142}
143
144#[derive(Clone, Debug, PartialEq, Eq)]
145pub struct ResolvedLashlangToolBinding {
146    pub module_path: Vec<String>,
147    pub operation: String,
148    pub authority_type: String,
149    pub aliases: Vec<String>,
150}
151
152impl ResolvedLashlangToolBinding {
153    pub fn module_path_string(&self) -> String {
154        self.module_path.join(".")
155    }
156
157    pub fn call_path(&self) -> String {
158        format!("{}.{}", self.module_path_string(), self.operation)
159    }
160}
161
162fn default_authority_type(module_path: &[String]) -> String {
163    module_path
164        .last()
165        .map(|segment| {
166            let mut chars = segment.chars();
167            match chars.next() {
168                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
169                None => "Tool".to_string(),
170            }
171        })
172        .unwrap_or_else(|| "Tool".to_string())
173}
174
175fn validate_lashlang_identifier(tool_name: &str, label: &str, value: &str) -> Result<(), String> {
176    let value = value.trim();
177    let mut chars = value.chars();
178    let Some(first) = chars.next() else {
179        return Err(format!("tool `{tool_name}` has an empty Lashlang {label}"));
180    };
181    if !(first == '_' || first.is_ascii_alphabetic()) {
182        return Err(format!(
183            "tool `{tool_name}` has invalid Lashlang {label} `{value}`"
184        ));
185    }
186    if !chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {
187        return Err(format!(
188            "tool `{tool_name}` has invalid Lashlang {label} `{value}`"
189        ));
190    }
191    Ok(())
192}
193
194pub fn tool_lashlang_binding(
195    manifest: &lash_core::ToolManifest,
196) -> Result<Option<LashlangToolBinding>, String> {
197    manifest
198        .bindings
199        .get(LASHLANG_TOOL_BINDING_KEY)
200        .cloned()
201        .map(serde_json::from_value)
202        .transpose()
203        .map_err(|err| {
204            format!(
205                "tool `{}` has invalid `{LASHLANG_TOOL_BINDING_KEY}` binding: {err}",
206                manifest.name
207            )
208        })
209}
210
211pub fn required_tool_lashlang_binding(
212    manifest: &lash_core::ToolManifest,
213) -> Result<LashlangToolBinding, String> {
214    tool_lashlang_binding(manifest)?.ok_or_else(|| {
215        format!(
216            "tool `{}` is missing an explicit `{LASHLANG_TOOL_BINDING_KEY}` binding",
217            manifest.name
218        )
219    })
220}
221
222pub fn required_tool_lashlang_executable(
223    manifest: &lash_core::ToolManifest,
224) -> Result<ResolvedLashlangToolBinding, String> {
225    required_tool_lashlang_binding(manifest)?.executable_for(&manifest.name)
226}
227
228pub trait ToolManifestLashlangExt {
229    fn lashlang_binding(&self) -> Result<Option<LashlangToolBinding>, serde_json::Error>;
230}
231
232impl ToolManifestLashlangExt for lash_core::ToolManifest {
233    fn lashlang_binding(&self) -> Result<Option<LashlangToolBinding>, serde_json::Error> {
234        self.bindings
235            .get(LASHLANG_TOOL_BINDING_KEY)
236            .cloned()
237            .map(serde_json::from_value)
238            .transpose()
239    }
240}
241
242pub trait ToolDefinitionLashlangExt {
243    fn with_lashlang_binding(self, lashlang_binding: LashlangToolBinding) -> Self;
244}
245
246impl ToolDefinitionLashlangExt for lash_core::ToolDefinition {
247    fn with_lashlang_binding(mut self, lashlang_binding: LashlangToolBinding) -> Self {
248        let value = serde_json::to_value(lashlang_binding)
249            .expect("lashlang tool binding must serialize to JSON");
250        self.manifest
251            .bindings
252            .insert(LASHLANG_TOOL_BINDING_KEY.to_string(), value);
253        self
254    }
255}
256
257pub trait RemoteToolGrantLashlangExt {
258    fn with_lashlang_binding(self, lashlang_binding: LashlangToolBinding) -> Self;
259    fn lashlang_binding(&self) -> Result<Option<LashlangToolBinding>, serde_json::Error>;
260}
261
262impl RemoteToolGrantLashlangExt for lash_remote_protocol::RemoteToolGrant {
263    fn with_lashlang_binding(mut self, lashlang_binding: LashlangToolBinding) -> Self {
264        let value = serde_json::to_value(lashlang_binding)
265            .expect("lashlang tool binding must serialize to JSON");
266        self.bindings
267            .insert(LASHLANG_TOOL_BINDING_KEY.to_string(), value);
268        self
269    }
270
271    fn lashlang_binding(&self) -> Result<Option<LashlangToolBinding>, serde_json::Error> {
272        self.bindings
273            .get(LASHLANG_TOOL_BINDING_KEY)
274            .cloned()
275            .map(serde_json::from_value)
276            .transpose()
277    }
278}
279
280#[derive(Clone, Debug)]
281pub struct LashlangSurface {
282    pub abilities: LashlangAbilities,
283    pub language_features: LashlangLanguageFeatures,
284    pub resources: LashlangHostCatalog,
285}
286
287impl Default for LashlangSurface {
288    fn default() -> Self {
289        Self {
290            abilities: LashlangAbilities::default().with_sleep(),
291            language_features: LashlangLanguageFeatures::default(),
292            resources: LashlangHostCatalog::new(),
293        }
294    }
295}
296
297impl LashlangSurface {
298    pub fn new(
299        abilities: LashlangAbilities,
300        language_features: LashlangLanguageFeatures,
301        resources: LashlangHostCatalog,
302    ) -> Self {
303        Self {
304            abilities,
305            language_features,
306            resources,
307        }
308    }
309
310    pub fn for_process_registry(mut self, process_registry_available: bool) -> Self {
311        self.abilities = self.abilities.with_sleep();
312        if process_registry_available {
313            self.abilities = self.abilities.with_processes().with_process_signals();
314        } else {
315            self.abilities.processes = false;
316            self.abilities.process_signals = false;
317        }
318        self
319    }
320
321    pub fn with_resources(mut self, resources: LashlangHostCatalog) -> Self {
322        self.resources.extend(resources);
323        self
324    }
325
326    pub fn with_plugin_extensions(
327        mut self,
328        extensions: &lash_core::PluginExtensions,
329    ) -> Result<Self, String> {
330        for payload in extensions.payloads(LASHLANG_SURFACE_EXTENSION_ID) {
331            let contribution: LashlangSurfaceContribution = serde_json::from_value(payload.clone())
332                .map_err(|err| {
333                    format!("invalid `{LASHLANG_SURFACE_EXTENSION_ID}` extension payload: {err}")
334                })?;
335            self.abilities = self.abilities.union(contribution.abilities);
336            self.language_features = self.language_features.union(contribution.language_features);
337            self.resources.extend(contribution.resources);
338        }
339        Ok(self)
340    }
341
342    pub fn host_environment(
343        &self,
344        catalog: &lash_core::ToolCatalog,
345    ) -> Result<LashlangHostEnvironment, String> {
346        lashlang_host_environment_from_tool_catalog(
347            catalog,
348            self.abilities,
349            self.language_features,
350            self.resources.clone(),
351        )
352    }
353}
354
355pub fn lashlang_host_environment_from_tool_catalog(
356    catalog: &lash_core::ToolCatalog,
357    abilities: LashlangAbilities,
358    language_features: LashlangLanguageFeatures,
359    host_resources: LashlangHostCatalog,
360) -> Result<LashlangHostEnvironment, String> {
361    let mut resources = lashlang_resources_from_tool_catalog(catalog)?;
362    resources.extend(host_resources);
363    if abilities.triggers {
364        lashlang::add_trigger_resource_operations(&mut resources);
365    }
366    Ok(
367        LashlangHostEnvironment::new(resources, abilities)
368            .with_language_features(language_features),
369    )
370}
371
372pub fn lashlang_resources_from_tool_catalog(
373    catalog: &lash_core::ToolCatalog,
374) -> Result<LashlangHostCatalog, String> {
375    let mut host_catalog = LashlangHostCatalog::new();
376    // Every catalog member is callable; membership is the execution gate.
377    for entry in catalog.tools.iter() {
378        let lashlang_binding = required_tool_lashlang_executable(&entry.manifest)?;
379        host_catalog.add_module_operation(
380            lashlang_binding.module_path.iter().map(String::as_str),
381            lashlang_binding.authority_type.clone(),
382            lashlang_binding.operation.clone(),
383            entry.manifest.id.to_string(),
384            lashlang::TypeExpr::Any,
385            lashlang::TypeExpr::Any,
386        );
387    }
388    Ok(host_catalog)
389}
390
391pub fn lashlang_host_environment_satisfies_requirements(
392    required: &lashlang::HostRequirements,
393    current: &LashlangHostEnvironment,
394) -> Result<(), String> {
395    let abilities = required.abilities;
396    let current_abilities = current.abilities;
397    if abilities.processes && !current_abilities.processes {
398        return Err("processes are not available".to_string());
399    }
400    if abilities.sleep && !current_abilities.sleep {
401        return Err("sleep is not available".to_string());
402    }
403    if abilities.process_signals && !current_abilities.process_signals {
404        return Err("process signals are not available".to_string());
405    }
406    if abilities.triggers && !current_abilities.triggers {
407        return Err("triggers are not available".to_string());
408    }
409    if required.language_features.label_annotations && !current.language_features.label_annotations
410    {
411        return Err("label annotations are not available".to_string());
412    }
413
414    for (_, module) in required.resources.module_instances() {
415        let current_module = current
416            .resources
417            .resolve_module_path(&module.path)
418            .ok_or_else(|| format!("module `{}` is not available", module.alias))?;
419        if current_module.resource_type != module.resource_type {
420            return Err(format!(
421                "module `{}` has type `{}`, expected `{}`",
422                module.alias, current_module.resource_type, module.resource_type
423            ));
424        }
425        for (operation, required_binding) in &module.operations {
426            match current.resources.resolve_module_operation(
427                &module.resource_type,
428                &module.alias,
429                operation,
430            ) {
431                Some(current_binding) if current_binding == required_binding => {}
432                Some(current_binding) => {
433                    return Err(format!(
434                        "module `{}` operation `{operation}` resolves to `{}`, expected `{}`",
435                        module.alias,
436                        current_binding.host_operation,
437                        required_binding.host_operation
438                    ));
439                }
440                None => {
441                    return Err(format!(
442                        "module `{}` does not expose operation `{operation}`",
443                        module.alias
444                    ));
445                }
446            }
447        }
448    }
449
450    for (resource_type, required_type) in required.resources.resource_types() {
451        if !current.resources.has_resource_type(resource_type) {
452            return Err(format!("resource type `{resource_type}` is not available"));
453        }
454        for (operation, required_binding) in &required_type.operations {
455            let current_binding = current
456                .resources
457                .resolve_operation(resource_type, operation)
458                .ok_or_else(|| {
459                    format!(
460                        "resource type `{resource_type}` does not expose operation `{operation}`"
461                    )
462                })?;
463            if current_binding.input_ty != required_binding.input_ty {
464                return Err(format!(
465                    "resource type `{resource_type}` operation `{operation}` has incompatible input type"
466                ));
467            }
468            if current_binding.output_ty != required_binding.output_ty {
469                return Err(format!(
470                    "resource type `{resource_type}` operation `{operation}` has incompatible output type"
471                ));
472            }
473        }
474    }
475    for (name, required_data_type) in required.resources.named_data_types() {
476        let current_data_type = current
477            .resources
478            .resolve_named_data_type(name)
479            .ok_or_else(|| format!("host data type `{name}` is not available"))?;
480        if current_data_type != required_data_type {
481            return Err(format!(
482                "host data type `{name}` has incompatible structure"
483            ));
484        }
485    }
486    for (path, required_binding) in required.resources.value_constructors() {
487        let current_binding = current
488            .resources
489            .resolve_value_constructor(&path.split('.').collect::<Vec<_>>())
490            .ok_or_else(|| format!("value constructor `{path}` is not available"))?;
491        if current_binding.input_ty != required_binding.input_ty {
492            return Err(format!(
493                "value constructor `{path}` has incompatible input type"
494            ));
495        }
496        if current_binding.output_ty != required_binding.output_ty {
497            return Err(format!(
498                "value constructor `{path}` has incompatible output type"
499            ));
500        }
501    }
502    for (source_ty, required_binding) in required.resources.trigger_sources() {
503        let current_binding = current
504            .resources
505            .resolve_trigger_source(source_ty)
506            .ok_or_else(|| format!("trigger source type `{source_ty}` is not available"))?;
507        if current_binding != required_binding {
508            return Err(format!(
509                "trigger source type `{source_ty}` has incompatible event type"
510            ));
511        }
512    }
513
514    Ok(())
515}
516
517#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
518pub struct LashlangProcessInput {
519    pub module_ref: lashlang::ModuleRef,
520    pub process_ref: lashlang::ProcessRef,
521    pub host_requirements_ref: lashlang::HostRequirementsRef,
522    pub process_name: String,
523    #[serde(default)]
524    pub args: serde_json::Map<String, serde_json::Value>,
525}
526
527impl LashlangProcessInput {
528    pub fn process_identity(&self) -> lash_core::ProcessIdentity {
529        lashlang_process_identity(self)
530    }
531
532    pub fn remote_identity(&self) -> lash_remote_protocol::RemoteProcessIdentity {
533        lash_remote_protocol::RemoteProcessIdentity {
534            kind: LASHLANG_ENGINE_KIND.to_string(),
535            label: Some(self.process_name.clone()),
536            definition: Some(lash_remote_protocol::RemoteProcessDefinitionIdentity {
537                value: self.definition(),
538            }),
539        }
540    }
541
542    pub fn to_process_input(&self) -> Result<lash_core::ProcessInput, serde_json::Error> {
543        Ok(lash_core::ProcessInput::Engine {
544            kind: LASHLANG_ENGINE_KIND.to_string(),
545            payload: serde_json::to_value(self)?,
546        })
547    }
548
549    pub fn into_process_input(self) -> Result<lash_core::ProcessInput, serde_json::Error> {
550        self.to_process_input()
551    }
552
553    pub fn remote_trigger_subscription_draft(
554        &self,
555        registrant: lash_remote_protocol::RemoteProcessOriginator,
556        env_ref: lash_remote_protocol::RemoteProcessExecutionEnvRef,
557        source_type: impl Into<String>,
558        source_key: impl Into<String>,
559    ) -> Result<lash_remote_protocol::RemoteTriggerSubscriptionDraft, serde_json::Error> {
560        Ok(
561            lash_remote_protocol::RemoteTriggerSubscriptionDraft::for_process(
562                registrant,
563                env_ref,
564                source_type,
565                source_key,
566                self.clone().try_into()?,
567                self.remote_identity(),
568            ),
569        )
570    }
571
572    pub fn from_payload(payload: serde_json::Value) -> Result<Self, serde_json::Error> {
573        serde_json::from_value(payload)
574    }
575
576    pub fn definition(&self) -> serde_json::Value {
577        serde_json::json!({
578            "module_ref": self.module_ref,
579            "process_ref": self.process_ref,
580            "host_requirements_ref": self.host_requirements_ref,
581            "process_name": self.process_name,
582        })
583    }
584}
585
586impl TryFrom<LashlangProcessInput> for lash_remote_protocol::RemoteProcessInput {
587    type Error = serde_json::Error;
588
589    fn try_from(value: LashlangProcessInput) -> Result<Self, Self::Error> {
590        Ok(Self::Engine {
591            kind: LASHLANG_ENGINE_KIND.to_string(),
592            payload: serde_json::to_value(value)?,
593        })
594    }
595}
596
597#[derive(Clone, Debug)]
598pub struct PreparedLashlangProcessStart {
599    pub registration: lash_core::ProcessRegistration,
600    pub label: Option<String>,
601}
602
603pub async fn prepare_lashlang_process_start(
604    artifact_store: Arc<dyn LashlangArtifactStore>,
605    parent_start_seed: &str,
606    start: lashlang::ProcessStart,
607) -> Result<PreparedLashlangProcessStart, String> {
608    let display_name = Some(start.process_name.clone());
609    let artifact = artifact_store
610        .get_module_artifact(&start.module_ref)
611        .await
612        .map_err(|err| format!("failed to load lashlang module artifact: {err}"))?
613        .ok_or_else(|| {
614            format!(
615                "missing lashlang module artifact `{}` for process `{}`",
616                start.module_ref, start.process_name
617            )
618        })?;
619    if artifact.host_requirements_ref != start.host_requirements_ref {
620        return Err(format!(
621            "lashlang module artifact `{}` host requirements mismatch: process requested {}, artifact has {}",
622            start.module_ref, start.host_requirements_ref, artifact.host_requirements_ref
623        ));
624    }
625    if artifact.process_ref(&start.process_name) != Some(&start.process_ref) {
626        return Err(format!(
627            "lashlang module artifact `{}` does not export process `{}` as requested ref {:?}",
628            start.module_ref, start.process_name, start.process_ref
629        ));
630    }
631    let args = match serde_json::to_value(lashlang::Value::Record(Arc::new(start.args)))
632        .map_err(|err| format!("failed to serialize process args: {err}"))?
633    {
634        serde_json::Value::Object(map) => map,
635        _ => return Err("process args must serialize as a record".to_string()),
636    };
637    let signal_event_types = artifact
638        .canonical_ir
639        .process(&start.process_name)
640        .map(lashlang_process_signal_event_types)
641        .unwrap_or_default();
642    let process_input = LashlangProcessInput {
643        module_ref: start.module_ref,
644        process_ref: start.process_ref,
645        host_requirements_ref: start.host_requirements_ref,
646        process_name: start.process_name,
647        args,
648    };
649    let identity = lashlang_process_identity(&process_input);
650    let process_id =
651        deterministic_lashlang_process_id(parent_start_seed, &start.start_site, &process_input)
652            .map_err(|err| format!("failed to derive deterministic process id: {err}"))?;
653    let process_input = process_input
654        .into_process_input()
655        .map_err(|err| format!("failed to encode process input: {err}"))?;
656    let registration = lash_core::ProcessRegistration::new(
657        process_id,
658        process_input,
659        // Lashlang engine rows are journaled and idempotent by process id, so
660        // recovery may re-execute them (ADR 0019).
661        lash_core::RecoveryDisposition::Rerunnable,
662        lash_core::ProcessProvenance::host(),
663    )
664    .with_identity(identity)
665    .with_extra_event_types(
666        lashlang_process_event_types()
667            .into_iter()
668            .chain(signal_event_types),
669    );
670    Ok(PreparedLashlangProcessStart {
671        registration,
672        label: display_name,
673    })
674}
675
676pub fn deterministic_lashlang_process_id(
677    parent_start_seed: &str,
678    start_site: &lashlang::LashlangExecutionCallSite,
679    input: &LashlangProcessInput,
680) -> Result<String, serde_json::Error> {
681    let args = serde_json::to_string(&input.args)?;
682    let occurrence = start_site.occurrence.to_string();
683    let process_ref = lashlang::process_ref_key(&input.process_ref);
684    let mut hasher = Sha256::new();
685    for part in [
686        "lashlang-process-start:v1",
687        parent_start_seed,
688        start_site.site.node_id.as_str(),
689        occurrence.as_str(),
690        input.module_ref.as_str(),
691        process_ref.as_str(),
692        input.host_requirements_ref.as_str(),
693        input.process_name.as_str(),
694        args.as_str(),
695    ] {
696        hasher.update(part.as_bytes());
697        hasher.update([0]);
698    }
699    let hash = format!("{:x}", hasher.finalize());
700    Ok(format!("process:lashlang:sha256:{hash}"))
701}
702
703pub fn resolve_lashlang_module_operation(
704    host_environment: &lashlang::LashlangHostEnvironment,
705    receiver: &lashlang::ResourceHandle,
706    operation: &str,
707) -> Result<String, lashlang::ExecutionHostError> {
708    host_environment
709        .resources
710        .resolve_module_operation(&receiver.resource_type, &receiver.alias, operation)
711        .map(|binding| binding.host_operation.clone())
712        .ok_or_else(|| {
713            lashlang::ExecutionHostError::new(format!(
714                "module `{}` of type `{}` does not expose operation `{operation}`",
715                receiver.alias, receiver.resource_type
716            ))
717        })
718}
719
720fn lashlang_process_identity(input: &LashlangProcessInput) -> lash_core::ProcessIdentity {
721    lash_core::ProcessIdentity::new(LASHLANG_ENGINE_KIND)
722        .with_label(Some(input.process_name.clone()))
723        .with_definition(Some(input.definition()))
724}
725
726#[derive(Clone)]
727pub struct LashlangProcessEngine {
728    artifact_store: Arc<dyn LashlangArtifactStore>,
729    process_cache: Arc<Mutex<CompiledProcessCache>>,
730    surface: LashlangSurface,
731    execution_sink: Option<Arc<dyn lash_trace::TraceSink>>,
732    trace_context: lash_trace::TraceContext,
733}
734
735impl LashlangProcessEngine {
736    pub fn new(artifact_store: Arc<dyn LashlangArtifactStore>, surface: LashlangSurface) -> Self {
737        Self {
738            artifact_store,
739            process_cache: Arc::new(Mutex::new(CompiledProcessCache::new())),
740            surface,
741            execution_sink: None,
742            trace_context: lash_trace::TraceContext::default(),
743        }
744    }
745
746    pub fn in_memory(surface: LashlangSurface) -> Self {
747        Self::new(
748            lashlang::global_in_memory_lashlang_artifact_store(),
749            surface,
750        )
751    }
752
753    pub fn with_execution_trace(
754        mut self,
755        sink: Option<Arc<dyn lash_trace::TraceSink>>,
756        trace_context: lash_trace::TraceContext,
757    ) -> Self {
758        self.execution_sink = sink;
759        self.trace_context = trace_context;
760        self
761    }
762
763    pub fn artifact_store(&self) -> Arc<dyn LashlangArtifactStore> {
764        Arc::clone(&self.artifact_store)
765    }
766}
767
768#[async_trait::async_trait]
769impl lash_core::ProcessEngine for LashlangProcessEngine {
770    fn kind(&self) -> &'static str {
771        LASHLANG_ENGINE_KIND
772    }
773
774    async fn validate_start(
775        &self,
776        context: lash_core::ProcessEngineValidationContext<'_>,
777        payload: &serde_json::Value,
778        _env_spec: Option<&lash_core::ProcessExecutionEnvSpec>,
779    ) -> Result<(), lash_core::PluginError> {
780        let input: LashlangProcessInput =
781            serde_json::from_value(payload.clone()).map_err(|err| {
782                lash_core::PluginError::Session(format!("invalid lashlang process payload: {err}"))
783            })?;
784        let artifact = self
785            .artifact_store
786            .get_module_artifact(&input.module_ref)
787            .await
788            .map_err(|err| lash_core::PluginError::Session(format!("load module artifact: {err}")))?
789            .ok_or_else(|| {
790                lash_core::PluginError::Session(format!(
791                    "missing lashlang module artifact `{}`",
792                    input.module_ref
793                ))
794            })?;
795        if artifact.host_requirements_ref != input.host_requirements_ref {
796            return Err(lash_core::PluginError::Session(format!(
797                "lashlang process `{}` requested surface {}, artifact has {}",
798                input.process_name, input.host_requirements_ref, artifact.host_requirements_ref
799            )));
800        }
801        if artifact.process_ref(&input.process_name) != Some(&input.process_ref) {
802            return Err(lash_core::PluginError::Session(format!(
803                "lashlang module `{}` does not export process `{}` as requested ref {:?}",
804                input.module_ref, input.process_name, input.process_ref
805            )));
806        }
807        let surface = self
808            .surface
809            .clone()
810            .for_process_registry(context.process_registry_available());
811        let host_environment = surface
812            .host_environment(context.tool_catalog())
813            .map_err(lash_core::PluginError::Session)?;
814        if let Err(err) = lashlang_host_environment_satisfies_requirements(
815            &artifact.host_requirements,
816            &host_environment,
817        ) {
818            return Err(lash_core::PluginError::Session(format!(
819                "lashlang process `{}` is incompatible with this host surface: {err}",
820                input.process_name
821            )));
822        }
823        Ok(())
824    }
825
826    async fn run(
827        &self,
828        context: lash_core::ProcessEngineRunContext<'_>,
829        payload: serde_json::Value,
830    ) -> lash_core::ProcessRunOutcome {
831        process::run_lashlang_process(self.clone(), context, payload).await
832    }
833
834    fn identity(&self, payload: &serde_json::Value) -> lash_core::ProcessIdentity {
835        match LashlangProcessInput::from_payload(payload.clone()) {
836            Ok(input) => lashlang_process_identity(&input),
837            Err(_) => lash_core::ProcessIdentity::new(LASHLANG_ENGINE_KIND),
838        }
839    }
840
841    fn durability_tier(&self) -> lash_core::DurabilityTier {
842        lashlang_durability_tier(self.artifact_store.durability_tier())
843    }
844}
845
846mod bridge;
847mod catalogue_preview;
848mod deferred;
849mod process;
850mod typed_output;
851
852pub use bridge::{
853    lashlang_value_to_json, process_event_payload, protocol_tool_output_to_lashlang_value,
854    protocol_tool_reply_to_lashlang_value, sleep_duration_ms,
855};
856pub use catalogue_preview::{
857    CataloguePreviewEntry, CataloguePreviewOptions, DEFAULT_CATALOGUE_PREVIEW_CALL_NAME_LIMIT,
858    DEFAULT_CATALOGUE_PREVIEW_MODULE_LIMIT, catalogue_preview_contribution,
859    catalogue_preview_contribution_for_entries,
860    catalogue_preview_contribution_for_entries_with_options,
861    catalogue_preview_contribution_for_manifests, catalogue_preview_contribution_with_options,
862    catalogue_preview_entries_from_catalog_records, catalogue_preview_entries_from_manifests,
863    catalogue_preview_entry_from_catalog_record, catalogue_preview_entry_from_manifest,
864};
865pub use deferred::{
866    DeferredResolutionRecord, DeferredToolResolver, Resolution, SharedDeferredToolResolver,
867    ToolGrant, link_with_deferred_resolution, resolve_and_fold_deferred,
868};
869pub use process::{
870    lashlang_process_event_types, lashlang_process_signal_event_types, lashlang_type_expr_schema,
871};
872pub use typed_output::parse_output_schema;
873
874#[cfg(test)]
875mod tests {
876    use super::*;
877
878    struct EveryNEffectsController(usize);
879
880    #[async_trait::async_trait]
881    impl lash_core::AwaitEventResolver for EveryNEffectsController {}
882
883    #[async_trait::async_trait]
884    impl lash_core::RuntimeEffectController for EveryNEffectsController {
885        fn wants_segment_boundary(
886            &self,
887            progress: &lash_core::SegmentProgress,
888        ) -> Option<lash_core::BoundaryReason> {
889            progress
890                .effects_executed
891                .is_multiple_of(self.0 as u64)
892                .then_some(lash_core::BoundaryReason::JournalBudget)
893        }
894
895        async fn execute_effect(
896            &self,
897            _envelope: lash_core::RuntimeEffectEnvelope,
898            _local_executor: lash_core::RuntimeEffectLocalExecutor<'_>,
899        ) -> Result<lash_core::RuntimeEffectOutcome, lash_core::RuntimeEffectControllerError>
900        {
901            unreachable!("predicate test does not execute effects")
902        }
903    }
904
905    #[test]
906    fn every_n_controller_requests_boundaries_and_inline_default_does_not() {
907        let progress = lash_core::SegmentProgress {
908            effects_executed: 2,
909            journaled_bytes_estimate: None,
910        };
911        assert_eq!(
912            lash_core::RuntimeEffectController::wants_segment_boundary(
913                &EveryNEffectsController(2),
914                &progress,
915            ),
916            Some(lash_core::BoundaryReason::JournalBudget)
917        );
918        assert_eq!(
919            lash_core::RuntimeEffectController::wants_segment_boundary(
920                &lash_core::InlineRuntimeEffectController,
921                &progress,
922            ),
923            None
924        );
925    }
926
927    #[test]
928    fn process_input_serializes_as_generic_engine_payload() {
929        let hash = lashlang::ContentHash::new("abc123");
930        let input = LashlangProcessInput {
931            module_ref: lashlang::ModuleRef::new(&hash),
932            process_ref: lashlang::ProcessRef::new(hash.clone(), 7),
933            host_requirements_ref: lashlang::HostRequirementsRef::new(&hash),
934            process_name: "main".to_string(),
935            args: serde_json::Map::from_iter([("prompt".to_string(), serde_json::json!("go"))]),
936        };
937
938        let process_input = input
939            .clone()
940            .into_process_input()
941            .expect("lashlang process input serializes");
942
943        let lash_core::ProcessInput::Engine { kind, payload } = process_input else {
944            panic!("lashlang runtime must use the generic engine process input");
945        };
946        assert_eq!(kind, LASHLANG_ENGINE_KIND);
947        assert_eq!(
948            LashlangProcessInput::from_payload(payload)
949                .expect("engine payload decodes")
950                .process_name,
951            input.process_name
952        );
953    }
954
955    #[test]
956    fn process_input_remote_helpers_use_generic_engine_and_identity() {
957        let hash = lashlang::ContentHash::new("abc123");
958        let input = LashlangProcessInput {
959            module_ref: lashlang::ModuleRef::new(&hash),
960            process_ref: lashlang::ProcessRef::new(hash.clone(), 7),
961            host_requirements_ref: lashlang::HostRequirementsRef::new(&hash),
962            process_name: "main".to_string(),
963            args: serde_json::Map::from_iter([("prompt".to_string(), serde_json::json!("go"))]),
964        };
965
966        let remote_input: lash_remote_protocol::RemoteProcessInput = input
967            .clone()
968            .try_into()
969            .expect("lashlang process input serializes remotely");
970        let lash_remote_protocol::RemoteProcessInput::Engine { kind, payload } = remote_input
971        else {
972            panic!("lashlang runtime must use the generic remote engine process input");
973        };
974        assert_eq!(kind, LASHLANG_ENGINE_KIND);
975        assert_eq!(
976            LashlangProcessInput::from_payload(payload)
977                .expect("remote payload decodes")
978                .process_name,
979            "main"
980        );
981
982        let identity = input.process_identity();
983        assert_eq!(identity.kind, LASHLANG_ENGINE_KIND);
984        assert_eq!(identity.label.as_deref(), Some("main"));
985        assert_eq!(input.remote_identity().label.as_deref(), Some("main"));
986
987        let draft = input
988            .remote_trigger_subscription_draft(
989                lash_remote_protocol::RemoteProcessOriginator::Host { scope: None },
990                "process-env:sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
991                    .parse()
992                    .expect("canonical env ref"),
993                "ui.button.pressed",
994                "source-key",
995            )
996            .expect("remote trigger draft");
997        draft.validate().expect("draft validates");
998        assert_eq!(draft.target_label.as_deref(), Some("main"));
999        assert_eq!(draft.target_identity.label.as_deref(), Some("main"));
1000    }
1001
1002    #[test]
1003    fn missing_tool_binding_is_not_fabricated() {
1004        let tool = lash_core::ToolDefinition::raw(
1005            "tool:test/read_file",
1006            "read_file",
1007            "read a file",
1008            lash_core::ToolDefinition::default_input_schema(),
1009            serde_json::Value::Null,
1010        );
1011
1012        let err = required_tool_lashlang_executable(&tool.manifest)
1013            .expect_err("missing explicit binding should fail");
1014
1015        assert!(err.contains("missing an explicit `lashlang.tool` binding"));
1016    }
1017
1018    #[test]
1019    fn explicit_tool_binding_attaches_lashlang_metadata() {
1020        let tool = lash_core::ToolDefinition::raw(
1021            "tool:test/read_file",
1022            "read_file",
1023            "read a file",
1024            lash_core::ToolDefinition::default_input_schema(),
1025            serde_json::Value::Null,
1026        )
1027        .with_lashlang_binding(
1028            LashlangToolBinding::new(["fs"], "read")
1029                .with_authority_type("Filesystem")
1030                .with_aliases(["cat"]),
1031        );
1032
1033        let binding =
1034            required_tool_lashlang_executable(&tool.manifest).expect("explicit binding resolves");
1035
1036        assert_eq!(binding.module_path, vec!["fs"]);
1037        assert_eq!(binding.operation, "read");
1038        assert_eq!(binding.authority_type, "Filesystem");
1039        assert_eq!(binding.aliases, vec!["cat"]);
1040    }
1041
1042    #[test]
1043    fn dotted_operation_names_are_rejected() {
1044        let tool = lash_core::ToolDefinition::raw(
1045            "tool:test/update_plan",
1046            "update_plan",
1047            "update a plan",
1048            lash_core::ToolDefinition::default_input_schema(),
1049            serde_json::Value::Null,
1050        )
1051        .with_lashlang_binding(LashlangToolBinding::new(["tools"], "update.plan"));
1052
1053        let err = required_tool_lashlang_executable(&tool.manifest)
1054            .expect_err("dotted operation cannot compile as one Lashlang operation");
1055
1056        assert!(err.contains("invalid Lashlang operation name `update.plan`"));
1057    }
1058
1059    #[test]
1060    fn manifest_lashlang_binding_accessor_reports_absent_valid_and_malformed() {
1061        let mut manifest = lash_core::ToolDefinition::raw(
1062            "tool:test/read_file",
1063            "read_file",
1064            "read a file",
1065            lash_core::ToolDefinition::default_input_schema(),
1066            serde_json::Value::Null,
1067        )
1068        .manifest;
1069        assert_eq!(manifest.lashlang_binding().expect("absent binding"), None);
1070
1071        manifest.bindings.insert(
1072            LASHLANG_TOOL_BINDING_KEY.to_string(),
1073            serde_json::json!({
1074                "module_path": ["fs"],
1075                "operation": "read"
1076            }),
1077        );
1078        let binding = manifest
1079            .lashlang_binding()
1080            .expect("valid binding")
1081            .expect("present binding");
1082        assert_eq!(binding.module_path, vec!["fs"]);
1083        assert_eq!(binding.operation.as_deref(), Some("read"));
1084
1085        manifest.bindings.insert(
1086            LASHLANG_TOOL_BINDING_KEY.to_string(),
1087            serde_json::json!({ "module_path": "fs" }),
1088        );
1089        assert!(manifest.lashlang_binding().is_err());
1090    }
1091
1092    #[test]
1093    fn remote_grant_lashlang_binding_accessor_reports_absent_valid_and_malformed() {
1094        let grant = remote_tool_grant("read_file");
1095        assert_eq!(grant.lashlang_binding().expect("absent binding"), None);
1096
1097        let grant = grant.with_lashlang_binding(LashlangToolBinding::new(["fs"], "read"));
1098        let binding = grant
1099            .lashlang_binding()
1100            .expect("valid binding")
1101            .expect("present binding");
1102        assert_eq!(binding.module_path, vec!["fs"]);
1103        assert_eq!(binding.operation.as_deref(), Some("read"));
1104
1105        let mut malformed = grant;
1106        malformed.bindings.insert(
1107            LASHLANG_TOOL_BINDING_KEY.to_string(),
1108            serde_json::json!({ "module_path": "fs" }),
1109        );
1110        assert!(malformed.lashlang_binding().is_err());
1111    }
1112
1113    #[test]
1114    fn deterministic_process_id_reuses_replayed_start_site_and_args() {
1115        let input = test_process_input(serde_json::json!({ "root": "." }));
1116        let site = test_start_site("child_process:scan", 1);
1117
1118        let first = deterministic_lashlang_process_id("parent:root", &site, &input)
1119            .expect("process id derives");
1120        let second = deterministic_lashlang_process_id("parent:root", &site, &input)
1121            .expect("process id derives");
1122
1123        assert_eq!(first, second);
1124        assert!(first.starts_with("process:lashlang:sha256:"));
1125    }
1126
1127    #[test]
1128    fn deterministic_process_id_separates_parallel_sites_ordinals_and_parents() {
1129        let input = test_process_input(serde_json::json!({ "root": "." }));
1130        let left = deterministic_lashlang_process_id(
1131            "parent:root",
1132            &test_start_site("child_process:left", 1),
1133            &input,
1134        )
1135        .expect("left id derives");
1136        let right = deterministic_lashlang_process_id(
1137            "parent:root",
1138            &test_start_site("child_process:right", 1),
1139            &input,
1140        )
1141        .expect("right id derives");
1142        let second_ordinal = deterministic_lashlang_process_id(
1143            "parent:root",
1144            &test_start_site("child_process:left", 2),
1145            &input,
1146        )
1147        .expect("second ordinal id derives");
1148        let nested_parent = deterministic_lashlang_process_id(
1149            "parent:nested",
1150            &test_start_site("child_process:left", 1),
1151            &input,
1152        )
1153        .expect("nested parent id derives");
1154
1155        assert_ne!(left, right);
1156        assert_ne!(left, second_ordinal);
1157        assert_ne!(left, nested_parent);
1158    }
1159
1160    #[tokio::test(flavor = "current_thread")]
1161    async fn prepared_start_replays_same_registration_id_without_duplicate_child_identity() {
1162        let store = Arc::new(InMemoryLashlangArtifactStore::new());
1163        let environment = LashlangHostEnvironment::new(
1164            lashlang::LashlangHostCatalog::new(),
1165            LashlangAbilities::default().with_processes(),
1166        );
1167        let output = lashlang::compile_module(lashlang::ModuleCompileRequest {
1168            source: r#"process scan(root: str) -> str { finish root }"#,
1169            environment: &environment,
1170            artifact_store: Some(store.as_ref()),
1171        })
1172        .await
1173        .expect("module compiles and persists");
1174        let artifact_store: Arc<dyn LashlangArtifactStore> = store;
1175        let site = test_start_site("child_process:scan", 1);
1176
1177        let first = prepare_lashlang_process_start(
1178            Arc::clone(&artifact_store),
1179            "parent:root",
1180            test_process_start(&output, site.clone(), "."),
1181        )
1182        .await
1183        .expect("first start prepares");
1184        let replayed = prepare_lashlang_process_start(
1185            Arc::clone(&artifact_store),
1186            "parent:root",
1187            test_process_start(&output, site.clone(), "."),
1188        )
1189        .await
1190        .expect("replayed start prepares");
1191        let sibling = prepare_lashlang_process_start(
1192            Arc::clone(&artifact_store),
1193            "parent:root",
1194            test_process_start(&output, test_start_site("child_process:scan", 2), "."),
1195        )
1196        .await
1197        .expect("sibling start prepares");
1198
1199        assert_eq!(first.registration.id, replayed.registration.id);
1200        assert_eq!(first.registration.identity, replayed.registration.identity);
1201        assert_ne!(first.registration.id, sibling.registration.id);
1202    }
1203
1204    #[test]
1205    fn surface_merges_plugin_extensions() {
1206        let contribution = LashlangSurfaceContribution::new(
1207            LashlangAbilities::default().with_processes(),
1208            LashlangLanguageFeatures::default().with_label_annotations(),
1209            LashlangHostCatalog::tool_default(["lookup"]),
1210        );
1211        let extensions = lash_core::PluginExtensions::from_contributions([
1212            lash_core::PluginExtensionContribution::new(
1213                LASHLANG_SURFACE_EXTENSION_ID,
1214                contribution,
1215            )
1216            .expect("extension payload serializes"),
1217        ]);
1218
1219        let surface = LashlangSurface::default()
1220            .with_plugin_extensions(&extensions)
1221            .expect("lashlang surface extension merges");
1222        let environment = surface
1223            .host_environment(&lash_core::ToolCatalog::default())
1224            .expect("empty tool catalog has no Lashlang bindings to validate");
1225
1226        assert!(environment.abilities.sleep);
1227        assert!(environment.abilities.processes);
1228        assert!(environment.language_features.label_annotations);
1229        assert!(
1230            environment
1231                .resources
1232                .resolve_module_operation("Tools", "tools", "lookup")
1233                .is_some()
1234        );
1235    }
1236
1237    fn remote_tool_grant(name: &str) -> lash_remote_protocol::RemoteToolGrant {
1238        lash_remote_protocol::RemoteToolGrant {
1239            protocol_version: lash_remote_protocol::REMOTE_PROTOCOL_VERSION,
1240            id: format!("remote-tool:{name}"),
1241            name: name.to_string(),
1242            description: String::new(),
1243            input_schema: lash_remote_protocol::RemoteSchemaContract {
1244                canonical: lash_core::ToolDefinition::default_input_schema(),
1245                projection: lash_remote_protocol::RemoteSchemaProjectionPolicy::default(),
1246            },
1247            output_schema: lash_remote_protocol::RemoteSchemaContract::default(),
1248            output_contract: lash_remote_protocol::RemoteToolOutputContract::Static,
1249            examples: Vec::new(),
1250            activation: None,
1251            argument_projection: None,
1252            scheduling: None,
1253            retry_policy: None,
1254            bindings: Default::default(),
1255        }
1256    }
1257
1258    fn test_process_input(args: serde_json::Value) -> LashlangProcessInput {
1259        let hash = lashlang::ContentHash::new("abc123");
1260        let args = args
1261            .as_object()
1262            .expect("test args must be an object")
1263            .clone();
1264        LashlangProcessInput {
1265            module_ref: lashlang::ModuleRef::new(&hash),
1266            process_ref: lashlang::ProcessRef::new(hash.clone(), 7),
1267            host_requirements_ref: lashlang::HostRequirementsRef::new(&hash),
1268            process_name: "scan".to_string(),
1269            args,
1270        }
1271    }
1272
1273    fn test_start_site(node_id: &str, occurrence: u64) -> lashlang::LashlangExecutionCallSite {
1274        lashlang::LashlangExecutionCallSite {
1275            site: lashlang::LashlangExecutionSite {
1276                node_id: node_id.to_string(),
1277                node_kind: "child_process".to_string(),
1278                label: "start scan".to_string(),
1279                branch: None,
1280            },
1281            occurrence,
1282        }
1283    }
1284
1285    fn test_process_start(
1286        output: &lashlang::ModuleCompileOutput,
1287        start_site: lashlang::LashlangExecutionCallSite,
1288        root: &str,
1289    ) -> lashlang::ProcessStart {
1290        let mut args = lashlang::Record::new();
1291        args.insert("root".to_string(), lashlang::Value::String(root.into()));
1292        lashlang::ProcessStart {
1293            module_ref: output.module_ref.clone(),
1294            process_ref: output
1295                .artifact
1296                .process_ref("scan")
1297                .expect("scan process export")
1298                .clone(),
1299            host_requirements_ref: output.host_requirements_ref.clone(),
1300            start_site,
1301            process_name: "scan".to_string(),
1302            args,
1303        }
1304    }
1305}