Skip to main content

lean_rs_worker/
capability.rs

1//! Builder for worker-backed downstream capabilities.
2//!
3//! This module composes Lake target building, worker child resolution, worker
4//! startup, session opening, and optional metadata validation. It deliberately
5//! does not know downstream command names or row schemas.
6
7use std::env;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use std::time::Duration;
11
12use lean_rs::{LeanBuiltCapability, LeanCapabilityPreflight, LeanLoaderDiagnosticCode};
13use serde::Deserialize;
14use serde_json::Value;
15
16use crate::pool::{LeanWorkerRestartPolicyClass, LeanWorkerSessionKey};
17use crate::session::{
18    LeanWorkerCancellationToken, LeanWorkerCapabilityMetadata, LeanWorkerProgressSink, LeanWorkerRuntimeMetadata,
19    LeanWorkerSession, LeanWorkerSessionConfig,
20};
21use crate::supervisor::{
22    LEAN_WORKER_REQUEST_TIMEOUT_LONG_RUNNING, LeanWorker, LeanWorkerConfig, LeanWorkerError, LeanWorkerRestartPolicy,
23};
24
25const WORKER_CHILD_ENV: &str = "LEAN_RS_WORKER_CHILD";
26
27/// Builder for a worker-backed Lean capability session.
28///
29/// The builder hides the common setup sequence for downstream tools:
30///
31/// 1. build the Lake shared-library target with `lean-toolchain`;
32/// 2. resolve and start the `lean-rs-worker-child` process;
33/// 3. health-check the worker;
34/// 4. open the configured host session once; and
35/// 5. optionally validate downstream capability metadata.
36///
37/// Callers still provide the Lake project root, package name, library target,
38/// and imports because those are the downstream capability's identity. Worker
39/// framing, child lifecycle, path probing, timeouts, and restart policy stay
40/// behind the builder.
41#[derive(Clone, Debug)]
42pub struct LeanWorkerCapabilityBuilder {
43    project_root: PathBuf,
44    package: String,
45    lib_name: String,
46    imports: Vec<String>,
47    built_dylib_path: Option<PathBuf>,
48    built_capability: Option<LeanBuiltCapability>,
49    worker_child: Option<LeanWorkerChild>,
50    startup_timeout: Option<Duration>,
51    request_timeout: Option<Duration>,
52    restart_policy: Option<LeanWorkerRestartPolicy>,
53    metadata_check: Option<CapabilityMetadataCheck>,
54}
55
56impl LeanWorkerCapabilityBuilder {
57    /// Create a builder for a Lake project and capability library.
58    ///
59    /// `project_root` is the directory containing `lakefile.lean`. `package`
60    /// is the Lake package name used by `lean-rs-host`, and `lib_name` is the
61    /// Lake `lean_lib` target to build and load.
62    #[must_use]
63    pub fn new(
64        project_root: impl Into<PathBuf>,
65        package: impl Into<String>,
66        lib_name: impl Into<String>,
67        imports: impl IntoIterator<Item = impl Into<String>>,
68    ) -> Self {
69        Self {
70            project_root: project_root.into(),
71            package: package.into(),
72            lib_name: lib_name.into(),
73            imports: imports.into_iter().map(Into::into).collect(),
74            built_dylib_path: None,
75            built_capability: None,
76            worker_child: None,
77            startup_timeout: None,
78            request_timeout: None,
79            restart_policy: None,
80            metadata_check: None,
81        }
82    }
83
84    /// Create a builder from a build-script produced capability.
85    ///
86    /// Manifest-backed descriptors are the canonical packaged-app path. The
87    /// builder reads package, module, and primary dylib facts from the
88    /// manifest, then infers the Lake project root from the standard
89    /// `.lake/build/lib/<dylib>` layout so the worker child can initialize
90    /// Lean's import search path. Direct dylib descriptors remain supported as
91    /// a compatibility path when callers also provide package and module names.
92    ///
93    /// # Errors
94    ///
95    /// Returns `LeanWorkerError` if manifest data cannot be parsed, the
96    /// fallback dylib path cannot be resolved, the compatibility descriptor is
97    /// missing package/module names, or the dylib is not under a standard Lake
98    /// build directory.
99    pub fn from_built_capability(
100        spec: &LeanBuiltCapability,
101        imports: impl IntoIterator<Item = impl Into<String>>,
102    ) -> Result<Self, LeanWorkerError> {
103        let artifact = WorkerCapabilityArtifact::from_built_capability(spec)?;
104        let project_root = infer_lake_project_root_from_dylib(&artifact.dylib_path)?;
105        Ok(Self {
106            project_root,
107            package: artifact.package,
108            lib_name: artifact.module,
109            imports: imports.into_iter().map(Into::into).collect(),
110            built_dylib_path: Some(artifact.dylib_path),
111            built_capability: Some(spec.clone()),
112            worker_child: None,
113            startup_timeout: None,
114            request_timeout: None,
115            restart_policy: None,
116            metadata_check: None,
117        })
118    }
119
120    /// Use an explicit `lean-rs-worker-child` executable.
121    ///
122    /// Tests and packaged applications should use this when the worker child
123    /// is not discoverable beside the current executable.
124    #[must_use]
125    pub fn worker_executable(mut self, path: impl Into<PathBuf>) -> Self {
126        self.worker_child = Some(LeanWorkerChild::path(path));
127        self
128    }
129
130    /// Resolve the worker executable with a packaged worker-child locator.
131    #[must_use]
132    pub fn worker_child(mut self, child: LeanWorkerChild) -> Self {
133        self.worker_child = Some(child);
134        self
135    }
136
137    /// Set the maximum time to wait for worker startup.
138    #[must_use]
139    pub fn startup_timeout(mut self, timeout: Duration) -> Self {
140        self.startup_timeout = Some(timeout);
141        self
142    }
143
144    /// Set the maximum time to wait for one worker request.
145    #[must_use]
146    pub fn request_timeout(mut self, timeout: Duration) -> Self {
147        self.request_timeout = Some(timeout);
148        self
149    }
150
151    /// Use the documented long-running request timeout profile.
152    #[must_use]
153    pub fn long_running_requests(mut self) -> Self {
154        self.request_timeout = Some(LEAN_WORKER_REQUEST_TIMEOUT_LONG_RUNNING);
155        self
156    }
157
158    /// Set the worker restart policy used after startup.
159    #[must_use]
160    pub fn restart_policy(mut self, policy: LeanWorkerRestartPolicy) -> Self {
161        self.restart_policy = Some(policy);
162        self
163    }
164
165    /// Validate generic capability metadata after the session opens.
166    ///
167    /// The export must have ABI `String -> IO String`, matching
168    /// `LeanWorkerSession::capability_metadata`. The returned metadata is
169    /// stored on the opened capability for callers that need it.
170    #[must_use]
171    pub fn validate_metadata(mut self, export: impl Into<String>, request: Value) -> Self {
172        self.metadata_check = Some(CapabilityMetadataCheck {
173            export: export.into(),
174            request,
175            expected: None,
176        });
177        self
178    }
179
180    /// Validate that a capability metadata export returns the expected facts.
181    ///
182    /// This is the pool-facing metadata expectation hook. The metadata remains
183    /// downstream-defined; `lean-rs-worker` only checks that the generic
184    /// metadata envelope matches the caller's requested expectation.
185    #[must_use]
186    pub fn expect_metadata(
187        mut self,
188        export: impl Into<String>,
189        request: Value,
190        expected: LeanWorkerCapabilityMetadata,
191    ) -> Self {
192        self.metadata_check = Some(CapabilityMetadataCheck {
193            export: export.into(),
194            request,
195            expected: Some(expected),
196        });
197        self
198    }
199
200    /// Return the session reuse key represented by this builder.
201    ///
202    /// The key is for worker-pool reuse only. It is not a downstream cache key
203    /// and does not encode row schemas, ranking, reporting, or source
204    /// provenance.
205    #[must_use]
206    pub fn session_key(&self) -> LeanWorkerSessionKey {
207        let restart_policy_class = match &self.restart_policy {
208            Some(policy) if policy == &LeanWorkerRestartPolicy::default() => LeanWorkerRestartPolicyClass::Default,
209            Some(_policy) => LeanWorkerRestartPolicyClass::Custom,
210            None => LeanWorkerRestartPolicyClass::Default,
211        };
212        let mut key = LeanWorkerSessionKey::new(
213            self.project_root.clone(),
214            self.package.clone(),
215            self.lib_name.clone(),
216            self.imports.clone(),
217        )
218        .restart_policy_class(restart_policy_class);
219        if let Some(check) = &self.metadata_check {
220            key = key.metadata_expectation(check.export.clone(), check.request.clone(), check.expected.clone());
221        }
222        key
223    }
224
225    pub(crate) fn pool_request_timeout(&self) -> Duration {
226        self.request_timeout
227            .unwrap_or(crate::supervisor::LEAN_WORKER_REQUEST_TIMEOUT_DEFAULT)
228    }
229
230    /// Check deployment facts before running a real worker command.
231    ///
232    /// The report validates the worker child locator, manifest-backed
233    /// capability artifact when present, worker protocol handshake, session
234    /// opening, and optional metadata expectation. It keeps child paths,
235    /// protocol frames, and loader environment details below the worker
236    /// boundary.
237    #[must_use]
238    pub fn check(&self) -> LeanWorkerBootstrapReport {
239        let mut checks = self.bootstrap_static_checks();
240        if checks.iter().any(LeanWorkerBootstrapCheck::is_error) {
241            return LeanWorkerBootstrapReport::new(checks);
242        }
243
244        match self.clone().open_unchecked() {
245            Ok(capability) => {
246                drop(capability.terminate());
247            }
248            Err(err) => checks.push(check_from_open_error(&err)),
249        }
250        LeanWorkerBootstrapReport::new(checks)
251    }
252
253    fn bootstrap_static_checks(&self) -> Vec<LeanWorkerBootstrapCheck> {
254        let mut checks = Vec::new();
255        match self
256            .worker_child
257            .as_ref()
258            .map_or_else(resolve_default_worker_executable, LeanWorkerChild::resolve)
259        {
260            Ok(path) => {
261                if let Err(err) = validate_worker_child_path(&path) {
262                    checks.push(check_from_open_error(&err));
263                }
264            }
265            Err(err) => checks.push(check_from_open_error(&err)),
266        }
267
268        if let Some(spec) = &self.built_capability
269            && spec.resolved_manifest_path().is_ok()
270        {
271            let report = LeanCapabilityPreflight::new(spec.clone()).check();
272            for check in report.errors() {
273                checks.push(LeanWorkerBootstrapCheck::error(
274                    LeanWorkerBootstrapDiagnosticCode::CapabilityPreflight { code: check.code() },
275                    check.subject().to_owned(),
276                    check.message().to_owned(),
277                    check.repair_hint().to_owned(),
278                ));
279            }
280        }
281        checks
282    }
283
284    /// Build the Lake target, start the worker, open the session, and return a ready capability.
285    ///
286    /// # Errors
287    ///
288    /// Returns `LeanWorkerError` if Lake cannot build the target, the worker
289    /// child cannot be resolved or spawned, the worker fails startup/health,
290    /// the session cannot open, or metadata validation fails.
291    pub fn open(self) -> Result<LeanWorkerCapability, LeanWorkerError> {
292        let report = self.bootstrap_static_report();
293        if let Some(check) = report.first_error() {
294            return Err(LeanWorkerError::Bootstrap {
295                code: check.code(),
296                message: check.message().to_owned(),
297            });
298        }
299        self.open_unchecked()
300    }
301
302    fn bootstrap_static_report(&self) -> LeanWorkerBootstrapReport {
303        LeanWorkerBootstrapReport::new(self.bootstrap_static_checks())
304    }
305
306    fn open_unchecked(self) -> Result<LeanWorkerCapability, LeanWorkerError> {
307        let dylib_path = match self.built_dylib_path {
308            Some(path) => path,
309            None => lean_toolchain::build_lake_target_quiet(&self.project_root, &self.lib_name)
310                .map_err(|diagnostic| LeanWorkerError::CapabilityBuild { diagnostic })?,
311        };
312        let worker_executable = self
313            .worker_child
314            .map_or_else(resolve_default_worker_executable, |child| child.resolve())?;
315        validate_worker_child_path(&worker_executable)?;
316
317        let mut config = LeanWorkerConfig::new(worker_executable);
318        if let Some(timeout) = self.startup_timeout {
319            config = config.startup_timeout(timeout);
320        }
321        if let Some(timeout) = self.request_timeout {
322            config = config.request_timeout(timeout);
323        }
324        if let Some(policy) = self.restart_policy {
325            config = config.restart_policy(policy);
326        }
327
328        let mut worker = LeanWorker::spawn(&config)?;
329        worker.health()?;
330
331        let session_config = LeanWorkerSessionConfig::new(
332            self.project_root.clone(),
333            self.package.clone(),
334            self.lib_name.clone(),
335            self.imports.clone(),
336        );
337
338        let validated_metadata = {
339            let mut session = worker.open_session(&session_config, None, None)?;
340            match self.metadata_check {
341                Some(check) => {
342                    let metadata = session.capability_metadata(&check.export, &check.request, None, None)?;
343                    if let Some(expected) = check.expected
344                        && metadata != expected
345                    {
346                        return Err(LeanWorkerError::CapabilityMetadataMismatch {
347                            export: check.export,
348                            expected: Box::new(expected),
349                            actual: Box::new(metadata),
350                        });
351                    }
352                    Some(metadata)
353                }
354                None => None,
355            }
356        };
357
358        Ok(LeanWorkerCapability {
359            worker,
360            session_config,
361            dylib_path,
362            validated_metadata,
363        })
364    }
365}
366
367/// Stable worker bootstrap diagnostic codes.
368#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
369#[non_exhaustive]
370pub enum LeanWorkerBootstrapDiagnosticCode {
371    /// The worker child locator did not resolve to a file.
372    WorkerChildUnresolved,
373    /// The worker child exists but is not executable.
374    WorkerChildNotExecutable,
375    /// Manifest-backed capability preflight reported a loader/artifact issue.
376    CapabilityPreflight { code: LeanLoaderDiagnosticCode },
377    /// The worker child did not complete the protocol handshake.
378    WorkerHandshakeFailed,
379    /// Capability metadata did not match the caller's expectation.
380    CapabilityMetadataMismatch,
381    /// Worker bootstrap failed for a reason outside the named deployment checks.
382    WorkerStartupFailed,
383}
384
385impl LeanWorkerBootstrapDiagnosticCode {
386    /// Stable string identifier suitable for logs and support reports.
387    #[must_use]
388    pub const fn as_str(self) -> &'static str {
389        match self {
390            Self::WorkerChildUnresolved => "lean_rs.worker.bootstrap.child_unresolved",
391            Self::WorkerChildNotExecutable => "lean_rs.worker.bootstrap.child_not_executable",
392            Self::CapabilityPreflight { code } => code.as_str(),
393            Self::WorkerHandshakeFailed => "lean_rs.worker.bootstrap.handshake_failed",
394            Self::CapabilityMetadataMismatch => "lean_rs.worker.bootstrap.metadata_mismatch",
395            Self::WorkerStartupFailed => "lean_rs.worker.bootstrap.startup_failed",
396        }
397    }
398}
399
400impl std::fmt::Display for LeanWorkerBootstrapDiagnosticCode {
401    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
402        f.write_str(self.as_str())
403    }
404}
405
406/// Severity of one worker bootstrap finding.
407#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
408#[non_exhaustive]
409pub enum LeanWorkerBootstrapSeverity {
410    /// Informational finding that does not block startup.
411    Info,
412    /// Suspicious state that may still start.
413    Warning,
414    /// The worker should not start real commands until this is fixed.
415    Error,
416}
417
418/// One bounded worker bootstrap finding.
419#[derive(Clone, Debug, Eq, PartialEq)]
420pub struct LeanWorkerBootstrapCheck {
421    code: LeanWorkerBootstrapDiagnosticCode,
422    severity: LeanWorkerBootstrapSeverity,
423    subject: String,
424    message: String,
425    repair_hint: String,
426}
427
428impl LeanWorkerBootstrapCheck {
429    fn error(
430        code: LeanWorkerBootstrapDiagnosticCode,
431        subject: impl Into<String>,
432        message: impl Into<String>,
433        repair_hint: impl Into<String>,
434    ) -> Self {
435        Self {
436            code,
437            severity: LeanWorkerBootstrapSeverity::Error,
438            subject: bound_bootstrap_text(subject.into()),
439            message: bound_bootstrap_text(message.into()),
440            repair_hint: bound_bootstrap_text(repair_hint.into()),
441        }
442    }
443
444    /// Stable diagnostic code.
445    #[must_use]
446    pub fn code(&self) -> LeanWorkerBootstrapDiagnosticCode {
447        self.code
448    }
449
450    /// Whether this finding blocks worker startup.
451    #[must_use]
452    pub fn severity(&self) -> LeanWorkerBootstrapSeverity {
453        self.severity
454    }
455
456    /// Child binary, artifact, export, or protocol step this finding concerns.
457    #[must_use]
458    pub fn subject(&self) -> &str {
459        &self.subject
460    }
461
462    /// Bounded explanation of the finding.
463    #[must_use]
464    pub fn message(&self) -> &str {
465        &self.message
466    }
467
468    /// Bounded repair hint for packaged applications.
469    #[must_use]
470    pub fn repair_hint(&self) -> &str {
471        &self.repair_hint
472    }
473
474    fn is_error(&self) -> bool {
475        self.severity == LeanWorkerBootstrapSeverity::Error
476    }
477}
478
479/// Structured result of worker bootstrap checks for one capability builder.
480#[derive(Clone, Debug, Eq, PartialEq)]
481pub struct LeanWorkerBootstrapReport {
482    checks: Vec<LeanWorkerBootstrapCheck>,
483}
484
485impl LeanWorkerBootstrapReport {
486    fn new(checks: Vec<LeanWorkerBootstrapCheck>) -> Self {
487        Self { checks }
488    }
489
490    /// All bootstrap findings.
491    #[must_use]
492    pub fn checks(&self) -> &[LeanWorkerBootstrapCheck] {
493        &self.checks
494    }
495
496    /// Blocking bootstrap findings.
497    pub fn errors(&self) -> impl Iterator<Item = &LeanWorkerBootstrapCheck> {
498        self.checks
499            .iter()
500            .filter(|check| check.severity == LeanWorkerBootstrapSeverity::Error)
501    }
502
503    /// Whether the worker bootstrap checks found no blocking findings.
504    #[must_use]
505    pub fn is_ok(&self) -> bool {
506        self.first_error().is_none()
507    }
508
509    /// First blocking finding, if any.
510    #[must_use]
511    pub fn first_error(&self) -> Option<&LeanWorkerBootstrapCheck> {
512        self.errors().next()
513    }
514}
515
516/// A worker-backed capability with its Lake target built and worker started.
517///
518/// The value owns the worker supervisor and the session configuration. It is
519/// the normal entry point for downstream capability use until the typed command
520/// facade lands on top of it.
521#[derive(Debug)]
522pub struct LeanWorkerCapability {
523    worker: LeanWorker,
524    session_config: LeanWorkerSessionConfig,
525    dylib_path: PathBuf,
526    validated_metadata: Option<LeanWorkerCapabilityMetadata>,
527}
528
529impl LeanWorkerCapability {
530    /// Open a worker session for this capability.
531    ///
532    /// The builder has already proved that the session can open. This method
533    /// is still fallible because worker cycling, cancellation, or a child
534    /// failure may require a fresh session.
535    ///
536    /// # Errors
537    ///
538    /// Returns `LeanWorkerError` if the worker is dead, the child cannot open
539    /// the configured imports, cancellation is already requested, a progress
540    /// sink panics, or protocol communication fails.
541    pub fn open_session(
542        &mut self,
543        cancellation: Option<&LeanWorkerCancellationToken>,
544        progress: Option<&dyn LeanWorkerProgressSink>,
545    ) -> Result<LeanWorkerSession<'_>, LeanWorkerError> {
546        self.worker.open_session(&self.session_config, cancellation, progress)
547    }
548
549    /// Open a worker session with a caller-supplied import set, overriding the imports
550    /// the builder was constructed with. The capability's `project_root` / `package` /
551    /// `lib_name` are unchanged.
552    ///
553    /// Lifecycle is identical to [`open_session`](Self::open_session): the returned
554    /// session borrows from `&mut self` and dies when dropped.
555    ///
556    /// # Errors
557    ///
558    /// Same as [`open_session`](Self::open_session).
559    pub fn open_session_with_imports(
560        &mut self,
561        imports: impl IntoIterator<Item = impl Into<String>>,
562        cancellation: Option<&LeanWorkerCancellationToken>,
563        progress: Option<&dyn LeanWorkerProgressSink>,
564    ) -> Result<LeanWorkerSession<'_>, LeanWorkerError> {
565        let config = LeanWorkerSessionConfig::new(
566            self.session_config.project_root_string(),
567            self.session_config.package().to_owned(),
568            self.session_config.lib_name().to_owned(),
569            imports,
570        );
571        self.worker.open_session(&config, cancellation, progress)
572    }
573
574    /// Return the built capability dylib path resolved by `lean-toolchain`.
575    #[must_use]
576    pub fn dylib_path(&self) -> &Path {
577        &self.dylib_path
578    }
579
580    /// Return the session configuration used by this capability.
581    #[must_use]
582    pub fn session_config(&self) -> &LeanWorkerSessionConfig {
583        &self.session_config
584    }
585
586    /// Return capability metadata validated by the builder, if requested.
587    #[must_use]
588    pub fn validated_metadata(&self) -> Option<&LeanWorkerCapabilityMetadata> {
589        self.validated_metadata.as_ref()
590    }
591
592    /// Return protocol/runtime facts captured from the worker handshake.
593    #[must_use]
594    pub fn runtime_metadata(&self) -> LeanWorkerRuntimeMetadata {
595        self.worker.runtime_metadata()
596    }
597
598    /// Borrow the underlying worker for lifecycle operations such as cycling.
599    #[must_use]
600    pub fn worker(&self) -> &LeanWorker {
601        &self.worker
602    }
603
604    /// Mutably borrow the underlying worker for lifecycle operations such as cycling.
605    #[must_use]
606    pub fn worker_mut(&mut self) -> &mut LeanWorker {
607        &mut self.worker
608    }
609
610    /// Terminate the worker child and return its exit status.
611    ///
612    /// # Errors
613    ///
614    /// Returns `LeanWorkerError` if the worker is already dead, the terminate
615    /// request fails, or waiting for the child fails.
616    pub fn terminate(self) -> Result<crate::supervisor::LeanWorkerExit, LeanWorkerError> {
617        self.worker.terminate()
618    }
619}
620
621#[derive(Clone, Debug)]
622struct CapabilityMetadataCheck {
623    export: String,
624    request: Value,
625    expected: Option<LeanWorkerCapabilityMetadata>,
626}
627
628#[derive(Debug)]
629struct WorkerCapabilityArtifact {
630    dylib_path: PathBuf,
631    package: String,
632    module: String,
633}
634
635impl WorkerCapabilityArtifact {
636    fn from_built_capability(spec: &LeanBuiltCapability) -> Result<Self, LeanWorkerError> {
637        if let Ok(manifest_path) = spec.resolved_manifest_path() {
638            return Self::from_manifest(&manifest_path);
639        }
640
641        let dylib_path = spec.dylib_path().map_err(|err| LeanWorkerError::Setup {
642            message: err.to_string(),
643        })?;
644        let package = spec.package_name().ok_or_else(|| LeanWorkerError::Setup {
645            message: "LeanBuiltCapability is missing the Lake package name; call `.package(...)`".to_owned(),
646        })?;
647        let module = spec.module_name().ok_or_else(|| LeanWorkerError::Setup {
648            message: "LeanBuiltCapability is missing the root Lean module name; call `.module(...)`".to_owned(),
649        })?;
650        Ok(Self {
651            dylib_path,
652            package: package.to_owned(),
653            module: module.to_owned(),
654        })
655    }
656
657    fn from_manifest(manifest_path: &Path) -> Result<Self, LeanWorkerError> {
658        let bytes = std::fs::read(manifest_path).map_err(|err| LeanWorkerError::Bootstrap {
659            code: LeanWorkerBootstrapDiagnosticCode::CapabilityPreflight {
660                code: LeanLoaderDiagnosticCode::MissingManifest,
661            },
662            message: format!(
663                "could not read Lean capability manifest '{}': {err}",
664                manifest_path.display()
665            ),
666        })?;
667        let manifest: WorkerCapabilityManifest =
668            serde_json::from_slice(&bytes).map_err(|err| LeanWorkerError::Bootstrap {
669                code: LeanWorkerBootstrapDiagnosticCode::CapabilityPreflight {
670                    code: LeanLoaderDiagnosticCode::MalformedManifest,
671                },
672                message: format!(
673                    "Lean capability manifest '{}' is malformed: {err}",
674                    manifest_path.display()
675                ),
676            })?;
677        if manifest.schema_version != u64::from(lean_toolchain::CAPABILITY_MANIFEST_SCHEMA_VERSION) {
678            return Err(LeanWorkerError::Bootstrap {
679                code: LeanWorkerBootstrapDiagnosticCode::CapabilityPreflight {
680                    code: LeanLoaderDiagnosticCode::UnsupportedManifestSchema,
681                },
682                message: format!(
683                    "unsupported Lean capability manifest schema {}; supported schema is {}",
684                    manifest.schema_version,
685                    lean_toolchain::CAPABILITY_MANIFEST_SCHEMA_VERSION
686                ),
687            });
688        }
689        Ok(Self {
690            dylib_path: manifest.primary_dylib,
691            package: manifest.package,
692            module: manifest.module,
693        })
694    }
695}
696
697#[derive(Deserialize)]
698struct WorkerCapabilityManifest {
699    schema_version: u64,
700    primary_dylib: PathBuf,
701    package: String,
702    module: String,
703}
704
705/// Locator for an app-owned worker child executable.
706///
707/// Dependency binaries are not automatically installed with downstream
708/// applications. Production apps should ship a tiny binary that calls
709/// [`crate::run_worker_child_stdio`] and point the capability builder at it
710/// through this locator.
711#[derive(Clone, Debug, Eq, PartialEq)]
712pub struct LeanWorkerChild {
713    executable_name: Option<String>,
714    explicit_path: Option<PathBuf>,
715    env_var: Option<String>,
716}
717
718impl LeanWorkerChild {
719    /// Locate a worker child beside the current executable, or beside the
720    /// Cargo profile directory during tests and `cargo run`.
721    #[must_use]
722    pub fn sibling(executable_name: impl Into<String>) -> Self {
723        Self {
724            executable_name: Some(with_exe_suffix(executable_name.into())),
725            explicit_path: None,
726            env_var: None,
727        }
728    }
729
730    /// Use an explicit worker child path.
731    #[must_use]
732    pub fn path(path: impl Into<PathBuf>) -> Self {
733        Self {
734            executable_name: None,
735            explicit_path: Some(path.into()),
736            env_var: None,
737        }
738    }
739
740    /// Add an environment-variable override for launchers and tests.
741    #[must_use]
742    pub fn env_override(mut self, env_var: impl Into<String>) -> Self {
743        self.env_var = Some(env_var.into());
744        self
745    }
746
747    fn resolve(&self) -> Result<PathBuf, LeanWorkerError> {
748        let mut tried = Vec::new();
749        if let Some(env_var) = &self.env_var
750            && let Some(value) = env::var_os(env_var)
751        {
752            let path = PathBuf::from(value);
753            if path.is_file() {
754                return Ok(path);
755            }
756            tried.push(path);
757            return Err(LeanWorkerError::WorkerChildUnresolved { tried });
758        }
759        if let Some(path) = &self.explicit_path {
760            return Ok(path.clone());
761        }
762
763        let executable_name = self
764            .executable_name
765            .clone()
766            .unwrap_or_else(|| with_exe_suffix("lean-rs-worker-child".to_owned()));
767        tried.extend(candidate_sibling_worker_paths(&executable_name));
768        if executable_name == with_exe_suffix("lean-rs-worker-child".to_owned())
769            && let Some(path) = try_build_workspace_worker_child(&executable_name, &mut tried)
770        {
771            return Ok(path);
772        }
773        for path in dedup_paths(&tried) {
774            if path.is_file() {
775                return Ok(path);
776            }
777        }
778        Err(LeanWorkerError::WorkerChildUnresolved { tried })
779    }
780}
781
782impl Default for LeanWorkerChild {
783    fn default() -> Self {
784        Self::sibling("lean-rs-worker-child").env_override(WORKER_CHILD_ENV)
785    }
786}
787
788fn resolve_default_worker_executable() -> Result<PathBuf, LeanWorkerError> {
789    LeanWorkerChild::default().resolve()
790}
791
792fn validate_worker_child_path(path: &Path) -> Result<(), LeanWorkerError> {
793    if !path.is_file() {
794        return Err(LeanWorkerError::WorkerChildNotExecutable {
795            path: path.to_path_buf(),
796            reason: "path does not point to a file".to_owned(),
797        });
798    }
799    if !is_executable_file(path) {
800        return Err(LeanWorkerError::WorkerChildNotExecutable {
801            path: path.to_path_buf(),
802            reason: "file is not executable by this user".to_owned(),
803        });
804    }
805    Ok(())
806}
807
808#[cfg(unix)]
809fn is_executable_file(path: &Path) -> bool {
810    use std::os::unix::fs::PermissionsExt as _;
811
812    std::fs::metadata(path).is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0)
813}
814
815#[cfg(not(unix))]
816fn is_executable_file(_path: &Path) -> bool {
817    true
818}
819
820fn check_from_open_error(err: &LeanWorkerError) -> LeanWorkerBootstrapCheck {
821    match err {
822        LeanWorkerError::WorkerChildUnresolved { tried } => LeanWorkerBootstrapCheck::error(
823            LeanWorkerBootstrapDiagnosticCode::WorkerChildUnresolved,
824            "worker child",
825            format!("could not resolve worker child; tried {}", format_paths(tried)),
826            "ship an app-owned worker child binary beside the app or configure LeanWorkerChild::env_override",
827        ),
828        LeanWorkerError::WorkerChildNotExecutable { path, reason } => LeanWorkerBootstrapCheck::error(
829            LeanWorkerBootstrapDiagnosticCode::WorkerChildNotExecutable,
830            path.display().to_string(),
831            reason.clone(),
832            "ship an app-owned worker child binary and ensure it is executable",
833        ),
834        LeanWorkerError::Bootstrap { code, message } => LeanWorkerBootstrapCheck::error(
835            *code,
836            code.as_str(),
837            message.clone(),
838            "fix the reported bootstrap input",
839        ),
840        LeanWorkerError::Handshake { message } => LeanWorkerBootstrapCheck::error(
841            LeanWorkerBootstrapDiagnosticCode::WorkerHandshakeFailed,
842            "worker handshake",
843            message.clone(),
844            "ensure the worker child calls lean_rs_worker::run_worker_child_stdio and matches this crate version",
845        ),
846        LeanWorkerError::Timeout {
847            operation: "startup", ..
848        } => LeanWorkerBootstrapCheck::error(
849            LeanWorkerBootstrapDiagnosticCode::WorkerHandshakeFailed,
850            "worker handshake",
851            err.to_string(),
852            "check that the worker child starts promptly and writes the lean-rs-worker handshake",
853        ),
854        LeanWorkerError::CapabilityMetadataMismatch { export, .. } => LeanWorkerBootstrapCheck::error(
855            LeanWorkerBootstrapDiagnosticCode::CapabilityMetadataMismatch,
856            export.clone(),
857            "capability metadata did not match the requested expectation",
858            "rebuild or select a capability whose metadata matches the caller expectation",
859        ),
860        other @ (LeanWorkerError::Spawn { .. }
861        | LeanWorkerError::CapabilityBuild { .. }
862        | LeanWorkerError::Setup { .. }
863        | LeanWorkerError::Protocol { .. }
864        | LeanWorkerError::Worker { .. }
865        | LeanWorkerError::ChildExited { .. }
866        | LeanWorkerError::ChildPanicOrAbort { .. }
867        | LeanWorkerError::Timeout { .. }
868        | LeanWorkerError::Cancelled { .. }
869        | LeanWorkerError::ProgressPanic { .. }
870        | LeanWorkerError::DataSinkPanic { .. }
871        | LeanWorkerError::DiagnosticSinkPanic { .. }
872        | LeanWorkerError::StreamExportFailed { .. }
873        | LeanWorkerError::StreamCallbackFailed { .. }
874        | LeanWorkerError::StreamRowMalformed { .. }
875        | LeanWorkerError::CapabilityMetadataMalformed { .. }
876        | LeanWorkerError::CapabilityDoctorMalformed { .. }
877        | LeanWorkerError::TypedCommandRequestEncode { .. }
878        | LeanWorkerError::TypedCommandResponseDecode { .. }
879        | LeanWorkerError::TypedCommandRowDecode { .. }
880        | LeanWorkerError::TypedCommandSummaryDecode { .. }
881        | LeanWorkerError::LeaseInvalidated { .. }
882        | LeanWorkerError::WorkerPoolExhausted { .. }
883        | LeanWorkerError::WorkerPoolMemoryBudgetExceeded { .. }
884        | LeanWorkerError::WorkerPoolQueueTimeout { .. }
885        | LeanWorkerError::UnsupportedRequest { .. }
886        | LeanWorkerError::Wait { .. }) => LeanWorkerBootstrapCheck::error(
887            LeanWorkerBootstrapDiagnosticCode::WorkerStartupFailed,
888            "worker bootstrap",
889            other.to_string(),
890            "run the bootstrap check in a deployment environment and rebuild the worker child or capability artifact",
891        ),
892    }
893}
894
895fn format_paths(paths: &[PathBuf]) -> String {
896    if paths.is_empty() {
897        return "<none>".to_owned();
898    }
899    paths
900        .iter()
901        .map(|path| path.display().to_string())
902        .collect::<Vec<_>>()
903        .join(", ")
904}
905
906fn bound_bootstrap_text(mut text: String) -> String {
907    const LIMIT: usize = 1_024;
908    if text.len() <= LIMIT {
909        return text;
910    }
911    while !text.is_char_boundary(LIMIT) {
912        text.pop();
913    }
914    text.truncate(LIMIT);
915    text.push_str("...");
916    text
917}
918
919fn candidate_sibling_worker_paths(executable_name: &str) -> Vec<PathBuf> {
920    let mut tried = Vec::new();
921    if let Ok(current_exe) = env::current_exe() {
922        if let Some(dir) = current_exe.parent() {
923            tried.push(dir.join(executable_name));
924        }
925        if let Some(profile_dir) = current_exe.parent().and_then(Path::parent) {
926            tried.push(profile_dir.join(executable_name));
927        }
928    }
929    tried
930}
931
932fn with_exe_suffix(mut executable_name: String) -> String {
933    if !env::consts::EXE_SUFFIX.is_empty() && !executable_name.ends_with(env::consts::EXE_SUFFIX) {
934        executable_name.push_str(env::consts::EXE_SUFFIX);
935    }
936    executable_name
937}
938
939fn infer_lake_project_root_from_dylib(dylib_path: &Path) -> Result<PathBuf, LeanWorkerError> {
940    let lib_dir = dylib_path.parent();
941    let build_dir = lib_dir.and_then(Path::parent);
942    let lake_dir = build_dir.and_then(Path::parent);
943    let project_root = lake_dir.and_then(Path::parent);
944    match (lib_dir, build_dir, lake_dir, project_root) {
945        (Some(lib), Some(build), Some(lake), Some(root))
946            if lib.file_name().is_some_and(|name| name == "lib")
947                && build.file_name().is_some_and(|name| name == "build")
948                && lake.file_name().is_some_and(|name| name == ".lake") =>
949        {
950            Ok(root.to_path_buf())
951        }
952        _ => Err(LeanWorkerError::Setup {
953            message: format!(
954                "built capability dylib '{}' is not under a standard .lake/build/lib directory",
955                dylib_path.display()
956            ),
957        }),
958    }
959}
960
961fn try_build_workspace_worker_child(executable_name: &str, tried: &mut Vec<PathBuf>) -> Option<PathBuf> {
962    let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
963    let workspace = manifest_dir.parent()?.parent()?;
964    if !workspace
965        .join("crates")
966        .join("lean-rs-worker")
967        .join("Cargo.toml")
968        .is_file()
969    {
970        return None;
971    }
972
973    let debug = workspace.join("target").join("debug").join(executable_name);
974    let release = workspace.join("target").join("release").join(executable_name);
975    tried.push(debug.clone());
976    tried.push(release.clone());
977    if debug.is_file() {
978        return Some(debug);
979    }
980    if release.is_file() {
981        return Some(release);
982    }
983
984    let cargo = env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
985    let status = Command::new(cargo)
986        .current_dir(workspace)
987        .args(["build", "-p", "lean-rs-worker", "--bin", "lean-rs-worker-child"])
988        .status()
989        .ok()?;
990    if !status.success() {
991        return None;
992    }
993    debug.is_file().then_some(debug)
994}
995
996fn dedup_paths(paths: &[PathBuf]) -> Vec<PathBuf> {
997    let mut unique = Vec::new();
998    for path in paths {
999        if !unique.iter().any(|existing| existing == path) {
1000            unique.push(path.clone());
1001        }
1002    }
1003    unique
1004}