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
9pub 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 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#[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 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 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
118async 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
180async 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 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; 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 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
256fn 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}