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, InstructionsConfig, PackContext,
10    PathPolicy, ProviderInit, ProviderRegistry, SamplingArgs, Session,
11};
12
13use crate::cli::{Cli, 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    // ---- 0. SIGTERM handler (ADR-0018, Task 21): installed before any
36    //         pre-run work so a pre-run SIGTERM exits 1 cleanly; armed with
37    //         the session's cancel handle once one exists. ----
38    #[cfg(unix)]
39    let cancel_slot = crate::signal::install_sigterm();
40
41    // ---- 1. Prompt: positional, or stdin when absent / `-`. ----
42    let prompt = resolve_prompt(cli.prompt.as_deref())?;
43
44    // ---- 2. Workspace root: canonicalize FIRST, then hand the SAME canonical
45    //         path to the host (jail root), the engine (cwd), and the pack
46    //         (prompt context) — they must agree (STATUS concern #7). ----
47    let cwd = match &cli.cwd {
48        Some(dir) => dir.clone(),
49        None => std::env::current_dir()?,
50    };
51    let cwd = std::fs::canonicalize(&cwd)
52        .map_err(|e| PreRunError(format!("--cwd {}: {e}", cwd.display())))?;
53
54    let mut host_config = HostConfig::new(&cwd);
55    if cli.dangerously_skip_permissions {
56        host_config.path_policy = PathPolicy::Unrestricted;
57    }
58    let host = Arc::new(Host::new(host_config)?);
59
60    // ---- 2b. Settings (ADR-0024): the durable defaults *under* the flags —
61    //          an explicit flag/env always wins. Layer degradations surface as
62    //          stderr warnings, never hard errors. ----
63    let settings_load = locode_core::load_settings(&cwd, cli.settings.as_deref());
64    for warning in &settings_load.warnings {
65        output::warning_line(warning);
66    }
67    let settings = settings_load.settings;
68
69    // ---- 2c. Resume target (`-c`/`-r`, ADR-0024 §2.5): recover the transcript
70    //          and the run identity (pack/wire/model/id) from the rollout header;
71    //          an explicit conflicting flag errors rather than silently swapping
72    //          a session's pack or wire mid-transcript. ----
73    let identity = resolve_identity(&cli, &cwd, &settings)?;
74
75    // ---- 3. Provider: registry-resolved (ADR-0015); unknown names and factory
76    //         failures (missing env, …) fail BEFORE driving the loop. Built first
77    //         so the pack env block can name the model (D9). ----
78    let pack = locode_core::resolve(&identity.harness)?;
79    let registry = pack.build_registry(&host);
80    let session_id = identity.session_id.clone();
81    let built = providers
82        .build(
83            &identity.api_schema,
84            &ProviderInit {
85                session_id: session_id.clone(),
86                model: identity.model_override.clone(),
87            },
88        )
89        .map_err(|e| PreRunError(e.to_string()))?;
90    let (provider, model) = (built.provider, built.model);
91
92    // ---- 3b. Wire requirement: a pack whose tools only round-trip on a specific
93    //          wire (codex's freeform `apply_patch` → openai-responses, D5) rejects
94    //          a mismatched `--api-schema` here, before the loop. ----
95    enforce_wire_requirement(pack, provider.api_schema())?;
96
97    // ---- 4. Pack: preamble (system prompt + env + first-turn reminder). ----
98    let pack_ctx = PackContext {
99        cwd: cwd.clone(),
100        os: std::env::consts::OS.to_string(),
101        shell: std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
102        date: chrono::Local::now().format("%Y-%m-%d").to_string(),
103        headless: true,
104        is_git_repo: detect_git_repo(&cwd),
105        model: Some(model.clone()),
106        os_version: os_version(),
107        timezone: timezone(),
108        strip_identity: cli.strip_identity,
109    };
110    // A resumed session's preamble IS the recovered history (the pack preamble
111    // is already inside it — it was traced); Init then carries the full
112    // transcript, keeping the stream self-sufficient with zero engine changes.
113    let preamble = match &identity.resumed {
114        Some(resumed) => resumed.history.clone(),
115        None => pack.preamble(&pack_ctx),
116    };
117
118    // Pack-specific user-prompt shaping (grok wraps in <user_query>; claude sends
119    // it verbatim). The pack owns the shape — the exec layer stays harness-agnostic.
120    let user_prompt = pack.shape_user_prompt(&prompt);
121
122    // ---- 5. Engine config + event sink per output mode. ----
123    let config = EngineConfig {
124        session_id,
125        harness: pack.name().to_string(),
126        api_schema: provider.api_schema().to_string(),
127        model,
128        cwd: cwd.clone(),
129        workspace_root: cwd,
130        max_turns: cli.max_turns,
131        sampling_args: SamplingArgs::default(),
132        cache_hint: CacheHint::Standard,
133        // Opt-in headless streaming (`--stream`) — required for unbounded output
134        // (Anthropic rejects non-streaming past ~10 min). Off by default keeps
135        // `-p` byte-for-byte as it was (ADR-0021).
136        streaming: cli.stream,
137        // Project-instruction loading (`AGENTS.md`, ADR-0023) — on by default,
138        // `--no-project-instructions` opts out. `root_stop_pattern` threads
139        // through from settings (ADR-0024 §1.4; matching activates in S2).
140        instructions: InstructionsConfig {
141            enabled: !cli.no_project_instructions,
142            root_stop_pattern: settings.root_stop_pattern.clone(),
143            ..InstructionsConfig::default()
144        },
145        ..EngineConfig::default()
146    };
147    // ---- 5b. Session trace (ADR-0024 §2): every run appends a rollout under
148    //          `<locode home>/sessions/<encoded-cwd>/`. See `build_trace_writer`.
149    let mut trace = build_trace_writer(&cli, &identity, &config.cwd);
150
151    let sink = make_sink(cli.output_format, trace.take());
152
153    // ---- 6. Drive to a terminal state (infallible) and emit the artifact. ----
154    let mut session = Session::new(provider, registry, preamble, config, sink);
155    #[cfg(unix)]
156    crate::signal::arm(&cancel_slot, session.cancel_handle());
157    let report = session.run_text(user_prompt).await;
158
159    match cli.output_format {
160        OutputFormat::Json => output::write_json_line(&report),
161        OutputFormat::Text => output::write_text(report.final_message.as_deref().unwrap_or("")),
162        OutputFormat::StreamJson => {} // the result event already streamed
163    }
164    Ok(output::exit_code(report.status))
165}
166
167/// A recovered session (`-c`/`-r`): its rollout path + replayable history.
168struct ResumedSession {
169    path: std::path::PathBuf,
170    history: Vec<locode_core::Message>,
171}
172
173/// The run identity: pack/wire/model/session-id, from flags, settings, and (for
174/// `-c`/`-r`) the rollout header — which wins over settings but loses to an
175/// explicit flag only by *erroring* (no silent pack/wire swap mid-transcript).
176struct RunIdentity {
177    harness: String,
178    api_schema: String,
179    model_override: Option<String>,
180    session_id: String,
181    resumed: Option<ResumedSession>,
182}
183
184fn resolve_identity(
185    cli: &Cli,
186    cwd: &std::path::Path,
187    settings: &locode_core::Settings,
188) -> Result<RunIdentity, PreRunError> {
189    // Locate + read the rollout when resuming.
190    let recovered = if cli.continue_session || cli.resume.is_some() {
191        let home = locode_core::locode_home().map_err(PreRunError)?;
192        let root = home.join("sessions");
193        let path = if let Some(id) = &cli.resume {
194            locode_core::find_rollout_by_id(&root, cwd, id)
195                .ok_or_else(|| PreRunError(format!("--resume: no session `{id}` found")))?
196        } else {
197            locode_core::find_latest_rollout(&root, cwd).ok_or_else(|| {
198                PreRunError(format!(
199                    "--continue: no session found for {}",
200                    cwd.display()
201                ))
202            })?
203        };
204        let contents = locode_core::read_rollout(&path).map_err(PreRunError)?;
205        Some((path, contents))
206    } else {
207        None
208    };
209
210    if let Some((path, contents)) = recovered {
211        let meta = contents.meta;
212        // Explicit flags may confirm the recorded identity, never change it.
213        if let Some(flag) = cli.harness
214            && flag.as_str() != meta.harness
215        {
216            return Err(PreRunError(format!(
217                "--harness {} conflicts with the resumed session's harness `{}`",
218                flag.as_str(),
219                meta.harness
220            )));
221        }
222        if let Some(flag) = &cli.api_schema
223            && flag != &meta.api_schema
224        {
225            return Err(PreRunError(format!(
226                "--api-schema {flag} conflicts with the resumed session's wire `{}` \
227                 (a session never crosses wires)",
228                meta.api_schema
229            )));
230        }
231        return Ok(RunIdentity {
232            harness: meta.harness.clone(),
233            api_schema: meta.api_schema.clone(),
234            // The model is deliberately NOT recovered from the header (user
235            // decision 2026-07-24): resume resolves it exactly like a fresh
236            // run — flag > settings > the wire's default. Pack/wire stay
237            // header-bound because they affect transcript validity; the model
238            // doesn't, and yesterday's model should not leak into today.
239            model_override: cli.model.clone().or_else(|| settings.model.clone()),
240            session_id: meta.session_id.clone(),
241            resumed: Some(ResumedSession {
242                path,
243                history: contents.history,
244            }),
245        });
246    }
247
248    Ok(RunIdentity {
249        harness: match cli.harness {
250            Some(harness) => harness.as_str().to_string(),
251            None => settings
252                .harness
253                .clone()
254                .unwrap_or_else(|| "claude".to_string()),
255        },
256        api_schema: cli
257            .api_schema
258            .clone()
259            .or_else(|| settings.api_schema.clone())
260            .unwrap_or_else(|| "anthropic".to_string()),
261        model_override: cli.model.clone().or_else(|| settings.model.clone()),
262        session_id: new_session_id(),
263        resumed: None,
264    })
265}
266
267/// The session-trace writer (ADR-0024 §2): decoration over the event sink, so
268/// tracing needs zero engine changes. A resumed run reopens the same rollout for
269/// appending; a fresh run creates a new one. `None` — no rollout written — when
270/// there is no `~/.locode` home, `--no-session-persistence` is set, or a resume
271/// reopen fails (warned, never fatal).
272fn build_trace_writer(
273    cli: &Cli,
274    identity: &RunIdentity,
275    cwd: &std::path::Path,
276) -> Option<locode_core::TraceWriter> {
277    let root = locode_core::locode_home()
278        .ok()
279        .filter(|_| !cli.no_session_persistence)?
280        .join("sessions");
281    match &identity.resumed {
282        // Reopen the same rollout for appending (id and file continue).
283        Some(resumed) => locode_core::TraceWriter::resume(resumed.path.clone(), root)
284            .map_err(|e| output::warning_line(&format!("trace: {e}; tracing disabled")))
285            .ok(),
286        None => Some(locode_core::TraceWriter::new(
287            root,
288            locode_core::TraceExtras {
289                cli_version: env!("CARGO_PKG_VERSION").to_string(),
290                git: git_meta(cwd),
291                ..Default::default()
292            },
293        )),
294    }
295}
296
297/// The event sink for `output_format`, decorated with the session-trace writer
298/// (ADR-0024 §2): every event feeds the trace first; a trace failure warns once
299/// and disables tracing, never the run. `stream-json` additionally writes the
300/// whole-message JSONL to stdout; `json`/`text` emit nothing per-event.
301fn make_sink(
302    output_format: OutputFormat,
303    mut trace: Option<locode_core::TraceWriter>,
304) -> Box<dyn EventSink> {
305    let stream = matches!(output_format, OutputFormat::StreamJson);
306    Box::new(FnSink(move |event| {
307        if let Some(writer) = trace.as_mut() {
308            writer.on_event(&event);
309            if let Some(e) = writer.take_error() {
310                output::warning_line(&format!("trace: {e}; tracing disabled"));
311            }
312        }
313        if stream && in_whole_message_trace(&event) {
314            output::write_json_line(&event);
315        }
316    }))
317}
318
319/// Reject a `--api-schema` a pack's tools can't round-trip on (codex's freeform
320/// `apply_patch` requires the OpenAI Responses wire, D5). `mock` (keyless CI) is
321/// the universal escape hatch and is always allowed, independent of the pack list.
322fn enforce_wire_requirement(pack: &dyn locode_core::Pack, schema: &str) -> Result<(), PreRunError> {
323    if schema != "mock"
324        && let Some(required) = pack.required_api_schemas()
325        && !required.contains(&schema)
326    {
327        return Err(PreRunError(format!(
328            "harness `{}` requires one of these wires: {}; got `--api-schema {}`",
329            pack.name(),
330            required.join(", "),
331            schema,
332        )));
333    }
334    Ok(())
335}
336
337/// Positional prompt, or stdin when absent / `-` (Codex's convention;
338/// positional XOR stdin per the plan addendum). Empty → usage error.
339fn resolve_prompt(arg: Option<&str>) -> Result<String, PreRunError> {
340    let prompt = match arg {
341        Some("-") | None => {
342            let mut buf = String::new();
343            std::io::stdin().read_to_string(&mut buf)?;
344            buf
345        }
346        Some(text) => text.to_string(),
347    };
348    let prompt = prompt.trim().to_string();
349    if prompt.is_empty() {
350        return Err(PreRunError(
351            "no prompt: pass it as the positional argument or on stdin".to_string(),
352        ));
353    }
354    Ok(prompt)
355}
356
357/// Best-effort git provenance for the trace header (ADR-0024 §2.3) — one
358/// `git rev-parse` + one `remote get-url`, all fields optional; `None` when not
359/// in a repo at all.
360fn git_meta(cwd: &std::path::Path) -> Option<locode_core::GitMeta> {
361    if !detect_git_repo(cwd) {
362        return None;
363    }
364    let run = |args: &[&str]| -> Option<String> {
365        let out = std::process::Command::new("git")
366            .arg("-C")
367            .arg(cwd)
368            .args(args)
369            .output()
370            .ok()?;
371        if !out.status.success() {
372            return None;
373        }
374        let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
375        (!s.is_empty()).then_some(s)
376    };
377    Some(locode_core::GitMeta {
378        root: run(&["rev-parse", "--show-toplevel"]).map(std::path::PathBuf::from),
379        branch: run(&["rev-parse", "--abbrev-ref", "HEAD"]),
380        head: run(&["rev-parse", "HEAD"]),
381        remote: run(&["remote", "get-url", "origin"]),
382    })
383}
384
385/// Whether `cwd` is inside a git repository — walk up looking for a `.git` entry
386/// (a cheap probe for the Claude pack's env `Is a git repository:` line, D9; no
387/// host handle needed in `preamble()`).
388fn detect_git_repo(cwd: &std::path::Path) -> bool {
389    cwd.ancestors().any(|dir| dir.join(".git").exists())
390}
391
392/// `uname -s -r` for the Claude pack's env `OS Version:` line; `None` off Unix or
393/// if the probe fails.
394fn os_version() -> Option<String> {
395    #[cfg(unix)]
396    {
397        let out = std::process::Command::new("uname")
398            .args(["-s", "-r"])
399            .output()
400            .ok()?;
401        if !out.status.success() {
402            return None;
403        }
404        let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
405        (!s.is_empty()).then_some(s)
406    }
407    #[cfg(not(unix))]
408    {
409        None
410    }
411}
412
413/// Best-effort IANA timezone name for the codex pack's `<environment_context>`
414/// (`America/Los_Angeles`), dependency-free: `$TZ` if set, else the `/etc/localtime`
415/// symlink target after `zoneinfo/`. `None` when neither resolves (the pack then
416/// omits the `<timezone>` line — codex's field is optional).
417fn timezone() -> Option<String> {
418    if let Ok(tz) = std::env::var("TZ") {
419        let tz = tz.trim();
420        if !tz.is_empty() {
421            return Some(tz.to_string());
422        }
423    }
424    #[cfg(unix)]
425    {
426        let target = std::fs::read_link("/etc/localtime").ok()?;
427        let s = target.to_string_lossy();
428        s.split_once("zoneinfo/")
429            .map(|(_, name)| name.to_string())
430            .filter(|name| !name.is_empty())
431    }
432    #[cfg(not(unix))]
433    {
434        None
435    }
436}
437
438/// A unique-enough session id for a headless run (no uuid dep in v0).
439fn new_session_id() -> String {
440    let now = std::time::SystemTime::now()
441        .duration_since(std::time::UNIX_EPOCH)
442        .map_or(0, |d| d.as_millis());
443    format!("sess-{now}-{}", std::process::id())
444}
445
446/// Whether an event belongs in the whole-message `stream-json` trace (ADR-0021
447/// Q1): everything except live token deltas, which are a TUI-only concern so the
448/// trace stays replayable/whole-message even under `--stream`.
449fn in_whole_message_trace(event: &locode_core::Event) -> bool {
450    !matches!(event, locode_core::Event::MessageDelta { .. })
451}
452
453#[cfg(test)]
454mod tests {
455    use super::{enforce_wire_requirement, in_whole_message_trace};
456    use locode_core::{Event, Message, Role};
457
458    #[test]
459    fn codex_rejects_a_non_responses_wire() {
460        let codex = locode_core::resolve("codex").unwrap();
461        // A real, mismatched wire is rejected pre-run with an actionable message.
462        let err = enforce_wire_requirement(codex, "anthropic").expect_err("mismatch");
463        assert!(err.0.contains("codex"), "{}", err.0);
464        assert!(err.0.contains("openai-responses"), "{}", err.0);
465        assert!(err.0.contains("anthropic"), "{}", err.0);
466        // The required wire and the keyless-CI escape hatch both pass.
467        assert!(enforce_wire_requirement(codex, "openai-responses").is_ok());
468        assert!(enforce_wire_requirement(codex, "mock").is_ok());
469    }
470
471    #[test]
472    fn wire_agnostic_packs_accept_any_wire() {
473        let grok = locode_core::resolve("grok").unwrap();
474        assert!(enforce_wire_requirement(grok, "anthropic").is_ok());
475        assert!(enforce_wire_requirement(grok, "openai-responses").is_ok());
476    }
477
478    #[test]
479    fn stream_json_trace_drops_message_deltas_keeps_whole_messages() {
480        // Token deltas are dropped from the trace...
481        assert!(!in_whole_message_trace(&Event::MessageDelta {
482            text: "tok".into()
483        }));
484        // ...but whole messages and every other event stay.
485        assert!(in_whole_message_trace(&Event::Message {
486            message: Message {
487                role: Role::Assistant,
488                content: vec![],
489            },
490        }));
491        assert!(in_whole_message_trace(&Event::Error {
492            message: "e".into()
493        }));
494    }
495}