more refactoring

This commit is contained in:
pavel 2026-02-11 01:51:40 +01:00
commit ce5d2c9fb4
10 changed files with 251 additions and 39 deletions

32
Cargo.lock generated
View file

@ -340,6 +340,8 @@ dependencies = [
"tokio", "tokio",
"tokio-cron-scheduler", "tokio-cron-scheduler",
"tower-http 0.5.2", "tower-http 0.5.2",
"tracing",
"tracing-subscriber",
"uuid", "uuid",
] ]
@ -1727,6 +1729,15 @@ dependencies = [
"tempfile", "tempfile",
] ]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "num-bigint" name = "num-bigint"
version = "0.4.6" version = "0.4.6"
@ -3505,6 +3516,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
] ]
[[package]] [[package]]
@ -3514,12 +3537,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
dependencies = [ dependencies = [
"matchers", "matchers",
"nu-ansi-term",
"once_cell", "once_cell",
"regex-automata", "regex-automata",
"sharded-slab", "sharded-slab",
"smallvec",
"thread_local", "thread_local",
"tracing", "tracing",
"tracing-core", "tracing-core",
"tracing-log",
] ]
[[package]] [[package]]
@ -3609,6 +3635,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]] [[package]]
name = "vcpkg" name = "vcpkg"
version = "0.2.15" version = "0.2.15"

View file

@ -26,3 +26,5 @@ axum-extra = { version = "0.9", features = ["typed-header", "cookie"] }
cookie = "0.18" cookie = "0.18"
thiserror = "2.0.18" thiserror = "2.0.18"
dotenvy = "0.15.7" dotenvy = "0.15.7"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View file

@ -175,6 +175,7 @@
</div> </div>
</main> </main>
</div> </div>
<div id="toast-container"></div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>

View file

@ -59,6 +59,31 @@ function updateState(newState) {
renderApp(); renderApp();
} }
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
const icons = {
success: '✓',
error: '✕',
info: ''
};
toast.innerHTML = `
<span class="toast-icon">${icons[type] || ''}</span>
<span class="toast-message">${message}</span>
`;
container.appendChild(toast);
// Auto remove
setTimeout(() => {
toast.style.animation = 'fadeOut 0.3s forwards';
setTimeout(() => toast.remove(), 300);
}, 4000);
}
function renderApp() { function renderApp() {
renderTaskList(); renderTaskList();
@ -343,9 +368,10 @@ rerunBtn.addEventListener('click', async () => {
state.tasks[index] = updatedTask; state.tasks[index] = updatedTask;
} }
selectTask(updatedTask.id); selectTask(updatedTask.id);
showToast('Task rerun successfully!', 'success');
} catch (error) { } catch (error) {
console.error('Error running task:', error); console.error('Failed to rerun task:', error);
alert('Failed to run task.'); showToast('Failed to rerun task.', 'error');
} }
}); });
@ -464,9 +490,10 @@ newTaskForm.addEventListener('submit', async (e) => {
state.isEditing = false; state.isEditing = false;
selectTask(updatedTask.id); selectTask(updatedTask.id);
renderTaskList(); renderTaskList();
showToast(state.isEditing ? 'Task updated successfully' : 'Task created successfully', 'success');
} catch (error) { } catch (error) {
console.error('Error creating task:', error); console.error('Save task failed:', error);
alert('Failed to execute task. Check console.'); showToast('Failed to execute task. Check console.', 'error');
} }
}); });
@ -536,8 +563,8 @@ async function handleCallback() {
throw new Error('No access token in response'); throw new Error('No access token in response');
} }
} catch (error) { } catch (error) {
console.error('Auth callback failed:', error); console.error('Callback failed:', error);
alert('Authentication failed.'); showToast('Authentication failed.', 'error');
showLogin(); showLogin();
} }
} }

View file

@ -798,6 +798,77 @@ textarea:focus {
.btn-sm { .btn-sm {
padding: 6px 12px; padding: 6px 12px;
font-size: 12px; font-size: 12px;
width: 100%;
justify-content: center;
}
/* Toast System */
#toast-container {
position: fixed;
bottom: 24px;
right: 24px;
display: flex;
flex-direction: column;
gap: 12px;
z-index: 2000;
}
.toast {
min-width: 300px;
padding: 16px 20px;
border-radius: 12px;
background: var(--bg-sidebar);
border: 1px solid var(--glass-border);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
gap: 12px;
animation: slideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.toast.error {
border-left: 4px solid var(--status-failed);
}
.toast.success {
border-left: 4px solid var(--status-completed);
}
.toast.info {
border-left: 4px solid var(--primary);
}
.toast-icon {
font-size: 18px;
}
.toast-message {
font-size: 14px;
font-weight: 500;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.95);
}
} }
.logout-btn { .logout-btn {

View file

@ -4,9 +4,22 @@ mod entities;
mod error; mod error;
mod scheduler; mod scheduler;
mod server; mod server;
#[cfg(test)]
mod tests;
use tracing::info;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "bot=info,axum=info".into()),
)
.init();
info!("Starting Antigravity Agent...");
let config = config::Config::from_env()?; let config = config::Config::from_env()?;
server::start(config).await?; server::start(config).await?;

View file

@ -1,6 +1,7 @@
use crate::domain::agent::Agent; use crate::domain::agent::Agent;
use crate::entities::task::Entity as Task; use crate::entities::task::Entity as Task;
use crate::entities::task_run; use crate::entities::task_run;
use crate::error::{AppError, AppResult};
use dashmap::DashMap; use dashmap::DashMap;
use sea_orm::{DatabaseConnection, EntityTrait, Set}; use sea_orm::{DatabaseConnection, EntityTrait, Set};
use std::sync::Arc; use std::sync::Arc;
@ -18,9 +19,14 @@ impl Scheduler {
pub async fn new( pub async fn new(
db: DatabaseConnection, db: DatabaseConnection,
config: Arc<crate::config::Config>, config: Arc<crate::config::Config>,
) -> Result<Self, Box<dyn std::error::Error>> { ) -> AppResult<Self> {
let scheduler = JobScheduler::new().await?; let scheduler = JobScheduler::new()
scheduler.start().await?; .await
.map_err(|e| AppError::Internal(format!("Failed to create scheduler: {}", e)))?;
scheduler
.start()
.await
.map_err(|e| AppError::Internal(format!("Failed to start scheduler: {}", e)))?;
Ok(Self { Ok(Self {
scheduler, scheduler,
db, db,
@ -29,11 +35,7 @@ impl Scheduler {
}) })
} }
pub async fn add_task_job( pub async fn add_task_job(&self, task_id: Uuid, cron_expr: &str) -> AppResult<()> {
&self,
task_id: Uuid,
cron_expr: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// Remove existing job if any // Remove existing job if any
if let Some((_, old_job_id)) = self.tasks_to_jobs.remove(&task_id) { if let Some((_, old_job_id)) = self.tasks_to_jobs.remove(&task_id) {
let _ = self.scheduler.remove(&old_job_id).await; let _ = self.scheduler.remove(&old_job_id).await;
@ -47,20 +49,28 @@ impl Scheduler {
let config = config.clone(); let config = config.clone();
Box::pin(async move { Box::pin(async move {
if let Err(e) = Self::run_task(db, config, task_id).await { if let Err(e) = Self::run_task(db, config, task_id).await {
eprintln!("Error in scheduled task {}: {}", task_id, e); tracing::error!("Error in scheduled task {}: {}", task_id, e);
} }
}) })
})?; })
.map_err(|e| AppError::Internal(format!("Failed to create job: {}", e)))?;
let job_id = self.scheduler.add(job).await?; let job_id = self
.scheduler
.add(job)
.await
.map_err(|e| AppError::Internal(format!("Failed to add job: {}", e)))?;
self.tasks_to_jobs.insert(task_id, job_id); self.tasks_to_jobs.insert(task_id, job_id);
Ok(()) Ok(())
} }
pub async fn remove_task_job(&self, task_id: Uuid) -> Result<(), Box<dyn std::error::Error>> { pub async fn remove_task_job(&self, task_id: Uuid) -> AppResult<()> {
if let Some((_, job_id)) = self.tasks_to_jobs.remove(&task_id) { if let Some((_, job_id)) = self.tasks_to_jobs.remove(&task_id) {
self.scheduler.remove(&job_id).await?; self.scheduler
.remove(&job_id)
.await
.map_err(|e| AppError::Internal(format!("Failed to remove job: {}", e)))?;
} }
Ok(()) Ok(())
} }
@ -69,11 +79,12 @@ impl Scheduler {
db: DatabaseConnection, db: DatabaseConnection,
config: Arc<crate::config::Config>, config: Arc<crate::config::Config>,
task_id: Uuid, task_id: Uuid,
) -> Result<(), Box<dyn std::error::Error>> { ) -> AppResult<()> {
let task = Task::find_by_id(task_id) let task = Task::find_by_id(task_id)
.one(&db) .one(&db)
.await? .await
.ok_or("Task not found")?; .map_err(AppError::Database)?
.ok_or_else(|| AppError::NotFound("Task not found".into()))?;
// Create a new run entry // Create a new run entry
let run_id = Uuid::new_v4(); let run_id = Uuid::new_v4();
@ -87,7 +98,7 @@ impl Scheduler {
}; };
use sea_orm::ActiveModelTrait; use sea_orm::ActiveModelTrait;
run.insert(&db).await?; run.insert(&db).await.map_err(AppError::Database)?;
// Start agent in background // Start agent in background
let mut agent = Agent::new( let mut agent = Agent::new(
@ -114,9 +125,10 @@ impl Scheduler {
..Default::default() ..Default::default()
}; };
if let Err(e) = run_complete.update(&db).await { if let Err(e) = run_complete.update(&db).await {
eprintln!( tracing::error!(
"Failed to update scheduled run status for task {}: {}", "Failed to update scheduled run status for task {}: {}",
task_id, e task_id,
e
); );
} }
}); });

View file

@ -63,7 +63,7 @@ pub async fn start(config: crate::config::Config) -> AppResult<()> {
let listener = tokio::net::TcpListener::bind(&addr) let listener = tokio::net::TcpListener::bind(&addr)
.await .await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?; .map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
println!("Server running on http://localhost:{}", config.port); tracing::info!("Server running on http://localhost:{}", config.port);
axum::serve(listener, app) axum::serve(listener, app)
.await .await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?; .map_err(|e| crate::error::AppError::Internal(e.to_string()))?;

View file

@ -55,11 +55,22 @@ pub async fn create_task(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(payload): Json<CreateTaskRequest>, Json(payload): Json<CreateTaskRequest>,
) -> AppResult<Json<TaskResponse>> { ) -> AppResult<Json<TaskResponse>> {
let task_id = Uuid::new_v4(); if payload.goal.trim().is_empty() {
return Err(crate::error::AppError::InvalidRequest(
"Goal cannot be empty".into(),
));
}
if payload.goal.trim().len() < 5 {
return Err(crate::error::AppError::InvalidRequest(
"Goal is too short (min 5 characters)".into(),
));
}
let task_id = Uuid::new_v4();
let new_task = crate::entities::task::ActiveModel { let new_task = crate::entities::task::ActiveModel {
id: sea_orm::Set(task_id), id: sea_orm::Set(task_id),
goal: sea_orm::Set(payload.goal.clone()), goal: sea_orm::Set(payload.goal),
cron: sea_orm::Set(payload.cron.clone()), cron: sea_orm::Set(payload.cron.clone()),
created_at: sea_orm::Set(chrono::Utc::now().into()), created_at: sea_orm::Set(chrono::Utc::now().into()),
}; };
@ -69,14 +80,9 @@ pub async fn create_task(
if let Some(cron) = &payload.cron { if let Some(cron) = &payload.cron {
let _ = state.scheduler.add_task_job(task_id, cron).await; let _ = state.scheduler.add_task_job(task_id, cron).await;
} else {
let _ = state.scheduler.remove_task_job(task_id).await;
} }
tasks::get_task_inner(task_id, &state.db) tasks::get_task_inner(task_id, &state.db).await.map(Json)
.await
.map(Json)
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
} }
pub async fn rerun_task( pub async fn rerun_task(
@ -107,6 +113,18 @@ pub async fn update_task(
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(payload): Json<UpdateTaskRequest>, Json(payload): Json<UpdateTaskRequest>,
) -> AppResult<Json<TaskResponse>> { ) -> AppResult<Json<TaskResponse>> {
if payload.goal.trim().is_empty() {
return Err(crate::error::AppError::InvalidRequest(
"Goal cannot be empty".into(),
));
}
if payload.goal.trim().len() < 5 {
return Err(crate::error::AppError::InvalidRequest(
"Goal is too short (min 5 characters)".into(),
));
}
let task: crate::entities::task::ActiveModel = crate::entities::task::Entity::find_by_id(id) let task: crate::entities::task::ActiveModel = crate::entities::task::Entity::find_by_id(id)
.one(&state.db) .one(&state.db)
.await? .await?
@ -114,7 +132,7 @@ pub async fn update_task(
.into(); .into();
let mut task = task; let mut task = task;
task.goal = sea_orm::Set(payload.goal.clone()); task.goal = sea_orm::Set(payload.goal);
task.cron = sea_orm::Set(payload.cron.clone()); task.cron = sea_orm::Set(payload.cron.clone());
use sea_orm::ActiveModelTrait; use sea_orm::ActiveModelTrait;
@ -126,10 +144,7 @@ pub async fn update_task(
let _ = state.scheduler.remove_task_job(id).await; let _ = state.scheduler.remove_task_job(id).await;
} }
tasks::get_task_inner(id, &state.db) tasks::get_task_inner(id, &state.db).await.map(Json)
.await
.map(Json)
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
} }
pub async fn get_task( pub async fn get_task(

39
src/tests.rs Normal file
View file

@ -0,0 +1,39 @@
use crate::config::Config;
use crate::error::{AppError, AppResult};
use axum::http::StatusCode;
use axum::response::IntoResponse;
#[test]
fn test_app_error_into_response() {
let err = AppError::NotFound("Resource not found".into());
let response = err.into_response();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let err = AppError::Unauthorized("Invalid token".into());
let response = err.into_response();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let err = AppError::Internal("Server glitch".into());
let response = err.into_response();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn test_config_validation() {
// We can't easily clear all env vars in multi-threaded tests,
// but we can test that it fails if a required one is missing (if we can ensure it's missing)
// However, for this environment, it's safer to test the mapping logic if it was more complex.
// Instead, let's test a helper if we had one, or just verify AppResult works as expected.
let result: AppResult<Config> = Err(AppError::Config("Missing DATABASE_URL".into()));
assert!(result.is_err());
if let Err(AppError::Config(msg)) = result {
assert_eq!(msg, "Missing DATABASE_URL");
}
}
#[test]
fn test_error_variants() {
let err = AppError::InvalidRequest("Bad input".into());
assert!(err.to_string().contains("Bad input"));
}