1use murmur_artifact::{
9 current_platform, version_fit, FitSubject, VersionFit, WitContracts, WitLevel,
10};
11use wasmtime::component::{Component, Linker, ResourceTable};
12use wasmtime::{Engine, Store};
13use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
14
15use crate::bindings::process_driver::ProcessDriver as ProcessDriverBindings;
16use crate::errors::RuntimeError;
17use crate::limits::{classify_guest_failure, EpochTicker, ExecutionLimiter, GuestFailure};
18use crate::ExecutionLimits;
19
20pub use crate::bindings::process_driver::exports::murmur::driver::process::{
21 Bridge, Description, DriverFile, Event, ExitStatus, FailureKind, InterruptMethod, LaunchPlan,
22 LaunchRequest, RetryInfo, Session, SessionInfo, SessionMode, ToolCallInfo, ToolCallProgress,
23 ToolCallStart, ToolResultInfo, TurnFailure, Usage,
24};
25
26pub const PROCESS_DRIVER_IFACE: &str = "murmur:driver/process@0.3.0";
28
29const PROCESS_DRIVER_IFACE_PREFIX: &str = "murmur:driver/process@";
32
33pub fn check_driver_interface(
46 transport: &str,
47 name: &str,
48 version: &str,
49 contracts: Option<&WitContracts>,
50 level: &WitLevel,
51) -> Result<(), RuntimeError> {
52 let process_exports: Vec<String> = contracts
53 .map(|c| {
54 c.exports
55 .iter()
56 .filter(|export| export.starts_with(PROCESS_DRIVER_IFACE_PREFIX))
57 .cloned()
58 .collect()
59 })
60 .unwrap_or_default();
61 let expected = level_interface(level, "murmur:driver", "process")
62 .unwrap_or_else(|| PROCESS_DRIVER_IFACE.to_string());
63 match transport {
64 "process" if process_exports.contains(&expected) => Ok(()),
65 "process" if process_exports.is_empty() => {
66 Err(RuntimeError::ProcessDriverInterfaceMissing {
67 name: name.to_string(),
68 version: version.to_string(),
69 expected,
70 })
71 }
72 "process" => Err(
75 level_mismatch(name, version, level, process_exports, Vec::new()).unwrap_or_else(
76 || RuntimeError::ProcessDriverInterfaceMissing {
77 name: name.to_string(),
78 version: version.to_string(),
79 expected,
80 },
81 ),
82 ),
83 "http" if !process_exports.is_empty() => {
84 Err(RuntimeError::HttpDriverExportsProcessInterface {
85 name: name.to_string(),
86 version: version.to_string(),
87 exported: process_exports,
88 })
89 }
90 _ => Ok(()),
91 }
92}
93
94const RETIRED_TEXT_IFACE_PREFIX: &str = "murmur:text/";
97
98const STREAM_IFACE_PREFIX: &str = "murmur:stream/";
100
101pub fn check_stream_interface(
116 name: &str,
117 version: &str,
118 contracts: Option<&WitContracts>,
119 level: &WitLevel,
120) -> Result<(), RuntimeError> {
121 let expected = level_interface(level, "murmur:stream", "events")
122 .unwrap_or_else(|| crate::runtime::WIT_STREAM_EVENTS_IFACE.to_string());
123 let imported: Vec<String> = contracts
124 .map(|c| {
125 c.imports
126 .iter()
127 .filter(|import| {
128 import.starts_with(RETIRED_TEXT_IFACE_PREFIX)
129 || (import.starts_with(STREAM_IFACE_PREFIX) && **import != expected)
130 })
131 .cloned()
132 .collect()
133 })
134 .unwrap_or_default();
135 if imported.is_empty() {
136 return Ok(());
137 }
138 if let Some(mismatch) = level_mismatch(name, version, level, Vec::new(), imported.clone()) {
139 return Err(mismatch);
140 }
141 Err(RuntimeError::DriverStreamInterfaceNotServed {
142 name: name.to_string(),
143 version: version.to_string(),
144 imported,
145 expected,
146 })
147}
148
149pub(crate) fn driver_refusal(
153 stream: Result<(), RuntimeError>,
154 driver: Result<(), RuntimeError>,
155) -> Result<(), RuntimeError> {
156 match (stream, driver) {
157 (
158 Err(RuntimeError::WitLevelMismatch {
159 mismatched: imports,
160 ..
161 }),
162 Err(RuntimeError::WitLevelMismatch {
163 name,
164 version,
165 level,
166 mismatched: mut exports,
167 }),
168 ) => {
169 exports.extend(imports);
170 Err(RuntimeError::WitLevelMismatch {
171 name,
172 version,
173 level,
174 mismatched: exports,
175 })
176 }
177 (Err(_), Err(driver @ RuntimeError::WitLevelMismatch { .. })) => Err(driver),
178 (Err(stream), _) => Err(stream),
179 (Ok(()), driver) => driver,
180 }
181}
182
183fn level_interface(level: &WitLevel, package: &str, interface: &str) -> Option<String> {
185 level
186 .version_of(package)
187 .map(|version| format!("{package}/{interface}@{version}"))
188}
189
190fn level_mismatch(
193 name: &str,
194 version: &str,
195 level: &WitLevel,
196 exports: Vec<String>,
197 imports: Vec<String>,
198) -> Option<RuntimeError> {
199 let subject = FitSubject::component(WitContracts { exports, imports });
200 match version_fit(&subject, level, current_platform()) {
202 VersionFit::Unserved(mismatched) => Some(RuntimeError::WitLevelMismatch {
203 name: name.to_string(),
204 version: version.to_string(),
205 level: level.level,
206 mismatched,
207 }),
208 VersionFit::Fits | VersionFit::NoPayloadFor(_) => None,
209 }
210}
211
212pub fn check_required_env(
217 name: &str,
218 version: &str,
219 description: &Description,
220 env_allow: &[String],
221) -> Result<(), RuntimeError> {
222 let mut missing: Vec<String> = Vec::new();
223 for var in &description.required_env {
224 if !env_allow.contains(var) && !missing.contains(var) {
225 missing.push(var.clone());
226 }
227 }
228 if missing.is_empty() {
229 Ok(())
230 } else {
231 Err(RuntimeError::ProcessDriverRequiredEnvNotAllowed {
232 name: name.to_string(),
233 version: version.to_string(),
234 missing,
235 })
236 }
237}
238
239struct ProcessDriverStoreState {
241 wasi: WasiCtx,
242 table: ResourceTable,
243 limits: ExecutionLimiter,
244}
245
246impl WasiView for ProcessDriverStoreState {
247 fn ctx(&mut self) -> WasiCtxView<'_> {
248 WasiCtxView {
249 ctx: &mut self.wasi,
250 table: &mut self.table,
251 }
252 }
253}
254
255pub struct ProcessDriver {
258 name: String,
259 version: String,
260 store: Store<ProcessDriverStoreState>,
261 bindings: ProcessDriverBindings,
262}
263
264impl ProcessDriver {
265 pub async fn instantiate(
274 engine: &Engine,
275 component: &Component,
276 name: &str,
277 version: &str,
278 ) -> Result<Self, RuntimeError> {
279 let load_error = |message: String| RuntimeError::ProcessDriverLoad {
280 name: name.to_string(),
281 version: version.to_string(),
282 message,
283 };
284 let limits = ExecutionLimits::default().for_hook_calls(None);
285
286 let mut linker: Linker<ProcessDriverStoreState> = Linker::new(engine);
287 wasmtime_wasi::p2::add_to_linker_async(&mut linker)
288 .map_err(|e| load_error(e.to_string()))?;
289
290 let state = ProcessDriverStoreState {
291 wasi: WasiCtxBuilder::new().build(),
294 table: ResourceTable::new(),
295 limits: limits.limiter(),
296 };
297 let mut store = Store::new(engine, state);
298 store.limiter(|state| &mut state.limits);
299 store.set_epoch_deadline(limits.deadline_ticks());
300
301 let bindings = ProcessDriverBindings::instantiate_async(&mut store, component, &linker)
302 .await
303 .map_err(|e| load_error(format!("{e:#}")))?;
304 Ok(Self {
305 name: name.to_string(),
306 version: version.to_string(),
307 store,
308 bindings,
309 })
310 }
311
312 pub async fn describe(&mut self) -> Result<Description, RuntimeError> {
317 self.reset_deadline();
318 let description = self
319 .bindings
320 .murmur_driver_process()
321 .call_describe(&mut self.store)
322 .await
323 .map_err(|e| {
324 let message = match classify_guest_failure(&e, &self.store.data().limits) {
325 GuestFailure::DeadlineExceeded { seconds } => {
326 format!("describe() exceeded its {seconds}s deadline and was interrupted")
327 }
328 GuestFailure::ResourceLimit { message } => {
329 format!("describe() exceeded its resource limits: {message}")
330 }
331 GuestFailure::Other => format!("describe() trapped: {e:#}"),
332 };
333 self.load_error(message)
334 })?;
335 if let Some(bad) = unusable_env_name(&description) {
336 return Err(self.load_error(format!(
337 "describe() names {bad:?} in required-env, which is not a usable variable name"
338 )));
339 }
340 Ok(description)
341 }
342
343 pub async fn launch(
346 &mut self,
347 request: LaunchRequest,
348 ) -> Result<Result<LaunchPlan, String>, RuntimeError> {
349 self.reset_deadline();
350 self.bindings
351 .murmur_driver_process()
352 .call_launch(&mut self.store, &request)
353 .await
354 .map_err(|e| self.call_error("launch", &e))
355 }
356
357 pub async fn parse(&mut self, lines: Vec<String>) -> Result<Vec<Event>, RuntimeError> {
360 self.reset_deadline();
361 self.bindings
362 .murmur_driver_process()
363 .call_parse(&mut self.store, &lines)
364 .await
365 .map_err(|e| self.call_error("parse", &e))
366 }
367
368 pub async fn classify_exit(&mut self, exit: ExitStatus) -> Result<Event, RuntimeError> {
370 self.reset_deadline();
371 self.bindings
372 .murmur_driver_process()
373 .call_classify_exit(&mut self.store, &exit)
374 .await
375 .map_err(|e| self.call_error("classify-exit", &e))
376 }
377
378 fn reset_deadline(&mut self) {
382 let deadline = self.store.data().limits.limits().deadline_ticks();
383 self.store.set_epoch_deadline(deadline);
384 }
385
386 fn call_error(&self, call: &str, error: &wasmtime::Error) -> RuntimeError {
387 let message = match classify_guest_failure(error, &self.store.data().limits) {
388 GuestFailure::DeadlineExceeded { seconds } => {
389 format!("exceeded its {seconds}s deadline and was interrupted")
390 }
391 GuestFailure::ResourceLimit { message } => {
392 format!("exceeded its resource limits: {message}")
393 }
394 GuestFailure::Other => format!("trapped: {error:#}"),
395 };
396 RuntimeError::ProcessDriverCallFailed {
397 name: self.name.clone(),
398 version: self.version.clone(),
399 call: call.to_string(),
400 message,
401 }
402 }
403
404 fn load_error(&self, message: String) -> RuntimeError {
405 RuntimeError::ProcessDriverLoad {
406 name: self.name.clone(),
407 version: self.version.clone(),
408 message,
409 }
410 }
411}
412
413pub(crate) fn is_usable_env_name(name: &str) -> bool {
420 !name.is_empty() && !name.contains('=') && !name.contains('\0')
421}
422
423fn unusable_env_name(description: &Description) -> Option<&str> {
426 description
427 .required_env
428 .iter()
429 .find(|var| !is_usable_env_name(var))
430 .map(String::as_str)
431}
432
433const STANDALONE_NAME: &str = "<wasm>";
436const STANDALONE_VERSION: &str = "";
437
438struct StandaloneDriver {
441 engine: Engine,
442 component: Component,
443 rt: tokio::runtime::Runtime,
444 _ticker: EpochTicker,
445}
446
447impl StandaloneDriver {
448 fn compile(wasm: &[u8]) -> Result<Self, RuntimeError> {
449 let engine = crate::runtime::build_engine()?;
450 let ticker = EpochTicker::spawn(&engine);
451 let component =
452 Component::new(&engine, wasm).map_err(|e| RuntimeError::ToolComponentCompile {
453 name: STANDALONE_NAME.to_string(),
454 version: STANDALONE_VERSION.to_string(),
455 message: e.to_string(),
456 })?;
457 let rt = tokio::runtime::Builder::new_current_thread()
458 .enable_all()
459 .build()
460 .map_err(|e| {
461 RuntimeError::Runtime(format!("failed to build process driver runtime: {e}"))
462 })?;
463 Ok(StandaloneDriver {
464 engine,
465 component,
466 rt,
467 _ticker: ticker,
468 })
469 }
470
471 async fn instantiate(&self) -> Result<ProcessDriver, RuntimeError> {
472 ProcessDriver::instantiate(
473 &self.engine,
474 &self.component,
475 STANDALONE_NAME,
476 STANDALONE_VERSION,
477 )
478 .await
479 }
480}
481
482pub fn describe_process_driver_wasm(wasm: &[u8]) -> Result<Description, RuntimeError> {
489 let driver = StandaloneDriver::compile(wasm)?;
490 driver.rt.block_on(async {
491 let mut instance = driver.instantiate().await?;
492 instance.describe().await
493 })
494}
495
496pub fn launch_and_parse_process_driver_wasm(
503 wasm: &[u8],
504 request: LaunchRequest,
505 lines: Vec<String>,
506) -> Result<(Result<LaunchPlan, String>, Vec<Event>), RuntimeError> {
507 let driver = StandaloneDriver::compile(wasm)?;
508 driver.rt.block_on(async {
509 let mut instance = driver.instantiate().await?;
510 let plan = instance.launch(request).await?;
511 let events = instance.parse(lines).await?;
512 Ok((plan, events))
513 })
514}
515
516#[cfg(test)]
517mod tests {
518 use murmur_artifact::ContractDirection;
519
520 use super::*;
521
522 fn served() -> &'static WitLevel {
523 crate::served_wit_level()
524 }
525
526 fn contracts(exports: &[&str]) -> WitContracts {
527 WitContracts {
528 exports: exports.iter().map(|e| e.to_string()).collect(),
529 imports: Vec::new(),
530 }
531 }
532
533 #[test]
534 fn process_driver_interface_process_transport_accepts_the_process_export() {
535 let c = contracts(&[PROCESS_DRIVER_IFACE]);
536 assert!(check_driver_interface("process", "d", "1.0.0", Some(&c), served()).is_ok());
537 }
538
539 #[test]
540 fn process_driver_interface_process_transport_refuses_a_tool_export() {
541 let c = contracts(&["murmur:tool/run@0.1.0"]);
542 let err = check_driver_interface("process", "d", "1.0.0", Some(&c), served()).unwrap_err();
543 match &err {
544 RuntimeError::ProcessDriverInterfaceMissing { expected, .. } => {
545 assert_eq!(expected, PROCESS_DRIVER_IFACE);
546 }
547 other => panic!("unexpected error: {other:?}"),
548 }
549 let message = err.to_string();
550 assert!(message.contains("'d@1.0.0'"), "{message}");
551 assert!(message.contains(PROCESS_DRIVER_IFACE), "{message}");
552 }
553
554 #[test]
555 fn process_driver_interface_process_transport_refuses_another_version_as_a_level_mismatch() {
556 let c = contracts(&["murmur:driver/process@0.0.1"]);
557 let err = check_driver_interface("process", "d", "1.0.0", Some(&c), served()).unwrap_err();
558 match &err {
559 RuntimeError::WitLevelMismatch {
560 level, mismatched, ..
561 } => {
562 assert_eq!(*level, served().level);
563 assert_eq!(mismatched.len(), 1);
564 assert_eq!(mismatched[0].interface, "murmur:driver/process@0.0.1");
565 assert_eq!(mismatched[0].direction, ContractDirection::Export);
566 }
567 other => panic!("unexpected error: {other:?}"),
568 }
569 assert_eq!(
570 err.to_string(),
571 format!(
572 "artifact 'd@1.0.0' does not fit WIT level {0}, the level this mur serves: it \
573 exports murmur:driver/process@0.0.1, and level {0} has murmur:driver@{1}",
574 served().level,
575 served().version_of("murmur:driver").unwrap()
576 )
577 );
578 }
579
580 fn level_2() -> WitLevel {
582 let mut level = served().clone();
583 level.level = 2;
584 for (package, version) in &mut level.packages {
585 if package == "murmur:driver" {
586 *version = "0.4.0".to_string();
587 }
588 }
589 level
590 }
591
592 #[test]
593 fn process_driver_interface_follows_the_level_it_is_given() {
594 let c = contracts(&["murmur:driver/process@0.3.0"]);
595 assert!(check_driver_interface("process", "d", "1.0.0", Some(&c), served()).is_ok());
596 match check_driver_interface("process", "d", "1.0.0", Some(&c), &level_2()).unwrap_err() {
597 RuntimeError::WitLevelMismatch {
598 level, mismatched, ..
599 } => {
600 assert_eq!(level, 2);
601 assert_eq!(mismatched[0].direction, ContractDirection::Export);
602 assert_eq!(mismatched[0].package, "murmur:driver");
603 assert_eq!(mismatched[0].served_version.as_deref(), Some("0.4.0"));
604 }
605 other => panic!("unexpected error: {other:?}"),
606 }
607 let stream = importing(&["murmur:stream/events@0.1.0"]);
608 assert!(check_stream_interface("d", "1.0.0", Some(&stream), &level_2()).is_ok());
609 }
610
611 #[test]
612 fn process_driver_interface_process_transport_refuses_no_contracts() {
613 let err = check_driver_interface("process", "d", "1.0.0", None, served()).unwrap_err();
614 assert!(matches!(
615 err,
616 RuntimeError::ProcessDriverInterfaceMissing { .. }
617 ));
618 }
619
620 #[test]
621 fn process_driver_interface_http_transport_refuses_the_process_export() {
622 let c = contracts(&[PROCESS_DRIVER_IFACE]);
623 let err = check_driver_interface("http", "d", "1.0.0", Some(&c), served()).unwrap_err();
624 match &err {
625 RuntimeError::HttpDriverExportsProcessInterface { name, exported, .. } => {
626 assert_eq!(name, "d");
627 assert_eq!(exported, &vec![PROCESS_DRIVER_IFACE.to_string()]);
628 }
629 other => panic!("unexpected error: {other:?}"),
630 }
631 }
632
633 #[test]
634 fn process_driver_interface_http_transport_accepts_a_tool_export() {
635 let c = contracts(&["murmur:tool/run@0.1.0"]);
636 assert!(check_driver_interface("http", "d", "1.0.0", Some(&c), served()).is_ok());
637 }
638
639 fn importing(imports: &[&str]) -> WitContracts {
640 WitContracts {
641 exports: vec!["murmur:tool/run@0.1.0".to_string()],
642 imports: imports.iter().map(|i| i.to_string()).collect(),
643 }
644 }
645
646 #[test]
647 fn stream_interface_refuses_and_lists_a_murmur_text_import() {
648 let c = importing(&[
649 "wasi:cli/environment@0.2.12",
650 "murmur:text/chunks@0.1.0",
651 "murmur:task/task@0.1.0",
652 ]);
653 let err = check_stream_interface("d", "1.0.0", Some(&c), served()).unwrap_err();
654 match &err {
655 RuntimeError::WitLevelMismatch {
656 name,
657 version,
658 mismatched,
659 ..
660 } => {
661 assert_eq!((name.as_str(), version.as_str()), ("d", "1.0.0"));
662 assert_eq!(mismatched.len(), 1);
663 assert_eq!(mismatched[0].interface, "murmur:text/chunks@0.1.0");
664 assert_eq!(mismatched[0].served_version, None);
665 }
666 other => panic!("unexpected error: {other:?}"),
667 }
668 let message = err.to_string();
669 assert!(message.contains("'d@1.0.0'"), "{message}");
670 assert!(
671 message.contains(
672 "it imports murmur:text/chunks@0.1.0, and level 1 has no version of murmur:text"
673 ),
674 "{message}"
675 );
676 }
677
678 #[test]
679 fn stream_interface_refuses_another_version_of_murmur_stream() {
680 let c = importing(&["murmur:stream/events@0.2.0"]);
681 let err = check_stream_interface("d", "1.0.0", Some(&c), served()).unwrap_err();
682 assert!(matches!(
683 err,
684 RuntimeError::WitLevelMismatch { ref mismatched, .. }
685 if mismatched.len() == 1 && mismatched[0].interface == "murmur:stream/events@0.2.0"
686 ));
687 }
688
689 #[test]
692 fn stream_interface_refuses_an_unknown_interface_at_the_served_version_as_not_served() {
693 let c = importing(&["murmur:stream/other@0.1.0"]);
694 let err = check_stream_interface("d", "1.0.0", Some(&c), served()).unwrap_err();
695 match &err {
696 RuntimeError::DriverStreamInterfaceNotServed {
697 imported, expected, ..
698 } => {
699 assert_eq!(imported, &vec!["murmur:stream/other@0.1.0".to_string()]);
700 assert_eq!(expected, crate::runtime::WIT_STREAM_EVENTS_IFACE);
701 }
702 other => panic!("unexpected error: {other:?}"),
703 }
704 let both = importing(&["murmur:stream/other@0.1.0", "murmur:text/chunks@0.1.0"]);
706 assert!(matches!(
707 check_stream_interface("d", "1.0.0", Some(&both), served()).unwrap_err(),
708 RuntimeError::WitLevelMismatch { ref mismatched, .. }
709 if mismatched.len() == 1 && mismatched[0].interface == "murmur:text/chunks@0.1.0"
710 ));
711 }
712
713 #[test]
714 fn driver_refusal_reports_a_level_mismatch_over_any_other() {
715 let not_served = || {
716 check_stream_interface(
717 "d",
718 "1.0.0",
719 Some(&importing(&["murmur:stream/other@0.1.0"])),
720 served(),
721 )
722 };
723 let old_export = || {
724 check_driver_interface(
725 "process",
726 "d",
727 "1.0.0",
728 Some(&contracts(&["murmur:driver/process@0.2.0"])),
729 served(),
730 )
731 };
732 let text_import = || {
733 check_stream_interface(
734 "d",
735 "1.0.0",
736 Some(&importing(&["murmur:text/chunks@0.1.0"])),
737 served(),
738 )
739 };
740 let no_export = || check_driver_interface("process", "d", "1.0.0", None, served());
741
742 assert!(matches!(
743 driver_refusal(not_served(), old_export()),
744 Err(RuntimeError::WitLevelMismatch { .. })
745 ));
746 assert!(matches!(
747 driver_refusal(text_import(), no_export()),
748 Err(RuntimeError::WitLevelMismatch { .. })
749 ));
750 assert!(matches!(
751 driver_refusal(not_served(), no_export()),
752 Err(RuntimeError::DriverStreamInterfaceNotServed { .. })
753 ));
754 match driver_refusal(text_import(), old_export()) {
755 Err(RuntimeError::WitLevelMismatch { mismatched, .. }) => {
756 let names: Vec<&str> = mismatched.iter().map(|m| m.interface.as_str()).collect();
757 assert_eq!(
758 names,
759 ["murmur:driver/process@0.2.0", "murmur:text/chunks@0.1.0"]
760 );
761 }
762 other => panic!("unexpected result: {other:?}"),
763 }
764 assert!(driver_refusal(Ok(()), Ok(())).is_ok());
765 }
766
767 #[test]
768 fn stream_interface_accepts_exactly_the_served_instance() {
769 let c = importing(&["murmur:stream/events@0.1.0", "wasi:http/types@0.2.12"]);
770 assert!(check_stream_interface("d", "1.0.0", Some(&c), served()).is_ok());
771 }
772
773 #[test]
774 fn stream_interface_accepts_a_component_with_no_murmur_import() {
775 let c = importing(&["wasi:cli/environment@0.2.12"]);
776 assert!(check_stream_interface("d", "1.0.0", Some(&c), served()).is_ok());
777 assert!(check_stream_interface("d", "1.0.0", Some(&importing(&[])), served()).is_ok());
778 }
779
780 #[test]
781 fn stream_interface_accepts_no_contracts() {
782 assert!(check_stream_interface("d", "1.0.0", None, served()).is_ok());
783 }
784
785 fn description(required_env: &[&str]) -> Description {
786 Description {
787 harness: "h".to_string(),
788 binary: "b".to_string(),
789 version_args: Vec::new(),
790 tested_versions: Vec::new(),
791 interrupt: InterruptMethod::Unsupported,
792 required_env: required_env.iter().map(|v| v.to_string()).collect(),
793 streams_text: false,
794 reports_usage: false,
795 }
796 }
797
798 #[test]
799 fn required_env_all_declared_passes() {
800 let allow = vec!["A".to_string(), "B".to_string(), "C".to_string()];
801 assert!(check_required_env("d", "1.0.0", &description(&["B", "A"]), &allow).is_ok());
802 }
803
804 #[test]
805 fn required_env_reports_missing_names_in_order_once_each() {
806 let allow = vec!["B".to_string()];
807 let err = check_required_env("d", "1.0.0", &description(&["Z", "B", "A", "Z"]), &allow)
808 .unwrap_err();
809 match &err {
810 RuntimeError::ProcessDriverRequiredEnvNotAllowed { missing, .. } => {
811 assert_eq!(missing, &vec!["Z".to_string(), "A".to_string()]);
812 }
813 other => panic!("unexpected error: {other:?}"),
814 }
815 let message = err.to_string();
816 assert!(message.contains("'d@1.0.0'"), "{message}");
817 assert!(message.contains("Z, A"), "{message}");
818 }
819
820 #[test]
821 fn required_env_usable_names_pass() {
822 assert_eq!(
823 unusable_env_name(&description(&["HOME", "PROFILE_2"])),
824 None
825 );
826 assert_eq!(unusable_env_name(&description(&[])), None);
827 }
828
829 #[test]
830 fn required_env_refuses_a_name_that_is_not_a_variable() {
831 for bad in ["", "A=B", "A\0B"] {
832 assert_eq!(
833 unusable_env_name(&description(&["HOME", bad, "TAIL"])),
834 Some(bad),
835 "{bad:?} should be refused"
836 );
837 }
838 }
839
840 #[test]
846 #[ignore = "a measurement, not an assertion; run with --release --ignored --nocapture"]
847 fn process_driver_parse_cost() {
848 use std::time::Instant;
849
850 const WASM: &[u8] = include_bytes!(concat!(
851 env!("CARGO_MANIFEST_DIR"),
852 "/../murmur-cli/tests/fixtures/process-driver/tool/process-driver.wasm"
853 ));
854 let engine = crate::runtime::build_engine().expect("engine");
855 let _ticker = EpochTicker::spawn(&engine);
856 let component = Component::new(&engine, WASM).expect("fixture compiles");
857 let rt = tokio::runtime::Builder::new_current_thread()
858 .enable_all()
859 .build()
860 .expect("runtime");
861
862 let instantiations = 50;
863 let start = Instant::now();
864 for _ in 0..instantiations {
865 rt.block_on(ProcessDriver::instantiate(
866 &engine, &component, "d", "0.1.0",
867 ))
868 .expect("instantiates");
869 }
870 let per_instantiation = start.elapsed() / instantiations;
871
872 let mut driver = rt
873 .block_on(ProcessDriver::instantiate(
874 &engine, &component, "d", "0.1.0",
875 ))
876 .expect("instantiates");
877 for lines in [1usize, 64] {
878 let batch: Vec<String> = (0..lines).map(|i| format!("text line {i}")).collect();
879 let runs = 2_000;
880 let mut samples = Vec::with_capacity(runs);
881 for _ in 0..runs {
882 let start = Instant::now();
883 let events = rt.block_on(driver.parse(batch.clone())).expect("parses");
884 samples.push(start.elapsed());
885 assert_eq!(events.len(), lines);
886 }
887 samples.sort_unstable();
888 let median = samples[samples.len() / 2];
889 let p95 = samples[samples.len() * 95 / 100];
890 crate::diagnostic::raw_to_stdout(&format!(
891 "parse {lines}-line batch on one reused instance: median {median:?}, p95 {p95:?} \
892 ({runs} calls)\n"
893 ));
894 }
895 crate::diagnostic::raw_to_stdout(&format!(
896 "instantiate one driver: {per_instantiation:?} (mean of {instantiations})\n"
897 ));
898 }
899
900 #[test]
901 fn process_driver_granted_nothing() {
902 let wat = r#"(component
903 (import "murmur:stream/events@0.1.0" (instance
904 (export "emit-chunk" (func (param "chunk" string)))
905 ))
906 )"#;
907 let bytes = wat::parse_str(wat).expect("component WAT parses");
908 let engine = crate::runtime::build_engine().expect("engine");
909 let _ticker = EpochTicker::spawn(&engine);
910 let component = Component::new(&engine, &bytes).expect("component compiles");
911 let rt = tokio::runtime::Builder::new_current_thread()
912 .enable_all()
913 .build()
914 .expect("runtime");
915 let result = rt.block_on(ProcessDriver::instantiate(
916 &engine, &component, "d", "1.0.0",
917 ));
918 match result {
919 Err(RuntimeError::ProcessDriverLoad { name, message, .. }) => {
920 assert_eq!(name, "d");
921 assert!(message.contains("murmur:stream/events@0.1.0"), "{message}");
922 }
923 Err(other) => panic!("unexpected error: {other:?}"),
924 Ok(_) => panic!("a driver importing a Murmur interface instantiated"),
925 }
926 }
927
928 #[test]
929 fn launch_and_parse_run_on_one_instance() {
930 const WASM: &[u8] = include_bytes!(concat!(
931 env!("CARGO_MANIFEST_DIR"),
932 "/../murmur-cli/tests/fixtures/process-driver/tool/process-driver.wasm"
933 ));
934 let request = LaunchRequest {
935 model: None,
936 system_prompt: String::new(),
937 config: None,
938 bridge: Some(Bridge {
939 server_name: "murmur".to_string(),
940 url: "http://127.0.0.1:1/mcp".to_string(),
941 bearer_token: "token".to_string(),
942 tool_names: vec!["python3".to_string()],
943 }),
944 session: Session {
945 id: "s1".to_string(),
946 mode: SessionMode::New,
947 },
948 harness_version: None,
949 task: "probe".to_string(),
950 };
951 let lines = vec![
952 r#"tool c1 murmur__python3 {"command":"x"}"#.to_string(),
953 "end done".to_string(),
954 ];
955 let (plan, events) =
956 launch_and_parse_process_driver_wasm(WASM, request, lines).expect("driver runs");
957 let plan = plan.expect("the fixture accepts a non-empty task");
958 assert!(plan
959 .env_set
960 .iter()
961 .any(|(name, value)| name == "FIXTURE_BRIDGE_TOKEN" && value == "token"));
962 match &events[..] {
965 [Event::ToolCall(call), Event::TurnEnd(summary)] => {
966 assert_eq!(call.name, "python3");
967 assert_eq!(call.input, r#"{"command":"x"}"#);
968 assert_eq!(summary, "done");
969 }
970 other => panic!("unexpected events: {other:?}"),
971 }
972 }
973}