1use anyhow::{Context, Result};
7use serde::Deserialize;
8use serde_json::{Value, json};
9
10use crate::cli::LogsArgs;
11use crate::deploy::DeploymentState;
12
13const RAILWAY_API_URL: &str = "https://backboard.railway.app/graphql/v2";
14
15async fn fetch_railway_logs(token: &str, deployment_id: &str, limit: u32) -> Result<Vec<LogEntry>> {
17 let client = reqwest::Client::new();
18
19 let query = r#"query ($deploymentId: String!, $limit: Int) {
20 deploymentLogs(deploymentId: $deploymentId, limit: $limit) {
21 timestamp
22 message
23 severity
24 }
25 }"#;
26
27 let body = json!({
28 "query": query,
29 "variables": {
30 "deploymentId": deployment_id,
31 "limit": limit,
32 },
33 });
34
35 let resp = client
36 .post(RAILWAY_API_URL)
37 .header("Content-Type", "application/json")
38 .header("Authorization", format!("Bearer {token}"))
39 .json(&body)
40 .send()
41 .await
42 .context("failed to reach Railway API")?;
43
44 if !resp.status().is_success() {
45 let status = resp.status();
46 let text = resp.text().await.unwrap_or_default();
47 anyhow::bail!("Railway API returned HTTP {status}: {text}");
48 }
49
50 let json: Value = resp
51 .json()
52 .await
53 .context("failed to parse Railway response")?;
54
55 if let Some(errors) = json.get("errors") {
56 if let Some(arr) = errors.as_array() {
57 if !arr.is_empty() {
58 let messages: Vec<&str> = arr
59 .iter()
60 .filter_map(|e| e.get("message").and_then(Value::as_str))
61 .collect();
62 anyhow::bail!("Railway GraphQL error: {}", messages.join("; "));
63 }
64 }
65 }
66
67 let logs = json
68 .get("data")
69 .and_then(|d| d.get("deploymentLogs"))
70 .cloned()
71 .unwrap_or(Value::Array(vec![]));
72
73 let entries: Vec<LogEntry> = serde_json::from_value(logs).unwrap_or_default();
74 Ok(entries)
75}
76
77async fn fetch_latest_deployment_id(
79 token: &str,
80 project_id: &str,
81 service_name: &str,
82) -> Result<Option<String>> {
83 let client = reqwest::Client::new();
84
85 #[derive(Deserialize)]
86 struct ProjectData {
87 project: ProjectServices,
88 }
89 #[derive(Deserialize)]
90 struct ProjectServices {
91 services: Edges<ServiceNode>,
92 }
93 #[derive(Deserialize)]
94 struct Edges<T> {
95 edges: Vec<Edge<T>>,
96 }
97 #[derive(Deserialize)]
98 struct Edge<T> {
99 node: T,
100 }
101 #[derive(Deserialize)]
102 struct ServiceNode {
103 name: String,
104 #[serde(rename = "serviceInstances")]
105 service_instances: Edges<InstanceNode>,
106 }
107 #[derive(Deserialize)]
108 struct InstanceNode {
109 #[serde(rename = "latestDeployment")]
110 latest_deployment: Option<DeploymentRef>,
111 }
112 #[derive(Deserialize)]
113 struct DeploymentRef {
114 id: String,
115 }
116
117 let query = r#"query ($projectId: String!) {
118 project(id: $projectId) {
119 services {
120 edges {
121 node {
122 name
123 serviceInstances {
124 edges {
125 node {
126 latestDeployment { id }
127 }
128 }
129 }
130 }
131 }
132 }
133 }
134 }"#;
135
136 let body = json!({
137 "query": query,
138 "variables": { "projectId": project_id },
139 });
140
141 let resp = client
142 .post(RAILWAY_API_URL)
143 .header("Content-Type", "application/json")
144 .header("Authorization", format!("Bearer {token}"))
145 .json(&body)
146 .send()
147 .await
148 .context("failed to reach Railway API")?;
149
150 let json: Value = resp.json().await?;
151 let data: ProjectData = serde_json::from_value(json.get("data").cloned().unwrap_or_default())?;
152
153 for edge in data.project.services.edges {
154 if edge.node.name == service_name {
155 return Ok(edge
156 .node
157 .service_instances
158 .edges
159 .first()
160 .and_then(|i| i.node.latest_deployment.as_ref())
161 .map(|d| d.id.clone()));
162 }
163 }
164
165 Ok(None)
166}
167
168#[derive(Debug, Deserialize)]
169struct LogEntry {
170 #[serde(default)]
171 timestamp: Option<String>,
172 #[serde(default)]
173 message: String,
174 #[serde(default)]
175 severity: Option<String>,
176}
177
178pub async fn run(args: LogsArgs) -> Result<()> {
179 let state = DeploymentState::load(&args.agent)
180 .with_context(|| format!("no deployment found for agent '{}'", args.agent))?;
181
182 let token = std::env::var("RAILWAY_API_TOKEN")
183 .context("RAILWAY_API_TOKEN required for log streaming")?;
184
185 let service_names: Vec<String> = if let Some(ref svc) = args.service {
187 if !state.services.contains_key(svc) {
188 let available: Vec<&str> = state.services.keys().map(String::as_str).collect();
189 anyhow::bail!(
190 "service '{svc}' not found. Available: {}",
191 available.join(", ")
192 );
193 }
194 vec![svc.clone()]
195 } else {
196 state.services.keys().cloned().collect()
197 };
198
199 println!(
200 "Logs for agent '{}' (project: {})",
201 state.agent_name, state.project_name
202 );
203 println!("═══════════════════════════════════════════");
204
205 let mut found_any = false;
206
207 for svc_name in &service_names {
208 let deployment_id = fetch_latest_deployment_id(&token, &state.project_id, svc_name).await?;
210
211 let Some(deployment_id) = deployment_id else {
212 println!("\n[{svc_name}] No active deployment found.");
213 continue;
214 };
215
216 match fetch_railway_logs(&token, &deployment_id, args.lines).await {
217 Ok(entries) => {
218 if entries.is_empty() {
219 println!("\n[{svc_name}] No log entries.");
220 continue;
221 }
222
223 found_any = true;
224 println!("\n── {svc_name} ({} entries) ──", entries.len());
225
226 for entry in &entries {
227 let ts = entry.timestamp.as_deref().unwrap_or(" ");
228 let severity = entry.severity.as_deref().unwrap_or("INFO");
229 let msg = &entry.message;
230
231 let ts_display = if ts.len() > 19 { &ts[..19] } else { ts };
233
234 println!("{ts_display} [{severity:>5}] {msg}");
235 }
236 }
237 Err(e) => {
238 eprintln!("\n[{svc_name}] Failed to fetch logs: {e}");
239 }
240 }
241 }
242
243 if !found_any && args.service.is_none() {
244 println!();
245 println!("No logs found for any service.");
246 println!(
247 "Services may still be deploying. Run `life status --agent {}` to check.",
248 args.agent
249 );
250 }
251
252 Ok(())
253}