1use 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 pub local_credential: Option<LocalCredentialDescriptor>,
35}
36
37#[derive(Clone, PartialEq, Eq)]
45pub struct LocalCredentialDescriptor {
46 pub provider: String,
48 pub auth_mode: String,
50 pub env_var: String,
52 pub material_ref: String,
54 pub scopes: Vec<String>,
56 pub secret: String,
58}
59
60impl 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#[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}