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        let operation_binding = catalog
380            .resolve_contract(&entry.manifest.name)
381            .as_deref()
382            .map(lashlang_tool_contract_types)
383            .unwrap_or(lashlang::ResourceOperationBinding {
384                input_ty: lashlang::TypeExpr::Any,
385                output_ty: lashlang::TypeExpr::Any,
386                output_from_input: None,
387            });
388        host_catalog.add_module_operation_binding(
389            lashlang_binding.module_path.iter().map(String::as_str),
390            lashlang_binding.authority_type.clone(),
391            lashlang_binding.operation.clone(),
392            entry.manifest.id.to_string(),
393            operation_binding,
394        );
395    }
396    Ok(host_catalog)
397}
398
399fn lashlang_tool_contract_types(
400    contract: &lash_core::ToolContract,
401) -> lashlang::ResourceOperationBinding {
402    let input_ty = lashlang::json_schema_to_type_expr(contract.input_schema.canonical());
403    let (output_ty, output_from_input) = match &contract.output_contract {
404        lash_core::ToolOutputContract::Static => (
405            lashlang::json_schema_to_type_expr(contract.output_schema.canonical()),
406            None,
407        ),
408        lash_core::ToolOutputContract::FromInputSchema {
409            input_field,
410            default_schema,
411        } => (
412            lashlang::TypeExpr::Any,
413            Some(lashlang::OutputFromInputBinding {
414                input_field: input_field.clone(),
415                default_schema: default_schema
416                    .as_ref()
417                    .map(lashlang::json_schema_to_type_expr),
418            }),
419        ),
420    };
421    lashlang::ResourceOperationBinding {
422        input_ty,
423        output_ty,
424        output_from_input,
425    }
426}
427
428pub fn lashlang_host_environment_satisfies_requirements(
429    required: &lashlang::HostRequirements,
430    current: &LashlangHostEnvironment,
431) -> Result<(), String> {
432    let abilities = required.abilities;
433    let current_abilities = current.abilities;
434    if abilities.processes && !current_abilities.processes {
435        return Err("processes are not available".to_string());
436    }
437    if abilities.sleep && !current_abilities.sleep {
438        return Err("sleep is not available".to_string());
439    }
440    if abilities.process_signals && !current_abilities.process_signals {
441        return Err("process signals are not available".to_string());
442    }
443    if abilities.triggers && !current_abilities.triggers {
444        return Err("triggers are not available".to_string());
445    }
446    if required.language_features.label_annotations && !current.language_features.label_annotations
447    {
448        return Err("label annotations are not available".to_string());
449    }
450
451    for (_, module) in required.resources.module_instances() {
452        let current_module = current
453            .resources
454            .resolve_module_path(&module.path)
455            .ok_or_else(|| format!("module `{}` is not available", module.alias))?;
456        if current_module.resource_type != module.resource_type {
457            return Err(format!(
458                "module `{}` has type `{}`, expected `{}`",
459                module.alias, current_module.resource_type, module.resource_type
460            ));
461        }
462        for (operation, required_binding) in &module.operations {
463            match current.resources.resolve_module_operation(
464                &module.resource_type,
465                &module.alias,
466                operation,
467            ) {
468                Some(current_binding) if current_binding == required_binding => {}
469                Some(current_binding) => {
470                    return Err(format!(
471                        "module `{}` operation `{operation}` resolves to `{}`, expected `{}`",
472                        module.alias,
473                        current_binding.host_operation,
474                        required_binding.host_operation
475                    ));
476                }
477                None => {
478                    return Err(format!(
479                        "module `{}` does not expose operation `{operation}`",
480                        module.alias
481                    ));
482                }
483            }
484        }
485    }
486
487    for (resource_type, required_type) in required.resources.resource_types() {
488        if !current.resources.has_resource_type(resource_type) {
489            return Err(format!("resource type `{resource_type}` is not available"));
490        }
491        for (operation, required_binding) in &required_type.operations {
492            let current_binding = current
493                .resources
494                .resolve_operation(resource_type, operation)
495                .ok_or_else(|| {
496                    format!(
497                        "resource type `{resource_type}` does not expose operation `{operation}`"
498                    )
499                })?;
500            if current_binding.input_ty != required_binding.input_ty {
501                return Err(format!(
502                    "resource type `{resource_type}` operation `{operation}` has incompatible input type"
503                ));
504            }
505            if current_binding.output_ty != required_binding.output_ty {
506                return Err(format!(
507                    "resource type `{resource_type}` operation `{operation}` has incompatible output type"
508                ));
509            }
510        }
511    }
512    for (name, required_data_type) in required.resources.named_data_types() {
513        let current_data_type = current
514            .resources
515            .resolve_named_data_type(name)
516            .ok_or_else(|| format!("host data type `{name}` is not available"))?;
517        if current_data_type != required_data_type {
518            return Err(format!(
519                "host data type `{name}` has incompatible structure"
520            ));
521        }
522    }
523    for (path, required_binding) in required.resources.value_constructors() {
524        let current_binding = current
525            .resources
526            .resolve_value_constructor(&path.split('.').collect::<Vec<_>>())
527            .ok_or_else(|| format!("value constructor `{path}` is not available"))?;
528        if current_binding.input_ty != required_binding.input_ty {
529            return Err(format!(
530                "value constructor `{path}` has incompatible input type"
531            ));
532        }
533        if current_binding.output_ty != required_binding.output_ty {
534            return Err(format!(
535                "value constructor `{path}` has incompatible output type"
536            ));
537        }
538    }
539    for (source_ty, required_binding) in required.resources.trigger_sources() {
540        let current_binding = current
541            .resources
542            .resolve_trigger_source(source_ty)
543            .ok_or_else(|| format!("trigger source type `{source_ty}` is not available"))?;
544        if current_binding != required_binding {
545            return Err(format!(
546                "trigger source type `{source_ty}` has incompatible event type"
547            ));
548        }
549    }
550
551    Ok(())
552}
553
554#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
555pub struct LashlangProcessInput {
556    pub module_ref: lashlang::ModuleRef,
557    pub process_ref: lashlang::ProcessRef,
558    pub host_requirements_ref: lashlang::HostRequirementsRef,
559    pub process_name: String,
560    #[serde(default)]
561    pub args: serde_json::Map<String, serde_json::Value>,
562}
563
564impl LashlangProcessInput {
565    pub fn process_identity(&self) -> lash_core::ProcessIdentity {
566        lashlang_process_identity(self)
567    }
568
569    pub fn remote_identity(&self) -> lash_remote_protocol::RemoteProcessIdentity {
570        lash_remote_protocol::RemoteProcessIdentity {
571            kind: LASHLANG_ENGINE_KIND.to_string(),
572            label: Some(self.process_name.clone()),
573            definition: Some(lash_remote_protocol::RemoteProcessDefinitionIdentity {
574                value: self.definition(),
575            }),
576        }
577    }
578
579    pub fn to_process_input(&self) -> Result<lash_core::ProcessInput, serde_json::Error> {
580        Ok(lash_core::ProcessInput::Engine {
581            kind: LASHLANG_ENGINE_KIND.to_string(),
582            payload: serde_json::to_value(self)?,
583        })
584    }
585
586    pub fn into_process_input(self) -> Result<lash_core::ProcessInput, serde_json::Error> {
587        self.to_process_input()
588    }
589
590    pub fn remote_trigger_subscription_draft(
591        &self,
592        registrant: lash_remote_protocol::RemoteProcessOriginator,
593        env_ref: lash_remote_protocol::RemoteProcessExecutionEnvRef,
594        source_type: impl Into<String>,
595        source_key: impl Into<String>,
596    ) -> Result<lash_remote_protocol::RemoteTriggerSubscriptionDraft, serde_json::Error> {
597        Ok(
598            lash_remote_protocol::RemoteTriggerSubscriptionDraft::for_process(
599                registrant,
600                env_ref,
601                source_type,
602                source_key,
603                self.clone().try_into()?,
604                self.remote_identity(),
605            ),
606        )
607    }
608
609    pub fn from_payload(payload: serde_json::Value) -> Result<Self, serde_json::Error> {
610        serde_json::from_value(payload)
611    }
612
613    pub fn definition(&self) -> serde_json::Value {
614        serde_json::json!({
615            "module_ref": self.module_ref,
616            "process_ref": self.process_ref,
617            "host_requirements_ref": self.host_requirements_ref,
618            "process_name": self.process_name,
619        })
620    }
621}
622
623impl TryFrom<LashlangProcessInput> for lash_remote_protocol::RemoteProcessInput {
624    type Error = serde_json::Error;
625
626    fn try_from(value: LashlangProcessInput) -> Result<Self, Self::Error> {
627        Ok(Self::Engine {
628            kind: LASHLANG_ENGINE_KIND.to_string(),
629            payload: serde_json::to_value(value)?,
630        })
631    }
632}
633
634#[derive(Clone, Debug)]
635pub struct PreparedLashlangProcessStart {
636    pub registration: lash_core::ProcessRegistration,
637    pub label: Option<String>,
638}
639
640pub async fn prepare_lashlang_process_start(
641    artifact_store: Arc<dyn LashlangArtifactStore>,
642    parent_start_seed: &str,
643    start: lashlang::ProcessStart,
644) -> Result<PreparedLashlangProcessStart, String> {
645    let display_name = Some(start.process_name.clone());
646    let artifact = artifact_store
647        .get_module_artifact(&start.module_ref)
648        .await
649        .map_err(|err| format!("failed to load lashlang module artifact: {err}"))?
650        .ok_or_else(|| {
651            format!(
652                "missing lashlang module artifact `{}` for process `{}`",
653                start.module_ref, start.process_name
654            )
655        })?;
656    if artifact.host_requirements_ref != start.host_requirements_ref {
657        return Err(format!(
658            "lashlang module artifact `{}` host requirements mismatch: process requested {}, artifact has {}",
659            start.module_ref, start.host_requirements_ref, artifact.host_requirements_ref
660        ));
661    }
662    if artifact.process_ref(&start.process_name) != Some(&start.process_ref) {
663        return Err(format!(
664            "lashlang module artifact `{}` does not export process `{}` as requested ref {:?}",
665            start.module_ref, start.process_name, start.process_ref
666        ));
667    }
668    let args = match serde_json::to_value(lashlang::Value::Record(Arc::new(start.args)))
669        .map_err(|err| format!("failed to serialize process args: {err}"))?
670    {
671        serde_json::Value::Object(map) => map,
672        _ => return Err("process args must serialize as a record".to_string()),
673    };
674    let signal_event_types = artifact
675        .canonical_ir
676        .process(&start.process_name)
677        .map(lashlang_process_signal_event_types)
678        .unwrap_or_default();
679    let process_input = LashlangProcessInput {
680        module_ref: start.module_ref,
681        process_ref: start.process_ref,
682        host_requirements_ref: start.host_requirements_ref,
683        process_name: start.process_name,
684        args,
685    };
686    let identity = lashlang_process_identity(&process_input);
687    let process_id =
688        deterministic_lashlang_process_id(parent_start_seed, &start.start_site, &process_input)
689            .map_err(|err| format!("failed to derive deterministic process id: {err}"))?;
690    let process_input = process_input
691        .into_process_input()
692        .map_err(|err| format!("failed to encode process input: {err}"))?;
693    let registration = lash_core::ProcessRegistration::new(
694        process_id,
695        process_input,
696        // Lashlang engine rows are journaled and idempotent by process id, so
697        // recovery may re-execute them (ADR 0019).
698        lash_core::RecoveryDisposition::Rerunnable,
699        lash_core::ProcessProvenance::host(),
700    )
701    .with_identity(identity)
702    .with_extra_event_types(
703        lashlang_process_event_types()
704            .into_iter()
705            .chain(signal_event_types),
706    );
707    Ok(PreparedLashlangProcessStart {
708        registration,
709        label: display_name,
710    })
711}
712
713pub fn deterministic_lashlang_process_id(
714    parent_start_seed: &str,
715    start_site: &lashlang::LashlangExecutionCallSite,
716    input: &LashlangProcessInput,
717) -> Result<String, serde_json::Error> {
718    let args = serde_json::to_string(&input.args)?;
719    let occurrence = start_site.occurrence.to_string();
720    let process_ref = lashlang::process_ref_key(&input.process_ref);
721    let mut hasher = Sha256::new();
722    for part in [
723        "lashlang-process-start:v1",
724        parent_start_seed,
725        start_site.site.node_id.as_str(),
726        occurrence.as_str(),
727        input.module_ref.as_str(),
728        process_ref.as_str(),
729        input.host_requirements_ref.as_str(),
730        input.process_name.as_str(),
731        args.as_str(),
732    ] {
733        hasher.update(part.as_bytes());
734        hasher.update([0]);
735    }
736    let hash = format!("{:x}", hasher.finalize());
737    Ok(format!("process:lashlang:sha256:{hash}"))
738}
739
740pub fn resolve_lashlang_module_operation(
741    host_environment: &lashlang::LashlangHostEnvironment,
742    receiver: &lashlang::ResourceHandle,
743    operation: &str,
744) -> Result<String, lashlang::ExecutionHostError> {
745    host_environment
746        .resources
747        .resolve_module_operation(&receiver.resource_type, &receiver.alias, operation)
748        .map(|binding| binding.host_operation.clone())
749        .ok_or_else(|| {
750            lashlang::ExecutionHostError::new(format!(
751                "module `{}` of type `{}` does not expose operation `{operation}`",
752                receiver.alias, receiver.resource_type
753            ))
754        })
755}
756
757fn lashlang_process_identity(input: &LashlangProcessInput) -> lash_core::ProcessIdentity {
758    lash_core::ProcessIdentity::new(LASHLANG_ENGINE_KIND)
759        .with_label(Some(input.process_name.clone()))
760        .with_definition(Some(input.definition()))
761}
762
763#[derive(Clone)]
764pub struct LashlangProcessEngine {
765    artifact_store: Arc<dyn LashlangArtifactStore>,
766    process_cache: Arc<Mutex<CompiledProcessCache>>,
767    surface: LashlangSurface,
768    execution_sink: Option<Arc<dyn lash_trace::TraceSink>>,
769    trace_context: lash_trace::TraceContext,
770}
771
772impl LashlangProcessEngine {
773    pub fn new(artifact_store: Arc<dyn LashlangArtifactStore>, surface: LashlangSurface) -> Self {
774        Self {
775            artifact_store,
776            process_cache: Arc::new(Mutex::new(CompiledProcessCache::new())),
777            surface,
778            execution_sink: None,
779            trace_context: lash_trace::TraceContext::default(),
780        }
781    }
782
783    pub fn in_memory(surface: LashlangSurface) -> Self {
784        Self::new(
785            lashlang::global_in_memory_lashlang_artifact_store(),
786            surface,
787        )
788    }
789
790    pub fn with_execution_trace(
791        mut self,
792        sink: Option<Arc<dyn lash_trace::TraceSink>>,
793        trace_context: lash_trace::TraceContext,
794    ) -> Self {
795        self.execution_sink = sink;
796        self.trace_context = trace_context;
797        self
798    }
799
800    pub fn artifact_store(&self) -> Arc<dyn LashlangArtifactStore> {
801        Arc::clone(&self.artifact_store)
802    }
803}
804
805#[async_trait::async_trait]
806impl lash_core::ProcessEngine for LashlangProcessEngine {
807    fn kind(&self) -> &'static str {
808        LASHLANG_ENGINE_KIND
809    }
810
811    async fn validate_start(
812        &self,
813        context: lash_core::ProcessEngineValidationContext<'_>,
814        payload: &serde_json::Value,
815        _env_spec: Option<&lash_core::ProcessExecutionEnvSpec>,
816    ) -> Result<(), lash_core::PluginError> {
817        let input: LashlangProcessInput =
818            serde_json::from_value(payload.clone()).map_err(|err| {
819                lash_core::PluginError::Session(format!("invalid lashlang process payload: {err}"))
820            })?;
821        let artifact = self
822            .artifact_store
823            .get_module_artifact(&input.module_ref)
824            .await
825            .map_err(|err| lash_core::PluginError::Session(format!("load module artifact: {err}")))?
826            .ok_or_else(|| {
827                lash_core::PluginError::Session(format!(
828                    "missing lashlang module artifact `{}`",
829                    input.module_ref
830                ))
831            })?;
832        if artifact.host_requirements_ref != input.host_requirements_ref {
833            return Err(lash_core::PluginError::Session(format!(
834                "lashlang process `{}` requested surface {}, artifact has {}",
835                input.process_name, input.host_requirements_ref, artifact.host_requirements_ref
836            )));
837        }
838        if artifact.process_ref(&input.process_name) != Some(&input.process_ref) {
839            return Err(lash_core::PluginError::Session(format!(
840                "lashlang module `{}` does not export process `{}` as requested ref {:?}",
841                input.module_ref, input.process_name, input.process_ref
842            )));
843        }
844        let surface = self
845            .surface
846            .clone()
847            .for_process_registry(context.process_registry_available());
848        let host_environment = surface
849            .host_environment(context.tool_catalog())
850            .map_err(lash_core::PluginError::Session)?;
851        if let Err(err) = lashlang_host_environment_satisfies_requirements(
852            &artifact.host_requirements,
853            &host_environment,
854        ) {
855            return Err(lash_core::PluginError::Session(format!(
856                "lashlang process `{}` is incompatible with this host surface: {err}",
857                input.process_name
858            )));
859        }
860        Ok(())
861    }
862
863    async fn run(
864        &self,
865        context: lash_core::ProcessEngineRunContext<'_>,
866        payload: serde_json::Value,
867    ) -> lash_core::ProcessRunOutcome {
868        process::run_lashlang_process(self.clone(), context, payload).await
869    }
870
871    fn identity(&self, payload: &serde_json::Value) -> lash_core::ProcessIdentity {
872        match LashlangProcessInput::from_payload(payload.clone()) {
873            Ok(input) => lashlang_process_identity(&input),
874            Err(_) => lash_core::ProcessIdentity::new(LASHLANG_ENGINE_KIND),
875        }
876    }
877
878    fn durability_tier(&self) -> lash_core::DurabilityTier {
879        lashlang_durability_tier(self.artifact_store.durability_tier())
880    }
881}
882
883mod bridge;
884mod catalogue_preview;
885mod deferred;
886mod process;
887mod typed_output;
888
889pub use bridge::{
890    lashlang_value_to_json, process_event_payload, protocol_tool_output_to_lashlang_value,
891    protocol_tool_reply_to_lashlang_value, sleep_duration_ms,
892};
893pub use catalogue_preview::{
894    CataloguePreviewEntry, CataloguePreviewOptions, DEFAULT_CATALOGUE_PREVIEW_CALL_NAME_LIMIT,
895    DEFAULT_CATALOGUE_PREVIEW_MODULE_LIMIT, catalogue_preview_contribution,
896    catalogue_preview_contribution_for_entries,
897    catalogue_preview_contribution_for_entries_with_options,
898    catalogue_preview_contribution_for_manifests, catalogue_preview_contribution_with_options,
899    catalogue_preview_entries_from_catalog_records, catalogue_preview_entries_from_manifests,
900    catalogue_preview_entry_from_catalog_record, catalogue_preview_entry_from_manifest,
901};
902pub use deferred::{
903    DeferredResolutionLinkKey, DeferredResolutionRecord, DeferredToolResolver, Resolution,
904    SharedDeferredToolResolver, ToolGrant, link_with_deferred_resolution,
905    resolve_and_fold_deferred,
906};
907pub use process::{
908    lashlang_process_event_types, lashlang_process_signal_event_types, lashlang_type_expr_schema,
909    trace_lashlang_main_map,
910};
911pub use typed_output::parse_output_schema;
912
913#[cfg(test)]
914mod tests {
915    use super::*;
916
917    struct EveryNEffectsController(usize);
918
919    #[async_trait::async_trait]
920    impl lash_core::AwaitEventResolver for EveryNEffectsController {}
921
922    #[async_trait::async_trait]
923    impl lash_core::RuntimeEffectController for EveryNEffectsController {
924        fn wants_segment_boundary(
925            &self,
926            progress: &lash_core::SegmentProgress,
927        ) -> Option<lash_core::BoundaryReason> {
928            progress
929                .effects_executed
930                .is_multiple_of(self.0 as u64)
931                .then_some(lash_core::BoundaryReason::JournalBudget)
932        }
933
934        async fn execute_effect(
935            &self,
936            _envelope: lash_core::RuntimeEffectEnvelope,
937            _local_executor: lash_core::RuntimeEffectLocalExecutor<'_>,
938        ) -> Result<lash_core::RuntimeEffectOutcome, lash_core::RuntimeEffectControllerError>
939        {
940            unreachable!("predicate test does not execute effects")
941        }
942    }
943
944    #[test]
945    fn every_n_controller_requests_boundaries_and_inline_default_does_not() {
946        let progress = lash_core::SegmentProgress {
947            effects_executed: 2,
948            journaled_bytes_estimate: None,
949        };
950        assert_eq!(
951            lash_core::RuntimeEffectController::wants_segment_boundary(
952                &EveryNEffectsController(2),
953                &progress,
954            ),
955            Some(lash_core::BoundaryReason::JournalBudget)
956        );
957        assert_eq!(
958            lash_core::RuntimeEffectController::wants_segment_boundary(
959                &lash_core::InlineRuntimeEffectController,
960                &progress,
961            ),
962            None
963        );
964    }
965
966    #[tokio::test(flavor = "current_thread")]
967    async fn foreground_trace_skeleton_is_derived_from_the_workflow_graph() {
968        let source = r#"
969            @label(title: "Seed value")
970            value = 1
971            if true {
972              @label(title: "Selected print")
973              print value
974            } else {
975              @label(title: "Skipped print")
976              print 0
977            }
978            count = 0
979            while count < 1 {
980              @label(title: "Loop print")
981              print count
982              count = count + 1
983            }
984            @label(title: "Finish value")
985            finish value
986        "#;
987        let environment = LashlangHostEnvironment::new(
988            lashlang::LashlangHostCatalog::new(),
989            LashlangAbilities::all(),
990        )
991        .with_language_features(
992            lashlang::LashlangLanguageFeatures::default().with_label_annotations(),
993        );
994        let output = lashlang::compile_module(lashlang::ModuleCompileRequest {
995            source,
996            environment: &environment,
997            artifact_store: None,
998        })
999        .await
1000        .expect("labeled workflow compiles");
1001        let graph = lashlang::workflow_graph_from_source(source).expect("workflow graph projects");
1002        let trace_map = trace_lashlang_main_map(&output.artifact);
1003
1004        let expected_nodes = graph
1005            .nodes()
1006            .flat_map(|node| &node.execution_sites)
1007            .map(|site| {
1008                lashlang::runtime_execution_site_for_workflow_site(&output.artifact, site)
1009                    .expect("workflow execution site should exist in the compiled artifact")
1010                    .node_id
1011            })
1012            .collect::<std::collections::BTreeSet<_>>();
1013        let actual_nodes = trace_map
1014            .nodes
1015            .iter()
1016            .map(|node| node.id.clone())
1017            .collect::<std::collections::BTreeSet<_>>();
1018
1019        assert!(!expected_nodes.is_empty());
1020        assert_eq!(actual_nodes, expected_nodes);
1021        assert!(
1022            trace_map
1023                .nodes
1024                .iter()
1025                .any(|node| node.label == "Selected print")
1026        );
1027        assert!(
1028            trace_map
1029                .nodes
1030                .iter()
1031                .any(|node| node.label == "Loop print")
1032        );
1033    }
1034
1035    #[test]
1036    fn process_input_serializes_as_generic_engine_payload() {
1037        let hash = lashlang::ContentHash::new("abc123");
1038        let input = LashlangProcessInput {
1039            module_ref: lashlang::ModuleRef::new(&hash),
1040            process_ref: lashlang::ProcessRef::new(hash.clone(), 7),
1041            host_requirements_ref: lashlang::HostRequirementsRef::new(&hash),
1042            process_name: "main".to_string(),
1043            args: serde_json::Map::from_iter([("prompt".to_string(), serde_json::json!("go"))]),
1044        };
1045
1046        let process_input = input
1047            .clone()
1048            .into_process_input()
1049            .expect("lashlang process input serializes");
1050
1051        let lash_core::ProcessInput::Engine { kind, payload } = process_input else {
1052            panic!("lashlang runtime must use the generic engine process input");
1053        };
1054        assert_eq!(kind, LASHLANG_ENGINE_KIND);
1055        assert_eq!(
1056            LashlangProcessInput::from_payload(payload)
1057                .expect("engine payload decodes")
1058                .process_name,
1059            input.process_name
1060        );
1061    }
1062
1063    #[test]
1064    fn process_input_remote_helpers_use_generic_engine_and_identity() {
1065        let hash = lashlang::ContentHash::new("abc123");
1066        let input = LashlangProcessInput {
1067            module_ref: lashlang::ModuleRef::new(&hash),
1068            process_ref: lashlang::ProcessRef::new(hash.clone(), 7),
1069            host_requirements_ref: lashlang::HostRequirementsRef::new(&hash),
1070            process_name: "main".to_string(),
1071            args: serde_json::Map::from_iter([("prompt".to_string(), serde_json::json!("go"))]),
1072        };
1073
1074        let remote_input: lash_remote_protocol::RemoteProcessInput = input
1075            .clone()
1076            .try_into()
1077            .expect("lashlang process input serializes remotely");
1078        let lash_remote_protocol::RemoteProcessInput::Engine { kind, payload } = remote_input
1079        else {
1080            panic!("lashlang runtime must use the generic remote engine process input");
1081        };
1082        assert_eq!(kind, LASHLANG_ENGINE_KIND);
1083        assert_eq!(
1084            LashlangProcessInput::from_payload(payload)
1085                .expect("remote payload decodes")
1086                .process_name,
1087            "main"
1088        );
1089
1090        let identity = input.process_identity();
1091        assert_eq!(identity.kind, LASHLANG_ENGINE_KIND);
1092        assert_eq!(identity.label.as_deref(), Some("main"));
1093        assert_eq!(input.remote_identity().label.as_deref(), Some("main"));
1094
1095        let draft = input
1096            .remote_trigger_subscription_draft(
1097                lash_remote_protocol::RemoteProcessOriginator::Host { scope: None },
1098                "process-env:sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1099                    .parse()
1100                    .expect("canonical env ref"),
1101                "ui.button.pressed",
1102                "source-key",
1103            )
1104            .expect("remote trigger draft");
1105        draft.validate().expect("draft validates");
1106        assert_eq!(draft.target_label.as_deref(), Some("main"));
1107        assert_eq!(draft.target_identity.label.as_deref(), Some("main"));
1108    }
1109
1110    #[test]
1111    fn missing_tool_binding_is_not_fabricated() {
1112        let tool = lash_core::ToolDefinition::raw(
1113            "tool:test/read_file",
1114            "read_file",
1115            "read a file",
1116            lash_core::ToolDefinition::default_input_schema(),
1117            serde_json::Value::Null,
1118        );
1119
1120        let err = required_tool_lashlang_executable(&tool.manifest)
1121            .expect_err("missing explicit binding should fail");
1122
1123        assert!(err.contains("missing an explicit `lashlang.tool` binding"));
1124    }
1125
1126    #[test]
1127    fn explicit_tool_binding_attaches_lashlang_metadata() {
1128        let tool = lash_core::ToolDefinition::raw(
1129            "tool:test/read_file",
1130            "read_file",
1131            "read a file",
1132            lash_core::ToolDefinition::default_input_schema(),
1133            serde_json::Value::Null,
1134        )
1135        .with_lashlang_binding(
1136            LashlangToolBinding::new(["fs"], "read")
1137                .with_authority_type("Filesystem")
1138                .with_aliases(["cat"]),
1139        );
1140
1141        let binding =
1142            required_tool_lashlang_executable(&tool.manifest).expect("explicit binding resolves");
1143
1144        assert_eq!(binding.module_path, vec!["fs"]);
1145        assert_eq!(binding.operation, "read");
1146        assert_eq!(binding.authority_type, "Filesystem");
1147        assert_eq!(binding.aliases, vec!["cat"]);
1148    }
1149
1150    #[test]
1151    fn tool_catalog_imports_declared_static_schema_types() {
1152        let tool = lash_core::ToolDefinition::raw(
1153            "tool:test/read_file",
1154            "read_file",
1155            "read a file",
1156            serde_json::json!({
1157                "type": "object",
1158                "properties": {
1159                    "path": { "type": "string" },
1160                    "retries": { "type": "integer" }
1161                },
1162                "required": ["path"],
1163                "additionalProperties": false
1164            }),
1165            serde_json::json!({
1166                "type": "array",
1167                "items": { "type": ["string", "null"] }
1168            }),
1169        )
1170        .with_lashlang_binding(
1171            LashlangToolBinding::new(["fs"], "read").with_authority_type("Filesystem"),
1172        );
1173        let catalog = lash_core::ToolCatalog::from_tool_definitions(vec![tool]);
1174
1175        let resources =
1176            lashlang_resources_from_tool_catalog(&catalog).expect("tool schemas import");
1177        let operation = resources
1178            .resolve_operation("Filesystem", "read")
1179            .expect("operation is registered");
1180
1181        assert_eq!(
1182            operation.input_ty,
1183            lashlang::TypeExpr::Object(vec![
1184                lashlang::TypeField {
1185                    name: "path".into(),
1186                    ty: lashlang::TypeExpr::Str,
1187                    optional: false,
1188                },
1189                lashlang::TypeField {
1190                    name: "retries".into(),
1191                    ty: lashlang::TypeExpr::Int,
1192                    optional: true,
1193                },
1194            ])
1195        );
1196        assert_eq!(
1197            operation.output_ty,
1198            lashlang::TypeExpr::List(Box::new(lashlang::TypeExpr::Union(vec![
1199                lashlang::TypeExpr::Str,
1200                lashlang::TypeExpr::Null,
1201            ])))
1202        );
1203    }
1204
1205    #[test]
1206    fn from_input_schema_tool_imports_contract_marker_and_default() {
1207        let tool = lash_core::ToolDefinition::raw(
1208            "tool:test/generate",
1209            "generate",
1210            "generate typed output",
1211            serde_json::json!({
1212                "type": "object",
1213                "properties": { "schema": {} },
1214                "required": ["schema"],
1215                "additionalProperties": false
1216            }),
1217            serde_json::json!({ "type": "string" }),
1218        )
1219        .with_output_from_input_schema("schema", Some(serde_json::json!({ "type": "string" })))
1220        .with_lashlang_binding(
1221            LashlangToolBinding::new(["generate"], "run").with_authority_type("Generator"),
1222        );
1223        let catalog = lash_core::ToolCatalog::from_tool_definitions(vec![tool]);
1224
1225        let resources =
1226            lashlang_resources_from_tool_catalog(&catalog).expect("tool schemas import");
1227        let operation = resources
1228            .resolve_operation("Generator", "run")
1229            .expect("operation is registered");
1230
1231        assert_eq!(
1232            operation.input_ty,
1233            lashlang::TypeExpr::Object(vec![lashlang::TypeField {
1234                name: "schema".into(),
1235                ty: lashlang::TypeExpr::Any,
1236                optional: false,
1237            }])
1238        );
1239        assert_eq!(operation.output_ty, lashlang::TypeExpr::Any);
1240        assert_eq!(
1241            operation.output_from_input,
1242            Some(lashlang::OutputFromInputBinding {
1243                input_field: "schema".to_string(),
1244                default_schema: Some(lashlang::TypeExpr::Str),
1245            })
1246        );
1247    }
1248
1249    #[test]
1250    fn representable_type_schema_subset_round_trips() {
1251        let types = [
1252            lashlang::TypeExpr::Any,
1253            lashlang::TypeExpr::Str,
1254            lashlang::TypeExpr::Int,
1255            lashlang::TypeExpr::Float,
1256            lashlang::TypeExpr::Bool,
1257            lashlang::TypeExpr::Null,
1258            lashlang::TypeExpr::Enum(vec!["fast".into(), "safe".into()]),
1259            lashlang::TypeExpr::List(Box::new(lashlang::TypeExpr::Str)),
1260            lashlang::TypeExpr::Union(vec![lashlang::TypeExpr::Str, lashlang::TypeExpr::Null]),
1261        ];
1262
1263        for expected in types {
1264            let schema = lashlang_type_expr_schema(&expected);
1265            assert_eq!(lashlang::json_schema_to_type_expr(&schema), expected);
1266        }
1267    }
1268
1269    #[test]
1270    fn dotted_operation_names_are_rejected() {
1271        let tool = lash_core::ToolDefinition::raw(
1272            "tool:test/update_plan",
1273            "update_plan",
1274            "update a plan",
1275            lash_core::ToolDefinition::default_input_schema(),
1276            serde_json::Value::Null,
1277        )
1278        .with_lashlang_binding(LashlangToolBinding::new(["tools"], "update.plan"));
1279
1280        let err = required_tool_lashlang_executable(&tool.manifest)
1281            .expect_err("dotted operation cannot compile as one Lashlang operation");
1282
1283        assert!(err.contains("invalid Lashlang operation name `update.plan`"));
1284    }
1285
1286    #[test]
1287    fn manifest_lashlang_binding_accessor_reports_absent_valid_and_malformed() {
1288        let mut manifest = lash_core::ToolDefinition::raw(
1289            "tool:test/read_file",
1290            "read_file",
1291            "read a file",
1292            lash_core::ToolDefinition::default_input_schema(),
1293            serde_json::Value::Null,
1294        )
1295        .manifest;
1296        assert_eq!(manifest.lashlang_binding().expect("absent binding"), None);
1297
1298        manifest.bindings.insert(
1299            LASHLANG_TOOL_BINDING_KEY.to_string(),
1300            serde_json::json!({
1301                "module_path": ["fs"],
1302                "operation": "read"
1303            }),
1304        );
1305        let binding = manifest
1306            .lashlang_binding()
1307            .expect("valid binding")
1308            .expect("present binding");
1309        assert_eq!(binding.module_path, vec!["fs"]);
1310        assert_eq!(binding.operation.as_deref(), Some("read"));
1311
1312        manifest.bindings.insert(
1313            LASHLANG_TOOL_BINDING_KEY.to_string(),
1314            serde_json::json!({ "module_path": "fs" }),
1315        );
1316        assert!(manifest.lashlang_binding().is_err());
1317    }
1318
1319    #[test]
1320    fn remote_grant_lashlang_binding_accessor_reports_absent_valid_and_malformed() {
1321        let grant = remote_tool_grant("read_file");
1322        assert_eq!(grant.lashlang_binding().expect("absent binding"), None);
1323
1324        let grant = grant.with_lashlang_binding(LashlangToolBinding::new(["fs"], "read"));
1325        let binding = grant
1326            .lashlang_binding()
1327            .expect("valid binding")
1328            .expect("present binding");
1329        assert_eq!(binding.module_path, vec!["fs"]);
1330        assert_eq!(binding.operation.as_deref(), Some("read"));
1331
1332        let mut malformed = grant;
1333        malformed.bindings.insert(
1334            LASHLANG_TOOL_BINDING_KEY.to_string(),
1335            serde_json::json!({ "module_path": "fs" }),
1336        );
1337        assert!(malformed.lashlang_binding().is_err());
1338    }
1339
1340    #[test]
1341    fn deterministic_process_id_reuses_replayed_start_site_and_args() {
1342        let input = test_process_input(serde_json::json!({ "root": "." }));
1343        let site = test_start_site("child_process:scan", 1);
1344
1345        let first = deterministic_lashlang_process_id("parent:root", &site, &input)
1346            .expect("process id derives");
1347        let second = deterministic_lashlang_process_id("parent:root", &site, &input)
1348            .expect("process id derives");
1349
1350        assert_eq!(first, second);
1351        assert!(first.starts_with("process:lashlang:sha256:"));
1352    }
1353
1354    #[test]
1355    fn deterministic_process_id_separates_parallel_sites_ordinals_and_parents() {
1356        let input = test_process_input(serde_json::json!({ "root": "." }));
1357        let left = deterministic_lashlang_process_id(
1358            "parent:root",
1359            &test_start_site("child_process:left", 1),
1360            &input,
1361        )
1362        .expect("left id derives");
1363        let right = deterministic_lashlang_process_id(
1364            "parent:root",
1365            &test_start_site("child_process:right", 1),
1366            &input,
1367        )
1368        .expect("right id derives");
1369        let second_ordinal = deterministic_lashlang_process_id(
1370            "parent:root",
1371            &test_start_site("child_process:left", 2),
1372            &input,
1373        )
1374        .expect("second ordinal id derives");
1375        let nested_parent = deterministic_lashlang_process_id(
1376            "parent:nested",
1377            &test_start_site("child_process:left", 1),
1378            &input,
1379        )
1380        .expect("nested parent id derives");
1381
1382        assert_ne!(left, right);
1383        assert_ne!(left, second_ordinal);
1384        assert_ne!(left, nested_parent);
1385    }
1386
1387    #[tokio::test(flavor = "current_thread")]
1388    async fn prepared_start_replays_same_registration_id_without_duplicate_child_identity() {
1389        let store = Arc::new(InMemoryLashlangArtifactStore::new());
1390        let environment = LashlangHostEnvironment::new(
1391            lashlang::LashlangHostCatalog::new(),
1392            LashlangAbilities::default().with_processes(),
1393        );
1394        let output = lashlang::compile_module(lashlang::ModuleCompileRequest {
1395            source: r#"process scan(root: str) -> str { finish root }"#,
1396            environment: &environment,
1397            artifact_store: Some(store.as_ref()),
1398        })
1399        .await
1400        .expect("module compiles and persists");
1401        let artifact_store: Arc<dyn LashlangArtifactStore> = store;
1402        let site = test_start_site("child_process:scan", 1);
1403
1404        let first = prepare_lashlang_process_start(
1405            Arc::clone(&artifact_store),
1406            "parent:root",
1407            test_process_start(&output, site.clone(), "."),
1408        )
1409        .await
1410        .expect("first start prepares");
1411        let replayed = prepare_lashlang_process_start(
1412            Arc::clone(&artifact_store),
1413            "parent:root",
1414            test_process_start(&output, site.clone(), "."),
1415        )
1416        .await
1417        .expect("replayed start prepares");
1418        let sibling = prepare_lashlang_process_start(
1419            Arc::clone(&artifact_store),
1420            "parent:root",
1421            test_process_start(&output, test_start_site("child_process:scan", 2), "."),
1422        )
1423        .await
1424        .expect("sibling start prepares");
1425
1426        assert_eq!(first.registration.id, replayed.registration.id);
1427        assert_eq!(first.registration.identity, replayed.registration.identity);
1428        assert_ne!(first.registration.id, sibling.registration.id);
1429    }
1430
1431    #[test]
1432    fn surface_merges_plugin_extensions() {
1433        let contribution = LashlangSurfaceContribution::new(
1434            LashlangAbilities::default().with_processes(),
1435            LashlangLanguageFeatures::default().with_label_annotations(),
1436            LashlangHostCatalog::tool_default(["lookup"]),
1437        );
1438        let extensions = lash_core::PluginExtensions::from_contributions([
1439            lash_core::PluginExtensionContribution::new(
1440                LASHLANG_SURFACE_EXTENSION_ID,
1441                contribution,
1442            )
1443            .expect("extension payload serializes"),
1444        ]);
1445
1446        let surface = LashlangSurface::default()
1447            .with_plugin_extensions(&extensions)
1448            .expect("lashlang surface extension merges");
1449        let environment = surface
1450            .host_environment(&lash_core::ToolCatalog::default())
1451            .expect("empty tool catalog has no Lashlang bindings to validate");
1452
1453        assert!(environment.abilities.sleep);
1454        assert!(environment.abilities.processes);
1455        assert!(environment.language_features.label_annotations);
1456        assert!(
1457            environment
1458                .resources
1459                .resolve_module_operation("Tools", "tools", "lookup")
1460                .is_some()
1461        );
1462    }
1463
1464    fn remote_tool_grant(name: &str) -> lash_remote_protocol::RemoteToolGrant {
1465        lash_remote_protocol::RemoteToolGrant {
1466            protocol_version: lash_remote_protocol::REMOTE_PROTOCOL_VERSION,
1467            id: format!("remote-tool:{name}"),
1468            name: name.to_string(),
1469            description: String::new(),
1470            input_schema: lash_remote_protocol::RemoteSchemaContract {
1471                canonical: lash_core::ToolDefinition::default_input_schema(),
1472                projection: lash_remote_protocol::RemoteSchemaProjectionPolicy::default(),
1473            },
1474            output_schema: lash_remote_protocol::RemoteSchemaContract::default(),
1475            output_contract: lash_remote_protocol::RemoteToolOutputContract::Static,
1476            examples: Vec::new(),
1477            activation: None,
1478            argument_projection: None,
1479            scheduling: None,
1480            retry_policy: None,
1481            bindings: Default::default(),
1482        }
1483    }
1484
1485    fn test_process_input(args: serde_json::Value) -> LashlangProcessInput {
1486        let hash = lashlang::ContentHash::new("abc123");
1487        let args = args
1488            .as_object()
1489            .expect("test args must be an object")
1490            .clone();
1491        LashlangProcessInput {
1492            module_ref: lashlang::ModuleRef::new(&hash),
1493            process_ref: lashlang::ProcessRef::new(hash.clone(), 7),
1494            host_requirements_ref: lashlang::HostRequirementsRef::new(&hash),
1495            process_name: "scan".to_string(),
1496            args,
1497        }
1498    }
1499
1500    fn test_start_site(node_id: &str, occurrence: u64) -> lashlang::LashlangExecutionCallSite {
1501        lashlang::LashlangExecutionCallSite {
1502            site: lashlang::LashlangExecutionSite {
1503                node_id: node_id.to_string(),
1504                node_kind: "child_process".to_string(),
1505                label: "start scan".to_string(),
1506                branch: None,
1507                workflow_site: lashlang::WorkflowExecutionSite::new(
1508                    "process:scan",
1509                    [],
1510                    "child_process",
1511                    "start scan",
1512                ),
1513            },
1514            occurrence,
1515        }
1516    }
1517
1518    fn test_process_start(
1519        output: &lashlang::ModuleCompileOutput,
1520        start_site: lashlang::LashlangExecutionCallSite,
1521        root: &str,
1522    ) -> lashlang::ProcessStart {
1523        let mut args = lashlang::Record::new();
1524        args.insert("root".to_string(), lashlang::Value::String(root.into()));
1525        lashlang::ProcessStart {
1526            module_ref: output.module_ref.clone(),
1527            process_ref: output
1528                .artifact
1529                .process_ref("scan")
1530                .expect("scan process export")
1531                .clone(),
1532            host_requirements_ref: output.host_requirements_ref.clone(),
1533            start_site,
1534            process_name: "scan".to_string(),
1535            args,
1536        }
1537    }
1538}