Skip to main content

runx_runtime/execution/
orchestrator.rs

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