Skip to main content

runx_runtime/execution/
orchestrator.rs

1// rust-style-allow: large-file - the canonical entrypoint keeps request/result
2// types and prepared/unprepared execution dispatch in one reviewable boundary.
3//! Canonical local orchestration entrypoint.
4//!
5//! CLI commands and TypeScript wrappers should enter local skill, graph, and
6//! harness execution through this module instead of calling narrower execution
7//! helpers directly.
8
9use std::collections::BTreeMap;
10use std::path::PathBuf;
11
12use runx_contracts::{ClosureDisposition, JsonValue, Receipt};
13use thiserror::Error;
14
15use super::harness::{HarnessReplayError, HarnessReplayOutput};
16use super::prepared_skill::{PreparedEntryProvenance, PreparedSkillRun, prepare_skill_run};
17#[cfg(feature = "cli-tool")]
18use super::runner::GraphRun;
19use super::skill_front::{InlineHarnessReport, SkillRunError};
20use crate::effects::RuntimeEffectRegistry;
21
22#[derive(Clone, Debug, PartialEq)]
23pub struct SkillRunRequest {
24    pub skill_path: PathBuf,
25    pub receipt_dir: Option<PathBuf>,
26    pub run_id: Option<String>,
27    pub answers_path: Option<PathBuf>,
28    pub inputs: BTreeMap<String, JsonValue>,
29    pub env: BTreeMap<String, String>,
30    pub cwd: PathBuf,
31    /// Optional one-shot, per-run local credential supplied at invocation.
32    ///
33    /// When present, the runtime derives a `CredentialDelivery` from it for this
34    /// single run. The secret value is never persisted and is redacted from
35    /// captured output, receipts, and metadata through the existing delivery
36    /// channel. `None` keeps the current no-credential behavior.
37    pub local_credential: Option<LocalCredentialDescriptor>,
38}
39
40/// Structured per-run credential provision request.
41///
42/// This is the local, no-network establishment surface for the OSS CLI: the
43/// caller supplies the non-secret binding fields plus the raw secret value, and
44/// the runtime turns it into a `CredentialDelivery` through the existing opaque
45/// `MaterialResolver`. No secret state is persisted; the descriptor lives only
46/// for the duration of a single run.
47#[derive(Clone, PartialEq, Eq)]
48pub struct LocalCredentialDescriptor {
49    /// Provider the credential authenticates against (for example `github`).
50    pub provider: String,
51    /// Authentication mode label carried on the delivery profile/envelope.
52    pub auth_mode: String,
53    /// Environment variable the secret is delivered into for the skill process.
54    pub env_var: String,
55    /// Opaque reference identifying the in-memory material for this run.
56    pub material_ref: String,
57    /// Scopes recorded on the credential envelope.
58    pub scopes: Vec<String>,
59    /// The raw secret value supplied for this run only.
60    pub secret: String,
61}
62
63// Manual Debug so the raw secret never reaches logs, panics, or any Debug of an
64// enclosing type (e.g. SkillRunRequest).
65impl std::fmt::Debug for LocalCredentialDescriptor {
66    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        formatter
68            .debug_struct("LocalCredentialDescriptor")
69            .field("provider", &self.provider)
70            .field("auth_mode", &self.auth_mode)
71            .field("env_var", &self.env_var)
72            .field("material_ref", &self.material_ref)
73            .field("scopes", &self.scopes)
74            .field("secret", &"[redacted]")
75            .finish()
76    }
77}
78
79#[derive(Clone, Debug, PartialEq, Eq)]
80pub struct GraphRunRequest {
81    pub graph_path: PathBuf,
82}
83
84#[derive(Clone, Debug, PartialEq, Eq)]
85pub struct HarnessRunRequest {
86    pub fixture_path: PathBuf,
87}
88
89/// Request to run a skill's declared inline harness (`harness.cases`) rather than
90/// a standalone fixture file. `skill_path` is a skill package directory or its
91/// `SKILL.md`; receipts each case seals land under `receipt_dir`.
92#[derive(Clone, Debug, PartialEq, Eq)]
93pub struct InlineHarnessRequest {
94    pub skill_path: PathBuf,
95    pub receipt_dir: Option<PathBuf>,
96    pub env: Option<BTreeMap<String, String>>,
97}
98
99#[derive(Clone, Debug, Default, PartialEq, Eq)]
100pub struct RunContinuation {
101    pub run_id: Option<String>,
102    pub answers_path: Option<PathBuf>,
103}
104
105#[derive(Clone, Debug, PartialEq)]
106pub enum RunRequest {
107    Skill(Box<SkillRunRequest>),
108    Graph(GraphRunRequest),
109    Harness(HarnessRunRequest),
110}
111
112#[derive(Clone, Debug, PartialEq)]
113pub struct RunResult {
114    pub status: RunStatus,
115    pub output: JsonValue,
116    pub receipt_refs: Vec<String>,
117    pub child_receipt_refs: Vec<String>,
118    pub pending_requests: Vec<JsonValue>,
119    pub diagnostics: Vec<String>,
120}
121
122#[derive(Clone, Copy, Debug, Eq, PartialEq)]
123pub enum RunStatus {
124    NeedsAgent,
125    Sealed,
126    Succeeded,
127    Failed,
128}
129
130#[derive(Debug, Error)]
131pub enum OrchestratorError {
132    #[error(transparent)]
133    SkillRun(#[from] SkillRunError),
134    #[error(transparent)]
135    Runtime(#[from] crate::RuntimeError),
136    #[error(transparent)]
137    Harness(#[from] HarnessReplayError),
138    #[error(
139        "native graph orchestration is unavailable because runx-runtime was built without the cli-tool feature"
140    )]
141    CliToolFeatureDisabled,
142}
143
144#[derive(Clone, Debug, Default)]
145pub struct LocalOrchestrator {
146    effects: RuntimeEffectRegistry,
147}
148
149impl LocalOrchestrator {
150    #[must_use]
151    pub fn with_effects(effects: RuntimeEffectRegistry) -> Self {
152        Self { effects }
153    }
154
155    pub fn run(&self, request: RunRequest) -> Result<RunResult, OrchestratorError> {
156        match request {
157            RunRequest::Skill(request) => self.run_skill(&request),
158            RunRequest::Graph(request) => self.run_graph(&request),
159            RunRequest::Harness(request) => self.run_harness(&request),
160        }
161    }
162
163    pub fn run_skill(&self, request: &SkillRunRequest) -> Result<RunResult, OrchestratorError> {
164        let output = super::skill_front::execute_skill_run_with_effects(request, &self.effects)?;
165        Ok(skill_result(output))
166    }
167
168    pub fn run_skill_with_runner(
169        &self,
170        request: &SkillRunRequest,
171        runner: &str,
172    ) -> Result<RunResult, OrchestratorError> {
173        let overrides = super::skill_front::SkillRunOverrides {
174            runner: Some(runner.to_owned()),
175            seeded_answers: None,
176        };
177        let output = super::skill_front::execute_skill_run_with_overrides(
178            request,
179            &overrides,
180            &self.effects,
181        )?;
182        Ok(skill_result(output))
183    }
184
185    pub fn prepare_skill(
186        &self,
187        request: SkillRunRequest,
188        runner: Option<&str>,
189        entry: PreparedEntryProvenance,
190    ) -> Result<PreparedSkillRun, OrchestratorError> {
191        Ok(prepare_skill_run(request, runner, entry)?)
192    }
193
194    pub fn run_prepared_skill(
195        &self,
196        prepared: &PreparedSkillRun,
197    ) -> Result<RunResult, OrchestratorError> {
198        if !prepared.is_ready() {
199            return Err(SkillRunError::Invalid(
200                prepared
201                    .report()
202                    .blocked_reason
203                    .clone()
204                    .unwrap_or_else(|| "prepared skill run is blocked".to_owned()),
205            )
206            .into());
207        }
208        if prepared.approval().is_none() {
209            return Err(SkillRunError::Invalid(
210                "prepared skill run requires digest-bound operator approval".to_owned(),
211            )
212            .into());
213        }
214        prepared.verify_artifacts()?;
215        let overrides = super::skill_front::SkillRunOverrides {
216            runner: Some(prepared.selected_runner().to_owned()),
217            seeded_answers: None,
218        };
219        let output = super::skill_front::execute_prepared_skill_run_with_resolved(
220            prepared.request(),
221            &overrides,
222            &self.effects,
223            &prepared.report().request.skill_path,
224            prepared.manifest(),
225            prepared.runner(),
226        )?;
227        Ok(skill_result(output))
228    }
229
230    pub fn run_graph(&self, request: &GraphRunRequest) -> Result<RunResult, OrchestratorError> {
231        #[cfg(feature = "cli-tool")]
232        {
233            let mut options = super::runner::RuntimeOptions::from_process_env()?;
234            options.effects = self.effects.clone();
235            let runtime =
236                super::runner::Runtime::new(crate::adapters::cli_tool::CliToolAdapter, options);
237            graph_result(runtime.run_graph_file(&request.graph_path)?)
238        }
239        #[cfg(not(feature = "cli-tool"))]
240        {
241            let _ = request;
242            Err(OrchestratorError::CliToolFeatureDisabled)
243        }
244    }
245
246    pub fn run_harness(&self, request: &HarnessRunRequest) -> Result<RunResult, OrchestratorError> {
247        harness_result(self.run_harness_fixture(request)?)
248    }
249
250    pub fn run_inline_harness(
251        &self,
252        request: &InlineHarnessRequest,
253    ) -> Result<InlineHarnessReport, OrchestratorError> {
254        Ok(super::skill_front::run_inline_harness_with_effects(
255            &request.skill_path,
256            request.receipt_dir.as_deref(),
257            request.env.as_ref(),
258            &self.effects,
259        )?)
260    }
261
262    #[cfg(feature = "cli-tool")]
263    pub fn run_harness_fixture(
264        &self,
265        request: &HarnessRunRequest,
266    ) -> Result<HarnessReplayOutput, OrchestratorError> {
267        let mut options = super::runner::RuntimeOptions::from_process_env()?;
268        options.created_at = crate::time::DEFAULT_CREATED_AT.to_owned();
269        options.effects = self.effects.clone();
270        Ok(super::harness::run_harness_fixture_with_adapter(
271            &request.fixture_path,
272            super::skill_front::SkillRunGraphAdapter::default(),
273            options,
274        )?)
275    }
276
277    #[cfg(not(feature = "cli-tool"))]
278    pub fn run_harness_fixture(
279        &self,
280        request: &HarnessRunRequest,
281    ) -> Result<HarnessReplayOutput, OrchestratorError> {
282        let _ = self;
283        let _ = request;
284        Err(OrchestratorError::CliToolFeatureDisabled)
285    }
286}
287
288fn skill_result(output: JsonValue) -> RunResult {
289    let status = match object_string(&output, "status") {
290        Some("needs_agent") => RunStatus::NeedsAgent,
291        Some("sealed") => RunStatus::Sealed,
292        _ => RunStatus::Succeeded,
293    };
294    let receipt_refs = object_string(&output, "receipt_id")
295        .map(|receipt_id| vec![receipt_id.to_owned()])
296        .unwrap_or_default();
297    let pending_requests = object_array(&output, "requests")
298        .map(|requests| requests.to_vec())
299        .unwrap_or_default();
300    RunResult {
301        status,
302        output,
303        receipt_refs,
304        child_receipt_refs: Vec::new(),
305        pending_requests,
306        diagnostics: Vec::new(),
307    }
308}
309
310#[cfg(feature = "cli-tool")]
311fn graph_result(run: GraphRun) -> Result<RunResult, OrchestratorError> {
312    let status = status_from_receipt(&run.receipt);
313    let output = receipt_json(&run.receipt)?;
314    Ok(RunResult {
315        status,
316        output,
317        receipt_refs: vec![run.receipt.id.to_string()],
318        child_receipt_refs: child_receipt_refs(&run.receipt),
319        pending_requests: Vec::new(),
320        diagnostics: Vec::new(),
321    })
322}
323
324fn harness_result(output: HarnessReplayOutput) -> Result<RunResult, OrchestratorError> {
325    let status = status_from_receipt(&output.receipt);
326    let value = receipt_json(&output.receipt)?;
327    Ok(RunResult {
328        status,
329        output: value,
330        receipt_refs: vec![output.receipt.id.to_string()],
331        child_receipt_refs: child_receipt_refs(&output.receipt),
332        pending_requests: Vec::new(),
333        diagnostics: Vec::new(),
334    })
335}
336
337fn status_from_receipt(receipt: &Receipt) -> RunStatus {
338    match receipt.seal.disposition {
339        ClosureDisposition::Closed => RunStatus::Sealed,
340        _ => RunStatus::Failed,
341    }
342}
343
344fn receipt_json(receipt: &Receipt) -> Result<JsonValue, OrchestratorError> {
345    let value = serde_json::to_value(receipt)
346        .map_err(|source| crate::RuntimeError::json("serializing orchestrated receipt", source))?;
347    serde_json::from_value(value)
348        .map_err(|source| crate::RuntimeError::json("normalizing orchestrated receipt", source))
349        .map_err(Into::into)
350}
351
352fn child_receipt_refs(receipt: &Receipt) -> Vec<String> {
353    receipt
354        .lineage
355        .as_ref()
356        .map(|lineage| {
357            lineage
358                .children
359                .iter()
360                .map(|reference| reference.uri.clone().into_string())
361                .collect()
362        })
363        .unwrap_or_default()
364}
365
366fn object_string<'a>(value: &'a JsonValue, key: &str) -> Option<&'a str> {
367    let JsonValue::Object(object) = value else {
368        return None;
369    };
370    let JsonValue::String(value) = object.get(key)? else {
371        return None;
372    };
373    Some(value)
374}
375
376fn object_array<'a>(value: &'a JsonValue, key: &str) -> Option<&'a Vec<JsonValue>> {
377    let JsonValue::Object(object) = value else {
378        return None;
379    };
380    let JsonValue::Array(value) = object.get(key)? else {
381        return None;
382    };
383    Some(value)
384}