Skip to main content

runx_cli/
runtime.rs

1// rust-style-allow: large-file because CLI runtime wiring binds payment
2// finality supervisor selection, external adapter translation, and receipt
3// metadata persistence at one audited command boundary.
4use std::env;
5use std::fs;
6use std::path::PathBuf;
7
8use runx_contracts::{
9    ExternalAdapterInvocation, ExternalAdapterInvocationSchema, ExternalAdapterManifest,
10    ExternalAdapterProtocolVersion, JsonObject, JsonValue, Reference, ReferenceType,
11};
12use runx_pay::{
13    DeterministicPaymentFinalitySupervisor, PaymentFinalitySupervisor,
14    PaymentFinalitySupervisorError, PaymentFinalitySupervisorEvidence,
15    PaymentFinalitySupervisorRequest, PaymentRuntimeEffect,
16    ledger::{X402_PAY_PAYMENT_PROFILE, persist_x402_payment_ledger_projection_event},
17    supervisor::{
18        payment_finality_supervisor_evidence_payload, payment_supervisor_evidence_from_payload,
19    },
20};
21use runx_runtime::{
22    CredentialDelivery, HarnessReplayOutput, LocalOrchestrator, ProviderPermissionEffect,
23    RUNX_RECEIPT_DIR_ENV, RuntimeEffectRegistry,
24    adapters::external_adapter::{
25        ExternalAdapterProcessOutcome, ExternalAdapterProcessSupervisor, ExternalAdapterSupervisor,
26    },
27};
28
29pub const RUNX_PAYMENT_FINALITY_SUPERVISOR_MANIFEST_ENV: &str =
30    "RUNX_PAYMENT_FINALITY_SUPERVISOR_MANIFEST";
31const PAYMENT_FINALITY_SUPERVISOR_SKILL_REF: &str = "runx/payment-finality-supervisor";
32
33#[must_use]
34pub fn local_orchestrator() -> LocalOrchestrator {
35    LocalOrchestrator::with_effects(payment_effect_registry())
36}
37
38#[must_use]
39pub fn payment_effect_registry() -> RuntimeEffectRegistry {
40    let mut registry = RuntimeEffectRegistry::with_effect(PaymentRuntimeEffect::new(
41        ConfiguredPaymentFinalitySupervisor::from_env(),
42    ));
43    let _ = registry.register_effect(ProviderPermissionEffect);
44    registry
45}
46
47pub fn persist_payment_ledger_projection(output: &HarnessReplayOutput) -> Result<(), String> {
48    if metadata_string(output, "payment_ledger_profile") != Some(X402_PAY_PAYMENT_PROFILE) {
49        return Ok(());
50    }
51    let Some(receipt_dir) = env::var_os(RUNX_RECEIPT_DIR_ENV).map(PathBuf::from) else {
52        return Ok(());
53    };
54    let scenario_id = metadata_string(output, "payment_ledger_scenario_id")
55        .ok_or_else(|| "metadata.payment_ledger_scenario_id is required".to_owned())?;
56    persist_x402_payment_ledger_projection_event(
57        receipt_dir,
58        &format!("gx_{}", output.fixture.name),
59        output.receipt.created_at.as_str(),
60        &output.receipt,
61        &output.steps,
62        scenario_id,
63    )
64    .map(|_| ())
65    .map_err(|error| error.to_string())
66}
67
68fn metadata_string<'a>(output: &'a HarnessReplayOutput, key: &str) -> Option<&'a str> {
69    output
70        .fixture
71        .metadata
72        .get(key)
73        .and_then(|value| match value {
74            JsonValue::String(value) => Some(value.as_str()),
75            _ => None,
76        })
77}
78
79enum ConfiguredPaymentFinalitySupervisor {
80    Deterministic(DeterministicPaymentFinalitySupervisor),
81    External(Box<ExternalAdapterPaymentFinalitySupervisor>),
82    Unavailable(String),
83}
84
85impl ConfiguredPaymentFinalitySupervisor {
86    fn from_env() -> Self {
87        let Some(path) = env::var_os(RUNX_PAYMENT_FINALITY_SUPERVISOR_MANIFEST_ENV) else {
88            return Self::Deterministic(DeterministicPaymentFinalitySupervisor);
89        };
90        let path = PathBuf::from(path);
91        match ExternalAdapterPaymentFinalitySupervisor::from_manifest_path(path.clone()) {
92            Ok(supervisor) => Self::External(Box::new(supervisor)),
93            Err(message) => Self::Unavailable(format!(
94                "{}={} is invalid: {message}",
95                RUNX_PAYMENT_FINALITY_SUPERVISOR_MANIFEST_ENV,
96                path.display()
97            )),
98        }
99    }
100}
101
102impl PaymentFinalitySupervisor for ConfiguredPaymentFinalitySupervisor {
103    fn supervise(
104        &self,
105        request: PaymentFinalitySupervisorRequest<'_>,
106    ) -> Result<PaymentFinalitySupervisorEvidence, PaymentFinalitySupervisorError> {
107        match self {
108            Self::Deterministic(supervisor) => supervisor.supervise(request),
109            Self::External(supervisor) => supervisor.supervise(request),
110            Self::Unavailable(message) => Err(PaymentFinalitySupervisorError::InvalidEvidence {
111                message: message.clone(),
112            }),
113        }
114    }
115}
116
117struct ExternalAdapterPaymentFinalitySupervisor<S = ExternalAdapterProcessSupervisor> {
118    manifest: ExternalAdapterManifest,
119    supervisor: S,
120}
121
122impl ExternalAdapterPaymentFinalitySupervisor {
123    fn from_manifest_path(path: PathBuf) -> Result<Self, String> {
124        let raw = fs::read_to_string(&path)
125            .map_err(|source| format!("could not read manifest: {source}"))?;
126        let manifest: ExternalAdapterManifest = serde_json::from_str(&raw)
127            .map_err(|source| format!("manifest JSON is invalid: {source}"))?;
128        Ok(Self::new(manifest, ExternalAdapterProcessSupervisor))
129    }
130}
131
132impl<S> ExternalAdapterPaymentFinalitySupervisor<S> {
133    fn new(manifest: ExternalAdapterManifest, supervisor: S) -> Self {
134        Self {
135            manifest,
136            supervisor,
137        }
138    }
139}
140
141impl<S> PaymentFinalitySupervisor for ExternalAdapterPaymentFinalitySupervisor<S>
142where
143    S: ExternalAdapterSupervisor + Send + Sync,
144{
145    fn supervise(
146        &self,
147        request: PaymentFinalitySupervisorRequest<'_>,
148    ) -> Result<PaymentFinalitySupervisorEvidence, PaymentFinalitySupervisorError> {
149        let invocation = payment_finality_invocation(&self.manifest, &request)?;
150        let outcome = self
151            .supervisor
152            .invoke_external_adapter(&self.manifest, &invocation, &CredentialDelivery::none())
153            .map_err(|source| PaymentFinalitySupervisorError::InvalidEvidence {
154                message: format!("external adapter payment finality supervisor failed: {source}"),
155            })?;
156        let payload = payment_finality_payload_from_outcome(outcome)?;
157        let evidence = payment_supervisor_evidence_from_payload(&payload).map_err(|source| {
158            PaymentFinalitySupervisorError::InvalidEvidence {
159                message: source.to_string(),
160            }
161        })?;
162        Ok(PaymentFinalitySupervisorEvidence::new(
163            request.family,
164            payment_finality_supervisor_evidence_payload(&evidence),
165        ))
166    }
167}
168
169fn payment_finality_invocation(
170    manifest: &ExternalAdapterManifest,
171    request: &PaymentFinalitySupervisorRequest<'_>,
172) -> Result<ExternalAdapterInvocation, PaymentFinalitySupervisorError> {
173    let proof_ref = required_payload_string(&request.payload, "proof_ref")?;
174    let invocation_id = format!("payment_finality.{}.invoke", identifier_segment(proof_ref));
175    let run_id = format!("payment_finality.{}", identifier_segment(proof_ref));
176    let mut inputs = request.payload.clone();
177    inputs.insert(
178        "effect_family".to_owned(),
179        JsonValue::String(request.family.to_owned()),
180    );
181    Ok(ExternalAdapterInvocation {
182        schema: ExternalAdapterInvocationSchema::V1,
183        protocol_version: ExternalAdapterProtocolVersion::V1,
184        invocation_id: invocation_id.into(),
185        adapter_id: manifest.adapter_id.clone(),
186        run_id: run_id.clone().into(),
187        step_id: "payment_finality".into(),
188        source_type: "external-adapter".into(),
189        skill_ref: PAYMENT_FINALITY_SUPERVISOR_SKILL_REF.into(),
190        harness_ref: Reference::with_uri(ReferenceType::Harness, format!("runx:harness:{run_id}")),
191        host_ref: Reference::with_uri(ReferenceType::Host, "runx:host:cli"),
192        inputs,
193        resolved_inputs: None,
194        cwd: env::current_dir()
195            .ok()
196            .map(|path| path.to_string_lossy().into_owned())
197            .filter(|value| !value.is_empty())
198            .map(Into::into),
199        receipt_dir: env::var(RUNX_RECEIPT_DIR_ENV).ok().map(Into::into),
200        env: None,
201        credential_refs: None,
202        metadata: None,
203    })
204}
205
206fn payment_finality_payload_from_outcome(
207    outcome: ExternalAdapterProcessOutcome,
208) -> Result<JsonObject, PaymentFinalitySupervisorError> {
209    let Some(output) = outcome.response.output else {
210        return Err(PaymentFinalitySupervisorError::InvalidEvidence {
211            message: "external adapter payment finality supervisor returned no output".to_owned(),
212        });
213    };
214    match output.get("payment_finality_evidence") {
215        Some(JsonValue::Object(payload)) => Ok(payload.clone()),
216        Some(_) => Err(PaymentFinalitySupervisorError::InvalidEvidence {
217            message: "external adapter payment_finality_evidence must be an object".to_owned(),
218        }),
219        None => Ok(output),
220    }
221}
222
223fn required_payload_string<'a>(
224    payload: &'a JsonObject,
225    field: &'static str,
226) -> Result<&'a str, PaymentFinalitySupervisorError> {
227    match payload.get(field) {
228        Some(JsonValue::String(value)) if !value.is_empty() => Ok(value),
229        Some(_) => Err(PaymentFinalitySupervisorError::InvalidEvidence {
230            message: format!("payment finality request field {field} must be a non-empty string"),
231        }),
232        None => Err(PaymentFinalitySupervisorError::InvalidEvidence {
233            message: format!("payment finality request field {field} is missing"),
234        }),
235    }
236}
237
238fn identifier_segment(value: &str) -> String {
239    let segment: String = value
240        .chars()
241        .map(|ch| {
242            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
243                ch
244            } else {
245                '_'
246            }
247        })
248        .collect();
249    if segment.is_empty() {
250        "unknown".to_owned()
251    } else {
252        segment
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use std::sync::Mutex;
259
260    use runx_contracts::{
261        EXTERNAL_ADAPTER_PROTOCOL_VERSION, ExternalAdapterResponse, ExternalAdapterSandboxIntent,
262        ExternalAdapterStatus, ExternalAdapterTimeouts, ExternalAdapterTransport,
263        ExternalAdapterTransportKind, JsonNumber,
264    };
265    use runx_pay::PAYMENT_EFFECT_FAMILY;
266
267    use super::*;
268
269    #[test]
270    fn external_adapter_payment_finality_supervisor_round_trips_evidence() -> Result<(), String> {
271        let supervisor = RecordingSupervisor::with_payload(nested_evidence_output());
272        let adapter = ExternalAdapterPaymentFinalitySupervisor::new(test_manifest(), supervisor);
273        let result = adapter
274            .supervise(test_request())
275            .map_err(|error| error.to_string())?;
276
277        assert_eq!(result.family, PAYMENT_EFFECT_FAMILY);
278        assert_eq!(
279            result.payload.get("proof_ref"),
280            Some(&JsonValue::String("proof_x402_1".to_owned()))
281        );
282        let invocation = adapter
283            .supervisor
284            .last_invocation
285            .lock()
286            .map_err(|_| "recording supervisor lock poisoned".to_owned())?
287            .clone()
288            .ok_or_else(|| "invocation captured".to_owned())?;
289        assert_eq!(invocation.source_type.as_str(), "external-adapter");
290        assert_eq!(
291            invocation.inputs.get("effect_family"),
292            Some(&JsonValue::String(PAYMENT_EFFECT_FAMILY.to_owned()))
293        );
294        Ok(())
295    }
296
297    #[test]
298    fn external_adapter_payment_finality_supervisor_accepts_direct_evidence_output()
299    -> Result<(), String> {
300        let supervisor = RecordingSupervisor::with_payload(evidence_payload());
301        let adapter = ExternalAdapterPaymentFinalitySupervisor::new(test_manifest(), supervisor);
302        let result = adapter
303            .supervise(test_request())
304            .map_err(|error| error.to_string())?;
305
306        assert_eq!(
307            result.payload.get("provider_event_ref"),
308            Some(&JsonValue::String("0xfeed".to_owned()))
309        );
310        Ok(())
311    }
312
313    #[test]
314    fn external_adapter_payment_finality_supervisor_rejects_missing_output() {
315        let supervisor = RecordingSupervisor::without_output();
316        let adapter = ExternalAdapterPaymentFinalitySupervisor::new(test_manifest(), supervisor);
317        let result = adapter.supervise(test_request());
318
319        assert!(matches!(
320            result,
321            Err(PaymentFinalitySupervisorError::InvalidEvidence { .. })
322        ));
323    }
324
325    struct RecordingSupervisor {
326        output: Option<JsonObject>,
327        last_invocation: Mutex<Option<ExternalAdapterInvocation>>,
328    }
329
330    impl RecordingSupervisor {
331        fn with_payload(output: JsonObject) -> Self {
332            Self {
333                output: Some(output),
334                last_invocation: Mutex::new(None),
335            }
336        }
337
338        fn without_output() -> Self {
339            Self {
340                output: None,
341                last_invocation: Mutex::new(None),
342            }
343        }
344    }
345
346    impl ExternalAdapterSupervisor for RecordingSupervisor {
347        fn invoke_external_adapter(
348            &self,
349            manifest: &ExternalAdapterManifest,
350            invocation: &ExternalAdapterInvocation,
351            _credential_delivery: &CredentialDelivery,
352        ) -> Result<
353            ExternalAdapterProcessOutcome,
354            runx_runtime::adapters::external_adapter::ExternalAdapterSupervisorError,
355        > {
356            *self.last_invocation.lock().map_err(|_| {
357                runx_runtime::adapters::external_adapter::ExternalAdapterSupervisorError::Io {
358                    context: "recording external adapter invocation".to_owned(),
359                    source: std::io::Error::other("recording supervisor lock poisoned"),
360                }
361            })? = Some(invocation.clone());
362            Ok(ExternalAdapterProcessOutcome {
363                response: ExternalAdapterResponse {
364                    schema: "runx.external_adapter.response.v1".to_owned(),
365                    protocol_version: EXTERNAL_ADAPTER_PROTOCOL_VERSION.to_owned(),
366                    invocation_id: invocation.invocation_id.to_string(),
367                    adapter_id: manifest.adapter_id.to_string(),
368                    status: ExternalAdapterStatus::Completed,
369                    stdout: None,
370                    stderr: None,
371                    exit_code: Some(Some(0)),
372                    output: self.output.clone(),
373                    artifacts: None,
374                    errors: None,
375                    telemetry: None,
376                    metadata: None,
377                    observed_at: "2026-06-11T00:00:00Z".to_owned(),
378                },
379                process_exit_code: Some(0),
380                duration_ms: 1,
381            })
382        }
383    }
384
385    fn test_manifest() -> ExternalAdapterManifest {
386        ExternalAdapterManifest {
387            schema: runx_contracts::ExternalAdapterManifestSchema::V1,
388            protocol_version: ExternalAdapterProtocolVersion::V1,
389            adapter_id: "x402-finality-test".into(),
390            name: "x402 finality test".into(),
391            version: "0.1.0".into(),
392            supported_source_types: vec!["external-adapter".into()],
393            transport: ExternalAdapterTransport {
394                kind: ExternalAdapterTransportKind::Process,
395                command: Some("node".into()),
396                args: Some(vec!["scripts/x402-testnet-settle.mjs".to_owned()]),
397                endpoint: None,
398            },
399            timeouts: ExternalAdapterTimeouts {
400                startup_ms: 1_000,
401                invocation_ms: 30_000,
402            },
403            credential_needs: None,
404            sandbox_intent: ExternalAdapterSandboxIntent {
405                profile: "network".into(),
406                network: true,
407                cwd_policy: "workspace".into(),
408                writable_paths: None,
409            },
410            metadata: None,
411        }
412    }
413
414    fn test_request() -> PaymentFinalitySupervisorRequest<'static> {
415        PaymentFinalitySupervisorRequest {
416            family: PAYMENT_EFFECT_FAMILY,
417            payload: supervisor_payload(),
418        }
419    }
420
421    fn supervisor_payload() -> JsonObject {
422        let mut payload = JsonObject::new();
423        payload.insert(
424            "skill_settlement_status".to_owned(),
425            JsonValue::String("fulfilled".to_owned()),
426        );
427        payload.insert(
428            "proof_ref".to_owned(),
429            JsonValue::String("proof_x402_1".to_owned()),
430        );
431        payload.insert("rail".to_owned(), JsonValue::String("x402".to_owned()));
432        payload.insert(
433            "counterparty".to_owned(),
434            JsonValue::String("merchant:demo".to_owned()),
435        );
436        payload.insert(
437            "amount_minor".to_owned(),
438            JsonValue::Number(JsonNumber::U64(125)),
439        );
440        payload.insert("currency".to_owned(), JsonValue::String("USD".to_owned()));
441        payload.insert(
442            "idempotency_key".to_owned(),
443            JsonValue::String("idem_1".to_owned()),
444        );
445        payload
446    }
447
448    fn nested_evidence_output() -> JsonObject {
449        let mut output = JsonObject::new();
450        output.insert(
451            "payment_finality_evidence".to_owned(),
452            JsonValue::Object(evidence_payload()),
453        );
454        output
455    }
456
457    fn evidence_payload() -> JsonObject {
458        let mut payload = JsonObject::new();
459        payload.insert(
460            "verifier_id".to_owned(),
461            JsonValue::String(runx_pay::supervisor::PAYMENT_RAIL_SUPERVISOR_VERIFIER_ID.to_owned()),
462        );
463        payload.insert(
464            "proof_ref".to_owned(),
465            JsonValue::String("proof_x402_1".to_owned()),
466        );
467        payload.insert("rail".to_owned(), JsonValue::String("x402".to_owned()));
468        payload.insert(
469            "counterparty".to_owned(),
470            JsonValue::String("merchant:demo".to_owned()),
471        );
472        payload.insert(
473            "amount_minor".to_owned(),
474            JsonValue::Number(JsonNumber::U64(125)),
475        );
476        payload.insert("currency".to_owned(), JsonValue::String("USD".to_owned()));
477        payload.insert(
478            "idempotency_key".to_owned(),
479            JsonValue::String("idem_1".to_owned()),
480        );
481        payload.insert(
482            "settlement_status".to_owned(),
483            JsonValue::String("fulfilled".to_owned()),
484        );
485        payload.insert(
486            "provider_event_ref".to_owned(),
487            JsonValue::String("0xfeed".to_owned()),
488        );
489        payload
490    }
491}