1use std::time::{Duration, Instant};
2
3use serde::Serialize;
4use tauri::{AppHandle, Emitter, Manager};
5
6use crate::queue::BatchQueue;
7use crate::types::*;
8use crate::BatchItemHandler;
9
10const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2);
11
12#[derive(Debug, Clone, Serialize)]
15#[serde(rename_all = "camelCase")]
16struct BatchJobStartedEvent {
17 job_id: String,
18 operation: String,
19 resource_key: String,
20 total_items: usize,
21}
22
23#[derive(Debug, Clone, Serialize)]
24#[serde(rename_all = "camelCase")]
25struct BatchItemProgressEvent {
26 job_id: String,
27 item_id: String,
28 status: BatchItemStatus,
29 completed: usize,
30 total: usize,
31 error: Option<String>,
32 duration_ms: Option<u64>,
33 eta_remaining_ms: Option<u64>,
34 eta: Option<EtaEstimate>,
35}
36
37#[derive(Debug, Clone, Serialize)]
38#[serde(rename_all = "camelCase")]
39struct BatchJobCompletedEvent {
40 summary: BatchCompletionSummary,
41}
42
43pub fn spawn<D, H>(app_handle: AppHandle, handler: H)
50where
51 D: Clone + Send + Sync + Serialize + serde::de::DeserializeOwned + 'static,
52 H: BatchItemHandler<D> + 'static,
53{
54 spawn_with_interval(app_handle, handler, DEFAULT_POLL_INTERVAL);
55}
56
57pub fn spawn_with_interval<D, H>(app_handle: AppHandle, handler: H, poll_interval: Duration)
59where
60 D: Clone + Send + Sync + Serialize + serde::de::DeserializeOwned + 'static,
61 H: BatchItemHandler<D> + 'static,
62{
63 tauri::async_runtime::spawn(async move {
64 run_loop(app_handle, handler, poll_interval).await;
65 });
66}
67
68async fn run_loop<D, H>(app_handle: AppHandle, handler: H, poll_interval: Duration)
69where
70 D: Clone + Send + Sync + Serialize + serde::de::DeserializeOwned + 'static,
71 H: BatchItemHandler<D>,
72{
73 loop {
74 tokio::time::sleep(poll_interval).await;
75
76 let queue = match app_handle.try_state::<BatchQueue<D>>() {
77 Some(q) => q,
78 None => continue,
79 };
80
81 if queue.has_running_job() {
82 continue;
83 }
84
85 let job = match queue.next_queued() {
86 Some(j) => j,
87 None => continue,
88 };
89
90 process_batch_job(&app_handle, &queue, &handler, &job).await;
91 }
92}
93
94async fn process_batch_job<D, H>(
95 app_handle: &AppHandle,
96 queue: &BatchQueue<D>,
97 handler: &H,
98 job: &BatchJob<D>,
99) where
100 D: Clone + Send + Sync + Serialize + serde::de::DeserializeOwned + 'static,
101 H: BatchItemHandler<D>,
102{
103 let job_id = job.id.clone();
104
105 if let Err(e) = queue.mark_running(&job_id) {
106 tracing::error!(job_id = %job_id, error = %e, "Failed to mark job as running");
107 return;
108 }
109
110 let _ = app_handle.emit(
111 "ai_batch:job_started",
112 BatchJobStartedEvent {
113 job_id: job_id.clone(),
114 operation: job.operation.clone(),
115 resource_key: job.resource_key.clone(),
116 total_items: job.items.len(),
117 },
118 );
119
120 let total = job.items.len();
121 let mut completed_count: usize = 0;
122
123 for item in &job.items {
124 if let Some(current_job) = queue.get_job(&job_id) {
126 if let Some(ci) = current_job.items.iter().find(|i| i.id == item.id) {
127 if ci.status == BatchItemStatus::Cancelled {
128 completed_count += 1;
129 continue;
130 }
131 }
132 if current_job.status == BatchJobStatus::Cancelled {
133 break;
134 }
135 }
136
137 if job.overwrite_policy == OverwritePolicy::Skip
139 && handler.should_skip(&item.data, &job.operation)
140 {
141 let _ = queue.update_item(
142 &job_id,
143 &item.id,
144 BatchItemStatus::Skipped,
145 Some("Skipped: already has data".to_string()),
146 None,
147 );
148 completed_count += 1;
149
150 let eta = queue.estimate_remaining(&job_id);
151 let _ = app_handle.emit(
152 "ai_batch:item_progress",
153 BatchItemProgressEvent {
154 job_id: job_id.clone(),
155 item_id: item.id.clone(),
156 status: BatchItemStatus::Skipped,
157 completed: completed_count,
158 total,
159 error: Some("Skipped".to_string()),
160 duration_ms: None,
161 eta_remaining_ms: eta.as_ref().map(|estimate| estimate.remaining_ms),
162 eta,
163 },
164 );
165 continue;
166 }
167
168 queue.stamp_trial_id(&job_id, &item.id);
172
173 let _ = queue.update_item(&job_id, &item.id, BatchItemStatus::Running, None, None);
175
176 let start = Instant::now();
178 let result = handler
179 .process(&item.data, &job.resource_key, &job.operation)
180 .await;
181 let duration_ms = start.elapsed().as_millis() as u64;
182
183 let (status, error) = match result {
184 Ok(item_result) => {
185 if item_result.success {
186 (BatchItemStatus::Completed, None)
187 } else {
188 (
189 BatchItemStatus::Failed,
190 item_result.error.or(Some("Unknown error".to_string())),
191 )
192 }
193 }
194 Err(e) => (BatchItemStatus::Failed, Some(format!("{:#}", e))),
195 };
196
197 let _ = queue.update_item(
198 &job_id,
199 &item.id,
200 status.clone(),
201 error.clone(),
202 Some(duration_ms),
203 );
204
205 completed_count += 1;
206 let eta = queue.estimate_remaining(&job_id);
207 let _ = app_handle.emit(
208 "ai_batch:item_progress",
209 BatchItemProgressEvent {
210 job_id: job_id.clone(),
211 item_id: item.id.clone(),
212 status,
213 completed: completed_count,
214 total,
215 error,
216 duration_ms: Some(duration_ms),
217 eta_remaining_ms: eta.as_ref().map(|estimate| estimate.remaining_ms),
218 eta,
219 },
220 );
221 }
222
223 match queue.mark_completed(&job_id) {
224 Ok(Some(summary)) => {
225 let _ = app_handle.emit("ai_batch:job_completed", BatchJobCompletedEvent { summary });
226 }
227 Ok(None) => {}
228 Err(e) => tracing::error!(job_id = %job_id, error = %e, "Failed to mark job completed"),
229 }
230}