foundry_tui_app/controller/
jobs.rs1use chrono::Local;
2use foundry_tui_foundry::{StreamKind, ToolEvent, ToolKind, ToolRequest};
3use tokio::sync::mpsc::UnboundedSender;
4
5use crate::{
6 model::{AnvilInstanceStatus, JobRecord, JobStatus, LogLine, LogStream},
7 parsing::contains_placeholders,
8 project_inventory::scan_project_inventory,
9};
10
11use super::AppController;
12
13impl AppController {
14 pub fn handle_tool_event(&mut self, event: ToolEvent) {
15 match event {
16 ToolEvent::Started {
17 job_id,
18 commandline,
19 } => {
20 let entry = LogLine {
21 ts: Local::now(),
22 job_id: Some(job_id),
23 stream: LogStream::System,
24 message: format!("starting `{}`", commandline),
25 };
26 self.push_log(entry.clone());
27 self.push_anvil_log(job_id, entry);
28 self.set_anvil_instance_status(job_id, AnvilInstanceStatus::Running);
29
30 if let Some(job) = self.model.jobs.get_mut(&job_id) {
31 job.commandline = commandline;
32 }
33 }
34 ToolEvent::Output {
35 job_id,
36 stream,
37 line,
38 } => {
39 let stream = match stream {
40 StreamKind::Stdout => LogStream::Stdout,
41 StreamKind::Stderr => LogStream::Stderr,
42 };
43
44 let entry = LogLine {
45 ts: Local::now(),
46 job_id: Some(job_id),
47 stream,
48 message: line,
49 };
50 self.push_log(entry.clone());
51 self.push_anvil_log(job_id, entry);
52 }
53 ToolEvent::Finished { job_id, result } => {
54 let status = if result.cancelled {
55 JobStatus::Cancelled
56 } else if result.status_code == Some(0) {
57 JobStatus::Success
58 } else {
59 JobStatus::Failed
60 };
61
62 if let Some(job) = self.model.jobs.get_mut(&job_id) {
63 job.status = status;
64 job.finished_at = Some(Local::now());
65 job.duration_ms = Some(result.duration_ms);
66 job.status_code = result.status_code;
67 }
68
69 if let Some(job) = self.model.jobs.get(&job_id).cloned() {
70 self.model.history.insert(0, job);
71 self.model.history.truncate(self.config.jobs.keep_history);
72 }
73
74 self.job_manager.mark_finished(job_id);
75 let anvil_status = match status {
76 JobStatus::Failed => AnvilInstanceStatus::Failed,
77 JobStatus::Running => AnvilInstanceStatus::Running,
78 JobStatus::Success | JobStatus::Cancelled => AnvilInstanceStatus::Stopped,
79 };
80 self.set_anvil_instance_status(job_id, anvil_status);
81
82 self.model.notification = Some(format!(
83 "job #{job_id} {} in {}ms",
84 status.label(),
85 result.duration_ms
86 ));
87
88 let entry = LogLine {
89 ts: Local::now(),
90 job_id: Some(job_id),
91 stream: LogStream::System,
92 message: format!(
93 "completed status={} code={:?} stdout={} stderr={}",
94 status.label(),
95 result.status_code,
96 result.stdout_lines.len(),
97 result.stderr_lines.len()
98 ),
99 };
100 self.push_log(entry.clone());
101 self.push_anvil_log(job_id, entry);
102 }
103 ToolEvent::Failed { job_id, error } => {
104 if let Some(job) = self.model.jobs.get_mut(&job_id) {
105 job.status = JobStatus::Failed;
106 job.finished_at = Some(Local::now());
107 }
108
109 self.job_manager.mark_finished(job_id);
110 self.set_anvil_instance_status(job_id, AnvilInstanceStatus::Failed);
111
112 self.model.notification = Some(format!("job #{job_id} failed to start"));
113 let entry = LogLine {
114 ts: Local::now(),
115 job_id: Some(job_id),
116 stream: LogStream::System,
117 message: format!("launch error: {error}"),
118 };
119 self.push_log(entry.clone());
120 self.push_anvil_log(job_id, entry);
121 }
122 }
123 }
124
125 pub(crate) fn start_tool_job(
126 &mut self,
127 name: &str,
128 tool: ToolKind,
129 args: Vec<String>,
130 tool_events: &UnboundedSender<ToolEvent>,
131 ) -> Option<u64> {
132 if contains_placeholders(&args) {
133 self.model.notification = Some(format!(
134 "{name} has placeholders. Edit config at {}",
135 self.model.config_path.display()
136 ));
137 return None;
138 }
139
140 let mut request = ToolRequest::new(tool, args, self.model.project_root.clone());
141
142 if matches!(tool, ToolKind::Forge) {
143 request.profile = Some(self.config.foundry.profile.clone());
144 }
145
146 if matches!(tool, ToolKind::Cast | ToolKind::Forge) {
147 request.rpc_target = self.default_rpc_target();
148 }
149
150 match self.start_tool_request_job(name, request, tool_events) {
151 Ok(job_id) => {
152 self.model.notification = Some(format!("queued {name} as job #{job_id}"));
153 Some(job_id)
154 }
155 Err(_) => None,
156 }
157 }
158
159 pub(crate) fn start_tool_request_job(
160 &mut self,
161 name: &str,
162 request: ToolRequest,
163 tool_events: &UnboundedSender<ToolEvent>,
164 ) -> std::result::Result<u64, String> {
165 let commandline = request.display_commandline();
166 let started_at = Local::now();
167
168 let job_id = match self.job_manager.spawn(request, tool_events.clone()) {
169 Ok(job_id) => job_id,
170 Err(error) => {
171 self.model.notification = Some(error.clone());
172 self.push_log(LogLine {
173 ts: Local::now(),
174 job_id: None,
175 stream: LogStream::System,
176 message: format!("unable to queue `{name}`: {error}"),
177 });
178 return Err(error);
179 }
180 };
181
182 self.model.jobs.insert(
183 job_id,
184 JobRecord {
185 id: job_id,
186 name: name.to_string(),
187 commandline,
188 status: JobStatus::Running,
189 started_at,
190 finished_at: None,
191 duration_ms: None,
192 status_code: None,
193 },
194 );
195
196 Ok(job_id)
197 }
198
199 pub(crate) fn default_rpc_target(&self) -> Option<String> {
200 self.active_rpc_target().map(|(_, url)| url)
201 }
202
203 pub(crate) fn active_rpc_target(&self) -> Option<(String, String)> {
204 if let Some(preset) = &self.config.foundry.default_rpc_preset {
205 if let Some(url) = self.config.rpc_presets.get(preset) {
206 return Some((preset.clone(), url.clone()));
207 }
208 }
209
210 self.config
211 .rpc_presets
212 .iter()
213 .next()
214 .map(|(name, url)| (name.clone(), url.clone()))
215 }
216
217 pub(crate) fn refresh_project_inventory(&mut self) {
218 let inventory = scan_project_inventory(&self.model.project_root);
219 self.model.project_sol_files = inventory.sol_files;
220 self.model.project_has_foundry_toml = inventory.has_foundry_toml;
221 self.model.project_has_remappings = inventory.has_remappings;
222 self.model.project_indexed_at = Local::now();
223 }
224
225 pub(crate) fn push_log(&mut self, entry: LogLine) {
226 self.model.logs.push(entry);
227 if self.model.logs.len() > self.config.jobs.max_log_lines {
228 let overflow = self.model.logs.len() - self.config.jobs.max_log_lines;
229 self.model.logs.drain(0..overflow);
230 }
231 }
232
233 pub(crate) fn push_anvil_log(&mut self, job_id: u64, entry: LogLine) {
234 let Some(instance) = self
235 .model
236 .anvil_instances
237 .iter_mut()
238 .find(|instance| instance.job_id == job_id)
239 else {
240 return;
241 };
242
243 instance.logs.push(entry);
244 if instance.logs.len() > self.config.jobs.max_log_lines {
245 let overflow = instance.logs.len() - self.config.jobs.max_log_lines;
246 instance.logs.drain(0..overflow);
247 }
248 }
249
250 pub(crate) fn set_anvil_instance_status(&mut self, job_id: u64, status: AnvilInstanceStatus) {
251 if let Some(instance) = self
252 .model
253 .anvil_instances
254 .iter_mut()
255 .find(|instance| instance.job_id == job_id)
256 {
257 instance.status = status;
258 }
259 }
260}