1use 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 pub local_credential: Option<LocalCredentialDescriptor>,
38}
39
40#[derive(Clone, PartialEq, Eq)]
48pub struct LocalCredentialDescriptor {
49 pub provider: String,
51 pub auth_mode: String,
53 pub env_var: String,
55 pub material_ref: String,
57 pub scopes: Vec<String>,
59 pub secret: String,
61}
62
63impl 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#[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}