+
+
+
-
-
-
-
-
⌘
Ready for a new directive?
Select a task from the sidebar or create a new one to begin.
+
+
+
+
Recent Activity
+Track latest agent executions across all directives.
+
+
+
+
+
+ | Directive | +Status | +Date | +
|---|
Running
Researching local LLM performance trends
+
+
+
+
-
- Final Answer
-
-
-
-
-
-
- `
- )
+ const sortedTasks = [...state.tasks].sort((a, b) => {
+ const aDate = a.runs && a.runs.length > 0
+ ? new Date(a.runs[a.runs.length - 1].created_at)
+ : new Date(a.created_at);
+ const bDate = b.runs && b.runs.length > 0
+ ? new Date(b.runs[b.runs.length - 1].created_at)
+ : new Date(b.created_at);
+ return bDate - aDate;
+ });
+
+ taskListEl.innerHTML = sortedTasks
+ .map((task) => {
+ const latestRun = task.runs && task.runs.length > 0
+ ? task.runs[task.runs.length - 1]
+ : null;
+ const status = latestRun ? latestRun.status : 'pending';
+ const date = latestRun ? new Date(latestRun.created_at) : new Date(task.created_at);
+
+ return `
+
+
+ `;
+ })
.join('');
// Add event listeners
@@ -61,31 +135,120 @@ function renderTaskList() {
});
}
-function selectTask(id) {
+function selectTask(id, runId = null) {
state.selectedTaskId = id;
+ state.currentView = 'task';
const task = state.tasks.find((t) => t.id === id);
if (!task) return;
+ if (runId) {
+ state.selectedRunId = runId;
+ } else if (task.runs && task.runs.length > 0) {
+ // Default to latest run if not specified
+ state.selectedRunId = task.runs[task.runs.length - 1].id;
+ } else {
+ state.selectedRunId = null;
+ }
+
renderTaskList();
+ renderRunHistory(task);
showTaskView(task);
}
-function showTaskView(task) {
+function renderRunHistory(task) {
+ runListEl.innerHTML = task.runs
+ .slice()
+ .reverse()
+ .map(
+ (run, index) => `
+
+ Run #${task.runs.length - index}
+ ${new Date(run.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
+
+ `
+ )
+ .join('');
+
+ document.querySelectorAll('.run-item').forEach((item) => {
+ item.addEventListener('click', () => {
+ selectTask(task.id, item.dataset.id);
+ });
+ });
+}
+
+function showDashboard() {
+ state.currentView = 'dashboard';
+ state.selectedTaskId = null;
+ state.selectedRunId = null;
+
emptyStateEl.classList.add('hidden');
+ taskViewEl.classList.add('hidden');
+ dashboardViewEl.classList.remove('hidden');
+
+ renderTaskList();
+ fetchRecentRuns();
+}
+
+function renderDashboard(recentRuns) {
+ recentRunsListEl.innerHTML = recentRuns
+ .map(
+ (run) => `
+
+ ${escapeHtml(run.goal)}
+ ${run.status}
+ ${new Date(run.created_at).toLocaleString()}
+
+ `
+ )
+ .join('');
+
+ document.querySelectorAll('#recent-runs-list tr').forEach((row) => {
+ row.addEventListener('click', () => {
+ selectTask(row.dataset.taskId, row.dataset.runId);
+ });
+ });
+}
+
+function showTaskView(task) {
+ state.currentView = 'task';
+ emptyStateEl.classList.add('hidden');
+ dashboardViewEl.classList.add('hidden');
taskViewEl.classList.remove('hidden');
+ const run = task.runs.find(r => r.id === state.selectedRunId) || task.runs[task.runs.length - 1];
+
viewGoalEl.textContent = task.goal;
- viewStatusEl.textContent = task.status;
- viewStatusEl.className = `status-badge ${task.status}`;
- viewDateEl.textContent = new Date(task.created_at).toLocaleDateString(undefined, {
+
+ // Show schedule info if exists
+ const scheduleInfo = task.cron ? `,
+ pub cron: Option,
pub created_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
-pub enum Relation {}
+pub enum Relation {
+ #[sea_orm(has_many = "super::task_run::Entity")]
+ TaskRuns,
+}
+
+impl Related for Entity {
+ fn to() -> RelationDef {
+ Relation::TaskRuns.def()
+ }
+}
impl ActiveModelBehavior for ActiveModel {}
diff --git a/src/entities/task_run.rs b/src/entities/task_run.rs
new file mode 100644
index 0000000..dfb9ab3
--- /dev/null
+++ b/src/entities/task_run.rs
@@ -0,0 +1,36 @@
+use sea_orm::entity::prelude::*;
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
+#[sea_orm(table_name = "task_runs")]
+pub struct Model {
+ #[sea_orm(primary_key, auto_increment = false)]
+ pub id: Uuid,
+ pub task_id: Uuid,
+ pub status: String,
+ #[sea_orm(column_type = "Text")]
+ pub logs: String,
+ #[sea_orm(column_type = "Text", nullable)]
+ pub answer: Option,
+ pub created_at: DateTimeWithTimeZone,
+}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {
+ #[sea_orm(
+ belongs_to = "super::task::Entity",
+ from = "Column::TaskId",
+ to = "super::task::Column::Id",
+ on_update = "NoAction",
+ on_delete = "Cascade"
+ )]
+ Task,
+}
+
+impl Related for Entity {
+ fn to() -> RelationDef {
+ Relation::Task.def()
+ }
+}
+
+impl ActiveModelBehavior for ActiveModel {}
diff --git a/src/main.rs b/src/main.rs
index 8e58fed..a25ab72 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,6 +1,7 @@
mod agent;
mod api;
mod entities;
+mod scheduler;
mod server;
mod tools;
diff --git a/src/scheduler.rs b/src/scheduler.rs
new file mode 100644
index 0000000..0c2c493
--- /dev/null
+++ b/src/scheduler.rs
@@ -0,0 +1,118 @@
+use crate::agent::Agent;
+use crate::entities::task::Entity as Task;
+use crate::entities::task_run;
+use dashmap::DashMap;
+use sea_orm::{DatabaseConnection, EntityTrait, Set};
+use tokio_cron_scheduler::{Job, JobScheduler};
+use uuid::Uuid;
+
+pub struct Scheduler {
+ scheduler: JobScheduler,
+ db: DatabaseConnection,
+ tasks_to_jobs: DashMap,
+ zen_api_key: Option,
+ tavily_api_key: Option,
+}
+
+impl Scheduler {
+ pub async fn new(
+ db: DatabaseConnection,
+ zen_api_key: Option,
+ tavily_api_key: Option,
+ ) -> Result> {
+ let scheduler = JobScheduler::new().await?;
+ scheduler.start().await?;
+ Ok(Self {
+ scheduler,
+ db,
+ tasks_to_jobs: DashMap::new(),
+ zen_api_key,
+ tavily_api_key,
+ })
+ }
+
+ pub async fn add_task_job(
+ &self,
+ task_id: Uuid,
+ cron_expr: &str,
+ ) -> Result<(), Box> {
+ // Remove existing job if any
+ if let Some((_, old_job_id)) = self.tasks_to_jobs.remove(&task_id) {
+ let _ = self.scheduler.remove(&old_job_id).await;
+ }
+
+ let db = self.db.clone();
+ let zen_key = self.zen_api_key.clone();
+ let tavily_key = self.tavily_api_key.clone();
+
+ let job = Job::new_async(cron_expr, move |_uuid, _l| {
+ let db = db.clone();
+ let zen_key = zen_key.clone();
+ let tavily_key = tavily_key.clone();
+ Box::pin(async move {
+ if let Err(e) = Self::run_task(db, zen_key, tavily_key, task_id).await {
+ eprintln!("Error in scheduled task {}: {}", task_id, e);
+ }
+ })
+ })?;
+
+ let job_id = self.scheduler.add(job).await?;
+ self.tasks_to_jobs.insert(task_id, job_id);
+
+ Ok(())
+ }
+
+ pub async fn remove_task_job(&self, task_id: Uuid) -> Result<(), Box> {
+ if let Some((_, job_id)) = self.tasks_to_jobs.remove(&task_id) {
+ self.scheduler.remove(&job_id).await?;
+ }
+ Ok(())
+ }
+
+ async fn run_task(
+ db: DatabaseConnection,
+ zen_key: Option,
+ tavily_key: Option,
+ task_id: Uuid,
+ ) -> Result<(), Box> {
+ let task = Task::find_by_id(task_id)
+ .one(&db)
+ .await?
+ .ok_or("Task not found")?;
+
+ // Create a new run entry
+ let run_id = Uuid::new_v4();
+ let run = task_run::ActiveModel {
+ id: Set(run_id),
+ task_id: Set(task_id),
+ status: Set("running".to_string()),
+ logs: Set(String::new()),
+ answer: Set(None),
+ created_at: Set(chrono::Utc::now().into()),
+ };
+
+ use sea_orm::ActiveModelTrait;
+ run.insert(&db).await?;
+
+ // Start agent in background
+ let mut agent = Agent::new(zen_key, tavily_key, task.goal.clone());
+
+ tokio::spawn(async move {
+ let (logs, answer) = match agent.run().await {
+ Ok(res) => res,
+ Err(e) => (format!("Scheduled run failed: {}", e), None),
+ };
+
+ let run_complete = task_run::ActiveModel {
+ id: Set(run_id),
+ status: Set("completed".to_string()),
+ logs: Set(logs),
+ answer: Set(answer),
+ ..Default::default()
+ };
+ let _ = run_complete.update(&db).await;
+ });
+
+ Ok(())
+ }
+}
diff --git a/src/server.rs b/src/server.rs
index a3c2c26..70489ff 100644
--- a/src/server.rs
+++ b/src/server.rs
@@ -1,23 +1,28 @@
use axum::{
Json, Router,
extract::{Path, State},
- http::{Method, StatusCode},
+ http::StatusCode,
routing::{get, post},
};
use chrono::Utc;
-use sea_orm::{ActiveModelTrait, Database, DatabaseConnection, EntityTrait, QueryOrder, Set};
+use sea_orm::{
+ ActiveModelTrait, Database, DatabaseConnection, EntityTrait, QueryOrder, QuerySelect, Set,
+};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
-use tower_http::cors::{Any, CorsLayer};
+use tower_http::cors::CorsLayer;
use uuid::Uuid;
use crate::agent::Agent;
use crate::entities::task::{self, Entity as Task};
+use crate::entities::task_run::{self, Entity as TaskRun};
+use crate::scheduler::Scheduler;
use migration::{Migrator, MigratorTrait};
#[derive(Clone)]
pub struct AppState {
pub db: DatabaseConnection,
+ pub scheduler: Arc,
pub zen_api_key: Option,
pub tavily_api_key: Option,
}
@@ -25,18 +30,42 @@ pub struct AppState {
#[derive(Deserialize)]
pub struct CreateTaskRequest {
pub goal: String,
+ pub cron: Option,
+}
+
+#[derive(Deserialize)]
+pub struct UpdateTaskRequest {
+ pub goal: String,
+ pub cron: Option,
}
#[derive(Serialize)]
pub struct TaskResponse {
pub id: Uuid,
pub goal: String,
+ pub cron: Option,
+ pub created_at: chrono::DateTime,
+ pub runs: Vec,
+}
+
+#[derive(Serialize)]
+pub struct TaskRunResponse {
+ pub id: Uuid,
pub status: String,
pub logs: String,
pub answer: Option,
pub created_at: chrono::DateTime,
}
+#[derive(Serialize)]
+pub struct RecentRunResponse {
+ pub id: Uuid,
+ pub task_id: Uuid,
+ pub goal: String,
+ pub status: String,
+ pub created_at: chrono::DateTime,
+}
+
pub async fn start(db_url: &str) -> Result<(), Box> {
let db = Database::connect(db_url).await?;
Migrator::up(&db, None).await?;
@@ -44,20 +73,31 @@ pub async fn start(db_url: &str) -> Result<(), Box> {
let zen_api_key = std::env::var("ZEN_API_KEY").ok();
let tavily_api_key = std::env::var("TAVILY_API_KEY").ok();
+ let scheduler =
+ Arc::new(Scheduler::new(db.clone(), zen_api_key.clone(), tavily_api_key.clone()).await?);
+
+ // Load existing scheduled tasks
+ let existing_tasks = Task::find().all(&db).await?;
+ for task in existing_tasks {
+ if let Some(cron) = task.cron {
+ let _ = scheduler.add_task_job(task.id, &cron).await;
+ }
+ }
+
let state = Arc::new(AppState {
db,
+ scheduler,
zen_api_key,
tavily_api_key,
});
- let cors = CorsLayer::new()
- .allow_origin(Any)
- .allow_methods([Method::GET, Method::POST])
- .allow_headers(Any);
+ let cors = CorsLayer::permissive();
let app = Router::new()
.route("/tasks", post(create_task).get(list_tasks))
- .route("/tasks/:id", get(get_task))
+ .route("/tasks/:id", get(get_task).put(update_task))
+ .route("/tasks/:id/runs", post(rerun_task))
+ .route("/runs/recent", get(get_recent_runs))
.layer(cors)
.with_state(state);
@@ -72,6 +112,7 @@ async fn list_tasks(
State(state): State>,
) -> Result>, (StatusCode, String)> {
let tasks = Task::find()
+ .find_with_related(TaskRun)
.order_by_desc(task::Column::CreatedAt)
.all(&state.db)
.await
@@ -79,13 +120,21 @@ async fn list_tasks(
let response = tasks
.into_iter()
- .map(|t| TaskResponse {
+ .map(|(t, runs)| TaskResponse {
id: t.id,
goal: t.goal,
- status: t.status,
- logs: t.logs,
- answer: t.answer,
+ cron: t.cron,
created_at: t.created_at,
+ runs: runs
+ .into_iter()
+ .map(|r| TaskRunResponse {
+ id: r.id,
+ status: r.status,
+ logs: r.logs,
+ answer: r.answer,
+ created_at: r.created_at,
+ })
+ .collect(),
})
.collect();
@@ -98,13 +147,11 @@ async fn create_task(
) -> Result, (StatusCode, String)> {
let task_id = Uuid::new_v4();
- // Initial save
+ // Initial task save
let new_task = task::ActiveModel {
id: Set(task_id),
goal: Set(payload.goal.clone()),
- status: Set("running".to_string()),
- logs: Set(String::new()),
- answer: Set(None),
+ cron: Set(payload.cron.clone()),
created_at: Set(Utc::now().into()),
};
@@ -113,14 +160,54 @@ async fn create_task(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
- // Run agent in the background or synchronously for now as requested
- // "it can be created and executed on the agent with an api. the resulting logs after execution will get returned by the endpoint"
- // This implies we wait for it to finish.
+ if let Some(cron) = &payload.cron {
+ let _ = state.scheduler.add_task_job(task_id, cron).await;
+ } else {
+ let _ = state.scheduler.remove_task_job(task_id).await;
+ }
+
+ execute_agent_run(state, task_id, payload.goal).await
+}
+
+async fn rerun_task(
+ State(state): State>,
+ Path(id): Path,
+) -> Result, (StatusCode, String)> {
+ let task = Task::find_by_id(id)
+ .one(&state.db)
+ .await
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
+ .ok_or((StatusCode::NOT_FOUND, "Task not found".to_string()))?;
+
+ execute_agent_run(state, task.id, task.goal).await
+}
+
+async fn execute_agent_run(
+ state: Arc,
+ task_id: Uuid,
+ goal: String,
+) -> Result, (StatusCode, String)> {
+ let run_id = Uuid::new_v4();
+
+ // Initial run save
+ let new_run = task_run::ActiveModel {
+ id: Set(run_id),
+ task_id: Set(task_id),
+ status: Set("running".to_string()),
+ logs: Set(String::new()),
+ answer: Set(None),
+ created_at: Set(Utc::now().into()),
+ };
+
+ new_run
+ .insert(&state.db)
+ .await
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let mut agent = Agent::new(
state.zen_api_key.clone(),
state.tavily_api_key.clone(),
- payload.goal.clone(),
+ goal.clone(),
);
let (logs, answer) = match agent.run().await {
@@ -129,51 +216,111 @@ async fn create_task(
};
// Update with final logs and status
- let mut task: task::ActiveModel = Task::find_by_id(task_id)
+ let mut run: task_run::ActiveModel = TaskRun::find_by_id(run_id)
.one(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((
StatusCode::NOT_FOUND,
- "Task not found after insert".to_string(),
+ "Run not found after insert".to_string(),
))?
.into();
- task.logs = Set(logs.clone());
- task.answer = Set(answer.clone());
- task.status = Set("completed".to_string());
+ run.logs = Set(logs.clone());
+ run.answer = Set(answer.clone());
+ run.status = Set("completed".to_string());
- let updated_task = task
- .update(&state.db)
+ run.update(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
- Ok(Json(TaskResponse {
- id: updated_task.id,
- goal: updated_task.goal,
- status: updated_task.status,
- logs: updated_task.logs,
- answer: updated_task.answer,
- created_at: updated_task.created_at,
- }))
+ get_task(Path(task_id), State(state)).await
+}
+
+async fn update_task(
+ State(state): State>,
+ Path(id): Path,
+ Json(payload): Json,
+) -> Result, (StatusCode, String)> {
+ let mut task: task::ActiveModel = Task::find_by_id(id)
+ .one(&state.db)
+ .await
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
+ .ok_or((StatusCode::NOT_FOUND, "Task not found".to_string()))?
+ .into();
+
+ task.goal = Set(payload.goal.clone());
+ task.cron = Set(payload.cron.clone());
+
+ task.update(&state.db)
+ .await
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+
+ if let Some(cron) = &payload.cron {
+ let _ = state.scheduler.add_task_job(id, cron).await;
+ } else {
+ let _ = state.scheduler.remove_task_job(id).await;
+ }
+
+ get_task(Path(id), State(state)).await
}
async fn get_task(
Path(id): Path,
State(state): State>,
) -> Result, (StatusCode, String)> {
- let task = Task::find_by_id(id)
- .one(&state.db)
+ let results = Task::find_by_id(id)
+ .find_with_related(TaskRun)
+ .all(&state.db)
.await
- .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+
+ let (task, runs) = results
+ .into_iter()
+ .next()
.ok_or((StatusCode::NOT_FOUND, "Task not found".to_string()))?;
Ok(Json(TaskResponse {
id: task.id,
goal: task.goal,
- status: task.status,
- logs: task.logs,
- answer: task.answer,
+ cron: task.cron,
created_at: task.created_at,
+ runs: runs
+ .into_iter()
+ .map(|r| TaskRunResponse {
+ id: r.id,
+ status: r.status,
+ logs: r.logs,
+ answer: r.answer,
+ created_at: r.created_at,
+ })
+ .collect(),
}))
}
+
+async fn get_recent_runs(
+ State(state): State>,
+) -> Result>, (StatusCode, String)> {
+ let results = TaskRun::find()
+ .find_also_related(Task)
+ .order_by_desc(task_run::Column::CreatedAt)
+ .limit(50)
+ .all(&state.db)
+ .await
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+
+ let response = results
+ .into_iter()
+ .filter_map(|(run, task_opt)| {
+ task_opt.map(|task| RecentRunResponse {
+ id: run.id,
+ task_id: run.task_id,
+ goal: task.goal,
+ status: run.status,
+ created_at: run.created_at,
+ })
+ })
+ .collect();
+
+ Ok(Json(response))
+}
diff --git a/src/tools.rs b/src/tools.rs
index a02398f..9588c3c 100644
--- a/src/tools.rs
+++ b/src/tools.rs
@@ -20,28 +20,6 @@ pub fn get_tools() -> Vec {
}),
},
},
- Tool {
- tool_type: "function".to_string(),
- function: FunctionDefinition {
- name: "write_file".to_string(),
- description: "Write content to a file. Ensure parent directories exist."
- .to_string(),
- parameters: serde_json::json!({
- "type": "object",
- "properties": {
- "path": {
- "type": "string",
- "description": "The path to the file to write"
- },
- "content": {
- "type": "string",
- "description": "The content to write to the file"
- }
- },
- "required": ["path", "content"]
- }),
- },
- },
Tool {
tool_type: "function".to_string(),
function: FunctionDefinition {
Execution Logs
-
- Feb 9, 2026
+
+
+
+
+
+
+
+
+
-
+
+ Final Answer
+
+
+
+
+
+ Execution Logs
+
+ Feb 9, 2026
+
+
+
+
+
-
-
-
+
-
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 723e011..46960a3 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -9,7 +9,9 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
- "lucide-static": "^0.563.0"
+ "dompurify": "^3.3.1",
+ "lucide-static": "^0.563.0",
+ "marked": "^17.0.1"
},
"devDependencies": {
"vite": "^7.3.1"
@@ -814,6 +816,22 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/dompurify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
+ "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
@@ -895,6 +913,18 @@
"integrity": "sha512-O7ZlK+VFfc/G5KYwUb9jO60RlPNKV6Dyu+OgQC/QCiO4FMPIK9qrIIkYHHG6PLgLKvmELmf1eKSAso+D/vAvcg==",
"license": "ISC"
},
+ "node_modules/marked": {
+ "version": "17.0.1",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz",
+ "integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==",
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 90a5369..f388674 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -14,6 +14,8 @@
"vite": "^7.3.1"
},
"dependencies": {
- "lucide-static": "^0.563.0"
+ "dompurify": "^3.3.1",
+ "lucide-static": "^0.563.0",
+ "marked": "^17.0.1"
}
}
diff --git a/frontend/src/main.js b/frontend/src/main.js
index 4534f43..9218ef0 100644
--- a/frontend/src/main.js
+++ b/frontend/src/main.js
@@ -1,8 +1,14 @@
+import { marked } from 'marked';
+import DOMPurify from 'dompurify';
+
const API_URL = 'http://localhost:3000';
const state = {
tasks: [],
selectedTaskId: null,
+ selectedRunId: null,
+ currentView: 'dashboard', // 'dashboard' or 'task'
+ isEditing: false,
};
// DOM elements
@@ -19,17 +25,59 @@ const viewStatusEl = document.getElementById('view-status');
const viewDateEl = document.getElementById('view-date');
const answerContainerEl = document.getElementById('answer-container');
const answerOutputEl = document.getElementById('answer-output');
+const rerunBtn = document.getElementById('rerun-btn');
+const runListEl = document.getElementById('run-list');
+const toggleLogsBtn = document.getElementById('toggle-logs-btn');
+const logsContainerEl = document.getElementById('logs-container');
+const dashboardViewEl = document.getElementById('dashboard-view');
+const recentRunsListEl = document.getElementById('recent-runs-list');
+const logoLink = document.getElementById('logo-link');
+const editTaskBtn = document.getElementById('edit-task-btn');
+const modalTitle = document.getElementById('modal-title');
+const submitTaskBtn = document.getElementById('submit-task-btn');
+const goalTextarea = document.getElementById('goal-textarea');
+const cronInput = document.getElementById('cron-input');
+const schedulePresets = document.getElementById('schedule-presets');
+const toggleCustomCronBtn = document.getElementById('toggle-custom-cron');
+const customCronContainer = document.getElementById('custom-cron-container');
+const presetBtns = document.querySelectorAll('.btn-preset');
async function fetchTasks() {
try {
const response = await fetch(`${API_URL}/tasks`);
- state.tasks = await response.json();
+ const newTasks = await response.json();
+
+ // Check if we should follow the latest run (if we were already watching it)
+ let shouldFollowLatest = false;
+ if (state.selectedTaskId) {
+ const currentTask = state.tasks.find(t => t.id === state.selectedTaskId);
+ if (currentTask && currentTask.runs && currentTask.runs.length > 0) {
+ const latestRunId = currentTask.runs[currentTask.runs.length - 1].id;
+ if (state.selectedRunId === latestRunId) {
+ shouldFollowLatest = true;
+ }
+ } else if (!state.selectedRunId) {
+ shouldFollowLatest = true;
+ }
+ }
+
+ state.tasks = newTasks;
renderTaskList();
- // Update current view if a task is selected
+ // If we are on the dashboard, refresh it too
+ if (state.currentView === 'dashboard') {
+ fetchRecentRuns();
+ }
+
+ // If a task is selected, update it
if (state.selectedTaskId) {
- const task = state.tasks.find(t => t.id === state.selectedTaskId);
+ const task = state.tasks.find((t) => t.id === state.selectedTaskId);
if (task) {
+ if (shouldFollowLatest && task.runs && task.runs.length > 0) {
+ state.selectedRunId = task.runs[task.runs.length - 1].id;
+ }
+
+ renderRunHistory(task);
showTaskView(task);
}
}
@@ -38,19 +86,45 @@ async function fetchTasks() {
}
}
+async function fetchRecentRuns() {
+ try {
+ const response = await fetch(`${API_URL}/runs/recent`);
+ const recentRuns = await response.json();
+ renderDashboard(recentRuns);
+ } catch (error) {
+ console.error('Error fetching recent runs:', error);
+ }
+}
+
function renderTaskList() {
- taskListEl.innerHTML = state.tasks
- .map(
- (task) => `
- New Agent Task
+New Agent Task
Provide a goal for the agent to execute.
${task.goal}
-
-
- ${new Date(task.created_at).toLocaleDateString()}
-
- ${task.goal}
+
+
+ ${date.toLocaleDateString()} ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
+
+ 🕒 Scheduled: ${task.cron}
` : '';
+ const headerMain = document.querySelector('.header-main');
+ const existingBadge = headerMain.querySelector('.schedule-badge');
+ if (existingBadge) existingBadge.remove();
+ if (scheduleInfo) {
+ headerMain.insertAdjacentHTML('beforeend', scheduleInfo);
+ }
+
+ if (!run) {
+ viewStatusEl.textContent = 'No runs';
+ logsOutputEl.innerHTML = '';
+ answerContainerEl.classList.add('hidden');
+ return;
+ }
+
+ viewStatusEl.textContent = run.status;
+ viewStatusEl.className = `status-badge ${run.status}`;
+ viewDateEl.textContent = new Date(run.created_at).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
- year: 'numeric'
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit'
});
- if (task.answer) {
+ if (run.answer) {
answerContainerEl.classList.remove('hidden');
- answerOutputEl.textContent = task.answer;
+ const rawHtml = marked.parse(run.answer);
+ answerOutputEl.innerHTML = DOMPurify.sanitize(rawHtml);
} else {
answerContainerEl.classList.add('hidden');
}
@@ -95,7 +258,7 @@ function showTaskView(task) {
const isFirstLoad = logsOutputEl.innerHTML === '';
// Simple log format
- logsOutputEl.innerHTML = task.logs
+ logsOutputEl.innerHTML = run.logs
.split('\n')
.map((line) => `${escapeHtml(line)}
`)
.join('');
@@ -112,31 +275,140 @@ function escapeHtml(text) {
}
// Event Listeners
+rerunBtn.addEventListener('click', async () => {
+ if (!state.selectedTaskId) return;
+
+ try {
+ const response = await fetch(`${API_URL}/tasks/${state.selectedTaskId}/runs`, {
+ method: 'POST',
+ });
+ const updatedTask = await response.json();
+ const index = state.tasks.findIndex(t => t.id === updatedTask.id);
+ if (index !== -1) {
+ state.tasks[index] = updatedTask;
+ }
+ selectTask(updatedTask.id);
+ } catch (error) {
+ console.error('Error re-running task:', error);
+ alert('Failed to re-run task.');
+ }
+});
+
newTaskBtn.addEventListener('click', () => {
+ state.isEditing = false;
+ modalTitle.textContent = 'New Agent Task';
+ submitTaskBtn.textContent = 'Execute Directive';
+ goalTextarea.value = '';
+ cronInput.value = '';
+ updateScheduleUI('');
+ modalContainer.classList.remove('hidden');
+});
+
+toggleLogsBtn.addEventListener('click', () => {
+ logsContainerEl.classList.toggle('hidden');
+ const isHidden = logsContainerEl.classList.contains('hidden');
+ toggleLogsBtn.classList.toggle('active', !isHidden);
+ toggleLogsBtn.innerHTML = isHidden ? '⌨ Inspect Logs' : '✕ Hide Logs';
+});
+
+editTaskBtn.addEventListener('click', () => {
+ const task = state.tasks.find((t) => t.id === state.selectedTaskId);
+ if (!task) return;
+
+ state.isEditing = true;
+ modalTitle.textContent = 'Edit Directive';
+ submitTaskBtn.textContent = 'Update Directive';
+ goalTextarea.value = task.goal;
+ cronInput.value = task.cron || '';
+ updateScheduleUI(task.cron || '');
modalContainer.classList.remove('hidden');
});
cancelTaskBtn.addEventListener('click', () => {
modalContainer.classList.add('hidden');
+ state.isEditing = false;
+});
+
+logoLink.addEventListener('click', (e) => {
+ e.preventDefault();
+ showDashboard();
+});
+
+function updateScheduleUI(cron) {
+ let matched = false;
+ presetBtns.forEach(btn => {
+ if (btn.dataset.cron === (cron || '')) {
+ btn.classList.add('active');
+ matched = true;
+ } else {
+ btn.classList.remove('active');
+ }
+ });
+
+ if (matched) {
+ customCronContainer.classList.add('hidden');
+ } else if (cron) {
+ customCronContainer.classList.remove('hidden');
+ } else {
+ customCronContainer.classList.add('hidden');
+ }
+}
+
+presetBtns.forEach(btn => {
+ btn.addEventListener('click', () => {
+ const cron = btn.dataset.cron;
+ cronInput.value = cron;
+ updateScheduleUI(cron);
+ });
+});
+
+toggleCustomCronBtn.addEventListener('click', () => {
+ customCronContainer.classList.toggle('hidden');
+});
+
+cronInput.addEventListener('input', () => {
+ // If user types manually, update presets active state
+ updateScheduleUI(cronInput.value);
});
newTaskForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(newTaskForm);
const goal = formData.get('goal');
+ const cron = formData.get('cron') || null;
modalContainer.classList.add('hidden');
newTaskForm.reset();
try {
- const response = await fetch(`${API_URL}/tasks`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ goal }),
- });
- const newTask = await response.json();
- state.tasks.unshift(newTask);
- selectTask(newTask.id);
+ let response;
+ if (state.isEditing) {
+ response = await fetch(`${API_URL}/tasks/${state.selectedTaskId}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ goal, cron }),
+ });
+ } else {
+ response = await fetch(`${API_URL}/tasks`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ goal, cron }),
+ });
+ }
+ const updatedTask = await response.json();
+
+ if (state.isEditing) {
+ const index = state.tasks.findIndex(t => t.id === updatedTask.id);
+ if (index !== -1) {
+ state.tasks[index] = updatedTask;
+ }
+ } else {
+ state.tasks.unshift(updatedTask);
+ }
+
+ state.isEditing = false;
+ selectTask(updatedTask.id);
+ renderTaskList();
} catch (error) {
console.error('Error creating task:', error);
alert('Failed to execute task. Check console.');
@@ -144,6 +416,21 @@ newTaskForm.addEventListener('submit', async (e) => {
});
// Initial load
+// Initial fetch
fetchTasks();
-// Poll for updates every 5 seconds (simplistic for now)
-setInterval(fetchTasks, 5000);
+
+// Auto-refresh every 3 seconds
+let isPolling = false;
+async function startAutoRefresh() {
+ setInterval(async () => {
+ if (isPolling) return;
+ isPolling = true;
+ try {
+ await fetchTasks();
+ } finally {
+ isPolling = false;
+ }
+ }, 3000);
+}
+
+startAutoRefresh();
diff --git a/frontend/src/style.css b/frontend/src/style.css
index a5c41cd..d6738f5 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -54,6 +54,80 @@ body {
border-bottom: 1px solid var(--glass-border);
}
+#logo-link {
+ text-decoration: none;
+ color: inherit;
+ transition: var(--transition);
+}
+
+#logo-link:hover {
+ opacity: 0.8;
+}
+
+/* Dashboard View */
+.dashboard-view {
+ padding: 48px;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.dashboard-header {
+ margin-bottom: 32px;
+}
+
+.dashboard-header h2 {
+ font-size: 32px;
+ margin-bottom: 8px;
+}
+
+.dashboard-header p {
+ color: var(--text-dim);
+}
+
+.dashboard-content {
+ flex: 1;
+ overflow: hidden;
+ border-radius: 12px;
+ display: flex;
+ flex-direction: column;
+}
+
+.activity-table {
+ width: 100%;
+ border-collapse: collapse;
+ text-align: left;
+}
+
+.activity-table th {
+ padding: 16px 24px;
+ background: rgba(255, 255, 255, 0.02);
+ border-bottom: 1px solid var(--glass-border);
+ font-size: 13px;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ color: var(--text-dim);
+}
+
+.activity-table td {
+ padding: 16px 24px;
+ border-bottom: 1px solid var(--glass-border);
+ font-size: 14px;
+}
+
+.activity-table tr {
+ cursor: pointer;
+ transition: var(--transition);
+}
+
+.activity-table tr:hover {
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.activity-table .status-badge {
+ display: inline-block;
+}
+
.logo {
display: flex;
align-items: center;
@@ -157,6 +231,9 @@ body {
.view-header {
margin-bottom: 32px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
}
.header-main {
@@ -165,6 +242,75 @@ body {
gap: 16px;
}
+.task-content {
+ display: flex;
+ gap: 24px;
+ flex: 1;
+ overflow: hidden;
+}
+
+.run-history {
+ width: 240px;
+ border-radius: 12px;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.runs-header {
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--glass-border);
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.runs-header h3 {
+ font-size: 13px;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ color: var(--text-dim);
+}
+
+#run-list {
+ list-style: none;
+ overflow-y: auto;
+ padding: 8px;
+}
+
+.run-item {
+ padding: 12px 16px;
+ border-radius: 8px;
+ cursor: pointer;
+ margin-bottom: 4px;
+ font-size: 13px;
+ transition: var(--transition);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.run-item:hover {
+ background: rgba(255, 255, 255, 0.05);
+}
+
+.run-item.active {
+ background: rgba(93, 93, 255, 0.1);
+ color: var(--primary);
+ font-weight: 500;
+}
+
+.run-item-date {
+ font-size: 11px;
+ color: var(--text-dim);
+}
+
+.run-details {
+ flex: 1;
+ display: flex;
+ flex-direction: row;
+ overflow: hidden;
+ gap: 24px;
+}
+
.status-badge {
padding: 4px 12px;
border-radius: 100px;
@@ -186,13 +332,28 @@ body {
border: 1px solid rgba(81, 207, 102, 0.2);
}
+.schedule-badge {
+ font-size: 11px;
+ background: rgba(255, 255, 255, 0.05);
+ padding: 4px 10px;
+ border-radius: 100px;
+ color: var(--text-dim);
+ border: 1px solid var(--glass-border);
+ font-weight: 500;
+}
+
.logs-container {
- flex: 1;
- min-height: 200px;
+ width: 450px;
border-radius: 12px;
display: flex;
flex-direction: column;
overflow: hidden;
+ transition: transform 0.3s ease, width 0.3s ease, opacity 0.3s ease;
+ flex-shrink: 0;
+}
+
+.logs-container.hidden {
+ display: none !important;
}
.logs-header {
@@ -226,11 +387,10 @@ body {
/* Answer Section */
.answer-container {
- margin-bottom: 32px;
+ flex: 1;
border-radius: 12px;
border-left: 4px solid var(--primary);
background: linear-gradient(to right, rgba(93, 93, 255, 0.05), transparent);
- max-height: 40vh;
overflow-y: auto;
display: flex;
flex-direction: column;
@@ -255,10 +415,93 @@ body {
.answer-output {
padding: 24px;
- font-size: 16px;
- line-height: 1.7;
- color: var(--text-main);
- white-space: pre-wrap;
+ line-height: 1.6;
+ color: var(--text);
+ font-size: 15px;
+}
+
+.answer-output h1,
+.answer-output h2,
+.answer-output h3 {
+ margin-top: 24px;
+ margin-bottom: 12px;
+ color: #fff;
+}
+
+.answer-output h1 {
+ font-size: 24px;
+ border-bottom: 1px solid var(--glass-border);
+ padding-bottom: 8px;
+}
+
+.answer-output h2 {
+ font-size: 20px;
+}
+
+.answer-output h3 {
+ font-size: 18px;
+}
+
+.answer-output p {
+ margin-bottom: 16px;
+}
+
+.answer-output ul,
+.answer-output ol {
+ margin-bottom: 16px;
+ padding-left: 20px;
+}
+
+.answer-output li {
+ margin-bottom: 6px;
+}
+
+.answer-output code {
+ font-family: var(--font-mono);
+ background: rgba(255, 255, 255, 0.1);
+ padding: 2px 4px;
+ border-radius: 4px;
+ font-size: 13px;
+}
+
+.answer-output pre {
+ background: rgba(0, 0, 0, 0.3);
+ padding: 16px;
+ border-radius: 8px;
+ overflow-x: auto;
+ margin-bottom: 16px;
+ border: 1px solid var(--glass-border);
+}
+
+.answer-output pre code {
+ background: none;
+ padding: 0;
+ display: block;
+}
+
+.answer-output hr {
+ border: none;
+ border-top: 1px solid var(--glass-border);
+ margin: 24px 0;
+}
+
+.answer-output a {
+ color: var(--primary);
+ text-decoration: none;
+}
+
+.answer-output a:hover {
+ text-decoration: underline;
+}
+
+.answer-output blockquote {
+ border-left: 4px solid var(--primary);
+ background: rgba(93, 93, 255, 0.05);
+ padding: 12px 20px;
+ margin: 0 0 16px 0;
+ color: var(--text-dim);
+ font-style: italic;
+
}
/* Buttons */
@@ -344,6 +587,104 @@ textarea:focus {
border-color: var(--primary);
}
+.form-group {
+ margin-bottom: 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.form-group label {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text-dim);
+}
+
+.form-group input[type="text"] {
+ background: rgba(0, 0, 0, 0.2);
+ border: 1px solid var(--glass-border);
+ border-radius: 8px;
+ padding: 12px 16px;
+ color: var(--text-main);
+ font-family: inherit;
+}
+
+.form-group input[type="text"]:focus {
+ outline: none;
+ border-color: var(--primary);
+}
+
+.form-help {
+ font-size: 11px;
+ color: var(--text-dim);
+ font-style: italic;
+}
+
+.preset-group {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 10px;
+ margin-bottom: 12px;
+}
+
+.btn-preset {
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid var(--glass-border);
+ color: var(--text-dim);
+ padding: 10px;
+ font-size: 13px;
+ border-radius: 8px;
+ transition: all 0.2s;
+}
+
+.btn-preset:hover {
+ background: rgba(255, 255, 255, 0.1);
+ color: var(--text-main);
+}
+
+.btn-preset.active {
+ background: rgba(0, 150, 255, 0.1);
+ border-color: var(--primary);
+ color: var(--primary);
+}
+
+.custom-cron-toggle {
+ display: flex;
+ justify-content: flex-end;
+ margin-bottom: 8px;
+}
+
+.btn-link {
+ background: none;
+ border: none;
+ color: var(--text-dim);
+ font-size: 11px;
+ cursor: pointer;
+ text-decoration: underline;
+ padding: 0;
+}
+
+.btn-link:hover {
+ color: var(--primary);
+}
+
+#custom-cron-container {
+ margin-top: 8px;
+ animation: fadeIn 0.3s ease;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(-5px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
.modal-actions {
display: flex;
justify-content: flex-end;
diff --git a/migration/src/lib.rs b/migration/src/lib.rs
index 7a26c3d..e206140 100644
--- a/migration/src/lib.rs
+++ b/migration/src/lib.rs
@@ -2,6 +2,8 @@ pub use sea_orm_migration::prelude::*;
mod m20220101_000001_create_table;
mod m20260210_000002_add_answer_column;
+mod m20260210_000003_separate_runs;
+mod m20260210_000004_add_cron_column;
pub struct Migrator;
@@ -11,6 +13,8 @@ impl MigratorTrait for Migrator {
vec![
Box::new(m20220101_000001_create_table::Migration),
Box::new(m20260210_000002_add_answer_column::Migration),
+ Box::new(m20260210_000003_separate_runs::Migration),
+ Box::new(m20260210_000004_add_cron_column::Migration),
]
}
}
diff --git a/migration/src/m20260210_000003_separate_runs.rs b/migration/src/m20260210_000003_separate_runs.rs
new file mode 100644
index 0000000..61a485e
--- /dev/null
+++ b/migration/src/m20260210_000003_separate_runs.rs
@@ -0,0 +1,107 @@
+use sea_orm_migration::prelude::*;
+
+#[derive(DeriveMigrationName)]
+pub struct Migration;
+
+#[async_trait::async_trait]
+impl MigrationTrait for Migration {
+ async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ // 1. Create task_runs table
+ manager
+ .create_table(
+ Table::create()
+ .table(TaskRuns::Table)
+ .if_not_exists()
+ .col(ColumnDef::new(TaskRuns::Id).uuid().not_null().primary_key())
+ .col(ColumnDef::new(TaskRuns::TaskId).uuid().not_null())
+ .col(ColumnDef::new(TaskRuns::Status).string().not_null())
+ .col(ColumnDef::new(TaskRuns::Logs).text().not_null())
+ .col(ColumnDef::new(TaskRuns::Answer).text().null())
+ .col(
+ ColumnDef::new(TaskRuns::CreatedAt)
+ .timestamp_with_time_zone()
+ .not_null(),
+ )
+ .foreign_key(
+ ForeignKey::create()
+ .name("fk-task_runs-task_id")
+ .from(TaskRuns::Table, TaskRuns::TaskId)
+ .to(Tasks::Table, Tasks::Id)
+ .on_delete(ForeignKeyAction::Cascade),
+ )
+ .to_owned(),
+ )
+ .await?;
+
+ // 2. Migrate existing data from tasks to task_runs
+ // We use raw SQL for simplicity in migration
+ let db = manager.get_connection();
+ db.execute_unprepared(
+ "INSERT INTO task_runs (id, task_id, status, logs, answer, created_at)
+ SELECT id, id, status, logs, answer, created_at FROM tasks",
+ )
+ .await?;
+
+ // 3. Remove columns from tasks table (using a temp table for wider SQLite compatibility if needed,
+ // but let's try alter table first as it's cleaner if supported)
+ manager
+ .alter_table(
+ Table::alter()
+ .table(Tasks::Table)
+ .drop_column(Tasks::Status)
+ .drop_column(Tasks::Logs)
+ .drop_column(Tasks::Answer)
+ .to_owned(),
+ )
+ .await?;
+
+ Ok(())
+ }
+
+ async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ // Re-add columns to tasks
+ manager
+ .alter_table(
+ Table::alter()
+ .table(Tasks::Table)
+ .add_column(ColumnDef::new(Tasks::Status).string().null())
+ .add_column(ColumnDef::new(Tasks::Logs).text().null())
+ .add_column(ColumnDef::new(Tasks::Answer).text().null())
+ .to_owned(),
+ )
+ .await?;
+
+ // Restore data from latest run if possible (best effort)
+ let db = manager.get_connection();
+ db.execute_unprepared(
+ "UPDATE tasks SET
+ status = (SELECT status FROM task_runs WHERE task_id = tasks.id ORDER BY created_at DESC LIMIT 1),
+ logs = (SELECT logs FROM task_runs WHERE task_id = tasks.id ORDER BY created_at DESC LIMIT 1),
+ answer = (SELECT answer FROM task_runs WHERE task_id = tasks.id ORDER BY created_at DESC LIMIT 1)"
+ ).await?;
+
+ manager
+ .drop_table(Table::drop().table(TaskRuns::Table).to_owned())
+ .await
+ }
+}
+
+#[derive(DeriveIden)]
+enum TaskRuns {
+ Table,
+ Id,
+ TaskId,
+ Status,
+ Logs,
+ Answer,
+ CreatedAt,
+}
+
+#[derive(DeriveIden)]
+enum Tasks {
+ Table,
+ Id,
+ Status,
+ Logs,
+ Answer,
+}
diff --git a/migration/src/m20260210_000004_add_cron_column.rs b/migration/src/m20260210_000004_add_cron_column.rs
new file mode 100644
index 0000000..4a91e04
--- /dev/null
+++ b/migration/src/m20260210_000004_add_cron_column.rs
@@ -0,0 +1,35 @@
+use sea_orm_migration::prelude::*;
+
+#[derive(DeriveMigrationName)]
+pub struct Migration;
+
+#[async_trait::async_trait]
+impl MigrationTrait for Migration {
+ async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ manager
+ .alter_table(
+ Table::alter()
+ .table(Tasks::Table)
+ .add_column(ColumnDef::new(Tasks::Cron).string().null())
+ .to_owned(),
+ )
+ .await
+ }
+
+ async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ manager
+ .alter_table(
+ Table::alter()
+ .table(Tasks::Table)
+ .drop_column(Tasks::Cron)
+ .to_owned(),
+ )
+ .await
+ }
+}
+
+#[derive(DeriveIden)]
+enum Tasks {
+ Table,
+ Cron,
+}
diff --git a/src/entities/mod.rs b/src/entities/mod.rs
index cdafe4a..7ff42bf 100644
--- a/src/entities/mod.rs
+++ b/src/entities/mod.rs
@@ -1 +1,2 @@
pub mod task;
+pub mod task_run;
diff --git a/src/entities/task.rs b/src/entities/task.rs
index 69151a9..d300805 100644
--- a/src/entities/task.rs
+++ b/src/entities/task.rs
@@ -7,15 +7,20 @@ pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub goal: String,
- pub status: String,
- #[sea_orm(column_type = "Text")]
- pub logs: String,
- #[sea_orm(column_type = "Text", nullable)]
- pub answer: Option