Skip to main content

outlayer_cli/commands/
run.rs

1use anyhow::{Context, Result};
2use serde_json::{json, Value};
3use sha2::{Digest, Sha256};
4
5use crate::api::{ApiClient, HttpsCallRequest, SecretsRef};
6use crate::config::{self, NetworkConfig};
7use crate::near::{ContractCaller, NearClient};
8
9// ── Source types ──────────────────────────────────────────────────────
10
11pub enum RunSource {
12    Project {
13        project_id: String,
14        version: Option<String>,
15    },
16    GitHub {
17        repo: String,
18        commit: Option<String>,
19    },
20    WasmUrl {
21        url: String,
22        hash: Option<String>,
23    },
24}
25
26impl RunSource {
27    /// Build contract-format JSON for request_execution
28    async fn to_contract_json(&self, build_target: &str) -> Result<Value> {
29        match self {
30            RunSource::Project { project_id, version } => Ok(json!({
31                "Project": {
32                    "project_id": project_id,
33                    "version_key": version
34                }
35            })),
36            RunSource::GitHub { repo, commit } => {
37                let commit = commit.as_deref().unwrap_or("main");
38                Ok(json!({
39                    "GitHub": {
40                        "repo": repo,
41                        "commit": commit,
42                        "build_target": build_target
43                    }
44                }))
45            }
46            RunSource::WasmUrl { url, hash } => {
47                let hash = match hash.as_deref() {
48                    Some(h) => h.to_string(),
49                    None => {
50                        eprintln!("Downloading WASM to compute hash...");
51                        compute_wasm_hash(url).await?
52                    }
53                };
54                Ok(json!({
55                    "WasmUrl": {
56                        "url": url,
57                        "hash": hash,
58                        "build_target": build_target
59                    }
60                }))
61            }
62        }
63    }
64
65    fn label(&self) -> String {
66        match self {
67            RunSource::Project { project_id, .. } => project_id.clone(),
68            RunSource::GitHub { repo, .. } => format!("github:{repo}"),
69            RunSource::WasmUrl { url, .. } => format!("wasm:{url}"),
70        }
71    }
72}
73
74// ── Run ──────────────────────────────────────────────────────────────
75
76/// `outlayer run` — execute agent from project, github, or wasm url
77#[allow(clippy::too_many_arguments)]
78pub async fn run(
79    network: &NetworkConfig,
80    source: RunSource,
81    input: Option<String>,
82    input_file: Option<String>,
83    is_async: bool,
84    deposit: Option<String>,
85    compute_limit: Option<u64>,
86    build_target: &str,
87    secrets_ref: Option<SecretsRef>,
88) -> Result<()> {
89    let input_value = parse_input(input, input_file)?;
90
91    // HTTPS API only works for Project source with payment key
92    if let RunSource::Project { ref project_id, ref version } = source {
93        if let Ok(payment_key) = find_payment_key() {
94            return run_https(
95                network,
96                project_id,
97                &payment_key,
98                input_value,
99                is_async,
100                deposit,
101                version.clone(),
102                compute_limit,
103                secrets_ref,
104            )
105            .await;
106        }
107    }
108
109    // On-chain for all source types
110    if is_async {
111        anyhow::bail!("--async is only supported with payment key (HTTPS API)");
112    }
113
114    eprintln!("Executing on-chain with NEAR deposit.\n");
115    run_on_chain(network, &source, input_value, compute_limit, build_target, secrets_ref).await
116}
117
118/// Execute via HTTPS API (Project source + payment key)
119async fn run_https(
120    network: &NetworkConfig,
121    project_id: &str,
122    payment_key: &str,
123    input_value: Value,
124    is_async: bool,
125    deposit: Option<String>,
126    version: Option<String>,
127    compute_limit: Option<u64>,
128    secrets_ref: Option<SecretsRef>,
129) -> Result<()> {
130    let (owner, project) = split_project(project_id)?;
131
132    let api = ApiClient::new(network);
133    let body = HttpsCallRequest {
134        input: input_value,
135        is_async,
136        version_key: version,
137        secrets_ref,
138    };
139
140    eprintln!("Running {owner}/{project}...");
141
142    let response = api
143        .call_project(
144            owner,
145            project,
146            payment_key,
147            &body,
148            compute_limit,
149            deposit.as_deref(),
150        )
151        .await?;
152
153    if is_async {
154        eprintln!("Call ID: {}", response.call_id);
155        eprintln!("Status:  {}", response.status);
156        if let Some(poll_url) = &response.poll_url {
157            eprintln!("Poll:    {poll_url}");
158        }
159    } else {
160        if let Some(output) = &response.output {
161            println!(
162                "{}",
163                serde_json::to_string_pretty(output).unwrap_or_else(|_| output.to_string())
164            );
165        }
166        if let Some(error) = &response.error {
167            eprintln!("Error: {error}");
168        }
169        if let Some(cost) = &response.compute_cost {
170            eprintln!("Cost: {cost}");
171        }
172        if let Some(time_ms) = response.time_ms {
173            eprintln!("Time: {time_ms}ms");
174        }
175    }
176
177    Ok(())
178}
179
180/// Execute on-chain via request_execution (any source type)
181async fn run_on_chain(
182    network: &NetworkConfig,
183    source: &RunSource,
184    input_value: Value,
185    compute_limit: Option<u64>,
186    build_target: &str,
187    secrets_ref: Option<SecretsRef>,
188) -> Result<()> {
189    let creds = config::load_credentials(network)?;
190    let near = NearClient::new(network);
191    let caller = ContractCaller::from_credentials(&creds, network)?;
192
193    let resource_limits = json!({
194        "max_instructions": compute_limit.unwrap_or(1_000_000_000),
195        "max_memory_mb": 128,
196        "max_execution_seconds": 10
197    });
198
199    // Estimate cost
200    let cost_str: String = near
201        .estimate_execution_cost(Some(resource_limits.clone()))
202        .await?;
203    let cost_yocto: u128 = cost_str
204        .trim_matches('"')
205        .parse()
206        .context("Failed to parse execution cost")?;
207
208    let cost_near = cost_yocto as f64 / 1e24;
209    let label = source.label();
210    eprintln!("Running {label} (on-chain, ~{cost_near:.4} NEAR)...");
211
212    let source_json = source.to_contract_json(build_target).await?;
213    let input_data = serde_json::to_string(&input_value)?;
214    let gas = 300_000_000_000_000u64; // 300 TGas
215
216    let secrets_ref_json = secrets_ref
217        .map(|sr| json!({"profile": sr.profile, "account_id": sr.account_id}))
218        .unwrap_or(Value::Null);
219
220    let result = caller
221        .call_contract(
222            "request_execution",
223            json!({
224                "source": source_json,
225                "resource_limits": resource_limits,
226                "input_data": input_data,
227                "secrets_ref": secrets_ref_json,
228                "response_format": "Json",
229                "payer_account_id": null,
230                "params": null
231            }),
232            gas,
233            cost_yocto,
234        )
235        .await
236        .context("On-chain execution failed")?;
237
238    if let Some(hash) = &result.tx_hash {
239        eprintln!("Tx: {hash}");
240    }
241
242    // Print result
243    if let Some(value) = &result.value {
244        if !value.is_null() {
245            println!(
246                "{}",
247                serde_json::to_string_pretty(value)
248                    .unwrap_or_else(|_| value.to_string())
249            );
250        }
251    }
252
253    Ok(())
254}
255
256// ── Helpers ────────────────────────────────────────────────────────────
257
258fn split_project(project_id: &str) -> Result<(&str, &str)> {
259    project_id
260        .split_once('/')
261        .context("Project must be in format owner/name (e.g. alice.near/my-agent)")
262}
263
264async fn compute_wasm_hash(url: &str) -> Result<String> {
265    let client = reqwest::Client::new();
266    let resp = client
267        .get(url)
268        .send()
269        .await
270        .with_context(|| format!("Failed to download WASM from {url}"))?;
271
272    if !resp.status().is_success() {
273        anyhow::bail!("Failed to download WASM: HTTP {}", resp.status());
274    }
275
276    let bytes = resp.bytes().await.context("Failed to read WASM body")?;
277    let hash = hex::encode(Sha256::digest(&bytes));
278    eprintln!("Hash: {hash} ({} bytes)", bytes.len());
279    Ok(hash)
280}
281
282fn find_payment_key() -> Result<String> {
283    if let Ok(key) = std::env::var("PAYMENT_KEY") {
284        return Ok(key);
285    }
286
287    let cwd = std::env::current_dir()?;
288    let env_path = cwd.join(".env");
289    if env_path.exists() {
290        let content = std::fs::read_to_string(&env_path)?;
291        for line in content.lines() {
292            if let Some(val) = line.strip_prefix("PAYMENT_KEY=") {
293                return Ok(val.to_string());
294            }
295        }
296    }
297
298    anyhow::bail!("No payment key found")
299}
300
301fn parse_input(input: Option<String>, input_file: Option<String>) -> Result<Value> {
302    if let Some(file) = input_file {
303        let data = std::fs::read_to_string(&file)
304            .with_context(|| format!("Failed to read input file: {file}"))?;
305        serde_json::from_str(&data).with_context(|| format!("Invalid JSON in {file}"))
306    } else if let Some(json_str) = input {
307        serde_json::from_str(&json_str).context("Invalid JSON input")
308    } else if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
309        Ok(Value::Object(serde_json::Map::new()))
310    } else {
311        let mut buf = String::new();
312        std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
313        if buf.trim().is_empty() {
314            Ok(Value::Object(serde_json::Map::new()))
315        } else {
316            serde_json::from_str(&buf).context("Invalid JSON from stdin")
317        }
318    }
319}