Skip to main content

locode_exec/
run.rs

1//! Session assembly + drive (plan §3.2): resolve inputs, build host/pack/
2//! provider through the facade, run the engine, emit per `--output-format`.
3
4use std::io::Read;
5use std::process::ExitCode;
6use std::sync::Arc;
7
8use locode_core::{
9    CacheHint, EngineConfig, EventSink, FnSink, Host, HostConfig, NullSink, PackContext,
10    PathPolicy, ProviderInit, ProviderRegistry, SamplingArgs, Session, grok,
11};
12
13use crate::cli::{Cli, Harness, OutputFormat};
14use crate::output;
15
16/// A pre-run failure (config/setup — no report exists yet): stderr + exit 1.
17pub struct PreRunError(pub String);
18
19impl<E: std::fmt::Display> From<E> for PreRunError {
20    fn from(e: E) -> Self {
21        PreRunError(e.to_string())
22    }
23}
24
25/// Build and drive one session; returns the process exit code.
26///
27/// Every terminal state of a *started* run yields a report (the engine's
28/// `run()` is infallible) — only pre-run setup can fail here.
29///
30/// # Errors
31/// [`PreRunError`] on config/setup failures before a run exists (bad `--cwd`,
32/// unknown/misconfigured provider, empty prompt): stderr + exit 1, nothing on
33/// stdout.
34pub async fn run(cli: Cli, providers: &ProviderRegistry) -> Result<ExitCode, PreRunError> {
35    // ---- 1. Prompt: positional, or stdin when absent / `-`. ----
36    let prompt = resolve_prompt(cli.prompt.as_deref())?;
37
38    // ---- 2. Workspace root: canonicalize FIRST, then hand the SAME canonical
39    //         path to the host (jail root), the engine (cwd), and the pack
40    //         (prompt context) — they must agree (STATUS concern #7). ----
41    let cwd = match cli.cwd {
42        Some(dir) => dir,
43        None => std::env::current_dir()?,
44    };
45    let cwd = std::fs::canonicalize(&cwd)
46        .map_err(|e| PreRunError(format!("--cwd {}: {e}", cwd.display())))?;
47
48    let mut host_config = HostConfig::new(&cwd);
49    if cli.dangerously_skip_permissions {
50        host_config.path_policy = PathPolicy::Unrestricted;
51    }
52    let host = Arc::new(Host::new(host_config)?);
53
54    // ---- 3. Pack: tools + preamble (system prompt + <user_info> prefix). ----
55    let pack = locode_core::resolve(cli.harness.as_str())?;
56    let registry = pack.build_registry(&host);
57    let pack_ctx = PackContext {
58        cwd: cwd.clone(),
59        os: std::env::consts::OS.to_string(),
60        shell: std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
61        date: chrono::Local::now().format("%Y-%m-%d").to_string(),
62        headless: true,
63        strip_identity: cli.strip_identity,
64    };
65    let preamble = pack.preamble(&pack_ctx);
66
67    // The grok system prompt directs the model to the <user_query> tag — wrap
68    // the prompt the way the harness expects (pack-specific shaping).
69    let user_prompt = match cli.harness {
70        Harness::Grok => grok::prompt::user_query(&prompt),
71    };
72
73    // ---- 4. Provider: registry-resolved (ADR-0015); unknown names and
74    //         factory failures (missing env, …) fail BEFORE driving the loop. ----
75    let session_id = new_session_id();
76    let built = providers
77        .build(
78            &cli.api_schema,
79            &ProviderInit {
80                session_id: session_id.clone(),
81            },
82        )
83        .map_err(|e| PreRunError(e.to_string()))?;
84    let (provider, model) = (built.provider, built.model);
85
86    // ---- 5. Engine config + event sink per output mode. ----
87    let config = EngineConfig {
88        session_id,
89        harness: cli.harness.as_str().to_string(),
90        api_schema: provider.api_schema().to_string(),
91        model,
92        cwd: cwd.clone(),
93        workspace_root: cwd,
94        max_turns: cli.max_turns,
95        sampling_args: SamplingArgs::default(),
96        cache_hint: CacheHint::Standard,
97        ..EngineConfig::default()
98    };
99    let sink: Box<dyn EventSink> = match cli.output_format {
100        // stream-json writes each event live; the terminal `result` event
101        // carries the same Report as json mode.
102        OutputFormat::StreamJson => Box::new(FnSink(|event| output::write_json_line(&event))),
103        // json/text only want the final report — events are dropped.
104        OutputFormat::Json | OutputFormat::Text => Box::new(NullSink),
105    };
106
107    // ---- 6. Drive to a terminal state (infallible) and emit the artifact. ----
108    let mut session = Session::new(provider, registry, preamble, config, sink);
109    let report = session.run_text(user_prompt).await;
110
111    match cli.output_format {
112        OutputFormat::Json => output::write_json_line(&report),
113        OutputFormat::Text => output::write_text(report.final_message.as_deref().unwrap_or("")),
114        OutputFormat::StreamJson => {} // the result event already streamed
115    }
116    Ok(output::exit_code(report.status))
117}
118
119/// Positional prompt, or stdin when absent / `-` (Codex's convention;
120/// positional XOR stdin per the plan addendum). Empty → usage error.
121fn resolve_prompt(arg: Option<&str>) -> Result<String, PreRunError> {
122    let prompt = match arg {
123        Some("-") | None => {
124            let mut buf = String::new();
125            std::io::stdin().read_to_string(&mut buf)?;
126            buf
127        }
128        Some(text) => text.to_string(),
129    };
130    let prompt = prompt.trim().to_string();
131    if prompt.is_empty() {
132        return Err(PreRunError(
133            "no prompt: pass it as the positional argument or on stdin".to_string(),
134        ));
135    }
136    Ok(prompt)
137}
138
139/// A unique-enough session id for a headless run (no uuid dep in v0).
140fn new_session_id() -> String {
141    let now = std::time::SystemTime::now()
142        .duration_since(std::time::UNIX_EPOCH)
143        .map_or(0, |d| d.as_millis());
144    format!("sess-{now}-{}", std::process::id())
145}